Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down
27 changes: 18 additions & 9 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/domain/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
125 changes: 125 additions & 0 deletions packages/cli/src/handler.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {},
): 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 });
}
});
});
80 changes: 54 additions & 26 deletions packages/cli/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/src/utils/validate-project-name.test.ts
Original file line number Diff line number Diff line change
@@ -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",
);
});
});
16 changes: 16 additions & 0 deletions packages/cli/src/utils/validate-project-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,24 @@ export function validateProjectName(
return Effect.succeed(name);
}

export function validateProjectDestination(
destination: string,
): Effect.Effect<string, string> {
return destination === "."
? Effect.succeed(destination)
: validateProjectName(destination);
}

export function validateProjectNameWithHelpDoc(
name: string,
): Effect.Effect<string, HelpDoc.HelpDoc> {
return validateProjectName(name).pipe(Effect.mapError(HelpDoc.p));
}

export function validateProjectDestinationWithHelpDoc(
destination: string,
): Effect.Effect<string, HelpDoc.HelpDoc> {
return validateProjectDestination(destination).pipe(
Effect.mapError(HelpDoc.p),
);
}