From b93eaf5b0e258893b8fd3dfefb074412c47aa0ad Mon Sep 17 00:00:00 2001 From: Navyansh Kesarwani Date: Thu, 30 Oct 2025 20:22:59 +0530 Subject: [PATCH] feat: added lingo.dev validate command --- packages/cli/src/cli/cmd/validate.spec.ts | 238 ++++++++++++++ packages/cli/src/cli/cmd/validate.ts | 376 ++++++++++++++++++++++ packages/cli/src/cli/index.ts | 2 + 3 files changed, 616 insertions(+) create mode 100644 packages/cli/src/cli/cmd/validate.spec.ts create mode 100644 packages/cli/src/cli/cmd/validate.ts diff --git a/packages/cli/src/cli/cmd/validate.spec.ts b/packages/cli/src/cli/cmd/validate.spec.ts new file mode 100644 index 000000000..72ed61660 --- /dev/null +++ b/packages/cli/src/cli/cmd/validate.spec.ts @@ -0,0 +1,238 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import * as fs from "fs"; +import * as path from "path"; +import * as os from "os"; +import validateCmd from "./validate"; + +describe("validate command", () => { + let tempDir: string; + let originalCwd: string; + + beforeEach(() => { + // Save original working directory + originalCwd = process.cwd(); + + // Create a temporary directory for testing + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "lingo-validate-test-")); + process.chdir(tempDir); + }); + + afterEach(() => { + // Restore original working directory + process.chdir(originalCwd); + + // Clean up temporary directory + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it("should be defined", () => { + expect(validateCmd).toBeDefined(); + }); + + it("should have correct command name", () => { + expect(validateCmd.name()).toBe("validate"); + }); + + it("should have correct description", () => { + expect(validateCmd.description()).toBe( + "Validate lingo.dev configuration and file accessibility", + ); + }); + + it("should have --strict option", () => { + const options = validateCmd.options; + const strictOption = options.find((opt: any) => + opt.flags.includes("--strict"), + ); + expect(strictOption).toBeDefined(); + expect(strictOption?.description).toContain("Strict mode"); + }); + + it("should have --api-key option", () => { + const options = validateCmd.options; + const apiKeyOption = options.find((opt: any) => + opt.flags.includes("--api-key"), + ); + expect(apiKeyOption).toBeDefined(); + expect(apiKeyOption?.description).toContain("API key"); + }); + + describe("validation logic", () => { + it("should detect missing i18n.json", async () => { + // Don't create i18n.json - command should fail + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation((code?: any) => { + throw new Error(`Process.exit(${code})`); + }); + + try { + await validateCmd.parseAsync(["node", "test", "validate"], { + from: "user", + }); + } catch (error: any) { + // Expected to throw due to process.exit(1) + expect(error.message).toContain("Process.exit(1)"); + } + + exitSpy.mockRestore(); + }); + + it("should validate a valid i18n.json configuration", async () => { + // Create a valid i18n.json + const i18nConfig = { + $schema: "https://lingo.dev/schema/i18n.json", + version: 0, + locale: { + source: "en", + targets: ["fr", "es"], + }, + buckets: { + json: { + include: ["src/i18n/[locale].json"], + }, + }, + }; + + fs.writeFileSync( + path.join(tempDir, "i18n.json"), + JSON.stringify(i18nConfig, null, 2), + ); + + // Create source directory and file + const srcDir = path.join(tempDir, "src", "i18n"); + fs.mkdirSync(srcDir, { recursive: true }); + + // Create source locale file + const sourceFile = path.join(srcDir, "en.json"); + fs.writeFileSync( + sourceFile, + JSON.stringify({ hello: "Hello", world: "World" }, null, 2), + ); + + // Mock process.exit to prevent actual exit + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation((code?: any) => { + if (code && code !== 0) { + throw new Error(`Process.exit(${code})`); + } + return undefined as never; + }); + + // Mock exitGracefully to prevent exit + vi.mock("../utils/exit-gracefully", () => ({ + exitGracefully: vi.fn(), + })); + + try { + await validateCmd.parseAsync(["node", "test", "validate"], { + from: "user", + }); + // If we get here, validation should have passed (warnings are OK) + expect(exitSpy).not.toHaveBeenCalledWith(1); + } catch (error: any) { + // If it throws, it should not be an exit error with code 1 + expect(error.message).not.toContain("Process.exit(1)"); + } finally { + exitSpy.mockRestore(); + } + }); + + it("should detect invalid locale codes", async () => { + // Create an i18n.json with invalid locale + const i18nConfig = { + $schema: "https://lingo.dev/schema/i18n.json", + version: 0, + locale: { + source: "invalid-locale-123", + targets: ["fr"], + }, + buckets: { + json: { + include: ["src/i18n/[locale].json"], + }, + }, + }; + + fs.writeFileSync( + path.join(tempDir, "i18n.json"), + JSON.stringify(i18nConfig, null, 2), + ); + + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation((code?: any) => { + throw new Error(`Process.exit(${code})`); + }); + + try { + await validateCmd.parseAsync(["node", "test", "validate"], { + from: "user", + }); + } catch (error: any) { + // Should exit with error due to invalid locale + expect(error.message).toContain("Process.exit"); + } + + exitSpy.mockRestore(); + }); + + it("should handle --strict flag for missing target files", async () => { + const i18nConfig = { + $schema: "https://lingo.dev/schema/i18n.json", + version: 0, + locale: { + source: "en", + targets: ["fr"], + }, + buckets: { + json: { + include: ["src/i18n/[locale].json"], + }, + }, + }; + + fs.writeFileSync( + path.join(tempDir, "i18n.json"), + JSON.stringify(i18nConfig, null, 2), + ); + + // Create source directory and file + const srcDir = path.join(tempDir, "src", "i18n"); + fs.mkdirSync(srcDir, { recursive: true }); + + const sourceFile = path.join(srcDir, "en.json"); + fs.writeFileSync( + sourceFile, + JSON.stringify({ hello: "Hello" }, null, 2), + ); + + // Don't create target file (fr.json) + // With --strict, this should cause an error + + const exitSpy = vi + .spyOn(process, "exit") + .mockImplementation((code?: any) => { + if (code !== 0) { + throw new Error(`Process.exit(${code})`); + } + return undefined as never; + }); + + try { + await validateCmd.parseAsync(["node", "test", "validate", "--strict"], { + from: "user", + }); + } catch (error: any) { + // In strict mode, missing target files should cause exit with error + expect(error.message).toContain("Process.exit(1)"); + } + + exitSpy.mockRestore(); + }); + + }); +}); diff --git a/packages/cli/src/cli/cmd/validate.ts b/packages/cli/src/cli/cmd/validate.ts new file mode 100644 index 000000000..37086a62f --- /dev/null +++ b/packages/cli/src/cli/cmd/validate.ts @@ -0,0 +1,376 @@ +import { + bucketTypeSchema, + I18nConfig, + localeCodeSchema, + resolveOverriddenLocale, +} from "@lingo.dev/_spec"; +import { Command } from "interactive-commander"; +import Z from "zod"; +import * as fs from "fs"; +import * as path from "path"; +import { getConfig } from "../utils/config"; +import { getSettings } from "../utils/settings"; +import { CLIError } from "../utils/errors"; +import createBucketLoader from "../loaders"; +import { createAuthenticator } from "../utils/auth"; +import { getBuckets } from "../utils/buckets"; +import chalk from "chalk"; +import { checkIfFileExists } from "../utils/fs"; +import trackEvent from "../utils/observability"; +import { exitGracefully } from "../utils/exit-gracefully"; + +export default new Command() + .command("validate") + .description("Validate lingo.dev configuration and file accessibility") + .helpOption("-h, --help", "Show help") + .option( + "--strict", + "Strict mode: missing target files are treated as errors instead of warnings", + ) + .option( + "--api-key ", + "Override the API key from settings or environment variables for this run", + ) + .action(async function (options) { + const flags = parseFlags(options); + let authId: string | null = null; + let errorCount = 0; + let warningCount = 0; + let checksPassedCount = 0; + + try { + // Track validation start + trackEvent("validate", "cmd.validate.start", { flags }); + + // 1. Validate configuration file + const i18nConfig = getConfig(); + + if (!i18nConfig) { + console.log( + chalk.red("✗") + " Configuration file (i18n.json) not found", + ); + errorCount++; + process.exit(1); + } + + console.log( + chalk.green("✓") + " Configuration file (i18n.json) exists", + ); + checksPassedCount++; + + // 2. Check authentication if API key is provided + if (flags.apiKey || process.env.LINGO_API_KEY) { + const settings = getSettings(flags.apiKey); + + if (settings.auth.apiKey) { + try { + const authenticator = createAuthenticator({ + apiKey: settings.auth.apiKey, + apiUrl: settings.auth.apiUrl, + }); + const user = await authenticator.whoami(); + if (user) { + authId = user.id; + console.log( + chalk.green("✓") + ` Authenticated as ${user.email}`, + ); + checksPassedCount++; + } else { + console.log( + chalk.red("✗") + " Authentication failed: Invalid API key", + ); + errorCount++; + } + } catch (error: any) { + console.log( + chalk.red("✗") + ` Authentication failed: ${error.message}`, + ); + errorCount++; + } + } + } + + // 3. Validate source locale + try { + localeCodeSchema.parse(i18nConfig.locale.source); + console.log( + chalk.green("✓") + ` Source locale '${i18nConfig.locale.source}' is valid`, + ); + checksPassedCount++; + } catch (error) { + console.log( + chalk.red("✗") + + ` Source locale '${i18nConfig.locale.source}' is invalid`, + ); + errorCount++; + } + + // 4. Validate target locales + if (!i18nConfig.locale.targets || i18nConfig.locale.targets.length === 0) { + console.log( + chalk.yellow("⚠") + " No target locales defined in i18n.json", + ); + warningCount++; + } else { + let allTargetsValid = true; + for (const targetLocale of i18nConfig.locale.targets) { + try { + localeCodeSchema.parse(targetLocale); + } catch (error) { + allTargetsValid = false; + console.log( + chalk.red("✗") + ` Target locale '${targetLocale}' is invalid`, + ); + errorCount++; + } + } + + if (allTargetsValid) { + const localeList = i18nConfig.locale.targets + .map((l) => `'${l}'`) + .join(", "); + console.log( + chalk.green("✓") + ` Target locales [${localeList}] are valid`, + ); + checksPassedCount++; + } + } + + // 5. Validate buckets + if (!i18nConfig.buckets || Object.keys(i18nConfig.buckets).length === 0) { + console.log(chalk.red("✗") + " No buckets defined in i18n.json"); + errorCount++; + trackEvent(authId || "validate", "cmd.validate.error", { + flags, + error: "No buckets defined", + authenticated: !!authId, + }); + process.exit(1); + } + + const buckets = getBuckets(i18nConfig); + + // Check each bucket type is supported + for (const bucket of buckets) { + try { + bucketTypeSchema.parse(bucket.type); + console.log( + chalk.green("✓") + ` Bucket type '${bucket.type}' is supported`, + ); + checksPassedCount++; + } catch (error) { + console.log( + chalk.red("✗") + ` Bucket type '${bucket.type}' is not supported`, + ); + errorCount++; + } + } + + // 6. Validate file paths and accessibility + let allSourceFilesReadable = true; + let allTargetDirsWritable = true; + + for (const bucket of buckets) { + for (const bucketPath of bucket.paths) { + const sourceLocale = resolveOverriddenLocale( + i18nConfig.locale.source, + bucketPath.delimiter, + ); + + try { + const bucketLoader = createBucketLoader( + bucket.type, + bucketPath.pathPattern, + { + defaultLocale: sourceLocale, + injectLocale: bucket.injectLocale, + formatter: i18nConfig.formatter, + }, + bucket.lockedKeys, + bucket.lockedPatterns, + bucket.ignoredKeys, + ); + + bucketLoader.setDefaultLocale(sourceLocale); + await bucketLoader.init(); + + // Check source file + const sourceFilePath = path.resolve( + bucketPath.pathPattern.replaceAll("[locale]", sourceLocale), + ); + + if (checkIfFileExists(sourceFilePath)) { + try { + await bucketLoader.pull(sourceLocale); + fs.accessSync(sourceFilePath, fs.constants.R_OK); + console.log( + chalk.green("✓") + ` Source file exists: ${sourceFilePath}`, + ); + checksPassedCount++; + } catch (error) { + console.log( + chalk.red("✗") + + ` Source file exists but is not readable: ${sourceFilePath}`, + ); + errorCount++; + allSourceFilesReadable = false; + } + } else { + console.log( + chalk.red("✗") + ` Source file not found: ${sourceFilePath}`, + ); + errorCount++; + } + + // Check target files + const targetLocales = i18nConfig.locale.targets || []; + for (const _targetLocale of targetLocales) { + const targetLocale = resolveOverriddenLocale( + _targetLocale, + bucketPath.delimiter, + ); + const targetFilePath = path.resolve( + bucketPath.pathPattern.replaceAll("[locale]", targetLocale), + ); + + if (checkIfFileExists(targetFilePath)) { + try { + // Check read access + fs.accessSync(targetFilePath, fs.constants.R_OK); + + // Check write access + try { + fs.accessSync(targetFilePath, fs.constants.W_OK); + console.log( + chalk.green("✓") + + ` Target file exists: ${targetFilePath}`, + ); + checksPassedCount++; + } catch (error) { + console.log( + chalk.red("✗") + + ` Target file exists but is not writable: ${targetFilePath}`, + ); + errorCount++; + allTargetDirsWritable = false; + } + } catch (error) { + console.log( + chalk.red("✗") + + ` Target file exists but is not readable: ${targetFilePath}`, + ); + errorCount++; + } + } else { + // File doesn't exist - check if directory is writable + const dir = path.dirname(targetFilePath); + + try { + if (checkIfFileExists(dir)) { + fs.accessSync(dir, fs.constants.W_OK); + + if (flags.strict) { + console.log( + chalk.red("✗") + + ` Target file missing: ${targetFilePath} (strict mode)`, + ); + errorCount++; + } else { + console.log( + chalk.yellow("⚠") + + ` Target file missing: ${targetFilePath} (will be created)`, + ); + warningCount++; + } + } else { + console.log( + chalk.red("✗") + + ` Target directory does not exist: ${dir}`, + ); + errorCount++; + allTargetDirsWritable = false; + } + } catch (error) { + console.log( + chalk.red("✗") + ` Target directory is not writable: ${dir}`, + ); + errorCount++; + allTargetDirsWritable = false; + } + } + } + } catch (error: any) { + console.log( + chalk.red("✗") + + ` Failed to initialize bucket loader for '${bucketPath.pathPattern}': ${error.message}`, + ); + errorCount++; + } + } + } + + // Summary checks + if (allSourceFilesReadable) { + console.log(chalk.green("✓") + " All source files are readable"); + checksPassedCount++; + } + + if (allTargetDirsWritable) { + console.log(chalk.green("✓") + " Target directories are writable"); + checksPassedCount++; + } + + // Final summary + console.log(); + if (errorCount === 0 && warningCount === 0) { + console.log( + chalk.green( + `Validation complete: ${checksPassedCount} checks passed`, + ), + ); + } else if (errorCount === 0) { + console.log( + chalk.yellow( + `Validation complete: ${checksPassedCount} checks passed, ${warningCount} warning${warningCount !== 1 ? "s" : ""}`, + ), + ); + } else { + console.log( + chalk.red( + `Validation failed: ${checksPassedCount} checks passed, ${warningCount} warning${warningCount !== 1 ? "s" : ""}, ${errorCount} error${errorCount !== 1 ? "s" : ""}`, + ), + ); + } + + // Track validation completion + trackEvent(authId || "validate", "cmd.validate.success", { + flags, + errorCount, + warningCount, + checksPassedCount, + authenticated: !!authId, + }); + + // Exit with appropriate code + if (errorCount > 0) { + process.exit(1); + } else { + exitGracefully(); + } + } catch (error: any) { + console.log(chalk.red(`✗ Validation failed: ${error.message}`)); + trackEvent(authId || "validate", "cmd.validate.error", { + flags, + error: error.message, + authenticated: !!authId, + }); + process.exit(1); + } + }); + +function parseFlags(options: any) { + return Z.object({ + strict: Z.boolean().optional(), + apiKey: Z.string().optional(), + }).parse(options); +} \ No newline at end of file diff --git a/packages/cli/src/cli/index.ts b/packages/cli/src/cli/index.ts index 151b5c6b7..af08b3cc8 100644 --- a/packages/cli/src/cli/index.ts +++ b/packages/cli/src/cli/index.ts @@ -17,6 +17,7 @@ import cleanupCmd from "./cmd/cleanup"; import mcpCmd from "./cmd/mcp"; import ciCmd from "./cmd/ci"; import statusCmd from "./cmd/status"; +import validateCmd from "./cmd/validate"; import mayTheFourthCmd from "./cmd/may-the-fourth"; import packageJson from "../../package.json"; import run from "./cmd/run"; @@ -59,6 +60,7 @@ Star the the repo :) https://github.com/LingoDotDev/lingo.dev .addCommand(mcpCmd) .addCommand(ciCmd) .addCommand(statusCmd) + .addCommand(validateCmd) .addCommand(mayTheFourthCmd, { hidden: true }) .addCommand(run) .addCommand(purgeCmd)