@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.
- 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 +701 -707
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +689 -695
- 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 +2 -2
- 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/index.ts +48 -830
- package/src/exec/index.ts +120 -0
- package/src/setup/index.ts +4 -1
- package/src/test/index.ts +32 -109
- 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
|
|
3
|
-
import { mkdir,
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|