diff --git a/packages/cli/src/lib/__tests__/helpers.exec.test.ts b/packages/cli/src/lib/__tests__/helpers.exec.test.ts new file mode 100644 index 0000000..ac5d489 --- /dev/null +++ b/packages/cli/src/lib/__tests__/helpers.exec.test.ts @@ -0,0 +1,44 @@ +import { mkdtempSync, realpathSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "bun:test"; + +import { exec } from "../helpers"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("exec", () => { + it("runs a command and returns stdout", async () => { + const result = await exec([process.execPath, "--version"]); + + expect(result.exitCode).toBe(0); + expect(result.stdout.length).toBeGreaterThan(0); + }); + + it("respects cwd", async () => { + const cwd = realpathSync( + mkdtempSync(join(tmpdir(), "create-start-kit-dev-")) + ); + tempDirs.push(cwd); + + const result = await exec( + [process.execPath, "-e", "console.log(process.cwd())"], + { cwd } + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe(cwd); + }); + + it("returns non-zero exit code without throwing", async () => { + const result = await exec([process.execPath, "-e", "process.exit(7)"]); + + expect(result.exitCode).toBe(7); + }); +}); diff --git a/packages/cli/src/lib/helpers.ts b/packages/cli/src/lib/helpers.ts index e5cee90..b416435 100644 --- a/packages/cli/src/lib/helpers.ts +++ b/packages/cli/src/lib/helpers.ts @@ -79,17 +79,35 @@ export function generateSecret(bytes = 32): string { } export async function exec( - command: string + command: string[], + options?: { cwd?: string } ): Promise<{ stdout: string; stderr: string; exitCode: number }> { - // biome-ignore lint/correctness/noUndeclaredVariables: Bun global available at runtime - const proc = Bun.spawn(["sh", "-c", command], { - stdout: "pipe", - stderr: "pipe", - }); - const stdout = await new Response(proc.stdout).text(); - const stderr = await new Response(proc.stderr).text(); - const exitCode = await proc.exited; - return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }; + if (command.length === 0) { + return { stdout: "", stderr: "No command provided", exitCode: 1 }; + } + + try { + // biome-ignore lint/correctness/noUndeclaredVariables: Bun global available at runtime + const proc = Bun.spawn(command, { + cwd: options?.cwd, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const exitCode = await proc.exited; + + return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }; + } catch (error) { + return { + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + exitCode: 127, + }; + } } export async function testDbConnection( diff --git a/packages/cli/src/lib/state.ts b/packages/cli/src/lib/state.ts index bd1d62d..a739291 100644 --- a/packages/cli/src/lib/state.ts +++ b/packages/cli/src/lib/state.ts @@ -1,4 +1,5 @@ import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; const STATE_FILE = ".setup-state.json"; @@ -39,7 +40,7 @@ const DEFAULT_STATE: SetupState = { }; function getStatePath(): string { - return `${process.cwd()}/${STATE_FILE}`; + return join(process.cwd(), STATE_FILE); } export function loadState(): SetupState { diff --git a/packages/cli/src/phases/database.ts b/packages/cli/src/phases/database.ts index 2a182e2..68779d2 100644 --- a/packages/cli/src/phases/database.ts +++ b/packages/cli/src/phases/database.ts @@ -44,9 +44,15 @@ export async function runDatabase(state: SetupState): Promise { const s = spinner(); s.start("Creating instant Neon database via Instagres..."); - const result = await exec( - "bunx get-db --yes --env .env --key DATABASE_URL" - ); + const result = await exec([ + "bunx", + "get-db", + "--yes", + "--env", + ".env", + "--key", + "DATABASE_URL", + ]); if (result.exitCode !== 0) { s.stop("Failed to create database"); @@ -131,9 +137,13 @@ export async function runDatabase(state: SetupState): Promise { writeEnvFile(".env", envVars); // Use --force to skip interactive confirmation prompts - const migrateResult = await exec( - "bun --env-file=.env drizzle-kit push --force" - ); + const migrateResult = await exec([ + "bun", + "--env-file=.env", + "drizzle-kit", + "push", + "--force", + ]); if (migrateResult.exitCode !== 0) { ms.stop("Schema push failed"); diff --git a/packages/cli/src/phases/infra.ts b/packages/cli/src/phases/infra.ts index 5580ed8..f0bf29f 100644 --- a/packages/cli/src/phases/infra.ts +++ b/packages/cli/src/phases/infra.ts @@ -11,7 +11,7 @@ async function startDockerService( ): Promise { const s = spinner(); s.start(`Starting ${name}...`); - const result = await exec(`docker compose up -d ${service}`); + const result = await exec(["docker", "compose", "up", "-d", service]); if (result.exitCode !== 0) { s.stop(`Failed to start ${name}`); log.error(result.stderr); diff --git a/packages/cli/src/phases/scaffold.ts b/packages/cli/src/phases/scaffold.ts index 11aacee..d0d01a9 100644 --- a/packages/cli/src/phases/scaffold.ts +++ b/packages/cli/src/phases/scaffold.ts @@ -1,4 +1,5 @@ -import { existsSync } from "node:fs"; +import { existsSync, rmSync } from "node:fs"; +import { resolve } from "node:path"; import { isCancel, log, spinner, text } from "@clack/prompts"; import { downloadTemplate } from "giget"; @@ -23,14 +24,19 @@ async function fetchTemplate(targetDir: string): Promise { process.exit(1); } - await exec(`git -C "${targetDir}" init`); + const gitInit = await exec(["git", "init"], { cwd: targetDir }); + + if (gitInit.exitCode !== 0) { + log.warn("Could not initialize git repository"); + log.warn(gitInit.stderr || "git init failed"); + } } async function installDeps(targetDir: string): Promise { const s = spinner(); s.start("Installing dependencies..."); - const result = await exec(`cd "${targetDir}" && bun install`); + const result = await exec(["bun", "install"], { cwd: targetDir }); if (result.exitCode !== 0) { s.stop("Install failed"); @@ -62,7 +68,7 @@ export async function runScaffold(projectNameArg?: string): Promise { } const dirName = toKebabCase(projectName as string); - const targetDir = `${process.cwd()}/${dirName}`; + const targetDir = resolve(process.cwd(), dirName); if (existsSync(targetDir)) { log.error(`Directory "${dirName}" already exists.`); @@ -75,7 +81,7 @@ export async function runScaffold(projectNameArg?: string): Promise { await installDeps(targetDir); // Remove the state file if it exists from the template - await exec(`rm -f "${targetDir}/.setup-state.json"`); + rmSync(resolve(targetDir, ".setup-state.json"), { force: true }); log.success(`Project created in ./${dirName}`); diff --git a/packages/cli/src/theme/theme-apply.ts b/packages/cli/src/theme/theme-apply.ts index 06bcf43..ec32eb3 100644 --- a/packages/cli/src/theme/theme-apply.ts +++ b/packages/cli/src/theme/theme-apply.ts @@ -1,4 +1,5 @@ import { writeFileSync } from "node:fs"; +import { join } from "node:path"; import { log, spinner } from "@clack/prompts"; import type { BaseColorName, @@ -82,7 +83,7 @@ export function applyTheme(targetDir: string, config: ThemeConfig): void { s.start("Applying theme..."); const css = generateFullAppCss(config); - writeFileSync(`${targetDir}/src/app.css`, css, "utf-8"); + writeFileSync(join(targetDir, "src", "app.css"), css, "utf-8"); s.stop( `Theme applied: ${config.theme} (base: ${config.baseColor}, radius: ${config.radius}, font: ${config.font})`