@creatorem/cli 0.0.1 → 1.0.5
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 +148 -0
- package/dist/cli.js +74992 -0
- package/dist/cli.js.map +1 -0
- package/package.json +26 -5
- package/src/cli.tsx +141 -0
- package/src/commands/create-dashboard.tsx +455 -0
- package/src/commands/create-mobile.tsx +555 -0
- package/src/commands/create.tsx +1119 -0
- package/src/commands/generate-migration.mjs +17 -66
- package/src/commands/generate-migration.tsx +46 -0
- package/src/commands/generate-schemas.mjs +2 -2
- package/src/commands/generate-schemas.tsx +36 -0
- package/src/dashboard-features/ai/index.ts +102 -0
- package/src/dashboard-features/analytics/index.ts +31 -0
- package/src/dashboard-features/billing/index.ts +349 -0
- package/src/dashboard-features/content-type/index.ts +64 -0
- package/src/dashboard-features/email-templates/index.ts +17 -0
- package/src/dashboard-features/emailer/index.ts +27 -0
- package/src/dashboard-features/index.ts +28 -0
- package/src/dashboard-features/keybindings/index.ts +52 -0
- package/src/dashboard-features/manager.ts +349 -0
- package/src/dashboard-features/monitoring/index.ts +16 -0
- package/src/dashboard-features/notification/index.ts +40 -0
- package/src/dashboard-features/onboarding/index.ts +65 -0
- package/src/dashboard-features/organization/index.ts +38 -0
- package/src/dashboard-features/types.ts +41 -0
- package/src/mobile-features/index.ts +12 -0
- package/src/mobile-features/manager.ts +1 -0
- package/src/mobile-features/notification/index.ts +41 -0
- package/src/mobile-features/onboarding/index.ts +35 -0
- package/src/mobile-features/organization/index.ts +38 -0
- package/src/mobile-features/types.ts +1 -0
- package/src/shims/signal-exit.js +32 -0
- package/src/ui/app.tsx +68 -0
- package/src/ui/multi-select.tsx +106 -0
- package/src/utils/ast.ts +422 -0
- package/src/utils/env-template.ts +635 -0
- package/tests/test-cli-features.sh +81 -0
- package/tests/test-cli-mobile.sh +65 -0
- package/tsconfig.json +15 -0
- package/tsup.config.ts +21 -0
- 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
|
+
}
|