@better-openclaw/core 1.0.8 → 1.0.9

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.
Files changed (96) hide show
  1. package/dist/bare-metal-partition.d.mts.map +1 -1
  2. package/dist/bare-metal-partition.mjs +1 -0
  3. package/dist/bare-metal-partition.mjs.map +1 -1
  4. package/dist/composer.d.mts.map +1 -1
  5. package/dist/composer.mjs +11 -1
  6. package/dist/composer.mjs.map +1 -1
  7. package/dist/errors.d.mts +17 -0
  8. package/dist/errors.d.mts.map +1 -0
  9. package/dist/errors.mjs +24 -0
  10. package/dist/errors.mjs.map +1 -0
  11. package/dist/generate.d.mts +4 -3
  12. package/dist/generate.d.mts.map +1 -1
  13. package/dist/generate.mjs +13 -5
  14. package/dist/generate.mjs.map +1 -1
  15. package/dist/generators/traefik.d.mts +19 -0
  16. package/dist/generators/traefik.d.mts.map +1 -0
  17. package/dist/generators/traefik.mjs +86 -0
  18. package/dist/generators/traefik.mjs.map +1 -0
  19. package/dist/generators/traefik.test.d.mts +1 -0
  20. package/dist/generators/traefik.test.mjs +69 -0
  21. package/dist/generators/traefik.test.mjs.map +1 -0
  22. package/dist/index.d.mts +4 -2
  23. package/dist/index.mjs +4 -2
  24. package/dist/migrations.d.mts +14 -0
  25. package/dist/migrations.d.mts.map +1 -0
  26. package/dist/migrations.mjs +33 -0
  27. package/dist/migrations.mjs.map +1 -0
  28. package/dist/migrations.test.d.mts +1 -0
  29. package/dist/migrations.test.mjs +42 -0
  30. package/dist/migrations.test.mjs.map +1 -0
  31. package/dist/resolver.d.mts +6 -1
  32. package/dist/resolver.d.mts.map +1 -1
  33. package/dist/resolver.mjs +21 -3
  34. package/dist/resolver.mjs.map +1 -1
  35. package/dist/schema.d.mts +1 -0
  36. package/dist/schema.d.mts.map +1 -1
  37. package/dist/schema.mjs +1 -0
  38. package/dist/schema.mjs.map +1 -1
  39. package/dist/services/definitions/caddy.mjs +20 -1
  40. package/dist/services/definitions/caddy.mjs.map +1 -1
  41. package/dist/services/definitions/cal-com.d.mts +7 -0
  42. package/dist/services/definitions/cal-com.d.mts.map +1 -0
  43. package/dist/services/definitions/cal-com.mjs +88 -0
  44. package/dist/services/definitions/cal-com.mjs.map +1 -0
  45. package/dist/services/definitions/grafana.mjs +13 -1
  46. package/dist/services/definitions/grafana.mjs.map +1 -1
  47. package/dist/services/definitions/index.d.mts +4 -1
  48. package/dist/services/definitions/index.d.mts.map +1 -1
  49. package/dist/services/definitions/index.mjs +8 -2
  50. package/dist/services/definitions/index.mjs.map +1 -1
  51. package/dist/services/definitions/neo4j.d.mts +7 -0
  52. package/dist/services/definitions/neo4j.d.mts.map +1 -0
  53. package/dist/services/definitions/neo4j.mjs +91 -0
  54. package/dist/services/definitions/neo4j.mjs.map +1 -0
  55. package/dist/services/definitions/traefik.mjs +0 -1
  56. package/dist/services/definitions/traefik.mjs.map +1 -1
  57. package/dist/services/definitions/xyops.d.mts +7 -0
  58. package/dist/services/definitions/xyops.d.mts.map +1 -0
  59. package/dist/services/definitions/xyops.mjs +86 -0
  60. package/dist/services/definitions/xyops.mjs.map +1 -0
  61. package/dist/types.d.mts +8 -1
  62. package/dist/types.d.mts.map +1 -1
  63. package/dist/types.mjs.map +1 -1
  64. package/dist/validator.mjs +11 -0
  65. package/dist/validator.mjs.map +1 -1
  66. package/dist/version-manager.d.mts +1 -1
  67. package/dist/version-manager.d.mts.map +1 -1
  68. package/dist/version-manager.mjs +11 -5
  69. package/dist/version-manager.mjs.map +1 -1
  70. package/dist/version-manager.test.d.mts +1 -0
  71. package/dist/version-manager.test.mjs +102 -0
  72. package/dist/version-manager.test.mjs.map +1 -0
  73. package/package.json +1 -1
  74. package/src/__snapshots__/composer.snapshot.test.ts.snap +15 -1
  75. package/src/bare-metal-partition.ts +1 -0
  76. package/src/composer.ts +22 -1
  77. package/src/errors.ts +23 -0
  78. package/src/generate.ts +22 -4
  79. package/src/generators/traefik.test.ts +97 -0
  80. package/src/generators/traefik.ts +104 -0
  81. package/src/index.ts +7 -1
  82. package/src/migrations.test.ts +36 -0
  83. package/src/migrations.ts +49 -0
  84. package/src/resolver.ts +37 -3
  85. package/src/schema.ts +1 -0
  86. package/src/services/definitions/caddy.ts +23 -1
  87. package/src/services/definitions/cal-com.ts +91 -0
  88. package/src/services/definitions/grafana.ts +16 -1
  89. package/src/services/definitions/index.ts +9 -0
  90. package/src/services/definitions/neo4j.ts +96 -0
  91. package/src/services/definitions/traefik.ts +0 -2
  92. package/src/services/definitions/xyops.ts +94 -0
  93. package/src/types.ts +5 -1
  94. package/src/validator.ts +16 -0
  95. package/src/version-manager.test.ts +134 -0
  96. package/src/version-manager.ts +12 -5
