@h-ai/deploy 1.0.0-beta.1 → 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({
@@ -117,9 +122,9 @@ var en_US_default = {
117
122
  deploy_webProxyInvalid: "The API prefix must be an absolute path containing only letters, numbers, underscores, hyphens and slashes.",
118
123
  deploy_webBaseConflict: "The website mount path conflicts with an API or health endpoint. Choose a different PUBLIC_BASE_PATH.",
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
- deploy_storagePublicEndpointInvalid: "Invalid storage.publicEndpoint. Use a browser-accessible HTTP(S) origin without a bucket, path, credentials, query, or fragment.",
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
@@ -225,9 +232,9 @@ var ja_JP_default = {
225
232
  deploy_webProxyInvalid: "API\u30D7\u30EC\u30D5\u30A3\u30C3\u30AF\u30B9\u306F\u3001\u82F1\u6570\u5B57\u3001\u30A2\u30F3\u30C0\u30FC\u30B9\u30B3\u30A2\u3001\u30CF\u30A4\u30D5\u30F3\u3001\u30B9\u30E9\u30C3\u30B7\u30E5\u306E\u307F\u3092\u542B\u3080\u7D76\u5BFE\u30D1\u30B9\u3067\u6307\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
226
233
  deploy_webBaseConflict: "\u30B5\u30A4\u30C8\u306E\u30DE\u30A6\u30F3\u30C8\u30D1\u30B9\u304CAPI\u307E\u305F\u306F\u30D8\u30EB\u30B9\u30C1\u30A7\u30C3\u30AF\u3068\u7AF6\u5408\u3057\u3066\u3044\u307E\u3059\u3002\u5225\u306EPUBLIC_BASE_PATH\u3092\u6307\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
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
- deploy_storagePublicEndpointInvalid: "storage.publicEndpoint \u304C\u7121\u52B9\u3067\u3059\u3002\u30D0\u30B1\u30C3\u30C8\u3001\u30D1\u30B9\u3001\u8A8D\u8A3C\u60C5\u5831\u3001\u30AF\u30A8\u30EA\u3001\u30D5\u30E9\u30B0\u30E1\u30F3\u30C8\u3092\u542B\u307E\u306A\u3044\u3001\u30D6\u30E9\u30A6\u30B6\u30FC\u304B\u3089\u5230\u9054\u53EF\u80FD\u306A HTTP(S) \u30AA\u30EA\u30B8\u30F3\u3092\u6307\u5B9A\u3057\u3066\u304F\u3060\u3055\u3044\u3002",
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
@@ -333,9 +342,9 @@ var zh_CN_default = {
333
342
  deploy_webProxyInvalid: "API \u524D\u7F00\u5FC5\u987B\u662F\u4EC5\u542B\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u4E0B\u5212\u7EBF\u3001\u8FDE\u5B57\u7B26\u548C\u659C\u6760\u7684\u7EDD\u5BF9\u8DEF\u5F84\u3002",
334
343
  deploy_webBaseConflict: "\u7F51\u7AD9\u6302\u8F7D\u76EE\u5F55\u4E0E API \u6216\u5065\u5EB7\u68C0\u67E5\u5165\u53E3\u51B2\u7A81\uFF0C\u8BF7\u4F7F\u7528\u4E0D\u540C\u7684 PUBLIC_BASE_PATH\u3002",
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
- deploy_storagePublicEndpointInvalid: "storage.publicEndpoint \u65E0\u6548\uFF1A\u8BF7\u586B\u5199\u6D4F\u89C8\u5668\u53EF\u8BBF\u95EE\u7684 HTTP(S) \u6E90\u5730\u5740\uFF0C\u4E0D\u80FD\u5305\u542B\u6876\u540D\u3001\u8DEF\u5F84\u3001\u51ED\u636E\u3001\u67E5\u8BE2\u6216\u7247\u6BB5\u3002",
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,12 +4148,22 @@ 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, webBasePath]) : "";
3795
- return `server {
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("");
4166
+ return `${options.storageBucket ? renderStorageUpstream(options.storageBucket, options.storageBasePath) : ""}server {
3796
4167
  listen ${webPort};
3797
4168
  server_name _;
3798
4169
  ${renderTrustedEdgeProxies(options.trustedEdgeProxies)} root /usr/share/nginx/html;
@@ -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;
@@ -3828,9 +4193,11 @@ ${storageLocation}${webLocation} location = ${webBasePath}/runtime-config.js {
3828
4193
  }
3829
4194
  `;
3830
4195
  }
3831
- function renderStorageLocation(bucket, reservedPaths) {
3832
- const path = `/${bucket}`;
3833
- if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket) || bucket.includes("..") || ["health", "_hai"].includes(bucket) || reservedPaths.some((reserved) => reserved && (reserved === path || reserved.startsWith(`${path}/`) || path.startsWith(`${reserved}/`)))) {
4196
+ function renderStorageLocation(bucket, reservedPaths, storageBasePath = "") {
4197
+ renderWebLocation(storageBasePath);
4198
+ const path = `${storageBasePath}/${bucket}`;
4199
+ const reserved = ["/health", "/_hai", ...reservedPaths].filter(Boolean);
4200
+ if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(bucket) || bucket.includes("..") || ["health", "_hai"].includes(bucket) || reserved.some((item) => item === path || item.startsWith(`${path}/`) || path.startsWith(`${item}/`) || storageBasePath && (item === storageBasePath || item.startsWith(`${storageBasePath}/`) || storageBasePath.startsWith(`${item}/`)))) {
3834
4201
  throw new Error("Storage bucket conflicts with application routes or is not a valid DNS bucket name");
3835
4202
  }
3836
4203
  return ` location ^~ ${path}/ {
@@ -3847,7 +4214,8 @@ function renderStorageLocation(bucket, reservedPaths) {
3847
4214
  add_header Cache-Control "private, no-store" always;
3848
4215
  limit_except GET PUT OPTIONS { deny all; }
3849
4216
  client_max_body_size 0;
3850
- proxy_pass http://storage:9000;
4217
+ ${storageBasePath ? ` # map \u4FDD\u7559\u539F\u59CB URI \u4E0E\u67E5\u8BE2\uFF1B\u4E0D\u5728 location \u5185\u521B\u5EFA\u6539\u53D8\u4EE3\u7406\u7EE7\u627F\u7684 if \u914D\u7F6E\u3002
4218
+ proxy_pass http://hai_storage_proxy$hai_storage_uri;` : " proxy_pass http://storage:9000;"}
3851
4219
  proxy_http_version 1.1;
3852
4220
  proxy_set_header Host $http_host;
3853
4221
  proxy_set_header Connection "";
@@ -3859,16 +4227,27 @@ function renderStorageLocation(bucket, reservedPaths) {
3859
4227
  }
3860
4228
  `;
3861
4229
  }
4230
+ function renderStorageUpstream(bucket, storageBasePath = "") {
4231
+ if (!storageBasePath)
4232
+ return "";
4233
+ const path = `${storageBasePath}/${bucket}`.replaceAll(".", "\\\\.");
4234
+ return `upstream hai_storage_proxy { server storage:9000; }
4235
+ map $request_uri $hai_storage_uri {
4236
+ default $request_uri;
4237
+ "~^${path}(/.*)$" /${bucket}$1;
4238
+ }
4239
+ `;
4240
+ }
3862
4241
  function generateApplicationProxyConfig(options) {
3863
4242
  const { primaryService, port, bucket, reservedPaths, maxPlaintextBytes } = options;
3864
4243
  if (!Number.isInteger(port) || port < 1 || port > 65535)
3865
4244
  throw new Error("Invalid application proxy target");
3866
4245
  const bodyLimits = resolveTransportBodyLimits(maxPlaintextBytes);
3867
- return `server {
4246
+ return `${bucket ? renderStorageUpstream(bucket, options.storageBasePath) : ""}server {
3868
4247
  listen 8080;
3869
4248
  server_name _;
3870
4249
  ${renderTrustedEdgeProxies(options.trustedEdgeProxies)} large_client_header_buffers 4 32k;
3871
- ${renderStorageLocation(bucket, reservedPaths)} location = /_hai/gateway-health {
4250
+ ${bucket ? renderStorageLocation(bucket, reservedPaths, options.storageBasePath) : ""} location = /_hai/gateway-health {
3872
4251
  access_log off;
3873
4252
  return 200 'ok';
3874
4253
  }
@@ -3976,9 +4355,22 @@ function prepareRequestAccess(options) {
3976
4355
  }
3977
4356
  }
3978
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
+ }
3979
4371
  var ServerResultSchema = z.object({
3980
4372
  deploymentId: z.string(),
3981
- stage: z.enum(["preflight", "credentials", "recover", "images", "dependencies", "start", "health", "complete"]),
4373
+ stage: z.enum(["preflight", "credentials", "recover", "images", "dependencies", "start", "https", "health", "complete"]),
3982
4374
  exitCode: z.number().int().min(0).max(255),
3983
4375
  durationSeconds: z.number().int().nonnegative(),
3984
4376
  recoveryStatus: z.enum(["not-needed", "passed", "failed", "stopped"]).optional()
@@ -3990,7 +4382,7 @@ function makeDeploymentId() {
3990
4382
  return `${stamp}-${randomBytes(3).toString("hex")}`;
3991
4383
  }
3992
4384
  function sleep(ms) {
3993
- return new Promise((resolve8) => setTimeout(resolve8, ms));
4385
+ return new Promise((resolve9) => setTimeout(resolve9, ms));
3994
4386
  }
3995
4387
  function createDockerSshProvider(config, container) {
3996
4388
  const ssh = config.ssh ? createSshExecutor(config.ssh) : void 0;
@@ -4014,13 +4406,28 @@ function createDockerSshProvider(config, container) {
4014
4406
  deployM("deploy_remoteRuntimeMissing", { params: { runtime: candidates.join(" / ") } })
4015
4407
  );
4016
4408
  }
4017
- async function healthCheck(url) {
4409
+ async function healthCheck(url, certificate) {
4018
4410
  for (let attempt = 0; attempt < HEALTH_MAX_ATTEMPTS; attempt++) {
4019
4411
  try {
4020
- const response = await fetch(url, { signal: AbortSignal.timeout(5e3) });
4021
- await response.body?.cancel();
4022
- if (response.ok)
4023
- 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
+ }
4024
4431
  } catch {
4025
4432
  }
4026
4433
  await sleep(HEALTH_POLL_INTERVAL);
@@ -4031,6 +4438,8 @@ function createDockerSshProvider(config, container) {
4031
4438
  const { appDir, projectName, scan, provisionEnv, appEnv, options } = context;
4032
4439
  const deploymentId = makeDeploymentId();
4033
4440
  const hostPort = config.expose.hostPort;
4441
+ const https = config.expose.https;
4442
+ const httpsAssets = https ? createHttpsAssets(https, resolveProjectDirectory(appDir)) : void 0;
4034
4443
  const deploymentRoot = join(resolveProjectDirectory(appDir), ".hai", "deploy", options?.environment ?? "prod");
4035
4444
  const artifact = context.artifact;
4036
4445
  const resolvedBuildDir = context.artifactDir;
@@ -4049,26 +4458,32 @@ function createDockerSshProvider(config, container) {
4049
4458
  const containerPort = app?.containerPort ?? (primaryService === "serv" ? artifact.servContainerPort : artifact.webContainerPort) ?? config.expose.containerPort;
4050
4459
  const webContainerPort = web?.containerPort ?? artifact.webContainerPort ?? 8080;
4051
4460
  const host = config.ssh?.host;
4052
- 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}`;
4053
4462
  const webBasePath = artifact.webBasePath ?? "";
4054
4463
  const applicationUrl = `${url}${webBasePath ? `${webBasePath}/` : ""}`;
4055
- const standaloneWebConfig = primaryService === "web" && !scan.isSvelteKit && !!webBasePath;
4056
- 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"];
4057
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;
4058
4469
  const managedStorage = sidecar.services.includes("storage") ? JSON.parse(sidecar.env.HAI_STORAGE) : void 0;
4470
+ let storageBasePath = "";
4059
4471
  if (managedStorage?.publicEndpoint !== void 0) {
4060
4472
  try {
4061
4473
  if (typeof managedStorage.publicEndpoint !== "string")
4062
4474
  throw new Error("Invalid public endpoint type");
4475
+ const unsafeCharacters = /[\u0000-\u0020\u007F\\%]/.test(managedStorage.publicEndpoint);
4063
4476
  const endpoint = new URL(managedStorage.publicEndpoint);
4064
- if (!["http:", "https:"].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.pathname !== "/" || endpoint.search || endpoint.hash)
4477
+ if (!["http:", "https:"].includes(endpoint.protocol) || endpoint.username || endpoint.password || endpoint.search || endpoint.hash || !/^\/(?:[\w.-]+(?:\/[\w.-]+)*\/?)?$/.test(endpoint.pathname) || unsafeCharacters || /(?:^|\/)\.{1,2}(?:\/|$)/.test(managedStorage.publicEndpoint)) {
4065
4478
  throw new Error("Invalid public endpoint origin");
4479
+ }
4480
+ storageBasePath = endpoint.pathname.replace(/\/+$/, "");
4066
4481
  } catch {
4067
4482
  return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_storagePublicEndpointInvalid"));
4068
4483
  }
4069
4484
  }
4070
4485
  const storageRouting = managedStorage ? createHash("sha256").update(JSON.stringify({ ...managedStorage, secretAccessKey: void 0 })).digest("hex") : "";
4071
- const gateway = !!managedStorage && !hasWebProxy;
4486
+ const gateway = !hasWebProxy && (!!managedStorage || !!https && !standaloneWebConfig);
4072
4487
  if (gateway && primaryService === "web" && !scan.isSvelteKit)
4073
4488
  return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_storageBackendRequired"));
4074
4489
  const proxyService = gateway ? "gateway" : "web";
@@ -4079,6 +4494,35 @@ function createDockerSshProvider(config, container) {
4079
4494
  const server = existsSync(serverPath) || hasWebProxy ? loadDeploymentConfig("serv", serverPath, env) : ok({});
4080
4495
  if (!server.success)
4081
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
+ }
4082
4526
  let requestAccessSnapshot;
4083
4527
  if (updatesApp && primaryService === "serv") {
4084
4528
  const prepared2 = prepareRequestAccess({ settings: server.data, module: "serv", directory: appDir });
@@ -4140,25 +4584,25 @@ function createDockerSshProvider(config, container) {
4140
4584
  if (typeof apiPrefix !== "string" || !/^\/[\w/-]+$/.test(apiPrefix)) {
4141
4585
  return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_webProxyInvalid"));
4142
4586
  }
4143
- 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(/\/+$/, "")}/`)))
4144
4588
  return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_webBaseConflict"));
4145
4589
  const transport = server.data.transport;
4146
4590
  try {
4147
4591
  const configured = transport ? transport.maxPlaintextBytes : void 0;
4148
4592
  const limits = resolveTransportBodyLimits(Math.max(createTransportBodyPolicy(configured === "auto" ? void 0 : configured, transport ? transport.routePlaintextBytes : void 0).maximum.plaintextBytes, feedbackTransportBytes ?? 0));
4149
- webProxy = generateWebProxyConfig(containerPort, apiPrefix, { webPort: webContainerPort, maxPlaintextBytes: limits.plaintextBytes, webBasePath, storageBucket: managedStorage?.bucket, trustedEdgeProxies: config.trustedEdgeProxies });
4593
+ webProxy = generateWebProxyConfig(containerPort, apiPrefix, { webPort: webContainerPort, maxPlaintextBytes: limits.plaintextBytes, webBasePath, backendPaths, storageBucket: managedStorage?.bucket, storageBasePath, trustedEdgeProxies: config.trustedEdgeProxies });
4150
4594
  } catch (error) {
4151
4595
  return err(HaiDeployError.CONFIG_ERROR, error instanceof RangeError ? error.message : deployM(managedStorage ? "deploy_storageProxyInvalid" : "deploy_transportBodyLimitInvalid"));
4152
4596
  }
4153
- env.HAI_SERV = JSON.stringify({ ...server.data, cors: { ...cors, origin: (!cors?.origin || cors.origin === "*") && config.ssh ? url : cors?.origin ?? "*" } });
4154
- } 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) {
4155
4599
  try {
4156
4600
  const http = server.data.http;
4157
4601
  if (primaryService === "serv") {
4158
4602
  const transport = server.data.transport;
4159
4603
  applicationPlaintextBytes = Math.max(createTransportBodyPolicy(transport?.maxPlaintextBytes === "auto" ? void 0 : transport?.maxPlaintextBytes, transport?.routePlaintextBytes).maximum.plaintextBytes, feedbackTransportBytes ?? 0);
4160
4604
  }
4161
- webProxy = generateApplicationProxyConfig({ primaryService, port: containerPort, bucket: managedStorage.bucket, reservedPaths: [webBasePath, http?.apiPrefix ?? "/api/v1"], 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 });
4162
4606
  } catch {
4163
4607
  return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_storageProxyInvalid"));
4164
4608
  }
@@ -4167,8 +4611,15 @@ function createDockerSshProvider(config, container) {
4167
4611
  return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_webBaseConflict"));
4168
4612
  webProxy = generateStaticWebConfig(containerPort, webBasePath);
4169
4613
  }
4170
- const requestedOutput = options?.output ?? join(deploymentRoot, `bundle-${projectName}-${deploymentId}-${artifact.appVersion}.tar`);
4171
- 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, "");
4172
4623
  const suffix = `-${artifact.appVersion}`;
4173
4624
  const name = stem.endsWith(suffix) ? stem : `${stem}${suffix}`;
4174
4625
  if (!/^[\w.+-]+$/.test(name))
@@ -4201,10 +4652,13 @@ function createDockerSshProvider(config, container) {
4201
4652
  containerPort,
4202
4653
  primaryService,
4203
4654
  requestAccessPolicy: requestAccessSnapshot !== void 0,
4655
+ nodeRuntime: primaryService === "serv" || scan.isSvelteKit,
4656
+ nodeOptions: env.NODE_OPTIONS,
4204
4657
  sidecar,
4205
4658
  web: hasWebProxy ? { imageRef: web?.imageRef ?? "localhost/hai-unselected:0", containerPort: webContainerPort } : void 0,
4206
4659
  mountWebConfig: standaloneWebConfig,
4207
- gateway
4660
+ gateway,
4661
+ https
4208
4662
  });
4209
4663
  const composePath = join(artifactDir, composeName);
4210
4664
  writeFileSync(composePath, composeContent);
@@ -4213,6 +4667,8 @@ function createDockerSshProvider(config, container) {
4213
4667
  const selected = options?.selectedServices ? [...options.selectedServices] : artifact.partial ? images.map((image) => image.name) : [...images.map((image) => image.name), ...Object.keys(sidecar.composeServices)];
4214
4668
  if (gateway && !partial)
4215
4669
  selected.push("gateway");
4670
+ if (https?.type === "letsencrypt" && !partial)
4671
+ selected.push("certbot");
4216
4672
  if (!selected.length || selected.some((name2) => !/^[a-z][a-z0-9-]*$/.test(name2)) || new Set(selected).size !== selected.length)
4217
4673
  return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_selectionInvalid", { params: { service: selected.join(", ") } }));
4218
4674
  for (const name2 of selected) {
@@ -4237,7 +4693,9 @@ function createDockerSshProvider(config, container) {
4237
4693
  containerPort,
4238
4694
  middlewareEnv: serializeEnvFile(sidecar.env),
4239
4695
  healthPaths,
4240
- output: join(artifactDir, `${name}.tar`)
4696
+ httpsAssets,
4697
+ output: join(artifactDir, `${name}.tar${compressed ? ".gz" : ""}`),
4698
+ cacheDirectory: join(resolveProjectDirectory(appDir), ".hai", "cache", "images")
4241
4699
  });
4242
4700
  if (options?.bundleOnly)
4243
4701
  return ok({ url: "", deploymentId, status: "packaged", provider: "docker-ssh", healthCheck: "skipped", envVarsSet: Object.keys(env), ...bundle });
@@ -4259,7 +4717,7 @@ function createDockerSshProvider(config, container) {
4259
4717
  return uploaded;
4260
4718
  let pendingOutput = "";
4261
4719
  const started = await ssh.exec(
4262
- `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; }`,
4263
4721
  { timeoutMs: 6e5, secrets: sidecar.secrets, onOutput: (output) => {
4264
4722
  const parts = output.split("\n");
4265
4723
  for (let index = 0; index < parts.length; index++) {
@@ -4268,7 +4726,7 @@ function createDockerSshProvider(config, container) {
4268
4726
  break;
4269
4727
  const line = pendingOutput;
4270
4728
  pendingOutput = "";
4271
- const stage = /^\[(preflight|images|dependencies|start|health|complete)\]/.exec(line)?.[1];
4729
+ const stage = /^\[(preflight|images|dependencies|start|https|health|complete)\]/.exec(line)?.[1];
4272
4730
  if (stage)
4273
4731
  options?.onProgress?.(stage);
4274
4732
  }
@@ -4301,7 +4759,7 @@ ${terminal ? deployM("deploy_remoteFinished", { params: { stage: terminal.stage,
4301
4759
  logger5.info("Running health check", { url: applicationUrl });
4302
4760
  let healthy = true;
4303
4761
  for (const healthPath of healthPaths) {
4304
- if (!await healthCheck(`${url}${healthPath}`)) {
4762
+ if (!await healthCheck(`${url}${healthPath}`, httpsAssets?.["tls/fullchain.pem"])) {
4305
4763
  healthy = false;
4306
4764
  break;
4307
4765
  }
@@ -4588,7 +5046,7 @@ async function pollDeploymentStatus(token, deploymentId) {
4588
5046
  if (deployment.readyState === "ERROR" || deployment.readyState === "CANCELED") {
4589
5047
  return "error";
4590
5048
  }
4591
- await new Promise((resolve8) => setTimeout(resolve8, POLL_INTERVAL));
5049
+ await new Promise((resolve9) => setTimeout(resolve9, POLL_INTERVAL));
4592
5050
  }
4593
5051
  return "building";
4594
5052
  }
@@ -5123,7 +5581,7 @@ var deployCredentials = {
5123
5581
  saveAll: (entries) => saveCredentials(entries)
5124
5582
  };
5125
5583
  function mergeProvisionEnv(results, scan, options) {
5126
- let env = {};
5584
+ const env = {};
5127
5585
  const reachRequirements = scan?.middlewares?.filter((item) => item.configName === "reach") ?? [];
5128
5586
  const reachProviders = reachRequirements.map((item) => item.config);
5129
5587
  let hasReachOutput = false;
@@ -5138,13 +5596,16 @@ function mergeProvisionEnv(results, scan, options) {
5138
5596
  });
5139
5597
  if (targets.length) {
5140
5598
  for (const target of targets)
5141
- 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 };
5142
5600
  } else {
5143
5601
  reachProviders.push(provider);
5144
5602
  }
5145
5603
  }
5146
5604
  }
5147
- 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
+ }
5148
5609
  }
5149
5610
  if (hasReachOutput)
5150
5611
  env.HAI_REACH_PROVIDERS = JSON.stringify(reachProviders);
@@ -5565,7 +6026,8 @@ async function createDeploymentPlan(appDir, input, options = {}) {
5565
6026
  }
5566
6027
  }
5567
6028
  const sidecar = provider.type === "docker-ssh" ? planSidecars(directory, projectName, app, provisionEnv, effective, config.container?.s3Engine, { dbPassword: "", storagePassword: "", ldapPassword: "" }) : void 0;
5568
- 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));
5569
6031
  if (gateway && artifact.primaryService === "web" && !app.isSvelteKit)
5570
6032
  return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_storageBackendRequired"));
5571
6033
  if (gateway) {
@@ -5573,18 +6035,23 @@ async function createDeploymentPlan(appDir, input, options = {}) {
5573
6035
  if (!baseline.required)
5574
6036
  selected.push("gateway");
5575
6037
  }
6038
+ if (https?.type === "letsencrypt") {
6039
+ available.push("certbot");
6040
+ if (!baseline.required)
6041
+ selected.push("certbot");
6042
+ }
5576
6043
  const services = Object.entries(sidecar?.composeServices ?? {}).filter(([name]) => selected.includes(name));
5577
6044
  return ok({
5578
6045
  projectName,
5579
6046
  root,
5580
- appDir: directory,
6047
+ serviceDir: directory,
5581
6048
  environment: environment.data.environment,
5582
- 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 },
5583
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,
5584
6051
  selected,
5585
6052
  retained: available.filter((name) => !selected.includes(name)),
5586
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] : [])] : [],
5587
- 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"] : []],
5588
6055
  logicalVolumes: Object.keys(sidecar?.volumes ?? {}),
5589
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) })),
5590
6057
  configuration: { files: [join(root, "hai.json"), ...configFiles].filter(existsSync), environmentKeys: Object.keys(environment.data.values).sort() },