@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.mjs
CHANGED
|
@@ -1,32 +1,34 @@
|
|
|
1
1
|
#!/usr/bin/env -S npx tsx
|
|
2
2
|
import { n as __require, t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
|
|
3
|
-
import { $ as
|
|
3
|
+
import { $ as quoteIdentifier, At as getDokployCredentials, B as run$1, Ct as loadEnvFiles, Dt as logoutCommand, Et as loginCommand, G as derivedContainers, H as FAKE_ENV, I as withGeneratedSecrets, J as LOCAL_CA_PATH, K as reconcileWorkspace, Mt as getDokployToken, P as createStateStore, Q as postgresStatements, St as isPortAvailable, Tt as prepareEntryCredentials, U as backendsOf$1, V as runOutput, W as constructGlobs, X as applyPostgres, Y as forgetState, Z as postgresDatabaseNames, _t as primaryPortKey, at as localRolePassword, bt as createEntryWrapper, c as dockerCommand, ct as hostFor, d as generateDockerignore, dt as providerOf, et as composeFor, f as generateSlimDockerfile, g as createEmptyState, gt as portKeys, h as resolveHost, ht as fakeKey, it as envFor, jt as getDokployRegistryId, kt as whoamiCommand, l as siteDockerfile, lt as cacheBackendFor, m as isMainFrontendApp, mt as describeServices, n as sniffAppEnvironment, nt as appEnvKeys, ot as planFor, p as validateImageRef, pt as DEFAULT_IMAGES, r as DokployApi, rt as networkEnv, s as appPackageName$1, tt as pgClient, u as detectPackageManager$2, vt as withOwningTsconfigJsx, wt as loadSecretsForApp, xt as findAvailablePort, yt as createCredentialsPreload, z as keyFingerprint } from "./sniffer-bdleks5l.mjs";
|
|
4
4
|
import { i as ConstructGenerator, t as discover } from "./discover-FrYvzkeC.mjs";
|
|
5
5
|
import { d as parseModuleConfig, f as appKey, l as loadWorkspaceConfig, n as NotABackendApp, o as loadAppConfig, r as NotInAnApp, s as loadConfig, t as ConfigNotFound, u as loadWorkspaceSettings } from "./config-C5-EEdmr.mjs";
|
|
6
|
-
import { E as TEST_STAGE, O as deployedProblems, T as InvalidStages, a as getAppBuildOrder, k as stageProblems, o as getAppGkmConfig, t as allConstructGlobs } from "./workspace-Bp1xVaOz.mjs";
|
|
7
|
-
import { a as withCustomSecret, i as toEmbeddableSecrets, n as maskPassword } from "./storage-BzaUxmqe.mjs";
|
|
8
|
-
import { a as getKeyPath, i as FileSecretsStore, n as secretsStoreFor, s as projectKey } from "./store-BS8A7NcA.mjs";
|
|
9
|
-
import { i as telemetryFor, n as EndpointGenerator, r as runtimeFor } from "./OpenApiTsGenerator-
|
|
6
|
+
import { D as assertDeployedStage, E as TEST_STAGE, O as deployedProblems, T as InvalidStages, a as getAppBuildOrder, k as stageProblems, o as getAppGkmConfig, t as allConstructGlobs } from "./workspace-Bp1xVaOz.mjs";
|
|
7
|
+
import { a as withCustomSecret, i as toEmbeddableSecrets, n as maskPassword, t as initStageSecrets } from "./storage-BzaUxmqe.mjs";
|
|
8
|
+
import { a as getKeyPath, f as imageRef, i as FileSecretsStore, n as secretsStoreFor, s as projectKey, u as deployIdentity } from "./store-BS8A7NcA.mjs";
|
|
9
|
+
import { i as telemetryFor, n as EndpointGenerator, r as runtimeFor } from "./OpenApiTsGenerator-q5B0z8Xg.mjs";
|
|
10
10
|
import { generateOpenApi, openapiCommand, resolveOpenApiConfig } from "./openapi.mjs";
|
|
11
|
+
import { n as DEFAULT_CACHE, r as DEFAULT_EVENTS } from "./fakes-C9RiT6XG.mjs";
|
|
11
12
|
import { a as describeHolder, i as assignAppPorts, o as holderOf, r as appTag, s as withAppPorts, t as APP_TAG_ENV } from "./appPorts-C9z9Pzn9.mjs";
|
|
12
|
-
import { a as writeTestHarness, c as driversFor, i as TEST_MANIFEST_ENV, n as TEST_READY_FILE, o as SubscriberGenerator, s as cacheBackendsIn, t as TEST_READY_ENV } from "./ready-
|
|
13
|
+
import { a as writeTestHarness, c as driversFor, i as TEST_MANIFEST_ENV, n as TEST_READY_FILE, o as SubscriberGenerator, s as cacheBackendsIn, t as TEST_READY_ENV } from "./ready-DQIy21W5.mjs";
|
|
13
14
|
import { n as withCompute, r as withRoutes, t as manifestModule } from "./emit-C1e8qD08.mjs";
|
|
14
|
-
import { t as LocalStateStore } from "./LocalStateStore-
|
|
15
|
+
import { t as LocalStateStore } from "./LocalStateStore-DfUwQsL8.mjs";
|
|
15
16
|
import { createRequire } from "node:module";
|
|
16
|
-
import { basename, dirname, isAbsolute, join, parse, relative, resolve } from "node:path";
|
|
17
|
+
import { basename, dirname, extname, isAbsolute, join, parse, relative, resolve } from "node:path";
|
|
17
18
|
import { Command } from "commander";
|
|
18
19
|
import { existsSync, linkSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
19
|
-
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
20
|
+
import { appendFile, chmod, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
20
21
|
import { homedir } from "node:os";
|
|
21
22
|
import { promisify } from "node:util";
|
|
22
|
-
import { canonicalId, kebabCase, migrationFolder, provideKey, schemeBase, serviceKey } from "@geekmidas/manifest";
|
|
23
|
+
import { DATABASE_FOLDERS, MIGRATIONS_ROOT, canonicalId, kebabCase, migrationFolder, migrationTargets, provideKey, provisionOrder, publicEnvFor, schemeBase, serviceKey } from "@geekmidas/manifest";
|
|
23
24
|
import fg from "fast-glob";
|
|
24
25
|
import { execFile, execSync, spawn } from "node:child_process";
|
|
25
26
|
import chokidar from "chokidar";
|
|
26
27
|
import { connect } from "node:net";
|
|
27
28
|
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
28
29
|
import { pathToFileURL } from "node:url";
|
|
29
|
-
import
|
|
30
|
+
import pg from "pg";
|
|
31
|
+
import { parse as parse$1, parseDocument, stringify } from "yaml";
|
|
30
32
|
import prompts from "prompts";
|
|
31
33
|
import { createServer as createServer$1 } from "node:http";
|
|
32
34
|
import { Readable } from "node:stream";
|
|
@@ -39,7 +41,7 @@ import { Function } from "@geekmidas/constructs/functions";
|
|
|
39
41
|
import { Queue } from "@geekmidas/constructs/queue";
|
|
40
42
|
import { Topic } from "@geekmidas/constructs/topic";
|
|
41
43
|
//#region package.json
|
|
42
|
-
var version = "10.0.0-alpha.
|
|
44
|
+
var version = "10.0.0-alpha.61";
|
|
43
45
|
//#endregion
|
|
44
46
|
//#region src/trust/index.ts
|
|
45
47
|
/**
|
|
@@ -56,7 +58,7 @@ var version = "10.0.0-alpha.60";
|
|
|
56
58
|
* worse trade than one that tells you what to type. Caddy's own `caddy trust`
|
|
57
59
|
* draws the line in the same place.
|
|
58
60
|
*/
|
|
59
|
-
const logger$
|
|
61
|
+
const logger$8 = console;
|
|
60
62
|
const run = promisify(execFile);
|
|
61
63
|
/**
|
|
62
64
|
* How each platform is told about a new authority.
|
|
@@ -108,15 +110,15 @@ async function trustCommand(options = {}) {
|
|
|
108
110
|
const certificate = join(workspace.root, LOCAL_CA_PATH);
|
|
109
111
|
if (!existsSync(certificate)) throw new NoLocalAuthority(certificate);
|
|
110
112
|
const { describe, asks, steps } = installation(process.platform, certificate);
|
|
111
|
-
logger$
|
|
112
|
-
logger$
|
|
113
|
+
logger$8.log(`\n🔐 Trusting the local authority in ${describe}`);
|
|
114
|
+
logger$8.log(` ${certificate}\n`);
|
|
113
115
|
if (options.dryRun) {
|
|
114
|
-
for (const [command, args] of steps) logger$
|
|
116
|
+
for (const [command, args] of steps) logger$8.log(` ${command} ${args.join(" ")}`);
|
|
115
117
|
return;
|
|
116
118
|
}
|
|
117
|
-
logger$
|
|
119
|
+
logger$8.log(` ${asks}\n`);
|
|
118
120
|
for (const [command, args] of steps) await run(command, args, { stdio: "inherit" });
|
|
119
|
-
logger$
|
|
121
|
+
logger$8.log("\n✅ Local https addresses are trusted. Restart your browser.\n");
|
|
120
122
|
}
|
|
121
123
|
/**
|
|
122
124
|
* Trust the authority if this machine lacks it and its owner agrees.
|
|
@@ -133,7 +135,7 @@ async function ensureTrusted(root, url, options = {}) {
|
|
|
133
135
|
if (await isTrusted(url)) return;
|
|
134
136
|
if (!options.assumeYes) {
|
|
135
137
|
if (!process.stdin.isTTY) {
|
|
136
|
-
logger$
|
|
138
|
+
logger$8.log(" Local https addresses are not trusted here. Run \"gkm trust\", or \"gkm setup --yes\".");
|
|
137
139
|
return;
|
|
138
140
|
}
|
|
139
141
|
if (await declined(root)) return;
|
|
@@ -145,7 +147,7 @@ async function ensureTrusted(root, url, options = {}) {
|
|
|
145
147
|
});
|
|
146
148
|
if (!install) {
|
|
147
149
|
await recordDeclined(root);
|
|
148
|
-
logger$
|
|
150
|
+
logger$8.log(" Skipped. Run \"gkm trust\" later.");
|
|
149
151
|
return;
|
|
150
152
|
}
|
|
151
153
|
}
|
|
@@ -1310,7 +1312,7 @@ function closeFakes(served) {
|
|
|
1310
1312
|
}
|
|
1311
1313
|
//#endregion
|
|
1312
1314
|
//#region src/exec/index.ts
|
|
1313
|
-
const logger$
|
|
1315
|
+
const logger$7 = console;
|
|
1314
1316
|
/** `gkm exec` was given nothing to run. */
|
|
1315
1317
|
var NoCommandSpecified = class extends Error {
|
|
1316
1318
|
constructor() {
|
|
@@ -1333,17 +1335,17 @@ async function execCommand(commandArgs, options = {}) {
|
|
|
1333
1335
|
const cwd = options.cwd ?? process.cwd();
|
|
1334
1336
|
if (!commandArgs[0]) throw new NoCommandSpecified();
|
|
1335
1337
|
const defaultEnv = loadEnvFiles(".env");
|
|
1336
|
-
if (defaultEnv.loaded.length > 0) logger$
|
|
1338
|
+
if (defaultEnv.loaded.length > 0) logger$7.log(`📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
|
|
1337
1339
|
const { credentials, secretsJsonPath, appName } = await prepareEntryCredentials({ cwd });
|
|
1338
|
-
if (appName) logger$
|
|
1340
|
+
if (appName) logger$7.log(`📦 App: ${appName}`);
|
|
1339
1341
|
const secretCount = Object.keys(credentials).filter((k) => k !== "PORT").length;
|
|
1340
|
-
if (secretCount > 0) logger$
|
|
1342
|
+
if (secretCount > 0) logger$7.log(`🔐 Loaded ${secretCount} secret(s)`);
|
|
1341
1343
|
const preloadDir = join(cwd, ".gkm");
|
|
1342
1344
|
await mkdir(preloadDir, { recursive: true });
|
|
1343
1345
|
const preloadPath = join(preloadDir, "credentials-preload.mjs");
|
|
1344
1346
|
await createCredentialsPreload(preloadPath, secretsJsonPath);
|
|
1345
1347
|
const [cmd, ...args] = commandArgs;
|
|
1346
|
-
logger$
|
|
1348
|
+
logger$7.log(`🚀 Running: ${[cmd, ...args].join(" ")}`);
|
|
1347
1349
|
const nodeOptions = [(process.env.NODE_OPTIONS ?? "").replace(/--import[= ]\S+/g, "").trim(), `--import=${preloadPath}`].filter(Boolean).join(" ");
|
|
1348
1350
|
const child = spawn(cmd, args, {
|
|
1349
1351
|
cwd,
|
|
@@ -1357,7 +1359,7 @@ async function execCommand(commandArgs, options = {}) {
|
|
|
1357
1359
|
const exitCode = await new Promise((resolve) => {
|
|
1358
1360
|
child.on("close", (code) => resolve(code ?? 0));
|
|
1359
1361
|
child.on("error", (error) => {
|
|
1360
|
-
logger$
|
|
1362
|
+
logger$7.error(`Failed to run command: ${error.message}`);
|
|
1361
1363
|
resolve(1);
|
|
1362
1364
|
});
|
|
1363
1365
|
});
|
|
@@ -1365,7 +1367,7 @@ async function execCommand(commandArgs, options = {}) {
|
|
|
1365
1367
|
}
|
|
1366
1368
|
//#endregion
|
|
1367
1369
|
//#region src/dev/index.ts
|
|
1368
|
-
const logger$
|
|
1370
|
+
const logger$6 = console;
|
|
1369
1371
|
/**
|
|
1370
1372
|
* Normalize telescope configuration
|
|
1371
1373
|
* @internal Exported for testing
|
|
@@ -1435,13 +1437,13 @@ async function devCommand(options) {
|
|
|
1435
1437
|
if (options.subscribers === false) process.env[SUBSCRIBERS_ENV] = "off";
|
|
1436
1438
|
if (options.entry) return entryDevCommand(options);
|
|
1437
1439
|
const defaultEnv = loadEnvFiles(".env");
|
|
1438
|
-
if (defaultEnv.loaded.length > 0) logger$
|
|
1440
|
+
if (defaultEnv.loaded.length > 0) logger$6.log(`📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
|
|
1439
1441
|
const appConfig = await loadAppConfig().catch((error) => {
|
|
1440
1442
|
if (error instanceof NotInAnApp || error instanceof NotABackendApp) return;
|
|
1441
1443
|
throw error;
|
|
1442
1444
|
});
|
|
1443
1445
|
if (!appConfig) {
|
|
1444
|
-
logger$
|
|
1446
|
+
logger$6.log("🔎 Reading gkm.config.ts and the constructs");
|
|
1445
1447
|
const { workspace: everything } = await loadWorkspaceConfig();
|
|
1446
1448
|
return workspaceDevCommand(everything, options);
|
|
1447
1449
|
}
|
|
@@ -1454,12 +1456,12 @@ async function devCommand(options) {
|
|
|
1454
1456
|
holder: holderOf
|
|
1455
1457
|
});
|
|
1456
1458
|
if (assigned.running.length > 0) throw new WorkspacePortsInUse(assigned.running);
|
|
1457
|
-
for (const { app, from, to, holder } of assigned.moved) logger$
|
|
1459
|
+
for (const { app, from, to, holder } of assigned.moved) logger$6.log(`↪️ ${app}: ${from} is held by ${describeHolder(holder)}, which is not this workspace's — using ${to}`);
|
|
1458
1460
|
const workspace = withAppPorts(appConfig.workspace, assigned.ports);
|
|
1459
1461
|
const workspaceAppPort = assigned.ports[workspaceAppName] ?? appConfig.app.port;
|
|
1460
1462
|
process.env[APP_TAG_ENV] = appTag(appConfig.workspaceRoot, workspaceAppName);
|
|
1461
1463
|
if (appConfig.app.entry) {
|
|
1462
|
-
logger$
|
|
1464
|
+
logger$6.log(`📄 Using entry point: ${appConfig.app.entry}`);
|
|
1463
1465
|
return entryDevCommand({
|
|
1464
1466
|
...options,
|
|
1465
1467
|
entry: appConfig.app.entry,
|
|
@@ -1469,8 +1471,8 @@ async function devCommand(options) {
|
|
|
1469
1471
|
}
|
|
1470
1472
|
if (config.env) {
|
|
1471
1473
|
const { loaded, missing } = loadEnvFiles(config.env, appRoot);
|
|
1472
|
-
if (loaded.length > 0) logger$
|
|
1473
|
-
if (missing.length > 0) logger$
|
|
1474
|
+
if (loaded.length > 0) logger$6.log(`📦 Loaded env: ${loaded.join(", ")}`);
|
|
1475
|
+
if (missing.length > 0) logger$6.warn(`⚠️ Missing env files: ${missing.join(", ")}`);
|
|
1474
1476
|
}
|
|
1475
1477
|
const telescope = normalizeTelescopeConfig(config.telescope);
|
|
1476
1478
|
const hooks = normalizeHooksConfig(config.hooks, appRoot);
|
|
@@ -1489,7 +1491,7 @@ async function devCommand(options) {
|
|
|
1489
1491
|
bustCache,
|
|
1490
1492
|
serveEmpty: true
|
|
1491
1493
|
}));
|
|
1492
|
-
logger$
|
|
1494
|
+
logger$6.log(`🔨 Building ${workspaceAppName ?? "the app"}`);
|
|
1493
1495
|
const initial = await build();
|
|
1494
1496
|
const runtime = config.runtime ?? "node";
|
|
1495
1497
|
let secretsJsonPath;
|
|
@@ -1500,11 +1502,11 @@ async function devCommand(options) {
|
|
|
1500
1502
|
const reconciled = await reconcileWorkspace(workspace, {
|
|
1501
1503
|
stage: workspace.stages.local,
|
|
1502
1504
|
...options.fake ? { fake: true } : {},
|
|
1503
|
-
progress: (message) => logger$
|
|
1505
|
+
progress: (message) => logger$6.log(message)
|
|
1504
1506
|
});
|
|
1505
1507
|
if (reconciled.services.length > 0) {
|
|
1506
|
-
logger$
|
|
1507
|
-
for (const line of describeServices(reconciled.services)) logger$
|
|
1508
|
+
logger$6.log("🐳 Services");
|
|
1509
|
+
for (const line of describeServices(reconciled.services)) logger$6.log(line);
|
|
1508
1510
|
}
|
|
1509
1511
|
Object.assign(appSecrets, reconciled.env);
|
|
1510
1512
|
const own = reconciled.plan.resources.find((r) => (r.kind === "rest-api" || r.kind === "site") && appKey(r.id) === workspaceAppName);
|
|
@@ -1571,11 +1573,11 @@ async function devCommand(options) {
|
|
|
1571
1573
|
cwd: appRoot
|
|
1572
1574
|
});
|
|
1573
1575
|
watcher.on("error", (error) => {
|
|
1574
|
-
logger$
|
|
1576
|
+
logger$6.error("❌ Watcher error:", error);
|
|
1575
1577
|
});
|
|
1576
1578
|
let rebuildTimeout = null;
|
|
1577
1579
|
watcher.on("change", async (path) => {
|
|
1578
|
-
logger$
|
|
1580
|
+
logger$6.log(`🔄 ${path} changed — rebuilding`);
|
|
1579
1581
|
if (rebuildTimeout) clearTimeout(rebuildTimeout);
|
|
1580
1582
|
rebuildTimeout = setTimeout(async () => {
|
|
1581
1583
|
try {
|
|
@@ -1584,8 +1586,8 @@ async function devCommand(options) {
|
|
|
1584
1586
|
await devServer.restart();
|
|
1585
1587
|
} catch (error) {
|
|
1586
1588
|
const err = error;
|
|
1587
|
-
logger$
|
|
1588
|
-
if (err.stack) logger$
|
|
1589
|
+
logger$6.error(`❌ Rebuild failed: ${err.message}`);
|
|
1590
|
+
if (err.stack) logger$6.error(err.stack);
|
|
1589
1591
|
}
|
|
1590
1592
|
}, 300);
|
|
1591
1593
|
});
|
|
@@ -1593,13 +1595,13 @@ async function devCommand(options) {
|
|
|
1593
1595
|
const shutdown = () => {
|
|
1594
1596
|
if (isShuttingDown) return;
|
|
1595
1597
|
isShuttingDown = true;
|
|
1596
|
-
logger$
|
|
1598
|
+
logger$6.log("\n🛑 Shutting down...");
|
|
1597
1599
|
Promise.all([
|
|
1598
1600
|
watcher.close(),
|
|
1599
1601
|
devServer.stop(),
|
|
1600
1602
|
discovery.leave()
|
|
1601
1603
|
]).catch((err) => {
|
|
1602
|
-
logger$
|
|
1604
|
+
logger$6.error("Error during shutdown:", err);
|
|
1603
1605
|
}).finally(() => {
|
|
1604
1606
|
process.exit(0);
|
|
1605
1607
|
});
|
|
@@ -1803,13 +1805,13 @@ async function workspaceDevCommand(configured, options) {
|
|
|
1803
1805
|
holder: holderOf
|
|
1804
1806
|
});
|
|
1805
1807
|
if (assigned.running.length > 0) throw new WorkspacePortsInUse(assigned.running);
|
|
1806
|
-
for (const { app, from, to, holder } of assigned.moved) logger$
|
|
1808
|
+
for (const { app, from, to, holder } of assigned.moved) logger$6.log(`↪️ ${app}: ${from} is held by ${describeHolder(holder)}, which is not this workspace's — using ${to}`);
|
|
1807
1809
|
const workspace = withAppPorts(configured, assigned.ports);
|
|
1808
1810
|
const appCount = Object.keys(workspace.apps).length;
|
|
1809
1811
|
const frontendApps = Object.entries(workspace.apps).filter(([_, app]) => app.type === "web");
|
|
1810
1812
|
const conflicts = checkPortConflicts(workspace);
|
|
1811
1813
|
if (conflicts.length > 0) {
|
|
1812
|
-
for (const conflict of conflicts) logger$
|
|
1814
|
+
for (const conflict of conflicts) logger$6.error(`❌ Port conflict: Apps "${conflict.app1}" and "${conflict.app2}" both use port ${conflict.port}`);
|
|
1813
1815
|
throw new Error("Port conflicts detected. Please assign unique ports to each app.");
|
|
1814
1816
|
}
|
|
1815
1817
|
if (frontendApps.length > 0) {
|
|
@@ -1818,10 +1820,10 @@ async function workspaceDevCommand(configured, options) {
|
|
|
1818
1820
|
for (const result of validationResults) {
|
|
1819
1821
|
if (!result.valid) {
|
|
1820
1822
|
hasErrors = true;
|
|
1821
|
-
logger$
|
|
1822
|
-
for (const error of result.errors) logger$
|
|
1823
|
+
logger$6.error(`\n❌ Frontend app "${result.appName}" validation failed:`);
|
|
1824
|
+
for (const error of result.errors) logger$6.error(` • ${error}`);
|
|
1823
1825
|
}
|
|
1824
|
-
for (const warning of result.warnings) logger$
|
|
1826
|
+
for (const warning of result.warnings) logger$6.warn(` ⚠️ ${result.appName}: ${warning}`);
|
|
1825
1827
|
}
|
|
1826
1828
|
if (hasErrors) throw new Error("Frontend app validation failed. Fix the issues above and try again.");
|
|
1827
1829
|
}
|
|
@@ -1829,24 +1831,24 @@ async function workspaceDevCommand(configured, options) {
|
|
|
1829
1831
|
const reconciled = await reconcileWorkspace(workspace, {
|
|
1830
1832
|
stage: workspace.stages.local,
|
|
1831
1833
|
...options.fake ? { fake: true } : {},
|
|
1832
|
-
progress: (message) => logger$
|
|
1834
|
+
progress: (message) => logger$6.log(message)
|
|
1833
1835
|
});
|
|
1834
1836
|
if (reconciled.services.length > 0) {
|
|
1835
|
-
logger$
|
|
1836
|
-
for (const line of describeServices(reconciled.services)) logger$
|
|
1837
|
+
logger$6.log("🐳 Services");
|
|
1838
|
+
for (const line of describeServices(reconciled.services)) logger$6.log(line);
|
|
1837
1839
|
}
|
|
1838
1840
|
const secretsEnv = {
|
|
1839
1841
|
...rawSecrets,
|
|
1840
1842
|
...reconciled.env
|
|
1841
1843
|
};
|
|
1842
1844
|
const fakes = options.fake ? await serveFakes(reconciled.fakes, reconciled.ports) : [];
|
|
1843
|
-
for (const { id, port } of fakes) logger$
|
|
1844
|
-
logger$
|
|
1845
|
+
for (const { id, port } of fakes) logger$6.log(`🎭 ${id} fake: http://localhost:${port}`);
|
|
1846
|
+
logger$6.log(options.migrate || options.seed ? "🗄️ Migrating the databases" : "🗄️ Checking for pending migrations");
|
|
1845
1847
|
if (options.migrate || options.seed) {
|
|
1846
|
-
const { prepareDevDatabases } = await import("./migrate-
|
|
1848
|
+
const { prepareDevDatabases } = await import("./migrate-5_J5JySj.mjs");
|
|
1847
1849
|
await prepareDevDatabases(workspace, reconciled.env, { seed: !!options.seed });
|
|
1848
|
-
} else await import("./migrate-
|
|
1849
|
-
logger$
|
|
1850
|
+
} else await import("./migrate-5_J5JySj.mjs").then(({ reportPendingMigrations }) => reportPendingMigrations(workspace, reconciled.env)).catch((error) => {
|
|
1851
|
+
logger$6.log(`⚠️ Could not check for pending migrations: ${error instanceof Error ? error.message : String(error)}`);
|
|
1850
1852
|
});
|
|
1851
1853
|
const appUrls = {};
|
|
1852
1854
|
for (const resource of reconciled.plan.resources) {
|
|
@@ -1867,11 +1869,11 @@ async function workspaceDevCommand(configured, options) {
|
|
|
1867
1869
|
else {
|
|
1868
1870
|
const { filters, unpackaged } = turboFilters(workspace);
|
|
1869
1871
|
turboFilter = filters.flatMap((name) => ["--filter", name]);
|
|
1870
|
-
if (unpackaged.length > 0) logger$
|
|
1872
|
+
if (unpackaged.length > 0) logger$6.warn(`⚠️ No package.json with a name for: ${unpackaged.join(", ")} — turbo cannot run ${unpackaged.length === 1 ? "it" : "them"}.`);
|
|
1871
1873
|
}
|
|
1872
1874
|
const buildOrder = getAppBuildOrder(workspace);
|
|
1873
1875
|
const width = Math.max(...buildOrder.map((name) => name.length));
|
|
1874
|
-
logger$
|
|
1876
|
+
logger$6.log(`\n${workspace.name}: ${appCount} app(s)`);
|
|
1875
1877
|
for (const appName of buildOrder) {
|
|
1876
1878
|
const app = workspace.apps[appName];
|
|
1877
1879
|
if (!app) continue;
|
|
@@ -1879,14 +1881,14 @@ async function workspaceDevCommand(configured, options) {
|
|
|
1879
1881
|
const own = reconciled.plan.resources.find((r) => r.kind === "mobile-app" && appKey(r.id) === appName);
|
|
1880
1882
|
const scheme = own ? reconciled.env[own.envKey] : void 0;
|
|
1881
1883
|
const device = Object.entries(reconciled.env).find(([key]) => key.endsWith("_DEVICE_URL"))?.[1];
|
|
1882
|
-
logger$
|
|
1884
|
+
logger$6.log(` ${appName.padEnd(width)} ${scheme ? `${scheme}://` : "mobile"}${device ? ` — devices reach ${new URL(device).hostname}` : ""}`);
|
|
1883
1885
|
continue;
|
|
1884
1886
|
}
|
|
1885
1887
|
const local = `http://localhost:${app.port}`;
|
|
1886
1888
|
const url = Object.entries(appUrls).find(([id]) => appKey(id) === appName)?.[1];
|
|
1887
|
-
logger$
|
|
1889
|
+
logger$6.log(` ${appName.padEnd(width)} ${url ? `${url} -> ${local}` : local}`);
|
|
1888
1890
|
}
|
|
1889
|
-
logger$
|
|
1891
|
+
logger$6.log("");
|
|
1890
1892
|
const configFiles = [
|
|
1891
1893
|
"gkm.config.ts",
|
|
1892
1894
|
"gkm.config.js",
|
|
@@ -1934,7 +1936,7 @@ async function workspaceDevCommand(configured, options) {
|
|
|
1934
1936
|
...options.fake ? { [FAKE_ENV]: "1" } : {},
|
|
1935
1937
|
[DISCOVERY_QUIET_ENV]: "1"
|
|
1936
1938
|
};
|
|
1937
|
-
logger$
|
|
1939
|
+
logger$6.log(`🚀 Starting ${appCount} app(s) — each builds before it answers, and says so when it is ready`);
|
|
1938
1940
|
const turboProcess = spawn("pnpm", [
|
|
1939
1941
|
"turbo",
|
|
1940
1942
|
"run",
|
|
@@ -1950,7 +1952,7 @@ async function workspaceDevCommand(configured, options) {
|
|
|
1950
1952
|
const shutdown = () => {
|
|
1951
1953
|
if (isShuttingDown) return;
|
|
1952
1954
|
isShuttingDown = true;
|
|
1953
|
-
logger$
|
|
1955
|
+
logger$6.log("\n🛑 Shutting down workspace...");
|
|
1954
1956
|
closeFakes(fakes);
|
|
1955
1957
|
discovery.leave();
|
|
1956
1958
|
const pid = turboProcess.pid;
|
|
@@ -1976,7 +1978,7 @@ async function workspaceDevCommand(configured, options) {
|
|
|
1976
1978
|
process.on("SIGTERM", shutdown);
|
|
1977
1979
|
return new Promise((resolve, reject) => {
|
|
1978
1980
|
turboProcess.on("error", (error) => {
|
|
1979
|
-
logger$
|
|
1981
|
+
logger$6.error("❌ Turbo error:", error);
|
|
1980
1982
|
reject(error);
|
|
1981
1983
|
});
|
|
1982
1984
|
turboProcess.on("exit", (code) => {
|
|
@@ -1995,11 +1997,11 @@ async function entryDevCommand(options) {
|
|
|
1995
1997
|
const entryPath = resolve(process.cwd(), entry);
|
|
1996
1998
|
if (!existsSync(entryPath)) throw new Error(`Entry file not found: ${entryPath}`);
|
|
1997
1999
|
const defaultEnv = loadEnvFiles(".env");
|
|
1998
|
-
if (defaultEnv.loaded.length > 0) logger$
|
|
2000
|
+
if (defaultEnv.loaded.length > 0) logger$6.log(`📦 Loaded env: ${defaultEnv.loaded.join(", ")}`);
|
|
1999
2001
|
const { credentials, resolvedPort, secretsJsonPath, appName } = await prepareEntryCredentials({ explicitPort: options.portExplicit ? options.port : void 0 });
|
|
2000
|
-
if (appName) logger$
|
|
2001
|
-
logger$
|
|
2002
|
-
if (Object.keys(credentials).length > 1) logger$
|
|
2002
|
+
if (appName) logger$6.log(`📦 App: ${appName} (port ${resolvedPort})`);
|
|
2003
|
+
logger$6.log(`🚀 Starting entry file: ${entry} on port ${resolvedPort}`);
|
|
2004
|
+
if (Object.keys(credentials).length > 1) logger$6.log(`🔐 Loaded ${Object.keys(credentials).length - 1} secret(s) + PORT`);
|
|
2003
2005
|
const wrapperDir = join(process.cwd(), ".gkm");
|
|
2004
2006
|
await mkdir(wrapperDir, { recursive: true });
|
|
2005
2007
|
const wrapperPath = join(wrapperDir, "entry-wrapper.ts");
|
|
@@ -2010,7 +2012,7 @@ async function entryDevCommand(options) {
|
|
|
2010
2012
|
const shutdown = () => {
|
|
2011
2013
|
if (isShuttingDown) return;
|
|
2012
2014
|
isShuttingDown = true;
|
|
2013
|
-
logger$
|
|
2015
|
+
logger$6.log("\n🛑 Shutting down...");
|
|
2014
2016
|
runner.stop();
|
|
2015
2017
|
process.exit(0);
|
|
2016
2018
|
};
|
|
@@ -2046,14 +2048,14 @@ var EntryRunner = class {
|
|
|
2046
2048
|
});
|
|
2047
2049
|
let restartTimeout = null;
|
|
2048
2050
|
this.watcher.on("change", (path) => {
|
|
2049
|
-
logger$
|
|
2051
|
+
logger$6.log(`📝 File changed: ${path}`);
|
|
2050
2052
|
if (restartTimeout) clearTimeout(restartTimeout);
|
|
2051
2053
|
restartTimeout = setTimeout(async () => {
|
|
2052
|
-
logger$
|
|
2054
|
+
logger$6.log("🔄 Restarting...");
|
|
2053
2055
|
await this.restart();
|
|
2054
2056
|
}, 300);
|
|
2055
2057
|
});
|
|
2056
|
-
logger$
|
|
2058
|
+
logger$6.log(`👀 Watching for changes in: ${watchDir}`);
|
|
2057
2059
|
}
|
|
2058
2060
|
}
|
|
2059
2061
|
async runProcess() {
|
|
@@ -2069,17 +2071,17 @@ var EntryRunner = class {
|
|
|
2069
2071
|
});
|
|
2070
2072
|
this.isRunning = true;
|
|
2071
2073
|
this.childProcess.on("error", (error) => {
|
|
2072
|
-
logger$
|
|
2074
|
+
logger$6.error("❌ Process error:", error);
|
|
2073
2075
|
});
|
|
2074
2076
|
this.childProcess.on("exit", (code) => {
|
|
2075
|
-
if (code !== null && code !== 0 && code !== 143) logger$
|
|
2077
|
+
if (code !== null && code !== 0 && code !== 143) logger$6.error(`❌ Process exited with code ${code}`);
|
|
2076
2078
|
this.isRunning = false;
|
|
2077
2079
|
});
|
|
2078
2080
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
2079
2081
|
if (this.isRunning) {
|
|
2080
|
-
logger$
|
|
2081
|
-
logger$
|
|
2082
|
-
logger$
|
|
2082
|
+
logger$6.log("");
|
|
2083
|
+
logger$6.log(` \x1b[32m✓ Ready\x1b[0m at \x1b[36mhttp://localhost:${this.port}\x1b[0m`);
|
|
2084
|
+
logger$6.log("");
|
|
2083
2085
|
}
|
|
2084
2086
|
}
|
|
2085
2087
|
async restart() {
|
|
@@ -2198,7 +2200,7 @@ start({
|
|
|
2198
2200
|
* lets a console read what this machine is running.
|
|
2199
2201
|
*/
|
|
2200
2202
|
function announce(discovery) {
|
|
2201
|
-
logger$
|
|
2203
|
+
logger$6.log(`🧭 Discovery: ${discovery.connectUrl}`);
|
|
2202
2204
|
}
|
|
2203
2205
|
/** Run `fn` with `console.log` muted — warnings and errors still print. */
|
|
2204
2206
|
async function quietly(fn) {
|
|
@@ -2316,7 +2318,7 @@ var DevServer = class {
|
|
|
2316
2318
|
this.actualPort = this.requestedPort;
|
|
2317
2319
|
} else {
|
|
2318
2320
|
this.actualPort = await findAvailablePort(this.requestedPort);
|
|
2319
|
-
if (this.actualPort !== this.requestedPort) logger$
|
|
2321
|
+
if (this.actualPort !== this.requestedPort) logger$6.log(`ℹ️ Port ${this.requestedPort} was in use, using port ${this.actualPort} instead`);
|
|
2320
2322
|
}
|
|
2321
2323
|
const serverEntryPath = join(this.appRoot, ".gkm", "server", "server.ts");
|
|
2322
2324
|
await this.createServerEntry();
|
|
@@ -2336,11 +2338,11 @@ var DevServer = class {
|
|
|
2336
2338
|
});
|
|
2337
2339
|
this.isRunning = true;
|
|
2338
2340
|
this.serverProcess.on("error", (error) => {
|
|
2339
|
-
logger$
|
|
2341
|
+
logger$6.error("❌ Server error:", error);
|
|
2340
2342
|
});
|
|
2341
2343
|
const child = this.serverProcess;
|
|
2342
2344
|
child.on("exit", (code, signal) => {
|
|
2343
|
-
if (code !== null && code !== 0 && signal !== "SIGTERM") logger$
|
|
2345
|
+
if (code !== null && code !== 0 && signal !== "SIGTERM") logger$6.error(`❌ Server exited with code ${code}`);
|
|
2344
2346
|
this.isRunning = false;
|
|
2345
2347
|
if (this.serverProcess === child) this.onStatus?.("stopped");
|
|
2346
2348
|
});
|
|
@@ -2349,13 +2351,13 @@ var DevServer = class {
|
|
|
2349
2351
|
const local = `http://localhost:${this.actualPort}`;
|
|
2350
2352
|
const address = this.publicUrl ? `${this.publicUrl} -> ${local}` : local;
|
|
2351
2353
|
const seconds = ((Date.now() - this.startTime) / 1e3).toFixed(1);
|
|
2352
|
-
logger$
|
|
2354
|
+
logger$6.log(`\x1b[32m✓\x1b[0m ${this.label} ready in ${seconds}s ${address}`);
|
|
2353
2355
|
const tools = this.selfServing ? [] : [
|
|
2354
2356
|
...this.enableOpenApi ? ["docs /__docs"] : [],
|
|
2355
2357
|
...this.telescope ? [`telescope ${this.telescope.path}`] : [],
|
|
2356
2358
|
...this.databaseApi ? [`db ${this.databaseApi}`] : []
|
|
2357
2359
|
];
|
|
2358
|
-
if (tools.length > 0) logger$
|
|
2360
|
+
if (tools.length > 0) logger$6.log(` ${tools.join(" · ")}`);
|
|
2359
2361
|
this.onStatus?.("ready");
|
|
2360
2362
|
}
|
|
2361
2363
|
}
|
|
@@ -2995,9 +2997,63 @@ export const { app } = await surface.server({ envParser, context: snifferContext
|
|
|
2995
2997
|
export default app;
|
|
2996
2998
|
`);
|
|
2997
2999
|
}
|
|
3000
|
+
/**
|
|
3001
|
+
* The production server for a surface that serves itself.
|
|
3002
|
+
*
|
|
3003
|
+
* `app.ts` exports the construct's own Hono app and starts nothing, which is
|
|
3004
|
+
* what `gkm dev` wants: its server mounts the app beside its tools. An image
|
|
3005
|
+
* needs a process, so a production build starts one here — the health check
|
|
3006
|
+
* every image is probed on, the surface behind it, and the same bounded drain
|
|
3007
|
+
* on SIGTERM the generated route servers have. Without it an auth server had
|
|
3008
|
+
* no bundle to put in an image at all.
|
|
3009
|
+
*/
|
|
3010
|
+
async function writeSurfaceServer(outputDir, options) {
|
|
3011
|
+
await mkdir(outputDir, { recursive: true });
|
|
3012
|
+
const shutdown = options.gracefulShutdown ? `
|
|
3013
|
+
// Stop taking requests, let in-flight ones finish, then close what the
|
|
3014
|
+
// construct opened — bounded by GKM_SHUTDOWN_TIMEOUT_MS, under Docker's 10s
|
|
3015
|
+
// stop timeout, so the process exits on its own terms.
|
|
3016
|
+
let shuttingDown = false;
|
|
3017
|
+
const shutdown = async () => {
|
|
3018
|
+
if (shuttingDown) return;
|
|
3019
|
+
shuttingDown = true;
|
|
3020
|
+
const deadline = Number(process.env.GKM_SHUTDOWN_TIMEOUT_MS) || 8000;
|
|
3021
|
+
setTimeout(() => process.exit(1), deadline).unref();
|
|
3022
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
3023
|
+
await runShutdownHooks((error) => console.error(error));
|
|
3024
|
+
process.exit(0);
|
|
3025
|
+
};
|
|
3026
|
+
|
|
3027
|
+
process.on('SIGTERM', shutdown);
|
|
3028
|
+
process.on('SIGINT', shutdown);
|
|
3029
|
+
` : "";
|
|
3030
|
+
const path = join(outputDir, "server.ts");
|
|
3031
|
+
await writeFile(path, `#!/usr/bin/env node
|
|
3032
|
+
/**
|
|
3033
|
+
* Production server for a surface that serves itself.
|
|
3034
|
+
* Generated by 'gkm build --production'.
|
|
3035
|
+
*/
|
|
3036
|
+
import { serve } from '@hono/node-server';
|
|
3037
|
+
import { Hono } from 'hono';
|
|
3038
|
+
${options.gracefulShutdown ? "import { runShutdownHooks } from '@geekmidas/constructs';\n" : ""}import { app as surface } from './app.js';
|
|
3039
|
+
|
|
3040
|
+
const port = Number(process.env.PORT) || 3000;
|
|
3041
|
+
|
|
3042
|
+
const app = new Hono();
|
|
3043
|
+
// First, so a probe is answered without reaching the surface's own routes.
|
|
3044
|
+
app.get(${JSON.stringify(options.healthCheck)}, (c) =>
|
|
3045
|
+
c.json({ status: 'ok', timestamp: Date.now() }),
|
|
3046
|
+
);
|
|
3047
|
+
app.route('/', surface);
|
|
3048
|
+
|
|
3049
|
+
const server = serve({ fetch: app.fetch, port });
|
|
3050
|
+
console.log(JSON.stringify({ msg: 'Production server started', port }));
|
|
3051
|
+
${shutdown}`);
|
|
3052
|
+
return path;
|
|
3053
|
+
}
|
|
2998
3054
|
//#endregion
|
|
2999
3055
|
//#region src/build/index.ts
|
|
3000
|
-
const logger$
|
|
3056
|
+
const logger$5 = console;
|
|
3001
3057
|
/**
|
|
3002
3058
|
* Where `gkm build --stage` leaves the key its bundle's secrets were encrypted
|
|
3003
3059
|
* with, beside the bundle in `.gkm/server/` and readable by its owner alone.
|
|
@@ -3045,7 +3101,7 @@ async function buildCommand(options) {
|
|
|
3045
3101
|
const isAtWorkspaceRoot = cwd === workspaceRoot;
|
|
3046
3102
|
const someAppIsElsewhere = Object.values(loadedConfig.workspace.apps).some((app) => resolve(workspaceRoot, app.path) !== workspaceRoot);
|
|
3047
3103
|
if (isAtWorkspaceRoot && someAppIsElsewhere) {
|
|
3048
|
-
logger$
|
|
3104
|
+
logger$5.log("📦 Detected workspace configuration");
|
|
3049
3105
|
return workspaceBuildCommand(loadedConfig.workspace, options);
|
|
3050
3106
|
}
|
|
3051
3107
|
}
|
|
@@ -3091,13 +3147,13 @@ async function buildOneApp(input) {
|
|
|
3091
3147
|
const { config, options, workspace, appRoot, target } = input;
|
|
3092
3148
|
const cacheBackend = cacheBackendFor(providerOf(workspace));
|
|
3093
3149
|
const production = normalizeProductionConfig(options.production ?? false);
|
|
3094
|
-
if (production) logger$
|
|
3095
|
-
logger$
|
|
3096
|
-
logger$
|
|
3150
|
+
if (production) logger$5.log(`🏭 Building for PRODUCTION`);
|
|
3151
|
+
logger$5.log(`Building for ${target}`);
|
|
3152
|
+
logger$5.log(`Loading constructs from: ${formatRoutes(config.constructs)}`);
|
|
3097
3153
|
const telescope = production ? void 0 : normalizeTelescopeConfig(config.telescope);
|
|
3098
|
-
if (telescope) logger$
|
|
3154
|
+
if (telescope) logger$5.log(`🔭 Telescope enabled at ${telescope.path}`);
|
|
3099
3155
|
const hooks = normalizeHooksConfig(config.hooks, appRoot);
|
|
3100
|
-
if (hooks) logger$
|
|
3156
|
+
if (hooks) logger$5.log(`🪝 Server hooks enabled`);
|
|
3101
3157
|
return buildApp({
|
|
3102
3158
|
config,
|
|
3103
3159
|
workspaceRoot: workspace.root,
|
|
@@ -3139,7 +3195,7 @@ async function writeManifest(input) {
|
|
|
3139
3195
|
});
|
|
3140
3196
|
const path = join(manifestDir, `${target}.ts`);
|
|
3141
3197
|
await writeFile(path, manifestModule(folded, backends));
|
|
3142
|
-
logger$
|
|
3198
|
+
logger$5.log(`Manifest: ${relative(process.cwd(), path)}`);
|
|
3143
3199
|
}
|
|
3144
3200
|
/**
|
|
3145
3201
|
* Turn one app's constructs into its generated entry.
|
|
@@ -3167,7 +3223,21 @@ async function buildApp(input) {
|
|
|
3167
3223
|
});
|
|
3168
3224
|
if (selfServing) {
|
|
3169
3225
|
await writeSurfaceEntry(join(appRoot, ".gkm", "server"), selfServing.source);
|
|
3170
|
-
logger$
|
|
3226
|
+
logger$5.log(`Generated a server for ${selfServing.id} from its own declaration`);
|
|
3227
|
+
if (production?.bundle && !input.skipBundle) {
|
|
3228
|
+
const outputDir = join(appRoot, ".gkm", "server");
|
|
3229
|
+
const entryPoint = await writeSurfaceServer(outputDir, production);
|
|
3230
|
+
const { bundleServer } = await import("./bundler-B5VsKW-_.mjs");
|
|
3231
|
+
await bundleServer({
|
|
3232
|
+
entryPoint,
|
|
3233
|
+
outputDir: join(outputDir, "dist"),
|
|
3234
|
+
minify: production.minify,
|
|
3235
|
+
sourcemap: false,
|
|
3236
|
+
external: production.external,
|
|
3237
|
+
...input.stage ? { stage: input.stage } : {}
|
|
3238
|
+
});
|
|
3239
|
+
logger$5.log(`✅ Bundle complete: .gkm/server/dist/server.mjs`);
|
|
3240
|
+
}
|
|
3171
3241
|
return { selfServing: selfServing.id };
|
|
3172
3242
|
}
|
|
3173
3243
|
const derived = ownersContext({
|
|
@@ -3212,14 +3282,14 @@ async function buildApp(input) {
|
|
|
3212
3282
|
topicGenerator.load(code, appRoot, bustCache)
|
|
3213
3283
|
]);
|
|
3214
3284
|
const allEndpoints = servedBy(loadedEndpoints, derived.surface);
|
|
3215
|
-
logger$
|
|
3216
|
-
logger$
|
|
3217
|
-
logger$
|
|
3218
|
-
logger$
|
|
3219
|
-
logger$
|
|
3220
|
-
logger$
|
|
3285
|
+
logger$5.log(`Found ${allEndpoints.length} endpoints`);
|
|
3286
|
+
logger$5.log(`Found ${allFunctions.length} functions`);
|
|
3287
|
+
logger$5.log(`Found ${allCrons.length} crons`);
|
|
3288
|
+
logger$5.log(`Found ${allSubscribers.length} subscribers`);
|
|
3289
|
+
logger$5.log(`Found ${allQueues.length} queues`);
|
|
3290
|
+
logger$5.log(`Found ${allTopics.length} topics`);
|
|
3221
3291
|
if (!input.serveEmpty && allEndpoints.length === 0 && allFunctions.length === 0 && allCrons.length === 0 && allSubscribers.length === 0 && allQueues.length === 0 && allTopics.length === 0) {
|
|
3222
|
-
logger$
|
|
3292
|
+
logger$5.log("No endpoints, functions, crons, subscribers, queues, or topics found to process");
|
|
3223
3293
|
return {};
|
|
3224
3294
|
}
|
|
3225
3295
|
if (production?.subscribers === "include" && derived.surface) {
|
|
@@ -3232,7 +3302,7 @@ async function buildApp(input) {
|
|
|
3232
3302
|
[allQueues.length, "queue consumer"],
|
|
3233
3303
|
[allSubscribers.length, "subscriber"]
|
|
3234
3304
|
].filter(([n]) => n > 0).map(([n, what]) => `${n} ${what}${n === 1 ? "" : "s"}`);
|
|
3235
|
-
if (left.length > 0) logger$
|
|
3305
|
+
if (left.length > 0) logger$5.log(`Serving ${derived.surface.id} only: leaving out ${left.join(", ")} — they run in a Worker's image`);
|
|
3236
3306
|
}
|
|
3237
3307
|
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);
|
|
3238
3308
|
await generateOpenApi(allEndpoints.map(({ construct }) => construct), {
|
|
@@ -3255,7 +3325,7 @@ async function buildForTarget(target, context, appRoot, workspaceRoot, endpointG
|
|
|
3255
3325
|
force: true
|
|
3256
3326
|
});
|
|
3257
3327
|
await mkdir(outputDir, { recursive: true });
|
|
3258
|
-
logger$
|
|
3328
|
+
logger$5.log(`\nGenerating handlers for ${target}`);
|
|
3259
3329
|
const options = {
|
|
3260
3330
|
target,
|
|
3261
3331
|
root: workspaceRoot
|
|
@@ -3271,7 +3341,7 @@ async function buildForTarget(target, context, appRoot, workspaceRoot, endpointG
|
|
|
3271
3341
|
queueGenerator.build(context, queues, outputDir, options),
|
|
3272
3342
|
topicGenerator.build(context, topics, outputDir, options)
|
|
3273
3343
|
]);
|
|
3274
|
-
logger$
|
|
3344
|
+
logger$5.log(`Generated ${routes.length} routes, ${functionInfos.length} functions, ${cronInfos.length} crons, ${subscriberInfos.length} subscribers, ${queueInfos.length} queues, ${topicInfos.length} topics for ${target}`);
|
|
3275
3345
|
const fields = {
|
|
3276
3346
|
...context.surface ? { surface: context.surface.id } : {},
|
|
3277
3347
|
functions: functionInfos,
|
|
@@ -3294,8 +3364,8 @@ async function buildForTarget(target, context, appRoot, workspaceRoot, endpointG
|
|
|
3294
3364
|
};
|
|
3295
3365
|
let masterKey;
|
|
3296
3366
|
if (context.production?.bundle && !skipBundle) {
|
|
3297
|
-
logger$
|
|
3298
|
-
const { bundleServer } = await import("./bundler-
|
|
3367
|
+
logger$5.log(`\n📦 Bundling production server...`);
|
|
3368
|
+
const { bundleServer } = await import("./bundler-B5VsKW-_.mjs");
|
|
3299
3369
|
const allConstructs = [
|
|
3300
3370
|
...endpoints.map((e) => e.construct),
|
|
3301
3371
|
...functions.map((f) => f.construct),
|
|
@@ -3312,13 +3382,13 @@ async function buildForTarget(target, context, appRoot, workspaceRoot, endpointG
|
|
|
3312
3382
|
stage,
|
|
3313
3383
|
constructs: allConstructs
|
|
3314
3384
|
})).masterKey;
|
|
3315
|
-
logger$
|
|
3385
|
+
logger$5.log(`✅ Bundle complete: .gkm/server/dist/server.mjs`);
|
|
3316
3386
|
if (masterKey) {
|
|
3317
3387
|
const keyPath = join(outputDir, MASTER_KEY_FILE);
|
|
3318
3388
|
await writeFile(keyPath, `${masterKey}\n`, { mode: 384 });
|
|
3319
3389
|
await chmod(keyPath, 384);
|
|
3320
|
-
logger$
|
|
3321
|
-
logger$
|
|
3390
|
+
logger$5.log(`\n🔐 Secrets encrypted for deployment`);
|
|
3391
|
+
logger$5.log(` Run with GKM_MASTER_KEY set to the key in ${relative(appRoot, keyPath)} (fingerprint ${keyFingerprint(masterKey)})`);
|
|
3322
3392
|
}
|
|
3323
3393
|
}
|
|
3324
3394
|
return {
|
|
@@ -3419,12 +3489,12 @@ async function workspaceBuildCommand(workspace, options) {
|
|
|
3419
3489
|
const apps = Object.entries(workspace.apps);
|
|
3420
3490
|
const backendApps = apps.filter(([, app]) => app.type === "backend");
|
|
3421
3491
|
const frontendApps = apps.filter(([, app]) => app.type === "web");
|
|
3422
|
-
logger$
|
|
3423
|
-
logger$
|
|
3424
|
-
logger$
|
|
3425
|
-
if (options.production) logger$
|
|
3492
|
+
logger$5.log(`\n🏗️ Building workspace: ${workspace.name}`);
|
|
3493
|
+
logger$5.log(` Backend apps: ${backendApps.map(([name]) => name).join(", ") || "none"}`);
|
|
3494
|
+
logger$5.log(` Frontend apps: ${frontendApps.map(([name]) => name).join(", ") || "none"}`);
|
|
3495
|
+
if (options.production) logger$5.log(` 🏭 Production mode enabled`);
|
|
3426
3496
|
const buildOrder = getAppBuildOrder(workspace);
|
|
3427
|
-
logger$
|
|
3497
|
+
logger$5.log(` Build order: ${buildOrder.join(" → ")}`);
|
|
3428
3498
|
try {
|
|
3429
3499
|
const target = options.provider ?? providerOf(workspace);
|
|
3430
3500
|
const builds = [];
|
|
@@ -3432,7 +3502,7 @@ async function workspaceBuildCommand(workspace, options) {
|
|
|
3432
3502
|
const app = workspace.apps[appName];
|
|
3433
3503
|
const config = getAppGkmConfig(workspace, appName);
|
|
3434
3504
|
if (!app || app.type !== "backend" || !config) continue;
|
|
3435
|
-
logger$
|
|
3505
|
+
logger$5.log(`\n⚙️ ${appName}`);
|
|
3436
3506
|
const output = await buildOneApp({
|
|
3437
3507
|
config,
|
|
3438
3508
|
options,
|
|
@@ -3457,8 +3527,8 @@ async function workspaceBuildCommand(workspace, options) {
|
|
|
3457
3527
|
const pm = detectPackageManager$1();
|
|
3458
3528
|
const [turboBin, turboArgs] = getTurboArgs(pm, filters, { only: true });
|
|
3459
3529
|
if (filters.length > 0) {
|
|
3460
|
-
logger$
|
|
3461
|
-
logger$
|
|
3530
|
+
logger$5.log(`\n📦 Using ${pm} with Turbo for the other apps...\n`);
|
|
3531
|
+
logger$5.log(`Running: ${[turboBin, ...turboArgs].join(" ")}`);
|
|
3462
3532
|
await run$1(turboBin, turboArgs, {
|
|
3463
3533
|
cwd: workspace.root,
|
|
3464
3534
|
env: {
|
|
@@ -3476,15 +3546,15 @@ async function workspaceBuildCommand(workspace, options) {
|
|
|
3476
3546
|
outputPath
|
|
3477
3547
|
});
|
|
3478
3548
|
}
|
|
3479
|
-
logger$
|
|
3480
|
-
logger$
|
|
3549
|
+
logger$5.log(`\n✅ Workspace build complete!`);
|
|
3550
|
+
logger$5.log(`\n📋 Build Summary:`);
|
|
3481
3551
|
for (const result of results) {
|
|
3482
3552
|
const icon = result.type === "backend" ? "⚙️" : result.type === "mobile" ? "📱" : "🌐";
|
|
3483
|
-
logger$
|
|
3553
|
+
logger$5.log(` ${icon} ${result.appName}: ${result.outputPath || "built"}`);
|
|
3484
3554
|
}
|
|
3485
3555
|
} catch (error) {
|
|
3486
3556
|
const errorMessage = error instanceof Error ? error.message : "Build failed";
|
|
3487
|
-
logger$
|
|
3557
|
+
logger$5.log(`\n❌ Build failed: ${errorMessage}`);
|
|
3488
3558
|
for (const [appName, app] of apps) results.push({
|
|
3489
3559
|
appName,
|
|
3490
3560
|
type: app.type,
|
|
@@ -3513,6 +3583,1491 @@ function getAppOutputPath(workspace, _appName, app) {
|
|
|
3513
3583
|
function formatRoutes(routes) {
|
|
3514
3584
|
return Array.isArray(routes) ? routes.join(", ") : routes;
|
|
3515
3585
|
}
|
|
3586
|
+
//#endregion
|
|
3587
|
+
//#region src/migrate/provider.ts
|
|
3588
|
+
/**
|
|
3589
|
+
* One construct's migrations and seeds, read from its folders.
|
|
3590
|
+
*
|
|
3591
|
+
* Two kinds of file, told apart by extension rather than by configuration: a
|
|
3592
|
+
* script exports `up` (and may export `down`) — or `seed(db, { stage })`, in
|
|
3593
|
+
* `seeds/` — and a
|
|
3594
|
+
* `.sql` file is run whole. The second is what makes a folder independent of
|
|
3595
|
+
* the client the app uses — Better Auth's schema is SQL whatever the
|
|
3596
|
+
* application queries with.
|
|
3597
|
+
*/
|
|
3598
|
+
const SCRIPTS = /* @__PURE__ */ new Set([
|
|
3599
|
+
".ts",
|
|
3600
|
+
".mts",
|
|
3601
|
+
".js",
|
|
3602
|
+
".mjs"
|
|
3603
|
+
]);
|
|
3604
|
+
/** Two files in one folder that would be one migration. */
|
|
3605
|
+
var DuplicateMigration = class extends Error {
|
|
3606
|
+
folder;
|
|
3607
|
+
migration;
|
|
3608
|
+
constructor(folder, migration) {
|
|
3609
|
+
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.`);
|
|
3610
|
+
this.folder = folder;
|
|
3611
|
+
this.migration = migration;
|
|
3612
|
+
this.name = "DuplicateMigration";
|
|
3613
|
+
}
|
|
3614
|
+
};
|
|
3615
|
+
/** A script in a migrations folder that exports no `up`. */
|
|
3616
|
+
var MigrationHasNoUp = class extends Error {
|
|
3617
|
+
file;
|
|
3618
|
+
constructor(file) {
|
|
3619
|
+
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.`);
|
|
3620
|
+
this.file = file;
|
|
3621
|
+
this.name = "MigrationHasNoUp";
|
|
3622
|
+
}
|
|
3623
|
+
};
|
|
3624
|
+
/** A script in a seeds folder that exports no `seed`. */
|
|
3625
|
+
var SeedHasNoSeed = class extends Error {
|
|
3626
|
+
file;
|
|
3627
|
+
constructor(file) {
|
|
3628
|
+
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.`);
|
|
3629
|
+
this.file = file;
|
|
3630
|
+
this.name = "SeedHasNoSeed";
|
|
3631
|
+
}
|
|
3632
|
+
};
|
|
3633
|
+
/**
|
|
3634
|
+
* The migration files in a folder, by name, without reading them.
|
|
3635
|
+
*
|
|
3636
|
+
* Also what `gkm dev` compares against a construct's history to report what
|
|
3637
|
+
* is pending, which is why it is separate from loading them.
|
|
3638
|
+
*/
|
|
3639
|
+
async function migrationFiles(folder) {
|
|
3640
|
+
const entries = await readdir(folder, { withFileTypes: true }).catch((error) => {
|
|
3641
|
+
if (error.code === "ENOENT") return [];
|
|
3642
|
+
throw error;
|
|
3643
|
+
});
|
|
3644
|
+
const files = /* @__PURE__ */ new Map();
|
|
3645
|
+
for (const entry of entries) {
|
|
3646
|
+
if (!entry.isFile() || !isMigration(entry.name)) continue;
|
|
3647
|
+
const name = entry.name.slice(0, -extname(entry.name).length);
|
|
3648
|
+
if (files.has(name)) throw new DuplicateMigration(folder, name);
|
|
3649
|
+
files.set(name, join(folder, entry.name));
|
|
3650
|
+
}
|
|
3651
|
+
return files;
|
|
3652
|
+
}
|
|
3653
|
+
/** A Kysely provider over one construct's folder. */
|
|
3654
|
+
function folderProvider(folder, raw) {
|
|
3655
|
+
return { async getMigrations() {
|
|
3656
|
+
const migrations = {};
|
|
3657
|
+
for (const [name, file] of await migrationFiles(folder)) migrations[name] = extname(file) === ".sql" ? await sqlMigration(file, raw) : await scriptMigration(file);
|
|
3658
|
+
return migrations;
|
|
3659
|
+
} };
|
|
3660
|
+
}
|
|
3661
|
+
/**
|
|
3662
|
+
* The seeds in a folder, loaded, in name order.
|
|
3663
|
+
*
|
|
3664
|
+
* Every one runs on every pass — there is no history to consult — so the order
|
|
3665
|
+
* is the only thing between two seeds where one reads what another writes.
|
|
3666
|
+
* Name them to sort that way.
|
|
3667
|
+
*/
|
|
3668
|
+
async function loadSeeds(folder, raw) {
|
|
3669
|
+
const files = [...await migrationFiles(folder)].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
3670
|
+
const seeds = [];
|
|
3671
|
+
for (const [name, file] of files) seeds.push({
|
|
3672
|
+
name,
|
|
3673
|
+
run: extname(file) === ".sql" ? (await sqlMigration(file, raw)).up : await scriptSeed(file)
|
|
3674
|
+
});
|
|
3675
|
+
return seeds;
|
|
3676
|
+
}
|
|
3677
|
+
/**
|
|
3678
|
+
* A seed script, imported as it is on disk now.
|
|
3679
|
+
*
|
|
3680
|
+
* Keyed by its modification time: a seed is run again after it is edited, and
|
|
3681
|
+
* a process that has imported it once would otherwise run the old one.
|
|
3682
|
+
*/
|
|
3683
|
+
async function scriptSeed(file) {
|
|
3684
|
+
const url = pathToFileURL(file);
|
|
3685
|
+
url.searchParams.set("mtime", String((await stat(file)).mtimeMs));
|
|
3686
|
+
const module = await import(url.href);
|
|
3687
|
+
if (typeof module.seed !== "function") throw new SeedHasNoSeed(file);
|
|
3688
|
+
return module.seed;
|
|
3689
|
+
}
|
|
3690
|
+
function isMigration(file) {
|
|
3691
|
+
if (file.endsWith(".d.ts")) return false;
|
|
3692
|
+
if (/\.(spec|test)\.[cm]?[jt]s$/.test(file)) return false;
|
|
3693
|
+
const extension = extname(file);
|
|
3694
|
+
return extension === ".sql" || SCRIPTS.has(extension);
|
|
3695
|
+
}
|
|
3696
|
+
async function scriptMigration(file) {
|
|
3697
|
+
const module = await import(pathToFileURL(file).href);
|
|
3698
|
+
if (typeof module.up !== "function") throw new MigrationHasNoUp(file);
|
|
3699
|
+
return {
|
|
3700
|
+
up: module.up,
|
|
3701
|
+
...typeof module.down === "function" ? { down: module.down } : {}
|
|
3702
|
+
};
|
|
3703
|
+
}
|
|
3704
|
+
/**
|
|
3705
|
+
* A `.sql` file, run whole.
|
|
3706
|
+
*
|
|
3707
|
+
* One query rather than split on `;`: with no parameters, Postgres takes
|
|
3708
|
+
* several statements in one message, and splitting by hand breaks on the first
|
|
3709
|
+
* `;` inside a string or a function body. There is no `down` — a generated
|
|
3710
|
+
* schema is rolled back by a migration that says how, not by guessing.
|
|
3711
|
+
*/
|
|
3712
|
+
async function sqlMigration(file, raw) {
|
|
3713
|
+
const statements = await readFile(file, "utf-8");
|
|
3714
|
+
return { up: async (db) => {
|
|
3715
|
+
await raw.raw(statements).execute(db);
|
|
3716
|
+
} };
|
|
3717
|
+
}
|
|
3718
|
+
//#endregion
|
|
3719
|
+
//#region src/migrate/databases.ts
|
|
3720
|
+
/**
|
|
3721
|
+
* Bring each construct's schema up to its migrations folder.
|
|
3722
|
+
*
|
|
3723
|
+
* Which constructs, in what order, from which folder, is `migrationTargets`'
|
|
3724
|
+
* answer and nobody else's. What is decided here is only how one target is
|
|
3725
|
+
* run: as its owner, in its own schema, with a history of its own.
|
|
3726
|
+
*/
|
|
3727
|
+
/** A folder under `db/` that names no construct. */
|
|
3728
|
+
var UnknownMigrationFolder = class extends Error {
|
|
3729
|
+
folder;
|
|
3730
|
+
known;
|
|
3731
|
+
constructor(folder, known) {
|
|
3732
|
+
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.`);
|
|
3733
|
+
this.folder = folder;
|
|
3734
|
+
this.known = known;
|
|
3735
|
+
this.name = "UnknownMigrationFolder";
|
|
3736
|
+
}
|
|
3737
|
+
};
|
|
3738
|
+
/** A construct was asked for by a name that takes no migrations. */
|
|
3739
|
+
var NoSuchMigrationTarget = class extends Error {
|
|
3740
|
+
construct;
|
|
3741
|
+
known;
|
|
3742
|
+
constructor(construct, known) {
|
|
3743
|
+
super(`'${construct}' is not a database construct. ` + (known.length > 0 ? `The ones with migrations: ${known.join(", ")}.` : "This project declares no database."));
|
|
3744
|
+
this.construct = construct;
|
|
3745
|
+
this.known = known;
|
|
3746
|
+
this.name = "NoSuchMigrationTarget";
|
|
3747
|
+
}
|
|
3748
|
+
};
|
|
3749
|
+
/**
|
|
3750
|
+
* A migration left directly in a construct's folder, where nothing reads it.
|
|
3751
|
+
*
|
|
3752
|
+
* Migrations moved into `migrations/` so seeds could sit beside them. A file
|
|
3753
|
+
* still at the old level would otherwise never run, and nothing would say so.
|
|
3754
|
+
*/
|
|
3755
|
+
var MigrationsOutsideFolder = class extends Error {
|
|
3756
|
+
folder;
|
|
3757
|
+
files;
|
|
3758
|
+
constructor(folder, files) {
|
|
3759
|
+
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.`);
|
|
3760
|
+
this.folder = folder;
|
|
3761
|
+
this.files = files;
|
|
3762
|
+
this.name = "MigrationsOutsideFolder";
|
|
3763
|
+
}
|
|
3764
|
+
};
|
|
3765
|
+
/** A folder inside a construct's folder that is neither `migrations` nor `seeds`. */
|
|
3766
|
+
var UnknownDatabaseFolder = class extends Error {
|
|
3767
|
+
folder;
|
|
3768
|
+
constructor(folder) {
|
|
3769
|
+
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.`);
|
|
3770
|
+
this.folder = folder;
|
|
3771
|
+
this.name = "UnknownDatabaseFolder";
|
|
3772
|
+
}
|
|
3773
|
+
};
|
|
3774
|
+
/** A seed failed; its own writes are rolled back, the ones before it stay. */
|
|
3775
|
+
var SeedFailed = class extends Error {
|
|
3776
|
+
construct;
|
|
3777
|
+
seed;
|
|
3778
|
+
cause;
|
|
3779
|
+
constructor(construct, seed, cause) {
|
|
3780
|
+
super(`${construct}: seed '${seed}' failed — ${cause instanceof Error ? cause.message : String(cause)}. Its writes were rolled back; fix it and run it again.`);
|
|
3781
|
+
this.construct = construct;
|
|
3782
|
+
this.seed = seed;
|
|
3783
|
+
this.cause = cause;
|
|
3784
|
+
this.name = "SeedFailed";
|
|
3785
|
+
}
|
|
3786
|
+
};
|
|
3787
|
+
/** A construct with roles has no owner URL to migrate with. */
|
|
3788
|
+
var NoOwnerCredential = class extends Error {
|
|
3789
|
+
construct;
|
|
3790
|
+
key;
|
|
3791
|
+
constructor(construct, key) {
|
|
3792
|
+
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?`);
|
|
3793
|
+
this.construct = construct;
|
|
3794
|
+
this.key = key;
|
|
3795
|
+
this.name = "NoOwnerCredential";
|
|
3796
|
+
}
|
|
3797
|
+
};
|
|
3798
|
+
/** A migration failed; the ones before it in the run stay applied. */
|
|
3799
|
+
var MigrationFailed = class extends Error {
|
|
3800
|
+
construct;
|
|
3801
|
+
migration;
|
|
3802
|
+
cause;
|
|
3803
|
+
constructor(construct, migration, cause) {
|
|
3804
|
+
super(`${construct}: migration ${migration ? `'${migration}' ` : ""}failed — ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
3805
|
+
this.construct = construct;
|
|
3806
|
+
this.migration = migration;
|
|
3807
|
+
this.cause = cause;
|
|
3808
|
+
this.name = "MigrationFailed";
|
|
3809
|
+
}
|
|
3810
|
+
};
|
|
3811
|
+
/** The project's Kysely could not be found from where a database is declared. */
|
|
3812
|
+
var KyselyNotFound = class extends Error {
|
|
3813
|
+
from;
|
|
3814
|
+
constructor(from) {
|
|
3815
|
+
super(`Could not resolve 'kysely' from ${from}. Migrations run with the project's own Kysely — install it where the database is declared.`);
|
|
3816
|
+
this.from = from;
|
|
3817
|
+
this.name = "KyselyNotFound";
|
|
3818
|
+
}
|
|
3819
|
+
};
|
|
3820
|
+
/** Apply every pending migration, construct by construct, parents first. */
|
|
3821
|
+
async function migrateDatabases(options) {
|
|
3822
|
+
const runs = [];
|
|
3823
|
+
for (const target of await targetsFor(options)) {
|
|
3824
|
+
if ((await migrationFiles(join(options.root, target.migrations))).size === 0) {
|
|
3825
|
+
runs.push({
|
|
3826
|
+
target,
|
|
3827
|
+
applied: []
|
|
3828
|
+
});
|
|
3829
|
+
continue;
|
|
3830
|
+
}
|
|
3831
|
+
const applied = await withMigrator(options, target, async (migrator) => {
|
|
3832
|
+
const { error, results = [] } = await migrator.migrateToLatest();
|
|
3833
|
+
if (error) {
|
|
3834
|
+
const failed = results.find((result) => result.status === "Error");
|
|
3835
|
+
throw new MigrationFailed(target.id, failed?.migrationName, error);
|
|
3836
|
+
}
|
|
3837
|
+
return results.filter((result) => result.status === "Success").map((result) => result.migrationName);
|
|
3838
|
+
});
|
|
3839
|
+
runs.push({
|
|
3840
|
+
target,
|
|
3841
|
+
applied
|
|
3842
|
+
});
|
|
3843
|
+
}
|
|
3844
|
+
return runs;
|
|
3845
|
+
}
|
|
3846
|
+
/**
|
|
3847
|
+
* Run every construct's seeds, parents first — each one, every time, on every
|
|
3848
|
+
* stage, production included. Each is handed the stage, so a seed decides
|
|
3849
|
+
* what belongs where.
|
|
3850
|
+
*
|
|
3851
|
+
* Seeds are reference data kept up to date by being run again, so there is no
|
|
3852
|
+
* history: a seed is an upsert, and a changed one is applied by the next run.
|
|
3853
|
+
* Each runs in its own transaction, as the construct's owner, so a failure
|
|
3854
|
+
* leaves nothing half-written. Assumes the schema is migrated —
|
|
3855
|
+
* {@link migrateAndSeed} is what makes that true.
|
|
3856
|
+
*/
|
|
3857
|
+
async function seedDatabases(options) {
|
|
3858
|
+
const runs = [];
|
|
3859
|
+
for (const target of await targetsFor(options)) {
|
|
3860
|
+
const folder = join(options.root, target.seeds);
|
|
3861
|
+
if ((await migrationFiles(folder)).size === 0) {
|
|
3862
|
+
runs.push({
|
|
3863
|
+
target,
|
|
3864
|
+
seeded: []
|
|
3865
|
+
});
|
|
3866
|
+
continue;
|
|
3867
|
+
}
|
|
3868
|
+
const seeded = await withOwner(options, target, async (db, kysely) => {
|
|
3869
|
+
const names = [];
|
|
3870
|
+
for (const seed of await loadSeeds(folder, kysely.sql)) {
|
|
3871
|
+
try {
|
|
3872
|
+
await db.transaction().execute((trx) => seed.run(trx, { stage: options.stage }));
|
|
3873
|
+
} catch (error) {
|
|
3874
|
+
throw new SeedFailed(target.id, seed.name, error);
|
|
3875
|
+
}
|
|
3876
|
+
names.push(seed.name);
|
|
3877
|
+
}
|
|
3878
|
+
return names;
|
|
3879
|
+
});
|
|
3880
|
+
runs.push({
|
|
3881
|
+
target,
|
|
3882
|
+
seeded
|
|
3883
|
+
});
|
|
3884
|
+
}
|
|
3885
|
+
return runs;
|
|
3886
|
+
}
|
|
3887
|
+
/**
|
|
3888
|
+
* Migrate, then seed: a seed only ever runs against the schema it was written
|
|
3889
|
+
* for.
|
|
3890
|
+
*
|
|
3891
|
+
* With `only`, the constructs it lives in are migrated too — a tenant's seed
|
|
3892
|
+
* cannot run before the database that owns its schema exists — and only it is
|
|
3893
|
+
* seeded.
|
|
3894
|
+
*/
|
|
3895
|
+
async function migrateAndSeed(options) {
|
|
3896
|
+
const migrations = [];
|
|
3897
|
+
for (const target of await withParents(options)) migrations.push(...await migrateDatabases({
|
|
3898
|
+
...options,
|
|
3899
|
+
only: target.id
|
|
3900
|
+
}));
|
|
3901
|
+
return {
|
|
3902
|
+
migrations,
|
|
3903
|
+
seeds: await seedDatabases(options)
|
|
3904
|
+
};
|
|
3905
|
+
}
|
|
3906
|
+
/** The targets `only` names, after the ones it lives in; all without it. */
|
|
3907
|
+
async function withParents(options) {
|
|
3908
|
+
const chosen = await targetsFor(options);
|
|
3909
|
+
if (!options.only) return chosen;
|
|
3910
|
+
const all = migrationTargets(options.manifest);
|
|
3911
|
+
const ids = /* @__PURE__ */ new Set();
|
|
3912
|
+
for (const target of chosen) {
|
|
3913
|
+
let current = target;
|
|
3914
|
+
while (current) {
|
|
3915
|
+
ids.add(current.id);
|
|
3916
|
+
const parent = current.of;
|
|
3917
|
+
current = parent ? all.find((t) => t.id === parent) : void 0;
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
return all.filter((target) => ids.has(target.id));
|
|
3921
|
+
}
|
|
3922
|
+
/**
|
|
3923
|
+
* What each construct's folder has that its history does not — without
|
|
3924
|
+
* applying any of it. What `gkm dev` reports.
|
|
3925
|
+
*/
|
|
3926
|
+
async function pendingMigrations(options) {
|
|
3927
|
+
const found = [];
|
|
3928
|
+
for (const target of await targetsFor(options)) {
|
|
3929
|
+
if ((await migrationFiles(join(options.root, target.migrations))).size === 0) continue;
|
|
3930
|
+
const pending = await withMigrator(options, target, async (migrator) => (await migrator.getMigrations()).filter((migration) => migration.executedAt === void 0).map((migration) => migration.name));
|
|
3931
|
+
if (pending.length > 0) found.push({
|
|
3932
|
+
target,
|
|
3933
|
+
pending
|
|
3934
|
+
});
|
|
3935
|
+
}
|
|
3936
|
+
return found;
|
|
3937
|
+
}
|
|
3938
|
+
/**
|
|
3939
|
+
* The targets this run covers, with every folder under `db/` accounted for.
|
|
3940
|
+
*
|
|
3941
|
+
* A folder that names no construct is an error rather than something to skip:
|
|
3942
|
+
* a typo in its name is otherwise a migration that silently never runs.
|
|
3943
|
+
*/
|
|
3944
|
+
async function targetsFor(options) {
|
|
3945
|
+
const targets = migrationTargets(options.manifest);
|
|
3946
|
+
const folders = targets.map((target) => target.folder);
|
|
3947
|
+
for (const folder of await foldersIn(join(options.root, MIGRATIONS_ROOT))) {
|
|
3948
|
+
const relative = `${MIGRATIONS_ROOT}/${folder}`;
|
|
3949
|
+
if (!folders.includes(relative)) throw new UnknownMigrationFolder(relative, folders);
|
|
3950
|
+
await checkLayout(options.root, relative);
|
|
3951
|
+
}
|
|
3952
|
+
if (!options.only) return targets;
|
|
3953
|
+
const id = canonicalId(options.only);
|
|
3954
|
+
const chosen = targets.filter((target) => target.id === id);
|
|
3955
|
+
if (chosen.length === 0) throw new NoSuchMigrationTarget(options.only, targets.map((target) => target.id));
|
|
3956
|
+
return chosen;
|
|
3957
|
+
}
|
|
3958
|
+
/**
|
|
3959
|
+
* A construct's folder holds `migrations/` and `seeds/`, and nothing else —
|
|
3960
|
+
* anything beside them is a file or folder nothing would ever run.
|
|
3961
|
+
*/
|
|
3962
|
+
async function checkLayout(root, folder) {
|
|
3963
|
+
const entries = await readdir(join(root, folder), { withFileTypes: true });
|
|
3964
|
+
const loose = entries.filter((entry) => entry.isFile() && !entry.name.startsWith(".")).map((entry) => entry.name);
|
|
3965
|
+
if (loose.length > 0) throw new MigrationsOutsideFolder(folder, loose);
|
|
3966
|
+
for (const entry of entries) if (entry.isDirectory() && !DATABASE_FOLDERS.includes(entry.name)) throw new UnknownDatabaseFolder(`${folder}/${entry.name}`);
|
|
3967
|
+
}
|
|
3968
|
+
async function foldersIn(dir) {
|
|
3969
|
+
return (await readdir(dir, { withFileTypes: true }).catch((error) => {
|
|
3970
|
+
if (error.code === "ENOENT") return [];
|
|
3971
|
+
throw error;
|
|
3972
|
+
})).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
3973
|
+
}
|
|
3974
|
+
/**
|
|
3975
|
+
* A migrator for one target, connected as its owner.
|
|
3976
|
+
*
|
|
3977
|
+
* No `migrationTableSchema`: the owner role's `search_path` is pinned to the
|
|
3978
|
+
* construct's schema, so the history lands there — `app.kysely_migration` for
|
|
3979
|
+
* the database, `auth_database.kysely_migration` for its tenant — and two
|
|
3980
|
+
* constructs in one Postgres never share one.
|
|
3981
|
+
*
|
|
3982
|
+
* Unordered migrations are allowed. Files are timestamped, so a branch merged
|
|
3983
|
+
* after another's migration ran carries an older one; refusing it would make
|
|
3984
|
+
* every such merge a manual repair.
|
|
3985
|
+
*/
|
|
3986
|
+
async function withMigrator(options, target, run) {
|
|
3987
|
+
return withOwner(options, target, (db, { Migrator, sql }) => run(new Migrator({
|
|
3988
|
+
db,
|
|
3989
|
+
provider: folderProvider(join(options.root, target.migrations), sql),
|
|
3990
|
+
allowUnorderedMigrations: true
|
|
3991
|
+
})));
|
|
3992
|
+
}
|
|
3993
|
+
/**
|
|
3994
|
+
* One target's database, connected as its owner and closed afterwards — what
|
|
3995
|
+
* migrations and seeds both run as.
|
|
3996
|
+
*/
|
|
3997
|
+
async function withOwner(options, target, run) {
|
|
3998
|
+
const kysely = await kyselyFrom(options.sources?.[target.id]?.file ?? (target.of ? options.sources?.[target.of]?.file : void 0) ?? join(options.root, "package.json"));
|
|
3999
|
+
const db = new kysely.Kysely({ dialect: new kysely.PostgresDialect({ pool: new pg.Pool(poolConfig(ownerUrl(options, target))) }) });
|
|
4000
|
+
try {
|
|
4001
|
+
return await run(db, kysely);
|
|
4002
|
+
} finally {
|
|
4003
|
+
await db.destroy();
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
/**
|
|
4007
|
+
* The owner's URL, with no fallback to the runtime one.
|
|
4008
|
+
*
|
|
4009
|
+
* The runtime role can create nothing, so falling back would only move the
|
|
4010
|
+
* failure to the first `CREATE TABLE` and make it read like a permissions
|
|
4011
|
+
* bug. The one exception is a database declared `roles: false`, where there is
|
|
4012
|
+
* no owner — both URLs are the master credential by design.
|
|
4013
|
+
*/
|
|
4014
|
+
function ownerUrl(options, target) {
|
|
4015
|
+
const key = provideKey(target.id, "ownerUrl");
|
|
4016
|
+
const owner = options.env[key];
|
|
4017
|
+
if (owner) return owner;
|
|
4018
|
+
const database = options.manifest[target.of ?? target.id];
|
|
4019
|
+
if (database?.kind === "database" && database.roles === false) {
|
|
4020
|
+
const url = options.env[provideKey(target.id, "url")];
|
|
4021
|
+
if (url) return url;
|
|
4022
|
+
}
|
|
4023
|
+
throw new NoOwnerCredential(target.id, key);
|
|
4024
|
+
}
|
|
4025
|
+
/**
|
|
4026
|
+
* Pool settings for a URL, with `?search_path=` as the startup option Postgres
|
|
4027
|
+
* reads. Only a `roles: false` URL carries it — a role pins its own.
|
|
4028
|
+
*/
|
|
4029
|
+
function poolConfig(url) {
|
|
4030
|
+
const parsed = new URL(url);
|
|
4031
|
+
const searchPath = parsed.searchParams.get("search_path");
|
|
4032
|
+
if (!searchPath) return { connectionString: url };
|
|
4033
|
+
parsed.searchParams.delete("search_path");
|
|
4034
|
+
return {
|
|
4035
|
+
connectionString: parsed.toString(),
|
|
4036
|
+
options: `-c search_path=${searchPath}`
|
|
4037
|
+
};
|
|
4038
|
+
}
|
|
4039
|
+
/** Kysely as the project resolves it from `from`. */
|
|
4040
|
+
async function kyselyFrom(from) {
|
|
4041
|
+
const require = createRequire(from);
|
|
4042
|
+
try {
|
|
4043
|
+
const [main, migration] = await Promise.all([import(pathToFileURL(require.resolve("kysely")).href), import(pathToFileURL(require.resolve("kysely/migration")).href)]);
|
|
4044
|
+
return {
|
|
4045
|
+
Kysely: main.Kysely,
|
|
4046
|
+
PostgresDialect: main.PostgresDialect,
|
|
4047
|
+
sql: main.sql,
|
|
4048
|
+
Migrator: migration.Migrator
|
|
4049
|
+
};
|
|
4050
|
+
} catch {
|
|
4051
|
+
throw new KyselyNotFound(from);
|
|
4052
|
+
}
|
|
4053
|
+
}
|
|
4054
|
+
//#endregion
|
|
4055
|
+
//#region src/compose/docker.ts
|
|
4056
|
+
/**
|
|
4057
|
+
* The Docker operations `gkm compose` needs, behind an interface.
|
|
4058
|
+
*
|
|
4059
|
+
* Small on purpose, like reconcile's: the rules about *when* each runs — the
|
|
4060
|
+
* registry is asked before anything is pulled, nothing is started for a tag
|
|
4061
|
+
* that is missing — are asserted against a fake, and this file is only the
|
|
4062
|
+
* wiring to the real CLI.
|
|
4063
|
+
*
|
|
4064
|
+
* Every call is an argument array. Image refs, tags and paths come from the
|
|
4065
|
+
* workspace, and none of them reaches a shell.
|
|
4066
|
+
*/
|
|
4067
|
+
/** How long a stack has to pass its health checks. */
|
|
4068
|
+
const WAIT_TIMEOUT_S = 180;
|
|
4069
|
+
/** `docker compose port` printed nothing a port can be read from. */
|
|
4070
|
+
var ServicePortUnknown = class extends Error {
|
|
4071
|
+
service;
|
|
4072
|
+
inside;
|
|
4073
|
+
output;
|
|
4074
|
+
constructor(service, inside, output) {
|
|
4075
|
+
super(`'${service}' publishes no host port for ${inside} (docker said: ${output.trim() || "nothing"}). Is it running? \`docker compose ps\` shows the stack.`);
|
|
4076
|
+
this.service = service;
|
|
4077
|
+
this.inside = inside;
|
|
4078
|
+
this.output = output;
|
|
4079
|
+
this.name = "ServicePortUnknown";
|
|
4080
|
+
}
|
|
4081
|
+
};
|
|
4082
|
+
function compose(stack, args) {
|
|
4083
|
+
return [
|
|
4084
|
+
"compose",
|
|
4085
|
+
"-p",
|
|
4086
|
+
stack.project,
|
|
4087
|
+
"-f",
|
|
4088
|
+
stack.file,
|
|
4089
|
+
...args
|
|
4090
|
+
];
|
|
4091
|
+
}
|
|
4092
|
+
/** Run a program and keep everything it said, whatever it exits with. */
|
|
4093
|
+
function capture(command, args, timeoutMs = 6e4) {
|
|
4094
|
+
return new Promise((resolve, reject) => {
|
|
4095
|
+
const child = spawn(command, [...args], {
|
|
4096
|
+
stdio: [
|
|
4097
|
+
"ignore",
|
|
4098
|
+
"pipe",
|
|
4099
|
+
"pipe"
|
|
4100
|
+
],
|
|
4101
|
+
shell: false
|
|
4102
|
+
});
|
|
4103
|
+
let stdout = "";
|
|
4104
|
+
let stderr = "";
|
|
4105
|
+
child.stdout.on("data", (chunk) => {
|
|
4106
|
+
stdout += chunk.toString();
|
|
4107
|
+
});
|
|
4108
|
+
child.stderr.on("data", (chunk) => {
|
|
4109
|
+
stderr += chunk.toString();
|
|
4110
|
+
});
|
|
4111
|
+
const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
|
|
4112
|
+
timer.unref?.();
|
|
4113
|
+
child.on("error", (error) => {
|
|
4114
|
+
clearTimeout(timer);
|
|
4115
|
+
reject(error);
|
|
4116
|
+
});
|
|
4117
|
+
child.on("close", (code) => {
|
|
4118
|
+
clearTimeout(timer);
|
|
4119
|
+
resolve({
|
|
4120
|
+
code,
|
|
4121
|
+
stdout,
|
|
4122
|
+
stderr
|
|
4123
|
+
});
|
|
4124
|
+
});
|
|
4125
|
+
});
|
|
4126
|
+
}
|
|
4127
|
+
/**
|
|
4128
|
+
* Whether a failed `docker manifest inspect` means the tag is not there, as
|
|
4129
|
+
* opposed to the registry not answering.
|
|
4130
|
+
*
|
|
4131
|
+
* Read off the message because that is all the CLI gives. Anything not
|
|
4132
|
+
* recognisably "no such tag" is treated as the registry being unreachable:
|
|
4133
|
+
* a registry that refused credentials must not be reported as a tag that was
|
|
4134
|
+
* never pushed, or the fix offered would be the wrong one.
|
|
4135
|
+
*/
|
|
4136
|
+
function isMissingManifest(output) {
|
|
4137
|
+
return /no such manifest|manifest unknown|manifest for .* not found|not found: manifest/i.test(output);
|
|
4138
|
+
}
|
|
4139
|
+
const dockerCompose = {
|
|
4140
|
+
async lookup(ref) {
|
|
4141
|
+
const { code, stdout, stderr } = await capture("docker", [
|
|
4142
|
+
"manifest",
|
|
4143
|
+
"inspect",
|
|
4144
|
+
ref
|
|
4145
|
+
]);
|
|
4146
|
+
if (code === 0) return {
|
|
4147
|
+
ref,
|
|
4148
|
+
status: "found"
|
|
4149
|
+
};
|
|
4150
|
+
const output = `${stderr}\n${stdout}`.trim();
|
|
4151
|
+
return isMissingManifest(output) ? {
|
|
4152
|
+
ref,
|
|
4153
|
+
status: "missing"
|
|
4154
|
+
} : {
|
|
4155
|
+
ref,
|
|
4156
|
+
status: "unreachable",
|
|
4157
|
+
detail: output
|
|
4158
|
+
};
|
|
4159
|
+
},
|
|
4160
|
+
async build(stack, services) {
|
|
4161
|
+
await run$1("docker", compose(stack, ["build", ...services]), {
|
|
4162
|
+
cwd: stack.cwd,
|
|
4163
|
+
env: {
|
|
4164
|
+
...process.env,
|
|
4165
|
+
DOCKER_BUILDKIT: "1"
|
|
4166
|
+
}
|
|
4167
|
+
});
|
|
4168
|
+
},
|
|
4169
|
+
async pull(stack, services) {
|
|
4170
|
+
await run$1("docker", compose(stack, ["pull", ...services]), { cwd: stack.cwd });
|
|
4171
|
+
},
|
|
4172
|
+
async up(stack, services) {
|
|
4173
|
+
await run$1("docker", compose(stack, [
|
|
4174
|
+
"up",
|
|
4175
|
+
"-d",
|
|
4176
|
+
"--wait",
|
|
4177
|
+
`--wait-timeout=${WAIT_TIMEOUT_S}`,
|
|
4178
|
+
...services ? [] : ["--remove-orphans"],
|
|
4179
|
+
...services ?? []
|
|
4180
|
+
]), { cwd: stack.cwd });
|
|
4181
|
+
},
|
|
4182
|
+
async down(stack) {
|
|
4183
|
+
await run$1("docker", compose(stack, ["down"]), { cwd: stack.cwd });
|
|
4184
|
+
},
|
|
4185
|
+
async port(stack, service, inside) {
|
|
4186
|
+
const output = await runOutput("docker", compose(stack, [
|
|
4187
|
+
"port",
|
|
4188
|
+
service,
|
|
4189
|
+
String(inside)
|
|
4190
|
+
]), { cwd: stack.cwd });
|
|
4191
|
+
const port = Number(output.trim().split(":").pop());
|
|
4192
|
+
if (!Number.isInteger(port) || port <= 0) throw new ServicePortUnknown(service, inside, output);
|
|
4193
|
+
return port;
|
|
4194
|
+
},
|
|
4195
|
+
async copyOut(stack, service, from, to) {
|
|
4196
|
+
await mkdir(dirname(to), { recursive: true });
|
|
4197
|
+
await run$1("docker", compose(stack, [
|
|
4198
|
+
"cp",
|
|
4199
|
+
`${service}:${from}`,
|
|
4200
|
+
to
|
|
4201
|
+
]), { cwd: stack.cwd });
|
|
4202
|
+
},
|
|
4203
|
+
async digest(ref) {
|
|
4204
|
+
const { code, stdout } = await capture("docker", [
|
|
4205
|
+
"image",
|
|
4206
|
+
"inspect",
|
|
4207
|
+
"--format={{json .RepoDigests}} {{.Id}}",
|
|
4208
|
+
ref
|
|
4209
|
+
]);
|
|
4210
|
+
if (code !== 0) return void 0;
|
|
4211
|
+
const [json = "[]", id] = stdout.trim().split(" ");
|
|
4212
|
+
const repository = ref.split("@")[0].replace(/:[\w][\w.-]*$/, "");
|
|
4213
|
+
try {
|
|
4214
|
+
const digests = JSON.parse(json);
|
|
4215
|
+
const match = Array.isArray(digests) ? digests.find((entry) => typeof entry === "string" && entry.startsWith(`${repository}@`)) : void 0;
|
|
4216
|
+
if (match) return match.slice(repository.length + 1);
|
|
4217
|
+
} catch {}
|
|
4218
|
+
return id || void 0;
|
|
4219
|
+
}
|
|
4220
|
+
};
|
|
4221
|
+
//#endregion
|
|
4222
|
+
//#region src/compose/images.ts
|
|
4223
|
+
/**
|
|
4224
|
+
* The tag a site's image carries for a release.
|
|
4225
|
+
*
|
|
4226
|
+
* A site's public URLs are inlined when it is built, so one bundle serves one
|
|
4227
|
+
* stage: `v1.4.0-production` and `v1.4.0-staging` are two images of the same
|
|
4228
|
+
* commit. A backend reads its URLs at runtime, so one image serves every stage
|
|
4229
|
+
* and its tag is the release's alone.
|
|
4230
|
+
*/
|
|
4231
|
+
function siteTag(tag, stage) {
|
|
4232
|
+
return `${tag}-${stage}`;
|
|
4233
|
+
}
|
|
4234
|
+
/** Images a tag names that the registry does not have. */
|
|
4235
|
+
var ImageTagNotFound = class extends Error {
|
|
4236
|
+
tag;
|
|
4237
|
+
refs;
|
|
4238
|
+
constructor(tag, refs) {
|
|
4239
|
+
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.`);
|
|
4240
|
+
this.tag = tag;
|
|
4241
|
+
this.refs = refs;
|
|
4242
|
+
this.name = "ImageTagNotFound";
|
|
4243
|
+
}
|
|
4244
|
+
};
|
|
4245
|
+
/** The registry did not answer, or did not let this machine in. */
|
|
4246
|
+
var RegistryUnreachable = class extends Error {
|
|
4247
|
+
ref;
|
|
4248
|
+
detail;
|
|
4249
|
+
constructor(ref, detail) {
|
|
4250
|
+
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>\`).`);
|
|
4251
|
+
this.ref = ref;
|
|
4252
|
+
this.detail = detail;
|
|
4253
|
+
this.name = "RegistryUnreachable";
|
|
4254
|
+
}
|
|
4255
|
+
};
|
|
4256
|
+
/**
|
|
4257
|
+
* Assert every image exists, before anything else happens.
|
|
4258
|
+
*
|
|
4259
|
+
* Asked all at once and reported all at once: a release missing two images is
|
|
4260
|
+
* one problem with two names, and finding the second only after fixing the
|
|
4261
|
+
* first is a slower way to learn the same thing.
|
|
4262
|
+
*
|
|
4263
|
+
* @throws {RegistryUnreachable} when the registry cannot say
|
|
4264
|
+
* @throws {ImageTagNotFound} listing every missing image
|
|
4265
|
+
*/
|
|
4266
|
+
async function assertImagesExist(docker, tag, images) {
|
|
4267
|
+
const lookups = await Promise.all(images.map((image) => docker.lookup(image.ref)));
|
|
4268
|
+
const unreachable = lookups.find((lookup) => lookup.status === "unreachable");
|
|
4269
|
+
if (unreachable?.status === "unreachable") throw new RegistryUnreachable(unreachable.ref, unreachable.detail);
|
|
4270
|
+
const missing = lookups.filter((lookup) => lookup.status === "missing").map((lookup) => lookup.ref);
|
|
4271
|
+
if (missing.length > 0) throw new ImageTagNotFound(tag, missing);
|
|
4272
|
+
}
|
|
4273
|
+
//#endregion
|
|
4274
|
+
//#region src/compose/caddyfile.ts
|
|
4275
|
+
/** The Caddyfile for a stack's edge. */
|
|
4276
|
+
function edgeCaddyfile(sites, options) {
|
|
4277
|
+
const global = options.local ? `{
|
|
4278
|
+
# No ACME and no public DNS: the CA is Caddy's own and the names resolve
|
|
4279
|
+
# to this machine.
|
|
4280
|
+
local_certs
|
|
4281
|
+
auto_https disable_redirects
|
|
4282
|
+
}` : options.email ? `{
|
|
4283
|
+
email ${options.email}
|
|
4284
|
+
}` : "";
|
|
4285
|
+
const blocks = sites.map(({ host, upstream }) => `https://${host} {${options.local ? `
|
|
4286
|
+
tls internal
|
|
4287
|
+
` : ""}
|
|
4288
|
+
# Host and X-Forwarded-* are passed through as Caddy does by default, so
|
|
4289
|
+
# an app builds redirects, cookies and links on the address a caller used.
|
|
4290
|
+
reverse_proxy ${upstream} {
|
|
4291
|
+
# Never buffer: a streamed response — server-sent events, a Next.js
|
|
4292
|
+
# RSC payload — reaches the client as each chunk is written.
|
|
4293
|
+
flush_interval -1
|
|
4294
|
+
}
|
|
4295
|
+
}`);
|
|
4296
|
+
return `${HEADER}${global ? `${global}\n\n` : ""}${blocks.join("\n\n")}\n`;
|
|
4297
|
+
}
|
|
4298
|
+
const HEADER = `# Generated by gkm compose from the construct manifest — do not edit.
|
|
4299
|
+
#
|
|
4300
|
+
# One host per API and site, each in front of its own container.
|
|
4301
|
+
`;
|
|
4302
|
+
//#endregion
|
|
4303
|
+
//#region src/compose/stack.ts
|
|
4304
|
+
/**
|
|
4305
|
+
* The stack a workspace runs as for one stage — pure.
|
|
4306
|
+
*
|
|
4307
|
+
* Everything `gkm compose` writes is decided here, as data: the compose file,
|
|
4308
|
+
* the Caddyfile, one env file per backend, and the image each app runs. The
|
|
4309
|
+
* command around it reads the workspace, asks the registry, writes the files
|
|
4310
|
+
* and starts Docker; none of those decide anything, so all of it can be
|
|
4311
|
+
* asserted without a daemon.
|
|
4312
|
+
*
|
|
4313
|
+
* The values come from the same derivations every other target uses. The
|
|
4314
|
+
* plan is reconcile's `planFor`, the keys an app reads are `appEnvKeys`, and
|
|
4315
|
+
* every URL is `envFor`'s — asked twice, once with each app at its public
|
|
4316
|
+
* address and once on the compose network, because a browser and a sibling
|
|
4317
|
+
* service reach the same API by different names.
|
|
4318
|
+
*/
|
|
4319
|
+
/** Where a stack's files are written, relative to the workspace root. */
|
|
4320
|
+
function stackDir(stage) {
|
|
4321
|
+
return `.gkm/compose/${stage}`;
|
|
4322
|
+
}
|
|
4323
|
+
/**
|
|
4324
|
+
* The compose project a stage's stack runs as.
|
|
4325
|
+
*
|
|
4326
|
+
* The deploy identity's scope, so two workspaces with one name in different
|
|
4327
|
+
* namespaces never share containers or volumes — and the stage, so a
|
|
4328
|
+
* machine can run two stages side by side.
|
|
4329
|
+
*/
|
|
4330
|
+
function composeProject(identity) {
|
|
4331
|
+
return `${identity.scope}-${identity.stage}`;
|
|
4332
|
+
}
|
|
4333
|
+
/** The ports the edge is published on, and the variables that move them. */
|
|
4334
|
+
const EDGE_PORT_ENV = {
|
|
4335
|
+
https: "GKM_COMPOSE_HTTPS_PORT",
|
|
4336
|
+
http: "GKM_COMPOSE_HTTP_PORT"
|
|
4337
|
+
};
|
|
4338
|
+
/**
|
|
4339
|
+
* Containers this target does not run.
|
|
4340
|
+
*
|
|
4341
|
+
* Object storage is the stage's own — an externally hosted bucket whose URL
|
|
4342
|
+
* the stage's secrets hold — and so is a deployed stage's mail. The AWS
|
|
4343
|
+
* emulator never applies: a stack is a server target, whose events are
|
|
4344
|
+
* pg-boss.
|
|
4345
|
+
*/
|
|
4346
|
+
const NOT_RUN = {
|
|
4347
|
+
minio: true,
|
|
4348
|
+
localstack: true,
|
|
4349
|
+
caddy: true
|
|
4350
|
+
};
|
|
4351
|
+
/** Kinds whose `<ID>_URL` a stage must be given, because nothing here runs them. */
|
|
4352
|
+
const EXTERNAL_KINDS = {
|
|
4353
|
+
objects: true,
|
|
4354
|
+
"file-server": true
|
|
4355
|
+
};
|
|
4356
|
+
/** The S3 client's credentials, read beside a bucket's URL when it has them. */
|
|
4357
|
+
const STORAGE_KEYS = [
|
|
4358
|
+
"AWS_ACCESS_KEY_ID",
|
|
4359
|
+
"AWS_SECRET_ACCESS_KEY",
|
|
4360
|
+
"AWS_REGION"
|
|
4361
|
+
];
|
|
4362
|
+
/** A bucket the stack needs and the stage was not given. */
|
|
4363
|
+
var BucketNotConfigured = class extends Error {
|
|
4364
|
+
app;
|
|
4365
|
+
id;
|
|
4366
|
+
key;
|
|
4367
|
+
stage;
|
|
4368
|
+
constructor(app, id, key, stage) {
|
|
4369
|
+
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}`);
|
|
4370
|
+
this.app = app;
|
|
4371
|
+
this.id = id;
|
|
4372
|
+
this.key = key;
|
|
4373
|
+
this.stage = stage;
|
|
4374
|
+
this.name = "BucketNotConfigured";
|
|
4375
|
+
}
|
|
4376
|
+
};
|
|
4377
|
+
/** A value only the stage's secrets can hold, and they do not. */
|
|
4378
|
+
var StageSecretMissing = class extends Error {
|
|
4379
|
+
app;
|
|
4380
|
+
key;
|
|
4381
|
+
stage;
|
|
4382
|
+
constructor(app, key, stage) {
|
|
4383
|
+
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}`);
|
|
4384
|
+
this.app = app;
|
|
4385
|
+
this.key = key;
|
|
4386
|
+
this.stage = stage;
|
|
4387
|
+
this.name = "StageSecretMissing";
|
|
4388
|
+
}
|
|
4389
|
+
};
|
|
4390
|
+
/** A deployed stage's secrets with no seed to derive its passwords from. */
|
|
4391
|
+
var StageSeedMissing = class extends Error {
|
|
4392
|
+
stage;
|
|
4393
|
+
constructor(stage) {
|
|
4394
|
+
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.`);
|
|
4395
|
+
this.stage = stage;
|
|
4396
|
+
this.name = "StageSeedMissing";
|
|
4397
|
+
}
|
|
4398
|
+
};
|
|
4399
|
+
/** A workspace with no API and no site to run. */
|
|
4400
|
+
var NothingToCompose = class extends Error {
|
|
4401
|
+
root;
|
|
4402
|
+
constructor(root) {
|
|
4403
|
+
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.`);
|
|
4404
|
+
this.root = root;
|
|
4405
|
+
this.name = "NothingToCompose";
|
|
4406
|
+
}
|
|
4407
|
+
};
|
|
4408
|
+
/** A value that cannot be written to an env file a line at a time. */
|
|
4409
|
+
var EnvValueMultiline = class extends Error {
|
|
4410
|
+
app;
|
|
4411
|
+
key;
|
|
4412
|
+
constructor(app, key) {
|
|
4413
|
+
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.`);
|
|
4414
|
+
this.app = app;
|
|
4415
|
+
this.key = key;
|
|
4416
|
+
this.name = "EnvValueMultiline";
|
|
4417
|
+
}
|
|
4418
|
+
};
|
|
4419
|
+
/** The services a stack's apps wait on and the edge waits on in turn. */
|
|
4420
|
+
const HEALTHY = { condition: "service_healthy" };
|
|
4421
|
+
/**
|
|
4422
|
+
* An app's health check: its own server, asked on 127.0.0.1 — not
|
|
4423
|
+
* `localhost`, which resolves to `::1` first in an Alpine container while a
|
|
4424
|
+
* server may listen on IPv4 alone.
|
|
4425
|
+
*/
|
|
4426
|
+
function probe(port, path) {
|
|
4427
|
+
return {
|
|
4428
|
+
test: [
|
|
4429
|
+
"CMD",
|
|
4430
|
+
"wget",
|
|
4431
|
+
"-q",
|
|
4432
|
+
"-O",
|
|
4433
|
+
"/dev/null",
|
|
4434
|
+
`http://127.0.0.1:${port}${path}`
|
|
4435
|
+
],
|
|
4436
|
+
interval: "5s",
|
|
4437
|
+
timeout: "3s",
|
|
4438
|
+
retries: 20,
|
|
4439
|
+
start_period: "10s"
|
|
4440
|
+
};
|
|
4441
|
+
}
|
|
4442
|
+
/** The stack for one stage. */
|
|
4443
|
+
function composeStack(input) {
|
|
4444
|
+
const { workspace, manifest, stage, identity } = input;
|
|
4445
|
+
const local = stage === workspace.stages.local;
|
|
4446
|
+
const project = composeProject(identity);
|
|
4447
|
+
const custom = input.secrets?.custom ?? {};
|
|
4448
|
+
const https = input.ports?.https ?? 443;
|
|
4449
|
+
const http = input.ports?.http ?? 80;
|
|
4450
|
+
const plan = planFor(manifest, stage, provisionOrder(manifest), {
|
|
4451
|
+
localStage: workspace.stages.local,
|
|
4452
|
+
events: DEFAULT_EVENTS.server,
|
|
4453
|
+
cache: DEFAULT_CACHE.server,
|
|
4454
|
+
edge: false
|
|
4455
|
+
});
|
|
4456
|
+
const byId = new Map(plan.resources.map((r) => [r.id, r]));
|
|
4457
|
+
const seed = local ? void 0 : input.secrets?.seed;
|
|
4458
|
+
if (!local && !seed) throw new StageSeedMissing(stage);
|
|
4459
|
+
const credential = {
|
|
4460
|
+
master: local ? "geekmidas" : localRolePassword(workspace.name, plan, "master", seed),
|
|
4461
|
+
...seed ? { seed } : {}
|
|
4462
|
+
};
|
|
4463
|
+
const infra = plan.containers.filter((c) => !NOT_RUN[c]).filter((c) => local || c !== "mailpit").sort();
|
|
4464
|
+
const apps = stackApps(workspace, manifest, plan, {
|
|
4465
|
+
local,
|
|
4466
|
+
stage,
|
|
4467
|
+
https,
|
|
4468
|
+
identity,
|
|
4469
|
+
images: input.images
|
|
4470
|
+
});
|
|
4471
|
+
if (apps.length === 0) throw new NothingToCompose(workspace.root);
|
|
4472
|
+
const derivation = {
|
|
4473
|
+
project: workspace.name,
|
|
4474
|
+
master: credential.master,
|
|
4475
|
+
...credential.seed ? { seed: credential.seed } : {}
|
|
4476
|
+
};
|
|
4477
|
+
const outside = networkEnv(plan, {
|
|
4478
|
+
...derivation,
|
|
4479
|
+
addresses: Object.fromEntries(apps.map((app) => [app.id, app.url]))
|
|
4480
|
+
});
|
|
4481
|
+
const inside = networkEnv(plan, {
|
|
4482
|
+
...derivation,
|
|
4483
|
+
addresses: Object.fromEntries(apps.map((app) => [app.id, `http://${app.name}:${app.port}`]))
|
|
4484
|
+
});
|
|
4485
|
+
const owners = /* @__PURE__ */ new Map();
|
|
4486
|
+
for (const resource of plan.resources) {
|
|
4487
|
+
owners.set(resource.envKey, resource);
|
|
4488
|
+
if (resource.kind === "email") {
|
|
4489
|
+
owners.set(provideKey(resource.id, "from"), resource);
|
|
4490
|
+
owners.set(provideKey(resource.id, "inboxUrl"), resource);
|
|
4491
|
+
}
|
|
4492
|
+
if (resource.kind === "external-api") owners.set(provideKey(resource.id, "credentials"), resource);
|
|
4493
|
+
}
|
|
4494
|
+
const surfaceUrls = new Map(plan.resources.filter((r) => r.kind === "rest-api" || r.kind === "site").map((r) => [r.envKey, r.id]));
|
|
4495
|
+
const appById = new Map(apps.map((app) => [app.id, app]));
|
|
4496
|
+
/**
|
|
4497
|
+
* What a key resolves to for one app, from the stage's secrets where only
|
|
4498
|
+
* they can hold it, and the derivations otherwise.
|
|
4499
|
+
*/
|
|
4500
|
+
const valueFor = (app, key, owner) => {
|
|
4501
|
+
if (owner && EXTERNAL_KINDS[owner.kind] && key === owner.envKey) {
|
|
4502
|
+
const value = custom[key];
|
|
4503
|
+
if (value === void 0) throw new BucketNotConfigured(app.name, owner.id, key, stage);
|
|
4504
|
+
return value;
|
|
4505
|
+
}
|
|
4506
|
+
if (owner && suppliedBy(owner, key, local)) {
|
|
4507
|
+
const value = custom[key];
|
|
4508
|
+
if (value === void 0) throw new StageSecretMissing(app.name, key, stage);
|
|
4509
|
+
return value;
|
|
4510
|
+
}
|
|
4511
|
+
if (custom[key] !== void 0) return custom[key];
|
|
4512
|
+
const surface = surfaceUrls.get(key);
|
|
4513
|
+
if (app.kind === "rest-api" && surface && surface !== app.id) return inside[key];
|
|
4514
|
+
return outside[key];
|
|
4515
|
+
};
|
|
4516
|
+
for (const app of apps) {
|
|
4517
|
+
if (app.kind === "site") {
|
|
4518
|
+
const site = manifest[app.id];
|
|
4519
|
+
if (site?.kind !== "site") continue;
|
|
4520
|
+
const args = {};
|
|
4521
|
+
for (const [key, source] of Object.entries(publicEnvFor(site, manifest))) {
|
|
4522
|
+
const value = valueFor(app, source, owners.get(source));
|
|
4523
|
+
if (value !== void 0) args[key] = value;
|
|
4524
|
+
}
|
|
4525
|
+
if (app.build && Object.keys(args).length > 0) app.build.args = args;
|
|
4526
|
+
continue;
|
|
4527
|
+
}
|
|
4528
|
+
const keys = appEnvKeys(manifest, app.name, input.runnables) ?? /* @__PURE__ */ new Set();
|
|
4529
|
+
const values = {};
|
|
4530
|
+
for (const key of [...keys].sort()) {
|
|
4531
|
+
const owner = owners.get(key) ?? storageOwner(key, plan);
|
|
4532
|
+
if (!local && owner?.kind === "email" && key.endsWith("_INBOX_URL")) continue;
|
|
4533
|
+
if (STORAGE_KEYS.includes(key)) {
|
|
4534
|
+
if (custom[key] !== void 0) values[key] = custom[key];
|
|
4535
|
+
continue;
|
|
4536
|
+
}
|
|
4537
|
+
const value = valueFor(app, key, owner);
|
|
4538
|
+
if (value !== void 0) values[key] = value;
|
|
4539
|
+
}
|
|
4540
|
+
const trusted = provideKey(app.id, "trustedOrigins");
|
|
4541
|
+
if (keys.has(trusted)) {
|
|
4542
|
+
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}`);
|
|
4543
|
+
const origins = [...(values[trusted] ?? "").split(",").filter(Boolean), ...internal];
|
|
4544
|
+
values[trusted] = [...new Set(origins)].join(",");
|
|
4545
|
+
}
|
|
4546
|
+
app.env = {
|
|
4547
|
+
...values,
|
|
4548
|
+
NODE_ENV: "production",
|
|
4549
|
+
PORT: String(app.port),
|
|
4550
|
+
STAGE: stage
|
|
4551
|
+
};
|
|
4552
|
+
for (const [key, value] of Object.entries(app.env)) if (/[\r\n]/.test(value)) throw new EnvValueMultiline(app.name, key);
|
|
4553
|
+
}
|
|
4554
|
+
const compose = stackFile({
|
|
4555
|
+
project,
|
|
4556
|
+
plan,
|
|
4557
|
+
infra,
|
|
4558
|
+
apps,
|
|
4559
|
+
master: credential.master,
|
|
4560
|
+
https,
|
|
4561
|
+
http
|
|
4562
|
+
});
|
|
4563
|
+
const caddyfile = edgeCaddyfile(apps.map((app) => ({
|
|
4564
|
+
host: app.host,
|
|
4565
|
+
upstream: `${app.name}:${app.port}`
|
|
4566
|
+
})), { local });
|
|
4567
|
+
const dockerfiles = {};
|
|
4568
|
+
for (const app of apps) {
|
|
4569
|
+
if (!app.build) continue;
|
|
4570
|
+
dockerfiles[app.build.dockerfile] = app.kind === "site" ? siteDockerfile(app.name, workspace.apps[app.name], {
|
|
4571
|
+
turboPackage: input.packages?.[app.name] ?? app.name,
|
|
4572
|
+
packageManager: input.packageManager ?? "pnpm",
|
|
4573
|
+
manifest
|
|
4574
|
+
}) : generateSlimDockerfile({
|
|
4575
|
+
imageName: app.name,
|
|
4576
|
+
baseImage: "node:22-alpine",
|
|
4577
|
+
port: app.port,
|
|
4578
|
+
healthCheckPath: "/health",
|
|
4579
|
+
prebuilt: true,
|
|
4580
|
+
packageManager: input.packageManager ?? "pnpm",
|
|
4581
|
+
bundle: `${app.path}/.gkm/server/dist/server.mjs`
|
|
4582
|
+
});
|
|
4583
|
+
}
|
|
4584
|
+
return {
|
|
4585
|
+
project,
|
|
4586
|
+
stage,
|
|
4587
|
+
local,
|
|
4588
|
+
apps,
|
|
4589
|
+
infra,
|
|
4590
|
+
compose,
|
|
4591
|
+
caddyfile,
|
|
4592
|
+
dockerfiles,
|
|
4593
|
+
plan,
|
|
4594
|
+
credential
|
|
4595
|
+
};
|
|
4596
|
+
/** Whether only the stage's secrets can supply `key` to `owner`. */
|
|
4597
|
+
function suppliedBy(owner, key, isLocal) {
|
|
4598
|
+
if (owner.kind === "credential") return true;
|
|
4599
|
+
if (owner.kind === "external-api") return key === provideKey(owner.id, "credentials");
|
|
4600
|
+
if (isLocal) return false;
|
|
4601
|
+
if (owner.kind === "secret" || owner.kind === "encryption") return true;
|
|
4602
|
+
return owner.kind === "email" && !key.endsWith("_INBOX_URL");
|
|
4603
|
+
}
|
|
4604
|
+
}
|
|
4605
|
+
/** Which bucket an S3 credential key belongs to — the first one declared. */
|
|
4606
|
+
function storageOwner(key, plan) {
|
|
4607
|
+
if (!STORAGE_KEYS.includes(key)) return void 0;
|
|
4608
|
+
return plan.resources.find((r) => r.kind === "objects");
|
|
4609
|
+
}
|
|
4610
|
+
/** The apps a stack runs: every API and every site, at its stage address. */
|
|
4611
|
+
function stackApps(workspace, manifest, plan, options) {
|
|
4612
|
+
const apps = [];
|
|
4613
|
+
const resources = new Map(plan.resources.map((r) => [r.id, r]));
|
|
4614
|
+
for (const [id, declaration] of Object.entries(manifest)) {
|
|
4615
|
+
if (declaration.kind !== "rest-api" && declaration.kind !== "site") continue;
|
|
4616
|
+
const name = appKey(id);
|
|
4617
|
+
const app = workspace.apps[name];
|
|
4618
|
+
const resource = resources.get(id);
|
|
4619
|
+
if (!app || !resource || !app.port) continue;
|
|
4620
|
+
const host = hostOf(name, app, workspace, resource, options);
|
|
4621
|
+
const tag = declaration.kind === "site" ? siteTag(options.images.tag, options.stage) : options.images.tag;
|
|
4622
|
+
const ref = validateImageRef(imageRef(options.identity, name, options.images.registry, tag));
|
|
4623
|
+
apps.push({
|
|
4624
|
+
app: name,
|
|
4625
|
+
name,
|
|
4626
|
+
id,
|
|
4627
|
+
kind: declaration.kind,
|
|
4628
|
+
path: app.path,
|
|
4629
|
+
port: app.port,
|
|
4630
|
+
host,
|
|
4631
|
+
url: `https://${host}${options.local && options.https !== 443 ? `:${options.https}` : ""}`,
|
|
4632
|
+
ref,
|
|
4633
|
+
tag,
|
|
4634
|
+
...options.images.mode === "build" ? { build: { dockerfile: `${stackDir(options.stage)}/Dockerfile.${name}` } } : {}
|
|
4635
|
+
});
|
|
4636
|
+
}
|
|
4637
|
+
return apps.sort((a, b) => a.name.localeCompare(b.name));
|
|
4638
|
+
}
|
|
4639
|
+
/**
|
|
4640
|
+
* The host an app answers on for a stage: the `gkm dev` edge's
|
|
4641
|
+
* `*.localhost` names for the local stage, and the stage's domain — by the
|
|
4642
|
+
* same rule a deploy follows — for any other.
|
|
4643
|
+
*/
|
|
4644
|
+
function hostOf(name, app, workspace, resource, options) {
|
|
4645
|
+
if (options.local) return hostFor(resource, workspace.name);
|
|
4646
|
+
return resolveHost(name, app, options.stage, workspace.deploy?.domains, isMainFrontendApp(name, app, workspace.apps));
|
|
4647
|
+
}
|
|
4648
|
+
/** The compose document: the infrastructure, the apps, and the edge. */
|
|
4649
|
+
function stackFile(options) {
|
|
4650
|
+
const { project, plan, infra, apps } = options;
|
|
4651
|
+
const derived = composeFor({
|
|
4652
|
+
...plan,
|
|
4653
|
+
containers: [...infra]
|
|
4654
|
+
}, {
|
|
4655
|
+
project,
|
|
4656
|
+
ports: Object.fromEntries(portKeys(infra, plan.fakes).map((key) => [key, 0]))
|
|
4657
|
+
});
|
|
4658
|
+
const services = {};
|
|
4659
|
+
const volumes = {
|
|
4660
|
+
...derived.volumes,
|
|
4661
|
+
"caddy-data": {},
|
|
4662
|
+
"caddy-config": {}
|
|
4663
|
+
};
|
|
4664
|
+
for (const name of infra) {
|
|
4665
|
+
const { ports: _ports, depends_on, profiles: _profiles, ...service } = derived.services[name];
|
|
4666
|
+
services[name] = {
|
|
4667
|
+
...service,
|
|
4668
|
+
...depends_on?.length ? { depends_on: Object.fromEntries(depends_on.map((dep) => [dep, HEALTHY])) } : {}
|
|
4669
|
+
};
|
|
4670
|
+
}
|
|
4671
|
+
const postgres = services.postgres;
|
|
4672
|
+
if (postgres) {
|
|
4673
|
+
postgres.environment = {
|
|
4674
|
+
...postgres.environment,
|
|
4675
|
+
POSTGRES_PASSWORD: options.master
|
|
4676
|
+
};
|
|
4677
|
+
postgres.ports = ["127.0.0.1::5432"];
|
|
4678
|
+
}
|
|
4679
|
+
for (const app of apps) services[app.name] = {
|
|
4680
|
+
image: app.ref,
|
|
4681
|
+
...app.build ? { build: {
|
|
4682
|
+
context: "../../..",
|
|
4683
|
+
dockerfile: app.build.dockerfile,
|
|
4684
|
+
...app.build.args ? { args: app.build.args } : {}
|
|
4685
|
+
} } : {},
|
|
4686
|
+
restart: "unless-stopped",
|
|
4687
|
+
...app.kind === "rest-api" ? {
|
|
4688
|
+
env_file: [{
|
|
4689
|
+
path: `./${app.name}.env`,
|
|
4690
|
+
format: "raw"
|
|
4691
|
+
}],
|
|
4692
|
+
...infra.length ? { depends_on: Object.fromEntries(infra.map((name) => [name, HEALTHY])) } : {}
|
|
4693
|
+
} : {},
|
|
4694
|
+
healthcheck: probe(app.port, app.kind === "site" ? "/" : "/health")
|
|
4695
|
+
};
|
|
4696
|
+
services.caddy = {
|
|
4697
|
+
image: DEFAULT_IMAGES.caddy,
|
|
4698
|
+
restart: "unless-stopped",
|
|
4699
|
+
ports: [`${options.https}:443`, `${options.http}:80`],
|
|
4700
|
+
volumes: [
|
|
4701
|
+
"./Caddyfile:/etc/caddy/Caddyfile:ro",
|
|
4702
|
+
"caddy-data:/data",
|
|
4703
|
+
"caddy-config:/config"
|
|
4704
|
+
],
|
|
4705
|
+
depends_on: Object.fromEntries(apps.map((app) => [app.name, HEALTHY])),
|
|
4706
|
+
healthcheck: {
|
|
4707
|
+
test: [
|
|
4708
|
+
"CMD",
|
|
4709
|
+
"caddy",
|
|
4710
|
+
"version"
|
|
4711
|
+
],
|
|
4712
|
+
interval: "10s",
|
|
4713
|
+
timeout: "5s",
|
|
4714
|
+
retries: 5
|
|
4715
|
+
}
|
|
4716
|
+
};
|
|
4717
|
+
return {
|
|
4718
|
+
name: project,
|
|
4719
|
+
services,
|
|
4720
|
+
volumes
|
|
4721
|
+
};
|
|
4722
|
+
}
|
|
4723
|
+
/** Render an app's env file: one `KEY=value` per line, read raw by compose. */
|
|
4724
|
+
function envFile(env) {
|
|
4725
|
+
return `${Object.entries(env).sort(([a], [b]) => a.localeCompare(b)).map(([key, value]) => `${key}=${value}`).join("\n")}\n`;
|
|
4726
|
+
}
|
|
4727
|
+
//#endregion
|
|
4728
|
+
//#region src/compose/index.ts
|
|
4729
|
+
/**
|
|
4730
|
+
* `gkm compose` — a workspace's APIs and sites for one stage, as one Docker
|
|
4731
|
+
* Compose stack behind Caddy.
|
|
4732
|
+
*
|
|
4733
|
+
* The order is the point of this file, and the rules it keeps are these:
|
|
4734
|
+
*
|
|
4735
|
+
* - A tag is checked before anything happens. Every app's image is asked of
|
|
4736
|
+
* the registry first; one missing image stops the run naming them all, and
|
|
4737
|
+
* nothing is written, pulled or started.
|
|
4738
|
+
* - The databases exist before the apps do. Postgres comes up alone, its
|
|
4739
|
+
* databases, roles and grants are created and its migrations applied from
|
|
4740
|
+
* this machine, and only then do the apps start — so an app never boots
|
|
4741
|
+
* against a schema that is not there yet.
|
|
4742
|
+
* - What a stage runs is recorded. Each app's tag and the digest it resolved
|
|
4743
|
+
* to are written to the stage's state, so "what is this stage running" has
|
|
4744
|
+
* an exact answer.
|
|
4745
|
+
*
|
|
4746
|
+
* Docker, the registry, the build and the clock are injected, so the rules are
|
|
4747
|
+
* asserted without a daemon; the defaults are the real ones.
|
|
4748
|
+
*/
|
|
4749
|
+
const logger$4 = console;
|
|
4750
|
+
/** `--build` and `--pull` together ask for two different things. */
|
|
4751
|
+
var ComposeModeConflict = class extends Error {
|
|
4752
|
+
constructor() {
|
|
4753
|
+
super("--build and --pull ask for opposite things: build images from this checkout, or pull a tag CI pushed. Pass one of them.");
|
|
4754
|
+
this.name = "ComposeModeConflict";
|
|
4755
|
+
}
|
|
4756
|
+
};
|
|
4757
|
+
/** A build with no commit to tag its images with. */
|
|
4758
|
+
var NoGitRevision = class extends Error {
|
|
4759
|
+
root;
|
|
4760
|
+
constructor(root) {
|
|
4761
|
+
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.`);
|
|
4762
|
+
this.root = root;
|
|
4763
|
+
this.name = "NoGitRevision";
|
|
4764
|
+
}
|
|
4765
|
+
};
|
|
4766
|
+
/** A port variable that does not hold a port. */
|
|
4767
|
+
var EdgePortInvalid = class extends Error {
|
|
4768
|
+
variable;
|
|
4769
|
+
value;
|
|
4770
|
+
constructor(variable, value) {
|
|
4771
|
+
super(`${variable}=${value} is not a port. Set it to a number between 1 and 65535, or unset it for the default.`);
|
|
4772
|
+
this.variable = variable;
|
|
4773
|
+
this.value = value;
|
|
4774
|
+
this.name = "EdgePortInvalid";
|
|
4775
|
+
}
|
|
4776
|
+
};
|
|
4777
|
+
/** The commit HEAD is at, short — and `-dirty` when the tree has changes. */
|
|
4778
|
+
async function gitRevision(root) {
|
|
4779
|
+
let sha;
|
|
4780
|
+
try {
|
|
4781
|
+
sha = (await runOutput("git", [
|
|
4782
|
+
"rev-parse",
|
|
4783
|
+
"--short",
|
|
4784
|
+
"HEAD"
|
|
4785
|
+
], { cwd: root })).trim();
|
|
4786
|
+
} catch {
|
|
4787
|
+
throw new NoGitRevision(root);
|
|
4788
|
+
}
|
|
4789
|
+
if (!sha) throw new NoGitRevision(root);
|
|
4790
|
+
return (await runOutput("git", ["status", "--porcelain"], { cwd: root }).catch(() => "")).trim() ? `${sha}-dirty` : sha;
|
|
4791
|
+
}
|
|
4792
|
+
/**
|
|
4793
|
+
* Bundle a backend with the gkm that is running — the same build a deploy
|
|
4794
|
+
* makes, so the image holds one file and no dependencies.
|
|
4795
|
+
*/
|
|
4796
|
+
async function bundleWithThisCli(appRoot) {
|
|
4797
|
+
const entry = process.argv[1];
|
|
4798
|
+
if (!entry) return;
|
|
4799
|
+
await run$1(process.execPath, [
|
|
4800
|
+
...process.execArgv,
|
|
4801
|
+
entry,
|
|
4802
|
+
"build",
|
|
4803
|
+
"--provider",
|
|
4804
|
+
"server",
|
|
4805
|
+
"--production"
|
|
4806
|
+
], { cwd: appRoot });
|
|
4807
|
+
}
|
|
4808
|
+
const defaultDeps = {
|
|
4809
|
+
docker: dockerCompose,
|
|
4810
|
+
bundle: bundleWithThisCli,
|
|
4811
|
+
revision: gitRevision
|
|
4812
|
+
};
|
|
4813
|
+
/** The edge's ports, from the environment — 443 and 80 when unset. */
|
|
4814
|
+
function edgePorts(env = process.env) {
|
|
4815
|
+
const read = (variable, fallback) => {
|
|
4816
|
+
const value = env[variable];
|
|
4817
|
+
if (value === void 0 || value === "") return fallback;
|
|
4818
|
+
const port = Number(value);
|
|
4819
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new EdgePortInvalid(variable, value);
|
|
4820
|
+
return port;
|
|
4821
|
+
};
|
|
4822
|
+
return {
|
|
4823
|
+
https: read(EDGE_PORT_ENV.https, 443),
|
|
4824
|
+
http: read(EDGE_PORT_ENV.http, 80)
|
|
4825
|
+
};
|
|
4826
|
+
}
|
|
4827
|
+
/** `gkm compose`. */
|
|
4828
|
+
async function composeCommand(options = {}, deps = {}) {
|
|
4829
|
+
const { docker, bundle, revision } = {
|
|
4830
|
+
...defaultDeps,
|
|
4831
|
+
...deps
|
|
4832
|
+
};
|
|
4833
|
+
const env = deps.env ?? process.env;
|
|
4834
|
+
if (options.build && options.pull) throw new ComposeModeConflict();
|
|
4835
|
+
const { workspace } = await loadWorkspaceConfig(options.cwd);
|
|
4836
|
+
const root = workspace.root;
|
|
4837
|
+
const stage = options.stage ?? workspace.stages.local;
|
|
4838
|
+
if (!(stage === workspace.stages.local)) assertDeployedStage(workspace.stages, stage);
|
|
4839
|
+
const identity = deployIdentity(workspace, stage);
|
|
4840
|
+
const dir = join(root, stackDir(stage));
|
|
4841
|
+
const file = join(dir, "docker-compose.yml");
|
|
4842
|
+
const ref = {
|
|
4843
|
+
project: composeProject(identity),
|
|
4844
|
+
file,
|
|
4845
|
+
cwd: root
|
|
4846
|
+
};
|
|
4847
|
+
if (options.down) {
|
|
4848
|
+
await docker.down(ref);
|
|
4849
|
+
logger$4.log(`🛑 Stopped ${ref.project}. Its volumes are kept.`);
|
|
4850
|
+
return;
|
|
4851
|
+
}
|
|
4852
|
+
const sources = {};
|
|
4853
|
+
const runnables = {};
|
|
4854
|
+
const manifest = await discover({
|
|
4855
|
+
patterns: constructGlobs(workspace),
|
|
4856
|
+
cwd: root,
|
|
4857
|
+
sources,
|
|
4858
|
+
runnables
|
|
4859
|
+
});
|
|
4860
|
+
const mode = options.build || !options.pull && !options.tag ? "build" : "pull";
|
|
4861
|
+
const tag = options.tag ?? (mode === "pull" ? "latest" : await revision(root));
|
|
4862
|
+
const { secrets, keep } = await stageSecrets(workspace, manifest, stage);
|
|
4863
|
+
const packageManager = detectPackageManager$2(root);
|
|
4864
|
+
const stack = composeStack({
|
|
4865
|
+
workspace,
|
|
4866
|
+
manifest,
|
|
4867
|
+
runnables,
|
|
4868
|
+
stage,
|
|
4869
|
+
identity,
|
|
4870
|
+
images: {
|
|
4871
|
+
mode,
|
|
4872
|
+
tag,
|
|
4873
|
+
...workspace.deploy?.dokploy?.registry ? { registry: workspace.deploy.dokploy.registry } : {}
|
|
4874
|
+
},
|
|
4875
|
+
secrets,
|
|
4876
|
+
ports: edgePorts(env),
|
|
4877
|
+
packageManager,
|
|
4878
|
+
packages: Object.fromEntries(Object.entries(workspace.apps).map(([name, app]) => [name, appPackageName$1(root, name, app.path)]))
|
|
4879
|
+
});
|
|
4880
|
+
if (mode === "pull" && !options.dryRun) await assertImagesExist(docker, tag, stack.apps);
|
|
4881
|
+
if (options.dryRun) keep.skip();
|
|
4882
|
+
else await keep.write();
|
|
4883
|
+
const files = await writeStack(root, dir, stack);
|
|
4884
|
+
printPlan(stack, mode, files);
|
|
4885
|
+
if (options.dryRun) return {
|
|
4886
|
+
stack,
|
|
4887
|
+
files
|
|
4888
|
+
};
|
|
4889
|
+
const store = deps.state ? await deps.state(workspace) : await createStateStore({
|
|
4890
|
+
config: workspace.state,
|
|
4891
|
+
workspaceRoot: root,
|
|
4892
|
+
workspaceName: workspace.name
|
|
4893
|
+
});
|
|
4894
|
+
const lock = await store.lock(stage, { operation: "compose" });
|
|
4895
|
+
try {
|
|
4896
|
+
const apps = stack.apps.map((app) => app.name);
|
|
4897
|
+
if (mode === "build") {
|
|
4898
|
+
for (const app of stack.apps.filter((a) => a.kind === "rest-api")) {
|
|
4899
|
+
logger$4.log(`\n📦 Bundling ${app.name}…`);
|
|
4900
|
+
await bundle(join(root, app.path));
|
|
4901
|
+
}
|
|
4902
|
+
logger$4.log("\n🐳 Building images…");
|
|
4903
|
+
await docker.build(ref, apps);
|
|
4904
|
+
} else {
|
|
4905
|
+
logger$4.log(`\n🐳 Pulling ${tag}…`);
|
|
4906
|
+
await docker.pull(ref, apps);
|
|
4907
|
+
}
|
|
4908
|
+
if (stack.infra.length > 0) {
|
|
4909
|
+
logger$4.log(`\n🗄️ Starting ${stack.infra.join(", ")}…`);
|
|
4910
|
+
await docker.up(ref, stack.infra);
|
|
4911
|
+
}
|
|
4912
|
+
if (stack.infra.includes("postgres")) await prepareDatabases(stack, workspace, manifest, sources, {
|
|
4913
|
+
docker,
|
|
4914
|
+
ref,
|
|
4915
|
+
sql: deps.sql ?? pgClient,
|
|
4916
|
+
migrate: deps.migrate ?? migrateDatabases
|
|
4917
|
+
});
|
|
4918
|
+
logger$4.log("\n🚀 Starting the stack…");
|
|
4919
|
+
await docker.up(ref);
|
|
4920
|
+
if (stack.local) await docker.copyOut(ref, "caddy", "/data/caddy/pki/authorities/local/root.crt", join(dir, "caddy-root.crt")).catch(() => {});
|
|
4921
|
+
const images = await recordImages(store, stack, docker, mode);
|
|
4922
|
+
logger$4.log(`\n✅ ${ref.project} is running:`);
|
|
4923
|
+
for (const app of stack.apps) logger$4.log(` ${app.name.padEnd(12)} ${app.url}`);
|
|
4924
|
+
if (stack.local) logger$4.log(`\n🔐 Certificates are from Caddy's local CA: NODE_EXTRA_CA_CERTS=${join(stackDir(stage), "caddy-root.crt")}`);
|
|
4925
|
+
return {
|
|
4926
|
+
stack,
|
|
4927
|
+
files,
|
|
4928
|
+
images
|
|
4929
|
+
};
|
|
4930
|
+
} finally {
|
|
4931
|
+
await lock.release();
|
|
4932
|
+
}
|
|
4933
|
+
}
|
|
4934
|
+
/**
|
|
4935
|
+
* The stage's secrets — and, for a deployed stage, everything it generates
|
|
4936
|
+
* once: its seed, and each declared secret and keyring. Written back before
|
|
4937
|
+
* anything is derived from them, so the next run derives the same passwords.
|
|
4938
|
+
*/
|
|
4939
|
+
async function stageSecrets(workspace, manifest, stage) {
|
|
4940
|
+
const store = await secretsStoreFor(workspace, stage);
|
|
4941
|
+
const stored = await store.read(stage);
|
|
4942
|
+
const nothing = {
|
|
4943
|
+
write: async () => {},
|
|
4944
|
+
skip: () => {}
|
|
4945
|
+
};
|
|
4946
|
+
if (stage === workspace.stages.local) return {
|
|
4947
|
+
secrets: stored,
|
|
4948
|
+
keep: nothing
|
|
4949
|
+
};
|
|
4950
|
+
const { secrets, generated } = withGeneratedSecrets(stored ?? initStageSecrets(stage), manifest);
|
|
4951
|
+
if (generated.length === 0) return {
|
|
4952
|
+
secrets,
|
|
4953
|
+
keep: nothing
|
|
4954
|
+
};
|
|
4955
|
+
return {
|
|
4956
|
+
secrets,
|
|
4957
|
+
keep: {
|
|
4958
|
+
async write() {
|
|
4959
|
+
await store.write(stage, secrets);
|
|
4960
|
+
logger$4.log(`🔑 Generated for "${stage}" (${store.name}): ${generated.join(", ")}`);
|
|
4961
|
+
},
|
|
4962
|
+
skip() {
|
|
4963
|
+
logger$4.log(`🔑 "${stage}" has no ${generated.join(", ")} yet; this dry run used values it did not keep.`);
|
|
4964
|
+
}
|
|
4965
|
+
}
|
|
4966
|
+
};
|
|
4967
|
+
}
|
|
4968
|
+
/** Write the stack's files: owner-only, since the env files hold secrets. */
|
|
4969
|
+
async function writeStack(root, dir, stack) {
|
|
4970
|
+
await mkdir(dir, {
|
|
4971
|
+
recursive: true,
|
|
4972
|
+
mode: 448
|
|
4973
|
+
});
|
|
4974
|
+
await chmod(dir, 448);
|
|
4975
|
+
const files = [];
|
|
4976
|
+
const write = async (path, content, mode = 420) => {
|
|
4977
|
+
await mkdir(dirname(path), { recursive: true });
|
|
4978
|
+
await writeFile(path, content, { mode });
|
|
4979
|
+
await chmod(path, mode);
|
|
4980
|
+
files.push(path);
|
|
4981
|
+
};
|
|
4982
|
+
await write(join(dir, "docker-compose.yml"), composeYaml(stack));
|
|
4983
|
+
await write(join(dir, "Caddyfile"), stack.caddyfile);
|
|
4984
|
+
for (const app of stack.apps) if (app.env) await write(join(dir, `${app.name}.env`), envFile(app.env), 384);
|
|
4985
|
+
for (const [path, content] of Object.entries(stack.dockerfiles)) await write(join(root, path), content);
|
|
4986
|
+
await ignoreStacks(root);
|
|
4987
|
+
return files;
|
|
4988
|
+
}
|
|
4989
|
+
/** Make sure the build context leaves `.gkm/compose` out. */
|
|
4990
|
+
async function ignoreStacks(root) {
|
|
4991
|
+
const path = join(root, ".dockerignore");
|
|
4992
|
+
if (!existsSync(path)) {
|
|
4993
|
+
await writeFile(path, generateDockerignore());
|
|
4994
|
+
return;
|
|
4995
|
+
}
|
|
4996
|
+
const current = await readFile(path, "utf-8");
|
|
4997
|
+
if (/^\/?\.gkm\/compose\/?$|^\/?\.gkm\/?$/m.test(current)) return;
|
|
4998
|
+
await 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`);
|
|
4999
|
+
}
|
|
5000
|
+
function composeYaml(stack) {
|
|
5001
|
+
return `# Generated by gkm compose from the construct manifest — do not edit.
|
|
5002
|
+
# The ${stack.stage} stage's APIs and sites behind one Caddy. Each backend
|
|
5003
|
+
# reads exactly the keys in its own env file beside this one.
|
|
5004
|
+
#
|
|
5005
|
+
# gkm compose${stack.local ? "" : ` --stage ${stack.stage}`} start or update it
|
|
5006
|
+
# gkm compose${stack.local ? "" : ` --stage ${stack.stage}`} --down stop it
|
|
5007
|
+
${stringify(stack.compose, {
|
|
5008
|
+
lineWidth: 0,
|
|
5009
|
+
aliasDuplicateObjects: false
|
|
5010
|
+
})}`;
|
|
5011
|
+
}
|
|
5012
|
+
/** Create what the plan names in the stack's Postgres, then migrate it. */
|
|
5013
|
+
async function prepareDatabases(stack, workspace, manifest, sources, deps) {
|
|
5014
|
+
const port = await deps.docker.port(deps.ref, "postgres", 5432);
|
|
5015
|
+
const statements = postgresStatements(stack.plan, workspace.name, stack.credential.seed);
|
|
5016
|
+
if (statements.length > 0) {
|
|
5017
|
+
logger$4.log("🗄️ Creating databases, roles and grants…");
|
|
5018
|
+
await applyPostgres(deps.sql(port, stack.credential.master), statements);
|
|
5019
|
+
}
|
|
5020
|
+
const env = envFor(stack.plan, {
|
|
5021
|
+
ports: { [primaryPortKey("postgres")]: port },
|
|
5022
|
+
project: workspace.name,
|
|
5023
|
+
master: stack.credential.master,
|
|
5024
|
+
...stack.credential.seed ? { seed: stack.credential.seed } : {}
|
|
5025
|
+
});
|
|
5026
|
+
const runs = await deps.migrate({
|
|
5027
|
+
root: workspace.root,
|
|
5028
|
+
manifest,
|
|
5029
|
+
sources,
|
|
5030
|
+
env
|
|
5031
|
+
});
|
|
5032
|
+
for (const { target, applied } of runs) if (applied.length > 0) logger$4.log(`🗄️ ${target.migrations}: applied ${applied.length}`);
|
|
5033
|
+
}
|
|
5034
|
+
/**
|
|
5035
|
+
* Record each app's image in the stage's state: the tag it ran and the digest
|
|
5036
|
+
* the tag resolved to — or, for an image built here and never pushed, its
|
|
5037
|
+
* content id.
|
|
5038
|
+
*/
|
|
5039
|
+
async function recordImages(store, stack, docker, mode) {
|
|
5040
|
+
if (!await store.read(stack.stage)) await store.write(stack.stage, createEmptyState(stack.stage, "", ""), { expectedVersion: null });
|
|
5041
|
+
const images = {};
|
|
5042
|
+
for (const app of stack.apps) {
|
|
5043
|
+
const digest = await docker.digest(app.ref);
|
|
5044
|
+
images[app.name] = {
|
|
5045
|
+
ref: app.ref,
|
|
5046
|
+
tag: app.tag,
|
|
5047
|
+
...digest ? { digest } : {}
|
|
5048
|
+
};
|
|
5049
|
+
await store.putResource(stack.stage, {
|
|
5050
|
+
key: `compose:${app.name}`,
|
|
5051
|
+
type: "compose-image",
|
|
5052
|
+
status: "ready",
|
|
5053
|
+
...digest ? { id: digest } : {},
|
|
5054
|
+
data: {
|
|
5055
|
+
project: stack.project,
|
|
5056
|
+
ref: app.ref,
|
|
5057
|
+
tag: app.tag,
|
|
5058
|
+
source: mode,
|
|
5059
|
+
...digest ? { digest } : {}
|
|
5060
|
+
}
|
|
5061
|
+
});
|
|
5062
|
+
}
|
|
5063
|
+
return images;
|
|
5064
|
+
}
|
|
5065
|
+
function printPlan(stack, mode, files) {
|
|
5066
|
+
logger$4.log(`\n🧱 ${stack.project} — stage ${stack.stage}`);
|
|
5067
|
+
for (const app of stack.apps) logger$4.log(` ${app.name.padEnd(12)} ${app.url.padEnd(40)} ${mode === "build" ? "build" : "pull"} ${app.ref}`);
|
|
5068
|
+
if (stack.infra.length > 0) logger$4.log(` infrastructure: ${stack.infra.join(", ")}`);
|
|
5069
|
+
logger$4.log(`\n📝 Wrote ${files.length} file(s) under ${stackDir(stack.stage)}/`);
|
|
5070
|
+
}
|
|
3516
5071
|
/**
|
|
3517
5072
|
* Format a fatal error for display.
|
|
3518
5073
|
* Always includes the full stack trace since these are process-ending errors.
|
|
@@ -4918,25 +6473,25 @@ function loadPackageJson() {
|
|
|
4918
6473
|
* Run: pnpm --filter @geekmidas/cli sync-versions
|
|
4919
6474
|
*/
|
|
4920
6475
|
const GEEKMIDAS_VERSIONS = {
|
|
4921
|
-
"@geekmidas/audit": "~10.0.0-alpha.
|
|
4922
|
-
"@geekmidas/auth": "~10.0.0-alpha.
|
|
4923
|
-
"@geekmidas/cache": "~10.0.0-alpha.
|
|
4924
|
-
"@geekmidas/client": "~10.0.0-alpha.
|
|
4925
|
-
"@geekmidas/cloud": "~10.0.0-alpha.
|
|
4926
|
-
"@geekmidas/constructs": "~10.0.0-alpha.
|
|
4927
|
-
"@geekmidas/db": "~10.0.0-alpha.
|
|
4928
|
-
"@geekmidas/emailkit": "~10.0.0-alpha.
|
|
4929
|
-
"@geekmidas/envkit": "~10.0.0-alpha.
|
|
4930
|
-
"@geekmidas/errors": "~10.0.0-alpha.
|
|
4931
|
-
"@geekmidas/events": "~10.0.0-alpha.
|
|
4932
|
-
"@geekmidas/logger": "~10.0.0-alpha.
|
|
4933
|
-
"@geekmidas/manifest": "~10.0.0-alpha.
|
|
4934
|
-
"@geekmidas/rate-limit": "~10.0.0-alpha.
|
|
4935
|
-
"@geekmidas/schema": "~10.0.0-alpha.
|
|
4936
|
-
"@geekmidas/services": "~10.0.0-alpha.
|
|
4937
|
-
"@geekmidas/storage": "~10.0.0-alpha.
|
|
4938
|
-
"@geekmidas/telescope": "~10.0.0-alpha.
|
|
4939
|
-
"@geekmidas/testkit": "~10.0.0-alpha.
|
|
6476
|
+
"@geekmidas/audit": "~10.0.0-alpha.61",
|
|
6477
|
+
"@geekmidas/auth": "~10.0.0-alpha.61",
|
|
6478
|
+
"@geekmidas/cache": "~10.0.0-alpha.61",
|
|
6479
|
+
"@geekmidas/client": "~10.0.0-alpha.61",
|
|
6480
|
+
"@geekmidas/cloud": "~10.0.0-alpha.61",
|
|
6481
|
+
"@geekmidas/constructs": "~10.0.0-alpha.61",
|
|
6482
|
+
"@geekmidas/db": "~10.0.0-alpha.61",
|
|
6483
|
+
"@geekmidas/emailkit": "~10.0.0-alpha.61",
|
|
6484
|
+
"@geekmidas/envkit": "~10.0.0-alpha.61",
|
|
6485
|
+
"@geekmidas/errors": "~10.0.0-alpha.61",
|
|
6486
|
+
"@geekmidas/events": "~10.0.0-alpha.61",
|
|
6487
|
+
"@geekmidas/logger": "~10.0.0-alpha.61",
|
|
6488
|
+
"@geekmidas/manifest": "~10.0.0-alpha.61",
|
|
6489
|
+
"@geekmidas/rate-limit": "~10.0.0-alpha.61",
|
|
6490
|
+
"@geekmidas/schema": "~10.0.0-alpha.61",
|
|
6491
|
+
"@geekmidas/services": "~10.0.0-alpha.61",
|
|
6492
|
+
"@geekmidas/storage": "~10.0.0-alpha.61",
|
|
6493
|
+
"@geekmidas/telescope": "~10.0.0-alpha.61",
|
|
6494
|
+
"@geekmidas/testkit": "~10.0.0-alpha.61",
|
|
4940
6495
|
"@geekmidas/cli": `~${loadPackageJson().version}`
|
|
4941
6496
|
};
|
|
4942
6497
|
//#endregion
|
|
@@ -10837,7 +12392,7 @@ function testDatabases(reconciled) {
|
|
|
10837
12392
|
* starts without the reference data the app needs.
|
|
10838
12393
|
*/
|
|
10839
12394
|
async function migrateTestStage(workspace, env) {
|
|
10840
|
-
const { migrateAndSeed } = await import("./migrate-
|
|
12395
|
+
const { migrateAndSeed } = await import("./migrate-5_J5JySj.mjs");
|
|
10841
12396
|
const { discover } = await import("./discover-FrYvzkeC.mjs").then((n) => n.n);
|
|
10842
12397
|
const sources = {};
|
|
10843
12398
|
const manifest = await discover({
|
|
@@ -11268,7 +12823,7 @@ program.command("migrate").description("Apply each database construct's migratio
|
|
|
11268
12823
|
try {
|
|
11269
12824
|
const globalOptions = program.opts();
|
|
11270
12825
|
if (globalOptions.cwd) process.chdir(globalOptions.cwd);
|
|
11271
|
-
const { migrateCommand } = await import("./migrate-
|
|
12826
|
+
const { migrateCommand } = await import("./migrate-5_J5JySj.mjs");
|
|
11272
12827
|
await migrateCommand({
|
|
11273
12828
|
...options,
|
|
11274
12829
|
...construct ? { construct } : {}
|
|
@@ -11282,7 +12837,7 @@ program.command("seed").description("Migrate, then run each database construct's
|
|
|
11282
12837
|
try {
|
|
11283
12838
|
const globalOptions = program.opts();
|
|
11284
12839
|
if (globalOptions.cwd) process.chdir(globalOptions.cwd);
|
|
11285
|
-
const { seedCommand } = await import("./migrate-
|
|
12840
|
+
const { seedCommand } = await import("./migrate-5_J5JySj.mjs");
|
|
11286
12841
|
await seedCommand({
|
|
11287
12842
|
...options,
|
|
11288
12843
|
...construct ? { construct } : {}
|
|
@@ -11296,7 +12851,7 @@ program.command("migration").description("Write a construct's next migration: an
|
|
|
11296
12851
|
try {
|
|
11297
12852
|
const globalOptions = program.opts();
|
|
11298
12853
|
if (globalOptions.cwd) process.chdir(globalOptions.cwd);
|
|
11299
|
-
const { migrationCommand } = await import("./migrate-
|
|
12854
|
+
const { migrationCommand } = await import("./migrate-5_J5JySj.mjs");
|
|
11300
12855
|
await migrationCommand({
|
|
11301
12856
|
construct,
|
|
11302
12857
|
...name ? { name } : {}
|
|
@@ -11341,6 +12896,16 @@ program.command("docker").description("Generate Docker deployment files").option
|
|
|
11341
12896
|
process.exit(1);
|
|
11342
12897
|
}
|
|
11343
12898
|
});
|
|
12899
|
+
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) => {
|
|
12900
|
+
try {
|
|
12901
|
+
const globalOptions = program.opts();
|
|
12902
|
+
if (globalOptions.cwd) process.chdir(globalOptions.cwd);
|
|
12903
|
+
await composeCommand(options);
|
|
12904
|
+
} catch (error) {
|
|
12905
|
+
console.error(formatError(error));
|
|
12906
|
+
process.exit(1);
|
|
12907
|
+
}
|
|
12908
|
+
});
|
|
11344
12909
|
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) => {
|
|
11345
12910
|
try {
|
|
11346
12911
|
const globalOptions = program.opts();
|
|
@@ -11394,7 +12959,7 @@ program.command("encryption:rotate").description("Add a new current key to an En
|
|
|
11394
12959
|
try {
|
|
11395
12960
|
const globalOptions = program.opts();
|
|
11396
12961
|
if (globalOptions.cwd) process.chdir(globalOptions.cwd);
|
|
11397
|
-
const { encryptionRotateCommand } = await import("./encryption-
|
|
12962
|
+
const { encryptionRotateCommand } = await import("./encryption-DaA9gRXr.mjs");
|
|
11398
12963
|
await encryptionRotateCommand(construct, options);
|
|
11399
12964
|
} catch (error) {
|
|
11400
12965
|
console.error(formatError(error));
|
|
@@ -11405,7 +12970,7 @@ program.command("encryption:retire").description("Remove an old key from an Encr
|
|
|
11405
12970
|
try {
|
|
11406
12971
|
const globalOptions = program.opts();
|
|
11407
12972
|
if (globalOptions.cwd) process.chdir(globalOptions.cwd);
|
|
11408
|
-
const { encryptionRetireCommand } = await import("./encryption-
|
|
12973
|
+
const { encryptionRetireCommand } = await import("./encryption-DaA9gRXr.mjs");
|
|
11409
12974
|
await encryptionRetireCommand(construct, key, options);
|
|
11410
12975
|
} catch (error) {
|
|
11411
12976
|
console.error(formatError(error));
|
|
@@ -11457,7 +13022,7 @@ program.command("secrets:reconcile").description("Backfill missing custom secret
|
|
|
11457
13022
|
console.error(`No secrets found for stage "${stage}". Run "gkm secrets:init --stage ${stage}" first.`);
|
|
11458
13023
|
process.exit(1);
|
|
11459
13024
|
}
|
|
11460
|
-
const { derivedContainers } = await import("./sniffer-
|
|
13025
|
+
const { derivedContainers } = await import("./sniffer-bdleks5l.mjs").then((n) => n.q);
|
|
11461
13026
|
const result = reconcileMissingSecrets(secrets, workspace, await derivedContainers(workspace, stage));
|
|
11462
13027
|
if (!result) {
|
|
11463
13028
|
console.log(`\n✓ Secrets for stage "${stage}" are up-to-date`);
|
|
@@ -11472,7 +13037,7 @@ program.command("secrets:reconcile").description("Backfill missing custom secret
|
|
|
11472
13037
|
}
|
|
11473
13038
|
});
|
|
11474
13039
|
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) => {
|
|
11475
|
-
const { deployCli } = await import("./cli-
|
|
13040
|
+
const { deployCli } = await import("./cli-DEA_J3Sa.mjs");
|
|
11476
13041
|
const globalOptions = program.opts();
|
|
11477
13042
|
const code = await deployCli({
|
|
11478
13043
|
cwd: resolve(globalOptions.cwd ?? process.cwd()),
|
|
@@ -11639,6 +13204,6 @@ program.command("upgrade").description("Upgrade @geekmidas/cli (with --all, ever
|
|
|
11639
13204
|
});
|
|
11640
13205
|
program.parse();
|
|
11641
13206
|
//#endregion
|
|
11642
|
-
export { formatError as i, slug as n, stamp as r, generateFullstackCustomSecrets as t };
|
|
13207
|
+
export { DuplicateMigration as _, KyselyNotFound as a, NoOwnerCredential as c, UnknownDatabaseFolder as d, UnknownMigrationFolder as f, seedDatabases as g, pendingMigrations as h, formatError as i, NoSuchMigrationTarget as l, migrateDatabases as m, slug as n, MigrationFailed as o, migrateAndSeed as p, stamp as r, MigrationsOutsideFolder as s, generateFullstackCustomSecrets as t, SeedFailed as u, MigrationHasNoUp as v, SeedHasNoSeed as y };
|
|
11643
13208
|
|
|
11644
13209
|
//# sourceMappingURL=index.mjs.map
|