@geekmidas/cli 10.0.0-alpha.0 → 10.0.0-alpha.10

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 (76) hide show
  1. package/README.md +12 -15
  2. package/bin/adjacent-tsconfig.mjs +59 -0
  3. package/bin/gkm.mjs +7 -1
  4. package/dist/{config-DbPE1KXF.cjs → config-BZ1F0WuA.cjs} +7 -80
  5. package/dist/config-BZ1F0WuA.cjs.map +1 -0
  6. package/dist/{config-D8o6I_7Z.mjs → config-DVVph9yz.mjs} +9 -76
  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-K961ThG3.cjs → credentials-Xamqe_NJ.cjs} +20 -19
  13. package/dist/credentials-Xamqe_NJ.cjs.map +1 -0
  14. package/dist/{credentials-Dh0Q6HzY.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 +2154 -859
  23. package/dist/index.cjs.map +1 -1
  24. package/dist/index.mjs +2156 -861
  25. package/dist/index.mjs.map +1 -1
  26. package/dist/{openapi-CljONd_M.cjs → openapi-SjsgARie.cjs} +127 -50
  27. package/dist/openapi-SjsgARie.cjs.map +1 -0
  28. package/dist/{openapi-CgpMWMmM.mjs → openapi-Xx0A7Wg0.mjs} +109 -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-DSutDAlt.mjs → setup-BA8ciTPU.mjs} +4 -4
  39. package/dist/setup-BsZvZP6f.cjs +11 -0
  40. package/dist/{setup-DOsYueeQ.mjs → setup-C8KOx8_8.mjs} +5 -5
  41. package/dist/setup-C8KOx8_8.mjs.map +1 -0
  42. package/dist/{setup-CJiQM2pX.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-C6VJQVtG.cjs +6845 -0
  54. package/dist/workspace-C6VJQVtG.cjs.map +1 -0
  55. package/dist/{workspace-8acupeaI.cjs → workspace-XXtNhqJl.cjs} +3 -3
  56. package/dist/workspace-zoqRbihZ.mjs +6678 -0
  57. package/dist/workspace-zoqRbihZ.mjs.map +1 -0
  58. package/package.json +33 -32
  59. package/dist/config-D8o6I_7Z.mjs.map +0 -1
  60. package/dist/config-DbPE1KXF.cjs.map +0 -1
  61. package/dist/credentials-Dh0Q6HzY.mjs.map +0 -1
  62. package/dist/credentials-K961ThG3.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-CgpMWMmM.mjs.map +0 -1
  66. package/dist/openapi-CljONd_M.cjs.map +0 -1
  67. package/dist/setup-CJiQM2pX.cjs.map +0 -1
  68. package/dist/setup-DMtU3p41.cjs +0 -11
  69. package/dist/setup-DOsYueeQ.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-7FqprxuJ.mjs +0 -4323
  73. package/dist/workspace-7FqprxuJ.mjs.map +0 -1
  74. package/dist/workspace-XvqO9jIv.cjs +0 -4472
  75. package/dist/workspace-XvqO9jIv.cjs.map +0 -1
  76. package/dist/workspace-yP5AHnrn.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-D8o6I_7Z.mjs";
5
- import { allConstructGlobs, getAppBuildOrder, getDependencyEnvVars, getDeployTargetError, getPublicEnvPrefix, isDeployTargetSupported, stripPublicPrefix } from "./workspace-7FqprxuJ.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-Dh0Q6HzY.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-CgpMWMmM.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,9 +15,9 @@ 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-DOsYueeQ.mjs";
18
+ import { setupCommand, trustCommand } from "./setup-C8KOx8_8.mjs";
19
19
  import { createRequire } from "node:module";
20
- import { copyFileSync, existsSync, readFileSync, unlinkSync } from "node:fs";
20
+ import { copyFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
21
21
  import { basename, dirname, isAbsolute, join, parse, relative, resolve } from "node:path";
22
22
  import { DEFAULT_POSTGRES_VERSION, cacheTable, canonicalId, cloudName, cookieDomain, dependentsOf, provideKey, provisionOrder, scopedName, serviceKey } from "@geekmidas/manifest";
23
23
  import fg from "fast-glob";
@@ -27,7 +27,7 @@ import * as readline from "node:readline/promises";
27
27
  import { mkdir, readFile, writeFile } from "node:fs/promises";
28
28
  import { execSync, spawn } from "node:child_process";
29
29
  import chokidar from "chokidar";
30
- import { parse as parse$1 } from "yaml";
30
+ import { parse as parse$1, parseDocument } from "yaml";
31
31
  import { Client } from "pg";
32
32
  import { createHash, randomBytes } from "node:crypto";
33
33
  import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
@@ -43,7 +43,7 @@ import prompts from "prompts";
43
43
 
44
44
  //#region package.json
45
45
  var name = "@geekmidas/cli";
46
- var version = "9.0.2";
46
+ var version = "10.0.0-alpha.10";
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";
@@ -124,12 +124,12 @@ var repository = {
124
124
  "url": "https://github.com/geekmidas/toolbox"
125
125
  };
126
126
  var dependencies = {
127
- "@apidevtools/swagger-parser": "^10.1.0",
128
- "@aws-sdk/client-iam": "~3.992.0",
129
- "@aws-sdk/client-route-53": "~3.992.0",
130
- "@aws-sdk/client-s3": "~3.992.0",
131
- "@aws-sdk/client-ssm": "~3.992.0",
132
- "@aws-sdk/credential-providers": "~3.992.0",
127
+ "@apidevtools/swagger-parser": "~13.1.0",
128
+ "@aws-sdk/client-iam": "~3.1136.0",
129
+ "@aws-sdk/client-route-53": "~3.1136.0",
130
+ "@aws-sdk/client-s3": "~3.1136.0",
131
+ "@aws-sdk/client-ssm": "~3.1136.0",
132
+ "@aws-sdk/credential-providers": "~3.1136.0",
133
133
  "@geekmidas/cache": "workspace:*",
134
134
  "@geekmidas/constructs": "workspace:~",
135
135
  "@geekmidas/db": "workspace:*",
@@ -138,28 +138,29 @@ var dependencies = {
138
138
  "@geekmidas/logger": "workspace:~",
139
139
  "@geekmidas/manifest": "workspace:*",
140
140
  "@geekmidas/schema": "workspace:~",
141
- "chokidar": "~4.0.3",
142
- "commander": "^12.1.0",
143
- "dotenv": "~17.2.3",
144
- "esbuild": "^0.28.2",
145
- "fast-glob": "^3.3.2",
146
- "hono": "~4.8.0",
147
- "lodash.kebabcase": "^4.1.1",
148
- "openapi-typescript": "^7.4.2",
149
- "pg": "~8.17.1",
141
+ "chokidar": "~5.0.0",
142
+ "commander": "~15.0.0",
143
+ "dotenv": "~18.0.1",
144
+ "esbuild": "~0.28.2",
145
+ "fast-glob": "~3.3.3",
146
+ "get-tsconfig": "~4.10.1",
147
+ "hono": "~4.13.8",
148
+ "lodash.kebabcase": "~4.1.1",
149
+ "openapi-typescript": "~7.13.0",
150
+ "pg": "~8.23.0",
150
151
  "prompts": "~2.4.2",
151
152
  "tsx": "~4.20.3",
152
- "yaml": "~2.8.2"
153
+ "yaml": "~2.9.1"
153
154
  };
154
155
  var devDependencies = {
155
156
  "@geekmidas/testkit": "workspace:*",
156
- "@types/lodash.kebabcase": "^4.1.9",
157
+ "@types/lodash.kebabcase": "~4.1.9",
157
158
  "@types/node": "~24.9.1",
158
- "@types/pg": "~8.16.0",
159
+ "@types/pg": "~8.23.1",
159
160
  "@types/prompts": "~2.4.9",
160
- "typescript": "^5.8.2",
161
- "vitest": "^3.2.4",
162
- "zod": "~4.1.13"
161
+ "typescript": "~5.8.2",
162
+ "vitest": "~3.2.4",
163
+ "zod": "~4.6.5"
163
164
  };
164
165
  var peerDependencies = { "@geekmidas/telescope": "workspace:~" };
165
166
  var peerDependenciesMeta = { "@geekmidas/telescope": { "optional": true } };
@@ -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);
446
- const content = `import { AWSScheduledFunction } from '@geekmidas/constructs/crons';
607
+ const runtime = runtimeFor(context, dirname(handlerPath), owner);
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
 
@@ -7343,301 +7510,56 @@ function printStateDetails(state) {
7343
7510
  }
7344
7511
 
7345
7512
  //#endregion
7346
- //#region src/init/versions.ts
7347
- const require$1 = createRequire(import.meta.url);
7348
- function loadPackageJson() {
7349
- try {
7350
- return require$1("../package.json");
7351
- } catch {
7352
- return require$1("../../package.json");
7353
- }
7354
- }
7355
- const pkg = loadPackageJson();
7513
+ //#region src/init/constructs.ts
7514
+ /** The glob every generated config points at. One glob, every kind. */
7515
+ const CONSTRUCTS_GLOB = "./src/constructs/**/*.ts";
7356
7516
  /**
7357
- * CLI version resolved from package.json at runtime
7517
+ * Where a workspace keeps its constructs: at the root, beside the apps that
7518
+ * share them, rather than inside any one of them.
7358
7519
  */
7359
- const CLI_VERSION = `~${pkg.version}`;
7520
+ const WORKSPACE_CONSTRUCTS_GLOB = "./constructs/**/*.ts";
7360
7521
  /**
7361
- * Package versions for @geekmidas packages
7522
+ * Where each routes structure puts an app's handlers, relative to the app.
7362
7523
  *
7363
- * AUTO-GENERATED (except CLI) - Do not edit manually
7364
- * Run: pnpm --filter @geekmidas/cli sync-versions
7365
- */
7366
- const GEEKMIDAS_VERSIONS = {
7367
- "@geekmidas/audit": "~9.0.2",
7368
- "@geekmidas/auth": "~9.0.2",
7369
- "@geekmidas/cache": "~9.0.2",
7370
- "@geekmidas/client": "~9.0.2",
7371
- "@geekmidas/cloud": "~9.0.2",
7372
- "@geekmidas/constructs": "~9.0.2",
7373
- "@geekmidas/db": "~9.0.2",
7374
- "@geekmidas/emailkit": "~9.0.2",
7375
- "@geekmidas/envkit": "~9.0.2",
7376
- "@geekmidas/errors": "~9.0.2",
7377
- "@geekmidas/events": "~9.0.2",
7378
- "@geekmidas/logger": "~9.0.2",
7379
- "@geekmidas/manifest": "~9.0.2",
7380
- "@geekmidas/rate-limit": "~9.0.2",
7381
- "@geekmidas/schema": "~9.0.2",
7382
- "@geekmidas/services": "~9.0.2",
7383
- "@geekmidas/storage": "~9.0.2",
7384
- "@geekmidas/studio": "~9.0.2",
7385
- "@geekmidas/telescope": "~9.0.2",
7386
- "@geekmidas/testkit": "~9.0.2",
7387
- "@geekmidas/cli": CLI_VERSION
7388
- };
7389
-
7390
- //#endregion
7391
- //#region src/init/generators/auth.ts
7392
- /**
7393
- * Generate auth app files for fullstack template
7394
- * Uses better-auth with magic link authentication
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.
7395
7526
  */
7396
- function generateAuthAppFiles(options) {
7397
- if (!options.monorepo || options.template !== "fullstack") return [];
7398
- const packageName = `@${options.name}/auth`;
7399
- const modelsPackage = `@${options.name}/models`;
7400
- const packageJson = {
7401
- name: packageName,
7402
- version: "0.0.1",
7403
- private: true,
7404
- type: "module",
7405
- scripts: {
7406
- dev: "gkm dev --entry ./src/index.ts",
7407
- build: "tsc",
7408
- start: "node dist/index.js",
7409
- typecheck: "tsc --noEmit",
7410
- "db:migrate": "gkm exec -- npx @better-auth/cli migrate",
7411
- "db:generate": "gkm exec -- npx @better-auth/cli generate"
7412
- },
7413
- dependencies: {
7414
- [modelsPackage]: "workspace:*",
7415
- "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
7416
- "@geekmidas/logger": GEEKMIDAS_VERSIONS["@geekmidas/logger"],
7417
- "@hono/node-server": "~1.13.0",
7418
- "better-auth": "~1.2.0",
7419
- hono: "~4.8.0",
7420
- kysely: "~0.27.0",
7421
- pg: "~8.13.0"
7422
- },
7423
- devDependencies: {
7424
- "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
7425
- "@types/node": "~22.0.0",
7426
- "@types/pg": "~8.11.0",
7427
- tsx: "~4.20.0",
7428
- typescript: "~5.8.2"
7429
- }
7430
- };
7431
- const tsConfig = {
7432
- extends: "../../tsconfig.json",
7433
- compilerOptions: {
7434
- noEmit: true,
7435
- allowImportingTsExtensions: true,
7436
- baseUrl: ".",
7437
- paths: {
7438
- "~/*": ["./src/*"],
7439
- [`@${options.name}/*`]: ["../../packages/*/src"]
7440
- }
7441
- },
7442
- include: ["src/**/*.ts"],
7443
- exclude: ["node_modules", "dist"]
7444
- };
7445
- const envTs = `import { Credentials } from '@geekmidas/envkit/credentials';
7446
- import { EnvironmentParser } from '@geekmidas/envkit';
7447
-
7448
- export const envParser = new EnvironmentParser({ ...process.env, ...Credentials });
7449
-
7450
- // Global config - only minimal shared values
7451
- // Service-specific config should be parsed where needed
7452
- export const config = envParser
7453
- .create((get) => ({
7454
- nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
7455
- stage: get('STAGE').enum(['development', 'staging', 'production']).default('development'),
7456
- }))
7457
- .parse();
7458
- `;
7459
- const loggerTs = `import { createLogger } from '@geekmidas/logger/${options.loggerType}';
7460
-
7461
- export const logger = createLogger();
7462
- `;
7463
- const authTs = `import { betterAuth } from 'better-auth';
7464
- import { magicLink } from 'better-auth/plugins';
7465
- import pg from 'pg';
7466
- import { envParser } from './config/env.ts';
7467
- import { logger } from './config/logger.ts';
7468
-
7469
- // Parse auth-specific config (no defaults - values from secrets)
7470
- const authConfig = envParser
7471
- .create((get) => ({
7472
- databaseUrl: get('DATABASE_URL').string(),
7473
- baseUrl: get('BETTER_AUTH_URL').string(),
7474
- trustedOrigins: get('BETTER_AUTH_TRUSTED_ORIGINS').string(),
7475
- secret: get('BETTER_AUTH_SECRET').string(),
7476
- }))
7477
- .parse();
7478
-
7479
- export const auth = betterAuth({
7480
- database: new pg.Pool({
7481
- connectionString: authConfig.databaseUrl,
7482
- }),
7483
- baseURL: authConfig.baseUrl,
7484
- trustedOrigins: authConfig.trustedOrigins.split(','),
7485
- secret: authConfig.secret,
7486
- plugins: [
7487
- magicLink({
7488
- sendMagicLink: async ({ email, url }) => {
7489
- // TODO: Implement email sending using @geekmidas/emailkit
7490
- // For development, log the magic link
7491
- logger.info({ email, url }, 'Magic link generated');
7492
- console.log('\\n================================');
7493
- console.log('MAGIC LINK FOR:', email);
7494
- console.log(url);
7495
- console.log('================================\\n');
7496
- },
7497
- expiresIn: 300, // 5 minutes
7498
- }),
7499
- ],
7500
- emailAndPassword: {
7501
- enabled: false, // Only magic link for now
7502
- },
7503
- });
7504
-
7505
- export type Auth = typeof auth;
7506
- `;
7507
- const indexTs = `import { Hono } from 'hono';
7508
- import { cors } from 'hono/cors';
7509
- import { serve } from '@hono/node-server';
7510
- import { auth } from './auth.ts';
7511
- import { envParser } from './config/env.ts';
7512
- import { logger } from './config/logger.ts';
7513
-
7514
- // Parse server config (no defaults - values from secrets)
7515
- const serverConfig = envParser
7516
- .create((get) => ({
7517
- port: get('PORT').string().transform(Number),
7518
- trustedOrigins: get('BETTER_AUTH_TRUSTED_ORIGINS').string(),
7519
- }))
7520
- .parse();
7521
-
7522
- const app = new Hono();
7523
-
7524
- // CORS must be registered before routes
7525
- app.use(
7526
- '/api/auth/*',
7527
- cors({
7528
- origin: serverConfig.trustedOrigins.split(','),
7529
- allowHeaders: ['Content-Type', 'Authorization'],
7530
- allowMethods: ['POST', 'GET', 'OPTIONS'],
7531
- credentials: true,
7532
- }),
7533
- );
7534
-
7535
- // Health check endpoint
7536
- app.get('/health', (c) => {
7537
- return c.json({
7538
- status: 'ok',
7539
- service: 'auth',
7540
- timestamp: new Date().toISOString(),
7541
- });
7542
- });
7543
-
7544
- // Mount better-auth handler
7545
- app.on(['POST', 'GET'], '/api/auth/*', (c) => {
7546
- return auth.handler(c.req.raw);
7547
- });
7548
-
7549
- logger.info({ port: serverConfig.port }, 'Starting auth server');
7550
-
7551
- serve({
7552
- fetch: app.fetch,
7553
- port: serverConfig.port,
7554
- }, (info) => {
7555
- logger.info({ port: info.port }, 'Auth server running');
7556
- });
7557
- `;
7558
- const gitignore = `node_modules/
7559
- dist/
7560
- .env.local
7561
- *.log
7562
- `;
7563
- return [
7564
- {
7565
- path: "apps/auth/package.json",
7566
- content: `${JSON.stringify(packageJson, null, 2)}\n`
7567
- },
7568
- {
7569
- path: "apps/auth/tsconfig.json",
7570
- content: `${JSON.stringify(tsConfig, null, 2)}\n`
7571
- },
7572
- {
7573
- path: "apps/auth/src/config/env.ts",
7574
- content: envTs
7575
- },
7576
- {
7577
- path: "apps/auth/src/config/logger.ts",
7578
- content: loggerTs
7579
- },
7580
- {
7581
- path: "apps/auth/src/auth.ts",
7582
- content: authTs
7583
- },
7584
- {
7585
- path: "apps/auth/src/index.ts",
7586
- content: indexTs
7587
- },
7588
- {
7589
- path: "apps/auth/.gitignore",
7590
- content: gitignore
7591
- }
7592
- ];
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
+ }
7593
7533
  }
7594
-
7595
- //#endregion
7596
- //#region src/init/constructs.ts
7597
- /** The glob every generated config points at. One glob, every kind. */
7598
- const CONSTRUCTS_GLOB = "./src/constructs/**/*.ts";
7599
7534
  /**
7600
- * The database a project named `name` declares.
7535
+ * Plain ids — `Database`, not `Beetlefit`.
7601
7536
  *
7602
- * Named after the project rather than `Db`, so two apps in one workspace can
7603
- * declare one each without colliding on an id or on an env key.
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.
7604
7542
  */
