Skip to content

FlagLint Blog

OpenFeature flagd Node.js: Replace LaunchDarkly with a Self-Hosted Provider

LaunchDarkly’s pricing model has a well-documented inflection point. Feature flag evaluation is cheap per-call; access to the dashboard, the CLI, the audit log, and the enterprise tier isn’t. Teams that hit the threshold face a classic build-or-buy rethink — often for the first time after years of letting the LaunchDarkly SDK grow roots across their Node.js codebase.

The migration is a two-phase problem, and each phase is independently reversible.

  • Phase 1 (code migration): Use FlagLint to rewrite every direct LaunchDarkly SDK call to the OpenFeature API. Your flags keep evaluating through LaunchDarkly’s backend — nothing breaks.
  • Phase 2 (provider swap): Replace the LaunchDarkly OpenFeature provider with flagd and cut the vendor dependency entirely. One file changes.

This post walks both phases end-to-end using the OpenFeature flagd Node.js stack. If your application code already calls the OpenFeature API, jump straight to Phase 2.

What the OpenFeature flagd Node.js stack gives you

Section titled “What the OpenFeature flagd Node.js stack gives you”

OpenFeature is a CNCF-hosted specification for vendor-neutral flag evaluation. flagd is its reference provider: a lightweight evaluation server you run yourself. It reads flag definitions from a JSON file, a Kubernetes ConfigMap, or a remote endpoint; evaluates them with fractional rollout and targeting rule support; and exposes a gRPC endpoint your application connects to.

The OpenFeature flagd Node.js combination means:

  • No vendor account. flagd is a binary or Docker container you operate.
  • No pricing tier. Flag count, MAU, team seats — none of it applies.
  • Portable flag definitions. JSON files in your repo, versioned with your code.
  • Provider portability. If you later want Flipt or GrowthBook, swapping providers is a one-file change — the same change you’ll make when leaving LaunchDarkly.

Point FlagLint at your source directory:

Terminal window
npx flaglint@latest audit ./src

If you accidentally point it at a directory with no JavaScript or TypeScript files — a documentation folder, for example — FlagLint tells you immediately:

- Auditing ./src/content/docs/...
No matching files found. Check your .flaglintrc include patterns.

Point it at your application source. Running against a Node.js service with seven LaunchDarkly flag call sites across two files produces:

- Auditing ....
# FlagLint Audit Report
**Scanned at:** 2026-07-23T03:06:31.585Z
**Scan root:** /tmp/.../demo-app
**Files scanned:** 2
**Duration:** 45ms
## Summary
| Total Flags | High Risk | Medium Risk | Total Usages |
|-------------|-----------|-------------|--------------|
| 7 | 1 | 6 | 7 |
| Dynamic Keys | Detail Evals | Bulk Calls | Stale Signals | Safely Automatable | Manual Review |
|--------------|--------------|------------|---------------|-------------------|---------------|
| 0 | 0 | 1 | 0 | 6 | 1 |
> **Staleness:** No staleness signals detected. Heuristics checked: keyword match (flag key
> contains old/deprecated/legacy/temp/tmp/test/demo), path pattern (test/spec/mock files,
> deprecated/old/legacy directories), and minFileCount threshold.
## Migration Readiness
Migration readiness: **86/100** · ready
[██████████████████████░░░] 86%
6 safely automatable · 1 require manual review
## Flag Debt Inventory
| Flag Key | Risk | Usages | Files | Call Types | Reasons |
|----------|------|--------|-------|------------|---------|
| `*` | 🔴 High | 1 | 1 | allFlagsState | bulk call |
| `new-dashboard` | 🟢 Automatable | 1 | 1 | variation | safely automatable |
| `checkout-variant` | 🟢 Automatable | 1 | 1 | variation | safely automatable |
| `payment-v2` | 🟢 Automatable | 1 | 1 | variation | safely automatable |
| `rollout-percentage` | 🟢 Automatable | 1 | 1 | variation | safely automatable |
| `checkout-v2` | 🟢 Automatable | 1 | 1 | variation | safely automatable |
| `promo-banner` | 🟢 Automatable | 1 | 1 | variation | safely automatable |
## Next Steps
- Run `flaglint migrate --dry-run` to preview safe OpenFeature rewrites
- Run `flaglint validate --no-direct-launchdarkly` to enforce OF boundary in CI
- Review HIGH risk flags manually before any automated migration
✓ Audit complete: 7 flags — 1 high risk, 6 medium risk (45ms, 2 files)

