From 96ac8a584c58ff6defad34adbd9bb448ef993346 Mon Sep 17 00:00:00 2001 From: David Turnbull Date: Wed, 22 Oct 2025 11:21:13 +1100 Subject: [PATCH] feat: add upward config discovery and enhanced error messages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Improve UX when running CLI commands from subdirectories by implementing automatic upward config search (similar to Git) and providing helpful error messages when config files are found in subdirectories. Key improvements: - Commands now search parent directories for i18n.json (like Git) - Users can run commands from any subdirectory within their project - Error messages list configs found in subdirectories with guidance - File operations resolve paths relative to config root - Consistent error handling across all commands 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- packages/cli/src/cli/cmd/i18n.ts | 14 +-- packages/cli/src/cli/cmd/run/setup.ts | 10 +- packages/cli/src/cli/cmd/show/config.ts | 17 +-- packages/cli/src/cli/cmd/status.ts | 14 +-- packages/cli/src/cli/utils/buckets.ts | 12 +- packages/cli/src/cli/utils/cache.ts | 4 +- packages/cli/src/cli/utils/config.ts | 144 ++++++++++++++++++++++-- packages/cli/src/cli/utils/delta.ts | 4 +- packages/cli/src/cli/utils/lockfile.ts | 4 +- 9 files changed, 165 insertions(+), 58 deletions(-) diff --git a/packages/cli/src/cli/cmd/i18n.ts b/packages/cli/src/cli/cmd/i18n.ts index 96670f13b..d5e8fc1bd 100644 --- a/packages/cli/src/cli/cmd/i18n.ts +++ b/packages/cli/src/cli/cmd/i18n.ts @@ -8,7 +8,7 @@ import { Command } from "interactive-commander"; import Z from "zod"; import _ from "lodash"; import * as path from "path"; -import { getConfig } from "../utils/config"; +import { getConfigOrThrow } from "../utils/config"; import { getSettings } from "../utils/settings"; import { ConfigError, @@ -128,7 +128,7 @@ export default new Command() const errorDetails: ErrorDetail[] = []; try { ora.start("Loading configuration..."); - const i18nConfig = getConfig(); + const i18nConfig = getConfigOrThrow(); const settings = getSettings(flags.apiKey); ora.succeed("Configuration loaded"); @@ -673,16 +673,10 @@ export async function validateAuth(settings: ReturnType) { } function validateParams( - i18nConfig: I18nConfig | null, + i18nConfig: I18nConfig, flags: ReturnType, ) { - if (!i18nConfig) { - throw new ConfigError({ - message: - "i18n.json not found. Please run `lingo.dev init` to initialize the project.", - docUrl: "i18nNotFound", - }); - } else if (!i18nConfig.buckets || !Object.keys(i18nConfig.buckets).length) { + if (!i18nConfig.buckets || !Object.keys(i18nConfig.buckets).length) { throw new ConfigError({ message: "No buckets found in i18n.json. Please add at least one bucket containing i18n content.", diff --git a/packages/cli/src/cli/cmd/run/setup.ts b/packages/cli/src/cli/cmd/run/setup.ts index ca6652109..0913db266 100644 --- a/packages/cli/src/cli/cmd/run/setup.ts +++ b/packages/cli/src/cli/cmd/run/setup.ts @@ -3,7 +3,7 @@ import { Listr } from "listr2"; import { colors } from "../../constants"; import { CmdRunContext, flagsSchema } from "./_types"; import { commonTaskRendererOptions } from "./_const"; -import { getConfig } from "../../utils/config"; +import { getConfigOrThrow } from "../../utils/config"; import createLocalizer from "../../localizer"; export default async function setup(input: CmdRunContext) { @@ -21,13 +21,9 @@ export default async function setup(input: CmdRunContext) { { title: "Loading i18n configuration", task: async (ctx, task) => { - ctx.config = getConfig(true); + ctx.config = getConfigOrThrow(true); - if (!ctx.config) { - throw new Error( - "i18n.json not found. Please run `lingo.dev init` to initialize the project.", - ); - } else if ( + if ( !ctx.config.buckets || !Object.keys(ctx.config.buckets).length ) { diff --git a/packages/cli/src/cli/cmd/show/config.ts b/packages/cli/src/cli/cmd/show/config.ts index 858323a2b..33b5d5b47 100644 --- a/packages/cli/src/cli/cmd/show/config.ts +++ b/packages/cli/src/cli/cmd/show/config.ts @@ -1,28 +1,15 @@ import { Command } from "interactive-commander"; import _ from "lodash"; -import fs from "fs"; -import path from "path"; import { defaultConfig } from "@lingo.dev/_spec"; +import { getConfig } from "../../utils/config"; export default new Command() .command("config") .description("Print effective i18n.json after merging with defaults") .helpOption("-h, --help", "Show help") .action(async (options) => { - const fileConfig = loadReplexicaFileConfig(); + const fileConfig = getConfig(false); const config = _.merge({}, defaultConfig, fileConfig); console.log(JSON.stringify(config, null, 2)); }); - -function loadReplexicaFileConfig(): any { - const replexicaConfigPath = path.resolve(process.cwd(), "i18n.json"); - const fileExists = fs.existsSync(replexicaConfigPath); - if (!fileExists) { - return undefined; - } - - const fileContent = fs.readFileSync(replexicaConfigPath, "utf-8"); - const replexicaFileConfig = JSON.parse(fileContent); - return replexicaFileConfig; -} diff --git a/packages/cli/src/cli/cmd/status.ts b/packages/cli/src/cli/cmd/status.ts index fb9d49e2b..7e8d24e70 100644 --- a/packages/cli/src/cli/cmd/status.ts +++ b/packages/cli/src/cli/cmd/status.ts @@ -8,7 +8,7 @@ import { Command } from "interactive-commander"; import Z from "zod"; import _ from "lodash"; import * as path from "path"; -import { getConfig } from "../utils/config"; +import { getConfigOrThrow } from "../utils/config"; import { getSettings } from "../utils/settings"; import { CLIError } from "../utils/errors"; import Ora from "ora"; @@ -67,7 +67,7 @@ export default new Command() try { ora.start("Loading configuration..."); - const i18nConfig = getConfig(); + const i18nConfig = getConfigOrThrow(); const settings = getSettings(flags.apiKey); ora.succeed("Configuration loaded"); @@ -673,16 +673,10 @@ async function tryAuthenticate(settings: ReturnType) { } function validateParams( - i18nConfig: I18nConfig | null, + i18nConfig: I18nConfig, flags: ReturnType, ) { - if (!i18nConfig) { - throw new CLIError({ - message: - "i18n.json not found. Please run `lingo.dev init` to initialize the project.", - docUrl: "i18nNotFound", - }); - } else if (!i18nConfig.buckets || !Object.keys(i18nConfig.buckets).length) { + if (!i18nConfig.buckets || !Object.keys(i18nConfig.buckets).length) { throw new CLIError({ message: "No buckets found in i18n.json. Please add at least one bucket containing i18n content.", diff --git a/packages/cli/src/cli/utils/buckets.ts b/packages/cli/src/cli/utils/buckets.ts index 962030c09..cc03e61b2 100644 --- a/packages/cli/src/cli/utils/buckets.ts +++ b/packages/cli/src/cli/utils/buckets.ts @@ -10,6 +10,7 @@ import { } from "@lingo.dev/_spec"; import { bucketTypeSchema } from "@lingo.dev/_spec"; import Z from "zod"; +import { getConfigRoot } from "./config"; type BucketConfig = { type: Z.infer; @@ -99,13 +100,15 @@ function expandPlaceholderedGlob( _pathPattern: string, sourceLocale: string, ): string[] { - const absolutePathPattern = path.resolve(_pathPattern); + const configRoot = getConfigRoot() || process.cwd(); + + const absolutePathPattern = path.resolve(configRoot, _pathPattern); const pathPattern = normalizePath( - path.relative(process.cwd(), absolutePathPattern), + path.relative(configRoot, absolutePathPattern), ); if (pathPattern.startsWith("..")) { throw new CLIError({ - message: `Invalid path pattern: ${pathPattern}. Path pattern must be within the current working directory.`, + message: `Invalid path pattern: ${pathPattern}. Path pattern must be within the config root directory.`, docUrl: "invalidPathPattern", }); } @@ -141,10 +144,11 @@ function expandPlaceholderedGlob( follow: true, withFileTypes: true, windowsPathsNoEscape: true, // Windows path support + cwd: configRoot, }) .filter((file) => file.isFile() || file.isSymbolicLink()) .map((file) => file.fullpath()) - .map((fullpath) => normalizePath(path.relative(process.cwd(), fullpath))); + .map((fullpath) => normalizePath(path.relative(configRoot, fullpath))); // transform each source file path back to [locale] placeholder paths const placeholderedPaths = sourcePaths.map((sourcePath) => { diff --git a/packages/cli/src/cli/utils/cache.ts b/packages/cli/src/cli/utils/cache.ts index 790168a3d..81ce2a732 100644 --- a/packages/cli/src/cli/utils/cache.ts +++ b/packages/cli/src/cli/utils/cache.ts @@ -1,5 +1,6 @@ import path from "path"; import fs from "fs"; +import { getConfigRoot } from "./config"; interface CacheRow { targetLocale: string; @@ -81,7 +82,8 @@ function _appendToCache(rows: CacheRow[]) { } function _getCacheFilePath() { - return path.join(process.cwd(), "i18n.cache"); + const configRoot = getConfigRoot() || process.cwd(); + return path.join(configRoot, "i18n.cache"); } function _buildJSONLines(rows: CacheRow[]) { diff --git a/packages/cli/src/cli/utils/config.ts b/packages/cli/src/cli/utils/config.ts index b56aa60cc..be18f6018 100644 --- a/packages/cli/src/cli/utils/config.ts +++ b/packages/cli/src/cli/utils/config.ts @@ -3,15 +3,18 @@ import fs from "fs"; import path from "path"; import { I18nConfig, parseI18nConfig } from "@lingo.dev/_spec"; -export function getConfig(resave = true): I18nConfig | null { - const configFilePath = _getConfigFilePath(); +let _cachedConfigPath: string | null = null; +let _cachedConfigRoot: string | null = null; - const configFileExists = fs.existsSync(configFilePath); - if (!configFileExists) { +export function getConfig(resave = true): I18nConfig | null { + const configInfo = _findConfigPath(); + if (!configInfo) { return null; } - const fileContents = fs.readFileSync(configFilePath, "utf8"); + const { configPath, configRoot } = configInfo; + + const fileContents = fs.readFileSync(configPath, "utf8"); const rawConfig = JSON.parse(fileContents); const result = parseI18nConfig(rawConfig); @@ -25,17 +28,140 @@ export function getConfig(resave = true): I18nConfig | null { return result; } +export function getConfigOrThrow(resave = true): I18nConfig { + const config = getConfig(resave); + + if (!config) { + // Try to find configs in subdirectories to provide helpful error message + const foundBelow = findConfigsDownwards(); + if (foundBelow.length > 0) { + const configList = foundBelow + .slice(0, 5) // Limit to 5 to avoid overwhelming output + .map((p) => ` - ${p}`) + .join("\n"); + const moreText = + foundBelow.length > 5 + ? `\n ... and ${foundBelow.length - 5} more` + : ""; + throw new Error( + `i18n.json not found in current directory or parent directories.\n\n` + + `Found ${foundBelow.length} config file(s) in subdirectories:\n` + + configList + + moreText + + `\n\nPlease cd into one of these directories, or run \`lingo.dev init\` to initialize a new project.`, + ); + } else { + throw new Error( + `i18n.json not found. Please run \`lingo.dev init\` to initialize the project.`, + ); + } + } + + return config; +} + export function saveConfig(config: I18nConfig) { - const configFilePath = _getConfigFilePath(); + const configInfo = _findConfigPath(); + if (!configInfo) { + throw new Error("Cannot save config: i18n.json not found"); + } const serialized = JSON.stringify(config, null, 2); - fs.writeFileSync(configFilePath, serialized); + fs.writeFileSync(configInfo.configPath, serialized); return config; } +export function getConfigRoot(): string | null { + const configInfo = _findConfigPath(); + return configInfo?.configRoot || null; +} + +export function findConfigsDownwards( + startDir: string = process.cwd(), + maxDepth: number = 3, +): string[] { + const found: string[] = []; + + function search(dir: string, depth: number) { + if (depth > maxDepth) return; + + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + // Skip common directories that shouldn't contain configs + if ( + entry.name === "node_modules" || + entry.name === ".git" || + entry.name === "dist" || + entry.name === "build" || + entry.name.startsWith(".") + ) { + continue; + } + + const subDir = path.join(dir, entry.name); + const configPath = path.join(subDir, "i18n.json"); + + if (fs.existsSync(configPath)) { + found.push(path.relative(startDir, configPath)); + } + + search(subDir, depth + 1); + } + } + } catch (error) { + // Ignore permission errors, etc. + } + } + + search(startDir, 0); + return found; +} + // Private -function _getConfigFilePath() { - return path.join(process.cwd(), "i18n.json"); +function _findConfigPath(): { configPath: string; configRoot: string } | null { + // Use cached path if available + if (_cachedConfigPath && _cachedConfigRoot) { + return { configPath: _cachedConfigPath, configRoot: _cachedConfigRoot }; + } + + const result = _findConfigUpwards(process.cwd()); + if (result) { + _cachedConfigPath = result.configPath; + _cachedConfigRoot = result.configRoot; + } + + return result; +} + +function _findConfigUpwards( + startDir: string, +): { configPath: string; configRoot: string } | null { + let currentDir = path.resolve(startDir); + const root = path.parse(currentDir).root; + + while (true) { + const configPath = path.join(currentDir, "i18n.json"); + + if (fs.existsSync(configPath)) { + return { + configPath, + configRoot: currentDir, + }; + } + + // Check if we've reached the filesystem root + if (currentDir === root) { + break; + } + + // Move up one directory + currentDir = path.dirname(currentDir); + } + + return null; } diff --git a/packages/cli/src/cli/utils/delta.ts b/packages/cli/src/cli/utils/delta.ts index 4950a70b6..b42716fd3 100644 --- a/packages/cli/src/cli/utils/delta.ts +++ b/packages/cli/src/cli/utils/delta.ts @@ -4,6 +4,7 @@ import { md5 } from "./md5"; import { tryReadFile, writeFile, checkIfFileExists } from "../utils/fs"; import * as path from "path"; import YAML from "yaml"; +import { getConfigRoot } from "./config"; const LockSchema = z.object({ version: z.literal(1).default(1), @@ -33,7 +34,8 @@ export type Delta = { }; export function createDeltaProcessor(fileKey: string) { - const lockfilePath = path.join(process.cwd(), "i18n.lock"); + const configRoot = getConfigRoot() || process.cwd(); + const lockfilePath = path.join(configRoot, "i18n.lock"); return { async checkIfLockExists() { return checkIfFileExists(lockfilePath); diff --git a/packages/cli/src/cli/utils/lockfile.ts b/packages/cli/src/cli/utils/lockfile.ts index f1e7dee6e..800abad16 100644 --- a/packages/cli/src/cli/utils/lockfile.ts +++ b/packages/cli/src/cli/utils/lockfile.ts @@ -4,6 +4,7 @@ import Z from "zod"; import YAML from "yaml"; import { MD5 } from "object-hash"; import _ from "lodash"; +import { getConfigRoot } from "./config"; export function createLockfileHelper() { return { @@ -79,7 +80,8 @@ export function createLockfileHelper() { } function _getLockfilePath() { - return path.join(process.cwd(), "i18n.lock"); + const configRoot = getConfigRoot() || process.cwd(); + return path.join(configRoot, "i18n.lock"); } }