@geekmidas/cli 1.10.35 → 1.10.36

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 (63) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/dist/{config-Cuo8vFsp.cjs → config-QnuOcYXp.cjs} +2 -2
  3. package/dist/{config-Cuo8vFsp.cjs.map → config-QnuOcYXp.cjs.map} +1 -1
  4. package/dist/{config-B62g483e.mjs → config-U-gojtxn.mjs} +2 -2
  5. package/dist/{config-B62g483e.mjs.map → config-U-gojtxn.mjs.map} +1 -1
  6. package/dist/config.cjs +2 -2
  7. package/dist/config.d.cts +1 -1
  8. package/dist/config.d.mts +1 -1
  9. package/dist/config.mjs +2 -2
  10. package/dist/deploy/sniffer-loader.cjs +1 -1
  11. package/dist/{index-Dt_dZ7K4.d.mts → index-D7iT4dnv.d.mts} +41 -4
  12. package/dist/index-D7iT4dnv.d.mts.map +1 -0
  13. package/dist/{index-C3t5VL4R.d.cts → index-DRQq26DF.d.cts} +41 -4
  14. package/dist/index-DRQq26DF.d.cts.map +1 -0
  15. package/dist/index.cjs +1094 -30
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.mjs +1094 -30
  18. package/dist/index.mjs.map +1 -1
  19. package/dist/{openapi-CflxypuN.mjs → openapi-BA0e3I_s.mjs} +65 -15
  20. package/dist/openapi-BA0e3I_s.mjs.map +1 -0
  21. package/dist/{openapi-B_rJjImN.cjs → openapi-Bb4UEyZN.cjs} +65 -15
  22. package/dist/openapi-Bb4UEyZN.cjs.map +1 -0
  23. package/dist/openapi.cjs +3 -3
  24. package/dist/openapi.d.cts +7 -0
  25. package/dist/openapi.d.cts.map +1 -1
  26. package/dist/openapi.d.mts +7 -0
  27. package/dist/openapi.d.mts.map +1 -1
  28. package/dist/openapi.mjs +3 -3
  29. package/dist/workspace/index.cjs +4 -1
  30. package/dist/workspace/index.d.cts +2 -2
  31. package/dist/workspace/index.d.mts +2 -2
  32. package/dist/workspace/index.mjs +2 -2
  33. package/dist/{workspace-CGYykWfn.cjs → workspace-BobDOIG9.cjs} +74 -3
  34. package/dist/workspace-BobDOIG9.cjs.map +1 -0
  35. package/dist/{workspace-Bi4X7Yzy.mjs → workspace-Cgmvgwh8.mjs} +57 -4
  36. package/dist/workspace-Cgmvgwh8.mjs.map +1 -0
  37. package/package.json +4 -4
  38. package/src/__tests__/openapi.spec.ts +99 -9
  39. package/src/deploy/__tests__/env-resolver.spec.ts +28 -0
  40. package/src/deploy/env-resolver.ts +5 -10
  41. package/src/deploy/index.ts +13 -7
  42. package/src/deploy/sniffer.ts +10 -4
  43. package/src/dev/index.ts +29 -1
  44. package/src/index.ts +6 -2
  45. package/src/init/__tests__/generators.spec.ts +147 -0
  46. package/src/init/generators/mobile-expo.ts +576 -0
  47. package/src/init/generators/monorepo.ts +42 -11
  48. package/src/init/generators/web-tanstack.ts +348 -0
  49. package/src/init/index.ts +40 -3
  50. package/src/init/templates/index.ts +31 -0
  51. package/src/init/versions.ts +2 -2
  52. package/src/openapi.ts +110 -23
  53. package/src/workspace/__tests__/index.spec.ts +92 -0
  54. package/src/workspace/__tests__/publicEnv.spec.ts +64 -0
  55. package/src/workspace/index.ts +16 -2
  56. package/src/workspace/publicEnv.ts +59 -0
  57. package/src/workspace/types.ts +6 -1
  58. package/dist/index-C3t5VL4R.d.cts.map +0 -1
  59. package/dist/index-Dt_dZ7K4.d.mts.map +0 -1
  60. package/dist/openapi-B_rJjImN.cjs.map +0 -1
  61. package/dist/openapi-CflxypuN.mjs.map +0 -1
  62. package/dist/workspace-Bi4X7Yzy.mjs.map +0 -1
  63. package/dist/workspace-CGYykWfn.cjs.map +0 -1
