@h-ai/deploy 0.1.0-alpha.66 → 0.1.0-alpha.67

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,11 +1,11 @@
1
1
  import { createHash, randomBytes } from 'crypto';
2
- import { createReadStream, readFileSync, existsSync, lstatSync, writeFileSync, chmodSync, mkdirSync, mkdtempSync, copyFileSync, rmSync, statSync, readdirSync } from 'fs';
2
+ import { createReadStream, readFileSync, existsSync, lstatSync, writeFileSync, chmodSync, mkdirSync, mkdtempSync, copyFileSync, renameSync, rmSync, statSync, readdirSync } from 'fs';
3
3
  import { resolve, join, basename, dirname, relative, sep } from 'path';
4
4
  import { z } from 'zod';
5
- import { core, ok, err } from '@h-ai/core';
5
+ import { core, err, ok } from '@h-ai/core';
6
6
  import process2 from 'process';
7
7
  import { parseEnv } from 'util';
8
- import { parse, stringify, parseAllDocuments } from 'yaml';
8
+ import { LineCounter, parseDocument, parse, stringify, parseAllDocuments } from 'yaml';
9
9
  import { homedir } from 'os';
10
10
  import { spawn } from 'child_process';
11
11
  import { Buffer } from 'buffer';
@@ -13,25 +13,33 @@ import { Buffer } from 'buffer';
13
13
  // src/deploy-artifact.ts
14
14
  var DeploymentArtifactSchema = z.object({
15
15
  /** 清单格式版本,与应用版本和部署环境无关。 */
16
- version: z.literal(2),
16
+ version: z.literal(3),
17
17
  /** 应用根 package.json 的版本,用于归档、Compose 与桌面交付;最长 80 字符。 */
18
18
  appVersion: z.string().max(80).regex(/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9a-z-]+(?:\.[0-9a-z-]+)*)?(?:\+[0-9a-z-]+(?:\.[0-9a-z-]+)*)?$/i),
19
- /** 一次完整构建的标识,app/web 必须来自同一份清单。 */
19
+ /** 本次构建标识;同一应用版本允许重复构建。 */
20
20
  buildId: z.string().regex(/^[a-z0-9][\w.+-]*$/i),