7605
- function databaseFor(name$1) {
7606
- const id = canonicalId(name$1);
7607
- return {
7608
- id,
7609
- service: serviceKey(id),
7610
- urlKey: provideKey(id, "url"),
7611
- ownerUrlKey: provideKey(id, "ownerUrl")
7612
- };
7613
- }
7614
- /** The bucket a project named `name` declares. */
7615
- function storageFor(name$1) {
7616
- const id = canonicalId(`${name$1}-uploads`);
7617
- return {
7618
- id,
7619
- service: serviceKey(id),
7620
- urlKey: provideKey(id, "url")
7621
- };
7622
- }
7623
- /** The mail sender a project named `name` declares. */
7624
- function emailFor(name$1) {
7625
- const id = canonicalId(`${name$1}-mail`);
7543
+ function scaffolded(id) {
7626
7544
  return {
7627
7545
  id,
7628
7546
  service: serviceKey(id),
7629
7547
  urlKey: provideKey(id, "url")
7630
7548
  };
7631
7549
  }
7632
- /** The cache a project named `name` declares. */
7633
- function cacheFor(name$1) {
7634
- const id = canonicalId(`${name$1}-cache`);
7550
+ /** The database a project declares. */
7551
+ function databaseFor() {
7635
7552
  return {
7636
- id,
7637
- service: serviceKey(id),
7638
- urlKey: provideKey(id, "url")
7553
+ ...scaffolded("Database"),
7554
+ ownerUrlKey: provideKey("Database", "ownerUrl")
7639
7555
  };
7640
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");
7641
7563
  /**
7642
7564
  * The files a project's declared database needs.
7643
7565
  *
@@ -7645,8 +7567,8 @@ function cacheFor(name$1) {
7645
7567
  * the same way — and so the schema type, the migration, and the id the test
7646
7568
  * setup reads all come from one place.
7647
7569
  */
7648
- function databaseFiles(name$1) {
7649
- const db = databaseFor(name$1);
7570
+ function databaseFiles() {
7571
+ const db = databaseFor();
7650
7572
  return [{
7651
7573
  path: "src/constructs/database.ts",
7652
7574
  content: `import { KyselyDatabase } from '@geekmidas/constructs/database/kysely';
@@ -7675,7 +7597,16 @@ export interface Database {
7675
7597
  */
7676
7598
  export const database = new KyselyDatabase<Database, '${db.id}'>('${db.id}');
7677
7599
  `
7678
- }, {
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 {
7679
7610
  path: "src/db/migrations/001_create_users.ts",
7680
7611
  content: `import type { Kysely } from 'kysely';
7681
7612
 
@@ -7703,25 +7634,538 @@ export async function down(db: Kysely<unknown>): Promise<void> {
7703
7634
  await db.schema.dropTable('users').execute();
7704
7635
  }
7705
7636
  `
7706
- }];
7637
+ };
7707
7638
  }
7708
-
7709
- //#endregion
7710
- //#region src/init/generators/config.ts
7711
7639
  /**
7712
- * Vitest config content with globalSetup for database-enabled apps
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.
7713
7646
  */
7714
- const vitestConfigContent = `import { defineConfig } from 'vitest/config';
7715
- import tsconfigPaths from 'vite-tsconfig-paths';
7647
+ function workspaceConstructsGlobs(structure, appsDir) {
7648
+ return [WORKSPACE_CONSTRUCTS_GLOB, `./${appsDir}/*/${routesGlob(structure).replace(/^\.\//, "")}`];
7649
+ }
7716
7650
 
7717
- export default defineConfig({
7718
- plugins: [tsconfigPaths()],
7719
- test: {
7720
- environment: 'node',
7721
- globalSetup: './test/globalSetup.ts',
7722
- },
7723
- });
7724
- `;
7651
+ //#endregion
7652
+ //#region src/init/generators/agents.ts
7653
+ /**
7654
+ * `AGENTS.md` and `CLAUDE.md` for a scaffolded project.
7655
+ *
7656
+ * A generated project is the first thing an agent sees, and without this it
7657
+ * sees only the output: a `gkm.config.ts` naming three keys, a `constructs/`
7658
+ * directory, and a build step that reads both. None of that says what the
7659
+ * shape *is*, so an agent reconstructs it from the framework it recognises —
7660
+ * and writes an Express app beside a `RestApi`, or restates in config what a
7661
+ * construct already declared, which is the exact mistake v10 exists to make
7662
+ * impossible.
7663
+ *
7664
+ * Instructions live in `AGENTS.md` because that is the file every tool reads.
7665
+ * `CLAUDE.md` points at it rather than copying it: two files saying the same
7666
+ * thing is two files to keep in step, and the copy that drifts is always the
7667
+ * one nobody opened.
7668
+ *
7669
+ * Content is derived from the same `TemplateOptions` the rest of the scaffold
7670
+ * is built from, so a project with no database gets no database section, and a
7671
+ * worker gets no endpoint guide. A guide that documents what was not generated
7672
+ * teaches the reader to distrust it.
7673
+ */
7674
+ function generateAgentFiles(options, template) {
7675
+ return [{
7676
+ path: "AGENTS.md",
7677
+ content: agentsContent(options, template)
7678
+ }, {
7679
+ path: "CLAUDE.md",
7680
+ content: claudeContent()
7681
+ }];
7682
+ }
7683
+ /**
7684
+ * The pointer.
7685
+ *
7686
+ * Deliberately short and deliberately not a summary — a summary is a second
7687
+ * copy of the conventions that ages independently of the first.
7688
+ */
7689
+ function claudeContent() {
7690
+ return `# CLAUDE.md
7691
+
7692
+ The conventions for this project live in [AGENTS.md](./AGENTS.md).
7693
+
7694
+ Read that file before writing code here. It is the single copy, kept that way
7695
+ on purpose: anything repeated here would be a second thing to keep in step, and
7696
+ the stale one is always the copy nobody opened.
7697
+ `;
7698
+ }
7699
+ function agentsContent(options, template) {
7700
+ const { name: name$1, monorepo, database, telescope, studio, packageManager, services } = options;
7701
+ const constructsGlob = monorepo ? WORKSPACE_CONSTRUCTS_GLOB : CONSTRUCTS_GLOB;
7702
+ const isWorker = template.name === "worker";
7703
+ const isServerless = template.name === "serverless";
7704
+ const run = packageManager === "npm" ? "npx" : `${packageManager} exec`;
7705
+ const sections = [];
7706
+ sections.push(`# Agent conventions — ${name$1}
7707
+
7708
+ Rules for anyone, human or agent, writing code in this project. Built with
7709
+ [\`@geekmidas/toolbox\`](https://github.com/geekmidas/toolbox).
7710
+
7711
+ Read this before adding a file. Most of what follows exists because the obvious
7712
+ approach from another framework produces something that compiles, runs, and is
7713
+ wrong in a way nothing catches.`);
7714
+ sections.push(`## The one idea
7715
+
7716
+ **Constructs declare; config does not restate.**
7717
+
7718
+ A construct — a \`RestApi\`, a database, a bucket, a topic — is the single
7719
+ declaration of a thing. The build reads those declarations and derives
7720
+ everything downstream from them: which containers exist, which apps get built,
7721
+ what the OpenAPI spec says, which env vars a process needs.
7722
+
7723
+ \`gkm.config.ts\` holds only what no construct can answer:
7724
+
7725
+ \`\`\`typescript
7726
+ export default defineWorkspace({
7727
+ name: '${name$1}',
7728
+ stages: { local: 'dev', deployed: ['staging', 'prod'], protected: ['prod'] },
7729
+ constructs: '${constructsGlob}',
7730
+ secrets: { enabled: true },
7731
+ });
7732
+ \`\`\`
7733
+
7734
+ If you find yourself adding a key to config that names something already
7735
+ declared in a construct — a path, a port, a route, a container — stop. That is
7736
+ the drift this design removes. The second copy is the one that goes wrong.`);
7737
+ sections.push(`## Layout
7738
+
7739
+ \`\`\`
7740
+ ${layoutBlock(options, template)}
7741
+ \`\`\`
7742
+
7743
+ Handlers are found by **one glob**, and what kind a file exports is decided by
7744
+ the value it exports, not by the directory it sits in.`);
7745
+ sections.push(`## Commands
7746
+
7747
+ \`\`\`bash
7748
+ ${run} gkm dev # dev server, hot reload, services up, secrets injected
7749
+ ${run} gkm build # production build
7750
+ ${run} gkm test # tests, with secrets injected
7751
+ ${run} gkm exec -- <cmd> # run any command with secrets injected
7752
+ ${run} gkm openapi # regenerate the OpenAPI spec
7753
+ \`\`\`
7754
+
7755
+ Secrets are encrypted at rest and injected at run time:
7756
+
7757
+ \`\`\`bash
7758
+ ${run} gkm secrets:set DATABASE_URL postgres://...
7759
+ ${run} gkm secrets:show
7760
+ \`\`\`
7761
+
7762
+ **Never run a tool that needs project env vars directly.** It will see an empty
7763
+ environment and fail in a way that looks like a config bug. Go through
7764
+ \`gkm exec --\`, which is what injects them.`);
7765
+ if (!isWorker) sections.push(endpointSection(options));
7766
+ if (isWorker || isServerless || services.events) sections.push(backgroundSection(template));
7767
+ if (database) sections.push(databaseSection());
7768
+ sections.push(`## Environment
7769
+
7770
+ Environment is parsed once, through \`envkit\`, and never read from
7771
+ \`process.env\` directly:
7772
+
7773
+ \`\`\`typescript
7774
+ import { EnvironmentParser } from '@geekmidas/envkit';
7775
+ import { Credentials } from '@geekmidas/envkit/credentials';
7776
+
7777
+ export const envParser = new EnvironmentParser({
7778
+ ...process.env,
7779
+ ...Credentials,
7780
+ });
7781
+
7782
+ export const config = envParser
7783
+ .create((get) => ({
7784
+ port: get('PORT').string().transform(Number).default(3000),
7785
+ }))
7786
+ .parse();
7787
+ \`\`\`
7788
+
7789
+ \`Credentials\` is what \`gkm dev\` and \`gkm exec\` inject. A bare
7790
+ \`process.env.FOO\` bypasses both the decryption and the validation, so it reads
7791
+ as \`undefined\` in exactly the environments that matter.`);
7792
+ sections.push(conventionsSection());
7793
+ sections.push(testingSection(database));
7794
+ if (telescope || studio) {
7795
+ const tools = [telescope && "**Telescope** — request and exception monitoring", studio && "**Studio** — dev dashboard and database browser"].filter(Boolean);
7796
+ sections.push(`## Dev tools
7797
+
7798
+ ${tools.map((t) => `- ${t}`).join("\n")}
7799
+
7800
+ Both are derived from declared constructs. Neither needs wiring up by hand.`);
7801
+ }
7802
+ sections.push(`## Before you say it works
7803
+
7804
+ \`\`\`bash
7805
+ ${run} gkm build
7806
+ \`\`\`
7807
+
7808
+ A build is the only thing that proves the manifest still resolves. Code that
7809
+ typechecks can still declare two constructs with one id, or a handler no glob
7810
+ reaches — and neither shows up until something reads the graph.`);
7811
+ return `${sections.join("\n\n")}\n`;
7812
+ }
7813
+ function layoutBlock(options, template) {
7814
+ const { monorepo, apiPath, database } = options;
7815
+ const isWorker = template.name === "worker";
7816
+ if (monorepo) return `constructs/ # every construct: the surface, the database, topics
7817
+ ${apiPath}/ # the API app
7818
+ gkm.config.ts # name, constructs glob, secrets`;
7819
+ const lines = ["src/constructs/ # the surface, and anything it declares", "src/config/ # env parser, logger"];
7820
+ if (!isWorker) lines.push("src/endpoints/ # HTTP handlers");
7821
+ if (isWorker) {
7822
+ lines.push("src/crons/ # scheduled work");
7823
+ lines.push("src/subscribers/ # topic consumers");
7824
+ }
7825
+ if (database) lines.push("src/db/ # migrations, schema");
7826
+ lines.push("gkm.config.ts # name, constructs glob, secrets");
7827
+ return lines.join("\n");
7828
+ }
7829
+ function endpointSection(options) {
7830
+ const { database } = options;
7831
+ return `## Adding an endpoint
7832
+
7833
+ An endpoint is built **from the surface that serves it**. That is where its
7834
+ logger, environment parser and authorizers come from, so none of them is passed
7835
+ per endpoint:
7836
+
7837
+ \`\`\`typescript
7838
+ import { z } from 'zod';
7839
+ import { router } from '../constructs/router.ts';
7840
+
7841
+ export const createUser = router
7842
+ .post('/users')
7843
+ .body(z.object({ name: z.string(), email: z.email() }))
7844
+ .output(z.object({ id: z.uuid() }))
7845
+ .handle(async ({ body, logger${database ? ", services" : ""} }) => {
7846
+ logger.info({ name: body.name }, 'creating user');
7847
+ ${database ? ` const user = await services.database
7848
+ .insertInto('users')
7849
+ .values({ name: body.name, email: body.email })
7850
+ .returningAll()
7851
+ .executeTakeFirstOrThrow();
7852
+
7853
+ return { id: user.id };` : ` return { id: crypto.randomUUID() };`}
7854
+ });
7855
+ \`\`\`
7856
+
7857
+ **Export it.** An endpoint that is never exported is never found — the glob
7858
+ loads the module and filters by value, so a handler assigned to a local
7859
+ \`const\` simply does not exist as far as the build is concerned.
7860
+
7861
+ **Do not write an HTTP server.** No \`express()\`, no \`new Hono()\`, no
7862
+ \`app.listen\`. The entry is generated from the surface; a hand-written server
7863
+ beside it is a second app that shadows the real one.
7864
+
7865
+ ### Branching the factory
7866
+
7867
+ Share what a *group* of endpoints needs by branching once, in its own file:
7868
+
7869
+ \`\`\`typescript
7870
+ export const router = api.endpoints${database ? ".database(database)" : ""};
7871
+ export const sessionRouter = router.session(/* … */);
7872
+ \`\`\`
7873
+
7874
+ Branch for a group, never for a single endpoint — a one-endpoint branch is
7875
+ indirection with nothing on the other side of it.`;
7876
+ }
7877
+ function backgroundSection(template) {
7878
+ const isWorker = template.name === "worker";
7879
+ return `## Background work
7880
+
7881
+ Crons, subscribers and functions are declared the same way endpoints are: as
7882
+ exported values the build discovers.
7883
+
7884
+ \`\`\`typescript
7885
+ // a scheduled job, built from the worker that runs it
7886
+ import { worker } from '../constructs/worker.ts';
7887
+
7888
+ export const cleanup = worker
7889
+ .cron('rate(1 day)')
7890
+ .handle(async ({ logger }) => {
7891
+ logger.info('cleaning up');
7892
+ });
7893
+ \`\`\`
7894
+
7895
+ \`\`\`typescript
7896
+ // a topic subscriber
7897
+ import { userEvents } from '../constructs/topics.ts';
7898
+ import { worker } from '../constructs/worker.ts';
7899
+
7900
+ export const onUserCreated = worker
7901
+ .topic(userEvents)
7902
+ .subscribe(['user.created'])
7903
+ .handle(async ({ events, logger }) => {
7904
+ logger.info({ count: events.length }, 'handling user events');
7905
+ });
7906
+ \`\`\`
7907
+
7908
+ Handlers receive a **batch**, not one event. Both transports deliver in
7909
+ batches, so a per-event round trip is a round trip per event for no reason.
7910
+
7911
+ ${isWorker ? `This project is background work and nothing else — there is no HTTP surface
7912
+ to add routes to.` : `Declaring a topic is what makes the broker exist. Nothing lists it in config.`}`;
7913
+ }
7914
+ function databaseSection() {
7915
+ return `## Database
7916
+
7917
+ The database is a **construct**, not a hand-rolled service. Declaring it is what
7918
+ makes a Postgres exist locally and a database exist on deploy — nothing names
7919
+ \`postgres\` in config.
7920
+
7921
+ An endpoint reaches it by declaring the dependency, and gets it typed:
7922
+
7923
+ \`\`\`typescript
7924
+ export const listUsers = router
7925
+ .get('/users')
7926
+ .output(z.array(z.object({ id: z.uuid() })))
7927
+ .handle(async ({ services }) => {
7928
+ return services.database.selectFrom('users').selectAll().execute();
7929
+ });
7930
+ \`\`\`
7931
+
7932
+ Queries go through Kysely. Never open your own connection pool — the one the
7933
+ construct provides is the one that gets credentials, pooling and shutdown.`;
7934
+ }
7935
+ function conventionsSection() {
7936
+ return `## Conventions
7937
+
7938
+ **Zod formats are top-level schemas.**
7939
+
7940
+ \`\`\`typescript
7941
+ // yes
7942
+ z.email()
7943
+ z.url()
7944
+ z.uuid()
7945
+
7946
+ // no
7947
+ z.string().email()
7948
+ z.string().url()
7949
+ \`\`\`
7950
+
7951
+ Zod 4 made each format a schema in its own right. The chained form still runs,
7952
+ but it types as a \`ZodString\` carrying a check, so it cannot be composed or
7953
+ narrowed — and it emits different JSON Schema, which means a published API
7954
+ contract would depend on which spelling someone reached for.
7955
+
7956
+ \`envkit\` is not affected: \`get('ADMIN_EMAIL').string().email()\` is its own
7957
+ builder, not Zod's. Leave it alone.
7958
+
7959
+ **Style.** Two-space indent, single quotes, semicolons, trailing commas, 80
7960
+ columns. \`import type\` for type-only imports. Run the formatter rather than
7961
+ matching it by hand.
7962
+
7963
+ **Construct ids are unique.** Two constructs sharing an id fails discovery for
7964
+ the whole project, not just the second one.`;
7965
+ }
7966
+ function testingSection(database) {
7967
+ return `## Testing
7968
+
7969
+ **Integration over unit.** Prefer the real dependency to a mock — a real
7970
+ database, a real cache. Mocks are for things you genuinely cannot run:
7971
+ the filesystem, the clock, external HTTP (use MSW for that).
7972
+
7973
+ **Test behaviour, not implementation.** Assert what the code did, not which
7974
+ functions it called on the way.
7975
+
7976
+ \`\`\`bash
7977
+ ${database ? "gkm test # starts services, injects secrets, then runs vitest" : "gkm test"}
7978
+ \`\`\`
7979
+
7980
+ Run tests through \`gkm test\`, not \`vitest\` directly${database ? " — it is what starts the database and injects the secrets the suite needs" : ""}.`;
7981
+ }
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
+
8007
+ //#endregion
8008
+ //#region src/init/versions.ts
8009
+ const require$1 = createRequire(import.meta.url);
8010
+ function loadPackageJson() {
8011
+ try {
8012
+ return require$1("../package.json");
8013
+ } catch {
8014
+ return require$1("../../package.json");
8015
+ }
8016
+ }
8017
+ const pkg = loadPackageJson();
8018
+ /**
8019
+ * CLI version resolved from package.json at runtime
8020
+ */
8021
+ const CLI_VERSION = `~${pkg.version}`;
8022
+ /**
8023
+ * Package versions for @geekmidas packages
8024
+ *
8025
+ * AUTO-GENERATED (except CLI) - Do not edit manually
8026
+ * Run: pnpm --filter @geekmidas/cli sync-versions
8027
+ */
8028
+ const GEEKMIDAS_VERSIONS = {
8029
+ "@geekmidas/audit": "~10.0.0-alpha.10",
8030
+ "@geekmidas/auth": "~10.0.0-alpha.10",
8031
+ "@geekmidas/cache": "~10.0.0-alpha.10",
8032
+ "@geekmidas/client": "~10.0.0-alpha.10",
8033
+ "@geekmidas/cloud": "~10.0.0-alpha.10",
8034
+ "@geekmidas/constructs": "~10.0.0-alpha.10",
8035
+ "@geekmidas/db": "~10.0.0-alpha.10",
8036
+ "@geekmidas/emailkit": "~10.0.0-alpha.10",
8037
+ "@geekmidas/envkit": "~10.0.0-alpha.10",
8038
+ "@geekmidas/errors": "~10.0.0-alpha.10",
8039
+ "@geekmidas/events": "~10.0.0-alpha.10",
8040
+ "@geekmidas/logger": "~10.0.0-alpha.10",
8041
+ "@geekmidas/manifest": "~10.0.0-alpha.10",
8042
+ "@geekmidas/rate-limit": "~10.0.0-alpha.10",
8043
+ "@geekmidas/schema": "~10.0.0-alpha.10",
8044
+ "@geekmidas/services": "~10.0.0-alpha.10",
8045
+ "@geekmidas/storage": "~10.0.0-alpha.10",
8046
+ "@geekmidas/studio": "~10.0.0-alpha.10",
8047
+ "@geekmidas/telescope": "~10.0.0-alpha.10",
8048
+ "@geekmidas/testkit": "~10.0.0-alpha.10",
8049
+ "@geekmidas/cli": CLI_VERSION
8050
+ };
8051
+
8052
+ //#endregion
8053
+ //#region src/init/generators/auth.ts
8054
+ /**
8055
+ * Generate auth app files for fullstack template
8056
+ * Uses better-auth with magic link authentication
8057
+ */
8058
+ function generateAuthAppFiles(options) {
8059
+ if (!options.monorepo || options.template !== "fullstack") return [];
8060
+ const packageName = `@${options.name}/auth`;
8061
+ const modelsPackage = `@${options.name}/models`;
8062
+ const packageJson = {
8063
+ name: packageName,
8064
+ version: "0.0.1",
8065
+ private: true,
8066
+ type: "module",
8067
+ scripts: {
8068
+ dev: "gkm dev",
8069
+ build: "gkm build",
8070
+ "db:migrate": "gkm exec -- npx @better-auth/cli migrate",
8071
+ "db:generate": "gkm exec -- npx @better-auth/cli generate"
8072
+ },
8073
+ dependencies: {
8074
+ [modelsPackage]: "workspace:*",
8075
+ "@geekmidas/auth": GEEKMIDAS_VERSIONS["@geekmidas/auth"],
8076
+ "@geekmidas/constructs": GEEKMIDAS_VERSIONS["@geekmidas/constructs"],
8077
+ "@geekmidas/db": GEEKMIDAS_VERSIONS["@geekmidas/db"],
8078
+ "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
8079
+ "@geekmidas/logger": GEEKMIDAS_VERSIONS["@geekmidas/logger"],
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
8085
+ },
8086
+ devDependencies: {
8087
+ "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
8088
+ "@types/node": "~22.0.0",
8089
+ "@types/pg": "~8.23.1",
8090
+ tsx: "~4.20.0",
8091
+ typescript: "~5.8.2"
8092
+ }
8093
+ };
8094
+ const tsConfig = {
8095
+ extends: "../../tsconfig.json",
8096
+ compilerOptions: {
8097
+ noEmit: true,
8098
+ allowImportingTsExtensions: true,
8099
+ baseUrl: ".",
8100
+ paths: {
8101
+ "~/*": ["./src/*"],
8102
+ [`@${options.name}/*`]: ["../../packages/*/src"]
8103
+ }
8104
+ },
8105
+ include: ["src/**/*.ts"],
8106
+ exclude: ["node_modules", "dist"]
8107
+ };
8108
+ const gitignore = `node_modules/
8109
+ dist/
8110
+ .env.local
8111
+ *.log
8112
+ `;
8113
+ return [
8114
+ {
8115
+ path: "apps/auth/package.json",
8116
+ content: `${JSON.stringify(packageJson, null, 2)}\n`
8117
+ },
8118
+ {
8119
+ path: "apps/auth/tsconfig.json",
8120
+ content: `${JSON.stringify(tsConfig, null, 2)}\n`
8121
+ },
8122
+ {
8123
+ path: "apps/auth/.gitignore",
8124
+ content: gitignore
8125
+ }
8126
+ ];
8127
+ }
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
+
8153
+ //#endregion
8154
+ //#region src/init/generators/config.ts
8155
+ /**
8156
+ * Vitest config content with globalSetup for database-enabled apps
8157
+ */
8158
+ const vitestConfigContent = `import { defineConfig } from 'vitest/config';
8159
+ import tsconfigPaths from 'vite-tsconfig-paths';
8160
+
8161
+ export default defineConfig({
8162
+ plugins: [tsconfigPaths()],
8163
+ test: {
8164
+ environment: 'node',
8165
+ globalSetup: './test/globalSetup.ts',
8166
+ },
8167
+ });
8168
+ `;
7725
8169
  /**
7726
8170
  * Generate configuration files (gkm.config.ts, tsconfig.json, biome.json, turbo.json)
7727
8171
  */
@@ -7730,13 +8174,7 @@ function generateConfigFiles(options, template) {
7730
8174
  const isServerless = template.name === "serverless";
7731
8175
  const hasWorker = template.name === "worker";
7732
8176
  const isFullstack = options.template === "fullstack";
7733
- const getRoutesGlob = () => {
7734
- switch (routesStructure) {
7735
- case "centralized-endpoints": return "./src/endpoints/**/*.ts";
7736
- case "centralized-routes": return "./src/routes/**/*.ts";
7737
- case "domain-based": return "./src/**/routes/*.ts";
7738
- }
7739
- };
8177
+ const getRoutesGlob = () => routesGlob(routesStructure);
7740
8178
  if (isFullstack) return generateSingleAppConfigFiles(options, template, {
7741
8179
  telescope,
7742
8180
  studio,
@@ -7747,7 +8185,7 @@ function generateConfigFiles(options, template) {
7747
8185
  });
7748
8186
  let gkmConfig = `import { defineConfig } from '@geekmidas/cli/config';
7749
8187
 
7750
- export default defineConfig({${options.monorepo ? "" : `
8188
+ export default defineConfig({${stagesBlock(options.stages)}${options.monorepo ? "" : `
7751
8189
  // One glob, every kind. What reconcile reads to derive the containers this
7752
8190
  // app needs: a declared database is why a Postgres exists at all, so nothing
7753
8191
  // lists \`postgres\` anywhere.
@@ -7789,20 +8227,7 @@ export default defineConfig({${options.monorepo ? "" : `
7789
8227
  gkmConfig += `
7790
8228
  });
7791
8229
  `;
7792
- const tsConfig = options.monorepo ? {
7793
- extends: "../../tsconfig.json",
7794
- compilerOptions: {
7795
- noEmit: true,
7796
- allowImportingTsExtensions: true,
7797
- baseUrl: ".",
7798
- paths: {
7799
- "~/*": ["./src/*"],
7800
- [`@${options.name}/*`]: ["../../packages/*/src"]
7801
- }
7802
- },
7803
- include: ["src/**/*.ts"],
7804
- exclude: ["node_modules", "dist"]
7805
- } : {
8230
+ const tsConfig = options.monorepo ? workspaceApiTsConfig(options.name) : {
7806
8231
  compilerOptions: {
7807
8232
  target: "ES2022",
7808
8233
  module: "NodeNext",
@@ -7814,7 +8239,9 @@ export default defineConfig({${options.monorepo ? "" : `
7814
8239
  forceConsistentCasingInFileNames: true,
7815
8240
  resolveJsonModule: true,
7816
8241
  noEmit: true,
7817
- allowImportingTsExtensions: true
8242
+ allowImportingTsExtensions: true,
8243
+ baseUrl: ".",
8244
+ paths: { "~/*": ["./src/*"] }
7818
8245
  },
7819
8246
  include: ["src/**/*.ts"],
7820
8247
  exclude: ["node_modules", "dist"]
@@ -7840,7 +8267,7 @@ export default defineConfig({${options.monorepo ? "" : `
7840
8267
  clientKind: "git",
7841
8268
  useIgnoreFile: true
7842
8269
  },
7843
- organizeImports: { enabled: true },
8270
+ assist: { actions: { source: { organizeImports: "on" } } },
7844
8271
  formatter: {
7845
8272
  enabled: true,
7846
8273
  indentStyle: "space",
@@ -7864,11 +8291,13 @@ export default defineConfig({${options.monorepo ? "" : `
7864
8291
  style: { noNonNullAssertion: "off" }
7865
8292
  }
7866
8293
  },
7867
- files: { ignore: [
7868
- "node_modules",
7869
- "dist",
7870
- ".gkm",
7871
- "coverage"
8294
+ css: { parser: { tailwindDirectives: true } },
8295
+ files: { includes: [
8296
+ "**",
8297
+ "!**/node_modules",
8298
+ "!**/dist",
8299
+ "!**/.gkm",
8300
+ "!**/coverage"
7872
8301
  ] }
7873
8302
  };
7874
8303
  const turboConfig = {
@@ -7922,8 +8351,16 @@ export default defineConfig({${options.monorepo ? "" : `
7922
8351
  });
7923
8352
  return files$1;
7924
8353
  }
7925
- function generateSingleAppConfigFiles(options, _template, _helpers) {
7926
- 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 {
7927
8364
  extends: "../../tsconfig.json",
7928
8365
  compilerOptions: {
7929
8366
  noEmit: true,
@@ -7931,12 +8368,16 @@ function generateSingleAppConfigFiles(options, _template, _helpers) {
7931
8368
  baseUrl: ".",
7932
8369
  paths: {
7933
8370
  "~/*": ["./src/*"],
7934
- [`@${options.name}/*`]: ["../../packages/*/src"]
8371
+ [`@${name$1}/constructs/*`]: ["../../constructs/*"],
8372
+ [`@${name$1}/*`]: ["../../packages/*/src"]
7935
8373
  }
7936
8374
  },
7937
8375
  include: ["src/**/*.ts"],
7938
8376
  exclude: ["node_modules", "dist"]
7939
8377
  };
8378
+ }
8379
+ function generateSingleAppConfigFiles(options, _template, _helpers) {
8380
+ const tsConfig = workspaceApiTsConfig(options.name);
7940
8381
  const files$1 = [{
7941
8382
  path: "tsconfig.json",
7942
8383
  content: `${JSON.stringify(tsConfig, null, 2)}\n`
@@ -8072,7 +8513,7 @@ function generateDockerFiles(options, template, dbApps) {
8072
8513
  MP_SMTP_AUTH: \${SMTP_USER:-${options.name}}:\${SMTP_PASS:-${options.name}}`);
8073
8514
  if (options.services?.storage) {
8074
8515
  services.push(` minio:
8075
- image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z
8516
+ image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z
8076
8517
  container_name: ${options.name}-minio
8077
8518
  restart: unless-stopped
8078
8519
  entrypoint: sh
@@ -8313,6 +8754,327 @@ coverage/
8313
8754
  return files$1;
8314
8755
  }
8315
8756
 
8757
+ //#endregion
8758
+ //#region src/init/generators/github.ts
8759
+ /** How each package manager is set up, installs, and runs a script. */
8760
+ const TOOLING = {
8761
+ pnpm: {
8762
+ setup: ` - uses: pnpm/action-setup@v4
8763
+ PNPM_VERSION
8764
+ - uses: actions/setup-node@v4
8765
+ with:
8766
+ node-version: 22
8767
+ cache: pnpm
8768
+ `,
8769
+ install: "pnpm install --frozen-lockfile",
8770
+ run: "pnpm run",
8771
+ exec: "pnpm exec"
8772
+ },
8773
+ npm: {
8774
+ setup: ` - uses: actions/setup-node@v4
8775
+ with:
8776
+ node-version: 22
8777
+ cache: npm
8778
+ `,
8779
+ install: "npm ci",
8780
+ run: "npm run",
8781
+ exec: "npx --no-install"
8782
+ },
8783
+ yarn: {
8784
+ setup: ` - run: corepack enable
8785
+
8786
+ - uses: actions/setup-node@v4
8787
+ with:
8788
+ node-version: 22
8789
+ cache: yarn
8790
+ `,
8791
+ install: "yarn install --immutable",
8792
+ run: "yarn run",
8793
+ exec: "yarn"
8794
+ },
8795
+ bun: {
8796
+ setup: ` - uses: oven-sh/setup-bun@v2
8797
+ `,
8798
+ install: "bun install --frozen-lockfile",
8799
+ run: "bun run",
8800
+ exec: "bunx"
8801
+ }
8802
+ };
8803
+ /** The package manager's steps, with pnpm pinned where package.json does not. */
8804
+ function tooling(options) {
8805
+ const pm = TOOLING[options.packageManager];
8806
+ return {
8807
+ ...pm,
8808
+ setup: pm.setup.replace("PNPM_VERSION\n", options.monorepo ? "" : " with:\n version: 10\n")
8809
+ };
8810
+ }
8811
+ function ci(options) {
8812
+ const pm = tooling(options);
8813
+ return `name: CI
8814
+
8815
+ on:
8816
+ pull_request:
8817
+ branches: [main]
8818
+
8819
+ concurrency:
8820
+ group: \${{ github.workflow }}-\${{ github.ref }}
8821
+ cancel-in-progress: true
8822
+
8823
+ permissions:
8824
+ contents: read
8825
+
8826
+ jobs:
8827
+ validate:
8828
+ name: Validate
8829
+ runs-on: ubuntu-latest
8830
+ steps:
8831
+ - uses: actions/checkout@v4
8832
+
8833
+ ${pm.setup}
8834
+ - name: Install
8835
+ run: ${pm.install}
8836
+
8837
+ - name: Build
8838
+ run: ${pm.run} build
8839
+
8840
+ - name: Lint
8841
+ run: ${pm.run} lint
8842
+
8843
+ - name: Typecheck
8844
+ run: ${pm.run} typecheck
8845
+
8846
+ # \`gkm test\` starts the containers the declared constructs need (the
8847
+ # runner has Docker) and, with GKM_AUTO_SETUP, generates throwaway
8848
+ # secrets for the test stage — there is no key to share with CI.
8849
+ - name: Test
8850
+ timeout-minutes: 15
8851
+ run: ${pm.run} test:once
8852
+ env:
8853
+ CI: true
8854
+ GKM_AUTO_SETUP: 1
8855
+ `;
8856
+ }
8857
+ const releaseDrafterWorkflow = `name: Release Drafter
8858
+
8859
+ on:
8860
+ push:
8861
+ branches: [main]
8862
+ pull_request:
8863
+ types: [opened, reopened, synchronize, edited]
8864
+
8865
+ permissions:
8866
+ contents: read
8867
+
8868
+ jobs:
8869
+ draft:
8870
+ name: Update release draft
8871
+ runs-on: ubuntu-latest
8872
+ permissions:
8873
+ contents: write
8874
+ pull-requests: write
8875
+ steps:
8876
+ - uses: release-drafter/release-drafter@v6
8877
+ with:
8878
+ commitish: main
8879
+ env:
8880
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
8881
+ `;
8882
+ function releaseDrafterConfig(options) {
8883
+ const mobile = options.frontendFramework === "expo" ? ` - title: "📱 Mobile"
8884
+ labels: [mobile, ios, android]
8885
+ collapse-after: 10
8886
+ ` : "";
8887
+ return `name-template: "v$RESOLVED_VERSION"
8888
+ tag-template: "v$RESOLVED_VERSION"
8889
+
8890
+ categories:
8891
+ - title: "✨ New Features"
8892
+ labels: [feature, enhancement]
8893
+ collapse-after: 10
8894
+ - title: "🐛 Bug Fixes"
8895
+ labels: [bug, fix]
8896
+ collapse-after: 10
8897
+ ${mobile} - title: "🏗 Infrastructure"
8898
+ labels: [infrastructure, ci]
8899
+ collapse-after: 10
8900
+ - title: "📦 Dependencies"
8901
+ labels: [dependencies]
8902
+ collapse-after: 5
8903
+ - title: "🔒 Security"
8904
+ labels: [security]
8905
+ - title: "🧹 Maintenance"
8906
+ labels: [chore, refactor]
8907
+ collapse-after: 5
8908
+
8909
+ change-template: "- $TITLE @$AUTHOR (#$NUMBER)"
8910
+ change-title-escapes: '\\<*_&'
8911
+
8912
+ version-resolver:
8913
+ major:
8914
+ labels: [breaking-change, major]
8915
+ minor:
8916
+ labels: [feature, enhancement, minor]
8917
+ patch:
8918
+ labels: [bug, fix, chore, dependencies, patch]
8919
+ default: patch
8920
+
8921
+ # Labelled from the conventional-commit prefix of the pull request title.
8922
+ autolabeler:
8923
+ - label: feature
8924
+ title: ["/^feat/i"]
8925
+ - label: bug
8926
+ title: ["/^fix/i"]
8927
+ - label: chore
8928
+ title: ["/^chore/i", "/^refactor/i"]
8929
+ - label: dependencies
8930
+ title: ["/^deps/i", "/^chore\\\\(deps\\\\)/i"]
8931
+ - label: ci
8932
+ title: ["/^ci/i"]
8933
+ - label: breaking-change
8934
+ title: ["/^\\\\w+(\\\\(.+\\\\))?!:/"]
8935
+
8936
+ template: |
8937
+ ## What's Changed
8938
+
8939
+ $CHANGES
8940
+
8941
+ **Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION
8942
+ `;
8943
+ }
8944
+ function deploy(options) {
8945
+ const pm = tooling(options);
8946
+ const sst = options.deployTarget === "sst";
8947
+ const credentials = sst ? `
8948
+ # An IAM role GitHub assumes through OIDC — no long-lived AWS keys. Set
8949
+ # AWS_ROLE_ARN as a variable on each environment.
8950
+ - uses: aws-actions/configure-aws-credentials@v4
8951
+ with:
8952
+ role-to-assume: \${{ vars.AWS_ROLE_ARN }}
8953
+ aws-region: ${options.region}
8954
+ ` : "";
8955
+ const deployEnv = sst ? "" : `
8956
+ DOKPLOY_API_TOKEN: \${{ secrets.DOKPLOY_API_TOKEN }}
8957
+ DOKPLOY_ENDPOINT: \${{ vars.DOKPLOY_ENDPOINT }}`;
8958
+ return `name: Deploy
8959
+
8960
+ # Which stages exist, and which are protected, is read from \`stages\` in
8961
+ # gkm.config.ts — nothing here names one:
8962
+ #
8963
+ # push to main every deployed stage that is not protected
8964
+ # release published the protected stages (publish the drafted release)
8965
+ # run workflow the one stage you name
8966
+ on:
8967
+ push:
8968
+ branches: [main]
8969
+ release:
8970
+ types: [published]
8971
+ workflow_dispatch:
8972
+ inputs:
8973
+ stage:
8974
+ description: A deployed stage from gkm.config.ts
8975
+ required: true
8976
+
8977
+ permissions:
8978
+ contents: read
8979
+ id-token: write
8980
+
8981
+ jobs:
8982
+ stages:
8983
+ name: Pick stages
8984
+ runs-on: ubuntu-latest
8985
+ outputs:
8986
+ stages: \${{ steps.pick.outputs.stages }}
8987
+ steps:
8988
+ - uses: actions/checkout@v4
8989
+
8990
+ ${pm.setup}
8991
+ - name: Install
8992
+ run: ${pm.install}
8993
+
8994
+ - name: Read stages from gkm.config.ts
8995
+ id: pick
8996
+ run: |
8997
+ ${pm.exec} tsx -e "
8998
+ import config from './gkm.config.ts';
8999
+ const { deployed, protected: kept = [] } = config.stages;
9000
+ const event = process.env.EVENT;
9001
+ if (event === 'workflow_dispatch' && !deployed.includes(process.env.STAGE)) {
9002
+ console.error('Not a deployed stage: ' + process.env.STAGE + ' (deployed: ' + deployed.join(', ') + ')');
9003
+ process.exit(1);
9004
+ }
9005
+ const stages =
9006
+ event === 'workflow_dispatch' ? [process.env.STAGE]
9007
+ : event === 'release' ? kept
9008
+ : deployed.filter((stage) => !kept.includes(stage));
9009
+ console.log('stages=' + JSON.stringify(stages));
9010
+ " >> "$GITHUB_OUTPUT"
9011
+ env:
9012
+ EVENT: \${{ github.event_name }}
9013
+ STAGE: \${{ inputs.stage }}
9014
+
9015
+ deploy:
9016
+ name: Deploy \${{ matrix.stage }}
9017
+ needs: stages
9018
+ if: needs.stages.outputs.stages != '[]'
9019
+ runs-on: ubuntu-latest
9020
+ strategy:
9021
+ fail-fast: false
9022
+ matrix:
9023
+ stage: \${{ fromJSON(needs.stages.outputs.stages) }}
9024
+ # One GitHub environment per stage: its secrets and variables, and any
9025
+ # approval rule you put on a protected one.
9026
+ environment: \${{ matrix.stage }}
9027
+ concurrency:
9028
+ group: deploy-\${{ matrix.stage }}
9029
+ cancel-in-progress: false
9030
+ steps:
9031
+ - uses: actions/checkout@v4
9032
+
9033
+ ${pm.setup}
9034
+ - name: Install
9035
+ run: ${pm.install}
9036
+
9037
+ # The stage's secrets are committed encrypted; the key never is. Each
9038
+ # environment holds its own as GKM_SECRETS_KEY.
9039
+ # Owner-only, as the CLI writes its own keys. The stage reaches the shell
9040
+ # as a variable, never pasted into the script: on a manual run it is
9041
+ # whatever was typed.
9042
+ - name: Stage secrets key
9043
+ run: |
9044
+ mkdir -p -m 700 ~/.gkm/${options.name}
9045
+ (umask 077 && printf '%s' "$KEY" > ~/.gkm/${options.name}/"$STAGE".key)
9046
+ env:
9047
+ KEY: \${{ secrets.GKM_SECRETS_KEY }}
9048
+ STAGE: \${{ matrix.stage }}
9049
+ ${credentials}
9050
+ - name: Deploy
9051
+ run: ${pm.run} "deploy:$STAGE"
9052
+ env:
9053
+ STAGE: \${{ matrix.stage }}${deployEnv}
9054
+ `;
9055
+ }
9056
+ function generateGithubFiles(options) {
9057
+ const files$1 = [
9058
+ {
9059
+ path: ".github/workflows/ci.yml",
9060
+ content: ci(options)
9061
+ },
9062
+ {
9063
+ path: ".github/workflows/release-drafter.yml",
9064
+ content: releaseDrafterWorkflow
9065
+ },
9066
+ {
9067
+ path: ".github/release-drafter.yml",
9068
+ content: releaseDrafterConfig(options)
9069
+ }
9070
+ ];
9071
+ if (options.monorepo && options.deployTarget !== "none") files$1.push({
9072
+ path: ".github/workflows/deploy.yml",
9073
+ content: deploy(options)
9074
+ });
9075
+ return files$1;
9076
+ }
9077
+
8316
9078
  //#endregion
8317
9079
  //#region src/init/generators/mobile-expo.ts
8318
9080
  /**
@@ -8345,12 +9107,12 @@ function generateExpoAppFiles(options) {
8345
9107
  dependencies: {
8346
9108
  [apiPackage]: "workspace:*",
8347
9109
  [modelsPackage]: "workspace:*",
8348
- "@better-auth/expo": "~1.2.0",
9110
+ "@better-auth/expo": DEPENDENCY_VERSIONS["better-auth"],
8349
9111
  "@geekmidas/client": GEEKMIDAS_VERSIONS["@geekmidas/client"],
8350
9112
  "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
8351
9113
  "@react-navigation/native": "^7.1.0",
8352
- "@tanstack/react-query": "~5.80.0",
8353
- "better-auth": "~1.2.0",
9114
+ "@tanstack/react-query": DEPENDENCY_VERSIONS["@tanstack/react-query"],
9115
+ "better-auth": DEPENDENCY_VERSIONS["better-auth"],
8354
9116
  expo: "~55.0.0",
8355
9117
  "expo-constants": "~55.0.0",
8356
9118
  "expo-dev-client": "~55.0.0",
@@ -8486,7 +9248,7 @@ module.exports = withNativeWind(config, { input: './global.css' });
8486
9248
  "@/*": ["./*"],
8487
9249
  [modelsPackage]: ["../../packages/models/src"],
8488
9250
  [`${modelsPackage}/*`]: ["../../packages/models/src/*"],
8489
- [`${apiPackage}/client`]: ["../../apps/api/.gkm/openapi.ts"]
9251
+ [`${apiPackage}/client`]: ["../../apps/api/.gkm/openapi/api.ts"]
8490
9252
  }
8491
9253
  },
8492
9254
  include: [
@@ -9044,14 +9806,142 @@ export type ListUsersResponse = z.infer<typeof ListUsersResponseSchema>;
9044
9806
  ];
9045
9807
  }
9046
9808
 
9809
+ //#endregion
9810
+ //#region src/init/generators/deploy.ts
9811
+ /** One `deploy:<stage>` script per deployed stage. */
9812
+ const perStage = (options, command) => Object.fromEntries(options.stages.deployed.map((stage) => [`deploy:${stage}`, command(stage)]));
9813
+ function deployPackage(options) {
9814
+ switch (options.deployTarget) {
9815
+ case "dokploy": return {
9816
+ scripts: perStage(options, (stage) => `gkm deploy --provider dokploy --stage ${stage}`),
9817
+ dependencies: {},
9818
+ devDependencies: {}
9819
+ };
9820
+ case "sst": {
9821
+ const v = GEEKMIDAS_VERSIONS;
9822
+ return {
9823
+ scripts: perStage(options, (stage) => `gkm build --provider aws && sst deploy --stage ${stage}`),
9824
+ dependencies: {
9825
+ "@geekmidas/cloud": v["@geekmidas/cloud"],
9826
+ "@geekmidas/db": v["@geekmidas/db"],
9827
+ "@geekmidas/envkit": v["@geekmidas/envkit"],
9828
+ "@geekmidas/events": v["@geekmidas/events"],
9829
+ "@geekmidas/manifest": v["@geekmidas/manifest"],
9830
+ "@geekmidas/storage": v["@geekmidas/storage"],
9831
+ pg: DEPENDENCY_VERSIONS.pg
9832
+ },
9833
+ devDependencies: { sst: DEPENDENCY_VERSIONS.sst }
9834
+ };
9835
+ }
9836
+ default: return {
9837
+ scripts: {},
9838
+ dependencies: {},
9839
+ devDependencies: {}
9840
+ };
9841
+ }
9842
+ }
9843
+ function generateDeployFiles(options) {
9844
+ if (options.deployTarget !== "sst") return [];
9845
+ if (!options.region) throw new Error("An SST deploy needs a region.");
9846
+ const db = databaseFor();
9847
+ const mail = emailFor();
9848
+ const hasDatabase = options.template === "fullstack" || options.services.db;
9849
+ const inputs = [...hasDatabase ? [` // RDS needs a network. Created here rather than assumed, because a
9850
+ // VPC with NAT is a monthly cost in an account that may already
9851
+ // have one — replace it with \`sst.aws.Vpc.get(…)\` if so.
9852
+ ${db.id}: { vpc },`] : [], ...options.services.mail ? [` // Every provider rejects an unverified sender, so there is no
9853
+ // default; the synth stops with \`EmailNeedsSender\` until it is set.
9854
+ ${mail.id}: { from: process.env.MAIL_FROM as string },`] : []];
9855
+ return [{
9856
+ path: "sst.config.ts",
9857
+ content: `/// <reference path="./.sst/platform/config.d.ts" />
9858
+
9859
+ /**
9860
+ * ${options.name} on AWS.
9861
+ *
9862
+ * No bucket, cluster, queue or IAM is listed here: they come from the
9863
+ * constructs the application declares, through \`fromManifest\`. What is here
9864
+ * is what varies by stage, and the inputs a declaration cannot carry.
9865
+ *
9866
+ * Deploy with \`pnpm run deploy:<stage>\`, which builds the manifest first.
9867
+ */
9868
+ const region = '${options.region}';
9869
+
9870
+ /** From \`stages.protected\` in gkm.config.ts. */
9871
+ const PROTECTED: string[] = ${JSON.stringify(options.stages.protected ?? []).replace(/"/g, "'")};
9872
+
9873
+ export default $config({
9874
+ app(input) {
9875
+ return {
9876
+ name: '${options.name}',
9877
+ // Any other stage is disposable; a protected one's database should not
9878
+ // vanish because somebody removed a stack.
9879
+ removal: PROTECTED.includes(input?.stage) ? 'retain' : 'remove',
9880
+ protect: PROTECTED.includes(input?.stage),
9881
+ home: 'aws',
9882
+ providers: { aws: { region } },
9883
+ };
9884
+ },
9885
+
9886
+ async run() {
9887
+ const { App, fromManifest, Stack } = await import('@geekmidas/cloud/sst');
9888
+ // Written by \`gkm build --provider aws\`. Imported rather than discovered,
9889
+ // so SST's toolchain never evaluates the application's own modules.
9890
+ const { backends, constructs } = await import('./.gkm/manifest/aws.js');
9891
+
9892
+ ${hasDatabase ? " const vpc = new sst.aws.Vpc('Vpc', { nat: 'ec2' });\n\n" : ""} const app = new App({
9893
+ name: $app.name,
9894
+ stage: $app.stage,
9895
+ domain: '',
9896
+ region,
9897
+ hostedZoneId: '',
9898
+ });
9899
+
9900
+ return fromManifest(
9901
+ new Stack(app, '${canonicalId(options.name)}'),
9902
+ constructs,
9903
+ {
9904
+ ${inputs.join("\n")}
9905
+ },
9906
+ backends,
9907
+ );
9908
+ },
9909
+ });
9910
+ `
9911
+ }];
9912
+ }
9913
+
9047
9914
  //#endregion
9048
9915
  //#region src/init/generators/monorepo.ts
9049
9916
  /**
9917
+ * What the root `constructs/` folder imports, and the peers each construct
9918
+ * needs, for the services this workspace was scaffolded with.
9919
+ */
9920
+ function rootConstructDependencies(options) {
9921
+ const v = GEEKMIDAS_VERSIONS;
9922
+ return {
9923
+ "@geekmidas/constructs": v["@geekmidas/constructs"],
9924
+ "@geekmidas/logger": v["@geekmidas/logger"],
9925
+ ...options.loggerType === "pino" ? { pino: DEPENDENCY_VERSIONS.pino } : {},
9926
+ "@geekmidas/telescope": v["@geekmidas/telescope"],
9927
+ "@geekmidas/auth": v["@geekmidas/auth"],
9928
+ "better-auth": DEPENDENCY_VERSIONS["better-auth"],
9929
+ "@geekmidas/db": v["@geekmidas/db"],
9930
+ kysely: DEPENDENCY_VERSIONS.kysely,
9931
+ pg: DEPENDENCY_VERSIONS.pg,
9932
+ ...options.services.storage ? { "@geekmidas/storage": v["@geekmidas/storage"] } : {},
9933
+ ...options.services.mail ? { "@geekmidas/emailkit": v["@geekmidas/emailkit"] } : {},
9934
+ ...options.services.cache ? { "@geekmidas/cache": v["@geekmidas/cache"] } : {}
9935
+ };
9936
+ }
9937
+ /**
9050
9938
  * Generate monorepo root files (pnpm-workspace.yaml, root package.json, etc.)
9051
9939
  */
9052
9940
  function generateMonorepoFiles(options, _template) {
9053
9941
  if (!options.monorepo) return [];
9054
9942
  const isFullstack = options.template === "fullstack";
9943
+ const deploy$1 = deployPackage(options);
9944
+ const isSst = options.deployTarget === "sst";
9055
9945
  const rootPackageJson = {
9056
9946
  name: options.name,
9057
9947
  version: "0.0.1",
@@ -9068,9 +9958,13 @@ function generateMonorepoFiles(options, _template) {
9068
9958
  fmt: "biome format . --write",
9069
9959
  "fmt:check": "biome format .",
9070
9960
  ...isFullstack ? { storybook: "pnpm --filter ./packages/ui storybook" } : {},
9071
- ...options.deployTarget === "dokploy" ? { deploy: "gkm deploy --provider dokploy --stage production" } : {}
9961
+ ...deploy$1.scripts
9962
+ },
9963
+ dependencies: {
9964
+ zod: DEPENDENCY_VERSIONS.zod,
9965
+ ...isFullstack ? rootConstructDependencies(options) : {},
9966
+ ...deploy$1.dependencies
9072
9967
  },
9073
- dependencies: { zod: "~4.1.0" },
9074
9968
  devDependencies: {
9075
9969
  "@biomejs/biome": "~2.3.0",
9076
9970
  "@geekmidas/cli": GEEKMIDAS_VERSIONS["@geekmidas/cli"],
@@ -9078,7 +9972,8 @@ function generateMonorepoFiles(options, _template) {
9078
9972
  tsx: "~4.20.0",
9079
9973
  turbo: "~2.3.0",
9080
9974
  typescript: "~5.8.2",
9081
- vitest: "~4.0.0"
9975
+ vitest: "~4.0.0",
9976
+ ...deploy$1.devDependencies
9082
9977
  }
9083
9978
  };
9084
9979
  const apiPathParts = options.apiPath.split("/");
@@ -9094,7 +9989,7 @@ function generateMonorepoFiles(options, _template) {
9094
9989
  clientKind: "git",
9095
9990
  useIgnoreFile: true
9096
9991
  },
9097
- organizeImports: { enabled: true },
9992
+ assist: { actions: { source: { organizeImports: "on" } } },
9098
9993
  formatter: {
9099
9994
  enabled: true,
9100
9995
  indentStyle: "space",
@@ -9118,11 +10013,14 @@ function generateMonorepoFiles(options, _template) {
9118
10013
  style: { noNonNullAssertion: "off" }
9119
10014
  }
9120
10015
  },
9121
- files: { ignore: [
9122
- "node_modules",
9123
- "dist",
9124
- ".gkm",
9125
- "coverage"
10016
+ css: { parser: { tailwindDirectives: true } },
10017
+ files: { includes: [
10018
+ "**",
10019
+ "!**/node_modules",
10020
+ "!**/dist",
10021
+ "!**/.gkm",
10022
+ "!**/coverage",
10023
+ ...isSst ? ["!**/.sst", "!sst-env.d.ts"] : []
9126
10024
  ] }
9127
10025
  };
9128
10026
  const turboConfig = {
@@ -9188,9 +10086,15 @@ coverage/
9188
10086
 
9189
10087
  # Turbo
9190
10088
  .turbo/
9191
- `;
10089
+ ${isSst ? "\n# SST\n.sst/\n" : ""}`;
9192
10090
  const tsConfig = {
9193
10091
  compilerOptions: {
10092
+ ...isFullstack ? {
10093
+ baseUrl: ".",
10094
+ paths: { [`@${options.name}/constructs/*`]: ["./constructs/*"] },
10095
+ allowImportingTsExtensions: true,
10096
+ noEmit: true
10097
+ } : {},
9194
10098
  target: "ES2022",
9195
10099
  module: "NodeNext",
9196
10100
  moduleResolution: "NodeNext",
@@ -9201,16 +10105,21 @@ coverage/
9201
10105
  forceConsistentCasingInFileNames: true,
9202
10106
  resolveJsonModule: true
9203
10107
  },
9204
- exclude: ["node_modules", "dist"]
10108
+ exclude: [
10109
+ "node_modules",
10110
+ "dist",
10111
+ ...isSst ? ["sst.config.ts", ".sst"] : []
10112
+ ]
9205
10113
  };
9206
10114
  const vitestConfig = `import { defineConfig } from 'vitest/config';
9207
10115
 
9208
10116
  export default defineConfig({
9209
10117
  test: {
9210
- globals: true,
9211
- environment: 'node',
9212
- include: ['apps/**/*.{test,spec}.ts', 'packages/**/*.{test,spec}.ts'],
9213
- exclude: ['**/node_modules/**', '**/dist/**'],
10118
+ // Each app and package is its own project, so its own config — an app's
10119
+ // \`globalSetup\`, its path aliases — applies to its own tests.
10120
+ projects: ['apps/*', 'packages/*'],
10121
+ // The shared packages ship without tests of their own.
10122
+ passWithNoTests: true,
9214
10123
  coverage: {
9215
10124
  provider: 'v8',
9216
10125
  reporter: ['text', 'json', 'html'],
@@ -9291,132 +10200,224 @@ export default defineConfig({
9291
10200
  {
9292
10201
  path: ".vscode/extensions.json",
9293
10202
  content: `${JSON.stringify(vscodeExtensions, null, " ")}\n`
9294
- }
10203
+ },
10204
+ ...generateDeployFiles(options)
9295
10205
  ];
9296
10206
  if (isFullstack) files$1.push({
9297
10207
  path: "gkm.config.ts",
9298
10208
  content: generateWorkspaceConfig(options)
9299
10209
  });
9300
- return files$1;
9301
- }
10210
+ return files$1;
10211
+ }
10212
+ /**
10213
+ * `gkm.config.ts` for the fullstack workspace.
10214
+ *
10215
+ * What is *not* here is the point. There is no `apps` block: the apps are the
10216
+ * constructs that said they have a process — a `RestApi`, a `BetterAuth`, a
10217
+ * `StaticSite` — and naming them again here would be the same sentence written
10218
+ * twice with only one copy checked.
10219
+ *
10220
+ * There is no `services` block either unless something non-default was picked.
10221
+ * Every key defaults from the deploy target: on a server target a cache is a
10222
+ * table in the database that is already there, storage is MinIO, mail is SES.
10223
+ */
10224
+ function generateWorkspaceConfig(options) {
10225
+ let config = `import { defineWorkspace } from '@geekmidas/cli/config';
10226
+
10227
+ export default defineWorkspace({
10228
+ // The scope every physical name is built from: \`Database\` becomes
10229
+ // \`production-${options.name}-database\` on Dokploy and on AWS alike.
10230
+ name: '${options.name}',
10231
+ ${stagesBlock(options.stages)}
10232
+
10233
+ // Every kind, in every app. A declared database is why a Postgres exists, a
10234
+ // declared bucket is why MinIO does, a declared topic is why a broker does —
10235
+ // none of it listed here. It is also where the apps come from: a
10236
+ // \`StaticSite\` is an app, and so is every \`RestApi\`. And it finds each
10237
+ // app's endpoints, crons and subscribers: an endpoint belongs to the surface
10238
+ // it was built from, not to the directory it sits in.
10239
+ constructs: [
10240
+ ${workspaceConstructsGlobs(options.routesStructure, dirname(options.apiPath)).map((glob) => ` '${glob}',`).join("\n")}
10241
+ ],
10242
+
10243
+ secrets: {
10244
+ enabled: true,
10245
+ },
10246
+ });
10247
+ `;
10248
+ if (options.services.events && options.services.events !== "pgboss") config = config.replace(" secrets: {", ` services: {
10249
+ events: '${options.services.events}',
10250
+ },
10251
+
10252
+ secrets: {`);
10253
+ return config;
10254
+ }
10255
+ /**
10256
+ * The workspace's constructs, at its root rather than inside an app.
10257
+ *
10258
+ * They outlive the app that used them first: a site depending on the API is a
10259
+ * fact about the workspace, and a construct reaching across into a sibling
10260
+ * app's `src/` to state it is the shape this layout removes. Apps import them
10261
+ * through the `@<name>/constructs/*` path the root tsconfig maps.
10262
+ *
10263
+ * Every one of these is also an answer config used to give: the database is
10264
+ * why a Postgres exists, the surface and the site are the apps, and the edges
10265
+ * between them are the CORS origins and the build order.
10266
+ */
10267
+ function generateRootConstructs(options) {
10268
+ if (!options.monorepo || options.template !== "fullstack") return [];
10269
+ const { name: name$1, services, frontendFramework } = options;
10270
+ const db = databaseFor();
10271
+ const files$1 = [];
10272
+ files$1.push({
10273
+ path: "constructs/logger.ts",
10274
+ content: `import { createLogger } from '@geekmidas/logger/${options.loggerType}';
10275
+
10276
+ /**
10277
+ * The logger every endpoint runs with.
10278
+ *
10279
+ * Named once, here, because the surface carries it. It used to be
10280
+ * \`logger: './src/config/logger#logger'\` in config — a module path the build
10281
+ * printed into each generated handler, checked by nothing and wrong the moment
10282
+ * the file moved.
10283
+ */
10284
+ export const logger = createLogger();
10285
+ `
10286
+ });
10287
+ files$1.push({
10288
+ path: "constructs/database.ts",
10289
+ content: `import { KyselyDatabase } from '@geekmidas/constructs/database/kysely';
10290
+ import type { Generated } from 'kysely';
10291
+
10292
+ /** Your database schema. Add tables here. */
10293
+ export interface Database {
10294
+ users: {
10295
+ id: Generated<string>;
10296
+ name: string;
10297
+ email: string;
10298
+ created_at: Generated<Date>;
10299
+ };
10300
+ }
10301
+
10302
+ /**
10303
+ * The database, declared once.
10304
+ *
10305
+ * The container, the database inside it, its roles and schema, and
10306
+ * \`${db.urlKey}\` all derive from this line — which is why nothing lists
10307
+ * \`postgres\` anywhere.
10308
+ */
10309
+ export const database = new KyselyDatabase<Database, '${db.id}'>('${db.id}');
10310
+
10311
+ /**
10312
+ * The auth server's own schema in that same Postgres, with its own role.
10313
+ *
10314
+ * A schema tenant rather than a second database: one container, and a role
10315
+ * whose \`search_path\` is pinned, so Better Auth's tables cannot collide with
10316
+ * the application's.
10317
+ */
10318
+ export const authDb = database.schema<Record<string, never>, 'AuthDb'>('AuthDb');
10319
+ `
10320
+ });
10321
+ files$1.push({
10322
+ path: "constructs/auth.ts",
10323
+ content: `import { BetterAuth } from '@geekmidas/constructs/auth';
10324
+ import { authDb } from './database.ts';
10325
+
10326
+ /**
10327
+ * The auth server.
10328
+ *
10329
+ * There is no \`src/index.ts\` to go with this, and that is the point: the
10330
+ * routes are a wildcard, so no glob finds them and the build generates the
10331
+ * entry from this declaration instead. CORS, the trusted origins and the
10332
+ * cookie domain come from whatever declared an edge to it — nobody writes an
10333
+ * origin down.
10334
+ */
10335
+ export const auth = new BetterAuth('Auth', {
10336
+ path: 'apps/auth',
10337
+ database: authDb,
10338
+ basePath: '/api/auth',
10339
+ });
10340
+ `
10341
+ });
10342
+ files$1.push({
10343
+ path: "constructs/api.ts",
10344
+ content: `import { RestApi } from '@geekmidas/constructs/rest-api';
10345
+ import { auth } from './auth.ts';
10346
+ import { logger } from './logger.ts';
10347
+
9302
10348
  /**
9303
- * Generate the frontend app entry for the workspace config based on the
9304
- * selected `frontendFramework`. Defaults to Next.js for backwards compat.
9305
- */
9306
- function generateFrontendAppEntry(options) {
9307
- switch (options.frontendFramework) {
9308
- case "tanstack-start": return `web: {
9309
- type: 'web',
9310
- framework: 'tanstack-start',
9311
- path: 'apps/web',
9312
- port: 3001,
9313
- dependencies: ['api', 'auth'],
9314
- config: {
9315
- client: './src/config/client.ts',
9316
- server: './src/config/server.ts',
9317
- },
9318
- },`;
9319
- case "expo": return `app: {
9320
- type: 'mobile',
9321
- framework: 'expo',
9322
- path: 'apps/app',
9323
- port: 8081,
9324
- dependencies: ['api', 'auth'],
9325
- },`;
9326
- default: return `web: {
9327
- type: 'web',
9328
- framework: 'nextjs',
9329
- path: 'apps/web',
9330
- port: 3001,
9331
- dependencies: ['api', 'auth'],
9332
- config: {
9333
- client: './src/config/client.ts',
9334
- server: './src/config/server.ts',
9335
- },
9336
- },`;
9337
- }
9338
- }
10349
+ * The application's HTTP surface — one RestApi, one container.
10350
+ *
10351
+ * \`path\` is where its app lives. Its handlers are found by the workspace's
10352
+ * \`constructs\` glob, and belong to it because they were built from it.
10353
+ */
10354
+ export const api = new RestApi('Api', {
10355
+ path: '${options.apiPath}',
10356
+
10357
+ // Typed out rather than omitted: an API that ships open because a field was
10358
+ // left off is the one default worth refusing to have.
10359
+ defaultAuthorizer: 'none',
10360
+
10361
+ // The actual logger, not a path to one.
10362
+ logger,
10363
+ }).auth(auth);
10364
+ `
10365
+ });
10366
+ const variant = frontendFramework === "tanstack-start" ? "{ path: 'apps/web', variant: 'tanstack' }" : frontendFramework === "expo" ? void 0 : "{ path: 'apps/web', variant: 'next' }";
10367
+ if (variant !== void 0) files$1.push({
10368
+ path: "constructs/site.ts",
10369
+ content: `import { StaticSite } from '@geekmidas/constructs/site';
10370
+ import { api } from './api.ts';
10371
+ import { auth } from './auth.ts';
10372
+
9339
10373
  /**
9340
- * Generate gkm.config.ts with defineWorkspace for fullstack template
9341
- */
9342
- function generateWorkspaceConfig(options) {
9343
- const { telescope, services, deployTarget, routesStructure } = options;
9344
- const getRoutesGlob = () => {
9345
- switch (routesStructure) {
9346
- case "centralized-endpoints": return "./src/endpoints/**/*.ts";
9347
- case "centralized-routes": return "./src/routes/**/*.ts";
9348
- case "domain-based": return "./src/**/routes/*.ts";
9349
- }
9350
- };
9351
- let config = `import { defineWorkspace } from '@geekmidas/cli/config';
10374
+ * The frontend — a construct like any other, which is what makes it an app.
10375
+ *
10376
+ * \`path\` is where it lives. \`.dependsOn()\` is the single fact
10377
+ * behind four things that are hand-maintained otherwise: this site's
10378
+ * build-time API URL, the API's CORS origins, the auth server's trusted
10379
+ * origins, and which generated client lands here.
10380
+ */
10381
+ export const web = new StaticSite('Web', ${variant}).dependsOn([api, auth]);
10382
+ `
10383
+ });
10384
+ if (services.storage) {
10385
+ const bucket = storageFor();
10386
+ files$1.push({
10387
+ path: "constructs/storage.ts",
10388
+ content: `import { ObjectStorage } from '@geekmidas/constructs/object-storage';
9352
10389
 
9353
- export default defineWorkspace({
9354
- name: '${options.name}',
9355
- apps: {
9356
- api: {
9357
- type: 'backend',
9358
- path: 'apps/api',
9359
- port: 3000,
9360
- routes: '${getRoutesGlob()}',
9361
- envParser: './src/config/env#envParser',
9362
- logger: './src/config/logger#logger',
9363
- dependencies: ['auth'],`;
9364
- if (telescope) config += `
9365
- telescope: {
9366
- enabled: true,
9367
- path: '/__telescope',
9368
- },`;
9369
- config += `
9370
- openapi: {
9371
- enabled: true,
9372
- },
9373
- },
9374
- auth: {
9375
- type: 'backend',
9376
- path: 'apps/auth',
9377
- port: 3002,
9378
- entry: './src/index.ts',
9379
- framework: 'better-auth',
9380
- envParser: './src/config/env#envParser',
9381
- logger: './src/config/logger#logger',
9382
- },
9383
- ${generateFrontendAppEntry(options)}
9384
- },
9385
- shared: {
9386
- packages: ['packages/*'],
9387
- models: {
9388
- path: 'packages/models',
9389
- schema: 'zod',
9390
- },
9391
- },`;
9392
- if (services.db || services.cache || services.mail || services.storage || services.events) {
9393
- config += `
9394
- services: {`;
9395
- if (services.db) config += `
9396
- db: true,`;
9397
- if (services.cache) config += `
9398
- cache: true,`;
9399
- if (services.mail) config += `
9400
- mail: true,`;
9401
- if (services.storage) config += `
9402
- storage: true,`;
9403
- if (services.events) config += `
9404
- events: '${services.events}',`;
9405
- config += `
9406
- },`;
10390
+ /** A bucket — MinIO locally, S3 deployed, one declaration for both. */
10391
+ export const uploads = new ObjectStorage('${bucket.id}');
10392
+ `
10393
+ });
9407
10394
  }
9408
- if (deployTarget === "dokploy") config += `
9409
- deploy: {
9410
- default: 'dokploy',
9411
- },`;
9412
- config += `
9413
- secrets: {
9414
- enabled: true,
9415
- },`;
9416
- config += `
9417
- });
9418
- `;
9419
- return config;
10395
+ if (services.mail) {
10396
+ const mail = emailFor();
10397
+ files$1.push({
10398
+ path: "constructs/email.ts",
10399
+ content: `import { Email } from '@geekmidas/constructs/email';
10400
+
10401
+ /** Outbound mail — Mailpit locally, SES deployed. */
10402
+ export const email = new Email('${mail.id}', { templates: {} });
10403
+ `
10404
+ });
10405
+ }
10406
+ if (services.cache) {
10407
+ const kv = cacheFor();
10408
+ files$1.push({
10409
+ path: "constructs/cache.ts",
10410
+ content: `import { Cache } from '@geekmidas/constructs/cache';
10411
+
10412
+ /**
10413
+ * A cache. Which backend serves it follows the deploy target — a table in the
10414
+ * database on a server, Upstash on AWS — so nothing here says where it lives.
10415
+ */
10416
+ export const cache = new Cache('${kv.id}');
10417
+ `
10418
+ });
10419
+ }
10420
+ return files$1;
9420
10421
  }
9421
10422
 
9422
10423
  //#endregion
@@ -9436,9 +10437,9 @@ const apiTemplate = {
9436
10437
  "@geekmidas/errors": GEEKMIDAS_VERSIONS["@geekmidas/errors"],
9437
10438
  "@geekmidas/auth": GEEKMIDAS_VERSIONS["@geekmidas/auth"],
9438
10439
  "@hono/node-server": "~1.14.1",
9439
- hono: "~4.8.2",
9440
- pino: "~9.6.0",
9441
- zod: "~4.1.0"
10440
+ hono: DEPENDENCY_VERSIONS.hono,
10441
+ pino: DEPENDENCY_VERSIONS.pino,
10442
+ zod: DEPENDENCY_VERSIONS.zod
9442
10443
  },
9443
10444
  devDependencies: {
9444
10445
  "@biomejs/biome": "~2.3.0",
@@ -9462,10 +10463,10 @@ const apiTemplate = {
9462
10463
  },
9463
10464
  files: (options) => {
9464
10465
  const { loggerType, routesStructure, monorepo, name: name$1, services } = options;
9465
- const bucket = storageFor(name$1);
9466
- const kv = cacheFor(name$1);
9467
- const db = databaseFor(name$1);
9468
- const mail = emailFor(name$1);
10466
+ const bucket = storageFor();
10467
+ const kv = cacheFor();
10468
+ const db = databaseFor();
10469
+ const mail = emailFor();
9469
10470
  const src = (path) => monorepo ? `~/${path}` : `./${path}`;
9470
10471
  const declares = !monorepo;
9471
10472
  const loggerContent = `import { createLogger } from '@geekmidas/logger/${loggerType}';
@@ -9484,14 +10485,24 @@ export const logger = createLogger();
9484
10485
  }
9485
10486
  }
9486
10487
  };
9487
- const apiImport = routesStructure === "domain-based" ? "../../constructs/api.js" : "../constructs/api.js";
10488
+ /**
10489
+ * Where a file at the app's `src/` root reaches a construct.
10490
+ *
10491
+ * A workspace keeps them at its own root, so the app goes through the
10492
+ * `@<name>/constructs/*` alias rather than climbing out with `../../`.
10493
+ */
10494
+ const constructsImport = (file) => monorepo ? `@${name$1}/constructs/${file}.ts` : `./constructs/${file}.ts`;
10495
+ const apiImport = monorepo ? `@${name$1}/constructs/api.ts` : routesStructure === "domain-based" ? "../../constructs/api.js" : "../constructs/api.js";
9488
10496
  const files$1 = [
9489
- {
10497
+ ...declares ? [{
9490
10498
  path: "src/constructs/api.ts",
9491
10499
  content: `import { RestApi } from '@geekmidas/constructs/rest-api';
9492
10500
  import { logger } from '../config/logger.ts';
9493
10501
 
9494
10502
  export const api = new RestApi('Api', {
10503
+ // The project is the app.
10504
+ path: '.',
10505
+
9495
10506
  // Typed out rather than omitted: an API that ships open because a field was
9496
10507
  // left off is the one default worth refusing to have.
9497
10508
  defaultAuthorizer: 'none',
@@ -9501,7 +10512,7 @@ export const api = new RestApi('Api', {
9501
10512
  logger,
9502
10513
  });
9503
10514
  `
9504
- },
10515
+ }] : [],
9505
10516
  {
9506
10517
  path: "src/config/env.ts",
9507
10518
  content: `import { Credentials } from '@geekmidas/envkit/credentials';
@@ -9514,7 +10525,7 @@ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials
9514
10525
  export const config = envParser
9515
10526
  .create((get) => ({
9516
10527
  nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
9517
- stage: get('STAGE').enum(['development', 'staging', 'production']).default('development'),
10528
+ stage: ${stageEnv(options.stages)},
9518
10529
  }))
9519
10530
  .parse();
9520
10531
  `
@@ -9666,7 +10677,7 @@ export const authService = {
9666
10677
  headers: { cookie },
9667
10678
  });
9668
10679
  if (!res.ok) return null;
9669
- return res.json();
10680
+ return (await res.json()) as Session | null;
9670
10681
  },
9671
10682
  };
9672
10683
  },
@@ -9676,10 +10687,9 @@ export const authService = {
9676
10687
  files$1.push({
9677
10688
  path: "src/router.ts",
9678
10689
  content: `import { UnauthorizedError } from '@geekmidas/errors';
9679
- import { api } from './constructs/api.ts';${options.database ? `
9680
- import { database } from './constructs/database.ts';` : ""}
10690
+ import { api } from '${constructsImport("api")}';${options.database ? `
10691
+ import { database } from '${constructsImport("database")}';` : ""}
9681
10692
  import { authService, type Session } from './services/auth.ts';
9682
- import { logger } from './config/logger.ts';
9683
10693
 
9684
10694
  /**
9685
10695
  * The shared endpoint factory — no session required.
@@ -9724,8 +10734,8 @@ export const profileEndpoint = sessionRouter
9724
10734
  }
9725
10735
  if (!monorepo) files$1.push({
9726
10736
  path: "src/router.ts",
9727
- content: `import { api } from './constructs/api.ts';${options.database ? `
9728
- import { database } from './constructs/database.ts';` : ""}
10737
+ content: `import { api } from '${constructsImport("api")}';${options.database ? `
10738
+ import { database } from '${constructsImport("database")}';` : ""}
9729
10739
 
9730
10740
  /**
9731
10741
  * The shared endpoint factory.
@@ -9737,7 +10747,8 @@ import { database } from './constructs/database.ts';` : ""}
9737
10747
  export const router = api.endpoints${options.database ? ".database(database)" : ""};
9738
10748
  `
9739
10749
  });
9740
- if (options.database && declares) files$1.push(...databaseFiles(name$1));
10750
+ if (options.database && declares) files$1.push(...databaseFiles());
10751
+ else if (options.database) files$1.push(usersMigration());
9741
10752
  if (services.storage && declares) files$1.push({
9742
10753
  path: "src/constructs/storage.ts",
9743
10754
  content: `import { ObjectStorage } from '@geekmidas/constructs/object-storage';
@@ -9887,9 +10898,9 @@ const minimalTemplate = {
9887
10898
  "@geekmidas/rate-limit": GEEKMIDAS_VERSIONS["@geekmidas/rate-limit"],
9888
10899
  "@geekmidas/schema": GEEKMIDAS_VERSIONS["@geekmidas/schema"],
9889
10900
  "@hono/node-server": "~1.14.1",
9890
- hono: "~4.8.2",
9891
- pino: "~9.6.0",
9892
- zod: "~4.1.0"
10901
+ hono: DEPENDENCY_VERSIONS.hono,
10902
+ pino: DEPENDENCY_VERSIONS.pino,
10903
+ zod: DEPENDENCY_VERSIONS.zod
9893
10904
  },
9894
10905
  devDependencies: {
9895
10906
  "@biomejs/biome": "~2.3.0",
@@ -9913,7 +10924,7 @@ const minimalTemplate = {
9913
10924
  },
9914
10925
  files: (options) => {
9915
10926
  const { loggerType, routesStructure, name: name$1 } = options;
9916
- const db = databaseFor(name$1);
10927
+ const db = databaseFor();
9917
10928
  const loggerContent = `import { createLogger } from '@geekmidas/logger/${loggerType}';
9918
10929
 
9919
10930
  export const logger = createLogger();
@@ -9933,6 +10944,9 @@ export const logger = createLogger();
9933
10944
  import { logger } from '../config/logger.ts';
9934
10945
 
9935
10946
  export const api = new RestApi('Api', {
10947
+ // The project is the app.
10948
+ path: '.',
10949
+
9936
10950
  // Typed out rather than omitted: an API that ships open because a field was
9937
10951
  // left off is the one default worth refusing to have.
9938
10952
  defaultAuthorizer: 'none',
@@ -9955,7 +10969,7 @@ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials
9955
10969
  export const config = envParser
9956
10970
  .create((get) => ({
9957
10971
  nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
9958
- stage: get('STAGE').enum(['development', 'staging', 'production']).default('development'),
10972
+ stage: ${stageEnv(options.stages)},
9959
10973
  }))
9960
10974
  .parse();
9961
10975
  `
@@ -9982,7 +10996,7 @@ export const healthEndpoint = api
9982
10996
  `
9983
10997
  }
9984
10998
  ];
9985
- if (options.database) files$1.push(...databaseFiles(name$1));
10999
+ if (options.database) files$1.push(...databaseFiles());
9986
11000
  if (options.telescope) files$1.push({
9987
11001
  path: "src/config/telescope.ts",
9988
11002
  content: `import { Telescope } from '@geekmidas/telescope';
@@ -10046,8 +11060,8 @@ const serverlessTemplate = {
10046
11060
  "@geekmidas/rate-limit": GEEKMIDAS_VERSIONS["@geekmidas/rate-limit"],
10047
11061
  "@geekmidas/schema": GEEKMIDAS_VERSIONS["@geekmidas/schema"],
10048
11062
  "@hono/node-server": "~1.14.1",
10049
- hono: "~4.8.2",
10050
- pino: "~9.6.0"
11063
+ hono: DEPENDENCY_VERSIONS.hono,
11064
+ pino: DEPENDENCY_VERSIONS.pino
10051
11065
  },
10052
11066
  devDependencies: {
10053
11067
  "@biomejs/biome": "~2.3.0",
@@ -10090,6 +11104,9 @@ export const logger = createLogger();
10090
11104
  import { logger } from '../config/logger.ts';
10091
11105
 
10092
11106
  export const api = new RestApi('Api', {
11107
+ // The project is the app.
11108
+ path: '.',
11109
+
10093
11110
  // Typed out rather than omitted: an API that ships open because a field was
10094
11111
  // left off is the one default worth refusing to have.
10095
11112
  defaultAuthorizer: 'none',
@@ -10112,7 +11129,7 @@ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials
10112
11129
  export const config = envParser
10113
11130
  .create((get) => ({
10114
11131
  nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
10115
- stage: get('STAGE').enum(['dev', 'staging', 'prod']).default('dev'),
11132
+ stage: ${stageEnv(options.stages)},
10116
11133
  }))
10117
11134
  .parse();
10118
11135
  `
@@ -10142,19 +11159,27 @@ export const healthEndpoint = api
10142
11159
  },
10143
11160
  {
10144
11161
  path: "src/functions/hello.ts",
10145
- content: `import { f } from '@geekmidas/constructs/functions';
10146
- import { z } from 'zod';
11162
+ content: `import { z } from 'zod';
11163
+ import { worker } from '~/constructs/worker.ts';
10147
11164
 
10148
- export const helloFunction = f
11165
+ export const helloFunction = worker
10149
11166
  .input(z.object({ name: z.string() }))
10150
11167
  .output(z.object({ message: z.string() }))
10151
11168
  .handle(async ({ input }) => ({
10152
11169
  message: \`Hello, \${input.name}!\`,
10153
11170
  }));
11171
+ `
11172
+ },
11173
+ {
11174
+ path: "src/constructs/worker.ts",
11175
+ content: `import { Worker } from '@geekmidas/constructs/worker';
11176
+ import { logger } from '../config/logger.ts';
11177
+
11178
+ export const worker = new Worker('Jobs', { logger });
10154
11179
  `
10155
11180
  }
10156
11181
  ];
10157
- if (options.database) files$1.push(...databaseFiles(name$1));
11182
+ if (options.database) files$1.push(...databaseFiles());
10158
11183
  if (options.telescope) files$1.push({
10159
11184
  path: "src/config/telescope.ts",
10160
11185
  content: `import { Telescope } from '@geekmidas/telescope';
@@ -10163,7 +11188,7 @@ import { InMemoryStorage } from '@geekmidas/telescope/storage/memory';
10163
11188
  // Note: For production Lambda, consider using a persistent storage
10164
11189
  export const telescope = new Telescope({
10165
11190
  storage: new InMemoryStorage({ maxEntries: 50 }),
10166
- enabled: process.env.STAGE === 'dev',
11191
+ enabled: process.env.STAGE === '${options.stages.local}',
10167
11192
  });
10168
11193
  `
10169
11194
  });
@@ -10173,6 +11198,22 @@ export const telescope = new Telescope({
10173
11198
 
10174
11199
  //#endregion
10175
11200
  //#region src/init/templates/worker.ts
11201
+ /**
11202
+ * A project that is background work and nothing else.
11203
+ *
11204
+ * It used to scaffold a `RestApi` with no endpoints on it — an HTTP surface
11205
+ * declared by a project whose premise is that nothing calls it over HTTP —
11206
+ * because a surface was the only construct that made an app exist and the only
11207
+ * way to get a logger into the generated runtime. `Worker` is the construct
11208
+ * that was missing: it takes no authorizer, publishes no address, and hands out
11209
+ * factories already carrying its logger.
11210
+ *
11211
+ * It scaffolds a cron when the project has a database, and not otherwise. A
11212
+ * server fires its own crons, and the schedule lives in Postgres so that
11213
+ * running more than one replica still fires each job once — so a worker names
11214
+ * the database it keeps schedules in. Without one there is nowhere to keep
11215
+ * them, and an example that could not run is worse than none.
11216
+ */
10176
11217
  const workerTemplate = {
10177
11218
  name: "worker",
10178
11219
  description: "Background job processing",
@@ -10182,11 +11223,8 @@ const workerTemplate = {
10182
11223
  "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
10183
11224
  "@geekmidas/events": GEEKMIDAS_VERSIONS["@geekmidas/events"],
10184
11225
  "@geekmidas/logger": GEEKMIDAS_VERSIONS["@geekmidas/logger"],
10185
- "@geekmidas/rate-limit": GEEKMIDAS_VERSIONS["@geekmidas/rate-limit"],
10186
11226
  "@geekmidas/schema": GEEKMIDAS_VERSIONS["@geekmidas/schema"],
10187
- "@hono/node-server": "~1.14.1",
10188
- hono: "~4.8.2",
10189
- pino: "~9.6.0"
11227
+ pino: DEPENDENCY_VERSIONS.pino
10190
11228
  },
10191
11229
  devDependencies: {
10192
11230
  "@biomejs/biome": "~2.3.0",
@@ -10208,34 +11246,18 @@ const workerTemplate = {
10208
11246
  "fmt:check": "biome format ."
10209
11247
  },
10210
11248
  files: (options) => {
10211
- const { loggerType, routesStructure, name: name$1 } = options;
11249
+ const { loggerType, name: name$1, database } = options;
10212
11250
  const loggerContent = `import { createLogger } from '@geekmidas/logger/${loggerType}';
10213
11251
 
10214
11252
  export const logger = createLogger();
10215
11253
  `;
10216
- const getRoutePath = (file) => {
10217
- switch (routesStructure) {
10218
- case "centralized-endpoints": return `src/endpoints/${file}`;
10219
- case "centralized-routes": return `src/routes/${file}`;
10220
- case "domain-based": return `src/${file.replace(".ts", "")}/routes/index.ts`;
10221
- }
10222
- };
10223
- const apiImport = routesStructure === "domain-based" ? "../../constructs/api.js" : "../constructs/api.js";
10224
11254
  const files$1 = [
10225
11255
  {
10226
- path: "src/constructs/api.ts",
10227
- content: `import { RestApi } from '@geekmidas/constructs/rest-api';
10228
- import { logger } from '../config/logger.ts';
10229
-
10230
- export const api = new RestApi('Api', {
10231
- // Typed out rather than omitted: an API that ships open because a field was
10232
- // left off is the one default worth refusing to have.
10233
- defaultAuthorizer: 'none',
11256
+ path: "src/constructs/worker.ts",
11257
+ content: `import { Worker } from '@geekmidas/constructs/worker';
11258
+ import { logger } from '../config/logger.ts';${database ? "\nimport { database } from './database.ts';" : ""}
10234
11259
 
10235
- // The actual logger, not a path to one. Every endpoint built from this
10236
- // surface runs with it, and so does the generated entry.
10237
- logger,
10238
- });
11260
+ export const worker = new Worker('Jobs', { logger })${database ? ".database(database)" : ""};
10239
11261
  `
10240
11262
  },
10241
11263
  {
@@ -10250,7 +11272,7 @@ export const envParser = new EnvironmentParser({ ...process.env, ...Credentials
10250
11272
  export const config = envParser
10251
11273
  .create((get) => ({
10252
11274
  nodeEnv: get('NODE_ENV').enum(['development', 'test', 'production']).default('development'),
10253
- stage: get('STAGE').enum(['development', 'staging', 'production']).default('development'),
11275
+ stage: ${stageEnv(options.stages)},
10254
11276
  }))
10255
11277
  .parse();
10256
11278
  `
@@ -10259,23 +11281,6 @@ export const config = envParser
10259
11281
  path: "src/config/logger.ts",
10260
11282
  content: loggerContent
10261
11283
  },
10262
- {
10263
- path: getRoutePath("health.ts"),
10264
- content: `import { z } from 'zod';
10265
- import { api } from '${apiImport}';
10266
-
10267
- export const healthEndpoint = api
10268
- .get('/health')
10269
- .output(z.object({
10270
- status: z.string(),
10271
- timestamp: z.string(),
10272
- }))
10273
- .handle(async () => ({
10274
- status: 'ok',
10275
- timestamp: new Date().toISOString(),
10276
- }));
10277
- `
10278
- },
10279
11284
  {
10280
11285
  path: "src/events/types.ts",
10281
11286
  content: `import type { PublishableMessage } from '@geekmidas/events';
@@ -10317,46 +11322,47 @@ export const eventsPublisherService = {
10317
11322
  },
10318
11323
  {
10319
11324
  path: "src/subscribers/user-events.ts",
10320
- content: `import { s } from '@geekmidas/constructs/subscribers';
11325
+ content: `import { worker } from '~/constructs/worker.ts';
10321
11326
  import { eventsPublisherService } from '~/events/publisher.ts';
10322
11327
 
10323
- export const userEventsSubscriber = s
11328
+ export const userEventsSubscriber = worker
10324
11329
  .publisher(eventsPublisherService)
10325
11330
  .subscribe(['user.created', 'user.updated'])
10326
- .handle(async ({ event, logger }) => {
10327
- logger.info({ type: event.type, payload: event.payload }, 'Processing user event');
10328
-
10329
- switch (event.type) {
10330
- case 'user.created':
10331
- // Handle user creation
10332
- logger.info({ userId: event.payload.userId }, 'New user created');
10333
- break;
10334
- case 'user.updated':
10335
- // Handle user update
10336
- logger.info({ userId: event.payload.userId }, 'User updated');
10337
- break;
11331
+ .handle(async ({ events, logger }) => {
11332
+ // A batch, not a single event. Both transports deliver in batches, so
11333
+ // handling them one at a time is a round trip per event for no reason.
11334
+ logger.info({ count: events.length }, 'Processing user events');
11335
+
11336
+ for (const event of events) {
11337
+ switch (event.type) {
11338
+ case 'user.created':
11339
+ logger.info({ userId: event.payload.userId }, 'New user created');
11340
+ break;
11341
+ case 'user.updated':
11342
+ logger.info({ userId: event.payload.userId }, 'User updated');
11343
+ break;
11344
+ }
10338
11345
  }
10339
11346
  });
10340
11347
  `
10341
- },
10342
- {
10343
- path: "src/crons/cleanup.ts",
10344
- content: `import { cron } from '@geekmidas/constructs/crons';
11348
+ }
11349
+ ];
11350
+ if (database) files$1.push({
11351
+ path: "src/crons/cleanup.ts",
11352
+ content: `import { worker } from '~/constructs/worker.ts';
10345
11353
 
10346
- // Run every day at midnight
10347
- export const cleanupCron = cron('0 0 * * *')
11354
+ export const cleanup = worker
11355
+ .cron('rate(1 day)')
10348
11356
  .handle(async ({ logger }) => {
10349
- logger.info('Running cleanup job');
11357
+ logger.info('Running cleanup');
10350
11358
 
10351
- // Add your cleanup logic here
10352
- // e.g., delete old sessions, clean up temp files, etc.
11359
+ // Delete old sessions, clear temp files, whatever falls out of date.
10353
11360
 
10354
- logger.info('Cleanup job completed');
11361
+ logger.info('Cleanup complete');
10355
11362
  });
10356
11363
  `
10357
- }
10358
- ];
10359
- if (options.database) files$1.push(...databaseFiles(name$1));
11364
+ });
11365
+ if (options.database) files$1.push(...databaseFiles());
10360
11366
  if (options.telescope) files$1.push({
10361
11367
  path: "src/config/telescope.ts",
10362
11368
  content: `import { Telescope } from '@geekmidas/telescope';
@@ -10377,7 +11383,7 @@ export const telescope = new Telescope({
10377
11383
  /**
10378
11384
  * OpenAPI output path (fixed, not configurable)
10379
11385
  */
10380
- const OPENAPI_OUTPUT_PATH$1 = "./.gkm/openapi.ts";
11386
+ const OPENAPI_OUTPUT_PATH$1 = "./.gkm/openapi/api.ts";
10381
11387
  /**
10382
11388
  * Endpoints output path (generated by gkm dev/build)
10383
11389
  */
@@ -10483,15 +11489,23 @@ const packageManagerChoices = [
10483
11489
  /**
10484
11490
  * Deploy target choices for prompts
10485
11491
  */
10486
- const deployTargetChoices = [{
10487
- title: "Dokploy",
10488
- value: "dokploy",
10489
- description: "Deploy to Dokploy (Docker-based hosting)"
10490
- }, {
10491
- title: "Configure later",
10492
- value: "none",
10493
- description: "Skip deployment setup for now"
10494
- }];
11492
+ const deployTargetChoices = [
11493
+ {
11494
+ title: "Dokploy",
11495
+ value: "dokploy",
11496
+ description: "Deploy to Dokploy (Docker-based hosting)"
11497
+ },
11498
+ {
11499
+ title: "AWS (SST)",
11500
+ value: "sst",
11501
+ description: "Deploy to your AWS account through SST"
11502
+ },
11503
+ {
11504
+ title: "Configure later",
11505
+ value: "none",
11506
+ description: "Skip deployment setup for now"
11507
+ }
11508
+ ];
10495
11509
  /**
10496
11510
  * Services choices for multi-select prompt
10497
11511
  */
@@ -10584,12 +11598,12 @@ function generatePackageJson(options, template) {
10584
11598
  if (declares && services.mail) dependencies$1["@geekmidas/emailkit"] = GEEKMIDAS_VERSIONS["@geekmidas/emailkit"];
10585
11599
  if (database) {
10586
11600
  dependencies$1["@geekmidas/db"] = GEEKMIDAS_VERSIONS["@geekmidas/db"];
10587
- dependencies$1.kysely = "~0.28.2";
10588
- dependencies$1.pg = "~8.16.0";
10589
- devDependencies$1["@types/pg"] = "~8.15.0";
11601
+ dependencies$1.kysely = DEPENDENCY_VERSIONS.kysely;
11602
+ dependencies$1.pg = DEPENDENCY_VERSIONS.pg;
11603
+ devDependencies$1["@types/pg"] = "~8.23.1";
10590
11604
  devDependencies$1["@geekmidas/testkit"] = GEEKMIDAS_VERSIONS["@geekmidas/testkit"];
10591
11605
  devDependencies$1["@faker-js/faker"] = "~9.8.0";
10592
- devDependencies$1["kysely-ctl"] = "~0.10.0";
11606
+ devDependencies$1["kysely-ctl"] = DEPENDENCY_VERSIONS["kysely-ctl"];
10593
11607
  devDependencies$1["vite-tsconfig-paths"] = "~5.1.0";
10594
11608
  }
10595
11609
  if (monorepo) {
@@ -10609,6 +11623,8 @@ function generatePackageJson(options, template) {
10609
11623
  const appName = pathParts[pathParts.length - 1] || "api";
10610
11624
  packageName = `@${name$1}/${appName}`;
10611
11625
  }
11626
+ dependencies$1["@geekmidas/client"] = GEEKMIDAS_VERSIONS["@geekmidas/client"];
11627
+ dependencies$1["@tanstack/react-query"] = DEPENDENCY_VERSIONS["@tanstack/react-query"];
10612
11628
  const packageJson = {
10613
11629
  name: packageName,
10614
11630
  version: "0.0.1",
@@ -10646,7 +11662,7 @@ function generateSourceFiles(options, template) {
10646
11662
  */
10647
11663
  function generateTestFiles(options, _template) {
10648
11664
  if (!options.database) return [];
10649
- const db = databaseFor(options.name);
11665
+ const db = databaseFor();
10650
11666
  const declares = !options.monorepo;
10651
11667
  const schema = declares ? "../src/constructs/database.ts" : "~/services/database.ts";
10652
11668
  const runtimeUrl = declares ? db.urlKey : "DATABASE_URL";
@@ -10682,14 +11698,16 @@ import pg from 'pg';
10682
11698
  import { wrapVitestKyselyTransaction } from '@geekmidas/testkit/kysely';
10683
11699
  import type { Database } from '${schema}';
10684
11700
 
10685
- const connection = new Kysely<Database>({
11701
+ const db = new Kysely<Database>({
10686
11702
  dialect: new PostgresDialect({
10687
11703
  pool: new pg.Pool({ connectionString: process.env.${runtimeUrl} }),
10688
11704
  }),
10689
11705
  });
10690
11706
 
10691
11707
  export const it = wrapVitestKyselyTransaction<Database>(itVitest, {
10692
- connection,
11708
+ // A function, or a construct: an instance is taken for a construct and has
11709
+ // no service to register.
11710
+ connection: () => db,
10693
11711
  });
10694
11712
  `
10695
11713
  },
@@ -10774,10 +11792,10 @@ export const usersBuilder = KyselyFactory.createBuilder<Database, 'users'>(
10774
11792
  import { it } from './config.ts';
10775
11793
 
10776
11794
  describe('example', () => {
10777
- it('should have a working test setup', async ({ db }) => {
10778
- // db is a transaction-wrapped Kysely instance
10779
- // All changes are automatically rolled back after the test
10780
- expect(db).toBeDefined();
11795
+ it('should have a working test setup', async ({ trx }) => {
11796
+ // trx is a Kysely transaction; everything in it is rolled back after the
11797
+ // test
11798
+ expect(trx).toBeDefined();
10781
11799
  });
10782
11800
  });
10783
11801
  `
@@ -12028,8 +13046,8 @@ export const Alert: Story = {
12028
13046
  ),
12029
13047
  };
12030
13048
  `;
12031
- const componentsUiIndex = `export { Button, type ButtonProps, buttonVariants } from './button.tsx';
12032
- export { Input } from './input.tsx';
13049
+ const componentsUiIndex = `export { Button, type ButtonProps, buttonVariants } from './button/index.tsx';
13050
+ export { Input } from './input/index.tsx';
12033
13051
  export {
12034
13052
  Card,
12035
13053
  CardHeader,
@@ -12037,17 +13055,17 @@ export {
12037
13055
  CardTitle,
12038
13056
  CardDescription,
12039
13057
  CardContent,
12040
- } from './card.tsx';
12041
- export { Label } from './label.tsx';
12042
- export { Badge, type BadgeProps, badgeVariants } from './badge.tsx';
12043
- export { Separator } from './separator.tsx';
12044
- export { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs.tsx';
13058
+ } from './card/index.tsx';
13059
+ export { Label } from './label/index.tsx';
13060
+ export { Badge, type BadgeProps, badgeVariants } from './badge/index.tsx';
13061
+ export { Separator } from './separator/index.tsx';
13062
+ export { Tabs, TabsList, TabsTrigger, TabsContent } from './tabs/index.tsx';
12045
13063
  export {
12046
13064
  Tooltip,
12047
13065
  TooltipTrigger,
12048
13066
  TooltipContent,
12049
13067
  TooltipProvider,
12050
- } from './tooltip.tsx';
13068
+ } from './tooltip/index.tsx';
12051
13069
  export {
12052
13070
  Dialog,
12053
13071
  DialogPortal,
@@ -12059,7 +13077,7 @@ export {
12059
13077
  DialogFooter,
12060
13078
  DialogTitle,
12061
13079
  DialogDescription,
12062
- } from './dialog.tsx';
13080
+ } from './dialog/index.tsx';
12063
13081
  `;
12064
13082
  const buttonIndexTsx = buttonTsx;
12065
13083
  const inputIndexTsx = inputTsx;
@@ -12227,8 +13245,8 @@ function generateWebAppFiles(options) {
12227
13245
  [uiPackage]: "workspace:*",
12228
13246
  "@geekmidas/client": GEEKMIDAS_VERSIONS["@geekmidas/client"],
12229
13247
  "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
12230
- "@tanstack/react-query": "~5.80.0",
12231
- "better-auth": "~1.2.0",
13248
+ "@tanstack/react-query": DEPENDENCY_VERSIONS["@tanstack/react-query"],
13249
+ "better-auth": DEPENDENCY_VERSIONS["better-auth"],
12232
13250
  next: "~16.1.0",
12233
13251
  react: "~19.2.0",
12234
13252
  "react-dom": "~19.2.0"
@@ -12284,7 +13302,7 @@ export default nextConfig;
12284
13302
  baseUrl: ".",
12285
13303
  paths: {
12286
13304
  "~/*": ["./src/*", "../../packages/ui/src/*"],
12287
- [`${apiPackage}/client`]: ["../../apps/api/.gkm/openapi.ts"],
13305
+ [`${apiPackage}/client`]: ["../../apps/api/.gkm/openapi/api.ts"],
12288
13306
  [`${modelsPackage}`]: ["../../packages/models/src"],
12289
13307
  [`${modelsPackage}/*`]: ["../../packages/models/src/*"],
12290
13308
  [`${uiPackage}`]: ["../../packages/ui/src"],
@@ -12297,12 +13315,7 @@ export default nextConfig;
12297
13315
  "**/*.tsx",
12298
13316
  ".next/types/**/*.ts"
12299
13317
  ],
12300
- exclude: ["node_modules"],
12301
- references: [
12302
- { path: "../../apps/api" },
12303
- { path: "../../packages/ui" },
12304
- { path: "../../packages/models" }
12305
- ]
13318
+ exclude: ["node_modules"]
12306
13319
  };
12307
13320
  const queryClientTs = `import { QueryClient } from '@tanstack/react-query';
12308
13321
 
@@ -12565,10 +13578,10 @@ function generateTanStackWebFiles(options) {
12565
13578
  [uiPackage]: "workspace:*",
12566
13579
  "@geekmidas/client": GEEKMIDAS_VERSIONS["@geekmidas/client"],
12567
13580
  "@geekmidas/envkit": GEEKMIDAS_VERSIONS["@geekmidas/envkit"],
12568
- "@tanstack/react-query": "~5.80.0",
13581
+ "@tanstack/react-query": DEPENDENCY_VERSIONS["@tanstack/react-query"],
12569
13582
  "@tanstack/react-router": "^1.87.0",
12570
13583
  "@tanstack/react-start": "^1.87.0",
12571
- "better-auth": "~1.2.0",
13584
+ "better-auth": DEPENDENCY_VERSIONS["better-auth"],
12572
13585
  react: "~19.2.0",
12573
13586
  "react-dom": "~19.2.0"
12574
13587
  },
@@ -12636,7 +13649,7 @@ export default defineConfig({
12636
13649
  [`${modelsPackage}/*`]: ["../../packages/models/src/*"],
12637
13650
  [uiPackage]: ["../../packages/ui/src"],
12638
13651
  [`${uiPackage}/*`]: ["../../packages/ui/src/*"],
12639
- [`${apiPackage}/client`]: ["../../apps/api/.gkm/openapi.ts"]
13652
+ [`${apiPackage}/client`]: ["../../apps/api/.gkm/openapi/api.ts"]
12640
13653
  }
12641
13654
  },
12642
13655
  include: [
@@ -12926,12 +13939,27 @@ function detectPackageManager(cwd = process.cwd()) {
12926
13939
  if (userAgent.includes("bun")) return "bun";
12927
13940
  return "npm";
12928
13941
  }
13942
+ /** `gkm init`'s flags, which a name can swallow when a space goes missing. */
13943
+ const INIT_FLAGS = [
13944
+ "yes",
13945
+ "name",
13946
+ "template",
13947
+ "monorepo",
13948
+ "api-path",
13949
+ "pm",
13950
+ "skip-install"
13951
+ ];
12929
13952
  /**
12930
13953
  * Validate project name for npm package naming conventions
12931
13954
  */
12932
13955
  function validateProjectName(name$1) {
12933
13956
  if (!name$1) return "Project name is required";
12934
13957
  if (!/^[a-z0-9-_@/.]+$/i.test(name$1)) return "Project name can only contain letters, numbers, hyphens, underscores, @, /, and .";
13958
+ const flag = name$1.match(/--(\w[\w-]*)$/)?.[1];
13959
+ if (flag && INIT_FLAGS.includes(flag)) {
13960
+ const intended = name$1.slice(0, -(flag.length + 2));
13961
+ return `"${name$1}" ends in the --${flag} flag. Did you mean \`${intended} --${flag}\`?`;
13962
+ }
12935
13963
  const reserved = [
12936
13964
  "node_modules",
12937
13965
  ".git",
@@ -12971,6 +13999,18 @@ function getRunCommand(pkgManager, script) {
12971
13999
  default: return `npm run ${script}`;
12972
14000
  }
12973
14001
  }
14002
+ /**
14003
+ * Run a binary the project installed, through its package manager — so it is
14004
+ * the project's own version, not whatever \`npx\` would fetch.
14005
+ */
14006
+ function getExecCommand(pkgManager, command) {
14007
+ switch (pkgManager) {
14008
+ case "pnpm": return `pnpm exec ${command}`;
14009
+ case "yarn": return `yarn ${command}`;
14010
+ case "bun": return `bunx ${command}`;
14011
+ default: return `npx --no-install ${command}`;
14012
+ }
14013
+ }
12974
14014
  const lockfileByPm = {
12975
14015
  pnpm: "pnpm-lock.yaml",
12976
14016
  yarn: "yarn.lock",
@@ -13041,12 +14081,31 @@ function findWorkspacePackages(cwd, pm) {
13041
14081
 
13042
14082
  //#endregion
13043
14083
  //#region src/init/index.ts
14084
+ /** `' staging, prod '` → `['staging', 'prod']`. */
14085
+ const stageList = (value) => value.split(",").map((name$1) => name$1.trim()).filter(Boolean);
14086
+ /** `eu-west-1`, `us-gov-west-1`, `ap-southeast-2`. */
14087
+ const AWS_REGION = /^[a-z]{2}(-gov)?-[a-z]+-\d$/;
13044
14088
  /**
13045
14089
  * Main init command - scaffolds a new project
13046
14090
  */
13047
14091
  async function initCommand(projectName, options = {}) {
13048
14092
  const cwd = process.cwd();
13049
14093
  const detectedPkgManager = detectPackageManager(cwd);
14094
+ 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(", ")}.`);
14095
+ if (options.region && !AWS_REGION.test(options.region)) throw new Error(`"${options.region}" is not an AWS region. Use one like eu-west-1.`);
14096
+ if (options.stages || options.localStage || options.protectedStage) {
14097
+ const deployed = stageList(options.stages ?? "");
14098
+ const problems = [
14099
+ ...deployedProblems(deployed),
14100
+ ...options.localStage ? stageProblems({
14101
+ local: options.localStage,
14102
+ deployed
14103
+ }) : [],
14104
+ ...options.protectedStage && !deployed.includes(options.protectedStage) ? [`--protected-stage "${options.protectedStage}" is not in --stages`] : []
14105
+ ];
14106
+ if (problems.length) throw new Error([...new Set(problems)].join("\n"));
14107
+ }
14108
+ let deployedSoFar = [];
13050
14109
  prompts.override({});
13051
14110
  const onCancel = () => {
13052
14111
  process.exit(0);
@@ -13097,12 +14156,49 @@ async function initCommand(projectName, options = {}) {
13097
14156
  initial: packageManagerChoices.findIndex((c) => c.value === detectedPkgManager)
13098
14157
  },
13099
14158
  {
13100
- type: options.yes ? null : "select",
14159
+ type: options.yes || options.deploy ? null : "select",
13101
14160
  name: "deployTarget",
13102
14161
  message: "Deployment target:",
13103
14162
  choices: deployTargetChoices,
13104
14163
  initial: 0
13105
14164
  },
14165
+ {
14166
+ type: (_prev, values) => !options.yes && !options.region && (options.deploy ?? values.deployTarget) === "sst" ? "text" : null,
14167
+ name: "region",
14168
+ message: "AWS region (e.g. eu-west-1):",
14169
+ validate: (value) => AWS_REGION.test(value.trim()) || "An AWS region, like eu-west-1"
14170
+ },
14171
+ {
14172
+ type: options.yes || options.stages ? null : "text",
14173
+ name: "deployedStages",
14174
+ message: "Deployed stages, comma-separated (e.g. staging, prod):",
14175
+ validate: (value) => {
14176
+ const deployed = stageList(value);
14177
+ if (deployed.length === 0) return "At least one stage";
14178
+ return deployedProblems(deployed)[0] ?? true;
14179
+ }
14180
+ },
14181
+ {
14182
+ type: (_prev, values) => options.yes || options.protectedStage ? null : stageList(options.stages ?? values.deployedStages ?? "").length > 1 ? "select" : null,
14183
+ name: "protectedStage",
14184
+ message: "Which one is production (retained and protected)?",
14185
+ choices: (_prev, values) => stageList(options.stages ?? values.deployedStages ?? "").map((name$2) => ({
14186
+ title: name$2,
14187
+ value: name$2
14188
+ }))
14189
+ },
14190
+ {
14191
+ type: (_prev, values) => {
14192
+ deployedSoFar = stageList(options.stages ?? values.deployedStages ?? "");
14193
+ return options.yes || options.localStage ? null : "text";
14194
+ },
14195
+ name: "localStage",
14196
+ message: "Local stage, what gkm dev runs as (e.g. dev):",
14197
+ validate: (value) => stageProblems({
14198
+ local: value.trim(),
14199
+ deployed: deployedSoFar
14200
+ })[0] ?? true
14201
+ },
13106
14202
  {
13107
14203
  type: options.yes ? null : "confirm",
13108
14204
  name: "telescope",
@@ -13168,7 +14264,7 @@ async function initCommand(projectName, options = {}) {
13168
14264
  };
13169
14265
  if (services.events === "pgboss") services.db = true;
13170
14266
  const pkgManager = options.pm ? options.pm : options.yes ? "pnpm" : answers.packageManager ?? detectedPkgManager;
13171
- const deployTarget = options.yes ? "dokploy" : answers.deployTarget ?? "dokploy";
14267
+ const deployTarget = options.deploy ?? (options.yes ? "none" : answers.deployTarget ?? "none");
13172
14268
  const database = services.db;
13173
14269
  const frontendFramework = isFullstack ? options.yes ? "nextjs" : answers.frontendFramework ?? "nextjs" : void 0;
13174
14270
  const templateOptions = {
@@ -13183,6 +14279,8 @@ async function initCommand(projectName, options = {}) {
13183
14279
  apiPath: monorepo ? options.apiPath ?? "apps/api" : "",
13184
14280
  packageManager: pkgManager,
13185
14281
  deployTarget,
14282
+ stages: resolveStages(options, answers),
14283
+ ...deployTarget === "sst" ? { region: options.region ?? (options.yes ? "eu-west-1" : answers.region?.trim()) } : {},
13186
14284
  services,
13187
14285
  frontendFramework
13188
14286
  };
@@ -13211,7 +14309,13 @@ async function initCommand(projectName, options = {}) {
13211
14309
  ...isMonorepo$1 ? [] : generateDockerFiles(templateOptions, baseTemplate, dbApps)
13212
14310
  ] : [];
13213
14311
  const dockerFiles = isMonorepo$1 && baseTemplate ? generateDockerFiles(templateOptions, baseTemplate, dbApps) : [];
13214
- const rootFiles = baseTemplate ? [...generateMonorepoFiles(templateOptions, baseTemplate), ...generateModelsPackage(templateOptions)] : [];
14312
+ const rootFiles = baseTemplate ? [
14313
+ ...generateAgentFiles(templateOptions, baseTemplate),
14314
+ ...generateGithubFiles(templateOptions),
14315
+ ...generateMonorepoFiles(templateOptions, baseTemplate),
14316
+ ...generateRootConstructs(templateOptions),
14317
+ ...generateModelsPackage(templateOptions)
14318
+ ] : [];
13215
14319
  const webAppFiles = isFullstack ? frontendFramework === "tanstack-start" ? generateTanStackWebFiles(templateOptions) : frontendFramework === "expo" ? generateExpoAppFiles(templateOptions) : generateWebAppFiles(templateOptions) : [];
13216
14320
  const authAppFiles = isFullstack ? generateAuthAppFiles(templateOptions) : [];
13217
14321
  const uiPackageFiles = isFullstack ? generateUiPackageFiles(templateOptions) : [];
@@ -13253,12 +14357,12 @@ async function initCommand(projectName, options = {}) {
13253
14357
  if (services.mail) secretServices.push("mailpit");
13254
14358
  if (services.events === "sns") secretServices.push("localstack");
13255
14359
  if (services.events === "rabbitmq") secretServices.push("rabbitmq");
13256
- const devSecrets = createStageSecrets("development", secretServices, {
14360
+ const local = templateOptions.stages.local;
14361
+ const devSecrets = createStageSecrets(local, secretServices, {
13257
14362
  projectName: name$1,
13258
14363
  eventsBackend: services.events
13259
14364
  });
13260
14365
  const customSecrets = {
13261
- NODE_ENV: "development",
13262
14366
  PORT: "3000",
13263
14367
  LOG_LEVEL: "debug",
13264
14368
  JWT_SECRET: `dev-${Date.now()}-${Math.random().toString(36).slice(2)}`
@@ -13278,8 +14382,8 @@ async function initCommand(projectName, options = {}) {
13278
14382
  }
13279
14383
  devSecrets.custom = customSecrets;
13280
14384
  await writeStageSecrets(devSecrets, targetDir);
13281
- const keyPath = getKeyPath("development", name$1);
13282
- console.log(` Secrets: .gkm/secrets/development.json (encrypted)`);
14385
+ const keyPath = getKeyPath(local, name$1);
14386
+ console.log(` Secrets: .gkm/secrets/${local}.json (encrypted)`);
13283
14387
  console.log(` Key: ${keyPath}\n`);
13284
14388
  if (!options.skipInstall) {
13285
14389
  console.log("\n📦 Installing dependencies...\n");
@@ -13292,11 +14396,13 @@ async function initCommand(projectName, options = {}) {
13292
14396
  console.error("Failed to install dependencies");
13293
14397
  }
13294
14398
  try {
13295
- execSync("npx @biomejs/biome format --write --unsafe .", {
14399
+ execSync(getExecCommand(pkgManager, "biome check --write ."), {
13296
14400
  cwd: targetDir,
13297
14401
  stdio: "inherit"
13298
14402
  });
13299
- } catch {}
14403
+ } catch {
14404
+ console.warn(`\n⚠️ Formatting did not finish cleanly. Run ${getRunCommand(pkgManager, "fmt")} to see why.`);
14405
+ }
13300
14406
  }
13301
14407
  console.log("\n📦 Initializing git repository...\n");
13302
14408
  try {
@@ -13368,19 +14474,46 @@ function printNextSteps(projectName, options, pkgManager) {
13368
14474
  console.log(` └── turbo.json # Turbo config`);
13369
14475
  console.log("");
13370
14476
  }
14477
+ const { local, deployed } = options.stages;
13371
14478
  console.log("🔐 Secrets management:");
13372
- console.log(` gkm secrets:show --stage development # View secrets`);
13373
- console.log(` gkm secrets:set KEY VALUE --stage development # Add secret`);
13374
- console.log(` gkm secrets:init --stage production # Create production secrets`);
14479
+ console.log(` gkm secrets:show --stage ${local} # View secrets`);
14480
+ console.log(` gkm secrets:set KEY VALUE --stage ${local} # Add secret`);
14481
+ for (const stage of deployed) console.log(` gkm secrets:init --stage ${stage} # Create ${stage} secrets`);
13375
14482
  console.log("");
13376
14483
  if (options.deployTarget === "dokploy") {
13377
14484
  console.log("🚀 Deployment:");
13378
- console.log(` ${getRunCommand(pkgManager, "deploy")}`);
14485
+ for (const stage of deployed) console.log(` ${getRunCommand(pkgManager, `deploy:${stage}`)}`);
14486
+ console.log("");
14487
+ }
14488
+ if (options.deployTarget === "sst") {
14489
+ console.log("🚀 Deployment (AWS, through SST):");
14490
+ for (const stage of deployed) console.log(` ${getRunCommand(pkgManager, `deploy:${stage}`)} # gkm build --provider aws, then sst deploy`);
14491
+ console.log(` Uses your AWS credentials, in ${options.region}.`);
14492
+ if (options.services.mail) console.log(" Set MAIL_FROM to a sender verified in SES first.");
13379
14493
  console.log("");
13380
14494
  }
13381
14495
  console.log("📚 Documentation: https://geekmidas.github.io/toolbox/");
13382
14496
  console.log("");
13383
14497
  }
14498
+ /**
14499
+ * The stages the project declares, from the answers or the flags.
14500
+ *
14501
+ * `--yes` with no stage flags has nobody to ask and needs an answer, so it
14502
+ * takes `local`, and one deployed stage, `production`, that is protected.
14503
+ */
14504
+ function resolveStages(options, answers) {
14505
+ const deployed = stageList(options.stages ?? answers.deployedStages ?? (options.yes ? "production" : ""));
14506
+ const kept = options.protectedStage ?? answers.protectedStage ?? (deployed.length === 1 || options.yes ? deployed[0] : void 0);
14507
+ const local = (options.localStage ?? answers.localStage ?? (options.yes ? "local" : "")).trim();
14508
+ const stages = {
14509
+ local,
14510
+ deployed,
14511
+ ...kept ? { protected: [kept] } : {}
14512
+ };
14513
+ const problems = stageProblems(stages);
14514
+ if (problems.length) throw new Error(problems.join("\n"));
14515
+ return stages;
14516
+ }
13384
14517
 
13385
14518
  //#endregion
13386
14519
  //#region src/secrets/index.ts
@@ -13591,19 +14724,20 @@ function maskUrl(url) {
13591
14724
  * Environment variables are sniffed to inject only what the app needs.
13592
14725
  */
13593
14726
  async function testCommand(options = {}) {
13594
- const stage = options.stage ?? "development";
13595
14727
  const cwd = process.cwd();
14728
+ const stage = options.stage ?? await loadWorkspaceConfig(cwd).then((loaded) => loaded.workspace.stages.local).catch(() => void 0);
14729
+ if (!stage) throw new Error("No stage to test with: add `stages` to gkm.config.ts, or pass --stage.");
13596
14730
  console.log(`\n🧪 Running tests with ${stage} environment...\n`);
13597
14731
  const autoSetup = options.autoSetup || Boolean(process.env.GKM_AUTO_SETUP);
13598
14732
  if (autoSetup) {
13599
- const { ensureStageSecrets } = await import("./setup-DSutDAlt.mjs");
14733
+ const { ensureStageSecrets } = await import("./setup-BA8ciTPU.mjs");
13600
14734
  const generated = await ensureStageSecrets(stage, cwd);
13601
14735
  if (generated) console.log(` 🔐 Generated fresh ${stage} secrets (auto-setup)`);
13602
14736
  }
13603
14737
  const defaultEnv = loadEnvFiles(".env");
13604
14738
  if (defaultEnv.loaded.length > 0) console.log(` 📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
13605
14739
  const result = await prepareEntryCredentials({
13606
- stages: [stage],
14740
+ stage,
13607
14741
  startDocker: true,
13608
14742
  secretsFileName: "test-secrets.json",
13609
14743
  resolveDockerPorts: "full",
@@ -13684,145 +14818,305 @@ function rewriteDatabaseUrlForTests(env) {
13684
14818
  return result;
13685
14819
  }
13686
14820
 
14821
+ //#endregion
14822
+ //#region src/upgrade/version.ts
14823
+ /**
14824
+ * Just enough semver for `gkm upgrade`: ordering versions, and reading the
14825
+ * floor out of a simple range. The same ordering the release pipeline uses to
14826
+ * decide what is published (`scripts/release-state.mjs`).
14827
+ */
14828
+ const VERSION = /(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/;
14829
+ function parse$2(version$1) {
14830
+ const match = version$1.match(VERSION);
14831
+ if (!match) return void 0;
14832
+ const [, major, minor, patch, pre] = match;
14833
+ return {
14834
+ core: [
14835
+ Number(major),
14836
+ Number(minor),
14837
+ Number(patch)
14838
+ ],
14839
+ ids: pre ? pre.split(".").map((id) => /^\d+$/.test(id) ? Number(id) : id) : []
14840
+ };
14841
+ }
14842
+ /** Negative, zero or positive, as `a` is below, equal to or above `b`. */
14843
+ function compareVersions(a, b) {
14844
+ const x = parse$2(a);
14845
+ const y = parse$2(b);
14846
+ if (!x || !y) return 0;
14847
+ for (let i = 0; i < 3; i++) if (x.core[i] !== y.core[i]) return x.core[i] - y.core[i];
14848
+ if (!x.ids.length || !y.ids.length) return y.ids.length - x.ids.length;
14849
+ for (let i = 0; i < Math.max(x.ids.length, y.ids.length); i++) {
14850
+ const p = x.ids[i];
14851
+ const q = y.ids[i];
14852
+ if (p === void 0) return -1;
14853
+ if (q === void 0) return 1;
14854
+ if (p === q) continue;
14855
+ if (typeof p === "number" && typeof q === "number") return p - q;
14856
+ if (typeof p === "number") return -1;
14857
+ if (typeof q === "number") return 1;
14858
+ return p < q ? -1 : 1;
14859
+ }
14860
+ return 0;
14861
+ }
14862
+ /** `alpha` for `10.0.0-alpha.6`; undefined for a release. */
14863
+ function prereleaseTag(version$1) {
14864
+ const id = parse$2(version$1)?.ids[0];
14865
+ return typeof id === "string" ? id : void 0;
14866
+ }
14867
+ /**
14868
+ * A range that is one operator and one version — `~1.2.3`, `^1.2.3`, `>=1.2.3`,
14869
+ * `1.2.3` — split into the two. Anything else (`>=8 <10`, `a || b`) is not
14870
+ * something to rewrite mechanically.
14871
+ */
14872
+ function simpleRange(range) {
14873
+ const match = range.trim().match(/^(\^|~|>=|)v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$/);
14874
+ return match ? {
14875
+ operator: match[1],
14876
+ version: match[2]
14877
+ } : void 0;
14878
+ }
14879
+
13687
14880
  //#endregion
13688
14881
  //#region src/upgrade/index.ts
13689
14882
  const logger = console;
14883
+ const SCOPE = "@geekmidas/";
14884
+ const DEP_FIELDS = [
14885
+ "dependencies",
14886
+ "devDependencies",
14887
+ "peerDependencies",
14888
+ "optionalDependencies"
14889
+ ];
14890
+ async function packument(name$1) {
14891
+ const res = await fetch(`https://registry.npmjs.org/${name$1.replace("/", "%2F")}`, { headers: { accept: "application/vnd.npm.install-v1+json" } });
14892
+ if (!res.ok) throw new RegistryUnavailable(name$1, res.status);
14893
+ return await res.json();
14894
+ }
14895
+ /** Every registry range in the workspace's package.json files. */
14896
+ function packageOccurrences(paths) {
14897
+ const found = [];
14898
+ for (const file of paths) {
14899
+ const pkg$1 = JSON.parse(readFileSync(file, "utf-8"));
14900
+ for (const field of DEP_FIELDS) for (const [name$1, range] of Object.entries(pkg$1[field] ?? {})) found.push({
14901
+ name: name$1,
14902
+ range,
14903
+ file,
14904
+ field
14905
+ });
14906
+ }
14907
+ return found;
14908
+ }
14909
+ /** pnpm catalog entries, which is where `catalog:` references resolve. */
14910
+ function catalogOccurrences(root) {
14911
+ const file = join(root, "pnpm-workspace.yaml");
14912
+ if (!existsSync(file)) return [];
14913
+ const doc = parseDocument(readFileSync(file, "utf-8")).toJS() ?? {};
14914
+ const found = [];
14915
+ for (const [name$1, range] of Object.entries(doc.catalog ?? {})) found.push({
14916
+ name: name$1,
14917
+ range: String(range),
14918
+ file,
14919
+ field: "catalog"
14920
+ });
14921
+ for (const [catalog, entries] of Object.entries(doc.catalogs ?? {})) for (const [name$1, range] of Object.entries(entries ?? {})) found.push({
14922
+ name: name$1,
14923
+ range: String(range),
14924
+ file,
14925
+ field: "catalog",
14926
+ catalog
14927
+ });
14928
+ return found;
14929
+ }
14930
+ /** Not a registry range: resolved elsewhere, so not ours to move. */
14931
+ const isIndirect = (range) => /^(workspace|catalog|link|file|npm|git|github|https?):/.test(range);
14932
+ /**
14933
+ * The version every `@geekmidas` package moves to.
14934
+ *
14935
+ * All of them share one version, so the CLI's line answers for the rest. The
14936
+ * tag is the one the project is already on — a project on `10.0.0-alpha.6`
14937
+ * follows `alpha` — because `latest` names a different major while a new one
14938
+ * is in prerelease, and following it would move the project backwards.
14939
+ */
14940
+ function resolveTarget(distTags, installed, tag) {
14941
+ const highest = [...installed].sort(compareVersions).at(-1);
14942
+ const chosen = tag ?? (highest && prereleaseTag(highest)) ?? "latest";
14943
+ const version$1 = distTags[chosen];
14944
+ if (!version$1) throw new NoReleaseOnTag(chosen, Object.keys(distTags));
14945
+ return {
14946
+ tag: chosen,
14947
+ version: version$1
14948
+ };
14949
+ }
14950
+ /** The same operator on the new version — `^`, `~`, `>=` or none, as written. */
14951
+ function withVersion(range, version$1) {
14952
+ return `${simpleRange(range)?.operator ?? ""}${version$1}`;
14953
+ }
13690
14954
  async function upgradeCommand(options = {}) {
13691
14955
  const cwd = process.cwd();
13692
- logger.log("\n📦 Scanning workspace for @geekmidas packages...\n");
13693
14956
  const pm = detectPackageManager(cwd);
14957
+ const root = findWorkspaceRoot(cwd, pm);
14958
+ logger.log("\n📦 Scanning workspace for @geekmidas packages...\n");
13694
14959
  logger.log(` Package manager: ${pm}`);
13695
- const packageJsonPaths = findWorkspacePackages(cwd, pm);
13696
- logger.log(` Found ${packageJsonPaths.length} package(s) in workspace\n`);
13697
- const dependencies$1 = scanForGeekmidasDeps(packageJsonPaths);
13698
- if (dependencies$1.length === 0) {
14960
+ const packageJsons = findWorkspacePackages(cwd, pm);
14961
+ const occurrences = [...packageOccurrences(packageJsons), ...catalogOccurrences(root)];
14962
+ logger.log(` Found ${packageJsons.length} package(s) in workspace\n`);
14963
+ const ours = occurrences.filter((o) => o.name.startsWith(SCOPE) && !isIndirect(o.range));
14964
+ if (ours.length === 0) {
13699
14965
  logger.log(" No @geekmidas packages found.\n");
13700
14966
  return;
13701
14967
  }
13702
- const uniquePackages = [...new Set(dependencies$1.map((d) => d.packageName))];
13703
- logger.log(` Checking ${uniquePackages.length} unique @geekmidas package(s) on npm...\n`);
13704
- const latestVersions = await fetchLatestVersions(uniquePackages);
13705
- const upgradeInfos = dependencies$1.map((dep) => resolveUpgradeInfo(dep, latestVersions));
13706
- printUpgradeTable(upgradeInfos);
13707
- const upgradable = upgradeInfos.filter((info) => info.needsUpgrade && !info.isWorkspaceRef);
13708
- if (upgradable.length === 0) {
13709
- logger.log("\n All @geekmidas packages are up to date!\n");
14968
+ const installed = ours.map((o) => simpleRange(o.range)?.version).filter((v) => Boolean(v));
14969
+ const cli = await packument(`${SCOPE}cli`);
14970
+ const target = resolveTarget(cli["dist-tags"], installed, options.tag);
14971
+ logger.log(` Target: ${target.version} (npm "${target.tag}")\n`);
14972
+ const highest = installed.sort(compareVersions).at(-1);
14973
+ if (compareVersions(target.version, highest) < 0) throw new WouldDowngrade(highest, target.tag, target.version);
14974
+ const moving = options.all ? ours : ours.filter((o) => o.name === `${SCOPE}cli`);
14975
+ const changes = [];
14976
+ for (const o of moving) {
14977
+ if (!simpleRange(o.range)) {
14978
+ logger.log(` ⚠ ${o.name} ${o.range} is not a simple range; left as is (target ${target.version}).`);
14979
+ continue;
14980
+ }
14981
+ const next = withVersion(o.range, target.version);
14982
+ if (next !== o.range) changes.push({
14983
+ ...o,
14984
+ next
14985
+ });
14986
+ }
14987
+ if (options.all) changes.push(...await peerChanges(occurrences, ours, target.version));
14988
+ if (changes.length === 0) {
14989
+ logger.log(" Everything is already on the target version.\n");
13710
14990
  return;
13711
14991
  }
13712
- logger.log(`\n ${upgradable.length} package(s) can be upgraded.\n`);
14992
+ printChanges(changes, root);
14993
+ if (!options.all && ours.some((o) => o.name !== `${SCOPE}cli`)) logger.log("\n Only @geekmidas/cli moves without --all. Afterwards, run `gkm upgrade --all` with the new CLI.");
13713
14994
  if (options.dryRun) {
13714
- logger.log(" --dry-run: No changes made.\n");
13715
- printUpgradeCommands(upgradable, pm);
14995
+ logger.log("\n --dry-run: no changes made.\n");
13716
14996
  return;
13717
14997
  }
13718
- executeUpgrade(upgradable, pm, cwd);
13719
- logger.log("\n ✅ Upgrade complete! Run your tests to verify.\n");
14998
+ applyChanges(changes);
14999
+ install(pm, root);
15000
+ logger.log("\n ✅ Upgrade complete. Run your tests to verify.\n");
13720
15001
  }
13721
- function scanForGeekmidasDeps(packageJsonPaths) {
13722
- const results = [];
13723
- const depTypes = [
13724
- "dependencies",
13725
- "devDependencies",
13726
- "peerDependencies"
13727
- ];
13728
- for (const pkgJsonPath of packageJsonPaths) {
13729
- const pkg$1 = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
13730
- const workspaceName = pkg$1.name ?? pkgJsonPath;
13731
- for (const depType of depTypes) {
13732
- const deps = pkg$1[depType];
13733
- if (!deps) continue;
13734
- for (const [name$1, version$1] of Object.entries(deps)) {
13735
- if (!name$1.startsWith("@geekmidas/")) continue;
13736
- results.push({
13737
- packageName: name$1,
13738
- currentVersion: version$1,
13739
- depType,
13740
- packageJsonPath: pkgJsonPath,
13741
- workspaceName
13742
- });
13743
- }
15002
+ /**
15003
+ * Third-party packages the project already lists, raised to the floor of the
15004
+ * peer range the target `@geekmidas` versions declare.
15005
+ *
15006
+ * Only raised, never lowered, and only where both the peer range and the
15007
+ * project's range are one operator and one version; anything else is reported
15008
+ * for a person to decide.
15009
+ */
15010
+ async function peerChanges(occurrences, ours, version$1) {
15011
+ const floors = /* @__PURE__ */ new Map();
15012
+ const names = [...new Set(ours.map((o) => o.name))];
15013
+ const docs = await Promise.all(names.map(packument));
15014
+ for (const doc of docs) {
15015
+ const peers = doc.versions[version$1]?.peerDependencies ?? {};
15016
+ for (const [peer, range] of Object.entries(peers)) {
15017
+ if (peer.startsWith(SCOPE)) continue;
15018
+ const floor = simpleRange(range)?.version;
15019
+ if (!floor) continue;
15020
+ const known = floors.get(peer);
15021
+ if (!known || compareVersions(floor, known) > 0) floors.set(peer, floor);
15022
+ }
15023
+ }
15024
+ const changes = [];
15025
+ for (const o of occurrences) {
15026
+ const floor = floors.get(o.name);
15027
+ if (!floor || isIndirect(o.range) || o.field === "peerDependencies") continue;
15028
+ const current = simpleRange(o.range);
15029
+ if (!current) {
15030
+ logger.log(` ⚠ ${o.name} ${o.range} is not a simple range; @geekmidas needs at least ${floor}.`);
15031
+ continue;
13744
15032
  }
15033
+ if (compareVersions(current.version, floor) < 0) changes.push({
15034
+ ...o,
15035
+ next: withVersion(o.range, floor)
15036
+ });
13745
15037
  }
13746
- return results;
13747
- }
13748
- async function fetchLatestVersions(packageNames) {
13749
- const versions = /* @__PURE__ */ new Map();
13750
- const results = await Promise.allSettled(packageNames.map(async (name$1) => {
13751
- const res = await fetch(`https://registry.npmjs.org/${name$1}/latest`);
13752
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
13753
- const data = await res.json();
13754
- return {
13755
- name: name$1,
13756
- version: data.version
13757
- };
13758
- }));
13759
- for (const result of results) if (result.status === "fulfilled") versions.set(result.value.name, result.value.version);
13760
- return versions;
13761
- }
13762
- function resolveUpgradeInfo(dep, latestVersions) {
13763
- const isWorkspaceRef = dep.currentVersion.startsWith("workspace:");
13764
- const latestVersion = latestVersions.get(dep.packageName) ?? "unknown";
13765
- const currentBare = dep.currentVersion.replace(/^[\^~>=<]*/g, "");
13766
- const needsUpgrade = !isWorkspaceRef && latestVersion !== "unknown" && currentBare !== latestVersion;
13767
- return {
13768
- ...dep,
13769
- latestVersion,
13770
- isWorkspaceRef,
13771
- needsUpgrade
13772
- };
13773
- }
13774
- function printUpgradeTable(infos) {
13775
- const byPackage = /* @__PURE__ */ new Map();
13776
- for (const info of infos) if (!byPackage.has(info.packageName)) byPackage.set(info.packageName, info);
13777
- const nameWidth = 33;
13778
- const verWidth = 14;
13779
- const statusWidth = 14;
13780
- const hr = ` ${"─".repeat(nameWidth + verWidth * 2 + statusWidth + 5)}`;
13781
- logger.log(hr);
13782
- logger.log(` ${"Package".padEnd(nameWidth)} ${"Current".padEnd(verWidth)} ${"Latest".padEnd(verWidth)} ${"Status".padEnd(statusWidth)}`);
13783
- logger.log(hr);
13784
- for (const [, info] of byPackage) {
13785
- const name$1 = info.packageName.padEnd(nameWidth);
13786
- const current = info.currentVersion.padEnd(verWidth);
13787
- const latest = info.latestVersion.padEnd(verWidth);
13788
- let status;
13789
- if (info.isWorkspaceRef) status = "workspace";
13790
- else if (info.needsUpgrade) status = "⬆ upgrade";
13791
- else status = "✓ up-to-date";
13792
- logger.log(` ${name$1} ${current} ${latest} ${status}`);
13793
- }
13794
- logger.log(hr);
13795
- }
13796
- function printUpgradeCommands(upgradable, pm) {
13797
- logger.log(" Commands that would be run:\n");
13798
- const uniquePackages = [...new Set(upgradable.map((i) => i.packageName))];
13799
- const cmd = getWorkspaceUpgradeCommand(pm, uniquePackages);
13800
- logger.log(` ${cmd}\n`);
13801
- }
13802
- function getWorkspaceUpgradeCommand(pm, packages) {
13803
- const pkgList = packages.join(" ");
13804
- switch (pm) {
13805
- case "pnpm": return `pnpm update -r ${pkgList} --latest`;
13806
- case "yarn": return `yarn upgrade ${pkgList}`;
13807
- case "bun": return `bun update ${pkgList}`;
13808
- case "npm": return `npm update ${pkgList} --workspaces`;
13809
- default: return `npm update ${pkgList}`;
15038
+ return changes;
15039
+ }
15040
+ function printChanges(changes, root) {
15041
+ logger.log(" Changes:\n");
15042
+ for (const c of changes) {
15043
+ const where = c.field === "catalog" ? `pnpm-workspace.yaml (${c.catalog ? `catalogs.${c.catalog}` : "catalog"})` : `${relative(root, c.file) || "package.json"} (${c.field})`;
15044
+ logger.log(` ${c.name.padEnd(34)} ${c.range} → ${c.next} ${where}`);
15045
+ }
15046
+ }
15047
+ function applyChanges(changes) {
15048
+ const byFile = /* @__PURE__ */ new Map();
15049
+ for (const c of changes) byFile.set(c.file, [...byFile.get(c.file) ?? [], c]);
15050
+ for (const [file, fileChanges] of byFile) {
15051
+ const text = readFileSync(file, "utf-8");
15052
+ if (file.endsWith(".yaml")) {
15053
+ const doc = parseDocument(text);
15054
+ for (const c of fileChanges) {
15055
+ const path = c.catalog ? [
15056
+ "catalogs",
15057
+ c.catalog,
15058
+ c.name
15059
+ ] : ["catalog", c.name];
15060
+ doc.setIn(path, c.next);
15061
+ }
15062
+ writeFileSync(file, doc.toString());
15063
+ continue;
15064
+ }
15065
+ const pkg$1 = JSON.parse(text);
15066
+ for (const c of fileChanges) pkg$1[c.field][c.name] = c.next;
15067
+ const indent = text.match(/^[ \t]+(?=")/m)?.[0] ?? " ";
15068
+ writeFileSync(file, `${JSON.stringify(pkg$1, null, indent)}\n`);
13810
15069
  }
13811
15070
  }
13812
- function executeUpgrade(upgradable, pm, cwd) {
13813
- const uniquePackages = [...new Set(upgradable.map((i) => i.packageName))];
13814
- const cmd = getWorkspaceUpgradeCommand(pm, uniquePackages);
13815
- logger.log(` Running: ${cmd}\n`);
15071
+ function install(pm, cwd) {
15072
+ const command = getInstallCommand(pm);
15073
+ logger.log(`\n Running: ${command}\n`);
13816
15074
  try {
13817
- execSync(cmd, {
15075
+ execSync(command, {
13818
15076
  cwd,
13819
15077
  stdio: "inherit",
13820
- timeout: 12e4
15078
+ timeout: 3e5
13821
15079
  });
13822
15080
  } catch {
13823
- throw new Error("Package upgrade failed. Check the output above for details.");
15081
+ throw new InstallFailed(command);
13824
15082
  }
13825
15083
  }
15084
+ /** npm did not answer for a package, so there is no version to compare. */
15085
+ var RegistryUnavailable = class extends Error {
15086
+ constructor(packageName, status) {
15087
+ super(`npm answered ${status} for ${packageName}. Check the name and your network.`);
15088
+ this.packageName = packageName;
15089
+ this.status = status;
15090
+ this.name = "RegistryUnavailable";
15091
+ }
15092
+ };
15093
+ /** A dist-tag npm does not have, e.g. `--tag beta` before any beta. */
15094
+ var NoReleaseOnTag = class extends Error {
15095
+ constructor(tag, tags) {
15096
+ super(`npm has no "${tag}" release of @geekmidas/cli. Tags: ${tags.join(", ")}.`);
15097
+ this.tag = tag;
15098
+ this.tags = tags;
15099
+ this.name = "NoReleaseOnTag";
15100
+ }
15101
+ };
15102
+ /** The line asked for is behind what is installed; upgrade never goes back. */
15103
+ var WouldDowngrade = class extends Error {
15104
+ constructor(installed, tag, target) {
15105
+ super(`The project is already on ${installed}, ahead of npm's "${tag}" (${target}). Pass --tag to follow another line.`);
15106
+ this.installed = installed;
15107
+ this.tag = tag;
15108
+ this.target = target;
15109
+ this.name = "WouldDowngrade";
15110
+ }
15111
+ };
15112
+ /** The install after rewriting versions failed; its own output says why. */
15113
+ var InstallFailed = class extends Error {
15114
+ constructor(command) {
15115
+ super(`\`${command}\` failed. Check the output above for details.`);
15116
+ this.command = command;
15117
+ this.name = "InstallFailed";
15118
+ }
15119
+ };
13826
15120
 
13827
15121
  //#endregion
13828
15122
  //#region src/index.ts
@@ -13831,7 +15125,7 @@ program.name("gkm").description("GeekMidas backend framework CLI").version(packa
13831
15125
  const opts = program.opts();
13832
15126
  if (opts.debug) enableDebug();
13833
15127
  });
13834
- 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) => {
15128
+ 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) => {
13835
15129
  try {
13836
15130
  const globalOptions = program.opts();
13837
15131
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
@@ -13851,7 +15145,7 @@ program.command("trust").description("Trust the local edge's certificate authori
13851
15145
  process.exit(1);
13852
15146
  }
13853
15147
  });
13854
- 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) => {
15148
+ 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) => {
13855
15149
  try {
13856
15150
  const globalOptions = program.opts();
13857
15151
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
@@ -13923,7 +15217,7 @@ program.command("exec").description("Run a command with secrets injected into Cr
13923
15217
  process.exit(1);
13924
15218
  }
13925
15219
  });
13926
- 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) => {
15220
+ 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) => {
13927
15221
  try {
13928
15222
  const globalOptions = program.opts();
13929
15223
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
@@ -14071,7 +15365,7 @@ program.command("secrets:push").description("Push secrets to remote provider (SS
14071
15365
  const { workspace } = await loadWorkspaceConfig$1();
14072
15366
  const secrets = await readStageSecrets$1(options.stage, workspace.root);
14073
15367
  if (secrets) {
14074
- const { derivedContainers: derivedContainers$1 } = await import("./workspace-yP5AHnrn.mjs");
15368
+ const { derivedContainers: derivedContainers$1 } = await import("./workspace-BzrG_2SI.mjs");
14075
15369
  const result = reconcileMissingSecrets(secrets, workspace, await derivedContainers$1(workspace, options.stage));
14076
15370
  if (result) {
14077
15371
  await writeStageSecrets$1(result.secrets, workspace.root);
@@ -14100,7 +15394,7 @@ program.command("secrets:pull").description("Pull secrets from remote provider (
14100
15394
  console.error(`No remote secrets found for stage "${options.stage}".`);
14101
15395
  process.exit(1);
14102
15396
  }
14103
- const { derivedContainers: derivedContainers$1 } = await import("./workspace-yP5AHnrn.mjs");
15397
+ const { derivedContainers: derivedContainers$1 } = await import("./workspace-BzrG_2SI.mjs");
14104
15398
  const result = reconcileMissingSecrets(secrets, workspace, await derivedContainers$1(workspace, options.stage));
14105
15399
  if (result) {
14106
15400
  secrets = result.secrets;
@@ -14114,7 +15408,7 @@ program.command("secrets:pull").description("Pull secrets from remote provider (
14114
15408
  process.exit(1);
14115
15409
  }
14116
15410
  });
14117
- program.command("secrets:reconcile").description("Backfill missing custom secrets from workspace config").option("--stage <stage>", "Stage name", "development").action(async (options) => {
15411
+ program.command("secrets:reconcile").description("Backfill missing custom secrets from workspace config").option("--stage <stage>", "Stage name (default: stages.local)").action(async (options) => {
14118
15412
  try {
14119
15413
  const globalOptions = program.opts();
14120
15414
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
@@ -14122,19 +15416,20 @@ program.command("secrets:reconcile").description("Backfill missing custom secret
14122
15416
  const { reconcileMissingSecrets } = await import("./reconcile-CFs8C_1a.mjs");
14123
15417
  const { readStageSecrets: readStageSecrets$1, writeStageSecrets: writeStageSecrets$1 } = await import("./storage-CYlGwWTj.mjs");
14124
15418
  const { workspace } = await loadWorkspaceConfig$1();
14125
- const secrets = await readStageSecrets$1(options.stage, workspace.root);
15419
+ const stage = options.stage ?? workspace.stages.local;
15420
+ const secrets = await readStageSecrets$1(stage, workspace.root);
14126
15421
  if (!secrets) {
14127
- console.error(`No secrets found for stage "${options.stage}". Run "gkm secrets:init --stage ${options.stage}" first.`);
15422
+ console.error(`No secrets found for stage "${stage}". Run "gkm secrets:init --stage ${stage}" first.`);
14128
15423
  process.exit(1);
14129
15424
  }
14130
- const { derivedContainers: derivedContainers$1 } = await import("./workspace-yP5AHnrn.mjs");
14131
- const result = reconcileMissingSecrets(secrets, workspace, await derivedContainers$1(workspace, options.stage));
15425
+ const { derivedContainers: derivedContainers$1 } = await import("./workspace-BzrG_2SI.mjs");
15426
+ const result = reconcileMissingSecrets(secrets, workspace, await derivedContainers$1(workspace, stage));
14132
15427
  if (!result) {
14133
- console.log(`\n✓ Secrets for stage "${options.stage}" are up-to-date`);
15428
+ console.log(`\n✓ Secrets for stage "${stage}" are up-to-date`);
14134
15429
  return;
14135
15430
  }
14136
15431
  await writeStageSecrets$1(result.secrets, workspace.root);
14137
- console.log(`\n✓ Reconciled ${result.addedKeys.length} missing secret(s) for stage "${options.stage}":`);
15432
+ console.log(`\n✓ Reconciled ${result.addedKeys.length} missing secret(s) for stage "${stage}":`);
14138
15433
  for (const key of result.addedKeys) console.log(` + ${key}`);
14139
15434
  } catch (error) {
14140
15435
  console.error(formatError(error));
@@ -14288,7 +15583,7 @@ program.command("state:diff").description("Compare local and remote deployment s
14288
15583
  process.exit(1);
14289
15584
  }
14290
15585
  });
14291
- program.command("upgrade").description("Upgrade all @geekmidas packages to their latest versions").option("--dry-run", "Show what would be upgraded without making changes").action(async (options) => {
15586
+ program.command("upgrade").description("Upgrade @geekmidas/cli (with --all, every @geekmidas package) along the release line the project is on").option("--dry-run", "Show what would be upgraded without making changes").option("--all", "Every @geekmidas package, and the third-party packages their peers require").option("--tag <tag>", "npm dist-tag to follow (default: the line you are on)").action(async (options) => {
14292
15587
  try {
14293
15588
  const globalOptions = program.opts();
14294
15589
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);