@@ -0,0 +1,348 @@
1
+ import type { GeneratedFile, TemplateOptions } from '../templates/index.js';
2
+ import { GEEKMIDAS_VERSIONS } from '../versions.js';
3
+
4
+ /**
5
+ * Generate TanStack Start web app files for the fullstack template.
6
+ *
7
+ * Mirrors the Next.js scaffold (apps/web) but on Vite + TanStack Start +
8
+ * TanStack Router with the `VITE_` env-var convention.
9
+ */
10
+ export function generateTanStackWebFiles(
11
+ options: TemplateOptions,
12
+ ): GeneratedFile[] {
13
+ if (!options.monorepo || options.template !== 'fullstack') {
14
+ return [];
15
+ }
16
+
17
+ const packageName = `@${options.name}/web`;
18
+ const apiPackage = `@${options.name}/api`;
19
+ const modelsPackage = `@${options.name}/models`;
20
+ const uiPackage = `@${options.name}/ui`;
21
+
22
+ const packageJson = {
23
+ name: packageName,
24
+ version: '0.0.1',
25
+ private: true,
26
+ type: 'module',
27
+ scripts: {
28
+ dev: 'gkm exec -- vite dev',
29
+ build: 'gkm exec -- vite build',
30
+ start: 'node .output/server/index.mjs',
31
+ typecheck: 'tsc --noEmit',
32
+ },
33
+ dependencies: {
34
+ [apiPackage]: 'workspace:*',
35
+ [modelsPackage]: 'workspace:*',
36
+ [uiPackage]: 'workspace:*',
37
+ '@geekmidas/client': GEEKMIDAS_VERSIONS['@geekmidas/client'],
38
+ '@geekmidas/envkit': GEEKMIDAS_VERSIONS['@geekmidas/envkit'],
39
+ '@tanstack/react-query': '~5.80.0',
40
+ '@tanstack/react-router': '^1.87.0',
41
+ '@tanstack/react-start': '^1.87.0',
42
+ 'better-auth': '~1.2.0',
43
+ react: '~19.2.0',
44
+ 'react-dom': '~19.2.0',
45
+ },
46
+ devDependencies: {
47
+ '@geekmidas/cli': GEEKMIDAS_VERSIONS['@geekmidas/cli'],
48
+ '@tailwindcss/vite': '^4.0.0',
49
+ '@tanstack/router-plugin': '^1.87.0',
50
+ '@types/node': '~22.0.0',
51
+ '@types/react': '~19.0.0',
52
+ '@types/react-dom': '~19.0.0',
53
+ '@vitejs/plugin-react': '^4.3.4',
54
+ tailwindcss: '^4.0.0',
55
+ tsx: '~4.20.0',
56
+ typescript: '~5.8.2',
57
+ vite: '^7.0.0',
58
+ 'vite-tsconfig-paths': '~5.1.0',
59
+ },
60
+ };
61
+
62
+ const viteConfig = `import { Credentials } from '@geekmidas/envkit/credentials';
63
+ import tailwindcss from '@tailwindcss/vite';
64
+ import { tanstackStart } from '@tanstack/react-start/plugin/vite';
65
+ import viteReact from '@vitejs/plugin-react';
66
+ import { defineConfig } from 'vite';
67
+ import tsconfigPaths from 'vite-tsconfig-paths';
68
+
69
+ export default defineConfig({
70
+ server: {
71
+ port: Number(Credentials.PORT ?? 3001),
72
+ },
73
+ // VITE_* vars are inlined automatically; no manual loadEnv needed.
74
+ envPrefix: 'VITE_',
75
+ plugins: [
76
+ tsconfigPaths({ root: import.meta.dirname }),
77
+ tailwindcss(),
78
+ tanstackStart(),
79
+ viteReact(),
80
+ ],
81
+ });
82
+ `;
83
+
84
+ const tsConfig = {
85
+ extends: '../../tsconfig.json',
86
+ compilerOptions: {
87
+ lib: ['dom', 'dom.iterable', 'ES2022'],
88
+ allowJs: true,
89
+ skipLibCheck: true,
90
+ strict: true,
91
+ noEmit: true,
92
+ allowImportingTsExtensions: true,
93
+ esModuleInterop: true,
94
+ module: 'ESNext',
95
+ moduleResolution: 'bundler',
96
+ resolveJsonModule: true,
97
+ isolatedModules: true,
98
+ jsx: 'preserve',
99
+ incremental: true,
100
+ baseUrl: '.',
101
+ types: ['vite/client'],
102
+ paths: {
103
+ '~/*': ['./src/*', '../../packages/ui/src/*'],
104
+ [modelsPackage]: ['../../packages/models/src'],
105
+ [`${modelsPackage}/*`]: ['../../packages/models/src/*'],
106
+ [uiPackage]: ['../../packages/ui/src'],
107
+ [`${uiPackage}/*`]: ['../../packages/ui/src/*'],
108
+ [`${apiPackage}/client`]: ['../../apps/api/.gkm/openapi.ts'],
109
+ },
110
+ },
111
+ include: ['**/*.ts', '**/*.tsx', 'src/routeTree.gen.ts'],
112
+ exclude: ['node_modules', '.output', '.vinxi', 'dist'],
113
+ };
114
+
115
+ const queryClientTs = `import { QueryClient } from '@tanstack/react-query';
116
+
117
+ function makeQueryClient() {
118
+ return new QueryClient({
119
+ defaultOptions: {
120
+ queries: {
121
+ staleTime: 60 * 1000,
122
+ },
123
+ },
124
+ });
125
+ }
126
+
127
+ let browserQueryClient: QueryClient | undefined = undefined;
128
+
129
+ export function getQueryClient() {
130
+ if (typeof window === 'undefined') {
131
+ return makeQueryClient();
132
+ }
133
+ if (!browserQueryClient) browserQueryClient = makeQueryClient();
134
+ return browserQueryClient;
135
+ }
136
+ `;
137
+
138
+ const clientConfigTs = `import { EnvironmentParser } from '@geekmidas/envkit';
139
+
140
+ // Client config - VITE_* vars are inlined at build time.
141
+ const envParser = new EnvironmentParser({
142
+ VITE_API_URL: import.meta.env.VITE_API_URL,
143
+ VITE_AUTH_URL: import.meta.env.VITE_AUTH_URL,
144
+ });
145
+
146
+ export const clientConfig = envParser
147
+ .create((get) => ({
148
+ apiUrl: get('VITE_API_URL').string(),
149
+ authUrl: get('VITE_AUTH_URL').string(),
150
+ }))
151
+ .parse();
152
+ `;
153
+
154
+ const serverConfigTs = `import { EnvironmentParser } from '@geekmidas/envkit';
155
+
156
+ // Server config - all env vars (server-side only, not exposed to browser).
157
+ // Access these only in TanStack Start server functions / server routes.
158
+ const envParser = new EnvironmentParser({ ...process.env });
159
+
160
+ export const serverConfig = envParser
161
+ .create((_get) => ({
162
+ // Add server-only secrets here
163
+ // Example: stripeSecretKey: _get('STRIPE_SECRET_KEY').string(),
164
+ }))
165
+ .parse();
166
+ `;
167
+
168
+ const authClientTs = `import { magicLinkClient } from 'better-auth/client/plugins';
169
+ import { createAuthClient } from 'better-auth/react';
170
+ import { clientConfig } from '~/config/client.ts';
171
+
172
+ export const authClient = createAuthClient({
173
+ baseURL: clientConfig.authUrl,
174
+ plugins: [magicLinkClient()],
175
+ });
176
+
177
+ export const { signIn, signUp, signOut, useSession, magicLink } = authClient;
178
+ `;
179
+
180
+ const apiIndexTs = `import { createApi } from '${apiPackage}/client';
181
+ import { clientConfig } from '~/config/client.ts';
182
+ import { getQueryClient } from '~/lib/query-client.ts';
183
+
184
+ export function createAppApi(options?: { headers?: Record<string, string> }) {
185
+ return createApi({
186
+ baseURL: clientConfig.apiUrl,
187
+ queryClient: getQueryClient(),
188
+ headers: options?.headers,
189
+ onRequest: (config) => ({ ...config, credentials: 'include' }),
190
+ });
191
+ }
192
+
193
+ export const api = createAppApi();
194
+ `;
195
+
196
+ const routerTs = `import { createRouter } from '@tanstack/react-router';
197
+ import { routeTree } from './routeTree.gen.ts';
198
+
199
+ export function getRouter() {
200
+ return createRouter({
201
+ routeTree,
202
+ scrollRestoration: true,
203
+ defaultPreload: 'intent',
204
+ });
205
+ }
206
+
207
+ declare module '@tanstack/react-router' {
208
+ interface Register {
209
+ router: ReturnType<typeof getRouter>;
210
+ }
211
+ }
212
+ `;
213
+
214
+ // Placeholder routeTree.gen — TanStack's plugin regenerates this on dev.
215
+ const routeTreeGen = `// This file is auto-generated by @tanstack/router-plugin.
216
+ // It will be overwritten on the next \`vite dev\` run.
217
+ import { rootRouteWithContext } from '@tanstack/react-router';
218
+
219
+ export const routeTree = rootRouteWithContext()({
220
+ // Routes are populated by the plugin from src/routes/**.tsx
221
+ } as never);
222
+ `;
223
+
224
+ const rootRouteTsx = `/// <reference types="vite/client" />
225
+ import '~/styles/globals.css';
226
+
227
+ import { QueryClientProvider } from '@tanstack/react-query';
228
+ import {
229
+ HeadContent,
230
+ Outlet,
231
+ Scripts,
232
+ createRootRoute,
233
+ } from '@tanstack/react-router';
234
+ import type { ReactNode } from 'react';
235
+ import { getQueryClient } from '~/lib/query-client.ts';
236
+
237
+ export const Route = createRootRoute({
238
+ head: () => ({
239
+ meta: [
240
+ { charSet: 'utf-8' },
241
+ { name: 'viewport', content: 'width=device-width, initial-scale=1' },
242
+ { title: '${options.name}' },
243
+ ],
244
+ }),
245
+ component: RootComponent,
246
+ });
247
+
248
+ function RootComponent() {
249
+ const queryClient = getQueryClient();
250
+ return (
251
+ <RootDocument>
252
+ <QueryClientProvider client={queryClient}>
253
+ <Outlet />
254
+ </QueryClientProvider>
255
+ </RootDocument>
256
+ );
257
+ }
258
+
259
+ function RootDocument({ children }: { children: ReactNode }) {
260
+ return (
261
+ <html lang="en">
262
+ <head>
263
+ <HeadContent />
264
+ </head>
265
+ <body>
266
+ {children}
267
+ <Scripts />
268
+ </body>
269
+ </html>
270
+ );
271
+ }
272
+ `;
273
+
274
+ const indexRouteTsx = `import { createFileRoute } from '@tanstack/react-router';
275
+ import { api } from '~/api/index.ts';
276
+
277
+ export const Route = createFileRoute('/')({
278
+ component: HomeComponent,
279
+ });
280
+
281
+ function HomeComponent() {
282
+ const { data: health } = api.useQuery('GET /health', {});
283
+
284
+ return (
285
+ <main className="min-h-screen bg-background p-8">
286
+ <div className="mx-auto max-w-4xl space-y-8">
287
+ <div className="space-y-2">
288
+ <h1 className="text-4xl font-bold tracking-tight">
289
+ Welcome to ${options.name}
290
+ </h1>
291
+ <p className="text-muted-foreground">
292
+ Your TanStack Start app is ready.
293
+ </p>
294
+ </div>
295
+
296
+ <section className="rounded-lg border p-6">
297
+ <h2 className="text-xl font-semibold">API Status</h2>
298
+ {health ? (
299
+ <pre className="mt-4 rounded bg-muted p-4 text-sm">
300
+ {JSON.stringify(health, null, 2)}
301
+ </pre>
302
+ ) : (
303
+ <p className="mt-2 text-muted-foreground">Connecting…</p>
304
+ )}
305
+ </section>
306
+ </div>
307
+ </main>
308
+ );
309
+ }
310
+ `;
311
+
312
+ const globalsCss = `@import '${uiPackage}/styles';
313
+
314
+ @source "../..";
315
+ @source "../../../../packages/ui/src";
316
+ `;
317
+
318
+ const gitignore = `.output/
319
+ .vinxi/
320
+ node_modules/
321
+ .env.local
322
+ *.log
323
+ src/routeTree.gen.ts
324
+ `;
325
+
326
+ return [
327
+ {
328
+ path: 'apps/web/package.json',
329
+ content: `${JSON.stringify(packageJson, null, 2)}\n`,
330
+ },
331
+ { path: 'apps/web/vite.config.ts', content: viteConfig },
332
+ {
333
+ path: 'apps/web/tsconfig.json',
334
+ content: `${JSON.stringify(tsConfig, null, 2)}\n`,
335
+ },
336
+ { path: 'apps/web/src/lib/query-client.ts', content: queryClientTs },
337
+ { path: 'apps/web/src/lib/auth-client.ts', content: authClientTs },
338
+ { path: 'apps/web/src/config/client.ts', content: clientConfigTs },
339
+ { path: 'apps/web/src/config/server.ts', content: serverConfigTs },
340
+ { path: 'apps/web/src/api/index.ts', content: apiIndexTs },
341
+ { path: 'apps/web/src/router.tsx', content: routerTs },
342
+ { path: 'apps/web/src/routeTree.gen.ts', content: routeTreeGen },
343
+ { path: 'apps/web/src/routes/__root.tsx', content: rootRouteTsx },
344
+ { path: 'apps/web/src/routes/index.tsx', content: indexRouteTsx },
345
+ { path: 'apps/web/src/styles/globals.css', content: globalsCss },
346
+ { path: 'apps/web/.gitignore', content: gitignore },
347
+ ];
348
+ }
package/src/init/index.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  generateDockerFiles,
18
18
  } from './generators/docker.js';
19
19
  import { generateEnvFiles } from './generators/env.js';
20
+ import { generateExpoAppFiles } from './generators/mobile-expo.js';
20
21
  import { generateModelsPackage } from './generators/models.js';
21
22
  import { generateMonorepoFiles } from './generators/monorepo.js';
22
23
  import { generatePackageJson } from './generators/package.js';
@@ -24,10 +25,13 @@ import { generateSourceFiles } from './generators/source.js';
24
25
  import { generateTestFiles } from './generators/test.js';
25
26
  import { generateUiPackageFiles } from './generators/ui.js';
26
27
  import { generateWebAppFiles } from './generators/web.js';
28
+ import { generateTanStackWebFiles } from './generators/web-tanstack.js';
27
29
  import {
28
30
  type DeployTarget,
29
31
  deployTargetChoices,
30
32
  eventsBackendChoices,
33
+ type FullstackFrontendFramework,
34
+ frontendFrameworkChoices,
31
35
  getTemplate,
32
36
  isFullstackTemplate,
33
37
  loggerTypeChoices,
@@ -154,6 +158,18 @@ export async function initCommand(
154
158
  choices: routesStructureChoices,
155
159
  initial: 0,
156
160
  },
161
+ {
162
+ // Only prompt for frontend framework on the fullstack template.
163
+ type: (_prev, values) =>
164
+ !options.yes &&
165
+ (options.template === 'fullstack' || values.template === 'fullstack')
166
+ ? 'select'
167
+ : null,
168
+ name: 'frontendFramework',
169
+ message: 'Frontend framework:',
170
+ choices: frontendFrameworkChoices,
171
+ initial: 0,
172
+ },
157
173
  ],
