@freestyle-sh/fdev-cli
Advanced tools
+2
-2
| { | ||
| "name": "@freestyle-sh/fdev-cli", | ||
| "version": "0.1.5", | ||
| "version": "0.1.6", | ||
| "type": "module", | ||
@@ -24,3 +24,3 @@ "repository": { | ||
| "commander": "^14.0.3", | ||
| "@freestyle-sh/fdev-engine": "0.1.5" | ||
| "@freestyle-sh/fdev-engine": "0.1.6" | ||
| }, | ||
@@ -27,0 +27,0 @@ "devDependencies": { |
+1
-1
@@ -11,4 +11,4 @@ # @freestyle-sh/fdev-cli | ||
| `fdev init` asks for a project name, Freestyle API key, and package manager. It creates `fdev.config.ts`, `.env`, `.env.example`, `package.json`, and local ignore rules. | ||
| `fdev init` asks for a project name, Freestyle API key, and package manager. It creates a project folder containing `fdev.config.ts`, `.env`, `.env.example`, `package.json`, and local ignore rules. | ||
| Projects should install matching `@freestyle-sh/fdev-sdk` versions locally. |
+158
-12
| #!/usr/bin/env bun | ||
| import { existsSync, mkdirSync } from "node:fs"; | ||
| import { dirname, join } from "node:path"; | ||
| import { existsSync } from "node:fs"; | ||
| import { dirname, join, relative, resolve } from "node:path"; | ||
| import { createInterface } from "node:readline/promises"; | ||
@@ -17,3 +17,3 @@ import chalk from "chalk"; | ||
| import { FDEV_CLI_VERSION } from "./version.ts"; | ||
| import { defaultProjectName, initProject, normalizeMachineName, type InitProjectResult } from "./init.ts"; | ||
| import { initProject, normalizeMachineName, type InitProjectResult } from "./init.ts"; | ||
@@ -251,4 +251,4 @@ type GlobalOptions = { | ||
| async function runInit(command: Command, options: InitOptions): Promise<void> { | ||
| const paths = resolveCommandConfigPaths(command); | ||
| mkdirSync(paths.projectDir, { recursive: true }); | ||
| const answers = await resolveInitAnswers(options, wantsJson(command)); | ||
| const paths = resolveInitProjectPaths(command, answers.name); | ||
@@ -259,3 +259,2 @@ if (existsSync(paths.configPath) && !options.force) { | ||
| const answers = await resolveInitAnswers(paths.projectDir, options, wantsJson(command)); | ||
| const result = initProject({ | ||
@@ -279,3 +278,2 @@ projectDir: paths.projectDir, | ||
| async function resolveInitAnswers( | ||
| projectDir: string, | ||
| options: InitOptions, | ||
@@ -298,3 +296,3 @@ jsonMode: boolean, | ||
| console.log(chalk.bold("Initialize fdev")); | ||
| console.log(chalk.dim("This creates fdev.config.ts, .env, package.json, and local ignore rules.")); | ||
| console.log(chalk.dim("This creates a project folder with fdev.config.ts, .env, package.json, and local ignore rules.")); | ||
| console.log(""); | ||
@@ -305,3 +303,3 @@ } | ||
| ? normalizeMachineName(options.name) | ||
| : await promptName(defaultProjectName(projectDir)); | ||
| : await promptName(); | ||
| const apiKey = options.apiKey?.trim() || await promptRequiredSecret("Freestyle API key"); | ||
@@ -326,3 +324,17 @@ const packageManager = options.packageManager ?? (jsonMode || !canPrompt() ? "skip" : await promptPackageManager("skip")); | ||
| async function promptName(defaultName: string): Promise<string> { | ||
| function resolveInitProjectPaths(command: Command, name: string): { projectDir: string; configPath: string } { | ||
| const options = command.optsWithGlobals() as GlobalOptions; | ||
| if (options.config) { | ||
| throw new Error(`fdev init does not support --config. Use -C/--project to choose the parent directory.`); | ||
| } | ||
| const parentDir = resolve(process.cwd(), options.project ?? "."); | ||
| const projectDir = resolve(parentDir, name); | ||
| return { | ||
| projectDir, | ||
| configPath: join(projectDir, DEFAULT_CONFIG_FILE), | ||
| }; | ||
| } | ||
| async function promptName(): Promise<string> { | ||
| const rl = createInterface({ input: process.stdin, output: process.stdout }); | ||
@@ -332,6 +344,6 @@ | ||
| for (;;) { | ||
| const prompt = `${chalk.cyan("?")} What do you want to call it? ${chalk.dim(`(${defaultName})`)} `; | ||
| const prompt = `${chalk.cyan("?")} Project name: `; | ||
| const answer = await rl.question(prompt); | ||
| try { | ||
| return normalizeMachineName(answer || defaultName); | ||
| return normalizeMachineName(answer); | ||
| } catch (error) { | ||
@@ -355,2 +367,17 @@ console.log(chalk.red(error instanceof Error ? error.message : String(error))); | ||
| async function promptPackageManager(defaultValue: PackageManager): Promise<PackageManager> { | ||
| const choices: Array<{ value: PackageManager; label: string; hint: string }> = [ | ||
| { value: "npm", label: "npm", hint: "npm install" }, | ||
| { value: "bun", label: "bun", hint: "bun install" }, | ||
| { value: "pnpm", label: "pnpm", hint: "pnpm install" }, | ||
| { value: "skip", label: "skip", hint: "do not install now" }, | ||
| ]; | ||
| const stdin = process.stdin; | ||
| if (stdin.isTTY && process.stdout.isTTY) { | ||
| return await promptSelect("Install dependencies?", choices, defaultValue); | ||
| } | ||
| return await promptPackageManagerText(defaultValue); | ||
| } | ||
| async function promptPackageManagerText(defaultValue: PackageManager): Promise<PackageManager> { | ||
| const rl = createInterface({ input: process.stdin, output: process.stdout }); | ||
@@ -372,2 +399,115 @@ const choices = "npm, bun, pnpm, skip"; | ||
| async function promptSelect<T extends string>( | ||
| label: string, | ||
| choices: Array<{ value: T; label: string; hint?: string }>, | ||
| defaultValue: T, | ||
| ): Promise<T> { | ||
| const stdin = process.stdin; | ||
| const stdout = process.stdout; | ||
| const defaultIndex = choices.findIndex((choice) => choice.value === defaultValue); | ||
| let index = defaultIndex >= 0 ? defaultIndex : 0; | ||
| let rendered = false; | ||
| const lineCount = choices.length + 1; | ||
| return new Promise<T>((resolvePromise, reject) => { | ||
| const wasRaw = stdin.isRaw; | ||
| const render = () => { | ||
| if (rendered) { | ||
| stdout.write(`\x1b[${lineCount}A\x1b[J`); | ||
| } | ||
| rendered = true; | ||
| stdout.write(`${chalk.cyan("?")} ${label}\n`); | ||
| for (const [choiceIndex, choice] of choices.entries()) { | ||
| const selected = choiceIndex === index; | ||
| const pointer = selected ? chalk.cyan("›") : " "; | ||
| const name = selected ? chalk.cyan(choice.label) : choice.label; | ||
| const hint = choice.hint ? chalk.dim(` ${choice.hint}`) : ""; | ||
| stdout.write(`${pointer} ${name}${hint}\n`); | ||
| } | ||
| }; | ||
| const cleanup = () => { | ||
| stdin.off("data", onData); | ||
| stdin.setRawMode(wasRaw); | ||
| stdin.pause(); | ||
| stdout.write("\x1b[?25h"); | ||
| }; | ||
| const finish = () => { | ||
| const selected = choices[index]!; | ||
| if (rendered) { | ||
| stdout.write(`\x1b[${lineCount}A\x1b[J`); | ||
| } | ||
| cleanup(); | ||
| stdout.write(`${chalk.cyan("?")} ${label} ${chalk.green(selected.label)}\n`); | ||
| resolvePromise(selected.value); | ||
| }; | ||
| const cancel = () => { | ||
| cleanup(); | ||
| stdout.write("\n"); | ||
| reject(new Error("Init cancelled.")); | ||
| }; | ||
| const move = (delta: number) => { | ||
| index = (index + delta + choices.length) % choices.length; | ||
| render(); | ||
| }; | ||
| const onData = (chunk: Buffer | string) => { | ||
| const key = String(chunk); | ||
| if (key.includes("\u0003")) { | ||
| cancel(); | ||
| return; | ||
| } | ||
| for (let offset = 0; offset < key.length;) { | ||
| if (key.startsWith("\u001b[A", offset)) { | ||
| move(-1); | ||
| offset += 3; | ||
| continue; | ||
| } | ||
| if (key.startsWith("\u001b[B", offset)) { | ||
| move(1); | ||
| offset += 3; | ||
| continue; | ||
| } | ||
| const char = key[offset]!; | ||
| if (char === "\r" || char === "\n" || char === " ") { | ||
| finish(); | ||
| return; | ||
| } | ||
| if (char === "k") { | ||
| move(-1); | ||
| offset += 1; | ||
| continue; | ||
| } | ||
| if (char === "j") { | ||
| move(1); | ||
| offset += 1; | ||
| continue; | ||
| } | ||
| const numericChoice = Number(char); | ||
| if (Number.isInteger(numericChoice) && numericChoice >= 1 && numericChoice <= choices.length) { | ||
| index = numericChoice - 1; | ||
| finish(); | ||
| return; | ||
| } | ||
| offset += 1; | ||
| } | ||
| }; | ||
| stdout.write("\x1b[?25l"); | ||
| stdin.resume(); | ||
| stdin.setRawMode(true); | ||
| stdin.setEncoding("utf8"); | ||
| stdin.on("data", onData); | ||
| render(); | ||
| }); | ||
| } | ||
| async function promptSecret(label: string): Promise<string> { | ||
@@ -495,2 +635,3 @@ const stdin = process.stdin; | ||
| console.log(chalk.bold("Next steps")); | ||
| console.log(` cd ${displayProjectDir(result.projectDir)}`); | ||
| if (install.skipped) { | ||
@@ -502,2 +643,7 @@ console.log(` ${detectInstallCommand(result.packageJsonPath)}`); | ||
| function displayProjectDir(projectDir: string): string { | ||
| const path = relative(process.cwd(), projectDir); | ||
| return path && !path.startsWith("..") ? path : projectDir; | ||
| } | ||
| function printInitLine(status: "created" | "updated" | "kept", path: string): void { | ||
@@ -504,0 +650,0 @@ const color = status === "kept" ? chalk.dim : status === "updated" ? chalk.yellow : chalk.green; |
+8
-5
| import { describe, expect, test } from "bun:test"; | ||
| import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; | ||
| import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; | ||
| import { tmpdir } from "node:os"; | ||
@@ -11,3 +11,4 @@ import { join } from "node:path"; | ||
| test("creates a full fdev project", () => { | ||
| const projectDir = mkdtempSync(join(tmpdir(), "fdev-init-")); | ||
| const parentDir = mkdtempSync(join(tmpdir(), "fdev-init-")); | ||
| const projectDir = join(parentDir, "platform-api"); | ||
| const result = initProject({ | ||
@@ -21,2 +22,4 @@ projectDir, | ||
| expect(result.name).toBe("platform-api"); | ||
| expect(result.projectDir).toBe(projectDir); | ||
| expect(existsSync(projectDir)).toBe(true); | ||
| expect(result.created).toEqual({ | ||
@@ -76,6 +79,6 @@ config: true, | ||
| test("defaults empty names to fdev", () => { | ||
| expect(normalizeMachineName(" ")).toBe("fdev"); | ||
| expect(normalizeMachineName("!!!")).toBe("fdev"); | ||
| test("rejects empty names", () => { | ||
| expect(() => normalizeMachineName(" ")).toThrow("Project name is required."); | ||
| expect(() => normalizeMachineName("!!!")).toThrow("Project name is required."); | ||
| }); | ||
| }); |
+9
-13
@@ -1,8 +0,6 @@ | ||
| import { existsSync, readFileSync, writeFileSync } from "node:fs"; | ||
| import { basename, join } from "node:path"; | ||
| import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
| import { FDEV_CLI_VERSION } from "./version.ts"; | ||
| import { SDK_PACKAGE_NAME } from "./project.ts"; | ||
| export const DEFAULT_MACHINE_NAME = "fdev"; | ||
| export type InitProjectInput = { | ||
@@ -18,2 +16,3 @@ projectDir: string; | ||
| name: string; | ||
| projectDir: string; | ||
| configPath: string; | ||
@@ -41,2 +40,3 @@ envPath: string; | ||
| const name = normalizeMachineName(input.name); | ||
| mkdirSync(input.projectDir, { recursive: true }); | ||
@@ -66,2 +66,3 @@ if (existsSync(input.configPath) && !input.force) { | ||
| name, | ||
| projectDir: input.projectDir, | ||
| configPath: input.configPath, | ||
@@ -88,9 +89,8 @@ envPath, | ||
| export function defaultProjectName(projectDir: string): string { | ||
| return normalizeMachineName(packageNameFromDir(projectDir)); | ||
| } | ||
| export function normalizeMachineName(value: string): string { | ||
| const name = value.trim().toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); | ||
| return name || DEFAULT_MACHINE_NAME; | ||
| if (!name) { | ||
| throw new Error("Project name is required."); | ||
| } | ||
| return name; | ||
| } | ||
@@ -215,6 +215,2 @@ | ||
| function packageNameFromDir(projectDir: string): string { | ||
| return basename(projectDir).toLowerCase().replace(/[^a-z0-9._-]+/g, "-") || "fdev-project"; | ||
| } | ||
| function isRecord(value: unknown): value is Record<string, any> { | ||
@@ -221,0 +217,0 @@ return Boolean(value && typeof value === "object" && !Array.isArray(value)); |
+1
-1
@@ -1,1 +0,1 @@ | ||
| export const FDEV_CLI_VERSION = "0.1.5"; | ||
| export const FDEV_CLI_VERSION = "0.1.6"; |
40391
11.9%1075
13.76%+ Added
+ Added
- Removed
- Removed