21
- /** API/独立应用和可选 Web 的镜像归档,至少包含一个 app。 */
21
+ /** 只包含所选镜像的构建不能用于首次安装。 */
22
+ partial: z.boolean().default(false),
23
+ /** API 单独构建时仍保留工程的 Web 拓扑。 */
24
+ hasWeb: z.boolean().default(false),
25
+ /** 工程显式配置的应用端口,部分构建未包含 app 镜像时仍保留;缺省由部署配置决定。 */
26
+ appContainerPort: z.number().int().min(1).max(65535).optional(),
27
+ /** 工程显式配置的 Web 端口,部分构建未包含 web 镜像时仍保留;缺省使用部署默认值。 */
28
+ webContainerPort: z.number().int().min(1).max(65535).optional(),
29
+ /** 本次构建选择的镜像归档。 */
22
30
  images: z.array(z.object({
23
31
  /** app 为服务或独立应用,web 为同源 API 代理的静态前端。 */
24
32
  name: z.enum(["app", "web"]),
25
33
  /** 相对于清单目录的 tar 文件名。 */
26
34
  file: z.string().regex(/^[\w.+-]+\.tar$/),
27
35
  /** 归档导入后可直接使用的不可变镜像引用。 */
28
- imageRef: z.string().regex(/^[a-z0-9][a-z0-9./:_-]+$/),
36
+ imageRef: z.string().regex(/^[a-z0-9][a-z0-9./:_-]*:[a-z0-9_][a-z0-9_.-]*$/).refine((value) => !value.endsWith(":latest")),
29
37
  /** tar 内容的 SHA-256,用于部署前校验。 */
30
38
  sha256: z.string().regex(/^[a-f0-9]{64}$/),
31
39
  /** 容器内部监听端口;服务器公开端口由部署选择。 */
32
40
  containerPort: z.number().int().min(1).max(65535).optional()
33
- })).min(1).max(2).refine((images) => images.filter((image) => image.name === "app").length === 1 && new Set(images.map((image) => image.name)).size === images.length)
34
- }).refine((artifact) => artifact.images.every((image) => image.file === `${image.name}-${artifact.appVersion}.tar`));
41
+ })).min(1).max(2).refine((images) => new Set(images.map((image) => image.name)).size === images.length)
42
+ }).refine((artifact) => artifact.images.every((image) => image.file === `${image.name}-${artifact.appVersion}.tar`)).refine((artifact) => artifact.partial || artifact.images.some((image) => image.name === "app")).refine((artifact) => artifact.partial || !artifact.hasWeb || artifact.images.some((image) => image.name === "web"));
35
43
  async function artifactChecksum(file) {
36
44
  const hash = createHash("sha256");
37
45
  for await (const chunk of createReadStream(file))
@@ -99,11 +107,21 @@ var en_US_default = {
99
107
  deploy_purposeEmail: "Email/HTTP delivery; the default SMTP container only captures test messages",
100
108
  deploy_purposeSms: "SMS through Aliyun credentials; requires an existing signature and quota",
101
109
  deploy_webProxyInvalid: "The API prefix must be an absolute path containing only letters, numbers, underscores, hyphens and slashes.",
102
- deploy_artifactInvalid: "Build artifacts are missing, corrupt, or could not be packaged. Run pnpm build --target docker at the project root and point --artifact-dir to a complete build directory.",
110
+ deploy_artifactInvalid: "Image artifacts are missing or invalid. Run pnpm build --target docker, select valid --artifact-dir, or use pnpm run deploy --build.",
103
111
  deploy_sshRequired: "Automatic deployment requires SSH settings. Use --bundle-only for manual transfer when the server is unreachable.",
104
- deploy_bundleInstructions: "This bundle contains private configuration. Transfer it securely and restrict access.\n1. Copy {file} to Linux using removable media, a private network or scp.\n2. Install Docker/Podman with compose, tar, sha256sum and sh. Load middleware images using docker load/podman load with the exact tags below.\n3. Create a private release directory: mkdir -p release && chmod 700 release\n4. From the bundle directory run tar -xf {file} -C release\n5. Run cd release && sh {start}. Set HAI_DEPLOY_PROVIDER_REMOTE_RUNTIME=docker or podman to select the runtime explicitly.\n6. Check docker compose -f {compose} --env-file .env.runtime ps (or logs; use podman instead of docker for Podman), Web and /health after startup. Bundle generation does not perform server health checks.\nRequired images (app/web are included; prepare middleware separately):\n{images}\nBuilt images can be reused across environments; this bundle contains configuration for the selected environment only.",
112
+ deploy_bundleInstructions: "Bundle: {file}\nIncludes all images needed by selected services, including self-hosted middleware.\nWith SSH access, run deploy.ps1 (Windows) or deploy.sh (Linux/macOS) from the adjacent deployment directory.\nFor isolated servers, copy and extract the tar into a separate directory, then run sh apply.sh /opt/hai/apps/project-environment.\nLinux requires Docker/Podman, Compose, sh, tar, sha256sum, awk, curl and filesystem/container permissions.\nPartial updates require a successful full installation with matching ports/layout. Steps return nonzero on failure; result-DEPLOYMENT_ID.json records the outcome.\nThe bundle contains private runtime configuration; transfer it securely.\nIncluded images:\n{images}",
105
113
  deploy_bundleMetadata: "Application version: {version}\nBuild ID: {buildId}\nCompose file: {compose}",
106
- deploy_bundleOnlyContainer: "Compose bundle generation is supported only by docker-ssh."
114
+ deploy_bundleOnlyContainer: "Compose bundle generation is supported only by docker-ssh.",
115
+ deploy_selectedContainerOnly: "Selected-service deployment requires a docker-ssh Compose destination.",
116
+ deploy_selectionInvalid: "Cannot deploy {service}: check the service name and build its image first.",
117
+ deploy_imageVersionRequired: "Specify an image version; latest is not allowed: {image}",
118
+ deploy_bundleRuntimeRequired: "Start local Podman/Docker to export middleware images, or prepare this complete offline bundle on the build machine.",
119
+ deploy_bundleFailed: "Package preparation or deployment failed: {reason}",
120
+ deploy_configFileInvalid: "Invalid YAML in {path}, line {line}. Check indentation, colons and quotes.",
121
+ deploy_configFileUnreadable: "Cannot read configuration file {path}. Check its path and permissions.",
122
+ deploy_bundleNameInvalid: "Bundle filenames may contain letters, numbers, dots, plus signs, hyphens and underscores; parent directories may contain spaces.",
123
+ deploy_secretsInvalid: "Middleware credentials are damaged or incomplete: {path}. Restore a backup; passwords are not regenerated because existing data may become inaccessible.",
124
+ deploy_storageArtifactRequired: "Storage initialization requires the application image. Select a complete artifact containing app with --artifact-dir, or run pnpm build --target docker --images app first."
107
125
  };
108
126
 
109
127
  // messages/zh-CN.json
@@ -157,11 +175,21 @@ var zh_CN_default = {
157
175
  deploy_purposeEmail: "\u90AE\u4EF6\u6216 HTTP \u901A\u77E5\u6295\u9012\uFF1B\u9ED8\u8BA4 SMTP \u5BB9\u5668\u4EC5\u7528\u4E8E\u6D4B\u8BD5\u63A5\u6536",
158
176
  deploy_purposeSms: "\u901A\u8FC7\u963F\u91CC\u4E91\u51ED\u8BC1\u63A5\u5165\u77ED\u4FE1\u670D\u52A1\uFF0C\u9700\u5DF2\u6709\u7B7E\u540D\u4E0E\u989D\u5EA6",
159
177
  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",
160
- deploy_artifactInvalid: "\u6784\u5EFA\u4EA7\u7269\u7F3A\u5931\u3001\u635F\u574F\u6216\u90E8\u7F72\u6253\u5305\u5931\u8D25\u3002\u8BF7\u5148\u5728\u5DE5\u7A0B\u6839\u8FD0\u884C pnpm build --target docker\uFF0C\u5E76\u68C0\u67E5 --artifact-dir \u6307\u5411\u5B8C\u6574\u7684\u6784\u5EFA\u76EE\u5F55\u3002",
178
+ 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",
161
179
  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",
162
- deploy_bundleInstructions: "\u6B64\u5305\u5305\u542B\u79C1\u6709\u914D\u7F6E\uFF0C\u8BF7\u5B89\u5168\u642C\u8FD0\u5E76\u9650\u5236\u8BBF\u95EE\u6743\u9650\u3002\n1. \u5C06 {file} \u590D\u5236\u5230 Linux \u670D\u52A1\u5668\uFF08U \u76D8\u3001\u5185\u7F51\u4F20\u8F93\u6216 scp \u5747\u53EF\uFF09\u3002\n2. \u51C6\u5907 Docker/Podman\u3001\u5BF9\u5E94 compose \u547D\u4EE4\u3001tar\u3001sha256sum \u548C sh\uFF1B\u5C06\u6240\u9700\u4E2D\u95F4\u4EF6\u955C\u50CF\u7528 docker load/podman load \u5BFC\u5165\uFF0C\u955C\u50CF\u6807\u7B7E\u987B\u4E0E\u4E0B\u5217\u6E05\u5355\u5B8C\u5168\u4E00\u81F4\u3002\n3. \u65B0\u5EFA\u4EC5\u90E8\u7F72\u7528\u6237\u53EF\u8BBF\u95EE\u7684\u76EE\u5F55\uFF1Amkdir -p release && chmod 700 release\n4. \u5728\u5305\u6240\u5728\u76EE\u5F55\u6267\u884C tar -xf {file} -C release\n5. \u6267\u884C cd release && sh {start}\uFF1B\u53EF\u7528 HAI_DEPLOY_PROVIDER_REMOTE_RUNTIME=docker \u6216 podman \u663E\u5F0F\u9009\u62E9\u8FD0\u884C\u65F6\u3002\n6. \u542F\u52A8\u540E\u7528 docker compose -f {compose} --env-file .env.runtime ps\uFF08\u6216 logs\uFF1BPodman \u5C06 docker \u6362\u4E3A podman\uFF09\u68C0\u67E5\u72B6\u6001\u5E76\u9A8C\u8BC1 Web\u3001/health\uFF1B\u4EC5\u6253\u5305\u4E0D\u4F1A\u6267\u884C\u670D\u52A1\u5668\u5065\u5EB7\u68C0\u67E5\u3002\n\u6240\u9700\u955C\u50CF\uFF08app/web \u5DF2\u542B\u5728\u5305\u4E2D\uFF0C\u4E2D\u95F4\u4EF6\u9700\u53E6\u884C\u51C6\u5907\uFF09\uFF1A\n{images}\n\u6784\u5EFA\u955C\u50CF\u53EF\u7528\u4E8E\u591A\u73AF\u5883\uFF1B\u6B64\u5305\u7684\u73AF\u5883\u914D\u7F6E\u4EC5\u7528\u4E8E\u672C\u6B21\u6240\u9009\u73AF\u5883\u3002",
180
+ deploy_bundleInstructions: "\u90E8\u7F72\u5305\uFF1A{file}\n\u5305\u4E2D\u5DF2\u5305\u542B\u672C\u6B21\u9009\u62E9\u670D\u52A1\u9700\u8981\u7684\u955C\u50CF\uFF0C\u5305\u62EC\u81EA\u5EFA\u4E2D\u95F4\u4EF6\u3002\n\u6709 SSH \u8FDE\u63A5\u65F6\uFF0C\u5728\u65C1\u8FB9\u7684\u90E8\u7F72\u76EE\u5F55\u8FD0\u884C deploy.ps1\uFF08Windows\uFF09\u6216 deploy.sh\uFF08Linux/macOS\uFF09\u3002\n\u5B8C\u5168\u9694\u79BB\u65F6\uFF0C\u590D\u5236 tar \u5230 Linux \u670D\u52A1\u5668\uFF0C\u5728\u72EC\u7ACB\u76EE\u5F55\u89E3\u5305\uFF0C\u518D\u6267\u884C sh apply.sh /opt/hai/apps/\u9879\u76EE\u73AF\u5883\u3002\n\u670D\u52A1\u5668\u9700\u8981 Docker/Podman\u3001Compose\u3001sh\u3001tar\u3001sha256sum\u3001awk \u548C curl\uFF0C\u4EE5\u53CA\u76EE\u5F55\u3001\u5BB9\u5668\u8FD0\u884C\u6743\u9650\u3002\n\u90E8\u5206\u66F4\u65B0\u8981\u6C42\u670D\u52A1\u5668\u5DF2\u6709\u6210\u529F\u7684\u5B8C\u6574\u90E8\u7F72\uFF0C\u7AEF\u53E3\u53CA\u5E94\u7528\u5E03\u5C40\u4FDD\u6301\u4E00\u81F4\u3002\u6BCF\u6B65\u5931\u8D25\u4F1A\u8FD4\u56DE\u975E\u96F6\u9000\u51FA\u7801\uFF1B\u7ED3\u679C\u8BB0\u5F55\u5728\u90E8\u7F72\u76EE\u5F55 result-\u90E8\u7F72ID.json\u3002\n\u5305\u5305\u542B\u79C1\u6709\u8FD0\u884C\u914D\u7F6E\uFF0C\u8BF7\u5B89\u5168\u4F20\u8F93\u3002\n\u6240\u542B\u955C\u50CF\uFF1A\n{images}",
163
181
  deploy_bundleMetadata: "\u5E94\u7528\u7248\u672C\uFF1A{version}\n\u6784\u5EFA\u7F16\u53F7\uFF1A{buildId}\nCompose \u6587\u4EF6\uFF1A{compose}",
164
- deploy_bundleOnlyContainer: "\u4EC5\u751F\u6210 Compose tar \u53EA\u652F\u6301 docker-ssh\u3002"
182
+ deploy_bundleOnlyContainer: "\u4EC5\u751F\u6210 Compose tar \u53EA\u652F\u6301 docker-ssh\u3002",
183
+ deploy_selectedContainerOnly: "\u6309\u670D\u52A1\u5C40\u90E8\u90E8\u7F72\u4EC5\u9002\u7528\u4E8E docker-ssh Compose \u76EE\u6807\u3002",
184
+ deploy_selectionInvalid: "\u65E0\u6CD5\u90E8\u7F72\u670D\u52A1 {service}\uFF1A\u8BF7\u68C0\u67E5\u670D\u52A1\u540D\u79F0\uFF0C\u5E76\u5148\u6784\u5EFA\u5BF9\u5E94\u955C\u50CF\u3002",
185
+ deploy_storageArtifactRequired: "\u5B58\u50A8\u521D\u59CB\u5316\u9700\u8981\u5E94\u7528\u955C\u50CF\u3002\u8BF7\u7528 --artifact-dir \u9009\u62E9\u542B app \u955C\u50CF\u7684\u5B8C\u6574\u5236\u54C1\uFF0C\u6216\u5148\u6267\u884C pnpm build --target docker --images app\u3002",
186
+ deploy_imageVersionRequired: "\u955C\u50CF\u5FC5\u987B\u6307\u5B9A\u7248\u672C\uFF0C\u4E0D\u80FD\u4F7F\u7528 latest\uFF1A{image}",
187
+ deploy_bundleRuntimeRequired: "\u5B8C\u6574\u79BB\u7EBF\u5305\u9700\u8981\u5BFC\u51FA\u4E2D\u95F4\u4EF6\u955C\u50CF\u3002\u8BF7\u542F\u52A8\u672C\u5730 Podman/Docker\uFF0C\u6216\u5728\u6784\u5EFA\u673A\u6253\u5305\u3002",
188
+ deploy_bundleFailed: "\u4EA4\u4ED8\u51C6\u5907\u6216\u90E8\u7F72\u5931\u8D25\uFF1A{reason}",
189
+ deploy_configFileInvalid: "\u914D\u7F6E\u6587\u4EF6 {path} \u7B2C {line} \u884C\u683C\u5F0F\u6709\u8BEF\u3002\u8BF7\u68C0\u67E5\u7F29\u8FDB\u3001\u5192\u53F7\u548C\u5F15\u53F7\u540E\u91CD\u8BD5\u3002",
190
+ deploy_configFileUnreadable: "\u65E0\u6CD5\u8BFB\u53D6\u914D\u7F6E\u6587\u4EF6 {path}\uFF0C\u8BF7\u68C0\u67E5\u6587\u4EF6\u8DEF\u5F84\u4E0E\u8BFB\u53D6\u6743\u9650\u3002",
191
+ deploy_bundleNameInvalid: "\u90E8\u7F72\u5305\u6587\u4EF6\u540D\u53EA\u652F\u6301\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u70B9\u3001\u52A0\u53F7\u3001\u77ED\u6A2A\u7EBF\u548C\u4E0B\u5212\u7EBF\uFF1B\u6240\u5728\u76EE\u5F55\u53EF\u4EE5\u5305\u542B\u7A7A\u683C\u3002",
192
+ deploy_secretsInvalid: "\u4E2D\u95F4\u4EF6\u51ED\u636E\u6587\u4EF6\u635F\u574F\u6216\u7F3A\u5C11\u5B57\u6BB5\uFF1A{path}\u3002\u8BF7\u4ECE\u5907\u4EFD\u6062\u590D\uFF1B\u4E0D\u4F1A\u81EA\u52A8\u66F4\u6362\u5BC6\u7801\uFF0C\u4EE5\u514D\u5DF2\u6709\u6570\u636E\u65E0\u6CD5\u8BBF\u95EE\u3002"
165
193
  };
166
194
 
167
195
  // src/deploy-i18n.ts
@@ -177,11 +205,11 @@ var VercelProviderConfigSchema = z.object({
177
205
  });
178
206
  var SshConfigSchema = z.object({
179
207
  /** 远程主机地址或 ~/.ssh/config 中的 Host 别名 */
180
- host: z.string().min(1),
208
+ host: z.string().regex(/^[a-z0-9][a-z0-9.:-]*$/i),
181
209
  /** SSH 端口 */
182
210
  port: z.number().int().min(1).max(65535).default(22),
183
211
  /** SSH 用户名 */
184
- username: z.string().min(1),
212
+ username: z.string().regex(/^[a-z_][\w-]*$/i),
185
213
  /** 私钥文件路径(省略时使用 ssh-agent 或 ~/.ssh 默认密钥) */
186
214
  identityFile: z.string().min(1).optional(),
187
215
  /** known_hosts 文件路径(省略时使用默认;配合 CI/临时环境隔离主机指纹) */
@@ -206,7 +234,7 @@ var DockerSshProviderConfigSchema = z.object({
206
234
  /** 远程主机运行参数 */
207
235
  remote: z.object({
208
236
  /** 远程应用根目录 */
209
- baseDir: z.string().min(1).default("/opt/hai/apps"),
237
+ baseDir: z.string().regex(/^\/[\w/. -]+$/).refine((value) => !value.split("/").includes("..")).default("/opt/hai/apps"),
210
238
  /** 远程容器运行时(auto 优先 podman) */
211
239
  runtime: z.enum(["auto", "docker", "podman"]).default("auto")
212
240
  }).default({ baseDir: "/opt/hai/apps", runtime: "auto" }),
@@ -305,10 +333,16 @@ function resolveConfig(value, prefix, env) {
305
333
  }
306
334
  function loadDeploymentConfig(name, path, values) {
307
335
  try {
308
- const config = resolveConfig(parse(readFileSync(path, "utf8")), `HAI_${name.toUpperCase()}`, values);
336
+ const lineCounter = new LineCounter();
337
+ const document = parseDocument(readFileSync(path, "utf8"), { lineCounter, prettyErrors: false });
338
+ if (document.errors.length)
339
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_configFileInvalid", { params: { path, line: String(lineCounter.linePos(document.errors[0].pos[0]).line) } }));
340
+ const config = resolveConfig(document.toJS(), `HAI_${name.toUpperCase()}`, values);
341
+ if (config !== null && (typeof config !== "object" || Array.isArray(config)))
342
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_configFileInvalid", { params: { path, line: "1" } }));
309
343
  return ok(config && typeof config === "object" && !Array.isArray(config) ? config : {});
310
344
  } catch {
311
- return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_environmentReadFailed"));
345
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_configFileUnreadable", { params: { path } }));
312
346
  }
313
347
  }
314
348
  function loadDeploymentEnvironment(options) {
@@ -759,6 +793,7 @@ function maskSecrets(text, secrets) {
759
793
  function runCommand(command, args, options = {}) {
760
794
  return new Promise((resolve6) => {
761
795
  const child = spawn(command, args, {
796
+ windowsHide: true,
762
797
  cwd: options.cwd,
763
798
  env: options.env ? { ...process2.env, ...options.env } : process2.env,
764
799
  shell: false
@@ -787,6 +822,7 @@ function runCommand(command, args, options = {}) {
787
822
  }
788
823
  child.stdout.on("data", (chunk) => {
789
824
  stdout += chunk.toString("utf-8");
825
+ options.onOutput?.(maskSecrets(chunk.toString("utf-8"), options.secrets));
790
826
  });
791
827
  child.stderr.on("data", (chunk) => {
792
828
  stderr += chunk.toString("utf-8");
@@ -830,62 +866,393 @@ ${stderr || `exit ${exitCode}`}`, options.secrets).slice(-3e3) }
830
866
  });
831
867
  }
832
868
 
833
- // src/container/deploy-bundle.ts
869
+ // src/container/deploy-scripts.ts
834
870
  function shellArgument(value) {
835
871
  return `'${value.replaceAll("'", `'"'"'`)}'`;
836
872
  }
837
- async function createDeploymentBundle(options) {
838
- const { directory, artifactDir, artifact, projectName, environment, runtime, coreEnv } = options;
839
- const stem = basename(options.output).replace(/\.tar$/i, "");
840
- const versionSuffix = `-${artifact.appVersion}`;
841
- const output = resolve(dirname(options.output), `${stem.endsWith(versionSuffix) ? stem : `${stem}${versionSuffix}`}.tar`);
842
- const composeName = `docker-compose-${artifact.appVersion}.yml`;
843
- mkdirSync(dirname(output), { recursive: true, mode: 448 });
844
- const staging = mkdtempSync(join(directory, ".bundle-"));
845
- chmodSync(staging, 448);
846
- const temporaryTar = `${output}.partial`;
847
- const startName = `start-${environment}.sh`;
848
- try {
849
- mkdirSync(join(staging, "images"));
850
- for (const image of artifact.images)
851
- copyFileSync(join(artifactDir, image.file), join(staging, "images", image.file));
852
- for (const file of [composeName, ".env.runtime", ...artifact.images.some((image) => image.name === "web") ? ["web.conf"] : []])
853
- copyFileSync(join(directory, file), join(staging, file));
854
- const compose = parse(readFileSync(join(directory, composeName), "utf8"));
855
- const images = [...new Set(Object.values(compose.services).flatMap((service) => service.image ? [service.image] : []))];
856
- const start = `#!/bin/sh
873
+ function createApplyScript(options) {
874
+ const values = {
875
+ ID: shellArgument(options.deploymentId),
876
+ PROJECT: shellArgument(`hai-${options.projectName}`),
877
+ ENV: shellArgument(options.environment),
878
+ RUNTIME: shellArgument(options.runtime),
879
+ PARTIAL: options.partial ? "true" : "false",
880
+ SELECTED: shellArgument(options.selected.join(" ")),
881
+ ROOT: shellArgument(options.directory),
882
+ PORT: String(options.hostPort),
883
+ HEALTH: options.healthPaths.map(shellArgument).join(" ")
884
+ };
885
+ return String.raw`#!/bin/sh
857
886
  set -eu
858
- cd "$(dirname "$0")"
859
887
  umask 077
860
- export HAI_DEPLOY_ENVIRONMENT=${shellArgument(environment)}
861
- export HAI_CORE_ENV=${shellArgument(coreEnv)}
862
- runtime=${shellArgument(runtime)}
863
- if [ -n "\${HAI_DEPLOY_PROVIDER_REMOTE_RUNTIME:-}" ]; then runtime="$HAI_DEPLOY_PROVIDER_REMOTE_RUNTIME"; fi
888
+ source_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
889
+ destination=@@ROOT@@
890
+ if [ "$#" -gt 0 ]; then destination=$1; fi
891
+ root=$(mkdir -p -- "$destination" && CDPATH= cd -- "$destination" && pwd)
892
+ id=@@ID@@
893
+ project=@@PROJECT@@
894
+ export HAI_DEPLOY_ENVIRONMENT=@@ENV@@
895
+ runtime=@@RUNTIME@@
896
+ partial=@@PARTIAL@@
897
+ orphan_option=''
898
+ if [ "$partial" = false ]; then orphan_option=--remove-orphans; fi
899
+ recreate_option=--force-recreate
900
+ if [ "$partial" = true ]; then recreate_option=--no-recreate; fi
901
+ selected=@@SELECTED@@
902
+ release="$root/releases/$id"
903
+ case "$release/" in "$source_dir/"*) printf 'Extract the package outside the deployment directory before applying it.\n' >&2; exit 1;; esac
904
+ stage=''
905
+ previous=''
906
+ restore=''
907
+ changed=false
908
+ locked=false
909
+ started=$(date +%s)
910
+ step_started=$started
911
+ step() {
912
+ if [ -n "$stage" ]; then
913
+ duration=$(( $(date +%s) - step_started ))
914
+ printf '[PASS] %s (%ss)\n' "$stage" "$duration"
915
+ printf '{"stage":"%s","status":"passed","durationSeconds":%s}\n' "$stage" "$duration" >> "$root/steps-$id.jsonl"
916
+ fi
917
+ stage="$1"
918
+ step_started=$(date +%s)
919
+ printf '\n[%s] %s\n' "$stage" "$2"
920
+ }
921
+ compose() {
922
+ arguments_file="$release/compose-files"
923
+ if [ -n "$restore" ]; then arguments_file="$root/releases/$restore/compose-files"; fi
924
+ # -f 按顺序合并,由 Compose 保留未选服务、卷和环境文件,不自行拼装配置。
925
+ command_args="$release/.compose-command"
926
+ # 文件列表先反向读取,再向参数头部加入,保留原始合并顺序。
927
+ awk '{lines[NR]=$0} END {for(i=NR;i>0;i--) print lines[i]}' "$arguments_file" > "$command_args"
928
+ while IFS= read -r file; do set -- -f "$file" "$@"; done < "$command_args"
929
+ "$runtime" compose -p "$project" "$@"
930
+ }
931
+ remove_selected() {
932
+ test "$partial" = true || return 0
933
+ # 直接替换选中容器,避免 Compose Provider 连带重建依赖它的未选服务;不删除卷。
934
+ for service in "$@"; do
935
+ containers=$("$runtime" ps -aq --filter "label=com.docker.compose.project=$project" --filter "label=com.docker.compose.service=$service") || return 1
936
+ for container in $containers; do
937
+ "$runtime" rm -f "$container" || return 1
938
+ done
939
+ done
940
+ }
941
+ finish() {
942
+ code=$?
943
+ trap - EXIT HUP INT TERM
944
+ if [ "$code" -ne 0 ]; then
945
+ printf '[FAIL] %s (exit %s)\n' "$stage" "$code" >&2
946
+ if [ "$changed" = true ]; then
947
+ diagnostics="$root/diagnostics-$id.log"
948
+ : > "$diagnostics"
949
+ for service in $selected; do
950
+ containers=$("$runtime" ps -aq --filter "label=com.docker.compose.project=$project" --filter "label=com.docker.compose.service=$service" || true)
951
+ for container in $containers; do
952
+ "$runtime" inspect --format '{{.Name}} {{.State.Status}} exit={{.State.ExitCode}}' "$container" >> "$diagnostics" 2>&1 || true
953
+ "$runtime" logs --tail 80 "$container" >> "$diagnostics" 2>&1 || true
954
+ done
955
+ done
956
+ printf 'Startup diagnostics saved to %s (private server file).\n' "$diagnostics" >&2
957
+ fi
958
+ if [ -f "$release/rollback-images" ]; then
959
+ while IFS=' ' read -r image reference; do
960
+ test -n "$reference" || continue
961
+ "$runtime" tag "$image" "$reference" || printf '[FAIL] Cannot restore image %s\n' "$reference" >&2
962
+ done < "$release/rollback-images"
963
+ fi
964
+ if [ "$changed" = true ] && [ -n "$previous" ]; then
965
+ printf '[RECOVER] Restoring previous selected services\n' >&2
966
+ recover_selected=$selected
967
+ if [ "$partial" = false ]; then recover_selected=$(grep -v -- '-init$' "$root/releases/$previous/services"); fi
968
+ restore="$previous"
969
+ if remove_selected $recover_selected && compose up -d --no-deps --no-build --pull never "$recreate_option" $orphan_option $recover_selected; then
970
+ case " $selected " in *' app '*)
971
+ if grep -qx web "$root/releases/$previous/services"; then compose exec -T web nginx -s reload || true; fi
972
+ ;;
973
+ esac
974
+ printf '[RECOVER] Previous containers restored; verify application health\n' >&2
975
+ else
976
+ printf '[FAIL] Recovery failed. Use the previous release and inspect container logs.\n' >&2
977
+ fi
978
+ elif [ "$changed" = true ]; then
979
+ compose stop $selected || true
980
+ printf '[RECOVER] Failed first installation stopped; data volumes retained\n' >&2
981
+ fi
982
+ fi
983
+ if [ "$locked" = true ]; then
984
+ elapsed=$(( $(date +%s) - started ))
985
+ outcome=passed
986
+ if [ "$code" -ne 0 ]; then outcome=failed; fi
987
+ printf '{"stage":"%s","status":"%s","durationSeconds":%s}\n' "$stage" "$outcome" "$(( $(date +%s) - step_started ))" >> "$root/steps-$id.jsonl"
988
+ printf '{"deploymentId":"%s","stage":"%s","exitCode":%s,"durationSeconds":%s}\n' "$id" "$stage" "$code" "$elapsed" > "$root/result-$id.json"
989
+ rmdir "$root/.deploy-lock" || true
990
+ fi
991
+ exit "$code"
992
+ }
993
+ trap finish EXIT
994
+ trap 'exit 130' HUP INT TERM
995
+ step preflight 'Checking deployment tools and package'
996
+ for tool in sha256sum tar awk curl; do command -v "$tool" >/dev/null || { printf 'Required command missing: %s\n' "$tool" >&2; exit 1; }; done
864
997
  if [ "$runtime" = auto ]; then
865
998
  if command -v podman >/dev/null 2>&1 && podman version >/dev/null 2>&1; then runtime=podman; else runtime=docker; fi
866
999
  fi
867
- case "$runtime" in docker|podman) ;; *) echo 'HAI_DEPLOY_PROVIDER_REMOTE_RUNTIME must be docker or podman' >&2; exit 1;; esac
1000
+ case "$runtime" in docker|podman) ;; *) printf 'Runtime must be docker or podman\n' >&2; exit 1;; esac
868
1001
  "$runtime" version >/dev/null
869
1002
  "$runtime" compose version >/dev/null
1003
+ "$runtime" compose up --help | grep -q never || { printf 'Compose is too old. Install Docker Compose v2 or podman-compose 1.6.0 or newer (supports --pull never).\n' >&2; exit 1; }
1004
+ mkdir "$root/.deploy-lock" 2>/dev/null || { printf 'Another deployment is running. Check %s/.deploy-lock before retrying.\n' "$root" >&2; exit 1; }
1005
+ locked=true
1006
+ cd "$source_dir"
870
1007
  sha256sum -c SHA256SUMS
871
- chmod 600 .env.runtime
872
- ${artifact.images.map((image) => `"$runtime" load -i ${shellArgument(`images/${image.file}`)}`).join("\n")}
873
- # \u79BB\u7EBF\u670D\u52A1\u5668\u5E94\u5148\u5BFC\u5165\u5168\u90E8\u4E2D\u95F4\u4EF6\u955C\u50CF\uFF1B\u7981\u6B62\u9759\u9ED8\u5C1D\u8BD5\u8054\u7F51\u3002
874
- for image in ${images.map(shellArgument).join(" ")}; do
875
- "$runtime" image inspect "$image" >/dev/null 2>&1 || { echo "Missing image: $image. Load the matching image archive before retrying." >&2; exit 1; }
1008
+ if [ -f "$root/current" ]; then
1009
+ previous=$(cat "$root/current")
1010
+ case "$previous" in ''|*[!a-zA-Z0-9._-]*) printf 'Invalid deployment state\n' >&2; exit 1;; esac
1011
+ test -f "$root/releases/$previous/compose-files"
1012
+ fi
1013
+ if [ "$partial" = true ]; then
1014
+ test -n "$previous" || { printf 'Partial deployment requires a successful full installation. Deploy a complete bundle first.\n' >&2; exit 1; }
1015
+ cmp -s layout "$root/releases/$previous/layout" || { printf 'Ports, API routes or application layout changed. Create a full deployment bundle.\n' >&2; exit 1; }
1016
+ fi
1017
+ test ! -e "$release" || { printf 'This deployment ID was already used. Create a new deployment bundle.\n' >&2; exit 1; }
1018
+ mkdir -p "$release"
1019
+ cp -R "$source_dir/." "$release/"
1020
+ # Nginx 以非 root 用户运行;只开放不含凭据的代理配置读取权限。
1021
+ if [ -f "$release/web.conf" ]; then chmod 644 "$release/web.conf"; fi
1022
+ : > "$release/compose-files"
1023
+ if [ "$partial" = true ]; then
1024
+ cp "$root/releases/$previous/compose-files" "$release/compose-files"
1025
+ cp "$root/releases/$previous/middleware.env" "$release/middleware.env"
1026
+ for service in $selected; do
1027
+ grep -qx "$service" "$root/releases/$previous/services" || { printf 'Service %s has no installed baseline. Use a full deployment.\n' "$service" >&2; exit 1; }
1028
+ done
1029
+ fi
1030
+ printf '%s\n' "$release/compose-entry.json" >> "$release/compose-files"
1031
+ compose config >/dev/null
1032
+ compose config --services > "$release/services"
1033
+ : > "$release/rollback-images"
1034
+ if [ -n "$previous" ]; then
1035
+ for service in $selected; do
1036
+ containers=$("$runtime" ps -aq --filter "label=com.docker.compose.project=$project" --filter "label=com.docker.compose.service=$service")
1037
+ for container in $containers; do
1038
+ "$runtime" inspect --format '{{.Image}} {{.Config.Image}}' "$container" >> "$release/rollback-images"
1039
+ done
1040
+ done
1041
+ fi
1042
+ step images 'Loading packaged images (no registry access)'
1043
+ while IFS=' ' read -r archive reference; do
1044
+ test -n "$archive" || continue
1045
+ "$runtime" load -i "$release/images/$archive"
1046
+ "$runtime" image inspect "$reference" >/dev/null
1047
+ architecture=$("$runtime" image inspect --format '{{.Architecture}}' "$reference")
1048
+ host_arch=$(uname -m)
1049
+ 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
1050
+ done < "$release/image-list"
1051
+ step dependencies 'Checking dependencies of selected services'
1052
+ if [ "$partial" = true ]; then cat "$release/services" >> "$release/dependencies"; fi
1053
+ while IFS= read -r dependency; do
1054
+ test -n "$dependency" || continue
1055
+ case "$dependency" in *-init) continue;; esac
1056
+ case " $selected " in *" $dependency "*) continue;; esac
1057
+ running=$("$runtime" ps -q --filter "label=com.docker.compose.project=$project" --filter "label=com.docker.compose.service=$dependency")
1058
+ test -n "$running" || { printf 'Dependency %s is not running. Include it in a full deployment.\n' "$dependency" >&2; exit 1; }
1059
+ for container in $running; do
1060
+ test "$("$runtime" inspect --format '{{.State.Running}}' "$container")" = true || { printf 'Dependency %s is stopped. Start it before retrying.\n' "$dependency" >&2; exit 1; }
1061
+ done
1062
+ done < "$release/dependencies"
1063
+ step start 'Updating selected services'
1064
+ changed=true
1065
+ remove_selected $selected
1066
+ if [ -f "$release/initialize-storage" ]; then
1067
+ remove_selected storage-init
1068
+ compose up -d --no-deps --no-build --pull never storage
1069
+ compose up --no-deps --no-build --pull never "$recreate_option" --exit-code-from storage-init storage-init
1070
+ fi
1071
+ compose up -d --no-deps --no-build --pull never "$recreate_option" $orphan_option $selected
1072
+ case " $selected " in
1073
+ *' app '*)
1074
+ case " $selected " in *' web '*) ;; *)
1075
+ if grep -qx web "$release/services"; then compose exec -T web nginx -s reload; fi
1076
+ esac
1077
+ ;;
1078
+ esac
1079
+ step health 'Checking HTTP access'
1080
+ for path in @@HEALTH@@; do
1081
+ ready=false
1082
+ attempt=0
1083
+ while [ "$attempt" -lt 40 ]; do
1084
+ if curl --noproxy '*' -fsS --max-time 5 "http://127.0.0.1:@@PORT@@$path" >/dev/null 2>&1; then ready=true; break; fi
1085
+ attempt=$((attempt + 1))
1086
+ sleep 3
1087
+ done
1088
+ test "$ready" = true || { printf 'HTTP check failed: %s. Inspect compose logs for selected services.\n' "$path" >&2; exit 1; }
876
1089
  done
877
- exec "$runtime" compose -f ${shellArgument(composeName)} --env-file .env.runtime -p ${shellArgument(`hai-${projectName}`)} up -d
1090
+ step complete 'Deployment passed all checks'
1091
+ printf '%s\n' "$id" > "$root/current.next"
1092
+ mv "$root/current.next" "$root/current"
1093
+ printf '[PASS] %s\n' "$id"
1094
+ `.replace(/@@([A-Z]+)@@/g, (_match, name) => values[name]);
1095
+ }
1096
+ function createUploadScripts(config, bundle, projectName) {
1097
+ const ssh = config.ssh;
1098
+ const host = ssh?.host ?? "SERVER_HOST";
1099
+ const user = ssh?.username ?? "deploy";
1100
+ const remote = `${config.remote.baseDir}/${projectName}`;
1101
+ const port = ssh?.port ?? 22;
1102
+ const sh = String.raw`#!/bin/sh
1103
+ set -eu
1104
+ cd "$(dirname "$0")"
1105
+ host=@@HOST@@
1106
+ user=@@USER@@
1107
+ port=@@PORT@@
1108
+ remote=@@REMOTE@@
1109
+ bundle=@@BUNDLE@@
1110
+ if [ "$#" -ge 1 ]; then host=$1; fi
1111
+ if [ "$#" -ge 2 ]; then port=$2; fi
1112
+ if [ "$#" -ge 3 ]; then user=$3; fi
1113
+ identity=$(printenv HAI_DEPLOY_IDENTITY_FILE || true)
1114
+ if [ -z "$identity" ]; then identity=@@IDENTITY@@; fi
1115
+ test "$host" != SERVER_HOST || { printf 'Specify the server: sh deploy.sh SERVER [PORT] [USER].\n' >&2; exit 1; }
1116
+ for tool in ssh scp; do command -v "$tool" >/dev/null || { printf 'Install OpenSSH (%s).\n' "$tool" >&2; exit 1; }; done
1117
+ set -- -o BatchMode=yes -o StrictHostKeyChecking=@@STRICT@@ -o ConnectTimeout=10
1118
+ if [ -n "$identity" ]; then set -- "$@" -i "$identity"; fi
1119
+ known_hosts=@@KNOWN@@
1120
+ if value=$(printenv HAI_DEPLOY_KNOWN_HOSTS_FILE); then known_hosts=$value; fi
1121
+ if [ -n "$known_hosts" ]; then set -- "$@" -o "UserKnownHostsFile=$known_hosts"; fi
1122
+ printf '[1/3] Connecting to %s\n' "$host"
1123
+ ssh "$@" -p "$port" "$user@$host" "mkdir -p '$remote/incoming' && chmod 700 '$remote' '$remote/incoming'"
1124
+ printf '[2/3] Uploading %s\n' "$bundle"
1125
+ scp_host=$host
1126
+ case "$host" in *:*) scp_host="[$host]";; esac
1127
+ scp "$@" -P "$port" "$bundle" "$user@$scp_host:$remote/incoming/$bundle"
1128
+ printf '[3/3] Applying package and checking HTTP access\n'
1129
+ ssh "$@" -p "$port" "$user@$host" "cd '$remote/incoming' && mkdir -p '$bundle.contents' && tar -xf '$bundle' -C '$bundle.contents' && sh '$bundle.contents/apply.sh' '$remote'"
1130
+ `;
1131
+ const ps1 = String.raw`param([string]$IdentityFile = $env:HAI_DEPLOY_IDENTITY_FILE)
1132
+ $ErrorActionPreference = 'Stop'
1133
+ Set-Location -LiteralPath $PSScriptRoot
1134
+ $target = Get-Content -LiteralPath './deploy-target.json' -Raw | ConvertFrom-Json
1135
+ if ($target.host -eq 'SERVER_HOST') { throw 'Set the server host in deploy-target.json.' }
1136
+ if ($target.host -notmatch '^[a-zA-Z0-9][a-zA-Z0-9.:-]*$' -or $target.username -notmatch '^[a-zA-Z_][a-zA-Z0-9_-]*$' -or $target.directory -notmatch '^/[a-zA-Z0-9/_. -]+$' -or $target.directory -match '(^|/)\.\.(/|$)' -or $target.bundle -notmatch '^[a-zA-Z0-9_.+-]+\.tar$') { throw 'Invalid deployment target.' }
1137
+ $options = @('-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', 'ConnectTimeout=10')
1138
+ if ($target.strictHostKeyChecking -eq $false) { $options[3] = 'StrictHostKeyChecking=no' }
1139
+ if (-not $IdentityFile) { $IdentityFile = $target.identityFile }
1140
+ if ($IdentityFile) { $options += @('-i', $IdentityFile) }
1141
+ if ($target.knownHostsFile) { $options += @('-o', "UserKnownHostsFile=$($target.knownHostsFile)") }
1142
+ $destination = "$($target.username)@$($target.host)"
1143
+ $scpDestination = if ($target.host.Contains(':')) { "$($target.username)@[$($target.host)]" } else { $destination }
1144
+ $remote = $target.directory
1145
+ $bundle = $target.bundle
1146
+ function Invoke-Step([string]$Name, [string]$Command, [string[]]$Arguments) {
1147
+ Write-Host "[$Name]"
1148
+ & $Command @Arguments
1149
+ if ($LASTEXITCODE -ne 0) { throw "$Name failed (exit $LASTEXITCODE)." }
1150
+ }
1151
+ try {
1152
+ foreach ($tool in @('ssh', 'scp')) { Get-Command $tool -ErrorAction Stop | Out-Null }
1153
+ Invoke-Step '1/3 Connect' 'ssh' ($options + @('-p', [string]$target.port, $destination, "mkdir -p '$remote/incoming' && chmod 700 '$remote' '$remote/incoming'"))
1154
+ Invoke-Step '2/3 Upload' 'scp' ($options + @('-P', [string]$target.port, $bundle, "$($scpDestination):$remote/incoming/$bundle"))
1155
+ Invoke-Step '3/3 Apply and verify HTTP' 'ssh' ($options + @('-p', [string]$target.port, $destination, "cd '$remote/incoming' && mkdir -p '$bundle.contents' && tar -xf '$bundle' -C '$bundle.contents' && sh '$bundle.contents/apply.sh' '$remote'"))
1156
+ } catch { Write-Error $_; exit 1 }
878
1157
  `;
879
- writeFileSync(join(directory, startName), start, { mode: 448 });
880
- writeFileSync(join(staging, startName), start, { mode: 448 });
881
- const instructions = deployM("deploy_bundleInstructions", { params: { file: basename(output), start: startName, images: images.join("\n"), compose: composeName } });
882
- const metadata = deployM("deploy_bundleMetadata", { params: { version: artifact.appVersion, buildId: artifact.buildId, compose: composeName } });
883
- writeFileSync(join(staging, "README.txt"), `${metadata}
1158
+ const values = { HOST: shellArgument(host), USER: shellArgument(user), PORT: String(port), REMOTE: shellArgument(remote), BUNDLE: shellArgument(bundle), IDENTITY: shellArgument(ssh?.identityFile ?? ""), KNOWN: shellArgument(ssh?.knownHostsFile ?? ""), STRICT: ssh?.strictHostKeyChecking === false ? "no" : "accept-new" };
1159
+ return { sh: sh.replace(/@@([A-Z]+)@@/g, (_match, name) => values[name]), ps1 };
1160
+ }
1161
+
1162
+ // src/container/deploy-bundle.ts
1163
+ async function createDeploymentBundle(options) {
1164
+ const { directory, artifactDir, artifact, projectName, environment, config, selected } = options;
1165
+ const stem = basename(options.output).replace(/\.tar$/i, "");
1166
+ const suffix = `-${artifact.appVersion}`;
1167
+ const output = resolve(dirname(options.output), `${stem.endsWith(suffix) ? stem : `${stem}${suffix}`}.tar`);
1168
+ if (!/^[\w.+-]+\.tar$/.test(basename(output)))
1169
+ throw new Error(deployM("deploy_bundleNameInvalid"));
1170
+ const staging = mkdtempSync(join(directory, ".bundle-"));
1171
+ const temporaryTar = `${output}.partial`;
1172
+ mkdirSync(dirname(output), { recursive: true, mode: 448 });
1173
+ chmodSync(staging, 448);
1174
+ const files = [];
1175
+ const put = (file, content) => {
1176
+ mkdirSync(dirname(join(staging, file)), { recursive: true });
1177
+ writeFileSync(join(staging, file), content, { mode: 384 });
1178
+ files.push(file);
1179
+ };
1180
+ try {
1181
+ mkdirSync(join(staging, "images"));
1182
+ const compose = parse(readFileSync(join(directory, `docker-compose-${artifact.appVersion}.yml`), "utf8"));
1183
+ const dependencies = /* @__PURE__ */ new Set();
1184
+ const references = /* @__PURE__ */ new Set();
1185
+ for (const name of selected) {
1186
+ const service = compose.services[name];
1187
+ if (!service)
1188
+ throw new Error(deployM("deploy_selectionInvalid", { params: { service: name } }));
1189
+ if (typeof service.image === "string")
1190
+ references.add(service.image);
1191
+ if (service.depends_on) {
1192
+ for (const dependency of Array.isArray(service.depends_on) ? service.depends_on : Object.keys(service.depends_on))
1193
+ dependencies.add(dependency);
1194
+ }
1195
+ if (service.env_file)
1196
+ service.env_file = [`../${options.deploymentId}/.env.runtime`, `../${options.deploymentId}/middleware.env`];
1197
+ if (Array.isArray(service.volumes))
1198
+ service.volumes = service.volumes.map((mount) => typeof mount === "string" ? mount.replace("./web.conf:", `../${options.deploymentId}/web.conf:`) : mount);
1199
+ }
1200
+ put("compose.json", JSON.stringify({ ...compose, services: Object.fromEntries(selected.map((name) => [name, compose.services[name]])) }));
1201
+ put("compose-entry.json", JSON.stringify({ ...compose, services: Object.fromEntries(selected.map((name) => [name, { extends: { file: `../${options.deploymentId}/compose.json`, service: name } }])) }));
1202
+ let runtime;
1203
+ const imageList = [];
1204
+ for (const reference of references) {
1205
+ if (!/:[^/:]+$/.test(reference) || /:latest$/i.test(reference))
1206
+ throw new Error(deployM("deploy_imageVersionRequired", { params: { image: reference } }));
1207
+ const built = artifact.images.find((image) => image.imageRef === reference);
1208
+ const file = built?.file ?? `middleware-${imageList.length + 1}.tar`;
1209
+ if (built) {
1210
+ copyFileSync(join(artifactDir, file), join(staging, "images", file));
1211
+ } else {
1212
+ if (!runtime) {
1213
+ const candidates = options.runtime === "auto" ? ["podman", "docker"] : [options.runtime];
1214
+ for (const candidate of candidates) {
1215
+ if ((await runCommand(candidate, ["version"])).success) {
1216
+ runtime = candidate;
1217
+ break;
1218
+ }
1219
+ }
1220
+ if (!runtime)
1221
+ throw new Error(deployM("deploy_bundleRuntimeRequired"));
1222
+ }
1223
+ if (!(await runCommand(runtime, ["image", "inspect", reference])).success) {
1224
+ const pulled = await runCommand(runtime, ["pull", reference], { timeoutMs: 6e5 });
1225
+ if (!pulled.success)
1226
+ throw new Error(pulled.error.message);
1227
+ }
1228
+ const saved = await runCommand(runtime, ["save", ...runtime === "podman" ? ["--format", "docker-archive"] : [], "-o", join(staging, "images", file), reference], { timeoutMs: 6e5 });
1229
+ if (!saved.success)
1230
+ throw new Error(saved.error.message);
1231
+ }
1232
+ files.push(`images/${file}`);
1233
+ imageList.push(`${file} ${reference}`);
1234
+ }
1235
+ put(".env.runtime", readFileSync(join(directory, ".env.runtime"), "utf8"));
1236
+ put("middleware.env", options.middlewareEnv);
1237
+ if (selected.includes("web"))
1238
+ put("web.conf", readFileSync(join(directory, "web.conf"), "utf8"));
1239
+ 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)}
1240
+ `);
1241
+ put("layout", `${config.expose.hostPort}:${options.containerPort}:${options.hasWeb}:${options.hasWeb ? await artifactChecksum(join(directory, "web.conf")) : ""}
1242
+ `);
1243
+ put("image-list", `${imageList.join("\n")}
1244
+ `);
1245
+ put("dependencies", `${[...dependencies].join("\n")}
1246
+ `);
1247
+ if (selected.includes("storage-init"))
1248
+ put("initialize-storage", "");
1249
+ put("apply.sh", createApplyScript({ deploymentId: options.deploymentId, projectName, environment, directory: `${config.remote.baseDir}/${projectName}`, runtime: config.remote.runtime, partial: options.partial, selected: selected.filter((name) => name !== "storage-init"), hostPort: config.expose.hostPort, healthPaths: options.healthPaths }));
1250
+ const instructions = deployM("deploy_bundleInstructions", { params: { file: basename(output), start: "apply.sh", images: [...references].join("\n"), compose: "compose.json" } });
1251
+ put("README.txt", `${deployM("deploy_bundleMetadata", { params: { version: artifact.appVersion, buildId: artifact.buildId, compose: "compose.json" } })}
884
1252
  ${instructions}
885
1253
  `);
886
- copyFileSync(join(artifactDir, "manifest.json"), join(staging, "manifest.json"));
887
1254
  const checksums = [];
888
- for (const file of [composeName, "manifest.json", ".env.runtime", startName, ...artifact.images.some((image) => image.name === "web") ? ["web.conf"] : [], ...artifact.images.map((image) => `images/${image.file}`)])
1255
+ for (const file of files)
889
1256
  checksums.push(`${await artifactChecksum(join(staging, file))} ${file}`);
890
1257
  writeFileSync(join(staging, "SHA256SUMS"), `${checksums.join("\n")}
891
1258
  `);
@@ -893,25 +1260,33 @@ ${instructions}
893
1260
  if (!tar.success)
894
1261
  throw new Error(tar.error.message);
895
1262
  chmodSync(temporaryTar, 384);
896
- const { renameSync } = await import('fs');
897
1263
  renameSync(temporaryTar, output);
898
- return { bundlePath: output, instructions, startName };
1264
+ const upload = createUploadScripts(config, basename(output), projectName);
1265
+ const scripts = `${output.slice(0, -4)}-deploy`;
1266
+ mkdirSync(scripts, { recursive: true, mode: 448 });
1267
+ writeFileSync(join(scripts, "deploy.sh"), upload.sh.replace('cd "$(dirname "$0")"', 'cd "$(dirname "$0")/.."'), { mode: 448 });
1268
+ writeFileSync(join(scripts, "deploy.ps1"), upload.ps1.replace("Set-Location -LiteralPath $PSScriptRoot", "Set-Location -LiteralPath (Split-Path -Parent $PSScriptRoot)").replace("'./deploy-target.json'", "(Join-Path $PSScriptRoot 'deploy-target.json')"));
1269
+ writeFileSync(join(scripts, "deploy-target.json"), `${JSON.stringify({ ...config.ssh, host: config.ssh?.host ?? "SERVER_HOST", username: config.ssh?.username ?? "deploy", port: config.ssh?.port ?? 22, directory: `${config.remote.baseDir}/${projectName}`, bundle: basename(output) }, null, 2)}
1270
+ `);
1271
+ writeFileSync(join(scripts, "README.txt"), instructions);
1272
+ return { bundlePath: output, instructions: `${instructions}
1273
+ ${scripts}`, startName: "apply.sh" };
899
1274
  } finally {
900
1275
  rmSync(staging, { recursive: true, force: true });
901
1276
  rmSync(temporaryTar, { force: true });
902
1277
  }
903
1278
  }
904
1279
  var IMAGES = {
905
- postgres: "postgres:16-alpine",
906
- mysql: "mysql:8.4",
907
- redis: "redis:7-alpine",
1280
+ postgres: "docker.io/library/postgres:16.15-alpine",
1281
+ mysql: "docker.io/library/mysql:8.4.11",
1282
+ redis: "docker.io/library/redis:7.4.11-alpine",
908
1283
  rustfs: "ghcr.io/rustfs/rustfs:1.0.1",
909
1284
  minio: "ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z",
910
- qdrant: "qdrant/qdrant:v1.12.4",
911
- pgvector: "pgvector/pgvector:pg17",
912
- chroma: "chromadb/chroma:1.5.5",
1285
+ qdrant: "docker.io/qdrant/qdrant:v1.19.2",
1286
+ pgvector: "docker.io/pgvector/pgvector:0.8.0-pg17",
1287
+ chroma: "docker.io/chromadb/chroma:1.5.5",
913
1288
  ldap: "docker.io/osixia/openldap:1.5.0",
914
- smtp: "axllent/mailpit:v1.27.4"
1289
+ smtp: "docker.io/axllent/mailpit:v1.27.4"
915
1290
  };
916
1291
  function generatePassword() {
917
1292
  return randomBytes(24).toString("base64url");
@@ -920,8 +1295,12 @@ function resolveSidecarSecrets(appDir, projectName) {
920
1295
  const secretFile = join(appDir, ".hai", "deploy", "runtime", `${projectName}.secrets.json`);
921
1296
  if (existsSync(secretFile)) {
922
1297
  try {
923
- return JSON.parse(readFileSync(secretFile, "utf-8"));
1298
+ const saved = JSON.parse(readFileSync(secretFile, "utf-8"));
1299
+ if (!saved || !["dbPassword", "storagePassword", "ldapPassword"].every((key) => typeof saved[key] === "string" && saved[key].length > 0))
1300
+ throw new Error("Invalid credentials");
1301
+ return saved;
924
1302
  } catch {
1303
+ throw new Error(deployM("deploy_secretsInvalid", { params: { path: secretFile } }));
925
1304
  }
926
1305
  }
927
1306
  const secrets = {
@@ -1089,6 +1468,28 @@ function planChromaVecdb(plan, projectName, net) {
1089
1468
  plan.env.HAI_VECDB = JSON.stringify({ type: "chroma", url: "http://vecdb:8000" });
1090
1469
  }
1091
1470
  function planSidecars(appDir, projectName, scan, provisionEnv, options, s3Engine = "rustfs") {
1471
+ const serviceModes = { ...options?.serviceModes };
1472
+ for (const requirement of scan.middlewares ?? []) {
1473
+ if (options?.selectedServices && !options.selectedServices.includes(requirement.service))
1474
+ continue;
1475
+ const mode = serviceModes[requirement.envPrefix] ?? serviceModes[requirement.service];
1476
+ if (mode !== "keep")
1477
+ continue;
1478
+ const endpoint = requirement.config.url ?? requirement.config.endpoint;
1479
+ let host = requirement.config.host;
1480
+ if (typeof endpoint === "string") {
1481
+ try {
1482
+ host = new URL(endpoint).hostname;
1483
+ } catch {
1484
+ continue;
1485
+ }
1486
+ }
1487
+ if (host === requirement.service || ["sqlite", "local", "memory", "lancedb"].includes(requirement.backend)) {
1488
+ serviceModes[requirement.envPrefix] = "default";
1489
+ serviceModes[requirement.service] = "default";
1490
+ }
1491
+ }
1492
+ options = { ...options, serviceModes };
1092
1493
  const plan = {
1093
1494
  services: [],
1094
1495
  composeServices: {},
@@ -1215,23 +1616,6 @@ function generateComposeFile(options) {
1215
1616
  };
1216
1617
  if (Object.keys(sidecar.volumes).length > 0)
1217
1618
  compose.volumes = sidecar.volumes;
1218
- if (options.previousCompose && options.serviceModes) {
1219
- const previous = parse(options.previousCompose);
1220
- for (const [service, mode] of Object.entries(options.serviceModes)) {
1221
- if (mode !== "keep")
1222
- continue;
1223
- for (const name of [service, `${service}-init`]) {
1224
- if (previous.services?.[name]) {
1225
- services[name] = previous.services[name];
1226
- app.depends_on = [.../* @__PURE__ */ new Set([...app.depends_on ?? [], service])];
1227
- }
1228
- }
1229
- }
1230
- const previousMounts = previous.services?.app?.volumes;
1231
- if (Array.isArray(previousMounts) && Object.values(options.serviceModes).includes("keep"))
1232
- app.volumes = [.../* @__PURE__ */ new Set([...app.volumes ?? [], ...previousMounts])];
1233
- compose.volumes = { ...previous.volumes, ...sidecar.volumes };
1234
- }
1235
1619
  if (services["storage-init"]) {
1236
1620
  app.depends_on = {
1237
1621
  ...Object.fromEntries((app.depends_on ?? []).map((name) => [name, { condition: "service_started" }])),
@@ -1318,14 +1702,15 @@ function buildScpUploadArgs(config, localPath, remotePath) {
1318
1702
  "-P",
1319
1703
  String(config.port),
1320
1704
  localPath,
1321
- `${config.username}@${config.host}:${remotePath}`
1705
+ `${config.username}@${config.host.includes(":") ? `[${config.host}]` : config.host}:${remotePath}`
1322
1706
  ];
1323
1707
  }
1324
1708
  function createSshExecutor(config) {
1325
1709
  const exec = async (remoteCommand, options) => {
1326
1710
  return runCommand("ssh", buildSshExecArgs(config, remoteCommand), {
1327
1711
  timeoutMs: options?.timeoutMs,
1328
- secrets: options?.secrets
1712
+ secrets: options?.secrets,
1713
+ onOutput: options?.onOutput
1329
1714
  });
1330
1715
  };
1331
1716
  const upload = async (localPath, remotePath) => {
@@ -1414,11 +1799,23 @@ function createDockerSshProvider(config, container) {
1414
1799
  const images = artifact.images;
1415
1800
  const web = images.find((image) => image.name === "web");
1416
1801
  const app = images.find((image) => image.name === "app");
1417
- const containerPort = app.containerPort ?? config.expose.containerPort;
1418
- const url = config.ssh ? `http://${config.ssh.host}:${hostPort}` : `http://SERVER_HOST:${hostPort}`;
1802
+ if (options?.selectedServices) {
1803
+ const available = [...images.map((image) => image.name), ...scan.requiredServices];
1804
+ const invalid = options.selectedServices.find((name) => !available.includes(name));
1805
+ if (!options.selectedServices.length || new Set(options.selectedServices).size !== options.selectedServices.length || invalid)
1806
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_selectionInvalid", { params: { service: invalid ?? options.selectedServices.join(", ") } }));
1807
+ }
1808
+ const hasWeb = artifact.hasWeb || !!web;
1809
+ const partial = artifact.partial || options?.selectedServices !== void 0;
1810
+ const containerPort = app?.containerPort ?? artifact.appContainerPort ?? config.expose.containerPort;
1811
+ const webContainerPort = web?.containerPort ?? artifact.webContainerPort ?? 8080;
1812
+ const host = config.ssh?.host;
1813
+ const url = host ? `http://${host.includes(":") ? `[${host}]` : host}:${hostPort}` : `http://SERVER_HOST:${hostPort}`;
1419
1814
  const { env, sidecar } = buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort, options, appEnv, container?.s3Engine);
