diff --git a/packages/create-remix/__tests__/create-remix-test.ts b/packages/create-remix/__tests__/create-remix-test.ts index 68f28938357..5d21bb0b13a 100644 --- a/packages/create-remix/__tests__/create-remix-test.ts +++ b/packages/create-remix/__tests__/create-remix-test.ts @@ -1,1480 +1,363 @@ -import type { ChildProcessWithoutNullStreams } from "node:child_process"; -import { spawn } from "node:child_process"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; -import fse from "fs-extra"; -import semver from "semver"; -import stripAnsi from "strip-ansi"; - -import { jestTimeout } from "./setupAfterEnv"; -import { createRemix } from "../index"; -import { server } from "./msw"; - -beforeAll(() => server.listen({ onUnhandledRequest: "error" })); -afterAll(() => server.close()); - -// this is so we can mock "npm install" etc. in a cross-platform way -jest.mock("execa"); - -const DOWN = "\x1B\x5B\x42"; -const ENTER = "\x0D"; +import * as readline from "readline"; +import crossSpawn from "cross-spawn"; -const TEMP_DIR = path.join( - fse.realpathSync(tmpdir()), - `remix-tests-${Math.random().toString(32).slice(2)}` -); -function maskTempDir(string: string) { - return string.replace(TEMP_DIR, ""); -} - -jest.setTimeout(30_000); -beforeAll(async () => { - await fse.remove(TEMP_DIR); - await fse.ensureDir(TEMP_DIR); -}); +import { createRemix } from "../index"; -afterAll(async () => { - await fse.remove(TEMP_DIR); -}); +// Mock dependencies +jest.mock("readline"); +jest.mock("cross-spawn"); -describe("create-remix CLI", () => { - let tempDirs = new Set(); +describe("createRemix", () => { + let mockReadline: any; + let mockSpawn: any; + let mockChildProcess: any; + let consoleLogSpy: jest.SpyInstance; beforeEach(() => { + // Reset all mocks jest.clearAllMocks(); - }); - - afterEach(async () => { - for (let dir of tempDirs) { - await fse.remove(dir); - } - tempDirs = new Set(); - }); - - function getProjectDir(name: string) { - let tmpDir = path.join(TEMP_DIR, name); - tempDirs.add(tmpDir); - return tmpDir; - } - - it("supports the --help flag", async () => { - let { stdout } = await execCreateRemix({ - args: ["--help"], - }); - expect(stdout.trim()).toMatchInlineSnapshot(` - "create-remix - - Usage: - - $ create-remix <...options> - - Values: - - projectDir The Remix project directory - - Options: - - --help, -h Print this help message and exit - --version, -V Print the CLI version and exit - --no-color Disable ANSI colors in console output - --no-motion Disable animations in console output - - --template The project template to use - --[no-]install Whether or not to install dependencies after creation - --package-manager The package manager to use - --show-install-output Whether to show the output of the install process - --[no-]init-script Whether or not to run the template's remix.init script, if present - --[no-]git-init Whether or not to initialize a Git repository - --yes, -y Skip all option prompts and run setup - --remix-version, -v The version of Remix to use - - Creating a new project: - - Remix projects are created from templates. A template can be: - - - a GitHub repo shorthand, :username/:repo or :username/:repo/:directory - - the URL of a GitHub repo (or directory within it) - - the URL of a tarball - - a file path to a directory of files - - a file path to a tarball - - $ create-remix my-app --template remix-run/grunge-stack - $ create-remix my-app --template remix-run/remix/templates/remix - $ create-remix my-app --template remix-run/examples/basic - $ create-remix my-app --template :username/:repo - $ create-remix my-app --template :username/:repo/:directory - $ create-remix my-app --template https://github.com/:username/:repo - $ create-remix my-app --template https://github.com/:username/:repo/tree/:branch - $ create-remix my-app --template https://github.com/:username/:repo/tree/:branch/:directory - $ create-remix my-app --template https://github.com/:username/:repo/archive/refs/tags/:tag.tar.gz - $ create-remix my-app --template https://example.com/remix-template.tar.gz - $ create-remix my-app --template ./path/to/remix-template - $ create-remix my-app --template ./path/to/remix-template.tar.gz - - To create a new project from a template in a private GitHub repo, - pass the \`token\` flag with a personal access token with access - to that repo. - - Initialize a project: - - Remix project templates may contain a \`remix.init\` directory - with a script that initializes the project. This script automatically - runs during \`remix create\`, but if you ever need to run it manually - you can run: - - $ remix init" - `); - }); - - it("supports the --version flag", async () => { - let { stdout } = await execCreateRemix({ - args: ["--version"], - }); - expect(!!semver.valid(stdout.trim())).toBe(true); - }); - - it("allows you to go through the prompts", async () => { - let projectDir = getProjectDir("prompts"); - - let { status, stderr } = await execCreateRemix({ - args: [], - interactions: [ - { - question: /where.*create.*project/i, - type: [projectDir, ENTER], - }, - { - question: /init.*git/i, - type: ["n"], - }, - { - question: /install dependencies/i, - type: ["n"], - }, - ], - }); - - 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("supports the --yes flag", async () => { - let projectDir = getProjectDir("yes"); - - let { status, stderr } = await execCreateRemix({ - args: [projectDir, "--yes", "--no-git-init", "--no-install"], - }); - 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 project directory isn't provided when shell isn't interactive", async () => { - let projectDir = getProjectDir("non-interactive-no-project-dir"); - - let { status, stderr } = await execCreateRemix({ - args: ["--no-install"], - interactive: false, - }); - - expect(stderr.trim()).toMatchInlineSnapshot( - `"▲ Oh no! No project directory provided"` - ); - expect(status).toBe(1); - expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeFalsy(); - expect(fse.existsSync(path.join(projectDir, "app/root.tsx"))).toBeFalsy(); - }); - - it("works for GitHub username/repo combo", async () => { - let projectDir = getProjectDir("github-username-repo"); - - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - "remix-fake-tester-username/remix-fake-tester-repo", - "--no-git-init", - "--no-install", - ], - }); - - 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("works for GitHub username/repo/path combo", async () => { - let projectDir = getProjectDir("github-username-repo-path"); - - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - "fake-remix-tester/nested-dir/stack", - "--no-git-init", - "--no-install", - ], - }); - - 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(); - }); + // Mock console.log + consoleLogSpy = jest.spyOn(console, "log").mockImplementation(); - it("works for GitHub username/repo/path combo (when dots exist in folder)", async () => { - let projectDir = getProjectDir("github-username-repo-path-dots"); - - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - "fake-remix-tester/nested-dir/folder.with.dots", - "--no-git-init", - "--no-install", - ], - }); + // Setup readline mock + mockReadline = { + question: jest.fn(), + close: jest.fn(), + on: jest.fn(), + }; + (readline.createInterface as jest.Mock).mockReturnValue(mockReadline); - 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(); + // Setup child process mock + mockChildProcess = { + on: jest.fn(), + }; + mockSpawn = crossSpawn as jest.MockedFunction; + mockSpawn.mockReturnValue(mockChildProcess as any); }); - it("fails for GitHub username/repo/path combo when path doesn't exist", async () => { - let projectDir = getProjectDir("github-username-repo-path-missing"); - - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - "fake-remix-tester/nested-dir/this/path/does/not/exist", - "--no-git-init", - "--no-install", - ], - }); - - expect(stderr.trim()).toMatchInlineSnapshot( - `"▲ Oh no! The path "this/path/does/not/exist" was not found in this GitHub repo."` - ); - expect(status).toBe(1); - expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeFalsy(); - expect(fse.existsSync(path.join(projectDir, "app/root.tsx"))).toBeFalsy(); + afterEach(() => { + consoleLogSpy.mockRestore(); }); - it("fails for private GitHub username/repo combo without a token", async () => { - let projectDir = getProjectDir("private-repo-no-token"); - - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - "private-org/private-repo", - "--no-git-init", - "--no-install", - ], - }); + describe("when user confirms with 'y'", () => { + it("should spawn the command and resolve on successful exit", async () => { + // Arrange + let argv = ["--template", "my-template"]; + let questionCallback: Function; + let exitHandler: Function; - expect(stderr.trim()).toMatchInlineSnapshot( - `"▲ Oh no! There was a problem fetching the file from GitHub. The request responded with a 404 status. Please try again later."` - ); - expect(status).toBe(1); - }); + // Capture the question callback + mockReadline.question.mockImplementation( + (question: string, callback: Function) => { + questionCallback = callback; + } + ); - it("succeeds for private GitHub username/repo combo with a valid token", async () => { - let projectDir = getProjectDir("github-username-repo-with-token"); - - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - "private-org/private-repo", - "--no-git-init", - "--no-install", - "--token", - "valid-token", - ], - }); + // Capture the child process event handlers + mockChildProcess.on.mockImplementation( + (event: string, handler: Function) => { + if (event === "exit") { + exitHandler = handler; + } + return mockChildProcess; + } + ); - 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(); - }); + // Act + let promise = createRemix(argv); - it("works for remote tarballs", async () => { - let projectDir = getProjectDir("remote-tarball"); - - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - "https://example.com/remix-stack.tar.gz", - "--no-git-init", - "--no-install", - ], - }); + // Simulate user answering 'y' + questionCallback!("y"); - 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(); - }); + // Simulate successful exit + exitHandler!(0); - it("fails for private github release tarballs", async () => { - let projectDir = getProjectDir("private-release-tarball-no-token"); - - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - "https://github.com/private-org/private-repo/releases/download/v0.0.1/stack.tar.gz", - "--no-git-init", - "--no-install", - ], - }); + await promise; - expect(stderr.trim()).toMatchInlineSnapshot( - `"▲ Oh no! There was a problem fetching the file from GitHub. The request responded with a 404 status. Please try again later."` - ); - expect(status).toBe(1); - }); + // Assert + expect(readline.createInterface).toHaveBeenCalledWith({ + input: process.stdin, + output: process.stdout, + }); - it("succeeds for private github release tarballs when including token", async () => { - let projectDir = getProjectDir("private-release-tarball-with-token"); - - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - "https://github.com/private-org/private-repo/releases/download/v0.0.1/stack.tar.gz", - "--token", - "valid-token", - "--no-git-init", - "--no-install", - ], - }); + expect(consoleLogSpy).toHaveBeenCalledWith( + "\nDid you mean `npx create-react-router@latest`?\n" + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + "\nRunning: npx create-react-router@latest\n" + ); - 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(); - }); + expect(mockSpawn).toHaveBeenCalledWith( + "npx", + ["create-react-router@latest", "--template", "my-template"], + { + stdio: "inherit", + env: process.env, + } + ); - it("works for different branches and nested paths", async () => { - let projectDir = getProjectDir("diff-branch"); - - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - "https://github.com/fake-remix-tester/nested-dir/tree/dev/stack", - "--no-git-init", - "--no-install", - ], + expect(mockReadline.close).toHaveBeenCalled(); }); - 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("should reject when child process exits with non-zero code", async () => { + // Arrange + let questionCallback: Function; + let exitHandler: Function; - it("fails for different branches and nested paths when path doesn't exist", async () => { - let projectDir = getProjectDir("diff-branch-invalid-path"); - - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - "https://github.com/fake-remix-tester/nested-dir/tree/dev/this/path/does/not/exist", - "--no-git-init", - "--no-install", - ], - }); - - expect(stderr.trim()).toMatchInlineSnapshot( - `"▲ Oh no! The path "this/path/does/not/exist" was not found in this GitHub repo."` - ); - expect(status).toBe(1); - expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeFalsy(); - expect(fse.existsSync(path.join(projectDir, "app/root.tsx"))).toBeFalsy(); - }); - - it("works for a path to a tarball on disk", async () => { - let projectDir = getProjectDir("local-tarball"); - - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - path.join(__dirname, "fixtures", "arc.tar.gz"), - "--no-git-init", - "--no-install", - ], - }); - - 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(); - }); + mockReadline.question.mockImplementation( + (question: string, callback: Function) => { + questionCallback = callback; + } + ); - it("works for a path to a tgz tarball on disk", async () => { - let projectDir = getProjectDir("local-tarball"); - - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - path.join(__dirname, "fixtures", "arc.tgz"), - "--no-git-init", - "--no-install", - ], - }); + mockChildProcess.on.mockImplementation( + (event: string, handler: Function) => { + if (event === "exit") { + exitHandler = handler; + } + return mockChildProcess; + } + ); - 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(); - }); + // Act + let promise = createRemix([]); + questionCallback!("y"); + exitHandler!(1); - it("works for a file URL to a tarball on disk", async () => { - let projectDir = getProjectDir("file-url-tarball"); - - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - pathToFileURL( - path.join(__dirname, "fixtures", "arc.tar.gz") - ).toString(), - "--no-git-init", - "--no-install", - ], + // Assert + await expect(promise).rejects.toThrow("Command failed with exit code 1"); + expect(mockReadline.close).toHaveBeenCalled(); }); - 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("should reject when child process emits error", async () => { + // Arrange + let error = new Error("Spawn error"); + let questionCallback: Function; + let errorHandler: Function; - it("works for a file path to a directory on disk", async () => { - let projectDir = getProjectDir("local-directory"); - - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - path.join(__dirname, "fixtures", "stack"), - "--no-git-init", - "--no-install", - ], - }); - - 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(); - }); + mockReadline.question.mockImplementation( + (question: string, callback: Function) => { + questionCallback = callback; + } + ); - it("works for a file URL to a directory on disk", async () => { - let projectDir = getProjectDir("file-url-directory"); - - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - pathToFileURL(path.join(__dirname, "fixtures", "stack")).toString(), - "--no-git-init", - "--no-install", - ], - }); + mockChildProcess.on.mockImplementation( + (event: string, handler: Function) => { + if (event === "error") { + errorHandler = handler; + } + return mockChildProcess; + } + ); - 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(); - }); + // Act + let promise = createRemix([]); + questionCallback!("y"); + errorHandler!(error); - it("prompts to run remix.init script when installing dependencies", async () => { - let projectDir = getProjectDir("remix-init-prompt"); - - let { status, stdout, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - path.join(__dirname, "fixtures", "successful-remix-init"), - "--no-git-init", - "--debug", - ], - interactions: [ - { - question: /install dependencies/i, - type: ["y", ENTER], - }, - { - question: /init script/i, - type: ["y", ENTER], - }, - ], + // Assert + await expect(promise).rejects.toThrow("Spawn error"); + expect(mockReadline.close).toHaveBeenCalled(); }); - - expect(stderr.trim()).toBeFalsy(); - expect(stdout).toContain(`Template's remix.init script complete`); - expect(status).toBe(0); - expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); - expect(fse.existsSync(path.join(projectDir, "test.txt"))).toBeTruthy(); - expect(fse.existsSync(path.join(projectDir, "remix.init"))).toBeFalsy(); }); - it("doesn't prompt to run remix.init script when not installing dependencies", async () => { - let projectDir = getProjectDir("remix-init-skip-on-no-install"); - - let { status, stdout, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - path.join(__dirname, "fixtures", "successful-remix-init"), - "--no-git-init", - ], - interactions: [ - { - question: /install dependencies/i, - type: ["n", ENTER], - }, - ], - }); + describe("when user confirms with 'yes'", () => { + it("should spawn the command (case insensitive)", async () => { + // Arrange + let questionCallback: Function; + let exitHandler: Function; - expect(stderr.trim()).toBeFalsy(); - expect(stdout).toContain(`Skipping template's remix.init script.`); - expect(status).toBe(0); - expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); - - // Init script hasn't run so file exists - expect(fse.existsSync(path.join(projectDir, "test.txt"))).toBeFalsy(); - - // Init script hasn't run so remix.init directory still exists - expect(fse.existsSync(path.join(projectDir, "remix.init"))).toBeTruthy(); - }); + mockReadline.question.mockImplementation( + (question: string, callback: Function) => { + questionCallback = callback; + } + ); - it("runs remix.init script when --install and --init-script flags are passed", async () => { - let projectDir = getProjectDir("remix-init-prompt-with-flags"); - - let { status, stdout, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - path.join(__dirname, "fixtures", "successful-remix-init"), - "--no-git-init", - "--install", - "--init-script", - "--debug", - ], - }); + mockChildProcess.on.mockImplementation( + (event: string, handler: Function) => { + if (event === "exit") { + exitHandler = handler; + } + return mockChildProcess; + } + ); - expect(stderr.trim()).toBeFalsy(); - expect(status).toBe(0); + // Act + let promise = createRemix([]); + questionCallback!("YES"); + exitHandler!(0); - expect(stdout).toContain(`Template's remix.init script complete`); - expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); - expect(fse.existsSync(path.join(projectDir, "test.txt"))).toBeTruthy(); - expect(fse.existsSync(path.join(projectDir, "remix.init"))).toBeFalsy(); - }); + await promise; - it("doesn't run remix.init script when --no-install flag is passed, even when --init-script flag is passed", async () => { - let projectDir = getProjectDir( - "remix-init-skip-on-no-install-with-init-flag" - ); - - let { status, stdout, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - path.join(__dirname, "fixtures", "successful-remix-init"), - "--no-git-init", - "--no-install", - "--init-script", - ], + // Assert + expect(mockSpawn).toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith( + "\nRunning: npx create-react-router@latest\n" + ); }); - - expect(stderr.trim()).toBeFalsy(); - expect(stdout).toContain(`Skipping template's remix.init script.`); - expect(status).toBe(0); - expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); - - // Init script hasn't run so file exists - expect(fse.existsSync(path.join(projectDir, "test.txt"))).toBeFalsy(); - - // Init script hasn't run so remix.init directory still exists - expect(fse.existsSync(path.join(projectDir, "remix.init"))).toBeTruthy(); }); - it("doesn't run remix.init script when --no-init-script flag is passed", async () => { - let projectDir = getProjectDir("remix-init-skip-on-no-init-flag"); - - let { status, stdout, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - path.join(__dirname, "fixtures", "successful-remix-init"), - "--no-git-init", - "--install", - "--no-init-script", - ], - }); + describe("when user declines", () => { + it("should not spawn command when user answers 'n'", async () => { + // Arrange + let questionCallback: Function; - expect(stderr.trim()).toBeFalsy(); - expect(stdout).toContain(`Skipping template's remix.init script.`); - expect(status).toBe(0); - expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); + mockReadline.question.mockImplementation( + (question: string, callback: Function) => { + questionCallback = callback; + } + ); - // Init script hasn't run so file exists - expect(fse.existsSync(path.join(projectDir, "test.txt"))).toBeFalsy(); + // Act + let promise = createRemix([]); + questionCallback!("n"); - // Init script hasn't run so remix.init directory still exists - expect(fse.existsSync(path.join(projectDir, "remix.init"))).toBeTruthy(); - }); + await promise; - it("throws an error when invalid remix.init script when automatically ran", async () => { - let projectDir = getProjectDir("invalid-remix-init-auto"); - - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - path.join(__dirname, "fixtures", "failing-remix-init"), - "--no-git-init", - "--install", - "--init-script", - ], + // Assert + expect(mockSpawn).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith("\nCommand not executed."); + expect(mockReadline.close).toHaveBeenCalled(); }); - expect(stderr.trim()).toMatchInlineSnapshot( - `"▲ Oh no! Template's remix.init script failed"` - ); - expect(status).toBe(1); - expect(fse.existsSync(path.join(projectDir, "package.json"))).toBeTruthy(); - expect(fse.existsSync(path.join(projectDir, "test.txt"))).toBeFalsy(); - expect(fse.existsSync(path.join(projectDir, "remix.init"))).toBeTruthy(); - }); - - it("runs npm install by default", async () => { - let originalUserAgent = process.env.npm_config_user_agent; - process.env.npm_config_user_agent = undefined; - - let projectDir = getProjectDir("npm-install-default"); - - let execa = require("execa"); - execa.mockImplementation(async () => {}); - - // Suppress terminal output - let stdoutMock = jest - .spyOn(process.stdout, "write") - .mockImplementation(() => true); - - await createRemix([ - projectDir, - "--template", - path.join(__dirname, "fixtures", "blank"), - "--no-git-init", - "--yes", - ]); - - stdoutMock.mockReset(); - - expect(execa).toHaveBeenCalledWith( - "npm", - expect.arrayContaining(["install"]), - expect.anything() - ); - - process.env.npm_config_user_agent = originalUserAgent; - }); - - it("runs npm install if package manager in user agent string is unknown", async () => { - let originalUserAgent = process.env.npm_config_user_agent; - process.env.npm_config_user_agent = - "unknown_package_manager/1.0.0 npm/? node/v14.17.0 linux x64"; - - let projectDir = getProjectDir("npm-install-on-unknown-package-manager"); - - let execa = require("execa"); - execa.mockImplementation(async () => {}); - - // Suppress terminal output - let stdoutMock = jest - .spyOn(process.stdout, "write") - .mockImplementation(() => true); - - await createRemix([ - projectDir, - "--template", - path.join(__dirname, "fixtures", "blank"), - "--no-git-init", - "--yes", - ]); + it("should not spawn command when user answers anything else", async () => { + // Arrange + let questionCallback: Function; - stdoutMock.mockReset(); - - expect(execa).toHaveBeenCalledWith( - "npm", - expect.arrayContaining(["install"]), - expect.anything() - ); - - process.env.npm_config_user_agent = originalUserAgent; - }); - - it("recognizes when npm was used to run the command", async () => { - let originalUserAgent = process.env.npm_config_user_agent; - process.env.npm_config_user_agent = - "npm/8.19.4 npm/? node/v14.17.0 linux x64"; - - let projectDir = getProjectDir("npm-install-from-user-agent"); - - let execa = require("execa"); - execa.mockImplementation(async () => {}); - - // Suppress terminal output - let stdoutMock = jest - .spyOn(process.stdout, "write") - .mockImplementation(() => true); - - await createRemix([ - projectDir, - "--template", - path.join(__dirname, "fixtures", "blank"), - "--no-git-init", - "--yes", - ]); - - stdoutMock.mockReset(); - - expect(execa).toHaveBeenCalledWith( - "npm", - expect.arrayContaining(["install"]), - expect.anything() - ); - process.env.npm_config_user_agent = originalUserAgent; - }); - - it("recognizes when Yarn was used to run the command", async () => { - let originalUserAgent = process.env.npm_config_user_agent; - process.env.npm_config_user_agent = - "yarn/1.22.18 npm/? node/v14.17.0 linux x64"; - - let projectDir = getProjectDir("yarn-create-from-user-agent"); - - let execa = require("execa"); - execa.mockImplementation(async () => {}); - - // Suppress terminal output - let stdoutMock = jest - .spyOn(process.stdout, "write") - .mockImplementation(() => true); - - await createRemix([ - projectDir, - "--template", - path.join(__dirname, "fixtures", "blank"), - "--no-git-init", - "--yes", - ]); - - stdoutMock.mockReset(); - - expect(execa).toHaveBeenCalledWith( - "yarn", - expect.arrayContaining(["install"]), - expect.anything() - ); - process.env.npm_config_user_agent = originalUserAgent; - }); + mockReadline.question.mockImplementation( + (question: string, callback: Function) => { + questionCallback = callback; + } + ); - it("recognizes when pnpm was used to run the command", async () => { - let originalUserAgent = process.env.npm_config_user_agent; - process.env.npm_config_user_agent = - "pnpm/6.32.3 npm/? node/v14.17.0 linux x64"; - - let projectDir = getProjectDir("pnpm-create-from-user-agent"); - - let execa = require("execa"); - execa.mockImplementation(async () => {}); - - // Suppress terminal output - let stdoutMock = jest - .spyOn(process.stdout, "write") - .mockImplementation(() => true); - - await createRemix([ - projectDir, - "--template", - path.join(__dirname, "fixtures", "blank"), - "--no-git-init", - "--yes", - ]); - - stdoutMock.mockReset(); - - expect(execa).toHaveBeenCalledWith( - "pnpm", - expect.arrayContaining(["install"]), - expect.anything() - ); - process.env.npm_config_user_agent = originalUserAgent; - }); + // Act + let promise = createRemix([]); + questionCallback!("maybe"); - it("recognizes when Bun was used to run the command", async () => { - let originalUserAgent = process.env.npm_config_user_agent; - process.env.npm_config_user_agent = - "bun/0.7.0 npm/? node/v14.17.0 linux x64"; - - let projectDir = getProjectDir("bun-create-from-user-agent"); - - let execa = require("execa"); - execa.mockImplementation(async () => {}); - - // Suppress terminal output - let stdoutMock = jest - .spyOn(process.stdout, "write") - .mockImplementation(() => true); - - await createRemix([ - projectDir, - "--template", - path.join(__dirname, "fixtures", "blank"), - "--no-git-init", - "--yes", - ]); - - stdoutMock.mockReset(); - - expect(execa).toHaveBeenCalledWith( - "bun", - expect.arrayContaining(["install"]), - expect.anything() - ); - process.env.npm_config_user_agent = originalUserAgent; - }); + await promise; - it("supports specifying the package manager, regardless of user agent", async () => { - let originalUserAgent = process.env.npm_config_user_agent; - process.env.npm_config_user_agent = - "yarn/1.22.18 npm/? node/v14.17.0 linux x64"; - - let projectDir = getProjectDir("pnpm-create-override"); - - let execa = require("execa"); - execa.mockImplementation(async () => {}); - - // Suppress terminal output - let stdoutMock = jest - .spyOn(process.stdout, "write") - .mockImplementation(() => true); - - await createRemix([ - projectDir, - "--template", - path.join(__dirname, "fixtures", "blank"), - "--no-git-init", - "--yes", - "--package-manager", - "pnpm", - ]); - - stdoutMock.mockReset(); - - expect(execa).toHaveBeenCalledWith( - "pnpm", - expect.arrayContaining(["install"]), - expect.anything() - ); - 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", - ], + // Assert + expect(mockSpawn).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith("\nCommand not executed."); + expect(mockReadline.close).toHaveBeenCalled(); }); - - 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")); - } - }); - - it("changes star dependencies for only Remix packages", async () => { - let projectDir = getProjectDir("local-directory"); - - let { status } = await execCreateRemix({ - args: [ - projectDir, - "--template", - path.join(__dirname, "fixtures", "stack"), - "--no-git-init", - "--no-install", - ], - }); + describe("when readline is closed without answer", () => { + it("should reject with appropriate error", async () => { + // Arrange + let rlCloseHandler: Function; + let hasAnswered = false; - expect(status).toBe(0); - - let packageJsonPath = path.join(projectDir, "package.json"); - let packageJson = JSON.parse(String(fse.readFileSync(packageJsonPath))); - let dependencies = packageJson.dependencies; - - expect(dependencies).toMatchObject({ - "@remix-run/react": expect.any(String), - remix: expect.any(String), - "not-remix": "*", - }); - }); - - 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(); + mockReadline.on.mockImplementation((event: string, handler: Function) => { + if (event === "close") { + rlCloseHandler = handler; + } + return mockReadline; }); - 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(); - }); + mockReadline.question.mockImplementation( + (question: string, callback: Function) => { + // Don't call the callback - simulate closing without answering + } + ); - 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(); + mockReadline.close.mockImplementation(() => { + if (!hasAnswered && rlCloseHandler) { + rlCloseHandler(); + } }); - }); - 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(); - }); + // Act + let promise = createRemix([]); - 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(); - }); + // Simulate closing readline without answering + mockReadline.close(); - 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(); - }); + // Assert + await expect(promise).rejects.toThrow( + "User did not confirm command execution" + ); }); }); - describe("errors", () => { - it("identifies when a github repo is not accessible (403)", async () => { - let projectDir = getProjectDir("repo-403"); + describe("edge cases", () => { + it("should handle empty argv array", async () => { + // Arrange + let questionCallback: Function; + let exitHandler: Function; - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - "error-username/403", - "--no-git-init", - "--no-install", - ], - }); - - expect(stderr.trim()).toMatchInlineSnapshot( - `"▲ Oh no! There was a problem fetching the file from GitHub. The request responded with a 403 status. Please try again later."` + mockReadline.question.mockImplementation( + (question: string, callback: Function) => { + questionCallback = callback; + } ); - expect(status).toBe(1); - }); - - it("identifies when a github repo does not exist (404)", async () => { - let projectDir = getProjectDir("repo-404"); - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - "error-username/404", - "--no-git-init", - "--no-install", - ], - }); - - expect(stderr.trim()).toMatchInlineSnapshot( - `"▲ Oh no! There was a problem fetching the file from GitHub. The request responded with a 404 status. Please try again later."` + mockChildProcess.on.mockImplementation( + (event: string, handler: Function) => { + if (event === "exit") { + exitHandler = handler; + } + return mockChildProcess; + } ); - expect(status).toBe(1); - }); - it("identifies when something unknown goes wrong with the repo request (4xx)", async () => { - let projectDir = getProjectDir("repo-4xx"); + // Act + let promise = createRemix([]); + questionCallback!("y"); + exitHandler!(0); - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - "error-username/400", - "--no-git-init", - "--no-install", - ], - }); + await promise; - expect(stderr.trim()).toMatchInlineSnapshot( - `"▲ Oh no! There was a problem fetching the file from GitHub. The request responded with a 400 status. Please try again later."` + // Assert + expect(mockSpawn).toHaveBeenCalledWith( + "npx", + ["create-react-router@latest"], + expect.any(Object) ); - expect(status).toBe(1); }); - it("identifies when a remote tarball does not exist (404)", async () => { - let projectDir = getProjectDir("remote-tarball-404"); + it("should pass through multiple arguments", async () => { + // Arrange + let argv = ["--template", "remix", "--install", "--typescript"]; + let questionCallback: Function; + let exitHandler: Function; - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - "https://example.com/error/404/remix-stack.tar.gz", - "--no-git-init", - "--no-install", - ], - }); - - expect(stderr.trim()).toMatchInlineSnapshot( - `"▲ Oh no! There was a problem fetching the file. The request responded with a 404 status. Please try again later."` + mockReadline.question.mockImplementation( + (question: string, callback: Function) => { + questionCallback = callback; + } ); - expect(status).toBe(1); - }); - - it("identifies when a remote tarball does not exist (4xx)", async () => { - let projectDir = getProjectDir("remote-tarball-4xx"); - let { status, stderr } = await execCreateRemix({ - args: [ - projectDir, - "--template", - "https://example.com/error/400/remix-stack.tar.gz", - "--no-git-init", - "--no-install", - ], - }); - - expect(stderr.trim()).toMatchInlineSnapshot( - `"▲ Oh no! There was a problem fetching the file. The request responded with a 400 status. Please try again later."` + mockChildProcess.on.mockImplementation( + (event: string, handler: Function) => { + if (event === "exit") { + exitHandler = handler; + } + return mockChildProcess; + } ); - expect(status).toBe(1); - }); - }); - describe("supports proxy usage", () => { - beforeAll(() => { - server.close(); - }); - afterAll(() => { - server.listen({ onUnhandledRequest: "error" }); - }); - it("uses the proxy from env var", async () => { - let projectDir = await getProjectDir("template"); + // Act + let promise = createRemix(argv); + questionCallback!("y"); + exitHandler!(0); - let { stderr } = await execCreateRemix({ - args: [ - projectDir, + await promise; + + // Assert + expect(mockSpawn).toHaveBeenCalledWith( + "npx", + [ + "create-react-router@latest", "--template", - "remix-run/grunge-stack", - "--no-install", - "--no-git-init", - "--debug", + "remix", + "--install", + "--typescript", ], - mockNetwork: false, - env: { HTTPS_PROXY: "http://127.0.0.1:33128" }, - }); - - expect(stderr.trim()).toMatch("127.0.0.1:33"); + expect.any(Object) + ); }); }); }); - -async function execCreateRemix({ - args = [], - interactions = [], - interactive = true, - env = {}, - mockNetwork = true, - cwd, -}: { - args: string[]; - interactive?: boolean; - interactions?: ShellInteractions; - env?: Record; - mockNetwork?: boolean; - cwd?: string; -}) { - let proc = spawn( - "node", - [ - "--require", - require.resolve("esbuild-register"), - ...(mockNetwork - ? ["--require", path.join(__dirname, "./msw-register.ts")] - : []), - path.resolve(__dirname, "../cli.ts"), - ...args, - ], - { - cwd, - stdio: [null, null, null], - env: { - ...process.env, - ...env, - ...(interactive ? { CREATE_REMIX_FORCE_INTERACTIVE: "true" } : {}), - }, - } - ); - - return await interactWithShell(proc, interactions); -} - -interface ShellResult { - status: number | "timeout" | null; - stdout: string; - stderr: string; -} - -type ShellInteractions = Array< - | { question: RegExp; type: Array; answer?: never } - | { question: RegExp; answer: RegExp; type?: never } ->; - -async function interactWithShell( - proc: ChildProcessWithoutNullStreams, - interactions: ShellInteractions -): Promise { - proc.stdin.setDefaultEncoding("utf-8"); - - let deferred = defer(); - - let stepNumber = 0; - - let stdout = ""; - let stderr = ""; - proc.stdout.on("data", (chunk: unknown) => { - if (chunk instanceof Buffer) { - chunk = String(chunk); - } - if (typeof chunk !== "string") { - console.error({ stdoutChunk: chunk }); - throw new Error("stdout chunk is not a string"); - } - stdout += stripAnsi(maskTempDir(chunk)); - let step = interactions[stepNumber]; - if (!step) return; - let { question, answer, type } = step; - if (question.test(chunk)) { - if (answer) { - let currentSelection = chunk - .split("\n") - .slice(1) - .find( - (line) => - line.includes("❯") || line.includes(">") || line.includes("●") - ); - - if (currentSelection && answer.test(currentSelection)) { - proc.stdin.write(ENTER); - stepNumber += 1; - } else { - proc.stdin.write(DOWN); - } - } else if (type) { - for (let command of type) { - proc.stdin.write(command); - } - stepNumber += 1; - } - } - - if (stepNumber === interactions.length) { - proc.stdin.end(); - } - }); - - proc.stderr.on("data", (chunk: unknown) => { - if (chunk instanceof Buffer) { - chunk = String(chunk); - } - if (typeof chunk !== "string") { - console.error({ stderrChunk: chunk }); - throw new Error("stderr chunk is not a string"); - } - stderr += stripAnsi(maskTempDir(chunk)); - }); - - proc.on("close", (status) => { - deferred.resolve({ status, stdout, stderr }); - }); - - // this ensures that if we do timeout we at least get as much useful - // output as possible. - let timeout = setTimeout(() => { - if (deferred.state.current === "pending") { - proc.kill(); - deferred.resolve({ status: "timeout", stdout, stderr }); - } - }, jestTimeout); - - let result = await deferred.promise; - clearTimeout(timeout); - - return result; -} - -function defer() { - let resolve: (value: Value) => void, reject: (reason?: any) => void; - let state: { current: "pending" | "resolved" | "rejected" } = { - current: "pending", - }; - let promise = new Promise((res, rej) => { - resolve = (value: Value) => { - state.current = "resolved"; - return res(value); - }; - reject = (reason?: any) => { - state.current = "rejected"; - return rej(reason); - }; - }); - return { promise, resolve: resolve!, reject: reject!, state }; -} diff --git a/packages/create-remix/copy-template.ts b/packages/create-remix/copy-template.ts deleted file mode 100644 index aa97e30c7ff..00000000000 --- a/packages/create-remix/copy-template.ts +++ /dev/null @@ -1,563 +0,0 @@ -import process from "node:process"; -import url from "node:url"; -import fs from "node:fs"; -import path from "node:path"; -import stream from "node:stream"; -import { promisify } from "node:util"; -import { fetch } from "@remix-run/web-fetch"; -import gunzip from "gunzip-maybe"; -import tar from "tar-fs"; -import { ProxyAgent } from "proxy-agent"; - -import { color, isUrl } from "./utils"; - -const defaultAgent = new ProxyAgent(); -const httpsAgent = new ProxyAgent(); -httpsAgent.protocol = "https:"; -function agent(url: string) { - return new URL(url).protocol === "https:" ? httpsAgent : defaultAgent; -} - -export async function copyTemplate( - template: string, - destPath: string, - options: CopyTemplateOptions -): Promise<{ localTemplateDirectory: string } | undefined> { - let { log = () => {} } = options; - - /** - * Valid templates are: - * - local file or directory on disk - * - GitHub owner/repo shorthand - * - GitHub owner/repo/directory shorthand - * - full GitHub repo URL - * - any tarball URL - */ - - try { - if (isLocalFilePath(template)) { - log(`Using the template from local file at "${template}"`); - let filepath = template.startsWith("file://") - ? url.fileURLToPath(template) - : template; - let isLocalDir = await copyTemplateFromLocalFilePath(filepath, destPath); - return isLocalDir ? { localTemplateDirectory: filepath } : undefined; - } - - if (isGithubRepoShorthand(template)) { - log(`Using the template from the "${template}" repo`); - await copyTemplateFromGithubRepoShorthand(template, destPath, options); - return; - } - - if (isValidGithubRepoUrl(template)) { - log(`Using the template from "${template}"`); - await copyTemplateFromGithubRepoUrl(template, destPath, options); - return; - } - - if (isUrl(template)) { - log(`Using the template from "${template}"`); - await copyTemplateFromGenericUrl(template, destPath, options); - return; - } - - throw new CopyTemplateError( - `"${color.bold(template)}" is an invalid template. Run ${color.bold( - "create-remix --help" - )} to see supported template formats.` - ); - } catch (error) { - await options.onError(error); - } -} - -interface CopyTemplateOptions { - debug?: boolean; - token?: string; - onError(error: unknown): any; - log?(message: string): any; -} - -function isLocalFilePath(input: string): boolean { - try { - return ( - input.startsWith("file://") || - fs.existsSync( - path.isAbsolute(input) ? input : path.resolve(process.cwd(), input) - ) - ); - } catch (_) { - return false; - } -} - -async function copyTemplateFromRemoteTarball( - url: string, - destPath: string, - options: CopyTemplateOptions -) { - return await downloadAndExtractTarball(destPath, url, options); -} - -async function copyTemplateFromGithubRepoShorthand( - repoShorthand: string, - destPath: string, - options: CopyTemplateOptions -) { - let [owner, name, ...path] = repoShorthand.split("/"); - let filePath = path.length ? path.join("/") : null; - - await downloadAndExtractRepoTarball( - { owner, name, filePath }, - destPath, - options - ); -} - -async function copyTemplateFromGithubRepoUrl( - repoUrl: string, - destPath: string, - options: CopyTemplateOptions -) { - await downloadAndExtractRepoTarball(getRepoInfo(repoUrl), destPath, options); -} - -async function copyTemplateFromGenericUrl( - url: string, - destPath: string, - options: CopyTemplateOptions -) { - await copyTemplateFromRemoteTarball(url, destPath, options); -} - -async function copyTemplateFromLocalFilePath( - filePath: string, - destPath: string -): Promise { - if (filePath.endsWith(".tar.gz") || filePath.endsWith(".tgz")) { - await extractLocalTarball(filePath, destPath); - return false; - } - if (fs.statSync(filePath).isDirectory()) { - // 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." - ); -} - -const pipeline = promisify(stream.pipeline); - -async function extractLocalTarball( - tarballPath: string, - destPath: string -): Promise { - try { - await pipeline( - fs.createReadStream(tarballPath), - gunzip(), - tar.extract(destPath, { strip: 1 }) - ); - } catch (error: unknown) { - throw new CopyTemplateError( - "There was a problem extracting the file from the provided template." + - ` Template filepath: \`${tarballPath}\`` + - ` Destination directory: \`${destPath}\`` + - ` ${error}` - ); - } -} - -interface TarballDownloadOptions { - debug?: boolean; - filePath?: string | null; - token?: string; -} - -async function downloadAndExtractRepoTarball( - repo: RepoInfo, - destPath: string, - options: TarballDownloadOptions -) { - // If we have a direct file path we will also have the branch. We can skip the - // redirect and get the tarball URL directly. - if (repo.branch && repo.filePath) { - let tarballURL = `https://codeload.github.com/${repo.owner}/${repo.name}/tar.gz/${repo.branch}`; - return await downloadAndExtractTarball(destPath, tarballURL, { - ...options, - filePath: repo.filePath, - }); - } - - // If we don't know the branch, the GitHub API will figure out the default and - // redirect the request to the tarball. - // https://docs.github.com/en/rest/reference/repos#download-a-repository-archive-tar - let url = `https://api.github.com/repos/${repo.owner}/${repo.name}/tarball`; - if (repo.branch) { - url += `/${repo.branch}`; - } - - return await downloadAndExtractTarball(destPath, url, { - ...options, - filePath: repo.filePath ?? null, - }); -} - -interface DownloadAndExtractTarballOptions { - token?: string; - filePath?: string | null; -} - -async function downloadAndExtractTarball( - downloadPath: string, - tarballUrl: string, - { token, filePath }: DownloadAndExtractTarballOptions -): Promise { - let resourceUrl = tarballUrl; - let headers: HeadersInit = {}; - let isGithubUrl = new URL(tarballUrl).host.endsWith("github.com"); - if (token && isGithubUrl) { - headers.Authorization = `token ${token}`; - } - if (isGithubReleaseAssetUrl(tarballUrl)) { - // We can download the asset via the GitHub api, but first we need to look - // up the asset id - let info = getGithubReleaseAssetInfo(tarballUrl); - headers.Accept = "application/vnd.github.v3+json"; - - let releaseUrl = - info.tag === "latest" - ? `https://api.github.com/repos/${info.owner}/${info.name}/releases/latest` - : `https://api.github.com/repos/${info.owner}/${info.name}/releases/tags/${info.tag}`; - - let response = await fetch(releaseUrl, { - agent: agent("https://api.github.com"), - headers, - }); - - if (response.status !== 200) { - throw new CopyTemplateError( - "There was a problem fetching the file from GitHub. The request " + - `responded with a ${response.status} status. Please try again later.` - ); - } - - let body = (await response.json()) as { assets: GitHubApiReleaseAsset[] }; - if ( - !body || - typeof body !== "object" || - !body.assets || - !Array.isArray(body.assets) - ) { - throw new CopyTemplateError( - "There was a problem fetching the file from GitHub. No asset was " + - "found at that url. Please try again later." - ); - } - - let assetId = body.assets.find((asset) => { - // If the release is "latest", the url won't match the download url - return info.tag === "latest" - ? asset?.browser_download_url?.includes(info.asset) - : asset?.browser_download_url === tarballUrl; - })?.id; - if (assetId == null) { - throw new CopyTemplateError( - "There was a problem fetching the file from GitHub. No asset was " + - "found at that url. Please try again later." - ); - } - resourceUrl = `https://api.github.com/repos/${info.owner}/${info.name}/releases/assets/${assetId}`; - headers.Accept = "application/octet-stream"; - } - let response = await fetch(resourceUrl, { - agent: agent(resourceUrl), - headers, - }); - - if (!response.body || response.status !== 200) { - if (token) { - throw new CopyTemplateError( - `There was a problem fetching the file${ - isGithubUrl ? " from GitHub" : "" - }. The request ` + - `responded with a ${response.status} status. Perhaps your \`--token\`` + - "is expired or invalid." - ); - } - throw new CopyTemplateError( - `There was a problem fetching the file${ - isGithubUrl ? " from GitHub" : "" - }. The request ` + - `responded with a ${response.status} status. Please try again later.` - ); - } - - // file paths returned from GitHub are always unix style - if (filePath) { - filePath = filePath.split(path.sep).join(path.posix.sep); - } - - let filePathHasFiles = false; - - try { - let input = new stream.PassThrough(); - // Start reading stream into passthrough, don't await to avoid buffering - writeReadableStreamToWritable(response.body, input); - await pipeline( - input, - gunzip(), - tar.extract(downloadPath, { - map(header) { - let originalDirName = header.name.split("/")[0]; - header.name = header.name.replace(`${originalDirName}/`, ""); - - if (filePath) { - // Include trailing slash on startsWith when filePath doesn't include - // it so something like `templates/remix` doesn't inadvertently - // include `templates/remix-javascript/*` files - if ( - (filePath.endsWith(path.posix.sep) && - header.name.startsWith(filePath)) || - (!filePath.endsWith(path.posix.sep) && - header.name.startsWith(filePath + path.posix.sep)) - ) { - filePathHasFiles = true; - header.name = header.name.replace(filePath, ""); - } else { - header.name = "__IGNORE__"; - } - } - - return header; - }, - ignore(_filename, header) { - if (!header) { - throw Error("Header is undefined"); - } - return header.name === "__IGNORE__"; - }, - }) - ); - } catch (_) { - throw new CopyTemplateError( - "There was a problem extracting the file from the provided template." + - ` Template URL: \`${tarballUrl}\`` + - ` Destination directory: \`${downloadPath}\`` - ); - } - - if (filePath && !filePathHasFiles) { - throw new CopyTemplateError( - `The path "${filePath}" was not found in this ${ - isGithubUrl ? "GitHub repo." : "tarball." - }` - ); - } -} - -// Copied from remix-node/stream.ts -async function writeReadableStreamToWritable( - stream: ReadableStream, - writable: stream.Writable -) { - let reader = stream.getReader(); - let flushable = writable as { flush?: Function }; - - try { - while (true) { - let { done, value } = await reader.read(); - - if (done) { - writable.end(); - break; - } - - writable.write(value); - if (typeof flushable.flush === "function") { - flushable.flush(); - } - } - } catch (error: unknown) { - writable.destroy(error as Error); - throw error; - } -} - -function isValidGithubRepoUrl( - input: string | URL -): input is URL | GithubUrlString { - if (!isUrl(input)) { - return false; - } - try { - let url = new URL(input); - let pathSegments = url.pathname.slice(1).split("/"); - - return ( - url.protocol === "https:" && - url.hostname === "github.com" && - // The pathname must have at least 2 segments. If it has more than 2, the - // third must be "tree" and it must have at least 4 segments. - // https://github.com/:owner/:repo - // https://github.com/:owner/:repo/tree/:ref - pathSegments.length >= 2 && - (pathSegments.length > 2 - ? pathSegments[2] === "tree" && pathSegments.length >= 4 - : true) - ); - } catch (_) { - return false; - } -} - -function isGithubRepoShorthand(value: string) { - if (isUrl(value)) { - return false; - } - // This supports :owner/:repo and :owner/:repo/nested/path, e.g. - // remix-run/remix - // remix-run/remix/templates/express - // remix-run/examples/socket.io - return /^[\w-]+\/[\w-.]+(\/[\w-.]+)*$/.test(value); -} - -function isGithubReleaseAssetUrl(url: string) { - /** - * Accounts for the following formats: - * https://github.com/owner/repository/releases/download/v0.0.1/stack.tar.gz - * ~or~ - * https://github.com/owner/repository/releases/latest/download/stack.tar.gz - */ - return ( - url.startsWith("https://github.com") && - (url.includes("/releases/download/") || - url.includes("/releases/latest/download/")) - ); -} - -function getGithubReleaseAssetInfo(browserUrl: string): ReleaseAssetInfo { - /** - * https://github.com/owner/repository/releases/download/v0.0.1/stack.tar.gz - * ~or~ - * https://github.com/owner/repository/releases/latest/download/stack.tar.gz - */ - - let url = new URL(browserUrl); - let [, owner, name, , downloadOrLatest, tag, asset] = url.pathname.split("/"); - - if (downloadOrLatest === "latest" && tag === "download") { - // handle the GitHub URL quirk for latest releases - tag = "latest"; - } - - return { - browserUrl, - owner, - name, - asset, - tag, - }; -} - -function getRepoInfo(validatedGithubUrl: string): RepoInfo { - let url = new URL(validatedGithubUrl); - let [, owner, name, tree, branch, ...file] = url.pathname.split("/") as [ - _: string, - Owner: string, - Name: string, - Tree: string | undefined, - Branch: string | undefined, - FileInfo: string | undefined - ]; - let filePath = file.join("/"); - - if (tree === undefined) { - return { - owner, - name, - branch: null, - filePath: null, - }; - } - - return { - owner, - name, - // If we've validated the GitHub URL and there is a tree, there will also be - // a branch - branch: branch!, - filePath: filePath === "" || filePath === "/" ? null : filePath, - }; -} - -export class CopyTemplateError extends Error { - constructor(message: string) { - super(message); - this.name = "CopyTemplateError"; - } -} - -interface RepoInfo { - owner: string; - name: string; - branch?: string | null; - filePath?: string | null; -} - -// https://docs.github.com/en/rest/releases/assets?apiVersion=2022-11-28#get-a-release-asset -interface GitHubApiReleaseAsset { - url: string; - browser_download_url: string; - id: number; - node_id: string; - name: string; - label: string; - state: "uploaded" | "open"; - content_type: string; - size: number; - download_count: number; - created_at: string; - updated_at: string; - uploader: null | GitHubApiUploader; -} - -interface GitHubApiUploader { - name: string | null; - email: string | null; - login: string; - id: number; - node_id: string; - avatar_url: string; - gravatar_id: string | null; - url: string; - html_url: string; - followers_url: string; - following_url: string; - gists_url: string; - starred_url: string; - subscriptions_url: string; - organizations_url: string; - repos_url: string; - events_url: string; - received_events_url: string; - type: string; - site_admin: boolean; - starred_at: string; -} - -interface ReleaseAssetInfo { - browserUrl: string; - owner: string; - name: string; - asset: string; - tag: string; -} - -type GithubUrlString = - | `https://github.com/${string}/${string}` - | `https://www.github.com/${string}/${string}`; diff --git a/packages/create-remix/index.ts b/packages/create-remix/index.ts index 35e7381500d..ba48bd9997a 100644 --- a/packages/create-remix/index.ts +++ b/packages/create-remix/index.ts @@ -1,858 +1,47 @@ -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"; -import arg from "arg"; -import * as semver from "semver"; -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, - sleep, - strip, - stripDirectoryFromPath, - success, - toValidProjectName, -} from "./utils"; -import { renderLoadingIndicator } from "./loading-indicator"; -import { copyTemplate, CopyTemplateError } from "./copy-template"; - -async function createRemix(argv: string[]) { - let ctx = await getContext(argv); - if (ctx.help) { - printHelp(ctx); - return; - } - if (ctx.versionRequested) { - log(thisRemixVersion); - return; - } - - let steps = [ - introStep, - projectNameStep, - copyTemplateToTempDirStep, - copyTempDirToAppDirStep, - gitInitQuestionStep, - installDependenciesQuestionStep, - runInitScriptQuestionStep, - installDependenciesStep, - gitInitStep, - runInitScriptStep, - doneStep, - ]; - - try { - for (let step of steps) { - await step(ctx); - } - } catch (err) { - if (ctx.debug) { - console.error(err); - } - throw err; - } -} - -async function getContext(argv: string[]): Promise { - let flags = arg( - { - "--debug": Boolean, - "--remix-version": String, - "-v": "--remix-version", - "--template": String, - "--token": String, - "--yes": Boolean, - "-y": "--yes", - "--install": Boolean, - "--no-install": Boolean, - "--package-manager": String, - "--show-install-output": Boolean, - "--init-script": Boolean, - "--no-init-script": Boolean, - "--git-init": Boolean, - "--no-git-init": Boolean, - "--help": Boolean, - "-h": "--help", - "--version": Boolean, - "--V": "--version", - "--no-color": Boolean, - "--no-motion": Boolean, - "--overwrite": Boolean, - }, - { argv, permissive: true } - ); - - let { - "--debug": debug = false, - "--help": help = false, - "--remix-version": selectedRemixVersion, - "--template": template, - "--token": token, - "--install": install, - "--no-install": noInstall, - "--package-manager": pkgManager, - "--show-install-output": showInstallOutput = false, - "--git-init": git, - "--no-init-script": noInitScript, - "--init-script": initScript, - "--no-git-init": noGit, - "--no-motion": noMotion, - "--yes": yes, - "--version": versionRequested, - "--overwrite": overwrite, - } = flags; - - let cwd = flags["_"][0] as string; - let interactive = isInteractive(); - let projectName = cwd; - - if (!interactive) { - yes = true; - } - - if (selectedRemixVersion) { - if (semver.valid(selectedRemixVersion)) { - // do nothing, we're good - } else if (semver.coerce(selectedRemixVersion)) { - selectedRemixVersion = semver.coerce(selectedRemixVersion)!.version; - } else { - log( - `\n${color.warning( - `${selectedRemixVersion} is an invalid version specifier. Using Remix v${thisRemixVersion}.` - )}` - ); - selectedRemixVersion = undefined; - } - } - - 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), - initScript: initScript ?? (noInitScript ? false : yes), - initScriptPath: null, - help, - install: install ?? (noInstall ? false : yes), - showInstallOutput, - noMotion, - pkgManager: validatePackageManager( - pkgManager ?? - // npm, pnpm, Yarn, and Bun set the user agent environment variable that can be used - // to determine which package manager ran the command. - (process.env.npm_config_user_agent ?? "npm").split("/")[0] - ), - projectName, - prompt, - remixVersion: selectedRemixVersion || thisRemixVersion, - template, - token, - versionRequested, - }; - - return context; -} - -interface Context { - tempDir: string; - cwd: string; - interactive: boolean; - debug: boolean; - git?: boolean; - initScript?: boolean; - initScriptPath: null | string; - help: boolean; - install?: boolean; - showInstallOutput: boolean; - noMotion?: boolean; - pkgManager: PackageManager; - projectName?: string; - prompt: typeof prompt; - remixVersion: string; - stdin?: typeof process.stdin; - stdout?: typeof process.stdout; - template?: string; - token?: string; - versionRequested?: boolean; - overwrite?: boolean; -} - -async function introStep(ctx: Context) { - log( - `\n${color.bgWhite(` ${color.black("remix")} `)} ${color.green( - color.bold(`v${ctx.remixVersion}`) - )} ${color.bold("💿 Let's build a better website...")}` - ); - - if (!ctx.interactive) { - log(""); - info("Shell is not interactive.", [ - `Using default options. This is equivalent to running with the `, - color.reset("--yes"), - ` flag.`, - ]); - } -} - -async function projectNameStep(ctx: Context) { - // valid cwd is required if shell isn't interactive - 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); - info("Directory:", [ - "Using ", - color.reset(ctx.cwd), - " as project directory", - ]); - } - - 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", +import * as readline from "readline"; +import crossSpawn from "cross-spawn"; + +export async function createRemix(argv: string[]) { + return new Promise((resolve, reject) => { + let rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, }); - ctx.cwd = name!; - ctx.projectName = toValidProjectName(name!); - return; - } - - let name = ctx.cwd; - if (name === "." || name === "./") { - let parts = process.cwd().split(path.sep); - name = parts[parts.length - 1]; - } else if (name.startsWith("./") || name.startsWith("../")) { - let parts = name.split("/"); - name = parts[parts.length - 1]; - } - ctx.projectName = toValidProjectName(name); -} - -async function copyTemplateToTempDirStep(ctx: Context) { - if (ctx.template) { - log(""); - info("Template:", ["Using ", color.reset(ctx.template), "..."]); - } else { - log(""); - info("Using basic template", [ - "See https://remix.run/guides/templates for more", - ]); - } - let template = - ctx.template ?? - "https://github.com/remix-run/remix/tree/main/templates/remix"; - - await loadingIndicator({ - start: "Template copying...", - end: "Template copied", - while: async () => { - 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) { - 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) { - debug(message); - await sleep(500); + console.log("\nDid you mean `npx create-react-router@latest`?\n"); + + rl.question("Would you like to run this command? (y/n): ", (answer) => { + if (answer.toLowerCase() === "y" || answer.toLowerCase() === "yes") { + console.log("\nRunning: npx create-react-router@latest\n"); + + // Use cross-spawn for better cross-platform support + let child = crossSpawn("npx", ["create-react-router@latest", ...argv], { + stdio: "inherit", + env: process.env, + }); + + child.on("error", (error) => { + reject(error); + rl.close(); + }); + + child.on("exit", (code) => { + rl.close(); + if (code !== 0) { + reject(new Error(`Command failed with exit code ${code}`)); + } else { + resolve(); } - }, - }); - - 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)) - .sort((a, b) => a.localeCompare(b)); - - 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); -} - -async function installDependenciesQuestionStep(ctx: Context) { - if (ctx.install === undefined) { - let { deps = true } = await ctx.prompt({ - name: "deps", - type: "confirm", - label: title("deps"), - message: `Install dependencies with ${ctx.pkgManager}?`, - hint: "recommended", - initial: true, - }); - ctx.install = deps; - } -} - -async function runInitScriptQuestionStep(ctx: Context) { - if (!ctx.initScriptPath) { - return; - } - - // We can't run the init script without installing dependencies - if (!ctx.install) { - return; - } - - if (ctx.initScript === undefined) { - let { init } = await ctx.prompt({ - name: "init", - type: "confirm", - label: title("init"), - message: `This template has a remix.init script. Do you want to run it?`, - hint: "recommended", - initial: true, - }); - - ctx.initScript = init; - } -} - -async function installDependenciesStep(ctx: Context) { - let { install, pkgManager, showInstallOutput, cwd } = ctx; - - if (!install) { - await sleep(100); - info("Skipping install step.", [ - "Remember to install dependencies after setup with ", - color.reset(`${pkgManager} install`), - ".", - ]); - return; - } - - function runInstall() { - return installDependencies({ - cwd, - pkgManager, - showInstallOutput, - }); - } - - if (showInstallOutput) { - log(""); - info(`Install`, `Dependencies installing with ${pkgManager}...`); - log(""); - await runInstall(); - log(""); - return; - } - - log(""); - await loadingIndicator({ - start: `Dependencies installing with ${pkgManager}...`, - end: "Dependencies installed", - while: runInstall, - ctx, - }); -} - -async function gitInitQuestionStep(ctx: Context) { - if (fs.existsSync(path.join(ctx.cwd, ".git"))) { - info("Nice!", `Git has already been initialized`); - return; - } - - let git = ctx.git; - if (ctx.git === undefined) { - ({ git } = await ctx.prompt({ - name: "git", - type: "confirm", - label: title("git"), - message: `Initialize a new git repository?`, - hint: "recommended", - initial: true, - })); - } - - ctx.git = git ?? false; -} - -async function gitInitStep(ctx: Context) { - if (!ctx.git) { - return; - } - - if (fs.existsSync(path.join(ctx.cwd, ".git"))) { - log(""); - info("Nice!", `Git has already been initialized`); - return; - } - - log(""); - await loadingIndicator({ - start: "Git initializing...", - end: "Git initialized", - while: async () => { - let options = { cwd: ctx.cwd, stdio: "ignore" } as const; - let commitMsg = "Initial commit from create-remix"; - try { - await execa("git", ["init"], options); - await execa("git", ["add", "."], options); - await execa("git", ["commit", "-m", commitMsg], options); - } catch (err) { - error("Oh no!", "Failed to initialize git."); - throw err; + }); + } else { + console.log("\nCommand not executed."); + rl.close(); + resolve(); } - }, - ctx, - }); -} - -async function runInitScriptStep(ctx: Context) { - if (!ctx.initScriptPath) { - return; - } - - let initCommand = `${packageManagerExecScript[ctx.pkgManager]} remix init`; - - if (!ctx.install || !ctx.initScript) { - await sleep(100); - log(""); - info("Skipping template's remix.init script.", [ - ctx.install - ? "You can run the script in the " - : "After installing dependencies, you can run the script in the ", - color.reset("remix.init"), - " directory with ", - color.reset(initCommand), - ".", - ]); - return; - } - - let initScriptDir = path.dirname(ctx.initScriptPath); - let initPackageJson = path.resolve(initScriptDir, "package.json"); - let packageManager = ctx.pkgManager; - - try { - if (await fileExists(initPackageJson)) { - await loadingIndicator({ - start: `Dependencies for remix.init script installing with ${ctx.pkgManager}...`, - end: "Dependencies for remix.init script installed", - while: () => - installDependencies({ - pkgManager: ctx.pkgManager, - cwd: initScriptDir, - showInstallOutput: ctx.showInstallOutput, - }), - ctx, - }); - } - } catch (err) { - error("Oh no!", "Failed to install dependencies for template init script"); - throw err; - } - - log(""); - info("Running template's remix.init script...", "\n"); - - try { - let initFn = require(ctx.initScriptPath); - if (typeof initFn !== "function" && initFn.default) { - initFn = initFn.default; - } - if (typeof initFn !== "function") { - throw new Error("remix.init script doesn't export a function."); - } - let rootDirectory = path.resolve(ctx.cwd); - await initFn({ packageManager, rootDirectory }); - } catch (err) { - error("Oh no!", "Template's remix.init script failed"); - throw err; - } - - try { - await rm(initScriptDir); - } catch (err) { - error("Oh no!", "Failed to remove template's remix.init script"); - throw err; - } - - log(""); - success("Template's remix.init script complete"); - - if (ctx.git) { - await loadingIndicator({ - start: "Committing changes from remix.init script...", - end: "Committed changes from remix.init script", - while: async () => { - let options = { cwd: ctx.cwd, stdio: "ignore" } as const; - let commitMsg = "Initialize project with remix.init script"; - try { - await execa("git", ["add", "."], options); - await execa("git", ["commit", "-m", commitMsg], options); - } catch (err) { - error("Oh no!", "Failed to commit changes from remix.init script."); - throw err; - } - }, - ctx, }); - } -} - -async function doneStep(ctx: Context) { - let projectDir = path.relative(process.cwd(), ctx.cwd); - - let max = process.stdout.columns; - let prefix = max < 80 ? " " : " ".repeat(9); - await sleep(200); - - log(`\n ${color.bgWhite(color.black(" done "))} That's it!`); - await sleep(100); - if (projectDir !== "") { - let enter = [ - `\n${prefix}Enter your project directory using`, - color.cyan(`cd .${path.sep}${projectDir}`), - ]; - let len = enter[0].length + stripAnsi(enter[1]).length; - log(enter.join(len > max ? "\n" + prefix : " ")); - } - log( - `${prefix}Check out ${color.bold( - "README.md" - )} for development and deploy instructions.` - ); - await sleep(100); - log( - `\n${prefix}Join the community at ${color.cyan(`https://rmx.as/discord`)}\n` - ); - await sleep(200); -} - -type PackageManager = "npm" | "yarn" | "pnpm" | "bun"; -const packageManagerExecScript: Record = { - npm: "npx", - yarn: "yarn", - pnpm: "pnpm exec", - bun: "bunx", -}; - -function validatePackageManager(pkgManager: string): PackageManager { - return packageManagerExecScript.hasOwnProperty(pkgManager) - ? (pkgManager as PackageManager) - : "npm"; -} - -async function installDependencies({ - pkgManager, - cwd, - showInstallOutput, -}: { - pkgManager: PackageManager; - cwd: string; - showInstallOutput: boolean; -}) { - try { - await execa(pkgManager, ["install"], { - cwd, - stdio: showInstallOutput ? "inherit" : "ignore", + rl.on("close", () => { + reject(new Error("User did not confirm command execution")); }); - } catch (err) { - error("Oh no!", "Failed to install dependencies."); - throw err; - } -} - -async function updatePackageJSON(ctx: Context) { - let packageJSONPath = path.join(ctx.cwd, "package.json"); - if (!fs.existsSync(packageJSONPath)) { - let relativePath = path.relative(process.cwd(), ctx.cwd); - error( - "Oh no!", - "The provided template must be a Remix project with a `package.json` " + - `file, but that file does not exist in ${color.bold(relativePath)}.` - ); - throw new Error(`package.json does not exist in ${ctx.cwd}`); - } - - let contents = await fs.promises.readFile(packageJSONPath, "utf-8"); - let packageJSON: any; - try { - packageJSON = JSON.parse(contents); - if (!isValidJsonObject(packageJSON)) { - throw Error(); - } - } catch (err) { - error( - "Oh no!", - "The provided template must be a Remix project with a `package.json` " + - `file, but that file is invalid.` - ); - throw err; - } - - for (let pkgKey of ["dependencies", "devDependencies"] as const) { - let dependencies = packageJSON[pkgKey]; - if (!dependencies) continue; - - if (!isValidJsonObject(dependencies)) { - error( - "Oh no!", - "The provided template must be a Remix project with a `package.json` " + - `file, but its ${pkgKey} value is invalid.` - ); - throw new Error(`package.json ${pkgKey} are invalid`); - } - - for (let dependency in dependencies) { - let version = dependencies[dependency]; - if ( - (dependency.startsWith("@remix-run/") || dependency === "remix") && - version === "*" - ) { - dependencies[dependency] = semver.prerelease(ctx.remixVersion) - ? // Templates created from prereleases should pin to a specific version - ctx.remixVersion - : "^" + ctx.remixVersion; - } - } - } - - if (!ctx.initScriptPath) { - packageJSON.name = ctx.projectName; - } - - fs.promises.writeFile( - packageJSONPath, - JSON.stringify(sortPackageJSON(packageJSON), null, 2), - "utf-8" - ); -} - -async function loadingIndicator(args: { - start: string; - end: string; - while: (...args: any) => Promise; - ctx: Context; -}) { - let { ctx, ...rest } = args; - await renderLoadingIndicator({ - ...rest, - noMotion: args.ctx.noMotion, }); } - -function title(text: string) { - return align(color.bgWhite(` ${color.black(text)} `), "end", 7) + " "; -} - -function printHelp(ctx: Context) { - // prettier-ignore - let output = ` -${title("create-remix")} - -${color.heading("Usage")}: - -${color.dim("$")} ${color.greenBright("create-remix")} ${color.arg("")} ${color.arg("<...options>")} - -${color.heading("Values")}: - -${color.arg("projectDir")} ${color.dim(`The Remix project directory`)} - -${color.heading("Options")}: - -${color.arg("--help, -h")} ${color.dim(`Print this help message and exit`)} -${color.arg("--version, -V")} ${color.dim(`Print the CLI version and exit`)} -${color.arg("--no-color")} ${color.dim(`Disable ANSI colors in console output`)} -${color.arg("--no-motion")} ${color.dim(`Disable animations in console output`)} - -${color.arg("--template ")} ${color.dim(`The project template to use`)} -${color.arg("--[no-]install")} ${color.dim(`Whether or not to install dependencies after creation`)} -${color.arg("--package-manager")} ${color.dim(`The package manager to use`)} -${color.arg("--show-install-output")} ${color.dim(`Whether to show the output of the install process`)} -${color.arg("--[no-]init-script")} ${color.dim(`Whether or not to run the template's remix.init script, if present`)} -${color.arg("--[no-]git-init")} ${color.dim(`Whether or not to initialize a Git repository`)} -${color.arg("--yes, -y")} ${color.dim(`Skip all option prompts and run setup`)} -${color.arg("--remix-version, -v")} ${color.dim(`The version of Remix to use`)} - -${color.heading("Creating a new project")}: - -Remix projects are created from templates. A template can be: - -- a GitHub repo shorthand, :username/:repo or :username/:repo/:directory -- the URL of a GitHub repo (or directory within it) -- the URL of a tarball -- a file path to a directory of files -- a file path to a tarball -${[ - "remix-run/grunge-stack", - "remix-run/remix/templates/remix", - "remix-run/examples/basic", - ":username/:repo", - ":username/:repo/:directory", - "https://github.com/:username/:repo", - "https://github.com/:username/:repo/tree/:branch", - "https://github.com/:username/:repo/tree/:branch/:directory", - "https://github.com/:username/:repo/archive/refs/tags/:tag.tar.gz", - "https://example.com/remix-template.tar.gz", - "./path/to/remix-template", - "./path/to/remix-template.tar.gz", -].reduce((str, example) => { - return `${str}\n${color.dim("$")} ${color.greenBright("create-remix")} my-app ${color.arg(`--template ${example}`)}`; -}, "")} - -To create a new project from a template in a private GitHub repo, -pass the \`token\` flag with a personal access token with access -to that repo. - -${color.heading("Initialize a project")}: - -Remix project templates may contain a \`remix.init\` directory -with a script that initializes the project. This script automatically -runs during \`remix create\`, but if you ever need to run it manually -you can run: - -${color.dim("$")} ${color.greenBright("remix")} init -`; - - log(output); -} - -function align(text: string, dir: "start" | "end" | "center", len: number) { - let pad = Math.max(len - strip(text).length, 0); - switch (dir) { - case "start": - return text + " ".repeat(pad); - case "end": - return " ".repeat(pad) + text; - case "center": - return ( - " ".repeat(Math.floor(pad / 2)) + text + " ".repeat(Math.floor(pad / 2)) - ); - default: - return text; - } -} - -async function getInitScriptPath(cwd: string) { - let initScriptDir = path.join(cwd, "remix.init"); - let initScriptPath = path.resolve(initScriptDir, "index.js"); - return (await fileExists(initScriptPath)) ? initScriptPath : null; -} - -export { createRemix }; -export type { Context }; diff --git a/packages/create-remix/loading-indicator.ts b/packages/create-remix/loading-indicator.ts deleted file mode 100644 index 63254db6cd9..00000000000 --- a/packages/create-remix/loading-indicator.ts +++ /dev/null @@ -1,175 +0,0 @@ -// Adapted from https://github.com/withastro/cli-kit -// MIT License Copyright (c) 2022 Nate Moore -import process from "node:process"; -import readline from "node:readline"; -import { erase, cursor } from "sisteransi"; - -import { reverse, sleep, color } from "./utils"; - -const GRADIENT_COLORS: Array<`#${string}`> = [ - "#ffffff", - "#dadada", - "#dadada", - "#a8deaa", - "#a8deaa", - "#a8deaa", - "#d0f0bd", - "#d0f0bd", - "#ffffed", - "#ffffed", - "#ffffed", - "#ffffed", - "#ffffed", - "#ffffed", - "#ffffed", - "#ffffed", - "#ffffed", - "#f7f8ca", - "#f7f8ca", - "#eae6ba", - "#eae6ba", - "#eae6ba", - "#dadada", - "#dadada", - "#ffffff", -]; - -const MAX_FRAMES = 8; - -const LEADING_FRAMES = Array.from( - { length: MAX_FRAMES * 2 }, - () => GRADIENT_COLORS[0] -); -const TRAILING_FRAMES = Array.from( - { length: MAX_FRAMES * 2 }, - () => GRADIENT_COLORS[GRADIENT_COLORS.length - 1] -); -const INDICATOR_FULL_FRAMES = [ - ...LEADING_FRAMES, - ...GRADIENT_COLORS, - ...TRAILING_FRAMES, - ...reverse(GRADIENT_COLORS), -]; -const INDICATOR_GRADIENT = reverse( - INDICATOR_FULL_FRAMES.map((_, i) => loadingIndicatorFrame(i)) -); - -export async function renderLoadingIndicator({ - start, - end, - while: update = () => sleep(100), - noMotion = false, - stdin = process.stdin, - stdout = process.stdout, -}: { - start: string; - end: string; - while: (...args: any) => Promise; - noMotion?: boolean; - stdin?: NodeJS.ReadStream & { fd: 0 }; - stdout?: NodeJS.WriteStream & { fd: 1 }; -}) { - let act = update(); - let tooSlow = Object.create(null); - let result = await Promise.race([sleep(500).then(() => tooSlow), act]); - if (result === tooSlow) { - let loading = await gradient(color.green(start), { - stdin, - stdout, - noMotion, - }); - await act; - loading.stop(); - } - stdout.write(`${" ".repeat(5)} ${color.green("✔")} ${color.green(end)}\n`); -} - -function loadingIndicatorFrame(offset = 0) { - let frames = INDICATOR_FULL_FRAMES.slice(offset, offset + (MAX_FRAMES - 2)); - if (frames.length < MAX_FRAMES - 2) { - let filled = new Array(MAX_FRAMES - frames.length - 2).fill( - GRADIENT_COLORS[0] - ); - frames.push(...filled); - } - return frames; -} - -function getGradientAnimationFrames() { - return INDICATOR_GRADIENT.map( - (colors) => " " + colors.map((g, i) => color.hex(g)("█")).join("") - ); -} - -async function gradient( - text: string, - { stdin = process.stdin, stdout = process.stdout, noMotion = false } = {} -) { - let { createLogUpdate } = await import("log-update"); - let logUpdate = createLogUpdate(stdout); - let frameIndex = 0; - let frames = getGradientAnimationFrames(); - let interval: NodeJS.Timeout; - let rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 50 }); - readline.emitKeypressEvents(stdin, rl); - - if (stdin.isTTY) stdin.setRawMode(true); - function keypress(char: string) { - if (char === "\x03") { - loadingIndicator.stop(); - process.exit(0); - } - if (stdin.isTTY) stdin.setRawMode(true); - stdout.write(cursor.hide + erase.lines(1)); - } - - let done = false; - let loadingIndicator = { - start() { - stdout.write(cursor.hide); - stdin.on("keypress", keypress); - logUpdate(`${frames[0]} ${text}`); - - async function loop() { - if (done) return; - if (frameIndex < frames.length - 1) { - frameIndex++; - } else { - frameIndex = 0; - } - let frame = frames[frameIndex]; - logUpdate( - `${(noMotion - ? getMotionlessFrame(frameIndex) - : color.supportsColor - ? frame - : getColorlessFrame(frameIndex) - ).padEnd(MAX_FRAMES - 1, " ")} ${text}` - ); - if (!done) await sleep(20); - loop(); - } - - loop(); - }, - stop() { - done = true; - stdin.removeListener("keypress", keypress); - clearInterval(interval); - logUpdate.clear(); - rl.close(); - }, - }; - loadingIndicator.start(); - return loadingIndicator; -} - -function getColorlessFrame(frameIndex: number) { - return ( - frameIndex % 3 === 0 ? ".. .. " : frameIndex % 3 === 1 ? " .. .." : ". .. ." - ).padEnd(MAX_FRAMES - 1 + 20, " "); -} - -function getMotionlessFrame(frameIndex: number) { - return " ".repeat(MAX_FRAMES - 1); -} diff --git a/packages/create-remix/package.json b/packages/create-remix/package.json index 973146814d3..1bb4506fc53 100644 --- a/packages/create-remix/package.json +++ b/packages/create-remix/package.json @@ -20,30 +20,7 @@ "tsc": "tsc" }, "dependencies": { - "@remix-run/web-fetch": "^4.4.2", - "arg": "^5.0.1", - "chalk": "^4.1.2", - "execa": "5.1.1", - "fs-extra": "^10.0.0", - "gunzip-maybe": "^1.4.2", - "log-update": "^5.0.1", - "proxy-agent": "^6.3.0", - "recursive-readdir": "^2.2.3", - "rimraf": "^4.1.2", - "semver": "^7.3.7", - "sisteransi": "^1.0.5", - "sort-package-json": "^1.55.0", - "strip-ansi": "^6.0.1", - "tar-fs": "^2.1.1" - }, - "devDependencies": { - "@types/gunzip-maybe": "^1.4.0", - "@types/recursive-readdir": "^2.2.1", - "@types/tar-fs": "^2.0.1", - "esbuild": "0.17.6", - "esbuild-register": "^3.3.2", - "msw": "^1.2.3", - "tiny-invariant": "^1.2.0" + "cross-spawn": "^7.0.6" }, "engines": { "node": ">=18.0.0" diff --git a/packages/create-remix/prompt.ts b/packages/create-remix/prompt.ts deleted file mode 100644 index 716ff7ea8de..00000000000 --- a/packages/create-remix/prompt.ts +++ /dev/null @@ -1,193 +0,0 @@ -// Adapted from https://github.com/withastro/cli-kit -// MIT License Copyright (c) 2022 Nate Moore -// https://github.com/withastro/cli-kit/tree/main/src/prompt -import process from "node:process"; - -import { ConfirmPrompt, type ConfirmPromptOptions } from "./prompts-confirm"; -import { - SelectPrompt, - type SelectPromptOptions, - type SelectChoice, -} from "./prompts-select"; -import { - MultiSelectPrompt, - type MultiSelectPromptOptions, -} from "./prompts-multi-select"; -import { TextPrompt, type TextPromptOptions } from "./prompts-text"; -import { identity } from "./utils"; - -const prompts = { - text: (args: TextPromptOptions) => toPrompt(TextPrompt, args), - confirm: (args: ConfirmPromptOptions) => toPrompt(ConfirmPrompt, args), - select: []>( - args: SelectPromptOptions - ) => toPrompt(SelectPrompt, args), - multiselect: []>( - args: MultiSelectPromptOptions - ) => toPrompt(MultiSelectPrompt, args), -}; - -export async function prompt< - T extends Readonly> | Readonly[]>, - P extends T extends Readonly ? T[number] : T = T extends Readonly< - any[] - > - ? T[number] - : T ->(questions: T, opts: PromptTypeOptions

