From 3095a14b1dbbc85cd390251dde571047c6f81e1e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 16:31:06 -0300 Subject: [PATCH 1/2] Fix scaffold audit gaps: install, names, writer safety, and docs links. Implement post-scaffold git init and package install, centralize project name sanitization, harden template writes with path checks and batching, and stop doc link checker from scanning fenced code blocks. Co-authored-by: Cursor --- apps/cli/src/create.ts | 35 +++++--- apps/cli/src/index.ts | 17 +++- apps/cli/src/post-scaffold.test.ts | 89 +++++++++++++++++++ apps/cli/src/post-scaffold.ts | 78 ++++++++++++++++ apps/web/lib/build-command.test.ts | 17 ++++ apps/web/lib/build-command.ts | 15 ++-- .../template-generator/src/writer.test.ts | 35 ++++++++ packages/template-generator/src/writer.ts | 36 ++++++-- packages/types/package.json | 6 +- packages/types/src/index.ts | 14 +-- packages/types/src/project-name.test.ts | 34 +++++++ packages/types/src/project-name.ts | 48 ++++++++++ pnpm-lock.yaml | 3 + scripts/check-doc-links.mjs | 11 ++- 14 files changed, 401 insertions(+), 37 deletions(-) create mode 100644 apps/cli/src/post-scaffold.test.ts create mode 100644 apps/cli/src/post-scaffold.ts create mode 100644 apps/web/lib/build-command.test.ts create mode 100644 packages/template-generator/src/writer.test.ts create mode 100644 packages/types/src/project-name.test.ts create mode 100644 packages/types/src/project-name.ts diff --git a/apps/cli/src/create.ts b/apps/cli/src/create.ts index 7356f5f..d8cd5f4 100644 --- a/apps/cli/src/create.ts +++ b/apps/cli/src/create.ts @@ -1,7 +1,6 @@ -import { execSync } from "node:child_process"; import { existsSync } from "node:fs"; import { createRequire } from "node:module"; -import { basename, dirname, isAbsolute, resolve } from "node:path"; +import { dirname, isAbsolute, resolve } from "node:path"; import { scaffold } from "@veloz-stack/template-generator/scaffold"; import { AddonId, @@ -11,11 +10,14 @@ import { getUiDisableReason, MODULE_IDS, PRESETS, + projectNameValidationError, + sanitizeProjectName, validateConfig, } from "@veloz-stack/types"; import type { ModuleId, ProjectConfig } from "./stack-types.js"; import chalk from "chalk"; import { gatherInteractive } from "./prompts.js"; +import { initGitRepo, installDependencies } from "./post-scaffold.js"; const require = createRequire(import.meta.url); const CLI_VERSION = readCliVersion(); @@ -76,6 +78,9 @@ export async function runCreate(input: CreateInput): Promise { const config = await resolveConfig(input); applyUiFallback(input, config); const target = resolveTargetDirectory(config); + if (target === null) { + process.exit(1); + } const errors = validateConfig(config); if (errors.length > 0) { @@ -121,13 +126,18 @@ function applyUiFallback(input: CreateInput, config: ProjectConfig): void { } } -function resolveTargetDirectory(config: ProjectConfig): string { +function resolveTargetDirectory(config: ProjectConfig): string | null { const raw = config.projectName.trim(); - const safeName = - basename(raw) - .replaceAll(/[^a-z0-9-_]/gi, "-") - .replaceAll(/^-+/g, "") - .toLowerCase() || "my-veloz-stack"; + const safeName = sanitizeProjectName(raw, DEFAULT_CONFIG.projectName); + const nameError = projectNameValidationError(safeName); + if (nameError) { + console.error( + chalk.red( + `\n✗ Nome de projeto inválido "${safeName}" após normalização: ${nameError}`, + ), + ); + return null; + } config.projectName = safeName; if (isAbsolute(raw)) { return resolve(dirname(raw), safeName); @@ -150,7 +160,10 @@ async function writeScaffold( cliVersion: CLI_VERSION, }); if (config.git) { - execSync("git init", { cwd: target, stdio: "ignore" }); + initGitRepo(target); + } + if (config.install) { + installDependencies(target, config); } printScaffoldSuccess(config, fileCount); } @@ -158,7 +171,9 @@ async function writeScaffold( function printScaffoldSuccess(config: ProjectConfig, fileCount: number): void { writeStdout(chalk.green(`\n✓ ${fileCount} arquivos criados.`)); writeStdout(chalk.dim(` cd ${config.projectName}`)); - writeStdout(chalk.dim(` ${config.pm} install`)); + if (!config.install) { + writeStdout(chalk.dim(` ${config.pm} install`)); + } writeStdout(chalk.dim(` ${config.pm} dev`)); if (config.deploy === "veloz") { diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 6f64dfc..c2df71e 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -15,9 +15,24 @@ import { RuntimeId as RuntimeIdSchema, UiId as UiIdSchema, } from "@veloz-stack/types"; +import { createRequire } from "node:module"; import { Cli, z } from "incur"; import { runCreate } from "./create.js"; +const require = createRequire(import.meta.url); +const CLI_VERSION: string = (() => { + const raw: unknown = require("../package.json"); + if ( + typeof raw === "object" && + raw !== null && + "version" in raw && + typeof raw.version === "string" + ) { + return raw.version; + } + throw new Error("Invalid apps/cli/package.json"); +})(); + // Re-wrap each enum with incur's own zod instance so schema identity checks // inside incur recognize them. const reenum = (s: { options: readonly string[] }) => { @@ -44,7 +59,7 @@ const PackageManagerId = reenum(PackageManagerIdSchema); const UiId = reenum(UiIdSchema); const cli = Cli.create("create-veloz-stack", { - version: "0.0.1", + version: CLI_VERSION, description: "Scaffolder TypeScript opinado pro Brasil — PIX, LGPD, SMS, pronto pro Claude. Deploy no Veloz.", sync: { diff --git a/apps/cli/src/post-scaffold.test.ts b/apps/cli/src/post-scaffold.test.ts new file mode 100644 index 0000000..b0a5435 --- /dev/null +++ b/apps/cli/src/post-scaffold.test.ts @@ -0,0 +1,89 @@ +import { execSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ProjectConfig } from "./stack-types.js"; +import { initGitRepo, installDependencies } from "./post-scaffold.js"; + +vi.mock("node:child_process", () => ({ + execSync: vi.fn(), +})); + +const execMock = vi.mocked(execSync); + +const baseConfig = { + projectName: "demo", + frontend: "tanstack-start", + mobile: "none", + desktop: "none", + backend: "hono", + runtime: "bun", + api: "orpc", + db: "postgres", + orm: "drizzle", + dbHosting: "veloz", + auth: "better-auth", + deploy: "veloz", + pm: "bun", + ui: "shadcn", + examples: [], + modules: [], + addons: [], + oxlintStrict: false, + lefthookCi: false, + lefthookAdvanced: false, + git: true, + install: true, + preset: "veloz-br", +} satisfies ProjectConfig; + +afterEach(() => { + execMock.mockReset(); +}); + +describe("initGitRepo", () => { + it("runs git init", () => { + const dir = mkdtempSync(join(tmpdir(), "veloz-git-")); + initGitRepo(dir); + expect(execMock).toHaveBeenCalledWith( + "git init", + expect.objectContaining({ cwd: dir }), + ); + }); + + it("does not throw when git init fails", () => { + execMock.mockImplementation(() => { + throw new Error("git missing"); + }); + expect(() => { + initGitRepo("/tmp/x"); + }).not.toThrow(); + }); +}); + +describe("installDependencies", () => { + it("runs bun install when pm is bun", () => { + const dir = mkdtempSync(join(tmpdir(), "veloz-install-")); + writeFileSync(join(dir, ".env.example"), "FOO=1\n"); + execMock.mockImplementation(() => Buffer.from("")); + installDependencies(dir, baseConfig); + expect(execMock).toHaveBeenCalledWith( + "bun install", + expect.objectContaining({ cwd: dir, stdio: "inherit" }), + ); + }); + + it("warns but does not throw when install fails", () => { + const dir = mkdtempSync(join(tmpdir(), "veloz-install-fail-")); + execMock.mockImplementation((cmd) => { + if (typeof cmd === "string" && cmd.includes("install")) { + throw new Error("network"); + } + return Buffer.from(""); + }); + expect(() => { + installDependencies(dir, baseConfig); + }).not.toThrow(); + }); +}); diff --git a/apps/cli/src/post-scaffold.ts b/apps/cli/src/post-scaffold.ts new file mode 100644 index 0000000..5bf959a --- /dev/null +++ b/apps/cli/src/post-scaffold.ts @@ -0,0 +1,78 @@ +import { execSync } from "node:child_process"; +import { copyFileSync, existsSync } from "node:fs"; +import { join } from "node:path"; +import type { ProjectConfig } from "./stack-types.js"; +import chalk from "chalk"; + +function writeStdout(line: string): void { + process.stdout.write(`${line}\n`); +} + +/** Initialize git when requested; never fails the scaffold if git is unavailable. */ +export function initGitRepo(target: string): void { + try { + execSync("git init", { cwd: target, stdio: "ignore" }); + } catch { + writeStdout( + chalk.yellow( + "⚠ Não foi possível inicializar o git — você pode rodar `git init` manualmente.", + ), + ); + } +} + +function ensureEnvFile(target: string): void { + const example = join(target, ".env.example"); + const env = join(target, ".env"); + if (existsSync(example) && !existsSync(env)) { + copyFileSync(example, env); + } +} + +/** Lefthook prepare scripts expect a git repo even when `config.git` is false. */ +function ensureGitForHooks(target: string): void { + try { + execSync("git rev-parse --git-dir", { cwd: target, stdio: "ignore" }); + } catch { + try { + execSync("git init", { cwd: target, stdio: "ignore" }); + } catch { + /* install may still work without git */ + } + } +} + +function installCommand(pm: ProjectConfig["pm"]): string { + if (pm === "bun") { + return "bun install"; + } + if (pm === "npm") { + return "npm install --no-audit --no-fund"; + } + return "pnpm install"; +} + +/** + * Install dependencies in the scaffolded project when `config.install` is true. + * Warns and continues on failure so files on disk are not left in a confusing state. + */ +export function installDependencies( + target: string, + config: ProjectConfig, +): void { + ensureEnvFile(target); + ensureGitForHooks(target); + + const cmd = installCommand(config.pm); + writeStdout(chalk.dim(`\n ${cmd}`)); + try { + execSync(cmd, { cwd: target, stdio: "inherit" }); + writeStdout(chalk.green("✓ Dependências instaladas.")); + } catch { + writeStdout( + chalk.yellow( + `⚠ Falha ao instalar — rode \`${config.pm} install\` dentro do projeto.`, + ), + ); + } +} diff --git a/apps/web/lib/build-command.test.ts b/apps/web/lib/build-command.test.ts new file mode 100644 index 0000000..77f329f --- /dev/null +++ b/apps/web/lib/build-command.test.ts @@ -0,0 +1,17 @@ +import { DEFAULT_CONFIG } from "@veloz-stack/types"; +import { describe, expect, it } from "vitest"; +import { buildCommand } from "./build-command"; +import type { ProjectConfig } from "@/lib/veloz-stack-types"; + +const base: ProjectConfig = { ...DEFAULT_CONFIG }; + +describe("buildCommand", () => { + it("sanitizes project names the same way as the CLI", () => { + const cmd = buildCommand({ + ...base, + projectName: "_Foo.Bar!", + }); + expect(cmd).toContain("foo-bar"); + expect(cmd).not.toContain("Foo.Bar"); + }); +}); diff --git a/apps/web/lib/build-command.ts b/apps/web/lib/build-command.ts index 00fde2d..ca23a01 100644 --- a/apps/web/lib/build-command.ts +++ b/apps/web/lib/build-command.ts @@ -1,4 +1,4 @@ -import { DEFAULT_CONFIG } from "@veloz-stack/types"; +import { DEFAULT_CONFIG, sanitizeProjectName } from "@veloz-stack/types"; import type { ProjectConfig } from "@/lib/veloz-stack-types"; @@ -84,14 +84,6 @@ function toolingFlags(cfg: ProjectConfig): string[] { return flags; } -function sanitizeProjectName(name: string): string { - const normalized = name.trim() || DEFAULT_CONFIG.projectName; - const sanitized = normalized - .replaceAll(/[^a-zA-Z0-9._-]/g, "-") - .replaceAll(/^-+/g, ""); - return sanitized || DEFAULT_CONFIG.projectName; -} - /** * Build a copy-paste CLI command for the current stack config. * Omits flags that match {@link DEFAULT_CONFIG}; emits explicit `--modules ''` when @@ -103,7 +95,10 @@ export function buildCommand( ): string { const runner = opts?.pm ?? cfg.pm; const head = createHead(runner); - const projectName = sanitizeProjectName(cfg.projectName); + const projectName = sanitizeProjectName( + cfg.projectName, + DEFAULT_CONFIG.projectName, + ); const flags = [ ...stackFlags(cfg), ...moduleAndExampleFlags(cfg), diff --git a/packages/template-generator/src/writer.test.ts b/packages/template-generator/src/writer.test.ts new file mode 100644 index 0000000..5a026db --- /dev/null +++ b/packages/template-generator/src/writer.test.ts @@ -0,0 +1,35 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { describe, expect, it } from "vitest"; +import { VirtualFs } from "./vfs"; +import { writeTree } from "./writer"; + +describe("writeTree", () => { + it("writes files under the target root", async () => { + const root = await mkdtemp(join(tmpdir(), "veloz-writer-")); + try { + const vfs = new VirtualFs(); + vfs.write("apps/web/package.json", '{"name":"web"}\n'); + const count = await writeTree(vfs, root); + expect(count).toBe(1); + const body = await readFile(join(root, "apps/web/package.json"), "utf8"); + expect(body).toContain('"name":"web"'); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); + + it("refuses path traversal outside the scaffold root", async () => { + const root = await mkdtemp(join(tmpdir(), "veloz-writer-")); + try { + const vfs = new VirtualFs(); + vfs.write("../escape.txt", "nope"); + await expect(writeTree(vfs, root)).rejects.toThrow( + /outside scaffold root/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/template-generator/src/writer.ts b/packages/template-generator/src/writer.ts index 882514b..c8c081e 100644 --- a/packages/template-generator/src/writer.ts +++ b/packages/template-generator/src/writer.ts @@ -1,19 +1,43 @@ import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; import type { VirtualFs } from "./vfs"; +const WRITE_CONCURRENCY = 16; + +function assertPathInsideRoot(rootDir: string, absPath: string): void { + const rel = relative(rootDir, absPath); + if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) { + return; + } + throw new Error( + `Refusing to write outside scaffold root: ${relative(rootDir, absPath)}`, + ); +} + /** Writes every entry in the VFS to `rootDir`, creating parents as needed. */ export async function writeTree( vfs: VirtualFs, rootDir: string, ): Promise { const entries = [...vfs.entries()]; + const root = resolve(rootDir); + + const batches: [string, string][][] = []; + for (let i = 0; i < entries.length; i += WRITE_CONCURRENCY) { + batches.push(entries.slice(i, i + WRITE_CONCURRENCY)); + } + await Promise.all( - entries.map(async ([path, content]) => { - const abs = join(rootDir, path); - await mkdir(dirname(abs), { recursive: true }); - await writeFile(abs, content, "utf8"); - }), + batches.map((batch) => + Promise.all( + batch.map(async ([path, content]) => { + const abs = resolve(root, path); + assertPathInsideRoot(root, abs); + await mkdir(dirname(abs), { recursive: true }); + await writeFile(abs, content, "utf8"); + }), + ), + ), ); return entries.length; } diff --git a/packages/types/package.json b/packages/types/package.json index b18e0ce..4e854e9 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -15,12 +15,14 @@ "./resolve-stack-axis": "./src/resolve-stack-axis.ts" }, "scripts": { - "check-types": "tsc --noEmit" + "check-types": "tsc --noEmit", + "test": "vitest run" }, "dependencies": { "zod": "catalog:" }, "devDependencies": { - "typescript": "catalog:" + "typescript": "catalog:", + "vitest": "catalog:" } } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index af3239f..6196b1b 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -4,6 +4,7 @@ */ import { z } from "zod"; import { MODULE_IDS } from "./modules"; +import { ProjectNameSchema } from "./project-name"; /** Web framework axis (TanStack Start, Next.js, meta-frameworks, or none). */ export const FrontendId = z.enum([ @@ -136,12 +137,7 @@ export type ModuleId = z.infer; /** Full stack selection for scaffolding (CLI flags, web builder state, generator input). */ export const ProjectConfig = z.object({ - projectName: z - .string() - .min(1) - .regex(/^[a-z0-9][a-z0-9-_]*$/, { - message: "lowercase letters, digits, hyphen, underscore", - }), + projectName: ProjectNameSchema, frontend: FrontendId, mobile: MobileId.default("none"), desktop: DesktopId.default("none"), @@ -235,3 +231,9 @@ export { VELOZ_STACK_CONFIG_FILENAME, VELOZ_STACK_CONFIG_SCHEMA_URL, } from "./veloz-stack-config"; +export { + PROJECT_NAME_FALLBACK, + ProjectNameSchema, + projectNameValidationError, + sanitizeProjectName, +} from "./project-name"; diff --git a/packages/types/src/project-name.test.ts b/packages/types/src/project-name.test.ts new file mode 100644 index 0000000..a994672 --- /dev/null +++ b/packages/types/src/project-name.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { + projectNameValidationError, + sanitizeProjectName, +} from "./project-name"; + +describe("sanitizeProjectName", () => { + it("lowercases while keeping allowed separators", () => { + expect(sanitizeProjectName("My_App")).toBe("my_app"); + }); + + it("strips leading underscores and trailing hyphens", () => { + expect(sanitizeProjectName("_Foo.Bar!")).toBe("foo-bar"); + }); + + it("falls back when input is only symbols", () => { + expect(sanitizeProjectName("@@@")).toBe("my-veloz-stack"); + }); +}); + +describe("projectNameValidationError", () => { + it("accepts valid slugs", () => { + expect(projectNameValidationError("my-app")).toBeNull(); + }); + + it("rejects names starting with underscore", () => { + expect(projectNameValidationError("_bad")).not.toBeNull(); + }); + + it("accepts sanitized output from messy input", () => { + const name = sanitizeProjectName("_Foo.Bar!"); + expect(projectNameValidationError(name)).toBeNull(); + }); +}); diff --git a/packages/types/src/project-name.ts b/packages/types/src/project-name.ts new file mode 100644 index 0000000..852109f --- /dev/null +++ b/packages/types/src/project-name.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +function fileBaseName(raw: string): string { + const slash = Math.max(raw.lastIndexOf("/"), raw.lastIndexOf("\\")); + return slash >= 0 ? raw.slice(slash + 1) : raw; +} + +/** Default folder name when input sanitizes to empty. */ +export const PROJECT_NAME_FALLBACK = "my-veloz-stack"; + +/** Canonical project folder / npm scope segment (matches {@link ProjectConfig}). */ +export const ProjectNameSchema = z + .string() + .min(1) + .regex(/^[a-z0-9][a-z0-9-_]*$/, { + message: + "Use lowercase letters, digits, hyphen, or underscore; name must start with a letter or digit", + }); + +/** + * Normalize user input into a lowercase npm-safe project slug. + * Strips leading/trailing separators and disallowed characters. + */ +export function sanitizeProjectName( + raw: string, + fallback = PROJECT_NAME_FALLBACK, +): string { + const trimmed = raw.trim(); + const base = fileBaseName(trimmed) || fallback; + const name = base + .replaceAll(/[^a-z0-9-_]/gi, "-") + .replaceAll(/^[-_]+/g, "") + .replaceAll(/[-_]+$/g, "") + .toLowerCase(); + return name || fallback; +} + +/** Returns a validation message when `name` is not a valid {@link ProjectNameSchema} value. */ +export function projectNameValidationError(name: string): string | null { + const result = ProjectNameSchema.safeParse(name); + if (result.success) { + return null; + } + return ( + result.error.issues[0]?.message ?? + "Invalid project name (lowercase letters, digits, hyphen, underscore)" + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fa27950..b2b15d6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -228,6 +228,9 @@ importers: typescript: specifier: 'catalog:' version: 6.0.3 + vitest: + specifier: 'catalog:' + version: 4.1.7(@types/node@20.19.41)(vite@7.3.2(@types/node@20.19.41)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.3)) packages: diff --git a/scripts/check-doc-links.mjs b/scripts/check-doc-links.mjs index 3e582cc..bb39b9b 100644 --- a/scripts/check-doc-links.mjs +++ b/scripts/check-doc-links.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node /** * Verify relative markdown links resolve to files in the repo. - * Skips http(s), mailto, and anchor-only targets. + * Skips http(s), mailto, anchor-only targets, and links inside fenced code blocks. */ import fs from "node:fs"; import path from "node:path"; @@ -28,6 +28,13 @@ function listMarkdown(dir, acc = []) { return acc; } +/** @param {string} text */ +function stripFencedCodeBlocks(text) { + return text + .replaceAll(/```[\s\S]*?```/g, "") + .replaceAll(/~~~[\s\S]*?~~~/g, ""); +} + const SKIP_PREFIXES = [ "docs/plans/archive/", "packages/template-generator/templates/", @@ -41,7 +48,7 @@ for (const file of listMarkdown(ROOT)) { if (SKIP_PREFIXES.some((p) => relFile.startsWith(p))) { continue; } - const text = fs.readFileSync(file, "utf8"); + const text = stripFencedCodeBlocks(fs.readFileSync(file, "utf8")); const dir = path.dirname(file); for (const m of text.matchAll(linkRe)) { const raw = m[1].trim(); From f7e456e2841bd79d60e0f31cecddf63e29d3c9b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 30 May 2026 16:40:52 -0300 Subject: [PATCH 2/2] Address CodeRabbit: bounded writer concurrency and install guard. Honor config.install inside installDependencies, use execFileSync for git/install commands, and write template files in bounded batches without unbounded parallel Promise.all. Co-authored-by: Cursor --- apps/cli/src/post-scaffold.test.ts | 22 ++++++---- apps/cli/src/post-scaffold.ts | 51 ++++++++++++++++++----- packages/template-generator/src/writer.ts | 36 +++++++++------- 3 files changed, 75 insertions(+), 34 deletions(-) diff --git a/apps/cli/src/post-scaffold.test.ts b/apps/cli/src/post-scaffold.test.ts index b0a5435..b43b3d4 100644 --- a/apps/cli/src/post-scaffold.test.ts +++ b/apps/cli/src/post-scaffold.test.ts @@ -1,4 +1,4 @@ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { mkdtempSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -7,10 +7,10 @@ import type { ProjectConfig } from "./stack-types.js"; import { initGitRepo, installDependencies } from "./post-scaffold.js"; vi.mock("node:child_process", () => ({ - execSync: vi.fn(), + execFileSync: vi.fn(), })); -const execMock = vi.mocked(execSync); +const execMock = vi.mocked(execFileSync); const baseConfig = { projectName: "demo", @@ -47,7 +47,8 @@ describe("initGitRepo", () => { const dir = mkdtempSync(join(tmpdir(), "veloz-git-")); initGitRepo(dir); expect(execMock).toHaveBeenCalledWith( - "git init", + "git", + ["init"], expect.objectContaining({ cwd: dir }), ); }); @@ -63,21 +64,28 @@ describe("initGitRepo", () => { }); describe("installDependencies", () => { + it("no-ops when config.install is false", () => { + const dir = mkdtempSync(join(tmpdir(), "veloz-install-skip-")); + installDependencies(dir, { ...baseConfig, install: false }); + expect(execMock).not.toHaveBeenCalled(); + }); + it("runs bun install when pm is bun", () => { const dir = mkdtempSync(join(tmpdir(), "veloz-install-")); writeFileSync(join(dir, ".env.example"), "FOO=1\n"); execMock.mockImplementation(() => Buffer.from("")); installDependencies(dir, baseConfig); expect(execMock).toHaveBeenCalledWith( - "bun install", + "bun", + ["install"], expect.objectContaining({ cwd: dir, stdio: "inherit" }), ); }); it("warns but does not throw when install fails", () => { const dir = mkdtempSync(join(tmpdir(), "veloz-install-fail-")); - execMock.mockImplementation((cmd) => { - if (typeof cmd === "string" && cmd.includes("install")) { + execMock.mockImplementation((_cmd, args) => { + if (Array.isArray(args) && args[0] === "install") { throw new Error("network"); } return Buffer.from(""); diff --git a/apps/cli/src/post-scaffold.ts b/apps/cli/src/post-scaffold.ts index 5bf959a..be4ae59 100644 --- a/apps/cli/src/post-scaffold.ts +++ b/apps/cli/src/post-scaffold.ts @@ -1,4 +1,4 @@ -import { execSync } from "node:child_process"; +import { execFileSync } from "node:child_process"; import { copyFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import type { ProjectConfig } from "./stack-types.js"; @@ -8,10 +8,23 @@ function writeStdout(line: string): void { process.stdout.write(`${line}\n`); } +interface RunOptions { + cwd: string; + stdio: "ignore" | "inherit"; +} + +function runCommand( + command: string, + args: string[], + options: RunOptions, +): void { + execFileSync(command, args, options); +} + /** Initialize git when requested; never fails the scaffold if git is unavailable. */ export function initGitRepo(target: string): void { try { - execSync("git init", { cwd: target, stdio: "ignore" }); + runCommand("git", ["init"], { cwd: target, stdio: "ignore" }); } catch { writeStdout( chalk.yellow( @@ -32,41 +45,57 @@ function ensureEnvFile(target: string): void { /** Lefthook prepare scripts expect a git repo even when `config.git` is false. */ function ensureGitForHooks(target: string): void { try { - execSync("git rev-parse --git-dir", { cwd: target, stdio: "ignore" }); + runCommand("git", ["rev-parse", "--git-dir"], { + cwd: target, + stdio: "ignore", + }); } catch { try { - execSync("git init", { cwd: target, stdio: "ignore" }); + runCommand("git", ["init"], { cwd: target, stdio: "ignore" }); } catch { /* install may still work without git */ } } } -function installCommand(pm: ProjectConfig["pm"]): string { +function installArgs(pm: ProjectConfig["pm"]): { + command: string; + args: string[]; + label: string; +} { if (pm === "bun") { - return "bun install"; + return { command: "bun", args: ["install"], label: "bun install" }; } if (pm === "npm") { - return "npm install --no-audit --no-fund"; + return { + command: "npm", + args: ["install", "--no-audit", "--no-fund"], + label: "npm install --no-audit --no-fund", + }; } - return "pnpm install"; + return { command: "pnpm", args: ["install"], label: "pnpm install" }; } /** * Install dependencies in the scaffolded project when `config.install` is true. + * No-ops when `config.install` is false (including direct callers and tests). * Warns and continues on failure so files on disk are not left in a confusing state. */ export function installDependencies( target: string, config: ProjectConfig, ): void { + if (!config.install) { + return; + } + ensureEnvFile(target); ensureGitForHooks(target); - const cmd = installCommand(config.pm); - writeStdout(chalk.dim(`\n ${cmd}`)); + const { command, args, label } = installArgs(config.pm); + writeStdout(chalk.dim(`\n ${label}`)); try { - execSync(cmd, { cwd: target, stdio: "inherit" }); + runCommand(command, args, { cwd: target, stdio: "inherit" }); writeStdout(chalk.green("✓ Dependências instaladas.")); } catch { writeStdout( diff --git a/packages/template-generator/src/writer.ts b/packages/template-generator/src/writer.ts index c8c081e..f97790b 100644 --- a/packages/template-generator/src/writer.ts +++ b/packages/template-generator/src/writer.ts @@ -19,25 +19,29 @@ export async function writeTree( vfs: VirtualFs, rootDir: string, ): Promise { - const entries = [...vfs.entries()]; + const entries = [...vfs.entries()] as [string, string][]; const root = resolve(rootDir); - const batches: [string, string][][] = []; - for (let i = 0; i < entries.length; i += WRITE_CONCURRENCY) { - batches.push(entries.slice(i, i + WRITE_CONCURRENCY)); + async function writeBatch(batch: [string, string][]): Promise { + await Promise.all( + batch.map(async ([path, content]) => { + const abs = resolve(root, path); + assertPathInsideRoot(root, abs); + await mkdir(dirname(abs), { recursive: true }); + await writeFile(abs, content, "utf8"); + }), + ); } - await Promise.all( - batches.map((batch) => - Promise.all( - batch.map(async ([path, content]) => { - const abs = resolve(root, path); - assertPathInsideRoot(root, abs); - await mkdir(dirname(abs), { recursive: true }); - await writeFile(abs, content, "utf8"); - }), - ), - ), - ); + async function writeFromOffset(offset: number): Promise { + if (offset >= entries.length) { + return; + } + const batch = entries.slice(offset, offset + WRITE_CONCURRENCY); + await writeBatch(batch); + await writeFromOffset(offset + WRITE_CONCURRENCY); + } + + await writeFromOffset(0); return entries.length; }