@geekmidas/cli 1.5.0 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/dist/{HostingerProvider-B9N-TKbp.mjs → HostingerProvider-402UdK89.mjs} +34 -1
- package/dist/HostingerProvider-402UdK89.mjs.map +1 -0
- package/dist/{HostingerProvider-DUV9-Tzg.cjs → HostingerProvider-BiXdHjiq.cjs} +34 -1
- package/dist/HostingerProvider-BiXdHjiq.cjs.map +1 -0
- package/dist/{Route53Provider-C8mS0zY6.mjs → Route53Provider-DbBo7Uz5.mjs} +53 -1
- package/dist/Route53Provider-DbBo7Uz5.mjs.map +1 -0
- package/dist/{Route53Provider-Bs7Arms9.cjs → Route53Provider-kfJ77LmL.cjs} +53 -1
- package/dist/Route53Provider-kfJ77LmL.cjs.map +1 -0
- package/dist/backup-provisioner-B5e-F6zX.cjs +164 -0
- package/dist/backup-provisioner-B5e-F6zX.cjs.map +1 -0
- package/dist/backup-provisioner-BIArpmTr.mjs +163 -0
- package/dist/backup-provisioner-BIArpmTr.mjs.map +1 -0
- package/dist/{config-ZQM1vBoz.cjs → config-6JHOwLCx.cjs} +30 -2
- package/dist/{config-ZQM1vBoz.cjs.map → config-6JHOwLCx.cjs.map} +1 -1
- package/dist/{config-DfCJ29PQ.mjs → config-DxASSNjr.mjs} +25 -3
- package/dist/{config-DfCJ29PQ.mjs.map → config-DxASSNjr.mjs.map} +1 -1
- package/dist/config.cjs +3 -2
- package/dist/config.d.cts +14 -2
- package/dist/config.d.cts.map +1 -1
- package/dist/config.d.mts +15 -3
- package/dist/config.d.mts.map +1 -1
- package/dist/config.mjs +3 -3
- package/dist/{dokploy-api-z0833e7r.mjs → dokploy-api-2ldYoN3i.mjs} +131 -1
- package/dist/dokploy-api-2ldYoN3i.mjs.map +1 -0
- package/dist/dokploy-api-C93pveuy.mjs +3 -0
- package/dist/dokploy-api-CbDh4o93.cjs +3 -0
- package/dist/{dokploy-api-CQvhV6Hd.cjs → dokploy-api-DLgvEQlr.cjs} +131 -1
- package/dist/dokploy-api-DLgvEQlr.cjs.map +1 -0
- package/dist/{index-C0SpUT9Y.d.mts → index-C-KxSGGK.d.mts} +133 -31
- package/dist/index-C-KxSGGK.d.mts.map +1 -0
- package/dist/{index-B58qjyBd.d.cts → index-Cyk2rTyj.d.cts} +132 -30
- package/dist/index-Cyk2rTyj.d.cts.map +1 -0
- package/dist/index.cjs +662 -152
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +626 -116
- package/dist/index.mjs.map +1 -1
- package/dist/{openapi-BcSjLfWq.mjs → openapi-BYlyAbH3.mjs} +6 -5
- package/dist/openapi-BYlyAbH3.mjs.map +1 -0
- package/dist/{openapi-D6Hcfov0.cjs → openapi-CnvwSRDU.cjs} +6 -5
- package/dist/openapi-CnvwSRDU.cjs.map +1 -0
- package/dist/openapi.cjs +3 -3
- package/dist/openapi.d.cts +1 -0
- package/dist/openapi.d.cts.map +1 -1
- package/dist/openapi.d.mts +2 -1
- package/dist/openapi.d.mts.map +1 -1
- package/dist/openapi.mjs +3 -3
- package/dist/{types-B9UZ7fOG.d.mts → types-CZg5iUgD.d.mts} +1 -1
- package/dist/{types-B9UZ7fOG.d.mts.map → types-CZg5iUgD.d.mts.map} +1 -1
- package/dist/workspace/index.cjs +1 -1
- package/dist/workspace/index.d.cts +1 -1
- package/dist/workspace/index.d.mts +2 -2
- package/dist/workspace/index.mjs +1 -1
- package/dist/{workspace-BW2iU37P.mjs → workspace-9IQIjwkQ.mjs} +20 -4
- package/dist/workspace-9IQIjwkQ.mjs.map +1 -0
- package/dist/{workspace-2Do2YcGZ.cjs → workspace-D2ocAlpl.cjs} +20 -4
- package/dist/workspace-D2ocAlpl.cjs.map +1 -0
- package/examples/cron-example.ts +6 -6
- package/examples/function-example.ts +1 -1
- package/package.json +6 -3
- package/src/config.ts +44 -0
- package/src/deploy/__tests__/backup-provisioner.spec.ts +428 -0
- package/src/deploy/__tests__/createDnsProvider.spec.ts +23 -0
- package/src/deploy/__tests__/env-resolver.spec.ts +1 -1
- package/src/deploy/__tests__/undeploy.spec.ts +758 -0
- package/src/deploy/backup-provisioner.ts +316 -0
- package/src/deploy/dns/DnsProvider.ts +39 -1
- package/src/deploy/dns/HostingerProvider.ts +74 -0
- package/src/deploy/dns/Route53Provider.ts +81 -0
- package/src/deploy/dns/index.ts +25 -0
- package/src/deploy/dokploy-api.ts +237 -0
- package/src/deploy/index.ts +71 -13
- package/src/deploy/state.ts +171 -0
- package/src/deploy/undeploy.ts +407 -0
- package/src/dev/__tests__/index.spec.ts +490 -0
- package/src/dev/index.ts +313 -18
- package/src/generators/FunctionGenerator.ts +1 -1
- package/src/generators/Generator.ts +4 -1
- package/src/init/__tests__/generators.spec.ts +167 -18
- package/src/init/__tests__/init.spec.ts +66 -3
- package/src/init/generators/auth.ts +6 -5
- package/src/init/generators/config.ts +49 -7
- package/src/init/generators/docker.ts +8 -8
- package/src/init/generators/index.ts +1 -0
- package/src/init/generators/models.ts +3 -5
- package/src/init/generators/package.ts +4 -0
- package/src/init/generators/test.ts +133 -0
- package/src/init/generators/ui.ts +13 -12
- package/src/init/generators/web.ts +9 -8
- package/src/init/index.ts +2 -0
- package/src/init/templates/api.ts +6 -6
- package/src/init/templates/minimal.ts +2 -2
- package/src/init/templates/worker.ts +2 -2
- package/src/init/versions.ts +3 -3
- package/src/openapi.ts +6 -2
- package/src/test/__tests__/__fixtures__/workspace.ts +104 -0
- package/src/test/__tests__/api.spec.ts +199 -0
- package/src/test/__tests__/auth.spec.ts +162 -0
- package/src/test/__tests__/index.spec.ts +323 -0
- package/src/test/__tests__/web.spec.ts +210 -0
- package/src/test/index.ts +165 -14
- package/src/workspace/__tests__/index.spec.ts +3 -0
- package/src/workspace/index.ts +4 -2
- package/src/workspace/schema.ts +26 -0
- package/src/workspace/types.ts +14 -37
- package/dist/HostingerProvider-B9N-TKbp.mjs.map +0 -1
- package/dist/HostingerProvider-DUV9-Tzg.cjs.map +0 -1
- package/dist/Route53Provider-Bs7Arms9.cjs.map +0 -1
- package/dist/Route53Provider-C8mS0zY6.mjs.map +0 -1
- package/dist/dokploy-api-CQvhV6Hd.cjs.map +0 -1
- package/dist/dokploy-api-CWc02yyg.cjs +0 -3
- package/dist/dokploy-api-DSJYNx88.mjs +0 -3
- package/dist/dokploy-api-z0833e7r.mjs.map +0 -1
- package/dist/index-B58qjyBd.d.cts.map +0 -1
- package/dist/index-C0SpUT9Y.d.mts.map +0 -1
- package/dist/openapi-BcSjLfWq.mjs.map +0 -1
- package/dist/openapi-D6Hcfov0.cjs.map +0 -1
- package/dist/workspace-2Do2YcGZ.cjs.map +0 -1
- package/dist/workspace-BW2iU37P.mjs.map +0 -1
package/src/dev/index.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { type ChildProcess, execSync, spawn } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
3
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { createServer } from 'node:net';
|
|
5
5
|
import { dirname, join, resolve } from 'node:path';
|
|
6
6
|
import chokidar from 'chokidar';
|
|
7
7
|
import { config as dotenvConfig } from 'dotenv';
|
|
8
8
|
import fg from 'fast-glob';
|
|
9
|
+
import { parse as parseYaml } from 'yaml';
|
|
9
10
|
import { resolveProviders } from '../build/providerResolver';
|
|
10
11
|
import type {
|
|
11
12
|
BuildContext,
|
|
@@ -17,6 +18,7 @@ import type {
|
|
|
17
18
|
import {
|
|
18
19
|
getAppNameFromCwd,
|
|
19
20
|
loadAppConfig,
|
|
21
|
+
loadWorkspaceAppInfo,
|
|
20
22
|
loadWorkspaceConfig,
|
|
21
23
|
parseModuleConfig,
|
|
22
24
|
} from '../config';
|
|
@@ -137,6 +139,255 @@ export async function findAvailablePort(
|
|
|
137
139
|
);
|
|
138
140
|
}
|
|
139
141
|
|
|
142
|
+
/**
|
|
143
|
+
* A port mapping extracted from docker-compose.yml.
|
|
144
|
+
* Only entries using env var interpolation (e.g., `${VAR:-default}:container`) are captured.
|
|
145
|
+
*/
|
|
146
|
+
export interface ComposePortMapping {
|
|
147
|
+
service: string;
|
|
148
|
+
envVar: string;
|
|
149
|
+
defaultPort: number;
|
|
150
|
+
containerPort: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Port state persisted to .gkm/ports.json, keyed by env var name. */
|
|
154
|
+
export type PortState = Record<string, number>;
|
|
155
|
+
|
|
156
|
+
export interface ResolvedServicePorts {
|
|
157
|
+
dockerEnv: Record<string, string>;
|
|
158
|
+
ports: PortState;
|
|
159
|
+
mappings: ComposePortMapping[];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const PORT_STATE_PATH = '.gkm/ports.json';
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Parse docker-compose.yml and extract all port mappings that use env var interpolation.
|
|
166
|
+
* Entries like `'${POSTGRES_HOST_PORT:-5432}:5432'` are captured.
|
|
167
|
+
* Fixed port mappings like `'5050:80'` are skipped.
|
|
168
|
+
* @internal Exported for testing
|
|
169
|
+
*/
|
|
170
|
+
export function parseComposePortMappings(
|
|
171
|
+
composePath: string,
|
|
172
|
+
): ComposePortMapping[] {
|
|
173
|
+
if (!existsSync(composePath)) {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const content = readFileSync(composePath, 'utf-8');
|
|
178
|
+
const compose = parseYaml(content) as {
|
|
179
|
+
services?: Record<string, { ports?: string[] }>;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
if (!compose?.services) {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const results: ComposePortMapping[] = [];
|
|
187
|
+
|
|
188
|
+
for (const [serviceName, serviceConfig] of Object.entries(compose.services)) {
|
|
189
|
+
for (const portMapping of serviceConfig?.ports ?? []) {
|
|
190
|
+
const match = String(portMapping).match(/\$\{(\w+):-(\d+)\}:(\d+)/);
|
|
191
|
+
if (match?.[1] && match[2] && match[3]) {
|
|
192
|
+
results.push({
|
|
193
|
+
service: serviceName,
|
|
194
|
+
envVar: match[1],
|
|
195
|
+
defaultPort: Number(match[2]),
|
|
196
|
+
containerPort: Number(match[3]),
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return results;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Load saved port state from .gkm/ports.json.
|
|
207
|
+
* @internal Exported for testing
|
|
208
|
+
*/
|
|
209
|
+
export async function loadPortState(workspaceRoot: string): Promise<PortState> {
|
|
210
|
+
try {
|
|
211
|
+
const raw = await readFile(join(workspaceRoot, PORT_STATE_PATH), 'utf-8');
|
|
212
|
+
return JSON.parse(raw) as PortState;
|
|
213
|
+
} catch {
|
|
214
|
+
return {};
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Save port state to .gkm/ports.json.
|
|
220
|
+
* @internal Exported for testing
|
|
221
|
+
*/
|
|
222
|
+
export async function savePortState(
|
|
223
|
+
workspaceRoot: string,
|
|
224
|
+
ports: PortState,
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
const dir = join(workspaceRoot, '.gkm');
|
|
227
|
+
await mkdir(dir, { recursive: true });
|
|
228
|
+
await writeFile(
|
|
229
|
+
join(workspaceRoot, PORT_STATE_PATH),
|
|
230
|
+
`${JSON.stringify(ports, null, 2)}\n`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Check if a project's own Docker container is running and return its host port.
|
|
236
|
+
* Uses `docker compose port` scoped to the project's compose file.
|
|
237
|
+
* @internal Exported for testing
|
|
238
|
+
*/
|
|
239
|
+
export function getContainerHostPort(
|
|
240
|
+
workspaceRoot: string,
|
|
241
|
+
service: string,
|
|
242
|
+
containerPort: number,
|
|
243
|
+
): number | null {
|
|
244
|
+
try {
|
|
245
|
+
const result = execSync(`docker compose port ${service} ${containerPort}`, {
|
|
246
|
+
cwd: workspaceRoot,
|
|
247
|
+
stdio: 'pipe',
|
|
248
|
+
})
|
|
249
|
+
.toString()
|
|
250
|
+
.trim();
|
|
251
|
+
const match = result.match(/:(\d+)$/);
|
|
252
|
+
return match ? Number(match[1]) : null;
|
|
253
|
+
} catch {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Resolve host ports for Docker services by parsing docker-compose.yml.
|
|
260
|
+
* Priority: running container → saved state → find available port.
|
|
261
|
+
* Persists resolved ports to .gkm/ports.json.
|
|
262
|
+
* @internal Exported for testing
|
|
263
|
+
*/
|
|
264
|
+
export async function resolveServicePorts(
|
|
265
|
+
workspaceRoot: string,
|
|
266
|
+
): Promise<ResolvedServicePorts> {
|
|
267
|
+
const composePath = join(workspaceRoot, 'docker-compose.yml');
|
|
268
|
+
const mappings = parseComposePortMappings(composePath);
|
|
269
|
+
|
|
270
|
+
if (mappings.length === 0) {
|
|
271
|
+
return { dockerEnv: {}, ports: {}, mappings: [] };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const savedState = await loadPortState(workspaceRoot);
|
|
275
|
+
const dockerEnv: Record<string, string> = {};
|
|
276
|
+
const ports: PortState = {};
|
|
277
|
+
|
|
278
|
+
logger.log('\n🔌 Resolving service ports...');
|
|
279
|
+
|
|
280
|
+
for (const mapping of mappings) {
|
|
281
|
+
// 1. Check if own container is already running
|
|
282
|
+
const containerPort = getContainerHostPort(
|
|
283
|
+
workspaceRoot,
|
|
284
|
+
mapping.service,
|
|
285
|
+
mapping.containerPort,
|
|
286
|
+
);
|
|
287
|
+
if (containerPort !== null) {
|
|
288
|
+
ports[mapping.envVar] = containerPort;
|
|
289
|
+
dockerEnv[mapping.envVar] = String(containerPort);
|
|
290
|
+
logger.log(
|
|
291
|
+
` 🔄 ${mapping.service}:${mapping.containerPort}: reusing existing container on port ${containerPort}`,
|
|
292
|
+
);
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 2. Check saved port state
|
|
297
|
+
const savedPort = savedState[mapping.envVar];
|
|
298
|
+
if (savedPort && (await isPortAvailable(savedPort))) {
|
|
299
|
+
ports[mapping.envVar] = savedPort;
|
|
300
|
+
dockerEnv[mapping.envVar] = String(savedPort);
|
|
301
|
+
logger.log(
|
|
302
|
+
` 💾 ${mapping.service}:${mapping.containerPort}: using saved port ${savedPort}`,
|
|
303
|
+
);
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// 3. Find available port
|
|
308
|
+
const resolvedPort = await findAvailablePort(mapping.defaultPort);
|
|
309
|
+
ports[mapping.envVar] = resolvedPort;
|
|
310
|
+
dockerEnv[mapping.envVar] = String(resolvedPort);
|
|
311
|
+
|
|
312
|
+
if (resolvedPort !== mapping.defaultPort) {
|
|
313
|
+
logger.log(
|
|
314
|
+
` ⚡ ${mapping.service}:${mapping.containerPort}: port ${mapping.defaultPort} occupied, using port ${resolvedPort}`,
|
|
315
|
+
);
|
|
316
|
+
} else {
|
|
317
|
+
logger.log(
|
|
318
|
+
` ✅ ${mapping.service}:${mapping.containerPort}: using default port ${resolvedPort}`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
await savePortState(workspaceRoot, ports);
|
|
324
|
+
|
|
325
|
+
return { dockerEnv, ports, mappings };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Replace a port in a URL string.
|
|
330
|
+
* Handles both `hostname:port` and `localhost:port` patterns.
|
|
331
|
+
* @internal Exported for testing
|
|
332
|
+
*/
|
|
333
|
+
export function replacePortInUrl(
|
|
334
|
+
url: string,
|
|
335
|
+
oldPort: number,
|
|
336
|
+
newPort: number,
|
|
337
|
+
): string {
|
|
338
|
+
if (oldPort === newPort) return url;
|
|
339
|
+
return url.replace(new RegExp(`:${oldPort}(?=/|$)`, 'g'), `:${newPort}`);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Rewrite connection URLs and port vars in secrets with resolved ports.
|
|
344
|
+
* Uses the parsed compose mappings to determine which default ports to replace.
|
|
345
|
+
* Pure transform — does not modify secrets on disk.
|
|
346
|
+
* @internal Exported for testing
|
|
347
|
+
*/
|
|
348
|
+
export function rewriteUrlsWithPorts(
|
|
349
|
+
secrets: Record<string, string>,
|
|
350
|
+
resolvedPorts: ResolvedServicePorts,
|
|
351
|
+
): Record<string, string> {
|
|
352
|
+
const { ports, mappings } = resolvedPorts;
|
|
353
|
+
const result = { ...secrets };
|
|
354
|
+
|
|
355
|
+
// Build a map of defaultPort → resolvedPort for all changed ports
|
|
356
|
+
const portReplacements: { defaultPort: number; resolvedPort: number }[] = [];
|
|
357
|
+
for (const mapping of mappings) {
|
|
358
|
+
const resolved = ports[mapping.envVar];
|
|
359
|
+
if (resolved !== undefined) {
|
|
360
|
+
portReplacements.push({
|
|
361
|
+
defaultPort: mapping.defaultPort,
|
|
362
|
+
resolvedPort: resolved,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Rewrite _PORT env vars whose values match a default port
|
|
368
|
+
for (const [key, value] of Object.entries(result)) {
|
|
369
|
+
if (!key.endsWith('_PORT')) continue;
|
|
370
|
+
for (const { defaultPort, resolvedPort } of portReplacements) {
|
|
371
|
+
if (value === String(defaultPort)) {
|
|
372
|
+
result[key] = String(resolvedPort);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Rewrite URLs containing default ports
|
|
378
|
+
for (const [key, value] of Object.entries(result)) {
|
|
379
|
+
if (!key.endsWith('_URL') && key !== 'DATABASE_URL') continue;
|
|
380
|
+
|
|
381
|
+
let rewritten = value;
|
|
382
|
+
for (const { defaultPort, resolvedPort } of portReplacements) {
|
|
383
|
+
rewritten = replacePortInUrl(rewritten, defaultPort, resolvedPort);
|
|
384
|
+
}
|
|
385
|
+
result[key] = rewritten;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return result;
|
|
389
|
+
}
|
|
390
|
+
|
|
140
391
|
/**
|
|
141
392
|
* Normalize telescope configuration
|
|
142
393
|
* @internal Exported for testing
|
|
@@ -578,11 +829,12 @@ export async function devCommand(options: DevOptions): Promise<void> {
|
|
|
578
829
|
resolved.providers[0] as LegacyProvider,
|
|
579
830
|
enableOpenApi,
|
|
580
831
|
appRoot,
|
|
832
|
+
true, // bust module cache on rebuild
|
|
581
833
|
);
|
|
582
834
|
|
|
583
835
|
// Regenerate OpenAPI if enabled
|
|
584
836
|
if (enableOpenApi) {
|
|
585
|
-
await generateOpenApi(config, { silent: true });
|
|
837
|
+
await generateOpenApi(config, { silent: true, bustCache: true });
|
|
586
838
|
}
|
|
587
839
|
|
|
588
840
|
logger.log('✅ Rebuild complete, restarting server...');
|
|
@@ -848,6 +1100,7 @@ export async function loadSecretsForApp(
|
|
|
848
1100
|
*/
|
|
849
1101
|
export async function startWorkspaceServices(
|
|
850
1102
|
workspace: NormalizedWorkspace,
|
|
1103
|
+
portEnv?: Record<string, string>,
|
|
851
1104
|
): Promise<void> {
|
|
852
1105
|
const services = workspace.services;
|
|
853
1106
|
if (!services.db && !services.cache && !services.mail) {
|
|
@@ -886,6 +1139,7 @@ export async function startWorkspaceServices(
|
|
|
886
1139
|
execSync(`docker compose up -d ${servicesToStart.join(' ')}`, {
|
|
887
1140
|
cwd: workspace.root,
|
|
888
1141
|
stdio: 'inherit',
|
|
1142
|
+
env: { ...process.env, ...portEnv },
|
|
889
1143
|
});
|
|
890
1144
|
|
|
891
1145
|
logger.log('✅ Services started');
|
|
@@ -972,11 +1226,17 @@ async function workspaceDevCommand(
|
|
|
972
1226
|
}
|
|
973
1227
|
}
|
|
974
1228
|
|
|
975
|
-
//
|
|
976
|
-
await
|
|
1229
|
+
// Resolve dynamic service ports from docker-compose.yml
|
|
1230
|
+
const resolvedPorts = await resolveServicePorts(workspace.root);
|
|
1231
|
+
|
|
1232
|
+
// Start docker-compose services with resolved ports
|
|
1233
|
+
await startWorkspaceServices(workspace, resolvedPorts.dockerEnv);
|
|
977
1234
|
|
|
978
|
-
// Load secrets if enabled
|
|
979
|
-
const secretsEnv =
|
|
1235
|
+
// Load secrets if enabled, then rewrite URLs with resolved ports
|
|
1236
|
+
const secretsEnv = rewriteUrlsWithPorts(
|
|
1237
|
+
await loadDevSecrets(workspace),
|
|
1238
|
+
resolvedPorts,
|
|
1239
|
+
);
|
|
980
1240
|
if (Object.keys(secretsEnv).length > 0) {
|
|
981
1241
|
logger.log(` Loaded ${Object.keys(secretsEnv).length} secret(s)`);
|
|
982
1242
|
}
|
|
@@ -1196,6 +1456,7 @@ async function buildServer(
|
|
|
1196
1456
|
provider: LegacyProvider,
|
|
1197
1457
|
enableOpenApi: boolean,
|
|
1198
1458
|
appRoot: string = process.cwd(),
|
|
1459
|
+
bustCache = false,
|
|
1199
1460
|
): Promise<void> {
|
|
1200
1461
|
// Initialize generators
|
|
1201
1462
|
const endpointGenerator = new EndpointGenerator();
|
|
@@ -1206,11 +1467,13 @@ async function buildServer(
|
|
|
1206
1467
|
// Load all constructs (resolve paths relative to appRoot)
|
|
1207
1468
|
const [allEndpoints, allFunctions, allCrons, allSubscribers] =
|
|
1208
1469
|
await Promise.all([
|
|
1209
|
-
endpointGenerator.load(config.routes, appRoot),
|
|
1210
|
-
config.functions
|
|
1211
|
-
|
|
1470
|
+
endpointGenerator.load(config.routes, appRoot, bustCache),
|
|
1471
|
+
config.functions
|
|
1472
|
+
? functionGenerator.load(config.functions, appRoot, bustCache)
|
|
1473
|
+
: [],
|
|
1474
|
+
config.crons ? cronGenerator.load(config.crons, appRoot, bustCache) : [],
|
|
1212
1475
|
config.subscribers
|
|
1213
|
-
? subscriberGenerator.load(config.subscribers, appRoot)
|
|
1476
|
+
? subscriberGenerator.load(config.subscribers, appRoot, bustCache)
|
|
1214
1477
|
: [],
|
|
1215
1478
|
]);
|
|
1216
1479
|
|
|
@@ -1347,10 +1610,10 @@ export async function prepareEntryCredentials(options: {
|
|
|
1347
1610
|
let appName: string | undefined;
|
|
1348
1611
|
|
|
1349
1612
|
try {
|
|
1350
|
-
const
|
|
1351
|
-
workspaceAppPort =
|
|
1352
|
-
secretsRoot =
|
|
1353
|
-
appName =
|
|
1613
|
+
const appInfo = await loadWorkspaceAppInfo(cwd);
|
|
1614
|
+
workspaceAppPort = appInfo.app.port;
|
|
1615
|
+
secretsRoot = appInfo.workspaceRoot;
|
|
1616
|
+
appName = appInfo.appName;
|
|
1354
1617
|
} catch (error) {
|
|
1355
1618
|
// Not in a workspace - use defaults
|
|
1356
1619
|
logger.log(
|
|
@@ -1842,7 +2105,7 @@ export async function execCommand(
|
|
|
1842
2105
|
|
|
1843
2106
|
// Prepare credentials (loads workspace config and secrets)
|
|
1844
2107
|
// Don't inject PORT for exec since we're not running a server
|
|
1845
|
-
const { credentials, secretsJsonPath, appName } =
|
|
2108
|
+
const { credentials, secretsJsonPath, appName, secretsRoot } =
|
|
1846
2109
|
await prepareEntryCredentials({ cwd });
|
|
1847
2110
|
|
|
1848
2111
|
if (appName) {
|
|
@@ -1856,6 +2119,33 @@ export async function execCommand(
|
|
|
1856
2119
|
logger.log(`🔐 Loaded ${secretCount} secret(s)`);
|
|
1857
2120
|
}
|
|
1858
2121
|
|
|
2122
|
+
// Rewrite URLs with resolved Docker ports (from gkm dev)
|
|
2123
|
+
const composePath = join(secretsRoot, 'docker-compose.yml');
|
|
2124
|
+
const mappings = parseComposePortMappings(composePath);
|
|
2125
|
+
if (mappings.length > 0) {
|
|
2126
|
+
const ports = await loadPortState(secretsRoot);
|
|
2127
|
+
if (Object.keys(ports).length > 0) {
|
|
2128
|
+
const rewritten = rewriteUrlsWithPorts(credentials, {
|
|
2129
|
+
dockerEnv: {},
|
|
2130
|
+
ports,
|
|
2131
|
+
mappings,
|
|
2132
|
+
});
|
|
2133
|
+
Object.assign(credentials, rewritten);
|
|
2134
|
+
logger.log(`🔌 Applied ${Object.keys(ports).length} port mapping(s)`);
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
// Inject dependency URLs (works for both frontend and backend apps)
|
|
2139
|
+
try {
|
|
2140
|
+
const appInfo = await loadWorkspaceAppInfo(cwd);
|
|
2141
|
+
if (appInfo.appName) {
|
|
2142
|
+
const depEnv = getDependencyEnvVars(appInfo.workspace, appInfo.appName);
|
|
2143
|
+
Object.assign(credentials, depEnv);
|
|
2144
|
+
}
|
|
2145
|
+
} catch {
|
|
2146
|
+
// Not in a workspace — skip dependency URL injection
|
|
2147
|
+
}
|
|
2148
|
+
|
|
1859
2149
|
// Create preload script that injects Credentials
|
|
1860
2150
|
// Create in cwd so package resolution works (finds node_modules in app directory)
|
|
1861
2151
|
const preloadDir = join(cwd, '.gkm');
|
|
@@ -1864,13 +2154,18 @@ export async function execCommand(
|
|
|
1864
2154
|
await createCredentialsPreload(preloadPath, secretsJsonPath);
|
|
1865
2155
|
|
|
1866
2156
|
// Build command
|
|
1867
|
-
const [cmd, ...
|
|
2157
|
+
const [cmd, ...rawArgs] = commandArgs;
|
|
1868
2158
|
|
|
1869
2159
|
if (!cmd) {
|
|
1870
2160
|
throw new Error('No command specified');
|
|
1871
2161
|
}
|
|
1872
2162
|
|
|
1873
|
-
|
|
2163
|
+
// Replace template variables in command args (e.g. $PORT -> resolved port)
|
|
2164
|
+
const args = rawArgs.map((arg) =>
|
|
2165
|
+
arg.replace(/\$PORT\b/g, credentials.PORT ?? '3000'),
|
|
2166
|
+
);
|
|
2167
|
+
|
|
2168
|
+
logger.log(`🚀 Running: ${[cmd, ...args].join(' ')}`);
|
|
1874
2169
|
|
|
1875
2170
|
// Merge NODE_OPTIONS with existing value (if any)
|
|
1876
2171
|
// Add tsx loader first so our .ts preload can be loaded
|
|
@@ -99,7 +99,7 @@ export class FunctionGenerator extends ConstructGenerator<
|
|
|
99
99
|
context.loggerPath,
|
|
100
100
|
);
|
|
101
101
|
|
|
102
|
-
const content = `import { AWSLambdaFunction } from '@geekmidas/constructs/
|
|
102
|
+
const content = `import { AWSLambdaFunction } from '@geekmidas/constructs/aws';
|
|
103
103
|
import { ${exportName} } from '${importPath}';
|
|
104
104
|
import ${context.envParserImportPattern} from '${relativeEnvParserPath}';
|
|
105
105
|
import ${context.loggerImportPattern} from '${relativeLoggerPath}';
|
|
@@ -34,6 +34,7 @@ export abstract class ConstructGenerator<T extends Construct, R = void> {
|
|
|
34
34
|
async load(
|
|
35
35
|
patterns?: Routes,
|
|
36
36
|
cwd = process.cwd(),
|
|
37
|
+
bustCache = false,
|
|
37
38
|
): Promise<GeneratedConstruct<T>[]> {
|
|
38
39
|
const logger = console;
|
|
39
40
|
|
|
@@ -56,7 +57,9 @@ export abstract class ConstructGenerator<T extends Construct, R = void> {
|
|
|
56
57
|
for await (const f of files) {
|
|
57
58
|
try {
|
|
58
59
|
const file = f.toString();
|
|
59
|
-
|
|
60
|
+
// Append cache-busting query param to force re-import of changed modules
|
|
61
|
+
const importPath = bustCache ? `${file}?t=${Date.now()}` : file;
|
|
62
|
+
const module = await import(importPath);
|
|
60
63
|
|
|
61
64
|
// Check all exports for constructs
|
|
62
65
|
for (const [key, construct] of Object.entries(module)) {
|
|
@@ -5,7 +5,9 @@ import { generateEnvFiles } from '../generators/env.js';
|
|
|
5
5
|
import { generateModelsPackage } from '../generators/models.js';
|
|
6
6
|
import { generateMonorepoFiles } from '../generators/monorepo.js';
|
|
7
7
|
import { generatePackageJson } from '../generators/package.js';
|
|
8
|
+
import { generateTestFiles } from '../generators/test.js';
|
|
8
9
|
import { generateUiPackageFiles } from '../generators/ui.js';
|
|
10
|
+
import { apiTemplate } from '../templates/api.js';
|
|
9
11
|
import type { TemplateOptions } from '../templates/index.js';
|
|
10
12
|
import { minimalTemplate } from '../templates/minimal.js';
|
|
11
13
|
import { serverlessTemplate } from '../templates/serverless.js';
|
|
@@ -211,31 +213,46 @@ describe('generateDockerFiles', () => {
|
|
|
211
213
|
expect(files[0].path).toBe('docker-compose.yml');
|
|
212
214
|
});
|
|
213
215
|
|
|
214
|
-
it('should include postgres when database is enabled', () => {
|
|
216
|
+
it('should include postgres with dynamic port when database is enabled', () => {
|
|
215
217
|
const files = generateDockerFiles(baseOptions, minimalTemplate);
|
|
216
218
|
expect(files[0].content).toContain('postgres');
|
|
217
|
-
expect(files[0].content).toContain('5432');
|
|
219
|
+
expect(files[0].content).toContain("'${POSTGRES_HOST_PORT:-5432}:5432'");
|
|
218
220
|
});
|
|
219
221
|
|
|
220
|
-
it('should include redis', () => {
|
|
222
|
+
it('should include redis with dynamic port', () => {
|
|
221
223
|
const files = generateDockerFiles(baseOptions, minimalTemplate);
|
|
222
224
|
expect(files[0].content).toContain('redis');
|
|
223
|
-
expect(files[0].content).toContain('6379');
|
|
225
|
+
expect(files[0].content).toContain("'${REDIS_HOST_PORT:-6379}:6379'");
|
|
224
226
|
});
|
|
225
227
|
|
|
226
|
-
it('should include serverless-redis-http for serverless template', () => {
|
|
228
|
+
it('should include serverless-redis-http with dynamic port for serverless template', () => {
|
|
227
229
|
const options = { ...baseOptions, template: 'serverless' as const };
|
|
228
230
|
const files = generateDockerFiles(options, serverlessTemplate);
|
|
229
231
|
expect(files[0].content).toContain('hiett/serverless-redis-http');
|
|
230
|
-
expect(files[0].content).toContain('8079');
|
|
232
|
+
expect(files[0].content).toContain("'${SRH_HOST_PORT:-8079}:80'");
|
|
231
233
|
});
|
|
232
234
|
|
|
233
|
-
it('should include rabbitmq for worker template', () => {
|
|
235
|
+
it('should include rabbitmq with dynamic ports for worker template', () => {
|
|
234
236
|
const options = { ...baseOptions, template: 'worker' as const };
|
|
235
237
|
const files = generateDockerFiles(options, workerTemplate);
|
|
236
238
|
expect(files[0].content).toContain('rabbitmq');
|
|
237
|
-
expect(files[0].content).toContain('5672');
|
|
238
|
-
expect(files[0].content).toContain(
|
|
239
|
+
expect(files[0].content).toContain("'${RABBITMQ_HOST_PORT:-5672}:5672'");
|
|
240
|
+
expect(files[0].content).toContain(
|
|
241
|
+
"'${RABBITMQ_MGMT_HOST_PORT:-15672}:15672'",
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should include mailpit with dynamic ports when mail is enabled', () => {
|
|
246
|
+
const options = {
|
|
247
|
+
...baseOptions,
|
|
248
|
+
services: { db: true, cache: true, mail: true },
|
|
249
|
+
};
|
|
250
|
+
const files = generateDockerFiles(options, minimalTemplate);
|
|
251
|
+
expect(files[0].content).toContain('mailpit');
|
|
252
|
+
expect(files[0].content).toContain(
|
|
253
|
+
"'${MAILPIT_SMTP_HOST_PORT:-1025}:1025'",
|
|
254
|
+
);
|
|
255
|
+
expect(files[0].content).toContain("'${MAILPIT_UI_HOST_PORT:-8025}:8025'");
|
|
239
256
|
});
|
|
240
257
|
});
|
|
241
258
|
|
|
@@ -557,15 +574,15 @@ describe('generateUiPackageFiles', () => {
|
|
|
557
574
|
(f) => f.path === 'packages/ui/src/components/ui/index.ts',
|
|
558
575
|
);
|
|
559
576
|
expect(indexFile).toBeDefined();
|
|
560
|
-
expect(indexFile!.content).toContain("from './button'");
|
|
561
|
-
expect(indexFile!.content).toContain("from './input'");
|
|
562
|
-
expect(indexFile!.content).toContain("from './card'");
|
|
563
|
-
expect(indexFile!.content).toContain("from './label'");
|
|
564
|
-
expect(indexFile!.content).toContain("from './badge'");
|
|
565
|
-
expect(indexFile!.content).toContain("from './separator'");
|
|
566
|
-
expect(indexFile!.content).toContain("from './tabs'");
|
|
567
|
-
expect(indexFile!.content).toContain("from './tooltip'");
|
|
568
|
-
expect(indexFile!.content).toContain("from './dialog'");
|
|
577
|
+
expect(indexFile!.content).toContain("from './button.tsx'");
|
|
578
|
+
expect(indexFile!.content).toContain("from './input.tsx'");
|
|
579
|
+
expect(indexFile!.content).toContain("from './card.tsx'");
|
|
580
|
+
expect(indexFile!.content).toContain("from './label.tsx'");
|
|
581
|
+
expect(indexFile!.content).toContain("from './badge.tsx'");
|
|
582
|
+
expect(indexFile!.content).toContain("from './separator.tsx'");
|
|
583
|
+
expect(indexFile!.content).toContain("from './tabs.tsx'");
|
|
584
|
+
expect(indexFile!.content).toContain("from './tooltip.tsx'");
|
|
585
|
+
expect(indexFile!.content).toContain("from './dialog.tsx'");
|
|
569
586
|
});
|
|
570
587
|
|
|
571
588
|
it('should include cn utility function', () => {
|
|
@@ -645,3 +662,135 @@ describe('generateUiPackageFiles', () => {
|
|
|
645
662
|
expect(config.compilerOptions.paths['~/*']).toEqual(['./src/*']);
|
|
646
663
|
});
|
|
647
664
|
});
|
|
665
|
+
|
|
666
|
+
describe('generateTestFiles', () => {
|
|
667
|
+
it('should return empty array when database is disabled', () => {
|
|
668
|
+
const options = { ...baseOptions, database: false };
|
|
669
|
+
const files = generateTestFiles(options, minimalTemplate);
|
|
670
|
+
expect(files).toHaveLength(0);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('should generate all test infrastructure files when database is enabled', () => {
|
|
674
|
+
const files = generateTestFiles(baseOptions, minimalTemplate);
|
|
675
|
+
const paths = files.map((f) => f.path);
|
|
676
|
+
expect(paths).toContain('test/config.ts');
|
|
677
|
+
expect(paths).toContain('test/globalSetup.ts');
|
|
678
|
+
expect(paths).toContain('test/factory/index.ts');
|
|
679
|
+
expect(paths).toContain('test/factory/users.ts');
|
|
680
|
+
expect(paths).toContain('test/example.spec.ts');
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
it('should use wrapVitestKyselyTransaction in config', () => {
|
|
684
|
+
const files = generateTestFiles(baseOptions, minimalTemplate);
|
|
685
|
+
const configFile = files.find((f) => f.path === 'test/config.ts');
|
|
686
|
+
expect(configFile).toBeDefined();
|
|
687
|
+
expect(configFile!.content).toContain('wrapVitestKyselyTransaction');
|
|
688
|
+
expect(configFile!.content).toContain('@geekmidas/testkit/kysely');
|
|
689
|
+
expect(configFile!.content).toContain('~/services/database.ts');
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it('should use PostgresKyselyMigrator in globalSetup', () => {
|
|
693
|
+
const files = generateTestFiles(baseOptions, minimalTemplate);
|
|
694
|
+
const setupFile = files.find((f) => f.path === 'test/globalSetup.ts');
|
|
695
|
+
expect(setupFile).toBeDefined();
|
|
696
|
+
expect(setupFile!.content).toContain('PostgresKyselyMigrator');
|
|
697
|
+
expect(setupFile!.content).toContain('_test');
|
|
698
|
+
expect(setupFile!.content).toContain('migrateToLatest');
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it('should use KyselyFactory in factory files', () => {
|
|
702
|
+
const files = generateTestFiles(baseOptions, minimalTemplate);
|
|
703
|
+
const factoryIndex = files.find((f) => f.path === 'test/factory/index.ts');
|
|
704
|
+
expect(factoryIndex).toBeDefined();
|
|
705
|
+
expect(factoryIndex!.content).toContain('KyselyFactory');
|
|
706
|
+
expect(factoryIndex!.content).toContain('createFactory');
|
|
707
|
+
|
|
708
|
+
const usersBuilder = files.find((f) => f.path === 'test/factory/users.ts');
|
|
709
|
+
expect(usersBuilder).toBeDefined();
|
|
710
|
+
expect(usersBuilder!.content).toContain('KyselyFactory.createBuilder');
|
|
711
|
+
expect(usersBuilder!.content).toContain("'users'");
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
it('should generate example spec with transaction-wrapped it', () => {
|
|
715
|
+
const files = generateTestFiles(baseOptions, minimalTemplate);
|
|
716
|
+
const exampleSpec = files.find((f) => f.path === 'test/example.spec.ts');
|
|
717
|
+
expect(exampleSpec).toBeDefined();
|
|
718
|
+
expect(exampleSpec!.content).toContain("from './config.ts'");
|
|
719
|
+
expect(exampleSpec!.content).toContain('{ db }');
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it('should work with fullstack template options', () => {
|
|
723
|
+
const options: TemplateOptions = {
|
|
724
|
+
...baseOptions,
|
|
725
|
+
template: 'fullstack',
|
|
726
|
+
monorepo: true,
|
|
727
|
+
apiPath: 'apps/api',
|
|
728
|
+
};
|
|
729
|
+
const files = generateTestFiles(options, apiTemplate);
|
|
730
|
+
expect(files.length).toBeGreaterThan(0);
|
|
731
|
+
const paths = files.map((f) => f.path);
|
|
732
|
+
expect(paths).toContain('test/config.ts');
|
|
733
|
+
expect(paths).toContain('test/globalSetup.ts');
|
|
734
|
+
});
|
|
735
|
+
});
|
|
736
|
+
|
|
737
|
+
describe('generateConfigFiles - vitest.config.ts', () => {
|
|
738
|
+
it('should generate vitest.config.ts when database is enabled (standalone)', () => {
|
|
739
|
+
const files = generateConfigFiles(baseOptions, minimalTemplate);
|
|
740
|
+
const paths = files.map((f) => f.path);
|
|
741
|
+
expect(paths).toContain('vitest.config.ts');
|
|
742
|
+
|
|
743
|
+
const vitestConfig = files.find((f) => f.path === 'vitest.config.ts');
|
|
744
|
+
expect(vitestConfig!.content).toContain('globalSetup');
|
|
745
|
+
expect(vitestConfig!.content).toContain('./test/globalSetup.ts');
|
|
746
|
+
expect(vitestConfig!.content).toContain('vite-tsconfig-paths');
|
|
747
|
+
expect(vitestConfig!.content).not.toContain('globals: true');
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it('should not generate vitest.config.ts when database is disabled', () => {
|
|
751
|
+
const options = { ...baseOptions, database: false };
|
|
752
|
+
const files = generateConfigFiles(options, minimalTemplate);
|
|
753
|
+
const paths = files.map((f) => f.path);
|
|
754
|
+
expect(paths).not.toContain('vitest.config.ts');
|
|
755
|
+
});
|
|
756
|
+
|
|
757
|
+
it('should generate vitest.config.ts for monorepo app with database', () => {
|
|
758
|
+
const options: TemplateOptions = {
|
|
759
|
+
...baseOptions,
|
|
760
|
+
monorepo: true,
|
|
761
|
+
apiPath: 'apps/api',
|
|
762
|
+
};
|
|
763
|
+
const files = generateConfigFiles(options, minimalTemplate);
|
|
764
|
+
const paths = files.map((f) => f.path);
|
|
765
|
+
expect(paths).toContain('vitest.config.ts');
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it('should generate vitest.config.ts for fullstack template with database', () => {
|
|
769
|
+
const options: TemplateOptions = {
|
|
770
|
+
...baseOptions,
|
|
771
|
+
template: 'fullstack',
|
|
772
|
+
monorepo: true,
|
|
773
|
+
apiPath: 'apps/api',
|
|
774
|
+
};
|
|
775
|
+
const files = generateConfigFiles(options, apiTemplate);
|
|
776
|
+
const paths = files.map((f) => f.path);
|
|
777
|
+
expect(paths).toContain('vitest.config.ts');
|
|
778
|
+
});
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
describe('generatePackageJson - testkit dependencies', () => {
|
|
782
|
+
it('should include testkit and faker when database is enabled', () => {
|
|
783
|
+
const files = generatePackageJson(baseOptions, minimalTemplate);
|
|
784
|
+
const pkg = JSON.parse(files[0].content);
|
|
785
|
+
expect(pkg.devDependencies['@geekmidas/testkit']).toMatch(/^~/);
|
|
786
|
+
expect(pkg.devDependencies['@faker-js/faker']).toMatch(/^~/);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it('should not include testkit when database is disabled', () => {
|
|
790
|
+
const options = { ...baseOptions, database: false };
|
|
791
|
+
const files = generatePackageJson(options, minimalTemplate);
|
|
792
|
+
const pkg = JSON.parse(files[0].content);
|
|
793
|
+
expect(pkg.devDependencies['@geekmidas/testkit']).toBeUndefined();
|
|
794
|
+
expect(pkg.devDependencies['@faker-js/faker']).toBeUndefined();
|
|
795
|
+
});
|
|
796
|
+
});
|