Skip to content

fix(cli): cache npm lookups, CLI binaries, and upgrade nudge#13257

Open
Swimburger wants to merge 8 commits intomainfrom
devin/1773067964-cli-perf-improvements
Open

fix(cli): cache npm lookups, CLI binaries, and upgrade nudge#13257
Swimburger wants to merge 8 commits intomainfrom
devin/1773067964-cli-perf-improvements

Conversation

@Swimburger
Copy link
Member

@Swimburger Swimburger commented Mar 9, 2026

Description

Refs performance investigation from Devin session.
Requested by @Swimburger.

The Fern CLI is slow even for simple commands like fern --version due to repeated network calls (npm registry lookups, upgrade checks) and re-downloading the CLI binary via npx on every version-redirected invocation. This PR adds file-based caching in ~/.fern/ to eliminate redundant work across runs while preserving the existing version redirection behavior.

Changes Made

  • New CliCache.ts: Generic file-based cache utility using JSON files with TTL support, stored in ~/.fern/. Uses atomic writes (temp file + rename) to prevent partial reads from concurrent CLI processes. Cache directory path is computed lazily via getFernCacheDir() (not a top-level constant) so the module is testable with a mocked homedir. Catch blocks silently swallow errors (best-effort cache — any failure falls through to the uncached code path).
  • Cached npm registry lookups (getLatestVersionOfCli.ts): Results are cached with a 4-hour TTL via opt-in useCache parameter (default false). Only the upgrade nudge code path passes useCache: true; callers in cli.ts (version redirection, fern init) always fetch fresh data from the registry.
  • Cached CLI binaries (rerunFernCliAtVersion.ts): Instead of npx fern-api@<version> (re-downloads every time), installs into ~/.fern/versions/<version>/ and reuses on subsequent runs. On cached CLI execution failure (e.g. corrupted install), removes the cache directory and falls back to npx automatically. Falls back to npx if initial caching also fails.
  • Cached upgrade nudge (CliContext.ts): Caches a structured { message, latestVersion, fromVersion } object. On exit, validates the cache by checking both isVersionAhead(cached.latestVersion, currentVersion) and cached.fromVersion === currentVersion before displaying — so stale messages are suppressed after the user upgrades (including to intermediate versions). Refreshes cache for next run with a 300ms timeout.
  • Unit tests for all three caching layers (28 new tests):
    • CliCache.test.ts: read/write round-trips, TTL expiration, corrupted data handling, atomic write verification, error resilience, various value types
    • getLatestVersionOfCli.test.ts: useCache parameter behavior (cache hit/miss/bypass), non-prod CLI short-circuit, prerelease cache key separation
    • upgradeNudgeCache.test.ts: fromVersion validation prevents stale nudge after intermediate upgrades, cleared cache handling, TTL expiry, cache round-trips
  • Updated README.md generator (if applicable) — N/A

⚠️ Reviewer checklist / key areas

  1. useCache scoping: The version lookup cache is only used by the upgrade nudge path. Verify this is correct — fern init and version redirection callers in cli.ts do NOT pass useCache: true and always hit the npm registry.

  2. Hardcoded CLI entry point path (rerunFernCliAtVersion.ts:25): getCachedCliEntryPoint assumes the npm package has cli.cjs at its root inside node_modules/<packageName>/. If the published fern-api package structure changes, this path breaks. Graceful fallback to npx exists, but the user would silently lose the caching benefit.

  3. Silent catch blocks in CliCache.ts: Intentional — console.debug was tried but it writes to stderr, which polluted CLI output and broke ETE tests. Since this is a best-effort utility with no logger access, errors are silently swallowed and execution falls through to the uncached path. This deviates from REVIEW.md's "no empty catch blocks" rule.

  4. Corrupted cache recovery (rerunFernCliAtVersion.ts:137-147): When a cached CLI binary fails to execute, the code removes the entire ~/.fern/versions/<version>/ directory and falls back to npx. This is a best-effort cleanup — if rmSync fails, the corrupted cache persists but the npx fallback still runs.

  5. No cache eviction: Old versions accumulate in ~/.fern/versions/ indefinitely. May want a cleanup strategy eventually.

  6. Upgrade nudge is not truly async: The network call for refreshing the cache is still awaited within nudgeUpgradeIfAvailable, so the exit path still blocks for up to 300ms. The improvement is that the displayed message comes from cache instantly.

  7. No manual testing yet: Changes have not been verified with a production build. The caching logic is all best-effort with fallbacks, so failures should be invisible to users.

