@geekmidas/cli 1.9.0 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +42 -6
  3. package/dist/{HostingerProvider-CEsQbmpY.cjs → HostingerProvider-5KYmwoK2.cjs} +1 -1
  4. package/dist/{HostingerProvider-CEsQbmpY.cjs.map → HostingerProvider-5KYmwoK2.cjs.map} +1 -1
  5. package/dist/{HostingerProvider-DkahM5AP.mjs → HostingerProvider-ANWchdiK.mjs} +1 -1
  6. package/dist/{HostingerProvider-DkahM5AP.mjs.map → HostingerProvider-ANWchdiK.mjs.map} +1 -1
  7. package/dist/{LocalStateProvider-Roi202l7.cjs → LocalStateProvider-CLifRC0Y.cjs} +1 -1
  8. package/dist/{LocalStateProvider-Roi202l7.cjs.map → LocalStateProvider-CLifRC0Y.cjs.map} +1 -1
  9. package/dist/{LocalStateProvider-DXIwWb7k.mjs → LocalStateProvider-Dp0KkRcw.mjs} +1 -1
  10. package/dist/{LocalStateProvider-DXIwWb7k.mjs.map → LocalStateProvider-Dp0KkRcw.mjs.map} +1 -1
  11. package/dist/{Route53Provider-Ckq_n5Be.mjs → Route53Provider-QoPgcXxn.mjs} +1 -1
  12. package/dist/{Route53Provider-Ckq_n5Be.mjs.map → Route53Provider-QoPgcXxn.mjs.map} +1 -1
  13. package/dist/{Route53Provider-BqXeHzuc.cjs → Route53Provider-owQQ4pn6.cjs} +1 -1
  14. package/dist/{Route53Provider-BqXeHzuc.cjs.map → Route53Provider-owQQ4pn6.cjs.map} +1 -1
  15. package/dist/{SSMStateProvider-BReQA5re.cjs → SSMStateProvider-CT8tjl9o.cjs} +1 -1
  16. package/dist/{SSMStateProvider-BReQA5re.cjs.map → SSMStateProvider-CT8tjl9o.cjs.map} +1 -1
  17. package/dist/{SSMStateProvider-wddd0_-d.mjs → SSMStateProvider-CksOTB8M.mjs} +1 -1
  18. package/dist/{SSMStateProvider-wddd0_-d.mjs.map → SSMStateProvider-CksOTB8M.mjs.map} +1 -1
  19. package/dist/{backup-provisioner-BAExdDtc.mjs → backup-provisioner-BEXoHTuC.mjs} +1 -1
  20. package/dist/{backup-provisioner-BAExdDtc.mjs.map → backup-provisioner-BEXoHTuC.mjs.map} +1 -1
  21. package/dist/{backup-provisioner-C8VK63I-.cjs → backup-provisioner-C4noe75O.cjs} +1 -1
  22. package/dist/{backup-provisioner-C8VK63I-.cjs.map → backup-provisioner-C4noe75O.cjs.map} +1 -1
  23. package/dist/{bundler-BxHyDhdt.mjs → bundler-DQYjKFPm.mjs} +1 -1
  24. package/dist/{bundler-BxHyDhdt.mjs.map → bundler-DQYjKFPm.mjs.map} +1 -1
  25. package/dist/{bundler-CuMIfXw5.cjs → bundler-NpfYPBUo.cjs} +1 -1
  26. package/dist/{bundler-CuMIfXw5.cjs.map → bundler-NpfYPBUo.cjs.map} +1 -1
  27. package/dist/{config-6JHOwLCx.cjs → config-D3ORuiUs.cjs} +2 -2
  28. package/dist/{config-6JHOwLCx.cjs.map → config-D3ORuiUs.cjs.map} +1 -1
  29. package/dist/{config-DxASSNjr.mjs → config-jsRYHOHU.mjs} +2 -2
  30. package/dist/{config-DxASSNjr.mjs.map → config-jsRYHOHU.mjs.map} +1 -1
  31. package/dist/config.cjs +2 -2
  32. package/dist/config.d.cts +2 -2
  33. package/dist/config.d.mts +2 -2
  34. package/dist/config.mjs +2 -2
  35. package/dist/fullstack-secrets-COWz084x.cjs +238 -0
  36. package/dist/fullstack-secrets-COWz084x.cjs.map +1 -0
  37. package/dist/fullstack-secrets-UZAFWuH4.mjs +202 -0
  38. package/dist/fullstack-secrets-UZAFWuH4.mjs.map +1 -0
  39. package/dist/{index-BVNXOydm.d.mts → index-3n-giNaw.d.mts} +18 -6
  40. package/dist/index-3n-giNaw.d.mts.map +1 -0
  41. package/dist/{index-Cyk2rTyj.d.cts → index-CiEOtKEX.d.cts} +18 -6
  42. package/dist/index-CiEOtKEX.d.cts.map +1 -0
  43. package/dist/index.cjs +322 -433
  44. package/dist/index.cjs.map +1 -1
  45. package/dist/index.mjs +306 -417
  46. package/dist/index.mjs.map +1 -1
  47. package/dist/{openapi-CnvwSRDU.cjs → openapi-BYxAWwok.cjs} +178 -32
  48. package/dist/openapi-BYxAWwok.cjs.map +1 -0
  49. package/dist/{openapi-BYlyAbH3.mjs → openapi-DenF-okj.mjs} +148 -32
  50. package/dist/openapi-DenF-okj.mjs.map +1 -0
  51. package/dist/{openapi-react-query-DaTMSPD5.mjs → openapi-react-query-C4UdILaI.mjs} +1 -1
  52. package/dist/{openapi-react-query-DaTMSPD5.mjs.map → openapi-react-query-C4UdILaI.mjs.map} +1 -1
  53. package/dist/{openapi-react-query-BeXvk-wa.cjs → openapi-react-query-DYbBq-WJ.cjs} +1 -1
  54. package/dist/{openapi-react-query-BeXvk-wa.cjs.map → openapi-react-query-DYbBq-WJ.cjs.map} +1 -1
  55. package/dist/openapi-react-query.cjs +1 -1
  56. package/dist/openapi-react-query.mjs +1 -1
  57. package/dist/openapi.cjs +3 -3
  58. package/dist/openapi.d.cts +1 -1
  59. package/dist/openapi.d.cts.map +1 -1
  60. package/dist/openapi.d.mts +1 -1
  61. package/dist/openapi.d.mts.map +1 -1
  62. package/dist/openapi.mjs +3 -3
  63. package/dist/reconcile-7yarEvmK.cjs +36 -0
  64. package/dist/reconcile-7yarEvmK.cjs.map +1 -0
  65. package/dist/reconcile-D2WCDQue.mjs +36 -0
  66. package/dist/reconcile-D2WCDQue.mjs.map +1 -0
  67. package/dist/{sync-BnqNNc6O.mjs → sync-6FoT41G3.mjs} +1 -1
  68. package/dist/{sync-CHfhmXF3.mjs → sync-CbeKrnQV.mjs} +1 -1
  69. package/dist/{sync-CHfhmXF3.mjs.map → sync-CbeKrnQV.mjs.map} +1 -1
  70. package/dist/{sync-BOS0jKLn.cjs → sync-DdkKaHqP.cjs} +1 -1
  71. package/dist/{sync-BOS0jKLn.cjs.map → sync-DdkKaHqP.cjs.map} +1 -1
  72. package/dist/sync-RsnjXYwG.cjs +4 -0
  73. package/dist/{types-eTlj5f2M.d.mts → types-C7QJJl9f.d.cts} +6 -2
  74. package/dist/types-C7QJJl9f.d.cts.map +1 -0
  75. package/dist/{types-l53qUmGt.d.cts → types-Iqsq_FIG.d.mts} +6 -2
  76. package/dist/types-Iqsq_FIG.d.mts.map +1 -0
  77. package/dist/workspace/index.cjs +1 -1
  78. package/dist/workspace/index.d.cts +2 -2
  79. package/dist/workspace/index.d.mts +2 -2
  80. package/dist/workspace/index.mjs +1 -1
  81. package/dist/{workspace-D2ocAlpl.cjs → workspace-4SP3Gx4Y.cjs} +11 -3
  82. package/dist/{workspace-D2ocAlpl.cjs.map → workspace-4SP3Gx4Y.cjs.map} +1 -1
  83. package/dist/{workspace-9IQIjwkQ.mjs → workspace-D4z4A4cq.mjs} +11 -3
  84. package/dist/{workspace-9IQIjwkQ.mjs.map → workspace-D4z4A4cq.mjs.map} +1 -1
  85. package/package.json +2 -2
  86. package/src/build/__tests__/manifests.spec.ts +171 -0
  87. package/src/build/__tests__/partitions.spec.ts +110 -0
  88. package/src/build/index.ts +58 -15
  89. package/src/build/manifests.ts +153 -32
  90. package/src/build/partitions.ts +58 -0
  91. package/src/deploy/sniffer.ts +6 -1
  92. package/src/dev/__tests__/index.spec.ts +49 -0
  93. package/src/dev/index.ts +84 -63
  94. package/src/generators/Generator.ts +27 -7
  95. package/src/generators/OpenApiTsGenerator.ts +4 -4
  96. package/src/index.ts +79 -1
  97. package/src/init/versions.ts +4 -4
  98. package/src/openapi.ts +2 -1
  99. package/src/secrets/__tests__/reconcile.spec.ts +123 -0
  100. package/src/secrets/reconcile.ts +53 -0
  101. package/src/setup/fullstack-secrets.ts +2 -0
  102. package/src/types.ts +17 -1
  103. package/src/workspace/client-generator.ts +6 -3
  104. package/src/workspace/schema.ts +13 -3
  105. package/dist/index-BVNXOydm.d.mts.map +0 -1
  106. package/dist/index-Cyk2rTyj.d.cts.map +0 -1
  107. package/dist/openapi-BYlyAbH3.mjs.map +0 -1
  108. package/dist/openapi-CnvwSRDU.cjs.map +0 -1
  109. package/dist/sync-BxFB34zW.cjs +0 -4
  110. package/dist/types-eTlj5f2M.d.mts.map +0 -1
  111. package/dist/types-l53qUmGt.d.cts.map +0 -1