A readiness score of 86/100 means six call sites are safely automatable. The one high-risk item — allFlagsState — is a bulk inventory call with no single-flag codemod. Five LaunchDarkly SDK Patterns That Block Automatic Migration covers how to resolve it manually.

For a larger service, add --effort-estimate to get a migration hours projection before you commit to the work. See LaunchDarkly Flag Debt for how to read the output.

Before writing anything to disk, preview what changes:

Terminal window
npx flaglint@latest migrate --dry-run ./src
LaunchDarkly usages found: 7
Safely automatable: 6 · Manual review: 1
# FlagLint migrate --dry-run
These diffs use the placeholder `openFeatureClient` and require OpenFeature
provider/client setup before they can be applied.
No files are modified by dry-run output.
Reviewable diffs: 6
Diffs requiring provider setup: 6
Skipped usages: 1
## Diffs
diff --git a/featureFlags.ts b/featureFlags.ts
--- a/featureFlags.ts
+++ b/featureFlags.ts
@@ -7,1 +7,1 @@
- return client.variation('new-dashboard', user, false);
+ return openFeatureClient.getBooleanValue('new-dashboard', false, user);
@@ -12,1 +12,1 @@
- return client.variation('checkout-variant', user, 'control');
+ return openFeatureClient.getStringValue('checkout-variant', 'control', user);
@@ -17,1 +17,1 @@
- return client.variation('payment-v2', context, false);
+ return openFeatureClient.getBooleanValue('payment-v2', false, context);
@@ -22,1 +22,1 @@
- return client.variation('rollout-percentage', user, 0);
+ return openFeatureClient.getNumberValue('rollout-percentage', 0, user);
diff --git a/routes.ts b/routes.ts
--- a/routes.ts
+++ b/routes.ts
@@ -7,1 +7,1 @@
- const useNewFlow = await ldClient.variation('checkout-v2', user, false);
+ const useNewFlow = await openFeatureClient.getBooleanValue('checkout-v2', false, user);
@@ -8,1 +8,1 @@
- const showBanner = await ldClient.variation('promo-banner', user, false);
+ const showBanner = await openFeatureClient.getBooleanValue('promo-banner', false, user);
## Skipped Usages
- routes.ts:18:9 — `*` via `allFlagsState`: bulk inventory call has no single-flag codemod

Notice the argument order shift: variation(flag key, context, default) becomes getBooleanValue(flag key, default, context). FlagLint rewrites this at the AST level — not with find-and-replace — so it handles wrappers and aliased clients that grep misses. Getting the argument order wrong silently is the most common production bug in LaunchDarkly-to-OpenFeature migrations. See Why LaunchDarkly → OpenFeature Migrations Break in Production for the full explanation.

Phase 1: apply with the LaunchDarkly OpenFeature provider

Section titled “Phase 1: apply with the LaunchDarkly OpenFeature provider”

Apply the rewrites now, while keeping LaunchDarkly evaluating your flags. Flags continue to work exactly as before; you get to run your test suite before touching the backend at all.

Install the provider packages:

Terminal window
npm install @openfeature/server-sdk @launchdarkly/node-server-sdk @launchdarkly/openfeature-node-server

Add provider initialization at application startup:

bootstrap.ts
import { OpenFeature } from "@openfeature/server-sdk";
import { LaunchDarklyProvider } from "@launchdarkly/openfeature-node-server";
const provider = new LaunchDarklyProvider(process.env.LD_SDK_KEY!);
await OpenFeature.setProviderAndWait(provider);
export const openFeatureClient = OpenFeature.getClient();

