@creatorem/cli 0.0.1 → 1.0.4

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 (39) hide show
  1. package/package.json +26 -5
  2. package/src/cli.tsx +141 -0
  3. package/src/commands/create-dashboard.tsx +455 -0
  4. package/src/commands/create-mobile.tsx +555 -0
  5. package/src/commands/create.tsx +1119 -0
  6. package/src/commands/generate-migration.mjs +17 -66
  7. package/src/commands/generate-migration.tsx +46 -0
  8. package/src/commands/generate-schemas.mjs +2 -2
  9. package/src/commands/generate-schemas.tsx +36 -0
  10. package/src/dashboard-features/ai/index.ts +102 -0
  11. package/src/dashboard-features/analytics/index.ts +31 -0
  12. package/src/dashboard-features/billing/index.ts +349 -0
  13. package/src/dashboard-features/content-type/index.ts +64 -0
  14. package/src/dashboard-features/email-templates/index.ts +17 -0
  15. package/src/dashboard-features/emailer/index.ts +27 -0
  16. package/src/dashboard-features/index.ts +28 -0
  17. package/src/dashboard-features/keybindings/index.ts +52 -0
  18. package/src/dashboard-features/manager.ts +349 -0
  19. package/src/dashboard-features/monitoring/index.ts +16 -0
  20. package/src/dashboard-features/notification/index.ts +40 -0
  21. package/src/dashboard-features/onboarding/index.ts +65 -0
  22. package/src/dashboard-features/organization/index.ts +38 -0
  23. package/src/dashboard-features/types.ts +41 -0
  24. package/src/mobile-features/index.ts +12 -0
  25. package/src/mobile-features/manager.ts +1 -0
  26. package/src/mobile-features/notification/index.ts +41 -0
  27. package/src/mobile-features/onboarding/index.ts +35 -0
  28. package/src/mobile-features/organization/index.ts +38 -0
  29. package/src/mobile-features/types.ts +1 -0
  30. package/src/shims/signal-exit.js +9 -0
  31. package/src/ui/app.tsx +68 -0
  32. package/src/ui/multi-select.tsx +106 -0
  33. package/src/utils/ast.ts +422 -0
  34. package/src/utils/env-template.ts +635 -0
  35. package/tests/test-cli-features.sh +81 -0
  36. package/tests/test-cli-mobile.sh +65 -0
  37. package/tsconfig.json +15 -0
  38. package/tsup.config.ts +21 -0
  39. package/bin/cli.mjs +0 -40