@@ -0,0 +1,58 @@
1
+ import type { Construct } from '@geekmidas/constructs';
2
+ import type { GeneratedConstruct } from '../generators/Generator';
3
+
4
+ export const DEFAULT_PARTITION = 'default';
5
+
6
+ /**
7
+ * Group constructs by their partition field.
8
+ * Constructs without a partition are placed in the 'default' group.
9
+ */
10
+ export function groupByPartition<T extends Construct>(
11
+ constructs: GeneratedConstruct<T>[],
12
+ ): Map<string, GeneratedConstruct<T>[]> {
13
+ const groups = new Map<string, GeneratedConstruct<T>[]>();
14
+
15
+ for (const construct of constructs) {
16
+ const partition = construct.partition ?? DEFAULT_PARTITION;
17
+ const group = groups.get(partition);
18
+ if (group) {
19
+ group.push(construct);
20
+ } else {
21
+ groups.set(partition, [construct]);
22
+ }
23
+ }
24
+
25
+ return groups;
26
+ }
27
+
28
+ /**
29
+ * Check if any construct across the given arrays has a non-undefined partition.
30
+ * When true, the manifest should use the partitioned shape for that construct type.
31
+ */
32
+ export function hasPartitions<T extends Construct>(
33
+ constructs: GeneratedConstruct<T>[],
34
+ ): boolean {
35
+ return constructs.some((c) => c.partition !== undefined);
36
+ }
37
+
38
+ /**
39
+ * Group an info array by partition, using the partition values from
40
+ * the corresponding construct array. Both arrays must be the same length
41
+ * and in the same order.
42
+ */
43
+ export function groupInfosByPartition<T>(
44
+ infos: T[],
45
+ constructs: GeneratedConstruct<any>[],
46
+ ): Record<string, T[]> {
47
+ const groups: Record<string, T[]> = {};
48
+
49
+ for (let i = 0; i < infos.length; i++) {
50
+ const partition = constructs[i]?.partition ?? DEFAULT_PARTITION;
51
+ if (!groups[partition]) {
52
+ groups[partition] = [];
53
+ }
54
+ groups[partition].push(infos[i]!);
55
+ }
56
+
57
+ return groups;
58
+ }
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module';
4
4
  import { dirname, resolve } from 'node:path';
