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
14 changes: 4 additions & 10 deletions packages/cli/src/cli/cmd/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -139,7 +139,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");

Expand Down Expand Up @@ -682,16 +682,10 @@ export async function validateAuth(settings: ReturnType<typeof getSettings>) {
}

function validateParams(
i18nConfig: I18nConfig | null,
i18nConfig: I18nConfig,
flags: ReturnType<typeof parseFlags>,
) {
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.",
Expand Down
10 changes: 3 additions & 7 deletions packages/cli/src/cli/cmd/run/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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
) {
Expand Down
17 changes: 2 additions & 15 deletions packages/cli/src/cli/cmd/show/config.ts
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 4 additions & 10 deletions packages/cli/src/cli/cmd/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -681,16 +681,10 @@ async function tryAuthenticate(settings: ReturnType<typeof getSettings>) {
}

function validateParams(
i18nConfig: I18nConfig | null,
i18nConfig: I18nConfig,
flags: ReturnType<typeof parseFlags>,
) {
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.",
Expand Down
12 changes: 8 additions & 4 deletions packages/cli/src/cli/utils/buckets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof bucketTypeSchema>;
Expand Down Expand Up @@ -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",
});
}
Expand Down Expand Up @@ -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) => {
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/cli/utils/cache.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from "path";
import fs from "fs";
import { getConfigRoot } from "./config";

interface CacheRow {
targetLocale: string;
Expand Down Expand Up @@ -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[]) {
Expand Down
144 changes: 135 additions & 9 deletions packages/cli/src/cli/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment on lines +6 to +7
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The module-level cache variables can become stale when process.chdir() is called (which happens in CI flows at packages/cli/src/cli/cmd/ci/flows/in-branch.ts line 131 and packages/cli/src/cli/cmd/ci/platforms/gitlab.ts line 14). After a directory change, the cached path would still point to the old location relative to the new cwd, causing config resolution to fail or use the wrong config file. Consider clearing the cache when the working directory changes, or store absolute paths instead of relying on process.cwd().

Copilot uses AI. Check for mistakes.

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;
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused variable configRoot.

Suggested change
const { configPath, configRoot } = configInfo;
const { configPath } = configInfo;

Copilot uses AI. Check for mistakes.

const fileContents = fs.readFileSync(configPath, "utf8");
const rawConfig = JSON.parse(fileContents);

const result = parseI18nConfig(rawConfig);
Expand All @@ -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.`,
);
}
Comment on lines +46 to +57
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message format is inconsistent with the standard error structure used elsewhere in the codebase. Other commands use CLIError or ConfigError with message and docUrl properties (see status.ts, i18n.ts). This plain Error without a docUrl prevents users from accessing documentation links that could help resolve the issue.

Copilot uses AI. Check for mistakes.
}

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;
}
Comment on lines 63 to 73
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The saveConfig function will fail silently in a race condition scenario: if the config file is deleted between the _findConfigPath() check and the fs.writeFileSync() call, the write will create the file in a potentially incorrect location (wherever the last found config was). Consider using the configPath from the initial config load or validating the path still exists before writing.

Copilot uses AI. Check for mistakes.

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(".")
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function silently skips directories starting with a dot (line 100), but this excludes legitimate use cases like .github, .vscode, or other dot-prefixed project directories that might contain i18n configs. While this is reasonable for most cases, consider documenting this behavior in the function's JSDoc or making it configurable.

Copilot uses AI. Check for mistakes.
) {
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;
}
Comment on lines +80 to +122
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The findConfigsDownwards function has quadratic time complexity due to checking fs.existsSync() for every subdirectory individually. In a monorepo with many directories, this could cause significant performance degradation. Consider collecting all directories first, then batch-checking for config files, or using a more efficient traversal strategy.

Copilot uses AI. Check for mistakes.

// 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;
}
4 changes: 3 additions & 1 deletion packages/cli/src/cli/utils/delta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).prefault(1),
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 3 additions & 1 deletion packages/cli/src/cli/utils/lockfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
}
}

Expand Down
Loading