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"); } }