Apply the rewrites:

Terminal window
npx flaglint@latest migrate ./src

Your call sites now use getBooleanValue, getStringValue, and getNumberValue from the OpenFeature server SDK. The LaunchDarkly SDK key is still required at runtime — the LaunchDarkly OpenFeature provider wraps it. But your application code no longer imports from @launchdarkly/node-server-sdk directly. You can replace the provider later without touching a single call site.

Once the OpenFeature migration is complete and your test suite passes, swapping the backend from LaunchDarkly to flagd takes two steps: write your flag definitions, then update the single bootstrap file.

Terminal window
# macOS / Linux via Homebrew
brew install open-feature/tap/flagd
flagd start --uri file:./flags.json
# Docker
docker run -p 8013:8013 ghcr.io/open-feature/flagd:latest start --uri file:./flags.json
{
"$schema": "https://flagd.dev/schema/v0/flags.json",
"flags": {
"new-dashboard": {
"state": "ENABLED",
"variants": { "on": true, "off": false },
"defaultVariant": "on"
},
"checkout-variant": {
"state": "ENABLED",
"variants": { "control": "control", "v2": "v2" },
"defaultVariant": "control"
},
"checkout-v2": {
"state": "ENABLED",
"variants": { "on": true, "off": false },
"defaultVariant": "off"
},
"promo-banner": {
"state": "DISABLED",
"variants": { "on": true, "off": false },
"defaultVariant": "off"
}
}
}

Commit this file to your repository. Flag changes are now a JSON diff in a pull request — reviewable, auditable, and rolled back with a revert.

Update bootstrap.ts — the only file that changes

Section titled “Update bootstrap.ts — the only file that changes”
Terminal window
npm install @openfeature/server-sdk @openfeature/flagd-provider
// bootstrap.ts — only this file changes
import { OpenFeature } from "@openfeature/server-sdk";
import { FlagdProvider } from "@openfeature/flagd-provider";
await OpenFeature.setProviderAndWait(new FlagdProvider());
export const openFeatureClient = OpenFeature.getClient();

Delete LD_SDK_KEY from your environment. Every flag evaluation in your application now routes to flagd. Every call site you migrated in Phase 1 is unchanged.

This is the payoff of the OpenFeature flagd Node.js approach: provider-agnostic application code where the backend is genuinely swappable. If you later want percentage rollouts or targeting rules, flagd’s JSON schema supports both without changing application code. If you want to move to a hosted provider, the swap is again a one-file change.

Lock in the migration by blocking new direct LaunchDarkly SDK calls from landing:

Terminal window
npx flaglint@latest validate --no-direct-launchdarkly ./src

Add this step to your GitHub Actions workflow:

- name: Enforce OpenFeature boundary
run: npx flaglint@latest validate --no-direct-launchdarkly ./src

Full setup with SARIF annotation upload is covered in Enforcing Your LaunchDarkly to OpenFeature Migration in GitHub Actions.

The OpenFeature flagd Node.js stack resolves both categories of LaunchDarkly dependency: the SDK lock-in in your code (FlagLint handles Phase 1), and the vendor dependency in your infrastructure (flagd handles Phase 2). Phase 1 takes an afternoon for a typical Node.js service. Phase 2 is one file change and a bootstrap swap.

flaglint-go: We Field-Tested It Against Real Repos and Found Zero Recall

flaglint-go is a native Go binary for auditing LaunchDarkly Go server SDK usage — no Node.js required.

Terminal window
brew install flaglint/tap/flaglint-go
flaglint-go audit ./services

It’s the Go-native counterpart to flaglint-js, and it shares the same non-negotiable rule: a variable is only ever treated as a LaunchDarkly client when its identity can be proven — never by matching a method name in isolation. That rule is why flaglint-js earned trust in the first place, and flaglint-go inherited it from day one.

