@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.
Files changed (100) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/dist/{config-B62g483e.mjs → config-Bbu1JKvf.mjs} +2 -2
  3. package/dist/{config-B62g483e.mjs.map → config-Bbu1JKvf.mjs.map} +1 -1
  4. package/dist/{config-Cuo8vFsp.cjs → config-DujV8DbR.cjs} +2 -2
  5. package/dist/{config-Cuo8vFsp.cjs.map → config-DujV8DbR.cjs.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/{fullstack-secrets-Bchl2MDd.cjs → fullstack-secrets-BYzi6Hwl.cjs} +3 -2
  12. package/dist/{fullstack-secrets-Bchl2MDd.cjs.map → fullstack-secrets-BYzi6Hwl.cjs.map} +1 -1
  13. package/dist/{fullstack-secrets-P84v8zWn.mjs → fullstack-secrets-l8s3II_H.mjs} +3 -2
  14. package/dist/{fullstack-secrets-P84v8zWn.mjs.map → fullstack-secrets-l8s3II_H.mjs.map} +1 -1
  15. package/dist/{index-Dt_dZ7K4.d.mts → index-BBvGMG_A.d.mts} +162 -68
  16. package/dist/index-BBvGMG_A.d.mts.map +1 -0
  17. package/dist/{index-C3t5VL4R.d.cts → index-Dz2a7xQU.d.cts} +162 -68
  18. package/dist/index-Dz2a7xQU.d.cts.map +1 -0
  19. package/dist/index.cjs +1366 -149
  20. package/dist/index.cjs.map +1 -1
  21. package/dist/index.mjs +1366 -149
  22. package/dist/index.mjs.map +1 -1
  23. package/dist/{openapi-B_rJjImN.cjs → openapi-0JUzU5Tv.cjs} +72 -15
  24. package/dist/openapi-0JUzU5Tv.cjs.map +1 -0
  25. package/dist/{openapi-CflxypuN.mjs → openapi-C5AycKTR.mjs} +73 -16
  26. package/dist/openapi-C5AycKTR.mjs.map +1 -0
  27. package/dist/openapi.cjs +3 -3
  28. package/dist/openapi.d.cts +7 -0
  29. package/dist/openapi.d.cts.map +1 -1
  30. package/dist/openapi.d.mts +7 -0
  31. package/dist/openapi.d.mts.map +1 -1
  32. package/dist/openapi.mjs +3 -3
  33. package/dist/{reconcile-DAAKa0YR.mjs → reconcile-CYGmqXvq.mjs} +2 -2
  34. package/dist/{reconcile-DAAKa0YR.mjs.map → reconcile-CYGmqXvq.mjs.map} +1 -1
  35. package/dist/{reconcile-CZtzdrnc.cjs → reconcile-D93YG7G5.cjs} +2 -2
  36. package/dist/{reconcile-CZtzdrnc.cjs.map → reconcile-D93YG7G5.cjs.map} +1 -1
  37. package/dist/workspace/index.cjs +4 -1
  38. package/dist/workspace/index.d.cts +2 -2
  39. package/dist/workspace/index.d.mts +2 -2
  40. package/dist/workspace/index.mjs +2 -2
  41. package/dist/{workspace-CGYykWfn.cjs → workspace-CQTT7HJQ.cjs} +107 -27
  42. package/dist/workspace-CQTT7HJQ.cjs.map +1 -0
  43. package/dist/{workspace-Bi4X7Yzy.mjs → workspace-D-37Ylqo.mjs} +90 -28
  44. package/dist/workspace-D-37Ylqo.mjs.map +1 -0
  45. package/docs/dev-server-resilience-design.md +189 -0
  46. package/package.json +4 -4
  47. package/src/__tests__/openapi.spec.ts +99 -9
  48. package/src/build/__tests__/workspace-build.spec.ts +7 -7
  49. package/src/build/index.ts +25 -9
  50. package/src/credentials/__tests__/helpers.ts +1 -1
  51. package/src/credentials/__tests__/workspaceCredentials.spec.ts +1 -1
  52. package/src/deploy/__tests__/domain.spec.ts +12 -12
  53. package/src/deploy/__tests__/env-resolver.spec.ts +29 -1
  54. package/src/deploy/__tests__/index.spec.ts +14 -14
  55. package/src/deploy/__tests__/sniffer.spec.ts +9 -9
  56. package/src/deploy/domain.ts +19 -11
  57. package/src/deploy/env-resolver.ts +5 -10
  58. package/src/deploy/index.ts +25 -8
  59. package/src/deploy/sniffer.ts +11 -5
  60. package/src/deploy/types.ts +1 -1
  61. package/src/dev/__tests__/index.spec.ts +11 -11
  62. package/src/dev/index.ts +43 -9
  63. package/src/docker/__tests__/compose.spec.ts +4 -4
  64. package/src/docker/compose.ts +5 -3
  65. package/src/docker/index.ts +34 -6
  66. package/src/docker/templates.ts +138 -0
  67. package/src/generators/OpenApiTsGenerator.ts +22 -0
  68. package/src/generators/__tests__/OpenApiTsGenerator.registry.spec.ts +77 -0
  69. package/src/index.ts +6 -2
  70. package/src/init/__tests__/generators.spec.ts +147 -0
  71. package/src/init/__tests__/init.spec.ts +1 -1
  72. package/src/init/generators/mobile-expo.ts +576 -0
  73. package/src/init/generators/monorepo.ts +42 -11
  74. package/src/init/generators/web-tanstack.ts +348 -0
  75. package/src/init/index.ts +40 -3
  76. package/src/init/templates/index.ts +31 -0
  77. package/src/init/versions.ts +2 -2
  78. package/src/openapi.ts +110 -23
  79. package/src/secrets/__tests__/reconcile.spec.ts +1 -1
  80. package/src/setup/__tests__/reconcile-secrets.spec.ts +2 -2
  81. package/src/setup/fullstack-secrets.ts +7 -1
  82. package/src/setup/index.ts +2 -1
  83. package/src/test/__tests__/__fixtures__/workspace.ts +1 -1
  84. package/src/test/__tests__/web.spec.ts +2 -2
  85. package/src/workspace/__tests__/client-generator.spec.ts +6 -6
  86. package/src/workspace/__tests__/index.spec.ts +96 -4
  87. package/src/workspace/__tests__/publicEnv.spec.ts +64 -0
  88. package/src/workspace/__tests__/schema.spec.ts +27 -30
  89. package/src/workspace/__tests__/type-inference.spec.ts +5 -5
  90. package/src/workspace/client-generator.ts +10 -7
  91. package/src/workspace/index.ts +17 -3
  92. package/src/workspace/publicEnv.ts +57 -0
  93. package/src/workspace/schema.ts +126 -27
  94. package/src/workspace/types.ts +40 -64
  95. package/dist/index-C3t5VL4R.d.cts.map +0 -1
  96. package/dist/index-Dt_dZ7K4.d.mts.map +0 -1
  97. package/dist/openapi-B_rJjImN.cjs.map +0 -1
  98. package/dist/openapi-CflxypuN.mjs.map +0 -1
  99. package/dist/workspace-Bi4X7Yzy.mjs.map +0 -1
  100. package/dist/workspace-CGYykWfn.cjs.map +0 -1
@@ -0,0 +1,189 @@
1
+ # Dev Server Resilience Design
2
+
3
+ Status: parked / not yet started
4
+ Owner: TBD
5
+ Scope: `packages/cli/src/dev/index.ts`, `packages/cli/src/generators/Generator.ts`
6
+
7
+ ## Background
8
+
9
+ Running `gkm dev` against a real workspace (e.g. `rezgo`: api, auth, web, app/Expo)
10
+ exposes three classes of failure. None are caused by user code; they are
11
+ limitations of the current dev orchestrator and hot-reload implementation.
12
+
13
+ ## Issue 1 — Hot reload misses new files and serves stale transitive modules
14
+
15
+ ### Symptoms
16
+
17
+ - Adding a new route file does not trigger a rebuild.
18
+ - After adding a new export to a non-route file, route files that import it
19
+ fail with:
20
+ `SyntaxError: The requested module '~/.../foo.ts' does not provide an export named 'bar'`
21
+ - Some changes "just don't get picked up" until the dev server is restarted.
22
+
23
+ ### Root causes
24
+
25
+ 1. **`add`/`unlink` events are ignored.** The chokidar watcher in `dev/index.ts`
26
+ only listens for `change`:
27
+ ```ts
28
+ watcher.on('change', async (path) => { ... });
29
+ ```
30
+ New or deleted files never trigger a rebuild.
31
+
32
+ 2. **Cache-bust is shallow.** `Generator.ts:load()` appends `?t=<now>` only to
33
+ the top-level globbed route file URL. Node's ESM cache is keyed by URL, so
34
+ any *transitive* import (services, domain modules, env, etc.) is served from
35
+ cache forever within the CLI process. When a transitive module is edited,
36
+ the route file is re-evaluated against the stale cached version → the
37
+ "does not provide an export named" error above.
38
+
39
+ 3. **Watch patterns are too narrow.** Only `routes`, `functions`, `crons`,
40
+ `subscribers` (plus envParser / logger / hooks files) are watched. Edits to
41
+ `src/domain/**`, `src/services/**`, etc. don't fire any rebuild even though
42
+ those files are pulled in transitively at openapi/build time.
43
+
44
+ ### Proposed fix
45
+
46
+ - Subscribe to `add` and `unlink` on the watcher and re-resolve the glob set
47
+ before rebuilding.
48
+ - Broaden the watch pattern to cover the app's `src/**/*.{ts,tsx}` (configurable),
49
+ not just construct entry directories.
50
+ - Bust transitive caches. Two viable approaches:
51
+ - **Worker thread per rebuild** (preferred): move `EndpointGenerator.load` /
52
+ `FunctionGenerator.load` / etc. into a short-lived `worker_threads` worker
53
+ that gets recreated on each rebuild. Each worker has a fresh module graph,
54
+ so transitive imports are guaranteed fresh. Cost is one worker spawn per
55
+ rebuild (~50-100ms), but avoids the unbounded-memory leak of accumulating
56
+ `?t=N` URLs in the main process.
57
+ - **Custom loader hook**: install a Node loader that rewrites every resolved
58
+ URL to include the current rebuild generation. Cheaper per rebuild but
59
+ fragile (loader compatibility with tsx, source maps, etc.).
60
+
61
+ Worker thread is the recommended path.
62
+
63
+ ## Issue 2 — Ctrl+C orphans `tsx`/`node` processes
64
+
65
+ ### Symptoms
66
+
67
+ After pressing Ctrl+C in a workspace `gkm dev`, the shell returns but
68
+ `tsx` and `node` processes remain alive. Workaround is `killall node`.
69
+
70
+ ### Process chain in workspace mode
71
+
72
+ ```
73
+ shell
74
+ └── gkm dev (top-level, workspace)
75
+ └── pnpm turbo run dev [spawned with detached: true]
76
+ ├── @rezgo/api: gkm dev (per-app)
77
+ │ └── tsx [spawned with detached: true]
78
+ │ └── node (Hono server)
79
+ ├── @rezgo/auth: ...
80
+ └── @rezgo/app: ...
81
+ ```
82
+
83
+ ### Root cause
84
+
85
+ The per-app `gkm dev` spawns `tsx` with `detached: true`
86
+ (`dev/index.ts:1521`). That moves tsx into a *new process group* outside the
87
+ turbo subtree. The workspace shutdown handler only sends SIGTERM/SIGKILL to
88
+ turbo's pgid; tsx's pgid is never targeted. After SIGKILL hits turbo's group,
89
+ the per-app gkm processes die *before* their async shutdown finishes calling
90
+ `process.kill(-tsxPgid, 'SIGKILL')`. tsx and its node child are orphaned.
91
+
92
+ The `detached: true` flag exists because the user's running Hono server may
93
+ ignore SIGTERM, so we need to kill its whole group. The design conflict is:
94
+ detaching tsx is necessary to forcibly kill it, but it also escapes the
95
+ top-level reachability tree.
96
+
97
+ ### Proposed fix
98
+
99
+ Track every tsx PID centrally:
100
+
101
+ - Each `DevServer` registers its `tsx` pid into a shared `Set<number>` at the
102
+ workspace level (or writes to a state file under `.gkm/`).
103
+ - Workspace shutdown iterates that set and issues `process.kill(-pid, 'SIGKILL')`
104
+ for each known tsx pgid *before* falling back to the turbo-pgid SIGKILL.
105
+ - As belt-and-suspenders, drain the registry on per-app `gkm dev` exit so dead
106
+ pids are not retargeted.
107
+
108
+ This makes the kill reachable regardless of process-group escape.
109
+
110
+ ## Issue 3 — One app's crash brings down the whole workspace
111
+
112
+ ### Symptoms
113
+
114
+ `@rezgo/app` (Expo) crashed with a Metro `DependencyGraph._onHasteChange`
115
+ TypeError. Within seconds, `@rezgo/api`, `@rezgo/auth`, `@rezgo/web` all
116
+ received SIGTERM and shut down. The user lost 21 minutes of warmed dev state.
117
+
118
+ ### Root causes
119
+
120
+ 1. **Turbo's persistent-task semantics.** When a persistent task in
121
+ `turbo run dev` exits non-zero, turbo cancels sibling persistent tasks. There
122
+ is no flag (as of turbo 2.x) that keeps survivors running through a sibling
123
+ crash. `--continue` only affects scheduling of *queued* tasks, not running
124
+ persistent ones.
125
+ 2. **gkm's orchestrator treats turbo's non-zero exit as fatal.** In
126
+ `workspaceDevCommand`:
127
+ ```ts
128
+ turboProcess.on('exit', (code) => {
129
+ if (code !== null && code !== 0) {
130
+ reject(new Error(`Turbo exited with code ${code}`));
131
+ }
132
+ });
133
+ ```
134
+ The reject fires the shutdown handler, which kills any survivors that
135
+ turbo hadn't already torn down.
136
+
137
+ ### Proposed fix
138
+
139
+ Stop using `turbo run dev` for the persistent dev loop. Build an in-process
140
+ supervisor inside `workspaceDevCommand`:
141
+
142
+ - Resolve dependency order via the existing `getAppBuildOrder` (we still use
143
+ turbo's task graph indirectly via workspace config).
144
+ - For each app, `spawn` its dev command directly (`pnpm dev` in that app's
145
+ cwd, or for backend apps invoke the per-app gkm dev codepath directly without
146
+ going through pnpm/turbo).
147
+ - Supervise each child:
148
+ - `on('exit', code)`: log the crash, do **not** propagate, schedule a restart
149
+ with exponential backoff (start at 1s, cap at 30s, reset after a 60s clean
150
+ run).
151
+ - Stream stdout/stderr to the parent with an app-name prefix so output stays
152
+ readable.
153
+ - Centralize the PID registry from Issue 2 here.
154
+ - Ctrl+C handler: iterate registry, SIGKILL each child group, then exit.
155
+
156
+ Turbo remains the build-time orchestrator (`gkm build`, dependency-ordered
157
+ package builds). Only the dev persistent loop is moved off it.
158
+
159
+ ### Why not fix turbo's behavior instead
160
+
161
+ Tried first. `--continue=dependencies-successful` and friends don't apply to
162
+ sibling persistent tasks. Filing it upstream is fine, but the dev experience
163
+ needs to be fixed now and the supervisor pattern is also what makes Issues 1
164
+ and 2 solvable cleanly.
165
+
166
+ ## Implementation order (when unparked)
167
+
168
+ 1. Land the in-process supervisor (Issue 3 fix). This gives us a single owner
169
+ of the dev process tree.
170
+ 2. Move tsx PID registration into the supervisor (Issue 2 fix).
171
+ 3. Move hot-reload (worker thread + broadened watcher + `add`/`unlink`) into
172
+ the per-app branch of the supervisor (Issue 1 fix).
173
+
174
+ Each step is shippable independently. Order matters: 1 unblocks the cleanest
175
+ form of 2 and 3.
176
+
177
+ ## Out of scope
178
+
179
+ - Replacing `tsx` (e.g. with `node --experimental-strip-types`).
180
+ - Native Bun runtime for dev — separate decision.
181
+ - Replacing chokidar.
182
+
183
+ ## Open questions
184
+
185
+ - Should the supervisor expose a `--no-restart` flag for CI-like local runs?
186
+ - Restart backoff thresholds — should they be configurable per app in
187
+ workspace config, or fixed?
188
+ - Should we surface a small status line ("3 of 4 apps running") when one app
189
+ is dead but the rest are healthy?
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geekmidas/cli",
3
- "version": "1.10.35",
3
+ "version": "1.10.37",
4
4
  "description": "CLI tools for building Lambda handlers, server applications, and generating OpenAPI specs",
5
5
  "private": false,
6
6
  "type": "module",
@@ -56,11 +56,11 @@
56
56
  "prompts": "~2.4.2",
57
57
  "tsx": "~4.20.3",
58
58
  "yaml": "~2.8.2",
59
- "@geekmidas/constructs": "~3.0.9",
60
- "@geekmidas/envkit": "~1.0.5",
59
+ "@geekmidas/constructs": "~3.0.10",
60
+ "@geekmidas/envkit": "~1.0.6",
61
61
  "@geekmidas/errors": "~1.0.0",
62
62
  "@geekmidas/logger": "~1.0.1",
63
- "@geekmidas/schema": "~1.0.0"
63
+ "@geekmidas/schema": "~1.0.1"
64
64
  },
65
65
  "devDependencies": {
66
66
  "@types/lodash.kebabcase": "^4.1.9",
@@ -2,12 +2,6 @@ import { existsSync, realpathSync } from 'node:fs';
2
2
  import { mkdir, readFile, rm } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
- import {
6
- generateOpenApi,
7
- OPENAPI_OUTPUT_PATH,
8
- openapiCommand,
9
- resolveOpenApiConfig,
10
- } from '../openapi';
11
5
  import type { GkmConfig } from '../types';
12
6
  import {
13
7
  cleanupDir,
@@ -16,6 +10,33 @@ import {
16
10
  createTestFile,
17
11
  } from './test-helpers';
18
12
 
13
+ const spawnCalls: Array<{ cmd: string; args: string[]; cwd: string }> = [];
14
+ let mockSpawnExitCode = 0;
15
+
16
+ vi.mock('node:child_process', async (importOriginal) => {
17
+ const actual = await importOriginal<typeof import('node:child_process')>();
18
+ return {
19
+ ...actual,
20
+ spawn: vi.fn((cmd: string, args: string[], opts: any) => {
21
+ spawnCalls.push({ cmd, args: args ?? [], cwd: opts?.cwd ?? '' });
22
+ const child: any = {
23
+ on(event: string, cb: (code?: number) => void) {
24
+ if (event === 'close') queueMicrotask(() => cb(mockSpawnExitCode));
25
+ return child;
26
+ },
27
+ };
28
+ return child;
29
+ }),
30
+ };
31
+ });
32
+
33
+ const {
34
+ generateOpenApi,
35
+ OPENAPI_OUTPUT_PATH,
36
+ openapiCommand,
37
+ resolveOpenApiConfig,
38
+ } = await import('../openapi');
39
+
19
40
  describe('resolveOpenApiConfig', () => {
20
41
  const baseConfig: GkmConfig = {
21
42
  routes: './src/endpoints/**/*.ts',
@@ -483,7 +504,7 @@ describe('openapiCommand - workspace mode', () => {
483
504
  vi.restoreAllMocks();
484
505
  });
485
506
 
486
- it('should generate OpenAPI for backend app in workspace', async () => {
507
+ it('should generate OpenAPI for a single app via --app flag', async () => {
487
508
  // Create workspace structure
488
509
  const apiDir = join(tempDir, 'apps/api');
489
510
  await mkdir(apiDir, { recursive: true });
@@ -515,10 +536,11 @@ describe('openapiCommand - workspace mode', () => {
515
536
  }),
516
537
  );
517
538
 
518
- process.chdir(tempDir);
539
+ // Subprocess invocation simulated: CWD = appPath, --app passed
540
+ process.chdir(apiDir);
519
541
  const consoleSpy = vi.spyOn(console, 'log');
520
542
 
521
- await openapiCommand({ cwd: tempDir });
543
+ await openapiCommand({ cwd: tempDir, app: 'api' });
522
544
 
523
545
  // Should generate OpenAPI in the backend app's .gkm folder
524
546
  const outputPath = join(apiDir, OPENAPI_OUTPUT_PATH);
@@ -532,4 +554,72 @@ describe('openapiCommand - workspace mode', () => {
532
554
  expect.stringContaining('[api] Generated OpenAPI'),
533
555
  );
534
556
  });
557
+
558
+ it('should throw when --app references unknown app', async () => {
559
+ await createTestFile(
560
+ tempDir,
561
+ 'gkm.config.json',
562
+ JSON.stringify({
563
+ name: 'test-workspace',
564
+ apps: {
565
+ api: {
566
+ type: 'backend',
567
+ path: 'apps/api',
568
+ port: 3000,
569
+ routes: './src/endpoints/**/*.ts',
570
+ openapi: { enabled: true },
571
+ },
572
+ },
573
+ }),
574
+ );
575
+
576
+ await expect(
577
+ openapiCommand({ cwd: tempDir, app: 'missing' }),
578
+ ).rejects.toThrow(/App "missing" not found/);
579
+ });
580
+
581
+ it('should spawn one subprocess per backend app in multi-app mode', async () => {
582
+ // Create workspace with two backend apps
583
+ const apiDir = join(tempDir, 'apps/api');
584
+ const adminDir = join(tempDir, 'apps/admin');
585
+ await mkdir(apiDir, { recursive: true });
586
+ await mkdir(adminDir, { recursive: true });
587
+
588
+ await createTestFile(
589
+ tempDir,
590
+ 'gkm.config.json',
591
+ JSON.stringify({
592
+ name: 'test-workspace',
593
+ apps: {
594
+ api: {
595
+ type: 'backend',
596
+ path: 'apps/api',
597
+ port: 3000,
598
+ routes: './src/endpoints/**/*.ts',
599
+ openapi: { enabled: true },
600
+ },
601
+ admin: {
602
+ type: 'backend',
603
+ path: 'apps/admin',
604
+ port: 3001,
605
+ routes: './src/endpoints/**/*.ts',
606
+ openapi: { enabled: true },
607
+ },
608
+ },
609
+ }),
610
+ );
611
+
612
+ spawnCalls.length = 0;
613
+ mockSpawnExitCode = 0;
614
+
615
+ await openapiCommand({ cwd: tempDir });
616
+
617
+ expect(spawnCalls).toHaveLength(2);
618
+ expect(spawnCalls[0]?.cwd).toBe(apiDir);
619
+ expect(spawnCalls[0]?.args).toContain('--app');
620
+ expect(spawnCalls[0]?.args).toContain('api');
621
+ expect(spawnCalls[1]?.cwd).toBe(adminDir);
622
+ expect(spawnCalls[1]?.args).toContain('--app');
623
+ expect(spawnCalls[1]?.args).toContain('admin');
624
+ });
535
625
  });
@@ -103,13 +103,13 @@ describe('Workspace Build Command', () => {
103
103
  it('should have correct structure for failed build', () => {
104
104
  const result: AppBuildResult = {
105
105
  appName: 'web',
106
- type: 'frontend',
106
+ type: 'web',
107
107
  success: false,
108
108
  error: 'Build failed',
109
109
  };
110
110
 
111
111
  expect(result.appName).toBe('web');
112
- expect(result.type).toBe('frontend');
112
+ expect(result.type).toBe('web');
113
113
  expect(result.success).toBe(false);
114
114
  expect(result.error).toBe('Build failed');
115
115
  expect(result.outputPath).toBeUndefined();
@@ -137,14 +137,14 @@ describe('Workspace Build Command', () => {
137
137
  routes: './src/**/*.ts',
138
138
  },
139
139
  web: {
140
- type: 'frontend',
140
+ type: 'web',
141
141
  path: 'apps/web',
142
142
  port: 3002,
143
143
  dependencies: ['api', 'auth'],
144
144
  framework: 'nextjs',
145
145
  },
146
146
  admin: {
147
- type: 'frontend',
147
+ type: 'web',
148
148
  path: 'apps/admin',
149
149
  port: 3003,
150
150
  dependencies: ['api'],
@@ -159,7 +159,7 @@ describe('Workspace Build Command', () => {
159
159
 
160
160
  const apps = Object.entries(workspace.apps);
161
161
  const backendApps = apps.filter(([, app]) => app.type === 'backend');
162
- const frontendApps = apps.filter(([, app]) => app.type === 'frontend');
162
+ const frontendApps = apps.filter(([, app]) => app.type === 'web');
163
163
 
164
164
  expect(backendApps).toHaveLength(2);
165
165
  expect(frontendApps).toHaveLength(2);
@@ -181,7 +181,7 @@ describe('Workspace Build Command', () => {
181
181
  dependencies: [],
182
182
  },
183
183
  web: {
184
- type: 'frontend',
184
+ type: 'web',
185
185
  path: 'apps/web',
186
186
  port: 3001,
187
187
  dependencies: [],
@@ -207,7 +207,7 @@ describe('Workspace Build Command', () => {
207
207
  const frontendOutputPath = join(
208
208
  workspace.root,
209
209
  frontendApp.path,
210
- frontendApp.type === 'frontend' ? '.next' : '.gkm',
210
+ frontendApp.type === 'web' ? '.next' : '.gkm',
211
211
  );
212
212
  expect(frontendOutputPath).toBe('/workspace/apps/web/.next');
213
213
  });
@@ -362,7 +362,7 @@ async function buildForProvider(
362
362
  */
363
363
  export interface AppBuildResult {
364
364
  appName: string;
365
- type: 'backend' | 'frontend';
365
+ type: 'backend' | 'web' | 'mobile';
366
366
  success: boolean;
367
367
  outputPath?: string;
368
368
  error?: string;
@@ -415,7 +415,7 @@ export async function workspaceBuildCommand(
415
415
  const results: AppBuildResult[] = [];
416
416
  const apps = Object.entries(workspace.apps);
417
417
  const backendApps = apps.filter(([, app]) => app.type === 'backend');
418
- const frontendApps = apps.filter(([, app]) => app.type === 'frontend');
418
+ const frontendApps = apps.filter(([, app]) => app.type === 'web');
419
419
 
420
420
  logger.log(`\n🏗️ Building workspace: ${workspace.name}`);
421
421
  logger.log(
@@ -486,7 +486,12 @@ export async function workspaceBuildCommand(
486
486
  // Summary
487
487
  logger.log(`\n📋 Build Summary:`);
488
488
  for (const result of results) {
489
- const icon = result.type === 'backend' ? '⚙️' : '🌐';
489
+ const icon =
490
+ result.type === 'backend'
491
+ ? '⚙️'
492
+ : result.type === 'mobile'
493
+ ? '📱'
494
+ : '🌐';
490
495
  logger.log(
491
496
  ` ${icon} ${result.appName}: ${result.outputPath || 'built'}`,
492
497
  );
@@ -522,13 +527,24 @@ function getAppOutputPath(
522
527
  ): string {
523
528
  const appPath = join(workspace.root, app.path);
524
529
 
525
- if (app.type === 'frontend') {
526
- // Next.js standalone output
527
- return join(appPath, '.next');
528
- } else {
529
- // Backend .gkm output
530
- return join(appPath, '.gkm');
530
+ if (app.type === 'mobile') {
531
+ // Mobile builds are produced by the framework's own toolchain
532
+ // (e.g. EAS Build) — no local output path.
533
+ return '';
534
+ }
535
+ if (app.type === 'web') {
536
+ switch (app.framework) {
537
+ case 'vite':
538
+ case 'tanstack-start':
539
+ return join(appPath, 'dist');
540
+ case 'remix':
541
+ return join(appPath, 'build');
542
+ default:
543
+ return join(appPath, '.next');
544
+ }
531
545
  }
546
+ // Backend .gkm output
547
+ return join(appPath, '.gkm');
532
548
  }
533
549
 
534
550
  /**
@@ -76,7 +76,7 @@ export function createWorkspaceConfig(
76
76
  const deps = app.dependencies
77
77
  ? `dependencies: [${app.dependencies.map((d) => `'${d}'`).join(', ')}],`
78
78
  : '';
79
- if (app.type === 'frontend') {
79
+ if (app.type === 'web') {
80
80
  const framework = app.framework ?? 'nextjs';
81
81
  return ` ${name}: {
82
82
  type: '${app.type}',
@@ -45,7 +45,7 @@ describe('workspace credentials', () => {
45
45
  {
46
46
  api: { type: 'backend', path: 'apps/api', port: 3001 },
47
47
  web: {
48
- type: 'frontend',
48
+ type: 'web',
49
49
  path: 'apps/web',
50
50
  port: 3002,
51
51
  dependencies: ['api'],
@@ -55,7 +55,7 @@ describe('resolveHost', () => {
55
55
  });
56
56
 
57
57
  it('should return base domain for main frontend app', () => {
58
- const app = createApp({ type: 'frontend' });
58
+ const app = createApp({ type: 'web' });
59
59
  const host = resolveHost('web', app, 'production', dokployConfig, true);
60
60
  expect(host).toBe('myapp.com');
61
61
  });
@@ -100,7 +100,7 @@ describe('resolveHost', () => {
100
100
  });
101
101
 
102
102
  describe('isMainFrontendApp', () => {
103
- const createApp = (type: 'backend' | 'frontend'): NormalizedAppConfig => ({
103
+ const createApp = (type: 'backend' | 'web'): NormalizedAppConfig => ({
104
104
  type,
105
105
  path: 'apps/test',
106
106
  port: 3000,
@@ -111,7 +111,7 @@ describe('isMainFrontendApp', () => {
111
111
  it('should return false for backend apps', () => {
112
112
  const apps = {
113
113
  api: createApp('backend'),
114
- web: createApp('frontend'),
114
+ web: createApp('web'),
115
115
  };
116
116
  expect(isMainFrontendApp('api', apps.api, apps)).toBe(false);
117
117
  });
@@ -119,8 +119,8 @@ describe('isMainFrontendApp', () => {
119
119
  it('should return true for app named "web" if it is frontend', () => {
120
120
  const apps = {
121
121
  api: createApp('backend'),
122
- web: createApp('frontend'),
123
- admin: createApp('frontend'),
122
+ web: createApp('web'),
123
+ admin: createApp('web'),
124
124
  };
125
125
  expect(isMainFrontendApp('web', apps.web, apps)).toBe(true);
126
126
  });
@@ -128,8 +128,8 @@ describe('isMainFrontendApp', () => {
128
128
  it('should return true for first frontend app when no "web" app', () => {
129
129
  const apps = {
130
130
  api: createApp('backend'),
131
- dashboard: createApp('frontend'),
132
- admin: createApp('frontend'),
131
+ dashboard: createApp('web'),
132
+ admin: createApp('web'),
133
133
  };
134
134
  expect(isMainFrontendApp('dashboard', apps.dashboard, apps)).toBe(true);
135
135
  expect(isMainFrontendApp('admin', apps.admin, apps)).toBe(false);
@@ -138,22 +138,22 @@ describe('isMainFrontendApp', () => {
138
138
  it('should return false for non-first frontend when no "web" app', () => {
139
139
  const apps = {
140
140
  api: createApp('backend'),
141
- dashboard: createApp('frontend'),
142
- admin: createApp('frontend'),
141
+ dashboard: createApp('web'),
142
+ admin: createApp('web'),
143
143
  };
144
144
  expect(isMainFrontendApp('admin', apps.admin, apps)).toBe(false);
145
145
  });
146
146
 
147
147
  it('should return false when no frontend apps in allApps (edge case)', () => {
148
148
  // Edge case: checking a frontend app against an empty allApps
149
- const frontendApp = createApp('frontend');
149
+ const frontendApp = createApp('web');
150
150
  expect(isMainFrontendApp('myapp', frontendApp, {})).toBe(false);
151
151
  });
152
152
  });
153
153
 
154
154
  describe('generatePublicUrlBuildArgs', () => {
155
155
  const createApp = (dependencies: string[]): NormalizedAppConfig => ({
156
- type: 'frontend',
156
+ type: 'web',
157
157
  path: 'apps/web',
158
158
  port: 3001,
159
159
  dependencies,
@@ -214,7 +214,7 @@ describe('generatePublicUrlBuildArgs', () => {
214
214
 
215
215
  describe('getPublicUrlArgNames', () => {
216
216
  const createApp = (dependencies: string[]): NormalizedAppConfig => ({
217
- type: 'frontend',
217
+ type: 'web',
218
218
  path: 'apps/web',
219
219
  port: 3001,
220
220
  dependencies,
@@ -517,6 +517,34 @@ describe('resolveEnvVar', () => {
517
517
  expect(resolveEnvVar('NEXT_PUBLIC_AUTH_URL', context)).toBeUndefined();
518
518
  });
519
519
  });
520
+
521
+ describe('VITE_ prefix', () => {
522
+ it('should resolve VITE_API_URL from dependencyUrls.api', () => {
523
+ const context = createContext({
524
+ dependencyUrls: { api: 'https://api.example.com' },
525
+ });
526
+
527
+ expect(resolveEnvVar('VITE_API_URL', context)).toBe(
528
+ 'https://api.example.com',
529
+ );
530
+ });
531
+
532
+ it('should resolve all of AUTH_URL, NEXT_PUBLIC_AUTH_URL, and VITE_AUTH_URL to the same value', () => {
533
+ const context = createContext({
534
+ dependencyUrls: { auth: 'https://auth.example.com' },
535
+ });
536
+
537
+ expect(resolveEnvVar('AUTH_URL', context)).toBe(
538
+ 'https://auth.example.com',
539
+ );
540
+ expect(resolveEnvVar('NEXT_PUBLIC_AUTH_URL', context)).toBe(
541
+ 'https://auth.example.com',
542
+ );
543
+ expect(resolveEnvVar('VITE_AUTH_URL', context)).toBe(
544
+ 'https://auth.example.com',
545
+ );
546
+ });
547
+ });
520
548
  });
521
549
  });
522
550
 
@@ -710,7 +738,7 @@ describe('Docker build arg extraction', () => {
710
738
  overrides: Partial<EnvResolverContext> = {},
711
739
  ): EnvResolverContext => ({
712
740
  app: {
713
- type: 'frontend',
741
+ type: 'web',
714
742
  path: 'apps/web',
715
743
  port: 3001,
716
744
  dependencies: ['api', 'auth'],