Skip to content

Commit cb7cb17

Browse files
feat: simplify flow and CLI output (#9)
* refactor(create): simplify context flow and prompt UX * refactor(cli): simplify setup output and style intro * refactor(cli): use node styleText for intro branding * fix(init): default package manager fallback to npm
1 parent efaedad commit cb7cb17

20 files changed

Lines changed: 716 additions & 461 deletions

AGENTS.md

Lines changed: 0 additions & 52 deletions
This file was deleted.

src/commands/create.ts

Lines changed: 138 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {
22
cancel,
3+
intro,
34
isCancel,
45
log,
56
select,
@@ -9,16 +10,24 @@ import {
910
import fs from "fs-extra";
1011
import path from "node:path";
1112

12-
import { scaffoldCreateTemplate } from "../templates/render-create-template";
13+
import {
14+
scaffoldCreateTemplate,
15+
} from "../templates/render-create-template";
1316
import {
1417
CreateCommandInputSchema,
1518
CreateTemplateSchema,
19+
type CreatePromptContext,
20+
type CreateTargetPathState,
1621
type CreateCommandInput,
1722
type CreateTemplate,
1823
type InitCommandInput,
1924
type SchemaPreset,
2025
} from "../types";
21-
import { runInitCommand } from "./init";
26+
import {
27+
collectInitContext,
28+
executeInitContext,
29+
} from "./init";
30+
import { getCreatePrismaIntro } from "../ui/branding";
2231

2332
const DEFAULT_PROJECT_NAME = "my-app";
2433
const DEFAULT_TEMPLATE: CreateTemplate = "hono";
@@ -38,21 +47,33 @@ function formatPathForDisplay(filePath: string): string {
3847
return path.relative(process.cwd(), filePath) || ".";
3948
}
4049

50+
function validateProjectName(value: string | undefined): string | undefined {
51+
const trimmed = String(value ?? "").trim();
52+
if (trimmed.length === 0) {
53+
return "Please enter a project name.";
54+
}
55+
56+
if (trimmed === "." || trimmed === "..") {
57+
return "Project name cannot be '.' or '..'.";
58+
}
59+
60+
if (path.isAbsolute(trimmed)) {
61+
return "Use a relative project name instead of an absolute path.";
62+
}
63+
64+
return undefined;
65+
}
66+
4167
async function promptForProjectName(): Promise<string | undefined> {
4268
const projectName = await text({
4369
message: "Project name",
4470
placeholder: DEFAULT_PROJECT_NAME,
4571
initialValue: DEFAULT_PROJECT_NAME,
46-
validate: (value) => {
47-
const trimmed = String(value ?? "").trim();
48-
return trimmed.length > 0
49-
? undefined
50-
: "Please enter a valid project name.";
51-
},
72+
validate: validateProjectName,
5273
});
5374

5475
if (isCancel(projectName)) {
55-
cancel("Cancelled.");
76+
cancel("Operation cancelled.");
5677
return undefined;
5778
}
5879

@@ -78,24 +99,63 @@ async function promptForCreateTemplate(): Promise<CreateTemplate | undefined> {
7899
});
79100

80101
if (isCancel(template)) {
81-
cancel("Cancelled.");
102+
cancel("Operation cancelled.");
82103
return undefined;
83104
}
84105

85106
return CreateTemplateSchema.parse(template);
86107
}
87108

88-
async function isDirectoryEmpty(directoryPath: string): Promise<boolean> {
89-
if (!(await fs.pathExists(directoryPath))) {
90-
return true;
109+
async function inspectTargetPath(targetPath: string): Promise<CreateTargetPathState> {
110+
if (!(await fs.pathExists(targetPath))) {
111+
return {
112+
exists: false,
113+
isDirectory: true,
114+
isEmptyDirectory: true,
115+
};
116+
}
117+
118+
const stats = await fs.stat(targetPath);
119+
if (!stats.isDirectory()) {
120+
return {
121+
exists: true,
122+
isDirectory: false,
123+
isEmptyDirectory: false,
124+
};
91125
}
92126

93-
const entries = await fs.readdir(directoryPath);
94-
return entries.length === 0;
127+
const entries = await fs.readdir(targetPath);
128+
return {
129+
exists: true,
130+
isDirectory: true,
131+
isEmptyDirectory: entries.length === 0,
132+
};
95133
}
96134

97135
export async function runCreateCommand(rawInput: CreateCommandInput = {}): Promise<void> {
98-
const input = CreateCommandInputSchema.parse(rawInput);
136+
try {
137+
const input = CreateCommandInputSchema.parse(rawInput);
138+
139+
intro(getCreatePrismaIntro());
140+
141+
const context = await collectCreateContext(input);
142+
if (!context) {
143+
return;
144+
}
145+
146+
await executeCreateContext(context);
147+
} catch (error) {
148+
cancel(
149+
`Create command failed: ${
150+
error instanceof Error ? error.message : String(error)
151+
}`
152+
);
153+
}
154+
}
155+
156+
async function collectCreateContext(
157+
input: CreateCommandInput
158+
): Promise<CreatePromptContext | undefined> {
99159
const useDefaults = input.yes === true;
100160
const force = input.force === true;
101161

@@ -116,9 +176,20 @@ export async function runCreateCommand(rawInput: CreateCommandInput = {}): Promi
116176
input.schemaPreset ?? DEFAULT_SCHEMA_PRESET;
117177

118178
const targetDirectory = path.resolve(process.cwd(), projectName);
119-
const targetExists = await fs.pathExists(targetDirectory);
120-
const targetIsEmpty = await isDirectoryEmpty(targetDirectory);
121-
if (targetExists && !targetIsEmpty && !force) {
179+
const targetPathState = await inspectTargetPath(targetDirectory);
180+
if (targetPathState.exists && !targetPathState.isDirectory) {
181+
cancel(
182+
`Target path ${formatPathForDisplay(
183+
targetDirectory
184+
)} already exists and is not a directory. Choose a different project name.`
185+
);
186+
return;
187+
}
188+
if (
189+
targetPathState.exists &&
190+
!targetPathState.isEmptyDirectory &&
191+
!force
192+
) {
122193
cancel(
123194
`Target directory ${formatPathForDisplay(
124195
targetDirectory
@@ -127,14 +198,47 @@ export async function runCreateCommand(rawInput: CreateCommandInput = {}): Promi
127198
return;
128199
}
129200

201+
const initInput: InitCommandInput = {
202+
yes: input.yes,
203+
verbose: input.verbose,
204+
provider: input.provider,
205+
packageManager: input.packageManager,
206+
prismaPostgres: input.prismaPostgres,
207+
databaseUrl: input.databaseUrl,
208+
install: input.install,
209+
generate: input.generate,
210+
schemaPreset,
211+
};
212+
213+
const initContext = await collectInitContext(initInput, {
214+
skipIntro: true,
215+
projectDir: targetDirectory,
216+
});
217+
if (!initContext) {
218+
return;
219+
}
220+
221+
return {
222+
targetDirectory,
223+
targetPathState,
224+
force,
225+
template,
226+
schemaPreset,
227+
projectPackageName: toPackageName(path.basename(targetDirectory)),
228+
initContext,
229+
};
230+
}
231+
232+
async function executeCreateContext(context: CreatePromptContext): Promise<void> {
130233
const scaffoldSpinner = spinner();
131-
scaffoldSpinner.start(`Scaffolding ${template} project...`);
234+
scaffoldSpinner.start(`Scaffolding ${context.template} project...`);
132235
try {
133236
await scaffoldCreateTemplate({
134-
projectDir: targetDirectory,
135-
projectName: toPackageName(path.basename(targetDirectory)),
136-
template,
137-
schemaPreset,
237+
projectDir: context.targetDirectory,
238+
projectName: context.projectPackageName,
239+
template: context.template,
240+
schemaPreset: context.schemaPreset,
241+
packageManager: context.initContext.packageManager,
138242
});
139243
scaffoldSpinner.stop("Project files scaffolded.");
140244
} catch (error) {
@@ -143,28 +247,21 @@ export async function runCreateCommand(rawInput: CreateCommandInput = {}): Promi
143247
return;
144248
}
145249

146-
if (targetExists && !targetIsEmpty && force) {
250+
if (
251+
context.targetPathState.exists &&
252+
!context.targetPathState.isEmptyDirectory &&
253+
context.force
254+
) {
147255
log.warn(
148-
`Used --force in non-empty directory ${formatPathForDisplay(targetDirectory)}.`
256+
`Used --force in non-empty directory ${formatPathForDisplay(context.targetDirectory)}.`
149257
);
150258
}
151259

152-
const initInput: InitCommandInput = {
153-
yes: input.yes,
154-
verbose: input.verbose,
155-
provider: input.provider,
156-
packageManager: input.packageManager,
157-
prismaPostgres: input.prismaPostgres,
158-
databaseUrl: input.databaseUrl,
159-
install: input.install,
160-
generate: input.generate,
161-
schemaPreset,
162-
};
163-
164-
const cdStep = `- cd ${formatPathForDisplay(targetDirectory)}`;
165-
await runInitCommand(initInput, {
260+
const cdStep = `- cd ${formatPathForDisplay(context.targetDirectory)}`;
261+
await executeInitContext(context.initContext, {
166262
skipIntro: true,
167263
prependNextSteps: [cdStep],
168-
projectDir: targetDirectory,
264+
projectDir: context.targetDirectory,
265+
includeDevNextStep: true,
169266
});
170267
}

0 commit comments

Comments
 (0)