@openpalm/lib 0.11.0-beta.1 → 0.11.0-beta.11

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 (49) hide show
  1. package/README.md +2 -0
  2. package/package.json +5 -1
  3. package/src/control-plane/akm-vault.test.ts +1 -4
  4. package/src/control-plane/akm-vault.ts +5 -1
  5. package/src/control-plane/channels.ts +8 -6
  6. package/src/control-plane/compose-args.test.ts +0 -12
  7. package/src/control-plane/compose-args.ts +0 -4
  8. package/src/control-plane/compose-errors.test.ts +106 -0
  9. package/src/control-plane/compose-errors.ts +117 -0
  10. package/src/control-plane/config-persistence.ts +49 -13
  11. package/src/control-plane/core-assets.ts +63 -7
  12. package/src/control-plane/docker.ts +15 -4
  13. package/src/control-plane/env.ts +4 -1
  14. package/src/control-plane/host-opencode.test.ts +0 -3
  15. package/src/control-plane/install-edge-cases.test.ts +29 -69
  16. package/src/control-plane/lifecycle.ts +39 -50
  17. package/src/control-plane/migrate-0110.test.ts +177 -0
  18. package/src/control-plane/migrate-0110.ts +99 -0
  19. package/src/control-plane/operator-ids.test.ts +130 -0
  20. package/src/control-plane/operator-ids.ts +89 -0
  21. package/src/control-plane/paths.ts +8 -3
  22. package/src/control-plane/registry-components.test.ts +3 -2
  23. package/src/control-plane/registry.test.ts +198 -4
  24. package/src/control-plane/registry.ts +333 -4
  25. package/src/control-plane/secret-mappings.ts +2 -3
  26. package/src/control-plane/secrets.ts +17 -11
  27. package/src/control-plane/setup-config.schema.json +3 -3
  28. package/src/control-plane/setup-status.ts +6 -1
  29. package/src/control-plane/setup-validation.ts +2 -2
  30. package/src/control-plane/setup.test.ts +42 -20
  31. package/src/control-plane/setup.ts +25 -41
  32. package/src/control-plane/spec-to-env.test.ts +30 -16
  33. package/src/control-plane/spec-to-env.ts +37 -21
  34. package/src/control-plane/stack-spec.test.ts +5 -11
  35. package/src/control-plane/stack-spec.ts +2 -6
  36. package/src/control-plane/types.ts +0 -22
  37. package/src/control-plane/ui-assets.ts +45 -9
  38. package/src/control-plane/validate.ts +1 -1
  39. package/src/index.ts +26 -13
  40. package/src/logger.test.ts +12 -12
  41. package/src/logger.ts +1 -1
  42. package/src/control-plane/admin-token.ts +0 -73
  43. package/src/control-plane/audit.ts +0 -41
  44. package/src/control-plane/lock.test.ts +0 -194
  45. package/src/control-plane/lock.ts +0 -176
  46. package/src/control-plane/provider-config.ts +0 -34
  47. package/src/control-plane/secret-backend.test.ts +0 -349
  48. package/src/control-plane/secret-backend.ts +0 -362
  49. package/src/control-plane/spec-validator.ts +0 -62
@@ -14,8 +14,6 @@ import type { ControlPlaneState } from "./types.js";
14
14
 