Before shipping, we validated flaglint-go the way you’d hope any static analysis tool gets validated: not just against synthetic fixtures we wrote ourselves, but against real, unmodified, open-source Go repositories known to use the LaunchDarkly SDK — including the official launchdarkly-labs/ld-sample-app-go, plus weaviate/weaviate, CMS-Enterprise/mint-app, and e2b-dev/infra.

The result: zero false positives, but also zero recall on every single repo with genuine usage.

That’s a striking failure. Not “missed a few edge cases” — missed all of it, on the official sample app included. The scanner wasn’t broken; it was doing exactly what it was designed to do (prove identity syntactically, never guess by name) — it just turned out real Go code almost never wires up the LaunchDarkly client the simple way our synthetic fixtures assumed.

Three different repos, three different indirection patterns, none of them exotic:

The official sample app wires the client through a package-level singleton getter, called from a different package entirely:

// package ldclient
func GetLdClient() *ld.LDClient { /* ... */ }
// package api, a different file, a different package
client := ldclient.GetLdClient()
client.BoolVariation("test-flag", ctx, false)

weaviate stores the client into a wrapper struct via composite literal, then reaches it through a two-level field chain — on a generic struct:

type LDIntegration struct {
ldClient *ld.LDClient
}
type FeatureFlag[T SupportedTypes] struct {
ldInteg *LDIntegration
}
// inside one of FeatureFlag[T]'s methods:
flag, err := f.ldInteg.ldClient.StringVariation(f.key, f.ldInteg.ldContext, v)

mint-app passes an already-constructed client into a struct’s constructor as a plain function parameter — there’s no assignment to trace at all; the parameter’s declared type is the only place identity is ever established.

None of these require real type-checking to resolve. In every case, the proof of identity is available directly from the AST — a struct’s declared field type, a function’s declared parameter or return type — the same “trust the syntax, no build required” spirit as the rest of the scanner. What they do require is looking at more than one file at a time, since the code that constructs the client and the code that uses it are routinely in a different file, sometimes a different package, from wherever the binding was first established.

We rearchitected the scanner from a per-file model (read, parse, detect, discard — one file at a time) into a whole-scan pass: parse every file up front, then resolve identity across the entire scan before any detection runs. That closed all three gaps:

  • Composite-literal struct-field binding&LDIntegration{ldClient: client} binds the field when client is itself already bound.
  • Multi-level field-selector chains, including through generics — a struct’s fields can be declared in a different file than where they’re used.
  • Cross-package factory/getter functions, resolved via real go.mod-derived import paths — never a name-based guess. (We explicitly considered and rejected matching by “last segment of the import path” as a shortcut — that’s a name heuristic wearing an import-path costume, exactly the kind of thing our non-negotiable identity rule exists to prevent.)
  • Parameter-typed client bindings — a parameter declared *ld.LDClient is bound from its type alone, no assignment to trace.

We also found and fixed a bug that had nothing to do with the original plan: Go generics. weaviate’s FeatureFlag[T] broke a piece of code that had only ever been tested against non-generic structs — a method receiver on a generic type has a different AST shape (*ast.IndexExpr) than a plain one, and the scanner silently failed to resolve it at all. Found only by testing against real code that happened to use generics.

Before merging any of this, we ran an independent review pass — a fresh reviewer with no context on the implementation, adversarially checking the diff. It found something real: two of the new whole-scan indices (struct field types, and package-level/struct-field bindings) were keyed by bare name across the entire scan, not scoped to a package. Go allows two completely unrelated packages to each declare their own Service struct with their own Client field — and a genuinely-bound client in one package would have incorrectly matched a same-named, unconnected field in a totally different package.

That’s exactly the class of false positive our whole identity model exists to prevent, and it slipped through the first pass. We fixed it by partitioning every whole-scan index by package — matching how an unqualified identifier is only ever visible within its own package in real Go anyway — added regression fixtures reproducing the exact collision, and re-verified against all three real repos to confirm detection was unaffected by the fix.

After the fix, we re-cloned and re-scanned the same repos:

