@openpalm/lib 0.9.6 → 0.9.7
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/package.json +1 -1
- package/src/control-plane/channels.ts +3 -0
- package/src/control-plane/connection-mapping.ts +2 -2
- package/src/control-plane/core-asset-provider.ts +1 -0
- package/src/control-plane/core-assets.ts +28 -0
- package/src/control-plane/docker.ts +2 -1
- package/src/control-plane/env.test.ts +109 -0
- package/src/control-plane/env.ts +2 -2
- package/src/control-plane/fs-asset-provider.ts +4 -0
- package/src/control-plane/install-edge-cases.test.ts +1214 -0
- package/src/control-plane/lifecycle.ts +11 -2
- package/src/control-plane/model-runner.ts +27 -2
- package/src/control-plane/setup-status.ts +1 -1
- package/src/control-plane/setup.test.ts +720 -1
- package/src/control-plane/setup.ts +597 -115
- package/src/control-plane/stack-spec.ts +64 -0
- package/src/control-plane/staging.ts +29 -6
- package/src/control-plane/types.ts +2 -3
- package/src/index.ts +30 -0
- package/src/provider-constants.ts +13 -2
|
@@ -2,14 +2,21 @@ import { describe, expect, it, beforeEach, afterEach } from "bun:test";
|
|
|
2
2
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { parse as yamlParse } from "yaml";
|
|
5
6
|
import {
|
|
6
7
|
validateSetupInput,
|
|
7
8
|
buildSecretsFromSetup,
|
|
8
9
|
buildConnectionEnvVarMap,
|
|
9
10
|
performSetup,
|
|
11
|
+
validateSetupConfig,
|
|
12
|
+
normalizeToSetupInput,
|
|
13
|
+
buildChannelCredentialEnvVars,
|
|
14
|
+
performSetupFromConfig,
|
|
15
|
+
CHANNEL_CREDENTIAL_ENV_MAP,
|
|
10
16
|
} from "./setup.js";
|
|
11
|
-
import type { SetupInput, SetupConnection } from "./setup.js";
|
|
17
|
+
import type { SetupInput, SetupConnection, SetupConfig } from "./setup.js";
|
|
12
18
|
import type { CoreAssetProvider } from "./core-asset-provider.js";
|
|
19
|
+
import { STACK_SPEC_FILENAME } from "./stack-spec.js";
|
|
13
20
|
|
|
14
21
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
15
22
|
|
|
@@ -44,6 +51,7 @@ function createStubAssetProvider(): CoreAssetProvider {
|
|
|
44
51
|
caddyfile: () =>
|
|
45
52
|
":80 {\n @denied not remote_ip 127.0.0.0/8 ::1\n respond @denied 403\n}\n",
|
|
46
53
|
ollamaCompose: () => "services:\n ollama:\n image: ollama/ollama\n",
|
|
54
|
+
adminCompose: () => "services:\n admin:\n image: openpalm/admin\n",
|
|
47
55
|
agentsMd: () => "# Agents\n",
|
|
48
56
|
opencodeConfig: () => '{"$schema":"https://opencode.ai/config.json"}\n',
|
|
49
57
|
adminOpencodeConfig: () => '{"$schema":"https://opencode.ai/config.json","plugin":["@openpalm/admin-tools"]}\n',
|
|
@@ -169,6 +177,26 @@ describe("validateSetupInput", () => {
|
|
|
169
177
|
const result = validateSetupInput(input);
|
|
170
178
|
expect(result.valid).toBe(true);
|
|
171
179
|
});
|
|
180
|
+
|
|
181
|
+
it("rejects memoryUserId with dots", () => {
|
|
182
|
+
const input = makeValidInput({ memoryUserId: "user.name" });
|
|
183
|
+
const result = validateSetupInput(input);
|
|
184
|
+
expect(result.valid).toBe(false);
|
|
185
|
+
expect(result.errors.some((e) => e.includes("alphanumeric and underscores only"))).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("rejects memoryUserId with hyphens", () => {
|
|
189
|
+
const input = makeValidInput({ memoryUserId: "user-name" });
|
|
190
|
+
const result = validateSetupInput(input);
|
|
191
|
+
expect(result.valid).toBe(false);
|
|
192
|
+
expect(result.errors.some((e) => e.includes("alphanumeric and underscores only"))).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("accepts memoryUserId with underscores", () => {
|
|
196
|
+
const input = makeValidInput({ memoryUserId: "user_name_123" });
|
|
197
|
+
const result = validateSetupInput(input);
|
|
198
|
+
expect(result.valid).toBe(true);
|
|
199
|
+
});
|
|
172
200
|
});
|
|
173
201
|
|
|
174
202
|
// ── Tests: buildSecretsFromSetup ─────────────────────────────────────────
|
|
@@ -473,4 +501,695 @@ describe("performSetup", () => {
|
|
|
473
501
|
const memConfig = JSON.parse(readFileSync(memConfigPath, "utf-8"));
|
|
474
502
|
expect(memConfig.mem0.vector_store.config.embedding_model_dims).toBe(768);
|
|
475
503
|
});
|
|
504
|
+
|
|
505
|
+
it("writes openpalm.yaml with correct structure", async () => {
|
|
506
|
+
const result = await performSetup(makeValidInput(), createStubAssetProvider());
|
|
507
|
+
expect(result.ok).toBe(true);
|
|
508
|
+
|
|
509
|
+
const specPath = join(configDir, STACK_SPEC_FILENAME);
|
|
510
|
+
expect(existsSync(specPath)).toBe(true);
|
|
511
|
+
|
|
512
|
+
const spec = yamlParse(readFileSync(specPath, "utf-8"));
|
|
513
|
+
expect(spec.version).toBe(3);
|
|
514
|
+
expect(spec.connections).toBeArrayOfSize(1);
|
|
515
|
+
expect(spec.connections[0].id).toBe("openai-main");
|
|
516
|
+
expect(spec.connections[0].provider).toBe("openai");
|
|
517
|
+
expect(spec.assignments.llm.model).toBe("gpt-4o");
|
|
518
|
+
expect(spec.assignments.embeddings.model).toBe("text-embedding-3-small");
|
|
519
|
+
expect(spec.ollamaEnabled).toBe(false);
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it("does not corrupt profile when duplicate connection ID with hyphen is skipped by env var map", async () => {
|
|
523
|
+
// When two connections share a provider and the second has a hyphen in the ID,
|
|
524
|
+
// buildConnectionEnvVarMap skips it (OPENAI_API_KEY_OPENAI-2 fails SAFE_ENV_KEY_RE).
|
|
525
|
+
// The profile for that connection should have hasApiKey=false and apiKeyEnvVar=""
|
|
526
|
+
// rather than passing undefined through via a non-null assertion.
|
|
527
|
+
const input = makeValidInput({
|
|
528
|
+
connections: [
|
|
529
|
+
{ id: "openai_primary", name: "OpenAI Primary", provider: "openai", baseUrl: "https://api.openai.com", apiKey: "sk-primary" },
|
|
530
|
+
{ id: "openai-secondary", name: "OpenAI Secondary", provider: "openai", baseUrl: "https://api.openai.com", apiKey: "sk-secondary" },
|
|
531
|
+
],
|
|
532
|
+
assignments: {
|
|
533
|
+
llm: { connectionId: "openai_primary", model: "gpt-4o" },
|
|
534
|
+
embeddings: { connectionId: "openai_primary", model: "text-embedding-3-small" },
|
|
535
|
+
},
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
const result = await performSetup(input, createStubAssetProvider());
|
|
539
|
+
expect(result.ok).toBe(true);
|
|
540
|
+
|
|
541
|
+
const profilesPath = join(configDir, "connections", "profiles.json");
|
|
542
|
+
const doc = JSON.parse(readFileSync(profilesPath, "utf-8"));
|
|
543
|
+
expect(doc.profiles).toHaveLength(2);
|
|
544
|
+
|
|
545
|
+
// The second connection's env var was skipped — hasApiKey must be false, apiKeyEnvVar must be ""
|
|
546
|
+
const secondary = doc.profiles.find((p: { id: string }) => p.id === "openai-secondary");
|
|
547
|
+
expect(secondary).toBeDefined();
|
|
548
|
+
expect(secondary.auth.mode).toBe("none");
|
|
549
|
+
});
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
// ── Helpers: SetupConfig ─────────────────────────────────────────────────
|
|
553
|
+
|
|
554
|
+
function makeValidConfig(overrides?: Partial<SetupConfig>): SetupConfig {
|
|
555
|
+
return {
|
|
556
|
+
version: 1,
|
|
557
|
+
owner: { name: "Test User", email: "test@example.com" },
|
|
558
|
+
security: { adminToken: "test-admin-token-12345" },
|
|
559
|
+
connections: [
|
|
560
|
+
{
|
|
561
|
+
id: "openai-main",
|
|
562
|
+
name: "OpenAI",
|
|
563
|
+
provider: "openai",
|
|
564
|
+
baseUrl: "https://api.openai.com",
|
|
565
|
+
apiKey: "sk-test-key-123",
|
|
566
|
+
},
|
|
567
|
+
],
|
|
568
|
+
assignments: {
|
|
569
|
+
llm: { connectionId: "openai-main", model: "gpt-4o" },
|
|
570
|
+
embeddings: { connectionId: "openai-main", model: "text-embedding-3-small" },
|
|
571
|
+
},
|
|
572
|
+
memory: { userId: "test_user" },
|
|
573
|
+
...overrides,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// ── Tests: validateSetupConfig ───────────────────────────────────────────
|
|
578
|
+
|
|
579
|
+
describe("validateSetupConfig", () => {
|
|
580
|
+
it("accepts a valid config", () => {
|
|
581
|
+
const result = validateSetupConfig(makeValidConfig());
|
|
582
|
+
expect(result.valid).toBe(true);
|
|
583
|
+
expect(result.errors).toHaveLength(0);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
it("rejects null input", () => {
|
|
587
|
+
const result = validateSetupConfig(null);
|
|
588
|
+
expect(result.valid).toBe(false);
|
|
589
|
+
expect(result.errors).toContain("Config must be a non-null object");
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it("rejects wrong version", () => {
|
|
593
|
+
const config = { ...makeValidConfig(), version: 2 };
|
|
594
|
+
const result = validateSetupConfig(config);
|
|
595
|
+
expect(result.valid).toBe(false);
|
|
596
|
+
expect(result.errors.some((e) => e.includes("version must be 1"))).toBe(true);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it("rejects missing security object", () => {
|
|
600
|
+
const config = makeValidConfig();
|
|
601
|
+
(config as Record<string, unknown>).security = null;
|
|
602
|
+
const result = validateSetupConfig(config);
|
|
603
|
+
expect(result.valid).toBe(false);
|
|
604
|
+
expect(result.errors.some((e) => e.includes("security object is required"))).toBe(true);
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
it("rejects missing security.adminToken", () => {
|
|
608
|
+
const config = makeValidConfig();
|
|
609
|
+
config.security.adminToken = "";
|
|
610
|
+
const result = validateSetupConfig(config);
|
|
611
|
+
expect(result.valid).toBe(false);
|
|
612
|
+
expect(result.errors.some((e) => e.includes("security.adminToken"))).toBe(true);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it("rejects short security.adminToken", () => {
|
|
616
|
+
const config = makeValidConfig();
|
|
617
|
+
config.security.adminToken = "short";
|
|
618
|
+
const result = validateSetupConfig(config);
|
|
619
|
+
expect(result.valid).toBe(false);
|
|
620
|
+
expect(result.errors.some((e) => e.includes("at least 8"))).toBe(true);
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
it("rejects empty connections array", () => {
|
|
624
|
+
const config = makeValidConfig({ connections: [] });
|
|
625
|
+
const result = validateSetupConfig(config);
|
|
626
|
+
expect(result.valid).toBe(false);
|
|
627
|
+
expect(result.errors.some((e) => e.includes("connections"))).toBe(true);
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
it("rejects duplicate connection IDs", () => {
|
|
631
|
+
const conn: SetupConnection = {
|
|
632
|
+
id: "dup",
|
|
633
|
+
name: "Dup",
|
|
634
|
+
provider: "openai",
|
|
635
|
+
baseUrl: "",
|
|
636
|
+
apiKey: "",
|
|
637
|
+
};
|
|
638
|
+
const config = makeValidConfig({ connections: [conn, conn] });
|
|
639
|
+
const result = validateSetupConfig(config);
|
|
640
|
+
expect(result.valid).toBe(false);
|
|
641
|
+
expect(result.errors.some((e) => e.includes("Duplicate"))).toBe(true);
|
|
642
|
+
});
|
|
643
|
+
|
|
644
|
+
it("rejects unsupported provider", () => {
|
|
645
|
+
const config = makeValidConfig({
|
|
646
|
+
connections: [
|
|
647
|
+
{ id: "bad", name: "Bad", provider: "unsupported-provider", baseUrl: "", apiKey: "" },
|
|
648
|
+
],
|
|
649
|
+
});
|
|
650
|
+
const result = validateSetupConfig(config);
|
|
651
|
+
expect(result.valid).toBe(false);
|
|
652
|
+
expect(result.errors.some((e) => e.includes("outside wizard scope"))).toBe(true);
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it("rejects missing assignments.llm", () => {
|
|
656
|
+
const config = makeValidConfig();
|
|
657
|
+
(config.assignments as Record<string, unknown>).llm = null;
|
|
658
|
+
const result = validateSetupConfig(config);
|
|
659
|
+
expect(result.valid).toBe(false);
|
|
660
|
+
expect(result.errors.some((e) => e.includes("assignments.llm"))).toBe(true);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
it("rejects missing assignments.embeddings", () => {
|
|
664
|
+
const config = makeValidConfig();
|
|
665
|
+
(config.assignments as Record<string, unknown>).embeddings = null;
|
|
666
|
+
const result = validateSetupConfig(config);
|
|
667
|
+
expect(result.valid).toBe(false);
|
|
668
|
+
expect(result.errors.some((e) => e.includes("assignments.embeddings"))).toBe(true);
|
|
669
|
+
});
|
|
670
|
+
|
|
671
|
+
it("rejects assignment referencing non-existent connection", () => {
|
|
672
|
+
const config = makeValidConfig();
|
|
673
|
+
config.assignments.llm.connectionId = "does-not-exist";
|
|
674
|
+
const result = validateSetupConfig(config);
|
|
675
|
+
expect(result.valid).toBe(false);
|
|
676
|
+
expect(result.errors.some((e) => e.includes("does not match any connection"))).toBe(true);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
it("requires discord botToken when discord channel is an enabled object", () => {
|
|
680
|
+
const config = makeValidConfig({
|
|
681
|
+
channels: {
|
|
682
|
+
discord: { applicationId: "123456" },
|
|
683
|
+
},
|
|
684
|
+
});
|
|
685
|
+
const result = validateSetupConfig(config);
|
|
686
|
+
expect(result.valid).toBe(false);
|
|
687
|
+
expect(result.errors.some((e) => e.includes("discord.botToken"))).toBe(true);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
it("does not require discord botToken when enabled is false", () => {
|
|
691
|
+
const config = makeValidConfig({
|
|
692
|
+
channels: {
|
|
693
|
+
discord: { enabled: false },
|
|
694
|
+
},
|
|
695
|
+
});
|
|
696
|
+
const result = validateSetupConfig(config);
|
|
697
|
+
expect(result.valid).toBe(true);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("requires slack slackBotToken and slackAppToken when slack is an enabled object", () => {
|
|
701
|
+
const config = makeValidConfig({
|
|
702
|
+
channels: {
|
|
703
|
+
slack: { allowedChannels: "#general" },
|
|
704
|
+
},
|
|
705
|
+
});
|
|
706
|
+
const result = validateSetupConfig(config);
|
|
707
|
+
expect(result.valid).toBe(false);
|
|
708
|
+
expect(result.errors.some((e) => e.includes("slack.slackBotToken"))).toBe(true);
|
|
709
|
+
expect(result.errors.some((e) => e.includes("slack.slackAppToken"))).toBe(true);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
it("accepts slack with required tokens", () => {
|
|
713
|
+
const config = makeValidConfig({
|
|
714
|
+
channels: {
|
|
715
|
+
slack: { slackBotToken: "xoxb-test", slackAppToken: "xapp-test" },
|
|
716
|
+
},
|
|
717
|
+
});
|
|
718
|
+
const result = validateSetupConfig(config);
|
|
719
|
+
expect(result.valid).toBe(true);
|
|
720
|
+
});
|
|
721
|
+
|
|
722
|
+
it("accepts channels as boolean values", () => {
|
|
723
|
+
const config = makeValidConfig({
|
|
724
|
+
channels: { chat: true, api: false },
|
|
725
|
+
});
|
|
726
|
+
const result = validateSetupConfig(config);
|
|
727
|
+
expect(result.valid).toBe(true);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it("rejects invalid channel value type", () => {
|
|
731
|
+
const config = makeValidConfig({
|
|
732
|
+
channels: { chat: "yes" as unknown as boolean },
|
|
733
|
+
});
|
|
734
|
+
const result = validateSetupConfig(config);
|
|
735
|
+
expect(result.valid).toBe(false);
|
|
736
|
+
expect(result.errors.some((e) => e.includes("channels.chat"))).toBe(true);
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
it("accepts valid owner fields", () => {
|
|
740
|
+
const config = makeValidConfig({ owner: { name: "Alice", email: "alice@test.com" } });
|
|
741
|
+
const result = validateSetupConfig(config);
|
|
742
|
+
expect(result.valid).toBe(true);
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("rejects non-string owner.name", () => {
|
|
746
|
+
const config = makeValidConfig();
|
|
747
|
+
(config.owner as Record<string, unknown>).name = 42;
|
|
748
|
+
const result = validateSetupConfig(config);
|
|
749
|
+
expect(result.valid).toBe(false);
|
|
750
|
+
expect(result.errors.some((e) => e.includes("owner.name"))).toBe(true);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
it("accepts valid memory section", () => {
|
|
754
|
+
const config = makeValidConfig({ memory: { userId: "my_user" } });
|
|
755
|
+
const result = validateSetupConfig(config);
|
|
756
|
+
expect(result.valid).toBe(true);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it("rejects non-string memory.userId", () => {
|
|
760
|
+
const config = makeValidConfig();
|
|
761
|
+
(config.memory as Record<string, unknown>).userId = 123;
|
|
762
|
+
const result = validateSetupConfig(config);
|
|
763
|
+
expect(result.valid).toBe(false);
|
|
764
|
+
expect(result.errors.some((e) => e.includes("memory.userId"))).toBe(true);
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
it("rejects memory.userId with dots", () => {
|
|
768
|
+
const config = makeValidConfig({ memory: { userId: "user.name" } });
|
|
769
|
+
const result = validateSetupConfig(config);
|
|
770
|
+
expect(result.valid).toBe(false);
|
|
771
|
+
expect(result.errors.some((e) => e.includes("alphanumeric and underscores only"))).toBe(true);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it("rejects memory.userId with hyphens", () => {
|
|
775
|
+
const config = makeValidConfig({ memory: { userId: "user-name" } });
|
|
776
|
+
const result = validateSetupConfig(config);
|
|
777
|
+
expect(result.valid).toBe(false);
|
|
778
|
+
expect(result.errors.some((e) => e.includes("alphanumeric and underscores only"))).toBe(true);
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
it("rejects non-integer embeddingDims", () => {
|
|
782
|
+
const config = makeValidConfig();
|
|
783
|
+
config.assignments.embeddings.embeddingDims = 1.5;
|
|
784
|
+
const result = validateSetupConfig(config);
|
|
785
|
+
expect(result.valid).toBe(false);
|
|
786
|
+
expect(result.errors.some((e) => e.includes("embeddingDims"))).toBe(true);
|
|
787
|
+
});
|
|
788
|
+
|
|
789
|
+
it("rejects non-boolean/object service value", () => {
|
|
790
|
+
const config = makeValidConfig({ services: { admin: "yes" } as unknown as Record<string, boolean> });
|
|
791
|
+
const result = validateSetupConfig(config);
|
|
792
|
+
expect(result.valid).toBe(false);
|
|
793
|
+
expect(result.errors).toContainEqual(expect.stringContaining("services.admin"));
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it("rejects service object without enabled boolean", () => {
|
|
797
|
+
const config = makeValidConfig({ services: { admin: { enabled: "yes" } } as unknown as Record<string, boolean> });
|
|
798
|
+
const result = validateSetupConfig(config);
|
|
799
|
+
expect(result.valid).toBe(false);
|
|
800
|
+
expect(result.errors).toContainEqual(expect.stringContaining("services.admin.enabled"));
|
|
801
|
+
});
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
// ── Tests: normalizeToSetupInput ─────────────────────────────────────────
|
|
805
|
+
|
|
806
|
+
describe("normalizeToSetupInput", () => {
|
|
807
|
+
it("maps all fields correctly for a full config", () => {
|
|
808
|
+
const config = makeValidConfig();
|
|
809
|
+
const input = normalizeToSetupInput(config);
|
|
810
|
+
|
|
811
|
+
expect(input.adminToken).toBe("test-admin-token-12345");
|
|
812
|
+
expect(input.ownerName).toBe("Test User");
|
|
813
|
+
expect(input.ownerEmail).toBe("test@example.com");
|
|
814
|
+
expect(input.memoryUserId).toBe("test_user");
|
|
815
|
+
expect(input.ollamaEnabled).toBe(false);
|
|
816
|
+
expect(input.connections).toHaveLength(1);
|
|
817
|
+
expect(input.connections[0].id).toBe("openai-main");
|
|
818
|
+
expect(input.assignments.llm.model).toBe("gpt-4o");
|
|
819
|
+
expect(input.assignments.embeddings.model).toBe("text-embedding-3-small");
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
it("defaults memoryUserId when not provided", () => {
|
|
823
|
+
const config = makeValidConfig({ memory: undefined });
|
|
824
|
+
const input = normalizeToSetupInput(config);
|
|
825
|
+
expect(input.memoryUserId).toBe("default_user");
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
it("maps tts string to voice.tts", () => {
|
|
829
|
+
const config = makeValidConfig();
|
|
830
|
+
config.assignments.tts = "kokoro";
|
|
831
|
+
const input = normalizeToSetupInput(config);
|
|
832
|
+
expect(input.voice?.tts).toBe("kokoro");
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
it("maps tts object to voice.tts using engine field", () => {
|
|
836
|
+
const config = makeValidConfig();
|
|
837
|
+
config.assignments.tts = { engine: "openai-tts", model: "tts-1" };
|
|
838
|
+
const input = normalizeToSetupInput(config);
|
|
839
|
+
expect(input.voice?.tts).toBe("openai-tts");
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
it("maps stt string to voice.stt", () => {
|
|
843
|
+
const config = makeValidConfig();
|
|
844
|
+
config.assignments.stt = "whisper-local";
|
|
845
|
+
const input = normalizeToSetupInput(config);
|
|
846
|
+
expect(input.voice?.stt).toBe("whisper-local");
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
it("maps stt object to voice.stt using engine field", () => {
|
|
850
|
+
const config = makeValidConfig();
|
|
851
|
+
config.assignments.stt = { engine: "openai-stt", model: "whisper-1" };
|
|
852
|
+
const input = normalizeToSetupInput(config);
|
|
853
|
+
expect(input.voice?.stt).toBe("openai-stt");
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
it("omits voice when neither tts nor stt are set", () => {
|
|
857
|
+
const config = makeValidConfig();
|
|
858
|
+
const input = normalizeToSetupInput(config);
|
|
859
|
+
expect(input.voice).toBeUndefined();
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
it("handles null tts/stt values", () => {
|
|
863
|
+
const config = makeValidConfig();
|
|
864
|
+
config.assignments.tts = null;
|
|
865
|
+
config.assignments.stt = null;
|
|
866
|
+
const input = normalizeToSetupInput(config);
|
|
867
|
+
expect(input.voice).toBeUndefined();
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
it("extracts enabled channels from boolean values", () => {
|
|
871
|
+
const config = makeValidConfig({
|
|
872
|
+
channels: { chat: true, api: true, discord: false },
|
|
873
|
+
});
|
|
874
|
+
const input = normalizeToSetupInput(config);
|
|
875
|
+
expect(input.channels).toContain("chat");
|
|
876
|
+
expect(input.channels).toContain("api");
|
|
877
|
+
expect(input.channels).not.toContain("discord");
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it("extracts enabled channels from object values", () => {
|
|
881
|
+
const config = makeValidConfig({
|
|
882
|
+
channels: {
|
|
883
|
+
discord: { botToken: "bot-token-123", enabled: true },
|
|
884
|
+
slack: { slackBotToken: "xoxb-test", slackAppToken: "xapp-test", enabled: false },
|
|
885
|
+
},
|
|
886
|
+
});
|
|
887
|
+
const input = normalizeToSetupInput(config);
|
|
888
|
+
expect(input.channels).toContain("discord");
|
|
889
|
+
expect(input.channels).not.toContain("slack");
|
|
890
|
+
});
|
|
891
|
+
|
|
892
|
+
it("defaults channel enabled to true when object has no enabled field", () => {
|
|
893
|
+
const config = makeValidConfig({
|
|
894
|
+
channels: {
|
|
895
|
+
discord: { botToken: "bot-token-123" },
|
|
896
|
+
},
|
|
897
|
+
});
|
|
898
|
+
const input = normalizeToSetupInput(config);
|
|
899
|
+
expect(input.channels).toContain("discord");
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
it("extracts services from boolean and object values", () => {
|
|
903
|
+
const config = makeValidConfig({
|
|
904
|
+
services: {
|
|
905
|
+
admin: true,
|
|
906
|
+
ollama: false,
|
|
907
|
+
openviking: { enabled: true },
|
|
908
|
+
},
|
|
909
|
+
});
|
|
910
|
+
const input = normalizeToSetupInput(config);
|
|
911
|
+
expect(input.services?.admin).toBe(true);
|
|
912
|
+
expect(input.services?.ollama).toBe(false);
|
|
913
|
+
expect(input.ollamaEnabled).toBe(false);
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
it("sets ollamaEnabled from services.ollama", () => {
|
|
917
|
+
const config = makeValidConfig({
|
|
918
|
+
services: { ollama: true },
|
|
919
|
+
});
|
|
920
|
+
const input = normalizeToSetupInput(config);
|
|
921
|
+
expect(input.ollamaEnabled).toBe(true);
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
it("omits channels when all channels are disabled", () => {
|
|
925
|
+
const config = makeValidConfig({
|
|
926
|
+
channels: { chat: false, api: false, discord: { enabled: false } },
|
|
927
|
+
});
|
|
928
|
+
const input = normalizeToSetupInput(config);
|
|
929
|
+
expect(input.channels).toBeUndefined();
|
|
930
|
+
});
|
|
931
|
+
|
|
932
|
+
it("omits channels when none are configured", () => {
|
|
933
|
+
const config = makeValidConfig({ channels: undefined });
|
|
934
|
+
const input = normalizeToSetupInput(config);
|
|
935
|
+
expect(input.channels).toBeUndefined();
|
|
936
|
+
});
|
|
937
|
+
|
|
938
|
+
it("omits services when none are configured", () => {
|
|
939
|
+
const config = makeValidConfig({ services: undefined });
|
|
940
|
+
const input = normalizeToSetupInput(config);
|
|
941
|
+
expect(input.services).toBeUndefined();
|
|
942
|
+
});
|
|
943
|
+
});
|
|
944
|
+
|
|
945
|
+
// ── Tests: buildChannelCredentialEnvVars ──────────────────────────────────
|
|
946
|
+
|
|
947
|
+
describe("buildChannelCredentialEnvVars", () => {
|
|
948
|
+
it("maps discord credentials to env vars", () => {
|
|
949
|
+
const envVars = buildChannelCredentialEnvVars({
|
|
950
|
+
discord: {
|
|
951
|
+
botToken: "bot-token-123",
|
|
952
|
+
applicationId: "app-id-456",
|
|
953
|
+
allowedGuilds: "guild1,guild2",
|
|
954
|
+
},
|
|
955
|
+
});
|
|
956
|
+
expect(envVars.DISCORD_BOT_TOKEN).toBe("bot-token-123");
|
|
957
|
+
expect(envVars.DISCORD_APPLICATION_ID).toBe("app-id-456");
|
|
958
|
+
expect(envVars.DISCORD_ALLOWED_GUILDS).toBe("guild1,guild2");
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
it("maps slack credentials to env vars", () => {
|
|
962
|
+
const envVars = buildChannelCredentialEnvVars({
|
|
963
|
+
slack: {
|
|
964
|
+
slackBotToken: "xoxb-slack-token",
|
|
965
|
+
slackAppToken: "xapp-slack-token",
|
|
966
|
+
allowedChannels: "#general,#random",
|
|
967
|
+
},
|
|
968
|
+
});
|
|
969
|
+
expect(envVars.SLACK_BOT_TOKEN).toBe("xoxb-slack-token");
|
|
970
|
+
expect(envVars.SLACK_APP_TOKEN).toBe("xapp-slack-token");
|
|
971
|
+
expect(envVars.SLACK_ALLOWED_CHANNELS).toBe("#general,#random");
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
it("converts boolean values to strings", () => {
|
|
975
|
+
const envVars = buildChannelCredentialEnvVars({
|
|
976
|
+
discord: {
|
|
977
|
+
botToken: "bot-token-123",
|
|
978
|
+
registerCommands: true,
|
|
979
|
+
},
|
|
980
|
+
});
|
|
981
|
+
expect(envVars.DISCORD_REGISTER_COMMANDS).toBe("true");
|
|
982
|
+
});
|
|
983
|
+
|
|
984
|
+
it("skips boolean-only channel entries", () => {
|
|
985
|
+
const envVars = buildChannelCredentialEnvVars({
|
|
986
|
+
chat: true,
|
|
987
|
+
api: false,
|
|
988
|
+
});
|
|
989
|
+
expect(Object.keys(envVars)).toHaveLength(0);
|
|
990
|
+
});
|
|
991
|
+
|
|
992
|
+
it("skips unknown channels not in CHANNEL_CREDENTIAL_ENV_MAP", () => {
|
|
993
|
+
const envVars = buildChannelCredentialEnvVars({
|
|
994
|
+
"custom-channel": {
|
|
995
|
+
apiKey: "custom-key",
|
|
996
|
+
enabled: true,
|
|
997
|
+
},
|
|
998
|
+
});
|
|
999
|
+
expect(Object.keys(envVars)).toHaveLength(0);
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
it("skips undefined and null credential values", () => {
|
|
1003
|
+
const envVars = buildChannelCredentialEnvVars({
|
|
1004
|
+
discord: {
|
|
1005
|
+
botToken: "bot-token-123",
|
|
1006
|
+
applicationId: undefined,
|
|
1007
|
+
allowedGuilds: undefined,
|
|
1008
|
+
},
|
|
1009
|
+
});
|
|
1010
|
+
expect(envVars.DISCORD_BOT_TOKEN).toBe("bot-token-123");
|
|
1011
|
+
expect(envVars.DISCORD_APPLICATION_ID).toBeUndefined();
|
|
1012
|
+
expect(envVars.DISCORD_ALLOWED_GUILDS).toBeUndefined();
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
it("skips empty string credential values", () => {
|
|
1016
|
+
const envVars = buildChannelCredentialEnvVars({
|
|
1017
|
+
discord: {
|
|
1018
|
+
botToken: "bot-token-123",
|
|
1019
|
+
applicationId: "",
|
|
1020
|
+
},
|
|
1021
|
+
});
|
|
1022
|
+
expect(envVars.DISCORD_BOT_TOKEN).toBe("bot-token-123");
|
|
1023
|
+
expect(envVars.DISCORD_APPLICATION_ID).toBeUndefined();
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
it("returns empty object for undefined channels", () => {
|
|
1027
|
+
const envVars = buildChannelCredentialEnvVars(undefined);
|
|
1028
|
+
expect(Object.keys(envVars)).toHaveLength(0);
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
it("handles multiple channels simultaneously", () => {
|
|
1032
|
+
const envVars = buildChannelCredentialEnvVars({
|
|
1033
|
+
discord: { botToken: "discord-bot" },
|
|
1034
|
+
slack: { slackBotToken: "slack-bot", slackAppToken: "slack-app" },
|
|
1035
|
+
chat: true,
|
|
1036
|
+
});
|
|
1037
|
+
expect(envVars.DISCORD_BOT_TOKEN).toBe("discord-bot");
|
|
1038
|
+
expect(envVars.SLACK_BOT_TOKEN).toBe("slack-bot");
|
|
1039
|
+
expect(envVars.SLACK_APP_TOKEN).toBe("slack-app");
|
|
1040
|
+
expect(Object.keys(envVars)).toHaveLength(3);
|
|
1041
|
+
});
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
// ── Tests: CHANNEL_CREDENTIAL_ENV_MAP ────────────────────────────────────
|
|
1045
|
+
|
|
1046
|
+
describe("CHANNEL_CREDENTIAL_ENV_MAP", () => {
|
|
1047
|
+
it("has discord mappings", () => {
|
|
1048
|
+
expect(CHANNEL_CREDENTIAL_ENV_MAP.discord).toBeDefined();
|
|
1049
|
+
expect(CHANNEL_CREDENTIAL_ENV_MAP.discord.botToken).toBe("DISCORD_BOT_TOKEN");
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
it("has slack mappings", () => {
|
|
1053
|
+
expect(CHANNEL_CREDENTIAL_ENV_MAP.slack).toBeDefined();
|
|
1054
|
+
expect(CHANNEL_CREDENTIAL_ENV_MAP.slack.slackBotToken).toBe("SLACK_BOT_TOKEN");
|
|
1055
|
+
});
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
// ── Tests: performSetupFromConfig ────────────────────────────────────────
|
|
1059
|
+
|
|
1060
|
+
describe("performSetupFromConfig", () => {
|
|
1061
|
+
let tempBase: string;
|
|
1062
|
+
let configDir: string;
|
|
1063
|
+
let dataDir: string;
|
|
1064
|
+
let stateDir: string;
|
|
1065
|
+
|
|
1066
|
+
const savedEnv: Record<string, string | undefined> = {};
|
|
1067
|
+
|
|
1068
|
+
beforeEach(() => {
|
|
1069
|
+
tempBase = mkdtempSync(join(tmpdir(), "openpalm-setup-config-"));
|
|
1070
|
+
configDir = join(tempBase, "config");
|
|
1071
|
+
dataDir = join(tempBase, "data");
|
|
1072
|
+
stateDir = join(tempBase, "state");
|
|
1073
|
+
|
|
1074
|
+
// Create required directory structure
|
|
1075
|
+
for (const dir of [
|
|
1076
|
+
configDir,
|
|
1077
|
+
join(configDir, "channels"),
|
|
1078
|
+
join(configDir, "connections"),
|
|
1079
|
+
join(configDir, "assistant"),
|
|
1080
|
+
join(configDir, "automations"),
|
|
1081
|
+
join(configDir, "stash"),
|
|
1082
|
+
dataDir,
|
|
1083
|
+
join(dataDir, "admin"),
|
|
1084
|
+
join(dataDir, "memory"),
|
|
1085
|
+
join(dataDir, "assistant"),
|
|
1086
|
+
join(dataDir, "guardian"),
|
|
1087
|
+
join(dataDir, "caddy"),
|
|
1088
|
+
join(dataDir, "caddy", "data"),
|
|
1089
|
+
join(dataDir, "caddy", "config"),
|
|
1090
|
+
join(dataDir, "automations"),
|
|
1091
|
+
join(dataDir, "opencode"),
|
|
1092
|
+
stateDir,
|
|
1093
|
+
join(stateDir, "artifacts"),
|
|
1094
|
+
join(stateDir, "audit"),
|
|
1095
|
+
join(stateDir, "artifacts", "channels"),
|
|
1096
|
+
join(stateDir, "automations"),
|
|
1097
|
+
join(stateDir, "opencode"),
|
|
1098
|
+
]) {
|
|
1099
|
+
mkdirSync(dir, { recursive: true });
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
// Create stub stack.env so isSetupComplete doesn't crash
|
|
1103
|
+
writeFileSync(join(stateDir, "artifacts", "stack.env"), "OPENPALM_SETUP_COMPLETE=false\n");
|
|
1104
|
+
|
|
1105
|
+
// Seed a secrets.env file to avoid ensureSecrets() file-not-found
|
|
1106
|
+
writeFileSync(
|
|
1107
|
+
join(configDir, "secrets.env"),
|
|
1108
|
+
[
|
|
1109
|
+
"# OpenPalm Secrets",
|
|
1110
|
+
"export OPENPALM_ADMIN_TOKEN=",
|
|
1111
|
+
"export ADMIN_TOKEN=",
|
|
1112
|
+
"export OPENAI_API_KEY=",
|
|
1113
|
+
"export OPENAI_BASE_URL=",
|
|
1114
|
+
"export ANTHROPIC_API_KEY=",
|
|
1115
|
+
"export GROQ_API_KEY=",
|
|
1116
|
+
"export MISTRAL_API_KEY=",
|
|
1117
|
+
"export GOOGLE_API_KEY=",
|
|
1118
|
+
"export MEMORY_USER_ID=default_user",
|
|
1119
|
+
"export MEMORY_AUTH_TOKEN=abc123",
|
|
1120
|
+
"export OWNER_NAME=",
|
|
1121
|
+
"export OWNER_EMAIL=",
|
|
1122
|
+
"",
|
|
1123
|
+
].join("\n")
|
|
1124
|
+
);
|
|
1125
|
+
|
|
1126
|
+
// Override env vars for test isolation
|
|
1127
|
+
savedEnv.OPENPALM_CONFIG_HOME = process.env.OPENPALM_CONFIG_HOME;
|
|
1128
|
+
savedEnv.OPENPALM_DATA_HOME = process.env.OPENPALM_DATA_HOME;
|
|
1129
|
+
savedEnv.OPENPALM_STATE_HOME = process.env.OPENPALM_STATE_HOME;
|
|
1130
|
+
process.env.OPENPALM_CONFIG_HOME = configDir;
|
|
1131
|
+
process.env.OPENPALM_DATA_HOME = dataDir;
|
|
1132
|
+
process.env.OPENPALM_STATE_HOME = stateDir;
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
afterEach(() => {
|
|
1136
|
+
process.env.OPENPALM_CONFIG_HOME = savedEnv.OPENPALM_CONFIG_HOME;
|
|
1137
|
+
process.env.OPENPALM_DATA_HOME = savedEnv.OPENPALM_DATA_HOME;
|
|
1138
|
+
process.env.OPENPALM_STATE_HOME = savedEnv.OPENPALM_STATE_HOME;
|
|
1139
|
+
rmSync(tempBase, { recursive: true, force: true });
|
|
1140
|
+
});
|
|
1141
|
+
|
|
1142
|
+
it("returns an error for invalid config", async () => {
|
|
1143
|
+
const config = makeValidConfig();
|
|
1144
|
+
(config as Record<string, unknown>).version = 99;
|
|
1145
|
+
const result = await performSetupFromConfig(config, createStubAssetProvider());
|
|
1146
|
+
expect(result.ok).toBe(false);
|
|
1147
|
+
expect(result.error).toContain("version must be 1");
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
it("completes setup with a valid config", async () => {
|
|
1151
|
+
const result = await performSetupFromConfig(makeValidConfig(), createStubAssetProvider());
|
|
1152
|
+
expect(result.ok).toBe(true);
|
|
1153
|
+
|
|
1154
|
+
// Verify secrets.env was written
|
|
1155
|
+
const secretsContent = readFileSync(join(configDir, "secrets.env"), "utf-8");
|
|
1156
|
+
expect(secretsContent).toContain("test-admin-token-12345");
|
|
1157
|
+
});
|
|
1158
|
+
|
|
1159
|
+
it("writes channel credentials to secrets.env", async () => {
|
|
1160
|
+
const config = makeValidConfig({
|
|
1161
|
+
channels: {
|
|
1162
|
+
discord: {
|
|
1163
|
+
botToken: "discord-bot-token-xyz",
|
|
1164
|
+
applicationId: "discord-app-id-123",
|
|
1165
|
+
},
|
|
1166
|
+
},
|
|
1167
|
+
});
|
|
1168
|
+
const result = await performSetupFromConfig(config, createStubAssetProvider());
|
|
1169
|
+
expect(result.ok).toBe(true);
|
|
1170
|
+
|
|
1171
|
+
const secretsContent = readFileSync(join(configDir, "secrets.env"), "utf-8");
|
|
1172
|
+
expect(secretsContent).toContain("discord-bot-token-xyz");
|
|
1173
|
+
expect(secretsContent).toContain("discord-app-id-123");
|
|
1174
|
+
});
|
|
1175
|
+
|
|
1176
|
+
it("writes memory config with correct models", async () => {
|
|
1177
|
+
const result = await performSetupFromConfig(makeValidConfig(), createStubAssetProvider());
|
|
1178
|
+
expect(result.ok).toBe(true);
|
|
1179
|
+
|
|
1180
|
+
const memConfigPath = join(dataDir, "memory", "default_config.json");
|
|
1181
|
+
expect(existsSync(memConfigPath)).toBe(true);
|
|
1182
|
+
|
|
1183
|
+
const memConfig = JSON.parse(readFileSync(memConfigPath, "utf-8"));
|
|
1184
|
+
expect(memConfig.mem0.llm.config.model).toBe("gpt-4o");
|
|
1185
|
+
expect(memConfig.mem0.embedder.config.model).toBe("text-embedding-3-small");
|
|
1186
|
+
});
|
|
1187
|
+
|
|
1188
|
+
it("creates staged artifacts", async () => {
|
|
1189
|
+
const result = await performSetupFromConfig(makeValidConfig(), createStubAssetProvider());
|
|
1190
|
+
expect(result.ok).toBe(true);
|
|
1191
|
+
|
|
1192
|
+
const stagedCompose = join(stateDir, "artifacts", "docker-compose.yml");
|
|
1193
|
+
expect(existsSync(stagedCompose)).toBe(true);
|
|
1194
|
+
});
|
|
476
1195
|
});
|