@openpalm/lib 0.11.0-rc.3 → 0.11.0
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 +8 -1
- 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/home.ts +3 -3
- package/src/control-plane/install-edge-cases.test.ts +2 -31
- package/src/control-plane/lifecycle.ts +8 -4
- 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 +22 -10
- 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/ui-assets.test.ts +205 -2
- package/src/control-plane/ui-assets.ts +6 -4
- package/src/index.ts +9 -10
- package/src/control-plane/stack-spec.test.ts +0 -98
- package/src/control-plane/stack-spec.ts +0 -88
|
@@ -1,9 +1,14 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from "bun:test";
|
|
2
2
|
import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { existsSync, readFileSync } from "node:fs";
|
|
6
|
-
import {
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import {
|
|
8
|
+
resolveUiBuildDir, readUiBuildVersion, UI_VERSION_STAMP,
|
|
9
|
+
seedOpenPalmDir, SKELETON_VERSION_STAMP,
|
|
10
|
+
uiUpdateChannel, checkAndUpdateUiBuild,
|
|
11
|
+
} from "./ui-assets.js";
|
|
7
12
|
|
|
8
13
|
let root = "";
|
|
9
14
|
let opHome = "";
|
|
@@ -128,3 +133,201 @@ describe("seedOpenPalmDir — version guard (P2)", () => {
|
|
|
128
133
|
expect(readFileSync(stamp(), "utf-8").trim()).toBe("v2");
|
|
129
134
|
});
|
|
130
135
|
});
|
|
136
|
+
|
|
137
|
+
// ── uiUpdateChannel ───────────────────────────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
describe("uiUpdateChannel", () => {
|
|
140
|
+
it("returns 'latest' for a stable version", () => {
|
|
141
|
+
expect(uiUpdateChannel("0.11.0")).toBe("latest");
|
|
142
|
+
expect(uiUpdateChannel("1.0.0")).toBe("latest");
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns 'next' for a prerelease version (contains '-')", () => {
|
|
146
|
+
expect(uiUpdateChannel("0.11.0-rc.2")).toBe("next");
|
|
147
|
+
expect(uiUpdateChannel("0.11.0-beta.5")).toBe("next");
|
|
148
|
+
expect(uiUpdateChannel("1.0.0-alpha.1")).toBe("next");
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// ── npm integrity verification (via checkAndUpdateUiBuild) ────────────────────
|
|
153
|
+
//
|
|
154
|
+
// We mock globalThis.fetch to avoid real network calls. The integrity paths
|
|
155
|
+
// are exercised through checkAndUpdateUiBuild → downloadNpmUiBundle (for the
|
|
156
|
+
// missing-integrity and mismatch cases) and through checkAndUpdateUiBuild
|
|
157
|
+
// returning {updated:false} early (for the up-to-date case).
|
|
158
|
+
|
|
159
|
+
/** Build a correct sha512 SRI string for the given bytes. */
|
|
160
|
+
function makeSri(data: Uint8Array): string {
|
|
161
|
+
const digest = createHash("sha512").update(data).digest("base64");
|
|
162
|
+
return `sha512-${digest}`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
describe("npm integrity verification (fail-closed)", () => {
|
|
166
|
+
let savedFetch: typeof globalThis.fetch;
|
|
167
|
+
|
|
168
|
+
beforeEach(() => {
|
|
169
|
+
savedFetch = globalThis.fetch;
|
|
170
|
+
// Set forceRemote context: no local build available for these tests.
|
|
171
|
+
// (data/ui has no index.js, bundledUi dir exists but is empty → no local build)
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
afterEach(() => {
|
|
175
|
+
globalThis.fetch = savedFetch;
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("throws when the manifest has no integrity hash (fail-closed)", async () => {
|
|
179
|
+
// manifest fetch returns a version with no integrity
|
|
180
|
+
globalThis.fetch = async (_url: string | URL | Request) => {
|
|
181
|
+
const url = String(typeof _url === "string" ? _url : (_url as Request).url ?? _url);
|
|
182
|
+
if (url.includes("registry.npmjs.org")) {
|
|
183
|
+
return new Response(
|
|
184
|
+
JSON.stringify({
|
|
185
|
+
version: "0.11.0",
|
|
186
|
+
dist: { tarball: "https://registry.npmjs.org/tarball.tgz" },
|
|
187
|
+
// integrity intentionally omitted
|
|
188
|
+
}),
|
|
189
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
// tarball fetch — should NOT be reached because we throw before it
|
|
193
|
+
return new Response("not-reached", { status: 200 });
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const result = await checkAndUpdateUiBuild("0.11.0-beta.1", join(dataUi, ".."));
|
|
197
|
+
// Missing integrity → non-fatal error path
|
|
198
|
+
expect(result.updated).toBe(false);
|
|
199
|
+
expect(result.error).toMatch(/no integrity hash/i);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("throws when the tarball bytes do not match the stated integrity hash", async () => {
|
|
203
|
+
const fakeData = new Uint8Array([1, 2, 3, 4]);
|
|
204
|
+
const wrongSri = `sha512-${Buffer.from("wrong").toString("base64")}`;
|
|
205
|
+
|
|
206
|
+
globalThis.fetch = async (_url: string | URL | Request) => {
|
|
207
|
+
const url = String(typeof _url === "string" ? _url : (_url as Request).url ?? _url);
|
|
208
|
+
if (url.includes("registry.npmjs.org") && !url.includes("tarball")) {
|
|
209
|
+
return new Response(
|
|
210
|
+
JSON.stringify({
|
|
211
|
+
version: "0.99.0", // newer than anything on disk
|
|
212
|
+
dist: { tarball: "https://registry.npmjs.org/tarball.tgz", integrity: wrongSri },
|
|
213
|
+
}),
|
|
214
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
// tarball response — bytes deliberately do NOT match wrongSri
|
|
218
|
+
return new Response(fakeData, { status: 200 });
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
const result = await checkAndUpdateUiBuild("0.11.0", join(dataUi, ".."));
|
|
222
|
+
expect(result.updated).toBe(false);
|
|
223
|
+
expect(result.error).toMatch(/integrity mismatch/i);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── checkAndUpdateUiBuild ─────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
describe("checkAndUpdateUiBuild", () => {
|
|
230
|
+
let savedFetch: typeof globalThis.fetch;
|
|
231
|
+
|
|
232
|
+
beforeEach(() => {
|
|
233
|
+
savedFetch = globalThis.fetch;
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
afterEach(() => {
|
|
237
|
+
globalThis.fetch = savedFetch;
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
/** Make a minimal npm manifest response for a given version. */
|
|
241
|
+
function manifestResponse(version: string, integrity?: string) {
|
|
242
|
+
return new Response(
|
|
243
|
+
JSON.stringify({
|
|
244
|
+
version,
|
|
245
|
+
dist: {
|
|
246
|
+
tarball: "https://registry.npmjs.org/tarball.tgz",
|
|
247
|
+
...(integrity !== undefined ? { integrity } : {}),
|
|
248
|
+
},
|
|
249
|
+
}),
|
|
250
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
it("returns {updated:false} when the npm channel version is not newer than on-disk stamp", async () => {
|
|
255
|
+
// Seed data/ui with a stamped build
|
|
256
|
+
makeBuild(dataUi, "0.11.0");
|
|
257
|
+
|
|
258
|
+
globalThis.fetch = async () => manifestResponse("0.11.0"); // same version
|
|
259
|
+
const result = await checkAndUpdateUiBuild("0.11.0", join(opHome, "data"));
|
|
260
|
+
expect(result.updated).toBe(false);
|
|
261
|
+
expect(result.latestVersion).toBe("0.11.0");
|
|
262
|
+
expect(result.error).toBeUndefined();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("returns {updated:false} when the npm channel version is older than on-disk stamp", async () => {
|
|
266
|
+
makeBuild(dataUi, "0.12.0");
|
|
267
|
+
|
|
268
|
+
globalThis.fetch = async () => manifestResponse("0.11.0"); // older
|
|
269
|
+
const result = await checkAndUpdateUiBuild("0.12.0", join(opHome, "data"));
|
|
270
|
+
expect(result.updated).toBe(false);
|
|
271
|
+
expect(result.latestVersion).toBe("0.11.0");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("returns {updated:false, error} when the manifest fetch rejects (non-fatal)", async () => {
|
|
275
|
+
globalThis.fetch = async () => { throw new Error("network failure"); };
|
|
276
|
+
|
|
277
|
+
const result = await checkAndUpdateUiBuild("0.11.0", join(opHome, "data"));
|
|
278
|
+
expect(result.updated).toBe(false);
|
|
279
|
+
expect(result.latestVersion).toBeNull();
|
|
280
|
+
expect(result.error).toMatch(/network failure/i);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("returns {updated:false, error} when the registry returns a non-OK status", async () => {
|
|
284
|
+
globalThis.fetch = async () => new Response("not found", { status: 404 });
|
|
285
|
+
|
|
286
|
+
const result = await checkAndUpdateUiBuild("0.11.0", join(opHome, "data"));
|
|
287
|
+
expect(result.updated).toBe(false);
|
|
288
|
+
expect(result.error).toBeDefined();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("attempts an update when the on-disk build is unstamped (legacy data/ui)", async () => {
|
|
292
|
+
// Unstamped data/ui — cannot compare, so it should try to refresh from npm.
|
|
293
|
+
// We give it a manifest with missing integrity so it fails non-fatally
|
|
294
|
+
// (avoids needing a real tarball), but we confirm it DID attempt the download.
|
|
295
|
+
makeBuild(dataUi, null); // unstamped
|
|
296
|
+
|
|
297
|
+
let manifestFetched = false;
|
|
298
|
+
globalThis.fetch = async (_url: string | URL | Request) => {
|
|
299
|
+
const url = String(typeof _url === "string" ? _url : (_url as Request).url ?? _url);
|
|
300
|
+
if (url.includes("registry.npmjs.org")) {
|
|
301
|
+
manifestFetched = true;
|
|
302
|
+
// Return a manifest without integrity so downloadNpmUiBundle throws
|
|
303
|
+
return manifestResponse("0.11.1");
|
|
304
|
+
}
|
|
305
|
+
return new Response("", { status: 200 });
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
const result = await checkAndUpdateUiBuild("0.11.0", join(opHome, "data"));
|
|
309
|
+
expect(manifestFetched).toBe(true);
|
|
310
|
+
// non-fatal: missing integrity → error path
|
|
311
|
+
expect(result.updated).toBe(false);
|
|
312
|
+
expect(result.error).toBeDefined();
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it("attempts an update when npm has a newer version than the on-disk stamp", async () => {
|
|
316
|
+
makeBuild(dataUi, "0.11.0");
|
|
317
|
+
|
|
318
|
+
let manifestFetched = false;
|
|
319
|
+
globalThis.fetch = async (_url: string | URL | Request) => {
|
|
320
|
+
const url = String(typeof _url === "string" ? _url : (_url as Request).url ?? _url);
|
|
321
|
+
if (url.includes("registry.npmjs.org")) {
|
|
322
|
+
manifestFetched = true;
|
|
323
|
+
return manifestResponse("0.12.0"); // newer — no integrity → non-fatal error
|
|
324
|
+
}
|
|
325
|
+
return new Response("", { status: 200 });
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
const result = await checkAndUpdateUiBuild("0.11.0", join(opHome, "data"));
|
|
329
|
+
expect(manifestFetched).toBe(true);
|
|
330
|
+
expect(result.updated).toBe(false);
|
|
331
|
+
expect(result.error).toMatch(/no integrity hash/i);
|
|
332
|
+
});
|
|
333
|
+
});
|
|
@@ -4,11 +4,12 @@
|
|
|
4
4
|
* These functions are consumed by both the CLI and the Electron shell — they
|
|
5
5
|
* must use only Node.js-compatible APIs (no Bun.spawn, Bun.write, etc.).
|
|
6
6
|
*
|
|
7
|
-
* Source resolution order (
|
|
7
|
+
* Source resolution order (UI build and .openpalm/ skeleton):
|
|
8
8
|
* 1. OPENPALM_REPO_ROOT env var — explicit dev override
|
|
9
9
|
* 2. Relative to import.meta.url — works for `bun run` / source installs
|
|
10
10
|
* 3. Relative to process.execPath — works for compiled Bun binary in repo
|
|
11
|
-
* 4. null →
|
|
11
|
+
* 4. null → remote download (the UI build from the @openpalm/ui npm registry
|
|
12
|
+
* tarball; the .openpalm skeleton from the GitHub repo tarball)
|
|
12
13
|
*/
|
|
13
14
|
import {
|
|
14
15
|
existsSync, mkdirSync, readdirSync, copyFileSync,
|
|
@@ -175,7 +176,7 @@ export async function seedOpenPalmDir(
|
|
|
175
176
|
|
|
176
177
|
/**
|
|
177
178
|
* Locate the compiled SvelteKit UI build on disk.
|
|
178
|
-
* Returns null when not found — triggers
|
|
179
|
+
* Returns null when not found — triggers the npm registry download in seedUiBuild.
|
|
179
180
|
*/
|
|
180
181
|
export function resolveLocalUiBuild(): string | null {
|
|
181
182
|
return resolveLocalCandidate(
|
|
@@ -230,7 +231,8 @@ export function readUiBuildVersion(dir: string): string | null {
|
|
|
230
231
|
* Resolve which UI build to run.
|
|
231
232
|
*
|
|
232
233
|
* Two channels exist: the bundled build (shipped inside the AppImage / source
|
|
233
|
-
* tree) and `data/ui` (operator-updatable, seeded from
|
|
234
|
+
* tree) and `data/ui` (operator-updatable, seeded from the @openpalm/ui npm
|
|
235
|
+
* registry tarball). To fix
|
|
234
236
|
* the stale-`data/ui` shadowing bug AND stay forward-compatible with updating the
|
|
235
237
|
* UI without shipping a new app (D5), selection is VERSION-AWARE:
|
|
236
238
|
*
|
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,
|
|
@@ -307,16 +316,6 @@ export {
|
|
|
307
316
|
summarizeComposeStderr,
|
|
308
317
|
} from "./control-plane/compose-errors.js";
|
|
309
318
|
|
|
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
319
|
// ── Spec-to-Env Derivation ──────────────────────────────────────────────
|
|
321
320
|
export type { VoiceVarsConfig } from "./control-plane/spec-to-env.js";
|
|
322
321
|
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
|
-
}
|