@@ -0,0 +1,635 @@
1
+ import { Project, SyntaxKind } from 'ts-morph';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import { fileURLToPath } from 'url';
5
+
6
+ export interface EnvVarInfo {
7
+ name: string;
8
+ required: boolean;
9
+ description: string;
10
+ group: string;
11
+ defaultValue?: string;
12
+ }
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Package map: @kit/foo → absolute directory
16
+ // ---------------------------------------------------------------------------
17
+
18
+ const SKIP_DIRS = new Set([
19
+ 'node_modules', '.git', '.next', '.turbo', 'dist', 'build', '.cache', '.expo',
20
+ ]);
21
+
22
+ export async function buildPackageMap(monoRoot: string): Promise<Map<string, string>> {
23
+ const map = new Map<string, string>();
24
+
25
+ const scan = async (dir: string, depth: number) => {
26
+ if (depth > 6) return;
27
+ let entries: fs.Dirent[];
28
+ try { entries = await fs.readdir(dir, { withFileTypes: true }); }
29
+ catch { return; }
30
+
31
+ for (const entry of entries) {
32
+ if (!entry.isDirectory() || SKIP_DIRS.has(entry.name)) continue;
33
+ const subDir = path.join(dir, entry.name);
34
+ const pkgJsonPath = path.join(subDir, 'package.json');
35
+ if (await fs.pathExists(pkgJsonPath)) {
36
+ try {
37
+ const pkg = await fs.readJson(pkgJsonPath);
38
+ if (pkg.name) map.set(pkg.name, subDir);
39
+ } catch { /* ignore malformed package.json */ }
40
+ }
41
+ await scan(subDir, depth + 1);
42
+ }
43
+ };
44
+
45
+ await scan(monoRoot, 0);
46
+ return map;
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Module specifier resolution
51
+ // ---------------------------------------------------------------------------
52
+
53
+ function resolveSpecifier(
54
+ specifier: string,
55
+ fromDir: string,
56
+ pkgMap: Map<string, string>,
57
+ ): string | null {
58
+ // Relative imports
59
+ if (specifier.startsWith('.')) {
60
+ for (const candidate of [
61
+ path.resolve(fromDir, specifier + '.ts'),
62
+ path.resolve(fromDir, specifier, 'index.ts'),
63
+ ]) {
64
+ if (fs.existsSync(candidate)) return candidate;
65
+ }
66
+ return null;
67
+ }
68
+
69
+ // Exact package name (e.g. '@kit/billing')
70
+ const exactDir = pkgMap.get(specifier);
71
+ if (exactDir) {
72
+ for (const candidate of [
73
+ path.join(exactDir, 'index.ts'),
74
+ path.join(exactDir, 'envs.ts'),
75
+ ]) {
76
+ if (fs.existsSync(candidate)) return candidate;
77
+ }
78
+ return null;
79
+ }
80
+
81
+ // Sub-path (e.g. '@kit/billing/envs' → '@kit/billing' dir + 'envs.ts')
82
+ for (const [pkgName, pkgDir] of pkgMap) {
83
+ if (specifier.startsWith(pkgName + '/')) {
84
+ const subPath = specifier.slice(pkgName.length + 1);
85
+ for (const candidate of [
86
+ path.join(pkgDir, subPath + '.ts'),
87
+ path.join(pkgDir, subPath, 'index.ts'),
88
+ ]) {
89
+ if (fs.existsSync(candidate)) return candidate;
90
+ }
91
+ }
92
+ }
93
+
94
+ return null;
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Helpers
99
+ // ---------------------------------------------------------------------------
100
+
101
+ function groupNameFromSpecifier(specifier: string): string {
102
+ // '@kit/supabase-server/envs' → 'Supabase Server'
103
+ // '@kit/lemon-squeezy/envs' → 'Lemon Squeezy'
104
+ const match = specifier.match(/^@(?:[^/]+)\/([^/]+)/);
105
+ if (match) {
106
+ return match[1]
107
+ .replace(/-/g, ' ')
108
+ .replace(/\b\w/g, c => c.toUpperCase());
109
+ }
110
+ return specifier;
111
+ }
112
+
113
+ function cleanComment(raw: string): string {
114
+ return raw
115
+ .replace(/^\/\*\*?/, '')
116
+ .replace(/\*\/$/, '')
117
+ .split('\n')
118
+ .map(line => line.replace(/^\s*\*\s?/, '').trim())
119
+ .filter(Boolean)
120
+ .join('\n')
121
+ .trim();
122
+ }
123
+
124
+ function extractDefaultFromDescription(description: string): string | undefined {
125
+ const line = description
126
+ .split('\n')
127
+ .map(part => part.trim())
128
+ .find(part => /^@default(\s|$)/.test(part));
129
+
130
+ if (!line) return undefined;
131
+
132
+ const rawValue = line.replace(/^@default\s*/, '').trim();
133
+ if (!rawValue) return undefined;
134
+
135
+ // Support patterns like:
136
+ // @default "http://127.0.0.1:54321" for local dev purpose only
137
+ const quoted = rawValue.match(/(['"`])((?:\\.|(?!\1).)*)\1/);
138
+ if (quoted) return quoted[2];
139
+
140
+ // Fallback for unquoted defaults with trailing explanation.
141
+ const token = rawValue.split(/\s+/, 1)[0];
142
+ return token || undefined;
143
+ }
144
+
145
+ function extractDefaultFromInitializer(initializer: any): string | undefined {
146
+ if (!initializer) return undefined;
147
+
148
+ const calls = initializer.getDescendantsOfKind?.(SyntaxKind.CallExpression) ?? [];
149
+ for (const call of calls) {
150
+ const expr = call.getExpression?.();
151
+ if (!expr || expr.getKind() !== SyntaxKind.PropertyAccessExpression) continue;
152
+
153
+ const access = expr.asKind?.(SyntaxKind.PropertyAccessExpression);
154
+ if (!access || access.getName() !== 'default') continue;
155
+
156
+ const [arg] = call.getArguments?.() ?? [];
157
+ if (!arg) return undefined;
158
+
159
+ switch (arg.getKind()) {
160
+ case SyntaxKind.StringLiteral:
161
+ case SyntaxKind.NoSubstitutionTemplateLiteral:
162
+ return arg.getLiteralText?.() ?? arg.getText().replace(/^['"`]|['"`]$/g, '');
163
+ case SyntaxKind.NumericLiteral:
164
+ case SyntaxKind.BigIntLiteral:
165
+ return arg.getText();
166
+ case SyntaxKind.TrueKeyword:
167
+ return 'true';
168
+ case SyntaxKind.FalseKeyword:
169
+ return 'false';
170
+ case SyntaxKind.NullKeyword:
171
+ return 'null';
172
+ default:
173
+ return arg.getText?.();
174
+ }
175
+ }
176
+
177
+ return undefined;
178
+ }
179
+
180
+ function getRootIdentifier(node: any): string | null {
181
+ if (!node) return null;
182
+ switch (node.getKind()) {
183
+ case SyntaxKind.Identifier:
184
+ return node.getText();
185
+ case SyntaxKind.CallExpression:
186
+ return getRootIdentifier(node.getExpression());
187
+ case SyntaxKind.PropertyAccessExpression:
188
+ return getRootIdentifier(node.getExpression());
189
+ default:
190
+ return null;
191
+ }
192
+ }
193
+
194
+ // ---------------------------------------------------------------------------
195
+ // Extract env vars from a single createEnv(...) call
196
+ // ---------------------------------------------------------------------------
197
+
198
+ function extractVarsFromCreateEnvCall(callExpr: any, group: string): EnvVarInfo[] {
199
+ const args = callExpr.getArguments?.();
200
+ if (!args || args.length === 0) return [];
201
+
202
+ const configObj = args[0];
203
+ if (configObj.getKind() !== SyntaxKind.ObjectLiteralExpression) return [];
204
+
205
+ // runtimeEnv keys are the canonical env var names
206
+ const runtimeEnvProp = configObj
207
+ .getProperty?.('runtimeEnv')
208
+ ?.asKind?.(SyntaxKind.PropertyAssignment);
209
+ if (!runtimeEnvProp) return [];
210
+
211
+ const runtimeEnvObj = runtimeEnvProp
212
+ .getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);
213
+ if (!runtimeEnvObj) return [];
214
+
215
+ const varNames: string[] = [];
216
+ for (const prop of runtimeEnvObj.getProperties()) {
217
+ const kind = prop.getKind();
218
+ if (
219
+ kind === SyntaxKind.PropertyAssignment ||
220
+ kind === SyntaxKind.ShorthandPropertyAssignment
221
+ ) {
222
+ const name: string | undefined = (prop as any).getName?.();
223
+ if (name) varNames.push(name);
224
+ }
225
+ }
226
+
227
+ if (varNames.length === 0) return [];
228
+
229
+ // Cross-reference server + client for description and required flag
230
+ const metaMap = new Map<string, {
231
+ required: boolean;
232
+ description: string;
233
+ defaultValue?: string;
234
+ }>();
235
+
236
+ for (const sectionName of ['server', 'client'] as const) {
237
+ const sectionObj = configObj
238
+ .getProperty?.(sectionName)
239
+ ?.asKind?.(SyntaxKind.PropertyAssignment)
240
+ ?.getInitializerIfKind(SyntaxKind.ObjectLiteralExpression);
241
+ if (!sectionObj) continue;
242
+
243
+ for (const prop of sectionObj.getProperties()) {
244
+ if (prop.getKind() !== SyntaxKind.PropertyAssignment) continue;
245
+ const pa = prop.asKind?.(SyntaxKind.PropertyAssignment);
246
+ if (!pa) continue;
247
+
248
+ const name: string | undefined = pa.getName?.();
249
+ if (!name) continue;
250
+
251
+ const commentRanges = pa.getLeadingCommentRanges?.() ?? [];
252
+ const description = commentRanges
253
+ .map((r: any) => cleanComment(r.getText()))
254
+ .filter(Boolean)
255
+ .join('\n');
256
+ const defaultValue = extractDefaultFromInitializer(pa.getInitializer?.())
257
+ ?? extractDefaultFromDescription(description);
258
+
259
+ const initText: string = pa.getInitializer()?.getText() ?? '';
260
+ const required = !initText.includes('.optional()');
261
+
262
+ metaMap.set(name, { required, description, defaultValue });
263
+ }
264
+ }
265
+
266
+ return varNames.map(name => ({
267
+ name,
268
+ required: metaMap.get(name)?.required ?? true,
269
+ description: metaMap.get(name)?.description ?? '',
270
+ group,
271
+ defaultValue: metaMap.get(name)?.defaultValue,
272
+ }));
273
+ }
274
+
275
+ // ---------------------------------------------------------------------------
276
+ // Recursive file collector
277
+ // ---------------------------------------------------------------------------
278
+
279
+ /**
280
+ * Collect env vars from a file and its transitive dependencies.
281
+ *
282
+ * @param requestedIdentifiers
283
+ * - `null` → collect everything (used when entering a leaf file via re-export)
284
+ * - non-null Set → only process createEnv calls whose enclosing variable declaration
285
+ * name is in the set, and only follow re-exports that export one of them.
286
+ * This prevents aggregator files (e.g. @kit/billing/envs) from pulling
287
+ * in sibling exports that are not actually used (e.g. lemonSqueezyEnvs
288
+ * when only stripeEnvs is referenced).
289
+ */
290
+ export async function collectEnvVarsFromFile(
291
+ filePath: string,
292
+ pkgMap: Map<string, string>,
293
+ visited: Set<string>,
294
+ group: string,
295
+ requestedIdentifiers: Set<string> | null = null,
296
+ ): Promise<EnvVarInfo[]> {
297
+ if (visited.has(filePath) || !fs.existsSync(filePath)) return [];
298
+ visited.add(filePath);
299
+
300
+ const project = new Project({ skipAddingFilesFromTsConfig: true });
301
+ let sourceFile: any;
302
+ try {
303
+ sourceFile = project.addSourceFileAtPath(filePath);
304
+ } catch {
305
+ return [];
306
+ }
307
+
308
+ const fromDir = path.dirname(filePath);
309
+ const results: EnvVarInfo[] = [];
310
+
311
+ // ── Step 1: Follow re-exports ─────────────────────────────────────────
312
+ // e.g. `export { stripeEnvs } from '@kit/stripe/envs'`
313
+ // When requestedIdentifiers is set, only follow re-exports that export at
314
+ // least one of the requested identifiers. This is the key guard that stops
315
+ // lemonSqueezyEnvs / umamiEnvs from being collected when they aren't used.
316
+ for (const exportDec of sourceFile.getExportDeclarations()) {
317
+ if (!exportDec.hasModuleSpecifier?.()) continue;
318
+ const spec: string = exportDec.getModuleSpecifierValue?.() ?? '';
319
+ if (!spec) continue;
320
+
321
+ if (requestedIdentifiers && requestedIdentifiers.size > 0) {
322
+ const namedExports: string[] = (exportDec.getNamedExports?.() ?? [])
323
+ .map((e: any) => e.getName?.() ?? '');
324
+ if (!namedExports.some(n => requestedIdentifiers.has(n))) continue;
325
+ }
326
+
327
+ const resolved = resolveSpecifier(spec, fromDir, pkgMap);
328
+ if (!resolved) continue;
329
+
330
+ const subGroup = spec.startsWith('@') ? groupNameFromSpecifier(spec) : group;
331
+ // Entering the target of a re-export: the file is a leaf, collect everything.
332
+ results.push(...await collectEnvVarsFromFile(resolved, pkgMap, visited, subGroup, null));
333
+ }
334
+
335
+ // ── Step 2: Process createEnv(...) calls ──────────────────────────────
336
+ const calls: any[] = sourceFile
337
+ .getDescendantsOfKind(SyntaxKind.CallExpression)
338
+ .filter((c: any) => {
339
+ try { return c.getExpression().getText() === 'createEnv'; }
340
+ catch { return false; }
341
+ });
342
+
343
+ for (const call of calls) {
344
+ // When we have specific identifiers to look for, only process createEnv
345
+ // calls that belong to one of those identifiers. We detect ownership via
346
+ // the nearest enclosing VariableDeclaration (which holds the function or
347
+ // object that wraps this createEnv call).
348
+ if (requestedIdentifiers && requestedIdentifiers.size > 0) {
349
+ const enclosingVarName: string | undefined = call
350
+ .getFirstAncestorByKind?.(SyntaxKind.VariableDeclaration)
351
+ ?.getName?.();
352
+ // Skip if the enclosing var doesn't match, or if there is none.
353
+ if (!enclosingVarName || !requestedIdentifiers.has(enclosingVarName)) continue;
354
+ }
355
+
356
+ const args = call.getArguments?.();
357
+ if (!args || args.length === 0) continue;
358
+ const configObj = args[0];
359
+ if (configObj.getKind() !== SyntaxKind.ObjectLiteralExpression) continue;
360
+
361
+ // 2a. Follow `extends` entries first so the upstream packages appear in
362
+ // the template before this file's own local vars.
363
+ // Group entries by source module specifier so that multiple identifiers
364
+ // from the same module (e.g. utilsEnvs + sharedRouteEnvs from @kit/utils/envs)
365
+ // are collected in a single recursive call — otherwise the visited set would
366
+ // block the second identifier.
367
+ const extendsProp = configObj
368
+ .getProperty?.('extends')
369
+ ?.asKind?.(SyntaxKind.PropertyAssignment);
370
+
371
+ if (extendsProp) {
372
+ const extendsArr = extendsProp
373
+ .getInitializerIfKind(SyntaxKind.ArrayLiteralExpression);
374
+
375
+ if (extendsArr) {
376
+ // Map: moduleSpecifier → { subGroup, rootIds }
377
+ const byModule = new Map<string, { subGroup: string; rootIds: Set<string> }>();
378
+
379
+ for (const element of extendsArr.getElements()) {
380
+ const rootId = getRootIdentifier(element);
381
+ if (!rootId) continue;
382
+
383
+ let importDec: any;
384
+ try {
385
+ importDec = sourceFile.getImportDeclarations().find((imp: any) => {
386
+ try {
387
+ if (imp.getNamedImports().some(
388
+ (n: any) => n.getNameNode().getText() === rootId
389
+ )) return true;
390
+ if (imp.getDefaultImport()?.getText() === rootId) return true;
391
+ if (imp.getNamespaceImport()?.getText() === rootId) return true;
392
+ } catch { /* ignore */ }
393
+ return false;
394
+ });
395
+ } catch { /* ignore */ }
396
+
397
+ if (!importDec) continue;
398
+ const moduleSpec: string = importDec.getModuleSpecifierValue?.() ?? '';
399
+ if (!moduleSpec) continue;
400
+
401
+ if (!byModule.has(moduleSpec)) {
402
+ const subGroup = moduleSpec.startsWith('@')
403
+ ? groupNameFromSpecifier(moduleSpec)
404
+ : group;
405
+ byModule.set(moduleSpec, { subGroup, rootIds: new Set() });
406
+ }
407
+ byModule.get(moduleSpec)!.rootIds.add(rootId);
408
+ }
409
+
410
+ // Recurse once per source module, passing the set of identifiers
411
+ // we need from that module. This propagates the filtering all the
412
+ // way through aggregator files.
413
+ for (const [moduleSpec, { subGroup, rootIds }] of byModule.entries()) {
414
+ const resolvedModule = resolveSpecifier(moduleSpec, fromDir, pkgMap);
415
+ if (!resolvedModule) continue;
416
+ results.push(
417
+ ...await collectEnvVarsFromFile(
418
+ resolvedModule, pkgMap, visited, subGroup, rootIds,
419
+ )
420
+ );
421
+ }
422
+ }
423
+ }
424
+
425
+ // 2b. Extract this call's own runtimeEnv vars (after extends so that
426
+ // duplicates — e.g. EMAIL_FROM — are attributed to the upstream package).
427
+ results.push(...extractVarsFromCreateEnvCall(call, group));
428
+ }
429
+
430
+ return results;
431
+ }
432
+
433
+ // ---------------------------------------------------------------------------
434
+ // Template generator
435
+ // ---------------------------------------------------------------------------
436
+
437
+ export function generateEnvTemplate(vars: EnvVarInfo[]): string {
438
+ // Deduplicate by name – first occurrence wins (upstream package definition
439
+ // takes priority over the dashboard's re-declaration of the same var).
440
+ const seen = new Set<string>();
441
+ const unique: EnvVarInfo[] = [];
442
+ for (const v of vars) {
443
+ if (!seen.has(v.name)) {
444
+ seen.add(v.name);
445
+ unique.push(v);
446
+ }
447
+ }
448
+
449
+ // Group by source package, preserving insertion order
450
+ const groups = new Map<string, EnvVarInfo[]>();
451
+ for (const v of unique) {
452
+ if (!groups.has(v.group)) groups.set(v.group, []);
453
+ groups.get(v.group)!.push(v);
454
+ }
455
+
456
+ const lines: string[] = [
457
+ '# =============================================================================',
458
+ '# Dashboard Environment Variables',
459
+ '# Generated by Creatorem CLI',
460
+ '# =============================================================================',
461
+ '',
462
+ ];
463
+
464
+ for (const [group, groupVars] of groups) {
465
+ const bar = '─'.repeat(Math.max(2, 76 - group.length));
466
+ lines.push(`# ── ${group} ${bar}`);
467
+ lines.push('');
468
+
469
+ for (const v of groupVars) {
470
+ if (v.description) {
471
+ for (const line of v.description.split('\n')) {
472
+ if (line.trim()) lines.push(`# ${line}`);
473
+ }
474
+ }
475
+ if (!v.required) lines.push('# optional');
476
+ lines.push(`${v.name}=${v.defaultValue ?? ''}`);
477
+ lines.push('');
478
+ }
479
+ }
480
+
481
+ return lines.join('\n');
482
+ }
483
+
484
+ // ---------------------------------------------------------------------------
485
+ // Main entry point
486
+ // ---------------------------------------------------------------------------
487
+
488
+ /**
489
+ * Parse `apps/dashboard/envs.ts` (after feature removals have been applied),
490
+ * recursively follow only the env definitions that are actually referenced in
491
+ * the extends array, and write `.env.template` into the dashboard root.
492
+ */
493
+ export interface GenerateEnvTemplateOptions {
494
+ createEnvFile?: boolean;
495
+ fallbackMonoRoot?: string;
496
+ }
497
+
498
+ function isWithinRoot(root: string, target: string): boolean {
499
+ const rel = path.relative(root, target);
500
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
501
+ }
502
+
503
+ function isCreatoreMonorepoRoot(root: string): boolean {
504
+ return (
505
+ fs.existsSync(path.join(root, 'pnpm-workspace.yaml')) &&
506
+ fs.existsSync(path.join(root, 'turbo.json')) &&
507
+ fs.existsSync(path.join(root, 'apps')) &&
508
+ fs.existsSync(path.join(root, 'kit'))
509
+ );
510
+ }
511
+
512
+ function findNearestCreatoreMonorepoRoot(startDir: string): string | null {
513
+ let current = path.resolve(startDir);
514
+
515
+ while (true) {
516
+ if (isCreatoreMonorepoRoot(current)) return current;
517
+ const parent = path.dirname(current);
518
+ if (parent === current) return null;
519
+ current = parent;
520
+ }
521
+ }
522
+
523
+ function getImplicitFallbackRoots(): string[] {
524
+ const roots = new Set<string>();
525
+ const addNearestRoot = (startDir: string) => {
526
+ const root = findNearestCreatoreMonorepoRoot(startDir);
527
+ if (root) roots.add(root);
528
+ };
529
+
530
+ addNearestRoot(process.cwd());
531
+
532
+ try {
533
+ const moduleDir = path.dirname(fileURLToPath(import.meta.url));
534
+ addNearestRoot(moduleDir);
535
+ } catch {
536
+ // Ignore when module path cannot be resolved in non-standard runtimes.
537
+ }
538
+
539
+ return Array.from(roots);
540
+ }
541
+
542
+ function mergeEnvVarMetadata(vars: EnvVarInfo[], fallbackVars: EnvVarInfo[]): EnvVarInfo[] {
543
+ const fallbackByName = new Map<string, { description: string; defaultValue?: string }>();
544
+
545
+ for (const v of fallbackVars) {
546
+ const existing = fallbackByName.get(v.name);
547
+ if (!existing) {
548
+ fallbackByName.set(v.name, {
549
+ description: v.description,
550
+ defaultValue: v.defaultValue,
551
+ });
552
+ continue;
553
+ }
554
+
555
+ fallbackByName.set(v.name, {
556
+ description: existing.description || v.description,
557
+ defaultValue: existing.defaultValue ?? v.defaultValue,
558
+ });
559
+ }
560
+
561
+ return vars.map(v => {
562
+ const fallback = fallbackByName.get(v.name);
563
+ if (!fallback) return v;
564
+
565
+ return {
566
+ ...v,
567
+ description: v.description || fallback.description,
568
+ defaultValue: v.defaultValue ?? fallback.defaultValue,
569
+ };
570
+ });
571
+ }
572
+
573
+ export async function generateDashboardEnvTemplate(
574
+ dashboardRoot: string,
575
+ monoRoot: string,
576
+ options: GenerateEnvTemplateOptions = {},
577
+ ): Promise<void> {
578
+ const envsPath = path.join(dashboardRoot, 'envs.ts');
579
+ if (!fs.existsSync(envsPath)) return;
580
+
581
+ const pkgMap = await buildPackageMap(monoRoot);
582
+ const visited = new Set<string>();
583
+ // Initial call: null requestedIdentifiers → collect the dashboard file in full
584
+ const vars = await collectEnvVarsFromFile(envsPath, pkgMap, visited, 'Dashboard', null);
585
+
586
+ let mergedVars = vars;
587
+ const resolvedMonoRoot = path.resolve(monoRoot);
588
+ const resolvedDashboardRoot = path.resolve(dashboardRoot);
589
+
590
+ if (isWithinRoot(resolvedMonoRoot, resolvedDashboardRoot)) {
591
+ const relativeAppPath = path.relative(resolvedMonoRoot, resolvedDashboardRoot);
592
+ const fallbackCandidates = [
593
+ options.fallbackMonoRoot,
594
+ ...getImplicitFallbackRoots(),
595
+ ]
596
+ .filter((candidate): candidate is string => Boolean(candidate))
597
+ .map(candidate => path.resolve(candidate))
598
+ .filter(candidate => candidate !== resolvedMonoRoot);
599
+
600
+ const seenFallbackRoots = new Set<string>();
601
+
602
+ for (const fallbackRoot of fallbackCandidates) {
603
+ if (seenFallbackRoots.has(fallbackRoot)) continue;
604
+ seenFallbackRoots.add(fallbackRoot);
605
+
606
+ const fallbackAppRoot = path.join(fallbackRoot, relativeAppPath);
607
+ const fallbackEnvsPath = path.join(fallbackAppRoot, 'envs.ts');
608
+ if (!await fs.pathExists(fallbackEnvsPath)) continue;
609
+
610
+ const fallbackPkgMap = await buildPackageMap(fallbackRoot);
611
+ const fallbackVisited = new Set<string>();
612
+ const fallbackVars = await collectEnvVarsFromFile(
613
+ fallbackEnvsPath,
614
+ fallbackPkgMap,
615
+ fallbackVisited,
616
+ 'Dashboard',
617
+ null,
618
+ );
619
+
620
+ mergedVars = mergeEnvVarMetadata(mergedVars, fallbackVars);
621
+ }
622
+ }
623
+
624
+ const content = generateEnvTemplate(mergedVars);
625
+ const templatePath = path.join(dashboardRoot, '.env.template');
626
+ await fs.writeFile(templatePath, content, 'utf-8');
627
+
628
+ if (options.createEnvFile) {
629
+ const envPath = path.join(dashboardRoot, '.env');
630
+ const envExists = await fs.pathExists(envPath);
631
+ if (!envExists) {
632
+ await fs.copy(templatePath, envPath);
633
+ }
634
+ }
635
+ }