diff --git a/packages/cli/src/cli/commands/project/validate.ts b/packages/cli/src/cli/commands/project/validate.ts new file mode 100644 index 00000000..cb3b82b1 --- /dev/null +++ b/packages/cli/src/cli/commands/project/validate.ts @@ -0,0 +1,180 @@ +import { dirname, join, relative } from "node:path"; +import { log } from "@clack/prompts"; +import { Command } from "commander"; +import { globby } from "globby"; +import { z } from "zod"; +import type { CLIContext } from "@/cli/types.js"; +import { runCommand } from "@/cli/utils/index.js"; +import type { RunCommandResult } from "@/cli/utils/runCommand.js"; +import { + CONFIG_FILE_EXTENSION_GLOB, + FUNCTION_CONFIG_GLOB, +} from "@/core/consts.js"; +import { + ConfigNotFoundError, + findProjectRoot, + ProjectConfigSchema, + pathExists, + readJsonFile, +} from "@/core/index.js"; +import { AgentConfigSchema } from "@/core/resources/agent/schema.js"; +import { ConnectorResourceSchema } from "@/core/resources/connector/schema.js"; +import { EntitySchema } from "@/core/resources/entity/schema.js"; +import { FunctionConfigSchema } from "@/core/resources/function/schema.js"; + +interface FileResult { + relativePath: string; + valid: boolean; + errorText?: string; +} + +async function validateFiles( + dir: string, + glob: string, + schema: z.ZodType, + projectRoot: string, +): Promise { + if (!(await pathExists(dir))) { + return []; + } + + const files = await globby(glob, { cwd: dir, absolute: true }); + const results: FileResult[] = []; + + for (const filePath of files) { + const relativePath = relative(projectRoot, filePath); + try { + const parsed = await readJsonFile(filePath); + const result = schema.safeParse(parsed); + if (result.success) { + results.push({ relativePath, valid: true }); + } else { + results.push({ + relativePath, + valid: false, + errorText: z.prettifyError(result.error), + }); + } + } catch (error) { + results.push({ + relativePath, + valid: false, + errorText: error instanceof Error ? error.message : String(error), + }); + } + } + + return results; +} + +function printResults(results: FileResult[]): void { + for (const result of results) { + if (result.valid) { + log.success(result.relativePath); + } else { + const indented = result.errorText + ?.split("\n") + .map((line) => ` ${line}`) + .join("\n"); + log.error(`${result.relativePath}\n${indented ?? ""}`); + } + } +} + +async function validateAction(): Promise { + const found = await findProjectRoot(); + if (!found) { + throw new ConfigNotFoundError(); + } + + const { root, configPath } = found; + const relConfigPath = relative(root, configPath); + const allResults: FileResult[] = []; + + // Validate project config first — we need it to know resource dirs + const configParsed = await readJsonFile(configPath); + const configResult = ProjectConfigSchema.safeParse(configParsed); + + if (!configResult.success) { + allResults.push({ + relativePath: relConfigPath, + valid: false, + errorText: z.prettifyError(configResult.error), + }); + printResults(allResults); + process.exitCode = 1; + return { outroMessage: "1 file checked — 0 passed, 1 failed" }; + } + + allResults.push({ relativePath: relConfigPath, valid: true }); + + const config = configResult.data; + const configDir = dirname(configPath); + + // Validate all resource types in parallel + const [entityResults, agentResults, connectorResults, functionResults] = + await Promise.all([ + validateFiles( + join(configDir, config.entitiesDir), + `*.${CONFIG_FILE_EXTENSION_GLOB}`, + EntitySchema, + root, + ), + validateFiles( + join(configDir, config.agentsDir), + `*.${CONFIG_FILE_EXTENSION_GLOB}`, + AgentConfigSchema, + root, + ), + validateFiles( + join(configDir, config.connectorsDir), + `*.${CONFIG_FILE_EXTENSION_GLOB}`, + ConnectorResourceSchema, + root, + ), + validateFiles( + join(configDir, config.functionsDir), + FUNCTION_CONFIG_GLOB, + FunctionConfigSchema, + root, + ), + ]); + + allResults.push( + ...entityResults, + ...agentResults, + ...connectorResults, + ...functionResults, + ); + + printResults(allResults); + + const failCount = allResults.filter((r) => !r.valid).length; + const passCount = allResults.length - failCount; + const total = allResults.length; + + if (failCount > 0) { + process.exitCode = 1; + } + + return { + outroMessage: + failCount > 0 + ? `${total} file${total !== 1 ? "s" : ""} checked — ${passCount} passed, ${failCount} failed` + : `All ${passCount} file${passCount !== 1 ? "s" : ""} valid`, + }; +} + +export function getValidateCommand(context: CLIContext): Command { + return new Command("validate") + .description( + "Validate all resource config files in the project (entities, functions, agents, connectors)", + ) + .action(async () => { + await runCommand( + validateAction, + { requireAuth: false, requireAppConfig: false }, + context, + ); + }); +} diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index e83edde4..db8e7b26 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -17,6 +17,7 @@ import { getTypesCommand } from "@/cli/commands/types/index.js"; import packageJson from "../../package.json"; import { getDevCommand } from "./commands/dev.js"; import { getEjectCommand } from "./commands/project/eject.js"; +import { getValidateCommand } from "./commands/project/validate.js"; import type { CLIContext } from "./types.js"; export function createProgram(context: CLIContext): Command { @@ -44,6 +45,7 @@ export function createProgram(context: CLIContext): Command { program.addCommand(getDeployCommand(context)); program.addCommand(getLinkCommand(context)); program.addCommand(getEjectCommand(context)); + program.addCommand(getValidateCommand(context)); // Register entities commands program.addCommand(getEntitiesPushCommand(context)); diff --git a/packages/cli/tests/cli/validate.spec.ts b/packages/cli/tests/cli/validate.spec.ts new file mode 100644 index 00000000..311858fd --- /dev/null +++ b/packages/cli/tests/cli/validate.spec.ts @@ -0,0 +1,87 @@ +import { describe, it } from "vitest"; +import { fixture, setupCLITests } from "./testkit/index.js"; + +describe("validate command", () => { + const t = setupCLITests(); + + it("fails when not in a project directory", async () => { + const result = await t.run("validate"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("No Base44 project found"); + }); + + it("succeeds with only a config file and no resource files", async () => { + await t.givenProject(fixture("basic")); + + const result = await t.run("validate"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("valid"); + }); + + it("shows valid entities as passing", async () => { + await t.givenProject(fixture("with-entities")); + + const result = await t.run("validate"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("entities"); + }); + + it("fails and shows error for invalid entity", async () => { + await t.givenProject(fixture("invalid-entity")); + + const result = await t.run("validate"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("name"); + }); + + it("fails and shows error for invalid agent", async () => { + await t.givenProject(fixture("invalid-agent")); + + const result = await t.run("validate"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("agents"); + }); + + it("fails and shows error for invalid function config", async () => { + await t.givenProject(fixture("invalid-function-config")); + + const result = await t.run("validate"); + + t.expectResult(result).toFail(); + t.expectResult(result).toContain("function"); + }); + + it("validates all resource types in a full project", async () => { + await t.givenProject(fixture("full-project")); + + const result = await t.run("validate"); + + t.expectResult(result).toSucceed(); + t.expectResult(result).toContain("valid"); + }); + + it("collects all errors without stopping at first failure", async () => { + await t.givenProject(fixture("invalid-entity")); + + const result = await t.run("validate"); + + // Should show a summary with count, not just bail on first file + t.expectResult(result).toFail(); + t.expectResult(result).toContain("failed"); + }); + + it("does not require authentication", async () => { + // No login, just project setup + await t.givenProject(fixture("with-entities")); + + const result = await t.run("validate"); + + // Should not fail with auth error + t.expectResult(result).toSucceed(); + }); +}); diff --git a/packages/cli/tests/fixtures/invalid-function-config/base44/config.jsonc b/packages/cli/tests/fixtures/invalid-function-config/base44/config.jsonc new file mode 100644 index 00000000..4296e8ff --- /dev/null +++ b/packages/cli/tests/fixtures/invalid-function-config/base44/config.jsonc @@ -0,0 +1,3 @@ +{ + "name": "Invalid Function Config Test", +} diff --git a/packages/cli/tests/fixtures/invalid-function-config/base44/functions/send-email/function.jsonc b/packages/cli/tests/fixtures/invalid-function-config/base44/functions/send-email/function.jsonc new file mode 100644 index 00000000..6d468082 --- /dev/null +++ b/packages/cli/tests/fixtures/invalid-function-config/base44/functions/send-email/function.jsonc @@ -0,0 +1,4 @@ +{ + // missing required "name" field + "entry": "index.ts" +}