1815
+ if (sidecar.composeServices["storage-init"] && !app && options?.selectedServices?.includes("storage"))
1816
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_storageArtifactRequired"));
1420
1817
  let webProxy;
1421
- if (web) {
1818
+ if (hasWeb) {
1422
1819
  const server = loadDeploymentConfig("serv", join(appDir, "config", "_serv.yml"), env);
1423
1820
  if (!server.success)
1424
1821
  return server;
@@ -1427,12 +1824,10 @@ function createDockerSshProvider(config, container) {
1427
1824
  const apiPrefix = http?.apiPrefix ?? "/api/v1";
1428
1825
  if (typeof apiPrefix !== "string" || !/^\/[\w/-]+$/.test(apiPrefix))
1429
1826
  return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_webProxyInvalid"));
1430
- webProxy = generateWebProxyConfig(containerPort, apiPrefix, web.containerPort ?? 8080);
1827
+ webProxy = generateWebProxyConfig(containerPort, apiPrefix, webContainerPort);
1431
1828
  env.HAI_SERV = JSON.stringify({ ...server.data, cors: { ...cors, origin: (!cors?.origin || cors.origin === "*") && config.ssh ? url : cors?.origin ?? "*" } });
1432
1829
  }
1433
1830
  const envPath = join(artifactDir, ".env.runtime");
1434
- const previousEnv = existsSync(envPath) ? parseEnv(readFileSync(envPath, "utf8")) : {};
1435
- Object.assign(env, { ...previousEnv, ...env });
1436
1831
  writeFileSync(envPath, serializeEnvFile(env), { mode: 384 });
1437
1832
  if (options?.environment) {
1438
1833
  const environment = loadDeploymentEnvironment({ environment: options.environment, envDir: options.envDir ?? scan.envDir ?? appDir });
@@ -1443,36 +1838,46 @@ function createDockerSshProvider(config, container) {
1443
1838
  return saved;
1444
1839
  }
1445
1840
  const composeName = `docker-compose-${artifact.appVersion}.yml`;
1446
- const latestPath = join(artifactDir, "latest");
1447
- const previousName = existsSync(latestPath) ? readFileSync(latestPath, "utf8").trim() : "";
1448
- const previousPath = /^[\w.+-]+\.yml$/.test(previousName) ? join(artifactDir, previousName) : void 0;
1449
1841
  const composeContent = generateComposeFile({
1450
1842
  projectName,
1451
1843
  appVersion: artifact.appVersion,
1452
- imageRef: app.imageRef,
1844
+ imageRef: app?.imageRef ?? "localhost/hai-unselected:0",
1453
1845
  hostPort,
1454
1846
  containerPort,
1455
1847
  sidecar,
1456
- web: web ? { imageRef: web.imageRef, containerPort: web.containerPort ?? 8080 } : void 0,
1457
- serviceModes: options?.serviceModes,
1458
- previousCompose: previousPath && existsSync(previousPath) ? readFileSync(previousPath, "utf8") : void 0
1848
+ web: hasWeb ? { imageRef: web?.imageRef ?? "localhost/hai-unselected:0", containerPort: webContainerPort } : void 0
1459
1849
  });
1460
1850
  const composePath = join(artifactDir, composeName);
1461
1851
  writeFileSync(composePath, composeContent);
1462
1852
  if (webProxy)
1463
1853
  writeFileSync(join(artifactDir, "web.conf"), webProxy);
1854
+ const selected = options?.selectedServices ? [...options.selectedServices] : artifact.partial ? images.map((image) => image.name) : [...images.map((image) => image.name), ...Object.keys(sidecar.composeServices)];
1855
+ if (!selected.length || selected.some((name) => !/^[a-z][a-z0-9-]*$/.test(name)) || new Set(selected).size !== selected.length)
1856
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_selectionInvalid", { params: { service: selected.join(", ") } }));
1857
+ for (const name of selected) {
1858
+ if (["app", "web"].includes(name) && !images.some((image) => image.name === name))
1859
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_selectionInvalid", { params: { service: name } }));
1860
+ }
1861
+ if (selected.includes("storage") && sidecar.composeServices["storage-init"] && !selected.includes("storage-init"))
1862
+ selected.push("storage-init");
1863
+ options?.onProgress?.("packaging");
1464
1864
  const bundle = await createDeploymentBundle({
1465
1865
  directory: artifactDir,
1466
1866
  artifactDir: resolvedBuildDir,
1467
1867
  artifact,
1468
1868
  projectName,
1469
1869
  environment: options?.environment ?? "prod",
1470
- runtime: config.remote.runtime,
1471
- coreEnv: env.HAI_CORE_ENV ?? "production",
1870
+ deploymentId,
1871
+ config,
1872
+ runtime: options?.runtime ?? "auto",
1873
+ selected,
1874
+ partial,
1875
+ hasWeb,
1876
+ containerPort,
1877
+ middlewareEnv: serializeEnvFile(sidecar.env),
1878
+ healthPaths: hasWeb ? ["/health", "/_hai/web-health", "/"] : [scan.isSvelteKit ? "/" : "/health"],
1472
1879
  output: options?.output ?? join(artifactDir, `bundle-${projectName}-${deploymentId}-${artifact.appVersion}.tar`)
1473
1880
  });
1474
- writeFileSync(latestPath, `${composeName}
1475
- `);
1476
1881
  if (options?.bundleOnly)
1477
1882
  return ok({ url: "", deploymentId, status: "packaged", provider: "docker-ssh", healthCheck: "skipped", envVarsSet: Object.keys(env), ...bundle });
1478
1883
  if (!ssh)
@@ -1484,22 +1889,31 @@ function createDockerSshProvider(config, container) {
1484
1889
  if (!remoteRuntime.success)
1485
1890
  return remoteRuntime;
1486
1891
  const remoteDir = `${baseDir}/${projectName}`;
1487
- const remoteTar = `${remoteDir}/${basename(bundle.bundlePath)}`;
1488
- const prepared = await ssh.exec(`mkdir -p -- ${shellArgument(remoteDir)} && chmod 700 -- ${shellArgument(remoteDir)}`);
1892
+ const remoteTar = `${remoteDir}/incoming/${basename(bundle.bundlePath)}`;
1893
+ const prepared = await ssh.exec(`mkdir -p -- ${shellArgument(`${remoteDir}/incoming`)} && chmod 700 -- ${shellArgument(remoteDir)} ${shellArgument(`${remoteDir}/incoming`)}`);
1489
1894
  if (!prepared.success)
1490
1895
  return prepared;
1491
1896
  const uploaded = await ssh.upload(bundle.bundlePath, remoteTar);
1492
1897
  if (!uploaded.success)
1493
1898
  return uploaded;
1899
+ let pendingOutput = "";
1494
1900
  const started = await ssh.exec(
1495
- `cd ${shellArgument(remoteDir)} && chmod 600 ${shellArgument(remoteTar)} && tar -xf ${shellArgument(remoteTar)} && HAI_DEPLOY_PROVIDER_REMOTE_RUNTIME=${shellArgument(remoteRuntime.data)} sh ${shellArgument(bundle.startName)}`,
1496
- { timeoutMs: 3e5, secrets: sidecar.secrets }
1901
+ `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)}`,
1902
+ { timeoutMs: 6e5, secrets: sidecar.secrets, onOutput: (output) => {
1903
+ pendingOutput += output;
1904
+ const lines = pendingOutput.split("\n");
1905
+ pendingOutput = lines.pop() ?? "";
1906
+ for (const line of lines) {
1907
+ const stage = /^\[(preflight|images|dependencies|start|health|complete)\]/.exec(line)?.[1];
1908
+ if (stage)
1909
+ options?.onProgress?.(stage);
1910
+ }
1911
+ } }
1497
1912
  );
1498
1913
  if (!started.success)
1499
1914
  return started;
1500
- await ssh.exec(`rm -f -- ${shellArgument(remoteTar)} ${images.map((image) => shellArgument(`${remoteDir}/images/${image.file}`)).join(" ")}`);
1501
1915
  logger5.info("Running health check", { url });
1502
- const healthy = await healthCheck(`${url}${!web && scan.isSvelteKit ? "/" : "/health"}`) && (!web || await healthCheck(`${url}/_hai/web-health`) && await healthCheck(`${url}/`));
1916
+ const healthy = await healthCheck(`${url}${!hasWeb && scan.isSvelteKit ? "/" : "/health"}`) && (!hasWeb || await healthCheck(`${url}/_hai/web-health`) && await healthCheck(`${url}/`));
1503
1917
  if (!healthy) {
1504
1918
  return err(
1505
1919
  HaiDeployError.HEALTH_CHECK_FAILED,
@@ -1523,7 +1937,8 @@ function createDockerSshProvider(config, container) {
1523
1937
  return await deploy2(context);
1524
1938
  } catch (error) {
1525
1939
  logger5.error("Deployment bundle failed", { error });
1526
- return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_artifactInvalid"), error);
1940
+ const reason = error instanceof Error ? error.message : String(error);
1941
+ return err(HaiDeployError.DEPLOY_FAILED, deployM("deploy_bundleFailed", { params: { reason: maskSecrets(reason, Object.values(context.appEnv ?? {}).filter((value) => value.length > 6)) } }), error);
1527
1942
  }
1528
1943
  };
1529
1944
  return { name: "docker-ssh", deploy: deploySafely };
@@ -2502,11 +2917,19 @@ var deploy = {
2502
2917
  ...scanOptions.environment ? { environment: environment.data.environment } : {},
2503
2918
  ...options?.skipMiddleware ? { serviceModes: Object.fromEntries(scan.requiredServices.map((service) => [service, "keep"])) } : {}
2504
2919
  };
2920
+ if (options.selectedServices) {
2921
+ options.serviceModes = {
2922
+ ...options.serviceModes,
2923
+ ...Object.fromEntries(scan.requiredServices.filter((service) => !options.selectedServices.includes(service)).map((service) => [service, "keep"]))
2924
+ };
2925
+ }
2505
2926
  if (currentContainerProvider) {
2506
2927
  return deployContainerApp(currentContainerProvider, appDir, scan, projectName, options, appEnv);
2507
2928
  }
2508
2929
  if (options?.bundleOnly)
2509
2930
  return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_bundleOnlyContainer"));
2931
+ if (options.selectedServices)
2932
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_selectedContainerOnly"));
2510
2933
  if (!currentProvider) {
2511
2934
  return notInitialized.result();
2512
2935
  }
@@ -2579,6 +3002,17 @@ async function deployContainerApp(provider, appDir, scan, rawProjectName, option
2579
3002
  } catch {
2580
3003
  return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_artifactInvalid"));
2581
3004
  }
3005
+ if (artifact.partial && !options?.selectedServices) {
3006
+ const selectedServices = artifact.images.map((image) => image.name);
3007
+ options = {
3008
+ ...options,
3009
+ selectedServices,
3010
+ serviceModes: {
3011
+ ...options?.serviceModes,
3012
+ ...Object.fromEntries(scan.requiredServices.map((service) => [service, "keep"]))
3013
+ }
3014
+ };
3015
+ }
2582
3016
  if (!options?.bundleOnly && currentConfig?.provider.type === "docker-ssh" && !currentConfig.provider.ssh)
2583
3017
  return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_sshRequired"));
2584
3018
  let provisionEnv = {};