@geekmidas/cli 10.0.0-alpha.26 → 10.0.0-alpha.28
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/dist/{HostingerProvider-BY26bWwB.mjs → HostingerProvider-Btxpto71.mjs} +2 -2
- package/dist/{HostingerProvider-BY26bWwB.mjs.map → HostingerProvider-Btxpto71.mjs.map} +1 -1
- package/dist/{bundler-mwH7WOb1.mjs → bundler-D5RcGmVM.mjs} +2 -2
- package/dist/{bundler-mwH7WOb1.mjs.map → bundler-D5RcGmVM.mjs.map} +1 -1
- package/dist/{config-Dn9DW2Fz.cjs → config-BTFLHfRb.cjs} +25 -4
- package/dist/{config-Dn9DW2Fz.cjs.map → config-BTFLHfRb.cjs.map} +1 -1
- package/dist/{config-DBGpRTUy.mjs → config-Dqxa-_Ge.mjs} +20 -5
- package/dist/{config-DBGpRTUy.mjs.map → config-Dqxa-_Ge.mjs.map} +1 -1
- package/dist/config.cjs +3 -2
- package/dist/config.d.cts +12 -1
- package/dist/config.d.cts.map +1 -1
- package/dist/config.d.mts +12 -1
- package/dist/config.d.mts.map +1 -1
- package/dist/config.mjs +3 -3
- package/dist/deploy/sniffer-loader.cjs +1 -1
- package/dist/{discover-B_HtYJCr.cjs → discover-BTh87DOO.cjs} +24 -2
- package/dist/{discover-B_HtYJCr.cjs.map → discover-BTh87DOO.cjs.map} +1 -1
- package/dist/{discover-0fhCbnk0.mjs → discover-CXGlQHm1.mjs} +24 -2
- package/dist/{discover-0fhCbnk0.mjs.map → discover-CXGlQHm1.mjs.map} +1 -1
- package/dist/{github-D58lt7RR.cjs → github-BUCXEl2v.cjs} +6 -6
- package/dist/{github-D58lt7RR.cjs.map → github-BUCXEl2v.cjs.map} +1 -1
- package/dist/{github-D5SBlOx5.mjs → github-zkzu-ka2.mjs} +4 -4
- package/dist/{github-D5SBlOx5.mjs.map → github-zkzu-ka2.mjs.map} +1 -1
- package/dist/{index-RCUizGxL.d.cts → index-BPN7kghQ.d.cts} +2 -12
- package/dist/{index-B4H4XF7A.d.mts.map → index-BPN7kghQ.d.cts.map} +1 -1
- package/dist/{index-B4H4XF7A.d.mts → index-BqWKWRm0.d.mts} +2 -12
- package/dist/{index-RCUizGxL.d.cts.map → index-BqWKWRm0.d.mts.map} +1 -1
- package/dist/index.cjs +805 -790
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +754 -741
- package/dist/index.mjs.map +1 -1
- package/dist/{openapi-DHQlstv6.mjs → openapi-D-Lk5Hr5.mjs} +3 -3
- package/dist/{openapi-DHQlstv6.mjs.map → openapi-D-Lk5Hr5.mjs.map} +1 -1
- package/dist/{openapi-BAKQLho-.cjs → openapi-K4H5tHzA.cjs} +3 -3
- package/dist/{openapi-BAKQLho-.cjs.map → openapi-K4H5tHzA.cjs.map} +1 -1
- package/dist/openapi.cjs +1 -1
- package/dist/openapi.mjs +1 -1
- package/dist/reconcile/public.cjs +1 -1
- package/dist/reconcile/public.d.cts +12 -0
- package/dist/reconcile/public.d.cts.map +1 -1
- package/dist/reconcile/public.d.mts +12 -0
- package/dist/reconcile/public.d.mts.map +1 -1
- package/dist/reconcile/public.mjs +1 -1
- package/dist/{generator-LfZU7NlU.mjs → reconcile-DhbCvh4K.mjs} +111 -2
- package/dist/reconcile-DhbCvh4K.mjs.map +1 -0
- package/dist/{generator-Wdfp0jCS.cjs → reconcile-szmJw_Sb.cjs} +130 -3
- package/dist/reconcile-szmJw_Sb.cjs.map +1 -0
- package/dist/workspace/index.cjs +1 -2
- package/dist/workspace/index.d.cts +2 -2
- package/dist/workspace/index.d.mts +2 -2
- package/dist/workspace/index.mjs +2 -2
- package/dist/{workspace-CRD9YinV.cjs → workspace-Bav3TVrP.cjs} +47 -78
- package/dist/workspace-Bav3TVrP.cjs.map +1 -0
- package/dist/{credentials-fQSAe4Nb.cjs → workspace-Cv48QwPK.cjs} +347 -516
- package/dist/workspace-Cv48QwPK.cjs.map +1 -0
- package/dist/{workspace-Bu3xNCej.mjs → workspace-T4IVGSoB.mjs} +48 -73
- package/dist/workspace-T4IVGSoB.mjs.map +1 -0
- package/dist/{credentials-DI9kJcaP.mjs → workspace-cBobQVoA.mjs} +349 -500
- package/dist/workspace-cBobQVoA.mjs.map +1 -0
- package/package.json +14 -14
- package/dist/credentials-DI9kJcaP.mjs.map +0 -1
- package/dist/credentials-fQSAe4Nb.cjs.map +0 -1
- package/dist/generator-LfZU7NlU.mjs.map +0 -1
- package/dist/generator-Wdfp0jCS.cjs.map +0 -1
- package/dist/reconcile-gb1lz1Dk.mjs +0 -113
- package/dist/reconcile-gb1lz1Dk.mjs.map +0 -1
- package/dist/reconcile-hx_vBvux.cjs +0 -136
- package/dist/reconcile-hx_vBvux.cjs.map +0 -1
- package/dist/transfer-CAJs_vhu.mjs +0 -122
- package/dist/transfer-CAJs_vhu.mjs.map +0 -1
- package/dist/transfer-D2qLFXsw.cjs +0 -151
- package/dist/transfer-D2qLFXsw.cjs.map +0 -1
- package/dist/workspace-Bu3xNCej.mjs.map +0 -1
- package/dist/workspace-CRD9YinV.cjs.map +0 -1
|
@@ -1,23 +1,242 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
|
|
2
|
-
import { a as DEFAULT_EVENTS$1, i as DEFAULT_CACHE, o as DEFAULT_STORAGE, t as discover } from "./discover-
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { r as generatePgBossUrl } from "./generator-LfZU7NlU.mjs";
|
|
2
|
+
import { a as DEFAULT_EVENTS$1, i as DEFAULT_CACHE, o as DEFAULT_STORAGE, t as discover } from "./discover-CXGlQHm1.mjs";
|
|
3
|
+
import { l as appKey, o as loadWorkspaceAppInfo, r as getAppNameFromCwd, s as loadWorkspaceConfig } from "./config-Dqxa-_Ge.mjs";
|
|
4
|
+
import { E as TEST_STAGE, t as allConstructGlobs } from "./workspace-T4IVGSoB.mjs";
|
|
6
5
|
import { n as readStageSecrets, o as toEmbeddableSecrets, r as secretsExist } from "./storage-TP0x118M.mjs";
|
|
7
|
-
import { existsSync
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
8
7
|
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
9
8
|
import { dirname, join, resolve } from "node:path";
|
|
10
|
-
import { execFile
|
|
11
|
-
import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependentsOf, provideKey, providedKeyFor, provisionOrder, publicEnvFor } from "@geekmidas/manifest";
|
|
9
|
+
import { execFile } from "node:child_process";
|
|
10
|
+
import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependenciesOf, dependentsOf, provideKey, providedKeyFor, provisionOrder, publicEnvFor } from "@geekmidas/manifest";
|
|
12
11
|
import { createServer } from "node:net";
|
|
13
12
|
import { config } from "dotenv";
|
|
14
|
-
import { parse as parse$1, stringify } from "yaml";
|
|
15
13
|
import { createHash } from "node:crypto";
|
|
16
14
|
import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
|
|
17
15
|
import { CreateBucketCommand, GetBucketPolicyCommand, HeadBucketCommand, PutBucketPolicyCommand, S3Client } from "@aws-sdk/client-s3";
|
|
18
16
|
import { Client } from "pg";
|
|
17
|
+
import { stringify } from "yaml";
|
|
19
18
|
import { promisify } from "node:util";
|
|
20
19
|
import { cacheTableStatements } from "@geekmidas/cache/postgres";
|
|
20
|
+
//#region src/credentials/index.ts
|
|
21
|
+
const logger = console;
|
|
22
|
+
/**
|
|
23
|
+
* Load environment files
|
|
24
|
+
* @internal Exported for testing
|
|
25
|
+
*/
|
|
26
|
+
function loadEnvFiles(envConfig, cwd = process.cwd()) {
|
|
27
|
+
const loaded = [];
|
|
28
|
+
const missing = [];
|
|
29
|
+
const envFiles = envConfig ? Array.isArray(envConfig) ? envConfig : [envConfig] : [".env"];
|
|
30
|
+
for (const envFile of envFiles) {
|
|
31
|
+
const envPath = resolve(cwd, envFile);
|
|
32
|
+
if (existsSync(envPath)) {
|
|
33
|
+
config({
|
|
34
|
+
path: envPath,
|
|
35
|
+
override: true,
|
|
36
|
+
quiet: true
|
|
37
|
+
});
|
|
38
|
+
loaded.push(envFile);
|
|
39
|
+
} else if (envConfig) missing.push(envFile);
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
loaded,
|
|
43
|
+
missing
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Check if a port is available
|
|
48
|
+
* @internal Exported for testing
|
|
49
|
+
*/
|
|
50
|
+
async function isPortAvailable(port) {
|
|
51
|
+
return new Promise((resolve) => {
|
|
52
|
+
const server = createServer();
|
|
53
|
+
server.once("error", (err) => {
|
|
54
|
+
if (err.code === "EADDRINUSE") resolve(false);
|
|
55
|
+
else resolve(false);
|
|
56
|
+
});
|
|
57
|
+
server.once("listening", () => {
|
|
58
|
+
server.close();
|
|
59
|
+
resolve(true);
|
|
60
|
+
});
|
|
61
|
+
server.listen(port);
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Find an available port starting from the preferred port
|
|
66
|
+
* @internal Exported for testing
|
|
67
|
+
*/
|
|
68
|
+
async function findAvailablePort(preferredPort, maxAttempts = 10) {
|
|
69
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
70
|
+
const port = preferredPort + i;
|
|
71
|
+
if (await isPortAvailable(port)) return port;
|
|
72
|
+
logger.log(`⚠️ Port ${port} is in use, trying ${port + 1}...`);
|
|
73
|
+
}
|
|
74
|
+
throw new Error(`Could not find an available port after trying ${maxAttempts} ports starting from ${preferredPort}`);
|
|
75
|
+
}
|
|
76
|
+
const PORT_STATE_PATH = ".gkm/ports.json";
|
|
77
|
+
/**
|
|
78
|
+
* Load saved port state from .gkm/ports.json.
|
|
79
|
+
* @internal Exported for testing
|
|
80
|
+
*/
|
|
81
|
+
async function loadPortState(workspaceRoot) {
|
|
82
|
+
try {
|
|
83
|
+
const raw = await readFile(join(workspaceRoot, PORT_STATE_PATH), "utf-8");
|
|
84
|
+
return JSON.parse(raw);
|
|
85
|
+
} catch {
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Save port state to .gkm/ports.json.
|
|
91
|
+
* @internal Exported for testing
|
|
92
|
+
*/
|
|
93
|
+
async function savePortState(workspaceRoot, ports) {
|
|
94
|
+
const dir = join(workspaceRoot, ".gkm");
|
|
95
|
+
await mkdir(dir, { recursive: true });
|
|
96
|
+
await writeFile(join(workspaceRoot, PORT_STATE_PATH), `${JSON.stringify(ports, null, 2)}\n`);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Load and flatten secrets for an app from encrypted storage.
|
|
100
|
+
* For workspace app: maps {APP}_DATABASE_URL → DATABASE_URL.
|
|
101
|
+
* @internal Exported for testing
|
|
102
|
+
*/
|
|
103
|
+
async function loadSecretsForApp(secretsRoot, stage, appName) {
|
|
104
|
+
let secrets = {};
|
|
105
|
+
if (secretsExist(stage, secretsRoot)) {
|
|
106
|
+
const stageSecrets = await readStageSecrets(stage, secretsRoot);
|
|
107
|
+
if (stageSecrets) {
|
|
108
|
+
logger.log(`🔐 Loading secrets from stage: ${stage}`);
|
|
109
|
+
secrets = toEmbeddableSecrets(stageSecrets);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (Object.keys(secrets).length === 0) return {};
|
|
113
|
+
if (!appName) return secrets;
|
|
114
|
+
const prefix = appName.toUpperCase();
|
|
115
|
+
const mapped = { ...secrets };
|
|
116
|
+
const appDbUrl = secrets[`${prefix}_DATABASE_URL`];
|
|
117
|
+
if (appDbUrl) mapped.DATABASE_URL = appDbUrl;
|
|
118
|
+
return mapped;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Walk up the directory tree to find the root containing .gkm/secrets/.
|
|
122
|
+
* @internal Exported for testing
|
|
123
|
+
*/
|
|
124
|
+
function findSecretsRoot(startDir) {
|
|
125
|
+
let dir = startDir;
|
|
126
|
+
while (dir !== "/") {
|
|
127
|
+
if (existsSync(join(dir, ".gkm", "secrets"))) return dir;
|
|
128
|
+
const parent = dirname(dir);
|
|
129
|
+
if (parent === dir) break;
|
|
130
|
+
dir = parent;
|
|
131
|
+
}
|
|
132
|
+
return startDir;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Generate the credentials injection code snippet.
|
|
136
|
+
* This is the common logic used by both entry wrapper and exec preload.
|
|
137
|
+
* @internal
|
|
138
|
+
*/
|
|
139
|
+
function generateCredentialsInjection(secretsJsonPath) {
|
|
140
|
+
return `import { existsSync, readFileSync } from 'node:fs';
|
|
141
|
+
|
|
142
|
+
// Inject dev secrets via globalThis and process.env
|
|
143
|
+
// Using globalThis.__gkm_credentials__ avoids CJS/ESM interop issues where
|
|
144
|
+
// Object.assign on the Credentials export only mutates one module copy.
|
|
145
|
+
const secretsPath = '${secretsJsonPath}';
|
|
146
|
+
if (existsSync(secretsPath)) {
|
|
147
|
+
const secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
|
|
148
|
+
globalThis.__gkm_credentials__ = secrets;
|
|
149
|
+
Object.assign(process.env, secrets);
|
|
150
|
+
}
|
|
151
|
+
`;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Create a preload script that injects secrets into Credentials.
|
|
155
|
+
* Used by `gkm exec` to inject secrets before running any command.
|
|
156
|
+
* @internal Exported for testing
|
|
157
|
+
*/
|
|
158
|
+
async function createCredentialsPreload(preloadPath, secretsJsonPath) {
|
|
159
|
+
const content = `/**
|
|
160
|
+
* Credentials preload generated by 'gkm exec'
|
|
161
|
+
* This file is loaded via NODE_OPTIONS="--import <path>"
|
|
162
|
+
*/
|
|
163
|
+
${generateCredentialsInjection(secretsJsonPath)}`;
|
|
164
|
+
await writeFile(preloadPath, content);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Create a wrapper script that injects secrets before importing the entry file.
|
|
168
|
+
* @internal Exported for testing
|
|
169
|
+
*/
|
|
170
|
+
async function createEntryWrapper(wrapperPath, entryPath, secretsJsonPath) {
|
|
171
|
+
const content = `#!/usr/bin/env node
|
|
172
|
+
/**
|
|
173
|
+
* Entry wrapper generated by 'gkm dev --entry'
|
|
174
|
+
*/
|
|
175
|
+
${secretsJsonPath ? `${generateCredentialsInjection(secretsJsonPath)}
|
|
176
|
+
` : ""}// Import and run the user's entry file (dynamic import ensures secrets load first)
|
|
177
|
+
await import('${entryPath}');
|
|
178
|
+
`;
|
|
179
|
+
await writeFile(wrapperPath, content);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Prepare credentials for dev/exec/test modes.
|
|
183
|
+
* Loads workspace config, secrets, resolves Docker ports, rewrites URLs,
|
|
184
|
+
* injects PORT, dependency URLs, and writes credentials JSON.
|
|
185
|
+
*
|
|
186
|
+
* @param options.stage - The stage whose secrets to load. Default: the project's `stages.local`.
|
|
187
|
+
* @param options.startDocker - Start Docker Compose services after port resolution. Default: false.
|
|
188
|
+
* @param options.secretsFileName - Custom secrets JSON filename. Default: 'dev-secrets-{appName}.json' or 'dev-secrets.json'.
|
|
189
|
+
* @internal Exported for testing
|
|
190
|
+
*/
|
|
191
|
+
async function prepareEntryCredentials(options) {
|
|
192
|
+
const cwd = options.cwd ?? process.cwd();
|
|
193
|
+
let workspaceAppPort;
|
|
194
|
+
let secretsRoot = cwd;
|
|
195
|
+
let appName;
|
|
196
|
+
let appInfo;
|
|
197
|
+
let workspace;
|
|
198
|
+
try {
|
|
199
|
+
appInfo = await loadWorkspaceAppInfo(cwd);
|
|
200
|
+
workspace = appInfo.workspace;
|
|
201
|
+
workspaceAppPort = appInfo.app.port;
|
|
202
|
+
secretsRoot = appInfo.workspaceRoot;
|
|
203
|
+
appName = appInfo.appName;
|
|
204
|
+
} catch {
|
|
205
|
+
workspace = await loadWorkspaceConfig(cwd).then((loaded) => loaded.workspace).catch(() => void 0);
|
|
206
|
+
secretsRoot = workspace?.root ?? findSecretsRoot(cwd);
|
|
207
|
+
appName = getAppNameFromCwd(cwd) ?? void 0;
|
|
208
|
+
}
|
|
209
|
+
const resolvedPort = options.explicitPort ?? workspaceAppPort ?? 3e3;
|
|
210
|
+
const stage = options.stage ?? workspace?.stages.local;
|
|
211
|
+
const credentials = stage ? await loadSecretsForApp(secretsRoot, stage, appName) : {};
|
|
212
|
+
credentials.PORT = String(resolvedPort);
|
|
213
|
+
const declaredKeys = [];
|
|
214
|
+
if (workspace) {
|
|
215
|
+
const { reconcileWorkspace } = await Promise.resolve().then(() => workspace_exports);
|
|
216
|
+
const reconciled = await reconcileWorkspace(workspace, {
|
|
217
|
+
stage: options.reconcileStage ?? workspace.stages.local,
|
|
218
|
+
start: options.startDocker ?? false
|
|
219
|
+
});
|
|
220
|
+
Object.assign(credentials, reconciled.env);
|
|
221
|
+
declaredKeys.push(...Object.keys(reconciled.env));
|
|
222
|
+
if (Object.keys(reconciled.env).length > 0) logger.log(`🔌 Resolved ${Object.keys(reconciled.env).length} declared URL(s)`);
|
|
223
|
+
}
|
|
224
|
+
const secretsDir = join(secretsRoot, ".gkm");
|
|
225
|
+
await mkdir(secretsDir, { recursive: true });
|
|
226
|
+
const secretsFileName = options.secretsFileName ?? (appName ? `dev-secrets-${appName}.json` : "dev-secrets.json");
|
|
227
|
+
const secretsJsonPath = join(secretsDir, secretsFileName);
|
|
228
|
+
await writeFile(secretsJsonPath, JSON.stringify(credentials, null, 2));
|
|
229
|
+
return {
|
|
230
|
+
credentials,
|
|
231
|
+
resolvedPort,
|
|
232
|
+
secretsJsonPath,
|
|
233
|
+
appName,
|
|
234
|
+
secretsRoot,
|
|
235
|
+
appInfo,
|
|
236
|
+
declaredKeys
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
//#endregion
|
|
21
240
|
//#region src/workspace/backends.ts
|
|
22
241
|
/**
|
|
23
242
|
* Which backend each declared kind resolves to — decided by where the project
|
|
@@ -296,11 +515,16 @@ function portOf(address) {
|
|
|
296
515
|
*/
|
|
297
516
|
function hostFor(resource, project) {
|
|
298
517
|
return [
|
|
299
|
-
resource.name,
|
|
518
|
+
resource.root && project ? stageOf(resource) : resource.name,
|
|
300
519
|
project,
|
|
301
520
|
LOCAL_TLD
|
|
302
521
|
].filter(Boolean).join(".");
|
|
303
522
|
}
|
|
523
|
+
/** The stage a resource's name carries, or nothing for the local stage's. */
|
|
524
|
+
function stageOf(resource) {
|
|
525
|
+
const base = resource.id.toLowerCase();
|
|
526
|
+
return resource.name === base ? void 0 : resource.name.slice(base.length + 1);
|
|
527
|
+
}
|
|
304
528
|
/**
|
|
305
529
|
* The static root config, which only ever imports.
|
|
306
530
|
*
|
|
@@ -367,6 +591,49 @@ const GENERATED = `# Generated by gkm from the construct manifest — do not edi
|
|
|
367
591
|
# from code; a browser needs the root trusted once.
|
|
368
592
|
`;
|
|
369
593
|
//#endregion
|
|
594
|
+
//#region src/workspace/rootSite.ts
|
|
595
|
+
/**
|
|
596
|
+
* Which site the base domain points at — deployed and locally alike.
|
|
597
|
+
*
|
|
598
|
+
* Three rules, in order, and the last one is the point:
|
|
599
|
+
*
|
|
600
|
+
* 1. **One site** — it is the root. Unambiguous, so nothing has to be said.
|
|
601
|
+
* 2. **Named `web`** — the convention wins, because people already rely on it.
|
|
602
|
+
* 3. **Declared `root: true`** — the site says so itself.
|
|
603
|
+
*
|
|
604
|
+
* Anything else throws. It used to take the *first* site it iterated, and that
|
|
605
|
+
* order came from the manifest, which is built in glob traversal order — so two
|
|
606
|
+
* sites with neither named `web` meant renaming a file could move the root
|
|
607
|
+
* domain, silently.
|
|
608
|
+
*
|
|
609
|
+
* One rule for both targets: deployed the root site gets the base domain, and
|
|
610
|
+
* locally it gets the project's bare host behind the edge. Before this was
|
|
611
|
+
* shared, the local edge put every site on a subdomain, so what a developer
|
|
612
|
+
* browsed was not the shape production served.
|
|
613
|
+
*
|
|
614
|
+
* @returns the root site's name, or `undefined` when there are no sites
|
|
615
|
+
* @throws {AmbiguousRootSite} when several sites could be the root and none says it is
|
|
616
|
+
*/
|
|
617
|
+
function rootSite(sites) {
|
|
618
|
+
if (sites.length === 0) return void 0;
|
|
619
|
+
if (sites.length === 1) return sites[0].name;
|
|
620
|
+
if (sites.some(({ name }) => name === "web")) return "web";
|
|
621
|
+
const declared = sites.filter(({ root }) => root === true);
|
|
622
|
+
if (declared.length === 1) return declared[0].name;
|
|
623
|
+
throw new AmbiguousRootSite(sites.map(({ name }) => name), declared.length > 1);
|
|
624
|
+
}
|
|
625
|
+
/** Several sites could hold the base domain, and none of them says it does. */
|
|
626
|
+
var AmbiguousRootSite = class extends Error {
|
|
627
|
+
sites;
|
|
628
|
+
tooMany;
|
|
629
|
+
constructor(sites, tooMany = false) {
|
|
630
|
+
super(tooMany ? `More than one site declares \`root: true\` — ${sites.join(", ")}. The base domain points at one of them.` : `${sites.length} sites and nothing says which holds the base domain: ${sites.join(", ")}. Name one of them \`web\`, or declare \`root: true\` on the one the base domain points at. It used to be whichever the glob reached first, which is not a thing to decide a hostname.`);
|
|
631
|
+
this.sites = sites;
|
|
632
|
+
this.tooMany = tooMany;
|
|
633
|
+
this.name = "AmbiguousRootSite";
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
//#endregion
|
|
370
637
|
//#region src/reconcile/plan.ts
|
|
371
638
|
/**
|
|
372
639
|
* The reconcile plan — what a manifest wants to exist, for one stage.
|
|
@@ -574,6 +841,15 @@ function planFor(manifest, stage, order, options = {}) {
|
|
|
574
841
|
for (const resource of placeless) resource.of = only.id;
|
|
575
842
|
}
|
|
576
843
|
}
|
|
844
|
+
const sites = resources.filter((r) => r.kind === "site");
|
|
845
|
+
const root = rootSite(sites.map((r) => {
|
|
846
|
+
const declared = manifest[r.id];
|
|
847
|
+
return {
|
|
848
|
+
name: appKey(r.id),
|
|
849
|
+
...declared?.kind === "site" && declared.root ? { root: true } : {}
|
|
850
|
+
};
|
|
851
|
+
}));
|
|
852
|
+
for (const site of sites) if (appKey(site.id) === root) site.root = true;
|
|
577
853
|
const workerBroker = events === "pgboss" && Object.values(manifest).some((d) => d?.kind === "worker") && resources.some((r) => r.kind === "database");
|
|
578
854
|
return {
|
|
579
855
|
stage,
|
|
@@ -1057,14 +1333,67 @@ function inNetworkEnv(workspace, manifest) {
|
|
|
1057
1333
|
});
|
|
1058
1334
|
return Object.fromEntries(Object.entries(env).map(([key, value]) => [key, rewrite(value)]));
|
|
1059
1335
|
}
|
|
1336
|
+
/**
|
|
1337
|
+
* The keys one app's environment may hold: what its own declaration provides
|
|
1338
|
+
* and requires, and what each construct it has an edge to provides.
|
|
1339
|
+
*
|
|
1340
|
+
* The edges, not the manifest. Every app used to be handed every key the
|
|
1341
|
+
* workspace resolved — a web app got the database's owner URL and the auth
|
|
1342
|
+
* server's signing secret, and the API got the auth server's database — which
|
|
1343
|
+
* is exactly what a declared edge exists to rule out. A deploy composes an
|
|
1344
|
+
* app's environment from its edges; so does this.
|
|
1345
|
+
*
|
|
1346
|
+
* One hop: a surface an app calls is reached over HTTP, so its URL is the
|
|
1347
|
+
* app's business and its database is not. A site also gets the public
|
|
1348
|
+
* variant of each key its bundle inlines (`NEXT_PUBLIC_API_URL`), and a surface
|
|
1349
|
+
* the edges of the workers whose crons and subscribers its server runs.
|
|
1350
|
+
*
|
|
1351
|
+
* `undefined` for an app no declaration describes, which gets nothing but its
|
|
1352
|
+
* port.
|
|
1353
|
+
*/
|
|
1354
|
+
function appEnvKeys(manifest, appName, runnables = {}) {
|
|
1355
|
+
const entry = Object.entries(manifest).find(([id, d]) => (d.kind === "rest-api" || d.kind === "site") && appKey(id) === appName);
|
|
1356
|
+
if (!entry) return void 0;
|
|
1357
|
+
const [id, declaration] = entry;
|
|
1358
|
+
const keys = /* @__PURE__ */ new Set([
|
|
1359
|
+
...declaration.provides ?? [],
|
|
1360
|
+
...declaration.requires ?? [],
|
|
1361
|
+
...declaration.kind === "rest-api" ? declaration.endpoints.flatMap((endpoint) => endpoint.requires ?? []) : []
|
|
1362
|
+
]);
|
|
1363
|
+
const edges = [...dependenciesOf(declaration).map((edge) => edge.target), ...runnables[id] ?? []];
|
|
1364
|
+
if (declaration.kind === "rest-api" && declaration.endpoints.length === 0) for (const [otherId, other] of Object.entries(manifest)) {
|
|
1365
|
+
if (other.kind !== "worker") continue;
|
|
1366
|
+
edges.push(...dependenciesOf(other).map((edge) => edge.target), ...runnables[otherId] ?? []);
|
|
1367
|
+
keys.add("EVENT_PUBLISHER_CONNECTION_STRING");
|
|
1368
|
+
keys.add("EVENT_SUBSCRIBER_CONNECTION_STRING");
|
|
1369
|
+
}
|
|
1370
|
+
for (const id of edges) {
|
|
1371
|
+
const target = manifest[id];
|
|
1372
|
+
if (!target) continue;
|
|
1373
|
+
for (const key of target.provides ?? []) keys.add(key);
|
|
1374
|
+
if (target.kind === "queue" || target.kind === "topic") {
|
|
1375
|
+
keys.add("EVENT_PUBLISHER_CONNECTION_STRING");
|
|
1376
|
+
keys.add("EVENT_SUBSCRIBER_CONNECTION_STRING");
|
|
1377
|
+
}
|
|
1378
|
+
if (target.kind === "objects") {
|
|
1379
|
+
keys.add("AWS_ACCESS_KEY_ID");
|
|
1380
|
+
keys.add("AWS_SECRET_ACCESS_KEY");
|
|
1381
|
+
keys.add("AWS_REGION");
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
if (declaration.kind === "site") for (const key of Object.keys(publicEnvFor(declaration, manifest))) keys.add(key);
|
|
1385
|
+
return keys;
|
|
1386
|
+
}
|
|
1060
1387
|
/** One compose service per app the workspace runs, behind the apps profile. */
|
|
1061
|
-
function appServices(workspace, manifest, containers) {
|
|
1388
|
+
function appServices(workspace, manifest, containers, runnables = {}) {
|
|
1062
1389
|
const env = inNetworkEnv(workspace, manifest);
|
|
1063
1390
|
const services = {};
|
|
1064
1391
|
for (const [name, app] of Object.entries(workspace.apps)) {
|
|
1065
1392
|
if (app.type === "mobile" || !app.port) continue;
|
|
1066
1393
|
const key = appKey(name);
|
|
1067
1394
|
const health = app.type === "web" ? "/" : "/health";
|
|
1395
|
+
const allowed = appEnvKeys(manifest, name, runnables);
|
|
1396
|
+
const own = Object.fromEntries(Object.entries(env).filter(([envKey]) => allowed?.has(envKey)));
|
|
1068
1397
|
services[key] = {
|
|
1069
1398
|
image: `${key}:\${TAG:-latest}`,
|
|
1070
1399
|
build: {
|
|
@@ -1076,7 +1405,7 @@ function appServices(workspace, manifest, containers) {
|
|
|
1076
1405
|
environment: {
|
|
1077
1406
|
NODE_ENV: "production",
|
|
1078
1407
|
PORT: String(app.port),
|
|
1079
|
-
...
|
|
1408
|
+
...own
|
|
1080
1409
|
},
|
|
1081
1410
|
...containers.some((c) => c !== "caddy") ? { depends_on: containers.filter((c) => c !== "caddy") } : {},
|
|
1082
1411
|
healthcheck: {
|
|
@@ -2235,8 +2564,7 @@ var workspace_exports = /* @__PURE__ */ __exportAll({
|
|
|
2235
2564
|
constructGlobs: () => constructGlobs,
|
|
2236
2565
|
derivedContainers: () => derivedContainers,
|
|
2237
2566
|
reconcileWorkspace: () => reconcileWorkspace,
|
|
2238
|
-
surfaceAddresses: () => surfaceAddresses
|
|
2239
|
-
usesConstructs: () => usesConstructs
|
|
2567
|
+
surfaceAddresses: () => surfaceAddresses
|
|
2240
2568
|
});
|
|
2241
2569
|
/**
|
|
2242
2570
|
* Every constructs glob in the workspace, resolved against its app.
|
|
@@ -2247,10 +2575,6 @@ var workspace_exports = /* @__PURE__ */ __exportAll({
|
|
|
2247
2575
|
function constructGlobs(workspace) {
|
|
2248
2576
|
return allConstructGlobs(workspace);
|
|
2249
2577
|
}
|
|
2250
|
-
/** Whether any app has adopted the constructs glob. */
|
|
2251
|
-
function usesConstructs(workspace) {
|
|
2252
|
-
return constructGlobs(workspace).length > 0;
|
|
2253
|
-
}
|
|
2254
2578
|
/** The backends a workspace's target implies, as the plan takes them. */
|
|
2255
2579
|
function backendsOf(workspace) {
|
|
2256
2580
|
const on = providerOf(workspace);
|
|
@@ -2286,10 +2610,13 @@ async function derivedContainers(workspace, stage, manifest) {
|
|
|
2286
2610
|
* stays a pure-ish function of its inputs and the store keeps its single owner.
|
|
2287
2611
|
*/
|
|
2288
2612
|
async function reconcileWorkspace(workspace, options) {
|
|
2289
|
-
const
|
|
2613
|
+
const runnables = {};
|
|
2614
|
+
const discovered = await discover({
|
|
2290
2615
|
patterns: constructGlobs(workspace),
|
|
2291
|
-
cwd: workspace.root
|
|
2616
|
+
cwd: workspace.root,
|
|
2617
|
+
runnables
|
|
2292
2618
|
});
|
|
2619
|
+
const manifest = options.manifest ?? discovered;
|
|
2293
2620
|
const result = await reconcile({
|
|
2294
2621
|
root: workspace.root,
|
|
2295
2622
|
project: workspace.name,
|
|
@@ -2299,7 +2626,7 @@ async function reconcileWorkspace(workspace, options) {
|
|
|
2299
2626
|
...backendsOf(workspace),
|
|
2300
2627
|
saved: await loadPortState(workspace.root),
|
|
2301
2628
|
addresses: surfaceAddresses(workspace, manifest),
|
|
2302
|
-
apps: (containers) => appServices(workspace, manifest, containers),
|
|
2629
|
+
apps: (containers) => appServices(workspace, manifest, containers, runnables),
|
|
2303
2630
|
...options.start === void 0 ? {} : { start: options.start }
|
|
2304
2631
|
});
|
|
2305
2632
|
await savePortState(workspace.root, { ...result.ports });
|
|
@@ -2336,484 +2663,6 @@ function localAddress(port) {
|
|
|
2336
2663
|
return `http://localhost:${port}`;
|
|
2337
2664
|
}
|
|
2338
2665
|
//#endregion
|
|
2339
|
-
|
|
2340
|
-
const logger = console;
|
|
2341
|
-
/**
|
|
2342
|
-
* Load environment files
|
|
2343
|
-
* @internal Exported for testing
|
|
2344
|
-
*/
|
|
2345
|
-
function loadEnvFiles(envConfig, cwd = process.cwd()) {
|
|
2346
|
-
const loaded = [];
|
|
2347
|
-
const missing = [];
|
|
2348
|
-
const envFiles = envConfig ? Array.isArray(envConfig) ? envConfig : [envConfig] : [".env"];
|
|
2349
|
-
for (const envFile of envFiles) {
|
|
2350
|
-
const envPath = resolve(cwd, envFile);
|
|
2351
|
-
if (existsSync(envPath)) {
|
|
2352
|
-
config({
|
|
2353
|
-
path: envPath,
|
|
2354
|
-
override: true,
|
|
2355
|
-
quiet: true
|
|
2356
|
-
});
|
|
2357
|
-
loaded.push(envFile);
|
|
2358
|
-
} else if (envConfig) missing.push(envFile);
|
|
2359
|
-
}
|
|
2360
|
-
return {
|
|
2361
|
-
loaded,
|
|
2362
|
-
missing
|
|
2363
|
-
};
|
|
2364
|
-
}
|
|
2365
|
-
/**
|
|
2366
|
-
* Check if a port is available
|
|
2367
|
-
* @internal Exported for testing
|
|
2368
|
-
*/
|
|
2369
|
-
async function isPortAvailable(port) {
|
|
2370
|
-
return new Promise((resolve) => {
|
|
2371
|
-
const server = createServer();
|
|
2372
|
-
server.once("error", (err) => {
|
|
2373
|
-
if (err.code === "EADDRINUSE") resolve(false);
|
|
2374
|
-
else resolve(false);
|
|
2375
|
-
});
|
|
2376
|
-
server.once("listening", () => {
|
|
2377
|
-
server.close();
|
|
2378
|
-
resolve(true);
|
|
2379
|
-
});
|
|
2380
|
-
server.listen(port);
|
|
2381
|
-
});
|
|
2382
|
-
}
|
|
2383
|
-
/**
|
|
2384
|
-
* Find an available port starting from the preferred port
|
|
2385
|
-
* @internal Exported for testing
|
|
2386
|
-
*/
|
|
2387
|
-
async function findAvailablePort(preferredPort, maxAttempts = 10) {
|
|
2388
|
-
for (let i = 0; i < maxAttempts; i++) {
|
|
2389
|
-
const port = preferredPort + i;
|
|
2390
|
-
if (await isPortAvailable(port)) return port;
|
|
2391
|
-
logger.log(`⚠️ Port ${port} is in use, trying ${port + 1}...`);
|
|
2392
|
-
}
|
|
2393
|
-
throw new Error(`Could not find an available port after trying ${maxAttempts} ports starting from ${preferredPort}`);
|
|
2394
|
-
}
|
|
2395
|
-
const PORT_STATE_PATH = ".gkm/ports.json";
|
|
2396
|
-
/**
|
|
2397
|
-
* Parse docker-compose.yml and extract all port mappings that use env var interpolation.
|
|
2398
|
-
* Entries like `'${POSTGRES_HOST_PORT:-5432}:5432'` are captured.
|
|
2399
|
-
* Fixed port mappings like `'5050:80'` are skipped.
|
|
2400
|
-
* @internal Exported for testing
|
|
2401
|
-
*/
|
|
2402
|
-
function parseComposePortMappings(composePath) {
|
|
2403
|
-
if (!existsSync(composePath)) return [];
|
|
2404
|
-
const content = readFileSync(composePath, "utf-8");
|
|
2405
|
-
const compose = parse$1(content);
|
|
2406
|
-
if (!compose?.services) return [];
|
|
2407
|
-
const results = [];
|
|
2408
|
-
for (const [serviceName, serviceConfig] of Object.entries(compose.services)) for (const portMapping of serviceConfig?.ports ?? []) {
|
|
2409
|
-
const match = String(portMapping).match(/\$\{(\w+):-(\d+)\}:(\d+)/);
|
|
2410
|
-
if (match?.[1] && match[2] && match[3]) results.push({
|
|
2411
|
-
service: serviceName,
|
|
2412
|
-
envVar: match[1],
|
|
2413
|
-
defaultPort: Number(match[2]),
|
|
2414
|
-
containerPort: Number(match[3])
|
|
2415
|
-
});
|
|
2416
|
-
}
|
|
2417
|
-
return results;
|
|
2418
|
-
}
|
|
2419
|
-
/**
|
|
2420
|
-
* Load saved port state from .gkm/ports.json.
|
|
2421
|
-
* @internal Exported for testing
|
|
2422
|
-
*/
|
|
2423
|
-
async function loadPortState(workspaceRoot) {
|
|
2424
|
-
try {
|
|
2425
|
-
const raw = await readFile(join(workspaceRoot, PORT_STATE_PATH), "utf-8");
|
|
2426
|
-
return JSON.parse(raw);
|
|
2427
|
-
} catch {
|
|
2428
|
-
return {};
|
|
2429
|
-
}
|
|
2430
|
-
}
|
|
2431
|
-
/**
|
|
2432
|
-
* Save port state to .gkm/ports.json.
|
|
2433
|
-
* @internal Exported for testing
|
|
2434
|
-
*/
|
|
2435
|
-
async function savePortState(workspaceRoot, ports) {
|
|
2436
|
-
const dir = join(workspaceRoot, ".gkm");
|
|
2437
|
-
await mkdir(dir, { recursive: true });
|
|
2438
|
-
await writeFile(join(workspaceRoot, PORT_STATE_PATH), `${JSON.stringify(ports, null, 2)}\n`);
|
|
2439
|
-
}
|
|
2440
|
-
/**
|
|
2441
|
-
* Check if a project's own Docker container is running and return its host port.
|
|
2442
|
-
* Uses `docker compose port` scoped to the project's compose file.
|
|
2443
|
-
* @internal Exported for testing
|
|
2444
|
-
*/
|
|
2445
|
-
function getContainerHostPort(workspaceRoot, service, containerPort) {
|
|
2446
|
-
try {
|
|
2447
|
-
const match = execSync(`docker compose port ${service} ${containerPort}`, {
|
|
2448
|
-
cwd: workspaceRoot,
|
|
2449
|
-
stdio: "pipe"
|
|
2450
|
-
}).toString().trim().match(/:(\d+)$/);
|
|
2451
|
-
return match ? Number(match[1]) : null;
|
|
2452
|
-
} catch {
|
|
2453
|
-
return null;
|
|
2454
|
-
}
|
|
2455
|
-
}
|
|
2456
|
-
/**
|
|
2457
|
-
* Resolve host ports for Docker services by parsing docker-compose.yml.
|
|
2458
|
-
* Priority: running container → saved state → find available port.
|
|
2459
|
-
* Persists resolved ports to .gkm/ports.json.
|
|
2460
|
-
* @internal Exported for testing
|
|
2461
|
-
*/
|
|
2462
|
-
async function resolveServicePorts(workspaceRoot) {
|
|
2463
|
-
const mappings = parseComposePortMappings(join(workspaceRoot, "docker-compose.yml"));
|
|
2464
|
-
if (mappings.length === 0) return {
|
|
2465
|
-
dockerEnv: {},
|
|
2466
|
-
ports: {},
|
|
2467
|
-
mappings: []
|
|
2468
|
-
};
|
|
2469
|
-
const savedState = await loadPortState(workspaceRoot);
|
|
2470
|
-
const dockerEnv = {};
|
|
2471
|
-
const ports = {};
|
|
2472
|
-
const assignedPorts = /* @__PURE__ */ new Set();
|
|
2473
|
-
logger.log("\n🔌 Resolving service ports...");
|
|
2474
|
-
for (const mapping of mappings) {
|
|
2475
|
-
const containerPort = getContainerHostPort(workspaceRoot, mapping.service, mapping.containerPort);
|
|
2476
|
-
if (containerPort !== null) {
|
|
2477
|
-
ports[mapping.envVar] = containerPort;
|
|
2478
|
-
dockerEnv[mapping.envVar] = String(containerPort);
|
|
2479
|
-
assignedPorts.add(containerPort);
|
|
2480
|
-
logger.log(` 🔄 ${mapping.service}:${mapping.containerPort}: reusing existing container on port ${containerPort}`);
|
|
2481
|
-
continue;
|
|
2482
|
-
}
|
|
2483
|
-
const savedPort = savedState[mapping.envVar];
|
|
2484
|
-
if (savedPort && !assignedPorts.has(savedPort) && await isPortAvailable(savedPort)) {
|
|
2485
|
-
ports[mapping.envVar] = savedPort;
|
|
2486
|
-
dockerEnv[mapping.envVar] = String(savedPort);
|
|
2487
|
-
assignedPorts.add(savedPort);
|
|
2488
|
-
logger.log(` 💾 ${mapping.service}:${mapping.containerPort}: using saved port ${savedPort}`);
|
|
2489
|
-
continue;
|
|
2490
|
-
}
|
|
2491
|
-
let resolvedPort = await findAvailablePort(mapping.defaultPort);
|
|
2492
|
-
while (assignedPorts.has(resolvedPort)) resolvedPort = await findAvailablePort(resolvedPort + 1);
|
|
2493
|
-
ports[mapping.envVar] = resolvedPort;
|
|
2494
|
-
dockerEnv[mapping.envVar] = String(resolvedPort);
|
|
2495
|
-
assignedPorts.add(resolvedPort);
|
|
2496
|
-
if (resolvedPort !== mapping.defaultPort) logger.log(` ⚡ ${mapping.service}:${mapping.containerPort}: port ${mapping.defaultPort} occupied, using port ${resolvedPort}`);
|
|
2497
|
-
else logger.log(` ✅ ${mapping.service}:${mapping.containerPort}: using default port ${resolvedPort}`);
|
|
2498
|
-
}
|
|
2499
|
-
await savePortState(workspaceRoot, ports);
|
|
2500
|
-
return {
|
|
2501
|
-
dockerEnv,
|
|
2502
|
-
ports,
|
|
2503
|
-
mappings
|
|
2504
|
-
};
|
|
2505
|
-
}
|
|
2506
|
-
/**
|
|
2507
|
-
* Replace a port in a URL string.
|
|
2508
|
-
* Handles both `hostname:port` and `localhost:port` patterns.
|
|
2509
|
-
* @internal Exported for testing
|
|
2510
|
-
*/
|
|
2511
|
-
function replacePortInUrl(url, oldPort, newPort) {
|
|
2512
|
-
if (oldPort === newPort) return url;
|
|
2513
|
-
let result = url.replace(new RegExp(`:${oldPort}(?=[/?#]|$)`, "g"), `:${newPort}`);
|
|
2514
|
-
result = result.replace(new RegExp(`%3A${oldPort}(?=[%/?#&]|$)`, "gi"), `%3A${newPort}`);
|
|
2515
|
-
return result;
|
|
2516
|
-
}
|
|
2517
|
-
/**
|
|
2518
|
-
* Rewrite connection URLs and port vars in secrets with resolved ports.
|
|
2519
|
-
* Uses the parsed compose mappings to determine which default ports to replace.
|
|
2520
|
-
* Pure transform — does not modify secrets on disk.
|
|
2521
|
-
* @internal Exported for testing
|
|
2522
|
-
*/
|
|
2523
|
-
function rewriteUrlsWithPorts(secrets, resolvedPorts) {
|
|
2524
|
-
const { ports, mappings } = resolvedPorts;
|
|
2525
|
-
const result = { ...secrets };
|
|
2526
|
-
const portReplacements = [];
|
|
2527
|
-
const serviceNames = /* @__PURE__ */ new Set();
|
|
2528
|
-
for (const mapping of mappings) {
|
|
2529
|
-
serviceNames.add(mapping.service);
|
|
2530
|
-
const resolved = ports[mapping.envVar];
|
|
2531
|
-
if (resolved !== void 0) portReplacements.push({
|
|
2532
|
-
defaultPort: mapping.defaultPort,
|
|
2533
|
-
resolvedPort: resolved
|
|
2534
|
-
});
|
|
2535
|
-
}
|
|
2536
|
-
for (const [key, value] of Object.entries(result)) {
|
|
2537
|
-
if (!key.endsWith("_HOST")) continue;
|
|
2538
|
-
if (serviceNames.has(value)) result[key] = "localhost";
|
|
2539
|
-
}
|
|
2540
|
-
for (const [key, value] of Object.entries(result)) {
|
|
2541
|
-
if (!key.endsWith("_PORT")) continue;
|
|
2542
|
-
for (const { defaultPort, resolvedPort } of portReplacements) if (value === String(defaultPort)) result[key] = String(resolvedPort);
|
|
2543
|
-
}
|
|
2544
|
-
for (const [key, value] of Object.entries(result)) {
|
|
2545
|
-
if (!key.endsWith("_URL") && !key.endsWith("_ENDPOINT") && !key.endsWith("_CONNECTION_STRING") && key !== "DATABASE_URL") continue;
|
|
2546
|
-
let rewritten = value;
|
|
2547
|
-
for (const name of serviceNames) rewritten = rewritten.replace(new RegExp(`@${name}:`, "g"), "@localhost:");
|
|
2548
|
-
for (const { defaultPort, resolvedPort } of portReplacements) rewritten = replacePortInUrl(rewritten, defaultPort, resolvedPort);
|
|
2549
|
-
result[key] = rewritten;
|
|
2550
|
-
}
|
|
2551
|
-
return result;
|
|
2552
|
-
}
|
|
2553
|
-
/**
|
|
2554
|
-
* Build the environment variables to pass to `docker compose up`.
|
|
2555
|
-
* Merges process.env, secrets, and port mappings so that Docker Compose
|
|
2556
|
-
* can interpolate variables like ${POSTGRES_USER} correctly.
|
|
2557
|
-
* @internal Exported for testing
|
|
2558
|
-
*/
|
|
2559
|
-
function buildDockerComposeEnv(secretsEnv, portEnv) {
|
|
2560
|
-
return {
|
|
2561
|
-
...process.env,
|
|
2562
|
-
...secretsEnv,
|
|
2563
|
-
...portEnv
|
|
2564
|
-
};
|
|
2565
|
-
}
|
|
2566
|
-
/**
|
|
2567
|
-
* Parse all service names from a docker-compose.yml file.
|
|
2568
|
-
* @internal Exported for testing
|
|
2569
|
-
*/
|
|
2570
|
-
function parseComposeServiceNames(composePath) {
|
|
2571
|
-
if (!existsSync(composePath)) return [];
|
|
2572
|
-
const content = readFileSync(composePath, "utf-8");
|
|
2573
|
-
const compose = parse$1(content);
|
|
2574
|
-
return Object.keys(compose?.services ?? {});
|
|
2575
|
-
}
|
|
2576
|
-
/**
|
|
2577
|
-
* Start docker-compose services for a single-app project (no workspace config).
|
|
2578
|
-
* Starts all services defined in docker-compose.yml.
|
|
2579
|
-
*/
|
|
2580
|
-
async function startComposeServices(cwd, portEnv, secretsEnv) {
|
|
2581
|
-
const composeFile = join(cwd, "docker-compose.yml");
|
|
2582
|
-
if (!existsSync(composeFile)) return;
|
|
2583
|
-
const servicesToStart = parseComposeServiceNames(composeFile);
|
|
2584
|
-
if (servicesToStart.length === 0) return;
|
|
2585
|
-
logger.log(`🐳 Starting services: ${servicesToStart.join(", ")}`);
|
|
2586
|
-
try {
|
|
2587
|
-
execSync(`docker compose up -d ${servicesToStart.join(" ")}`, {
|
|
2588
|
-
cwd,
|
|
2589
|
-
stdio: "inherit",
|
|
2590
|
-
env: buildDockerComposeEnv(secretsEnv, portEnv)
|
|
2591
|
-
});
|
|
2592
|
-
logger.log("✅ Services started");
|
|
2593
|
-
} catch (error) {
|
|
2594
|
-
logger.error("❌ Failed to start services:", error.message);
|
|
2595
|
-
throw error;
|
|
2596
|
-
}
|
|
2597
|
-
}
|
|
2598
|
-
/**
|
|
2599
|
-
* Start docker-compose services for a workspace.
|
|
2600
|
-
* Discovers all services from docker-compose.yml and starts everything
|
|
2601
|
-
* except app services (which are managed by turbo).
|
|
2602
|
-
* @internal Exported for testing
|
|
2603
|
-
*/
|
|
2604
|
-
async function startWorkspaceServices(workspace, portEnv, secretsEnv) {
|
|
2605
|
-
const composeFile = join(workspace.root, "docker-compose.yml");
|
|
2606
|
-
if (!existsSync(composeFile)) return;
|
|
2607
|
-
const allServices = parseComposeServiceNames(composeFile);
|
|
2608
|
-
const appNames = new Set(Object.keys(workspace.apps));
|
|
2609
|
-
const servicesToStart = allServices.filter((name) => !appNames.has(name));
|
|
2610
|
-
if (servicesToStart.length === 0) return;
|
|
2611
|
-
logger.log(`🐳 Starting services: ${servicesToStart.join(", ")}`);
|
|
2612
|
-
try {
|
|
2613
|
-
execSync(`docker compose up -d ${servicesToStart.join(" ")}`, {
|
|
2614
|
-
cwd: workspace.root,
|
|
2615
|
-
stdio: "inherit",
|
|
2616
|
-
env: buildDockerComposeEnv(secretsEnv, portEnv)
|
|
2617
|
-
});
|
|
2618
|
-
logger.log("✅ Services started");
|
|
2619
|
-
} catch (error) {
|
|
2620
|
-
logger.error("❌ Failed to start services:", error.message);
|
|
2621
|
-
throw error;
|
|
2622
|
-
}
|
|
2623
|
-
}
|
|
2624
|
-
/**
|
|
2625
|
-
* Load and flatten secrets for an app from encrypted storage.
|
|
2626
|
-
* For workspace app: maps {APP}_DATABASE_URL → DATABASE_URL.
|
|
2627
|
-
* @internal Exported for testing
|
|
2628
|
-
*/
|
|
2629
|
-
async function loadSecretsForApp(secretsRoot, stage, appName) {
|
|
2630
|
-
let secrets = {};
|
|
2631
|
-
if (secretsExist(stage, secretsRoot)) {
|
|
2632
|
-
const stageSecrets = await readStageSecrets(stage, secretsRoot);
|
|
2633
|
-
if (stageSecrets) {
|
|
2634
|
-
logger.log(`🔐 Loading secrets from stage: ${stage}`);
|
|
2635
|
-
secrets = toEmbeddableSecrets(stageSecrets);
|
|
2636
|
-
}
|
|
2637
|
-
}
|
|
2638
|
-
if (Object.keys(secrets).length === 0) return {};
|
|
2639
|
-
if (!appName) return secrets;
|
|
2640
|
-
const prefix = appName.toUpperCase();
|
|
2641
|
-
const mapped = { ...secrets };
|
|
2642
|
-
const appDbUrl = secrets[`${prefix}_DATABASE_URL`];
|
|
2643
|
-
if (appDbUrl) mapped.DATABASE_URL = appDbUrl;
|
|
2644
|
-
return mapped;
|
|
2645
|
-
}
|
|
2646
|
-
/**
|
|
2647
|
-
* Walk up the directory tree to find the root containing .gkm/secrets/.
|
|
2648
|
-
* @internal Exported for testing
|
|
2649
|
-
*/
|
|
2650
|
-
function findSecretsRoot(startDir) {
|
|
2651
|
-
let dir = startDir;
|
|
2652
|
-
while (dir !== "/") {
|
|
2653
|
-
if (existsSync(join(dir, ".gkm", "secrets"))) return dir;
|
|
2654
|
-
const parent = dirname(dir);
|
|
2655
|
-
if (parent === dir) break;
|
|
2656
|
-
dir = parent;
|
|
2657
|
-
}
|
|
2658
|
-
return startDir;
|
|
2659
|
-
}
|
|
2660
|
-
/**
|
|
2661
|
-
* Generate the credentials injection code snippet.
|
|
2662
|
-
* This is the common logic used by both entry wrapper and exec preload.
|
|
2663
|
-
* @internal
|
|
2664
|
-
*/
|
|
2665
|
-
function generateCredentialsInjection(secretsJsonPath) {
|
|
2666
|
-
return `import { existsSync, readFileSync } from 'node:fs';
|
|
2667
|
-
|
|
2668
|
-
// Inject dev secrets via globalThis and process.env
|
|
2669
|
-
// Using globalThis.__gkm_credentials__ avoids CJS/ESM interop issues where
|
|
2670
|
-
// Object.assign on the Credentials export only mutates one module copy.
|
|
2671
|
-
const secretsPath = '${secretsJsonPath}';
|
|
2672
|
-
if (existsSync(secretsPath)) {
|
|
2673
|
-
const secrets = JSON.parse(readFileSync(secretsPath, 'utf-8'));
|
|
2674
|
-
globalThis.__gkm_credentials__ = secrets;
|
|
2675
|
-
Object.assign(process.env, secrets);
|
|
2676
|
-
}
|
|
2677
|
-
`;
|
|
2678
|
-
}
|
|
2679
|
-
/**
|
|
2680
|
-
* Create a preload script that injects secrets into Credentials.
|
|
2681
|
-
* Used by `gkm exec` to inject secrets before running any command.
|
|
2682
|
-
* @internal Exported for testing
|
|
2683
|
-
*/
|
|
2684
|
-
async function createCredentialsPreload(preloadPath, secretsJsonPath) {
|
|
2685
|
-
const content = `/**
|
|
2686
|
-
* Credentials preload generated by 'gkm exec'
|
|
2687
|
-
* This file is loaded via NODE_OPTIONS="--import <path>"
|
|
2688
|
-
*/
|
|
2689
|
-
${generateCredentialsInjection(secretsJsonPath)}`;
|
|
2690
|
-
await writeFile(preloadPath, content);
|
|
2691
|
-
}
|
|
2692
|
-
/**
|
|
2693
|
-
* Create a wrapper script that injects secrets before importing the entry file.
|
|
2694
|
-
* @internal Exported for testing
|
|
2695
|
-
*/
|
|
2696
|
-
async function createEntryWrapper(wrapperPath, entryPath, secretsJsonPath) {
|
|
2697
|
-
const content = `#!/usr/bin/env node
|
|
2698
|
-
/**
|
|
2699
|
-
* Entry wrapper generated by 'gkm dev --entry'
|
|
2700
|
-
*/
|
|
2701
|
-
${secretsJsonPath ? `${generateCredentialsInjection(secretsJsonPath)}
|
|
2702
|
-
` : ""}// Import and run the user's entry file (dynamic import ensures secrets load first)
|
|
2703
|
-
await import('${entryPath}');
|
|
2704
|
-
`;
|
|
2705
|
-
await writeFile(wrapperPath, content);
|
|
2706
|
-
}
|
|
2707
|
-
/**
|
|
2708
|
-
* Prepare credentials for dev/exec/test modes.
|
|
2709
|
-
* Loads workspace config, secrets, resolves Docker ports, rewrites URLs,
|
|
2710
|
-
* injects PORT, dependency URLs, and writes credentials JSON.
|
|
2711
|
-
*
|
|
2712
|
-
* @param options.resolveDockerPorts - How to resolve Docker ports:
|
|
2713
|
-
* - `'full'` (default): probe running containers, saved state, then find available ports. Used by dev/test.
|
|
2714
|
-
* - `'readonly'`: check running containers and saved state only, never probe for new ports. Used by exec.
|
|
2715
|
-
* @param options.stage - The stage whose secrets to load. Default: the project's `stages.local`.
|
|
2716
|
-
* @param options.startDocker - Start Docker Compose services after port resolution. Default: false.
|
|
2717
|
-
* @param options.secretsFileName - Custom secrets JSON filename. Default: 'dev-secrets-{appName}.json' or 'dev-secrets.json'.
|
|
2718
|
-
* @internal Exported for testing
|
|
2719
|
-
*/
|
|
2720
|
-
async function prepareEntryCredentials(options) {
|
|
2721
|
-
const cwd = options.cwd ?? process.cwd();
|
|
2722
|
-
const portMode = options.resolveDockerPorts ?? "full";
|
|
2723
|
-
let workspaceAppPort;
|
|
2724
|
-
let secretsRoot = cwd;
|
|
2725
|
-
let appName;
|
|
2726
|
-
let appInfo;
|
|
2727
|
-
let workspace;
|
|
2728
|
-
try {
|
|
2729
|
-
appInfo = await loadWorkspaceAppInfo(cwd);
|
|
2730
|
-
workspace = appInfo.workspace;
|
|
2731
|
-
workspaceAppPort = appInfo.app.port;
|
|
2732
|
-
secretsRoot = appInfo.workspaceRoot;
|
|
2733
|
-
appName = appInfo.appName;
|
|
2734
|
-
} catch {
|
|
2735
|
-
workspace = await loadWorkspaceConfig(cwd).then((loaded) => loaded.workspace).catch(() => void 0);
|
|
2736
|
-
secretsRoot = workspace?.root ?? findSecretsRoot(cwd);
|
|
2737
|
-
appName = getAppNameFromCwd(cwd) ?? void 0;
|
|
2738
|
-
}
|
|
2739
|
-
const resolvedPort = options.explicitPort ?? workspaceAppPort ?? 3e3;
|
|
2740
|
-
const stage = options.stage ?? workspace?.stages.local;
|
|
2741
|
-
const credentials = stage ? await loadSecretsForApp(secretsRoot, stage, appName) : {};
|
|
2742
|
-
credentials.PORT = String(resolvedPort);
|
|
2743
|
-
const declaredKeys = [];
|
|
2744
|
-
if (workspace && usesConstructs(workspace)) {
|
|
2745
|
-
const { reconcileWorkspace } = await Promise.resolve().then(() => workspace_exports);
|
|
2746
|
-
const reconciled = await reconcileWorkspace(workspace, {
|
|
2747
|
-
stage: options.reconcileStage ?? workspace.stages.local,
|
|
2748
|
-
start: options.startDocker ?? false
|
|
2749
|
-
});
|
|
2750
|
-
Object.assign(credentials, reconciled.env);
|
|
2751
|
-
declaredKeys.push(...Object.keys(reconciled.env));
|
|
2752
|
-
if (Object.keys(reconciled.env).length > 0) logger.log(`🔌 Resolved ${Object.keys(reconciled.env).length} declared URL(s)`);
|
|
2753
|
-
} else {
|
|
2754
|
-
const mappings = parseComposePortMappings(join(secretsRoot, "docker-compose.yml"));
|
|
2755
|
-
if (mappings.length > 0) {
|
|
2756
|
-
let resolvedPorts;
|
|
2757
|
-
if (portMode === "full") resolvedPorts = await resolveServicePorts(secretsRoot);
|
|
2758
|
-
else {
|
|
2759
|
-
const savedPorts = await loadPortState(secretsRoot);
|
|
2760
|
-
const ports = {};
|
|
2761
|
-
for (const mapping of mappings) {
|
|
2762
|
-
const containerPort = getContainerHostPort(secretsRoot, mapping.service, mapping.containerPort);
|
|
2763
|
-
if (containerPort !== null) ports[mapping.envVar] = containerPort;
|
|
2764
|
-
else {
|
|
2765
|
-
const saved = savedPorts[mapping.envVar];
|
|
2766
|
-
if (saved !== void 0) ports[mapping.envVar] = saved;
|
|
2767
|
-
}
|
|
2768
|
-
}
|
|
2769
|
-
resolvedPorts = {
|
|
2770
|
-
dockerEnv: {},
|
|
2771
|
-
ports,
|
|
2772
|
-
mappings
|
|
2773
|
-
};
|
|
2774
|
-
}
|
|
2775
|
-
if (options.startDocker) {
|
|
2776
|
-
if (appInfo) await startWorkspaceServices(appInfo.workspace, resolvedPorts.dockerEnv, credentials);
|
|
2777
|
-
else await startComposeServices(secretsRoot, resolvedPorts.dockerEnv, credentials);
|
|
2778
|
-
}
|
|
2779
|
-
if (Object.keys(resolvedPorts.ports).length > 0) {
|
|
2780
|
-
const rewritten = rewriteUrlsWithPorts(credentials, resolvedPorts);
|
|
2781
|
-
Object.assign(credentials, rewritten);
|
|
2782
|
-
logger.log(`🔌 Applied ${Object.keys(resolvedPorts.ports).length} port mapping(s)`);
|
|
2783
|
-
}
|
|
2784
|
-
}
|
|
2785
|
-
}
|
|
2786
|
-
if (appInfo?.appName) {
|
|
2787
|
-
const depEnv = getDependencyEnvVars(appInfo.workspace, appInfo.appName);
|
|
2788
|
-
for (const [key, value] of Object.entries(depEnv)) credentials[key] ??= value;
|
|
2789
|
-
}
|
|
2790
|
-
if (!credentials.EVENT_PUBLISHER_CONNECTION_STRING && !credentials.EVENT_SUBSCRIBER_CONNECTION_STRING && credentials.PGBOSS_DB_USER) {
|
|
2791
|
-
const pgbossUrl = generatePgBossUrl({
|
|
2792
|
-
username: credentials.PGBOSS_DB_USER,
|
|
2793
|
-
password: credentials.PGBOSS_DB_PASSWORD ?? "",
|
|
2794
|
-
host: credentials.POSTGRES_HOST ?? "localhost",
|
|
2795
|
-
port: Number(credentials.POSTGRES_PORT ?? "5432"),
|
|
2796
|
-
database: credentials.POSTGRES_DB ?? "app"
|
|
2797
|
-
});
|
|
2798
|
-
credentials.EVENT_PUBLISHER_CONNECTION_STRING = pgbossUrl;
|
|
2799
|
-
credentials.EVENT_SUBSCRIBER_CONNECTION_STRING = pgbossUrl;
|
|
2800
|
-
}
|
|
2801
|
-
const secretsDir = join(secretsRoot, ".gkm");
|
|
2802
|
-
await mkdir(secretsDir, { recursive: true });
|
|
2803
|
-
const secretsFileName = options.secretsFileName ?? (appName ? `dev-secrets-${appName}.json` : "dev-secrets.json");
|
|
2804
|
-
const secretsJsonPath = join(secretsDir, secretsFileName);
|
|
2805
|
-
await writeFile(secretsJsonPath, JSON.stringify(credentials, null, 2));
|
|
2806
|
-
return {
|
|
2807
|
-
credentials,
|
|
2808
|
-
resolvedPort,
|
|
2809
|
-
secretsJsonPath,
|
|
2810
|
-
appName,
|
|
2811
|
-
secretsRoot,
|
|
2812
|
-
appInfo,
|
|
2813
|
-
declaredKeys
|
|
2814
|
-
};
|
|
2815
|
-
}
|
|
2816
|
-
//#endregion
|
|
2817
|
-
export { providerOf as C, eventsBackendFor as S, COMPOSE_PATH as _, loadEnvFiles as a, resourceName as b, resolveServicePorts as c, backendsOf as d, constructGlobs as f, workspace_exports as g, usesConstructs as h, isPortAvailable as i, rewriteUrlsWithPorts as l, reconcileWorkspace as m, createEntryWrapper as n, loadSecretsForApp as o, derivedContainers as p, findAvailablePort as r, prepareEntryCredentials as s, createCredentialsPreload as t, startWorkspaceServices as u, LOCAL_CA_PATH as v, storageBackendFor as w, cacheBackendFor as x, applyPostgres as y };
|
|
2666
|
+
export { findAvailablePort as _, workspace_exports as a, loadSecretsForApp as b, applyPostgres as c, cacheBackendFor as d, eventsBackendFor as f, createEntryWrapper as g, createCredentialsPreload as h, reconcileWorkspace as i, resourceName as l, storageBackendFor as m, constructGlobs as n, COMPOSE_PATH as o, providerOf as p, derivedContainers as r, LOCAL_CA_PATH as s, backendsOf as t, rootSite as u, isPortAvailable as v, prepareEntryCredentials as x, loadEnvFiles as y };
|
|
2818
2667
|
|
|
2819
|
-
//# sourceMappingURL=
|
|
2668
|
+
//# sourceMappingURL=workspace-cBobQVoA.mjs.map
|