@h-ai/deploy 0.1.0-alpha.63 → 0.1.0-alpha.65

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,10 +1,11 @@
1
1
  import { z } from 'zod';
2
- import { core, err, ok } from '@h-ai/core';
3
- import { existsSync, readFileSync, writeFileSync, readdirSync, mkdirSync, statSync } from 'fs';
2
+ import { core, ok, err } from '@h-ai/core';
3
+ import { readFileSync, existsSync, writeFileSync, chmodSync, mkdirSync, statSync, readdirSync } from 'fs';
4
+ import { resolve, join, basename, dirname, relative, sep } from 'path';
5
+ import process2 from 'process';
6
+ import { parseEnv } from 'util';
7
+ import { parse, stringify, parseAllDocuments } from 'yaml';
4
8
  import { homedir } from 'os';
5
- import { join, dirname, relative, sep } from 'path';
6
- import process from 'process';
7
- import { parse, stringify } from 'yaml';
8
9
  import { execSync, spawn } from 'child_process';
9
10
  import { randomBytes, createHash } from 'crypto';
10
11
  import { Buffer } from 'buffer';
@@ -46,7 +47,24 @@ var en_US_default = {
46
47
  deploy_remoteConnectFailed: "Cannot connect to remote host {host}: {error}",
47
48
  deploy_remoteRuntimeMissing: "Remote host missing container runtime or compose: {runtime}",
48
49
  deploy_imageTransferFailed: "Image transfer failed: {error}",
49
- deploy_healthCheckFailed: "Health check failed, app not ready at {url}"
50
+ deploy_healthCheckFailed: "Health check failed, app not ready at {url}",
51
+ deploy_invalidEnvironment: "Use letters, numbers, underscores or hyphens for the environment; local/example are reserved.",
52
+ deploy_environmentMissing: "Environment file missing: {path}. Create and configure it before deploying.",
53
+ deploy_environmentReadFailed: "Cannot read deployment environment files; check syntax and permissions.",
54
+ deploy_environmentWriteFailed: "Cannot save deployment configuration; check file syntax and permissions.",
55
+ deploy_multipleApplications: "Multiple service packages found; specify the application directory.",
56
+ deploy_purposeSqlite: "Local relational database persisted in a volume",
57
+ deploy_purposeDatabase: "Relational database service for business data",
58
+ deploy_purposeMemory: "Per-process memory cache; cleared on restart, no external service",
59
+ deploy_purposeLocal: "Local filesystem storage persisted in a volume",
60
+ deploy_purposeCache: "Shared cache, sessions and distributed locks",
61
+ deploy_purposeStorage: "S3-compatible object storage for uploaded files",
62
+ deploy_purposeVector: "Vector indexing and similarity search",
63
+ deploy_purposeDisabled: "LDAP login is disabled; no directory service is created",
64
+ deploy_purposeLdap: "Directory account authentication; the default directory has no business users",
65
+ deploy_purposeConsole: "Messages go to application logs without delivery",
66
+ 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"
50
68
  };
51
69
 
52
70
  // messages/zh-CN.json
@@ -84,7 +102,24 @@ var zh_CN_default = {
84
102
  deploy_remoteConnectFailed: "\u65E0\u6CD5\u8FDE\u63A5\u8FDC\u7A0B\u4E3B\u673A {host}\uFF1A{error}",
85
103
  deploy_remoteRuntimeMissing: "\u8FDC\u7A0B\u4E3B\u673A\u7F3A\u5C11\u5BB9\u5668\u8FD0\u884C\u65F6\u6216 compose\uFF1A{runtime}",
86
104
  deploy_imageTransferFailed: "\u955C\u50CF\u4F20\u8F93\u5931\u8D25\uFF1A{error}",
87
- deploy_healthCheckFailed: "\u5065\u5EB7\u68C0\u67E5\u5931\u8D25\uFF0C\u5E94\u7528\u672A\u5728 {url} \u5C31\u7EEA"
105
+ deploy_healthCheckFailed: "\u5065\u5EB7\u68C0\u67E5\u5931\u8D25\uFF0C\u5E94\u7528\u672A\u5728 {url} \u5C31\u7EEA",
106
+ deploy_invalidEnvironment: "\u73AF\u5883\u540D\u79F0\u4EC5\u5141\u8BB8\u5B57\u6BCD\u3001\u6570\u5B57\u3001\u4E0B\u5212\u7EBF\u548C\u8FDE\u5B57\u7B26\uFF0C\u4E14\u4E0D\u80FD\u4F7F\u7528 local/example\u3002",
107
+ deploy_environmentMissing: "\u7F3A\u5C11\u73AF\u5883\u914D\u7F6E {path}\uFF1B\u8BF7\u5148\u521B\u5EFA\u8BE5\u6587\u4EF6\u5E76\u5B8C\u6210\u914D\u7F6E\uFF0C\u518D\u91CD\u65B0\u90E8\u7F72\u3002",
108
+ deploy_environmentReadFailed: "\u90E8\u7F72\u73AF\u5883\u6587\u4EF6\u8BFB\u53D6\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u6587\u4EF6\u683C\u5F0F\u4E0E\u6743\u9650\u3002",
109
+ deploy_environmentWriteFailed: "\u90E8\u7F72\u914D\u7F6E\u5199\u5165\u5931\u8D25\uFF0C\u8BF7\u68C0\u67E5\u6587\u4EF6\u683C\u5F0F\u4E0E\u6743\u9650\u3002",
110
+ deploy_multipleApplications: "\u53D1\u73B0\u591A\u4E2A\u670D\u52A1\u5305\uFF0C\u8BF7\u6307\u5B9A\u8981\u90E8\u7F72\u7684\u5E94\u7528\u76EE\u5F55\u3002",
111
+ deploy_purposeSqlite: "\u672C\u5730\u6587\u4EF6\u5173\u7CFB\u6570\u636E\u5E93\uFF0C\u4F7F\u7528\u6301\u4E45\u5316\u5377",
112
+ deploy_purposeDatabase: "\u72EC\u7ACB\u5173\u7CFB\u6570\u636E\u5E93\u670D\u52A1\uFF0C\u4FDD\u5B58\u4E1A\u52A1\u6570\u636E",
113
+ deploy_purposeMemory: "\u5355\u8FDB\u7A0B\u5185\u5B58\u7F13\u5B58\uFF0C\u91CD\u542F\u540E\u6E05\u7A7A\uFF0C\u4E0D\u521B\u5EFA\u5916\u90E8\u670D\u52A1",
114
+ deploy_purposeLocal: "\u672C\u5730\u6587\u4EF6\u5B58\u50A8\uFF0C\u4F7F\u7528\u6301\u4E45\u5316\u5377",
115
+ deploy_purposeCache: "\u5171\u4EAB\u7F13\u5B58\u3001\u4F1A\u8BDD\u4E0E\u5206\u5E03\u5F0F\u9501",
116
+ deploy_purposeStorage: "S3 \u517C\u5BB9\u5BF9\u8C61\u5B58\u50A8\uFF0C\u4FDD\u5B58\u4E0A\u4F20\u6587\u4EF6",
117
+ deploy_purposeVector: "\u5411\u91CF\u7D22\u5F15\u4E0E\u76F8\u4F3C\u5EA6\u641C\u7D22",
118
+ deploy_purposeDisabled: "LDAP \u767B\u5F55\u672A\u542F\u7528\uFF0C\u4E0D\u521B\u5EFA\u76EE\u5F55\u670D\u52A1",
119
+ deploy_purposeLdap: "\u4F01\u4E1A\u76EE\u5F55\u8D26\u53F7\u8BA4\u8BC1\uFF1B\u9ED8\u8BA4\u76EE\u5F55\u4E0D\u5305\u542B\u4E1A\u52A1\u7528\u6237",
120
+ deploy_purposeConsole: "\u6D88\u606F\u8F93\u51FA\u5230\u5E94\u7528\u65E5\u5FD7\uFF0C\u4E0D\u5B9E\u9645\u6295\u9012",
121
+ 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"
88
123
  };
89
124
 
90
125
  // src/deploy-i18n.ts