5
5
  import { fileURLToPath, pathToFileURL } from 'node:url';
6
6
  import type { SniffResult } from '@geekmidas/envkit/sniffer';
7
+ import { normalizeRoutes } from '../workspace/client-generator.js';
7
8
  import type { NormalizedAppConfig } from '../workspace/types.js';
8
9
 
9
10
  const __filename = fileURLToPath(import.meta.url);
@@ -155,7 +156,11 @@ export async function sniffAppEnvironment(
155
156
 
156
157
  // 4. Route-based apps - load routes and call getEnvironment() on each construct
157
158
  if (app.routes) {
158
- const result = await sniffRouteFiles(app.routes, app.path, workspacePath);
159
+ const result = await sniffRouteFiles(
160
+ normalizeRoutes(app.routes),
161
+ app.path,
162
+ workspacePath,
163
+ );
159
164
 
160
165
  if (logWarnings && result.error) {
161
166
  console.warn(
@@ -12,6 +12,7 @@ import {
12
12
  checkPortConflicts,
13
13
  findAvailablePort,
14
14
  generateAllDependencyEnvVars,
15
+ generateServerEntryContent,
15
16
  isPortAvailable,
16
17
  loadPortState,
17
18
  loadSecretsForApp,
@@ -1678,3 +1679,51 @@ services:
1678
1679
  expect(mappings).toEqual([]);
1679
1680
  });
1680
1681
  });
1682
+
1683
+ describe('generateServerEntryContent', () => {
1684
+ it('should use dynamic import for createApp when secrets are provided', () => {
1685
+ const content = generateServerEntryContent({
1686
+ secretsJsonPath: '/tmp/dev-secrets.json',
1687
+ });
1688
+
1689
+ // createApp must be a dynamic import so Credentials are populated first
1690
+ expect(content).toContain("await import('./app.js')");
1691
+ // Must NOT have a static import of createApp
1692
+ expect(content).not.toMatch(/^import.*createApp/m);
1693
+ });
1694
+
1695
+ it('should use dynamic import for createApp without secrets', () => {
1696
+ const content = generateServerEntryContent({});
1697
+
1698
+ expect(content).toContain("await import('./app.js')");
1699
+ expect(content).not.toMatch(/^import.*createApp/m);
1700
+ });
1701
+
1702
+ it('should inject Credentials assignment before dynamic import', () => {
1703
+ const content = generateServerEntryContent({
1704
+ secretsJsonPath: '/tmp/dev-secrets.json',
1705
+ });
1706
+
1707
+ const credentialsAssignIdx = content.indexOf('Object.assign(Credentials');
1708
+ const dynamicImportIdx = content.indexOf("await import('./app.js')");
1709
+
1710
+ expect(credentialsAssignIdx).toBeGreaterThan(-1);
1711
+ expect(dynamicImportIdx).toBeGreaterThan(-1);
1712
+ expect(credentialsAssignIdx).toBeLessThan(dynamicImportIdx);
1713
+ });
1714
+
1715
+ it('should not include credentials injection when no secrets path', () => {
1716
+ const content = generateServerEntryContent({});
1717
+
1718
+ expect(content).not.toContain('Object.assign(Credentials');
1719
+ expect(content).not.toContain('existsSync');
1720
+ });
1721
+
1722
+ it('should use custom app import path when provided', () => {
1723
+ const content = generateServerEntryContent({
1724
+ appImportPath: './custom-app.js',
1725
+ });
1726
+
1727
+ expect(content).toContain("await import('./custom-app.js')");
1728
+ });
1729
+ });
package/src/dev/index.ts CHANGED
@@ -1832,6 +1832,85 @@ class EntryRunner {
1832
1832
  }
1833
1833
  }
