diff --git a/README.md b/README.md index eaf4f0c..628b736 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,9 @@ A CLI tool for creating OpenTUI projects from templates, organized as a Bun work ```bash # Use with bun (no installation required) bun create tui my-tui-project + +# Initialize an empty current directory +bun create tui . ``` ## Available Templates @@ -73,6 +76,9 @@ Options: # Interactive mode (prompts for all options) bun create tui my-project +# Initialize the current directory (it must be empty, except for .git) +bun create tui . + # Use an alias (built-in template) bun create tui -t react my-project diff --git a/packages/cli/README.md b/packages/cli/README.md index 54b8118..10681c8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -34,6 +34,9 @@ This will prompt you for: - Project name - Template choice (Core, React, Solid, or Custom) +To initialize the current directory, pass `.`. The directory must be empty, +apart from an existing `.git` directory. Its folder name is used as the package name. + ### With Arguments ```bash @@ -45,6 +48,9 @@ bun create tui -t core my-core-app # Create a Solid project bun create tui -t solid my-solid-app + +# Initialize the current directory +bun create tui . ``` ## Template Formats @@ -108,7 +114,7 @@ Any public GitHub repository can be used as a template. Use either shorthand (`o | Argument | Description | Required | | -------------- | -------------------------------------- | -------- | -| `project-name` | The folder to bootstrap the project in | No | +| `project-name` | The folder to bootstrap the project in; use `.` for the current directory | No | ## Options diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index f2f1310..ebe5b64 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -10,16 +10,19 @@ import { import { createProject } from "./handler"; import { ProjectSettings } from "./project-settings"; import { + validateProjectDestination, + validateProjectDestinationWithHelpDoc, validateProjectName, - validateProjectNameWithHelpDoc, } from "./utils/validate-project-name"; const projectName = Args.directory({ name: "project-name", - exists: "no", + exists: "either", }).pipe( - Args.withDescription("The folder to bootstrap the project in"), - Args.mapEffect(validateProjectNameWithHelpDoc), + Args.withDescription( + "The folder to bootstrap the project in (use . for the current directory)", + ), + Args.mapEffect(validateProjectDestinationWithHelpDoc), Args.optional, ); @@ -53,13 +56,14 @@ function handleCommand(args: { readonly verbose: boolean; }) { return Effect.gen(function* () { + const projectNameFromArgument = Option.isSome(args.projectName); const resolvedProjectName = yield* Option.getOrElse( Option.map(args.projectName, Effect.succeed), () => Prompt.text({ message: "What is your project named?", default: "my-opentui-project", - }).pipe(Effect.flatMap(validateProjectName)), + }).pipe(Effect.flatMap(validateProjectDestination)), ); const resolvedProjectTemplate = yield* Option.getOrElse( @@ -88,14 +92,19 @@ function handleCommand(args: { ), ); - const projectPath = yield* Path.Path.pipe( - Effect.map((path) => path.resolve(resolvedProjectName)), - ); + const path = yield* Path.Path; + const useCurrentDirectory = resolvedProjectName === "."; + const projectPath = path.resolve(resolvedProjectName); + const resolvedPackageName = useCurrentDirectory + ? yield* validateProjectName(path.basename(projectPath)) + : resolvedProjectName; return yield* createProject().pipe( ProjectSettings.provide({ - projectName: resolvedProjectName, + projectName: resolvedPackageName, projectPath, + useCurrentDirectory, + projectNameFromArgument, projectTemplate: resolvedProjectTemplate, skipGit: args.noGit, skipInstall: args.noInstall, diff --git a/packages/cli/src/domain/config.ts b/packages/cli/src/domain/config.ts index e0c40a4..613dcb8 100644 --- a/packages/cli/src/domain/config.ts +++ b/packages/cli/src/domain/config.ts @@ -3,6 +3,8 @@ import type { GitHubTemplateSource } from "./template"; export interface ProjectConfig { readonly projectName: string; readonly projectPath: string; + readonly useCurrentDirectory: boolean; + readonly projectNameFromArgument: boolean; readonly projectTemplate: GitHubTemplateSource; readonly skipGit: boolean; readonly skipInstall: boolean; diff --git a/packages/cli/src/handler.test.ts b/packages/cli/src/handler.test.ts new file mode 100644 index 0000000..f80ead9 --- /dev/null +++ b/packages/cli/src/handler.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from "bun:test"; +import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { NodeContext } from "@effect/platform-node"; +import { Effect, Logger, LogLevel } from "effect"; +import type { ProjectConfig } from "./domain/config"; +import { TemplateDownloadError } from "./domain/errors"; +import type { GitHubTemplateSource } from "./domain/template"; +import { createProject } from "./handler"; +import { ProjectSettings } from "./project-settings"; +import { PackageManager } from "./services/package-manager"; +import { Project } from "./services/project"; +import { TemplateDownloader } from "./services/template-downloader"; +import { UpdateChecker } from "./services/update-checker"; + +const projectTemplate = {} as GitHubTemplateSource; + +function runCreateProject(config: ProjectConfig) { + return createProject().pipe( + ProjectSettings.provide(config), + Effect.provideService(TemplateDownloader, { + download: () => + Effect.tryPromise(() => + writeFile( + join(config.projectPath, "package.json"), + JSON.stringify({ name: "template" }), + ), + ).pipe( + Effect.mapError( + (cause) => + new TemplateDownloadError({ + cause, + message: "Failed to write test package.json", + }), + ), + ), + }), + Effect.provideService(Project, { + initializeGitRepository: () => Effect.void, + }), + Effect.provideService(PackageManager, { + name: "bun", + install: () => Effect.void, + }), + Effect.provideService(UpdateChecker, { + check: () => Effect.void, + }), + Effect.provide(NodeContext.layer), + Logger.withMinimumLogLevel(LogLevel.Fatal), + Effect.either, + Effect.runPromise, + ); +} + +function settings( + projectPath: string, + overrides: Partial = {}, +): ProjectConfig { + return { + projectName: "create-tui-test", + projectPath, + useCurrentDirectory: false, + projectNameFromArgument: true, + projectTemplate, + skipGit: true, + skipInstall: true, + verbose: false, + ...overrides, + }; +} + +describe("createProject current-directory handling", () => { + it("rejects an existing named directory supplied as an argument", async () => { + const directory = await mkdtemp(join(tmpdir(), "create-tui-test-")); + + try { + const result = await runCreateProject(settings(directory)); + + expect(result).toMatchObject({ + _tag: "Left", + left: { message: "Directory already exists." }, + }); + } finally { + await rm(directory, { force: true, recursive: true }); + } + }); + + it("rejects a non-empty current directory without deleting its files", async () => { + const directory = await mkdtemp(join(tmpdir(), "create-tui-test-")); + const existingFile = join(directory, "keep-me"); + await writeFile(existingFile, "keep"); + + try { + const result = await runCreateProject( + settings(directory, { useCurrentDirectory: true }), + ); + + expect(result).toMatchObject({ + _tag: "Left", + left: { message: "Current directory is not empty." }, + }); + expect(await Bun.file(existingFile).text()).toBe("keep"); + } finally { + await rm(directory, { force: true, recursive: true }); + } + }); + + it("allows a current directory containing only .git", async () => { + const directory = await mkdtemp(join(tmpdir(), "create-tui-test-")); + const gitDirectory = join(directory, ".git"); + await mkdir(gitDirectory); + + try { + const result = await runCreateProject( + settings(directory, { useCurrentDirectory: true }), + ); + + expect(result).toMatchObject({ _tag: "Right" }); + expect((await stat(gitDirectory)).isDirectory()).toBe(true); + } finally { + await rm(directory, { force: true, recursive: true }); + } + }); +}); diff --git a/packages/cli/src/handler.ts b/packages/cli/src/handler.ts index e4a6707..2be006f 100644 --- a/packages/cli/src/handler.ts +++ b/packages/cli/src/handler.ts @@ -31,37 +31,65 @@ export function createProject() { ); if (directoryExists) { - yield* Effect.logWarning( - AnsiDoc.hsep([ - AnsiDoc.text("Directory"), - AnsiDoc.text(projectSettings.projectPath).pipe( - AnsiDoc.annotate(Ansi.yellow), - ), - AnsiDoc.text("already exists"), - ]), - ); - - const shouldDelete = yield* Prompt.confirm({ - message: "Would you like to delete it?", - }); - - if (!shouldDelete) { + if (projectSettings.useCurrentDirectory) { + const entries = yield* fs + .readDirectory(projectSettings.projectPath) + .pipe( + Effect.mapError( + (cause) => + new CreateProjectError({ + cause, + message: "Failed to read current directory.", + hint: "Check that you have read permissions for the directory.", + }), + ), + ); + const projectFiles = entries.filter((entry) => entry !== ".git"); + + if (projectFiles.length > 0) { + return yield* new CreateProjectError({ + message: "Current directory is not empty.", + hint: "Use an empty directory or choose a different project name.", + }); + } + } else if (projectSettings.projectNameFromArgument) { return yield* new CreateProjectError({ message: "Directory already exists.", hint: "Use a different project name or remove it.", }); - } + } else { + yield* Effect.logWarning( + AnsiDoc.hsep([ + AnsiDoc.text("Directory"), + AnsiDoc.text(projectSettings.projectPath).pipe( + AnsiDoc.annotate(Ansi.yellow), + ), + AnsiDoc.text("already exists"), + ]), + ); - yield* fs.remove(projectSettings.projectPath, { recursive: true }).pipe( - Effect.mapError( - (cause) => - new CreateProjectError({ - cause, - message: "Failed to delete directory.", - hint: "Try manually removing it.", - }), - ), - ); + const shouldDelete = yield* Prompt.confirm({ + message: "Would you like to delete it?", + }); + + if (!shouldDelete) { + return yield* new CreateProjectError({ + message: "Directory already exists.", + hint: "Use a different project name or remove it.", + }); + } + + yield* fs.remove(projectSettings.projectPath, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new CreateProjectError({ + cause, + message: "Failed to delete directory.", + hint: "Try manually removing it.", + }), + ), + ); + } } yield* Effect.logInfo( diff --git a/packages/cli/src/utils/validate-project-name.test.ts b/packages/cli/src/utils/validate-project-name.test.ts new file mode 100644 index 0000000..8c5bca0 --- /dev/null +++ b/packages/cli/src/utils/validate-project-name.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from "bun:test"; +import { Effect } from "effect"; +import { validateProjectDestination } from "./validate-project-name"; + +describe("validateProjectDestination", () => { + it("accepts a dot for the current directory", () => { + expect(Effect.runSync(validateProjectDestination("."))).toBe("."); + }); + + it("continues to reject other dot-prefixed names", () => { + expect(() => Effect.runSync(validateProjectDestination(".hidden"))).toThrow( + "Project name must not start with a period", + ); + }); +}); diff --git a/packages/cli/src/utils/validate-project-name.ts b/packages/cli/src/utils/validate-project-name.ts index 58090c9..68d11d7 100644 --- a/packages/cli/src/utils/validate-project-name.ts +++ b/packages/cli/src/utils/validate-project-name.ts @@ -138,8 +138,24 @@ export function validateProjectName( return Effect.succeed(name); } +export function validateProjectDestination( + destination: string, +): Effect.Effect { + return destination === "." + ? Effect.succeed(destination) + : validateProjectName(destination); +} + export function validateProjectNameWithHelpDoc( name: string, ): Effect.Effect { return validateProjectName(name).pipe(Effect.mapError(HelpDoc.p)); } + +export function validateProjectDestinationWithHelpDoc( + destination: string, +): Effect.Effect { + return validateProjectDestination(destination).pipe( + Effect.mapError(HelpDoc.p), + ); +}