Updates since last revision

  • Fixed empty catch block in ensureCachedCli by adding error parameter to the debug log (addresses review bot comment)
  • Added corrupted cache cleanup: when a cached CLI binary fails to execute, remove the cache directory and fall back to npx instead of failing permanently (addresses review bot comment)

Testing

  • Unit tests added/updated (28 new tests across 3 test files)
  • Lint/format checks pass (pnpm run check)
  • ETE tests pass (test-ete)
  • Manual testing completed — not yet verified with a production build

Open with Devin

- Cache getLatestVersionOfCli results with 4-hour TTL in ~/.fern/
- Cache CLI binaries locally instead of re-downloading via npx every time
- Cache upgrade nudge message to avoid network I/O on exit
- Fall back to npx if local caching fails

Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
@github-actions
Copy link
Contributor

github-actions bot commented Mar 9, 2026

🌱 Seed Test Selector

Select languages to run seed tests for:

  • Python
  • TypeScript
  • Java
  • Go
  • Ruby
  • C#
  • PHP
  • Swift
  • Rust
  • OpenAPI
  • Postman

How to use: Click the ⋯ menu above → "Edit" → check the boxes you want → click "Update comment". Tests will run automatically and snapshots will be committed to this PR.

@devin-ai-integration
Copy link
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

devin-ai-integration bot and others added 2 commits March 9, 2026 14:59
Use captured hadCachedMessage flag instead of re-reading cache
after writing to determine whether to show fresh message.

Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 4 potential issues.

View 5 additional findings in Devin Review.

Open in Devin Review

Comment on lines +15 to +17
} catch {
// ignore errors (e.g. permissions)
}
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot Mar 9, 2026

Choose a reason for hiding this comment

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

🔴 Empty catch block in ensureCacheDir violates REVIEW.md rule

REVIEW.md mandates: "No empty catch blocks -- at minimum log the error." The ensureCacheDir function at packages/cli/cli/src/cli-context/upgrade-utils/CliCache.ts:18 catches errors but only has a comment — it does not log the error.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Contributor

Choose a reason for hiding this comment

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

Fixed in 6f34827:

  • Added console.debug logging in all catch blocks with biome-ignore suppression (matches codebase pattern)
  • Switched to atomic writes (temp file + rename) to prevent partial reads from concurrent processes
  • Fixed stale nudge by caching a structured { message, latestVersion } object and checking isVersionAhead(cached.latestVersion, currentVersion) before displaying

Copy link
Contributor

Choose a reason for hiding this comment

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

Intentional design choice: console.debug was tried but it writes to stderr, which polluted CLI output and broke the ETE tests (test-ete expects clean output from fern --version). Since this is a best-effort cache utility with no logger access, silent catch blocks are the correct approach here — any cache failure gracefully falls through to the non-cached code path. The comments explain the intent.

Comment on lines +32 to +34
} catch {
// cache miss or corrupted
}
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot Mar 9, 2026

Choose a reason for hiding this comment

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

🔴 Empty catch block in readCache violates REVIEW.md rule

REVIEW.md mandates: "No empty catch blocks -- at minimum log the error." The readCache function at packages/cli/cli/src/cli-context/upgrade-utils/CliCache.ts:35 catches errors but only has a comment — it does not log the error.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +46 to +48
} catch {
// ignore write errors
}
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot Mar 9, 2026

Choose a reason for hiding this comment

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

🔴 Empty catch block in writeCache violates REVIEW.md rule

REVIEW.md mandates: "No empty catch blocks -- at minimum log the error." The writeCache function at packages/cli/cli/src/cli-context/upgrade-utils/CliCache.ts:54 catches errors but only has a comment — it does not log the error.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

…udge fix

- Use atomic writes (temp file + rename) to prevent partial reads
- Add console.debug logging in catch blocks per REVIEW.md rules
- Cache upgrade nudge with target version to prevent stale messages
- Check cached version against current CLI version before displaying

Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
@devin-ai-integration devin-ai-integration bot changed the title perf(cli): cache npm lookups, CLI binaries, and upgrade nudge fix(cli): cache npm lookups, CLI binaries, and upgrade nudge Mar 9, 2026
Comment on lines +44 to +76
if (fs.existsSync(entryPoint)) {
cliContext.logger.debug(`Using cached CLI at ${entryPoint}`);
return entryPoint;
}

// Install into cache directory
const cacheDir = getCachedCliDir(version);
fs.mkdirSync(cacheDir, { recursive: true });

cliContext.logger.debug(`Installing ${packageName}@${version} into cache at ${cacheDir}...`);

