@geekmidas/cli 10.0.0-alpha.23 → 10.0.0-alpha.25

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 (47) hide show
  1. package/bin/adjacent-tsconfig.mjs +46 -8
  2. package/bin/gkm.mjs +2 -2
  3. package/dist/{config-DdKkoUE1.mjs → config-DBGpRTUy.mjs} +2 -2
  4. package/dist/{config-DdKkoUE1.mjs.map → config-DBGpRTUy.mjs.map} +1 -1
  5. package/dist/{config-DSXI480r.cjs → config-Dn9DW2Fz.cjs} +2 -2
  6. package/dist/{config-DSXI480r.cjs.map → config-Dn9DW2Fz.cjs.map} +1 -1
  7. package/dist/config.cjs +1 -1
  8. package/dist/config.mjs +1 -1
  9. package/dist/{credentials-D9TKJJZc.mjs → credentials-BpoOUa74.mjs} +17 -6
  10. package/dist/credentials-BpoOUa74.mjs.map +1 -0
  11. package/dist/{credentials-COrSpZfF.cjs → credentials-Dbyccl3A.cjs} +17 -6
  12. package/dist/credentials-Dbyccl3A.cjs.map +1 -0
  13. package/dist/deploy/sniffer-routes-worker.cjs +1 -1
  14. package/dist/deploy/sniffer-routes-worker.mjs +1 -1
  15. package/dist/{discover-CK-n3w6b.mjs → discover-0fhCbnk0.mjs} +3 -2
  16. package/dist/{discover-CK-n3w6b.mjs.map → discover-0fhCbnk0.mjs.map} +1 -1
  17. package/dist/{discover-BERGFg8v.cjs → discover-B_HtYJCr.cjs} +3 -2
  18. package/dist/{discover-BERGFg8v.cjs.map → discover-B_HtYJCr.cjs.map} +1 -1
  19. package/dist/{github-B4OQQZBV.mjs → github-BOaa3kQR.mjs} +3 -3
  20. package/dist/{github-B4OQQZBV.mjs.map → github-BOaa3kQR.mjs.map} +1 -1
  21. package/dist/{github-BpsnSDw2.cjs → github-CUjW3G91.cjs} +3 -3
  22. package/dist/{github-BpsnSDw2.cjs.map → github-CUjW3G91.cjs.map} +1 -1
  23. package/dist/index.cjs +1865 -1759
  24. package/dist/index.cjs.map +1 -1
  25. package/dist/index.mjs +1865 -1759
  26. package/dist/index.mjs.map +1 -1
  27. package/dist/{openapi-CN69hrf0.cjs → openapi-BAKQLho-.cjs} +16 -6
  28. package/dist/openapi-BAKQLho-.cjs.map +1 -0
  29. package/dist/{openapi-B5PSuLym.mjs → openapi-DHQlstv6.mjs} +16 -6
  30. package/dist/openapi-DHQlstv6.mjs.map +1 -0
  31. package/dist/openapi.cjs +1 -1
  32. package/dist/openapi.mjs +1 -1
  33. package/dist/reconcile/public.cjs +1 -1
  34. package/dist/reconcile/public.d.cts +7 -0
  35. package/dist/reconcile/public.d.cts.map +1 -1
  36. package/dist/reconcile/public.d.mts +7 -0
  37. package/dist/reconcile/public.d.mts.map +1 -1
  38. package/dist/reconcile/public.mjs +1 -1
  39. package/dist/{transfer-Bk5YlRyB.mjs → transfer-B88fTvnh.mjs} +2 -2
  40. package/dist/{transfer-Bk5YlRyB.mjs.map → transfer-B88fTvnh.mjs.map} +1 -1
  41. package/dist/{transfer-BZbyaV3I.cjs → transfer-D59M4aaR.cjs} +2 -2
  42. package/dist/{transfer-BZbyaV3I.cjs.map → transfer-D59M4aaR.cjs.map} +1 -1
  43. package/package.json +17 -12
  44. package/dist/credentials-COrSpZfF.cjs.map +0 -1
  45. package/dist/credentials-D9TKJJZc.mjs.map +0 -1
  46. package/dist/openapi-B5PSuLym.mjs.map +0 -1
  47. package/dist/openapi-CN69hrf0.cjs.map +0 -1
package/dist/index.mjs CHANGED
@@ -1,15 +1,15 @@
1
1
  #!/usr/bin/env -S npx tsx
2
2
  import { n as __require, t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
3
- import { i as DEFAULT_CACHE, r as ConstructGenerator, s as isPartitionedRoutes, t as discover } from "./discover-CK-n3w6b.mjs";
4
- import { i as loadConfig, l as derivedApps, n as getAppNameFromCwd, o as loadWorkspaceConfig, r as loadAppConfig, s as parseModuleConfig } from "./config-DdKkoUE1.mjs";
3
+ import { i as DEFAULT_CACHE, r as ConstructGenerator, s as isPartitionedRoutes, t as discover } from "./discover-0fhCbnk0.mjs";
4
+ import { i as loadConfig, l as derivedApps, n as getAppNameFromCwd, o as loadWorkspaceConfig, r as loadAppConfig, s as parseModuleConfig } from "./config-DBGpRTUy.mjs";
5
5
  import { A as stripPublicPrefix, C as InvalidStages, D as stageProblems, E as deployedProblems, T as assertDeployedStage, a as getAppBuildOrder, k as getPublicEnvPrefix, o as getAppGkmConfig, s as getDependencyEnvVars, t as allConstructGlobs, v as getDeployTargetError, w as TEST_STAGE, y as isDeployTargetSupported } from "./workspace-Bu3xNCej.mjs";
6
- import { C as providerOf, S as eventsBackendFor, _ as COMPOSE_PATH, a as loadEnvFiles, b as resourceName, c as resolveServicePorts, d as backendsOf, f as constructGlobs, h as usesConstructs, i as isPortAvailable, l as rewriteUrlsWithPorts, m as reconcileWorkspace, n as createEntryWrapper, o as loadSecretsForApp, p as derivedContainers, r as findAvailablePort, s as prepareEntryCredentials, t as createCredentialsPreload, u as startWorkspaceServices, v as LOCAL_CA_PATH, w as storageBackendFor, x as cacheBackendFor, y as applyPostgres } from "./credentials-D9TKJJZc.mjs";
6
+ import { C as providerOf, S as eventsBackendFor, _ as COMPOSE_PATH, a as loadEnvFiles, b as resourceName, c as resolveServicePorts, d as backendsOf, f as constructGlobs, h as usesConstructs, i as isPortAvailable, l as rewriteUrlsWithPorts, m as reconcileWorkspace, n as createEntryWrapper, o as loadSecretsForApp, p as derivedContainers, r as findAvailablePort, s as prepareEntryCredentials, t as createCredentialsPreload, u as startWorkspaceServices, v as LOCAL_CA_PATH, w as storageBackendFor, x as cacheBackendFor, y as applyPostgres } from "./credentials-BpoOUa74.mjs";
7
7
  import { a as generateServiceCredentials, i as generateSecurePassword, n as generateConnectionUrls, o as rotateServicePassword, t as createStageSecrets } from "./generator-LfZU7NlU.mjs";
8
8
  import { c as getKeyPath, i as setCustomSecret, n as readStageSecrets, o as toEmbeddableSecrets, r as secretsExist, s as writeStageSecrets, t as maskPassword } from "./storage-TP0x118M.mjs";
9
- import { a as openapiCommand, c as EndpointGenerator, l as runtimeFor, n as generateOpenApi, o as resolveOpenApiConfig, r as generateOpenApiFrom, s as OpenApiTsGenerator, t as OPENAPI_OUTPUT_PATH$1 } from "./openapi-B5PSuLym.mjs";
9
+ import { a as openapiCommand, c as EndpointGenerator, l as runtimeFor, n as generateOpenApi, o as resolveOpenApiConfig, s as OpenApiTsGenerator, t as OPENAPI_OUTPUT_PATH$1 } from "./openapi-DHQlstv6.mjs";
10
10
  import { i as withRoutes, r as withCompute } from "./emit-Bzq7JsMP.mjs";
11
11
  import { i as writeDockerEnvFromSecrets, r as generateFullstackCustomSecrets } from "./reconcile-gb1lz1Dk.mjs";
12
- import { a as isRemoteStore, n as pullStageSecrets, r as pushStageSecrets, t as NoStoredSecrets } from "./transfer-Bk5YlRyB.mjs";
12
+ import { a as isRemoteStore, n as pullStageSecrets, r as pushStageSecrets, t as NoStoredSecrets } from "./transfer-B88fTvnh.mjs";
13
13
  import { createRequire } from "node:module";
14
14
  import { Command } from "commander";
15
15
  import { stdin, stdout } from "node:process";
@@ -28,18 +28,18 @@ import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
28
28
  import { Client } from "pg";
29
29
  import { promisify } from "node:util";
30
30
  import { cacheTableStatements } from "@geekmidas/cache/postgres";
31
+ import { fileURLToPath, pathToFileURL } from "node:url";
31
32
  import { Cron } from "@geekmidas/constructs/crons";
32
33
  import { Function } from "@geekmidas/constructs/functions";
33
34
  import { Queue } from "@geekmidas/constructs/queue";
34
35
  import { Subscriber } from "@geekmidas/constructs/subscribers";
35
36
  import { Topic } from "@geekmidas/constructs/topic";
36
- import { fileURLToPath, pathToFileURL } from "node:url";
37
37
  import { lookup } from "node:dns/promises";
38
38
  import prompts from "prompts";
39
39
  import { EnvironmentParser } from "@geekmidas/envkit";
40
40
  import { serviceContext } from "@geekmidas/services";
41
41
  //#region package.json
42
- var version = "10.0.0-alpha.23";
42
+ var version = "10.0.0-alpha.25";
43
43
  //#endregion
44
44
  //#region src/auth/credentials.ts
45
45
  /**
@@ -394,1774 +394,1774 @@ function isEnabled(config) {
394
394
  return config.enabled !== false;
395
395
  }
396
396
  //#endregion
397
- //#region src/generators/CronGenerator.ts
398
- var CronGenerator = class extends ConstructGenerator {
399
- async build(context, constructs, outputDir, options) {
400
- const provider = options?.provider || "aws-lambda";
401
- const logger = console;
402
- const cronInfos = [];
403
- if (provider === "server") {
404
- await this.generateServerCronsFile(outputDir, constructs);
405
- logger.log(`Generated server crons file with ${constructs.length} crons`);
406
- return cronInfos;
407
- }
408
- if (constructs.length === 0 || provider !== "aws-lambda") return cronInfos;
409
- const cronsDir = join(outputDir, "crons");
410
- await mkdir(cronsDir, { recursive: true });
411
- for (const { key, construct, path } of constructs) {
412
- const handlerFile = await this.generateCronHandler(cronsDir, path.relative, key, context, construct.owner);
413
- cronInfos.push({
414
- name: key,
415
- handler: relative(process.cwd(), handlerFile).replace(/\.ts$/, ".handler"),
416
- schedule: construct.schedule || "rate(1 hour)",
417
- timeout: construct.timeout,
418
- memorySize: construct.memorySize,
419
- environment: await construct.getEnvironment({ markOptional: context.markOptional }),
420
- dependencies: construct.constructs
421
- });
422
- logger.log(`Generated cron handler: ${key}`);
423
- }
424
- return cronInfos;
425
- }
426
- isConstruct(value) {
427
- return Cron.isCron(value);
428
- }
429
- /**
430
- * The crons file a server entry imports.
431
- *
432
- * Shaped after `generateServerSubscribersFile`: one module, every cron, a
433
- * `setupCrons` the generated entry calls beside `setupSubscribers`.
434
- *
435
- * It picks its scheduler from what the process can reach. With a Postgres it
436
- * uses pg-boss, whose schedules live in the database — so a deployment
437
- * running four replicas fires each job once, which is the thing a timer in
438
- * every process gets wrong and does not report. With no database it falls
439
- * back to timers and says plainly that it is doing so.
440
- */
441
- async generateServerCronsFile(outputDir, crons) {
442
- await mkdir(outputDir, { recursive: true });
443
- const cronsPath = join(outputDir, "crons.ts");
444
- const importsByFile = /* @__PURE__ */ new Map();
445
- for (const { path, key } of crons) {
446
- const importPath = relative(dirname(cronsPath), path.relative).replace(/\.ts$/, ".js");
447
- if (!importsByFile.has(importPath)) importsByFile.set(importPath, []);
448
- importsByFile.get(importPath)?.push(key);
449
- }
450
- const content = `/**
451
- * Generated cron setup.
452
- *
453
- * Schedules every cron this app declares, in the process that serves it.
454
- *
455
- * The schedule lives in Postgres, through pg-boss, in the database the worker
456
- * named with \`.database(db)\`. That is what makes running several replicas
457
- * safe: each job fires once, where a timer in every process fires it once per
458
- * replica and reports nothing.
459
- *
460
- * No connection string appears here. The construct that owns the database is
461
- * the only thing that knows its key, and it is resolved through service
462
- * discovery like any other dependency.
463
- */
464
- import type { EnvironmentParser } from '@geekmidas/envkit';
465
- import type { Logger } from '@geekmidas/logger';
466
- import { toCronExpression } from '@geekmidas/constructs/crons';
467
- import { ServiceDiscovery } from '@geekmidas/services';
468
- ${Array.from(importsByFile.entries()).map(([importPath, exports]) => `import { ${exports.join(", ")} } from '${importPath}';`).join("\n")}
469
-
470
- const crons = [
471
- ${crons.map(({ key }) => ` { name: '${key}', cron: ${key} }`).join(",\n")}
472
- ];
473
-
474
- type Stoppable = { stop: () => Promise<void> | void };
475
- const running: Stoppable[] = [];
476
-
477
- export async function setupCrons(
478
- envParser: EnvironmentParser<any>,
479
- logger: Logger,
480
- ): Promise<void> {
481
- if (crons.length === 0) {
482
- return;
483
- }
484
-
485
- const serviceDiscovery = ServiceDiscovery.getInstance(envParser);
486
-
487
- // Resolved once per cron rather than per firing: registering services on
488
- // every tick would reconnect a database every minute.
489
- const prepared = [];
490
- for (const { name, cron } of crons) {
491
- try {
492
- const schedule = toCronExpression(cron.schedule);
493
- const services = cron.services.length > 0
494
- ? await serviceDiscovery.register(cron.services)
495
- : {};
496
-
497
- prepared.push({ name, cron, schedule, services });
498
- } catch (error) {
499
- // An unrepresentable schedule is a build-time mistake, and failing the
500
- // process for it would take the HTTP server down with it.
501
- logger.error({ error, cron: name }, 'Cron has no runnable schedule, skipping');
502
- }
503
- }
504
-
505
- const run = async (entry: (typeof prepared)[number]) => {
506
- try {
507
- await entry.cron.handler({
508
- input: undefined,
509
- services: entry.services,
510
- logger: entry.cron.logger,
511
- });
512
- } catch (error) {
513
- logger.error({ error, cron: entry.name }, 'Cron failed');
514
- }
515
- };
516
-
517
- // Declared once on the worker, carried onto every cron it built — so this
518
- // reads it from any of them rather than from config or the environment.
519
- const scheduleStore = prepared.find(({ cron }) => cron.scheduleStore)?.cron
520
- .scheduleStore;
521
-
522
- if (!scheduleStore) {
523
- // Declared, not discovered. A worker says where its schedules live with
524
- // \`.database(db)\`; inferring one from whatever database happened to be
525
- // around would move the schedules the day an app declared a second.
526
- logger.error(
527
- { crons: prepared.map(({ name }) => name) },
528
- 'These crons have nowhere to keep their schedule. A server fires its own ' +
529
- 'crons, and a process scheduling in memory fires each job once per ' +
530
- 'replica — so the schedule lives in Postgres. Say where with ' +
531
- '\`new Worker(…).database(db)\`.',
532
- );
533
- return;
534
- }
535
-
536
- const [store] = await serviceDiscovery
537
- .register([scheduleStore])
538
- .then((s) => Object.values(s));
539
-
540
- const { PgBoss } = await import('pg-boss');
541
- // The pool the construct already opened — no connection string is read here,
542
- // and none is named anywhere outside the construct that owns it.
543
- const boss = new PgBoss({ db: store as never });
544
- await boss.start();
545
-
546
- // What this app declares now. Anything else under the prefix belonged to a
547
- // cron since renamed or deleted, and would keep firing at a handler that has
548
- // gone.
549
- //
550
- // The prefix is \`cron.\` rather than \`cron:\`: pg-boss accepts
551
- // alphanumerics, underscores, hyphens, periods and slashes in a queue name,
552
- // and rejects a colon outright.
553
- const declared = new Set(prepared.map(({ name }) => \`cron.\${name}\`));
554
- for (const existing of await boss.getSchedules()) {
555
- if (existing.name.startsWith('cron.') && !declared.has(existing.name)) {
556
- await boss.unschedule(existing.name);
557
- logger.info({ cron: existing.name }, 'Removed a schedule nothing declares');
558
- }
559
- }
560
-
561
- for (const entry of prepared) {
562
- const queue = \`cron.\${entry.name}\`;
563
- await boss.createQueue(queue);
564
- // UTC, so this agrees with the schedule a deploy target would have made.
565
- await boss.schedule(queue, entry.schedule, undefined, { tz: 'UTC' });
566
- await boss.work(queue, () => run(entry));
567
-
568
- logger.info({ cron: entry.name, schedule: entry.schedule }, 'Cron scheduled');
569
- }
570
-
571
- running.push({ stop: () => boss.stop() });
572
- logger.info({ count: prepared.length }, 'Crons scheduled');
573
- }
574
-
575
- export async function stopCrons(): Promise<void> {
576
- await Promise.all(running.map((r) => r.stop()));
577
- running.length = 0;
578
- }
579
- `;
580
- await writeFile(cronsPath, content);
581
- return cronsPath;
582
- }
583
- async generateCronHandler(outputDir, sourceFile, exportName, context, owner) {
584
- const handlerFileName = `${exportName}.ts`;
585
- const handlerPath = join(outputDir, handlerFileName);
586
- const importPath = relative(dirname(handlerPath), sourceFile).replace(/\.ts$/, ".js");
587
- const runtime = runtimeFor(context, dirname(handlerPath), owner);
588
- const content = `import { AWSScheduledFunction } from '@geekmidas/constructs/aws';
589
- import { ${exportName} } from '${importPath}';
590
- ${runtime.imports}
591
- ${runtime.bindings}
592
-
593
- const adapter = new AWSScheduledFunction(envParser, ${exportName});
594
-
595
- export const handler = adapter.handler;
596
- `;
597
- await writeFile(handlerPath, content);
598
- return handlerPath;
599
- }
600
- };
601
- //#endregion
602
- //#region src/generators/drivers.ts
603
- /**
604
- * Driver registration for generated entry points.
605
- *
606
- * A construct declares that it needs object storage and is handed one URL; the
607
- * scheme in that URL picks the driver that builds the client. Which drivers
608
- * exist is therefore the *entry point's* decision, not the construct's and not
609
- * the application's — an app that never talks to S3 should never resolve the AWS
610
- * SDK, and application code that registers a driver has named a provider in the
611
- * one layer this design keeps provider-free.
612
- *
613
- * So the generated entries register: `gkm dev`'s server for local development,
614
- * and each Lambda handler for its own target. Registration is an explicit call
615
- * rather than an import side effect, because a side-effecting module is exactly
616
- * what a bundler is entitled to drop.
617
- */
618
- /** The `s3://` driver, which serves MinIO locally and S3 deployed. */
619
- const S3 = {
620
- imports: `import { registerStorageDriver } from '@geekmidas/storage';\nimport { s3Driver } from '@geekmidas/storage/aws';`,
621
- setup: "registerStorageDriver(s3Driver);"
622
- };
623
- const NONE = {
624
- imports: "",
625
- setup: ""
626
- };
397
+ //#region src/workspace/client-generator.ts
627
398
  /**
628
- * The cache driver for a backend — exactly one, never all three.
629
- *
630
- * Keyed off the *backend* rather than off a dependency, because unlike storage
631
- * the backend already says which driver is right: a project caching in Postgres
632
- * resolves `pg` and never `ioredis`, and one caching in Upstash resolves
633
- * neither. That is the whole reason the drivers live behind separate subpaths.
634
- *
635
- * Both HTTP schemes are registered for Upstash, because the local proxy speaks
636
- * `http://` and Upstash speaks `https://`, and an entry that registered one
637
- * would work in exactly one of the two places.
399
+ * Normalize routes to an array of patterns.
400
+ * Handles string, string[], and PartitionedRoutes (extracts paths).
401
+ * @internal Exported for use in dev command
638
402
  */
