@featurevisor/catalog 0.0.1 → 3.0.1

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.
@@ -0,0 +1,1888 @@
1
+ import * as childProcess from "child_process";
2
+ import * as fs from "fs";
3
+ import * as http from "http";
4
+ import * as path from "path";
5
+ import type {
6
+ Attribute,
7
+ Condition,
8
+ Group,
9
+ GroupSegment,
10
+ HistoryEntry,
11
+ ParsedFeature,
12
+ Schema,
13
+ Segment,
14
+ Target,
15
+ Test,
16
+ } from "@featurevisor/types";
17
+
18
+ import type {
19
+ CatalogEntityType,
20
+ CatalogIndex,
21
+ CatalogManifest,
22
+ DevEditor,
23
+ EntityDetail,
24
+ EntitySummary,
25
+ HistoryEntry as CatalogHistoryEntry,
26
+ LastModified,
27
+ } from "../types";
28
+ import { sortSetKeys } from "../entityTypes";
29
+
30
+ const CATALOG_SCHEMA_VERSION = "1";
31
+ const CATALOG_HISTORY_PAGE_SIZE = 50;
32
+ const CLI_FORMAT_GREEN = "\x1b[32m%s\x1b[0m";
33
+ const CLI_FORMAT_DIM = "\x1b[2m%s\x1b[0m";
34
+ const CLI_FORMAT_BOLD = "\x1b[1m%s\x1b[0m";
35
+
36
+ export interface CatalogPluginParsedOptions {
37
+ _: string[];
38
+ [key: string]: any;
39
+ }
40
+
41
+ export interface CatalogPluginHandlerOptions {
42
+ rootDirectoryPath: string;
43
+ projectConfig: any;
44
+ datasource: any;
45
+ parsed: CatalogPluginParsedOptions;
46
+ }
47
+
48
+ export interface CatalogPlugin {
49
+ command: string;
50
+ handler: (options: CatalogPluginHandlerOptions) => Promise<void | boolean>;
51
+ examples: {
52
+ command: string;
53
+ description: string;
54
+ }[];
55
+ }
56
+
57
+ export interface CatalogRuntime {
58
+ getProjectSetExecutions: (
59
+ projectConfig: any,
60
+ datasource: any,
61
+ selectedSet?: string,
62
+ ) => Promise<Array<{ set: string; projectConfig: any; datasource: any }>>;
63
+ }
64
+
65
+ export interface CatalogExportOptions {
66
+ outDir?: string;
67
+ copyAssets?: boolean;
68
+ browserRouter?: boolean;
69
+ dev?: boolean;
70
+ devEditors?: DevEditor[];
71
+ devSession?: CatalogDevSession;
72
+ preserveAssets?: boolean;
73
+ }
74
+
75
+ export interface CatalogServeOptions {
76
+ outDir?: string;
77
+ port?: number | string;
78
+ browserRouter?: boolean;
79
+ liveReload?: boolean;
80
+ }
81
+
82
+ export interface CatalogServerHandle {
83
+ close: () => Promise<void>;
84
+ triggerReload: () => void;
85
+ }
86
+
87
+ interface CatalogHistoryIndex {
88
+ entries: CatalogHistoryEntry[];
89
+ bySet: Record<string, CatalogHistoryEntry[]>;
90
+ byEntity: Record<string, CatalogHistoryEntry[]>;
91
+ lastModifiedByEntity: Record<string, LastModified>;
92
+ }
93
+
94
+ interface CatalogDevSession {
95
+ outputDirectoryPath: string;
96
+ devEditors: DevEditor[];
97
+ historyIndex: CatalogHistoryIndex;
98
+ links: CatalogManifest["links"];
99
+ repositoryRootDirectoryPath: string;
100
+ repositorySourceRootDirectoryPath: string;
101
+ }
102
+
103
+ interface CatalogBuildContext {
104
+ rootDirectoryPath: string;
105
+ repositorySourceRootDirectoryPath: string;
106
+ outputDirectoryPath: string;
107
+ dataDirectoryPath: string;
108
+ historyIndex: CatalogHistoryIndex;
109
+ devEditors: DevEditor[];
110
+ progress: CatalogProgressReporter;
111
+ writer: CatalogJsonWriter;
112
+ }
113
+
114
+ type EntityMaps = {
115
+ feature: Record<string, ParsedFeature>;
116
+ segment: Record<string, Segment>;
117
+ attribute: Record<string, Attribute>;
118
+ target: Record<string, Target>;
119
+ group: Record<string, Group>;
120
+ schema: Record<string, Schema>;
121
+ test: Record<string, Test>;
122
+ };
123
+
124
+ type RelationshipMaps = {
125
+ featureTargets: Record<string, Set<string>>;
126
+ featureTests: Record<string, Set<string>>;
127
+ featureRequiredBy: Record<string, Set<string>>;
128
+ featureSegments: Record<string, Set<string>>;
129
+ featureAttributes: Record<string, Set<string>>;
130
+ featureSchemas: Record<string, Set<string>>;
131
+ featureGroups: Record<string, Set<string>>;
132
+ segmentsUsedInFeatures: Record<string, Set<string>>;
133
+ attributesUsedInFeatures: Record<string, Set<string>>;
134
+ attributesUsedInSegments: Record<string, Set<string>>;
135
+ segmentTargets: Record<string, Set<string>>;
136
+ attributeTargets: Record<string, Set<string>>;
137
+ groupsUsedInFeatures: Record<string, Set<string>>;
138
+ schemasUsedInFeatures: Record<string, Set<string>>;
139
+ segmentTests: Record<string, Set<string>>;
140
+ targetFeatures: Record<string, Set<string>>;
141
+ };
142
+
143
+ interface SourceFileInfo {
144
+ sourcePath: string;
145
+ absolutePath: string;
146
+ }
147
+
148
+ class CatalogJsonWriter {
149
+ async write(filePath: string, value: unknown) {
150
+ await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
151
+ await fs.promises.writeFile(filePath, JSON.stringify(value, null, 2));
152
+ }
153
+ }
154
+
155
+ function colorize(value: string, colorCode: number) {
156
+ return `\x1b[${colorCode}m${value}\x1b[0m`;
157
+ }
158
+
159
+ function prettyDuration(diffInMs: number) {
160
+ let diff = Math.abs(diffInMs);
161
+
162
+ if (diff === 0) {
163
+ return "0ms";
164
+ }
165
+
166
+ const ms = diff % 1000;
167
+ diff = (diff - ms) / 1000;
168
+ const secs = diff % 60;
169
+ diff = (diff - secs) / 60;
170
+ const mins = diff % 60;
171
+ const hrs = (diff - mins) / 60;
172
+ const parts: string[] = [];
173
+
174
+ if (hrs) parts.push(`${hrs}h`);
175
+ if (mins) parts.push(`${mins}m`);
176
+ if (secs) parts.push(`${secs}s`);
177
+ if (ms) parts.push(`${ms}ms`);
178
+
179
+ return parts.join(" ");
180
+ }
181
+
182
+ function pluralize(count: number, singular: string, plural = `${singular}s`) {
183
+ return `${count} ${count === 1 ? singular : plural}`;
184
+ }
185
+
186
+ function formatCatalogPath(rootDirectoryPath: string, filePath: string) {
187
+ const relativePath = path.relative(rootDirectoryPath, filePath);
188
+
189
+ if (relativePath && !relativePath.startsWith("..") && !path.isAbsolute(relativePath)) {
190
+ return relativePath;
191
+ }
192
+
193
+ return filePath;
194
+ }
195
+
196
+ class CatalogProgressReporter {
197
+ private readonly startedAt = Date.now();
198
+
199
+ constructor(
200
+ private readonly rootDirectoryPath: string,
201
+ private readonly outputDirectoryPath: string,
202
+ ) {}
203
+
204
+ start(options: { browserRouter: boolean; sets: boolean }) {
205
+ console.log("");
206
+ console.log(CLI_FORMAT_BOLD, "Generating Featurevisor catalog");
207
+ console.log(
208
+ ` ${colorize("Output", 36)}: ${formatCatalogPath(
209
+ this.rootDirectoryPath,
210
+ this.outputDirectoryPath,
211
+ )}`,
212
+ );
213
+ console.log(` ${colorize("Router", 36)}: ${options.browserRouter ? "browser" : "hash"}`);
214
+ console.log(` ${colorize("Sets", 36)}: ${options.sets ? "enabled" : "none"}`);
215
+ console.log("");
216
+ }
217
+
218
+ step(label: string, detail?: string) {
219
+ const suffix = detail ? `: ${colorize(detail, 2)}` : "";
220
+ console.log(` ${colorize("•", 36)} ${label}${suffix}`);
221
+ return Date.now();
222
+ }
223
+
224
+ done(startedAt: number, detail?: string) {
225
+ const suffix = detail ? ` ${detail}` : "";
226
+ console.log(CLI_FORMAT_DIM, ` done in ${prettyDuration(Date.now() - startedAt)}${suffix}`);
227
+ }
228
+
229
+ setStart(set: string | undefined) {
230
+ console.log("");
231
+ console.log(CLI_FORMAT_BOLD, set ? `Set "${set}"` : "Root catalog");
232
+ return Date.now();
233
+ }
234
+
235
+ complete() {
236
+ console.log("");
237
+ console.log(
238
+ CLI_FORMAT_GREEN,
239
+ `Catalog exported to ${formatCatalogPath(this.rootDirectoryPath, this.outputDirectoryPath)}`,
240
+ );
241
+ console.log(CLI_FORMAT_BOLD, `Time: ${prettyDuration(Date.now() - this.startedAt)}`);
242
+ }
243
+ }
244
+
245
+ function encodeKey(key: string) {
246
+ return encodeURIComponent(key);
247
+ }
248
+
249
+ function toPosixPath(value: string) {
250
+ return value.split(path.sep).join("/");
251
+ }
252
+
253
+ function getRealPath(value: string) {
254
+ try {
255
+ return fs.realpathSync.native(value);
256
+ } catch {
257
+ return value;
258
+ }
259
+ }
260
+
261
+ function runGit(rootDirectoryPath: string, args: string[]) {
262
+ return childProcess.execFileSync("git", ["-C", rootDirectoryPath, ...args], {
263
+ encoding: "utf8",
264
+ stdio: ["ignore", "pipe", "ignore"],
265
+ });
266
+ }
267
+
268
+ async function readAll<T>(keys: string[], read: (key: string) => Promise<T>) {
269
+ const result: Record<string, T> = {};
270
+
271
+ for (const key of keys) {
272
+ result[key] = await read(key);
273
+ }
274
+
275
+ return result;
276
+ }
277
+
278
+ function sortSet(value?: Set<string>) {
279
+ return Array.from(value || []).sort();
280
+ }
281
+
282
+ function addToSet(map: Record<string, Set<string>>, key: string, value: string) {
283
+ if (!map[key]) {
284
+ map[key] = new Set();
285
+ }
286
+
287
+ map[key].add(value);
288
+ }
289
+
290
+ function collectAttributeKeysFromConditions(
291
+ condition: Condition | Condition[] | "*" | undefined,
292
+ result: Set<string>,
293
+ ) {
294
+ if (!condition || condition === "*") {
295
+ return;
296
+ }
297
+
298
+ if (Array.isArray(condition)) {
299
+ condition.forEach((item) => collectAttributeKeysFromConditions(item, result));
300
+ return;
301
+ }
302
+
303
+ if (typeof condition === "string") {
304
+ return;
305
+ }
306
+
307
+ if ("attribute" in condition) {
308
+ result.add(condition.attribute);
309
+ return;
310
+ }
311
+
312
+ if ("and" in condition) collectAttributeKeysFromConditions(condition.and, result);
313
+ if ("or" in condition) collectAttributeKeysFromConditions(condition.or, result);
314
+ if ("not" in condition) collectAttributeKeysFromConditions(condition.not, result);
315
+ }
316
+
317
+ function collectSegmentKeys(
318
+ segments: GroupSegment | GroupSegment[] | "*" | undefined,
319
+ result: Set<string>,
320
+ ) {
321
+ if (!segments || segments === "*") {
322
+ return;
323
+ }
324
+
325
+ if (typeof segments === "string") {
326
+ result.add(segments);
327
+ return;
328
+ }
329
+
330
+ if (Array.isArray(segments)) {
331
+ segments.forEach((segment) => collectSegmentKeys(segment, result));
332
+ return;
333
+ }
334
+
335
+ if ("and" in segments) collectSegmentKeys(segments.and, result);
336
+ if ("or" in segments) collectSegmentKeys(segments.or, result);
337
+ if ("not" in segments) collectSegmentKeys(segments.not, result);
338
+ }
339
+
340
+ function collectFeatureKeysFromRequired(required: ParsedFeature["required"], result: Set<string>) {
341
+ for (const item of required || []) {
342
+ result.add(typeof item === "string" ? item : item.key);
343
+ }
344
+ }
345
+
346
+ function collectSchemaKeysFromVariables(
347
+ variablesSchema: ParsedFeature["variablesSchema"],
348
+ result: Set<string>,
349
+ ) {
350
+ for (const schema of Object.values(variablesSchema || {})) {
351
+ if ("schema" in schema && typeof schema.schema === "string") {
352
+ result.add(schema.schema);
353
+ }
354
+ }
355
+ }
356
+
357
+ function collectGroupKeysFromRules(rules: ParsedFeature["rules"], result: Set<string>) {
358
+ const ruleRows = Array.isArray(rules) ? rules : Object.values(rules || {}).flat();
359
+
360
+ for (const rule of ruleRows) {
361
+ if (rule.segments && !Array.isArray(rule.segments) && typeof rule.segments === "object") {
362
+ for (const groupKey of ["and", "or", "not"] as const) {
363
+ if (groupKey in rule.segments) {
364
+ result.add(JSON.stringify(rule.segments));
365
+ }
366
+ }
367
+ }
368
+ }
369
+ }
370
+
371
+ function getFeatureRules(feature: ParsedFeature) {
372
+ return Array.isArray(feature.rules) ? feature.rules : Object.values(feature.rules || {}).flat();
373
+ }
374
+
375
+ function getFeatureForce(feature: ParsedFeature) {
376
+ return Array.isArray(feature.force) ? feature.force : Object.values(feature.force || {}).flat();
377
+ }
378
+
379
+ function matchesFeaturePatterns(featureKey: string, patterns?: "*" | string[]) {
380
+ if (!patterns) {
381
+ return false;
382
+ }
383
+
384
+ const normalizedPatterns = patterns === "*" ? [patterns] : patterns;
385
+
386
+ return normalizedPatterns.some((pattern) => {
387
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
388
+ return new RegExp(`^${escaped}$`).test(featureKey);
389
+ });
390
+ }
391
+
392
+ function targetIncludesFeature(target: Target, featureKey: string, feature: ParsedFeature) {
393
+ const featureTags = feature.tags || [];
394
+ let matchesTags = true;
395
+
396
+ if (target.tag) {
397
+ matchesTags = featureTags.includes(target.tag);
398
+ } else if (Array.isArray(target.tags)) {
399
+ matchesTags = target.tags.some((tag) => featureTags.includes(tag));
400
+ } else if (target.tags && "or" in target.tags) {
401
+ matchesTags = target.tags.or.some((tag) => featureTags.includes(tag));
402
+ } else if (target.tags && "and" in target.tags) {
403
+ matchesTags = target.tags.and.every((tag) => featureTags.includes(tag));
404
+ }
405
+
406
+ const matchesIncludedFeatures = target.includeFeatures
407
+ ? matchesFeaturePatterns(featureKey, target.includeFeatures)
408
+ : true;
409
+ const matchesExcludedFeatures = matchesFeaturePatterns(featureKey, target.excludeFeatures);
410
+
411
+ return matchesTags && matchesIncludedFeatures && !matchesExcludedFeatures;
412
+ }
413
+
414
+ function getHistoryEntityKey(type: CatalogEntityType, key: string, set?: string) {
415
+ return `${set || ""}\x1f${type}\x1f${key}`;
416
+ }
417
+
418
+ function toLastModified(entry: CatalogHistoryEntry): LastModified {
419
+ return {
420
+ commit: entry.commit,
421
+ author: entry.author,
422
+ timestamp: entry.timestamp,
423
+ };
424
+ }
425
+
426
+ function getLastModified(
427
+ historyIndex: CatalogHistoryIndex,
428
+ type: CatalogEntityType,
429
+ key: string,
430
+ set?: string,
431
+ ): LastModified | undefined {
432
+ return historyIndex.lastModifiedByEntity[getHistoryEntityKey(type, key, set)];
433
+ }
434
+
435
+ function getEntitySummary(
436
+ entity: Record<string, any>,
437
+ type: CatalogEntityType,
438
+ key: string,
439
+ historyIndex: CatalogHistoryIndex,
440
+ set?: string,
441
+ extra: Partial<EntitySummary> = {},
442
+ ): EntitySummary {
443
+ return {
444
+ key,
445
+ description: entity.description,
446
+ archived: entity.archived,
447
+ deprecated: entity.deprecated,
448
+ promotable: entity.promotable,
449
+ ...extra,
450
+ lastModified: getLastModified(historyIndex, type, key, set),
451
+ href: `entities/${type}/${encodeKey(key)}.json`,
452
+ };
453
+ }
454
+
455
+ function toSearchableScalar(value: unknown): string | undefined {
456
+ if (value === undefined || value === null) {
457
+ return undefined;
458
+ }
459
+
460
+ if (typeof value === "string") {
461
+ return value;
462
+ }
463
+
464
+ if (typeof value === "number" || typeof value === "boolean") {
465
+ return String(value);
466
+ }
467
+
468
+ return undefined;
469
+ }
470
+
471
+ function getFeatureVariationValues(feature: ParsedFeature) {
472
+ return (feature.variations || [])
473
+ .map((variation) => toSearchableScalar(variation.value))
474
+ .filter((value): value is string => Boolean(value));
475
+ }
476
+
477
+ function getFeatureVariableKeys(feature: ParsedFeature) {
478
+ return Object.keys(feature.variablesSchema || {}).sort();
479
+ }
480
+
481
+ function getFeatureEnvironmentKeys(feature: ParsedFeature, environments?: string[]) {
482
+ if (!Array.isArray(environments) || environments.length === 0) {
483
+ return undefined;
484
+ }
485
+
486
+ const found = new Set<string>();
487
+
488
+ for (const key of ["rules", "force", "expose"] as const) {
489
+ const value = feature[key] as Record<string, unknown> | unknown[] | undefined;
490
+
491
+ if (value && !Array.isArray(value) && typeof value === "object") {
492
+ for (const environment of Object.keys(value)) {
493
+ if (environments.includes(environment)) {
494
+ found.add(environment);
495
+ }
496
+ }
497
+ }
498
+ }
499
+
500
+ return Array.from(found).sort();
501
+ }
502
+
503
+ function getFeatureRulesForEnvironment(feature: ParsedFeature, environment: string) {
504
+ if (Array.isArray(feature.rules)) {
505
+ return feature.rules;
506
+ }
507
+
508
+ return feature.rules?.[environment] || [];
509
+ }
510
+
511
+ function isFeatureExposedInEnvironment(feature: ParsedFeature, environment: string) {
512
+ const featureExpose = feature.expose as Record<string, unknown> | boolean | undefined;
513
+ const rules = feature.rules as Record<string, any> | any[] | undefined;
514
+ const environmentRules = !Array.isArray(rules) ? rules?.[environment] : undefined;
515
+
516
+ if (environmentRules?.expose === false) {
517
+ return false;
518
+ }
519
+
520
+ if (featureExpose === false) {
521
+ return false;
522
+ }
523
+
524
+ if (
525
+ featureExpose &&
526
+ typeof featureExpose === "object" &&
527
+ !Array.isArray(featureExpose) &&
528
+ featureExpose[environment] === false
529
+ ) {
530
+ return false;
531
+ }
532
+
533
+ return true;
534
+ }
535
+
536
+ function isFeatureEnabledInEnvironment(feature: ParsedFeature, environment: string) {
537
+ if (feature.archived === true) {
538
+ return false;
539
+ }
540
+
541
+ if (!isFeatureExposedInEnvironment(feature, environment)) {
542
+ return false;
543
+ }
544
+
545
+ return getFeatureRulesForEnvironment(feature, environment).some(
546
+ (rule: { percentage?: number }) => (rule.percentage || 0) > 0,
547
+ );
548
+ }
549
+
550
+ function getFeatureEnvironmentStatus(
551
+ feature: ParsedFeature,
552
+ ): Pick<EntitySummary, "environmentStatus" | "environmentStatusEnvironment"> {
553
+ if (Array.isArray(feature.rules)) {
554
+ return {};
555
+ }
556
+
557
+ const environments = Object.keys(feature.rules || {});
558
+ const productionEnvironment = environments.find((environment) =>
559
+ environment.toLowerCase().startsWith("prod"),
560
+ );
561
+
562
+ if (!productionEnvironment) {
563
+ return {};
564
+ }
565
+
566
+ if (isFeatureEnabledInEnvironment(feature, productionEnvironment)) {
567
+ return {
568
+ environmentStatus: "production",
569
+ environmentStatusEnvironment: productionEnvironment,
570
+ };
571
+ }
572
+
573
+ for (const environment of environments) {
574
+ if (isFeatureEnabledInEnvironment(feature, environment)) {
575
+ return {
576
+ environmentStatus: "other",
577
+ environmentStatusEnvironment: productionEnvironment,
578
+ };
579
+ }
580
+ }
581
+
582
+ return {
583
+ environmentStatus: "disabled",
584
+ environmentStatusEnvironment: productionEnvironment,
585
+ };
586
+ }
587
+
588
+ function toCatalogHistoryEntry(entry: HistoryEntry, set?: string): CatalogHistoryEntry {
589
+ return {
590
+ commit: entry.commit,
591
+ author: entry.author,
592
+ timestamp: entry.timestamp,
593
+ entities: entry.entities.map((entity) => ({
594
+ type: entity.type,
595
+ key: entity.key,
596
+ set,
597
+ })),
598
+ };
599
+ }
600
+
601
+ async function getGitHistoryIndex(
602
+ projectConfig: any,
603
+ datasource: any,
604
+ ): Promise<CatalogHistoryIndex> {
605
+ const rootEntries = (await datasource.listHistoryEntries()).map((entry: HistoryEntry) =>
606
+ toCatalogHistoryEntry(entry),
607
+ );
608
+ const bySet: Record<string, CatalogHistoryEntry[]> = {};
609
+ const entries = [...rootEntries];
610
+
611
+ if (projectConfig.sets) {
612
+ for (const set of await datasource.listSets()) {
613
+ const setDatasource = datasource.forSet(set);
614
+ bySet[set] = (await setDatasource.listHistoryEntries()).map((entry: HistoryEntry) =>
615
+ toCatalogHistoryEntry(entry, set),
616
+ );
617
+ entries.push(...bySet[set]);
618
+ }
619
+ }
620
+
621
+ entries.sort((a, b) => b.timestamp.localeCompare(a.timestamp));
622
+
623
+ const byEntity: Record<string, CatalogHistoryEntry[]> = {};
624
+ const lastModifiedByEntity: Record<string, LastModified> = {};
625
+
626
+ for (const entry of entries) {
627
+ for (const entity of entry.entities) {
628
+ const key = getHistoryEntityKey(entity.type, entity.key, entity.set);
629
+ if (!byEntity[key]) {
630
+ byEntity[key] = [];
631
+ }
632
+
633
+ byEntity[key].push(entry);
634
+
635
+ if (!lastModifiedByEntity[key]) {
636
+ lastModifiedByEntity[key] = toLastModified(entry);
637
+ }
638
+ }
639
+ }
640
+
641
+ return {
642
+ entries,
643
+ bySet,
644
+ byEntity,
645
+ lastModifiedByEntity,
646
+ };
647
+ }
648
+
649
+ async function writeHistoryPages(
650
+ writer: CatalogJsonWriter,
651
+ outputDirectoryPath: string,
652
+ entries: CatalogHistoryEntry[],
653
+ ) {
654
+ const totalPages = Math.max(1, Math.ceil(entries.length / CATALOG_HISTORY_PAGE_SIZE));
655
+
656
+ for (let page = 1; page <= totalPages; page++) {
657
+ await writer.write(path.join(outputDirectoryPath, `page-${page}.json`), {
658
+ page,
659
+ pageSize: CATALOG_HISTORY_PAGE_SIZE,
660
+ totalPages,
661
+ entries: entries.slice(
662
+ (page - 1) * CATALOG_HISTORY_PAGE_SIZE,
663
+ page * CATALOG_HISTORY_PAGE_SIZE,
664
+ ),
665
+ });
666
+ }
667
+ }
668
+
669
+ function getEntityFilePath(
670
+ projectConfig: any,
671
+ datasource: any,
672
+ type: CatalogEntityType,
673
+ key: string,
674
+ ) {
675
+ const directoryByType: Record<CatalogEntityType, string> = {
676
+ feature: projectConfig.featuresDirectoryPath,
677
+ segment: projectConfig.segmentsDirectoryPath,
678
+ attribute: projectConfig.attributesDirectoryPath,
679
+ target: projectConfig.targetsDirectoryPath,
680
+ group: projectConfig.groupsDirectoryPath,
681
+ schema: projectConfig.schemasDirectoryPath,
682
+ test: projectConfig.testsDirectoryPath,
683
+ };
684
+ const filePath = key.split(projectConfig.namespaceCharacter || ".").join(path.sep);
685
+
686
+ return path.join(directoryByType[type], `${filePath}.${datasource.getExtension()}`);
687
+ }
688
+
689
+ function getSourceFileInfo(
690
+ repositorySourceRootDirectoryPath: string,
691
+ projectConfig: any,
692
+ datasource: any,
693
+ type: CatalogEntityType,
694
+ key: string,
695
+ options: { resolveAbsolutePath?: boolean } = {},
696
+ ): SourceFileInfo {
697
+ const filePath = path.resolve(getEntityFilePath(projectConfig, datasource, type, key));
698
+ const absolutePath = options.resolveAbsolutePath ? getRealPath(filePath) : filePath;
699
+
700
+ return {
701
+ sourcePath: toPosixPath(path.relative(repositorySourceRootDirectoryPath, filePath)),
702
+ absolutePath,
703
+ };
704
+ }
705
+
706
+ function isExecutableFile(filePath: string) {
707
+ try {
708
+ const stat = fs.statSync(filePath);
709
+ if (!stat.isFile()) {
710
+ return false;
711
+ }
712
+
713
+ if (process.platform === "win32") {
714
+ return true;
715
+ }
716
+
717
+ fs.accessSync(filePath, fs.constants.X_OK);
718
+ return true;
719
+ } catch {
720
+ return false;
721
+ }
722
+ }
723
+
724
+ function hasCommandInPath(command: string) {
725
+ const pathEntries = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
726
+ const extensions =
727
+ process.platform === "win32"
728
+ ? (process.env.PATHEXT || ".EXE;.CMD;.BAT;.COM").split(";").filter(Boolean)
729
+ : [""];
730
+
731
+ return pathEntries.some((entry) =>
732
+ extensions.some((extension) => isExecutableFile(path.join(entry, `${command}${extension}`))),
733
+ );
734
+ }
735
+
736
+ function hasKnownEditorInstall(editor: DevEditor["id"]) {
737
+ if (hasCommandInPath(editor === "cursor" ? "cursor" : "code")) {
738
+ return true;
739
+ }
740
+
741
+ if (process.platform === "darwin") {
742
+ const appName = editor === "cursor" ? "Cursor.app" : "Visual Studio Code.app";
743
+
744
+ return [
745
+ path.join("/Applications", appName),
746
+ path.join(process.env.HOME || "", "Applications", appName),
747
+ ].some((appPath) => fs.existsSync(appPath));
748
+ }
749
+
750
+ if (process.platform === "win32") {
751
+ const localAppData = process.env.LOCALAPPDATA || "";
752
+ const programFiles = process.env.ProgramFiles || "";
753
+ const programFilesX86 = process.env["ProgramFiles(x86)"] || "";
754
+ const candidates =
755
+ editor === "cursor"
756
+ ? [
757
+ path.join(localAppData, "Programs", "Cursor", "Cursor.exe"),
758
+ path.join(programFiles, "Cursor", "Cursor.exe"),
759
+ path.join(programFilesX86, "Cursor", "Cursor.exe"),
760
+ ]
761
+ : [
762
+ path.join(localAppData, "Programs", "Microsoft VS Code", "Code.exe"),
763
+ path.join(programFiles, "Microsoft VS Code", "Code.exe"),
764
+ path.join(programFilesX86, "Microsoft VS Code", "Code.exe"),
765
+ ];
766
+
767
+ return candidates.some((candidate) => fs.existsSync(candidate));
768
+ }
769
+
770
+ return false;
771
+ }
772
+
773
+ function detectDevEditors(): DevEditor[] {
774
+ const editors: DevEditor[] = [];
775
+
776
+ if (hasKnownEditorInstall("cursor")) {
777
+ editors.push({ id: "cursor", label: "Cursor", icon: "cursor" });
778
+ }
779
+
780
+ if (hasKnownEditorInstall("vscode")) {
781
+ editors.push({ id: "vscode", label: "VS Code", icon: "vscode" });
782
+ }
783
+
784
+ return editors;
785
+ }
786
+
787
+ function encodeEditorPath(filePath: string) {
788
+ return encodeURI(filePath.split(path.sep).join("/")).replace(/#/g, "%23").replace(/\?/g, "%3F");
789
+ }
790
+
791
+ function getEditorUri(editor: DevEditor["id"], filePath: string) {
792
+ return `${editor === "cursor" ? "cursor" : "vscode"}://file/${encodeEditorPath(filePath)}`;
793
+ }
794
+
795
+ function getEditorLinks(editors: DevEditor[], sourceFileInfo: SourceFileInfo) {
796
+ if (editors.length === 0) {
797
+ return undefined;
798
+ }
799
+
800
+ return Object.fromEntries(
801
+ editors.map((editor) => [editor.id, getEditorUri(editor.id, sourceFileInfo.absolutePath)]),
802
+ );
803
+ }
804
+
805
+ function getCurrentBranch(rootDirectoryPath: string) {
806
+ try {
807
+ return runGit(rootDirectoryPath, ["symbolic-ref", "--short", "HEAD"]).trim() || "HEAD";
808
+ } catch {
809
+ return "HEAD";
810
+ }
811
+ }
812
+
813
+ function getRepositoryRootDirectoryPath(rootDirectoryPath: string) {
814
+ try {
815
+ return (
816
+ getRealPath(runGit(rootDirectoryPath, ["rev-parse", "--show-toplevel"]).trim()) ||
817
+ getRealPath(rootDirectoryPath)
818
+ );
819
+ } catch {
820
+ return getRealPath(rootDirectoryPath);
821
+ }
822
+ }
823
+
824
+ function getRepositorySourceRootDirectoryPath(rootDirectoryPath: string) {
825
+ try {
826
+ const gitRootDirectoryPath =
827
+ runGit(rootDirectoryPath, ["rev-parse", "--show-toplevel"]).trim() || rootDirectoryPath;
828
+ const realRootDirectoryPath = getRealPath(rootDirectoryPath);
829
+
830
+ if (realRootDirectoryPath !== rootDirectoryPath) {
831
+ return path.resolve(
832
+ rootDirectoryPath,
833
+ path.relative(realRootDirectoryPath, gitRootDirectoryPath),
834
+ );
835
+ }
836
+
837
+ return gitRootDirectoryPath;
838
+ } catch {
839
+ return rootDirectoryPath;
840
+ }
841
+ }
842
+
843
+ function getOwnerAndRepoFromGitRemote(origin: string, host: string) {
844
+ const escapedHost = host.replace(/[.+?^${}()|[\]\\]/g, "\\$&");
845
+ const match = origin.match(new RegExp(`${escapedHost}[:/]([^/]+)/(.+?)(?:\\.git)?$`));
846
+
847
+ if (!match) {
848
+ return undefined;
849
+ }
850
+
851
+ return {
852
+ owner: match[1],
853
+ repo: match[2],
854
+ };
855
+ }
856
+
857
+ function getRepoLinks(rootDirectoryPath: string): CatalogManifest["links"] {
858
+ try {
859
+ const origin = runGit(rootDirectoryPath, ["config", "--get", "remote.origin.url"]).trim();
860
+ const branch = encodeURI(getCurrentBranch(rootDirectoryPath));
861
+ const providers: Record<
862
+ NonNullable<NonNullable<CatalogManifest["links"]>["provider"]>,
863
+ {
864
+ host: string;
865
+ repository: (owner: string, repo: string) => string;
866
+ source: (owner: string, repo: string) => string;
867
+ commit: (owner: string, repo: string) => string;
868
+ }
869
+ > = {
870
+ github: {
871
+ host: "github.com",
872
+ repository: (owner, repo) => `https://github.com/${owner}/${repo}`,
873
+ source: (owner, repo) => `https://github.com/${owner}/${repo}/blob/${branch}/{{path}}`,
874
+ commit: (owner, repo) => `https://github.com/${owner}/${repo}/commit/{{hash}}`,
875
+ },
876
+ gitlab: {
877
+ host: "gitlab.com",
878
+ repository: (owner, repo) => `https://gitlab.com/${owner}/${repo}`,
879
+ source: (owner, repo) => `https://gitlab.com/${owner}/${repo}/-/blob/${branch}/{{path}}`,
880
+ commit: (owner, repo) => `https://gitlab.com/${owner}/${repo}/-/commit/{{hash}}`,
881
+ },
882
+ bitbucket: {
883
+ host: "bitbucket.org",
884
+ repository: (owner, repo) => `https://bitbucket.org/${owner}/${repo}`,
885
+ source: (owner, repo) => `https://bitbucket.org/${owner}/${repo}/src/${branch}/{{path}}`,
886
+ commit: (owner, repo) => `https://bitbucket.org/${owner}/${repo}/commits/{{hash}}`,
887
+ },
888
+ };
889
+
890
+ for (const provider of Object.keys(providers) as Array<
891
+ NonNullable<NonNullable<CatalogManifest["links"]>["provider"]>
892
+ >) {
893
+ const config = providers[provider];
894
+ const details = getOwnerAndRepoFromGitRemote(origin, config.host);
895
+
896
+ if (details) {
897
+ return {
898
+ provider,
899
+ repository: config.repository(details.owner, details.repo),
900
+ source: config.source(details.owner, details.repo),
901
+ commit: config.commit(details.owner, details.repo),
902
+ };
903
+ }
904
+ }
905
+ } catch {
906
+ return undefined;
907
+ }
908
+ }
909
+
910
+ function buildRelationships(maps: EntityMaps): RelationshipMaps {
911
+ const relationships: RelationshipMaps = {
912
+ featureTargets: {},
913
+ featureTests: {},
914
+ featureRequiredBy: {},
915
+ featureSegments: {},
916
+ featureAttributes: {},
917
+ featureSchemas: {},
918
+ featureGroups: {},
919
+ segmentsUsedInFeatures: {},
920
+ attributesUsedInFeatures: {},
921
+ attributesUsedInSegments: {},
922
+ segmentTargets: {},
923
+ attributeTargets: {},
924
+ groupsUsedInFeatures: {},
925
+ schemasUsedInFeatures: {},
926
+ segmentTests: {},
927
+ targetFeatures: {},
928
+ };
929
+
930
+ for (const [featureKey, feature] of Object.entries(maps.feature)) {
931
+ const required = new Set<string>();
932
+ const segments = new Set<string>();
933
+ const attributes = new Set<string>();
934
+ const schemas = new Set<string>();
935
+ const groups = new Set<string>();
936
+
937
+ collectFeatureKeysFromRequired(feature.required, required);
938
+ collectSchemaKeysFromVariables(feature.variablesSchema, schemas);
939
+ collectGroupKeysFromRules(feature.rules, groups);
940
+
941
+ for (const rule of getFeatureRules(feature)) {
942
+ collectSegmentKeys(rule.segments, segments);
943
+ collectAttributeKeysFromConditions(
944
+ (rule as { conditions?: Condition | Condition[] | "*" }).conditions,
945
+ attributes,
946
+ );
947
+ }
948
+
949
+ for (const force of getFeatureForce(feature)) {
950
+ collectSegmentKeys(force.segments, segments);
951
+ collectAttributeKeysFromConditions(force.conditions, attributes);
952
+ }
953
+
954
+ for (const variation of feature.variations || []) {
955
+ for (const overrides of Object.values(variation.variableOverrides || {})) {
956
+ for (const override of overrides) {
957
+ collectSegmentKeys(override.segments, segments);
958
+ collectAttributeKeysFromConditions(override.conditions, attributes);
959
+ }
960
+ }
961
+ }
962
+
963
+ for (const requiredKey of required) {
964
+ addToSet(relationships.featureRequiredBy, requiredKey, featureKey);
965
+ }
966
+
967
+ for (const segmentKey of segments) {
968
+ addToSet(relationships.featureSegments, featureKey, segmentKey);
969
+ addToSet(relationships.segmentsUsedInFeatures, segmentKey, featureKey);
970
+ }
971
+
972
+ for (const attributeKey of attributes) {
973
+ addToSet(relationships.featureAttributes, featureKey, attributeKey);
974
+ addToSet(relationships.attributesUsedInFeatures, attributeKey, featureKey);
975
+ }
976
+
977
+ for (const schemaKey of schemas) {
978
+ addToSet(relationships.featureSchemas, featureKey, schemaKey);
979
+ addToSet(relationships.schemasUsedInFeatures, schemaKey, featureKey);
980
+ }
981
+
982
+ for (const groupKey of groups) {
983
+ addToSet(relationships.featureGroups, featureKey, groupKey);
984
+ addToSet(relationships.groupsUsedInFeatures, groupKey, featureKey);
985
+ }
986
+ }
987
+
988
+ for (const [segmentKey, segment] of Object.entries(maps.segment)) {
989
+ const attributes = new Set<string>();
990
+ collectAttributeKeysFromConditions(segment.conditions, attributes);
991
+
992
+ for (const attributeKey of attributes) {
993
+ addToSet(relationships.attributesUsedInSegments, attributeKey, segmentKey);
994
+ }
995
+ }
996
+
997
+ for (const [targetKey, target] of Object.entries(maps.target)) {
998
+ for (const [featureKey, feature] of Object.entries(maps.feature)) {
999
+ if (targetIncludesFeature(target, featureKey, feature)) {
1000
+ addToSet(relationships.targetFeatures, targetKey, featureKey);
1001
+ addToSet(relationships.featureTargets, featureKey, targetKey);
1002
+ }
1003
+ }
1004
+ }
1005
+
1006
+ for (const [featureKey, targetKeys] of Object.entries(relationships.featureTargets)) {
1007
+ for (const targetKey of targetKeys) {
1008
+ for (const segmentKey of relationships.featureSegments[featureKey] || []) {
1009
+ addToSet(relationships.segmentTargets, segmentKey, targetKey);
1010
+
1011
+ const segmentAttributes = new Set<string>();
1012
+ collectAttributeKeysFromConditions(maps.segment[segmentKey]?.conditions, segmentAttributes);
1013
+
1014
+ for (const attributeKey of segmentAttributes) {
1015
+ addToSet(relationships.attributeTargets, attributeKey, targetKey);
1016
+ }
1017
+ }
1018
+
1019
+ for (const attributeKey of relationships.featureAttributes[featureKey] || []) {
1020
+ addToSet(relationships.attributeTargets, attributeKey, targetKey);
1021
+ }
1022
+ }
1023
+ }
1024
+
1025
+ for (const [testKey, test] of Object.entries(maps.test)) {
1026
+ if ("feature" in test) {
1027
+ addToSet(relationships.featureTests, test.feature, testKey);
1028
+ }
1029
+
1030
+ if ("segment" in test) {
1031
+ addToSet(relationships.segmentTests, test.segment, testKey);
1032
+ }
1033
+ }
1034
+
1035
+ return relationships;
1036
+ }
1037
+
1038
+ function getEntityRelationships(
1039
+ type: CatalogEntityType,
1040
+ key: string,
1041
+ relationships: RelationshipMaps,
1042
+ ): Record<string, string[]> {
1043
+ if (type === "feature") {
1044
+ return {
1045
+ targets: sortSet(relationships.featureTargets[key]),
1046
+ tests: sortSet(relationships.featureTests[key]),
1047
+ requiredBy: sortSet(relationships.featureRequiredBy[key]),
1048
+ segments: sortSet(relationships.featureSegments[key]),
1049
+ attributes: sortSet(relationships.featureAttributes[key]),
1050
+ schemas: sortSet(relationships.featureSchemas[key]),
1051
+ groups: sortSet(relationships.featureGroups[key]),
1052
+ };
1053
+ }
1054
+
1055
+ if (type === "segment") {
1056
+ return {
1057
+ features: sortSet(relationships.segmentsUsedInFeatures[key]),
1058
+ tests: sortSet(relationships.segmentTests[key]),
1059
+ attributes: sortSet(relationships.attributesUsedInSegments[key]),
1060
+ targets: sortSet(relationships.segmentTargets[key]),
1061
+ };
1062
+ }
1063
+
1064
+ if (type === "attribute") {
1065
+ return {
1066
+ features: sortSet(relationships.attributesUsedInFeatures[key]),
1067
+ segments: sortSet(relationships.attributesUsedInSegments[key]),
1068
+ targets: sortSet(relationships.attributeTargets[key]),
1069
+ };
1070
+ }
1071
+
1072
+ if (type === "target") {
1073
+ return {
1074
+ features: sortSet(relationships.targetFeatures[key]),
1075
+ };
1076
+ }
1077
+
1078
+ if (type === "schema") {
1079
+ return {
1080
+ features: sortSet(relationships.schemasUsedInFeatures[key]),
1081
+ };
1082
+ }
1083
+
1084
+ if (type === "group") {
1085
+ return {
1086
+ features: sortSet(relationships.groupsUsedInFeatures[key]),
1087
+ };
1088
+ }
1089
+
1090
+ return {};
1091
+ }
1092
+
1093
+ async function buildSetCatalog(
1094
+ context: CatalogBuildContext,
1095
+ set: string,
1096
+ projectConfig: any,
1097
+ datasource: any,
1098
+ outputRelativeDirectory: string,
1099
+ ) {
1100
+ const outputDirectoryPath = path.join(context.dataDirectoryPath, outputRelativeDirectory);
1101
+ const setStartedAt = context.progress.setStart(set || undefined);
1102
+ const entitiesStartedAt = context.progress.step("Processing entities");
1103
+ const [featureKeys, segmentKeys, attributeKeys, targetKeys, groupKeys, schemaKeys, testKeys] =
1104
+ await Promise.all([
1105
+ datasource.listFeatures(),
1106
+ datasource.listSegments(),
1107
+ datasource.listAttributes(),
1108
+ datasource.listTargets(),
1109
+ datasource.listGroups(),
1110
+ datasource.listSchemas(),
1111
+ datasource.listTests(),
1112
+ ]);
1113
+ const maps: EntityMaps = {
1114
+ feature: await readAll<ParsedFeature>(featureKeys, (key) => datasource.readFeature(key)),
1115
+ segment: await readAll<Segment>(segmentKeys, (key) => datasource.readSegment(key)),
1116
+ attribute: await readAll<Attribute>(attributeKeys, (key) => datasource.readAttribute(key)),
1117
+ target: await readAll<Target>(targetKeys, (key) => datasource.readTarget(key)),
1118
+ group: await readAll<Group>(groupKeys, (key) => datasource.readGroup(key)),
1119
+ schema: await readAll<Schema>(schemaKeys, (key) => datasource.readSchema(key)),
1120
+ test: await readAll<Test>(testKeys, (key) => datasource.readTest(key)),
1121
+ };
1122
+ context.progress.done(
1123
+ entitiesStartedAt,
1124
+ `(${[
1125
+ pluralize(featureKeys.length, "feature"),
1126
+ pluralize(segmentKeys.length, "segment"),
1127
+ pluralize(attributeKeys.length, "attribute"),
1128
+ pluralize(targetKeys.length, "target"),
1129
+ pluralize(groupKeys.length, "group"),
1130
+ pluralize(schemaKeys.length, "schema"),
1131
+ pluralize(testKeys.length, "test"),
1132
+ ].join(", ")})`,
1133
+ );
1134
+
1135
+ const relationshipsStartedAt = context.progress.step("Mapping relationships");
1136
+ const relationships = buildRelationships(maps);
1137
+ context.progress.done(relationshipsStartedAt);
1138
+
1139
+ const history = set ? context.historyIndex.bySet[set] || [] : context.historyIndex.entries;
1140
+ const index: CatalogIndex = {
1141
+ set,
1142
+ counts: {
1143
+ feature: featureKeys.length,
1144
+ segment: segmentKeys.length,
1145
+ attribute: attributeKeys.length,
1146
+ target: targetKeys.length,
1147
+ group: groupKeys.length,
1148
+ schema: schemaKeys.length,
1149
+ test: testKeys.length,
1150
+ },
1151
+ entities: {
1152
+ feature: [],
1153
+ segment: [],
1154
+ attribute: [],
1155
+ target: [],
1156
+ group: [],
1157
+ schema: [],
1158
+ test: [],
1159
+ },
1160
+ };
1161
+
1162
+ const historyStartedAt = context.progress.step("Writing history pages");
1163
+ await writeHistoryPages(context.writer, path.join(outputDirectoryPath, "history"), history);
1164
+ context.progress.done(historyStartedAt, `(${pluralize(history.length, "entry", "entries")})`);
1165
+
1166
+ const entityPlan: Array<{
1167
+ type: CatalogEntityType;
1168
+ keys: string[];
1169
+ entities: Record<string, any>;
1170
+ }> = [
1171
+ { type: "feature", keys: featureKeys, entities: maps.feature },
1172
+ { type: "segment", keys: segmentKeys, entities: maps.segment },
1173
+ { type: "attribute", keys: attributeKeys, entities: maps.attribute },
1174
+ { type: "target", keys: targetKeys, entities: maps.target },
1175
+ { type: "group", keys: groupKeys, entities: maps.group },
1176
+ { type: "schema", keys: schemaKeys, entities: maps.schema },
1177
+ { type: "test", keys: testKeys, entities: maps.test },
1178
+ ];
1179
+
1180
+ const detailsStartedAt = context.progress.step("Writing entity details");
1181
+ for (const plan of entityPlan) {
1182
+ for (const key of plan.keys) {
1183
+ const entity = plan.entities[key];
1184
+ const featureEnvironmentStatus =
1185
+ plan.type === "feature" ? getFeatureEnvironmentStatus(entity) : {};
1186
+ const sourceFileInfo = getSourceFileInfo(
1187
+ context.repositorySourceRootDirectoryPath,
1188
+ projectConfig,
1189
+ datasource,
1190
+ plan.type,
1191
+ key,
1192
+ { resolveAbsolutePath: context.devEditors.length > 0 },
1193
+ );
1194
+ const lastModified = getLastModified(context.historyIndex, plan.type, key, set);
1195
+ const entityRelationships = getEntityRelationships(plan.type, key, relationships);
1196
+ const detail: EntityDetail = {
1197
+ type: plan.type,
1198
+ key,
1199
+ entity,
1200
+ sourcePath: sourceFileInfo.sourcePath,
1201
+ editLinks: getEditorLinks(context.devEditors, sourceFileInfo),
1202
+ lastModified,
1203
+ relationships: entityRelationships,
1204
+ environments: projectConfig.environments,
1205
+ historyPath: `${path.posix.join(
1206
+ "data",
1207
+ outputRelativeDirectory.split(path.sep).join(path.posix.sep),
1208
+ "entities",
1209
+ plan.type,
1210
+ encodeKey(key),
1211
+ "history",
1212
+ )}`,
1213
+ };
1214
+
1215
+ await context.writer.write(
1216
+ path.join(outputDirectoryPath, "entities", plan.type, `${encodeKey(key)}.json`),
1217
+ detail,
1218
+ );
1219
+
1220
+ await writeHistoryPages(
1221
+ context.writer,
1222
+ path.join(outputDirectoryPath, "entities", plan.type, encodeKey(key), "history"),
1223
+ context.historyIndex.byEntity[getHistoryEntityKey(plan.type, key, set)] || [],
1224
+ );
1225
+
1226
+ index.entities[plan.type].push(
1227
+ getEntitySummary(entity, plan.type, key, context.historyIndex, set, {
1228
+ tags: entity.tags ? entity.tags : undefined,
1229
+ targets:
1230
+ plan.type === "feature"
1231
+ ? sortSet(relationships.featureTargets[key])
1232
+ : plan.type === "segment"
1233
+ ? sortSet(relationships.segmentTargets[key])
1234
+ : plan.type === "attribute"
1235
+ ? sortSet(relationships.attributeTargets[key])
1236
+ : undefined,
1237
+ usedInFeatureCount:
1238
+ plan.type === "segment"
1239
+ ? sortSet(relationships.segmentsUsedInFeatures[key]).length
1240
+ : undefined,
1241
+ usedInSegmentCount:
1242
+ plan.type === "attribute"
1243
+ ? sortSet(relationships.attributesUsedInSegments[key]).length
1244
+ : undefined,
1245
+ environments:
1246
+ plan.type === "feature"
1247
+ ? getFeatureEnvironmentKeys(entity, projectConfig.environments)
1248
+ : projectConfig.environments,
1249
+ variationValues: plan.type === "feature" ? getFeatureVariationValues(entity) : undefined,
1250
+ variableKeys: plan.type === "feature" ? getFeatureVariableKeys(entity) : undefined,
1251
+ hasVariations: plan.type === "feature" ? Boolean(entity.variations?.length) : undefined,
1252
+ hasVariables:
1253
+ plan.type === "feature"
1254
+ ? Object.keys(entity.variablesSchema || {}).length > 0
1255
+ : undefined,
1256
+ ...featureEnvironmentStatus,
1257
+ }),
1258
+ );
1259
+ }
1260
+
1261
+ index.entities[plan.type].sort((a, b) => a.key.localeCompare(b.key));
1262
+ }
1263
+ context.progress.done(detailsStartedAt);
1264
+
1265
+ await context.writer.write(path.join(outputDirectoryPath, "index.json"), index);
1266
+ context.progress.done(setStartedAt);
1267
+
1268
+ return index;
1269
+ }
1270
+
1271
+ async function copyCatalogAssets(outputDirectoryPath: string) {
1272
+ const catalogPackagePath = path.dirname(require.resolve("@featurevisor/catalog/package.json"));
1273
+ const catalogDistPath = path.join(catalogPackagePath, "dist");
1274
+
1275
+ if (!fs.existsSync(catalogDistPath)) {
1276
+ throw new Error(
1277
+ "Catalog UI assets are missing. Run `npm run build --workspace @featurevisor/catalog` first.",
1278
+ );
1279
+ }
1280
+
1281
+ await fs.promises.cp(catalogDistPath, outputDirectoryPath, { recursive: true });
1282
+ }
1283
+
1284
+ export async function exportCatalog(
1285
+ runtime: CatalogRuntime,
1286
+ rootDirectoryPath: string,
1287
+ projectConfig: any,
1288
+ datasource: any,
1289
+ options: CatalogExportOptions = {},
1290
+ ) {
1291
+ const outputDirectoryPath = options.outDir
1292
+ ? path.resolve(rootDirectoryPath, options.outDir)
1293
+ : projectConfig.catalogDirectoryPath;
1294
+ const dataDirectoryPath = path.join(outputDirectoryPath, "data");
1295
+ const progress = new CatalogProgressReporter(rootDirectoryPath, outputDirectoryPath);
1296
+ const writer = new CatalogJsonWriter();
1297
+
1298
+ progress.start({
1299
+ browserRouter: options.browserRouter !== false,
1300
+ sets: projectConfig.sets === true,
1301
+ });
1302
+
1303
+ let stepStartedAt = progress.step("Preparing output directory");
1304
+ if (options.preserveAssets) {
1305
+ await fs.promises.rm(dataDirectoryPath, { recursive: true, force: true });
1306
+ } else {
1307
+ await fs.promises.rm(outputDirectoryPath, { recursive: true, force: true });
1308
+ }
1309
+ await fs.promises.mkdir(dataDirectoryPath, { recursive: true });
1310
+ progress.done(stepStartedAt);
1311
+
1312
+ if (options.copyAssets !== false) {
1313
+ stepStartedAt = progress.step("Copying Catalog UI assets");
1314
+ await copyCatalogAssets(outputDirectoryPath);
1315
+ progress.done(stepStartedAt);
1316
+ }
1317
+
1318
+ const devEditors = options.dev
1319
+ ? options.devSession?.devEditors || options.devEditors || detectDevEditors()
1320
+ : [];
1321
+
1322
+ stepStartedAt = progress.step("Resolving repository links");
1323
+ const repositoryRootDirectoryPath =
1324
+ options.devSession?.repositoryRootDirectoryPath ||
1325
+ getRepositoryRootDirectoryPath(rootDirectoryPath);
1326
+ const repositorySourceRootDirectoryPath =
1327
+ options.devSession?.repositorySourceRootDirectoryPath ||
1328
+ getRepositorySourceRootDirectoryPath(rootDirectoryPath);
1329
+ const links = options.devSession?.links || getRepoLinks(repositoryRootDirectoryPath);
1330
+ progress.done(stepStartedAt, links?.repository ? `(${links.repository})` : "(none)");
1331
+
1332
+ stepStartedAt = progress.step("Reading Git history");
1333
+ const historyIndex =
1334
+ options.devSession?.historyIndex || (await getGitHistoryIndex(projectConfig, datasource));
1335
+ progress.done(stepStartedAt, `(${pluralize(historyIndex.entries.length, "commit")})`);
1336
+
1337
+ const context: CatalogBuildContext = {
1338
+ rootDirectoryPath,
1339
+ repositorySourceRootDirectoryPath,
1340
+ outputDirectoryPath,
1341
+ dataDirectoryPath,
1342
+ historyIndex,
1343
+ devEditors,
1344
+ progress,
1345
+ writer,
1346
+ };
1347
+
1348
+ stepStartedAt = progress.step("Discovering project sets");
1349
+ const executions = await runtime.getProjectSetExecutions(projectConfig, datasource);
1350
+ progress.done(
1351
+ stepStartedAt,
1352
+ projectConfig.sets
1353
+ ? `(${executions.map((execution) => execution.set).join(", ") || "none"})`
1354
+ : "(root)",
1355
+ );
1356
+
1357
+ stepStartedAt = progress.step("Writing project history");
1358
+ await writeHistoryPages(
1359
+ writer,
1360
+ path.join(dataDirectoryPath, "project", "history"),
1361
+ historyIndex.entries,
1362
+ );
1363
+ progress.done(stepStartedAt, `(${pluralize(historyIndex.entries.length, "entry", "entries")})`);
1364
+
1365
+ const setIndexes: Record<string, CatalogIndex> = {};
1366
+ for (const execution of executions) {
1367
+ const outputRelativeDirectory = projectConfig.sets ? path.join("sets", execution.set) : "root";
1368
+ setIndexes[execution.set || "root"] = await buildSetCatalog(
1369
+ context,
1370
+ execution.set,
1371
+ execution.projectConfig,
1372
+ execution.datasource,
1373
+ outputRelativeDirectory,
1374
+ );
1375
+ }
1376
+
1377
+ stepStartedAt = progress.step("Writing manifest");
1378
+ const manifest: CatalogManifest = {
1379
+ schemaVersion: CATALOG_SCHEMA_VERSION,
1380
+ generatedAt: new Date().toISOString(),
1381
+ router: options.browserRouter === false ? "hash" : "browser",
1382
+ sets: projectConfig.sets,
1383
+ setKeys: projectConfig.sets ? sortSetKeys(executions.map((execution) => execution.set)) : [],
1384
+ projectConfig: {
1385
+ tags: projectConfig.tags,
1386
+ environments: projectConfig.environments,
1387
+ },
1388
+ dev: options.dev ? { editors: devEditors } : undefined,
1389
+ links,
1390
+ paths: {
1391
+ projectHistory: "data/project/history/page-1.json",
1392
+ root: projectConfig.sets ? undefined : "data/root/index.json",
1393
+ sets: projectConfig.sets
1394
+ ? Object.fromEntries(
1395
+ executions.map((execution) => [
1396
+ execution.set,
1397
+ `data/sets/${encodeURIComponent(execution.set)}/index.json`,
1398
+ ]),
1399
+ )
1400
+ : undefined,
1401
+ },
1402
+ counts: Object.fromEntries(Object.keys(setIndexes).map((key) => [key, setIndexes[key].counts])),
1403
+ };
1404
+
1405
+ await writer.write(path.join(dataDirectoryPath, "manifest.json"), manifest);
1406
+ progress.done(stepStartedAt);
1407
+ progress.complete();
1408
+
1409
+ return {
1410
+ outputDirectoryPath,
1411
+ manifest,
1412
+ };
1413
+ }
1414
+
1415
+ function getContentType(filePath: string) {
1416
+ const extension = path.extname(filePath);
1417
+
1418
+ switch (extension) {
1419
+ case ".js":
1420
+ return "text/javascript";
1421
+ case ".css":
1422
+ return "text/css";
1423
+ case ".json":
1424
+ return "application/json";
1425
+ case ".png":
1426
+ return "image/png";
1427
+ case ".svg":
1428
+ return "image/svg+xml";
1429
+ case ".ico":
1430
+ return "image/x-icon";
1431
+ default:
1432
+ return "text/html";
1433
+ }
1434
+ }
1435
+
1436
+ function getCatalogLiveReloadClientScript() {
1437
+ return [
1438
+ "<script>",
1439
+ "(() => {",
1440
+ ' const source = new EventSource("/__featurevisor_catalog_reload");',
1441
+ ' source.addEventListener("reload", () => window.location.reload());',
1442
+ " source.onerror = () => {",
1443
+ " source.close();",
1444
+ " setTimeout(() => window.location.reload(), 1000);",
1445
+ " };",
1446
+ "})();",
1447
+ "</script>",
1448
+ ].join("");
1449
+ }
1450
+
1451
+ function injectCatalogLiveReloadClient(html: string) {
1452
+ const script = getCatalogLiveReloadClientScript();
1453
+
1454
+ if (html.includes("</body>")) {
1455
+ return html.replace("</body>", `${script}</body>`);
1456
+ }
1457
+
1458
+ return `${html}${script}`;
1459
+ }
1460
+
1461
+ function getCatalogInputWatchPaths(rootDirectoryPath: string, projectConfig: any) {
1462
+ const paths = [path.join(rootDirectoryPath, "featurevisor.config.js")];
1463
+
1464
+ if (projectConfig.sets) {
1465
+ paths.push(projectConfig.setsDirectoryPath);
1466
+ return paths;
1467
+ }
1468
+
1469
+ paths.push(
1470
+ projectConfig.featuresDirectoryPath,
1471
+ projectConfig.segmentsDirectoryPath,
1472
+ projectConfig.attributesDirectoryPath,
1473
+ projectConfig.targetsDirectoryPath,
1474
+ projectConfig.groupsDirectoryPath,
1475
+ projectConfig.schemasDirectoryPath,
1476
+ projectConfig.testsDirectoryPath,
1477
+ );
1478
+
1479
+ return paths.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
1480
+ }
1481
+
1482
+ function createCatalogInputWatcher(
1483
+ rootDirectoryPath: string,
1484
+ projectConfig: any,
1485
+ ignoredDirectoryPaths: string[],
1486
+ onChange: (changedPaths: string[]) => void,
1487
+ ) {
1488
+ const watchPaths = getCatalogInputWatchPaths(rootDirectoryPath, projectConfig);
1489
+
1490
+ function shouldIgnore(targetPath: string) {
1491
+ const resolvedTargetPath = path.resolve(targetPath);
1492
+
1493
+ return ignoredDirectoryPaths.some((ignoredDirectoryPath) => {
1494
+ const resolvedIgnoredPath = path.resolve(ignoredDirectoryPath);
1495
+
1496
+ return (
1497
+ resolvedTargetPath === resolvedIgnoredPath ||
1498
+ resolvedTargetPath.startsWith(`${resolvedIgnoredPath}${path.sep}`)
1499
+ );
1500
+ });
1501
+ }
1502
+
1503
+ function collectSnapshotEntries(directoryPath: string, snapshotEntries: Map<string, string>) {
1504
+ if (shouldIgnore(directoryPath)) {
1505
+ return;
1506
+ }
1507
+
1508
+ let entries: fs.Dirent[] = [];
1509
+
1510
+ try {
1511
+ entries = fs.readdirSync(directoryPath, { withFileTypes: true });
1512
+ } catch {
1513
+ return;
1514
+ }
1515
+
1516
+ for (const entry of entries) {
1517
+ const entryPath = path.join(directoryPath, entry.name);
1518
+
1519
+ if (shouldIgnore(entryPath)) {
1520
+ continue;
1521
+ }
1522
+
1523
+ if (entry.isDirectory()) {
1524
+ collectSnapshotEntries(entryPath, snapshotEntries);
1525
+ continue;
1526
+ }
1527
+
1528
+ if (!entry.isFile()) {
1529
+ continue;
1530
+ }
1531
+
1532
+ try {
1533
+ const stat = fs.statSync(entryPath);
1534
+ snapshotEntries.set(entryPath, `${stat.size}:${stat.mtimeMs}`);
1535
+ } catch {
1536
+ // Ignore transient editor save races.
1537
+ }
1538
+ }
1539
+ }
1540
+
1541
+ function createSnapshot() {
1542
+ const snapshotEntries = new Map<string, string>();
1543
+
1544
+ for (const watchPath of watchPaths) {
1545
+ if (!fs.existsSync(watchPath)) {
1546
+ continue;
1547
+ }
1548
+
1549
+ const stat = fs.statSync(watchPath);
1550
+
1551
+ if (stat.isFile()) {
1552
+ snapshotEntries.set(watchPath, `${stat.size}:${stat.mtimeMs}`);
1553
+ continue;
1554
+ }
1555
+
1556
+ collectSnapshotEntries(watchPath, snapshotEntries);
1557
+ }
1558
+
1559
+ return snapshotEntries;
1560
+ }
1561
+
1562
+ function getSnapshotChanges(previous: Map<string, string>, next: Map<string, string>) {
1563
+ const changedPaths = new Set<string>();
1564
+
1565
+ for (const [filePath, signature] of Array.from(next.entries())) {
1566
+ if (previous.get(filePath) !== signature) {
1567
+ changedPaths.add(filePath);
1568
+ }
1569
+ }
1570
+
1571
+ for (const filePath of Array.from(previous.keys())) {
1572
+ if (!next.has(filePath)) {
1573
+ changedPaths.add(filePath);
1574
+ }
1575
+ }
1576
+
1577
+ return Array.from(changedPaths);
1578
+ }
1579
+
1580
+ let previousSnapshot = createSnapshot();
1581
+ const interval = setInterval(() => {
1582
+ const nextSnapshot = createSnapshot();
1583
+ const changedPaths = getSnapshotChanges(previousSnapshot, nextSnapshot);
1584
+ previousSnapshot = nextSnapshot;
1585
+
1586
+ if (changedPaths.length > 0) {
1587
+ onChange(changedPaths);
1588
+ }
1589
+ }, 1000);
1590
+
1591
+ return () => clearInterval(interval);
1592
+ }
1593
+
1594
+ export async function serveCatalog(
1595
+ runtime: CatalogRuntime,
1596
+ rootDirectoryPath: string,
1597
+ projectConfig: any,
1598
+ datasource: any,
1599
+ options: CatalogServeOptions = {},
1600
+ ): Promise<CatalogServerHandle> {
1601
+ const outputDirectoryPath = options.outDir
1602
+ ? path.resolve(rootDirectoryPath, options.outDir)
1603
+ : projectConfig.catalogDirectoryPath;
1604
+
1605
+ if (!fs.existsSync(outputDirectoryPath)) {
1606
+ await exportCatalog(runtime, rootDirectoryPath, projectConfig, datasource, {
1607
+ outDir: outputDirectoryPath,
1608
+ browserRouter: options.browserRouter,
1609
+ });
1610
+ }
1611
+
1612
+ const port = Number(options.port || 3000);
1613
+ const liveReloadClients = new Set<http.ServerResponse>();
1614
+
1615
+ function triggerReload() {
1616
+ liveReloadClients.forEach((client) => {
1617
+ client.write("event: reload\n");
1618
+ client.write("data: reload\n\n");
1619
+ });
1620
+ }
1621
+
1622
+ const server = http.createServer((request, response) => {
1623
+ const requestedUrl = decodeURIComponent((request.url || "/").split("?")[0]);
1624
+
1625
+ if (options.liveReload && requestedUrl === "/__featurevisor_catalog_reload") {
1626
+ response.writeHead(200, {
1627
+ "Content-Type": "text/event-stream",
1628
+ "Cache-Control": "no-cache, no-transform",
1629
+ Connection: "keep-alive",
1630
+ });
1631
+ response.write("\n");
1632
+ liveReloadClients.add(response);
1633
+ request.on("close", () => liveReloadClients.delete(response));
1634
+ return;
1635
+ }
1636
+
1637
+ const requestedPath = requestedUrl === "/" ? "/index.html" : requestedUrl;
1638
+ const assetPath = requestedPath === "/favicon.ico" ? "/favicon.png" : requestedPath;
1639
+ const filePath = path.join(outputDirectoryPath, assetPath);
1640
+ const safeFilePath = filePath.startsWith(outputDirectoryPath)
1641
+ ? filePath
1642
+ : path.join(outputDirectoryPath, "index.html");
1643
+
1644
+ fs.readFile(safeFilePath, (error, content) => {
1645
+ if (!error) {
1646
+ if (options.liveReload && path.basename(safeFilePath) === "index.html") {
1647
+ response.writeHead(200, { "Content-Type": "text/html" });
1648
+ response.end(injectCatalogLiveReloadClient(content.toString("utf8")));
1649
+ return;
1650
+ }
1651
+
1652
+ response.writeHead(200, { "Content-Type": getContentType(safeFilePath) });
1653
+ response.end(content);
1654
+ return;
1655
+ }
1656
+
1657
+ if (
1658
+ requestedPath.startsWith("/assets/") ||
1659
+ requestedPath.startsWith("/data/") ||
1660
+ requestedPath === "/favicon.ico"
1661
+ ) {
1662
+ response.writeHead(404, { "Content-Type": "text/plain" });
1663
+ response.end("404 Not Found");
1664
+ return;
1665
+ }
1666
+
1667
+ fs.readFile(path.join(outputDirectoryPath, "index.html"), (indexError, indexContent) => {
1668
+ if (indexError) {
1669
+ response.writeHead(500, { "Content-Type": "text/plain" });
1670
+ response.end("Catalog index.html not found.");
1671
+ return;
1672
+ }
1673
+
1674
+ response.writeHead(200, { "Content-Type": "text/html" });
1675
+ response.end(
1676
+ options.liveReload
1677
+ ? injectCatalogLiveReloadClient(indexContent.toString("utf8"))
1678
+ : indexContent,
1679
+ );
1680
+ });
1681
+ });
1682
+ });
1683
+
1684
+ server.on("error", (error) => {
1685
+ console.error(`Unable to serve catalog on http://127.0.0.1:${port}/`);
1686
+ console.error(error);
1687
+ process.exitCode = 1;
1688
+ });
1689
+
1690
+ server.listen(port, "127.0.0.1", () => {
1691
+ console.log(`Catalog running at http://127.0.0.1:${port}/`);
1692
+ });
1693
+
1694
+ return {
1695
+ close: () =>
1696
+ new Promise<void>((resolve, reject) => {
1697
+ server.close((error) => {
1698
+ if (error) {
1699
+ reject(error);
1700
+ return;
1701
+ }
1702
+
1703
+ resolve();
1704
+ });
1705
+ }),
1706
+ triggerReload,
1707
+ };
1708
+ }
1709
+
1710
+ export async function createCatalogDevSession(
1711
+ rootDirectoryPath: string,
1712
+ projectConfig: any,
1713
+ datasource: any,
1714
+ options: CatalogExportOptions = {},
1715
+ ): Promise<CatalogDevSession> {
1716
+ const outputDirectoryPath = options.outDir
1717
+ ? path.resolve(rootDirectoryPath, options.outDir)
1718
+ : projectConfig.catalogDirectoryPath;
1719
+ const repositoryRootDirectoryPath = getRepositoryRootDirectoryPath(rootDirectoryPath);
1720
+ const repositorySourceRootDirectoryPath = getRepositorySourceRootDirectoryPath(rootDirectoryPath);
1721
+
1722
+ return {
1723
+ outputDirectoryPath,
1724
+ devEditors: options.devEditors || detectDevEditors(),
1725
+ historyIndex: await getGitHistoryIndex(projectConfig, datasource),
1726
+ links: getRepoLinks(repositoryRootDirectoryPath),
1727
+ repositoryRootDirectoryPath,
1728
+ repositorySourceRootDirectoryPath,
1729
+ };
1730
+ }
1731
+
1732
+ export function createCatalogApi(runtime: CatalogRuntime) {
1733
+ return {
1734
+ exportCatalog: (
1735
+ rootDirectoryPath: string,
1736
+ projectConfig: any,
1737
+ datasource: any,
1738
+ options: CatalogExportOptions = {},
1739
+ ) => exportCatalog(runtime, rootDirectoryPath, projectConfig, datasource, options),
1740
+ serveCatalog: (
1741
+ rootDirectoryPath: string,
1742
+ projectConfig: any,
1743
+ datasource: any,
1744
+ options: CatalogServeOptions = {},
1745
+ ) => serveCatalog(runtime, rootDirectoryPath, projectConfig, datasource, options),
1746
+ };
1747
+ }
1748
+
1749
+ function shouldCopyAssets(parsed: CatalogPluginParsedOptions) {
1750
+ return parsed.assets !== false && parsed.noAssets !== true && parsed["no-assets"] !== true;
1751
+ }
1752
+
1753
+ export function createCatalogPlugin(
1754
+ runtime: CatalogRuntime,
1755
+ api: ReturnType<typeof createCatalogApi> = createCatalogApi(runtime),
1756
+ ): CatalogPlugin {
1757
+ return {
1758
+ command: "catalog [subcommand]",
1759
+ handler: async ({ rootDirectoryPath, projectConfig, datasource, parsed }) => {
1760
+ const allowedSubcommands = ["export", "serve"];
1761
+ const browserRouter = !(parsed.hashRouter || parsed["hash-router"]);
1762
+
1763
+ if (!parsed.subcommand) {
1764
+ const outputDirectoryPath = parsed.outDir
1765
+ ? path.resolve(rootDirectoryPath, parsed.outDir)
1766
+ : projectConfig.catalogDirectoryPath;
1767
+ const devSession = await createCatalogDevSession(
1768
+ rootDirectoryPath,
1769
+ projectConfig,
1770
+ datasource,
1771
+ {
1772
+ outDir: parsed.outDir,
1773
+ },
1774
+ );
1775
+
1776
+ await api.exportCatalog(rootDirectoryPath, projectConfig, datasource, {
1777
+ outDir: parsed.outDir,
1778
+ copyAssets: shouldCopyAssets(parsed),
1779
+ browserRouter,
1780
+ dev: true,
1781
+ devSession,
1782
+ });
1783
+ const server = await api.serveCatalog(rootDirectoryPath, projectConfig, datasource, {
1784
+ outDir: parsed.outDir,
1785
+ port: parsed.port || parsed.p,
1786
+ browserRouter,
1787
+ liveReload: true,
1788
+ });
1789
+ const ignoredDirectoryPaths = [
1790
+ path.join(rootDirectoryPath, ".git"),
1791
+ path.join(rootDirectoryPath, "node_modules"),
1792
+ path.join(rootDirectoryPath, ".featurevisor"),
1793
+ path.join(rootDirectoryPath, "datafiles"),
1794
+ path.join(rootDirectoryPath, "catalog"),
1795
+ outputDirectoryPath,
1796
+ ];
1797
+ let exportInFlight = false;
1798
+ let exportQueued = false;
1799
+ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
1800
+
1801
+ const runRebuildAndReload = async () => {
1802
+ if (exportInFlight) {
1803
+ exportQueued = true;
1804
+ return;
1805
+ }
1806
+
1807
+ exportInFlight = true;
1808
+ console.log("\n[catalog] Rebuilding because project files changed");
1809
+
1810
+ try {
1811
+ await api.exportCatalog(rootDirectoryPath, projectConfig, datasource, {
1812
+ outDir: parsed.outDir,
1813
+ copyAssets: false,
1814
+ preserveAssets: true,
1815
+ browserRouter,
1816
+ dev: true,
1817
+ devSession,
1818
+ });
1819
+ server.triggerReload();
1820
+ } catch (error) {
1821
+ console.error("[catalog] Export failed during watch mode");
1822
+ console.error(error);
1823
+ } finally {
1824
+ exportInFlight = false;
1825
+
1826
+ if (exportQueued) {
1827
+ exportQueued = false;
1828
+ void runRebuildAndReload();
1829
+ }
1830
+ }
1831
+ };
1832
+
1833
+ const stopWatchingProject = createCatalogInputWatcher(
1834
+ rootDirectoryPath,
1835
+ projectConfig,
1836
+ ignoredDirectoryPaths,
1837
+ () => {
1838
+ if (debounceTimer) {
1839
+ clearTimeout(debounceTimer);
1840
+ }
1841
+ debounceTimer = setTimeout(() => {
1842
+ debounceTimer = null;
1843
+ void runRebuildAndReload();
1844
+ }, 250);
1845
+ },
1846
+ );
1847
+
1848
+ process.on("exit", stopWatchingProject);
1849
+ return;
1850
+ }
1851
+
1852
+ if (allowedSubcommands.indexOf(parsed.subcommand) === -1) {
1853
+ console.log("Please specify a subcommand: `export` or `serve`");
1854
+ return false;
1855
+ }
1856
+
1857
+ if (parsed.subcommand === "export") {
1858
+ await api.exportCatalog(rootDirectoryPath, projectConfig, datasource, {
1859
+ outDir: parsed.outDir,
1860
+ copyAssets: shouldCopyAssets(parsed),
1861
+ browserRouter,
1862
+ });
1863
+ }
1864
+
1865
+ if (parsed.subcommand === "serve") {
1866
+ await api.serveCatalog(rootDirectoryPath, projectConfig, datasource, {
1867
+ outDir: parsed.outDir,
1868
+ port: parsed.port || parsed.p,
1869
+ browserRouter,
1870
+ });
1871
+ }
1872
+ },
1873
+ examples: [
1874
+ {
1875
+ command: "catalog",
1876
+ description: "generate and serve the static catalog locally",
1877
+ },
1878
+ {
1879
+ command: "catalog export",
1880
+ description: "generate static catalog with project data",
1881
+ },
1882
+ {
1883
+ command: "catalog serve",
1884
+ description: "serve the generated catalog locally",
1885
+ },
1886
+ ],
1887
+ };
1888
+ }