@h-ai/deploy 1.0.0-beta.2 → 1.0.0-beta.3

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/index.js CHANGED
@@ -1,18 +1,23 @@
1
- import { createHash, randomUUID, randomBytes } from 'crypto';
2
- import { createReadStream, readFileSync, existsSync, lstatSync, writeFileSync, readdirSync, realpathSync, chmodSync, mkdirSync, mkdtempSync, copyFileSync, renameSync, rmSync, statSync } from 'fs';
1
+ import { createHash, randomUUID, randomBytes, X509Certificate, createPublicKey, createPrivateKey } from 'crypto';
2
+ import { createReadStream, readFileSync, existsSync, lstatSync, writeFileSync, readdirSync, realpathSync, chmodSync, mkdirSync, statSync, mkdtempSync, copyFileSync, renameSync, rmSync, createWriteStream } from 'fs';
3
3
  import { resolve, extname, join, dirname, relative, basename, sep } from 'path';
4
4
  import { z } from 'zod';
5
- import { core, err, ok, RequestAccessConfigSchema, TrustedProxiesSchema } from '@h-ai/core';
5
+ import { core, err, ok, CoreConfigSchema, RequestAccessConfigSchema, TrustedProxiesSchema } from '@h-ai/core';
6
6
  import process6 from 'process';
7
7
  import { parseEnv } from 'util';
8
8
  import { LineCounter, parseDocument, parse, stringify, parseAllDocuments } from 'yaml';
9
9
  import { homedir, tmpdir } from 'os';
10
+ import { request } from 'https';
11
+ import { rootCertificates } from 'tls';
10
12
  import { resolveFeedbackBudget } from '@h-ai/api-contract/feedback';
11
13
  import { resolveTransportBodyLimits, createTransportBodyPolicy, parseTransportServerKeyRing } from '@h-ai/crypto';
14
+ import { Transform } from 'stream';
15
+ import { pipeline } from 'stream/promises';
12
16
  import { spawn } from 'child_process';
13
17
  import { readdir, readFile } from 'fs/promises';
14
18
  import { setTimeout as setTimeout$1 } from 'timers/promises';
15
19
  import { Buffer } from 'buffer';
20
+ import { isIP } from 'net';
16
21
 
17
22
  // src/deploy-artifact.ts
