@geekmidas/cli 1.10.16 → 1.10.18
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 +12 -0
- package/dist/{bundler-B4AackW5.mjs → bundler-C5xkxnyr.mjs} +2 -2
- package/dist/{bundler-B4AackW5.mjs.map → bundler-C5xkxnyr.mjs.map} +1 -1
- package/dist/{bundler-BhhfkI9T.cjs → bundler-i-az1DZ2.cjs} +2 -2
- package/dist/{bundler-BhhfkI9T.cjs.map → bundler-i-az1DZ2.cjs.map} +1 -1
- package/dist/index.cjs +699 -712
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +686 -699
- package/dist/index.mjs.map +1 -1
- package/dist/{openapi-BYxAWwok.cjs → openapi-CsCNpSf8.cjs} +1 -1
- package/dist/{openapi-BYxAWwok.cjs.map → openapi-CsCNpSf8.cjs.map} +1 -1
- package/dist/{openapi-DenF-okj.mjs → openapi-kvwpKbNe.mjs} +1 -1
- package/dist/{openapi-DenF-okj.mjs.map → openapi-kvwpKbNe.mjs.map} +1 -1
- package/dist/openapi.cjs +1 -1
- package/dist/openapi.mjs +1 -1
- package/dist/{storage-DOEtT2Hr.cjs → storage-ChVQI_G7.cjs} +1 -1
- package/dist/{storage-dbb9RyBl.mjs → storage-CpMNB77O.mjs} +1 -1
- package/dist/{storage-dbb9RyBl.mjs.map → storage-CpMNB77O.mjs.map} +1 -1
- package/dist/{storage-B1wvztiJ.cjs → storage-DLEb8Dkd.cjs} +1 -1
- package/dist/{storage-B1wvztiJ.cjs.map → storage-DLEb8Dkd.cjs.map} +1 -1
- package/dist/{storage-Cs4WBsc4.mjs → storage-mwbL7PhP.mjs} +1 -1
- package/dist/{sync-DGXXSk2v.cjs → sync-BWD_I5Ai.cjs} +2 -2
- package/dist/{sync-DGXXSk2v.cjs.map → sync-BWD_I5Ai.cjs.map} +1 -1
- package/dist/sync-ByaRPBxh.cjs +4 -0
- package/dist/{sync-COnAugP-.mjs → sync-CYBVB64f.mjs} +1 -1
- package/dist/{sync-D_NowTkZ.mjs → sync-lExOTa9t.mjs} +2 -2
- package/dist/{sync-D_NowTkZ.mjs.map → sync-lExOTa9t.mjs.map} +1 -1
- package/package.json +3 -3
- package/src/credentials/__tests__/fullDockerPorts.spec.ts +144 -0
- package/src/credentials/__tests__/helpers.ts +112 -0
- package/src/credentials/__tests__/prepareEntryCredentials.spec.ts +125 -0
- package/src/credentials/__tests__/readonlyDockerPorts.spec.ts +190 -0
- package/src/credentials/__tests__/workspaceCredentials.spec.ts +209 -0
- package/src/credentials/index.ts +826 -0
- package/src/dev/__tests__/index.spec.ts +68 -0
- package/src/dev/index.ts +44 -821
- package/src/exec/index.ts +120 -0
- package/src/init/versions.ts +1 -1
- package/src/setup/index.ts +4 -1
- package/src/test/index.ts +32 -109
- package/dist/sync-D1Pa30oV.cjs +0 -4
|
@@ -0,0 +1,826 @@
|
|
|
1
|
+
import { execSync } 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';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { config as dotenvConfig } from 'dotenv';
|
|
7
|
+
import { parse as parseYaml } from 'yaml';
|
|
8
|
+
import {
|
|
9
|
+
getAppNameFromCwd,
|
|
10
|
+
loadWorkspaceAppInfo,
|
|
11
|
+
type WorkspaceAppInfo,
|
|
12
|
+
} from '../config';
|
|
13
|
+
import {
|
|
14
|
+
readStageSecrets,
|
|
15
|
+
secretsExist,
|
|
16
|
+
toEmbeddableSecrets,
|
|
17
|
+
} from '../secrets/storage.js';
|
|
18
|
+
import { getDependencyEnvVars } from '../workspace/index.js';
|
|
19
|
+
|
|
20
|
+
const logger = console;
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Environment files
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Load environment files
|
|
28
|
+
* @internal Exported for testing
|
|
29
|
+
*/
|
|
30
|
+
export function loadEnvFiles(
|
|
31
|
+
envConfig: string | string[] | undefined,
|
|
32
|
+
cwd: string = process.cwd(),
|
|
33
|
+
): { loaded: string[]; missing: string[] } {
|
|
34
|
+
const loaded: string[] = [];
|
|
35
|
+
const missing: string[] = [];
|
|
36
|
+
|
|
37
|
+
// Normalize to array
|
|
38
|
+
const envFiles = envConfig
|
|
39
|
+
? Array.isArray(envConfig)
|
|
40
|
+
? envConfig
|
|
41
|
+
: [envConfig]
|
|
42
|
+
: ['.env'];
|
|
43
|
+
|
|
44
|
+
// Load each env file in order (later files override earlier)
|
|
45
|
+
for (const envFile of envFiles) {
|
|
46
|
+
const envPath = resolve(cwd, envFile);
|
|
47
|
+
if (existsSync(envPath)) {
|
|
48
|
+
dotenvConfig({ path: envPath, override: true, quiet: true });
|
|
49
|
+
loaded.push(envFile);
|
|
50
|
+
} else if (envConfig) {
|
|
51
|
+
// Only report as missing if explicitly configured
|
|
52
|
+
missing.push(envFile);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { loaded, missing };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Port utilities
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Check if a port is available
|
|
65
|
+
* @internal Exported for testing
|
|
66
|
+
*/
|
|
67
|
+
export async function isPortAvailable(port: number): Promise<boolean> {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
const server = createServer();
|
|
70
|
+
|
|
71
|
+
server.once('error', (err: NodeJS.ErrnoException) => {
|
|
72
|
+
if (err.code === 'EADDRINUSE') {
|
|
73
|
+
resolve(false);
|
|
74
|
+
} else {
|
|
75
|
+
resolve(false);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
server.once('listening', () => {
|
|
80
|
+
server.close();
|
|
81
|
+
resolve(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
server.listen(port);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Find an available port starting from the preferred port
|
|
90
|
+
* @internal Exported for testing
|
|
91
|
+
*/
|
|
92
|
+
export async function findAvailablePort(
|
|
93
|
+
preferredPort: number,
|
|
94
|
+
maxAttempts = 10,
|
|
95
|
+
): Promise<number> {
|
|
96
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
97
|
+
const port = preferredPort + i;
|
|
98
|
+
if (await isPortAvailable(port)) {
|
|
99
|
+
return port;
|
|
100
|
+
}
|
|
101
|
+
logger.log(`⚠️ Port ${port} is in use, trying ${port + 1}...`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Could not find an available port after trying ${maxAttempts} ports starting from ${preferredPort}`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// Docker Compose port mapping
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* A port mapping extracted from docker-compose.yml.
|
|
115
|
+
* Only entries using env var interpolation (e.g., `${VAR:-default}:container`) are captured.
|
|
116
|
+
*/
|
|
117
|
+
export interface ComposePortMapping {
|
|
118
|
+
service: string;
|
|
119
|
+
envVar: string;
|
|
120
|
+
defaultPort: number;
|
|
121
|
+
containerPort: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Port state persisted to .gkm/ports.json, keyed by env var name. */
|
|
125
|
+
export type PortState = Record<string, number>;
|
|
126
|
+
|
|
127
|
+
export interface ResolvedServicePorts {
|
|
128
|
+
dockerEnv: Record<string, string>;
|
|
129
|
+
ports: PortState;
|
|
130
|
+
mappings: ComposePortMapping[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const PORT_STATE_PATH = '.gkm/ports.json';
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse docker-compose.yml and extract all port mappings that use env var interpolation.
|
|
137
|
+
* Entries like `'${POSTGRES_HOST_PORT:-5432}:5432'` are captured.
|
|
138
|
+
* Fixed port mappings like `'5050:80'` are skipped.
|
|
139
|
+
* @internal Exported for testing
|
|
140
|
+
*/
|
|
141
|
+
export function parseComposePortMappings(
|
|
142
|
+
composePath: string,
|
|
143
|
+
): ComposePortMapping[] {
|
|
144
|
+
if (!existsSync(composePath)) {
|
|
145
|
+
return [];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const content = readFileSync(composePath, 'utf-8');
|
|
149
|
+
const compose = parseYaml(content) as {
|
|
150
|
+
services?: Record<string, { ports?: string[] }>;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
if (!compose?.services) {
|
|
154
|
+
return [];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const results: ComposePortMapping[] = [];
|
|
158
|
+
|
|
159
|
+
for (const [serviceName, serviceConfig] of Object.entries(compose.services)) {
|
|
160
|
+
for (const portMapping of serviceConfig?.ports ?? []) {
|
|
161
|
+
const match = String(portMapping).match(/\$\{(\w+):-(\d+)\}:(\d+)/);
|
|
162
|
+
if (match?.[1] && match[2] && match[3]) {
|
|
163
|
+
results.push({
|
|
164
|
+
service: serviceName,
|
|
165
|
+
envVar: match[1],
|
|
166
|
+
defaultPort: Number(match[2]),
|
|
167
|
+
containerPort: Number(match[3]),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return results;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Load saved port state from .gkm/ports.json.
|
|
178
|
+
* @internal Exported for testing
|
|
179
|
+
*/
|
|
180
|
+
export async function loadPortState(workspaceRoot: string): Promise<PortState> {
|
|
181
|
+
try {
|
|
182
|
+
const raw = await readFile(join(workspaceRoot, PORT_STATE_PATH), 'utf-8');
|
|
183
|
+
return JSON.parse(raw) as PortState;
|
|
184
|
+
} catch {
|
|
185
|
+
return {};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Save port state to .gkm/ports.json.
|
|
191
|
+
* @internal Exported for testing
|
|
192
|
+
*/
|
|
193
|
+
export async function savePortState(
|
|
194
|
+
workspaceRoot: string,
|
|
195
|
+
ports: PortState,
|
|
196
|
+
): Promise<void> {
|
|
197
|
+
const dir = join(workspaceRoot, '.gkm');
|
|
198
|
+
await mkdir(dir, { recursive: true });
|
|
199
|
+
await writeFile(
|
|
200
|
+
join(workspaceRoot, PORT_STATE_PATH),
|
|
201
|
+
`${JSON.stringify(ports, null, 2)}\n`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Check if a project's own Docker container is running and return its host port.
|
|
207
|
+
* Uses `docker compose port` scoped to the project's compose file.
|
|
208
|
+
* @internal Exported for testing
|
|
209
|
+
*/
|
|
210
|
+
export function getContainerHostPort(
|
|
211
|
+
workspaceRoot: string,
|
|
212
|
+
service: string,
|
|
213
|
+
containerPort: number,
|
|
214
|
+
): number | null {
|
|
215
|
+
try {
|
|
216
|
+
const result = execSync(`docker compose port ${service} ${containerPort}`, {
|
|
217
|
+
cwd: workspaceRoot,
|
|
218
|
+
stdio: 'pipe',
|
|
219
|
+
})
|
|
220
|
+
.toString()
|
|
221
|
+
.trim();
|
|
222
|
+
const match = result.match(/:(\d+)$/);
|
|
223
|
+
return match ? Number(match[1]) : null;
|
|
224
|
+
} catch {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Resolve host ports for Docker services by parsing docker-compose.yml.
|
|
231
|
+
* Priority: running container → saved state → find available port.
|
|
232
|
+
* Persists resolved ports to .gkm/ports.json.
|
|
233
|
+
* @internal Exported for testing
|
|
234
|
+
*/
|
|
235
|
+
export async function resolveServicePorts(
|
|
236
|
+
workspaceRoot: string,
|
|
237
|
+
): Promise<ResolvedServicePorts> {
|
|
238
|
+
const composePath = join(workspaceRoot, 'docker-compose.yml');
|
|
239
|
+
const mappings = parseComposePortMappings(composePath);
|
|
240
|
+
|
|
241
|
+
if (mappings.length === 0) {
|
|
242
|
+
return { dockerEnv: {}, ports: {}, mappings: [] };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const savedState = await loadPortState(workspaceRoot);
|
|
246
|
+
const dockerEnv: Record<string, string> = {};
|
|
247
|
+
const ports: PortState = {};
|
|
248
|
+
// Track ports assigned in this cycle to avoid duplicates
|
|
249
|
+
const assignedPorts = new Set<number>();
|
|
250
|
+
|
|
251
|
+
logger.log('\n🔌 Resolving service ports...');
|
|
252
|
+
|
|
253
|
+
for (const mapping of mappings) {
|
|
254
|
+
// 1. Check if own container is already running
|
|
255
|
+
const containerPort = getContainerHostPort(
|
|
256
|
+
workspaceRoot,
|
|
257
|
+
mapping.service,
|
|
258
|
+
mapping.containerPort,
|
|
259
|
+
);
|
|
260
|
+
if (containerPort !== null) {
|
|
261
|
+
ports[mapping.envVar] = containerPort;
|
|
262
|
+
dockerEnv[mapping.envVar] = String(containerPort);
|
|
263
|
+
assignedPorts.add(containerPort);
|
|
264
|
+
logger.log(
|
|
265
|
+
` 🔄 ${mapping.service}:${mapping.containerPort}: reusing existing container on port ${containerPort}`,
|
|
266
|
+
);
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// 2. Check saved port state
|
|
271
|
+
const savedPort = savedState[mapping.envVar];
|
|
272
|
+
if (
|
|
273
|
+
savedPort &&
|
|
274
|
+
!assignedPorts.has(savedPort) &&
|
|
275
|
+
(await isPortAvailable(savedPort))
|
|
276
|
+
) {
|
|
277
|
+
ports[mapping.envVar] = savedPort;
|
|
278
|
+
dockerEnv[mapping.envVar] = String(savedPort);
|
|
279
|
+
assignedPorts.add(savedPort);
|
|
280
|
+
logger.log(
|
|
281
|
+
` 💾 ${mapping.service}:${mapping.containerPort}: using saved port ${savedPort}`,
|
|
282
|
+
);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 3. Find available port (skipping ports already assigned this cycle)
|
|
287
|
+
let resolvedPort = await findAvailablePort(mapping.defaultPort);
|
|
288
|
+
while (assignedPorts.has(resolvedPort)) {
|
|
289
|
+
resolvedPort = await findAvailablePort(resolvedPort + 1);
|
|
290
|
+
}
|
|
291
|
+
ports[mapping.envVar] = resolvedPort;
|
|
292
|
+
dockerEnv[mapping.envVar] = String(resolvedPort);
|
|
293
|
+
assignedPorts.add(resolvedPort);
|
|
294
|
+
|
|
295
|
+
if (resolvedPort !== mapping.defaultPort) {
|
|
296
|
+
logger.log(
|
|
297
|
+
` ⚡ ${mapping.service}:${mapping.containerPort}: port ${mapping.defaultPort} occupied, using port ${resolvedPort}`,
|
|
298
|
+
);
|
|
299
|
+
} else {
|
|
300
|
+
logger.log(
|
|
301
|
+
` ✅ ${mapping.service}:${mapping.containerPort}: using default port ${resolvedPort}`,
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
await savePortState(workspaceRoot, ports);
|
|
307
|
+
|
|
308
|
+
return { dockerEnv, ports, mappings };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// URL rewriting
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Replace a port in a URL string.
|
|
317
|
+
* Handles both `hostname:port` and `localhost:port` patterns.
|
|
318
|
+
* @internal Exported for testing
|
|
319
|
+
*/
|
|
320
|
+
export function replacePortInUrl(
|
|
321
|
+
url: string,
|
|
322
|
+
oldPort: number,
|
|
323
|
+
newPort: number,
|
|
324
|
+
): string {
|
|
325
|
+
if (oldPort === newPort) return url;
|
|
326
|
+
// Replace literal :port (in authority section)
|
|
327
|
+
let result = url.replace(
|
|
328
|
+
new RegExp(`:${oldPort}(?=[/?#]|$)`, 'g'),
|
|
329
|
+
`:${newPort}`,
|
|
330
|
+
);
|
|
331
|
+
// Replace URL-encoded :port (e.g., in query params like endpoint=http%3A%2F%2Flocalhost%3A4566)
|
|
332
|
+
result = result.replace(
|
|
333
|
+
new RegExp(`%3A${oldPort}(?=[%/?#&]|$)`, 'gi'),
|
|
334
|
+
`%3A${newPort}`,
|
|
335
|
+
);
|
|
336
|
+
return result;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Rewrite connection URLs and port vars in secrets with resolved ports.
|
|
341
|
+
* Uses the parsed compose mappings to determine which default ports to replace.
|
|
342
|
+
* Pure transform — does not modify secrets on disk.
|
|
343
|
+
* @internal Exported for testing
|
|
344
|
+
*/
|
|
345
|
+
export function rewriteUrlsWithPorts(
|
|
346
|
+
secrets: Record<string, string>,
|
|
347
|
+
resolvedPorts: ResolvedServicePorts,
|
|
348
|
+
): Record<string, string> {
|
|
349
|
+
const { ports, mappings } = resolvedPorts;
|
|
350
|
+
const result = { ...secrets };
|
|
351
|
+
|
|
352
|
+
// Build a map of defaultPort → resolvedPort for all changed ports
|
|
353
|
+
const portReplacements: { defaultPort: number; resolvedPort: number }[] = [];
|
|
354
|
+
// Collect Docker service names for hostname rewriting
|
|
355
|
+
const serviceNames = new Set<string>();
|
|
356
|
+
for (const mapping of mappings) {
|
|
357
|
+
serviceNames.add(mapping.service);
|
|
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 _HOST env vars that use Docker service names
|
|
368
|
+
for (const [key, value] of Object.entries(result)) {
|
|
369
|
+
if (!key.endsWith('_HOST')) continue;
|
|
370
|
+
if (serviceNames.has(value)) {
|
|
371
|
+
result[key] = 'localhost';
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Rewrite _PORT env vars whose values match a default port
|
|
376
|
+
for (const [key, value] of Object.entries(result)) {
|
|
377
|
+
if (!key.endsWith('_PORT')) continue;
|
|
378
|
+
for (const { defaultPort, resolvedPort } of portReplacements) {
|
|
379
|
+
if (value === String(defaultPort)) {
|
|
380
|
+
result[key] = String(resolvedPort);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Rewrite URLs: replace Docker service hostnames with localhost and fix ports
|
|
386
|
+
for (const [key, value] of Object.entries(result)) {
|
|
387
|
+
if (
|
|
388
|
+
!key.endsWith('_URL') &&
|
|
389
|
+
!key.endsWith('_ENDPOINT') &&
|
|
390
|
+
!key.endsWith('_CONNECTION_STRING') &&
|
|
391
|
+
key !== 'DATABASE_URL'
|
|
392
|
+
)
|
|
393
|
+
continue;
|
|
394
|
+
|
|
395
|
+
let rewritten = value;
|
|
396
|
+
for (const name of serviceNames) {
|
|
397
|
+
rewritten = rewritten.replace(
|
|
398
|
+
new RegExp(`@${name}:`, 'g'),
|
|
399
|
+
'@localhost:',
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
for (const { defaultPort, resolvedPort } of portReplacements) {
|
|
403
|
+
rewritten = replacePortInUrl(rewritten, defaultPort, resolvedPort);
|
|
404
|
+
}
|
|
405
|
+
result[key] = rewritten;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Docker Compose services
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Build the environment variables to pass to `docker compose up`.
|
|
417
|
+
* Merges process.env, secrets, and port mappings so that Docker Compose
|
|
418
|
+
* can interpolate variables like ${POSTGRES_USER} correctly.
|
|
419
|
+
* @internal Exported for testing
|
|
420
|
+
*/
|
|
421
|
+
export function buildDockerComposeEnv(
|
|
422
|
+
secretsEnv?: Record<string, string>,
|
|
423
|
+
portEnv?: Record<string, string>,
|
|
424
|
+
): Record<string, string | undefined> {
|
|
425
|
+
return { ...process.env, ...secretsEnv, ...portEnv };
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Parse all service names from a docker-compose.yml file.
|
|
430
|
+
* @internal Exported for testing
|
|
431
|
+
*/
|
|
432
|
+
export function parseComposeServiceNames(composePath: string): string[] {
|
|
433
|
+
if (!existsSync(composePath)) {
|
|
434
|
+
return [];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const content = readFileSync(composePath, 'utf-8');
|
|
438
|
+
const compose = parseYaml(content) as {
|
|
439
|
+
services?: Record<string, unknown>;
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
return Object.keys(compose?.services ?? {});
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Start docker-compose services for a single-app project (no workspace config).
|
|
447
|
+
* Starts all services defined in docker-compose.yml.
|
|
448
|
+
*/
|
|
449
|
+
export async function startComposeServices(
|
|
450
|
+
cwd: string,
|
|
451
|
+
portEnv?: Record<string, string>,
|
|
452
|
+
secretsEnv?: Record<string, string>,
|
|
453
|
+
): Promise<void> {
|
|
454
|
+
const composeFile = join(cwd, 'docker-compose.yml');
|
|
455
|
+
if (!existsSync(composeFile)) {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const servicesToStart = parseComposeServiceNames(composeFile);
|
|
460
|
+
if (servicesToStart.length === 0) {
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
logger.log(`🐳 Starting services: ${servicesToStart.join(', ')}`);
|
|
465
|
+
|
|
466
|
+
try {
|
|
467
|
+
execSync(`docker compose up -d ${servicesToStart.join(' ')}`, {
|
|
468
|
+
cwd,
|
|
469
|
+
stdio: 'inherit',
|
|
470
|
+
env: buildDockerComposeEnv(secretsEnv, portEnv),
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
logger.log('✅ Services started');
|
|
474
|
+
} catch (error) {
|
|
475
|
+
logger.error('❌ Failed to start services:', (error as Error).message);
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Start docker-compose services for a workspace.
|
|
482
|
+
* Discovers all services from docker-compose.yml and starts everything
|
|
483
|
+
* except app services (which are managed by turbo).
|
|
484
|
+
* @internal Exported for testing
|
|
485
|
+
*/
|
|
486
|
+
export async function startWorkspaceServices(
|
|
487
|
+
workspace: { root: string; apps: Record<string, unknown> },
|
|
488
|
+
portEnv?: Record<string, string>,
|
|
489
|
+
secretsEnv?: Record<string, string>,
|
|
490
|
+
): Promise<void> {
|
|
491
|
+
const composeFile = join(workspace.root, 'docker-compose.yml');
|
|
492
|
+
if (!existsSync(composeFile)) {
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Discover all services from docker-compose.yml
|
|
497
|
+
const allServices = parseComposeServiceNames(composeFile);
|
|
498
|
+
|
|
499
|
+
// Exclude app services (managed by turbo, not docker)
|
|
500
|
+
const appNames = new Set(Object.keys(workspace.apps));
|
|
501
|
+
const servicesToStart = allServices.filter((name) => !appNames.has(name));
|
|
502
|
+
|
|
503
|
+
if (servicesToStart.length === 0) {
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
logger.log(`🐳 Starting services: ${servicesToStart.join(', ')}`);
|
|
508
|
+
|
|
509
|
+
try {
|
|
510
|
+
// Start services with docker-compose, passing secrets so that
|
|
511
|
+
// POSTGRES_USER, POSTGRES_PASSWORD, etc. are interpolated correctly
|
|
512
|
+
execSync(`docker compose up -d ${servicesToStart.join(' ')}`, {
|
|
513
|
+
cwd: workspace.root,
|
|
514
|
+
stdio: 'inherit',
|
|
515
|
+
env: buildDockerComposeEnv(secretsEnv, portEnv),
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
logger.log('✅ Services started');
|
|
519
|
+
} catch (error) {
|
|
520
|
+
logger.error('❌ Failed to start services:', (error as Error).message);
|
|
521
|
+
throw error;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
// Secrets loading
|
|
527
|
+
// ---------------------------------------------------------------------------
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Load and flatten secrets for an app from encrypted storage.
|
|
531
|
+
* For workspace app: maps {APP}_DATABASE_URL → DATABASE_URL.
|
|
532
|
+
* @internal Exported for testing
|
|
533
|
+
*/
|
|
534
|
+
export async function loadSecretsForApp(
|
|
535
|
+
secretsRoot: string,
|
|
536
|
+
appName?: string,
|
|
537
|
+
stages: string[] = ['dev', 'development'],
|
|
538
|
+
): Promise<Record<string, string>> {
|
|
539
|
+
let secrets: Record<string, string> = {};
|
|
540
|
+
|
|
541
|
+
for (const stage of stages) {
|
|
542
|
+
if (secretsExist(stage, secretsRoot)) {
|
|
543
|
+
const stageSecrets = await readStageSecrets(stage, secretsRoot);
|
|
544
|
+
if (stageSecrets) {
|
|
545
|
+
logger.log(`🔐 Loading secrets from stage: ${stage}`);
|
|
546
|
+
secrets = toEmbeddableSecrets(stageSecrets);
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
if (Object.keys(secrets).length === 0) {
|
|
553
|
+
return {};
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Single app mode - no mapping needed
|
|
557
|
+
if (!appName) {
|
|
558
|
+
return secrets;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Workspace app mode - map {APP}_* to generic names
|
|
562
|
+
const prefix = appName.toUpperCase();
|
|
563
|
+
const mapped = { ...secrets };
|
|
564
|
+
|
|
565
|
+
// Map {APP}_DATABASE_URL → DATABASE_URL
|
|
566
|
+
const appDbUrl = secrets[`${prefix}_DATABASE_URL`];
|
|
567
|
+
if (appDbUrl) {
|
|
568
|
+
mapped.DATABASE_URL = appDbUrl;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
return mapped;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Walk up the directory tree to find the root containing .gkm/secrets/.
|
|
576
|
+
* @internal Exported for testing
|
|
577
|
+
*/
|
|
578
|
+
export function findSecretsRoot(startDir: string): string {
|
|
579
|
+
let dir = startDir;
|
|
580
|
+
while (dir !== '/') {
|
|
581
|
+
if (existsSync(join(dir, '.gkm', 'secrets'))) {
|
|
582
|
+
return dir;
|
|
583
|
+
}
|
|
584
|
+
const parent = dirname(dir);
|
|
585
|
+
if (parent === dir) break;
|
|
586
|
+
dir = parent;
|
|
587
|
+
}
|
|
588
|
+
return startDir;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ---------------------------------------------------------------------------
|
|
592
|
+
// Credentials preload / injection
|
|
593
|
+
// ---------------------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Generate the credentials injection code snippet.
|
|
597
|
+
* This is the common logic used by both entry wrapper and exec preload.
|
|
598
|
+
* @internal
|
|
599
|
+
*/
|
|
600
|
+
function generateCredentialsInjection(secretsJsonPath: string): string {
|
|
601
|
+
return `import { existsSync, readFileSync } from 'node:fs';
|
|
602
|
+
|
|
603
|
+
// Inject dev secrets via globalThis and process.env
|
|
604
|
+
// Using globalThis.__gkm_credentials__ avoids CJS/ESM interop issues where
|
|
605
|
+
// Object.assign on the Credentials export only mutates one module copy.
|
|
606
|
+
const secretsPath = '${secretsJsonPath}';
|
|
607
|
+
if (existsSync(secretsPath)) {
|
|
608
|
+
const secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
|
|
609
|
+
globalThis.__gkm_credentials__ = secrets;
|
|
610
|
+
Object.assign(process.env, secrets);
|
|
611
|
+
}
|
|
612
|
+
`;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Create a preload script that injects secrets into Credentials.
|
|
617
|
+
* Used by `gkm exec` to inject secrets before running any command.
|
|
618
|
+
* @internal Exported for testing
|
|
619
|
+
*/
|
|
620
|
+
export async function createCredentialsPreload(
|
|
621
|
+
preloadPath: string,
|
|
622
|
+
secretsJsonPath: string,
|
|
623
|
+
): Promise<void> {
|
|
624
|
+
const content = `/**
|
|
625
|
+
* Credentials preload generated by 'gkm exec'
|
|
626
|
+
* This file is loaded via NODE_OPTIONS="--import <path>"
|
|
627
|
+
*/
|
|
628
|
+
${generateCredentialsInjection(secretsJsonPath)}`;
|
|
629
|
+
|
|
630
|
+
await writeFile(preloadPath, content);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* Create a wrapper script that injects secrets before importing the entry file.
|
|
635
|
+
* @internal Exported for testing
|
|
636
|
+
*/
|
|
637
|
+
export async function createEntryWrapper(
|
|
638
|
+
wrapperPath: string,
|
|
639
|
+
entryPath: string,
|
|
640
|
+
secretsJsonPath?: string,
|
|
641
|
+
): Promise<void> {
|
|
642
|
+
const credentialsInjection = secretsJsonPath
|
|
643
|
+
? `${generateCredentialsInjection(secretsJsonPath)}
|
|
644
|
+
`
|
|
645
|
+
: '';
|
|
646
|
+
|
|
647
|
+
// Use dynamic import() to ensure secrets are assigned before the entry file loads
|
|
648
|
+
// Static imports are hoisted, so Object.assign would run after the entry file is loaded
|
|
649
|
+
const content = `#!/usr/bin/env node
|
|
650
|
+
/**
|
|
651
|
+
* Entry wrapper generated by 'gkm dev --entry'
|
|
652
|
+
*/
|
|
653
|
+
${credentialsInjection}// Import and run the user's entry file (dynamic import ensures secrets load first)
|
|
654
|
+
await import('${entryPath}');
|
|
655
|
+
`;
|
|
656
|
+
|
|
657
|
+
await writeFile(wrapperPath, content);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// ---------------------------------------------------------------------------
|
|
661
|
+
// Prepare credentials (shared by dev, exec, test)
|
|
662
|
+
// ---------------------------------------------------------------------------
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Result of preparing credentials.
|
|
666
|
+
*/
|
|
667
|
+
export interface EntryCredentialsResult {
|
|
668
|
+
/** Credentials to inject (secrets + PORT) */
|
|
669
|
+
credentials: Record<string, string>;
|
|
670
|
+
/** Resolved port (from --port, workspace config, or default 3000) */
|
|
671
|
+
resolvedPort: number;
|
|
672
|
+
/** Path where credentials JSON was written */
|
|
673
|
+
secretsJsonPath: string;
|
|
674
|
+
/** Resolved app name (if in workspace) */
|
|
675
|
+
appName: string | undefined;
|
|
676
|
+
/** Secrets root directory */
|
|
677
|
+
secretsRoot: string;
|
|
678
|
+
/** Workspace app info (if in a workspace) */
|
|
679
|
+
appInfo?: WorkspaceAppInfo;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* Prepare credentials for dev/exec/test modes.
|
|
684
|
+
* Loads workspace config, secrets, resolves Docker ports, rewrites URLs,
|
|
685
|
+
* injects PORT, dependency URLs, and writes credentials JSON.
|
|
686
|
+
*
|
|
687
|
+
* @param options.resolveDockerPorts - How to resolve Docker ports:
|
|
688
|
+
* - `'full'` (default): probe running containers, saved state, then find available ports. Used by dev/test.
|
|
689
|
+
* - `'readonly'`: check running containers and saved state only, never probe for new ports. Used by exec.
|
|
690
|
+
* @param options.stages - Secret stages to try, in order. Default: ['dev', 'development'].
|
|
691
|
+
* @param options.startDocker - Start Docker Compose services after port resolution. Default: false.
|
|
692
|
+
* @param options.secretsFileName - Custom secrets JSON filename. Default: 'dev-secrets-{appName}.json' or 'dev-secrets.json'.
|
|
693
|
+
* @internal Exported for testing
|
|
694
|
+
*/
|
|
695
|
+
export async function prepareEntryCredentials(options: {
|
|
696
|
+
explicitPort?: number;
|
|
697
|
+
cwd?: string;
|
|
698
|
+
resolveDockerPorts?: 'full' | 'readonly';
|
|
699
|
+
/** Secret stages to try, in order. Default: ['dev', 'development'] */
|
|
700
|
+
stages?: string[];
|
|
701
|
+
/** Start Docker Compose services after port resolution. Default: false */
|
|
702
|
+
startDocker?: boolean;
|
|
703
|
+
/** Custom secrets JSON filename. Default: 'dev-secrets-{appName}.json' or 'dev-secrets.json' */
|
|
704
|
+
secretsFileName?: string;
|
|
705
|
+
}): Promise<EntryCredentialsResult> {
|
|
706
|
+
const cwd = options.cwd ?? process.cwd();
|
|
707
|
+
const portMode = options.resolveDockerPorts ?? 'full';
|
|
708
|
+
|
|
709
|
+
// Try to get workspace app config for port and secrets
|
|
710
|
+
let workspaceAppPort: number | undefined;
|
|
711
|
+
let secretsRoot: string = cwd;
|
|
712
|
+
let appName: string | undefined;
|
|
713
|
+
let appInfo: WorkspaceAppInfo | undefined;
|
|
714
|
+
|
|
715
|
+
try {
|
|
716
|
+
appInfo = await loadWorkspaceAppInfo(cwd);
|
|
717
|
+
workspaceAppPort = appInfo.app.port;
|
|
718
|
+
secretsRoot = appInfo.workspaceRoot;
|
|
719
|
+
appName = appInfo.appName;
|
|
720
|
+
} catch (error) {
|
|
721
|
+
// Not in a workspace - use defaults
|
|
722
|
+
logger.log(
|
|
723
|
+
`⚠️ Could not load workspace config: ${(error as Error).message}`,
|
|
724
|
+
);
|
|
725
|
+
secretsRoot = findSecretsRoot(cwd);
|
|
726
|
+
appName = getAppNameFromCwd(cwd) ?? undefined;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Determine port: explicit --port > workspace config > default 3000
|
|
730
|
+
const resolvedPort = options.explicitPort ?? workspaceAppPort ?? 3000;
|
|
731
|
+
|
|
732
|
+
// Load secrets and inject PORT
|
|
733
|
+
const credentials = await loadSecretsForApp(
|
|
734
|
+
secretsRoot,
|
|
735
|
+
appName,
|
|
736
|
+
options.stages,
|
|
737
|
+
);
|
|
738
|
+
|
|
739
|
+
// Always inject PORT into credentials so apps can read it
|
|
740
|
+
credentials.PORT = String(resolvedPort);
|
|
741
|
+
|
|
742
|
+
// Resolve Docker ports and rewrite connection URLs
|
|
743
|
+
const composePath = join(secretsRoot, 'docker-compose.yml');
|
|
744
|
+
const mappings = parseComposePortMappings(composePath);
|
|
745
|
+
if (mappings.length > 0) {
|
|
746
|
+
let resolvedPorts: ResolvedServicePorts;
|
|
747
|
+
|
|
748
|
+
if (portMode === 'full') {
|
|
749
|
+
// Full resolution: probe containers, saved state, find available ports
|
|
750
|
+
resolvedPorts = await resolveServicePorts(secretsRoot);
|
|
751
|
+
} else {
|
|
752
|
+
// Readonly: check running containers and saved state only
|
|
753
|
+
const savedPorts = await loadPortState(secretsRoot);
|
|
754
|
+
const ports: PortState = {};
|
|
755
|
+
|
|
756
|
+
for (const mapping of mappings) {
|
|
757
|
+
const containerPort = getContainerHostPort(
|
|
758
|
+
secretsRoot,
|
|
759
|
+
mapping.service,
|
|
760
|
+
mapping.containerPort,
|
|
761
|
+
);
|
|
762
|
+
if (containerPort !== null) {
|
|
763
|
+
ports[mapping.envVar] = containerPort;
|
|
764
|
+
} else {
|
|
765
|
+
const saved = savedPorts[mapping.envVar];
|
|
766
|
+
if (saved !== undefined) {
|
|
767
|
+
ports[mapping.envVar] = saved;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
resolvedPorts = { dockerEnv: {}, ports, mappings };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
// Start Docker services if requested (between port resolution and URL rewriting)
|
|
776
|
+
// Docker needs raw secrets (POSTGRES_USER, etc.) + resolved port env for compose interpolation
|
|
777
|
+
if (options.startDocker) {
|
|
778
|
+
if (appInfo) {
|
|
779
|
+
await startWorkspaceServices(
|
|
780
|
+
appInfo.workspace,
|
|
781
|
+
resolvedPorts.dockerEnv,
|
|
782
|
+
credentials,
|
|
783
|
+
);
|
|
784
|
+
} else {
|
|
785
|
+
await startComposeServices(
|
|
786
|
+
secretsRoot,
|
|
787
|
+
resolvedPorts.dockerEnv,
|
|
788
|
+
credentials,
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
if (Object.keys(resolvedPorts.ports).length > 0) {
|
|
794
|
+
const rewritten = rewriteUrlsWithPorts(credentials, resolvedPorts);
|
|
795
|
+
Object.assign(credentials, rewritten);
|
|
796
|
+
logger.log(
|
|
797
|
+
`🔌 Applied ${Object.keys(resolvedPorts.ports).length} port mapping(s)`,
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// Inject dependency URLs (works for both frontend and backend apps)
|
|
803
|
+
if (appInfo?.appName) {
|
|
804
|
+
const depEnv = getDependencyEnvVars(appInfo.workspace, appInfo.appName);
|
|
805
|
+
Object.assign(credentials, depEnv);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// Write secrets to temp JSON file (always write since we have PORT)
|
|
809
|
+
// Use app-specific filename to avoid race conditions when running multiple apps via turbo
|
|
810
|
+
const secretsDir = join(secretsRoot, '.gkm');
|
|
811
|
+
await mkdir(secretsDir, { recursive: true });
|
|
812
|
+
const secretsFileName =
|
|
813
|
+
options.secretsFileName ??
|
|
814
|
+
(appName ? `dev-secrets-${appName}.json` : 'dev-secrets.json');
|
|
815
|
+
const secretsJsonPath = join(secretsDir, secretsFileName);
|
|
816
|
+
await writeFile(secretsJsonPath, JSON.stringify(credentials, null, 2));
|
|
817
|
+
|
|
818
|
+
return {
|
|
819
|
+
credentials,
|
|
820
|
+
resolvedPort,
|
|
821
|
+
secretsJsonPath,
|
|
822
|
+
appName,
|
|
823
|
+
secretsRoot,
|
|
824
|
+
appInfo,
|
|
825
|
+
};
|
|
826
|
+
}
|