639
- const CACHE_DRIVERS = {
640
- upstash: {
641
- imports: `import { registerCacheDriver } from '@geekmidas/cache';\nimport { upstashCacheDriver, upstashInsecureCacheDriver } from '@geekmidas/cache/upstash';`,
642
- setup: "registerCacheDriver(upstashCacheDriver);\nregisterCacheDriver(upstashInsecureCacheDriver);"
643
- },
644
- elasticache: {
645
- imports: `import { registerCacheDriver } from '@geekmidas/cache';\nimport { redisCacheDriver, redissCacheDriver } from '@geekmidas/cache/redis';`,
646
- setup: "registerCacheDriver(redisCacheDriver);\nregisterCacheDriver(redissCacheDriver);"
647
- },
648
- db: {
649
- imports: `import { registerCacheDriver } from '@geekmidas/cache';\nimport { postgresCacheDriver } from '@geekmidas/cache/postgres';`,
650
- setup: "registerCacheDriver(postgresCacheDriver);"
403
+ function normalizeRoutes(routes) {
404
+ if (!routes) return [];
405
+ if (isPartitionedRoutes(routes)) return Array.isArray(routes.paths) ? routes.paths : [routes.paths];
406
+ return Array.isArray(routes) ? routes : [routes];
407
+ }
408
+ //#endregion
409
+ //#region src/exec/index.ts
410
+ const logger$12 = console;
411
+ /** `gkm exec` was given nothing to run. */
412
+ var NoCommandSpecified = class extends Error {
413
+ constructor() {
414
+ super("No command specified. Usage: gkm exec -- <command>");
415
+ this.name = "NoCommandSpecified";
651
416
  }
652
417
  };
653
418
  /**
654
- * Everything a generated entry should register: object storage, and the cache.
419
+ * Run a command with secrets injected into Credentials.
420
+ * Uses Node's --import flag to preload a script that populates Credentials
421
+ * before the command loads any modules that depend on them.
655
422
  *
656
- * Merged here rather than threaded separately, because a generated file has one
657
- * import block and one setup block whatever fills them.
423
+ * @example
424
+ * ```bash
425
+ * gkm exec -- npx @better-auth/cli migrate
426
+ * gkm exec -- npx prisma migrate dev
427
+ * ```
658
428
  */
659
- function driversFor(options) {
660
- const backends = options.cache === false || options.cache === void 0 ? [] : typeof options.cache === "string" ? [options.cache] : options.cache;
661
- const parts = [storageDriversFor(options.appRoot), ...[...new Set(backends)].map((backend) => CACHE_DRIVERS[backend] ?? CACHE_DRIVERS[DEFAULT_CACHE.aws])].filter((part) => part.imports || part.setup);
662
- if (parts.length === 0) return NONE;
429
+ async function execCommand(commandArgs, options = {}) {
430
+ const cwd = options.cwd ?? process.cwd();
431
+ if (!commandArgs[0]) throw new NoCommandSpecified();
432
+ const defaultEnv = loadEnvFiles(".env");
433
+ if (defaultEnv.loaded.length > 0) logger$12.log(`📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
434
+ const { credentials, secretsJsonPath, appName } = await prepareEntryCredentials({
435
+ cwd,
436
+ resolveDockerPorts: "readonly"
437
+ });
438
+ if (appName) logger$12.log(`📦 App: ${appName}`);
439
+ const secretCount = Object.keys(credentials).filter((k) => k !== "PORT").length;
440
+ if (secretCount > 0) logger$12.log(`🔐 Loaded ${secretCount} secret(s)`);
441
+ const preloadDir = join(cwd, ".gkm");
442
+ await mkdir(preloadDir, { recursive: true });
443
+ const preloadPath = join(preloadDir, "credentials-preload.mjs");
444
+ await createCredentialsPreload(preloadPath, secretsJsonPath);
445
+ const [cmd, ...args] = commandArgs;
446
+ logger$12.log(`🚀 Running: ${[cmd, ...args].join(" ")}`);
447
+ const nodeOptions = [(process.env.NODE_OPTIONS ?? "").replace(/--import[= ]\S+/g, "").trim(), `--import=${preloadPath}`].filter(Boolean).join(" ");
448
+ const child = spawn(cmd, args, {
449
+ cwd,
450
+ stdio: "inherit",
451
+ env: {
452
+ ...process.env,
453
+ ...credentials,
454
+ NODE_OPTIONS: nodeOptions
455
+ }
456
+ });
457
+ const exitCode = await new Promise((resolve) => {
458
+ child.on("close", (code) => resolve(code ?? 0));
459
+ child.on("error", (error) => {
460
+ logger$12.error(`Failed to run command: ${error.message}`);
461
+ resolve(1);
462
+ });
463
+ });
464
+ if (exitCode !== 0) process.exit(exitCode);
465
+ }
466
+ //#endregion
467
+ //#region src/dev/index.ts
468
+ const logger$11 = console;
469
+ /**
470
+ * Normalize telescope configuration
471
+ * @internal Exported for testing
472
+ */
473
+ function normalizeTelescopeConfig(config) {
474
+ if (config === false) return;
475
+ if (typeof config === "string") {
476
+ const { path: telescopePath, importPattern: telescopeImportPattern } = parseModuleConfig(config, "telescope");
477
+ return {
478
+ enabled: true,
479
+ telescopePath,
480
+ telescopeImportPattern,
481
+ path: "/__telescope",
482
+ ignore: [],
483
+ recordBody: true,
484
+ maxEntries: 1e3,
485
+ websocket: true
486
+ };
487
+ }
488
+ if (!(config === true || config === void 0 || config.enabled !== false)) return;
489
+ const telescopeConfig = typeof config === "object" ? config : {};
663
490
  return {
664
- imports: parts.map((part) => part.imports).join("\n"),
665
- setup: parts.map((part) => part.setup).join("\n")
491
+ enabled: true,
492
+ path: telescopeConfig.path ?? "/__telescope",
493
+ ignore: telescopeConfig.ignore ?? [],
494
+ recordBody: telescopeConfig.recordBody ?? true,
495
+ maxEntries: telescopeConfig.maxEntries ?? 1e3,
496
+ websocket: telescopeConfig.websocket ?? true
666
497
  };
667
498
  }
668
499
  /**
669
- * Which cache backends an app's entry actually has to speak.
670
- *
671
- * The config answers this for a cache that named nowhere, and the *declaration*
672
- * answers it for one that named a database — `orders.cache('Sessions')` is a
673
- * statement about the application, so a deployment cannot move that cache and
674
- * the entry cannot register a driver for somewhere else.
675
- *
676
- * Reading only the config is how an entry ends up registering the Upstash
677
- * driver for a URL the target composed as `postgres://`, which fails at the
678
- * first request with `UnregisteredCacheScheme` and a stack that points at the
679
- * cache rather than at the config that disagreed.
500
+ * Normalize studio configuration
501
+ * @internal Exported for testing
680
502
  */
681
- function cacheBackendsIn(manifest, configured) {
682
- const caches = Object.values(manifest).filter((d) => d.kind === "cache");
683
- return [...new Set(caches.map((c) => c.of ? "db" : configured))];
503
+ function normalizeStudioConfig(config) {
504
+ if (config === false) return;
505
+ if (typeof config === "string") {
506
+ const { path: studioPath, importPattern: studioImportPattern } = parseModuleConfig(config, "studio");
507
+ return {
508
+ enabled: true,
509
+ studioPath,
510
+ studioImportPattern,
511
+ path: "/__studio",
512
+ schema: "public"
513
+ };
514
+ }
515
+ if (!(config === true || config === void 0 || config.enabled !== false)) return;
516
+ const studioConfig = typeof config === "object" ? config : {};
517
+ return {
518
+ enabled: true,
519
+ path: studioConfig.path ?? "/__studio",
520
+ schema: studioConfig.schema ?? "public"
521
+ };
684
522
  }
685
523
  /**
686
- * The drivers an app's entry points should register.
687
- *
688
- * Keyed off the dependency rather than off a declared bucket, because the entry
689
- * is generated before discovery has run on the watcher's rebuild path — and an
690
- * app that installed `@geekmidas/storage` has already paid for the SDK it would
691
- * otherwise resolve lazily.
524
+ * Normalize hooks configuration
525
+ * @internal Exported for testing
692
526
  */
693
- function storageDriversFor(appRoot) {
694
- return dependsOnStorage(appRoot) ? S3 : NONE;
527
+ function normalizeHooksConfig(config, cwd = process.cwd()) {
528
+ if (!config?.server) return;
529
+ const serverPath = config.server.endsWith(".ts") ? config.server : `${config.server}.ts`;
530
+ return { serverHooksPath: resolve(cwd, serverPath) };
695
531
  }
696
- function dependsOnStorage(appRoot) {
697
- const manifest = join(appRoot, "package.json");
698
- if (!existsSync(manifest)) return false;
699
- try {
700
- const pkg = JSON.parse(readFileSync(manifest, "utf8"));
701
- return Boolean(pkg.dependencies?.["@geekmidas/storage"] ?? pkg.devDependencies?.["@geekmidas/storage"]);
702
- } catch {
703
- return false;
704
- }
532
+ /**
533
+ * Normalize production configuration
534
+ * @internal Exported for testing
535
+ */
536
+ function normalizeProductionConfig(cliProduction, configProduction) {
537
+ if (!cliProduction) return;
538
+ const config = configProduction ?? {};
539
+ return {
540
+ enabled: true,
541
+ bundle: config.bundle ?? true,
542
+ minify: config.minify ?? true,
543
+ healthCheck: config.healthCheck ?? "/health",
544
+ gracefulShutdown: config.gracefulShutdown ?? true,
545
+ external: config.external ?? [],
546
+ subscribers: config.subscribers ?? "exclude",
547
+ openapi: config.openapi ?? false,
548
+ optimizedHandlers: config.optimizedHandlers ?? true
549
+ };
705
550
  }
706
- //#endregion
707
- //#region src/generators/FunctionGenerator.ts
708
- var FunctionGenerator = class extends ConstructGenerator {
709
- isConstruct(value) {
710
- return Function.isFunction(value);
711
- }
712
- async build(context, constructs, outputDir, options) {
713
- const provider = options?.provider || "aws-lambda";
714
- const logger = console;
715
- const functionInfos = [];
716
- if (constructs.length === 0 || provider !== "aws-lambda") return functionInfos;
717
- const functionsDir = join(outputDir, "functions");
718
- await mkdir(functionsDir, { recursive: true });
719
- for (const { key, construct, path } of constructs) {
720
- const handlerFile = await this.generateFunctionHandler(functionsDir, path.relative, key, context, construct.owner);
721
- functionInfos.push({
722
- name: key,
723
- handler: relative(process.cwd(), handlerFile).replace(/\.ts$/, ".handler"),
724
- timeout: construct.timeout,
725
- memorySize: construct.memorySize,
726
- environment: await construct.getEnvironment({ markOptional: context.markOptional }),
727
- dependencies: construct.constructs
551
+ /**
552
+ * Get production config from GkmConfig
553
+ * @internal
554
+ */
555
+ function getProductionConfigFromGkm(config) {
556
+ const serverConfig = config.providers?.server;
557
+ if (typeof serverConfig === "object") return serverConfig.production;
558
+ }
559
+ async function devCommand(options) {
560
+ if (options.entry) return entryDevCommand(options);
561
+ const defaultEnv = loadEnvFiles(".env");
562
+ if (defaultEnv.loaded.length > 0) logger$11.log(`📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
563
+ const appName = getAppNameFromCwd();
564
+ let config;
565
+ let appRoot = process.cwd();
566
+ let secretsRoot = process.cwd();
567
+ let workspaceAppName;
568
+ let workspaceAppPort;
569
+ let workspace;
570
+ if (appName) try {
571
+ const appConfig = await loadAppConfig();
572
+ config = appConfig.gkmConfig;
573
+ appRoot = appConfig.appRoot;
574
+ secretsRoot = appConfig.workspaceRoot;
575
+ workspaceAppName = appConfig.appName;
576
+ workspaceAppPort = appConfig.app.port;
577
+ workspace = appConfig.workspace;
578
+ logger$11.log(`📦 Running app: ${appConfig.appName} on port ${workspaceAppPort}`);
579
+ if (appConfig.app.entry) {
580
+ logger$11.log(`📄 Using entry point: ${appConfig.app.entry}`);
581
+ return entryDevCommand({
582
+ ...options,
583
+ entry: appConfig.app.entry,
584
+ port: workspaceAppPort,
585
+ portExplicit: true
728
586
  });
729
- logger.log(`Generated function handler: ${key}`);
730
587
  }
731
- return functionInfos;
588
+ } catch {
589
+ const loadedConfig = await loadWorkspaceConfig();
590
+ if (loadedConfig.type === "workspace") {
591
+ logger$11.log("📦 Detected workspace configuration");
592
+ return workspaceDevCommand(loadedConfig.workspace, options);
593
+ }
594
+ config = loadedConfig.raw;
732
595
  }
733
- async generateFunctionHandler(outputDir, sourceFile, exportName, context, owner) {
734
- const handlerFileName = `${exportName}.ts`;
735
- const handlerPath = join(outputDir, handlerFileName);
736
- const importPath = relative(dirname(handlerPath), sourceFile).replace(/\.ts$/, ".js");
737
- const runtime = runtimeFor(context, dirname(handlerPath), owner);
738
- const content = `import { AWSLambdaFunction } from '@geekmidas/constructs/aws';
739
- import { ${exportName} } from '${importPath}';
740
- ${runtime.imports}
741
- ${runtime.bindings}
742
-
743
- const adapter = new AWSLambdaFunction(envParser, ${exportName});
744
-
745
- export const handler = adapter.handler;
746
- `;
747
- await writeFile(handlerPath, content);
748
- return handlerPath;
596
+ else {
597
+ const loadedConfig = await loadWorkspaceConfig();
598
+ if (loadedConfig.type === "workspace") {
599
+ logger$11.log("📦 Detected workspace configuration");
600
+ return workspaceDevCommand(loadedConfig.workspace, options);
601
+ }
602
+ config = loadedConfig.raw;
603
+ workspace = loadedConfig.workspace;
749
604
  }
750
- };
751
- //#endregion
752
- //#region src/generators/QueueGenerator.ts
753
- /**
754
- * Generates the runtime for `q` queue workers.
755
- *
756
- * - **server** (`gkm dev`): a single `queues.ts` exposing `setupQueues()` that
757
- * runs an in-process pg-boss poller alongside the Hono server — no SQS/Lambda.
758
- * Each queue subscribes by its **name** on the shared event connection.
759
- * - **aws-lambda**: one handler file per queue wrapping `AWSLambdaQueue`, backed
760
- * by an SQS event-source mapping.
761
- */
762
- var QueueGenerator = class extends ConstructGenerator {
763
- isConstruct(value) {
764
- return Queue.isQueue(value);
605
+ if (config.env) {
606
+ const { loaded, missing } = loadEnvFiles(config.env, appRoot);
607
+ if (loaded.length > 0) logger$11.log(`📦 Loaded env: ${loaded.join(", ")}`);
608
+ if (missing.length > 0) logger$11.warn(`⚠️ Missing env files: ${missing.join(", ")}`);
765
609
  }
766
- async build(context, constructs, outputDir, options) {
767
- const provider = options?.provider || "aws-lambda";
768
- const logger = console;
769
- const queueInfos = [];
770
- if (provider === "server") {
771
- await this.generateServerQueuesFile(outputDir, constructs);
772
- logger.log(`Generated server queues file with ${constructs.length} queues (polling mode)`);
773
- return queueInfos;
610
+ const resolved = resolveProviders(config, { provider: "server" });
611
+ logger$11.log("🚀 Starting development server...");
612
+ logger$11.log(`Loading constructs from: ${config.constructs}`);
613
+ const telescope = normalizeTelescopeConfig(config.telescope);
614
+ if (telescope) logger$11.log(`🔭 Telescope enabled at ${telescope.path}`);
615
+ const studio = normalizeStudioConfig(config.studio);
616
+ if (studio) logger$11.log(`🗄️ Studio enabled at ${studio.path}`);
617
+ const hooks = normalizeHooksConfig(config.hooks, appRoot);
618
+ if (hooks) logger$11.log(`🪝 Server hooks enabled from ${config.hooks?.server}`);
619
+ const enableOpenApi = resolveOpenApiConfig(config).enabled || resolved.enableOpenApi;
620
+ if (enableOpenApi) logger$11.log(`📄 OpenAPI output: ${OPENAPI_OUTPUT_PATH$1}`);
621
+ const build = (bustCache = false) => buildApp({
622
+ config,
623
+ workspaceRoot: workspace?.root ?? secretsRoot,
624
+ appRoot,
625
+ providers: ["server"],
626
+ enableOpenApi,
627
+ cacheBackend: cacheBackendFor(providerOf(workspace ?? config)),
628
+ telescope,
629
+ studio,
630
+ hooks,
631
+ skipBundle: true,
632
+ bustCache,
633
+ serveEmpty: true
634
+ });
635
+ const initial = await build();
636
+ const runtime = config.runtime ?? "node";
637
+ let secretsJsonPath;
638
+ const appSecrets = await loadSecretsForApp(secretsRoot, config.stages.local, workspaceAppName);
639
+ if (workspace && usesConstructs(workspace)) {
640
+ const reconciled = await reconcileWorkspace(workspace, { stage: workspace.stages.local });
641
+ if (reconciled.changed && reconciled.plan.containers.length > 0) {
642
+ logger$11.log(`🐳 Services: ${reconciled.plan.containers.join(", ")}`);
643
+ for (const [container, address] of Object.entries(reconciled.addresses)) logger$11.log(` ${container}: ${address}`);
774
644
  }
775
- if (constructs.length === 0 || provider !== "aws-lambda") return queueInfos;
776
- const queuesDir = join(outputDir, "queues");
777
- await mkdir(queuesDir, { recursive: true });
778
- for (const { key, construct, path } of constructs) {
779
- const handlerFile = await this.generateQueueHandler(queuesDir, path.relative, key, context, construct.owner);
780
- queueInfos.push({
781
- name: construct.name,
782
- handler: relative(process.cwd(), handlerFile).replace(/\.ts$/, ".handler"),
783
- batchSize: construct.batchSize,
784
- fifo: construct.fifo,
785
- timeout: construct.timeout,
786
- environment: await construct.getEnvironment({ markOptional: context.markOptional }),
787
- dependencies: construct.constructs
788
- });
789
- logger.log(`Generated queue handler: ${key}`);
645
+ Object.assign(appSecrets, reconciled.env);
646
+ } else {
647
+ const resolvedPorts = await resolveServicePorts(secretsRoot);
648
+ if (Object.keys(resolvedPorts.ports).length > 0) {
649
+ const rewritten = rewriteUrlsWithPorts(appSecrets, resolvedPorts);
650
+ Object.assign(appSecrets, rewritten);
651
+ logger$11.log(`🔌 Applied ${Object.keys(resolvedPorts.ports).length} port mapping(s)`);
790
652
  }
791
- return queueInfos;
792
653
  }
793
- async generateQueueHandler(outputDir, sourceFile, exportName, context, owner) {
794
- const handlerPath = join(outputDir, `${exportName}.ts`);
795
- const importPath = relative(dirname(handlerPath), sourceFile).replace(/\.ts$/, ".js");
796
- const runtime = runtimeFor(context, dirname(handlerPath), owner);
797
- const content = `import { AWSLambdaQueue } from '@geekmidas/constructs/aws';
798
- import { ${exportName} } from '${importPath}';
799
- ${runtime.imports}
800
- ${runtime.bindings}
801
-
802
- const adapter = new AWSLambdaQueue(envParser, ${exportName});
803
-
804
- export const handler = adapter.handler;
805
- `;
806
- await writeFile(handlerPath, content);
807
- return handlerPath;
654
+ if (workspace && workspaceAppName) {
655
+ const depEnv = getDependencyEnvVars(workspace, workspaceAppName);
656
+ Object.assign(appSecrets, depEnv);
808
657
  }
809
- async generateServerQueuesFile(outputDir, queues) {
810
- await mkdir(outputDir, { recursive: true });
811
- const queuesPath = join(outputDir, "queues.ts");
812
- const importsByFile = /* @__PURE__ */ new Map();
813
- for (const { path, key } of queues) {
814
- const importPath = relative(dirname(queuesPath), path.relative).replace(/\.ts$/, ".js");
815
- if (!importsByFile.has(importPath)) importsByFile.set(importPath, []);
816
- importsByFile.get(importPath)?.push(key);
817
- }
818
- const content = `/**
819
- * Generated queue workers setup
820
- *
821
- * ⚠️ WARNING: This is for LOCAL DEVELOPMENT ONLY
822
- * This runs an in-process pg-boss poller alongside the HTTP server. Each queue
823
- * subscribes by its name on the shared event connection
824
- * (EVENT_SUBSCRIBER_CONNECTION_STRING), so producers using
825
- * <NAME>_PUBLISHER_CONNECTION_STRING (pgboss:// locally) reach it.
826
- *
827
- * For production, queues run as AWS Lambda with SQS event-source mappings.
828
- */
829
- import type { EnvironmentParser } from '@geekmidas/envkit';
830
- import type { Logger } from '@geekmidas/logger';
831
- import { EventConnectionFactory, Subscriber } from '@geekmidas/events';
832
- import type { EventConnection, EventSubscriber } from '@geekmidas/events';
833
- import { ServiceDiscovery } from '@geekmidas/services';
834
- ${Array.from(importsByFile.entries()).map(([importPath, exports]) => `import { ${exports.join(", ")} } from '${importPath}';`).join("\n")}
835
-
836
- const queues = [
837
- ${queues.map(({ key }) => key).join(",\n ")}
838
- ];
839
-
840
- const activeSubscribers: EventSubscriber<any>[] = [];
841
-
842
- export async function setupQueues(
843
- envParser: EnvironmentParser<any>,
844
- logger: Logger,
845
- ): Promise<void> {
846
- if (queues.length === 0) {
847
- return;
848
- }
849
-
850
- logger.info('Setting up queue workers in polling mode (local development)');
851
-
852
- const config = envParser.create((get) => ({
853
- connectionString: get('EVENT_SUBSCRIBER_CONNECTION_STRING').string(),
854
- })).parse();
855
-
856
- const serviceDiscovery = ServiceDiscovery.getInstance(envParser);
857
-
858
- let connection: EventConnection;
859
- try {
860
- connection = await EventConnectionFactory.fromConnectionString(config.connectionString);
861
- const connectionType = new URL(config.connectionString).protocol.replace(':', '');
862
- logger.info({ connectionType }, 'Created shared event connection for queues');
863
- } catch (error) {
864
- logger.error({ error }, 'Failed to create event connection for queues');
865
- return;
866
- }
867
-
868
- for (const queue of queues) {
869
- try {
870
- const eventSubscriber = await Subscriber.fromConnection(connection);
871
-
872
- const services = queue.services.length > 0
873
- ? await serviceDiscovery.register(queue.services)
874
- : {};
875
-
876
- // A queue subscribes to a single "type" — its own name.
877
- await eventSubscriber.subscribe([queue.name], async (message) => {
878
- try {
879
- const validation = await queue.messageSchema['~standard'].validate(message.payload);
880
- if (validation.issues) {
881
- logger.error({ issues: validation.issues, queue: queue.name }, 'Queue message failed validation');
882
- return;
883
- }
884
-
885
- await queue.handler({
886
- messages: [validation.value],
887
- services: services,
888
- logger: queue.logger,
889
- });
890
-
891
- logger.debug({ queue: queue.name }, 'Successfully processed queue message');
892
- } catch (error) {
893
- logger.error({ error, queue: queue.name }, 'Failed to process queue message');
894
- // Message will become visible again for retry.
895
- }
896
- });
897
-
898
- activeSubscribers.push(eventSubscriber);
899
- logger.info({ queue: queue.name }, 'Queue worker started polling');
900
- } catch (error) {
901
- logger.error({ error, queue: queue.name }, 'Failed to setup queue worker');
902
- }
903
- }
904
-
905
- const shutdown = () => {
906
- logger.info('Stopping all queue workers');
907
- for (const _ of activeSubscribers) {
908
- connection.stop();
909
- }
910
- };
911
-
912
- process.on('SIGTERM', shutdown);
913
- process.on('SIGINT', shutdown);
658
+ if (Object.keys(appSecrets).length > 0) {
659
+ const secretsDir = join(secretsRoot, ".gkm");
660
+ await mkdir(secretsDir, { recursive: true });
661
+ const secretsFileName = workspaceAppName ? `dev-secrets-${workspaceAppName}.json` : "dev-secrets.json";
662
+ secretsJsonPath = join(secretsDir, secretsFileName);
663
+ await writeFile(secretsJsonPath, JSON.stringify(appSecrets, null, 2));
664
+ logger$11.log(`🔐 Loaded ${Object.keys(appSecrets).length} secret(s)`);
665
+ }
666
+ const devServer = new DevServer(resolved.providers[0], options.port ?? workspaceAppPort ?? 3e3, options.portExplicit ?? false, enableOpenApi, telescope, studio, runtime, appRoot, secretsJsonPath, initial.selfServing !== void 0);
667
+ await devServer.start();
668
+ const hooksFile = (config.hooks?.server?.split("#"))?.[0];
669
+ const normalizedPatterns = [config.constructs, ...hooksFile ? [hooksFile.endsWith(".ts") ? hooksFile : `${hooksFile}.ts`] : []].flat().filter((p) => typeof p === "string").map((p) => p.startsWith("./") ? p.slice(2) : p);
670
+ logger$11.log(`👀 Watching for changes in: ${normalizedPatterns.join(", ")}`);
671
+ const resolvedFiles = await fg(normalizedPatterns, {
672
+ cwd: appRoot,
673
+ absolute: false,
674
+ onlyFiles: true
675
+ });
676
+ const dirsToWatch = [...new Set(resolvedFiles.map((f) => {
677
+ return f.split("/").slice(0, -1).join("/");
678
+ }))];
679
+ logger$11.log(`📁 Found ${resolvedFiles.length} files in ${dirsToWatch.length} directories`);
680
+ const watcher = chokidar.watch([...resolvedFiles, ...dirsToWatch], {
681
+ ignored: /(^|[/\\])\../,
682
+ persistent: true,
683
+ ignoreInitial: true,
684
+ cwd: appRoot
685
+ });
686
+ watcher.on("ready", () => {
687
+ logger$11.log("🔍 File watcher ready");
688
+ });
689
+ watcher.on("error", (error) => {
690
+ logger$11.error("❌ Watcher error:", error);
691
+ });
692
+ let rebuildTimeout = null;
693
+ watcher.on("change", async (path) => {
694
+ logger$11.log(`📝 File changed: ${path}`);
695
+ if (rebuildTimeout) clearTimeout(rebuildTimeout);
696
+ rebuildTimeout = setTimeout(async () => {
697
+ try {
698
+ logger$11.log("🔄 Rebuilding...");
699
+ const rebuilt = await build(true);
700
+ logger$11.log("✅ Rebuild complete, restarting server...");
701
+ devServer.selfServing = rebuilt.selfServing !== void 0;
702
+ await devServer.restart();
703
+ } catch (error) {
704
+ const err = error;
705
+ logger$11.error(`❌ Rebuild failed: ${err.message}`);
706
+ if (err.stack) logger$11.error(err.stack);
707
+ }
708
+ }, 300);
709
+ });
710
+ let isShuttingDown = false;
711
+ const shutdown = () => {
712
+ if (isShuttingDown) return;
713
+ isShuttingDown = true;
714
+ logger$11.log("\n🛑 Shutting down...");
715
+ Promise.all([watcher.close(), devServer.stop()]).catch((err) => {
716
+ logger$11.error("Error during shutdown:", err);
717
+ }).finally(() => {
718
+ process.exit(0);
719
+ });
720
+ };
721
+ process.on("SIGINT", shutdown);
722
+ process.on("SIGTERM", shutdown);
914
723
  }
915
- `;
916
- await writeFile(queuesPath, content);
917
- return queuesPath;
724
+ /**
725
+ * Generate all dependency environment variables for all apps.
726
+ * Returns a flat object with all {APP_NAME}_URL variables.
727
+ * @internal Exported for testing
728
+ */
729
+ function generateAllDependencyEnvVars(workspace, urlPrefix = "http://localhost") {
730
+ const env = {};
731
+ for (const appName of Object.keys(workspace.apps)) {
732
+ const appEnv = getDependencyEnvVars(workspace, appName, urlPrefix);
733
+ Object.assign(env, appEnv);
734
+ }
735
+ return env;
736
+ }
737
+ /**
738
+ * Check for port conflicts across all apps.
739
+ * Returns list of conflicts if any ports are duplicated.
740
+ * @internal Exported for testing
741
+ */
742
+ function checkPortConflicts(workspace) {
743
+ const conflicts = [];
744
+ const portToApp = /* @__PURE__ */ new Map();
745
+ for (const [appName, app] of Object.entries(workspace.apps)) {
746
+ const existingApp = portToApp.get(app.port);
747
+ if (existingApp) conflicts.push({
748
+ app1: existingApp,
749
+ app2: appName,
750
+ port: app.port
751
+ });
752
+ else portToApp.set(app.port, appName);
753
+ }
754
+ return conflicts;
755
+ }
756
+ const FRONTEND_FRAMEWORKS = {
757
+ nextjs: {
758
+ displayName: "Next.js",
759
+ configFiles: [
760
+ "next.config.js",
761
+ "next.config.ts",
762
+ "next.config.mjs",
763
+ "next.config.cjs"
764
+ ],
765
+ dependency: "next",
766
+ installCommand: "pnpm add next react react-dom"
767
+ },
768
+ remix: {
769
+ displayName: "Remix",
770
+ configFiles: [
771
+ "remix.config.js",
772
+ "remix.config.mjs",
773
+ "remix.config.ts",
774
+ "vite.config.js",
775
+ "vite.config.ts",
776
+ "vite.config.mjs",
777
+ "vite.config.cjs"
778
+ ],
779
+ dependency: "@remix-run/dev",
780
+ installCommand: "pnpm add -D @remix-run/dev"
781
+ },
782
+ vite: {
783
+ displayName: "Vite",
784
+ configFiles: [
785
+ "vite.config.js",
786
+ "vite.config.ts",
787
+ "vite.config.mjs",
788
+ "vite.config.cjs"
789
+ ],
790
+ dependency: "vite",
791
+ installCommand: "pnpm add -D vite"
792
+ },
793
+ "tanstack-start": {
794
+ displayName: "TanStack Start",
795
+ configFiles: [
796
+ "vite.config.js",
797
+ "vite.config.ts",
798
+ "vite.config.mjs",
799
+ "vite.config.cjs"
800
+ ],
801
+ dependency: "@tanstack/react-start",
802
+ installCommand: "pnpm add @tanstack/react-start @tanstack/react-router"
803
+ },
804
+ expo: {
805
+ displayName: "Expo",
806
+ configFiles: [
807
+ "app.config.ts",
808
+ "app.config.js",
809
+ "app.json"
810
+ ],
811
+ dependency: "expo",
812
+ installCommand: "pnpm add expo"
918
813
  }
919
814
  };
920
- //#endregion
921
- //#region src/generators/SubscriberGenerator.ts
922
- var SubscriberGenerator = class extends ConstructGenerator {
923
- isConstruct(value) {
924
- return Subscriber.isSubscriber(value);
815
+ /** Whether a configured framework is one this table knows how to start. */
816
+ function isClientFramework(framework) {
817
+ return framework !== void 0 && framework in FRONTEND_FRAMEWORKS;
818
+ }
819
+ /**
820
+ * Auto-detect the frontend framework by scanning package.json deps and config
821
+ * files. Dependency match wins over config file match because deps are more
822
+ * distinctive (a Remix app has both vite.config.ts AND @remix-run/dev).
823
+ */
824
+ function detectFrontendFramework(fullPath, deps) {
825
+ const order = [
826
+ "expo",
827
+ "tanstack-start",
828
+ "nextjs",
829
+ "remix",
830
+ "vite"
831
+ ];
832
+ for (const name of order) if (deps[FRONTEND_FRAMEWORKS[name].dependency]) return name;
833
+ for (const name of order) if (FRONTEND_FRAMEWORKS[name].configFiles.some((file) => existsSync(join(fullPath, file)))) return name;
834
+ }
835
+ /**
836
+ * Validate a frontend app configuration.
837
+ *
838
+ * If `framework` is provided, validates strictly against that framework's
839
+ * expected config file and dependency. Otherwise auto-detects the framework
840
+ * from package.json and config files. If no recognized framework is found, the
841
+ * app is allowed through with a warning provided it has a `dev` script.
842
+ *
843
+ * @internal Exported for testing
844
+ */
845
+ async function validateFrontendApp(appName, appPath, workspaceRoot, framework) {
846
+ const errors = [];
847
+ const warnings = [];
848
+ const fullPath = join(workspaceRoot, appPath);
849
+ const packageJsonPath = join(fullPath, "package.json");
850
+ if (!existsSync(packageJsonPath)) {
851
+ errors.push(`package.json not found at ${appPath}. Run: pnpm init in the app directory.`);
852
+ return {
853
+ appName,
854
+ valid: false,
855
+ errors,
856
+ warnings
857
+ };
858
+ }
859
+ let pkg;
860
+ try {
861
+ pkg = __require(packageJsonPath);
862
+ } catch {
863
+ errors.push(`Failed to read package.json at ${packageJsonPath}`);
864
+ return {
865
+ appName,
866
+ valid: false,
867
+ errors,
868
+ warnings
869
+ };
870
+ }
871
+ const deps = {
872
+ ...pkg.dependencies,
873
+ ...pkg.devDependencies
874
+ };
875
+ const resolvedFramework = framework ?? detectFrontendFramework(fullPath, deps);
876
+ if (resolvedFramework) {
877
+ const spec = FRONTEND_FRAMEWORKS[resolvedFramework];
878
+ if (!spec.configFiles.some((file) => existsSync(join(fullPath, file)))) errors.push(`${spec.displayName} config file not found. Expected one of: ${spec.configFiles.join(", ")}`);
879
+ if (!deps[spec.dependency]) errors.push(`${spec.displayName} not found in dependencies. Run: ${spec.installCommand}`);
880
+ } else warnings.push("No recognized frontend framework detected (Next.js, Vite, Remix). The app will run via its \"dev\" script. Set `framework` in your app config to enable strict validation.");
881
+ if (!pkg.scripts?.dev) {
882
+ if (resolvedFramework) warnings.push("No \"dev\" script found in package.json. Turbo expects a \"dev\" script to run.");
883
+ else errors.push("No \"dev\" script found in package.json. Without a recognized framework or a dev script, there is nothing to run.");
884
+ }
885
+ return {
886
+ appName,
887
+ valid: errors.length === 0,
888
+ errors,
889
+ warnings
890
+ };
891
+ }
892
+ /**
893
+ * Validate all frontend apps in the workspace.
894
+ * Returns validation results for each frontend app.
895
+ * @internal Exported for testing
896
+ */
897
+ async function validateFrontendApps(workspace) {
898
+ const results = [];
899
+ for (const [appName, app] of Object.entries(workspace.apps)) if (app.type === "web") {
900
+ const result = await validateFrontendApp(appName, app.path, workspace.root, isClientFramework(app.framework) ? app.framework : void 0);
901
+ results.push(result);
902
+ }
903
+ return results;
904
+ }
905
+ /**
906
+ * Load secrets for development stage.
907
+ * Returns env vars to inject, or empty object if secrets not configured/found.
908
+ * @internal Exported for testing
909
+ */
910
+ async function loadDevSecrets(workspace) {
911
+ if (!workspace.secrets.enabled) return {};
912
+ const stage = workspace.stages.local;
913
+ if (secretsExist(stage, workspace.root)) {
914
+ const secrets = await readStageSecrets(stage, workspace.root);
915
+ if (secrets) {
916
+ logger$11.log(`🔐 Loading secrets from stage: ${stage}`);
917
+ return toEmbeddableSecrets(secrets);
918
+ }
919
+ }
920
+ logger$11.warn(`⚠️ Secrets enabled but no "${stage}" secrets found. Run "gkm setup" to initialize the local stage`);
921
+ return {};
922
+ }
923
+ /**
924
+ * Workspace dev command - orchestrates multi-app development using Turbo.
925
+ *
926
+ * Flow:
927
+ * 1. Check for port conflicts
928
+ * 2. Start docker-compose services (db, cache, mail)
929
+ * 3. Generate dependency URLs ({APP_NAME}_URL)
930
+ * 4. Spawn turbo run dev with injected env vars
931
+ */
932
+ async function workspaceDevCommand(workspace, options) {
933
+ const appCount = Object.keys(workspace.apps).length;
934
+ const backendApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "backend");
935
+ const frontendApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "web");
936
+ const mobileApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "mobile");
937
+ logger$11.log(`\n🚀 Starting workspace: ${workspace.name}`);
938
+ const counts = [`${backendApps.length} backend`, `${frontendApps.length} web`];
939
+ if (mobileApps.length > 0) counts.push(`${mobileApps.length} mobile`);
940
+ logger$11.log(` ${counts.join(", ")} app(s)`);
941
+ const conflicts = checkPortConflicts(workspace);
942
+ if (conflicts.length > 0) {
943
+ for (const conflict of conflicts) logger$11.error(`❌ Port conflict: Apps "${conflict.app1}" and "${conflict.app2}" both use port ${conflict.port}`);
944
+ throw new Error("Port conflicts detected. Please assign unique ports to each app.");
925
945
  }
926
- async build(context, constructs, outputDir, options) {
927
- const provider = options?.provider || "aws-lambda";
928
- const logger = console;
929
- const subscriberInfos = [];
930
- if (provider === "server") {
931
- await this.generateServerSubscribersFile(outputDir, constructs);
932
- logger.log(`Generated server subscribers file with ${constructs.length} subscribers (polling mode)`);
933
- return subscriberInfos;
946
+ if (frontendApps.length > 0) {
947
+ logger$11.log("\n🔍 Validating frontend apps...");
948
+ const validationResults = await validateFrontendApps(workspace);
949
+ let hasErrors = false;
950
+ for (const result of validationResults) {
951
+ if (!result.valid) {
952
+ hasErrors = true;
953
+ logger$11.error(`\n❌ Frontend app "${result.appName}" validation failed:`);
954
+ for (const error of result.errors) logger$11.error(` • ${error}`);
955
+ }
956
+ for (const warning of result.warnings) logger$11.warn(` ⚠️ ${result.appName}: ${warning}`);
934
957
  }
935
- if (constructs.length === 0) return subscriberInfos;
936
- if (provider !== "aws-lambda") return subscriberInfos;
937
- const subscribersDir = join(outputDir, "subscribers");
938
- await mkdir(subscribersDir, { recursive: true });
939
- for (const { key, construct, path } of constructs) {
940
- const handlerFile = await this.generateSubscriberHandler(subscribersDir, path.relative, key, construct, context, construct.owner);
941
- subscriberInfos.push({
942
- name: key,
943
- handler: relative(process.cwd(), handlerFile).replace(/\.ts$/, ".handler"),
944
- subscribedEvents: construct.subscribedEvents || [],
945
- ...construct.topicName ? {
946
- transport: "topic",
947
- topic: construct.topicName
948
- } : {},
949
- timeout: construct.timeout,
950
- memorySize: construct.memorySize,
951
- environment: await construct.getEnvironment({ markOptional: context.markOptional }),
952
- dependencies: construct.constructs
953
- });
954
- logger.log(`Generated subscriber handler: ${key}`);
958
+ if (hasErrors) throw new Error("Frontend app validation failed. Fix the issues above and try again.");
959
+ logger$11.log("✅ Frontend apps validated");
960
+ }
961
+ const rawSecrets = await loadDevSecrets(workspace);
962
+ let secretsEnv;
963
+ if (usesConstructs(workspace)) {
964
+ const reconciled = await reconcileWorkspace(workspace, { stage: workspace.stages.local });
965
+ if (reconciled.changed && reconciled.plan.containers.length > 0) {
966
+ logger$11.log(`🐳 Services: ${reconciled.plan.containers.join(", ")}`);
967
+ for (const [container, address] of Object.entries(reconciled.addresses)) logger$11.log(` ${container}: ${address}`);
955
968
  }
956
- return subscriberInfos;
969
+ secretsEnv = {
970
+ ...rawSecrets,
971
+ ...reconciled.env
972
+ };
973
+ } else {
974
+ const resolvedPorts = await resolveServicePorts(workspace.root);
975
+ await startWorkspaceServices(workspace, resolvedPorts.dockerEnv, rawSecrets);
976
+ secretsEnv = rewriteUrlsWithPorts(rawSecrets, resolvedPorts);
957
977
  }
958
- async generateSubscriberHandler(outputDir, sourceFile, exportName, _subscriber, context, owner) {
959
- const handlerFileName = `${exportName}.ts`;
960
- const handlerPath = join(outputDir, handlerFileName);
961
- const importPath = relative(dirname(handlerPath), sourceFile).replace(/\.ts$/, ".js");
962
- const runtime = runtimeFor(context, dirname(handlerPath), owner);
963
- const content = `import { AWSLambdaSubscriber } from '@geekmidas/constructs/aws';
964
- import { ${exportName} } from '${importPath}';
965
- ${runtime.imports}
966
- ${runtime.bindings}
967
-
968
- const adapter = new AWSLambdaSubscriber(envParser, ${exportName});
969
-
970
- export const handler = adapter.handler;
971
- `;
972
- await writeFile(handlerPath, content);
973
- return handlerPath;
978
+ if (Object.keys(secretsEnv).length > 0) logger$11.log(` Loaded ${Object.keys(secretsEnv).length} secret(s)`);
979
+ const dependencyEnv = generateAllDependencyEnvVars(workspace);
980
+ if (Object.keys(dependencyEnv).length > 0) {
981
+ logger$11.log("📡 Dependency URLs:");
982
+ for (const [key, value] of Object.entries(dependencyEnv)) logger$11.log(` ${key}=${value}`);
974
983
  }
975
- async generateServerSubscribersFile(outputDir, subscribers) {
976
- await mkdir(outputDir, { recursive: true });
977
- const subscribersPath = join(outputDir, "subscribers.ts");
978
- const importsByFile = /* @__PURE__ */ new Map();
979
- for (const { path, key } of subscribers) {
980
- const importPath = relative(dirname(subscribersPath), path.relative).replace(/\.ts$/, ".js");
981
- if (!importsByFile.has(importPath)) importsByFile.set(importPath, []);
982
- importsByFile.get(importPath)?.push(key);
984
+ let turboFilter = [];
985
+ if (options.app) {
986
+ if (!workspace.apps[options.app]) {
987
+ const appNames = Object.keys(workspace.apps).join(", ");
988
+ throw new Error(`App "${options.app}" not found. Available apps: ${appNames}`);
983
989
  }
984
- const content = `/**
985
- * Generated subscribers setup
986
- *
987
- * ⚠️ WARNING: This is for LOCAL DEVELOPMENT ONLY
988
- * This uses event polling which is not suitable for production.
989
- *
990
- * For production, use AWS Lambda with SQS/SNS event source mappings.
991
- * Lambda automatically:
992
- * - Scales based on queue depth
993
- * - Handles batch processing and retries
994
- * - Manages dead letter queues
995
- * - Provides better cost optimization
996
- *
997
- * This polling implementation is useful for:
998
- * - Local development and testing
999
- * - Understanding event flow without Lambda deployment
1000
- *
1001
- * Supported connection strings:
1002
- * - sqs://region/account-id/queue-name (SQS queue)
1003
- * - sns://region/account-id/topic-name (SNS topic)
1004
- * - rabbitmq://host:port/queue-name (RabbitMQ)
1005
- * - pgboss://user:pass@host:port/database (pg-boss / PostgreSQL)
1006
- * - basic://in-memory (In-memory for testing)
1007
- */
1008
- import type { EnvironmentParser } from '@geekmidas/envkit';
1009
- import type { Logger } from '@geekmidas/logger';
1010
- import { EventConnectionFactory, Subscriber } from '@geekmidas/events';
1011
- import type { EventConnection, EventSubscriber } from '@geekmidas/events';
1012
- import { ServiceDiscovery } from '@geekmidas/services';
1013
- ${Array.from(importsByFile.entries()).map(([importPath, exports]) => `import { ${exports.join(", ")} } from '${importPath}';`).join("\n")}
1014
-
1015
- const subscribers = [
1016
- ${subscribers.map(({ key }) => key).join(",\n ")}
1017
- ];
1018
-
1019
- const activeSubscribers: EventSubscriber<any>[] = [];
1020
-
1021
- export async function setupSubscribers(
1022
- envParser: EnvironmentParser<any>,
1023
- logger: Logger,
1024
- ): Promise<void> {
1025
- if (subscribers.length === 0) {
1026
- return;
1027
- }
1028
-
1029
- logger.info('Setting up subscribers in polling mode (local development)');
1030
-
1031
- const config = envParser.create((get) => ({
1032
- connectionString: get('EVENT_SUBSCRIBER_CONNECTION_STRING').string(),
1033
- })).parse();
1034
-
1035
- const serviceDiscovery = ServiceDiscovery.getInstance(envParser);
1036
-
1037
- // Create connection once, outside the loop (more efficient)
1038
- // EventConnectionFactory automatically determines the right connection type
1039
- let connection: EventConnection;
1040
- try {
1041
- connection = await EventConnectionFactory.fromConnectionString(config.connectionString);
1042
-
1043
- const connectionType = new URL(config.connectionString).protocol.replace(':', '');
1044
- logger.info({ connectionType }, 'Created shared event connection');
1045
- } catch (error) {
1046
- logger.error({ error }, 'Failed to create event connection');
1047
- return;
1048
- }
1049
-
1050
- for (const subscriber of subscribers) {
1051
- try {
1052
- // Create subscriber from shared connection
1053
- const eventSubscriber = await Subscriber.fromConnection(connection);
1054
-
1055
- // Register services
1056
- const services = subscriber.services.length > 0
1057
- ? await serviceDiscovery.register(subscriber.services)
1058
- : {};
1059
-
1060
- // Subscribe to events
1061
- const subscribedEvents = subscriber.subscribedEvents || [];
1062
-
1063
- if (subscribedEvents.length === 0) {
1064
- logger.warn({ subscriber: subscriber.constructor.name }, 'Subscriber has no subscribed events, skipping');
1065
- continue;
1066
- }
1067
-
1068
- await eventSubscriber.subscribe(subscribedEvents, async (event) => {
1069
- try {
1070
- // Process single event (batch of 1)
1071
- await subscriber.handler({
1072
- events: [event],
1073
- services: services as any,
1074
- logger: subscriber.logger,
1075
- });
1076
-
1077
- logger.debug({ eventType: event.type }, 'Successfully processed event');
1078
- } catch (error) {
1079
- logger.error({ error, event }, 'Failed to process event');
1080
- // Event will become visible again for retry
1081
- }
1082
- });
1083
-
1084
- activeSubscribers.push(eventSubscriber);
1085
-
1086
- logger.info(
1087
- {
1088
- events: subscribedEvents,
1089
- },
1090
- 'Subscriber started polling'
1091
- );
1092
- } catch (error) {
1093
- logger.error({ error, subscriber: subscriber.constructor.name }, 'Failed to setup subscriber');
1094
- }
1095
- }
1096
-
1097
- // Setup graceful shutdown
1098
- const shutdown = () => {
1099
- logger.info('Stopping all subscribers');
1100
- for (const eventSubscriber of activeSubscribers) {
1101
- connection.stop();
1102
- }
1103
- };
1104
-
1105
- process.on('SIGTERM', shutdown);
1106
- process.on('SIGINT', shutdown);
1107
- }
1108
- `;
1109
- await writeFile(subscribersPath, content);
1110
- return subscribersPath;
1111
- }
1112
- };
1113
- //#endregion
1114
- //#region src/generators/TopicGenerator.ts
1115
- /**
1116
- * Discovers `t` topics into `manifest.topics`. A topic is a *resource*, not a
1117
- * function — it has no handler to generate. Producers reach it via its derived
1118
- * publisher (`topic.publisher`, whose connection string is sniffed onto the
1119
- * producing construct) and subscribers bind via `s.topic(topic)`; this generator
1120
- * only records the topic + its event contract so infra can provision the SNS
1121
- * topic. Identical output for every provider (locally pg-boss routes by event
1122
- * name, so there's nothing to run).
1123
- */
1124
- var TopicGenerator = class extends ConstructGenerator {
1125
- isConstruct(value) {
1126
- return Topic.isTopic(value);
990
+ turboFilter = ["--filter", appPackageName(workspace, options.app) ?? options.app];
991
+ logger$11.log(`\n🎯 Running single app: ${options.app}`);
992
+ } else if (options.filter) {
993
+ turboFilter = ["--filter", options.filter];
994
+ logger$11.log(`\n🔍 Using filter: ${options.filter}`);
995
+ } else {
996
+ const { filters, unpackaged } = turboFilters(workspace);
997
+ turboFilter = filters.flatMap((name) => ["--filter", name]);
998
+ if (unpackaged.length > 0) logger$11.warn(`⚠️ No package.json with a name for: ${unpackaged.join(", ")} — turbo cannot run ${unpackaged.length === 1 ? "it" : "them"}.`);
999
+ logger$11.log(`\n🎯 Running all ${appCount} apps`);
1127
1000
  }
1128
- async build(_context, constructs, _outputDir, _options) {
1129
- const logger = console;
1130
- const topicInfos = constructs.map(({ construct }) => ({
1131
- name: construct.name,
1132
- events: construct.eventTypes
1133
- }));
1134
- for (const topic of topicInfos) logger.log(`Discovered topic: ${topic.name}`);
1135
- return topicInfos;
1001
+ const buildOrder = getAppBuildOrder(workspace);
1002
+ logger$11.log("\n📋 Apps (in dependency order):");
1003
+ for (const appName of buildOrder) {
1004
+ const app = workspace.apps[appName];
1005
+ if (!app) continue;
1006
+ const deps = app.dependencies.length > 0 ? ` (depends on: ${app.dependencies.join(", ")})` : "";
1007
+ const icon = app.type === "backend" ? "🔧" : app.type === "mobile" ? "📱" : "🌐";
1008
+ logger$11.log(` ${icon} ${appName} → http://localhost:${app.port}${deps}`);
1136
1009
  }
1137
- };
1138
- //#endregion
1139
- //#region src/workspace/client-generator.ts
1140
- /**
1141
- * Normalize routes to an array of patterns.
1142
- * Handles string, string[], and PartitionedRoutes (extracts paths).
1143
- * @internal Exported for use in dev command
1144
- */
1145
- function normalizeRoutes(routes) {
1146
- if (!routes) return [];
1147
- if (isPartitionedRoutes(routes)) return Array.isArray(routes.paths) ? routes.paths : [routes.paths];
1148
- return Array.isArray(routes) ? routes : [routes];
1149
- }
1150
- //#endregion
1151
- //#region src/exec/index.ts
1152
- const logger$12 = console;
1153
- /** `gkm exec` was given nothing to run. */
1154
- var NoCommandSpecified = class extends Error {
1155
- constructor() {
1156
- super("No command specified. Usage: gkm exec -- <command>");
1157
- this.name = "NoCommandSpecified";
1010
+ const configFiles = [
1011
+ "gkm.config.ts",
1012
+ "gkm.config.js",
1013
+ "gkm.config.json"
1014
+ ];
1015
+ let configPath = "";
1016
+ for (const file of configFiles) {
1017
+ const fullPath = join(workspace.root, file);
1018
+ if (existsSync(fullPath)) {
1019
+ configPath = fullPath;
1020
+ break;
1021
+ }
1158
1022
  }
1159
- };
1160
- /**
1161
- * Run a command with secrets injected into Credentials.
1162
- * Uses Node's --import flag to preload a script that populates Credentials
1163
- * before the command loads any modules that depend on them.
1164
- *
1165
- * @example
1166
- * ```bash
1167
- * gkm exec -- npx @better-auth/cli migrate
1168
- * gkm exec -- npx prisma migrate dev
1169
- * ```
1170
- */
1171
- async function execCommand(commandArgs, options = {}) {
1172
- const cwd = options.cwd ?? process.cwd();
1173
- if (!commandArgs[0]) throw new NoCommandSpecified();
1174
- const defaultEnv = loadEnvFiles(".env");
1175
- if (defaultEnv.loaded.length > 0) logger$12.log(`📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
1176
- const { credentials, secretsJsonPath, appName } = await prepareEntryCredentials({
1177
- cwd,
1178
- resolveDockerPorts: "readonly"
1179
- });
1180
- if (appName) logger$12.log(`📦 App: ${appName}`);
1181
- const secretCount = Object.keys(credentials).filter((k) => k !== "PORT").length;
1182
- if (secretCount > 0) logger$12.log(`🔐 Loaded ${secretCount} secret(s)`);
1183
- const preloadDir = join(cwd, ".gkm");
1184
- await mkdir(preloadDir, { recursive: true });
1185
- const preloadPath = join(preloadDir, "credentials-preload.mjs");
1186
- await createCredentialsPreload(preloadPath, secretsJsonPath);
1187
- const [cmd, ...args] = commandArgs;
1188
- logger$12.log(`🚀 Running: ${[cmd, ...args].join(" ")}`);
1189
- const nodeOptions = [(process.env.NODE_OPTIONS ?? "").replace(/--import[= ]\S+/g, "").trim(), `--import=${preloadPath}`].filter(Boolean).join(" ");
1190
- const child = spawn(cmd, args, {
1191
- cwd,
1023
+ const turboEnv = {
1024
+ ...process.env,
1025
+ ...secretsEnv,
1026
+ ...dependencyEnv,
1027
+ NODE_ENV: "development",
1028
+ ...configPath ? { GKM_CONFIG_PATH: configPath } : {}
1029
+ };
1030
+ logger$11.log("\n🏃 Starting turbo run dev...\n");
1031
+ const turboProcess = spawn("pnpm", [
1032
+ "turbo",
1033
+ "run",
1034
+ "dev",
1035
+ ...turboFilter
1036
+ ], {
1037
+ cwd: workspace.root,
1192
1038
  stdio: "inherit",
1193
- env: {
1194
- ...process.env,
1195
- ...credentials,
1196
- NODE_OPTIONS: nodeOptions
1197
- }
1039
+ env: turboEnv,
1040
+ detached: true
1198
1041
  });
1199
- const exitCode = await new Promise((resolve) => {
1200
- child.on("close", (code) => resolve(code ?? 0));
1201
- child.on("error", (error) => {
1202
- logger$12.error(`Failed to run command: ${error.message}`);
1203
- resolve(1);
1042
+ let isShuttingDown = false;
1043
+ const shutdown = () => {
1044
+ if (isShuttingDown) return;
1045
+ isShuttingDown = true;
1046
+ logger$11.log("\n🛑 Shutting down workspace...");
1047
+ const pid = turboProcess.pid;
1048
+ if (pid) try {
1049
+ process.kill(-pid, "SIGTERM");
1050
+ } catch {
1051
+ try {
1052
+ process.kill(pid, "SIGTERM");
1053
+ } catch {}
1054
+ }
1055
+ setTimeout(() => {
1056
+ if (pid) try {
1057
+ process.kill(-pid, "SIGKILL");
1058
+ } catch {
1059
+ try {
1060
+ process.kill(pid, "SIGKILL");
1061
+ } catch {}
1062
+ }
1063
+ process.exit(0);
1064
+ }, 3e3);
1065
+ };
1066
+ process.on("SIGINT", shutdown);
1067
+ process.on("SIGTERM", shutdown);
1068
+ return new Promise((resolve, reject) => {
1069
+ turboProcess.on("error", (error) => {
1070
+ logger$11.error("❌ Turbo error:", error);
1071
+ reject(error);
1072
+ });
1073
+ turboProcess.on("exit", (code) => {
1074
+ if (code !== null && code !== 0) reject(/* @__PURE__ */ new Error(`Turbo exited with code ${code}`));
1075
+ else resolve();
1204
1076
  });
1205
1077
  });
1206
- if (exitCode !== 0) process.exit(exitCode);
1207
1078
  }
1208
- //#endregion
1209
- //#region src/dev/index.ts
1210
- const logger$11 = console;
1211
1079
  /**
1212
- * Normalize telescope configuration
1213
- * @internal Exported for testing
1080
+ * Run any TypeScript file with secret injection.
1081
+ * Does not require gkm.config.ts.
1214
1082
  */
1215
- function normalizeTelescopeConfig(config) {
1216
- if (config === false) return;
1217
- if (typeof config === "string") {
1218
- const { path: telescopePath, importPattern: telescopeImportPattern } = parseModuleConfig(config, "telescope");
1219
- return {
1220
- enabled: true,
1221
- telescopePath,
1222
- telescopeImportPattern,
1223
- path: "/__telescope",
1224
- ignore: [],
1225
- recordBody: true,
1226
- maxEntries: 1e3,
1227
- websocket: true
1228
- };
1229
- }
1230
- if (!(config === true || config === void 0 || config.enabled !== false)) return;
1231
- const telescopeConfig = typeof config === "object" ? config : {};
1232
- return {
1233
- enabled: true,
1234
- path: telescopeConfig.path ?? "/__telescope",
1235
- ignore: telescopeConfig.ignore ?? [],
1236
- recordBody: telescopeConfig.recordBody ?? true,
1237
- maxEntries: telescopeConfig.maxEntries ?? 1e3,
1238
- websocket: telescopeConfig.websocket ?? true
1083
+ async function entryDevCommand(options) {
1084
+ const { entry, watch = true } = options;
1085
+ if (!entry) throw new Error("--entry requires a file path");
1086
+ const entryPath = resolve(process.cwd(), entry);
1087
+ if (!existsSync(entryPath)) throw new Error(`Entry file not found: ${entryPath}`);
1088
+ const defaultEnv = loadEnvFiles(".env");
1089
+ if (defaultEnv.loaded.length > 0) logger$11.log(`📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
1090
+ const { credentials, resolvedPort, secretsJsonPath, appName } = await prepareEntryCredentials({ explicitPort: options.portExplicit ? options.port : void 0 });
1091
+ if (appName) logger$11.log(`📦 App: ${appName} (port ${resolvedPort})`);
1092
+ logger$11.log(`🚀 Starting entry file: ${entry} on port ${resolvedPort}`);
1093
+ if (Object.keys(credentials).length > 1) logger$11.log(`🔐 Loaded ${Object.keys(credentials).length - 1} secret(s) + PORT`);
1094
+ const wrapperDir = join(process.cwd(), ".gkm");
1095
+ await mkdir(wrapperDir, { recursive: true });
1096
+ const wrapperPath = join(wrapperDir, "entry-wrapper.ts");
1097
+ await createEntryWrapper(wrapperPath, entryPath, secretsJsonPath);
1098
+ const runner = new EntryRunner(wrapperPath, entryPath, watch, resolvedPort);
1099
+ await runner.start();
1100
+ let isShuttingDown = false;
1101
+ const shutdown = () => {
1102
+ if (isShuttingDown) return;
1103
+ isShuttingDown = true;
1104
+ logger$11.log("\n🛑 Shutting down...");
1105
+ runner.stop();
1106
+ process.exit(0);
1239
1107
  };
1108
+ process.on("SIGINT", shutdown);
1109
+ process.on("SIGTERM", shutdown);
1110
+ await new Promise(() => {});
1240
1111
  }
1241
1112
  /**
1242
- * Normalize studio configuration
1243
- * @internal Exported for testing
1113
+ * Runs and watches a TypeScript entry file using tsx.
1244
1114
  */
1245
- function normalizeStudioConfig(config) {
1246
- if (config === false) return;
1247
- if (typeof config === "string") {
1248
- const { path: studioPath, importPattern: studioImportPattern } = parseModuleConfig(config, "studio");
1249
- return {
1250
- enabled: true,
1251
- studioPath,
1252
- studioImportPattern,
1253
- path: "/__studio",
1254
- schema: "public"
1115
+ var EntryRunner = class {
1116
+ wrapperPath;
1117
+ entryPath;
1118
+ watch;
1119
+ port;
1120
+ childProcess = null;
1121
+ watcher = null;
1122
+ isRunning = false;
1123
+ constructor(wrapperPath, entryPath, watch, port) {
1124
+ this.wrapperPath = wrapperPath;
1125
+ this.entryPath = entryPath;
1126
+ this.watch = watch;
1127
+ this.port = port;
1128
+ }
1129
+ async start() {
1130
+ await this.runProcess();
1131
+ if (this.watch) {
1132
+ const watchDir = dirname(this.entryPath);
1133
+ this.watcher = chokidar.watch(watchDir, {
1134
+ ignored: /(^|[/\\])\../,
1135
+ persistent: true,
1136
+ ignoreInitial: true
1137
+ });
1138
+ let restartTimeout = null;
1139
+ this.watcher.on("change", (path) => {
1140
+ logger$11.log(`📝 File changed: ${path}`);
1141
+ if (restartTimeout) clearTimeout(restartTimeout);
1142
+ restartTimeout = setTimeout(async () => {
1143
+ logger$11.log("🔄 Restarting...");
1144
+ await this.restart();
1145
+ }, 300);
1146
+ });
1147
+ logger$11.log(`👀 Watching for changes in: ${watchDir}`);
1148
+ }
1149
+ }
1150
+ async runProcess() {
1151
+ const env = {
1152
+ ...process.env,
1153
+ PORT: String(this.port)
1255
1154
  };
1155
+ this.childProcess = spawn("npx", ["tsx", this.wrapperPath], {
1156
+ stdio: "inherit",
1157
+ env,
1158
+ detached: true
1159
+ });
1160
+ this.isRunning = true;
1161
+ this.childProcess.on("error", (error) => {
1162
+ logger$11.error("❌ Process error:", error);
1163
+ });
1164
+ this.childProcess.on("exit", (code) => {
1165
+ if (code !== null && code !== 0 && code !== 143) logger$11.error(`❌ Process exited with code ${code}`);
1166
+ this.isRunning = false;
1167
+ });
1168
+ await new Promise((resolve) => setTimeout(resolve, 500));
1169
+ if (this.isRunning) {
1170
+ logger$11.log("");
1171
+ logger$11.log(` \x1b[32m✓ Ready\x1b[0m at \x1b[36mhttp://localhost:${this.port}\x1b[0m`);
1172
+ logger$11.log("");
1173
+ }
1256
1174
  }
1257
- if (!(config === true || config === void 0 || config.enabled !== false)) return;
1258
- const studioConfig = typeof config === "object" ? config : {};
1259
- return {
1260
- enabled: true,
1261
- path: studioConfig.path ?? "/__studio",
1262
- schema: studioConfig.schema ?? "public"
1263
- };
1264
- }
1175
+ async restart() {
1176
+ this.stopProcess();
1177
+ await new Promise((resolve) => setTimeout(resolve, 500));
1178
+ await this.runProcess();
1179
+ }
1180
+ stop() {
1181
+ this.watcher?.close();
1182
+ this.stopProcess();
1183
+ }
1184
+ stopProcess() {
1185
+ if (this.childProcess && this.isRunning) {
1186
+ const pid = this.childProcess.pid;
1187
+ if (pid) try {
1188
+ process.kill(-pid, "SIGTERM");
1189
+ } catch {
1190
+ try {
1191
+ process.kill(pid, "SIGTERM");
1192
+ } catch {}
1193
+ }
1194
+ this.childProcess = null;
1195
+ this.isRunning = false;
1196
+ }
1197
+ }
1198
+ };
1265
1199
  /**
1266
- * Normalize hooks configuration
1200
+ * Generate the content of the dev server entry file (server.ts).
1201
+ * Uses dynamic import for createApp so Credentials are populated
1202
+ * before any app modules evaluate.
1267
1203
  * @internal Exported for testing
1268
1204
  */
1269
- function normalizeHooksConfig(config, cwd = process.cwd()) {
1270
- if (!config?.server) return;
1271
- const serverPath = config.server.endsWith(".ts") ? config.server : `${config.server}.ts`;
1272
- return { serverHooksPath: resolve(cwd, serverPath) };
1205
+ function generateServerEntryContent(options) {
1206
+ const { secretsJsonPath, runtime = "node", enableOpenApi = false, appImportPath = "./app.js", selfServing = false } = options;
1207
+ const credentialsInjection = secretsJsonPath ? `import { existsSync, readFileSync } from 'node:fs';
1208
+
1209
+ // Inject dev secrets via globalThis (must happen before app import)
1210
+ const secretsPath = '${secretsJsonPath}';
1211
+ if (existsSync(secretsPath)) {
1212
+ const __secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
1213
+ globalThis.__gkm_credentials__ = __secrets;
1214
+ Object.assign(process.env, __secrets);
1273
1215
  }
1216
+
1217
+ ` : "";
1218
+ const serveCode = runtime === "bun" ? `Bun.serve({
1219
+ port,
1220
+ fetch: app.fetch,
1221
+ });` : `const { serve } = await import('@hono/node-server');
1222
+ const server = serve({
1223
+ fetch: app.fetch,
1224
+ port,
1225
+ });
1226
+ // Inject WebSocket support if available
1227
+ const injectWs = (app as any).__injectWebSocket;
1228
+ if (injectWs) {
1229
+ injectWs(server);
1230
+ console.log('🔌 Telescope real-time updates enabled');
1231
+ }`;
1232
+ if (selfServing) return `#!/usr/bin/env node
1274
1233
  /**
1275
- * Normalize production configuration
1276
- * @internal Exported for testing
1277
- */
1278
- function normalizeProductionConfig(cliProduction, configProduction) {
1279
- if (!cliProduction) return;
1280
- const config = configProduction ?? {};
1281
- return {
1282
- enabled: true,
1283
- bundle: config.bundle ?? true,
1284
- minify: config.minify ?? true,
1285
- healthCheck: config.healthCheck ?? "/health",
1286
- gracefulShutdown: config.gracefulShutdown ?? true,
1287
- external: config.external ?? [],
1288
- subscribers: config.subscribers ?? "exclude",
1289
- openapi: config.openapi ?? false,
1290
- optimizedHandlers: config.optimizedHandlers ?? true
1291
- };
1292
- }
1234
+ * Development server entry point for a surface that serves itself
1235
+ * This file is auto-generated by 'gkm dev'
1236
+ */
1237
+ ${credentialsInjection}
1238
+ const port = process.argv.includes('--port')
1239
+ ? Number.parseInt(process.argv[process.argv.indexOf('--port') + 1])
1240
+ : 3000;
1241
+
1242
+ // Dynamic import so Credentials are populated before the surface evaluates
1243
+ const { app } = await import('${appImportPath}');
1244
+
1245
+ ${serveCode.replace(/^ {4}/gm, "")}
1246
+ console.log(\`Server started on port \${port}\`);
1247
+ `;
1248
+ return `#!/usr/bin/env node
1293
1249
  /**
1294
- * Get production config from GkmConfig
1295
- * @internal
1296
- */
1297
- function getProductionConfigFromGkm(config) {
1298
- const serverConfig = config.providers?.server;
1299
- if (typeof serverConfig === "object") return serverConfig.production;
1250
+ * Development server entry point
1251
+ * This file is auto-generated by 'gkm dev'
1252
+ */
1253
+ ${credentialsInjection}
1254
+ const port = process.argv.includes('--port')
1255
+ ? Number.parseInt(process.argv[process.argv.indexOf('--port') + 1])
1256
+ : 3000;
1257
+
1258
+ // Dynamic import so Credentials are populated before env.ts evaluates
1259
+ const { createApp } = await import('${appImportPath}');
1260
+
1261
+ // createApp is async to support optional WebSocket setup
1262
+ const { app, start } = await createApp(undefined, ${enableOpenApi});
1263
+
1264
+ // Start the server
1265
+ start({
1266
+ port,
1267
+ serve: async (app, port) => {
1268
+ ${serveCode}
1269
+ },
1270
+ }).catch((error) => {
1271
+ console.error('Failed to start server:', error);
1272
+ process.exit(1);
1273
+ });
1274
+ `;
1300
1275
  }
1301
- async function devCommand(options) {
1302
- if (options.entry) return entryDevCommand(options);
1303
- const defaultEnv = loadEnvFiles(".env");
1304
- if (defaultEnv.loaded.length > 0) logger$11.log(`📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
1305
- const appName = getAppNameFromCwd();
1306
- let config;
1307
- let appRoot = process.cwd();
1308
- let secretsRoot = process.cwd();
1309
- let workspaceAppName;
1310
- let workspaceAppPort;
1311
- let workspace;
1312
- if (appName) try {
1313
- const appConfig = await loadAppConfig();
1314
- config = appConfig.gkmConfig;
1315
- appRoot = appConfig.appRoot;
1316
- secretsRoot = appConfig.workspaceRoot;
1317
- workspaceAppName = appConfig.appName;
1318
- workspaceAppPort = appConfig.app.port;
1319
- workspace = appConfig.workspace;
1320
- logger$11.log(`📦 Running app: ${appConfig.appName} on port ${workspaceAppPort}`);
1321
- if (appConfig.app.entry) {
1322
- logger$11.log(`📄 Using entry point: ${appConfig.app.entry}`);
1323
- return entryDevCommand({
1324
- ...options,
1325
- entry: appConfig.app.entry,
1326
- port: workspaceAppPort,
1327
- portExplicit: true
1328
- });
1276
+ var DevServer = class {
1277
+ provider;
1278
+ requestedPort;
1279
+ portExplicit;
1280
+ enableOpenApi;
1281
+ telescope;
1282
+ studio;
1283
+ runtime;
1284
+ appRoot;
1285
+ secretsJsonPath;
1286
+ selfServing;
1287
+ serverProcess = null;
1288
+ isRunning = false;
1289
+ actualPort;
1290
+ startTime = Date.now();
1291
+ constructor(provider, requestedPort, portExplicit, enableOpenApi, telescope, studio, runtime = "node", appRoot = process.cwd(), secretsJsonPath, selfServing = false) {
1292
+ this.provider = provider;
1293
+ this.requestedPort = requestedPort;
1294
+ this.portExplicit = portExplicit;
1295
+ this.enableOpenApi = enableOpenApi;
1296
+ this.telescope = telescope;
1297
+ this.studio = studio;
1298
+ this.runtime = runtime;
1299
+ this.appRoot = appRoot;
1300
+ this.secretsJsonPath = secretsJsonPath;
1301
+ this.selfServing = selfServing;
1302
+ this.actualPort = requestedPort;
1303
+ }
1304
+ async start() {
1305
+ this.startTime = Date.now();
1306
+ if (this.isRunning) await this.stop();
1307
+ if (this.portExplicit) {
1308
+ if (!await isPortAvailable(this.requestedPort)) throw new Error(`Port ${this.requestedPort} is already in use. Either stop the process using that port or omit -p/--port to auto-select an available port.`);
1309
+ this.actualPort = this.requestedPort;
1310
+ } else {
1311
+ this.actualPort = await findAvailablePort(this.requestedPort);
1312
+ if (this.actualPort !== this.requestedPort) logger$11.log(`ℹ️ Port ${this.requestedPort} was in use, using port ${this.actualPort} instead`);
1329
1313
  }
1330
- } catch {
1331
- const loadedConfig = await loadWorkspaceConfig();
1332
- if (loadedConfig.type === "workspace") {
1333
- logger$11.log("📦 Detected workspace configuration");
1334
- return workspaceDevCommand(loadedConfig.workspace, options);
1314
+ const serverEntryPath = join(this.appRoot, ".gkm", this.provider, "server.ts");
1315
+ await this.createServerEntry();
1316
+ logger$11.log(`\n⏳ Starting server...`);
1317
+ this.serverProcess = spawn("npx", [
1318
+ "tsx",
1319
+ serverEntryPath,
1320
+ "--port",
1321
+ this.actualPort.toString()
1322
+ ], {
1323
+ stdio: "inherit",
1324
+ env: {
1325
+ ...process.env,
1326
+ NODE_ENV: "development"
1327
+ },
1328
+ detached: true
1329
+ });
1330
+ this.isRunning = true;
1331
+ this.serverProcess.on("error", (error) => {
1332
+ logger$11.error("❌ Server error:", error);
1333
+ });
1334
+ this.serverProcess.on("exit", (code, signal) => {
1335
+ if (code !== null && code !== 0 && signal !== "SIGTERM") logger$11.error(`❌ Server exited with code ${code}`);
1336
+ this.isRunning = false;
1337
+ });
1338
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
1339
+ if (this.isRunning) {
1340
+ const base = `http://localhost:${this.actualPort}`;
1341
+ const lines = [` Local: ${base}`];
1342
+ if (this.enableOpenApi) lines.push(` API Docs: ${base}/__docs`);
1343
+ if (this.telescope) lines.push(` Telescope: ${base}${this.telescope.path}`);
1344
+ if (this.studio) lines.push(` Studio: ${base}${this.studio.path}`);
1345
+ const maxLen = Math.max(...lines.map((l) => l.length));
1346
+ const pad = (s) => s.padEnd(maxLen);
1347
+ const border = "─".repeat(maxLen + 2);
1348
+ logger$11.log("");
1349
+ logger$11.log(` \x1b[32m✓ Ready\x1b[0m in ${((Date.now() - this.startTime) / 1e3).toFixed(1)}s`);
1350
+ logger$11.log("");
1351
+ logger$11.log(` ┌${border}┐`);
1352
+ for (const line of lines) logger$11.log(` │ ${pad(line)} │`);
1353
+ logger$11.log(` └${border}┘`);
1354
+ logger$11.log("");
1355
+ }
1356
+ }
1357
+ async stop() {
1358
+ const port = this.actualPort;
1359
+ if (this.serverProcess && this.isRunning) {
1360
+ const pid = this.serverProcess.pid;
1361
+ if (pid) try {
1362
+ process.kill(-pid, "SIGKILL");
1363
+ } catch {
1364
+ try {
1365
+ process.kill(pid, "SIGKILL");
1366
+ } catch {}
1367
+ }
1368
+ this.serverProcess = null;
1369
+ this.isRunning = false;
1335
1370
  }
1336
- config = loadedConfig.raw;
1371
+ this.killProcessesOnPort(port);
1337
1372
  }
1338
- else {
1339
- const loadedConfig = await loadWorkspaceConfig();
1340
- if (loadedConfig.type === "workspace") {
1341
- logger$11.log("📦 Detected workspace configuration");
1342
- return workspaceDevCommand(loadedConfig.workspace, options);
1373
+ killProcessesOnPort(port) {
1374
+ try {
1375
+ execSync(`lsof -ti tcp:${port} -sTCP:LISTEN | xargs kill -9 2>/dev/null || true`, { stdio: "ignore" });
1376
+ } catch {}
1377
+ }
1378
+ async restart() {
1379
+ const portToReuse = this.actualPort;
1380
+ await this.stop();
1381
+ let attempts = 0;
1382
+ while (attempts < 30) {
1383
+ if (await isPortAvailable(portToReuse)) break;
1384
+ await new Promise((resolve) => setTimeout(resolve, 100));
1385
+ attempts++;
1343
1386
  }
1344
- config = loadedConfig.raw;
1345
- workspace = loadedConfig.workspace;
1387
+ this.requestedPort = portToReuse;
1388
+ await this.start();
1346
1389
  }
1347
- if (config.env) {
1348
- const { loaded, missing } = loadEnvFiles(config.env, appRoot);
1349
- if (loaded.length > 0) logger$11.log(`📦 Loaded env: ${loaded.join(", ")}`);
1350
- if (missing.length > 0) logger$11.warn(`⚠️ Missing env files: ${missing.join(", ")}`);
1390
+ async createServerEntry() {
1391
+ const { writeFile: fsWriteFile } = await import("node:fs/promises");
1392
+ await fsWriteFile(join(this.appRoot, ".gkm", this.provider, "server.ts"), generateServerEntryContent({
1393
+ secretsJsonPath: this.secretsJsonPath,
1394
+ runtime: this.runtime,
1395
+ enableOpenApi: this.enableOpenApi,
1396
+ selfServing: this.selfServing
1397
+ }));
1351
1398
  }
1352
- const resolved = resolveProviders(config, { provider: "server" });
1353
- logger$11.log("🚀 Starting development server...");
1354
- logger$11.log(`Loading constructs from: ${config.constructs}`);
1355
- const telescope = normalizeTelescopeConfig(config.telescope);
1356
- if (telescope) logger$11.log(`🔭 Telescope enabled at ${telescope.path}`);
1357
- const studio = normalizeStudioConfig(config.studio);
1358
- if (studio) logger$11.log(`🗄️ Studio enabled at ${studio.path}`);
1359
- const hooks = normalizeHooksConfig(config.hooks, appRoot);
1360
- if (hooks) logger$11.log(`🪝 Server hooks enabled from ${config.hooks?.server}`);
1361
- const enableOpenApi = resolveOpenApiConfig(config).enabled || resolved.enableOpenApi;
1362
- if (enableOpenApi) logger$11.log(`📄 OpenAPI output: ${OPENAPI_OUTPUT_PATH$1}`);
1363
- const buildContext = {
1364
- telescope,
1365
- studio,
1366
- hooks
1367
- };
1368
- await buildServer(config, buildContext, resolved.providers[0], enableOpenApi, appRoot);
1369
- if (enableOpenApi) await generateOpenApiFrom(config.constructs, { openapi: config.openapi });
1370
- const runtime = config.runtime ?? "node";
1371
- let secretsJsonPath;
1372
- const appSecrets = await loadSecretsForApp(secretsRoot, config.stages.local, workspaceAppName);
1373
- if (workspace && usesConstructs(workspace)) {
1374
- const reconciled = await reconcileWorkspace(workspace, { stage: workspace.stages.local });
1375
- if (reconciled.changed && reconciled.plan.containers.length > 0) {
1376
- logger$11.log(`🐳 Services: ${reconciled.plan.containers.join(", ")}`);
1377
- for (const [container, address] of Object.entries(reconciled.addresses)) logger$11.log(` ${container}: ${address}`);
1399
+ };
1400
+ //#endregion
1401
+ //#region src/generators/CronGenerator.ts
1402
+ var CronGenerator = class extends ConstructGenerator {
1403
+ async build(context, constructs, outputDir, options) {
1404
+ const provider = options?.provider || "aws-lambda";
1405
+ const logger = console;
1406
+ const cronInfos = [];
1407
+ if (provider === "server") {
1408
+ await this.generateServerCronsFile(outputDir, constructs);
1409
+ logger.log(`Generated server crons file with ${constructs.length} crons`);
1410
+ return cronInfos;
1378
1411
  }
1379
- Object.assign(appSecrets, reconciled.env);
1380
- } else {
1381
- const resolvedPorts = await resolveServicePorts(secretsRoot);
1382
- if (Object.keys(resolvedPorts.ports).length > 0) {
1383
- const rewritten = rewriteUrlsWithPorts(appSecrets, resolvedPorts);
1384
- Object.assign(appSecrets, rewritten);
1385
- logger$11.log(`🔌 Applied ${Object.keys(resolvedPorts.ports).length} port mapping(s)`);
1412
+ if (constructs.length === 0 || provider !== "aws-lambda") return cronInfos;
1413
+ const cronsDir = join(outputDir, "crons");
1414
+ await mkdir(cronsDir, { recursive: true });
1415
+ for (const { key, construct, path } of constructs) {
1416
+ const handlerFile = await this.generateCronHandler(cronsDir, path.relative, key, context, construct.owner);
1417
+ cronInfos.push({
1418
+ name: key,
1419
+ handler: relative(process.cwd(), handlerFile).replace(/\.ts$/, ".handler"),
1420
+ schedule: construct.schedule || "rate(1 hour)",
1421
+ timeout: construct.timeout,
1422
+ memorySize: construct.memorySize,
1423
+ environment: await construct.getEnvironment({ markOptional: context.markOptional }),
1424
+ dependencies: construct.constructs
1425
+ });
1426
+ logger.log(`Generated cron handler: ${key}`);
1386
1427
  }
1428
+ return cronInfos;
1387
1429
  }
1388
- if (workspace && workspaceAppName) {
1389
- const depEnv = getDependencyEnvVars(workspace, workspaceAppName);
1390
- Object.assign(appSecrets, depEnv);
1391
- }
1392
- if (Object.keys(appSecrets).length > 0) {
1393
- const secretsDir = join(secretsRoot, ".gkm");
1394
- await mkdir(secretsDir, { recursive: true });
1395
- const secretsFileName = workspaceAppName ? `dev-secrets-${workspaceAppName}.json` : "dev-secrets.json";
1396
- secretsJsonPath = join(secretsDir, secretsFileName);
1397
- await writeFile(secretsJsonPath, JSON.stringify(appSecrets, null, 2));
1398
- logger$11.log(`🔐 Loaded ${Object.keys(appSecrets).length} secret(s)`);
1430
+ isConstruct(value) {
1431
+ return Cron.isCron(value);
1399
1432
  }
1400
- const devServer = new DevServer(resolved.providers[0], options.port ?? workspaceAppPort ?? 3e3, options.portExplicit ?? false, enableOpenApi, telescope, studio, runtime, appRoot, secretsJsonPath);
1401
- await devServer.start();
1402
- const hooksFile = (config.hooks?.server?.split("#"))?.[0];
1403
- const normalizedPatterns = [config.constructs, ...hooksFile ? [hooksFile.endsWith(".ts") ? hooksFile : `${hooksFile}.ts`] : []].flat().filter((p) => typeof p === "string").map((p) => p.startsWith("./") ? p.slice(2) : p);
1404
- logger$11.log(`👀 Watching for changes in: ${normalizedPatterns.join(", ")}`);
1405
- const resolvedFiles = await fg(normalizedPatterns, {
1406
- cwd: appRoot,
1407
- absolute: false,
1408
- onlyFiles: true
1409
- });
1410
- const dirsToWatch = [...new Set(resolvedFiles.map((f) => {
1411
- return f.split("/").slice(0, -1).join("/");
1412
- }))];
1413
- logger$11.log(`📁 Found ${resolvedFiles.length} files in ${dirsToWatch.length} directories`);
1414
- const watcher = chokidar.watch([...resolvedFiles, ...dirsToWatch], {
1415
- ignored: /(^|[/\\])\../,
1416
- persistent: true,
1417
- ignoreInitial: true,
1418
- cwd: appRoot
1419
- });
1420
- watcher.on("ready", () => {
1421
- logger$11.log("🔍 File watcher ready");
1422
- });
1423
- watcher.on("error", (error) => {
1424
- logger$11.error("❌ Watcher error:", error);
1425
- });
1426
- let rebuildTimeout = null;
1427
- watcher.on("change", async (path) => {
1428
- logger$11.log(`📝 File changed: ${path}`);
1429
- if (rebuildTimeout) clearTimeout(rebuildTimeout);
1430
- rebuildTimeout = setTimeout(async () => {
1431
- try {
1432
- logger$11.log("🔄 Rebuilding...");
1433
- await buildServer(config, buildContext, resolved.providers[0], enableOpenApi, appRoot, true);
1434
- if (enableOpenApi) await generateOpenApiFrom(config.constructs, {
1435
- openapi: config.openapi,
1436
- silent: true,
1437
- bustCache: true
1438
- });
1439
- logger$11.log("✅ Rebuild complete, restarting server...");
1440
- await devServer.restart();
1441
- } catch (error) {
1442
- const err = error;
1443
- logger$11.error(`❌ Rebuild failed: ${err.message}`);
1444
- if (err.stack) logger$11.error(err.stack);
1445
- }
1446
- }, 300);
1447
- });
1448
- let isShuttingDown = false;
1449
- const shutdown = () => {
1450
- if (isShuttingDown) return;
1451
- isShuttingDown = true;
1452
- logger$11.log("\n🛑 Shutting down...");
1453
- Promise.all([watcher.close(), devServer.stop()]).catch((err) => {
1454
- logger$11.error("Error during shutdown:", err);
1455
- }).finally(() => {
1456
- process.exit(0);
1457
- });
1458
- };
1459
- process.on("SIGINT", shutdown);
1460
- process.on("SIGTERM", shutdown);
1433
+ /**
1434
+ * The crons file a server entry imports.
1435
+ *
1436
+ * Shaped after `generateServerSubscribersFile`: one module, every cron, a
1437
+ * `setupCrons` the generated entry calls beside `setupSubscribers`.
1438
+ *
1439
+ * It picks its scheduler from what the process can reach. With a Postgres it
1440
+ * uses pg-boss, whose schedules live in the database — so a deployment
1441
+ * running four replicas fires each job once, which is the thing a timer in
1442
+ * every process gets wrong and does not report. With no database it falls
1443
+ * back to timers and says plainly that it is doing so.
1444
+ */
1445
+ async generateServerCronsFile(outputDir, crons) {
1446
+ await mkdir(outputDir, { recursive: true });
1447
+ const cronsPath = join(outputDir, "crons.ts");
1448
+ const importsByFile = /* @__PURE__ */ new Map();
1449
+ for (const { path, key } of crons) {
1450
+ const importPath = relative(dirname(cronsPath), path.relative).replace(/\.ts$/, ".js");
1451
+ if (!importsByFile.has(importPath)) importsByFile.set(importPath, []);
1452
+ importsByFile.get(importPath)?.push(key);
1453
+ }
1454
+ const content = `/**
1455
+ * Generated cron setup.
1456
+ *
1457
+ * Schedules every cron this app declares, in the process that serves it.
1458
+ *
1459
+ * The schedule lives in Postgres, through pg-boss, in the database the worker
1460
+ * named with \`.database(db)\`. That is what makes running several replicas
1461
+ * safe: each job fires once, where a timer in every process fires it once per
1462
+ * replica and reports nothing.
1463
+ *
1464
+ * No connection string appears here. The construct that owns the database is
1465
+ * the only thing that knows its key, and it is resolved through service
1466
+ * discovery like any other dependency.
1467
+ */
1468
+ import type { EnvironmentParser } from '@geekmidas/envkit';
1469
+ import type { Logger } from '@geekmidas/logger';
1470
+ import { toCronExpression } from '@geekmidas/constructs/crons';
1471
+ import { ServiceDiscovery } from '@geekmidas/services';
1472
+ ${Array.from(importsByFile.entries()).map(([importPath, exports]) => `import { ${exports.join(", ")} } from '${importPath}';`).join("\n")}
1473
+
1474
+ const crons = [
1475
+ ${crons.map(({ key }) => ` { name: '${key}', cron: ${key} }`).join(",\n")}
1476
+ ];
1477
+
1478
+ type Stoppable = { stop: () => Promise<void> | void };
1479
+ const running: Stoppable[] = [];
1480
+
1481
+ export async function setupCrons(
1482
+ envParser: EnvironmentParser<any>,
1483
+ logger: Logger,
1484
+ ): Promise<void> {
1485
+ if (crons.length === 0) {
1486
+ return;
1487
+ }
1488
+
1489
+ const serviceDiscovery = ServiceDiscovery.getInstance(envParser);
1490
+
1491
+ // Resolved once per cron rather than per firing: registering services on
1492
+ // every tick would reconnect a database every minute.
1493
+ const prepared = [];
1494
+ for (const { name, cron } of crons) {
1495
+ try {
1496
+ const schedule = toCronExpression(cron.schedule);
1497
+ const services = cron.services.length > 0
1498
+ ? await serviceDiscovery.register(cron.services)
1499
+ : {};
1500
+
1501
+ prepared.push({ name, cron, schedule, services });
1502
+ } catch (error) {
1503
+ // An unrepresentable schedule is a build-time mistake, and failing the
1504
+ // process for it would take the HTTP server down with it.
1505
+ logger.error({ error, cron: name }, 'Cron has no runnable schedule, skipping');
1506
+ }
1507
+ }
1508
+
1509
+ const run = async (entry: (typeof prepared)[number]) => {
1510
+ try {
1511
+ await entry.cron.handler({
1512
+ input: undefined,
1513
+ services: entry.services,
1514
+ logger: entry.cron.logger,
1515
+ });
1516
+ } catch (error) {
1517
+ logger.error({ error, cron: entry.name }, 'Cron failed');
1518
+ }
1519
+ };
1520
+
1521
+ // The app's one pg-boss: the events broker's, as the broker's role, in the
1522
+ // broker's schema. A server fires its own crons, and a process scheduling in
1523
+ // memory fires each job once per replica — so the schedule lives in
1524
+ // Postgres. It used to be a second pg-boss on the worker's database, as the
1525
+ // runtime role, which can neither create a schema nor use the broker's.
1526
+ const { url } = envParser
1527
+ .create((get) => ({
1528
+ url: get('EVENT_SUBSCRIBER_CONNECTION_STRING').string().optional(),
1529
+ }))
1530
+ .parse();
1531
+
1532
+ if (!url?.startsWith('pgboss://')) {
1533
+ logger.error(
1534
+ { crons: prepared.map(({ name }) => name) },
1535
+ 'These crons have nowhere to keep their schedule. On a server they are ' +
1536
+ 'scheduled through the pg-boss events broker ' +
1537
+ '(EVENT_SUBSCRIBER_CONNECTION_STRING), and ' +
1538
+ (url ? 'the broker configured is not pg-boss.' : 'none is configured.'),
1539
+ );
1540
+ return;
1541
+ }
1542
+
1543
+ const { PgBossConnection } = await import('@geekmidas/events/pgboss');
1544
+ const connection = await PgBossConnection.fromConnectionString(url);
1545
+ const boss = connection.instance!;
1546
+
1547
+ // What this app declares now. Anything else under the prefix belonged to a
1548
+ // cron since renamed or deleted, and would keep firing at a handler that has
1549
+ // gone.
1550
+ //
1551
+ // The prefix is \`cron.\` rather than \`cron:\`: pg-boss accepts
1552
+ // alphanumerics, underscores, hyphens, periods and slashes in a queue name,
1553
+ // and rejects a colon outright.
1554
+ const declared = new Set(prepared.map(({ name }) => \`cron.\${name}\`));
1555
+ for (const existing of await boss.getSchedules()) {
1556
+ if (existing.name.startsWith('cron.') && !declared.has(existing.name)) {
1557
+ await boss.unschedule(existing.name);
1558
+ logger.info({ cron: existing.name }, 'Removed a schedule nothing declares');
1559
+ }
1560
+ }
1561
+
1562
+ for (const entry of prepared) {
1563
+ const queue = \`cron.\${entry.name}\`;
1564
+ await boss.createQueue(queue);
1565
+ // UTC, so this agrees with the schedule a deploy target would have made.
1566
+ await boss.schedule(queue, entry.schedule, undefined, { tz: 'UTC' });
1567
+ await boss.work(queue, () => run(entry));
1568
+
1569
+ logger.info({ cron: entry.name, schedule: entry.schedule }, 'Cron scheduled');
1570
+ }
1571
+
1572
+ running.push({ stop: () => connection.close() });
1573
+ logger.info({ count: prepared.length }, 'Crons scheduled');
1461
1574
  }
1462
- /**
1463
- * Generate all dependency environment variables for all apps.
1464
- * Returns a flat object with all {APP_NAME}_URL variables.
1465
- * @internal Exported for testing
1466
- */
1467
- function generateAllDependencyEnvVars(workspace, urlPrefix = "http://localhost") {
1468
- const env = {};
1469
- for (const appName of Object.keys(workspace.apps)) {
1470
- const appEnv = getDependencyEnvVars(workspace, appName, urlPrefix);
1471
- Object.assign(env, appEnv);
1472
- }
1473
- return env;
1575
+
1576
+ export async function stopCrons(): Promise<void> {
1577
+ await Promise.all(running.map((r) => r.stop()));
1578
+ running.length = 0;
1474
1579
  }
1475
- /**
1476
- * Check for port conflicts across all apps.
1477
- * Returns list of conflicts if any ports are duplicated.
1478
- * @internal Exported for testing
1479
- */
1480
- function checkPortConflicts(workspace) {
1481
- const conflicts = [];
1482
- const portToApp = /* @__PURE__ */ new Map();
1483
- for (const [appName, app] of Object.entries(workspace.apps)) {
1484
- const existingApp = portToApp.get(app.port);
1485
- if (existingApp) conflicts.push({
1486
- app1: existingApp,
1487
- app2: appName,
1488
- port: app.port
1489
- });
1490
- else portToApp.set(app.port, appName);
1580
+ `;
1581
+ await writeFile(cronsPath, content);
1582
+ return cronsPath;
1491
1583
  }
1492
- return conflicts;
1493
- }
1494
- const FRONTEND_FRAMEWORKS = {
1495
- nextjs: {
1496
- displayName: "Next.js",
1497
- configFiles: [
1498
- "next.config.js",
1499
- "next.config.ts",
1500
- "next.config.mjs",
1501
- "next.config.cjs"
1502
- ],
1503
- dependency: "next",
1504
- installCommand: "pnpm add next react react-dom"
1505
- },
1506
- remix: {
1507
- displayName: "Remix",
1508
- configFiles: [
1509
- "remix.config.js",
1510
- "remix.config.mjs",
1511
- "remix.config.ts",
1512
- "vite.config.js",
1513
- "vite.config.ts",
1514
- "vite.config.mjs",
1515
- "vite.config.cjs"
1516
- ],
1517
- dependency: "@remix-run/dev",
1518
- installCommand: "pnpm add -D @remix-run/dev"
1519
- },
1520
- vite: {
1521
- displayName: "Vite",
1522
- configFiles: [
1523
- "vite.config.js",
1524
- "vite.config.ts",
1525
- "vite.config.mjs",
1526
- "vite.config.cjs"
1527
- ],
1528
- dependency: "vite",
1529
- installCommand: "pnpm add -D vite"
1530
- },
1531
- "tanstack-start": {
1532
- displayName: "TanStack Start",
1533
- configFiles: [
1534
- "vite.config.js",
1535
- "vite.config.ts",
1536
- "vite.config.mjs",
1537
- "vite.config.cjs"
1538
- ],
1539
- dependency: "@tanstack/react-start",
1540
- installCommand: "pnpm add @tanstack/react-start @tanstack/react-router"
1541
- },
1542
- expo: {
1543
- displayName: "Expo",
1544
- configFiles: [
1545
- "app.config.ts",
1546
- "app.config.js",
1547
- "app.json"
1548
- ],
1549
- dependency: "expo",
1550
- installCommand: "pnpm add expo"
1584
+ async generateCronHandler(outputDir, sourceFile, exportName, context, owner) {
1585
+ const handlerFileName = `${exportName}.ts`;
1586
+ const handlerPath = join(outputDir, handlerFileName);
1587
+ const importPath = relative(dirname(handlerPath), sourceFile).replace(/\.ts$/, ".js");
1588
+ const runtime = runtimeFor(context, dirname(handlerPath), owner);
1589
+ const content = `import { AWSScheduledFunction } from '@geekmidas/constructs/aws';
1590
+ import { ${exportName} } from '${importPath}';
1591
+ ${runtime.imports}
1592
+ ${runtime.bindings}
1593
+
1594
+ const adapter = new AWSScheduledFunction(envParser, ${exportName});
1595
+
1596
+ export const handler = adapter.handler;
1597
+ `;
1598
+ await writeFile(handlerPath, content);
1599
+ return handlerPath;
1551
1600
  }
1552
1601
  };
1553
- /** Whether a configured framework is one this table knows how to start. */
1554
- function isClientFramework(framework) {
1555
- return framework !== void 0 && framework in FRONTEND_FRAMEWORKS;
1556
- }
1557
- /**
1558
- * Auto-detect the frontend framework by scanning package.json deps and config
1559
- * files. Dependency match wins over config file match because deps are more
1560
- * distinctive (a Remix app has both vite.config.ts AND @remix-run/dev).
1561
- */
1562
- function detectFrontendFramework(fullPath, deps) {
1563
- const order = [
1564
- "expo",
1565
- "tanstack-start",
1566
- "nextjs",
1567
- "remix",
1568
- "vite"
1569
- ];
1570
- for (const name of order) if (deps[FRONTEND_FRAMEWORKS[name].dependency]) return name;
1571
- for (const name of order) if (FRONTEND_FRAMEWORKS[name].configFiles.some((file) => existsSync(join(fullPath, file)))) return name;
1572
- }
1602
+ //#endregion
1603
+ //#region src/generators/drivers.ts
1573
1604
  /**
1574
- * Validate a frontend app configuration.
1605
+ * Driver registration for generated entry points.
1575
1606
  *
1576
- * If `framework` is provided, validates strictly against that framework's
1577
- * expected config file and dependency. Otherwise auto-detects the framework
1578
- * from package.json and config files. If no recognized framework is found, the
1579
- * app is allowed through with a warning provided it has a `dev` script.
1607
+ * A construct declares that it needs object storage and is handed one URL; the
1608
+ * scheme in that URL picks the driver that builds the client. Which drivers
1609
+ * exist is therefore the *entry point's* decision, not the construct's and not
1610
+ * the application's — an app that never talks to S3 should never resolve the AWS
1611
+ * SDK, and application code that registers a driver has named a provider in the
1612
+ * one layer this design keeps provider-free.
1580
1613
  *
1581
- * @internal Exported for testing
1582
- */
1583
- async function validateFrontendApp(appName, appPath, workspaceRoot, framework) {
1584
- const errors = [];
1585
- const warnings = [];
1586
- const fullPath = join(workspaceRoot, appPath);
1587
- const packageJsonPath = join(fullPath, "package.json");
1588
- if (!existsSync(packageJsonPath)) {
1589
- errors.push(`package.json not found at ${appPath}. Run: pnpm init in the app directory.`);
1590
- return {
1591
- appName,
1592
- valid: false,
1593
- errors,
1594
- warnings
1595
- };
1596
- }
1597
- let pkg;
1598
- try {
1599
- pkg = __require(packageJsonPath);
1600
- } catch {
1601
- errors.push(`Failed to read package.json at ${packageJsonPath}`);
1602
- return {
1603
- appName,
1604
- valid: false,
1605
- errors,
1606
- warnings
1607
- };
1608
- }
1609
- const deps = {
1610
- ...pkg.dependencies,
1611
- ...pkg.devDependencies
1612
- };
1613
- const resolvedFramework = framework ?? detectFrontendFramework(fullPath, deps);
1614
- if (resolvedFramework) {
1615
- const spec = FRONTEND_FRAMEWORKS[resolvedFramework];
1616
- if (!spec.configFiles.some((file) => existsSync(join(fullPath, file)))) errors.push(`${spec.displayName} config file not found. Expected one of: ${spec.configFiles.join(", ")}`);
1617
- if (!deps[spec.dependency]) errors.push(`${spec.displayName} not found in dependencies. Run: ${spec.installCommand}`);
1618
- } else warnings.push("No recognized frontend framework detected (Next.js, Vite, Remix). The app will run via its \"dev\" script. Set `framework` in your app config to enable strict validation.");
1619
- if (!pkg.scripts?.dev) {
1620
- if (resolvedFramework) warnings.push("No \"dev\" script found in package.json. Turbo expects a \"dev\" script to run.");
1621
- else errors.push("No \"dev\" script found in package.json. Without a recognized framework or a dev script, there is nothing to run.");
1622
- }
1623
- return {
1624
- appName,
1625
- valid: errors.length === 0,
1626
- errors,
1627
- warnings
1628
- };
1629
- }
1630
- /**
1631
- * Validate all frontend apps in the workspace.
1632
- * Returns validation results for each frontend app.
1633
- * @internal Exported for testing
1614
+ * So the generated entries register: `gkm dev`'s server for local development,
1615
+ * and each Lambda handler for its own target. Registration is an explicit call
1616
+ * rather than an import side effect, because a side-effecting module is exactly
1617
+ * what a bundler is entitled to drop.
1634
1618
  */
1635
- async function validateFrontendApps(workspace) {
1636
- const results = [];
1637
- for (const [appName, app] of Object.entries(workspace.apps)) if (app.type === "web") {
1638
- const result = await validateFrontendApp(appName, app.path, workspace.root, isClientFramework(app.framework) ? app.framework : void 0);
1639
- results.push(result);
1640
- }
1641
- return results;
1642
- }
1619
+ /** The `s3://` driver, which serves MinIO locally and S3 deployed. */
1620
+ const S3 = {
1621
+ imports: `import { registerStorageDriver } from '@geekmidas/storage';\nimport { s3Driver } from '@geekmidas/storage/aws';`,
1622
+ setup: "registerStorageDriver(s3Driver);"
1623
+ };
1624
+ const NONE = {
1625
+ imports: "",
1626
+ setup: ""
1627
+ };
1643
1628
  /**
1644
- * Load secrets for development stage.
1645
- * Returns env vars to inject, or empty object if secrets not configured/found.
1646
- * @internal Exported for testing
1629
+ * The cache driver for a backend — exactly one, never all three.
1630
+ *
1631
+ * Keyed off the *backend* rather than off a dependency, because unlike storage
1632
+ * the backend already says which driver is right: a project caching in Postgres
1633
+ * resolves `pg` and never `ioredis`, and one caching in Upstash resolves
1634
+ * neither. That is the whole reason the drivers live behind separate subpaths.
1635
+ *
1636
+ * Both HTTP schemes are registered for Upstash, because the local proxy speaks
1637
+ * `http://` and Upstash speaks `https://`, and an entry that registered one
1638
+ * would work in exactly one of the two places.
1647
1639
  */
1648
- async function loadDevSecrets(workspace) {
1649
- if (!workspace.secrets.enabled) return {};
1650
- const stage = workspace.stages.local;
1651
- if (secretsExist(stage, workspace.root)) {
1652
- const secrets = await readStageSecrets(stage, workspace.root);
1653
- if (secrets) {
1654
- logger$11.log(`🔐 Loading secrets from stage: ${stage}`);
1655
- return toEmbeddableSecrets(secrets);
1656
- }
1640
+ const CACHE_DRIVERS = {
1641
+ upstash: {
1642
+ imports: `import { registerCacheDriver } from '@geekmidas/cache';\nimport { upstashCacheDriver, upstashInsecureCacheDriver } from '@geekmidas/cache/upstash';`,
1643
+ setup: "registerCacheDriver(upstashCacheDriver);\nregisterCacheDriver(upstashInsecureCacheDriver);"
1644
+ },
1645
+ elasticache: {
1646
+ imports: `import { registerCacheDriver } from '@geekmidas/cache';\nimport { redisCacheDriver, redissCacheDriver } from '@geekmidas/cache/redis';`,
1647
+ setup: "registerCacheDriver(redisCacheDriver);\nregisterCacheDriver(redissCacheDriver);"
1648
+ },
1649
+ db: {
1650
+ imports: `import { registerCacheDriver } from '@geekmidas/cache';\nimport { postgresCacheDriver } from '@geekmidas/cache/postgres';`,
1651
+ setup: "registerCacheDriver(postgresCacheDriver);"
1657
1652
  }
1658
- logger$11.warn(`⚠️ Secrets enabled but no "${stage}" secrets found. Run "gkm setup" to initialize the local stage`);
1659
- return {};
1660
- }
1653
+ };
1661
1654
  /**
1662
- * Workspace dev command - orchestrates multi-app development using Turbo.
1655
+ * Everything a generated entry should register: object storage, and the cache.
1663
1656
  *
1664
- * Flow:
1665
- * 1. Check for port conflicts
1666
- * 2. Start docker-compose services (db, cache, mail)
1667
- * 3. Generate dependency URLs ({APP_NAME}_URL)
1668
- * 4. Spawn turbo run dev with injected env vars
1657
+ * Merged here rather than threaded separately, because a generated file has one
1658
+ * import block and one setup block whatever fills them.
1669
1659
  */
1670
- async function workspaceDevCommand(workspace, options) {
1671
- const appCount = Object.keys(workspace.apps).length;
1672
- const backendApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "backend");
1673
- const frontendApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "web");
1674
- const mobileApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "mobile");
1675
- logger$11.log(`\n🚀 Starting workspace: ${workspace.name}`);
1676
- const counts = [`${backendApps.length} backend`, `${frontendApps.length} web`];
1677
- if (mobileApps.length > 0) counts.push(`${mobileApps.length} mobile`);
1678
- logger$11.log(` ${counts.join(", ")} app(s)`);
1679
- const conflicts = checkPortConflicts(workspace);
1680
- if (conflicts.length > 0) {
1681
- for (const conflict of conflicts) logger$11.error(`❌ Port conflict: Apps "${conflict.app1}" and "${conflict.app2}" both use port ${conflict.port}`);
1682
- throw new Error("Port conflicts detected. Please assign unique ports to each app.");
1683
- }
1684
- if (frontendApps.length > 0) {
1685
- logger$11.log("\n🔍 Validating frontend apps...");
1686
- const validationResults = await validateFrontendApps(workspace);
1687
- let hasErrors = false;
1688
- for (const result of validationResults) {
1689
- if (!result.valid) {
1690
- hasErrors = true;
1691
- logger$11.error(`\n❌ Frontend app "${result.appName}" validation failed:`);
1692
- for (const error of result.errors) logger$11.error(` • ${error}`);
1693
- }
1694
- for (const warning of result.warnings) logger$11.warn(` ⚠️ ${result.appName}: ${warning}`);
1695
- }
1696
- if (hasErrors) throw new Error("Frontend app validation failed. Fix the issues above and try again.");
1697
- logger$11.log("✅ Frontend apps validated");
1698
- }
1699
- const rawSecrets = await loadDevSecrets(workspace);
1700
- let secretsEnv;
1701
- if (usesConstructs(workspace)) {
1702
- const reconciled = await reconcileWorkspace(workspace, { stage: workspace.stages.local });
1703
- if (reconciled.changed && reconciled.plan.containers.length > 0) {
1704
- logger$11.log(`🐳 Services: ${reconciled.plan.containers.join(", ")}`);
1705
- for (const [container, address] of Object.entries(reconciled.addresses)) logger$11.log(` ${container}: ${address}`);
1706
- }
1707
- secretsEnv = {
1708
- ...rawSecrets,
1709
- ...reconciled.env
1710
- };
1711
- } else {
1712
- const resolvedPorts = await resolveServicePorts(workspace.root);
1713
- await startWorkspaceServices(workspace, resolvedPorts.dockerEnv, rawSecrets);
1714
- secretsEnv = rewriteUrlsWithPorts(rawSecrets, resolvedPorts);
1715
- }
1716
- if (Object.keys(secretsEnv).length > 0) logger$11.log(` Loaded ${Object.keys(secretsEnv).length} secret(s)`);
1717
- const dependencyEnv = generateAllDependencyEnvVars(workspace);
1718
- if (Object.keys(dependencyEnv).length > 0) {
1719
- logger$11.log("📡 Dependency URLs:");
1720
- for (const [key, value] of Object.entries(dependencyEnv)) logger$11.log(` ${key}=${value}`);
1721
- }
1722
- let turboFilter = [];
1723
- if (options.app) {
1724
- if (!workspace.apps[options.app]) {
1725
- const appNames = Object.keys(workspace.apps).join(", ");
1726
- throw new Error(`App "${options.app}" not found. Available apps: ${appNames}`);
1727
- }
1728
- turboFilter = ["--filter", options.app];
1729
- logger$11.log(`\n🎯 Running single app: ${options.app}`);
1730
- } else if (options.filter) {
1731
- turboFilter = ["--filter", options.filter];
1732
- logger$11.log(`\n🔍 Using filter: ${options.filter}`);
1733
- } else logger$11.log(`\n🎯 Running all ${appCount} apps`);
1734
- const buildOrder = getAppBuildOrder(workspace);
1735
- logger$11.log("\n📋 Apps (in dependency order):");
1736
- for (const appName of buildOrder) {
1737
- const app = workspace.apps[appName];
1738
- if (!app) continue;
1739
- const deps = app.dependencies.length > 0 ? ` (depends on: ${app.dependencies.join(", ")})` : "";
1740
- const icon = app.type === "backend" ? "🔧" : app.type === "mobile" ? "📱" : "🌐";
1741
- logger$11.log(` ${icon} ${appName} → http://localhost:${app.port}${deps}`);
1742
- }
1743
- const configFiles = [
1744
- "gkm.config.ts",
1745
- "gkm.config.js",
1746
- "gkm.config.json"
1747
- ];
1748
- let configPath = "";
1749
- for (const file of configFiles) {
1750
- const fullPath = join(workspace.root, file);
1751
- if (existsSync(fullPath)) {
1752
- configPath = fullPath;
1753
- break;
1754
- }
1755
- }
1756
- const turboEnv = {
1757
- ...process.env,
1758
- ...secretsEnv,
1759
- ...dependencyEnv,
1760
- NODE_ENV: "development",
1761
- ...configPath ? { GKM_CONFIG_PATH: configPath } : {}
1762
- };
1763
- logger$11.log("\n🏃 Starting turbo run dev...\n");
1764
- const turboProcess = spawn("pnpm", [
1765
- "turbo",
1766
- "run",
1767
- "dev",
1768
- ...turboFilter
1769
- ], {
1770
- cwd: workspace.root,
1771
- stdio: "inherit",
1772
- env: turboEnv,
1773
- detached: true
1774
- });
1775
- let isShuttingDown = false;
1776
- const shutdown = () => {
1777
- if (isShuttingDown) return;
1778
- isShuttingDown = true;
1779
- logger$11.log("\n🛑 Shutting down workspace...");
1780
- const pid = turboProcess.pid;
1781
- if (pid) try {
1782
- process.kill(-pid, "SIGTERM");
1783
- } catch {
1784
- try {
1785
- process.kill(pid, "SIGTERM");
1786
- } catch {}
1787
- }
1788
- setTimeout(() => {
1789
- if (pid) try {
1790
- process.kill(-pid, "SIGKILL");
1791
- } catch {
1792
- try {
1793
- process.kill(pid, "SIGKILL");
1794
- } catch {}
1795
- }
1796
- process.exit(0);
1797
- }, 3e3);
1660
+ function driversFor(options) {
1661
+ const backends = options.cache === false || options.cache === void 0 ? [] : typeof options.cache === "string" ? [options.cache] : options.cache;
1662
+ const parts = [storageDriversFor(options.appRoot), ...[...new Set(backends)].map((backend) => CACHE_DRIVERS[backend] ?? CACHE_DRIVERS[DEFAULT_CACHE.aws])].filter((part) => part.imports || part.setup);
1663
+ if (parts.length === 0) return NONE;
1664
+ return {
1665
+ imports: parts.map((part) => part.imports).join("\n"),
1666
+ setup: parts.map((part) => part.setup).join("\n")
1798
1667
  };
1799
- process.on("SIGINT", shutdown);
1800
- process.on("SIGTERM", shutdown);
1801
- return new Promise((resolve, reject) => {
1802
- turboProcess.on("error", (error) => {
1803
- logger$11.error("❌ Turbo error:", error);
1804
- reject(error);
1805
- });
1806
- turboProcess.on("exit", (code) => {
1807
- if (code !== null && code !== 0) reject(/* @__PURE__ */ new Error(`Turbo exited with code ${code}`));
1808
- else resolve();
1809
- });
1810
- });
1811
1668
  }
1812
1669
  /**
1813
- * What the app declares, or nothing when it declares in the old way.
1670
+ * Which cache backends an app's entry actually has to speak.
1671
+ *
1672
+ * The config answers this for a cache that named nowhere, and the *declaration*
1673
+ * answers it for one that named a database — `orders.cache('Sessions')` is a
1674
+ * statement about the application, so a deployment cannot move that cache and
1675
+ * the entry cannot register a driver for somewhere else.
1814
1676
  *
1815
- * Discovery imports the application's construct modules, which is why this is
1816
- * guarded rather than unconditional: a project with no `constructs` glob has
1817
- * not adopted the model, and there is nothing to import.
1677
+ * Reading only the config is how an entry ends up registering the Upstash
1678
+ * driver for a URL the target composed as `postgres://`, which fails at the
1679
+ * first request with `UnregisteredCacheScheme` and a stack that points at the
1680
+ * cache rather than at the config that disagreed.
1818
1681
  */
1819
- async function declaredConstructs(config, appRoot) {
1820
- const patterns = typeof config?.constructs === "string" || Array.isArray(config?.constructs) ? config.constructs : void 0;
1821
- if (!patterns) return {};
1822
- try {
1823
- return await discover({
1824
- patterns: (Array.isArray(patterns) ? patterns : [patterns]).map((p) => isAbsolute(p) ? p : join(appRoot, p)),
1825
- cwd: appRoot
1826
- });
1827
- } catch {
1828
- return {};
1829
- }
1830
- }
1831
- async function buildServer(config, context, provider, enableOpenApi, appRoot = process.cwd(), bustCache = false) {
1832
- context = {
1833
- ...context,
1834
- storageDrivers: driversFor({
1835
- appRoot,
1836
- cache: cacheBackendsIn(await declaredConstructs(config, appRoot), cacheBackendFor(providerOf(config ?? {})))
1837
- })
1838
- };
1839
- const endpointGenerator = new EndpointGenerator();
1840
- const functionGenerator = new FunctionGenerator();
1841
- const cronGenerator = new CronGenerator();
1842
- const subscriberGenerator = new SubscriberGenerator();
1843
- const queueGenerator = new QueueGenerator();
1844
- const topicGenerator = new TopicGenerator();
1845
- const [allEndpoints, allFunctions, allCrons, allSubscribers, allQueues, allTopics] = await Promise.all([
1846
- endpointGenerator.load(config.constructs, appRoot, bustCache),
1847
- config.functions ? functionGenerator.load(config.functions, appRoot, bustCache) : [],
1848
- config.crons ? cronGenerator.load(config.crons, appRoot, bustCache) : [],
1849
- config.subscribers ? subscriberGenerator.load(config.subscribers, appRoot, bustCache) : [],
1850
- config.queues ? queueGenerator.load(config.queues, appRoot, bustCache) : [],
1851
- config.topics ? topicGenerator.load(config.topics, appRoot, bustCache) : []
1852
- ]);
1853
- const outputDir = join(appRoot, ".gkm", provider);
1854
- await mkdir(outputDir, { recursive: true });
1855
- await Promise.all([
1856
- endpointGenerator.build(context, allEndpoints, outputDir, {
1857
- provider,
1858
- enableOpenApi
1859
- }),
1860
- functionGenerator.build(context, allFunctions, outputDir, { provider }),
1861
- cronGenerator.build(context, allCrons, outputDir, { provider }),
1862
- subscriberGenerator.build(context, allSubscribers, outputDir, { provider }),
1863
- queueGenerator.build(context, allQueues, outputDir, { provider }),
1864
- topicGenerator.build(context, allTopics, outputDir, { provider })
1865
- ]);
1682
+ function cacheBackendsIn(manifest, configured) {
1683
+ const caches = Object.values(manifest).filter((d) => d.kind === "cache");
1684
+ return [...new Set(caches.map((c) => c.of ? "db" : configured))];
1866
1685
  }
1867
1686
  /**
1868
- * Run any TypeScript file with secret injection.
1869
- * Does not require gkm.config.ts.
1687
+ * The drivers an app's entry points should register.
1688
+ *
1689
+ * Keyed off the dependency rather than off a declared bucket, because the entry
1690
+ * is generated before discovery has run on the watcher's rebuild path — and an
1691
+ * app that installed `@geekmidas/storage` has already paid for the SDK it would
1692
+ * otherwise resolve lazily.
1870
1693
  */
1871
- async function entryDevCommand(options) {
1872
- const { entry, watch = true } = options;
1873
- if (!entry) throw new Error("--entry requires a file path");
1874
- const entryPath = resolve(process.cwd(), entry);
1875
- if (!existsSync(entryPath)) throw new Error(`Entry file not found: ${entryPath}`);
1876
- const defaultEnv = loadEnvFiles(".env");
1877
- if (defaultEnv.loaded.length > 0) logger$11.log(`📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
1878
- const { credentials, resolvedPort, secretsJsonPath, appName } = await prepareEntryCredentials({ explicitPort: options.portExplicit ? options.port : void 0 });
1879
- if (appName) logger$11.log(`📦 App: ${appName} (port ${resolvedPort})`);
1880
- logger$11.log(`🚀 Starting entry file: ${entry} on port ${resolvedPort}`);
1881
- if (Object.keys(credentials).length > 1) logger$11.log(`🔐 Loaded ${Object.keys(credentials).length - 1} secret(s) + PORT`);
1882
- const wrapperDir = join(process.cwd(), ".gkm");
1883
- await mkdir(wrapperDir, { recursive: true });
1884
- const wrapperPath = join(wrapperDir, "entry-wrapper.ts");
1885
- await createEntryWrapper(wrapperPath, entryPath, secretsJsonPath);
1886
- const runner = new EntryRunner(wrapperPath, entryPath, watch, resolvedPort);
1887
- await runner.start();
1888
- let isShuttingDown = false;
1889
- const shutdown = () => {
1890
- if (isShuttingDown) return;
1891
- isShuttingDown = true;
1892
- logger$11.log("\n🛑 Shutting down...");
1893
- runner.stop();
1894
- process.exit(0);
1895
- };
1896
- process.on("SIGINT", shutdown);
1897
- process.on("SIGTERM", shutdown);
1898
- await new Promise(() => {});
1694
+ function storageDriversFor(appRoot) {
1695
+ return dependsOnStorage(appRoot) ? S3 : NONE;
1696
+ }
1697
+ function dependsOnStorage(appRoot) {
1698
+ const manifest = join(appRoot, "package.json");
1699
+ if (!existsSync(manifest)) return false;
1700
+ try {
1701
+ const pkg = JSON.parse(readFileSync(manifest, "utf8"));
1702
+ return Boolean(pkg.dependencies?.["@geekmidas/storage"] ?? pkg.devDependencies?.["@geekmidas/storage"]);
1703
+ } catch {
1704
+ return false;
1705
+ }
1899
1706
  }
1707
+ //#endregion
1708
+ //#region src/generators/FunctionGenerator.ts
1709
+ var FunctionGenerator = class extends ConstructGenerator {
1710
+ isConstruct(value) {
1711
+ return Function.isFunction(value);
1712
+ }
1713
+ async build(context, constructs, outputDir, options) {
1714
+ const provider = options?.provider || "aws-lambda";
1715
+ const logger = console;
1716
+ const functionInfos = [];
1717
+ if (constructs.length === 0 || provider !== "aws-lambda") return functionInfos;
1718
+ const functionsDir = join(outputDir, "functions");
1719
+ await mkdir(functionsDir, { recursive: true });
1720
+ for (const { key, construct, path } of constructs) {
1721
+ const handlerFile = await this.generateFunctionHandler(functionsDir, path.relative, key, context, construct.owner);
1722
+ functionInfos.push({
1723
+ name: key,
1724
+ handler: relative(process.cwd(), handlerFile).replace(/\.ts$/, ".handler"),
1725
+ timeout: construct.timeout,
1726
+ memorySize: construct.memorySize,
1727
+ environment: await construct.getEnvironment({ markOptional: context.markOptional }),
1728
+ dependencies: construct.constructs
1729
+ });
1730
+ logger.log(`Generated function handler: ${key}`);
1731
+ }
1732
+ return functionInfos;
1733
+ }
1734
+ async generateFunctionHandler(outputDir, sourceFile, exportName, context, owner) {
1735
+ const handlerFileName = `${exportName}.ts`;
1736
+ const handlerPath = join(outputDir, handlerFileName);
1737
+ const importPath = relative(dirname(handlerPath), sourceFile).replace(/\.ts$/, ".js");
1738
+ const runtime = runtimeFor(context, dirname(handlerPath), owner);
1739
+ const content = `import { AWSLambdaFunction } from '@geekmidas/constructs/aws';
1740
+ import { ${exportName} } from '${importPath}';
1741
+ ${runtime.imports}
1742
+ ${runtime.bindings}
1743
+
1744
+ const adapter = new AWSLambdaFunction(envParser, ${exportName});
1745
+
1746
+ export const handler = adapter.handler;
1747
+ `;
1748
+ await writeFile(handlerPath, content);
1749
+ return handlerPath;
1750
+ }
1751
+ };
1752
+ //#endregion
1753
+ //#region src/generators/QueueGenerator.ts
1900
1754
  /**
1901
- * Runs and watches a TypeScript entry file using tsx.
1755
+ * Generates the runtime for `q` queue workers.
1756
+ *
1757
+ * - **server** (`gkm dev`): a single `queues.ts` exposing `setupQueues()` that
1758
+ * runs an in-process pg-boss poller alongside the Hono server — no SQS/Lambda.
1759
+ * Each queue subscribes by its **name** on the shared event connection.
1760
+ * - **aws-lambda**: one handler file per queue wrapping `AWSLambdaQueue`, backed
1761
+ * by an SQS event-source mapping.
1902
1762
  */
1903
- var EntryRunner = class {
1904
- wrapperPath;
1905
- entryPath;
1906
- watch;
1907
- port;
1908
- childProcess = null;
1909
- watcher = null;
1910
- isRunning = false;
1911
- constructor(wrapperPath, entryPath, watch, port) {
1912
- this.wrapperPath = wrapperPath;
1913
- this.entryPath = entryPath;
1914
- this.watch = watch;
1915
- this.port = port;
1763
+ var QueueGenerator = class extends ConstructGenerator {
1764
+ isConstruct(value) {
1765
+ return Queue.isQueue(value);
1916
1766
  }
1917
- async start() {
1918
- await this.runProcess();
1919
- if (this.watch) {
1920
- const watchDir = dirname(this.entryPath);
1921
- this.watcher = chokidar.watch(watchDir, {
1922
- ignored: /(^|[/\\])\../,
1923
- persistent: true,
1924
- ignoreInitial: true
1925
- });
1926
- let restartTimeout = null;
1927
- this.watcher.on("change", (path) => {
1928
- logger$11.log(`📝 File changed: ${path}`);
1929
- if (restartTimeout) clearTimeout(restartTimeout);
1930
- restartTimeout = setTimeout(async () => {
1931
- logger$11.log("🔄 Restarting...");
1932
- await this.restart();
1933
- }, 300);
1767
+ async build(context, constructs, outputDir, options) {
1768
+ const provider = options?.provider || "aws-lambda";
1769
+ const logger = console;
1770
+ const queueInfos = [];
1771
+ if (provider === "server") {
1772
+ await this.generateServerQueuesFile(outputDir, constructs);
1773
+ logger.log(`Generated server queues file with ${constructs.length} queues (polling mode)`);
1774
+ return queueInfos;
1775
+ }
1776
+ if (constructs.length === 0 || provider !== "aws-lambda") return queueInfos;
1777
+ const queuesDir = join(outputDir, "queues");
1778
+ await mkdir(queuesDir, { recursive: true });
1779
+ for (const { key, construct, path } of constructs) {
1780
+ const handlerFile = await this.generateQueueHandler(queuesDir, path.relative, key, context, construct.owner);
1781
+ queueInfos.push({
1782
+ name: construct.name,
1783
+ handler: relative(process.cwd(), handlerFile).replace(/\.ts$/, ".handler"),
1784
+ batchSize: construct.batchSize,
1785
+ fifo: construct.fifo,
1786
+ timeout: construct.timeout,
1787
+ environment: await construct.getEnvironment({ markOptional: context.markOptional }),
1788
+ dependencies: construct.constructs
1934
1789
  });
1935
- logger$11.log(`👀 Watching for changes in: ${watchDir}`);
1790
+ logger.log(`Generated queue handler: ${key}`);
1791
+ }
1792
+ return queueInfos;
1793
+ }
1794
+ async generateQueueHandler(outputDir, sourceFile, exportName, context, owner) {
1795
+ const handlerPath = join(outputDir, `${exportName}.ts`);
1796
+ const importPath = relative(dirname(handlerPath), sourceFile).replace(/\.ts$/, ".js");
1797
+ const runtime = runtimeFor(context, dirname(handlerPath), owner);
1798
+ const content = `import { AWSLambdaQueue } from '@geekmidas/constructs/aws';
1799
+ import { ${exportName} } from '${importPath}';
1800
+ ${runtime.imports}
1801
+ ${runtime.bindings}
1802
+
1803
+ const adapter = new AWSLambdaQueue(envParser, ${exportName});
1804
+
1805
+ export const handler = adapter.handler;
1806
+ `;
1807
+ await writeFile(handlerPath, content);
1808
+ return handlerPath;
1809
+ }
1810
+ async generateServerQueuesFile(outputDir, queues) {
1811
+ await mkdir(outputDir, { recursive: true });
1812
+ const queuesPath = join(outputDir, "queues.ts");
1813
+ if (queues.length === 0) {
1814
+ await writeFile(queuesPath, `/**
1815
+ * Generated queues setup — this app declares none.
1816
+ */
1817
+ import type { EnvironmentParser } from '@geekmidas/envkit';
1818
+ import type { Logger } from '@geekmidas/logger';
1819
+
1820
+ export async function setupQueues(
1821
+ _envParser: EnvironmentParser<any>,
1822
+ _logger: Logger,
1823
+ ): Promise<void> {}
1824
+ `);
1825
+ return queuesPath;
1826
+ }
1827
+ const importsByFile = /* @__PURE__ */ new Map();
1828
+ for (const { path, key } of queues) {
1829
+ const importPath = relative(dirname(queuesPath), path.relative).replace(/\.ts$/, ".js");
1830
+ if (!importsByFile.has(importPath)) importsByFile.set(importPath, []);
1831
+ importsByFile.get(importPath)?.push(key);
1936
1832
  }
1833
+ const content = `/**
1834
+ * Generated queue workers setup
1835
+ *
1836
+ * ⚠️ WARNING: This is for LOCAL DEVELOPMENT ONLY
1837
+ * This runs an in-process pg-boss poller alongside the HTTP server. Each queue
1838
+ * subscribes by its name on the shared event connection
1839
+ * (EVENT_SUBSCRIBER_CONNECTION_STRING), so producers using
1840
+ * <NAME>_PUBLISHER_CONNECTION_STRING (pgboss:// locally) reach it.
1841
+ *
1842
+ * For production, queues run as AWS Lambda with SQS event-source mappings.
1843
+ */
1844
+ import type { EnvironmentParser } from '@geekmidas/envkit';
1845
+ import type { Logger } from '@geekmidas/logger';
1846
+ import { EventConnectionFactory, Subscriber } from '@geekmidas/events';
1847
+ import type { EventConnection, EventSubscriber } from '@geekmidas/events';
1848
+ import { ServiceDiscovery } from '@geekmidas/services';
1849
+ ${Array.from(importsByFile.entries()).map(([importPath, exports]) => `import { ${exports.join(", ")} } from '${importPath}';`).join("\n")}
1850
+
1851
+ const queues = [
1852
+ ${queues.map(({ key }) => key).join(",\n ")}
1853
+ ];
1854
+
1855
+ const activeSubscribers: EventSubscriber<any>[] = [];
1856
+
1857
+ export async function setupQueues(
1858
+ envParser: EnvironmentParser<any>,
1859
+ logger: Logger,
1860
+ ): Promise<void> {
1861
+ if (queues.length === 0) {
1862
+ return;
1863
+ }
1864
+
1865
+ logger.info('Setting up queue workers in polling mode (local development)');
1866
+
1867
+ const config = envParser.create((get) => ({
1868
+ connectionString: get('EVENT_SUBSCRIBER_CONNECTION_STRING').string(),
1869
+ })).parse();
1870
+
1871
+ const serviceDiscovery = ServiceDiscovery.getInstance(envParser);
1872
+
1873
+ let connection: EventConnection;
1874
+ try {
1875
+ connection = await EventConnectionFactory.fromConnectionString(config.connectionString);
1876
+ const connectionType = new URL(config.connectionString).protocol.replace(':', '');
1877
+ logger.info({ connectionType }, 'Created shared event connection for queues');
1878
+ } catch (error) {
1879
+ logger.error({ error }, 'Failed to create event connection for queues');
1880
+ return;
1881
+ }
1882
+
1883
+ for (const queue of queues) {
1884
+ try {
1885
+ const eventSubscriber = await Subscriber.fromConnection(connection);
1886
+
1887
+ const services = queue.services.length > 0
1888
+ ? await serviceDiscovery.register(queue.services)
1889
+ : {};
1890
+
1891
+ // A queue subscribes to a single "type" — its own name.
1892
+ await eventSubscriber.subscribe([queue.name], async (message) => {
1893
+ try {
1894
+ const validation = await queue.messageSchema['~standard'].validate(message.payload);
1895
+ if (validation.issues) {
1896
+ logger.error({ issues: validation.issues, queue: queue.name }, 'Queue message failed validation');
1897
+ return;
1898
+ }
1899
+
1900
+ await queue.handler({
1901
+ messages: [validation.value],
1902
+ services: services,
1903
+ logger: queue.logger,
1904
+ });
1905
+
1906
+ logger.debug({ queue: queue.name }, 'Successfully processed queue message');
1907
+ } catch (error) {
1908
+ logger.error({ error, queue: queue.name }, 'Failed to process queue message');
1909
+ // Message will become visible again for retry.
1910
+ }
1911
+ });
1912
+
1913
+ activeSubscribers.push(eventSubscriber);
1914
+ logger.info({ queue: queue.name }, 'Queue worker started polling');
1915
+ } catch (error) {
1916
+ logger.error({ error, queue: queue.name }, 'Failed to setup queue worker');
1917
+ }
1918
+ }
1919
+
1920
+ const shutdown = () => {
1921
+ logger.info('Stopping all queue workers');
1922
+ for (const _ of activeSubscribers) {
1923
+ connection.stop();
1924
+ }
1925
+ };
1926
+
1927
+ process.on('SIGTERM', shutdown);
1928
+ process.on('SIGINT', shutdown);
1929
+ }
1930
+ `;
1931
+ await writeFile(queuesPath, content);
1932
+ return queuesPath;
1937
1933
  }
1938
- async runProcess() {
1939
- const env = {
1940
- ...process.env,
1941
- PORT: String(this.port)
1942
- };
1943
- this.childProcess = spawn("npx", ["tsx", this.wrapperPath], {
1944
- stdio: "inherit",
1945
- env,
1946
- detached: true
1947
- });
1948
- this.isRunning = true;
1949
- this.childProcess.on("error", (error) => {
1950
- logger$11.error("❌ Process error:", error);
1951
- });
1952
- this.childProcess.on("exit", (code) => {
1953
- if (code !== null && code !== 0 && code !== 143) logger$11.error(`❌ Process exited with code ${code}`);
1954
- this.isRunning = false;
1955
- });
1956
- await new Promise((resolve) => setTimeout(resolve, 500));
1957
- if (this.isRunning) {
1958
- logger$11.log("");
1959
- logger$11.log(` \x1b[32m✓ Ready\x1b[0m at \x1b[36mhttp://localhost:${this.port}\x1b[0m`);
1960
- logger$11.log("");
1961
- }
1934
+ };
1935
+ //#endregion
1936
+ //#region src/generators/SubscriberGenerator.ts
1937
+ var SubscriberGenerator = class extends ConstructGenerator {
1938
+ isConstruct(value) {
1939
+ return Subscriber.isSubscriber(value);
1962
1940
  }
1963
- async restart() {
1964
- this.stopProcess();
1965
- await new Promise((resolve) => setTimeout(resolve, 500));
1966
- await this.runProcess();
1941
+ async build(context, constructs, outputDir, options) {
1942
+ const provider = options?.provider || "aws-lambda";
1943
+ const logger = console;
1944
+ const subscriberInfos = [];
1945
+ if (provider === "server") {
1946
+ await this.generateServerSubscribersFile(outputDir, constructs);
1947
+ logger.log(`Generated server subscribers file with ${constructs.length} subscribers (polling mode)`);
1948
+ return subscriberInfos;
1949
+ }
1950
+ if (constructs.length === 0) return subscriberInfos;
1951
+ if (provider !== "aws-lambda") return subscriberInfos;
1952
+ const subscribersDir = join(outputDir, "subscribers");
1953
+ await mkdir(subscribersDir, { recursive: true });
1954
+ for (const { key, construct, path } of constructs) {
1955
+ const handlerFile = await this.generateSubscriberHandler(subscribersDir, path.relative, key, construct, context, construct.owner);
1956
+ subscriberInfos.push({
1957
+ name: key,
1958
+ handler: relative(process.cwd(), handlerFile).replace(/\.ts$/, ".handler"),
1959
+ subscribedEvents: construct.subscribedEvents || [],
1960
+ ...construct.topicName ? {
1961
+ transport: "topic",
1962
+ topic: construct.topicName
1963
+ } : {},
1964
+ timeout: construct.timeout,
1965
+ memorySize: construct.memorySize,
1966
+ environment: await construct.getEnvironment({ markOptional: context.markOptional }),
1967
+ dependencies: construct.constructs
1968
+ });
1969
+ logger.log(`Generated subscriber handler: ${key}`);
1970
+ }
1971
+ return subscriberInfos;
1967
1972
  }
1968
- stop() {
1969
- this.watcher?.close();
1970
- this.stopProcess();
1973
+ async generateSubscriberHandler(outputDir, sourceFile, exportName, _subscriber, context, owner) {
1974
+ const handlerFileName = `${exportName}.ts`;
1975
+ const handlerPath = join(outputDir, handlerFileName);
1976
+ const importPath = relative(dirname(handlerPath), sourceFile).replace(/\.ts$/, ".js");
1977
+ const runtime = runtimeFor(context, dirname(handlerPath), owner);
1978
+ const content = `import { AWSLambdaSubscriber } from '@geekmidas/constructs/aws';
1979
+ import { ${exportName} } from '${importPath}';
1980
+ ${runtime.imports}
1981
+ ${runtime.bindings}
1982
+
1983
+ const adapter = new AWSLambdaSubscriber(envParser, ${exportName});
1984
+
1985
+ export const handler = adapter.handler;
1986
+ `;
1987
+ await writeFile(handlerPath, content);
1988
+ return handlerPath;
1971
1989
  }
1972
- stopProcess() {
1973
- if (this.childProcess && this.isRunning) {
1974
- const pid = this.childProcess.pid;
1975
- if (pid) try {
1976
- process.kill(-pid, "SIGTERM");
1977
- } catch {
1978
- try {
1979
- process.kill(pid, "SIGTERM");
1980
- } catch {}
1981
- }
1982
- this.childProcess = null;
1983
- this.isRunning = false;
1990
+ async generateServerSubscribersFile(outputDir, subscribers) {
1991
+ await mkdir(outputDir, { recursive: true });
1992
+ const subscribersPath = join(outputDir, "subscribers.ts");
1993
+ if (subscribers.length === 0) {
1994
+ await writeFile(subscribersPath, `/**
1995
+ * Generated subscribers setup — this app declares none.
1996
+ */
1997
+ import type { EnvironmentParser } from '@geekmidas/envkit';
1998
+ import type { Logger } from '@geekmidas/logger';
1999
+
2000
+ export async function setupSubscribers(
2001
+ _envParser: EnvironmentParser<any>,
2002
+ _logger: Logger,
2003
+ ): Promise<void> {}
2004
+ `);
2005
+ return subscribersPath;
1984
2006
  }
1985
- }
1986
- };
1987
- /**
1988
- * Generate the content of the dev server entry file (server.ts).
1989
- * Uses dynamic import for createApp so Credentials are populated
1990
- * before any app modules evaluate.
1991
- * @internal Exported for testing
1992
- */
1993
- function generateServerEntryContent(options) {
1994
- const { secretsJsonPath, runtime = "node", enableOpenApi = false, appImportPath = "./app.js" } = options;
1995
- return `#!/usr/bin/env node
1996
- /**
1997
- * Development server entry point
1998
- * This file is auto-generated by 'gkm dev'
2007
+ const importsByFile = /* @__PURE__ */ new Map();
2008
+ for (const { path, key } of subscribers) {
2009
+ const importPath = relative(dirname(subscribersPath), path.relative).replace(/\.ts$/, ".js");
2010
+ if (!importsByFile.has(importPath)) importsByFile.set(importPath, []);
2011
+ importsByFile.get(importPath)?.push(key);
2012
+ }
2013
+ const content = `/**
2014
+ * Generated subscribers setup
2015
+ *
2016
+ * ⚠️ WARNING: This is for LOCAL DEVELOPMENT ONLY
2017
+ * This uses event polling which is not suitable for production.
2018
+ *
2019
+ * For production, use AWS Lambda with SQS/SNS event source mappings.
2020
+ * Lambda automatically:
2021
+ * - Scales based on queue depth
2022
+ * - Handles batch processing and retries
2023
+ * - Manages dead letter queues
2024
+ * - Provides better cost optimization
2025
+ *
2026
+ * This polling implementation is useful for:
2027
+ * - Local development and testing
2028
+ * - Understanding event flow without Lambda deployment
2029
+ *
2030
+ * Supported connection strings:
2031
+ * - sqs://region/account-id/queue-name (SQS queue)
2032
+ * - sns://region/account-id/topic-name (SNS topic)
2033
+ * - rabbitmq://host:port/queue-name (RabbitMQ)
2034
+ * - pgboss://user:pass@host:port/database (pg-boss / PostgreSQL)
2035
+ * - basic://in-memory (In-memory for testing)
1999
2036
  */
2000
- ${secretsJsonPath ? `import { existsSync, readFileSync } from 'node:fs';
2037
+ import type { EnvironmentParser } from '@geekmidas/envkit';
2038
+ import type { Logger } from '@geekmidas/logger';
2039
+ import { EventConnectionFactory, Subscriber } from '@geekmidas/events';
2040
+ import type { EventConnection, EventSubscriber } from '@geekmidas/events';
2041
+ import { ServiceDiscovery } from '@geekmidas/services';
2042
+ ${Array.from(importsByFile.entries()).map(([importPath, exports]) => `import { ${exports.join(", ")} } from '${importPath}';`).join("\n")}
2001
2043
 
2002
- // Inject dev secrets via globalThis (must happen before app import)
2003
- const secretsPath = '${secretsJsonPath}';
2004
- if (existsSync(secretsPath)) {
2005
- const __secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
2006
- globalThis.__gkm_credentials__ = __secrets;
2007
- Object.assign(process.env, __secrets);
2008
- }
2044
+ const subscribers = [
2045
+ ${subscribers.map(({ key }) => key).join(",\n ")}
2046
+ ];
2009
2047
 
2010
- ` : ""}
2011
- const port = process.argv.includes('--port')
2012
- ? Number.parseInt(process.argv[process.argv.indexOf('--port') + 1])
2013
- : 3000;
2048
+ const activeSubscribers: EventSubscriber<any>[] = [];
2014
2049
 
2015
- // Dynamic import so Credentials are populated before env.ts evaluates
2016
- const { createApp } = await import('${appImportPath}');
2050
+ export async function setupSubscribers(
2051
+ envParser: EnvironmentParser<any>,
2052
+ logger: Logger,
2053
+ ): Promise<void> {
2054
+ if (subscribers.length === 0) {
2055
+ return;
2056
+ }
2017
2057
 
2018
- // createApp is async to support optional WebSocket setup
2019
- const { app, start } = await createApp(undefined, ${enableOpenApi});
2058
+ logger.info('Setting up subscribers in polling mode (local development)');
2020
2059
 
2021
- // Start the server
2022
- start({
2023
- port,
2024
- serve: async (app, port) => {
2025
- ${runtime === "bun" ? `Bun.serve({
2026
- port,
2027
- fetch: app.fetch,
2028
- });` : `const { serve } = await import('@hono/node-server');
2029
- const server = serve({
2030
- fetch: app.fetch,
2031
- port,
2032
- });
2033
- // Inject WebSocket support if available
2034
- const injectWs = (app as any).__injectWebSocket;
2035
- if (injectWs) {
2036
- injectWs(server);
2037
- console.log('🔌 Telescope real-time updates enabled');
2038
- }`}
2039
- },
2040
- }).catch((error) => {
2041
- console.error('Failed to start server:', error);
2042
- process.exit(1);
2043
- });
2044
- `;
2060
+ const config = envParser.create((get) => ({
2061
+ connectionString: get('EVENT_SUBSCRIBER_CONNECTION_STRING').string(),
2062
+ })).parse();
2063
+
2064
+ const serviceDiscovery = ServiceDiscovery.getInstance(envParser);
2065
+
2066
+ // Create connection once, outside the loop (more efficient)
2067
+ // EventConnectionFactory automatically determines the right connection type
2068
+ let connection: EventConnection;
2069
+ try {
2070
+ connection = await EventConnectionFactory.fromConnectionString(config.connectionString);
2071
+
2072
+ const connectionType = new URL(config.connectionString).protocol.replace(':', '');
2073
+ logger.info({ connectionType }, 'Created shared event connection');
2074
+ } catch (error) {
2075
+ logger.error({ error }, 'Failed to create event connection');
2076
+ return;
2077
+ }
2078
+
2079
+ for (const subscriber of subscribers) {
2080
+ try {
2081
+ // Create subscriber from shared connection
2082
+ const eventSubscriber = await Subscriber.fromConnection(connection);
2083
+
2084
+ // Register services
2085
+ const services = subscriber.services.length > 0
2086
+ ? await serviceDiscovery.register(subscriber.services)
2087
+ : {};
2088
+
2089
+ // Subscribe to events
2090
+ const subscribedEvents = subscriber.subscribedEvents || [];
2091
+
2092
+ if (subscribedEvents.length === 0) {
2093
+ logger.warn({ subscriber: subscriber.constructor.name }, 'Subscriber has no subscribed events, skipping');
2094
+ continue;
2095
+ }
2096
+
2097
+ await eventSubscriber.subscribe(subscribedEvents, async (event) => {
2098
+ try {
2099
+ // Process single event (batch of 1)
2100
+ await subscriber.handler({
2101
+ events: [event],
2102
+ services: services as any,
2103
+ logger: subscriber.logger,
2104
+ });
2105
+
2106
+ logger.debug({ eventType: event.type }, 'Successfully processed event');
2107
+ } catch (error) {
2108
+ logger.error({ error, event }, 'Failed to process event');
2109
+ // Event will become visible again for retry
2110
+ }
2111
+ });
2112
+
2113
+ activeSubscribers.push(eventSubscriber);
2114
+
2115
+ logger.info(
2116
+ {
2117
+ events: subscribedEvents,
2118
+ },
2119
+ 'Subscriber started polling'
2120
+ );
2121
+ } catch (error) {
2122
+ logger.error({ error, subscriber: subscriber.constructor.name }, 'Failed to setup subscriber');
2123
+ }
2124
+ }
2125
+
2126
+ // Setup graceful shutdown
2127
+ const shutdown = () => {
2128
+ logger.info('Stopping all subscribers');
2129
+ for (const eventSubscriber of activeSubscribers) {
2130
+ connection.stop();
2131
+ }
2132
+ };
2133
+
2134
+ process.on('SIGTERM', shutdown);
2135
+ process.on('SIGINT', shutdown);
2045
2136
  }
2046
- var DevServer = class {
2047
- provider;
2048
- requestedPort;
2049
- portExplicit;
2050
- enableOpenApi;
2051
- telescope;
2052
- studio;
2053
- runtime;
2054
- appRoot;
2055
- secretsJsonPath;
2056
- serverProcess = null;
2057
- isRunning = false;
2058
- actualPort;
2059
- startTime = Date.now();
2060
- constructor(provider, requestedPort, portExplicit, enableOpenApi, telescope, studio, runtime = "node", appRoot = process.cwd(), secretsJsonPath) {
2061
- this.provider = provider;
2062
- this.requestedPort = requestedPort;
2063
- this.portExplicit = portExplicit;
2064
- this.enableOpenApi = enableOpenApi;
2065
- this.telescope = telescope;
2066
- this.studio = studio;
2067
- this.runtime = runtime;
2068
- this.appRoot = appRoot;
2069
- this.secretsJsonPath = secretsJsonPath;
2070
- this.actualPort = requestedPort;
2071
- }
2072
- async start() {
2073
- this.startTime = Date.now();
2074
- if (this.isRunning) await this.stop();
2075
- if (this.portExplicit) {
2076
- if (!await isPortAvailable(this.requestedPort)) throw new Error(`Port ${this.requestedPort} is already in use. Either stop the process using that port or omit -p/--port to auto-select an available port.`);
2077
- this.actualPort = this.requestedPort;
2078
- } else {
2079
- this.actualPort = await findAvailablePort(this.requestedPort);
2080
- if (this.actualPort !== this.requestedPort) logger$11.log(`ℹ️ Port ${this.requestedPort} was in use, using port ${this.actualPort} instead`);
2081
- }
2082
- const serverEntryPath = join(this.appRoot, ".gkm", this.provider, "server.ts");
2083
- await this.createServerEntry();
2084
- logger$11.log(`\n⏳ Starting server...`);
2085
- this.serverProcess = spawn("npx", [
2086
- "tsx",
2087
- serverEntryPath,
2088
- "--port",
2089
- this.actualPort.toString()
2090
- ], {
2091
- stdio: "inherit",
2092
- env: {
2093
- ...process.env,
2094
- NODE_ENV: "development"
2095
- },
2096
- detached: true
2097
- });
2098
- this.isRunning = true;
2099
- this.serverProcess.on("error", (error) => {
2100
- logger$11.error("❌ Server error:", error);
2101
- });
2102
- this.serverProcess.on("exit", (code, signal) => {
2103
- if (code !== null && code !== 0 && signal !== "SIGTERM") logger$11.error(`❌ Server exited with code ${code}`);
2104
- this.isRunning = false;
2105
- });
2106
- await new Promise((resolve) => setTimeout(resolve, 1e3));
2107
- if (this.isRunning) {
2108
- const base = `http://localhost:${this.actualPort}`;
2109
- const lines = [` Local: ${base}`];
2110
- if (this.enableOpenApi) lines.push(` API Docs: ${base}/__docs`);
2111
- if (this.telescope) lines.push(` Telescope: ${base}${this.telescope.path}`);
2112
- if (this.studio) lines.push(` Studio: ${base}${this.studio.path}`);
2113
- const maxLen = Math.max(...lines.map((l) => l.length));
2114
- const pad = (s) => s.padEnd(maxLen);
2115
- const border = "─".repeat(maxLen + 2);
2116
- logger$11.log("");
2117
- logger$11.log(` \x1b[32m✓ Ready\x1b[0m in ${((Date.now() - this.startTime) / 1e3).toFixed(1)}s`);
2118
- logger$11.log("");
2119
- logger$11.log(` ┌${border}┐`);
2120
- for (const line of lines) logger$11.log(` │ ${pad(line)} │`);
2121
- logger$11.log(` └${border}┘`);
2122
- logger$11.log("");
2123
- }
2124
- }
2125
- async stop() {
2126
- const port = this.actualPort;
2127
- if (this.serverProcess && this.isRunning) {
2128
- const pid = this.serverProcess.pid;
2129
- if (pid) try {
2130
- process.kill(-pid, "SIGKILL");
2131
- } catch {
2132
- try {
2133
- process.kill(pid, "SIGKILL");
2134
- } catch {}
2135
- }
2136
- this.serverProcess = null;
2137
- this.isRunning = false;
2138
- }
2139
- this.killProcessesOnPort(port);
2140
- }
2141
- killProcessesOnPort(port) {
2142
- try {
2143
- execSync(`lsof -ti tcp:${port} | xargs kill -9 2>/dev/null || true`, { stdio: "ignore" });
2144
- } catch {}
2137
+ `;
2138
+ await writeFile(subscribersPath, content);
2139
+ return subscribersPath;
2145
2140
  }
2146
- async restart() {
2147
- const portToReuse = this.actualPort;
2148
- await this.stop();
2149
- let attempts = 0;
2150
- while (attempts < 30) {
2151
- if (await isPortAvailable(portToReuse)) break;
2152
- await new Promise((resolve) => setTimeout(resolve, 100));
2153
- attempts++;
2154
- }
2155
- this.requestedPort = portToReuse;
2156
- await this.start();
2141
+ };
2142
+ //#endregion
2143
+ //#region src/generators/TopicGenerator.ts
2144
+ /**
2145
+ * Discovers `t` topics into `manifest.topics`. A topic is a *resource*, not a
2146
+ * function — it has no handler to generate. Producers reach it via its derived
2147
+ * publisher (`topic.publisher`, whose connection string is sniffed onto the
2148
+ * producing construct) and subscribers bind via `s.topic(topic)`; this generator
2149
+ * only records the topic + its event contract so infra can provision the SNS
2150
+ * topic. Identical output for every provider (locally pg-boss routes by event
2151
+ * name, so there's nothing to run).
2152
+ */
2153
+ var TopicGenerator = class extends ConstructGenerator {
2154
+ isConstruct(value) {
2155
+ return Topic.isTopic(value);
2157
2156
  }
2158
- async createServerEntry() {
2159
- const { writeFile: fsWriteFile } = await import("node:fs/promises");
2160
- await fsWriteFile(join(this.appRoot, ".gkm", this.provider, "server.ts"), generateServerEntryContent({
2161
- secretsJsonPath: this.secretsJsonPath,
2162
- runtime: this.runtime,
2163
- enableOpenApi: this.enableOpenApi
2157
+ async build(_context, constructs, _outputDir, _options) {
2158
+ const logger = console;
2159
+ const topicInfos = constructs.map(({ construct }) => ({
2160
+ name: construct.name,
2161
+ events: construct.eventTypes
2164
2162
  }));
2163
+ for (const topic of topicInfos) logger.log(`Discovered topic: ${topic.name}`);
2164
+ return topicInfos;
2165
2165
  }
2166
2166
  };
2167
2167
  //#endregion
@@ -2375,6 +2375,76 @@ function mapTopicMetadata(topics) {
2375
2375
  for (const [partition, partitionTopics] of Object.entries(topics)) result[partition] = partitionTopics.map(mapFn);
2376
2376
  return result;
2377
2377
  }
2378
+ //#endregion
2379
+ //#region src/build/owners.ts
2380
+ /**
2381
+ * What a generated entry imports its runtime from, read off the discovered
2382
+ * constructs: the surface it serves, every construct a runnable can be built
2383
+ * from, and the database Studio browses.
2384
+ *
2385
+ * `gkm build` and `gkm dev` both write entries that import the surface for its
2386
+ * logger and environment parser, so both ask here. A dev server that skipped it
2387
+ * had nothing to import them from.
2388
+ */
2389
+ function ownersContext(options) {
2390
+ const { declared, sources, workspaceRoot, appRoot, studio } = options;
2391
+ const surfaces = restApis(declared);
2392
+ const primary = servedSurface(declared, workspaceRoot, appRoot) ?? surfaces.find((d) => d.endpoints.length === 0) ?? surfaces[0];
2393
+ const browsable = Object.entries(declared).find(([, d]) => d.kind === "database");
2394
+ const browsableSource = browsable ? sources[browsable[0]] : void 0;
2395
+ const owners = {};
2396
+ for (const [id, declaration] of Object.entries(declared)) {
2397
+ if (declaration.kind !== "rest-api" && declaration.kind !== "worker") continue;
2398
+ const source = sources[id];
2399
+ if (!source) continue;
2400
+ owners[id] = {
2401
+ specifier: source.file,
2402
+ exportName: source.exportName
2403
+ };
2404
+ }
2405
+ const primarySource = primary ? sources[primary.id] : void 0;
2406
+ return {
2407
+ owners,
2408
+ ...primary ? { surface: {
2409
+ id: primary.id,
2410
+ trustedOriginsKey: provideKey(primary.id, "trustedOrigins"),
2411
+ ...primary.cors ? { cors: primary.cors } : {},
2412
+ ...primarySource ? { module: {
2413
+ specifier: primarySource.file,
2414
+ exportName: primarySource.exportName
2415
+ } } : {}
2416
+ } } : {},
2417
+ studio: studio && browsableSource ? {
2418
+ ...studio,
2419
+ database: {
2420
+ specifier: browsableSource.file,
2421
+ exportName: browsableSource.exportName
2422
+ }
2423
+ } : studio
2424
+ };
2425
+ }
2426
+ /**
2427
+ * The endpoints one surface serves.
2428
+ *
2429
+ * One glob finds every surface's endpoints, wherever their files live. A build
2430
+ * keeps the ones built from the surface it serves; one built from no surface at
2431
+ * all has nowhere else to go.
2432
+ */
2433
+ function servedBy(endpoints, surface) {
2434
+ if (!surface) return endpoints;
2435
+ return endpoints.filter(({ construct }) => !construct.surface || construct.surface.id === surface.id);
2436
+ }
2437
+ function restApis(declared) {
2438
+ return Object.values(declared).filter((d) => d.kind === "rest-api");
2439
+ }
2440
+ /**
2441
+ * The surface whose declared `path` is the directory being built — asked of
2442
+ * every surface, because a workspace has several and each app's build serves
2443
+ * exactly one.
2444
+ */
2445
+ function servedSurface(declared, workspaceRoot, appRoot) {
2446
+ return restApis(declared).find((d) => resolve(workspaceRoot, d.path) === resolve(appRoot));
2447
+ }
2378
2448
  /**
2379
2449
  * Check if any construct across the given arrays has a non-undefined partition.
2380
2450
  * When true, the manifest should use the partitioned shape for that construct type.
@@ -2397,7 +2467,29 @@ function groupInfosByPartition(infos, constructs) {
2397
2467
  return groups;
2398
2468
  }
2399
2469
  //#endregion
2400
- //#region src/build/index.ts
2470
+ //#region src/build/surfaceEntry.ts
2471
+ /** Whether a construct serves itself, asked of the construct. */
2472
+ function servesItself(construct) {
2473
+ return typeof construct?.server === "function";
2474
+ }
2475
+ /**
2476
+ * The surface this app serves, when that surface serves itself.
2477
+ *
2478
+ * An auth server's routes are a wildcard the construct mounts, so a glob finds
2479
+ * nothing and the entry only has to start it. That used to be inferred —
2480
+ * "nothing found, and the surface declares routes" — which is a guess about
2481
+ * the construct from the outside. The construct can simply be asked.
2482
+ */
2483
+ function selfServingSurface(options) {
2484
+ const { declared, sources, workspaceRoot, appRoot } = options;
2485
+ const served = servedSurface(declared, workspaceRoot, appRoot);
2486
+ const source = served ? sources[served.id] : void 0;
2487
+ if (!served || !source || !servesItself(source.construct)) return void 0;
2488
+ return {
2489
+ id: served.id,
2490
+ source
2491
+ };
2492
+ }
2401
2493
  /**
2402
2494
  * The entry for a surface that serves itself.
2403
2495
  *
@@ -2431,6 +2523,8 @@ export const { app } = await surface.server({ envParser, context: snifferContext
2431
2523
  export default app;
2432
2524
  `);
2433
2525
  }
2526
+ //#endregion
2527
+ //#region src/build/index.ts
2434
2528
  const logger$9 = console;
2435
2529
  /**
2436
2530
  * What a build run at the workspace root reads.
@@ -2474,58 +2568,71 @@ async function buildCommand(options) {
2474
2568
  if (studio) logger$9.log(`🗄️ Studio enabled at ${studio.path}`);
2475
2569
  const hooks = normalizeHooksConfig(config.hooks);
2476
2570
  if (hooks) logger$9.log(`🪝 Server hooks enabled`);
2571
+ return buildApp({
2572
+ config,
2573
+ workspaceRoot: loadedConfig.workspace.root,
2574
+ appRoot: process.cwd(),
2575
+ providers: resolved.providers,
2576
+ enableOpenApi: resolved.enableOpenApi,
2577
+ cacheBackend,
2578
+ production,
2579
+ telescope,
2580
+ studio,
2581
+ hooks,
2582
+ markOptional: options.markOptional ?? false,
2583
+ skipBundle: options.skipBundle ?? false,
2584
+ stage: options.stage
2585
+ });
2586
+ }
2587
+ /**
2588
+ * Turn one app's constructs into its generated entry.
2589
+ *
2590
+ * `gkm build` and `gkm dev` both run this. They used to be two copies of it,
2591
+ * and every time the build learned something about constructs — that the
2592
+ * entry takes its logger from the surface, that an auth server serves itself —
2593
+ * dev did not, and failed on exactly the apps the build had learned to handle.
2594
+ */
2595
+ async function buildApp(input) {
2596
+ const { config, workspaceRoot, appRoot, providers, enableOpenApi, cacheBackend, production, telescope, studio, hooks, bustCache = false } = input;
2477
2597
  const constructGlobs = typeof config.constructs === "string" || Array.isArray(config.constructs) ? config.constructs : void 0;
2478
2598
  const constructSources = {};
2479
2599
  const declared = constructGlobs ? await discover({
2480
2600
  patterns: constructGlobs,
2481
- cwd: process.cwd(),
2601
+ cwd: appRoot,
2602
+ bustCache,
2482
2603
  sources: constructSources
2483
2604
  }) : {};
2484
- const surfaces = Object.values(declared).filter((d) => d.kind === "rest-api");
2485
- const workspaceRoot = loadedConfig.workspace.root;
2486
- const served = surfaces.find((d) => resolve(workspaceRoot, d.path) === resolve(process.cwd()));
2487
- const primary = served ?? surfaces.find((d) => d.endpoints.length === 0) ?? surfaces[0];
2488
- const browsable = Object.entries(declared).find(([, d]) => d.kind === "database");
2489
- const browsableSource = browsable ? constructSources[browsable[0]] : void 0;
2490
- const owners = {};
2491
- for (const [id, declaration] of Object.entries(declared)) {
2492
- if (declaration.kind !== "rest-api" && declaration.kind !== "worker") continue;
2493
- const source = constructSources[id];
2494
- if (!source) continue;
2495
- owners[id] = {
2496
- specifier: source.file,
2497
- exportName: source.exportName
2498
- };
2499
- }
2605
+ const selfServing = selfServingSurface({
2606
+ declared,
2607
+ sources: constructSources,
2608
+ workspaceRoot,
2609
+ appRoot
2610
+ });
2611
+ if (selfServing) {
2612
+ await writeSurfaceEntry(join(appRoot, ".gkm", "server"), selfServing.source);
2613
+ logger$9.log(`Generated a server for ${selfServing.id} from its own declaration`);
2614
+ return { selfServing: selfServing.id };
2615
+ }
2616
+ const derived = ownersContext({
2617
+ declared,
2618
+ sources: constructSources,
2619
+ workspaceRoot,
2620
+ appRoot,
2621
+ studio
2622
+ });
2500
2623
  const buildContext = {
2501
- owners,
2502
- ...primary ? { surface: {
2503
- id: primary.id,
2504
- trustedOriginsKey: provideKey(primary.id, "trustedOrigins"),
2505
- ...primary.cors ? { cors: primary.cors } : {},
2506
- ...constructSources[primary.id] ? { module: {
2507
- specifier: constructSources[primary.id].file,
2508
- exportName: constructSources[primary.id].exportName
2509
- } } : {}
2510
- } } : {},
2624
+ ...derived,
2511
2625
  telescope,
2512
- studio: studio && browsableSource ? {
2513
- ...studio,
2514
- database: {
2515
- specifier: browsableSource.file,
2516
- exportName: browsableSource.exportName
2517
- }
2518
- } : studio,
2519
2626
  hooks,
2520
2627
  production,
2521
2628
  constructGlobs,
2522
2629
  cacheBackend,
2523
2630
  emailBackend: "ses",
2524
2631
  storageDrivers: driversFor({
2525
- appRoot: process.cwd(),
2632
+ appRoot,
2526
2633
  cache: cacheBackendsIn(declared, cacheBackend)
2527
2634
  }),
2528
- markOptional: options.markOptional ?? false
2635
+ markOptional: input.markOptional ?? false
2529
2636
  };
2530
2637
  const endpointGenerator = new EndpointGenerator();
2531
2638
  const functionGenerator = new FunctionGenerator();
@@ -2535,43 +2642,37 @@ async function buildCommand(options) {
2535
2642
  const topicGenerator = new TopicGenerator();
2536
2643
  const code = constructGlobs;
2537
2644
  const [loadedEndpoints, allFunctions, allCrons, allSubscribers, allQueues, allTopics] = await Promise.all([
2538
- endpointGenerator.load(code),
2539
- functionGenerator.load(code),
2540
- cronGenerator.load(code),
2541
- subscriberGenerator.load(code),
2542
- queueGenerator.load(code),
2543
- topicGenerator.load(code)
2645
+ endpointGenerator.load(code, appRoot, bustCache),
2646
+ functionGenerator.load(code, appRoot, bustCache),
2647
+ cronGenerator.load(code, appRoot, bustCache),
2648
+ subscriberGenerator.load(code, appRoot, bustCache),
2649
+ queueGenerator.load(code, appRoot, bustCache),
2650
+ topicGenerator.load(code, appRoot, bustCache)
2544
2651
  ]);
2545
- const allEndpoints = primary ? loadedEndpoints.filter(({ construct }) => !construct.surface || construct.surface.id === primary.id) : loadedEndpoints;
2652
+ const allEndpoints = servedBy(loadedEndpoints, derived.surface);
2546
2653
  logger$9.log(`Found ${allEndpoints.length} endpoints`);
2547
2654
  logger$9.log(`Found ${allFunctions.length} functions`);
2548
2655
  logger$9.log(`Found ${allCrons.length} crons`);
2549
2656
  logger$9.log(`Found ${allSubscribers.length} subscribers`);
2550
2657
  logger$9.log(`Found ${allQueues.length} queues`);
2551
2658
  logger$9.log(`Found ${allTopics.length} topics`);
2552
- if (allEndpoints.length === 0 && allFunctions.length === 0 && allCrons.length === 0 && allSubscribers.length === 0 && allQueues.length === 0 && allTopics.length === 0) {
2553
- const selfServing = served && served.endpoints.length > 0 && constructSources[served.id] !== void 0 ? [served.id, served] : void 0;
2554
- if (selfServing) {
2555
- const [id] = selfServing;
2556
- await writeSurfaceEntry(join(process.cwd(), ".gkm", "server"), constructSources[id]);
2557
- logger$9.log(`Generated a server for ${id} from its own declaration`);
2558
- return {};
2559
- }
2659
+ if (!input.serveEmpty && allEndpoints.length === 0 && allFunctions.length === 0 && allCrons.length === 0 && allSubscribers.length === 0 && allQueues.length === 0 && allTopics.length === 0) {
2560
2660
  logger$9.log("No endpoints, functions, crons, subscribers, queues, or topics found to process");
2561
2661
  return {};
2562
2662
  }
2563
- const rootOutputDir = join(process.cwd(), ".gkm");
2663
+ const rootOutputDir = join(appRoot, ".gkm");
2564
2664
  await mkdir(rootOutputDir, { recursive: true });
2565
2665
  let result = {};
2566
- for (const provider of resolved.providers) {
2567
- 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);
2666
+ for (const provider of providers) {
2667
+ const providerResult = await buildForProvider(provider, buildContext, appRoot, declared, endpointGenerator, functionGenerator, cronGenerator, subscriberGenerator, queueGenerator, topicGenerator, allEndpoints, allFunctions, allCrons, allSubscribers, allQueues, allTopics, enableOpenApi, input.skipBundle ?? false, input.stage);
2568
2668
  if (providerResult.masterKey) result = providerResult;
2569
2669
  }
2570
2670
  await generateOpenApi(allEndpoints.map(({ construct }) => construct), { openapi: config.openapi });
2571
2671
  return result;
2572
2672
  }
2573
- async function buildForProvider(provider, context, rootOutputDir, endpointGenerator, functionGenerator, cronGenerator, subscriberGenerator, queueGenerator, topicGenerator, endpoints, functions, crons, subscribers, queues, topics, enableOpenApi, skipBundle, stage) {
2574
- const outputDir = join(process.cwd(), ".gkm", provider);
2673
+ async function buildForProvider(provider, context, appRoot, discovered, endpointGenerator, functionGenerator, cronGenerator, subscriberGenerator, queueGenerator, topicGenerator, endpoints, functions, crons, subscribers, queues, topics, enableOpenApi, skipBundle, stage) {
2674
+ const rootOutputDir = join(appRoot, ".gkm");
2675
+ const outputDir = join(rootOutputDir, provider);
2575
2676
  await mkdir(outputDir, { recursive: true });
2576
2677
  logger$9.log(`\nGenerating handlers for provider: ${provider}`);
2577
2678
  const [routes, functionInfos, cronInfos, subscriberInfos, queueInfos, topicInfos] = await Promise.all([
@@ -2587,10 +2688,6 @@ async function buildForProvider(provider, context, rootOutputDir, endpointGenera
2587
2688
  ]);
2588
2689
  logger$9.log(`Generated ${routes.length} routes, ${functionInfos.length} functions, ${cronInfos.length} crons, ${subscriberInfos.length} subscribers, ${queueInfos.length} queues, ${topicInfos.length} topics for ${provider}`);
2589
2690
  const manifestRoutes = assembleManifestField(routes, endpoints);
2590
- const discovered = context.constructGlobs ? await discover({
2591
- patterns: context.constructGlobs,
2592
- cwd: process.cwd()
2593
- }) : {};
2594
2691
  const manifestFunctions = assembleManifestField(functionInfos, functions);
2595
2692
  const manifestCrons = assembleManifestField(cronInfos, crons);
2596
2693
  const manifestQueues = assembleManifestField(queueInfos, queues);
@@ -2604,8 +2701,8 @@ async function buildForProvider(provider, context, rootOutputDir, endpointGenera
2604
2701
  authorizer: construct.authorizer?.name ?? "none"
2605
2702
  }))), endpoints);
2606
2703
  await generateServerManifest(rootOutputDir, {
2607
- handler: relative(process.cwd(), join(outputDir, "app.ts")),
2608
- endpoints: relative(process.cwd(), join(outputDir, "endpoints.ts"))
2704
+ handler: relative(appRoot, join(outputDir, "app.ts")),
2705
+ endpoints: relative(appRoot, join(outputDir, "endpoints.ts"))
2609
2706
  }, serverRouteField, manifestSubscribers, manifestQueues, manifestTopics);
2610
2707
  let masterKey;
2611
2708
  if (context.production?.bundle && !skipBundle) {
@@ -2684,14 +2781,9 @@ function getTurboCommand(pm, filters = []) {
2684
2781
  function turboFilters(workspace) {
2685
2782
  const filters = [];
2686
2783
  const unpackaged = [];
2687
- for (const [appName, app] of Object.entries(workspace.apps)) {
2688
- const pkgPath = join(workspace.root, app.path, "package.json");
2689
- if (!existsSync(pkgPath)) {
2690
- unpackaged.push(appName);
2691
- continue;
2692
- }
2693
- const name = JSON.parse(readFileSync(pkgPath, "utf8")).name;
2694
- if (typeof name === "string" && name.length > 0) filters.push(name);
2784
+ for (const appName of Object.keys(workspace.apps)) {
2785
+ const name = appPackageName(workspace, appName);
2786
+ if (name) filters.push(name);
2695
2787
  else unpackaged.push(appName);
2696
2788
  }
2697
2789
  return {
@@ -2700,6 +2792,20 @@ function turboFilters(workspace) {
2700
2792
  };
2701
2793
  }
2702
2794
  /**
2795
+ * The package name turbo knows an app by, from its own `package.json`.
2796
+ *
2797
+ * Not the app's key: `api` in the workspace is `@shop/api` to turbo, and a
2798
+ * filter naming the key matches nothing.
2799
+ */
2800
+ function appPackageName(workspace, appName) {
2801
+ const app = workspace.apps[appName];
2802
+ if (!app) return void 0;
2803
+ const pkgPath = join(workspace.root, app.path, "package.json");
2804
+ if (!existsSync(pkgPath)) return void 0;
2805
+ const name = JSON.parse(readFileSync(pkgPath, "utf8")).name;
2806
+ return typeof name === "string" && name.length > 0 ? name : void 0;
2807
+ }
2808
+ /**
2703
2809
  * Build all apps in a workspace using Turbo for dependency-ordered parallel builds.
2704
2810
  * @internal Exported for testing
2705
2811
  */
@@ -8409,26 +8515,26 @@ function loadPackageJson() {
8409
8515
  * Run: pnpm --filter @geekmidas/cli sync-versions
8410
8516
  */
8411
8517
  const GEEKMIDAS_VERSIONS = {
8412
- "@geekmidas/audit": "~10.0.0-alpha.23",
8413
- "@geekmidas/auth": "~10.0.0-alpha.23",
8414
- "@geekmidas/cache": "~10.0.0-alpha.23",
8415
- "@geekmidas/client": "~10.0.0-alpha.23",
8416
- "@geekmidas/cloud": "~10.0.0-alpha.23",
8417
- "@geekmidas/constructs": "~10.0.0-alpha.23",
8418
- "@geekmidas/db": "~10.0.0-alpha.23",
8419
- "@geekmidas/emailkit": "~10.0.0-alpha.23",
8420
- "@geekmidas/envkit": "~10.0.0-alpha.23",
8421
- "@geekmidas/errors": "~10.0.0-alpha.23",
8422
- "@geekmidas/events": "~10.0.0-alpha.23",
8423
- "@geekmidas/logger": "~10.0.0-alpha.23",
8424
- "@geekmidas/manifest": "~10.0.0-alpha.23",
8425
- "@geekmidas/rate-limit": "~10.0.0-alpha.23",
8426
- "@geekmidas/schema": "~10.0.0-alpha.23",
8427
- "@geekmidas/services": "~10.0.0-alpha.23",
8428
- "@geekmidas/storage": "~10.0.0-alpha.23",
8429
- "@geekmidas/studio": "~10.0.0-alpha.23",
8430
- "@geekmidas/telescope": "~10.0.0-alpha.23",
8431
- "@geekmidas/testkit": "~10.0.0-alpha.23",
8518
+ "@geekmidas/audit": "~10.0.0-alpha.25",
8519
+ "@geekmidas/auth": "~10.0.0-alpha.25",
8520
+ "@geekmidas/cache": "~10.0.0-alpha.25",
8521
+ "@geekmidas/client": "~10.0.0-alpha.25",
8522
+ "@geekmidas/cloud": "~10.0.0-alpha.25",
8523
+ "@geekmidas/constructs": "~10.0.0-alpha.25",
8524
+ "@geekmidas/db": "~10.0.0-alpha.25",
8525
+ "@geekmidas/emailkit": "~10.0.0-alpha.25",
8526
+ "@geekmidas/envkit": "~10.0.0-alpha.25",
8527
+ "@geekmidas/errors": "~10.0.0-alpha.25",
8528
+ "@geekmidas/events": "~10.0.0-alpha.25",
8529
+ "@geekmidas/logger": "~10.0.0-alpha.25",
8530
+ "@geekmidas/manifest": "~10.0.0-alpha.25",
8531
+ "@geekmidas/rate-limit": "~10.0.0-alpha.25",
8532
+ "@geekmidas/schema": "~10.0.0-alpha.25",
8533
+ "@geekmidas/services": "~10.0.0-alpha.25",
8534
+ "@geekmidas/storage": "~10.0.0-alpha.25",
8535
+ "@geekmidas/studio": "~10.0.0-alpha.25",
8536
+ "@geekmidas/telescope": "~10.0.0-alpha.25",
8537
+ "@geekmidas/testkit": "~10.0.0-alpha.25",
8432
8538
  "@geekmidas/cli": `~${loadPackageJson().version}`
8433
8539
  };
8434
8540
  //#endregion
@@ -15849,7 +15955,7 @@ program.command("dev").description("Reconcile constructs and start the dev serve
15849
15955
  const globalOptions = program.opts();
15850
15956
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
15851
15957
  await devCommand({
15852
- port: options.port ? Number.parseInt(options.port, 10) : 3e3,
15958
+ port: options.port ? Number.parseInt(options.port, 10) : void 0,
15853
15959
  portExplicit: !!options.port,
15854
15960
  enableOpenApi: options.enableOpenapi ?? true,
15855
15961
  entry: options.entry,
@@ -16002,7 +16108,7 @@ program.command("secrets:push").description("Send a deployed stage's secrets to
16002
16108
  const globalOptions = program.opts();
16003
16109
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
16004
16110
  const { loadWorkspaceConfig } = await import("./config.mjs");
16005
- const { pushStageSecrets } = await import("./transfer-Bk5YlRyB.mjs").then((n) => n.i);
16111
+ const { pushStageSecrets } = await import("./transfer-B88fTvnh.mjs").then((n) => n.i);
16006
16112
  const { workspace } = await loadWorkspaceConfig();
16007
16113
  const { addedKeys } = await pushStageSecrets(workspace, options.stage, { profile: options.profile });
16008
16114
  for (const key of addedKeys) console.log(` + ${key} (reconciled)`);
@@ -16017,7 +16123,7 @@ program.command("secrets:pull").description("Bring a deployed stage's secrets fr
16017
16123
  const globalOptions = program.opts();
16018
16124
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
16019
16125
  const { loadWorkspaceConfig } = await import("./config.mjs");
16020
- const { pullStageSecrets } = await import("./transfer-Bk5YlRyB.mjs").then((n) => n.i);
16126
+ const { pullStageSecrets } = await import("./transfer-B88fTvnh.mjs").then((n) => n.i);
16021
16127
  const { workspace } = await loadWorkspaceConfig();
16022
16128
  const { addedKeys } = await pullStageSecrets(workspace, options.stage, { profile: options.profile });
16023
16129
  for (const key of addedKeys) console.log(` + ${key} (reconciled)`);
@@ -16041,7 +16147,7 @@ program.command("secrets:reconcile").description("Backfill missing custom secret
16041
16147
  console.error(`No secrets found for stage "${stage}". Run "gkm secrets:init --stage ${stage}" first.`);
16042
16148
  process.exit(1);
16043
16149
  }
16044
- const { derivedContainers } = await import("./credentials-D9TKJJZc.mjs").then((n) => n.g);
16150
+ const { derivedContainers } = await import("./credentials-BpoOUa74.mjs").then((n) => n.g);
16045
16151
  const result = reconcileMissingSecrets(secrets, workspace, await derivedContainers(workspace, stage));
16046
16152
  if (!result) {
16047
16153
  console.log(`\n✓ Secrets for stage "${stage}" are up-to-date`);
@@ -16100,7 +16206,7 @@ program.command("deploy:github").description("Let GitHub Actions deploy a stage:
16100
16206
  try {
16101
16207
  const globalOptions = program.opts();
16102
16208
  if (globalOptions.cwd) process.chdir(globalOptions.cwd);
16103
- const { deployGithubCommand } = await import("./github-B4OQQZBV.mjs");
16209
+ const { deployGithubCommand } = await import("./github-BOaa3kQR.mjs");
16104
16210
  await deployGithubCommand(options);
16105
16211
  } catch (error) {
16106
16212
  console.error(formatError(error));