@h-ai/deploy 0.1.0-alpha.65 → 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,16 +1,60 @@
1
- import { z } from 'zod';
2
- import { core, ok, err } from '@h-ai/core';
3
- import { readFileSync, existsSync, writeFileSync, chmodSync, mkdirSync, statSync, readdirSync } from 'fs';
1
+ import { createHash, randomBytes } from 'crypto';
2
+ import { createReadStream, readFileSync, existsSync, lstatSync, writeFileSync, chmodSync, mkdirSync, mkdtempSync, copyFileSync, renameSync, rmSync, statSync, readdirSync } from 'fs';
4
3
  import { resolve, join, basename, dirname, relative, sep } from 'path';
4
+ import { z } from 'zod';
5
+ import { core, err, ok } from '@h-ai/core';
5
6
  import process2 from 'process';
6
7
  import { parseEnv } from 'util';
7
- import { parse, stringify, parseAllDocuments } from 'yaml';
8
+ import { LineCounter, parseDocument, parse, stringify, parseAllDocuments } from 'yaml';
8
9
  import { homedir } from 'os';
9
- import { execSync, spawn } from 'child_process';
10
- import { randomBytes, createHash } from 'crypto';
10
+ import { spawn } from 'child_process';
11
11
  import { Buffer } from 'buffer';
12
12
 
