@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.
- package/dist/bare-metal-partition.d.mts.map +1 -1
- package/dist/bare-metal-partition.mjs +1 -0
- package/dist/bare-metal-partition.mjs.map +1 -1
- package/dist/composer.d.mts.map +1 -1
- package/dist/composer.mjs +11 -1
- package/dist/composer.mjs.map +1 -1
- package/dist/errors.d.mts +17 -0
- package/dist/errors.d.mts.map +1 -0
- package/dist/errors.mjs +24 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/generate.d.mts +4 -3
- package/dist/generate.d.mts.map +1 -1
- package/dist/generate.mjs +13 -5
- package/dist/generate.mjs.map +1 -1
- package/dist/generators/traefik.d.mts +19 -0
- package/dist/generators/traefik.d.mts.map +1 -0
- package/dist/generators/traefik.mjs +86 -0
- package/dist/generators/traefik.mjs.map +1 -0
- package/dist/generators/traefik.test.d.mts +1 -0
- package/dist/generators/traefik.test.mjs +69 -0
- package/dist/generators/traefik.test.mjs.map +1 -0
- package/dist/index.d.mts +4 -2
- package/dist/index.mjs +4 -2
- package/dist/migrations.d.mts +14 -0
- package/dist/migrations.d.mts.map +1 -0
- package/dist/migrations.mjs +33 -0
- package/dist/migrations.mjs.map +1 -0
- package/dist/migrations.test.d.mts +1 -0
- package/dist/migrations.test.mjs +42 -0
- package/dist/migrations.test.mjs.map +1 -0
- package/dist/resolver.d.mts +6 -1
- package/dist/resolver.d.mts.map +1 -1
- package/dist/resolver.mjs +21 -3
- package/dist/resolver.mjs.map +1 -1
- package/dist/schema.d.mts +1 -0
- package/dist/schema.d.mts.map +1 -1
- package/dist/schema.mjs +1 -0
- package/dist/schema.mjs.map +1 -1
- package/dist/services/definitions/caddy.mjs +20 -1
- package/dist/services/definitions/caddy.mjs.map +1 -1
- package/dist/services/definitions/cal-com.d.mts +7 -0
- package/dist/services/definitions/cal-com.d.mts.map +1 -0
- package/dist/services/definitions/cal-com.mjs +88 -0
- package/dist/services/definitions/cal-com.mjs.map +1 -0
- package/dist/services/definitions/grafana.mjs +13 -1
- package/dist/services/definitions/grafana.mjs.map +1 -1
- package/dist/services/definitions/index.d.mts +4 -1
- package/dist/services/definitions/index.d.mts.map +1 -1
- package/dist/services/definitions/index.mjs +8 -2
- package/dist/services/definitions/index.mjs.map +1 -1
- package/dist/services/definitions/neo4j.d.mts +7 -0
- package/dist/services/definitions/neo4j.d.mts.map +1 -0
- package/dist/services/definitions/neo4j.mjs +91 -0
- package/dist/services/definitions/neo4j.mjs.map +1 -0
- package/dist/services/definitions/traefik.mjs +0 -1
- package/dist/services/definitions/traefik.mjs.map +1 -1
- package/dist/services/definitions/xyops.d.mts +7 -0
- package/dist/services/definitions/xyops.d.mts.map +1 -0
- package/dist/services/definitions/xyops.mjs +86 -0
- package/dist/services/definitions/xyops.mjs.map +1 -0
- package/dist/types.d.mts +8 -1
- package/dist/types.d.mts.map +1 -1
- package/dist/types.mjs.map +1 -1
- package/dist/validator.mjs +11 -0
- package/dist/validator.mjs.map +1 -1
- package/dist/version-manager.d.mts +1 -1
- package/dist/version-manager.d.mts.map +1 -1
- package/dist/version-manager.mjs +11 -5
- package/dist/version-manager.mjs.map +1 -1
- package/dist/version-manager.test.d.mts +1 -0
- package/dist/version-manager.test.mjs +102 -0
- package/dist/version-manager.test.mjs.map +1 -0
- package/package.json +1 -1
- package/src/__snapshots__/composer.snapshot.test.ts.snap +15 -1
- package/src/bare-metal-partition.ts +1 -0
- package/src/composer.ts +22 -1
- package/src/errors.ts +23 -0
- package/src/generate.ts +22 -4
- package/src/generators/traefik.test.ts +97 -0
- package/src/generators/traefik.ts +104 -0
- package/src/index.ts +7 -1
- package/src/migrations.test.ts +36 -0
- package/src/migrations.ts +49 -0
- package/src/resolver.ts +37 -3
- package/src/schema.ts +1 -0
- package/src/services/definitions/caddy.ts +23 -1
- package/src/services/definitions/cal-com.ts +91 -0
- package/src/services/definitions/grafana.ts +16 -1
- package/src/services/definitions/index.ts +9 -0
- package/src/services/definitions/neo4j.ts +96 -0
- package/src/services/definitions/traefik.ts +0 -2
- package/src/services/definitions/xyops.ts +94 -0
- package/src/types.ts +5 -1
- package/src/validator.ts +16 -0
- package/src/version-manager.test.ts +134 -0
- 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
|
@@ -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:
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
+
}
|