@geekmidas/cli 0.29.0 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/{config-BhryDQEq.cjs → config-BAE9LFC1.cjs} +2 -2
  2. package/dist/{config-BhryDQEq.cjs.map → config-BAE9LFC1.cjs.map} +1 -1
  3. package/dist/{config-C9bdq0l-.mjs → config-BC5n1a2D.mjs} +2 -2
  4. package/dist/{config-C9bdq0l-.mjs.map → config-BC5n1a2D.mjs.map} +1 -1
  5. package/dist/config.cjs +2 -2
  6. package/dist/config.d.cts +1 -1
  7. package/dist/config.d.mts +1 -1
  8. package/dist/config.mjs +2 -2
  9. package/dist/{index-CWN-bgrO.d.mts → index-C7TkoYmt.d.mts} +5 -1
  10. package/dist/index-C7TkoYmt.d.mts.map +1 -0
  11. package/dist/{index-DEWYvYvg.d.cts → index-CpchsC9w.d.cts} +5 -1
  12. package/dist/index-CpchsC9w.d.cts.map +1 -0
  13. package/dist/index.cjs +58 -9
  14. package/dist/index.cjs.map +1 -1
  15. package/dist/index.mjs +58 -9
  16. package/dist/index.mjs.map +1 -1
  17. package/dist/{openapi-BCEFhkLh.mjs → openapi-CjYeF-Tg.mjs} +2 -2
  18. package/dist/{openapi-BCEFhkLh.mjs.map → openapi-CjYeF-Tg.mjs.map} +1 -1
  19. package/dist/{openapi-D82bBqG7.cjs → openapi-a-e3Y8WA.cjs} +2 -2
  20. package/dist/{openapi-D82bBqG7.cjs.map → openapi-a-e3Y8WA.cjs.map} +1 -1
  21. package/dist/openapi.cjs +3 -3
  22. package/dist/openapi.mjs +3 -3
  23. package/dist/workspace/index.cjs +1 -1
  24. package/dist/workspace/index.d.cts +1 -1
  25. package/dist/workspace/index.d.mts +1 -1
  26. package/dist/workspace/index.mjs +1 -1
  27. package/dist/{workspace-DQjmv9lk.mjs → workspace-DFJ3sWfY.mjs} +19 -3
  28. package/dist/{workspace-DQjmv9lk.mjs.map → workspace-DFJ3sWfY.mjs.map} +1 -1
  29. package/dist/{workspace-CiZBOjf9.cjs → workspace-My0A4IRO.cjs} +19 -3
  30. package/dist/{workspace-CiZBOjf9.cjs.map → workspace-My0A4IRO.cjs.map} +1 -1
  31. package/package.json +4 -4
  32. package/src/dev/__tests__/entry.spec.ts +140 -0
  33. package/src/dev/__tests__/index.spec.ts +223 -0
  34. package/src/dev/index.ts +329 -5
  35. package/src/index.ts +30 -16
  36. package/src/init/__tests__/generators.spec.ts +17 -9
  37. package/src/init/versions.ts +1 -1
  38. package/src/workspace/__tests__/schema.spec.ts +114 -0
  39. package/src/workspace/schema.ts +23 -1
  40. package/tsconfig.tsbuildinfo +1 -1
  41. package/dist/index-CWN-bgrO.d.mts.map +0 -1
  42. package/dist/index-DEWYvYvg.d.cts.map +0 -1
package/src/dev/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { type ChildProcess, execSync, spawn } from 'node:child_process';
2
2
  import { existsSync } from 'node:fs';
3
- import { mkdir } from 'node:fs/promises';
3
+ import { mkdir, writeFile } from 'node:fs/promises';
4
4
  import { createServer } from 'node:net';
5
- import { join, resolve } from 'node:path';
5
+ import { dirname, join, resolve } from 'node:path';
6
6
  import chokidar from 'chokidar';
7
7
  import { config as dotenvConfig } from 'dotenv';
8
8
  import fg from 'fast-glob';
