@openpalm/lib 0.11.0-rc.6 → 0.11.1
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/compose-args.test.ts +4 -2
- package/src/control-plane/compose-args.ts +2 -3
- package/src/control-plane/config-persistence.ts +45 -4
- package/src/control-plane/core-assets.ts +24 -16
- package/src/control-plane/defaults.ts +16 -0
- package/src/control-plane/env.ts +15 -0
- package/src/control-plane/hardware-detect.ts +114 -0
- package/src/control-plane/home.ts +3 -3
- package/src/control-plane/install-edge-cases.test.ts +2 -31
- package/src/control-plane/lifecycle.ts +75 -21
- package/src/control-plane/migrations.test.ts +272 -0
- package/src/control-plane/migrations.ts +423 -0
- package/src/control-plane/paths.ts +1 -1
- package/src/control-plane/registry.ts +25 -8
- package/src/control-plane/setup-recommendation.test.ts +94 -0
- package/src/control-plane/setup-recommendation.ts +98 -0
- package/src/control-plane/setup.test.ts +2 -22
- package/src/control-plane/setup.ts +0 -4
- package/src/control-plane/skeleton-guardrail.test.ts +3 -2
- package/src/control-plane/spec-to-env.ts +2 -2
- package/src/control-plane/upgrade-path.test.ts +113 -0
- package/src/index.ts +24 -10
- package/src/control-plane/stack-spec.test.ts +0 -98
- package/src/control-plane/stack-spec.ts +0 -88
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// Pure decision engine for "what should setup do about AI providers?".
|
|
2
|
+
//
|
|
3
|
+
// Inputs are gathered by the caller (detected cloud providers, host-local
|
|
4
|
+
// providers, GPU). This module makes the call and produces a recommendation +
|
|
5
|
+
// user-facing alert. It is intentionally pure and free of I/O so it is trivially
|
|
6
|
+
// unit-testable and easy to evolve as new hardware/providers/models ship — the
|
|
7
|
+
// only things to edit are the constants at the top and the ordered rules in
|
|
8
|
+
// recommendSetup().
|
|
9
|
+
|
|
10
|
+
import type { GpuInfo, GpuVendor } from "./hardware-detect.js";
|
|
11
|
+
|
|
12
|
+
export type { GpuInfo, GpuVendor } from "./hardware-detect.js";
|
|
13
|
+
|
|
14
|
+
/** Minimum VRAM to auto-enable in-stack Ollama for local models. Edit freely. */
|
|
15
|
+
export const MIN_LOCAL_GPU_VRAM_MB = 8 * 1024;
|
|
16
|
+
|
|
17
|
+
/** Ollama hardware-profile variant chosen per GPU vendor. Extend per new vendor. */
|
|
18
|
+
const VENDOR_PROFILE_VARIANT: Record<GpuVendor, "cuda" | "rocm" | "cpu"> = {
|
|
19
|
+
nvidia: "cuda",
|
|
20
|
+
amd: "rocm",
|
|
21
|
+
unknown: "cpu",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function gpuToProfileVariant(gpu: GpuInfo): "cuda" | "rocm" | "cpu" {
|
|
25
|
+
return VENDOR_PROFILE_VARIANT[gpu.vendor] ?? "cpu";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type DetectedHostProvider = { provider: string; url: string };
|
|
29
|
+
|
|
30
|
+
export type SetupRecommendationInput = {
|
|
31
|
+
/** Cloud providers already connected (api-key / oauth / env). */
|
|
32
|
+
cloudProviders: string[];
|
|
33
|
+
/** Local providers reachable on the host (e.g. ollama, lmstudio), available only. */
|
|
34
|
+
hostProviders: DetectedHostProvider[];
|
|
35
|
+
/** Best detected GPU, or null. */
|
|
36
|
+
gpu: GpuInfo | null;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type SetupRecommendation =
|
|
40
|
+
// A cloud provider is connected — nothing to auto-configure; proceed normally.
|
|
41
|
+
| { action: "use-cloud"; cloudProviders: string[] }
|
|
42
|
+
// No cloud, but local providers are running on the host — add them and proceed
|
|
43
|
+
// to model detection.
|
|
44
|
+
| { action: "use-host-providers"; hostProviders: DetectedHostProvider[]; alert: string }
|
|
45
|
+
// No provider at all, but a capable GPU exists — enable in-stack Ollama.
|
|
46
|
+
| { action: "enable-ollama"; profileVariant: "cuda" | "rocm" | "cpu"; gpu: GpuInfo; alert: string }
|
|
47
|
+
// No provider and no capable GPU — the user must connect one manually.
|
|
48
|
+
| { action: "connect-manually"; alert: string };
|
|
49
|
+
|
|
50
|
+
const fmtGb = (mb: number): string => (mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 1);
|
|
51
|
+
|
|
52
|
+
const labelHostProviders = (h: DetectedHostProvider[]): string =>
|
|
53
|
+
h.map((p) => p.provider).join(" and ");
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Decide what setup should do, given detected providers + hardware.
|
|
57
|
+
*
|
|
58
|
+
* Order (first match wins):
|
|
59
|
+
* 1. cloud provider connected -> use it.
|
|
60
|
+
* 2. host-local provider running -> add it, proceed.
|
|
61
|
+
* 3. capable GPU (>= threshold) -> enable in-stack Ollama.
|
|
62
|
+
* 4. otherwise -> ask the user to connect a provider.
|
|
63
|
+
*/
|
|
64
|
+
export function recommendSetup(input: SetupRecommendationInput): SetupRecommendation {
|
|
65
|
+
const { cloudProviders, hostProviders, gpu } = input;
|
|
66
|
+
|
|
67
|
+
if (cloudProviders.length > 0) {
|
|
68
|
+
return { action: "use-cloud", cloudProviders };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (hostProviders.length > 0) {
|
|
72
|
+
return {
|
|
73
|
+
action: "use-host-providers",
|
|
74
|
+
hostProviders,
|
|
75
|
+
alert: `No cloud AI provider was detected, but ${labelHostProviders(hostProviders)} ${
|
|
76
|
+
hostProviders.length > 1 ? "are" : "is"
|
|
77
|
+
} running on your computer — added automatically. Pick your models on the next step.`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (gpu && gpu.vramMb >= MIN_LOCAL_GPU_VRAM_MB) {
|
|
82
|
+
return {
|
|
83
|
+
action: "enable-ollama",
|
|
84
|
+
profileVariant: gpuToProfileVariant(gpu),
|
|
85
|
+
gpu,
|
|
86
|
+
alert: `No AI provider was detected, but a capable GPU was found (${gpu.name}, ${fmtGb(
|
|
87
|
+
gpu.vramMb,
|
|
88
|
+
)} GB). Local models via Ollama have been enabled for you.`,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
action: "connect-manually",
|
|
94
|
+
alert:
|
|
95
|
+
"No AI provider was detected and no GPU with enough memory for local models was found. " +
|
|
96
|
+
"Connect a provider to continue — sign in to a provider on the next step, or add a custom OpenAI-compatible endpoint and key.",
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
performSetup,
|
|
11
11
|
} from "./setup.js";
|
|
12
12
|
import type { SetupSpec, SetupConnection } from "./setup.js";
|
|
13
|
-
import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
|
|
14
13
|
import { readSecret } from './secrets-files.js';
|
|
15
14
|
|
|
16
15
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
@@ -431,15 +430,6 @@ describe("performSetup", () => {
|
|
|
431
430
|
expect(config.llm).toBeUndefined();
|
|
432
431
|
});
|
|
433
432
|
|
|
434
|
-
it("writes stack.yml v2 version marker", async () => {
|
|
435
|
-
const result = await performSetup(makeValidSpec());
|
|
436
|
-
expect(result.ok).toBe(true);
|
|
437
|
-
|
|
438
|
-
const spec = readStackSpec(stackDir);
|
|
439
|
-
expect(spec).not.toBeNull();
|
|
440
|
-
expect(spec!.version).toBe(2);
|
|
441
|
-
});
|
|
442
|
-
|
|
443
433
|
it("writes core compose file to stack/", async () => {
|
|
444
434
|
const result = await performSetup(makeValidSpec());
|
|
445
435
|
expect(result.ok).toBe(true);
|
|
@@ -522,16 +512,10 @@ describe("performSetup", () => {
|
|
|
522
512
|
}
|
|
523
513
|
});
|
|
524
514
|
|
|
525
|
-
it("
|
|
515
|
+
it("does not create a stack.yml (addon state lives in stack.env)", async () => {
|
|
526
516
|
const result = await performSetup(makeValidSpec());
|
|
527
517
|
expect(result.ok).toBe(true);
|
|
528
|
-
|
|
529
|
-
const specPath = join(stackDir, STACK_SPEC_FILENAME);
|
|
530
|
-
expect(existsSync(specPath)).toBe(true);
|
|
531
|
-
|
|
532
|
-
const spec = readStackSpec(stackDir);
|
|
533
|
-
expect(spec).not.toBeNull();
|
|
534
|
-
expect(spec!.version).toBe(2);
|
|
518
|
+
expect(existsSync(join(stackDir, "stack.yml"))).toBe(false);
|
|
535
519
|
});
|
|
536
520
|
|
|
537
521
|
it("completes setup with multiple connections", async () => {
|
|
@@ -545,10 +529,6 @@ describe("performSetup", () => {
|
|
|
545
529
|
const result = await performSetup(input);
|
|
546
530
|
expect(result.ok).toBe(true);
|
|
547
531
|
|
|
548
|
-
const spec = readStackSpec(stackDir);
|
|
549
|
-
expect(spec).not.toBeNull();
|
|
550
|
-
expect(spec!.version).toBe(2);
|
|
551
|
-
|
|
552
532
|
const stackEnv = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), 'utf-8');
|
|
553
533
|
expect(stackEnv).not.toContain('OPENAI_API_KEY=');
|
|
554
534
|
expect(readSecret(stackDir, 'openai_api_key')).toBeNull();
|
|
@@ -24,7 +24,6 @@ import {
|
|
|
24
24
|
writeAuthJsonProviderKeys,
|
|
25
25
|
} from "./secrets.js";
|
|
26
26
|
import { createState } from "./lifecycle.js";
|
|
27
|
-
import { readStackSpec, writeStackSpec } from "./stack-spec.js";
|
|
28
27
|
import { writeVoiceVars } from "./spec-to-env.js";
|
|
29
28
|
import type { ControlPlaneState } from "./types.js";
|
|
30
29
|
import { validateSetupSpec } from "./setup-validation.js";
|
|
@@ -221,9 +220,6 @@ export async function performSetup(
|
|
|
221
220
|
// single try/catch so that a disk-full or permission-denied mid-way returns a
|
|
222
221
|
// clean error rather than leaving a broken half-installed ~/.openpalm/.
|
|
223
222
|
try {
|
|
224
|
-
// Preserve addon enablement while refreshing the stack schema marker.
|
|
225
|
-
writeStackSpec(state.stackDir, readStackSpec(state.stackDir) ?? { version: 2 });
|
|
226
|
-
|
|
227
223
|
// Write image tag and AKM mount paths to stack.env — atomic to avoid
|
|
228
224
|
// partial writes if the process is interrupted mid-write.
|
|
229
225
|
const systemEnvForAkm = existsSync(`${state.stashDir}/env/stack.env`)
|
|
@@ -57,12 +57,13 @@ describe("skeleton: helper scripts", () => {
|
|
|
57
57
|
// ── config/ subdirectory ──────────────────────────────────────────────
|
|
58
58
|
|
|
59
59
|
describe("skeleton: .openpalm/config/ structure", () => {
|
|
60
|
-
test("config/stack/ exists with fixed compose files
|
|
60
|
+
test("config/stack/ exists with fixed compose files (no stack.yml)", () => {
|
|
61
61
|
expect(existsSync(join(SKELETON_DIR, "config", "stack", "core.compose.yml"))).toBe(true);
|
|
62
62
|
expect(existsSync(join(SKELETON_DIR, "config", "stack", "services.compose.yml"))).toBe(true);
|
|
63
63
|
expect(existsSync(join(SKELETON_DIR, "config", "stack", "channels.compose.yml"))).toBe(true);
|
|
64
64
|
expect(existsSync(join(SKELETON_DIR, "config", "stack", "custom.compose.yml"))).toBe(true);
|
|
65
|
-
|
|
65
|
+
// stack.yml removed in 0.11.0 — addon enablement lives in stack.env.
|
|
66
|
+
expect(existsSync(join(SKELETON_DIR, "config", "stack", "stack.yml"))).toBe(false);
|
|
66
67
|
});
|
|
67
68
|
|
|
68
69
|
test("config/stack/addons/ does not exist", () => {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Voice channel vars (TTS/STT) are written separately via writeVoiceVars.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { SPEC_DEFAULTS } from "./
|
|
8
|
+
import { SPEC_DEFAULTS } from "./defaults.js";
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
10
10
|
import { dirname } from "node:path";
|
|
11
11
|
import { mergeEnvContent } from "./env.js";
|
|
@@ -14,7 +14,7 @@ import { assertNoSecretLikeStackEnvKeys } from './secrets.js';
|
|
|
14
14
|
import { stackEnvPathFromStackDir } from './paths.js';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Derive the system.env key-value pairs from the
|
|
17
|
+
* Derive the system.env key-value pairs from the setup spec + defaults.
|
|
18
18
|
* Secrets (tokens, API keys, HMAC) are NOT included — the caller merges them.
|
|
19
19
|
*/
|
|
20
20
|
export function deriveSystemEnvFromSpec(homeDir: string): Record<string, string> {
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Upgrade-path regression tests.
|
|
3
|
+
*
|
|
4
|
+
* #449 — Check-up "latest" install: a `latest` (or empty) tag selection must be
|
|
5
|
+
* resolved to the concrete newest published platform tag BEFORE fetching stack
|
|
6
|
+
* assets. GitHub has no `.openpalm/...` asset tree at a `latest` ref, so passing
|
|
7
|
+
* `latest` straight through used to fail with a raw download error.
|
|
8
|
+
*
|
|
9
|
+
* #450 — "Update now" must force-recreate guardian + channel containers so they
|
|
10
|
+
* re-resolve their npm dist-tag adapters; guardian must never fall out of the
|
|
11
|
+
* recreated service set.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, test, expect, afterEach } from "bun:test";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { resolveLatestPlatformTag, applyTagChange } from "./lifecycle.js";
|
|
19
|
+
import type { ControlPlaneState } from "./types.js";
|
|
20
|
+
|
|
21
|
+
const LIB_CONTROL_PLANE_DIR = join(import.meta.dir);
|
|
22
|
+
|
|
23
|
+
const realFetch = globalThis.fetch;
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
globalThis.fetch = realFetch;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function dockerTagsResponse(names: string[]): Response {
|
|
29
|
+
return new Response(
|
|
30
|
+
JSON.stringify({ results: names.map((name) => ({ name })) }),
|
|
31
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── #449: latest-tag resolution ──────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
describe("resolveLatestPlatformTag (#449)", () => {
|
|
38
|
+
test("returns the newest semver tag from the Docker registry", async () => {
|
|
39
|
+
globalThis.fetch = (async () =>
|
|
40
|
+
dockerTagsResponse(["latest", "v0.11.0", "edge"])) as typeof fetch;
|
|
41
|
+
|
|
42
|
+
const tag = await resolveLatestPlatformTag("openpalm");
|
|
43
|
+
expect(tag).toBe("v0.11.0");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("throws when the registry yields no usable tag", async () => {
|
|
47
|
+
globalThis.fetch = (async () => dockerTagsResponse(["latest"])) as typeof fetch;
|
|
48
|
+
await expect(resolveLatestPlatformTag("openpalm")).rejects.toThrow(
|
|
49
|
+
/No usable Docker image tag/,
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
describe("applyTagChange latest resolution (#449)", () => {
|
|
55
|
+
function makeState(): ControlPlaneState {
|
|
56
|
+
const home = mkdtempSync(join(tmpdir(), "openpalm-upgrade-test-"));
|
|
57
|
+
mkdirSync(join(home, "knowledge", "env"), { recursive: true });
|
|
58
|
+
writeFileSync(join(home, "knowledge", "env", "stack.env"), "OP_IMAGE_NAMESPACE=openpalm\n");
|
|
59
|
+
return {
|
|
60
|
+
homeDir: home,
|
|
61
|
+
configDir: join(home, "config"),
|
|
62
|
+
stashDir: join(home, "knowledge"),
|
|
63
|
+
workspaceDir: join(home, "workspace"),
|
|
64
|
+
dataDir: join(home, "data"),
|
|
65
|
+
stackDir: join(home, "config", "stack"),
|
|
66
|
+
services: {},
|
|
67
|
+
artifacts: { compose: "" },
|
|
68
|
+
artifactMeta: [],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test('a "latest" selection that cannot be resolved fails with a clear validation error, not a raw download error', async () => {
|
|
73
|
+
globalThis.fetch = (async () => {
|
|
74
|
+
throw new Error("network down");
|
|
75
|
+
}) as typeof fetch;
|
|
76
|
+
|
|
77
|
+
const state = makeState();
|
|
78
|
+
// Resolution happens BEFORE any asset download, so the error must be the
|
|
79
|
+
// resolution message — never the GitHub "Failed to download ..." error.
|
|
80
|
+
await expect(applyTagChange(state, "latest")).rejects.toThrow(
|
|
81
|
+
/Cannot resolve "latest" to a concrete release/,
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('an empty selection is treated like "latest" and resolved (not passed through as a blank ref)', async () => {
|
|
86
|
+
globalThis.fetch = (async () => {
|
|
87
|
+
throw new Error("network down");
|
|
88
|
+
}) as typeof fetch;
|
|
89
|
+
|
|
90
|
+
const state = makeState();
|
|
91
|
+
await expect(applyTagChange(state, " ")).rejects.toThrow(
|
|
92
|
+
/Cannot resolve "latest" to a concrete release/,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// ── #450: upgrade recreates guardian + channel containers ─────────────────
|
|
98
|
+
|
|
99
|
+
describe("performUpgrade force-recreates managed services (#450)", () => {
|
|
100
|
+
test("performUpgrade passes forceRecreate to composeUp", () => {
|
|
101
|
+
const src = readFileSync(join(LIB_CONTROL_PLANE_DIR, "lifecycle.ts"), "utf-8");
|
|
102
|
+
// The post-pull composeUp in performUpgrade must force-recreate so channel
|
|
103
|
+
// containers re-resolve their dist-tag adapters.
|
|
104
|
+
expect(src).toMatch(/composeUp\(\{[^}]*forceRecreate:\s*true/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("buildManagedServices always includes the core services (guardian)", () => {
|
|
108
|
+
const src = readFileSync(join(LIB_CONTROL_PLANE_DIR, "lifecycle.ts"), "utf-8");
|
|
109
|
+
// Guardian comes from CORE_SERVICES and must be seeded into the set
|
|
110
|
+
// regardless of how the rest of the service list is discovered.
|
|
111
|
+
expect(src).toContain("new Set<string>(CORE_SERVICES)");
|
|
112
|
+
});
|
|
113
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -39,6 +39,15 @@ export {
|
|
|
39
39
|
backupOpenPalmHome,
|
|
40
40
|
} from "./control-plane/backup.js";
|
|
41
41
|
|
|
42
|
+
// ── Layout migration harness ────────────────────────────────────────────────
|
|
43
|
+
export {
|
|
44
|
+
ensureMigrated,
|
|
45
|
+
MigrationError,
|
|
46
|
+
CURRENT_LAYOUT_VERSION,
|
|
47
|
+
LAYOUT_VERSION_KEY,
|
|
48
|
+
} from "./control-plane/migrations.js";
|
|
49
|
+
export type { MigrationReport } from "./control-plane/migrations.js";
|
|
50
|
+
|
|
42
51
|
// ── Registry Catalog ─────────────────────────────────────────────────────
|
|
43
52
|
export type {
|
|
44
53
|
AddonMutationResult,
|
|
@@ -243,6 +252,7 @@ export {
|
|
|
243
252
|
applyUpgrade,
|
|
244
253
|
performUpgrade,
|
|
245
254
|
applyTagChange,
|
|
255
|
+
resolveLatestPlatformTag,
|
|
246
256
|
updateStackEnvToLatestImageTag,
|
|
247
257
|
buildComposeFileList,
|
|
248
258
|
buildManagedServices,
|
|
@@ -287,6 +297,20 @@ export {
|
|
|
287
297
|
export type { LocalProviderDetection } from "./control-plane/model-runner.js";
|
|
288
298
|
export { detectLocalProviders } from "./control-plane/model-runner.js";
|
|
289
299
|
|
|
300
|
+
// ── Hardware detection + setup recommendation ───────────────────────────
|
|
301
|
+
export type { GpuInfo, GpuVendor } from "./control-plane/hardware-detect.js";
|
|
302
|
+
export { detectGpu, parseNvidiaSmi, parseRocmSmi } from "./control-plane/hardware-detect.js";
|
|
303
|
+
export type {
|
|
304
|
+
DetectedHostProvider,
|
|
305
|
+
SetupRecommendation,
|
|
306
|
+
SetupRecommendationInput,
|
|
307
|
+
} from "./control-plane/setup-recommendation.js";
|
|
308
|
+
export {
|
|
309
|
+
recommendSetup,
|
|
310
|
+
gpuToProfileVariant,
|
|
311
|
+
MIN_LOCAL_GPU_VRAM_MB,
|
|
312
|
+
} from "./control-plane/setup-recommendation.js";
|
|
313
|
+
|
|
290
314
|
// ── Compose Arguments ────────────────────────────────────────────────────
|
|
291
315
|
export {
|
|
292
316
|
buildComposeOptions,
|
|
@@ -307,16 +331,6 @@ export {
|
|
|
307
331
|
summarizeComposeStderr,
|
|
308
332
|
} from "./control-plane/compose-errors.js";
|
|
309
333
|
|
|
310
|
-
// ── Stack Spec (v2) ──────────────────────────────────────────────────────
|
|
311
|
-
export type {
|
|
312
|
-
StackSpec,
|
|
313
|
-
} from "./control-plane/stack-spec.js";
|
|
314
|
-
export {
|
|
315
|
-
STACK_SPEC_FILENAME,
|
|
316
|
-
writeStackSpec,
|
|
317
|
-
readStackSpec,
|
|
318
|
-
} from "./control-plane/stack-spec.js";
|
|
319
|
-
|
|
320
334
|
// ── Spec-to-Env Derivation ──────────────────────────────────────────────
|
|
321
335
|
export type { VoiceVarsConfig } from "./control-plane/spec-to-env.js";
|
|
322
336
|
export {
|
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Stack spec parser tests.
|
|
3
|
-
*
|
|
4
|
-
* Verifies that readStackSpec / writeStackSpec produce consistent results.
|
|
5
|
-
*/
|
|
6
|
-
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
7
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
8
|
-
import { join } from "node:path";
|
|
9
|
-
import { tmpdir } from "node:os";
|
|
10
|
-
import {
|
|
11
|
-
readStackSpec,
|
|
12
|
-
writeStackSpec,
|
|
13
|
-
STACK_SPEC_FILENAME,
|
|
14
|
-
} from "./stack-spec.js";
|
|
15
|
-
import type { StackSpec } from "./stack-spec.js";
|
|
16
|
-
|
|
17
|
-
let configDir: string;
|
|
18
|
-
|
|
19
|
-
beforeEach(() => {
|
|
20
|
-
configDir = mkdtempSync(join(tmpdir(), "stack-spec-test-"));
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
afterEach(() => {
|
|
24
|
-
rmSync(configDir, { recursive: true, force: true });
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
const MINIMAL_SPEC: StackSpec = { version: 2 };
|
|
28
|
-
|
|
29
|
-
// ── readStackSpec / writeStackSpec round-trip ────────────────────────────
|
|
30
|
-
|
|
31
|
-
describe("readStackSpec / writeStackSpec round-trip", () => {
|
|
32
|
-
it("round-trips a minimal spec", () => {
|
|
33
|
-
writeStackSpec(configDir, MINIMAL_SPEC);
|
|
34
|
-
const read = readStackSpec(configDir);
|
|
35
|
-
expect(read).not.toBeNull();
|
|
36
|
-
expect(read!.version).toBe(2);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("round-trips enabled addons", () => {
|
|
40
|
-
writeStackSpec(configDir, { version: 2, addons: ['chat', 'api'] });
|
|
41
|
-
expect(readStackSpec(configDir)).toEqual({ version: 2, addons: ['api', 'chat'] });
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("writes to the canonical filename", () => {
|
|
45
|
-
writeStackSpec(configDir, MINIMAL_SPEC);
|
|
46
|
-
const expectedPath = join(configDir, STACK_SPEC_FILENAME);
|
|
47
|
-
expect(expectedPath).toBe(join(configDir, "stack.yml"));
|
|
48
|
-
expect(readStackSpec(configDir)).not.toBeNull();
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it("ignores legacy capabilities fields on read", () => {
|
|
52
|
-
// On upgraded installs, old stack.yml may have capabilities — should still parse
|
|
53
|
-
writeFileSync(join(configDir, STACK_SPEC_FILENAME),
|
|
54
|
-
"version: 2\ncapabilities:\n llm: openai/gpt-4o\n embeddings:\n provider: openai\n model: text-embedding-3-small\n dims: 1536\n"
|
|
55
|
-
);
|
|
56
|
-
const read = readStackSpec(configDir);
|
|
57
|
-
expect(read).not.toBeNull();
|
|
58
|
-
expect(read!.version).toBe(2);
|
|
59
|
-
});
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
// ── readStackSpec edge cases ────────────────────────────────────────────
|
|
63
|
-
|
|
64
|
-
describe("readStackSpec edge cases", () => {
|
|
65
|
-
it("returns null for missing file", () => {
|
|
66
|
-
expect(readStackSpec(configDir)).toBeNull();
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("returns null for v1 format (connections array)", () => {
|
|
70
|
-
writeFileSync(join(configDir, STACK_SPEC_FILENAME), "version: 1\nconnections: []\n");
|
|
71
|
-
expect(readStackSpec(configDir)).toBeNull();
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
it("returns null for corrupt YAML", () => {
|
|
75
|
-
writeFileSync(join(configDir, STACK_SPEC_FILENAME), "{{invalid yaml");
|
|
76
|
-
expect(readStackSpec(configDir)).toBeNull();
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
it("returns valid spec for version 2 with no other fields", () => {
|
|
80
|
-
writeFileSync(join(configDir, STACK_SPEC_FILENAME), "version: 2\n");
|
|
81
|
-
const spec = readStackSpec(configDir);
|
|
82
|
-
expect(spec).not.toBeNull();
|
|
83
|
-
expect(spec!.version).toBe(2);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
it("ignores malformed addon names", () => {
|
|
87
|
-
writeFileSync(join(configDir, STACK_SPEC_FILENAME), "version: 2\naddons:\n - chat\n - ../bad\n - API\n");
|
|
88
|
-
expect(readStackSpec(configDir)).toEqual({ version: 2, addons: ['chat'] });
|
|
89
|
-
});
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
// ── STACK_SPEC_FILENAME ───────────────────────────────────────────────────
|
|
93
|
-
|
|
94
|
-
describe("STACK_SPEC_FILENAME", () => {
|
|
95
|
-
it("is stack.yml", () => {
|
|
96
|
-
expect(STACK_SPEC_FILENAME).toBe("stack.yml");
|
|
97
|
-
});
|
|
98
|
-
});
|
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Stack specification file (stack.yml) management.
|
|
3
|
-
*
|
|
4
|
-
* The stack spec is a YAML document used as a version marker for the
|
|
5
|
-
* OpenPalm installation schema. AI provider configuration lives in
|
|
6
|
-
* config/akm/config.json (managed via the admin AKM tab).
|
|
7
|
-
*
|
|
8
|
-
* v2: capabilities removed — LLM/embedding now live in akm config.
|
|
9
|
-
*/
|
|
10
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
11
|
-
import { stringify as yamlStringify, parse as yamlParse } from "yaml";
|
|
12
|
-
|
|
13
|
-
// ── StackSpec v2 ────────────────────────────────────────────────────────
|
|
14
|
-
|
|
15
|
-
export type StackSpec = {
|
|
16
|
-
version: 2;
|
|
17
|
-
addons?: string[];
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
const ADDON_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
21
|
-
|
|
22
|
-
// ── Constants ───────────────────────────────────────────────────────────
|
|
23
|
-
|
|
24
|
-
export const STACK_SPEC_FILENAME = "stack.yml";
|
|
25
|
-
|
|
26
|
-
export const SPEC_DEFAULTS = {
|
|
27
|
-
ports: {
|
|
28
|
-
assistant: 3800,
|
|
29
|
-
hostUi: 3880,
|
|
30
|
-
assistantSsh: 2222,
|
|
31
|
-
},
|
|
32
|
-
image: {
|
|
33
|
-
namespace: "openpalm",
|
|
34
|
-
tag: "latest",
|
|
35
|
-
},
|
|
36
|
-
} as const;
|
|
37
|
-
|
|
38
|
-
// ── Read / Write ────────────────────────────────────────────────────────
|
|
39
|
-
|
|
40
|
-
export function writeStackSpec(configDir: string, spec: StackSpec): void {
|
|
41
|
-
mkdirSync(configDir, { recursive: true });
|
|
42
|
-
const content = yamlStringify(spec, { indent: 2 });
|
|
43
|
-
writeFileSync(`${configDir}/${STACK_SPEC_FILENAME}`, content);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Read the stack spec. Returns null for missing or corrupt files.
|
|
48
|
-
* Only the version field is checked; legacy capability fields are ignored.
|
|
49
|
-
*/
|
|
50
|
-
export function readStackSpec(configDir: string): StackSpec | null {
|
|
51
|
-
const path = `${configDir}/${STACK_SPEC_FILENAME}`;
|
|
52
|
-
if (!existsSync(path)) return null;
|
|
53
|
-
|
|
54
|
-
let raw: unknown;
|
|
55
|
-
try {
|
|
56
|
-
raw = yamlParse(readFileSync(path, "utf-8"), { maxAliasCount: 100 });
|
|
57
|
-
} catch {
|
|
58
|
-
return null;
|
|
59
|
-
}
|
|
60
|
-
if (typeof raw !== "object" || raw === null) return null;
|
|
61
|
-
const obj = raw as Record<string, unknown>;
|
|
62
|
-
if (obj.version !== 2) return null;
|
|
63
|
-
const spec: StackSpec = { version: 2 };
|
|
64
|
-
if (Array.isArray(obj.addons)) {
|
|
65
|
-
const addons = obj.addons
|
|
66
|
-
.filter((value): value is string => typeof value === 'string' && ADDON_NAME_RE.test(value))
|
|
67
|
-
.filter((value, index, all) => all.indexOf(value) === index)
|
|
68
|
-
.sort();
|
|
69
|
-
if (addons.length > 0) spec.addons = addons;
|
|
70
|
-
}
|
|
71
|
-
return spec;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function listStackSpecAddons(configDir: string): string[] {
|
|
75
|
-
return readStackSpec(configDir)?.addons ?? [];
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function setStackSpecAddon(configDir: string, name: string, enabled: boolean): void {
|
|
79
|
-
if (!ADDON_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
80
|
-
const current = readStackSpec(configDir) ?? { version: 2 };
|
|
81
|
-
const addons = new Set(current.addons ?? []);
|
|
82
|
-
if (enabled) addons.add(name);
|
|
83
|
-
else addons.delete(name);
|
|
84
|
-
const next: StackSpec = { version: 2 };
|
|
85
|
-
const sorted = [...addons].sort();
|
|
86
|
-
if (sorted.length > 0) next.addons = sorted;
|
|
87
|
-
writeStackSpec(configDir, next);
|
|
88
|
-
}
|