15
15
  function makeState(homeDir: string): ControlPlaneState {
16
16
  return {
17
- adminToken: "test-admin",
18
- assistantToken: "test-assistant",
19
17
  homeDir,
20
18
  configDir: join(homeDir, "config"),
21
19
  stashDir: join(homeDir, "stash"),
@@ -26,7 +24,6 @@ function makeState(homeDir: string): ControlPlaneState {
26
24
  services: {},
27
25
  artifacts: { compose: "" },
28
26
  artifactMeta: [],
29
- audit: [],
30
27
  };
31
28
  }
32
29
 
@@ -36,7 +36,7 @@ function makeValidSpec(overrides?: Partial<SetupSpec>): SetupSpec {
36
36
  version: 2,
37
37
  llm: { provider: "openai", model: "gpt-4o", baseUrl: "https://api.openai.com/v1" },
38
38
  embedding: { provider: "openai", model: "text-embedding-3-small", dims: 1536, baseUrl: "https://api.openai.com/v1" },
39
- security: { adminToken: "test-admin-token-12345" },
39
+ security: { uiLoginPassword: "test-admin-token-12345" },
40
40
  owner: { name: "Test User", email: "test@example.com" },
41
41
  connections: [
42
42
  {
@@ -135,16 +135,15 @@ function seedMinimalEnvFiles(): void {
135
135
  join(stackDir, "stack.env"),
136
136
  [
137
137
  "# OpenPalm — Stack Configuration",
138
- "OP_UI_TOKEN=",
139
- "OP_ASSISTANT_TOKEN=",
138
+ "OP_UI_LOGIN_PASSWORD=",
140
139
  "OPENAI_API_KEY=",
141
140
  "OPENAI_BASE_URL=",
142
141
  "ANTHROPIC_API_KEY=",
143
142
  "GROQ_API_KEY=",
144
143
  "MISTRAL_API_KEY=",
145
144
  "GOOGLE_API_KEY=",
146
- "OWNER_NAME=",
147
- "OWNER_EMAIL=",
145
+ "OP_OWNER_NAME=",
146
+ "OP_OWNER_EMAIL=",
148
147
  "",
149
148
  ].join("\n")
150
149
  );
@@ -171,8 +170,6 @@ describe("Fresh Install", () => {
171
170
  // does create stack.env with required keys when files do not exist.
172
171
  it("ensureSecrets creates state/stack.env with required keys on fresh install", () => {
173
172
  const state: ControlPlaneState = {
174
- adminToken: "",
175
- assistantToken: "",
176
173
  homeDir,
177
174
  configDir,
178
175
  stashDir: join(homeDir, "stash"),
@@ -183,7 +180,6 @@ describe("Fresh Install", () => {
183
180
  services: {},
184
181
  artifacts: { compose: "" },
185
182
  artifactMeta: [],
186
- audit: [],
187
183
  };
188
184
 
189
185
  ensureSecrets(state);
@@ -191,7 +187,7 @@ describe("Fresh Install", () => {
191
187
  // API keys and owner info are seeded in state/stack.env.
192
188
  const stackContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
193
189
  expect(stackContent).toContain("OPENAI_API_KEY=");
194
- expect(stackContent).toContain("OWNER_NAME=");
190
+ expect(stackContent).toContain("OP_OWNER_NAME=");
195
191
  });
196
192
 
197
193
  // Scenario 2: isSetupComplete returns false before setup
@@ -253,11 +249,9 @@ describe("Existing Install", () => {
253
249
  // Scenario 5: ensureSecrets does NOT overwrite existing stack.env
254
250
  it("ensureSecrets does not overwrite existing stack.env tokens", () => {
255
251
  mkdirSync(stateDir, { recursive: true });
256
- writeFileSync(join(stackDir, "stack.env"), "OP_UI_TOKEN=my-custom-token\nOP_ASSISTANT_TOKEN=existing-token\n");
252
+ writeFileSync(join(stackDir, "stack.env"), "OP_UI_LOGIN_PASSWORD=my-custom-password\n");
257
253
 
258
254
  const state: ControlPlaneState = {
259
- adminToken: "",
260
- assistantToken: "",
261
255
  homeDir,
262
256
  configDir,
263
257
  stashDir: join(homeDir, "stash"),
@@ -268,57 +262,29 @@ describe("Existing Install", () => {
268
262
  services: {},
269
263
  artifacts: { compose: "" },
270
264
  artifactMeta: [],
271
- audit: [],
272
265
  };
273
266
 
274
267
  ensureSecrets(state);
275
268
 
276
- // Existing tokens must be preserved
269
+ // Existing password must be preserved
277
270
  const afterContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
278
- expect(afterContent).toContain("OP_UI_TOKEN=my-custom-token");
279
- expect(afterContent).toContain("OP_ASSISTANT_TOKEN=existing-token");
271
+ expect(afterContent).toContain("OP_UI_LOGIN_PASSWORD=my-custom-password");
280
272
  });
281
273
 
282
- // Scenario 6: performSetup re-run preserves OP_ASSISTANT_TOKEN
283
- it("performSetup re-run preserves OP_ASSISTANT_TOKEN from first run", async () => {
284
- // First setup
285
- await performSetup(makeValidSpec());
274
+ // Scenario 6: performSetup re-run rewrites OP_UI_LOGIN_PASSWORD when the
275
+ // operator supplies a new one in the spec. This is intentional — the
276
+ // wizard "rerun" path is how an operator rotates the password. The
277
+ // legacy OP_ASSISTANT_TOKEN preservation test was removed with the token.
278
+ it("performSetup re-run rewrites OP_UI_LOGIN_PASSWORD when spec changes", async () => {
279
+ await performSetup(makeValidSpec({ security: { uiLoginPassword: "first-password-12345" } }));
286
280
 
287
- const secretsAfterFirst = readFileSync(
288
- join(stackDir, "stack.env"),
289
- "utf-8"
290
- );
291
- const firstMatch = secretsAfterFirst.match(
292
- /OP_ASSISTANT_TOKEN=([a-f0-9]+)/
293
- );
294
- expect(firstMatch).not.toBeNull();
295
- const firstToken = firstMatch![1];
281
+ const afterFirst = readFileSync(join(stackDir, "stack.env"), "utf-8");
282
+ expect(afterFirst).toContain("OP_UI_LOGIN_PASSWORD=first-password-12345");
296
283
 
297
- // Second setup (re-run with different API key)
298
- await performSetup(
299
- makeValidSpec({
300
- connections: [
301
- {
302
- id: "openai-main",
303
- name: "OpenAI",
304
- provider: "openai",
305
- baseUrl: "https://api.openai.com",
306
- apiKey: "sk-different-key-999",
307
- },
308
- ],
309
- })
310
- );
284
+ await performSetup(makeValidSpec({ security: { uiLoginPassword: "second-password-12345" } }));
311
285
 
312
- const secretsAfterSecond = readFileSync(
313
- join(stackDir, "stack.env"),
314
- "utf-8"
315
- );
316
- const secondMatch = secretsAfterSecond.match(
317
- /OP_ASSISTANT_TOKEN=([a-f0-9]+)/
318
- );
319
- expect(secondMatch).not.toBeNull();
320
- // OP_ASSISTANT_TOKEN should be preserved across setups
321
- expect(secondMatch![1]).toBe(firstToken);
286
+ const afterSecond = readFileSync(join(stackDir, "stack.env"), "utf-8");
287
+ expect(afterSecond).toContain("OP_UI_LOGIN_PASSWORD=second-password-12345");
322
288
  });
323
289
 
324
290
  // Scenario 7: performSetup must NOT mark OP_SETUP_COMPLETE — see scenario
@@ -386,11 +352,9 @@ describe("Broken/Corrupt State", () => {
386
352
  // Scenario 9: ensureSecrets is idempotent on repeated calls
387
353
  it("ensureSecrets is idempotent — second call does not overwrite existing stack.env", () => {
388
354
  mkdirSync(stateDir, { recursive: true });
389
- writeFileSync(join(stackDir, "stack.env"), "OP_UI_TOKEN=existing-token\nOP_ASSISTANT_TOKEN=existing-assistant\n");
355
+ writeFileSync(join(stackDir, "stack.env"), "OP_UI_LOGIN_PASSWORD=existing-password\n");
390
356
 
391
357
  const state: ControlPlaneState = {
392
- adminToken: "",
393
- assistantToken: "",
394
358
  homeDir,
395
359
  configDir,
396
360
  stashDir: join(homeDir, "stash"),
@@ -401,15 +365,13 @@ describe("Broken/Corrupt State", () => {
401
365
  services: {},
402
366
  artifacts: { compose: "" },
403
367
  artifactMeta: [],
404
- audit: [],
405
368
  };
406
369
 
407
370
  ensureSecrets(state);
408
371
 
409
- // Existing tokens must be preserved
372
+ // Existing password must be preserved
410
373
  const content = readFileSync(join(stackDir, "stack.env"), "utf-8");
411
- expect(content).toContain("OP_UI_TOKEN=existing-token");
412
- expect(content).toContain("OP_ASSISTANT_TOKEN=existing-assistant");
374
+ expect(content).toContain("OP_UI_LOGIN_PASSWORD=existing-password");
413
375
  });
414
376
 
415
377
  // Scenario 10: env file with malformed lines
@@ -447,11 +409,11 @@ describe("Broken/Corrupt State", () => {
447
409
  expect(isSetupComplete(stackDir)).toBe(false);
448
410
  });
449
411
 
450
- it("isSetupComplete falls back to true when admin token is set but OP_SETUP_COMPLETE missing", () => {
412
+ it("isSetupComplete falls back to true when UI login password is set but OP_SETUP_COMPLETE missing", () => {
451
413
  mkdirSync(stateDir, { recursive: true });
452
414
  writeFileSync(
453
415
  join(stackDir, "stack.env"),
454
- "OP_IMAGE_TAG=latest\nexport OP_UI_TOKEN=my-real-token\n"
416
+ "OP_IMAGE_TAG=latest\nexport OP_UI_LOGIN_PASSWORD=my-real-password\n"
455
417
  );
456
418
 
457
419
  expect(isSetupComplete(stackDir)).toBe(true);
@@ -527,12 +489,12 @@ describe("Environment Edge Cases", () => {
527
489
  rmSync(homeDir, { recursive: true, force: true });
528
490
  });
529
491
 
530
- // Scenario 16: Commented-out ADMIN_TOKEN but OP_UI_TOKEN set
531
- it("isSetupComplete detects OP_UI_TOKEN when ADMIN_TOKEN is commented out", () => {
492
+ // Scenario 16: isSetupComplete picks up OP_UI_LOGIN_PASSWORD when set
493
+ it("isSetupComplete detects OP_UI_LOGIN_PASSWORD", () => {
532
494
  mkdirSync(stateDir, { recursive: true });
533
495
  writeFileSync(
534
496
  join(stackDir, "stack.env"),
535
- "SOME_OTHER_KEY=value\nexport OP_UI_TOKEN=real-token-here\n"
497
+ "SOME_OTHER_KEY=value\nexport OP_UI_LOGIN_PASSWORD=real-password-here\n"
536
498
  );
537
499
 
538
500
  expect(isSetupComplete(stackDir)).toBe(true);
@@ -705,13 +667,11 @@ describe("performSetup end-to-end artifacts", () => {
705
667
  ).toBe(true);
706
668
  });
707
669
 
708
- it("writes admin and assistant tokens to stack.env", async () => {
670
+ it("writes the UI login password to stack.env", async () => {
709
671
  await performSetup(makeValidSpec());
710
672
 
711
673
  const secrets = parseEnvFile(join(stackDir, "stack.env"));
712
- expect(secrets.OP_UI_TOKEN).toBe("test-admin-token-12345");
713
- expect(typeof secrets.OP_ASSISTANT_TOKEN).toBe("string");
714
- expect(secrets.OP_ASSISTANT_TOKEN).not.toBe("test-admin-token-12345");
674
+ expect(secrets.OP_UI_LOGIN_PASSWORD).toBe("test-admin-token-12345");
715
675
  });
716
676
 
717
677
  it("writes akm config with llm provider and model", async () => {
@@ -1,7 +1,7 @@
1
1
  /** Lifecycle helpers — state factory, apply transitions, compose file list. */
2
2
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
3
3
  import { parseEnvFile, mergeEnvContent } from "./env.js";
4
- import type { ControlPlaneState, CallerType, AuditContext } from "./types.js";
4
+ import type { ControlPlaneState, CallerType } from "./types.js";
5
5
  import { CORE_SERVICES } from "./types.js";
6
6
  import {
7
7
  resolveOpenPalmHome,
@@ -12,7 +12,7 @@ import {
12
12
  resolveStateDir,
13
13
  resolveStackDir,
14
14
  } from "./home.js";
15
- import { ensureSecrets, readStackEnv, updateSystemSecretsEnv } from "./secrets.js";
15
+ import { ensureSecrets } from "./secrets.js";
16
16
  import {
17
17
  resolveRuntimeFiles,
18
18
  writeRuntimeFiles,
@@ -20,22 +20,18 @@ import {
20
20
  discoverStackOverlays,
21
21
  ensureComposeVolumeTargets,
22
22
  } from "./config-persistence.js";
23
- import { readStackSpec } from "./stack-spec.js";
24
23
  import { refreshCoreAssets } from "./core-assets.js";
25
24
  import { isSetupComplete } from "./setup-status.js";
26
25
  import { snapshotCurrentState } from "./rollback.js";
27
26
  import { checkDocker, composePreflight, composePull, composeUp, composeConfigServices, resolveComposeProjectName } from "./docker.js";
28
- import { acquireLock, releaseLock } from "./lock.js";
29
- import { appendAudit } from "./audit.js";
27
+ import { acquireInstallLock, releaseInstallLock } from "./install-lock.js";
30
28
  import { listEnabledAddonIds } from "./registry.js";
31
29
 
32
30
  const IMAGE_NAMESPACE_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
33
31
  const SEMVER_TAG_RE = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
34
32
 
35
33
 
36
- export function createState(
37
- adminToken?: string
38
- ): ControlPlaneState {
34
+ export function createState(): ControlPlaneState {
39
35
  const homeDir = resolveOpenPalmHome();
40
36
  const configDir = resolveConfigDir();
41
37
  const stashDir = resolveStashDir();
@@ -50,8 +46,6 @@ export function createState(
50
46
  }
51
47
 
52
48
  const bootstrapState: ControlPlaneState = {
53
- adminToken: adminToken ?? process.env.OP_UI_TOKEN ?? "",
54
- assistantToken: "",
55
49
  homeDir,
56
50
  configDir,
57
51
  stashDir,
@@ -62,23 +56,10 @@ export function createState(
62
56
  services,
63
57
  artifacts: { compose: "" },
64
58
  artifactMeta: [],
65
- audit: [],
66
59
  };
67
60
 
68
61
  ensureSecrets(bootstrapState);
69
62
 
70
- const stackEnv = readStackEnv(stackDir);
71
- // Precedence: explicit parameter > stack.env > process.env.
72
- bootstrapState.adminToken =
73
- adminToken
74
- ?? stackEnv.OP_UI_TOKEN
75
- ?? process.env.OP_UI_TOKEN
76
- ?? "";
77
- bootstrapState.assistantToken =
78
- stackEnv.OP_ASSISTANT_TOKEN
79
- ?? process.env.OP_ASSISTANT_TOKEN
80
- ?? "";
81
-
82
63
  return bootstrapState;
83
64
  }
84
65
 
@@ -142,48 +123,37 @@ async function reconcileCore(
142
123
  return active;
143
124
  }
144
125
 
145
- export async function applyInstall(state: ControlPlaneState, ctx?: AuditContext): Promise<void> {
146
- const lock = acquireLock(state.homeDir, "install");
126
+ export async function applyInstall(state: ControlPlaneState): Promise<void> {
127
+ const lock = acquireInstallLock(state.stateDir);
128
+ if (!lock) throw new Error("Another install is already in progress");
147
129
  try {
148
130
  await reconcileCore(state, { activateServices: true });
149
131
  // Pre-create host-side volume mount targets as the current user so
150
132
  // Docker doesn't create them root-owned (which causes EACCES inside
151
133
  // non-root containers).
152
134
  ensureComposeVolumeTargets(state);
153
- if (ctx) appendAudit(state, ctx.actor, "install", {}, true, ctx.requestId ?? "", ctx.callerType ?? "unknown");
154
- } catch (err) {
155
- if (ctx) appendAudit(state, ctx.actor, "install", { error: String(err) }, false, ctx.requestId ?? "", ctx.callerType ?? "unknown");
156
- throw err;
157
135
  } finally {
158
- releaseLock(lock);
136
+ releaseInstallLock(lock);
159
137
  }
160
138
  }
161
139
 
162
- export async function applyUpdate(state: ControlPlaneState, ctx?: AuditContext): Promise<{ restarted: string[] }> {
163
- const lock = acquireLock(state.homeDir, "update");
140
+ export async function applyUpdate(state: ControlPlaneState): Promise<{ restarted: string[] }> {
141
+ const lock = acquireInstallLock(state.stateDir);
142
+ if (!lock) throw new Error("Another install is already in progress");
164
143
  try {
165
- const result = { restarted: await reconcileCore(state, {}) };
166
- if (ctx) appendAudit(state, ctx.actor, "update", { restarted: result.restarted }, true, ctx.requestId ?? "", ctx.callerType ?? "unknown");
167
- return result;
168
- } catch (err) {
169
- if (ctx) appendAudit(state, ctx.actor, "update", { error: String(err) }, false, ctx.requestId ?? "", ctx.callerType ?? "unknown");
170
- throw err;
144
+ return { restarted: await reconcileCore(state, {}) };
171
145
  } finally {
172
- releaseLock(lock);
146
+ releaseInstallLock(lock);
173
147
  }
174
148
  }
175
149
 
176
- export async function applyUninstall(state: ControlPlaneState, ctx?: AuditContext): Promise<{ stopped: string[] }> {
177
- const lock = acquireLock(state.homeDir, "uninstall");
150
+ export async function applyUninstall(state: ControlPlaneState): Promise<{ stopped: string[] }> {
151
+ const lock = acquireInstallLock(state.stateDir);
152
+ if (!lock) throw new Error("Another install is already in progress");
178
153
  try {
179
- const result = { stopped: await reconcileCore(state, { deactivateServices: true }) };
180
- if (ctx) appendAudit(state, ctx.actor, "uninstall", { stopped: result.stopped }, true, ctx.requestId ?? "", ctx.callerType ?? "unknown");
181
- return result;
182
- } catch (err) {
183
- if (ctx) appendAudit(state, ctx.actor, "uninstall", { error: String(err) }, false, ctx.requestId ?? "", ctx.callerType ?? "unknown");
184
- throw err;
154
+ return { stopped: await reconcileCore(state, { deactivateServices: true }) };
185
155
  } finally {
186
- releaseLock(lock);
156
+ releaseInstallLock(lock);
187
157
  }
188
158
  }
189
159
 
@@ -250,13 +220,14 @@ export async function applyUpgrade(
250
220
  updated: string[];
251
221
  restarted: string[];
252
222
  }> {
253
- const lock = acquireLock(state.homeDir, "upgrade");
223
+ const lock = acquireInstallLock(state.stateDir);
224
+ if (!lock) throw new Error("Another install is already in progress");
254
225
  try {
255
226
  const { backupDir, updated } = await refreshCoreAssets();
256
227
  const restarted = await reconcileCore(state, {});
257
228
  return { backupDir, updated, restarted };
258
229
  } finally {
259
- releaseLock(lock);
230
+ releaseInstallLock(lock);
260
231
  }
261
232
  }
262
233
 
@@ -328,6 +299,24 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
328
299
  };
329
300
  }
330
301
 
302
+ /**
303
+ * Set a specific image tag in stack.env then pull images and restart containers.
304
+ * Used by the admin "set version" action — skips the auto-detect step in performUpgrade.
305
+ */
306
+ export async function applyTagChange(state: ControlPlaneState, tag: string): Promise<UpgradeResult> {
307
+ const stackEnvPath = `${state.stackDir}/stack.env`;
308
+ const currentContent = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
309
+ writeFileSync(stackEnvPath, mergeEnvContent(currentContent, { OP_IMAGE_TAG: tag }, { uncomment: true }));
310
+ const upgradeResult = await applyUpgrade(state);
311
+ return {
312
+ imageTag: tag,
313
+ namespace: "openpalm",
314
+ backupDir: upgradeResult.backupDir,
315
+ assetsUpdated: upgradeResult.updated,
316
+ restarted: upgradeResult.restarted,
317
+ };
318
+ }
319
+
331
320
  export function buildComposeFileList(state: ControlPlaneState): string[] {
332
321
  return discoverStackOverlays(state.stackDir);
333
322
  }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Tests for the 0.11.0 auth-migration shim.
3
+ */
4
+ import { describe, expect, it, beforeEach, afterEach } from "bun:test";
5
+ import {
6
+ existsSync,
7
+ mkdirSync,
8
+ mkdtempSync,
9
+ readFileSync,
10
+ rmSync,
11
+ statSync,
12
+ writeFileSync,
13
+ chmodSync,
14
+ } from "node:fs";
15
+ import { tmpdir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { migrateAuth0110 } from "./migrate-0110.js";
18
+ import type { ControlPlaneState } from "./types.js";
19
+
20
+ function makeState(homeDir: string): ControlPlaneState {
21
+ return {
22
+ homeDir,
23
+ configDir: join(homeDir, "config"),
24
+ stashDir: join(homeDir, "stash"),
25
+ workspaceDir: join(homeDir, "workspace"),
26
+ cacheDir: join(homeDir, "cache"),
27
+ stateDir: join(homeDir, "state"),
28
+ stackDir: join(homeDir, "config", "stack"),
29
+ services: {},
30
+ artifacts: { compose: "" },
31
+ artifactMeta: [],
32
+ };
33
+ }
34
+
35
+ function seedStackEnv(stackDir: string, content: string): string {
36
+ mkdirSync(stackDir, { recursive: true });
37
+ const path = join(stackDir, "stack.env");
38
+ writeFileSync(path, content, { encoding: "utf-8", mode: 0o600 });
39
+ chmodSync(path, 0o600);
40
+ return path;
41
+ }
42
+
43
+ describe("migrateAuth0110", () => {
44
+ let homeDir: string;
45
+
46
+ beforeEach(() => {
47
+ homeDir = mkdtempSync(join(tmpdir(), "openpalm-migrate-0110-"));
48
+ });
49
+
50
+ afterEach(() => {
51
+ rmSync(homeDir, { recursive: true, force: true });
52
+ });
53
+
54
+ it("no-ops on a fresh install (no stack.env)", () => {
55
+ const state = makeState(homeDir);
56
+ const result = migrateAuth0110(state);
57
+ expect(result.migrated).toBe(false);
58
+ expect(result.reason).toContain("fresh install");
59
+ });
60
+
61
+ it("promotes OP_UI_TOKEN → OP_UI_LOGIN_PASSWORD and removes legacy keys", () => {
62
+ const state = makeState(homeDir);
63
+ const stackEnvPath = seedStackEnv(
64
+ state.stackDir,
65
+ [
66
+ "# header",
67
+ "OP_UI_TOKEN=legacy-token-value",
68
+ "OP_ASSISTANT_TOKEN=some-assistant-token",
69
+ "OP_OPENCODE_PASSWORD=opencode-secret",
70
+ "",
71
+ ].join("\n"),
72
+ );
73
+
74
+ const result = migrateAuth0110(state);
75
+ expect(result.migrated).toBe(true);
76
+ expect(result.reason).toContain("promoted OP_UI_TOKEN");
77
+ expect(result.reason).toContain("removed OP_UI_TOKEN");
78
+ expect(result.reason).toContain("removed OP_ASSISTANT_TOKEN");
79
+
80
+ const after = readFileSync(stackEnvPath, "utf-8");
81
+ expect(after).toContain("OP_UI_LOGIN_PASSWORD=legacy-token-value");
82
+ expect(after).not.toMatch(/^OP_UI_TOKEN=/m);
83
+ expect(after).not.toMatch(/^OP_ASSISTANT_TOKEN=/m);
84
+ // Unrelated keys preserved
85
+ expect(after).toContain("OP_OPENCODE_PASSWORD=opencode-secret");
86
+
87
+ // Perms preserved
88
+ expect(statSync(stackEnvPath).mode & 0o777).toBe(0o600);
89
+
90
+ // Migration log appended
91
+ const logPath = join(state.stateDir, "logs", "migration-0.11.0.log");
92
+ expect(existsSync(logPath)).toBe(true);
93
+ const log = readFileSync(logPath, "utf-8");
94
+ expect(log).toContain("migrate-auth-0110");
95
+ expect(log).toContain("promoted OP_UI_TOKEN");
96
+ });
97
+
98
+ it("does not overwrite an existing OP_UI_LOGIN_PASSWORD", () => {
99
+ const state = makeState(homeDir);
100
+ const stackEnvPath = seedStackEnv(
101
+ state.stackDir,
102
+ [
103
+ "OP_UI_LOGIN_PASSWORD=new-password",
104
+ "OP_UI_TOKEN=legacy-value",
105
+ "",
106
+ ].join("\n"),
107
+ );
108
+
109
+ const result = migrateAuth0110(state);
110
+ expect(result.migrated).toBe(true);
111
+ expect(result.reason).not.toContain("promoted");
112
+ expect(result.reason).toContain("removed OP_UI_TOKEN");
113
+
114
+ const after = readFileSync(stackEnvPath, "utf-8");
115
+ expect(after).toContain("OP_UI_LOGIN_PASSWORD=new-password");
116
+ expect(after).not.toMatch(/^OP_UI_TOKEN=/m);
117
+ });
118
+
119
+ it("removes OP_ASSISTANT_TOKEN even when only it is present", () => {
120
+ const state = makeState(homeDir);
121
+ const stackEnvPath = seedStackEnv(
122
+ state.stackDir,
123
+ [
124
+ "OP_UI_LOGIN_PASSWORD=pw",
125
+ "OP_ASSISTANT_TOKEN=stale",
126
+ "",
127
+ ].join("\n"),
128
+ );
129
+
130
+ const result = migrateAuth0110(state);
131
+ expect(result.migrated).toBe(true);
132
+ expect(result.reason).toContain("removed OP_ASSISTANT_TOKEN");
133
+ expect(readFileSync(stackEnvPath, "utf-8")).not.toMatch(/^OP_ASSISTANT_TOKEN=/m);
134
+ });
135
+
136
+ it("is idempotent: second run reports already-migrated", () => {
137
+ const state = makeState(homeDir);
138
+ seedStackEnv(
139
+ state.stackDir,
140
+ [
141
+ "OP_UI_TOKEN=t",
142
+ "OP_ASSISTANT_TOKEN=t2",
143
+ "",
144
+ ].join("\n"),
145
+ );
146
+
147
+ const first = migrateAuth0110(state);
148
+ expect(first.migrated).toBe(true);
149
+
150
+ const second = migrateAuth0110(state);
151
+ expect(second.migrated).toBe(false);
152
+ expect(second.reason).toContain("already migrated");
153
+ });
154
+
155
+ it("treats an empty OP_UI_TOKEN value as not-set (no promotion)", () => {
156
+ const state = makeState(homeDir);
157
+ const stackEnvPath = seedStackEnv(
158
+ state.stackDir,
159
+ [
160
+ "OP_UI_TOKEN=",
161
+ "OP_ASSISTANT_TOKEN=foo",
162
+ "",
163
+ ].join("\n"),
164
+ );
165
+
166
+ const result = migrateAuth0110(state);
167
+ expect(result.migrated).toBe(true);
168
+ // Empty-string OP_UI_TOKEN should NOT be promoted as a password.
169
+ expect(result.reason).not.toContain("promoted");
170
+
171
+ const after = readFileSync(stackEnvPath, "utf-8");
172
+ // The empty OP_UI_TOKEN line is still removed.
173
+ expect(after).not.toMatch(/^OP_UI_TOKEN=/m);
174
+ // No OP_UI_LOGIN_PASSWORD added (would be an empty value).
175
+ expect(after).not.toMatch(/^OP_UI_LOGIN_PASSWORD=/m);
176
+ });
177
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * One-shot migration for the 0.11.0 auth refactor.
3
+ *
4
+ * Existing installs have OP_UI_TOKEN and OP_ASSISTANT_TOKEN in
5
+ * config/stack/stack.env. The 0.11.0 refactor (auth-and-proxy-refactor-plan.md)
6
+ * replaces them with a single OP_UI_LOGIN_PASSWORD. If we don't migrate,
7
+ * operators get locked out the moment they run the new UI build because the
8
+ * login route compares the cookie against process.env.OP_UI_LOGIN_PASSWORD,
9
+ * which is empty on existing installs.
10
+ *
11
+ * Migration logic (idempotent):
12
+ * - If OP_UI_LOGIN_PASSWORD is unset AND OP_UI_TOKEN is set, copy
13
+ * OP_UI_TOKEN's value into OP_UI_LOGIN_PASSWORD.
14
+ * - Remove OP_UI_TOKEN and OP_ASSISTANT_TOKEN from stack.env (they're
15
+ * no longer used).
16
+ * - Append a one-line summary to state/logs/migration-0.11.0.log.
17
+ * - If OP_UI_LOGIN_PASSWORD is already set, leave it alone — the operator
18
+ * already migrated or set up fresh.
19
+ *
20
+ * Called from ensureSecrets so it runs before any auth-required code path
21
+ * gets a chance to see the half-migrated state.
22
+ */
23
+ import {
24
+ existsSync,
25
+ readFileSync,
26
+ writeFileSync,
27
+ chmodSync,
28
+ appendFileSync,
29
+ mkdirSync,
30
+ } from "node:fs";
31
+ import { dirname } from "node:path";
32
+ import { parseEnvContent, removeEnvKey, upsertEnvValue } from "./env.js";
33
+ import { migration0110LogPath } from "./paths.js";
34
+ import type { ControlPlaneState } from "./types.js";
35
+
36
+ export type MigrateAuth0110Result = {
37
+ /** True if any change was written to stack.env. */
38
+ migrated: boolean;
39
+ /** Human-readable description of what changed (or why nothing did). */
40
+ reason: string;
41
+ };
42
+
43
+ export function migrateAuth0110(state: ControlPlaneState): MigrateAuth0110Result {
44
+ const stackEnvPath = `${state.stackDir}/stack.env`;
45
+ if (!existsSync(stackEnvPath)) {
46
+ return { migrated: false, reason: "no stack.env yet (fresh install)" };
47
+ }
48
+
49
+ const before = readFileSync(stackEnvPath, "utf-8");
50
+ const parsed = parseEnvContent(before);
51
+ const hasLoginPw = typeof parsed.OP_UI_LOGIN_PASSWORD === "string" && parsed.OP_UI_LOGIN_PASSWORD.length > 0;
52
+ const hasUiToken = typeof parsed.OP_UI_TOKEN === "string" && parsed.OP_UI_TOKEN.length > 0;
53
+ const hasAssistantToken = "OP_ASSISTANT_TOKEN" in parsed;
54
+ const hasUiTokenLine = "OP_UI_TOKEN" in parsed;
55
+
56
+ if (hasLoginPw && !hasUiTokenLine && !hasAssistantToken) {
57
+ return { migrated: false, reason: "already migrated" };
58
+ }
59
+
60
+ let content = before;
61
+ const changes: string[] = [];
62
+
63
+ if (!hasLoginPw && hasUiToken) {
64
+ content = upsertEnvValue(content, "OP_UI_LOGIN_PASSWORD", parsed.OP_UI_TOKEN);
65
+ changes.push("promoted OP_UI_TOKEN → OP_UI_LOGIN_PASSWORD");
66
+ }
67
+ if (hasUiTokenLine) {
68
+ content = removeEnvKey(content, "OP_UI_TOKEN");
69
+ changes.push("removed OP_UI_TOKEN");
70
+ }
71
+ if (hasAssistantToken) {
72
+ content = removeEnvKey(content, "OP_ASSISTANT_TOKEN");
73
+ changes.push("removed OP_ASSISTANT_TOKEN");
74
+ }
75
+
76
+ if (changes.length === 0) {
77
+ return { migrated: false, reason: "no changes needed" };
78
+ }
79
+
80
+ // Preserve the 0600 mode the existing file should already have.
81
+ writeFileSync(stackEnvPath, content, { encoding: "utf-8", mode: 0o600 });
82
+ try { chmodSync(stackEnvPath, 0o600); } catch { /* best-effort */ }
83
+
84
+ // Best-effort audit line. The migration log is small and append-only;
85
+ // if it fails (perm error, fs full), we don't roll back the migration.
86
+ try {
87
+ const logPath = migration0110LogPath(state);
88
+ mkdirSync(dirname(logPath), { recursive: true });
89
+ appendFileSync(
90
+ logPath,
91
+ `${new Date().toISOString()} migrate-auth-0110 ${changes.join("; ")}\n`,
92
+ "utf-8",
93
+ );
94
+ } catch {
95
+ /* best-effort */
96
+ }
97
+
98
+ return { migrated: true, reason: changes.join("; ") };
99
+ }