= {}): Promise> { - let { - onSubmit = identity, - onCancel = () => process.exit(0), - stdin = process.stdin, - stdout = process.stdout, - } = opts; - - let answers = {} as Answers; - - let questionsArray = ( - Array.isArray(questions) ? questions : [questions] - ) as Readonly; - let answer: Answer

; - let quit: any; - let name: string; - let type: P["type"]; - - for (let question of questionsArray) { - ({ name, type } = question); - - try { - // Get the injected answer if there is one or prompt the user - // @ts-expect-error - answer = await prompts[type](Object.assign({ stdin, stdout }, question)); - answers[name] = answer as any; - quit = await onSubmit(question, answer, answers); - } catch (err) { - quit = !(await onCancel(question, answers)); - } - if (quit) { - return answers; - } - } - return answers; -} - -function toPrompt< - T extends - | typeof TextPrompt - | typeof ConfirmPrompt - | typeof SelectPrompt - | typeof MultiSelectPrompt ->(el: T, args: any, opts: any = {}) { - if ( - el !== TextPrompt && - el !== ConfirmPrompt && - el !== SelectPrompt && - el !== MultiSelectPrompt - ) { - throw new Error(`Invalid prompt type: ${el.name}`); - } - - return new Promise((res, rej) => { - let p = new el( - args, - // @ts-expect-error - opts - ); - let onAbort = args.onAbort || opts.onAbort || identity; - let onSubmit = args.onSubmit || opts.onSubmit || identity; - let onExit = args.onExit || opts.onExit || identity; - p.on("state", args.onState || identity); - p.on("submit", (x: any) => res(onSubmit(x))); - p.on("exit", (x: any) => res(onExit(x))); - p.on("abort", (x: any) => rej(onAbort(x))); - }); -} - -type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ( - k: infer I -) => void - ? I - : never; - -interface BasePromptType { - name: string; -} - -interface TextPromptType extends BasePromptType { - type: "text"; -} - -interface ConfirmPromptType extends BasePromptType { - type: "confirm"; -} - -interface SelectPromptType< - Choices extends Readonly[]> -> extends BasePromptType { - type: "select"; - choices: Choices; -} - -interface MultiSelectPromptType< - Choices extends Readonly[]> -> extends BasePromptType { - type: "multiselect"; - choices: Choices; -} - -interface SelectChoiceType { - value: unknown; - label: string; - hint?: string; -} - -type PromptType< - Choices extends Readonly = Readonly -> = - | TextPromptType - | ConfirmPromptType - | SelectPromptType - | MultiSelectPromptType; - -type PromptChoices> = T extends SelectPromptType< - infer Choices -> - ? Choices - : T extends MultiSelectPromptType - ? Choices - : never; - -type Answer< - T extends PromptType, - Choices extends Readonly = PromptChoices -> = T extends TextPromptType - ? string - : T extends ConfirmPromptType - ? boolean - : T extends SelectPromptType - ? Choices[number]["value"] - : T extends MultiSelectPromptType - ? (Choices[number]["value"] | undefined)[] - : never; - -type Answers< - T extends Readonly> | Readonly[]> -> = T extends Readonly> - ? Partial<{ [key in T["name"]]: Answer }> - : T extends Readonly[]> - ? UnionToIntersection> - : never; - -interface PromptTypeOptions< - T extends PromptType, - Choices extends Readonly = PromptChoices -> { - onSubmit?( - question: T | Readonly, - answer: Answer, - answers: Answers - ): any; - onCancel?(question: T | Readonly, answers: Answers): any; - stdin?: NodeJS.ReadStream; - stdout?: NodeJS.WriteStream; -} diff --git a/packages/create-remix/prompts-confirm.ts b/packages/create-remix/prompts-confirm.ts deleted file mode 100644 index 593ae29683f..00000000000 --- a/packages/create-remix/prompts-confirm.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Adapted from https://github.com/withastro/cli-kit - * @license MIT License Copyright (c) 2022 Nate Moore - */ -import { cursor, erase } from "sisteransi"; - -import { Prompt, type PromptOptions } from "./prompts-prompt-base"; -import { color, strip, clear, type ActionKey } from "./utils"; - -export interface ConfirmPromptOptions extends PromptOptions { - label: string; - message: string; - initial?: boolean; - hint?: string; - validate?: (v: any) => boolean; - error?: string; -} - -export type ConfirmPromptChoices = [ - { value: true; label: string }, - { value: false; label: string } -]; - -export class ConfirmPrompt extends Prompt { - label: string; - msg: string; - value: boolean | undefined; - initialValue: boolean; - hint?: string; - choices: ConfirmPromptChoices; - cursor: number; - done: boolean | undefined; - name = "ConfirmPrompt" as const; - - // set by render which is called in constructor - outputText!: string; - - constructor(opts: ConfirmPromptOptions) { - super(opts); - this.label = opts.label; - this.hint = opts.hint; - this.msg = opts.message; - this.value = opts.initial; - this.initialValue = !!opts.initial; - this.choices = [ - { value: true, label: "Yes" }, - { value: false, label: "No" }, - ]; - this.cursor = this.choices.findIndex((c) => c.value === this.initialValue); - this.render(); - } - - get type() { - return "confirm" as const; - } - - exit() { - this.abort(); - } - - abort() { - this.done = this.aborted = true; - this.fire(); - this.render(); - this.out.write("\n"); - this.close(); - } - - submit() { - this.value = this.value || false; - this.cursor = this.choices.findIndex((c) => c.value === this.value); - this.done = true; - this.aborted = false; - this.fire(); - this.render(); - this.out.write("\n"); - this.close(); - } - - moveCursor(n: number) { - this.cursor = n; - this.value = this.choices[n].value; - this.fire(); - } - - reset() { - this.moveCursor(0); - this.fire(); - this.render(); - } - - first() { - this.moveCursor(0); - this.render(); - } - - last() { - this.moveCursor(this.choices.length - 1); - this.render(); - } - - left() { - if (this.cursor === 0) { - this.moveCursor(this.choices.length - 1); - } else { - this.moveCursor(this.cursor - 1); - } - this.render(); - } - - right() { - if (this.cursor === this.choices.length - 1) { - this.moveCursor(0); - } else { - this.moveCursor(this.cursor + 1); - } - this.render(); - } - - _(c: string, key: ActionKey) { - if (!Number.isNaN(Number.parseInt(c))) { - let n = Number.parseInt(c) - 1; - this.moveCursor(n); - this.render(); - return this.submit(); - } - if (c.toLowerCase() === "y") { - this.value = true; - return this.submit(); - } - if (c.toLowerCase() === "n") { - this.value = false; - return this.submit(); - } - return; - } - - render() { - if (this.closed) { - return; - } - if (this.firstRender) { - this.out.write(cursor.hide); - } else { - this.out.write(clear(this.outputText, this.out.columns)); - } - super.render(); - let outputText = [ - "\n", - this.label, - " ", - this.msg, - this.done ? "" : this.hint ? color.dim(` (${this.hint})`) : "", - "\n", - ]; - - outputText.push(" ".repeat(strip(this.label).length)); - - if (this.done) { - outputText.push(" ", color.dim(`${this.choices[this.cursor].label}`)); - } else { - outputText.push( - " ", - this.choices - .map((choice, i) => - i === this.cursor - ? `${color.green("●")} ${choice.label} ` - : color.dim(`○ ${choice.label} `) - ) - .join(color.dim(" ")) - ); - } - this.outputText = outputText.join(""); - - this.out.write(erase.line + cursor.to(0) + this.outputText); - } -} diff --git a/packages/create-remix/prompts-multi-select.ts b/packages/create-remix/prompts-multi-select.ts deleted file mode 100644 index 64b88995d42..00000000000 --- a/packages/create-remix/prompts-multi-select.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Adapted from https://github.com/withastro/cli-kit - * @license MIT License Copyright (c) 2022 Nate Moore - */ -import { cursor, erase } from "sisteransi"; - -import { Prompt, type PromptOptions } from "./prompts-prompt-base"; -import { type SelectChoice } from "./prompts-select"; -import { color, strip, clear, type ActionKey } from "./utils"; - -export interface MultiSelectPromptOptions< - Choices extends Readonly[]> -> extends PromptOptions { - hint?: string; - message: string; - label: string; - initial?: Choices[number]["value"]; - validate?: (v: any) => boolean; - error?: string; - choices: Choices; -} - -export class MultiSelectPrompt< - Choices extends Readonly[]> -> extends Prompt { - choices: Readonly>; - label: string; - msg: string; - hint?: string; - value: Array; - initialValue: Choices[number]["value"]; - done: boolean | undefined; - cursor: number; - name = "MultiSelectPrompt" as const; - - // set by render which is called in constructor - outputText!: string; - - constructor(opts: MultiSelectPromptOptions) { - if ( - !opts.choices || - !Array.isArray(opts.choices) || - opts.choices.length < 1 - ) { - throw new Error("MultiSelectPrompt must contain choices"); - } - super(opts); - this.label = opts.label; - this.msg = opts.message; - this.hint = opts.hint; - this.value = []; - this.choices = - opts.choices.map((choice) => ({ ...choice, selected: false })) || []; - this.initialValue = opts.initial || this.choices[0].value; - this.cursor = this.choices.findIndex((c) => c.value === this.initialValue); - this.render(); - } - - get type() { - return "multiselect" as const; - } - - exit() { - this.abort(); - } - - abort() { - this.done = this.aborted = true; - this.cursor = this.choices.findIndex((c) => c.value === this.initialValue); - this.fire(); - this.render(); - this.out.write("\n"); - this.close(); - } - - submit() { - return this.toggle(); - } - - finish() { - // eslint-disable-next-line no-self-assign - this.value = this.value; - this.done = true; - this.aborted = false; - this.fire(); - this.render(); - this.out.write("\n"); - this.close(); - } - - moveCursor(n: number) { - this.cursor = n; - this.fire(); - } - - toggle() { - let choice = this.choices[this.cursor]; - if (!choice) return; - choice.selected = !choice.selected; - this.render(); - } - - _(c: string, key: ActionKey) { - if (c === " ") { - return this.toggle(); - } - if (c.toLowerCase() === "c") { - return this.finish(); - } - return; - } - - reset() { - this.moveCursor(0); - this.fire(); - this.render(); - } - - first() { - this.moveCursor(0); - this.render(); - } - - last() { - this.moveCursor(this.choices.length - 1); - this.render(); - } - - up() { - if (this.cursor === 0) { - this.moveCursor(this.choices.length - 1); - } else { - this.moveCursor(this.cursor - 1); - } - this.render(); - } - - down() { - if (this.cursor === this.choices.length - 1) { - this.moveCursor(0); - } else { - this.moveCursor(this.cursor + 1); - } - this.render(); - } - - render() { - if (this.closed) return; - if (this.firstRender) { - this.out.write(cursor.hide); - } else { - this.out.write(clear(this.outputText, this.out.columns)); - } - super.render(); - - let outputText = ["\n", this.label, " ", this.msg, "\n"]; - - let prefix = " ".repeat(strip(this.label).length); - - if (this.done) { - outputText.push( - this.choices - .map((choice) => - choice.selected ? `${prefix} ${color.dim(`${choice.label}`)}\n` : "" - ) - .join("") - .trimEnd() - ); - } else { - outputText.push( - this.choices - .map((choice, i) => - i === this.cursor - ? `${prefix.slice(0, -2)}${color.cyanBright("▶")} ${ - choice.selected ? color.green("■") : color.whiteBright("□") - } ${color.underline(choice.label)} ${ - choice.hint ? color.dim(choice.hint) : "" - }` - : color[choice.selected ? "reset" : "dim"]( - `${prefix} ${choice.selected ? color.green("■") : "□"} ${ - choice.label - } ` - ) - ) - .join("\n") - ); - outputText.push( - `\n\n${prefix} Press ${color.inverse(" C ")} to continue` - ); - } - this.outputText = outputText.join(""); - this.out.write(erase.line + cursor.to(0) + this.outputText); - } -} diff --git a/packages/create-remix/prompts-prompt-base.ts b/packages/create-remix/prompts-prompt-base.ts deleted file mode 100644 index 1f0823576e5..00000000000 --- a/packages/create-remix/prompts-prompt-base.ts +++ /dev/null @@ -1,115 +0,0 @@ -/** - * Adapted from https://github.com/withastro/cli-kit - * @license MIT License Copyright (c) 2022 Nate Moore - */ -import process from "node:process"; -import EventEmitter from "node:events"; -import readline from "node:readline"; -import { beep, cursor } from "sisteransi"; - -import { color, action, type ActionKey } from "./utils"; - -export class Prompt extends EventEmitter { - firstRender: boolean; - in: any; - out: any; - onRender: any; - close: () => void; - aborted: any; - exited: any; - closed: boolean | undefined; - name = "Prompt"; - - constructor(opts: PromptOptions = {}) { - super(); - this.firstRender = true; - this.in = opts.stdin || process.stdin; - this.out = opts.stdout || process.stdout; - this.onRender = (opts.onRender || (() => void 0)).bind(this); - let rl = readline.createInterface({ - input: this.in, - escapeCodeTimeout: 50, - }); - readline.emitKeypressEvents(this.in, rl); - - if (this.in.isTTY) this.in.setRawMode(true); - let isSelect = - ["SelectPrompt", "MultiSelectPrompt"].indexOf(this.constructor.name) > -1; - - let keypress = (str: string, key: ActionKey) => { - if (this.in.isTTY) this.in.setRawMode(true); - let a = action(key, isSelect); - if (a === false) { - try { - this._(str, key); - } catch (_) {} - // @ts-expect-error - } else if (typeof this[a] === "function") { - // @ts-expect-error - this[a](key); - } - }; - - this.close = () => { - this.out.write(cursor.show); - this.in.removeListener("keypress", keypress); - if (this.in.isTTY) this.in.setRawMode(false); - rl.close(); - this.emit( - this.aborted ? "abort" : this.exited ? "exit" : "submit", - // @ts-expect-error - this.value - ); - this.closed = true; - }; - - this.in.on("keypress", keypress); - } - - get type(): string { - throw new Error("Method type not implemented."); - } - - bell() { - this.out.write(beep); - } - - fire() { - this.emit("state", { - // @ts-expect-error - value: this.value, - aborted: !!this.aborted, - exited: !!this.exited, - }); - } - - render() { - this.onRender(color); - if (this.firstRender) this.firstRender = false; - } - - _(c: string, key: ActionKey) { - throw new Error("Method _ not implemented."); - } -} - -export interface PromptOptions { - stdin?: typeof process.stdin; - stdout?: typeof process.stdout; - onRender?(render: (...text: unknown[]) => string): void; - onSubmit?( - v: any - ): void | undefined | boolean | Promise; - onCancel?( - v: any - ): void | undefined | boolean | Promise; - onAbort?( - v: any - ): void | undefined | boolean | Promise; - onExit?( - v: any - ): void | undefined | boolean | Promise; - onState?( - v: any - ): void | undefined | boolean | Promise; -} diff --git a/packages/create-remix/prompts-select.ts b/packages/create-remix/prompts-select.ts deleted file mode 100644 index 2a1eb4c77ac..00000000000 --- a/packages/create-remix/prompts-select.ts +++ /dev/null @@ -1,221 +0,0 @@ -/** - * Adapted from https://github.com/withastro/cli-kit - * @license MIT License Copyright (c) 2022 Nate Moore - */ -import { cursor, erase } from "sisteransi"; - -import { Prompt, type PromptOptions } from "./prompts-prompt-base"; -import { color, strip, clear, shouldUseAscii, type ActionKey } from "./utils"; - -export interface SelectChoice { - value: unknown; - label: string; - hint?: string; -} - -export interface SelectPromptOptions< - Choices extends Readonly[]> -> extends PromptOptions { - hint?: string; - message: string; - label: string; - initial?: Choices[number]["value"] | undefined; - validate?: (v: any) => boolean; - error?: string; - choices: Choices; -} - -export class SelectPrompt< - Choices extends Readonly[]> -> extends Prompt { - choices: Choices; - label: string; - msg: string; - hint?: string; - value: Choices[number]["value"] | undefined; - initialValue: Choices[number]["value"]; - search: string | null; - done: boolean | undefined; - cursor: number; - name = "SelectPrompt" as const; - private _timeout: NodeJS.Timeout | undefined; - - // set by render which is called in constructor - outputText!: string; - - constructor(opts: SelectPromptOptions) { - if ( - !opts.choices || - !Array.isArray(opts.choices) || - opts.choices.length < 1 - ) { - throw new Error("SelectPrompt must contain choices"); - } - super(opts); - this.label = opts.label; - this.hint = opts.hint; - this.msg = opts.message; - this.value = opts.initial; - this.choices = opts.choices; - this.initialValue = opts.initial || this.choices[0].value; - this.cursor = this.choices.findIndex((c) => c.value === this.initialValue); - this.search = null; - this.render(); - } - - get type() { - return "select" as const; - } - - exit() { - this.abort(); - } - - abort() { - this.done = this.aborted = true; - this.cursor = this.choices.findIndex((c) => c.value === this.initialValue); - this.fire(); - this.render(); - this.out.write("\n"); - this.close(); - } - - submit() { - this.value = this.value || undefined; - this.cursor = this.choices.findIndex((c) => c.value === this.value); - this.done = true; - this.aborted = false; - this.fire(); - this.render(); - this.out.write("\n"); - this.close(); - } - - delete() { - this.search = null; - this.render(); - } - - _(c: string, key: ActionKey) { - if (this._timeout) clearTimeout(this._timeout); - if (!Number.isNaN(Number.parseInt(c))) { - let n = Number.parseInt(c) - 1; - this.moveCursor(n); - this.render(); - return this.submit(); - } - this.search = this.search || ""; - this.search += c.toLowerCase(); - let choices = !this.search ? this.choices.slice(this.cursor) : this.choices; - let n = choices.findIndex((c) => - c.label.toLowerCase().includes(this.search!) - ); - if (n > -1) { - this.moveCursor(n); - this.render(); - } - this._timeout = setTimeout(() => { - this.search = null; - }, 500); - } - - moveCursor(n: number) { - this.cursor = n; - this.value = this.choices[n].value; - this.fire(); - } - - reset() { - this.moveCursor(0); - this.fire(); - this.render(); - } - - first() { - this.moveCursor(0); - this.render(); - } - - last() { - this.moveCursor(this.choices.length - 1); - this.render(); - } - - up() { - if (this.cursor === 0) { - this.moveCursor(this.choices.length - 1); - } else { - this.moveCursor(this.cursor - 1); - } - this.render(); - } - - down() { - if (this.cursor === this.choices.length - 1) { - this.moveCursor(0); - } else { - this.moveCursor(this.cursor + 1); - } - this.render(); - } - - highlight(label: string) { - if (!this.search) return label; - let n = label.toLowerCase().indexOf(this.search.toLowerCase()); - if (n === -1) return label; - return [ - label.slice(0, n), - color.underline(label.slice(n, n + this.search.length)), - label.slice(n + this.search.length), - ].join(""); - } - - render() { - if (this.closed) return; - if (this.firstRender) this.out.write(cursor.hide); - else this.out.write(clear(this.outputText, this.out.columns)); - super.render(); - - let outputText = [ - "\n", - this.label, - " ", - this.msg, - this.done - ? "" - : this.hint - ? (this.out.columns < 80 ? "\n" + " ".repeat(8) : "") + - color.dim(` (${this.hint})`) - : "", - "\n", - ]; - - let prefix = " ".repeat(strip(this.label).length); - - if (this.done) { - outputText.push( - `${prefix} `, - color.dim(`${this.choices[this.cursor]?.label}`) - ); - } else { - outputText.push( - this.choices - .map((choice, i) => - i === this.cursor - ? `${prefix} ${color.green( - shouldUseAscii() ? ">" : "●" - )} ${this.highlight(choice.label)} ${ - choice.hint ? color.dim(choice.hint) : "" - }` - : color.dim( - `${prefix} ${shouldUseAscii() ? "—" : "○"} ${choice.label} ` - ) - ) - .join("\n") - ); - } - this.outputText = outputText.join(""); - - this.out.write(erase.line + cursor.to(0) + this.outputText); - } -} diff --git a/packages/create-remix/prompts-text.ts b/packages/create-remix/prompts-text.ts deleted file mode 100644 index 146ea04a6d3..00000000000 --- a/packages/create-remix/prompts-text.ts +++ /dev/null @@ -1,284 +0,0 @@ -/** - * Adapted from https://github.com/withastro/cli-kit - * @license MIT License Copyright (c) 2022 Nate Moore - */ -import { cursor, erase } from "sisteransi"; - -import { Prompt, type PromptOptions } from "./prompts-prompt-base"; -import { - color, - strip, - clear, - lines, - shouldUseAscii, - type ActionKey, -} from "./utils"; - -export interface TextPromptOptions extends PromptOptions { - label: string; - message: string; - initial?: string; - style?: string; - validate?: (v: any) => v is string; - error?: string; - hint?: string; -} - -export class TextPrompt extends Prompt { - transform: { render: (v: string) => any; scale: number }; - label: string; - scale: number; - msg: string; - initial: string; - hint?: string; - validator: (v: any) => boolean | Promise; - errorMsg: string; - cursor: number; - cursorOffset: number; - clear: any; - done: boolean | undefined; - error: boolean | undefined; - red: boolean | undefined; - outputError: string | undefined; - name = "TextPrompt" as const; - - // set by value setter, value is set in constructor - _value!: string; - placeholder!: boolean; - rendered!: string; - - // set by render which is called in constructor - outputText!: string; - - constructor(opts: TextPromptOptions) { - super(opts); - this.transform = { render: (v) => v, scale: 1 }; - this.label = opts.label; - this.scale = this.transform.scale; - this.msg = opts.message; - this.hint = opts.hint; - this.initial = opts.initial || ""; - this.validator = opts.validate || (() => true); - this.value = ""; - this.errorMsg = opts.error || "Please enter a valid value"; - this.cursor = Number(!!this.initial); - this.cursorOffset = 0; - this.clear = clear(``, this.out.columns); - this.render(); - } - - get type() { - return "text" as const; - } - - set value(v: string) { - if (!v && this.initial) { - this.placeholder = true; - this.rendered = color.dim(this.initial); - } else { - this.placeholder = false; - this.rendered = this.transform.render(v); - } - this._value = v; - this.fire(); - } - - get value() { - return this._value; - } - - reset() { - this.value = ""; - this.cursor = Number(!!this.initial); - this.cursorOffset = 0; - this.fire(); - this.render(); - } - - exit() { - this.abort(); - } - - abort() { - this.value = this.value || this.initial; - this.done = this.aborted = true; - this.error = false; - this.red = false; - this.fire(); - this.render(); - this.out.write("\n"); - this.close(); - } - - async validate() { - let valid = await this.validator(this.value); - if (typeof valid === `string`) { - this.errorMsg = valid; - valid = false; - } - this.error = !valid; - } - - async submit() { - this.value = this.value || this.initial; - this.cursorOffset = 0; - this.cursor = this.rendered.length; - await this.validate(); - if (this.error) { - this.red = true; - this.fire(); - this.render(); - return; - } - this.done = true; - this.aborted = false; - this.fire(); - this.render(); - this.out.write("\n"); - this.close(); - } - - next() { - if (!this.placeholder) return this.bell(); - this.value = this.initial; - this.cursor = this.rendered.length; - this.fire(); - this.render(); - } - - moveCursor(n: number) { - if (this.placeholder) return; - this.cursor = this.cursor + n; - this.cursorOffset += n; - } - - _(c: string, key: ActionKey) { - let s1 = this.value.slice(0, this.cursor); - let s2 = this.value.slice(this.cursor); - this.value = `${s1}${c}${s2}`; - this.red = false; - this.cursor = this.placeholder ? 0 : s1.length + 1; - this.render(); - } - - delete() { - if (this.isCursorAtStart()) return this.bell(); - let s1 = this.value.slice(0, this.cursor - 1); - let s2 = this.value.slice(this.cursor); - this.value = `${s1}${s2}`; - this.red = false; - this.outputError = ""; - this.error = false; - if (this.isCursorAtStart()) { - this.cursorOffset = 0; - } else { - this.cursorOffset++; - this.moveCursor(-1); - } - this.render(); - } - - deleteForward() { - if (this.cursor * this.scale >= this.rendered.length || this.placeholder) - return this.bell(); - let s1 = this.value.slice(0, this.cursor); - let s2 = this.value.slice(this.cursor + 1); - this.value = `${s1}${s2}`; - this.red = false; - this.outputError = ""; - this.error = false; - if (this.isCursorAtEnd()) { - this.cursorOffset = 0; - } else { - this.cursorOffset++; - } - this.render(); - } - - first() { - this.cursor = 0; - this.render(); - } - - last() { - this.cursor = this.value.length; - this.render(); - } - - left() { - if (this.cursor <= 0 || this.placeholder) return this.bell(); - this.moveCursor(-1); - this.render(); - } - - right() { - if (this.cursor * this.scale >= this.rendered.length || this.placeholder) - return this.bell(); - this.moveCursor(1); - this.render(); - } - - isCursorAtStart() { - return this.cursor === 0 || (this.placeholder && this.cursor === 1); - } - - isCursorAtEnd() { - return ( - this.cursor === this.rendered.length || - (this.placeholder && this.cursor === this.rendered.length + 1) - ); - } - - render() { - if (this.closed) return; - if (!this.firstRender) { - if (this.outputError) - this.out.write( - cursor.down(lines(this.outputError, this.out.columns) - 1) + - clear(this.outputError, this.out.columns) - ); - this.out.write(clear(this.outputText, this.out.columns)); - } - super.render(); - this.outputError = ""; - - let prefix = " ".repeat(strip(this.label).length); - - this.outputText = [ - "\n", - this.label, - " ", - this.msg, - this.done - ? "" - : this.hint - ? (this.out.columns < 80 ? "\n" + " ".repeat(8) : "") + - color.dim(` (${this.hint})`) - : "", - "\n" + prefix, - " ", - this.done ? color.dim(this.rendered) : this.rendered, - ].join(""); - - if (this.error) { - this.outputError += ` ${color.redBright( - (shouldUseAscii() ? "> " : "▶ ") + this.errorMsg - )}`; - } - - this.out.write( - erase.line + - cursor.to(0) + - this.outputText + - cursor.save + - this.outputError + - cursor.restore + - cursor.move( - this.placeholder - ? (this.rendered.length - 9) * -1 - : this.cursorOffset, - 0 - ) - ); - } -} diff --git a/packages/create-remix/utils.ts b/packages/create-remix/utils.ts deleted file mode 100644 index f2b6c5e5c39..00000000000 --- a/packages/create-remix/utils.ts +++ /dev/null @@ -1,305 +0,0 @@ -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; - -export const color = { - supportsColor: SUPPORTS_COLOR, - heading: safeColor(chalk.bold), - arg: safeColor(chalk.yellowBright), - error: safeColor(chalk.red), - warning: safeColor(chalk.yellow), - hint: safeColor(chalk.blue), - bold: safeColor(chalk.bold), - black: safeColor(chalk.black), - white: safeColor(chalk.white), - blue: safeColor(chalk.blue), - cyan: safeColor(chalk.cyan), - red: safeColor(chalk.red), - yellow: safeColor(chalk.yellow), - green: safeColor(chalk.green), - blackBright: safeColor(chalk.blackBright), - whiteBright: safeColor(chalk.whiteBright), - blueBright: safeColor(chalk.blueBright), - cyanBright: safeColor(chalk.cyanBright), - redBright: safeColor(chalk.redBright), - yellowBright: safeColor(chalk.yellowBright), - greenBright: safeColor(chalk.greenBright), - bgBlack: safeColor(chalk.bgBlack), - bgWhite: safeColor(chalk.bgWhite), - bgBlue: safeColor(chalk.bgBlue), - bgCyan: safeColor(chalk.bgCyan), - bgRed: safeColor(chalk.bgRed), - bgYellow: safeColor(chalk.bgYellow), - bgGreen: safeColor(chalk.bgGreen), - bgBlackBright: safeColor(chalk.bgBlackBright), - bgWhiteBright: safeColor(chalk.bgWhiteBright), - bgBlueBright: safeColor(chalk.bgBlueBright), - bgCyanBright: safeColor(chalk.bgCyanBright), - bgRedBright: safeColor(chalk.bgRedBright), - bgYellowBright: safeColor(chalk.bgYellowBright), - bgGreenBright: safeColor(chalk.bgGreenBright), - gray: safeColor(chalk.gray), - dim: safeColor(chalk.dim), - reset: safeColor(chalk.reset), - inverse: safeColor(chalk.inverse), - hex: (color: string) => safeColor(chalk.hex(color)), - underline: chalk.underline, -}; - -function safeColor(style: chalk.Chalk) { - return SUPPORTS_COLOR ? style : identity; -} - -export { type ActionKey }; - -const unicode = { enabled: os.platform() !== "win32" }; -export const shouldUseAscii = () => !unicode.enabled; - -export function isInteractive() { - // Support explicit override for testing purposes - if ("CREATE_REMIX_FORCE_INTERACTIVE" in process.env) { - return true; - } - - // Adapted from https://github.com/sindresorhus/is-interactive - return Boolean( - process.stdout.isTTY && - process.env.TERM !== "dumb" && - !("CI" in process.env) - ); -} - -export function log(message: string) { - return process.stdout.write(message + "\n"); -} - -export let stderr = process.stderr; -/** @internal Used to mock `process.stderr.write` for testing purposes */ -export function setStderr(writable: typeof process.stderr) { - stderr = writable; -} - -export function logError(message: string) { - return stderr.write(message + "\n"); -} - -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) => colorizeText(textPart)) - .join(""); - - if (process.stdout.columns < 80) { - logger( - `${" ".repeat(5)} ${colorizePrefix(symbol)} ${colorizePrefix(prefix)}` - ); - logger(`${" ".repeat(9)}${formattedText}`); - } else { - 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) { - logBullet(log, color.green, color.dim, "✔", text); -} - -export function error(prefix: string, text?: string | string[]) { - log(""); - logBullet(logError, color.red, color.error, "▲", prefix, text); -} - -export function sleep(ms: number) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export function toValidProjectName(projectName: string) { - if (isValidProjectName(projectName)) { - return projectName; - } - return projectName - .trim() - .toLowerCase() - .replace(/\s+/g, "-") - .replace(/^[._]/, "") - .replace(/[^a-z\d\-~]+/g, "-") - .replace(/^-+/, "") - .replace(/-+$/, ""); -} - -function isValidProjectName(projectName: string) { - return /^(?:@[a-z\d\-*~][a-z\d\-*._~]*\/)?[a-z\d\-~][a-z\d\-._~]*$/.test( - projectName - ); -} - -export function identity(v: V) { - return v; -} - -export function strip(str: string) { - let pattern = [ - "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)", - "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))", - ].join("|"); - let RGX = new RegExp(pattern, "g"); - return typeof str === "string" ? str.replace(RGX, "") : str; -} - -export function reverse(arr: T[]): T[] { - return [...arr].reverse(); -} - -export function isValidJsonObject(obj: any): obj is Record { - return !!(obj && typeof obj === "object" && !Array.isArray(obj)); -} - -export async function directoryExists(p: string) { - try { - let stat = await fs.promises.stat(p); - return stat.isDirectory(); - } catch { - return false; - } -} - -export async function fileExists(p: string) { - try { - let stat = await fs.promises.stat(p); - return stat.isFile(); - } catch { - return false; - } -} - -export async function ensureDirectory(dir: string) { - if (!(await directoryExists(dir))) { - await fs.promises.mkdir(dir, { recursive: true }); - } -} - -export function pathContains(path: string, dir: string) { - let relative = path.replace(dir, ""); - return relative.length < path.length && !relative.startsWith(".."); -} - -export function isUrl(value: string | URL) { - try { - new URL(value); - return true; - } catch (_) { - return false; - } -} - -export function clear(prompt: string, perLine: number) { - if (!perLine) return erase.line + cursor.to(0); - let rows = 0; - let lines = prompt.split(/\r?\n/); - for (let line of lines) { - rows += 1 + Math.floor(Math.max(strip(line).length - 1, 0) / perLine); - } - - return erase.lines(rows); -} - -export function lines(msg: string, perLine: number) { - let lines = String(strip(msg) || "").split(/\r?\n/); - if (!perLine) return lines.length; - return lines - .map((l) => Math.ceil(l.length / perLine)) - .reduce((a, b) => a + b); -} - -export function action(key: ActionKey, isSelect: boolean) { - if (key.meta && key.name !== "escape") return; - - if (key.ctrl) { - if (key.name === "a") return "first"; - if (key.name === "c") return "abort"; - if (key.name === "d") return "abort"; - if (key.name === "e") return "last"; - if (key.name === "g") return "reset"; - } - - if (isSelect) { - if (key.name === "j") return "down"; - if (key.name === "k") return "up"; - } - - if (key.name === "return") return "submit"; - if (key.name === "enter") return "submit"; // ctrl + J - if (key.name === "backspace") return "delete"; - if (key.name === "delete") return "deleteForward"; - if (key.name === "abort") return "abort"; - if (key.name === "escape") return "exit"; - if (key.name === "tab") return "next"; - if (key.name === "pagedown") return "nextPage"; - if (key.name === "pageup") return "prevPage"; - if (key.name === "home") return "home"; - if (key.name === "end") return "end"; - - if (key.name === "up") return "up"; - if (key.name === "down") return "down"; - if (key.name === "right") return "right"; - if (key.name === "left") return "left"; - - 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/pnpm-lock.yaml b/pnpm-lock.yaml index 0a4c2eb50c0..6ba439a763d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -751,73 +751,9 @@ importers: packages/create-remix: dependencies: - '@remix-run/web-fetch': - specifier: ^4.4.2 - version: 4.4.2 - arg: - specifier: ^5.0.1 - version: 5.0.2 - chalk: - specifier: ^4.1.2 - version: 4.1.2 - execa: - specifier: 5.1.1 - version: 5.1.1 - fs-extra: - specifier: ^10.0.0 - version: 10.1.0 - gunzip-maybe: - specifier: ^1.4.2 - version: 1.4.2 - log-update: - specifier: ^5.0.1 - version: 5.0.1 - proxy-agent: - specifier: ^6.3.0 - version: 6.4.0 - recursive-readdir: - specifier: ^2.2.3 - version: 2.2.3 - rimraf: - specifier: ^4.1.2 - version: 4.4.1 - semver: - specifier: ^7.3.7 - version: 7.5.4 - sisteransi: - specifier: ^1.0.5 - version: 1.0.5 - sort-package-json: - specifier: ^1.55.0 - version: 1.57.0 - strip-ansi: - specifier: ^6.0.1 - version: 6.0.1 - tar-fs: - specifier: ^2.1.1 - version: 2.1.1 - devDependencies: - '@types/gunzip-maybe': - specifier: ^1.4.0 - version: 1.4.2 - '@types/recursive-readdir': - specifier: ^2.2.1 - version: 2.2.4 - '@types/tar-fs': - specifier: ^2.0.1 - version: 2.0.4 - esbuild: - specifier: 0.17.6 - version: 0.17.6 - esbuild-register: - specifier: ^3.3.2 - version: 3.5.0(esbuild@0.17.6) - msw: - specifier: ^1.2.3 - version: 1.3.2(typescript@5.1.6) - tiny-invariant: - specifier: ^1.2.0 - version: 1.3.1 + cross-spawn: + specifier: ^7.0.6 + version: 7.0.6 packages/remix: {} @@ -4948,10 +4884,6 @@ packages: engines: {node: '>= 10'} dev: true - /@tootallnate/quickjs-emscripten@0.23.0: - resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==} - dev: false - /@types/acorn@4.0.6: resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} dependencies: @@ -5311,12 +5243,6 @@ packages: '@types/scheduler': 0.16.2 csstype: 3.1.1 - /@types/recursive-readdir@2.2.4: - resolution: {integrity: sha512-84REEGT3lcgopvpkmGApzmU5UEG0valme5rQS/KGiguTkJ70/Au8UYZTyrzoZnY9svuX9351+1uvrRPzWDD/uw==} - dependencies: - '@types/node': 18.17.1 - dev: true - /@types/resolve@1.17.1: resolution: {integrity: sha512-yy7HuzQhj0dhGpD8RLXSZWEkLsV9ibvxvi6EiJ3bkqLAO1RGo0WbkWQiwpRlSFymTJRz0d3k5LM3kkx8ArDbLw==} dependencies: @@ -5747,15 +5673,6 @@ packages: - supports-color dev: true - /agent-base@7.1.0: - resolution: {integrity: sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==} - engines: {node: '>= 14'} - dependencies: - debug: 4.4.0(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - dev: false - /aggregate-error@3.1.0: resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} engines: {node: '>=8'} @@ -5907,13 +5824,6 @@ packages: dependencies: type-fest: 0.21.3 - /ansi-escapes@5.0.0: - resolution: {integrity: sha512-5GFMVX8HqE/TB+FuBJGuO5XG0WrsA6ptUqoODaT/n9mmUaZFkqnBueB4leqGBCmrUHnCnC4PCZTCd0E7QQ83bA==} - engines: {node: '>=12'} - dependencies: - type-fest: 1.4.0 - dev: false - /ansi-escapes@6.2.0: resolution: {integrity: sha512-kzRaCqXnpzWs+3z5ABPQiVke+iq0KXkHo8xiWV4RPTi5Yli0l97BEQuhXV1s7+aSU/fu1kUuxgS4MsQ0fRuygw==} engines: {node: '>=14.16'} @@ -6225,13 +6135,6 @@ packages: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} dev: false - /ast-types@0.13.4: - resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} - engines: {node: '>=4'} - dependencies: - tslib: 2.6.2 - dev: false - /astral-regex@2.0.0: resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} engines: {node: '>=8'} @@ -6415,11 +6318,6 @@ packages: safe-buffer: 5.1.2 dev: false - /basic-ftp@5.0.4: - resolution: {integrity: sha512-8PzkB0arJFV4jJWSGOYR+OEic6aeKMu/osRhBULN6RY0ykby6LKhbmuQ5ublvaas5BOwboah5D87nrHyuh8PPA==} - engines: {node: '>=10.0.0'} - dev: false - /bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} dependencies: @@ -6791,13 +6689,6 @@ packages: dependencies: restore-cursor: 3.1.0 - /cli-cursor@4.0.0: - resolution: {integrity: sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - dependencies: - restore-cursor: 4.0.0 - dev: false - /cli-spinners@2.9.2: resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} engines: {node: '>=6'} @@ -7066,6 +6957,14 @@ packages: shebang-command: 2.0.0 which: 2.0.2 + /cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + /csrf@3.1.0: resolution: {integrity: sha512-uTqEnCvWRk042asU6JtapDTcJeeailFy4ydOQS28bj1hcLnYRiqi8SsD2jS412AY1I/4qdOwWZun774iqywf9w==} engines: {node: '>= 0.8'} @@ -7198,11 +7097,6 @@ packages: engines: {node: '>= 6'} dev: false - /data-uri-to-buffer@6.0.2: - resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} - engines: {node: '>= 14'} - dev: false - /data-urls@4.0.0: resolution: {integrity: sha512-/mMTei/JXPqvFqQtfyTowxmJVwr2PVAeCcDxyFf6LhoOu/09TX2OX3kb2wzi4DMXcfj4OItwDOnhl5oziPnT6g==} engines: {node: '>=14'} @@ -7376,15 +7270,6 @@ packages: /defu@6.1.4: resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} - /degenerator@5.0.1: - resolution: {integrity: sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ==} - engines: {node: '>= 14'} - dependencies: - ast-types: 0.13.4 - escodegen: 2.1.0 - esprima: 4.0.1 - dev: false - /delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -7891,18 +7776,6 @@ packages: engines: {node: '>=12'} dev: false - /escodegen@2.1.0: - resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} - engines: {node: '>=6.0'} - hasBin: true - dependencies: - esprima: 4.0.1 - estraverse: 5.3.0 - esutils: 2.0.3 - optionalDependencies: - source-map: 0.6.1 - dev: false - /eslint-import-resolver-node@0.3.7: resolution: {integrity: sha512-gozW2blMLJCeFpBwugLTGyvVjNoeo1knonXAcatC6bjPBZitotxdWf7Gimr25N4c0AAOo4eOUfaG82IJPDpqCA==} dependencies: @@ -8435,7 +8308,7 @@ packages: resolution: {integrity: sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==} engines: {node: '>=10'} dependencies: - cross-spawn: 7.0.3 + cross-spawn: 7.0.6 get-stream: 5.2.0 human-signals: 1.1.1 is-stream: 2.0.1 @@ -8719,7 +8592,7 @@ packages: resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==} engines: {node: '>=14'} dependencies: - cross-spawn: 7.0.3 + cross-spawn: 7.0.6 signal-exit: 4.1.0 /forever-agent@0.6.1: @@ -8785,15 +8658,6 @@ packages: universalify: 2.0.0 dev: false - /fs-extra@11.2.0: - resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==} - engines: {node: '>=14.14'} - dependencies: - graceful-fs: 4.2.11 - jsonfile: 6.1.0 - universalify: 2.0.0 - dev: false - /fs-extra@7.0.1: resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} engines: {node: '>=6 <7 || >=8'} @@ -8936,18 +8800,6 @@ packages: resolve-pkg-maps: 1.0.0 dev: false - /get-uri@6.0.3: - resolution: {integrity: sha512-BzUrJBS9EcUb4cFol8r4W3v1cPsSyajLSthNkz5BxbpDcHN5tIrM10E2eNvfnvBn3DaT3DUgx0OpsBKkaOpanw==} - engines: {node: '>= 14'} - dependencies: - basic-ftp: 5.0.4 - data-uri-to-buffer: 6.0.2 - debug: 4.4.0(supports-color@8.1.1) - fs-extra: 11.2.0 - transitivePeerDependencies: - - supports-color - dev: false - /getos@3.2.1: resolution: {integrity: sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==} dependencies: @@ -8959,10 +8811,6 @@ packages: assert-plus: 1.0.0 dev: false - /git-hooks-list@1.0.3: - resolution: {integrity: sha512-Y7wLWcrLUXwk2noSka166byGCvhMtDRpgHdzCno1UQv/n/Hegp++a2xBWJL1lJarnKD3SWaljD+0z1ztqxuKyQ==} - dev: false - /glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -9012,16 +8860,6 @@ packages: once: 1.4.0 dev: false - /glob@9.3.5: - resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} - engines: {node: '>=16 || 14 >=14.17'} - dependencies: - fs.realpath: 1.0.0 - minimatch: 8.0.4 - minipass: 4.2.8 - path-scurry: 1.10.1 - dev: false - /global-dirs@3.0.0: resolution: {integrity: sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA==} engines: {node: '>=10'} @@ -9046,20 +8884,6 @@ packages: define-properties: 1.2.1 dev: false - /globby@10.0.0: - resolution: {integrity: sha512-3LifW9M4joGZasyYPz2A1U74zbC/45fvpXUvO/9KbSa+VV0aGZarWkfdgKyR9sExNP0t0x0ss/UMJpNpcaTspw==} - engines: {node: '>=8'} - dependencies: - '@types/glob': 7.2.0 - array-union: 2.1.0 - dir-glob: 3.0.1 - fast-glob: 3.2.11 - glob: 7.2.0 - ignore: 5.3.1 - merge2: 1.4.1 - slash: 3.0.0 - dev: false - /globby@10.0.1: resolution: {integrity: sha512-sSs4inE1FB2YQiymcmTv6NWENryABjUNPeWhOvmn4SjtKybglsyPZxFB3U1/+L1bYi0rNZDqCLlHyLYDl1Pq5A==} engines: {node: '>=8'} @@ -9292,16 +9116,6 @@ packages: - supports-color dev: true - /http-proxy-agent@7.0.1: - resolution: {integrity: sha512-My1KCEPs6A0hb4qCVzYp8iEvA8j8YqcvXLZZH8C9OFuTYpYjHE7N2dtG3mRl1HMD4+VGXpF3XcDVcxGBT7yDZQ==} - engines: {node: '>= 14'} - dependencies: - agent-base: 7.1.0 - debug: 4.4.0(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - dev: false - /http-signature@1.3.6: resolution: {integrity: sha512-3adrsD6zqo4GsTqtO7FyrejHNv+NgiIfAfv68+jVlFmSr9OGy7zrxONceFRLKvnnZA5jbxQBX1u9PpB6Wi32Gw==} engines: {node: '>=0.10'} @@ -9321,16 +9135,6 @@ packages: - supports-color dev: true - /https-proxy-agent@7.0.3: - resolution: {integrity: sha512-kCnwztfX0KZJSLOBrcL0emLeFako55NWMovvyPP2AjsghNk9RB1yjSI+jVumPHYZsNXegNoqupSW9IY3afSH8w==} - engines: {node: '>= 14'} - dependencies: - agent-base: 7.1.0 - debug: 4.4.0(supports-color@8.1.1) - transitivePeerDependencies: - - supports-color - dev: false - /human-id@1.0.2: resolution: {integrity: sha512-UNopramDEhHJD+VR+ehk8rOslwSfByxPIZyJRfV739NDhN5LF1fa1MqnzKm2lGTQRjNrjK19Q5fhkgIfjlVUKw==} dev: false @@ -9455,14 +9259,6 @@ packages: engines: {node: '>= 0.10'} dev: false - /ip-address@9.0.5: - resolution: {integrity: sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==} - engines: {node: '>= 12'} - dependencies: - jsbn: 1.1.0 - sprintf-js: 1.1.3 - dev: false - /ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -9652,11 +9448,6 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} - /is-fullwidth-code-point@4.0.0: - resolution: {integrity: sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==} - engines: {node: '>=12'} - dev: false - /is-generator-fn@2.1.0: resolution: {integrity: sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==} engines: {node: '>=6'} @@ -9747,11 +9538,6 @@ packages: engines: {node: '>=0.10.0'} dev: false - /is-plain-obj@2.1.0: - resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} - engines: {node: '>=8'} - dev: false - /is-plain-obj@3.0.0: resolution: {integrity: sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==} engines: {node: '>=10'} @@ -10442,10 +10228,6 @@ packages: resolution: {integrity: sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==} dev: false - /jsbn@1.1.0: - resolution: {integrity: sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==} - dev: false - /jsdom@22.1.0: resolution: {integrity: sha512-/9AVW7xNbsBv6GfWho4TTNjEo9fe6Zhf9O7s0Fhhr3u+awPwAJMKwAMXnkk5vBxflqLW9hTHX/0cs+P3gW+cQw==} engines: {node: '>=16'} @@ -10795,17 +10577,6 @@ packages: wrap-ansi: 6.2.0 dev: false - /log-update@5.0.1: - resolution: {integrity: sha512-5UtUDQ/6edw4ofyljDNcOVJQ4c7OjDro4h3y8e1GQL5iYElYclVHJ3zeWchylvMaKnDbDilC8irOVyexnA/Slw==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - dependencies: - ansi-escapes: 5.0.0 - cli-cursor: 4.0.0 - slice-ansi: 5.0.0 - strip-ansi: 7.1.0 - wrap-ansi: 8.1.0 - dev: false - /log-utils@0.2.1: resolution: {integrity: sha512-udyegKoMz9eGfpKAX//Khy7sVAZ8b1F7oLDnepZv/1/y8xTvsyPgqQrM94eG8V0vcc2BieYI2kVW4+aa6m+8Qw==} engines: {node: '>=0.10.0'} @@ -11954,13 +11725,6 @@ packages: brace-expansion: 2.0.1 dev: false - /minimatch@8.0.4: - resolution: {integrity: sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==} - engines: {node: '>=16 || 14 >=14.17'} - dependencies: - brace-expansion: 2.0.1 - dev: false - /minimatch@9.0.3: resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} engines: {node: '>=16 || 14 >=14.17'} @@ -12008,11 +11772,6 @@ packages: yallist: 4.0.0 dev: false - /minipass@4.2.8: - resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} - engines: {node: '>=8'} - dev: false - /minipass@5.0.0: resolution: {integrity: sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==} engines: {node: '>=8'} @@ -12164,11 +11923,6 @@ packages: resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} engines: {node: '>= 0.6'} - /netmask@2.0.2: - resolution: {integrity: sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg==} - engines: {node: '>= 0.4.0'} - dev: false - /nice-try@1.0.5: resolution: {integrity: sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==} dev: false @@ -12521,30 +12275,6 @@ packages: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - /pac-proxy-agent@7.0.1: - resolution: {integrity: sha512-ASV8yU4LLKBAjqIPMbrgtaKIvxQri/yh2OpI+S6hVa9JRkUI3Y3NPFbfngDtY7oFtSMD3w31Xns89mDa3Feo5A==} - engines: {node: '>= 14'} - dependencies: - '@tootallnate/quickjs-emscripten': 0.23.0 - agent-base: 7.1.0 - debug: 4.4.0(supports-color@8.1.1) - get-uri: 6.0.3 - http-proxy-agent: 7.0.1 - https-proxy-agent: 7.0.3 - pac-resolver: 7.0.1 - socks-proxy-agent: 8.0.2 - transitivePeerDependencies: - - supports-color - dev: false - - /pac-resolver@7.0.1: - resolution: {integrity: sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg==} - engines: {node: '>= 14'} - dependencies: - degenerator: 5.0.1 - netmask: 2.0.2 - dev: false - /pako@0.2.9: resolution: {integrity: sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==} dev: false @@ -13081,30 +12811,10 @@ packages: forwarded: 0.2.0 ipaddr.js: 1.9.1 - /proxy-agent@6.4.0: - resolution: {integrity: sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ==} - engines: {node: '>= 14'} - dependencies: - agent-base: 7.1.0 - debug: 4.4.0(supports-color@8.1.1) - http-proxy-agent: 7.0.1 - https-proxy-agent: 7.0.3 - lru-cache: 7.18.3 - pac-proxy-agent: 7.0.1 - proxy-from-env: 1.1.0 - socks-proxy-agent: 8.0.2 - transitivePeerDependencies: - - supports-color - dev: false - /proxy-from-env@1.0.0: resolution: {integrity: sha512-F2JHgJQ1iqwnHDcQjVBsq3n/uoaFL+iPW/eAeL7kVxy/2RrWaN4WroKjjvbsoRtv0ftelNyC01bjRhn/bhcf4A==} dev: false - /proxy-from-env@1.1.0: - resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} - dev: false - /pseudomap@1.0.2: resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} dev: false @@ -13353,13 +13063,6 @@ packages: resolve: 1.22.8 dev: false - /recursive-readdir@2.2.3: - resolution: {integrity: sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA==} - engines: {node: '>=6.0.0'} - dependencies: - minimatch: 3.1.2 - dev: false - /redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} engines: {node: '>=8'} @@ -13613,14 +13316,6 @@ packages: onetime: 5.1.2 signal-exit: 3.0.7 - /restore-cursor@4.0.0: - resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} - engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - dependencies: - onetime: 5.1.2 - signal-exit: 3.0.7 - dev: false - /retry@0.12.0: resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} engines: {node: '>= 4'} @@ -13641,14 +13336,6 @@ packages: dependencies: glob: 7.2.0 - /rimraf@4.4.1: - resolution: {integrity: sha512-Gk8NlF062+T9CqNGn6h4tls3k6T1+/nXdOcSZVikNVtlRdYpA7wRJJMoXmuvOnLW844rPjdQ7JgXCYM6PPC/og==} - engines: {node: '>=14'} - hasBin: true - dependencies: - glob: 9.3.5 - dev: false - /rndm@1.2.0: resolution: {integrity: sha512-fJhQQI5tLrQvYIYFpOnFinzv9dwmR7hRnUz1XqP3OJ1jIweTNOd6aTO4jwQSgcBSFUB+/KHJxuGneime+FdzOw==} dev: false @@ -14006,19 +13693,6 @@ packages: is-fullwidth-code-point: 3.0.0 dev: false - /slice-ansi@5.0.0: - resolution: {integrity: sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==} - engines: {node: '>=12'} - dependencies: - ansi-styles: 6.2.1 - is-fullwidth-code-point: 4.0.0 - dev: false - - /smart-buffer@4.2.0: - resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} - engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} - dev: false - /smartwrap@2.0.2: resolution: {integrity: sha512-vCsKNQxb7PnCNd2wY1WClWifAc2lwqsG8OaswpJkVJsvMGcnEntdTCDajZCkk93Ay1U3t/9puJmb525Rg5MZBA==} engines: {node: '>=6'} @@ -14032,42 +13706,6 @@ packages: yargs: 15.4.1 dev: false - /socks-proxy-agent@8.0.2: - resolution: {integrity: sha512-8zuqoLv1aP/66PHF5TqwJ7Czm3Yv32urJQHrVyhD7mmA6d61Zv8cIXQYPTWwmg6qlupnPvs/QKDmfa4P/qct2g==} - engines: {node: '>= 14'} - dependencies: - agent-base: 7.1.0 - debug: 4.4.0(supports-color@8.1.1) - socks: 2.7.3 - transitivePeerDependencies: - - supports-color - dev: false - - /socks@2.7.3: - resolution: {integrity: sha512-vfuYK48HXCTFD03G/1/zkIls3Ebr2YNa4qU9gHDZdblHLiqhJrJGkY3+0Nx0JpN9qBhJbVObc1CNciT1bIZJxw==} - engines: {node: '>= 10.0.0', npm: '>= 3.0.0'} - deprecated: please use 2.7.4 or 2.8.1 to fix package-lock issue - dependencies: - ip-address: 9.0.5 - smart-buffer: 4.2.0 - dev: false - - /sort-object-keys@1.1.3: - resolution: {integrity: sha512-855pvK+VkU7PaKYPc+Jjnmt4EzejQHyhhF33q31qG8x7maDzkeFhAAThdCYay11CISO+qAMwjOBP+fPZe0IPyg==} - dev: false - - /sort-package-json@1.57.0: - resolution: {integrity: sha512-FYsjYn2dHTRb41wqnv+uEqCUvBpK3jZcTp9rbz2qDTmel7Pmdtf+i2rLaaPMRZeSVM60V3Se31GyWFpmKs4Q5Q==} - hasBin: true - dependencies: - detect-indent: 6.1.0 - detect-newline: 3.1.0 - git-hooks-list: 1.0.3 - globby: 10.0.0 - is-plain-obj: 2.1.0 - sort-object-keys: 1.1.3 - dev: false - /source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -14138,10 +13776,6 @@ packages: /sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} - /sprintf-js@1.1.3: - resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} - dev: false - /sshpk@1.17.0: resolution: {integrity: sha512-/9HIEs1ZXGhSPE8X6Ccm7Nam1z8KcoCqPdI7ecm1N33EzAetWahvQWVqLZtaZQ+IDKX4IyA2o0gBzqIMkAagHQ==} engines: {node: '>=0.10.0'} @@ -14804,11 +14438,6 @@ packages: engines: {node: '>=8'} dev: false - /type-fest@1.4.0: - resolution: {integrity: sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==} - engines: {node: '>=10'} - dev: false - /type-fest@2.19.0: resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} engines: {node: '>=12.20'}