@@ -117,9 +152,9 @@ var SshConfigSchema = z.object({
117
152
  var PortExposeSchema = z.object({
118
153
  type: z.literal("port").default("port"),
119
154
  /** 容器内监听端口(adapter-node 默认 3000) */
120
- containerPort: z.number().int().positive().default(3e3),
155
+ containerPort: z.number().int().min(1).max(65535).default(3e3),
121
156
  /** 宿主机映射端口 */
122
- hostPort: z.number().int().positive()
157
+ hostPort: z.number().int().min(1).max(65535)
123
158
  });
124
159
  var ExposeSchema = PortExposeSchema;
125
160
  var DockerSshProviderConfigSchema = z.object({
@@ -175,11 +210,22 @@ var ServicesConfigSchema = z.object({
175
210
  sms: z.discriminatedUnion("provisioner", [AliyunSmsServiceSchema]).optional()
176
211
  }).optional();
177
212
  var DeployConfigSchema = z.object({
213
+ /** 部署环境文件模式;dev/test/prod 或自定义模式,禁止 local/example。 */
214
+ environment: z.string().regex(/^[a-z0-9][\w-]*$/i).refine((value) => !["local", "example"].includes(value.toLowerCase())).optional(),
178
215
  provider: ProviderConfigSchema,
179
216
  services: ServicesConfigSchema,
180
217
  /** 本地容器构建参数(docker-ssh 使用;auto 优先 podman) */
181
218
  container: z.object({
182
- runtime: z.enum(["auto", "docker", "podman"]).default("auto")
219
+ runtime: z.enum(["auto", "docker", "podman"]).default("auto"),
220
+ /** Podman 构建是否继承代理;容器不能访问宿主机 localhost 代理时设为 false。 */
221
+ httpProxy: z.boolean().default(true),
222
+ /** 自建 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()
183
229
  }).optional()
184
230
  });
185
231
  var DeployErrorInfo = {
@@ -206,7 +252,165 @@ var DeployErrorInfo = {
206
252
  };
207
253
  var HaiDeployError = core.error.buildHaiErrorsDef("deploy", DeployErrorInfo);
208
254
 
209
- // src/deploy-credentials.ts
255
+ // src/deploy-environment.ts
256
+ function resolveConfig(value, prefix, env) {
257
+ if (env[prefix] !== void 0)
258
+ return parse(env[prefix]) ?? env[prefix];
259
+ if (Array.isArray(value))
260
+ return value.map((item, index) => resolveConfig(item, `${prefix}_${index}`, env));
261
+ if (value && typeof value === "object")
262
+ return Object.fromEntries(Object.entries(value).map(([key, nested]) => [key, resolveConfig(nested, `${prefix}_${key.toUpperCase()}`, env)]));
263
+ if (typeof value !== "string")
264
+ return value;
265
+ const resolved = value.replace(/\$\{([^:}]+)(?::([^}]*))?\}/g, (_match, name, fallback) => env[name] ?? fallback ?? "");
266
+ return /^\$\{[^}]+\}$/.test(value) ? parse(resolved) ?? resolved : resolved;
267
+ }
268
+ function loadDeploymentConfig(name, path, values) {
269
+ try {
270
+ const config = resolveConfig(parse(readFileSync(path, "utf8")), `HAI_${name.toUpperCase()}`, values);
271
+ return ok(config && typeof config === "object" && !Array.isArray(config) ? config : {});
272
+ } catch {
273
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_environmentReadFailed"));
274
+ }
275
+ }
276
+ function loadDeploymentEnvironment(options) {
277
+ const environment = options.environment === "production" ? "prod" : options.environment === "development" ? "dev" : options.environment;
278
+ if (!/^[a-z0-9][\w-]*$/i.test(environment) || ["local", "example"].includes(environment.toLowerCase()))
279
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_invalidEnvironment"));
280
+ const envDir = resolve(options.envDir);
281
+ const target = join(envDir, `.env.${environment}`);
282
+ if (options.requireFile !== false && !existsSync(target))
283
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_environmentMissing", { params: { path: target } }));
284
+ const loaded = core.config.loadEnvFiles({ mode: environment, envDir });
285
+ if (!loaded.success)
286
+ return loaded;
287
+ try {
288
+ const values = { ...loaded.data };
289
+ for (const [name, value] of Object.entries(process2.env)) {
290
+ if (/^(?:HAI_|PUBLIC_|VITE_)/.test(name) && value !== void 0)
291
+ values[name] = value;
292
+ }
293
+ values.HAI_DEPLOY_ENVIRONMENT = environment;
294
+ values.HAI_CORE_ENV ??= environment === "prod" ? "production" : environment === "dev" ? "development" : environment === "test" ? "test" : "staging";
295
+ const targetValues = {};
296
+ if (existsSync(target)) {
297
+ for (const [name, value] of Object.entries(parseEnv(readFileSync(target, "utf8")))) {
298
+ if (value !== void 0)
299
+ targetValues[name] = value;
300
+ }
301
+ }
302
+ return ok({ environment, envDir, values, targetValues });
303
+ } catch {
304
+ return err(HaiDeployError.CONFIG_ERROR, deployM("deploy_environmentReadFailed"));
305
+ }
306
+ }
307
+ function isSecret(name, value) {
308
+ if (/PASSWORD|SECRET|TOKEN|APIKEY|PRIVATEKEY|ACCESSKEY|AUTHORIZATION/i.test(name.replace(/[^a-z0-9]/gi, "")) || /(?:^|_)PASS$/i.test(name) || value.includes("PRIVATE KEY"))
309
+ return true;
310
+ try {
311
+ const url = new URL(value);
312
+ if (url.username || url.password || [...url.searchParams.keys()].some((key) => /password|secret|token|key|authorization/i.test(key)))
313
+ return true;
314
+ } catch {
315
+ }
316
+ try {
317
+ const parsed = JSON.parse(value);
318
+ if (parsed && typeof parsed === "object")
319
+ return Object.entries(parsed).some(([key, nested]) => isSecret(key, typeof nested === "string" ? nested : JSON.stringify(nested)));
320
+ } catch {
321
+ }
322
+ return false;
323
+ }
324
+ function publicConfig(value) {
325
+ if (Array.isArray(value))
326
+ return value.map(publicConfig);
327
+ if (value && typeof value === "object")
328
+ return Object.fromEntries(Object.entries(value).filter(([name, item]) => !isSecret(name, typeof item === "string" ? item : "")).map(([name, item]) => [name, publicConfig(item)]));
329
+ return value;
330
+ }
331
+ function quoteEnv(value) {
332
+ try {
333
+ const parsed = JSON.parse(value);
334
+ if (parsed && typeof parsed === "object")
335
+ return `'${JSON.stringify(parsed).replaceAll("'", "\\u0027")}'`;
336
+ } catch {
337
+ }
338
+ if (!value.includes("'"))
339
+ return `'${value}'`;
340
+ if (!value.includes('"'))
341
+ return `"${value}"`;
342
+ if (!value.includes("`"))
343
+ return `\`${value}\``;
344
+ if (!/[\r\n#]/.test(value))
345
+ return value;
346
+ throw new Error("Unsupported dotenv value");
347
+ }
348
+ function updateFile(path, values, privateFile) {
349
+ let content = existsSync(path) ? readFileSync(path, "utf8") : "# Local private deployment configuration; do not commit.\n";
350
+ for (const [name, value] of Object.entries(values)) {
351
+ if (!/^[A-Z][A-Z0-9_]*$/.test(name))
352
+ throw new Error("Invalid environment variable name");
353
+ const line = `${name}=${quoteEnv(value)}`;
354
+ const pattern = new RegExp(`^(?:export\\s+)?${name}\\s*=\\s*(?:'[^']*'|"[^"]*"|\`[^\`]*\`|[^\\r\\n]*)[^\\r\\n]*`, "gm");
355
+ if (pattern.test(content))
356
+ content = content.replace(pattern, () => line);
357
+ else
358
+ content = `${content.trimEnd()}
359
+ ${line}
360
+ `;
361
+ }
362
+ writeFileSync(path, content, { mode: privateFile ? 384 : 420 });
363
+ if (privateFile)
364
+ chmodSync(path, 384);
365
+ }
366
+ function removeOverrides(path, names, privateFile = true) {
367
+ if (!existsSync(path) || names.length === 0)
368
+ return;
369
+ let content = readFileSync(path, "utf8");
370
+ for (const name of names)
371
+ content = content.replace(new RegExp(`^(?:export\\s+)?${name}\\s*=\\s*(?:'[^']*'|"[^"]*"|\`[^\`]*\`|[^\\r\\n]*)[^\\r\\n]*(?:\\r?\\n|$)`, "gm"), "");
372
+ writeFileSync(path, content, { mode: privateFile ? 384 : 420 });
373
+ chmodSync(path, privateFile ? 384 : 420);
374
+ }
375
+ function saveDeploymentEnvironment(environment, values, options = {}) {
376
+ try {
377
+ const shared = {};
378
+ const privateValues = {};
379
+ for (const [name, value] of Object.entries(values)) {
380
+ if (options.private) {
381
+ privateValues[name] = value;
382
+ } else if (isSecret(name, value)) {
383
+ privateValues[name] = value;
384
+ try {
385
+ const parsed = JSON.parse(value);
386
+ if (parsed && typeof parsed === "object")
387
+ shared[name] = JSON.stringify(publicConfig(parsed));
388
+ } catch {
389
+ }
390
+ } else {
391
+ shared[name] = value;
392
+ }
393
+ }
394
+ if (Object.keys(shared).length)
395
+ updateFile(join(environment.envDir, `.env.${environment.environment}`), shared, false);
396
+ if (Object.keys(privateValues).length)
397
+ updateFile(join(environment.envDir, ".env.local"), privateValues, true);
398
+ removeOverrides(join(environment.envDir, `.env.${environment.environment}`), Object.keys(privateValues).filter((name) => !(name in shared)), false);
399
+ removeOverrides(join(environment.envDir, ".env.local"), Object.keys(shared).filter((name) => !(name in privateValues)));
400
+ if (Object.keys(privateValues).length) {
401
+ const ignorePath = join(environment.envDir, ".gitignore");
402
+ const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf8") : "";
403
+ if (!ignore.split(/\r?\n/).includes(".env.local"))
404
+ writeFileSync(ignorePath, `${ignore.trimEnd()}
405
+ .env.local
406
+ .hai/deploy/
407
+ `);
408
+ }
409
+ return ok(Object.keys(values));
410
+ } catch {
411
+ return err(HaiDeployError.ENV_VAR_FAILED, deployM("deploy_environmentWriteFailed"));
412
+ }
413
+ }
210
414
  var logger = core.logger.child({ module: "deploy", scope: "credentials" });
211
415
  var DEPLOY_CREDENTIAL_KEY_PATTERN = /^HAI_DEPLOY_[A-Z0-9_]+$/;
212
416
  function isAllowedCredentialKey(key) {
@@ -270,7 +474,7 @@ function loadCredentials() {
270
474
  const data = readCredentialsFile();
271
475
  const keys = Object.keys(data);
272
476
  for (const key of keys) {
273
- process.env[key] = String(data[key]);
477
+ process2.env[key] = String(data[key]);
274
478
  }
275
479
  logger.debug("Credentials loaded", { count: keys.length });
276
480
  return ok(keys);
@@ -291,7 +495,7 @@ function saveCredential(key, value) {
291
495
  const data = readCredentialsFile();
292
496
  data[key] = value;
293
497
  writeCredentialsFile(data);
294
- process.env[key] = value;
498
+ process2.env[key] = value;
295
499
  logger.debug("Credential saved", { key });
296
500
  return ok(void 0);
297
501
  } catch (error) {
@@ -311,7 +515,7 @@ function saveCredentials(entries) {
311
515
  const data = readCredentialsFile();
312
516
  for (const [key, value] of Object.entries(entries)) {
313
517
  data[key] = value;
314
- process.env[key] = value;
518
+ process2.env[key] = value;
315
519
  }
316
520
  writeCredentialsFile(data);
317
521
  logger.debug("Credentials saved", { count: Object.keys(entries).length });
@@ -328,13 +532,17 @@ function saveCredentials(entries) {
328
532
  }
329
533
  }
330
534
  var logger2 = core.logger.child({ module: "deploy", scope: "functions" });
535
+ function usesDefaultMiddleware(scan, service, options) {
536
+ const requirements = scan.middlewares?.filter((item) => item.service === service) ?? [];
537
+ return requirements.length ? requirements.some((item) => (options?.serviceModes?.[item.envPrefix] ?? options?.serviceModes?.[service] ?? "default") === "default") : (options?.serviceModes?.[service] ?? "default") === "default";
538
+ }
331
539
  function buildApp(appDir, buildCommand, envVars = {}) {
332
540
  logger2.info("Building application", { appDir, buildCommand });
333
541
  try {
334
542
  execSync(buildCommand, {
335
543
  cwd: appDir,
336
544
  stdio: "inherit",
337
- env: { ...process.env, ...envVars }
545
+ env: { ...process2.env, ...envVars }
338
546
  });
339
547
  return ok(void 0);
340
548
  } catch (error) {
@@ -387,195 +595,144 @@ function recoveryFailure(error, recovery) {
387
595
  });
388
596
  }
389
597
  var logger3 = core.logger.child({ module: "deploy", scope: "scanner" });
390
- function readYaml(filePath) {
391
- if (!existsSync(filePath)) {
392
- return null;
598
+ function record(value) {
599
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : {};
600
+ }
601
+ function runtimeDependencies(appDir) {
602
+ let root = appDir;
603
+ while (!existsSync(join(root, "pnpm-lock.yaml"))) {
604
+ const parent = dirname(root);
605
+ if (parent === root)
606
+ throw new Error("Missing pnpm lockfile; run pnpm install");
607
+ root = parent;
393
608
  }
394
- try {
395
- const content = readFileSync(filePath, "utf-8");
396
- const parsed = parse(content);
397
- if (parsed === null || parsed === void 0 || typeof parsed !== "object") {
398
- return null;
609
+ const importerKey = relative(root, appDir).replaceAll("\\", "/") || ".";
610
+ const documents = parseAllDocuments(readFileSync(join(root, "pnpm-lock.yaml"), "utf8"));
611
+ if (documents.some((document) => document.errors.length))
612
+ throw new Error("Invalid pnpm lockfile");
613
+ const lock = documents.map((document) => record(document.toJS())).reverse().find((document) => importerKey in record(document.importers));
614
+ if (!lock)
615
+ throw new Error("Application is missing from pnpm lockfile; run pnpm install");
616
+ const importers = record(lock.importers);
617
+ const packages = record(lock.packages);
618
+ const snapshots = record(lock.snapshots);
619
+ const names = /* @__PURE__ */ new Set();
620
+ const visited = /* @__PURE__ */ new Set();
621
+ const visit = (id, node, importer) => {
622
+ if (visited.has(id))
623
+ return;
624
+ visited.add(id);
625
+ const metadata = importer === void 0 ? record(packages[id.split("(")[0]]) : {};
626
+ const peers = record(metadata.peerDependencies);
627
+ for (const [name, reference] of Object.entries({ ...record(node.dependencies), ...record(node.optionalDependencies) })) {
628
+ if (name in peers)
629
+ continue;
630
+ const version = typeof reference === "string" ? reference : record(reference).version;
631
+ if (typeof version !== "string")
632
+ throw new Error("Invalid pnpm dependency reference");
633
+ if (version.startsWith("link:")) {
634
+ if (importer === void 0)
635
+ throw new Error("Invalid workspace dependency");
636
+ const target = relative(root, resolve(root, importer, version.slice(5))).replaceAll("\\", "/") || ".";
637
+ const linked = record(importers[target]);
638
+ if (!Object.keys(linked).length)
639
+ throw new Error("Missing linked workspace importer");
640
+ names.add(name);
641
+ visit(`workspace:${target}`, linked, target);
642
+ continue;
643
+ }
644
+ const key = version in snapshots || version in packages ? version : `${name}@${version}`;
645
+ names.add(key.match(/^(@[^/]+\/[^@]+|[^@]+)@/)?.[1] ?? name);
646
+ const snapshot = record(snapshots[key] ?? packages[key]);
647
+ if (!Object.keys(snapshot).length && !(key in snapshots) && !(key in packages))
648
+ throw new Error("Missing pnpm dependency snapshot");
649
+ visit(key, snapshot);
399
650
  }
400
- return parsed;
401
- } catch {
402
- return null;
403
- }
651
+ };
652
+ visit(`workspace:${importerKey}`, record(importers[importerKey]), importerKey);
653
+ return { runtime: names, build: /* @__PURE__ */ new Set([...names, ...Object.keys(record(record(importers[importerKey]).devDependencies))]), root };
404
654
  }
405
- function readJson(filePath) {
406
- if (!existsSync(filePath)) {
407
- return null;
408
- }
655
+ async function scanApp(inputDir, options) {
409
656
  try {
410
- const content = readFileSync(filePath, "utf-8");
411
- return JSON.parse(content);
412
- } catch {
413
- return null;
414
- }
415
- }
416
- function hasDependency(pkg, name) {
417
- const deps = pkg.dependencies;
418
- const devDeps = pkg.devDependencies;
419
- return deps !== void 0 && name in deps || devDeps !== void 0 && name in devDeps;
420
- }
421
- function detectRequiredServices(configDir) {
422
- const services = [];
423
- const backends = {};
424
- if (!existsSync(configDir)) {
425
- return { services, backends };
426
- }
427
- const files = readdirSync(configDir);
428
- const dbFile = files.find((f) => f.startsWith("_db."));
429
- if (dbFile) {
430
- const dbConfig = readYaml(join(configDir, dbFile));
431
- if (dbConfig !== null) {
432
- services.push("db");
433
- backends.db = dbConfig.type ?? "postgresql";
434
- }
435
- }
436
- const cacheFile = files.find((f) => f.startsWith("_cache."));
437
- if (cacheFile) {
438
- const cacheConfig = readYaml(join(configDir, cacheFile));
439
- if (cacheConfig !== null && cacheConfig.type === "redis") {
440
- services.push("cache");
441
- backends.cache = "redis";
442
- }
443
- }
444
- const storageFile = files.find((f) => f.startsWith("_storage."));
445
- if (storageFile) {
446
- const storageConfig = readYaml(join(configDir, storageFile));
447
- if (storageConfig !== null && detectStorageIsS3(storageConfig)) {
448
- services.push("storage");
449
- backends.storage = "s3";
450
- }
451
- }
452
- const vecdbFile = files.find((f) => f.startsWith("_vecdb."));
453
- if (vecdbFile) {
454
- const vecdbConfig = readYaml(join(configDir, vecdbFile));
455
- if (vecdbConfig !== null) {
456
- services.push("vecdb");
457
- backends.vecdb = vecdbConfig.type ?? "qdrant";
458
- }
459
- }
460
- const reachFile = files.find((f) => f.startsWith("_reach."));
461
- if (reachFile) {
462
- const reachConfig = readYaml(join(configDir, reachFile));
463
- if (reachConfig !== null) {
464
- detectReachServices(reachConfig, services);
465
- }
466
- }
467
- return { services, backends };
468
- }
469
- function detectStorageIsS3(config) {
470
- if (config.type === "s3") {
471
- return true;
472
- }
473
- const providers = config.providers;
474
- if (providers === void 0) {
475
- return false;
476
- }
477
- return Object.values(providers).some((p) => p?.type === "s3");
478
- }
479
- function detectReachServices(config, services) {
480
- const providers = config.providers;
481
- if (!Array.isArray(providers)) {
482
- return;
483
- }
484
- let hasEmail = false;
485
- let hasSms = false;
486
- for (const provider of providers) {
487
- const providerType = provider.type;
488
- if (!hasEmail && (providerType === "smtp" || providerType === "api")) {
489
- hasEmail = true;
490
- services.push("email");
657
+ const appDir = resolve(inputDir);
658
+ const graph = runtimeDependencies(appDir);
659
+ const envDir = options?.envDir ? resolve(options.envDir) : existsSync(join(appDir, ".env")) ? appDir : graph.root;
660
+ const loaded = loadDeploymentEnvironment({ environment: options?.environment ?? "prod", envDir, requireFile: options?.environment !== void 0 });
661
+ if (!loaded.success)
662
+ return loaded;
663
+ const env = loaded.data.values;
664
+ const deps = graph.runtime;
665
+ const configs = {};
666
+ for (const [name, owner] of Object.entries({ db: "reldb", cache: "cache", storage: "storage", vecdb: "vecdb", iam: "iam", reach: "reach" })) {
667
+ configs[name] = {};
668
+ if (!deps.has(`@h-ai/${owner}`))
669
+ continue;
670
+ const configFile = ["yml", "yaml"].map((ext) => join(appDir, "config", `_${name}.${ext}`)).find(existsSync);
671
+ const config = configFile ? loadDeploymentConfig(name, configFile, env) : void 0;
672
+ if (config && !config.success)
673
+ return config;
674
+ configs[name] = config?.data ?? {};
491
675
  }
492
- if (!hasSms && providerType === "aliyun-sms") {
493
- hasSms = true;
494
- services.push("sms");
676
+ const middlewares = [];
677
+ const add = (service, backend, configName = service, configPath = [], config = configs[configName] ?? {}, source = `_${configName}.yml`) => {
678
+ middlewares.push({ service, backend, source, configName, configPath, envPrefix: ["HAI", configName, ...configPath].map((part) => part.toUpperCase()).join("_"), config });
679
+ };
680
+ const iam = deps.has("@h-ai/iam");
681
+ if (deps.has("@h-ai/reldb"))
682
+ add("db", String(configs.db.type ?? "sqlite"), "db", [], configs.db, "@h-ai/reldb / _db.yml");
683
+ if (deps.has("@h-ai/cache"))
684
+ add("cache", String(configs.cache.type ?? "memory"), "cache", [], configs.cache, "@h-ai/cache / _cache.yml");
685
+ if (deps.has("@h-ai/storage"))
686
+ add("storage", String(configs.storage.type ?? "local"));
687
+ if (deps.has("@h-ai/vecdb"))
688
+ add("vecdb", String(configs.vecdb.type ?? "lancedb"));
689
+ if (iam) {
690
+ const ldap = record(configs.iam.ldap);
691
+ add("ldap", record(configs.iam.login).ldap === true ? "ldap" : "disabled", "iam", ["ldap"], ldap, "@h-ai/iam (LDAP)");
495
692
  }
496
- }
497
- }
498
- async function scanApp(appDir) {
499
- logger3.debug("Scanning application", { appDir });
500
- try {
501
- const pkg = readJson(join(appDir, "package.json"));
502
- if (pkg === null) {
503
- return err(
504
- HaiDeployError.SCAN_FAILED,
505
- deployM("deploy_scanFailed", { params: { error: "package.json not found" } })
506
- );
693
+ const providers = configs.reach.providers;
694
+ if (deps.has("@h-ai/reach") && Array.isArray(providers)) {
695
+ for (const [index, item] of providers.entries()) {
696
+ const provider = record(item);
697
+ const type = String(provider.type ?? "");
698
+ const service = type === "aliyun-sms" ? "sms" : ["smtp", "api", "console"].includes(type) ? "email" : void 0;
699
+ if (service)
700
+ add(service, type, "reach", ["providers", String(index)], provider, "@h-ai/reach");
701
+ }
507
702
  }
508
- const appName = extractAppName(pkg);
509
- const isSvelteKit = existsSync(join(appDir, "svelte.config.js")) || existsSync(join(appDir, "svelte.config.ts"));
510
- const adapterInstalled = hasDependency(pkg, "@sveltejs/adapter-vercel");
511
- const nodeAdapterInstalled = hasDependency(pkg, "@sveltejs/adapter-node");
512
- const configDir = join(appDir, "config");
513
- const configDetection = detectRequiredServices(configDir);
514
- const depServices = detectServicesFromDependencies(pkg);
515
- const requiredServices = Array.from(/* @__PURE__ */ new Set([...configDetection.services, ...depServices]));
516
- const scripts = pkg.scripts;
517
- const buildCommand = scripts?.build ?? "pnpm build";
518
- const scanResult = {
519
- appName,
703
+ if (deps.has("@h-ai/reach") && !middlewares.some((item) => item.configName === "reach"))
704
+ add("email", "console", "reach", ["providers", "0"], { name: "email", type: "console" }, "@h-ai/reach");
705
+ const isSvelteKit = graph.build.has("@sveltejs/kit");
706
+ const scan = {
707
+ appDir,
708
+ envDir,
709
+ middlewares,
710
+ haiModules: [...deps].filter((name) => name.startsWith("@h-ai/")).sort(),
711
+ appName: basename(appDir),
520
712
  isSvelteKit,
521
- adapterInstalled,
522
- nodeAdapterInstalled,
523
- requiredServices,
524
- serviceBackends: configDetection.backends,
525
- buildCommand
713
+ adapterInstalled: graph.build.has("@sveltejs/adapter-vercel"),
714
+ nodeAdapterInstalled: graph.build.has("@sveltejs/adapter-node"),
715
+ requiredServices: [...new Set(middlewares.map((item) => item.service))],
716
+ serviceBackends: Object.fromEntries(middlewares.map((item) => [item.service, item.backend])),
717
+ buildCommand: "pnpm build"
526
718
  };
527
- logger3.info("Application scanned", {
528
- appName,
529
- isSvelteKit,
530
- adapterInstalled,
531
- nodeAdapterInstalled,
532
- requiredServices,
533
- serviceBackends: configDetection.backends
534
- });
535
- return ok(scanResult);
536
- } catch (error) {
537
- logger3.error("Application scan failed", { appDir, error });
538
- return err(
539
- HaiDeployError.SCAN_FAILED,
540
- deployM("deploy_scanFailed", {
541
- params: { error: error instanceof Error ? error.message : String(error) }
542
- }),
543
- error
544
- );
545
- }
546
- }
547
- function detectServicesFromDependencies(pkg) {
548
- const depMap = {
549
- "@h-ai/reldb": "db",
550
- "@h-ai/cache": "cache",
551
- "@h-ai/storage": "storage",
552
- "@h-ai/vecdb": "vecdb"
553
- };
554
- const services = [];
555
- for (const [depName, serviceType] of Object.entries(depMap)) {
556
- if (hasDependency(pkg, depName)) {
557
- services.push(serviceType);
558
- }
559
- }
560
- return services;
561
- }
562
- function extractAppName(pkg) {
563
- const name = pkg.name ?? "app";
564
- const slashIndex = name.indexOf("/");
565
- if (slashIndex >= 0) {
566
- return name.slice(slashIndex + 1);
719
+ logger3.info("Application scanned", { appName: scan.appName, haiModules: scan.haiModules, requiredServices: scan.requiredServices, serviceBackends: scan.serviceBackends });
720
+ return ok(scan);
721
+ } catch {
722
+ return err(HaiDeployError.SCAN_FAILED, deployM("deploy_scanFailed", { params: { error: "Invalid or ambiguous application/configuration/lockfile; specify the application directory and run pnpm install" } }));
567
723
  }
568
- return name;
569
724
  }
570
725
  var IMAGES = {
571
726
  postgres: "postgres:16-alpine",
572
727
  mysql: "mysql:8.4",
573
728
  redis: "redis:7-alpine",
574
- minio: "minio/minio:RELEASE.2025-09-07T16-13-09Z",
575
- mc: "minio/mc:RELEASE.2025-08-13T08-35-41Z",
729
+ rustfs: "ghcr.io/rustfs/rustfs:1.0.1",
730
+ minio: "ghcr.io/coollabsio/minio:RELEASE.2025-10-15T17-29-55Z",
576
731
  qdrant: "qdrant/qdrant:v1.12.4",
577
732
  pgvector: "pgvector/pgvector:pg17",
578
- chroma: "chromadb/chroma:0.5.23"
733
+ chroma: "chromadb/chroma:1.5.5",
734
+ ldap: "docker.io/osixia/openldap:1.5.0",
735
+ smtp: "axllent/mailpit:v1.27.4"
579
736
  };
580
737
  function generatePassword() {
581
738
  return randomBytes(24).toString("base64url");
@@ -590,7 +747,8 @@ function resolveSidecarSecrets(appDir, projectName) {
590
747
  }
591
748
  const secrets = {
592
749
  dbPassword: generatePassword(),
593
- storagePassword: generatePassword()
750
+ storagePassword: generatePassword(),
751
+ ldapPassword: generatePassword()
594
752
  };
595
753
  mkdirSync(dirname(secretFile), { recursive: true });
596
754
  writeFileSync(secretFile, JSON.stringify(secrets), { mode: 384 });
@@ -651,38 +809,57 @@ function planRedisCache(plan, projectName, net) {
651
809
  };
652
810
  plan.env.HAI_CACHE = JSON.stringify({ type: "redis", url: "redis://cache:6379" });
653
811
  }
654
- function planMinioStorage(plan, projectName, password, net) {
812
+ function planS3Storage(plan, projectName, password, net, engine) {
655
813
  plan.services.push("storage");
656
814
  plan.dependsOn.push("storage");
657
815
  plan.composeServices.storage = {
658
- image: IMAGES.minio,
816
+ image: IMAGES[engine],
659
817
  container_name: `hai-${projectName}-storage`,
660
818
  restart: "unless-stopped",
661
- command: ["server", "/data"],
819
+ ...engine === "minio" ? { command: ["server", "/data"] } : {},
662
820
  env_file: [".env.runtime"],
663
821
  volumes: [`${projectName}-storage-data:/data`],
664
822
  networks: net
665
823
  };
666
824
  plan.composeServices["storage-init"] = {
667
- image: IMAGES.mc,
668
825
  depends_on: ["storage"],
669
826
  env_file: [".env.runtime"],
670
827
  entrypoint: [
671
- "sh",
672
- "-c",
673
- 'until mc alias set s3 http://storage:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD"; do sleep 1; done; mc mb -p s3/hai; exit 0'
828
+ "node",
829
+ "--input-type=module",
830
+ "-e",
831
+ // 复用应用 @h-ai/storage 已依赖的 SDK;不额外拉取失效的 mc 镜像,不把密钥拼进命令。
832
+ `import { S3Client, HeadBucketCommand, CreateBucketCommand } from '@aws-sdk/client-s3';
833
+ const config = JSON.parse(process.env.HAI_STORAGE);
834
+ const client = new S3Client({ endpoint: config.endpoint, region: config.region, forcePathStyle: config.forcePathStyle, credentials: { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey } });
835
+ let ready = false;
836
+ for (let attempt = 0; attempt < 120; attempt++) {
837
+ try {
838
+ try { await client.send(new HeadBucketCommand({ Bucket: config.bucket })); }
839
+ catch (error) {
840
+ if (error.$$metadata?.httpStatusCode !== 404) throw error;
841
+ await client.send(new CreateBucketCommand({ Bucket: config.bucket }));
842
+ }
843
+ ready = true; break;
844
+ } catch { await new Promise(resolve => setTimeout(resolve, 1000)); }
845
+ }
846
+ client.destroy();
847
+ if (!ready) throw new Error('S3 bucket initialization failed');`
674
848
  ],
675
849
  networks: net
676
850
  };
677
851
  plan.volumes[`${projectName}-storage-data`] = null;
678
- plan.env.MINIO_ROOT_USER = "hai";
679
- plan.env.MINIO_ROOT_PASSWORD = password;
852
+ if (engine === "rustfs") {
853
+ Object.assign(plan.env, { RUSTFS_ACCESS_KEY: "hai-storage", RUSTFS_SECRET_KEY: password, RUSTFS_ADDRESS: "0.0.0.0:9000", RUSTFS_VOLUMES: "/data", RUSTFS_CONSOLE_ENABLE: "false" });
854
+ } else {
855
+ Object.assign(plan.env, { MINIO_ROOT_USER: "hai-storage", MINIO_ROOT_PASSWORD: password });
856
+ }
680
857
  plan.env.HAI_STORAGE = JSON.stringify({
681
858
  type: "s3",
682
859
  endpoint: "http://storage:9000",
683
860
  bucket: "hai",
684
861
  region: "us-east-1",
685
- accessKeyId: "hai",
862
+ accessKeyId: "hai-storage",
686
863
  secretAccessKey: password,
687
864
  forcePathStyle: true
688
865
  });
@@ -732,7 +909,7 @@ function planChromaVecdb(plan, projectName, net) {
732
909
  plan.volumes[`${projectName}-vecdb-data`] = null;
733
910
  plan.env.HAI_VECDB = JSON.stringify({ type: "chroma", url: "http://vecdb:8000" });
734
911
  }
735
- function planSidecars(appDir, projectName, scan, provisionEnv) {
912
+ function planSidecars(appDir, projectName, scan, provisionEnv, options, s3Engine = "rustfs") {
736
913
  const plan = {
737
914
  services: [],
738
915
  composeServices: {},
@@ -742,10 +919,14 @@ function planSidecars(appDir, projectName, scan, provisionEnv) {
742
919
  dependsOn: [],
743
920
  secrets: []
744
921
  };
922
+ if (!scan.requiredServices.some((service) => usesDefaultMiddleware(scan, service, options)))
923
+ return plan;
745
924
  const secrets = resolveSidecarSecrets(appDir, projectName);
746
925
  const net = ["hai-app"];
747
926
  const backends = scan.serviceBackends;
748
927
  for (const service of scan.requiredServices) {
928
+ if (!usesDefaultMiddleware(scan, service, options))
929
+ continue;
749
930
  if (service === "db" && !provisionEnv.HAI_DB) {
750
931
  const backend = backends.db ?? "postgresql";
751
932
  if (backend === "mysql")
@@ -755,9 +936,13 @@ function planSidecars(appDir, projectName, scan, provisionEnv) {
755
936
  else
756
937
  planPostgresDb(plan, projectName, secrets.dbPassword, net);
757
938
  } else if (service === "cache" && !provisionEnv.HAI_CACHE) {
758
- planRedisCache(plan, projectName, net);
939
+ if ((backends.cache ?? "redis") === "redis")
940
+ planRedisCache(plan, projectName, net);
759
941
  } else if (service === "storage" && !provisionEnv.HAI_STORAGE) {
760
- planMinioStorage(plan, projectName, secrets.storagePassword, net);
942
+ if ((backends.storage ?? "s3") === "s3")
943
+ planS3Storage(plan, projectName, secrets.storagePassword, net, s3Engine);
944
+ else
945
+ addAppDataVolume(plan, projectName);
761
946
  } else if (service === "vecdb" && !provisionEnv.HAI_VECDB) {
762
947
  const backend = backends.vecdb ?? "qdrant";
763
948
  if (backend === "pgvector")
@@ -768,16 +953,39 @@ function planSidecars(appDir, projectName, scan, provisionEnv) {
768
953
  addAppDataVolume(plan, projectName);
769
954
  else
770
955
  planQdrantVecdb(plan, projectName, net);
956
+ } else if (service === "ldap" && backends.ldap === "ldap" && !provisionEnv.HAI_IAM_LDAP) {
957
+ const password = secrets.ldapPassword ?? secrets.dbPassword;
958
+ plan.services.push("ldap");
959
+ plan.dependsOn.push("ldap");
960
+ plan.composeServices.ldap = { image: IMAGES.ldap, restart: "unless-stopped", env_file: [".env.runtime"], networks: net, volumes: [`${projectName}-ldap-data:/var/lib/ldap`, `${projectName}-ldap-config:/etc/ldap/slapd.d`] };
961
+ plan.volumes[`${projectName}-ldap-data`] = null;
962
+ plan.volumes[`${projectName}-ldap-config`] = null;
963
+ Object.assign(plan.env, { LDAP_DOMAIN: "example.org", LDAP_ORGANISATION: "HAI", LDAP_ADMIN_PASSWORD: password, LDAP_TLS: "false", HAI_IAM_LOGIN_LDAP: "true", HAI_IAM_LDAP: JSON.stringify({ url: "ldap://ldap:389", bindDn: "cn=admin,dc=example,dc=org", bindPassword: password, searchBase: "dc=example,dc=org" }) });
964
+ plan.secrets.push(password);
965
+ } else if (service === "email" && (scan.middlewares ? scan.middlewares.some((item) => item.service === "email" && item.backend === "smtp" && (options?.serviceModes?.[item.envPrefix] ?? options?.serviceModes?.email ?? "default") === "default") : backends.email === "smtp") && !provisionEnv.HAI_REACH_PROVIDERS) {
966
+ plan.services.push("email");
967
+ plan.dependsOn.push("email");
968
+ plan.composeServices.email = { image: IMAGES.smtp, restart: "unless-stopped", networks: net };
969
+ const requirements = scan.middlewares?.filter((item) => item.service === "email" && item.backend === "smtp" && ["default", void 0].includes(options?.serviceModes?.[item.envPrefix] ?? options?.serviceModes?.email));
970
+ if (requirements?.length) {
971
+ for (const requirement of requirements)
972
+ plan.env[requirement.envPrefix] = JSON.stringify({ name: requirement.config.name ?? "email", type: "smtp", host: "email", port: 1025, secure: false, from: "noreply@example.com" });
973
+ } else if (!scan.middlewares) {
974
+ plan.env.HAI_REACH_PROVIDERS_0 = JSON.stringify({ name: "email", type: "smtp", host: "email", port: 1025, secure: false, from: "noreply@example.com" });
975
+ }
771
976
  }
772
977
  }
773
978
  return plan;
774
979
  }
775
- function buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort) {
776
- const sidecar = planSidecars(appDir, projectName, scan, provisionEnv);
980
+ function buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort, options, appEnv = {}, s3Engine = "rustfs") {
981
+ const sidecar = planSidecars(appDir, projectName, scan, provisionEnv, options, s3Engine);
777
982
  const env = {
778
983
  NODE_ENV: "production",
779
984
  HOST: "0.0.0.0",
780
985
  PORT: String(containerPort),
986
+ HAI_SERV_SERVER_HOST: "0.0.0.0",
987
+ HAI_SERV_SERVER_PORT: String(containerPort),
988
+ ...appEnv,
781
989
  ...sidecar.env,
782
990
  ...provisionEnv
783
991
  };
@@ -799,21 +1007,55 @@ function generateComposeFile(options) {
799
1007
  app.volumes = sidecar.appVolumes;
800
1008
  if (sidecar.dependsOn.length > 0)
801
1009
  app.depends_on = sidecar.dependsOn;
1010
+ const services = { app, ...sidecar.composeServices };
1011
+ if (services["storage-init"])
1012
+ services["storage-init"] = { ...services["storage-init"], image: imageRef };
802
1013
  const compose = {
803
- services: {
804
- app,
805
- ...sidecar.composeServices
806
- },
1014
+ services,
807
1015
  networks: {
808
1016
  "hai-app": { name: `hai-${projectName}` }
809
1017
  }
810
1018
  };
811
1019
  if (Object.keys(sidecar.volumes).length > 0)
812
1020
  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
+ if (services["storage-init"]) {
1039
+ app.depends_on = {
1040
+ ...Object.fromEntries((app.depends_on ?? []).map((name) => [name, { condition: "service_started" }])),
1041
+ "storage-init": { condition: "service_completed_successfully" }
1042
+ };
1043
+ }
813
1044
  return stringify(compose);
814
1045
  }
815
1046
  function serializeEnvFile(env) {
816
- return `${Object.entries(env).map(([key, value]) => `${key}=${value}`).join("\n")}
1047
+ return `${Object.entries(env).map(([key, value]) => {
1048
+ let literal = value;
1049
+ if (/[\s#$'"]/.test(value)) {
1050
+ if (value.startsWith("{") || value.startsWith("["))
1051
+ literal = `'${value.replaceAll("'", "\\u0027")}'`;
1052
+ else if (!value.includes("'"))
1053
+ literal = `'${value}'`;
1054
+ else
1055
+ literal = JSON.stringify(value).replaceAll("$", "$$");
1056
+ }
1057
+ return `${key}=${literal}`;
1058
+ }).join("\n")}
817
1059
  `;
818
1060
  }
819
1061
  function generateDockerignore() {
@@ -826,31 +1068,46 @@ function generateDockerignore() {
826
1068
  **/test-results
827
1069
  **/playwright-report
828
1070
  **/.hai/deploy
1071
+ **/.env.local
1072
+ **/.env.*.local
1073
+ **/.env.runtime
829
1074
  .git
830
1075
  `;
831
1076
  }
832
- function generateFallbackDockerfile(packageName, containerPort) {
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";
833
1082
  return `# syntax=docker/dockerfile:1.7
834
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
835
1086
  ENV PNPM_HOME=/pnpm
836
1087
  ENV PATH=$PNPM_HOME:$PATH
837
1088
  ENV CI=true
1089
+ ENV HAI_DEPLOY_ENVIRONMENT=${environment}
1090
+ ENV HAI_CORE_ENV=${coreEnv}
838
1091
  WORKDIR /workspace
839
1092
  RUN corepack enable && corepack prepare pnpm@12.11.2 --activate
840
1093
  COPY . .
841
1094
  RUN pnpm install --frozen-lockfile --ignore-scripts
842
- RUN pnpm --filter ${packageName} build
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
843
1097
  RUN pnpm pm deploy --filter ${packageName} --prod /prod/app
844
1098
 
845
1099
  FROM node:24-bookworm-slim AS runtime
846
1100
  ENV NODE_ENV=production
1101
+ ENV HAI_DEPLOY_ENVIRONMENT=${environment}
1102
+ ENV HAI_CORE_ENV=${coreEnv}
847
1103
  ENV HOST=0.0.0.0
848
1104
  ENV PORT=${containerPort}
849
1105
  WORKDIR /app
850
1106
  COPY --from=build --chown=node:node /prod/app ./
1107
+ RUN mkdir -p data && chown node:node data
851
1108
  USER node
852
1109
  EXPOSE ${containerPort}
853
- CMD ["node", "build"]
1110
+ CMD ["node", ${JSON.stringify(options.entrypoint ?? "build")}]
854
1111
  `;
855
1112
  }
856
1113
  var logger4 = core.logger.child({ module: "deploy", scope: "command" });
@@ -865,10 +1122,10 @@ function maskSecrets(text, secrets) {
865
1122
  return masked;
866
1123
  }
867
1124
  function runCommand(command, args, options = {}) {
868
- return new Promise((resolve) => {
1125
+ return new Promise((resolve3) => {
869
1126
  const child = spawn(command, args, {
870
1127
  cwd: options.cwd,
871
- env: options.env ? { ...process.env, ...options.env } : process.env,
1128
+ env: options.env ? { ...process2.env, ...options.env } : process2.env,
872
1129
  shell: false
873
1130
  });
874
1131
  let stdout = "";
@@ -881,7 +1138,7 @@ function runCommand(command, args, options = {}) {
881
1138
  settled = true;
882
1139
  if (timer)
883
1140
  clearTimeout(timer);
884
- resolve(result);
1141
+ resolve3(result);
885
1142
  };
886
1143
  if (options.timeoutMs && options.timeoutMs > 0) {
887
1144
  timer = setTimeout(() => {
@@ -918,12 +1175,14 @@ function runCommand(command, args, options = {}) {
918
1175
  logger4.error("Command exited with non-zero code", {
919
1176
  command,
920
1177
  code: exitCode,
921
- stderr: maskSecrets(stderr, options.secrets).slice(-500)
1178
+ stderr: maskSecrets(stderr, options.secrets).slice(-500),
1179
+ stdout: maskSecrets(stdout, options.secrets).slice(-3e3)
922
1180
  });
923
1181
  finish(err(
924
1182
  HaiDeployError.REMOTE_COMMAND_FAILED,
925
1183
  deployM("deploy_commandFailed", {
926
- params: { command, error: maskSecrets(stderr || `exit ${exitCode}`, options.secrets).slice(-500) }
1184
+ params: { command, error: maskSecrets(`${stdout.slice(-2500)}
1185
+ ${stderr || `exit ${exitCode}`}`, options.secrets).slice(-3e3) }
927
1186
  })
928
1187
  ));
929
1188
  });
@@ -956,6 +1215,12 @@ async function buildImage(runtime, options) {
956
1215
  const args = ["build", "-f", options.dockerfilePath, "-t", options.imageRef];
957
1216
  if (runtime === "podman")
958
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}`);
959
1224
  args.push(options.contextDir);
960
1225
  logger5.info("Building container image", { runtime, imageRef: options.imageRef });
961
1226
  const result = await runCommand(runtime, args, { cwd: options.contextDir });
@@ -1049,6 +1314,9 @@ function createSshExecutor(config) {
1049
1314
  var logger7 = core.logger.child({ module: "deploy", scope: "provider-docker-ssh" });
1050
1315
  var HEALTH_POLL_INTERVAL = 3e3;
1051
1316
  var HEALTH_MAX_ATTEMPTS = 40;
1317
+ function shellArgument(value) {
1318
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
1319
+ }
1052
1320
  function makeDeploymentId() {
1053
1321
  const now = /* @__PURE__ */ new Date();
1054
1322
  const pad = (n) => String(n).padStart(2, "0");
@@ -1060,7 +1328,7 @@ function parseLoadedImageRef(stdout, fallback) {
1060
1328
  return match ? match[1] : fallback;
1061
1329
  }
1062
1330
  function sleep(ms) {
1063
- return new Promise((resolve) => setTimeout(resolve, ms));
1331
+ return new Promise((resolve3) => setTimeout(resolve3, ms));
1064
1332
  }
1065
1333
  function readPackageName(appDir) {
1066
1334
  try {
@@ -1083,7 +1351,9 @@ function resolveWorkspaceRoot(appDir) {
1083
1351
  }
1084
1352
  return appDir;
1085
1353
  }
1086
- function createDockerSshProvider(config, localRuntimePref = "auto") {
1354
+ function createDockerSshProvider(config, container) {
1355
+ const localRuntimePref = container?.runtime ?? "auto";
1356
+ const httpProxy = container?.httpProxy ?? true;
1087
1357
  const ssh = createSshExecutor(config.ssh);
1088
1358
  const baseDir = config.remote.baseDir;
1089
1359
  async function detectRemoteRuntime() {
@@ -1116,7 +1386,7 @@ function createDockerSshProvider(config, localRuntimePref = "auto") {
1116
1386
  return false;
1117
1387
  }
1118
1388
  const deploy2 = async (context) => {
1119
- const { appDir, projectName, scan, provisionEnv } = context;
1389
+ const { appDir, projectName, scan, provisionEnv, appEnv, options } = context;
1120
1390
  const deploymentId = makeDeploymentId();
1121
1391
  const containerPort = config.expose.containerPort;
1122
1392
  const hostPort = config.expose.hostPort;
@@ -1125,7 +1395,7 @@ function createDockerSshProvider(config, localRuntimePref = "auto") {
1125
1395
  if (!runtimeResult.success)
1126
1396
  return err(runtimeResult.error);
1127
1397
  const localRuntime = runtimeResult.data;
1128
- const artifactDir = join(appDir, ".hai", "deploy");
1398
+ const artifactDir = join(appDir, ".hai", "deploy", options?.environment ?? "prod");
1129
1399
  mkdirSync(artifactDir, { recursive: true });
1130
1400
  const appDockerfile = join(appDir, "Dockerfile");
1131
1401
  let dockerfilePath;
@@ -1135,21 +1405,33 @@ function createDockerSshProvider(config, localRuntimePref = "auto") {
1135
1405
  contextDir = appDir;
1136
1406
  } else {
1137
1407
  dockerfilePath = join(artifactDir, "Dockerfile");
1138
- writeFileSync(dockerfilePath, generateFallbackDockerfile(readPackageName(appDir), containerPort));
1408
+ writeFileSync(dockerfilePath, generateFallbackDockerfile(readPackageName(appDir), containerPort, { environment: options?.environment, entrypoint: scan.isSvelteKit ? "build" : "dist/index.js" }));
1139
1409
  writeFileSync(`${dockerfilePath}.dockerignore`, generateDockerignore());
1140
1410
  contextDir = resolveWorkspaceRoot(appDir);
1141
1411
  logger7.info("Generated fallback Dockerfile", { dockerfilePath, contextDir });
1142
1412
  }
1413
+ const { env, sidecar } = buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort, options, appEnv, container?.s3Engine);
1414
+ const envPath = join(artifactDir, ".env.runtime");
1415
+ const previousEnv = existsSync(envPath) ? parseEnv(readFileSync(envPath, "utf8")) : {};
1416
+ Object.assign(env, { ...previousEnv, ...env });
1417
+ writeFileSync(envPath, serializeEnvFile(env), { mode: 384 });
1418
+ if (options?.environment) {
1419
+ const environment = loadDeploymentEnvironment({ environment: options.environment, envDir: options.envDir ?? scan.envDir ?? appDir });
1420
+ if (!environment.success)
1421
+ return environment;
1422
+ const saved = saveDeploymentEnvironment(environment.data, { HAI_DEPLOY_ENVIRONMENT: environment.data.environment, HAI_CORE_ENV: environment.data.values.HAI_CORE_ENV, ...sidecar.env, ...provisionEnv });
1423
+ if (!saved.success)
1424
+ return saved;
1425
+ }
1143
1426
  const buildResult = await buildImage(localRuntime, {
1144
1427
  contextDir,
1145
1428
  dockerfilePath,
1146
- imageRef: localImageTag
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
1147
1432
  });
1148
1433
  if (!buildResult.success)
1149
1434
  return err(buildResult.error);
1150
- const { env, sidecar } = buildRuntimeEnv(appDir, projectName, scan, provisionEnv, containerPort);
1151
- const envPath = join(artifactDir, ".env.runtime");
1152
- writeFileSync(envPath, serializeEnvFile(env), { mode: 384 });
1153
1435
  const tarPath = join(artifactDir, `${projectName}.tar`);
1154
1436
  const saveResult = await saveImage(localRuntime, localImageTag, tarPath);
1155
1437
  if (!saveResult.success)
@@ -1162,20 +1444,20 @@ function createDockerSshProvider(config, localRuntimePref = "auto") {
1162
1444
  return err(remoteRuntimeResult.error);
1163
1445
  const remoteRuntime = remoteRuntimeResult.data;
1164
1446
  const remoteDir = `${baseDir}/${projectName}`;
1165
- const prepareResult = await ssh.exec(`mkdir -p ${remoteDir} && chmod 700 ${remoteDir}`);
1447
+ const prepareResult = await ssh.exec(`mkdir -p -- ${shellArgument(remoteDir)} && chmod 700 -- ${shellArgument(remoteDir)}`);
1166
1448
  if (!prepareResult.success)
1167
1449
  return err(prepareResult.error);
1168
1450
  const remoteTar = `${remoteDir}/image.tar`;
1169
1451
  const uploadTarResult = await ssh.upload(tarPath, remoteTar);
1170
1452
  if (!uploadTarResult.success)
1171
1453
  return err(uploadTarResult.error);
1172
- const loadResult = await ssh.exec(`${remoteRuntime} load -i ${remoteTar}`, { timeoutMs: 3e5 });
1454
+ const loadResult = await ssh.exec(`${remoteRuntime} load -i ${shellArgument(remoteTar)}`, { timeoutMs: 3e5 });
1173
1455
  if (!loadResult.success)
1174
1456
  return err(loadResult.error);
1175
1457
  const loadedRef = parseLoadedImageRef(loadResult.data.stdout, localImageTag);
1176
1458
  const remoteImageRef = remoteRuntime === "podman" ? `localhost/${localImageTag}` : localImageTag;
1177
1459
  if (loadedRef !== remoteImageRef) {
1178
- const tagResult = await ssh.exec(`${remoteRuntime} tag ${loadedRef} ${remoteImageRef}`);
1460
+ const tagResult = await ssh.exec(`${remoteRuntime} tag ${shellArgument(loadedRef)} ${shellArgument(remoteImageRef)}`);
1179
1461
  if (!tagResult.success)
1180
1462
  return err(tagResult.error);
1181
1463
  }
@@ -1184,7 +1466,9 @@ function createDockerSshProvider(config, localRuntimePref = "auto") {
1184
1466
  imageRef: remoteImageRef,
1185
1467
  hostPort,
1186
1468
  containerPort,
1187
- sidecar
1469
+ sidecar,
1470
+ serviceModes: options?.serviceModes,
1471
+ previousCompose: existsSync(join(artifactDir, "compose.yml")) ? readFileSync(join(artifactDir, "compose.yml"), "utf8") : void 0
1188
1472
  });
1189
1473
  const composePath = join(artifactDir, "compose.yml");
1190
1474
  writeFileSync(composePath, composeContent);
@@ -1194,20 +1478,33 @@ function createDockerSshProvider(config, localRuntimePref = "auto") {
1194
1478
  const uploadEnvResult = await ssh.upload(envPath, `${remoteDir}/.env.runtime`);
1195
1479
  if (!uploadEnvResult.success)
1196
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;
1197
1494
  const secretMask = sidecar.secrets;
1198
- const chmodResult = await ssh.exec(`chmod 600 ${remoteDir}/.env.runtime`, { secrets: secretMask });
1495
+ const chmodResult = await ssh.exec(`chmod 600 -- ${shellArgument(`${remoteDir}/.env.runtime`)}`, { secrets: secretMask });
1199
1496
  if (!chmodResult.success)
1200
1497
  return err(chmodResult.error);
1201
1498
  const upResult = await ssh.exec(
1202
- `cd ${remoteDir} && ${remoteRuntime} compose -p hai-${projectName} up -d`,
1499
+ `cd ${shellArgument(remoteDir)} && sh ${shellArgument(startName)}`,
1203
1500
  { timeoutMs: 3e5, secrets: secretMask }
1204
1501
  );
1205
1502
  if (!upResult.success)
1206
1503
  return err(upResult.error);
1207
- await ssh.exec(`rm -f ${remoteTar}`);
1504
+ await ssh.exec(`rm -f -- ${shellArgument(remoteTar)}`);
1208
1505
  const url = `http://${config.ssh.host}:${hostPort}`;
1209
1506
  logger7.info("Running health check", { url });
1210
- const healthy = await healthCheck(`${url}/`);
1507
+ const healthy = await healthCheck(`${url}${scan.isSvelteKit ? "/" : "/health"}`);
1211
1508
  if (!healthy) {
1212
1509
  return err(
1213
1510
  HaiDeployError.HEALTH_CHECK_FAILED,
@@ -1462,7 +1759,7 @@ async function pollDeploymentStatus(token, deploymentId) {
1462
1759
  if (deployment.readyState === "ERROR" || deployment.readyState === "CANCELED") {
1463
1760
  return "error";
1464
1761
  }
1465
- await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL));
1762
+ await new Promise((resolve3) => setTimeout(resolve3, POLL_INTERVAL));
1466
1763
  }
1467
1764
  return "building";
1468
1765
  }
@@ -2000,15 +2297,31 @@ function normalizeProjectName(name) {
2000
2297
  const normalized = name.replace(/^@[^/]+\//, "").toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "");
2001
2298
  return normalized || "app";
2002
2299
  }
2003
- function mergeProvisionEnv(results) {
2300
+ function mergeProvisionEnv(results, scan, options) {
2004
2301
  let env = {};
2005
- const reachProviders = [];
2302
+ const reachRequirements = scan?.middlewares?.filter((item) => item.configName === "reach") ?? [];
2303
+ const reachProviders = reachRequirements.map((item) => item.config);
2304
+ let hasReachOutput = false;
2006
2305
  for (const prov of results) {
2007
- if (prov.envVars.HAI_REACH_PROVIDERS)
2008
- reachProviders.push(...JSON.parse(prov.envVars.HAI_REACH_PROVIDERS));
2306
+ if (prov.envVars.HAI_REACH_PROVIDERS) {
2307
+ hasReachOutput = true;
2308
+ const supplied = JSON.parse(prov.envVars.HAI_REACH_PROVIDERS);
2309
+ for (const provider of supplied) {
2310
+ const targets = reachRequirements.filter((item) => {
2311
+ const mode = options?.serviceModes?.[item.envPrefix] ?? options?.serviceModes?.[item.service];
2312
+ return item.service === prov.serviceType && (!mode || mode === "default");
2313
+ });
2314
+ if (targets.length) {
2315
+ for (const target of targets)
2316
+ reachProviders[reachRequirements.indexOf(target)] = { ...provider, name: target.config.name ?? provider.name };
2317
+ } else {
2318
+ reachProviders.push(provider);
2319
+ }
2320
+ }
2321
+ }
2009
2322
  env = { ...env, ...prov.envVars };
2010
2323
  }
2011
- if (reachProviders.length > 0)
2324
+ if (hasReachOutput)
2012
2325
  env.HAI_REACH_PROVIDERS = JSON.stringify(reachProviders);
2013
2326
  return env;
2014
2327
  }
@@ -2043,7 +2356,7 @@ var deploy = {
2043
2356
  let provider = null;
2044
2357
  let containerProvider = null;
2045
2358
  if (parsed.provider.type === "docker-ssh") {
2046
- containerProvider = createDockerSshProvider(parsed.provider, parsed.container?.runtime);
2359
+ containerProvider = createDockerSshProvider(parsed.provider, parsed.container);
2047
2360
  logger14.info("Container deploy provider ready", { provider: "docker-ssh", host: parsed.provider.ssh.host });
2048
2361
  } else {
2049
2362
  provider = createProviderByType(parsed.provider.type);
@@ -2121,17 +2434,19 @@ var deploy = {
2121
2434
  get isInitialized() {
2122
2435
  return currentProvider !== null || currentContainerProvider !== null;
2123
2436
  },
2124
- async scan(appDir) {
2437
+ async scan(appDir, options) {
2125
2438
  logger14.debug("Scanning application", { appDir });
2126
- return scanApp(appDir);
2439
+ return scanApp(appDir, options);
2127
2440
  },
2128
- async provisionAll(projectName) {
2441
+ async provisionAll(projectName, services) {
2129
2442
  if (!currentProvider && !currentContainerProvider) {
2130
2443
  return notInitialized.result();
2131
2444
  }
2132
2445
  logger14.info("Provisioning all services", { projectName, count: currentProvisioners.size });
2133
2446
  const results = [];
2134
2447
  for (const [serviceType, provisioner] of currentProvisioners) {
2448
+ if (services && !services.includes(serviceType))
2449
+ continue;
2135
2450
  logger14.debug("Provisioning service", { serviceType, provisioner: provisioner.name });
2136
2451
  const provResult = await provisioner.provision(projectName);
2137
2452
  if (!provResult.success) {
@@ -2160,15 +2475,26 @@ var deploy = {
2160
2475
  if (!currentProvider && !currentContainerProvider) {
2161
2476
  return notInitialized.result();
2162
2477
  }
2163
- logger14.info("Starting deployment", { appDir, options });
2164
- const scanResult = await scanApp(appDir);
2478
+ logger14.info("Starting deployment", { appDir, environment: options?.environment, projectName: options?.projectName });
2479
+ const scanOptions = { ...options, environment: options?.environment ?? currentConfig?.environment };
2480
+ const scanResult = await scanApp(appDir, scanOptions);
2165
2481
  if (!scanResult.success) {
2166
2482
  return err(scanResult.error);
2167
2483
  }
2168
2484
  const scan = scanResult.data;
2169
- const projectName = options?.projectName ?? scan.appName;
2485
+ appDir = scan.appDir ?? appDir;
2486
+ const environment = loadDeploymentEnvironment({ environment: scanOptions.environment ?? "prod", envDir: scan.envDir ?? appDir, requireFile: scanOptions.environment !== void 0 });
2487
+ if (!environment.success)
2488
+ return environment;
2489
+ const appEnv = Object.fromEntries(Object.entries(environment.data.values).filter(([key]) => !key.startsWith("HAI_CLI_") && (!key.startsWith("HAI_DEPLOY_") || key === "HAI_DEPLOY_ENVIRONMENT")));
2490
+ const projectName = `${options?.projectName ?? scan.appName}${scanOptions.environment ? `-${environment.data.environment}` : ""}`;
2491
+ options = {
2492
+ ...options,
2493
+ ...scanOptions.environment ? { environment: environment.data.environment } : {},
2494
+ ...options?.skipProvision ? { serviceModes: Object.fromEntries(scan.requiredServices.map((service) => [service, "keep"])) } : {}
2495
+ };
2170
2496
  if (currentContainerProvider) {
2171
- return deployContainerApp(currentContainerProvider, appDir, scan, projectName, options);
2497
+ return deployContainerApp(currentContainerProvider, appDir, scan, projectName, options, appEnv);
2172
2498
  }
2173
2499
  if (!currentProvider) {
2174
2500
  return notInitialized.result();
@@ -2181,15 +2507,20 @@ var deploy = {
2181
2507
  })
2182
2508
  );
2183
2509
  }
2184
- let allEnvVars = {};
2510
+ let allEnvVars = appEnv;
2185
2511
  const recovery = { projectName, stage: "provision", resources: [] };
2186
2512
  if (!options?.skipProvision) {
2187
- const provResults = await deploy.provisionAll(projectName);
2513
+ const provResults = await deploy.provisionAll(projectName, options?.serviceModes ? scan.requiredServices.filter((service) => usesDefaultMiddleware(scan, service, options)) : void 0);
2188
2514
  if (!provResults.success) {
2189
2515
  return err(provResults.error);
2190
2516
  }
2191
2517
  recovery.resources = provResults.data.map(({ envVars: _envVars, ...resource }) => resource);
2192
- allEnvVars = mergeProvisionEnv(provResults.data);
2518
+ allEnvVars = { ...allEnvVars, ...mergeProvisionEnv(provResults.data, scan, options) };
2519
+ if (options?.environment) {
2520
+ const saved = saveDeploymentEnvironment(environment.data, mergeProvisionEnv(provResults.data, scan, options));
2521
+ if (!saved.success)
2522
+ return recoveryFailure(saved.error, { ...recovery, stage: "save-env" });
2523
+ }
2193
2524
  }
2194
2525
  const projectResult = await currentProvider.createProject(projectName);
2195
2526
  if (!projectResult.success) {
@@ -2226,7 +2557,7 @@ var deploy = {
2226
2557
  return ok(result);
2227
2558
  }
2228
2559
  };
2229
- async function deployContainerApp(provider, appDir, scan, rawProjectName, options) {
2560
+ async function deployContainerApp(provider, appDir, scan, rawProjectName, options, appEnv = {}) {
2230
2561
  const projectName = normalizeProjectName(rawProjectName);
2231
2562
  if (scan.isSvelteKit && !scan.nodeAdapterInstalled) {
2232
2563
  return err(
@@ -2236,15 +2567,60 @@ async function deployContainerApp(provider, appDir, scan, rawProjectName, option
2236
2567
  }
2237
2568
  let provisionEnv = {};
2238
2569
  if (!options?.skipProvision) {
2239
- const provResults = await deploy.provisionAll(projectName);
2570
+ const provResults = await deploy.provisionAll(projectName, options?.serviceModes ? scan.requiredServices.filter((service) => usesDefaultMiddleware(scan, service, options)) : void 0);
2240
2571
  if (!provResults.success) {
2241
2572
  return err(provResults.error);
2242
2573
  }
2243
- provisionEnv = mergeProvisionEnv(provResults.data);
2574
+ provisionEnv = mergeProvisionEnv(provResults.data, scan, options);
2244
2575
  }
2245
- return provider.deploy({ appDir, projectName, scan, provisionEnv });
2576
+ return provider.deploy({ appDir, projectName, scan, provisionEnv, appEnv, options });
2577
+ }
2578
+
2579
+ // src/deploy-middleware.ts
2580
+ var field = (name, defaultValue, kind, secret = false) => ({ name, defaultValue, kind, secret });
2581
+ var required = (name, defaultValue, kind) => ({ ...field(name, defaultValue, kind), required: true });
2582
+ var DEPLOY_MIDDLEWARE = {
2583
+ db: [
2584
+ { backend: "sqlite", label: "SQLite", purpose: "deploy_purposeSqlite", fields: [required("database", "./data/app.db")] },
2585
+ ...["postgresql", "mysql"].map((backend) => ({
2586
+ backend,
2587
+ label: backend === "mysql" ? "MySQL" : "PostgreSQL",
2588
+ purpose: "deploy_purposeDatabase",
2589
+ provisioner: backend === "postgresql" ? "neon" : void 0,
2590
+ fields: [field("url", "", "url", true), required("host", "localhost"), required("port", backend === "mysql" ? "3306" : "5432", "port"), required("database", "hai"), field("user", "hai"), field("password", "", void 0, true), field("ssl", "false")]
2591
+ }))
2592
+ ],
2593
+ cache: [
2594
+ { backend: "memory", label: "Memory", purpose: "deploy_purposeMemory", fields: [] },
2595
+ { backend: "redis", label: "Redis", purpose: "deploy_purposeCache", provisioner: "upstash", fields: [field("url", "", "url", true), required("host", "localhost"), required("port", "6379", "port"), field("password", "", void 0, true), { ...field("db", "0", "number"), maximum: 15 }, field("tls", "false", "boolean"), field("keyPrefix"), field("connectTimeout", "10000", "number"), field("commandTimeout", "5000", "number")] }
2596
+ ],
2597
+ storage: [
2598
+ { backend: "local", label: "Local filesystem", purpose: "deploy_purposeLocal", fields: [required("root", "./data/storage")] },
2599
+ { backend: "s3", label: "S3 / RustFS / MinIO / Cloudflare R2", purpose: "deploy_purposeStorage", provisioner: "cloudflare-r2", fields: [field("endpoint", "", "url"), required("bucket", "hai"), required("region", "us-east-1"), required("accessKeyId"), { ...required("secretAccessKey"), secret: true }, field("forcePathStyle", "true", "boolean")] }
2600
+ ],
2601
+ vecdb: [
2602
+ { backend: "lancedb", label: "LanceDB", purpose: "deploy_purposeLocal", fields: [required("path", "./data/vectors")] },
2603
+ { backend: "qdrant", label: "Qdrant", purpose: "deploy_purposeVector", fields: [required("url", "http://localhost:6333", "url"), field("apiKey", "", void 0, true)] },
2604
+ { backend: "pgvector", label: "PostgreSQL / pgvector", purpose: "deploy_purposeVector", fields: [field("url", "", "url", true), required("host", "localhost"), required("port", "5432", "port"), required("database", "hai"), field("user", "hai"), field("password", "", void 0, true)] },
2605
+ { backend: "chroma", label: "Chroma", purpose: "deploy_purposeVector", fields: [required("url", "http://localhost:8000", "url")] }
2606
+ ],
2607
+ ldap: [
2608
+ { backend: "disabled", label: "Disabled", purpose: "deploy_purposeDisabled", fields: [] },
2609
+ { backend: "ldap", label: "LDAP / OpenLDAP", purpose: "deploy_purposeLdap", fields: [required("url", "ldap://localhost:389", "url"), required("bindDn", "cn=admin,dc=example,dc=org"), { ...required("bindPassword"), secret: true }, required("searchBase", "dc=example,dc=org"), field("searchFilter", "(uid={{username}})"), field("usernameAttribute", "uid"), field("emailAttribute", "mail"), field("displayNameAttribute", "cn"), field("useTls", "false", "boolean"), { ...field("connectTimeout", "5000", "number"), minimum: 1e3 }] }
2610
+ ],
2611
+ email: [
2612
+ { backend: "console", label: "Console", purpose: "deploy_purposeConsole", fields: [] },
2613
+ { backend: "smtp", label: "SMTP", purpose: "deploy_purposeEmail", provisioner: "resend", fields: [required("host", "localhost"), required("port", "465", "port"), field("secure", "true", "boolean"), field("user"), field("pass", "", void 0, true), required("from", "noreply@example.com")] },
2614
+ { backend: "api", label: "HTTP notification API", purpose: "deploy_purposeEmail", fields: [required("url", "", "url"), field("method", "POST"), field("headers"), field("timeout", "10000", "number")] }
2615
+ ],
2616
+ sms: [
2617
+ { backend: "aliyun-sms", label: "Aliyun SMS", purpose: "deploy_purposeSms", provisioner: "aliyun", fields: [required("accessKeyId"), { ...required("accessKeySecret"), secret: true }, required("signName"), field("endpoint", "dysmsapi.aliyuncs.com")] }
2618
+ ]
2619
+ };
2620
+ function describeMiddlewareBackend(backend) {
2621
+ return deployM(backend.purpose);
2246
2622
  }
2247
2623
 
2248
- export { DeployConfigSchema, HaiDeployError, ProviderConfigSchema, deploy, getDeployRecovery };
2624
+ export { DEPLOY_MIDDLEWARE, DeployConfigSchema, HaiDeployError, ProviderConfigSchema, deploy, describeMiddlewareBackend, getDeployRecovery, loadDeploymentConfig, loadDeploymentEnvironment, saveDeploymentEnvironment };
2249
2625
  //# sourceMappingURL=index.js.map
2250
2626
  //# sourceMappingURL=index.js.map