@mergedapp/feature-flags 0.1.0
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.
- package/README.md +435 -0
- package/dist/cli.js +578 -0
- package/dist/index.cjs +897 -0
- package/dist/index.d.cts +175 -0
- package/dist/index.d.ts +175 -0
- package/dist/index.js +856 -0
- package/dist/react.cjs +239 -0
- package/dist/react.d.cts +223 -0
- package/dist/react.d.ts +223 -0
- package/dist/react.js +213 -0
- package/package.json +68 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
|
|
4
|
+
|
|
5
|
+
// src/cli/generate.ts
|
|
6
|
+
import { mkdir, writeFile } from "fs/promises";
|
|
7
|
+
import { dirname as dirname2, resolve as resolve2 } from "path";
|
|
8
|
+
|
|
9
|
+
// src/cli/config-loader.ts
|
|
10
|
+
import { existsSync } from "fs";
|
|
11
|
+
import { readFile } from "fs/promises";
|
|
12
|
+
import { dirname, isAbsolute, resolve } from "path";
|
|
13
|
+
import { pathToFileURL } from "url";
|
|
14
|
+
var CONFIG_FILE_NAMES = [
|
|
15
|
+
"featureflags.config.json",
|
|
16
|
+
"featureflags.config.js",
|
|
17
|
+
"featureflags.config.mjs",
|
|
18
|
+
"featureflags.config.cjs"
|
|
19
|
+
];
|
|
20
|
+
async function load(params) {
|
|
21
|
+
const cwd = params.cwd ?? process.cwd();
|
|
22
|
+
if (params.configPath) {
|
|
23
|
+
const explicitPath = isAbsolute(params.configPath) ? params.configPath : resolve(cwd, params.configPath);
|
|
24
|
+
return loadFile({
|
|
25
|
+
configPath: explicitPath
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const discoveredConfigPath = await findNearestConfigPath({
|
|
29
|
+
cwd
|
|
30
|
+
});
|
|
31
|
+
if (!discoveredConfigPath) {
|
|
32
|
+
return {
|
|
33
|
+
config: {},
|
|
34
|
+
configPath: null
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
return loadFile({
|
|
38
|
+
configPath: discoveredConfigPath
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
__name(load, "load");
|
|
42
|
+
function resolveConfigRelativePath(params) {
|
|
43
|
+
if (!params.value) {
|
|
44
|
+
return void 0;
|
|
45
|
+
}
|
|
46
|
+
if (isAbsolute(params.value) || !params.configPath) {
|
|
47
|
+
return params.value;
|
|
48
|
+
}
|
|
49
|
+
return resolve(dirname(params.configPath), params.value);
|
|
50
|
+
}
|
|
51
|
+
__name(resolveConfigRelativePath, "resolveConfigRelativePath");
|
|
52
|
+
async function findNearestConfigPath(params) {
|
|
53
|
+
let currentDirectory = resolve(params.cwd);
|
|
54
|
+
while (true) {
|
|
55
|
+
for (const fileName of CONFIG_FILE_NAMES) {
|
|
56
|
+
const candidatePath = resolve(currentDirectory, fileName);
|
|
57
|
+
if (existsSync(candidatePath)) {
|
|
58
|
+
return candidatePath;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const parentDirectory = dirname(currentDirectory);
|
|
62
|
+
if (parentDirectory === currentDirectory) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
currentDirectory = parentDirectory;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
__name(findNearestConfigPath, "findNearestConfigPath");
|
|
69
|
+
async function loadFile(params) {
|
|
70
|
+
if (params.configPath.endsWith(".json")) {
|
|
71
|
+
const content = await readFile(params.configPath, "utf-8");
|
|
72
|
+
return {
|
|
73
|
+
config: JSON.parse(content),
|
|
74
|
+
configPath: params.configPath
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
const imported = await import(pathToFileURL(params.configPath).href);
|
|
78
|
+
const config = imported.default ?? imported;
|
|
79
|
+
if (!config || typeof config !== "object") {
|
|
80
|
+
return {
|
|
81
|
+
config: {},
|
|
82
|
+
configPath: params.configPath
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
config,
|
|
87
|
+
configPath: params.configPath
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
__name(loadFile, "loadFile");
|
|
91
|
+
var FeatureFlagCliConfigLoader = {
|
|
92
|
+
load,
|
|
93
|
+
resolveConfigRelativePath
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
// src/cli/generate.ts
|
|
97
|
+
function assertValidCodeKey(codeKey) {
|
|
98
|
+
if (!/^[a-z][A-Za-z0-9]*$/.test(codeKey)) {
|
|
99
|
+
throw new Error(`Invalid feature flag codeKey "${codeKey}". Code keys must be lowerCamelCase alphanumeric identifiers.`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
__name(assertValidCodeKey, "assertValidCodeKey");
|
|
103
|
+
function flagTypeToTsType(flagType) {
|
|
104
|
+
switch (flagType) {
|
|
105
|
+
case "BOOLEAN":
|
|
106
|
+
return "boolean";
|
|
107
|
+
case "STRING":
|
|
108
|
+
return "string";
|
|
109
|
+
case "NUMBER":
|
|
110
|
+
return "number";
|
|
111
|
+
case "JSON":
|
|
112
|
+
return "Record<string, unknown>";
|
|
113
|
+
case "ARRAY":
|
|
114
|
+
return "unknown[]";
|
|
115
|
+
default:
|
|
116
|
+
return "unknown";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
__name(flagTypeToTsType, "flagTypeToTsType");
|
|
120
|
+
function escapeJsDoc(str) {
|
|
121
|
+
return str.replace(/\*\//g, "*\\/").replace(/\n/g, " ");
|
|
122
|
+
}
|
|
123
|
+
__name(escapeJsDoc, "escapeJsDoc");
|
|
124
|
+
async function fetchDefinitions(params) {
|
|
125
|
+
const url = new URL("/api/feature-flags/definitions", params.apiUrl);
|
|
126
|
+
url.searchParams.set("organizationId", params.organizationId);
|
|
127
|
+
if (params.teamId) {
|
|
128
|
+
url.searchParams.set("teamId", params.teamId);
|
|
129
|
+
}
|
|
130
|
+
const response = await fetch(url.toString(), {
|
|
131
|
+
headers: {
|
|
132
|
+
Authorization: `Bearer ${params.clientKey}`
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
if (!response.ok) {
|
|
136
|
+
throw new Error(`Failed to fetch definitions: ${response.status} ${response.statusText}`);
|
|
137
|
+
}
|
|
138
|
+
const body = await response.json();
|
|
139
|
+
return body.definitions;
|
|
140
|
+
}
|
|
141
|
+
__name(fetchDefinitions, "fetchDefinitions");
|
|
142
|
+
function generateCode(definitions) {
|
|
143
|
+
const activeFlags = definitions.filter((d) => !d.isArchived);
|
|
144
|
+
if (activeFlags.length === 0) {
|
|
145
|
+
return `// Auto-generated by @mergedapp/feature-flags. Do not edit.
|
|
146
|
+
// Generated at: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
147
|
+
// No active feature flags found.
|
|
148
|
+
|
|
149
|
+
import { MergedFeatureFlags } from "@mergedapp/feature-flags"
|
|
150
|
+
import { createTypedHooks } from "@mergedapp/feature-flags/react"
|
|
151
|
+
import type { FeatureFlagClientConfig } from "@mergedapp/feature-flags"
|
|
152
|
+
|
|
153
|
+
export const FLAGS = {} as const
|
|
154
|
+
|
|
155
|
+
export interface FlagValues {}
|
|
156
|
+
|
|
157
|
+
export type FlagName = keyof typeof FLAGS
|
|
158
|
+
|
|
159
|
+
export function createClient(config: FeatureFlagClientConfig) {
|
|
160
|
+
return new MergedFeatureFlags<FlagValues>({ ...config, flagIds: FLAGS })
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const { FeatureFlagProvider, FeatureFlag, useFeatureFlag, useFeatureFlags, useFeatureFlagClient, useFeatureFlagStatus } =
|
|
164
|
+
createTypedHooks<FlagValues>()
|
|
165
|
+
`;
|
|
166
|
+
}
|
|
167
|
+
const flagEntries = activeFlags.map((flag) => {
|
|
168
|
+
assertValidCodeKey(flag.codeKey);
|
|
169
|
+
const doc = flag.description ? ` /** ${escapeJsDoc(flag.description)} */
|
|
170
|
+
` : "";
|
|
171
|
+
return {
|
|
172
|
+
key: flag.codeKey,
|
|
173
|
+
id: flag.id,
|
|
174
|
+
type: flag.type,
|
|
175
|
+
doc
|
|
176
|
+
};
|
|
177
|
+
});
|
|
178
|
+
const flagsConst = flagEntries.map((e) => `${e.doc} ${e.key}: "${e.id}",`).join("\n");
|
|
179
|
+
const flagValuesEntries = flagEntries.map((e) => ` ${e.key}: ${flagTypeToTsType(e.type)}`).join("\n");
|
|
180
|
+
return `// Auto-generated by @mergedapp/feature-flags. Do not edit.
|
|
181
|
+
// Generated at: ${(/* @__PURE__ */ new Date()).toISOString()}
|
|
182
|
+
|
|
183
|
+
import { MergedFeatureFlags } from "@mergedapp/feature-flags"
|
|
184
|
+
import { createTypedHooks } from "@mergedapp/feature-flags/react"
|
|
185
|
+
import type { FeatureFlagClientConfig } from "@mergedapp/feature-flags"
|
|
186
|
+
|
|
187
|
+
/** Stable flag ID mapping. Names are for readability; IDs are used at runtime. */
|
|
188
|
+
export const FLAGS = {
|
|
189
|
+
${flagsConst}
|
|
190
|
+
} as const
|
|
191
|
+
|
|
192
|
+
/** Type-safe flag value types inferred from flag definitions. */
|
|
193
|
+
export interface FlagValues {
|
|
194
|
+
${flagValuesEntries}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export type FlagName = keyof typeof FLAGS
|
|
198
|
+
|
|
199
|
+
/** Create a type-safe client bound to this flag registry. */
|
|
200
|
+
export function createClient(config: FeatureFlagClientConfig) {
|
|
201
|
+
return new MergedFeatureFlags<FlagValues>({ ...config, flagIds: FLAGS })
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Pre-typed React hooks, provider, and JSX wrapper bound to this flag registry. */
|
|
205
|
+
export const { FeatureFlagProvider, FeatureFlag, useFeatureFlag, useFeatureFlags, useFeatureFlagClient, useFeatureFlagStatus } =
|
|
206
|
+
createTypedHooks<FlagValues>()
|
|
207
|
+
`;
|
|
208
|
+
}
|
|
209
|
+
__name(generateCode, "generateCode");
|
|
210
|
+
async function generate(args2) {
|
|
211
|
+
const loadedConfig = await FeatureFlagCliConfigLoader.load({
|
|
212
|
+
configPath: args2.config
|
|
213
|
+
});
|
|
214
|
+
const config = loadedConfig.config;
|
|
215
|
+
const apiUrl = args2["api-url"] ?? config.apiUrl ?? process.env.FEATURE_FLAG_API_URL;
|
|
216
|
+
const clientKey = args2["client-key"] ?? config.clientKey ?? process.env.FEATURE_FLAG_CLIENT_KEY;
|
|
217
|
+
const outputPath = args2.output ?? FeatureFlagCliConfigLoader.resolveConfigRelativePath({
|
|
218
|
+
value: config.outputPath,
|
|
219
|
+
configPath: loadedConfig.configPath
|
|
220
|
+
}) ?? "./src/generated/feature-flags.ts";
|
|
221
|
+
const teamId = args2["team-id"] ?? config.teamId;
|
|
222
|
+
const organizationId = args2["organization-id"] ?? config.organizationId ?? process.env.FEATURE_FLAG_ORGANIZATION_ID;
|
|
223
|
+
if (!apiUrl) {
|
|
224
|
+
throw new Error("Missing --api-url or FEATURE_FLAG_API_URL env var.");
|
|
225
|
+
}
|
|
226
|
+
if (!clientKey) {
|
|
227
|
+
throw new Error("Missing --client-key or FEATURE_FLAG_CLIENT_KEY env var.");
|
|
228
|
+
}
|
|
229
|
+
if (!organizationId) {
|
|
230
|
+
throw new Error("Missing --organization-id, FEATURE_FLAG_ORGANIZATION_ID, or config.organizationId.");
|
|
231
|
+
}
|
|
232
|
+
console.log(`Fetching flag definitions from ${apiUrl}...`);
|
|
233
|
+
const definitions = await fetchDefinitions({
|
|
234
|
+
apiUrl,
|
|
235
|
+
clientKey,
|
|
236
|
+
organizationId,
|
|
237
|
+
teamId
|
|
238
|
+
});
|
|
239
|
+
console.log(`Found ${definitions.length} flag definitions.`);
|
|
240
|
+
const code = generateCode(definitions);
|
|
241
|
+
const resolvedPath = resolve2(outputPath);
|
|
242
|
+
await mkdir(dirname2(resolvedPath), {
|
|
243
|
+
recursive: true
|
|
244
|
+
});
|
|
245
|
+
await writeFile(resolvedPath, code, "utf-8");
|
|
246
|
+
console.log(`Generated typed flags at ${resolvedPath}`);
|
|
247
|
+
}
|
|
248
|
+
__name(generate, "generate");
|
|
249
|
+
|
|
250
|
+
// src/cli/audit.ts
|
|
251
|
+
import { readFile as readFile2, readdir } from "fs/promises";
|
|
252
|
+
import { resolve as resolve3, join, extname } from "path";
|
|
253
|
+
var CODE_EXTENSIONS = /* @__PURE__ */ new Set([
|
|
254
|
+
".ts",
|
|
255
|
+
".tsx",
|
|
256
|
+
".js",
|
|
257
|
+
".jsx",
|
|
258
|
+
".mjs",
|
|
259
|
+
".cjs"
|
|
260
|
+
]);
|
|
261
|
+
async function collectFiles(dir) {
|
|
262
|
+
const files = [];
|
|
263
|
+
const entries = await readdir(dir, {
|
|
264
|
+
withFileTypes: true
|
|
265
|
+
});
|
|
266
|
+
for (const entry of entries) {
|
|
267
|
+
const fullPath = join(dir, entry.name);
|
|
268
|
+
if (entry.isDirectory()) {
|
|
269
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") {
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
files.push(...await collectFiles(fullPath));
|
|
273
|
+
} else if (CODE_EXTENSIONS.has(extname(entry.name))) {
|
|
274
|
+
files.push(fullPath);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return files;
|
|
278
|
+
}
|
|
279
|
+
__name(collectFiles, "collectFiles");
|
|
280
|
+
async function fetchDefinitions2(params) {
|
|
281
|
+
const url = new URL("/api/feature-flags/definitions", params.apiUrl);
|
|
282
|
+
url.searchParams.set("organizationId", params.organizationId);
|
|
283
|
+
const response = await fetch(url.toString(), {
|
|
284
|
+
headers: {
|
|
285
|
+
Authorization: `Bearer ${params.clientKey}`
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
if (!response.ok) {
|
|
289
|
+
throw new Error(`Failed to fetch definitions: ${response.status} ${response.statusText}`);
|
|
290
|
+
}
|
|
291
|
+
const body = await response.json();
|
|
292
|
+
return body.definitions;
|
|
293
|
+
}
|
|
294
|
+
__name(fetchDefinitions2, "fetchDefinitions");
|
|
295
|
+
async function audit(args2) {
|
|
296
|
+
const dir = resolve3(args2.dir ?? "./src");
|
|
297
|
+
const loadedConfig = await FeatureFlagCliConfigLoader.load({
|
|
298
|
+
configPath: args2.config
|
|
299
|
+
});
|
|
300
|
+
const config = loadedConfig.config;
|
|
301
|
+
const apiUrl = args2["api-url"] ?? config.apiUrl ?? process.env.FEATURE_FLAG_API_URL;
|
|
302
|
+
const clientKey = args2["client-key"] ?? config.clientKey ?? process.env.FEATURE_FLAG_CLIENT_KEY;
|
|
303
|
+
const organizationId = args2["organization-id"] ?? config.organizationId ?? process.env.FEATURE_FLAG_ORGANIZATION_ID;
|
|
304
|
+
if (!apiUrl || !clientKey || !organizationId) {
|
|
305
|
+
throw new Error("Missing --api-url, --client-key, --organization-id, or a discoverable featureflags config.");
|
|
306
|
+
}
|
|
307
|
+
console.log(`Fetching flag definitions from ${apiUrl}...`);
|
|
308
|
+
const definitions = await fetchDefinitions2({
|
|
309
|
+
apiUrl,
|
|
310
|
+
clientKey,
|
|
311
|
+
organizationId
|
|
312
|
+
});
|
|
313
|
+
console.log(`Scanning ${dir} for flag references...`);
|
|
314
|
+
const files = await collectFiles(dir);
|
|
315
|
+
const flagIdSet = new Set(definitions.map((d) => d.id));
|
|
316
|
+
const flagCodeKeySet = new Set(definitions.map((d) => d.codeKey));
|
|
317
|
+
const archivedIds = new Set(definitions.filter((d) => d.isArchived).map((d) => d.id));
|
|
318
|
+
const referencedIds = /* @__PURE__ */ new Set();
|
|
319
|
+
const referencedCodeKeys = /* @__PURE__ */ new Set();
|
|
320
|
+
for (const file of files) {
|
|
321
|
+
const content = await readFile2(file, "utf-8");
|
|
322
|
+
for (const id of flagIdSet) {
|
|
323
|
+
if (content.includes(id)) {
|
|
324
|
+
referencedIds.add(id);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
for (const codeKey of flagCodeKeySet) {
|
|
328
|
+
if (content.includes(`"${codeKey}"`) || content.includes(`'${codeKey}'`)) {
|
|
329
|
+
referencedCodeKeys.add(codeKey);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const codeKeyToDefinition = new Map(definitions.map((d) => [
|
|
334
|
+
d.codeKey,
|
|
335
|
+
d
|
|
336
|
+
]));
|
|
337
|
+
const allReferenced = /* @__PURE__ */ new Set();
|
|
338
|
+
for (const id of referencedIds) allReferenced.add(id);
|
|
339
|
+
for (const codeKey of referencedCodeKeys) {
|
|
340
|
+
const def = codeKeyToDefinition.get(codeKey);
|
|
341
|
+
if (def) allReferenced.add(def.id);
|
|
342
|
+
}
|
|
343
|
+
const used = [];
|
|
344
|
+
const unused = [];
|
|
345
|
+
const archivedButReferenced = [];
|
|
346
|
+
for (const def of definitions) {
|
|
347
|
+
if (allReferenced.has(def.id)) {
|
|
348
|
+
if (archivedIds.has(def.id)) {
|
|
349
|
+
archivedButReferenced.push(def);
|
|
350
|
+
} else {
|
|
351
|
+
used.push(def);
|
|
352
|
+
}
|
|
353
|
+
} else if (!def.isArchived) {
|
|
354
|
+
unused.push(def);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
console.log("\n--- Feature Flag Audit Report ---\n");
|
|
358
|
+
if (used.length > 0) {
|
|
359
|
+
console.log(`Active and referenced (${used.length}):`);
|
|
360
|
+
for (const d of used) {
|
|
361
|
+
console.log(` [OK] ${d.codeKey} (${d.id})`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (unused.length > 0) {
|
|
365
|
+
console.log(`
|
|
366
|
+
Defined in API but not referenced in code (${unused.length}):`);
|
|
367
|
+
for (const d of unused) {
|
|
368
|
+
console.log(` [UNUSED] ${d.codeKey} (${d.id})`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
if (archivedButReferenced.length > 0) {
|
|
372
|
+
console.log(`
|
|
373
|
+
Archived but still referenced in code (${archivedButReferenced.length}):`);
|
|
374
|
+
for (const d of archivedButReferenced) {
|
|
375
|
+
console.log(` [STALE] ${d.codeKey} (${d.id}) \u2014 archived, needs cleanup`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
if (unused.length === 0 && archivedButReferenced.length === 0) {
|
|
379
|
+
console.log("\nAll flags are clean.");
|
|
380
|
+
}
|
|
381
|
+
console.log(`
|
|
382
|
+
Scanned ${files.length} files in ${dir}.`);
|
|
383
|
+
}
|
|
384
|
+
__name(audit, "audit");
|
|
385
|
+
|
|
386
|
+
// src/cli/cleanup.ts
|
|
387
|
+
import { readFile as readFile3, writeFile as writeFile2, readdir as readdir2 } from "fs/promises";
|
|
388
|
+
import { resolve as resolve4, join as join2, extname as extname2 } from "path";
|
|
389
|
+
var CODE_EXTENSIONS2 = /* @__PURE__ */ new Set([
|
|
390
|
+
".ts",
|
|
391
|
+
".tsx",
|
|
392
|
+
".js",
|
|
393
|
+
".jsx",
|
|
394
|
+
".mjs",
|
|
395
|
+
".cjs"
|
|
396
|
+
]);
|
|
397
|
+
async function collectFiles2(dir) {
|
|
398
|
+
const files = [];
|
|
399
|
+
const entries = await readdir2(dir, {
|
|
400
|
+
withFileTypes: true
|
|
401
|
+
});
|
|
402
|
+
for (const entry of entries) {
|
|
403
|
+
const fullPath = join2(dir, entry.name);
|
|
404
|
+
if (entry.isDirectory()) {
|
|
405
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") {
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
files.push(...await collectFiles2(fullPath));
|
|
409
|
+
} else if (CODE_EXTENSIONS2.has(extname2(entry.name))) {
|
|
410
|
+
files.push(fullPath);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return files;
|
|
414
|
+
}
|
|
415
|
+
__name(collectFiles2, "collectFiles");
|
|
416
|
+
async function fetchDefinitions3(params) {
|
|
417
|
+
const url = new URL("/api/feature-flags/definitions", params.apiUrl);
|
|
418
|
+
url.searchParams.set("organizationId", params.organizationId);
|
|
419
|
+
const response = await fetch(url.toString(), {
|
|
420
|
+
headers: {
|
|
421
|
+
Authorization: `Bearer ${params.clientKey}`
|
|
422
|
+
}
|
|
423
|
+
});
|
|
424
|
+
if (!response.ok) {
|
|
425
|
+
throw new Error(`Failed to fetch definitions: ${response.status} ${response.statusText}`);
|
|
426
|
+
}
|
|
427
|
+
const body = await response.json();
|
|
428
|
+
return body.definitions;
|
|
429
|
+
}
|
|
430
|
+
__name(fetchDefinitions3, "fetchDefinitions");
|
|
431
|
+
async function cleanup(args2) {
|
|
432
|
+
const dir = resolve4(args2.dir ?? "./src");
|
|
433
|
+
const dryRun = "dry-run" in args2;
|
|
434
|
+
const apiUrl = args2["api-url"] ?? process.env.FEATURE_FLAG_API_URL;
|
|
435
|
+
const clientKey = args2["client-key"] ?? process.env.FEATURE_FLAG_CLIENT_KEY;
|
|
436
|
+
const organizationId = args2["organization-id"];
|
|
437
|
+
if (!apiUrl || !clientKey || !organizationId) {
|
|
438
|
+
throw new Error("Missing --api-url, --client-key, or --organization-id.");
|
|
439
|
+
}
|
|
440
|
+
console.log(`Fetching flag definitions from ${apiUrl}...`);
|
|
441
|
+
const definitions = await fetchDefinitions3({
|
|
442
|
+
apiUrl,
|
|
443
|
+
clientKey,
|
|
444
|
+
organizationId
|
|
445
|
+
});
|
|
446
|
+
const archivedFlags = definitions.filter((d) => d.isArchived && d.type === "BOOLEAN");
|
|
447
|
+
if (archivedFlags.length === 0) {
|
|
448
|
+
console.log("No archived boolean flags to clean up.");
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
console.log(`Found ${archivedFlags.length} archived boolean flags to scan for.`);
|
|
452
|
+
console.log(`Scanning ${dir}...`);
|
|
453
|
+
const files = await collectFiles2(dir);
|
|
454
|
+
let totalReplacements = 0;
|
|
455
|
+
for (const filePath of files) {
|
|
456
|
+
let content = await readFile3(filePath, "utf-8");
|
|
457
|
+
let modified = false;
|
|
458
|
+
for (const flag of archivedFlags) {
|
|
459
|
+
const result = replaceArchivedFlagChecks({
|
|
460
|
+
content,
|
|
461
|
+
dryRun,
|
|
462
|
+
filePath,
|
|
463
|
+
flag
|
|
464
|
+
});
|
|
465
|
+
content = result.content;
|
|
466
|
+
modified ||= result.replacements > 0;
|
|
467
|
+
totalReplacements += result.replacements;
|
|
468
|
+
}
|
|
469
|
+
if (modified && !dryRun) {
|
|
470
|
+
await writeFile2(filePath, content, "utf-8");
|
|
471
|
+
console.log(` Updated: ${filePath}`);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
if (totalReplacements === 0) {
|
|
475
|
+
console.log("No archived flag references found in code.");
|
|
476
|
+
} else if (dryRun) {
|
|
477
|
+
console.log(`
|
|
478
|
+
${totalReplacements} replacement(s) would be made. Re-run without --dry-run to apply.`);
|
|
479
|
+
} else {
|
|
480
|
+
console.log(`
|
|
481
|
+
${totalReplacements} replacement(s) applied. Run 'merged-ff generate' to update the generated file.`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
__name(cleanup, "cleanup");
|
|
485
|
+
function escapeRegex(str) {
|
|
486
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
487
|
+
}
|
|
488
|
+
__name(escapeRegex, "escapeRegex");
|
|
489
|
+
function createIsEnabledCallPattern(value) {
|
|
490
|
+
const target = escapeRegex(value);
|
|
491
|
+
return new RegExp(String.raw`(?:\b(?:this|[A-Za-z_$][\w$]*)(?:(?:\?\.|\.)[A-Za-z_$][\w$]*)*(?:\?\.|\.))?isEnabled\(["']${target}["']\)`, "g");
|
|
492
|
+
}
|
|
493
|
+
__name(createIsEnabledCallPattern, "createIsEnabledCallPattern");
|
|
494
|
+
function replaceArchivedFlagChecks(params) {
|
|
495
|
+
const patterns = [
|
|
496
|
+
createIsEnabledCallPattern(params.flag.id),
|
|
497
|
+
createIsEnabledCallPattern(params.flag.codeKey)
|
|
498
|
+
];
|
|
499
|
+
let nextContent = params.content;
|
|
500
|
+
let replacements = 0;
|
|
501
|
+
for (const pattern of patterns) {
|
|
502
|
+
nextContent = nextContent.replace(pattern, (match) => {
|
|
503
|
+
replacements++;
|
|
504
|
+
if (params.dryRun) {
|
|
505
|
+
console.log(` [DRY RUN] ${params.filePath}: ${match} \u2192 false`);
|
|
506
|
+
return match;
|
|
507
|
+
}
|
|
508
|
+
return "false";
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
return {
|
|
512
|
+
content: nextContent,
|
|
513
|
+
replacements
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
__name(replaceArchivedFlagChecks, "replaceArchivedFlagChecks");
|
|
517
|
+
|
|
518
|
+
// src/cli/parse-args.ts
|
|
519
|
+
function parseArgs(args2) {
|
|
520
|
+
const result = {};
|
|
521
|
+
for (const arg of args2) {
|
|
522
|
+
const keyValueMatch = arg.match(/^--([^=]+)=(.+)$/);
|
|
523
|
+
if (keyValueMatch) {
|
|
524
|
+
result[keyValueMatch[1]] = keyValueMatch[2];
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const flagMatch = arg.match(/^--(.+)$/);
|
|
528
|
+
if (flagMatch) {
|
|
529
|
+
result[flagMatch[1]] = "true";
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return result;
|
|
533
|
+
}
|
|
534
|
+
__name(parseArgs, "parseArgs");
|
|
535
|
+
|
|
536
|
+
// src/cli.ts
|
|
537
|
+
var [command, ...args] = process.argv.slice(2);
|
|
538
|
+
async function main() {
|
|
539
|
+
const parsed = parseArgs(args);
|
|
540
|
+
switch (command) {
|
|
541
|
+
case "generate":
|
|
542
|
+
await generate(parsed);
|
|
543
|
+
break;
|
|
544
|
+
case "audit":
|
|
545
|
+
await audit(parsed);
|
|
546
|
+
break;
|
|
547
|
+
case "cleanup":
|
|
548
|
+
await cleanup(parsed);
|
|
549
|
+
break;
|
|
550
|
+
default:
|
|
551
|
+
console.log(`@mergedapp/feature-flags CLI
|
|
552
|
+
|
|
553
|
+
Usage:
|
|
554
|
+
merged-ff generate Generate typed SDK from flag definitions
|
|
555
|
+
merged-ff audit Scan codebase for unused/stale flags
|
|
556
|
+
merged-ff cleanup Remove stale flag references (interactive)
|
|
557
|
+
|
|
558
|
+
Options:
|
|
559
|
+
--api-url=<url> API base URL
|
|
560
|
+
--client-key=<key> API key (or set FEATURE_FLAG_CLIENT_KEY env var)
|
|
561
|
+
--output=<path> Output path for generated file (default: ./src/generated/feature-flags.ts)
|
|
562
|
+
--dir=<path> Directory to scan (audit/cleanup, default: ./src)
|
|
563
|
+
--config=<path> Config file path for generate/audit (default: auto-discover featureflags.config.{json,js,mjs,cjs} from cwd upward)
|
|
564
|
+
--dry-run Preview changes without applying (cleanup)
|
|
565
|
+
--organization-id=<id> Organization ID (required)
|
|
566
|
+
--team-id=<id> Team ID scope`);
|
|
567
|
+
if (command) {
|
|
568
|
+
console.error(`
|
|
569
|
+
Unknown command: ${command}`);
|
|
570
|
+
process.exit(1);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
__name(main, "main");
|
|
575
|
+
main().catch((error) => {
|
|
576
|
+
console.error(error instanceof Error ? error.message : error);
|
|
577
|
+
process.exit(1);
|
|
578
|
+
});
|