Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 25 additions & 10 deletions apps/cli/src/create.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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();
Expand Down Expand Up @@ -76,6 +78,9 @@ export async function runCreate(input: CreateInput): Promise<void> {
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) {
Expand Down Expand Up @@ -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);
Expand All @@ -150,15 +160,20 @@ 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);
}

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") {
Expand Down
17 changes: 16 additions & 1 deletion apps/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] }) => {
Expand All @@ -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: {
Expand Down
97 changes: 97 additions & 0 deletions apps/cli/src/post-scaffold.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { execFileSync } 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", () => ({
execFileSync: vi.fn(),
}));

const execMock = vi.mocked(execFileSync);

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("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"],
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, args) => {
if (Array.isArray(args) && args[0] === "install") {
throw new Error("network");
}
return Buffer.from("");
});
expect(() => {
installDependencies(dir, baseConfig);
}).not.toThrow();
});
});
107 changes: 107 additions & 0 deletions apps/cli/src/post-scaffold.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { execFileSync } 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`);
}

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 {
runCommand("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 {
runCommand("git", ["rev-parse", "--git-dir"], {
cwd: target,
stdio: "ignore",
});
} catch {
try {
runCommand("git", ["init"], { cwd: target, stdio: "ignore" });
} catch {
/* install may still work without git */
}
}
}

function installArgs(pm: ProjectConfig["pm"]): {
command: string;
args: string[];
label: string;
} {
if (pm === "bun") {
return { command: "bun", args: ["install"], label: "bun install" };
}
if (pm === "npm") {
return {
command: "npm",
args: ["install", "--no-audit", "--no-fund"],
label: "npm install --no-audit --no-fund",
};
}
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 { command, args, label } = installArgs(config.pm);
writeStdout(chalk.dim(`\n ${label}`));
try {
runCommand(command, args, { cwd: target, stdio: "inherit" });
writeStdout(chalk.green("✓ Dependências instaladas."));
} catch {
writeStdout(
chalk.yellow(
`⚠ Falha ao instalar — rode \`${config.pm} install\` dentro do projeto.`,
),
);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
17 changes: 17 additions & 0 deletions apps/web/lib/build-command.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
15 changes: 5 additions & 10 deletions apps/web/lib/build-command.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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
Expand All @@ -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),
Expand Down
Loading
Loading