$ flaglint-go audit ./ld-sample-app-go
Scan complete — 1 unique flag(s) across 1 call site(s) (3 file(s))
Migration readiness: 100/100 · ready
1 low risk · 0 medium risk · 0 high risk
$ flaglint-go audit ./weaviate
Scan complete — 0 unique flag(s) across 4 call site(s) (4512 file(s))
Migration readiness: 0/100 · complex
0 low risk · 0 medium risk · 4 high risk

weaviate’s four call sites all show as high-risk dynamic keys — correctly so, since the flag key there (f.key) is a runtime struct field, not a string literal. That’s the honest answer, not a false claim of full static resolution.

We’re not going to pretend this closes every gap. e2b-dev/infra’s usage is still undetected — it takes a bound client’s method value and passes it through a generic helper function, which is a genuinely harder problem (interprocedural data-flow, not just “look at more files”). That, along with a handful of narrower gaps found along the way, is filed as tracked, public issues rather than silently swept under the rug — see the Supported Scope and Limitations pages for the full, honest list.

Terminal window
brew install flaglint/tap/flaglint-go
flaglint-go audit ./services

LaunchDarkly Feature Flag Cleanup: Audit, Rewrite, and Enforce in TypeScript

Your codebase has been accumulating direct LaunchDarkly SDK calls for years. The team knows a cleanup is overdue, but nobody has a clear picture of how many flag keys exist, which ones are safely automatable, and which ones will bite you if touched carelessly. LaunchDarkly’s built-in cleanup tooling — Vega — requires an Enterprise subscription. Grep finds string literals but misses dynamic keys, detail evaluations, and bulk calls. You end up either doing nothing or hand-editing files one at a time and hoping the argument order is correct.

FlagLint is a free, open-source CLI that automates LaunchDarkly feature flag cleanup in TypeScript and JavaScript codebases using AST-based static analysis — not regex — to classify every direct LaunchDarkly SDK call by risk, generate a readable flag debt inventory, rewrite safe call sites to the OpenFeature standard, and enforce the resulting boundary in CI. No LaunchDarkly API key required.

This guide walks through a complete cleanup cycle: audit → rewrite → enforce.

The first step of any LaunchDarkly feature flag cleanup is understanding what you are dealing with. Run flaglint audit against your source directory:

Terminal window
npx flaglint audit ./src

Here is the real output for a two-file Node.js service with seven flag evaluations across checkout and pricing modules:

- Auditing ./src...
# FlagLint Audit Report
**Scanned at:** 2026-07-06T04:47:44.977Z
**Scan root:** ./src
**Files scanned:** 2
**Duration:** 35ms
## Summary
| Total Flags | High Risk | Medium Risk | Total Usages |
|-------------|-----------|-------------|--------------|
| 7 | 2 | 5 | 7 |
| Dynamic Keys | Detail Evals | Bulk Calls | Stale Signals | Safely Automatable | Manual Review |
|--------------|--------------|------------|---------------|-------------------|---------------|
| 1 | 1 | 0 | 0 | 5 | 2 |
> **Staleness:** No staleness signals detected. Heuristics checked: keyword match
> (flag key contains old/deprecated/legacy/temp/tmp/test/demo), path pattern
> (test/spec/mock files, deprecated/old/legacy directories), and minFileCount
> threshold. Git-history-based staleness (last evaluation date) requires git
> metadata and is not available in a pure static scan.
## Migration Readiness
Migration readiness: **71/100** · moderate
[██████████████████░░░░░░░] 71%
5 safely automatable · 2 require manual review
## Flag Debt Inventory
| Flag Key | Risk | Usages | Files | Call Types | Reasons |
|----------|------|--------|-------|------------|---------|
| `<dynamic key>` | 🔴 High | 1 | 1 | numberVariation | dynamic key |
| `beta-pricing` | 🔴 High | 1 | 1 | boolVariationDetail | detail evaluation |
| `checkout-v2` | 🟢 Automatable | 1 | 1 | boolVariation | safely automatable |
| `discount-percentage` | 🟢 Automatable | 1 | 1 | numberVariation | safely automatable |
| `ui-theme` | 🟢 Automatable | 1 | 1 | stringVariation | safely automatable |
| `checkout-config` | 🟡 Medium | 1 | 1 | jsonVariation | safely automatable, json variation |
| `promo-banner` | 🟢 Automatable | 1 | 1 | boolVariation | safely automatable |
## Next Steps
- Run `flaglint migrate --dry-run` to preview safe OpenFeature rewrites
- Run `flaglint validate --no-direct-launchdarkly` to enforce OF boundary in CI
- Review HIGH risk flags manually before any automated migration
✓ Audit complete: 7 flags — 2 high risk, 5 medium risk (35ms, 2 files)
Migration readiness: 71/100 · moderate
[██████████████████░░░░░░░] 71%
5 safely automatable · 2 require manual review