@@ -0,0 +1,102 @@
1
+ import { n as describe, r as it, t as globalExpect } from "./vi.2VT5v0um-Qk6MgAnK.mjs";
2
+ import { getAllServices, getServiceById } from "./services/registry.mjs";
3
+ import { resolve } from "./resolver.mjs";
4
+ import { checkCompatibility, getImageReference, getImageTag, pinImageTags } from "./version-manager.mjs";
5
+
6
+ //#region src/version-manager.test.ts
7
+ describe("getImageTag", () => {
8
+ it("returns the tag for a known service", () => {
9
+ const tag = getImageTag("redis");
10
+ globalExpect(tag).toBeDefined();
11
+ globalExpect(typeof tag).toBe("string");
12
+ });
13
+ it("returns undefined for an unknown service", () => {
14
+ globalExpect(getImageTag("nonexistent-service-xyz")).toBeUndefined();
15
+ });
16
+ });
17
+ describe("getImageReference", () => {
18
+ it("returns image:tag for a known service", () => {
19
+ const ref = getImageReference("redis");
20
+ globalExpect(ref).toBeDefined();
21
+ globalExpect(ref).toContain(":");
22
+ globalExpect(ref).toMatch(/^.+:.+$/);
23
+ });
24
+ it("returns undefined for an unknown service", () => {
25
+ globalExpect(getImageReference("nonexistent-service-xyz")).toBeUndefined();
26
+ });
27
+ });
28
+ describe("pinImageTags", () => {
29
+ it("pins image tags from the registry for all services", () => {
30
+ const resolved = resolve({
31
+ services: ["redis", "postgresql"],
32
+ skillPacks: [],
33
+ proxy: "none",
34
+ gpu: false,
35
+ platform: "linux/amd64"
36
+ });
37
+ const pinned = pinImageTags(resolved);
38
+ globalExpect(pinned.services).toHaveLength(resolved.services.length);
39
+ for (const svc of pinned.services) {
40
+ globalExpect(svc.definition.imageTag).toBeDefined();
41
+ globalExpect(typeof svc.definition.imageTag).toBe("string");
42
+ globalExpect(svc.definition.imageTag.length).toBeGreaterThan(0);
43
+ }
44
+ });
45
+ it("does not mutate the original resolved output", () => {
46
+ const resolved = resolve({
47
+ services: ["redis"],
48
+ skillPacks: [],
49
+ proxy: "none",
50
+ gpu: false,
51
+ platform: "linux/amd64"
52
+ });
53
+ const originalTag = resolved.services[0]?.definition.imageTag;
54
+ pinImageTags(resolved);
55
+ globalExpect(resolved.services[0]?.definition.imageTag).toBe(originalTag);
56
+ });
57
+ it("preserves non-tag properties", () => {
58
+ const resolved = resolve({
59
+ services: ["redis"],
60
+ skillPacks: [],
61
+ proxy: "none",
62
+ gpu: false,
63
+ platform: "linux/amd64"
64
+ });
65
+ const pinned = pinImageTags(resolved);
66
+ globalExpect(pinned.services[0]?.definition.id).toBe("redis");
67
+ globalExpect(pinned.services[0]?.definition.name).toBe("Redis");
68
+ globalExpect(pinned.estimatedMemoryMB).toBe(resolved.estimatedMemoryMB);
69
+ });
70
+ });
71
+ describe("checkCompatibility", () => {
72
+ it("warns when Redis and Valkey are both selected", () => {
73
+ const all = getAllServices();
74
+ const redis = all.find((s) => s.id === "redis");
75
+ const valkey = all.find((s) => s.id === "valkey");
76
+ if (redis && valkey) globalExpect(checkCompatibility([redis, valkey]).some((w) => w.message.includes("Redis") && w.message.includes("Valkey"))).toBe(true);
77
+ });
78
+ it("warns when Caddy and Traefik are both selected", () => {
79
+ const all = getAllServices();
80
+ const caddy = all.find((s) => s.id === "caddy");
81
+ const traefik = all.find((s) => s.id === "traefik");
82
+ if (caddy && traefik) globalExpect(checkCompatibility([caddy, traefik]).some((w) => w.message.includes("Caddy") && w.message.includes("Traefik"))).toBe(true);
83
+ });
84
+ it("warns about multiple vector databases", () => {
85
+ const all = getAllServices();
86
+ const qdrant = all.find((s) => s.id === "qdrant");
87
+ const chromadb = all.find((s) => s.id === "chromadb");
88
+ if (qdrant && chromadb) globalExpect(checkCompatibility([qdrant, chromadb]).some((w) => w.message.includes("vector database"))).toBe(true);
89
+ });
90
+ it("warns about GPU services", () => {
91
+ const gpuService = getAllServices().find((s) => s.gpuRequired);
92
+ if (gpuService) globalExpect(checkCompatibility([gpuService]).some((w) => w.message.includes("GPU"))).toBe(true);
93
+ });
94
+ it("returns no warnings for a single non-conflicting service", () => {
95
+ const redis = getServiceById("redis");
96
+ if (redis) globalExpect(checkCompatibility([redis]).filter((w) => w.type === "compatibility" && !w.message.includes("GPU"))).toHaveLength(0);
97
+ });
98
+ });
99
+
100
+ //#endregion
101
+ export { };
102
+ //# sourceMappingURL=version-manager.test.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version-manager.test.mjs","names":[],"sources":["../src/version-manager.test.ts"],"sourcesContent":["import { describe, expect, it } from \"vitest\";\nimport { resolve } from \"./resolver.js\";\nimport { getAllServices, getServiceById } from \"./services/registry.js\";\nimport { checkCompatibility, getImageReference, getImageTag, pinImageTags } from \"./version-manager.js\";\n\ndescribe(\"getImageTag\", () => {\n\tit(\"returns the tag for a known service\", () => {\n\t\tconst tag = getImageTag(\"redis\");\n\t\texpect(tag).toBeDefined();\n\t\texpect(typeof tag).toBe(\"string\");\n\t});\n\n\tit(\"returns undefined for an unknown service\", () => {\n\t\texpect(getImageTag(\"nonexistent-service-xyz\")).toBeUndefined();\n\t});\n});\n\ndescribe(\"getImageReference\", () => {\n\tit(\"returns image:tag for a known service\", () => {\n\t\tconst ref = getImageReference(\"redis\");\n\t\texpect(ref).toBeDefined();\n\t\texpect(ref).toContain(\":\");\n\t\texpect(ref).toMatch(/^.+:.+$/);\n\t});\n\n\tit(\"returns undefined for an unknown service\", () => {\n\t\texpect(getImageReference(\"nonexistent-service-xyz\")).toBeUndefined();\n\t});\n});\n\ndescribe(\"pinImageTags\", () => {\n\tit(\"pins image tags from the registry for all services\", () => {\n\t\tconst resolved = resolve({\n\t\t\tservices: [\"redis\", \"postgresql\"],\n\t\t\tskillPacks: [],\n\t\t\tproxy: \"none\",\n\t\t\tgpu: false,\n\t\t\tplatform: \"linux/amd64\",\n\t\t});\n\n\t\tconst pinned = pinImageTags(resolved);\n\n\t\texpect(pinned.services).toHaveLength(resolved.services.length);\n\n\t\tfor (const svc of pinned.services) {\n\t\t\texpect(svc.definition.imageTag).toBeDefined();\n\t\t\texpect(typeof svc.definition.imageTag).toBe(\"string\");\n\t\t\texpect(svc.definition.imageTag.length).toBeGreaterThan(0);\n\t\t}\n\t});\n\n\tit(\"does not mutate the original resolved output\", () => {\n\t\tconst resolved = resolve({\n\t\t\tservices: [\"redis\"],\n\t\t\tskillPacks: [],\n\t\t\tproxy: \"none\",\n\t\t\tgpu: false,\n\t\t\tplatform: \"linux/amd64\",\n\t\t});\n\n\t\tconst originalTag = resolved.services[0]?.definition.imageTag;\n\t\tpinImageTags(resolved);\n\n\t\texpect(resolved.services[0]?.definition.imageTag).toBe(originalTag);\n\t});\n\n\tit(\"preserves non-tag properties\", () => {\n\t\tconst resolved = resolve({\n\t\t\tservices: [\"redis\"],\n\t\t\tskillPacks: [],\n\t\t\tproxy: \"none\",\n\t\t\tgpu: false,\n\t\t\tplatform: \"linux/amd64\",\n\t\t});\n\n\t\tconst pinned = pinImageTags(resolved);\n\t\texpect(pinned.services[0]?.definition.id).toBe(\"redis\");\n\t\texpect(pinned.services[0]?.definition.name).toBe(\"Redis\");\n\t\texpect(pinned.estimatedMemoryMB).toBe(resolved.estimatedMemoryMB);\n\t});\n});\n\ndescribe(\"checkCompatibility\", () => {\n\tit(\"warns when Redis and Valkey are both selected\", () => {\n\t\tconst all = getAllServices();\n\t\tconst redis = all.find((s) => s.id === \"redis\");\n\t\tconst valkey = all.find((s) => s.id === \"valkey\");\n\n\t\tif (redis && valkey) {\n\t\t\tconst warnings = checkCompatibility([redis, valkey]);\n\t\t\texpect(warnings.some((w) => w.message.includes(\"Redis\") && w.message.includes(\"Valkey\"))).toBe(true);\n\t\t}\n\t});\n\n\tit(\"warns when Caddy and Traefik are both selected\", () => {\n\t\tconst all = getAllServices();\n\t\tconst caddy = all.find((s) => s.id === \"caddy\");\n\t\tconst traefik = all.find((s) => s.id === \"traefik\");\n\n\t\tif (caddy && traefik) {\n\t\t\tconst warnings = checkCompatibility([caddy, traefik]);\n\t\t\texpect(warnings.some((w) => w.message.includes(\"Caddy\") && w.message.includes(\"Traefik\"))).toBe(true);\n\t\t}\n\t});\n\n\tit(\"warns about multiple vector databases\", () => {\n\t\tconst all = getAllServices();\n\t\tconst qdrant = all.find((s) => s.id === \"qdrant\");\n\t\tconst chromadb = all.find((s) => s.id === \"chromadb\");\n\n\t\tif (qdrant && chromadb) {\n\t\t\tconst warnings = checkCompatibility([qdrant, chromadb]);\n\t\t\texpect(warnings.some((w) => w.message.includes(\"vector database\"))).toBe(true);\n\t\t}\n\t});\n\n\tit(\"warns about GPU services\", () => {\n\t\tconst all = getAllServices();\n\t\tconst gpuService = all.find((s) => s.gpuRequired);\n\n\t\tif (gpuService) {\n\t\t\tconst warnings = checkCompatibility([gpuService]);\n\t\t\texpect(warnings.some((w) => w.message.includes(\"GPU\"))).toBe(true);\n\t\t}\n\t});\n\n\tit(\"returns no warnings for a single non-conflicting service\", () => {\n\t\tconst redis = getServiceById(\"redis\");\n\t\tif (redis) {\n\t\t\tconst warnings = checkCompatibility([redis]);\n\t\t\texpect(warnings.filter((w) => w.type === \"compatibility\" && !w.message.includes(\"GPU\"))).toHaveLength(0);\n\t\t}\n\t});\n});\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-openclaw/core",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "private": false,
5
5
  "description": "Core logic for better-openclaw: schemas, service registry, resolver, composer, validators and more",
