@geekmidas/cli 0.48.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/deploy/sniffer-envkit-patch.cjs +27 -0
- package/dist/deploy/sniffer-envkit-patch.cjs.map +1 -0
- package/dist/deploy/sniffer-envkit-patch.d.cts +46 -0
- package/dist/deploy/sniffer-envkit-patch.d.cts.map +1 -0
- package/dist/deploy/sniffer-envkit-patch.d.mts +46 -0
- package/dist/deploy/sniffer-envkit-patch.d.mts.map +1 -0
- package/dist/deploy/sniffer-envkit-patch.mjs +20 -0
- package/dist/deploy/sniffer-envkit-patch.mjs.map +1 -0
- package/dist/deploy/sniffer-hooks.cjs +25 -0
- package/dist/deploy/sniffer-hooks.cjs.map +1 -0
- package/dist/deploy/sniffer-hooks.d.cts +27 -0
- package/dist/deploy/sniffer-hooks.d.cts.map +1 -0
- package/dist/deploy/sniffer-hooks.d.mts +27 -0
- package/dist/deploy/sniffer-hooks.d.mts.map +1 -0
- package/dist/deploy/sniffer-hooks.mjs +24 -0
- package/dist/deploy/sniffer-hooks.mjs.map +1 -0
- package/dist/deploy/sniffer-loader.cjs +16 -0
- package/dist/deploy/sniffer-loader.cjs.map +1 -0
- package/dist/deploy/sniffer-loader.d.cts +1 -0
- package/dist/deploy/sniffer-loader.d.mts +1 -0
- package/dist/deploy/sniffer-loader.mjs +15 -0
- package/dist/deploy/sniffer-loader.mjs.map +1 -0
- package/dist/deploy/sniffer-worker.cjs +42 -0
- package/dist/deploy/sniffer-worker.cjs.map +1 -0
- package/dist/deploy/sniffer-worker.d.cts +9 -0
- package/dist/deploy/sniffer-worker.d.cts.map +1 -0
- package/dist/deploy/sniffer-worker.d.mts +9 -0
- package/dist/deploy/sniffer-worker.d.mts.map +1 -0
- package/dist/deploy/sniffer-worker.mjs +41 -0
- package/dist/deploy/sniffer-worker.mjs.map +1 -0
- package/dist/{dokploy-api-DvzIDxTj.mjs → dokploy-api-94KzmTVf.mjs} +4 -4
- package/dist/dokploy-api-94KzmTVf.mjs.map +1 -0
- package/dist/dokploy-api-CItuaWTq.mjs +3 -0
- package/dist/dokploy-api-DBNE8MDt.cjs +3 -0
- package/dist/{dokploy-api-BDLu0qWi.cjs → dokploy-api-YD8WCQfW.cjs} +4 -4
- package/dist/dokploy-api-YD8WCQfW.cjs.map +1 -0
- package/dist/index.cjs +2415 -1893
- package/dist/index.cjs.map +1 -1
- package/dist/index.mjs +2411 -1889
- package/dist/index.mjs.map +1 -1
- package/package.json +8 -6
- package/src/build/__tests__/handler-templates.spec.ts +947 -0
- package/src/deploy/__tests__/__fixtures__/entry-apps/async-entry.ts +24 -0
- package/src/deploy/__tests__/__fixtures__/entry-apps/nested-config-entry.ts +24 -0
- package/src/deploy/__tests__/__fixtures__/entry-apps/no-env-entry.ts +12 -0
- package/src/deploy/__tests__/__fixtures__/entry-apps/simple-entry.ts +14 -0
- package/src/deploy/__tests__/__fixtures__/entry-apps/throwing-entry.ts +16 -0
- package/src/deploy/__tests__/__fixtures__/env-parsers/non-function-export.ts +10 -0
- package/src/deploy/__tests__/__fixtures__/env-parsers/parseable-env-parser.ts +18 -0
- package/src/deploy/__tests__/__fixtures__/env-parsers/throwing-env-parser.ts +18 -0
- package/src/deploy/__tests__/__fixtures__/env-parsers/valid-env-parser.ts +16 -0
- package/src/deploy/__tests__/dns-verification.spec.ts +229 -0
- package/src/deploy/__tests__/dokploy-api.spec.ts +2 -3
- package/src/deploy/__tests__/domain.spec.ts +7 -3
- package/src/deploy/__tests__/env-resolver.spec.ts +469 -0
- package/src/deploy/__tests__/index.spec.ts +12 -12
- package/src/deploy/__tests__/secrets.spec.ts +4 -1
- package/src/deploy/__tests__/sniffer.spec.ts +326 -1
- package/src/deploy/__tests__/state.spec.ts +844 -0
- package/src/deploy/dns/hostinger-api.ts +4 -1
- package/src/deploy/dns/index.ts +113 -1
- package/src/deploy/docker.ts +1 -2
- package/src/deploy/dokploy-api.ts +18 -9
- package/src/deploy/domain.ts +5 -4
- package/src/deploy/env-resolver.ts +278 -0
- package/src/deploy/index.ts +525 -119
- package/src/deploy/secrets.ts +7 -2
- package/src/deploy/sniffer-envkit-patch.ts +59 -0
- package/src/deploy/sniffer-hooks.ts +57 -0
- package/src/deploy/sniffer-loader.ts +28 -0
- package/src/deploy/sniffer-worker.ts +74 -0
- package/src/deploy/sniffer.ts +170 -14
- package/src/deploy/state.ts +162 -1
- package/src/init/versions.ts +3 -3
- package/tsconfig.tsbuildinfo +1 -1
- package/tsdown.config.ts +5 -0
- package/dist/dokploy-api-BDLu0qWi.cjs.map +0 -1
- package/dist/dokploy-api-BN3V57z1.mjs +0 -3
- package/dist/dokploy-api-BdCKjFDA.cjs +0 -3
- package/dist/dokploy-api-DvzIDxTj.mjs.map +0 -1
package/dist/index.cjs
CHANGED
|
@@ -4,7 +4,7 @@ const require_workspace = require('./workspace-CaVW6j2q.cjs');
|
|
|
4
4
|
const require_config = require('./config-HYiM3iQJ.cjs');
|
|
5
5
|
const require_openapi = require('./openapi-D7WwlpPF.cjs');
|
|
6
6
|
const require_storage = require('./storage-BPRgh3DU.cjs');
|
|
7
|
-
const require_dokploy_api = require('./dokploy-api-
|
|
7
|
+
const require_dokploy_api = require('./dokploy-api-YD8WCQfW.cjs');
|
|
8
8
|
const require_encryption = require('./encryption-DaCB_NmS.cjs');
|
|
9
9
|
const require_openapi_react_query = require('./openapi-react-query-C_MxpBgF.cjs');
|
|
10
10
|
const node_fs = require_chunk.__toESM(require("node:fs"));
|
|
@@ -23,6 +23,7 @@ const __geekmidas_constructs_crons = require_chunk.__toESM(require("@geekmidas/c
|
|
|
23
23
|
const __geekmidas_constructs_functions = require_chunk.__toESM(require("@geekmidas/constructs/functions"));
|
|
24
24
|
const __geekmidas_constructs_subscribers = require_chunk.__toESM(require("@geekmidas/constructs/subscribers"));
|
|
25
25
|
const node_crypto = require_chunk.__toESM(require("node:crypto"));
|
|
26
|
+
const pg = require_chunk.__toESM(require("pg"));
|
|
26
27
|
const node_dns_promises = require_chunk.__toESM(require("node:dns/promises"));
|
|
27
28
|
const node_url = require_chunk.__toESM(require("node:url"));
|
|
28
29
|
const prompts = require_chunk.__toESM(require("prompts"));
|
|
@@ -30,7 +31,7 @@ const node_module = require_chunk.__toESM(require("node:module"));
|
|
|
30
31
|
|
|
31
32
|
//#region package.json
|
|
32
33
|
var name = "@geekmidas/cli";
|
|
33
|
-
var version = "0.
|
|
34
|
+
var version = "0.50.0";
|
|
34
35
|
var description = "CLI tools for building Lambda handlers, server applications, and generating OpenAPI specs";
|
|
35
36
|
var private$1 = false;
|
|
36
37
|
var type = "module";
|
|
@@ -86,12 +87,14 @@ var dependencies = {
|
|
|
86
87
|
"hono": "~4.8.0",
|
|
87
88
|
"lodash.kebabcase": "^4.1.1",
|
|
88
89
|
"openapi-typescript": "^7.4.2",
|
|
90
|
+
"pg": "~8.17.1",
|
|
89
91
|
"prompts": "~2.4.2"
|
|
90
92
|
};
|
|
91
93
|
var devDependencies = {
|
|
92
94
|
"@geekmidas/testkit": "workspace:*",
|
|
93
95
|
"@types/lodash.kebabcase": "^4.1.9",
|
|
94
96
|
"@types/node": "~24.9.1",
|
|
97
|
+
"@types/pg": "~8.16.0",
|
|
95
98
|
"@types/prompts": "~2.4.9",
|
|
96
99
|
"typescript": "^5.8.2",
|
|
97
100
|
"vitest": "^3.2.4",
|
|
@@ -254,7 +257,7 @@ const logger$11 = console;
|
|
|
254
257
|
* Validate Dokploy token by making a test API call
|
|
255
258
|
*/
|
|
256
259
|
async function validateDokployToken(endpoint, token) {
|
|
257
|
-
const { DokployApi: DokployApi$1 } = await Promise.resolve().then(() => require("./dokploy-api-
|
|
260
|
+
const { DokployApi: DokployApi$1 } = await Promise.resolve().then(() => require("./dokploy-api-DBNE8MDt.cjs"));
|
|
258
261
|
const api = new DokployApi$1({
|
|
259
262
|
baseUrl: endpoint,
|
|
260
263
|
token
|
|
@@ -1867,9 +1870,9 @@ var DevServer = class {
|
|
|
1867
1870
|
}
|
|
1868
1871
|
async createServerEntry() {
|
|
1869
1872
|
const { writeFile: fsWriteFile } = await import("node:fs/promises");
|
|
1870
|
-
const { relative: relative$6, dirname: dirname$
|
|
1873
|
+
const { relative: relative$6, dirname: dirname$9 } = await import("node:path");
|
|
1871
1874
|
const serverPath = (0, node_path.join)(this.appRoot, ".gkm", this.provider, "server.ts");
|
|
1872
|
-
const relativeAppPath = relative$6(dirname$
|
|
1875
|
+
const relativeAppPath = relative$6(dirname$9(serverPath), (0, node_path.join)(dirname$9(serverPath), "app.js"));
|
|
1873
1876
|
const credentialsInjection = this.secretsJsonPath ? `import { Credentials } from '@geekmidas/envkit/credentials';
|
|
1874
1877
|
import { existsSync, readFileSync } from 'node:fs';
|
|
1875
1878
|
|
|
@@ -2275,2027 +2278,2143 @@ function getAppOutputPath(workspace, _appName, app) {
|
|
|
2275
2278
|
}
|
|
2276
2279
|
|
|
2277
2280
|
//#endregion
|
|
2278
|
-
//#region src/
|
|
2279
|
-
/**
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
};
|
|
2285
|
-
/** Default Docker image versions for services */
|
|
2286
|
-
const DEFAULT_SERVICE_VERSIONS = {
|
|
2287
|
-
postgres: "16-alpine",
|
|
2288
|
-
redis: "7-alpine",
|
|
2289
|
-
rabbitmq: "3-management-alpine"
|
|
2290
|
-
};
|
|
2291
|
-
/** Get the default full image reference for a service */
|
|
2292
|
-
function getDefaultImage(serviceName) {
|
|
2293
|
-
return `${DEFAULT_SERVICE_IMAGES[serviceName]}:${DEFAULT_SERVICE_VERSIONS[serviceName]}`;
|
|
2281
|
+
//#region src/deploy/state.ts
|
|
2282
|
+
/**
|
|
2283
|
+
* Get the state file path for a stage
|
|
2284
|
+
*/
|
|
2285
|
+
function getStateFilePath(workspaceRoot, stage) {
|
|
2286
|
+
return (0, node_path.join)(workspaceRoot, ".gkm", `deploy-${stage}.json`);
|
|
2294
2287
|
}
|
|
2295
|
-
/**
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
}
|
|
2309
|
-
}
|
|
2288
|
+
/**
|
|
2289
|
+
* Read the deploy state for a stage
|
|
2290
|
+
* Returns null if state file doesn't exist
|
|
2291
|
+
*/
|
|
2292
|
+
async function readStageState(workspaceRoot, stage) {
|
|
2293
|
+
const filePath = getStateFilePath(workspaceRoot, stage);
|
|
2294
|
+
try {
|
|
2295
|
+
const content = await (0, node_fs_promises.readFile)(filePath, "utf-8");
|
|
2296
|
+
return JSON.parse(content);
|
|
2297
|
+
} catch (error) {
|
|
2298
|
+
if (error.code === "ENOENT") return null;
|
|
2299
|
+
console.warn(`Warning: Could not read deploy state: ${error}`);
|
|
2300
|
+
return null;
|
|
2310
2301
|
}
|
|
2311
|
-
return result;
|
|
2312
2302
|
}
|
|
2313
2303
|
/**
|
|
2314
|
-
*
|
|
2304
|
+
* Write the deploy state for a stage
|
|
2315
2305
|
*/
|
|
2316
|
-
function
|
|
2317
|
-
const
|
|
2318
|
-
const
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
services:
|
|
2323
|
-
api:
|
|
2324
|
-
build:
|
|
2325
|
-
context: ../..
|
|
2326
|
-
dockerfile: .gkm/docker/Dockerfile
|
|
2327
|
-
image: ${imageRef}\${IMAGE_NAME:-${imageName}}:\${TAG:-latest}
|
|
2328
|
-
container_name: ${imageName}
|
|
2329
|
-
restart: unless-stopped
|
|
2330
|
-
ports:
|
|
2331
|
-
- "\${PORT:-${port}}:${port}"
|
|
2332
|
-
environment:
|
|
2333
|
-
- NODE_ENV=production
|
|
2334
|
-
`;
|
|
2335
|
-
if (serviceMap.has("postgres")) yaml += ` - DATABASE_URL=\${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/app}
|
|
2336
|
-
`;
|
|
2337
|
-
if (serviceMap.has("redis")) yaml += ` - REDIS_URL=\${REDIS_URL:-redis://redis:6379}
|
|
2338
|
-
`;
|
|
2339
|
-
if (serviceMap.has("rabbitmq")) yaml += ` - RABBITMQ_URL=\${RABBITMQ_URL:-amqp://rabbitmq:5672}
|
|
2340
|
-
`;
|
|
2341
|
-
yaml += ` healthcheck:
|
|
2342
|
-
test: ["CMD", "wget", "-q", "--spider", "http://localhost:${port}${healthCheckPath}"]
|
|
2343
|
-
interval: 30s
|
|
2344
|
-
timeout: 3s
|
|
2345
|
-
retries: 3
|
|
2346
|
-
`;
|
|
2347
|
-
if (serviceMap.size > 0) {
|
|
2348
|
-
yaml += ` depends_on:
|
|
2349
|
-
`;
|
|
2350
|
-
for (const serviceName of serviceMap.keys()) yaml += ` ${serviceName}:
|
|
2351
|
-
condition: service_healthy
|
|
2352
|
-
`;
|
|
2353
|
-
}
|
|
2354
|
-
yaml += ` networks:
|
|
2355
|
-
- app-network
|
|
2356
|
-
`;
|
|
2357
|
-
const postgresImage = serviceMap.get("postgres");
|
|
2358
|
-
if (postgresImage) yaml += `
|
|
2359
|
-
postgres:
|
|
2360
|
-
image: ${postgresImage}
|
|
2361
|
-
container_name: postgres
|
|
2362
|
-
restart: unless-stopped
|
|
2363
|
-
environment:
|
|
2364
|
-
POSTGRES_USER: \${POSTGRES_USER:-postgres}
|
|
2365
|
-
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:-postgres}
|
|
2366
|
-
POSTGRES_DB: \${POSTGRES_DB:-app}
|
|
2367
|
-
volumes:
|
|
2368
|
-
- postgres_data:/var/lib/postgresql/data
|
|
2369
|
-
healthcheck:
|
|
2370
|
-
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
2371
|
-
interval: 5s
|
|
2372
|
-
timeout: 5s
|
|
2373
|
-
retries: 5
|
|
2374
|
-
networks:
|
|
2375
|
-
- app-network
|
|
2376
|
-
`;
|
|
2377
|
-
const redisImage = serviceMap.get("redis");
|
|
2378
|
-
if (redisImage) yaml += `
|
|
2379
|
-
redis:
|
|
2380
|
-
image: ${redisImage}
|
|
2381
|
-
container_name: redis
|
|
2382
|
-
restart: unless-stopped
|
|
2383
|
-
volumes:
|
|
2384
|
-
- redis_data:/data
|
|
2385
|
-
healthcheck:
|
|
2386
|
-
test: ["CMD", "redis-cli", "ping"]
|
|
2387
|
-
interval: 5s
|
|
2388
|
-
timeout: 5s
|
|
2389
|
-
retries: 5
|
|
2390
|
-
networks:
|
|
2391
|
-
- app-network
|
|
2392
|
-
`;
|
|
2393
|
-
const rabbitmqImage = serviceMap.get("rabbitmq");
|
|
2394
|
-
if (rabbitmqImage) yaml += `
|
|
2395
|
-
rabbitmq:
|
|
2396
|
-
image: ${rabbitmqImage}
|
|
2397
|
-
container_name: rabbitmq
|
|
2398
|
-
restart: unless-stopped
|
|
2399
|
-
environment:
|
|
2400
|
-
RABBITMQ_DEFAULT_USER: \${RABBITMQ_USER:-guest}
|
|
2401
|
-
RABBITMQ_DEFAULT_PASS: \${RABBITMQ_PASSWORD:-guest}
|
|
2402
|
-
ports:
|
|
2403
|
-
- "15672:15672" # Management UI
|
|
2404
|
-
volumes:
|
|
2405
|
-
- rabbitmq_data:/var/lib/rabbitmq
|
|
2406
|
-
healthcheck:
|
|
2407
|
-
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
|
|
2408
|
-
interval: 10s
|
|
2409
|
-
timeout: 5s
|
|
2410
|
-
retries: 5
|
|
2411
|
-
networks:
|
|
2412
|
-
- app-network
|
|
2413
|
-
`;
|
|
2414
|
-
yaml += `
|
|
2415
|
-
volumes:
|
|
2416
|
-
`;
|
|
2417
|
-
if (serviceMap.has("postgres")) yaml += ` postgres_data:
|
|
2418
|
-
`;
|
|
2419
|
-
if (serviceMap.has("redis")) yaml += ` redis_data:
|
|
2420
|
-
`;
|
|
2421
|
-
if (serviceMap.has("rabbitmq")) yaml += ` rabbitmq_data:
|
|
2422
|
-
`;
|
|
2423
|
-
yaml += `
|
|
2424
|
-
networks:
|
|
2425
|
-
app-network:
|
|
2426
|
-
driver: bridge
|
|
2427
|
-
`;
|
|
2428
|
-
return yaml;
|
|
2306
|
+
async function writeStageState(workspaceRoot, stage, state) {
|
|
2307
|
+
const filePath = getStateFilePath(workspaceRoot, stage);
|
|
2308
|
+
const dir = (0, node_path.join)(workspaceRoot, ".gkm");
|
|
2309
|
+
await (0, node_fs_promises.mkdir)(dir, { recursive: true });
|
|
2310
|
+
state.lastDeployedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2311
|
+
await (0, node_fs_promises.writeFile)(filePath, JSON.stringify(state, null, 2));
|
|
2429
2312
|
}
|
|
2430
2313
|
/**
|
|
2431
|
-
*
|
|
2314
|
+
* Create a new empty state for a stage
|
|
2432
2315
|
*/
|
|
2433
|
-
function
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
dockerfile: .gkm/docker/Dockerfile
|
|
2443
|
-
image: ${imageRef}\${IMAGE_NAME:-${imageName}}:\${TAG:-latest}
|
|
2444
|
-
container_name: ${imageName}
|
|
2445
|
-
restart: unless-stopped
|
|
2446
|
-
ports:
|
|
2447
|
-
- "\${PORT:-${port}}:${port}"
|
|
2448
|
-
environment:
|
|
2449
|
-
- NODE_ENV=production
|
|
2450
|
-
healthcheck:
|
|
2451
|
-
test: ["CMD", "wget", "-q", "--spider", "http://localhost:${port}${healthCheckPath}"]
|
|
2452
|
-
interval: 30s
|
|
2453
|
-
timeout: 3s
|
|
2454
|
-
retries: 3
|
|
2455
|
-
networks:
|
|
2456
|
-
- app-network
|
|
2457
|
-
|
|
2458
|
-
networks:
|
|
2459
|
-
app-network:
|
|
2460
|
-
driver: bridge
|
|
2461
|
-
`;
|
|
2316
|
+
function createEmptyState(stage, environmentId) {
|
|
2317
|
+
return {
|
|
2318
|
+
provider: "dokploy",
|
|
2319
|
+
stage,
|
|
2320
|
+
environmentId,
|
|
2321
|
+
applications: {},
|
|
2322
|
+
services: {},
|
|
2323
|
+
lastDeployedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2324
|
+
};
|
|
2462
2325
|
}
|
|
2463
2326
|
/**
|
|
2464
|
-
*
|
|
2465
|
-
* Apps can communicate with each other via service names.
|
|
2466
|
-
* @internal Exported for testing
|
|
2327
|
+
* Get application ID from state
|
|
2467
2328
|
*/
|
|
2468
|
-
function
|
|
2469
|
-
|
|
2470
|
-
const apps = Object.entries(workspace.apps);
|
|
2471
|
-
const services = workspace.services;
|
|
2472
|
-
const hasPostgres = services.db !== void 0 && services.db !== false;
|
|
2473
|
-
const hasRedis = services.cache !== void 0 && services.cache !== false;
|
|
2474
|
-
const hasMail = services.mail !== void 0 && services.mail !== false;
|
|
2475
|
-
const postgresImage = getInfraServiceImage("postgres", services.db);
|
|
2476
|
-
const redisImage = getInfraServiceImage("redis", services.cache);
|
|
2477
|
-
let yaml = `# Docker Compose for ${workspace.name} workspace
|
|
2478
|
-
# Generated by gkm - do not edit manually
|
|
2479
|
-
|
|
2480
|
-
services:
|
|
2481
|
-
`;
|
|
2482
|
-
for (const [appName, app] of apps) yaml += generateAppService(appName, app, apps, {
|
|
2483
|
-
registry,
|
|
2484
|
-
hasPostgres,
|
|
2485
|
-
hasRedis
|
|
2486
|
-
});
|
|
2487
|
-
if (hasPostgres) yaml += `
|
|
2488
|
-
postgres:
|
|
2489
|
-
image: ${postgresImage}
|
|
2490
|
-
container_name: ${workspace.name}-postgres
|
|
2491
|
-
restart: unless-stopped
|
|
2492
|
-
environment:
|
|
2493
|
-
POSTGRES_USER: \${POSTGRES_USER:-postgres}
|
|
2494
|
-
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:-postgres}
|
|
2495
|
-
POSTGRES_DB: \${POSTGRES_DB:-app}
|
|
2496
|
-
volumes:
|
|
2497
|
-
- postgres_data:/var/lib/postgresql/data
|
|
2498
|
-
healthcheck:
|
|
2499
|
-
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
2500
|
-
interval: 5s
|
|
2501
|
-
timeout: 5s
|
|
2502
|
-
retries: 5
|
|
2503
|
-
networks:
|
|
2504
|
-
- workspace-network
|
|
2505
|
-
`;
|
|
2506
|
-
if (hasRedis) yaml += `
|
|
2507
|
-
redis:
|
|
2508
|
-
image: ${redisImage}
|
|
2509
|
-
container_name: ${workspace.name}-redis
|
|
2510
|
-
restart: unless-stopped
|
|
2511
|
-
volumes:
|
|
2512
|
-
- redis_data:/data
|
|
2513
|
-
healthcheck:
|
|
2514
|
-
test: ["CMD", "redis-cli", "ping"]
|
|
2515
|
-
interval: 5s
|
|
2516
|
-
timeout: 5s
|
|
2517
|
-
retries: 5
|
|
2518
|
-
networks:
|
|
2519
|
-
- workspace-network
|
|
2520
|
-
`;
|
|
2521
|
-
if (hasMail) yaml += `
|
|
2522
|
-
mailpit:
|
|
2523
|
-
image: axllent/mailpit:latest
|
|
2524
|
-
container_name: ${workspace.name}-mailpit
|
|
2525
|
-
restart: unless-stopped
|
|
2526
|
-
ports:
|
|
2527
|
-
- "8025:8025" # Web UI
|
|
2528
|
-
- "1025:1025" # SMTP
|
|
2529
|
-
networks:
|
|
2530
|
-
- workspace-network
|
|
2531
|
-
`;
|
|
2532
|
-
yaml += `
|
|
2533
|
-
volumes:
|
|
2534
|
-
`;
|
|
2535
|
-
if (hasPostgres) yaml += ` postgres_data:
|
|
2536
|
-
`;
|
|
2537
|
-
if (hasRedis) yaml += ` redis_data:
|
|
2538
|
-
`;
|
|
2539
|
-
yaml += `
|
|
2540
|
-
networks:
|
|
2541
|
-
workspace-network:
|
|
2542
|
-
driver: bridge
|
|
2543
|
-
`;
|
|
2544
|
-
return yaml;
|
|
2329
|
+
function getApplicationId(state, appName) {
|
|
2330
|
+
return state?.applications[appName];
|
|
2545
2331
|
}
|
|
2546
2332
|
/**
|
|
2547
|
-
*
|
|
2333
|
+
* Set application ID in state (mutates state)
|
|
2548
2334
|
*/
|
|
2549
|
-
function
|
|
2550
|
-
|
|
2551
|
-
postgres: "postgres:16-alpine",
|
|
2552
|
-
redis: "redis:7-alpine"
|
|
2553
|
-
};
|
|
2554
|
-
if (!config || config === true) return defaults[serviceName];
|
|
2555
|
-
if (typeof config === "object") {
|
|
2556
|
-
if (config.image) return config.image;
|
|
2557
|
-
if (config.version) {
|
|
2558
|
-
const baseImage = serviceName === "postgres" ? "postgres" : "redis";
|
|
2559
|
-
return `${baseImage}:${config.version}`;
|
|
2560
|
-
}
|
|
2561
|
-
}
|
|
2562
|
-
return defaults[serviceName];
|
|
2335
|
+
function setApplicationId(state, appName, applicationId) {
|
|
2336
|
+
state.applications[appName] = applicationId;
|
|
2563
2337
|
}
|
|
2564
2338
|
/**
|
|
2565
|
-
*
|
|
2339
|
+
* Get postgres ID from state
|
|
2566
2340
|
*/
|
|
2567
|
-
function
|
|
2568
|
-
|
|
2569
|
-
const imageRef = registry ? `\${REGISTRY:-${registry}}/` : "";
|
|
2570
|
-
const healthCheckPath = app.type === "frontend" ? "/" : "/health";
|
|
2571
|
-
const healthCheckCmd = app.type === "frontend" ? `["CMD", "wget", "-q", "--spider", "http://localhost:${app.port}/"]` : `["CMD", "wget", "-q", "--spider", "http://localhost:${app.port}${healthCheckPath}"]`;
|
|
2572
|
-
let yaml = `
|
|
2573
|
-
${appName}:
|
|
2574
|
-
build:
|
|
2575
|
-
context: .
|
|
2576
|
-
dockerfile: .gkm/docker/Dockerfile.${appName}
|
|
2577
|
-
image: ${imageRef}\${${appName.toUpperCase()}_IMAGE:-${appName}}:\${TAG:-latest}
|
|
2578
|
-
container_name: ${appName}
|
|
2579
|
-
restart: unless-stopped
|
|
2580
|
-
ports:
|
|
2581
|
-
- "\${${appName.toUpperCase()}_PORT:-${app.port}}:${app.port}"
|
|
2582
|
-
environment:
|
|
2583
|
-
- NODE_ENV=production
|
|
2584
|
-
- PORT=${app.port}
|
|
2585
|
-
`;
|
|
2586
|
-
for (const dep of app.dependencies) {
|
|
2587
|
-
const depApp = allApps.find(([name$1]) => name$1 === dep)?.[1];
|
|
2588
|
-
if (depApp) yaml += ` - ${dep.toUpperCase()}_URL=http://${dep}:${depApp.port}
|
|
2589
|
-
`;
|
|
2590
|
-
}
|
|
2591
|
-
if (app.type === "backend") {
|
|
2592
|
-
if (hasPostgres) yaml += ` - DATABASE_URL=\${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/app}
|
|
2593
|
-
`;
|
|
2594
|
-
if (hasRedis) yaml += ` - REDIS_URL=\${REDIS_URL:-redis://redis:6379}
|
|
2595
|
-
`;
|
|
2596
|
-
}
|
|
2597
|
-
yaml += ` healthcheck:
|
|
2598
|
-
test: ${healthCheckCmd}
|
|
2599
|
-
interval: 30s
|
|
2600
|
-
timeout: 3s
|
|
2601
|
-
retries: 3
|
|
2602
|
-
`;
|
|
2603
|
-
const dependencies$1 = [...app.dependencies];
|
|
2604
|
-
if (app.type === "backend") {
|
|
2605
|
-
if (hasPostgres) dependencies$1.push("postgres");
|
|
2606
|
-
if (hasRedis) dependencies$1.push("redis");
|
|
2607
|
-
}
|
|
2608
|
-
if (dependencies$1.length > 0) {
|
|
2609
|
-
yaml += ` depends_on:
|
|
2610
|
-
`;
|
|
2611
|
-
for (const dep of dependencies$1) yaml += ` ${dep}:
|
|
2612
|
-
condition: service_healthy
|
|
2613
|
-
`;
|
|
2614
|
-
}
|
|
2615
|
-
yaml += ` networks:
|
|
2616
|
-
- workspace-network
|
|
2617
|
-
`;
|
|
2618
|
-
return yaml;
|
|
2341
|
+
function getPostgresId(state) {
|
|
2342
|
+
return state?.services.postgresId;
|
|
2619
2343
|
}
|
|
2620
|
-
|
|
2621
|
-
//#endregion
|
|
2622
|
-
//#region src/docker/templates.ts
|
|
2623
|
-
const LOCKFILES = [
|
|
2624
|
-
["pnpm-lock.yaml", "pnpm"],
|
|
2625
|
-
["bun.lockb", "bun"],
|
|
2626
|
-
["yarn.lock", "yarn"],
|
|
2627
|
-
["package-lock.json", "npm"]
|
|
2628
|
-
];
|
|
2629
2344
|
/**
|
|
2630
|
-
*
|
|
2631
|
-
* Walks up the directory tree to find lockfile (for monorepos)
|
|
2345
|
+
* Set postgres ID in state (mutates state)
|
|
2632
2346
|
*/
|
|
2633
|
-
function
|
|
2634
|
-
|
|
2635
|
-
const root = (0, node_path.parse)(dir).root;
|
|
2636
|
-
while (dir !== root) {
|
|
2637
|
-
for (const [lockfile, pm] of LOCKFILES) if ((0, node_fs.existsSync)((0, node_path.join)(dir, lockfile))) return pm;
|
|
2638
|
-
dir = (0, node_path.dirname)(dir);
|
|
2639
|
-
}
|
|
2640
|
-
for (const [lockfile, pm] of LOCKFILES) if ((0, node_fs.existsSync)((0, node_path.join)(root, lockfile))) return pm;
|
|
2641
|
-
return "pnpm";
|
|
2347
|
+
function setPostgresId(state, postgresId) {
|
|
2348
|
+
state.services.postgresId = postgresId;
|
|
2642
2349
|
}
|
|
2643
2350
|
/**
|
|
2644
|
-
*
|
|
2645
|
-
* Returns the full path to the lockfile, or null if not found
|
|
2351
|
+
* Get redis ID from state
|
|
2646
2352
|
*/
|
|
2647
|
-
function
|
|
2648
|
-
|
|
2649
|
-
const root = (0, node_path.parse)(dir).root;
|
|
2650
|
-
while (dir !== root) {
|
|
2651
|
-
for (const [lockfile] of LOCKFILES) {
|
|
2652
|
-
const lockfilePath = (0, node_path.join)(dir, lockfile);
|
|
2653
|
-
if ((0, node_fs.existsSync)(lockfilePath)) return lockfilePath;
|
|
2654
|
-
}
|
|
2655
|
-
dir = (0, node_path.dirname)(dir);
|
|
2656
|
-
}
|
|
2657
|
-
for (const [lockfile] of LOCKFILES) {
|
|
2658
|
-
const lockfilePath = (0, node_path.join)(root, lockfile);
|
|
2659
|
-
if ((0, node_fs.existsSync)(lockfilePath)) return lockfilePath;
|
|
2660
|
-
}
|
|
2661
|
-
return null;
|
|
2353
|
+
function getRedisId(state) {
|
|
2354
|
+
return state?.services.redisId;
|
|
2662
2355
|
}
|
|
2663
2356
|
/**
|
|
2664
|
-
*
|
|
2357
|
+
* Set redis ID in state (mutates state)
|
|
2665
2358
|
*/
|
|
2666
|
-
function
|
|
2667
|
-
|
|
2668
|
-
if (!lockfilePath) return false;
|
|
2669
|
-
const lockfileDir = (0, node_path.dirname)(lockfilePath);
|
|
2670
|
-
return lockfileDir !== cwd;
|
|
2359
|
+
function setRedisId(state, redisId) {
|
|
2360
|
+
state.services.redisId = redisId;
|
|
2671
2361
|
}
|
|
2672
2362
|
/**
|
|
2673
|
-
*
|
|
2363
|
+
* Set app credentials in state (mutates state)
|
|
2674
2364
|
*/
|
|
2675
|
-
function
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
while (dir !== root) {
|
|
2679
|
-
if ((0, node_fs.existsSync)((0, node_path.join)(dir, "turbo.json"))) return true;
|
|
2680
|
-
dir = (0, node_path.dirname)(dir);
|
|
2681
|
-
}
|
|
2682
|
-
return (0, node_fs.existsSync)((0, node_path.join)(root, "turbo.json"));
|
|
2365
|
+
function setAppCredentials(state, appName, credentials) {
|
|
2366
|
+
if (!state.appCredentials) state.appCredentials = {};
|
|
2367
|
+
state.appCredentials[appName] = credentials;
|
|
2683
2368
|
}
|
|
2684
2369
|
/**
|
|
2685
|
-
* Get
|
|
2686
|
-
* Turbo prune creates a subset that may not perfectly match the lockfile
|
|
2370
|
+
* Get all app credentials from state
|
|
2687
2371
|
*/
|
|
2688
|
-
function
|
|
2689
|
-
|
|
2690
|
-
pnpm: "pnpm install",
|
|
2691
|
-
npm: "npm install",
|
|
2692
|
-
yarn: "yarn install",
|
|
2693
|
-
bun: "bun install"
|
|
2694
|
-
};
|
|
2695
|
-
return commands[pm];
|
|
2372
|
+
function getAllAppCredentials(state) {
|
|
2373
|
+
return state?.appCredentials ?? {};
|
|
2696
2374
|
}
|
|
2697
2375
|
/**
|
|
2698
|
-
* Get
|
|
2376
|
+
* Get a generated secret for an app
|
|
2699
2377
|
*/
|
|
2700
|
-
function
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
cacheTarget: "/root/.npm",
|
|
2720
|
-
cacheId: "npm",
|
|
2721
|
-
run: "npm run",
|
|
2722
|
-
exec: "npx",
|
|
2723
|
-
dlx: "npx",
|
|
2724
|
-
addGlobal: "npm install -g"
|
|
2725
|
-
},
|
|
2726
|
-
yarn: {
|
|
2727
|
-
install: "corepack enable && corepack prepare yarn@stable --activate",
|
|
2728
|
-
lockfile: "yarn.lock",
|
|
2729
|
-
fetch: "",
|
|
2730
|
-
installCmd: "yarn install --frozen-lockfile",
|
|
2731
|
-
cacheTarget: "/root/.yarn/cache",
|
|
2732
|
-
cacheId: "yarn",
|
|
2733
|
-
run: "yarn",
|
|
2734
|
-
exec: "yarn exec",
|
|
2735
|
-
dlx: "yarn dlx",
|
|
2736
|
-
addGlobal: "yarn global add"
|
|
2737
|
-
},
|
|
2738
|
-
bun: {
|
|
2739
|
-
install: "npm install -g bun",
|
|
2740
|
-
lockfile: "bun.lockb",
|
|
2741
|
-
fetch: "",
|
|
2742
|
-
installCmd: "bun install --frozen-lockfile",
|
|
2743
|
-
cacheTarget: "/root/.bun/install/cache",
|
|
2744
|
-
cacheId: "bun",
|
|
2745
|
-
run: "bun run",
|
|
2746
|
-
exec: "bunx",
|
|
2747
|
-
dlx: "bunx",
|
|
2748
|
-
addGlobal: "bun add -g"
|
|
2749
|
-
}
|
|
2378
|
+
function getGeneratedSecret(state, appName, secretName) {
|
|
2379
|
+
return state?.generatedSecrets?.[appName]?.[secretName];
|
|
2380
|
+
}
|
|
2381
|
+
/**
|
|
2382
|
+
* Set a generated secret for an app (mutates state)
|
|
2383
|
+
*/
|
|
2384
|
+
function setGeneratedSecret(state, appName, secretName, value) {
|
|
2385
|
+
if (!state.generatedSecrets) state.generatedSecrets = {};
|
|
2386
|
+
if (!state.generatedSecrets[appName]) state.generatedSecrets[appName] = {};
|
|
2387
|
+
state.generatedSecrets[appName][secretName] = value;
|
|
2388
|
+
}
|
|
2389
|
+
/**
|
|
2390
|
+
* Set DNS verification record for a hostname (mutates state)
|
|
2391
|
+
*/
|
|
2392
|
+
function setDnsVerification(state, hostname, serverIp) {
|
|
2393
|
+
if (!state.dnsVerified) state.dnsVerified = {};
|
|
2394
|
+
state.dnsVerified[hostname] = {
|
|
2395
|
+
serverIp,
|
|
2396
|
+
verifiedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
2750
2397
|
};
|
|
2751
|
-
return configs[pm];
|
|
2752
2398
|
}
|
|
2753
2399
|
/**
|
|
2754
|
-
*
|
|
2755
|
-
* Optimized for build speed with:
|
|
2756
|
-
* - BuildKit cache mounts for package manager store
|
|
2757
|
-
* - pnpm fetch for better layer caching (when using pnpm)
|
|
2758
|
-
* - Optional turbo prune for monorepos
|
|
2400
|
+
* Check if a hostname is already verified with the given IP
|
|
2759
2401
|
*/
|
|
2760
|
-
function
|
|
2761
|
-
const
|
|
2762
|
-
|
|
2763
|
-
...options,
|
|
2764
|
-
turboPackage: turboPackage ?? "api"
|
|
2765
|
-
});
|
|
2766
|
-
const pm = getPmConfig(packageManager);
|
|
2767
|
-
const installPm = pm.install ? `\n# Install ${packageManager}\nRUN ${pm.install}\n` : "";
|
|
2768
|
-
const hasFetch = packageManager === "pnpm";
|
|
2769
|
-
const depsStage = hasFetch ? `# Copy lockfile first for better caching
|
|
2770
|
-
COPY ${pm.lockfile} ./
|
|
2771
|
-
|
|
2772
|
-
# Fetch dependencies (downloads to virtual store, cached separately)
|
|
2773
|
-
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
2774
|
-
${pm.fetch}
|
|
2775
|
-
|
|
2776
|
-
# Copy package.json after fetch
|
|
2777
|
-
COPY package.json ./
|
|
2778
|
-
|
|
2779
|
-
# Install from cache (fast - no network needed)
|
|
2780
|
-
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
2781
|
-
${pm.installCmd}` : `# Copy package files
|
|
2782
|
-
COPY package.json ${pm.lockfile} ./
|
|
2783
|
-
|
|
2784
|
-
# Install dependencies with cache
|
|
2785
|
-
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
2786
|
-
${pm.installCmd}`;
|
|
2787
|
-
return `# syntax=docker/dockerfile:1
|
|
2788
|
-
# Stage 1: Dependencies
|
|
2789
|
-
FROM ${baseImage} AS deps
|
|
2790
|
-
|
|
2791
|
-
WORKDIR /app
|
|
2792
|
-
${installPm}
|
|
2793
|
-
${depsStage}
|
|
2794
|
-
|
|
2795
|
-
# Stage 2: Build
|
|
2796
|
-
FROM deps AS builder
|
|
2797
|
-
|
|
2798
|
-
WORKDIR /app
|
|
2799
|
-
|
|
2800
|
-
# Copy source (deps already installed)
|
|
2801
|
-
COPY . .
|
|
2802
|
-
|
|
2803
|
-
# Debug: Show node_modules/.bin contents and build production server
|
|
2804
|
-
RUN echo "=== node_modules/.bin contents ===" && \
|
|
2805
|
-
ls -la node_modules/.bin/ 2>/dev/null || echo "node_modules/.bin not found" && \
|
|
2806
|
-
echo "=== Checking for gkm ===" && \
|
|
2807
|
-
which gkm 2>/dev/null || echo "gkm not in PATH" && \
|
|
2808
|
-
ls -la node_modules/.bin/gkm 2>/dev/null || echo "gkm binary not found in node_modules/.bin" && \
|
|
2809
|
-
echo "=== Running build ===" && \
|
|
2810
|
-
./node_modules/.bin/gkm build --provider server --production
|
|
2811
|
-
|
|
2812
|
-
# Stage 3: Production
|
|
2813
|
-
FROM ${baseImage} AS runner
|
|
2814
|
-
|
|
2815
|
-
WORKDIR /app
|
|
2816
|
-
|
|
2817
|
-
# Install tini for proper signal handling as PID 1
|
|
2818
|
-
RUN apk add --no-cache tini
|
|
2819
|
-
|
|
2820
|
-
# Create non-root user
|
|
2821
|
-
RUN addgroup --system --gid 1001 nodejs && \\
|
|
2822
|
-
adduser --system --uid 1001 hono
|
|
2823
|
-
|
|
2824
|
-
# Copy bundled server
|
|
2825
|
-
COPY --from=builder --chown=hono:nodejs /app/.gkm/server/dist/server.mjs ./
|
|
2826
|
-
|
|
2827
|
-
# Environment
|
|
2828
|
-
ENV NODE_ENV=production
|
|
2829
|
-
ENV PORT=${port}
|
|
2830
|
-
|
|
2831
|
-
# Health check
|
|
2832
|
-
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
2833
|
-
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
2834
|
-
|
|
2835
|
-
# Switch to non-root user
|
|
2836
|
-
USER hono
|
|
2837
|
-
|
|
2838
|
-
EXPOSE ${port}
|
|
2839
|
-
|
|
2840
|
-
# Use tini as entrypoint to handle PID 1 responsibilities
|
|
2841
|
-
ENTRYPOINT ["/sbin/tini", "--"]
|
|
2842
|
-
CMD ["node", "server.mjs"]
|
|
2843
|
-
`;
|
|
2402
|
+
function isDnsVerified(state, hostname, serverIp) {
|
|
2403
|
+
const record = state?.dnsVerified?.[hostname];
|
|
2404
|
+
return record?.serverIp === serverIp;
|
|
2844
2405
|
}
|
|
2406
|
+
|
|
2407
|
+
//#endregion
|
|
2408
|
+
//#region src/deploy/dns/hostinger-api.ts
|
|
2845
2409
|
/**
|
|
2846
|
-
*
|
|
2847
|
-
*
|
|
2410
|
+
* Hostinger DNS API client
|
|
2411
|
+
*
|
|
2412
|
+
* API Documentation: https://developers.hostinger.com/
|
|
2413
|
+
* Authentication: Bearer token from hpanel.hostinger.com/profile/api
|
|
2848
2414
|
*/
|
|
2849
|
-
|
|
2850
|
-
const { baseImage, port, healthCheckPath, turboPackage, packageManager } = options;
|
|
2851
|
-
const pm = getPmConfig(packageManager);
|
|
2852
|
-
const installPm = pm.install ? `RUN ${pm.install}` : "";
|
|
2853
|
-
const turboInstallCmd = getTurboInstallCmd(packageManager);
|
|
2854
|
-
const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
|
|
2855
|
-
return `# syntax=docker/dockerfile:1
|
|
2856
|
-
# Stage 1: Prune monorepo
|
|
2857
|
-
FROM ${baseImage} AS pruner
|
|
2858
|
-
|
|
2859
|
-
WORKDIR /app
|
|
2860
|
-
|
|
2861
|
-
${installPm}
|
|
2862
|
-
|
|
2863
|
-
COPY . .
|
|
2864
|
-
|
|
2865
|
-
# Prune to only include necessary packages
|
|
2866
|
-
RUN ${turboCmd} prune ${turboPackage} --docker
|
|
2867
|
-
|
|
2868
|
-
# Stage 2: Install dependencies
|
|
2869
|
-
FROM ${baseImage} AS deps
|
|
2870
|
-
|
|
2871
|
-
WORKDIR /app
|
|
2872
|
-
|
|
2873
|
-
${installPm}
|
|
2874
|
-
|
|
2875
|
-
# Copy pruned lockfile and package.jsons
|
|
2876
|
-
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
2877
|
-
COPY --from=pruner /app/out/json/ ./
|
|
2878
|
-
|
|
2879
|
-
# Install dependencies (no frozen-lockfile since turbo prune creates a subset)
|
|
2880
|
-
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
2881
|
-
${turboInstallCmd}
|
|
2882
|
-
|
|
2883
|
-
# Stage 3: Build
|
|
2884
|
-
FROM deps AS builder
|
|
2885
|
-
|
|
2886
|
-
WORKDIR /app
|
|
2887
|
-
|
|
2888
|
-
# Copy pruned source
|
|
2889
|
-
COPY --from=pruner /app/out/full/ ./
|
|
2890
|
-
|
|
2891
|
-
# Debug: Show node_modules/.bin contents and build production server
|
|
2892
|
-
RUN echo "=== node_modules/.bin contents ===" && \
|
|
2893
|
-
ls -la node_modules/.bin/ 2>/dev/null || echo "node_modules/.bin not found" && \
|
|
2894
|
-
echo "=== Checking for gkm ===" && \
|
|
2895
|
-
which gkm 2>/dev/null || echo "gkm not in PATH" && \
|
|
2896
|
-
ls -la node_modules/.bin/gkm 2>/dev/null || echo "gkm binary not found in node_modules/.bin" && \
|
|
2897
|
-
echo "=== Running build ===" && \
|
|
2898
|
-
./node_modules/.bin/gkm build --provider server --production
|
|
2899
|
-
|
|
2900
|
-
# Stage 4: Production
|
|
2901
|
-
FROM ${baseImage} AS runner
|
|
2902
|
-
|
|
2903
|
-
WORKDIR /app
|
|
2904
|
-
|
|
2905
|
-
RUN apk add --no-cache tini
|
|
2906
|
-
|
|
2907
|
-
RUN addgroup --system --gid 1001 nodejs && \\
|
|
2908
|
-
adduser --system --uid 1001 hono
|
|
2909
|
-
|
|
2910
|
-
COPY --from=builder --chown=hono:nodejs /app/.gkm/server/dist/server.mjs ./
|
|
2911
|
-
|
|
2912
|
-
ENV NODE_ENV=production
|
|
2913
|
-
ENV PORT=${port}
|
|
2914
|
-
|
|
2915
|
-
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
2916
|
-
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
2917
|
-
|
|
2918
|
-
USER hono
|
|
2919
|
-
|
|
2920
|
-
EXPOSE ${port}
|
|
2921
|
-
|
|
2922
|
-
ENTRYPOINT ["/sbin/tini", "--"]
|
|
2923
|
-
CMD ["node", "server.mjs"]
|
|
2924
|
-
`;
|
|
2925
|
-
}
|
|
2415
|
+
const HOSTINGER_API_BASE = "https://developers.hostinger.com";
|
|
2926
2416
|
/**
|
|
2927
|
-
*
|
|
2417
|
+
* Hostinger API error
|
|
2928
2418
|
*/
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
RUN apk add --no-cache tini
|
|
2939
|
-
|
|
2940
|
-
# Create non-root user
|
|
2941
|
-
RUN addgroup --system --gid 1001 nodejs && \\
|
|
2942
|
-
adduser --system --uid 1001 hono
|
|
2943
|
-
|
|
2944
|
-
# Copy pre-built bundle
|
|
2945
|
-
COPY .gkm/server/dist/server.mjs ./
|
|
2946
|
-
|
|
2947
|
-
# Environment
|
|
2948
|
-
ENV NODE_ENV=production
|
|
2949
|
-
ENV PORT=${port}
|
|
2950
|
-
|
|
2951
|
-
# Health check
|
|
2952
|
-
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
2953
|
-
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
2954
|
-
|
|
2955
|
-
# Switch to non-root user
|
|
2956
|
-
USER hono
|
|
2957
|
-
|
|
2958
|
-
EXPOSE ${port}
|
|
2959
|
-
|
|
2960
|
-
# Use tini as entrypoint to handle PID 1 responsibilities
|
|
2961
|
-
ENTRYPOINT ["/sbin/tini", "--"]
|
|
2962
|
-
CMD ["node", "server.mjs"]
|
|
2963
|
-
`;
|
|
2964
|
-
}
|
|
2419
|
+
var HostingerApiError = class extends Error {
|
|
2420
|
+
constructor(message, status, statusText, errors) {
|
|
2421
|
+
super(message);
|
|
2422
|
+
this.status = status;
|
|
2423
|
+
this.statusText = statusText;
|
|
2424
|
+
this.errors = errors;
|
|
2425
|
+
this.name = "HostingerApiError";
|
|
2426
|
+
}
|
|
2427
|
+
};
|
|
2965
2428
|
/**
|
|
2966
|
-
*
|
|
2429
|
+
* Hostinger DNS API client
|
|
2430
|
+
*
|
|
2431
|
+
* @example
|
|
2432
|
+
* ```ts
|
|
2433
|
+
* const api = new HostingerApi(token);
|
|
2434
|
+
*
|
|
2435
|
+
* // Get all records for a domain
|
|
2436
|
+
* const records = await api.getRecords('traflabs.io');
|
|
2437
|
+
*
|
|
2438
|
+
* // Create/update records
|
|
2439
|
+
* await api.upsertRecords('traflabs.io', [
|
|
2440
|
+
* { name: 'api.joemoer', type: 'A', ttl: 300, records: ['1.2.3.4'] }
|
|
2441
|
+
* ]);
|
|
2442
|
+
* ```
|
|
2967
2443
|
*/
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
.
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
.
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
.
|
|
3007
|
-
|
|
2444
|
+
var HostingerApi = class {
|
|
2445
|
+
token;
|
|
2446
|
+
constructor(token) {
|
|
2447
|
+
this.token = token;
|
|
2448
|
+
}
|
|
2449
|
+
/**
|
|
2450
|
+
* Make a request to the Hostinger API
|
|
2451
|
+
*/
|
|
2452
|
+
async request(method, endpoint, body) {
|
|
2453
|
+
const url = `${HOSTINGER_API_BASE}${endpoint}`;
|
|
2454
|
+
const response = await fetch(url, {
|
|
2455
|
+
method,
|
|
2456
|
+
headers: {
|
|
2457
|
+
"Content-Type": "application/json",
|
|
2458
|
+
Authorization: `Bearer ${this.token}`
|
|
2459
|
+
},
|
|
2460
|
+
body: body ? JSON.stringify(body) : void 0
|
|
2461
|
+
});
|
|
2462
|
+
if (!response.ok) {
|
|
2463
|
+
let errorMessage = `Hostinger API error: ${response.status} ${response.statusText}`;
|
|
2464
|
+
let errors;
|
|
2465
|
+
try {
|
|
2466
|
+
const errorBody = await response.json();
|
|
2467
|
+
if (errorBody.message) errorMessage = `Hostinger API error: ${errorBody.message}`;
|
|
2468
|
+
errors = errorBody.errors;
|
|
2469
|
+
} catch {}
|
|
2470
|
+
throw new HostingerApiError(errorMessage, response.status, response.statusText, errors);
|
|
2471
|
+
}
|
|
2472
|
+
const text = await response.text();
|
|
2473
|
+
if (!text || text.trim() === "") return void 0;
|
|
2474
|
+
return JSON.parse(text);
|
|
2475
|
+
}
|
|
2476
|
+
/**
|
|
2477
|
+
* Get all DNS records for a domain
|
|
2478
|
+
*
|
|
2479
|
+
* @param domain - Root domain (e.g., 'traflabs.io')
|
|
2480
|
+
*/
|
|
2481
|
+
async getRecords(domain) {
|
|
2482
|
+
const response = await this.request("GET", `/api/dns/v1/zones/${domain}`);
|
|
2483
|
+
return response.data || [];
|
|
2484
|
+
}
|
|
2485
|
+
/**
|
|
2486
|
+
* Create or update DNS records
|
|
2487
|
+
*
|
|
2488
|
+
* @param domain - Root domain (e.g., 'traflabs.io')
|
|
2489
|
+
* @param records - Records to create/update
|
|
2490
|
+
* @param overwrite - If true, replaces all existing records. If false, merges with existing.
|
|
2491
|
+
*/
|
|
2492
|
+
async upsertRecords(domain, records, overwrite = false) {
|
|
2493
|
+
await this.request("PUT", `/api/dns/v1/zones/${domain}`, {
|
|
2494
|
+
overwrite,
|
|
2495
|
+
zone: records
|
|
2496
|
+
});
|
|
2497
|
+
}
|
|
2498
|
+
/**
|
|
2499
|
+
* Validate DNS records before applying
|
|
2500
|
+
*
|
|
2501
|
+
* @param domain - Root domain (e.g., 'traflabs.io')
|
|
2502
|
+
* @param records - Records to validate
|
|
2503
|
+
* @returns true if valid, throws if invalid
|
|
2504
|
+
*/
|
|
2505
|
+
async validateRecords(domain, records) {
|
|
2506
|
+
await this.request("POST", `/api/dns/v1/zones/${domain}/validate`, {
|
|
2507
|
+
overwrite: false,
|
|
2508
|
+
zone: records
|
|
2509
|
+
});
|
|
2510
|
+
return true;
|
|
2511
|
+
}
|
|
2512
|
+
/**
|
|
2513
|
+
* Delete specific DNS records
|
|
2514
|
+
*
|
|
2515
|
+
* @param domain - Root domain (e.g., 'traflabs.io')
|
|
2516
|
+
* @param filters - Filters to match records for deletion
|
|
2517
|
+
*/
|
|
2518
|
+
async deleteRecords(domain, filters) {
|
|
2519
|
+
await this.request("DELETE", `/api/dns/v1/zones/${domain}`, { filters });
|
|
2520
|
+
}
|
|
2521
|
+
/**
|
|
2522
|
+
* Check if a specific record exists
|
|
2523
|
+
*
|
|
2524
|
+
* @param domain - Root domain (e.g., 'traflabs.io')
|
|
2525
|
+
* @param name - Subdomain name (e.g., 'api.joemoer')
|
|
2526
|
+
* @param type - Record type (e.g., 'A')
|
|
2527
|
+
*/
|
|
2528
|
+
async recordExists(domain, name$1, type$1 = "A") {
|
|
2529
|
+
const records = await this.getRecords(domain);
|
|
2530
|
+
return records.some((r) => r.name === name$1 && r.type === type$1);
|
|
2531
|
+
}
|
|
2532
|
+
/**
|
|
2533
|
+
* Create a single A record if it doesn't exist
|
|
2534
|
+
*
|
|
2535
|
+
* @param domain - Root domain (e.g., 'traflabs.io')
|
|
2536
|
+
* @param subdomain - Subdomain name (e.g., 'api.joemoer')
|
|
2537
|
+
* @param ip - IP address to point to
|
|
2538
|
+
* @param ttl - TTL in seconds (default: 300)
|
|
2539
|
+
* @returns true if created, false if already exists
|
|
2540
|
+
*/
|
|
2541
|
+
async createARecordIfNotExists(domain, subdomain, ip, ttl = 300) {
|
|
2542
|
+
const exists = await this.recordExists(domain, subdomain, "A");
|
|
2543
|
+
if (exists) return false;
|
|
2544
|
+
await this.upsertRecords(domain, [{
|
|
2545
|
+
name: subdomain,
|
|
2546
|
+
type: "A",
|
|
2547
|
+
ttl,
|
|
2548
|
+
records: [{ content: ip }]
|
|
2549
|
+
}]);
|
|
2550
|
+
return true;
|
|
2551
|
+
}
|
|
2552
|
+
};
|
|
3008
2553
|
|
|
3009
|
-
|
|
3010
|
-
|
|
3011
|
-
|
|
3012
|
-
|
|
3013
|
-
|
|
2554
|
+
//#endregion
|
|
2555
|
+
//#region src/deploy/dns/index.ts
|
|
2556
|
+
const logger$6 = console;
|
|
2557
|
+
/**
|
|
2558
|
+
* Resolve IP address from a hostname
|
|
2559
|
+
*/
|
|
2560
|
+
async function resolveHostnameToIp(hostname) {
|
|
2561
|
+
try {
|
|
2562
|
+
const addresses = await (0, node_dns_promises.lookup)(hostname, { family: 4 });
|
|
2563
|
+
return addresses.address;
|
|
2564
|
+
} catch (error) {
|
|
2565
|
+
throw new Error(`Failed to resolve IP for ${hostname}: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
2566
|
+
}
|
|
3014
2567
|
}
|
|
3015
2568
|
/**
|
|
3016
|
-
*
|
|
2569
|
+
* Extract subdomain from full hostname relative to root domain
|
|
2570
|
+
*
|
|
2571
|
+
* @example
|
|
2572
|
+
* extractSubdomain('api.joemoer.traflabs.io', 'traflabs.io') => 'api.joemoer'
|
|
2573
|
+
* extractSubdomain('joemoer.traflabs.io', 'traflabs.io') => 'joemoer'
|
|
3017
2574
|
*/
|
|
3018
|
-
function
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
# Run any custom startup scripts here
|
|
3023
|
-
# Example: wait for database
|
|
3024
|
-
# until nc -z $DB_HOST $DB_PORT; do
|
|
3025
|
-
# echo "Waiting for database..."
|
|
3026
|
-
# sleep 1
|
|
3027
|
-
# done
|
|
3028
|
-
|
|
3029
|
-
# Execute the main command
|
|
3030
|
-
exec "$@"
|
|
3031
|
-
`;
|
|
2575
|
+
function extractSubdomain(hostname, rootDomain) {
|
|
2576
|
+
if (!hostname.endsWith(rootDomain)) throw new Error(`Hostname ${hostname} is not under root domain ${rootDomain}`);
|
|
2577
|
+
const subdomain = hostname.slice(0, -(rootDomain.length + 1));
|
|
2578
|
+
return subdomain || "@";
|
|
3032
2579
|
}
|
|
3033
2580
|
/**
|
|
3034
|
-
*
|
|
2581
|
+
* Generate required DNS records for a deployment
|
|
3035
2582
|
*/
|
|
3036
|
-
function
|
|
3037
|
-
const
|
|
3038
|
-
|
|
3039
|
-
|
|
3040
|
-
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
};
|
|
2583
|
+
function generateRequiredRecords(appHostnames, rootDomain, serverIp) {
|
|
2584
|
+
const records = [];
|
|
2585
|
+
for (const [appName, hostname] of appHostnames) {
|
|
2586
|
+
const subdomain = extractSubdomain(hostname, rootDomain);
|
|
2587
|
+
records.push({
|
|
2588
|
+
hostname,
|
|
2589
|
+
subdomain,
|
|
2590
|
+
type: "A",
|
|
2591
|
+
value: serverIp,
|
|
2592
|
+
appName
|
|
2593
|
+
});
|
|
2594
|
+
}
|
|
2595
|
+
return records;
|
|
3050
2596
|
}
|
|
3051
2597
|
/**
|
|
3052
|
-
*
|
|
3053
|
-
* Uses turbo prune for monorepo optimization.
|
|
3054
|
-
* @internal Exported for testing
|
|
2598
|
+
* Print DNS records table
|
|
3055
2599
|
*/
|
|
3056
|
-
function
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
const
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
$
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3098
|
-
|
|
3099
|
-
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
2600
|
+
function printDnsRecordsTable(records, rootDomain) {
|
|
2601
|
+
logger$6.log(`\n 📋 DNS Records for ${rootDomain}:`);
|
|
2602
|
+
logger$6.log(" ┌─────────────────────────────────────┬──────┬─────────────────┬────────┐");
|
|
2603
|
+
logger$6.log(" │ Subdomain │ Type │ Value │ Status │");
|
|
2604
|
+
logger$6.log(" ├─────────────────────────────────────┼──────┼─────────────────┼────────┤");
|
|
2605
|
+
for (const record of records) {
|
|
2606
|
+
const subdomain = record.subdomain.padEnd(35);
|
|
2607
|
+
const type$1 = record.type.padEnd(4);
|
|
2608
|
+
const value = record.value.padEnd(15);
|
|
2609
|
+
let status;
|
|
2610
|
+
if (record.error) status = "✗";
|
|
2611
|
+
else if (record.created) status = "✓ new";
|
|
2612
|
+
else if (record.existed) status = "✓";
|
|
2613
|
+
else status = "?";
|
|
2614
|
+
logger$6.log(` │ ${subdomain} │ ${type$1} │ ${value} │ ${status.padEnd(6)} │`);
|
|
2615
|
+
}
|
|
2616
|
+
logger$6.log(" └─────────────────────────────────────┴──────┴─────────────────┴────────┘");
|
|
2617
|
+
}
|
|
2618
|
+
/**
|
|
2619
|
+
* Print DNS records in a simple format for manual setup
|
|
2620
|
+
*/
|
|
2621
|
+
function printDnsRecordsSimple(records, rootDomain) {
|
|
2622
|
+
logger$6.log("\n 📋 Required DNS Records:");
|
|
2623
|
+
logger$6.log(` Add these A records to your DNS provider (${rootDomain}):\n`);
|
|
2624
|
+
for (const record of records) logger$6.log(` ${record.subdomain} → ${record.value} (A record)`);
|
|
2625
|
+
logger$6.log("");
|
|
2626
|
+
}
|
|
2627
|
+
/**
|
|
2628
|
+
* Prompt for input (reuse from deploy/index.ts pattern)
|
|
2629
|
+
*/
|
|
2630
|
+
async function promptForToken(message) {
|
|
2631
|
+
const { stdin, stdout } = await import("node:process");
|
|
2632
|
+
if (!stdin.isTTY) throw new Error("Interactive input required for Hostinger token.");
|
|
2633
|
+
stdout.write(message);
|
|
2634
|
+
return new Promise((resolve$3) => {
|
|
2635
|
+
let value = "";
|
|
2636
|
+
const onData = (char) => {
|
|
2637
|
+
const c = char.toString();
|
|
2638
|
+
if (c === "\n" || c === "\r") {
|
|
2639
|
+
stdin.setRawMode(false);
|
|
2640
|
+
stdin.pause();
|
|
2641
|
+
stdin.removeListener("data", onData);
|
|
2642
|
+
stdout.write("\n");
|
|
2643
|
+
resolve$3(value);
|
|
2644
|
+
} else if (c === "") {
|
|
2645
|
+
stdin.setRawMode(false);
|
|
2646
|
+
stdin.pause();
|
|
2647
|
+
stdout.write("\n");
|
|
2648
|
+
process.exit(1);
|
|
2649
|
+
} else if (c === "" || c === "\b") {
|
|
2650
|
+
if (value.length > 0) value = value.slice(0, -1);
|
|
2651
|
+
} else value += c;
|
|
2652
|
+
};
|
|
2653
|
+
stdin.setRawMode(true);
|
|
2654
|
+
stdin.resume();
|
|
2655
|
+
stdin.on("data", onData);
|
|
2656
|
+
});
|
|
2657
|
+
}
|
|
2658
|
+
/**
|
|
2659
|
+
* Create DNS records using the configured provider
|
|
2660
|
+
*/
|
|
2661
|
+
async function createDnsRecords(records, dnsConfig) {
|
|
2662
|
+
const { provider, domain: rootDomain, ttl = 300 } = dnsConfig;
|
|
2663
|
+
if (provider === "manual") return records.map((r) => ({
|
|
2664
|
+
...r,
|
|
2665
|
+
created: false,
|
|
2666
|
+
existed: false
|
|
2667
|
+
}));
|
|
2668
|
+
if (provider === "hostinger") return createHostingerRecords(records, rootDomain, ttl);
|
|
2669
|
+
if (provider === "cloudflare") {
|
|
2670
|
+
logger$6.log(" ⚠ Cloudflare DNS integration not yet implemented");
|
|
2671
|
+
return records.map((r) => ({
|
|
2672
|
+
...r,
|
|
2673
|
+
error: "Cloudflare not implemented"
|
|
2674
|
+
}));
|
|
2675
|
+
}
|
|
2676
|
+
return records;
|
|
2677
|
+
}
|
|
2678
|
+
/**
|
|
2679
|
+
* Create DNS records at Hostinger
|
|
2680
|
+
*/
|
|
2681
|
+
async function createHostingerRecords(records, rootDomain, ttl) {
|
|
2682
|
+
let token = await getHostingerToken();
|
|
2683
|
+
if (!token) {
|
|
2684
|
+
logger$6.log("\n 📋 Hostinger API token not found.");
|
|
2685
|
+
logger$6.log(" Get your token from: https://hpanel.hostinger.com/profile/api\n");
|
|
2686
|
+
try {
|
|
2687
|
+
token = await promptForToken(" Hostinger API Token: ");
|
|
2688
|
+
await storeHostingerToken(token);
|
|
2689
|
+
logger$6.log(" ✓ Token saved");
|
|
2690
|
+
} catch {
|
|
2691
|
+
logger$6.log(" ⚠ Could not get token, skipping DNS creation");
|
|
2692
|
+
return records.map((r) => ({
|
|
2693
|
+
...r,
|
|
2694
|
+
error: "No API token"
|
|
2695
|
+
}));
|
|
2696
|
+
}
|
|
2697
|
+
}
|
|
2698
|
+
const api = new HostingerApi(token);
|
|
2699
|
+
const results = [];
|
|
2700
|
+
let existingRecords = [];
|
|
2701
|
+
try {
|
|
2702
|
+
existingRecords = await api.getRecords(rootDomain);
|
|
2703
|
+
} catch (error) {
|
|
2704
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2705
|
+
logger$6.log(` ⚠ Failed to fetch existing DNS records: ${message}`);
|
|
2706
|
+
return records.map((r) => ({
|
|
2707
|
+
...r,
|
|
2708
|
+
error: message
|
|
2709
|
+
}));
|
|
2710
|
+
}
|
|
2711
|
+
for (const record of records) {
|
|
2712
|
+
const existing = existingRecords.find((r) => r.name === record.subdomain && r.type === "A");
|
|
2713
|
+
if (existing) {
|
|
2714
|
+
results.push({
|
|
2715
|
+
...record,
|
|
2716
|
+
existed: true,
|
|
2717
|
+
created: false
|
|
2718
|
+
});
|
|
2719
|
+
continue;
|
|
2720
|
+
}
|
|
2721
|
+
try {
|
|
2722
|
+
await api.upsertRecords(rootDomain, [{
|
|
2723
|
+
name: record.subdomain,
|
|
2724
|
+
type: "A",
|
|
2725
|
+
ttl,
|
|
2726
|
+
records: [{ content: record.value }]
|
|
2727
|
+
}]);
|
|
2728
|
+
results.push({
|
|
2729
|
+
...record,
|
|
2730
|
+
created: true,
|
|
2731
|
+
existed: false
|
|
2732
|
+
});
|
|
2733
|
+
} catch (error) {
|
|
2734
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2735
|
+
results.push({
|
|
2736
|
+
...record,
|
|
2737
|
+
error: message
|
|
2738
|
+
});
|
|
2739
|
+
}
|
|
2740
|
+
}
|
|
2741
|
+
return results;
|
|
2742
|
+
}
|
|
2743
|
+
/**
|
|
2744
|
+
* Main DNS orchestration function for deployments
|
|
2745
|
+
*/
|
|
2746
|
+
async function orchestrateDns(appHostnames, dnsConfig, dokployEndpoint) {
|
|
2747
|
+
if (!dnsConfig) return null;
|
|
2748
|
+
const { domain: rootDomain, autoCreate = true } = dnsConfig;
|
|
2749
|
+
logger$6.log("\n🌐 Setting up DNS records...");
|
|
2750
|
+
let serverIp;
|
|
2751
|
+
try {
|
|
2752
|
+
const endpointUrl = new URL(dokployEndpoint);
|
|
2753
|
+
serverIp = await resolveHostnameToIp(endpointUrl.hostname);
|
|
2754
|
+
logger$6.log(` Server IP: ${serverIp} (from ${endpointUrl.hostname})`);
|
|
2755
|
+
} catch (error) {
|
|
2756
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2757
|
+
logger$6.log(` ⚠ Failed to resolve server IP: ${message}`);
|
|
2758
|
+
return null;
|
|
2759
|
+
}
|
|
2760
|
+
const requiredRecords = generateRequiredRecords(appHostnames, rootDomain, serverIp);
|
|
2761
|
+
if (requiredRecords.length === 0) {
|
|
2762
|
+
logger$6.log(" No DNS records needed");
|
|
2763
|
+
return {
|
|
2764
|
+
records: [],
|
|
2765
|
+
success: true,
|
|
2766
|
+
serverIp
|
|
2767
|
+
};
|
|
2768
|
+
}
|
|
2769
|
+
let finalRecords;
|
|
2770
|
+
if (autoCreate && dnsConfig.provider !== "manual") {
|
|
2771
|
+
logger$6.log(` Creating DNS records at ${dnsConfig.provider}...`);
|
|
2772
|
+
finalRecords = await createDnsRecords(requiredRecords, dnsConfig);
|
|
2773
|
+
const created = finalRecords.filter((r) => r.created).length;
|
|
2774
|
+
const existed = finalRecords.filter((r) => r.existed).length;
|
|
2775
|
+
const failed = finalRecords.filter((r) => r.error).length;
|
|
2776
|
+
if (created > 0) logger$6.log(` ✓ Created ${created} DNS record(s)`);
|
|
2777
|
+
if (existed > 0) logger$6.log(` ✓ ${existed} record(s) already exist`);
|
|
2778
|
+
if (failed > 0) logger$6.log(` ⚠ ${failed} record(s) failed`);
|
|
2779
|
+
} else finalRecords = requiredRecords;
|
|
2780
|
+
printDnsRecordsTable(finalRecords, rootDomain);
|
|
2781
|
+
const hasFailures = finalRecords.some((r) => r.error);
|
|
2782
|
+
if (dnsConfig.provider === "manual" || hasFailures) printDnsRecordsSimple(finalRecords.filter((r) => !r.created && !r.existed), rootDomain);
|
|
2783
|
+
return {
|
|
2784
|
+
records: finalRecords,
|
|
2785
|
+
success: !hasFailures,
|
|
2786
|
+
serverIp
|
|
2787
|
+
};
|
|
2788
|
+
}
|
|
2789
|
+
/**
|
|
2790
|
+
* Verify DNS records resolve correctly after deployment.
|
|
2791
|
+
*
|
|
2792
|
+
* This function:
|
|
2793
|
+
* 1. Checks state for previously verified hostnames (skips if already verified with same IP)
|
|
2794
|
+
* 2. Attempts to resolve each hostname to an IP
|
|
2795
|
+
* 3. Compares resolved IP with expected server IP
|
|
2796
|
+
* 4. Updates state with verification results
|
|
2797
|
+
*
|
|
2798
|
+
* @param appHostnames - Map of app names to hostnames
|
|
2799
|
+
* @param serverIp - Expected IP address the hostnames should resolve to
|
|
2800
|
+
* @param state - Deploy state for caching verification results
|
|
2801
|
+
* @returns Array of verification results
|
|
2802
|
+
*/
|
|
2803
|
+
async function verifyDnsRecords(appHostnames, serverIp, state) {
|
|
2804
|
+
const results = [];
|
|
2805
|
+
logger$6.log("\n🔍 Verifying DNS records...");
|
|
2806
|
+
for (const [appName, hostname] of appHostnames) {
|
|
2807
|
+
if (isDnsVerified(state, hostname, serverIp)) {
|
|
2808
|
+
logger$6.log(` ✓ ${hostname} (previously verified)`);
|
|
2809
|
+
results.push({
|
|
2810
|
+
hostname,
|
|
2811
|
+
appName,
|
|
2812
|
+
verified: true,
|
|
2813
|
+
expectedIp: serverIp,
|
|
2814
|
+
skipped: true
|
|
2815
|
+
});
|
|
2816
|
+
continue;
|
|
2817
|
+
}
|
|
2818
|
+
try {
|
|
2819
|
+
const resolvedIp = await resolveHostnameToIp(hostname);
|
|
2820
|
+
if (resolvedIp === serverIp) {
|
|
2821
|
+
setDnsVerification(state, hostname, serverIp);
|
|
2822
|
+
logger$6.log(` ✓ ${hostname} → ${resolvedIp}`);
|
|
2823
|
+
results.push({
|
|
2824
|
+
hostname,
|
|
2825
|
+
appName,
|
|
2826
|
+
verified: true,
|
|
2827
|
+
resolvedIp,
|
|
2828
|
+
expectedIp: serverIp
|
|
2829
|
+
});
|
|
2830
|
+
} else {
|
|
2831
|
+
logger$6.log(` ⚠ ${hostname} resolves to ${resolvedIp}, expected ${serverIp}`);
|
|
2832
|
+
results.push({
|
|
2833
|
+
hostname,
|
|
2834
|
+
appName,
|
|
2835
|
+
verified: false,
|
|
2836
|
+
resolvedIp,
|
|
2837
|
+
expectedIp: serverIp
|
|
2838
|
+
});
|
|
2839
|
+
}
|
|
2840
|
+
} catch (error) {
|
|
2841
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2842
|
+
logger$6.log(` ⚠ ${hostname} DNS not propagated (${message})`);
|
|
2843
|
+
results.push({
|
|
2844
|
+
hostname,
|
|
2845
|
+
appName,
|
|
2846
|
+
verified: false,
|
|
2847
|
+
expectedIp: serverIp,
|
|
2848
|
+
error: message
|
|
2849
|
+
});
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
const verified = results.filter((r) => r.verified).length;
|
|
2853
|
+
const skipped = results.filter((r) => r.skipped).length;
|
|
2854
|
+
const pending = results.filter((r) => !r.verified).length;
|
|
2855
|
+
if (pending > 0) {
|
|
2856
|
+
logger$6.log(`\n ${verified} verified, ${pending} pending propagation`);
|
|
2857
|
+
logger$6.log(" DNS changes may take 5-30 minutes to propagate");
|
|
2858
|
+
} else if (skipped > 0) logger$6.log(` ${verified} verified (${skipped} from cache)`);
|
|
2859
|
+
return results;
|
|
2860
|
+
}
|
|
3146
2861
|
|
|
3147
|
-
|
|
2862
|
+
//#endregion
|
|
2863
|
+
//#region src/docker/compose.ts
|
|
2864
|
+
/** Default Docker images for services */
|
|
2865
|
+
const DEFAULT_SERVICE_IMAGES = {
|
|
2866
|
+
postgres: "postgres",
|
|
2867
|
+
redis: "redis",
|
|
2868
|
+
rabbitmq: "rabbitmq"
|
|
2869
|
+
};
|
|
2870
|
+
/** Default Docker image versions for services */
|
|
2871
|
+
const DEFAULT_SERVICE_VERSIONS = {
|
|
2872
|
+
postgres: "16-alpine",
|
|
2873
|
+
redis: "7-alpine",
|
|
2874
|
+
rabbitmq: "3-management-alpine"
|
|
2875
|
+
};
|
|
2876
|
+
/** Get the default full image reference for a service */
|
|
2877
|
+
function getDefaultImage(serviceName) {
|
|
2878
|
+
return `${DEFAULT_SERVICE_IMAGES[serviceName]}:${DEFAULT_SERVICE_VERSIONS[serviceName]}`;
|
|
2879
|
+
}
|
|
2880
|
+
/** Normalize services config to a consistent format - returns Map of service name to full image reference */
|
|
2881
|
+
function normalizeServices(services) {
|
|
2882
|
+
const result = /* @__PURE__ */ new Map();
|
|
2883
|
+
if (Array.isArray(services)) for (const name$1 of services) result.set(name$1, getDefaultImage(name$1));
|
|
2884
|
+
else for (const [name$1, config] of Object.entries(services)) {
|
|
2885
|
+
const serviceName = name$1;
|
|
2886
|
+
if (config === true) result.set(serviceName, getDefaultImage(serviceName));
|
|
2887
|
+
else if (config && typeof config === "object") {
|
|
2888
|
+
const serviceConfig = config;
|
|
2889
|
+
if (serviceConfig.image) result.set(serviceName, serviceConfig.image);
|
|
2890
|
+
else {
|
|
2891
|
+
const version$1 = serviceConfig.version ?? DEFAULT_SERVICE_VERSIONS[serviceName];
|
|
2892
|
+
result.set(serviceName, `${DEFAULT_SERVICE_IMAGES[serviceName]}:${version$1}`);
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
}
|
|
2896
|
+
return result;
|
|
2897
|
+
}
|
|
2898
|
+
/**
|
|
2899
|
+
* Generate docker-compose.yml for production deployment
|
|
2900
|
+
*/
|
|
2901
|
+
function generateDockerCompose(options) {
|
|
2902
|
+
const { imageName, registry, port, healthCheckPath, services } = options;
|
|
2903
|
+
const serviceMap = normalizeServices(services);
|
|
2904
|
+
const imageRef = registry ? `\${REGISTRY:-${registry}}/` : "";
|
|
2905
|
+
let yaml = `version: '3.8'
|
|
3148
2906
|
|
|
3149
|
-
|
|
3150
|
-
|
|
2907
|
+
services:
|
|
2908
|
+
api:
|
|
2909
|
+
build:
|
|
2910
|
+
context: ../..
|
|
2911
|
+
dockerfile: .gkm/docker/Dockerfile
|
|
2912
|
+
image: ${imageRef}\${IMAGE_NAME:-${imageName}}:\${TAG:-latest}
|
|
2913
|
+
container_name: ${imageName}
|
|
2914
|
+
restart: unless-stopped
|
|
2915
|
+
ports:
|
|
2916
|
+
- "\${PORT:-${port}}:${port}"
|
|
2917
|
+
environment:
|
|
2918
|
+
- NODE_ENV=production
|
|
2919
|
+
`;
|
|
2920
|
+
if (serviceMap.has("postgres")) yaml += ` - DATABASE_URL=\${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/app}
|
|
2921
|
+
`;
|
|
2922
|
+
if (serviceMap.has("redis")) yaml += ` - REDIS_URL=\${REDIS_URL:-redis://redis:6379}
|
|
2923
|
+
`;
|
|
2924
|
+
if (serviceMap.has("rabbitmq")) yaml += ` - RABBITMQ_URL=\${RABBITMQ_URL:-amqp://rabbitmq:5672}
|
|
2925
|
+
`;
|
|
2926
|
+
yaml += ` healthcheck:
|
|
2927
|
+
test: ["CMD", "wget", "-q", "--spider", "http://localhost:${port}${healthCheckPath}"]
|
|
2928
|
+
interval: 30s
|
|
2929
|
+
timeout: 3s
|
|
2930
|
+
retries: 3
|
|
2931
|
+
`;
|
|
2932
|
+
if (serviceMap.size > 0) {
|
|
2933
|
+
yaml += ` depends_on:
|
|
2934
|
+
`;
|
|
2935
|
+
for (const serviceName of serviceMap.keys()) yaml += ` ${serviceName}:
|
|
2936
|
+
condition: service_healthy
|
|
2937
|
+
`;
|
|
2938
|
+
}
|
|
2939
|
+
yaml += ` networks:
|
|
2940
|
+
- app-network
|
|
2941
|
+
`;
|
|
2942
|
+
const postgresImage = serviceMap.get("postgres");
|
|
2943
|
+
if (postgresImage) yaml += `
|
|
2944
|
+
postgres:
|
|
2945
|
+
image: ${postgresImage}
|
|
2946
|
+
container_name: postgres
|
|
2947
|
+
restart: unless-stopped
|
|
2948
|
+
environment:
|
|
2949
|
+
POSTGRES_USER: \${POSTGRES_USER:-postgres}
|
|
2950
|
+
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:-postgres}
|
|
2951
|
+
POSTGRES_DB: \${POSTGRES_DB:-app}
|
|
2952
|
+
volumes:
|
|
2953
|
+
- postgres_data:/var/lib/postgresql/data
|
|
2954
|
+
healthcheck:
|
|
2955
|
+
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
2956
|
+
interval: 5s
|
|
2957
|
+
timeout: 5s
|
|
2958
|
+
retries: 5
|
|
2959
|
+
networks:
|
|
2960
|
+
- app-network
|
|
2961
|
+
`;
|
|
2962
|
+
const redisImage = serviceMap.get("redis");
|
|
2963
|
+
if (redisImage) yaml += `
|
|
2964
|
+
redis:
|
|
2965
|
+
image: ${redisImage}
|
|
2966
|
+
container_name: redis
|
|
2967
|
+
restart: unless-stopped
|
|
2968
|
+
volumes:
|
|
2969
|
+
- redis_data:/data
|
|
2970
|
+
healthcheck:
|
|
2971
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
2972
|
+
interval: 5s
|
|
2973
|
+
timeout: 5s
|
|
2974
|
+
retries: 5
|
|
2975
|
+
networks:
|
|
2976
|
+
- app-network
|
|
2977
|
+
`;
|
|
2978
|
+
const rabbitmqImage = serviceMap.get("rabbitmq");
|
|
2979
|
+
if (rabbitmqImage) yaml += `
|
|
2980
|
+
rabbitmq:
|
|
2981
|
+
image: ${rabbitmqImage}
|
|
2982
|
+
container_name: rabbitmq
|
|
2983
|
+
restart: unless-stopped
|
|
2984
|
+
environment:
|
|
2985
|
+
RABBITMQ_DEFAULT_USER: \${RABBITMQ_USER:-guest}
|
|
2986
|
+
RABBITMQ_DEFAULT_PASS: \${RABBITMQ_PASSWORD:-guest}
|
|
2987
|
+
ports:
|
|
2988
|
+
- "15672:15672" # Management UI
|
|
2989
|
+
volumes:
|
|
2990
|
+
- rabbitmq_data:/var/lib/rabbitmq
|
|
2991
|
+
healthcheck:
|
|
2992
|
+
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
|
|
2993
|
+
interval: 10s
|
|
2994
|
+
timeout: 5s
|
|
2995
|
+
retries: 5
|
|
2996
|
+
networks:
|
|
2997
|
+
- app-network
|
|
2998
|
+
`;
|
|
2999
|
+
yaml += `
|
|
3000
|
+
volumes:
|
|
3001
|
+
`;
|
|
3002
|
+
if (serviceMap.has("postgres")) yaml += ` postgres_data:
|
|
3003
|
+
`;
|
|
3004
|
+
if (serviceMap.has("redis")) yaml += ` redis_data:
|
|
3005
|
+
`;
|
|
3006
|
+
if (serviceMap.has("rabbitmq")) yaml += ` rabbitmq_data:
|
|
3007
|
+
`;
|
|
3008
|
+
yaml += `
|
|
3009
|
+
networks:
|
|
3010
|
+
app-network:
|
|
3011
|
+
driver: bridge
|
|
3151
3012
|
`;
|
|
3013
|
+
return yaml;
|
|
3152
3014
|
}
|
|
3153
3015
|
/**
|
|
3154
|
-
* Generate a
|
|
3155
|
-
* Uses turbo prune for monorepo optimization.
|
|
3156
|
-
* @internal Exported for testing
|
|
3016
|
+
* Generate a minimal docker-compose.yml for API only
|
|
3157
3017
|
*/
|
|
3158
|
-
function
|
|
3159
|
-
const {
|
|
3160
|
-
const
|
|
3161
|
-
|
|
3162
|
-
const turboInstallCmd = getTurboInstallCmd(packageManager);
|
|
3163
|
-
const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
|
|
3164
|
-
return `# syntax=docker/dockerfile:1
|
|
3165
|
-
# Backend Dockerfile with turbo prune optimization
|
|
3166
|
-
|
|
3167
|
-
# Stage 1: Prune monorepo
|
|
3168
|
-
FROM ${baseImage} AS pruner
|
|
3169
|
-
|
|
3170
|
-
WORKDIR /app
|
|
3171
|
-
|
|
3172
|
-
${installPm}
|
|
3173
|
-
|
|
3174
|
-
COPY . .
|
|
3175
|
-
|
|
3176
|
-
# Prune to only include necessary packages
|
|
3177
|
-
RUN ${turboCmd} prune ${turboPackage} --docker
|
|
3178
|
-
|
|
3179
|
-
# Stage 2: Install dependencies
|
|
3180
|
-
FROM ${baseImage} AS deps
|
|
3181
|
-
|
|
3182
|
-
WORKDIR /app
|
|
3183
|
-
|
|
3184
|
-
${installPm}
|
|
3185
|
-
|
|
3186
|
-
# Copy pruned lockfile and package.jsons
|
|
3187
|
-
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
3188
|
-
COPY --from=pruner /app/out/json/ ./
|
|
3189
|
-
|
|
3190
|
-
# Install dependencies
|
|
3191
|
-
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
3192
|
-
${turboInstallCmd}
|
|
3193
|
-
|
|
3194
|
-
# Stage 3: Build
|
|
3195
|
-
FROM deps AS builder
|
|
3196
|
-
|
|
3197
|
-
WORKDIR /app
|
|
3198
|
-
|
|
3199
|
-
# Build-time args for encrypted secrets
|
|
3200
|
-
ARG GKM_ENCRYPTED_CREDENTIALS=""
|
|
3201
|
-
ARG GKM_CREDENTIALS_IV=""
|
|
3202
|
-
|
|
3203
|
-
# Copy pruned source
|
|
3204
|
-
COPY --from=pruner /app/out/full/ ./
|
|
3205
|
-
|
|
3206
|
-
# Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
|
|
3207
|
-
# Using wildcard to make it optional for single-app projects
|
|
3208
|
-
COPY --from=pruner /app/gkm.config.* ./
|
|
3209
|
-
COPY --from=pruner /app/tsconfig.* ./
|
|
3210
|
-
|
|
3211
|
-
# Write encrypted credentials for gkm build to embed
|
|
3212
|
-
RUN if [ -n "$GKM_ENCRYPTED_CREDENTIALS" ]; then \
|
|
3213
|
-
mkdir -p ${appPath}/.gkm && \
|
|
3214
|
-
echo "$GKM_ENCRYPTED_CREDENTIALS" > ${appPath}/.gkm/credentials.enc && \
|
|
3215
|
-
echo "$GKM_CREDENTIALS_IV" > ${appPath}/.gkm/credentials.iv; \
|
|
3216
|
-
fi
|
|
3217
|
-
|
|
3218
|
-
# Build production server using gkm
|
|
3219
|
-
RUN cd ${appPath} && ./node_modules/.bin/gkm build --provider server --production
|
|
3220
|
-
|
|
3221
|
-
# Stage 4: Production
|
|
3222
|
-
FROM ${baseImage} AS runner
|
|
3223
|
-
|
|
3224
|
-
WORKDIR /app
|
|
3225
|
-
|
|
3226
|
-
RUN apk add --no-cache tini
|
|
3227
|
-
|
|
3228
|
-
RUN addgroup --system --gid 1001 nodejs && \\
|
|
3229
|
-
adduser --system --uid 1001 hono
|
|
3230
|
-
|
|
3231
|
-
# Copy bundled server
|
|
3232
|
-
COPY --from=builder --chown=hono:nodejs /app/${appPath}/.gkm/server/dist/server.mjs ./
|
|
3233
|
-
|
|
3234
|
-
ENV NODE_ENV=production
|
|
3235
|
-
ENV PORT=${port}
|
|
3236
|
-
|
|
3237
|
-
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
3238
|
-
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
3239
|
-
|
|
3240
|
-
USER hono
|
|
3018
|
+
function generateMinimalDockerCompose(options) {
|
|
3019
|
+
const { imageName, registry, port, healthCheckPath } = options;
|
|
3020
|
+
const imageRef = registry ? `\${REGISTRY:-${registry}}/` : "";
|
|
3021
|
+
return `version: '3.8'
|
|
3241
3022
|
|
|
3242
|
-
|
|
3023
|
+
services:
|
|
3024
|
+
api:
|
|
3025
|
+
build:
|
|
3026
|
+
context: ../..
|
|
3027
|
+
dockerfile: .gkm/docker/Dockerfile
|
|
3028
|
+
image: ${imageRef}\${IMAGE_NAME:-${imageName}}:\${TAG:-latest}
|
|
3029
|
+
container_name: ${imageName}
|
|
3030
|
+
restart: unless-stopped
|
|
3031
|
+
ports:
|
|
3032
|
+
- "\${PORT:-${port}}:${port}"
|
|
3033
|
+
environment:
|
|
3034
|
+
- NODE_ENV=production
|
|
3035
|
+
healthcheck:
|
|
3036
|
+
test: ["CMD", "wget", "-q", "--spider", "http://localhost:${port}${healthCheckPath}"]
|
|
3037
|
+
interval: 30s
|
|
3038
|
+
timeout: 3s
|
|
3039
|
+
retries: 3
|
|
3040
|
+
networks:
|
|
3041
|
+
- app-network
|
|
3243
3042
|
|
|
3244
|
-
|
|
3245
|
-
|
|
3043
|
+
networks:
|
|
3044
|
+
app-network:
|
|
3045
|
+
driver: bridge
|
|
3246
3046
|
`;
|
|
3247
3047
|
}
|
|
3248
3048
|
/**
|
|
3249
|
-
* Generate
|
|
3250
|
-
*
|
|
3251
|
-
* This is used for apps that don't use gkm routes (e.g., Better Auth servers).
|
|
3049
|
+
* Generate docker-compose.yml for a workspace with all apps as services.
|
|
3050
|
+
* Apps can communicate with each other via service names.
|
|
3252
3051
|
* @internal Exported for testing
|
|
3253
3052
|
*/
|
|
3254
|
-
function
|
|
3255
|
-
const {
|
|
3256
|
-
const
|
|
3257
|
-
const
|
|
3258
|
-
const
|
|
3259
|
-
const
|
|
3260
|
-
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
WORKDIR /app
|
|
3267
|
-
|
|
3268
|
-
${installPm}
|
|
3269
|
-
|
|
3270
|
-
COPY . .
|
|
3271
|
-
|
|
3272
|
-
# Prune to only include necessary packages
|
|
3273
|
-
RUN ${turboCmd} prune ${turboPackage} --docker
|
|
3274
|
-
|
|
3275
|
-
# Stage 2: Install dependencies
|
|
3276
|
-
FROM ${baseImage} AS deps
|
|
3277
|
-
|
|
3278
|
-
WORKDIR /app
|
|
3279
|
-
|
|
3280
|
-
${installPm}
|
|
3281
|
-
|
|
3282
|
-
# Copy pruned lockfile and package.jsons
|
|
3283
|
-
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
3284
|
-
COPY --from=pruner /app/out/json/ ./
|
|
3285
|
-
|
|
3286
|
-
# Install dependencies
|
|
3287
|
-
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
3288
|
-
${turboInstallCmd}
|
|
3289
|
-
|
|
3290
|
-
# Stage 3: Build with tsdown
|
|
3291
|
-
FROM deps AS builder
|
|
3292
|
-
|
|
3293
|
-
WORKDIR /app
|
|
3294
|
-
|
|
3295
|
-
# Build-time args for encrypted secrets
|
|
3296
|
-
ARG GKM_ENCRYPTED_CREDENTIALS=""
|
|
3297
|
-
ARG GKM_CREDENTIALS_IV=""
|
|
3298
|
-
|
|
3299
|
-
# Copy pruned source
|
|
3300
|
-
COPY --from=pruner /app/out/full/ ./
|
|
3301
|
-
|
|
3302
|
-
# Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
|
|
3303
|
-
# Using wildcard to make it optional for single-app projects
|
|
3304
|
-
COPY --from=pruner /app/tsconfig.* ./
|
|
3305
|
-
|
|
3306
|
-
# Write encrypted credentials for tsdown to embed via define
|
|
3307
|
-
RUN if [ -n "$GKM_ENCRYPTED_CREDENTIALS" ]; then \
|
|
3308
|
-
mkdir -p ${appPath}/.gkm && \
|
|
3309
|
-
echo "$GKM_ENCRYPTED_CREDENTIALS" > ${appPath}/.gkm/credentials.enc && \
|
|
3310
|
-
echo "$GKM_CREDENTIALS_IV" > ${appPath}/.gkm/credentials.iv; \
|
|
3311
|
-
fi
|
|
3312
|
-
|
|
3313
|
-
# Bundle entry point with esbuild (outputs to dist/index.mjs)
|
|
3314
|
-
# Creates a fully standalone bundle with all dependencies included
|
|
3315
|
-
# Use define to embed credentials if present
|
|
3316
|
-
RUN cd ${appPath} && \
|
|
3317
|
-
if [ -f .gkm/credentials.enc ]; then \
|
|
3318
|
-
CREDS=$(cat .gkm/credentials.enc) && \
|
|
3319
|
-
IV=$(cat .gkm/credentials.iv) && \
|
|
3320
|
-
npx esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \
|
|
3321
|
-
--outfile=dist/index.mjs --packages=bundle \
|
|
3322
|
-
--banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);' \
|
|
3323
|
-
--define:__GKM_ENCRYPTED_CREDENTIALS__="'\\"$CREDS\\"'" \
|
|
3324
|
-
--define:__GKM_CREDENTIALS_IV__="'\\"$IV\\"'"; \
|
|
3325
|
-
else \
|
|
3326
|
-
npx esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \
|
|
3327
|
-
--outfile=dist/index.mjs --packages=bundle \
|
|
3328
|
-
--banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);'; \
|
|
3329
|
-
fi
|
|
3330
|
-
|
|
3331
|
-
# Stage 4: Production
|
|
3332
|
-
FROM ${baseImage} AS runner
|
|
3333
|
-
|
|
3334
|
-
WORKDIR /app
|
|
3335
|
-
|
|
3336
|
-
RUN apk add --no-cache tini
|
|
3337
|
-
|
|
3338
|
-
RUN addgroup --system --gid 1001 nodejs && \\
|
|
3339
|
-
adduser --system --uid 1001 app
|
|
3340
|
-
|
|
3341
|
-
# Copy bundled output only (no node_modules needed - fully bundled)
|
|
3342
|
-
COPY --from=builder --chown=app:nodejs /app/${appPath}/dist/index.mjs ./
|
|
3343
|
-
|
|
3344
|
-
ENV NODE_ENV=production
|
|
3345
|
-
ENV PORT=${port}
|
|
3346
|
-
|
|
3347
|
-
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
3348
|
-
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
3349
|
-
|
|
3350
|
-
USER app
|
|
3351
|
-
|
|
3352
|
-
EXPOSE ${port}
|
|
3053
|
+
function generateWorkspaceCompose(workspace, options = {}) {
|
|
3054
|
+
const { registry } = options;
|
|
3055
|
+
const apps = Object.entries(workspace.apps);
|
|
3056
|
+
const services = workspace.services;
|
|
3057
|
+
const hasPostgres = services.db !== void 0 && services.db !== false;
|
|
3058
|
+
const hasRedis = services.cache !== void 0 && services.cache !== false;
|
|
3059
|
+
const hasMail = services.mail !== void 0 && services.mail !== false;
|
|
3060
|
+
const postgresImage = getInfraServiceImage("postgres", services.db);
|
|
3061
|
+
const redisImage = getInfraServiceImage("redis", services.cache);
|
|
3062
|
+
let yaml = `# Docker Compose for ${workspace.name} workspace
|
|
3063
|
+
# Generated by gkm - do not edit manually
|
|
3353
3064
|
|
|
3354
|
-
|
|
3355
|
-
|
|
3065
|
+
services:
|
|
3066
|
+
`;
|
|
3067
|
+
for (const [appName, app] of apps) yaml += generateAppService(appName, app, apps, {
|
|
3068
|
+
registry,
|
|
3069
|
+
hasPostgres,
|
|
3070
|
+
hasRedis
|
|
3071
|
+
});
|
|
3072
|
+
if (hasPostgres) yaml += `
|
|
3073
|
+
postgres:
|
|
3074
|
+
image: ${postgresImage}
|
|
3075
|
+
container_name: ${workspace.name}-postgres
|
|
3076
|
+
restart: unless-stopped
|
|
3077
|
+
environment:
|
|
3078
|
+
POSTGRES_USER: \${POSTGRES_USER:-postgres}
|
|
3079
|
+
POSTGRES_PASSWORD: \${POSTGRES_PASSWORD:-postgres}
|
|
3080
|
+
POSTGRES_DB: \${POSTGRES_DB:-app}
|
|
3081
|
+
volumes:
|
|
3082
|
+
- postgres_data:/var/lib/postgresql/data
|
|
3083
|
+
healthcheck:
|
|
3084
|
+
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
|
3085
|
+
interval: 5s
|
|
3086
|
+
timeout: 5s
|
|
3087
|
+
retries: 5
|
|
3088
|
+
networks:
|
|
3089
|
+
- workspace-network
|
|
3090
|
+
`;
|
|
3091
|
+
if (hasRedis) yaml += `
|
|
3092
|
+
redis:
|
|
3093
|
+
image: ${redisImage}
|
|
3094
|
+
container_name: ${workspace.name}-redis
|
|
3095
|
+
restart: unless-stopped
|
|
3096
|
+
volumes:
|
|
3097
|
+
- redis_data:/data
|
|
3098
|
+
healthcheck:
|
|
3099
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
3100
|
+
interval: 5s
|
|
3101
|
+
timeout: 5s
|
|
3102
|
+
retries: 5
|
|
3103
|
+
networks:
|
|
3104
|
+
- workspace-network
|
|
3105
|
+
`;
|
|
3106
|
+
if (hasMail) yaml += `
|
|
3107
|
+
mailpit:
|
|
3108
|
+
image: axllent/mailpit:latest
|
|
3109
|
+
container_name: ${workspace.name}-mailpit
|
|
3110
|
+
restart: unless-stopped
|
|
3111
|
+
ports:
|
|
3112
|
+
- "8025:8025" # Web UI
|
|
3113
|
+
- "1025:1025" # SMTP
|
|
3114
|
+
networks:
|
|
3115
|
+
- workspace-network
|
|
3116
|
+
`;
|
|
3117
|
+
yaml += `
|
|
3118
|
+
volumes:
|
|
3356
3119
|
`;
|
|
3120
|
+
if (hasPostgres) yaml += ` postgres_data:
|
|
3121
|
+
`;
|
|
3122
|
+
if (hasRedis) yaml += ` redis_data:
|
|
3123
|
+
`;
|
|
3124
|
+
yaml += `
|
|
3125
|
+
networks:
|
|
3126
|
+
workspace-network:
|
|
3127
|
+
driver: bridge
|
|
3128
|
+
`;
|
|
3129
|
+
return yaml;
|
|
3357
3130
|
}
|
|
3358
|
-
|
|
3359
|
-
//#endregion
|
|
3360
|
-
//#region src/docker/index.ts
|
|
3361
|
-
const logger$6 = console;
|
|
3362
3131
|
/**
|
|
3363
|
-
*
|
|
3364
|
-
* Generates Dockerfile, docker-compose.yml, and related files
|
|
3365
|
-
*
|
|
3366
|
-
* Default: Multi-stage Dockerfile that builds from source inside Docker
|
|
3367
|
-
* --slim: Slim Dockerfile that copies pre-built bundle (requires prior build)
|
|
3132
|
+
* Get infrastructure service image with version.
|
|
3368
3133
|
*/
|
|
3369
|
-
|
|
3370
|
-
const
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3134
|
+
function getInfraServiceImage(serviceName, config) {
|
|
3135
|
+
const defaults = {
|
|
3136
|
+
postgres: "postgres:16-alpine",
|
|
3137
|
+
redis: "redis:7-alpine"
|
|
3138
|
+
};
|
|
3139
|
+
if (!config || config === true) return defaults[serviceName];
|
|
3140
|
+
if (typeof config === "object") {
|
|
3141
|
+
if (config.image) return config.image;
|
|
3142
|
+
if (config.version) {
|
|
3143
|
+
const baseImage = serviceName === "postgres" ? "postgres" : "redis";
|
|
3144
|
+
return `${baseImage}:${config.version}`;
|
|
3145
|
+
}
|
|
3374
3146
|
}
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3381
|
-
|
|
3382
|
-
|
|
3383
|
-
|
|
3147
|
+
return defaults[serviceName];
|
|
3148
|
+
}
|
|
3149
|
+
/**
|
|
3150
|
+
* Generate a service definition for an app.
|
|
3151
|
+
*/
|
|
3152
|
+
function generateAppService(appName, app, allApps, options) {
|
|
3153
|
+
const { registry, hasPostgres, hasRedis } = options;
|
|
3154
|
+
const imageRef = registry ? `\${REGISTRY:-${registry}}/` : "";
|
|
3155
|
+
const healthCheckPath = app.type === "frontend" ? "/" : "/health";
|
|
3156
|
+
const healthCheckCmd = app.type === "frontend" ? `["CMD", "wget", "-q", "--spider", "http://localhost:${app.port}/"]` : `["CMD", "wget", "-q", "--spider", "http://localhost:${app.port}${healthCheckPath}"]`;
|
|
3157
|
+
let yaml = `
|
|
3158
|
+
${appName}:
|
|
3159
|
+
build:
|
|
3160
|
+
context: .
|
|
3161
|
+
dockerfile: .gkm/docker/Dockerfile.${appName}
|
|
3162
|
+
image: ${imageRef}\${${appName.toUpperCase()}_IMAGE:-${appName}}:\${TAG:-latest}
|
|
3163
|
+
container_name: ${appName}
|
|
3164
|
+
restart: unless-stopped
|
|
3165
|
+
ports:
|
|
3166
|
+
- "\${${appName.toUpperCase()}_PORT:-${app.port}}:${app.port}"
|
|
3167
|
+
environment:
|
|
3168
|
+
- NODE_ENV=production
|
|
3169
|
+
- PORT=${app.port}
|
|
3170
|
+
`;
|
|
3171
|
+
for (const dep of app.dependencies) {
|
|
3172
|
+
const depApp = allApps.find(([name$1]) => name$1 === dep)?.[1];
|
|
3173
|
+
if (depApp) yaml += ` - ${dep.toUpperCase()}_URL=http://${dep}:${depApp.port}
|
|
3174
|
+
`;
|
|
3384
3175
|
}
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
3400
|
-
|
|
3401
|
-
|
|
3402
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
const dockerfile = useSlim ? generateSlimDockerfile(templateOptions) : generateMultiStageDockerfile(templateOptions);
|
|
3414
|
-
const dockerMode = useSlim ? "slim" : useTurbo ? "turbo" : "multi-stage";
|
|
3415
|
-
const dockerfilePath = (0, node_path.join)(dockerDir, "Dockerfile");
|
|
3416
|
-
await (0, node_fs_promises.writeFile)(dockerfilePath, dockerfile);
|
|
3417
|
-
logger$6.log(`Generated: .gkm/docker/Dockerfile (${dockerMode}, ${packageManager})`);
|
|
3418
|
-
const composeOptions = {
|
|
3419
|
-
imageName: dockerConfig.imageName,
|
|
3420
|
-
registry: options.registry ?? dockerConfig.registry,
|
|
3421
|
-
port: dockerConfig.port,
|
|
3422
|
-
healthCheckPath,
|
|
3423
|
-
services: dockerConfig.compose?.services ?? {}
|
|
3424
|
-
};
|
|
3425
|
-
const hasServices = Array.isArray(composeOptions.services) ? composeOptions.services.length > 0 : Object.keys(composeOptions.services).length > 0;
|
|
3426
|
-
const dockerCompose = hasServices ? generateDockerCompose(composeOptions) : generateMinimalDockerCompose(composeOptions);
|
|
3427
|
-
const composePath = (0, node_path.join)(dockerDir, "docker-compose.yml");
|
|
3428
|
-
await (0, node_fs_promises.writeFile)(composePath, dockerCompose);
|
|
3429
|
-
logger$6.log("Generated: .gkm/docker/docker-compose.yml");
|
|
3430
|
-
const dockerignore = generateDockerignore();
|
|
3431
|
-
const dockerignorePath = (0, node_path.join)(process.cwd(), ".dockerignore");
|
|
3432
|
-
await (0, node_fs_promises.writeFile)(dockerignorePath, dockerignore);
|
|
3433
|
-
logger$6.log("Generated: .dockerignore (project root)");
|
|
3434
|
-
const entrypoint = generateDockerEntrypoint();
|
|
3435
|
-
const entrypointPath = (0, node_path.join)(dockerDir, "docker-entrypoint.sh");
|
|
3436
|
-
await (0, node_fs_promises.writeFile)(entrypointPath, entrypoint);
|
|
3437
|
-
logger$6.log("Generated: .gkm/docker/docker-entrypoint.sh");
|
|
3438
|
-
const result = {
|
|
3439
|
-
dockerfile: dockerfilePath,
|
|
3440
|
-
dockerCompose: composePath,
|
|
3441
|
-
dockerignore: dockerignorePath,
|
|
3442
|
-
entrypoint: entrypointPath
|
|
3443
|
-
};
|
|
3444
|
-
if (options.build) await buildDockerImage(dockerConfig.imageName, options);
|
|
3445
|
-
if (options.push) await pushDockerImage(dockerConfig.imageName, options);
|
|
3446
|
-
return result;
|
|
3176
|
+
if (app.type === "backend") {
|
|
3177
|
+
if (hasPostgres) yaml += ` - DATABASE_URL=\${DATABASE_URL:-postgresql://postgres:postgres@postgres:5432/app}
|
|
3178
|
+
`;
|
|
3179
|
+
if (hasRedis) yaml += ` - REDIS_URL=\${REDIS_URL:-redis://redis:6379}
|
|
3180
|
+
`;
|
|
3181
|
+
}
|
|
3182
|
+
yaml += ` healthcheck:
|
|
3183
|
+
test: ${healthCheckCmd}
|
|
3184
|
+
interval: 30s
|
|
3185
|
+
timeout: 3s
|
|
3186
|
+
retries: 3
|
|
3187
|
+
`;
|
|
3188
|
+
const dependencies$1 = [...app.dependencies];
|
|
3189
|
+
if (app.type === "backend") {
|
|
3190
|
+
if (hasPostgres) dependencies$1.push("postgres");
|
|
3191
|
+
if (hasRedis) dependencies$1.push("redis");
|
|
3192
|
+
}
|
|
3193
|
+
if (dependencies$1.length > 0) {
|
|
3194
|
+
yaml += ` depends_on:
|
|
3195
|
+
`;
|
|
3196
|
+
for (const dep of dependencies$1) yaml += ` ${dep}:
|
|
3197
|
+
condition: service_healthy
|
|
3198
|
+
`;
|
|
3199
|
+
}
|
|
3200
|
+
yaml += ` networks:
|
|
3201
|
+
- workspace-network
|
|
3202
|
+
`;
|
|
3203
|
+
return yaml;
|
|
3447
3204
|
}
|
|
3205
|
+
|
|
3206
|
+
//#endregion
|
|
3207
|
+
//#region src/docker/templates.ts
|
|
3208
|
+
const LOCKFILES = [
|
|
3209
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
3210
|
+
["bun.lockb", "bun"],
|
|
3211
|
+
["yarn.lock", "yarn"],
|
|
3212
|
+
["package-lock.json", "npm"]
|
|
3213
|
+
];
|
|
3448
3214
|
/**
|
|
3449
|
-
*
|
|
3450
|
-
*
|
|
3451
|
-
* Returns cleanup function if file was copied
|
|
3215
|
+
* Detect package manager from lockfiles
|
|
3216
|
+
* Walks up the directory tree to find lockfile (for monorepos)
|
|
3452
3217
|
*/
|
|
3453
|
-
function
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
return
|
|
3218
|
+
function detectPackageManager$1(cwd = process.cwd()) {
|
|
3219
|
+
let dir = cwd;
|
|
3220
|
+
const root = (0, node_path.parse)(dir).root;
|
|
3221
|
+
while (dir !== root) {
|
|
3222
|
+
for (const [lockfile, pm] of LOCKFILES) if ((0, node_fs.existsSync)((0, node_path.join)(dir, lockfile))) return pm;
|
|
3223
|
+
dir = (0, node_path.dirname)(dir);
|
|
3458
3224
|
}
|
|
3459
|
-
const
|
|
3460
|
-
|
|
3461
|
-
if (lockfilePath === localLockfile) return null;
|
|
3462
|
-
logger$6.log(` Copying ${lockfileName} from monorepo root...`);
|
|
3463
|
-
(0, node_fs.copyFileSync)(lockfilePath, localLockfile);
|
|
3464
|
-
return () => {
|
|
3465
|
-
try {
|
|
3466
|
-
(0, node_fs.unlinkSync)(localLockfile);
|
|
3467
|
-
} catch {}
|
|
3468
|
-
};
|
|
3225
|
+
for (const [lockfile, pm] of LOCKFILES) if ((0, node_fs.existsSync)((0, node_path.join)(root, lockfile))) return pm;
|
|
3226
|
+
return "pnpm";
|
|
3469
3227
|
}
|
|
3470
3228
|
/**
|
|
3471
|
-
*
|
|
3472
|
-
*
|
|
3229
|
+
* Find the lockfile path by walking up the directory tree
|
|
3230
|
+
* Returns the full path to the lockfile, or null if not found
|
|
3473
3231
|
*/
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
const
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
(0,
|
|
3483
|
-
cwd,
|
|
3484
|
-
stdio: "inherit",
|
|
3485
|
-
env: {
|
|
3486
|
-
...process.env,
|
|
3487
|
-
DOCKER_BUILDKIT: "1"
|
|
3488
|
-
}
|
|
3489
|
-
});
|
|
3490
|
-
logger$6.log(`✅ Docker image built: ${fullImageName}`);
|
|
3491
|
-
} catch (error) {
|
|
3492
|
-
throw new Error(`Failed to build Docker image: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
3493
|
-
} finally {
|
|
3494
|
-
cleanup?.();
|
|
3232
|
+
function findLockfilePath(cwd = process.cwd()) {
|
|
3233
|
+
let dir = cwd;
|
|
3234
|
+
const root = (0, node_path.parse)(dir).root;
|
|
3235
|
+
while (dir !== root) {
|
|
3236
|
+
for (const [lockfile] of LOCKFILES) {
|
|
3237
|
+
const lockfilePath = (0, node_path.join)(dir, lockfile);
|
|
3238
|
+
if ((0, node_fs.existsSync)(lockfilePath)) return lockfilePath;
|
|
3239
|
+
}
|
|
3240
|
+
dir = (0, node_path.dirname)(dir);
|
|
3495
3241
|
}
|
|
3242
|
+
for (const [lockfile] of LOCKFILES) {
|
|
3243
|
+
const lockfilePath = (0, node_path.join)(root, lockfile);
|
|
3244
|
+
if ((0, node_fs.existsSync)(lockfilePath)) return lockfilePath;
|
|
3245
|
+
}
|
|
3246
|
+
return null;
|
|
3496
3247
|
}
|
|
3497
3248
|
/**
|
|
3498
|
-
*
|
|
3249
|
+
* Check if we're in a monorepo (lockfile is in a parent directory)
|
|
3499
3250
|
*/
|
|
3500
|
-
|
|
3501
|
-
const
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
logger$6.log(`\n🚀 Pushing Docker image: ${fullImageName}`);
|
|
3506
|
-
try {
|
|
3507
|
-
(0, node_child_process.execSync)(`docker push ${fullImageName}`, {
|
|
3508
|
-
cwd: process.cwd(),
|
|
3509
|
-
stdio: "inherit"
|
|
3510
|
-
});
|
|
3511
|
-
logger$6.log(`✅ Docker image pushed: ${fullImageName}`);
|
|
3512
|
-
} catch (error) {
|
|
3513
|
-
throw new Error(`Failed to push Docker image: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
3514
|
-
}
|
|
3251
|
+
function isMonorepo(cwd = process.cwd()) {
|
|
3252
|
+
const lockfilePath = findLockfilePath(cwd);
|
|
3253
|
+
if (!lockfilePath) return false;
|
|
3254
|
+
const lockfileDir = (0, node_path.dirname)(lockfilePath);
|
|
3255
|
+
return lockfileDir !== cwd;
|
|
3515
3256
|
}
|
|
3516
3257
|
/**
|
|
3517
|
-
*
|
|
3258
|
+
* Check if turbo.json exists (walks up directory tree)
|
|
3518
3259
|
*/
|
|
3519
|
-
function
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
|
|
3525
|
-
return pkg$1.name;
|
|
3526
|
-
} catch {
|
|
3527
|
-
return void 0;
|
|
3260
|
+
function hasTurboConfig(cwd = process.cwd()) {
|
|
3261
|
+
let dir = cwd;
|
|
3262
|
+
const root = (0, node_path.parse)(dir).root;
|
|
3263
|
+
while (dir !== root) {
|
|
3264
|
+
if ((0, node_fs.existsSync)((0, node_path.join)(dir, "turbo.json"))) return true;
|
|
3265
|
+
dir = (0, node_path.dirname)(dir);
|
|
3528
3266
|
}
|
|
3267
|
+
return (0, node_fs.existsSync)((0, node_path.join)(root, "turbo.json"));
|
|
3529
3268
|
}
|
|
3530
3269
|
/**
|
|
3531
|
-
*
|
|
3532
|
-
*
|
|
3270
|
+
* Get install command for turbo builds (without frozen lockfile)
|
|
3271
|
+
* Turbo prune creates a subset that may not perfectly match the lockfile
|
|
3533
3272
|
*/
|
|
3534
|
-
|
|
3535
|
-
const
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
|
|
3539
|
-
|
|
3540
|
-
const packageManager = detectPackageManager$1(workspace.root);
|
|
3541
|
-
logger$6.log(` Package manager: ${packageManager}`);
|
|
3542
|
-
for (const [appName, app] of apps) {
|
|
3543
|
-
const appPath = app.path;
|
|
3544
|
-
const fullAppPath = (0, node_path.join)(workspace.root, appPath);
|
|
3545
|
-
const turboPackage = getAppPackageName(fullAppPath) ?? appName;
|
|
3546
|
-
const imageName = appName;
|
|
3547
|
-
const hasEntry = !!app.entry;
|
|
3548
|
-
const buildType = hasEntry ? "entry" : app.type;
|
|
3549
|
-
logger$6.log(`\n 📄 Generating Dockerfile for ${appName} (${buildType})`);
|
|
3550
|
-
let dockerfile;
|
|
3551
|
-
if (app.type === "frontend") dockerfile = generateNextjsDockerfile({
|
|
3552
|
-
imageName,
|
|
3553
|
-
baseImage: "node:22-alpine",
|
|
3554
|
-
port: app.port,
|
|
3555
|
-
appPath,
|
|
3556
|
-
turboPackage,
|
|
3557
|
-
packageManager
|
|
3558
|
-
});
|
|
3559
|
-
else if (app.entry) dockerfile = generateEntryDockerfile({
|
|
3560
|
-
imageName,
|
|
3561
|
-
baseImage: "node:22-alpine",
|
|
3562
|
-
port: app.port,
|
|
3563
|
-
appPath,
|
|
3564
|
-
entry: app.entry,
|
|
3565
|
-
turboPackage,
|
|
3566
|
-
packageManager,
|
|
3567
|
-
healthCheckPath: "/health"
|
|
3568
|
-
});
|
|
3569
|
-
else dockerfile = generateBackendDockerfile({
|
|
3570
|
-
imageName,
|
|
3571
|
-
baseImage: "node:22-alpine",
|
|
3572
|
-
port: app.port,
|
|
3573
|
-
appPath,
|
|
3574
|
-
turboPackage,
|
|
3575
|
-
packageManager,
|
|
3576
|
-
healthCheckPath: "/health"
|
|
3577
|
-
});
|
|
3578
|
-
const dockerfilePath = (0, node_path.join)(dockerDir, `Dockerfile.${appName}`);
|
|
3579
|
-
await (0, node_fs_promises.writeFile)(dockerfilePath, dockerfile);
|
|
3580
|
-
logger$6.log(` Generated: .gkm/docker/Dockerfile.${appName}`);
|
|
3581
|
-
results.push({
|
|
3582
|
-
appName,
|
|
3583
|
-
type: app.type,
|
|
3584
|
-
dockerfile: dockerfilePath,
|
|
3585
|
-
imageName
|
|
3586
|
-
});
|
|
3587
|
-
}
|
|
3588
|
-
const dockerignore = generateDockerignore();
|
|
3589
|
-
const dockerignorePath = (0, node_path.join)(workspace.root, ".dockerignore");
|
|
3590
|
-
await (0, node_fs_promises.writeFile)(dockerignorePath, dockerignore);
|
|
3591
|
-
logger$6.log(`\n Generated: .dockerignore (workspace root)`);
|
|
3592
|
-
const dockerCompose = generateWorkspaceCompose(workspace, { registry: options.registry });
|
|
3593
|
-
const composePath = (0, node_path.join)(dockerDir, "docker-compose.yml");
|
|
3594
|
-
await (0, node_fs_promises.writeFile)(composePath, dockerCompose);
|
|
3595
|
-
logger$6.log(` Generated: .gkm/docker/docker-compose.yml`);
|
|
3596
|
-
logger$6.log(`\n✅ Generated ${results.length} Dockerfile(s) + docker-compose.yml`);
|
|
3597
|
-
logger$6.log("\n📋 Build commands:");
|
|
3598
|
-
for (const result of results) {
|
|
3599
|
-
const icon = result.type === "backend" ? "⚙️" : "🌐";
|
|
3600
|
-
logger$6.log(` ${icon} docker build -f .gkm/docker/Dockerfile.${result.appName} -t ${result.imageName} .`);
|
|
3601
|
-
}
|
|
3602
|
-
logger$6.log("\n📋 Run all services:");
|
|
3603
|
-
logger$6.log(" docker compose -f .gkm/docker/docker-compose.yml up --build");
|
|
3604
|
-
return {
|
|
3605
|
-
apps: results,
|
|
3606
|
-
dockerCompose: composePath,
|
|
3607
|
-
dockerignore: dockerignorePath
|
|
3273
|
+
function getTurboInstallCmd(pm) {
|
|
3274
|
+
const commands = {
|
|
3275
|
+
pnpm: "pnpm install",
|
|
3276
|
+
npm: "npm install",
|
|
3277
|
+
yarn: "yarn install",
|
|
3278
|
+
bun: "bun install"
|
|
3608
3279
|
};
|
|
3280
|
+
return commands[pm];
|
|
3609
3281
|
}
|
|
3610
|
-
|
|
3611
|
-
//#endregion
|
|
3612
|
-
//#region src/deploy/docker.ts
|
|
3613
3282
|
/**
|
|
3614
|
-
* Get
|
|
3615
|
-
* Used for Dokploy app/project naming
|
|
3283
|
+
* Get package manager specific commands and paths
|
|
3616
3284
|
*/
|
|
3617
|
-
function
|
|
3618
|
-
const
|
|
3619
|
-
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
|
|
3623
|
-
|
|
3624
|
-
|
|
3285
|
+
function getPmConfig(pm) {
|
|
3286
|
+
const configs = {
|
|
3287
|
+
pnpm: {
|
|
3288
|
+
install: "corepack enable && corepack prepare pnpm@latest --activate",
|
|
3289
|
+
lockfile: "pnpm-lock.yaml",
|
|
3290
|
+
fetch: "pnpm fetch",
|
|
3291
|
+
installCmd: "pnpm install --frozen-lockfile --offline",
|
|
3292
|
+
cacheTarget: "/root/.local/share/pnpm/store",
|
|
3293
|
+
cacheId: "pnpm",
|
|
3294
|
+
run: "pnpm",
|
|
3295
|
+
exec: "pnpm exec",
|
|
3296
|
+
dlx: "pnpm dlx",
|
|
3297
|
+
addGlobal: "pnpm add -g"
|
|
3298
|
+
},
|
|
3299
|
+
npm: {
|
|
3300
|
+
install: "",
|
|
3301
|
+
lockfile: "package-lock.json",
|
|
3302
|
+
fetch: "",
|
|
3303
|
+
installCmd: "npm ci",
|
|
3304
|
+
cacheTarget: "/root/.npm",
|
|
3305
|
+
cacheId: "npm",
|
|
3306
|
+
run: "npm run",
|
|
3307
|
+
exec: "npx",
|
|
3308
|
+
dlx: "npx",
|
|
3309
|
+
addGlobal: "npm install -g"
|
|
3310
|
+
},
|
|
3311
|
+
yarn: {
|
|
3312
|
+
install: "corepack enable && corepack prepare yarn@stable --activate",
|
|
3313
|
+
lockfile: "yarn.lock",
|
|
3314
|
+
fetch: "",
|
|
3315
|
+
installCmd: "yarn install --frozen-lockfile",
|
|
3316
|
+
cacheTarget: "/root/.yarn/cache",
|
|
3317
|
+
cacheId: "yarn",
|
|
3318
|
+
run: "yarn",
|
|
3319
|
+
exec: "yarn exec",
|
|
3320
|
+
dlx: "yarn dlx",
|
|
3321
|
+
addGlobal: "yarn global add"
|
|
3322
|
+
},
|
|
3323
|
+
bun: {
|
|
3324
|
+
install: "npm install -g bun",
|
|
3325
|
+
lockfile: "bun.lockb",
|
|
3326
|
+
fetch: "",
|
|
3327
|
+
installCmd: "bun install --frozen-lockfile",
|
|
3328
|
+
cacheTarget: "/root/.bun/install/cache",
|
|
3329
|
+
cacheId: "bun",
|
|
3330
|
+
run: "bun run",
|
|
3331
|
+
exec: "bunx",
|
|
3332
|
+
dlx: "bunx",
|
|
3333
|
+
addGlobal: "bun add -g"
|
|
3334
|
+
}
|
|
3335
|
+
};
|
|
3336
|
+
return configs[pm];
|
|
3625
3337
|
}
|
|
3626
3338
|
/**
|
|
3627
|
-
*
|
|
3628
|
-
*
|
|
3339
|
+
* Generate a multi-stage Dockerfile for building from source
|
|
3340
|
+
* Optimized for build speed with:
|
|
3341
|
+
* - BuildKit cache mounts for package manager store
|
|
3342
|
+
* - pnpm fetch for better layer caching (when using pnpm)
|
|
3343
|
+
* - Optional turbo prune for monorepos
|
|
3629
3344
|
*/
|
|
3630
|
-
function
|
|
3631
|
-
const
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3345
|
+
function generateMultiStageDockerfile(options) {
|
|
3346
|
+
const { baseImage, port, healthCheckPath, turbo, turboPackage, packageManager } = options;
|
|
3347
|
+
if (turbo) return generateTurboDockerfile({
|
|
3348
|
+
...options,
|
|
3349
|
+
turboPackage: turboPackage ?? "api"
|
|
3350
|
+
});
|
|
3351
|
+
const pm = getPmConfig(packageManager);
|
|
3352
|
+
const installPm = pm.install ? `\n# Install ${packageManager}\nRUN ${pm.install}\n` : "";
|
|
3353
|
+
const hasFetch = packageManager === "pnpm";
|
|
3354
|
+
const depsStage = hasFetch ? `# Copy lockfile first for better caching
|
|
3355
|
+
COPY ${pm.lockfile} ./
|
|
3356
|
+
|
|
3357
|
+
# Fetch dependencies (downloads to virtual store, cached separately)
|
|
3358
|
+
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
3359
|
+
${pm.fetch}
|
|
3360
|
+
|
|
3361
|
+
# Copy package.json after fetch
|
|
3362
|
+
COPY package.json ./
|
|
3363
|
+
|
|
3364
|
+
# Install from cache (fast - no network needed)
|
|
3365
|
+
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
3366
|
+
${pm.installCmd}` : `# Copy package files
|
|
3367
|
+
COPY package.json ${pm.lockfile} ./
|
|
3368
|
+
|
|
3369
|
+
# Install dependencies with cache
|
|
3370
|
+
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
3371
|
+
${pm.installCmd}`;
|
|
3372
|
+
return `# syntax=docker/dockerfile:1
|
|
3373
|
+
# Stage 1: Dependencies
|
|
3374
|
+
FROM ${baseImage} AS deps
|
|
3375
|
+
|
|
3376
|
+
WORKDIR /app
|
|
3377
|
+
${installPm}
|
|
3378
|
+
${depsStage}
|
|
3379
|
+
|
|
3380
|
+
# Stage 2: Build
|
|
3381
|
+
FROM deps AS builder
|
|
3382
|
+
|
|
3383
|
+
WORKDIR /app
|
|
3384
|
+
|
|
3385
|
+
# Copy source (deps already installed)
|
|
3386
|
+
COPY . .
|
|
3387
|
+
|
|
3388
|
+
# Debug: Show node_modules/.bin contents and build production server
|
|
3389
|
+
RUN echo "=== node_modules/.bin contents ===" && \
|
|
3390
|
+
ls -la node_modules/.bin/ 2>/dev/null || echo "node_modules/.bin not found" && \
|
|
3391
|
+
echo "=== Checking for gkm ===" && \
|
|
3392
|
+
which gkm 2>/dev/null || echo "gkm not in PATH" && \
|
|
3393
|
+
ls -la node_modules/.bin/gkm 2>/dev/null || echo "gkm binary not found in node_modules/.bin" && \
|
|
3394
|
+
echo "=== Running build ===" && \
|
|
3395
|
+
./node_modules/.bin/gkm build --provider server --production
|
|
3396
|
+
|
|
3397
|
+
# Stage 3: Production
|
|
3398
|
+
FROM ${baseImage} AS runner
|
|
3399
|
+
|
|
3400
|
+
WORKDIR /app
|
|
3401
|
+
|
|
3402
|
+
# Install tini for proper signal handling as PID 1
|
|
3403
|
+
RUN apk add --no-cache tini
|
|
3404
|
+
|
|
3405
|
+
# Create non-root user
|
|
3406
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
3407
|
+
adduser --system --uid 1001 hono
|
|
3408
|
+
|
|
3409
|
+
# Copy bundled server
|
|
3410
|
+
COPY --from=builder --chown=hono:nodejs /app/.gkm/server/dist/server.mjs ./
|
|
3411
|
+
|
|
3412
|
+
# Environment
|
|
3413
|
+
ENV NODE_ENV=production
|
|
3414
|
+
ENV PORT=${port}
|
|
3415
|
+
|
|
3416
|
+
# Health check
|
|
3417
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
3418
|
+
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
3419
|
+
|
|
3420
|
+
# Switch to non-root user
|
|
3421
|
+
USER hono
|
|
3422
|
+
|
|
3423
|
+
EXPOSE ${port}
|
|
3424
|
+
|
|
3425
|
+
# Use tini as entrypoint to handle PID 1 responsibilities
|
|
3426
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
3427
|
+
CMD ["node", "server.mjs"]
|
|
3428
|
+
`;
|
|
3642
3429
|
}
|
|
3643
|
-
const logger$5 = console;
|
|
3644
3430
|
/**
|
|
3645
|
-
*
|
|
3431
|
+
* Generate a Dockerfile optimized for Turbo monorepos
|
|
3432
|
+
* Uses turbo prune to create minimal Docker context
|
|
3646
3433
|
*/
|
|
3647
|
-
function
|
|
3648
|
-
|
|
3649
|
-
|
|
3434
|
+
function generateTurboDockerfile(options) {
|
|
3435
|
+
const { baseImage, port, healthCheckPath, turboPackage, packageManager } = options;
|
|
3436
|
+
const pm = getPmConfig(packageManager);
|
|
3437
|
+
const installPm = pm.install ? `RUN ${pm.install}` : "";
|
|
3438
|
+
const turboInstallCmd = getTurboInstallCmd(packageManager);
|
|
3439
|
+
const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
|
|
3440
|
+
return `# syntax=docker/dockerfile:1
|
|
3441
|
+
# Stage 1: Prune monorepo
|
|
3442
|
+
FROM ${baseImage} AS pruner
|
|
3443
|
+
|
|
3444
|
+
WORKDIR /app
|
|
3445
|
+
|
|
3446
|
+
${installPm}
|
|
3447
|
+
|
|
3448
|
+
COPY . .
|
|
3449
|
+
|
|
3450
|
+
# Prune to only include necessary packages
|
|
3451
|
+
RUN ${turboCmd} prune ${turboPackage} --docker
|
|
3452
|
+
|
|
3453
|
+
# Stage 2: Install dependencies
|
|
3454
|
+
FROM ${baseImage} AS deps
|
|
3455
|
+
|
|
3456
|
+
WORKDIR /app
|
|
3457
|
+
|
|
3458
|
+
${installPm}
|
|
3459
|
+
|
|
3460
|
+
# Copy pruned lockfile and package.jsons
|
|
3461
|
+
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
3462
|
+
COPY --from=pruner /app/out/json/ ./
|
|
3463
|
+
|
|
3464
|
+
# Install dependencies (no frozen-lockfile since turbo prune creates a subset)
|
|
3465
|
+
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
3466
|
+
${turboInstallCmd}
|
|
3467
|
+
|
|
3468
|
+
# Stage 3: Build
|
|
3469
|
+
FROM deps AS builder
|
|
3470
|
+
|
|
3471
|
+
WORKDIR /app
|
|
3472
|
+
|
|
3473
|
+
# Copy pruned source
|
|
3474
|
+
COPY --from=pruner /app/out/full/ ./
|
|
3475
|
+
|
|
3476
|
+
# Debug: Show node_modules/.bin contents and build production server
|
|
3477
|
+
RUN echo "=== node_modules/.bin contents ===" && \
|
|
3478
|
+
ls -la node_modules/.bin/ 2>/dev/null || echo "node_modules/.bin not found" && \
|
|
3479
|
+
echo "=== Checking for gkm ===" && \
|
|
3480
|
+
which gkm 2>/dev/null || echo "gkm not in PATH" && \
|
|
3481
|
+
ls -la node_modules/.bin/gkm 2>/dev/null || echo "gkm binary not found in node_modules/.bin" && \
|
|
3482
|
+
echo "=== Running build ===" && \
|
|
3483
|
+
./node_modules/.bin/gkm build --provider server --production
|
|
3484
|
+
|
|
3485
|
+
# Stage 4: Production
|
|
3486
|
+
FROM ${baseImage} AS runner
|
|
3487
|
+
|
|
3488
|
+
WORKDIR /app
|
|
3489
|
+
|
|
3490
|
+
RUN apk add --no-cache tini
|
|
3491
|
+
|
|
3492
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
3493
|
+
adduser --system --uid 1001 hono
|
|
3494
|
+
|
|
3495
|
+
COPY --from=builder --chown=hono:nodejs /app/.gkm/server/dist/server.mjs ./
|
|
3496
|
+
|
|
3497
|
+
ENV NODE_ENV=production
|
|
3498
|
+
ENV PORT=${port}
|
|
3499
|
+
|
|
3500
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
3501
|
+
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
3502
|
+
|
|
3503
|
+
USER hono
|
|
3504
|
+
|
|
3505
|
+
EXPOSE ${port}
|
|
3506
|
+
|
|
3507
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
3508
|
+
CMD ["node", "server.mjs"]
|
|
3509
|
+
`;
|
|
3650
3510
|
}
|
|
3651
3511
|
/**
|
|
3652
|
-
*
|
|
3653
|
-
* @param imageRef - Full image reference (registry/name:tag)
|
|
3654
|
-
* @param appName - Name of the app (used for Dockerfile.{appName} in workspaces)
|
|
3655
|
-
* @param buildArgs - Build arguments to pass to docker build
|
|
3512
|
+
* Generate a slim Dockerfile for pre-built bundles
|
|
3656
3513
|
*/
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3667
|
-
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
|
|
3673
|
-
|
|
3674
|
-
|
|
3675
|
-
|
|
3676
|
-
|
|
3677
|
-
|
|
3678
|
-
|
|
3679
|
-
|
|
3680
|
-
|
|
3681
|
-
|
|
3682
|
-
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
|
|
3514
|
+
function generateSlimDockerfile(options) {
|
|
3515
|
+
const { baseImage, port, healthCheckPath } = options;
|
|
3516
|
+
return `# Slim Dockerfile for pre-built production bundle
|
|
3517
|
+
FROM ${baseImage}
|
|
3518
|
+
|
|
3519
|
+
WORKDIR /app
|
|
3520
|
+
|
|
3521
|
+
# Install tini for proper signal handling as PID 1
|
|
3522
|
+
# Handles SIGTERM propagation and zombie process reaping
|
|
3523
|
+
RUN apk add --no-cache tini
|
|
3524
|
+
|
|
3525
|
+
# Create non-root user
|
|
3526
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
3527
|
+
adduser --system --uid 1001 hono
|
|
3528
|
+
|
|
3529
|
+
# Copy pre-built bundle
|
|
3530
|
+
COPY .gkm/server/dist/server.mjs ./
|
|
3531
|
+
|
|
3532
|
+
# Environment
|
|
3533
|
+
ENV NODE_ENV=production
|
|
3534
|
+
ENV PORT=${port}
|
|
3535
|
+
|
|
3536
|
+
# Health check
|
|
3537
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
3538
|
+
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
3539
|
+
|
|
3540
|
+
# Switch to non-root user
|
|
3541
|
+
USER hono
|
|
3542
|
+
|
|
3543
|
+
EXPOSE ${port}
|
|
3544
|
+
|
|
3545
|
+
# Use tini as entrypoint to handle PID 1 responsibilities
|
|
3546
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
3547
|
+
CMD ["node", "server.mjs"]
|
|
3548
|
+
`;
|
|
3692
3549
|
}
|
|
3693
3550
|
/**
|
|
3694
|
-
*
|
|
3551
|
+
* Generate .dockerignore file
|
|
3695
3552
|
*/
|
|
3696
|
-
|
|
3697
|
-
|
|
3698
|
-
|
|
3699
|
-
|
|
3700
|
-
|
|
3701
|
-
|
|
3702
|
-
|
|
3703
|
-
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3553
|
+
function generateDockerignore() {
|
|
3554
|
+
return `# Dependencies
|
|
3555
|
+
node_modules
|
|
3556
|
+
.pnpm-store
|
|
3557
|
+
|
|
3558
|
+
# Build output (except what we need)
|
|
3559
|
+
.gkm/aws*
|
|
3560
|
+
.gkm/server/*.ts
|
|
3561
|
+
!.gkm/server/dist
|
|
3562
|
+
|
|
3563
|
+
# IDE and editor
|
|
3564
|
+
.idea
|
|
3565
|
+
.vscode
|
|
3566
|
+
*.swp
|
|
3567
|
+
*.swo
|
|
3568
|
+
|
|
3569
|
+
# Git
|
|
3570
|
+
.git
|
|
3571
|
+
.gitignore
|
|
3572
|
+
|
|
3573
|
+
# Logs
|
|
3574
|
+
*.log
|
|
3575
|
+
npm-debug.log*
|
|
3576
|
+
pnpm-debug.log*
|
|
3577
|
+
|
|
3578
|
+
# Test files
|
|
3579
|
+
**/*.test.ts
|
|
3580
|
+
**/*.spec.ts
|
|
3581
|
+
**/__tests__
|
|
3582
|
+
coverage
|
|
3583
|
+
|
|
3584
|
+
# Documentation
|
|
3585
|
+
docs
|
|
3586
|
+
*.md
|
|
3587
|
+
!README.md
|
|
3588
|
+
|
|
3589
|
+
# Environment files (handle secrets separately)
|
|
3590
|
+
.env
|
|
3591
|
+
.env.*
|
|
3592
|
+
!.env.example
|
|
3593
|
+
|
|
3594
|
+
# Docker files (don't copy recursively)
|
|
3595
|
+
Dockerfile*
|
|
3596
|
+
docker-compose*
|
|
3597
|
+
.dockerignore
|
|
3598
|
+
`;
|
|
3707
3599
|
}
|
|
3708
3600
|
/**
|
|
3709
|
-
*
|
|
3601
|
+
* Generate docker-entrypoint.sh for custom startup logic
|
|
3710
3602
|
*/
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
3714
|
-
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
logger$5.log("\n Example docker run:");
|
|
3726
|
-
logger$5.log(` docker run -e GKM_MASTER_KEY=${masterKey} ${imageRef}`);
|
|
3727
|
-
}
|
|
3728
|
-
return {
|
|
3729
|
-
imageRef,
|
|
3730
|
-
masterKey
|
|
3731
|
-
};
|
|
3603
|
+
function generateDockerEntrypoint() {
|
|
3604
|
+
return `#!/bin/sh
|
|
3605
|
+
set -e
|
|
3606
|
+
|
|
3607
|
+
# Run any custom startup scripts here
|
|
3608
|
+
# Example: wait for database
|
|
3609
|
+
# until nc -z $DB_HOST $DB_PORT; do
|
|
3610
|
+
# echo "Waiting for database..."
|
|
3611
|
+
# sleep 1
|
|
3612
|
+
# done
|
|
3613
|
+
|
|
3614
|
+
# Execute the main command
|
|
3615
|
+
exec "$@"
|
|
3616
|
+
`;
|
|
3732
3617
|
}
|
|
3733
3618
|
/**
|
|
3734
|
-
* Resolve Docker
|
|
3735
|
-
* - imageName: from config, or cwd package.json, or 'app' (for Docker image)
|
|
3736
|
-
* - projectName: from root package.json, or 'app' (for Dokploy project)
|
|
3737
|
-
* - appName: from cwd package.json, or projectName (for Dokploy app within project)
|
|
3619
|
+
* Resolve Docker configuration from GkmConfig with defaults
|
|
3738
3620
|
*/
|
|
3739
|
-
function resolveDockerConfig(config) {
|
|
3740
|
-
const
|
|
3741
|
-
|
|
3742
|
-
|
|
3621
|
+
function resolveDockerConfig$1(config) {
|
|
3622
|
+
const docker = config.docker ?? {};
|
|
3623
|
+
let defaultImageName = "api";
|
|
3624
|
+
try {
|
|
3625
|
+
const pkg$1 = require(`${process.cwd()}/package.json`);
|
|
3626
|
+
if (pkg$1.name) defaultImageName = pkg$1.name.replace(/^@[^/]+\//, "");
|
|
3627
|
+
} catch {}
|
|
3743
3628
|
return {
|
|
3744
|
-
registry:
|
|
3745
|
-
imageName,
|
|
3746
|
-
|
|
3747
|
-
|
|
3629
|
+
registry: docker.registry ?? "",
|
|
3630
|
+
imageName: docker.imageName ?? defaultImageName,
|
|
3631
|
+
baseImage: docker.baseImage ?? "node:22-alpine",
|
|
3632
|
+
port: docker.port ?? 3e3,
|
|
3633
|
+
compose: docker.compose
|
|
3748
3634
|
};
|
|
3749
3635
|
}
|
|
3750
|
-
|
|
3751
|
-
//#endregion
|
|
3752
|
-
//#region src/deploy/dokploy.ts
|
|
3753
|
-
const logger$4 = console;
|
|
3754
|
-
/**
|
|
3755
|
-
* Get the Dokploy API token from stored credentials or environment
|
|
3756
|
-
*/
|
|
3757
|
-
async function getApiToken$1() {
|
|
3758
|
-
const token = await getDokployToken();
|
|
3759
|
-
if (!token) throw new Error("Dokploy credentials not found.\nRun \"gkm login --service dokploy\" to authenticate, or set DOKPLOY_API_TOKEN.");
|
|
3760
|
-
return token;
|
|
3761
|
-
}
|
|
3762
3636
|
/**
|
|
3763
|
-
*
|
|
3637
|
+
* Generate a Dockerfile for Next.js frontend apps using standalone output.
|
|
3638
|
+
* Uses turbo prune for monorepo optimization.
|
|
3639
|
+
* @internal Exported for testing
|
|
3764
3640
|
*/
|
|
3765
|
-
|
|
3766
|
-
const
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3641
|
+
function generateNextjsDockerfile(options) {
|
|
3642
|
+
const { baseImage, port, appPath, turboPackage, packageManager, publicUrlArgs = ["NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_AUTH_URL"] } = options;
|
|
3643
|
+
const pm = getPmConfig(packageManager);
|
|
3644
|
+
const installPm = pm.install ? `RUN ${pm.install}` : "";
|
|
3645
|
+
const turboInstallCmd = getTurboInstallCmd(packageManager);
|
|
3646
|
+
const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
|
|
3647
|
+
const publicUrlArgDeclarations = publicUrlArgs.map((arg) => `ARG ${arg}=""`).join("\n");
|
|
3648
|
+
const publicUrlEnvDeclarations = publicUrlArgs.map((arg) => `ENV ${arg}=$${arg}`).join("\n");
|
|
3649
|
+
return `# syntax=docker/dockerfile:1
|
|
3650
|
+
# Next.js standalone Dockerfile with turbo prune optimization
|
|
3651
|
+
|
|
3652
|
+
# Stage 1: Prune monorepo
|
|
3653
|
+
FROM ${baseImage} AS pruner
|
|
3654
|
+
|
|
3655
|
+
WORKDIR /app
|
|
3656
|
+
|
|
3657
|
+
${installPm}
|
|
3658
|
+
|
|
3659
|
+
COPY . .
|
|
3660
|
+
|
|
3661
|
+
# Prune to only include necessary packages
|
|
3662
|
+
RUN ${turboCmd} prune ${turboPackage} --docker
|
|
3663
|
+
|
|
3664
|
+
# Stage 2: Install dependencies
|
|
3665
|
+
FROM ${baseImage} AS deps
|
|
3666
|
+
|
|
3667
|
+
WORKDIR /app
|
|
3668
|
+
|
|
3669
|
+
${installPm}
|
|
3670
|
+
|
|
3671
|
+
# Copy pruned lockfile and package.jsons
|
|
3672
|
+
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
3673
|
+
COPY --from=pruner /app/out/json/ ./
|
|
3674
|
+
|
|
3675
|
+
# Install dependencies
|
|
3676
|
+
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
3677
|
+
${turboInstallCmd}
|
|
3678
|
+
|
|
3679
|
+
# Stage 3: Build
|
|
3680
|
+
FROM deps AS builder
|
|
3681
|
+
|
|
3682
|
+
WORKDIR /app
|
|
3683
|
+
|
|
3684
|
+
# Build-time args for public API URLs (populated by gkm deploy)
|
|
3685
|
+
# These get baked into the Next.js build as public environment variables
|
|
3686
|
+
${publicUrlArgDeclarations}
|
|
3687
|
+
|
|
3688
|
+
# Convert ARGs to ENVs for Next.js build
|
|
3689
|
+
${publicUrlEnvDeclarations}
|
|
3690
|
+
|
|
3691
|
+
# Copy pruned source
|
|
3692
|
+
COPY --from=pruner /app/out/full/ ./
|
|
3693
|
+
|
|
3694
|
+
# Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
|
|
3695
|
+
# Using wildcard to make it optional for single-app projects
|
|
3696
|
+
COPY --from=pruner /app/tsconfig.* ./
|
|
3697
|
+
|
|
3698
|
+
# Ensure public directory exists (may be empty for scaffolded projects)
|
|
3699
|
+
RUN mkdir -p ${appPath}/public
|
|
3700
|
+
|
|
3701
|
+
# Set Next.js to produce standalone output
|
|
3702
|
+
ENV NEXT_TELEMETRY_DISABLED=1
|
|
3703
|
+
|
|
3704
|
+
# Build the application
|
|
3705
|
+
RUN ${turboCmd} run build --filter=${turboPackage}
|
|
3706
|
+
|
|
3707
|
+
# Stage 4: Production
|
|
3708
|
+
FROM ${baseImage} AS runner
|
|
3709
|
+
|
|
3710
|
+
WORKDIR /app
|
|
3711
|
+
|
|
3712
|
+
# Install tini for proper signal handling
|
|
3713
|
+
RUN apk add --no-cache tini
|
|
3714
|
+
|
|
3715
|
+
# Create non-root user
|
|
3716
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
3717
|
+
adduser --system --uid 1001 nextjs
|
|
3718
|
+
|
|
3719
|
+
# Set environment
|
|
3720
|
+
ENV NODE_ENV=production
|
|
3721
|
+
ENV NEXT_TELEMETRY_DISABLED=1
|
|
3722
|
+
ENV PORT=${port}
|
|
3723
|
+
ENV HOSTNAME="0.0.0.0"
|
|
3724
|
+
|
|
3725
|
+
# Copy static files and standalone output
|
|
3726
|
+
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/standalone ./
|
|
3727
|
+
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/.next/static ./${appPath}/.next/static
|
|
3728
|
+
COPY --from=builder --chown=nextjs:nodejs /app/${appPath}/public ./${appPath}/public
|
|
3729
|
+
|
|
3730
|
+
USER nextjs
|
|
3731
|
+
|
|
3732
|
+
EXPOSE ${port}
|
|
3733
|
+
|
|
3734
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
3735
|
+
CMD ["node", "${appPath}/server.js"]
|
|
3736
|
+
`;
|
|
3771
3737
|
}
|
|
3772
3738
|
/**
|
|
3773
|
-
*
|
|
3739
|
+
* Generate a Dockerfile for backend apps in a workspace.
|
|
3740
|
+
* Uses turbo prune for monorepo optimization.
|
|
3741
|
+
* @internal Exported for testing
|
|
3774
3742
|
*/
|
|
3775
|
-
|
|
3776
|
-
const {
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
const
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
}
|
|
3743
|
+
function generateBackendDockerfile(options) {
|
|
3744
|
+
const { baseImage, port, appPath, turboPackage, packageManager, healthCheckPath = "/health" } = options;
|
|
3745
|
+
const pm = getPmConfig(packageManager);
|
|
3746
|
+
const installPm = pm.install ? `RUN ${pm.install}` : "";
|
|
3747
|
+
const turboInstallCmd = getTurboInstallCmd(packageManager);
|
|
3748
|
+
const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
|
|
3749
|
+
return `# syntax=docker/dockerfile:1
|
|
3750
|
+
# Backend Dockerfile with turbo prune optimization
|
|
3751
|
+
|
|
3752
|
+
# Stage 1: Prune monorepo
|
|
3753
|
+
FROM ${baseImage} AS pruner
|
|
3754
|
+
|
|
3755
|
+
WORKDIR /app
|
|
3756
|
+
|
|
3757
|
+
${installPm}
|
|
3758
|
+
|
|
3759
|
+
COPY . .
|
|
3760
|
+
|
|
3761
|
+
# Prune to only include necessary packages
|
|
3762
|
+
RUN ${turboCmd} prune ${turboPackage} --docker
|
|
3763
|
+
|
|
3764
|
+
# Stage 2: Install dependencies
|
|
3765
|
+
FROM ${baseImage} AS deps
|
|
3766
|
+
|
|
3767
|
+
WORKDIR /app
|
|
3768
|
+
|
|
3769
|
+
${installPm}
|
|
3770
|
+
|
|
3771
|
+
# Copy pruned lockfile and package.jsons
|
|
3772
|
+
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
3773
|
+
COPY --from=pruner /app/out/json/ ./
|
|
3774
|
+
|
|
3775
|
+
# Install dependencies
|
|
3776
|
+
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
3777
|
+
${turboInstallCmd}
|
|
3778
|
+
|
|
3779
|
+
# Stage 3: Build
|
|
3780
|
+
FROM deps AS builder
|
|
3781
|
+
|
|
3782
|
+
WORKDIR /app
|
|
3783
|
+
|
|
3784
|
+
# Build-time args for encrypted secrets
|
|
3785
|
+
ARG GKM_ENCRYPTED_CREDENTIALS=""
|
|
3786
|
+
ARG GKM_CREDENTIALS_IV=""
|
|
3787
|
+
|
|
3788
|
+
# Copy pruned source
|
|
3789
|
+
COPY --from=pruner /app/out/full/ ./
|
|
3790
|
+
|
|
3791
|
+
# Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
|
|
3792
|
+
# Using wildcard to make it optional for single-app projects
|
|
3793
|
+
COPY --from=pruner /app/gkm.config.* ./
|
|
3794
|
+
COPY --from=pruner /app/tsconfig.* ./
|
|
3795
|
+
|
|
3796
|
+
# Write encrypted credentials for gkm build to embed
|
|
3797
|
+
RUN if [ -n "$GKM_ENCRYPTED_CREDENTIALS" ]; then \
|
|
3798
|
+
mkdir -p ${appPath}/.gkm && \
|
|
3799
|
+
echo "$GKM_ENCRYPTED_CREDENTIALS" > ${appPath}/.gkm/credentials.enc && \
|
|
3800
|
+
echo "$GKM_CREDENTIALS_IV" > ${appPath}/.gkm/credentials.iv; \
|
|
3801
|
+
fi
|
|
3835
3802
|
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3803
|
+
# Build production server using gkm
|
|
3804
|
+
RUN cd ${appPath} && ./node_modules/.bin/gkm build --provider server --production
|
|
3805
|
+
|
|
3806
|
+
# Stage 4: Production
|
|
3807
|
+
FROM ${baseImage} AS runner
|
|
3808
|
+
|
|
3809
|
+
WORKDIR /app
|
|
3810
|
+
|
|
3811
|
+
RUN apk add --no-cache tini
|
|
3812
|
+
|
|
3813
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
3814
|
+
adduser --system --uid 1001 hono
|
|
3815
|
+
|
|
3816
|
+
# Copy bundled server
|
|
3817
|
+
COPY --from=builder --chown=hono:nodejs /app/${appPath}/.gkm/server/dist/server.mjs ./
|
|
3818
|
+
|
|
3819
|
+
ENV NODE_ENV=production
|
|
3820
|
+
ENV PORT=${port}
|
|
3821
|
+
|
|
3822
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
3823
|
+
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
3824
|
+
|
|
3825
|
+
USER hono
|
|
3826
|
+
|
|
3827
|
+
EXPOSE ${port}
|
|
3828
|
+
|
|
3829
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
3830
|
+
CMD ["node", "server.mjs"]
|
|
3831
|
+
`;
|
|
3858
3832
|
}
|
|
3859
3833
|
/**
|
|
3860
|
-
*
|
|
3834
|
+
* Generate a Dockerfile for apps with a custom entry point.
|
|
3835
|
+
* Uses esbuild to bundle the entry point into dist/index.mjs with all dependencies.
|
|
3836
|
+
* This is used for apps that don't use gkm routes (e.g., Better Auth servers).
|
|
3837
|
+
* @internal Exported for testing
|
|
3861
3838
|
*/
|
|
3862
|
-
|
|
3863
|
-
const
|
|
3864
|
-
const
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3839
|
+
function generateEntryDockerfile(options) {
|
|
3840
|
+
const { baseImage, port, appPath, entry, turboPackage, packageManager, healthCheckPath = "/health" } = options;
|
|
3841
|
+
const pm = getPmConfig(packageManager);
|
|
3842
|
+
const installPm = pm.install ? `RUN ${pm.install}` : "";
|
|
3843
|
+
const turboInstallCmd = getTurboInstallCmd(packageManager);
|
|
3844
|
+
const turboCmd = packageManager === "pnpm" ? "pnpm dlx turbo" : "npx turbo";
|
|
3845
|
+
return `# syntax=docker/dockerfile:1
|
|
3846
|
+
# Entry-based Dockerfile with turbo prune + tsdown bundling
|
|
3847
|
+
|
|
3848
|
+
# Stage 1: Prune monorepo
|
|
3849
|
+
FROM ${baseImage} AS pruner
|
|
3850
|
+
|
|
3851
|
+
WORKDIR /app
|
|
3852
|
+
|
|
3853
|
+
${installPm}
|
|
3854
|
+
|
|
3855
|
+
COPY . .
|
|
3856
|
+
|
|
3857
|
+
# Prune to only include necessary packages
|
|
3858
|
+
RUN ${turboCmd} prune ${turboPackage} --docker
|
|
3859
|
+
|
|
3860
|
+
# Stage 2: Install dependencies
|
|
3861
|
+
FROM ${baseImage} AS deps
|
|
3862
|
+
|
|
3863
|
+
WORKDIR /app
|
|
3864
|
+
|
|
3865
|
+
${installPm}
|
|
3866
|
+
|
|
3867
|
+
# Copy pruned lockfile and package.jsons
|
|
3868
|
+
COPY --from=pruner /app/out/${pm.lockfile} ./
|
|
3869
|
+
COPY --from=pruner /app/out/json/ ./
|
|
3870
|
+
|
|
3871
|
+
# Install dependencies
|
|
3872
|
+
RUN --mount=type=cache,id=${pm.cacheId},target=${pm.cacheTarget} \\
|
|
3873
|
+
${turboInstallCmd}
|
|
3874
|
+
|
|
3875
|
+
# Stage 3: Build with tsdown
|
|
3876
|
+
FROM deps AS builder
|
|
3877
|
+
|
|
3878
|
+
WORKDIR /app
|
|
3879
|
+
|
|
3880
|
+
# Build-time args for encrypted secrets
|
|
3881
|
+
ARG GKM_ENCRYPTED_CREDENTIALS=""
|
|
3882
|
+
ARG GKM_CREDENTIALS_IV=""
|
|
3883
|
+
|
|
3884
|
+
# Copy pruned source
|
|
3885
|
+
COPY --from=pruner /app/out/full/ ./
|
|
3886
|
+
|
|
3887
|
+
# Copy workspace root configs for turbo builds (turbo prune doesn't include root configs)
|
|
3888
|
+
# Using wildcard to make it optional for single-app projects
|
|
3889
|
+
COPY --from=pruner /app/tsconfig.* ./
|
|
3890
|
+
|
|
3891
|
+
# Write encrypted credentials for tsdown to embed via define
|
|
3892
|
+
RUN if [ -n "$GKM_ENCRYPTED_CREDENTIALS" ]; then \
|
|
3893
|
+
mkdir -p ${appPath}/.gkm && \
|
|
3894
|
+
echo "$GKM_ENCRYPTED_CREDENTIALS" > ${appPath}/.gkm/credentials.enc && \
|
|
3895
|
+
echo "$GKM_CREDENTIALS_IV" > ${appPath}/.gkm/credentials.iv; \
|
|
3896
|
+
fi
|
|
3897
|
+
|
|
3898
|
+
# Bundle entry point with esbuild (outputs to dist/index.mjs)
|
|
3899
|
+
# Creates a fully standalone bundle with all dependencies included
|
|
3900
|
+
# Use define to embed credentials if present
|
|
3901
|
+
RUN cd ${appPath} && \
|
|
3902
|
+
if [ -f .gkm/credentials.enc ]; then \
|
|
3903
|
+
CREDS=$(cat .gkm/credentials.enc) && \
|
|
3904
|
+
IV=$(cat .gkm/credentials.iv) && \
|
|
3905
|
+
npx esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \
|
|
3906
|
+
--outfile=dist/index.mjs --packages=bundle \
|
|
3907
|
+
--banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);' \
|
|
3908
|
+
--define:__GKM_ENCRYPTED_CREDENTIALS__="'\\"$CREDS\\"'" \
|
|
3909
|
+
--define:__GKM_CREDENTIALS_IV__="'\\"$IV\\"'"; \
|
|
3910
|
+
else \
|
|
3911
|
+
npx esbuild ${entry} --bundle --platform=node --target=node22 --format=esm \
|
|
3912
|
+
--outfile=dist/index.mjs --packages=bundle \
|
|
3913
|
+
--banner:js='import { createRequire } from "module"; const require = createRequire(import.meta.url);'; \
|
|
3914
|
+
fi
|
|
3915
|
+
|
|
3916
|
+
# Stage 4: Production
|
|
3917
|
+
FROM ${baseImage} AS runner
|
|
3918
|
+
|
|
3919
|
+
WORKDIR /app
|
|
3920
|
+
|
|
3921
|
+
RUN apk add --no-cache tini
|
|
3922
|
+
|
|
3923
|
+
RUN addgroup --system --gid 1001 nodejs && \\
|
|
3924
|
+
adduser --system --uid 1001 app
|
|
3925
|
+
|
|
3926
|
+
# Copy bundled output only (no node_modules needed - fully bundled)
|
|
3927
|
+
COPY --from=builder --chown=app:nodejs /app/${appPath}/dist/index.mjs ./
|
|
3928
|
+
|
|
3929
|
+
ENV NODE_ENV=production
|
|
3930
|
+
ENV PORT=${port}
|
|
3931
|
+
|
|
3932
|
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \\
|
|
3933
|
+
CMD wget -qO- http://localhost:${port}${healthCheckPath} > /dev/null 2>&1 || exit 1
|
|
3934
|
+
|
|
3935
|
+
USER app
|
|
3936
|
+
|
|
3937
|
+
EXPOSE ${port}
|
|
3938
|
+
|
|
3939
|
+
ENTRYPOINT ["/sbin/tini", "--"]
|
|
3940
|
+
CMD ["node", "index.mjs"]
|
|
3941
|
+
`;
|
|
3868
3942
|
}
|
|
3943
|
+
|
|
3944
|
+
//#endregion
|
|
3945
|
+
//#region src/docker/index.ts
|
|
3946
|
+
const logger$5 = console;
|
|
3869
3947
|
/**
|
|
3870
|
-
*
|
|
3948
|
+
* Docker command implementation
|
|
3949
|
+
* Generates Dockerfile, docker-compose.yml, and related files
|
|
3950
|
+
*
|
|
3951
|
+
* Default: Multi-stage Dockerfile that builds from source inside Docker
|
|
3952
|
+
* --slim: Slim Dockerfile that copies pre-built bundle (requires prior build)
|
|
3871
3953
|
*/
|
|
3872
|
-
function
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3954
|
+
async function dockerCommand(options) {
|
|
3955
|
+
const loadedConfig = await require_config.loadWorkspaceConfig();
|
|
3956
|
+
if (loadedConfig.type === "workspace") {
|
|
3957
|
+
logger$5.log("📦 Detected workspace configuration");
|
|
3958
|
+
return workspaceDockerCommand(loadedConfig.workspace, options);
|
|
3959
|
+
}
|
|
3960
|
+
const config = await require_config.loadConfig();
|
|
3961
|
+
const dockerConfig = resolveDockerConfig$1(config);
|
|
3962
|
+
const serverConfig = typeof config.providers?.server === "object" ? config.providers.server : void 0;
|
|
3963
|
+
const healthCheckPath = serverConfig?.production?.healthCheck ?? "/health";
|
|
3964
|
+
const useSlim = options.slim === true;
|
|
3965
|
+
if (useSlim) {
|
|
3966
|
+
const distDir = (0, node_path.join)(process.cwd(), ".gkm", "server", "dist");
|
|
3967
|
+
const hasBuild = (0, node_fs.existsSync)((0, node_path.join)(distDir, "server.mjs"));
|
|
3968
|
+
if (!hasBuild) 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.");
|
|
3969
|
+
}
|
|
3970
|
+
const dockerDir = (0, node_path.join)(process.cwd(), ".gkm", "docker");
|
|
3971
|
+
await (0, node_fs_promises.mkdir)(dockerDir, { recursive: true });
|
|
3972
|
+
const packageManager = detectPackageManager$1();
|
|
3973
|
+
const inMonorepo = isMonorepo();
|
|
3974
|
+
const hasTurbo = hasTurboConfig();
|
|
3975
|
+
let useTurbo = options.turbo ?? false;
|
|
3976
|
+
if (inMonorepo && !useSlim) if (hasTurbo) {
|
|
3977
|
+
useTurbo = true;
|
|
3978
|
+
logger$5.log(" Detected monorepo with turbo.json - using turbo prune");
|
|
3979
|
+
} 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");
|
|
3980
|
+
let turboPackage = options.turboPackage ?? dockerConfig.imageName;
|
|
3981
|
+
if (useTurbo && !options.turboPackage) try {
|
|
3982
|
+
const pkg$1 = require(`${process.cwd()}/package.json`);
|
|
3983
|
+
if (pkg$1.name) {
|
|
3984
|
+
turboPackage = pkg$1.name;
|
|
3985
|
+
logger$5.log(` Turbo package: ${turboPackage}`);
|
|
3986
|
+
}
|
|
3987
|
+
} catch {}
|
|
3988
|
+
const templateOptions = {
|
|
3989
|
+
imageName: dockerConfig.imageName,
|
|
3990
|
+
baseImage: dockerConfig.baseImage,
|
|
3991
|
+
port: dockerConfig.port,
|
|
3992
|
+
healthCheckPath,
|
|
3993
|
+
prebuilt: useSlim,
|
|
3994
|
+
turbo: useTurbo,
|
|
3995
|
+
turboPackage,
|
|
3996
|
+
packageManager
|
|
3880
3997
|
};
|
|
3998
|
+
const dockerfile = useSlim ? generateSlimDockerfile(templateOptions) : generateMultiStageDockerfile(templateOptions);
|
|
3999
|
+
const dockerMode = useSlim ? "slim" : useTurbo ? "turbo" : "multi-stage";
|
|
4000
|
+
const dockerfilePath = (0, node_path.join)(dockerDir, "Dockerfile");
|
|
4001
|
+
await (0, node_fs_promises.writeFile)(dockerfilePath, dockerfile);
|
|
4002
|
+
logger$5.log(`Generated: .gkm/docker/Dockerfile (${dockerMode}, ${packageManager})`);
|
|
4003
|
+
const composeOptions = {
|
|
4004
|
+
imageName: dockerConfig.imageName,
|
|
4005
|
+
registry: options.registry ?? dockerConfig.registry,
|
|
4006
|
+
port: dockerConfig.port,
|
|
4007
|
+
healthCheckPath,
|
|
4008
|
+
services: dockerConfig.compose?.services ?? {}
|
|
4009
|
+
};
|
|
4010
|
+
const hasServices = Array.isArray(composeOptions.services) ? composeOptions.services.length > 0 : Object.keys(composeOptions.services).length > 0;
|
|
4011
|
+
const dockerCompose = hasServices ? generateDockerCompose(composeOptions) : generateMinimalDockerCompose(composeOptions);
|
|
4012
|
+
const composePath = (0, node_path.join)(dockerDir, "docker-compose.yml");
|
|
4013
|
+
await (0, node_fs_promises.writeFile)(composePath, dockerCompose);
|
|
4014
|
+
logger$5.log("Generated: .gkm/docker/docker-compose.yml");
|
|
4015
|
+
const dockerignore = generateDockerignore();
|
|
4016
|
+
const dockerignorePath = (0, node_path.join)(process.cwd(), ".dockerignore");
|
|
4017
|
+
await (0, node_fs_promises.writeFile)(dockerignorePath, dockerignore);
|
|
4018
|
+
logger$5.log("Generated: .dockerignore (project root)");
|
|
4019
|
+
const entrypoint = generateDockerEntrypoint();
|
|
4020
|
+
const entrypointPath = (0, node_path.join)(dockerDir, "docker-entrypoint.sh");
|
|
4021
|
+
await (0, node_fs_promises.writeFile)(entrypointPath, entrypoint);
|
|
4022
|
+
logger$5.log("Generated: .gkm/docker/docker-entrypoint.sh");
|
|
4023
|
+
const result = {
|
|
4024
|
+
dockerfile: dockerfilePath,
|
|
4025
|
+
dockerCompose: composePath,
|
|
4026
|
+
dockerignore: dockerignorePath,
|
|
4027
|
+
entrypoint: entrypointPath
|
|
4028
|
+
};
|
|
4029
|
+
if (options.build) await buildDockerImage(dockerConfig.imageName, options);
|
|
4030
|
+
if (options.push) await pushDockerImage(dockerConfig.imageName, options);
|
|
4031
|
+
return result;
|
|
3881
4032
|
}
|
|
3882
4033
|
/**
|
|
3883
|
-
*
|
|
3884
|
-
|
|
3885
|
-
function
|
|
3886
|
-
return state?.applications[appName];
|
|
3887
|
-
}
|
|
3888
|
-
/**
|
|
3889
|
-
* Set application ID in state (mutates state)
|
|
3890
|
-
*/
|
|
3891
|
-
function setApplicationId(state, appName, applicationId) {
|
|
3892
|
-
state.applications[appName] = applicationId;
|
|
3893
|
-
}
|
|
3894
|
-
/**
|
|
3895
|
-
* Get postgres ID from state
|
|
3896
|
-
*/
|
|
3897
|
-
function getPostgresId(state) {
|
|
3898
|
-
return state?.services.postgresId;
|
|
3899
|
-
}
|
|
3900
|
-
/**
|
|
3901
|
-
* Set postgres ID in state (mutates state)
|
|
4034
|
+
* Ensure lockfile exists in the build context
|
|
4035
|
+
* For monorepos, copies from workspace root if needed
|
|
4036
|
+
* Returns cleanup function if file was copied
|
|
3902
4037
|
*/
|
|
3903
|
-
function
|
|
3904
|
-
|
|
4038
|
+
function ensureLockfile(cwd) {
|
|
4039
|
+
const lockfilePath = findLockfilePath(cwd);
|
|
4040
|
+
if (!lockfilePath) {
|
|
4041
|
+
logger$5.warn("\n⚠️ No lockfile found. Docker build may fail or use stale dependencies.");
|
|
4042
|
+
return null;
|
|
4043
|
+
}
|
|
4044
|
+
const lockfileName = (0, node_path.basename)(lockfilePath);
|
|
4045
|
+
const localLockfile = (0, node_path.join)(cwd, lockfileName);
|
|
4046
|
+
if (lockfilePath === localLockfile) return null;
|
|
4047
|
+
logger$5.log(` Copying ${lockfileName} from monorepo root...`);
|
|
4048
|
+
(0, node_fs.copyFileSync)(lockfilePath, localLockfile);
|
|
4049
|
+
return () => {
|
|
4050
|
+
try {
|
|
4051
|
+
(0, node_fs.unlinkSync)(localLockfile);
|
|
4052
|
+
} catch {}
|
|
4053
|
+
};
|
|
3905
4054
|
}
|
|
3906
4055
|
/**
|
|
3907
|
-
*
|
|
4056
|
+
* Build Docker image
|
|
4057
|
+
* Uses BuildKit for cache mount support
|
|
3908
4058
|
*/
|
|
3909
|
-
function
|
|
3910
|
-
|
|
4059
|
+
async function buildDockerImage(imageName, options) {
|
|
4060
|
+
const tag = options.tag ?? "latest";
|
|
4061
|
+
const registry = options.registry;
|
|
4062
|
+
const fullImageName = registry ? `${registry}/${imageName}:${tag}` : `${imageName}:${tag}`;
|
|
4063
|
+
logger$5.log(`\n🐳 Building Docker image: ${fullImageName}`);
|
|
4064
|
+
const cwd = process.cwd();
|
|
4065
|
+
const cleanup = ensureLockfile(cwd);
|
|
4066
|
+
try {
|
|
4067
|
+
(0, node_child_process.execSync)(`DOCKER_BUILDKIT=1 docker build -f .gkm/docker/Dockerfile -t ${fullImageName} .`, {
|
|
4068
|
+
cwd,
|
|
4069
|
+
stdio: "inherit",
|
|
4070
|
+
env: {
|
|
4071
|
+
...process.env,
|
|
4072
|
+
DOCKER_BUILDKIT: "1"
|
|
4073
|
+
}
|
|
4074
|
+
});
|
|
4075
|
+
logger$5.log(`✅ Docker image built: ${fullImageName}`);
|
|
4076
|
+
} catch (error) {
|
|
4077
|
+
throw new Error(`Failed to build Docker image: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
4078
|
+
} finally {
|
|
4079
|
+
cleanup?.();
|
|
4080
|
+
}
|
|
3911
4081
|
}
|
|
3912
4082
|
/**
|
|
3913
|
-
*
|
|
4083
|
+
* Push Docker image to registry
|
|
3914
4084
|
*/
|
|
3915
|
-
function
|
|
3916
|
-
|
|
4085
|
+
async function pushDockerImage(imageName, options) {
|
|
4086
|
+
const tag = options.tag ?? "latest";
|
|
4087
|
+
const registry = options.registry;
|
|
4088
|
+
if (!registry) throw new Error("Registry is required to push Docker image. Use --registry or configure docker.registry in gkm.config.ts");
|
|
4089
|
+
const fullImageName = `${registry}/${imageName}:${tag}`;
|
|
4090
|
+
logger$5.log(`\n🚀 Pushing Docker image: ${fullImageName}`);
|
|
4091
|
+
try {
|
|
4092
|
+
(0, node_child_process.execSync)(`docker push ${fullImageName}`, {
|
|
4093
|
+
cwd: process.cwd(),
|
|
4094
|
+
stdio: "inherit"
|
|
4095
|
+
});
|
|
4096
|
+
logger$5.log(`✅ Docker image pushed: ${fullImageName}`);
|
|
4097
|
+
} catch (error) {
|
|
4098
|
+
throw new Error(`Failed to push Docker image: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
4099
|
+
}
|
|
3917
4100
|
}
|
|
3918
|
-
|
|
3919
|
-
//#endregion
|
|
3920
|
-
//#region src/deploy/dns/hostinger-api.ts
|
|
3921
|
-
/**
|
|
3922
|
-
* Hostinger DNS API client
|
|
3923
|
-
*
|
|
3924
|
-
* API Documentation: https://developers.hostinger.com/
|
|
3925
|
-
* Authentication: Bearer token from hpanel.hostinger.com/profile/api
|
|
3926
|
-
*/
|
|
3927
|
-
const HOSTINGER_API_BASE = "https://developers.hostinger.com";
|
|
3928
4101
|
/**
|
|
3929
|
-
*
|
|
4102
|
+
* Get the package name from package.json in an app directory.
|
|
3930
4103
|
*/
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
3934
|
-
|
|
3935
|
-
|
|
3936
|
-
|
|
3937
|
-
|
|
4104
|
+
function getAppPackageName(appPath) {
|
|
4105
|
+
try {
|
|
4106
|
+
const pkgPath = (0, node_path.join)(appPath, "package.json");
|
|
4107
|
+
if (!(0, node_fs.existsSync)(pkgPath)) return void 0;
|
|
4108
|
+
const content = (0, node_fs.readFileSync)(pkgPath, "utf-8");
|
|
4109
|
+
const pkg$1 = JSON.parse(content);
|
|
4110
|
+
return pkg$1.name;
|
|
4111
|
+
} catch {
|
|
4112
|
+
return void 0;
|
|
3938
4113
|
}
|
|
3939
|
-
}
|
|
4114
|
+
}
|
|
3940
4115
|
/**
|
|
3941
|
-
*
|
|
3942
|
-
*
|
|
3943
|
-
* @example
|
|
3944
|
-
* ```ts
|
|
3945
|
-
* const api = new HostingerApi(token);
|
|
3946
|
-
*
|
|
3947
|
-
* // Get all records for a domain
|
|
3948
|
-
* const records = await api.getRecords('traflabs.io');
|
|
3949
|
-
*
|
|
3950
|
-
* // Create/update records
|
|
3951
|
-
* await api.upsertRecords('traflabs.io', [
|
|
3952
|
-
* { name: 'api.joemoer', type: 'A', ttl: 300, records: ['1.2.3.4'] }
|
|
3953
|
-
* ]);
|
|
3954
|
-
* ```
|
|
4116
|
+
* Generate Dockerfiles for all apps in a workspace.
|
|
4117
|
+
* @internal Exported for testing
|
|
3955
4118
|
*/
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
|
|
3960
|
-
|
|
3961
|
-
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
const
|
|
3966
|
-
const
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
4119
|
+
async function workspaceDockerCommand(workspace, options) {
|
|
4120
|
+
const results = [];
|
|
4121
|
+
const apps = Object.entries(workspace.apps);
|
|
4122
|
+
logger$5.log(`\n🐳 Generating Dockerfiles for workspace: ${workspace.name}`);
|
|
4123
|
+
const dockerDir = (0, node_path.join)(workspace.root, ".gkm", "docker");
|
|
4124
|
+
await (0, node_fs_promises.mkdir)(dockerDir, { recursive: true });
|
|
4125
|
+
const packageManager = detectPackageManager$1(workspace.root);
|
|
4126
|
+
logger$5.log(` Package manager: ${packageManager}`);
|
|
4127
|
+
for (const [appName, app] of apps) {
|
|
4128
|
+
const appPath = app.path;
|
|
4129
|
+
const fullAppPath = (0, node_path.join)(workspace.root, appPath);
|
|
4130
|
+
const turboPackage = getAppPackageName(fullAppPath) ?? appName;
|
|
4131
|
+
const imageName = appName;
|
|
4132
|
+
const hasEntry = !!app.entry;
|
|
4133
|
+
const buildType = hasEntry ? "entry" : app.type;
|
|
4134
|
+
logger$5.log(`\n 📄 Generating Dockerfile for ${appName} (${buildType})`);
|
|
4135
|
+
let dockerfile;
|
|
4136
|
+
if (app.type === "frontend") dockerfile = generateNextjsDockerfile({
|
|
4137
|
+
imageName,
|
|
4138
|
+
baseImage: "node:22-alpine",
|
|
4139
|
+
port: app.port,
|
|
4140
|
+
appPath,
|
|
4141
|
+
turboPackage,
|
|
4142
|
+
packageManager
|
|
3973
4143
|
});
|
|
3974
|
-
if (
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
}
|
|
3984
|
-
const text = await response.text();
|
|
3985
|
-
if (!text || text.trim() === "") return void 0;
|
|
3986
|
-
return JSON.parse(text);
|
|
3987
|
-
}
|
|
3988
|
-
/**
|
|
3989
|
-
* Get all DNS records for a domain
|
|
3990
|
-
*
|
|
3991
|
-
* @param domain - Root domain (e.g., 'traflabs.io')
|
|
3992
|
-
*/
|
|
3993
|
-
async getRecords(domain) {
|
|
3994
|
-
const response = await this.request("GET", `/api/dns/v1/zones/${domain}`);
|
|
3995
|
-
return response.data || [];
|
|
3996
|
-
}
|
|
3997
|
-
/**
|
|
3998
|
-
* Create or update DNS records
|
|
3999
|
-
*
|
|
4000
|
-
* @param domain - Root domain (e.g., 'traflabs.io')
|
|
4001
|
-
* @param records - Records to create/update
|
|
4002
|
-
* @param overwrite - If true, replaces all existing records. If false, merges with existing.
|
|
4003
|
-
*/
|
|
4004
|
-
async upsertRecords(domain, records, overwrite = false) {
|
|
4005
|
-
await this.request("PUT", `/api/dns/v1/zones/${domain}`, {
|
|
4006
|
-
overwrite,
|
|
4007
|
-
zone: records
|
|
4144
|
+
else if (app.entry) dockerfile = generateEntryDockerfile({
|
|
4145
|
+
imageName,
|
|
4146
|
+
baseImage: "node:22-alpine",
|
|
4147
|
+
port: app.port,
|
|
4148
|
+
appPath,
|
|
4149
|
+
entry: app.entry,
|
|
4150
|
+
turboPackage,
|
|
4151
|
+
packageManager,
|
|
4152
|
+
healthCheckPath: "/health"
|
|
4008
4153
|
});
|
|
4009
|
-
|
|
4010
|
-
|
|
4011
|
-
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
|
|
4015
|
-
|
|
4016
|
-
|
|
4017
|
-
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4154
|
+
else dockerfile = generateBackendDockerfile({
|
|
4155
|
+
imageName,
|
|
4156
|
+
baseImage: "node:22-alpine",
|
|
4157
|
+
port: app.port,
|
|
4158
|
+
appPath,
|
|
4159
|
+
turboPackage,
|
|
4160
|
+
packageManager,
|
|
4161
|
+
healthCheckPath: "/health"
|
|
4162
|
+
});
|
|
4163
|
+
const dockerfilePath = (0, node_path.join)(dockerDir, `Dockerfile.${appName}`);
|
|
4164
|
+
await (0, node_fs_promises.writeFile)(dockerfilePath, dockerfile);
|
|
4165
|
+
logger$5.log(` Generated: .gkm/docker/Dockerfile.${appName}`);
|
|
4166
|
+
results.push({
|
|
4167
|
+
appName,
|
|
4168
|
+
type: app.type,
|
|
4169
|
+
dockerfile: dockerfilePath,
|
|
4170
|
+
imageName
|
|
4021
4171
|
});
|
|
4022
|
-
return true;
|
|
4023
|
-
}
|
|
4024
|
-
/**
|
|
4025
|
-
* Delete specific DNS records
|
|
4026
|
-
*
|
|
4027
|
-
* @param domain - Root domain (e.g., 'traflabs.io')
|
|
4028
|
-
* @param filters - Filters to match records for deletion
|
|
4029
|
-
*/
|
|
4030
|
-
async deleteRecords(domain, filters) {
|
|
4031
|
-
await this.request("DELETE", `/api/dns/v1/zones/${domain}`, { filters });
|
|
4032
|
-
}
|
|
4033
|
-
/**
|
|
4034
|
-
* Check if a specific record exists
|
|
4035
|
-
*
|
|
4036
|
-
* @param domain - Root domain (e.g., 'traflabs.io')
|
|
4037
|
-
* @param name - Subdomain name (e.g., 'api.joemoer')
|
|
4038
|
-
* @param type - Record type (e.g., 'A')
|
|
4039
|
-
*/
|
|
4040
|
-
async recordExists(domain, name$1, type$1 = "A") {
|
|
4041
|
-
const records = await this.getRecords(domain);
|
|
4042
|
-
return records.some((r) => r.name === name$1 && r.type === type$1);
|
|
4043
4172
|
}
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
|
|
4047
|
-
|
|
4048
|
-
|
|
4049
|
-
|
|
4050
|
-
|
|
4051
|
-
|
|
4052
|
-
|
|
4053
|
-
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
4057
|
-
name: subdomain,
|
|
4058
|
-
type: "A",
|
|
4059
|
-
ttl,
|
|
4060
|
-
records: [{ content: ip }]
|
|
4061
|
-
}]);
|
|
4062
|
-
return true;
|
|
4173
|
+
const dockerignore = generateDockerignore();
|
|
4174
|
+
const dockerignorePath = (0, node_path.join)(workspace.root, ".dockerignore");
|
|
4175
|
+
await (0, node_fs_promises.writeFile)(dockerignorePath, dockerignore);
|
|
4176
|
+
logger$5.log(`\n Generated: .dockerignore (workspace root)`);
|
|
4177
|
+
const dockerCompose = generateWorkspaceCompose(workspace, { registry: options.registry });
|
|
4178
|
+
const composePath = (0, node_path.join)(dockerDir, "docker-compose.yml");
|
|
4179
|
+
await (0, node_fs_promises.writeFile)(composePath, dockerCompose);
|
|
4180
|
+
logger$5.log(` Generated: .gkm/docker/docker-compose.yml`);
|
|
4181
|
+
logger$5.log(`\n✅ Generated ${results.length} Dockerfile(s) + docker-compose.yml`);
|
|
4182
|
+
logger$5.log("\n📋 Build commands:");
|
|
4183
|
+
for (const result of results) {
|
|
4184
|
+
const icon = result.type === "backend" ? "⚙️" : "🌐";
|
|
4185
|
+
logger$5.log(` ${icon} docker build -f .gkm/docker/Dockerfile.${result.appName} -t ${result.imageName} .`);
|
|
4063
4186
|
}
|
|
4064
|
-
|
|
4187
|
+
logger$5.log("\n📋 Run all services:");
|
|
4188
|
+
logger$5.log(" docker compose -f .gkm/docker/docker-compose.yml up --build");
|
|
4189
|
+
return {
|
|
4190
|
+
apps: results,
|
|
4191
|
+
dockerCompose: composePath,
|
|
4192
|
+
dockerignore: dockerignorePath
|
|
4193
|
+
};
|
|
4194
|
+
}
|
|
4065
4195
|
|
|
4066
4196
|
//#endregion
|
|
4067
|
-
//#region src/deploy/
|
|
4068
|
-
const logger$3 = console;
|
|
4197
|
+
//#region src/deploy/docker.ts
|
|
4069
4198
|
/**
|
|
4070
|
-
*
|
|
4199
|
+
* Get app name from package.json in the current working directory
|
|
4200
|
+
* Used for Dokploy app/project naming
|
|
4071
4201
|
*/
|
|
4072
|
-
|
|
4202
|
+
function getAppNameFromCwd$1() {
|
|
4203
|
+
const packageJsonPath = (0, node_path.join)(process.cwd(), "package.json");
|
|
4204
|
+
if (!(0, node_fs.existsSync)(packageJsonPath)) return void 0;
|
|
4073
4205
|
try {
|
|
4074
|
-
const
|
|
4075
|
-
return
|
|
4076
|
-
} catch
|
|
4077
|
-
|
|
4078
|
-
}
|
|
4079
|
-
}
|
|
4080
|
-
/**
|
|
4081
|
-
* Extract subdomain from full hostname relative to root domain
|
|
4082
|
-
*
|
|
4083
|
-
* @example
|
|
4084
|
-
* extractSubdomain('api.joemoer.traflabs.io', 'traflabs.io') => 'api.joemoer'
|
|
4085
|
-
* extractSubdomain('joemoer.traflabs.io', 'traflabs.io') => 'joemoer'
|
|
4086
|
-
*/
|
|
4087
|
-
function extractSubdomain(hostname, rootDomain) {
|
|
4088
|
-
if (!hostname.endsWith(rootDomain)) throw new Error(`Hostname ${hostname} is not under root domain ${rootDomain}`);
|
|
4089
|
-
const subdomain = hostname.slice(0, -(rootDomain.length + 1));
|
|
4090
|
-
return subdomain || "@";
|
|
4091
|
-
}
|
|
4092
|
-
/**
|
|
4093
|
-
* Generate required DNS records for a deployment
|
|
4094
|
-
*/
|
|
4095
|
-
function generateRequiredRecords(appHostnames, rootDomain, serverIp) {
|
|
4096
|
-
const records = [];
|
|
4097
|
-
for (const [appName, hostname] of appHostnames) {
|
|
4098
|
-
const subdomain = extractSubdomain(hostname, rootDomain);
|
|
4099
|
-
records.push({
|
|
4100
|
-
hostname,
|
|
4101
|
-
subdomain,
|
|
4102
|
-
type: "A",
|
|
4103
|
-
value: serverIp,
|
|
4104
|
-
appName
|
|
4105
|
-
});
|
|
4106
|
-
}
|
|
4107
|
-
return records;
|
|
4108
|
-
}
|
|
4109
|
-
/**
|
|
4110
|
-
* Print DNS records table
|
|
4111
|
-
*/
|
|
4112
|
-
function printDnsRecordsTable(records, rootDomain) {
|
|
4113
|
-
logger$3.log("\n 📋 DNS Records for " + rootDomain + ":");
|
|
4114
|
-
logger$3.log(" ┌─────────────────────────────────────┬──────┬─────────────────┬────────┐");
|
|
4115
|
-
logger$3.log(" │ Subdomain │ Type │ Value │ Status │");
|
|
4116
|
-
logger$3.log(" ├─────────────────────────────────────┼──────┼─────────────────┼────────┤");
|
|
4117
|
-
for (const record of records) {
|
|
4118
|
-
const subdomain = record.subdomain.padEnd(35);
|
|
4119
|
-
const type$1 = record.type.padEnd(4);
|
|
4120
|
-
const value = record.value.padEnd(15);
|
|
4121
|
-
let status;
|
|
4122
|
-
if (record.error) status = "✗";
|
|
4123
|
-
else if (record.created) status = "✓ new";
|
|
4124
|
-
else if (record.existed) status = "✓";
|
|
4125
|
-
else status = "?";
|
|
4126
|
-
logger$3.log(` │ ${subdomain} │ ${type$1} │ ${value} │ ${status.padEnd(6)} │`);
|
|
4127
|
-
}
|
|
4128
|
-
logger$3.log(" └─────────────────────────────────────┴──────┴─────────────────┴────────┘");
|
|
4206
|
+
const pkg$1 = JSON.parse((0, node_fs.readFileSync)(packageJsonPath, "utf-8"));
|
|
4207
|
+
if (pkg$1.name) return pkg$1.name.replace(/^@[^/]+\//, "");
|
|
4208
|
+
} catch {}
|
|
4209
|
+
return void 0;
|
|
4129
4210
|
}
|
|
4130
4211
|
/**
|
|
4131
|
-
*
|
|
4212
|
+
* Get app name from package.json adjacent to the lockfile (project root)
|
|
4213
|
+
* Used for Docker image naming
|
|
4132
4214
|
*/
|
|
4133
|
-
function
|
|
4134
|
-
|
|
4135
|
-
|
|
4136
|
-
|
|
4137
|
-
|
|
4215
|
+
function getAppNameFromPackageJson() {
|
|
4216
|
+
const cwd = process.cwd();
|
|
4217
|
+
const lockfilePath = findLockfilePath(cwd);
|
|
4218
|
+
if (!lockfilePath) return void 0;
|
|
4219
|
+
const projectRoot = (0, node_path.dirname)(lockfilePath);
|
|
4220
|
+
const packageJsonPath = (0, node_path.join)(projectRoot, "package.json");
|
|
4221
|
+
if (!(0, node_fs.existsSync)(packageJsonPath)) return void 0;
|
|
4222
|
+
try {
|
|
4223
|
+
const pkg$1 = JSON.parse((0, node_fs.readFileSync)(packageJsonPath, "utf-8"));
|
|
4224
|
+
if (pkg$1.name) return pkg$1.name.replace(/^@[^/]+\//, "");
|
|
4225
|
+
} catch {}
|
|
4226
|
+
return void 0;
|
|
4138
4227
|
}
|
|
4228
|
+
const logger$4 = console;
|
|
4139
4229
|
/**
|
|
4140
|
-
*
|
|
4230
|
+
* Get the full image reference
|
|
4141
4231
|
*/
|
|
4142
|
-
|
|
4143
|
-
|
|
4144
|
-
|
|
4145
|
-
stdout.write(message);
|
|
4146
|
-
return new Promise((resolve$3) => {
|
|
4147
|
-
let value = "";
|
|
4148
|
-
const onData = (char) => {
|
|
4149
|
-
const c = char.toString();
|
|
4150
|
-
if (c === "\n" || c === "\r") {
|
|
4151
|
-
stdin.setRawMode(false);
|
|
4152
|
-
stdin.pause();
|
|
4153
|
-
stdin.removeListener("data", onData);
|
|
4154
|
-
stdout.write("\n");
|
|
4155
|
-
resolve$3(value);
|
|
4156
|
-
} else if (c === "") {
|
|
4157
|
-
stdin.setRawMode(false);
|
|
4158
|
-
stdin.pause();
|
|
4159
|
-
stdout.write("\n");
|
|
4160
|
-
process.exit(1);
|
|
4161
|
-
} else if (c === "" || c === "\b") {
|
|
4162
|
-
if (value.length > 0) value = value.slice(0, -1);
|
|
4163
|
-
} else value += c;
|
|
4164
|
-
};
|
|
4165
|
-
stdin.setRawMode(true);
|
|
4166
|
-
stdin.resume();
|
|
4167
|
-
stdin.on("data", onData);
|
|
4168
|
-
});
|
|
4232
|
+
function getImageRef(registry, imageName, tag) {
|
|
4233
|
+
if (registry) return `${registry}/${imageName}:${tag}`;
|
|
4234
|
+
return `${imageName}:${tag}`;
|
|
4169
4235
|
}
|
|
4170
4236
|
/**
|
|
4171
|
-
*
|
|
4237
|
+
* Build Docker image
|
|
4238
|
+
* @param imageRef - Full image reference (registry/name:tag)
|
|
4239
|
+
* @param appName - Name of the app (used for Dockerfile.{appName} in workspaces)
|
|
4240
|
+
* @param buildArgs - Build arguments to pass to docker build
|
|
4172
4241
|
*/
|
|
4173
|
-
async function
|
|
4174
|
-
|
|
4175
|
-
|
|
4176
|
-
|
|
4177
|
-
|
|
4178
|
-
|
|
4179
|
-
|
|
4180
|
-
|
|
4181
|
-
|
|
4182
|
-
|
|
4183
|
-
|
|
4184
|
-
|
|
4185
|
-
|
|
4186
|
-
|
|
4242
|
+
async function buildImage(imageRef, appName, buildArgs) {
|
|
4243
|
+
logger$4.log(`\n🔨 Building Docker image: ${imageRef}`);
|
|
4244
|
+
const cwd = process.cwd();
|
|
4245
|
+
const lockfilePath = findLockfilePath(cwd);
|
|
4246
|
+
const lockfileDir = lockfilePath ? (0, node_path.dirname)(lockfilePath) : cwd;
|
|
4247
|
+
const inMonorepo = lockfileDir !== cwd;
|
|
4248
|
+
if (appName || inMonorepo) logger$4.log(" Generating Dockerfile for monorepo (turbo prune)...");
|
|
4249
|
+
else logger$4.log(" Generating Dockerfile...");
|
|
4250
|
+
await dockerCommand({});
|
|
4251
|
+
const dockerfileSuffix = appName ? `.${appName}` : "";
|
|
4252
|
+
const dockerfilePath = `.gkm/docker/Dockerfile${dockerfileSuffix}`;
|
|
4253
|
+
const buildCwd = lockfilePath && (inMonorepo || appName) ? lockfileDir : cwd;
|
|
4254
|
+
if (buildCwd !== cwd) logger$4.log(` Building from workspace root: ${buildCwd}`);
|
|
4255
|
+
const buildArgsString = buildArgs && buildArgs.length > 0 ? buildArgs.map((arg) => `--build-arg "${arg}"`).join(" ") : "";
|
|
4256
|
+
try {
|
|
4257
|
+
const cmd = [
|
|
4258
|
+
"DOCKER_BUILDKIT=1 docker build",
|
|
4259
|
+
"--platform linux/amd64",
|
|
4260
|
+
`-f ${dockerfilePath}`,
|
|
4261
|
+
`-t ${imageRef}`,
|
|
4262
|
+
buildArgsString,
|
|
4263
|
+
"."
|
|
4264
|
+
].filter(Boolean).join(" ");
|
|
4265
|
+
(0, node_child_process.execSync)(cmd, {
|
|
4266
|
+
cwd: buildCwd,
|
|
4267
|
+
stdio: "inherit",
|
|
4268
|
+
env: {
|
|
4269
|
+
...process.env,
|
|
4270
|
+
DOCKER_BUILDKIT: "1"
|
|
4271
|
+
}
|
|
4272
|
+
});
|
|
4273
|
+
logger$4.log(`✅ Image built: ${imageRef}`);
|
|
4274
|
+
} catch (error) {
|
|
4275
|
+
throw new Error(`Failed to build Docker image: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
4187
4276
|
}
|
|
4188
|
-
return records;
|
|
4189
4277
|
}
|
|
4190
4278
|
/**
|
|
4191
|
-
*
|
|
4279
|
+
* Push Docker image to registry
|
|
4192
4280
|
*/
|
|
4193
|
-
async function
|
|
4194
|
-
|
|
4195
|
-
if (!token) {
|
|
4196
|
-
logger$3.log("\n 📋 Hostinger API token not found.");
|
|
4197
|
-
logger$3.log(" Get your token from: https://hpanel.hostinger.com/profile/api\n");
|
|
4198
|
-
try {
|
|
4199
|
-
token = await promptForToken(" Hostinger API Token: ");
|
|
4200
|
-
await storeHostingerToken(token);
|
|
4201
|
-
logger$3.log(" ✓ Token saved");
|
|
4202
|
-
} catch {
|
|
4203
|
-
logger$3.log(" ⚠ Could not get token, skipping DNS creation");
|
|
4204
|
-
return records.map((r) => ({
|
|
4205
|
-
...r,
|
|
4206
|
-
error: "No API token"
|
|
4207
|
-
}));
|
|
4208
|
-
}
|
|
4209
|
-
}
|
|
4210
|
-
const api = new HostingerApi(token);
|
|
4211
|
-
const results = [];
|
|
4212
|
-
let existingRecords = [];
|
|
4281
|
+
async function pushImage(imageRef) {
|
|
4282
|
+
logger$4.log(`\n☁️ Pushing image: ${imageRef}`);
|
|
4213
4283
|
try {
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
|
|
4221
|
-
}));
|
|
4222
|
-
}
|
|
4223
|
-
for (const record of records) {
|
|
4224
|
-
const existing = existingRecords.find((r) => r.name === record.subdomain && r.type === "A");
|
|
4225
|
-
if (existing) {
|
|
4226
|
-
results.push({
|
|
4227
|
-
...record,
|
|
4228
|
-
existed: true,
|
|
4229
|
-
created: false
|
|
4230
|
-
});
|
|
4231
|
-
continue;
|
|
4232
|
-
}
|
|
4233
|
-
try {
|
|
4234
|
-
await api.upsertRecords(rootDomain, [{
|
|
4235
|
-
name: record.subdomain,
|
|
4236
|
-
type: "A",
|
|
4237
|
-
ttl,
|
|
4238
|
-
records: [{ content: record.value }]
|
|
4239
|
-
}]);
|
|
4240
|
-
results.push({
|
|
4241
|
-
...record,
|
|
4242
|
-
created: true,
|
|
4243
|
-
existed: false
|
|
4244
|
-
});
|
|
4245
|
-
} catch (error) {
|
|
4246
|
-
const message = error instanceof Error ? error.message : "Unknown error";
|
|
4247
|
-
results.push({
|
|
4248
|
-
...record,
|
|
4249
|
-
error: message
|
|
4250
|
-
});
|
|
4251
|
-
}
|
|
4284
|
+
(0, node_child_process.execSync)(`docker push ${imageRef}`, {
|
|
4285
|
+
cwd: process.cwd(),
|
|
4286
|
+
stdio: "inherit"
|
|
4287
|
+
});
|
|
4288
|
+
logger$4.log(`✅ Image pushed: ${imageRef}`);
|
|
4289
|
+
} catch (error) {
|
|
4290
|
+
throw new Error(`Failed to push Docker image: ${error instanceof Error ? error.message : "Unknown error"}`);
|
|
4252
4291
|
}
|
|
4253
|
-
return results;
|
|
4254
4292
|
}
|
|
4255
4293
|
/**
|
|
4256
|
-
*
|
|
4294
|
+
* Deploy using Docker (build and optionally push image)
|
|
4257
4295
|
*/
|
|
4258
|
-
async function
|
|
4259
|
-
|
|
4260
|
-
const
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
|
|
4265
|
-
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4296
|
+
async function deployDocker(options) {
|
|
4297
|
+
const { stage, tag, skipPush, masterKey, config, buildArgs } = options;
|
|
4298
|
+
const imageName = config.imageName;
|
|
4299
|
+
const imageRef = getImageRef(config.registry, imageName, tag);
|
|
4300
|
+
await buildImage(imageRef, config.appName, buildArgs);
|
|
4301
|
+
if (!skipPush) if (!config.registry) logger$4.warn("\n⚠️ No registry configured. Use --skip-push or configure docker.registry in gkm.config.ts");
|
|
4302
|
+
else await pushImage(imageRef);
|
|
4303
|
+
logger$4.log("\n✅ Docker deployment ready!");
|
|
4304
|
+
logger$4.log(`\n📋 Deployment details:`);
|
|
4305
|
+
logger$4.log(` Image: ${imageRef}`);
|
|
4306
|
+
logger$4.log(` Stage: ${stage}`);
|
|
4307
|
+
if (masterKey) {
|
|
4308
|
+
logger$4.log(`\n🔐 Deploy with this environment variable:`);
|
|
4309
|
+
logger$4.log(` GKM_MASTER_KEY=${masterKey}`);
|
|
4310
|
+
logger$4.log("\n Example docker run:");
|
|
4311
|
+
logger$4.log(` docker run -e GKM_MASTER_KEY=${masterKey} ${imageRef}`);
|
|
4271
4312
|
}
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4313
|
+
return {
|
|
4314
|
+
imageRef,
|
|
4315
|
+
masterKey
|
|
4316
|
+
};
|
|
4317
|
+
}
|
|
4318
|
+
/**
|
|
4319
|
+
* Resolve Docker deploy config from gkm config
|
|
4320
|
+
* - imageName: from config, or cwd package.json, or 'app' (for Docker image)
|
|
4321
|
+
* - projectName: from root package.json, or 'app' (for Dokploy project)
|
|
4322
|
+
* - appName: from cwd package.json, or projectName (for Dokploy app within project)
|
|
4323
|
+
*/
|
|
4324
|
+
function resolveDockerConfig(config) {
|
|
4325
|
+
const projectName = getAppNameFromPackageJson() ?? "app";
|
|
4326
|
+
const appName = getAppNameFromCwd$1() ?? projectName;
|
|
4327
|
+
const imageName = config.docker?.imageName ?? appName;
|
|
4328
|
+
return {
|
|
4329
|
+
registry: config.docker?.registry,
|
|
4330
|
+
imageName,
|
|
4331
|
+
projectName,
|
|
4332
|
+
appName
|
|
4333
|
+
};
|
|
4334
|
+
}
|
|
4335
|
+
|
|
4336
|
+
//#endregion
|
|
4337
|
+
//#region src/deploy/dokploy.ts
|
|
4338
|
+
const logger$3 = console;
|
|
4339
|
+
/**
|
|
4340
|
+
* Get the Dokploy API token from stored credentials or environment
|
|
4341
|
+
*/
|
|
4342
|
+
async function getApiToken$1() {
|
|
4343
|
+
const token = await getDokployToken();
|
|
4344
|
+
if (!token) throw new Error("Dokploy credentials not found.\nRun \"gkm login --service dokploy\" to authenticate, or set DOKPLOY_API_TOKEN.");
|
|
4345
|
+
return token;
|
|
4346
|
+
}
|
|
4347
|
+
/**
|
|
4348
|
+
* Create a Dokploy API client
|
|
4349
|
+
*/
|
|
4350
|
+
async function createApi$1(endpoint) {
|
|
4351
|
+
const token = await getApiToken$1();
|
|
4352
|
+
return new require_dokploy_api.DokployApi({
|
|
4353
|
+
baseUrl: endpoint,
|
|
4354
|
+
token
|
|
4355
|
+
});
|
|
4356
|
+
}
|
|
4357
|
+
/**
|
|
4358
|
+
* Deploy to Dokploy
|
|
4359
|
+
*/
|
|
4360
|
+
async function deployDokploy(options) {
|
|
4361
|
+
const { stage, imageRef, masterKey, config } = options;
|
|
4362
|
+
logger$3.log(`\n🎯 Deploying to Dokploy...`);
|
|
4363
|
+
logger$3.log(` Endpoint: ${config.endpoint}`);
|
|
4364
|
+
logger$3.log(` Application: ${config.applicationId}`);
|
|
4365
|
+
const api = await createApi$1(config.endpoint);
|
|
4366
|
+
logger$3.log(` Configuring Docker image: ${imageRef}`);
|
|
4367
|
+
const registryOptions = {};
|
|
4368
|
+
if (config.registryId) {
|
|
4369
|
+
registryOptions.registryId = config.registryId;
|
|
4370
|
+
logger$3.log(` Using Dokploy registry: ${config.registryId}`);
|
|
4371
|
+
} else {
|
|
4372
|
+
const storedRegistryId = await getDokployRegistryId();
|
|
4373
|
+
if (storedRegistryId) {
|
|
4374
|
+
registryOptions.registryId = storedRegistryId;
|
|
4375
|
+
logger$3.log(` Using stored Dokploy registry: ${storedRegistryId}`);
|
|
4376
|
+
} else if (config.registryCredentials) {
|
|
4377
|
+
registryOptions.username = config.registryCredentials.username;
|
|
4378
|
+
registryOptions.password = config.registryCredentials.password;
|
|
4379
|
+
registryOptions.registryUrl = config.registryCredentials.registryUrl;
|
|
4380
|
+
logger$3.log(` Using registry credentials for: ${config.registryCredentials.registryUrl}`);
|
|
4381
|
+
} else {
|
|
4382
|
+
const username = process.env.DOCKER_REGISTRY_USERNAME;
|
|
4383
|
+
const password = process.env.DOCKER_REGISTRY_PASSWORD;
|
|
4384
|
+
const registryUrl = process.env.DOCKER_REGISTRY_URL || config.registry;
|
|
4385
|
+
if (username && password && registryUrl) {
|
|
4386
|
+
registryOptions.username = username;
|
|
4387
|
+
registryOptions.password = password;
|
|
4388
|
+
registryOptions.registryUrl = registryUrl;
|
|
4389
|
+
logger$3.log(` Using registry credentials from environment`);
|
|
4390
|
+
}
|
|
4391
|
+
}
|
|
4280
4392
|
}
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
const
|
|
4288
|
-
|
|
4289
|
-
|
|
4290
|
-
|
|
4291
|
-
|
|
4292
|
-
|
|
4293
|
-
|
|
4294
|
-
|
|
4393
|
+
await api.saveDockerProvider(config.applicationId, imageRef, registryOptions);
|
|
4394
|
+
logger$3.log(" ✓ Docker provider configured");
|
|
4395
|
+
const envVars = {};
|
|
4396
|
+
if (masterKey) envVars.GKM_MASTER_KEY = masterKey;
|
|
4397
|
+
if (Object.keys(envVars).length > 0) {
|
|
4398
|
+
logger$3.log(" Updating environment variables...");
|
|
4399
|
+
const envString = Object.entries(envVars).map(([key, value]) => `${key}=${value}`).join("\n");
|
|
4400
|
+
await api.saveApplicationEnv(config.applicationId, envString);
|
|
4401
|
+
logger$3.log(" ✓ Environment variables updated");
|
|
4402
|
+
}
|
|
4403
|
+
logger$3.log(" Triggering deployment...");
|
|
4404
|
+
await api.deployApplication(config.applicationId);
|
|
4405
|
+
logger$3.log(" ✓ Deployment triggered");
|
|
4406
|
+
logger$3.log("\n✅ Dokploy deployment initiated!");
|
|
4407
|
+
logger$3.log(`\n📋 Deployment details:`);
|
|
4408
|
+
logger$3.log(` Image: ${imageRef}`);
|
|
4409
|
+
logger$3.log(` Stage: ${stage}`);
|
|
4410
|
+
logger$3.log(` Application ID: ${config.applicationId}`);
|
|
4411
|
+
if (masterKey) logger$3.log(`\n🔐 GKM_MASTER_KEY has been set in Dokploy environment`);
|
|
4412
|
+
const deploymentUrl = `${config.endpoint}/project/${config.projectId}`;
|
|
4413
|
+
logger$3.log(`\n🔗 View deployment: ${deploymentUrl}`);
|
|
4295
4414
|
return {
|
|
4296
|
-
|
|
4297
|
-
|
|
4298
|
-
|
|
4415
|
+
imageRef,
|
|
4416
|
+
masterKey,
|
|
4417
|
+
url: deploymentUrl
|
|
4299
4418
|
};
|
|
4300
4419
|
}
|
|
4301
4420
|
|
|
@@ -4374,6 +4493,107 @@ function getPublicUrlArgNames(app) {
|
|
|
4374
4493
|
return app.dependencies.map((dep) => `NEXT_PUBLIC_${dep.toUpperCase()}_URL`);
|
|
4375
4494
|
}
|
|
4376
4495
|
|
|
4496
|
+
//#endregion
|
|
4497
|
+
//#region src/deploy/env-resolver.ts
|
|
4498
|
+
/**
|
|
4499
|
+
* Generate a secure random secret (64 hex characters = 32 bytes)
|
|
4500
|
+
*/
|
|
4501
|
+
function generateSecret() {
|
|
4502
|
+
return (0, node_crypto.randomBytes)(32).toString("hex");
|
|
4503
|
+
}
|
|
4504
|
+
/**
|
|
4505
|
+
* Get or generate a secret for an app.
|
|
4506
|
+
* If the secret already exists in state, returns it.
|
|
4507
|
+
* Otherwise generates a new one and stores it.
|
|
4508
|
+
*/
|
|
4509
|
+
function getOrGenerateSecret(state, appName, secretName) {
|
|
4510
|
+
const existing = getGeneratedSecret(state, appName, secretName);
|
|
4511
|
+
if (existing) return existing;
|
|
4512
|
+
const generated = generateSecret();
|
|
4513
|
+
setGeneratedSecret(state, appName, secretName, generated);
|
|
4514
|
+
return generated;
|
|
4515
|
+
}
|
|
4516
|
+
/**
|
|
4517
|
+
* Build a DATABASE_URL for an app with per-app credentials
|
|
4518
|
+
*/
|
|
4519
|
+
function buildDatabaseUrl(credentials, postgres) {
|
|
4520
|
+
const { dbUser, dbPassword } = credentials;
|
|
4521
|
+
const { host, port, database } = postgres;
|
|
4522
|
+
return `postgresql://${encodeURIComponent(dbUser)}:${encodeURIComponent(dbPassword)}@${host}:${port}/${database}`;
|
|
4523
|
+
}
|
|
4524
|
+
/**
|
|
4525
|
+
* Build a REDIS_URL
|
|
4526
|
+
*/
|
|
4527
|
+
function buildRedisUrl(redis) {
|
|
4528
|
+
const { host, port, password } = redis;
|
|
4529
|
+
if (password) return `redis://:${encodeURIComponent(password)}@${host}:${port}`;
|
|
4530
|
+
return `redis://${host}:${port}`;
|
|
4531
|
+
}
|
|
4532
|
+
/**
|
|
4533
|
+
* Resolve a single environment variable
|
|
4534
|
+
*/
|
|
4535
|
+
function resolveEnvVar(varName, context) {
|
|
4536
|
+
switch (varName) {
|
|
4537
|
+
case "PORT": return String(context.app.port);
|
|
4538
|
+
case "NODE_ENV": return context.stage === "production" ? "production" : "development";
|
|
4539
|
+
case "DATABASE_URL":
|
|
4540
|
+
if (context.appCredentials && context.postgres) return buildDatabaseUrl(context.appCredentials, context.postgres);
|
|
4541
|
+
break;
|
|
4542
|
+
case "REDIS_URL":
|
|
4543
|
+
if (context.redis) return buildRedisUrl(context.redis);
|
|
4544
|
+
break;
|
|
4545
|
+
case "BETTER_AUTH_URL": return `https://${context.appHostname}`;
|
|
4546
|
+
case "BETTER_AUTH_SECRET": return getOrGenerateSecret(context.state, context.appName, "BETTER_AUTH_SECRET");
|
|
4547
|
+
case "BETTER_AUTH_TRUSTED_ORIGINS":
|
|
4548
|
+
if (context.frontendUrls.length > 0) return context.frontendUrls.join(",");
|
|
4549
|
+
break;
|
|
4550
|
+
case "GKM_MASTER_KEY":
|
|
4551
|
+
if (context.masterKey) return context.masterKey;
|
|
4552
|
+
break;
|
|
4553
|
+
}
|
|
4554
|
+
if (context.userSecrets) {
|
|
4555
|
+
if (context.userSecrets.custom[varName]) return context.userSecrets.custom[varName];
|
|
4556
|
+
if (varName in context.userSecrets.urls) return context.userSecrets.urls[varName];
|
|
4557
|
+
if (varName === "POSTGRES_PASSWORD" && context.userSecrets.services.postgres) return context.userSecrets.services.postgres.password;
|
|
4558
|
+
if (varName === "REDIS_PASSWORD" && context.userSecrets.services.redis) return context.userSecrets.services.redis.password;
|
|
4559
|
+
}
|
|
4560
|
+
return void 0;
|
|
4561
|
+
}
|
|
4562
|
+
/**
|
|
4563
|
+
* Resolve all environment variables for an app
|
|
4564
|
+
*/
|
|
4565
|
+
function resolveEnvVars(requiredVars, context) {
|
|
4566
|
+
const resolved = {};
|
|
4567
|
+
const missing = [];
|
|
4568
|
+
for (const varName of requiredVars) {
|
|
4569
|
+
const value = resolveEnvVar(varName, context);
|
|
4570
|
+
if (value !== void 0) resolved[varName] = value;
|
|
4571
|
+
else missing.push(varName);
|
|
4572
|
+
}
|
|
4573
|
+
return {
|
|
4574
|
+
resolved,
|
|
4575
|
+
missing
|
|
4576
|
+
};
|
|
4577
|
+
}
|
|
4578
|
+
/**
|
|
4579
|
+
* Format missing variables error message
|
|
4580
|
+
*/
|
|
4581
|
+
function formatMissingVarsError(appName, missing, stage) {
|
|
4582
|
+
const varList = missing.map((v) => ` - ${v}`).join("\n");
|
|
4583
|
+
return `Deployment failed: ${appName} is missing required environment variables:\n${varList}\n\nAdd them with:\n gkm secrets:set <VAR_NAME> <value> --stage ${stage}\n\nOr add them to the app's requiredEnv in gkm.config.ts to have them auto-resolved.`;
|
|
4584
|
+
}
|
|
4585
|
+
/**
|
|
4586
|
+
* Validate that all required environment variables can be resolved
|
|
4587
|
+
*/
|
|
4588
|
+
function validateEnvVars(requiredVars, context) {
|
|
4589
|
+
const { resolved, missing } = resolveEnvVars(requiredVars, context);
|
|
4590
|
+
return {
|
|
4591
|
+
valid: missing.length === 0,
|
|
4592
|
+
missing,
|
|
4593
|
+
resolved
|
|
4594
|
+
};
|
|
4595
|
+
}
|
|
4596
|
+
|
|
4377
4597
|
//#endregion
|
|
4378
4598
|
//#region src/deploy/init.ts
|
|
4379
4599
|
const logger$2 = console;
|
|
@@ -4646,14 +4866,35 @@ function generateSecretsReport(encryptedApps, sniffedApps) {
|
|
|
4646
4866
|
|
|
4647
4867
|
//#endregion
|
|
4648
4868
|
//#region src/deploy/sniffer.ts
|
|
4869
|
+
const __filename$1 = (0, node_url.fileURLToPath)(require("url").pathToFileURL(__filename).href);
|
|
4870
|
+
const __dirname$1 = (0, node_path.dirname)(__filename$1);
|
|
4871
|
+
/**
|
|
4872
|
+
* Resolve the path to a sniffer helper file.
|
|
4873
|
+
* Handles both dev (.ts with tsx) and production (.mjs from dist).
|
|
4874
|
+
*
|
|
4875
|
+
* In production: sniffer.ts is bundled into dist/index.mjs, but sniffer helper
|
|
4876
|
+
* files are output to dist/deploy/ as standalone modules for subprocess loading.
|
|
4877
|
+
*
|
|
4878
|
+
* In development: All files are in src/deploy/ and loaded via tsx.
|
|
4879
|
+
*/
|
|
4880
|
+
function resolveSnifferFile(baseName) {
|
|
4881
|
+
const deployMjsPath = (0, node_path.resolve)(__dirname$1, "deploy", `${baseName}.mjs`);
|
|
4882
|
+
if ((0, node_fs.existsSync)(deployMjsPath)) return deployMjsPath;
|
|
4883
|
+
const mjsPath = (0, node_path.resolve)(__dirname$1, `${baseName}.mjs`);
|
|
4884
|
+
if ((0, node_fs.existsSync)(mjsPath)) return mjsPath;
|
|
4885
|
+
const tsPath = (0, node_path.resolve)(__dirname$1, `${baseName}.ts`);
|
|
4886
|
+
if ((0, node_fs.existsSync)(tsPath)) return tsPath;
|
|
4887
|
+
return tsPath;
|
|
4888
|
+
}
|
|
4649
4889
|
/**
|
|
4650
4890
|
* Get required environment variables for an app.
|
|
4651
4891
|
*
|
|
4652
|
-
* Detection strategy:
|
|
4653
|
-
*
|
|
4654
|
-
*
|
|
4655
|
-
*
|
|
4656
|
-
*
|
|
4892
|
+
* Detection strategy (in order):
|
|
4893
|
+
* 1. Frontend apps: Returns empty (no server secrets)
|
|
4894
|
+
* 2. Apps with `requiredEnv`: Uses explicit list from config
|
|
4895
|
+
* 3. Entry apps: Imports entry file in subprocess to capture config.parse() calls
|
|
4896
|
+
* 4. Apps with `envParser`: Runs SnifferEnvironmentParser to detect usage
|
|
4897
|
+
* 5. Apps with neither: Returns empty
|
|
4657
4898
|
*
|
|
4658
4899
|
* This function handles "fire and forget" async operations gracefully,
|
|
4659
4900
|
* capturing errors and unhandled rejections without failing the build.
|
|
@@ -4674,6 +4915,14 @@ async function sniffAppEnvironment(app, appName, workspacePath, options = {}) {
|
|
|
4674
4915
|
appName,
|
|
4675
4916
|
requiredEnvVars: [...app.requiredEnv]
|
|
4676
4917
|
};
|
|
4918
|
+
if (app.entry) {
|
|
4919
|
+
const result = await sniffEntryFile(app.entry, app.path, workspacePath);
|
|
4920
|
+
if (logWarnings && result.error) console.warn(`[sniffer] ${appName}: Entry file threw error during sniffing (env vars still captured): ${result.error.message}`);
|
|
4921
|
+
return {
|
|
4922
|
+
appName,
|
|
4923
|
+
requiredEnvVars: result.envVars
|
|
4924
|
+
};
|
|
4925
|
+
}
|
|
4677
4926
|
if (app.envParser) {
|
|
4678
4927
|
const result = await sniffEnvParser(app.envParser, app.path, workspacePath);
|
|
4679
4928
|
if (logWarnings) {
|
|
@@ -4691,6 +4940,80 @@ async function sniffAppEnvironment(app, appName, workspacePath, options = {}) {
|
|
|
4691
4940
|
};
|
|
4692
4941
|
}
|
|
4693
4942
|
/**
|
|
4943
|
+
* Sniff an entry file by importing it in a subprocess.
|
|
4944
|
+
*
|
|
4945
|
+
* Entry apps call `config.parse()` at module load time. To capture which
|
|
4946
|
+
* env vars are accessed, we:
|
|
4947
|
+
* 1. Spawn a subprocess with a module loader hook
|
|
4948
|
+
* 2. The loader intercepts `@geekmidas/envkit` and replaces EnvironmentParser
|
|
4949
|
+
* with SnifferEnvironmentParser
|
|
4950
|
+
* 3. Import the entry file (triggers config.parse())
|
|
4951
|
+
* 4. Capture and return the accessed env var names
|
|
4952
|
+
*
|
|
4953
|
+
* This approach provides process isolation - each app is sniffed in its own
|
|
4954
|
+
* subprocess, preventing module cache pollution.
|
|
4955
|
+
*
|
|
4956
|
+
* @param entryPath - Relative path to the entry file (e.g., './src/index.ts')
|
|
4957
|
+
* @param appPath - The app's path relative to workspace (e.g., 'apps/auth')
|
|
4958
|
+
* @param workspacePath - Absolute path to workspace root
|
|
4959
|
+
* @returns EntrySniffResult with env vars and optional error
|
|
4960
|
+
*/
|
|
4961
|
+
async function sniffEntryFile(entryPath, appPath, workspacePath) {
|
|
4962
|
+
const fullEntryPath = (0, node_path.resolve)(workspacePath, appPath, entryPath);
|
|
4963
|
+
const loaderPath = resolveSnifferFile("sniffer-loader");
|
|
4964
|
+
const workerPath = resolveSnifferFile("sniffer-worker");
|
|
4965
|
+
return new Promise((resolvePromise) => {
|
|
4966
|
+
const child = (0, node_child_process.spawn)("node", [
|
|
4967
|
+
"--import",
|
|
4968
|
+
loaderPath,
|
|
4969
|
+
workerPath,
|
|
4970
|
+
fullEntryPath
|
|
4971
|
+
], {
|
|
4972
|
+
cwd: (0, node_path.resolve)(workspacePath, appPath),
|
|
4973
|
+
stdio: [
|
|
4974
|
+
"ignore",
|
|
4975
|
+
"pipe",
|
|
4976
|
+
"pipe"
|
|
4977
|
+
],
|
|
4978
|
+
env: {
|
|
4979
|
+
...process.env,
|
|
4980
|
+
NODE_OPTIONS: "--import tsx"
|
|
4981
|
+
}
|
|
4982
|
+
});
|
|
4983
|
+
let stdout = "";
|
|
4984
|
+
let stderr = "";
|
|
4985
|
+
child.stdout.on("data", (data) => {
|
|
4986
|
+
stdout += data.toString();
|
|
4987
|
+
});
|
|
4988
|
+
child.stderr.on("data", (data) => {
|
|
4989
|
+
stderr += data.toString();
|
|
4990
|
+
});
|
|
4991
|
+
child.on("close", (code) => {
|
|
4992
|
+
try {
|
|
4993
|
+
const jsonMatch = stdout.match(/\{[^{}]*"envVars"[^{}]*\}[^{]*$/);
|
|
4994
|
+
if (jsonMatch) {
|
|
4995
|
+
const result = JSON.parse(jsonMatch[0]);
|
|
4996
|
+
resolvePromise({
|
|
4997
|
+
envVars: result.envVars || [],
|
|
4998
|
+
error: result.error ? new Error(result.error) : void 0
|
|
4999
|
+
});
|
|
5000
|
+
return;
|
|
5001
|
+
}
|
|
5002
|
+
} catch {}
|
|
5003
|
+
resolvePromise({
|
|
5004
|
+
envVars: [],
|
|
5005
|
+
error: new Error(`Failed to sniff entry file (exit code ${code}): ${stderr || stdout || "No output"}`)
|
|
5006
|
+
});
|
|
5007
|
+
});
|
|
5008
|
+
child.on("error", (err) => {
|
|
5009
|
+
resolvePromise({
|
|
5010
|
+
envVars: [],
|
|
5011
|
+
error: err
|
|
5012
|
+
});
|
|
5013
|
+
});
|
|
5014
|
+
});
|
|
5015
|
+
}
|
|
5016
|
+
/**
|
|
4694
5017
|
* Run the SnifferEnvironmentParser on an envParser module to detect
|
|
4695
5018
|
* which environment variables it accesses.
|
|
4696
5019
|
*
|
|
@@ -4800,10 +5123,130 @@ async function prompt(message, hidden = false) {
|
|
|
4800
5123
|
}
|
|
4801
5124
|
}
|
|
4802
5125
|
/**
|
|
5126
|
+
* Wait for Postgres to be ready to accept connections.
|
|
5127
|
+
*
|
|
5128
|
+
* Polls the Postgres server until it accepts a connection or max retries reached.
|
|
5129
|
+
* Used after enabling the external port to ensure the database is accessible
|
|
5130
|
+
* before creating users.
|
|
5131
|
+
*
|
|
5132
|
+
* @param host - The Postgres server hostname
|
|
5133
|
+
* @param port - The external port (typically 5432)
|
|
5134
|
+
* @param user - Master database user (postgres)
|
|
5135
|
+
* @param password - Master database password
|
|
5136
|
+
* @param database - Database name to connect to
|
|
5137
|
+
* @param maxRetries - Maximum number of connection attempts (default: 30)
|
|
5138
|
+
* @param retryIntervalMs - Milliseconds between retries (default: 2000)
|
|
5139
|
+
* @throws Error if Postgres is not ready after maxRetries
|
|
5140
|
+
*/
|
|
5141
|
+
async function waitForPostgres(host, port, user, password, database, maxRetries = 30, retryIntervalMs = 2e3) {
|
|
5142
|
+
for (let i = 0; i < maxRetries; i++) try {
|
|
5143
|
+
const client = new pg.Client({
|
|
5144
|
+
host,
|
|
5145
|
+
port,
|
|
5146
|
+
user,
|
|
5147
|
+
password,
|
|
5148
|
+
database
|
|
5149
|
+
});
|
|
5150
|
+
await client.connect();
|
|
5151
|
+
await client.end();
|
|
5152
|
+
return;
|
|
5153
|
+
} catch {
|
|
5154
|
+
if (i < maxRetries - 1) {
|
|
5155
|
+
logger$1.log(` Waiting for Postgres... (${i + 1}/${maxRetries})`);
|
|
5156
|
+
await new Promise((r) => setTimeout(r, retryIntervalMs));
|
|
5157
|
+
}
|
|
5158
|
+
}
|
|
5159
|
+
throw new Error(`Postgres not ready after ${maxRetries} retries`);
|
|
5160
|
+
}
|
|
5161
|
+
/**
|
|
5162
|
+
* Initialize Postgres with per-app users and schemas.
|
|
5163
|
+
*
|
|
5164
|
+
* This function implements the same user/schema isolation pattern used in local
|
|
5165
|
+
* dev mode (see docker/postgres/init.sh). It:
|
|
5166
|
+
*
|
|
5167
|
+
* 1. Temporarily enables the external Postgres port
|
|
5168
|
+
* 2. Connects using master credentials
|
|
5169
|
+
* 3. Creates each user with appropriate schema permissions
|
|
5170
|
+
* 4. Disables the external port for security
|
|
5171
|
+
*
|
|
5172
|
+
* Schema assignment follows this pattern:
|
|
5173
|
+
* - `api` app: Uses `public` schema (shared tables, migrations run here)
|
|
5174
|
+
* - Other apps: Get their own schema with `search_path` configured
|
|
5175
|
+
*
|
|
5176
|
+
* @param api - The Dokploy API client
|
|
5177
|
+
* @param postgres - The provisioned Postgres service details
|
|
5178
|
+
* @param serverHostname - The Dokploy server hostname (for external connection)
|
|
5179
|
+
* @param users - Array of users to create with their schema configuration
|
|
5180
|
+
*
|
|
5181
|
+
* @example
|
|
5182
|
+
* ```ts
|
|
5183
|
+
* await initializePostgresUsers(api, postgres, 'dokploy.example.com', [
|
|
5184
|
+
* { name: 'api', password: 'xxx', usePublicSchema: true },
|
|
5185
|
+
* { name: 'auth', password: 'yyy', usePublicSchema: false },
|
|
5186
|
+
* ]);
|
|
5187
|
+
* ```
|
|
5188
|
+
*/
|
|
5189
|
+
async function initializePostgresUsers(api, postgres, serverHostname, users) {
|
|
5190
|
+
logger$1.log("\n🔧 Initializing database users...");
|
|
5191
|
+
const externalPort = 5432;
|
|
5192
|
+
logger$1.log(` Enabling external port ${externalPort}...`);
|
|
5193
|
+
await api.savePostgresExternalPort(postgres.postgresId, externalPort);
|
|
5194
|
+
await api.deployPostgres(postgres.postgresId);
|
|
5195
|
+
logger$1.log(` Waiting for Postgres to be accessible at ${serverHostname}:${externalPort}...`);
|
|
5196
|
+
await waitForPostgres(serverHostname, externalPort, postgres.databaseUser, postgres.databasePassword, postgres.databaseName);
|
|
5197
|
+
const client = new pg.Client({
|
|
5198
|
+
host: serverHostname,
|
|
5199
|
+
port: externalPort,
|
|
5200
|
+
user: postgres.databaseUser,
|
|
5201
|
+
password: postgres.databasePassword,
|
|
5202
|
+
database: postgres.databaseName
|
|
5203
|
+
});
|
|
5204
|
+
try {
|
|
5205
|
+
await client.connect();
|
|
5206
|
+
for (const user of users) {
|
|
5207
|
+
const schemaName = user.usePublicSchema ? "public" : user.name;
|
|
5208
|
+
logger$1.log(` Creating user "${user.name}" with schema "${schemaName}"...`);
|
|
5209
|
+
await client.query(`
|
|
5210
|
+
DO $$ BEGIN
|
|
5211
|
+
CREATE USER "${user.name}" WITH PASSWORD '${user.password}';
|
|
5212
|
+
EXCEPTION WHEN duplicate_object THEN
|
|
5213
|
+
ALTER USER "${user.name}" WITH PASSWORD '${user.password}';
|
|
5214
|
+
END $$;
|
|
5215
|
+
`);
|
|
5216
|
+
if (user.usePublicSchema) await client.query(`
|
|
5217
|
+
GRANT ALL ON SCHEMA public TO "${user.name}";
|
|
5218
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON TABLES TO "${user.name}";
|
|
5219
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT ALL ON SEQUENCES TO "${user.name}";
|
|
5220
|
+
`);
|
|
5221
|
+
else await client.query(`
|
|
5222
|
+
CREATE SCHEMA IF NOT EXISTS "${schemaName}" AUTHORIZATION "${user.name}";
|
|
5223
|
+
ALTER USER "${user.name}" SET search_path TO "${schemaName}";
|
|
5224
|
+
GRANT USAGE ON SCHEMA "${schemaName}" TO "${user.name}";
|
|
5225
|
+
GRANT ALL ON ALL TABLES IN SCHEMA "${schemaName}" TO "${user.name}";
|
|
5226
|
+
ALTER DEFAULT PRIVILEGES IN SCHEMA "${schemaName}" GRANT ALL ON TABLES TO "${user.name}";
|
|
5227
|
+
`);
|
|
5228
|
+
logger$1.log(` ✓ User "${user.name}" configured`);
|
|
5229
|
+
}
|
|
5230
|
+
} finally {
|
|
5231
|
+
await client.end();
|
|
5232
|
+
}
|
|
5233
|
+
logger$1.log(" Disabling external port...");
|
|
5234
|
+
await api.savePostgresExternalPort(postgres.postgresId, null);
|
|
5235
|
+
await api.deployPostgres(postgres.postgresId);
|
|
5236
|
+
logger$1.log(" ✓ Database users initialized");
|
|
5237
|
+
}
|
|
5238
|
+
/**
|
|
5239
|
+
* Get the server hostname from the Dokploy endpoint URL
|
|
5240
|
+
*/
|
|
5241
|
+
function getServerHostname(endpoint) {
|
|
5242
|
+
const url = new URL(endpoint);
|
|
5243
|
+
return url.hostname;
|
|
5244
|
+
}
|
|
5245
|
+
/**
|
|
4803
5246
|
* Provision docker compose services in Dokploy
|
|
4804
5247
|
* @internal Exported for testing
|
|
4805
5248
|
*/
|
|
4806
|
-
async function provisionServices(api, projectId, environmentId,
|
|
5249
|
+
async function provisionServices(api, projectId, environmentId, projectName, services, existingServiceIds) {
|
|
4807
5250
|
logger$1.log(`\n🔍 provisionServices called: services=${JSON.stringify(services)}, envId=${environmentId}`);
|
|
4808
5251
|
if (!services || !environmentId) {
|
|
4809
5252
|
logger$1.log(" Skipping: no services or no environmentId");
|
|
@@ -4824,9 +5267,12 @@ async function provisionServices(api, projectId, environmentId, appName, service
|
|
|
4824
5267
|
else logger$1.log(` ⚠ Cached ID invalid, will create new`);
|
|
4825
5268
|
}
|
|
4826
5269
|
if (!postgres) {
|
|
4827
|
-
const
|
|
4828
|
-
const
|
|
4829
|
-
const result = await api.findOrCreatePostgres(postgresName, projectId, environmentId, {
|
|
5270
|
+
const databasePassword = (0, node_crypto.randomBytes)(16).toString("hex");
|
|
5271
|
+
const databaseName = projectName.replace(/-/g, "_");
|
|
5272
|
+
const result = await api.findOrCreatePostgres(postgresName, projectId, environmentId, {
|
|
5273
|
+
databaseName,
|
|
5274
|
+
databasePassword
|
|
5275
|
+
});
|
|
4830
5276
|
postgres = result.postgres;
|
|
4831
5277
|
created = result.created;
|
|
4832
5278
|
if (created) {
|
|
@@ -4861,8 +5307,8 @@ async function provisionServices(api, projectId, environmentId, appName, service
|
|
|
4861
5307
|
else logger$1.log(` ⚠ Cached ID invalid, will create new`);
|
|
4862
5308
|
}
|
|
4863
5309
|
if (!redis) {
|
|
4864
|
-
const { randomBytes: randomBytes$
|
|
4865
|
-
const databasePassword = randomBytes$
|
|
5310
|
+
const { randomBytes: randomBytes$3 } = await import("node:crypto");
|
|
5311
|
+
const databasePassword = randomBytes$3(16).toString("hex");
|
|
4866
5312
|
const result = await api.findOrCreateRedis(redisName, projectId, environmentId, { databasePassword });
|
|
4867
5313
|
redis = result.redis;
|
|
4868
5314
|
created = result.created;
|
|
@@ -4894,12 +5340,6 @@ async function provisionServices(api, projectId, environmentId, appName, service
|
|
|
4894
5340
|
*/
|
|
4895
5341
|
async function ensureDokploySetup(config, dockerConfig, stage, services) {
|
|
4896
5342
|
logger$1.log("\n🔧 Checking Dokploy setup...");
|
|
4897
|
-
const { readStageSecrets: readStageSecrets$1 } = await Promise.resolve().then(() => require("./storage-fOR8dMu5.cjs"));
|
|
4898
|
-
const existingSecrets = await readStageSecrets$1(stage);
|
|
4899
|
-
const existingUrls = {
|
|
4900
|
-
DATABASE_URL: existingSecrets?.urls?.DATABASE_URL,
|
|
4901
|
-
REDIS_URL: existingSecrets?.urls?.REDIS_URL
|
|
4902
|
-
};
|
|
4903
5343
|
let creds = await getDokployCredentials();
|
|
4904
5344
|
if (!creds) {
|
|
4905
5345
|
logger$1.log("\n📋 Dokploy credentials not found. Let's set them up.");
|
|
@@ -5225,6 +5665,8 @@ async function workspaceDeployCommand(workspace, options) {
|
|
|
5225
5665
|
postgres: services.db !== void 0 && services.db !== false,
|
|
5226
5666
|
redis: services.cache !== void 0 && services.cache !== false
|
|
5227
5667
|
};
|
|
5668
|
+
let provisionedPostgres = null;
|
|
5669
|
+
let provisionedRedis = null;
|
|
5228
5670
|
if (dockerServices.postgres || dockerServices.redis) {
|
|
5229
5671
|
logger$1.log("\n🔧 Provisioning infrastructure services...");
|
|
5230
5672
|
const existingServiceIds = {
|
|
@@ -5233,17 +5675,64 @@ async function workspaceDeployCommand(workspace, options) {
|
|
|
5233
5675
|
};
|
|
5234
5676
|
const provisionResult = await provisionServices(api, project.projectId, environmentId, workspace.name, dockerServices, existingServiceIds);
|
|
5235
5677
|
if (provisionResult?.serviceIds) {
|
|
5236
|
-
if (provisionResult.serviceIds.postgresId)
|
|
5237
|
-
|
|
5678
|
+
if (provisionResult.serviceIds.postgresId) {
|
|
5679
|
+
setPostgresId(state, provisionResult.serviceIds.postgresId);
|
|
5680
|
+
provisionedPostgres = await api.getPostgres(provisionResult.serviceIds.postgresId);
|
|
5681
|
+
}
|
|
5682
|
+
if (provisionResult.serviceIds.redisId) {
|
|
5683
|
+
setRedisId(state, provisionResult.serviceIds.redisId);
|
|
5684
|
+
provisionedRedis = await api.getRedis(provisionResult.serviceIds.redisId);
|
|
5685
|
+
}
|
|
5238
5686
|
}
|
|
5239
5687
|
}
|
|
5240
5688
|
const backendApps = appsToDeployNames.filter((name$1) => workspace.apps[name$1].type === "backend");
|
|
5241
5689
|
const frontendApps = appsToDeployNames.filter((name$1) => workspace.apps[name$1].type === "frontend");
|
|
5690
|
+
const perAppDbCredentials = /* @__PURE__ */ new Map();
|
|
5691
|
+
if (provisionedPostgres && backendApps.length > 0) {
|
|
5692
|
+
const appsNeedingDb = backendApps.filter((appName) => {
|
|
5693
|
+
const requirements = sniffedApps.get(appName);
|
|
5694
|
+
return requirements?.requiredEnvVars.includes("DATABASE_URL");
|
|
5695
|
+
});
|
|
5696
|
+
if (appsNeedingDb.length > 0) {
|
|
5697
|
+
logger$1.log(`\n🔐 Setting up per-app database credentials...`);
|
|
5698
|
+
logger$1.log(` Apps needing DATABASE_URL: ${appsNeedingDb.join(", ")}`);
|
|
5699
|
+
const existingCredentials = getAllAppCredentials(state);
|
|
5700
|
+
const usersToCreate = [];
|
|
5701
|
+
for (const appName of appsNeedingDb) {
|
|
5702
|
+
let credentials = existingCredentials[appName];
|
|
5703
|
+
if (credentials) logger$1.log(` ${appName}: Using existing credentials from state`);
|
|
5704
|
+
else {
|
|
5705
|
+
const password = (0, node_crypto.randomBytes)(16).toString("hex");
|
|
5706
|
+
credentials = {
|
|
5707
|
+
dbUser: appName,
|
|
5708
|
+
dbPassword: password
|
|
5709
|
+
};
|
|
5710
|
+
setAppCredentials(state, appName, credentials);
|
|
5711
|
+
logger$1.log(` ${appName}: Generated new credentials`);
|
|
5712
|
+
}
|
|
5713
|
+
perAppDbCredentials.set(appName, credentials);
|
|
5714
|
+
usersToCreate.push({
|
|
5715
|
+
name: appName,
|
|
5716
|
+
password: credentials.dbPassword,
|
|
5717
|
+
usePublicSchema: appName === "api"
|
|
5718
|
+
});
|
|
5719
|
+
}
|
|
5720
|
+
const serverHostname = getServerHostname(creds.endpoint);
|
|
5721
|
+
await initializePostgresUsers(api, provisionedPostgres, serverHostname, usersToCreate);
|
|
5722
|
+
}
|
|
5723
|
+
}
|
|
5242
5724
|
const publicUrls = {};
|
|
5243
5725
|
const results = [];
|
|
5244
5726
|
const dokployConfig = workspace.deploy.dokploy;
|
|
5245
5727
|
const appHostnames = /* @__PURE__ */ new Map();
|
|
5246
5728
|
const appDomainIds = /* @__PURE__ */ new Map();
|
|
5729
|
+
const frontendUrls = [];
|
|
5730
|
+
for (const appName of frontendApps) {
|
|
5731
|
+
const app = workspace.apps[appName];
|
|
5732
|
+
const isMainFrontend = isMainFrontendApp(appName, app, workspace.apps);
|
|
5733
|
+
const hostname = resolveHost(appName, app, stage, dokployConfig, isMainFrontend);
|
|
5734
|
+
frontendUrls.push(`https://${hostname}`);
|
|
5735
|
+
}
|
|
5247
5736
|
if (backendApps.length > 0) {
|
|
5248
5737
|
logger$1.log("\n📦 PHASE 1: Deploying backend applications...");
|
|
5249
5738
|
for (const appName of backendApps) {
|
|
@@ -5287,14 +5776,46 @@ async function workspaceDeployCommand(workspace, options) {
|
|
|
5287
5776
|
},
|
|
5288
5777
|
buildArgs
|
|
5289
5778
|
});
|
|
5290
|
-
const
|
|
5291
|
-
|
|
5779
|
+
const backendHost = resolveHost(appName, app, stage, dokployConfig, false);
|
|
5780
|
+
const envContext = {
|
|
5781
|
+
app,
|
|
5782
|
+
appName,
|
|
5783
|
+
stage,
|
|
5784
|
+
state,
|
|
5785
|
+
appCredentials: perAppDbCredentials.get(appName),
|
|
5786
|
+
postgres: provisionedPostgres ? {
|
|
5787
|
+
host: provisionedPostgres.appName,
|
|
5788
|
+
port: 5432,
|
|
5789
|
+
database: provisionedPostgres.databaseName
|
|
5790
|
+
} : void 0,
|
|
5791
|
+
redis: provisionedRedis ? {
|
|
5792
|
+
host: provisionedRedis.appName,
|
|
5793
|
+
port: 6379,
|
|
5794
|
+
password: provisionedRedis.databasePassword
|
|
5795
|
+
} : void 0,
|
|
5796
|
+
appHostname: backendHost,
|
|
5797
|
+
frontendUrls,
|
|
5798
|
+
userSecrets: stageSecrets ?? void 0,
|
|
5799
|
+
masterKey: appSecrets?.masterKey
|
|
5800
|
+
};
|
|
5801
|
+
const appRequirements = sniffedApps.get(appName);
|
|
5802
|
+
const requiredVars = appRequirements?.requiredEnvVars ?? [];
|
|
5803
|
+
const { valid, missing, resolved } = validateEnvVars(requiredVars, envContext);
|
|
5804
|
+
if (!valid) throw new Error(formatMissingVarsError(appName, missing, stage));
|
|
5805
|
+
const envVars = Object.entries(resolved).map(([key, value]) => `${key}=${value}`);
|
|
5806
|
+
if (Object.keys(resolved).length > 0) logger$1.log(` Resolved ${Object.keys(resolved).length} env vars: ${Object.keys(resolved).join(", ")}`);
|
|
5292
5807
|
await api.saveDockerProvider(application.applicationId, imageRef, { registryId });
|
|
5293
5808
|
await api.saveApplicationEnv(application.applicationId, envVars.join("\n"));
|
|
5294
5809
|
logger$1.log(` Deploying to Dokploy...`);
|
|
5295
5810
|
await api.deployApplication(application.applicationId);
|
|
5296
|
-
const
|
|
5297
|
-
|
|
5811
|
+
const existingDomains = await api.getDomainsByApplicationId(application.applicationId);
|
|
5812
|
+
const existingDomain = existingDomains.find((d) => d.host === backendHost);
|
|
5813
|
+
if (existingDomain) {
|
|
5814
|
+
appHostnames.set(appName, backendHost);
|
|
5815
|
+
appDomainIds.set(appName, existingDomain.domainId);
|
|
5816
|
+
publicUrls[appName] = `https://${backendHost}`;
|
|
5817
|
+
logger$1.log(` ✓ Domain: https://${backendHost} (existing)`);
|
|
5818
|
+
} else try {
|
|
5298
5819
|
const domain = await api.createDomain({
|
|
5299
5820
|
host: backendHost,
|
|
5300
5821
|
port: app.port,
|
|
@@ -5304,18 +5825,13 @@ async function workspaceDeployCommand(workspace, options) {
|
|
|
5304
5825
|
});
|
|
5305
5826
|
appHostnames.set(appName, backendHost);
|
|
5306
5827
|
appDomainIds.set(appName, domain.domainId);
|
|
5307
|
-
|
|
5308
|
-
|
|
5309
|
-
logger$1.log(` ✓ Domain: ${publicUrl}`);
|
|
5828
|
+
publicUrls[appName] = `https://${backendHost}`;
|
|
5829
|
+
logger$1.log(` ✓ Domain: https://${backendHost} (created)`);
|
|
5310
5830
|
} catch (domainError) {
|
|
5831
|
+
const message = domainError instanceof Error ? domainError.message : "Unknown error";
|
|
5832
|
+
logger$1.log(` ⚠ Domain creation failed: ${message}`);
|
|
5311
5833
|
appHostnames.set(appName, backendHost);
|
|
5312
|
-
try {
|
|
5313
|
-
const existingDomains = await api.getDomainsByApplicationId(application.applicationId);
|
|
5314
|
-
const matchingDomain = existingDomains.find((d) => d.host === backendHost);
|
|
5315
|
-
if (matchingDomain) appDomainIds.set(appName, matchingDomain.domainId);
|
|
5316
|
-
} catch {}
|
|
5317
5834
|
publicUrls[appName] = `https://${backendHost}`;
|
|
5318
|
-
logger$1.log(` ℹ Domain already configured: https://${backendHost}`);
|
|
5319
5835
|
}
|
|
5320
5836
|
results.push({
|
|
5321
5837
|
appName,
|
|
@@ -5384,7 +5900,14 @@ async function workspaceDeployCommand(workspace, options) {
|
|
|
5384
5900
|
await api.deployApplication(application.applicationId);
|
|
5385
5901
|
const isMainFrontend = isMainFrontendApp(appName, app, workspace.apps);
|
|
5386
5902
|
const frontendHost = resolveHost(appName, app, stage, dokployConfig, isMainFrontend);
|
|
5387
|
-
|
|
5903
|
+
const existingFrontendDomains = await api.getDomainsByApplicationId(application.applicationId);
|
|
5904
|
+
const existingFrontendDomain = existingFrontendDomains.find((d) => d.host === frontendHost);
|
|
5905
|
+
if (existingFrontendDomain) {
|
|
5906
|
+
appHostnames.set(appName, frontendHost);
|
|
5907
|
+
appDomainIds.set(appName, existingFrontendDomain.domainId);
|
|
5908
|
+
publicUrls[appName] = `https://${frontendHost}`;
|
|
5909
|
+
logger$1.log(` ✓ Domain: https://${frontendHost} (existing)`);
|
|
5910
|
+
} else try {
|
|
5388
5911
|
const domain = await api.createDomain({
|
|
5389
5912
|
host: frontendHost,
|
|
5390
5913
|
port: app.port,
|
|
@@ -5394,18 +5917,13 @@ async function workspaceDeployCommand(workspace, options) {
|
|
|
5394
5917
|
});
|
|
5395
5918
|
appHostnames.set(appName, frontendHost);
|
|
5396
5919
|
appDomainIds.set(appName, domain.domainId);
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
logger$1.log(` ✓ Domain: ${publicUrl}`);
|
|
5920
|
+
publicUrls[appName] = `https://${frontendHost}`;
|
|
5921
|
+
logger$1.log(` ✓ Domain: https://${frontendHost} (created)`);
|
|
5400
5922
|
} catch (domainError) {
|
|
5923
|
+
const message = domainError instanceof Error ? domainError.message : "Unknown error";
|
|
5924
|
+
logger$1.log(` ⚠ Domain creation failed: ${message}`);
|
|
5401
5925
|
appHostnames.set(appName, frontendHost);
|
|
5402
|
-
try {
|
|
5403
|
-
const existingDomains = await api.getDomainsByApplicationId(application.applicationId);
|
|
5404
|
-
const matchingDomain = existingDomains.find((d) => d.host === frontendHost);
|
|
5405
|
-
if (matchingDomain) appDomainIds.set(appName, matchingDomain.domainId);
|
|
5406
|
-
} catch {}
|
|
5407
5926
|
publicUrls[appName] = `https://${frontendHost}`;
|
|
5408
|
-
logger$1.log(` ℹ Domain already configured: https://${frontendHost}`);
|
|
5409
5927
|
}
|
|
5410
5928
|
results.push({
|
|
5411
5929
|
appName,
|
|
@@ -5433,6 +5951,10 @@ async function workspaceDeployCommand(workspace, options) {
|
|
|
5433
5951
|
const dnsConfig = workspace.deploy.dns;
|
|
5434
5952
|
if (dnsConfig && appHostnames.size > 0) {
|
|
5435
5953
|
const dnsResult = await orchestrateDns(appHostnames, dnsConfig, creds.endpoint);
|
|
5954
|
+
if (dnsResult?.serverIp && appHostnames.size > 0) {
|
|
5955
|
+
await verifyDnsRecords(appHostnames, dnsResult.serverIp, state);
|
|
5956
|
+
await writeStageState(workspace.root, stage, state);
|
|
5957
|
+
}
|
|
5436
5958
|
if (dnsResult?.success && appHostnames.size > 0) {
|
|
5437
5959
|
logger$1.log("\n🔒 Validating domains for SSL certificates...");
|
|
5438
5960
|
for (const [appName, hostname] of appHostnames) try {
|
|
@@ -5733,10 +6255,10 @@ const GEEKMIDAS_VERSIONS = {
|
|
|
5733
6255
|
"@geekmidas/cli": CLI_VERSION,
|
|
5734
6256
|
"@geekmidas/client": "~0.5.0",
|
|
5735
6257
|
"@geekmidas/cloud": "~0.2.0",
|
|
5736
|
-
"@geekmidas/constructs": "~0.
|
|
6258
|
+
"@geekmidas/constructs": "~0.8.0",
|
|
5737
6259
|
"@geekmidas/db": "~0.3.0",
|
|
5738
6260
|
"@geekmidas/emailkit": "~0.2.0",
|
|
5739
|
-
"@geekmidas/envkit": "~0.
|
|
6261
|
+
"@geekmidas/envkit": "~0.7.0",
|
|
5740
6262
|
"@geekmidas/errors": "~0.1.0",
|
|
5741
6263
|
"@geekmidas/events": "~0.2.0",
|
|
5742
6264
|
"@geekmidas/logger": "~0.4.0",
|
|
@@ -5745,7 +6267,7 @@ const GEEKMIDAS_VERSIONS = {
|
|
|
5745
6267
|
"@geekmidas/services": "~0.2.0",
|
|
5746
6268
|
"@geekmidas/storage": "~0.1.0",
|
|
5747
6269
|
"@geekmidas/studio": "~0.4.0",
|
|
5748
|
-
"@geekmidas/telescope": "~0.
|
|
6270
|
+
"@geekmidas/telescope": "~0.6.0",
|
|
5749
6271
|
"@geekmidas/testkit": "~0.6.0"
|
|
5750
6272
|
};
|
|
5751
6273
|
|