diff --git a/.changeset/create-remix-overwrite.md b/.changeset/create-remix-overwrite.md new file mode 100644 index 00000000000..1a27563924e --- /dev/null +++ b/.changeset/create-remix-overwrite.md @@ -0,0 +1,7 @@ +--- +"create-remix": minor +--- + +Remove empty directory checking in favor of `overwrite` prompt/flag. + +`create-remix` now allows you to write into an existing non-empty directory. It will perform a file-level comparison and if the template will overwrite any existing files in the destination directory, it will prompt you if it's OK to overwrite those files. If you answer no (the default) then it will exit without copying any files. You may skip this prompt with the `--overwrite` CLI flag. diff --git a/docs/other-api/create-remix.md b/docs/other-api/create-remix.md index cd47ee3afbf..50f8a0d50f2 100644 --- a/docs/other-api/create-remix.md +++ b/docs/other-api/create-remix.md @@ -72,4 +72,8 @@ To create a new project from a template in a private GitHub repo, pass the `--to +### `create-remix --overwrite` + +If `create-remix` detects any file collisions between the template and the directory you are creating your app in, it will prompt you for confirmation that it's OK to overwrite those files with the template versions. You may skip this prompt with the `--overwrite` CLI flag. + [templates]: ../pages/templates diff --git a/packages/create-remix/__tests__/create-remix-test.ts b/packages/create-remix/__tests__/create-remix-test.ts index 9a4f8e81abb..bc15019c927 100644 --- a/packages/create-remix/__tests__/create-remix-test.ts +++ b/packages/create-remix/__tests__/create-remix-test.ts @@ -190,26 +190,6 @@ describe("create-remix CLI", () => { expect(fse.existsSync(path.join(projectDir, "app/root.tsx"))).toBeFalsy(); }); - it("errors when project directory isn't empty when shell isn't interactive", async () => { - let notEmptyDir = getProjectDir("non-interactive-not-empty-dir"); - fse.mkdirSync(notEmptyDir); - fse.createFileSync(path.join(notEmptyDir, "some-file.txt")); - - let { status, stderr } = await execCreateRemix({ - args: [notEmptyDir, "--no-install"], - interactive: false, - }); - - expect( - stderr.trim().replace("\\", "/") // Normalize Windows path - ).toMatchInlineSnapshot( - `"▲ Oh no! Project directory \\"/non-interactive-not-empty-dir\\" is not empty"` - ); - expect(status).toBe(1); - expect(fse.existsSync(path.join(notEmptyDir, "package.json"))).toBeFalsy(); - expect(fse.existsSync(path.join(notEmptyDir, "app/root.tsx"))).toBeFalsy(); - }); - it("works for GitHub username/repo combo", async () => { let projectDir = getProjectDir("github-username-repo"); @@ -882,6 +862,282 @@ describe("create-remix CLI", () => { process.env.npm_config_user_agent = originalUserAgent; }); + it("works when creating an app in the current dir", async () => { + let emptyDir = getProjectDir("current-dir-if-empty"); + fse.mkdirSync(emptyDir); + + let { status, stderr } = await execCreateRemix({ + cwd: emptyDir, + args: [ + ".", + "--template", + path.join(__dirname, "fixtures", "stack"), + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(status).toBe(0); + expect(fse.existsSync(path.join(emptyDir, "package.json"))).toBeTruthy(); + expect(fse.existsSync(path.join(emptyDir, "app/root.tsx"))).toBeTruthy(); + }); + + it("does not copy .git nor node_modules directories if they exist in the template", async () => { + // Can't really commit this file into a git repo, so just create it as + // part of the test and then remove it when we're done + let templateWithIgnoredDirs = path.join( + __dirname, + "fixtures", + "with-ignored-dir" + ); + fse.mkdirSync(path.join(templateWithIgnoredDirs, ".git")); + fse.createFileSync( + path.join(templateWithIgnoredDirs, ".git", "some-git-file.txt") + ); + fse.mkdirSync(path.join(templateWithIgnoredDirs, "node_modules")); + fse.createFileSync( + path.join( + templateWithIgnoredDirs, + "node_modules", + "some-node-module-file.txt" + ) + ); + + let projectDir = getProjectDir("with-git-dir"); + + try { + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + templateWithIgnoredDirs, + "--no-git-init", + "--no-install", + ], + }); + + expect(stderr.trim()).toBeFalsy(); + expect(status).toBe(0); + expect(fse.existsSync(path.join(projectDir, ".git"))).toBeFalsy(); + expect(fse.existsSync(path.join(projectDir, "node_modules"))).toBeFalsy(); + expect( + fse.existsSync(path.join(projectDir, "package.json")) + ).toBeTruthy(); + } finally { + fse.removeSync(path.join(templateWithIgnoredDirs, ".git")); + fse.removeSync(path.join(templateWithIgnoredDirs, "node_modules")); + } + }); + + describe("when project directory contains files", () => { + describe("interactive shell", () => { + let interactive = true; + + it("works without prompt when there are no collisions", async () => { + let projectDir = getProjectDir("not-empty-dir-interactive"); + fse.mkdirSync(projectDir); + fse.createFileSync(path.join(projectDir, "some-file.txt")); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + path.join(__dirname, "fixtures", "stack"), + "--no-git-init", + "--no-install", + ], + interactive, + }); + + expect(stderr.trim()).toBeFalsy(); + expect(status).toBe(0); + expect( + fse.existsSync(path.join(projectDir, "package.json")) + ).toBeTruthy(); + expect( + fse.existsSync(path.join(projectDir, "app/root.tsx")) + ).toBeTruthy(); + }); + + it("prompts for overwrite when there are collisions", async () => { + let notEmptyDir = getProjectDir("not-empty-dir-interactive-collisions"); + fse.mkdirSync(notEmptyDir); + fse.createFileSync(path.join(notEmptyDir, "package.json")); + fse.createFileSync(path.join(notEmptyDir, "tsconfig.json")); + + let { status, stdout, stderr } = await execCreateRemix({ + args: [ + notEmptyDir, + "--template", + path.join(__dirname, "fixtures", "stack"), + "--no-git-init", + "--no-install", + ], + interactive, + interactions: [ + { + question: /contains files that will be overwritten/i, + type: ["y"], + }, + ], + }); + + expect(stdout).toContain("Files that would be overwritten:"); + expect(stdout).toContain("package.json"); + expect(stdout).toContain("tsconfig.json"); + expect(status).toBe(0); + expect(stderr.trim()).toBeFalsy(); + expect( + fse.existsSync(path.join(notEmptyDir, "package.json")) + ).toBeTruthy(); + expect( + fse.existsSync(path.join(notEmptyDir, "tsconfig.json")) + ).toBeTruthy(); + expect( + fse.existsSync(path.join(notEmptyDir, "app/root.tsx")) + ).toBeTruthy(); + }); + + it("works without prompt when --overwrite is specified", async () => { + let projectDir = getProjectDir( + "not-empty-dir-interactive-collisions-overwrite" + ); + fse.mkdirSync(projectDir); + fse.createFileSync(path.join(projectDir, "package.json")); + fse.createFileSync(path.join(projectDir, "tsconfig.json")); + + let { status, stdout, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + path.join(__dirname, "fixtures", "stack"), + "--overwrite", + "--no-git-init", + "--no-install", + ], + }); + + expect(stdout).toContain( + "Overwrite: overwriting files due to `--overwrite`" + ); + expect(stdout).toContain("package.json"); + expect(stdout).toContain("tsconfig.json"); + expect(status).toBe(0); + expect(stderr.trim()).toBeFalsy(); + expect( + fse.existsSync(path.join(projectDir, "package.json")) + ).toBeTruthy(); + expect( + fse.existsSync(path.join(projectDir, "tsconfig.json")) + ).toBeTruthy(); + expect( + fse.existsSync(path.join(projectDir, "app/root.tsx")) + ).toBeTruthy(); + }); + }); + + describe("non-interactive shell", () => { + let interactive = false; + + it("works when there are no collisions", async () => { + let projectDir = getProjectDir("not-empty-dir-non-interactive"); + fse.mkdirSync(projectDir); + fse.createFileSync(path.join(projectDir, "some-file.txt")); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + path.join(__dirname, "fixtures", "stack"), + "--no-git-init", + "--no-install", + ], + interactive, + }); + + expect(stderr.trim()).toBeFalsy(); + expect(status).toBe(0); + expect( + fse.existsSync(path.join(projectDir, "package.json")) + ).toBeTruthy(); + expect( + fse.existsSync(path.join(projectDir, "app/root.tsx")) + ).toBeTruthy(); + }); + + it("errors when there are collisions", async () => { + let projectDir = getProjectDir( + "not-empty-dir-non-interactive-collisions" + ); + fse.mkdirSync(projectDir); + fse.createFileSync(path.join(projectDir, "package.json")); + fse.createFileSync(path.join(projectDir, "tsconfig.json")); + + let { status, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + path.join(__dirname, "fixtures", "stack"), + "--no-git-init", + "--no-install", + ], + interactive, + }); + + expect(stderr.trim()).toMatchInlineSnapshot(` + "▲ Oh no! Destination directory contains files that would be overwritten + and no \`--overwrite\` flag was included in a non-interactive + environment. The following files would be overwritten: + package.json + tsconfig.json" + `); + expect(status).toBe(1); + expect( + fse.existsSync(path.join(projectDir, "app/root.tsx")) + ).toBeFalsy(); + }); + + it("works when there are collisions and --overwrite is specified", async () => { + let projectDir = getProjectDir( + "not-empty-dir-non-interactive-collisions-overwrite" + ); + fse.mkdirSync(projectDir); + fse.createFileSync(path.join(projectDir, "package.json")); + fse.createFileSync(path.join(projectDir, "tsconfig.json")); + + let { status, stdout, stderr } = await execCreateRemix({ + args: [ + projectDir, + "--template", + path.join(__dirname, "fixtures", "stack"), + "--no-git-init", + "--no-install", + "--overwrite", + ], + interactive, + }); + + expect(stdout).toContain( + "Overwrite: overwriting files due to `--overwrite`" + ); + expect(stdout).toContain("package.json"); + expect(stdout).toContain("tsconfig.json"); + expect(status).toBe(0); + expect(stderr.trim()).toBeFalsy(); + expect( + fse.existsSync(path.join(projectDir, "package.json")) + ).toBeTruthy(); + expect( + fse.existsSync(path.join(projectDir, "tsconfig.json")) + ).toBeTruthy(); + expect( + fse.existsSync(path.join(projectDir, "app/root.tsx")) + ).toBeTruthy(); + }); + }); + }); + describe("errors", () => { it("identifies when a github repo is not accessible (403)", async () => { let projectDir = getProjectDir("repo-403"); @@ -977,89 +1233,6 @@ describe("create-remix CLI", () => { ); expect(status).toBe(1); }); - - it("doesn't allow creating an app in a dir if it's not empty and then prompts for an empty dir", async () => { - let emptyDir = getProjectDir("prompt-for-dir-on-non-empty-dir"); - - let notEmptyDir = getProjectDir("not-empty-dir"); - fse.mkdirSync(notEmptyDir); - fse.createFileSync(path.join(notEmptyDir, "some-file.txt")); - - let { status, stdout, stderr } = await execCreateRemix({ - args: [ - notEmptyDir, - "--template", - path.join(__dirname, "fixtures", "stack"), - "--no-git-init", - "--no-install", - ], - interactions: [ - { - question: /where.*create.*project/i, - type: [emptyDir, ENTER], - }, - ], - }); - - expect(stderr.trim()).toBeFalsy(); - expect(stdout).toContain( - `Hmm... "${maskTempDir(notEmptyDir)}" is not empty!` - ); - expect(status).toBe(0); - expect(fse.existsSync(path.join(emptyDir, "package.json"))).toBeTruthy(); - expect(fse.existsSync(path.join(emptyDir, "app/root.tsx"))).toBeTruthy(); - }); - - it("allows creating an app in the current dir if it's empty", async () => { - let emptyDir = getProjectDir("current-dir-if-empty"); - fse.mkdirSync(emptyDir); - - let { status, stderr } = await execCreateRemix({ - cwd: emptyDir, - args: [ - ".", - "--template", - path.join(__dirname, "fixtures", "stack"), - "--no-git-init", - "--no-install", - ], - }); - - expect(stderr.trim()).toBeFalsy(); - expect(status).toBe(0); - expect(fse.existsSync(path.join(emptyDir, "package.json"))).toBeTruthy(); - expect(fse.existsSync(path.join(emptyDir, "app/root.tsx"))).toBeTruthy(); - }); - - it("doesn't allow creating an app in the current dir if it's not empty", async () => { - let emptyDir = getProjectDir("prompt-for-dir-if-current-dir-not-empty"); - let notEmptyDir = getProjectDir("not-empty-dir"); - fse.mkdirSync(notEmptyDir); - fse.createFileSync(path.join(notEmptyDir, "some-file.txt")); - - let { status, stdout, stderr } = await execCreateRemix({ - cwd: notEmptyDir, - args: [ - ".", - "--template", - path.join(__dirname, "fixtures", "stack"), - "--no-git-init", - "--no-install", - ], - interactions: [ - { - question: /where.*create.*project/i, - type: [emptyDir, ENTER], - }, - ], - }); - - expect(stderr.trim()).toBeFalsy(); - expect(stdout).toContain(`Hmm... "." is not empty!`); - expect(status).toBe(0); - expect(fse.existsSync(path.join(emptyDir, "package.json"))).toBeTruthy(); - expect(fse.existsSync(path.join(emptyDir, "app/root.tsx"))).toBeTruthy(); - }); }); describe("supports proxy usage", () => { diff --git a/packages/create-remix/__tests__/fixtures/with-ignored-dir/package.json b/packages/create-remix/__tests__/fixtures/with-ignored-dir/package.json new file mode 100644 index 00000000000..0967ef424bc --- /dev/null +++ b/packages/create-remix/__tests__/fixtures/with-ignored-dir/package.json @@ -0,0 +1 @@ +{} diff --git a/packages/create-remix/copy-template.ts b/packages/create-remix/copy-template.ts index e46abd320a0..4ba77c6c72b 100644 --- a/packages/create-remix/copy-template.ts +++ b/packages/create-remix/copy-template.ts @@ -1,7 +1,6 @@ import process from "node:process"; import url from "node:url"; import fs from "node:fs"; -import fse from "fs-extra"; import path from "node:path"; import stream from "node:stream"; import { promisify } from "node:util"; @@ -23,7 +22,7 @@ export async function copyTemplate( template: string, destPath: string, options: CopyTemplateOptions -) { +): Promise<{ localTemplateDirectory: string } | undefined> { let { log = () => {} } = options; /** @@ -41,8 +40,8 @@ export async function copyTemplate( let filepath = template.startsWith("file://") ? url.fileURLToPath(template) : template; - await copyTemplateFromLocalFilePath(filepath, destPath); - return; + let isLocalDir = await copyTemplateFromLocalFilePath(filepath, destPath); + return isLocalDir ? { localTemplateDirectory: filepath } : undefined; } if (isGithubRepoShorthand(template)) { @@ -135,14 +134,16 @@ async function copyTemplateFromGenericUrl( async function copyTemplateFromLocalFilePath( filePath: string, destPath: string -) { +): Promise { if (filePath.endsWith(".tar.gz")) { await extractLocalTarball(filePath, destPath); - return; + return false; } if (fs.statSync(filePath).isDirectory()) { - await fse.copy(filePath, destPath); - return; + // If our template is just a directory on disk, return true here and we'll + // just copy directly from there instead of "extracting" to a temp + // directory first + return true; } throw new CopyTemplateError( "The provided template is not a valid local directory or tarball." diff --git a/packages/create-remix/index.ts b/packages/create-remix/index.ts index bed4eb496b4..d58788499bf 100644 --- a/packages/create-remix/index.ts +++ b/packages/create-remix/index.ts @@ -1,6 +1,8 @@ import process from "node:process"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; +import fse from "fs-extra"; import stripAnsi from "strip-ansi"; import rm from "rimraf"; import execa from "execa"; @@ -11,17 +13,20 @@ import sortPackageJSON from "sort-package-json"; import { version as thisRemixVersion } from "./package.json"; import { prompt } from "./prompt"; import { + IGNORED_TEMPLATE_DIRECTORIES, color, + debug, ensureDirectory, error, fileExists, + getDirectoryFilesRecursive, info, isInteractive, isValidJsonObject, log, - pathContains, sleep, strip, + stripDirectoryFromPath, success, toValidProjectName, } from "./utils"; @@ -43,7 +48,8 @@ async function createRemix(argv: string[]) { let steps = [ introStep, projectNameStep, - templateStep, + copyTemplateToTempDirStep, + copyTempDirToAppDirStep, gitInitQuestionStep, installDependenciesQuestionStep, runInitScriptQuestionStep, @@ -89,6 +95,7 @@ async function getContext(argv: string[]): Promise { "--V": "--version", "--no-color": Boolean, "--no-motion": Boolean, + "--overwrite": Boolean, }, { argv, permissive: true } ); @@ -110,6 +117,7 @@ async function getContext(argv: string[]): Promise { "--no-motion": noMotion, "--yes": yes, "--version": versionRequested, + "--overwrite": overwrite, } = flags; let cwd = flags["_"][0] as string; @@ -137,7 +145,12 @@ async function getContext(argv: string[]): Promise { } let context: Context = { + tempDir: path.join( + await fs.promises.realpath(os.tmpdir()), + `create-remix--${Math.random().toString(36).substr(2, 8)}` + ), cwd, + overwrite, interactive, debug, git: git ?? (noGit ? false : yes), @@ -165,6 +178,7 @@ async function getContext(argv: string[]): Promise { } interface Context { + tempDir: string; cwd: string; interactive: boolean; debug: boolean; @@ -184,6 +198,7 @@ interface Context { template?: string; token?: string; versionRequested?: boolean; + overwrite?: boolean; } async function introStep(ctx: Context) { @@ -204,51 +219,28 @@ async function introStep(ctx: Context) { } async function projectNameStep(ctx: Context) { - let cwdIsEmpty = ctx.cwd && isEmpty(ctx.cwd); - // valid cwd is required if shell isn't interactive - if (!ctx.interactive) { - if (!ctx.cwd) { - error("Oh no!", "No project directory provided"); - throw new Error("No project directory provided"); - } - - if (!cwdIsEmpty) { - error( - "Oh no!", - `Project directory "${color.reset(ctx.cwd)}" is not empty` - ); - throw new Error("Project directory is not empty"); - } + if (!ctx.interactive && !ctx.cwd) { + error("Oh no!", "No project directory provided"); + throw new Error("No project directory provided"); } if (ctx.cwd) { await sleep(100); - - if (cwdIsEmpty) { - info("Directory:", [ - "Using ", - color.reset(ctx.cwd), - " as project directory", - ]); - } else { - info("Hmm...", [color.reset(`"${ctx.cwd}"`), " is not empty!"]); - } + info("Directory:", [ + "Using ", + color.reset(ctx.cwd), + " as project directory", + ]); } - if (!ctx.cwd || !cwdIsEmpty) { + if (!ctx.cwd) { let { name } = await ctx.prompt({ name: "name", type: "text", label: title("dir"), message: "Where should we create your new project?", initial: "./my-remix-app", - validate(value: string) { - if (!isEmpty(value)) { - return `Directory is not empty!`; - } - return true; - }, }); ctx.cwd = name!; ctx.projectName = toValidProjectName(name!); @@ -266,10 +258,10 @@ async function projectNameStep(ctx: Context) { ctx.projectName = toValidProjectName(name); } -async function templateStep(ctx: Context) { +async function copyTemplateToTempDirStep(ctx: Context) { if (ctx.template) { log(""); - info("Template", ["Using ", color.reset(ctx.template), "..."]); + info("Template:", ["Using ", color.reset(ctx.template), "..."]); } else { log(""); info("Using basic template", [ @@ -285,56 +277,117 @@ async function templateStep(ctx: Context) { start: "Template copying...", end: "Template copied", while: async () => { - let destPath = path.resolve(process.cwd(), ctx.cwd); - await ensureDirectory(destPath); - await copyTemplate(template, destPath, { + await ensureDirectory(ctx.tempDir); + if (ctx.debug) { + debug(`Extracting to: ${ctx.tempDir}`); + } + + let result = await copyTemplate(template, ctx.tempDir, { debug: ctx.debug, token: ctx.token, async onError(err) { - let cwd = process.cwd(); - let removing = (async () => { - if (cwd !== destPath && !pathContains(cwd, destPath)) { - try { - await rm(destPath); - } catch (_) { - error("Oh no!", ["Failed to remove ", destPath]); - } - } - })(); - if (ctx.debug) { - try { - await removing; - } catch (_) {} - throw err; - } - - await Promise.all([ - error( - "Oh no!", - err instanceof CopyTemplateError - ? err.message - : "Something went wrong. Run `create-remix --debug` to see more info.\n\n" + - "Open an issue to report the problem at " + - "https://github.com/remix-run/remix/issues/new" - ), - removing, - ]); - + error( + "Oh no!", + err instanceof CopyTemplateError + ? err.message + : "Something went wrong. Run `create-remix --debug` to see more info.\n\n" + + "Open an issue to report the problem at " + + "https://github.com/remix-run/remix/issues/new" + ); throw err; }, async log(message) { if (ctx.debug) { - info(message); + debug(message); await sleep(500); } }, }); - await updatePackageJSON(ctx); + if (result?.localTemplateDirectory) { + ctx.tempDir = path.resolve(result.localTemplateDirectory); + } }, ctx, }); +} + +async function copyTempDirToAppDirStep(ctx: Context) { + await ensureDirectory(ctx.cwd); + + let files1 = await getDirectoryFilesRecursive(ctx.tempDir); + let files2 = await getDirectoryFilesRecursive(ctx.cwd); + let collisions = files1.filter((f) => files2.includes(f)); + + if (collisions.length > 0) { + let getFileList = (prefix: string) => { + let moreFiles = collisions.length - 5; + let lines = ["", ...collisions.slice(0, 5)]; + if (moreFiles > 0) { + lines.push(`and ${moreFiles} more...`); + } + return lines.join(`\n${prefix}`); + }; + + if (ctx.overwrite) { + info( + "Overwrite:", + `overwriting files due to \`--overwrite\`:${getFileList(" ")}` + ); + } else if (!ctx.interactive) { + error( + "Oh no!", + `Destination directory contains files that would be overwritten\n` + + ` and no \`--overwrite\` flag was included in a non-interactive\n` + + ` environment. The following files would be overwritten:` + + getFileList(" ") + ); + throw new Error( + "File collisions detected in a non-interactive environment" + ); + } else { + if (ctx.debug) { + debug(`Colliding files:${getFileList(" ")}`); + } + + let { overwrite } = await ctx.prompt({ + name: "overwrite", + type: "confirm", + label: title("overwrite"), + message: + `Your project directory contains files that will be overwritten by\n` + + ` this template (you can force with \`--overwrite\`)\n\n` + + ` Files that would be overwritten:` + + `${getFileList(" ")}\n\n` + + ` Do you wish to continue?\n` + + ` `, + initial: false, + }); + if (!overwrite) { + throw new Error("Exiting to avoid overwriting files"); + } + } + } + + await fse.copy(ctx.tempDir, ctx.cwd, { + filter(src, dest) { + // We never copy .git/ or node_modules/ directories since it's highly + // unlikely we want them copied - and because templates are primarily + // being pulled from git tarballs which won't have .git/ and shouldn't + // have node_modules/ + let file = stripDirectoryFromPath(ctx.tempDir, src); + let isIgnored = IGNORED_TEMPLATE_DIRECTORIES.includes(file); + if (isIgnored) { + if (ctx.debug) { + debug(`Skipping copy of ${file} directory from template`); + } + return false; + } + return true; + }, + }); + await updatePackageJSON(ctx); ctx.initScriptPath = await getInitScriptPath(ctx.cwd); } @@ -572,7 +625,7 @@ async function doneStep(ctx: Context) { if (projectDir !== "") { let enter = [ `\n${prefix}Enter your project directory using`, - color.cyan(`cd ./${projectDir}`), + color.cyan(`cd .${path.sep}${projectDir}`), ]; let len = enter[0].length + stripAnsi(enter[1]).length; log(enter.join(len > max ? "\n" + prefix : " ")); @@ -589,23 +642,6 @@ async function doneStep(ctx: Context) { await sleep(200); } -function isEmpty(dirPath: string) { - if (!fs.existsSync(dirPath)) { - return true; - } - - // Some existing files can be safely ignored when checking if - // a directory is a valid project directory. - let VALID_PROJECT_DIRECTORY_SAFE_LIST = [".DS_Store", "Thumbs.db"]; - - let conflicts = fs.readdirSync(dirPath).filter((content) => { - return !VALID_PROJECT_DIRECTORY_SAFE_LIST.some((safeContent) => { - return content === safeContent; - }); - }); - return conflicts.length === 0; -} - type PackageManager = "npm" | "yarn" | "pnpm" | "bun"; const packageManagerExecScript: Record = { diff --git a/packages/create-remix/package.json b/packages/create-remix/package.json index ced6cebe95a..df6fa12ecdc 100644 --- a/packages/create-remix/package.json +++ b/packages/create-remix/package.json @@ -24,6 +24,7 @@ "log-update": "^5.0.1", "node-fetch": "^2.6.9", "proxy-agent": "^6.3.0", + "recursive-readdir": "^2.2.3", "rimraf": "^4.1.2", "semver": "^7.3.7", "sisteransi": "^1.0.5", @@ -34,6 +35,7 @@ "devDependencies": { "@types/gunzip-maybe": "^1.4.0", "@types/node-fetch": "^2.5.7", + "@types/recursive-readdir": "^2.2.1", "@types/tar-fs": "^2.0.1", "esbuild": "0.17.6", "esbuild-register": "^3.3.2", diff --git a/packages/create-remix/utils.ts b/packages/create-remix/utils.ts index 8e4c40594d2..f2b6c5e5c39 100644 --- a/packages/create-remix/utils.ts +++ b/packages/create-remix/utils.ts @@ -1,9 +1,11 @@ +import path from "node:path"; import process from "node:process"; import os from "node:os"; import fs from "node:fs"; import { type Key as ActionKey } from "node:readline"; import { erase, cursor } from "sisteransi"; import chalk from "chalk"; +import recursiveReaddir from "recursive-readdir"; // https://no-color.org/ const SUPPORTS_COLOR = chalk.supportsColor && !process.env.NO_COLOR; @@ -89,44 +91,48 @@ export function logError(message: string) { return stderr.write(message + "\n"); } -export function info(prefix: string, text?: string | string[]) { +function logBullet( + logger: typeof log | typeof logError, + colorizePrefix: (v: V) => V, + colorizeText: (v: V) => V, + symbol: string, + prefix: string, + text?: string | string[] +) { let textParts = Array.isArray(text) ? text : [text || ""].filter(Boolean); - let formattedText = textParts.map((textPart) => color.dim(textPart)).join(""); + let formattedText = textParts + .map((textPart) => colorizeText(textPart)) + .join(""); if (process.stdout.columns < 80) { - log(`${" ".repeat(5)} ${color.cyan("◼")} ${color.cyan(prefix)}`); - log(`${" ".repeat(9)}${formattedText}`); + logger( + `${" ".repeat(5)} ${colorizePrefix(symbol)} ${colorizePrefix(prefix)}` + ); + logger(`${" ".repeat(9)}${formattedText}`); } else { - log( - `${" ".repeat(5)} ${color.cyan("◼")} ${color.cyan( + logger( + `${" ".repeat(5)} ${colorizePrefix(symbol)} ${colorizePrefix( prefix )} ${formattedText}` ); } } +export function debug(prefix: string, text?: string | string[]) { + logBullet(log, color.yellow, color.dim, "●", prefix, text); +} + +export function info(prefix: string, text?: string | string[]) { + logBullet(log, color.cyan, color.dim, "◼", prefix, text); +} + export function success(text: string) { - log(`${" ".repeat(5)} ${color.green("✔")} ${color.green(text)}`); + logBullet(log, color.green, color.dim, "✔", text); } export function error(prefix: string, text?: string | string[]) { - let textParts = Array.isArray(text) ? text : [text || ""].filter(Boolean); - let formattedText = textParts - .map((textPart) => color.error(textPart)) - .join(""); - log(""); - - if (process.stdout.columns < 80) { - logError(`${" ".repeat(5)} ${color.red("▲")} ${color.red(prefix)}`); - logError(`${" ".repeat(9)}${formattedText}`); - } else { - logError( - `${" ".repeat(5)} ${color.red("▲")} ${color.red( - prefix - )} ${formattedText}` - ); - } + logBullet(logError, color.red, color.error, "▲", prefix, text); } export function sleep(ms: number) { @@ -266,3 +272,34 @@ export function action(key: ActionKey, isSelect: boolean) { return false; } + +export function stripDirectoryFromPath(dir: string, filePath: string) { + // Can't just do a regexp replace here since the windows paths mess it up :/ + let stripped = filePath; + if ( + (dir.endsWith(path.sep) && filePath.startsWith(dir)) || + (!dir.endsWith(path.sep) && filePath.startsWith(dir + path.sep)) + ) { + stripped = filePath.slice(dir.length); + if (stripped.startsWith(path.sep)) { + stripped = stripped.slice(1); + } + } + return stripped; +} + +// We do not copy these folders from templates so we can ignore them for comparisons +export const IGNORED_TEMPLATE_DIRECTORIES = [".git", "node_modules"]; + +export async function getDirectoryFilesRecursive(dir: string) { + let files = await recursiveReaddir(dir, [ + (file) => { + let strippedFile = stripDirectoryFromPath(dir, file); + let parts = strippedFile.split(path.sep); + return ( + parts.length > 1 && IGNORED_TEMPLATE_DIRECTORIES.includes(parts[0]) + ); + }, + ]); + return files.map((f) => stripDirectoryFromPath(dir, f)); +} diff --git a/yarn.lock b/yarn.lock index 12fcff44a04..8f619a3acd6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2743,6 +2743,13 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/recursive-readdir@^2.2.1": + version "2.2.1" + resolved "https://registry.npmjs.org/@types/recursive-readdir/-/recursive-readdir-2.2.1.tgz#330f5ec0b73e8aeaf267a6e056884e393f3543a3" + integrity sha512-Xd+Ptc4/F2ueInqy5yK2FI5FxtwwbX2+VZpcg+9oYsFJVen8qQKGapCr+Bi5wQtHU1cTXT8s+07lo/nKPgu8Gg== + dependencies: + "@types/node" "*" + "@types/resolve@1.17.1": version "1.17.1" resolved "https://registry.npmjs.org/@types/resolve/-/resolve-1.17.1.tgz" @@ -10242,6 +10249,13 @@ rechoir@^0.6.2: dependencies: resolve "^1.1.6" +recursive-readdir@^2.2.3: + version "2.2.3" + resolved "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz#e726f328c0d69153bcabd5c322d3195252379372" + integrity sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA== + dependencies: + minimatch "^3.0.5" + redent@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz"