@geekmidas/cli 1.10.35 → 1.10.37
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.
- package/CHANGELOG.md +20 -0
- package/dist/{config-B62g483e.mjs → config-Bbu1JKvf.mjs} +2 -2
- package/dist/{config-B62g483e.mjs.map → config-Bbu1JKvf.mjs.map} +1 -1
- package/dist/{config-Cuo8vFsp.cjs → config-DujV8DbR.cjs} +2 -2
- package/dist/{config-Cuo8vFsp.cjs.map → config-DujV8DbR.cjs.map} +1 -1
- package/dist/config.cjs +2 -2
- package/dist/config.d.cts +1 -1
- package/dist/config.d.mts +1 -1
- package/dist/config.mjs +2 -2
- package/dist/deploy/sniffer-loader.cjs +1 -1
- package/dist/{fullstack-secrets-Bchl2MDd.cjs → fullstack-secrets-BYzi6Hwl.cjs} +3 -2
- package/dist/{fullstack-secrets-Bchl2MDd.cjs.map → fullstack-secrets-BYzi6Hwl.cjs.map} +1 -1
- package/dist/{fullstack-secrets-P84v8zWn.mjs → fullstack-secrets-l8s3II_H.mjs} +3 -2
- package/dist/{fullstack-secrets-P84v8zWn.mjs.map → fullstack-secrets-l8s3II_H.mjs.map} +1 -1
- package/dist/{index-Dt_dZ7K4.d.mts → index-BBvGMG_A.d.mts} +162 -68
- package/dist/index-BBvGMG_A.d.mts.map +1 -0
- package/dist/{index-C3t5VL4R.d.cts → index-Dz2a7xQU.d.cts} +162 -68
- package/dist/index-Dz2a7xQU.d.cts.map +1 -0
- package/dist/index.cjs +1366 -149
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +1366 -149
- package/dist/index.mjs.map +1 -1
- package/dist/{openapi-B_rJjImN.cjs → openapi-0JUzU5Tv.cjs} +72 -15
- package/dist/openapi-0JUzU5Tv.cjs.map +1 -0
- package/dist/{openapi-CflxypuN.mjs → openapi-C5AycKTR.mjs} +73 -16
- package/dist/openapi-C5AycKTR.mjs.map +1 -0
- package/dist/openapi.cjs +3 -3
- package/dist/openapi.d.cts +7 -0
- package/dist/openapi.d.cts.map +1 -1
- package/dist/openapi.d.mts +7 -0
- package/dist/openapi.d.mts.map +1 -1
- package/dist/openapi.mjs +3 -3
- package/dist/{reconcile-DAAKa0YR.mjs → reconcile-CYGmqXvq.mjs} +2 -2
- package/dist/{reconcile-DAAKa0YR.mjs.map → reconcile-CYGmqXvq.mjs.map} +1 -1
- package/dist/{reconcile-CZtzdrnc.cjs → reconcile-D93YG7G5.cjs} +2 -2
- package/dist/{reconcile-CZtzdrnc.cjs.map → reconcile-D93YG7G5.cjs.map} +1 -1
- package/dist/workspace/index.cjs +4 -1
- package/dist/workspace/index.d.cts +2 -2
- package/dist/workspace/index.d.mts +2 -2
- package/dist/workspace/index.mjs +2 -2
- package/dist/{workspace-CGYykWfn.cjs → workspace-CQTT7HJQ.cjs} +107 -27
- package/dist/workspace-CQTT7HJQ.cjs.map +1 -0
- package/dist/{workspace-Bi4X7Yzy.mjs → workspace-D-37Ylqo.mjs} +90 -28
- package/dist/workspace-D-37Ylqo.mjs.map +1 -0
- package/docs/dev-server-resilience-design.md +189 -0
- package/package.json +4 -4
- package/src/__tests__/openapi.spec.ts +99 -9
- package/src/build/__tests__/workspace-build.spec.ts +7 -7
- package/src/build/index.ts +25 -9
- package/src/credentials/__tests__/helpers.ts +1 -1
- package/src/credentials/__tests__/workspaceCredentials.spec.ts +1 -1
- package/src/deploy/__tests__/domain.spec.ts +12 -12
- package/src/deploy/__tests__/env-resolver.spec.ts +29 -1
- package/src/deploy/__tests__/index.spec.ts +14 -14
- package/src/deploy/__tests__/sniffer.spec.ts +9 -9
- package/src/deploy/domain.ts +19 -11
- package/src/deploy/env-resolver.ts +5 -10
- package/src/deploy/index.ts +25 -8
- package/src/deploy/sniffer.ts +11 -5
- package/src/deploy/types.ts +1 -1
- package/src/dev/__tests__/index.spec.ts +11 -11
- package/src/dev/index.ts +43 -9
- package/src/docker/__tests__/compose.spec.ts +4 -4
- package/src/docker/compose.ts +5 -3
- package/src/docker/index.ts +34 -6
- package/src/docker/templates.ts +138 -0
- package/src/generators/OpenApiTsGenerator.ts +22 -0
- package/src/generators/__tests__/OpenApiTsGenerator.registry.spec.ts +77 -0
- package/src/index.ts +6 -2
- package/src/init/__tests__/generators.spec.ts +147 -0
- package/src/init/__tests__/init.spec.ts +1 -1
- package/src/init/generators/mobile-expo.ts +576 -0
- package/src/init/generators/monorepo.ts +42 -11
- package/src/init/generators/web-tanstack.ts +348 -0
- package/src/init/index.ts +40 -3
- package/src/init/templates/index.ts +31 -0
- package/src/init/versions.ts +2 -2
- package/src/openapi.ts +110 -23
- package/src/secrets/__tests__/reconcile.spec.ts +1 -1
- package/src/setup/__tests__/reconcile-secrets.spec.ts +2 -2
- package/src/setup/fullstack-secrets.ts +7 -1
- package/src/setup/index.ts +2 -1
- package/src/test/__tests__/__fixtures__/workspace.ts +1 -1
- package/src/test/__tests__/web.spec.ts +2 -2
- package/src/workspace/__tests__/client-generator.spec.ts +6 -6
- package/src/workspace/__tests__/index.spec.ts +96 -4
- package/src/workspace/__tests__/publicEnv.spec.ts +64 -0
- package/src/workspace/__tests__/schema.spec.ts +27 -30
- package/src/workspace/__tests__/type-inference.spec.ts +5 -5
- package/src/workspace/client-generator.ts +10 -7
- package/src/workspace/index.ts +17 -3
- package/src/workspace/publicEnv.ts +57 -0
- package/src/workspace/schema.ts +126 -27
- package/src/workspace/types.ts +40 -64
- package/dist/index-C3t5VL4R.d.cts.map +0 -1
- package/dist/index-Dt_dZ7K4.d.mts.map +0 -1
- package/dist/openapi-B_rJjImN.cjs.map +0 -1
- package/dist/openapi-CflxypuN.mjs.map +0 -1
- package/dist/workspace-Bi4X7Yzy.mjs.map +0 -1
- package/dist/workspace-CGYykWfn.cjs.map +0 -1
package/src/docker/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { copyFileSync, existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
|
3
3
|
import { mkdir, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { basename, join } from 'node:path';
|
|
5
5
|
import { loadConfig, loadWorkspaceConfig } from '../config';
|
|
6
|
+
import { getPublicUrlArgNames } from '../deploy/domain.js';
|
|
6
7
|
import type { NormalizedWorkspace } from '../workspace/types.js';
|
|
7
8
|
import {
|
|
8
9
|
generateDockerCompose,
|
|
@@ -18,7 +19,9 @@ import {
|
|
|
18
19
|
generateEntryDockerfile,
|
|
19
20
|
generateMultiStageDockerfile,
|
|
20
21
|
generateNextjsDockerfile,
|
|
22
|
+
generateNodeWebDockerfile,
|
|
21
23
|
generateSlimDockerfile,
|
|
24
|
+
generateViteStaticDockerfile,
|
|
22
25
|
hasTurboConfig,
|
|
23
26
|
isMonorepo,
|
|
24
27
|
resolveDockerConfig,
|
|
@@ -342,7 +345,7 @@ async function pushDockerImage(
|
|
|
342
345
|
*/
|
|
343
346
|
export interface AppDockerResult {
|
|
344
347
|
appName: string;
|
|
345
|
-
type: 'backend' | '
|
|
348
|
+
type: 'backend' | 'web' | 'mobile';
|
|
346
349
|
dockerfile: string;
|
|
347
350
|
imageName: string;
|
|
348
351
|
}
|
|
@@ -396,6 +399,15 @@ export async function workspaceDockerCommand(
|
|
|
396
399
|
|
|
397
400
|
// Generate Dockerfile for each app
|
|
398
401
|
for (const [appName, app] of apps) {
|
|
402
|
+
// Mobile apps deploy via their own toolchain (e.g. EAS Build for Expo)
|
|
403
|
+
// — no Docker image is produced.
|
|
404
|
+
if (app.type === 'mobile') {
|
|
405
|
+
logger.log(
|
|
406
|
+
`\n 📱 Skipping Docker for ${appName} (mobile app — deploy via framework toolchain)`,
|
|
407
|
+
);
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
|
|
399
411
|
const appPath = app.path;
|
|
400
412
|
const fullAppPath = join(workspace.root, appPath);
|
|
401
413
|
|
|
@@ -411,16 +423,31 @@ export async function workspaceDockerCommand(
|
|
|
411
423
|
|
|
412
424
|
let dockerfile: string;
|
|
413
425
|
|
|
414
|
-
if (app.type === '
|
|
415
|
-
|
|
416
|
-
|
|
426
|
+
if (app.type === 'web') {
|
|
427
|
+
const publicUrlArgs = getPublicUrlArgNames(app);
|
|
428
|
+
const webOpts = {
|
|
417
429
|
imageName,
|
|
418
430
|
baseImage: 'node:22-alpine',
|
|
419
431
|
port: app.port,
|
|
420
432
|
appPath,
|
|
421
433
|
turboPackage,
|
|
422
434
|
packageManager,
|
|
423
|
-
|
|
435
|
+
publicUrlArgs,
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
switch (app.framework) {
|
|
439
|
+
case 'vite':
|
|
440
|
+
dockerfile = generateViteStaticDockerfile(webOpts);
|
|
441
|
+
break;
|
|
442
|
+
case 'tanstack-start':
|
|
443
|
+
case 'remix':
|
|
444
|
+
dockerfile = generateNodeWebDockerfile(webOpts);
|
|
445
|
+
break;
|
|
446
|
+
default:
|
|
447
|
+
// nextjs (and any unspecified web framework — schema requires
|
|
448
|
+
// a valid framework, so this is just a default).
|
|
449
|
+
dockerfile = generateNextjsDockerfile(webOpts);
|
|
450
|
+
}
|
|
424
451
|
} else if (app.entry) {
|
|
425
452
|
// Backend with custom entry point - use tsdown bundling
|
|
426
453
|
dockerfile = generateEntryDockerfile({
|
|
@@ -479,7 +506,8 @@ export async function workspaceDockerCommand(
|
|
|
479
506
|
);
|
|
480
507
|
logger.log('\n📋 Build commands:');
|
|
481
508
|
for (const result of results) {
|
|
482
|
-
const icon =
|
|
509
|
+
const icon =
|
|
510
|
+
result.type === 'backend' ? '⚙️' : result.type === 'mobile' ? '📱' : '🌐';
|
|
483
511
|
logger.log(
|
|
484
512
|
` ${icon} docker build -f .gkm/docker/Dockerfile.${result.appName} -t ${result.imageName} .`,
|
|
485
513
|
);
|
package/src/docker/templates.ts
CHANGED
|
@@ -927,3 +927,141 @@ ENTRYPOINT ["/sbin/tini", "--"]
|
|
|
927
927
|
CMD ["node", "index.mjs"]
|
|
928
928
|
`;
|
|
929
929
|
}
|
|
930
|
+
|
|
931
|
+
/**
|
|
932
|
+
* Generate a Dockerfile for Node-based SSR web frameworks (TanStack Start,
|
|
933
|
+
* Remix). Builds with turbo, then runs the framework's production start
|
|
934
|
+
* script via `npm start`.
|
|
935
|
+
*
|
|
936
|
+
* Public URL build args are declared so framework bundlers (Vite, Vinxi)
|
|
937
|
+
* can inline them at build time. Per-framework prefix is the caller's job.
|
|
938
|
+
* @internal Exported for testing
|
|
939
|
+
*/
|
|
940
|
+
export function generateNodeWebDockerfile(
|
|
941
|
+
options: FrontendDockerfileOptions,
|
|
942
|
+
): string {
|
|
943
|
+
const {
|
|
944
|
+
baseImage,
|
|
945
|
+
port,
|
|
946
|
+
appPath,
|
|
947
|
+
turboPackage,
|
|
948
|
+
packageManager,
|
|
949
|
+
publicUrlArgs = [],
|
|
950
|
+
} = options;
|
|
951
|
+
|
|
952
|
+
const pm = getPmConfig(packageManager);
|
|
953
|
+
const installPm = pm.install ? `RUN ${pm.install}` : '';
|
|
954
|
+
const turboInstallCmd = getTurboInstallCmd(packageManager);
|
|
955
|
+
const turboCmd = packageManager === 'pnpm' ? 'pnpm dlx turbo' : 'npx turbo';
|
|
956
|
+
const startCmd =
|
|
957
|
+
packageManager === 'pnpm'
|
|
958
|
+
? 'pnpm start'
|
|
959
|
+
: `${pm.lockfile.startsWith('package-lock') ? 'npm' : packageManager} start`;
|
|
960
|
+
|
|
961
|
+
const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join('\n');
|
|
962
|
+
const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join('\n');
|
|
963
|
+
|
|
964
|
+
return `# syntax=docker/dockerfile:1
|
|
965
|
+
# Node SSR web Dockerfile (TanStack Start / Remix) with turbo prune
|
|
966
|
+
|
|
967
|
+
FROM ${baseImage} AS pruner
|
|
968
|
+
WORKDIR /app
|
|
969
|
+
${installPm}
|
|
970
|
+
COPY . .
|
|
971
|
+
RUN ${turboCmd} prune ${turboPackage} --docker
|
|
972
|
+
|
|
973
|
+
FROM ${baseImage} AS deps
|
|
974
|
+
WORKDIR /app
|
|
975
|
+
${installPm}
|
|
976
|
+
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
977
|
+
COPY --from=pruner /app/out/json/ ./
|
|
978
|
+
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
979
|
+
${turboInstallCmd}
|
|
980
|
+
|
|
981
|
+
FROM deps AS builder
|
|
982
|
+
WORKDIR /app
|
|
983
|
+
${argDecls}
|
|
984
|
+
${envDecls}
|
|
985
|
+
COPY --from=pruner /app/out/full/ ./
|
|
986
|
+
COPY --from=pruner /app/tsconfig.* ./
|
|
987
|
+
RUN ${turboCmd} run build --filter=${turboPackage}
|
|
988
|
+
|
|
989
|
+
FROM ${baseImage} AS runner
|
|
990
|
+
WORKDIR /app
|
|
991
|
+
RUN apk add --no-cache tini
|
|
992
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
993
|
+
adduser --system --uid 1001 app
|
|
994
|
+
|
|
995
|
+
# Copy the whole built workspace — frameworks differ on output paths
|
|
996
|
+
COPY --from=builder --chown=app:nodejs /app/ ./
|
|
997
|
+
|
|
998
|
+
ENV NODE_ENV=production
|
|
999
|
+
ENV PORT=${port}
|
|
1000
|
+
ENV HOSTNAME="0.0.0.0"
|
|
1001
|
+
|
|
1002
|
+
USER app
|
|
1003
|
+
EXPOSE ${port}
|
|
1004
|
+
|
|
1005
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
1006
|
+
CMD ["sh", "-c", "cd ${appPath} && ${startCmd}"]
|
|
1007
|
+
`;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Generate a Dockerfile for Vite SPA apps. Builds the static bundle with
|
|
1012
|
+
* turbo, then serves it via nginx.
|
|
1013
|
+
* @internal Exported for testing
|
|
1014
|
+
*/
|
|
1015
|
+
export function generateViteStaticDockerfile(
|
|
1016
|
+
options: FrontendDockerfileOptions,
|
|
1017
|
+
): string {
|
|
1018
|
+
const {
|
|
1019
|
+
baseImage,
|
|
1020
|
+
port,
|
|
1021
|
+
appPath,
|
|
1022
|
+
turboPackage,
|
|
1023
|
+
packageManager,
|
|
1024
|
+
publicUrlArgs = [],
|
|
1025
|
+
} = options;
|
|
1026
|
+
|
|
1027
|
+
const pm = getPmConfig(packageManager);
|
|
1028
|
+
const installPm = pm.install ? `RUN ${pm.install}` : '';
|
|
1029
|
+
const turboInstallCmd = getTurboInstallCmd(packageManager);
|
|
1030
|
+
const turboCmd = packageManager === 'pnpm' ? 'pnpm dlx turbo' : 'npx turbo';
|
|
1031
|
+
|
|
1032
|
+
const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join('\n');
|
|
1033
|
+
const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join('\n');
|
|
1034
|
+
|
|
1035
|
+
return `# syntax=docker/dockerfile:1
|
|
1036
|
+
# Vite SPA Dockerfile — builds static assets, serves via nginx
|
|
1037
|
+
|
|
1038
|
+
FROM ${baseImage} AS pruner
|
|
1039
|
+
WORKDIR /app
|
|
1040
|
+
${installPm}
|
|
1041
|
+
COPY . .
|
|
1042
|
+
RUN ${turboCmd} prune ${turboPackage} --docker
|
|
1043
|
+
|
|
1044
|
+
FROM ${baseImage} AS deps
|
|
1045
|
+
WORKDIR /app
|
|
1046
|
+
${installPm}
|
|
1047
|
+
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
1048
|
+
COPY --from=pruner /app/out/json/ ./
|
|
1049
|
+
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
1050
|
+
${turboInstallCmd}
|
|
1051
|
+
|
|
1052
|
+
FROM deps AS builder
|
|
1053
|
+
WORKDIR /app
|
|
1054
|
+
${argDecls}
|
|
1055
|
+
${envDecls}
|
|
1056
|
+
COPY --from=pruner /app/out/full/ ./
|
|
1057
|
+
COPY --from=pruner /app/tsconfig.* ./
|
|
1058
|
+
RUN ${turboCmd} run build --filter=${turboPackage}
|
|
1059
|
+
|
|
1060
|
+
FROM nginx:alpine AS runner
|
|
1061
|
+
COPY --from=builder /app/${appPath}/dist /usr/share/nginx/html
|
|
1062
|
+
RUN printf 'server {\\n listen ${port};\\n root /usr/share/nginx/html;\\n index index.html;\\n location / { try_files $uri $uri/ /index.html; }\\n}\\n' \\
|
|
1063
|
+
> /etc/nginx/conf.d/default.conf
|
|
1064
|
+
EXPOSE ${port}
|
|
1065
|
+
CMD ["nginx", "-g", "daemon off;"]
|
|
1066
|
+
`;
|
|
1067
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Endpoint } from '@geekmidas/constructs/endpoints';
|
|
2
2
|
import {
|
|
3
|
+
getRegisteredZodJsonSchemas,
|
|
3
4
|
getSchemaMetadata,
|
|
4
5
|
StandardSchemaJsonSchema,
|
|
5
6
|
} from '@geekmidas/schema/conversion';
|
|
@@ -313,6 +314,27 @@ export class OpenApiTsGenerator {
|
|
|
313
314
|
}
|
|
314
315
|
}
|
|
315
316
|
|
|
317
|
+
// Pull in every schema registered via `.meta({ id })` in zod's global
|
|
318
|
+
// registry, even when no endpoint references it directly. This makes
|
|
319
|
+
// derived schemas like `UserSchema.pick(...)` discoverable through the
|
|
320
|
+
// named parent type without requiring the parent to be wired into an
|
|
321
|
+
// endpoint.
|
|
322
|
+
const registrySchemas = await getRegisteredZodJsonSchemas();
|
|
323
|
+
for (const [rawName, rawSchema] of Object.entries(registrySchemas)) {
|
|
324
|
+
const defName = this.pascalCase(rawName);
|
|
325
|
+
if (collectedDefs.has(defName) || generatedNames.has(defName)) {
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const {
|
|
329
|
+
id: _id,
|
|
330
|
+
$defs: _defs,
|
|
331
|
+
...rest
|
|
332
|
+
} = rawSchema as JsonSchema & {
|
|
333
|
+
id?: string;
|
|
334
|
+
};
|
|
335
|
+
collectedDefs.set(defName, rest as JsonSchema);
|
|
336
|
+
}
|
|
337
|
+
|
|
316
338
|
// Generate interfaces for collected $defs (nested schemas with .meta({ id: 'X' }))
|
|
317
339
|
for (const [defName, defSchema] of collectedDefs) {
|
|
318
340
|
if (!generatedNames.has(defName)) {
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { e } from '@geekmidas/constructs/endpoints';
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { z } from 'zod/v4';
|
|
4
|
+
import { OpenApiTsGenerator } from '../OpenApiTsGenerator';
|
|
5
|
+
|
|
6
|
+
describe('OpenApiTsGenerator — zod global registry', () => {
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
z.globalRegistry.clear();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
z.globalRegistry.clear();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('emits a named interface for a registered schema even when no endpoint references it directly', async () => {
|
|
16
|
+
const UserSchema = z
|
|
17
|
+
.object({
|
|
18
|
+
id: z.string(),
|
|
19
|
+
email: z.string(),
|
|
20
|
+
name: z.string(),
|
|
21
|
+
})
|
|
22
|
+
.meta({ id: 'User' });
|
|
23
|
+
|
|
24
|
+
const UserResponseSchema = UserSchema.pick({
|
|
25
|
+
id: true,
|
|
26
|
+
name: true,
|
|
27
|
+
email: true,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const endpoint = e
|
|
31
|
+
.get('/users/:id')
|
|
32
|
+
.output(UserResponseSchema)
|
|
33
|
+
.handle(async () => ({ id: '1', name: 'a', email: 'a@b.c' }));
|
|
34
|
+
|
|
35
|
+
const generator = new OpenApiTsGenerator();
|
|
36
|
+
const content = await generator.generate([endpoint as any]);
|
|
37
|
+
|
|
38
|
+
expect(content).toMatch(/export interface User\b/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('does not duplicate when the same id is also produced by an endpoint', async () => {
|
|
42
|
+
const UserSchema = z
|
|
43
|
+
.object({
|
|
44
|
+
id: z.string(),
|
|
45
|
+
email: z.string(),
|
|
46
|
+
name: z.string(),
|
|
47
|
+
})
|
|
48
|
+
.meta({ id: 'User' });
|
|
49
|
+
|
|
50
|
+
const endpoint = e
|
|
51
|
+
.get('/users/:id')
|
|
52
|
+
.output(UserSchema)
|
|
53
|
+
.handle(async () => ({ id: '1', name: 'a', email: 'a@b.c' }));
|
|
54
|
+
|
|
55
|
+
const generator = new OpenApiTsGenerator();
|
|
56
|
+
const content = await generator.generate([endpoint as any]);
|
|
57
|
+
|
|
58
|
+
const occurrences = content.match(/export interface User\b/g) ?? [];
|
|
59
|
+
expect(occurrences.length).toBe(1);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('emits multiple registered schemas', async () => {
|
|
63
|
+
z.object({ id: z.string() }).meta({ id: 'Foo' });
|
|
64
|
+
z.object({ id: z.string() }).meta({ id: 'Bar' });
|
|
65
|
+
|
|
66
|
+
const endpoint = e
|
|
67
|
+
.get('/ping')
|
|
68
|
+
.output(z.object({ ok: z.boolean() }))
|
|
69
|
+
.handle(async () => ({ ok: true }));
|
|
70
|
+
|
|
71
|
+
const generator = new OpenApiTsGenerator();
|
|
72
|
+
const content = await generator.generate([endpoint as any]);
|
|
73
|
+
|
|
74
|
+
expect(content).toMatch(/export interface Foo\b/);
|
|
75
|
+
expect(content).toMatch(/export interface Bar\b/);
|
|
76
|
+
});
|
|
77
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -286,13 +286,17 @@ program
|
|
|
286
286
|
program
|
|
287
287
|
.command('openapi')
|
|
288
288
|
.description('Generate OpenAPI specification from endpoints')
|
|
289
|
-
.
|
|
289
|
+
.option(
|
|
290
|
+
'--app <name>',
|
|
291
|
+
'Workspace mode: generate for a single named backend app',
|
|
292
|
+
)
|
|
293
|
+
.action(async (options: { app?: string }) => {
|
|
290
294
|
try {
|
|
291
295
|
const globalOptions = program.opts();
|
|
292
296
|
if (globalOptions.cwd) {
|
|
293
297
|
process.chdir(globalOptions.cwd);
|
|
294
298
|
}
|
|
295
|
-
await openapiCommand({});
|
|
299
|
+
await openapiCommand({ app: options.app });
|
|
296
300
|
} catch (error) {
|
|
297
301
|
console.error(formatError(error));
|
|
298
302
|
process.exit(1);
|
|
@@ -2,11 +2,13 @@ import { describe, expect, it } from 'vitest';
|
|
|
2
2
|
import { generateConfigFiles } from '../generators/config.js';
|
|
3
3
|
import { generateDockerFiles } from '../generators/docker.js';
|
|
4
4
|
import { generateEnvFiles } from '../generators/env.js';
|
|
5
|
+
import { generateExpoAppFiles } from '../generators/mobile-expo.js';
|
|
5
6
|
import { generateModelsPackage } from '../generators/models.js';
|
|
6
7
|
import { generateMonorepoFiles } from '../generators/monorepo.js';
|
|
7
8
|
import { generatePackageJson } from '../generators/package.js';
|
|
8
9
|
import { generateTestFiles } from '../generators/test.js';
|
|
9
10
|
import { generateUiPackageFiles } from '../generators/ui.js';
|
|
11
|
+
import { generateTanStackWebFiles } from '../generators/web-tanstack.js';
|
|
10
12
|
import { apiTemplate } from '../templates/api.js';
|
|
11
13
|
import type { TemplateOptions } from '../templates/index.js';
|
|
12
14
|
import { minimalTemplate } from '../templates/minimal.js';
|
|
@@ -431,6 +433,151 @@ describe('generateMonorepoFiles', () => {
|
|
|
431
433
|
});
|
|
432
434
|
});
|
|
433
435
|
|
|
436
|
+
describe('generateTanStackWebFiles', () => {
|
|
437
|
+
const fullstackOptions: TemplateOptions = {
|
|
438
|
+
...baseOptions,
|
|
439
|
+
template: 'fullstack',
|
|
440
|
+
monorepo: true,
|
|
441
|
+
apiPath: 'apps/api',
|
|
442
|
+
frontendFramework: 'tanstack-start',
|
|
443
|
+
};
|
|
444
|
+
|
|
445
|
+
it('returns empty array for non-fullstack template', () => {
|
|
446
|
+
expect(generateTanStackWebFiles(baseOptions)).toHaveLength(0);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
it('generates the expected file paths', () => {
|
|
450
|
+
const files = generateTanStackWebFiles(fullstackOptions);
|
|
451
|
+
const paths = files.map((f) => f.path);
|
|
452
|
+
expect(paths).toContain('apps/web/package.json');
|
|
453
|
+
expect(paths).toContain('apps/web/vite.config.ts');
|
|
454
|
+
expect(paths).toContain('apps/web/src/router.tsx');
|
|
455
|
+
expect(paths).toContain('apps/web/src/routes/__root.tsx');
|
|
456
|
+
expect(paths).toContain('apps/web/src/routes/index.tsx');
|
|
457
|
+
expect(paths).toContain('apps/web/src/config/client.ts');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('uses VITE_ prefix in client config, not NEXT_PUBLIC_', () => {
|
|
461
|
+
const files = generateTanStackWebFiles(fullstackOptions);
|
|
462
|
+
const clientConfig = files.find(
|
|
463
|
+
(f) => f.path === 'apps/web/src/config/client.ts',
|
|
464
|
+
);
|
|
465
|
+
expect(clientConfig).toBeDefined();
|
|
466
|
+
expect(clientConfig!.content).toContain('VITE_API_URL');
|
|
467
|
+
expect(clientConfig!.content).toContain('VITE_AUTH_URL');
|
|
468
|
+
expect(clientConfig!.content).not.toContain('NEXT_PUBLIC_');
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('declares tanstack and vite as dependencies', () => {
|
|
472
|
+
const files = generateTanStackWebFiles(fullstackOptions);
|
|
473
|
+
const pkg = JSON.parse(
|
|
474
|
+
files.find((f) => f.path === 'apps/web/package.json')!.content,
|
|
475
|
+
);
|
|
476
|
+
expect(pkg.dependencies['@tanstack/react-start']).toBeDefined();
|
|
477
|
+
expect(pkg.dependencies['@tanstack/react-router']).toBeDefined();
|
|
478
|
+
expect(pkg.devDependencies.vite).toBeDefined();
|
|
479
|
+
expect(pkg.dependencies.next).toBeUndefined();
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
describe('generateExpoAppFiles', () => {
|
|
484
|
+
const fullstackOptions: TemplateOptions = {
|
|
485
|
+
...baseOptions,
|
|
486
|
+
template: 'fullstack',
|
|
487
|
+
monorepo: true,
|
|
488
|
+
apiPath: 'apps/api',
|
|
489
|
+
frontendFramework: 'expo',
|
|
490
|
+
};
|
|
491
|
+
|
|
492
|
+
it('returns empty array for non-fullstack template', () => {
|
|
493
|
+
expect(generateExpoAppFiles(baseOptions)).toHaveLength(0);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
it('generates the expected Expo file paths', () => {
|
|
497
|
+
const files = generateExpoAppFiles(fullstackOptions);
|
|
498
|
+
const paths = files.map((f) => f.path);
|
|
499
|
+
expect(paths).toContain('apps/app/package.json');
|
|
500
|
+
expect(paths).toContain('apps/app/app.config.ts');
|
|
501
|
+
expect(paths).toContain('apps/app/eas.json');
|
|
502
|
+
expect(paths).toContain('apps/app/babel.config.js');
|
|
503
|
+
expect(paths).toContain('apps/app/metro.config.js');
|
|
504
|
+
expect(paths).toContain('apps/app/app/_layout.tsx');
|
|
505
|
+
expect(paths).toContain('apps/app/app/index.tsx');
|
|
506
|
+
expect(paths).toContain('apps/app/app/login.tsx');
|
|
507
|
+
expect(paths).toContain('apps/app/lib/auth-client.ts');
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
it('uses EXPO_PUBLIC_ prefix in config.ts', () => {
|
|
511
|
+
const files = generateExpoAppFiles(fullstackOptions);
|
|
512
|
+
const configTs = files.find((f) => f.path === 'apps/app/config.ts');
|
|
513
|
+
expect(configTs).toBeDefined();
|
|
514
|
+
expect(configTs!.content).toContain('EXPO_PUBLIC_API_URL');
|
|
515
|
+
expect(configTs!.content).toContain('EXPO_PUBLIC_AUTH_URL');
|
|
516
|
+
expect(configTs!.content).not.toContain('VITE_');
|
|
517
|
+
expect(configTs!.content).not.toContain('NEXT_PUBLIC_');
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('seeds eas.json with EXPO_PUBLIC_ env vars per profile', () => {
|
|
521
|
+
const files = generateExpoAppFiles(fullstackOptions);
|
|
522
|
+
const eas = JSON.parse(
|
|
523
|
+
files.find((f) => f.path === 'apps/app/eas.json')!.content,
|
|
524
|
+
);
|
|
525
|
+
expect(eas.build.dev.env.EXPO_PUBLIC_API_URL).toBeDefined();
|
|
526
|
+
expect(eas.build.dev.env.EXPO_PUBLIC_AUTH_URL).toBeDefined();
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('declares expo + better-auth/expo as dependencies', () => {
|
|
530
|
+
const files = generateExpoAppFiles(fullstackOptions);
|
|
531
|
+
const pkg = JSON.parse(
|
|
532
|
+
files.find((f) => f.path === 'apps/app/package.json')!.content,
|
|
533
|
+
);
|
|
534
|
+
expect(pkg.dependencies.expo).toBeDefined();
|
|
535
|
+
expect(pkg.dependencies['@better-auth/expo']).toBeDefined();
|
|
536
|
+
expect(pkg.dependencies['expo-router']).toBeDefined();
|
|
537
|
+
expect(pkg.dependencies.next).toBeUndefined();
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
describe('generateMonorepoFiles - frontendFramework wiring', () => {
|
|
542
|
+
const fullstackBase: TemplateOptions = {
|
|
543
|
+
...baseOptions,
|
|
544
|
+
template: 'fullstack',
|
|
545
|
+
monorepo: true,
|
|
546
|
+
apiPath: 'apps/api',
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
it('emits framework: nextjs by default', () => {
|
|
550
|
+
const files = generateMonorepoFiles(
|
|
551
|
+
{ ...fullstackBase, frontendFramework: 'nextjs' },
|
|
552
|
+
minimalTemplate,
|
|
553
|
+
);
|
|
554
|
+
const cfg = files.find((f) => f.path === 'gkm.config.ts');
|
|
555
|
+
expect(cfg!.content).toContain("framework: 'nextjs'");
|
|
556
|
+
expect(cfg!.content).toContain("path: 'apps/web'");
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
it('emits framework: tanstack-start when selected', () => {
|
|
560
|
+
const files = generateMonorepoFiles(
|
|
561
|
+
{ ...fullstackBase, frontendFramework: 'tanstack-start' },
|
|
562
|
+
minimalTemplate,
|
|
563
|
+
);
|
|
564
|
+
const cfg = files.find((f) => f.path === 'gkm.config.ts');
|
|
565
|
+
expect(cfg!.content).toContain("framework: 'tanstack-start'");
|
|
566
|
+
expect(cfg!.content).toContain("path: 'apps/web'");
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
it('emits framework: expo and apps/app path when selected', () => {
|
|
570
|
+
const files = generateMonorepoFiles(
|
|
571
|
+
{ ...fullstackBase, frontendFramework: 'expo' },
|
|
572
|
+
minimalTemplate,
|
|
573
|
+
);
|
|
574
|
+
const cfg = files.find((f) => f.path === 'gkm.config.ts');
|
|
575
|
+
expect(cfg!.content).toContain("framework: 'expo'");
|
|
576
|
+
expect(cfg!.content).toContain("path: 'apps/app'");
|
|
577
|
+
expect(cfg!.content).toContain('port: 8081');
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
|
|
434
581
|
describe('generateModelsPackage', () => {
|
|
435
582
|
it('should return empty array for non-monorepo', () => {
|
|
436
583
|
const files = generateModelsPackage(baseOptions);
|
|
@@ -379,7 +379,7 @@ describe('initCommand', () => {
|
|
|
379
379
|
expect(content).toContain("name: 'my-fullstack'");
|
|
380
380
|
expect(content).toContain("type: 'backend'");
|
|
381
381
|
expect(content).toContain("path: 'apps/api'");
|
|
382
|
-
expect(content).toContain("type: '
|
|
382
|
+
expect(content).toContain("type: 'web'");
|
|
383
383
|
expect(content).toContain("framework: 'nextjs'");
|
|
384
384
|
expect(content).toContain("path: 'apps/web'");
|
|
385
385
|
expect(content).toContain("packages: ['packages/*']");
|