1834
1834
 
1835
+ /**
1836
+ * Generate the content of the dev server entry file (server.ts).
1837
+ * Uses dynamic import for createApp so Credentials are populated
1838
+ * before any app modules evaluate.
1839
+ * @internal Exported for testing
1840
+ */
1841
+ export function generateServerEntryContent(options: {
1842
+ secretsJsonPath?: string;
1843
+ runtime?: Runtime;
1844
+ enableOpenApi?: boolean;
1845
+ appImportPath?: string;
1846
+ }): string {
1847
+ const {
1848
+ secretsJsonPath,
1849
+ runtime = 'node',
1850
+ enableOpenApi = false,
1851
+ appImportPath = './app.js',
1852
+ } = options;
1853
+
1854
+ const credentialsInjection = secretsJsonPath
1855
+ ? `import { Credentials } from '@geekmidas/envkit/credentials';
1856
+ import { existsSync, readFileSync } from 'node:fs';
1857
+
1858
+ // Inject dev secrets into Credentials (must happen before app import)
1859
+ const secretsPath = '${secretsJsonPath}';
1860
+ if (existsSync(secretsPath)) {
1861
+ Object.assign(Credentials, JSON.parse(readFileSync(secretsPath, 'utf-8')));
1862
+ }
1863
+
1864
+ `
1865
+ : '';
1866
+
1867
+ const serveCode =
1868
+ runtime === 'bun'
1869
+ ? `Bun.serve({
1870
+ port,
1871
+ fetch: app.fetch,
1872
+ });`
1873
+ : `const { serve } = await import('@hono/node-server');
1874
+ const server = serve({
1875
+ fetch: app.fetch,
1876
+ port,
1877
+ });
1878
+ // Inject WebSocket support if available
1879
+ const injectWs = (app as any).__injectWebSocket;
1880
+ if (injectWs) {
1881
+ injectWs(server);
1882
+ console.log('🔌 Telescope real-time updates enabled');
1883
+ }`;
1884
+
1885
+ return `#!/usr/bin/env node
1886
+ /**
1887
+ * Development server entry point
1888
+ * This file is auto-generated by 'gkm dev'
1889
+ */
1890
+ ${credentialsInjection}
1891
+ const port = process.argv.includes('--port')
1892
+ ? Number.parseInt(process.argv[process.argv.indexOf('--port') + 1])
1893
+ : 3000;
1894
+
1895
+ // Dynamic import so Credentials are populated before env.ts evaluates
1896
+ const { createApp } = await import('${appImportPath}');
1897
+
1898
+ // createApp is async to support optional WebSocket setup
1899
+ const { app, start } = await createApp(undefined, ${enableOpenApi});
1900
+
1901
+ // Start the server
1902
+ start({
1903
+ port,
1904
+ serve: async (app, port) => {
1905
+ ${serveCode}
1906
+ },
1907
+ }).catch((error) => {
1908
+ console.error('Failed to start server:', error);
1909
+ process.exit(1);
1910
+ });
1911
+ `;
1912
+ }
1913
+
1835
1914
  class DevServer {
1836
1915
  private serverProcess: ChildProcess | null = null;
1837
1916
  private isRunning = false;
@@ -1997,72 +2076,14 @@ class DevServer {
1997
2076
 
1998
2077
  private async createServerEntry(): Promise<void> {
1999
2078
  const { writeFile: fsWriteFile } = await import('node:fs/promises');
2000
- const { relative, dirname } = await import('node:path');
2001
2079
 
2002
2080
  const serverPath = join(this.appRoot, '.gkm', this.provider, 'server.ts');
2003
2081
 
2004
- const relativeAppPath = relative(
2005
- dirname(serverPath),
2006
- join(dirname(serverPath), 'app.js'),
2007
- );
2008
-
2009
- // Generate credentials injection code if secrets are available
2010
- const credentialsInjection = this.secretsJsonPath
2011
- ? `import { Credentials } from '@geekmidas/envkit/credentials';
2012
- import { existsSync, readFileSync } from 'node:fs';
2013
-
2014
- // Inject dev secrets into Credentials (must happen before app import)
2015
- const secretsPath = '${this.secretsJsonPath}';
2016
- if (existsSync(secretsPath)) {
2017
- Object.assign(Credentials, JSON.parse(readFileSync(secretsPath, 'utf-8')));
2018
- }
2019
-
2020
- `
2021
- : '';
2022
-
2023
- const serveCode =
2024
- this.runtime === 'bun'
2025
- ? `Bun.serve({
2026
- port,
2027
- fetch: app.fetch,
2028
- });`
2029
- : `const { serve } = await import('@hono/node-server');
2030
- const server = serve({
2031
- fetch: app.fetch,
2032
- port,
2033
- });
2034
- // Inject WebSocket support if available
2035
- const injectWs = (app as any).__injectWebSocket;
2036
- if (injectWs) {
2037
- injectWs(server);
2038
- console.log('🔌 Telescope real-time updates enabled');
2039
- }`;
2040
-
2041
- const content = `#!/usr/bin/env node
2042
- /**
2043
- * Development server entry point
2044
- * This file is auto-generated by 'gkm dev'
2045
- */
2046
- ${credentialsInjection}import { createApp } from './${relativeAppPath.startsWith('.') ? relativeAppPath : `./${relativeAppPath}`}';
2047
-
2048
- const port = process.argv.includes('--port')
2049
- ? Number.parseInt(process.argv[process.argv.indexOf('--port') + 1])
2050
- : 3000;
2051
-
2052
- // createApp is async to support optional WebSocket setup
2053
- const { app, start } = await createApp(undefined, ${this.enableOpenApi});
2054
-
2055
- // Start the server
2056
- start({
2057
- port,
2058
- serve: async (app, port) => {
2059
- ${serveCode}
2060
- },
2061
- }).catch((error) => {
2062
- console.error('Failed to start server:', error);
2063
- process.exit(1);
2064
- });
2065
- `;
2082
+ const content = generateServerEntryContent({
2083
+ secretsJsonPath: this.secretsJsonPath,
2084
+ runtime: this.runtime,
2085
+ enableOpenApi: this.enableOpenApi,
2086
+ });
2066
2087
 
2067
2088
  await fsWriteFile(serverPath, content);
2068
2089
  }
@@ -3,7 +3,11 @@ import type { Construct } from '@geekmidas/constructs';
3
3
  import fg from 'fast-glob';
4
4
  import kebabCase from 'lodash.kebabcase';
5
5
  import type { BuildContext } from '../build/types';
6
- import type { LegacyProvider, Routes } from '../types';
6
+ import {
7
+ isPartitionedRoutes,
8
+ type LegacyProvider,
9
+ type Routes,
10
+ } from '../types';
7
11
 
8
12
  export interface GeneratorOptions {
9
13
  provider?: LegacyProvider;
@@ -38,12 +42,22 @@ export abstract class ConstructGenerator<T extends Construct, R = void> {
38
42
  ): Promise<GeneratedConstruct<T>[]> {
39
43
  const logger = console;
40
44
 
41
- // Normalize patterns to array
42
- const globPatterns = Array.isArray(patterns)
43
- ? patterns
44
- : patterns
45
- ? [patterns]
46
- : [];
45
+ // Extract glob patterns and optional partition function
46
+ let globPatterns: string[];
47
+ let partitionFn: ((filepath: string) => string) | undefined;
48
+
49
+ if (isPartitionedRoutes(patterns)) {
50
+ globPatterns = Array.isArray(patterns.paths)
51
+ ? patterns.paths
52
+ : [patterns.paths];
53
+ partitionFn = patterns.partition;
54
+ } else {
55
+ globPatterns = Array.isArray(patterns)
56
+ ? patterns
57
+ : patterns
58
+ ? [patterns]
59
+ : [];
60
+ }
47
61
 
48
62
  // Find all files
49
63
  const files = fg.stream(globPatterns, {
@@ -61,6 +75,9 @@ export abstract class ConstructGenerator<T extends Construct, R = void> {
61
75
  const importPath = bustCache ? `${file}?t=${Date.now()}` : file;
62
76
  const module = await import(importPath);
63
77
 
78
+ // Compute partition name for this file (if partition function provided)
79
+ const partition = partitionFn ? partitionFn(file) : undefined;
80
+
64
81
  // Check all exports for constructs
65
82
  for (const [key, construct] of Object.entries(module)) {
66
83
  if (this.isConstruct(construct)) {
@@ -72,6 +89,7 @@ export abstract class ConstructGenerator<T extends Construct, R = void> {
72
89
  absolute: file,
73
90
  relative: relative(process.cwd(), file),
74
91
  },
92
+ partition,
75
93
  });
76
94
  }
77
95
  }
@@ -95,4 +113,6 @@ export interface GeneratedConstruct<T extends Construct> {
95
113
  absolute: string;
96
114
  relative: string;
97
115
  };
116
+ /** Partition name assigned by the partition function, if configured. */
117
+ partition?: string;
98
118
  }
@@ -681,7 +681,7 @@ export function createApi(options: CreateApiOptions) {
681
681
  // API Client Factory
682
682
  // ============================================================
683
683
 
684
- import { TypedFetcher, type FetcherOptions } from '@geekmidas/client/fetcher';
684
+ import { createTypedFetcher, type FetcherOptions } from '@geekmidas/client/fetcher';
685
685
  import { createEndpointHooks } from '@geekmidas/client/endpoint-hooks';
686
686
  import type { QueryClient } from '@tanstack/react-query';
687
687
 
@@ -713,11 +713,11 @@ export interface CreateApiOptions extends Omit<FetcherOptions, 'baseURL'> {
713
713
  */
714
714
  export function createApi(options: CreateApiOptions) {
715
715
  const { queryClient, ...fetcherOptions } = options;
716
- const fetcher = new TypedFetcher<paths>(fetcherOptions);
716
+ const request = createTypedFetcher<paths>(fetcherOptions);
717
717
 
718
- const hooks = createEndpointHooks<paths>(fetcher.request.bind(fetcher), { queryClient });
718
+ const hooks = createEndpointHooks<paths>(request, { queryClient });
719
719
 
720
- return Object.assign(fetcher.request.bind(fetcher), hooks);
720
+ return Object.assign(request, hooks);
721
721
  }
722
722
  `;
723
723
 
package/src/index.ts CHANGED
@@ -518,8 +518,27 @@ program
518
518
 
519
519
  const { loadWorkspaceConfig } = await import('./config');
520
520
  const { pushSecrets } = await import('./secrets/sync');
521
+ const { reconcileMissingSecrets } = await import('./secrets/reconcile');
522
+ const { readStageSecrets, writeStageSecrets } = await import(
523
+ './secrets/storage'
524
+ );
521
525
 
522
526
  const { workspace } = await loadWorkspaceConfig();
527
+
528
+ const secrets = await readStageSecrets(options.stage, workspace.root);
529
+ if (secrets) {
530
+ const result = reconcileMissingSecrets(secrets, workspace);
531
+ if (result) {
532
+ await writeStageSecrets(result.secrets, workspace.root);
533
+ console.log(
534
+ ` Reconciled ${result.addedKeys.length} missing secret(s):`,
535
+ );
536
+ for (const key of result.addedKeys) {
537
+ console.log(` + ${key}`);
538
+ }
539
+ }
540
+ }
541
+
523
542
  await pushSecrets(options.stage, workspace);
524
543
  console.log(`\n✓ Secrets pushed for stage "${options.stage}"`);
525
544
  } catch (error) {
@@ -542,15 +561,27 @@ program
542
561
  const { loadWorkspaceConfig } = await import('./config');
543
562
  const { pullSecrets } = await import('./secrets/sync');
544
563
  const { writeStageSecrets } = await import('./secrets/storage');
564
+ const { reconcileMissingSecrets } = await import('./secrets/reconcile');
545
565
 
546
566
  const { workspace } = await loadWorkspaceConfig();
547
- const secrets = await pullSecrets(options.stage, workspace);
567
+ let secrets = await pullSecrets(options.stage, workspace);
548
568
 
549
569
  if (!secrets) {
550
570
  console.error(`No remote secrets found for stage "${options.stage}".`);
551
571
  process.exit(1);
552
572
  }
553
573
 
574
+ const result = reconcileMissingSecrets(secrets, workspace);
575
+ if (result) {
576
+ secrets = result.secrets;
577
+ console.log(
578
+ ` Reconciled ${result.addedKeys.length} missing secret(s):`,
579
+ );
580
+ for (const key of result.addedKeys) {
581
+ console.log(` + ${key}`);
582
+ }
583
+ }
584
+
554
585
  await writeStageSecrets(secrets, workspace.root);
555
586
  console.log(`\n✓ Secrets pulled for stage "${options.stage}"`);
556
587
  } catch (error) {
@@ -559,6 +590,53 @@ program
559
590
  }
560
591
  });
561
592
 
593
+ program
594
+ .command('secrets:reconcile')
595
+ .description('Backfill missing custom secrets from workspace config')
596
+ .option('--stage <stage>', 'Stage name', 'development')
597
+ .action(async (options: { stage: string }) => {
598
+ try {
599
+ const globalOptions = program.opts();
600
+ if (globalOptions.cwd) {
601
+ process.chdir(globalOptions.cwd);
602
+ }
603
+
604
+ const { loadWorkspaceConfig } = await import('./config');
605
+ const { reconcileMissingSecrets } = await import('./secrets/reconcile');
606
+ const { readStageSecrets, writeStageSecrets } = await import(
607
+ './secrets/storage'
608
+ );
609
+
610
+ const { workspace } = await loadWorkspaceConfig();
611
+ const secrets = await readStageSecrets(options.stage, workspace.root);
612
+
613
+ if (!secrets) {
614
+ console.error(
615
+ `No secrets found for stage "${options.stage}". Run "gkm secrets:init --stage ${options.stage}" first.`,
616
+ );
617
+ process.exit(1);
618
+ }
619
+
620
+ const result = reconcileMissingSecrets(secrets, workspace);
621
+
622
+ if (!result) {
623
+ console.log(`\n✓ Secrets for stage "${options.stage}" are up-to-date`);
624
+ return;
625
+ }
626
+
627
+ await writeStageSecrets(result.secrets, workspace.root);
628
+ console.log(
629
+ `\n✓ Reconciled ${result.addedKeys.length} missing secret(s) for stage "${options.stage}":`,
630
+ );
631
+ for (const key of result.addedKeys) {
632
+ console.log(` + ${key}`);
633
+ }
634
+ } catch (error) {
635
+ console.error(error instanceof Error ? error.message : 'Command failed');
636
+ process.exit(1);
637
+ }
638
+ });
639
+
562
640
  // Deploy command
563
641
  program
564
642
  .command('deploy')
@@ -30,14 +30,14 @@ export const GEEKMIDAS_VERSIONS = {
30
30
  '@geekmidas/audit': '~1.0.0',
31
31
  '@geekmidas/auth': '~1.0.0',
32
32
  '@geekmidas/cache': '~1.0.0',
33
- '@geekmidas/client': '~2.0.0',
33
+ '@geekmidas/client': '~3.0.0',
34
34
  '@geekmidas/cloud': '~1.0.0',
35
- '@geekmidas/constructs': '~1.1.1',
35
+ '@geekmidas/constructs': '~2.0.0',
36
36
  '@geekmidas/db': '~1.0.0',
37
37
  '@geekmidas/emailkit': '~1.0.0',
38
38
  '@geekmidas/envkit': '~1.0.3',
39
39
  '@geekmidas/errors': '~1.0.0',
40
- '@geekmidas/events': '~1.0.0',
40
+ '@geekmidas/events': '~1.1.0',
41
41
  '@geekmidas/logger': '~1.0.0',
42
42
  '@geekmidas/rate-limit': '~1.0.0',
43
43
  '@geekmidas/schema': '~1.0.0',
@@ -45,7 +45,7 @@ export const GEEKMIDAS_VERSIONS = {
45
45
  '@geekmidas/storage': '~1.0.0',
46
46
  '@geekmidas/studio': '~1.0.0',
47
47
  '@geekmidas/telescope': '~1.0.0',
48
- '@geekmidas/testkit': '~1.0.1',
48
+ '@geekmidas/testkit': '~1.0.2',
49
49
  '@geekmidas/cli': CLI_VERSION,
50
50
  } as const;
51
51
 
package/src/openapi.ts CHANGED
@@ -6,6 +6,7 @@ import { loadWorkspaceConfig } from './config.js';
6
6
  import { EndpointGenerator } from './generators/EndpointGenerator.js';
7
7
  import { OpenApiTsGenerator } from './generators/OpenApiTsGenerator.js';
8
8
  import type { GkmConfig, OpenApiConfig } from './types.js';
9
+ import { normalizeRoutes } from './workspace/client-generator.js';
9
10
 
10
11
  interface OpenAPIOptions {
11
12
  cwd?: string;
@@ -141,7 +142,7 @@ export async function openapiCommand(
141
142
  if (app.type !== 'backend' || !app.routes) continue;
142
143
 
143
144
  const appPath = join(workspaceRoot, app.path);
144
- const routes = Array.isArray(app.routes) ? app.routes : [app.routes];
145
+ const routes = normalizeRoutes(app.routes);
145
146
  const routesGlob = routes.map((r) => join(appPath, r));
146
147
 
147
148
  const gkmConfig: GkmConfig = {
@@ -0,0 +1,123 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { generateFullstackCustomSecrets } from '../../setup/fullstack-secrets';
3
+ import type { NormalizedWorkspace } from '../../workspace/types';
4
+ import { reconcileMissingSecrets } from '../reconcile';
5
+ import type { StageSecrets } from '../types';
6
+
7
+ function createMultiAppWorkspace(
8
+ overrides?: Partial<NormalizedWorkspace>,
9
+ ): NormalizedWorkspace {
10
+ return {
11
+ name: 'test-project',
12
+ root: '/tmp/test',
13
+ apps: {
14
+ api: {
15
+ type: 'backend',
16
+ path: 'apps/api',
17
+ port: 3000,
18
+ dependencies: [],
19
+ resolvedDeployTarget: 'dokploy',
20
+ },
21
+ web: {
22
+ type: 'frontend',
23
+ path: 'apps/web',
24
+ port: 3001,
25
+ dependencies: ['api'],
26
+ resolvedDeployTarget: 'dokploy',
27
+ framework: 'nextjs',
28
+ },
29
+ },
30
+ services: { db: true },
31
+ deploy: {},
32
+ shared: {},
33
+ secrets: {},
34
+ ...overrides,
35
+ } as NormalizedWorkspace;
36
+ }
37
+
38
+ function createSecrets(custom: Record<string, string> = {}): StageSecrets {
39
+ return {
40
+ stage: 'development',
41
+ createdAt: '2024-01-01T00:00:00.000Z',
42
+ updatedAt: '2024-01-01T00:00:00.000Z',
43
+ services: {},
44
+ urls: {},
45
+ custom,
46
+ };
47
+ }
48
+
49
+ describe('reconcileMissingSecrets', () => {
50
+ it('should return null for single-app workspace', () => {
51
+ const workspace = createMultiAppWorkspace({
52
+ apps: {
53
+ api: {
54
+ type: 'backend',
55
+ path: 'apps/api',
56
+ port: 3000,
57
+ dependencies: [],
58
+ resolvedDeployTarget: 'dokploy',
59
+ },
60
+ },
61
+ }) as NormalizedWorkspace;
62
+
63
+ const result = reconcileMissingSecrets(createSecrets(), workspace);
64
+ expect(result).toBeNull();
65
+ });
66
+
67
+ it('should return null when no keys are missing', () => {
68
+ const workspace = createMultiAppWorkspace();
69
+ const expected = generateFullstackCustomSecrets(workspace);
70
+
71
+ const result = reconcileMissingSecrets(createSecrets(expected), workspace);
72
+ expect(result).toBeNull();
73
+ });
74
+
75
+ it('should backfill missing keys', () => {
76
+ const workspace = createMultiAppWorkspace();
77
+ const secrets = createSecrets();
78
+
79
+ const result = reconcileMissingSecrets(secrets, workspace);
80
+
81
+ expect(result).not.toBeNull();
82
+ expect(result!.addedKeys.length).toBeGreaterThan(0);
83
+ for (const key of result!.addedKeys) {
84
+ expect(result!.secrets.custom[key]).toBeDefined();
85
+ }
86
+ });
87
+
88
+ it('should never overwrite existing keys', () => {
89
+ const workspace = createMultiAppWorkspace();
90
+ const existingValue = 'my-custom-jwt-secret';
91
+ const secrets = createSecrets({
92
+ JWT_SECRET: existingValue,
93
+ });
94
+
95
+ const result = reconcileMissingSecrets(secrets, workspace);
96
+
97
+ expect(result).not.toBeNull();
98
+ expect(result!.secrets.custom.JWT_SECRET).toBe(existingValue);
99
+ expect(result!.addedKeys).not.toContain('JWT_SECRET');
100
+ });
101
+
102
+ it('should update updatedAt timestamp', () => {
103
+ const workspace = createMultiAppWorkspace();
104
+ const secrets = createSecrets();
105
+ const originalUpdatedAt = secrets.updatedAt;
106
+
107
+ const result = reconcileMissingSecrets(secrets, workspace);
108
+
109
+ expect(result).not.toBeNull();
110
+ expect(result!.secrets.updatedAt).not.toBe(originalUpdatedAt);
111
+ });
112
+
113
+ it('should backfill frontend URL keys', () => {
114
+ const workspace = createMultiAppWorkspace();
115
+ const secrets = createSecrets();
116
+
117
+ const result = reconcileMissingSecrets(secrets, workspace);
118
+
119
+ expect(result).not.toBeNull();
120
+ expect(result!.secrets.custom.WEB_URL).toBe('http://localhost:3001');
121
+ expect(result!.addedKeys).toContain('WEB_URL');
122
+ });
123
+ });