diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 20dfd8d..a08a70d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,9 @@ jobs: - name: NPM install run: npm ci + - name: Run tests + run: npm test + - name: Pack (includes rescript build) run: npm pack diff --git a/AGENTS.md b/AGENTS.md index ddbd1d6..9457339 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,9 @@ - Make sure to use modern ReScript and not Reason syntax! Read https://rescript-lang.org/llms/manual/llm-small.txt to learn the language syntax. - Formatting is enforced by `rescript format`; keep 2-space indentation and prefer pattern matching over chained conditionals. +- Prefer `result` values over exceptions for expected failure paths; only raise or throw at clear integration boundaries where the surrounding API requires it. +- Prefer pattern matching over `if`/`else` chains when branching on the shape or state of the same value; plain comparisons across different values are fine with `if`/`else`. +- Do not run `rescript`, `npm test`, or `npm run prepack` in parallel; ReScript compiler artifacts are not safe for concurrent builds in this repo. - Module files are PascalCase (`Templates.res`), values/functions camelCase, types/variants PascalCase, and records snake_case fields only when matching external JSON. - Keep `.resi` signatures accurate and minimal; avoid exposing helpers that are template-specific. - When touching templates, mirror upstream defaults and keep package scripts consistent with the chosen toolchain. @@ -31,11 +34,13 @@ - **`npm start`** - Run CLI directly from source (`src/Main.res.mjs`) for interactive testing and development - **`npm run dev`** - Watch ReScript sources and rebuild automatically to `lib/` directory - **`npm run prepack`** - Compile ReScript and bundle with Rollup into `out/create-rescript-app.cjs` (production build) +- **`npm test`** - Compile ReScript sources and run the Node.js regression tests - **`npm run format`** - Apply ReScript formatter across all source files ## Testing and Validation -- **Manual Testing**: No automated test suite - perform smoke tests by running the CLI into a temp directory +- **Automated Tests**: Run `npm test` for automated coverage of CLI parsing and related helpers +- **Manual Testing**: Perform smoke tests by running the CLI into a temp directory - **Template Validation**: After changes, test each template type (basic/Next.js/Vite) to ensure templates bootstrap cleanly - **Build Verification**: Run `npm run prepack` to ensure the production bundle builds correctly diff --git a/README.md b/README.md index b0a895e..6985770 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,21 @@ or bun create rescript-app ``` +You can also skip the interactive prompts by passing a project name and template flag. +Supported templates are defined [`here`](./src/Templates.res). + +With npm, pass the template flag after `--`: + +```sh +npm create rescript-app@latest my-app -- --template vite +``` + +With Yarn, pnpm, and Bun, you can pass the template flag directly: + +```sh +yarn create rescript-app my-app --template vite +``` + ## Add to existing project If you have an existing JavaScript project containing a `package.json`, you can execute one of the above commands directly in your project's directory to add ReScript to your project. diff --git a/package.json b/package.json index f625729..e2f3064 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "start": "node src/Main.res.mjs", "prepack": "rescript && rollup -c", + "test": "rescript && node --test test/*Test.res.mjs", "format": "rescript format", "dev": "rescript -w" }, diff --git a/rescript.json b/rescript.json index a02508c..a2c9135 100644 --- a/rescript.json +++ b/rescript.json @@ -1,9 +1,16 @@ { "name": "create-rescript-app", - "sources": { - "dir": "src", - "subdirs": true - }, + "sources": [ + { + "dir": "src", + "subdirs": true + }, + { + "dir": "test", + "subdirs": true, + "type": "dev" + } + ], "package-specs": { "module": "esmodule", "in-source": true diff --git a/src/CommandLineArguments.res b/src/CommandLineArguments.res new file mode 100644 index 0000000..7ca208e --- /dev/null +++ b/src/CommandLineArguments.res @@ -0,0 +1,76 @@ +type t = { + projectName: option, + templateName: option, +} + +let supportedOptionsHint = `Supported options: --template <${Templates.supportedTemplateNames->Array.join( + "|", + )}> or -t <${Templates.supportedTemplateNames->Array.join("|")}>.` + +let getTemplateName = templateName => + switch Templates.getTemplateName(templateName) { + | Some(templateName) => Ok(templateName) + | None => + Error( + `Unknown template "${templateName}". Available templates: ${Templates.supportedTemplateNames->Array.join( + ", ", + )}.`, + ) + } + +let parseError = message => Error(`${message} ${supportedOptionsHint}`) + +let rec parseRemainingArguments = (remainingArguments, commandLineArguments) => + switch remainingArguments { + | list{} => Ok(commandLineArguments) + | list{"-t", templateName, ...remainingArguments} + | list{"--template", templateName, ...remainingArguments} => + switch getTemplateName(templateName) { + | Ok(templateName) => + parseRemainingArguments( + remainingArguments, + { + ...commandLineArguments, + templateName: Some(templateName), + }, + ) + | Error(message) => Error(message) + } + | list{"-t"} | list{"--template"} => parseError("Missing value for --template.") + | list{argument, ...remainingArguments} if argument->String.startsWith("--template=") => + switch argument->String.split("=") { + | [_, templateName] => + switch getTemplateName(templateName) { + | Ok(templateName) => + parseRemainingArguments( + remainingArguments, + { + ...commandLineArguments, + templateName: Some(templateName), + }, + ) + | Error(message) => Error(message) + } + | _ => parseError("Missing value for --template.") + } + | list{argument, ..._remainingArguments} if argument->String.startsWith("-") => + parseError(`Unknown option "${argument}".`) + | list{argument, ...remainingArguments} => + switch commandLineArguments.projectName { + | None => + parseRemainingArguments( + remainingArguments, + {...commandLineArguments, projectName: Some(argument)}, + ) + | Some(_) => parseError(`Unexpected argument "${argument}".`) + } + } + +let parse = remainingArguments => + parseRemainingArguments(remainingArguments, {projectName: None, templateName: None}) + +let fromProcessArgv = argv => + switch List.fromArray(argv) { + | list{_, _, ...remainingArguments} => parse(remainingArguments) + | _ => Ok({projectName: None, templateName: None}) + } diff --git a/src/CommandLineArguments.resi b/src/CommandLineArguments.resi new file mode 100644 index 0000000..6a059af --- /dev/null +++ b/src/CommandLineArguments.resi @@ -0,0 +1,7 @@ +type t = { + projectName: option, + templateName: option, +} + +let parse: list => result +let fromProcessArgv: array => result diff --git a/src/NewProject.res b/src/NewProject.res index cdc029e..772fd33 100644 --- a/src/NewProject.res +++ b/src/NewProject.res @@ -113,19 +113,33 @@ let createNewProject = async () => { ~versions={rescriptVersion: "11.1.1", rescriptCoreVersion: Some("1.5.0")}, ) } else { - let projectName = await P.text({ - message: "What is the name of your new ReScript project?", - placeholder: "my-rescript-app", - initialValue: ?Process.argv[2], - validate: validateProjectName, - })->P.resultOrRaise - - let templateName = await P.select({ - message: "Select a template", - options: getTemplateOptions(), - })->P.resultOrRaise - - let versions = await RescriptVersions.promptVersions() + let commandLineArguments = CommandLineArguments.fromProcessArgv(Process.argv)->Result.getOrThrow + let useDefaultVersions = Option.isSome(commandLineArguments.templateName) + + let projectName = switch commandLineArguments.projectName { + | Some(projectName) if useDefaultVersions => projectName->validateProjectName->Option.getOrThrow + + | initialValue => + await P.text({ + message: "What is the name of your new ReScript project?", + placeholder: "my-rescript-app", + ?initialValue, + validate: validateProjectName, + })->P.resultOrRaise + } + + let templateName = switch commandLineArguments.templateName { + | Some(templateName) => templateName + | None => + await P.select({ + message: "Select a template", + options: getTemplateOptions(), + })->P.resultOrRaise + } + + let versions = useDefaultVersions + ? await RescriptVersions.getDefaultVersions() + : await RescriptVersions.promptVersions() await createProject(~templateName, ~projectName, ~versions) } diff --git a/src/RescriptVersions.res b/src/RescriptVersions.res index 229598a..13d194f 100644 --- a/src/RescriptVersions.res +++ b/src/RescriptVersions.res @@ -11,6 +11,42 @@ type versions = {rescriptVersion: string, rescriptCoreVersion: option} let spinnerMessage = "Loading available versions..." +let makeVersions = rescriptVersion => { + let includesStdlib = CompareVersions.satisfies(rescriptVersion, includesStdlibVersionRange) + let rescriptCoreVersion = includesStdlib ? None : Some(finalRescriptCoreVersion) + + {rescriptVersion, rescriptCoreVersion} +} + +let getDefaultVersions = async () => { + let s = P.spinner() + + s->P.Spinner.start(spinnerMessage) + + let rescriptVersionsResult = await NpmRegistry.getPackageVersions( + "rescript", + rescriptVersionRange, + ) + + switch rescriptVersionsResult { + | Ok(_) => s->P.Spinner.stop("Versions loaded.") + | Error(_) => s->P.Spinner.stop(spinnerMessage) + } + + let rescriptVersion = switch rescriptVersionsResult { + | Ok([]) => JsError.throwWithMessage("No supported ReScript versions were found.") + | Ok([version]) => version + | Ok(rescriptVersions) => + switch rescriptVersions->Array.find(version => !(version->String.includes("-"))) { + | Some(version) => version + | None => rescriptVersions[0]->Option.getOrThrow + } + | Error(error) => error->NpmRegistry.getFetchErrorMessage->JsError.throwWithMessage + } + + makeVersions(rescriptVersion) +} + let promptVersions = async () => { let s = P.spinner() @@ -41,10 +77,7 @@ let promptVersions = async () => { | Error(error) => error->NpmRegistry.getFetchErrorMessage->JsError.throwWithMessage } - let includesStdlib = CompareVersions.satisfies(rescriptVersion, includesStdlibVersionRange) - let rescriptCoreVersion = includesStdlib ? None : Some(finalRescriptCoreVersion) - - {rescriptVersion, rescriptCoreVersion} + makeVersions(rescriptVersion) } let ensureYarnNodeModulesLinker = async () => { diff --git a/src/RescriptVersions.resi b/src/RescriptVersions.resi index 8f2c8d4..071c1dd 100644 --- a/src/RescriptVersions.resi +++ b/src/RescriptVersions.resi @@ -1,5 +1,7 @@ type versions = {rescriptVersion: string, rescriptCoreVersion: option} +let getDefaultVersions: unit => promise + let promptVersions: unit => promise let installVersions: versions => promise diff --git a/src/Templates.res b/src/Templates.res index 118446a..92c0b5b 100644 --- a/src/Templates.res +++ b/src/Templates.res @@ -5,15 +5,28 @@ type t = { } let basicTemplateName = "rescript-template-basic" +let viteTemplateName = "rescript-template-vite" +let nextjsTemplateName = "rescript-template-nextjs" +let templateNamePrefix = "rescript-template-" + +let supportedTemplateNames = ["vite", "nextjs", "basic"] + +let getTemplateName = templateName => { + let templateName = templateName->String.toLowerCase + + supportedTemplateNames + ->Array.find(supportedTemplateName => supportedTemplateName === templateName) + ->Option.map(_ => `${templateNamePrefix}${templateName}`) +} let templates = [ { - name: "rescript-template-vite", + name: viteTemplateName, displayName: "Vite", shortDescription: "Vite 7, React and Tailwind 4", }, { - name: "rescript-template-nextjs", + name: nextjsTemplateName, displayName: "Next.js", shortDescription: "Next.js 15 with static export and Tailwind 3", }, diff --git a/src/bindings/Node.res b/src/bindings/Node.res index 80c9b5e..6c74d70 100644 --- a/src/bindings/Node.res +++ b/src/bindings/Node.res @@ -63,6 +63,22 @@ module Process = { @scope("process") external exitWithCode: int => unit = "exit" } +module Assert = { + @module("node:assert/strict") + external strictEqual: ('a, 'a) => unit = "strictEqual" + + @module("node:assert/strict") + external fail: string => unit = "fail" +} + +module Test = { + @module("node:test") + external describe: (string, unit => unit) => unit = "describe" + + @module("node:test") + external test: (string, unit => unit) => unit = "test" +} + module Url = { type t diff --git a/test/CommandLineArgumentsTest.res b/test/CommandLineArgumentsTest.res new file mode 100644 index 0000000..70aa407 --- /dev/null +++ b/test/CommandLineArgumentsTest.res @@ -0,0 +1,118 @@ +open Node + +let assertCommandLineArguments = (actual: CommandLineArguments.t, ~projectName, ~templateName) => { + Assert.strictEqual(actual.projectName, projectName) + Assert.strictEqual(actual.templateName, templateName) +} + +let assertParseError = (~remainingArguments, ~message) => + switch CommandLineArguments.parse(remainingArguments) { + | Ok(_) => Assert.fail(`Expected parse error: ${message}`) + | Error(actualMessage) => Assert.strictEqual(actualMessage, message) + } + +Test.describe("CommandLineArguments", () => { + Test.test("returns empty values when no arguments are provided", () => { + switch CommandLineArguments.parse(list{}) { + | Ok(commandLineArguments) => + commandLineArguments->assertCommandLineArguments(~projectName=None, ~templateName=None) + | Error(message) => Assert.fail(message) + } + }) + + Test.test("parses the project name from the first positional argument", () => { + switch CommandLineArguments.parse(list{"my-app"}) { + | Ok(commandLineArguments) => + commandLineArguments->assertCommandLineArguments( + ~projectName=Some("my-app"), + ~templateName=None, + ) + | Error(message) => Assert.fail(message) + } + }) + + Test.test("parses the template name from the -t flag", () => { + switch CommandLineArguments.parse(list{"my-app", "-t", "vite"}) { + | Ok(commandLineArguments) => + commandLineArguments->assertCommandLineArguments( + ~projectName=Some("my-app"), + ~templateName=Some(Templates.viteTemplateName), + ) + | Error(message) => Assert.fail(message) + } + }) + + Test.test("parses the template name from the --template flag", () => { + switch CommandLineArguments.parse(list{"my-app", "--template", "nextjs"}) { + | Ok(commandLineArguments) => + commandLineArguments->assertCommandLineArguments( + ~projectName=Some("my-app"), + ~templateName=Some(Templates.nextjsTemplateName), + ) + | Error(message) => Assert.fail(message) + } + }) + + Test.test("parses the template name from the --template=... flag", () => { + switch CommandLineArguments.parse(list{"my-app", "--template=basic"}) { + | Ok(commandLineArguments) => + commandLineArguments->assertCommandLineArguments( + ~projectName=Some("my-app"), + ~templateName=Some(Templates.basicTemplateName), + ) + | Error(message) => Assert.fail(message) + } + }) + + Test.test("ignores the node executable and script path in process argv", () => { + switch CommandLineArguments.fromProcessArgv([ + "/usr/local/bin/node", + "/tmp/create-rescript-app", + "my-app", + "-t", + "vite", + ]) { + | Ok(commandLineArguments) => + commandLineArguments->assertCommandLineArguments( + ~projectName=Some("my-app"), + ~templateName=Some(Templates.viteTemplateName), + ) + | Error(message) => Assert.fail(message) + } + }) + + Test.test("rejects a missing template value", () => { + assertParseError( + ~remainingArguments=list{"my-app", "--template"}, + ~message="Missing value for --template. Supported options: --template or -t .", + ) + }) + + Test.test("rejects unknown options", () => { + assertParseError( + ~remainingArguments=list{"my-app", "--yes"}, + ~message="Unknown option \"--yes\". Supported options: --template or -t .", + ) + }) + + Test.test("rejects unknown templates", () => { + assertParseError( + ~remainingArguments=list{"my-app", "--template", "unknown"}, + ~message="Unknown template \"unknown\". Available templates: vite, nextjs, basic.", + ) + }) + + Test.test("rejects positional templates", () => { + assertParseError( + ~remainingArguments=list{"my-app", "vite"}, + ~message="Unexpected argument \"vite\". Supported options: --template or -t .", + ) + }) + + Test.test("rejects additional positional arguments after the template", () => { + assertParseError( + ~remainingArguments=list{"my-app", "-t", "vite", "extra"}, + ~message="Unexpected argument \"extra\". Supported options: --template or -t .", + ) + }) +})