13
- // src/deploy-config.ts
13
+ // src/deploy-artifact.ts
14
+ var DeploymentArtifactSchema = z.object({
15
+ /** 清单格式版本,与应用版本和部署环境无关。 */
16
+ version: z.literal(3),
17
+ /** 应用根 package.json 的版本,用于归档、Compose 与桌面交付;最长 80 字符。 */
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
+ /** 本次构建标识;同一应用版本允许重复构建。 */
20
+ buildId: z.string().regex(/^[a-z0-9][\w.+-]*$/i),
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
+ /** 本次构建选择的镜像归档。 */
30
+ images: z.array(z.object({
31
+ /** app 为服务或独立应用,web 为同源 API 代理的静态前端。 */
32
+ name: z.enum(["app", "web"]),
33
+ /** 相对于清单目录的 tar 文件名。 */
34
+ file: z.string().regex(/^[\w.+-]+\.tar$/),
35
+ /** 归档导入后可直接使用的不可变镜像引用。 */
36
+ imageRef: z.string().regex(/^[a-z0-9][a-z0-9./:_-]*:[a-z0-9_][a-z0-9_.-]*$/).refine((value) => !value.endsWith(":latest")),
37
+ /** tar 内容的 SHA-256,用于部署前校验。 */
38
+ sha256: z.string().regex(/^[a-f0-9]{64}$/),
39
+ /** 容器内部监听端口;服务器公开端口由部署选择。 */
40
+ containerPort: z.number().int().min(1).max(65535).optional()
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"));
43
+ async function artifactChecksum(file) {
44
+ const hash = createHash("sha256");
45
+ for await (const chunk of createReadStream(file))
46
+ hash.update(chunk);
47
+ return hash.digest("hex");
48
+ }
49
+ async function readDeploymentArtifact(directory) {
50
+ const artifact = DeploymentArtifactSchema.parse(JSON.parse(readFileSync(resolve(directory, "manifest.json"), "utf8")));
51
+ for (const image of artifact.images) {
52
+ const file = resolve(directory, image.file);
53
+ if (!existsSync(file) || !lstatSync(file).isFile() || lstatSync(file).isSymbolicLink() || await artifactChecksum(file) !== image.sha256)
54
+ throw new Error(`Invalid artifact: ${image.file}`);
55
+ }
56
+ return artifact;
57
+ }
14
58
 
15
59
  // messages/en-US.json
16
60
  var en_US_default = {
@@ -22,7 +66,6 @@ var en_US_default = {
22
66
  deploy_authRequired: "Authentication required",
23
67
  deploy_authFailed: "Authentication failed: {error}",
24
68
  deploy_projectCreateFailed: "Project creation failed: {error}",
25
- deploy_buildFailed: "Application build failed: {error}",
26
69
  deploy_uploadFailed: "Build output upload failed: {error}",
27
70
  deploy_deployFailed: "Deployment failed: {error}",
28
71
  deploy_provisionFailed: "Service provisioning failed: {service} - {error}",
@@ -42,8 +85,6 @@ var en_US_default = {
42
85
  deploy_recoverySuggestion: "Inspect listed resources in the provider console before retrying with the same project name. Unknown means the request may have created a resource. Reused resources are never deleted automatically; remove newly created resources manually only after checking their use.",
43
86
  deploy_commandTimeout: "Command timed out: {command}",
44
87
  deploy_commandFailed: "Command failed ({command}): {error}",
45
- deploy_containerRuntimeNotFound: "No usable container runtime found: {runtime}",
46
- deploy_containerBuildFailed: "Container image build failed: {error}",
47
88
  deploy_remoteConnectFailed: "Cannot connect to remote host {host}: {error}",
48
89
  deploy_remoteRuntimeMissing: "Remote host missing container runtime or compose: {runtime}",
49
90
  deploy_imageTransferFailed: "Image transfer failed: {error}",
@@ -64,7 +105,23 @@ var en_US_default = {
64
105
  deploy_purposeLdap: "Directory account authentication; the default directory has no business users",
65
106
  deploy_purposeConsole: "Messages go to application logs without delivery",
66
107
  deploy_purposeEmail: "Email/HTTP delivery; the default SMTP container only captures test messages",
67
- deploy_purposeSms: "SMS through Aliyun credentials; requires an existing signature and quota"
108
+ deploy_purposeSms: "SMS through Aliyun credentials; requires an existing signature and quota",
109
+ deploy_webProxyInvalid: "The API prefix must be an absolute path containing only letters, numbers, underscores, hyphens and slashes.",
110
+ deploy_artifactInvalid: "Image artifacts are missing or invalid. Run pnpm build --target docker, select valid --artifact-dir, or use pnpm run deploy --build.",
111
+ deploy_sshRequired: "Automatic deployment requires SSH settings. Use --bundle-only for manual transfer when the server is unreachable.",
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}",
113
+ deploy_bundleMetadata: "Application version: {version}\nBuild ID: {buildId}\nCompose file: {compose}",
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."
68
125
  };
69
126
 
70
127
  // messages/zh-CN.json
@@ -77,7 +134,6 @@ var zh_CN_default = {
77
134
  deploy_authRequired: "\u8BF7\u5148\u5B8C\u6210\u8BA4\u8BC1",
78
135
  deploy_authFailed: "\u8BA4\u8BC1\u5931\u8D25\uFF1A{error}",
79
136
  deploy_projectCreateFailed: "\u9879\u76EE\u521B\u5EFA\u5931\u8D25\uFF1A{error}",
80
- deploy_buildFailed: "\u5E94\u7528\u6784\u5EFA\u5931\u8D25\uFF1A{error}",
81
137
  deploy_uploadFailed: "\u6784\u5EFA\u4EA7\u7269\u4E0A\u4F20\u5931\u8D25\uFF1A{error}",
82
138
  deploy_deployFailed: "\u90E8\u7F72\u5931\u8D25\uFF1A{error}",
83
139
  deploy_provisionFailed: "\u57FA\u7840\u8BBE\u65BD\u5F00\u901A\u5931\u8D25\uFF1A{service} - {error}",
@@ -97,8 +153,6 @@ var zh_CN_default = {
97
153
  deploy_recoverySuggestion: "\u91CD\u8BD5\u524D\u5728\u670D\u52A1\u5546\u63A7\u5236\u53F0\u6838\u5BF9\u6E05\u5355\uFF0C\u5E76\u4F7F\u7528\u76F8\u540C\u9879\u76EE\u540D\u3002unknown \u8868\u793A\u8BF7\u6C42\u53EF\u80FD\u5DF2\u521B\u5EFA\u8D44\u6E90\u3002\u590D\u7528\u8D44\u6E90\u4E0D\u4F1A\u81EA\u52A8\u5220\u9664\uFF1B\u65B0\u5EFA\u8D44\u6E90\u4EC5\u5728\u786E\u8BA4\u672A\u88AB\u4F7F\u7528\u540E\u4EBA\u5DE5\u6E05\u7406\u3002",
98
154
  deploy_commandTimeout: "\u547D\u4EE4\u6267\u884C\u8D85\u65F6\uFF1A{command}",
99
155
  deploy_commandFailed: "\u547D\u4EE4\u6267\u884C\u5931\u8D25\uFF08{command}\uFF09\uFF1A{error}",
100
- deploy_containerRuntimeNotFound: "\u672A\u627E\u5230\u53EF\u7528\u7684\u5BB9\u5668\u8FD0\u884C\u65F6\uFF1A{runtime}",
101
- deploy_containerBuildFailed: "\u5BB9\u5668\u955C\u50CF\u6784\u5EFA\u5931\u8D25\uFF1A{error}",
102
156
  deploy_remoteConnectFailed: "\u65E0\u6CD5\u8FDE\u63A5\u8FDC\u7A0B\u4E3B\u673A {host}\uFF1A{error}",
103
157
  deploy_remoteRuntimeMissing: "\u8FDC\u7A0B\u4E3B\u673A\u7F3A\u5C11\u5BB9\u5668\u8FD0\u884C\u65F6\u6216 compose\uFF1A{runtime}",
104
158
  deploy_imageTransferFailed: "\u955C\u50CF\u4F20\u8F93\u5931\u8D25\uFF1A{error}",
@@ -119,7 +173,23 @@ var zh_CN_default = {
119
173
  deploy_purposeLdap: "\u4F01\u4E1A\u76EE\u5F55\u8D26\u53F7\u8BA4\u8BC1\uFF1B\u9ED8\u8BA4\u76EE\u5F55\u4E0D\u5305\u542B\u4E1A\u52A1\u7528\u6237",
120
174
  deploy_purposeConsole: "\u6D88\u606F\u8F93\u51FA\u5230\u5E94\u7528\u65E5\u5FD7\uFF0C\u4E0D\u5B9E\u9645\u6295\u9012",
121
175
  deploy_purposeEmail: "\u90AE\u4EF6\u6216 HTTP \u901A\u77E5\u6295\u9012\uFF1B\u9ED8\u8BA4 SMTP \u5BB9\u5668\u4EC5\u7528\u4E8E\u6D4B\u8BD5\u63A5\u6536",
122
- deploy_purposeSms: "\u901A\u8FC7\u963F\u91CC\u4E91\u51ED\u8BC1\u63A5\u5165\u77ED\u4FE1\u670D\u52A1\uFF0C\u9700\u5DF2\u6709\u7B7E\u540D\u4E0E\u989D\u5EA6"
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",
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",
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",
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",
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}",
181
+ deploy_bundleMetadata: "\u5E94\u7528\u7248\u672C\uFF1A{version}\n\u6784\u5EFA\u7F16\u53F7\uFF1A{buildId}\nCompose \u6587\u4EF6\uFF1A{compose}",
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"
123
193
  };
124
194
 
125
195
  // src/deploy-i18n.ts
@@ -135,11 +205,11 @@ var VercelProviderConfigSchema = z.object({
135
205
  });
136
206
  var SshConfigSchema = z.object({
137
207
  /** 远程主机地址或 ~/.ssh/config 中的 Host 别名 */
138
- host: z.string().min(1),
208
+ host: z.string().regex(/^[a-z0-9][a-z0-9.:-]*$/i),
139
209
  /** SSH 端口 */
140
210
  port: z.number().int().min(1).max(65535).default(22),
141
211
  /** SSH 用户名 */
142
- username: z.string().min(1),
212
+ username: z.string().regex(/^[a-z_][\w-]*$/i),
143
213
  /** 私钥文件路径(省略时使用 ssh-agent 或 ~/.ssh 默认密钥) */
144
214
  identityFile: z.string().min(1).optional(),
145
215
  /** known_hosts 文件路径(省略时使用默认;配合 CI/临时环境隔离主机指纹) */
@@ -160,11 +230,11 @@ var ExposeSchema = PortExposeSchema;
160
230
  var DockerSshProviderConfigSchema = z.object({
161
231
  type: z.literal("docker-ssh"),
162
232
  /** SSH 连接参数 */
163
- ssh: SshConfigSchema,
233
+ ssh: SshConfigSchema.optional(),
164
234
  /** 远程主机运行参数 */
165
235
  remote: z.object({
166
236
  /** 远程应用根目录 */
167
- baseDir: z.string().min(1).default("/opt/hai/apps"),
237
+ baseDir: z.string().regex(/^\/[\w/. -]+$/).refine((value) => !value.split("/").includes("..")).default("/opt/hai/apps"),
168
238
  /** 远程容器运行时(auto 优先 podman) */
169
239
  runtime: z.enum(["auto", "docker", "podman"]).default("auto")
170
240
  }).default({ baseDir: "/opt/hai/apps", runtime: "auto" }),
@@ -214,24 +284,15 @@ var DeployConfigSchema = z.object({
214
284
  environment: z.string().regex(/^[a-z0-9][\w-]*$/i).refine((value) => !["local", "example"].includes(value.toLowerCase())).optional(),
215
285
  provider: ProviderConfigSchema,
216
286
  services: ServicesConfigSchema,
217
- /** 本地容器构建参数(docker-ssh 使用;auto 优先 podman) */
287
+ /** Docker Compose 部署参数;构建配置位于项目 hai.build.json。 */
218
288
  container: z.object({
219
- runtime: z.enum(["auto", "docker", "podman"]).default("auto"),
220
- /** Podman 构建是否继承代理;容器不能访问宿主机 localhost 代理时设为 false。 */
221
- httpProxy: z.boolean().default(true),
222
289
  /** 自建 S3 服务实现;协议仍为 storage.type=s3。 */
223
- s3Engine: z.enum(["rustfs", "minio"]).default("rustfs"),
224
- /** SQLite 预编译包镜像根地址;省略时从官方 GitHub Release 下载,不允许嵌入凭据。 */
225
- sqlitePrebuildMirror: z.url().refine((value) => {
226
- const url = new URL(value);
227
- return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash;
228
- }).optional()
290
+ s3Engine: z.enum(["rustfs", "minio"]).default("rustfs")
229
291
  }).optional()
230
292
  });
231
293
  var DeployErrorInfo = {
232
294
  DEPLOY_FAILED: "001:500",
233
295
  PROJECT_CREATE_FAILED: "002:500",
234
- BUILD_FAILED: "003:500",
235
296
  UPLOAD_FAILED: "004:500",
236
297
  AUTH_REQUIRED: "005:401",
237
298
  AUTH_FAILED: "006:401",
@@ -243,8 +304,6 @@ var DeployErrorInfo = {
243
304
  UNSUPPORTED_TYPE: "012:400",
244
305
  CONFIG_ERROR: "013:500",
245
306
  CREDENTIAL_ERROR: "014:500",
246
- CONTAINER_RUNTIME_NOT_FOUND: "015:500",
247
- CONTAINER_BUILD_FAILED: "016:500",
248
307
  REMOTE_CONNECT_FAILED: "017:500",
249
308
  REMOTE_COMMAND_FAILED: "018:500",
250
309
  IMAGE_TRANSFER_FAILED: "019:500",
@@ -253,9 +312,16 @@ var DeployErrorInfo = {
253
312
  var HaiDeployError = core.error.buildHaiErrorsDef("deploy", DeployErrorInfo);
254
313
 
255
314
  // src/deploy-environment.ts
315
+ function environmentValue(value) {
316
+ try {
317
+ return parse(value) ?? value;
318
+ } catch {
319
+ return value;
320
+ }
321
+ }
256
322
  function resolveConfig(value, prefix, env) {
257
323
  if (env[prefix] !== void 0)
258
- return parse(env[prefix]) ?? env[prefix];
324
+ return environmentValue(env[prefix]);
259
325
  if (Array.isArray(value))
260
326
  return value.map((item, index) => resolveConfig(item, `${prefix}_${index}`, env));
261
327
  if (value && typeof value === "object")
@@ -263,14 +329,20 @@ function resolveConfig(value, prefix, env) {
263
329
  if (typeof value !== "string")
264
330
  return value;
265
331
  const resolved = value.replace(/\$\{([^:}]+)(?::([^}]*))?\}/g, (_match, name, fallback) => env[name] ?? fallback ?? "");
266
- return /^\$\{[^}]+\}$/.test(value) ? parse(resolved) ?? resolved : resolved;
332
+ return /^\$\{[^}]+\}$/.test(value) ? environmentValue(resolved) : resolved;
267
333
  }
268
334
  function loadDeploymentConfig(name, path, values) {
269
335
  try {
270
- 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" } }));
271
343
  return ok(config && typeof config === "object" && !Array.isArray(config) ? config : {});
272
344
  } catch {
273
- return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_environmentReadFailed"));
345
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_configFileUnreadable", { params: { path } }));
274
346
  }
275
347
  }
276
348
  function loadDeploymentEnvironment(options) {
@@ -531,39 +603,25 @@ function saveCredentials(entries) {
531
603
  );
532
604
  }
533
605
  }
534
- var logger2 = core.logger.child({ module: "deploy", scope: "functions" });
606
+ function resolveArtifactDirectory(appDir) {
607
+ let directory = resolve(appDir);
608
+ while (true) {
609
+ if (existsSync(join(directory, "hai.build.json")) || existsSync(join(directory, "pnpm-lock.yaml")))
610
+ return join(directory, ".hai/build/docker");
611
+ const parent = dirname(directory);
612
+ if (parent === directory)
613
+ return join(appDir, ".hai/build/docker");
614
+ directory = parent;
615
+ }
616
+ }
535
617
  function usesDefaultMiddleware(scan, service, options) {
536
618
  const requirements = scan.middlewares?.filter((item) => item.service === service) ?? [];
537
619
  return requirements.length ? requirements.some((item) => (options?.serviceModes?.[item.envPrefix] ?? options?.serviceModes?.[service] ?? "default") === "default") : (options?.serviceModes?.[service] ?? "default") === "default";
538
620
  }
539
- function buildApp(appDir, buildCommand, envVars = {}) {
540
- logger2.info("Building application", { appDir, buildCommand });
541
- try {
542
- execSync(buildCommand, {
543
- cwd: appDir,
544
- stdio: "inherit",
545
- env: { ...process2.env, ...envVars }
546
- });
547
- return ok(void 0);
548
- } catch (error) {
549
- return err(
550
- HaiDeployError.BUILD_FAILED,
551
- deployM("deploy_buildFailed", {
552
- params: { error: error instanceof Error ? error.message : String(error) }
553
- }),
554
- error
555
- );
556
- }
557
- }
558
621
  function resolveOutputDir(appDir, providerType) {
559
622
  switch (providerType) {
560
- case "vercel": {
561
- const vercelOutput = join(appDir, ".vercel", "output");
562
- if (existsSync(vercelOutput)) {
563
- return vercelOutput;
564
- }
565
- return join(appDir, "build");
566
- }
623
+ case "vercel":
624
+ return join(appDir, ".vercel", "output");
567
625
  default:
568
626
  return join(appDir, "build");
569
627
  }
@@ -594,7 +652,7 @@ function recoveryFailure(error, recovery) {
594
652
  ext: { recovery }
595
653
  });
596
654
  }
597
- var logger3 = core.logger.child({ module: "deploy", scope: "scanner" });
655
+ var logger2 = core.logger.child({ module: "deploy", scope: "scanner" });
598
656
  function record(value) {
599
657
  return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
600
658
  }
@@ -713,26 +771,522 @@ async function scanApp(inputDir, options) {
713
771
  adapterInstalled: graph.build.has("@sveltejs/adapter-vercel"),
714
772
  nodeAdapterInstalled: graph.build.has("@sveltejs/adapter-node"),
715
773
  requiredServices: [...new Set(middlewares.map((item) => item.service))],
716
- serviceBackends: Object.fromEntries(middlewares.map((item) => [item.service, item.backend])),
717
- buildCommand: "pnpm build"
774
+ serviceBackends: Object.fromEntries(middlewares.map((item) => [item.service, item.backend]))
718
775
  };
719
- logger3.info("Application scanned", { appName: scan.appName, haiModules: scan.haiModules, requiredServices: scan.requiredServices, serviceBackends: scan.serviceBackends });
776
+ logger2.info("Application scanned", { appName: scan.appName, haiModules: scan.haiModules, requiredServices: scan.requiredServices, serviceBackends: scan.serviceBackends });
720
777
  return ok(scan);
721
778
  } catch {
722
779
  return err(HaiDeployError.SCAN_FAILED, deployM("deploy_scanFailed", { params: { error: "Invalid or ambiguous application/configuration/lockfile; specify the application directory and run pnpm install" } }));
723
780
  }
724
781
  }
782
+ var logger3 = core.logger.child({ module: "deploy", scope: "command" });
783
+ function maskSecrets(text, secrets) {
784
+ if (!secrets || secrets.length === 0)
785
+ return text;
786
+ let masked = text;
787
+ for (const secret of secrets) {
788
+ if (secret && secret.length > 0)
789
+ masked = masked.split(secret).join("***");
790
+ }
791
+ return masked;
792
+ }
793
+ function runCommand(command, args, options = {}) {
794
+ return new Promise((resolve6) => {
795
+ const child = spawn(command, args, {
796
+ windowsHide: true,
797
+ cwd: options.cwd,
798
+ env: options.env ? { ...process2.env, ...options.env } : process2.env,
799
+ shell: false
800
+ });
801
+ let stdout = "";
802
+ let stderr = "";
803
+ let settled = false;
804
+ let timer;
805
+ const finish = (result) => {
806
+ if (settled)
807
+ return;
808
+ settled = true;
809
+ if (timer)
810
+ clearTimeout(timer);
811
+ resolve6(result);
812
+ };
813
+ if (options.timeoutMs && options.timeoutMs > 0) {
814
+ timer = setTimeout(() => {
815
+ child.kill("SIGKILL");
816
+ logger3.error("Command timed out", { command, timeoutMs: options.timeoutMs });
817
+ finish(err(
818
+ HaiDeployError.REMOTE_COMMAND_FAILED,
819
+ deployM("deploy_commandTimeout", { params: { command } })
820
+ ));
821
+ }, options.timeoutMs);
822
+ }
823
+ child.stdout.on("data", (chunk) => {
824
+ stdout += chunk.toString("utf-8");
825
+ options.onOutput?.(maskSecrets(chunk.toString("utf-8"), options.secrets));
826
+ });
827
+ child.stderr.on("data", (chunk) => {
828
+ stderr += chunk.toString("utf-8");
829
+ });
830
+ child.on("error", (error) => {
831
+ logger3.error("Command failed to start", { command, error: maskSecrets(String(error), options.secrets) });
832
+ finish(err(
833
+ HaiDeployError.REMOTE_COMMAND_FAILED,
834
+ deployM("deploy_commandFailed", {
835
+ params: { command, error: maskSecrets(error.message, options.secrets) }
836
+ }),
837
+ error
838
+ ));
839
+ });
840
+ child.on("close", (code) => {
841
+ const exitCode = code ?? -1;
842
+ if (exitCode === 0) {
843
+ finish(ok({ code: exitCode, stdout, stderr }));
844
+ return;
845
+ }
846
+ logger3.error("Command exited with non-zero code", {
847
+ command,
848
+ code: exitCode,
849
+ stderr: maskSecrets(stderr, options.secrets).slice(-500),
850
+ stdout: maskSecrets(stdout, options.secrets).slice(-3e3)
851
+ });
852
+ finish(err(
853
+ HaiDeployError.REMOTE_COMMAND_FAILED,
854
+ deployM("deploy_commandFailed", {
855
+ params: { command, error: maskSecrets(`${stdout.slice(-2500)}
856
+ ${stderr || `exit ${exitCode}`}`, options.secrets).slice(-3e3) }
857
+ })
858
+ ));
859
+ });
860
+ if (options.input !== void 0) {
861
+ child.stdin.write(options.input);
862
+ child.stdin.end();
863
+ } else {
864
+ child.stdin.end();
865
+ }
866
+ });
867
+ }
868
+
869
+ // src/container/deploy-scripts.ts
870
+ function shellArgument(value) {
871
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
872
+ }
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
886
+ set -eu
887
+ umask 077
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
997
+ if [ "$runtime" = auto ]; then
998
+ if command -v podman >/dev/null 2>&1 && podman version >/dev/null 2>&1; then runtime=podman; else runtime=docker; fi
999
+ fi
1000
+ case "$runtime" in docker|podman) ;; *) printf 'Runtime must be docker or podman\n' >&2; exit 1;; esac
1001
+ "$runtime" version >/dev/null
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"
1007
+ sha256sum -c SHA256SUMS
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; }
1089
+ done
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 }
1157
+ `;
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" } })}
1252
+ ${instructions}
1253
+ `);
1254
+ const checksums = [];
1255
+ for (const file of files)
1256
+ checksums.push(`${await artifactChecksum(join(staging, file))} ${file}`);
1257
+ writeFileSync(join(staging, "SHA256SUMS"), `${checksums.join("\n")}
1258
+ `);
1259
+ const tar = await runCommand("tar", ["-cf", temporaryTar, "-C", staging, "."]);
1260
+ if (!tar.success)
1261
+ throw new Error(tar.error.message);
1262
+ chmodSync(temporaryTar, 384);
1263
+ renameSync(temporaryTar, output);
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" };
1274
+ } finally {
1275
+ rmSync(staging, { recursive: true, force: true });
1276
+ rmSync(temporaryTar, { force: true });
1277
+ }
1278
+ }
725
1279
  var IMAGES = {
726
- postgres: "postgres:16-alpine",
727
- mysql: "mysql:8.4",
728
- 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",
729
1283
  rustfs: "ghcr.io/rustfs/rustfs:1.0.1",
730
1284
  minio: "ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z",
731
- qdrant: "qdrant/qdrant:v1.12.4",
732
- pgvector: "pgvector/pgvector:pg17",
733
- 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",
734
1288
  ldap: "docker.io/osixia/openldap:1.5.0",
735
- smtp: "axllent/mailpit:v1.27.4"
1289
+ smtp: "docker.io/axllent/mailpit:v1.27.4"
736
1290
  };
737
1291
  function generatePassword() {
738
1292
  return randomBytes(24).toString("base64url");
@@ -741,8 +1295,12 @@ function resolveSidecarSecrets(appDir, projectName) {
741
1295
  const secretFile = join(appDir, ".hai", "deploy", "runtime", `${projectName}.secrets.json`);
742
1296
  if (existsSync(secretFile)) {
743
1297
  try {
744
- 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;
745
1302
  } catch {
1303
+ throw new Error(deployM("deploy_secretsInvalid", { params: { path: secretFile } }));
746
1304
  }
747
1305
  }
748
1306
  const secrets = {
@@ -910,6 +1468,28 @@ function planChromaVecdb(plan, projectName, net) {
910
1468
  plan.env.HAI_VECDB = JSON.stringify({ type: "chroma", url: "http://vecdb:8000" });
911
1469
  }
912
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 };
913
1493
  const plan = {
914
1494
  services: [],
915
1495
  composeServices: {},
@@ -995,6 +1575,7 @@ function generateComposeFile(options) {
995
1575
  const { projectName, imageRef, hostPort, containerPort, sidecar } = options;
996
1576
  const app = {
997
1577
  image: imageRef,
1578
+ labels: { "org.opencontainers.image.version": options.appVersion },
998
1579
  container_name: `hai-${projectName}-app`,
999
1580
  restart: "unless-stopped",
1000
1581
  // 镜像已由 load 注入本地,禁止 compose 再从 registry 拉取
@@ -1008,41 +1589,76 @@ function generateComposeFile(options) {
1008
1589
  if (sidecar.dependsOn.length > 0)
1009
1590
  app.depends_on = sidecar.dependsOn;
1010
1591
  const services = { app, ...sidecar.composeServices };
1592
+ if (options.web) {
1593
+ delete app.ports;
1594
+ services.web = {
1595
+ image: options.web.imageRef,
1596
+ labels: { "org.opencontainers.image.version": options.appVersion },
1597
+ container_name: `hai-${projectName}-web`,
1598
+ restart: "unless-stopped",
1599
+ pull_policy: "never",
1600
+ ports: [`${hostPort}:${options.web.containerPort}`],
1601
+ environment: { PUBLIC_API_BASE: "" },
1602
+ healthcheck: { test: ["CMD", "wget", "-Y", "off", "-q", "-O", "/dev/null", `http://127.0.0.1:${options.web.containerPort}/_hai/web-health`], interval: "30s", timeout: "5s", retries: 3 },
1603
+ volumes: ["./web.conf:/etc/nginx/conf.d/default.conf:ro"],
1604
+ depends_on: ["app"],
1605
+ networks: ["hai-app"]
1606
+ };
1607
+ }
1011
1608
  if (services["storage-init"])
1012
1609
  services["storage-init"] = { ...services["storage-init"], image: imageRef };
1013
1610
  const compose = {
1611
+ "x-hai-version": options.appVersion,
1014
1612
  services,
1015
- networks: {
1613
+ "networks": {
1016
1614
  "hai-app": { name: `hai-${projectName}` }
1017
1615
  }
1018
1616
  };
1019
1617
  if (Object.keys(sidecar.volumes).length > 0)
1020
1618
  compose.volumes = sidecar.volumes;
1021
- if (options.previousCompose && options.serviceModes) {
1022
- const previous = parse(options.previousCompose);
1023
- for (const [service, mode] of Object.entries(options.serviceModes)) {
1024
- if (mode !== "keep")
1025
- continue;
1026
- for (const name of [service, `${service}-init`]) {
1027
- if (previous.services?.[name]) {
1028
- services[name] = previous.services[name];
1029
- app.depends_on = [.../* @__PURE__ */ new Set([...app.depends_on ?? [], service])];
1030
- }
1031
- }
1032
- }
1033
- const previousMounts = previous.services?.app?.volumes;
1034
- if (Array.isArray(previousMounts) && Object.values(options.serviceModes).includes("keep"))
1035
- app.volumes = [.../* @__PURE__ */ new Set([...app.volumes ?? [], ...previousMounts])];
1036
- compose.volumes = { ...previous.volumes, ...sidecar.volumes };
1037
- }
1038
1619
  if (services["storage-init"]) {
1039
1620
  app.depends_on = {
1040
1621
  ...Object.fromEntries((app.depends_on ?? []).map((name) => [name, { condition: "service_started" }])),
1041
1622
  "storage-init": { condition: "service_completed_successfully" }
1042
1623
  };
1043
1624
  }
1625
+ for (const service of Object.values(services))
1626
+ service.pull_policy = "never";
1044
1627
  return stringify(compose);
1045
1628
  }
1629
+ function generateWebProxyConfig(port, apiPrefix, webPort = 8080) {
1630
+ if (!/^\/[\w/-]+$/.test(apiPrefix) || ![port, webPort].every((value) => Number.isInteger(value) && value >= 1 && value <= 65535))
1631
+ throw new Error("Invalid web proxy target");
1632
+ return `server {
1633
+ listen ${webPort};
1634
+ server_name _;
1635
+ root /usr/share/nginx/html;
1636
+ location / {
1637
+ try_files $uri $uri/ /index.html;
1638
+ }
1639
+ location = /runtime-config.js {
1640
+ add_header Cache-Control "no-store";
1641
+ default_type application/javascript;
1642
+ return 200 'globalThis.__HAI_PUBLIC_CONFIG__={apiBase:globalThis.location.origin+"${apiPrefix}"};';
1643
+ }
1644
+ location = /_hai/web-health {
1645
+ access_log off;
1646
+ return 200 'ok';
1647
+ }
1648
+ location = /health {
1649
+ proxy_pass http://app:${port};
1650
+ proxy_set_header Host $http_host;
1651
+ }
1652
+ location ${apiPrefix.replace(/\/$/, "")}/ {
1653
+ proxy_pass http://app:${port};
1654
+ proxy_http_version 1.1;
1655
+ proxy_set_header Host $http_host;
1656
+ proxy_set_header X-Forwarded-Proto $scheme;
1657
+ proxy_buffering off;
1658
+ }
1659
+ }
1660
+ `;
1661
+ }
1046
1662
  function serializeEnvFile(env) {
1047
1663
  return `${Object.entries(env).map(([key, value]) => {
1048
1664
  let literal = value;
@@ -1058,194 +1674,7 @@ function serializeEnvFile(env) {
1058
1674
  }).join("\n")}
1059
1675
  `;
1060
1676
  }
1061
- function generateDockerignore() {
1062
- return `**/node_modules
1063
- **/dist
1064
- **/build
1065
- **/.turbo
1066
- **/.svelte-kit
1067
- **/coverage
1068
- **/test-results
1069
- **/playwright-report
1070
- **/.hai/deploy
1071
- **/.env.local
1072
- **/.env.*.local
1073
- **/.env.runtime
1074
- .git
1075
- `;
1076
- }
1077
- function generateFallbackDockerfile(packageName, containerPort, options = {}) {
1078
- if (!/^@?[a-z0-9][\w./-]*$/i.test(packageName) || !/^[a-z0-9][\w-]*$/i.test(options.environment ?? "prod"))
1079
- throw new Error("Invalid container build target");
1080
- const environment = options.environment ?? "prod";
1081
- const coreEnv = environment === "prod" ? "production" : environment === "dev" ? "development" : environment === "test" ? "test" : "staging";
1082
- return `# syntax=docker/dockerfile:1.7
1083
- FROM node:24-bookworm-slim AS build
1084
- ARG SQLITE_PREBUILD_MIRROR
1085
- ENV npm_config_better_sqlite3_binary_host_mirror=$SQLITE_PREBUILD_MIRROR
1086
- ENV PNPM_HOME=/pnpm
1087
- ENV PATH=$PNPM_HOME:$PATH
1088
- ENV CI=true
1089
- ENV HAI_DEPLOY_ENVIRONMENT=${environment}
1090
- ENV HAI_CORE_ENV=${coreEnv}
1091
- WORKDIR /workspace
1092
- RUN corepack enable && corepack prepare pnpm@12.11.2 --activate
1093
- COPY . .
1094
- RUN pnpm install --frozen-lockfile --ignore-scripts
1095
- RUN pnpm --filter ${packageName}... build
1096
- RUN if [ -d node_modules/better-sqlite3 ]; then cd node_modules/better-sqlite3; for attempt in 1 2 3; do node -e "require('node:child_process').execFileSync(process.execPath,[require.resolve('prebuild-install/bin.js'),'--verbose'],{stdio:'inherit'})" && break; [ "$attempt" = 3 ] && exit 1; sleep 3; done; fi
1097
- RUN pnpm pm deploy --filter ${packageName} --prod /prod/app
1098
-
1099
- FROM node:24-bookworm-slim AS runtime
1100
- ENV NODE_ENV=production
1101
- ENV HAI_DEPLOY_ENVIRONMENT=${environment}
1102
- ENV HAI_CORE_ENV=${coreEnv}
1103
- ENV HOST=0.0.0.0
1104
- ENV PORT=${containerPort}
1105
- WORKDIR /app
1106
- COPY --from=build --chown=node:node /prod/app ./
1107
- RUN mkdir -p data && chown node:node data
1108
- USER node
1109
- EXPOSE ${containerPort}
1110
- CMD ["node", ${JSON.stringify(options.entrypoint ?? "build")}]
1111
- `;
1112
- }
1113
- var logger4 = core.logger.child({ module: "deploy", scope: "command" });
1114
- function maskSecrets(text, secrets) {
1115
- if (!secrets || secrets.length === 0)
1116
- return text;
1117
- let masked = text;
1118
- for (const secret of secrets) {
1119
- if (secret && secret.length > 0)
1120
- masked = masked.split(secret).join("***");
1121
- }
1122
- return masked;
1123
- }
1124
- function runCommand(command, args, options = {}) {
1125
- return new Promise((resolve3) => {
1126
- const child = spawn(command, args, {
1127
- cwd: options.cwd,
1128
- env: options.env ? { ...process2.env, ...options.env } : process2.env,
1129
- shell: false
1130
- });
1131
- let stdout = "";
1132
- let stderr = "";
1133
- let settled = false;
1134
- let timer;
1135
- const finish = (result) => {
1136
- if (settled)
1137
- return;
1138
- settled = true;
1139
- if (timer)
1140
- clearTimeout(timer);
1141
- resolve3(result);
1142
- };
1143
- if (options.timeoutMs && options.timeoutMs > 0) {
1144
- timer = setTimeout(() => {
1145
- child.kill("SIGKILL");
1146
- logger4.error("Command timed out", { command, timeoutMs: options.timeoutMs });
1147
- finish(err(
1148
- HaiDeployError.REMOTE_COMMAND_FAILED,
1149
- deployM("deploy_commandTimeout", { params: { command } })
1150
- ));
1151
- }, options.timeoutMs);
1152
- }
1153
- child.stdout.on("data", (chunk) => {
1154
- stdout += chunk.toString("utf-8");
1155
- });
1156
- child.stderr.on("data", (chunk) => {
1157
- stderr += chunk.toString("utf-8");
1158
- });
1159
- child.on("error", (error) => {
1160
- logger4.error("Command failed to start", { command, error: maskSecrets(String(error), options.secrets) });
1161
- finish(err(
1162
- HaiDeployError.REMOTE_COMMAND_FAILED,
1163
- deployM("deploy_commandFailed", {
1164
- params: { command, error: maskSecrets(error.message, options.secrets) }
1165
- }),
1166
- error
1167
- ));
1168
- });
1169
- child.on("close", (code) => {
1170
- const exitCode = code ?? -1;
1171
- if (exitCode === 0) {
1172
- finish(ok({ code: exitCode, stdout, stderr }));
1173
- return;
1174
- }
1175
- logger4.error("Command exited with non-zero code", {
1176
- command,
1177
- code: exitCode,
1178
- stderr: maskSecrets(stderr, options.secrets).slice(-500),
1179
- stdout: maskSecrets(stdout, options.secrets).slice(-3e3)
1180
- });
1181
- finish(err(
1182
- HaiDeployError.REMOTE_COMMAND_FAILED,
1183
- deployM("deploy_commandFailed", {
1184
- params: { command, error: maskSecrets(`${stdout.slice(-2500)}
1185
- ${stderr || `exit ${exitCode}`}`, options.secrets).slice(-3e3) }
1186
- })
1187
- ));
1188
- });
1189
- if (options.input !== void 0) {
1190
- child.stdin.write(options.input);
1191
- child.stdin.end();
1192
- } else {
1193
- child.stdin.end();
1194
- }
1195
- });
1196
- }
1197
-
1198
- // src/container/deploy-container-runtime.ts
1199
- var logger5 = core.logger.child({ module: "deploy", scope: "container-runtime" });
1200
- async function detectLocalRuntime(preferred = "auto") {
1201
- const candidates = preferred === "auto" ? ["podman", "docker"] : [preferred];
1202
- for (const runtime of candidates) {
1203
- const probe = await runCommand(runtime, ["version"], { timeoutMs: 15e3 });
1204
- if (probe.success) {
1205
- logger5.info("Local container runtime detected", { runtime });
1206
- return ok(runtime);
1207
- }
1208
- }
1209
- return err(
1210
- HaiDeployError.CONTAINER_RUNTIME_NOT_FOUND,
1211
- deployM("deploy_containerRuntimeNotFound", { params: { runtime: candidates.join(" / ") } })
1212
- );
1213
- }
1214
- async function buildImage(runtime, options) {
1215
- const args = ["build", "-f", options.dockerfilePath, "-t", options.imageRef];
1216
- if (runtime === "podman")
1217
- args.push("--format", "docker", "--jobs", "1");
1218
- if (runtime === "podman" && existsSync(`${options.dockerfilePath}.dockerignore`))
1219
- args.push("--ignorefile", `${options.dockerfilePath}.dockerignore`);
1220
- if (runtime === "podman" && options.httpProxy === false)
1221
- args.push("--http-proxy=false");
1222
- for (const [key, value] of Object.entries(options.buildArgs ?? {}))
1223
- args.push("--build-arg", `${key}=${value}`);
1224
- args.push(options.contextDir);
1225
- logger5.info("Building container image", { runtime, imageRef: options.imageRef });
1226
- const result = await runCommand(runtime, args, { cwd: options.contextDir });
1227
- if (!result.success) {
1228
- return err(
1229
- HaiDeployError.CONTAINER_BUILD_FAILED,
1230
- deployM("deploy_containerBuildFailed", { params: { error: result.error.message } }),
1231
- result.error
1232
- );
1233
- }
1234
- return ok(void 0);
1235
- }
1236
- async function saveImage(runtime, imageRef, tarPath) {
1237
- logger5.info("Saving container image", { runtime, imageRef, tarPath });
1238
- const result = await runCommand(runtime, ["save", "-o", tarPath, imageRef], {});
1239
- if (!result.success) {
1240
- return err(
1241
- HaiDeployError.CONTAINER_BUILD_FAILED,
1242
- deployM("deploy_containerBuildFailed", { params: { error: result.error.message } }),
1243
- result.error
1244
- );
1245
- }
1246
- return ok(void 0);
1247
- }
1248
- var logger6 = core.logger.child({ module: "deploy", scope: "ssh" });
1677
+ var logger4 = core.logger.child({ module: "deploy", scope: "ssh" });
1249
1678
  function buildConnectOptions(config) {
1250
1679
  const args = ["-o", "BatchMode=yes"];
1251
1680
  args.push("-o", `ConnectTimeout=${config.connectTimeoutSeconds}`);
@@ -1273,14 +1702,15 @@ function buildScpUploadArgs(config, localPath, remotePath) {
1273
1702
  "-P",
1274
1703
  String(config.port),
1275
1704
  localPath,
1276
- `${config.username}@${config.host}:${remotePath}`
1705
+ `${config.username}@${config.host.includes(":") ? `[${config.host}]` : config.host}:${remotePath}`
1277
1706
  ];
1278
1707
  }
1279
1708
  function createSshExecutor(config) {
1280
1709
  const exec = async (remoteCommand, options) => {
1281
1710
  return runCommand("ssh", buildSshExecArgs(config, remoteCommand), {
1282
1711
  timeoutMs: options?.timeoutMs,
1283
- secrets: options?.secrets
1712
+ secrets: options?.secrets,
1713
+ onOutput: options?.onOutput
1284
1714
  });
1285
1715
  };
1286
1716
  const upload = async (localPath, remotePath) => {
@@ -1296,7 +1726,7 @@ function createSshExecutor(config) {
1296
1726
  return ok(void 0);
1297
1727
  };
1298
1728
  const ping = async () => {
1299
- logger6.debug("SSH preflight", { host: config.host, port: config.port });
1729
+ logger4.debug("SSH preflight", { host: config.host, port: config.port });
1300
1730
  const result = await exec("true", { timeoutMs: 2e4 });
1301
1731
  if (!result.success) {
1302
1732
  return err(
@@ -1311,52 +1741,24 @@ function createSshExecutor(config) {
1311
1741
  }
1312
1742
 
1313
1743
  // src/providers/deploy-provider-docker-ssh.ts
1314
- var logger7 = core.logger.child({ module: "deploy", scope: "provider-docker-ssh" });
1744
+ var logger5 = core.logger.child({ module: "deploy", scope: "provider-docker-ssh" });
1315
1745
  var HEALTH_POLL_INTERVAL = 3e3;
1316
1746
  var HEALTH_MAX_ATTEMPTS = 40;
1317
- function shellArgument(value) {
1318
- return `'${value.replaceAll("'", `'"'"'`)}'`;
1319
- }
1320
1747
  function makeDeploymentId() {
1321
1748
  const now = /* @__PURE__ */ new Date();
1322
1749
  const pad = (n) => String(n).padStart(2, "0");
1323
1750
  const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
1324
1751
  return `${stamp}-${randomBytes(3).toString("hex")}`;
1325
1752
  }
1326
- function parseLoadedImageRef(stdout, fallback) {
1327
- const match = stdout.match(/Loaded image(?:\(s\))?:\s*(\S+)/);
1328
- return match ? match[1] : fallback;
1329
- }
1330
1753
  function sleep(ms) {
1331
- return new Promise((resolve3) => setTimeout(resolve3, ms));
1332
- }
1333
- function readPackageName(appDir) {
1334
- try {
1335
- const pkg = JSON.parse(readFileSync(join(appDir, "package.json"), "utf-8"));
1336
- return pkg.name ?? "app";
1337
- } catch {
1338
- return "app";
1339
- }
1340
- }
1341
- function resolveWorkspaceRoot(appDir) {
1342
- if (existsSync(join(appDir, "pnpm-workspace.yaml")))
1343
- return appDir;
1344
- let current = appDir;
1345
- let parent = dirname(current);
1346
- while (parent !== current) {
1347
- if (existsSync(join(parent, "pnpm-workspace.yaml")))
1348
- return parent;
1349
- current = parent;
1350
- parent = dirname(parent);
1351
- }
1352
- return appDir;
1754
+ return new Promise((resolve6) => setTimeout(resolve6, ms));
1353
1755
  }
1354
1756
  function createDockerSshProvider(config, container) {
1355
- const localRuntimePref = container?.runtime ?? "auto";
1356
- const httpProxy = container?.httpProxy ?? true;
1357
- const ssh = createSshExecutor(config.ssh);
1757
+ const ssh = config.ssh ? createSshExecutor(config.ssh) : void 0;
1358
1758
  const baseDir = config.remote.baseDir;
1359
1759
  async function detectRemoteRuntime() {
1760
+ if (!ssh)
1761
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_sshRequired"));
1360
1762
  const candidates = config.remote.runtime === "auto" ? ["podman", "docker"] : [config.remote.runtime];
1361
1763
  for (const runtime of candidates) {
1362
1764
  const versionResult = await ssh.exec(`${runtime} version`, { timeoutMs: 2e4 });
@@ -1364,7 +1766,7 @@ function createDockerSshProvider(config, container) {
1364
1766
  continue;
1365
1767
  const composeResult = await ssh.exec(`${runtime} compose version`, { timeoutMs: 2e4 });
1366
1768
  if (composeResult.success) {
1367
- logger7.info("Remote container runtime detected", { runtime });
1769
+ logger5.info("Remote container runtime detected", { runtime });
1368
1770
  return ok(runtime);
1369
1771
  }
1370
1772
  }
@@ -1388,32 +1790,44 @@ function createDockerSshProvider(config, container) {
1388
1790
  const deploy2 = async (context) => {
1389
1791
  const { appDir, projectName, scan, provisionEnv, appEnv, options } = context;
1390
1792
  const deploymentId = makeDeploymentId();
1391
- const containerPort = config.expose.containerPort;
1392
1793
  const hostPort = config.expose.hostPort;
1393
- const localImageTag = `hai-${projectName}:${deploymentId}`;
1394
- const runtimeResult = await detectLocalRuntime(localRuntimePref);
1395
- if (!runtimeResult.success)
1396
- return err(runtimeResult.error);
1397
- const localRuntime = runtimeResult.data;
1398
1794
  const artifactDir = join(appDir, ".hai", "deploy", options?.environment ?? "prod");
1399
- mkdirSync(artifactDir, { recursive: true });
1400
- const appDockerfile = join(appDir, "Dockerfile");
1401
- let dockerfilePath;
1402
- let contextDir;
1403
- if (existsSync(appDockerfile)) {
1404
- dockerfilePath = appDockerfile;
1405
- contextDir = appDir;
1406
- } else {
1407
- dockerfilePath = join(artifactDir, "Dockerfile");
1408
- writeFileSync(dockerfilePath, generateFallbackDockerfile(readPackageName(appDir), containerPort, { environment: options?.environment, entrypoint: scan.isSvelteKit ? "build" : "dist/index.js" }));
1409
- writeFileSync(`${dockerfilePath}.dockerignore`, generateDockerignore());
1410
- contextDir = resolveWorkspaceRoot(appDir);
1411
- logger7.info("Generated fallback Dockerfile", { dockerfilePath, contextDir });
1795
+ mkdirSync(artifactDir, { recursive: true, mode: 448 });
1796
+ chmodSync(artifactDir, 448);
1797
+ const artifact = context.artifact;
1798
+ const resolvedBuildDir = context.artifactDir;
1799
+ const images = artifact.images;
1800
+ const web = images.find((image) => image.name === "web");
1801
+ const app = images.find((image) => image.name === "app");
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(", ") } }));
1412
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}`;
1413
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"));
1817
+ let webProxy;
1818
+ if (hasWeb) {
1819
+ const server = loadDeploymentConfig("serv", join(appDir, "config", "_serv.yml"), env);
1820
+ if (!server.success)
1821
+ return server;
1822
+ const http = server.data.http;
1823
+ const cors = server.data.cors;
1824
+ const apiPrefix = http?.apiPrefix ?? "/api/v1";
1825
+ if (typeof apiPrefix !== "string" || !/^\/[\w/-]+$/.test(apiPrefix))
1826
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_webProxyInvalid"));
1827
+ webProxy = generateWebProxyConfig(containerPort, apiPrefix, webContainerPort);
1828
+ env.HAI_SERV = JSON.stringify({ ...server.data, cors: { ...cors, origin: (!cors?.origin || cors.origin === "*") && config.ssh ? url : cors?.origin ?? "*" } });
1829
+ }
1414
1830
  const envPath = join(artifactDir, ".env.runtime");
1415
- const previousEnv = existsSync(envPath) ? parseEnv(readFileSync(envPath, "utf8")) : {};
1416
- Object.assign(env, { ...previousEnv, ...env });
1417
1831
  writeFileSync(envPath, serializeEnvFile(env), { mode: 384 });
1418
1832
  if (options?.environment) {
1419
1833
  const environment = loadDeploymentEnvironment({ environment: options.environment, envDir: options.envDir ?? scan.envDir ?? appDir });
@@ -1423,88 +1837,83 @@ function createDockerSshProvider(config, container) {
1423
1837
  if (!saved.success)
1424
1838
  return saved;
1425
1839
  }
1426
- const buildResult = await buildImage(localRuntime, {
1427
- contextDir,
1428
- dockerfilePath,
1429
- imageRef: localImageTag,
1430
- buildArgs: { HAI_DEPLOY_ENVIRONMENT: options?.environment ?? "prod", HAI_CORE_ENV: env.HAI_CORE_ENV ?? "production", SQLITE_PREBUILD_MIRROR: container?.sqlitePrebuildMirror ?? "" },
1431
- httpProxy
1432
- });
1433
- if (!buildResult.success)
1434
- return err(buildResult.error);
1435
- const tarPath = join(artifactDir, `${projectName}.tar`);
1436
- const saveResult = await saveImage(localRuntime, localImageTag, tarPath);
1437
- if (!saveResult.success)
1438
- return err(saveResult.error);
1439
- const pingResult = await ssh.ping();
1440
- if (!pingResult.success)
1441
- return err(pingResult.error);
1442
- const remoteRuntimeResult = await detectRemoteRuntime();
1443
- if (!remoteRuntimeResult.success)
1444
- return err(remoteRuntimeResult.error);
1445
- const remoteRuntime = remoteRuntimeResult.data;
1446
- const remoteDir = `${baseDir}/${projectName}`;
1447
- const prepareResult = await ssh.exec(`mkdir -p -- ${shellArgument(remoteDir)} && chmod 700 -- ${shellArgument(remoteDir)}`);
1448
- if (!prepareResult.success)
1449
- return err(prepareResult.error);
1450
- const remoteTar = `${remoteDir}/image.tar`;
1451
- const uploadTarResult = await ssh.upload(tarPath, remoteTar);
1452
- if (!uploadTarResult.success)
1453
- return err(uploadTarResult.error);
1454
- const loadResult = await ssh.exec(`${remoteRuntime} load -i ${shellArgument(remoteTar)}`, { timeoutMs: 3e5 });
1455
- if (!loadResult.success)
1456
- return err(loadResult.error);
1457
- const loadedRef = parseLoadedImageRef(loadResult.data.stdout, localImageTag);
1458
- const remoteImageRef = remoteRuntime === "podman" ? `localhost/${localImageTag}` : localImageTag;
1459
- if (loadedRef !== remoteImageRef) {
1460
- const tagResult = await ssh.exec(`${remoteRuntime} tag ${shellArgument(loadedRef)} ${shellArgument(remoteImageRef)}`);
1461
- if (!tagResult.success)
1462
- return err(tagResult.error);
1463
- }
1840
+ const composeName = `docker-compose-${artifact.appVersion}.yml`;
1464
1841
  const composeContent = generateComposeFile({
1465
1842
  projectName,
1466
- imageRef: remoteImageRef,
1843
+ appVersion: artifact.appVersion,
1844
+ imageRef: app?.imageRef ?? "localhost/hai-unselected:0",
1467
1845
  hostPort,
1468
1846
  containerPort,
1469
1847
  sidecar,
1470
- serviceModes: options?.serviceModes,
1471
- previousCompose: existsSync(join(artifactDir, "compose.yml")) ? readFileSync(join(artifactDir, "compose.yml"), "utf8") : void 0
1848
+ web: hasWeb ? { imageRef: web?.imageRef ?? "localhost/hai-unselected:0", containerPort: webContainerPort } : void 0
1472
1849
  });
1473
- const composePath = join(artifactDir, "compose.yml");
1850
+ const composePath = join(artifactDir, composeName);
1474
1851
  writeFileSync(composePath, composeContent);
1475
- const uploadComposeResult = await ssh.upload(composePath, `${remoteDir}/compose.yml`);
1476
- if (!uploadComposeResult.success)
1477
- return err(uploadComposeResult.error);
1478
- const uploadEnvResult = await ssh.upload(envPath, `${remoteDir}/.env.runtime`);
1479
- if (!uploadEnvResult.success)
1480
- return err(uploadEnvResult.error);
1481
- const startScript = `#!/bin/sh
1482
- set -eu
1483
- cd "$(dirname "$0")"
1484
- export HAI_DEPLOY_ENVIRONMENT=${options?.environment ?? "prod"}
1485
- export HAI_CORE_ENV=${shellArgument(env.HAI_CORE_ENV ?? "production")}
1486
- exec ${remoteRuntime} compose --env-file .env.runtime -p hai-${projectName} up -d
1487
- `;
1488
- const startName = `start-${options?.environment ?? "prod"}.sh`;
1489
- const startPath = join(artifactDir, startName);
1490
- writeFileSync(startPath, startScript, { mode: 448 });
1491
- const uploadStart = await ssh.upload(startPath, `${remoteDir}/${startName}`);
1492
- if (!uploadStart.success)
1493
- return uploadStart;
1494
- const secretMask = sidecar.secrets;
1495
- const chmodResult = await ssh.exec(`chmod 600 -- ${shellArgument(`${remoteDir}/.env.runtime`)}`, { secrets: secretMask });
1496
- if (!chmodResult.success)
1497
- return err(chmodResult.error);
1498
- const upResult = await ssh.exec(
1499
- `cd ${shellArgument(remoteDir)} && sh ${shellArgument(startName)}`,
1500
- { timeoutMs: 3e5, secrets: secretMask }
1852
+ if (webProxy)
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");
1864
+ const bundle = await createDeploymentBundle({
1865
+ directory: artifactDir,
1866
+ artifactDir: resolvedBuildDir,
1867
+ artifact,
1868
+ projectName,
1869
+ environment: options?.environment ?? "prod",
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"],
1879
+ output: options?.output ?? join(artifactDir, `bundle-${projectName}-${deploymentId}-${artifact.appVersion}.tar`)
1880
+ });
1881
+ if (options?.bundleOnly)
1882
+ return ok({ url: "", deploymentId, status: "packaged", provider: "docker-ssh", healthCheck: "skipped", envVarsSet: Object.keys(env), ...bundle });
1883
+ if (!ssh)
1884
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_sshRequired"));
1885
+ const ping = await ssh.ping();
1886
+ if (!ping.success)
1887
+ return ping;
1888
+ const remoteRuntime = await detectRemoteRuntime();
1889
+ if (!remoteRuntime.success)
1890
+ return remoteRuntime;
1891
+ const remoteDir = `${baseDir}/${projectName}`;
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`)}`);
1894
+ if (!prepared.success)
1895
+ return prepared;
1896
+ const uploaded = await ssh.upload(bundle.bundlePath, remoteTar);
1897
+ if (!uploaded.success)
1898
+ return uploaded;
1899
+ let pendingOutput = "";
1900
+ const started = await ssh.exec(
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
+ } }
1501
1912
  );
1502
- if (!upResult.success)
1503
- return err(upResult.error);
1504
- await ssh.exec(`rm -f -- ${shellArgument(remoteTar)}`);
1505
- const url = `http://${config.ssh.host}:${hostPort}`;
1506
- logger7.info("Running health check", { url });
1507
- const healthy = await healthCheck(`${url}${scan.isSvelteKit ? "/" : "/health"}`);
1913
+ if (!started.success)
1914
+ return started;
1915
+ logger5.info("Running health check", { url });
1916
+ const healthy = await healthCheck(`${url}${!hasWeb && scan.isSvelteKit ? "/" : "/health"}`) && (!hasWeb || await healthCheck(`${url}/_hai/web-health`) && await healthCheck(`${url}/`));
1508
1917
  if (!healthy) {
1509
1918
  return err(
1510
1919
  HaiDeployError.HEALTH_CHECK_FAILED,
@@ -1517,14 +1926,24 @@ exec ${remoteRuntime} compose --env-file .env.runtime -p hai-${projectName} up -
1517
1926
  status: "ready",
1518
1927
  provider: "docker-ssh",
1519
1928
  healthCheck: "passed",
1520
- envVarsSet: Object.keys(env)
1929
+ envVarsSet: Object.keys(env),
1930
+ bundlePath: bundle.bundlePath
1521
1931
  };
1522
- logger7.info("docker-ssh deployment complete", { url, deploymentId });
1932
+ logger5.info("docker-ssh deployment complete", { url, deploymentId });
1523
1933
  return ok(result);
1524
1934
  };
1525
- return { name: "docker-ssh", deploy: deploy2 };
1935
+ const deploySafely = async (context) => {
1936
+ try {
1937
+ return await deploy2(context);
1938
+ } catch (error) {
1939
+ logger5.error("Deployment bundle failed", { 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);
1942
+ }
1943
+ };
1944
+ return { name: "docker-ssh", deploy: deploySafely };
1526
1945
  }
1527
- var logger8 = core.logger.child({ module: "deploy", scope: "provider-vercel" });
1946
+ var logger6 = core.logger.child({ module: "deploy", scope: "provider-vercel" });
1528
1947
  var VERCEL_API = "https://api.vercel.com";
1529
1948
  var POLL_INTERVAL = 3e3;
1530
1949
  var MAX_POLL_ATTEMPTS = 60;
@@ -1577,7 +1996,7 @@ function createVercelProvider() {
1577
1996
  return {
1578
1997
  name: "vercel",
1579
1998
  async authenticate(apiToken) {
1580
- logger8.debug("Authenticating with Vercel");
1999
+ logger6.debug("Authenticating with Vercel");
1581
2000
  try {
1582
2001
  const user = await vercelFetch(
1583
2002
  apiToken,
@@ -1585,10 +2004,10 @@ function createVercelProvider() {
1585
2004
  );
1586
2005
  token = apiToken;
1587
2006
  const username = user.user.username || user.user.email;
1588
- logger8.info("Vercel authenticated", { user: username });
2007
+ logger6.info("Vercel authenticated", { user: username });
1589
2008
  return ok(username);
1590
2009
  } catch (error) {
1591
- logger8.error("Vercel authentication failed", { error });
2010
+ logger6.error("Vercel authentication failed", { error });
1592
2011
  return err(toDeployError(HaiDeployError.AUTH_FAILED, "deploy_authFailed", error));
1593
2012
  }
1594
2013
  },
@@ -1599,11 +2018,11 @@ function createVercelProvider() {
1599
2018
  deployM("deploy_authRequired")
1600
2019
  );
1601
2020
  }
1602
- logger8.debug("Creating Vercel project", { projectName });
2021
+ logger6.debug("Creating Vercel project", { projectName });
1603
2022
  try {
1604
2023
  const existingId = await findExistingProject(token, projectName);
1605
2024
  if (existingId !== null) {
1606
- logger8.info("Vercel project found", { projectName, projectId: existingId });
2025
+ logger6.info("Vercel project found", { projectName, projectId: existingId });
1607
2026
  return ok(existingId);
1608
2027
  }
1609
2028
  try {
@@ -1618,13 +2037,13 @@ function createVercelProvider() {
1618
2037
  })
1619
2038
  }
1620
2039
  );
1621
- logger8.info("Vercel project created", { projectName, projectId: project.id });
2040
+ logger6.info("Vercel project created", { projectName, projectId: project.id });
1622
2041
  return ok(project.id);
1623
2042
  } catch (error) {
1624
2043
  if (error instanceof VercelApiError && error.status === 409) {
1625
2044
  const concurrentProjectId = await findExistingProject(token, projectName);
1626
2045
  if (concurrentProjectId !== null) {
1627
- logger8.info("Vercel project reused after create conflict", {
2046
+ logger6.info("Vercel project reused after create conflict", {
1628
2047
  projectName,
1629
2048
  projectId: concurrentProjectId
1630
2049
  });
@@ -1634,7 +2053,7 @@ function createVercelProvider() {
1634
2053
  throw error;
1635
2054
  }
1636
2055
  } catch (error) {
1637
- logger8.error("Vercel project creation failed", { projectName, error });
2056
+ logger6.error("Vercel project creation failed", { projectName, error });
1638
2057
  return err(toDeployError(HaiDeployError.PROJECT_CREATE_FAILED, "deploy_projectCreateFailed", error));
1639
2058
  }
1640
2059
  },
@@ -1645,7 +2064,7 @@ function createVercelProvider() {
1645
2064
  deployM("deploy_authRequired")
1646
2065
  );
1647
2066
  }
1648
- logger8.debug("Setting Vercel env vars", { projectId, count: Object.keys(envVars).length });
2067
+ logger6.debug("Setting Vercel env vars", { projectId, count: Object.keys(envVars).length });
1649
2068
  try {
1650
2069
  const envEntries = Object.entries(envVars).map(([key, value]) => ({
1651
2070
  key,
@@ -1661,10 +2080,10 @@ function createVercelProvider() {
1661
2080
  body: JSON.stringify(envEntries)
1662
2081
  }
1663
2082
  );
1664
- logger8.info("Vercel env vars set", { projectId, count: envEntries.length });
2083
+ logger6.info("Vercel env vars set", { projectId, count: envEntries.length });
1665
2084
  return ok(void 0);
1666
2085
  } catch (error) {
1667
- logger8.error("Vercel env vars setup failed", { projectId, error });
2086
+ logger6.error("Vercel env vars setup failed", { projectId, error });
1668
2087
  return err(toDeployError(HaiDeployError.ENV_VAR_FAILED, "deploy_envVarFailed", error));
1669
2088
  }
1670
2089
  },
@@ -1675,8 +2094,11 @@ function createVercelProvider() {
1675
2094
  deployM("deploy_authRequired")
1676
2095
  );
1677
2096
  }
1678
- logger8.debug("Deploying to Vercel", { projectId, outputDir });
2097
+ logger6.debug("Deploying to Vercel", { projectId, outputDir });
1679
2098
  try {
2099
+ const outputConfig = JSON.parse(readFileSync(join(outputDir, "config.json"), "utf8"));
2100
+ if (outputConfig.version !== 3)
2101
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_artifactInvalid"));
1680
2102
  const filePaths = collectFiles(outputDir, outputDir);
1681
2103
  const files = [];
1682
2104
  for (const filePath of filePaths) {
@@ -1685,18 +2107,20 @@ function createVercelProvider() {
1685
2107
  const sha = createHash("sha1").update(content).digest("hex");
1686
2108
  const size = statSync(fullPath).size;
1687
2109
  await uploadFile(token, content, sha);
1688
- files.push({ file: filePath, sha, size });
2110
+ files.push({ file: `.vercel/output/${filePath}`, sha, size });
1689
2111
  }
1690
- logger8.debug("Files uploaded", { count: files.length });
2112
+ logger6.debug("Files uploaded", { count: files.length });
1691
2113
  const deployment = await vercelFetch(
1692
2114
  token,
1693
- "/v13/deployments",
2115
+ "/v13/deployments?prebuilt=1",
1694
2116
  {
1695
2117
  method: "POST",
1696
2118
  body: JSON.stringify({
1697
2119
  name: projectId,
1698
2120
  files,
1699
- projectSettings: { framework: "sveltekit" }
2121
+ version: 2,
2122
+ project: projectId,
2123
+ projectSettings: { framework: null, buildCommand: "", installCommand: "" }
1700
2124
  })
1701
2125
  }
1702
2126
  );
@@ -1707,14 +2131,14 @@ function createVercelProvider() {
1707
2131
  status: finalStatus,
1708
2132
  envVarsSet: []
1709
2133
  };
1710
- logger8.info("Vercel deployment complete", {
2134
+ logger6.info("Vercel deployment complete", {
1711
2135
  deploymentId: deployment.id,
1712
2136
  url: result.url,
1713
2137
  status: finalStatus
1714
2138
  });
1715
2139
  return ok(result);
1716
2140
  } catch (error) {
1717
- logger8.error("Vercel deployment failed", { projectId, error });
2141
+ logger6.error("Vercel deployment failed", { projectId, error });
1718
2142
  return err(toDeployError(HaiDeployError.UPLOAD_FAILED, "deploy_uploadFailed", error));
1719
2143
  }
1720
2144
  }
@@ -1759,11 +2183,11 @@ async function pollDeploymentStatus(token, deploymentId) {
1759
2183
  if (deployment.readyState === "ERROR" || deployment.readyState === "CANCELED") {
1760
2184
  return "error";
1761
2185
  }
1762
- await new Promise((resolve3) => setTimeout(resolve3, POLL_INTERVAL));
2186
+ await new Promise((resolve6) => setTimeout(resolve6, POLL_INTERVAL));
1763
2187
  }
1764
2188
  return "building";
1765
2189
  }
1766
- var logger9 = core.logger.child({ module: "deploy", scope: "provisioner-aliyun" });
2190
+ var logger7 = core.logger.child({ module: "deploy", scope: "provisioner-aliyun" });
1767
2191
  function createAliyunProvisioner() {
1768
2192
  let accessKey = null;
1769
2193
  let secretKey = null;
@@ -1772,7 +2196,7 @@ function createAliyunProvisioner() {
1772
2196
  name: "aliyun",
1773
2197
  serviceType: "sms",
1774
2198
  async authenticate(credentials) {
1775
- logger9.debug("Authenticating with Aliyun");
2199
+ logger7.debug("Authenticating with Aliyun");
1776
2200
  try {
1777
2201
  const akId = credentials.accessKeyId ?? credentials.access_key_id ?? credentials.access_key ?? "";
1778
2202
  const akSecret = credentials.accessKeySecret ?? credentials.access_key_secret ?? credentials.secret_key ?? "";
@@ -1782,10 +2206,10 @@ function createAliyunProvisioner() {
1782
2206
  accessKey = akId;
1783
2207
  secretKey = akSecret;
1784
2208
  signName = credentials.signName;
1785
- logger9.info("Aliyun authenticated", { accessKeyId: `${akId.slice(0, 6)}***` });
2209
+ logger7.info("Aliyun authenticated", { accessKeyId: `${akId.slice(0, 6)}***` });
1786
2210
  return ok(akId);
1787
2211
  } catch (error) {
1788
- logger9.error("Aliyun authentication failed", { error });
2212
+ logger7.error("Aliyun authentication failed", { error });
1789
2213
  return err(
1790
2214
  HaiDeployError.AUTH_FAILED,
1791
2215
  deployM("deploy_authFailed", {
@@ -1802,7 +2226,7 @@ function createAliyunProvisioner() {
1802
2226
  deployM("deploy_authRequired")
1803
2227
  );
1804
2228
  }
1805
- logger9.debug("Provisioning Aliyun SMS service");
2229
+ logger7.debug("Provisioning Aliyun SMS service");
1806
2230
  return ok({
1807
2231
  serviceType: "sms",
1808
2232
  provisionerName: "aliyun",
@@ -1815,7 +2239,7 @@ function createAliyunProvisioner() {
1815
2239
  }
1816
2240
  };
1817
2241
  }
1818
- var logger10 = core.logger.child({ module: "deploy", scope: "provisioner-neon" });
2242
+ var logger8 = core.logger.child({ module: "deploy", scope: "provisioner-neon" });
1819
2243
  var NEON_API = "https://console.neon.tech/api/v2";
1820
2244
  var DEFAULT_NEON_DATABASE_NAME = "neondb";
1821
2245
  var DEFAULT_NEON_ROLE_NAME = "neondb_owner";
@@ -1874,7 +2298,7 @@ function createNeonProvisioner() {
1874
2298
  name: "neon",
1875
2299
  serviceType: "db",
1876
2300
  async authenticate(credentials) {
1877
- logger10.debug("Authenticating with Neon");
2301
+ logger8.debug("Authenticating with Neon");
1878
2302
  try {
1879
2303
  const apiToken = credentials.apiKey ?? credentials.token ?? credentials.api_key ?? "";
1880
2304
  if (!apiToken) {
@@ -1882,10 +2306,10 @@ function createNeonProvisioner() {
1882
2306
  }
1883
2307
  await listNeonProjects(apiToken);
1884
2308
  token = apiToken;
1885
- logger10.info("Neon authenticated");
2309
+ logger8.info("Neon authenticated");
1886
2310
  return ok("neon-user");
1887
2311
  } catch (error) {
1888
- logger10.error("Neon authentication failed", { error });
2312
+ logger8.error("Neon authentication failed", { error });
1889
2313
  return err(
1890
2314
  HaiDeployError.AUTH_FAILED,
1891
2315
  deployM("deploy_authFailed", {
@@ -1902,7 +2326,7 @@ function createNeonProvisioner() {
1902
2326
  deployM("deploy_authRequired")
1903
2327
  );
1904
2328
  }
1905
- logger10.debug("Provisioning Neon database", { appName });
2329
+ logger8.debug("Provisioning Neon database", { appName });
1906
2330
  let resource;
1907
2331
  try {
1908
2332
  const projectName = `${appName}-db`;
@@ -1910,7 +2334,7 @@ function createNeonProvisioner() {
1910
2334
  if (existingProject !== null) {
1911
2335
  resource = { resourceInfo: `neon-project:${existingProject.id}`, resourceStatus: "reused" };
1912
2336
  const connectionUri2 = await getNeonConnectionUri(token, existingProject.id);
1913
- logger10.info("Neon project reused", {
2337
+ logger8.info("Neon project reused", {
1914
2338
  projectId: existingProject.id,
1915
2339
  projectName
1916
2340
  });
@@ -1932,10 +2356,10 @@ function createNeonProvisioner() {
1932
2356
  if (!projectId || !connectionUri) {
1933
2357
  throw new Error(deployM("deploy_provisionNoResult", { params: { service: "Neon" } }));
1934
2358
  }
1935
- logger10.info("Neon database provisioned", { projectId });
2359
+ logger8.info("Neon database provisioned", { projectId });
1936
2360
  return ok(buildNeonProvisionResult(projectId, connectionUri, "created"));
1937
2361
  } catch (error) {
1938
- logger10.error("Neon provisioning failed", { appName });
2362
+ logger8.error("Neon provisioning failed", { appName });
1939
2363
  if (resource) {
1940
2364
  return recoveryFailure({ ...HaiDeployError.PROVISION_FAILED}, {
1941
2365
  projectName: appName,
@@ -1957,7 +2381,7 @@ function createNeonProvisioner() {
1957
2381
  }
1958
2382
  };
1959
2383
  }
1960
- var logger11 = core.logger.child({ module: "deploy", scope: "provisioner-r2" });
2384
+ var logger9 = core.logger.child({ module: "deploy", scope: "provisioner-r2" });
1961
2385
  var CF_API = "https://api.cloudflare.com/client/v4";
1962
2386
  function createR2Provisioner() {
1963
2387
  let token = null;
@@ -1968,7 +2392,7 @@ function createR2Provisioner() {
1968
2392
  name: "r2",
1969
2393
  serviceType: "storage",
1970
2394
  async authenticate(credentials) {
1971
- logger11.debug("Authenticating with Cloudflare");
2395
+ logger9.debug("Authenticating with Cloudflare");
1972
2396
  try {
1973
2397
  const acctId = credentials.accountId ?? credentials.account_id ?? "";
1974
2398
  const apiTok = credentials.apiToken ?? credentials.api_token ?? credentials.token ?? "";
@@ -1985,10 +2409,10 @@ function createR2Provisioner() {
1985
2409
  token = apiTok;
1986
2410
  accessKey = credentials.accessKeyId;
1987
2411
  secretKey = credentials.secretAccessKey;
1988
- logger11.info("Cloudflare R2 authenticated", { accountId: acctId });
2412
+ logger9.info("Cloudflare R2 authenticated", { accountId: acctId });
1989
2413
  return ok(acctId);
1990
2414
  } catch (error) {
1991
- logger11.error("Cloudflare R2 authentication failed", { error });
2415
+ logger9.error("Cloudflare R2 authentication failed", { error });
1992
2416
  return err(
1993
2417
  HaiDeployError.AUTH_FAILED,
1994
2418
  deployM("deploy_authFailed", {
@@ -2006,7 +2430,7 @@ function createR2Provisioner() {
2006
2430
  );
2007
2431
  }
2008
2432
  const bucketName = `${appName}-storage`.toLowerCase().replace(/[^a-z0-9-]/g, "-");
2009
- logger11.debug("Provisioning R2 bucket", { appName, bucketName });
2433
+ logger9.debug("Provisioning R2 bucket", { appName, bucketName });
2010
2434
  try {
2011
2435
  const createRes = await fetch(
2012
2436
  `${CF_API}/accounts/${accountId}/r2/buckets`,
@@ -2022,7 +2446,7 @@ function createR2Provisioner() {
2022
2446
  if (!createRes.ok && createRes.status !== 409) {
2023
2447
  throw new Error(deployM("deploy_apiError", { params: { service: "Cloudflare", status: String(createRes.status) } }));
2024
2448
  }
2025
- logger11.info("R2 bucket provisioned", { bucketName });
2449
+ logger9.info("R2 bucket provisioned", { bucketName });
2026
2450
  return ok({
2027
2451
  serviceType: "storage",
2028
2452
  provisionerName: "r2",
@@ -2040,7 +2464,7 @@ function createR2Provisioner() {
2040
2464
  resourceInfo: `r2-bucket:${bucketName}`
2041
2465
  });
2042
2466
  } catch (error) {
2043
- logger11.error("R2 provisioning failed", { appName, error });
2467
+ logger9.error("R2 provisioning failed", { appName, error });
2044
2468
  return err(
2045
2469
  HaiDeployError.PROVISION_FAILED,
2046
2470
  deployM("deploy_provisionFailed", {
@@ -2055,7 +2479,7 @@ function createR2Provisioner() {
2055
2479
  }
2056
2480
  };
2057
2481
  }
2058
- var logger12 = core.logger.child({ module: "deploy", scope: "provisioner-resend" });
2482
+ var logger10 = core.logger.child({ module: "deploy", scope: "provisioner-resend" });
2059
2483
  var RESEND_API = "https://api.resend.com";
2060
2484
  function createResendProvisioner() {
2061
2485
  let token = null;
@@ -2064,7 +2488,7 @@ function createResendProvisioner() {
2064
2488
  name: "resend",
2065
2489
  serviceType: "email",
2066
2490
  async authenticate(credentials) {
2067
- logger12.debug("Authenticating with Resend");
2491
+ logger10.debug("Authenticating with Resend");
2068
2492
  try {
2069
2493
  const apiToken = credentials.apiKey ?? credentials.api_key ?? credentials.token ?? "";
2070
2494
  if (!apiToken || !credentials.from) {
@@ -2078,10 +2502,10 @@ function createResendProvisioner() {
2078
2502
  }
2079
2503
  token = apiToken;
2080
2504
  from = credentials.from;
2081
- logger12.info("Resend authenticated");
2505
+ logger10.info("Resend authenticated");
2082
2506
  return ok("resend-user");
2083
2507
  } catch (error) {
2084
- logger12.error("Resend authentication failed", { error });
2508
+ logger10.error("Resend authentication failed", { error });
2085
2509
  return err(
2086
2510
  HaiDeployError.AUTH_FAILED,
2087
2511
  deployM("deploy_authFailed", {
@@ -2098,7 +2522,7 @@ function createResendProvisioner() {
2098
2522
  deployM("deploy_authRequired")
2099
2523
  );
2100
2524
  }
2101
- logger12.debug("Provisioning Resend email service");
2525
+ logger10.debug("Provisioning Resend email service");
2102
2526
  return ok({
2103
2527
  serviceType: "email",
2104
2528
  provisionerName: "resend",
@@ -2111,7 +2535,7 @@ function createResendProvisioner() {
2111
2535
  }
2112
2536
  };
2113
2537
  }
2114
- var logger13 = core.logger.child({ module: "deploy", scope: "provisioner-upstash" });
2538
+ var logger11 = core.logger.child({ module: "deploy", scope: "provisioner-upstash" });
2115
2539
  var UPSTASH_API = "https://api.upstash.com";
2116
2540
  function createUpstashAuthHeader(email, apiKey) {
2117
2541
  return `Basic ${Buffer.from(`${email}:${apiKey}`).toString("base64")}`;
@@ -2162,7 +2586,7 @@ function createUpstashProvisioner() {
2162
2586
  name: "upstash",
2163
2587
  serviceType: "cache",
2164
2588
  async authenticate(credentials) {
2165
- logger13.debug("Authenticating with Upstash");
2589
+ logger11.debug("Authenticating with Upstash");
2166
2590
  try {
2167
2591
  const userEmail = credentials.email ?? "";
2168
2592
  const userKey = credentials.apiKey ?? credentials.api_key ?? credentials.token ?? "";
@@ -2172,10 +2596,10 @@ function createUpstashProvisioner() {
2172
2596
  await listUpstashDatabases(userEmail, userKey);
2173
2597
  email = userEmail;
2174
2598
  apiKey = userKey;
2175
- logger13.info("Upstash authenticated", { email: userEmail });
2599
+ logger11.info("Upstash authenticated", { email: userEmail });
2176
2600
  return ok(userEmail);
2177
2601
  } catch (error) {
2178
- logger13.error("Upstash authentication failed", { error });
2602
+ logger11.error("Upstash authentication failed", { error });
2179
2603
  return err(
2180
2604
  HaiDeployError.AUTH_FAILED,
2181
2605
  deployM("deploy_authFailed", {
@@ -2192,7 +2616,7 @@ function createUpstashProvisioner() {
2192
2616
  deployM("deploy_authRequired")
2193
2617
  );
2194
2618
  }
2195
- logger13.debug("Provisioning Upstash Redis", { appName });
2619
+ logger11.debug("Provisioning Upstash Redis", { appName });
2196
2620
  let resource;
2197
2621
  try {
2198
2622
  const databaseName = `${appName}-cache`;
@@ -2204,7 +2628,7 @@ function createUpstashProvisioner() {
2204
2628
  }
2205
2629
  resource = { resourceInfo: `upstash-db:${databaseId}`, resourceStatus: "reused" };
2206
2630
  const detail = await getUpstashDatabase(email, apiKey, databaseId);
2207
- logger13.info("Upstash Redis reused", { databaseId, databaseName });
2631
+ logger11.info("Upstash Redis reused", { databaseId, databaseName });
2208
2632
  return ok(buildUpstashProvisionResult({ ...existingDatabase, ...detail }, "reused"));
2209
2633
  }
2210
2634
  const data = await upstashFetch(email, apiKey, "/v2/redis/database", {
@@ -2217,10 +2641,10 @@ function createUpstashProvisioner() {
2217
2641
  });
2218
2642
  if (data.database_id)
2219
2643
  resource = { resourceInfo: `upstash-db:${data.database_id}`, resourceStatus: "created" };
2220
- logger13.info("Upstash Redis provisioned", { databaseId: data.database_id });
2644
+ logger11.info("Upstash Redis provisioned", { databaseId: data.database_id });
2221
2645
  return ok(buildUpstashProvisionResult(data, "created"));
2222
2646
  } catch (error) {
2223
- logger13.error("Upstash provisioning failed", { appName });
2647
+ logger11.error("Upstash provisioning failed", { appName });
2224
2648
  if (resource) {
2225
2649
  return recoveryFailure({ ...HaiDeployError.PROVISION_FAILED}, {
2226
2650
  projectName: appName,
@@ -2244,7 +2668,7 @@ function createUpstashProvisioner() {
2244
2668
  }
2245
2669
 
2246
2670
  // src/deploy-main.ts
2247
- var logger14 = core.logger.child({ module: "deploy", scope: "main" });
2671
+ var logger12 = core.logger.child({ module: "deploy", scope: "main" });
2248
2672
  var currentConfig = null;
2249
2673
  var currentProvider = null;
2250
2674
  var currentContainerProvider = null;
@@ -2329,7 +2753,7 @@ var deploy = {
2329
2753
  credentials: deployCredentials,
2330
2754
  async init(config) {
2331
2755
  if (initInProgress) {
2332
- logger14.warn("Deploy init already in progress, skipping concurrent call");
2756
+ logger12.warn("Deploy init already in progress, skipping concurrent call");
2333
2757
  return err(
2334
2758
  HaiDeployError.CONFIG_ERROR,
2335
2759
  deployM("deploy_configError", { params: { error: "init already in progress" } })
@@ -2338,13 +2762,13 @@ var deploy = {
2338
2762
  initInProgress = true;
2339
2763
  try {
2340
2764
  if (currentProvider || currentContainerProvider) {
2341
- logger14.warn("Deploy module is already initialized, reinitializing");
2765
+ logger12.warn("Deploy module is already initialized, reinitializing");
2342
2766
  await deploy.close();
2343
2767
  }
2344
- logger14.info("Initializing deploy module");
2768
+ logger12.info("Initializing deploy module");
2345
2769
  const parseResult = DeployConfigSchema.safeParse(config);
2346
2770
  if (!parseResult.success) {
2347
- logger14.error("Deploy config validation failed", { error: parseResult.error.message });
2771
+ logger12.error("Deploy config validation failed", { error: parseResult.error.message });
2348
2772
  return err(
2349
2773
  HaiDeployError.CONFIG_ERROR,
2350
2774
  deployM("deploy_configError", { params: { error: parseResult.error.message } }),
@@ -2357,14 +2781,14 @@ var deploy = {
2357
2781
  let containerProvider = null;
2358
2782
  if (parsed.provider.type === "docker-ssh") {
2359
2783
  containerProvider = createDockerSshProvider(parsed.provider, parsed.container);
2360
- logger14.info("Container deploy provider ready", { provider: "docker-ssh", host: parsed.provider.ssh.host });
2784
+ logger12.info("Container deploy provider ready", { provider: "docker-ssh", host: parsed.provider.ssh?.host });
2361
2785
  } else {
2362
2786
  provider = createProviderByType(parsed.provider.type);
2363
2787
  const authResult = await provider.authenticate(parsed.provider.token);
2364
2788
  if (!authResult.success) {
2365
2789
  return err(authResult.error);
2366
2790
  }
2367
- logger14.info("Provider authenticated", { provider: parsed.provider.type, user: authResult.data });
2791
+ logger12.info("Provider authenticated", { provider: parsed.provider.type, user: authResult.data });
2368
2792
  }
2369
2793
  currentProvisioners.clear();
2370
2794
  if (parsed.services) {
@@ -2376,14 +2800,14 @@ var deploy = {
2376
2800
  const credentials = extractCredentials(serviceConfig);
2377
2801
  const provAuthResult = await provisioner.authenticate(credentials);
2378
2802
  if (!provAuthResult.success) {
2379
- logger14.error("Provisioner authentication failed", {
2803
+ logger12.error("Provisioner authentication failed", {
2380
2804
  service: serviceType,
2381
2805
  provisioner: serviceConfig.provisioner
2382
2806
  });
2383
2807
  currentProvisioners.clear();
2384
2808
  return err(provAuthResult.error);
2385
2809
  }
2386
- logger14.info("Provisioner authenticated", {
2810
+ logger12.info("Provisioner authenticated", {
2387
2811
  service: serviceType,
2388
2812
  user: provAuthResult.data
2389
2813
  });
@@ -2393,7 +2817,7 @@ var deploy = {
2393
2817
  currentProvider = provider;
2394
2818
  currentContainerProvider = containerProvider;
2395
2819
  currentConfig = parsed;
2396
- logger14.info("Deploy module initialized", {
2820
+ logger12.info("Deploy module initialized", {
2397
2821
  provider: parsed.provider.type,
2398
2822
  provisioners: [...currentProvisioners.keys()]
2399
2823
  });
@@ -2403,7 +2827,7 @@ var deploy = {
2403
2827
  currentContainerProvider = null;
2404
2828
  currentConfig = null;
2405
2829
  currentProvisioners.clear();
2406
- logger14.error("Deploy module initialization failed", { error });
2830
+ logger12.error("Deploy module initialization failed", { error });
2407
2831
  return err(
2408
2832
  HaiDeployError.CONFIG_ERROR,
2409
2833
  deployM("deploy_configError", {
@@ -2418,15 +2842,15 @@ var deploy = {
2418
2842
  },
2419
2843
  async close() {
2420
2844
  if (!currentProvider && !currentContainerProvider) {
2421
- logger14.info("Deploy module already closed, skipping");
2845
+ logger12.info("Deploy module already closed, skipping");
2422
2846
  return;
2423
2847
  }
2424
- logger14.info("Closing deploy module");
2848
+ logger12.info("Closing deploy module");
2425
2849
  currentProvider = null;
2426
2850
  currentContainerProvider = null;
2427
2851
  currentConfig = null;
2428
2852
  currentProvisioners.clear();
2429
- logger14.info("Deploy module closed");
2853
+ logger12.info("Deploy module closed");
2430
2854
  },
2431
2855
  get config() {
2432
2856
  return currentConfig;
@@ -2435,22 +2859,22 @@ var deploy = {
2435
2859
  return currentProvider !== null || currentContainerProvider !== null;
2436
2860
  },
2437
2861
  async scan(appDir, options) {
2438
- logger14.debug("Scanning application", { appDir });
2862
+ logger12.debug("Scanning application", { appDir });
2439
2863
  return scanApp(appDir, options);
2440
2864
  },
2441
2865
  async provisionAll(projectName, services) {
2442
2866
  if (!currentProvider && !currentContainerProvider) {
2443
2867
  return notInitialized.result();
2444
2868
  }
2445
- logger14.info("Provisioning all services", { projectName, count: currentProvisioners.size });
2869
+ logger12.info("Provisioning all services", { projectName, count: currentProvisioners.size });
2446
2870
  const results = [];
2447
2871
  for (const [serviceType, provisioner] of currentProvisioners) {
2448
2872
  if (services && !services.includes(serviceType))
2449
2873
  continue;
2450
- logger14.debug("Provisioning service", { serviceType, provisioner: provisioner.name });
2874
+ logger12.debug("Provisioning service", { serviceType, provisioner: provisioner.name });
2451
2875
  const provResult = await provisioner.provision(projectName);
2452
2876
  if (!provResult.success) {
2453
- logger14.error("Service provisioning failed", {
2877
+ logger12.error("Service provisioning failed", {
2454
2878
  serviceType,
2455
2879
  provisioner: provisioner.name,
2456
2880
  code: provResult.error.code
@@ -2467,7 +2891,7 @@ var deploy = {
2467
2891
  });
2468
2892
  }
2469
2893
  results.push(provResult.data);
2470
- logger14.info("Service provisioned", { serviceType, provisioner: provisioner.name });
2894
+ logger12.info("Service provisioned", { serviceType, provisioner: provisioner.name });
2471
2895
  }
2472
2896
  return ok(results);
2473
2897
  },
@@ -2475,7 +2899,7 @@ var deploy = {
2475
2899
  if (!currentProvider && !currentContainerProvider) {
2476
2900
  return notInitialized.result();
2477
2901
  }
2478
- logger14.info("Starting deployment", { appDir, environment: options?.environment, projectName: options?.projectName });
2902
+ logger12.info("Starting deployment", { appDir, environment: options?.environment, projectName: options?.projectName });
2479
2903
  const scanOptions = { ...options, environment: options?.environment ?? currentConfig?.environment };
2480
2904
  const scanResult = await scanApp(appDir, scanOptions);
2481
2905
  if (!scanResult.success) {
@@ -2491,11 +2915,21 @@ var deploy = {
2491
2915
  options = {
2492
2916
  ...options,
2493
2917
  ...scanOptions.environment ? { environment: environment.data.environment } : {},
2494
- ...options?.skipProvision ? { serviceModes: Object.fromEntries(scan.requiredServices.map((service) => [service, "keep"])) } : {}
2918
+ ...options?.skipMiddleware ? { serviceModes: Object.fromEntries(scan.requiredServices.map((service) => [service, "keep"])) } : {}
2495
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
+ }
2496
2926
  if (currentContainerProvider) {
2497
2927
  return deployContainerApp(currentContainerProvider, appDir, scan, projectName, options, appEnv);
2498
2928
  }
2929
+ if (options?.bundleOnly)
2930
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_bundleOnlyContainer"));
2931
+ if (options.selectedServices)
2932
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_selectedContainerOnly"));
2499
2933
  if (!currentProvider) {
2500
2934
  return notInitialized.result();
2501
2935
  }
@@ -2507,9 +2941,11 @@ var deploy = {
2507
2941
  })
2508
2942
  );
2509
2943
  }
2944
+ if (!existsSync(resolveOutputDir(appDir, "vercel")))
2945
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_artifactInvalid"));
2510
2946
  let allEnvVars = appEnv;
2511
2947
  const recovery = { projectName, stage: "provision", resources: [] };
2512
- if (!options?.skipProvision) {
2948
+ if (!options?.skipMiddleware) {
2513
2949
  const provResults = await deploy.provisionAll(projectName, options?.serviceModes ? scan.requiredServices.filter((service) => usesDefaultMiddleware(scan, service, options)) : void 0);
2514
2950
  if (!provResults.success) {
2515
2951
  return err(provResults.error);
@@ -2534,12 +2970,6 @@ var deploy = {
2534
2970
  return recoveryFailure(envResult.error, { ...recovery, stage: "set-env" });
2535
2971
  }
2536
2972
  }
2537
- if (!options?.skipBuild) {
2538
- const buildResult = buildApp(appDir, scan.buildCommand, allEnvVars);
2539
- if (!buildResult.success) {
2540
- return recoveryFailure(buildResult.error, { ...recovery, stage: "build" });
2541
- }
2542
- }
2543
2973
  const outputDir = resolveOutputDir(appDir, currentConfig?.provider.type ?? "vercel");
2544
2974
  const deployResult = await currentProvider.deploy(projectId, outputDir);
2545
2975
  if (!deployResult.success) {
@@ -2549,7 +2979,7 @@ var deploy = {
2549
2979
  ...deployResult.data,
2550
2980
  envVarsSet: Object.keys(allEnvVars)
2551
2981
  };
2552
- logger14.info("Deployment completed", {
2982
+ logger12.info("Deployment completed", {
2553
2983
  url: result.url,
2554
2984
  deploymentId: result.deploymentId,
2555
2985
  status: result.status
@@ -2559,21 +2989,41 @@ var deploy = {
2559
2989
  };
2560
2990
  async function deployContainerApp(provider, appDir, scan, rawProjectName, options, appEnv = {}) {
2561
2991
  const projectName = normalizeProjectName(rawProjectName);
2562
- if (scan.isSvelteKit && !scan.nodeAdapterInstalled) {
2563
- return err(
2564
- HaiDeployError.ADAPTER_MISSING,
2565
- deployM("deploy_adapterMissing", { params: { adapter: "@sveltejs/adapter-node" } })
2566
- );
2992
+ let artifact;
2993
+ let artifactDir = options?.artifactDir ?? resolveArtifactDirectory(appDir);
2994
+ try {
2995
+ if (existsSync(join(artifactDir, "latest"))) {
2996
+ const latest = readFileSync(join(artifactDir, "latest"), "utf8").trim();
2997
+ if (!/^[a-z0-9][\w.+-]*$/i.test(latest))
2998
+ throw new Error("Invalid artifact pointer");
2999
+ artifactDir = join(artifactDir, latest);
3000
+ }
3001
+ artifact = await readDeploymentArtifact(artifactDir);
3002
+ } catch {
3003
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_artifactInvalid"));
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
+ };
2567
3015
  }
3016
+ if (!options?.bundleOnly && currentConfig?.provider.type === "docker-ssh" && !currentConfig.provider.ssh)
3017
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_sshRequired"));
2568
3018
  let provisionEnv = {};
2569
- if (!options?.skipProvision) {
3019
+ if (!options?.skipMiddleware) {
2570
3020
  const provResults = await deploy.provisionAll(projectName, options?.serviceModes ? scan.requiredServices.filter((service) => usesDefaultMiddleware(scan, service, options)) : void 0);
2571
3021
  if (!provResults.success) {
2572
3022
  return err(provResults.error);
2573
3023
  }
2574
3024
  provisionEnv = mergeProvisionEnv(provResults.data, scan, options);
2575
3025
  }
2576
- return provider.deploy({ appDir, projectName, scan, provisionEnv, appEnv, options });
3026
+ return provider.deploy({ appDir, artifact, artifactDir, projectName, scan, provisionEnv, appEnv, options });
2577
3027
  }
2578
3028
 
2579
3029
  // src/deploy-middleware.ts
@@ -2621,6 +3071,6 @@ function describeMiddlewareBackend(backend) {
2621
3071
  return deployM(backend.purpose);
2622
3072
  }
2623
3073
 
2624
- export { DEPLOY_MIDDLEWARE, DeployConfigSchema, HaiDeployError, ProviderConfigSchema, deploy, describeMiddlewareBackend, getDeployRecovery, loadDeploymentConfig, loadDeploymentEnvironment, saveDeploymentEnvironment };
3074
+ export { DEPLOY_MIDDLEWARE, DeployConfigSchema, DeploymentArtifactSchema, HaiDeployError, ProviderConfigSchema, artifactChecksum, deploy, describeMiddlewareBackend, getDeployRecovery, loadDeploymentConfig, loadDeploymentEnvironment, readDeploymentArtifact, saveDeploymentEnvironment };
2625
3075
  //# sourceMappingURL=index.js.map
2626
3076
  //# sourceMappingURL=index.js.map