@better-openclaw/core 1.0.8 → 1.0.10
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/bare-metal-partition.test.mjs +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/composer.snapshot.test.mjs +1 -1
- package/dist/composer.test.mjs +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/generate.test.mjs +1 -1
- package/dist/generators/bare-metal-install.test.mjs +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/presets/registry.test.mjs +1 -1
- 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/resolver.test.mjs +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/schema.test.mjs +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/services/registry.test.mjs +1 -1
- package/dist/skills/registry.d.mts.map +1 -1
- package/dist/skills/registry.mjs +454 -6
- package/dist/skills/registry.mjs.map +1 -1
- 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/validator.test.mjs +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/dist/{vi.2VT5v0um-Qk6MgAnK.mjs → vi.2VT5v0um-YSByewHe.mjs} +5 -5
- package/dist/{vi.2VT5v0um-Qk6MgAnK.mjs.map → vi.2VT5v0um-YSByewHe.mjs.map} +1 -1
- 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/skills/registry.ts +352 -6
- 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
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
|
+
}
|
package/src/resolver.ts
CHANGED
|
@@ -10,6 +10,18 @@ import type {
|
|
|
10
10
|
Warning,
|
|
11
11
|
} from "./types.js";
|
|
12
12
|
|
|
13
|
+
export interface MemoryThresholds {
|
|
14
|
+
info: number;
|
|
15
|
+
warning: number;
|
|
16
|
+
critical: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DEFAULT_MEMORY_THRESHOLDS: MemoryThresholds = {
|
|
20
|
+
info: 2048,
|
|
21
|
+
warning: 4096,
|
|
22
|
+
critical: 8192,
|
|
23
|
+
};
|
|
24
|
+
|
|
13
25
|
/**
|
|
14
26
|
* Resolves user selections into a complete, valid service list.
|
|
15
27
|
*
|
|
@@ -128,6 +140,27 @@ export function resolve(input: ResolverInput): ResolverOutput {
|
|
|
128
140
|
}
|
|
129
141
|
}
|
|
130
142
|
|
|
143
|
+
if (iteration >= maxIterations) {
|
|
144
|
+
warnings.push({
|
|
145
|
+
type: "resolution",
|
|
146
|
+
message: `Dependency resolution reached maximum iterations (${maxIterations}). Some transitive dependencies may not be fully resolved.`,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Check recommended services
|
|
151
|
+
for (const id of serviceIds) {
|
|
152
|
+
const def = getServiceById(id);
|
|
153
|
+
if (!def) continue;
|
|
154
|
+
for (const recId of def.recommends) {
|
|
155
|
+
if (!serviceIds.has(recId) && getServiceById(recId)) {
|
|
156
|
+
warnings.push({
|
|
157
|
+
type: "recommendation",
|
|
158
|
+
message: `${def.name} recommends "${recId}" for enhanced functionality`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
131
164
|
// 3. Detect conflicts
|
|
132
165
|
const resolvedDefs: ServiceDefinition[] = [];
|
|
133
166
|
for (const id of serviceIds) {
|
|
@@ -178,17 +211,18 @@ export function resolve(input: ResolverInput): ResolverOutput {
|
|
|
178
211
|
}
|
|
179
212
|
|
|
180
213
|
// Memory warnings
|
|
181
|
-
|
|
214
|
+
const thresholds = input.memoryThresholds ?? DEFAULT_MEMORY_THRESHOLDS;
|
|
215
|
+
if (estimatedMemoryMB > thresholds.critical) {
|
|
182
216
|
warnings.push({
|
|
183
217
|
type: "memory",
|
|
184
218
|
message: `Estimated ${(estimatedMemoryMB / 1024).toFixed(1)}GB RAM required. Ensure your server has sufficient resources.`,
|
|
185
219
|
});
|
|
186
|
-
} else if (estimatedMemoryMB >
|
|
220
|
+
} else if (estimatedMemoryMB > thresholds.warning) {
|
|
187
221
|
warnings.push({
|
|
188
222
|
type: "memory",
|
|
189
223
|
message: `Estimated ${(estimatedMemoryMB / 1024).toFixed(1)}GB RAM required. A server with at least 8GB RAM is recommended.`,
|
|
190
224
|
});
|
|
191
|
-
} else if (estimatedMemoryMB >
|
|
225
|
+
} else if (estimatedMemoryMB > thresholds.info) {
|
|
192
226
|
warnings.push({
|
|
193
227
|
type: "memory",
|
|
194
228
|
message: `Estimated ${(estimatedMemoryMB / 1024).toFixed(1)}GB RAM required.`,
|
package/src/schema.ts
CHANGED
|
@@ -201,6 +201,7 @@ export const PresetSchema = z.object({
|
|
|
201
201
|
// ─── Generation Input ───────────────────────────────────────────────────────
|
|
202
202
|
|
|
203
203
|
export const GenerationInputSchema = z.object({
|
|
204
|
+
configVersion: z.number().int().min(1).optional(),
|
|
204
205
|
projectName: z
|
|
205
206
|
.string()
|
|
206
207
|
.min(1)
|
|
@@ -37,12 +37,34 @@ export const caddyDefinition: ServiceDefinition = {
|
|
|
37
37
|
},
|
|
38
38
|
],
|
|
39
39
|
environment: [],
|
|
40
|
+
healthcheck: {
|
|
41
|
+
test: "wget -q --spider http://localhost:80 || exit 1",
|
|
42
|
+
interval: "30s",
|
|
43
|
+
timeout: "10s",
|
|
44
|
+
retries: 3,
|
|
45
|
+
startPeriod: "5s",
|
|
46
|
+
},
|
|
40
47
|
dependsOn: [],
|
|
41
48
|
restartPolicy: "unless-stopped",
|
|
42
49
|
networks: ["openclaw-network"],
|
|
43
50
|
|
|
44
51
|
skills: [],
|
|
45
|
-
openclawEnvVars: [
|
|
52
|
+
openclawEnvVars: [
|
|
53
|
+
{
|
|
54
|
+
key: "CADDY_HOST",
|
|
55
|
+
defaultValue: "caddy",
|
|
56
|
+
secret: false,
|
|
57
|
+
description: "Caddy reverse proxy hostname",
|
|
58
|
+
required: false,
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: "CADDY_HTTP_PORT",
|
|
62
|
+
defaultValue: "80",
|
|
63
|
+
secret: false,
|
|
64
|
+
description: "Caddy HTTP port",
|
|
65
|
+
required: false,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
46
68
|
|
|
47
69
|
docsUrl: "https://caddyserver.com/docs/",
|
|
48
70
|
tags: ["reverse-proxy", "auto-https", "ssl"],
|