@geekmidas/cli 10.0.0-alpha.60 → 10.0.0-alpha.61
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-BTZd2u0f.cjs → HostingerProvider-CgDaD8VR.cjs} +2 -2
- package/dist/{HostingerProvider-BTZd2u0f.cjs.map → HostingerProvider-CgDaD8VR.cjs.map} +1 -1
- package/dist/{HostingerProvider-DgzKa_Tj.mjs → HostingerProvider-DI_Pr2VE.mjs} +2 -2
- package/dist/{HostingerProvider-DgzKa_Tj.mjs.map → HostingerProvider-DI_Pr2VE.mjs.map} +1 -1
- package/dist/{LegacyStateStore-CToa2hga.mjs → LegacyStateStore-C7vFAxbu.mjs} +2 -2
- package/dist/{LegacyStateStore-CToa2hga.mjs.map → LegacyStateStore-C7vFAxbu.mjs.map} +1 -1
- package/dist/{LegacyStateStore-CidlPT0N.cjs → LegacyStateStore-JbBVFp_2.cjs} +2 -2
- package/dist/{LegacyStateStore-CidlPT0N.cjs.map → LegacyStateStore-JbBVFp_2.cjs.map} +1 -1
- package/dist/{LocalStateStore-Dpuqka1N.cjs → LocalStateStore-DHj_-CQB.cjs} +2 -2
- package/dist/{LocalStateStore-Dpuqka1N.cjs.map → LocalStateStore-DHj_-CQB.cjs.map} +1 -1
- package/dist/{LocalStateStore-BFLiOocU.mjs → LocalStateStore-DfUwQsL8.mjs} +2 -2
- package/dist/{LocalStateStore-BFLiOocU.mjs.map → LocalStateStore-DfUwQsL8.mjs.map} +1 -1
- package/dist/{OpenApiTsGenerator-BTuUumsV.cjs → OpenApiTsGenerator-BSrgw7B0.cjs} +31 -2
- package/dist/OpenApiTsGenerator-BSrgw7B0.cjs.map +1 -0
- package/dist/{OpenApiTsGenerator-CvVr1Ilz.mjs → OpenApiTsGenerator-q5B0z8Xg.mjs} +31 -2
- package/dist/OpenApiTsGenerator-q5B0z8Xg.mjs.map +1 -0
- package/dist/{S3StateStore-aaGkjcxO.mjs → S3StateStore-Dq6hHtzY.mjs} +2 -2
- package/dist/{S3StateStore-aaGkjcxO.mjs.map → S3StateStore-Dq6hHtzY.mjs.map} +1 -1
- package/dist/{S3StateStore-cnm813Xn.cjs → S3StateStore-xqnpUrzy.cjs} +2 -2
- package/dist/{S3StateStore-cnm813Xn.cjs.map → S3StateStore-xqnpUrzy.cjs.map} +1 -1
- package/dist/{SSMStateStore-DwaGprVX.cjs → SSMStateStore-5dBRwVlJ.cjs} +2 -2
- package/dist/{SSMStateStore-DwaGprVX.cjs.map → SSMStateStore-5dBRwVlJ.cjs.map} +1 -1
- package/dist/{SSMStateStore-BllaJKGo.mjs → SSMStateStore-SUmvAbLi.mjs} +2 -2
- package/dist/{SSMStateStore-BllaJKGo.mjs.map → SSMStateStore-SUmvAbLi.mjs.map} +1 -1
- package/dist/{bundler-DJVR4QlW.mjs → bundler-B5VsKW-_.mjs} +2 -2
- package/dist/{bundler-DJVR4QlW.mjs.map → bundler-B5VsKW-_.mjs.map} +1 -1
- package/dist/{bundler-CzXZPSBJ.cjs → bundler-DXATpO1T.cjs} +2 -2
- package/dist/{bundler-CzXZPSBJ.cjs.map → bundler-DXATpO1T.cjs.map} +1 -1
- package/dist/{cli-DzR1hpum.mjs → cli-DEA_J3Sa.mjs} +2 -2
- package/dist/{cli-DzR1hpum.mjs.map → cli-DEA_J3Sa.mjs.map} +1 -1
- package/dist/{cli-D_qTl20Z.cjs → cli-DQdrVkj2.cjs} +2 -2
- package/dist/{cli-D_qTl20Z.cjs.map → cli-DQdrVkj2.cjs.map} +1 -1
- package/dist/deploy/public.cjs +2 -2
- package/dist/deploy/public.mjs +2 -2
- package/dist/{deploy-D6Q09OkA.mjs → deploy-BJcLmVTc.mjs} +6 -153
- package/dist/deploy-BJcLmVTc.mjs.map +1 -0
- package/dist/{deploy-0fBVeWO-.cjs → deploy-BbJdgQ2b.cjs} +17 -170
- package/dist/deploy-BbJdgQ2b.cjs.map +1 -0
- package/dist/{encryption-aDA2AzVx.mjs → encryption-DaA9gRXr.mjs} +2 -2
- package/dist/{encryption-aDA2AzVx.mjs.map → encryption-DaA9gRXr.mjs.map} +1 -1
- package/dist/{encryption-CML4UaPi.cjs → encryption-ojzUo7-z.cjs} +2 -2
- package/dist/{encryption-CML4UaPi.cjs.map → encryption-ojzUo7-z.cjs.map} +1 -1
- package/dist/index.cjs +1720 -139
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +1712 -147
- package/dist/index.mjs.map +1 -1
- package/dist/migrate-5_J5JySj.mjs +265 -0
- package/dist/migrate-5_J5JySj.mjs.map +1 -0
- package/dist/migrate-BScRpCEx.cjs +270 -0
- package/dist/migrate-BScRpCEx.cjs.map +1 -0
- package/dist/openapi.cjs +1 -1
- package/dist/openapi.mjs +1 -1
- package/dist/{ready-CIHpnUc5.mjs → ready-DQIy21W5.mjs} +2 -2
- package/dist/{ready-CIHpnUc5.mjs.map → ready-DQIy21W5.mjs.map} +1 -1
- package/dist/{ready-CCLIAkwR.cjs → ready-XlNx-Gbo.cjs} +2 -2
- package/dist/{ready-CCLIAkwR.cjs.map → ready-XlNx-Gbo.cjs.map} +1 -1
- package/dist/{sniffer-DIv12ncU.cjs → sniffer-CQmJnNJM.cjs} +2569 -2161
- package/dist/sniffer-CQmJnNJM.cjs.map +1 -0
- package/dist/{sniffer-BM8fpCas.mjs → sniffer-bdleks5l.mjs} +2416 -2176
- package/dist/sniffer-bdleks5l.mjs.map +1 -0
- package/dist/vitest.cjs +1 -1
- package/dist/vitest.mjs +1 -1
- package/package.json +13 -13
- package/dist/OpenApiTsGenerator-BTuUumsV.cjs.map +0 -1
- package/dist/OpenApiTsGenerator-CvVr1Ilz.mjs.map +0 -1
- package/dist/deploy-0fBVeWO-.cjs.map +0 -1
- package/dist/deploy-D6Q09OkA.mjs.map +0 -1
- package/dist/migrate-BTqIflwc.cjs +0 -743
- package/dist/migrate-BTqIflwc.cjs.map +0 -1
- package/dist/migrate-B_6KofUo.mjs +0 -736
- package/dist/migrate-B_6KofUo.mjs.map +0 -1
- package/dist/sniffer-BM8fpCas.mjs.map +0 -1
- package/dist/sniffer-DIv12ncU.cjs.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env -S npx tsx
|
|
2
2
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
3
3
|
const require_rolldown_runtime = require("./rolldown-runtime-C0BPl7ul.cjs");
|
|
4
|
-
const require_sniffer = require("./sniffer-
|
|
4
|
+
const require_sniffer = require("./sniffer-CQmJnNJM.cjs");
|
|
5
5
|
const require_discover = require("./discover-Bc-4eA_p.cjs");
|
|
6
6
|
const require_config = require("./config-DXnkCEq0.cjs");
|
|
7
7
|
const require_workspace = require("./workspace-B64Xk1Mb.cjs");
|
|
8
8
|
const require_storage = require("./storage-CSGoC3bK.cjs");
|
|
9
9
|
const require_store = require("./store-KvNQwYaB.cjs");
|
|
10
|
-
const require_OpenApiTsGenerator = require("./OpenApiTsGenerator-
|
|
10
|
+
const require_OpenApiTsGenerator = require("./OpenApiTsGenerator-BSrgw7B0.cjs");
|
|
11
11
|
const require_openapi = require("./openapi.cjs");
|
|
12
|
+
const require_fakes = require("./fakes-zW-jNAbQ.cjs");
|
|
12
13
|
const require_appPorts = require("./appPorts-Cu79hJF6.cjs");
|
|
13
|
-
const require_ready = require("./ready-
|
|
14
|
+
const require_ready = require("./ready-XlNx-Gbo.cjs");
|
|
14
15
|
const require_emit = require("./emit-Dmc1nvdS.cjs");
|
|
15
|
-
const require_LocalStateStore = require("./LocalStateStore-
|
|
16
|
+
const require_LocalStateStore = require("./LocalStateStore-DHj_-CQB.cjs");
|
|
16
17
|
let node_path = require("node:path");
|
|
17
18
|
let commander = require("commander");
|
|
18
19
|
let node_fs = require("node:fs");
|
|
@@ -29,6 +30,8 @@ chokidar = require_rolldown_runtime.__toESM(chokidar, 1);
|
|
|
29
30
|
let node_net = require("node:net");
|
|
30
31
|
let node_crypto = require("node:crypto");
|
|
31
32
|
let node_url = require("node:url");
|
|
33
|
+
let pg = require("pg");
|
|
34
|
+
pg = require_rolldown_runtime.__toESM(pg, 1);
|
|
32
35
|
let yaml = require("yaml");
|
|
33
36
|
let prompts = require("prompts");
|
|
34
37
|
prompts = require_rolldown_runtime.__toESM(prompts, 1);
|
|
@@ -44,7 +47,7 @@ let _geekmidas_constructs_functions = require("@geekmidas/constructs/functions")
|
|
|
44
47
|
let _geekmidas_constructs_queue = require("@geekmidas/constructs/queue");
|
|
45
48
|
let _geekmidas_constructs_topic = require("@geekmidas/constructs/topic");
|
|
46
49
|
//#region package.json
|
|
47
|
-
var version = "10.0.0-alpha.
|
|
50
|
+
var version = "10.0.0-alpha.61";
|
|
48
51
|
//#endregion
|
|
49
52
|
//#region src/trust/index.ts
|
|
50
53
|
/**
|
|
@@ -61,7 +64,7 @@ var version = "10.0.0-alpha.60";
|
|
|
61
64
|
* worse trade than one that tells you what to type. Caddy's own `caddy trust`
|
|
62
65
|
* draws the line in the same place.
|
|
63
66
|
*/
|
|
64
|
-
const logger$
|
|
67
|
+
const logger$8 = console;
|
|
65
68
|
const run = (0, node_util.promisify)(node_child_process.execFile);
|
|
66
69
|
/**
|
|
67
70
|
* How each platform is told about a new authority.
|
|
@@ -113,15 +116,15 @@ async function trustCommand(options = {}) {
|
|
|
113
116
|
const certificate = (0, node_path.join)(workspace.root, require_sniffer.LOCAL_CA_PATH);
|
|
114
117
|
if (!(0, node_fs.existsSync)(certificate)) throw new NoLocalAuthority(certificate);
|
|
115
118
|
const { describe, asks, steps } = installation(process.platform, certificate);
|
|
116
|
-
logger$
|
|
117
|
-
logger$
|
|
119
|
+
logger$8.log(`\n🔐 Trusting the local authority in ${describe}`);
|
|
120
|
+
logger$8.log(` ${certificate}\n`);
|
|
118
121
|
if (options.dryRun) {
|
|
119
|
-
for (const [command, args] of steps) logger$
|
|
122
|
+
for (const [command, args] of steps) logger$8.log(` ${command} ${args.join(" ")}`);
|
|
120
123
|
return;
|
|
121
124
|
}
|
|
122
|
-
logger$
|
|
125
|
+
logger$8.log(` ${asks}\n`);
|
|
123
126
|
for (const [command, args] of steps) await run(command, args, { stdio: "inherit" });
|
|
124
|
-
logger$
|
|
127
|
+
logger$8.log("\n✅ Local https addresses are trusted. Restart your browser.\n");
|
|
125
128
|
}
|
|
126
129
|
/**
|
|
127
130
|
* Trust the authority if this machine lacks it and its owner agrees.
|
|
@@ -138,7 +141,7 @@ async function ensureTrusted(root, url, options = {}) {
|
|
|
138
141
|
if (await isTrusted(url)) return;
|
|
139
142
|
if (!options.assumeYes) {
|
|
140
143
|
if (!process.stdin.isTTY) {
|
|
141
|
-
logger$
|
|
144
|
+
logger$8.log(" Local https addresses are not trusted here. Run \"gkm trust\", or \"gkm setup --yes\".");
|
|
142
145
|
return;
|
|
143
146
|
}
|
|
144
147
|
if (await declined(root)) return;
|
|
@@ -150,7 +153,7 @@ async function ensureTrusted(root, url, options = {}) {
|
|
|
150
153
|
});
|
|
151
154
|
if (!install) {
|
|
152
155
|
await recordDeclined(root);
|
|
153
|
-
logger$
|
|
156
|
+
logger$8.log(" Skipped. Run \"gkm trust\" later.");
|
|
154
157
|
return;
|
|
155
158
|
}
|
|
156
159
|
}
|
|
@@ -1315,7 +1318,7 @@ function closeFakes(served) {
|
|
|
1315
1318
|
}
|
|
1316
1319
|
//#endregion
|
|
1317
1320
|
//#region src/exec/index.ts
|
|
1318
|
-
const logger$
|
|
1321
|
+
const logger$7 = console;
|
|
1319
1322
|
/** `gkm exec` was given nothing to run. */
|
|
1320
1323
|
var NoCommandSpecified = class extends Error {
|
|
1321
1324
|
constructor() {
|
|
@@ -1338,17 +1341,17 @@ async function execCommand(commandArgs, options = {}) {
|
|
|
1338
1341
|
const cwd = options.cwd ?? process.cwd();
|
|
1339
1342
|
if (!commandArgs[0]) throw new NoCommandSpecified();
|
|
1340
1343
|
const defaultEnv = require_sniffer.loadEnvFiles(".env");
|
|
1341
|
-
if (defaultEnv.loaded.length > 0) logger$
|
|
1344
|
+
if (defaultEnv.loaded.length > 0) logger$7.log(`📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
|
|
1342
1345
|
const { credentials, secretsJsonPath, appName } = await require_sniffer.prepareEntryCredentials({ cwd });
|
|
1343
|
-
if (appName) logger$
|
|
1346
|
+
if (appName) logger$7.log(`📦 App: ${appName}`);
|
|
1344
1347
|
const secretCount = Object.keys(credentials).filter((k) => k !== "PORT").length;
|
|
1345
|
-
if (secretCount > 0) logger$
|
|
1348
|
+
if (secretCount > 0) logger$7.log(`🔐 Loaded ${secretCount} secret(s)`);
|
|
1346
1349
|
const preloadDir = (0, node_path.join)(cwd, ".gkm");
|
|
1347
1350
|
await (0, node_fs_promises.mkdir)(preloadDir, { recursive: true });
|
|
1348
1351
|
const preloadPath = (0, node_path.join)(preloadDir, "credentials-preload.mjs");
|
|
1349
1352
|
await require_sniffer.createCredentialsPreload(preloadPath, secretsJsonPath);
|
|
1350
1353
|
const [cmd, ...args] = commandArgs;
|
|
1351
|
-
logger$
|
|
1354
|
+
logger$7.log(`🚀 Running: ${[cmd, ...args].join(" ")}`);
|
|
1352
1355
|
const nodeOptions = [(process.env.NODE_OPTIONS ?? "").replace(/--import[= ]\S+/g, "").trim(), `--import=${preloadPath}`].filter(Boolean).join(" ");
|
|
1353
1356
|
const child = (0, node_child_process.spawn)(cmd, args, {
|
|
1354
1357
|
cwd,
|
|
@@ -1362,7 +1365,7 @@ async function execCommand(commandArgs, options = {}) {
|
|
|
1362
1365
|
const exitCode = await new Promise((resolve) => {
|
|
1363
1366
|
child.on("close", (code) => resolve(code ?? 0));
|
|
1364
1367
|
child.on("error", (error) => {
|
|
1365
|
-
logger$
|
|
1368
|
+
logger$7.error(`Failed to run command: ${error.message}`);
|
|
1366
1369
|
resolve(1);
|
|
1367
1370
|
});
|
|
1368
1371
|
});
|
|
@@ -1370,7 +1373,7 @@ async function execCommand(commandArgs, options = {}) {
|
|
|
1370
1373
|
}
|
|
1371
1374
|
//#endregion
|
|
1372
1375
|
//#region src/dev/index.ts
|
|
1373
|
-
const logger$
|
|
1376
|
+
const logger$6 = console;
|
|
1374
1377
|
/**
|
|
1375
1378
|
* Normalize telescope configuration
|
|
1376
1379
|
* @internal Exported for testing
|
|
@@ -1440,13 +1443,13 @@ async function devCommand(options) {
|
|
|
1440
1443
|
if (options.subscribers === false) process.env[SUBSCRIBERS_ENV] = "off";
|
|
1441
1444
|
if (options.entry) return entryDevCommand(options);
|
|
1442
1445
|
const defaultEnv = require_sniffer.loadEnvFiles(".env");
|
|
1443
|
-
if (defaultEnv.loaded.length > 0) logger$
|
|
1446
|
+
if (defaultEnv.loaded.length > 0) logger$6.log(`📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
|
|
1444
1447
|
const appConfig = await require_config.loadAppConfig().catch((error) => {
|
|
1445
1448
|
if (error instanceof require_config.NotInAnApp || error instanceof require_config.NotABackendApp) return;
|
|
1446
1449
|
throw error;
|
|
1447
1450
|
});
|
|
1448
1451
|
if (!appConfig) {
|
|
1449
|
-
logger$
|
|
1452
|
+
logger$6.log("🔎 Reading gkm.config.ts and the constructs");
|
|
1450
1453
|
const { workspace: everything } = await require_config.loadWorkspaceConfig();
|
|
1451
1454
|
return workspaceDevCommand(everything, options);
|
|
1452
1455
|
}
|
|
@@ -1459,12 +1462,12 @@ async function devCommand(options) {
|
|
|
1459
1462
|
holder: require_appPorts.holderOf
|
|
1460
1463
|
});
|
|
1461
1464
|
if (assigned.running.length > 0) throw new WorkspacePortsInUse(assigned.running);
|
|
1462
|
-
for (const { app, from, to, holder } of assigned.moved) logger$
|
|
1465
|
+
for (const { app, from, to, holder } of assigned.moved) logger$6.log(`↪️ ${app}: ${from} is held by ${require_appPorts.describeHolder(holder)}, which is not this workspace's — using ${to}`);
|
|
1463
1466
|
const workspace = require_appPorts.withAppPorts(appConfig.workspace, assigned.ports);
|
|
1464
1467
|
const workspaceAppPort = assigned.ports[workspaceAppName] ?? appConfig.app.port;
|
|
1465
1468
|
process.env[require_appPorts.APP_TAG_ENV] = require_appPorts.appTag(appConfig.workspaceRoot, workspaceAppName);
|
|
1466
1469
|
if (appConfig.app.entry) {
|
|
1467
|
-
logger$
|
|
1470
|
+
logger$6.log(`📄 Using entry point: ${appConfig.app.entry}`);
|
|
1468
1471
|
return entryDevCommand({
|
|
1469
1472
|
...options,
|
|
1470
1473
|
entry: appConfig.app.entry,
|
|
@@ -1474,8 +1477,8 @@ async function devCommand(options) {
|
|
|
1474
1477
|
}
|
|
1475
1478
|
if (config.env) {
|
|
1476
1479
|
const { loaded, missing } = require_sniffer.loadEnvFiles(config.env, appRoot);
|
|
1477
|
-
if (loaded.length > 0) logger$
|
|
1478
|
-
if (missing.length > 0) logger$
|
|
1480
|
+
if (loaded.length > 0) logger$6.log(`📦 Loaded env: ${loaded.join(", ")}`);
|
|
1481
|
+
if (missing.length > 0) logger$6.warn(`⚠️ Missing env files: ${missing.join(", ")}`);
|
|
1479
1482
|
}
|
|
1480
1483
|
const telescope = normalizeTelescopeConfig(config.telescope);
|
|
1481
1484
|
const hooks = normalizeHooksConfig(config.hooks, appRoot);
|
|
@@ -1494,7 +1497,7 @@ async function devCommand(options) {
|
|
|
1494
1497
|
bustCache,
|
|
1495
1498
|
serveEmpty: true
|
|
1496
1499
|
}));
|
|
1497
|
-
logger$
|
|
1500
|
+
logger$6.log(`🔨 Building ${workspaceAppName ?? "the app"}`);
|
|
1498
1501
|
const initial = await build();
|
|
1499
1502
|
const runtime = config.runtime ?? "node";
|
|
1500
1503
|
let secretsJsonPath;
|
|
@@ -1505,11 +1508,11 @@ async function devCommand(options) {
|
|
|
1505
1508
|
const reconciled = await require_sniffer.reconcileWorkspace(workspace, {
|
|
1506
1509
|
stage: workspace.stages.local,
|
|
1507
1510
|
...options.fake ? { fake: true } : {},
|
|
1508
|
-
progress: (message) => logger$
|
|
1511
|
+
progress: (message) => logger$6.log(message)
|
|
1509
1512
|
});
|
|
1510
1513
|
if (reconciled.services.length > 0) {
|
|
1511
|
-
logger$
|
|
1512
|
-
for (const line of require_sniffer.describeServices(reconciled.services)) logger$
|
|
1514
|
+
logger$6.log("🐳 Services");
|
|
1515
|
+
for (const line of require_sniffer.describeServices(reconciled.services)) logger$6.log(line);
|
|
1513
1516
|
}
|
|
1514
1517
|
Object.assign(appSecrets, reconciled.env);
|
|
1515
1518
|
const own = reconciled.plan.resources.find((r) => (r.kind === "rest-api" || r.kind === "site") && require_config.appKey(r.id) === workspaceAppName);
|
|
@@ -1576,11 +1579,11 @@ async function devCommand(options) {
|
|
|
1576
1579
|
cwd: appRoot
|
|
1577
1580
|
});
|
|
1578
1581
|
watcher.on("error", (error) => {
|
|
1579
|
-
logger$
|
|
1582
|
+
logger$6.error("❌ Watcher error:", error);
|
|
1580
1583
|
});
|
|
1581
1584
|
let rebuildTimeout = null;
|
|
1582
1585
|
watcher.on("change", async (path) => {
|
|
1583
|
-
logger$
|
|
1586
|
+
logger$6.log(`🔄 ${path} changed — rebuilding`);
|
|
1584
1587
|
if (rebuildTimeout) clearTimeout(rebuildTimeout);
|
|
1585
1588
|
rebuildTimeout = setTimeout(async () => {
|
|
1586
1589
|
try {
|
|
@@ -1589,8 +1592,8 @@ async function devCommand(options) {
|
|
|
1589
1592
|
await devServer.restart();
|
|
1590
1593
|
} catch (error) {
|
|
1591
1594
|
const err = error;
|
|
1592
|
-
logger$
|
|
1593
|
-
if (err.stack) logger$
|
|
1595
|
+
logger$6.error(`❌ Rebuild failed: ${err.message}`);
|
|
1596
|
+
if (err.stack) logger$6.error(err.stack);
|
|
1594
1597
|
}
|
|
1595
1598
|
}, 300);
|
|
1596
1599
|
});
|
|
@@ -1598,13 +1601,13 @@ async function devCommand(options) {
|
|
|
1598
1601
|
const shutdown = () => {
|
|
1599
1602
|
if (isShuttingDown) return;
|
|
1600
1603
|
isShuttingDown = true;
|
|
1601
|
-
logger$
|
|
1604
|
+
logger$6.log("\n🛑 Shutting down...");
|
|
1602
1605
|
Promise.all([
|
|
1603
1606
|
watcher.close(),
|
|
1604
1607
|
devServer.stop(),
|
|
1605
1608
|
discovery.leave()
|
|
1606
1609
|
]).catch((err) => {
|
|
1607
|
-
logger$
|
|
1610
|
+
logger$6.error("Error during shutdown:", err);
|
|
1608
1611
|
}).finally(() => {
|
|
1609
1612
|
process.exit(0);
|
|
1610
1613
|
});
|
|
@@ -1808,13 +1811,13 @@ async function workspaceDevCommand(configured, options) {
|
|
|
1808
1811
|
holder: require_appPorts.holderOf
|
|
1809
1812
|
});
|
|
1810
1813
|
if (assigned.running.length > 0) throw new WorkspacePortsInUse(assigned.running);
|
|
1811
|
-
for (const { app, from, to, holder } of assigned.moved) logger$
|
|
1814
|
+
for (const { app, from, to, holder } of assigned.moved) logger$6.log(`↪️ ${app}: ${from} is held by ${require_appPorts.describeHolder(holder)}, which is not this workspace's — using ${to}`);
|
|
1812
1815
|
const workspace = require_appPorts.withAppPorts(configured, assigned.ports);
|
|
1813
1816
|
const appCount = Object.keys(workspace.apps).length;
|
|
1814
1817
|
const frontendApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "web");
|
|
1815
1818
|
const conflicts = checkPortConflicts(workspace);
|
|
1816
1819
|
if (conflicts.length > 0) {
|
|
1817
|
-
for (const conflict of conflicts) logger$
|
|
1820
|
+
for (const conflict of conflicts) logger$6.error(`❌ Port conflict: Apps "${conflict.app1}" and "${conflict.app2}" both use port ${conflict.port}`);
|
|
1818
1821
|
throw new Error("Port conflicts detected. Please assign unique ports to each app.");
|
|
1819
1822
|
}
|
|
1820
1823
|
if (frontendApps.length > 0) {
|
|
@@ -1823,10 +1826,10 @@ async function workspaceDevCommand(configured, options) {
|
|
|
1823
1826
|
for (const result of validationResults) {
|
|
1824
1827
|
if (!result.valid) {
|
|
1825
1828
|
hasErrors = true;
|
|
1826
|
-
logger$
|
|
1827
|
-
for (const error of result.errors) logger$
|
|
1829
|
+
logger$6.error(`\n❌ Frontend app "${result.appName}" validation failed:`);
|
|
1830
|
+
for (const error of result.errors) logger$6.error(` • ${error}`);
|
|
1828
1831
|
}
|
|
1829
|
-
for (const warning of result.warnings) logger$
|
|
1832
|
+
for (const warning of result.warnings) logger$6.warn(` ⚠️ ${result.appName}: ${warning}`);
|
|
1830
1833
|
}
|
|
1831
1834
|
if (hasErrors) throw new Error("Frontend app validation failed. Fix the issues above and try again.");
|
|
1832
1835
|
}
|
|
@@ -1834,24 +1837,24 @@ async function workspaceDevCommand(configured, options) {
|
|
|
1834
1837
|
const reconciled = await require_sniffer.reconcileWorkspace(workspace, {
|
|
1835
1838
|
stage: workspace.stages.local,
|
|
1836
1839
|
...options.fake ? { fake: true } : {},
|
|
1837
|
-
progress: (message) => logger$
|
|
1840
|
+
progress: (message) => logger$6.log(message)
|
|
1838
1841
|
});
|
|
1839
1842
|
if (reconciled.services.length > 0) {
|
|
1840
|
-
logger$
|
|
1841
|
-
for (const line of require_sniffer.describeServices(reconciled.services)) logger$
|
|
1843
|
+
logger$6.log("🐳 Services");
|
|
1844
|
+
for (const line of require_sniffer.describeServices(reconciled.services)) logger$6.log(line);
|
|
1842
1845
|
}
|
|
1843
1846
|
const secretsEnv = {
|
|
1844
1847
|
...rawSecrets,
|
|
1845
1848
|
...reconciled.env
|
|
1846
1849
|
};
|
|
1847
1850
|
const fakes = options.fake ? await serveFakes(reconciled.fakes, reconciled.ports) : [];
|
|
1848
|
-
for (const { id, port } of fakes) logger$
|
|
1849
|
-
logger$
|
|
1851
|
+
for (const { id, port } of fakes) logger$6.log(`🎭 ${id} fake: http://localhost:${port}`);
|
|
1852
|
+
logger$6.log(options.migrate || options.seed ? "🗄️ Migrating the databases" : "🗄️ Checking for pending migrations");
|
|
1850
1853
|
if (options.migrate || options.seed) {
|
|
1851
|
-
const { prepareDevDatabases } = await Promise.resolve().then(() => require("./migrate-
|
|
1854
|
+
const { prepareDevDatabases } = await Promise.resolve().then(() => require("./migrate-BScRpCEx.cjs"));
|
|
1852
1855
|
await prepareDevDatabases(workspace, reconciled.env, { seed: !!options.seed });
|
|
1853
|
-
} else await Promise.resolve().then(() => require("./migrate-
|
|
1854
|
-
logger$
|
|
1856
|
+
} else await Promise.resolve().then(() => require("./migrate-BScRpCEx.cjs")).then(({ reportPendingMigrations }) => reportPendingMigrations(workspace, reconciled.env)).catch((error) => {
|
|
1857
|
+
logger$6.log(`⚠️ Could not check for pending migrations: ${error instanceof Error ? error.message : String(error)}`);
|
|
1855
1858
|
});
|
|
1856
1859
|
const appUrls = {};
|
|
1857
1860
|
for (const resource of reconciled.plan.resources) {
|
|
@@ -1872,11 +1875,11 @@ async function workspaceDevCommand(configured, options) {
|
|
|
1872
1875
|
else {
|
|
1873
1876
|
const { filters, unpackaged } = turboFilters(workspace);
|
|
1874
1877
|
turboFilter = filters.flatMap((name) => ["--filter", name]);
|
|
1875
|
-
if (unpackaged.length > 0) logger$
|
|
1878
|
+
if (unpackaged.length > 0) logger$6.warn(`⚠️ No package.json with a name for: ${unpackaged.join(", ")} — turbo cannot run ${unpackaged.length === 1 ? "it" : "them"}.`);
|
|
1876
1879
|
}
|
|
1877
1880
|
const buildOrder = require_workspace.getAppBuildOrder(workspace);
|
|
1878
1881
|
const width = Math.max(...buildOrder.map((name) => name.length));
|
|
1879
|
-
logger$
|
|
1882
|
+
logger$6.log(`\n${workspace.name}: ${appCount} app(s)`);
|
|
1880
1883
|
for (const appName of buildOrder) {
|
|
1881
1884
|
const app = workspace.apps[appName];
|
|
1882
1885
|
if (!app) continue;
|
|
@@ -1884,14 +1887,14 @@ async function workspaceDevCommand(configured, options) {
|
|
|
1884
1887
|
const own = reconciled.plan.resources.find((r) => r.kind === "mobile-app" && require_config.appKey(r.id) === appName);
|
|
1885
1888
|
const scheme = own ? reconciled.env[own.envKey] : void 0;
|
|
1886
1889
|
const device = Object.entries(reconciled.env).find(([key]) => key.endsWith("_DEVICE_URL"))?.[1];
|
|
1887
|
-
logger$
|
|
1890
|
+
logger$6.log(` ${appName.padEnd(width)} ${scheme ? `${scheme}://` : "mobile"}${device ? ` — devices reach ${new URL(device).hostname}` : ""}`);
|
|
1888
1891
|
continue;
|
|
1889
1892
|
}
|
|
1890
1893
|
const local = `http://localhost:${app.port}`;
|
|
1891
1894
|
const url = Object.entries(appUrls).find(([id]) => require_config.appKey(id) === appName)?.[1];
|
|
1892
|
-
logger$
|
|
1895
|
+
logger$6.log(` ${appName.padEnd(width)} ${url ? `${url} -> ${local}` : local}`);
|
|
1893
1896
|
}
|
|
1894
|
-
logger$
|
|
1897
|
+
logger$6.log("");
|
|
1895
1898
|
const configFiles = [
|
|
1896
1899
|
"gkm.config.ts",
|
|
1897
1900
|
"gkm.config.js",
|
|
@@ -1939,7 +1942,7 @@ async function workspaceDevCommand(configured, options) {
|
|
|
1939
1942
|
...options.fake ? { [require_sniffer.FAKE_ENV]: "1" } : {},
|
|
1940
1943
|
[DISCOVERY_QUIET_ENV]: "1"
|
|
1941
1944
|
};
|
|
1942
|
-
logger$
|
|
1945
|
+
logger$6.log(`🚀 Starting ${appCount} app(s) — each builds before it answers, and says so when it is ready`);
|
|
1943
1946
|
const turboProcess = (0, node_child_process.spawn)("pnpm", [
|
|
1944
1947
|
"turbo",
|
|
1945
1948
|
"run",
|
|
@@ -1955,7 +1958,7 @@ async function workspaceDevCommand(configured, options) {
|
|
|
1955
1958
|
const shutdown = () => {
|
|
1956
1959
|
if (isShuttingDown) return;
|
|
1957
1960
|
isShuttingDown = true;
|
|
1958
|
-
logger$
|
|
1961
|
+
logger$6.log("\n🛑 Shutting down workspace...");
|
|
1959
1962
|
closeFakes(fakes);
|
|
1960
1963
|
discovery.leave();
|
|
1961
1964
|
const pid = turboProcess.pid;
|
|
@@ -1981,7 +1984,7 @@ async function workspaceDevCommand(configured, options) {
|
|
|
1981
1984
|
process.on("SIGTERM", shutdown);
|
|
1982
1985
|
return new Promise((resolve, reject) => {
|
|
1983
1986
|
turboProcess.on("error", (error) => {
|
|
1984
|
-
logger$
|
|
1987
|
+
logger$6.error("❌ Turbo error:", error);
|
|
1985
1988
|
reject(error);
|
|
1986
1989
|
});
|
|
1987
1990
|
turboProcess.on("exit", (code) => {
|
|
@@ -2000,11 +2003,11 @@ async function entryDevCommand(options) {
|
|
|
2000
2003
|
const entryPath = (0, node_path.resolve)(process.cwd(), entry);
|
|
2001
2004
|
if (!(0, node_fs.existsSync)(entryPath)) throw new Error(`Entry file not found: ${entryPath}`);
|
|
2002
2005
|
const defaultEnv = require_sniffer.loadEnvFiles(".env");
|
|
2003
|
-
if (defaultEnv.loaded.length > 0) logger$
|
|
2006
|
+
if (defaultEnv.loaded.length > 0) logger$6.log(`📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
|
|
2004
2007
|
const { credentials, resolvedPort, secretsJsonPath, appName } = await require_sniffer.prepareEntryCredentials({ explicitPort: options.portExplicit ? options.port : void 0 });
|
|
2005
|
-
if (appName) logger$
|
|
2006
|
-
logger$
|
|
2007
|
-
if (Object.keys(credentials).length > 1) logger$
|
|
2008
|
+
if (appName) logger$6.log(`📦 App: ${appName} (port ${resolvedPort})`);
|
|
2009
|
+
logger$6.log(`🚀 Starting entry file: ${entry} on port ${resolvedPort}`);
|
|
2010
|
+
if (Object.keys(credentials).length > 1) logger$6.log(`🔐 Loaded ${Object.keys(credentials).length - 1} secret(s) + PORT`);
|
|
2008
2011
|
const wrapperDir = (0, node_path.join)(process.cwd(), ".gkm");
|
|
2009
2012
|
await (0, node_fs_promises.mkdir)(wrapperDir, { recursive: true });
|
|
2010
2013
|
const wrapperPath = (0, node_path.join)(wrapperDir, "entry-wrapper.ts");
|
|
@@ -2015,7 +2018,7 @@ async function entryDevCommand(options) {
|
|
|
2015
2018
|
const shutdown = () => {
|
|
2016
2019
|
if (isShuttingDown) return;
|
|
2017
2020
|
isShuttingDown = true;
|
|
2018
|
-
logger$
|
|
2021
|
+
logger$6.log("\n🛑 Shutting down...");
|
|
2019
2022
|
runner.stop();
|
|
2020
2023
|
process.exit(0);
|
|
2021
2024
|
};
|
|
@@ -2051,14 +2054,14 @@ var EntryRunner = class {
|
|
|
2051
2054
|
});
|
|
2052
2055
|
let restartTimeout = null;
|
|
2053
2056
|
this.watcher.on("change", (path) => {
|
|
2054
|
-
logger$
|
|
2057
|
+
logger$6.log(`📝 File changed: ${path}`);
|
|
2055
2058
|
if (restartTimeout) clearTimeout(restartTimeout);
|
|
2056
2059
|
restartTimeout = setTimeout(async () => {
|
|
2057
|
-
logger$
|
|
2060
|
+
logger$6.log("🔄 Restarting...");
|
|
2058
2061
|
await this.restart();
|
|
2059
2062
|
}, 300);
|
|
2060
2063
|
});
|
|
2061
|
-
logger$
|
|
2064
|
+
logger$6.log(`👀 Watching for changes in: ${watchDir}`);
|
|
2062
2065
|
}
|
|
2063
2066
|
}
|
|
2064
2067
|
async runProcess() {
|
|
@@ -2074,17 +2077,17 @@ var EntryRunner = class {
|
|
|
2074
2077
|
});
|
|
2075
2078
|
this.isRunning = true;
|
|
2076
2079
|
this.childProcess.on("error", (error) => {
|
|
2077
|
-
logger$
|
|
2080
|
+
logger$6.error("❌ Process error:", error);
|
|
2078
2081
|
});
|
|
2079
2082
|
this.childProcess.on("exit", (code) => {
|
|
2080
|
-
if (code !== null && code !== 0 && code !== 143) logger$
|
|
2083
|
+
if (code !== null && code !== 0 && code !== 143) logger$6.error(`❌ Process exited with code ${code}`);
|
|
2081
2084
|
this.isRunning = false;
|
|
2082
2085
|
});
|
|
2083
2086
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
2084
2087
|
if (this.isRunning) {
|
|
2085
|
-
logger$
|
|
2086
|
-
logger$
|
|
2087
|
-
logger$
|
|
2088
|
+
logger$6.log("");
|
|
2089
|
+
logger$6.log(` \x1b[32m✓ Ready\x1b[0m at \x1b[36mhttp://localhost:${this.port}\x1b[0m`);
|
|
2090
|
+
logger$6.log("");
|
|
2088
2091
|
}
|
|
2089
2092
|
}
|
|
2090
2093
|
async restart() {
|
|
@@ -2203,7 +2206,7 @@ start({
|
|
|
2203
2206
|
* lets a console read what this machine is running.
|
|
2204
2207
|
*/
|
|
2205
2208
|
function announce(discovery) {
|
|
2206
|
-
logger$
|
|
2209
|
+
logger$6.log(`🧭 Discovery: ${discovery.connectUrl}`);
|
|
2207
2210
|
}
|
|
2208
2211
|
/** Run `fn` with `console.log` muted — warnings and errors still print. */
|
|
2209
2212
|
async function quietly(fn) {
|
|
@@ -2321,7 +2324,7 @@ var DevServer = class {
|
|
|
2321
2324
|
this.actualPort = this.requestedPort;
|
|
2322
2325
|
} else {
|
|
2323
2326
|
this.actualPort = await require_sniffer.findAvailablePort(this.requestedPort);
|
|
2324
|
-
if (this.actualPort !== this.requestedPort) logger$
|
|
2327
|
+
if (this.actualPort !== this.requestedPort) logger$6.log(`ℹ️ Port ${this.requestedPort} was in use, using port ${this.actualPort} instead`);
|
|
2325
2328
|
}
|
|
2326
2329
|
const serverEntryPath = (0, node_path.join)(this.appRoot, ".gkm", "server", "server.ts");
|
|
2327
2330
|
await this.createServerEntry();
|
|
@@ -2341,11 +2344,11 @@ var DevServer = class {
|
|
|
2341
2344
|
});
|
|
2342
2345
|
this.isRunning = true;
|
|
2343
2346
|
this.serverProcess.on("error", (error) => {
|
|
2344
|
-
logger$
|
|
2347
|
+
logger$6.error("❌ Server error:", error);
|
|
2345
2348
|
});
|
|
2346
2349
|
const child = this.serverProcess;
|
|
2347
2350
|
child.on("exit", (code, signal) => {
|
|
2348
|
-
if (code !== null && code !== 0 && signal !== "SIGTERM") logger$
|
|
2351
|
+
if (code !== null && code !== 0 && signal !== "SIGTERM") logger$6.error(`❌ Server exited with code ${code}`);
|
|
2349
2352
|
this.isRunning = false;
|
|
2350
2353
|
if (this.serverProcess === child) this.onStatus?.("stopped");
|
|
2351
2354
|
});
|
|
@@ -2354,13 +2357,13 @@ var DevServer = class {
|
|
|
2354
2357
|
const local = `http://localhost:${this.actualPort}`;
|
|
2355
2358
|
const address = this.publicUrl ? `${this.publicUrl} -> ${local}` : local;
|
|
2356
2359
|
const seconds = ((Date.now() - this.startTime) / 1e3).toFixed(1);
|
|
2357
|
-
logger$
|
|
2360
|
+
logger$6.log(`\x1b[32m✓\x1b[0m ${this.label} ready in ${seconds}s ${address}`);
|
|
2358
2361
|
const tools = this.selfServing ? [] : [
|
|
2359
2362
|
...this.enableOpenApi ? ["docs /__docs"] : [],
|
|
2360
2363
|
...this.telescope ? [`telescope ${this.telescope.path}`] : [],
|
|
2361
2364
|
...this.databaseApi ? [`db ${this.databaseApi}`] : []
|
|
2362
2365
|
];
|
|
2363
|
-
if (tools.length > 0) logger$
|
|
2366
|
+
if (tools.length > 0) logger$6.log(` ${tools.join(" · ")}`);
|
|
2364
2367
|
this.onStatus?.("ready");
|
|
2365
2368
|
}
|
|
2366
2369
|
}
|
|
@@ -3000,9 +3003,63 @@ export const { app } = await surface.server({ envParser, context: snifferContext
|
|
|
3000
3003
|
export default app;
|
|
3001
3004
|
`);
|
|
3002
3005
|
}
|
|
3006
|
+
/**
|
|
3007
|
+
* The production server for a surface that serves itself.
|
|
3008
|
+
*
|
|
3009
|
+
* `app.ts` exports the construct's own Hono app and starts nothing, which is
|
|
3010
|
+
* what `gkm dev` wants: its server mounts the app beside its tools. An image
|
|
3011
|
+
* needs a process, so a production build starts one here — the health check
|
|
3012
|
+
* every image is probed on, the surface behind it, and the same bounded drain
|
|
3013
|
+
* on SIGTERM the generated route servers have. Without it an auth server had
|
|
3014
|
+
* no bundle to put in an image at all.
|
|
3015
|
+
*/
|
|
3016
|
+
async function writeSurfaceServer(outputDir, options) {
|
|
3017
|
+
await (0, node_fs_promises.mkdir)(outputDir, { recursive: true });
|
|
3018
|
+
const shutdown = options.gracefulShutdown ? `
|
|
3019
|
+
// Stop taking requests, let in-flight ones finish, then close what the
|
|
3020
|
+
// construct opened — bounded by GKM_SHUTDOWN_TIMEOUT_MS, under Docker's 10s
|
|
3021
|
+
// stop timeout, so the process exits on its own terms.
|
|
3022
|
+
let shuttingDown = false;
|
|
3023
|
+
const shutdown = async () => {
|
|
3024
|
+
if (shuttingDown) return;
|
|
3025
|
+
shuttingDown = true;
|
|
3026
|
+
const deadline = Number(process.env.GKM_SHUTDOWN_TIMEOUT_MS) || 8000;
|
|
3027
|
+
setTimeout(() => process.exit(1), deadline).unref();
|
|
3028
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
3029
|
+
await runShutdownHooks((error) => console.error(error));
|
|
3030
|
+
process.exit(0);
|
|
3031
|
+
};
|
|
3032
|
+
|
|
3033
|
+
process.on('SIGTERM', shutdown);
|
|
3034
|
+
process.on('SIGINT', shutdown);
|
|
3035
|
+
` : "";
|
|
3036
|
+
const path = (0, node_path.join)(outputDir, "server.ts");
|
|
3037
|
+
await (0, node_fs_promises.writeFile)(path, `#!/usr/bin/env node
|
|
3038
|
+
/**
|
|
3039
|
+
* Production server for a surface that serves itself.
|
|
3040
|
+
* Generated by 'gkm build --production'.
|
|
3041
|
+
*/
|
|
3042
|
+
import { serve } from '@hono/node-server';
|
|
3043
|
+
import { Hono } from 'hono';
|
|
3044
|
+
${options.gracefulShutdown ? "import { runShutdownHooks } from '@geekmidas/constructs';\n" : ""}import { app as surface } from './app.js';
|
|
3045
|
+
|
|
3046
|
+
const port = Number(process.env.PORT) || 3000;
|
|
3047
|
+
|
|
3048
|
+
const app = new Hono();
|
|
3049
|
+
// First, so a probe is answered without reaching the surface's own routes.
|
|
3050
|
+
app.get(${JSON.stringify(options.healthCheck)}, (c) =>
|
|
3051
|
+
c.json({ status: 'ok', timestamp: Date.now() }),
|
|
3052
|
+
);
|
|
3053
|
+
app.route('/', surface);
|
|
3054
|
+
|
|
3055
|
+
const server = serve({ fetch: app.fetch, port });
|
|
3056
|
+
console.log(JSON.stringify({ msg: 'Production server started', port }));
|
|
3057
|
+
${shutdown}`);
|
|
3058
|
+
return path;
|
|
3059
|
+
}
|
|
3003
3060
|
//#endregion
|
|
3004
3061
|
//#region src/build/index.ts
|
|
3005
|
-
const logger$
|
|
3062
|
+
const logger$5 = console;
|
|
3006
3063
|
/**
|
|
3007
3064
|
* Where `gkm build --stage` leaves the key its bundle's secrets were encrypted
|
|
3008
3065
|
* with, beside the bundle in `.gkm/server/` and readable by its owner alone.
|
|
@@ -3050,7 +3107,7 @@ async function buildCommand(options) {
|
|
|
3050
3107
|
const isAtWorkspaceRoot = cwd === workspaceRoot;
|
|
3051
3108
|
const someAppIsElsewhere = Object.values(loadedConfig.workspace.apps).some((app) => (0, node_path.resolve)(workspaceRoot, app.path) !== workspaceRoot);
|
|
3052
3109
|
if (isAtWorkspaceRoot && someAppIsElsewhere) {
|
|
3053
|
-
logger$
|
|
3110
|
+
logger$5.log("📦 Detected workspace configuration");
|
|
3054
3111
|
return workspaceBuildCommand(loadedConfig.workspace, options);
|
|
3055
3112
|
}
|
|
3056
3113
|
}
|
|
@@ -3096,13 +3153,13 @@ async function buildOneApp(input) {
|
|
|
3096
3153
|
const { config, options, workspace, appRoot, target } = input;
|
|
3097
3154
|
const cacheBackend = require_sniffer.cacheBackendFor(require_sniffer.providerOf(workspace));
|
|
3098
3155
|
const production = normalizeProductionConfig(options.production ?? false);
|
|
3099
|
-
if (production) logger$
|
|
3100
|
-
logger$
|
|
3101
|
-
logger$
|
|
3156
|
+
if (production) logger$5.log(`🏭 Building for PRODUCTION`);
|
|
3157
|
+
logger$5.log(`Building for ${target}`);
|
|
3158
|
+
logger$5.log(`Loading constructs from: ${formatRoutes(config.constructs)}`);
|
|
3102
3159
|
const telescope = production ? void 0 : normalizeTelescopeConfig(config.telescope);
|
|
3103
|
-
if (telescope) logger$
|
|
3160
|
+
if (telescope) logger$5.log(`🔭 Telescope enabled at ${telescope.path}`);
|
|
3104
3161
|
const hooks = normalizeHooksConfig(config.hooks, appRoot);
|
|
3105
|
-
if (hooks) logger$
|
|
3162
|
+
if (hooks) logger$5.log(`🪝 Server hooks enabled`);
|
|
3106
3163
|
return buildApp({
|
|
3107
3164
|
config,
|
|
3108
3165
|
workspaceRoot: workspace.root,
|
|
@@ -3144,7 +3201,7 @@ async function writeManifest(input) {
|
|
|
3144
3201
|
});
|
|
3145
3202
|
const path = (0, node_path.join)(manifestDir, `${target}.ts`);
|
|
3146
3203
|
await (0, node_fs_promises.writeFile)(path, require_emit.manifestModule(folded, backends));
|
|
3147
|
-
logger$
|
|
3204
|
+
logger$5.log(`Manifest: ${(0, node_path.relative)(process.cwd(), path)}`);
|
|
3148
3205
|
}
|
|
3149
3206
|
/**
|
|
3150
3207
|
* Turn one app's constructs into its generated entry.
|
|
@@ -3172,7 +3229,21 @@ async function buildApp(input) {
|
|
|
3172
3229
|
});
|
|
3173
3230
|
if (selfServing) {
|
|
3174
3231
|
await writeSurfaceEntry((0, node_path.join)(appRoot, ".gkm", "server"), selfServing.source);
|
|
3175
|
-
logger$
|
|
3232
|
+
logger$5.log(`Generated a server for ${selfServing.id} from its own declaration`);
|
|
3233
|
+
if (production?.bundle && !input.skipBundle) {
|
|
3234
|
+
const outputDir = (0, node_path.join)(appRoot, ".gkm", "server");
|
|
3235
|
+
const entryPoint = await writeSurfaceServer(outputDir, production);
|
|
3236
|
+
const { bundleServer } = await Promise.resolve().then(() => require("./bundler-DXATpO1T.cjs"));
|
|
3237
|
+
await bundleServer({
|
|
3238
|
+
entryPoint,
|
|
3239
|
+
outputDir: (0, node_path.join)(outputDir, "dist"),
|
|
3240
|
+
minify: production.minify,
|
|
3241
|
+
sourcemap: false,
|
|
3242
|
+
external: production.external,
|
|
3243
|
+
...input.stage ? { stage: input.stage } : {}
|
|
3244
|
+
});
|
|
3245
|
+
logger$5.log(`✅ Bundle complete: .gkm/server/dist/server.mjs`);
|
|
3246
|
+
}
|
|
3176
3247
|
return { selfServing: selfServing.id };
|
|
3177
3248
|
}
|
|
3178
3249
|
const derived = ownersContext({
|
|
@@ -3217,14 +3288,14 @@ async function buildApp(input) {
|
|
|
3217
3288
|
topicGenerator.load(code, appRoot, bustCache)
|
|
3218
3289
|
]);
|
|
3219
3290
|
const allEndpoints = servedBy(loadedEndpoints, derived.surface);
|
|
3220
|
-
logger$
|
|
3221
|
-
logger$
|
|
3222
|
-
logger$
|
|
3223
|
-
logger$
|
|
3224
|
-
logger$
|
|
3225
|
-
logger$
|
|
3291
|
+
logger$5.log(`Found ${allEndpoints.length} endpoints`);
|
|
3292
|
+
logger$5.log(`Found ${allFunctions.length} functions`);
|
|
3293
|
+
logger$5.log(`Found ${allCrons.length} crons`);
|
|
3294
|
+
logger$5.log(`Found ${allSubscribers.length} subscribers`);
|
|
3295
|
+
logger$5.log(`Found ${allQueues.length} queues`);
|
|
3296
|
+
logger$5.log(`Found ${allTopics.length} topics`);
|
|
3226
3297
|
if (!input.serveEmpty && allEndpoints.length === 0 && allFunctions.length === 0 && allCrons.length === 0 && allSubscribers.length === 0 && allQueues.length === 0 && allTopics.length === 0) {
|
|
3227
|
-
logger$
|
|
3298
|
+
logger$5.log("No endpoints, functions, crons, subscribers, queues, or topics found to process");
|
|
3228
3299
|
return {};
|
|
3229
3300
|
}
|
|
3230
3301
|
if (production?.subscribers === "include" && derived.surface) {
|
|
@@ -3237,7 +3308,7 @@ async function buildApp(input) {
|
|
|
3237
3308
|
[allQueues.length, "queue consumer"],
|
|
3238
3309
|
[allSubscribers.length, "subscriber"]
|
|
3239
3310
|
].filter(([n]) => n > 0).map(([n, what]) => `${n} ${what}${n === 1 ? "" : "s"}`);
|
|
3240
|
-
if (left.length > 0) logger$
|
|
3311
|
+
if (left.length > 0) logger$5.log(`Serving ${derived.surface.id} only: leaving out ${left.join(", ")} — they run in a Worker's image`);
|
|
3241
3312
|
}
|
|
3242
3313
|
const result = await buildForTarget(target, buildContext, appRoot, workspaceRoot, endpointGenerator, functionGenerator, cronGenerator, subscriberGenerator, queueGenerator, topicGenerator, allEndpoints, allFunctions, allCrons, allSubscribers, allQueues, allTopics, enableOpenApi, input.skipBundle ?? false, input.stage);
|
|
3243
3314
|
await require_openapi.generateOpenApi(allEndpoints.map(({ construct }) => construct), {
|
|
@@ -3260,7 +3331,7 @@ async function buildForTarget(target, context, appRoot, workspaceRoot, endpointG
|
|
|
3260
3331
|
force: true
|
|
3261
3332
|
});
|
|
3262
3333
|
await (0, node_fs_promises.mkdir)(outputDir, { recursive: true });
|
|
3263
|
-
logger$
|
|
3334
|
+
logger$5.log(`\nGenerating handlers for ${target}`);
|
|
3264
3335
|
const options = {
|
|
3265
3336
|
target,
|
|
3266
3337
|
root: workspaceRoot
|
|
@@ -3276,7 +3347,7 @@ async function buildForTarget(target, context, appRoot, workspaceRoot, endpointG
|
|
|
3276
3347
|
queueGenerator.build(context, queues, outputDir, options),
|
|
3277
3348
|
topicGenerator.build(context, topics, outputDir, options)
|
|
3278
3349
|
]);
|
|
3279
|
-
logger$
|
|
3350
|
+
logger$5.log(`Generated ${routes.length} routes, ${functionInfos.length} functions, ${cronInfos.length} crons, ${subscriberInfos.length} subscribers, ${queueInfos.length} queues, ${topicInfos.length} topics for ${target}`);
|
|
3280
3351
|
const fields = {
|
|
3281
3352
|
...context.surface ? { surface: context.surface.id } : {},
|
|
3282
3353
|
functions: functionInfos,
|
|
@@ -3299,8 +3370,8 @@ async function buildForTarget(target, context, appRoot, workspaceRoot, endpointG
|
|
|
3299
3370
|
};
|
|
3300
3371
|
let masterKey;
|
|
3301
3372
|
if (context.production?.bundle && !skipBundle) {
|
|
3302
|
-
logger$
|
|
3303
|
-
const { bundleServer } = await Promise.resolve().then(() => require("./bundler-
|
|
3373
|
+
logger$5.log(`\n📦 Bundling production server...`);
|
|
3374
|
+
const { bundleServer } = await Promise.resolve().then(() => require("./bundler-DXATpO1T.cjs"));
|
|
3304
3375
|
const allConstructs = [
|
|
3305
3376
|
...endpoints.map((e) => e.construct),
|
|
3306
3377
|
...functions.map((f) => f.construct),
|
|
@@ -3317,13 +3388,13 @@ async function buildForTarget(target, context, appRoot, workspaceRoot, endpointG
|
|
|
3317
3388
|
stage,
|
|
3318
3389
|
constructs: allConstructs
|
|
3319
3390
|
})).masterKey;
|
|
3320
|
-
logger$
|
|
3391
|
+
logger$5.log(`✅ Bundle complete: .gkm/server/dist/server.mjs`);
|
|
3321
3392
|
if (masterKey) {
|
|
3322
3393
|
const keyPath = (0, node_path.join)(outputDir, MASTER_KEY_FILE);
|
|
3323
3394
|
await (0, node_fs_promises.writeFile)(keyPath, `${masterKey}\n`, { mode: 384 });
|
|
3324
3395
|
await (0, node_fs_promises.chmod)(keyPath, 384);
|
|
3325
|
-
logger$
|
|
3326
|
-
logger$
|
|
3396
|
+
logger$5.log(`\n🔐 Secrets encrypted for deployment`);
|
|
3397
|
+
logger$5.log(` Run with GKM_MASTER_KEY set to the key in ${(0, node_path.relative)(appRoot, keyPath)} (fingerprint ${require_sniffer.keyFingerprint(masterKey)})`);
|
|
3327
3398
|
}
|
|
3328
3399
|
}
|
|
3329
3400
|
return {
|
|
@@ -3424,12 +3495,12 @@ async function workspaceBuildCommand(workspace, options) {
|
|
|
3424
3495
|
const apps = Object.entries(workspace.apps);
|
|
3425
3496
|
const backendApps = apps.filter(([, app]) => app.type === "backend");
|
|
3426
3497
|
const frontendApps = apps.filter(([, app]) => app.type === "web");
|
|
3427
|
-
logger$
|
|
3428
|
-
logger$
|
|
3429
|
-
logger$
|
|
3430
|
-
if (options.production) logger$
|
|
3498
|
+
logger$5.log(`\n🏗️ Building workspace: ${workspace.name}`);
|
|
3499
|
+
logger$5.log(` Backend apps: ${backendApps.map(([name]) => name).join(", ") || "none"}`);
|
|
3500
|
+
logger$5.log(` Frontend apps: ${frontendApps.map(([name]) => name).join(", ") || "none"}`);
|
|
3501
|
+
if (options.production) logger$5.log(` 🏭 Production mode enabled`);
|
|
3431
3502
|
const buildOrder = require_workspace.getAppBuildOrder(workspace);
|
|
3432
|
-
logger$
|
|
3503
|
+
logger$5.log(` Build order: ${buildOrder.join(" → ")}`);
|
|
3433
3504
|
try {
|
|
3434
3505
|
const target = options.provider ?? require_sniffer.providerOf(workspace);
|
|
3435
3506
|
const builds = [];
|
|
@@ -3437,7 +3508,7 @@ async function workspaceBuildCommand(workspace, options) {
|
|
|
3437
3508
|
const app = workspace.apps[appName];
|
|
3438
3509
|
const config = require_workspace.getAppGkmConfig(workspace, appName);
|
|
3439
3510
|
if (!app || app.type !== "backend" || !config) continue;
|
|
3440
|
-
logger$
|
|
3511
|
+
logger$5.log(`\n⚙️ ${appName}`);
|
|
3441
3512
|
const output = await buildOneApp({
|
|
3442
3513
|
config,
|
|
3443
3514
|
options,
|
|
@@ -3462,8 +3533,8 @@ async function workspaceBuildCommand(workspace, options) {
|
|
|
3462
3533
|
const pm = detectPackageManager$1();
|
|
3463
3534
|
const [turboBin, turboArgs] = getTurboArgs(pm, filters, { only: true });
|
|
3464
3535
|
if (filters.length > 0) {
|
|
3465
|
-
logger$
|
|
3466
|
-
logger$
|
|
3536
|
+
logger$5.log(`\n📦 Using ${pm} with Turbo for the other apps...\n`);
|
|
3537
|
+
logger$5.log(`Running: ${[turboBin, ...turboArgs].join(" ")}`);
|
|
3467
3538
|
await require_sniffer.run(turboBin, turboArgs, {
|
|
3468
3539
|
cwd: workspace.root,
|
|
3469
3540
|
env: {
|
|
@@ -3481,15 +3552,15 @@ async function workspaceBuildCommand(workspace, options) {
|
|
|
3481
3552
|
outputPath
|
|
3482
3553
|
});
|
|
3483
3554
|
}
|
|
3484
|
-
logger$
|
|
3485
|
-
logger$
|
|
3555
|
+
logger$5.log(`\n✅ Workspace build complete!`);
|
|
3556
|
+
logger$5.log(`\n📋 Build Summary:`);
|
|
3486
3557
|
for (const result of results) {
|
|
3487
3558
|
const icon = result.type === "backend" ? "⚙️" : result.type === "mobile" ? "📱" : "🌐";
|
|
3488
|
-
logger$
|
|
3559
|
+
logger$5.log(` ${icon} ${result.appName}: ${result.outputPath || "built"}`);
|
|
3489
3560
|
}
|
|
3490
3561
|
} catch (error) {
|
|
3491
3562
|
const errorMessage = error instanceof Error ? error.message : "Build failed";
|
|
3492
|
-
logger$
|
|
3563
|
+
logger$5.log(`\n❌ Build failed: ${errorMessage}`);
|
|
3493
3564
|
for (const [appName, app] of apps) results.push({
|
|
3494
3565
|
appName,
|
|
3495
3566
|
type: app.type,
|
|
@@ -3518,6 +3589,1491 @@ function getAppOutputPath(workspace, _appName, app) {
|
|
|
3518
3589
|
function formatRoutes(routes) {
|
|
3519
3590
|
return Array.isArray(routes) ? routes.join(", ") : routes;
|
|
3520
3591
|
}
|
|
3592
|
+
//#endregion
|
|
3593
|
+
//#region src/migrate/provider.ts
|
|
3594
|
+
/**
|
|
3595
|
+
* One construct's migrations and seeds, read from its folders.
|
|
3596
|
+
*
|
|
3597
|
+
* Two kinds of file, told apart by extension rather than by configuration: a
|
|
3598
|
+
* script exports `up` (and may export `down`) — or `seed(db, { stage })`, in
|
|
3599
|
+
* `seeds/` — and a
|
|
3600
|
+
* `.sql` file is run whole. The second is what makes a folder independent of
|
|
3601
|
+
* the client the app uses — Better Auth's schema is SQL whatever the
|
|
3602
|
+
* application queries with.
|
|
3603
|
+
*/
|
|
3604
|
+
const SCRIPTS = /* @__PURE__ */ new Set([
|
|
3605
|
+
".ts",
|
|
3606
|
+
".mts",
|
|
3607
|
+
".js",
|
|
3608
|
+
".mjs"
|
|
3609
|
+
]);
|
|
3610
|
+
/** Two files in one folder that would be one migration. */
|
|
3611
|
+
var DuplicateMigration = class extends Error {
|
|
3612
|
+
folder;
|
|
3613
|
+
migration;
|
|
3614
|
+
constructor(folder, migration) {
|
|
3615
|
+
super(`${folder} has two migrations named '${migration}' — a script and a .sql file with the same name. A migration is recorded by name, so only one of them could ever run; rename or remove the other.`);
|
|
3616
|
+
this.folder = folder;
|
|
3617
|
+
this.migration = migration;
|
|
3618
|
+
this.name = "DuplicateMigration";
|
|
3619
|
+
}
|
|
3620
|
+
};
|
|
3621
|
+
/** A script in a migrations folder that exports no `up`. */
|
|
3622
|
+
var MigrationHasNoUp = class extends Error {
|
|
3623
|
+
file;
|
|
3624
|
+
constructor(file) {
|
|
3625
|
+
super(`${file} is in a migrations folder but exports no \`up\` function. Export \`async function up(db: Kysely<unknown>)\`, or move the file out of the folder if it is not a migration.`);
|
|
3626
|
+
this.file = file;
|
|
3627
|
+
this.name = "MigrationHasNoUp";
|
|
3628
|
+
}
|
|
3629
|
+
};
|
|
3630
|
+
/** A script in a seeds folder that exports no `seed`. */
|
|
3631
|
+
var SeedHasNoSeed = class extends Error {
|
|
3632
|
+
file;
|
|
3633
|
+
constructor(file) {
|
|
3634
|
+
super(`${file} is in a seeds folder but exports no \`seed\` function. Export \`async function seed(db, { stage })\`, or move the file out of the folder if it is not a seed.`);
|
|
3635
|
+
this.file = file;
|
|
3636
|
+
this.name = "SeedHasNoSeed";
|
|
3637
|
+
}
|
|
3638
|
+
};
|
|
3639
|
+
/**
|
|
3640
|
+
* The migration files in a folder, by name, without reading them.
|
|
3641
|
+
*
|
|
3642
|
+
* Also what `gkm dev` compares against a construct's history to report what
|
|
3643
|
+
* is pending, which is why it is separate from loading them.
|
|
3644
|
+
*/
|
|
3645
|
+
async function migrationFiles(folder) {
|
|
3646
|
+
const entries = await (0, node_fs_promises.readdir)(folder, { withFileTypes: true }).catch((error) => {
|
|
3647
|
+
if (error.code === "ENOENT") return [];
|
|
3648
|
+
throw error;
|
|
3649
|
+
});
|
|
3650
|
+
const files = /* @__PURE__ */ new Map();
|
|
3651
|
+
for (const entry of entries) {
|
|
3652
|
+
if (!entry.isFile() || !isMigration(entry.name)) continue;
|
|
3653
|
+
const name = entry.name.slice(0, -(0, node_path.extname)(entry.name).length);
|
|
3654
|
+
if (files.has(name)) throw new DuplicateMigration(folder, name);
|
|
3655
|
+
files.set(name, (0, node_path.join)(folder, entry.name));
|
|
3656
|
+
}
|
|
3657
|
+
return files;
|
|
3658
|
+
}
|
|
3659
|
+
/** A Kysely provider over one construct's folder. */
|
|
3660
|
+
function folderProvider(folder, raw) {
|
|
3661
|
+
return { async getMigrations() {
|
|
3662
|
+
const migrations = {};
|
|
3663
|
+
for (const [name, file] of await migrationFiles(folder)) migrations[name] = (0, node_path.extname)(file) === ".sql" ? await sqlMigration(file, raw) : await scriptMigration(file);
|
|
3664
|
+
return migrations;
|
|
3665
|
+
} };
|
|
3666
|
+
}
|
|
3667
|
+
/**
|
|
3668
|
+
* The seeds in a folder, loaded, in name order.
|
|
3669
|
+
*
|
|
3670
|
+
* Every one runs on every pass — there is no history to consult — so the order
|
|
3671
|
+
* is the only thing between two seeds where one reads what another writes.
|
|
3672
|
+
* Name them to sort that way.
|
|
3673
|
+
*/
|
|
3674
|
+
async function loadSeeds(folder, raw) {
|
|
3675
|
+
const files = [...await migrationFiles(folder)].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
3676
|
+
const seeds = [];
|
|
3677
|
+
for (const [name, file] of files) seeds.push({
|
|
3678
|
+
name,
|
|
3679
|
+
run: (0, node_path.extname)(file) === ".sql" ? (await sqlMigration(file, raw)).up : await scriptSeed(file)
|
|
3680
|
+
});
|
|
3681
|
+
return seeds;
|
|
3682
|
+
}
|
|
3683
|
+
/**
|
|
3684
|
+
* A seed script, imported as it is on disk now.
|
|
3685
|
+
*
|
|
3686
|
+
* Keyed by its modification time: a seed is run again after it is edited, and
|
|
3687
|
+
* a process that has imported it once would otherwise run the old one.
|
|
3688
|
+
*/
|
|
3689
|
+
async function scriptSeed(file) {
|
|
3690
|
+
const url = (0, node_url.pathToFileURL)(file);
|
|
3691
|
+
url.searchParams.set("mtime", String((await (0, node_fs_promises.stat)(file)).mtimeMs));
|
|
3692
|
+
const module = await import(url.href);
|
|
3693
|
+
if (typeof module.seed !== "function") throw new SeedHasNoSeed(file);
|
|
3694
|
+
return module.seed;
|
|
3695
|
+
}
|
|
3696
|
+
function isMigration(file) {
|
|
3697
|
+
if (file.endsWith(".d.ts")) return false;
|
|
3698
|
+
if (/\.(spec|test)\.[cm]?[jt]s$/.test(file)) return false;
|
|
3699
|
+
const extension = (0, node_path.extname)(file);
|
|
3700
|
+
return extension === ".sql" || SCRIPTS.has(extension);
|
|
3701
|
+
}
|
|
3702
|
+
async function scriptMigration(file) {
|
|
3703
|
+
const module = await import((0, node_url.pathToFileURL)(file).href);
|
|
3704
|
+
if (typeof module.up !== "function") throw new MigrationHasNoUp(file);
|
|
3705
|
+
return {
|
|
3706
|
+
up: module.up,
|
|
3707
|
+
...typeof module.down === "function" ? { down: module.down } : {}
|
|
3708
|
+
};
|
|
3709
|
+
}
|
|
3710
|
+
/**
|
|
3711
|
+
* A `.sql` file, run whole.
|
|
3712
|
+
*
|
|
3713
|
+
* One query rather than split on `;`: with no parameters, Postgres takes
|
|
3714
|
+
* several statements in one message, and splitting by hand breaks on the first
|
|
3715
|
+
* `;` inside a string or a function body. There is no `down` — a generated
|
|
3716
|
+
* schema is rolled back by a migration that says how, not by guessing.
|
|
3717
|
+
*/
|
|
3718
|
+
async function sqlMigration(file, raw) {
|
|
3719
|
+
const statements = await (0, node_fs_promises.readFile)(file, "utf-8");
|
|
3720
|
+
return { up: async (db) => {
|
|
3721
|
+
await raw.raw(statements).execute(db);
|
|
3722
|
+
} };
|
|
3723
|
+
}
|
|
3724
|
+
//#endregion
|
|
3725
|
+
//#region src/migrate/databases.ts
|
|
3726
|
+
/**
|
|
3727
|
+
* Bring each construct's schema up to its migrations folder.
|
|
3728
|
+
*
|
|
3729
|
+
* Which constructs, in what order, from which folder, is `migrationTargets`'
|
|
3730
|
+
* answer and nobody else's. What is decided here is only how one target is
|
|
3731
|
+
* run: as its owner, in its own schema, with a history of its own.
|
|
3732
|
+
*/
|
|
3733
|
+
/** A folder under `db/` that names no construct. */
|
|
3734
|
+
var UnknownMigrationFolder = class extends Error {
|
|
3735
|
+
folder;
|
|
3736
|
+
known;
|
|
3737
|
+
constructor(folder, known) {
|
|
3738
|
+
super(`${folder} names no database construct, so its migrations would never run. ${known.length > 0 ? `The folders that do: ${known.join(", ")}.` : "This project declares no database."} Rename the folder after the construct it belongs to.`);
|
|
3739
|
+
this.folder = folder;
|
|
3740
|
+
this.known = known;
|
|
3741
|
+
this.name = "UnknownMigrationFolder";
|
|
3742
|
+
}
|
|
3743
|
+
};
|
|
3744
|
+
/** A construct was asked for by a name that takes no migrations. */
|
|
3745
|
+
var NoSuchMigrationTarget = class extends Error {
|
|
3746
|
+
construct;
|
|
3747
|
+
known;
|
|
3748
|
+
constructor(construct, known) {
|
|
3749
|
+
super(`'${construct}' is not a database construct. ` + (known.length > 0 ? `The ones with migrations: ${known.join(", ")}.` : "This project declares no database."));
|
|
3750
|
+
this.construct = construct;
|
|
3751
|
+
this.known = known;
|
|
3752
|
+
this.name = "NoSuchMigrationTarget";
|
|
3753
|
+
}
|
|
3754
|
+
};
|
|
3755
|
+
/**
|
|
3756
|
+
* A migration left directly in a construct's folder, where nothing reads it.
|
|
3757
|
+
*
|
|
3758
|
+
* Migrations moved into `migrations/` so seeds could sit beside them. A file
|
|
3759
|
+
* still at the old level would otherwise never run, and nothing would say so.
|
|
3760
|
+
*/
|
|
3761
|
+
var MigrationsOutsideFolder = class extends Error {
|
|
3762
|
+
folder;
|
|
3763
|
+
files;
|
|
3764
|
+
constructor(folder, files) {
|
|
3765
|
+
super(`${folder} has ${files.join(", ")} directly inside it, where nothing runs them. Migrations live in ${folder}/migrations/ and seeds in ${folder}/seeds/ — move ${files.length === 1 ? "it" : "them"} there.`);
|
|
3766
|
+
this.folder = folder;
|
|
3767
|
+
this.files = files;
|
|
3768
|
+
this.name = "MigrationsOutsideFolder";
|
|
3769
|
+
}
|
|
3770
|
+
};
|
|
3771
|
+
/** A folder inside a construct's folder that is neither `migrations` nor `seeds`. */
|
|
3772
|
+
var UnknownDatabaseFolder = class extends Error {
|
|
3773
|
+
folder;
|
|
3774
|
+
constructor(folder) {
|
|
3775
|
+
super(`${folder} is neither migrations/ nor seeds/, so nothing in it would ever run. A construct's folder holds those two; rename or remove it.`);
|
|
3776
|
+
this.folder = folder;
|
|
3777
|
+
this.name = "UnknownDatabaseFolder";
|
|
3778
|
+
}
|
|
3779
|
+
};
|
|
3780
|
+
/** A seed failed; its own writes are rolled back, the ones before it stay. */
|
|
3781
|
+
var SeedFailed = class extends Error {
|
|
3782
|
+
construct;
|
|
3783
|
+
seed;
|
|
3784
|
+
cause;
|
|
3785
|
+
constructor(construct, seed, cause) {
|
|
3786
|
+
super(`${construct}: seed '${seed}' failed — ${cause instanceof Error ? cause.message : String(cause)}. Its writes were rolled back; fix it and run it again.`);
|
|
3787
|
+
this.construct = construct;
|
|
3788
|
+
this.seed = seed;
|
|
3789
|
+
this.cause = cause;
|
|
3790
|
+
this.name = "SeedFailed";
|
|
3791
|
+
}
|
|
3792
|
+
};
|
|
3793
|
+
/** A construct with roles has no owner URL to migrate with. */
|
|
3794
|
+
var NoOwnerCredential = class extends Error {
|
|
3795
|
+
construct;
|
|
3796
|
+
key;
|
|
3797
|
+
constructor(construct, key) {
|
|
3798
|
+
super(`${construct} has no ${key} to migrate with. Migrations run as the owner role — the runtime one cannot create a table — and nothing resolved its URL. Is the stage reconciled?`);
|
|
3799
|
+
this.construct = construct;
|
|
3800
|
+
this.key = key;
|
|
3801
|
+
this.name = "NoOwnerCredential";
|
|
3802
|
+
}
|
|
3803
|
+
};
|
|
3804
|
+
/** A migration failed; the ones before it in the run stay applied. */
|
|
3805
|
+
var MigrationFailed = class extends Error {
|
|
3806
|
+
construct;
|
|
3807
|
+
migration;
|
|
3808
|
+
cause;
|
|
3809
|
+
constructor(construct, migration, cause) {
|
|
3810
|
+
super(`${construct}: migration ${migration ? `'${migration}' ` : ""}failed — ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
3811
|
+
this.construct = construct;
|
|
3812
|
+
this.migration = migration;
|
|
3813
|
+
this.cause = cause;
|
|
3814
|
+
this.name = "MigrationFailed";
|
|
3815
|
+
}
|
|
3816
|
+
};
|
|
3817
|
+
/** The project's Kysely could not be found from where a database is declared. */
|
|
3818
|
+
var KyselyNotFound = class extends Error {
|
|
3819
|
+
from;
|
|
3820
|
+
constructor(from) {
|
|
3821
|
+
super(`Could not resolve 'kysely' from ${from}. Migrations run with the project's own Kysely — install it where the database is declared.`);
|
|
3822
|
+
this.from = from;
|
|
3823
|
+
this.name = "KyselyNotFound";
|
|
3824
|
+
}
|
|
3825
|
+
};
|
|
3826
|
+
/** Apply every pending migration, construct by construct, parents first. */
|
|
3827
|
+
async function migrateDatabases(options) {
|
|
3828
|
+
const runs = [];
|
|
3829
|
+
for (const target of await targetsFor(options)) {
|
|
3830
|
+
if ((await migrationFiles((0, node_path.join)(options.root, target.migrations))).size === 0) {
|
|
3831
|
+
runs.push({
|
|
3832
|
+
target,
|
|
3833
|
+
applied: []
|
|
3834
|
+
});
|
|
3835
|
+
continue;
|
|
3836
|
+
}
|
|
3837
|
+
const applied = await withMigrator(options, target, async (migrator) => {
|
|
3838
|
+
const { error, results = [] } = await migrator.migrateToLatest();
|
|
3839
|
+
if (error) {
|
|
3840
|
+
const failed = results.find((result) => result.status === "Error");
|
|
3841
|
+
throw new MigrationFailed(target.id, failed?.migrationName, error);
|
|
3842
|
+
}
|
|
3843
|
+
return results.filter((result) => result.status === "Success").map((result) => result.migrationName);
|
|
3844
|
+
});
|
|
3845
|
+
runs.push({
|
|
3846
|
+
target,
|
|
3847
|
+
applied
|
|
3848
|
+
});
|
|
3849
|
+
}
|
|
3850
|
+
return runs;
|
|
3851
|
+
}
|
|
3852
|
+
/**
|
|
3853
|
+
* Run every construct's seeds, parents first — each one, every time, on every
|
|
3854
|
+
* stage, production included. Each is handed the stage, so a seed decides
|
|
3855
|
+
* what belongs where.
|
|
3856
|
+
*
|
|
3857
|
+
* Seeds are reference data kept up to date by being run again, so there is no
|
|
3858
|
+
* history: a seed is an upsert, and a changed one is applied by the next run.
|
|
3859
|
+
* Each runs in its own transaction, as the construct's owner, so a failure
|
|
3860
|
+
* leaves nothing half-written. Assumes the schema is migrated —
|
|
3861
|
+
* {@link migrateAndSeed} is what makes that true.
|
|
3862
|
+
*/
|
|
3863
|
+
async function seedDatabases(options) {
|
|
3864
|
+
const runs = [];
|
|
3865
|
+
for (const target of await targetsFor(options)) {
|
|
3866
|
+
const folder = (0, node_path.join)(options.root, target.seeds);
|
|
3867
|
+
if ((await migrationFiles(folder)).size === 0) {
|
|
3868
|
+
runs.push({
|
|
3869
|
+
target,
|
|
3870
|
+
seeded: []
|
|
3871
|
+
});
|
|
3872
|
+
continue;
|
|
3873
|
+
}
|
|
3874
|
+
const seeded = await withOwner(options, target, async (db, kysely) => {
|
|
3875
|
+
const names = [];
|
|
3876
|
+
for (const seed of await loadSeeds(folder, kysely.sql)) {
|
|
3877
|
+
try {
|
|
3878
|
+
await db.transaction().execute((trx) => seed.run(trx, { stage: options.stage }));
|
|
3879
|
+
} catch (error) {
|
|
3880
|
+
throw new SeedFailed(target.id, seed.name, error);
|
|
3881
|
+
}
|
|
3882
|
+
names.push(seed.name);
|
|
3883
|
+
}
|
|
3884
|
+
return names;
|
|
3885
|
+
});
|
|
3886
|
+
runs.push({
|
|
3887
|
+
target,
|
|
3888
|
+
seeded
|
|
3889
|
+
});
|
|
3890
|
+
}
|
|
3891
|
+
return runs;
|
|
3892
|
+
}
|
|
3893
|
+
/**
|
|
3894
|
+
* Migrate, then seed: a seed only ever runs against the schema it was written
|
|
3895
|
+
* for.
|
|
3896
|
+
*
|
|
3897
|
+
* With `only`, the constructs it lives in are migrated too — a tenant's seed
|
|
3898
|
+
* cannot run before the database that owns its schema exists — and only it is
|
|
3899
|
+
* seeded.
|
|
3900
|
+
*/
|
|
3901
|
+
async function migrateAndSeed(options) {
|
|
3902
|
+
const migrations = [];
|
|
3903
|
+
for (const target of await withParents(options)) migrations.push(...await migrateDatabases({
|
|
3904
|
+
...options,
|
|
3905
|
+
only: target.id
|
|
3906
|
+
}));
|
|
3907
|
+
return {
|
|
3908
|
+
migrations,
|
|
3909
|
+
seeds: await seedDatabases(options)
|
|
3910
|
+
};
|
|
3911
|
+
}
|
|
3912
|
+
/** The targets `only` names, after the ones it lives in; all without it. */
|
|
3913
|
+
async function withParents(options) {
|
|
3914
|
+
const chosen = await targetsFor(options);
|
|
3915
|
+
if (!options.only) return chosen;
|
|
3916
|
+
const all = (0, _geekmidas_manifest.migrationTargets)(options.manifest);
|
|
3917
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3918
|
+
for (const target of chosen) {
|
|
3919
|
+
let current = target;
|
|
3920
|
+
while (current) {
|
|
3921
|
+
ids.add(current.id);
|
|
3922
|
+
const parent = current.of;
|
|
3923
|
+
current = parent ? all.find((t) => t.id === parent) : void 0;
|
|
3924
|
+
}
|
|
3925
|
+
}
|
|
3926
|
+
return all.filter((target) => ids.has(target.id));
|
|
3927
|
+
}
|
|
3928
|
+
/**
|
|
3929
|
+
* What each construct's folder has that its history does not — without
|
|
3930
|
+
* applying any of it. What `gkm dev` reports.
|
|
3931
|
+
*/
|
|
3932
|
+
async function pendingMigrations(options) {
|
|
3933
|
+
const found = [];
|
|
3934
|
+
for (const target of await targetsFor(options)) {
|
|
3935
|
+
if ((await migrationFiles((0, node_path.join)(options.root, target.migrations))).size === 0) continue;
|
|
3936
|
+
const pending = await withMigrator(options, target, async (migrator) => (await migrator.getMigrations()).filter((migration) => migration.executedAt === void 0).map((migration) => migration.name));
|
|
3937
|
+
if (pending.length > 0) found.push({
|
|
3938
|
+
target,
|
|
3939
|
+
pending
|
|
3940
|
+
});
|
|
3941
|
+
}
|
|
3942
|
+
return found;
|
|
3943
|
+
}
|
|
3944
|
+
/**
|
|
3945
|
+
* The targets this run covers, with every folder under `db/` accounted for.
|
|
3946
|
+
*
|
|
3947
|
+
* A folder that names no construct is an error rather than something to skip:
|
|
3948
|
+
* a typo in its name is otherwise a migration that silently never runs.
|
|
3949
|
+
*/
|
|
3950
|
+
async function targetsFor(options) {
|
|
3951
|
+
const targets = (0, _geekmidas_manifest.migrationTargets)(options.manifest);
|
|
3952
|
+
const folders = targets.map((target) => target.folder);
|
|
3953
|
+
for (const folder of await foldersIn((0, node_path.join)(options.root, _geekmidas_manifest.MIGRATIONS_ROOT))) {
|
|
3954
|
+
const relative = `${_geekmidas_manifest.MIGRATIONS_ROOT}/${folder}`;
|
|
3955
|
+
if (!folders.includes(relative)) throw new UnknownMigrationFolder(relative, folders);
|
|
3956
|
+
await checkLayout(options.root, relative);
|
|
3957
|
+
}
|
|
3958
|
+
if (!options.only) return targets;
|
|
3959
|
+
const id = (0, _geekmidas_manifest.canonicalId)(options.only);
|
|
3960
|
+
const chosen = targets.filter((target) => target.id === id);
|
|
3961
|
+
if (chosen.length === 0) throw new NoSuchMigrationTarget(options.only, targets.map((target) => target.id));
|
|
3962
|
+
return chosen;
|
|
3963
|
+
}
|
|
3964
|
+
/**
|
|
3965
|
+
* A construct's folder holds `migrations/` and `seeds/`, and nothing else —
|
|
3966
|
+
* anything beside them is a file or folder nothing would ever run.
|
|
3967
|
+
*/
|
|
3968
|
+
async function checkLayout(root, folder) {
|
|
3969
|
+
const entries = await (0, node_fs_promises.readdir)((0, node_path.join)(root, folder), { withFileTypes: true });
|
|
3970
|
+
const loose = entries.filter((entry) => entry.isFile() && !entry.name.startsWith(".")).map((entry) => entry.name);
|
|
3971
|
+
if (loose.length > 0) throw new MigrationsOutsideFolder(folder, loose);
|
|
3972
|
+
for (const entry of entries) if (entry.isDirectory() && !_geekmidas_manifest.DATABASE_FOLDERS.includes(entry.name)) throw new UnknownDatabaseFolder(`${folder}/${entry.name}`);
|
|
3973
|
+
}
|
|
3974
|
+
async function foldersIn(dir) {
|
|
3975
|
+
return (await (0, node_fs_promises.readdir)(dir, { withFileTypes: true }).catch((error) => {
|
|
3976
|
+
if (error.code === "ENOENT") return [];
|
|
3977
|
+
throw error;
|
|
3978
|
+
})).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
3979
|
+
}
|
|
3980
|
+
/**
|
|
3981
|
+
* A migrator for one target, connected as its owner.
|
|
3982
|
+
*
|
|
3983
|
+
* No `migrationTableSchema`: the owner role's `search_path` is pinned to the
|
|
3984
|
+
* construct's schema, so the history lands there — `app.kysely_migration` for
|
|
3985
|
+
* the database, `auth_database.kysely_migration` for its tenant — and two
|
|
3986
|
+
* constructs in one Postgres never share one.
|
|
3987
|
+
*
|
|
3988
|
+
* Unordered migrations are allowed. Files are timestamped, so a branch merged
|
|
3989
|
+
* after another's migration ran carries an older one; refusing it would make
|
|
3990
|
+
* every such merge a manual repair.
|
|
3991
|
+
*/
|
|
3992
|
+
async function withMigrator(options, target, run) {
|
|
3993
|
+
return withOwner(options, target, (db, { Migrator, sql }) => run(new Migrator({
|
|
3994
|
+
db,
|
|
3995
|
+
provider: folderProvider((0, node_path.join)(options.root, target.migrations), sql),
|
|
3996
|
+
allowUnorderedMigrations: true
|
|
3997
|
+
})));
|
|
3998
|
+
}
|
|
3999
|
+
/**
|
|
4000
|
+
* One target's database, connected as its owner and closed afterwards — what
|
|
4001
|
+
* migrations and seeds both run as.
|
|
4002
|
+
*/
|
|
4003
|
+
async function withOwner(options, target, run) {
|
|
4004
|
+
const kysely = await kyselyFrom(options.sources?.[target.id]?.file ?? (target.of ? options.sources?.[target.of]?.file : void 0) ?? (0, node_path.join)(options.root, "package.json"));
|
|
4005
|
+
const db = new kysely.Kysely({ dialect: new kysely.PostgresDialect({ pool: new pg.default.Pool(poolConfig(ownerUrl(options, target))) }) });
|
|
4006
|
+
try {
|
|
4007
|
+
return await run(db, kysely);
|
|
4008
|
+
} finally {
|
|
4009
|
+
await db.destroy();
|
|
4010
|
+
}
|
|
4011
|
+
}
|
|
4012
|
+
/**
|
|
4013
|
+
* The owner's URL, with no fallback to the runtime one.
|
|
4014
|
+
*
|
|
4015
|
+
* The runtime role can create nothing, so falling back would only move the
|
|
4016
|
+
* failure to the first `CREATE TABLE` and make it read like a permissions
|
|
4017
|
+
* bug. The one exception is a database declared `roles: false`, where there is
|
|
4018
|
+
* no owner — both URLs are the master credential by design.
|
|
4019
|
+
*/
|
|
4020
|
+
function ownerUrl(options, target) {
|
|
4021
|
+
const key = (0, _geekmidas_manifest.provideKey)(target.id, "ownerUrl");
|
|
4022
|
+
const owner = options.env[key];
|
|
4023
|
+
if (owner) return owner;
|
|
4024
|
+
const database = options.manifest[target.of ?? target.id];
|
|
4025
|
+
if (database?.kind === "database" && database.roles === false) {
|
|
4026
|
+
const url = options.env[(0, _geekmidas_manifest.provideKey)(target.id, "url")];
|
|
4027
|
+
if (url) return url;
|
|
4028
|
+
}
|
|
4029
|
+
throw new NoOwnerCredential(target.id, key);
|
|
4030
|
+
}
|
|
4031
|
+
/**
|
|
4032
|
+
* Pool settings for a URL, with `?search_path=` as the startup option Postgres
|
|
4033
|
+
* reads. Only a `roles: false` URL carries it — a role pins its own.
|
|
4034
|
+
*/
|
|
4035
|
+
function poolConfig(url) {
|
|
4036
|
+
const parsed = new URL(url);
|
|
4037
|
+
const searchPath = parsed.searchParams.get("search_path");
|
|
4038
|
+
if (!searchPath) return { connectionString: url };
|
|
4039
|
+
parsed.searchParams.delete("search_path");
|
|
4040
|
+
return {
|
|
4041
|
+
connectionString: parsed.toString(),
|
|
4042
|
+
options: `-c search_path=${searchPath}`
|
|
4043
|
+
};
|
|
4044
|
+
}
|
|
4045
|
+
/** Kysely as the project resolves it from `from`. */
|
|
4046
|
+
async function kyselyFrom(from) {
|
|
4047
|
+
const require$2 = (0, node_module.createRequire)(from);
|
|
4048
|
+
try {
|
|
4049
|
+
const [main, migration] = await Promise.all([import((0, node_url.pathToFileURL)(require$2.resolve("kysely")).href), import((0, node_url.pathToFileURL)(require$2.resolve("kysely/migration")).href)]);
|
|
4050
|
+
return {
|
|
4051
|
+
Kysely: main.Kysely,
|
|
4052
|
+
PostgresDialect: main.PostgresDialect,
|
|
4053
|
+
sql: main.sql,
|
|
4054
|
+
Migrator: migration.Migrator
|
|
4055
|
+
};
|
|
4056
|
+
} catch {
|
|
4057
|
+
throw new KyselyNotFound(from);
|
|
4058
|
+
}
|
|
4059
|
+
}
|
|
4060
|
+
//#endregion
|
|
4061
|
+
//#region src/compose/docker.ts
|
|
4062
|
+
/**
|
|
4063
|
+
* The Docker operations `gkm compose` needs, behind an interface.
|
|
4064
|
+
*
|
|
4065
|
+
* Small on purpose, like reconcile's: the rules about *when* each runs — the
|
|
4066
|
+
* registry is asked before anything is pulled, nothing is started for a tag
|
|
4067
|
+
* that is missing — are asserted against a fake, and this file is only the
|
|
4068
|
+
* wiring to the real CLI.
|
|
4069
|
+
*
|
|
4070
|
+
* Every call is an argument array. Image refs, tags and paths come from the
|
|
4071
|
+
* workspace, and none of them reaches a shell.
|
|
4072
|
+
*/
|
|
4073
|
+
/** How long a stack has to pass its health checks. */
|
|
4074
|
+
const WAIT_TIMEOUT_S = 180;
|
|
4075
|
+
/** `docker compose port` printed nothing a port can be read from. */
|
|
4076
|
+
var ServicePortUnknown = class extends Error {
|
|
4077
|
+
service;
|
|
4078
|
+
inside;
|
|
4079
|
+
output;
|
|
4080
|
+
constructor(service, inside, output) {
|
|
4081
|
+
super(`'${service}' publishes no host port for ${inside} (docker said: ${output.trim() || "nothing"}). Is it running? \`docker compose ps\` shows the stack.`);
|
|
4082
|
+
this.service = service;
|
|
4083
|
+
this.inside = inside;
|
|
4084
|
+
this.output = output;
|
|
4085
|
+
this.name = "ServicePortUnknown";
|
|
4086
|
+
}
|
|
4087
|
+
};
|
|
4088
|
+
function compose(stack, args) {
|
|
4089
|
+
return [
|
|
4090
|
+
"compose",
|
|
4091
|
+
"-p",
|
|
4092
|
+
stack.project,
|
|
4093
|
+
"-f",
|
|
4094
|
+
stack.file,
|
|
4095
|
+
...args
|
|
4096
|
+
];
|
|
4097
|
+
}
|
|
4098
|
+
/** Run a program and keep everything it said, whatever it exits with. */
|
|
4099
|
+
function capture(command, args, timeoutMs = 6e4) {
|
|
4100
|
+
return new Promise((resolve, reject) => {
|
|
4101
|
+
const child = (0, node_child_process.spawn)(command, [...args], {
|
|
4102
|
+
stdio: [
|
|
4103
|
+
"ignore",
|
|
4104
|
+
"pipe",
|
|
4105
|
+
"pipe"
|
|
4106
|
+
],
|
|
4107
|
+
shell: false
|
|
4108
|
+
});
|
|
4109
|
+
let stdout = "";
|
|
4110
|
+
let stderr = "";
|
|
4111
|
+
child.stdout.on("data", (chunk) => {
|
|
4112
|
+
stdout += chunk.toString();
|
|
4113
|
+
});
|
|
4114
|
+
child.stderr.on("data", (chunk) => {
|
|
4115
|
+
stderr += chunk.toString();
|
|
4116
|
+
});
|
|
4117
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
|
|
4118
|
+
timer.unref?.();
|
|
4119
|
+
child.on("error", (error) => {
|
|
4120
|
+
clearTimeout(timer);
|
|
4121
|
+
reject(error);
|
|
4122
|
+
});
|
|
4123
|
+
child.on("close", (code) => {
|
|
4124
|
+
clearTimeout(timer);
|
|
4125
|
+
resolve({
|
|
4126
|
+
code,
|
|
4127
|
+
stdout,
|
|
4128
|
+
stderr
|
|
4129
|
+
});
|
|
4130
|
+
});
|
|
4131
|
+
});
|
|
4132
|
+
}
|
|
4133
|
+
/**
|
|
4134
|
+
* Whether a failed `docker manifest inspect` means the tag is not there, as
|
|
4135
|
+
* opposed to the registry not answering.
|
|
4136
|
+
*
|
|
4137
|
+
* Read off the message because that is all the CLI gives. Anything not
|
|
4138
|
+
* recognisably "no such tag" is treated as the registry being unreachable:
|
|
4139
|
+
* a registry that refused credentials must not be reported as a tag that was
|
|
4140
|
+
* never pushed, or the fix offered would be the wrong one.
|
|
4141
|
+
*/
|
|
4142
|
+
function isMissingManifest(output) {
|
|
4143
|
+
return /no such manifest|manifest unknown|manifest for .* not found|not found: manifest/i.test(output);
|
|
4144
|
+
}
|
|
4145
|
+
const dockerCompose = {
|
|
4146
|
+
async lookup(ref) {
|
|
4147
|
+
const { code, stdout, stderr } = await capture("docker", [
|
|
4148
|
+
"manifest",
|
|
4149
|
+
"inspect",
|
|
4150
|
+
ref
|
|
4151
|
+
]);
|
|
4152
|
+
if (code === 0) return {
|
|
4153
|
+
ref,
|
|
4154
|
+
status: "found"
|
|
4155
|
+
};
|
|
4156
|
+
const output = `${stderr}\n${stdout}`.trim();
|
|
4157
|
+
return isMissingManifest(output) ? {
|
|
4158
|
+
ref,
|
|
4159
|
+
status: "missing"
|
|
4160
|
+
} : {
|
|
4161
|
+
ref,
|
|
4162
|
+
status: "unreachable",
|
|
4163
|
+
detail: output
|
|
4164
|
+
};
|
|
4165
|
+
},
|
|
4166
|
+
async build(stack, services) {
|
|
4167
|
+
await require_sniffer.run("docker", compose(stack, ["build", ...services]), {
|
|
4168
|
+
cwd: stack.cwd,
|
|
4169
|
+
env: {
|
|
4170
|
+
...process.env,
|
|
4171
|
+
DOCKER_BUILDKIT: "1"
|
|
4172
|
+
}
|
|
4173
|
+
});
|
|
4174
|
+
},
|
|
4175
|
+
async pull(stack, services) {
|
|
4176
|
+
await require_sniffer.run("docker", compose(stack, ["pull", ...services]), { cwd: stack.cwd });
|
|
4177
|
+
},
|
|
4178
|
+
async up(stack, services) {
|
|
4179
|
+
await require_sniffer.run("docker", compose(stack, [
|
|
4180
|
+
"up",
|
|
4181
|
+
"-d",
|
|
4182
|
+
"--wait",
|
|
4183
|
+
`--wait-timeout=${WAIT_TIMEOUT_S}`,
|
|
4184
|
+
...services ? [] : ["--remove-orphans"],
|
|
4185
|
+
...services ?? []
|
|
4186
|
+
]), { cwd: stack.cwd });
|
|
4187
|
+
},
|
|
4188
|
+
async down(stack) {
|
|
4189
|
+
await require_sniffer.run("docker", compose(stack, ["down"]), { cwd: stack.cwd });
|
|
4190
|
+
},
|
|
4191
|
+
async port(stack, service, inside) {
|
|
4192
|
+
const output = await require_sniffer.runOutput("docker", compose(stack, [
|
|
4193
|
+
"port",
|
|
4194
|
+
service,
|
|
4195
|
+
String(inside)
|
|
4196
|
+
]), { cwd: stack.cwd });
|
|
4197
|
+
const port = Number(output.trim().split(":").pop());
|
|
4198
|
+
if (!Number.isInteger(port) || port <= 0) throw new ServicePortUnknown(service, inside, output);
|
|
4199
|
+
return port;
|
|
4200
|
+
},
|
|
4201
|
+
async copyOut(stack, service, from, to) {
|
|
4202
|
+
await (0, node_fs_promises.mkdir)((0, node_path.dirname)(to), { recursive: true });
|
|
4203
|
+
await require_sniffer.run("docker", compose(stack, [
|
|
4204
|
+
"cp",
|
|
4205
|
+
`${service}:${from}`,
|
|
4206
|
+
to
|
|
4207
|
+
]), { cwd: stack.cwd });
|
|
4208
|
+
},
|
|
4209
|
+
async digest(ref) {
|
|
4210
|
+
const { code, stdout } = await capture("docker", [
|
|
4211
|
+
"image",
|
|
4212
|
+
"inspect",
|
|
4213
|
+
"--format={{json .RepoDigests}} {{.Id}}",
|
|
4214
|
+
ref
|
|
4215
|
+
]);
|
|
4216
|
+
if (code !== 0) return void 0;
|
|
4217
|
+
const [json = "[]", id] = stdout.trim().split(" ");
|
|
4218
|
+
const repository = ref.split("@")[0].replace(/:[\w][\w.-]*$/, "");
|
|
4219
|
+
try {
|
|
4220
|
+
const digests = JSON.parse(json);
|
|
4221
|
+
const match = Array.isArray(digests) ? digests.find((entry) => typeof entry === "string" && entry.startsWith(`${repository}@`)) : void 0;
|
|
4222
|
+
if (match) return match.slice(repository.length + 1);
|
|
4223
|
+
} catch {}
|
|
4224
|
+
return id || void 0;
|
|
4225
|
+
}
|
|
4226
|
+
};
|
|
4227
|
+
//#endregion
|
|
4228
|
+
//#region src/compose/images.ts
|
|
4229
|
+
/**
|
|
4230
|
+
* The tag a site's image carries for a release.
|
|
4231
|
+
*
|
|
4232
|
+
* A site's public URLs are inlined when it is built, so one bundle serves one
|
|
4233
|
+
* stage: `v1.4.0-production` and `v1.4.0-staging` are two images of the same
|
|
4234
|
+
* commit. A backend reads its URLs at runtime, so one image serves every stage
|
|
4235
|
+
* and its tag is the release's alone.
|
|
4236
|
+
*/
|
|
4237
|
+
function siteTag(tag, stage) {
|
|
4238
|
+
return `${tag}-${stage}`;
|
|
4239
|
+
}
|
|
4240
|
+
/** Images a tag names that the registry does not have. */
|
|
4241
|
+
var ImageTagNotFound = class extends Error {
|
|
4242
|
+
tag;
|
|
4243
|
+
refs;
|
|
4244
|
+
constructor(tag, refs) {
|
|
4245
|
+
super(`The registry has no image for ${refs.length === 1 ? "this app" : `${refs.length} apps`} at '${tag}':\n` + refs.map((ref) => ` - ${ref}`).join("\n") + `\nNothing was pulled or started. Push '${tag}' for every app from CI, pick a tag that exists, or run without --tag to build from this checkout.`);
|
|
4246
|
+
this.tag = tag;
|
|
4247
|
+
this.refs = refs;
|
|
4248
|
+
this.name = "ImageTagNotFound";
|
|
4249
|
+
}
|
|
4250
|
+
};
|
|
4251
|
+
/** The registry did not answer, or did not let this machine in. */
|
|
4252
|
+
var RegistryUnreachable = class extends Error {
|
|
4253
|
+
ref;
|
|
4254
|
+
detail;
|
|
4255
|
+
constructor(ref, detail) {
|
|
4256
|
+
super(`Could not ask the registry about ${ref}: ${detail || "no answer"}\nNothing was pulled or started. Check the registry is reachable and that this machine is logged in to it (\`docker login <registry>\`).`);
|
|
4257
|
+
this.ref = ref;
|
|
4258
|
+
this.detail = detail;
|
|
4259
|
+
this.name = "RegistryUnreachable";
|
|
4260
|
+
}
|
|
4261
|
+
};
|
|
4262
|
+
/**
|
|
4263
|
+
* Assert every image exists, before anything else happens.
|
|
4264
|
+
*
|
|
4265
|
+
* Asked all at once and reported all at once: a release missing two images is
|
|
4266
|
+
* one problem with two names, and finding the second only after fixing the
|
|
4267
|
+
* first is a slower way to learn the same thing.
|
|
4268
|
+
*
|
|
4269
|
+
* @throws {RegistryUnreachable} when the registry cannot say
|
|
4270
|
+
* @throws {ImageTagNotFound} listing every missing image
|
|
4271
|
+
*/
|
|
4272
|
+
async function assertImagesExist(docker, tag, images) {
|
|
4273
|
+
const lookups = await Promise.all(images.map((image) => docker.lookup(image.ref)));
|
|
4274
|
+
const unreachable = lookups.find((lookup) => lookup.status === "unreachable");
|
|
4275
|
+
if (unreachable?.status === "unreachable") throw new RegistryUnreachable(unreachable.ref, unreachable.detail);
|
|
4276
|
+
const missing = lookups.filter((lookup) => lookup.status === "missing").map((lookup) => lookup.ref);
|
|
4277
|
+
if (missing.length > 0) throw new ImageTagNotFound(tag, missing);
|
|
4278
|
+
}
|
|
4279
|
+
//#endregion
|
|
4280
|
+
//#region src/compose/caddyfile.ts
|
|
4281
|
+
/** The Caddyfile for a stack's edge. */
|
|
4282
|
+
function edgeCaddyfile(sites, options) {
|
|
4283
|
+
const global = options.local ? `{
|
|
4284
|
+
# No ACME and no public DNS: the CA is Caddy's own and the names resolve
|
|
4285
|
+
# to this machine.
|
|
4286
|
+
local_certs
|
|
4287
|
+
auto_https disable_redirects
|
|
4288
|
+
}` : options.email ? `{
|
|
4289
|
+
email ${options.email}
|
|
4290
|
+
}` : "";
|
|
4291
|
+
const blocks = sites.map(({ host, upstream }) => `https://${host} {${options.local ? `
|
|
4292
|
+
tls internal
|
|
4293
|
+
` : ""}
|
|
4294
|
+
# Host and X-Forwarded-* are passed through as Caddy does by default, so
|
|
4295
|
+
# an app builds redirects, cookies and links on the address a caller used.
|
|
4296
|
+
reverse_proxy ${upstream} {
|
|
4297
|
+
# Never buffer: a streamed response — server-sent events, a Next.js
|
|
4298
|
+
# RSC payload — reaches the client as each chunk is written.
|
|
4299
|
+
flush_interval -1
|
|
4300
|
+
}
|
|
4301
|
+
}`);
|
|
4302
|
+
return `${HEADER}${global ? `${global}\n\n` : ""}${blocks.join("\n\n")}\n`;
|
|
4303
|
+
}
|
|
4304
|
+
const HEADER = `# Generated by gkm compose from the construct manifest — do not edit.
|
|
4305
|
+
#
|
|
4306
|
+
# One host per API and site, each in front of its own container.
|
|
4307
|
+
`;
|
|
4308
|
+
//#endregion
|
|
4309
|
+
//#region src/compose/stack.ts
|
|
4310
|
+
/**
|
|
4311
|
+
* The stack a workspace runs as for one stage — pure.
|
|
4312
|
+
*
|
|
4313
|
+
* Everything `gkm compose` writes is decided here, as data: the compose file,
|
|
4314
|
+
* the Caddyfile, one env file per backend, and the image each app runs. The
|
|
4315
|
+
* command around it reads the workspace, asks the registry, writes the files
|
|
4316
|
+
* and starts Docker; none of those decide anything, so all of it can be
|
|
4317
|
+
* asserted without a daemon.
|
|
4318
|
+
*
|
|
4319
|
+
* The values come from the same derivations every other target uses. The
|
|
4320
|
+
* plan is reconcile's `planFor`, the keys an app reads are `appEnvKeys`, and
|
|
4321
|
+
* every URL is `envFor`'s — asked twice, once with each app at its public
|
|
4322
|
+
* address and once on the compose network, because a browser and a sibling
|
|
4323
|
+
* service reach the same API by different names.
|
|
4324
|
+
*/
|
|
4325
|
+
/** Where a stack's files are written, relative to the workspace root. */
|
|
4326
|
+
function stackDir(stage) {
|
|
4327
|
+
return `.gkm/compose/${stage}`;
|
|
4328
|
+
}
|
|
4329
|
+
/**
|
|
4330
|
+
* The compose project a stage's stack runs as.
|
|
4331
|
+
*
|
|
4332
|
+
* The deploy identity's scope, so two workspaces with one name in different
|
|
4333
|
+
* namespaces never share containers or volumes — and the stage, so a
|
|
4334
|
+
* machine can run two stages side by side.
|
|
4335
|
+
*/
|
|
4336
|
+
function composeProject(identity) {
|
|
4337
|
+
return `${identity.scope}-${identity.stage}`;
|
|
4338
|
+
}
|
|
4339
|
+
/** The ports the edge is published on, and the variables that move them. */
|
|
4340
|
+
const EDGE_PORT_ENV = {
|
|
4341
|
+
https: "GKM_COMPOSE_HTTPS_PORT",
|
|
4342
|
+
http: "GKM_COMPOSE_HTTP_PORT"
|
|
4343
|
+
};
|
|
4344
|
+
/**
|
|
4345
|
+
* Containers this target does not run.
|
|
4346
|
+
*
|
|
4347
|
+
* Object storage is the stage's own — an externally hosted bucket whose URL
|
|
4348
|
+
* the stage's secrets hold — and so is a deployed stage's mail. The AWS
|
|
4349
|
+
* emulator never applies: a stack is a server target, whose events are
|
|
4350
|
+
* pg-boss.
|
|
4351
|
+
*/
|
|
4352
|
+
const NOT_RUN = {
|
|
4353
|
+
minio: true,
|
|
4354
|
+
localstack: true,
|
|
4355
|
+
caddy: true
|
|
4356
|
+
};
|
|
4357
|
+
/** Kinds whose `<ID>_URL` a stage must be given, because nothing here runs them. */
|
|
4358
|
+
const EXTERNAL_KINDS = {
|
|
4359
|
+
objects: true,
|
|
4360
|
+
"file-server": true
|
|
4361
|
+
};
|
|
4362
|
+
/** The S3 client's credentials, read beside a bucket's URL when it has them. */
|
|
4363
|
+
const STORAGE_KEYS = [
|
|
4364
|
+
"AWS_ACCESS_KEY_ID",
|
|
4365
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
4366
|
+
"AWS_REGION"
|
|
4367
|
+
];
|
|
4368
|
+
/** A bucket the stack needs and the stage was not given. */
|
|
4369
|
+
var BucketNotConfigured = class extends Error {
|
|
4370
|
+
app;
|
|
4371
|
+
id;
|
|
4372
|
+
key;
|
|
4373
|
+
stage;
|
|
4374
|
+
constructor(app, id, key, stage) {
|
|
4375
|
+
super(`'${app}' uses the bucket '${id}', and gkm compose runs no object storage: the stage needs an externally hosted bucket. Set its URL in the stage's secrets: gkm secrets:set ${key} 's3://…' --stage ${stage}`);
|
|
4376
|
+
this.app = app;
|
|
4377
|
+
this.id = id;
|
|
4378
|
+
this.key = key;
|
|
4379
|
+
this.stage = stage;
|
|
4380
|
+
this.name = "BucketNotConfigured";
|
|
4381
|
+
}
|
|
4382
|
+
};
|
|
4383
|
+
/** A value only the stage's secrets can hold, and they do not. */
|
|
4384
|
+
var StageSecretMissing = class extends Error {
|
|
4385
|
+
app;
|
|
4386
|
+
key;
|
|
4387
|
+
stage;
|
|
4388
|
+
constructor(app, key, stage) {
|
|
4389
|
+
super(`'${app}' reads ${key}, and the stage '${stage}' has none. Nothing in the stack can derive it; set it in the stage's secrets: gkm secrets:set ${key} '…' --stage ${stage}`);
|
|
4390
|
+
this.app = app;
|
|
4391
|
+
this.key = key;
|
|
4392
|
+
this.stage = stage;
|
|
4393
|
+
this.name = "StageSecretMissing";
|
|
4394
|
+
}
|
|
4395
|
+
};
|
|
4396
|
+
/** A deployed stage's secrets with no seed to derive its passwords from. */
|
|
4397
|
+
var StageSeedMissing = class extends Error {
|
|
4398
|
+
stage;
|
|
4399
|
+
constructor(stage) {
|
|
4400
|
+
super(`The stage '${stage}' has no seed in its secrets, and a deployed stage's database passwords are derived from it. Run gkm compose without --dry-run once, or gkm deploy, to generate it.`);
|
|
4401
|
+
this.stage = stage;
|
|
4402
|
+
this.name = "StageSeedMissing";
|
|
4403
|
+
}
|
|
4404
|
+
};
|
|
4405
|
+
/** A workspace with no API and no site to run. */
|
|
4406
|
+
var NothingToCompose = class extends Error {
|
|
4407
|
+
root;
|
|
4408
|
+
constructor(root) {
|
|
4409
|
+
super(`${root} declares no RestApi and no site, so a stack would run nothing. Workers and mobile apps are not run by gkm compose yet.`);
|
|
4410
|
+
this.root = root;
|
|
4411
|
+
this.name = "NothingToCompose";
|
|
4412
|
+
}
|
|
4413
|
+
};
|
|
4414
|
+
/** A value that cannot be written to an env file a line at a time. */
|
|
4415
|
+
var EnvValueMultiline = class extends Error {
|
|
4416
|
+
app;
|
|
4417
|
+
key;
|
|
4418
|
+
constructor(app, key) {
|
|
4419
|
+
super(`${key} for '${app}' spans more than one line, and an env file holds one value per line. Store it on one line — base64, or JSON — and decode it where it is read.`);
|
|
4420
|
+
this.app = app;
|
|
4421
|
+
this.key = key;
|
|
4422
|
+
this.name = "EnvValueMultiline";
|
|
4423
|
+
}
|
|
4424
|
+
};
|
|
4425
|
+
/** The services a stack's apps wait on and the edge waits on in turn. */
|
|
4426
|
+
const HEALTHY = { condition: "service_healthy" };
|
|
4427
|
+
/**
|
|
4428
|
+
* An app's health check: its own server, asked on 127.0.0.1 — not
|
|
4429
|
+
* `localhost`, which resolves to `::1` first in an Alpine container while a
|
|
4430
|
+
* server may listen on IPv4 alone.
|
|
4431
|
+
*/
|
|
4432
|
+
function probe(port, path) {
|
|
4433
|
+
return {
|
|
4434
|
+
test: [
|
|
4435
|
+
"CMD",
|
|
4436
|
+
"wget",
|
|
4437
|
+
"-q",
|
|
4438
|
+
"-O",
|
|
4439
|
+
"/dev/null",
|
|
4440
|
+
`http://127.0.0.1:${port}${path}`
|
|
4441
|
+
],
|
|
4442
|
+
interval: "5s",
|
|
4443
|
+
timeout: "3s",
|
|
4444
|
+
retries: 20,
|
|
4445
|
+
start_period: "10s"
|
|
4446
|
+
};
|
|
4447
|
+
}
|
|
4448
|
+
/** The stack for one stage. */
|
|
4449
|
+
function composeStack(input) {
|
|
4450
|
+
const { workspace, manifest, stage, identity } = input;
|
|
4451
|
+
const local = stage === workspace.stages.local;
|
|
4452
|
+
const project = composeProject(identity);
|
|
4453
|
+
const custom = input.secrets?.custom ?? {};
|
|
4454
|
+
const https = input.ports?.https ?? 443;
|
|
4455
|
+
const http = input.ports?.http ?? 80;
|
|
4456
|
+
const plan = require_sniffer.planFor(manifest, stage, (0, _geekmidas_manifest.provisionOrder)(manifest), {
|
|
4457
|
+
localStage: workspace.stages.local,
|
|
4458
|
+
events: require_fakes.DEFAULT_EVENTS.server,
|
|
4459
|
+
cache: require_fakes.DEFAULT_CACHE.server,
|
|
4460
|
+
edge: false
|
|
4461
|
+
});
|
|
4462
|
+
const byId = new Map(plan.resources.map((r) => [r.id, r]));
|
|
4463
|
+
const seed = local ? void 0 : input.secrets?.seed;
|
|
4464
|
+
if (!local && !seed) throw new StageSeedMissing(stage);
|
|
4465
|
+
const credential = {
|
|
4466
|
+
master: local ? "geekmidas" : require_sniffer.localRolePassword(workspace.name, plan, "master", seed),
|
|
4467
|
+
...seed ? { seed } : {}
|
|
4468
|
+
};
|
|
4469
|
+
const infra = plan.containers.filter((c) => !NOT_RUN[c]).filter((c) => local || c !== "mailpit").sort();
|
|
4470
|
+
const apps = stackApps(workspace, manifest, plan, {
|
|
4471
|
+
local,
|
|
4472
|
+
stage,
|
|
4473
|
+
https,
|
|
4474
|
+
identity,
|
|
4475
|
+
images: input.images
|
|
4476
|
+
});
|
|
4477
|
+
if (apps.length === 0) throw new NothingToCompose(workspace.root);
|
|
4478
|
+
const derivation = {
|
|
4479
|
+
project: workspace.name,
|
|
4480
|
+
master: credential.master,
|
|
4481
|
+
...credential.seed ? { seed: credential.seed } : {}
|
|
4482
|
+
};
|
|
4483
|
+
const outside = require_sniffer.networkEnv(plan, {
|
|
4484
|
+
...derivation,
|
|
4485
|
+
addresses: Object.fromEntries(apps.map((app) => [app.id, app.url]))
|
|
4486
|
+
});
|
|
4487
|
+
const inside = require_sniffer.networkEnv(plan, {
|
|
4488
|
+
...derivation,
|
|
4489
|
+
addresses: Object.fromEntries(apps.map((app) => [app.id, `http://${app.name}:${app.port}`]))
|
|
4490
|
+
});
|
|
4491
|
+
const owners = /* @__PURE__ */ new Map();
|
|
4492
|
+
for (const resource of plan.resources) {
|
|
4493
|
+
owners.set(resource.envKey, resource);
|
|
4494
|
+
if (resource.kind === "email") {
|
|
4495
|
+
owners.set((0, _geekmidas_manifest.provideKey)(resource.id, "from"), resource);
|
|
4496
|
+
owners.set((0, _geekmidas_manifest.provideKey)(resource.id, "inboxUrl"), resource);
|
|
4497
|
+
}
|
|
4498
|
+
if (resource.kind === "external-api") owners.set((0, _geekmidas_manifest.provideKey)(resource.id, "credentials"), resource);
|
|
4499
|
+
}
|
|
4500
|
+
const surfaceUrls = new Map(plan.resources.filter((r) => r.kind === "rest-api" || r.kind === "site").map((r) => [r.envKey, r.id]));
|
|
4501
|
+
const appById = new Map(apps.map((app) => [app.id, app]));
|
|
4502
|
+
/**
|
|
4503
|
+
* What a key resolves to for one app, from the stage's secrets where only
|
|
4504
|
+
* they can hold it, and the derivations otherwise.
|
|
4505
|
+
*/
|
|
4506
|
+
const valueFor = (app, key, owner) => {
|
|
4507
|
+
if (owner && EXTERNAL_KINDS[owner.kind] && key === owner.envKey) {
|
|
4508
|
+
const value = custom[key];
|
|
4509
|
+
if (value === void 0) throw new BucketNotConfigured(app.name, owner.id, key, stage);
|
|
4510
|
+
return value;
|
|
4511
|
+
}
|
|
4512
|
+
if (owner && suppliedBy(owner, key, local)) {
|
|
4513
|
+
const value = custom[key];
|
|
4514
|
+
if (value === void 0) throw new StageSecretMissing(app.name, key, stage);
|
|
4515
|
+
return value;
|
|
4516
|
+
}
|
|
4517
|
+
if (custom[key] !== void 0) return custom[key];
|
|
4518
|
+
const surface = surfaceUrls.get(key);
|
|
4519
|
+
if (app.kind === "rest-api" && surface && surface !== app.id) return inside[key];
|
|
4520
|
+
return outside[key];
|
|
4521
|
+
};
|
|
4522
|
+
for (const app of apps) {
|
|
4523
|
+
if (app.kind === "site") {
|
|
4524
|
+
const site = manifest[app.id];
|
|
4525
|
+
if (site?.kind !== "site") continue;
|
|
4526
|
+
const args = {};
|
|
4527
|
+
for (const [key, source] of Object.entries((0, _geekmidas_manifest.publicEnvFor)(site, manifest))) {
|
|
4528
|
+
const value = valueFor(app, source, owners.get(source));
|
|
4529
|
+
if (value !== void 0) args[key] = value;
|
|
4530
|
+
}
|
|
4531
|
+
if (app.build && Object.keys(args).length > 0) app.build.args = args;
|
|
4532
|
+
continue;
|
|
4533
|
+
}
|
|
4534
|
+
const keys = require_sniffer.appEnvKeys(manifest, app.name, input.runnables) ?? /* @__PURE__ */ new Set();
|
|
4535
|
+
const values = {};
|
|
4536
|
+
for (const key of [...keys].sort()) {
|
|
4537
|
+
const owner = owners.get(key) ?? storageOwner(key, plan);
|
|
4538
|
+
if (!local && owner?.kind === "email" && key.endsWith("_INBOX_URL")) continue;
|
|
4539
|
+
if (STORAGE_KEYS.includes(key)) {
|
|
4540
|
+
if (custom[key] !== void 0) values[key] = custom[key];
|
|
4541
|
+
continue;
|
|
4542
|
+
}
|
|
4543
|
+
const value = valueFor(app, key, owner);
|
|
4544
|
+
if (value !== void 0) values[key] = value;
|
|
4545
|
+
}
|
|
4546
|
+
const trusted = (0, _geekmidas_manifest.provideKey)(app.id, "trustedOrigins");
|
|
4547
|
+
if (keys.has(trusted)) {
|
|
4548
|
+
const internal = (byId.get(app.id)?.callers ?? []).map((caller) => appById.get(caller)).filter((caller) => caller?.kind === "rest-api").map((caller) => `http://${caller.name}:${caller.port}`);
|
|
4549
|
+
const origins = [...(values[trusted] ?? "").split(",").filter(Boolean), ...internal];
|
|
4550
|
+
values[trusted] = [...new Set(origins)].join(",");
|
|
4551
|
+
}
|
|
4552
|
+
app.env = {
|
|
4553
|
+
...values,
|
|
4554
|
+
NODE_ENV: "production",
|
|
4555
|
+
PORT: String(app.port),
|
|
4556
|
+
STAGE: stage
|
|
4557
|
+
};
|
|
4558
|
+
for (const [key, value] of Object.entries(app.env)) if (/[\r\n]/.test(value)) throw new EnvValueMultiline(app.name, key);
|
|
4559
|
+
}
|
|
4560
|
+
const compose = stackFile({
|
|
4561
|
+
project,
|
|
4562
|
+
plan,
|
|
4563
|
+
infra,
|
|
4564
|
+
apps,
|
|
4565
|
+
master: credential.master,
|
|
4566
|
+
https,
|
|
4567
|
+
http
|
|
4568
|
+
});
|
|
4569
|
+
const caddyfile = edgeCaddyfile(apps.map((app) => ({
|
|
4570
|
+
host: app.host,
|
|
4571
|
+
upstream: `${app.name}:${app.port}`
|
|
4572
|
+
})), { local });
|
|
4573
|
+
const dockerfiles = {};
|
|
4574
|
+
for (const app of apps) {
|
|
4575
|
+
if (!app.build) continue;
|
|
4576
|
+
dockerfiles[app.build.dockerfile] = app.kind === "site" ? require_sniffer.siteDockerfile(app.name, workspace.apps[app.name], {
|
|
4577
|
+
turboPackage: input.packages?.[app.name] ?? app.name,
|
|
4578
|
+
packageManager: input.packageManager ?? "pnpm",
|
|
4579
|
+
manifest
|
|
4580
|
+
}) : require_sniffer.generateSlimDockerfile({
|
|
4581
|
+
imageName: app.name,
|
|
4582
|
+
baseImage: "node:22-alpine",
|
|
4583
|
+
port: app.port,
|
|
4584
|
+
healthCheckPath: "/health",
|
|
4585
|
+
prebuilt: true,
|
|
4586
|
+
packageManager: input.packageManager ?? "pnpm",
|
|
4587
|
+
bundle: `${app.path}/.gkm/server/dist/server.mjs`
|
|
4588
|
+
});
|
|
4589
|
+
}
|
|
4590
|
+
return {
|
|
4591
|
+
project,
|
|
4592
|
+
stage,
|
|
4593
|
+
local,
|
|
4594
|
+
apps,
|
|
4595
|
+
infra,
|
|
4596
|
+
compose,
|
|
4597
|
+
caddyfile,
|
|
4598
|
+
dockerfiles,
|
|
4599
|
+
plan,
|
|
4600
|
+
credential
|
|
4601
|
+
};
|
|
4602
|
+
/** Whether only the stage's secrets can supply `key` to `owner`. */
|
|
4603
|
+
function suppliedBy(owner, key, isLocal) {
|
|
4604
|
+
if (owner.kind === "credential") return true;
|
|
4605
|
+
if (owner.kind === "external-api") return key === (0, _geekmidas_manifest.provideKey)(owner.id, "credentials");
|
|
4606
|
+
if (isLocal) return false;
|
|
4607
|
+
if (owner.kind === "secret" || owner.kind === "encryption") return true;
|
|
4608
|
+
return owner.kind === "email" && !key.endsWith("_INBOX_URL");
|
|
4609
|
+
}
|
|
4610
|
+
}
|
|
4611
|
+
/** Which bucket an S3 credential key belongs to — the first one declared. */
|
|
4612
|
+
function storageOwner(key, plan) {
|
|
4613
|
+
if (!STORAGE_KEYS.includes(key)) return void 0;
|
|
4614
|
+
return plan.resources.find((r) => r.kind === "objects");
|
|
4615
|
+
}
|
|
4616
|
+
/** The apps a stack runs: every API and every site, at its stage address. */
|
|
4617
|
+
function stackApps(workspace, manifest, plan, options) {
|
|
4618
|
+
const apps = [];
|
|
4619
|
+
const resources = new Map(plan.resources.map((r) => [r.id, r]));
|
|
4620
|
+
for (const [id, declaration] of Object.entries(manifest)) {
|
|
4621
|
+
if (declaration.kind !== "rest-api" && declaration.kind !== "site") continue;
|
|
4622
|
+
const name = require_config.appKey(id);
|
|
4623
|
+
const app = workspace.apps[name];
|
|
4624
|
+
const resource = resources.get(id);
|
|
4625
|
+
if (!app || !resource || !app.port) continue;
|
|
4626
|
+
const host = hostOf(name, app, workspace, resource, options);
|
|
4627
|
+
const tag = declaration.kind === "site" ? siteTag(options.images.tag, options.stage) : options.images.tag;
|
|
4628
|
+
const ref = require_sniffer.validateImageRef(require_store.imageRef(options.identity, name, options.images.registry, tag));
|
|
4629
|
+
apps.push({
|
|
4630
|
+
app: name,
|
|
4631
|
+
name,
|
|
4632
|
+
id,
|
|
4633
|
+
kind: declaration.kind,
|
|
4634
|
+
path: app.path,
|
|
4635
|
+
port: app.port,
|
|
4636
|
+
host,
|
|
4637
|
+
url: `https://${host}${options.local && options.https !== 443 ? `:${options.https}` : ""}`,
|
|
4638
|
+
ref,
|
|
4639
|
+
tag,
|
|
4640
|
+
...options.images.mode === "build" ? { build: { dockerfile: `${stackDir(options.stage)}/Dockerfile.${name}` } } : {}
|
|
4641
|
+
});
|
|
4642
|
+
}
|
|
4643
|
+
return apps.sort((a, b) => a.name.localeCompare(b.name));
|
|
4644
|
+
}
|
|
4645
|
+
/**
|
|
4646
|
+
* The host an app answers on for a stage: the `gkm dev` edge's
|
|
4647
|
+
* `*.localhost` names for the local stage, and the stage's domain — by the
|
|
4648
|
+
* same rule a deploy follows — for any other.
|
|
4649
|
+
*/
|
|
4650
|
+
function hostOf(name, app, workspace, resource, options) {
|
|
4651
|
+
if (options.local) return require_sniffer.hostFor(resource, workspace.name);
|
|
4652
|
+
return require_sniffer.resolveHost(name, app, options.stage, workspace.deploy?.domains, require_sniffer.isMainFrontendApp(name, app, workspace.apps));
|
|
4653
|
+
}
|
|
4654
|
+
/** The compose document: the infrastructure, the apps, and the edge. */
|
|
4655
|
+
function stackFile(options) {
|
|
4656
|
+
const { project, plan, infra, apps } = options;
|
|
4657
|
+
const derived = require_sniffer.composeFor({
|
|
4658
|
+
...plan,
|
|
4659
|
+
containers: [...infra]
|
|
4660
|
+
}, {
|
|
4661
|
+
project,
|
|
4662
|
+
ports: Object.fromEntries(require_sniffer.portKeys(infra, plan.fakes).map((key) => [key, 0]))
|
|
4663
|
+
});
|
|
4664
|
+
const services = {};
|
|
4665
|
+
const volumes = {
|
|
4666
|
+
...derived.volumes,
|
|
4667
|
+
"caddy-data": {},
|
|
4668
|
+
"caddy-config": {}
|
|
4669
|
+
};
|
|
4670
|
+
for (const name of infra) {
|
|
4671
|
+
const { ports: _ports, depends_on, profiles: _profiles, ...service } = derived.services[name];
|
|
4672
|
+
services[name] = {
|
|
4673
|
+
...service,
|
|
4674
|
+
...depends_on?.length ? { depends_on: Object.fromEntries(depends_on.map((dep) => [dep, HEALTHY])) } : {}
|
|
4675
|
+
};
|
|
4676
|
+
}
|
|
4677
|
+
const postgres = services.postgres;
|
|
4678
|
+
if (postgres) {
|
|
4679
|
+
postgres.environment = {
|
|
4680
|
+
...postgres.environment,
|
|
4681
|
+
POSTGRES_PASSWORD: options.master
|
|
4682
|
+
};
|
|
4683
|
+
postgres.ports = ["127.0.0.1::5432"];
|
|
4684
|
+
}
|
|
4685
|
+
for (const app of apps) services[app.name] = {
|
|
4686
|
+
image: app.ref,
|
|
4687
|
+
...app.build ? { build: {
|
|
4688
|
+
context: "../../..",
|
|
4689
|
+
dockerfile: app.build.dockerfile,
|
|
4690
|
+
...app.build.args ? { args: app.build.args } : {}
|
|
4691
|
+
} } : {},
|
|
4692
|
+
restart: "unless-stopped",
|
|
4693
|
+
...app.kind === "rest-api" ? {
|
|
4694
|
+
env_file: [{
|
|
4695
|
+
path: `./${app.name}.env`,
|
|
4696
|
+
format: "raw"
|
|
4697
|
+
}],
|
|
4698
|
+
...infra.length ? { depends_on: Object.fromEntries(infra.map((name) => [name, HEALTHY])) } : {}
|
|
4699
|
+
} : {},
|
|
4700
|
+
healthcheck: probe(app.port, app.kind === "site" ? "/" : "/health")
|
|
4701
|
+
};
|
|
4702
|
+
services.caddy = {
|
|
4703
|
+
image: require_sniffer.DEFAULT_IMAGES.caddy,
|
|
4704
|
+
restart: "unless-stopped",
|
|
4705
|
+
ports: [`${options.https}:443`, `${options.http}:80`],
|
|
4706
|
+
volumes: [
|
|
4707
|
+
"./Caddyfile:/etc/caddy/Caddyfile:ro",
|
|
4708
|
+
"caddy-data:/data",
|
|
4709
|
+
"caddy-config:/config"
|
|
4710
|
+
],
|
|
4711
|
+
depends_on: Object.fromEntries(apps.map((app) => [app.name, HEALTHY])),
|
|
4712
|
+
healthcheck: {
|
|
4713
|
+
test: [
|
|
4714
|
+
"CMD",
|
|
4715
|
+
"caddy",
|
|
4716
|
+
"version"
|
|
4717
|
+
],
|
|
4718
|
+
interval: "10s",
|
|
4719
|
+
timeout: "5s",
|
|
4720
|
+
retries: 5
|
|
4721
|
+
}
|
|
4722
|
+
};
|
|
4723
|
+
return {
|
|
4724
|
+
name: project,
|
|
4725
|
+
services,
|
|
4726
|
+
volumes
|
|
4727
|
+
};
|
|
4728
|
+
}
|
|
4729
|
+
/** Render an app's env file: one `KEY=value` per line, read raw by compose. */
|
|
4730
|
+
function envFile(env) {
|
|
4731
|
+
return `${Object.entries(env).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}=${value}`).join("\n")}\n`;
|
|
4732
|
+
}
|
|
4733
|
+
//#endregion
|
|
4734
|
+
//#region src/compose/index.ts
|
|
4735
|
+
/**
|
|
4736
|
+
* `gkm compose` — a workspace's APIs and sites for one stage, as one Docker
|
|
4737
|
+
* Compose stack behind Caddy.
|
|
4738
|
+
*
|
|
4739
|
+
* The order is the point of this file, and the rules it keeps are these:
|
|
4740
|
+
*
|
|
4741
|
+
* - A tag is checked before anything happens. Every app's image is asked of
|
|
4742
|
+
* the registry first; one missing image stops the run naming them all, and
|
|
4743
|
+
* nothing is written, pulled or started.
|
|
4744
|
+
* - The databases exist before the apps do. Postgres comes up alone, its
|
|
4745
|
+
* databases, roles and grants are created and its migrations applied from
|
|
4746
|
+
* this machine, and only then do the apps start — so an app never boots
|
|
4747
|
+
* against a schema that is not there yet.
|
|
4748
|
+
* - What a stage runs is recorded. Each app's tag and the digest it resolved
|
|
4749
|
+
* to are written to the stage's state, so "what is this stage running" has
|
|
4750
|
+
* an exact answer.
|
|
4751
|
+
*
|
|
4752
|
+
* Docker, the registry, the build and the clock are injected, so the rules are
|
|
4753
|
+
* asserted without a daemon; the defaults are the real ones.
|
|
4754
|
+
*/
|
|
4755
|
+
const logger$4 = console;
|
|
4756
|
+
/** `--build` and `--pull` together ask for two different things. */
|
|
4757
|
+
var ComposeModeConflict = class extends Error {
|
|
4758
|
+
constructor() {
|
|
4759
|
+
super("--build and --pull ask for opposite things: build images from this checkout, or pull a tag CI pushed. Pass one of them.");
|
|
4760
|
+
this.name = "ComposeModeConflict";
|
|
4761
|
+
}
|
|
4762
|
+
};
|
|
4763
|
+
/** A build with no commit to tag its images with. */
|
|
4764
|
+
var NoGitRevision = class extends Error {
|
|
4765
|
+
root;
|
|
4766
|
+
constructor(root) {
|
|
4767
|
+
super(`Images built here are tagged with the commit they were built from, and ${root} is not in a git repository with a commit. Commit, or pass --tag to name the images yourself.`);
|
|
4768
|
+
this.root = root;
|
|
4769
|
+
this.name = "NoGitRevision";
|
|
4770
|
+
}
|
|
4771
|
+
};
|
|
4772
|
+
/** A port variable that does not hold a port. */
|
|
4773
|
+
var EdgePortInvalid = class extends Error {
|
|
4774
|
+
variable;
|
|
4775
|
+
value;
|
|
4776
|
+
constructor(variable, value) {
|
|
4777
|
+
super(`${variable}=${value} is not a port. Set it to a number between 1 and 65535, or unset it for the default.`);
|
|
4778
|
+
this.variable = variable;
|
|
4779
|
+
this.value = value;
|
|
4780
|
+
this.name = "EdgePortInvalid";
|
|
4781
|
+
}
|
|
4782
|
+
};
|
|
4783
|
+
/** The commit HEAD is at, short — and `-dirty` when the tree has changes. */
|
|
4784
|
+
async function gitRevision(root) {
|
|
4785
|
+
let sha;
|
|
4786
|
+
try {
|
|
4787
|
+
sha = (await require_sniffer.runOutput("git", [
|
|
4788
|
+
"rev-parse",
|
|
4789
|
+
"--short",
|
|
4790
|
+
"HEAD"
|
|
4791
|
+
], { cwd: root })).trim();
|
|
4792
|
+
} catch {
|
|
4793
|
+
throw new NoGitRevision(root);
|
|
4794
|
+
}
|
|
4795
|
+
if (!sha) throw new NoGitRevision(root);
|
|
4796
|
+
return (await require_sniffer.runOutput("git", ["status", "--porcelain"], { cwd: root }).catch(() => "")).trim() ? `${sha}-dirty` : sha;
|
|
4797
|
+
}
|
|
4798
|
+
/**
|
|
4799
|
+
* Bundle a backend with the gkm that is running — the same build a deploy
|
|
4800
|
+
* makes, so the image holds one file and no dependencies.
|
|
4801
|
+
*/
|
|
4802
|
+
async function bundleWithThisCli(appRoot) {
|
|
4803
|
+
const entry = process.argv[1];
|
|
4804
|
+
if (!entry) return;
|
|
4805
|
+
await require_sniffer.run(process.execPath, [
|
|
4806
|
+
...process.execArgv,
|
|
4807
|
+
entry,
|
|
4808
|
+
"build",
|
|
4809
|
+
"--provider",
|
|
4810
|
+
"server",
|
|
4811
|
+
"--production"
|
|
4812
|
+
], { cwd: appRoot });
|
|
4813
|
+
}
|
|
4814
|
+
const defaultDeps = {
|
|
4815
|
+
docker: dockerCompose,
|
|
4816
|
+
bundle: bundleWithThisCli,
|
|
4817
|
+
revision: gitRevision
|
|
4818
|
+
};
|
|
4819
|
+
/** The edge's ports, from the environment — 443 and 80 when unset. */
|
|
4820
|
+
function edgePorts(env = process.env) {
|
|
4821
|
+
const read = (variable, fallback) => {
|
|
4822
|
+
const value = env[variable];
|
|
4823
|
+
if (value === void 0 || value === "") return fallback;
|
|
4824
|
+
const port = Number(value);
|
|
4825
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new EdgePortInvalid(variable, value);
|
|
4826
|
+
return port;
|
|
4827
|
+
};
|
|
4828
|
+
return {
|
|
4829
|
+
https: read(EDGE_PORT_ENV.https, 443),
|
|
4830
|
+
http: read(EDGE_PORT_ENV.http, 80)
|
|
4831
|
+
};
|
|
4832
|
+
}
|
|
4833
|
+
/** `gkm compose`. */
|
|
4834
|
+
async function composeCommand(options = {}, deps = {}) {
|
|
4835
|
+
const { docker, bundle, revision } = {
|
|
4836
|
+
...defaultDeps,
|
|
4837
|
+
...deps
|
|
4838
|
+
};
|
|
4839
|
+
const env = deps.env ?? process.env;
|
|
4840
|
+
if (options.build && options.pull) throw new ComposeModeConflict();
|
|
4841
|
+
const { workspace } = await require_config.loadWorkspaceConfig(options.cwd);
|
|
4842
|
+
const root = workspace.root;
|
|
4843
|
+
const stage = options.stage ?? workspace.stages.local;
|
|
4844
|
+
if (!(stage === workspace.stages.local)) require_workspace.assertDeployedStage(workspace.stages, stage);
|
|
4845
|
+
const identity = require_store.deployIdentity(workspace, stage);
|
|
4846
|
+
const dir = (0, node_path.join)(root, stackDir(stage));
|
|
4847
|
+
const file = (0, node_path.join)(dir, "docker-compose.yml");
|
|
4848
|
+
const ref = {
|
|
4849
|
+
project: composeProject(identity),
|
|
4850
|
+
file,
|
|
4851
|
+
cwd: root
|
|
4852
|
+
};
|
|
4853
|
+
if (options.down) {
|
|
4854
|
+
await docker.down(ref);
|
|
4855
|
+
logger$4.log(`🛑 Stopped ${ref.project}. Its volumes are kept.`);
|
|
4856
|
+
return;
|
|
4857
|
+
}
|
|
4858
|
+
const sources = {};
|
|
4859
|
+
const runnables = {};
|
|
4860
|
+
const manifest = await require_discover.discover({
|
|
4861
|
+
patterns: require_sniffer.constructGlobs(workspace),
|
|
4862
|
+
cwd: root,
|
|
4863
|
+
sources,
|
|
4864
|
+
runnables
|
|
4865
|
+
});
|
|
4866
|
+
const mode = options.build || !options.pull && !options.tag ? "build" : "pull";
|
|
4867
|
+
const tag = options.tag ?? (mode === "pull" ? "latest" : await revision(root));
|
|
4868
|
+
const { secrets, keep } = await stageSecrets(workspace, manifest, stage);
|
|
4869
|
+
const packageManager = require_sniffer.detectPackageManager(root);
|
|
4870
|
+
const stack = composeStack({
|
|
4871
|
+
workspace,
|
|
4872
|
+
manifest,
|
|
4873
|
+
runnables,
|
|
4874
|
+
stage,
|
|
4875
|
+
identity,
|
|
4876
|
+
images: {
|
|
4877
|
+
mode,
|
|
4878
|
+
tag,
|
|
4879
|
+
...workspace.deploy?.dokploy?.registry ? { registry: workspace.deploy.dokploy.registry } : {}
|
|
4880
|
+
},
|
|
4881
|
+
secrets,
|
|
4882
|
+
ports: edgePorts(env),
|
|
4883
|
+
packageManager,
|
|
4884
|
+
packages: Object.fromEntries(Object.entries(workspace.apps).map(([name, app]) => [name, require_sniffer.appPackageName(root, name, app.path)]))
|
|
4885
|
+
});
|
|
4886
|
+
if (mode === "pull" && !options.dryRun) await assertImagesExist(docker, tag, stack.apps);
|
|
4887
|
+
if (options.dryRun) keep.skip();
|
|
4888
|
+
else await keep.write();
|
|
4889
|
+
const files = await writeStack(root, dir, stack);
|
|
4890
|
+
printPlan(stack, mode, files);
|
|
4891
|
+
if (options.dryRun) return {
|
|
4892
|
+
stack,
|
|
4893
|
+
files
|
|
4894
|
+
};
|
|
4895
|
+
const store = deps.state ? await deps.state(workspace) : await require_sniffer.createStateStore({
|
|
4896
|
+
config: workspace.state,
|
|
4897
|
+
workspaceRoot: root,
|
|
4898
|
+
workspaceName: workspace.name
|
|
4899
|
+
});
|
|
4900
|
+
const lock = await store.lock(stage, { operation: "compose" });
|
|
4901
|
+
try {
|
|
4902
|
+
const apps = stack.apps.map((app) => app.name);
|
|
4903
|
+
if (mode === "build") {
|
|
4904
|
+
for (const app of stack.apps.filter((a) => a.kind === "rest-api")) {
|
|
4905
|
+
logger$4.log(`\n📦 Bundling ${app.name}…`);
|
|
4906
|
+
await bundle((0, node_path.join)(root, app.path));
|
|
4907
|
+
}
|
|
4908
|
+
logger$4.log("\n🐳 Building images…");
|
|
4909
|
+
await docker.build(ref, apps);
|
|
4910
|
+
} else {
|
|
4911
|
+
logger$4.log(`\n🐳 Pulling ${tag}…`);
|
|
4912
|
+
await docker.pull(ref, apps);
|
|
4913
|
+
}
|
|
4914
|
+
if (stack.infra.length > 0) {
|
|
4915
|
+
logger$4.log(`\n🗄️ Starting ${stack.infra.join(", ")}…`);
|
|
4916
|
+
await docker.up(ref, stack.infra);
|
|
4917
|
+
}
|
|
4918
|
+
if (stack.infra.includes("postgres")) await prepareDatabases(stack, workspace, manifest, sources, {
|
|
4919
|
+
docker,
|
|
4920
|
+
ref,
|
|
4921
|
+
sql: deps.sql ?? require_sniffer.pgClient,
|
|
4922
|
+
migrate: deps.migrate ?? migrateDatabases
|
|
4923
|
+
});
|
|
4924
|
+
logger$4.log("\n🚀 Starting the stack…");
|
|
4925
|
+
await docker.up(ref);
|
|
4926
|
+
if (stack.local) await docker.copyOut(ref, "caddy", "/data/caddy/pki/authorities/local/root.crt", (0, node_path.join)(dir, "caddy-root.crt")).catch(() => {});
|
|
4927
|
+
const images = await recordImages(store, stack, docker, mode);
|
|
4928
|
+
logger$4.log(`\n✅ ${ref.project} is running:`);
|
|
4929
|
+
for (const app of stack.apps) logger$4.log(` ${app.name.padEnd(12)} ${app.url}`);
|
|
4930
|
+
if (stack.local) logger$4.log(`\n🔐 Certificates are from Caddy's local CA: NODE_EXTRA_CA_CERTS=${(0, node_path.join)(stackDir(stage), "caddy-root.crt")}`);
|
|
4931
|
+
return {
|
|
4932
|
+
stack,
|
|
4933
|
+
files,
|
|
4934
|
+
images
|
|
4935
|
+
};
|
|
4936
|
+
} finally {
|
|
4937
|
+
await lock.release();
|
|
4938
|
+
}
|
|
4939
|
+
}
|
|
4940
|
+
/**
|
|
4941
|
+
* The stage's secrets — and, for a deployed stage, everything it generates
|
|
4942
|
+
* once: its seed, and each declared secret and keyring. Written back before
|
|
4943
|
+
* anything is derived from them, so the next run derives the same passwords.
|
|
4944
|
+
*/
|
|
4945
|
+
async function stageSecrets(workspace, manifest, stage) {
|
|
4946
|
+
const store = await require_store.secretsStoreFor(workspace, stage);
|
|
4947
|
+
const stored = await store.read(stage);
|
|
4948
|
+
const nothing = {
|
|
4949
|
+
write: async () => {},
|
|
4950
|
+
skip: () => {}
|
|
4951
|
+
};
|
|
4952
|
+
if (stage === workspace.stages.local) return {
|
|
4953
|
+
secrets: stored,
|
|
4954
|
+
keep: nothing
|
|
4955
|
+
};
|
|
4956
|
+
const { secrets, generated } = require_sniffer.withGeneratedSecrets(stored ?? require_storage.initStageSecrets(stage), manifest);
|
|
4957
|
+
if (generated.length === 0) return {
|
|
4958
|
+
secrets,
|
|
4959
|
+
keep: nothing
|
|
4960
|
+
};
|
|
4961
|
+
return {
|
|
4962
|
+
secrets,
|
|
4963
|
+
keep: {
|
|
4964
|
+
async write() {
|
|
4965
|
+
await store.write(stage, secrets);
|
|
4966
|
+
logger$4.log(`🔑 Generated for "${stage}" (${store.name}): ${generated.join(", ")}`);
|
|
4967
|
+
},
|
|
4968
|
+
skip() {
|
|
4969
|
+
logger$4.log(`🔑 "${stage}" has no ${generated.join(", ")} yet; this dry run used values it did not keep.`);
|
|
4970
|
+
}
|
|
4971
|
+
}
|
|
4972
|
+
};
|
|
4973
|
+
}
|
|
4974
|
+
/** Write the stack's files: owner-only, since the env files hold secrets. */
|
|
4975
|
+
async function writeStack(root, dir, stack) {
|
|
4976
|
+
await (0, node_fs_promises.mkdir)(dir, {
|
|
4977
|
+
recursive: true,
|
|
4978
|
+
mode: 448
|
|
4979
|
+
});
|
|
4980
|
+
await (0, node_fs_promises.chmod)(dir, 448);
|
|
4981
|
+
const files = [];
|
|
4982
|
+
const write = async (path, content, mode = 420) => {
|
|
4983
|
+
await (0, node_fs_promises.mkdir)((0, node_path.dirname)(path), { recursive: true });
|
|
4984
|
+
await (0, node_fs_promises.writeFile)(path, content, { mode });
|
|
4985
|
+
await (0, node_fs_promises.chmod)(path, mode);
|
|
4986
|
+
files.push(path);
|
|
4987
|
+
};
|
|
4988
|
+
await write((0, node_path.join)(dir, "docker-compose.yml"), composeYaml(stack));
|
|
4989
|
+
await write((0, node_path.join)(dir, "Caddyfile"), stack.caddyfile);
|
|
4990
|
+
for (const app of stack.apps) if (app.env) await write((0, node_path.join)(dir, `${app.name}.env`), envFile(app.env), 384);
|
|
4991
|
+
for (const [path, content] of Object.entries(stack.dockerfiles)) await write((0, node_path.join)(root, path), content);
|
|
4992
|
+
await ignoreStacks(root);
|
|
4993
|
+
return files;
|
|
4994
|
+
}
|
|
4995
|
+
/** Make sure the build context leaves `.gkm/compose` out. */
|
|
4996
|
+
async function ignoreStacks(root) {
|
|
4997
|
+
const path = (0, node_path.join)(root, ".dockerignore");
|
|
4998
|
+
if (!(0, node_fs.existsSync)(path)) {
|
|
4999
|
+
await (0, node_fs_promises.writeFile)(path, require_sniffer.generateDockerignore());
|
|
5000
|
+
return;
|
|
5001
|
+
}
|
|
5002
|
+
const current = await (0, node_fs_promises.readFile)(path, "utf-8");
|
|
5003
|
+
if (/^\/?\.gkm\/compose\/?$|^\/?\.gkm\/?$/m.test(current)) return;
|
|
5004
|
+
await (0, node_fs_promises.appendFile)(path, `${current.endsWith("\n") ? "" : "\n"}\n# gkm compose's stacks: each app's env file holds its stage's secrets\n.gkm/compose\n`);
|
|
5005
|
+
}
|
|
5006
|
+
function composeYaml(stack) {
|
|
5007
|
+
return `# Generated by gkm compose from the construct manifest — do not edit.
|
|
5008
|
+
# The ${stack.stage} stage's APIs and sites behind one Caddy. Each backend
|
|
5009
|
+
# reads exactly the keys in its own env file beside this one.
|
|
5010
|
+
#
|
|
5011
|
+
# gkm compose${stack.local ? "" : ` --stage ${stack.stage}`} start or update it
|
|
5012
|
+
# gkm compose${stack.local ? "" : ` --stage ${stack.stage}`} --down stop it
|
|
5013
|
+
${(0, yaml.stringify)(stack.compose, {
|
|
5014
|
+
lineWidth: 0,
|
|
5015
|
+
aliasDuplicateObjects: false
|
|
5016
|
+
})}`;
|
|
5017
|
+
}
|
|
5018
|
+
/** Create what the plan names in the stack's Postgres, then migrate it. */
|
|
5019
|
+
async function prepareDatabases(stack, workspace, manifest, sources, deps) {
|
|
5020
|
+
const port = await deps.docker.port(deps.ref, "postgres", 5432);
|
|
5021
|
+
const statements = require_sniffer.postgresStatements(stack.plan, workspace.name, stack.credential.seed);
|
|
5022
|
+
if (statements.length > 0) {
|
|
5023
|
+
logger$4.log("🗄️ Creating databases, roles and grants…");
|
|
5024
|
+
await require_sniffer.applyPostgres(deps.sql(port, stack.credential.master), statements);
|
|
5025
|
+
}
|
|
5026
|
+
const env = require_sniffer.envFor(stack.plan, {
|
|
5027
|
+
ports: { [require_sniffer.primaryPortKey("postgres")]: port },
|
|
5028
|
+
project: workspace.name,
|
|
5029
|
+
master: stack.credential.master,
|
|
5030
|
+
...stack.credential.seed ? { seed: stack.credential.seed } : {}
|
|
5031
|
+
});
|
|
5032
|
+
const runs = await deps.migrate({
|
|
5033
|
+
root: workspace.root,
|
|
5034
|
+
manifest,
|
|
5035
|
+
sources,
|
|
5036
|
+
env
|
|
5037
|
+
});
|
|
5038
|
+
for (const { target, applied } of runs) if (applied.length > 0) logger$4.log(`🗄️ ${target.migrations}: applied ${applied.length}`);
|
|
5039
|
+
}
|
|
5040
|
+
/**
|
|
5041
|
+
* Record each app's image in the stage's state: the tag it ran and the digest
|
|
5042
|
+
* the tag resolved to — or, for an image built here and never pushed, its
|
|
5043
|
+
* content id.
|
|
5044
|
+
*/
|
|
5045
|
+
async function recordImages(store, stack, docker, mode) {
|
|
5046
|
+
if (!await store.read(stack.stage)) await store.write(stack.stage, require_sniffer.createEmptyState(stack.stage, "", ""), { expectedVersion: null });
|
|
5047
|
+
const images = {};
|
|
5048
|
+
for (const app of stack.apps) {
|
|
5049
|
+
const digest = await docker.digest(app.ref);
|
|
5050
|
+
images[app.name] = {
|
|
5051
|
+
ref: app.ref,
|
|
5052
|
+
tag: app.tag,
|
|
5053
|
+
...digest ? { digest } : {}
|
|
5054
|
+
};
|
|
5055
|
+
await store.putResource(stack.stage, {
|
|
5056
|
+
key: `compose:${app.name}`,
|
|
5057
|
+
type: "compose-image",
|
|
5058
|
+
status: "ready",
|
|
5059
|
+
...digest ? { id: digest } : {},
|
|
5060
|
+
data: {
|
|
5061
|
+
project: stack.project,
|
|
5062
|
+
ref: app.ref,
|
|
5063
|
+
tag: app.tag,
|
|
5064
|
+
source: mode,
|
|
5065
|
+
...digest ? { digest } : {}
|
|
5066
|
+
}
|
|
5067
|
+
});
|
|
5068
|
+
}
|
|
5069
|
+
return images;
|
|
5070
|
+
}
|
|
5071
|
+
function printPlan(stack, mode, files) {
|
|
5072
|
+
logger$4.log(`\n🧱 ${stack.project} — stage ${stack.stage}`);
|
|
5073
|
+
for (const app of stack.apps) logger$4.log(` ${app.name.padEnd(12)} ${app.url.padEnd(40)} ${mode === "build" ? "build" : "pull"} ${app.ref}`);
|
|
5074
|
+
if (stack.infra.length > 0) logger$4.log(` infrastructure: ${stack.infra.join(", ")}`);
|
|
5075
|
+
logger$4.log(`\n📝 Wrote ${files.length} file(s) under ${stackDir(stack.stage)}/`);
|
|
5076
|
+
}
|
|
3521
5077
|
/**
|
|
3522
5078
|
* Format a fatal error for display.
|
|
3523
5079
|
* Always includes the full stack trace since these are process-ending errors.
|
|
@@ -4923,25 +6479,25 @@ function loadPackageJson() {
|
|
|
4923
6479
|
* Run: pnpm --filter @geekmidas/cli sync-versions
|
|
4924
6480
|
*/
|
|
4925
6481
|
const GEEKMIDAS_VERSIONS = {
|
|
4926
|
-
"@geekmidas/audit": "~10.0.0-alpha.
|
|
4927
|
-
"@geekmidas/auth": "~10.0.0-alpha.
|
|
4928
|
-
"@geekmidas/cache": "~10.0.0-alpha.
|
|
4929
|
-
"@geekmidas/client": "~10.0.0-alpha.
|
|
4930
|
-
"@geekmidas/cloud": "~10.0.0-alpha.
|
|
4931
|
-
"@geekmidas/constructs": "~10.0.0-alpha.
|
|
4932
|
-
"@geekmidas/db": "~10.0.0-alpha.
|
|
4933
|
-
"@geekmidas/emailkit": "~10.0.0-alpha.
|
|
4934
|
-
"@geekmidas/envkit": "~10.0.0-alpha.
|
|
4935
|
-
"@geekmidas/errors": "~10.0.0-alpha.
|
|
4936
|
-
"@geekmidas/events": "~10.0.0-alpha.
|
|
4937
|
-
"@geekmidas/logger": "~10.0.0-alpha.
|
|
4938
|
-
"@geekmidas/manifest": "~10.0.0-alpha.
|
|
4939
|
-
"@geekmidas/rate-limit": "~10.0.0-alpha.
|
|
4940
|
-
"@geekmidas/schema": "~10.0.0-alpha.
|
|
4941
|
-
"@geekmidas/services": "~10.0.0-alpha.
|
|
4942
|
-
"@geekmidas/storage": "~10.0.0-alpha.
|
|
4943
|
-
"@geekmidas/telescope": "~10.0.0-alpha.
|
|
4944
|
-
"@geekmidas/testkit": "~10.0.0-alpha.
|
|
6482
|
+
"@geekmidas/audit": "~10.0.0-alpha.61",
|
|
6483
|
+
"@geekmidas/auth": "~10.0.0-alpha.61",
|
|
6484
|
+
"@geekmidas/cache": "~10.0.0-alpha.61",
|
|
6485
|
+
"@geekmidas/client": "~10.0.0-alpha.61",
|
|
6486
|
+
"@geekmidas/cloud": "~10.0.0-alpha.61",
|
|
6487
|
+
"@geekmidas/constructs": "~10.0.0-alpha.61",
|
|
6488
|
+
"@geekmidas/db": "~10.0.0-alpha.61",
|
|
6489
|
+
"@geekmidas/emailkit": "~10.0.0-alpha.61",
|
|
6490
|
+
"@geekmidas/envkit": "~10.0.0-alpha.61",
|
|
6491
|
+
"@geekmidas/errors": "~10.0.0-alpha.61",
|
|
6492
|
+
"@geekmidas/events": "~10.0.0-alpha.61",
|
|
6493
|
+
"@geekmidas/logger": "~10.0.0-alpha.61",
|
|
6494
|
+
"@geekmidas/manifest": "~10.0.0-alpha.61",
|
|
6495
|
+
"@geekmidas/rate-limit": "~10.0.0-alpha.61",
|
|
6496
|
+
"@geekmidas/schema": "~10.0.0-alpha.61",
|
|
6497
|
+
"@geekmidas/services": "~10.0.0-alpha.61",
|
|
6498
|
+
"@geekmidas/storage": "~10.0.0-alpha.61",
|
|
6499
|
+
"@geekmidas/telescope": "~10.0.0-alpha.61",
|
|
6500
|
+
"@geekmidas/testkit": "~10.0.0-alpha.61",
|
|
4945
6501
|
"@geekmidas/cli": `~${loadPackageJson().version}`
|
|
4946
6502
|
};
|
|
4947
6503
|
//#endregion
|
|
@@ -10842,7 +12398,7 @@ function testDatabases(reconciled) {
|
|
|
10842
12398
|
* starts without the reference data the app needs.
|
|
10843
12399
|
*/
|
|
10844
12400
|
async function migrateTestStage(workspace, env) {
|
|
10845
|
-
const { migrateAndSeed } = await Promise.resolve().then(() => require("./migrate-
|
|
12401
|
+
const { migrateAndSeed } = await Promise.resolve().then(() => require("./migrate-BScRpCEx.cjs"));
|
|
10846
12402
|
const { discover } = await Promise.resolve().then(() => require("./discover-Bc-4eA_p.cjs")).then((n) => n.discover_exports);
|
|
10847
12403
|
const sources = {};
|
|
10848
12404
|
const manifest = await discover({
|
|
@@ -11273,7 +12829,7 @@ program.command("migrate").description("Apply each database construct's migratio
|
|
|
11273
12829
|
try {
|
|
11274
12830
|
const globalOptions = program.opts();
|
|
11275
12831
|
if (globalOptions.cwd) process.chdir(globalOptions.cwd);
|
|
11276
|
-
const { migrateCommand } = await Promise.resolve().then(() => require("./migrate-
|
|
12832
|
+
const { migrateCommand } = await Promise.resolve().then(() => require("./migrate-BScRpCEx.cjs"));
|
|
11277
12833
|
await migrateCommand({
|
|
11278
12834
|
...options,
|
|
11279
12835
|
...construct ? { construct } : {}
|
|
@@ -11287,7 +12843,7 @@ program.command("seed").description("Migrate, then run each database construct's
|
|
|
11287
12843
|
try {
|
|
11288
12844
|
const globalOptions = program.opts();
|
|
11289
12845
|
if (globalOptions.cwd) process.chdir(globalOptions.cwd);
|
|
11290
|
-
const { seedCommand } = await Promise.resolve().then(() => require("./migrate-
|
|
12846
|
+
const { seedCommand } = await Promise.resolve().then(() => require("./migrate-BScRpCEx.cjs"));
|
|
11291
12847
|
await seedCommand({
|
|
11292
12848
|
...options,
|
|
11293
12849
|
...construct ? { construct } : {}
|
|
@@ -11301,7 +12857,7 @@ program.command("migration").description("Write a construct's next migration: an
|
|
|
11301
12857
|
try {
|
|
11302
12858
|
const globalOptions = program.opts();
|
|
11303
12859
|
if (globalOptions.cwd) process.chdir(globalOptions.cwd);
|
|
11304
|
-
const { migrationCommand } = await Promise.resolve().then(() => require("./migrate-
|
|
12860
|
+
const { migrationCommand } = await Promise.resolve().then(() => require("./migrate-BScRpCEx.cjs"));
|
|
11305
12861
|
await migrationCommand({
|
|
11306
12862
|
construct,
|
|
11307
12863
|
...name ? { name } : {}
|
|
@@ -11346,6 +12902,16 @@ program.command("docker").description("Generate Docker deployment files").option
|
|
|
11346
12902
|
process.exit(1);
|
|
11347
12903
|
}
|
|
11348
12904
|
});
|
|
12905
|
+
program.command("compose").description("Run the workspace's APIs and sites for a stage as one Docker Compose stack behind Caddy").option("--stage <stage>", "Stage to run (default: the local stage)").option("--tag <tag>", "Run the images CI pushed at this tag (sites: <tag>-<stage>); nothing is built").option("--build", "Build images from this checkout, tagged with the commit").option("--pull", "Pull images (at --tag, or latest) rather than build them").option("--dry-run", "Write the files and print the plan; start nothing").option("--down", "Stop the stage's stack (its volumes are kept)").action(async (options) => {
|
|
12906
|
+
try {
|
|
12907
|
+
const globalOptions = program.opts();
|
|
12908
|
+
if (globalOptions.cwd) process.chdir(globalOptions.cwd);
|
|
12909
|
+
await composeCommand(options);
|
|
12910
|
+
} catch (error) {
|
|
12911
|
+
console.error(formatError(error));
|
|
12912
|
+
process.exit(1);
|
|
12913
|
+
}
|
|
12914
|
+
});
|
|
11349
12915
|
program.command("prepack").description("Generate Docker files for production deployment").option("--build", "Build Docker image after generating files").option("--push", "Push image to registry after building").option("--tag <tag>", "Image tag", "latest").option("--registry <registry>", "Container registry URL").option("--slim", "Build locally first, then use slim Dockerfile").option("--skip-bundle", "Skip bundling step (only with --slim)").option("--turbo", "Use turbo prune for monorepo optimization").option("--turbo-package <name>", "Package name for turbo prune").action(async (options) => {
|
|
11350
12916
|
try {
|
|
11351
12917
|
const globalOptions = program.opts();
|
|
@@ -11399,7 +12965,7 @@ program.command("encryption:rotate").description("Add a new current key to an En
|
|
|
11399
12965
|
try {
|
|
11400
12966
|
const globalOptions = program.opts();
|
|
11401
12967
|
if (globalOptions.cwd) process.chdir(globalOptions.cwd);
|
|
11402
|
-
const { encryptionRotateCommand } = await Promise.resolve().then(() => require("./encryption-
|
|
12968
|
+
const { encryptionRotateCommand } = await Promise.resolve().then(() => require("./encryption-ojzUo7-z.cjs"));
|
|
11403
12969
|
await encryptionRotateCommand(construct, options);
|
|
11404
12970
|
} catch (error) {
|
|
11405
12971
|
console.error(formatError(error));
|
|
@@ -11410,7 +12976,7 @@ program.command("encryption:retire").description("Remove an old key from an Encr
|
|
|
11410
12976
|
try {
|
|
11411
12977
|
const globalOptions = program.opts();
|
|
11412
12978
|
if (globalOptions.cwd) process.chdir(globalOptions.cwd);
|
|
11413
|
-
const { encryptionRetireCommand } = await Promise.resolve().then(() => require("./encryption-
|
|
12979
|
+
const { encryptionRetireCommand } = await Promise.resolve().then(() => require("./encryption-ojzUo7-z.cjs"));
|
|
11414
12980
|
await encryptionRetireCommand(construct, key, options);
|
|
11415
12981
|
} catch (error) {
|
|
11416
12982
|
console.error(formatError(error));
|
|
@@ -11462,7 +13028,7 @@ program.command("secrets:reconcile").description("Backfill missing custom secret
|
|
|
11462
13028
|
console.error(`No secrets found for stage "${stage}". Run "gkm secrets:init --stage ${stage}" first.`);
|
|
11463
13029
|
process.exit(1);
|
|
11464
13030
|
}
|
|
11465
|
-
const { derivedContainers } = await Promise.resolve().then(() => require("./sniffer-
|
|
13031
|
+
const { derivedContainers } = await Promise.resolve().then(() => require("./sniffer-CQmJnNJM.cjs")).then((n) => n.workspace_exports);
|
|
11466
13032
|
const result = reconcileMissingSecrets(secrets, workspace, await derivedContainers(workspace, stage));
|
|
11467
13033
|
if (!result) {
|
|
11468
13034
|
console.log(`\n✓ Secrets for stage "${stage}" are up-to-date`);
|
|
@@ -11477,7 +13043,7 @@ program.command("secrets:reconcile").description("Backfill missing custom secret
|
|
|
11477
13043
|
}
|
|
11478
13044
|
});
|
|
11479
13045
|
program.command("deploy").description("Deploy application to a provider").requiredOption("--provider <provider>", "Deploy provider (docker, dokploy, aws-lambda)").requiredOption("--stage <stage>", "Deployment stage (e.g., production, staging)").option("--tag <tag>", "Image tag (default: stage-timestamp)").option("--skip-push", "Skip pushing image to registry").option("--skip-build", "Skip build step (use existing build)").option("--json", "Write events as JSON lines instead of progress; never prompts").option("--dry-run", "Show what would be created or reused; change, build and push nothing").action(async (options) => {
|
|
11480
|
-
const { deployCli } = await Promise.resolve().then(() => require("./cli-
|
|
13046
|
+
const { deployCli } = await Promise.resolve().then(() => require("./cli-DQdrVkj2.cjs"));
|
|
11481
13047
|
const globalOptions = program.opts();
|
|
11482
13048
|
const code = await deployCli({
|
|
11483
13049
|
cwd: (0, node_path.resolve)(globalOptions.cwd ?? process.cwd()),
|
|
@@ -11644,8 +13210,23 @@ program.command("upgrade").description("Upgrade @geekmidas/cli (with --all, ever
|
|
|
11644
13210
|
});
|
|
11645
13211
|
program.parse();
|
|
11646
13212
|
//#endregion
|
|
13213
|
+
exports.DuplicateMigration = DuplicateMigration;
|
|
13214
|
+
exports.KyselyNotFound = KyselyNotFound;
|
|
13215
|
+
exports.MigrationFailed = MigrationFailed;
|
|
13216
|
+
exports.MigrationHasNoUp = MigrationHasNoUp;
|
|
13217
|
+
exports.MigrationsOutsideFolder = MigrationsOutsideFolder;
|
|
13218
|
+
exports.NoOwnerCredential = NoOwnerCredential;
|
|
13219
|
+
exports.NoSuchMigrationTarget = NoSuchMigrationTarget;
|
|
13220
|
+
exports.SeedFailed = SeedFailed;
|
|
13221
|
+
exports.SeedHasNoSeed = SeedHasNoSeed;
|
|
13222
|
+
exports.UnknownDatabaseFolder = UnknownDatabaseFolder;
|
|
13223
|
+
exports.UnknownMigrationFolder = UnknownMigrationFolder;
|
|
11647
13224
|
exports.formatError = formatError;
|
|
11648
13225
|
exports.generateFullstackCustomSecrets = generateFullstackCustomSecrets;
|
|
13226
|
+
exports.migrateAndSeed = migrateAndSeed;
|
|
13227
|
+
exports.migrateDatabases = migrateDatabases;
|
|
13228
|
+
exports.pendingMigrations = pendingMigrations;
|
|
13229
|
+
exports.seedDatabases = seedDatabases;
|
|
11649
13230
|
exports.slug = slug;
|
|
11650
13231
|
exports.stamp = stamp;
|
|
11651
13232
|
|