@geekmidas/cli 1.10.17 → 1.10.19

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 (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/{bundler-B4AackW5.mjs → bundler-C5xkxnyr.mjs} +2 -2
  3. package/dist/{bundler-B4AackW5.mjs.map → bundler-C5xkxnyr.mjs.map} +1 -1
  4. package/dist/{bundler-BhhfkI9T.cjs → bundler-i-az1DZ2.cjs} +2 -2
  5. package/dist/{bundler-BhhfkI9T.cjs.map → bundler-i-az1DZ2.cjs.map} +1 -1
  6. package/dist/index.cjs +701 -707
  7. package/dist/index.cjs.map +1 -1
  8. package/dist/index.mjs +689 -695
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/{openapi-BYxAWwok.cjs → openapi-CsCNpSf8.cjs} +1 -1
  11. package/dist/{openapi-BYxAWwok.cjs.map → openapi-CsCNpSf8.cjs.map} +1 -1
  12. package/dist/{openapi-DenF-okj.mjs → openapi-kvwpKbNe.mjs} +1 -1
  13. package/dist/{openapi-DenF-okj.mjs.map → openapi-kvwpKbNe.mjs.map} +1 -1
  14. package/dist/openapi.cjs +1 -1
  15. package/dist/openapi.mjs +1 -1
  16. package/dist/{storage-DOEtT2Hr.cjs → storage-ChVQI_G7.cjs} +1 -1
  17. package/dist/{storage-dbb9RyBl.mjs → storage-CpMNB77O.mjs} +1 -1
  18. package/dist/{storage-dbb9RyBl.mjs.map → storage-CpMNB77O.mjs.map} +1 -1
  19. package/dist/{storage-B1wvztiJ.cjs → storage-DLEb8Dkd.cjs} +1 -1
  20. package/dist/{storage-B1wvztiJ.cjs.map → storage-DLEb8Dkd.cjs.map} +1 -1
  21. package/dist/{storage-Cs4WBsc4.mjs → storage-mwbL7PhP.mjs} +1 -1
  22. package/dist/{sync-DGXXSk2v.cjs → sync-BWD_I5Ai.cjs} +2 -2
  23. package/dist/{sync-DGXXSk2v.cjs.map → sync-BWD_I5Ai.cjs.map} +1 -1
  24. package/dist/sync-ByaRPBxh.cjs +4 -0
  25. package/dist/{sync-COnAugP-.mjs → sync-CYBVB64f.mjs} +1 -1
  26. package/dist/{sync-D_NowTkZ.mjs → sync-lExOTa9t.mjs} +2 -2
  27. package/dist/{sync-D_NowTkZ.mjs.map → sync-lExOTa9t.mjs.map} +1 -1
  28. package/package.json +2 -2
  29. package/src/credentials/__tests__/fullDockerPorts.spec.ts +144 -0
  30. package/src/credentials/__tests__/helpers.ts +112 -0
  31. package/src/credentials/__tests__/prepareEntryCredentials.spec.ts +125 -0
  32. package/src/credentials/__tests__/readonlyDockerPorts.spec.ts +190 -0
  33. package/src/credentials/__tests__/workspaceCredentials.spec.ts +209 -0
  34. package/src/credentials/index.ts +826 -0
  35. package/src/dev/index.ts +48 -830
  36. package/src/exec/index.ts +120 -0
  37. package/src/setup/index.ts +4 -1
  38. package/src/test/index.ts +32 -109
  39. package/dist/sync-D1Pa30oV.cjs +0 -4
package/src/dev/index.ts CHANGED
@@ -1,12 +1,9 @@
1
1
  import { type ChildProcess, execSync, spawn } from 'node:child_process';
2
- import { existsSync, readFileSync } from 'node:fs';
3
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
- import { createServer } from 'node:net';
2
+ import { existsSync } from 'node:fs';
3
+ import { mkdir, writeFile } from 'node:fs/promises';
5
4
  import { dirname, join, resolve } from 'node:path';
6
5
  import chokidar from 'chokidar';
7
- import { config as dotenvConfig } from 'dotenv';
8
6
  import fg from 'fast-glob';
9
- import { parse as parseYaml } from 'yaml';
10
7
  import { resolveProviders } from '../build/providerResolver';
11
8
  import type {
12
9
  BuildContext,
@@ -18,10 +15,20 @@ import type {
18
15
  import {
19
16
  getAppNameFromCwd,
20
17
  loadAppConfig,
21
- loadWorkspaceAppInfo,
22
18
  loadWorkspaceConfig,
23
19
  parseModuleConfig,
24
20
  } from '../config';
21
+ import {
22
+ createEntryWrapper,
23
+ findAvailablePort,
24
+ isPortAvailable,
25
+ loadEnvFiles,
26
+ loadSecretsForApp,
27
+ prepareEntryCredentials,
28
+ resolveServicePorts,
29
+ rewriteUrlsWithPorts,
30
+ startWorkspaceServices,
31
+ } from '../credentials';
25
32
  import {
26
33
  CronGenerator,
27
34
  EndpointGenerator,
@@ -58,380 +65,38 @@ import {
58
65
  type NormalizedWorkspace,
59
66
  } from '../workspace/index.js';
60
67
 
61
- const logger = console;
62
-
63
- /**
64
- * Load environment files
65
- * @internal Exported for testing
66
- */
67
- export function loadEnvFiles(
68
- envConfig: string | string[] | undefined,
69
- cwd: string = process.cwd(),
70
- ): { loaded: string[]; missing: string[] } {
71
- const loaded: string[] = [];
72
- const missing: string[] = [];
73
-
74
- // Normalize to array
75
- const envFiles = envConfig
76
- ? Array.isArray(envConfig)
77
- ? envConfig
78
- : [envConfig]
79
- : ['.env'];
80
-
81
- // Load each env file in order (later files override earlier)
82
- for (const envFile of envFiles) {
83
- const envPath = resolve(cwd, envFile);
84
- if (existsSync(envPath)) {
85
- dotenvConfig({ path: envPath, override: true, quiet: true });
86
- loaded.push(envFile);
87
- } else if (envConfig) {
88
- // Only report as missing if explicitly configured
89
- missing.push(envFile);
90
- }
91
- }
92
-
93
- return { loaded, missing };
94
- }
95
-
96
- /**
97
- * Check if a port is available
98
- * @internal Exported for testing
99
- */
100
- export async function isPortAvailable(port: number): Promise<boolean> {
101
- return new Promise((resolve) => {
102
- const server = createServer();
103
-
104
- server.once('error', (err: NodeJS.ErrnoException) => {
105
- if (err.code === 'EADDRINUSE') {
106
- resolve(false);
107
- } else {
108
- resolve(false);
109
- }
110
- });
111
-
112
- server.once('listening', () => {
113
- server.close();
114
- resolve(true);
115
- });
116
-
117
- server.listen(port);
118
- });
119
- }
120
-
121
- /**
122
- * Find an available port starting from the preferred port
123
- * @internal Exported for testing
124
- */
125
- export async function findAvailablePort(
126
- preferredPort: number,
127
- maxAttempts = 10,
128
- ): Promise<number> {
129
- for (let i = 0; i < maxAttempts; i++) {
130
- const port = preferredPort + i;
131
- if (await isPortAvailable(port)) {
132
- return port;
133
- }
134
- logger.log(`⚠️ Port ${port} is in use, trying ${port + 1}...`);
135
- }
136
-
137
- throw new Error(
138
- `Could not find an available port after trying ${maxAttempts} ports starting from ${preferredPort}`,
139
- );
140
- }
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
- // Track ports assigned in this cycle to avoid duplicates
278
- const assignedPorts = new Set<number>();
279
-
280
- logger.log('\n🔌 Resolving service ports...');
281
-
282
- for (const mapping of mappings) {
283
- // 1. Check if own container is already running
284
- const containerPort = getContainerHostPort(
285
- workspaceRoot,
286
- mapping.service,
287
- mapping.containerPort,
288
- );
289
- if (containerPort !== null) {
290
- ports[mapping.envVar] = containerPort;
291
- dockerEnv[mapping.envVar] = String(containerPort);
292
- assignedPorts.add(containerPort);
293
- logger.log(
294
- ` 🔄 ${mapping.service}:${mapping.containerPort}: reusing existing container on port ${containerPort}`,
295
- );
296
- continue;
297
- }
298
-
299
- // 2. Check saved port state
300
- const savedPort = savedState[mapping.envVar];
301
- if (
302
- savedPort &&
303
- !assignedPorts.has(savedPort) &&
304
- (await isPortAvailable(savedPort))
305
- ) {
306
- ports[mapping.envVar] = savedPort;
307
- dockerEnv[mapping.envVar] = String(savedPort);
308
- assignedPorts.add(savedPort);
309
- logger.log(
310
- ` 💾 ${mapping.service}:${mapping.containerPort}: using saved port ${savedPort}`,
311
- );
312
- continue;
313
- }
314
-
315
- // 3. Find available port (skipping ports already assigned this cycle)
316
- let resolvedPort = await findAvailablePort(mapping.defaultPort);
317
- while (assignedPorts.has(resolvedPort)) {
318
- resolvedPort = await findAvailablePort(resolvedPort + 1);
319
- }
320
- ports[mapping.envVar] = resolvedPort;
321
- dockerEnv[mapping.envVar] = String(resolvedPort);
322
- assignedPorts.add(resolvedPort);
323
-
324
- if (resolvedPort !== mapping.defaultPort) {
325
- logger.log(
326
- ` ⚡ ${mapping.service}:${mapping.containerPort}: port ${mapping.defaultPort} occupied, using port ${resolvedPort}`,
327
- );
328
- } else {
329
- logger.log(
330
- ` ✅ ${mapping.service}:${mapping.containerPort}: using default port ${resolvedPort}`,
331
- );
332
- }
333
- }
334
-
335
- await savePortState(workspaceRoot, ports);
336
-
337
- return { dockerEnv, ports, mappings };
338
- }
339
-
340
- /**
341
- * Replace a port in a URL string.
342
- * Handles both `hostname:port` and `localhost:port` patterns.
343
- * @internal Exported for testing
344
- */
345
- export function replacePortInUrl(
346
- url: string,
347
- oldPort: number,
348
- newPort: number,
349
- ): string {
350
- if (oldPort === newPort) return url;
351
- // Replace literal :port (in authority section)
352
- let result = url.replace(
353
- new RegExp(`:${oldPort}(?=[/?#]|$)`, 'g'),
354
- `:${newPort}`,
355
- );
356
- // Replace URL-encoded :port (e.g., in query params like endpoint=http%3A%2F%2Flocalhost%3A4566)
357
- result = result.replace(
358
- new RegExp(`%3A${oldPort}(?=[%/?#&]|$)`, 'gi'),
359
- `%3A${newPort}`,
360
- );
361
- return result;
362
- }
363
-
364
- /**
365
- * Rewrite connection URLs and port vars in secrets with resolved ports.
366
- * Uses the parsed compose mappings to determine which default ports to replace.
367
- * Pure transform — does not modify secrets on disk.
368
- * @internal Exported for testing
369
- */
370
- export function rewriteUrlsWithPorts(
371
- secrets: Record<string, string>,
372
- resolvedPorts: ResolvedServicePorts,
373
- ): Record<string, string> {
374
- const { ports, mappings } = resolvedPorts;
375
- const result = { ...secrets };
376
-
377
- // Build a map of defaultPort → resolvedPort for all changed ports
378
- const portReplacements: { defaultPort: number; resolvedPort: number }[] = [];
379
- // Collect Docker service names for hostname rewriting
380
- const serviceNames = new Set<string>();
381
- for (const mapping of mappings) {
382
- serviceNames.add(mapping.service);
383
- const resolved = ports[mapping.envVar];
384
- if (resolved !== undefined) {
385
- portReplacements.push({
386
- defaultPort: mapping.defaultPort,
387
- resolvedPort: resolved,
388
- });
389
- }
390
- }
391
-
392
- // Rewrite _HOST env vars that use Docker service names
393
- for (const [key, value] of Object.entries(result)) {
394
- if (!key.endsWith('_HOST')) continue;
395
- if (serviceNames.has(value)) {
396
- result[key] = 'localhost';
397
- }
398
- }
399
-
400
- // Rewrite _PORT env vars whose values match a default port
401
- for (const [key, value] of Object.entries(result)) {
402
- if (!key.endsWith('_PORT')) continue;
403
- for (const { defaultPort, resolvedPort } of portReplacements) {
404
- if (value === String(defaultPort)) {
405
- result[key] = String(resolvedPort);
406
- }
407
- }
408
- }
409
-
410
- // Rewrite URLs: replace Docker service hostnames with localhost and fix ports
411
- for (const [key, value] of Object.entries(result)) {
412
- if (
413
- !key.endsWith('_URL') &&
414
- !key.endsWith('_ENDPOINT') &&
415
- !key.endsWith('_CONNECTION_STRING') &&
416
- key !== 'DATABASE_URL'
417
- )
418
- continue;
419
-
420
- let rewritten = value;
421
- for (const name of serviceNames) {
422
- rewritten = rewritten.replace(
423
- new RegExp(`@${name}:`, 'g'),
424
- '@localhost:',
425
- );
426
- }
427
- for (const { defaultPort, resolvedPort } of portReplacements) {
428
- rewritten = replacePortInUrl(rewritten, defaultPort, resolvedPort);
429
- }
430
- result[key] = rewritten;
431
- }
68
+ // Re-export shared utilities from credentials module so existing imports
69
+ // from '../dev' or '../dev/index' continue to work.
70
+ export {
71
+ buildDockerComposeEnv,
72
+ type ComposePortMapping,
73
+ createCredentialsPreload,
74
+ createEntryWrapper,
75
+ type EntryCredentialsResult,
76
+ findAvailablePort,
77
+ findSecretsRoot,
78
+ getContainerHostPort,
79
+ isPortAvailable,
80
+ loadEnvFiles,
81
+ loadPortState,
82
+ loadSecretsForApp,
83
+ type PortState,
84
+ parseComposePortMappings,
85
+ parseComposeServiceNames,
86
+ prepareEntryCredentials,
87
+ type ResolvedServicePorts,
88
+ replacePortInUrl,
89
+ resolveServicePorts,
90
+ rewriteUrlsWithPorts,
91
+ savePortState,
92
+ startComposeServices,
93
+ startWorkspaceServices,
94
+ } from '../credentials';
95
+
96
+ // Re-export execCommand from its own module
97
+ export { type ExecOptions, execCommand } from '../exec';
432
98
 
433
- return result;
434
- }
99
+ const logger = console;
435
100
 
436
101
  /**
437
102
  * Normalize telescope configuration
@@ -767,7 +432,10 @@ export async function devCommand(options: DevOptions): Promise<void> {
767
432
  if (Object.keys(appSecrets).length > 0) {
768
433
  const secretsDir = join(secretsRoot, '.gkm');
769
434
  await mkdir(secretsDir, { recursive: true });
770
- secretsJsonPath = join(secretsDir, 'dev-secrets.json');
435
+ const secretsFileName = workspaceAppName
436
+ ? `dev-secrets-${workspaceAppName}.json`
437
+ : 'dev-secrets.json';
438
+ secretsJsonPath = join(secretsDir, secretsFileName);
771
439
  await writeFile(secretsJsonPath, JSON.stringify(appSecrets, null, 2));
772
440
  logger.log(`🔐 Loaded ${Object.keys(appSecrets).length} secret(s)`);
773
441
  }
@@ -1091,165 +759,6 @@ export async function loadDevSecrets(
1091
759
  return {};
1092
760
  }
1093
761
 
1094
- /**
1095
- * Load secrets from a path for dev mode.
1096
- * For single app: returns secrets as-is.
1097
- * For workspace app: maps {APP}_DATABASE_URL → DATABASE_URL.
1098
- * @internal Exported for testing
1099
- */
1100
- export async function loadSecretsForApp(
1101
- secretsRoot: string,
1102
- appName?: string,
1103
- ): Promise<Record<string, string>> {
1104
- // Try 'dev' stage first, then 'development'
1105
- const stages = ['dev', 'development'];
1106
-
1107
- let secrets: Record<string, string> = {};
1108
-
1109
- for (const stage of stages) {
1110
- if (secretsExist(stage, secretsRoot)) {
1111
- const stageSecrets = await readStageSecrets(stage, secretsRoot);
1112
- if (stageSecrets) {
1113
- logger.log(`🔐 Loading secrets from stage: ${stage}`);
1114
- secrets = toEmbeddableSecrets(stageSecrets);
1115
- break;
1116
- }
1117
- }
1118
- }
1119
-
1120
- if (Object.keys(secrets).length === 0) {
1121
- return {};
1122
- }
1123
-
1124
- // Single app mode - no mapping needed
1125
- if (!appName) {
1126
- return secrets;
1127
- }
1128
-
1129
- // Workspace app mode - map {APP}_* to generic names
1130
- const prefix = appName.toUpperCase();
1131
- const mapped = { ...secrets };
1132
-
1133
- // Map {APP}_DATABASE_URL → DATABASE_URL
1134
- const appDbUrl = secrets[`${prefix}_DATABASE_URL`];
1135
- if (appDbUrl) {
1136
- mapped.DATABASE_URL = appDbUrl;
1137
- }
1138
-
1139
- return mapped;
1140
- }
1141
-
1142
- /**
1143
- * Build the environment variables to pass to `docker compose up`.
1144
- * Merges process.env, secrets, and port mappings so that Docker Compose
1145
- * can interpolate variables like ${POSTGRES_USER} correctly.
1146
- * @internal Exported for testing
1147
- */
1148
- export function buildDockerComposeEnv(
1149
- secretsEnv?: Record<string, string>,
1150
- portEnv?: Record<string, string>,
1151
- ): Record<string, string | undefined> {
1152
- return { ...process.env, ...secretsEnv, ...portEnv };
1153
- }
1154
-
1155
- /**
1156
- * Parse all service names from a docker-compose.yml file.
1157
- * @internal Exported for testing
1158
- */
1159
- export function parseComposeServiceNames(composePath: string): string[] {
1160
- if (!existsSync(composePath)) {
1161
- return [];
1162
- }
1163
-
1164
- const content = readFileSync(composePath, 'utf-8');
1165
- const compose = parseYaml(content) as {
1166
- services?: Record<string, unknown>;
1167
- };
1168
-
1169
- return Object.keys(compose?.services ?? {});
1170
- }
1171
-
1172
- /**
1173
- * Start docker-compose services for the workspace.
1174
- * Parses the docker-compose.yml to discover all services and starts
1175
- * everything except app services (which are managed by turbo).
1176
- * This ensures manually added services are always started.
1177
- * @internal Exported for testing
1178
- */
1179
- /**
1180
- * Start docker-compose services for a single-app project (no workspace config).
1181
- * Starts all services defined in docker-compose.yml.
1182
- */
1183
- export async function startComposeServices(
1184
- cwd: string,
1185
- portEnv?: Record<string, string>,
1186
- secretsEnv?: Record<string, string>,
1187
- ): Promise<void> {
1188
- const composeFile = join(cwd, 'docker-compose.yml');
1189
- if (!existsSync(composeFile)) {
1190
- return;
1191
- }
1192
-
1193
- const servicesToStart = parseComposeServiceNames(composeFile);
1194
- if (servicesToStart.length === 0) {
1195
- return;
1196
- }
1197
-
1198
- logger.log(`🐳 Starting services: ${servicesToStart.join(', ')}`);
1199
-
1200
- try {
1201
- execSync(`docker compose up -d ${servicesToStart.join(' ')}`, {
1202
- cwd,
1203
- stdio: 'inherit',
1204
- env: buildDockerComposeEnv(secretsEnv, portEnv),
1205
- });
1206
-
1207
- logger.log('✅ Services started');
1208
- } catch (error) {
1209
- logger.error('❌ Failed to start services:', (error as Error).message);
1210
- throw error;
1211
- }
1212
- }
1213
-
1214
- export async function startWorkspaceServices(
1215
- workspace: NormalizedWorkspace,
1216
- portEnv?: Record<string, string>,
1217
- secretsEnv?: Record<string, string>,
1218
- ): Promise<void> {
1219
- const composeFile = join(workspace.root, 'docker-compose.yml');
1220
- if (!existsSync(composeFile)) {
1221
- return;
1222
- }
1223
-
1224
- // Discover all services from docker-compose.yml
1225
- const allServices = parseComposeServiceNames(composeFile);
1226
-
1227
- // Exclude app services (managed by turbo, not docker)
1228
- const appNames = new Set(Object.keys(workspace.apps));
1229
- const servicesToStart = allServices.filter((name) => !appNames.has(name));
1230
-
1231
- if (servicesToStart.length === 0) {
1232
- return;
1233
- }
1234
-
1235
- logger.log(`🐳 Starting services: ${servicesToStart.join(', ')}`);
1236
-
1237
- try {
1238
- // Start services with docker-compose, passing secrets so that
1239
- // POSTGRES_USER, POSTGRES_PASSWORD, etc. are interpolated correctly
1240
- execSync(`docker compose up -d ${servicesToStart.join(' ')}`, {
1241
- cwd: workspace.root,
1242
- stdio: 'inherit',
1243
- env: buildDockerComposeEnv(secretsEnv, portEnv),
1244
- });
1245
-
1246
- logger.log('✅ Services started');
1247
- } catch (error) {
1248
- logger.error('❌ Failed to start services:', (error as Error).message);
1249
- throw error;
1250
- }
1251
- }
1252
-
1253
762
  /**
1254
763
  * Workspace dev command - orchestrates multi-app development using Turbo.
1255
764
  *
@@ -1610,163 +1119,6 @@ async function buildServer(
1610
1119
  ]);
1611
1120
  }
1612
1121
 
1613
- /**
1614
- * Find the directory containing .gkm/secrets/.
1615
- * Walks up from cwd until it finds one, or returns cwd.
1616
- * @internal Exported for testing
1617
- */
1618
- export function findSecretsRoot(startDir: string): string {
1619
- let dir = startDir;
1620
- while (dir !== '/') {
1621
- if (existsSync(join(dir, '.gkm', 'secrets'))) {
1622
- return dir;
1623
- }
1624
- const parent = dirname(dir);
1625
- if (parent === dir) break;
1626
- dir = parent;
1627
- }
1628
- return startDir;
1629
- }
1630
-
1631
- /**
1632
- * Generate the credentials injection code snippet.
1633
- * This is the common logic used by both entry wrapper and exec preload.
1634
- * @internal
1635
- */
1636
- function generateCredentialsInjection(secretsJsonPath: string): string {
1637
- return `import { existsSync, readFileSync } from 'node:fs';
1638
-
1639
- // Inject dev secrets via globalThis and process.env
1640
- // Using globalThis.__gkm_credentials__ avoids CJS/ESM interop issues where
1641
- // Object.assign on the Credentials export only mutates one module copy.
1642
- const secretsPath = '${secretsJsonPath}';
1643
- if (existsSync(secretsPath)) {
1644
- const secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
1645
- globalThis.__gkm_credentials__ = secrets;
1646
- Object.assign(process.env, secrets);
1647
- }
1648
- `;
1649
- }
1650
-
1651
- /**
1652
- * Create a preload script that injects secrets into Credentials.
1653
- * Used by `gkm exec` to inject secrets before running any command.
1654
- * @internal Exported for testing
1655
- */
1656
- export async function createCredentialsPreload(
1657
- preloadPath: string,
1658
- secretsJsonPath: string,
1659
- ): Promise<void> {
1660
- const content = `/**
1661
- * Credentials preload generated by 'gkm exec'
1662
- * This file is loaded via NODE_OPTIONS="--import <path>"
1663
- */
1664
- ${generateCredentialsInjection(secretsJsonPath)}`;
1665
-
1666
- await writeFile(preloadPath, content);
1667
- }
1668
-
1669
- /**
1670
- * Create a wrapper script that injects secrets before importing the entry file.
1671
- * @internal Exported for testing
1672
- */
1673
- export async function createEntryWrapper(
1674
- wrapperPath: string,
1675
- entryPath: string,
1676
- secretsJsonPath?: string,
1677
- ): Promise<void> {
1678
- const credentialsInjection = secretsJsonPath
1679
- ? `${generateCredentialsInjection(secretsJsonPath)}
1680
- `
1681
- : '';
1682
-
1683
- // Use dynamic import() to ensure secrets are assigned before the entry file loads
1684
- // Static imports are hoisted, so Object.assign would run after the entry file is loaded
1685
- const content = `#!/usr/bin/env node
1686
- /**
1687
- * Entry wrapper generated by 'gkm dev --entry'
1688
- */
1689
- ${credentialsInjection}// Import and run the user's entry file (dynamic import ensures secrets load first)
1690
- await import('${entryPath}');
1691
- `;
1692
-
1693
- await writeFile(wrapperPath, content);
1694
- }
1695
-
1696
- /**
1697
- * Result of preparing entry credentials for dev mode.
1698
- */
1699
- export interface EntryCredentialsResult {
1700
- /** Credentials to inject (secrets + PORT) */
1701
- credentials: Record<string, string>;
1702
- /** Resolved port (from --port, workspace config, or default 3000) */
1703
- resolvedPort: number;
1704
- /** Path where credentials JSON was written */
1705
- secretsJsonPath: string;
1706
- /** Resolved app name (if in workspace) */
1707
- appName: string | undefined;
1708
- /** Secrets root directory */
1709
- secretsRoot: string;
1710
- }
1711
-
1712
- /**
1713
- * Prepare credentials for entry dev mode.
1714
- * Loads workspace config, secrets, and injects PORT.
1715
- * @internal Exported for testing
1716
- */
1717
- export async function prepareEntryCredentials(options: {
1718
- explicitPort?: number;
1719
- cwd?: string;
1720
- }): Promise<EntryCredentialsResult> {
1721
- const cwd = options.cwd ?? process.cwd();
1722
-
1723
- // Try to get workspace app config for port and secrets
1724
- let workspaceAppPort: number | undefined;
1725
- let secretsRoot: string = cwd;
1726
- let appName: string | undefined;
1727
-
1728
- try {
1729
- const appInfo = await loadWorkspaceAppInfo(cwd);
1730
- workspaceAppPort = appInfo.app.port;
1731
- secretsRoot = appInfo.workspaceRoot;
1732
- appName = appInfo.appName;
1733
- } catch (error) {
1734
- // Not in a workspace - use defaults
1735
- logger.log(
1736
- `⚠️ Could not load workspace config: ${(error as Error).message}`,
1737
- );
1738
- secretsRoot = findSecretsRoot(cwd);
1739
- appName = getAppNameFromCwd(cwd) ?? undefined;
1740
- }
1741
-
1742
- // Determine port: explicit --port > workspace config > default 3000
1743
- const resolvedPort = options.explicitPort ?? workspaceAppPort ?? 3000;
1744
-
1745
- // Load secrets and inject PORT
1746
- const credentials = await loadSecretsForApp(secretsRoot, appName);
1747
-
1748
- // Always inject PORT into credentials so apps can read it
1749
- credentials.PORT = String(resolvedPort);
1750
-
1751
- // Write secrets to temp JSON file (always write since we have PORT)
1752
- // Use app-specific filename to avoid race conditions when running multiple apps via turbo
1753
- const secretsDir = join(secretsRoot, '.gkm');
1754
- await mkdir(secretsDir, { recursive: true });
1755
- const secretsFileName = appName
1756
- ? `dev-secrets-${appName}.json`
1757
- : 'dev-secrets.json';
1758
- const secretsJsonPath = join(secretsDir, secretsFileName);
1759
- await writeFile(secretsJsonPath, JSON.stringify(credentials, null, 2));
1760
-
1761
- return {
1762
- credentials,
1763
- resolvedPort,
1764
- secretsJsonPath,
1765
- appName,
1766
- secretsRoot,
1767
- };
1768
- }
1769
-
1770
1122
  /**
1771
1123
  * Run any TypeScript file with secret injection.
1772
1124
  * Does not require gkm.config.ts.
@@ -2205,137 +1557,3 @@ class DevServer {
2205
1557
  await fsWriteFile(serverPath, content);
2206
1558
  }
2207
1559
  }
2208
-
2209
- /**
2210
- * Options for the exec command.
2211
- */
2212
- export interface ExecOptions {
2213
- /** Working directory */
2214
- cwd?: string;
2215
- }
2216
-
2217
- /**
2218
- * Run a command with secrets injected into Credentials.
2219
- * Uses Node's --import flag to preload a script that populates Credentials
2220
- * before the command loads any modules that depend on them.
2221
- *
2222
- * @example
2223
- * ```bash
2224
- * gkm exec -- npx @better-auth/cli migrate
2225
- * gkm exec -- npx prisma migrate dev
2226
- * ```
2227
- */
2228
- export async function execCommand(
2229
- commandArgs: string[],
2230
- options: ExecOptions = {},
2231
- ): Promise<void> {
2232
- const cwd = options.cwd ?? process.cwd();
2233
-
2234
- if (commandArgs.length === 0) {
2235
- throw new Error('No command specified. Usage: gkm exec -- <command>');
2236
- }
2237
-
2238
- // Load .env files
2239
- const defaultEnv = loadEnvFiles('.env');
2240
- if (defaultEnv.loaded.length > 0) {
2241
- logger.log(`📦 Loaded env: ${defaultEnv.loaded.join(', ')}`);
2242
- }
2243
-
2244
- // Prepare credentials (loads workspace config and secrets)
2245
- // Don't inject PORT for exec since we're not running a server
2246
- const { credentials, secretsJsonPath, appName, secretsRoot } =
2247
- await prepareEntryCredentials({ cwd });
2248
-
2249
- if (appName) {
2250
- logger.log(`📦 App: ${appName}`);
2251
- }
2252
-
2253
- const secretCount = Object.keys(credentials).filter(
2254
- (k) => k !== 'PORT',
2255
- ).length;
2256
- if (secretCount > 0) {
2257
- logger.log(`🔐 Loaded ${secretCount} secret(s)`);
2258
- }
2259
-
2260
- // Resolve actual Docker ports from running containers (not just saved state)
2261
- const resolvedPorts = await resolveServicePorts(secretsRoot);
2262
- if (
2263
- resolvedPorts.mappings.length > 0 &&
2264
- Object.keys(resolvedPorts.ports).length > 0
2265
- ) {
2266
- const rewritten = rewriteUrlsWithPorts(credentials, resolvedPorts);
2267
- Object.assign(credentials, rewritten);
2268
- logger.log(
2269
- `🔌 Applied ${Object.keys(resolvedPorts.ports).length} port mapping(s)`,
2270
- );
2271
- }
2272
-
2273
- // Inject dependency URLs (works for both frontend and backend apps)
2274
- try {
2275
- const appInfo = await loadWorkspaceAppInfo(cwd);
2276
- if (appInfo.appName) {
2277
- const depEnv = getDependencyEnvVars(appInfo.workspace, appInfo.appName);
2278
- Object.assign(credentials, depEnv);
2279
- }
2280
- } catch {
2281
- // Not in a workspace — skip dependency URL injection
2282
- }
2283
-
2284
- // Create preload script that injects Credentials
2285
- // Create in cwd so package resolution works (finds node_modules in app directory)
2286
- const preloadDir = join(cwd, '.gkm');
2287
- await mkdir(preloadDir, { recursive: true });
2288
- const preloadPath = join(preloadDir, 'credentials-preload.ts');
2289
- await createCredentialsPreload(preloadPath, secretsJsonPath);
2290
-
2291
- // Build command
2292
- const [cmd, ...rawArgs] = commandArgs;
2293
-
2294
- if (!cmd) {
2295
- throw new Error('No command specified');
2296
- }
2297
-
2298
- // Replace template variables in command args (e.g. $PORT -> resolved port)
2299
- const args = rawArgs.map((arg) =>
2300
- arg.replace(/\$PORT\b/g, credentials.PORT ?? '3000'),
2301
- );
2302
-
2303
- logger.log(`🚀 Running: ${[cmd, ...args].join(' ')}`);
2304
-
2305
- // Merge NODE_OPTIONS with existing value (if any)
2306
- // Add tsx loader first so our .ts preload can be loaded
2307
- const existingNodeOptions = process.env.NODE_OPTIONS ?? '';
2308
- const tsxImport = '--import=tsx';
2309
- const preloadImport = `--import=${preloadPath}`;
2310
-
2311
- // Build NODE_OPTIONS: existing + tsx loader + our preload
2312
- const nodeOptions = [existingNodeOptions, tsxImport, preloadImport]
2313
- .filter(Boolean)
2314
- .join(' ');
2315
-
2316
- // Spawn the command with secrets in both:
2317
- // 1. Environment variables (for tools that read process.env directly)
2318
- // 2. Preload script (for tools that use Credentials object)
2319
- const child = spawn(cmd, args, {
2320
- cwd,
2321
- stdio: 'inherit',
2322
- env: {
2323
- ...process.env,
2324
- ...credentials, // Inject secrets as env vars
2325
- NODE_OPTIONS: nodeOptions,
2326
- },
2327
- });
2328
-
2329
- // Wait for the command to complete
2330
- const exitCode = await new Promise<number>((resolve) => {
2331
- child.on('close', (code: number | null) => resolve(code ?? 0));
2332
- child.on('error', (error: Error) => {
2333
- logger.error(`Failed to run command: ${error.message}`);
2334
- resolve(1);
2335
- });
2336
- });
2337
-
2338
- if (exitCode !== 0) {
2339
- process.exit(exitCode);
2340
- }
2341
- }