The readiness score of 71/100 means 5 of 7 call sites can be rewritten automatically. The two high-risk entries need manual attention before anything else moves.

Add --format html --output flag-debt.html to produce a shareable report to attach to a migration planning ticket. The flag debt blog post covers the full range of audit options including effort estimates.

What the flag debt inventory is telling you

Section titled “What the flag debt inventory is telling you”

Two call types are classified as high risk:

Dynamic key (<dynamic key>) — the flag key is constructed at runtime from a variable or template literal (e.g., const key = `pricing-${plan}`). FlagLint cannot resolve which flag is actually evaluated at any given call site. Any automated rewrite here would silently touch the wrong call. These require a human decision: extract the dynamic key into a lookup table, split into separate static flag keys, or handle manually per call type.

Detail evaluation (beta-pricing via boolVariationDetail) — variationDetail returns a reason object with no direct OpenFeature equivalent. FlagLint skips these by design. You need to decide whether that reason metadata is still needed after migration, and if so, which OpenFeature detail API maps to your use case.

The five remaining call sites — boolVariation, numberVariation, stringVariation, jsonVariation, and a second boolVariation — are all safely automatable. FlagLint can rewrite every one of them without you touching a line.

Run flaglint migrate with --dry-run to see exactly what changes before any file is modified:

Terminal window
npx flaglint migrate ./src --dry-run

Real output (provider setup guidance section omitted; covered in Step 3 below):

- Scanning ./src...
LaunchDarkly usages found: 7
Safely automatable: 5 · Manual review: 2
Reviewable diffs: 5
Diffs requiring provider setup: 5
Skipped usages: 2
## Diffs
diff --git a/checkout.ts b/checkout.ts
--- a/checkout.ts
+++ b/checkout.ts
@@ -8,1 +8,1 @@
- const newCheckoutEnabled = await ldClient.boolVariation("checkout-v2", ctx, false);
+ const newCheckoutEnabled = await openFeatureClient.getBooleanValue("checkout-v2", false, ctx);
@@ -9,1 +9,1 @@
- const discountPct = await ldClient.numberVariation("discount-percentage", ctx, 0);
+ const discountPct = await openFeatureClient.getNumberValue("discount-percentage", 0, ctx);
@@ -10,1 +10,1 @@
- const theme = await ldClient.stringVariation("ui-theme", ctx, "default");
+ const theme = await openFeatureClient.getStringValue("ui-theme", "default", ctx);
@@ -11,1 +11,1 @@
- const config = await ldClient.jsonVariation("checkout-config", ctx, {});
+ const config = await openFeatureClient.getObjectValue("checkout-config", {}, ctx);
diff --git a/pricing.ts b/pricing.ts
--- a/pricing.ts
+++ b/pricing.ts
@@ -10,1 +10,1 @@
- const promoEnabled = await ldClient.boolVariation("promo-banner", ctx, false);
+ const promoEnabled = await openFeatureClient.getBooleanValue("promo-banner", false, ctx);
## Skipped Usages
- pricing.ts:9:26 — `dynamicKey` via `numberVariation`: dynamic key requires manual review
- pricing.ts:11:23 — `beta-pricing` via `boolVariationDetail`: detail methods skipped:
OpenFeature detail APIs exist, but LaunchDarkly/OpenFeature detail result parity requires
manual review