6
6
  "packageManager": "pnpm@9.15.4",
@@ -164,6 +164,8 @@ exports[`compose snapshot tests > devops preset (n8n + postgresql + redis + moni
164
164
  N8N_HOST: n8n
165
165
  N8N_PORT: "5678"
166
166
  N8N_WEBHOOK_URL: http://n8n:5678/
167
+ GRAFANA_HOST: grafana
168
+ GRAFANA_PORT: "3000"
167
169
  volumes:
168
170
  - \${OPENCLAW_CONFIG_DIR:-./openclaw/config}:/home/node/.openclaw
169
171
  - \${OPENCLAW_WORKSPACE_DIR:-./openclaw/workspace}:/home/node/.openclaw/workspace
@@ -382,6 +384,8 @@ exports[`compose snapshot tests > full preset (many services) 1`] = `
382
384
  BROWSERLESS_HOST: browserless
383
385
  BROWSERLESS_PORT: "3000"
384
386
  BROWSERLESS_TOKEN: \${BROWSERLESS_TOKEN}
387
+ CADDY_HOST: caddy
388
+ CADDY_HTTP_PORT: "80"
385
389
  FFMPEG_SHARED_DIR: /home/node/.openclaw/workspace/media
386
390
  GOTIFY_HOST: gotify
387
391
  GOTIFY_PORT: "8080"
@@ -410,6 +414,8 @@ exports[`compose snapshot tests > full preset (many services) 1`] = `
410
414
  N8N_HOST: n8n
411
415
  N8N_PORT: "5678"
412
416
  N8N_WEBHOOK_URL: http://n8n:5678/
417
+ GRAFANA_HOST: grafana
418
+ GRAFANA_PORT: "3000"
413
419
  volumes:
414
420
  - \${OPENCLAW_CONFIG_DIR:-./openclaw/config}:/home/node/.openclaw
415
421
  - \${OPENCLAW_WORKSPACE_DIR:-./openclaw/workspace}:/home/node/.openclaw/workspace
@@ -433,7 +439,7 @@ exports[`compose snapshot tests > full preset (many services) 1`] = `
433
439
  browserless:
434
440
  condition: service_healthy
435
441
  caddy:
436
- condition: service_started
442
+ condition: service_healthy
437
443
  meilisearch:
438
444
  condition: service_healthy
439
445
  minio:
@@ -474,6 +480,14 @@ exports[`compose snapshot tests > full preset (many services) 1`] = `
474
480
  volumes:
475
481
  - caddy-data:/data
476
482
  - caddy-config:/config
483
+ healthcheck:
484
+ test:
485
+ - CMD-SHELL
486
+ - wget -q --spider http://localhost:80 || exit 1
487
+ interval: 30s
488
+ timeout: 10s
489
+ retries: 3
490
+ start_period: 5s
477
491
  restart: unless-stopped
478
492
  networks:
479
493
  - openclaw-network
@@ -7,6 +7,7 @@ export function platformToNativePlatform(platform: Platform): NativePlatform {
7
7
  if (platform.startsWith("linux/")) return "linux";
8
8
  if (platform.startsWith("windows/")) return "windows";
9
9
  if (platform.startsWith("macos/")) return "macos";
10
+ console.warn(`Unknown platform prefix in "${platform}", defaulting to linux`);
10
11
  return "linux";
11
12
  }
12
13
 
package/src/composer.ts CHANGED
@@ -100,6 +100,12 @@ function buildGatewayServices(
100
100
  ],
101
101
  };
102
102
 
103
+ // Traefik labels for the gateway
104
+ const gwTraefikLabels = options.traefikLabels?.get("openclaw-gateway");
105
+ if (gwTraefikLabels) {
106
+ gateway.labels = gwTraefikLabels;
107
+ }
108
+
103
109
  if (options.bareMetalNativeHost) {
104
110
  gateway.extra_hosts = ["host.docker.internal:host-gateway"];
105
111
  }
@@ -198,7 +204,22 @@ function buildCompanionService(
198
204
 
199
205
  if (def.command) svc.command = def.command;
200
206
  if (def.entrypoint) svc.entrypoint = def.entrypoint;
201
- if (def.labels && Object.keys(def.labels).length > 0) svc.labels = def.labels;
207
+
208
+ // Labels: merge static definition labels with dynamic Traefik labels
209
+ const mergedLabels: Record<string, string> = {};
210
+ if (def.labels) Object.assign(mergedLabels, def.labels);
211
+ const traefikLabels = options.traefikLabels?.get(def.id);
212
+ if (traefikLabels) Object.assign(mergedLabels, traefikLabels);
213
+ if (Object.keys(mergedLabels).length > 0) svc.labels = mergedLabels;
214
+
215
+ // Traefik: bind-mount static config and Docker socket
216
+ if (def.id === "traefik" && options.traefikLabels) {
217
+ if (!svc.volumes) svc.volumes = [];
218
+ (svc.volumes as string[]).push(
219
+ "./traefik/traefik.yml:/etc/traefik/traefik.yml:ro",
220
+ "/var/run/docker.sock:/var/run/docker.sock:ro",
221
+ );
222
+ }
202
223
 
203
224
  let deploy: Record<string, unknown> | undefined;
204
225
  if (def.deploy) {
package/src/errors.ts ADDED
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Typed error classes for stack generation.
3
+ */
4
+
5
+ /** Thrown when the resolved stack configuration is invalid (e.g. conflicts). */
6
+ export class StackConfigError extends Error {
7
+ readonly code = "STACK_CONFIG_ERROR" as const;
8
+
9
+ constructor(message: string) {
10
+ super(message);
11
+ this.name = "StackConfigError";
12
+ }
13
+ }
14
+
15
+ /** Thrown when post-generation validation fails. */
16
+ export class ValidationError extends Error {
17
+ readonly code = "VALIDATION_ERROR" as const;
18
+
19
+ constructor(message: string) {
20
+ super(message);
21
+ this.name = "ValidationError";
22
+ }
23
+ }
package/src/generate.ts CHANGED
@@ -7,6 +7,7 @@ import { composeMultiFile } from "./composer.js";
7
7
  import { generateBareMetalInstall } from "./generators/bare-metal-install.js";
8
8
  import { generateCaddyfile } from "./generators/caddy.js";
9
9
  import { generateEnvFiles } from "./generators/env.js";
10
+ import { generateTraefikConfig } from "./generators/traefik.js";
10
11
  import { generateGrafanaConfig, generateGrafanaDashboard } from "./generators/grafana.js";
11
12
  import { generateN8nWorkflows } from "./generators/n8n-workflows.js";
12
13
  import { generateNativeInstallScripts } from "./generators/native-services.js";
@@ -15,6 +16,7 @@ import { generatePrometheusConfig } from "./generators/prometheus.js";
15
16
  import { generateReadme } from "./generators/readme.js";
16
17
  import { generateScripts } from "./generators/scripts.js";
17
18
  import { generateSkillFiles } from "./generators/skills.js";
19
+ import { migrateConfig } from "./migrations.js";
18
20
  import { resolve } from "./resolver.js";
19
21
  import type {
20
22
  GeneratedFiles,
@@ -23,6 +25,7 @@ import type {
23
25
  Platform,
24
26
  ResolverInput,
25
27
  } from "./types.js";
28
+ import { StackConfigError, ValidationError } from "./errors.js";
26
29
  import { validate } from "./validator.js";
27
30
 
28
31
  /** Resolver/compose only support linux image platforms; normalize for bare-metal (windows/macos). */
@@ -35,7 +38,10 @@ function getComposePlatform(platform: Platform): "linux/amd64" | "linux/arm64" {
35
38
  * Main orchestration function: takes generation input, resolves dependencies,
36
39
  * generates all files, validates, and returns the complete file tree.
37
40
  */
38
- export function generate(input: GenerationInput): GenerationResult {
41
+ export function generate(rawInput: GenerationInput): GenerationResult {
42
+ // Apply config migrations if needed
43
+ const input = migrateConfig(rawInput as Record<string, unknown>) as GenerationInput;
44
+
39
45
  const composePlatform = getComposePlatform(input.platform);
40
46
 
41
47
  // 1. Resolve dependencies
@@ -50,7 +56,7 @@ export function generate(input: GenerationInput): GenerationResult {
50
56
  const resolved = resolve(resolverInput);
51
57
 
52
58
  if (!resolved.isValid) {
53
- throw new Error(
59
+ throw new StackConfigError(
54
60
  `Invalid stack configuration: ${resolved.errors.map((e) => e.message).join("; ")}`,
55
61
  );
56
62
  }
@@ -72,6 +78,12 @@ export function generate(input: GenerationInput): GenerationResult {
72
78
  }
73
79
 
74
80
  // 2. Generate Docker Compose YAML (multi-file)
81
+ // Compute Traefik labels before composing (labels get injected into docker-compose services)
82
+ let traefikOutput: ReturnType<typeof generateTraefikConfig> | undefined;
83
+ if (input.proxy === "traefik" && input.domain) {
84
+ traefikOutput = generateTraefikConfig(resolvedForCompose, input.domain);
85
+ }
86
+
75
87
  const composeOptions = {
76
88
  projectName: input.projectName,
77
89
  proxy: input.proxy,
@@ -81,6 +93,7 @@ export function generate(input: GenerationInput): GenerationResult {
81
93
  deployment: input.deployment,
82
94
  openclawVersion: input.openclawVersion,
83
95
  bareMetalNativeHost: isBareMetal && nativeIds.size > 0,
96
+ traefikLabels: traefikOutput?.serviceLabels,
84
97
  };
85
98
  const composeResult = composeMultiFile(resolvedForCompose, composeOptions);
86
99
 
@@ -90,7 +103,7 @@ export function generate(input: GenerationInput): GenerationResult {
90
103
  generateSecrets: input.generateSecrets,
91
104
  });
92
105
  if (!validation.valid) {
93
- throw new Error(`Validation failed: ${validation.errors.map((e) => e.message).join("; ")}`);
106
+ throw new ValidationError(`Validation failed: ${validation.errors.map((e) => e.message).join("; ")}`);
94
107
  }
95
108
 
96
109
  // 4. Generate all files
@@ -158,6 +171,11 @@ export function generate(input: GenerationInput): GenerationResult {
158
171
  files["caddy/Caddyfile"] = generateCaddyfile(resolved, input.domain);
159
172
  }
160
173
 
174
+ // Traefik config (labels are already injected via composeOptions.traefikLabels)
175
+ if (traefikOutput) {
176
+ files["traefik/traefik.yml"] = traefikOutput.staticConfig;
177
+ }
178
+
161
179
  // Prometheus config
162
180
  const hasPrometheus = resolved.services.some((s) => s.definition.id === "prometheus");
163
181
  if (hasPrometheus) {
@@ -223,7 +241,7 @@ export function generate(input: GenerationInput): GenerationResult {
223
241
  };
224
242
  }
225
243
 
226
- function generateServicesDoc(resolved: import("./types.js").ResolverOutput): string {
244
+ export function generateServicesDoc(resolved: import("./types.js").ResolverOutput): string {
227
245
  const lines: string[] = [
228
246
  "# Service Reference",
229
247
  "",
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { generateTraefikConfig } from "./traefik.js";
3
+ import type { ResolverOutput } from "../types.js";
4
+ import { resolve } from "../resolver.js";
5
+
6
+ function resolveWith(services: string[]): ResolverOutput {
7
+ return resolve({ services, skillPacks: [], proxy: "traefik", gpu: false });
8
+ }
9
+
10
+ describe("generateTraefikConfig", () => {
11
+ const domain = "example.com";
12
+
13
+ it("generates static config with domain email and entrypoints", () => {
14
+ const resolved = resolveWith(["redis"]);
15
+ const { staticConfig } = generateTraefikConfig(resolved, domain);
16
+
17
+ expect(staticConfig).toContain("admin@example.com");
18
+ expect(staticConfig).toContain('address: ":80"');
19
+ expect(staticConfig).toContain('address: ":443"');
20
+ expect(staticConfig).toContain("exposedByDefault: false");
21
+ expect(staticConfig).toContain("openclaw-network");
22
+ });
23
+
24
+ it("generates labels for services with exposed ports", () => {
25
+ const resolved = resolveWith(["redis"]);
26
+ const { serviceLabels } = generateTraefikConfig(resolved, domain);
27
+
28
+ const redisLabels = serviceLabels.get("redis");
29
+ expect(redisLabels).toBeDefined();
30
+ expect(redisLabels!["traefik.enable"]).toBe("true");
31
+ expect(redisLabels!["traefik.http.routers.redis.rule"]).toBe(
32
+ "Host(`redis.example.com`)",
33
+ );
34
+ expect(redisLabels!["traefik.http.routers.redis.entrypoints"]).toBe("websecure");
35
+ expect(redisLabels!["traefik.http.routers.redis.tls.certresolver"]).toBe("letsencrypt");
36
+ expect(redisLabels!["traefik.http.services.redis.loadbalancer.server.port"]).toBe("6379");
37
+ });
38
+
39
+ it("generates HTTP to HTTPS redirect labels", () => {
40
+ const resolved = resolveWith(["redis"]);
41
+ const { serviceLabels } = generateTraefikConfig(resolved, domain);
42
+
43
+ const redisLabels = serviceLabels.get("redis")!;
44
+ expect(redisLabels["traefik.http.routers.redis-http.entrypoints"]).toBe("web");
45
+ expect(redisLabels["traefik.http.routers.redis-http.middlewares"]).toBe(
46
+ "redirect-to-https",
47
+ );
48
+ });
49
+
50
+ it("assigns root domain to gateway", () => {
51
+ const resolved = resolveWith(["redis"]);
52
+ const { serviceLabels } = generateTraefikConfig(resolved, domain);
53
+
54
+ const gwLabels = serviceLabels.get("openclaw-gateway");
55
+ expect(gwLabels).toBeDefined();
56
+ expect(gwLabels!["traefik.http.routers.gateway.rule"]).toBe(
57
+ "Host(`example.com`)",
58
+ );
59
+ expect(gwLabels!["traefik.http.services.gateway.loadbalancer.server.port"]).toBe("18789");
60
+ });
61
+
62
+ it("adds global redirect middleware on traefik service", () => {
63
+ const resolved = resolveWith(["redis"]);
64
+ const { serviceLabels } = generateTraefikConfig(resolved, domain);
65
+
66
+ const traefikLabels = serviceLabels.get("traefik");
67
+ expect(traefikLabels).toBeDefined();
68
+ expect(
69
+ traefikLabels!["traefik.http.middlewares.redirect-to-https.redirectscheme.scheme"],
70
+ ).toBe("https");
71
+ expect(
72
+ traefikLabels!["traefik.http.middlewares.redirect-to-https.redirectscheme.permanent"],
73
+ ).toBe("true");
74
+ });
75
+
76
+ it("skips proxy services and services without exposed ports", () => {
77
+ const resolved = resolveWith(["redis", "ffmpeg"]);
78
+ const { serviceLabels } = generateTraefikConfig(resolved, domain);
79
+
80
+ // ffmpeg has no exposed ports
81
+ expect(serviceLabels.has("ffmpeg")).toBe(false);
82
+ // traefik itself is handled separately (not as a regular service)
83
+ expect(serviceLabels.get("traefik")!["traefik.http.routers.traefik.rule"]).toBeUndefined();
84
+ });
85
+
86
+ it("sanitizes service names with hyphens in router names", () => {
87
+ const resolved = resolveWith(["open-webui"]);
88
+ const { serviceLabels } = generateTraefikConfig(resolved, domain);
89
+
90
+ const labels = serviceLabels.get("open-webui");
91
+ expect(labels).toBeDefined();
92
+ // Router name has hyphens removed
93
+ expect(labels!["traefik.http.routers.openwebui.rule"]).toBe(
94
+ "Host(`open-webui.example.com`)",
95
+ );
96
+ });
97
+ });
@@ -0,0 +1,104 @@
1
+ import type { ResolverOutput } from "../types.js";
2
+
3
+ export interface TraefikGeneratorOutput {
4
+ staticConfig: string;
5
+ serviceLabels: Map<string, Record<string, string>>;
6
+ }
7
+
8
+ /**
9
+ * Generates Traefik configuration: a static config YAML file and per-service
10
+ * Docker labels for automatic service discovery and reverse proxy routing.
11
+ *
12
+ * @param resolved - The resolved service configuration
13
+ * @param domain - The main domain for routing (e.g. "example.com")
14
+ * @returns Static config content and a map of serviceId → Docker labels
15
+ */
16
+ export function generateTraefikConfig(
17
+ resolved: ResolverOutput,
18
+ domain: string,
19
+ ): TraefikGeneratorOutput {
20
+ const staticConfig = generateStaticConfig(domain);
21
+ const serviceLabels = new Map<string, Record<string, string>>();
22
+
23
+ // Per-service labels for exposed ports
24
+ for (const { definition } of resolved.services) {
25
+ if (definition.id === "traefik" || definition.id === "caddy") continue;
26
+
27
+ const exposedPorts = definition.ports.filter((p) => p.exposed);
28
+ if (exposedPorts.length === 0) continue;
29
+
30
+ const primaryPort = exposedPorts[0]!;
31
+ const routerName = definition.id.replace(/-/g, "");
32
+
33
+ const labels: Record<string, string> = {
34
+ "traefik.enable": "true",
35
+ // HTTPS router
36
+ [`traefik.http.routers.${routerName}.rule`]: `Host(\`${definition.id}.${domain}\`)`,
37
+ [`traefik.http.routers.${routerName}.entrypoints`]: "websecure",
38
+ [`traefik.http.routers.${routerName}.tls.certresolver`]: "letsencrypt",
39
+ [`traefik.http.services.${routerName}.loadbalancer.server.port`]: String(
40
+ primaryPort.container,
41
+ ),
42
+ // HTTP → HTTPS redirect
43
+ [`traefik.http.routers.${routerName}-http.rule`]: `Host(\`${definition.id}.${domain}\`)`,
44
+ [`traefik.http.routers.${routerName}-http.entrypoints`]: "web",
45
+ [`traefik.http.routers.${routerName}-http.middlewares`]: "redirect-to-https",
46
+ };
47
+
48
+ serviceLabels.set(definition.id, labels);
49
+ }
50
+
51
+ // Gateway gets the root domain
52
+ serviceLabels.set("openclaw-gateway", {
53
+ "traefik.enable": "true",
54
+ "traefik.http.routers.gateway.rule": `Host(\`${domain}\`)`,
55
+ "traefik.http.routers.gateway.entrypoints": "websecure",
56
+ "traefik.http.routers.gateway.tls.certresolver": "letsencrypt",
57
+ "traefik.http.services.gateway.loadbalancer.server.port": "18789",
58
+ "traefik.http.routers.gateway-http.rule": `Host(\`${domain}\`)`,
59
+ "traefik.http.routers.gateway-http.entrypoints": "web",
60
+ "traefik.http.routers.gateway-http.middlewares": "redirect-to-https",
61
+ });
62
+
63
+ // Global redirect middleware on the Traefik service itself
64
+ serviceLabels.set("traefik", {
65
+ "traefik.enable": "true",
66
+ "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme": "https",
67
+ "traefik.http.middlewares.redirect-to-https.redirectscheme.permanent": "true",
68
+ });
69
+
70
+ return { staticConfig, serviceLabels };
71
+ }
72
+
73
+ function generateStaticConfig(domain: string): string {
74
+ return `# Traefik Static Configuration — Auto-generated by OpenClaw
75
+ # Domain: ${domain}
76
+
77
+ api:
78
+ dashboard: true
79
+ insecure: true
80
+
81
+ entryPoints:
82
+ web:
83
+ address: ":80"
84
+ websecure:
85
+ address: ":443"
86
+
87
+ providers:
88
+ docker:
89
+ endpoint: "unix:///var/run/docker.sock"
90
+ exposedByDefault: false
91
+ network: openclaw-network
92
+
93
+ certificatesResolvers:
94
+ letsencrypt:
95
+ acme:
96
+ email: admin@${domain}
97
+ storage: /letsencrypt/acme.json
98
+ httpChallenge:
99
+ entryPoint: web
100
+
101
+ log:
102
+ level: INFO
103
+ `;
104
+ }
package/src/index.ts CHANGED
@@ -8,7 +8,7 @@ export {
8
8
  } from "./bare-metal-partition.js";
