@openpalm/lib 0.11.0-beta.9 → 0.11.0-rc.18

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.
Files changed (66) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/src/control-plane/akm-sources.test.ts +206 -0
  4. package/src/control-plane/akm-sources.ts +234 -0
  5. package/src/control-plane/akm-user-env.test.ts +142 -0
  6. package/src/control-plane/akm-user-env.ts +167 -0
  7. package/src/control-plane/backup.ts +14 -5
  8. package/src/control-plane/channels.ts +48 -29
  9. package/src/control-plane/cleanup-guardrails.test.ts +1 -1
  10. package/src/control-plane/compose-args.test.ts +69 -30
  11. package/src/control-plane/compose-args.ts +62 -8
  12. package/src/control-plane/config-persistence.ts +102 -136
  13. package/src/control-plane/core-assets.ts +45 -60
  14. package/src/control-plane/defaults.ts +16 -0
  15. package/src/control-plane/docker.ts +15 -14
  16. package/src/control-plane/env.test.ts +10 -10
  17. package/src/control-plane/env.ts +16 -1
  18. package/src/control-plane/extends-support.test.ts +8 -8
  19. package/src/control-plane/fs-atomic.ts +15 -0
  20. package/src/control-plane/home.ts +34 -46
  21. package/src/control-plane/host-akm-sharing.test.ts +145 -0
  22. package/src/control-plane/host-akm-sharing.ts +129 -0
  23. package/src/control-plane/host-opencode.test.ts +82 -10
  24. package/src/control-plane/host-opencode.ts +42 -13
  25. package/src/control-plane/install-edge-cases.test.ts +100 -136
  26. package/src/control-plane/install-lock.ts +7 -7
  27. package/src/control-plane/lifecycle.ts +45 -40
  28. package/src/control-plane/markdown-task.ts +30 -50
  29. package/src/control-plane/migrations.test.ts +272 -0
  30. package/src/control-plane/migrations.ts +423 -0
  31. package/src/control-plane/opencode-client.ts +1 -1
  32. package/src/control-plane/paths.ts +61 -46
  33. package/src/control-plane/profile-ids.ts +21 -0
  34. package/src/control-plane/provider-models.ts +3 -3
  35. package/src/control-plane/registry.test.ts +107 -90
  36. package/src/control-plane/registry.ts +301 -110
  37. package/src/control-plane/rollback.ts +8 -38
  38. package/src/control-plane/scheduler.ts +10 -7
  39. package/src/control-plane/secret-audit.test.ts +159 -0
  40. package/src/control-plane/secret-audit.ts +255 -0
  41. package/src/control-plane/secret-mappings.ts +2 -2
  42. package/src/control-plane/secrets-files.test.ts +99 -0
  43. package/src/control-plane/secrets-files.ts +113 -0
  44. package/src/control-plane/secrets.ts +113 -86
  45. package/src/control-plane/setup-config.schema.json +1 -1
  46. package/src/control-plane/setup-status.ts +6 -11
  47. package/src/control-plane/setup.test.ts +137 -61
  48. package/src/control-plane/setup.ts +82 -63
  49. package/src/control-plane/skeleton-guardrail.test.ts +66 -56
  50. package/src/control-plane/spec-to-env.test.ts +63 -26
  51. package/src/control-plane/spec-to-env.ts +51 -14
  52. package/src/control-plane/task-files.test.ts +45 -0
  53. package/src/control-plane/task-files.ts +51 -0
  54. package/src/control-plane/types.ts +2 -4
  55. package/src/control-plane/ui-assets.test.ts +333 -0
  56. package/src/control-plane/ui-assets.ts +290 -142
  57. package/src/control-plane/validate.ts +13 -15
  58. package/src/index.ts +96 -26
  59. package/src/control-plane/akm-vault.test.ts +0 -105
  60. package/src/control-plane/akm-vault.ts +0 -311
  61. package/src/control-plane/core-assets.test.ts +0 -104
  62. package/src/control-plane/migrate-0110.test.ts +0 -177
  63. package/src/control-plane/migrate-0110.ts +0 -99
  64. package/src/control-plane/registry-components.test.ts +0 -391
  65. package/src/control-plane/stack-spec.test.ts +0 -94
  66. package/src/control-plane/stack-spec.ts +0 -67