18
23
  var DeploymentArtifactSchema = z.object({
@@ -119,7 +124,7 @@ var en_US_default = {
119
124
  deploy_storageProxyInvalid: "Cannot generate the storage proxy. Use a valid DNS bucket name whose path does not overlap the website, API, or health endpoints.",
120
125
  deploy_storagePublicEndpointInvalid: "Invalid storage.publicEndpoint. Use a browser-accessible HTTP(S) address with an optional valid proxy path, without a bucket, credentials, query or fragment.",
121
126
  deploy_storageBackendRequired: "A static browser app cannot deploy server-side storage credentials. Configure Storage in Serv or SvelteKit and obtain presigned links through its API.",
122
- deploy_artifactInvalid: "Image artifacts are missing or invalid. Run pnpm build --target docker, select valid --artifact-dir, or use pnpm run deploy --build.",
127
+ deploy_artifactInvalid: "Image artifacts are missing or invalid. Run pnpm build --target image, select valid --artifact-dir, or use pnpm run deploy --build.",
123
128
  deploy_sshRequired: "Automatic deployment requires SSH settings. Use --bundle-only for manual transfer when the server is unreachable.",
124
129
  deploy_sshPasswordInvalid: "SSH passwords must be nonempty, contain no CR/LF/NUL, and be at most 1023 UTF-8 bytes.",
125
130
  deploy_sshAskpassFailed: "Could not prepare the managed SSH password helper. Check OpenSSH and Windows PowerShell 5.",
@@ -169,7 +174,9 @@ var en_US_default = {
169
174
  deploy_secretImportFailed: "Legacy credential import was rejected during {phase}; check private input permissions, project/volume identity, live credentials and the deployment lock. No services were updated; inspect the protected state file if commit is unconfirmed",
170
175
  deploy_projectNameRequired: "The root package.json.name is missing or cannot be normalized into an image and deployment resource identity.",
171
176
  deploy_projectNameOverrideRemoved: "Deployment identity comes from the root package.json.name; remove the obsolete projectName override.",
172
- deploy_requestAccessInvalid: "Invalid request access policy ({field}). Check fields, IP addresses, timezones and time windows. For a file reference, use readable YAML no larger than 1 MiB. Fix the configuration and create a new bundle."
177
+ deploy_requestAccessInvalid: "Invalid request access policy ({field}). Check fields, IP addresses, timezones and time windows. For a file reference, use readable YAML no larger than 1 MiB. Fix the configuration and create a new bundle.",
178
+ deploy_httpsPortsInvalid: "HTTP and HTTPS ports must differ; Let\u2019s Encrypt HTTP-01 requires host port 80.",
179
+ deploy_httpsCertificateInvalid: "Invalid HTTPS certificate. Check readable PEM files, matching unencrypted private key, validity dates and domain, then repackage."
173
180
  };
174
181
 
175
182
  // messages/ja-JP.json
@@ -227,7 +234,7 @@ var ja_JP_default = {
227
234
  deploy_storageProxyInvalid: "\u30B9\u30C8\u30EC\u30FC\u30B8\u30D7\u30ED\u30AD\u30B7\u3092\u751F\u6210\u3067\u304D\u307E\u305B\u3093\u3002\u6709\u52B9\u306A DNS \u30D0\u30B1\u30C3\u30C8\u540D\u3092\u6307\u5B9A\u3057\u3001\u30B5\u30A4\u30C8\u3001API\u3001\u30D8\u30EB\u30B9\u30C1\u30A7\u30C3\u30AF\u306E\u30D1\u30B9\u3068\u306E\u91CD\u8907\u3092\u907F\u3051\u3066\u304F\u3060\u3055\u3044\u3002",
228
235
  deploy_storagePublicEndpointInvalid: "storage.publicEndpoint \u304C\u7121\u52B9\u3067\u3059\u3002\u30D6\u30E9\u30A6\u30B6\u30FC\u304B\u3089\u5230\u9054\u53EF\u80FD\u306A HTTP(S) \u30A2\u30C9\u30EC\u30B9\u306B\u6709\u52B9\u306A\u30D7\u30ED\u30AD\u30B7\u30D1\u30B9\u3092\u6307\u5B9A\u3067\u304D\u307E\u3059\u3002\u30D0\u30B1\u30C3\u30C8\u3001\u8A8D\u8A3C\u60C5\u5831\u3001\u30AF\u30A8\u30EA\u3001\u30D5\u30E9\u30B0\u30E1\u30F3\u30C8\u306F\u542B\u3081\u306A\u3044\u3067\u304F\u3060\u3055\u3044\u3002",
229
236
  deploy_storageBackendRequired: "\u9759\u7684\u30D6\u30E9\u30A6\u30B6\u30A2\u30D7\u30EA\u306B\u306F\u30B5\u30FC\u30D0\u30FC\u7528\u30B9\u30C8\u30EC\u30FC\u30B8\u8A8D\u8A3C\u60C5\u5831\u3092\u914D\u7F6E\u3067\u304D\u307E\u305B\u3093\u3002Serv \u307E\u305F\u306F SvelteKit \u3067 Storage \u3092\u8A2D\u5B9A\u3057\u3001API \u7D4C\u7531\u3067\u7F72\u540D\u4ED8\u304D\u30EA\u30F3\u30AF\u3092\u53D6\u5F97\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
230
- deploy_artifactInvalid: "\u30A4\u30E1\u30FC\u30B8\u6210\u679C\u7269\u304C\u5B58\u5728\u3057\u306A\u3044\u304B\u3001\u7121\u52B9\u3067\u3059\u3002pnpm build --target docker \u3092\u5B9F\u884C\u3059\u308B\u304B\u3001--artifact-dir \u3067\u6709\u52B9\u306A\u6210\u679C\u7269\u3092\u9078\u629E\u3059\u308B\u304B\u3001pnpm run deploy --build \u3092\u4F7F\u7528\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
237
+ deploy_artifactInvalid: "\u30A4\u30E1\u30FC\u30B8\u6210\u679C\u7269\u304C\u5B58\u5728\u3057\u306A\u3044\u304B\u3001\u7121\u52B9\u3067\u3059\u3002pnpm build --target image \u3092\u5B9F\u884C\u3059\u308B\u304B\u3001--artifact-dir \u3067\u6709\u52B9\u306A\u6210\u679C\u7269\u3092\u9078\u629E\u3059\u308B\u304B\u3001pnpm run deploy --build \u3092\u4F7F\u7528\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
231
238
  deploy_sshRequired: "\u81EA\u52D5\u30C7\u30D7\u30ED\u30A4\u306B\u306FSSH\u8A2D\u5B9A\u304C\u5FC5\u8981\u3067\u3059\u3002\u30B5\u30FC\u30D0\u30FC\u306B\u63A5\u7D9A\u3067\u304D\u306A\u3044\u5834\u5408\u306F--bundle-only\u3067\u624B\u52D5\u8EE2\u9001\u7528\u306E\u30D0\u30F3\u30C9\u30EB\u3092\u751F\u6210\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
232
239
  deploy_sshPasswordInvalid: "SSH\u30D1\u30B9\u30EF\u30FC\u30C9\u306F\u7A7A\u306B\u3067\u304D\u305A\u3001CR/LF/NUL\u3092\u542B\u3081\u3089\u308C\u307E\u305B\u3093\u3002UTF-8\u30671023\u30D0\u30A4\u30C8\u4EE5\u4E0B\u306B\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
233
240
  deploy_sshAskpassFailed: "\u7BA1\u7406\u5BFE\u8C61\u306ESSH\u30D1\u30B9\u30EF\u30FC\u30C9helper\u3092\u6E96\u5099\u3067\u304D\u307E\u305B\u3093\u3002OpenSSH\u3068Windows PowerShell 5\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
@@ -277,7 +284,9 @@ var ja_JP_default = {
277
284
  deploy_secretImportFailed: "\u65E7\u8A8D\u8A3C\u60C5\u5831\u306E\u30A4\u30F3\u30DD\u30FC\u30C8\u306F {phase} \u6BB5\u968E\u3067\u62D2\u5426\u3055\u308C\u307E\u3057\u305F\u3002\u5165\u529B\u6A29\u9650\u3001\u30D7\u30ED\u30B8\u30A7\u30AF\u30C8\u3068\u30DC\u30EA\u30E5\u30FC\u30E0\u3001\u5B9F\u969B\u306E\u8A8D\u8A3C\u60C5\u5831\u3001\u30C7\u30D7\u30ED\u30A4\u30ED\u30C3\u30AF\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044\u3002\u30B5\u30FC\u30D3\u30B9\u306F\u66F4\u65B0\u3055\u308C\u3066\u3044\u307E\u305B\u3093\u3002\u78BA\u5B9A\u304C\u4E0D\u660E\u306A\u5834\u5408\u306F\u4FDD\u8B77\u3055\u308C\u305F\u72B6\u614B\u30D5\u30A1\u30A4\u30EB\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044",
278
285
  deploy_projectNameRequired: "\u5DE5\u7A0B\u30EB\u30FC\u30C8\u306E package.json.name \u304C\u306A\u3044\u304B\u6B63\u898F\u5316\u3067\u304D\u306A\u3044\u305F\u3081\u3001\u30A4\u30E1\u30FC\u30B8\u3068\u30C7\u30D7\u30ED\u30A4\u30EA\u30BD\u30FC\u30B9\u306E\u8B58\u5225\u540D\u3092\u751F\u6210\u3067\u304D\u307E\u305B\u3093\u3002",
279
286
  deploy_projectNameOverrideRemoved: "\u30C7\u30D7\u30ED\u30A4\u8B58\u5225\u540D\u306F\u5DE5\u7A0B\u30EB\u30FC\u30C8\u306E package.json.name \u304B\u3089\u53D6\u5F97\u3057\u307E\u3059\u3002\u65E7 projectName \u4E0A\u66F8\u304D\u5F15\u6570\u3092\u524A\u9664\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
280
- deploy_requestAccessInvalid: "\u30EA\u30AF\u30A8\u30B9\u30C8\u30A2\u30AF\u30BB\u30B9\u8A2D\u5B9A\uFF08{field}\uFF09\u304C\u7121\u52B9\u3067\u3059\u3002\u30D5\u30A3\u30FC\u30EB\u30C9\u3001IP\u3001\u30BF\u30A4\u30E0\u30BE\u30FC\u30F3\u3001\u6642\u9593\u5E2F\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044\u3002\u30D5\u30A1\u30A4\u30EB\u306F\u8AAD\u307F\u53D6\u308A\u53EF\u80FD\u306A 1 MiB \u4EE5\u4E0B\u306E YAML \u3092\u6307\u5B9A\u3057\u3001\u4FEE\u6B63\u5F8C\u306B\u518D\u30D1\u30C3\u30B1\u30FC\u30B8\u3057\u3066\u304F\u3060\u3055\u3044\u3002"
287
+ deploy_requestAccessInvalid: "\u30EA\u30AF\u30A8\u30B9\u30C8\u30A2\u30AF\u30BB\u30B9\u8A2D\u5B9A\uFF08{field}\uFF09\u304C\u7121\u52B9\u3067\u3059\u3002\u30D5\u30A3\u30FC\u30EB\u30C9\u3001IP\u3001\u30BF\u30A4\u30E0\u30BE\u30FC\u30F3\u3001\u6642\u9593\u5E2F\u3092\u78BA\u8A8D\u3057\u3066\u304F\u3060\u3055\u3044\u3002\u30D5\u30A1\u30A4\u30EB\u306F\u8AAD\u307F\u53D6\u308A\u53EF\u80FD\u306A 1 MiB \u4EE5\u4E0B\u306E YAML \u3092\u6307\u5B9A\u3057\u3001\u4FEE\u6B63\u5F8C\u306B\u518D\u30D1\u30C3\u30B1\u30FC\u30B8\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
288
+ deploy_httpsPortsInvalid: "HTTP \u3068 HTTPS \u306E\u30DD\u30FC\u30C8\u306F\u7570\u306A\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059\u3002Let\u2019s Encrypt HTTP-01 \u306B\u306F\u30DB\u30B9\u30C8\u306E\u30DD\u30FC\u30C8 80 \u304C\u5FC5\u8981\u3067\u3059\u3002",
289
+ deploy_httpsCertificateInvalid: "HTTPS \u8A3C\u660E\u66F8\u304C\u7121\u52B9\u3067\u3059\u3002PEM \u30D5\u30A1\u30A4\u30EB\u3001\u6697\u53F7\u5316\u3055\u308C\u3066\u3044\u306A\u3044\u79D8\u5BC6\u9375\u3068\u306E\u4E00\u81F4\u3001\u6709\u52B9\u671F\u9593\u3001\u30C9\u30E1\u30A4\u30F3\u3092\u78BA\u8A8D\u3057\u3001\u518D\u4F5C\u6210\u3057\u3066\u304F\u3060\u3055\u3044\u3002"
281
290
  };
282
291
 
283
292
  // messages/zh-CN.json
@@ -335,7 +344,7 @@ var zh_CN_default = {
335
344
  deploy_storageProxyInvalid: "\u65E0\u6CD5\u751F\u6210\u5BF9\u8C61\u5B58\u50A8\u4EE3\u7406\uFF1A\u8BF7\u4F7F\u7528\u6709\u6548\u7684 DNS \u6876\u540D\uFF0C\u5E76\u786E\u4FDD\u6876\u8DEF\u5F84\u4E0D\u4E0E\u7F51\u7AD9\u76EE\u5F55\u3001API \u6216\u5065\u5EB7\u68C0\u67E5\u5165\u53E3\u91CD\u53E0\u3002",
336
345
  deploy_storagePublicEndpointInvalid: "storage.publicEndpoint \u65E0\u6548\uFF1A\u8BF7\u586B\u5199\u6D4F\u89C8\u5668\u53EF\u8BBF\u95EE\u7684 HTTP(S) \u5730\u5740\uFF0C\u53EF\u5305\u542B\u89C4\u8303\u7684\u4EE3\u7406\u76EE\u5F55\uFF0C\u4E0D\u80FD\u5305\u542B\u6876\u540D\u3001\u51ED\u636E\u3001\u67E5\u8BE2\u6216\u7247\u6BB5\u3002",
337
346
  deploy_storageBackendRequired: "\u9759\u6001\u6D4F\u89C8\u5668\u5E94\u7528\u4E0D\u80FD\u90E8\u7F72\u5B58\u50A8\u670D\u52A1\u7AEF\u914D\u7F6E\u3002\u8BF7\u5728 Serv \u6216 SvelteKit \u670D\u52A1\u4E2D\u914D\u7F6E Storage\uFF0C\u518D\u901A\u8FC7 API \u83B7\u53D6\u9884\u7B7E\u540D\u94FE\u63A5\u3002",
338
- deploy_artifactInvalid: "\u955C\u50CF\u4EA7\u7269\u7F3A\u5931\u6216\u6821\u9A8C\u5931\u8D25\u3002\u8BF7\u5148\u6267\u884C pnpm build --target docker\uFF0C\u6216\u7528 --artifact-dir \u6307\u5B9A\u6709\u6548\u4EA7\u7269\uFF1B\u4E5F\u53EF\u7528 pnpm run deploy --build \u4E00\u6B21\u5B8C\u6210\u3002",
347
+ deploy_artifactInvalid: "\u955C\u50CF\u4EA7\u7269\u7F3A\u5931\u6216\u6821\u9A8C\u5931\u8D25\u3002\u8BF7\u5148\u6267\u884C pnpm build --target image\uFF0C\u6216\u7528 --artifact-dir \u6307\u5B9A\u6709\u6548\u4EA7\u7269\uFF1B\u4E5F\u53EF\u7528 pnpm run deploy --build \u4E00\u6B21\u5B8C\u6210\u3002",
339
348
  deploy_sshRequired: "\u81EA\u52A8\u90E8\u7F72\u9700\u8981 SSH \u914D\u7F6E\uFF1B\u65E0\u7F51\u7EDC\u8FDE\u63A5\u65F6\u4F7F\u7528 --bundle-only \u751F\u6210\u90E8\u7F72\u5305\u5E76\u624B\u5DE5\u642C\u8FD0\u3002",
340
349
  deploy_sshPasswordInvalid: "SSH \u5BC6\u7801\u4E0D\u80FD\u4E3A\u7A7A\u6216\u5305\u542B CR/LF/NUL\uFF0CUTF-8 \u957F\u5EA6\u4E0D\u80FD\u8D85\u8FC7 1023 \u5B57\u8282\u3002",
341
350
  deploy_sshAskpassFailed: "\u65E0\u6CD5\u51C6\u5907\u53D7\u63A7 SSH \u5BC6\u7801 helper\uFF1B\u8BF7\u68C0\u67E5\u7CFB\u7EDF OpenSSH \u548C Windows PowerShell 5\u3002",
@@ -385,7 +394,9 @@ var zh_CN_default = {
385
394
  deploy_secretImportFailed: "\u65E7\u51ED\u636E\u5BFC\u5165\u5728 {phase} \u9636\u6BB5\u88AB\u62D2\u7EDD\uFF1B\u8BF7\u6838\u5BF9\u79C1\u6709\u8F93\u5165\u6743\u9650\u3001\u9879\u76EE/\u5377\u8EAB\u4EFD\u3001\u5B9E\u9645\u670D\u52A1\u51ED\u636E\u53CA\u90E8\u7F72\u9501\uFF0C\u672A\u6267\u884C\u670D\u52A1\u66F4\u65B0\uFF1B\u63D0\u4EA4\u7ED3\u679C\u4E0D\u660E\u65F6\u8BF7\u6838\u5BF9\u53D7\u4FDD\u62A4\u7684\u72B6\u6001\u6587\u4EF6",
386
395
  deploy_projectNameRequired: "\u5DE5\u7A0B\u6839 package.json.name \u7F3A\u5931\u6216\u65E0\u6CD5\u5F52\u4E00\u5316\uFF0C\u4E0D\u80FD\u751F\u6210\u955C\u50CF\u4E0E\u90E8\u7F72\u8D44\u6E90\u8EAB\u4EFD\u3002",
387
396
  deploy_projectNameOverrideRemoved: "\u90E8\u7F72\u9879\u76EE\u540D\u7EDF\u4E00\u6765\u81EA\u5DE5\u7A0B\u6839 package.json.name\uFF1B\u8BF7\u79FB\u9664\u65E7 projectName \u8986\u76D6\u53C2\u6570\u3002",
388
- deploy_requestAccessInvalid: "\u8BF7\u6C42\u8BBF\u95EE\u7B56\u7565\u914D\u7F6E\u65E0\u6548\uFF08{field}\uFF09\u3002\u8BF7\u68C0\u67E5\u5B57\u6BB5\u3001IP\u3001\u65F6\u533A\u53CA\u65F6\u95F4\u7A97\u53E3\uFF1B\u5982\u5F15\u7528\u6587\u4EF6\uFF0C\u8BF7\u786E\u8BA4\u6587\u4EF6\u53EF\u8BFB\u3001\u683C\u5F0F\u4E3A YAML \u4E14\u4E0D\u8D85\u8FC7 1 MiB\u3002\u4FEE\u6B63\u540E\u91CD\u65B0\u6253\u5305\u3002"
397
+ deploy_requestAccessInvalid: "\u8BF7\u6C42\u8BBF\u95EE\u7B56\u7565\u914D\u7F6E\u65E0\u6548\uFF08{field}\uFF09\u3002\u8BF7\u68C0\u67E5\u5B57\u6BB5\u3001IP\u3001\u65F6\u533A\u53CA\u65F6\u95F4\u7A97\u53E3\uFF1B\u5982\u5F15\u7528\u6587\u4EF6\uFF0C\u8BF7\u786E\u8BA4\u6587\u4EF6\u53EF\u8BFB\u3001\u683C\u5F0F\u4E3A YAML \u4E14\u4E0D\u8D85\u8FC7 1 MiB\u3002\u4FEE\u6B63\u540E\u91CD\u65B0\u6253\u5305\u3002",
398
+ deploy_httpsPortsInvalid: "HTTP \u4E0E HTTPS \u7AEF\u53E3\u5FC5\u987B\u4E0D\u540C\uFF1BLet\u2019s Encrypt \u7684 HTTP-01 \u5165\u53E3\u5FC5\u987B\u4F7F\u7528\u5BBF\u4E3B\u673A 80 \u7AEF\u53E3\u3002",
399
+ deploy_httpsCertificateInvalid: "HTTPS \u8BC1\u4E66\u65E0\u6548\uFF1A\u8BF7\u6838\u5BF9 PEM \u6587\u4EF6\u53EF\u8BFB\u3001\u8BC1\u4E66\u4E0E\u672A\u52A0\u5BC6\u79C1\u94A5\u5339\u914D\u3001\u8BC1\u4E66\u6709\u6548\u671F\u53CA\u57DF\u540D\uFF0C\u7136\u540E\u91CD\u65B0\u6253\u5305\u3002"
389
400
  };
390
401
 
391
402
  // src/deploy-i18n.ts
@@ -425,12 +436,43 @@ var SshConfigSchema = z.object({
425
436
  if (!ssh.password || /[\r\n\0]/.test(ssh.password) || new TextEncoder().encode(ssh.password).length > 1023)
426
437
  context.addIssue({ code: "custom", path: ["password"], message: deployM("deploy_sshPasswordInvalid") });
427
438
  });
439
+ var HttpsDomainSchema = z.string().max(253).regex(/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i).refine((value) => !value.includes(".."));
440
+ var httpsOptions = {
441
+ /** 公开访问域名;同时用于证书身份校验和 Nginx 入口。 */
442
+ domain: HttpsDomainSchema.describe("HTTPS \u516C\u5F00\u57DF\u540D\uFF1B\u8BC1\u4E66\u5FC5\u987B\u5339\u914D\u8BE5\u57DF\u540D"),
443
+ /** HTTPS 宿主端口,与 HTTP 端口不同。 */
444
+ port: z.number().int().min(1).max(65535).default(443).describe("HTTPS \u5BBF\u4E3B\u7AEF\u53E3\uFF0C\u9ED8\u8BA4 443\uFF0C\u987B\u4E0E HTTP \u7AEF\u53E3\u4E0D\u540C"),
445
+ /** 是否将普通 HTTP 请求跳转到 HTTPS。 */
446
+ redirectHttp: z.boolean().default(true).describe("\u666E\u901A HTTP \u8BF7\u6C42\u662F\u5426\u4EE5 308 \u8DF3\u8F6C\u81F3 HTTPS\uFF0C\u5065\u5EB7\u68C0\u67E5\u548C ACME \u6311\u6218\u9664\u5916")
447
+ };
448
+ var HttpsConfigSchema = z.discriminatedUnion("type", [
449
+ z.object({
450
+ type: z.literal("files"),
451
+ ...httpsOptions,
452
+ /** 相对于工程根或绝对路径的 PEM 完整证书链及未加密私钥。 */
453
+ certificateFile: z.string().min(1).describe("PEM \u5B8C\u6574\u8BC1\u4E66\u94FE\u8DEF\u5F84\uFF0C\u76F8\u5BF9\u5DE5\u7A0B\u6839\u6216\u7EDD\u5BF9\u8DEF\u5F84\uFF1B\u6253\u5305\u65F6\u590D\u5236\u5230\u79C1\u6709\u53D1\u5E03\u76EE\u5F55"),
454
+ privateKeyFile: z.string().min(1).describe("\u672A\u52A0\u5BC6 PEM \u79C1\u94A5\u8DEF\u5F84\uFF0C\u76F8\u5BF9\u5DE5\u7A0B\u6839\u6216\u7EDD\u5BF9\u8DEF\u5F84\uFF1B\u5FC5\u987B\u4E0E\u8BC1\u4E66\u5339\u914D")
455
+ }),
456
+ z.object({
457
+ type: z.literal("letsencrypt"),
458
+ ...httpsOptions,
459
+ domain: HttpsDomainSchema.refine((value) => value.includes(".") && !/^\d+(?:\.\d+){3}$/.test(value)).describe("Let\u2019s Encrypt \u5355\u4E2A\u516C\u7F51\u57DF\u540D\uFF0C\u4F7F\u7528 HTTP-01\uFF0C\u4E0D\u652F\u6301 IP \u6216\u901A\u914D\u7B26"),
460
+ email: z.email().describe("Let\u2019s Encrypt \u8D26\u53F7\u8054\u7CFB\u90AE\u7BB1"),
461
+ /** 使用者须明确同意 Let's Encrypt 的服务条款。 */
462
+ acceptTerms: z.literal(true).describe("\u660E\u786E\u540C\u610F Let\u2019s Encrypt \u670D\u52A1\u6761\u6B3E\u540E\u624D\u53EF\u8BBE\u7F6E\u4E3A true")
463
+ })
464
+ ]);
428
465
  var PortExposeSchema = z.object({
429
466
  type: z.literal("port").default("port"),
430
467
  /** 容器内监听端口(adapter-node 默认 3000) */
431
468
  containerPort: z.number().int().min(1).max(65535).default(3e3),
432
469
  /** 宿主机映射端口 */
433
- hostPort: z.number().int().min(1).max(65535)
470
+ hostPort: z.number().int().min(1).max(65535),
471
+ /** 可选 HTTPS:已有 PEM 文件或官方 Certbot 自动申请续期;省略保留 HTTP。 */
472
+ https: HttpsConfigSchema.optional().describe("\u53EF\u9009 HTTPS \u5165\u53E3\uFF1Bfiles \u4F7F\u7528\u5DF2\u6709\u8BC1\u4E66\uFF0Cletsencrypt \u4F7F\u7528\u5B98\u65B9 Certbot \u7533\u8BF7\u7EED\u671F\uFF0C\u7701\u7565\u4FDD\u6301 HTTP")
473
+ }).superRefine((expose, context) => {
474
+ if (expose.https && (expose.https.port === expose.hostPort || expose.https.type === "letsencrypt" && expose.hostPort !== 80))
475
+ context.addIssue({ code: "custom", path: ["https"], message: deployM("deploy_httpsPortsInvalid") });
434
476
  });
435
477
  var ExposeSchema = PortExposeSchema;
436
478
  var DockerSshProviderConfigSchema = z.object({
@@ -929,6 +971,32 @@ function saveCredentials(entries) {
929
971
  );
930
972
  }
931
973
  }
974
+
975
+ // src/deploy-middleware-config.ts
976
+ var connectionFields = {
977
+ db: ["url", "host", "port", "database", "user", "password", "ssl"],
978
+ cache: ["url", "host", "port", "password", "cluster", "sentinel", "tls"],
979
+ storage: ["endpoint", "bucket", "region", "accessKeyId", "secretAccessKey", "forcePathStyle", "createBucket"],
980
+ vecdb: ["url", "host", "port", "database", "user", "password", "apiKey", "path", "serverCommand", "startupTimeout"],
981
+ ldap: ["url", "bindDn", "bindPassword", "searchBase", "useTls"],
982
+ email: ["host", "port", "secure", "user", "pass"]
983
+ };
984
+ function mergeManagedMiddlewareConfig(requirement, supplied) {
985
+ if (!requirement || requirement.service !== "ldap" && requirement.backend !== supplied.type)
986
+ return supplied;
987
+ const configured = { ...requirement.config };
988
+ for (const field2 of connectionFields[requirement.service] ?? [])
989
+ delete configured[field2];
990
+ const merged = { ...configured, ...supplied };
991
+ if (requirement.service === "cache" && typeof configured.db === "number" && typeof supplied.url === "string") {
992
+ const url = new URL(supplied.url);
993
+ if (configured.db !== 0 || url.pathname !== "" && url.pathname !== "/") {
994
+ url.pathname = String(configured.db);
995
+ merged.url = url.toString();
996
+ }
997
+ }
998
+ return merged;
999
+ }
932
1000
  var RecoverySchema = z.object({
933
1001
  projectName: z.string(),
934
1002
  stage: z.string(),
@@ -1796,7 +1864,7 @@ async function groupExited(pid) {
1796
1864
  async function helper(command, args, timeoutMs) {
1797
1865
  if (timeoutMs <= 0)
1798
1866
  return false;
1799
- return new Promise((resolve8) => {
1867
+ return new Promise((resolve9) => {
1800
1868
  const child = spawn(command, args, { windowsHide: true, shell: false, stdio: "ignore" });
1801
1869
  let settled = false;
1802
1870
  let timer;
@@ -1806,7 +1874,7 @@ async function helper(command, args, timeoutMs) {
1806
1874
  settled = true;
1807
1875
  if (timer)
1808
1876
  clearTimeout(timer);
1809
- resolve8(success);
1877
+ resolve9(success);
1810
1878
  };
1811
1879
  timer = globalThis.setTimeout(() => {
1812
1880
  child.kill("SIGKILL");
@@ -1949,7 +2017,7 @@ ${deployM(state.localTermination === "confirmed" ? "deploy_localStopped" : state
1949
2017
  ext: { commandExecution: state }
1950
2018
  });
1951
2019
  try {
1952
- return await new Promise((resolve8) => {
2020
+ return await new Promise((resolve9) => {
1953
2021
  if (typeof command !== "string" || !command || command.includes("\0") || args.some((argument) => typeof argument !== "string" || argument.includes("\0")))
1954
2022
  throw new TypeError("Invalid command arguments");
1955
2023
  if (options.input !== void 0 && typeof options.input !== "string")
@@ -1984,7 +2052,7 @@ ${deployM(state.localTermination === "confirmed" ? "deploy_localStopped" : state
1984
2052
  settled = true;
1985
2053
  if (timer)
1986
2054
  clearTimeout(timer);
1987
- resolve8(result);
2055
+ resolve9(result);
1988
2056
  };
1989
2057
  const interrupt = (reason, message) => {
1990
2058
  interrupted = true;
@@ -2433,7 +2501,7 @@ start_services() {
2433
2501
  application_services=''
2434
2502
  application_orphan_option=$orphan_option
2435
2503
  for service in "$@"; do
2436
- case "$service" in serv|web|gateway) ;; *) middleware_services="$middleware_services $service";; esac
2504
+ case "$service" in serv|web|gateway|certbot) ;; *) middleware_services="$middleware_services $service";; esac
2437
2505
  done
2438
2506
  if [ -n "$middleware_services" ]; then
2439
2507
  compose up -d --no-deps --no-build --pull never "$recreate_option" $orphan_option $middleware_services || return 1
@@ -2449,6 +2517,9 @@ start_services() {
2449
2517
  fi
2450
2518
  case " $* " in *' gateway '*) compose up -d --no-deps --no-build --pull never "$recreate_option" gateway || return 1;; esac
2451
2519
  }
2520
+ start_renewal() {
2521
+ case " $* " in *' certbot '*) compose up -d --no-deps --no-build --pull never --no-recreate certbot || return 1;; esac
2522
+ }
2452
2523
  read_proxy_service() {
2453
2524
  proxy_directory=$1
2454
2525
  if [ -e "$proxy_directory/proxy-service" ] || [ -L "$proxy_directory/proxy-service" ]; then
@@ -2472,13 +2543,22 @@ reload_retained_proxy() {
2472
2543
  esac
2473
2544
  }
2474
2545
  wait_http() {
2475
- http_port=$1
2476
- shift
2546
+ http_release=$1
2547
+ http_port=$2
2548
+ shift 2
2549
+ https_domain=''
2550
+ if [ -f "$http_release/https" ]; then
2551
+ read -r https_domain https_port https_type < "$http_release/https"
2552
+ https_certificates="$http_release/tls"
2553
+ if [ "$https_type" = letsencrypt ]; then https_certificates="$root/https/certificates/$https_domain"; fi
2554
+ fi
2477
2555
  for http_path in "$@"; do
2478
2556
  http_ready=false
2479
2557
  http_attempt=0
2480
2558
  while [ "$http_attempt" -lt 40 ]; do
2481
- if curl --noproxy '*' -fsS --max-time 5 "http://127.0.0.1:$http_port$http_path" >/dev/null 2>&1; then http_ready=true; break; fi
2559
+ if [ -n "$https_domain" ]; then
2560
+ if curl --noproxy '*' -fsS --max-time 5 --cacert "$https_certificates/fullchain.pem" --resolve "$https_domain:$https_port:127.0.0.1" "https://$https_domain:$https_port$http_path" >/dev/null 2>&1; then http_ready=true; break; fi
2561
+ elif curl --noproxy '*' -fsS --max-time 5 "http://127.0.0.1:$http_port$http_path" >/dev/null 2>&1; then http_ready=true; break; fi
2482
2562
  http_attempt=$((http_attempt + 1))
2483
2563
  sleep 3
2484
2564
  done
@@ -2491,7 +2571,6 @@ verify_recovery() {
2491
2571
  recovery_port=$(awk -F: 'NR == 1 { print $1 }' "$recovery_release/layout") || return 1
2492
2572
  recovery_paths=''
2493
2573
  if [ -f "$recovery_release/health-paths" ]; then
2494
- recovery_port=$(cat "$recovery_release/health-port") || return 1
2495
2574
  recovery_paths=$(cat "$recovery_release/health-paths") || return 1
2496
2575
  else
2497
2576
  # 旧格式发布没有独立健康文件;按已存拓扑恢复最小 HTTP 检查。
@@ -2501,10 +2580,10 @@ verify_recovery() {
2501
2580
  elif [ "$recovery_primary" = web ]; then recovery_paths='/'; else recovery_paths='/health'; fi
2502
2581
  fi
2503
2582
  case "$recovery_port" in ''|*[!0-9]*) return 1;; esac
2504
- test -n "$recovery_paths" || return 1
2505
2583
  wait_services $recover_selected || return 1
2584
+ start_renewal $recover_selected || return 1
2506
2585
  reload_retained_proxy "$recovery_release" || return 1
2507
- wait_http "$recovery_port" $recovery_paths
2586
+ wait_http "$recovery_release" "$recovery_port" $recovery_paths
2508
2587
  }
2509
2588
  finish() {
2510
2589
  code=$?
@@ -2632,7 +2711,7 @@ if [ "$partial" = true ]; then
2632
2711
  test "$current_storage_routing" = "$previous_storage_routing" || { printf 'Managed storage configuration changed. Create a full deployment to switch its public endpoint, bucket, or access policy consistently.\n' >&2; exit 1; }
2633
2712
  if ! cmp -s layout "$root/releases/$previous/layout"; then
2634
2713
  # 容量调整允许复用已部署 Web 镜像;端口、路由及其余代理策略仍要求全量部署。
2635
- test "$(awk -F: '{print $1 ":" $2 ":" $3 ":" $4}' layout)" = "$(awk -F: '{print $1 ":" $2 ":" $3 ":" $4}' "$root/releases/$previous/layout")" || { printf 'Ports or application layout changed. Create a full deployment bundle.\n' >&2; exit 1; }
2714
+ test "$(awk -F: '{print $1 ":" $2 ":" $3 ":" $4 ":" $6}' layout)" = "$(awk -F: '{print $1 ":" $2 ":" $3 ":" $4 ":" $6}' "$root/releases/$previous/layout")" || { printf 'Ports, HTTPS or application layout changed. Create a full deployment bundle.\n' >&2; exit 1; }
2636
2715
  test -f web.conf && test -f proxy-entry.json || { printf 'Proxy configuration missing. Create a full deployment bundle.\n' >&2; exit 1; }
2637
2716
  previous_proxy=
2638
2717
  while IFS= read -r compose_file; do
@@ -2650,9 +2729,17 @@ if [ "$partial" = true ]; then
2650
2729
  fi
2651
2730
  fi
2652
2731
  mkdir -p "$release"
2653
- cp -R "$source_dir/." "$release/"
2732
+ # 镜像直接从本次解包目录导入;发布记录只保存运行配置与恢复证据。
2733
+ for entry in "$source_dir"/* "$source_dir"/.[!.]*; do
2734
+ test -e "$entry" || continue
2735
+ test "$entry" != "$source_dir/images" || continue
2736
+ cp -R "$entry" "$release/"
2737
+ done
2654
2738
  # Nginx 以非 root 用户运行;只开放不含凭据的代理配置读取权限。
2655
2739
  if [ -f "$release/web.conf" ]; then chmod 644 "$release/web.conf"; fi
2740
+ for https_script in https-start.sh renew-https.sh; do
2741
+ if [ -f "$release/$https_script" ]; then chmod 444 "$release/$https_script"; fi
2742
+ done
2656
2743
  # 策略不是凭据,发布目录保持700;只读挂载允许镜像中的非root应用读取。
2657
2744
  if [ -f "$release/request-access.yml" ]; then chmod 444 "$release/request-access.yml"; fi
2658
2745
  : > "$release/compose-files"
@@ -2666,6 +2753,12 @@ if [ "$partial" = true ]; then
2666
2753
  fi
2667
2754
  step credentials 'Resolving server-managed middleware credentials'
2668
2755
  sh "$release/resolve-managed-secrets.sh" "$root" "$release" "$runtime"
2756
+ if [ -f "$release/runtime-env.mjs" ]; then
2757
+ # 上级目录保护宿主秘密;只读文件挂载允许非 root 应用读取,宿主所有者仍可修改配置。
2758
+ chmod 700 "$root" "$release"
2759
+ chmod 644 "$release/.env.runtime" "$release/middleware.env"
2760
+ chmod 444 "$release/runtime-env.mjs"
2761
+ fi
2669
2762
  # 在任何可能启动服务(包括增量恢复旧拓扑)之前定稿,失败回滚不删除卷的引擎身份。
2670
2763
  sh "$release/resolve-backend-state.sh" "$root" "$release" "$runtime" "$previous" commit
2671
2764
  printf '%s\n' "$release/compose-entry.json" >> "$release/compose-files"
@@ -2703,12 +2796,15 @@ step images 'Loading packaged images (no registry access)'
2703
2796
  while IFS=' ' read -r archive reference; do
2704
2797
  test -n "$archive" || continue
2705
2798
  images_changed=true
2706
- "$runtime" load -i "$release/images/$archive"
2799
+ "$runtime" load -i "$source_dir/images/$archive"
2707
2800
  "$runtime" image inspect "$reference" >/dev/null
2708
2801
  architecture=$("$runtime" image inspect --format '{{.Architecture}}' "$reference")
2709
2802
  host_arch=$(uname -m)
2710
2803
  case "$host_arch:$architecture" in x86_64:amd64|aarch64:arm64) ;; *) printf 'Image architecture %s does not match server %s\n' "$architecture" "$host_arch" >&2; exit 1;; esac
2711
2804
  done < "$release/image-list"
2805
+ if [ -f "$release/prepare-https.sh" ]; then
2806
+ sh "$release/prepare-https.sh" "$root" "$release" "$runtime" prepare "$partial"
2807
+ fi
2712
2808
  step dependencies 'Checking dependencies of selected services'
2713
2809
  if [ "$partial" = true ]; then cat "$release/services" >> "$release/dependencies"; fi
2714
2810
  while IFS= read -r dependency; do
@@ -2720,9 +2816,18 @@ step start 'Updating selected services'
2720
2816
  changed=true
2721
2817
  remove_selected $selected
2722
2818
  start_services $selected
2819
+ if [ -f "$release/prepare-https.sh" ] && [ "$partial" = false ]; then
2820
+ step https 'Issuing or installing HTTPS certificate'
2821
+ tls_proxy=$(read_proxy_service "$release")
2822
+ wait_services "$tls_proxy"
2823
+ sh "$release/prepare-https.sh" "$root" "$release" "$runtime" issue "$partial"
2824
+ compose exec -T "$tls_proxy" nginx -t
2825
+ compose exec -T "$tls_proxy" nginx -s reload
2826
+ fi
2827
+ start_renewal $selected
2723
2828
  reload_retained_proxy "$release"
2724
2829
  step health 'Checking HTTP access'
2725
- wait_http @@PORT@@ @@HEALTH@@
2830
+ wait_http "$release" @@PORT@@ @@HEALTH@@
2726
2831
  step complete 'Deployment passed all checks'
2727
2832
  printf '%s\n' "$id" > "$root/current.next"
2728
2833
  mv "$root/current.next" "$root/current"
@@ -2776,7 +2881,7 @@ case "$timeout" in ''|*[!0-9]*|???????????*) invalid_target;; esac
2776
2881
  test "$timeout" -ge 1 && test "$timeout" -le 2147483647 || invalid_target
2777
2882
  case "$remote" in /*) ;; *) invalid_target;; esac
2778
2883
  case "$remote" in *[!a-zA-Z0-9/_.\ -]*|*/../*|*/..|/..) invalid_target;; esac
2779
- case "$bundle" in *.tar) ;; *) invalid_target;; esac
2884
+ case "$bundle" in *.tar|*.tar.gz) ;; *) invalid_target;; esac
2780
2885
  case "$bundle" in ''|*[!a-zA-Z0-9_.+-]*) invalid_target;; esac
2781
2886
  case "$strict" in true) strict=accept-new;; false) strict=no;; *) invalid_target;; esac
2782
2887
  test -f "./$bundle" || { printf 'Bundle missing: %s\n' "$bundle" >&2; exit 1; }
@@ -2900,7 +3005,7 @@ monitor=''
2900
3005
  if [ "$upload_code" -ne 0 ]; then printf '[FAIL] Upload failed (exit %s). Check SSH output above.\n' "$upload_code" >&2; exit "$upload_code"; fi
2901
3006
  printf '[UPLOAD] 100%% (%s/%s bytes)\n' "$total" "$total"
2902
3007
  printf '[3/3] Applying package and checking HTTP access\n'
2903
- if run_native ssh "$@" -p "$port" "$user@$host" "cd '$remote/incoming' && mkdir -p '$bundle.contents' && tar -xf '$bundle' -C '$bundle.contents' && sh '$bundle.contents/apply.sh' '$remote'"; then
3008
+ if run_native ssh "$@" -p "$port" "$user@$host" "cd '$remote/incoming' && mkdir -p '$bundle.contents' && tar -xf '$bundle' -C '$bundle.contents' && sh '$bundle.contents/apply.sh' '$remote' && { rm -rf -- '$bundle.contents' && rm -f -- '$bundle' || printf '[WARN] Deployment passed; incoming archive cleanup failed\\n' >&2; }"; then
2904
3009
  :
2905
3010
  else
2906
3011
  code=$?
@@ -2935,7 +3040,7 @@ if ($windowsRuntime) {
2935
3040
  Set-Location -LiteralPath $PSScriptRoot
2936
3041
  $target = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'deploy-target.json') -Raw -Encoding UTF8 | ConvertFrom-Json
2937
3042
  if ($target.host -eq 'SERVER_HOST') { throw 'Set the server host in deploy-target.json.' }
2938
- if ($target.host -isnot [string] -or $target.host -notmatch '^[a-zA-Z0-9][a-zA-Z0-9.:-]*$' -or $target.username -isnot [string] -or $target.username -notmatch '^[a-zA-Z_][a-zA-Z0-9_-]*$' -or $target.directory -isnot [string] -or $target.directory -notmatch '^/[a-zA-Z0-9/_. -]*$' -or $target.directory -match '(^|/)\.\.(/|$)' -or $target.bundle -isnot [string] -or $target.bundle -notmatch '^[a-zA-Z0-9_.+-]+\.tar$') { throw 'Invalid deployment target.' }
3043
+ if ($target.host -isnot [string] -or $target.host -notmatch '^[a-zA-Z0-9][a-zA-Z0-9.:-]*$' -or $target.username -isnot [string] -or $target.username -notmatch '^[a-zA-Z_][a-zA-Z0-9_-]*$' -or $target.directory -isnot [string] -or $target.directory -notmatch '^/[a-zA-Z0-9/_. -]*$' -or $target.directory -match '(^|/)\.\.(/|$)' -or $target.bundle -isnot [string] -or $target.bundle -notmatch '^[a-zA-Z0-9_.+-]+\.tar(\.gz)?$') { throw 'Invalid deployment target.' }
2939
3044
  $port = if ($null -eq $target.port) { 22 } else { $target.port }
2940
3045
  $timeout = if ($null -eq $target.connectTimeoutSeconds) { 10 } else { $target.connectTimeoutSeconds }
2941
3046
  if ($port -is [string] -or [string]$port -notmatch '^[0-9]+$' -or $port -lt 1 -or $port -gt 65535 -or $timeout -is [string] -or [string]$timeout -notmatch '^[0-9]+$' -or $timeout -lt 1 -or $timeout -gt 2147483647) { throw 'Invalid deployment port or timeout.' }
@@ -3094,7 +3199,7 @@ try {
3094
3199
  Invoke-Step '2/3 Upload' 'scp' ($options + @('-P', [string]$port, "./$bundle", "$($scpDestination):$remote/incoming/$bundle"))
3095
3200
  Write-Host "[UPLOAD] 100% ($uploadBytes/$uploadBytes bytes)"
3096
3201
  $applying = $true
3097
- Invoke-Step '3/3 Apply and verify HTTP' 'ssh' ($options + @('-p', [string]$port, $destination, "cd '$remote/incoming' && mkdir -p '$bundle.contents' && tar -xf '$bundle' -C '$bundle.contents' && sh '$bundle.contents/apply.sh' '$remote'"))
3202
+ Invoke-Step '3/3 Apply and verify HTTP' 'ssh' ($options + @('-p', [string]$port, $destination, "cd '$remote/incoming' && mkdir -p '$bundle.contents' && tar -xf '$bundle' -C '$bundle.contents' && sh '$bundle.contents/apply.sh' '$remote' && { rm -rf -- '$bundle.contents' && rm -f -- '$bundle' || printf '[WARN] Deployment passed; incoming archive cleanup failed\\n' >&2; }"))
3098
3203
  } catch {
3099
3204
  if ($applying) {
3100
3205
  if ($LASTEXITCODE -gt 0 -and $LASTEXITCODE -lt 128) { [Console]::Error.WriteLine("[FAIL] Remote deployment failed (exit $LASTEXITCODE). Inspect $remote/result-<deploymentId>.json (ID in README.txt) and startup diagnostics.") }
@@ -3263,13 +3368,14 @@ ${removed.error.message}` });
3263
3368
  async function createDeploymentBundle(options) {
3264
3369
  const { directory, artifactDir, artifact, projectName, environment, config, selected } = options;
3265
3370
  const output = resolve(options.output);
3266
- if (!/^[\w.+-]+\.tar$/.test(basename(output)))
3371
+ if (!/^[\w.+-]+\.tar(?:\.gz)?$/.test(basename(output)))
3267
3372
  throw new Error(deployM("deploy_bundleNameInvalid"));
3268
3373
  const staging = mkdtempSync(join(directory, ".bundle-"));
3269
3374
  const temporaryTar = `${output}.partial`;
3270
3375
  mkdirSync(dirname(output), { recursive: true, mode: 448 });
3271
3376
  chmodSync(staging, 448);
3272
3377
  const files = [];
3378
+ const fileHashes = /* @__PURE__ */ new Map();
3273
3379
  const put = (file, content) => {
3274
3380
  mkdirSync(dirname(join(staging, file)), { recursive: true });
3275
3381
  writeFileSync(join(staging, file), content, { mode: 384 });
@@ -3294,7 +3400,7 @@ async function createDeploymentBundle(options) {
3294
3400
  service.env_file = name === artifact.primaryService ? [`../${options.deploymentId}/.env.runtime`, `../${options.deploymentId}/middleware.env`] : [`../${options.deploymentId}/middleware-runtime.env`];
3295
3401
  }
3296
3402
  if (Array.isArray(service.volumes))
3297
- service.volumes = service.volumes.map((mount) => typeof mount === "string" ? mount.replace("./web.conf:", `../${options.deploymentId}/web.conf:`).replace("./request-access.yml:", `../${options.deploymentId}/request-access.yml:`) : mount);
3403
+ service.volumes = service.volumes.map((mount) => typeof mount === "string" ? mount.replace(/\.\/(web\.conf|request-access\.yml|tls|https-start\.sh|renew-https\.sh|\.env\.runtime|middleware\.env|runtime-env\.mjs):/g, `../${options.deploymentId}/$1:`) : mount);
3298
3404
  }
3299
3405
  put("compose.json", JSON.stringify({ ...compose, services: Object.fromEntries(selected.map((name) => [name, compose.services[name]])) }));
3300
3406
  put("compose-entry.json", JSON.stringify({ ...compose, services: Object.fromEntries(selected.map((name) => [name, { extends: { file: `../${options.deploymentId}/compose.json`, service: name } }])) }));
@@ -3305,8 +3411,12 @@ async function createDeploymentBundle(options) {
3305
3411
  throw new Error(deployM("deploy_imageVersionRequired", { params: { image: reference } }));
3306
3412
  const built = artifact.images.find((image) => image.imageRef === reference);
3307
3413
  const file = built?.file ?? `middleware-${imageList.length + 1}.tar`;
3414
+ const destination = join(staging, "images", file);
3308
3415
  if (built) {
3309
- copyFileSync(join(artifactDir, file), join(staging, "images", file));
3416
+ const checksum = await copyArchive(join(artifactDir, file), destination);
3417
+ if (checksum !== built.sha256)
3418
+ throw new Error(deployM("deploy_artifactInvalid"));
3419
+ fileHashes.set(`images/${file}`, checksum);
3310
3420
  } else {
3311
3421
  if (!runtime) {
3312
3422
  const candidates = options.runtime === "auto" ? ["podman", "docker"] : [options.runtime];
@@ -3319,14 +3429,41 @@ async function createDeploymentBundle(options) {
3319
3429
  if (!runtime)
3320
3430
  throw new Error(deployM("deploy_bundleRuntimeRequired"));
3321
3431
  }
3322
- if (!(await runCommand(runtime, ["image", "inspect", reference])).success) {
3432
+ let inspected = await runCommand(runtime, ["image", "inspect", "--format", "{{.Id}}", reference]);
3433
+ if (!inspected.success) {
3323
3434
  const pulled = await runCommand(runtime, ["pull", reference], { timeoutMs: 6e5 });
3324
3435
  if (!pulled.success)
3325
3436
  throw new Error(pulled.error.message);
3437
+ inspected = await runCommand(runtime, ["image", "inspect", "--format", "{{.Id}}", reference]);
3326
3438
  }
3327
- const saved = await runCommand(runtime, ["save", ...runtime === "podman" ? ["--format", "docker-archive"] : [], "-o", join(staging, "images", file), reference], { timeoutMs: 6e5 });
3328
- if (!saved.success)
3329
- throw new Error(saved.error.message);
3439
+ const identity = inspected.success && !inspected.data.stdoutTruncated ? inspected.data.stdout.trim().replace(/^sha256:/, "") : "";
3440
+ const cacheIdentity = createHash("sha256").update(identity).update("\0").update(reference).digest("hex");
3441
+ const cache = options.cacheDirectory && /^[a-f0-9]{64}$/.test(identity) ? join(options.cacheDirectory, `${runtime}-${cacheIdentity}.tar`) : void 0;
3442
+ let checksum;
3443
+ if (cache && existsSync(cache) && existsSync(`${cache}.sha256`) && lstatSync(cache).isFile() && !lstatSync(cache).isSymbolicLink()) {
3444
+ const copied = await copyArchive(cache, destination);
3445
+ if (copied === readFileSync(`${cache}.sha256`, "utf8").trim())
3446
+ checksum = copied;
3447
+ }
3448
+ if (!checksum) {
3449
+ const saved = await runCommand(runtime, ["save", ...runtime === "podman" ? ["--format", "docker-archive"] : [], "-o", destination, reference], { timeoutMs: 6e5 });
3450
+ if (!saved.success)
3451
+ throw new Error(saved.error.message);
3452
+ checksum = await artifactChecksum(destination);
3453
+ if (cache) {
3454
+ mkdirSync(dirname(cache), { recursive: true, mode: 448 });
3455
+ const temporary = `${cache}.${basename(staging)}`;
3456
+ try {
3457
+ copyFileSync(destination, temporary);
3458
+ renameSync(temporary, cache);
3459
+ writeFileSync(`${cache}.sha256`, `${checksum}
3460
+ `, { mode: 384 });
3461
+ } finally {
3462
+ rmSync(temporary, { force: true });
3463
+ }
3464
+ }
3465
+ }
3466
+ fileHashes.set(`images/${file}`, checksum);
3330
3467
  }
3331
3468
  files.push(`images/${file}`);
3332
3469
  imageList.push(`${file} ${reference}`);
@@ -3335,6 +3472,8 @@ async function createDeploymentBundle(options) {
3335
3472
  if (selected.includes(artifact.primaryService) && existsSync(join(directory, "request-access.yml")))
3336
3473
  put("request-access.yml", readFileSync(join(directory, "request-access.yml"), "utf8"));
3337
3474
  put("middleware.env", options.middlewareEnv);
3475
+ if (selected.includes(artifact.primaryService) && !compose.services[artifact.primaryService]?.env_file)
3476
+ put("runtime-env.mjs", readFileSync(new URL("./deploy-runtime-env.mjs", import.meta.url), "utf8"));
3338
3477
  const middlewareRuntime = join(directory, "middleware-runtime.env");
3339
3478
  const nativeEnvironment = existsSync(middlewareRuntime) ? readFileSync(middlewareRuntime, "utf8") : "";
3340
3479
  put("middleware-runtime.env", nativeEnvironment);
@@ -3342,18 +3481,17 @@ async function createDeploymentBundle(options) {
3342
3481
  put(file, content);
3343
3482
  if (options.hasWebConfig)
3344
3483
  put("web.conf", readFileSync(join(directory, "web.conf"), "utf8"));
3484
+ for (const [file, content] of Object.entries(options.httpsAssets ?? {}))
3485
+ put(file, content);
3345
3486
  put("manifest.json", `${JSON.stringify({ ...artifact, images: artifact.images.filter((image) => references.has(image.imageRef)), deploymentId: options.deploymentId, selectedServices: selected, partial: options.partial }, null, 2)}
3346
3487
  `);
3347
3488
  if (options.hasWebConfig) {
3348
3489
  put("proxy-entry.json", JSON.stringify({ services: { [options.proxyService ?? "web"]: { volumes: [`../${options.deploymentId}/web.conf:/etc/nginx/conf.d/default.conf:ro`] } } }));
3349
3490
  }
3350
- put("proxy-service", `${options.hasWebConfig ? options.proxyService ?? "web" : ""}
3351
- `);
3352
3491
  put("storage-routing", `${options.storageRouting ?? ""}
3353
3492
  `);
3354
- put("layout", `${config.expose.hostPort}:${options.containerPort}:${options.hasWebConfig ? options.proxyService === "gateway" ? "gateway" : "true" : "false"}:${artifact.primaryService}:${options.hasWebConfig ? await artifactChecksum(join(directory, "web.conf")) : ""}
3355
- `);
3356
- put("health-port", `${config.expose.hostPort}
3493
+ const httpsFingerprint = config.expose.https ? createHash("sha256").update(JSON.stringify({ ...config.expose.https, certificateFile: void 0, privateKeyFile: void 0, certificate: options.httpsAssets?.["tls/fullchain.pem"] })).digest("hex") : "";
3494
+ put("layout", `${config.expose.hostPort}:${options.containerPort}:${options.hasWebConfig ? options.proxyService === "gateway" ? "gateway" : "true" : "false"}:${artifact.primaryService}:${options.hasWebConfig ? await artifactChecksum(join(directory, "web.conf")) : ""}${httpsFingerprint ? `:${httpsFingerprint}` : ""}
3357
3495
  `);
3358
3496
  put("web-base-path", `${artifact.webBasePath ?? ""}
3359
3497
  `);
@@ -3371,15 +3509,15 @@ async function createDeploymentBundle(options) {
3371
3509
  const selection = deployM("deploy_bundleSelection", { params: { mode, services: selected.join(", ") } });
3372
3510
  const instructions = `${selection}
3373
3511
  ${deployM("deploy_bundleInstructions", { params: instructionParams })}`;
3374
- put("README.txt", `${deployM("deploy_bundleMetadata", { params: { version: artifact.appVersion, buildId: artifact.buildId, deploymentId: options.deploymentId, compose: "compose.json" } })}
3512
+ const readme = `${deployM("deploy_bundleMetadata", { params: { version: artifact.appVersion, buildId: artifact.buildId, deploymentId: options.deploymentId, compose: "compose.json" } })}
3375
3513
  ${instructions}
3376
- `);
3514
+ `;
3377
3515
  const checksums = [];
3378
3516
  for (const file of files)
3379
- checksums.push(`${await artifactChecksum(join(staging, file))} ${file}`);
3517
+ checksums.push(`${fileHashes.get(file) ?? await artifactChecksum(join(staging, file))} ${file}`);
3380
3518
  writeFileSync(join(staging, "SHA256SUMS"), `${checksums.join("\n")}
3381
3519
  `);
3382
- const tar = await runCommand("tar", ["-cf", temporaryTar, "-C", staging, "."]);
3520
+ const tar = await runCommand("tar", [output.endsWith(".gz") ? "-czf" : "-cf", temporaryTar, "-C", staging, "."]);
3383
3521
  if (!tar.success)
3384
3522
  throw new Error(tar.error.message);
3385
3523
  chmodSync(temporaryTar, 384);
@@ -3418,7 +3556,7 @@ ${instructions}
3418
3556
  };
3419
3557
  writeFileSync(join(scripts, "deploy-target.json"), `${JSON.stringify({ ...target, _comments: comments }, null, 2)}
3420
3558
  `);
3421
- writeFileSync(join(scripts, "README.txt"), readFileSync(join(staging, "README.txt"), "utf8"));
3559
+ writeFileSync(join(scripts, "README.txt"), readme);
3422
3560
  for (const file of [`docker-compose-${artifact.appVersion}.yml`, ".env.runtime", "middleware-runtime.env", "request-access.yml", ...options.hasWebConfig ? ["web.conf"] : []])
3423
3561
  rmSync(join(directory, file), { force: true });
3424
3562
  return { bundlePath: output, instructions: `${selection}
@@ -3429,6 +3567,178 @@ ${scripts}`, startName: "apply.sh" };
3429
3567
  rmSync(temporaryTar, { force: true });
3430
3568
  }
3431
3569
  }
3570
+ async function copyArchive(source, destination) {
3571
+ const hash = createHash("sha256");
3572
+ const digest = new Transform({
3573
+ transform(chunk, _encoding, callback) {
3574
+ hash.update(chunk);
3575
+ callback(null, chunk);
3576
+ }
3577
+ });
3578
+ await pipeline(createReadStream(source), digest, createWriteStream(destination, { mode: 384 }));
3579
+ return hash.digest("hex");
3580
+ }
3581
+ var CERTBOT_IMAGE = "docker.io/certbot/certbot:v5.9.0";
3582
+ function createHttpsAssets(config, projectDirectory) {
3583
+ const assets = {
3584
+ https: `${config.domain} ${config.port} ${config.type}
3585
+ `
3586
+ };
3587
+ if (config.type === "files") {
3588
+ try {
3589
+ const readPem = (file) => {
3590
+ const path = resolve(projectDirectory, file);
3591
+ if (!statSync(path).isFile() || statSync(path).size > 1048576)
3592
+ throw new Error("Invalid PEM file");
3593
+ return readFileSync(path, "utf8");
3594
+ };
3595
+ const certificate = readPem(config.certificateFile);
3596
+ const key = readPem(config.privateKeyFile);
3597
+ const parsed = new X509Certificate(certificate);
3598
+ const publicKey = createPublicKey(createPrivateKey(key)).export({ type: "spki", format: "der" });
3599
+ const now = Date.now();
3600
+ if (!publicKey.equals(parsed.publicKey.export({ type: "spki", format: "der" })) || now < Date.parse(parsed.validFrom) || now >= Date.parse(parsed.validTo) || !(isIP(config.domain) ? parsed.checkIP(config.domain) : parsed.checkHost(config.domain))) {
3601
+ throw new Error("Certificate validation failed");
3602
+ }
3603
+ assets["tls/fullchain.pem"] = certificate;
3604
+ assets["tls/privkey.pem"] = key;
3605
+ } catch {
3606
+ throw new Error(deployM("deploy_httpsCertificateInvalid"));
3607
+ }
3608
+ } else {
3609
+ assets["renew-https.sh"] = createRenewScript(config);
3610
+ }
3611
+ assets["prepare-https.sh"] = createPrepareScript(config);
3612
+ assets["https-start.sh"] = String.raw`#!/bin/sh
3613
+ set -eu
3614
+ # 目录挂载允许原子替换证书;只有新证书通过 nginx -t 才重载。
3615
+ watch_certificate() {
3616
+ previous=$(sha256sum /etc/nginx/tls/fullchain.pem)
3617
+ while sleep 60; do
3618
+ current=$(sha256sum /etc/nginx/tls/fullchain.pem) || continue
3619
+ if [ "$previous" != "$current" ]; then
3620
+ if nginx -t && nginx -s reload; then previous=$current; else printf '[ERROR] HTTPS certificate reload failed\n' >&2; fi
3621
+ fi
3622
+ done
3623
+ }
3624
+ watch_certificate &
3625
+ exec /docker-entrypoint.sh nginx -g 'daemon off;'
3626
+ `;
3627
+ return assets;
3628
+ }
3629
+ function renderHttpsProxy(source, config, httpPort) {
3630
+ const publicPort = config.port === 443 ? "" : `:${config.port}`;
3631
+ const challenge = " location ^~ /.well-known/acme-challenge/ { root /var/www/certbot; }\n";
3632
+ const tls = source.replace(`listen ${httpPort};`, `listen ${httpPort === 8443 ? 8444 : 8443} ssl;
3633
+ ssl_certificate /etc/nginx/tls/fullchain.pem;
3634
+ ssl_certificate_key /etc/nginx/tls/privkey.pem;`).replace("server_name _;", `server_name ${config.domain};`).replaceAll("proxy_set_header X-Forwarded-Proto $scheme;", "proxy_set_header X-Forwarded-Proto https;");
3635
+ const http = config.redirectHttp ? `server {
3636
+ listen ${httpPort};
3637
+ server_name ${config.domain};
3638
+ ${challenge} location ~ ^/_hai/(web-health|gateway-health)$ { access_log off; return 200 'ok'; }
3639
+ location / { return 308 https://${config.domain}${publicPort}$request_uri; }
3640
+ }
3641
+ ` : source.slice(source.indexOf("server {")).replace("server_name _;", `server_name ${config.domain};
3642
+ ${challenge}`);
3643
+ return `${tls}
3644
+ ${http}`;
3645
+ }
3646
+ var publishCertificate = String.raw`publish_certificate() {
3647
+ certificate="/etc/letsencrypt/live/$domain"
3648
+ test -s "$certificate/fullchain.pem" && test -s "$certificate/privkey.pem"
3649
+ cp "$certificate/fullchain.pem" /output/fullchain.pem.next
3650
+ cp "$certificate/privkey.pem" /output/privkey.pem.next
3651
+ chmod 444 /output/fullchain.pem.next /output/privkey.pem.next
3652
+ mv /output/privkey.pem.next /output/privkey.pem
3653
+ mv /output/fullchain.pem.next /output/fullchain.pem
3654
+ }
3655
+ `;
3656
+ function createRenewScript(config) {
3657
+ return `#!/bin/sh
3658
+ set -eu
3659
+ domain=${shellArgument(config.domain)}
3660
+ ${publishCertificate}${String.raw`
3661
+ trap 'exit 0' TERM INT
3662
+ # Certbot 自己判断到期时间;同一官方客户端同时负责申请与续期,不实现 ACME 协议。
3663
+ while :; do
3664
+ if certbot renew --cert-name "$domain" --non-interactive; then
3665
+ if [ -s "/etc/letsencrypt/live/$domain/fullchain.pem" ]; then
3666
+ current=$(sha256sum "/etc/letsencrypt/live/$domain/fullchain.pem" | awk '{print $1}')
3667
+ installed=$(sha256sum /output/fullchain.pem | awk '{print $1}')
3668
+ if [ "$current" != "$installed" ]; then publish_certificate; fi
3669
+ fi
3670
+ else
3671
+ printf '[ERROR] Certbot renewal failed; the existing certificate remains installed\n' >&2
3672
+ fi
3673
+ sleep 43200 & wait $! || exit 0
3674
+ done
3675
+ `}`;
3676
+ }
3677
+ function createPrepareScript(config) {
3678
+ const header = `#!/bin/sh
3679
+ set -eu
3680
+ umask 077
3681
+ root=$1
3682
+ release=$2
3683
+ runtime=$3
3684
+ mode=$4
3685
+ domain=${shellArgument(config.domain)}
3686
+ `;
3687
+ if (config.type === "files") {
3688
+ return header + String.raw`
3689
+ test "$mode" != prepare || {
3690
+ # 宿主 root/release 保持私密;容器仅可读取独立挂载的证书目录。
3691
+ chmod 700 "$root" "$release"
3692
+ chmod 755 "$release/tls"
3693
+ chmod 444 "$release/tls/fullchain.pem" "$release/tls/privkey.pem"
3694
+ }
3695
+ `;
3696
+ }
3697
+ const python = [
3698
+ "from cryptography import x509",
3699
+ "from cryptography.x509.oid import NameOID",
3700
+ "from cryptography.hazmat.primitives import hashes, serialization",
3701
+ "from cryptography.hazmat.primitives.asymmetric import ec",
3702
+ "from datetime import datetime, timedelta, timezone",
3703
+ "from pathlib import Path",
3704
+ "import sys",
3705
+ "name=sys.argv[1]",
3706
+ "key=ec.generate_private_key(ec.SECP256R1())",
3707
+ "subject=x509.Name([x509.NameAttribute(NameOID.COMMON_NAME,name)])",
3708
+ "now=datetime.now(timezone.utc)",
3709
+ "cert=x509.CertificateBuilder().subject_name(subject).issuer_name(subject).public_key(key.public_key()).serial_number(x509.random_serial_number()).not_valid_before(now-timedelta(minutes=1)).not_valid_after(now+timedelta(days=1)).add_extension(x509.SubjectAlternativeName([x509.DNSName(name)]),False).sign(key,hashes.SHA256())",
3710
+ 'Path("/output/privkey.pem").write_bytes(key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()))',
3711
+ 'Path("/output/fullchain.pem").write_bytes(cert.public_bytes(serialization.Encoding.PEM))'
3712
+ ].join("\n");
3713
+ const publish = `set -eu
3714
+ domain=${shellArgument(config.domain)}
3715
+ ${publishCertificate}
3716
+ publish_certificate
3717
+ `;
3718
+ return `${header}image=${shellArgument(CERTBOT_IMAGE)}
3719
+ email=${shellArgument(config.email)}
3720
+ bootstrap=${shellArgument(python)}
3721
+ ${String.raw`
3722
+ state="$root/https"
3723
+ certificates="$state/certificates/$domain"
3724
+ mkdir -p "$state/acme" "$state/webroot" "$certificates"
3725
+ chmod 700 "$root" "$state" "$state/acme"
3726
+ chmod 755 "$state/webroot" "$certificates"
3727
+ if [ "$mode" = prepare ]; then
3728
+ if [ ! -s "$certificates/fullchain.pem" ] || [ ! -s "$certificates/privkey.pem" ]; then
3729
+ if [ "$5" = true ]; then printf 'HTTPS certificate state missing; create a full deployment.\n' >&2; exit 1; fi
3730
+ # 临时证书只用于启动挑战入口;签发失败不得提交 current。
3731
+ "$runtime" run --rm --pull never --entrypoint python -v "$certificates:/output" "$image" -c "$bootstrap" "$domain"
3732
+ chmod 444 "$certificates/fullchain.pem" "$certificates/privkey.pem"
3733
+ fi
3734
+ elif [ "$mode" = issue ]; then
3735
+ "$runtime" run --rm --pull never -v "$state/acme:/etc/letsencrypt" -v "$state/webroot:/var/www/certbot" "$image" certonly --webroot --webroot-path /var/www/certbot --cert-name "$domain" --domain "$domain" --email "$email" --agree-tos --non-interactive --keep-until-expiring
3736
+ "$runtime" run --rm --pull never --entrypoint sh -v "$state/acme:/etc/letsencrypt:ro" -v "$certificates:/output" "$image" -c `}${shellArgument(publish)}
3737
+ fi
3738
+ `;
3739
+ }
3740
+
3741
+ // src/container/deploy-container-compose.ts
3432
3742
  var IMAGES = {
3433
3743
  postgres: "docker.io/library/postgres:16.15-alpine",
3434
3744
  mysql: "docker.io/library/mysql:8.4.11",
@@ -3693,12 +4003,17 @@ function planSidecars(_appDir, projectName, scan, provisionEnv, options, s3Engin
3693
4003
  const requirements = scan.middlewares?.filter((item) => item.service === "email" && item.backend === "smtp" && ["default", void 0].includes(options?.serviceModes?.[item.envPrefix] ?? options?.serviceModes?.email));
3694
4004
  if (requirements?.length) {
3695
4005
  for (const requirement of requirements)
3696
- plan.env[requirement.envPrefix] = JSON.stringify({ name: requirement.config.name ?? "email", type: "smtp", host: "email", port: 1025, secure: false, from: "noreply@example.com" });
4006
+ plan.env[requirement.envPrefix] = JSON.stringify({ name: requirement.config.name ?? "email", type: "smtp", host: "email", port: 1025, secure: false, from: requirement.config.from || "noreply@example.com" });
3697
4007
  } else if (!scan.middlewares) {
3698
4008
  plan.env.HAI_REACH_PROVIDERS_0 = JSON.stringify({ name: "email", type: "smtp", host: "email", port: 1025, secure: false, from: "noreply@example.com" });
3699
4009
  }
3700
4010
  }
3701
4011
  }
4012
+ for (const requirement of scan.middlewares ?? []) {
4013
+ const supplied = plan.env[requirement.envPrefix];
4014
+ if (supplied)
4015
+ plan.env[requirement.envPrefix] = JSON.stringify(mergeManagedMiddlewareConfig(requirement, JSON.parse(supplied)));
4016
+ }
3702
4017
  return plan;
3703
4018
  }
3704
4019
  function buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort, options, appEnv = {}, s3Engine = "rustfs") {
@@ -3717,6 +4032,7 @@ function buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort,
3717
4032
  function generateComposeFile(options) {
3718
4033
  const { projectName, imageRef, hostPort, containerPort, sidecar } = options;
3719
4034
  const primaryService = options.primaryService ?? "serv";
4035
+ const nodeRuntime = options.nodeRuntime ?? primaryService === "serv";
3720
4036
  const app = {
3721
4037
  image: imageRef,
3722
4038
  labels: { "org.opencontainers.image.version": options.appVersion },
@@ -3728,11 +4044,28 @@ function generateComposeFile(options) {
3728
4044
  ports: [`${hostPort}:${containerPort}`],
3729
4045
  networks: ["hai-app"]
3730
4046
  };
4047
+ if (primaryService === "serv") {
4048
+ app.healthcheck = {
4049
+ test: ["CMD", "node", "-e", "(async () => { try { const { core } = await import('@h-ai/core'); const { ServConfigSchema } = await import('@h-ai/serv'); core.init({ configDir: './config' }); const { server, http } = ServConfigSchema.parse(core.config.getOrThrow('serv')); if (http.health === false) process.exit(0); const response = await fetch('http://127.0.0.1:' + server.port + http.health.path, { signal: AbortSignal.timeout(4000) }); process.exit(response.ok ? 0 : 1); } catch { process.exit(1); } })()"],
4050
+ interval: "30s",
4051
+ timeout: "5s",
4052
+ start_period: "15s",
4053
+ retries: 3
4054
+ };
4055
+ }
3731
4056
  const appVolumes = [...sidecar.appVolumes];
4057
+ const appEnvironment = {};
4058
+ if (nodeRuntime) {
4059
+ delete app.env_file;
4060
+ appVolumes.push("./.env.runtime:/run/hai/.env.runtime:ro", "./middleware.env:/run/hai/middleware.env:ro", "./runtime-env.mjs:/run/hai/runtime-env.mjs:ro");
4061
+ appEnvironment.NODE_OPTIONS = `--import /run/hai/runtime-env.mjs${options.nodeOptions ? ` ${options.nodeOptions}` : ""}`.replaceAll("$", () => "$$");
4062
+ }
3732
4063
  if (options.requestAccessPolicy)
3733
4064
  appVolumes.push("./request-access.yml:/run/hai/request-access.yml:ro");
3734
4065
  if (options.web || options.gateway)
3735
- app.environment = { HAI_DEPLOY_PRIVATE_PROXY: "1" };
4066
+ appEnvironment.HAI_DEPLOY_PRIVATE_PROXY = "1";
4067
+ if (Object.keys(appEnvironment).length)
4068
+ app.environment = appEnvironment;
3736
4069
  if (options.mountWebConfig)
3737
4070
  appVolumes.push("./web.conf:/etc/nginx/conf.d/default.conf:ro");
3738
4071
  if (appVolumes.length)
@@ -3765,10 +4098,38 @@ function generateComposeFile(options) {
3765
4098
  ports: [`${hostPort}:8080`],
3766
4099
  healthcheck: { test: ["CMD", "wget", "-Y", "off", "-q", "-O", "/dev/null", "http://127.0.0.1:8080/_hai/gateway-health"], interval: "30s", timeout: "5s", retries: 3 },
3767
4100
  volumes: ["./web.conf:/etc/nginx/conf.d/default.conf:ro"],
3768
- depends_on: [primaryService, "storage"],
4101
+ depends_on: [primaryService, ...sidecar.services.includes("storage") ? ["storage"] : []],
3769
4102
  networks: ["hai-app"]
3770
4103
  };
3771
4104
  }
4105
+ if (options.https) {
4106
+ const proxy = services[options.gateway ? "gateway" : options.web ? "web" : primaryService];
4107
+ const https = options.https;
4108
+ const httpPort = options.gateway ? 8080 : options.web?.containerPort ?? containerPort;
4109
+ proxy.ports = [...proxy.ports, `${https.port}:${httpPort === 8443 ? 8444 : 8443}`];
4110
+ const certificates = https.type === "files" ? "./tls" : `../../https/certificates/${https.domain}`;
4111
+ proxy.volumes = [
4112
+ ...proxy.volumes,
4113
+ `${certificates}:/etc/nginx/tls:ro`,
4114
+ "./https-start.sh:/run/hai/https-start.sh:ro",
4115
+ ...https.type === "letsencrypt" ? ["../../https/webroot:/var/www/certbot:ro"] : []
4116
+ ];
4117
+ proxy.entrypoint = ["sh", "/run/hai/https-start.sh"];
4118
+ if (https.type === "letsencrypt") {
4119
+ services.certbot = {
4120
+ image: CERTBOT_IMAGE,
4121
+ restart: "unless-stopped",
4122
+ entrypoint: ["sh", "/run/hai/renew-https.sh"],
4123
+ volumes: [
4124
+ "../../https/acme:/etc/letsencrypt",
4125
+ "../../https/webroot:/var/www/certbot",
4126
+ `../../https/certificates/${https.domain}:/output`,
4127
+ "./renew-https.sh:/run/hai/renew-https.sh:ro"
4128
+ ],
4129
+ networks: ["hai-app"]
4130
+ };
4131
+ }
4132
+ }
3772
4133
  const compose = {
3773
4134
  "x-hai-version": options.appVersion,
3774
4135
  services,
@@ -3787,11 +4148,21 @@ function generateWebProxyConfig(port, apiPrefix, options = {}) {
3787
4148
  const webPort = options.webPort ?? 8080;
3788
4149
  const webBasePath = options.webBasePath ?? "";
3789
4150
  const apiPath = apiPrefix.replace(/\/+$/, "");
3790
- if (!/^\/[\w/-]+$/.test(apiPrefix) || ![port, webPort].every((value) => Number.isInteger(value) && value >= 1 && value <= 65535) || webBasePath && (webBasePath === apiPath || webBasePath.startsWith(`${apiPath}/`))) {
4151
+ const backendPaths = [...new Set(options.backendPaths ?? ["/health"])];
4152
+ if (!/^\/[\w/-]+$/.test(apiPrefix) || ![port, webPort].every((value) => Number.isInteger(value) && value >= 1 && value <= 65535) || backendPaths.some((path) => !/^\/[\w./-]*$/.test(path) || path.startsWith("//") || path.split("/").some((part) => part === "." || part === "..") || path === `${webBasePath}/runtime-config.js` || path === "/_hai/web-health") || webBasePath && (backendPaths.includes(webBasePath) || webBasePath === apiPath || webBasePath.startsWith(`${apiPath}/`))) {
3791
4153
  throw new Error("Invalid web proxy target");
3792
4154
  }
3793
4155
  const webLocation = renderWebLocation(webBasePath);
3794
- const storageLocation = options.storageBucket ? renderStorageLocation(options.storageBucket, [apiPath, ...options.storageBasePath ? [`${webBasePath}/_app`, `${webBasePath}/assets`, `${webBasePath}/runtime-config.js`] : [webBasePath]], options.storageBasePath) : "";
4156
+ const storageLocation = options.storageBucket ? renderStorageLocation(options.storageBucket, [apiPath, ...backendPaths, ...options.storageBasePath ? [`${webBasePath}/_app`, `${webBasePath}/assets`, `${webBasePath}/runtime-config.js`] : [webBasePath]], options.storageBasePath) : "";
4157
+ const backendLocations = backendPaths.map((path) => ` location = ${path} {
4158
+ proxy_pass http://serv:${port};
4159
+ proxy_http_version 1.1;
4160
+ proxy_set_header Host $http_host;
4161
+ proxy_set_header X-Forwarded-Proto $scheme;
4162
+ proxy_set_header X-Forwarded-For $remote_addr;
4163
+ proxy_set_header X-Real-IP $remote_addr;
4164
+ }
4165
+ `).join("");
3795
4166
  return `${options.storageBucket ? renderStorageUpstream(options.storageBucket, options.storageBasePath) : ""}server {
3796
4167
  listen ${webPort};
3797
4168
  server_name _;
@@ -3806,13 +4177,7 @@ ${storageLocation}${webLocation} location = ${webBasePath}/runtime-config.js {
3806
4177
  access_log off;
3807
4178
  return 200 'ok';
3808
4179
  }
3809
- location = /health {
3810
- proxy_pass http://serv:${port};
3811
- proxy_set_header Host $http_host;
3812
- proxy_set_header X-Forwarded-For $remote_addr;
3813
- proxy_set_header X-Real-IP $remote_addr;
3814
- }
3815
- location ${apiPrefix.replace(/\/$/, "")}/ {
4180
+ ${backendLocations} location ${apiPrefix.replace(/\/$/, "")}/ {
3816
4181
  client_max_body_size ${bodyLimits.encryptedPayloadBytes};
3817
4182
  proxy_pass http://serv:${port};
3818
4183
  proxy_http_version 1.1;
@@ -3878,11 +4243,11 @@ function generateApplicationProxyConfig(options) {
3878
4243
  if (!Number.isInteger(port) || port < 1 || port > 65535)
3879
4244
  throw new Error("Invalid application proxy target");
3880
4245
  const bodyLimits = resolveTransportBodyLimits(maxPlaintextBytes);
3881
- return `${renderStorageUpstream(bucket, options.storageBasePath)}server {
4246
+ return `${bucket ? renderStorageUpstream(bucket, options.storageBasePath) : ""}server {
3882
4247
  listen 8080;
3883
4248
  server_name _;
3884
4249
  ${renderTrustedEdgeProxies(options.trustedEdgeProxies)} large_client_header_buffers 4 32k;
3885
- ${renderStorageLocation(bucket, reservedPaths, options.storageBasePath)} location = /_hai/gateway-health {
4250
+ ${bucket ? renderStorageLocation(bucket, reservedPaths, options.storageBasePath) : ""} location = /_hai/gateway-health {
3886
4251
  access_log off;
3887
4252
  return 200 'ok';
3888
4253
  }
@@ -3990,9 +4355,22 @@ function prepareRequestAccess(options) {
3990
4355
  }
3991
4356
  }
3992
4357
  var HEALTH_MAX_ATTEMPTS = 40;
4358
+ function servicePath(value, fallback) {
4359
+ const path = value ?? fallback;
4360
+ if (typeof path !== "string" || !/^\/[\w./-]*$/.test(path) || path.startsWith("//") || path.split("/").some((part) => part === "." || part === ".."))
4361
+ throw new Error("Invalid service endpoint path");
4362
+ return path;
4363
+ }
4364
+ function endpointSettings(value) {
4365
+ if (value === void 0)
4366
+ return {};
4367
+ if (!value || typeof value !== "object" || Array.isArray(value))
4368
+ throw new Error("Invalid service endpoint configuration");
4369
+ return value;
4370
+ }
3993
4371
  var ServerResultSchema = z.object({
3994
4372
  deploymentId: z.string(),
3995
- stage: z.enum(["preflight", "credentials", "recover", "images", "dependencies", "start", "health", "complete"]),
4373
+ stage: z.enum(["preflight", "credentials", "recover", "images", "dependencies", "start", "https", "health", "complete"]),
3996
4374
  exitCode: z.number().int().min(0).max(255),
3997
4375
  durationSeconds: z.number().int().nonnegative(),
3998
4376
  recoveryStatus: z.enum(["not-needed", "passed", "failed", "stopped"]).optional()
@@ -4004,7 +4382,7 @@ function makeDeploymentId() {
4004
4382
  return `${stamp}-${randomBytes(3).toString("hex")}`;
4005
4383
  }
4006
4384
  function sleep(ms) {
4007
- return new Promise((resolve8) => setTimeout(resolve8, ms));
4385
+ return new Promise((resolve9) => setTimeout(resolve9, ms));
4008
4386
  }
4009
4387
  function createDockerSshProvider(config, container) {
4010
4388
  const ssh = config.ssh ? createSshExecutor(config.ssh) : void 0;
@@ -4028,13 +4406,28 @@ function createDockerSshProvider(config, container) {
4028
4406
  deployM("deploy_remoteRuntimeMissing", { params: { runtime: candidates.join(" / ") } })
4029
4407
  );
4030
4408
  }
4031
- async function healthCheck(url) {
4409
+ async function healthCheck(url, certificate) {
4032
4410
  for (let attempt = 0; attempt < HEALTH_MAX_ATTEMPTS; attempt++) {
4033
4411
  try {
4034
- const response = await fetch(url, { signal: AbortSignal.timeout(5e3) });
4035
- await response.body?.cancel();
4036
- if (response.ok)
4037
- return true;
4412
+ if (certificate) {
4413
+ const healthy = await new Promise((resolve9) => {
4414
+ const request$1 = request(url, { ca: [...rootCertificates, certificate], timeout: 5e3 }, (response) => {
4415
+ response.resume();
4416
+ resolve9(response.statusCode !== void 0 && response.statusCode >= 200 && response.statusCode < 300);
4417
+ });
4418
+ request$1.on("error", () => resolve9(false));
4419
+ request$1.on("close", () => resolve9(false));
4420
+ request$1.on("timeout", () => request$1.destroy());
4421
+ request$1.end();
4422
+ });
4423
+ if (healthy)
4424
+ return true;
4425
+ } else {
4426
+ const response = await fetch(url, { signal: AbortSignal.timeout(5e3) });
4427
+ await response.body?.cancel();
4428
+ if (response.ok)
4429
+ return true;
4430
+ }
4038
4431
  } catch {
4039
4432
  }
4040
4433
  await sleep(HEALTH_POLL_INTERVAL);
@@ -4045,6 +4438,8 @@ function createDockerSshProvider(config, container) {
4045
4438
  const { appDir, projectName, scan, provisionEnv, appEnv, options } = context;
4046
4439
  const deploymentId = makeDeploymentId();
4047
4440
  const hostPort = config.expose.hostPort;
4441
+ const https = config.expose.https;
4442
+ const httpsAssets = https ? createHttpsAssets(https, resolveProjectDirectory(appDir)) : void 0;
4048
4443
  const deploymentRoot = join(resolveProjectDirectory(appDir), ".hai", "deploy", options?.environment ?? "prod");
4049
4444
  const artifact = context.artifact;
4050
4445
  const resolvedBuildDir = context.artifactDir;
@@ -4063,12 +4458,14 @@ function createDockerSshProvider(config, container) {
4063
4458
  const containerPort = app?.containerPort ?? (primaryService === "serv" ? artifact.servContainerPort : artifact.webContainerPort) ?? config.expose.containerPort;
4064
4459
  const webContainerPort = web?.containerPort ?? artifact.webContainerPort ?? 8080;
4065
4460
  const host = config.ssh?.host;
4066
- const url = host ? `http://${host.includes(":") ? `[${host}]` : host}:${hostPort}` : `http://SERVER_HOST:${hostPort}`;
4461
+ const url = https ? `https://${https.domain}${https.port === 443 ? "" : `:${https.port}`}` : host ? `http://${host.includes(":") ? `[${host}]` : host}:${hostPort}` : `http://SERVER_HOST:${hostPort}`;
4067
4462
  const webBasePath = artifact.webBasePath ?? "";
4068
4463
  const applicationUrl = `${url}${webBasePath ? `${webBasePath}/` : ""}`;
4069
- const standaloneWebConfig = primaryService === "web" && !scan.isSvelteKit && !!webBasePath;
4070
- const healthPaths = hasWebProxy ? ["/health", "/_hai/web-health", `${webBasePath}/`] : scan.isSvelteKit ? [`${webBasePath}/`] : standaloneWebConfig ? ["/health", `${webBasePath}/`] : ["/health"];
4464
+ const standaloneWebConfig = primaryService === "web" && !scan.isSvelteKit && (!!webBasePath || !!https);
4465
+ const healthPaths = hasWebProxy ? ["/_hai/web-health", `${webBasePath}/`] : scan.isSvelteKit ? [`${webBasePath}/`] : primaryService === "serv" ? [] : standaloneWebConfig ? ["/health", `${webBasePath}/`] : ["/health"];
4071
4466
  const { env, sidecar } = buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort, options, appEnv, container?.s3Engine);
4467
+ if (https && scan.isSvelteKit && !env.ORIGIN)
4468
+ env.ORIGIN = url;
4072
4469
  const managedStorage = sidecar.services.includes("storage") ? JSON.parse(sidecar.env.HAI_STORAGE) : void 0;
4073
4470
  let storageBasePath = "";
4074
4471
  if (managedStorage?.publicEndpoint !== void 0) {
@@ -4086,7 +4483,7 @@ function createDockerSshProvider(config, container) {
4086
4483
  }
4087
4484
  }
4088
4485
  const storageRouting = managedStorage ? createHash("sha256").update(JSON.stringify({ ...managedStorage, secretAccessKey: void 0 })).digest("hex") : "";
4089
- const gateway = !!managedStorage && !hasWebProxy;
4486
+ const gateway = !hasWebProxy && (!!managedStorage || !!https && !standaloneWebConfig);
4090
4487
  if (gateway && primaryService === "web" && !scan.isSvelteKit)
4091
4488
  return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_storageBackendRequired"));
4092
4489
  const proxyService = gateway ? "gateway" : "web";
@@ -4097,6 +4494,35 @@ function createDockerSshProvider(config, container) {
4097
4494
  const server = existsSync(serverPath) || hasWebProxy ? loadDeploymentConfig("serv", serverPath, env) : ok({});
4098
4495
  if (!server.success)
4099
4496
  return server;
4497
+ const backendPaths = [];
4498
+ try {
4499
+ const http = endpointSettings(server.data.http);
4500
+ if (primaryService === "serv" && http.health !== false) {
4501
+ const health = endpointSettings(http.health);
4502
+ const path = servicePath(health.path, "/health");
4503
+ healthPaths.unshift(path);
4504
+ backendPaths.push(path, servicePath(health.readyPath, "/ready"));
4505
+ }
4506
+ if (hasWebProxy) {
4507
+ const corePath = join(appDir, "config", "_core.yml");
4508
+ const settings = existsSync(corePath) ? loadDeploymentConfig("core", corePath, env) : ok(env.HAI_CORE ? JSON.parse(env.HAI_CORE) : { env: env.HAI_CORE_ENV });
4509
+ if (!settings.success)
4510
+ return settings;
4511
+ const application = CoreConfigSchema.safeParse(settings.data);
4512
+ if (!application.success)
4513
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_webProxyInvalid"));
4514
+ if (application.data.env !== "production") {
4515
+ if (http.openapi !== void 0 && http.openapi !== false)
4516
+ backendPaths.push(servicePath(endpointSettings(http.openapi).path, "/openapi.json"));
4517
+ if (http.docs !== void 0 && http.docs !== false) {
4518
+ const docsPath = servicePath(endpointSettings(http.docs).path, "/docs");
4519
+ backendPaths.push(docsPath, `${docsPath.slice(0, docsPath.lastIndexOf("/") + 1)}_hai/scalar.js`);
4520
+ }
4521
+ }
4522
+ }
4523
+ } catch {
4524
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_webProxyInvalid"));
4525
+ }
4100
4526
  let requestAccessSnapshot;
4101
4527
  if (updatesApp && primaryService === "serv") {
4102
4528
  const prepared2 = prepareRequestAccess({ settings: server.data, module: "serv", directory: appDir });
@@ -4158,25 +4584,25 @@ function createDockerSshProvider(config, container) {
4158
4584
  if (typeof apiPrefix !== "string" || !/^\/[\w/-]+$/.test(apiPrefix)) {
4159
4585
  return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_webProxyInvalid"));
4160
4586
  }
4161
- if (webBasePath && (["/health", "/_hai/web-health"].includes(webBasePath) || webBasePath === apiPrefix.replace(/\/+$/, "") || webBasePath.startsWith(`${apiPrefix.replace(/\/+$/, "")}/`)))
4587
+ if (webBasePath && ([...backendPaths, "/_hai/web-health"].includes(webBasePath) || webBasePath === apiPrefix.replace(/\/+$/, "") || webBasePath.startsWith(`${apiPrefix.replace(/\/+$/, "")}/`)))
4162
4588
  return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_webBaseConflict"));
4163
4589
  const transport = server.data.transport;
4164
4590
  try {
4165
4591
  const configured = transport ? transport.maxPlaintextBytes : void 0;
4166
4592
  const limits = resolveTransportBodyLimits(Math.max(createTransportBodyPolicy(configured === "auto" ? void 0 : configured, transport ? transport.routePlaintextBytes : void 0).maximum.plaintextBytes, feedbackTransportBytes ?? 0));
4167
- webProxy = generateWebProxyConfig(containerPort, apiPrefix, { webPort: webContainerPort, maxPlaintextBytes: limits.plaintextBytes, webBasePath, storageBucket: managedStorage?.bucket, storageBasePath, trustedEdgeProxies: config.trustedEdgeProxies });
4593
+ webProxy = generateWebProxyConfig(containerPort, apiPrefix, { webPort: webContainerPort, maxPlaintextBytes: limits.plaintextBytes, webBasePath, backendPaths, storageBucket: managedStorage?.bucket, storageBasePath, trustedEdgeProxies: config.trustedEdgeProxies });
4168
4594
  } catch (error) {
4169
4595
  return err(HaiDeployError.CONFIG_ERROR, error instanceof RangeError ? error.message : deployM(managedStorage ? "deploy_storageProxyInvalid" : "deploy_transportBodyLimitInvalid"));
4170
4596
  }
4171
- env.HAI_SERV = JSON.stringify({ ...server.data, cors: { ...cors, origin: (!cors?.origin || cors.origin === "*") && config.ssh ? url : cors?.origin ?? "*" } });
4172
- } else if (gateway && managedStorage) {
4597
+ env.HAI_SERV = JSON.stringify({ ...server.data, cors: { ...cors, origin: (!cors?.origin || cors.origin === "*") && (config.ssh || https) ? url : cors?.origin ?? "*" } });
4598
+ } else if (gateway) {
4173
4599
  try {
4174
4600
  const http = server.data.http;
4175
4601
  if (primaryService === "serv") {
4176
4602
  const transport = server.data.transport;
4177
4603
  applicationPlaintextBytes = Math.max(createTransportBodyPolicy(transport?.maxPlaintextBytes === "auto" ? void 0 : transport?.maxPlaintextBytes, transport?.routePlaintextBytes).maximum.plaintextBytes, feedbackTransportBytes ?? 0);
4178
4604
  }
4179
- webProxy = generateApplicationProxyConfig({ primaryService, port: containerPort, bucket: managedStorage.bucket, storageBasePath, reservedPaths: [http?.apiPrefix ?? "/api/v1", ...storageBasePath ? [`${webBasePath}/_app`, `${webBasePath}/assets`, `${webBasePath}/runtime-config.js`] : [webBasePath]], maxPlaintextBytes: applicationPlaintextBytes, trustedEdgeProxies: config.trustedEdgeProxies });
4605
+ webProxy = generateApplicationProxyConfig({ primaryService, port: containerPort, bucket: managedStorage?.bucket, storageBasePath, reservedPaths: [http?.apiPrefix ?? "/api/v1", ...storageBasePath ? [`${webBasePath}/_app`, `${webBasePath}/assets`, `${webBasePath}/runtime-config.js`] : [webBasePath]], maxPlaintextBytes: applicationPlaintextBytes, trustedEdgeProxies: config.trustedEdgeProxies });
4180
4606
  } catch {
4181
4607
  return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_storageProxyInvalid"));
4182
4608
  }
@@ -4185,8 +4611,15 @@ function createDockerSshProvider(config, container) {
4185
4611
  return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_webBaseConflict"));
4186
4612
  webProxy = generateStaticWebConfig(containerPort, webBasePath);
4187
4613
  }
4188
- const requestedOutput = options?.output ?? join(deploymentRoot, `bundle-${projectName}-${deploymentId}-${artifact.appVersion}.tar`);
4189
- const stem = basename(requestedOutput).replace(/\.tar$/i, "");
4614
+ if (https && webProxy)
4615
+ webProxy = renderHttpsProxy(webProxy, https, hasWebProxy ? webContainerPort : gateway ? 8080 : containerPort);
4616
+ if (https && primaryService === "serv" && !hasWebProxy) {
4617
+ const cors = server.data.cors;
4618
+ env.HAI_SERV = JSON.stringify({ ...server.data, cors: { ...cors, origin: !cors?.origin || cors.origin === "*" ? url : cors.origin } });
4619
+ }
4620
+ const requestedOutput = options?.output ?? join(deploymentRoot, `bundle-${projectName}-${deploymentId}-${artifact.appVersion}.tar.gz`);
4621
+ const compressed = requestedOutput.endsWith(".tar.gz");
4622
+ const stem = basename(requestedOutput).replace(/\.tar(?:\.gz)?$/i, "");
4190
4623
  const suffix = `-${artifact.appVersion}`;
4191
4624
  const name = stem.endsWith(suffix) ? stem : `${stem}${suffix}`;
4192
4625
  if (!/^[\w.+-]+$/.test(name))
@@ -4219,10 +4652,13 @@ function createDockerSshProvider(config, container) {
4219
4652
  containerPort,
4220
4653
  primaryService,
4221
4654
  requestAccessPolicy: requestAccessSnapshot !== void 0,
4655
+ nodeRuntime: primaryService === "serv" || scan.isSvelteKit,
4656
+ nodeOptions: env.NODE_OPTIONS,
4222
4657
  sidecar,
4223
4658
  web: hasWebProxy ? { imageRef: web?.imageRef ?? "localhost/hai-unselected:0", containerPort: webContainerPort } : void 0,
4224
4659
  mountWebConfig: standaloneWebConfig,
4225
- gateway
4660
+ gateway,
4661
+ https
4226
4662
  });
4227
4663
  const composePath = join(artifactDir, composeName);
4228
4664
  writeFileSync(composePath, composeContent);
@@ -4231,6 +4667,8 @@ function createDockerSshProvider(config, container) {
4231
4667
  const selected = options?.selectedServices ? [...options.selectedServices] : artifact.partial ? images.map((image) => image.name) : [...images.map((image) => image.name), ...Object.keys(sidecar.composeServices)];
4232
4668
  if (gateway && !partial)
4233
4669
  selected.push("gateway");
4670
+ if (https?.type === "letsencrypt" && !partial)
4671
+ selected.push("certbot");
4234
4672
  if (!selected.length || selected.some((name2) => !/^[a-z][a-z0-9-]*$/.test(name2)) || new Set(selected).size !== selected.length)
4235
4673
  return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_selectionInvalid", { params: { service: selected.join(", ") } }));
4236
4674
  for (const name2 of selected) {
@@ -4255,7 +4693,9 @@ function createDockerSshProvider(config, container) {
4255
4693
  containerPort,
4256
4694
  middlewareEnv: serializeEnvFile(sidecar.env),
4257
4695
  healthPaths,
4258
- output: join(artifactDir, `${name}.tar`)
4696
+ httpsAssets,
4697
+ output: join(artifactDir, `${name}.tar${compressed ? ".gz" : ""}`),
4698
+ cacheDirectory: join(resolveProjectDirectory(appDir), ".hai", "cache", "images")
4259
4699
  });
4260
4700
  if (options?.bundleOnly)
4261
4701
  return ok({ url: "", deploymentId, status: "packaged", provider: "docker-ssh", healthCheck: "skipped", envVarsSet: Object.keys(env), ...bundle });
@@ -4277,7 +4717,7 @@ function createDockerSshProvider(config, container) {
4277
4717
  return uploaded;
4278
4718
  let pendingOutput = "";
4279
4719
  const started = await ssh.exec(
4280
- `mkdir -p ${shellArgument(`${remoteTar}.contents`)} && chmod 600 ${shellArgument(remoteTar)} && tar -xf ${shellArgument(remoteTar)} -C ${shellArgument(`${remoteTar}.contents`)} && sh ${shellArgument(`${remoteTar}.contents/${bundle.startName}`)} ${shellArgument(remoteDir)}`,
4720
+ `mkdir -p ${shellArgument(`${remoteTar}.contents`)} && chmod 600 ${shellArgument(remoteTar)} && tar -xf ${shellArgument(remoteTar)} -C ${shellArgument(`${remoteTar}.contents`)} && sh ${shellArgument(`${remoteTar}.contents/${bundle.startName}`)} ${shellArgument(remoteDir)} && { rm -rf -- ${shellArgument(`${remoteTar}.contents`)} && rm -f -- ${shellArgument(remoteTar)} || printf '[WARN] Deployment passed; incoming archive cleanup failed\\n' >&2; }`,
4281
4721
  { timeoutMs: 6e5, secrets: sidecar.secrets, onOutput: (output) => {
4282
4722
  const parts = output.split("\n");
4283
4723
  for (let index = 0; index < parts.length; index++) {
@@ -4286,7 +4726,7 @@ function createDockerSshProvider(config, container) {
4286
4726
  break;
4287
4727
  const line = pendingOutput;
4288
4728
  pendingOutput = "";
4289
- const stage = /^\[(preflight|images|dependencies|start|health|complete)\]/.exec(line)?.[1];
4729
+ const stage = /^\[(preflight|images|dependencies|start|https|health|complete)\]/.exec(line)?.[1];
4290
4730
  if (stage)
4291
4731
  options?.onProgress?.(stage);
4292
4732
  }
@@ -4319,7 +4759,7 @@ ${terminal ? deployM("deploy_remoteFinished", { params: { stage: terminal.stage,
4319
4759
  logger5.info("Running health check", { url: applicationUrl });
4320
4760
  let healthy = true;
4321
4761
  for (const healthPath of healthPaths) {
4322
- if (!await healthCheck(`${url}${healthPath}`)) {
4762
+ if (!await healthCheck(`${url}${healthPath}`, httpsAssets?.["tls/fullchain.pem"])) {
4323
4763
  healthy = false;
4324
4764
  break;
4325
4765
  }
@@ -4606,7 +5046,7 @@ async function pollDeploymentStatus(token, deploymentId) {
4606
5046
  if (deployment.readyState === "ERROR" || deployment.readyState === "CANCELED") {
4607
5047
  return "error";
4608
5048
  }
4609
- await new Promise((resolve8) => setTimeout(resolve8, POLL_INTERVAL));
5049
+ await new Promise((resolve9) => setTimeout(resolve9, POLL_INTERVAL));
4610
5050
  }
4611
5051
  return "building";
4612
5052
  }
@@ -5141,7 +5581,7 @@ var deployCredentials = {
5141
5581
  saveAll: (entries) => saveCredentials(entries)
5142
5582
  };
5143
5583
  function mergeProvisionEnv(results, scan, options) {
5144
- let env = {};
5584
+ const env = {};
5145
5585
  const reachRequirements = scan?.middlewares?.filter((item) => item.configName === "reach") ?? [];
5146
5586
  const reachProviders = reachRequirements.map((item) => item.config);
5147
5587
  let hasReachOutput = false;
@@ -5156,13 +5596,16 @@ function mergeProvisionEnv(results, scan, options) {
5156
5596
  });
5157
5597
  if (targets.length) {
5158
5598
  for (const target of targets)
5159
- reachProviders[reachRequirements.indexOf(target)] = { ...provider, name: target.config.name ?? provider.name };
5599
+ reachProviders[reachRequirements.indexOf(target)] = { ...mergeManagedMiddlewareConfig(target, provider), name: target.config.name ?? provider.name };
5160
5600
  } else {
5161
5601
  reachProviders.push(provider);
5162
5602
  }
5163
5603
  }
5164
5604
  }
5165
- env = { ...env, ...prov.envVars };
5605
+ for (const [name, value] of Object.entries(prov.envVars)) {
5606
+ const requirement = scan?.middlewares?.find((item) => item.envPrefix === name);
5607
+ env[name] = requirement ? JSON.stringify(mergeManagedMiddlewareConfig(requirement, JSON.parse(value))) : value;
5608
+ }
5166
5609
  }
5167
5610
  if (hasReachOutput)
5168
5611
  env.HAI_REACH_PROVIDERS = JSON.stringify(reachProviders);
@@ -5583,7 +6026,8 @@ async function createDeploymentPlan(appDir, input, options = {}) {
5583
6026
  }
5584
6027
  }
5585
6028
  const sidecar = provider.type === "docker-ssh" ? planSidecars(directory, projectName, app, provisionEnv, effective, config.container?.s3Engine, { dbPassword: "", storagePassword: "", ldapPassword: "" }) : void 0;
5586
- const gateway = !!artifact && sidecar?.services.includes("storage") && !(artifact.primaryService === "serv" && artifact.hasWeb);
6029
+ const https = provider.type === "docker-ssh" ? provider.expose.https : void 0;
6030
+ const gateway = !!artifact && !(artifact.primaryService === "serv" && artifact.hasWeb) && (sidecar?.services.includes("storage") || !!https && (artifact.primaryService === "serv" || app.isSvelteKit));
5587
6031
  if (gateway && artifact.primaryService === "web" && !app.isSvelteKit)
5588
6032
  return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_storageBackendRequired"));
5589
6033
  if (gateway) {
@@ -5591,18 +6035,23 @@ async function createDeploymentPlan(appDir, input, options = {}) {
5591
6035
  if (!baseline.required)
5592
6036
  selected.push("gateway");
5593
6037
  }
6038
+ if (https?.type === "letsencrypt") {
6039
+ available.push("certbot");
6040
+ if (!baseline.required)
6041
+ selected.push("certbot");
6042
+ }
5594
6043
  const services = Object.entries(sidecar?.composeServices ?? {}).filter(([name]) => selected.includes(name));
5595
6044
  return ok({
5596
6045
  projectName,
5597
6046
  root,
5598
- appDir: directory,
6047
+ serviceDir: directory,
5599
6048
  environment: environment.data.environment,
5600
- target: provider.type === "docker-ssh" ? { provider: provider.type, host: provider.ssh?.host, port: provider.ssh?.port, directory: remoteDirectory, runtime: provider.remote.runtime, hostPort: provider.expose.hostPort } : { provider: provider.type },
6049
+ target: provider.type === "docker-ssh" ? { provider: provider.type, host: provider.ssh?.host, port: provider.ssh?.port, directory: remoteDirectory, runtime: provider.remote.runtime, hostPort: provider.expose.hostPort, https: https ? { type: https.type, domain: https.domain, port: https.port } : void 0 } : { provider: provider.type },
5601
6050
  artifact: artifact ? { directory: artifactDir, version: artifact.appVersion, buildId: artifact.buildId, verified: true, images: artifact.images.map((image) => ({ name: image.name, reference: image.imageRef, sha256: image.sha256 })) } : void 0,
5602
6051
  selected,
5603
6052
  retained: available.filter((name) => !selected.includes(name)),
5604
6053
  containers: provider.type === "docker-ssh" ? [...selected.filter((name) => images.includes(name) || name === "gateway").map((name) => `hai-${projectName}-${name}`), ...services.flatMap(([, service]) => typeof service.container_name === "string" ? [service.container_name] : [])] : [],
5605
- composeManagedServices: services.filter(([, service]) => typeof service.container_name !== "string").map(([name]) => name),
6054
+ composeManagedServices: [...services.filter(([, service]) => typeof service.container_name !== "string").map(([name]) => name), ...selected.includes("certbot") ? ["certbot"] : []],
5606
6055
  logicalVolumes: Object.keys(sidecar?.volumes ?? {}),
5607
6056
  middleware: middlewares.map((item) => ({ service: item.service, backend: item.backend, configuration: item.envPrefix, action: selected.includes(item.service) ? effective.serviceModes?.[item.envPrefix] ?? effective.serviceModes?.[item.service] ?? "default" : "keep", provisioner: provisioners.get(item.service) })),
5608
6057
  configuration: { files: [join(root, "hai.json"), ...configFiles].filter(existsSync), environmentKeys: Object.keys(environment.data.values).sort() },