@@ -304,9 +304,18 @@ export interface DevOptions {
304
304
  app?: string;
305
305
  /** Filter apps by pattern (passed to turbo --filter) */
306
306
  filter?: string;
307
+ /** Entry file to run (bypasses gkm config) */
308
+ entry?: string;
309
+ /** Watch for file changes (default: true with --entry) */
310
+ watch?: boolean;
307
311
  }
308
312
 
309
313
  export async function devCommand(options: DevOptions): Promise<void> {
314
+ // Handle --entry mode: run any file with secret injection
315
+ if (options.entry) {
316
+ return entryDevCommand(options);
317
+ }
318
+
310
319
  // Load default .env file BEFORE loading config
311
320
  // This ensures env vars are available when config and its dependencies are loaded
312
321
  const defaultEnv = loadEnvFiles('.env');
@@ -318,6 +327,8 @@ export async function devCommand(options: DevOptions): Promise<void> {
318
327
  const appName = getAppNameFromCwd();
319
328
  let config: GkmConfig;
320
329
  let appRoot: string = process.cwd();
330
+ let secretsRoot: string = process.cwd(); // Where .gkm/secrets/ lives
331
+ let workspaceAppName: string | undefined; // Set if in workspace mode
321
332
 
322
333
  if (appName) {
323
334
  // Try to load app-specific config from workspace
@@ -325,6 +336,8 @@ export async function devCommand(options: DevOptions): Promise<void> {
325
336
  const appConfig = await loadAppConfig();
326
337
  config = appConfig.gkmConfig;
327
338
  appRoot = appConfig.appRoot;
339
+ secretsRoot = appConfig.workspaceRoot;
340
+ workspaceAppName = appConfig.appName;
328
341
  logger.log(`📦 Running app: ${appConfig.appName}`);
329
342
  } catch {
330
343
  // Not in a workspace or app not found in workspace - fall back to regular loading
@@ -438,6 +451,17 @@ export async function devCommand(options: DevOptions): Promise<void> {
438
451
  // Determine runtime (default to node)
439
452
  const runtime: Runtime = config.runtime ?? 'node';
440
453
 
454
+ // Load secrets for dev mode and write to JSON file
455
+ let secretsJsonPath: string | undefined;
456
+ const appSecrets = await loadSecretsForApp(secretsRoot, workspaceAppName);
457
+ if (Object.keys(appSecrets).length > 0) {
458
+ const secretsDir = join(secretsRoot, '.gkm');
459
+ await mkdir(secretsDir, { recursive: true });
460
+ secretsJsonPath = join(secretsDir, 'dev-secrets.json');
461
+ await writeFile(secretsJsonPath, JSON.stringify(appSecrets, null, 2));
462
+ logger.log(`🔐 Loaded ${Object.keys(appSecrets).length} secret(s)`);
463
+ }
464
+
441
465
  // Start the dev server
442
466
  const devServer = new DevServer(
443
467
  resolved.providers[0] as LegacyProvider,
@@ -448,6 +472,7 @@ export async function devCommand(options: DevOptions): Promise<void> {
448
472
  studio,
449
473
  runtime,
450
474
  appRoot,
475
+ secretsJsonPath,
451
476
  );
452
477
 
453
478
  await devServer.start();
@@ -754,6 +779,54 @@ export async function loadDevSecrets(
754
779
  return {};
755
780
  }
756
781
 
782
+ /**
783
+ * Load secrets from a path for dev mode.
784
+ * For single app: returns secrets as-is.
785
+ * For workspace app: maps {APP}_DATABASE_URL → DATABASE_URL.
786
+ * @internal Exported for testing
787
+ */
788
+ export async function loadSecretsForApp(
789
+ secretsRoot: string,
790
+ appName?: string,
791
+ ): Promise<Record<string, string>> {
792
+ // Try 'dev' stage first, then 'development'
793
+ const stages = ['dev', 'development'];
794
+
795
+ let secrets: Record<string, string> = {};
796
+
797
+ for (const stage of stages) {
798
+ if (secretsExist(stage, secretsRoot)) {
799
+ const stageSecrets = await readStageSecrets(stage, secretsRoot);
800
+ if (stageSecrets) {
801
+ logger.log(`🔐 Loading secrets from stage: ${stage}`);
802
+ secrets = toEmbeddableSecrets(stageSecrets);
803
+ break;
804
+ }
805
+ }
806
+ }
807
+
808
+ if (Object.keys(secrets).length === 0) {
809
+ return {};
810
+ }
811
+
812
+ // Single app mode - no mapping needed
813
+ if (!appName) {
814
+ return secrets;
815
+ }
816
+
817
+ // Workspace app mode - map {APP}_* to generic names
818
+ const prefix = appName.toUpperCase();
819
+ const mapped = { ...secrets };
820
+
821
+ // Map {APP}_DATABASE_URL → DATABASE_URL
822
+ const appDbUrl = secrets[`${prefix}_DATABASE_URL`];
823
+ if (appDbUrl) {
824
+ mapped.DATABASE_URL = appDbUrl;
825
+ }
826
+
827
+ return mapped;
828
+ }
829
+
757
830
  /**
758
831
  * Start docker-compose services for the workspace.
759
832
  * @internal Exported for testing
@@ -1178,6 +1251,242 @@ async function buildServer(
1178
1251
  ]);
1179
1252
  }
1180
1253
 
1254
+ /**
1255
+ * Find the directory containing .gkm/secrets/.
1256
+ * Walks up from cwd until it finds one, or returns cwd.
1257
+ * @internal Exported for testing
1258
+ */
1259
+ export function findSecretsRoot(startDir: string): string {
1260
+ let dir = startDir;
1261
+ while (dir !== '/') {
1262
+ if (existsSync(join(dir, '.gkm', 'secrets'))) {
1263
+ return dir;
1264
+ }
1265
+ const parent = dirname(dir);
1266
+ if (parent === dir) break;
1267
+ dir = parent;
1268
+ }
1269
+ return startDir;
1270
+ }
1271
+
1272
+ /**
1273
+ * Create a wrapper script that injects secrets before importing the entry file.
1274
+ * @internal Exported for testing
1275
+ */
1276
+ export async function createEntryWrapper(
1277
+ wrapperPath: string,
1278
+ entryPath: string,
1279
+ secretsJsonPath?: string,
1280
+ ): Promise<void> {
1281
+ const credentialsInjection = secretsJsonPath
1282
+ ? `import { Credentials } from '@geekmidas/envkit/credentials';
1283
+ import { existsSync, readFileSync } from 'node:fs';
1284
+
1285
+ // Inject dev secrets into Credentials (before app import)
1286
+ const secretsPath = '${secretsJsonPath}';
1287
+ if (existsSync(secretsPath)) {
1288
+ Object.assign(Credentials, JSON.parse(readFileSync(secretsPath, 'utf-8')));
1289
+ }
1290
+
1291
+ `
1292
+ : '';
1293
+
1294
+ const content = `#!/usr/bin/env node
1295
+ /**
1296
+ * Entry wrapper generated by 'gkm dev --entry'
1297
+ */
1298
+ ${credentialsInjection}// Import and run the user's entry file
1299
+ import '${entryPath}';
1300
+ `;
1301
+
1302
+ await writeFile(wrapperPath, content);
1303
+ }
1304
+
1305
+ /**
1306
+ * Run any TypeScript file with secret injection.
1307
+ * Does not require gkm.config.ts.
1308
+ */
1309
+ async function entryDevCommand(options: DevOptions): Promise<void> {
1310
+ const { entry, port = 3000, watch = true } = options;
1311
+
1312
+ if (!entry) {
1313
+ throw new Error('--entry requires a file path');
1314
+ }
1315
+
1316
+ const entryPath = resolve(process.cwd(), entry);
1317
+
1318
+ if (!existsSync(entryPath)) {
1319
+ throw new Error(`Entry file not found: ${entryPath}`);
1320
+ }
1321
+
1322
+ logger.log(`🚀 Starting entry file: ${entry}`);
1323
+
1324
+ // Load .env files
1325
+ const defaultEnv = loadEnvFiles('.env');
1326
+ if (defaultEnv.loaded.length > 0) {
1327
+ logger.log(`📦 Loaded env: ${defaultEnv.loaded.join(', ')}`);
1328
+ }
1329
+
1330
+ // Determine secrets root (current dir or workspace root)
1331
+ const secretsRoot = findSecretsRoot(process.cwd());
1332
+
1333
+ // Determine app name for per-app secret mapping
1334
+ const appName = getAppNameFromCwd() ?? undefined;
1335
+
1336
+ // Load secrets
1337
+ const appSecrets = await loadSecretsForApp(secretsRoot, appName);
1338
+ if (Object.keys(appSecrets).length > 0) {
1339
+ logger.log(`🔐 Loaded ${Object.keys(appSecrets).length} secret(s)`);
1340
+ }
1341
+
1342
+ // Write secrets to temp JSON file
1343
+ let secretsJsonPath: string | undefined;
1344
+ if (Object.keys(appSecrets).length > 0) {
1345
+ const secretsDir = join(secretsRoot, '.gkm');
1346
+ await mkdir(secretsDir, { recursive: true });
1347
+ secretsJsonPath = join(secretsDir, 'dev-secrets.json');
1348
+ await writeFile(secretsJsonPath, JSON.stringify(appSecrets, null, 2));
1349
+ }
1350
+
1351
+ // Create wrapper entry that injects secrets before importing user's file
1352
+ const wrapperDir = join(process.cwd(), '.gkm');
1353
+ await mkdir(wrapperDir, { recursive: true });
1354
+ const wrapperPath = join(wrapperDir, 'entry-wrapper.ts');
1355
+ await createEntryWrapper(wrapperPath, entryPath, secretsJsonPath);
1356
+
1357
+ // Start with tsx
1358
+ const runner = new EntryRunner(wrapperPath, entryPath, watch, port);
1359
+ await runner.start();
1360
+
1361
+ // Handle graceful shutdown
1362
+ let isShuttingDown = false;
1363
+ const shutdown = () => {
1364
+ if (isShuttingDown) return;
1365
+ isShuttingDown = true;
1366
+
1367
+ logger.log('\n🛑 Shutting down...');
1368
+ runner.stop();
1369
+ process.exit(0);
1370
+ };
1371
+
1372
+ process.on('SIGINT', shutdown);
1373
+ process.on('SIGTERM', shutdown);
1374
+
1375
+ // Keep the process alive
1376
+ await new Promise(() => {});
1377
+ }
1378
+
1379
+ /**
1380
+ * Runs and watches a TypeScript entry file using tsx.
1381
+ */
1382
+ class EntryRunner {
1383
+ private childProcess: ChildProcess | null = null;
1384
+ private watcher: ReturnType<typeof chokidar.watch> | null = null;
1385
+ private isRunning = false;
1386
+
1387
+ constructor(
1388
+ private wrapperPath: string,
1389
+ private entryPath: string,
1390
+ private watch: boolean,
1391
+ private port: number,
1392
+ ) {}
1393
+
1394
+ async start(): Promise<void> {
1395
+ await this.runProcess();
1396
+
1397
+ if (this.watch) {
1398
+ // Watch the entry file's directory for changes
1399
+ const watchDir = dirname(this.entryPath);
1400
+
1401
+ this.watcher = chokidar.watch(watchDir, {
1402
+ ignored: /(^|[/\\])\../,
1403
+ persistent: true,
1404
+ ignoreInitial: true,
1405
+ });
1406
+
1407
+ let restartTimeout: NodeJS.Timeout | null = null;
1408
+
1409
+ this.watcher.on('change', (path) => {
1410
+ logger.log(`📝 File changed: ${path}`);
1411
+
1412
+ // Debounce restarts
1413
+ if (restartTimeout) {
1414
+ clearTimeout(restartTimeout);
1415
+ }
1416
+
1417
+ restartTimeout = setTimeout(async () => {
1418
+ logger.log('🔄 Restarting...');
1419
+ await this.restart();
1420
+ }, 300);
1421
+ });
1422
+
1423
+ logger.log(`👀 Watching for changes in: ${watchDir}`);
1424
+ }
1425
+ }
1426
+
1427
+ private async runProcess(): Promise<void> {
1428
+ // Pass PORT as environment variable
1429
+ const env = { ...process.env, PORT: String(this.port) };
1430
+
1431
+ this.childProcess = spawn('npx', ['tsx', this.wrapperPath], {
1432
+ stdio: 'inherit',
1433
+ env,
1434
+ detached: true,
1435
+ });
1436
+
1437
+ this.isRunning = true;
1438
+
1439
+ this.childProcess.on('error', (error) => {
1440
+ logger.error('❌ Process error:', error);
1441
+ });
1442
+
1443
+ this.childProcess.on('exit', (code) => {
1444
+ if (code !== null && code !== 0 && code !== 143) {
1445
+ // 143 = SIGTERM
1446
+ logger.error(`❌ Process exited with code ${code}`);
1447
+ }
1448
+ this.isRunning = false;
1449
+ });
1450
+
1451
+ // Give the process a moment to start
1452
+ await new Promise((resolve) => setTimeout(resolve, 500));
1453
+
1454
+ if (this.isRunning) {
1455
+ logger.log(`\n🎉 Running at http://localhost:${this.port}`);
1456
+ }
1457
+ }
1458
+
1459
+ async restart(): Promise<void> {
1460
+ this.stopProcess();
1461
+ await new Promise((resolve) => setTimeout(resolve, 500));
1462
+ await this.runProcess();
1463
+ }
1464
+
1465
+ stop(): void {
1466
+ this.watcher?.close();
1467
+ this.stopProcess();
1468
+ }
1469
+
1470
+ private stopProcess(): void {
1471
+ if (this.childProcess && this.isRunning) {
1472
+ const pid = this.childProcess.pid;
1473
+ if (pid) {
1474
+ try {
1475
+ process.kill(-pid, 'SIGTERM');
1476
+ } catch {
1477
+ try {
1478
+ process.kill(pid, 'SIGTERM');
1479
+ } catch {
1480
+ // Process already dead
1481
+ }
1482
+ }
1483
+ }
1484
+ this.childProcess = null;
1485
+ this.isRunning = false;
1486
+ }
1487
+ }
1488
+ }
1489
+
1181
1490
  class DevServer {
1182
1491
  private serverProcess: ChildProcess | null = null;
1183
1492
  private isRunning = false;
@@ -1192,6 +1501,7 @@ class DevServer {
1192
1501
  private studio: NormalizedStudioConfig | undefined,
1193
1502
  private runtime: Runtime = 'node',
1194
1503
  private appRoot: string = process.cwd(),
1504
+ private secretsJsonPath?: string,
1195
1505
  ) {
1196
1506
  this.actualPort = requestedPort;
1197
1507
  }
@@ -1341,7 +1651,7 @@ class DevServer {
1341
1651
  }
1342
1652
 
1343
1653
  private async createServerEntry(): Promise<void> {
1344
- const { writeFile } = await import('node:fs/promises');
1654
+ const { writeFile: fsWriteFile } = await import('node:fs/promises');
1345
1655
  const { relative, dirname } = await import('node:path');
1346
1656
 
1347
1657
  const serverPath = join(this.appRoot, '.gkm', this.provider, 'server.ts');
@@ -1351,6 +1661,20 @@ class DevServer {
1351
1661
  join(dirname(serverPath), 'app.js'),
1352
1662
  );
1353
1663
 
1664
+ // Generate credentials injection code if secrets are available
1665
+ const credentialsInjection = this.secretsJsonPath
1666
+ ? `import { Credentials } from '@geekmidas/envkit/credentials';
1667
+ import { existsSync, readFileSync } from 'node:fs';
1668
+
1669
+ // Inject dev secrets into Credentials (must happen before app import)
1670
+ const secretsPath = '${this.secretsJsonPath}';
1671
+ if (existsSync(secretsPath)) {
1672
+ Object.assign(Credentials, JSON.parse(readFileSync(secretsPath, 'utf-8')));
1673
+ }
1674
+
1675
+ `
1676
+ : '';
1677
+
1354
1678
  const serveCode =
1355
1679
  this.runtime === 'bun'
1356
1680
  ? `Bun.serve({
@@ -1374,7 +1698,7 @@ class DevServer {
1374
1698
  * Development server entry point
1375
1699
  * This file is auto-generated by 'gkm dev'
1376
1700
  */
1377
- import { createApp } from './${relativeAppPath.startsWith('.') ? relativeAppPath : `./${relativeAppPath}`}';
1701
+ ${credentialsInjection}import { createApp } from './${relativeAppPath.startsWith('.') ? relativeAppPath : `./${relativeAppPath}`}';
1378
1702
 
1379
1703
  const port = process.argv.includes('--port')
1380
1704
  ? Number.parseInt(process.argv[process.argv.indexOf('--port') + 1])
@@ -1395,6 +1719,6 @@ start({
1395
1719
  });
1396
1720
  `;
1397
1721
 
1398
- await writeFile(serverPath, content);
1722
+ await fsWriteFile(serverPath, content);
1399
1723
  }
1400
1724
  }
package/src/index.ts CHANGED
@@ -136,28 +136,42 @@ program
136
136
  .command('dev')
137
137
  .description('Start development server with automatic reload')
138
138
  .option('-p, --port <port>', 'Port to run the development server on')
139
+ .option('--entry <file>', 'Entry file to run (bypasses gkm config)')
140
+ .option('--watch', 'Watch for file changes (default: true with --entry)')
141
+ .option('--no-watch', 'Disable file watching')
139
142
  .option(
140
143
  '--enable-openapi',
141
144
  'Enable OpenAPI documentation for development server',
142
145
  true,
143
146
  )
144
- .action(async (options: { port?: string; enableOpenapi?: boolean }) => {
145
- try {
146
- const globalOptions = program.opts();
147
- if (globalOptions.cwd) {
148
- process.chdir(globalOptions.cwd);
149
- }
147
+ .action(
148
+ async (options: {
149
+ port?: string;
150
+ entry?: string;
151
+ watch?: boolean;
152
+ enableOpenapi?: boolean;
153
+ }) => {
154
+ try {
155
+ const globalOptions = program.opts();
156
+ if (globalOptions.cwd) {
157
+ process.chdir(globalOptions.cwd);
158
+ }
150
159
 
151
- await devCommand({
152
- port: options.port ? Number.parseInt(options.port, 10) : 3000,
153
- portExplicit: !!options.port,
154
- enableOpenApi: options.enableOpenapi ?? true,
155
- });
156
- } catch (error) {
157
- console.error(error instanceof Error ? error.message : 'Command failed');
158
- process.exit(1);
159
- }
160
- });
160
+ await devCommand({
161
+ port: options.port ? Number.parseInt(options.port, 10) : 3000,
162
+ portExplicit: !!options.port,
163
+ enableOpenApi: options.enableOpenapi ?? true,
164
+ entry: options.entry,
165
+ watch: options.watch,
166
+ });
167
+ } catch (error) {
168
+ console.error(
169
+ error instanceof Error ? error.message : 'Command failed',
170
+ );
171
+ process.exit(1);
172
+ }
173
+ },
174
+ );
161
175
 
162
176
  program
163
177
  .command('test')
@@ -15,9 +15,14 @@ const baseOptions: TemplateOptions = {
15
15
  template: 'minimal',
16
16
  telescope: true,
17
17
  database: true,
18
- routeStyle: 'file-based',
18
+ studio: true,
19
+ loggerType: 'pino',
20
+ routesStructure: 'centralized-endpoints',
19
21
  monorepo: false,
20
22
  apiPath: '',
23
+ packageManager: 'pnpm',
24
+ deployTarget: 'dokploy',
25
+ services: { db: true, cache: true, mail: false },
21
26
  };
22
27
 
23
28
  describe('generatePackageJson', () => {
@@ -161,7 +166,8 @@ describe('generateConfigFiles', () => {
161
166
  };
162
167
  const files = generateConfigFiles(options, minimalTemplate);
163
168
  const tsConfig = files.find((f) => f.path === 'tsconfig.json');
164
- const config = JSON.parse(tsConfig?.content);
169
+ expect(tsConfig).toBeDefined();
170
+ const config = JSON.parse(tsConfig!.content);
165
171
  expect(config.extends).toBe('../../tsconfig.json');
166
172
  expect(config.compilerOptions.paths).toBeDefined();
167
173
  expect(config.compilerOptions.paths['@test-project/*']).toBeDefined();
@@ -274,7 +280,8 @@ describe('generateMonorepoFiles', () => {
274
280
  };
275
281
  const files = generateMonorepoFiles(options, minimalTemplate);
276
282
  const pkgJson = files.find((f) => f.path === 'package.json');
277
- const pkg = JSON.parse(pkgJson?.content);
283
+ expect(pkgJson).toBeDefined();
284
+ const pkg = JSON.parse(pkgJson!.content);
278
285
  expect(pkg.scripts.dev).toBe('turbo dev');
279
286
  expect(pkg.scripts.build).toBe('turbo build');
280
287
  expect(pkg.scripts.lint).toBe('biome lint .');
@@ -311,7 +318,8 @@ describe('generateModelsPackage', () => {
311
318
  const pkgJson = files.find(
312
319
  (f) => f.path === 'packages/models/package.json',
313
320
  );
314
- const pkg = JSON.parse(pkgJson?.content);
321
+ expect(pkgJson).toBeDefined();
322
+ const pkg = JSON.parse(pkgJson!.content);
315
323
  expect(pkg.name).toBe('@test-project/models');
316
324
  });
317
325
 
@@ -325,7 +333,8 @@ describe('generateModelsPackage', () => {
325
333
  const pkgJson = files.find(
326
334
  (f) => f.path === 'packages/models/package.json',
327
335
  );
328
- const pkg = JSON.parse(pkgJson?.content);
336
+ expect(pkgJson).toBeDefined();
337
+ const pkg = JSON.parse(pkgJson!.content);
329
338
  expect(pkg.dependencies.zod).toBeDefined();
330
339
  });
331
340
 
@@ -336,9 +345,7 @@ describe('generateModelsPackage', () => {
336
345
  apiPath: 'apps/api',
337
346
  };
338
347
  const files = generateModelsPackage(options);
339
- const userTs = files.find(
340
- (f) => f.path === 'packages/models/src/user.ts',
341
- );
348
+ const userTs = files.find((f) => f.path === 'packages/models/src/user.ts');
342
349
  const commonTs = files.find(
343
350
  (f) => f.path === 'packages/models/src/common.ts',
344
351
  );
@@ -359,7 +366,8 @@ describe('generateModelsPackage', () => {
359
366
  const tsConfig = files.find(
360
367
  (f) => f.path === 'packages/models/tsconfig.json',
361
368
  );
362
- const config = JSON.parse(tsConfig?.content);
369
+ expect(tsConfig).toBeDefined();
370
+ const config = JSON.parse(tsConfig!.content);
363
371
  expect(config.extends).toBe('../../tsconfig.json');
364
372
  });
365
373
  });
@@ -35,7 +35,7 @@ export const GEEKMIDAS_VERSIONS = {
35
35
  '@geekmidas/constructs': '~0.6.0',
36
36
  '@geekmidas/db': '~0.3.0',
37
37
  '@geekmidas/emailkit': '~0.2.0',
38
- '@geekmidas/envkit': '~0.4.0',
38
+ '@geekmidas/envkit': '~0.5.0',
39
39
  '@geekmidas/errors': '~0.1.0',
40
40
  '@geekmidas/events': '~0.2.0',
41
41
  '@geekmidas/logger': '~0.4.0',
@@ -478,6 +478,120 @@ describe('WorkspaceConfigSchema', () => {
478
478
  });
479
479
  });
480
480
 
481
+ describe('auth app configuration', () => {
482
+ it('should accept auth app with provider', () => {
483
+ const config = {
484
+ apps: {
485
+ auth: {
486
+ type: 'auth' as const,
487
+ path: 'apps/auth',
488
+ port: 3002,
489
+ provider: 'better-auth' as const,
490
+ },
491
+ },
492
+ };
493
+
494
+ const result = validateWorkspaceConfig(config);
495
+
496
+ expect(result.apps.auth.type).toBe('auth');
497
+ expect(result.apps.auth.provider).toBe('better-auth');
498
+ });
499
+
500
+ it('should reject auth app without provider', () => {
501
+ const config = {
502
+ apps: {
503
+ auth: {
504
+ type: 'auth' as const,
505
+ path: 'apps/auth',
506
+ port: 3002,
507
+ // Missing provider
508
+ },
509
+ },
510
+ };
511
+
512
+ const result = safeValidateWorkspaceConfig(config);
513
+
514
+ expect(result.success).toBe(false);
515
+ if (result.error) {
516
+ const formatted = formatValidationErrors(result.error);
517
+ expect(formatted).toContain('Auth apps must have provider defined');
518
+ }
519
+ });
520
+
521
+ it('should allow auth app with backend properties', () => {
522
+ const config = {
523
+ apps: {
524
+ auth: {
525
+ type: 'auth' as const,
526
+ path: 'apps/auth',
527
+ port: 3002,
528
+ provider: 'better-auth' as const,
529
+ envParser: './src/config/env',
530
+ logger: './src/logger',
531
+ telescope: true,
532
+ },
533
+ },
534
+ };
535
+
536
+ const result = validateWorkspaceConfig(config);
537
+
538
+ expect(result.apps.auth.type).toBe('auth');
539
+ expect(result.apps.auth.envParser).toBe('./src/config/env');
540
+ expect(result.apps.auth.telescope).toBe(true);
541
+ });
542
+
543
+ it('should validate fullstack workspace with auth app', () => {
544
+ const config = {
545
+ name: 'fullstack-app',
546
+ apps: {
547
+ api: {
548
+ type: 'backend' as const,
549
+ path: 'apps/api',
550
+ port: 3000,
551
+ routes: './src/endpoints/**/*.ts',
552
+ dependencies: ['auth'],
553
+ },
554
+ auth: {
555
+ type: 'auth' as const,
556
+ path: 'apps/auth',
557
+ port: 3002,
558
+ provider: 'better-auth' as const,
559
+ },
560
+ web: {
561
+ type: 'frontend' as const,
562
+ path: 'apps/web',
563
+ port: 3001,
564
+ framework: 'nextjs' as const,
565
+ dependencies: ['api', 'auth'],
566
+ },
567
+ },
568
+ };
569
+
570
+ const result = validateWorkspaceConfig(config);
571
+
572
+ expect(result.apps.api.dependencies).toEqual(['auth']);
573
+ expect(result.apps.auth.type).toBe('auth');
574
+ expect(result.apps.web.dependencies).toEqual(['api', 'auth']);
575
+ });
576
+
577
+ it('should reject invalid auth provider', () => {
578
+ const config = {
579
+ apps: {
580
+ auth: {
581
+ type: 'auth' as const,
582
+ path: 'apps/auth',
583
+ port: 3002,
584
+ provider: 'invalid-provider',
585
+ },
586
+ },
587
+ };
588
+
589
+ const result = safeValidateWorkspaceConfig(config);
590
+
591
+ expect(result.success).toBe(false);
592
+ });
593
+ });
594
+
481
595
  describe('deploy target helpers', () => {
482
596
  it('isDeployTargetSupported should return true for dokploy', () => {
483
597
  expect(isDeployTargetSupported('dokploy')).toBe(true);