@geekmidas/cli 10.0.0-alpha.24 → 10.0.0-alpha.26

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