Skip to content

Commit ec4af1d

Browse files
committed
fix(cli): make command execution cross-platform for scaffold and setup
1 parent da291e4 commit ec4af1d

5 files changed

Lines changed: 96 additions & 22 deletions

File tree

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { mkdtempSync, rmSync } from "node:fs";
2+
import { tmpdir } from "node:os";
3+
import { join } from "node:path";
4+
import { afterEach, describe, expect, it } from "bun:test";
5+
6+
import { exec } from "../helpers";
7+
8+
const tempDirs: string[] = [];
9+
10+
afterEach(() => {
11+
for (const dir of tempDirs.splice(0)) {
12+
rmSync(dir, { recursive: true, force: true });
13+
}
14+
});
15+
16+
describe("exec", () => {
17+
it("runs a command and returns stdout", async () => {
18+
const result = await exec([process.execPath, "--version"]);
19+
20+
expect(result.exitCode).toBe(0);
21+
expect(result.stdout.length).toBeGreaterThan(0);
22+
});
23+
24+
it("respects cwd", async () => {
25+
const cwd = mkdtempSync(join(tmpdir(), "create-start-kit-dev-"));
26+
tempDirs.push(cwd);
27+
28+
const result = await exec(
29+
[process.execPath, "-e", "console.log(process.cwd())"],
30+
{ cwd }
31+
);
32+
33+
expect(result.exitCode).toBe(0);
34+
expect(result.stdout).toBe(cwd);
35+
});
36+
37+
it("returns non-zero exit code without throwing", async () => {
38+
const result = await exec([process.execPath, "-e", "process.exit(7)"]);
39+
40+
expect(result.exitCode).toBe(7);
41+
});
42+
});

packages/cli/src/lib/helpers.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -79,17 +79,33 @@ export function generateSecret(bytes = 32): string {
7979
}
8080

8181
export async function exec(
82-
command: string
82+
command: string[],
83+
options?: { cwd?: string }
8384
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
84-
// biome-ignore lint/correctness/noUndeclaredVariables: Bun global available at runtime
85-
const proc = Bun.spawn(["sh", "-c", command], {
86-
stdout: "pipe",
87-
stderr: "pipe",
88-
});
89-
const stdout = await new Response(proc.stdout).text();
90-
const stderr = await new Response(proc.stderr).text();
91-
const exitCode = await proc.exited;
92-
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
85+
if (command.length === 0) {
86+
return { stdout: "", stderr: "No command provided", exitCode: 1 };
87+
}
88+
89+
try {
90+
// biome-ignore lint/correctness/noUndeclaredVariables: Bun global available at runtime
91+
const proc = Bun.spawn(command, {
92+
cwd: options?.cwd,
93+
stdout: "pipe",
94+
stderr: "pipe",
95+
});
96+
97+
const stdout = await new Response(proc.stdout).text();
98+
const stderr = await new Response(proc.stderr).text();
99+
const exitCode = await proc.exited;
100+
101+
return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode };
102+
} catch (error) {
103+
return {
104+
stdout: "",
105+
stderr: error instanceof Error ? error.message : String(error),
106+
exitCode: 127,
107+
};
108+
}
93109
}
94110

95111
export async function testDbConnection(

packages/cli/src/phases/database.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,15 @@ export async function runDatabase(state: SetupState): Promise<SetupState> {
4444
const s = spinner();
4545
s.start("Creating instant Neon database via Instagres...");
4646

47-
const result = await exec(
48-
"bunx get-db --yes --env .env --key DATABASE_URL"
49-
);
47+
const result = await exec([
48+
"bunx",
49+
"get-db",
50+
"--yes",
51+
"--env",
52+
".env",
53+
"--key",
54+
"DATABASE_URL",
55+
]);
5056

5157
if (result.exitCode !== 0) {
5258
s.stop("Failed to create database");
@@ -131,9 +137,13 @@ export async function runDatabase(state: SetupState): Promise<SetupState> {
131137
writeEnvFile(".env", envVars);
132138

133139
// Use --force to skip interactive confirmation prompts
134-
const migrateResult = await exec(
135-
"bun --env-file=.env drizzle-kit push --force"
136-
);
140+
const migrateResult = await exec([
141+
"bun",
142+
"--env-file=.env",
143+
"drizzle-kit",
144+
"push",
145+
"--force",
146+
]);
137147

138148
if (migrateResult.exitCode !== 0) {
139149
ms.stop("Schema push failed");

packages/cli/src/phases/infra.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ async function startDockerService(
1111
): Promise<void> {
1212
const s = spinner();
1313
s.start(`Starting ${name}...`);
14-
const result = await exec(`docker compose up -d ${service}`);
14+
const result = await exec(["docker", "compose", "up", "-d", service]);
1515
if (result.exitCode !== 0) {
1616
s.stop(`Failed to start ${name}`);
1717
log.error(result.stderr);

packages/cli/src/phases/scaffold.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { existsSync } from "node:fs";
1+
import { existsSync, rmSync } from "node:fs";
2+
import { resolve } from "node:path";
23
import { isCancel, log, spinner, text } from "@clack/prompts";
34
import { downloadTemplate } from "giget";
45

@@ -23,14 +24,19 @@ async function fetchTemplate(targetDir: string): Promise<void> {
2324
process.exit(1);
2425
}
2526

26-
await exec(`git -C "${targetDir}" init`);
27+
const gitInit = await exec(["git", "init"], { cwd: targetDir });
28+
29+
if (gitInit.exitCode !== 0) {
30+
log.warn("Could not initialize git repository");
31+
log.warn(gitInit.stderr || "git init failed");
32+
}
2733
}
2834

2935
async function installDeps(targetDir: string): Promise<void> {
3036
const s = spinner();
3137
s.start("Installing dependencies...");
3238

33-
const result = await exec(`cd "${targetDir}" && bun install`);
39+
const result = await exec(["bun", "install"], { cwd: targetDir });
3440

3541
if (result.exitCode !== 0) {
3642
s.stop("Install failed");
@@ -62,7 +68,7 @@ export async function runScaffold(projectNameArg?: string): Promise<string> {
6268
}
6369

6470
const dirName = toKebabCase(projectName as string);
65-
const targetDir = `${process.cwd()}/${dirName}`;
71+
const targetDir = resolve(process.cwd(), dirName);
6672

6773
if (existsSync(targetDir)) {
6874
log.error(`Directory "${dirName}" already exists.`);
@@ -75,7 +81,7 @@ export async function runScaffold(projectNameArg?: string): Promise<string> {
7581
await installDeps(targetDir);
7682

7783
// Remove the state file if it exists from the template
78-
await exec(`rm -f "${targetDir}/.setup-state.json"`);
84+
rmSync(resolve(targetDir, ".setup-state.json"), { force: true });
7985

8086
log.success(`Project created in ./${dirName}`);
8187

0 commit comments

Comments
 (0)