@mergedapp/feature-flags 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/README.md +651 -0
  2. package/dist/cjs/cli/audit.js +117 -0
  3. package/dist/cjs/cli/cleanup.js +105 -0
  4. package/dist/cjs/cli/config-loader.js +102 -0
  5. package/dist/cjs/cli/generate.js +194 -0
  6. package/dist/cjs/cli/parse-args.js +18 -0
  7. package/dist/cjs/cli.js +46 -0
  8. package/dist/cjs/client.js +505 -0
  9. package/dist/cjs/errors.js +24 -0
  10. package/dist/cjs/index.js +13 -0
  11. package/dist/cjs/jwt.js +85 -0
  12. package/dist/cjs/nestjs/bindings.js +36 -0
  13. package/dist/cjs/nestjs/constants.js +7 -0
  14. package/dist/cjs/nestjs/context.js +28 -0
  15. package/dist/cjs/nestjs/decorators.js +50 -0
  16. package/dist/cjs/nestjs/errors.js +25 -0
  17. package/dist/cjs/nestjs/evaluator.js +87 -0
  18. package/dist/cjs/nestjs/guard.js +67 -0
  19. package/dist/cjs/nestjs/interceptor.js +56 -0
  20. package/dist/cjs/nestjs/module.js +70 -0
  21. package/dist/cjs/nestjs/service.js +54 -0
  22. package/dist/cjs/nestjs/types.js +2 -0
  23. package/dist/cjs/nestjs.js +26 -0
  24. package/dist/cjs/openfeature/context.js +166 -0
  25. package/dist/cjs/openfeature/hooks.js +31 -0
  26. package/dist/cjs/openfeature/server-provider.js +107 -0
  27. package/dist/cjs/openfeature/server.js +13 -0
  28. package/dist/cjs/openfeature/shared.js +83 -0
  29. package/dist/cjs/openfeature/web-provider.js +156 -0
  30. package/dist/cjs/openfeature/web.js +13 -0
  31. package/dist/cjs/package.json +3 -0
  32. package/dist/cjs/persistence.js +249 -0
  33. package/dist/cjs/react/hooks.js +86 -0
  34. package/dist/cjs/react/provider.js +106 -0
  35. package/dist/cjs/react.js +7 -0
  36. package/dist/cjs/remote-evaluator.js +162 -0
  37. package/dist/cjs/types.js +2 -0
  38. package/dist/cli/audit.d.ts +3 -0
  39. package/dist/cli/audit.js +114 -0
  40. package/dist/cli/cleanup.d.ts +3 -0
  41. package/dist/cli/cleanup.js +102 -0
  42. package/dist/cli/config-loader.d.ts +26 -0
  43. package/dist/cli/config-loader.js +66 -0
  44. package/dist/cli/generate.d.ts +3 -0
  45. package/dist/cli/generate.js +191 -0
  46. package/dist/cli/parse-args.d.ts +1 -0
  47. package/dist/cli/parse-args.js +15 -0
  48. package/dist/cli.d.ts +1 -0
  49. package/dist/cli.js +45 -0
  50. package/dist/client.d.ts +67 -0
  51. package/dist/client.js +501 -0
  52. package/dist/errors.d.ts +15 -0
  53. package/dist/errors.js +18 -0
  54. package/dist/index.cjs +1 -0
  55. package/dist/index.d.ts +4 -0
  56. package/dist/index.js +3 -0
  57. package/dist/jwt.d.ts +20 -0
  58. package/dist/jwt.js +78 -0
  59. package/dist/nestjs/bindings.d.ts +5 -0
  60. package/dist/nestjs/bindings.js +33 -0
  61. package/dist/nestjs/constants.d.ts +4 -0
  62. package/dist/nestjs/constants.js +4 -0
  63. package/dist/nestjs/context.d.ts +12 -0
  64. package/dist/nestjs/context.js +24 -0
  65. package/dist/nestjs/decorators.d.ts +4 -0
  66. package/dist/nestjs/decorators.js +45 -0
  67. package/dist/nestjs/errors.d.ts +12 -0
  68. package/dist/nestjs/errors.js +20 -0
  69. package/dist/nestjs/evaluator.d.ts +17 -0
  70. package/dist/nestjs/evaluator.js +83 -0
  71. package/dist/nestjs/guard.d.ts +19 -0
  72. package/dist/nestjs/guard.js +63 -0
  73. package/dist/nestjs/interceptor.d.ts +10 -0
  74. package/dist/nestjs/interceptor.js +53 -0
  75. package/dist/nestjs/module.d.ts +6 -0
  76. package/dist/nestjs/module.js +67 -0
  77. package/dist/nestjs/service.d.ts +30 -0
  78. package/dist/nestjs/service.js +51 -0
  79. package/dist/nestjs/types.d.ts +100 -0
  80. package/dist/nestjs/types.js +1 -0
  81. package/dist/nestjs.cjs +1 -0
  82. package/dist/nestjs.d.ts +10 -0
  83. package/dist/nestjs.js +9 -0
  84. package/dist/openfeature/context.d.ts +10 -0
  85. package/dist/openfeature/context.js +160 -0
  86. package/dist/openfeature/hooks.d.ts +6 -0
  87. package/dist/openfeature/hooks.js +27 -0
  88. package/dist/openfeature/server-provider.d.ts +20 -0
  89. package/dist/openfeature/server-provider.js +102 -0
  90. package/dist/openfeature/server.cjs +1 -0
  91. package/dist/openfeature/server.d.ts +3 -0
  92. package/dist/openfeature/server.js +3 -0
  93. package/dist/openfeature/shared.d.ts +37 -0
  94. package/dist/openfeature/shared.js +74 -0
  95. package/dist/openfeature/web-provider.d.ts +27 -0
  96. package/dist/openfeature/web-provider.js +151 -0
  97. package/dist/openfeature/web.cjs +1 -0
  98. package/dist/openfeature/web.d.ts +3 -0
  99. package/dist/openfeature/web.js +3 -0
  100. package/dist/persistence.d.ts +39 -0
  101. package/dist/persistence.js +203 -0
  102. package/dist/react/hooks.d.ts +52 -0
  103. package/dist/react/hooks.js +78 -0
  104. package/dist/react/provider.d.ts +71 -0
  105. package/dist/react/provider.js +99 -0
  106. package/dist/react.cjs +1 -0
  107. package/dist/react.d.ts +2 -0
  108. package/dist/react.js +2 -0
  109. package/dist/remote-evaluator.d.ts +28 -0
  110. package/dist/remote-evaluator.js +158 -0
  111. package/dist/types.d.ts +56 -0
  112. package/dist/types.js +1 -0
  113. package/featureflags.config.schema.json +38 -0
  114. package/package.json +107 -0
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveFlagFromContext = resolveFlagFromContext;
4
+ exports.useFeatureFlagStatus = useFeatureFlagStatus;
5
+ exports.useUntypedFeatureFlag = useUntypedFeatureFlag;
6
+ exports.useUntypedFeatureFlags = useUntypedFeatureFlags;
7
+ exports.useUntypedFeatureFlagClient = useUntypedFeatureFlagClient;
8
+ exports.createTypedHooks = createTypedHooks;
9
+ const react_1 = require("react");
10
+ const provider_js_1 = require("./provider.js");
11
+ function useFeatureFlagContext() {
12
+ const context = (0, react_1.useContext)(provider_js_1.FeatureFlagContext);
13
+ if (!context) {
14
+ throw new Error("Feature flag hooks and components must be used within a <FeatureFlagProvider>.");
15
+ }
16
+ return context;
17
+ }
18
+ function resolveFlagFromContext(params) {
19
+ return (params.flags.find((flag) => flag.id === params.idOrName || flag.name === params.idOrName) ??
20
+ params.client.getFlag(params.idOrName));
21
+ }
22
+ function useFeatureFlagStatus() {
23
+ const { loadState } = useFeatureFlagContext();
24
+ return loadState;
25
+ }
26
+ function useUntypedFeatureFlag(idOrName) {
27
+ const { client, flags } = useFeatureFlagContext();
28
+ const flag = resolveFlagFromContext({ client, flags, idOrName });
29
+ return { enabled: flag?.enabled ?? false, value: flag?.value };
30
+ }
31
+ function useUntypedFeatureFlags() {
32
+ const { flags } = useFeatureFlagContext();
33
+ return flags;
34
+ }
35
+ function useUntypedFeatureFlagClient() {
36
+ const { client } = useFeatureFlagContext();
37
+ return client;
38
+ }
39
+ function createTypedHooks() {
40
+ function TypedFeatureFlagProvider(props) {
41
+ return (0, provider_js_1.FeatureFlagProvider)(props);
42
+ }
43
+ function typedUseFeatureFlag(name) {
44
+ const { client, flags } = useFeatureFlagContext();
45
+ const flag = resolveFlagFromContext({ client, flags, idOrName: name });
46
+ return {
47
+ enabled: flag?.enabled ?? false,
48
+ value: flag?.value,
49
+ };
50
+ }
51
+ function typedUseFeatureFlags() {
52
+ const { flags } = useFeatureFlagContext();
53
+ return flags;
54
+ }
55
+ function typedUseFeatureFlagClient() {
56
+ const { client } = useFeatureFlagContext();
57
+ return client;
58
+ }
59
+ function typedUseFeatureFlagStatus() {
60
+ return useFeatureFlagStatus();
61
+ }
62
+ const TypedFeatureFlag = (props) => {
63
+ const { children, fallback, loading, matchValue, name } = props;
64
+ const status = typedUseFeatureFlagStatus();
65
+ const { enabled, value } = typedUseFeatureFlag(name);
66
+ if (status.isLoading) {
67
+ return loading ?? fallback ?? null;
68
+ }
69
+ const shouldRenderChildren = typeof matchValue === "undefined" ? enabled : Object.is(value, matchValue);
70
+ if (!shouldRenderChildren) {
71
+ return fallback ?? null;
72
+ }
73
+ if (typeof children === "function") {
74
+ return children({ enabled, value, status });
75
+ }
76
+ return children;
77
+ };
78
+ return {
79
+ FeatureFlagProvider: TypedFeatureFlagProvider,
80
+ FeatureFlag: TypedFeatureFlag,
81
+ useFeatureFlag: typedUseFeatureFlag,
82
+ useFeatureFlags: typedUseFeatureFlags,
83
+ useFeatureFlagClient: typedUseFeatureFlagClient,
84
+ useFeatureFlagStatus: typedUseFeatureFlagStatus,
85
+ };
86
+ }
@@ -0,0 +1,106 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FeatureFlagContext = void 0;
4
+ exports.createFeatureFlagLoadState = createFeatureFlagLoadState;
5
+ exports.shouldRenderFeatureFlagLoadingFallback = shouldRenderFeatureFlagLoadingFallback;
6
+ exports.createSnapshotStore = createSnapshotStore;
7
+ exports.FeatureFlagProvider = FeatureFlagProvider;
8
+ const jsx_runtime_1 = require("react/jsx-runtime");
9
+ const react_1 = require("react");
10
+ exports.FeatureFlagContext = (0, react_1.createContext)(null);
11
+ const EMPTY_EVALUATED_FLAGS = [];
12
+ function createFeatureFlagLoadState(params) {
13
+ return {
14
+ status: params.status,
15
+ isLoading: params.status === "loading",
16
+ isReady: params.status === "ready",
17
+ error: params.error ?? params.runtimeStatus.lastError,
18
+ source: params.runtimeStatus.source,
19
+ isStale: params.runtimeStatus.isStale,
20
+ lastSuccessfulRefreshAt: params.runtimeStatus.lastSuccessfulRefreshAt,
21
+ tokenExpiresAt: params.runtimeStatus.tokenExpiresAt,
22
+ };
23
+ }
24
+ function shouldRenderFeatureFlagLoadingFallback(params) {
25
+ return params.blockUntilReady && params.loadState.isLoading;
26
+ }
27
+ function createSnapshotStore(params) {
28
+ let cachedFlags = params.initialFlags ?? params.client.getSnapshot();
29
+ let hasObservedClientUpdate = false;
30
+ function areSnapshotsEqual(left, right) {
31
+ if (left.length !== right.length) {
32
+ return false;
33
+ }
34
+ for (let index = 0; index < left.length; index++) {
35
+ const leftFlag = left[index];
36
+ const rightFlag = right[index];
37
+ if (leftFlag?.id !== rightFlag?.id ||
38
+ leftFlag?.name !== rightFlag?.name ||
39
+ leftFlag?.type !== rightFlag?.type ||
40
+ leftFlag?.teamId !== rightFlag?.teamId ||
41
+ leftFlag?.enabled !== rightFlag?.enabled ||
42
+ JSON.stringify(leftFlag?.value) !== JSON.stringify(rightFlag?.value)) {
43
+ return false;
44
+ }
45
+ }
46
+ return true;
47
+ }
48
+ return {
49
+ subscribe(onStoreChange) {
50
+ return params.client.subscribe(() => {
51
+ hasObservedClientUpdate = true;
52
+ onStoreChange();
53
+ });
54
+ },
55
+ getSnapshot() {
56
+ const current = params.client.getSnapshot();
57
+ const shouldAdoptClientSnapshot = current.length > 0 || !params.initialFlags || hasObservedClientUpdate;
58
+ if (shouldAdoptClientSnapshot && !areSnapshotsEqual(current, cachedFlags)) {
59
+ cachedFlags = current;
60
+ }
61
+ return cachedFlags;
62
+ },
63
+ getServerSnapshot() {
64
+ return params.initialFlags ?? EMPTY_EVALUATED_FLAGS;
65
+ },
66
+ };
67
+ }
68
+ function FeatureFlagProvider(props) {
69
+ const { client, blockUntilReady, loadingFallback, initialFlags, children } = props;
70
+ const [status, setStatus] = (0, react_1.useState)(initialFlags ? "ready" : "loading");
71
+ const [error, setError] = (0, react_1.useState)(null);
72
+ (0, react_1.useEffect)(() => {
73
+ let isDisposed = false;
74
+ setStatus(initialFlags ? "ready" : "loading");
75
+ setError(null);
76
+ client.initialize()
77
+ .then(() => {
78
+ if (isDisposed) {
79
+ return;
80
+ }
81
+ setStatus("ready");
82
+ })
83
+ .catch((initializationError) => {
84
+ if (isDisposed) {
85
+ return;
86
+ }
87
+ setStatus("error");
88
+ setError(initializationError instanceof Error
89
+ ? initializationError
90
+ : new Error("Unknown feature flag initialization error."));
91
+ });
92
+ return () => {
93
+ isDisposed = true;
94
+ client.destroy();
95
+ };
96
+ }, [client, initialFlags]);
97
+ const snapshotStore = (0, react_1.useMemo)(() => createSnapshotStore({ client, initialFlags }), [client, initialFlags]);
98
+ const flags = (0, react_1.useSyncExternalStore)(snapshotStore.subscribe, snapshotStore.getSnapshot, snapshotStore.getServerSnapshot);
99
+ const runtimeStatus = (0, react_1.useSyncExternalStore)(client.subscribe, client.getStatus.bind(client), client.getStatus.bind(client));
100
+ const loadState = (0, react_1.useMemo)(() => createFeatureFlagLoadState({ status, error, runtimeStatus }), [error, runtimeStatus, status]);
101
+ const value = (0, react_1.useMemo)(() => ({ client, flags, loadState }), [client, flags, loadState]);
102
+ if (shouldRenderFeatureFlagLoadingFallback({ blockUntilReady, loadState })) {
103
+ return (0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: loadingFallback ?? null });
104
+ }
105
+ return (0, jsx_runtime_1.jsx)(exports.FeatureFlagContext.Provider, { value: value, children: children });
106
+ }
@@ -0,0 +1,7 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createTypedHooks = exports.FeatureFlagProvider = void 0;
4
+ var provider_js_1 = require("./react/provider.js");
5
+ Object.defineProperty(exports, "FeatureFlagProvider", { enumerable: true, get: function () { return provider_js_1.FeatureFlagProvider; } });
6
+ var hooks_js_1 = require("./react/hooks.js");
7
+ Object.defineProperty(exports, "createTypedHooks", { enumerable: true, get: function () { return hooks_js_1.createTypedHooks; } });
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.RemoteFeatureFlagEvaluator = void 0;
4
+ const errors_js_1 = require("./errors.js");
5
+ const jwt_js_1 = require("./jwt.js");
6
+ const persistence_js_1 = require("./persistence.js");
7
+ const REMOTE_FETCH_TIMEOUT_MS = 5_000;
8
+ class RemoteFeatureFlagEvaluator {
9
+ publicKeyPem;
10
+ publicKeyCrypto = null;
11
+ evaluationCache = new Map();
12
+ inflightEvaluations = new Map();
13
+ apiUrl;
14
+ clientKey;
15
+ organizationId;
16
+ environmentId;
17
+ teamId;
18
+ constructor(config) {
19
+ this.apiUrl = config.apiUrl.replace(/\/$/, "");
20
+ this.clientKey = config.clientKey;
21
+ this.organizationId = config.organizationId.trim();
22
+ this.environmentId = config.environmentId.trim();
23
+ this.teamId = config.teamId?.trim() || null;
24
+ this.publicKeyPem = config.publicKey ?? null;
25
+ }
26
+ async evaluate(context) {
27
+ const contextKey = (0, persistence_js_1.serializeCanonicalValue)(context ?? null);
28
+ const cached = this.evaluationCache.get(contextKey);
29
+ if (cached && this.isCachedEvaluationValid(cached)) {
30
+ return cached;
31
+ }
32
+ if (cached) {
33
+ this.evaluationCache.delete(contextKey);
34
+ }
35
+ const inflight = this.inflightEvaluations.get(contextKey);
36
+ if (inflight) {
37
+ return inflight;
38
+ }
39
+ const evaluationPromise = this.evaluateUncached(context).finally(() => {
40
+ this.inflightEvaluations.delete(contextKey);
41
+ });
42
+ this.inflightEvaluations.set(contextKey, evaluationPromise);
43
+ const evaluation = await evaluationPromise;
44
+ if (this.isCachedEvaluationValid(evaluation)) {
45
+ this.evaluationCache.set(contextKey, evaluation);
46
+ }
47
+ else {
48
+ this.evaluationCache.delete(contextKey);
49
+ }
50
+ return evaluation;
51
+ }
52
+ async evaluateUncached(context) {
53
+ await this.ensureVerificationKey();
54
+ const response = await this.fetchSignedFlags(context ?? null);
55
+ const verified = await this.verifyWithRetry(response.token);
56
+ return {
57
+ expiresAt: verified.expiresAt,
58
+ fetchedAt: new Date().toISOString(),
59
+ flags: verified.flags,
60
+ };
61
+ }
62
+ isCachedEvaluationValid(evaluation) {
63
+ if (!evaluation.expiresAt) {
64
+ return false;
65
+ }
66
+ const expiresAt = new Date(evaluation.expiresAt).getTime();
67
+ return Number.isFinite(expiresAt) && expiresAt > Date.now();
68
+ }
69
+ async fetchSignedFlags(context) {
70
+ const url = new URL("/api/feature-flags/evaluate/signed", this.apiUrl);
71
+ const payload = {
72
+ organizationId: this.organizationId,
73
+ environmentId: this.environmentId,
74
+ ...(this.teamId ? { teamId: this.teamId } : {}),
75
+ ...(context ? { context } : {}),
76
+ };
77
+ let response;
78
+ try {
79
+ response = await this.fetchWithTimeout(url, {
80
+ method: "POST",
81
+ headers: {
82
+ Authorization: `Bearer ${this.clientKey}`,
83
+ "Content-Type": "application/json",
84
+ Accept: "application/json",
85
+ },
86
+ body: JSON.stringify(payload),
87
+ });
88
+ }
89
+ catch (error) {
90
+ if (error instanceof errors_js_1.FeatureFlagNetworkError) {
91
+ throw error;
92
+ }
93
+ throw new errors_js_1.FeatureFlagNetworkError("Failed to fetch feature flags.", { cause: error });
94
+ }
95
+ if (!response.ok) {
96
+ throw new errors_js_1.FeatureFlagNetworkError(`Feature flag API returned ${response.status}: ${response.statusText}`);
97
+ }
98
+ return (await response.json());
99
+ }
100
+ async fetchPublicKey() {
101
+ const url = `${this.apiUrl}/api/feature-flags/public-key`;
102
+ let response;
103
+ try {
104
+ response = await this.fetchWithTimeout(url);
105
+ }
106
+ catch (error) {
107
+ if (error instanceof errors_js_1.FeatureFlagNetworkError) {
108
+ throw error;
109
+ }
110
+ throw new errors_js_1.FeatureFlagNetworkError("Failed to fetch public key.", { cause: error });
111
+ }
112
+ if (!response.ok) {
113
+ throw new errors_js_1.FeatureFlagNetworkError(`Public key endpoint returned ${response.status}: ${response.statusText}`);
114
+ }
115
+ const body = (await response.json());
116
+ return body.publicKey;
117
+ }
118
+ async verifyWithRetry(token) {
119
+ try {
120
+ return await (0, jwt_js_1.verifyFeatureFlagTokenDetailed)({ token, publicKey: this.publicKeyCrypto });
121
+ }
122
+ catch (error) {
123
+ if (!(error instanceof errors_js_1.FeatureFlagVerificationError)) {
124
+ throw error;
125
+ }
126
+ (0, jwt_js_1.resetPublicKeyCache)();
127
+ this.publicKeyPem = await this.fetchPublicKey();
128
+ this.publicKeyCrypto = await (0, jwt_js_1.importPublicKey)(this.publicKeyPem);
129
+ return (0, jwt_js_1.verifyFeatureFlagTokenDetailed)({ token, publicKey: this.publicKeyCrypto });
130
+ }
131
+ }
132
+ async ensureVerificationKey() {
133
+ if (!this.publicKeyPem) {
134
+ this.publicKeyPem = await this.fetchPublicKey();
135
+ }
136
+ if (!this.publicKeyCrypto) {
137
+ this.publicKeyCrypto = await (0, jwt_js_1.importPublicKey)(this.publicKeyPem);
138
+ }
139
+ }
140
+ async fetchWithTimeout(input, init) {
141
+ const controller = new AbortController();
142
+ const timeout = setTimeout(() => {
143
+ controller.abort();
144
+ }, REMOTE_FETCH_TIMEOUT_MS);
145
+ try {
146
+ return await fetch(input, {
147
+ ...init,
148
+ signal: controller.signal,
149
+ });
150
+ }
151
+ catch (error) {
152
+ if (error instanceof Error && error.name === "AbortError") {
153
+ throw new errors_js_1.FeatureFlagNetworkError(`Feature flag request timed out after ${REMOTE_FETCH_TIMEOUT_MS}ms.`, { cause: error });
154
+ }
155
+ throw error;
156
+ }
157
+ finally {
158
+ clearTimeout(timeout);
159
+ }
160
+ }
161
+ }
162
+ exports.RemoteFeatureFlagEvaluator = RemoteFeatureFlagEvaluator;
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,3 @@
1
+ type AuditArgs = Record<string, string>;
2
+ export declare function audit(args: AuditArgs): Promise<void>;
3
+ export {};
@@ -0,0 +1,114 @@
1
+ import { readFile, readdir } from "node:fs/promises";
2
+ import { resolve, join, extname } from "node:path";
3
+ import { FeatureFlagCliConfigLoader } from "./config-loader.js";
4
+ const CODE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
5
+ async function collectFiles(dir) {
6
+ const files = [];
7
+ const entries = await readdir(dir, { withFileTypes: true });
8
+ for (const entry of entries) {
9
+ const fullPath = join(dir, entry.name);
10
+ if (entry.isDirectory()) {
11
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") {
12
+ continue;
13
+ }
14
+ files.push(...(await collectFiles(fullPath)));
15
+ }
16
+ else if (CODE_EXTENSIONS.has(extname(entry.name))) {
17
+ files.push(fullPath);
18
+ }
19
+ }
20
+ return files;
21
+ }
22
+ async function fetchDefinitions(params) {
23
+ const url = new URL("/api/feature-flags/definitions", params.apiUrl);
24
+ url.searchParams.set("organizationId", params.organizationId);
25
+ const response = await fetch(url.toString(), {
26
+ headers: { Authorization: `Bearer ${params.clientKey}` },
27
+ });
28
+ if (!response.ok) {
29
+ throw new Error(`Failed to fetch definitions: ${response.status} ${response.statusText}`);
30
+ }
31
+ const body = (await response.json());
32
+ return body.definitions;
33
+ }
34
+ export async function audit(args) {
35
+ const dir = resolve(args.dir ?? "./src");
36
+ const loadedConfig = await FeatureFlagCliConfigLoader.load({ configPath: args.config });
37
+ const config = loadedConfig.config;
38
+ const apiUrl = args["api-url"] ?? config.apiUrl ?? process.env.FEATURE_FLAG_API_URL;
39
+ const clientKey = args["client-key"] ?? config.clientKey ?? process.env.FEATURE_FLAG_CLIENT_KEY;
40
+ const organizationId = args["organization-id"] ?? config.organizationId ?? process.env.FEATURE_FLAG_ORGANIZATION_ID;
41
+ if (!apiUrl || !clientKey || !organizationId) {
42
+ throw new Error("Missing --api-url, --client-key, --organization-id, or a discoverable featureflags config.");
43
+ }
44
+ console.log(`Fetching flag definitions from ${apiUrl}...`);
45
+ const definitions = await fetchDefinitions({ apiUrl, clientKey, organizationId });
46
+ console.log(`Scanning ${dir} for flag references...`);
47
+ const files = await collectFiles(dir);
48
+ const flagIdSet = new Set(definitions.map((d) => d.id));
49
+ const flagCodeKeySet = new Set(definitions.map((d) => d.codeKey));
50
+ const archivedIds = new Set(definitions.filter((d) => d.isArchived).map((d) => d.id));
51
+ const referencedIds = new Set();
52
+ const referencedCodeKeys = new Set();
53
+ for (const file of files) {
54
+ const content = await readFile(file, "utf-8");
55
+ for (const id of flagIdSet) {
56
+ if (content.includes(id)) {
57
+ referencedIds.add(id);
58
+ }
59
+ }
60
+ for (const codeKey of flagCodeKeySet) {
61
+ if (content.includes(`"${codeKey}"`) || content.includes(`'${codeKey}'`)) {
62
+ referencedCodeKeys.add(codeKey);
63
+ }
64
+ }
65
+ }
66
+ const codeKeyToDefinition = new Map(definitions.map((d) => [d.codeKey, d]));
67
+ const allReferenced = new Set();
68
+ for (const id of referencedIds)
69
+ allReferenced.add(id);
70
+ for (const codeKey of referencedCodeKeys) {
71
+ const def = codeKeyToDefinition.get(codeKey);
72
+ if (def)
73
+ allReferenced.add(def.id);
74
+ }
75
+ const used = [];
76
+ const unused = [];
77
+ const archivedButReferenced = [];
78
+ for (const def of definitions) {
79
+ if (allReferenced.has(def.id)) {
80
+ if (archivedIds.has(def.id)) {
81
+ archivedButReferenced.push(def);
82
+ }
83
+ else {
84
+ used.push(def);
85
+ }
86
+ }
87
+ else if (!def.isArchived) {
88
+ unused.push(def);
89
+ }
90
+ }
91
+ console.log("\n--- Feature Flag Audit Report ---\n");
92
+ if (used.length > 0) {
93
+ console.log(`Active and referenced (${used.length}):`);
94
+ for (const d of used) {
95
+ console.log(` [OK] ${d.codeKey} (${d.id})`);
96
+ }
97
+ }
98
+ if (unused.length > 0) {
99
+ console.log(`\nDefined in API but not referenced in code (${unused.length}):`);
100
+ for (const d of unused) {
101
+ console.log(` [UNUSED] ${d.codeKey} (${d.id})`);
102
+ }
103
+ }
104
+ if (archivedButReferenced.length > 0) {
105
+ console.log(`\nArchived but still referenced in code (${archivedButReferenced.length}):`);
106
+ for (const d of archivedButReferenced) {
107
+ console.log(` [STALE] ${d.codeKey} (${d.id}) — archived, needs cleanup`);
108
+ }
109
+ }
110
+ if (unused.length === 0 && archivedButReferenced.length === 0) {
111
+ console.log("\nAll flags are clean.");
112
+ }
113
+ console.log(`\nScanned ${files.length} files in ${dir}.`);
114
+ }
@@ -0,0 +1,3 @@
1
+ type CleanupArgs = Record<string, string>;
2
+ export declare function cleanup(args: CleanupArgs): Promise<void>;
3
+ export {};
@@ -0,0 +1,102 @@
1
+ import { readFile, writeFile, readdir } from "node:fs/promises";
2
+ import { resolve, join, extname } from "node:path";
3
+ const CODE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
4
+ async function collectFiles(dir) {
5
+ const files = [];
6
+ const entries = await readdir(dir, { withFileTypes: true });
7
+ for (const entry of entries) {
8
+ const fullPath = join(dir, entry.name);
9
+ if (entry.isDirectory()) {
10
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") {
11
+ continue;
12
+ }
13
+ files.push(...(await collectFiles(fullPath)));
14
+ }
15
+ else if (CODE_EXTENSIONS.has(extname(entry.name))) {
16
+ files.push(fullPath);
17
+ }
18
+ }
19
+ return files;
20
+ }
21
+ async function fetchDefinitions(params) {
22
+ const url = new URL("/api/feature-flags/definitions", params.apiUrl);
23
+ url.searchParams.set("organizationId", params.organizationId);
24
+ const response = await fetch(url.toString(), {
25
+ headers: { Authorization: `Bearer ${params.clientKey}` },
26
+ });
27
+ if (!response.ok) {
28
+ throw new Error(`Failed to fetch definitions: ${response.status} ${response.statusText}`);
29
+ }
30
+ const body = (await response.json());
31
+ return body.definitions;
32
+ }
33
+ export async function cleanup(args) {
34
+ const dir = resolve(args.dir ?? "./src");
35
+ const dryRun = "dry-run" in args;
36
+ const apiUrl = args["api-url"] ?? process.env.FEATURE_FLAG_API_URL;
37
+ const clientKey = args["client-key"] ?? process.env.FEATURE_FLAG_CLIENT_KEY;
38
+ const organizationId = args["organization-id"];
39
+ if (!apiUrl || !clientKey || !organizationId) {
40
+ throw new Error("Missing --api-url, --client-key, or --organization-id.");
41
+ }
42
+ console.log(`Fetching flag definitions from ${apiUrl}...`);
43
+ const definitions = await fetchDefinitions({ apiUrl, clientKey, organizationId });
44
+ const archivedFlags = definitions.filter((d) => d.isArchived && d.type === "BOOLEAN");
45
+ if (archivedFlags.length === 0) {
46
+ console.log("No archived boolean flags to clean up.");
47
+ return;
48
+ }
49
+ console.log(`Found ${archivedFlags.length} archived boolean flags to scan for.`);
50
+ console.log(`Scanning ${dir}...`);
51
+ const files = await collectFiles(dir);
52
+ let totalReplacements = 0;
53
+ for (const filePath of files) {
54
+ let content = await readFile(filePath, "utf-8");
55
+ let modified = false;
56
+ for (const flag of archivedFlags) {
57
+ const result = replaceArchivedFlagChecks({ content, dryRun, filePath, flag });
58
+ content = result.content;
59
+ modified ||= result.replacements > 0;
60
+ totalReplacements += result.replacements;
61
+ }
62
+ if (modified && !dryRun) {
63
+ await writeFile(filePath, content, "utf-8");
64
+ console.log(` Updated: ${filePath}`);
65
+ }
66
+ }
67
+ if (totalReplacements === 0) {
68
+ console.log("No archived flag references found in code.");
69
+ }
70
+ else if (dryRun) {
71
+ console.log(`\n${totalReplacements} replacement(s) would be made. Re-run without --dry-run to apply.`);
72
+ }
73
+ else {
74
+ console.log(`\n${totalReplacements} replacement(s) applied. Run 'merged-ff generate' to update the generated file.`);
75
+ }
76
+ }
77
+ function escapeRegex(str) {
78
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
79
+ }
80
+ function createIsEnabledCallPattern(value) {
81
+ const target = escapeRegex(value);
82
+ return new RegExp(String.raw `(?:\b(?:this|[A-Za-z_$][\w$]*)(?:(?:\?\.|\.)[A-Za-z_$][\w$]*)*(?:\?\.|\.))?isEnabled\(["']${target}["']\)`, "g");
83
+ }
84
+ function replaceArchivedFlagChecks(params) {
85
+ const patterns = [
86
+ createIsEnabledCallPattern(params.flag.id),
87
+ createIsEnabledCallPattern(params.flag.codeKey),
88
+ ];
89
+ let nextContent = params.content;
90
+ let replacements = 0;
91
+ for (const pattern of patterns) {
92
+ nextContent = nextContent.replace(pattern, (match) => {
93
+ replacements++;
94
+ if (params.dryRun) {
95
+ console.log(` [DRY RUN] ${params.filePath}: ${match} → false`);
96
+ return match;
97
+ }
98
+ return "false";
99
+ });
100
+ }
101
+ return { content: nextContent, replacements };
102
+ }
@@ -0,0 +1,26 @@
1
+ export type FeatureFlagCliConfig = {
2
+ $schema?: string;
3
+ apiUrl?: string;
4
+ clientKey?: string;
5
+ environmentId?: string;
6
+ outputPath?: string;
7
+ teamId?: string;
8
+ organizationId?: string;
9
+ };
10
+ type LoadedFeatureFlagCliConfig = {
11
+ config: FeatureFlagCliConfig;
12
+ configPath: string | null;
13
+ };
14
+ declare function load(params: {
15
+ configPath?: string;
16
+ cwd?: string;
17
+ }): Promise<LoadedFeatureFlagCliConfig>;
18
+ declare function resolveConfigRelativePath(params: {
19
+ value?: string;
20
+ configPath?: string | null;
21
+ }): string | undefined;
22
+ export declare const FeatureFlagCliConfigLoader: {
23
+ load: typeof load;
24
+ resolveConfigRelativePath: typeof resolveConfigRelativePath;
25
+ };
26
+ export {};
@@ -0,0 +1,66 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { dirname, isAbsolute, resolve } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ const CONFIG_FILE_NAMES = [
6
+ "featureflags.config.json",
7
+ "featureflags.config.js",
8
+ "featureflags.config.mjs",
9
+ "featureflags.config.cjs",
10
+ ];
11
+ async function load(params) {
12
+ const cwd = params.cwd ?? process.cwd();
13
+ if (params.configPath) {
14
+ const explicitPath = isAbsolute(params.configPath) ? params.configPath : resolve(cwd, params.configPath);
15
+ return loadFile({ configPath: explicitPath });
16
+ }
17
+ const discoveredConfigPath = await findNearestConfigPath({ cwd });
18
+ if (!discoveredConfigPath) {
19
+ return { config: {}, configPath: null };
20
+ }
21
+ return loadFile({ configPath: discoveredConfigPath });
22
+ }
23
+ function resolveConfigRelativePath(params) {
24
+ if (!params.value) {
25
+ return undefined;
26
+ }
27
+ if (isAbsolute(params.value) || !params.configPath) {
28
+ return params.value;
29
+ }
30
+ return resolve(dirname(params.configPath), params.value);
31
+ }
32
+ async function findNearestConfigPath(params) {
33
+ let currentDirectory = resolve(params.cwd);
34
+ while (true) {
35
+ for (const fileName of CONFIG_FILE_NAMES) {
36
+ const candidatePath = resolve(currentDirectory, fileName);
37
+ if (existsSync(candidatePath)) {
38
+ return candidatePath;
39
+ }
40
+ }
41
+ const parentDirectory = dirname(currentDirectory);
42
+ if (parentDirectory === currentDirectory) {
43
+ return null;
44
+ }
45
+ currentDirectory = parentDirectory;
46
+ }
47
+ }
48
+ async function loadFile(params) {
49
+ if (params.configPath.endsWith(".json")) {
50
+ const content = await readFile(params.configPath, "utf-8");
51
+ return {
52
+ config: JSON.parse(content),
53
+ configPath: params.configPath,
54
+ };
55
+ }
56
+ const imported = await import(pathToFileURL(params.configPath).href);
57
+ const config = (imported.default ?? imported);
58
+ if (!config || typeof config !== "object") {
59
+ return { config: {}, configPath: params.configPath };
60
+ }
61
+ return { config, configPath: params.configPath };
62
+ }
63
+ export const FeatureFlagCliConfigLoader = {
64
+ load,
65
+ resolveConfigRelativePath,
66
+ };