Skip to content

Conversation

@davidturnbull
Copy link
Contributor

Problem

Users must run CLI commands from the exact directory containing i18n.json, which creates a poor user experience:

  • Running lingo.dev run from a subdirectory fails with a generic error
  • Error message doesn't help users find their config file
  • Common workflow of working in subdirectories (like src/, components/, etc.) is broken
  • Inconsistent with other CLI tools like Git, which work from any subdirectory

Before:

cd my-project/src/components
lingo.dev run

❌ Error: i18n.json not found. Please run 'lingo.dev init'
# No helpful context

Solution

Commands now automatically search parent directories for i18n.json (similar to how Git finds .git) and provide helpful guidance when the config isn't found.

After:

# Works from any subdirectory
cd my-project/src/components
lingo.dev run

✅ Successfully finds ../../../i18n.json and runs

# When config truly not found, shows helpful error
cd ~/empty-directory
lingo.dev run

❌ i18n.json not found in current directory or parent directories.

Found 2 config file(s) in subdirectories:
  - project1/i18n.json
  - project2/i18n.json

Please cd into one of these directories, or run 'lingo.dev init'

Key Improvements

  1. Upward Config Discovery: Commands search parent directories until they find i18n.json or reach the filesystem root
  2. Smart Path Resolution: All file paths (lockfile, cache, bucket patterns) resolve relative to the config location
  3. Enhanced Error Messages: When config not found, CLI searches subdirectories and lists found configs
  4. Monorepo Support: In nested configs, the nearest config wins (enables per-package configs)
  5. Consistent Experience: All commands (run, status, i18n) behave identically

Testing Instructions

Test 1: Run from subdirectory

cd packages/cli/demo/json/en
node ../../bin/cli.mjs show config
# Should display config from parent directory

Test 2: Helpful error messages

mkdir /tmp/test-parent
mkdir /tmp/test-parent/proj1 /tmp/test-parent/proj2
# Copy demo configs into proj1/ and proj2/
cd /tmp/test-parent
node path/to/cli.mjs run
# Should list both configs found in subdirectories

Test 3: Monorepo scenario

# Create nested configs and verify nearest one wins
mkdir -p /tmp/monorepo/packages/web
echo '{"locale":{"targets":["es"]}}' > /tmp/monorepo/i18n.json
echo '{"locale":{"targets":["fr"]}}' > /tmp/monorepo/packages/web/i18n.json
cd /tmp/monorepo/packages/web
node path/to/cli.mjs show config
# Should show targets: ["fr"] (nearest config)

Test 4: All commands work consistently

cd demo/json/deep/nested/subdirectory
node ../../../bin/cli.mjs status  # ✅ Works
node ../../../bin/cli.mjs run     # ✅ Works
node ../../../bin/cli.mjs show config  # ✅ Works

Behavior Notes

  • Backward Compatible: Commands still work when run from the config directory
  • Git-like: Searches upward only (doesn't search sibling directories)
  • Performance: Config path is cached after first discovery
  • Truncation: Error messages show max 5 subdirectory configs, then "... and N more"

What's Changed

  • 9 files modified
  • Config discovery logic centralized in utils/config.ts
  • All commands now use getConfigOrThrow() for consistent error handling
  • Path resolution updated to use config root directory

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 <noreply@anthropic.com>
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@maxprilutskiy maxprilutskiy requested a review from Copilot January 21, 2026 18:36
@maxprilutskiy
Copy link
Contributor

@copilot does it make sense to use cosmiconfig here? WDYT?

Copy link
Contributor

Copilot AI commented Jan 21, 2026

@maxprilutskiy I've opened a new pull request, #1756, to work on those changes. Once the pull request is ready, I'll request review from you.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 7 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

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.
Comment on lines +6 to +7
let _cachedConfigPath: string | null = null;
let _cachedConfigRoot: string | null = null;
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.
Comment on lines +46 to +57
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.`,
);
}
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.
Comment on lines 63 to 73
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;
}
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.
Comment on lines +80 to +122
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;
}
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.
}

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants