@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
|
@@ -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.
|
|
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.
|
|
60
|
-
"@geekmidas/envkit": "~1.0.
|
|
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.
|
|
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
|
|
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
|
-
|
|
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: '
|
|
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('
|
|
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: '
|
|
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: '
|
|
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 === '
|
|
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: '
|
|
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 === '
|
|
210
|
+
frontendApp.type === 'web' ? '.next' : '.gkm',
|
|
211
211
|
);
|
|
212
212
|
expect(frontendOutputPath).toBe('/workspace/apps/web/.next');
|
|
213
213
|
});
|
package/src/build/index.ts
CHANGED
|
@@ -362,7 +362,7 @@ async function buildForProvider(
|
|
|
362
362
|
*/
|
|
363
363
|
export interface AppBuildResult {
|
|
364
364
|
appName: string;
|
|
365
|
-
type: 'backend' | '
|
|
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 === '
|
|
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 =
|
|
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 === '
|
|
526
|
-
//
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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 === '
|
|
79
|
+
if (app.type === 'web') {
|
|
80
80
|
const framework = app.framework ?? 'nextjs';
|
|
81
81
|
return ` ${name}: {
|
|
82
82
|
type: '${app.type}',
|
|
@@ -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: '
|
|
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' | '
|
|
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('
|
|
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('
|
|
123
|
-
admin: createApp('
|
|
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('
|
|
132
|
-
admin: createApp('
|
|
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('
|
|
142
|
-
admin: createApp('
|
|
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('
|
|
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: '
|
|
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: '
|
|
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: '
|
|
741
|
+
type: 'web',
|
|
714
742
|
path: 'apps/web',
|
|
715
743
|
port: 3001,
|
|
716
744
|
dependencies: ['api', 'auth'],
|