@geekmidas/cli 10.0.0-alpha.64 → 10.0.0-alpha.66
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/README.md +15 -1
- package/dist/{HostingerProvider-Bx2lHHvu.cjs → HostingerProvider-BznqRC2S.cjs} +5 -5
- package/dist/HostingerProvider-BznqRC2S.cjs.map +1 -0
- package/dist/{HostingerProvider-Cd4aS5Kj.mjs → HostingerProvider-CY_VpVud.mjs} +4 -4
- package/dist/HostingerProvider-CY_VpVud.mjs.map +1 -0
- package/dist/{LegacyStateStore-C2abV49N.cjs → LegacyStateStore-BFcTT2QL.cjs} +2 -2
- package/dist/{LegacyStateStore-C2abV49N.cjs.map → LegacyStateStore-BFcTT2QL.cjs.map} +1 -1
- package/dist/{LegacyStateStore-DIgkGaUP.mjs → LegacyStateStore-CFheFo62.mjs} +2 -2
- package/dist/{LegacyStateStore-DIgkGaUP.mjs.map → LegacyStateStore-CFheFo62.mjs.map} +1 -1
- package/dist/{LocalStateStore-DiJqKyPy.cjs → LocalStateStore-BVUexSvu.cjs} +2 -2
- package/dist/{LocalStateStore-DiJqKyPy.cjs.map → LocalStateStore-BVUexSvu.cjs.map} +1 -1
- package/dist/{LocalStateStore-Cy1ZBW7U.mjs → LocalStateStore-Bm3x9oOg.mjs} +2 -2
- package/dist/{LocalStateStore-Cy1ZBW7U.mjs.map → LocalStateStore-Bm3x9oOg.mjs.map} +1 -1
- package/dist/{OpenApiTsGenerator-BWuDRHe9.cjs → OpenApiTsGenerator-B2zrdyP4.cjs} +2 -2
- package/dist/{OpenApiTsGenerator-BWuDRHe9.cjs.map → OpenApiTsGenerator-B2zrdyP4.cjs.map} +1 -1
- package/dist/{OpenApiTsGenerator-C6v-IeHt.mjs → OpenApiTsGenerator-BmUAcR7d.mjs} +2 -2
- package/dist/{OpenApiTsGenerator-C6v-IeHt.mjs.map → OpenApiTsGenerator-BmUAcR7d.mjs.map} +1 -1
- package/dist/{Route53Provider-DVILBsvV.cjs → Route53Provider-Bllus2NR.cjs} +2 -2
- package/dist/Route53Provider-Bllus2NR.cjs.map +1 -0
- package/dist/{Route53Provider-Cvj40A8I.mjs → Route53Provider-QXxjkZO1.mjs} +2 -2
- package/dist/Route53Provider-QXxjkZO1.mjs.map +1 -0
- package/dist/{S3StateStore-D3x6ivBL.mjs → S3StateStore-BFHuctWM.mjs} +2 -2
- package/dist/{S3StateStore-D3x6ivBL.mjs.map → S3StateStore-BFHuctWM.mjs.map} +1 -1
- package/dist/{S3StateStore-Ds0V1Lgm.cjs → S3StateStore-CoXBhILd.cjs} +2 -2
- package/dist/{S3StateStore-Ds0V1Lgm.cjs.map → S3StateStore-CoXBhILd.cjs.map} +1 -1
- package/dist/{SSMStateStore-CLipvx47.cjs → SSMStateStore-BqCA9TP2.cjs} +2 -2
- package/dist/{SSMStateStore-CLipvx47.cjs.map → SSMStateStore-BqCA9TP2.cjs.map} +1 -1
- package/dist/{SSMStateStore-DfF174xQ.mjs → SSMStateStore-C1kvHScY.mjs} +2 -2
- package/dist/{SSMStateStore-DfF174xQ.mjs.map → SSMStateStore-C1kvHScY.mjs.map} +1 -1
- package/dist/{backup-provisioner-DIVCbfee.mjs → backup-provisioner-C8DnCmxK.mjs} +2 -2
- package/dist/backup-provisioner-C8DnCmxK.mjs.map +1 -0
- package/dist/{backup-provisioner-BmhIO_Yr.cjs → backup-provisioner-oDEsPj02.cjs} +2 -2
- package/dist/backup-provisioner-oDEsPj02.cjs.map +1 -0
- package/dist/{bundler-DYgm9xsd.mjs → bundler-Dvaiud0h.mjs} +4 -4
- package/dist/{bundler-DYgm9xsd.mjs.map → bundler-Dvaiud0h.mjs.map} +1 -1
- package/dist/{bundler-C8uJf324.cjs → bundler-orRqUJNZ.cjs} +4 -4
- package/dist/{bundler-C8uJf324.cjs.map → bundler-orRqUJNZ.cjs.map} +1 -1
- package/dist/{cli-Bogz-zEj.mjs → cli-CfHm0AO7.mjs} +7 -6
- package/dist/cli-CfHm0AO7.mjs.map +1 -0
- package/dist/{cli-D3jofJ6j.cjs → cli-D1JkkOwg.cjs} +8 -7
- package/dist/cli-D1JkkOwg.cjs.map +1 -0
- package/dist/compose-CNQL8BnO.mjs +1398 -0
- package/dist/compose-CNQL8BnO.mjs.map +1 -0
- package/dist/compose-jrLy7KGO.cjs +1427 -0
- package/dist/compose-jrLy7KGO.cjs.map +1 -0
- package/dist/{config-Be-4_I6H.d.cts → config-4XFxX1al.d.cts} +3 -3
- package/dist/{config-Be-4_I6H.d.cts.map → config-4XFxX1al.d.cts.map} +1 -1
- package/dist/{config-DqNRiQOp.d.mts → config-UtKzhpFb.d.mts} +3 -3
- package/dist/{config-DqNRiQOp.d.mts.map → config-UtKzhpFb.d.mts.map} +1 -1
- package/dist/config.cjs +3 -3
- package/dist/config.d.cts +3 -3
- package/dist/config.d.mts +3 -3
- package/dist/config.mjs +3 -3
- package/dist/{credentials-B70vmLcm.cjs → credentials-Bzbg23n8.cjs} +176 -3
- package/dist/credentials-Bzbg23n8.cjs.map +1 -0
- package/dist/{credentials-s0wMeZBU.mjs → credentials-Dq1MnWQf.mjs} +128 -3
- package/dist/credentials-Dq1MnWQf.mjs.map +1 -0
- package/dist/databases-C2p5LGAp.cjs +419 -0
- package/dist/databases-C2p5LGAp.cjs.map +1 -0
- package/dist/databases-hUHTuwhx.mjs +346 -0
- package/dist/databases-hUHTuwhx.mjs.map +1 -0
- package/dist/{define-CYs0PEfZ.mjs → define-DR8xGYfL.mjs} +5 -1
- package/dist/define-DR8xGYfL.mjs.map +1 -0
- package/dist/{define-zUsP72ym.cjs → define-DzRz8FgH.cjs} +5 -1
- package/dist/define-DzRz8FgH.cjs.map +1 -0
- package/dist/deploy/public.cjs +35 -26
- package/dist/deploy/public.cjs.map +1 -1
- package/dist/deploy/public.d.cts +140 -20
- package/dist/deploy/public.d.cts.map +1 -1
- package/dist/deploy/public.d.mts +140 -20
- package/dist/deploy/public.d.mts.map +1 -1
- package/dist/deploy/public.mjs +13 -13
- package/dist/{terminal-Bdv--jhY.cjs → deploy-B7aJi_Ah.cjs} +146 -116
- package/dist/deploy-B7aJi_Ah.cjs.map +1 -0
- package/dist/{terminal-D7Ntz6WE.mjs → deploy-DWn7YfIc.mjs} +137 -89
- package/dist/deploy-DWn7YfIc.mjs.map +1 -0
- package/dist/{discover-LMrqsWSU.mjs → discover-BFoJWKGD.mjs} +4 -2
- package/dist/discover-BFoJWKGD.mjs.map +1 -0
- package/dist/{discover-DHdECueV.cjs → discover-OEfZ9OkA.cjs} +4 -2
- package/dist/discover-OEfZ9OkA.cjs.map +1 -0
- package/dist/dokploy-B4LcYG1E.cjs +38 -0
- package/dist/dokploy-B4LcYG1E.cjs.map +1 -0
- package/dist/dokploy-DalwLML5.mjs +38 -0
- package/dist/dokploy-DalwLML5.mjs.map +1 -0
- package/dist/{encryption-C9X98u2F.cjs → encryption-3KlGS1sl.cjs} +6 -8
- package/dist/{encryption-C9X98u2F.cjs.map → encryption-3KlGS1sl.cjs.map} +1 -1
- package/dist/{encryption-CKHjMpCr.mjs → encryption-CPfENwWO.mjs} +3 -5
- package/dist/{encryption-CKHjMpCr.mjs.map → encryption-CPfENwWO.mjs.map} +1 -1
- package/dist/{deploy-ZRXIPp1c.mjs → engine-CunvOG3s.mjs} +2116 -1855
- package/dist/engine-CunvOG3s.mjs.map +1 -0
- package/dist/{deploy-55rJkdKz.cjs → engine-DR6KurWX.cjs} +2172 -1869
- package/dist/engine-DR6KurWX.cjs.map +1 -0
- package/dist/{github-Dd3tMa1u.cjs → github-BJ5gh9bw.cjs} +7 -7
- package/dist/{github-Dd3tMa1u.cjs.map → github-BJ5gh9bw.cjs.map} +1 -1
- package/dist/{github-D-byNI7T.mjs → github-MXA9v7Ge.mjs} +3 -3
- package/dist/{github-D-byNI7T.mjs.map → github-MXA9v7Ge.mjs.map} +1 -1
- package/dist/{index-CfE9Y885.d.cts → index-CyApaHVe.d.cts} +16 -3
- package/dist/index-CyApaHVe.d.cts.map +1 -0
- package/dist/{index-CJ2qsSpp.d.mts → index-DD1FfUHh.d.mts} +16 -3
- package/dist/index-DD1FfUHh.d.mts.map +1 -0
- package/dist/index.cjs +465 -1691
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +405 -1619
- package/dist/index.mjs.map +1 -1
- package/dist/{local-C7wnrcYK.cjs → local-BdH4XN0E.cjs} +1 -178
- package/dist/local-BdH4XN0E.cjs.map +1 -0
- package/dist/{local-Cd8ZYQG3.mjs → local-CWR4Y_kW.mjs} +3 -126
- package/dist/local-CWR4Y_kW.mjs.map +1 -0
- package/dist/{migrate-B4gngTLe.mjs → migrate-BJBcm4sG.mjs} +7 -6
- package/dist/{migrate-B4gngTLe.mjs.map → migrate-BJBcm4sG.mjs.map} +1 -1
- package/dist/{migrate-tx9-P9uz.cjs → migrate-CF4J4GQS.cjs} +20 -19
- package/dist/{migrate-tx9-P9uz.cjs.map → migrate-CF4J4GQS.cjs.map} +1 -1
- package/dist/openapi.cjs +1 -1
- package/dist/openapi.d.cts +1 -1
- package/dist/openapi.d.mts +1 -1
- package/dist/openapi.mjs +1 -1
- package/dist/provider-B3Sv_a74.cjs +173 -0
- package/dist/provider-B3Sv_a74.cjs.map +1 -0
- package/dist/provider-C9_-45d5.cjs +56 -0
- package/dist/provider-C9_-45d5.cjs.map +1 -0
- package/dist/provider-D7rQzzzf.mjs +138 -0
- package/dist/provider-D7rQzzzf.mjs.map +1 -0
- package/dist/provider-q4lsYzYG.mjs +45 -0
- package/dist/provider-q4lsYzYG.mjs.map +1 -0
- package/dist/{ready-bQfu9GES.mjs → ready-2s3-CndS.mjs} +3 -3
- package/dist/{ready-bQfu9GES.mjs.map → ready-2s3-CndS.mjs.map} +1 -1
- package/dist/{ready-brEhh68f.cjs → ready-B6uHr4sm.cjs} +3 -3
- package/dist/{ready-brEhh68f.cjs.map → ready-B6uHr4sm.cjs.map} +1 -1
- package/dist/reconcile/public.cjs +1 -1
- package/dist/reconcile/public.d.cts +1 -1
- package/dist/reconcile/public.d.mts +1 -1
- package/dist/reconcile/public.mjs +1 -1
- package/dist/resolve-CVmch0wI.cjs +315 -0
- package/dist/resolve-CVmch0wI.cjs.map +1 -0
- package/dist/resolve-Db0tNlrW.mjs +262 -0
- package/dist/resolve-Db0tNlrW.mjs.map +1 -0
- package/dist/rollback-DvAo4OKS.mjs +133 -0
- package/dist/rollback-DvAo4OKS.mjs.map +1 -0
- package/dist/rollback-DxJK84hy.cjs +162 -0
- package/dist/rollback-DxJK84hy.cjs.map +1 -0
- package/dist/{runtime-CJEYK4Gz.d.mts → runtime-BRqFOvPM.d.cts} +2 -2
- package/dist/{runtime-CJEYK4Gz.d.mts.map → runtime-BRqFOvPM.d.cts.map} +1 -1
- package/dist/{runtime-Cjq1jlVX.d.cts → runtime-CEk5n4VH.d.mts} +2 -2
- package/dist/{runtime-Cjq1jlVX.d.cts.map → runtime-CEk5n4VH.d.mts.map} +1 -1
- package/dist/sandbox/discover-worker.cjs +1 -1
- package/dist/sandbox/discover-worker.mjs +1 -1
- package/dist/sandbox/migrate-worker.cjs +61 -0
- package/dist/sandbox/migrate-worker.cjs.map +1 -0
- package/dist/sandbox/migrate-worker.d.cts +1 -0
- package/dist/sandbox/migrate-worker.d.mts +1 -0
- package/dist/sandbox/migrate-worker.mjs +62 -0
- package/dist/sandbox/migrate-worker.mjs.map +1 -0
- package/dist/{schemas-D2LCF689.mjs → schemas-Cfhtpdo0.mjs} +10 -2
- package/dist/{schemas-Ckm_aBnF.cjs.map → schemas-Cfhtpdo0.mjs.map} +1 -1
- package/dist/{schemas-Ckm_aBnF.cjs → schemas-RUlKHu27.cjs} +15 -1
- package/dist/{schemas-D2LCF689.mjs.map → schemas-RUlKHu27.cjs.map} +1 -1
- package/dist/sniffer-5xmrkh2a.cjs +1742 -0
- package/dist/sniffer-5xmrkh2a.cjs.map +1 -0
- package/dist/sniffer-BAQhLEsI.mjs +1617 -0
- package/dist/sniffer-BAQhLEsI.mjs.map +1 -0
- package/dist/{sst-Cper040I.mjs → sst-B2HbrDR3.mjs} +4 -4
- package/dist/{sst-Cper040I.mjs.map → sst-B2HbrDR3.mjs.map} +1 -1
- package/dist/{sst-BdALKEas.cjs → sst-DcfsVH6x.cjs} +4 -4
- package/dist/{sst-BdALKEas.cjs.map → sst-DcfsVH6x.cjs.map} +1 -1
- package/dist/{sniffer-ByhbEW5A.cjs → state-Y6ATakMX.cjs} +1769 -2759
- package/dist/state-Y6ATakMX.cjs.map +1 -0
- package/dist/{sniffer-49KPbDgq.mjs → state-uuYqnRVO.mjs} +1763 -2764
- package/dist/state-uuYqnRVO.mjs.map +1 -0
- package/dist/target/public.cjs +6 -6
- package/dist/target/public.d.cts +2 -2
- package/dist/target/public.d.mts +2 -2
- package/dist/target/public.mjs +3 -3
- package/dist/terminal-CHy_MdjG.cjs +84 -0
- package/dist/terminal-CHy_MdjG.cjs.map +1 -0
- package/dist/terminal-DUhcZ1ND.mjs +84 -0
- package/dist/terminal-DUhcZ1ND.mjs.map +1 -0
- package/dist/{types-DkrDOMxw.d.cts → types-CSw2w69a.d.mts} +110 -20
- package/dist/types-CSw2w69a.d.mts.map +1 -0
- package/dist/{types-B_bEQvb9.d.mts → types-D5WIxRDP.d.cts} +111 -19
- package/dist/types-D5WIxRDP.d.cts.map +1 -0
- package/dist/vitest.cjs +1 -1
- package/dist/vitest.mjs +1 -1
- package/dist/workspace/index.cjs +3 -1
- package/dist/workspace/index.d.cts +3 -3
- package/dist/workspace/index.d.mts +3 -3
- package/dist/workspace/index.mjs +2 -2
- package/dist/{workspace-Bwit4q9O.mjs → workspace-Be6uBhlF.mjs} +36 -6
- package/dist/workspace-Be6uBhlF.mjs.map +1 -0
- package/dist/{workspace-BwgWJK7h.cjs → workspace-ON-bqiJz.cjs} +47 -5
- package/dist/workspace-ON-bqiJz.cjs.map +1 -0
- package/package.json +13 -13
- package/dist/HostingerProvider-Bx2lHHvu.cjs.map +0 -1
- package/dist/HostingerProvider-Cd4aS5Kj.mjs.map +0 -1
- package/dist/Route53Provider-Cvj40A8I.mjs.map +0 -1
- package/dist/Route53Provider-DVILBsvV.cjs.map +0 -1
- package/dist/backup-provisioner-BmhIO_Yr.cjs.map +0 -1
- package/dist/backup-provisioner-DIVCbfee.mjs.map +0 -1
- package/dist/cli-Bogz-zEj.mjs.map +0 -1
- package/dist/cli-D3jofJ6j.cjs.map +0 -1
- package/dist/credentials-B70vmLcm.cjs.map +0 -1
- package/dist/credentials-s0wMeZBU.mjs.map +0 -1
- package/dist/define-CYs0PEfZ.mjs.map +0 -1
- package/dist/define-zUsP72ym.cjs.map +0 -1
- package/dist/deploy-55rJkdKz.cjs.map +0 -1
- package/dist/deploy-Dc2LBJaK.mjs +0 -136
- package/dist/deploy-Dc2LBJaK.mjs.map +0 -1
- package/dist/deploy-ZRXIPp1c.mjs.map +0 -1
- package/dist/deploy-yUcR2OlW.cjs +0 -141
- package/dist/deploy-yUcR2OlW.cjs.map +0 -1
- package/dist/discover-DHdECueV.cjs.map +0 -1
- package/dist/discover-LMrqsWSU.mjs.map +0 -1
- package/dist/dokploy-CepdXuMl.mjs +0 -32
- package/dist/dokploy-CepdXuMl.mjs.map +0 -1
- package/dist/dokploy-D1d37rvM.cjs +0 -32
- package/dist/dokploy-D1d37rvM.cjs.map +0 -1
- package/dist/index-CJ2qsSpp.d.mts.map +0 -1
- package/dist/index-CfE9Y885.d.cts.map +0 -1
- package/dist/local-C7wnrcYK.cjs.map +0 -1
- package/dist/local-Cd8ZYQG3.mjs.map +0 -1
- package/dist/resolve-BXsNQjMR.cjs +0 -220
- package/dist/resolve-BXsNQjMR.cjs.map +0 -1
- package/dist/resolve-DZ2IJ0qY.mjs +0 -173
- package/dist/resolve-DZ2IJ0qY.mjs.map +0 -1
- package/dist/runtime-BGNDrHzh.cjs +0 -163
- package/dist/runtime-BGNDrHzh.cjs.map +0 -1
- package/dist/runtime-Cmn-FeYO.mjs +0 -134
- package/dist/runtime-Cmn-FeYO.mjs.map +0 -1
- package/dist/sniffer-49KPbDgq.mjs.map +0 -1
- package/dist/sniffer-ByhbEW5A.cjs.map +0 -1
- package/dist/storage-BzaUxmqe.mjs +0 -124
- package/dist/storage-BzaUxmqe.mjs.map +0 -1
- package/dist/storage-CSGoC3bK.cjs +0 -152
- package/dist/storage-CSGoC3bK.cjs.map +0 -1
- package/dist/store-BaaonhkJ.mjs +0 -415
- package/dist/store-BaaonhkJ.mjs.map +0 -1
- package/dist/store-D_2ByRtW.cjs +0 -510
- package/dist/store-D_2ByRtW.cjs.map +0 -1
- package/dist/terminal-Bdv--jhY.cjs.map +0 -1
- package/dist/terminal-D7Ntz6WE.mjs.map +0 -1
- package/dist/types-B_bEQvb9.d.mts.map +0 -1
- package/dist/types-DkrDOMxw.d.cts.map +0 -1
- package/dist/workspace-BwgWJK7h.cjs.map +0 -1
- package/dist/workspace-Bwit4q9O.mjs.map +0 -1
|
@@ -1,27 +1,21 @@
|
|
|
1
1
|
import { n as __require, t as __exportAll } from "./rolldown-runtime-BhDjJH2R.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import { d as output, r as discover
|
|
4
|
-
import { i as activeSandbox } from "./sandbox-Dm247NGx.mjs";
|
|
2
|
+
import { s as run$1, u as gkmHome } from "./local-CWR4Y_kW.mjs";
|
|
3
|
+
import { d as output, r as discover } from "./discover-BFoJWKGD.mjs";
|
|
5
4
|
import { t as appKey } from "./derive-B4vDpzgP.mjs";
|
|
6
|
-
import {
|
|
5
|
+
import { O as TEST_STAGE, h as getPublicEnvPrefix, n as allConstructGlobs } from "./workspace-Be6uBhlF.mjs";
|
|
7
6
|
import { ConfigNotFound, NotInAnApp, loadConfig, loadWorkspaceAppInfo, loadWorkspaceConfig } from "./config.mjs";
|
|
8
|
-
import {
|
|
9
|
-
import { i as FileSecretsStore, n as secretsStoreFor } from "./store-BaaonhkJ.mjs";
|
|
10
|
-
import { r as runtimeOf } from "./runtime-Cmn-FeYO.mjs";
|
|
7
|
+
import { c as runtimeOf } from "./resolve-Db0tNlrW.mjs";
|
|
11
8
|
import { i as DEFAULT_STORAGE, n as DEFAULT_CACHE, r as DEFAULT_EVENTS$1, t as readFakes } from "./fakes-C9RiT6XG.mjs";
|
|
12
9
|
import { basename, dirname, join, parse, resolve } from "node:path";
|
|
13
|
-
import {
|
|
14
|
-
import
|
|
15
|
-
import {
|
|
16
|
-
import { mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
17
|
-
import { hostname, networkInterfaces, userInfo } from "node:os";
|
|
10
|
+
import { constants, copyFileSync, existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
11
|
+
import { chmod, copyFile, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
12
|
+
import { homedir, networkInterfaces } from "node:os";
|
|
18
13
|
import { promisify } from "node:util";
|
|
19
|
-
import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependenciesOf, dependentsOf, externalApiUrl, kebabCase, mobileOrigins, provideKey, providedKeyFor, provisionOrder, publicEnvFor, schemeBase } from "@geekmidas/manifest";
|
|
20
|
-
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { DEFAULT_POSTGRES_VERSION, cacheTable, cookieDomain, dependenciesOf, dependentsOf, externalApiUrl, kebabCase, mobileOrigins, provideKey, providedKeyFor, provisionOrder, publicEnvFor, schemeBase, scopedName } from "@geekmidas/manifest";
|
|
21
15
|
import { execFile, spawn } from "node:child_process";
|
|
22
16
|
import { createServer } from "node:net";
|
|
23
17
|
import { config } from "dotenv";
|
|
24
|
-
import { createCipheriv, createHash, randomBytes
|
|
18
|
+
import { createCipheriv, createDecipheriv, createHash, randomBytes } from "node:crypto";
|
|
25
19
|
import { formatKeyring, generateKeyring } from "@geekmidas/constructs/encryption";
|
|
26
20
|
import { ownerRole, readerRole, roleStatements } from "@geekmidas/db/pg/roles";
|
|
27
21
|
import * as snsUrl from "@geekmidas/events/sns/url";
|
|
@@ -32,189 +26,528 @@ import { CreateQueueCommand, GetQueueUrlCommand, SQSClient } from "@aws-sdk/clie
|
|
|
32
26
|
import { Client } from "pg";
|
|
33
27
|
import { stringify } from "yaml";
|
|
34
28
|
import { cacheTableStatements } from "@geekmidas/cache/postgres";
|
|
35
|
-
//#region src/
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
29
|
+
//#region src/secrets/storage.ts
|
|
30
|
+
var storage_exports = /* @__PURE__ */ __exportAll({
|
|
31
|
+
initStageSecrets: () => initStageSecrets,
|
|
32
|
+
maskPassword: () => maskPassword,
|
|
33
|
+
toEmbeddableSecrets: () => toEmbeddableSecrets,
|
|
34
|
+
validateEnvironmentVariables: () => validateEnvironmentVariables,
|
|
35
|
+
withCustomSecret: () => withCustomSecret
|
|
36
|
+
});
|
|
37
|
+
/**
|
|
38
|
+
* Initialize an empty StageSecrets object for a stage.
|
|
39
|
+
*/
|
|
40
|
+
function initStageSecrets(stage) {
|
|
41
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
42
|
+
return {
|
|
43
|
+
stage,
|
|
44
|
+
createdAt: now,
|
|
45
|
+
updatedAt: now,
|
|
46
|
+
services: {},
|
|
47
|
+
urls: {},
|
|
48
|
+
custom: {}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Convert StageSecrets to embeddable format (flat key-value pairs).
|
|
53
|
+
* This is what gets encrypted and embedded in the bundle.
|
|
54
|
+
*/
|
|
55
|
+
function toEmbeddableSecrets(secrets) {
|
|
56
|
+
return {
|
|
57
|
+
...secrets.urls,
|
|
58
|
+
...secrets.custom,
|
|
59
|
+
...secrets.services.postgres && {
|
|
60
|
+
POSTGRES_USER: secrets.services.postgres.username,
|
|
61
|
+
POSTGRES_PASSWORD: secrets.services.postgres.password,
|
|
62
|
+
POSTGRES_DB: secrets.services.postgres.database ?? "app",
|
|
63
|
+
POSTGRES_HOST: secrets.services.postgres.host,
|
|
64
|
+
POSTGRES_PORT: String(secrets.services.postgres.port)
|
|
65
|
+
},
|
|
66
|
+
...secrets.services.redis && {
|
|
67
|
+
REDIS_PASSWORD: secrets.services.redis.password,
|
|
68
|
+
REDIS_HOST: secrets.services.redis.host,
|
|
69
|
+
REDIS_PORT: String(secrets.services.redis.port)
|
|
70
|
+
},
|
|
71
|
+
...secrets.services.minio && {
|
|
72
|
+
STORAGE_ACCESS_KEY_ID: secrets.services.minio.username,
|
|
73
|
+
STORAGE_SECRET_ACCESS_KEY: secrets.services.minio.password,
|
|
74
|
+
STORAGE_BUCKET: secrets.services.minio.bucket ?? "app",
|
|
75
|
+
STORAGE_REGION: "eu-west-1",
|
|
76
|
+
STORAGE_FORCE_PATH_STYLE: "true"
|
|
77
|
+
},
|
|
78
|
+
...secrets.services.mailpit && {
|
|
79
|
+
SMTP_HOST: secrets.services.mailpit.host,
|
|
80
|
+
SMTP_PORT: String(secrets.services.mailpit.port),
|
|
81
|
+
SMTP_USER: secrets.services.mailpit.username,
|
|
82
|
+
SMTP_PASS: secrets.services.mailpit.password,
|
|
83
|
+
SMTP_SECURE: "false",
|
|
84
|
+
MAIL_FROM: "noreply@localhost"
|
|
85
|
+
},
|
|
86
|
+
...secrets.services.localstack && {
|
|
87
|
+
AWS_ACCESS_KEY_ID: secrets.services.localstack.accessKeyId ?? secrets.services.localstack.username,
|
|
88
|
+
AWS_SECRET_ACCESS_KEY: secrets.services.localstack.password,
|
|
89
|
+
AWS_REGION: secrets.services.localstack.region ?? "us-east-1",
|
|
90
|
+
AWS_ENDPOINT_URL: `http://${secrets.services.localstack.host}:${secrets.services.localstack.port}`
|
|
91
|
+
},
|
|
92
|
+
...secrets.services.pgboss && {
|
|
93
|
+
PGBOSS_DB_USER: secrets.services.pgboss.username,
|
|
94
|
+
PGBOSS_DB_PASSWORD: secrets.services.pgboss.password
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** The same secrets with one custom value set, and `updatedAt` moved on. */
|
|
99
|
+
function withCustomSecret(secrets, key, value) {
|
|
100
|
+
return {
|
|
101
|
+
...secrets,
|
|
102
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
103
|
+
custom: {
|
|
104
|
+
...secrets.custom,
|
|
105
|
+
[key]: value
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Mask a password for display (show first 4 and last 2 chars).
|
|
111
|
+
*/
|
|
112
|
+
function maskPassword(password) {
|
|
113
|
+
if (password.length <= 8) return "********";
|
|
114
|
+
return `${password.slice(0, 4)}${"*".repeat(password.length - 6)}${password.slice(-2)}`;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Validate that all required environment variables are present in secrets.
|
|
118
|
+
*
|
|
119
|
+
* @param requiredVars - Array of environment variable names required by the application
|
|
120
|
+
* @param secrets - Stage secrets to validate against
|
|
121
|
+
* @returns Validation result with missing and provided variables
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* ```typescript
|
|
125
|
+
* const required = ['DATABASE_URL', 'API_KEY', 'JWT_SECRET'];
|
|
126
|
+
* const secrets = await secretsStoreFor(workspace, 'production').then((s) => s.read('production'));
|
|
127
|
+
* const result = validateEnvironmentVariables(required, secrets);
|
|
128
|
+
*
|
|
129
|
+
* if (!result.valid) {
|
|
130
|
+
* console.error(`Missing environment variables: ${result.missing.join(', ')}`);
|
|
131
|
+
* }
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
function validateEnvironmentVariables(requiredVars, secrets) {
|
|
135
|
+
const embeddable = toEmbeddableSecrets(secrets);
|
|
136
|
+
const availableVars = new Set(Object.keys(embeddable));
|
|
137
|
+
const missing = [];
|
|
138
|
+
const provided = [];
|
|
139
|
+
for (const varName of requiredVars) if (availableVars.has(varName)) provided.push(varName);
|
|
140
|
+
else missing.push(varName);
|
|
141
|
+
return {
|
|
142
|
+
valid: missing.length === 0,
|
|
143
|
+
missing: missing.sort(),
|
|
144
|
+
provided: provided.sort(),
|
|
145
|
+
required: [...requiredVars].sort()
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
149
|
+
//#region src/deploy/identity.ts
|
|
150
|
+
/**
|
|
151
|
+
* Who a deploy is, on a target it shares with other workspaces.
|
|
152
|
+
*
|
|
153
|
+
* A Dokploy server is one flat namespace of projects, images and Docker
|
|
154
|
+
* service names. Deploys used to identify everything they made by the
|
|
155
|
+
* workspace name alone, so two workspaces called `shop` — or `Shop` and
|
|
156
|
+
* `shop` — deploying to one server landed in the same project, pushed over
|
|
157
|
+
* each other's images and redeployed each other's applications. A
|
|
158
|
+
* `DeployIdentity` is what a deploy names and claims its resources by instead.
|
|
159
|
+
*/
|
|
160
|
+
/** A namespace that cannot be part of a Dokploy name or an image path. */
|
|
161
|
+
var DeployNamespaceInvalid = class extends Error {
|
|
162
|
+
namespace;
|
|
163
|
+
constructor(namespace) {
|
|
164
|
+
super(`deploy.namespace '${namespace}' is not usable as a name: use lowercase letters, digits and single '-' between them (e.g. 'acme' or 'acme-platform') in gkm.config.ts.`);
|
|
165
|
+
this.namespace = namespace;
|
|
166
|
+
this.name = "DeployNamespaceInvalid";
|
|
52
167
|
}
|
|
53
168
|
};
|
|
54
|
-
/**
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
* terminal program: raw mode has taken the signal away from the shell.
|
|
62
|
-
*/
|
|
63
|
-
async function prompt(message, options) {
|
|
64
|
-
if (!canPrompt()) throw new PromptNeedsTerminal(message, options.instead);
|
|
65
|
-
if (options.hidden) {
|
|
66
|
-
process.stdout.write(message);
|
|
67
|
-
return new Promise((resolve, reject) => {
|
|
68
|
-
let value = "";
|
|
69
|
-
const cleanup = () => {
|
|
70
|
-
process.stdin.setRawMode(false);
|
|
71
|
-
process.stdin.pause();
|
|
72
|
-
process.stdin.removeListener("data", onData);
|
|
73
|
-
process.stdin.removeListener("error", onError);
|
|
74
|
-
};
|
|
75
|
-
const onError = (err) => {
|
|
76
|
-
cleanup();
|
|
77
|
-
reject(err);
|
|
78
|
-
};
|
|
79
|
-
const onData = (char) => {
|
|
80
|
-
const c = char.toString();
|
|
81
|
-
if (c === "\n" || c === "\r") {
|
|
82
|
-
cleanup();
|
|
83
|
-
process.stdout.write("\n");
|
|
84
|
-
resolve(value);
|
|
85
|
-
} else if (c === "") {
|
|
86
|
-
cleanup();
|
|
87
|
-
process.stdout.write("\n");
|
|
88
|
-
process.exit(1);
|
|
89
|
-
} else if (c === "" || c === "\b") {
|
|
90
|
-
if (value.length > 0) value = value.slice(0, -1);
|
|
91
|
-
} else value += c;
|
|
92
|
-
};
|
|
93
|
-
process.stdin.setRawMode(true);
|
|
94
|
-
process.stdin.resume();
|
|
95
|
-
process.stdin.on("data", onData);
|
|
96
|
-
process.stdin.on("error", onError);
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
const rl = readline.createInterface({
|
|
100
|
-
input: stdin,
|
|
101
|
-
output: stdout
|
|
102
|
-
});
|
|
103
|
-
try {
|
|
104
|
-
return await rl.question(message);
|
|
105
|
-
} finally {
|
|
106
|
-
rl.close();
|
|
169
|
+
/** A workspace name with nothing a target can name a resource by. */
|
|
170
|
+
var DeployProjectNameInvalid = class extends Error {
|
|
171
|
+
workspaceName;
|
|
172
|
+
constructor(workspaceName) {
|
|
173
|
+
super(`The workspace name '${workspaceName}' has no letters or digits to name deployed resources by. Set "name" in gkm.config.ts.`);
|
|
174
|
+
this.workspaceName = workspaceName;
|
|
175
|
+
this.name = "DeployProjectNameInvalid";
|
|
107
176
|
}
|
|
177
|
+
};
|
|
178
|
+
const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
179
|
+
/**
|
|
180
|
+
* Lowercase `[a-z0-9-]`, runs of anything else collapsed to one `-`: valid as
|
|
181
|
+
* a Docker image path component, a Docker service name and a Dokploy name.
|
|
182
|
+
*/
|
|
183
|
+
function slug(value) {
|
|
184
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
185
|
+
}
|
|
186
|
+
/** The namespace a workspace is in when it does not name one. */
|
|
187
|
+
function defaultNamespace(workspaceName) {
|
|
188
|
+
return slug(kebabCase(workspaceName));
|
|
189
|
+
}
|
|
190
|
+
function deployIdentity(workspace, stage) {
|
|
191
|
+
const project = slug(workspace.name);
|
|
192
|
+
if (!project) throw new DeployProjectNameInvalid(workspace.name);
|
|
193
|
+
const fallback = defaultNamespace(workspace.name);
|
|
194
|
+
const namespace = workspace.deploy?.namespace ?? fallback;
|
|
195
|
+
if (!NAME.test(namespace)) throw new DeployNamespaceInvalid(namespace);
|
|
196
|
+
return {
|
|
197
|
+
namespace,
|
|
198
|
+
project,
|
|
199
|
+
stage,
|
|
200
|
+
key: `${namespace}/${project}`,
|
|
201
|
+
scope: namespace === fallback ? project : `${namespace}-${project}`
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The token a Dokploy project's description carries to say which identity
|
|
206
|
+
* created it. A project with the right name and no marker belongs to someone
|
|
207
|
+
* else, and a deploy never adopts it.
|
|
208
|
+
*/
|
|
209
|
+
function ownershipMarker(identity) {
|
|
210
|
+
return `gkm:${identity.key}`;
|
|
211
|
+
}
|
|
212
|
+
/** Whether a project description carries `identity`'s marker. */
|
|
213
|
+
function isOwnedBy(description, identity) {
|
|
214
|
+
const marker = ownershipMarker(identity);
|
|
215
|
+
return (description ?? "").split(/\s+/).includes(marker);
|
|
216
|
+
}
|
|
217
|
+
/** Any `gkm:` marker a description carries — whoever's it is. */
|
|
218
|
+
function markerOf(description) {
|
|
219
|
+
return (description ?? "").split(/\s+/).find((t) => t.startsWith("gkm:"));
|
|
220
|
+
}
|
|
221
|
+
/** What the Dokploy project is called. */
|
|
222
|
+
function projectName(identity) {
|
|
223
|
+
return identity.scope;
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* The repository an app's image is pushed to, below the registry:
|
|
227
|
+
* `<namespace>/<project>-<app>`.
|
|
228
|
+
*
|
|
229
|
+
* The namespace is a path segment of its own so that a registry that maps
|
|
230
|
+
* paths to owners (an organisation's packages, a Harbor project) can grant it
|
|
231
|
+
* as one; the app is joined to the project so the two read as one name.
|
|
232
|
+
*/
|
|
233
|
+
function imageName(identity, app) {
|
|
234
|
+
return `${identity.namespace}/${identity.project}-${slug(kebabCase(app))}`;
|
|
235
|
+
}
|
|
236
|
+
/** The full image ref an app is built, pushed and deployed as. */
|
|
237
|
+
function imageRef(identity, app, registry, tag) {
|
|
238
|
+
const name = imageName(identity, app);
|
|
239
|
+
return registry ? `${registry.replace(/\/+$/, "")}/${name}:${tag}` : `${name}:${tag}`;
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* What one application is called on the target.
|
|
243
|
+
*
|
|
244
|
+
* The same rule the constructs use, through the same `scopedName`, scoped by
|
|
245
|
+
* the stage and the identity: the application beside
|
|
246
|
+
* `production-kitchen-sink-database` is `production-kitchen-sink-api`. A
|
|
247
|
+
* Dokploy application's name is also its Docker service name, which is unique
|
|
248
|
+
* on the whole server, so the namespace has to be in it too — `acme`'s and
|
|
249
|
+
* `globex`'s `shop` would otherwise both run `production-shop-api`.
|
|
250
|
+
*
|
|
251
|
+
* The app id is dropped when it repeats the project, so a project named for its
|
|
252
|
+
* one application is `production-kitchen-sink` rather than
|
|
253
|
+
* `production-kitchen-sink-kitchen-sink`.
|
|
254
|
+
*/
|
|
255
|
+
function applicationName(identity, app) {
|
|
256
|
+
return slug(app) === identity.project ? `${identity.stage}-${identity.scope}` : scopedName([identity.stage, identity.scope], app);
|
|
108
257
|
}
|
|
109
258
|
//#endregion
|
|
110
|
-
//#region src/
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
*
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
|
|
126
|
-
*/
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
endpoint = endpoint.replace(/\/$/, "");
|
|
134
|
-
try {
|
|
135
|
-
new URL(endpoint);
|
|
136
|
-
} catch {
|
|
137
|
-
logger$2.error("Invalid URL format");
|
|
138
|
-
process.exit(1);
|
|
139
|
-
}
|
|
140
|
-
let token = providedToken;
|
|
141
|
-
if (!token) {
|
|
142
|
-
logger$2.log(`\nGenerate a token at: ${endpoint}/settings/profile\n`);
|
|
143
|
-
token = await prompt("API Token: ", {
|
|
144
|
-
hidden: true,
|
|
145
|
-
instead: LOGIN_INSTEAD
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
|
-
if (!token) {
|
|
149
|
-
logger$2.error("Token is required");
|
|
150
|
-
process.exit(1);
|
|
151
|
-
}
|
|
152
|
-
logger$2.log("\nValidating credentials...");
|
|
153
|
-
if (!await validateDokployToken(endpoint, token)) {
|
|
154
|
-
logger$2.error("\n✗ Invalid credentials. Please check your token and try again.");
|
|
155
|
-
process.exit(1);
|
|
156
|
-
}
|
|
157
|
-
await storeDokployCredentials(token, endpoint);
|
|
158
|
-
logger$2.log("\n✓ Successfully logged in to Dokploy!");
|
|
159
|
-
logger$2.log(` Endpoint: ${endpoint}`);
|
|
160
|
-
logger$2.log(` Credentials stored in: ${getCredentialsPath()}`);
|
|
161
|
-
logger$2.log("\nYou can now use deploy commands without setting DOKPLOY_API_TOKEN.");
|
|
259
|
+
//#region src/secrets/keystore.ts
|
|
260
|
+
/**
|
|
261
|
+
* The keys that open each stage's encrypted secrets file, kept outside the
|
|
262
|
+
* project in the CLI's home.
|
|
263
|
+
*
|
|
264
|
+
* Keys were kept at `~/.gkm/<folder>/<stage>.key`, named after the project
|
|
265
|
+
* directory — so two checkouts in folders with the same name (`~/work/api`
|
|
266
|
+
* and `~/oss/api`) shared one key, and the second `gkm secrets:init`
|
|
267
|
+
* overwrote the first project's. They are now kept under the project's
|
|
268
|
+
* identity, `<home>/keys/<namespace>/<project>/<stage>.key`, the same
|
|
269
|
+
* `<namespace>/<project>` a deploy claims its Dokploy project by. A key still
|
|
270
|
+
* at the old place is copied to the new one the first time it is read; the
|
|
271
|
+
* old file stays, so an older CLI on the same machine keeps working.
|
|
272
|
+
*/
|
|
273
|
+
/** Key length for AES-256 encryption */
|
|
274
|
+
const KEY_LENGTH = 32;
|
|
275
|
+
/** A keystore project key that is not `<namespace>/<project>`. */
|
|
276
|
+
var KeystoreProjectInvalid = class extends Error {
|
|
277
|
+
key;
|
|
278
|
+
constructor(key) {
|
|
279
|
+
super(`'${key}' is not a keystore project: expected '<namespace>/<project>' in lowercase letters, digits and '-', as deployIdentity() produces. Pass the workspace's identity key.`);
|
|
280
|
+
this.key = key;
|
|
281
|
+
this.name = "KeystoreProjectInvalid";
|
|
162
282
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
process.exit(1);
|
|
176
|
-
}
|
|
177
|
-
await storeHostingerToken(token);
|
|
178
|
-
logger$2.log("\n✓ Hostinger token stored.");
|
|
179
|
-
logger$2.log(` Credentials stored in: ${getCredentialsPath()}`);
|
|
180
|
-
logger$2.log(" Not validated here: every DNS endpoint is scoped to a domain, so the\n first deploy that touches DNS is where a bad token reports itself.");
|
|
181
|
-
logger$2.log("\nYou can now use it as a DNS provider without setting HOSTINGER_API_TOKEN.");
|
|
283
|
+
};
|
|
284
|
+
/** A stage has no key, here or at the place keys used to be kept. */
|
|
285
|
+
var KeyNotFound = class extends Error {
|
|
286
|
+
stage;
|
|
287
|
+
project;
|
|
288
|
+
path;
|
|
289
|
+
constructor(stage, project, path) {
|
|
290
|
+
super(`Encryption key not found for stage "${stage}" in project "${project}". Expected key at: ${path}. Run \`gkm secrets:init --stage ${stage}\` to create one, or copy the stage's key there.`);
|
|
291
|
+
this.stage = stage;
|
|
292
|
+
this.project = project;
|
|
293
|
+
this.path = path;
|
|
294
|
+
this.name = "KeyNotFound";
|
|
182
295
|
}
|
|
296
|
+
};
|
|
297
|
+
const PROJECT_KEY = /^[a-z0-9]+(?:-[a-z0-9]+)*\/[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
298
|
+
/**
|
|
299
|
+
* The keystore key of a workspace: its deploy identity without the stage, so
|
|
300
|
+
* a project's keys and its Dokploy project are named by the same thing.
|
|
301
|
+
*/
|
|
302
|
+
function projectKey(workspace) {
|
|
303
|
+
return deployIdentity(workspace, "").key;
|
|
304
|
+
}
|
|
305
|
+
/** The keystore project of a loaded workspace. */
|
|
306
|
+
function keystoreProject(workspace, home) {
|
|
307
|
+
const key = projectKey(workspace);
|
|
308
|
+
const unscoped = projectKey({ name: workspace.name });
|
|
309
|
+
return {
|
|
310
|
+
key,
|
|
311
|
+
legacy: [.../* @__PURE__ */ new Set([folderName(workspace.root), workspace.name])].filter((name) => !!name),
|
|
312
|
+
...unscoped !== key ? { aliases: [unscoped] } : {},
|
|
313
|
+
...home ? { home } : {}
|
|
314
|
+
};
|
|
183
315
|
}
|
|
184
316
|
/**
|
|
185
|
-
*
|
|
317
|
+
* Whether `name` stays inside the home it is joined to. A workspace name was
|
|
318
|
+
* joined as is — `@acme/shop` is `~/.gkm/@acme/shop` — but never upwards.
|
|
186
319
|
*/
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
320
|
+
function isLegacyName(name) {
|
|
321
|
+
return name.split(/[\\/]/).every((part) => part !== ".." && part !== "");
|
|
322
|
+
}
|
|
323
|
+
/** The last segment of a path, as the old keystore named projects. */
|
|
324
|
+
function folderName(path) {
|
|
325
|
+
const name = path.split(/[\\/]/).filter(Boolean).at(-1);
|
|
326
|
+
return name && name !== "." && name !== ".." ? name : void 0;
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* Get the keystore directory for a project:
|
|
330
|
+
* `<home>/keys/<namespace>/<project>`.
|
|
331
|
+
*/
|
|
332
|
+
function getKeystoreDir(project) {
|
|
333
|
+
if (!PROJECT_KEY.test(project.key)) throw new KeystoreProjectInvalid(project.key);
|
|
334
|
+
const [namespace, name] = project.key.split("/");
|
|
335
|
+
return join(project.home ?? gkmHome(), "keys", namespace, name);
|
|
336
|
+
}
|
|
337
|
+
/** Get the path to a stage's encryption key. */
|
|
338
|
+
function getKeyPath(stage, project) {
|
|
339
|
+
return join(getKeystoreDir(project), `${stage}.key`);
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Where else a stage's key may be, most likely first: under an alias of the
|
|
343
|
+
* project, then where keys were kept before they were keyed by identity —
|
|
344
|
+
* under the configured home, then under `~/.gkm`, which is where every CLI
|
|
345
|
+
* before `GKM_HOME` wrote.
|
|
346
|
+
*/
|
|
347
|
+
function legacyKeyPaths(stage, project) {
|
|
348
|
+
const aliases = (project.aliases ?? []).map((key) => getKeyPath(stage, {
|
|
349
|
+
key,
|
|
350
|
+
home: project.home
|
|
351
|
+
}));
|
|
352
|
+
const homes = [.../* @__PURE__ */ new Set([project.home ?? gkmHome(), join(homedir(), ".gkm")])];
|
|
353
|
+
const names = (project.legacy ?? []).filter(isLegacyName);
|
|
354
|
+
return [...aliases, ...names.flatMap((name) => homes.map((home) => join(home, name, `${stage}.key`)))];
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Generate a new encryption key for a stage, stored owner-only at
|
|
358
|
+
* `<home>/keys/<namespace>/<project>/<stage>.key`.
|
|
359
|
+
*/
|
|
360
|
+
async function generateKey(stage, project, flag = "w") {
|
|
361
|
+
const keyDir = getKeystoreDir(project);
|
|
362
|
+
const keyPath = getKeyPath(stage, project);
|
|
363
|
+
await mkdir(keyDir, {
|
|
364
|
+
recursive: true,
|
|
365
|
+
mode: 448
|
|
366
|
+
});
|
|
367
|
+
const key = randomBytes(KEY_LENGTH).toString("hex");
|
|
368
|
+
await writeFile(keyPath, key, {
|
|
369
|
+
mode: 384,
|
|
370
|
+
encoding: "utf-8",
|
|
371
|
+
flag
|
|
372
|
+
});
|
|
373
|
+
await chmod(keyPath, 384);
|
|
374
|
+
return key;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Copy a key from where it used to be kept to where it is kept now.
|
|
378
|
+
*
|
|
379
|
+
* Copied, not moved: an older CLI on the same machine still reads the old
|
|
380
|
+
* place. Exclusive, so two processes migrating at once cannot overwrite a key
|
|
381
|
+
* one of them already put there.
|
|
382
|
+
*/
|
|
383
|
+
async function migrateLegacyKey(stage, project) {
|
|
384
|
+
const legacy = legacyKeyPaths(stage, project).find((path) => existsSync(path));
|
|
385
|
+
if (!legacy) return false;
|
|
386
|
+
const keyPath = getKeyPath(stage, project);
|
|
387
|
+
await mkdir(getKeystoreDir(project), {
|
|
388
|
+
recursive: true,
|
|
389
|
+
mode: 448
|
|
390
|
+
});
|
|
391
|
+
try {
|
|
392
|
+
await copyFile(legacy, keyPath, constants.COPYFILE_EXCL);
|
|
393
|
+
} catch (error) {
|
|
394
|
+
if (error.code !== "EEXIST") throw error;
|
|
193
395
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
396
|
+
await chmod(keyPath, 384);
|
|
397
|
+
return true;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Read an encryption key for a stage, or `null` when there is none — after
|
|
401
|
+
* copying it over from where keys used to be kept, if it is still there.
|
|
402
|
+
*/
|
|
403
|
+
async function readKey(stage, project) {
|
|
404
|
+
const keyPath = getKeyPath(stage, project);
|
|
405
|
+
if (!existsSync(keyPath) && !await migrateLegacyKey(stage, project)) return null;
|
|
406
|
+
return (await readFile(keyPath, "utf-8")).trim();
|
|
407
|
+
}
|
|
408
|
+
/**
|
|
409
|
+
* Read an encryption key for a stage, throwing {@link KeyNotFound} if there
|
|
410
|
+
* is none.
|
|
411
|
+
*/
|
|
412
|
+
async function requireKey(stage, project) {
|
|
413
|
+
const key = await readKey(stage, project);
|
|
414
|
+
if (!key) throw new KeyNotFound(stage, project.key, getKeyPath(stage, project));
|
|
415
|
+
return key;
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* Get or create a key for a stage.
|
|
419
|
+
* If the key already exists, it is returned. Otherwise, a new key is generated.
|
|
420
|
+
*/
|
|
421
|
+
async function getOrCreateKey(stage, project) {
|
|
422
|
+
const existingKey = await readKey(stage, project);
|
|
423
|
+
if (existingKey) return existingKey;
|
|
424
|
+
try {
|
|
425
|
+
return await generateKey(stage, project, "wx");
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (error.code !== "EEXIST") throw error;
|
|
428
|
+
return requireKey(stage, project);
|
|
197
429
|
}
|
|
198
430
|
}
|
|
431
|
+
//#endregion
|
|
432
|
+
//#region src/secrets/file.ts
|
|
433
|
+
/** Where the files live, relative to the project root. */
|
|
434
|
+
const SECRETS_DIR = ".gkm/secrets";
|
|
435
|
+
/** AES-256-GCM configuration */
|
|
436
|
+
const ALGORITHM = "aes-256-gcm";
|
|
437
|
+
const IV_LENGTH = 12;
|
|
199
438
|
/**
|
|
200
|
-
*
|
|
439
|
+
* A stage's secrets as an encrypted file on this machine:
|
|
440
|
+
* `.gkm/secrets/<stage>.json`, with its key in the CLI's home at
|
|
441
|
+
* `keys/<namespace>/<project>/<stage>.key`.
|
|
442
|
+
*
|
|
443
|
+
* The local stage's store, always — its secrets belong to the machine running
|
|
444
|
+
* `gkm dev` — and a deployed stage's when `secrets.store` is `'file'`.
|
|
201
445
|
*/
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
446
|
+
var FileSecretsStore = class {
|
|
447
|
+
root;
|
|
448
|
+
name = "file";
|
|
449
|
+
project;
|
|
450
|
+
/**
|
|
451
|
+
* @param project - Whose keys: a workspace's `keystoreProject()`. Outside a
|
|
452
|
+
* workspace there is no config to name the project, so it is named the
|
|
453
|
+
* way a workspace without `name` is — its package name, else its folder.
|
|
454
|
+
*/
|
|
455
|
+
constructor(root, project) {
|
|
456
|
+
this.root = root;
|
|
457
|
+
this.project = project ?? {
|
|
458
|
+
key: projectKey({ name: packageName(root) ?? basename(root) }),
|
|
459
|
+
legacy: [basename(root)]
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
/** The file a stage's secrets are kept in. */
|
|
463
|
+
path(stage) {
|
|
464
|
+
return join(this.root, SECRETS_DIR, `${stage}.json`);
|
|
465
|
+
}
|
|
466
|
+
async read(stage) {
|
|
467
|
+
const path = this.path(stage);
|
|
468
|
+
if (!existsSync(path)) return null;
|
|
469
|
+
const data = JSON.parse(await readFile(path, "utf-8"));
|
|
470
|
+
if (!(data.version === 1 && data.encrypted && data.iv)) return data;
|
|
471
|
+
const key = await readKey(stage, this.project);
|
|
472
|
+
if (!key) throw new MissingSecretsKey(stage, this.project.key, getKeyPath(stage, this.project));
|
|
473
|
+
return decrypt(data, key);
|
|
474
|
+
}
|
|
475
|
+
async write(stage, secrets) {
|
|
476
|
+
await mkdir(join(this.root, SECRETS_DIR), { recursive: true });
|
|
477
|
+
const key = await getOrCreateKey(stage, this.project);
|
|
478
|
+
await writeFile(this.path(stage), JSON.stringify(encrypt(secrets, key), null, 2), "utf-8");
|
|
479
|
+
}
|
|
480
|
+
};
|
|
481
|
+
/** A stage's secrets file exists, and the key that opens it does not. */
|
|
482
|
+
var MissingSecretsKey = class extends Error {
|
|
483
|
+
stage;
|
|
484
|
+
project;
|
|
485
|
+
path;
|
|
486
|
+
constructor(stage, project, path) {
|
|
487
|
+
super(`Decryption key not found for stage "${stage}". Expected key at: ${path}. Copy the stage's key there from whoever created it, or set secrets.store to a store this machine can reach.`);
|
|
488
|
+
this.stage = stage;
|
|
489
|
+
this.project = project;
|
|
490
|
+
this.path = path;
|
|
491
|
+
this.name = "MissingSecretsKey";
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
/** A project's package name without its scope, as a workspace name reads it. */
|
|
495
|
+
function packageName(root) {
|
|
496
|
+
try {
|
|
497
|
+
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf-8"));
|
|
498
|
+
return typeof pkg.name === "string" ? pkg.name.replace(/^@[^/]+\//, "") : void 0;
|
|
499
|
+
} catch {
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
function encrypt(secrets, keyHex) {
|
|
504
|
+
const key = Buffer.from(keyHex, "hex");
|
|
505
|
+
const iv = randomBytes(IV_LENGTH);
|
|
506
|
+
const cipher = createCipheriv(ALGORITHM, key, iv);
|
|
507
|
+
const ciphertext = Buffer.concat([cipher.update(JSON.stringify(secrets), "utf-8"), cipher.final()]);
|
|
508
|
+
return {
|
|
509
|
+
version: 1,
|
|
510
|
+
encrypted: Buffer.concat([ciphertext, cipher.getAuthTag()]).toString("base64"),
|
|
511
|
+
iv: iv.toString("hex")
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
function decrypt(data, keyHex) {
|
|
515
|
+
const key = Buffer.from(keyHex, "hex");
|
|
516
|
+
const combined = Buffer.from(data.encrypted, "base64");
|
|
517
|
+
const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(data.iv, "hex"));
|
|
518
|
+
decipher.setAuthTag(combined.subarray(-16));
|
|
519
|
+
const plaintext = Buffer.concat([decipher.update(combined.subarray(0, -16)), decipher.final()]);
|
|
520
|
+
return JSON.parse(plaintext.toString("utf-8"));
|
|
211
521
|
}
|
|
522
|
+
//#endregion
|
|
523
|
+
//#region src/secrets/store.ts
|
|
524
|
+
var store_exports = /* @__PURE__ */ __exportAll({
|
|
525
|
+
FileSecretsStore: () => FileSecretsStore,
|
|
526
|
+
isRemoteStore: () => isRemoteStore,
|
|
527
|
+
secretsStoreFor: () => secretsStoreFor
|
|
528
|
+
});
|
|
212
529
|
/**
|
|
213
|
-
*
|
|
530
|
+
* The store a stage's secrets are kept in — what every command reads and
|
|
531
|
+
* writes, for the stage it acts on.
|
|
532
|
+
*
|
|
533
|
+
* The local stage is always the file: its secrets belong to the machine
|
|
534
|
+
* running `gkm dev`. A deployed stage uses `secrets.store`.
|
|
214
535
|
*/
|
|
215
|
-
function
|
|
216
|
-
|
|
217
|
-
return
|
|
536
|
+
async function secretsStoreFor(workspace, stage, options = {}) {
|
|
537
|
+
const configured = workspace.secrets.store ?? "file";
|
|
538
|
+
if (stage === workspace.stages.local || configured === "file") return new FileSecretsStore(workspace.root, keystoreProject(workspace, options.home));
|
|
539
|
+
if (configured.provider !== "ssm") return configured.provider;
|
|
540
|
+
const { AwsSecretsStore } = await import("./aws-DojzQnLd.mjs");
|
|
541
|
+
return new AwsSecretsStore({
|
|
542
|
+
project: workspace.name,
|
|
543
|
+
region: configured.region,
|
|
544
|
+
...options.profile ? { profile: options.profile } : {}
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
/** Whether a stage's secrets live somewhere other than this machine. */
|
|
548
|
+
function isRemoteStore(workspace, stage) {
|
|
549
|
+
const configured = workspace.secrets.store ?? "file";
|
|
550
|
+
return stage !== workspace.stages.local && configured !== "file";
|
|
218
551
|
}
|
|
219
552
|
//#endregion
|
|
220
553
|
//#region src/credentials/index.ts
|
|
@@ -453,17 +786,6 @@ async function prepareEntryCredentials(options) {
|
|
|
453
786
|
};
|
|
454
787
|
}
|
|
455
788
|
//#endregion
|
|
456
|
-
//#region src/workspace/client-generator.ts
|
|
457
|
-
/**
|
|
458
|
-
* Normalize routes to an array of patterns.
|
|
459
|
-
* Handles a glob or a list of them.
|
|
460
|
-
* @internal Exported for use in dev command
|
|
461
|
-
*/
|
|
462
|
-
function normalizeRoutes(routes) {
|
|
463
|
-
if (!routes) return [];
|
|
464
|
-
return Array.isArray(routes) ? routes : [routes];
|
|
465
|
-
}
|
|
466
|
-
//#endregion
|
|
467
789
|
//#region src/reconcile/containers.ts
|
|
468
790
|
/**
|
|
469
791
|
* What gkm knows about each container it can derive.
|
|
@@ -3358,822 +3680,693 @@ function localAddress(port) {
|
|
|
3358
3680
|
return `http://localhost:${port}`;
|
|
3359
3681
|
}
|
|
3360
3682
|
//#endregion
|
|
3361
|
-
//#region src/
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
});
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3683
|
+
//#region src/target/dokploy/domain.ts
|
|
3684
|
+
/** A stage was deployed that `deploy.domains` gives no domain. */
|
|
3685
|
+
var NoDomainForStage = class extends Error {
|
|
3686
|
+
stage;
|
|
3687
|
+
constructor(stage) {
|
|
3688
|
+
super(`No domain for stage "${stage}". Add it to gkm.config.ts: deploy: { domains: { ${stage}: 'example.com' } }.`);
|
|
3689
|
+
this.stage = stage;
|
|
3690
|
+
this.name = "NoDomainForStage";
|
|
3691
|
+
}
|
|
3692
|
+
};
|
|
3371
3693
|
/**
|
|
3372
|
-
*
|
|
3373
|
-
*
|
|
3694
|
+
* The hostname an app answers on for a stage.
|
|
3695
|
+
*
|
|
3696
|
+
* 1. An explicit `app.domain` — a string, or one per stage — wins.
|
|
3697
|
+
* 2. Otherwise from the stage's base domain in `deploy.domains`: the root site
|
|
3698
|
+
* answers on the domain itself, and every other app on
|
|
3699
|
+
* `{subdomain}.{domain}` — its declaration's `subdomain`, or its own name.
|
|
3374
3700
|
*
|
|
3375
|
-
* @
|
|
3376
|
-
* @returns Encrypted payload with ephemeral master key
|
|
3701
|
+
* @throws {NoDomainForStage} when `deploy.domains` names no domain for it
|
|
3377
3702
|
*/
|
|
3378
|
-
function
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
const
|
|
3384
|
-
|
|
3385
|
-
return
|
|
3386
|
-
|
|
3387
|
-
iv: iv.toString("hex"),
|
|
3388
|
-
masterKey: masterKey.toString("hex")
|
|
3389
|
-
};
|
|
3703
|
+
function resolveHost(appName, app, stage, domains, isMainFrontend) {
|
|
3704
|
+
if (app.domain) {
|
|
3705
|
+
if (typeof app.domain === "string") return app.domain;
|
|
3706
|
+
if (app.domain[stage]) return app.domain[stage];
|
|
3707
|
+
}
|
|
3708
|
+
const baseDomain = domains?.[stage];
|
|
3709
|
+
if (!baseDomain) throw new NoDomainForStage(stage);
|
|
3710
|
+
if (isMainFrontend) return baseDomain;
|
|
3711
|
+
return `${app.subdomain ?? appName}.${baseDomain}`;
|
|
3390
3712
|
}
|
|
3391
3713
|
/**
|
|
3392
|
-
*
|
|
3393
|
-
*
|
|
3714
|
+
* Whether this app is the site the base domain points at — see `rootSite`,
|
|
3715
|
+
* which the local edge asks too.
|
|
3716
|
+
*
|
|
3717
|
+
* @throws {AmbiguousRootSite} when several sites could be the root and none says it is
|
|
3394
3718
|
*/
|
|
3395
|
-
function
|
|
3396
|
-
return
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3719
|
+
function isMainFrontendApp(appName, app, allApps) {
|
|
3720
|
+
if (app.type !== "web") return false;
|
|
3721
|
+
return rootSite(Object.entries(allApps).filter(([, a]) => a.type === "web").map(([name, a]) => ({
|
|
3722
|
+
name,
|
|
3723
|
+
root: a.root
|
|
3724
|
+
}))) === appName;
|
|
3400
3725
|
}
|
|
3401
3726
|
/**
|
|
3402
|
-
*
|
|
3403
|
-
* SHA-256.
|
|
3727
|
+
* Get public URL arg names from app dependencies.
|
|
3404
3728
|
*
|
|
3405
|
-
*
|
|
3406
|
-
*
|
|
3407
|
-
*
|
|
3729
|
+
* @param app - The web/mobile app configuration
|
|
3730
|
+
* @returns Array of arg names like 'NEXT_PUBLIC_API_URL', or [] for frameworks
|
|
3731
|
+
* without a public prefix (e.g. Remix).
|
|
3408
3732
|
*/
|
|
3409
|
-
function
|
|
3410
|
-
|
|
3733
|
+
function getPublicUrlArgNames(app) {
|
|
3734
|
+
const prefix = getPublicEnvPrefix(app.framework);
|
|
3735
|
+
if (!prefix) return [];
|
|
3736
|
+
return app.dependencies.map((dep) => `${prefix}${dep.toUpperCase()}_URL`);
|
|
3411
3737
|
}
|
|
3412
3738
|
//#endregion
|
|
3413
|
-
//#region src/
|
|
3739
|
+
//#region src/docker/imageRef.ts
|
|
3414
3740
|
/**
|
|
3415
|
-
*
|
|
3416
|
-
* provision and nobody else issues.
|
|
3417
|
-
*
|
|
3418
|
-
* - **A seed**, which salts every password a deploy derives — a database's
|
|
3419
|
-
* master and roles, a bucket's root user. Derived from repo facts alone
|
|
3420
|
-
* (project, stage, construct id), those passwords were computable by anyone
|
|
3421
|
-
* who could read the repo.
|
|
3422
|
-
* - **Each `secret` construct's value** — an auth server's signing secret —
|
|
3423
|
-
* random, rather than derived.
|
|
3424
|
-
* - **Each `encryption` construct's keyring** — one random key and an index
|
|
3425
|
-
* key. Later keys are added by `gkm encryption:rotate`, never by a deploy.
|
|
3741
|
+
* An image reference, checked before it reaches `docker`.
|
|
3426
3742
|
*
|
|
3427
|
-
*
|
|
3428
|
-
*
|
|
3429
|
-
*
|
|
3430
|
-
*
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
* The stage's secrets with its seed and every declared `secret`'s value
|
|
3434
|
-
* present, generating what is missing. Pure: the caller writes the result
|
|
3435
|
-
* back to the stage's store when `generated` is not empty.
|
|
3743
|
+
* The parts come from the workspace — a registry in `gkm.config.ts`, an image
|
|
3744
|
+
* name from the project, a tag from `--tag` — and an argument array already
|
|
3745
|
+
* keeps them out of a shell. What it cannot do is stop `docker` reading a value
|
|
3746
|
+
* that starts with `-` as one of its own flags, so a ref is held to Docker's
|
|
3747
|
+
* own grammar (distribution/reference) and anything else is refused by name
|
|
3748
|
+
* before a build starts.
|
|
3436
3749
|
*/
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3750
|
+
const DOMAIN_COMPONENT = "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])";
|
|
3751
|
+
const DOMAIN = `${DOMAIN_COMPONENT}(?:\\.${DOMAIN_COMPONENT})*(?::[0-9]+)?`;
|
|
3752
|
+
const PATH_COMPONENT = "[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*";
|
|
3753
|
+
const REFERENCE = new RegExp(`^(?:${DOMAIN}/)?${PATH_COMPONENT}(?:/${PATH_COMPONENT})*(?::[\\w][\\w.-]{0,127})?(?:@[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9a-fA-F]{32,})?$`);
|
|
3754
|
+
/** Docker refuses a repository name longer than this. */
|
|
3755
|
+
const MAX_NAME_LENGTH = 255;
|
|
3756
|
+
/** A value that is not an image reference was about to be built or pushed. */
|
|
3757
|
+
var ImageRefInvalid = class extends Error {
|
|
3758
|
+
ref;
|
|
3759
|
+
constructor(ref) {
|
|
3760
|
+
super(`'${ref}' is not a valid image reference. An image is \`[registry/]name[:tag]\`: the name lowercase letters, digits and . _ - separators, the tag up to 128 of letters, digits, _ . and -, not starting with . or -. Check docker.registry in gkm.config.ts and the --tag passed.`);
|
|
3761
|
+
this.ref = ref;
|
|
3762
|
+
this.name = "ImageRefInvalid";
|
|
3446
3763
|
}
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
custom,
|
|
3454
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3455
|
-
} : secrets,
|
|
3456
|
-
generated
|
|
3457
|
-
};
|
|
3458
|
-
}
|
|
3459
|
-
/** 256 bits, URL-safe. */
|
|
3460
|
-
function random() {
|
|
3461
|
-
return randomBytes(32).toString("base64url");
|
|
3462
|
-
}
|
|
3463
|
-
//#endregion
|
|
3464
|
-
//#region src/deploy/StateProvider.ts
|
|
3465
|
-
/**
|
|
3466
|
-
* Check if value is a StateProvider implementation.
|
|
3467
|
-
*/
|
|
3468
|
-
function isStateProvider(value) {
|
|
3469
|
-
return typeof value === "object" && value !== null && typeof value.read === "function" && typeof value.write === "function";
|
|
3764
|
+
};
|
|
3765
|
+
/** Throw {@link ImageRefInvalid} unless `ref` is a Docker image reference. */
|
|
3766
|
+
function validateImageRef(ref) {
|
|
3767
|
+
const name = ref.split("@")[0].replace(/:[\w][\w.-]*$/, "");
|
|
3768
|
+
if (!REFERENCE.test(ref) || name.length > MAX_NAME_LENGTH) throw new ImageRefInvalid(ref);
|
|
3769
|
+
return ref;
|
|
3470
3770
|
}
|
|
3471
3771
|
//#endregion
|
|
3472
|
-
//#region src/
|
|
3772
|
+
//#region src/docker/templates.ts
|
|
3773
|
+
const LOCKFILES = [
|
|
3774
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
3775
|
+
["bun.lockb", "bun"],
|
|
3776
|
+
["yarn.lock", "yarn"],
|
|
3777
|
+
["package-lock.json", "npm"]
|
|
3778
|
+
];
|
|
3473
3779
|
/**
|
|
3474
|
-
*
|
|
3475
|
-
*
|
|
3476
|
-
* `StateProvider` reads and writes a whole stage at once, with nothing to stop
|
|
3477
|
-
* two runs of the same stage from overwriting each other and nowhere to record
|
|
3478
|
-
* a resource the moment it exists. A `StateStore` adds the three things a
|
|
3479
|
-
* deploy needs to be safe against crashes and concurrent runs:
|
|
3480
|
-
*
|
|
3481
|
-
* - `lock(stage)`: one run per stage. A second gets `StateLocked`, naming the
|
|
3482
|
-
* holder, instead of racing the first.
|
|
3483
|
-
* - Versioned `read` and conditional `write`: a write names the version it was
|
|
3484
|
-
* based on, and raises `StateVersionConflict` when the stored state moved on
|
|
3485
|
-
* underneath it, so nothing is lost silently even without the lock.
|
|
3486
|
-
* - Per-resource records: `putResource` journals one resource at a time, so a
|
|
3487
|
-
* run that dies half way leaves behind the ids of everything it created.
|
|
3488
|
-
*
|
|
3489
|
-
* Built-in stores: `LocalStateStore`, `SSMStateStore`, `S3StateStore`. A
|
|
3490
|
-
* custom `StateProvider` is wrapped by `LegacyStateStore`, which keeps it
|
|
3491
|
-
* working but cannot lock.
|
|
3780
|
+
* Detect package manager from lockfiles
|
|
3781
|
+
* Walks up the directory tree to find lockfile (for monorepos)
|
|
3492
3782
|
*/
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
super(`Deploy state for stage '${stage}' is locked by ${by} (${location}). Wait for that run to finish; if it crashed, release the lock with \`gkm state:unlock --stage ${stage}\`.`);
|
|
3500
|
-
this.stage = stage;
|
|
3501
|
-
this.holder = holder;
|
|
3502
|
-
this.location = location;
|
|
3503
|
-
this.name = "StateLocked";
|
|
3504
|
-
}
|
|
3505
|
-
};
|
|
3506
|
-
var StateVersionConflict = class extends Error {
|
|
3507
|
-
stage;
|
|
3508
|
-
expectedVersion;
|
|
3509
|
-
actualVersion;
|
|
3510
|
-
constructor(stage, expectedVersion, actualVersion) {
|
|
3511
|
-
super(`Deploy state for stage '${stage}' changed since it was read (expected ${expectedVersion ? `version ${expectedVersion}` : "no state"}, found ${actualVersion ? `version ${actualVersion}` : "no state"}). Another run wrote it: read the state again and re-apply the change, and hold the stage's lock so runs take turns.`);
|
|
3512
|
-
this.stage = stage;
|
|
3513
|
-
this.expectedVersion = expectedVersion;
|
|
3514
|
-
this.actualVersion = actualVersion;
|
|
3515
|
-
this.name = "StateVersionConflict";
|
|
3516
|
-
}
|
|
3517
|
-
};
|
|
3518
|
-
var StageStateMissing = class extends Error {
|
|
3519
|
-
stage;
|
|
3520
|
-
constructor(stage) {
|
|
3521
|
-
super(`There is no deploy state for stage '${stage}' to record a resource in. Write the stage's state first (write with expectedVersion: null).`);
|
|
3522
|
-
this.stage = stage;
|
|
3523
|
-
this.name = "StageStateMissing";
|
|
3524
|
-
}
|
|
3525
|
-
};
|
|
3526
|
-
var StateUnreadable = class extends Error {
|
|
3527
|
-
stage;
|
|
3528
|
-
location;
|
|
3529
|
-
constructor(stage, location, cause) {
|
|
3530
|
-
super(`Deploy state for stage '${stage}' at ${location} is not valid JSON. Restore it from a backup (or the provider's history) rather than deploying without it — a deploy without state recreates every resource.`, { cause });
|
|
3531
|
-
this.stage = stage;
|
|
3532
|
-
this.location = location;
|
|
3533
|
-
this.name = "StateUnreadable";
|
|
3534
|
-
}
|
|
3535
|
-
};
|
|
3536
|
-
var StateSchemaTooNew = class extends Error {
|
|
3537
|
-
stage;
|
|
3538
|
-
schemaVersion;
|
|
3539
|
-
constructor(stage, schemaVersion) {
|
|
3540
|
-
super(`Deploy state for stage '${stage}' has schema version ${String(schemaVersion)}, newer than this @geekmidas/cli understands (2). Upgrade @geekmidas/cli to the version that wrote it.`);
|
|
3541
|
-
this.stage = stage;
|
|
3542
|
-
this.schemaVersion = schemaVersion;
|
|
3543
|
-
this.name = "StateSchemaTooNew";
|
|
3783
|
+
function detectPackageManager(cwd = process.cwd()) {
|
|
3784
|
+
let dir = cwd;
|
|
3785
|
+
const root = parse(dir).root;
|
|
3786
|
+
while (dir !== root) {
|
|
3787
|
+
for (const [lockfile, pm] of LOCKFILES) if (existsSync(join(dir, lockfile))) return pm;
|
|
3788
|
+
dir = dirname(dir);
|
|
3544
3789
|
}
|
|
3545
|
-
|
|
3790
|
+
for (const [lockfile, pm] of LOCKFILES) if (existsSync(join(root, lockfile))) return pm;
|
|
3791
|
+
return "pnpm";
|
|
3792
|
+
}
|
|
3546
3793
|
/**
|
|
3547
|
-
*
|
|
3548
|
-
*
|
|
3549
|
-
* says so.
|
|
3794
|
+
* Find the lockfile path by walking up the directory tree
|
|
3795
|
+
* Returns the full path to the lockfile, or null if not found
|
|
3550
3796
|
*/
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
}
|
|
3559
|
-
|
|
3560
|
-
provider;
|
|
3561
|
-
constructor(provider) {
|
|
3562
|
-
super(`The '${provider}' state provider keys state by workspace name. Set "name" in gkm.config.ts.`);
|
|
3563
|
-
this.provider = provider;
|
|
3564
|
-
this.name = "StateStoreNeedsWorkspaceName";
|
|
3565
|
-
}
|
|
3566
|
-
};
|
|
3567
|
-
var UnknownStateProvider = class extends Error {
|
|
3568
|
-
config;
|
|
3569
|
-
constructor(config) {
|
|
3570
|
-
super(`Unknown state provider ${JSON.stringify(config)}. Use 'local', 'ssm', 's3', or an object implementing StateStore or StateProvider.`);
|
|
3571
|
-
this.config = config;
|
|
3572
|
-
this.name = "UnknownStateProvider";
|
|
3573
|
-
}
|
|
3574
|
-
};
|
|
3575
|
-
/** A lock or write that could not get at the state backend in time. */
|
|
3576
|
-
var StateStoreBusy = class extends Error {
|
|
3577
|
-
stage;
|
|
3578
|
-
location;
|
|
3579
|
-
constructor(stage, location) {
|
|
3580
|
-
super(`Timed out waiting to write deploy state for stage '${stage}' (${location}). If no other gkm process is running, remove ${location}.`);
|
|
3581
|
-
this.stage = stage;
|
|
3582
|
-
this.location = location;
|
|
3583
|
-
this.name = "StateStoreBusy";
|
|
3797
|
+
function findLockfilePath(cwd = process.cwd()) {
|
|
3798
|
+
let dir = cwd;
|
|
3799
|
+
const root = parse(dir).root;
|
|
3800
|
+
while (dir !== root) {
|
|
3801
|
+
for (const [lockfile] of LOCKFILES) {
|
|
3802
|
+
const lockfilePath = join(dir, lockfile);
|
|
3803
|
+
if (existsSync(lockfilePath)) return lockfilePath;
|
|
3804
|
+
}
|
|
3805
|
+
dir = dirname(dir);
|
|
3584
3806
|
}
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
try {
|
|
3589
|
-
parsed = JSON.parse(body);
|
|
3590
|
-
} catch (error) {
|
|
3591
|
-
throw new StateUnreadable(stage, location, error);
|
|
3807
|
+
for (const [lockfile] of LOCKFILES) {
|
|
3808
|
+
const lockfilePath = join(root, lockfile);
|
|
3809
|
+
if (existsSync(lockfilePath)) return lockfilePath;
|
|
3592
3810
|
}
|
|
3593
|
-
|
|
3594
|
-
const schemaVersion = parsed.schemaVersion;
|
|
3595
|
-
if (schemaVersion === void 0) return {
|
|
3596
|
-
schemaVersion: 1,
|
|
3597
|
-
state: parsed
|
|
3598
|
-
};
|
|
3599
|
-
if (schemaVersion === 2) return {
|
|
3600
|
-
schemaVersion: 2,
|
|
3601
|
-
document: parsed
|
|
3602
|
-
};
|
|
3603
|
-
throw new StateSchemaTooNew(stage, schemaVersion);
|
|
3604
|
-
}
|
|
3605
|
-
function encodeDocument(document) {
|
|
3606
|
-
return JSON.stringify(document, null, 2);
|
|
3811
|
+
return null;
|
|
3607
3812
|
}
|
|
3608
3813
|
/**
|
|
3609
|
-
*
|
|
3610
|
-
* so the first journaled deploy after an upgrade adopts them rather than
|
|
3611
|
-
* treating them as unknown.
|
|
3814
|
+
* Check if we're in a monorepo (lockfile is in a parent directory)
|
|
3612
3815
|
*/
|
|
3613
|
-
function
|
|
3614
|
-
const
|
|
3615
|
-
|
|
3616
|
-
|
|
3617
|
-
if (!id) return;
|
|
3618
|
-
const key = name ? `${type}:${name}` : type;
|
|
3619
|
-
resources[key] = {
|
|
3620
|
-
key,
|
|
3621
|
-
type,
|
|
3622
|
-
id,
|
|
3623
|
-
status: "ready",
|
|
3624
|
-
updatedAt: now
|
|
3625
|
-
};
|
|
3626
|
-
};
|
|
3627
|
-
seed("project", null, state.projectId);
|
|
3628
|
-
seed("environment", null, state.environmentId);
|
|
3629
|
-
for (const [app, id] of Object.entries(state.applications ?? {})) seed("application", app, id);
|
|
3630
|
-
seed("postgres", null, state.services?.postgresId);
|
|
3631
|
-
seed("redis", null, state.services?.redisId);
|
|
3632
|
-
seed("backup-destination", null, state.backups?.destinationId);
|
|
3633
|
-
return {
|
|
3634
|
-
schemaVersion: 2,
|
|
3635
|
-
stage,
|
|
3636
|
-
serial: 1,
|
|
3637
|
-
state,
|
|
3638
|
-
resources,
|
|
3639
|
-
updatedAt: now
|
|
3640
|
-
};
|
|
3641
|
-
}
|
|
3642
|
-
function newLockHolder(options) {
|
|
3643
|
-
let owner = "unknown";
|
|
3644
|
-
try {
|
|
3645
|
-
owner = userInfo().username;
|
|
3646
|
-
} catch {}
|
|
3647
|
-
return {
|
|
3648
|
-
id: randomUUID(),
|
|
3649
|
-
owner,
|
|
3650
|
-
host: hostname(),
|
|
3651
|
-
pid: process.pid,
|
|
3652
|
-
acquiredAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
3653
|
-
operation: options?.operation
|
|
3654
|
-
};
|
|
3655
|
-
}
|
|
3656
|
-
function contentVersion(body) {
|
|
3657
|
-
return createHash("sha256").update(body).digest("hex").slice(0, 16);
|
|
3816
|
+
function isMonorepo(cwd = process.cwd()) {
|
|
3817
|
+
const lockfilePath = findLockfilePath(cwd);
|
|
3818
|
+
if (!lockfilePath) return false;
|
|
3819
|
+
return dirname(lockfilePath) !== cwd;
|
|
3658
3820
|
}
|
|
3659
3821
|
/**
|
|
3660
|
-
*
|
|
3661
|
-
* stage, and create a lock object only if it does not exist. Migration and
|
|
3662
|
-
* resource records are the same for every such backend, so they live here.
|
|
3822
|
+
* Check if turbo.json exists (walks up directory tree)
|
|
3663
3823
|
*/
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
stage,
|
|
3671
|
-
holder,
|
|
3672
|
-
release: async () => {
|
|
3673
|
-
if (released) return;
|
|
3674
|
-
released = true;
|
|
3675
|
-
await this.removeLock(stage, holder);
|
|
3676
|
-
}
|
|
3677
|
-
};
|
|
3678
|
-
}
|
|
3679
|
-
async forceUnlock(stage) {
|
|
3680
|
-
const holder = await this.readLock(stage);
|
|
3681
|
-
await this.removeLock(stage, null);
|
|
3682
|
-
return holder;
|
|
3683
|
-
}
|
|
3684
|
-
async read(stage) {
|
|
3685
|
-
const current = await this.readDocument(stage);
|
|
3686
|
-
if (!current) return null;
|
|
3687
|
-
return {
|
|
3688
|
-
state: current.document.state,
|
|
3689
|
-
resources: current.document.resources,
|
|
3690
|
-
version: current.version
|
|
3691
|
-
};
|
|
3692
|
-
}
|
|
3693
|
-
async write(stage, state, options) {
|
|
3694
|
-
if (options.expectedVersion === null) {
|
|
3695
|
-
const document = {
|
|
3696
|
-
schemaVersion: 2,
|
|
3697
|
-
stage,
|
|
3698
|
-
serial: 1,
|
|
3699
|
-
state,
|
|
3700
|
-
resources: {},
|
|
3701
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3702
|
-
};
|
|
3703
|
-
return this.writeRaw(stage, encodeDocument(document), null);
|
|
3704
|
-
}
|
|
3705
|
-
return this.mutate(stage, options.expectedVersion, (document) => ({
|
|
3706
|
-
...document,
|
|
3707
|
-
state
|
|
3708
|
-
}));
|
|
3709
|
-
}
|
|
3710
|
-
async putResource(stage, record, options) {
|
|
3711
|
-
return this.mutate(stage, options?.expectedVersion, (document) => ({
|
|
3712
|
-
...document,
|
|
3713
|
-
resources: {
|
|
3714
|
-
...document.resources,
|
|
3715
|
-
[record.key]: {
|
|
3716
|
-
...record,
|
|
3717
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3718
|
-
}
|
|
3719
|
-
}
|
|
3720
|
-
}));
|
|
3721
|
-
}
|
|
3722
|
-
async deleteResource(stage, key, options) {
|
|
3723
|
-
return this.mutate(stage, options?.expectedVersion, (document) => {
|
|
3724
|
-
const { [key]: _removed, ...resources } = document.resources;
|
|
3725
|
-
return {
|
|
3726
|
-
...document,
|
|
3727
|
-
resources
|
|
3728
|
-
};
|
|
3729
|
-
});
|
|
3730
|
-
}
|
|
3731
|
-
async mutate(stage, expectedVersion, change) {
|
|
3732
|
-
const current = await this.readDocument(stage);
|
|
3733
|
-
if (!current) {
|
|
3734
|
-
if (expectedVersion !== void 0) throw new StateVersionConflict(stage, expectedVersion, null);
|
|
3735
|
-
throw new StageStateMissing(stage);
|
|
3736
|
-
}
|
|
3737
|
-
if (expectedVersion !== void 0 && current.version !== expectedVersion) throw new StateVersionConflict(stage, expectedVersion, current.version);
|
|
3738
|
-
const next = change(current.document);
|
|
3739
|
-
next.serial = current.document.serial + 1;
|
|
3740
|
-
next.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3741
|
-
return this.writeRaw(stage, encodeDocument(next), current.version);
|
|
3742
|
-
}
|
|
3743
|
-
/**
|
|
3744
|
-
* Reads the stage's document, migrating v1 in place on the way. The
|
|
3745
|
-
* migration is itself a conditional write: if another run migrated (or
|
|
3746
|
-
* wrote) first, its result is read instead.
|
|
3747
|
-
*/
|
|
3748
|
-
async readDocument(stage, attempt = 0) {
|
|
3749
|
-
const raw = await this.readRaw(stage);
|
|
3750
|
-
if (!raw) return null;
|
|
3751
|
-
const decoded = decodeDocument(stage, raw.body, this.location(stage));
|
|
3752
|
-
if (decoded.schemaVersion === 2) return {
|
|
3753
|
-
document: decoded.document,
|
|
3754
|
-
version: raw.version
|
|
3755
|
-
};
|
|
3756
|
-
await this.writeV1Backup(stage, raw.body);
|
|
3757
|
-
const document = migrateV1(stage, decoded.state);
|
|
3758
|
-
try {
|
|
3759
|
-
return {
|
|
3760
|
-
document,
|
|
3761
|
-
version: await this.writeRaw(stage, encodeDocument(document), raw.version)
|
|
3762
|
-
};
|
|
3763
|
-
} catch (error) {
|
|
3764
|
-
if (error instanceof StateVersionConflict && attempt < 2) return this.readDocument(stage, attempt + 1);
|
|
3765
|
-
throw error;
|
|
3766
|
-
}
|
|
3824
|
+
function hasTurboConfig(cwd = process.cwd()) {
|
|
3825
|
+
let dir = cwd;
|
|
3826
|
+
const root = parse(dir).root;
|
|
3827
|
+
while (dir !== root) {
|
|
3828
|
+
if (existsSync(join(dir, "turbo.json"))) return true;
|
|
3829
|
+
dir = dirname(dir);
|
|
3767
3830
|
}
|
|
3768
|
-
|
|
3769
|
-
function isStateStore(value) {
|
|
3770
|
-
return typeof value === "object" && value !== null && typeof value.lock === "function" && typeof value.read === "function" && typeof value.write === "function" && typeof value.putResource === "function";
|
|
3831
|
+
return existsSync(join(root, "turbo.json"));
|
|
3771
3832
|
}
|
|
3772
3833
|
/**
|
|
3773
|
-
*
|
|
3774
|
-
*
|
|
3775
|
-
* - none / `'local'`: `LocalStateStore` under `.gkm/`
|
|
3776
|
-
* - `'ssm'`: `SSMStateStore`, with no local cache — SSM is read directly, so
|
|
3777
|
-
* a stale local copy can never win over it
|
|
3778
|
-
* - `'s3'`: `S3StateStore`
|
|
3779
|
-
* - a `StateStore` object: used as is
|
|
3780
|
-
* - a `StateProvider` object: wrapped by `LegacyStateStore`
|
|
3834
|
+
* Get install command for turbo builds (without frozen lockfile)
|
|
3835
|
+
* Turbo prune creates a subset that may not perfectly match the lockfile
|
|
3781
3836
|
*/
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
if (isStateProvider(config.provider)) {
|
|
3790
|
-
const { LegacyStateStore } = await import("./LegacyStateStore-DIgkGaUP.mjs");
|
|
3791
|
-
return new LegacyStateStore(config.provider, { warn: options.warn });
|
|
3792
|
-
}
|
|
3793
|
-
if (config.provider === "ssm") {
|
|
3794
|
-
if (!workspaceName) throw new StateStoreNeedsWorkspaceName("ssm");
|
|
3795
|
-
const { SSMStateStore } = await import("./SSMStateStore-DfF174xQ.mjs");
|
|
3796
|
-
const ssm = config;
|
|
3797
|
-
return SSMStateStore.create({
|
|
3798
|
-
workspaceName,
|
|
3799
|
-
region: ssm.region,
|
|
3800
|
-
profile: ssm.profile
|
|
3801
|
-
});
|
|
3802
|
-
}
|
|
3803
|
-
if (config.provider === "s3") {
|
|
3804
|
-
if (!workspaceName) throw new StateStoreNeedsWorkspaceName("s3");
|
|
3805
|
-
const { S3StateStore } = await import("./S3StateStore-D3x6ivBL.mjs");
|
|
3806
|
-
const s3 = config;
|
|
3807
|
-
return S3StateStore.create({
|
|
3808
|
-
workspaceName,
|
|
3809
|
-
bucket: s3.bucket,
|
|
3810
|
-
prefix: s3.prefix,
|
|
3811
|
-
region: s3.region,
|
|
3812
|
-
profile: s3.profile
|
|
3813
|
-
});
|
|
3814
|
-
}
|
|
3815
|
-
throw new UnknownStateProvider(config);
|
|
3837
|
+
function getTurboInstallCmd(pm) {
|
|
3838
|
+
return {
|
|
3839
|
+
pnpm: "pnpm install",
|
|
3840
|
+
npm: "npm install",
|
|
3841
|
+
yarn: "yarn install",
|
|
3842
|
+
bun: "bun install"
|
|
3843
|
+
}[pm];
|
|
3816
3844
|
}
|
|
3817
|
-
//#endregion
|
|
3818
|
-
//#region src/deploy/state.ts
|
|
3819
3845
|
/**
|
|
3820
|
-
*
|
|
3846
|
+
* Get package manager specific commands and paths
|
|
3821
3847
|
*/
|
|
3822
|
-
function
|
|
3848
|
+
function getPmConfig(pm) {
|
|
3823
3849
|
return {
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3850
|
+
pnpm: {
|
|
3851
|
+
install: "corepack enable && corepack prepare pnpm@latest --activate",
|
|
3852
|
+
lockfile: "pnpm-lock.yaml",
|
|
3853
|
+
fetch: "pnpm fetch",
|
|
3854
|
+
installCmd: "pnpm install --frozen-lockfile --offline",
|
|
3855
|
+
cacheTarget: "/root/.local/share/pnpm/store",
|
|
3856
|
+
cacheId: "pnpm",
|
|
3857
|
+
run: "pnpm",
|
|
3858
|
+
exec: "pnpm exec",
|
|
3859
|
+
dlx: "pnpm dlx",
|
|
3860
|
+
addGlobal: "pnpm add -g"
|
|
3861
|
+
},
|
|
3862
|
+
npm: {
|
|
3863
|
+
install: "",
|
|
3864
|
+
lockfile: "package-lock.json",
|
|
3865
|
+
fetch: "",
|
|
3866
|
+
installCmd: "npm ci",
|
|
3867
|
+
cacheTarget: "/root/.npm",
|
|
3868
|
+
cacheId: "npm",
|
|
3869
|
+
run: "npm run",
|
|
3870
|
+
exec: "npx",
|
|
3871
|
+
dlx: "npx",
|
|
3872
|
+
addGlobal: "npm install -g"
|
|
3873
|
+
},
|
|
3874
|
+
yarn: {
|
|
3875
|
+
install: "corepack enable && corepack prepare yarn@stable --activate",
|
|
3876
|
+
lockfile: "yarn.lock",
|
|
3877
|
+
fetch: "",
|
|
3878
|
+
installCmd: "yarn install --frozen-lockfile",
|
|
3879
|
+
cacheTarget: "/root/.yarn/cache",
|
|
3880
|
+
cacheId: "yarn",
|
|
3881
|
+
run: "yarn",
|
|
3882
|
+
exec: "yarn exec",
|
|
3883
|
+
dlx: "yarn dlx",
|
|
3884
|
+
addGlobal: "yarn global add"
|
|
3885
|
+
},
|
|
3886
|
+
bun: {
|
|
3887
|
+
install: "npm install -g bun",
|
|
3888
|
+
lockfile: "bun.lockb",
|
|
3889
|
+
fetch: "",
|
|
3890
|
+
installCmd: "bun install --frozen-lockfile",
|
|
3891
|
+
cacheTarget: "/root/.bun/install/cache",
|
|
3892
|
+
cacheId: "bun",
|
|
3893
|
+
run: "bun run",
|
|
3894
|
+
exec: "bunx",
|
|
3895
|
+
dlx: "bunx",
|
|
3896
|
+
addGlobal: "bun add -g"
|
|
3897
|
+
}
|
|
3898
|
+
}[pm];
|
|
3832
3899
|
}
|
|
3833
3900
|
/**
|
|
3834
|
-
*
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
/**
|
|
3840
|
-
* Record the image an app was deployed with (mutates state)
|
|
3841
|
-
*/
|
|
3842
|
-
function setDeployedImage(state, appName, image) {
|
|
3843
|
-
state.images = {
|
|
3844
|
-
...state.images,
|
|
3845
|
-
[appName]: image
|
|
3846
|
-
};
|
|
3847
|
-
}
|
|
3848
|
-
/**
|
|
3849
|
-
* Get a generated secret for an app
|
|
3850
|
-
*/
|
|
3851
|
-
function getGeneratedSecret(state, appName, secretName) {
|
|
3852
|
-
return state?.generatedSecrets?.[appName]?.[secretName];
|
|
3853
|
-
}
|
|
3854
|
-
/**
|
|
3855
|
-
* Set a generated secret for an app (mutates state)
|
|
3856
|
-
*/
|
|
3857
|
-
function setGeneratedSecret(state, appName, secretName, value) {
|
|
3858
|
-
if (!state.generatedSecrets) state.generatedSecrets = {};
|
|
3859
|
-
if (!state.generatedSecrets[appName]) state.generatedSecrets[appName] = {};
|
|
3860
|
-
state.generatedSecrets[appName][secretName] = value;
|
|
3861
|
-
}
|
|
3862
|
-
/**
|
|
3863
|
-
* Set DNS verification record for a hostname (mutates state)
|
|
3864
|
-
*/
|
|
3865
|
-
function setDnsVerification(state, hostname, serverIp) {
|
|
3866
|
-
if (!state.dnsVerified) state.dnsVerified = {};
|
|
3867
|
-
state.dnsVerified[hostname] = {
|
|
3868
|
-
serverIp,
|
|
3869
|
-
verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
3870
|
-
};
|
|
3871
|
-
}
|
|
3872
|
-
/**
|
|
3873
|
-
* Check if a hostname is already verified with the given IP
|
|
3874
|
-
*/
|
|
3875
|
-
function isDnsVerified(state, hostname, serverIp) {
|
|
3876
|
-
return (state?.dnsVerified?.[hostname])?.serverIp === serverIp;
|
|
3877
|
-
}
|
|
3878
|
-
/**
|
|
3879
|
-
* Get the key for a DNS record in state
|
|
3901
|
+
* Generate a multi-stage Dockerfile for building from source
|
|
3902
|
+
* Optimized for build speed with:
|
|
3903
|
+
* - BuildKit cache mounts for package manager store
|
|
3904
|
+
* - pnpm fetch for better layer caching (when using pnpm)
|
|
3905
|
+
* - Optional turbo prune for monorepos
|
|
3880
3906
|
*/
|
|
3881
|
-
function
|
|
3882
|
-
|
|
3907
|
+
function generateMultiStageDockerfile(options) {
|
|
3908
|
+
const { baseImage, port, healthCheckPath, turbo, turboPackage, packageManager } = options;
|
|
3909
|
+
if (turbo) return generateTurboDockerfile({
|
|
3910
|
+
...options,
|
|
3911
|
+
turboPackage: turboPackage ?? "api"
|
|
3912
|
+
});
|
|
3913
|
+
const pm = getPmConfig(packageManager);
|
|
3914
|
+
return `# syntax=docker/dockerfile:1
|
|
3915
|
+
# Stage 1: Dependencies
|
|
3916
|
+
FROM ${baseImage} AS deps
|
|
3917
|
+
|
|
3918
|
+
WORKDIR /app
|
|
3919
|
+
${pm.install ? `\n# Install ${packageManager}\nRUN ${pm.install}\n` : ""}
|
|
3920
|
+
${packageManager === "pnpm" ? `# Copy lockfile first for better caching
|
|
3921
|
+
COPY ${pm.lockfile} ./
|
|
3922
|
+
|
|
3923
|
+
# Fetch dependencies (downloads to virtual store, cached separately)
|
|
3924
|
+
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
3925
|
+
${pm.fetch}
|
|
3926
|
+
|
|
3927
|
+
# Copy package.json after fetch
|
|
3928
|
+
COPY package.json ./
|
|
3929
|
+
|
|
3930
|
+
# Install from cache (fast - no network needed)
|
|
3931
|
+
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
3932
|
+
${pm.installCmd}` : `# Copy package files
|
|
3933
|
+
COPY package.json ${pm.lockfile} ./
|
|
3934
|
+
|
|
3935
|
+
# Install dependencies with cache
|
|
3936
|
+
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
3937
|
+
${pm.installCmd}`}
|
|
3938
|
+
|
|
3939
|
+
# Stage 2: Build
|
|
3940
|
+
FROM deps AS builder
|
|
3941
|
+
|
|
3942
|
+
WORKDIR /app
|
|
3943
|
+
|
|
3944
|
+
# Copy source (deps already installed)
|
|
3945
|
+
COPY . .
|
|
3946
|
+
|
|
3947
|
+
# Build any workspace packages the app depends on.
|
|
3948
|
+
#
|
|
3949
|
+
# A no-op for a project whose dependencies come from a registry, and required
|
|
3950
|
+
# for one whose CLI is a sibling package: \`turbo prune\` honours .gitignore, so a
|
|
3951
|
+
# workspace package's \`dist\` never reaches the image and its bin has nothing to
|
|
3952
|
+
# import. \`--if-present\` is what makes the same Dockerfile correct for both.
|
|
3953
|
+
RUN ${pm.run} --if-present build
|
|
3954
|
+
|
|
3955
|
+
# Build production server using gkm
|
|
3956
|
+
RUN ${pm.exec} gkm build --provider server --production
|
|
3957
|
+
|
|
3958
|
+
# Stage 3: Production
|
|
3959
|
+
FROM ${baseImage} AS runner
|
|
3960
|
+
|
|
3961
|
+
WORKDIR /app
|
|
3962
|
+
|
|
3963
|
+
# Install tini for proper signal handling as PID 1
|
|
3964
|
+
RUN apk add --no-cache tini
|
|
3965
|
+
|
|
3966
|
+
# Create non-root user
|
|
3967
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
3968
|
+
adduser --system --uid 1001 hono
|
|
3969
|
+
|
|
3970
|
+
# Copy bundled server
|
|
3971
|
+
COPY --from=builder --chown=hono:nodejs /app/.gkm/server/dist/server.mjs ./
|
|
3972
|
+
|
|
3973
|
+
# Environment
|
|
3974
|
+
ENV NODE_ENV=production
|
|
3975
|
+
ENV PORT=${port}
|
|
3976
|
+
|
|
3977
|
+
# Health check
|
|
3978
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
3979
|
+
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
3980
|
+
|
|
3981
|
+
# Switch to non-root user
|
|
3982
|
+
USER hono
|
|
3983
|
+
|
|
3984
|
+
EXPOSE ${port}
|
|
3985
|
+
|
|
3986
|
+
# Use tini as entrypoint to handle PID 1 responsibilities
|
|
3987
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
3988
|
+
CMD ["node", "server.mjs"]
|
|
3989
|
+
`;
|
|
3883
3990
|
}
|
|
3884
3991
|
/**
|
|
3885
|
-
*
|
|
3992
|
+
* Generate a Dockerfile optimized for Turbo monorepos
|
|
3993
|
+
* Uses turbo prune to create minimal Docker context
|
|
3886
3994
|
*/
|
|
3887
|
-
function
|
|
3888
|
-
|
|
3889
|
-
const
|
|
3890
|
-
|
|
3891
|
-
|
|
3892
|
-
|
|
3893
|
-
|
|
3995
|
+
function generateTurboDockerfile(options) {
|
|
3996
|
+
const { baseImage, port, healthCheckPath, turboPackage, packageManager } = options;
|
|
3997
|
+
const pm = getPmConfig(packageManager);
|
|
3998
|
+
const installPm = pm.install ? `RUN ${pm.install}` : "";
|
|
3999
|
+
const turboInstallCmd = getTurboInstallCmd(packageManager);
|
|
4000
|
+
return `# syntax=docker/dockerfile:1
|
|
4001
|
+
# Stage 1: Prune monorepo
|
|
4002
|
+
FROM ${baseImage} AS pruner
|
|
4003
|
+
|
|
4004
|
+
WORKDIR /app
|
|
4005
|
+
|
|
4006
|
+
${installPm}
|
|
4007
|
+
|
|
4008
|
+
COPY . .
|
|
4009
|
+
|
|
4010
|
+
# Prune to only include necessary packages
|
|
4011
|
+
RUN ${packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo"} prune ${turboPackage} --docker
|
|
4012
|
+
|
|
4013
|
+
# Stage 2: Install dependencies
|
|
4014
|
+
FROM ${baseImage} AS deps
|
|
4015
|
+
|
|
4016
|
+
WORKDIR /app
|
|
4017
|
+
|
|
4018
|
+
${installPm}
|
|
4019
|
+
|
|
4020
|
+
# Copy pruned lockfile and package.jsons
|
|
4021
|
+
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
4022
|
+
COPY --from=pruner /app/out/json/ ./
|
|
4023
|
+
|
|
4024
|
+
# Install dependencies (no frozen-lockfile since turbo prune creates a subset)
|
|
4025
|
+
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
4026
|
+
${turboInstallCmd}
|
|
4027
|
+
|
|
4028
|
+
# Stage 3: Build
|
|
4029
|
+
FROM deps AS builder
|
|
4030
|
+
|
|
4031
|
+
WORKDIR /app
|
|
4032
|
+
|
|
4033
|
+
# Copy pruned source
|
|
4034
|
+
COPY --from=pruner /app/out/full/ ./
|
|
4035
|
+
|
|
4036
|
+
# Build any workspace packages the app depends on.
|
|
4037
|
+
#
|
|
4038
|
+
# A no-op for a project whose dependencies come from a registry, and required
|
|
4039
|
+
# for one whose CLI is a sibling package: \`turbo prune\` honours .gitignore, so a
|
|
4040
|
+
# workspace package's \`dist\` never reaches the image and its bin has nothing to
|
|
4041
|
+
# import. \`--if-present\` is what makes the same Dockerfile correct for both.
|
|
4042
|
+
RUN ${pm.run} --if-present build
|
|
4043
|
+
|
|
4044
|
+
# Build production server using gkm
|
|
4045
|
+
RUN ${pm.exec} gkm build --provider server --production
|
|
4046
|
+
|
|
4047
|
+
# Stage 4: Production
|
|
4048
|
+
FROM ${baseImage} AS runner
|
|
4049
|
+
|
|
4050
|
+
WORKDIR /app
|
|
4051
|
+
|
|
4052
|
+
RUN apk add --no-cache tini
|
|
4053
|
+
|
|
4054
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
4055
|
+
adduser --system --uid 1001 hono
|
|
4056
|
+
|
|
4057
|
+
COPY --from=builder --chown=hono:nodejs /app/.gkm/server/dist/server.mjs ./
|
|
4058
|
+
|
|
4059
|
+
ENV NODE_ENV=production
|
|
4060
|
+
ENV PORT=${port}
|
|
4061
|
+
|
|
4062
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
4063
|
+
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
4064
|
+
|
|
4065
|
+
USER hono
|
|
4066
|
+
|
|
4067
|
+
EXPOSE ${port}
|
|
4068
|
+
|
|
4069
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
4070
|
+
CMD ["node", "server.mjs"]
|
|
4071
|
+
`;
|
|
3894
4072
|
}
|
|
3895
4073
|
/**
|
|
3896
|
-
*
|
|
4074
|
+
* Generate a slim Dockerfile for pre-built bundles
|
|
3897
4075
|
*/
|
|
3898
|
-
function
|
|
3899
|
-
|
|
4076
|
+
function generateSlimDockerfile(options) {
|
|
4077
|
+
const { baseImage, port, healthCheckPath, bundle = ".gkm/server/dist/server.mjs" } = options;
|
|
4078
|
+
return `# Slim Dockerfile for pre-built production bundle
|
|
4079
|
+
FROM ${baseImage}
|
|
4080
|
+
|
|
4081
|
+
WORKDIR /app
|
|
4082
|
+
|
|
4083
|
+
# Install tini for proper signal handling as PID 1
|
|
4084
|
+
# Handles SIGTERM propagation and zombie process reaping
|
|
4085
|
+
RUN apk add --no-cache tini
|
|
4086
|
+
|
|
4087
|
+
# Create non-root user
|
|
4088
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
4089
|
+
adduser --system --uid 1001 hono
|
|
4090
|
+
|
|
4091
|
+
# Copy pre-built bundle
|
|
4092
|
+
COPY ${bundle} ./
|
|
4093
|
+
|
|
4094
|
+
# Environment
|
|
4095
|
+
ENV NODE_ENV=production
|
|
4096
|
+
ENV PORT=${port}
|
|
4097
|
+
|
|
4098
|
+
# Health check
|
|
4099
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
4100
|
+
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
4101
|
+
|
|
4102
|
+
# Switch to non-root user
|
|
4103
|
+
USER hono
|
|
4104
|
+
|
|
4105
|
+
EXPOSE ${port}
|
|
4106
|
+
|
|
4107
|
+
# Use tini as entrypoint to handle PID 1 responsibilities
|
|
4108
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
4109
|
+
CMD ["node", "server.mjs"]
|
|
4110
|
+
`;
|
|
3900
4111
|
}
|
|
3901
4112
|
/**
|
|
3902
|
-
*
|
|
4113
|
+
* Generate .dockerignore file
|
|
3903
4114
|
*/
|
|
3904
|
-
function
|
|
3905
|
-
|
|
3906
|
-
|
|
3907
|
-
|
|
3908
|
-
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
4115
|
+
function generateDockerignore() {
|
|
4116
|
+
return `# Dependencies
|
|
4117
|
+
node_modules
|
|
4118
|
+
.pnpm-store
|
|
4119
|
+
|
|
4120
|
+
# Build output (except what we need)
|
|
4121
|
+
.gkm/aws*
|
|
4122
|
+
.gkm/server/*.ts
|
|
4123
|
+
!.gkm/server/dist
|
|
4124
|
+
|
|
4125
|
+
# The key gkm build --stage encrypted secrets with: runtime env only, never
|
|
4126
|
+
# the build context
|
|
4127
|
+
.gkm/server/master.key
|
|
4128
|
+
|
|
4129
|
+
# gkm compose's stacks: each app's env file holds its stage's secrets
|
|
4130
|
+
.gkm/compose
|
|
4131
|
+
|
|
4132
|
+
# IDE and editor
|
|
4133
|
+
.idea
|
|
4134
|
+
.vscode
|
|
4135
|
+
*.swp
|
|
4136
|
+
*.swo
|
|
4137
|
+
|
|
4138
|
+
# Git
|
|
4139
|
+
.git
|
|
4140
|
+
.gitignore
|
|
4141
|
+
|
|
4142
|
+
# Logs
|
|
4143
|
+
*.log
|
|
4144
|
+
npm-debug.log*
|
|
4145
|
+
pnpm-debug.log*
|
|
4146
|
+
|
|
4147
|
+
# Test files
|
|
4148
|
+
**/*.test.ts
|
|
4149
|
+
**/*.spec.ts
|
|
4150
|
+
**/__tests__
|
|
4151
|
+
coverage
|
|
4152
|
+
|
|
4153
|
+
# Documentation
|
|
4154
|
+
docs
|
|
4155
|
+
*.md
|
|
4156
|
+
!README.md
|
|
4157
|
+
|
|
4158
|
+
# Environment files (handle secrets separately)
|
|
4159
|
+
.env
|
|
4160
|
+
.env.*
|
|
4161
|
+
!.env.example
|
|
4162
|
+
|
|
4163
|
+
# Docker files (don't copy recursively)
|
|
4164
|
+
Dockerfile*
|
|
4165
|
+
docker-compose*
|
|
4166
|
+
.dockerignore
|
|
4167
|
+
`;
|
|
3912
4168
|
}
|
|
3913
|
-
//#endregion
|
|
3914
|
-
//#region src/deploy/domain.ts
|
|
3915
|
-
/** A stage was deployed that `deploy.domains` gives no domain. */
|
|
3916
|
-
var NoDomainForStage = class extends Error {
|
|
3917
|
-
stage;
|
|
3918
|
-
constructor(stage) {
|
|
3919
|
-
super(`No domain for stage "${stage}". Add it to gkm.config.ts: deploy: { domains: { ${stage}: 'example.com' } }.`);
|
|
3920
|
-
this.stage = stage;
|
|
3921
|
-
this.name = "NoDomainForStage";
|
|
3922
|
-
}
|
|
3923
|
-
};
|
|
3924
4169
|
/**
|
|
3925
|
-
*
|
|
3926
|
-
*
|
|
3927
|
-
* 1. An explicit `app.domain` — a string, or one per stage — wins.
|
|
3928
|
-
* 2. Otherwise from the stage's base domain in `deploy.domains`: the root site
|
|
3929
|
-
* answers on the domain itself, and every other app on
|
|
3930
|
-
* `{subdomain}.{domain}` — its declaration's `subdomain`, or its own name.
|
|
3931
|
-
*
|
|
3932
|
-
* @throws {NoDomainForStage} when `deploy.domains` names no domain for it
|
|
4170
|
+
* Generate docker-entrypoint.sh for custom startup logic
|
|
3933
4171
|
*/
|
|
3934
|
-
function
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
3938
|
-
|
|
3939
|
-
|
|
3940
|
-
|
|
3941
|
-
|
|
3942
|
-
|
|
4172
|
+
function generateDockerEntrypoint() {
|
|
4173
|
+
return `#!/bin/sh
|
|
4174
|
+
set -e
|
|
4175
|
+
|
|
4176
|
+
# Run any custom startup scripts here
|
|
4177
|
+
# Example: wait for database
|
|
4178
|
+
# until nc -z $DB_HOST $DB_PORT; do
|
|
4179
|
+
# echo "Waiting for database..."
|
|
4180
|
+
# sleep 1
|
|
4181
|
+
# done
|
|
4182
|
+
|
|
4183
|
+
# Execute the main command
|
|
4184
|
+
exec "$@"
|
|
4185
|
+
`;
|
|
3943
4186
|
}
|
|
3944
4187
|
/**
|
|
3945
|
-
*
|
|
3946
|
-
* which the local edge asks too.
|
|
3947
|
-
*
|
|
3948
|
-
* @throws {AmbiguousRootSite} when several sites could be the root and none says it is
|
|
4188
|
+
* Resolve Docker configuration from GkmConfig with defaults
|
|
3949
4189
|
*/
|
|
3950
|
-
function
|
|
3951
|
-
|
|
3952
|
-
return
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
4190
|
+
function resolveDockerConfig(config) {
|
|
4191
|
+
const docker = config.docker ?? {};
|
|
4192
|
+
return {
|
|
4193
|
+
registry: docker.registry ?? "",
|
|
4194
|
+
imageName: docker.imageName ?? config.name ?? "api",
|
|
4195
|
+
baseImage: docker.baseImage ?? "node:22-alpine",
|
|
4196
|
+
port: docker.port ?? 3e3
|
|
4197
|
+
};
|
|
3956
4198
|
}
|
|
3957
4199
|
/**
|
|
3958
|
-
*
|
|
3959
|
-
*
|
|
3960
|
-
* @
|
|
3961
|
-
* @returns Array of arg names like 'NEXT_PUBLIC_API_URL', or [] for frameworks
|
|
3962
|
-
* without a public prefix (e.g. Remix).
|
|
4200
|
+
* Generate a Dockerfile for Next.js frontend apps using standalone output.
|
|
4201
|
+
* Uses turbo prune for monorepo optimization.
|
|
4202
|
+
* @internal Exported for testing
|
|
3963
4203
|
*/
|
|
3964
|
-
function
|
|
3965
|
-
const
|
|
3966
|
-
|
|
3967
|
-
|
|
4204
|
+
function generateNextjsDockerfile(options) {
|
|
4205
|
+
const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_AUTH_URL"] } = options;
|
|
4206
|
+
const pm = getPmConfig(packageManager);
|
|
4207
|
+
const installPm = pm.install ? `RUN ${pm.install}` : "";
|
|
4208
|
+
const turboInstallCmd = getTurboInstallCmd(packageManager);
|
|
4209
|
+
const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
|
|
4210
|
+
const publicUrlArgDeclarations = publicUrlArgs.map((arg) => `ARG ${arg}=""`).join("\n");
|
|
4211
|
+
const publicUrlEnvDeclarations = publicUrlArgs.map((arg) => `ENV ${arg}=$${arg}`).join("\n");
|
|
4212
|
+
return `# syntax=docker/dockerfile:1
|
|
4213
|
+
# Next.js standalone Dockerfile with turbo prune optimization
|
|
4214
|
+
|
|
4215
|
+
# Stage 1: Prune monorepo
|
|
4216
|
+
FROM ${baseImage} AS pruner
|
|
4217
|
+
|
|
4218
|
+
WORKDIR /app
|
|
4219
|
+
|
|
4220
|
+
${installPm}
|
|
4221
|
+
|
|
4222
|
+
COPY . .
|
|
4223
|
+
|
|
4224
|
+
# Prune to only include necessary packages
|
|
4225
|
+
RUN ${turboCmd} prune ${turboPackage} --docker
|
|
4226
|
+
|
|
4227
|
+
# Stage 2: Install dependencies
|
|
4228
|
+
FROM ${baseImage} AS deps
|
|
4229
|
+
|
|
4230
|
+
WORKDIR /app
|
|
4231
|
+
|
|
4232
|
+
${installPm}
|
|
4233
|
+
|
|
4234
|
+
# Copy pruned lockfile and package.jsons
|
|
4235
|
+
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
4236
|
+
COPY --from=pruner /app/out/json/ ./
|
|
4237
|
+
|
|
4238
|
+
# Install dependencies
|
|
4239
|
+
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
4240
|
+
${turboInstallCmd}
|
|
4241
|
+
|
|
4242
|
+
# Stage 3: Build
|
|
4243
|
+
FROM deps AS builder
|
|
4244
|
+
|
|
4245
|
+
WORKDIR /app
|
|
4246
|
+
|
|
4247
|
+
# Build-time args for public API URLs (populated by gkm deploy)
|
|
4248
|
+
# These get baked into the Next.js build as public environment variables
|
|
4249
|
+
${publicUrlArgDeclarations}
|
|
4250
|
+
|
|
4251
|
+
# Convert ARGs to ENVs for Next.js build
|
|
4252
|
+
${publicUrlEnvDeclarations}
|
|
4253
|
+
|
|
4254
|
+
# Copy pruned source
|
|
4255
|
+
COPY --from=pruner /app/out/full/ ./
|
|
4256
|
+
|
|
4257
|
+
# Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
|
|
4258
|
+
# Using wildcard to make it optional for single-app projects
|
|
4259
|
+
COPY --from=pruner /app/tsconfig.* ./
|
|
4260
|
+
|
|
4261
|
+
# Ensure public directory exists (may be empty for scaffolded projects)
|
|
4262
|
+
RUN mkdir -p ${appPath}/public
|
|
4263
|
+
|
|
4264
|
+
# Set Next.js to produce standalone output
|
|
4265
|
+
ENV NEXT_TELEMETRY_DISABLED=1
|
|
4266
|
+
|
|
4267
|
+
# Build the application
|
|
4268
|
+
RUN ${turboCmd} run build --filter=${turboPackage}
|
|
4269
|
+
|
|
4270
|
+
# Stage 4: Production
|
|
4271
|
+
FROM ${baseImage} AS runner
|
|
4272
|
+
|
|
4273
|
+
WORKDIR /app
|
|
4274
|
+
|
|
4275
|
+
# Install tini for proper signal handling
|
|
4276
|
+
RUN apk add --no-cache tini
|
|
4277
|
+
|
|
4278
|
+
# Create non-root user
|
|
4279
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
4280
|
+
adduser --system --uid 1001 nextjs
|
|
4281
|
+
|
|
4282
|
+
# Set environment
|
|
4283
|
+
ENV NODE_ENV=production
|
|
4284
|
+
ENV NEXT_TELEMETRY_DISABLED=1
|
|
4285
|
+
ENV PORT=${port}
|
|
4286
|
+
ENV HOSTNAME="0.0.0.0"
|
|
4287
|
+
|
|
4288
|
+
# Copy static files and standalone output
|
|
4289
|
+
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/standalone ./
|
|
4290
|
+
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/static ./${appPath}/.next/static
|
|
4291
|
+
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/public ./${appPath}/public
|
|
4292
|
+
|
|
4293
|
+
USER nextjs
|
|
4294
|
+
|
|
4295
|
+
EXPOSE ${port}
|
|
4296
|
+
|
|
4297
|
+
# A site answers at its root; Docker, and Dokploy, read the container's
|
|
4298
|
+
# health from it rather than from the process merely running. 127.0.0.1, not
|
|
4299
|
+
# localhost: the server listens on IPv4, and localhost can resolve to ::1.
|
|
4300
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
4301
|
+
CMD wget -qO- http://127.0.0.1:${port}/ > /dev/null 2>&1 || exit 1
|
|
4302
|
+
|
|
4303
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
4304
|
+
CMD ["node", "${appPath}/server.js"]
|
|
4305
|
+
`;
|
|
3968
4306
|
}
|
|
3969
|
-
//#endregion
|
|
3970
|
-
//#region src/docker/imageRef.ts
|
|
3971
4307
|
/**
|
|
3972
|
-
*
|
|
3973
|
-
*
|
|
3974
|
-
*
|
|
3975
|
-
* name from the project, a tag from `--tag` — and an argument array already
|
|
3976
|
-
* keeps them out of a shell. What it cannot do is stop `docker` reading a value
|
|
3977
|
-
* that starts with `-` as one of its own flags, so a ref is held to Docker's
|
|
3978
|
-
* own grammar (distribution/reference) and anything else is refused by name
|
|
3979
|
-
* before a build starts.
|
|
4308
|
+
* Generate a Dockerfile for backend apps in a workspace.
|
|
4309
|
+
* Uses turbo prune for monorepo optimization.
|
|
4310
|
+
* @internal Exported for testing
|
|
3980
4311
|
*/
|
|
3981
|
-
|
|
3982
|
-
const
|
|
3983
|
-
const PATH_COMPONENT = "[a-z0-9]+(?:(?:[._]|__|-+)[a-z0-9]+)*";
|
|
3984
|
-
const REFERENCE = new RegExp(`^(?:${DOMAIN}/)?${PATH_COMPONENT}(?:/${PATH_COMPONENT})*(?::[\\w][\\w.-]{0,127})?(?:@[A-Za-z][A-Za-z0-9]*(?:[-_+.][A-Za-z][A-Za-z0-9]*)*:[0-9a-fA-F]{32,})?$`);
|
|
3985
|
-
/** Docker refuses a repository name longer than this. */
|
|
3986
|
-
const MAX_NAME_LENGTH = 255;
|
|
3987
|
-
/** A value that is not an image reference was about to be built or pushed. */
|
|
3988
|
-
var ImageRefInvalid = class extends Error {
|
|
3989
|
-
ref;
|
|
3990
|
-
constructor(ref) {
|
|
3991
|
-
super(`'${ref}' is not a valid image reference. An image is \`[registry/]name[:tag]\`: the name lowercase letters, digits and . _ - separators, the tag up to 128 of letters, digits, _ . and -, not starting with . or -. Check docker.registry in gkm.config.ts and the --tag passed.`);
|
|
3992
|
-
this.ref = ref;
|
|
3993
|
-
this.name = "ImageRefInvalid";
|
|
3994
|
-
}
|
|
3995
|
-
};
|
|
3996
|
-
/** Throw {@link ImageRefInvalid} unless `ref` is a Docker image reference. */
|
|
3997
|
-
function validateImageRef(ref) {
|
|
3998
|
-
const name = ref.split("@")[0].replace(/:[\w][\w.-]*$/, "");
|
|
3999
|
-
if (!REFERENCE.test(ref) || name.length > MAX_NAME_LENGTH) throw new ImageRefInvalid(ref);
|
|
4000
|
-
return ref;
|
|
4001
|
-
}
|
|
4002
|
-
//#endregion
|
|
4003
|
-
//#region src/docker/templates.ts
|
|
4004
|
-
const LOCKFILES = [
|
|
4005
|
-
["pnpm-lock.yaml", "pnpm"],
|
|
4006
|
-
["bun.lockb", "bun"],
|
|
4007
|
-
["yarn.lock", "yarn"],
|
|
4008
|
-
["package-lock.json", "npm"]
|
|
4009
|
-
];
|
|
4010
|
-
/**
|
|
4011
|
-
* Detect package manager from lockfiles
|
|
4012
|
-
* Walks up the directory tree to find lockfile (for monorepos)
|
|
4013
|
-
*/
|
|
4014
|
-
function detectPackageManager(cwd = process.cwd()) {
|
|
4015
|
-
let dir = cwd;
|
|
4016
|
-
const root = parse(dir).root;
|
|
4017
|
-
while (dir !== root) {
|
|
4018
|
-
for (const [lockfile, pm] of LOCKFILES) if (existsSync(join(dir, lockfile))) return pm;
|
|
4019
|
-
dir = dirname(dir);
|
|
4020
|
-
}
|
|
4021
|
-
for (const [lockfile, pm] of LOCKFILES) if (existsSync(join(root, lockfile))) return pm;
|
|
4022
|
-
return "pnpm";
|
|
4023
|
-
}
|
|
4024
|
-
/**
|
|
4025
|
-
* Find the lockfile path by walking up the directory tree
|
|
4026
|
-
* Returns the full path to the lockfile, or null if not found
|
|
4027
|
-
*/
|
|
4028
|
-
function findLockfilePath(cwd = process.cwd()) {
|
|
4029
|
-
let dir = cwd;
|
|
4030
|
-
const root = parse(dir).root;
|
|
4031
|
-
while (dir !== root) {
|
|
4032
|
-
for (const [lockfile] of LOCKFILES) {
|
|
4033
|
-
const lockfilePath = join(dir, lockfile);
|
|
4034
|
-
if (existsSync(lockfilePath)) return lockfilePath;
|
|
4035
|
-
}
|
|
4036
|
-
dir = dirname(dir);
|
|
4037
|
-
}
|
|
4038
|
-
for (const [lockfile] of LOCKFILES) {
|
|
4039
|
-
const lockfilePath = join(root, lockfile);
|
|
4040
|
-
if (existsSync(lockfilePath)) return lockfilePath;
|
|
4041
|
-
}
|
|
4042
|
-
return null;
|
|
4043
|
-
}
|
|
4044
|
-
/**
|
|
4045
|
-
* Check if we're in a monorepo (lockfile is in a parent directory)
|
|
4046
|
-
*/
|
|
4047
|
-
function isMonorepo(cwd = process.cwd()) {
|
|
4048
|
-
const lockfilePath = findLockfilePath(cwd);
|
|
4049
|
-
if (!lockfilePath) return false;
|
|
4050
|
-
return dirname(lockfilePath) !== cwd;
|
|
4051
|
-
}
|
|
4052
|
-
/**
|
|
4053
|
-
* Check if turbo.json exists (walks up directory tree)
|
|
4054
|
-
*/
|
|
4055
|
-
function hasTurboConfig(cwd = process.cwd()) {
|
|
4056
|
-
let dir = cwd;
|
|
4057
|
-
const root = parse(dir).root;
|
|
4058
|
-
while (dir !== root) {
|
|
4059
|
-
if (existsSync(join(dir, "turbo.json"))) return true;
|
|
4060
|
-
dir = dirname(dir);
|
|
4061
|
-
}
|
|
4062
|
-
return existsSync(join(root, "turbo.json"));
|
|
4063
|
-
}
|
|
4064
|
-
/**
|
|
4065
|
-
* Get install command for turbo builds (without frozen lockfile)
|
|
4066
|
-
* Turbo prune creates a subset that may not perfectly match the lockfile
|
|
4067
|
-
*/
|
|
4068
|
-
function getTurboInstallCmd(pm) {
|
|
4069
|
-
return {
|
|
4070
|
-
pnpm: "pnpm install",
|
|
4071
|
-
npm: "npm install",
|
|
4072
|
-
yarn: "yarn install",
|
|
4073
|
-
bun: "bun install"
|
|
4074
|
-
}[pm];
|
|
4075
|
-
}
|
|
4076
|
-
/**
|
|
4077
|
-
* Get package manager specific commands and paths
|
|
4078
|
-
*/
|
|
4079
|
-
function getPmConfig(pm) {
|
|
4080
|
-
return {
|
|
4081
|
-
pnpm: {
|
|
4082
|
-
install: "corepack enable && corepack prepare pnpm@latest --activate",
|
|
4083
|
-
lockfile: "pnpm-lock.yaml",
|
|
4084
|
-
fetch: "pnpm fetch",
|
|
4085
|
-
installCmd: "pnpm install --frozen-lockfile --offline",
|
|
4086
|
-
cacheTarget: "/root/.local/share/pnpm/store",
|
|
4087
|
-
cacheId: "pnpm",
|
|
4088
|
-
run: "pnpm",
|
|
4089
|
-
exec: "pnpm exec",
|
|
4090
|
-
dlx: "pnpm dlx",
|
|
4091
|
-
addGlobal: "pnpm add -g"
|
|
4092
|
-
},
|
|
4093
|
-
npm: {
|
|
4094
|
-
install: "",
|
|
4095
|
-
lockfile: "package-lock.json",
|
|
4096
|
-
fetch: "",
|
|
4097
|
-
installCmd: "npm ci",
|
|
4098
|
-
cacheTarget: "/root/.npm",
|
|
4099
|
-
cacheId: "npm",
|
|
4100
|
-
run: "npm run",
|
|
4101
|
-
exec: "npx",
|
|
4102
|
-
dlx: "npx",
|
|
4103
|
-
addGlobal: "npm install -g"
|
|
4104
|
-
},
|
|
4105
|
-
yarn: {
|
|
4106
|
-
install: "corepack enable && corepack prepare yarn@stable --activate",
|
|
4107
|
-
lockfile: "yarn.lock",
|
|
4108
|
-
fetch: "",
|
|
4109
|
-
installCmd: "yarn install --frozen-lockfile",
|
|
4110
|
-
cacheTarget: "/root/.yarn/cache",
|
|
4111
|
-
cacheId: "yarn",
|
|
4112
|
-
run: "yarn",
|
|
4113
|
-
exec: "yarn exec",
|
|
4114
|
-
dlx: "yarn dlx",
|
|
4115
|
-
addGlobal: "yarn global add"
|
|
4116
|
-
},
|
|
4117
|
-
bun: {
|
|
4118
|
-
install: "npm install -g bun",
|
|
4119
|
-
lockfile: "bun.lockb",
|
|
4120
|
-
fetch: "",
|
|
4121
|
-
installCmd: "bun install --frozen-lockfile",
|
|
4122
|
-
cacheTarget: "/root/.bun/install/cache",
|
|
4123
|
-
cacheId: "bun",
|
|
4124
|
-
run: "bun run",
|
|
4125
|
-
exec: "bunx",
|
|
4126
|
-
dlx: "bunx",
|
|
4127
|
-
addGlobal: "bun add -g"
|
|
4128
|
-
}
|
|
4129
|
-
}[pm];
|
|
4130
|
-
}
|
|
4131
|
-
/**
|
|
4132
|
-
* Generate a multi-stage Dockerfile for building from source
|
|
4133
|
-
* Optimized for build speed with:
|
|
4134
|
-
* - BuildKit cache mounts for package manager store
|
|
4135
|
-
* - pnpm fetch for better layer caching (when using pnpm)
|
|
4136
|
-
* - Optional turbo prune for monorepos
|
|
4137
|
-
*/
|
|
4138
|
-
function generateMultiStageDockerfile(options) {
|
|
4139
|
-
const { baseImage, port, healthCheckPath, turbo, turboPackage, packageManager } = options;
|
|
4140
|
-
if (turbo) return generateTurboDockerfile({
|
|
4141
|
-
...options,
|
|
4142
|
-
turboPackage: turboPackage ?? "api"
|
|
4143
|
-
});
|
|
4312
|
+
function generateBackendDockerfile(options) {
|
|
4313
|
+
const { baseImage, port, appPath, turboPackage, packageManager, healthCheckPath = "/health" } = options;
|
|
4144
4314
|
const pm = getPmConfig(packageManager);
|
|
4315
|
+
const installPm = pm.install ? `RUN ${pm.install}` : "";
|
|
4316
|
+
const turboInstallCmd = getTurboInstallCmd(packageManager);
|
|
4145
4317
|
return `# syntax=docker/dockerfile:1
|
|
4146
|
-
#
|
|
4147
|
-
|
|
4318
|
+
# Backend Dockerfile with turbo prune optimization
|
|
4319
|
+
|
|
4320
|
+
# Stage 1: Prune monorepo
|
|
4321
|
+
FROM ${baseImage} AS pruner
|
|
4148
4322
|
|
|
4149
4323
|
WORKDIR /app
|
|
4150
|
-
${pm.install ? `\n# Install ${packageManager}\nRUN ${pm.install}\n` : ""}
|
|
4151
|
-
${packageManager === "pnpm" ? `# Copy lockfile first for better caching
|
|
4152
|
-
COPY ${pm.lockfile} ./
|
|
4153
4324
|
|
|
4154
|
-
|
|
4155
|
-
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
4156
|
-
${pm.fetch}
|
|
4325
|
+
${installPm}
|
|
4157
4326
|
|
|
4158
|
-
|
|
4159
|
-
COPY package.json ./
|
|
4327
|
+
COPY . .
|
|
4160
4328
|
|
|
4161
|
-
#
|
|
4162
|
-
RUN
|
|
4163
|
-
${pm.installCmd}` : `# Copy package files
|
|
4164
|
-
COPY package.json ${pm.lockfile} ./
|
|
4329
|
+
# Prune to only include necessary packages
|
|
4330
|
+
RUN ${packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo"} prune ${turboPackage} --docker
|
|
4165
4331
|
|
|
4166
|
-
# Install dependencies
|
|
4332
|
+
# Stage 2: Install dependencies
|
|
4333
|
+
FROM ${baseImage} AS deps
|
|
4334
|
+
|
|
4335
|
+
WORKDIR /app
|
|
4336
|
+
|
|
4337
|
+
${installPm}
|
|
4338
|
+
|
|
4339
|
+
# Copy pruned lockfile and package.jsons
|
|
4340
|
+
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
4341
|
+
COPY --from=pruner /app/out/json/ ./
|
|
4342
|
+
|
|
4343
|
+
# Install dependencies
|
|
4167
4344
|
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
4168
|
-
${
|
|
4345
|
+
${turboInstallCmd}
|
|
4169
4346
|
|
|
4170
|
-
# Stage
|
|
4347
|
+
# Stage 3: Build
|
|
4171
4348
|
FROM deps AS builder
|
|
4172
4349
|
|
|
4173
4350
|
WORKDIR /app
|
|
4174
4351
|
|
|
4175
|
-
# Copy source
|
|
4176
|
-
COPY
|
|
4352
|
+
# Copy pruned source
|
|
4353
|
+
COPY --from=pruner /app/out/full/ ./
|
|
4354
|
+
|
|
4355
|
+
# Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
|
|
4356
|
+
# Using wildcard to make it optional for single-app projects
|
|
4357
|
+
COPY --from=pruner /app/gkm.config.* ./
|
|
4358
|
+
COPY --from=pruner /app/tsconfig.* ./
|
|
4359
|
+
|
|
4360
|
+
# Write encrypted credentials for gkm build to embed.
|
|
4361
|
+
# A build secret (\`docker build --secret id=gkm_credentials,src=…\`), not an
|
|
4362
|
+
# ARG: an ARG is recorded in the image history. Two lines, ciphertext then IV;
|
|
4363
|
+
# absent when the app has no secrets.
|
|
4364
|
+
RUN --mount=type=secret,id=gkm_credentials,required=false \
|
|
4365
|
+
if [ -s /run/secrets/gkm_credentials ]; then \
|
|
4366
|
+
mkdir -p ${appPath}/.gkm && \
|
|
4367
|
+
sed -n 1p /run/secrets/gkm_credentials > ${appPath}/.gkm/credentials.enc && \
|
|
4368
|
+
sed -n 2p /run/secrets/gkm_credentials > ${appPath}/.gkm/credentials.iv; \
|
|
4369
|
+
fi
|
|
4177
4370
|
|
|
4178
4371
|
# Build any workspace packages the app depends on.
|
|
4179
4372
|
#
|
|
@@ -4184,51 +4377,49 @@ COPY . .
|
|
|
4184
4377
|
RUN ${pm.run} --if-present build
|
|
4185
4378
|
|
|
4186
4379
|
# Build production server using gkm
|
|
4187
|
-
RUN ${pm.exec} gkm build --provider server --production
|
|
4380
|
+
RUN cd ${appPath} && ${pm.exec} gkm build --provider server --production
|
|
4188
4381
|
|
|
4189
|
-
# Stage
|
|
4382
|
+
# Stage 4: Production
|
|
4190
4383
|
FROM ${baseImage} AS runner
|
|
4191
4384
|
|
|
4192
4385
|
WORKDIR /app
|
|
4193
4386
|
|
|
4194
|
-
# Install tini for proper signal handling as PID 1
|
|
4195
4387
|
RUN apk add --no-cache tini
|
|
4196
4388
|
|
|
4197
|
-
# Create non-root user
|
|
4198
4389
|
RUN addgroup --system --gid 1001 nodejs && \\
|
|
4199
4390
|
adduser --system --uid 1001 hono
|
|
4200
4391
|
|
|
4201
4392
|
# Copy bundled server
|
|
4202
|
-
COPY --from=builder --chown=hono:nodejs /app/.gkm/server/dist/server.mjs ./
|
|
4393
|
+
COPY --from=builder --chown=hono:nodejs /app/${appPath}/.gkm/server/dist/server.mjs ./
|
|
4203
4394
|
|
|
4204
|
-
# Environment
|
|
4205
4395
|
ENV NODE_ENV=production
|
|
4206
4396
|
ENV PORT=${port}
|
|
4207
4397
|
|
|
4208
|
-
# Health check
|
|
4209
4398
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
4210
4399
|
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
4211
4400
|
|
|
4212
|
-
# Switch to non-root user
|
|
4213
4401
|
USER hono
|
|
4214
4402
|
|
|
4215
4403
|
EXPOSE ${port}
|
|
4216
4404
|
|
|
4217
|
-
# Use tini as entrypoint to handle PID 1 responsibilities
|
|
4218
4405
|
ENTRYPOINT ["/sbin/tini", "--"]
|
|
4219
4406
|
CMD ["node", "server.mjs"]
|
|
4220
4407
|
`;
|
|
4221
4408
|
}
|
|
4222
4409
|
/**
|
|
4223
|
-
* Generate a Dockerfile
|
|
4224
|
-
* Uses
|
|
4410
|
+
* Generate a Dockerfile for apps with a custom entry point.
|
|
4411
|
+
* Uses esbuild to bundle the entry point into dist/index.mjs with all dependencies.
|
|
4412
|
+
* This is used for apps that don't use gkm routes (e.g., Better Auth servers).
|
|
4413
|
+
* @internal Exported for testing
|
|
4225
4414
|
*/
|
|
4226
|
-
function
|
|
4227
|
-
const { baseImage, port,
|
|
4415
|
+
function generateEntryDockerfile(options) {
|
|
4416
|
+
const { baseImage, port, appPath, entry, turboPackage, packageManager, healthCheckPath = "/health" } = options;
|
|
4228
4417
|
const pm = getPmConfig(packageManager);
|
|
4229
4418
|
const installPm = pm.install ? `RUN ${pm.install}` : "";
|
|
4230
4419
|
const turboInstallCmd = getTurboInstallCmd(packageManager);
|
|
4231
4420
|
return `# syntax=docker/dockerfile:1
|
|
4421
|
+
# Entry-based Dockerfile with turbo prune + tsdown bundling
|
|
4422
|
+
|
|
4232
4423
|
# Stage 1: Prune monorepo
|
|
4233
4424
|
FROM ${baseImage} AS pruner
|
|
4234
4425
|
|
|
@@ -4252,11 +4443,11 @@ ${installPm}
|
|
|
4252
4443
|
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
4253
4444
|
COPY --from=pruner /app/out/json/ ./
|
|
4254
4445
|
|
|
4255
|
-
# Install dependencies
|
|
4446
|
+
# Install dependencies
|
|
4256
4447
|
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
4257
4448
|
${turboInstallCmd}
|
|
4258
4449
|
|
|
4259
|
-
# Stage 3: Build
|
|
4450
|
+
# Stage 3: Build with tsdown
|
|
4260
4451
|
FROM deps AS builder
|
|
4261
4452
|
|
|
4262
4453
|
WORKDIR /app
|
|
@@ -4264,501 +4455,149 @@ WORKDIR /app
|
|
|
4264
4455
|
# Copy pruned source
|
|
4265
4456
|
COPY --from=pruner /app/out/full/ ./
|
|
4266
4457
|
|
|
4267
|
-
#
|
|
4268
|
-
#
|
|
4269
|
-
|
|
4270
|
-
# for one whose CLI is a sibling package: \`turbo prune\` honours .gitignore, so a
|
|
4271
|
-
# workspace package's \`dist\` never reaches the image and its bin has nothing to
|
|
4272
|
-
# import. \`--if-present\` is what makes the same Dockerfile correct for both.
|
|
4273
|
-
RUN ${pm.run} --if-present build
|
|
4274
|
-
|
|
4275
|
-
# Build production server using gkm
|
|
4276
|
-
RUN ${pm.exec} gkm build --provider server --production
|
|
4458
|
+
# Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
|
|
4459
|
+
# Using wildcard to make it optional for single-app projects
|
|
4460
|
+
COPY --from=pruner /app/tsconfig.* ./
|
|
4277
4461
|
|
|
4278
|
-
#
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4295
|
-
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4462
|
+
# Bundle entry point with esbuild (outputs to dist/index.mjs)
|
|
4463
|
+
# Creates a fully standalone bundle with all dependencies included
|
|
4464
|
+
# Use define to embed credentials if present.
|
|
4465
|
+
# They arrive as a build secret (\`docker build --secret id=gkm_credentials,src=…\`),
|
|
4466
|
+
# not an ARG: an ARG is recorded in the image history. Two lines, ciphertext
|
|
4467
|
+
# then IV; absent when the app has no secrets.
|
|
4468
|
+
RUN --mount=type=secret,id=gkm_credentials,required=false \
|
|
4469
|
+
cd ${appPath} && \
|
|
4470
|
+
if [ -s /run/secrets/gkm_credentials ]; then \
|
|
4471
|
+
CREDS=$(sed -n 1p /run/secrets/gkm_credentials) && \
|
|
4472
|
+
IV=$(sed -n 2p /run/secrets/gkm_credentials) && \
|
|
4473
|
+
npx esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \
|
|
4474
|
+
--outfile=dist/index.mjs --packages=bundle \
|
|
4475
|
+
--banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);' \
|
|
4476
|
+
--define:__GKM_ENCRYPTED_CREDENTIALS__="'\\"$CREDS\\"'" \
|
|
4477
|
+
--define:__GKM_CREDENTIALS_IV__="'\\"$IV\\"'"; \
|
|
4478
|
+
else \
|
|
4479
|
+
npx esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \
|
|
4480
|
+
--outfile=dist/index.mjs --packages=bundle \
|
|
4481
|
+
--banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);'; \
|
|
4482
|
+
fi
|
|
4299
4483
|
|
|
4300
|
-
|
|
4301
|
-
|
|
4302
|
-
`;
|
|
4303
|
-
}
|
|
4304
|
-
/**
|
|
4305
|
-
* Generate a slim Dockerfile for pre-built bundles
|
|
4306
|
-
*/
|
|
4307
|
-
function generateSlimDockerfile(options) {
|
|
4308
|
-
const { baseImage, port, healthCheckPath, bundle = ".gkm/server/dist/server.mjs" } = options;
|
|
4309
|
-
return `# Slim Dockerfile for pre-built production bundle
|
|
4310
|
-
FROM ${baseImage}
|
|
4484
|
+
# Stage 4: Production
|
|
4485
|
+
FROM ${baseImage} AS runner
|
|
4311
4486
|
|
|
4312
4487
|
WORKDIR /app
|
|
4313
4488
|
|
|
4314
|
-
# Install tini for proper signal handling as PID 1
|
|
4315
|
-
# Handles SIGTERM propagation and zombie process reaping
|
|
4316
4489
|
RUN apk add --no-cache tini
|
|
4317
4490
|
|
|
4318
|
-
# Create non-root user
|
|
4319
4491
|
RUN addgroup --system --gid 1001 nodejs && \\
|
|
4320
|
-
adduser --system --uid 1001
|
|
4492
|
+
adduser --system --uid 1001 app
|
|
4321
4493
|
|
|
4322
|
-
# Copy
|
|
4323
|
-
COPY
|
|
4494
|
+
# Copy bundled output only (no node_modules needed - fully bundled)
|
|
4495
|
+
COPY --from=builder --chown=app:nodejs /app/${appPath}/dist/index.mjs ./
|
|
4324
4496
|
|
|
4325
|
-
# Environment
|
|
4326
4497
|
ENV NODE_ENV=production
|
|
4327
4498
|
ENV PORT=${port}
|
|
4328
4499
|
|
|
4329
|
-
# Health check
|
|
4330
4500
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
4331
4501
|
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
4332
4502
|
|
|
4333
|
-
|
|
4334
|
-
USER hono
|
|
4503
|
+
USER app
|
|
4335
4504
|
|
|
4336
4505
|
EXPOSE ${port}
|
|
4337
4506
|
|
|
4338
|
-
# Use tini as entrypoint to handle PID 1 responsibilities
|
|
4339
4507
|
ENTRYPOINT ["/sbin/tini", "--"]
|
|
4340
|
-
CMD ["node", "
|
|
4341
|
-
`;
|
|
4342
|
-
}
|
|
4343
|
-
/**
|
|
4344
|
-
* Generate .dockerignore file
|
|
4345
|
-
*/
|
|
4346
|
-
function generateDockerignore() {
|
|
4347
|
-
return `# Dependencies
|
|
4348
|
-
node_modules
|
|
4349
|
-
.pnpm-store
|
|
4350
|
-
|
|
4351
|
-
# Build output (except what we need)
|
|
4352
|
-
.gkm/aws*
|
|
4353
|
-
.gkm/server/*.ts
|
|
4354
|
-
!.gkm/server/dist
|
|
4355
|
-
|
|
4356
|
-
# The key gkm build --stage encrypted secrets with: runtime env only, never
|
|
4357
|
-
# the build context
|
|
4358
|
-
.gkm/server/master.key
|
|
4359
|
-
|
|
4360
|
-
# gkm compose's stacks: each app's env file holds its stage's secrets
|
|
4361
|
-
.gkm/compose
|
|
4362
|
-
|
|
4363
|
-
# IDE and editor
|
|
4364
|
-
.idea
|
|
4365
|
-
.vscode
|
|
4366
|
-
*.swp
|
|
4367
|
-
*.swo
|
|
4368
|
-
|
|
4369
|
-
# Git
|
|
4370
|
-
.git
|
|
4371
|
-
.gitignore
|
|
4372
|
-
|
|
4373
|
-
# Logs
|
|
4374
|
-
*.log
|
|
4375
|
-
npm-debug.log*
|
|
4376
|
-
pnpm-debug.log*
|
|
4377
|
-
|
|
4378
|
-
# Test files
|
|
4379
|
-
**/*.test.ts
|
|
4380
|
-
**/*.spec.ts
|
|
4381
|
-
**/__tests__
|
|
4382
|
-
coverage
|
|
4383
|
-
|
|
4384
|
-
# Documentation
|
|
4385
|
-
docs
|
|
4386
|
-
*.md
|
|
4387
|
-
!README.md
|
|
4388
|
-
|
|
4389
|
-
# Environment files (handle secrets separately)
|
|
4390
|
-
.env
|
|
4391
|
-
.env.*
|
|
4392
|
-
!.env.example
|
|
4393
|
-
|
|
4394
|
-
# Docker files (don't copy recursively)
|
|
4395
|
-
Dockerfile*
|
|
4396
|
-
docker-compose*
|
|
4397
|
-
.dockerignore
|
|
4398
|
-
`;
|
|
4399
|
-
}
|
|
4400
|
-
/**
|
|
4401
|
-
* Generate docker-entrypoint.sh for custom startup logic
|
|
4402
|
-
*/
|
|
4403
|
-
function generateDockerEntrypoint() {
|
|
4404
|
-
return `#!/bin/sh
|
|
4405
|
-
set -e
|
|
4406
|
-
|
|
4407
|
-
# Run any custom startup scripts here
|
|
4408
|
-
# Example: wait for database
|
|
4409
|
-
# until nc -z $DB_HOST $DB_PORT; do
|
|
4410
|
-
# echo "Waiting for database..."
|
|
4411
|
-
# sleep 1
|
|
4412
|
-
# done
|
|
4413
|
-
|
|
4414
|
-
# Execute the main command
|
|
4415
|
-
exec "$@"
|
|
4508
|
+
CMD ["node", "index.mjs"]
|
|
4416
4509
|
`;
|
|
4417
4510
|
}
|
|
4418
4511
|
/**
|
|
4419
|
-
*
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
imageName: docker.imageName ?? config.name ?? "api",
|
|
4426
|
-
baseImage: docker.baseImage ?? "node:22-alpine",
|
|
4427
|
-
port: docker.port ?? 3e3
|
|
4428
|
-
};
|
|
4429
|
-
}
|
|
4430
|
-
/**
|
|
4431
|
-
* Generate a Dockerfile for Next.js frontend apps using standalone output.
|
|
4432
|
-
* Uses turbo prune for monorepo optimization.
|
|
4512
|
+
* Generate a Dockerfile for Node-based SSR web frameworks (TanStack Start,
|
|
4513
|
+
* Remix). Builds with turbo, then runs the framework's production start
|
|
4514
|
+
* script via `npm start`.
|
|
4515
|
+
*
|
|
4516
|
+
* Public URL build args are declared so framework bundlers (Vite, Vinxi)
|
|
4517
|
+
* can inline them at build time. Per-framework prefix is the caller's job.
|
|
4433
4518
|
* @internal Exported for testing
|
|
4434
4519
|
*/
|
|
4435
|
-
function
|
|
4436
|
-
const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = [
|
|
4520
|
+
function generateNodeWebDockerfile(options) {
|
|
4521
|
+
const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = [] } = options;
|
|
4437
4522
|
const pm = getPmConfig(packageManager);
|
|
4438
4523
|
const installPm = pm.install ? `RUN ${pm.install}` : "";
|
|
4439
4524
|
const turboInstallCmd = getTurboInstallCmd(packageManager);
|
|
4440
4525
|
const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
|
|
4441
|
-
const
|
|
4442
|
-
const
|
|
4526
|
+
const startCmd = packageManager === "pnpm" ? "pnpm start" : `${pm.lockfile.startsWith("package-lock") ? "npm" : packageManager} start`;
|
|
4527
|
+
const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join("\n");
|
|
4528
|
+
const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join("\n");
|
|
4443
4529
|
return `# syntax=docker/dockerfile:1
|
|
4444
|
-
#
|
|
4530
|
+
# Node SSR web Dockerfile (TanStack Start / Remix) with turbo prune
|
|
4445
4531
|
|
|
4446
|
-
# Stage 1: Prune monorepo
|
|
4447
4532
|
FROM ${baseImage} AS pruner
|
|
4448
|
-
|
|
4449
4533
|
WORKDIR /app
|
|
4450
|
-
|
|
4451
4534
|
${installPm}
|
|
4452
|
-
|
|
4453
4535
|
COPY . .
|
|
4454
|
-
|
|
4455
|
-
# Prune to only include necessary packages
|
|
4456
4536
|
RUN ${turboCmd} prune ${turboPackage} --docker
|
|
4457
4537
|
|
|
4458
|
-
# Stage 2: Install dependencies
|
|
4459
4538
|
FROM ${baseImage} AS deps
|
|
4460
|
-
|
|
4461
4539
|
WORKDIR /app
|
|
4462
|
-
|
|
4463
4540
|
${installPm}
|
|
4464
|
-
|
|
4465
|
-
# Copy pruned lockfile and package.jsons
|
|
4466
4541
|
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
4467
4542
|
COPY --from=pruner /app/out/json/ ./
|
|
4468
|
-
|
|
4469
|
-
# Install dependencies
|
|
4470
4543
|
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
4471
4544
|
${turboInstallCmd}
|
|
4472
4545
|
|
|
4473
|
-
# Stage 3: Build
|
|
4474
4546
|
FROM deps AS builder
|
|
4475
|
-
|
|
4476
4547
|
WORKDIR /app
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
# These get baked into the Next.js build as public environment variables
|
|
4480
|
-
${publicUrlArgDeclarations}
|
|
4481
|
-
|
|
4482
|
-
# Convert ARGs to ENVs for Next.js build
|
|
4483
|
-
${publicUrlEnvDeclarations}
|
|
4484
|
-
|
|
4485
|
-
# Copy pruned source
|
|
4548
|
+
${argDecls}
|
|
4549
|
+
${envDecls}
|
|
4486
4550
|
COPY --from=pruner /app/out/full/ ./
|
|
4487
|
-
|
|
4488
|
-
# Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
|
|
4489
|
-
# Using wildcard to make it optional for single-app projects
|
|
4490
4551
|
COPY --from=pruner /app/tsconfig.* ./
|
|
4491
|
-
|
|
4492
|
-
# Ensure public directory exists (may be empty for scaffolded projects)
|
|
4493
|
-
RUN mkdir -p ${appPath}/public
|
|
4494
|
-
|
|
4495
|
-
# Set Next.js to produce standalone output
|
|
4496
|
-
ENV NEXT_TELEMETRY_DISABLED=1
|
|
4497
|
-
|
|
4498
|
-
# Build the application
|
|
4499
4552
|
RUN ${turboCmd} run build --filter=${turboPackage}
|
|
4500
4553
|
|
|
4501
|
-
# Stage 4: Production
|
|
4502
4554
|
FROM ${baseImage} AS runner
|
|
4503
|
-
|
|
4504
4555
|
WORKDIR /app
|
|
4505
|
-
|
|
4506
|
-
# Install tini for proper signal handling
|
|
4507
4556
|
RUN apk add --no-cache tini
|
|
4508
|
-
|
|
4509
|
-
# Create non-root user
|
|
4510
4557
|
RUN addgroup --system --gid 1001 nodejs && \\
|
|
4511
|
-
adduser --system --uid 1001
|
|
4558
|
+
adduser --system --uid 1001 app
|
|
4559
|
+
|
|
4560
|
+
# Copy the whole built workspace — frameworks differ on output paths
|
|
4561
|
+
COPY --from=builder --chown=app:nodejs /app/ ./
|
|
4512
4562
|
|
|
4513
|
-
# Set environment
|
|
4514
4563
|
ENV NODE_ENV=production
|
|
4515
|
-
ENV NEXT_TELEMETRY_DISABLED=1
|
|
4516
4564
|
ENV PORT=${port}
|
|
4517
4565
|
ENV HOSTNAME="0.0.0.0"
|
|
4518
4566
|
|
|
4519
|
-
|
|
4520
|
-
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/standalone ./
|
|
4521
|
-
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/static ./${appPath}/.next/static
|
|
4522
|
-
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/public ./${appPath}/public
|
|
4523
|
-
|
|
4524
|
-
USER nextjs
|
|
4525
|
-
|
|
4567
|
+
USER app
|
|
4526
4568
|
EXPOSE ${port}
|
|
4527
4569
|
|
|
4570
|
+
# A site answers at its root; Docker, and Dokploy, read the container's
|
|
4571
|
+
# health from it rather than from the process merely running. 127.0.0.1, not
|
|
4572
|
+
# localhost: the server listens on IPv4, and localhost can resolve to ::1.
|
|
4573
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
4574
|
+
CMD wget -qO- http://127.0.0.1:${port}/ > /dev/null 2>&1 || exit 1
|
|
4575
|
+
|
|
4528
4576
|
ENTRYPOINT ["/sbin/tini", "--"]
|
|
4529
|
-
CMD ["
|
|
4577
|
+
CMD ["sh", "-c", "cd ${appPath} && ${startCmd}"]
|
|
4530
4578
|
`;
|
|
4531
4579
|
}
|
|
4532
4580
|
/**
|
|
4533
|
-
* Generate a Dockerfile for
|
|
4534
|
-
*
|
|
4581
|
+
* Generate a Dockerfile for Vite SPA apps. Builds the static bundle with
|
|
4582
|
+
* turbo, then serves it via nginx.
|
|
4535
4583
|
* @internal Exported for testing
|
|
4536
4584
|
*/
|
|
4537
|
-
function
|
|
4538
|
-
const { baseImage, port, appPath, turboPackage, packageManager,
|
|
4585
|
+
function generateViteStaticDockerfile(options) {
|
|
4586
|
+
const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = [] } = options;
|
|
4539
4587
|
const pm = getPmConfig(packageManager);
|
|
4540
4588
|
const installPm = pm.install ? `RUN ${pm.install}` : "";
|
|
4541
4589
|
const turboInstallCmd = getTurboInstallCmd(packageManager);
|
|
4590
|
+
const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
|
|
4591
|
+
const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join("\n");
|
|
4592
|
+
const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join("\n");
|
|
4542
4593
|
return `# syntax=docker/dockerfile:1
|
|
4543
|
-
#
|
|
4594
|
+
# Vite SPA Dockerfile — builds static assets, serves via nginx
|
|
4544
4595
|
|
|
4545
|
-
# Stage 1: Prune monorepo
|
|
4546
4596
|
FROM ${baseImage} AS pruner
|
|
4547
|
-
|
|
4548
4597
|
WORKDIR /app
|
|
4549
|
-
|
|
4550
4598
|
${installPm}
|
|
4551
|
-
|
|
4552
4599
|
COPY . .
|
|
4553
|
-
|
|
4554
|
-
# Prune to only include necessary packages
|
|
4555
|
-
RUN ${packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo"} prune ${turboPackage} --docker
|
|
4556
|
-
|
|
4557
|
-
# Stage 2: Install dependencies
|
|
4558
|
-
FROM ${baseImage} AS deps
|
|
4559
|
-
|
|
4560
|
-
WORKDIR /app
|
|
4561
|
-
|
|
4562
|
-
${installPm}
|
|
4563
|
-
|
|
4564
|
-
# Copy pruned lockfile and package.jsons
|
|
4565
|
-
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
4566
|
-
COPY --from=pruner /app/out/json/ ./
|
|
4567
|
-
|
|
4568
|
-
# Install dependencies
|
|
4569
|
-
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
4570
|
-
${turboInstallCmd}
|
|
4571
|
-
|
|
4572
|
-
# Stage 3: Build
|
|
4573
|
-
FROM deps AS builder
|
|
4574
|
-
|
|
4575
|
-
WORKDIR /app
|
|
4576
|
-
|
|
4577
|
-
# Copy pruned source
|
|
4578
|
-
COPY --from=pruner /app/out/full/ ./
|
|
4579
|
-
|
|
4580
|
-
# Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
|
|
4581
|
-
# Using wildcard to make it optional for single-app projects
|
|
4582
|
-
COPY --from=pruner /app/gkm.config.* ./
|
|
4583
|
-
COPY --from=pruner /app/tsconfig.* ./
|
|
4584
|
-
|
|
4585
|
-
# Write encrypted credentials for gkm build to embed.
|
|
4586
|
-
# A build secret (\`docker build --secret id=gkm_credentials,src=…\`), not an
|
|
4587
|
-
# ARG: an ARG is recorded in the image history. Two lines, ciphertext then IV;
|
|
4588
|
-
# absent when the app has no secrets.
|
|
4589
|
-
RUN --mount=type=secret,id=gkm_credentials,required=false \
|
|
4590
|
-
if [ -s /run/secrets/gkm_credentials ]; then \
|
|
4591
|
-
mkdir -p ${appPath}/.gkm && \
|
|
4592
|
-
sed -n 1p /run/secrets/gkm_credentials > ${appPath}/.gkm/credentials.enc && \
|
|
4593
|
-
sed -n 2p /run/secrets/gkm_credentials > ${appPath}/.gkm/credentials.iv; \
|
|
4594
|
-
fi
|
|
4595
|
-
|
|
4596
|
-
# Build any workspace packages the app depends on.
|
|
4597
|
-
#
|
|
4598
|
-
# A no-op for a project whose dependencies come from a registry, and required
|
|
4599
|
-
# for one whose CLI is a sibling package: \`turbo prune\` honours .gitignore, so a
|
|
4600
|
-
# workspace package's \`dist\` never reaches the image and its bin has nothing to
|
|
4601
|
-
# import. \`--if-present\` is what makes the same Dockerfile correct for both.
|
|
4602
|
-
RUN ${pm.run} --if-present build
|
|
4603
|
-
|
|
4604
|
-
# Build production server using gkm
|
|
4605
|
-
RUN cd ${appPath} && ${pm.exec} gkm build --provider server --production
|
|
4606
|
-
|
|
4607
|
-
# Stage 4: Production
|
|
4608
|
-
FROM ${baseImage} AS runner
|
|
4609
|
-
|
|
4610
|
-
WORKDIR /app
|
|
4611
|
-
|
|
4612
|
-
RUN apk add --no-cache tini
|
|
4613
|
-
|
|
4614
|
-
RUN addgroup --system --gid 1001 nodejs && \\
|
|
4615
|
-
adduser --system --uid 1001 hono
|
|
4616
|
-
|
|
4617
|
-
# Copy bundled server
|
|
4618
|
-
COPY --from=builder --chown=hono:nodejs /app/${appPath}/.gkm/server/dist/server.mjs ./
|
|
4619
|
-
|
|
4620
|
-
ENV NODE_ENV=production
|
|
4621
|
-
ENV PORT=${port}
|
|
4622
|
-
|
|
4623
|
-
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
4624
|
-
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
4625
|
-
|
|
4626
|
-
USER hono
|
|
4627
|
-
|
|
4628
|
-
EXPOSE ${port}
|
|
4629
|
-
|
|
4630
|
-
ENTRYPOINT ["/sbin/tini", "--"]
|
|
4631
|
-
CMD ["node", "server.mjs"]
|
|
4632
|
-
`;
|
|
4633
|
-
}
|
|
4634
|
-
/**
|
|
4635
|
-
* Generate a Dockerfile for apps with a custom entry point.
|
|
4636
|
-
* Uses esbuild to bundle the entry point into dist/index.mjs with all dependencies.
|
|
4637
|
-
* This is used for apps that don't use gkm routes (e.g., Better Auth servers).
|
|
4638
|
-
* @internal Exported for testing
|
|
4639
|
-
*/
|
|
4640
|
-
function generateEntryDockerfile(options) {
|
|
4641
|
-
const { baseImage, port, appPath, entry, turboPackage, packageManager, healthCheckPath = "/health" } = options;
|
|
4642
|
-
const pm = getPmConfig(packageManager);
|
|
4643
|
-
const installPm = pm.install ? `RUN ${pm.install}` : "";
|
|
4644
|
-
const turboInstallCmd = getTurboInstallCmd(packageManager);
|
|
4645
|
-
return `# syntax=docker/dockerfile:1
|
|
4646
|
-
# Entry-based Dockerfile with turbo prune + tsdown bundling
|
|
4647
|
-
|
|
4648
|
-
# Stage 1: Prune monorepo
|
|
4649
|
-
FROM ${baseImage} AS pruner
|
|
4650
|
-
|
|
4651
|
-
WORKDIR /app
|
|
4652
|
-
|
|
4653
|
-
${installPm}
|
|
4654
|
-
|
|
4655
|
-
COPY . .
|
|
4656
|
-
|
|
4657
|
-
# Prune to only include necessary packages
|
|
4658
|
-
RUN ${packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo"} prune ${turboPackage} --docker
|
|
4659
|
-
|
|
4660
|
-
# Stage 2: Install dependencies
|
|
4661
|
-
FROM ${baseImage} AS deps
|
|
4662
|
-
|
|
4663
|
-
WORKDIR /app
|
|
4664
|
-
|
|
4665
|
-
${installPm}
|
|
4666
|
-
|
|
4667
|
-
# Copy pruned lockfile and package.jsons
|
|
4668
|
-
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
4669
|
-
COPY --from=pruner /app/out/json/ ./
|
|
4670
|
-
|
|
4671
|
-
# Install dependencies
|
|
4672
|
-
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
4673
|
-
${turboInstallCmd}
|
|
4674
|
-
|
|
4675
|
-
# Stage 3: Build with tsdown
|
|
4676
|
-
FROM deps AS builder
|
|
4677
|
-
|
|
4678
|
-
WORKDIR /app
|
|
4679
|
-
|
|
4680
|
-
# Copy pruned source
|
|
4681
|
-
COPY --from=pruner /app/out/full/ ./
|
|
4682
|
-
|
|
4683
|
-
# Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
|
|
4684
|
-
# Using wildcard to make it optional for single-app projects
|
|
4685
|
-
COPY --from=pruner /app/tsconfig.* ./
|
|
4686
|
-
|
|
4687
|
-
# Bundle entry point with esbuild (outputs to dist/index.mjs)
|
|
4688
|
-
# Creates a fully standalone bundle with all dependencies included
|
|
4689
|
-
# Use define to embed credentials if present.
|
|
4690
|
-
# They arrive as a build secret (\`docker build --secret id=gkm_credentials,src=…\`),
|
|
4691
|
-
# not an ARG: an ARG is recorded in the image history. Two lines, ciphertext
|
|
4692
|
-
# then IV; absent when the app has no secrets.
|
|
4693
|
-
RUN --mount=type=secret,id=gkm_credentials,required=false \
|
|
4694
|
-
cd ${appPath} && \
|
|
4695
|
-
if [ -s /run/secrets/gkm_credentials ]; then \
|
|
4696
|
-
CREDS=$(sed -n 1p /run/secrets/gkm_credentials) && \
|
|
4697
|
-
IV=$(sed -n 2p /run/secrets/gkm_credentials) && \
|
|
4698
|
-
npx esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \
|
|
4699
|
-
--outfile=dist/index.mjs --packages=bundle \
|
|
4700
|
-
--banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);' \
|
|
4701
|
-
--define:__GKM_ENCRYPTED_CREDENTIALS__="'\\"$CREDS\\"'" \
|
|
4702
|
-
--define:__GKM_CREDENTIALS_IV__="'\\"$IV\\"'"; \
|
|
4703
|
-
else \
|
|
4704
|
-
npx esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \
|
|
4705
|
-
--outfile=dist/index.mjs --packages=bundle \
|
|
4706
|
-
--banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);'; \
|
|
4707
|
-
fi
|
|
4708
|
-
|
|
4709
|
-
# Stage 4: Production
|
|
4710
|
-
FROM ${baseImage} AS runner
|
|
4711
|
-
|
|
4712
|
-
WORKDIR /app
|
|
4713
|
-
|
|
4714
|
-
RUN apk add --no-cache tini
|
|
4715
|
-
|
|
4716
|
-
RUN addgroup --system --gid 1001 nodejs && \\
|
|
4717
|
-
adduser --system --uid 1001 app
|
|
4718
|
-
|
|
4719
|
-
# Copy bundled output only (no node_modules needed - fully bundled)
|
|
4720
|
-
COPY --from=builder --chown=app:nodejs /app/${appPath}/dist/index.mjs ./
|
|
4721
|
-
|
|
4722
|
-
ENV NODE_ENV=production
|
|
4723
|
-
ENV PORT=${port}
|
|
4724
|
-
|
|
4725
|
-
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
4726
|
-
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
4727
|
-
|
|
4728
|
-
USER app
|
|
4729
|
-
|
|
4730
|
-
EXPOSE ${port}
|
|
4731
|
-
|
|
4732
|
-
ENTRYPOINT ["/sbin/tini", "--"]
|
|
4733
|
-
CMD ["node", "index.mjs"]
|
|
4734
|
-
`;
|
|
4735
|
-
}
|
|
4736
|
-
/**
|
|
4737
|
-
* Generate a Dockerfile for Node-based SSR web frameworks (TanStack Start,
|
|
4738
|
-
* Remix). Builds with turbo, then runs the framework's production start
|
|
4739
|
-
* script via `npm start`.
|
|
4740
|
-
*
|
|
4741
|
-
* Public URL build args are declared so framework bundlers (Vite, Vinxi)
|
|
4742
|
-
* can inline them at build time. Per-framework prefix is the caller's job.
|
|
4743
|
-
* @internal Exported for testing
|
|
4744
|
-
*/
|
|
4745
|
-
function generateNodeWebDockerfile(options) {
|
|
4746
|
-
const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = [] } = options;
|
|
4747
|
-
const pm = getPmConfig(packageManager);
|
|
4748
|
-
const installPm = pm.install ? `RUN ${pm.install}` : "";
|
|
4749
|
-
const turboInstallCmd = getTurboInstallCmd(packageManager);
|
|
4750
|
-
const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
|
|
4751
|
-
const startCmd = packageManager === "pnpm" ? "pnpm start" : `${pm.lockfile.startsWith("package-lock") ? "npm" : packageManager} start`;
|
|
4752
|
-
const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join("\n");
|
|
4753
|
-
const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join("\n");
|
|
4754
|
-
return `# syntax=docker/dockerfile:1
|
|
4755
|
-
# Node SSR web Dockerfile (TanStack Start / Remix) with turbo prune
|
|
4756
|
-
|
|
4757
|
-
FROM ${baseImage} AS pruner
|
|
4758
|
-
WORKDIR /app
|
|
4759
|
-
${installPm}
|
|
4760
|
-
COPY . .
|
|
4761
|
-
RUN ${turboCmd} prune ${turboPackage} --docker
|
|
4600
|
+
RUN ${turboCmd} prune ${turboPackage} --docker
|
|
4762
4601
|
|
|
4763
4602
|
FROM ${baseImage} AS deps
|
|
4764
4603
|
WORKDIR /app
|
|
@@ -4776,70 +4615,17 @@ COPY --from=pruner /app/out/full/ ./
|
|
|
4776
4615
|
COPY --from=pruner /app/tsconfig.* ./
|
|
4777
4616
|
RUN ${turboCmd} run build --filter=${turboPackage}
|
|
4778
4617
|
|
|
4779
|
-
FROM
|
|
4780
|
-
|
|
4781
|
-
RUN
|
|
4782
|
-
|
|
4783
|
-
adduser --system --uid 1001 app
|
|
4784
|
-
|
|
4785
|
-
# Copy the whole built workspace — frameworks differ on output paths
|
|
4786
|
-
COPY --from=builder --chown=app:nodejs /app/ ./
|
|
4787
|
-
|
|
4788
|
-
ENV NODE_ENV=production
|
|
4789
|
-
ENV PORT=${port}
|
|
4790
|
-
ENV HOSTNAME="0.0.0.0"
|
|
4791
|
-
|
|
4792
|
-
USER app
|
|
4618
|
+
FROM nginx:alpine AS runner
|
|
4619
|
+
COPY --from=builder /app/${appPath}/dist /usr/share/nginx/html
|
|
4620
|
+
RUN printf 'server {\\n listen ${port};\\n root /usr/share/nginx/html;\\n index index.html;\\n location / { try_files $uri $uri/ /index.html; }\\n}\\n' \\
|
|
4621
|
+
> /etc/nginx/conf.d/default.conf
|
|
4793
4622
|
EXPOSE ${port}
|
|
4794
|
-
|
|
4795
|
-
|
|
4796
|
-
|
|
4797
|
-
|
|
4798
|
-
}
|
|
4799
|
-
|
|
4800
|
-
* Generate a Dockerfile for Vite SPA apps. Builds the static bundle with
|
|
4801
|
-
* turbo, then serves it via nginx.
|
|
4802
|
-
* @internal Exported for testing
|
|
4803
|
-
*/
|
|
4804
|
-
function generateViteStaticDockerfile(options) {
|
|
4805
|
-
const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = [] } = options;
|
|
4806
|
-
const pm = getPmConfig(packageManager);
|
|
4807
|
-
const installPm = pm.install ? `RUN ${pm.install}` : "";
|
|
4808
|
-
const turboInstallCmd = getTurboInstallCmd(packageManager);
|
|
4809
|
-
const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
|
|
4810
|
-
const argDecls = publicUrlArgs.map((a) => `ARG ${a}=""`).join("\n");
|
|
4811
|
-
const envDecls = publicUrlArgs.map((a) => `ENV ${a}=$${a}`).join("\n");
|
|
4812
|
-
return `# syntax=docker/dockerfile:1
|
|
4813
|
-
# Vite SPA Dockerfile — builds static assets, serves via nginx
|
|
4814
|
-
|
|
4815
|
-
FROM ${baseImage} AS pruner
|
|
4816
|
-
WORKDIR /app
|
|
4817
|
-
${installPm}
|
|
4818
|
-
COPY . .
|
|
4819
|
-
RUN ${turboCmd} prune ${turboPackage} --docker
|
|
4820
|
-
|
|
4821
|
-
FROM ${baseImage} AS deps
|
|
4822
|
-
WORKDIR /app
|
|
4823
|
-
${installPm}
|
|
4824
|
-
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
4825
|
-
COPY --from=pruner /app/out/json/ ./
|
|
4826
|
-
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
4827
|
-
${turboInstallCmd}
|
|
4828
|
-
|
|
4829
|
-
FROM deps AS builder
|
|
4830
|
-
WORKDIR /app
|
|
4831
|
-
${argDecls}
|
|
4832
|
-
${envDecls}
|
|
4833
|
-
COPY --from=pruner /app/out/full/ ./
|
|
4834
|
-
COPY --from=pruner /app/tsconfig.* ./
|
|
4835
|
-
RUN ${turboCmd} run build --filter=${turboPackage}
|
|
4836
|
-
|
|
4837
|
-
FROM nginx:alpine AS runner
|
|
4838
|
-
COPY --from=builder /app/${appPath}/dist /usr/share/nginx/html
|
|
4839
|
-
RUN printf 'server {\\n listen ${port};\\n root /usr/share/nginx/html;\\n index index.html;\\n location / { try_files $uri $uri/ /index.html; }\\n}\\n' \\
|
|
4840
|
-
> /etc/nginx/conf.d/default.conf
|
|
4841
|
-
EXPOSE ${port}
|
|
4842
|
-
CMD ["nginx", "-g", "daemon off;"]
|
|
4623
|
+
# A site answers at its root; Docker, and Dokploy, read the container's
|
|
4624
|
+
# health from it rather than from the process merely running. 127.0.0.1, not
|
|
4625
|
+
# localhost: the server listens on IPv4, and localhost can resolve to ::1.
|
|
4626
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
4627
|
+
CMD wget -qO- http://127.0.0.1:${port}/ > /dev/null 2>&1 || exit 1
|
|
4628
|
+
CMD ["nginx", "-g", "daemon off;"]
|
|
4843
4629
|
`;
|
|
4844
4630
|
}
|
|
4845
4631
|
//#endregion
|
|
@@ -4849,1403 +4635,616 @@ const logger = output;
|
|
|
4849
4635
|
var DockerBuildFailed = class extends Error {
|
|
4850
4636
|
imageRef;
|
|
4851
4637
|
constructor(imageRef, cause) {
|
|
4852
|
-
super(`Failed to build Docker image: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
4853
|
-
this.imageRef = imageRef;
|
|
4854
|
-
this.name = "DockerBuildFailed";
|
|
4855
|
-
}
|
|
4856
|
-
};
|
|
4857
|
-
/** `docker push` failed — usually the registry login or its permissions. */
|
|
4858
|
-
var DockerPushFailed = class extends Error {
|
|
4859
|
-
imageRef;
|
|
4860
|
-
constructor(imageRef, cause) {
|
|
4861
|
-
super(`Failed to push Docker image: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
4862
|
-
this.imageRef = imageRef;
|
|
4863
|
-
this.name = "DockerPushFailed";
|
|
4864
|
-
}
|
|
4865
|
-
};
|
|
4866
|
-
/** `gkm docker --push` was asked for with nowhere to push to. */
|
|
4867
|
-
var PushNeedsRegistry = class extends Error {
|
|
4868
|
-
imageName;
|
|
4869
|
-
constructor(imageName) {
|
|
4870
|
-
super(`Registry is required to push Docker image '${imageName}'. Use --registry or configure docker.registry in gkm.config.ts`);
|
|
4871
|
-
this.imageName = imageName;
|
|
4872
|
-
this.name = "PushNeedsRegistry";
|
|
4873
|
-
}
|
|
4874
|
-
};
|
|
4875
|
-
/**
|
|
4876
|
-
* Docker command implementation
|
|
4877
|
-
* Generates Dockerfile, docker-compose.yml, and related files
|
|
4878
|
-
*
|
|
4879
|
-
* Default: Multi-stage Dockerfile that builds from source inside Docker
|
|
4880
|
-
* --slim: Slim Dockerfile that copies pre-built bundle (requires prior build)
|
|
4881
|
-
*/
|
|
4882
|
-
async function dockerCommand(options) {
|
|
4883
|
-
const cwd = options.cwd ?? process.cwd();
|
|
4884
|
-
const loadedConfig = await loadWorkspaceConfig(cwd);
|
|
4885
|
-
if (loadedConfig.type === "workspace") {
|
|
4886
|
-
logger.log("📦 Detected workspace configuration");
|
|
4887
|
-
return workspaceDockerCommand(loadedConfig.workspace, loadedConfig.manifest);
|
|
4888
|
-
}
|
|
4889
|
-
const dockerConfig = resolveDockerConfig(await loadConfig(cwd));
|
|
4890
|
-
const healthCheckPath = "/health";
|
|
4891
|
-
const useSlim = options.slim === true;
|
|
4892
|
-
if (useSlim) {
|
|
4893
|
-
const distDir = join(cwd, ".gkm", "server", "dist");
|
|
4894
|
-
if (!existsSync(join(distDir, "server.mjs"))) throw new Error("Slim Dockerfile requires a pre-built bundle. Run `gkm build --provider server --production` first, or omit --slim to use multi-stage build.");
|
|
4895
|
-
}
|
|
4896
|
-
const dockerDir = join(cwd, ".gkm", "docker");
|
|
4897
|
-
await mkdir(dockerDir, { recursive: true });
|
|
4898
|
-
const packageManager = detectPackageManager(cwd);
|
|
4899
|
-
const inMonorepo = isMonorepo(cwd);
|
|
4900
|
-
const hasTurbo = hasTurboConfig(cwd);
|
|
4901
|
-
let useTurbo = options.turbo ?? false;
|
|
4902
|
-
if (inMonorepo && !useSlim) {
|
|
4903
|
-
if (hasTurbo) {
|
|
4904
|
-
useTurbo = true;
|
|
4905
|
-
logger.log(" Detected monorepo with turbo.json - using turbo prune");
|
|
4906
|
-
} else throw new Error("Monorepo detected but turbo.json not found.\n\nDocker builds in monorepos require Turborepo for proper dependency isolation.\n\nTo fix this:\n 1. Install turbo: pnpm add -Dw turbo\n 2. Create turbo.json in your monorepo root\n 3. Run this command again\n\nSee: https://turbo.build/repo/docs/guides/tools/docker");
|
|
4907
|
-
}
|
|
4908
|
-
let turboPackage = options.turboPackage ?? dockerConfig.imageName;
|
|
4909
|
-
if (useTurbo && !options.turboPackage) try {
|
|
4910
|
-
const pkg = __require(`${cwd}/package.json`);
|
|
4911
|
-
if (pkg.name) {
|
|
4912
|
-
turboPackage = pkg.name;
|
|
4913
|
-
logger.log(` Turbo package: ${turboPackage}`);
|
|
4914
|
-
}
|
|
4915
|
-
} catch {}
|
|
4916
|
-
const templateOptions = {
|
|
4917
|
-
imageName: dockerConfig.imageName,
|
|
4918
|
-
baseImage: dockerConfig.baseImage,
|
|
4919
|
-
port: dockerConfig.port,
|
|
4920
|
-
healthCheckPath,
|
|
4921
|
-
prebuilt: useSlim,
|
|
4922
|
-
turbo: useTurbo,
|
|
4923
|
-
turboPackage,
|
|
4924
|
-
packageManager
|
|
4925
|
-
};
|
|
4926
|
-
const dockerfile = useSlim ? generateSlimDockerfile(templateOptions) : generateMultiStageDockerfile(templateOptions);
|
|
4927
|
-
const dockerMode = useSlim ? "slim" : useTurbo ? "turbo" : "multi-stage";
|
|
4928
|
-
const dockerfilePath = join(dockerDir, "Dockerfile");
|
|
4929
|
-
await writeFile(dockerfilePath, dockerfile);
|
|
4930
|
-
logger.log(`Generated: .gkm/docker/Dockerfile (${dockerMode}, ${packageManager})`);
|
|
4931
|
-
const composePath = await writeConstructsCompose(loadedConfig.workspace);
|
|
4932
|
-
const dockerignore = generateDockerignore();
|
|
4933
|
-
const dockerignorePath = join(cwd, ".dockerignore");
|
|
4934
|
-
await writeFile(dockerignorePath, dockerignore);
|
|
4935
|
-
logger.log("Generated: .dockerignore (project root)");
|
|
4936
|
-
const entrypoint = generateDockerEntrypoint();
|
|
4937
|
-
const entrypointPath = join(dockerDir, "docker-entrypoint.sh");
|
|
4938
|
-
await writeFile(entrypointPath, entrypoint);
|
|
4939
|
-
logger.log("Generated: .gkm/docker/docker-entrypoint.sh");
|
|
4940
|
-
const result = {
|
|
4941
|
-
dockerfile: dockerfilePath,
|
|
4942
|
-
dockerCompose: composePath,
|
|
4943
|
-
dockerignore: dockerignorePath,
|
|
4944
|
-
entrypoint: entrypointPath
|
|
4945
|
-
};
|
|
4946
|
-
if (options.build) await buildDockerImage(dockerConfig.imageName, options, cwd);
|
|
4947
|
-
if (options.push) await pushDockerImage(dockerConfig.imageName, options, cwd);
|
|
4948
|
-
return result;
|
|
4949
|
-
}
|
|
4950
|
-
/**
|
|
4951
|
-
* Ensure lockfile exists in the build context
|
|
4952
|
-
* For monorepos, copies from workspace root if needed
|
|
4953
|
-
* Returns cleanup function if file was copied
|
|
4954
|
-
*/
|
|
4955
|
-
function ensureLockfile(cwd) {
|
|
4956
|
-
const lockfilePath = findLockfilePath(cwd);
|
|
4957
|
-
if (!lockfilePath) {
|
|
4958
|
-
logger.warn("\n⚠️ No lockfile found. Docker build may fail or use stale dependencies.");
|
|
4959
|
-
return null;
|
|
4960
|
-
}
|
|
4961
|
-
const lockfileName = basename(lockfilePath);
|
|
4962
|
-
const localLockfile = join(cwd, lockfileName);
|
|
4963
|
-
if (lockfilePath === localLockfile) return null;
|
|
4964
|
-
logger.log(` Copying ${lockfileName} from monorepo root...`);
|
|
4965
|
-
copyFileSync(lockfilePath, localLockfile);
|
|
4966
|
-
return () => {
|
|
4967
|
-
try {
|
|
4968
|
-
unlinkSync(localLockfile);
|
|
4969
|
-
} catch {}
|
|
4970
|
-
};
|
|
4971
|
-
}
|
|
4972
|
-
/**
|
|
4973
|
-
* Build Docker image
|
|
4974
|
-
* Uses BuildKit for cache mount support
|
|
4975
|
-
*/
|
|
4976
|
-
async function buildDockerImage(imageName, options, cwd) {
|
|
4977
|
-
const tag = options.tag ?? "latest";
|
|
4978
|
-
const registry = options.registry;
|
|
4979
|
-
const fullImageName = validateImageRef(registry ? `${registry}/${imageName}:${tag}` : `${imageName}:${tag}`);
|
|
4980
|
-
logger.log(`\n🐳 Building Docker image: ${fullImageName}`);
|
|
4981
|
-
const cleanup = ensureLockfile(cwd);
|
|
4982
|
-
try {
|
|
4983
|
-
await run$1("docker", [
|
|
4984
|
-
"build",
|
|
4985
|
-
"--file=.gkm/docker/Dockerfile",
|
|
4986
|
-
`--tag=${fullImageName}`,
|
|
4987
|
-
"."
|
|
4988
|
-
], {
|
|
4989
|
-
cwd,
|
|
4990
|
-
env: {
|
|
4991
|
-
...process.env,
|
|
4992
|
-
DOCKER_BUILDKIT: "1"
|
|
4993
|
-
}
|
|
4994
|
-
});
|
|
4995
|
-
logger.log(`✅ Docker image built: ${fullImageName}`);
|
|
4996
|
-
} catch (error) {
|
|
4997
|
-
throw new DockerBuildFailed(fullImageName, error);
|
|
4998
|
-
} finally {
|
|
4999
|
-
cleanup?.();
|
|
5000
|
-
}
|
|
5001
|
-
}
|
|
5002
|
-
/**
|
|
5003
|
-
* Push Docker image to registry
|
|
5004
|
-
*/
|
|
5005
|
-
async function pushDockerImage(imageName, options, cwd) {
|
|
5006
|
-
const tag = options.tag ?? "latest";
|
|
5007
|
-
const registry = options.registry;
|
|
5008
|
-
if (!registry) throw new PushNeedsRegistry(imageName);
|
|
5009
|
-
const fullImageName = validateImageRef(`${registry}/${imageName}:${tag}`);
|
|
5010
|
-
logger.log(`\n🚀 Pushing Docker image: ${fullImageName}`);
|
|
5011
|
-
try {
|
|
5012
|
-
await run$1("docker", ["push", fullImageName], { cwd });
|
|
5013
|
-
logger.log(`✅ Docker image pushed: ${fullImageName}`);
|
|
5014
|
-
} catch (error) {
|
|
5015
|
-
throw new DockerPushFailed(fullImageName, error);
|
|
5016
|
-
}
|
|
5017
|
-
}
|
|
5018
|
-
/**
|
|
5019
|
-
* Get the package name from package.json in an app directory.
|
|
5020
|
-
*/
|
|
5021
|
-
function getAppPackageName(appPath) {
|
|
5022
|
-
try {
|
|
5023
|
-
const pkgPath = join(appPath, "package.json");
|
|
5024
|
-
if (!existsSync(pkgPath)) return;
|
|
5025
|
-
const content = readFileSync(pkgPath, "utf-8");
|
|
5026
|
-
return JSON.parse(content).name;
|
|
5027
|
-
} catch {
|
|
5028
|
-
return;
|
|
5029
|
-
}
|
|
5030
|
-
}
|
|
5031
|
-
/**
|
|
5032
|
-
* Generate Dockerfiles for all apps in a workspace.
|
|
5033
|
-
* @internal Exported for testing
|
|
5034
|
-
*/
|
|
5035
|
-
async function workspaceDockerCommand(workspace, manifest) {
|
|
5036
|
-
const results = [];
|
|
5037
|
-
const apps = Object.entries(workspace.apps);
|
|
5038
|
-
logger.log(`\n🐳 Generating Dockerfiles for workspace: ${workspace.name}`);
|
|
5039
|
-
const dockerDir = join(workspace.root, ".gkm", "docker");
|
|
5040
|
-
await mkdir(dockerDir, { recursive: true });
|
|
5041
|
-
const packageManager = detectPackageManager(workspace.root);
|
|
5042
|
-
logger.log(` Package manager: ${packageManager}`);
|
|
5043
|
-
for (const [appName, app] of apps) {
|
|
5044
|
-
if (app.type === "mobile") {
|
|
5045
|
-
logger.log(`\n 📱 Skipping Docker for ${appName} (mobile app — deploy via framework toolchain)`);
|
|
5046
|
-
continue;
|
|
5047
|
-
}
|
|
5048
|
-
const appPath = app.path;
|
|
5049
|
-
const turboPackage = getAppPackageName(join(workspace.root, appPath)) ?? appName;
|
|
5050
|
-
const imageName = appName;
|
|
5051
|
-
const buildType = !!app.entry ? "entry" : app.type;
|
|
5052
|
-
logger.log(`\n 📄 Generating Dockerfile for ${appName} (${buildType})`);
|
|
5053
|
-
let dockerfile;
|
|
5054
|
-
if (app.type === "web") dockerfile = siteDockerfile(appName, app, {
|
|
5055
|
-
turboPackage,
|
|
5056
|
-
packageManager,
|
|
5057
|
-
...manifest ? { manifest } : {}
|
|
5058
|
-
});
|
|
5059
|
-
else if (app.entry) dockerfile = generateEntryDockerfile({
|
|
5060
|
-
imageName,
|
|
5061
|
-
baseImage: "node:22-alpine",
|
|
5062
|
-
port: app.port,
|
|
5063
|
-
appPath,
|
|
5064
|
-
entry: app.entry,
|
|
5065
|
-
turboPackage,
|
|
5066
|
-
packageManager,
|
|
5067
|
-
healthCheckPath: "/health"
|
|
5068
|
-
});
|
|
5069
|
-
else dockerfile = generateBackendDockerfile({
|
|
5070
|
-
imageName,
|
|
5071
|
-
baseImage: "node:22-alpine",
|
|
5072
|
-
port: app.port,
|
|
5073
|
-
appPath,
|
|
5074
|
-
turboPackage,
|
|
5075
|
-
packageManager,
|
|
5076
|
-
healthCheckPath: "/health"
|
|
5077
|
-
});
|
|
5078
|
-
const dockerfilePath = join(dockerDir, `Dockerfile.${appName}`);
|
|
5079
|
-
await writeFile(dockerfilePath, dockerfile);
|
|
5080
|
-
logger.log(` Generated: .gkm/docker/Dockerfile.${appName}`);
|
|
5081
|
-
results.push({
|
|
5082
|
-
appName,
|
|
5083
|
-
type: app.type,
|
|
5084
|
-
dockerfile: dockerfilePath,
|
|
5085
|
-
imageName
|
|
5086
|
-
});
|
|
5087
|
-
}
|
|
5088
|
-
const dockerignore = generateDockerignore();
|
|
5089
|
-
const dockerignorePath = join(workspace.root, ".dockerignore");
|
|
5090
|
-
await writeFile(dockerignorePath, dockerignore);
|
|
5091
|
-
logger.log(`\n Generated: .dockerignore (workspace root)`);
|
|
5092
|
-
const composePath = await writeConstructsCompose(workspace);
|
|
5093
|
-
logger.log(`\n✅ Generated ${results.length} Dockerfile(s) + ${COMPOSE_PATH}`);
|
|
5094
|
-
logger.log("\n📋 Build commands:");
|
|
5095
|
-
for (const result of results) {
|
|
5096
|
-
const icon = result.type === "backend" ? "⚙️" : result.type === "mobile" ? "📱" : "🌐";
|
|
5097
|
-
logger.log(` ${icon} docker build -f .gkm/docker/Dockerfile.${result.appName} -t ${result.imageName} .`);
|
|
5098
|
-
}
|
|
5099
|
-
printRunInstructions(workspace);
|
|
5100
|
-
return {
|
|
5101
|
-
apps: results,
|
|
5102
|
-
dockerCompose: composePath,
|
|
5103
|
-
dockerignore: dockerignorePath
|
|
5104
|
-
};
|
|
5105
|
-
}
|
|
5106
|
-
/**
|
|
5107
|
-
* A site's Dockerfile, with an `ARG` for every key its bundler inlines.
|
|
5108
|
-
*
|
|
5109
|
-
* The keys come from the site's declaration where there is one — every
|
|
5110
|
-
* public key its edges give it, `VITE_UPLOADS_SERVER_URL` included — and an
|
|
5111
|
-
* `ARG` the Dockerfile does not declare is a build argument the build drops,
|
|
5112
|
-
* leaving the bundle with an empty URL.
|
|
5113
|
-
*/
|
|
5114
|
-
function siteDockerfile(appName, app, options) {
|
|
5115
|
-
const declared = options.manifest ? Object.entries(options.manifest).find(([id, d]) => d.kind === "site" && appKey(id) === appName)?.[1] : void 0;
|
|
5116
|
-
const publicUrlArgs = declared?.kind === "site" && options.manifest ? Object.keys(publicEnvFor(declared, options.manifest)) : getPublicUrlArgNames(app);
|
|
5117
|
-
const webOpts = {
|
|
5118
|
-
imageName: appName,
|
|
5119
|
-
baseImage: "node:22-alpine",
|
|
5120
|
-
port: app.port,
|
|
5121
|
-
appPath: app.path,
|
|
5122
|
-
turboPackage: options.turboPackage,
|
|
5123
|
-
packageManager: options.packageManager,
|
|
5124
|
-
publicUrlArgs
|
|
5125
|
-
};
|
|
5126
|
-
switch (app.framework) {
|
|
5127
|
-
case "vite": return generateViteStaticDockerfile(webOpts);
|
|
5128
|
-
case "tanstack-start":
|
|
5129
|
-
case "remix": return generateNodeWebDockerfile(webOpts);
|
|
5130
|
-
default: return generateNextjsDockerfile(webOpts);
|
|
5131
|
-
}
|
|
5132
|
-
}
|
|
5133
|
-
/**
|
|
5134
|
-
* The package name turbo prunes an app by: its package.json's, or its name.
|
|
5135
|
-
*/
|
|
5136
|
-
function appPackageName(root, appName, path) {
|
|
5137
|
-
return getAppPackageName(join(root, path)) ?? appName;
|
|
5138
|
-
}
|
|
5139
|
-
/**
|
|
5140
|
-
* Write `docker-compose.constructs.yml` — the same file `gkm dev` writes, with
|
|
5141
|
-
* the apps beside the containers their constructs derive.
|
|
5142
|
-
*
|
|
5143
|
-
* Through reconcile rather than a second generator, so what `gkm docker`
|
|
5144
|
-
* describes and what `gkm dev` runs cannot disagree. Nothing is started.
|
|
5145
|
-
*/
|
|
5146
|
-
async function writeConstructsCompose(workspace) {
|
|
5147
|
-
await reconcileWorkspace(workspace, {
|
|
5148
|
-
stage: workspace.stages.local,
|
|
5149
|
-
start: false
|
|
5150
|
-
});
|
|
5151
|
-
logger.log(` Generated: ${COMPOSE_PATH} (project root)`);
|
|
5152
|
-
return join(workspace.root, COMPOSE_PATH);
|
|
5153
|
-
}
|
|
5154
|
-
function printRunInstructions(workspace) {
|
|
5155
|
-
const project = existsSync(join(workspace.root, "docker-compose.yml")) ? " -f docker-compose.yml" : "";
|
|
5156
|
-
logger.log("\n📋 Run everything (the apps sit behind the \"apps\" profile):");
|
|
5157
|
-
logger.log(" gkm setup # starts the containers and creates what they hold");
|
|
5158
|
-
logger.log(` docker compose -f ${COMPOSE_PATH}${project} --profile apps up --build`);
|
|
5159
|
-
}
|
|
5160
|
-
//#endregion
|
|
5161
|
-
//#region src/deploy/dokploy-api.ts
|
|
5162
|
-
var dokploy_api_exports = /* @__PURE__ */ __exportAll({
|
|
5163
|
-
DEFAULT_DOKPLOY_TIMEOUT_MS: () => DEFAULT_DOKPLOY_TIMEOUT_MS,
|
|
5164
|
-
DokployApi: () => DokployApi,
|
|
5165
|
-
DokployApiError: () => DokployApiError,
|
|
5166
|
-
DokployRequestTimedOut: () => DokployRequestTimedOut
|
|
5167
|
-
});
|
|
5168
|
-
/** Per request, so a stalled server fails the deploy, not the CI job. */
|
|
5169
|
-
const DEFAULT_DOKPLOY_TIMEOUT_MS = 3e4;
|
|
5170
|
-
/**
|
|
5171
|
-
* Dokploy accepted the connection but did not answer in time.
|
|
5172
|
-
*
|
|
5173
|
-
* Not retried: a request that arrived and stalled may still be acted on, and
|
|
5174
|
-
* sending it again could repeat a deploy or a create the server is already
|
|
5175
|
-
* doing.
|
|
5176
|
-
*/
|
|
5177
|
-
var DokployRequestTimedOut = class extends Error {
|
|
5178
|
-
baseUrl;
|
|
5179
|
-
endpoint;
|
|
5180
|
-
timeoutMs;
|
|
5181
|
-
constructor(baseUrl, endpoint, timeoutMs) {
|
|
5182
|
-
super(`Dokploy at ${baseUrl} did not answer ${endpoint} within ${timeoutMs}ms. Check that the Dokploy server is healthy, or raise \`timeoutMs\` if this call is known to be slow.`);
|
|
5183
|
-
this.baseUrl = baseUrl;
|
|
5184
|
-
this.endpoint = endpoint;
|
|
5185
|
-
this.timeoutMs = timeoutMs;
|
|
5186
|
-
this.name = "DokployRequestTimedOut";
|
|
5187
|
-
}
|
|
5188
|
-
};
|
|
5189
|
-
/**
|
|
5190
|
-
* Waits `ms` between retries, or rejects with the signal's reason the moment
|
|
5191
|
-
* it aborts — a caller who gave up should not sit through the backoff.
|
|
5192
|
-
*/
|
|
5193
|
-
function pause(ms, signal) {
|
|
5194
|
-
return new Promise((resolve, reject) => {
|
|
5195
|
-
if (signal?.aborted) {
|
|
5196
|
-
reject(signal.reason);
|
|
5197
|
-
return;
|
|
5198
|
-
}
|
|
5199
|
-
const onAbort = () => {
|
|
5200
|
-
clearTimeout(timer);
|
|
5201
|
-
reject(signal?.reason);
|
|
5202
|
-
};
|
|
5203
|
-
const timer = setTimeout(() => {
|
|
5204
|
-
signal?.removeEventListener("abort", onAbort);
|
|
5205
|
-
resolve();
|
|
5206
|
-
}, ms);
|
|
5207
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
5208
|
-
});
|
|
5209
|
-
}
|
|
5210
|
-
var DokployApiError = class extends Error {
|
|
5211
|
-
status;
|
|
5212
|
-
statusText;
|
|
5213
|
-
issues;
|
|
5214
|
-
constructor(message, status, statusText, issues) {
|
|
5215
|
-
super(message);
|
|
5216
|
-
this.status = status;
|
|
5217
|
-
this.statusText = statusText;
|
|
5218
|
-
this.issues = issues;
|
|
5219
|
-
this.name = "DokployApiError";
|
|
5220
|
-
}
|
|
5221
|
-
};
|
|
5222
|
-
/**
|
|
5223
|
-
* Dokploy API client
|
|
5224
|
-
*/
|
|
5225
|
-
var DokployApi = class {
|
|
5226
|
-
baseUrl;
|
|
5227
|
-
token;
|
|
5228
|
-
timeoutMs;
|
|
5229
|
-
signal;
|
|
5230
|
-
constructor(options) {
|
|
5231
|
-
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
|
5232
|
-
this.token = options.token;
|
|
5233
|
-
this.timeoutMs = options.timeoutMs ?? 3e4;
|
|
5234
|
-
this.signal = options.signal;
|
|
5235
|
-
}
|
|
5236
|
-
/**
|
|
5237
|
-
* Make a GET request to the Dokploy API
|
|
5238
|
-
*/
|
|
5239
|
-
async get(endpoint) {
|
|
5240
|
-
return this.request("GET", endpoint);
|
|
5241
|
-
}
|
|
5242
|
-
/**
|
|
5243
|
-
* Make a POST request to the Dokploy API
|
|
5244
|
-
*/
|
|
5245
|
-
async post(endpoint, body) {
|
|
5246
|
-
return this.request("POST", endpoint, body);
|
|
5247
|
-
}
|
|
5248
|
-
/**
|
|
5249
|
-
* Make a request to the Dokploy API
|
|
5250
|
-
*/
|
|
5251
|
-
async request(method, endpoint, body) {
|
|
5252
|
-
const url = `${this.baseUrl}/api/${endpoint}`;
|
|
5253
|
-
let response;
|
|
5254
|
-
let timeout;
|
|
5255
|
-
let lastError;
|
|
5256
|
-
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
5257
|
-
this.signal?.throwIfAborted();
|
|
5258
|
-
timeout = AbortSignal.timeout(this.timeoutMs);
|
|
5259
|
-
try {
|
|
5260
|
-
response = await fetch(url, {
|
|
5261
|
-
method,
|
|
5262
|
-
headers: {
|
|
5263
|
-
"Content-Type": "application/json",
|
|
5264
|
-
"x-api-key": this.token
|
|
5265
|
-
},
|
|
5266
|
-
body: body ? JSON.stringify(body) : void 0,
|
|
5267
|
-
signal: this.signal ? AbortSignal.any([this.signal, timeout]) : timeout
|
|
5268
|
-
});
|
|
5269
|
-
break;
|
|
5270
|
-
} catch (error) {
|
|
5271
|
-
const stopped = this.stopped(timeout, endpoint);
|
|
5272
|
-
if (stopped) throw stopped;
|
|
5273
|
-
lastError = error;
|
|
5274
|
-
if (attempt === 3) break;
|
|
5275
|
-
await pause(attempt * 750, this.signal);
|
|
5276
|
-
}
|
|
5277
|
-
}
|
|
5278
|
-
if (!response || !timeout) {
|
|
5279
|
-
const detail = lastError instanceof Error ? lastError.message : String(lastError);
|
|
5280
|
-
throw new DokployApiError(`Could not reach Dokploy at ${this.baseUrl} (${endpoint}): ${detail}`, 0, "Network error");
|
|
5281
|
-
}
|
|
5282
|
-
try {
|
|
5283
|
-
return await this.read(response);
|
|
5284
|
-
} catch (error) {
|
|
5285
|
-
throw this.stopped(timeout, endpoint) ?? error;
|
|
5286
|
-
}
|
|
5287
|
-
}
|
|
5288
|
-
/**
|
|
5289
|
-
* What a request cut short should reject with: the caller's own reason
|
|
5290
|
-
* when they aborted, `DokployRequestTimedOut` when the deadline passed,
|
|
5291
|
-
* nothing when it was neither.
|
|
5292
|
-
*/
|
|
5293
|
-
stopped(timeout, endpoint) {
|
|
5294
|
-
if (this.signal?.aborted) return this.signal.reason;
|
|
5295
|
-
if (timeout.aborted) return new DokployRequestTimedOut(this.baseUrl, endpoint, this.timeoutMs);
|
|
5296
|
-
}
|
|
5297
|
-
/** Turns a response into its JSON body, or a `DokployApiError`. */
|
|
5298
|
-
async read(response) {
|
|
5299
|
-
if (!response.ok) {
|
|
5300
|
-
let errorMessage = `Dokploy API error: ${response.status} ${response.statusText}`;
|
|
5301
|
-
let issues;
|
|
5302
|
-
try {
|
|
5303
|
-
const errorBody = await response.json();
|
|
5304
|
-
if (errorBody.message) errorMessage = `Dokploy API error: ${errorBody.message}`;
|
|
5305
|
-
if (errorBody.issues?.length) {
|
|
5306
|
-
issues = errorBody.issues;
|
|
5307
|
-
errorMessage += `\n Issues: ${errorBody.issues.map((i) => i.message).join(", ")}`;
|
|
5308
|
-
}
|
|
5309
|
-
} catch {}
|
|
5310
|
-
throw new DokployApiError(errorMessage, response.status, response.statusText, issues);
|
|
5311
|
-
}
|
|
5312
|
-
const text = await response.text();
|
|
5313
|
-
if (!text || text.trim() === "") return;
|
|
5314
|
-
return JSON.parse(text);
|
|
5315
|
-
}
|
|
5316
|
-
/**
|
|
5317
|
-
* Every external port published on this server, across every project.
|
|
5318
|
-
*
|
|
5319
|
-
* Dokploy knows what it has bound; guessing does not. A port that looks free
|
|
5320
|
-
* from here can be held by a service in a project this deploy has never
|
|
5321
|
-
* heard of — which surfaces as `Port 5432 is already in use by container
|
|
5322
|
-
* "…"`, naming something the caller cannot see.
|
|
5323
|
-
*
|
|
5324
|
-
* `project.all` is a summary, so each project is fetched: the per-service
|
|
5325
|
-
* `externalPort` only appears on `project.one`.
|
|
5326
|
-
*/
|
|
5327
|
-
async publishedPorts() {
|
|
5328
|
-
const ports = /* @__PURE__ */ new Set();
|
|
5329
|
-
const projects = await this.listProjects();
|
|
5330
|
-
for (const project of projects) {
|
|
5331
|
-
const details = await this.getProject(project.projectId);
|
|
5332
|
-
for (const environment of details.environments ?? []) {
|
|
5333
|
-
const resources = [
|
|
5334
|
-
...environment.postgres ?? [],
|
|
5335
|
-
...environment.redis ?? [],
|
|
5336
|
-
...environment.applications ?? []
|
|
5337
|
-
];
|
|
5338
|
-
for (const resource of resources) if (typeof resource.externalPort === "number") ports.add(resource.externalPort);
|
|
5339
|
-
}
|
|
5340
|
-
}
|
|
5341
|
-
return ports;
|
|
5342
|
-
}
|
|
5343
|
-
/**
|
|
5344
|
-
* Validate the API token by making a test request
|
|
5345
|
-
*/
|
|
5346
|
-
async validateToken() {
|
|
5347
|
-
try {
|
|
5348
|
-
await this.get("project.all");
|
|
5349
|
-
return true;
|
|
5350
|
-
} catch {
|
|
5351
|
-
return false;
|
|
5352
|
-
}
|
|
5353
|
-
}
|
|
5354
|
-
/**
|
|
5355
|
-
* List all projects
|
|
5356
|
-
*/
|
|
5357
|
-
async listProjects() {
|
|
5358
|
-
return this.get("project.all");
|
|
5359
|
-
}
|
|
5360
|
-
/**
|
|
5361
|
-
* Get a single project by ID
|
|
5362
|
-
*/
|
|
5363
|
-
async getProject(projectId) {
|
|
5364
|
-
return this.get(`project.one?projectId=${projectId}`);
|
|
5365
|
-
}
|
|
5366
|
-
/**
|
|
5367
|
-
* Create a new project
|
|
5368
|
-
*/
|
|
5369
|
-
async createProject(name, description) {
|
|
5370
|
-
return this.post("project.create", {
|
|
5371
|
-
name,
|
|
5372
|
-
description: description ?? `Created by gkm CLI`
|
|
5373
|
-
});
|
|
5374
|
-
}
|
|
5375
|
-
/**
|
|
5376
|
-
* Change a project's name or description.
|
|
5377
|
-
*
|
|
5378
|
-
* The name is sent with the description because `project.update` validates
|
|
5379
|
-
* the whole project on some Dokploy versions, and a missing name fails it.
|
|
5380
|
-
*/
|
|
5381
|
-
async updateProject(projectId, updates) {
|
|
5382
|
-
await this.post("project.update", {
|
|
5383
|
-
projectId,
|
|
5384
|
-
...updates
|
|
5385
|
-
});
|
|
5386
|
-
}
|
|
5387
|
-
/**
|
|
5388
|
-
* Delete a project and all its resources
|
|
5389
|
-
*/
|
|
5390
|
-
async deleteProject(projectId) {
|
|
5391
|
-
await this.post("project.remove", { projectId });
|
|
5392
|
-
}
|
|
5393
|
-
/**
|
|
5394
|
-
* Create an environment in a project
|
|
5395
|
-
*/
|
|
5396
|
-
async createEnvironment(projectId, name, description) {
|
|
5397
|
-
return this.post("environment.create", {
|
|
5398
|
-
projectId,
|
|
5399
|
-
name,
|
|
5400
|
-
description: description ?? `${name} environment`
|
|
5401
|
-
});
|
|
5402
|
-
}
|
|
5403
|
-
/**
|
|
5404
|
-
* List all applications in a project
|
|
5405
|
-
*/
|
|
5406
|
-
async listApplications(projectId) {
|
|
5407
|
-
return this.listInProject(projectId, (env) => env.applications);
|
|
5408
|
-
}
|
|
5409
|
-
/**
|
|
5410
|
-
* Everything of one kind in a project, across its environments.
|
|
5411
|
-
*
|
|
5412
|
-
* From `project.one`, because the per-kind `*.all?projectId=` endpoints
|
|
5413
|
-
* return **404** on current Dokploy: resources live under environments now.
|
|
5414
|
-
* Those calls used to be wrapped in `catch { return [] }`, which turned "I
|
|
5415
|
-
* could not ask" into "there are none" — and for a find-or-create that is
|
|
5416
|
-
* the worst available answer. Every deploy created a second application, a
|
|
5417
|
-
* second Postgres and a second Redis, silently, for as long as the project
|
|
5418
|
-
* existed.
|
|
5419
|
-
*
|
|
5420
|
-
* A failure here is not swallowed: creating a duplicate is worse than
|
|
5421
|
-
* stopping.
|
|
5422
|
-
*/
|
|
5423
|
-
async listInProject(projectId, pick) {
|
|
5424
|
-
return ((await this.getProject(projectId)).environments ?? []).flatMap((environment) => (pick(environment) ?? []).map((resource) => ({
|
|
5425
|
-
...resource,
|
|
5426
|
-
environmentId: environment.environmentId
|
|
5427
|
-
})));
|
|
5428
|
-
}
|
|
5429
|
-
/**
|
|
5430
|
-
* Find an application by name in a project
|
|
5431
|
-
*/
|
|
5432
|
-
async findApplicationByName(projectId, name, environmentId) {
|
|
5433
|
-
const applications = await this.listApplications(projectId);
|
|
5434
|
-
const normalizedName = name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
5435
|
-
return applications.find((app) => (!environmentId || app.environmentId === environmentId) && (app.name === name || app.appName === normalizedName));
|
|
5436
|
-
}
|
|
5437
|
-
/**
|
|
5438
|
-
* Create a new application
|
|
5439
|
-
*/
|
|
5440
|
-
async createApplication(name, projectId, environmentId) {
|
|
5441
|
-
return this.post("application.create", {
|
|
5442
|
-
name,
|
|
5443
|
-
projectId,
|
|
5444
|
-
environmentId,
|
|
5445
|
-
appName: name.toLowerCase().replace(/[^a-z0-9-]/g, "-")
|
|
5446
|
-
});
|
|
5447
|
-
}
|
|
5448
|
-
/**
|
|
5449
|
-
* Find or create an application by name
|
|
5450
|
-
*/
|
|
5451
|
-
async findOrCreateApplication(name, projectId, environmentId) {
|
|
5452
|
-
const existing = await this.findApplicationByName(projectId, name, environmentId);
|
|
5453
|
-
if (existing) return {
|
|
5454
|
-
application: existing,
|
|
5455
|
-
created: false
|
|
5456
|
-
};
|
|
5457
|
-
return {
|
|
5458
|
-
application: await this.createApplication(name, projectId, environmentId),
|
|
5459
|
-
created: true
|
|
5460
|
-
};
|
|
5461
|
-
}
|
|
5462
|
-
/**
|
|
5463
|
-
* Get an application by ID
|
|
5464
|
-
*/
|
|
5465
|
-
async getApplication(applicationId) {
|
|
5466
|
-
try {
|
|
5467
|
-
return await this.get(`application.one?applicationId=${applicationId}`);
|
|
5468
|
-
} catch {
|
|
5469
|
-
return null;
|
|
5470
|
-
}
|
|
5471
|
-
}
|
|
5472
|
-
/**
|
|
5473
|
-
* Update an application
|
|
5474
|
-
*/
|
|
5475
|
-
async updateApplication(applicationId, updates) {
|
|
5476
|
-
await this.post("application.update", {
|
|
5477
|
-
applicationId,
|
|
5478
|
-
...updates
|
|
5479
|
-
});
|
|
5480
|
-
}
|
|
5481
|
-
/**
|
|
5482
|
-
* Save environment variables for an application
|
|
5483
|
-
*/
|
|
5484
|
-
async saveApplicationEnv(applicationId, env, options) {
|
|
5485
|
-
await this.post("application.saveEnvironment", {
|
|
5486
|
-
applicationId,
|
|
5487
|
-
env,
|
|
5488
|
-
buildArgs: options?.buildArgs ?? null,
|
|
5489
|
-
buildSecrets: options?.buildSecrets ?? null,
|
|
5490
|
-
createEnvFile: false
|
|
5491
|
-
});
|
|
5492
|
-
}
|
|
5493
|
-
/**
|
|
5494
|
-
* Configure application to use Docker provider (pull from registry)
|
|
5495
|
-
*
|
|
5496
|
-
* For private registries, either:
|
|
5497
|
-
* - Use `registryId` if the registry is configured in Dokploy
|
|
5498
|
-
* - Or provide `username`, `password`, and `registryUrl` directly
|
|
5499
|
-
*/
|
|
5500
|
-
async saveDockerProvider(applicationId, dockerImage, options) {
|
|
5501
|
-
await this.post("application.saveDockerProvider", {
|
|
5502
|
-
applicationId,
|
|
5503
|
-
dockerImage,
|
|
5504
|
-
...options,
|
|
5505
|
-
username: options?.username ?? null,
|
|
5506
|
-
password: options?.password ?? null,
|
|
5507
|
-
registryUrl: options?.registryUrl ?? null
|
|
5508
|
-
});
|
|
5509
|
-
}
|
|
5510
|
-
/**
|
|
5511
|
-
* Deploy an application
|
|
5512
|
-
*/
|
|
5513
|
-
async deployApplication(applicationId) {
|
|
5514
|
-
await this.post("application.deploy", { applicationId });
|
|
5515
|
-
}
|
|
5516
|
-
/**
|
|
5517
|
-
* Delete an application
|
|
5518
|
-
*/
|
|
5519
|
-
async deleteApplication(applicationId) {
|
|
5520
|
-
await this.post("application.remove", { applicationId });
|
|
5521
|
-
}
|
|
5522
|
-
/**
|
|
5523
|
-
* List all registries
|
|
5524
|
-
*/
|
|
5525
|
-
async listRegistries() {
|
|
5526
|
-
return this.get("registry.all");
|
|
5527
|
-
}
|
|
5528
|
-
/**
|
|
5529
|
-
* Create a new registry
|
|
5530
|
-
*/
|
|
5531
|
-
async createRegistry(registryName, registryUrl, username, password, options) {
|
|
5532
|
-
return this.post("registry.create", {
|
|
5533
|
-
registryName,
|
|
5534
|
-
registryUrl,
|
|
5535
|
-
username,
|
|
5536
|
-
password,
|
|
5537
|
-
imagePrefix: options?.imagePrefix
|
|
5538
|
-
});
|
|
5539
|
-
}
|
|
5540
|
-
/**
|
|
5541
|
-
* Get a registry by ID
|
|
5542
|
-
*/
|
|
5543
|
-
async getRegistry(registryId) {
|
|
5544
|
-
return this.get(`registry.one?registryId=${registryId}`);
|
|
5545
|
-
}
|
|
5546
|
-
/**
|
|
5547
|
-
* Update a registry
|
|
5548
|
-
*/
|
|
5549
|
-
async updateRegistry(registryId, updates) {
|
|
5550
|
-
await this.post("registry.update", {
|
|
5551
|
-
registryId,
|
|
5552
|
-
...updates
|
|
5553
|
-
});
|
|
5554
|
-
}
|
|
5555
|
-
/**
|
|
5556
|
-
* Delete a registry
|
|
5557
|
-
*/
|
|
5558
|
-
async deleteRegistry(registryId) {
|
|
5559
|
-
await this.post("registry.remove", { registryId });
|
|
5560
|
-
}
|
|
5561
|
-
/**
|
|
5562
|
-
* List all Postgres databases in a project
|
|
5563
|
-
*/
|
|
5564
|
-
async listPostgres(projectId) {
|
|
5565
|
-
return this.listInProject(projectId, (env) => env.postgres);
|
|
5566
|
-
}
|
|
5567
|
-
/**
|
|
5568
|
-
* Find a Postgres database by name in a project
|
|
5569
|
-
*/
|
|
5570
|
-
async findPostgresByName(projectId, name) {
|
|
5571
|
-
const databases = await this.listPostgres(projectId);
|
|
5572
|
-
const normalizedName = name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
5573
|
-
const match = databases.find((db) => db.name === name || db.appName === normalizedName);
|
|
5574
|
-
if (!match) return void 0;
|
|
5575
|
-
return this.getPostgres(match.postgresId);
|
|
5576
|
-
}
|
|
5577
|
-
/**
|
|
5578
|
-
* Create a new Postgres database
|
|
5579
|
-
*/
|
|
5580
|
-
async createPostgres(name, projectId, environmentId, options) {
|
|
5581
|
-
return this.post("postgres.create", {
|
|
5582
|
-
name,
|
|
5583
|
-
projectId,
|
|
5584
|
-
environmentId,
|
|
5585
|
-
appName: options?.appName ?? name.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
|
|
5586
|
-
databaseName: options?.databaseName,
|
|
5587
|
-
databaseUser: options?.databaseUser ?? "postgres",
|
|
5588
|
-
databasePassword: options?.databasePassword,
|
|
5589
|
-
dockerImage: options?.dockerImage ?? "postgres:18",
|
|
5590
|
-
description: options?.description ?? `Postgres database for ${name}`
|
|
5591
|
-
});
|
|
5592
|
-
}
|
|
5593
|
-
/**
|
|
5594
|
-
* Find or create a Postgres database by name
|
|
5595
|
-
*/
|
|
5596
|
-
async findOrCreatePostgres(name, projectId, environmentId, options) {
|
|
5597
|
-
const existing = await this.findPostgresByName(projectId, name);
|
|
5598
|
-
if (existing) return {
|
|
5599
|
-
postgres: existing,
|
|
5600
|
-
created: false
|
|
5601
|
-
};
|
|
5602
|
-
return {
|
|
5603
|
-
postgres: await this.createPostgres(name, projectId, environmentId, options),
|
|
5604
|
-
created: true
|
|
5605
|
-
};
|
|
5606
|
-
}
|
|
5607
|
-
/**
|
|
5608
|
-
* Get a Postgres database by ID
|
|
5609
|
-
*/
|
|
5610
|
-
async getPostgres(postgresId) {
|
|
5611
|
-
return this.get(`postgres.one?postgresId=${postgresId}`);
|
|
5612
|
-
}
|
|
5613
|
-
/**
|
|
5614
|
-
* Deploy a Postgres database
|
|
5615
|
-
*/
|
|
5616
|
-
async deployPostgres(postgresId) {
|
|
5617
|
-
await this.post("postgres.deploy", { postgresId });
|
|
5618
|
-
}
|
|
5619
|
-
/**
|
|
5620
|
-
* Save environment variables for Postgres
|
|
5621
|
-
*/
|
|
5622
|
-
async savePostgresEnv(postgresId, env) {
|
|
5623
|
-
await this.post("postgres.saveEnvironment", {
|
|
5624
|
-
postgresId,
|
|
5625
|
-
env
|
|
5626
|
-
});
|
|
5627
|
-
}
|
|
5628
|
-
/**
|
|
5629
|
-
* Set external port for Postgres (for external access)
|
|
5630
|
-
*/
|
|
5631
|
-
async savePostgresExternalPort(postgresId, externalPort) {
|
|
5632
|
-
await this.post("postgres.saveExternalPort", {
|
|
5633
|
-
postgresId,
|
|
5634
|
-
externalPort
|
|
5635
|
-
});
|
|
5636
|
-
}
|
|
5637
|
-
/**
|
|
5638
|
-
* Update Postgres configuration
|
|
5639
|
-
*/
|
|
5640
|
-
async updatePostgres(postgresId, updates) {
|
|
5641
|
-
await this.post("postgres.update", {
|
|
5642
|
-
postgresId,
|
|
5643
|
-
...updates
|
|
5644
|
-
});
|
|
5645
|
-
}
|
|
5646
|
-
/**
|
|
5647
|
-
* Delete a Postgres database
|
|
5648
|
-
*/
|
|
5649
|
-
async deletePostgres(postgresId) {
|
|
5650
|
-
await this.post("postgres.remove", { postgresId });
|
|
5651
|
-
}
|
|
5652
|
-
/**
|
|
5653
|
-
* The compose stacks in a project.
|
|
5654
|
-
*
|
|
5655
|
-
* Dokploy has first-class primitives for Postgres and Redis and none for
|
|
5656
|
-
* MinIO, so an object store is a compose stack — the one kind whose contents
|
|
5657
|
-
* this target writes rather than configures.
|
|
5658
|
-
*/
|
|
5659
|
-
async listCompose(projectId) {
|
|
5660
|
-
return this.listInProject(projectId, (env) => env.compose);
|
|
5661
|
-
}
|
|
5662
|
-
/** A compose stack by name, hydrated — `project.one` returns summaries. */
|
|
5663
|
-
async findComposeByName(projectId, name) {
|
|
5664
|
-
const summary = (await this.listCompose(projectId)).find((compose) => compose.name === name);
|
|
5665
|
-
return summary ? this.getCompose(summary.composeId) : void 0;
|
|
5666
|
-
}
|
|
5667
|
-
async getCompose(composeId) {
|
|
5668
|
-
return this.get(`compose.one?composeId=${composeId}`);
|
|
5669
|
-
}
|
|
5670
|
-
/**
|
|
5671
|
-
* A compose stack carrying the file this target wrote.
|
|
5672
|
-
*
|
|
5673
|
-
* Created empty and then updated, because `compose.create` takes no file:
|
|
5674
|
-
* it defaults to `sourceType: 'github'` and expects a repository. `raw` is
|
|
5675
|
-
* what makes the file ours.
|
|
5676
|
-
*/
|
|
5677
|
-
async findOrCreateCompose(name, projectId, environmentId, composeFile) {
|
|
5678
|
-
const existing = await this.findComposeByName(projectId, name);
|
|
5679
|
-
const compose = existing ?? await this.post("compose.create", {
|
|
5680
|
-
name,
|
|
5681
|
-
environmentId,
|
|
5682
|
-
composeType: "docker-compose"
|
|
5683
|
-
});
|
|
5684
|
-
await this.post("compose.update", {
|
|
5685
|
-
composeId: compose.composeId,
|
|
5686
|
-
sourceType: "raw",
|
|
5687
|
-
composeFile
|
|
5688
|
-
});
|
|
5689
|
-
return {
|
|
5690
|
-
compose,
|
|
5691
|
-
created: !existing
|
|
5692
|
-
};
|
|
5693
|
-
}
|
|
5694
|
-
async deployCompose(composeId) {
|
|
5695
|
-
await this.post("compose.deploy", { composeId });
|
|
5696
|
-
}
|
|
5697
|
-
async deleteCompose(composeId) {
|
|
5698
|
-
await this.post("compose.delete", {
|
|
5699
|
-
composeId,
|
|
5700
|
-
deleteVolumes: false
|
|
5701
|
-
});
|
|
5702
|
-
}
|
|
5703
|
-
/**
|
|
5704
|
-
* List all Redis instances in a project
|
|
5705
|
-
*/
|
|
5706
|
-
async listRedis(projectId) {
|
|
5707
|
-
return this.listInProject(projectId, (env) => env.redis);
|
|
5708
|
-
}
|
|
5709
|
-
/**
|
|
5710
|
-
* Find a Redis instance by name in a project
|
|
5711
|
-
*/
|
|
5712
|
-
async findRedisByName(projectId, name) {
|
|
5713
|
-
const instances = await this.listRedis(projectId);
|
|
5714
|
-
const normalizedName = name.toLowerCase().replace(/[^a-z0-9-]/g, "-");
|
|
5715
|
-
const match = instances.find((redis) => redis.name === name || redis.appName === normalizedName);
|
|
5716
|
-
return match ? this.getRedis(match.redisId) : void 0;
|
|
5717
|
-
}
|
|
5718
|
-
/**
|
|
5719
|
-
* Create a new Redis instance
|
|
5720
|
-
*/
|
|
5721
|
-
async createRedis(name, projectId, environmentId, options) {
|
|
5722
|
-
return this.post("redis.create", {
|
|
5723
|
-
name,
|
|
5724
|
-
projectId,
|
|
5725
|
-
environmentId,
|
|
5726
|
-
appName: options?.appName ?? name.toLowerCase().replace(/[^a-z0-9-]/g, "-"),
|
|
5727
|
-
databasePassword: options?.databasePassword,
|
|
5728
|
-
dockerImage: options?.dockerImage ?? "redis:8",
|
|
5729
|
-
description: options?.description ?? `Redis instance for ${name}`
|
|
5730
|
-
});
|
|
5731
|
-
}
|
|
5732
|
-
/**
|
|
5733
|
-
* Find or create a Redis instance by name
|
|
5734
|
-
*/
|
|
5735
|
-
async findOrCreateRedis(name, projectId, environmentId, options) {
|
|
5736
|
-
const existing = await this.findRedisByName(projectId, name);
|
|
5737
|
-
if (existing) return {
|
|
5738
|
-
redis: existing,
|
|
5739
|
-
created: false
|
|
5740
|
-
};
|
|
5741
|
-
return {
|
|
5742
|
-
redis: await this.createRedis(name, projectId, environmentId, options),
|
|
5743
|
-
created: true
|
|
5744
|
-
};
|
|
5745
|
-
}
|
|
5746
|
-
/**
|
|
5747
|
-
* Get a Redis instance by ID
|
|
5748
|
-
*/
|
|
5749
|
-
async getRedis(redisId) {
|
|
5750
|
-
return this.get(`redis.one?redisId=${redisId}`);
|
|
5751
|
-
}
|
|
5752
|
-
/**
|
|
5753
|
-
* Deploy a Redis instance
|
|
5754
|
-
*/
|
|
5755
|
-
async deployRedis(redisId) {
|
|
5756
|
-
await this.post("redis.deploy", { redisId });
|
|
5757
|
-
}
|
|
5758
|
-
/**
|
|
5759
|
-
* Save environment variables for Redis
|
|
5760
|
-
*/
|
|
5761
|
-
async saveRedisEnv(redisId, env) {
|
|
5762
|
-
await this.post("redis.saveEnvironment", {
|
|
5763
|
-
redisId,
|
|
5764
|
-
env
|
|
5765
|
-
});
|
|
5766
|
-
}
|
|
5767
|
-
/**
|
|
5768
|
-
* Set external port for Redis (for external access)
|
|
5769
|
-
*/
|
|
5770
|
-
async saveRedisExternalPort(redisId, externalPort) {
|
|
5771
|
-
await this.post("redis.saveExternalPort", {
|
|
5772
|
-
redisId,
|
|
5773
|
-
externalPort
|
|
5774
|
-
});
|
|
5775
|
-
}
|
|
5776
|
-
/**
|
|
5777
|
-
* Update Redis configuration
|
|
5778
|
-
*/
|
|
5779
|
-
async updateRedis(redisId, updates) {
|
|
5780
|
-
await this.post("redis.update", {
|
|
5781
|
-
redisId,
|
|
5782
|
-
...updates
|
|
5783
|
-
});
|
|
5784
|
-
}
|
|
5785
|
-
/**
|
|
5786
|
-
* Delete a Redis instance
|
|
5787
|
-
*/
|
|
5788
|
-
async deleteRedis(redisId) {
|
|
5789
|
-
await this.post("redis.remove", { redisId });
|
|
5790
|
-
}
|
|
5791
|
-
/**
|
|
5792
|
-
* Create a new domain for an application
|
|
5793
|
-
*/
|
|
5794
|
-
async createDomain(options) {
|
|
5795
|
-
return this.post("domain.create", options);
|
|
5796
|
-
}
|
|
5797
|
-
/**
|
|
5798
|
-
* Update an existing domain
|
|
5799
|
-
*/
|
|
5800
|
-
async updateDomain(domainId, updates) {
|
|
5801
|
-
await this.post("domain.update", {
|
|
5802
|
-
domainId,
|
|
5803
|
-
...updates
|
|
5804
|
-
});
|
|
5805
|
-
}
|
|
5806
|
-
/**
|
|
5807
|
-
* Delete a domain
|
|
5808
|
-
*/
|
|
5809
|
-
async deleteDomain(domainId) {
|
|
5810
|
-
await this.post("domain.delete", { domainId });
|
|
5811
|
-
}
|
|
5812
|
-
/**
|
|
5813
|
-
* Get a domain by ID
|
|
5814
|
-
*/
|
|
5815
|
-
async getDomain(domainId) {
|
|
5816
|
-
return this.get(`domain.one?domainId=${domainId}`);
|
|
5817
|
-
}
|
|
5818
|
-
/**
|
|
5819
|
-
* Get all domains for an application
|
|
5820
|
-
*/
|
|
5821
|
-
async getDomainsByApplicationId(applicationId) {
|
|
5822
|
-
return this.get(`domain.byApplicationId?applicationId=${applicationId}`);
|
|
5823
|
-
}
|
|
5824
|
-
/**
|
|
5825
|
-
* Validate a domain and trigger SSL certificate generation
|
|
5826
|
-
*
|
|
5827
|
-
* This should be called after DNS records are created and propagated.
|
|
5828
|
-
* It triggers Let's Encrypt certificate generation for HTTPS domains.
|
|
5829
|
-
*
|
|
5830
|
-
* @param domain - The domain hostname to validate (e.g., 'api.example.com')
|
|
5831
|
-
*/
|
|
5832
|
-
async validateDomain(domain) {
|
|
5833
|
-
return this.post("domain.validateDomain", { domain });
|
|
4638
|
+
super(`Failed to build Docker image: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
4639
|
+
this.imageRef = imageRef;
|
|
4640
|
+
this.name = "DockerBuildFailed";
|
|
5834
4641
|
}
|
|
5835
|
-
|
|
5836
|
-
|
|
5837
|
-
|
|
5838
|
-
|
|
5839
|
-
|
|
5840
|
-
|
|
5841
|
-
|
|
5842
|
-
|
|
4642
|
+
};
|
|
4643
|
+
/** `docker push` failed — usually the registry login or its permissions. */
|
|
4644
|
+
var DockerPushFailed = class extends Error {
|
|
4645
|
+
imageRef;
|
|
4646
|
+
constructor(imageRef, cause) {
|
|
4647
|
+
super(`Failed to push Docker image: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
4648
|
+
this.imageRef = imageRef;
|
|
4649
|
+
this.name = "DockerPushFailed";
|
|
5843
4650
|
}
|
|
5844
|
-
|
|
5845
|
-
|
|
5846
|
-
|
|
5847
|
-
|
|
5848
|
-
|
|
4651
|
+
};
|
|
4652
|
+
/** `gkm docker --push` was asked for with nowhere to push to. */
|
|
4653
|
+
var PushNeedsRegistry = class extends Error {
|
|
4654
|
+
imageName;
|
|
4655
|
+
constructor(imageName) {
|
|
4656
|
+
super(`Registry is required to push Docker image '${imageName}'. Use --registry or configure docker.registry in gkm.config.ts`);
|
|
4657
|
+
this.imageName = imageName;
|
|
4658
|
+
this.name = "PushNeedsRegistry";
|
|
5849
4659
|
}
|
|
5850
|
-
|
|
5851
|
-
|
|
5852
|
-
|
|
5853
|
-
|
|
5854
|
-
|
|
4660
|
+
};
|
|
4661
|
+
/**
|
|
4662
|
+
* Docker command implementation
|
|
4663
|
+
* Generates Dockerfile, docker-compose.yml, and related files
|
|
4664
|
+
*
|
|
4665
|
+
* Default: Multi-stage Dockerfile that builds from source inside Docker
|
|
4666
|
+
* --slim: Slim Dockerfile that copies pre-built bundle (requires prior build)
|
|
4667
|
+
*/
|
|
4668
|
+
async function dockerCommand(options) {
|
|
4669
|
+
const cwd = options.cwd ?? process.cwd();
|
|
4670
|
+
const loadedConfig = await loadWorkspaceConfig(cwd);
|
|
4671
|
+
if (loadedConfig.type === "workspace") {
|
|
4672
|
+
logger.log("📦 Detected workspace configuration");
|
|
4673
|
+
return workspaceDockerCommand(loadedConfig.workspace, loadedConfig.manifest);
|
|
5855
4674
|
}
|
|
5856
|
-
|
|
5857
|
-
|
|
5858
|
-
|
|
5859
|
-
|
|
5860
|
-
|
|
4675
|
+
const dockerConfig = resolveDockerConfig(await loadConfig(cwd));
|
|
4676
|
+
const healthCheckPath = "/health";
|
|
4677
|
+
const useSlim = options.slim === true;
|
|
4678
|
+
if (useSlim) {
|
|
4679
|
+
const distDir = join(cwd, ".gkm", "server", "dist");
|
|
4680
|
+
if (!existsSync(join(distDir, "server.mjs"))) throw new Error("Slim Dockerfile requires a pre-built bundle. Run `gkm build --provider server --production` first, or omit --slim to use multi-stage build.");
|
|
5861
4681
|
}
|
|
5862
|
-
|
|
5863
|
-
|
|
5864
|
-
|
|
5865
|
-
|
|
5866
|
-
|
|
4682
|
+
const dockerDir = join(cwd, ".gkm", "docker");
|
|
4683
|
+
await mkdir(dockerDir, { recursive: true });
|
|
4684
|
+
const packageManager = detectPackageManager(cwd);
|
|
4685
|
+
const inMonorepo = isMonorepo(cwd);
|
|
4686
|
+
const hasTurbo = hasTurboConfig(cwd);
|
|
4687
|
+
let useTurbo = options.turbo ?? false;
|
|
4688
|
+
if (inMonorepo && !useSlim) {
|
|
4689
|
+
if (hasTurbo) {
|
|
4690
|
+
useTurbo = true;
|
|
4691
|
+
logger.log(" Detected monorepo with turbo.json - using turbo prune");
|
|
4692
|
+
} else throw new Error("Monorepo detected but turbo.json not found.\n\nDocker builds in monorepos require Turborepo for proper dependency isolation.\n\nTo fix this:\n 1. Install turbo: pnpm add -Dw turbo\n 2. Create turbo.json in your monorepo root\n 3. Run this command again\n\nSee: https://turbo.build/repo/docs/guides/tools/docker");
|
|
5867
4693
|
}
|
|
5868
|
-
|
|
5869
|
-
|
|
5870
|
-
|
|
5871
|
-
|
|
5872
|
-
|
|
5873
|
-
|
|
5874
|
-
|
|
5875
|
-
|
|
5876
|
-
|
|
5877
|
-
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
4694
|
+
let turboPackage = options.turboPackage ?? dockerConfig.imageName;
|
|
4695
|
+
if (useTurbo && !options.turboPackage) try {
|
|
4696
|
+
const pkg = __require(`${cwd}/package.json`);
|
|
4697
|
+
if (pkg.name) {
|
|
4698
|
+
turboPackage = pkg.name;
|
|
4699
|
+
logger.log(` Turbo package: ${turboPackage}`);
|
|
4700
|
+
}
|
|
4701
|
+
} catch {}
|
|
4702
|
+
const templateOptions = {
|
|
4703
|
+
imageName: dockerConfig.imageName,
|
|
4704
|
+
baseImage: dockerConfig.baseImage,
|
|
4705
|
+
port: dockerConfig.port,
|
|
4706
|
+
healthCheckPath,
|
|
4707
|
+
prebuilt: useSlim,
|
|
4708
|
+
turbo: useTurbo,
|
|
4709
|
+
turboPackage,
|
|
4710
|
+
packageManager
|
|
4711
|
+
};
|
|
4712
|
+
const dockerfile = useSlim ? generateSlimDockerfile(templateOptions) : generateMultiStageDockerfile(templateOptions);
|
|
4713
|
+
const dockerMode = useSlim ? "slim" : useTurbo ? "turbo" : "multi-stage";
|
|
4714
|
+
const dockerfilePath = join(dockerDir, "Dockerfile");
|
|
4715
|
+
await writeFile(dockerfilePath, dockerfile);
|
|
4716
|
+
logger.log(`Generated: .gkm/docker/Dockerfile (${dockerMode}, ${packageManager})`);
|
|
4717
|
+
const composePath = await writeConstructsCompose(loadedConfig.workspace);
|
|
4718
|
+
const dockerignore = generateDockerignore();
|
|
4719
|
+
const dockerignorePath = join(cwd, ".dockerignore");
|
|
4720
|
+
await writeFile(dockerignorePath, dockerignore);
|
|
4721
|
+
logger.log("Generated: .dockerignore (project root)");
|
|
4722
|
+
const entrypoint = generateDockerEntrypoint();
|
|
4723
|
+
const entrypointPath = join(dockerDir, "docker-entrypoint.sh");
|
|
4724
|
+
await writeFile(entrypointPath, entrypoint);
|
|
4725
|
+
logger.log("Generated: .gkm/docker/docker-entrypoint.sh");
|
|
4726
|
+
const result = {
|
|
4727
|
+
dockerfile: dockerfilePath,
|
|
4728
|
+
dockerCompose: composePath,
|
|
4729
|
+
dockerignore: dockerignorePath,
|
|
4730
|
+
entrypoint: entrypointPath
|
|
4731
|
+
};
|
|
4732
|
+
if (options.build) await buildDockerImage(dockerConfig.imageName, options, cwd);
|
|
4733
|
+
if (options.push) await pushDockerImage(dockerConfig.imageName, options, cwd);
|
|
4734
|
+
return result;
|
|
4735
|
+
}
|
|
4736
|
+
/**
|
|
4737
|
+
* Ensure lockfile exists in the build context
|
|
4738
|
+
* For monorepos, copies from workspace root if needed
|
|
4739
|
+
* Returns cleanup function if file was copied
|
|
4740
|
+
*/
|
|
4741
|
+
function ensureLockfile(cwd) {
|
|
4742
|
+
const lockfilePath = findLockfilePath(cwd);
|
|
4743
|
+
if (!lockfilePath) {
|
|
4744
|
+
logger.warn("\n⚠️ No lockfile found. Docker build may fail or use stale dependencies.");
|
|
4745
|
+
return null;
|
|
5884
4746
|
}
|
|
5885
|
-
|
|
5886
|
-
|
|
5887
|
-
|
|
5888
|
-
|
|
5889
|
-
|
|
5890
|
-
|
|
5891
|
-
|
|
4747
|
+
const lockfileName = basename(lockfilePath);
|
|
4748
|
+
const localLockfile = join(cwd, lockfileName);
|
|
4749
|
+
if (lockfilePath === localLockfile) return null;
|
|
4750
|
+
logger.log(` Copying ${lockfileName} from monorepo root...`);
|
|
4751
|
+
copyFileSync(lockfilePath, localLockfile);
|
|
4752
|
+
return () => {
|
|
4753
|
+
try {
|
|
4754
|
+
unlinkSync(localLockfile);
|
|
4755
|
+
} catch {}
|
|
4756
|
+
};
|
|
4757
|
+
}
|
|
4758
|
+
/**
|
|
4759
|
+
* Build Docker image
|
|
4760
|
+
* Uses BuildKit for cache mount support
|
|
4761
|
+
*/
|
|
4762
|
+
async function buildDockerImage(imageName, options, cwd) {
|
|
4763
|
+
const tag = options.tag ?? "latest";
|
|
4764
|
+
const registry = options.registry;
|
|
4765
|
+
const fullImageName = validateImageRef(registry ? `${registry}/${imageName}:${tag}` : `${imageName}:${tag}`);
|
|
4766
|
+
logger.log(`\n🐳 Building Docker image: ${fullImageName}`);
|
|
4767
|
+
const cleanup = ensureLockfile(cwd);
|
|
4768
|
+
try {
|
|
4769
|
+
await run$1("docker", [
|
|
4770
|
+
"build",
|
|
4771
|
+
"--file=.gkm/docker/Dockerfile",
|
|
4772
|
+
`--tag=${fullImageName}`,
|
|
4773
|
+
"."
|
|
4774
|
+
], {
|
|
4775
|
+
cwd,
|
|
4776
|
+
env: {
|
|
4777
|
+
...process.env,
|
|
4778
|
+
DOCKER_BUILDKIT: "1"
|
|
4779
|
+
}
|
|
5892
4780
|
});
|
|
4781
|
+
logger.log(`✅ Docker image built: ${fullImageName}`);
|
|
4782
|
+
} catch (error) {
|
|
4783
|
+
throw new DockerBuildFailed(fullImageName, error);
|
|
4784
|
+
} finally {
|
|
4785
|
+
cleanup?.();
|
|
5893
4786
|
}
|
|
5894
|
-
|
|
5895
|
-
|
|
5896
|
-
|
|
5897
|
-
|
|
5898
|
-
|
|
4787
|
+
}
|
|
4788
|
+
/**
|
|
4789
|
+
* Push Docker image to registry
|
|
4790
|
+
*/
|
|
4791
|
+
async function pushDockerImage(imageName, options, cwd) {
|
|
4792
|
+
const tag = options.tag ?? "latest";
|
|
4793
|
+
const registry = options.registry;
|
|
4794
|
+
if (!registry) throw new PushNeedsRegistry(imageName);
|
|
4795
|
+
const fullImageName = validateImageRef(`${registry}/${imageName}:${tag}`);
|
|
4796
|
+
logger.log(`\n🚀 Pushing Docker image: ${fullImageName}`);
|
|
4797
|
+
try {
|
|
4798
|
+
await run$1("docker", ["push", fullImageName], { cwd });
|
|
4799
|
+
logger.log(`✅ Docker image pushed: ${fullImageName}`);
|
|
4800
|
+
} catch (error) {
|
|
4801
|
+
throw new DockerPushFailed(fullImageName, error);
|
|
5899
4802
|
}
|
|
5900
|
-
|
|
5901
|
-
|
|
5902
|
-
|
|
5903
|
-
|
|
5904
|
-
|
|
4803
|
+
}
|
|
4804
|
+
/**
|
|
4805
|
+
* Get the package name from package.json in an app directory.
|
|
4806
|
+
*/
|
|
4807
|
+
function getAppPackageName(appPath) {
|
|
4808
|
+
try {
|
|
4809
|
+
const pkgPath = join(appPath, "package.json");
|
|
4810
|
+
if (!existsSync(pkgPath)) return;
|
|
4811
|
+
const content = readFileSync(pkgPath, "utf-8");
|
|
4812
|
+
return JSON.parse(content).name;
|
|
4813
|
+
} catch {
|
|
4814
|
+
return;
|
|
5905
4815
|
}
|
|
5906
|
-
|
|
5907
|
-
|
|
5908
|
-
|
|
5909
|
-
|
|
5910
|
-
|
|
5911
|
-
|
|
5912
|
-
|
|
4816
|
+
}
|
|
4817
|
+
/**
|
|
4818
|
+
* Generate Dockerfiles for all apps in a workspace.
|
|
4819
|
+
* @internal Exported for testing
|
|
4820
|
+
*/
|
|
4821
|
+
async function workspaceDockerCommand(workspace, manifest) {
|
|
4822
|
+
const results = [];
|
|
4823
|
+
const apps = Object.entries(workspace.apps);
|
|
4824
|
+
logger.log(`\n🐳 Generating Dockerfiles for workspace: ${workspace.name}`);
|
|
4825
|
+
const dockerDir = join(workspace.root, ".gkm", "docker");
|
|
4826
|
+
await mkdir(dockerDir, { recursive: true });
|
|
4827
|
+
const packageManager = detectPackageManager(workspace.root);
|
|
4828
|
+
logger.log(` Package manager: ${packageManager}`);
|
|
4829
|
+
for (const [appName, app] of apps) {
|
|
4830
|
+
if (app.type === "mobile") {
|
|
4831
|
+
logger.log(`\n 📱 Skipping Docker for ${appName} (mobile app — deploy via framework toolchain)`);
|
|
4832
|
+
continue;
|
|
4833
|
+
}
|
|
4834
|
+
const appPath = app.path;
|
|
4835
|
+
const turboPackage = getAppPackageName(join(workspace.root, appPath)) ?? appName;
|
|
4836
|
+
const imageName = appName;
|
|
4837
|
+
const buildType = !!app.entry ? "entry" : app.type;
|
|
4838
|
+
logger.log(`\n 📄 Generating Dockerfile for ${appName} (${buildType})`);
|
|
4839
|
+
let dockerfile;
|
|
4840
|
+
if (app.type === "web") dockerfile = siteDockerfile(appName, app, {
|
|
4841
|
+
turboPackage,
|
|
4842
|
+
packageManager,
|
|
4843
|
+
...manifest ? { manifest } : {}
|
|
5913
4844
|
});
|
|
5914
|
-
|
|
5915
|
-
|
|
5916
|
-
|
|
5917
|
-
|
|
5918
|
-
|
|
5919
|
-
|
|
5920
|
-
|
|
5921
|
-
|
|
5922
|
-
|
|
5923
|
-
|
|
5924
|
-
|
|
5925
|
-
|
|
5926
|
-
|
|
5927
|
-
|
|
5928
|
-
|
|
5929
|
-
|
|
5930
|
-
|
|
5931
|
-
|
|
5932
|
-
|
|
5933
|
-
|
|
4845
|
+
else if (app.entry) dockerfile = generateEntryDockerfile({
|
|
4846
|
+
imageName,
|
|
4847
|
+
baseImage: "node:22-alpine",
|
|
4848
|
+
port: app.port,
|
|
4849
|
+
appPath,
|
|
4850
|
+
entry: app.entry,
|
|
4851
|
+
turboPackage,
|
|
4852
|
+
packageManager,
|
|
4853
|
+
healthCheckPath: "/health"
|
|
4854
|
+
});
|
|
4855
|
+
else dockerfile = generateBackendDockerfile({
|
|
4856
|
+
imageName,
|
|
4857
|
+
baseImage: "node:22-alpine",
|
|
4858
|
+
port: app.port,
|
|
4859
|
+
appPath,
|
|
4860
|
+
turboPackage,
|
|
4861
|
+
packageManager,
|
|
4862
|
+
healthCheckPath: "/health"
|
|
4863
|
+
});
|
|
4864
|
+
const dockerfilePath = join(dockerDir, `Dockerfile.${appName}`);
|
|
4865
|
+
await writeFile(dockerfilePath, dockerfile);
|
|
4866
|
+
logger.log(` Generated: .gkm/docker/Dockerfile.${appName}`);
|
|
4867
|
+
results.push({
|
|
4868
|
+
appName,
|
|
4869
|
+
type: app.type,
|
|
4870
|
+
dockerfile: dockerfilePath,
|
|
4871
|
+
imageName
|
|
5934
4872
|
});
|
|
5935
4873
|
}
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
5939
|
-
|
|
5940
|
-
|
|
5941
|
-
}
|
|
5942
|
-
|
|
5943
|
-
|
|
5944
|
-
|
|
5945
|
-
|
|
5946
|
-
await this.post("backup.manualBackup", { backupId });
|
|
4874
|
+
const dockerignore = generateDockerignore();
|
|
4875
|
+
const dockerignorePath = join(workspace.root, ".dockerignore");
|
|
4876
|
+
await writeFile(dockerignorePath, dockerignore);
|
|
4877
|
+
logger.log(`\n Generated: .dockerignore (workspace root)`);
|
|
4878
|
+
const composePath = await writeConstructsCompose(workspace);
|
|
4879
|
+
logger.log(`\n✅ Generated ${results.length} Dockerfile(s) + ${COMPOSE_PATH}`);
|
|
4880
|
+
logger.log("\n📋 Build commands:");
|
|
4881
|
+
for (const result of results) {
|
|
4882
|
+
const icon = result.type === "backend" ? "⚙️" : result.type === "mobile" ? "📱" : "🌐";
|
|
4883
|
+
logger.log(` ${icon} docker build -f .gkm/docker/Dockerfile.${result.appName} -t ${result.imageName} .`);
|
|
5947
4884
|
}
|
|
5948
|
-
|
|
5949
|
-
|
|
5950
|
-
|
|
5951
|
-
|
|
5952
|
-
|
|
4885
|
+
printRunInstructions(workspace);
|
|
4886
|
+
return {
|
|
4887
|
+
apps: results,
|
|
4888
|
+
dockerCompose: composePath,
|
|
4889
|
+
dockerignore: dockerignorePath
|
|
4890
|
+
};
|
|
4891
|
+
}
|
|
5953
4892
|
/**
|
|
5954
|
-
*
|
|
5955
|
-
* Handles both dev (.ts with tsx) and production (.mjs from dist).
|
|
5956
|
-
*
|
|
5957
|
-
* In production: sniffer.ts is bundled into dist/index.mjs, but sniffer helper
|
|
5958
|
-
* files are output to dist/deploy/ as standalone modules for subprocess loading.
|
|
4893
|
+
* A site's Dockerfile, with an `ARG` for every key its bundler inlines.
|
|
5959
4894
|
*
|
|
5960
|
-
*
|
|
4895
|
+
* The keys come from the site's declaration where there is one — every
|
|
4896
|
+
* public key its edges give it, `VITE_UPLOADS_SERVER_URL` included — and an
|
|
4897
|
+
* `ARG` the Dockerfile does not declare is a build argument the build drops,
|
|
4898
|
+
* leaving the bundle with an empty URL.
|
|
5961
4899
|
*/
|
|
5962
|
-
function
|
|
5963
|
-
const
|
|
5964
|
-
|
|
5965
|
-
const
|
|
5966
|
-
|
|
5967
|
-
|
|
5968
|
-
|
|
5969
|
-
|
|
4900
|
+
function siteDockerfile(appName, app, options) {
|
|
4901
|
+
const declared = options.manifest ? Object.entries(options.manifest).find(([id, d]) => d.kind === "site" && appKey(id) === appName)?.[1] : void 0;
|
|
4902
|
+
const publicUrlArgs = declared?.kind === "site" && options.manifest ? Object.keys(publicEnvFor(declared, options.manifest)) : getPublicUrlArgNames(app);
|
|
4903
|
+
const webOpts = {
|
|
4904
|
+
imageName: appName,
|
|
4905
|
+
baseImage: "node:22-alpine",
|
|
4906
|
+
port: app.port,
|
|
4907
|
+
appPath: app.path,
|
|
4908
|
+
turboPackage: options.turboPackage,
|
|
4909
|
+
packageManager: options.packageManager,
|
|
4910
|
+
publicUrlArgs
|
|
4911
|
+
};
|
|
4912
|
+
switch (app.framework) {
|
|
4913
|
+
case "vite": return generateViteStaticDockerfile(webOpts);
|
|
4914
|
+
case "tanstack-start":
|
|
4915
|
+
case "remix": return generateNodeWebDockerfile(webOpts);
|
|
4916
|
+
default: return generateNextjsDockerfile(webOpts);
|
|
4917
|
+
}
|
|
5970
4918
|
}
|
|
5971
4919
|
/**
|
|
5972
|
-
*
|
|
5973
|
-
* nothing of the deploy's: the deploy's Dokploy token, registry login and AWS
|
|
5974
|
-
* keys stay where they are. A worker that outlives the timeout is killed, and
|
|
5975
|
-
* comes back as the error the caller reports.
|
|
4920
|
+
* The package name turbo prunes an app by: its package.json's, or its name.
|
|
5976
4921
|
*/
|
|
5977
|
-
|
|
5978
|
-
|
|
5979
|
-
try {
|
|
5980
|
-
const result = await sandbox.exec("node", nodeArgs, {
|
|
5981
|
-
cwd,
|
|
5982
|
-
env: { ...sandbox.env },
|
|
5983
|
-
timeoutMs: run.timeoutMs ?? 3e4,
|
|
5984
|
-
output: "capture"
|
|
5985
|
-
});
|
|
5986
|
-
return {
|
|
5987
|
-
stdout: result.stdout,
|
|
5988
|
-
stderr: result.stderr,
|
|
5989
|
-
code: result.exitCode
|
|
5990
|
-
};
|
|
5991
|
-
} catch (error) {
|
|
5992
|
-
return { error: error instanceof Error ? error : new Error(String(error)) };
|
|
5993
|
-
}
|
|
5994
|
-
}
|
|
5995
|
-
/** The worker's JSON answer: the last object on stdout naming `envVars`. */
|
|
5996
|
-
function snifferAnswer(stdout) {
|
|
5997
|
-
try {
|
|
5998
|
-
const jsonMatch = stdout.match(/\{[^{}]*"envVars"[^{}]*\}[^{]*$/);
|
|
5999
|
-
return jsonMatch ? JSON.parse(jsonMatch[0]) : void 0;
|
|
6000
|
-
} catch {
|
|
6001
|
-
return;
|
|
6002
|
-
}
|
|
4922
|
+
function appPackageName(root, appName, path) {
|
|
4923
|
+
return getAppPackageName(join(root, path)) ?? appName;
|
|
6003
4924
|
}
|
|
6004
4925
|
/**
|
|
6005
|
-
*
|
|
4926
|
+
* Write `docker-compose.constructs.yml` — the same file `gkm dev` writes, with
|
|
4927
|
+
* the apps beside the containers their constructs derive.
|
|
6006
4928
|
*
|
|
6007
|
-
*
|
|
6008
|
-
*
|
|
6009
|
-
|
|
6010
|
-
|
|
6011
|
-
|
|
6012
|
-
|
|
6013
|
-
|
|
4929
|
+
* Through reconcile rather than a second generator, so what `gkm docker`
|
|
4930
|
+
* describes and what `gkm dev` runs cannot disagree. Nothing is started.
|
|
4931
|
+
*/
|
|
4932
|
+
async function writeConstructsCompose(workspace) {
|
|
4933
|
+
await reconcileWorkspace(workspace, {
|
|
4934
|
+
stage: workspace.stages.local,
|
|
4935
|
+
start: false
|
|
4936
|
+
});
|
|
4937
|
+
logger.log(` Generated: ${COMPOSE_PATH} (project root)`);
|
|
4938
|
+
return join(workspace.root, COMPOSE_PATH);
|
|
4939
|
+
}
|
|
4940
|
+
function printRunInstructions(workspace) {
|
|
4941
|
+
const project = existsSync(join(workspace.root, "docker-compose.yml")) ? " -f docker-compose.yml" : "";
|
|
4942
|
+
logger.log("\n📋 Run everything (the apps sit behind the \"apps\" profile):");
|
|
4943
|
+
logger.log(" gkm setup # starts the containers and creates what they hold");
|
|
4944
|
+
logger.log(` docker compose -f ${COMPOSE_PATH}${project} --profile apps up --build`);
|
|
4945
|
+
}
|
|
4946
|
+
//#endregion
|
|
4947
|
+
//#region src/deploy/generated.ts
|
|
4948
|
+
/**
|
|
4949
|
+
* The values a deployed stage generates once and keeps: what nothing can
|
|
4950
|
+
* provision and nobody else issues.
|
|
6014
4951
|
*
|
|
6015
|
-
*
|
|
6016
|
-
*
|
|
4952
|
+
* - **A seed**, which salts every password a deploy derives — a database's
|
|
4953
|
+
* master and roles, a bucket's root user. Derived from repo facts alone
|
|
4954
|
+
* (project, stage, construct id), those passwords were computable by anyone
|
|
4955
|
+
* who could read the repo.
|
|
4956
|
+
* - **Each `secret` construct's value** — an auth server's signing secret —
|
|
4957
|
+
* random, rather than derived.
|
|
4958
|
+
* - **Each `encryption` construct's keyring** — one random key and an index
|
|
4959
|
+
* key. Later keys are added by `gkm encryption:rotate`, never by a deploy.
|
|
6017
4960
|
*
|
|
6018
|
-
*
|
|
6019
|
-
*
|
|
6020
|
-
*
|
|
6021
|
-
*
|
|
6022
|
-
|
|
4961
|
+
* Both are generated on the first deploy that needs them and written to the
|
|
4962
|
+
* stage's store, so every later deploy reads the same values: sessions survive
|
|
4963
|
+
* a redeploy, and a role's password does not move. A value set by hand with
|
|
4964
|
+
* `gkm secrets:set` is kept, never replaced.
|
|
4965
|
+
*/
|
|
4966
|
+
/**
|
|
4967
|
+
* The stage's secrets with its seed and every declared `secret`'s value
|
|
4968
|
+
* present, generating what is missing. Pure: the caller writes the result
|
|
4969
|
+
* back to the stage's store when `generated` is not empty.
|
|
6023
4970
|
*/
|
|
6024
|
-
|
|
6025
|
-
const
|
|
6026
|
-
const
|
|
6027
|
-
|
|
6028
|
-
|
|
4971
|
+
function withGeneratedSecrets(secrets, manifest) {
|
|
4972
|
+
const generated = [];
|
|
4973
|
+
const custom = { ...secrets.custom };
|
|
4974
|
+
for (const declaration of Object.values(manifest)) {
|
|
4975
|
+
if (declaration?.kind !== "secret" && declaration?.kind !== "encryption") continue;
|
|
4976
|
+
const key = declaration.provides?.[0];
|
|
4977
|
+
if (!key || custom[key]) continue;
|
|
4978
|
+
custom[key] = declaration.kind === "encryption" ? generateKeyring() : random();
|
|
4979
|
+
generated.push(key);
|
|
4980
|
+
}
|
|
4981
|
+
const seed = secrets.seed ?? random();
|
|
4982
|
+
if (!secrets.seed) generated.push("seed");
|
|
4983
|
+
return {
|
|
4984
|
+
secrets: generated.length ? {
|
|
4985
|
+
...secrets,
|
|
4986
|
+
seed,
|
|
4987
|
+
custom,
|
|
4988
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
4989
|
+
} : secrets,
|
|
4990
|
+
generated
|
|
6029
4991
|
};
|
|
6030
|
-
|
|
6031
|
-
|
|
6032
|
-
|
|
6033
|
-
|
|
6034
|
-
|
|
6035
|
-
|
|
6036
|
-
|
|
6037
|
-
|
|
6038
|
-
|
|
6039
|
-
|
|
6040
|
-
|
|
6041
|
-
|
|
6042
|
-
|
|
6043
|
-
|
|
4992
|
+
}
|
|
4993
|
+
/** 256 bits, URL-safe. */
|
|
4994
|
+
function random() {
|
|
4995
|
+
return randomBytes(32).toString("base64url");
|
|
4996
|
+
}
|
|
4997
|
+
//#endregion
|
|
4998
|
+
//#region src/deploy/journal.ts
|
|
4999
|
+
var DeployJournal = class DeployJournal {
|
|
5000
|
+
store;
|
|
5001
|
+
stage;
|
|
5002
|
+
state;
|
|
5003
|
+
resources;
|
|
5004
|
+
version;
|
|
5005
|
+
existed;
|
|
5006
|
+
constructor(store, stage, state, resources, version, existed) {
|
|
5007
|
+
this.store = store;
|
|
5008
|
+
this.stage = stage;
|
|
5009
|
+
this.state = state;
|
|
5010
|
+
this.resources = resources;
|
|
5011
|
+
this.version = version;
|
|
5012
|
+
this.existed = existed;
|
|
5013
|
+
}
|
|
5014
|
+
/**
|
|
5015
|
+
* The stage's journal — its stored state, or `initial` written as the
|
|
5016
|
+
* stage's first state, so there is somewhere to record the very first
|
|
5017
|
+
* resource before it is created.
|
|
5018
|
+
*/
|
|
5019
|
+
static async open(store, stage, initial) {
|
|
5020
|
+
const stored = await store.read(stage);
|
|
5021
|
+
if (stored) return new DeployJournal(store, stage, stored.state, stored.resources, stored.version, true);
|
|
5022
|
+
const state = initial();
|
|
5023
|
+
const version = await store.write(stage, state, { expectedVersion: null });
|
|
5024
|
+
return new DeployJournal(store, stage, state, {}, version, false);
|
|
5025
|
+
}
|
|
5026
|
+
record(key) {
|
|
5027
|
+
return this.resources[key];
|
|
5028
|
+
}
|
|
5029
|
+
/** The keys a previous run marked `pending` and never saw created. */
|
|
5030
|
+
unfinished() {
|
|
5031
|
+
return Object.values(this.resources).filter((record) => record.status === "pending").map((record) => record.key);
|
|
5032
|
+
}
|
|
5033
|
+
/** Records that `entry` is about to be created. */
|
|
5034
|
+
async pending(entry) {
|
|
5035
|
+
await this.put({
|
|
5036
|
+
...entry,
|
|
5037
|
+
status: "pending"
|
|
5038
|
+
});
|
|
5039
|
+
}
|
|
5040
|
+
/** Records `entry` as existing with `id`; a no-op if it already is. */
|
|
5041
|
+
async ready(entry, id) {
|
|
5042
|
+
const current = this.resources[entry.key];
|
|
5043
|
+
if (current?.status === "ready" && current.id === id) return;
|
|
5044
|
+
await this.put({
|
|
5045
|
+
...entry,
|
|
5046
|
+
status: "ready",
|
|
5047
|
+
id
|
|
5048
|
+
});
|
|
5049
|
+
}
|
|
5050
|
+
/**
|
|
5051
|
+
* The resource `entry` names: by its recorded id, else by looking it up,
|
|
5052
|
+
* else created — with `pending` written before the create and `ready`
|
|
5053
|
+
* after, so a run that dies in between leaves a record the next run
|
|
5054
|
+
* resolves by looking the resource up rather than creating another.
|
|
5055
|
+
*/
|
|
5056
|
+
async ensure(entry, steps) {
|
|
5057
|
+
const record = this.resources[entry.key];
|
|
5058
|
+
let staleId;
|
|
5059
|
+
if (record?.status === "ready" && record.id && steps.get) {
|
|
5060
|
+
const known = await steps.get(record.id);
|
|
5061
|
+
if (known) {
|
|
5062
|
+
await this.ready(entry, steps.id(known));
|
|
5063
|
+
return {
|
|
5064
|
+
resource: known,
|
|
5065
|
+
via: "recorded"
|
|
5066
|
+
};
|
|
6044
5067
|
}
|
|
6045
|
-
|
|
5068
|
+
staleId = record.id;
|
|
5069
|
+
}
|
|
5070
|
+
const existing = await steps.find();
|
|
5071
|
+
if (existing) {
|
|
5072
|
+
await this.ready(entry, steps.id(existing));
|
|
6046
5073
|
return {
|
|
6047
|
-
|
|
6048
|
-
|
|
6049
|
-
|
|
5074
|
+
resource: existing,
|
|
5075
|
+
via: record?.status === "pending" ? "resumed" : "found",
|
|
5076
|
+
...staleId ? { staleId } : {}
|
|
6050
5077
|
};
|
|
6051
5078
|
}
|
|
5079
|
+
await this.pending(entry);
|
|
5080
|
+
const created = await steps.create();
|
|
5081
|
+
await this.ready(entry, steps.id(created));
|
|
6052
5082
|
return {
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
|
|
6056
|
-
};
|
|
6057
|
-
}
|
|
6058
|
-
if (app.entry) {
|
|
6059
|
-
const result = await sniffEntryFile(app.entry, app.path, workspacePath, run);
|
|
6060
|
-
if (logWarnings && result.error) output.warn(`[sniffer] ${appName}: Entry file threw error during sniffing (env vars still captured): ${result.error.message}`);
|
|
6061
|
-
return {
|
|
6062
|
-
appName,
|
|
6063
|
-
requiredEnvVars: applyMarkOptional(result.envVars, result.optionalEnvVars, markOptional),
|
|
6064
|
-
optionalEnvVars: result.optionalEnvVars
|
|
6065
|
-
};
|
|
6066
|
-
}
|
|
6067
|
-
if (app.routes) {
|
|
6068
|
-
const result = await sniffRouteFiles(normalizeRoutes(app.routes), app.path, workspacePath, run);
|
|
6069
|
-
if (logWarnings && result.error) output.warn(`[sniffer] ${appName}: Route sniffing threw error (env vars still captured): ${result.error.message}`);
|
|
6070
|
-
return {
|
|
6071
|
-
appName,
|
|
6072
|
-
requiredEnvVars: applyMarkOptional(result.envVars, result.optionalEnvVars, markOptional),
|
|
6073
|
-
optionalEnvVars: result.optionalEnvVars
|
|
5083
|
+
resource: created,
|
|
5084
|
+
via: "created",
|
|
5085
|
+
...staleId ? { staleId } : {}
|
|
6074
5086
|
};
|
|
6075
5087
|
}
|
|
6076
|
-
|
|
6077
|
-
|
|
6078
|
-
|
|
6079
|
-
|
|
6080
|
-
|
|
6081
|
-
|
|
6082
|
-
|
|
6083
|
-
|
|
6084
|
-
|
|
6085
|
-
|
|
5088
|
+
/** Writes the stage's state, keeping its resource records. */
|
|
5089
|
+
async save() {
|
|
5090
|
+
this.state.lastDeployedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5091
|
+
this.version = await this.store.write(this.stage, this.state, { expectedVersion: this.version });
|
|
5092
|
+
}
|
|
5093
|
+
async put(record) {
|
|
5094
|
+
this.version = await this.store.putResource(this.stage, record, { expectedVersion: this.version });
|
|
5095
|
+
this.resources = {
|
|
5096
|
+
...this.resources,
|
|
5097
|
+
[record.key]: {
|
|
5098
|
+
...record,
|
|
5099
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
5100
|
+
}
|
|
6086
5101
|
};
|
|
6087
5102
|
}
|
|
5103
|
+
};
|
|
5104
|
+
//#endregion
|
|
5105
|
+
//#region src/deploy/state.ts
|
|
5106
|
+
/**
|
|
5107
|
+
* Create a new empty state for a stage
|
|
5108
|
+
*/
|
|
5109
|
+
function createEmptyState(stage, projectId, environmentId) {
|
|
6088
5110
|
return {
|
|
6089
|
-
|
|
6090
|
-
|
|
6091
|
-
|
|
5111
|
+
provider: "dokploy",
|
|
5112
|
+
stage,
|
|
5113
|
+
projectId,
|
|
5114
|
+
environmentId,
|
|
5115
|
+
applications: {},
|
|
5116
|
+
services: {},
|
|
5117
|
+
lastDeployedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6092
5118
|
};
|
|
6093
5119
|
}
|
|
6094
5120
|
/**
|
|
6095
|
-
*
|
|
6096
|
-
* Only modifies the list when `markOptional` is true and there are optional vars.
|
|
5121
|
+
* Set application ID in state (mutates state)
|
|
6097
5122
|
*/
|
|
6098
|
-
function
|
|
6099
|
-
|
|
6100
|
-
|
|
6101
|
-
|
|
5123
|
+
function setApplicationId(state, appName, applicationId) {
|
|
5124
|
+
state.applications[appName] = applicationId;
|
|
5125
|
+
}
|
|
5126
|
+
/** Two records naming the same image: by digest when both have one. */
|
|
5127
|
+
function sameImage(a, b) {
|
|
5128
|
+
return a.digest && b.digest ? a.digest === b.digest : a.ref === b.ref;
|
|
5129
|
+
}
|
|
5130
|
+
/** The newest release in `history` after `index` that a rollback may restore. */
|
|
5131
|
+
function restorable(history, index, current) {
|
|
5132
|
+
return history.slice(index + 1).find((entry) => !entry.rolledBack && !sameImage(entry, current));
|
|
6102
5133
|
}
|
|
6103
5134
|
/**
|
|
6104
|
-
*
|
|
6105
|
-
*
|
|
6106
|
-
*
|
|
6107
|
-
* env vars are accessed, we:
|
|
6108
|
-
* 1. Spawn a subprocess with a module loader hook
|
|
6109
|
-
* 2. The loader intercepts `@geekmidas/envkit` and replaces EnvironmentParser
|
|
6110
|
-
* with SnifferEnvironmentParser
|
|
6111
|
-
* 3. Import the entry file (triggers config.parse())
|
|
6112
|
-
* 4. Capture and return the accessed env var names
|
|
6113
|
-
*
|
|
6114
|
-
* This approach provides process isolation - each app is sniffed in its own
|
|
6115
|
-
* subprocess, preventing module cache pollution.
|
|
6116
|
-
*
|
|
6117
|
-
* @param entryPath - Relative path to the entry file (e.g., './src/index.ts')
|
|
6118
|
-
* @param appPath - The app's path relative to workspace (e.g., 'apps/auth')
|
|
6119
|
-
* @param workspacePath - Absolute path to workspace root
|
|
6120
|
-
* @returns EntrySniffResult with env vars and optional error
|
|
5135
|
+
* Record that `image` went live for `appName` (mutates state): it becomes
|
|
5136
|
+
* `current`, and what was current becomes `previous`. Releasing what already
|
|
5137
|
+
* runs — a redeploy of the same digest — changes nothing.
|
|
6121
5138
|
*/
|
|
6122
|
-
|
|
6123
|
-
const
|
|
6124
|
-
|
|
6125
|
-
const
|
|
6126
|
-
|
|
6127
|
-
|
|
6128
|
-
envVars: [],
|
|
6129
|
-
optionalEnvVars: [],
|
|
6130
|
-
error: ran.error
|
|
5139
|
+
function recordRelease(state, appName, image, releasedAt = /* @__PURE__ */ new Date()) {
|
|
5140
|
+
const releases = state.releases?.[appName];
|
|
5141
|
+
if (releases && sameImage(releases.current, image)) return;
|
|
5142
|
+
const entry = {
|
|
5143
|
+
...image,
|
|
5144
|
+
releasedAt: releasedAt.toISOString()
|
|
6131
5145
|
};
|
|
6132
|
-
const
|
|
6133
|
-
|
|
6134
|
-
|
|
6135
|
-
|
|
6136
|
-
|
|
5146
|
+
const history = [entry, ...releases?.history ?? []].slice(0, 10);
|
|
5147
|
+
const previous = releases?.current;
|
|
5148
|
+
state.releases = {
|
|
5149
|
+
...state.releases,
|
|
5150
|
+
[appName]: {
|
|
5151
|
+
current: entry,
|
|
5152
|
+
...previous ? { previous } : {},
|
|
5153
|
+
history
|
|
5154
|
+
}
|
|
6137
5155
|
};
|
|
6138
|
-
|
|
6139
|
-
|
|
6140
|
-
|
|
6141
|
-
|
|
5156
|
+
}
|
|
5157
|
+
/**
|
|
5158
|
+
* Record that `appName` was rolled back to `to` (mutates state): every
|
|
5159
|
+
* release newer than it is marked rolled back, so neither this rollback nor
|
|
5160
|
+
* a later one restores it, and `previous` moves to the release before `to`.
|
|
5161
|
+
*/
|
|
5162
|
+
function recordRollback(state, appName, to) {
|
|
5163
|
+
const releases = state.releases?.[appName];
|
|
5164
|
+
if (!releases) return;
|
|
5165
|
+
const index = releases.history.findIndex((entry) => !entry.rolledBack && sameImage(entry, to));
|
|
5166
|
+
if (index === -1) return;
|
|
5167
|
+
const history = releases.history.map((entry, i) => i < index ? {
|
|
5168
|
+
...entry,
|
|
5169
|
+
rolledBack: true
|
|
5170
|
+
} : entry);
|
|
5171
|
+
const current = history[index];
|
|
5172
|
+
const previous = restorable(history, index, current);
|
|
5173
|
+
state.releases = {
|
|
5174
|
+
...state.releases,
|
|
5175
|
+
[appName]: {
|
|
5176
|
+
current,
|
|
5177
|
+
...previous ? { previous } : {},
|
|
5178
|
+
history
|
|
5179
|
+
}
|
|
6142
5180
|
};
|
|
6143
5181
|
}
|
|
6144
5182
|
/**
|
|
6145
|
-
*
|
|
6146
|
-
*
|
|
6147
|
-
* Route-based apps have endpoints, functions, crons, and subscribers that
|
|
6148
|
-
* use services. Each service's register() method accesses environment variables.
|
|
6149
|
-
*
|
|
6150
|
-
* This runs in a subprocess with tsx loader to properly handle TypeScript
|
|
6151
|
-
* compilation and path alias resolution (e.g., `src/...` imports).
|
|
6152
|
-
*
|
|
6153
|
-
* @param routes - Glob pattern(s) for route files
|
|
6154
|
-
* @param appPath - The app's path relative to workspace (e.g., 'apps/api')
|
|
6155
|
-
* @param workspacePath - Absolute path to workspace root
|
|
6156
|
-
* @returns EntrySniffResult with env vars and optional error
|
|
5183
|
+
* Get a generated secret for an app
|
|
6157
5184
|
*/
|
|
6158
|
-
|
|
6159
|
-
|
|
6160
|
-
const workerPath = resolveSnifferFile("sniffer-routes-worker");
|
|
6161
|
-
const pattern = (Array.isArray(routes) ? routes : [routes])[0];
|
|
6162
|
-
if (!pattern) return {
|
|
6163
|
-
envVars: [],
|
|
6164
|
-
optionalEnvVars: [],
|
|
6165
|
-
error: /* @__PURE__ */ new Error("No route patterns provided")
|
|
6166
|
-
};
|
|
6167
|
-
const ran = await runSniffer(run, workspacePath, fullAppPath, nodeWithTsx(workerPath, [fullAppPath, pattern]));
|
|
6168
|
-
if ("error" in ran) return {
|
|
6169
|
-
envVars: [],
|
|
6170
|
-
optionalEnvVars: [],
|
|
6171
|
-
error: ran.error
|
|
6172
|
-
};
|
|
6173
|
-
if (ran.stderr) ran.stderr.split("\n").filter((line) => line.trim()).forEach((line) => output.warn(line));
|
|
6174
|
-
const answer = snifferAnswer(ran.stdout);
|
|
6175
|
-
if (answer) return {
|
|
6176
|
-
envVars: answer.envVars || [],
|
|
6177
|
-
optionalEnvVars: answer.optionalEnvVars || [],
|
|
6178
|
-
error: answer.error ? new Error(answer.error) : void 0
|
|
6179
|
-
};
|
|
6180
|
-
return {
|
|
6181
|
-
envVars: [],
|
|
6182
|
-
optionalEnvVars: [],
|
|
6183
|
-
error: /* @__PURE__ */ new Error(`Failed to sniff route files (exit code ${ran.code}): ${ran.stderr || ran.stdout || "No output"}`)
|
|
6184
|
-
};
|
|
5185
|
+
function getGeneratedSecret(state, appName, secretName) {
|
|
5186
|
+
return state?.generatedSecrets?.[appName]?.[secretName];
|
|
6185
5187
|
}
|
|
6186
5188
|
/**
|
|
6187
|
-
*
|
|
6188
|
-
* which environment variables it accesses.
|
|
6189
|
-
*
|
|
6190
|
-
* In a subprocess, like the entry and route sniffers: the module is the
|
|
6191
|
-
* project's code, and it was imported into the deploy itself before — with
|
|
6192
|
-
* every credential the deploy held in reach. Fire-and-forget rejections are
|
|
6193
|
-
* collected there with the shared `sniffWithFireAndForget`.
|
|
6194
|
-
*
|
|
6195
|
-
* @param envParserPath - The envParser config (e.g., './src/config/env#envParser')
|
|
6196
|
-
* @param appPath - The app's path relative to workspace
|
|
6197
|
-
* @param workspacePath - Absolute path to workspace root
|
|
6198
|
-
* @returns SniffResult with env vars and any errors encountered
|
|
5189
|
+
* Set a generated secret for an app (mutates state)
|
|
6199
5190
|
*/
|
|
6200
|
-
|
|
6201
|
-
|
|
6202
|
-
if (!
|
|
6203
|
-
|
|
6204
|
-
|
|
6205
|
-
|
|
6206
|
-
|
|
6207
|
-
|
|
6208
|
-
|
|
6209
|
-
|
|
6210
|
-
|
|
6211
|
-
|
|
6212
|
-
|
|
6213
|
-
optionalEnvVars: [],
|
|
6214
|
-
unhandledRejections: [],
|
|
6215
|
-
error: ran.error
|
|
6216
|
-
};
|
|
6217
|
-
const answer = snifferAnswer(ran.stdout);
|
|
6218
|
-
if (!answer) return {
|
|
6219
|
-
envVars: [],
|
|
6220
|
-
optionalEnvVars: [],
|
|
6221
|
-
unhandledRejections: [],
|
|
6222
|
-
error: /* @__PURE__ */ new Error(`Failed to sniff envParser (exit code ${ran.code}): ${ran.stderr || ran.stdout || "No output"}`)
|
|
5191
|
+
function setGeneratedSecret(state, appName, secretName, value) {
|
|
5192
|
+
if (!state.generatedSecrets) state.generatedSecrets = {};
|
|
5193
|
+
if (!state.generatedSecrets[appName]) state.generatedSecrets[appName] = {};
|
|
5194
|
+
state.generatedSecrets[appName][secretName] = value;
|
|
5195
|
+
}
|
|
5196
|
+
/**
|
|
5197
|
+
* Set DNS verification record for a hostname (mutates state)
|
|
5198
|
+
*/
|
|
5199
|
+
function setDnsVerification(state, hostname, serverIp) {
|
|
5200
|
+
if (!state.dnsVerified) state.dnsVerified = {};
|
|
5201
|
+
state.dnsVerified[hostname] = {
|
|
5202
|
+
serverIp,
|
|
5203
|
+
verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6223
5204
|
};
|
|
6224
|
-
|
|
6225
|
-
|
|
6226
|
-
|
|
6227
|
-
|
|
6228
|
-
|
|
6229
|
-
|
|
5205
|
+
}
|
|
5206
|
+
/**
|
|
5207
|
+
* Check if a hostname is already verified with the given IP
|
|
5208
|
+
*/
|
|
5209
|
+
function isDnsVerified(state, hostname, serverIp) {
|
|
5210
|
+
return (state?.dnsVerified?.[hostname])?.serverIp === serverIp;
|
|
5211
|
+
}
|
|
5212
|
+
/**
|
|
5213
|
+
* Get the key for a DNS record in state
|
|
5214
|
+
*/
|
|
5215
|
+
function getDnsRecordKey(name, type) {
|
|
5216
|
+
return `${name}:${type}`;
|
|
5217
|
+
}
|
|
5218
|
+
/**
|
|
5219
|
+
* Set a created DNS record in state (mutates state)
|
|
5220
|
+
*/
|
|
5221
|
+
function setDnsRecord(state, record) {
|
|
5222
|
+
if (!state.dnsRecords) state.dnsRecords = {};
|
|
5223
|
+
const key = getDnsRecordKey(record.name, record.type);
|
|
5224
|
+
state.dnsRecords[key] = {
|
|
5225
|
+
...record,
|
|
5226
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
6230
5227
|
};
|
|
6231
5228
|
}
|
|
6232
5229
|
/**
|
|
6233
|
-
*
|
|
6234
|
-
*
|
|
6235
|
-
* @param apps - Map of app name to app config
|
|
6236
|
-
* @param workspacePath - Absolute path to workspace root
|
|
6237
|
-
* @param options - Optional configuration for sniffing behavior
|
|
6238
|
-
* @returns Map of app name to sniffed environment
|
|
5230
|
+
* Get backup state from state
|
|
6239
5231
|
*/
|
|
6240
|
-
|
|
6241
|
-
|
|
6242
|
-
|
|
6243
|
-
|
|
6244
|
-
|
|
6245
|
-
|
|
6246
|
-
|
|
5232
|
+
function getBackupState(state) {
|
|
5233
|
+
return state?.backups;
|
|
5234
|
+
}
|
|
5235
|
+
/**
|
|
5236
|
+
* Set backup state (mutates state)
|
|
5237
|
+
*/
|
|
5238
|
+
function setBackupState(state, backupState) {
|
|
5239
|
+
state.backups = backupState;
|
|
5240
|
+
}
|
|
5241
|
+
/**
|
|
5242
|
+
* Set postgres backup ID in state (mutates state)
|
|
5243
|
+
*/
|
|
5244
|
+
function setPostgresBackupId(state, backupId) {
|
|
5245
|
+
if (state.backups) state.backups.postgresBackupId = backupId;
|
|
6247
5246
|
}
|
|
6248
5247
|
//#endregion
|
|
6249
|
-
export {
|
|
5248
|
+
export { describeServices as $, reconcileWorkspace as A, withCustomSecret as At, appEnvKeys as B, validateImageRef as C, markerOf as Ct, backendsOf as D, maskPassword as Dt, FAKE_ENV as E, initStageSecrets as Et, postgresDatabaseNames as F, planFor as G, networkEnv as H, postgresStatements as I, cacheBackendFor as J, resourceName as K, quoteIdentifier as L, LOCAL_CA_PATH as M, forgetState as N, constructGlobs as O, storage_exports as Ot, applyPostgres as P, DEFAULT_IMAGES as Q, composeFor as R, generateSlimDockerfile as S, isOwnedBy as St, resolveHost as T, projectName as Tt, envFor as U, dockerfileOf as V, localRolePassword as W, providerOf as X, eventsBackendFor as Y, storageBackendFor as Z, appPackageName as _, readKey as _t, recordRelease as a, findAvailablePort as at, detectPackageManager as b, imageName as bt, setBackupState as c, loadSecretsForApp as ct, setGeneratedSecret as d, secretsStoreFor as dt, fakeKey as et, setPostgresBackupId as f, store_exports as ft, DockerPushFailed as g, projectKey as gt, DockerBuildFailed as h, keystoreProject as ht, isDnsVerified as i, createEntryWrapper as it, workspace_exports as j, derivedContainers as k, toEmbeddableSecrets as kt, setDnsRecord as l, prepareEntryCredentials as lt, withGeneratedSecrets as m, getKeyPath as mt, getBackupState as n, primaryPortKey as nt, recordRollback as o, isPortAvailable as ot, DeployJournal as p, FileSecretsStore as pt, hostFor as q, getGeneratedSecret as r, createCredentialsPreload as rt, setApplicationId as s, loadEnvFiles as st, createEmptyState as t, portKeys as tt, setDnsVerification as u, isRemoteStore as ut, dockerCommand as v, applicationName as vt, isMainFrontendApp as w, ownershipMarker as wt, generateDockerignore as x, imageRef as xt, siteDockerfile as y, deployIdentity as yt, pgClient as z };
|
|
6250
5249
|
|
|
6251
|
-
//# sourceMappingURL=
|
|
5250
|
+
//# sourceMappingURL=state-uuYqnRVO.mjs.map
|