9
9
  export type { ComposeResult } from "./composer.js";
10
10
  export { compose, composeMultiFile } from "./composer.js";
11
- export { generate } from "./generate.js";
11
+ export { generate, generateServicesDoc } from "./generate.js";
12
12
  export { generateCaddyfile } from "./generators/caddy.js";
13
13
  export type { EnvVarGroup } from "./generators/env.js";
14
14
  export { generateEnvFiles, getStructuredEnvVars } from "./generators/env.js";
@@ -109,6 +109,12 @@ export type {
109
109
  export { SERVICE_CATEGORIES } from "./types.js";
110
110
  export { validate } from "./validator.js";
111
111
 
112
+ // ─── Config Migrations ──────────────────────────────────────────────────────
113
+ export { migrateConfig, needsMigration, CURRENT_CONFIG_VERSION } from "./migrations.js";
114
+
115
+ // ─── Errors ─────────────────────────────────────────────────────────────────
116
+ export { StackConfigError, ValidationError } from "./errors.js";
117
+
112
118
  // ─── Version Manager ────────────────────────────────────────────────────────
113
119
  export {
114
120
  checkCompatibility,
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { migrateConfig, needsMigration, CURRENT_CONFIG_VERSION } from "./migrations.js";
3
+
4
+ describe("config migrations", () => {
5
+ it("migrates v1 config to current version", () => {
6
+ const v1 = { projectName: "test", services: ["redis"], skillPacks: [] };
7
+ const result = migrateConfig(v1);
8
+ expect(result.configVersion).toBe(CURRENT_CONFIG_VERSION);
9
+ expect(result.deploymentType).toBe("docker");
10
+ });
11
+
12
+ it("preserves existing deploymentType during migration", () => {
13
+ const v1 = { configVersion: 1, deploymentType: "bare-metal" };
14
+ const result = migrateConfig(v1);
15
+ expect(result.deploymentType).toBe("bare-metal");
16
+ });
17
+
18
+ it("passes through current version unchanged", () => {
19
+ const current = { configVersion: CURRENT_CONFIG_VERSION, projectName: "test" };
20
+ const result = migrateConfig(current);
21
+ expect(result).toEqual(current);
22
+ });
23
+
24
+ it("needsMigration returns true for old configs", () => {
25
+ expect(needsMigration({ configVersion: 1 })).toBe(true);
26
+ expect(needsMigration({})).toBe(true); // no version = v1
27
+ });
28
+
29
+ it("needsMigration returns false for current configs", () => {
30
+ expect(needsMigration({ configVersion: CURRENT_CONFIG_VERSION })).toBe(false);
31
+ });
32
+
33
+ it("throws for unknown version with no migration path", () => {
34
+ expect(() => migrateConfig({ configVersion: 99 })).toThrow("No migration path");
35
+ });
36
+ });
@@ -0,0 +1,49 @@
1
+ import type { GenerationInput } from "./types.js";
2
+
3
+ export const CURRENT_CONFIG_VERSION = 2;
4
+
5
+ type MigrationFn = (input: Record<string, unknown>) => Record<string, unknown>;
6
+
7
+ const migrations: Record<number, MigrationFn> = {
8
+ // v1 → v2: ensure deploymentType field exists (defaulting to "docker")
9
+ 1: (input) => ({
10
+ ...input,
11
+ configVersion: 2,
12
+ deploymentType: (input.deploymentType as string) ?? "docker",
13
+ }),
14
+ };
15
+
16
+ /**
17
+ * Applies sequential migrations to bring a config from its current version
18
+ * to CURRENT_CONFIG_VERSION. Returns the config unchanged if already current.
19
+ */
20
+ export function migrateConfig(input: Record<string, unknown>): Record<string, unknown> {
21
+ let version = (input.configVersion as number) ?? 1;
22
+
23
+ if (version > CURRENT_CONFIG_VERSION) {
24
+ throw new Error(
25
+ `No migration path from config version ${version}`,
26
+ );
27
+ }
28
+
29
+ let current = { ...input };
30
+
31
+ while (version < CURRENT_CONFIG_VERSION) {
32
+ const migrationFn = migrations[version];
33
+ if (!migrationFn) {
34
+ throw new Error(`No migration path from config version ${version}`);
35
+ }
36
+ current = migrationFn(current);
37
+ version++;
38
+ }
39
+
40
+ return current;
41
+ }
42
+
43
+ /**
44
+ * Returns true if the config needs migration to the current version.
45
+ */
46
+ export function needsMigration(input: Record<string, unknown>): boolean {
47
+ const version = (input.configVersion as number) ?? 1;
48
+ return version < CURRENT_CONFIG_VERSION;
49
+ }