diff --git a/.changeset/tender-files-sneeze.md b/.changeset/tender-files-sneeze.md new file mode 100644 index 0000000..33b4bb1 --- /dev/null +++ b/.changeset/tender-files-sneeze.md @@ -0,0 +1,5 @@ +--- +"create-effect-app": patch +--- + +add additional cli options for controlling features and configs diff --git a/package.json b/package.json index 87ef5f0..bebe017 100644 --- a/package.json +++ b/package.json @@ -10,23 +10,24 @@ "check": "pnpm --recursive --parallel run check", "lint": "eslint \"**/{src,test,examples,scripts}/**/*.{ts,mjs}\"", "lint-fix": "pnpm lint --fix", - "changeset-version": "changeset version && node ./scripts/version.mjs && node ./scripts/examples.mjs", + "changeset-version": "changeset version && node ./scripts/version.mjs && node ./scripts/examples.mjs && node ./scripts/templates.mjs", "changeset-publish": "pnpm build && TEST_DIST= pnpm vitest && changeset publish" }, "devDependencies": { "@changesets/changelog-github": "^0.5.0", "@changesets/cli": "^2.27.7", + "@dprint/formatter": "^0.4.1", "@effect/eslint-plugin": "^0.2.0", "@effect/language-service": "^0.1.0", "@effect/vitest": "^0.9.2", "@eslint/compat": "^1.1.1", "@eslint/eslintrc": "^3.1.0", - "@eslint/js": "^9.9.1", + "@eslint/js": "^9.10.0", "@types/node": "^22.5.4", "@typescript-eslint/eslint-plugin": "^8.4.0", "@typescript-eslint/parser": "^8.4.0", "effect": "^3.7.2", - "eslint": "^9.9.1", + "eslint": "^9.10.0", "eslint-import-resolver-typescript": "^3.6.3", "eslint-plugin-codegen": "^0.28.0", "eslint-plugin-deprecation": "^3.0.0", diff --git a/packages/create-effect-app/README.md b/packages/create-effect-app/README.md new file mode 100644 index 0000000..18e532f --- /dev/null +++ b/packages/create-effect-app/README.md @@ -0,0 +1,121 @@ +# Create Effect App + +The `create-effect-app` command-line application is a tool which allows you to quickly bootstrap new Effect applications from either a template or from an official Effect example application. + +## Getting Started + +There are two main ways to use `create-effect-app`: + +### Interactive + +The easiest way to use `create-effect-app` is interactively via your preferred package manager: + +*`npm`* + +```sh +npx create-effect-app [project-name] +``` + +*`pnpm`* + +```sh +pnpm create effect-app [project-name] +``` + +*`yarn`* + +```sh +yarn create effect-app [project-name] +``` + +*`bun`* + +```sh +bunx create-effect-app [project-name] +``` + +You will then be prompted to select the type of project you want to create and customize your project with additional options (see the full [usage](#usage) documentation below). + +### Non-Interactive + +You can also invoke the `create-effect-app` CLI non-interactively: + +#### Usage + +```sh +Create Effect App + +USAGE + +$ create-effect-app [(-t, --template basic | cli | monorepo) [--changesets] [--flake] [--prettier] [--eslint] [--workflows]] [] + +$ create-effect-app [(-e, --example http-server)] [] + +DESCRIPTION + +Create an Effect application from an example or a template repository + +ARGUMENTS + + + + A directory that must not exist. + + The folder to output the Effect application code into + + This setting is optional. + +OPTIONS + +(-e, --example http-server) + + One of the following: http-server + + The name of an official Effect example to use to bootstrap the application + +(-t, --template basic | cli | monorepo) + + One of the following: basic, cli, monorepo + + The name of an official Effect example to use to bootstrap the application + +--changesets + + A true or false value. + + Initialize project with Changesets + + This setting is optional. + +--flake + + A true or false value. + + Initialize project with a Nix flake + + This setting is optional. + +--prettier + + A true or false value. + + Initialize project with Prettier + + This setting is optional. + +--eslint + + A true or false value. + + Initialize project with ESLint + + This setting is optional. + +--workflows + + A true or false value. + + Initialize project with Effect's recommended GitHub actions + + This setting is optional. +``` diff --git a/packages/create-effect-app/package.json b/packages/create-effect-app/package.json index 17eb7a0..5483cb7 100644 --- a/packages/create-effect-app/package.json +++ b/packages/create-effect-app/package.json @@ -8,7 +8,7 @@ "create-effect-app": "./dist/bin.cjs" }, "engines": { - "node": ">=20.14.0" + "node": ">=18.0.0" }, "repository": { "type": "git", @@ -28,13 +28,15 @@ "check": "tsc -b tsconfig.json" }, "devDependencies": { - "@effect/cli": "^0.42.2", - "@effect/platform": "^0.63.2", - "@effect/platform-node": "^0.58.2", + "@effect/cli": "^0.43.2", + "@effect/platform": "^0.64.0", + "@effect/platform-node": "^0.59.0", "@effect/printer": "^0.35.2", "@effect/printer-ansi": "^0.35.2", "effect": "^3.7.2", "tar": "^7.4.3", - "tsup": "^8.2.4" + "tsup": "^8.2.4", + "yaml": "^2.5.1" } -} \ No newline at end of file +} + diff --git a/packages/create-effect-app/src/Cli.ts b/packages/create-effect-app/src/Cli.ts index ee1b986..60e2883 100644 --- a/packages/create-effect-app/src/Cli.ts +++ b/packages/create-effect-app/src/Cli.ts @@ -6,41 +6,107 @@ import * as FileSystem from "@effect/platform/FileSystem" import * as Path from "@effect/platform/Path" import * as Ansi from "@effect/printer-ansi/Ansi" import * as AnsiDoc from "@effect/printer-ansi/AnsiDoc" +import * as Array from "effect/Array" import * as Effect from "effect/Effect" +import * as Match from "effect/Match" import * as Option from "effect/Option" -import type { TemplateType } from "./Domain.js" +import * as Yaml from "yaml" +import { ProjectType } from "./Domain.js" import { GitHub } from "./GitHub.js" -import * as InternalExamples from "./internal/examples.js" +import type { Example } from "./internal/examples.js" +import { examples } from "./internal/examples.js" +import { type Template, templates } from "./internal/templates.js" import * as InternalVersion from "./internal/version.js" import { validateProjectName } from "./Utils.js" -const example = Options.choice("example", InternalExamples.examples).pipe( +// ============================================================================= +// CLI Specification +// ============================================================================= + +const projectName = Args.directory({ name: "project-name", exists: "no" }).pipe( + Args.withDescription("The folder to output the Effect application code into"), + Args.mapEffect(validateProjectName), + Args.mapEffect((projectName) => Effect.map(Path.Path, (path) => path.resolve(projectName))), + Args.optional +) + +const exampleType = Options.choice("example", examples).pipe( Options.withAlias("e"), Options.withDescription( "The name of an official Effect example to use to bootstrap the application" - ), - Options.optional + ) +) + +const templateType = Options.choice("template", templates).pipe( + Options.withAlias("t"), + Options.withDescription( + "The name of an official Effect example to use to bootstrap the application" + ) +) + +const withChangesets = Options.boolean("changesets").pipe( + Options.withDescription("Initialize project with Changesets") +) + +const withNixFlake = Options.boolean("flake").pipe( + Options.withDescription("Initialize project with a Nix flake") ) -const projectName = Args.directory({ - name: "project-name", - exists: "no" +const withPrettier = Options.boolean("prettier").pipe( + Options.withDescription("Initialize project with Prettier") +) + +const withESLint = Options.boolean("eslint").pipe( + Options.withDescription("Initialize project with ESLint") +) + +const withWorkflows = Options.boolean("workflows").pipe( + Options.withDescription("Initialize project with Effect's recommended GitHub actions") +) + +const projectType: Options.Options> = Options.all({ + example: exampleType }).pipe( - Args.withDescription("The directory to output the Effect application code into"), - Args.optional + Options.map(ProjectType.Example), + Options.orElse( + Options.all({ + template: templateType, + withChangesets, + withNixFlake, + withPrettier, + withESLint, + withWorkflows + }).pipe(Options.map(ProjectType.Template)) + ), + Options.optional ) -const command = Command.make("create-effect-app", { example, projectName }).pipe( +export interface RawConfig { + readonly projectName: Option.Option + readonly projectType: Option.Option +} + +export interface ResolvedConfig { + readonly projectName: string + readonly projectType: ProjectType +} + +export interface ExampleConfig extends ResolvedConfig { + readonly projectType: Extract +} + +export interface TemplateConfig extends ResolvedConfig { + readonly projectType: Extract +} + +const options = { + projectName, + projectType +} + +const command = Command.make("create-effect-app", options).pipe( Command.withDescription("Create an Effect application from an example or a template repository"), - Command.withHandler(({ example, projectName }) => - Effect.gen(function*() { - const projectPath = yield* resolveProjectPath(projectName) - return yield* Option.match(example, { - onNone: () => createTemplate(projectPath), - onSome: (example) => createExample(projectPath, example) - }) - }) - ) + Command.withHandler(handleCommand) ) export const cli = Command.run(command, { @@ -48,102 +114,298 @@ export const cli = Command.run(command, { version: `v${InternalVersion.moduleVersion}` }) -function resolveProjectPath(projectName: Option.Option) { - return Effect.gen(function*() { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - return yield* Option.match(projectName, { - onSome: (name) => Effect.succeed(path.resolve(name)), - onNone: () => - Prompt.text({ - message: "What is your project named?", - default: "effect-app", - validate: (name) => - Option.match(validateProjectName(name), { - onNone: () => - Effect.if(Effect.orDie(fs.exists(path.resolve(name))), { - onTrue: () => Effect.fail(`The path ${path.resolve(name)} already exists`), - onFalse: () => Effect.succeed(name) - }), - onSome: Effect.fail - }) - }) - }) +// ============================================================================= +// Utilities +// ============================================================================= + +function handleCommand(config: RawConfig) { + return Effect.all({ + projectName: resolveProjectName(config), + projectType: resolveProjectType(config) + }).pipe(Effect.flatMap(createProject)) +} + +const createProject = Match.type().pipe( + Match.when({ projectType: { _tag: "Example" } }, (config) => createExample(config)), + Match.when({ projectType: { _tag: "Template" } }, (config) => createTemplate(config)), + Match.orElseAbsurd +) + +function resolveProjectName(config: RawConfig) { + return Option.match(config.projectName, { + onSome: Effect.succeed, + onNone: () => + Prompt.text({ + message: "What is your project named?", + default: "effect-app" + }).pipe(Effect.flatMap((name) => Path.Path.pipe(Effect.map((path) => path.resolve(name))))) + }) +} + +function resolveProjectType(config: RawConfig) { + return Option.match(config.projectType, { + onSome: Effect.succeed, + onNone: () => Prompt.run(getUserInput) }) } -function createExample(projectPath: string, example: string) { +/** + * Examples are simply cloned as is from GitHub + */ +function createExample(config: ExampleConfig) { return Effect.gen(function*() { const fs = yield* FileSystem.FileSystem yield* Effect.logInfo(AnsiDoc.hsep([ AnsiDoc.text("Creating a new Effect application in"), - AnsiDoc.text(projectPath).pipe(AnsiDoc.annotate(Ansi.green)) + AnsiDoc.text(config.projectName).pipe(AnsiDoc.annotate(Ansi.green)) ])) // Create the project path - yield* fs.makeDirectory(projectPath) + yield* fs.makeDirectory(config.projectName, { recursive: true }) yield* Effect.logInfo(AnsiDoc.hsep([ AnsiDoc.text("Initializing example project:"), - AnsiDoc.text(example).pipe(AnsiDoc.annotate(Ansi.magenta)) + AnsiDoc.text(config.projectType.example).pipe(AnsiDoc.annotate(Ansi.magenta)) ])) // Download the example project from GitHub - yield* GitHub.downloadExample(projectPath, example) + yield* GitHub.downloadExample(config) yield* Effect.logInfo(AnsiDoc.hsep([ AnsiDoc.text("Success!").pipe(AnsiDoc.annotate(Ansi.green)), - AnsiDoc.text(`Effect example application was initialized in ${projectPath}`) + AnsiDoc.text(`Effect example application was initialized in ${config.projectName}`) ])) }) } -function createTemplate(projectPath: string) { +/** + * Templates are cloned from GitHub and then resolved against the preferences + * specified by the user + */ +function createTemplate(config: TemplateConfig) { return Effect.gen(function*() { const fs = yield* FileSystem.FileSystem - - // Prompt user for the project template to use - const template = yield* Prompt.select({ - message: "What project template should be used?", - choices: [ - { - title: "Basic", - value: "basic", - description: "A project containing a single package" - }, - { - title: "Monorepo", - value: "monorepo", - description: "A project containing multiple packages" - }, - { - title: "CLI Application", - value: "cli", - description: "A project containing a CLI application" - } - ] - }) + const path = yield* Path.Path yield* Effect.logInfo(AnsiDoc.hsep([ AnsiDoc.text("Creating a new Effect project in"), - AnsiDoc.text(projectPath).pipe(AnsiDoc.annotate(Ansi.green)) + AnsiDoc.text(config.projectName).pipe(AnsiDoc.annotate(Ansi.green)) ])) // Create the project directory - yield* fs.makeDirectory(projectPath) + yield* fs.makeDirectory(config.projectName, { recursive: true }) yield* Effect.logInfo(AnsiDoc.hsep([ AnsiDoc.text("Initializing project with template:"), - AnsiDoc.text(template).pipe(AnsiDoc.annotate(Ansi.magenta)) + AnsiDoc.text(config.projectType.template).pipe(AnsiDoc.annotate(Ansi.magenta)) ])) // Download the template project from GitHub - yield* GitHub.downloadTemplate(projectPath, template) + yield* GitHub.downloadTemplate(config) + + const packageJson = yield* fs.readFileString(path.join(config.projectName, "package.json")).pipe( + Effect.map((json) => JSON.parse(json)) + ) + + // Handle user preferences for changesets + if (!config.projectType.withChangesets) { + // Remove the .changesets directory + yield* fs.remove(path.join(config.projectName, ".changeset"), { + recursive: true + }) + // Remove patches for changesets + const patches = yield* fs.readDirectory(path.join(config.projectName, "patches")).pipe( + Effect.map(Array.filter((file) => file.includes("changeset"))) + ) + yield* Effect.forEach(patches, (patch) => fs.remove(path.join(config.projectName, "patches", patch))) + // Remove patched dependencies for changesets + const depsToRemove = Array.filter( + Object.keys(packageJson["pnpm"]["patchedDependencies"]), + (key) => key.includes("changeset") + ) + for (const patch of depsToRemove) { + delete packageJson["pnpm"]["patchedDependencies"][patch] + } + // Remove scripts for changesets + const scriptsToRemove = Array.filter( + Object.keys(packageJson["scripts"]), + (key) => key.includes("changeset") + ) + for (const script of scriptsToRemove) { + delete packageJson["scripts"][script] + } + // Remove packages for changesets + const pkgsToRemove = Array.filter( + Object.keys(packageJson["devDependencies"]), + (key) => key.includes("changeset") + ) + for (const pkg of pkgsToRemove) { + delete packageJson["devDependencies"][pkg] + } + // If git workflows are enabled, remove changesets related workflows + if (config.projectType.withWorkflows) { + yield* fs.remove(path.join(config.projectName, ".github", "workflows", "release.yml")) + } + } + + // Handle user preferences for Nix flakes + if (!config.projectType.withNixFlake) { + yield* Effect.forEach( + [".envrc", "flake.lock", "flake.nix"], + (file) => fs.remove(path.join(config.projectName, file)) + ) + } + + // Handle user preferences for Prettier + if (!config.projectType.withPrettier) { + // Remove prettier configuration files + yield* Effect.forEach( + [".prettierignore", ".prettierrc.json"], + (file) => fs.remove(path.join(config.projectName, file)) + ) + // Remove prettier from dependencies + delete packageJson["devDependencies"]["prettier"] + } + + // Handle user preferences for ESLint + if (!config.projectType.withESLint) { + // Remove eslint.config.mjs + yield* fs.remove(path.join(config.projectName, "eslint.config.mjs")) + // Remove eslint dependencies + const eslintDeps = Array.filter( + Object.keys(packageJson["devDependencies"]), + (key) => key.includes("eslint") + ) + for (const dep of eslintDeps) { + delete packageJson["devDependencies"][dep] + } + // Remove linting scripts + const scriptsToRemove = Array.filter( + Object.keys(packageJson["scripts"]), + (key) => key.includes("lint") + ) + for (const script of scriptsToRemove) { + delete packageJson["scripts"][script] + } + // If git workflows are enabled, remove lint workflows + if (config.projectType.withWorkflows) { + const checkWorkflowPath = path.join(config.projectName, ".github", "workflows", "check.yml") + const checkWorkflow = yield* fs.readFileString(checkWorkflowPath) + const checkYaml = Yaml.parse(checkWorkflow) + delete checkYaml["jobs"]["lint"] + yield* fs.writeFileString(checkWorkflowPath, Yaml.stringify(checkYaml, undefined, 2)) + } + } + + // Handle user preferences for GitHub workflows + if (!config.projectType.withWorkflows) { + // Remove the .github directory + yield* fs.remove(path.join(config.projectName, ".github"), { + recursive: true + }) + } + + // Write out the updated package.json + yield* fs.writeFileString( + path.join(config.projectName, "package.json"), + JSON.stringify(packageJson, undefined, 2) + ) yield* Effect.logInfo(AnsiDoc.hsep([ AnsiDoc.text("Success!").pipe(AnsiDoc.annotate(Ansi.green)), - AnsiDoc.text(`Effect template project was initialized in ${projectPath}`) + AnsiDoc.text(`Effect template project was initialized in:`), + AnsiDoc.hardLine, + AnsiDoc.indent(AnsiDoc.text(config.projectName).pipe(AnsiDoc.annotate(Ansi.magenta)), 2) ])) + + if (config.projectType.withChangesets) { + yield* Effect.logInfo(AnsiDoc.hsep([ + AnsiDoc.text("Make sure to update the Changesets configuration file"), + AnsiDoc.text("with your target GitHub repository for changelog links:"), + AnsiDoc.hardLine, + AnsiDoc.text(path.join(config.projectName, ".changeset", "config.json")).pipe( + AnsiDoc.annotate(Ansi.magenta), + AnsiDoc.indent(2) + ) + ])) + } }) } + +const getUserInput = Prompt.select<"example" | "template">({ + message: "What type of project would you like to create?", + choices: [ + { + title: "Template", + value: "template", + description: "A template project suitable for a package or application" + }, + { + title: "Example", + value: "example", + description: "An example project demonstrating usage of Effect" + } + ] +}).pipe(Prompt.flatMap((type): Prompt.Prompt => { + switch (type) { + case "example": { + return Prompt.all({ + example: Prompt.select({ + message: "What project template should be used?", + choices: [ + { + title: "HTTP Server", + value: "http-server", + description: "An HTTP server application with authentication / authorization" + } + ] + }) + }).pipe(Prompt.map(ProjectType.Example)) + } + case "template": { + return Prompt.all({ + template: Prompt.select