Notice the argument order flip: boolVariation("checkout-v2", ctx, false) becomes getBooleanValue("checkout-v2", false, ctx). The LaunchDarkly SDK puts context second and default last; OpenFeature reverses that. This reversed argument order is the most common source of silent production bugs in manual migrations — FlagLint handles it correctly for every safe call type.

The jsonVariationgetObjectValue rewrite is flagged json variation in the audit because OpenFeature’s JSON type is object. If your LaunchDarkly flag ever returns a primitive JSON value (number, string, boolean, null), call semantics differ. Review before applying.

The two skipped usages are left exactly as-is in source.

The dry-run output marks all five diffs as requiring provider setup. The LaunchDarkly SDK stays as your evaluation backend — you are changing the API your application code calls, not where flags are stored or evaluated.

Install once:

Terminal window
npm install @openfeature/server-sdk \
@launchdarkly/node-server-sdk \
@launchdarkly/openfeature-node-server

Add a bootstrap file at application startup. Do not remove existing LaunchDarkly packages — the OpenFeature provider depends on them at runtime:

import { OpenFeature } from "@openfeature/server-sdk";
import { LaunchDarklyProvider } from "@launchdarkly/openfeature-node-server";
const ldProvider = new LaunchDarklyProvider(process.env.LD_SDK_KEY!);
await OpenFeature.setProviderAndWait(ldProvider);
export const openFeatureClient = OpenFeature.getClient();

Import openFeatureClient in every module that has call sites in the migration plan, or configure openFeatureClientBindings in your .flaglintrc so FlagLint locates the client binding automatically. The add OpenFeature provider tutorial covers both approaches with full examples.

Once the OpenFeature provider is wired:

Terminal window
npx flaglint migrate ./src --apply

FlagLint rewrites only the five safely automatable call sites and leaves the two high-risk ones untouched. What you get is an ordinary git diff: five function-call replacements across two files, reviewable like any other PR. The dynamic key and detail evaluation remain as direct LaunchDarkly SDK calls until you handle them manually.

After --apply and manual resolution of the remaining two call sites, lock the boundary so no new direct LaunchDarkly SDK calls can reach main:

Terminal window
npx flaglint validate ./src --no-direct-launchdarkly

If you are mid-cleanup and cannot enforce a hard block yet, use baseline mode: it freezes existing flag debt and fails on any net-new addition.

Terminal window
# Write current findings as the accepted baseline
npx flaglint audit ./src --write-baseline .flaglint-baseline.json
# In CI: fail only on findings not present in the baseline
npx flaglint validate ./src \
--no-direct-launchdarkly \
--baseline .flaglint-baseline.json \
--fail-on-new

Commit .flaglint-baseline.json to source control. Each time you resolve a flag through migrate --apply or a manual cleanup, re-run --write-baseline to shrink the accepted set. The GitHub Actions integration guide shows the full CI step configuration, including SARIF upload for GitHub Code Scanning annotations.

If your LaunchDarkly SDK calls are spread across multiple packages, run the three commands per package rather than at the repo root. Each package can have its own .flaglintrc pointing to its local OpenFeature client binding. The monorepo guide covers per-package configuration and how to sequence the cleanup when the same flag key is evaluated in shared libraries and consumer apps simultaneously.

LaunchDarkly feature flag cleanup at the code level breaks into four repeatable steps:

  1. flaglint audit ./src — inventory your flag debt and get a readiness score
  2. flaglint migrate ./src --dry-run — review the migration plan before touching files
  3. flaglint migrate ./src --apply — apply safe rewrites; fix the remaining two manually
  4. flaglint validate ./src --no-direct-launchdarkly — gate the boundary in CI

No Enterprise subscription, no API key, no manual grep. The complete six-step migration walkthrough picks up from here if you want to see the full picture across a production Node.js service.