158
174
  { onCancel },
159
175
  );
@@ -221,6 +237,11 @@ export async function initCommand(
221
237
  : (answers.deployTarget ?? 'dokploy');
222
238
 
223
239
  const database = services.db;
240
+ const frontendFramework: FullstackFrontendFramework | undefined = isFullstack
241
+ ? options.yes
242
+ ? 'nextjs'
243
+ : (answers.frontendFramework ?? 'nextjs')
244
+ : undefined;
224
245
  const templateOptions: TemplateOptions = {
225
246
  name,
226
247
  template,
@@ -236,6 +257,7 @@ export async function initCommand(
236
257
  packageManager: pkgManager,
237
258
  deployTarget,
238
259
  services,
260
+ frontendFramework,
239
261
  };
240
262
 
241
263
  const targetDir = join(cwd, name);
@@ -293,8 +315,14 @@ export async function initCommand(
293
315
  ]
294
316
  : [];
295
317
 
296
- // Collect web app files for fullstack template
297
- const webAppFiles = isFullstack ? generateWebAppFiles(templateOptions) : [];
318
+ // Collect frontend app files for fullstack template, dispatched by framework
319
+ const webAppFiles = isFullstack
320
+ ? frontendFramework === 'tanstack-start'
321
+ ? generateTanStackWebFiles(templateOptions)
322
+ : frontendFramework === 'expo'
323
+ ? generateExpoAppFiles(templateOptions)
324
+ : generateWebAppFiles(templateOptions)
325
+ : [];
298
326
 
299
327
  // Collect auth app files for fullstack template
300
328
  const authAppFiles = isFullstack ? generateAuthAppFiles(templateOptions) : [];
@@ -473,7 +501,16 @@ function printNextSteps(
473
501
  console.log(` │ ├── api/ # Backend API`);
474
502
  if (isFullstackTemplate(options.template)) {
475
503
  console.log(` │ ├── auth/ # Auth service (better-auth)`);
476
- console.log(` │ └── web/ # Next.js frontend`);
504
+ switch (options.frontendFramework) {
505
+ case 'tanstack-start':
506
+ console.log(` │ └── web/ # TanStack Start frontend`);
507
+ break;
508
+ case 'expo':
509
+ console.log(` │ └── app/ # Expo (React Native) app`);
510
+ break;
511
+ default:
512
+ console.log(` │ └── web/ # Next.js frontend`);
513
+ }
477
514
  }
478
515
  console.log(` ├── packages/`);
479
516
  console.log(` │ ├── models/ # Shared Zod schemas`);
@@ -47,6 +47,14 @@ export interface ServicesSelection {
47
47
  events?: import('../../types.js').EventsBackend;
48
48
  }
49
49
 
50
+ /**
51
+ * Frontend framework for the fullstack template's client app.
52
+ * Limited to the three with first-class scaffolds; the wider
53
+ * `FrontendFramework` type also covers `vite` and `remix` which
54
+ * users can configure manually after init.
55
+ */
56
+ export type FullstackFrontendFramework = 'nextjs' | 'tanstack-start' | 'expo';
57
+
50
58
  /**
51
59
  * Options collected from user prompts
52
60
  */
@@ -67,6 +75,8 @@ export interface TemplateOptions {
67
75
  deployTarget: DeployTarget;
68
76
  /** Services selection */
69
77
  services: ServicesSelection;
78
+ /** Frontend framework (fullstack template only) */
79
+ frontendFramework?: FullstackFrontendFramework;
70
80
  }
71
81
 
72
82
  /**
@@ -156,6 +166,27 @@ export const allTemplateChoices = [
156
166
  },
157
167
  ];
158
168
 
169
+ /**
170
+ * Frontend framework choices for the fullstack template prompt.
171
+ */
172
+ export const frontendFrameworkChoices = [
173
+ {
174
+ title: 'Next.js',
175
+ value: 'nextjs' as FullstackFrontendFramework,
176
+ description: 'React server components, app router (default)',
177
+ },
178
+ {
179
+ title: 'TanStack Start',
180
+ value: 'tanstack-start' as FullstackFrontendFramework,
181
+ description: 'File-based router on Vite + Nitro',
182
+ },
183
+ {
184
+ title: 'Expo',
185
+ value: 'expo' as FullstackFrontendFramework,
186
+ description: 'React Native mobile app (iOS + Android)',
187
+ },
188
+ ];
189
+
159
190
  /**
160
191
  * Logger type choices for prompts
161
192
  */
@@ -35,9 +35,9 @@ export const GEEKMIDAS_VERSIONS = {
35
35
  '@geekmidas/constructs': '~3.0.9',
36
36
  '@geekmidas/db': '~1.0.1',
37
37
  '@geekmidas/emailkit': '~1.0.0',
38
- '@geekmidas/envkit': '~1.0.5',
38
+ '@geekmidas/envkit': '~1.0.6',
39
39
  '@geekmidas/errors': '~1.0.0',
40
- '@geekmidas/events': '~1.1.0',
40
+ '@geekmidas/events': '~1.1.1',
41
41
  '@geekmidas/logger': '~1.0.1',
42
42
  '@geekmidas/rate-limit': '~2.0.0',
43
43
  '@geekmidas/schema': '~1.0.0',
package/src/openapi.ts CHANGED
@@ -1,15 +1,25 @@
1
1
  #!/usr/bin/env -S npx tsx
2
2
 
3
+ import { spawn } from 'node:child_process';
3
4
  import { mkdir, writeFile } from 'node:fs/promises';
4
5
  import { dirname, join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
5
7
  import { loadWorkspaceConfig } from './config.js';
6
8
  import { EndpointGenerator } from './generators/EndpointGenerator.js';
7
9
  import { OpenApiTsGenerator } from './generators/OpenApiTsGenerator.js';
8
10
  import type { GkmConfig, OpenApiConfig } from './types.js';
9
11
  import { normalizeRoutes } from './workspace/client-generator.js';
12
+ import type { NormalizedAppConfig } from './workspace/types.js';
10
13
 
11
14
  interface OpenAPIOptions {
12
15
  cwd?: string;
16
+ /**
17
+ * Workspace mode: generate for a single named backend app. When set,
18
+ * generation runs in-process against the current CWD. When unset,
19
+ * workspace mode spawns one subprocess per backend app so each gets a
20
+ * fresh tsx that loads the app's own tsconfig (path aliases included).
21
+ */
22
+ app?: string;
13
23
  }
14
24
 
15
25
  /**
@@ -132,37 +142,114 @@ export async function openapiCommand(
132
142
  return;
133
143
  }
134
144
 
135
- // Generate OpenAPI for each backend app
136
- for (const [appName, app] of backendApps) {
137
- if (app.type !== 'backend' || !app.routes) continue;
138
-
139
- const appPath = join(workspaceRoot, app.path);
140
- const routes = normalizeRoutes(app.routes);
141
- const routesGlob = routes.map((r) => join(appPath, r));
142
-
143
- const gkmConfig: GkmConfig = {
144
- routes: routesGlob,
145
- envParser: app.envParser || '',
146
- logger: app.logger || '',
147
- openapi: app.openapi,
148
- };
149
-
150
- // Change to app directory for generation
151
- const originalCwd = process.cwd();
152
- process.chdir(appPath);
153
-
154
- const result = await generateOpenApi(gkmConfig, { silent: true });
155
-
156
- process.chdir(originalCwd);
157
-
145
+ // Single-app mode: generate in-process. The caller is expected to
146
+ // have CWD set to the app's directory (e.g., when invoked as a
147
+ // subprocess), so tsx picks up the app's tsconfig path aliases.
148
+ if (options.app) {
149
+ const entry = backendApps.find(([name]) => name === options.app);
150
+ if (!entry) {
151
+ throw new Error(
152
+ `App "${options.app}" not found or has OpenAPI disabled`,
153
+ );
154
+ }
155
+ const [appName, app] = entry;
156
+ const result = await generateOpenApiForApp(workspaceRoot, appName, app);
158
157
  if (result) {
159
158
  logger.log(
160
159
  `📄 [${appName}] Generated OpenAPI (${result.endpointCount} endpoints)`,
161
160
  );
162
161
  }
162
+ return;
163
+ }
164
+
165
+ // Multi-app mode: spawn a subprocess per backend app. Each
166
+ // subprocess starts with CWD at the app's directory so that
167
+ // tsx's tsconfig discovery picks up the app's `paths` aliases
168
+ // (e.g., `~/*`) instead of the workspace root's tsconfig.
169
+ for (const [appName, app] of backendApps) {
170
+ if (app.type !== 'backend' || !app.routes) continue;
171
+ const appPath = join(workspaceRoot, app.path);
172
+ await runOpenApiInSubprocess(appPath, appName);
163
173
  }
164
174
  }
165
175
  } catch (error) {
166
176
  throw new Error(`OpenAPI generation failed: ${(error as Error).message}`);
167
177
  }
168
178
  }
179
+
180
+ /**
181
+ * Generate OpenAPI for a single named app within a workspace.
182
+ * Runs in-process. The caller is responsible for ensuring `process.cwd()`
183
+ * is the app's directory so tsx loads the app's tsconfig path aliases.
184
+ */
185
+ async function generateOpenApiForApp(
186
+ workspaceRoot: string,
187
+ _appName: string,
188
+ app: NormalizedAppConfig,
189
+ ): Promise<{ outputPath: string; endpointCount: number } | null> {
190
+ if (app.type !== 'backend' || !app.routes) {
191
+ return null;
192
+ }
193
+
194
+ const appPath = join(workspaceRoot, app.path);
195
+ const routes = normalizeRoutes(app.routes);
196
+ const routesGlob = routes.map((r) => join(appPath, r));
197
+
198
+ const gkmConfig: GkmConfig = {
199
+ routes: routesGlob,
200
+ envParser: app.envParser || '',
201
+ logger: app.logger || '',
202
+ openapi: app.openapi,
203
+ };
204
+
205
+ return generateOpenApi(gkmConfig, { silent: true });
206
+ }
207
+
208
+ /**
209
+ * Resolve the gkm bin path. Tests can override via `GKM_BIN_PATH` env var
210
+ * to avoid depending on the built dist.
211
+ */
212
+ function resolveGkmBinPath(): string {
213
+ if (process.env.GKM_BIN_PATH) {
214
+ return process.env.GKM_BIN_PATH;
215
+ }
216
+ return fileURLToPath(new URL('../bin/gkm.mjs', import.meta.url));
217
+ }
218
+
219
+ /**
220
+ * Spawn a subprocess that runs `gkm openapi --app <name>` with `cwd` set to
221
+ * the app's directory. Inherits `NODE_OPTIONS` (which already contains
222
+ * `--import tsx`), so the child gets a fresh tsx instance whose tsconfig
223
+ * discovery picks up the app's tsconfig path aliases.
224
+ */
225
+ async function runOpenApiInSubprocess(
226
+ appCwd: string,
227
+ appName: string,
228
+ ): Promise<void> {
229
+ const binPath = resolveGkmBinPath();
230
+
231
+ await new Promise<void>((resolve, reject) => {
232
+ const child = spawn(
233
+ process.execPath,
234
+ [binPath, 'openapi', '--app', appName],
235
+ {
236
+ cwd: appCwd,
237
+ stdio: 'inherit',
238
+ env: process.env,
239
+ },
240
+ );
241
+
242
+ child.on('error', reject);
243
+ child.on('close', (code) => {
244
+ if (code === 0) {
245
+ resolve();
246
+ } else {
247
+ reject(
248
+ new Error(
249
+ `OpenAPI generation for app "${appName}" exited with code ${code}`,
250
+ ),
251
+ );
252
+ }
253
+ });
254
+ });
255
+ }