@@ -0,0 +1,333 @@
1
+ import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from "bun:test";
2
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { existsSync, readFileSync } from "node:fs";
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";
12
+
13
+ let root = "";
14
+ let opHome = "";
15
+ let repoRoot = "";
16
+ let dataUi = "";
17
+ let bundledUi = "";
18
+ const saved: Record<string, string | undefined> = {};
19
+
20
+ function makeBuild(dir: string, version: string | null): void {
21
+ mkdirSync(dir, { recursive: true });
22
+ writeFileSync(join(dir, "index.js"), "// ui server\n");
23
+ if (version !== null) writeFileSync(join(dir, UI_VERSION_STAMP), `${version}\n`);
24
+ }
25
+
26
+ beforeEach(() => {
27
+ root = mkdtempSync(join(tmpdir(), "ui-assets-"));
28
+ opHome = join(root, "ophome");
29
+ repoRoot = join(root, "repo");
30
+ dataUi = join(opHome, "data", "ui");
31
+ bundledUi = join(repoRoot, "packages", "ui", "build"); // resolveLocalUiBuild() candidate 1
32
+ saved.OP_HOME = process.env.OP_HOME;
33
+ saved.OPENPALM_REPO_ROOT = process.env.OPENPALM_REPO_ROOT;
34
+ process.env.OP_HOME = opHome;
35
+ // Pin the bundled candidate to a controlled location so the resolver never
36
+ // discovers the real packages/ui/build via its source-relative fallback.
37
+ // Default: an EMPTY build dir (exists but no index.js) = "no bundled build".
38
+ process.env.OPENPALM_REPO_ROOT = repoRoot;
39
+ mkdirSync(bundledUi, { recursive: true });
40
+ });
41
+
42
+ afterEach(() => {
43
+ rmSync(root, { recursive: true, force: true });
44
+ for (const k of ["OP_HOME", "OPENPALM_REPO_ROOT"] as const) {
45
+ if (saved[k] === undefined) delete process.env[k];
46
+ else process.env[k] = saved[k];
47
+ }
48
+ });
49
+
50
+ describe("readUiBuildVersion", () => {
51
+ it("reads the stamp, or null when absent", () => {
52
+ makeBuild(dataUi, "0.11.0");
53
+ expect(readUiBuildVersion(dataUi)).toBe("0.11.0");
54
+ makeBuild(bundledUi, null);
55
+ expect(readUiBuildVersion(bundledUi)).toBeNull();
56
+ });
57
+ });
58
+
59
+ describe("resolveUiBuildDir — version-aware selection", () => {
60
+ it("uses data/ui when only it exists", () => {
61
+ makeBuild(dataUi, "0.11.0");
62
+ expect(resolveUiBuildDir()).toBe(dataUi);
63
+ });
64
+
65
+ it("uses bundled when only it exists", () => {
66
+ makeBuild(bundledUi, "0.11.0");
67
+ process.env.OPENPALM_REPO_ROOT = repoRoot;
68
+ expect(resolveUiBuildDir()).toBe(bundledUi);
69
+ });
70
+
71
+ it("prefers data/ui only when it is strictly NEWER than bundled", () => {
72
+ makeBuild(dataUi, "0.12.0");
73
+ makeBuild(bundledUi, "0.11.0");
74
+ process.env.OPENPALM_REPO_ROOT = repoRoot;
75
+ expect(resolveUiBuildDir()).toBe(dataUi);
76
+ });
77
+
78
+ it("prefers bundled when it is newer than data/ui (fixes stale-data/ui shadowing)", () => {
79
+ makeBuild(dataUi, "0.11.0");
80
+ makeBuild(bundledUi, "0.12.0");
81
+ process.env.OPENPALM_REPO_ROOT = repoRoot;
82
+ expect(resolveUiBuildDir()).toBe(bundledUi);
83
+ });
84
+
85
+ it("prefers bundled when versions are equal", () => {
86
+ makeBuild(dataUi, "0.11.0");
87
+ makeBuild(bundledUi, "0.11.0");
88
+ process.env.OPENPALM_REPO_ROOT = repoRoot;
89
+ expect(resolveUiBuildDir()).toBe(bundledUi);
90
+ });
91
+
92
+ it("prefers bundled when data/ui is unstamped (cannot prove it is newer)", () => {
93
+ makeBuild(dataUi, null);
94
+ makeBuild(bundledUi, "0.11.0");
95
+ process.env.OPENPALM_REPO_ROOT = repoRoot;
96
+ expect(resolveUiBuildDir()).toBe(bundledUi);
97
+ });
98
+
99
+ it("falls back to the data/ui path when nothing is present (caller seeds)", () => {
100
+ expect(resolveUiBuildDir()).toBe(dataUi);
101
+ });
102
+ });
103
+
104
+ describe("seedOpenPalmDir — version guard (P2)", () => {
105
+ const seededFile = () => join(opHome, "config", "stack", "x.txt");
106
+ const stamp = () => join(opHome, SKELETON_VERSION_STAMP);
107
+
108
+ beforeEach(() => {
109
+ // Local skeleton source at OPENPALM_REPO_ROOT/.openpalm (candidate 1).
110
+ mkdirSync(join(repoRoot, ".openpalm", "config", "stack"), { recursive: true });
111
+ writeFileSync(join(repoRoot, ".openpalm", "config", "stack", "x.txt"), "seed\n");
112
+ mkdirSync(opHome, { recursive: true });
113
+ });
114
+
115
+ it("seeds once and stamps the version", async () => {
116
+ await seedOpenPalmDir("v1", opHome, join(opHome, "config"), join(opHome, "data"));
117
+ expect(existsSync(seededFile())).toBe(true);
118
+ expect(readFileSync(stamp(), "utf-8").trim()).toBe("v1");
119
+ });
120
+
121
+ it("does NOT re-seed (or re-materialize a removed file) for the same version", async () => {
122
+ await seedOpenPalmDir("v1", opHome, join(opHome, "config"), join(opHome, "data"));
123
+ rmSync(seededFile(), { force: true });
124
+ await seedOpenPalmDir("v1", opHome, join(opHome, "config"), join(opHome, "data"));
125
+ expect(existsSync(seededFile())).toBe(false); // guard skipped the copy
126
+ });
127
+
128
+ it("re-seeds on a version change", async () => {
129
+ await seedOpenPalmDir("v1", opHome, join(opHome, "config"), join(opHome, "data"));
130
+ rmSync(seededFile(), { force: true });
131
+ await seedOpenPalmDir("v2", opHome, join(opHome, "config"), join(opHome, "data"));
132
+ expect(existsSync(seededFile())).toBe(true);
133
+ expect(readFileSync(stamp(), "utf-8").trim()).toBe("v2");
134
+ });
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
+ });