FlagLint Is Now Installable via Homebrew

Starting with v1.1.0, FlagLint is installable via Homebrew — no Node.js required.

Terminal window
brew tap flaglint/tap
brew install flaglint

That’s it. The full CLI is available immediately, including audit, scan, migrate, and validate.

Until now, the only install paths were npm install -g flaglint or npx flaglint@latest. Both work fine — but both require Node.js 20 or newer. That’s a reasonable assumption for application developers, but it’s a friction point in a few common situations.

DevOps and platform engineers often run tooling audits across repos without a Node.js environment set up. Installing Node just to run one CLI is the kind of thing that gets a tool skipped in favour of whatever’s already available.

Docker-based CI is the bigger one. A lot of teams run their CI in minimal images — no Node, no npm. Adding a Node install step purely to run npx flaglint adds 30-60 seconds to every pipeline run and pulls in a dependency that has nothing to do with the actual application. With Homebrew, a Linux CI job can install FlagLint in a single brew install call with no Node dependency.

Mac developers who use Homebrew for CLI tools now get brew upgrade flaglint like any other tool, without thinking about npm.

The tap lives at github.com/flaglint/homebrew-tap. The formula fetches the published npm tarball directly from the npm registry and wires up the CLI binary.

The formula updates automatically on every release — a GitHub Actions job in the main repo runs after each npm publish, computes the new tarball SHA256, and commits an updated formula to the tap. So brew upgrade flaglint will always pull the current release without any manual intervention.

Terminal window
brew tap flaglint/tap
brew install flaglint
flaglint --version

Or if you already have Node.js, npx flaglint@latest still works exactly as before. The Homebrew path is an addition, not a replacement.

Once installed, the quickstart walks through the full audit → preview → apply workflow.

FlagLint Is Now Listed on the OpenFeature Ecosystem

FlagLint is now listed in the OpenFeature ecosystem directory as one of two integrations in the JavaScript/Server category.

I want to be straightforward about what that means — and what it doesn’t.

The OpenFeature ecosystem is a directory maintained by the OpenFeature project (a CNCF incubating standard) where providers, SDKs, hooks, and integrations can be listed. It is not an award or a certification. It is a discovery page. Teams that are already evaluating OpenFeature — researching providers, looking for tooling, trying to understand the ecosystem — land there.

Being listed means that those teams will now find FlagLint when they filter for integrations. That matters because the people who need FlagLint most are exactly the people who are actively thinking about OpenFeature.

The OpenFeature standard is the reason FlagLint exists. The whole problem FlagLint solves — the argument-order inversion between LaunchDarkly’s boolVariation(key, ctx, default) and OpenFeature’s getBooleanValue(key, default, ctx) — only surfaces when you are trying to move to OpenFeature. If teams weren’t adopting OpenFeature, there would be no migration to get wrong.

So it made sense to be in the directory where those teams are looking. Not to market FlagLint as a product, but to be findable at the point in the journey where someone is asking “what tooling exists around OpenFeature for LaunchDarkly migration?”

What FlagLint does in the context of OpenFeature

Section titled “What FlagLint does in the context of OpenFeature”

FlagLint is not an OpenFeature SDK or provider. It doesn’t evaluate flags. What it does is sit at the boundary between your existing LaunchDarkly codebase and the OpenFeature world you’re moving toward.

Specifically:

  • Audit — inventory every direct LaunchDarkly SDK call, classify each one by migration risk, produce a readiness score
  • Migrate — preview and apply proven-safe call-site rewrites that transpose arguments correctly and rename methods atomically
  • Validate — enforce in CI that no new direct LaunchDarkly calls land once you’ve drawn the boundary

None of that requires a network connection, an API key, or access to your LaunchDarkly environment. It’s all static analysis on your source code, running locally.

If you’re in the process of moving to OpenFeature and want to understand your current exposure before touching any code, the audit command is the right starting point.

Terminal window
npx flaglint@latest audit ./src

It runs in under a minute on most codebases and doesn’t touch any files.