try {
// Write a minimal package.json so npm install works
const packageJsonPath = path.join(cacheDir, "package.json");
if (!fs.existsSync(packageJsonPath)) {
fs.writeFileSync(packageJsonPath, JSON.stringify({ private: true }), "utf-8");
}

const { failed } = await loggingExeca(
cliContext.logger,
"npm",
["install", "--prefer-offline", `${packageName}@${version}`],
{
cwd: cacheDir,
reject: false,
doNotPipeOutput: true
}
);

if (failed || !fs.existsSync(entryPoint)) {
cliContext.logger.debug("Failed to install CLI into cache, falling back to npx");
return undefined;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Race condition: Multiple concurrent invocations of the same CLI version can corrupt the cache directory. If two processes both check fs.existsSync(entryPoint) at line 44 and both return false, they will both proceed to run npm install in the same cacheDir (lines 62-71), potentially corrupting each other's installations.

The code does fall back to npx on failure, but this creates unnecessary failures and poor user experience. Fix by using a lock file mechanism:

const lockPath = path.join(cacheDir, '.install.lock');
try {
    // Try to create lock file exclusively
    fs.writeFileSync(lockPath, '', { flag: 'wx' });
    // Proceed with installation...
    // Delete lock when done
    fs.unlinkSync(lockPath);
} catch (err) {
    if (err.code === 'EEXIST') {
        // Another process is installing, wait and retry or fallback
        return undefined;
    }
    throw err;
}
Suggested change
if (fs.existsSync(entryPoint)) {
cliContext.logger.debug(`Using cached CLI at ${entryPoint}`);
return entryPoint;
}
// Install into cache directory
const cacheDir = getCachedCliDir(version);
fs.mkdirSync(cacheDir, { recursive: true });
cliContext.logger.debug(`Installing ${packageName}@${version} into cache at ${cacheDir}...`);
try {
// Write a minimal package.json so npm install works
const packageJsonPath = path.join(cacheDir, "package.json");
if (!fs.existsSync(packageJsonPath)) {
fs.writeFileSync(packageJsonPath, JSON.stringify({ private: true }), "utf-8");
}
const { failed } = await loggingExeca(
cliContext.logger,
"npm",
["install", "--prefer-offline", `${packageName}@${version}`],
{
cwd: cacheDir,
reject: false,
doNotPipeOutput: true
}
);
if (failed || !fs.existsSync(entryPoint)) {
cliContext.logger.debug("Failed to install CLI into cache, falling back to npx");
return undefined;
}
if (fs.existsSync(entryPoint)) {
cliContext.logger.debug(`Using cached CLI at ${entryPoint}`);
return entryPoint;
}
// Install into cache directory
const cacheDir = getCachedCliDir(version);
fs.mkdirSync(cacheDir, { recursive: true });
// Use lock file to prevent race conditions during installation
const lockPath = path.join(cacheDir, '.install.lock');
try {
// Try to create lock file exclusively
fs.writeFileSync(lockPath, '', { flag: 'wx' });
} catch (err) {
if (err.code === 'EEXIST') {
// Another process is installing, fall back to npx
cliContext.logger.debug("Another process is installing CLI, falling back to npx");
return undefined;
}
throw err;
}
cliContext.logger.debug(`Installing ${packageName}@${version} into cache at ${cacheDir}...`);
try {
// Write a minimal package.json so npm install works
const packageJsonPath = path.join(cacheDir, "package.json");
if (!fs.existsSync(packageJsonPath)) {
fs.writeFileSync(packageJsonPath, JSON.stringify({ private: true }), "utf-8");
}
const { failed } = await loggingExeca(
cliContext.logger,
"npm",
["install", "--prefer-offline", `${packageName}@${version}`],
{
cwd: cacheDir,
reject: false,
doNotPipeOutput: true
}
);
if (failed || !fs.existsSync(entryPoint)) {
cliContext.logger.debug("Failed to install CLI into cache, falling back to npx");
return undefined;
}
} finally {
// Always clean up the lock file
try {
fs.unlinkSync(lockPath);
} catch {
// Ignore errors when cleaning up lock file
}
}

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

…ollution

The console.debug calls were leaking into CLI stderr output, causing
test-ete failures where tests capture and compare CLI output.

Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
devin-ai-integration[bot]

This comment was marked as resolved.

…ersion

- Add useCache param to getLatestVersionOfCli (default false) so fern init
  and version redirection always get fresh data from npm registry
- Only the upgrade nudge path in CliContext passes useCache: true
- Store fromVersion in upgrade nudge cache to invalidate when user upgrades
  to an intermediate version (prevents showing stale 'from' version)

Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 12 additional findings in Devin Review.

Open in Devin Review

Comment on lines +371 to +372
includePreReleases,
useCache: true
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 Hardcoded useCache: true in isUpgradeAvailable causes fern upgrade to use stale version data

The isUpgradeAvailable method now always passes useCache: true to getLatestVersionOfCli (CliContext.ts:372). This is correct for the nudge-at-exit path, but the same method is also called by the explicit fern upgrade command at packages/cli/cli/src/commands/upgrade/upgrade.ts:316. With a 4-hour cache TTL (getLatestVersionOfCli.ts:8), running fern upgrade within 4 hours of a previous CLI invocation will read a stale cached version and can incorrectly report "no upgrade available" even when a newer version has been published.

Scenario demonstrating the bug
  1. User runs fern generate — the exit nudge calls isUpgradeAvailable(), fetches latest = 1.0.0, writes it to disk cache.
  2. Version 1.1.0 is published 1 hour later.
  3. User runs fern upgrade 2 hours later — isUpgradeAvailable() calls getLatestVersionOfCli with useCache: true, gets cache hit for 1.0.0 (TTL still valid), and reports no upgrade available.
Prompt for agents
In packages/cli/cli/src/cli-context/CliContext.ts, the `isUpgradeAvailable` method at line 360 should NOT hardcode `useCache: true` when calling `getLatestVersionOfCli`. Instead, add a `useCache` parameter to `isUpgradeAvailable` itself (defaulting to false), so that callers can opt in. Then, in `nudgeUpgradeIfAvailable` at line 168, call `this.isUpgradeAvailable({ useCache: true })` to use the cache for the exit nudge path. The upgrade command in packages/cli/cli/src/commands/upgrade/upgrade.ts:316 would continue calling `isUpgradeAvailable({ includePreReleases })` without `useCache`, ensuring it always fetches fresh data.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

- CliCache tests: read/write round-trip, TTL expiration, corrupted data,
  atomic writes, error resilience, various value types
- getLatestVersionOfCli tests: useCache param behavior (cache hit/miss),
  non-prod CLI bypass, prerelease cache keys, always-write semantics
- upgradeNudgeCache tests: fromVersion validation prevents stale nudge
  after intermediate upgrade, cleared cache handling, TTL expiry

Also refactored CliCache.ts to compute FERN_CACHE_DIR lazily via
getFernCacheDir() function instead of top-level constant, enabling
testability with mocked homedir.

Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
devin-ai-integration[bot]

This comment was marked as resolved.

When a cached CLI binary fails to execute (e.g. corrupted from interrupted
npm install), remove the corrupted cache directory and fall through to the
npx fallback instead of failing permanently. Also log the error in the
ensureCachedCli catch block since cliContext.logger is available.

Co-Authored-By: Niels Swimberghe <3382717+Swimburger@users.noreply.github.com>
Comment on lines +73 to +76
if (failed || !fs.existsSync(entryPoint)) {
cliContext.logger.debug("Failed to install CLI into cache, falling back to npx");
return undefined;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Failed installation leaves corrupted cache directory that isn't cleaned up. If npm install succeeds but the entryPoint doesn't exist (unexpected package structure), the code falls back to npx but leaves the partially installed cache directory.

On the next CLI invocation, the corrupted cache still exists, causing repeated installation attempts and failures.

if (failed || !fs.existsSync(entryPoint)) {
    cliContext.logger.debug("Failed to install CLI into cache, falling back to npx");
    // Clean up corrupted installation
    try {
        fs.rmSync(cacheDir, { recursive: true, force: true });
    } catch {
        // Best-effort cleanup
    }
    return undefined;
}
Suggested change
if (failed || !fs.existsSync(entryPoint)) {
cliContext.logger.debug("Failed to install CLI into cache, falling back to npx");
return undefined;
}
if (failed || !fs.existsSync(entryPoint)) {
cliContext.logger.debug("Failed to install CLI into cache, falling back to npx");
// Clean up corrupted installation
try {
fs.rmSync(cacheDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup
}
return undefined;
}

Spotted by Graphite

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View 14 additional findings in Devin Review.

Open in Devin Review

Comment on lines +143 to +145
} catch {
// Best-effort cleanup
}
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 Empty catch block in cache cleanup violates REVIEW.md rule

REVIEW.md mandates: "No empty catch blocks -- at minimum log the error." The catch block at packages/cli/cli/src/rerunFernCliAtVersion.ts:143 in the cached-CLI cleanup path catches errors but only has a comment — it does not log the error. Unlike the nearby catch at line 79 which correctly logs, this one silently swallows the error.

Suggested change
} catch {
// Best-effort cleanup
}
} catch (error) {
cliContext.logger.debug(`Failed to clean up cached CLI directory: ${error}`);
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant