@geekmidas/cli 10.0.0-alpha.5 → 10.0.0-alpha.9

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 (74) hide show
  1. package/README.md +12 -8
  2. package/bin/adjacent-tsconfig.mjs +59 -0
  3. package/bin/gkm.mjs +7 -1
  4. package/dist/{config-C21ocYNd.cjs → config-BZ1F0WuA.cjs} +8 -81
  5. package/dist/config-BZ1F0WuA.cjs.map +1 -0
  6. package/dist/{config-WRdsQYZK.mjs → config-DVVph9yz.mjs} +10 -77
  7. package/dist/config-DVVph9yz.mjs.map +1 -0
  8. package/dist/config.cjs +2 -2
  9. package/dist/config.d.cts +2 -2
  10. package/dist/config.d.mts +2 -2
  11. package/dist/config.mjs +2 -2
  12. package/dist/{credentials-G1JwBoXH.cjs → credentials-Xamqe_NJ.cjs} +20 -19
  13. package/dist/credentials-Xamqe_NJ.cjs.map +1 -0
  14. package/dist/{credentials-BBIRJVp4.mjs → credentials-dP8vRVRv.mjs} +20 -19
  15. package/dist/credentials-dP8vRVRv.mjs.map +1 -0
  16. package/dist/discover-CAlFgYec.mjs.map +1 -1
  17. package/dist/discover-DTmFeloL.cjs.map +1 -1
  18. package/dist/{index-BThUzEIp.d.mts → index-C6e-ULLx.d.mts} +2 -2
  19. package/dist/index-C6e-ULLx.d.mts.map +1 -0
  20. package/dist/{index-CHB4tSzX.d.cts → index-CR5urhWX.d.cts} +2 -2
  21. package/dist/index-CR5urhWX.d.cts.map +1 -0
  22. package/dist/index.cjs +871 -337
  23. package/dist/index.cjs.map +1 -1
  24. package/dist/index.mjs +871 -337
  25. package/dist/index.mjs.map +1 -1
  26. package/dist/{openapi-CA0yJUTN.cjs → openapi-SjsgARie.cjs} +122 -50
  27. package/dist/openapi-SjsgARie.cjs.map +1 -0
  28. package/dist/{openapi-kkQbsRVj.mjs → openapi-Xx0A7Wg0.mjs} +104 -50
  29. package/dist/openapi-Xx0A7Wg0.mjs.map +1 -0
  30. package/dist/openapi.cjs +5 -3
  31. package/dist/openapi.d.cts +46 -10
  32. package/dist/openapi.d.cts.map +1 -1
  33. package/dist/openapi.d.mts +46 -10
  34. package/dist/openapi.d.mts.map +1 -1
  35. package/dist/openapi.mjs +4 -4
  36. package/dist/reconcile/public.d.cts +1 -1
  37. package/dist/reconcile/public.d.mts +1 -1
  38. package/dist/{setup-ZijS4qeC.mjs → setup-BA8ciTPU.mjs} +4 -4
  39. package/dist/setup-BsZvZP6f.cjs +11 -0
  40. package/dist/{setup-DXvRLTZN.mjs → setup-C8KOx8_8.mjs} +5 -5
  41. package/dist/setup-C8KOx8_8.mjs.map +1 -0
  42. package/dist/{setup-Cm2UnAdh.cjs → setup-hC6hI86f.cjs} +5 -5
  43. package/dist/setup-hC6hI86f.cjs.map +1 -0
  44. package/dist/{types-C-arnQ1r.d.mts → types-6pkp2RRj.d.mts} +46 -10
  45. package/dist/types-6pkp2RRj.d.mts.map +1 -0
  46. package/dist/{types-abJQuk7m.d.cts → types-Y3fKQSAK.d.cts} +46 -10
  47. package/dist/types-Y3fKQSAK.d.cts.map +1 -0
  48. package/dist/workspace/index.cjs +1 -1
  49. package/dist/workspace/index.d.cts +2 -2
  50. package/dist/workspace/index.d.mts +2 -2
  51. package/dist/workspace/index.mjs +1 -1
  52. package/dist/workspace-BzrG_2SI.mjs +8 -0
  53. package/dist/{workspace-BcucjwvA.cjs → workspace-C6VJQVtG.cjs} +79 -17
  54. package/dist/workspace-C6VJQVtG.cjs.map +1 -0
  55. package/dist/{workspace-DdJss8I6.cjs → workspace-XXtNhqJl.cjs} +3 -3
  56. package/dist/{workspace-BFMrAKqI.mjs → workspace-zoqRbihZ.mjs} +62 -18
  57. package/dist/workspace-zoqRbihZ.mjs.map +1 -0
  58. package/package.json +14 -13
  59. package/dist/config-C21ocYNd.cjs.map +0 -1
  60. package/dist/config-WRdsQYZK.mjs.map +0 -1
  61. package/dist/credentials-BBIRJVp4.mjs.map +0 -1
  62. package/dist/credentials-G1JwBoXH.cjs.map +0 -1
  63. package/dist/index-BThUzEIp.d.mts.map +0 -1
  64. package/dist/index-CHB4tSzX.d.cts.map +0 -1
  65. package/dist/openapi-CA0yJUTN.cjs.map +0 -1
  66. package/dist/openapi-kkQbsRVj.mjs.map +0 -1
  67. package/dist/setup-B5_oHHUj.cjs +0 -11
  68. package/dist/setup-Cm2UnAdh.cjs.map +0 -1
  69. package/dist/setup-DXvRLTZN.mjs.map +0 -1
  70. package/dist/types-C-arnQ1r.d.mts.map +0 -1
  71. package/dist/types-abJQuk7m.d.cts.map +0 -1
  72. package/dist/workspace-BFMrAKqI.mjs.map +0 -1
  73. package/dist/workspace-BcucjwvA.cjs.map +0 -1
  74. package/dist/workspace-k4l3rsFh.mjs +0 -8
package/dist/index.mjs CHANGED
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env -S npx tsx
2
2
  import { __require } from "./chunk-Duj1WY3L.mjs";
3
3
  import { ConstructGenerator, DEFAULT_CACHE, discover, isPartitionedRoutes } from "./discover-CAlFgYec.mjs";
4
- import { derivedApps, getAppNameFromCwd, loadAppConfig, loadConfig, loadWorkspaceConfig, parseModuleConfig, resolveAppSpec } from "./config-WRdsQYZK.mjs";
5
- import { allConstructGlobs, getAppBuildOrder, getDependencyEnvVars, getDeployTargetError, getPublicEnvPrefix, isDeployTargetSupported, stripPublicPrefix } from "./workspace-BFMrAKqI.mjs";
4
+ import { derivedApps, getAppNameFromCwd, loadAppConfig, loadConfig, loadWorkspaceConfig, parseModuleConfig } from "./config-DVVph9yz.mjs";
5
+ import { allConstructGlobs, assertDeployedStage, deployedProblems, getAppBuildOrder, getAppGkmConfig, getDependencyEnvVars, getDeployTargetError, getPublicEnvPrefix, isDeployTargetSupported, stageProblems, stripPublicPrefix } from "./workspace-zoqRbihZ.mjs";
6
6
  import { getCredentialsPath, getDokployCredentials, getDokployRegistryId, getDokployToken, removeDokployCredentials, storeDokployCredentials, storeDokployRegistryId, storeHostingerToken } from "./credentials-B5zRHdAl.mjs";
7
- import { applyPostgres, cacheBackendOf, constructGlobs, createCredentialsPreload, createEntryWrapper, derivedContainers, emailBackendOf, findAvailablePort, isPortAvailable, loadEnvFiles, loadSecretsForApp, prepareEntryCredentials, providerOf, reconcileWorkspace, resolveServicePorts, resourceName, rewriteUrlsWithPorts, startWorkspaceServices, storageBackendOf, usesConstructs } from "./credentials-BBIRJVp4.mjs";
7
+ import { applyPostgres, cacheBackendOf, constructGlobs, createCredentialsPreload, createEntryWrapper, derivedContainers, emailBackendOf, findAvailablePort, isPortAvailable, loadEnvFiles, loadSecretsForApp, prepareEntryCredentials, providerOf, reconcileWorkspace, resolveServicePorts, resourceName, rewriteUrlsWithPorts, startWorkspaceServices, storageBackendOf, usesConstructs } from "./credentials-dP8vRVRv.mjs";
8
8
  import { createStageSecrets, rotateServicePassword } from "./generator-VE2JrFlY.mjs";
9
9
  import { getKeyPath, maskPassword, readStageSecrets, secretsExist, setCustomSecret, toEmbeddableSecrets, writeStageSecrets } from "./storage-Bi24WfoW.mjs";
10
- import { EndpointGenerator, OPENAPI_OUTPUT_PATH, generateOpenApi, normalizeRoutes, openapiCommand, resolveOpenApiConfig } from "./openapi-kkQbsRVj.mjs";
10
+ import { EndpointGenerator, OPENAPI_OUTPUT_PATH, generateOpenApi, generateOpenApiFrom, normalizeRoutes, openapiCommand, resolveOpenApiConfig, runtimeFor } from "./openapi-Xx0A7Wg0.mjs";
11
11
  import { withCompute, withRoutes } from "./emit-CiZS_5Ub.mjs";
12
12
  import { DokployApi } from "./dokploy-api-aTFPz7_4.mjs";
13
13
  import { encryptSecrets } from "./encryption-D-abyhZx.mjs";
@@ -15,7 +15,7 @@ import { CachedStateProvider } from "./CachedStateProvider-DTniehMP.mjs";
15
15
  import { generateDbPassword, generateDbUrl, generateFullstackCustomSecrets } from "./fullstack-secrets-D1o9-G5l.mjs";
16
16
  import { generateReactQueryCommand } from "./openapi-react-query-Byltvm_7.mjs";
17
17
  import "./sync-CmEuNZsB.mjs";
18
- import { setupCommand, trustCommand } from "./setup-DXvRLTZN.mjs";
18
+ import { setupCommand, trustCommand } from "./setup-C8KOx8_8.mjs";
19
19
  import { createRequire } from "node:module";
20
20
  import { copyFileSync, existsSync, readFileSync, unlinkSync } from "node:fs";
21
21
  import { basename, dirname, isAbsolute, join, parse, relative, resolve } from "node:path";
@@ -43,7 +43,7 @@ import prompts from "prompts";
43
43
 
44
44
  //#region package.json
45
45
  var name = "@geekmidas/cli";
46
- var version = "10.0.0-alpha.4";
46
+ var version = "10.0.0-alpha.9";
47
47
  var description = "CLI tools for building Lambda handlers, server applications, and generating OpenAPI specs";
48
48
  var private$1 = false;
49
49
  var type = "module";
@@ -143,6 +143,7 @@ var dependencies = {
143
143
  "dotenv": "~18.0.1",
144
144
  "esbuild": "~0.28.2",
145
145
  "fast-glob": "~3.3.3",
146
+ "get-tsconfig": "~4.10.1",
146
147
  "hono": "~4.13.8",
147
148
  "lodash.kebabcase": "~4.1.1",
148
149
  "openapi-typescript": "~7.13.0",
@@ -157,8 +158,8 @@ var devDependencies = {
157
158
  "@types/node": "~24.9.1",
158
159
  "@types/pg": "~8.23.1",
159
160
  "@types/prompts": "~2.4.9",
160
- "typescript": "^5.8.2",
161
- "vitest": "^3.2.4",
161
+ "typescript": "~5.8.2",
162
+ "vitest": "~3.2.4",
162
163
  "zod": "~4.6.5"
163
164
  };
164
165
  var peerDependencies = { "@geekmidas/telescope": "workspace:~" };
@@ -415,11 +416,16 @@ var CronGenerator = class extends ConstructGenerator {
415
416
  const provider = options?.provider || "aws-lambda";
416
417
  const logger$12 = console;
417
418
  const cronInfos = [];
419
+ if (provider === "server") {
420
+ await this.generateServerCronsFile(outputDir, constructs);
421
+ logger$12.log(`Generated server crons file with ${constructs.length} crons`);
422
+ return cronInfos;
423
+ }
418
424
  if (constructs.length === 0 || provider !== "aws-lambda") return cronInfos;
419
425
  const cronsDir = join(outputDir, "crons");
420
426
  await mkdir(cronsDir, { recursive: true });
421
427
  for (const { key, construct, path } of constructs) {
422
- const handlerFile = await this.generateCronHandler(cronsDir, path.relative, key, context);
428
+ const handlerFile = await this.generateCronHandler(cronsDir, path.relative, key, context, construct.owner);
423
429
  cronInfos.push({
424
430
  name: key,
425
431
  handler: relative(process.cwd(), handlerFile).replace(/\.ts$/, ".handler"),
@@ -436,17 +442,173 @@ var CronGenerator = class extends ConstructGenerator {
436
442
  isConstruct(value) {
437
443
  return Cron.isCron(value);
438
444
  }
439
- async generateCronHandler(outputDir, sourceFile, exportName, context) {
445
+ /**
446
+ * The crons file a server entry imports.
447
+ *
448
+ * Shaped after `generateServerSubscribersFile`: one module, every cron, a
449
+ * `setupCrons` the generated entry calls beside `setupSubscribers`.
450
+ *
451
+ * It picks its scheduler from what the process can reach. With a Postgres it
452
+ * uses pg-boss, whose schedules live in the database — so a deployment
453
+ * running four replicas fires each job once, which is the thing a timer in
454
+ * every process gets wrong and does not report. With no database it falls
455
+ * back to timers and says plainly that it is doing so.
456
+ */
457
+ async generateServerCronsFile(outputDir, crons) {
458
+ await mkdir(outputDir, { recursive: true });
459
+ const cronsPath = join(outputDir, "crons.ts");
460
+ const importsByFile = /* @__PURE__ */ new Map();
461
+ for (const { path, key } of crons) {
462
+ const relativePath = relative(dirname(cronsPath), path.relative);
463
+ const importPath = relativePath.replace(/\.ts$/, ".js");
464
+ if (!importsByFile.has(importPath)) importsByFile.set(importPath, []);
465
+ importsByFile.get(importPath)?.push(key);
466
+ }
467
+ const imports = Array.from(importsByFile.entries()).map(([importPath, exports$1]) => `import { ${exports$1.join(", ")} } from '${importPath}';`).join("\n");
468
+ const entries = crons.map(({ key }) => ` { name: '${key}', cron: ${key} }`).join(",\n");
469
+ const content = `/**
470
+ * Generated cron setup.
471
+ *
472
+ * Schedules every cron this app declares, in the process that serves it.
473
+ *
474
+ * The schedule lives in Postgres, through pg-boss, in the database the worker
475
+ * named with \`.database(db)\`. That is what makes running several replicas
476
+ * safe: each job fires once, where a timer in every process fires it once per
477
+ * replica and reports nothing.
478
+ *
479
+ * No connection string appears here. The construct that owns the database is
480
+ * the only thing that knows its key, and it is resolved through service
481
+ * discovery like any other dependency.
482
+ */
483
+ import type { EnvironmentParser } from '@geekmidas/envkit';
484
+ import type { Logger } from '@geekmidas/logger';
485
+ import { toCronExpression } from '@geekmidas/constructs/crons';
486
+ import { ServiceDiscovery } from '@geekmidas/services';
487
+ ${imports}
488
+
489
+ const crons = [
490
+ ${entries}
491
+ ];
492
+
493
+ type Stoppable = { stop: () => Promise<void> | void };
494
+ const running: Stoppable[] = [];
495
+
496
+ export async function setupCrons(
497
+ envParser: EnvironmentParser<any>,
498
+ logger: Logger,
499
+ ): Promise<void> {
500
+ if (crons.length === 0) {
501
+ return;
502
+ }
503
+
504
+ const serviceDiscovery = ServiceDiscovery.getInstance(envParser);
505
+
506
+ // Resolved once per cron rather than per firing: registering services on
507
+ // every tick would reconnect a database every minute.
508
+ const prepared = [];
509
+ for (const { name, cron } of crons) {
510
+ try {
511
+ const schedule = toCronExpression(cron.schedule);
512
+ const services = cron.services.length > 0
513
+ ? await serviceDiscovery.register(cron.services)
514
+ : {};
515
+
516
+ prepared.push({ name, cron, schedule, services });
517
+ } catch (error) {
518
+ // An unrepresentable schedule is a build-time mistake, and failing the
519
+ // process for it would take the HTTP server down with it.
520
+ logger.error({ error, cron: name }, 'Cron has no runnable schedule, skipping');
521
+ }
522
+ }
523
+
524
+ const run = async (entry: (typeof prepared)[number]) => {
525
+ try {
526
+ await entry.cron.handler({
527
+ input: undefined,
528
+ services: entry.services,
529
+ logger: entry.cron.logger,
530
+ });
531
+ } catch (error) {
532
+ logger.error({ error, cron: entry.name }, 'Cron failed');
533
+ }
534
+ };
535
+
536
+ // Declared once on the worker, carried onto every cron it built — so this
537
+ // reads it from any of them rather than from config or the environment.
538
+ const scheduleStore = prepared.find(({ cron }) => cron.scheduleStore)?.cron
539
+ .scheduleStore;
540
+
541
+ if (!scheduleStore) {
542
+ // Declared, not discovered. A worker says where its schedules live with
543
+ // \`.database(db)\`; inferring one from whatever database happened to be
544
+ // around would move the schedules the day an app declared a second.
545
+ logger.error(
546
+ { crons: prepared.map(({ name }) => name) },
547
+ 'These crons have nowhere to keep their schedule. A server fires its own ' +
548
+ 'crons, and a process scheduling in memory fires each job once per ' +
549
+ 'replica — so the schedule lives in Postgres. Say where with ' +
550
+ '\`new Worker(…).database(db)\`.',
551
+ );
552
+ return;
553
+ }
554
+
555
+ const [store] = await serviceDiscovery
556
+ .register([scheduleStore])
557
+ .then((s) => Object.values(s));
558
+
559
+ const { PgBoss } = await import('pg-boss');
560
+ // The pool the construct already opened — no connection string is read here,
561
+ // and none is named anywhere outside the construct that owns it.
562
+ const boss = new PgBoss({ db: store as never });
563
+ await boss.start();
564
+
565
+ // What this app declares now. Anything else under the prefix belonged to a
566
+ // cron since renamed or deleted, and would keep firing at a handler that has
567
+ // gone.
568
+ //
569
+ // The prefix is \`cron.\` rather than \`cron:\`: pg-boss accepts
570
+ // alphanumerics, underscores, hyphens, periods and slashes in a queue name,
571
+ // and rejects a colon outright.
572
+ const declared = new Set(prepared.map(({ name }) => \`cron.\${name}\`));
573
+ for (const existing of await boss.getSchedules()) {
574
+ if (existing.name.startsWith('cron.') && !declared.has(existing.name)) {
575
+ await boss.unschedule(existing.name);
576
+ logger.info({ cron: existing.name }, 'Removed a schedule nothing declares');
577
+ }
578
+ }
579
+
580
+ for (const entry of prepared) {
581
+ const queue = \`cron.\${entry.name}\`;
582
+ await boss.createQueue(queue);
583
+ // UTC, so this agrees with the schedule a deploy target would have made.
584
+ await boss.schedule(queue, entry.schedule, undefined, { tz: 'UTC' });
585
+ await boss.work(queue, () => run(entry));
586
+
587
+ logger.info({ cron: entry.name, schedule: entry.schedule }, 'Cron scheduled');
588
+ }
589
+
590
+ running.push({ stop: () => boss.stop() });
591
+ logger.info({ count: prepared.length }, 'Crons scheduled');
592
+ }
593
+
594
+ export async function stopCrons(): Promise<void> {
595
+ await Promise.all(running.map((r) => r.stop()));
596
+ running.length = 0;
597
+ }
598
+ `;
599
+ await writeFile(cronsPath, content);
600
+ return cronsPath;
601
+ }
602
+ async generateCronHandler(outputDir, sourceFile, exportName, context, owner) {
440
603
  const handlerFileName = `${exportName}.ts`;
441
604
  const handlerPath = join(outputDir, handlerFileName);
442
605
  const relativePath = relative(dirname(handlerPath), sourceFile);
443
606
  const importPath = relativePath.replace(/\.ts$/, ".js");
444
- const relativeEnvParserPath = relative(dirname(handlerPath), context.envParserPath);
445
- const relativeLoggerPath = relative(dirname(handlerPath), context.loggerPath);
607
+ const runtime = runtimeFor(context, dirname(handlerPath), owner);
446
608
  const content = `import { AWSScheduledFunction } from '@geekmidas/constructs/aws';
447
609
  import { ${exportName} } from '${importPath}';
448
- import ${context.envParserImportPattern} from '${relativeEnvParserPath}';
449
- import ${context.loggerImportPattern} from '${relativeLoggerPath}';
610
+ ${runtime.imports}
611
+ ${runtime.bindings}
450
612
 
451
613
  const adapter = new AWSScheduledFunction(envParser, ${exportName});
452
614
 
@@ -562,7 +724,7 @@ var FunctionGenerator = class extends ConstructGenerator {
562
724
  const functionsDir = join(outputDir, "functions");
563
725
  await mkdir(functionsDir, { recursive: true });
564
726
  for (const { key, construct, path } of constructs) {
565
- const handlerFile = await this.generateFunctionHandler(functionsDir, path.relative, key, context);
727
+ const handlerFile = await this.generateFunctionHandler(functionsDir, path.relative, key, context, construct.owner);
566
728
  functionInfos.push({
567
729
  name: key,
568
730
  handler: relative(process.cwd(), handlerFile).replace(/\.ts$/, ".handler"),
@@ -575,17 +737,16 @@ var FunctionGenerator = class extends ConstructGenerator {
575
737
  }
576
738
  return functionInfos;
577
739
  }
578
- async generateFunctionHandler(outputDir, sourceFile, exportName, context) {
740
+ async generateFunctionHandler(outputDir, sourceFile, exportName, context, owner) {
579
741
  const handlerFileName = `${exportName}.ts`;
580
742
  const handlerPath = join(outputDir, handlerFileName);
581
743
  const relativePath = relative(dirname(handlerPath), sourceFile);
582
744
  const importPath = relativePath.replace(/\.ts$/, ".js");
583
- const relativeEnvParserPath = relative(dirname(handlerPath), context.envParserPath);
584
- const relativeLoggerPath = relative(dirname(handlerPath), context.loggerPath);
745
+ const runtime = runtimeFor(context, dirname(handlerPath), owner);
585
746
  const content = `import { AWSLambdaFunction } from '@geekmidas/constructs/aws';
586
747
  import { ${exportName} } from '${importPath}';
587
- import ${context.envParserImportPattern} from '${relativeEnvParserPath}';
588
- import ${context.loggerImportPattern} from '${relativeLoggerPath}';
748
+ ${runtime.imports}
749
+ ${runtime.bindings}
589
750
 
590
751
  const adapter = new AWSLambdaFunction(envParser, ${exportName});
591
752
 
@@ -624,7 +785,7 @@ var QueueGenerator = class extends ConstructGenerator {
624
785
  const queuesDir = join(outputDir, "queues");
625
786
  await mkdir(queuesDir, { recursive: true });
626
787
  for (const { key, construct, path } of constructs) {
627
- const handlerFile = await this.generateQueueHandler(queuesDir, path.relative, key, context);
788
+ const handlerFile = await this.generateQueueHandler(queuesDir, path.relative, key, context, construct.owner);
628
789
  queueInfos.push({
629
790
  name: construct.name,
630
791
  handler: relative(process.cwd(), handlerFile).replace(/\.ts$/, ".handler"),
@@ -638,13 +799,14 @@ var QueueGenerator = class extends ConstructGenerator {
638
799
  }
639
800
  return queueInfos;
640
801
  }
641
- async generateQueueHandler(outputDir, sourceFile, exportName, context) {
802
+ async generateQueueHandler(outputDir, sourceFile, exportName, context, owner) {
642
803
  const handlerPath = join(outputDir, `${exportName}.ts`);
643
804
  const importPath = relative(dirname(handlerPath), sourceFile).replace(/\.ts$/, ".js");
644
- const relativeEnvParserPath = relative(dirname(handlerPath), context.envParserPath);
805
+ const runtime = runtimeFor(context, dirname(handlerPath), owner);
645
806
  const content = `import { AWSLambdaQueue } from '@geekmidas/constructs/aws';
646
807
  import { ${exportName} } from '${importPath}';
647
- import ${context.envParserImportPattern} from '${relativeEnvParserPath}';
808
+ ${runtime.imports}
809
+ ${runtime.bindings}
648
810
 
649
811
  const adapter = new AWSLambdaQueue(envParser, ${exportName});
650
812
 
@@ -787,7 +949,7 @@ var SubscriberGenerator = class extends ConstructGenerator {
787
949
  const subscribersDir = join(outputDir, "subscribers");
788
950
  await mkdir(subscribersDir, { recursive: true });
789
951
  for (const { key, construct, path } of constructs) {
790
- const handlerFile = await this.generateSubscriberHandler(subscribersDir, path.relative, key, construct, context);
952
+ const handlerFile = await this.generateSubscriberHandler(subscribersDir, path.relative, key, construct, context, construct.owner);
791
953
  subscriberInfos.push({
792
954
  name: key,
793
955
  handler: relative(process.cwd(), handlerFile).replace(/\.ts$/, ".handler"),
@@ -805,15 +967,16 @@ var SubscriberGenerator = class extends ConstructGenerator {
805
967
  }
806
968
  return subscriberInfos;
807
969
  }
808
- async generateSubscriberHandler(outputDir, sourceFile, exportName, _subscriber, context) {
970
+ async generateSubscriberHandler(outputDir, sourceFile, exportName, _subscriber, context, owner) {
809
971
  const handlerFileName = `${exportName}.ts`;
810
972
  const handlerPath = join(outputDir, handlerFileName);
811
973
  const relativePath = relative(dirname(handlerPath), sourceFile);
812
974
  const importPath = relativePath.replace(/\.ts$/, ".js");
813
- const relativeEnvParserPath = relative(dirname(handlerPath), context.envParserPath);
975
+ const runtime = runtimeFor(context, dirname(handlerPath), owner);
814
976
  const content = `import { AWSLambdaSubscriber } from '@geekmidas/constructs/aws';
815
977
  import { ${exportName} } from '${importPath}';
816
- import ${context.envParserImportPattern} from '${relativeEnvParserPath}';
978
+ ${runtime.imports}
979
+ ${runtime.bindings}
817
980
 
818
981
  const adapter = new AWSLambdaSubscriber(envParser, ${exportName});
819
982
 
@@ -1196,13 +1359,7 @@ async function devCommand(options) {
1196
1359
  }
1197
1360
  const resolved = resolveProviders(config, { provider: "server" });
1198
1361
  logger$9.log("🚀 Starting development server...");
1199
- logger$9.log(`Loading routes from: ${config.routes}`);
1200
- if (config.functions) logger$9.log(`Loading functions from: ${config.functions}`);
1201
- if (config.crons) logger$9.log(`Loading crons from: ${config.crons}`);
1202
- if (config.subscribers) logger$9.log(`Loading subscribers from: ${config.subscribers}`);
1203
- logger$9.log(`Using envParser: ${config.envParser}`);
1204
- const { path: envParserPath, importPattern: envParserImportPattern } = parseModuleConfig(config.envParser, "envParser");
1205
- const { path: loggerPath, importPattern: loggerImportPattern } = parseModuleConfig(config.logger, "logger");
1362
+ logger$9.log(`Loading constructs from: ${config.constructs}`);
1206
1363
  const telescope = normalizeTelescopeConfig(config.telescope);
1207
1364
  if (telescope) logger$9.log(`🔭 Telescope enabled at ${telescope.path}`);
1208
1365
  const studio = normalizeStudioConfig(config.studio);
@@ -1213,21 +1370,17 @@ async function devCommand(options) {
1213
1370
  const enableOpenApi = openApiConfig.enabled || resolved.enableOpenApi;
1214
1371
  if (enableOpenApi) logger$9.log(`📄 OpenAPI output: ${OPENAPI_OUTPUT_PATH}`);
1215
1372
  const buildContext = {
1216
- envParserPath,
1217
- envParserImportPattern,
1218
- loggerPath,
1219
- loggerImportPattern,
1220
1373
  telescope,
1221
1374
  studio,
1222
1375
  hooks
1223
1376
  };
1224
1377
  await buildServer(config, buildContext, resolved.providers[0], enableOpenApi, appRoot);
1225
- if (enableOpenApi) await generateOpenApi(config);
1378
+ if (enableOpenApi) await generateOpenApiFrom(config.constructs, { openapi: config.openapi });
1226
1379
  const runtime = config.runtime ?? "node";
1227
1380
  let secretsJsonPath;
1228
- const appSecrets = await loadSecretsForApp(secretsRoot, workspaceAppName);
1381
+ const appSecrets = await loadSecretsForApp(secretsRoot, config.stages.local, workspaceAppName);
1229
1382
  if (workspace && usesConstructs(workspace)) {
1230
- const reconciled = await reconcileWorkspace(workspace, { stage: "development" });
1383
+ const reconciled = await reconcileWorkspace(workspace, { stage: workspace.stages.local });
1231
1384
  if (reconciled.changed && reconciled.plan.containers.length > 0) {
1232
1385
  logger$9.log(`🐳 Services: ${reconciled.plan.containers.join(", ")}`);
1233
1386
  for (const [container, address] of Object.entries(reconciled.addresses)) logger$9.log(` ${container}: ${address}`);
@@ -1255,19 +1408,9 @@ async function devCommand(options) {
1255
1408
  }
1256
1409
  const devServer = new DevServer(resolved.providers[0], options.port ?? workspaceAppPort ?? 3e3, options.portExplicit ?? false, enableOpenApi, telescope, studio, runtime, appRoot, secretsJsonPath);
1257
1410
  await devServer.start();
1258
- const envParserFile = config.envParser.split("#")[0] ?? config.envParser;
1259
- const loggerFile = config.logger.split("#")[0] ?? config.logger;
1260
1411
  const hooksFileParts = config.hooks?.server?.split("#");
1261
1412
  const hooksFile = hooksFileParts?.[0];
1262
- const watchPatterns = [
1263
- config.routes,
1264
- ...config.functions ? [config.functions] : [],
1265
- ...config.crons ? [config.crons] : [],
1266
- ...config.subscribers ? [config.subscribers] : [],
1267
- envParserFile.endsWith(".ts") ? envParserFile : `${envParserFile}.ts`,
1268
- loggerFile.endsWith(".ts") ? loggerFile : `${loggerFile}.ts`,
1269
- ...hooksFile ? [hooksFile.endsWith(".ts") ? hooksFile : `${hooksFile}.ts`] : []
1270
- ].flat().filter((p) => typeof p === "string");
1413
+ const watchPatterns = [config.constructs, ...hooksFile ? [hooksFile.endsWith(".ts") ? hooksFile : `${hooksFile}.ts`] : []].flat().filter((p) => typeof p === "string");
1271
1414
  const normalizedPatterns = watchPatterns.map((p) => p.startsWith("./") ? p.slice(2) : p);
1272
1415
  logger$9.log(`👀 Watching for changes in: ${normalizedPatterns.join(", ")}`);
1273
1416
  const resolvedFiles = await fg(normalizedPatterns, {
@@ -1300,7 +1443,8 @@ async function devCommand(options) {
1300
1443
  try {
1301
1444
  logger$9.log("🔄 Rebuilding...");
1302
1445
  await buildServer(config, buildContext, resolved.providers[0], enableOpenApi, appRoot, true);
1303
- if (enableOpenApi) await generateOpenApi(config, {
1446
+ if (enableOpenApi) await generateOpenApiFrom(config.constructs, {
1447
+ openapi: config.openapi,
1304
1448
  silent: true,
1305
1449
  bustCache: true
1306
1450
  });
@@ -1518,15 +1662,15 @@ async function validateFrontendApps(workspace) {
1518
1662
  */
1519
1663
  async function loadDevSecrets(workspace) {
1520
1664
  if (!workspace.secrets.enabled) return {};
1521
- const stages = ["dev", "development"];
1522
- for (const stage of stages) if (secretsExist(stage, workspace.root)) {
1665
+ const stage = workspace.stages.local;
1666
+ if (secretsExist(stage, workspace.root)) {
1523
1667
  const secrets = await readStageSecrets(stage, workspace.root);
1524
1668
  if (secrets) {
1525
1669
  logger$9.log(`🔐 Loading secrets from stage: ${stage}`);
1526
1670
  return toEmbeddableSecrets(secrets);
1527
1671
  }
1528
1672
  }
1529
- logger$9.warn("⚠️ Secrets enabled but no dev/development secrets found. Run \"gkm setup\" to initialize your development environment");
1673
+ logger$9.warn(`⚠️ Secrets enabled but no "${stage}" secrets found. Run "gkm setup" to initialize the local stage`);
1530
1674
  return {};
1531
1675
  }
1532
1676
  /**
@@ -1570,7 +1714,7 @@ async function workspaceDevCommand(workspace, options) {
1570
1714
  const rawSecrets = await loadDevSecrets(workspace);
1571
1715
  let secretsEnv;
1572
1716
  if (usesConstructs(workspace)) {
1573
- const reconciled = await reconcileWorkspace(workspace, { stage: "development" });
1717
+ const reconciled = await reconcileWorkspace(workspace, { stage: workspace.stages.local });
1574
1718
  if (reconciled.changed && reconciled.plan.containers.length > 0) {
1575
1719
  logger$9.log(`🐳 Services: ${reconciled.plan.containers.join(", ")}`);
1576
1720
  for (const [container, address] of Object.entries(reconciled.addresses)) logger$9.log(` ${container}: ${address}`);
@@ -1714,7 +1858,7 @@ async function buildServer(config, context, provider, enableOpenApi, appRoot = p
1714
1858
  const queueGenerator = new QueueGenerator();
1715
1859
  const topicGenerator = new TopicGenerator();
1716
1860
  const [allEndpoints, allFunctions, allCrons, allSubscribers, allQueues, allTopics] = await Promise.all([
1717
- endpointGenerator.load(config.routes, appRoot, bustCache),
1861
+ endpointGenerator.load(config.constructs, appRoot, bustCache),
1718
1862
  config.functions ? functionGenerator.load(config.functions, appRoot, bustCache) : [],
1719
1863
  config.crons ? cronGenerator.load(config.crons, appRoot, bustCache) : [],
1720
1864
  config.subscribers ? subscriberGenerator.load(config.subscribers, appRoot, bustCache) : [],
@@ -2302,18 +2446,36 @@ export default app;
2302
2446
  `);
2303
2447
  }
2304
2448
  const logger$7 = console;
2449
+ /**
2450
+ * What a build run at the workspace root reads.
2451
+ *
2452
+ * One backend app there, and it is that app's config, so its own `openapi`
2453
+ * and `telescope` settings still apply. Otherwise it is the workspace's: every
2454
+ * construct glob, and the backends they resolve to.
2455
+ */
2456
+ function rootGkmConfig(workspace) {
2457
+ const backends = Object.entries(workspace.apps).filter(([, app]) => app.type === "backend");
2458
+ const only = backends.length === 1 ? backends[0] : void 0;
2459
+ const own = only ? getAppGkmConfig(workspace, only[0]) : void 0;
2460
+ return own ?? {
2461
+ services: workspace.services,
2462
+ stages: workspace.stages,
2463
+ constructs: allConstructGlobs(workspace)
2464
+ };
2465
+ }
2305
2466
  async function buildCommand(options) {
2306
2467
  const loadedConfig = await loadWorkspaceConfig();
2307
2468
  if (loadedConfig.type === "workspace") {
2308
2469
  const cwd = resolve(process.cwd());
2309
- const workspaceRoot = resolve(loadedConfig.workspace.root);
2310
- const isAtWorkspaceRoot = cwd === workspaceRoot;
2311
- if (isAtWorkspaceRoot) {
2470
+ const workspaceRoot$1 = resolve(loadedConfig.workspace.root);
2471
+ const isAtWorkspaceRoot = cwd === workspaceRoot$1;
2472
+ const someAppIsElsewhere = Object.values(loadedConfig.workspace.apps).some((app) => resolve(workspaceRoot$1, app.path) !== workspaceRoot$1);
2473
+ if (isAtWorkspaceRoot && someAppIsElsewhere) {
2312
2474
  logger$7.log("📦 Detected workspace configuration");
2313
2475
  return workspaceBuildCommand(loadedConfig.workspace, options);
2314
2476
  }
2315
2477
  }
2316
- const config = loadedConfig.type === "workspace" ? (await loadAppConfig()).gkmConfig : await loadConfig();
2478
+ const config = loadedConfig.type !== "workspace" ? await loadConfig() : resolve(process.cwd()) === resolve(loadedConfig.workspace.root) ? rootGkmConfig(loadedConfig.workspace) : (await loadAppConfig()).gkmConfig;
2317
2479
  const resolved = resolveProviders(config, options);
2318
2480
  const backendConfig = config.services ?? loadedConfig.workspace?.services;
2319
2481
  const cacheBackend = cacheBackendOf(backendConfig?.cache);
@@ -2321,13 +2483,7 @@ async function buildCommand(options) {
2321
2483
  const production = normalizeProductionConfig(options.production ?? false, productionConfigFromGkm);
2322
2484
  if (production) logger$7.log(`🏭 Building for PRODUCTION`);
2323
2485
  logger$7.log(`Building with providers: ${resolved.providers.join(", ")}`);
2324
- logger$7.log(`Loading routes from: ${formatRoutes(config.routes)}`);
2325
- if (config.functions) logger$7.log(`Loading functions from: ${formatRoutes(config.functions)}`);
2326
- if (config.crons) logger$7.log(`Loading crons from: ${formatRoutes(config.crons)}`);
2327
- if (config.subscribers) logger$7.log(`Loading subscribers from: ${formatRoutes(config.subscribers)}`);
2328
- logger$7.log(`Using envParser: ${config.envParser}`);
2329
- const { path: envParserPath, importPattern: envParserImportPattern } = parseModuleConfig(config.envParser, "envParser");
2330
- const { path: loggerPath, importPattern: loggerImportPattern } = parseModuleConfig(config.logger, "logger");
2486
+ logger$7.log(`Loading constructs from: ${formatRoutes(config.constructs)}`);
2331
2487
  const telescope = production ? void 0 : normalizeTelescopeConfig(config.telescope);
2332
2488
  if (telescope) logger$7.log(`🔭 Telescope enabled at ${telescope.path}`);
2333
2489
  const studio = production ? void 0 : normalizeStudioConfig(config.studio);
@@ -2352,10 +2508,23 @@ async function buildCommand(options) {
2352
2508
  sources: constructSources
2353
2509
  }) : {};
2354
2510
  const surfaces = Object.values(declared).filter((d) => d.kind === "rest-api");
2355
- const primary = surfaces.find((d) => d.endpoints.length === 0) ?? surfaces[0];
2511
+ const workspaceRoot = loadedConfig.workspace.root;
2512
+ const served = surfaces.find((d) => resolve(workspaceRoot, d.path) === resolve(process.cwd()));
2513
+ const primary = served ?? surfaces.find((d) => d.endpoints.length === 0) ?? surfaces[0];
2356
2514
  const browsable = Object.entries(declared).find(([, d]) => d.kind === "database");
2357
2515
  const browsableSource = browsable ? constructSources[browsable[0]] : void 0;
2516
+ const owners = {};
2517
+ for (const [id, declaration] of Object.entries(declared)) {
2518
+ if (declaration.kind !== "rest-api" && declaration.kind !== "worker") continue;
2519
+ const source = constructSources[id];
2520
+ if (!source) continue;
2521
+ owners[id] = {
2522
+ specifier: source.file,
2523
+ exportName: source.exportName
2524
+ };
2525
+ }
2358
2526
  const buildContext = {
2527
+ owners,
2359
2528
  ...primary ? { surface: {
2360
2529
  id: primary.id,
2361
2530
  trustedOriginsKey: provideKey(primary.id, "trustedOrigins"),
@@ -2365,10 +2534,6 @@ async function buildCommand(options) {
2365
2534
  exportName: constructSources[primary.id].exportName
2366
2535
  } } : {}
2367
2536
  } } : {},
2368
- envParserPath,
2369
- envParserImportPattern,
2370
- loggerPath,
2371
- loggerImportPattern,
2372
2537
  telescope,
2373
2538
  studio: studio && browsableSource ? {
2374
2539
  ...studio,
@@ -2395,14 +2560,16 @@ async function buildCommand(options) {
2395
2560
  const subscriberGenerator = new SubscriberGenerator();
2396
2561
  const queueGenerator = new QueueGenerator();
2397
2562
  const topicGenerator = new TopicGenerator();
2398
- const [allEndpoints, allFunctions, allCrons, allSubscribers, allQueues, allTopics] = await Promise.all([
2399
- endpointGenerator.load(config.routes),
2400
- config.functions ? functionGenerator.load(config.functions) : [],
2401
- config.crons ? cronGenerator.load(config.crons) : [],
2402
- config.subscribers ? subscriberGenerator.load(config.subscribers) : [],
2403
- config.queues ? queueGenerator.load(config.queues) : [],
2404
- config.topics ? topicGenerator.load(config.topics) : []
2563
+ const code = constructGlobs$1;
2564
+ const [loadedEndpoints, allFunctions, allCrons, allSubscribers, allQueues, allTopics] = await Promise.all([
2565
+ endpointGenerator.load(code),
2566
+ functionGenerator.load(code),
2567
+ cronGenerator.load(code),
2568
+ subscriberGenerator.load(code),
2569
+ queueGenerator.load(code),
2570
+ topicGenerator.load(code)
2405
2571
  ]);
2572
+ const allEndpoints = primary ? loadedEndpoints.filter(({ construct }) => !construct.surface || construct.surface.id === primary.id) : loadedEndpoints;
2406
2573
  logger$7.log(`Found ${allEndpoints.length} endpoints`);
2407
2574
  logger$7.log(`Found ${allFunctions.length} functions`);
2408
2575
  logger$7.log(`Found ${allCrons.length} crons`);
@@ -2410,7 +2577,7 @@ async function buildCommand(options) {
2410
2577
  logger$7.log(`Found ${allQueues.length} queues`);
2411
2578
  logger$7.log(`Found ${allTopics.length} topics`);
2412
2579
  if (allEndpoints.length === 0 && allFunctions.length === 0 && allCrons.length === 0 && allSubscribers.length === 0 && allQueues.length === 0 && allTopics.length === 0) {
2413
- const selfServing = Object.entries(declared).find(([id, d]) => d.kind === "rest-api" && d.endpoints.length > 0 && constructSources[id] !== void 0 && resolve(loadedConfig.workspace.root, resolveAppSpec(id, d.app ?? {}, loadedConfig.workspace.root, "rest-api").path) === resolve(process.cwd()));
2580
+ const selfServing = served && served.endpoints.length > 0 && constructSources[served.id] !== void 0 ? [served.id, served] : void 0;
2414
2581
  if (selfServing) {
2415
2582
  const [id] = selfServing;
2416
2583
  await writeSurfaceEntry(join(process.cwd(), ".gkm", "server"), constructSources[id]);
@@ -2427,7 +2594,7 @@ async function buildCommand(options) {
2427
2594
  const providerResult = await buildForProvider(provider, buildContext, rootOutputDir, endpointGenerator, functionGenerator, cronGenerator, subscriberGenerator, queueGenerator, topicGenerator, allEndpoints, allFunctions, allCrons, allSubscribers, allQueues, allTopics, resolved.enableOpenApi, options.skipBundle ?? false, options.stage);
2428
2595
  if (providerResult.masterKey) result = providerResult;
2429
2596
  }
2430
- await generateOpenApi(config, { bustCache: true });
2597
+ await generateOpenApi(allEndpoints.map(({ construct }) => construct), { openapi: config.openapi });
2431
2598
  return result;
2432
2599
  }
2433
2600
  async function buildForProvider(provider, context, rootOutputDir, endpointGenerator, functionGenerator, cronGenerator, subscriberGenerator, queueGenerator, topicGenerator, endpoints, functions, crons, subscribers, queues, topics, enableOpenApi, skipBundle, stage) {
@@ -2681,7 +2848,6 @@ async function workspaceBuildCommand(workspace, options) {
2681
2848
  });
2682
2849
  }
2683
2850
  logger$7.log(`\n✅ Workspace build complete!`);
2684
- await openapiCommand({ cwd: workspace.root });
2685
2851
  logger$7.log(`\n📋 Build Summary:`);
2686
2852
  for (const result of results) {
2687
2853
  const icon = result.type === "backend" ? "⚙️" : result.type === "mobile" ? "📱" : "🌐";
@@ -3051,7 +3217,7 @@ function minioCompose(options) {
3051
3217
  return [
3052
3218
  "services:",
3053
3219
  ` ${service}:`,
3054
- " image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z",
3220
+ " image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z",
3055
3221
  ` command: server /data --console-address ":9001"`,
3056
3222
  " environment:",
3057
3223
  ` MINIO_ROOT_USER: ${user}`,
@@ -3870,7 +4036,7 @@ const DEFAULT_SERVICE_IMAGES = {
3870
4036
  postgres: "postgres",
3871
4037
  redis: "redis",
3872
4038
  rabbitmq: "rabbitmq",
3873
- minio: "quay.io/minio/minio",
4039
+ minio: "pgsty/minio",
3874
4040
  mailpit: "axllent/mailpit",
3875
4041
  localstack: "floci/floci"
3876
4042
  };
@@ -3879,7 +4045,7 @@ const DEFAULT_SERVICE_VERSIONS = {
3879
4045
  postgres: `${DEFAULT_POSTGRES_VERSION}-alpine`,
3880
4046
  redis: "7-alpine",
3881
4047
  rabbitmq: "3-management-alpine",
3882
- minio: "RELEASE.2025-09-07T16-13-09Z",
4048
+ minio: "RELEASE.2026-08-04T00-00-00Z",
3883
4049
  mailpit: "latest",
3884
4050
  localstack: "latest"
3885
4051
  };
@@ -4331,7 +4497,7 @@ function getInfraServiceImage(serviceName$1, pin) {
4331
4497
  const defaults = {
4332
4498
  postgres: "postgres:18-alpine",
4333
4499
  redis: "redis:7-alpine",
4334
- minio: "quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z"
4500
+ minio: "pgsty/minio:RELEASE.2026-08-04T00-00-00Z"
4335
4501
  };
4336
4502
  return pin || defaults[serviceName$1];
4337
4503
  }
@@ -5531,7 +5697,7 @@ async function workspaceDockerCommand(workspace, options) {
5531
5697
  logger$5.log(`\n Generated: .dockerignore (workspace root)`);
5532
5698
  const dockerCompose = generateWorkspaceCompose(workspace, {
5533
5699
  registry: options.registry,
5534
- containers: await derivedContainers(workspace, "development")
5700
+ containers: await derivedContainers(workspace, workspace.stages.local)
5535
5701
  });
5536
5702
  const composePath = join(dockerDir, "docker-compose.yml");
5537
5703
  await writeFile(composePath, dockerCompose);
@@ -6977,6 +7143,7 @@ async function workspaceDeployCommand(configured, options) {
6977
7143
  */
6978
7144
  async function deployCommand(options) {
6979
7145
  const loadedConfig = await loadWorkspaceConfig();
7146
+ assertDeployedStage(loadedConfig.workspace.stages, options.stage);
6980
7147
  return workspaceDeployCommand(loadedConfig.workspace, options);
6981
7148
  }
6982
7149
 
@@ -7352,47 +7519,47 @@ const CONSTRUCTS_GLOB = "./src/constructs/**/*.ts";
7352
7519
  */
7353
7520
  const WORKSPACE_CONSTRUCTS_GLOB = "./constructs/**/*.ts";
7354
7521
  /**
7355
- * The database a project named `name` declares.
7522
+ * Where each routes structure puts an app's handlers, relative to the app.
7356
7523
  *
7357
- * Named after the project rather than `Db`, so two apps in one workspace can
7358
- * declare one each without colliding on an id or on an env key.
7524
+ * `.ts` only, never `.tsx`: a TanStack site keeps its own `src/routes/`, and
7525
+ * this glob must not import a frontend's route components.
7359
7526
  */
7360
- function databaseFor(name$1) {
7361
- const id = canonicalId(name$1);
7362
- return {
7363
- id,
7364
- service: serviceKey(id),
7365
- urlKey: provideKey(id, "url"),
7366
- ownerUrlKey: provideKey(id, "ownerUrl")
7367
- };
7368
- }
7369
- /** The bucket a project named `name` declares. */
7370
- function storageFor(name$1) {
7371
- const id = canonicalId(`${name$1}-uploads`);
7372
- return {
7373
- id,
7374
- service: serviceKey(id),
7375
- urlKey: provideKey(id, "url")
7376
- };
7527
+ function routesGlob(structure) {
7528
+ switch (structure) {
7529
+ case "centralized-endpoints": return "./src/endpoints/**/*.ts";
7530
+ case "centralized-routes": return "./src/routes/**/*.ts";
7531
+ case "domain-based": return "./src/**/routes/*.ts";
7532
+ }
7377
7533
  }
7378
- /** The mail sender a project named `name` declares. */
7379
- function emailFor(name$1) {
7380
- const id = canonicalId(`${name$1}-mail`);
7534
+ /**
7535
+ * Plain ids — `Database`, not `Beetlefit`.
7536
+ *
7537
+ * The workspace `name` already scopes every physical name
7538
+ * (`production-beetlefit-database`), so an id that carried the project too
7539
+ * would say it twice: `production-beetlefit-beetlefit-cache`. Within a
7540
+ * workspace the constructs are declared once, at its root, so there is no
7541
+ * second app's `Database` to collide with.
7542
+ */
7543
+ function scaffolded(id) {
7381
7544
  return {
7382
7545
  id,
7383
7546
  service: serviceKey(id),
7384
7547
  urlKey: provideKey(id, "url")
7385
7548
  };
7386
7549
  }
7387
- /** The cache a project named `name` declares. */
7388
- function cacheFor(name$1) {
7389
- const id = canonicalId(`${name$1}-cache`);
7550
+ /** The database a project declares. */
7551
+ function databaseFor() {
7390
7552
  return {
7391
- id,
7392
- service: serviceKey(id),
7393
- urlKey: provideKey(id, "url")
7553
+ ...scaffolded("Database"),
7554
+ ownerUrlKey: provideKey("Database", "ownerUrl")
7394
7555
  };
7395
7556
  }
7557
+ /** The bucket a project declares. */
7558
+ const storageFor = () => scaffolded("Uploads");
7559
+ /** The mail sender a project declares. */
7560
+ const emailFor = () => scaffolded("Mail");
7561
+ /** The cache a project declares. */
7562
+ const cacheFor = () => scaffolded("Cache");
7396
7563
  /**
7397
7564
  * The files a project's declared database needs.
7398
7565
  *
@@ -7400,8 +7567,8 @@ function cacheFor(name$1) {
7400
7567
  * the same way — and so the schema type, the migration, and the id the test
7401
7568
  * setup reads all come from one place.
7402
7569
  */
7403
- function databaseFiles(name$1) {
7404
- const db = databaseFor(name$1);
7570
+ function databaseFiles() {
7571
+ const db = databaseFor();
7405
7572
  return [{
7406
7573
  path: "src/constructs/database.ts",
7407
7574
  content: `import { KyselyDatabase } from '@geekmidas/constructs/database/kysely';
@@ -7430,7 +7597,16 @@ export interface Database {
7430
7597
  */
7431
7598
  export const database = new KyselyDatabase<Database, '${db.id}'>('${db.id}');
7432
7599
  `
7433
- }, {
7600
+ }, usersMigration()];
7601
+ }
7602
+ /**
7603
+ * The table the scaffolded endpoints and factories expect.
7604
+ *
7605
+ * Its own function because a workspace's API needs it without the database
7606
+ * construct beside it — that one lives at the workspace root.
7607
+ */
7608
+ function usersMigration() {
7609
+ return {
7434
7610
  path: "src/db/migrations/001_create_users.ts",
7435
7611
  content: `import type { Kysely } from 'kysely';
7436
7612
 
@@ -7458,7 +7634,18 @@ export async function down(db: Kysely<unknown>): Promise<void> {
7458
7634
  await db.schema.dropTable('users').execute();
7459
7635
  }
7460
7636
  `
7461
- }];
7637
+ };
7638
+ }
7639
+ /**
7640
+ * What a workspace's `constructs` glob reaches: the root constructs, and the
7641
+ * handlers of every app laid out the way `init` was told.
7642
+ *
7643
+ * Every app in `appsDir` (`apps/*`) rather than the API's own directory.
7644
+ * Endpoints are split by the surface they were built from, so naming one app
7645
+ * here would only decide which apps' endpoints are silently never loaded.
7646
+ */
7647
+ function workspaceConstructsGlobs(structure, appsDir) {
7648
+ return [WORKSPACE_CONSTRUCTS_GLOB, `./${appsDir}/*/${routesGlob(structure).replace(/^\.\//, "")}`];
7462
7649
  }
7463
7650
 
7464
7651
  //#endregion
@@ -7538,6 +7725,7 @@ what the OpenAPI spec says, which env vars a process needs.
7538
7725
  \`\`\`typescript
7539
7726
  export default defineWorkspace({
7540
7727
  name: '${name$1}',
7728
+ stages: { local: 'dev', deployed: ['staging', 'prod'], protected: ['prod'] },
7541
7729
  constructs: '${constructsGlob}',
7542
7730
  secrets: { enabled: true },
7543
7731
  });
@@ -7694,11 +7882,11 @@ Crons, subscribers and functions are declared the same way endpoints are: as
7694
7882
  exported values the build discovers.
7695
7883
 
7696
7884
  \`\`\`typescript
7697
- // a scheduled job
7698
- import { c } from '@geekmidas/constructs/crons';
7885
+ // a scheduled job, built from the worker that runs it
7886
+ import { worker } from '../constructs/worker.ts';
7699
7887
 
7700
- export const cleanup = c
7701
- .schedule('rate(1 day)')
7888
+ export const cleanup = worker
7889
+ .cron('rate(1 day)')
7702
7890
  .handle(async ({ logger }) => {
7703
7891
  logger.info('cleaning up');
7704
7892
  });
@@ -7706,10 +7894,10 @@ export const cleanup = c
7706
7894
 
7707
7895
  \`\`\`typescript
7708
7896
  // a topic subscriber
7709
- import { s } from '@geekmidas/constructs/subscribers';
7710
7897
  import { userEvents } from '../constructs/topics.ts';
7898
+ import { worker } from '../constructs/worker.ts';
7711
7899
 
7712
- export const onUserCreated = s
7900
+ export const onUserCreated = worker
7713
7901
  .topic(userEvents)
7714
7902
  .subscribe(['user.created'])
7715
7903
  .handle(async ({ events, logger }) => {
@@ -7792,6 +7980,30 @@ ${database ? "gkm test # starts services, injects secrets, then runs vites
7792
7980
  Run tests through \`gkm test\`, not \`vitest\` directly${database ? " — it is what starts the database and injects the secrets the suite needs" : ""}.`;
7793
7981
  }
7794
7982
 
7983
+ //#endregion
7984
+ //#region src/init/dependencies.ts
7985
+ /**
7986
+ * Third-party ranges a scaffold installs beside the `@geekmidas` packages.
7987
+ *
7988
+ * Held to the peer ranges those packages declare, in one place, because a
7989
+ * template that pinned its own copy drifted below them: `kysely ~0.28` against
7990
+ * a `~0.29.6` peer is two copies of Kysely and a `Kysely<DB>` from one that is
7991
+ * not assignable to the other's.
7992
+ *
7993
+ * Hand-maintained, unlike `versions.ts`, which `sync-versions` rewrites whole.
7994
+ */
7995
+ const DEPENDENCY_VERSIONS = {
7996
+ "@tanstack/react-query": "~5.80.0",
7997
+ "better-auth": "~1.7.5",
7998
+ hono: "~4.13.8",
7999
+ kysely: "~0.29.6",
8000
+ "kysely-ctl": "~0.21.0",
8001
+ pg: "~8.23.0",
8002
+ pino: "~10.3.1",
8003
+ sst: "~4.17.1",
8004
+ zod: "~4.6.5"
8005
+ };
8006
+
7795
8007
  //#endregion
7796
8008
  //#region src/init/versions.ts
7797
8009
  const require$1 = createRequire(import.meta.url);
@@ -7814,26 +8026,26 @@ const CLI_VERSION = `~${pkg.version}`;
7814
8026
  * Run: pnpm --filter @geekmidas/cli sync-versions
7815
8027
  */
7816
8028
  const GEEKMIDAS_VERSIONS = {
7817
- "@geekmidas/audit": "~10.0.0-alpha.4",
7818
- "@geekmidas/auth": "~10.0.0-alpha.4",
7819
- "@geekmidas/cache": "~10.0.0-alpha.4",
7820
- "@geekmidas/client": "~10.0.0-alpha.4",
7821
- "@geekmidas/cloud": "~10.0.0-alpha.4",
7822
- "@geekmidas/constructs": "~10.0.0-alpha.4",
7823
- "@geekmidas/db": "~10.0.0-alpha.4",
7824
- "@geekmidas/emailkit": "~10.0.0-alpha.4",
7825
- "@geekmidas/envkit": "~10.0.0-alpha.4",
7826
- "@geekmidas/errors": "~10.0.0-alpha.4",
7827
- "@geekmidas/events": "~10.0.0-alpha.4",
7828
- "@geekmidas/logger": "~10.0.0-alpha.4",
7829
- "@geekmidas/manifest": "~10.0.0-alpha.4",
7830
- "@geekmidas/rate-limit": "~10.0.0-alpha.4",
7831
- "@geekmidas/schema": "~10.0.0-alpha.4",
7832
- "@geekmidas/services": "~10.0.0-alpha.4",
7833
- "@geekmidas/storage": "~10.0.0-alpha.4",
7834
- "@geekmidas/studio": "~10.0.0-alpha.4",
7835
- "@geekmidas/telescope": "~10.0.0-alpha.4",
7836
- "@geekmidas/testkit": "~10.0.0-alpha.4",
8029
+ "@geekmidas/audit": "~10.0.0-alpha.9",
8030
+ "@geekmidas/auth": "~10.0.0-alpha.9",
8031
+ "@geekmidas/cache": "~10.0.0-alpha.9",
8032
+ "@geekmidas/client": "~10.0.0-alpha.9",
8033
+ "@geekmidas/cloud": "~10.0.0-alpha.9",
8034
+ "@geekmidas/constructs": "~10.0.0-alpha.9",
8035
+ "@geekmidas/db": "~10.0.0-alpha.9",
8036
+ "@geekmidas/emailkit": "~10.0.0-alpha.9",
8037
+ "@geekmidas/envkit": "~10.0.0-alpha.9",
8038
+ "@geekmidas/errors": "~10.0.0-alpha.9",
8039
+ "@geekmidas/events": "~10.0.0-alpha.9",
8040
+ "@geekmidas/logger": "~10.0.0-alpha.9",
8041
+ "@geekmidas/manifest": "~10.0.0-alpha.9",
8042
+ "@geekmidas/rate-limit": "~10.0.0-alpha.9",
8043
+ "@geekmidas/schema": "~10.0.0-alpha.9",
8044
+ "@geekmidas/services": "~10.0.0-alpha.9",
8045
+ "@geekmidas/storage": "~10.0.0-alpha.9",
8046
+ "@geekmidas/studio": "~10.0.0-alpha.9",
8047
+ "@geekmidas/telescope": "~10.0.0-alpha.9",
8048
+ "@geekmidas/testkit": "~10.0.0-alpha.9",
7837
8049
  "@geekmidas/cli": CLI_VERSION
7838
8050
  };
7839
8051
 
@@ -7853,27 +8065,28 @@ function generateAuthAppFiles(options) {
7853
8065
  private: true,
7854
8066
  type: "module",
7855
8067
  scripts: {
7856
- dev: "gkm dev --entry ./src/index.ts",
7857
- build: "tsc",
7858
- start: "node dist/index.js",
7859
- typecheck: "tsc --noEmit",
8068
+ dev: "gkm dev",
8069
+ build: "gkm build",
7860
8070
  "db:migrate": "gkm exec -- npx @better-auth/cli migrate",
7861
8071
  "db:generate": "gkm exec -- npx @better-auth/cli generate"
7862
8072
  },
7863
8073
  dependencies: {
7864
8074
  [modelsPackage]: "workspace:*",
8075
+ "@geekmidas/auth": GEEKMIDAS_VERSIONS["@geekmidas/auth"],
8076
+ "@geekmidas/constructs": GEEKMIDAS_VERSIONS["@geekmidas/constructs"],
8077
+ "@geekmidas/db": GEEKMIDAS_VERSIONS["@geekmidas/db"],
7865
8078
  "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
7866
8079
  "@geekmidas/logger": GEEKMIDAS_VERSIONS["@geekmidas/logger"],
7867
- "@hono/node-server": "~1.13.0",
7868
- "better-auth": "~1.2.0",
7869
- hono: "~4.8.0",
7870
- kysely: "~0.27.0",
7871
- pg: "~8.13.0"
8080
+ "@hono/node-server": "~1.14.1",
8081
+ "better-auth": DEPENDENCY_VERSIONS["better-auth"],
8082
+ hono: DEPENDENCY_VERSIONS.hono,
8083
+ kysely: DEPENDENCY_VERSIONS.kysely,
8084
+ pg: DEPENDENCY_VERSIONS.pg
7872
8085
  },
7873
8086
  devDependencies: {
7874
8087
  "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
7875
8088
  "@types/node": "~22.0.0",
7876
- "@types/pg": "~8.11.0",
8089
+ "@types/pg": "~8.23.1",
7877
8090
  tsx: "~4.20.0",
7878
8091
  typescript: "~5.8.2"
7879
8092
  }
@@ -7913,6 +8126,30 @@ dist/
7913
8126
  ];
7914
8127
  }
7915
8128
 
8129
+ //#endregion
8130
+ //#region src/init/generators/stages.ts
8131
+ /** The `stages` block a generated gkm.config.ts declares, indented two spaces. */
8132
+ function stagesBlock(stages) {
8133
+ const list = (names) => `[${names.map((name$1) => `'${name$1}'`).join(", ")}]`;
8134
+ return `
8135
+ // Named by this project and read by every command: \`gkm dev\`, \`exec\` and
8136
+ // \`test\` run as \`local\`, and \`gkm deploy --stage\` accepts only \`deployed\`.
8137
+ stages: {
8138
+ local: '${stages.local}',
8139
+ deployed: ${list(stages.deployed)},${stages.protected?.length ? `
8140
+ // Retained and protected on removal.
8141
+ protected: ${list(stages.protected)},` : ""}
8142
+ },`;
8143
+ }
8144
+ /**
8145
+ * The `STAGE` the scaffolded env config parses: one of the declared stages,
8146
+ * defaulting to the local one.
8147
+ */
8148
+ function stageEnv(stages) {
8149
+ const names = [stages.local, ...stages.deployed].map((name$1) => `'${name$1}'`).join(", ");
8150
+ return `get('STAGE').enum([${names}]).default('${stages.local}')`;
8151
+ }
8152
+
7916
8153
  //#endregion
7917
8154
  //#region src/init/generators/config.ts
7918
8155
  /**
@@ -7937,13 +8174,7 @@ function generateConfigFiles(options, template) {
7937
8174
  const isServerless = template.name === "serverless";
7938
8175
  const hasWorker = template.name === "worker";
7939
8176
  const isFullstack = options.template === "fullstack";
7940
- const getRoutesGlob = () => {
7941
- switch (routesStructure) {
7942
- case "centralized-endpoints": return "./src/endpoints/**/*.ts";
7943
- case "centralized-routes": return "./src/routes/**/*.ts";
7944
- case "domain-based": return "./src/**/routes/*.ts";
7945
- }
7946
- };
8177
+ const getRoutesGlob = () => routesGlob(routesStructure);
7947
8178
  if (isFullstack) return generateSingleAppConfigFiles(options, template, {
7948
8179
  telescope,
7949
8180
  studio,
@@ -7954,7 +8185,7 @@ function generateConfigFiles(options, template) {
7954
8185
  });
7955
8186
  let gkmConfig = `import { defineConfig } from '@geekmidas/cli/config';
7956
8187
 
7957
- export default defineConfig({${options.monorepo ? "" : `
8188
+ export default defineConfig({${stagesBlock(options.stages)}${options.monorepo ? "" : `
7958
8189
  // One glob, every kind. What reconcile reads to derive the containers this
7959
8190
  // app needs: a declared database is why a Postgres exists at all, so nothing
7960
8191
  // lists \`postgres\` anywhere.
@@ -7996,21 +8227,7 @@ export default defineConfig({${options.monorepo ? "" : `
7996
8227
  gkmConfig += `
7997
8228
  });
7998
8229
  `;
7999
- const tsConfig = options.monorepo ? {
8000
- extends: "../../tsconfig.json",
8001
- compilerOptions: {
8002
- noEmit: true,
8003
- allowImportingTsExtensions: true,
8004
- baseUrl: ".",
8005
- paths: {
8006
- "~/*": ["./src/*"],
8007
- [`@${options.name}/constructs/*`]: ["../../constructs/*"],
8008
- [`@${options.name}/*`]: ["../../packages/*/src"]
8009
- }
8010
- },
8011
- include: ["src/**/*.ts"],
8012
- exclude: ["node_modules", "dist"]
8013
- } : {
8230
+ const tsConfig = options.monorepo ? workspaceApiTsConfig(options.name) : {
8014
8231
  compilerOptions: {
8015
8232
  target: "ES2022",
8016
8233
  module: "NodeNext",
@@ -8022,7 +8239,9 @@ export default defineConfig({${options.monorepo ? "" : `
8022
8239
  forceConsistentCasingInFileNames: true,
8023
8240
  resolveJsonModule: true,
8024
8241
  noEmit: true,
8025
- allowImportingTsExtensions: true
8242
+ allowImportingTsExtensions: true,
8243
+ baseUrl: ".",
8244
+ paths: { "~/*": ["./src/*"] }
8026
8245
  },
8027
8246
  include: ["src/**/*.ts"],
8028
8247
  exclude: ["node_modules", "dist"]
@@ -8048,7 +8267,7 @@ export default defineConfig({${options.monorepo ? "" : `
8048
8267
  clientKind: "git",
8049
8268
  useIgnoreFile: true
8050
8269
  },
8051
- organizeImports: { enabled: true },
8270
+ assist: { actions: { source: { organizeImports: "on" } } },
8052
8271
  formatter: {
8053
8272
  enabled: true,
8054
8273
  indentStyle: "space",
@@ -8072,11 +8291,13 @@ export default defineConfig({${options.monorepo ? "" : `
8072
8291
  style: { noNonNullAssertion: "off" }
8073
8292
  }
8074
8293
  },
8075
- files: { ignore: [
8076
- "node_modules",
8077
- "dist",
8078
- ".gkm",
8079
- "coverage"
8294
+ css: { parser: { tailwindDirectives: true } },
8295
+ files: { includes: [
8296
+ "**",
8297
+ "!**/node_modules",
8298
+ "!**/dist",
8299
+ "!**/.gkm",
8300
+ "!**/coverage"
8080
8301
  ] }
8081
8302
  };
8082
8303
  const turboConfig = {
@@ -8130,8 +8351,16 @@ export default defineConfig({${options.monorepo ? "" : `
8130
8351
  });
8131
8352
  return files$1;
8132
8353
  }
8133
- function generateSingleAppConfigFiles(options, _template, _helpers) {
8134
- const tsConfig = {
8354
+ /**
8355
+ * The tsconfig of an API inside a workspace.
8356
+ *
8357
+ * One definition for both ways of getting one — the fullstack template and
8358
+ * `--monorepo` — because the two copies had drifted: only one of them mapped
8359
+ * the root constructs, so in the other `@<name>/constructs/api.ts` fell through
8360
+ * to the `packages/` mapping and nothing could load the surface.
8361
+ */
8362
+ function workspaceApiTsConfig(name$1) {
8363
+ return {
8135
8364
  extends: "../../tsconfig.json",
8136
8365
  compilerOptions: {
8137
8366
  noEmit: true,
@@ -8139,12 +8368,16 @@ function generateSingleAppConfigFiles(options, _template, _helpers) {
8139
8368
  baseUrl: ".",
8140
8369
  paths: {
8141
8370
  "~/*": ["./src/*"],
8142
- [`@${options.name}/*`]: ["../../packages/*/src"]
8371
+ [`@${name$1}/constructs/*`]: ["../../constructs/*"],
8372
+ [`@${name$1}/*`]: ["../../packages/*/src"]
8143
8373
  }
8144
8374
  },
8145
8375
  include: ["src/**/*.ts"],
8146
8376
  exclude: ["node_modules", "dist"]
8147
8377
  };
8378
+ }
8379
+ function generateSingleAppConfigFiles(options, _template, _helpers) {
8380
+ const tsConfig = workspaceApiTsConfig(options.name);
8148
8381
  const files$1 = [{
8149
8382
  path: "tsconfig.json",
8150
8383
  content: `${JSON.stringify(tsConfig, null, 2)}\n`
@@ -8280,7 +8513,7 @@ function generateDockerFiles(options, template, dbApps) {
8280
8513
  MP_SMTP_AUTH: \${SMTP_USER:-${options.name}}:\${SMTP_PASS:-${options.name}}`);
8281
8514
  if (options.services?.storage) {
8282
8515
  services.push(` minio:
8283
- image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
8516
+ image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z
8284
8517
  container_name: ${options.name}-minio
8285
8518
  restart: unless-stopped
8286
8519
  entrypoint: sh
@@ -8553,12 +8786,12 @@ function generateExpoAppFiles(options) {
8553
8786
  dependencies: {
8554
8787
  [apiPackage]: "workspace:*",
8555
8788
  [modelsPackage]: "workspace:*",
8556
- "@better-auth/expo": "~1.2.0",
8789
+ "@better-auth/expo": DEPENDENCY_VERSIONS["better-auth"],
8557
8790
  "@geekmidas/client": GEEKMIDAS_VERSIONS["@geekmidas/client"],
8558
8791
  "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
8559
8792
  "@react-navigation/native": "^7.1.0",
8560
- "@tanstack/react-query": "~5.80.0",
8561
- "better-auth": "~1.2.0",
8793
+ "@tanstack/react-query": DEPENDENCY_VERSIONS["@tanstack/react-query"],
8794
+ "better-auth": DEPENDENCY_VERSIONS["better-auth"],
8562
8795
  expo: "~55.0.0",
8563
8796
  "expo-constants": "~55.0.0",
8564
8797
  "expo-dev-client": "~55.0.0",
@@ -8694,7 +8927,7 @@ module.exports = withNativeWind(config, { input: './global.css' });
8694
8927
  "@/*": ["./*"],
8695
8928
  [modelsPackage]: ["../../packages/models/src"],
8696
8929
  [`${modelsPackage}/*`]: ["../../packages/models/src/*"],
8697
- [`${apiPackage}/client`]: ["../../apps/api/.gkm/openapi.ts"]
8930
+ [`${apiPackage}/client`]: ["../../apps/api/.gkm/openapi/api.ts"]
8698
8931
  }
8699
8932
  },
8700
8933
  include: [
@@ -9252,14 +9485,142 @@ export type ListUsersResponse = z.infer<typeof ListUsersResponseSchema>;
9252
9485
  ];
9253
9486
  }
9254
9487
 
9488
+ //#endregion
9489
+ //#region src/init/generators/deploy.ts
9490
+ /** One `deploy:<stage>` script per deployed stage. */
9491
+ const perStage = (options, command) => Object.fromEntries(options.stages.deployed.map((stage) => [`deploy:${stage}`, command(stage)]));
9492
+ function deployPackage(options) {
9493
+ switch (options.deployTarget) {
9494
+ case "dokploy": return {
9495
+ scripts: perStage(options, (stage) => `gkm deploy --provider dokploy --stage ${stage}`),
9496
+ dependencies: {},
9497
+ devDependencies: {}
9498
+ };
9499
+ case "sst": {
9500
+ const v = GEEKMIDAS_VERSIONS;
9501
+ return {
9502
+ scripts: perStage(options, (stage) => `gkm build --provider aws && sst deploy --stage ${stage}`),
9503
+ dependencies: {
9504
+ "@geekmidas/cloud": v["@geekmidas/cloud"],
9505
+ "@geekmidas/db": v["@geekmidas/db"],
9506
+ "@geekmidas/envkit": v["@geekmidas/envkit"],
9507
+ "@geekmidas/events": v["@geekmidas/events"],
9508
+ "@geekmidas/manifest": v["@geekmidas/manifest"],
9509
+ "@geekmidas/storage": v["@geekmidas/storage"],
9510
+ pg: DEPENDENCY_VERSIONS.pg
9511
+ },
9512
+ devDependencies: { sst: DEPENDENCY_VERSIONS.sst }
9513
+ };
9514
+ }
9515
+ default: return {
9516
+ scripts: {},
9517
+ dependencies: {},
9518
+ devDependencies: {}
9519
+ };
9520
+ }
9521
+ }
9522
+ function generateDeployFiles(options) {
9523
+ if (options.deployTarget !== "sst") return [];
9524
+ if (!options.region) throw new Error("An SST deploy needs a region.");
9525
+ const db = databaseFor();
9526
+ const mail = emailFor();
9527
+ const hasDatabase = options.template === "fullstack" || options.services.db;
9528
+ const inputs = [...hasDatabase ? [` // RDS needs a network. Created here rather than assumed, because a
9529
+ // VPC with NAT is a monthly cost in an account that may already
9530
+ // have one — replace it with \`sst.aws.Vpc.get(…)\` if so.
9531
+ ${db.id}: { vpc },`] : [], ...options.services.mail ? [` // Every provider rejects an unverified sender, so there is no
9532
+ // default; the synth stops with \`EmailNeedsSender\` until it is set.
9533
+ ${mail.id}: { from: process.env.MAIL_FROM as string },`] : []];
9534
+ return [{
9535
+ path: "sst.config.ts",
9536
+ content: `/// <reference path="./.sst/platform/config.d.ts" />
9537
+
9538
+ /**
9539
+ * ${options.name} on AWS.
9540
+ *
9541
+ * No bucket, cluster, queue or IAM is listed here: they come from the
9542
+ * constructs the application declares, through \`fromManifest\`. What is here
9543
+ * is what varies by stage, and the inputs a declaration cannot carry.
9544
+ *
9545
+ * Deploy with \`pnpm run deploy:<stage>\`, which builds the manifest first.
9546
+ */
9547
+ const region = '${options.region}';
9548
+
9549
+ /** From \`stages.protected\` in gkm.config.ts. */
9550
+ const PROTECTED: string[] = ${JSON.stringify(options.stages.protected ?? []).replace(/"/g, "'")};
9551
+
9552
+ export default $config({
9553
+ app(input) {
9554
+ return {
9555
+ name: '${options.name}',
9556
+ // Any other stage is disposable; a protected one's database should not
9557
+ // vanish because somebody removed a stack.
9558
+ removal: PROTECTED.includes(input?.stage) ? 'retain' : 'remove',
9559
+ protect: PROTECTED.includes(input?.stage),
9560
+ home: 'aws',
9561
+ providers: { aws: { region } },
9562
+ };
9563
+ },
9564
+
9565
+ async run() {
9566
+ const { App, fromManifest, Stack } = await import('@geekmidas/cloud/sst');
9567
+ // Written by \`gkm build --provider aws\`. Imported rather than discovered,
9568
+ // so SST's toolchain never evaluates the application's own modules.
9569
+ const { backends, constructs } = await import('./.gkm/manifest/aws.js');
9570
+
9571
+ ${hasDatabase ? " const vpc = new sst.aws.Vpc('Vpc', { nat: 'ec2' });\n\n" : ""} const app = new App({
9572
+ name: $app.name,
9573
+ stage: $app.stage,
9574
+ domain: '',
9575
+ region,
9576
+ hostedZoneId: '',
9577
+ });
9578
+
9579
+ return fromManifest(
9580
+ new Stack(app, '${canonicalId(options.name)}'),
9581
+ constructs,
9582
+ {
9583
+ ${inputs.join("\n")}
9584
+ },
9585
+ backends,
9586
+ );
9587
+ },
9588
+ });
9589
+ `
9590
+ }];
9591
+ }
9592
+
9255
9593
  //#endregion
9256
9594
  //#region src/init/generators/monorepo.ts
9257
9595
  /**
9596
+ * What the root `constructs/` folder imports, and the peers each construct
9597
+ * needs, for the services this workspace was scaffolded with.
9598
+ */
9599
+ function rootConstructDependencies(options) {
9600
+ const v = GEEKMIDAS_VERSIONS;
9601
+ return {
9602
+ "@geekmidas/constructs": v["@geekmidas/constructs"],
9603
+ "@geekmidas/logger": v["@geekmidas/logger"],
9604
+ ...options.loggerType === "pino" ? { pino: DEPENDENCY_VERSIONS.pino } : {},
9605
+ "@geekmidas/telescope": v["@geekmidas/telescope"],
9606
+ "@geekmidas/auth": v["@geekmidas/auth"],
9607
+ "better-auth": DEPENDENCY_VERSIONS["better-auth"],
9608
+ "@geekmidas/db": v["@geekmidas/db"],
9609
+ kysely: DEPENDENCY_VERSIONS.kysely,
9610
+ pg: DEPENDENCY_VERSIONS.pg,
9611
+ ...options.services.storage ? { "@geekmidas/storage": v["@geekmidas/storage"] } : {},
9612
+ ...options.services.mail ? { "@geekmidas/emailkit": v["@geekmidas/emailkit"] } : {},
9613
+ ...options.services.cache ? { "@geekmidas/cache": v["@geekmidas/cache"] } : {}
9614
+ };
9615
+ }
9616
+ /**
9258
9617
  * Generate monorepo root files (pnpm-workspace.yaml, root package.json, etc.)
9259
9618
  */
9260
9619
  function generateMonorepoFiles(options, _template) {
9261
9620
  if (!options.monorepo) return [];
9262
9621
  const isFullstack = options.template === "fullstack";
9622
+ const deploy = deployPackage(options);
9623
+ const isSst = options.deployTarget === "sst";
9263
9624
  const rootPackageJson = {
9264
9625
  name: options.name,
9265
9626
  version: "0.0.1",
@@ -9276,9 +9637,13 @@ function generateMonorepoFiles(options, _template) {
9276
9637
  fmt: "biome format . --write",
9277
9638
  "fmt:check": "biome format .",
9278
9639
  ...isFullstack ? { storybook: "pnpm --filter ./packages/ui storybook" } : {},
9279
- ...options.deployTarget === "dokploy" ? { deploy: "gkm deploy --provider dokploy --stage production" } : {}
9640
+ ...deploy.scripts
9641
+ },
9642
+ dependencies: {
9643
+ zod: DEPENDENCY_VERSIONS.zod,
9644
+ ...isFullstack ? rootConstructDependencies(options) : {},
9645
+ ...deploy.dependencies
9280
9646
  },
9281
- dependencies: { zod: "~4.1.0" },
9282
9647
  devDependencies: {
9283
9648
  "@biomejs/biome": "~2.3.0",
9284
9649
  "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
@@ -9286,7 +9651,8 @@ function generateMonorepoFiles(options, _template) {
9286
9651
  tsx: "~4.20.0",
9287
9652
  turbo: "~2.3.0",
9288
9653
  typescript: "~5.8.2",
9289
- vitest: "~4.0.0"
9654
+ vitest: "~4.0.0",
9655
+ ...deploy.devDependencies
9290
9656
  }
9291
9657
  };
9292
9658
  const apiPathParts = options.apiPath.split("/");
@@ -9302,7 +9668,7 @@ function generateMonorepoFiles(options, _template) {
9302
9668
  clientKind: "git",
9303
9669
  useIgnoreFile: true
9304
9670
  },
9305
- organizeImports: { enabled: true },
9671
+ assist: { actions: { source: { organizeImports: "on" } } },
9306
9672
  formatter: {
9307
9673
  enabled: true,
9308
9674
  indentStyle: "space",
@@ -9326,11 +9692,14 @@ function generateMonorepoFiles(options, _template) {
9326
9692
  style: { noNonNullAssertion: "off" }
9327
9693
  }
9328
9694
  },
9329
- files: { ignore: [
9330
- "node_modules",
9331
- "dist",
9332
- ".gkm",
9333
- "coverage"
9695
+ css: { parser: { tailwindDirectives: true } },
9696
+ files: { includes: [
9697
+ "**",
9698
+ "!**/node_modules",
9699
+ "!**/dist",
9700
+ "!**/.gkm",
9701
+ "!**/coverage",
9702
+ ...isSst ? ["!**/.sst", "!sst-env.d.ts"] : []
9334
9703
  ] }
9335
9704
  };
9336
9705
  const turboConfig = {
@@ -9396,12 +9765,14 @@ coverage/
9396
9765
 
9397
9766
  # Turbo
9398
9767
  .turbo/
9399
- `;
9768
+ ${isSst ? "\n# SST\n.sst/\n" : ""}`;
9400
9769
  const tsConfig = {
9401
9770
  compilerOptions: {
9402
9771
  ...isFullstack ? {
9403
9772
  baseUrl: ".",
9404
- paths: { [`@${options.name}/constructs/*`]: ["./constructs/*"] }
9773
+ paths: { [`@${options.name}/constructs/*`]: ["./constructs/*"] },
9774
+ allowImportingTsExtensions: true,
9775
+ noEmit: true
9405
9776
  } : {},
9406
9777
  target: "ES2022",
9407
9778
  module: "NodeNext",
@@ -9413,16 +9784,21 @@ coverage/
9413
9784
  forceConsistentCasingInFileNames: true,
9414
9785
  resolveJsonModule: true
9415
9786
  },
9416
- exclude: ["node_modules", "dist"]
9787
+ exclude: [
9788
+ "node_modules",
9789
+ "dist",
9790
+ ...isSst ? ["sst.config.ts", ".sst"] : []
9791
+ ]
9417
9792
  };
9418
9793
  const vitestConfig = `import { defineConfig } from 'vitest/config';
9419
9794
 
9420
9795
  export default defineConfig({
9421
9796
  test: {
9422
- globals: true,
9423
- environment: 'node',
9424
- include: ['apps/**/*.{test,spec}.ts', 'packages/**/*.{test,spec}.ts'],
9425
- exclude: ['**/node_modules/**', '**/dist/**'],
9797
+ // Each app and package is its own project, so its own config — an app's
9798
+ // \`globalSetup\`, its path aliases — applies to its own tests.
9799
+ projects: ['apps/*', 'packages/*'],
9800
+ // The shared packages ship without tests of their own.
9801
+ passWithNoTests: true,
9426
9802
  coverage: {
9427
9803
  provider: 'v8',
9428
9804
  reporter: ['text', 'json', 'html'],
@@ -9503,7 +9879,8 @@ export default defineConfig({
9503
9879
  {
9504
9880
  path: ".vscode/extensions.json",
9505
9881
  content: `${JSON.stringify(vscodeExtensions, null, " ")}\n`
9506
- }
9882
+ },
9883
+ ...generateDeployFiles(options)
9507
9884
  ];
9508
9885
  if (isFullstack) files$1.push({
9509
9886
  path: "gkm.config.ts",
@@ -9530,12 +9907,17 @@ export default defineWorkspace({
9530
9907
  // The scope every physical name is built from: \`Database\` becomes
9531
9908
  // \`production-${options.name}-database\` on Dokploy and on AWS alike.
9532
9909
  name: '${options.name}',
9910
+ ${stagesBlock(options.stages)}
9533
9911
 
9534
- // One glob, every kind. A declared database is why a Postgres exists, a
9912
+ // Every kind, in every app. A declared database is why a Postgres exists, a
9535
9913
  // declared bucket is why MinIO does, a declared topic is why a broker does —
9536
9914
  // none of it listed here. It is also where the apps come from: a
9537
- // \`StaticSite\` is an app, and so is every \`RestApi\`.
9538
- constructs: '${WORKSPACE_CONSTRUCTS_GLOB}',
9915
+ // \`StaticSite\` is an app, and so is every \`RestApi\`. And it finds each
9916
+ // app's endpoints, crons and subscribers: an endpoint belongs to the surface
9917
+ // it was built from, not to the directory it sits in.
9918
+ constructs: [
9919
+ ${workspaceConstructsGlobs(options.routesStructure, dirname(options.apiPath)).map((glob) => ` '${glob}',`).join("\n")}
9920
+ ],
9539
9921
 
9540
9922
  secrets: {
9541
9923
  enabled: true,
@@ -9564,7 +9946,7 @@ export default defineWorkspace({
9564
9946
  function generateRootConstructs(options) {
9565
9947
  if (!options.monorepo || options.template !== "fullstack") return [];
9566
9948
  const { name: name$1, services, frontendFramework } = options;
9567
- const db = databaseFor(name$1);
9949
+ const db = databaseFor();
9568
9950
  const files$1 = [];
9569
9951
  files$1.push({
9570
9952
  path: "constructs/logger.ts",
@@ -9630,6 +10012,7 @@ import { authDb } from './database.ts';
9630
10012
  * origin down.
9631
10013
  */
9632
10014
  export const auth = new BetterAuth('Auth', {
10015
+ path: 'apps/auth',
9633
10016
  database: authDb,
9634
10017
  basePath: '/api/auth',
9635
10018
  });
@@ -9644,25 +10027,22 @@ import { logger } from './logger.ts';
9644
10027
  /**
9645
10028
  * The application's HTTP surface — one RestApi, one container.
9646
10029
  *
9647
- * No \`app\`: \`Api\` means \`apps/api\`, which the id already said. The
9648
- * \`code\` glob is here only because this scaffold puts its endpoints under
9649
- * \`src/\`, where the conventional layout has them at the app root.
10030
+ * \`path\` is where its app lives. Its handlers are found by the workspace's
10031
+ * \`constructs\` glob, and belong to it because they were built from it.
9650
10032
  */
9651
10033
  export const api = new RestApi('Api', {
10034
+ path: '${options.apiPath}',
10035
+
9652
10036
  // Typed out rather than omitted: an API that ships open because a field was
9653
10037
  // left off is the one default worth refusing to have.
9654
10038
  defaultAuthorizer: 'none',
9655
10039
 
9656
10040
  // The actual logger, not a path to one.
9657
10041
  logger,
9658
-
9659
- app: {
9660
- code: './src/{endpoints,functions,crons,queues,topics,subscribers}/**/*.ts',
9661
- },
9662
10042
  }).auth(auth);
9663
10043
  `
9664
10044
  });
9665
- const variant = frontendFramework === "tanstack-start" ? "{ variant: 'tanstack' }" : frontendFramework === "expo" ? void 0 : "{ variant: 'next' }";
10045
+ const variant = frontendFramework === "tanstack-start" ? "{ path: 'apps/web', variant: 'tanstack' }" : frontendFramework === "expo" ? void 0 : "{ path: 'apps/web', variant: 'next' }";
9666
10046
  if (variant !== void 0) files$1.push({
9667
10047
  path: "constructs/site.ts",
9668
10048
  content: `import { StaticSite } from '@geekmidas/constructs/site';
@@ -9672,7 +10052,7 @@ import { auth } from './auth.ts';
9672
10052
  /**
9673
10053
  * The frontend — a construct like any other, which is what makes it an app.
9674
10054
  *
9675
- * No \`path\`: \`Web\` means \`apps/web\`. \`.dependsOn()\` is the single fact
10055
+ * \`path\` is where it lives. \`.dependsOn()\` is the single fact
9676
10056
  * behind four things that are hand-maintained otherwise: this site's
9677
10057
  * build-time API URL, the API's CORS origins, the auth server's trusted
9678
10058
  * origins, and which generated client lands here.
@@ -9681,7 +10061,7 @@ export const web = new StaticSite('Web', ${variant}).dependsOn([api, auth]);
9681
10061
  `
9682
10062
  });
9683
10063
  if (services.storage) {
9684
- const bucket = storageFor(name$1);
10064
+ const bucket = storageFor();
9685
10065
  files$1.push({
9686
10066
  path: "constructs/storage.ts",
9687
10067
  content: `import { ObjectStorage } from '@geekmidas/constructs/object-storage';
@@ -9692,7 +10072,7 @@ export const uploads = new ObjectStorage('${bucket.id}');
9692
10072
  });
9693
10073
  }
9694
10074
  if (services.mail) {
9695
- const mail = emailFor(name$1);
10075
+ const mail = emailFor();
9696
10076
  files$1.push({
9697
10077
  path: "constructs/email.ts",
9698
10078
  content: `import { Email } from '@geekmidas/constructs/email';
@@ -9703,7 +10083,7 @@ export const email = new Email('${mail.id}', { templates: {} });
9703
10083
  });
9704
10084
  }
9705
10085
  if (services.cache) {
9706
- const kv = cacheFor(name$1);
10086
+ const kv = cacheFor();
9707
10087
  files$1.push({
9708
10088
  path: "constructs/cache.ts",
9709
10089
  content: `import { Cache } from '@geekmidas/constructs/cache';
@@ -9736,9 +10116,9 @@ const apiTemplate = {
9736
10116
  "@geekmidas/errors": GEEKMIDAS_VERSIONS["@geekmidas/errors"],
9737
10117
  "@geekmidas/auth": GEEKMIDAS_VERSIONS["@geekmidas/auth"],
9738
10118
  "@hono/node-server": "~1.14.1",
9739
- hono: "~4.8.2",
9740
- pino: "~9.6.0",
9741
- zod: "~4.1.0"
10119
+ hono: DEPENDENCY_VERSIONS.hono,
10120
+ pino: DEPENDENCY_VERSIONS.pino,
10121
+ zod: DEPENDENCY_VERSIONS.zod
9742
10122
  },
9743
10123
  devDependencies: {
9744
10124
  "@biomejs/biome": "~2.3.0",
@@ -9762,10 +10142,10 @@ const apiTemplate = {
9762
10142
  },
9763
10143
  files: (options) => {
9764
10144
  const { loggerType, routesStructure, monorepo, name: name$1, services } = options;
9765
- const bucket = storageFor(name$1);
9766
- const kv = cacheFor(name$1);
9767
- const db = databaseFor(name$1);
9768
- const mail = emailFor(name$1);
10145
+ const bucket = storageFor();
10146
+ const kv = cacheFor();
10147
+ const db = databaseFor();
10148
+ const mail = emailFor();
9769
10149
  const src = (path) => monorepo ? `~/${path}` : `./${path}`;
9770
10150
  const declares = !monorepo;
9771
10151
  const loggerContent = `import { createLogger } from '@geekmidas/logger/${loggerType}';
@@ -9799,6 +10179,9 @@ export const logger = createLogger();
9799
10179
  import { logger } from '../config/logger.ts';
9800
10180
 
9801
10181
  export const api = new RestApi('Api', {
10182
+ // The project is the app.
10183
+ path: '.',
10184
+
9802
10185
  // Typed out rather than omitted: an API that ships open because a field was
9803
10186
  // left off is the one default worth refusing to have.
9804
10187
  defaultAuthorizer: 'none',
@@ -9821,7 +10204,7 @@ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials
9821
10204
  export const config = envParser
9822
10205
  .create((get) => ({
9823
10206
  nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
9824
- stage: get('STAGE').enum(['development', 'staging', 'production']).default('development'),
10207
+ stage: ${stageEnv(options.stages)},
9825
10208
  }))
9826
10209
  .parse();
9827
10210
  `
@@ -9973,7 +10356,7 @@ export const authService = {
9973
10356
  headers: { cookie },
9974
10357
  });
9975
10358
  if (!res.ok) return null;
9976
- return res.json();
10359
+ return (await res.json()) as Session | null;
9977
10360
  },
9978
10361
  };
9979
10362
  },
@@ -9986,7 +10369,6 @@ export const authService = {
9986
10369
  import { api } from '${constructsImport("api")}';${options.database ? `
9987
10370
  import { database } from '${constructsImport("database")}';` : ""}
9988
10371
  import { authService, type Session } from './services/auth.ts';
9989
- import { logger } from './config/logger.ts';
9990
10372
 
9991
10373
  /**
9992
10374
  * The shared endpoint factory — no session required.
@@ -10044,7 +10426,8 @@ import { database } from '${constructsImport("database")}';` : ""}
10044
10426
  export const router = api.endpoints${options.database ? ".database(database)" : ""};
10045
10427
  `
10046
10428
  });
10047
- if (options.database && declares) files$1.push(...databaseFiles(name$1));
10429
+ if (options.database && declares) files$1.push(...databaseFiles());
10430
+ else if (options.database) files$1.push(usersMigration());
10048
10431
  if (services.storage && declares) files$1.push({
10049
10432
  path: "src/constructs/storage.ts",
10050
10433
  content: `import { ObjectStorage } from '@geekmidas/constructs/object-storage';
@@ -10194,9 +10577,9 @@ const minimalTemplate = {
10194
10577
  "@geekmidas/rate-limit": GEEKMIDAS_VERSIONS["@geekmidas/rate-limit"],
10195
10578
  "@geekmidas/schema": GEEKMIDAS_VERSIONS["@geekmidas/schema"],
10196
10579
  "@hono/node-server": "~1.14.1",
10197
- hono: "~4.8.2",
10198
- pino: "~9.6.0",
10199
- zod: "~4.1.0"
10580
+ hono: DEPENDENCY_VERSIONS.hono,
10581
+ pino: DEPENDENCY_VERSIONS.pino,
10582
+ zod: DEPENDENCY_VERSIONS.zod
10200
10583
  },
10201
10584
  devDependencies: {
10202
10585
  "@biomejs/biome": "~2.3.0",
@@ -10220,7 +10603,7 @@ const minimalTemplate = {
10220
10603
  },
10221
10604
  files: (options) => {
10222
10605
  const { loggerType, routesStructure, name: name$1 } = options;
10223
- const db = databaseFor(name$1);
10606
+ const db = databaseFor();
10224
10607
  const loggerContent = `import { createLogger } from '@geekmidas/logger/${loggerType}';
10225
10608
 
10226
10609
  export const logger = createLogger();
@@ -10240,6 +10623,9 @@ export const logger = createLogger();
10240
10623
  import { logger } from '../config/logger.ts';
10241
10624
 
10242
10625
  export const api = new RestApi('Api', {
10626
+ // The project is the app.
10627
+ path: '.',
10628
+
10243
10629
  // Typed out rather than omitted: an API that ships open because a field was
10244
10630
  // left off is the one default worth refusing to have.
10245
10631
  defaultAuthorizer: 'none',
@@ -10262,7 +10648,7 @@ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials
10262
10648
  export const config = envParser
10263
10649
  .create((get) => ({
10264
10650
  nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
10265
- stage: get('STAGE').enum(['development', 'staging', 'production']).default('development'),
10651
+ stage: ${stageEnv(options.stages)},
10266
10652
  }))
10267
10653
  .parse();
10268
10654
  `
@@ -10289,7 +10675,7 @@ export const healthEndpoint = api
10289
10675
  `
10290
10676
  }
10291
10677
  ];
10292
- if (options.database) files$1.push(...databaseFiles(name$1));
10678
+ if (options.database) files$1.push(...databaseFiles());
10293
10679
  if (options.telescope) files$1.push({
10294
10680
  path: "src/config/telescope.ts",
10295
10681
  content: `import { Telescope } from '@geekmidas/telescope';
@@ -10353,8 +10739,8 @@ const serverlessTemplate = {
10353
10739
  "@geekmidas/rate-limit": GEEKMIDAS_VERSIONS["@geekmidas/rate-limit"],
10354
10740
  "@geekmidas/schema": GEEKMIDAS_VERSIONS["@geekmidas/schema"],
10355
10741
  "@hono/node-server": "~1.14.1",
10356
- hono: "~4.8.2",
10357
- pino: "~9.6.0"
10742
+ hono: DEPENDENCY_VERSIONS.hono,
10743
+ pino: DEPENDENCY_VERSIONS.pino
10358
10744
  },
10359
10745
  devDependencies: {
10360
10746
  "@biomejs/biome": "~2.3.0",
@@ -10397,6 +10783,9 @@ export const logger = createLogger();
10397
10783
  import { logger } from '../config/logger.ts';
10398
10784
 
10399
10785
  export const api = new RestApi('Api', {
10786
+ // The project is the app.
10787
+ path: '.',
10788
+
10400
10789
  // Typed out rather than omitted: an API that ships open because a field was
10401
10790
  // left off is the one default worth refusing to have.
10402
10791
  defaultAuthorizer: 'none',
@@ -10419,7 +10808,7 @@ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials
10419
10808
  export const config = envParser
10420
10809
  .create((get) => ({
10421
10810
  nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
10422
- stage: get('STAGE').enum(['dev', 'staging', 'prod']).default('dev'),
10811
+ stage: ${stageEnv(options.stages)},
10423
10812
  }))
10424
10813
  .parse();
10425
10814
  `
@@ -10449,19 +10838,27 @@ export const healthEndpoint = api
10449
10838
  },
10450
10839
  {
10451
10840
  path: "src/functions/hello.ts",
10452
- content: `import { f } from '@geekmidas/constructs/functions';
10453
- import { z } from 'zod';
10841
+ content: `import { z } from 'zod';
10842
+ import { worker } from '~/constructs/worker.ts';
10454
10843
 
10455
- export const helloFunction = f
10844
+ export const helloFunction = worker
10456
10845
  .input(z.object({ name: z.string() }))
10457
10846
  .output(z.object({ message: z.string() }))
10458
10847
  .handle(async ({ input }) => ({
10459
10848
  message: \`Hello, \${input.name}!\`,
10460
10849
  }));
10850
+ `
10851
+ },
10852
+ {
10853
+ path: "src/constructs/worker.ts",
10854
+ content: `import { Worker } from '@geekmidas/constructs/worker';
10855
+ import { logger } from '../config/logger.ts';
10856
+
10857
+ export const worker = new Worker('Jobs', { logger });
10461
10858
  `
10462
10859
  }
10463
10860
  ];
10464
- if (options.database) files$1.push(...databaseFiles(name$1));
10861
+ if (options.database) files$1.push(...databaseFiles());
10465
10862
  if (options.telescope) files$1.push({
10466
10863
  path: "src/config/telescope.ts",
10467
10864
  content: `import { Telescope } from '@geekmidas/telescope';
@@ -10470,7 +10867,7 @@ import { InMemoryStorage } from '@geekmidas/telescope/storage/memory';
10470
10867
  // Note: For production Lambda, consider using a persistent storage
10471
10868
  export const telescope = new Telescope({
10472
10869
  storage: new InMemoryStorage({ maxEntries: 50 }),
10473
- enabled: process.env.STAGE === 'dev',
10870
+ enabled: process.env.STAGE === '${options.stages.local}',
10474
10871
  });
10475
10872
  `
10476
10873
  });
@@ -10490,12 +10887,11 @@ export const telescope = new Telescope({
10490
10887
  * that was missing: it takes no authorizer, publishes no address, and hands out
10491
10888
  * factories already carrying its logger.
10492
10889
  *
10493
- * It scaffolds no cron. A cron is the obvious thing to put in a worker and it
10494
- * would not run: `CronGenerator` emits handlers for `aws-lambda` only, and
10495
- * `.gkm/server/` has no crons file, so a scheduled job on a server target
10496
- * deploys nothing at all. Subscribers and queues do have a server runtime,
10497
- * which is why those are what this template teaches. The cron example returns
10498
- * when there is a scheduler to run it.
10890
+ * It scaffolds a cron when the project has a database, and not otherwise. A
10891
+ * server fires its own crons, and the schedule lives in Postgres so that
10892
+ * running more than one replica still fires each job once — so a worker names
10893
+ * the database it keeps schedules in. Without one there is nowhere to keep
10894
+ * them, and an example that could not run is worse than none.
10499
10895
  */
10500
10896
  const workerTemplate = {
10501
10897
  name: "worker",
@@ -10507,7 +10903,7 @@ const workerTemplate = {
10507
10903
  "@geekmidas/events": GEEKMIDAS_VERSIONS["@geekmidas/events"],
10508
10904
  "@geekmidas/logger": GEEKMIDAS_VERSIONS["@geekmidas/logger"],
10509
10905
  "@geekmidas/schema": GEEKMIDAS_VERSIONS["@geekmidas/schema"],
10510
- pino: "~10.3.1"
10906
+ pino: DEPENDENCY_VERSIONS.pino
10511
10907
  },
10512
10908
  devDependencies: {
10513
10909
  "@biomejs/biome": "~2.3.0",
@@ -10529,7 +10925,7 @@ const workerTemplate = {
10529
10925
  "fmt:check": "biome format ."
10530
10926
  },
10531
10927
  files: (options) => {
10532
- const { loggerType, name: name$1 } = options;
10928
+ const { loggerType, name: name$1, database } = options;
10533
10929
  const loggerContent = `import { createLogger } from '@geekmidas/logger/${loggerType}';
10534
10930
 
10535
10931
  export const logger = createLogger();
@@ -10538,9 +10934,9 @@ export const logger = createLogger();
10538
10934
  {
10539
10935
  path: "src/constructs/worker.ts",
10540
10936
  content: `import { Worker } from '@geekmidas/constructs/worker';
10541
- import { logger } from '../config/logger.ts';
10937
+ import { logger } from '../config/logger.ts';${database ? "\nimport { database } from './database.ts';" : ""}
10542
10938
 
10543
- export const worker = new Worker('Jobs', { logger });
10939
+ export const worker = new Worker('Jobs', { logger })${database ? ".database(database)" : ""};
10544
10940
  `
10545
10941
  },
10546
10942
  {
@@ -10555,7 +10951,7 @@ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials
10555
10951
  export const config = envParser
10556
10952
  .create((get) => ({
10557
10953
  nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
10558
- stage: get('STAGE').enum(['development', 'staging', 'production']).default('development'),
10954
+ stage: ${stageEnv(options.stages)},
10559
10955
  }))
10560
10956
  .parse();
10561
10957
  `
@@ -10608,7 +11004,7 @@ export const eventsPublisherService = {
10608
11004
  content: `import { worker } from '~/constructs/worker.ts';
10609
11005
  import { eventsPublisherService } from '~/events/publisher.ts';
10610
11006
 
10611
- export const userEventsSubscriber = worker.subscribers
11007
+ export const userEventsSubscriber = worker
10612
11008
  .publisher(eventsPublisherService)
10613
11009
  .subscribe(['user.created', 'user.updated'])
10614
11010
  .handle(async ({ events, logger }) => {
@@ -10630,7 +11026,22 @@ export const userEventsSubscriber = worker.subscribers
10630
11026
  `
10631
11027
  }
10632
11028
  ];
10633
- if (options.database) files$1.push(...databaseFiles(name$1));
11029
+ if (database) files$1.push({
11030
+ path: "src/crons/cleanup.ts",
11031
+ content: `import { worker } from '~/constructs/worker.ts';
11032
+
11033
+ export const cleanup = worker
11034
+ .cron('rate(1 day)')
11035
+ .handle(async ({ logger }) => {
11036
+ logger.info('Running cleanup');
11037
+
11038
+ // Delete old sessions, clear temp files, whatever falls out of date.
11039
+
11040
+ logger.info('Cleanup complete');
11041
+ });
11042
+ `
11043
+ });
11044
+ if (options.database) files$1.push(...databaseFiles());
10634
11045
  if (options.telescope) files$1.push({
10635
11046
  path: "src/config/telescope.ts",
10636
11047
  content: `import { Telescope } from '@geekmidas/telescope';
@@ -10651,7 +11062,7 @@ export const telescope = new Telescope({
10651
11062
  /**
10652
11063
  * OpenAPI output path (fixed, not configurable)
10653
11064
  */
10654
- const OPENAPI_OUTPUT_PATH$1 = "./.gkm/openapi.ts";
11065
+ const OPENAPI_OUTPUT_PATH$1 = "./.gkm/openapi/api.ts";
10655
11066
  /**
10656
11067
  * Endpoints output path (generated by gkm dev/build)
10657
11068
  */
@@ -10757,15 +11168,23 @@ const packageManagerChoices = [
10757
11168
  /**
10758
11169
  * Deploy target choices for prompts
10759
11170
  */
10760
- const deployTargetChoices = [{
10761
- title: "Dokploy",
10762
- value: "dokploy",
10763
- description: "Deploy to Dokploy (Docker-based hosting)"
10764
- }, {
10765
- title: "Configure later",
10766
- value: "none",
10767
- description: "Skip deployment setup for now"
10768
- }];
11171
+ const deployTargetChoices = [
11172
+ {
11173
+ title: "Dokploy",
11174
+ value: "dokploy",
11175
+ description: "Deploy to Dokploy (Docker-based hosting)"
11176
+ },
11177
+ {
11178
+ title: "AWS (SST)",
11179
+ value: "sst",
11180
+ description: "Deploy to your AWS account through SST"
11181
+ },
11182
+ {
11183
+ title: "Configure later",
11184
+ value: "none",
11185
+ description: "Skip deployment setup for now"
11186
+ }
11187
+ ];
10769
11188
  /**
10770
11189
  * Services choices for multi-select prompt
10771
11190
  */
@@ -10858,12 +11277,12 @@ function generatePackageJson(options, template) {
10858
11277
  if (declares && services.mail) dependencies$1["@geekmidas/emailkit"] = GEEKMIDAS_VERSIONS["@geekmidas/emailkit"];
10859
11278
  if (database) {
10860
11279
  dependencies$1["@geekmidas/db"] = GEEKMIDAS_VERSIONS["@geekmidas/db"];
10861
- dependencies$1.kysely = "~0.28.2";
10862
- dependencies$1.pg = "~8.16.0";
10863
- devDependencies$1["@types/pg"] = "~8.15.0";
11280
+ dependencies$1.kysely = DEPENDENCY_VERSIONS.kysely;
11281
+ dependencies$1.pg = DEPENDENCY_VERSIONS.pg;
11282
+ devDependencies$1["@types/pg"] = "~8.23.1";
10864
11283
  devDependencies$1["@geekmidas/testkit"] = GEEKMIDAS_VERSIONS["@geekmidas/testkit"];
10865
11284
  devDependencies$1["@faker-js/faker"] = "~9.8.0";
10866
- devDependencies$1["kysely-ctl"] = "~0.10.0";
11285
+ devDependencies$1["kysely-ctl"] = DEPENDENCY_VERSIONS["kysely-ctl"];
10867
11286
  devDependencies$1["vite-tsconfig-paths"] = "~5.1.0";
10868
11287
  }
10869
11288
  if (monorepo) {
@@ -10883,6 +11302,8 @@ function generatePackageJson(options, template) {
10883
11302
  const appName = pathParts[pathParts.length - 1] || "api";
10884
11303
  packageName = `@${name$1}/${appName}`;
10885
11304
  }
11305
+ dependencies$1["@geekmidas/client"] = GEEKMIDAS_VERSIONS["@geekmidas/client"];
11306
+ dependencies$1["@tanstack/react-query"] = DEPENDENCY_VERSIONS["@tanstack/react-query"];
10886
11307
  const packageJson = {
10887
11308
  name: packageName,
10888
11309
  version: "0.0.1",
@@ -10920,7 +11341,7 @@ function generateSourceFiles(options, template) {
10920
11341
  */
10921
11342
  function generateTestFiles(options, _template) {
10922
11343
  if (!options.database) return [];
10923
- const db = databaseFor(options.name);
11344
+ const db = databaseFor();
10924
11345
  const declares = !options.monorepo;
10925
11346
  const schema = declares ? "../src/constructs/database.ts" : "~/services/database.ts";
10926
11347
  const runtimeUrl = declares ? db.urlKey : "DATABASE_URL";
@@ -10956,14 +11377,16 @@ import pg from 'pg';
10956
11377
  import { wrapVitestKyselyTransaction } from '@geekmidas/testkit/kysely';
10957
11378
  import type { Database } from '${schema}';
10958
11379
 
10959
- const connection = new Kysely<Database>({
11380
+ const db = new Kysely<Database>({
10960
11381
  dialect: new PostgresDialect({
10961
11382
  pool: new pg.Pool({ connectionString: process.env.${runtimeUrl} }),
10962
11383
  }),
10963
11384
  });
10964
11385
 
10965
11386
  export const it = wrapVitestKyselyTransaction<Database>(itVitest, {
10966
- connection,
11387
+ // A function, or a construct: an instance is taken for a construct and has
11388
+ // no service to register.
11389
+ connection: () => db,
10967
11390
  });
10968
11391
  `
10969
11392
  },
@@ -11048,10 +11471,10 @@ export const usersBuilder = KyselyFactory.createBuilder<Database, 'users'>(
11048
11471
  import { it } from './config.ts';
11049
11472
 
11050
11473
  describe('example', () => {
11051
- it('should have a working test setup', async ({ db }) => {
11052
- // db is a transaction-wrapped Kysely instance
11053
- // All changes are automatically rolled back after the test
11054
- expect(db).toBeDefined();
11474
+ it('should have a working test setup', async ({ trx }) => {
11475
+ // trx is a Kysely transaction; everything in it is rolled back after the
11476
+ // test
11477
+ expect(trx).toBeDefined();
11055
11478
  });
11056
11479
  });
11057
11480
  `
@@ -12302,8 +12725,8 @@ export const Alert: Story = {
12302
12725
  ),
12303
12726
  };
12304
12727
  `;
12305
- const componentsUiIndex = `export { Button, type ButtonProps, buttonVariants } from './button.tsx';
12306
- export { Input } from './input.tsx';
12728
+ const componentsUiIndex = `export { Button, type ButtonProps, buttonVariants } from './button/index.tsx';
12729
+ export { Input } from './input/index.tsx';
12307
12730
  export {
12308
12731
  Card,
12309
12732
  CardHeader,
@@ -12311,17 +12734,17 @@ export {
12311
12734
  CardTitle,
12312
12735
  CardDescription,
12313
12736
  CardContent,
12314
- } from './card.tsx';
12315
- export { Label } from './label.tsx';
12316
- export { Badge, type BadgeProps, badgeVariants } from './badge.tsx';
12317
- export { Separator } from './separator.tsx';
12318
- export { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs.tsx';
12737
+ } from './card/index.tsx';
12738
+ export { Label } from './label/index.tsx';
12739
+ export { Badge, type BadgeProps, badgeVariants } from './badge/index.tsx';
12740
+ export { Separator } from './separator/index.tsx';
12741
+ export { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs/index.tsx';
12319
12742
  export {
12320
12743
  Tooltip,
12321
12744
  TooltipTrigger,
12322
12745
  TooltipContent,
12323
12746
  TooltipProvider,
12324
- } from './tooltip.tsx';
12747
+ } from './tooltip/index.tsx';
12325
12748
  export {
12326
12749
  Dialog,
12327
12750
  DialogPortal,
@@ -12333,7 +12756,7 @@ export {
12333
12756
  DialogFooter,
12334
12757
  DialogTitle,
12335
12758
  DialogDescription,
12336
- } from './dialog.tsx';
12759
+ } from './dialog/index.tsx';
12337
12760
  `;
12338
12761
  const buttonIndexTsx = buttonTsx;
12339
12762
  const inputIndexTsx = inputTsx;
@@ -12501,8 +12924,8 @@ function generateWebAppFiles(options) {
12501
12924
  [uiPackage]: "workspace:*",
12502
12925
  "@geekmidas/client": GEEKMIDAS_VERSIONS["@geekmidas/client"],
12503
12926
  "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
12504
- "@tanstack/react-query": "~5.80.0",
12505
- "better-auth": "~1.2.0",
12927
+ "@tanstack/react-query": DEPENDENCY_VERSIONS["@tanstack/react-query"],
12928
+ "better-auth": DEPENDENCY_VERSIONS["better-auth"],
12506
12929
  next: "~16.1.0",
12507
12930
  react: "~19.2.0",
12508
12931
  "react-dom": "~19.2.0"
@@ -12558,7 +12981,7 @@ export default nextConfig;
12558
12981
  baseUrl: ".",
12559
12982
  paths: {
12560
12983
  "~/*": ["./src/*", "../../packages/ui/src/*"],
12561
- [`${apiPackage}/client`]: ["../../apps/api/.gkm/openapi.ts"],
12984
+ [`${apiPackage}/client`]: ["../../apps/api/.gkm/openapi/api.ts"],
12562
12985
  [`${modelsPackage}`]: ["../../packages/models/src"],
12563
12986
  [`${modelsPackage}/*`]: ["../../packages/models/src/*"],
12564
12987
  [`${uiPackage}`]: ["../../packages/ui/src"],
@@ -12571,12 +12994,7 @@ export default nextConfig;
12571
12994
  "**/*.tsx",
12572
12995
  ".next/types/**/*.ts"
12573
12996
  ],
12574
- exclude: ["node_modules"],
12575
- references: [
12576
- { path: "../../apps/api" },
12577
- { path: "../../packages/ui" },
12578
- { path: "../../packages/models" }
12579
- ]
12997
+ exclude: ["node_modules"]
12580
12998
  };
12581
12999
  const queryClientTs = `import { QueryClient } from '@tanstack/react-query';
12582
13000
 
@@ -12839,10 +13257,10 @@ function generateTanStackWebFiles(options) {
12839
13257
  [uiPackage]: "workspace:*",
12840
13258
  "@geekmidas/client": GEEKMIDAS_VERSIONS["@geekmidas/client"],
12841
13259
  "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
12842
- "@tanstack/react-query": "~5.80.0",
13260
+ "@tanstack/react-query": DEPENDENCY_VERSIONS["@tanstack/react-query"],
12843
13261
  "@tanstack/react-router": "^1.87.0",
12844
13262
  "@tanstack/react-start": "^1.87.0",
12845
- "better-auth": "~1.2.0",
13263
+ "better-auth": DEPENDENCY_VERSIONS["better-auth"],
12846
13264
  react: "~19.2.0",
12847
13265
  "react-dom": "~19.2.0"
12848
13266
  },
@@ -12910,7 +13328,7 @@ export default defineConfig({
12910
13328
  [`${modelsPackage}/*`]: ["../../packages/models/src/*"],
12911
13329
  [uiPackage]: ["../../packages/ui/src"],
12912
13330
  [`${uiPackage}/*`]: ["../../packages/ui/src/*"],
12913
- [`${apiPackage}/client`]: ["../../apps/api/.gkm/openapi.ts"]
13331
+ [`${apiPackage}/client`]: ["../../apps/api/.gkm/openapi/api.ts"]
12914
13332
  }
12915
13333
  },
12916
13334
  include: [
@@ -13200,12 +13618,27 @@ function detectPackageManager(cwd = process.cwd()) {
13200
13618
  if (userAgent.includes("bun")) return "bun";
13201
13619
  return "npm";
13202
13620
  }
13621
+ /** `gkm init`'s flags, which a name can swallow when a space goes missing. */
13622
+ const INIT_FLAGS = [
13623
+ "yes",
13624
+ "name",
13625
+ "template",
13626
+ "monorepo",
13627
+ "api-path",
13628
+ "pm",
13629
+ "skip-install"
13630
+ ];
13203
13631
  /**
13204
13632
  * Validate project name for npm package naming conventions
13205
13633
  */
13206
13634
  function validateProjectName(name$1) {
13207
13635
  if (!name$1) return "Project name is required";
13208
13636
  if (!/^[a-z0-9-_@/.]+$/i.test(name$1)) return "Project name can only contain letters, numbers, hyphens, underscores, @, /, and .";
13637
+ const flag = name$1.match(/--(\w[\w-]*)$/)?.[1];
13638
+ if (flag && INIT_FLAGS.includes(flag)) {
13639
+ const intended = name$1.slice(0, -(flag.length + 2));
13640
+ return `"${name$1}" ends in the --${flag} flag. Did you mean \`${intended} --${flag}\`?`;
13641
+ }
13209
13642
  const reserved = [
13210
13643
  "node_modules",
13211
13644
  ".git",
@@ -13245,6 +13678,18 @@ function getRunCommand(pkgManager, script) {
13245
13678
  default: return `npm run ${script}`;
13246
13679
  }
13247
13680
  }
13681
+ /**
13682
+ * Run a binary the project installed, through its package manager — so it is
13683
+ * the project's own version, not whatever \`npx\` would fetch.
13684
+ */
13685
+ function getExecCommand(pkgManager, command) {
13686
+ switch (pkgManager) {
13687
+ case "pnpm": return `pnpm exec ${command}`;
13688
+ case "yarn": return `yarn ${command}`;
13689
+ case "bun": return `bunx ${command}`;
13690
+ default: return `npx --no-install ${command}`;
13691
+ }
13692
+ }
13248
13693
  const lockfileByPm = {
13249
13694
  pnpm: "pnpm-lock.yaml",
13250
13695
  yarn: "yarn.lock",
@@ -13315,12 +13760,31 @@ function findWorkspacePackages(cwd, pm) {
13315
13760
 
13316
13761
  //#endregion
13317
13762
  //#region src/init/index.ts
13763
+ /** `' staging, prod '` → `['staging', 'prod']`. */
13764
+ const stageList = (value) => value.split(",").map((name$1) => name$1.trim()).filter(Boolean);
13765
+ /** `eu-west-1`, `us-gov-west-1`, `ap-southeast-2`. */
13766
+ const AWS_REGION = /^[a-z]{2}(-gov)?-[a-z]+-\d$/;
13318
13767
  /**
13319
13768
  * Main init command - scaffolds a new project
13320
13769
  */
13321
13770
  async function initCommand(projectName, options = {}) {
13322
13771
  const cwd = process.cwd();
13323
13772
  const detectedPkgManager = detectPackageManager(cwd);
13773
+ if (options.deploy && !deployTargetChoices.some((choice) => choice.value === options.deploy)) throw new Error(`Unknown deploy target "${options.deploy}". Use ${deployTargetChoices.map((c) => c.value).join(", ")}.`);
13774
+ if (options.region && !AWS_REGION.test(options.region)) throw new Error(`"${options.region}" is not an AWS region. Use one like eu-west-1.`);
13775
+ if (options.stages || options.localStage || options.protectedStage) {
13776
+ const deployed = stageList(options.stages ?? "");
13777
+ const problems = [
13778
+ ...deployedProblems(deployed),
13779
+ ...options.localStage ? stageProblems({
13780
+ local: options.localStage,
13781
+ deployed
13782
+ }) : [],
13783
+ ...options.protectedStage && !deployed.includes(options.protectedStage) ? [`--protected-stage "${options.protectedStage}" is not in --stages`] : []
13784
+ ];
13785
+ if (problems.length) throw new Error([...new Set(problems)].join("\n"));
13786
+ }
13787
+ let deployedSoFar = [];
13324
13788
  prompts.override({});
13325
13789
  const onCancel = () => {
13326
13790
  process.exit(0);
@@ -13371,12 +13835,49 @@ async function initCommand(projectName, options = {}) {
13371
13835
  initial: packageManagerChoices.findIndex((c) => c.value === detectedPkgManager)
13372
13836
  },
13373
13837
  {
13374
- type: options.yes ? null : "select",
13838
+ type: options.yes || options.deploy ? null : "select",
13375
13839
  name: "deployTarget",
13376
13840
  message: "Deployment target:",
13377
13841
  choices: deployTargetChoices,
13378
13842
  initial: 0
13379
13843
  },
13844
+ {
13845
+ type: (_prev, values) => !options.yes && !options.region && (options.deploy ?? values.deployTarget) === "sst" ? "text" : null,
13846
+ name: "region",
13847
+ message: "AWS region (e.g. eu-west-1):",
13848
+ validate: (value) => AWS_REGION.test(value.trim()) || "An AWS region, like eu-west-1"
13849
+ },
13850
+ {
13851
+ type: options.yes || options.stages ? null : "text",
13852
+ name: "deployedStages",
13853
+ message: "Deployed stages, comma-separated (e.g. staging, prod):",
13854
+ validate: (value) => {
13855
+ const deployed = stageList(value);
13856
+ if (deployed.length === 0) return "At least one stage";
13857
+ return deployedProblems(deployed)[0] ?? true;
13858
+ }
13859
+ },
13860
+ {
13861
+ type: (_prev, values) => options.yes || options.protectedStage ? null : stageList(options.stages ?? values.deployedStages ?? "").length > 1 ? "select" : null,
13862
+ name: "protectedStage",
13863
+ message: "Which one is production (retained and protected)?",
13864
+ choices: (_prev, values) => stageList(options.stages ?? values.deployedStages ?? "").map((name$2) => ({
13865
+ title: name$2,
13866
+ value: name$2
13867
+ }))
13868
+ },
13869
+ {
13870
+ type: (_prev, values) => {
13871
+ deployedSoFar = stageList(options.stages ?? values.deployedStages ?? "");
13872
+ return options.yes || options.localStage ? null : "text";
13873
+ },
13874
+ name: "localStage",
13875
+ message: "Local stage, what gkm dev runs as (e.g. dev):",
13876
+ validate: (value) => stageProblems({
13877
+ local: value.trim(),
13878
+ deployed: deployedSoFar
13879
+ })[0] ?? true
13880
+ },
13380
13881
  {
13381
13882
  type: options.yes ? null : "confirm",
13382
13883
  name: "telescope",
@@ -13442,7 +13943,7 @@ async function initCommand(projectName, options = {}) {
13442
13943
  };
13443
13944
  if (services.events === "pgboss") services.db = true;
13444
13945
  const pkgManager = options.pm ? options.pm : options.yes ? "pnpm" : answers.packageManager ?? detectedPkgManager;
13445
- const deployTarget = options.yes ? "dokploy" : answers.deployTarget ?? "dokploy";
13946
+ const deployTarget = options.deploy ?? (options.yes ? "none" : answers.deployTarget ?? "none");
13446
13947
  const database = services.db;
13447
13948
  const frontendFramework = isFullstack ? options.yes ? "nextjs" : answers.frontendFramework ?? "nextjs" : void 0;
13448
13949
  const templateOptions = {
@@ -13457,6 +13958,8 @@ async function initCommand(projectName, options = {}) {
13457
13958
  apiPath: monorepo ? options.apiPath ?? "apps/api" : "",
13458
13959
  packageManager: pkgManager,
13459
13960
  deployTarget,
13961
+ stages: resolveStages(options, answers),
13962
+ ...deployTarget === "sst" ? { region: options.region ?? (options.yes ? "eu-west-1" : answers.region?.trim()) } : {},
13460
13963
  services,
13461
13964
  frontendFramework
13462
13965
  };
@@ -13532,12 +14035,12 @@ async function initCommand(projectName, options = {}) {
13532
14035
  if (services.mail) secretServices.push("mailpit");
13533
14036
  if (services.events === "sns") secretServices.push("localstack");
13534
14037
  if (services.events === "rabbitmq") secretServices.push("rabbitmq");
13535
- const devSecrets = createStageSecrets("development", secretServices, {
14038
+ const local = templateOptions.stages.local;
14039
+ const devSecrets = createStageSecrets(local, secretServices, {
13536
14040
  projectName: name$1,
13537
14041
  eventsBackend: services.events
13538
14042
  });
13539
14043
  const customSecrets = {
13540
- NODE_ENV: "development",
13541
14044
  PORT: "3000",
13542
14045
  LOG_LEVEL: "debug",
13543
14046
  JWT_SECRET: `dev-${Date.now()}-${Math.random().toString(36).slice(2)}`
@@ -13557,8 +14060,8 @@ async function initCommand(projectName, options = {}) {
13557
14060
  }
13558
14061
  devSecrets.custom = customSecrets;
13559
14062
  await writeStageSecrets(devSecrets, targetDir);
13560
- const keyPath = getKeyPath("development", name$1);
13561
- console.log(` Secrets: .gkm/secrets/development.json (encrypted)`);
14063
+ const keyPath = getKeyPath(local, name$1);
14064
+ console.log(` Secrets: .gkm/secrets/${local}.json (encrypted)`);
13562
14065
  console.log(` Key: ${keyPath}\n`);
13563
14066
  if (!options.skipInstall) {
13564
14067
  console.log("\n📦 Installing dependencies...\n");
@@ -13571,11 +14074,13 @@ async function initCommand(projectName, options = {}) {
13571
14074
  console.error("Failed to install dependencies");
13572
14075
  }
13573
14076
  try {
13574
- execSync("npx @biomejs/biome format --write --unsafe .", {
14077
+ execSync(getExecCommand(pkgManager, "biome check --write ."), {
13575
14078
  cwd: targetDir,
13576
14079
  stdio: "inherit"
13577
14080
  });
13578
- } catch {}
14081
+ } catch {
14082
+ console.warn(`\n⚠️ Formatting did not finish cleanly. Run ${getRunCommand(pkgManager, "fmt")} to see why.`);
14083
+ }
13579
14084
  }
13580
14085
  console.log("\n📦 Initializing git repository...\n");
13581
14086
  try {
@@ -13647,19 +14152,46 @@ function printNextSteps(projectName, options, pkgManager) {
13647
14152
  console.log(` └── turbo.json # Turbo config`);
13648
14153
  console.log("");
13649
14154
  }
14155
+ const { local, deployed } = options.stages;
13650
14156
  console.log("🔐 Secrets management:");
13651
- console.log(` gkm secrets:show --stage development # View secrets`);
13652
- console.log(` gkm secrets:set KEY VALUE --stage development # Add secret`);
13653
- console.log(` gkm secrets:init --stage production # Create production secrets`);
14157
+ console.log(` gkm secrets:show --stage ${local} # View secrets`);
14158
+ console.log(` gkm secrets:set KEY VALUE --stage ${local} # Add secret`);
14159
+ for (const stage of deployed) console.log(` gkm secrets:init --stage ${stage} # Create ${stage} secrets`);
13654
14160
  console.log("");
13655
14161
  if (options.deployTarget === "dokploy") {
13656
14162
  console.log("🚀 Deployment:");
13657
- console.log(` ${getRunCommand(pkgManager, "deploy")}`);
14163
+ for (const stage of deployed) console.log(` ${getRunCommand(pkgManager, `deploy:${stage}`)}`);
14164
+ console.log("");
14165
+ }
14166
+ if (options.deployTarget === "sst") {
14167
+ console.log("🚀 Deployment (AWS, through SST):");
14168
+ for (const stage of deployed) console.log(` ${getRunCommand(pkgManager, `deploy:${stage}`)} # gkm build --provider aws, then sst deploy`);
14169
+ console.log(` Uses your AWS credentials, in ${options.region}.`);
14170
+ if (options.services.mail) console.log(" Set MAIL_FROM to a sender verified in SES first.");
13658
14171
  console.log("");
13659
14172
  }
13660
14173
  console.log("📚 Documentation: https://geekmidas.github.io/toolbox/");
13661
14174
  console.log("");
13662
14175
  }
14176
+ /**
14177
+ * The stages the project declares, from the answers or the flags.
14178
+ *
14179
+ * `--yes` with no stage flags has nobody to ask and needs an answer, so it
14180
+ * takes `local`, and one deployed stage, `production`, that is protected.
14181
+ */
14182
+ function resolveStages(options, answers) {
14183
+ const deployed = stageList(options.stages ?? answers.deployedStages ?? (options.yes ? "production" : ""));
14184
+ const kept = options.protectedStage ?? answers.protectedStage ?? (deployed.length === 1 || options.yes ? deployed[0] : void 0);
14185
+ const local = (options.localStage ?? answers.localStage ?? (options.yes ? "local" : "")).trim();
14186
+ const stages = {
14187
+ local,
14188
+ deployed,
14189
+ ...kept ? { protected: [kept] } : {}
14190
+ };
14191
+ const problems = stageProblems(stages);
14192
+ if (problems.length) throw new Error(problems.join("\n"));
14193
+ return stages;
14194
+ }
13663
14195
 
13664
14196
  //#endregion
13665
14197
  //#region src/secrets/index.ts
@@ -13870,19 +14402,20 @@ function maskUrl(url) {
13870
14402
  * Environment variables are sniffed to inject only what the app needs.
13871
14403
  */
13872
14404
  async function testCommand(options = {}) {
13873
- const stage = options.stage ?? "development";
13874
14405
  const cwd = process.cwd();
14406
+ const stage = options.stage ?? await loadWorkspaceConfig(cwd).then((loaded) => loaded.workspace.stages.local).catch(() => void 0);
14407
+ if (!stage) throw new Error("No stage to test with: add `stages` to gkm.config.ts, or pass --stage.");
13875
14408
  console.log(`\n🧪 Running tests with ${stage} environment...\n`);
13876
14409
  const autoSetup = options.autoSetup || Boolean(process.env.GKM_AUTO_SETUP);
13877
14410
  if (autoSetup) {
13878
- const { ensureStageSecrets } = await import("./setup-ZijS4qeC.mjs");
14411
+ const { ensureStageSecrets } = await import("./setup-BA8ciTPU.mjs");
13879
14412
  const generated = await ensureStageSecrets(stage, cwd);
13880
14413
  if (generated) console.log(` 🔐 Generated fresh ${stage} secrets (auto-setup)`);
13881
14414
  }
13882
14415
  const defaultEnv = loadEnvFiles(".env");
13883
14416
  if (defaultEnv.loaded.length > 0) console.log(` 📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
13884
14417
  const result = await prepareEntryCredentials({
13885
- stages: [stage],
14418
+ stage,
13886
14419
  startDocker: true,
13887
14420
  secretsFileName: "test-secrets.json",
13888
14421
  resolveDockerPorts: "full",
@@ -14110,7 +14643,7 @@ program.name("gkm").description("GeekMidas backend framework CLI").version(packa
14110
14643
  const opts = program.opts();
14111
14644
  if (opts.debug) enableDebug();
14112
14645
  });
14113
- program.command("init").description("Scaffold a new project").argument("[name]", "Project name").option("--template <template>", "Project template (minimal, api, serverless, worker)").option("--skip-install", "Skip dependency installation", false).option("-y, --yes", "Skip prompts, use defaults", false).option("--monorepo", "Setup as monorepo with packages/models", false).option("--api-path <path>", "API app path in monorepo (default: apps/api)").option("--pm <manager>", "Package manager (pnpm, npm, yarn, bun)").action(async (name$1, options) => {
14646
+ program.command("init").description("Scaffold a new project").argument("[name]", "Project name").option("--template <template>", "Project template (minimal, api, serverless, worker)").option("--skip-install", "Skip dependency installation", false).option("-y, --yes", "Skip prompts, use defaults", false).option("--monorepo", "Setup as monorepo with packages/models", false).option("--api-path <path>", "API app path in monorepo (default: apps/api)").option("--pm <manager>", "Package manager (pnpm, npm, yarn, bun)").option("--deploy <target>", "Where it deploys (dokploy, sst, none)").option("--region <region>", "AWS region for an SST deploy (e.g. eu-west-1)").option("--stages <names>", "Deployed stages, comma-separated (e.g. staging,prod)").option("--protected-stage <name>", "Which deployed stage is production").option("--local-stage <name>", "What gkm dev runs as (e.g. dev)").option("--region <region>", "AWS region for an SST deploy (e.g. eu-west-1)").action(async (name$1, options) => {
14114
14647
  try {
14115
14648
  const globalOptions = program.opts();
14116
14649
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
@@ -14130,7 +14663,7 @@ program.command("trust").description("Trust the local edge's certificate authori
14130
14663
  process.exit(1);
14131
14664
  }
14132
14665
  });
14133
- program.command("setup").description("Reconcile declared constructs — containers, databases, buckets, secrets").option("--stage <stage>", "Stage name", "development").option("--force", "Regenerate secrets even if they exist").option("--skip-docker", "Skip starting Docker services").option("-y, --yes", "Skip prompts").action(async (options) => {
14666
+ program.command("setup").description("Reconcile declared constructs — containers, databases, buckets, secrets").option("--stage <stage>", "Stage name (default: stages.local)").option("--force", "Regenerate secrets even if they exist").option("--skip-docker", "Skip starting Docker services").option("-y, --yes", "Skip prompts").action(async (options) => {
14134
14667
  try {
14135
14668
  const globalOptions = program.opts();
14136
14669
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
@@ -14202,7 +14735,7 @@ program.command("exec").description("Run a command with secrets injected into Cr
14202
14735
  process.exit(1);
14203
14736
  }
14204
14737
  });
14205
- program.command("test").description("Run tests with secrets loaded from environment").option("--stage <stage>", "Stage to load secrets from", "development").option("--run", "Run tests once without watch mode").option("--watch", "Enable watch mode").option("--coverage", "Generate coverage report").option("--ui", "Open Vitest UI").option("--auto-setup", "Generate a fresh stage (secrets + key) from the workspace config when none exists (for CI; also via GKM_AUTO_SETUP)").argument("[pattern]", "Pattern to filter tests").action(async (pattern, options) => {
14738
+ program.command("test").description("Run tests with secrets loaded from environment").option("--stage <stage>", "Stage to load secrets from (default: stages.local)").option("--run", "Run tests once without watch mode").option("--watch", "Enable watch mode").option("--coverage", "Generate coverage report").option("--ui", "Open Vitest UI").option("--auto-setup", "Generate a fresh stage (secrets + key) from the workspace config when none exists (for CI; also via GKM_AUTO_SETUP)").argument("[pattern]", "Pattern to filter tests").action(async (pattern, options) => {
14206
14739
  try {
14207
14740
  const globalOptions = program.opts();
14208
14741
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
@@ -14350,7 +14883,7 @@ program.command("secrets:push").description("Push secrets to remote provider (SS
14350
14883
  const { workspace } = await loadWorkspaceConfig$1();
14351
14884
  const secrets = await readStageSecrets$1(options.stage, workspace.root);
14352
14885
  if (secrets) {
14353
- const { derivedContainers: derivedContainers$1 } = await import("./workspace-k4l3rsFh.mjs");
14886
+ const { derivedContainers: derivedContainers$1 } = await import("./workspace-BzrG_2SI.mjs");
14354
14887
  const result = reconcileMissingSecrets(secrets, workspace, await derivedContainers$1(workspace, options.stage));
14355
14888
  if (result) {
14356
14889
  await writeStageSecrets$1(result.secrets, workspace.root);
@@ -14379,7 +14912,7 @@ program.command("secrets:pull").description("Pull secrets from remote provider (
14379
14912
  console.error(`No remote secrets found for stage "${options.stage}".`);
14380
14913
  process.exit(1);
14381
14914
  }
14382
- const { derivedContainers: derivedContainers$1 } = await import("./workspace-k4l3rsFh.mjs");
14915
+ const { derivedContainers: derivedContainers$1 } = await import("./workspace-BzrG_2SI.mjs");
14383
14916
  const result = reconcileMissingSecrets(secrets, workspace, await derivedContainers$1(workspace, options.stage));
14384
14917
  if (result) {
14385
14918
  secrets = result.secrets;
@@ -14393,7 +14926,7 @@ program.command("secrets:pull").description("Pull secrets from remote provider (
14393
14926
  process.exit(1);
14394
14927
  }
14395
14928
  });
14396
- program.command("secrets:reconcile").description("Backfill missing custom secrets from workspace config").option("--stage <stage>", "Stage name", "development").action(async (options) => {
14929
+ program.command("secrets:reconcile").description("Backfill missing custom secrets from workspace config").option("--stage <stage>", "Stage name (default: stages.local)").action(async (options) => {
14397
14930
  try {
14398
14931
  const globalOptions = program.opts();
14399
14932
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
@@ -14401,19 +14934,20 @@ program.command("secrets:reconcile").description("Backfill missing custom secret
14401
14934
  const { reconcileMissingSecrets } = await import("./reconcile-CFs8C_1a.mjs");
14402
14935
  const { readStageSecrets: readStageSecrets$1, writeStageSecrets: writeStageSecrets$1 } = await import("./storage-CYlGwWTj.mjs");
14403
14936
  const { workspace } = await loadWorkspaceConfig$1();
14404
- const secrets = await readStageSecrets$1(options.stage, workspace.root);
14937
+ const stage = options.stage ?? workspace.stages.local;
14938
+ const secrets = await readStageSecrets$1(stage, workspace.root);
14405
14939
  if (!secrets) {
14406
- console.error(`No secrets found for stage "${options.stage}". Run "gkm secrets:init --stage ${options.stage}" first.`);
14940
+ console.error(`No secrets found for stage "${stage}". Run "gkm secrets:init --stage ${stage}" first.`);
14407
14941
  process.exit(1);
14408
14942
  }
14409
- const { derivedContainers: derivedContainers$1 } = await import("./workspace-k4l3rsFh.mjs");
14410
- const result = reconcileMissingSecrets(secrets, workspace, await derivedContainers$1(workspace, options.stage));
14943
+ const { derivedContainers: derivedContainers$1 } = await import("./workspace-BzrG_2SI.mjs");
14944
+ const result = reconcileMissingSecrets(secrets, workspace, await derivedContainers$1(workspace, stage));
14411
14945
  if (!result) {
14412
- console.log(`\n✓ Secrets for stage "${options.stage}" are up-to-date`);
14946
+ console.log(`\n✓ Secrets for stage "${stage}" are up-to-date`);
14413
14947
  return;
14414
14948
  }
14415
14949
  await writeStageSecrets$1(result.secrets, workspace.root);
14416
- console.log(`\n✓ Reconciled ${result.addedKeys.length} missing secret(s) for stage "${options.stage}":`);
14950
+ console.log(`\n✓ Reconciled ${result.addedKeys.length} missing secret(s) for stage "${stage}":`);
14417
14951
  for (const key of result.addedKeys) console.log(` + ${key}`);
14418
14952
  } catch (error) {
14419
14953
  console.error(formatError(error));