@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.
@@ -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("writes stack.yml as version marker only", async () => {
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 and stack.yml", () => {
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
- expect(existsSync(join(SKELETON_DIR, "config", "stack", "stack.yml"))).toBe(true);
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 "./stack-spec.js";
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 StackSpec.
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
- }