@openpalm/lib 0.11.0-beta.3 → 0.11.0-beta.7

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/README.md CHANGED
@@ -3,6 +3,8 @@
3
3
  Shared control-plane library for OpenPalm.
4
4
  CLI, admin, and scheduler use this package so stack behavior stays consistent.
5
5
 
6
+ > **Bun required.** This package ships TypeScript source and relies on Bun's native TS execution. It does not compile to JavaScript and is not compatible with Node.js.
7
+
6
8
  The current model is direct-write over `~/.openpalm/` plus native Docker Compose.
7
9
  Compose files in `stack/` and env files in `vault/` are the live runtime inputs.
8
10
 
package/package.json CHANGED
@@ -1,9 +1,12 @@
1
1
  {
2
2
  "name": "@openpalm/lib",
3
- "version": "0.11.0-beta.3",
3
+ "version": "0.11.0-beta.7",
4
4
  "license": "MPL-2.0",
5
5
  "type": "module",
6
6
  "description": "Shared control-plane library for OpenPalm — lifecycle, staging, secrets, channels, connections, scheduler",
7
+ "engines": {
8
+ "bun": ">=1.0.0"
9
+ },
7
10
  "scripts": {
8
11
  "test": "bun test"
9
12
  },
@@ -244,7 +244,11 @@ export async function deleteAkmVaultKey(
244
244
  const vaultPath = await ensureAkmUserVault(state, env);
245
245
  if (!vaultPath) return false;
246
246
  try {
247
- await execAkm(["vault", "unset", AKM_USER_VAULT_REF, key], env);
247
+ // --yes: newer akm versions require explicit confirmation for any
248
+ // destructive operation in non-interactive mode. Without this flag
249
+ // the command exits with NON_INTERACTIVE_REQUIRES_YES and our
250
+ // delete looks like a hard failure instead of an idempotent unset.
251
+ await execAkm(["vault", "unset", "--yes", AKM_USER_VAULT_REF, key], env);
248
252
  } catch (err) {
249
253
  // `unset` of a missing key is a benign no-op; many akm versions exit 0
250
254
  // anyway. If akm hard-fails (non-zero, non-empty stderr) we surface it.
@@ -19,9 +19,11 @@ function isValidChannelName(name: string): boolean {
19
19
  // ── Channel Discovery ─────────────────────────────────────────────────
20
20
 
21
21
  /**
22
- * Check if a compose file defines a channel service (has CHANNEL_NAME or GUARDIAN_URL).
23
- * This is compose-derived: we parse the actual compose content rather than
24
- * relying on filename patterns or directory naming conventions.
22
+ * Check if a compose file defines a channel service (has CHANNEL_NAME).
23
+ * Compose-derived: we parse the actual compose content rather than rely on
24
+ * filename or directory naming conventions. (GUARDIAN_URL used to be a
25
+ * fallback signal — it's been removed since channels-sdk now hardcodes the
26
+ * in-network guardian URL.)
25
27
  */
26
28
  export function isChannelAddon(composePath: string): boolean {
27
29
  try {
@@ -36,9 +38,9 @@ export function isChannelAddon(composePath: string): boolean {
36
38
  const env = (svcDef as Record<string, unknown>).environment;
37
39
  if (typeof env === "object" && env !== null) {
38
40
  if (Array.isArray(env)) {
39
- if (env.some((e: unknown) => typeof e === "string" && (e.startsWith("CHANNEL_NAME=") || e.startsWith("GUARDIAN_URL=")))) return true;
41
+ if (env.some((e: unknown) => typeof e === "string" && e.startsWith("CHANNEL_NAME="))) return true;
40
42
  } else {
41
- if ("CHANNEL_NAME" in (env as Record<string, unknown>) || "GUARDIAN_URL" in (env as Record<string, unknown>)) return true;
43
+ if ("CHANNEL_NAME" in (env as Record<string, unknown>)) return true;
42
44
  }
43
45
  }
44
46
  }
@@ -51,7 +53,7 @@ export function isChannelAddon(composePath: string): boolean {
51
53
  /**
52
54
  * Discover installed channels by scanning stack/addons/ for channel addons.
53
55
  * A channel addon is identified by compose-derived truth: its compose.yml
54
- * defines services with CHANNEL_NAME or GUARDIAN_URL environment variables.
56
+ * defines services with a CHANNEL_NAME environment variable.
55
57
  *
56
58
  * Non-channel addons (admin, ollama, etc.) are excluded.
57
59
  *
@@ -6,7 +6,6 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { join } from "node:path";
7
7
  import { tmpdir } from "node:os";
8
8
  import {
9
- COMPOSE_PROJECT_NAME,
10
9
  buildComposeOptions,
11
10
  buildComposeCliArgs,
12
11
  } from "./compose-args.js";
@@ -63,14 +62,6 @@ afterEach(() => {
63
62
  rmSync(tempDir, { recursive: true, force: true });
64
63
  });
65
64
 
66
- // ── COMPOSE_PROJECT_NAME ─────────────────────────────────────────────────
67
-
68
- describe("COMPOSE_PROJECT_NAME", () => {
69
- it("is 'openpalm'", () => {
70
- expect(COMPOSE_PROJECT_NAME).toBe("openpalm");
71
- });
72
- });
73
-
74
65
  // ── buildComposeOptions ──────────────────────────────────────────────────
75
66
 
76
67
  describe("buildComposeOptions", () => {
@@ -11,10 +11,6 @@ import { buildComposeFileList } from "./lifecycle.js";
11
11
  import { buildEnvFiles } from "./config-persistence.js";
12
12
  import { resolveComposeProjectName } from "./docker.js";
13
13
 
14
- // ── Constants ────────────────────────────────────────────────────────────
15
-
16
- export const COMPOSE_PROJECT_NAME = "openpalm";
17
-
18
14
  // ── Types ────────────────────────────────────────────────────────────────
19
15
 
20
16
  export type ComposeOptions = {
@@ -12,6 +12,7 @@ import { parseEnvFile, mergeEnvContent, expandEnvVars } from './env.js';
12
12
  import type { ControlPlaneState, ArtifactMeta } from "./types.js";
13
13
  import { isChannelAddon } from "./channels.js";
14
14
  import { listEnabledAddonIds } from "./registry.js";
15
+ import { resolveOperatorIds, hasUsableOperatorId } from "./operator-ids.js";
15
16
 
16
17
  import {
17
18
  readCoreCompose,
@@ -69,16 +70,35 @@ export function writeSystemEnv(state: ControlPlaneState): void {
69
70
  OP_SETUP_COMPLETE: alreadyComplete ? "true" : "false"
70
71
  };
71
72
 
73
+ // Backfill OP_UID/OP_GID when the existing stack.env was written by an
74
+ // older code path that hard-coded 1000, or when the file was created
75
+ // with missing/zero values. We only override when the current value is
76
+ // missing or zero — an operator who manually set OP_UID=2000 (e.g.
77
+ // because they're running on a host with a non-1000 service account)
78
+ // must not be silently changed.
79
+ const parsed = parseEnvFile(systemEnvPath);
80
+ const ids = resolveOperatorIds(state.homeDir);
81
+ if (ids) {
82
+ if (!hasUsableOperatorId(parsed, "OP_UID")) adminManaged.OP_UID = String(ids.uid);
83
+ if (!hasUsableOperatorId(parsed, "OP_GID")) adminManaged.OP_GID = String(ids.gid);
84
+ }
85
+
72
86
  const content = mergeEnvContent(base, adminManaged, {
73
87
  sectionHeader: "# ── Admin-managed ──────────────────────────────────────────────────"
74
88
  });
75
89
 
76
- writeFileSync(systemEnvPath, content);
90
+ writeFileSync(systemEnvPath, content, { mode: 0o600 });
91
+ chmodSync(systemEnvPath, 0o600);
77
92
  }
78
93
 
79
94
  function generateFallbackSystemEnv(state: ControlPlaneState): string {
80
- const uid = typeof process.getuid === "function" ? (process.getuid() ?? 1000) : 1000;
81
- const gid = typeof process.getgid === "function" ? (process.getgid() ?? 1000) : 1000;
95
+ // Operator UID/GID auto-detect from OP_HOME owner (or process UID).
96
+ // Skipped on Windows where containers run in WSL2 and OP_UID has no
97
+ // meaning on the host process.
98
+ const ids = resolveOperatorIds(state.homeDir);
99
+ const idLines: string[] = ids
100
+ ? [`OP_UID=${ids.uid}`, `OP_GID=${ids.gid}`]
101
+ : [];
82
102
 
83
103
  return [
84
104
  "# OpenPalm — System Configuration (managed by CLI/admin)",
@@ -92,18 +112,18 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
92
112
  "",
93
113
  "# ── Paths ──────────────────────────────────────────────────────────",
94
114
  `OP_HOME=${state.homeDir}`,
95
- `OP_UID=${uid}`,
96
- `OP_GID=${gid}`,
115
+ ...idLines,
97
116
  "",
98
117
  "# ── Images ──────────────────────────────────────────────────────────",
99
118
  `OP_IMAGE_NAMESPACE=${process.env.OP_IMAGE_NAMESPACE ?? "openpalm"}`,
100
119
  `OP_IMAGE_TAG=${DEFAULT_IMAGE_TAG}`,
101
120
  "",
102
121
  "# ── Ports (38XX range) ──────────────────────────────────────────────",
122
+ "# Guardian is network-only (no host port) — channels reach it via",
123
+ "# http://guardian:8080 over the channel_lan Docker network.",
103
124
  `OP_ASSISTANT_PORT=3800`,
104
125
  `OP_ADMIN_PORT=3880`,
105
126
  `OP_ADMIN_OPENCODE_PORT=3881`,
106
- `OP_GUARDIAN_PORT=3899`,
107
127
  ""
108
128
  ].join("\n");
109
129
  }
@@ -126,8 +146,21 @@ export function discoverStackOverlays(stackDir: string): string[] {
126
146
  .filter((e) => e.isDirectory())
127
147
  .sort((a, b) => a.name.localeCompare(b.name));
128
148
  for (const entry of entries) {
129
- const addonCompose = `${addonsDir}/${entry.name}/compose.yml`;
130
- if (existsSync(addonCompose)) files.push(addonCompose);
149
+ const dir = `${addonsDir}/${entry.name}`;
150
+ // Pick up compose.yml plus any compose.<variant>.yml sibling
151
+ // overlays (e.g. compose.cdi.yml generated by /admin/voice on
152
+ // CDI hosts). Stable sort: compose.yml first, then siblings
153
+ // alphabetically, so the base file's keys are the defaults and
154
+ // overlays merge on top in deterministic order.
155
+ const overlays = readdirSync(dir, { withFileTypes: true })
156
+ .filter((e) => e.isFile() && /^compose(\.[A-Za-z0-9_-]+)?\.ya?ml$/.test(e.name))
157
+ .map((e) => e.name)
158
+ .sort((a, b) => {
159
+ if (a === "compose.yml" || a === "compose.yaml") return -1;
160
+ if (b === "compose.yml" || b === "compose.yaml") return 1;
161
+ return a.localeCompare(b);
162
+ });
163
+ for (const name of overlays) files.push(`${dir}/${name}`);
131
164
  }
132
165
  }
133
166
 
@@ -225,7 +258,7 @@ export function writeChannelSecrets(stackDir: string, secrets: Record<string, st
225
258
  * (e.g. `/var/run/docker.sock`) are left alone.
226
259
  */
227
260
  export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
228
- const composeFiles = discoverStackOverlays(`${state.homeDir}/stack`);
261
+ const composeFiles = discoverStackOverlays(state.stackDir);
229
262
  if (composeFiles.length === 0) return;
230
263
 
231
264
  const envVars: Record<string, string> = {
@@ -281,9 +314,13 @@ export function ensureComposeVolumeTargets(state: ControlPlaneState): void {
281
314
  export function writeRuntimeFiles(
282
315
  state: ControlPlaneState
283
316
  ): void {
284
- // Write core compose to config/stack/
317
+ // Write core compose to config/stack/ only on first install —
318
+ // refreshCoreAssets() is the canonical writer on update.
285
319
  mkdirSync(state.stackDir, { recursive: true });
286
- writeFileSync(`${state.stackDir}/core.compose.yml`, state.artifacts.compose);
320
+ const composePath = `${state.stackDir}/core.compose.yml`;
321
+ if (!existsSync(composePath)) {
322
+ writeFileSync(composePath, state.artifacts.compose);
323
+ }
287
324
 
288
325
  // Load persisted channel HMAC secrets from guardian.env,
289
326
  // then generate new ones for new channel addons.
@@ -11,6 +11,7 @@
11
11
  */
12
12
  import { mkdirSync, writeFileSync, readFileSync, existsSync, copyFileSync } from "node:fs";
13
13
  import { dirname, join, resolve, sep } from "node:path";
14
+ import { fileURLToPath } from "node:url";
14
15
  import { resolveStateDir, resolveOpenPalmHome, resolveBackupsDir, resolveStashDir } from "./home.js";
15
16
  import { createLogger } from "../logger.js";
16
17
  import { sha256 } from "./crypto.js";
@@ -80,15 +81,31 @@ export function seedStashAssets(seeds: Record<string, string>): string[] {
80
81
  // ── Asset Refresh (GitHub download) ──────────────────────────────────
81
82
 
82
83
  const REPO = "itlackey/openpalm";
83
- const VERSION = process.env.OP_ASSET_VERSION ?? "main";
84
84
 
85
- // Stash seeds are intentionally NOT in this list — they use seedStashAssets()
86
- // which never overwrites existing files (user edits win on re-install).
85
+ function resolveAssetVersion(): string {
86
+ if (process.env.OP_ASSET_VERSION) return process.env.OP_ASSET_VERSION;
87
+ try {
88
+ const pkgJson = JSON.parse(
89
+ readFileSync(join(dirname(fileURLToPath(import.meta.url)), "../../package.json"), "utf-8")
90
+ );
91
+ return `v${pkgJson.version}`;
92
+ } catch {
93
+ return "main";
94
+ }
95
+ }
96
+ const VERSION = resolveAssetVersion();
97
+
98
+ // Persona files (openpalm.md, system.md), stash seeds, and user-editable config
99
+ // files are intentionally NOT in this list. They are seeded once (never
100
+ // overwritten) via seedOpenPalmDir (skipExisting) or SEEDED_ASSETS below.
87
101
  const MANAGED_ASSETS: { relPath: string; githubFilename: string }[] = [
88
- { relPath: "config/stack/core.compose.yml", githubFilename: ".openpalm/config/stack/core.compose.yml" },
89
- { relPath: "config/assistant/opencode.jsonc", githubFilename: ".openpalm/config/assistant/opencode.jsonc" },
90
- { relPath: "config/assistant/openpalm.md", githubFilename: ".openpalm/config/assistant/openpalm.md" },
91
- { relPath: "config/assistant/system.md", githubFilename: ".openpalm/config/assistant/system.md" },
102
+ { relPath: "config/stack/core.compose.yml", githubFilename: ".openpalm/config/stack/core.compose.yml" },
103
+ ];
104
+
105
+ // Seeded once — written only when the file does not exist yet.
106
+ // User edits always win; upgrade never touches these files.
107
+ const SEEDED_ASSETS: { relPath: string; githubFilename: string }[] = [
108
+ { relPath: "config/assistant/opencode.jsonc", githubFilename: ".openpalm/config/assistant/opencode.jsonc" },
92
109
  ];
93
110
 
94
111
  async function downloadAsset(filename: string): Promise<string> {
@@ -137,5 +154,44 @@ export async function refreshCoreAssets(): Promise<{
137
154
  updated.push(asset.relPath);
138
155
  }
139
156
 
157
+ // Seed user-editable assets only when missing — never overwrite.
158
+ for (const asset of SEEDED_ASSETS) {
159
+ const targetPath = join(homeDir, asset.relPath);
160
+ if (existsSync(targetPath)) continue;
161
+ const freshContent = await downloadAsset(asset.githubFilename);
162
+ mkdirSync(dirname(targetPath), { recursive: true });
163
+ writeFileSync(targetPath, freshContent);
164
+ updated.push(asset.relPath);
165
+ }
166
+
140
167
  return { backupDir, updated };
141
168
  }
169
+
170
+ // ── Assistant Persona File Seeding ────────────────────────────────────
171
+
172
+ /**
173
+ * Seed assistant persona files (openpalm.md, system.md) into OP_HOME.
174
+ *
175
+ * Idempotent: **never overwrites** an existing file — user edits always
176
+ * win. This preserves the "config/ is user-owned" contract: persona files
177
+ * are seeded once on first install and never touched again on update.
178
+ *
179
+ * `seeds` maps relative path keys (e.g. `"config/assistant/openpalm.md"`)
180
+ * to file content. Each file is written to `resolveOpenPalmHome()/<relPath>`
181
+ * only if the file does not already exist.
182
+ *
183
+ * Returns the list of relative paths that were actually written (empty on
184
+ * re-run when every seed already exists on disk).
185
+ */
186
+ export function seedAssistantPersonaFiles(seeds: Record<string, string>): string[] {
187
+ const homeDir = resolveOpenPalmHome();
188
+ const written: string[] = [];
189
+ for (const [relPath, content] of Object.entries(seeds)) {
190
+ const targetPath = join(homeDir, relPath);
191
+ if (existsSync(targetPath)) continue;
192
+ mkdirSync(dirname(targetPath), { recursive: true });
193
+ writeFileSync(targetPath, content);
194
+ written.push(relPath);
195
+ }
196
+ return written;
197
+ }
@@ -295,26 +295,37 @@ export async function composeLogs(
295
295
  return run(args, undefined);
296
296
  }
297
297
 
298
+ // 60-minute pull timeout. Voice addon ships a ~2.4 GB image (CPU) /
299
+ // ~7.6 GB (CUDA); on a 1-2 Mbps home connection these legitimately take
300
+ // 30+ minutes. The previous 5-min cap silently killed pulls mid-stream
301
+ // on first install, surfacing as an opaque "pull failed". The wizard's
302
+ // retry layer wraps this, so an actually-hung pull is bounded by the
303
+ // outer retry budget; this just gives any progressing pull room to
304
+ // finish on slow connections.
305
+ const PULL_TIMEOUT_MS = 60 * 60_000;
306
+
298
307
  /**
299
308
  * Pull image for a single service.
300
309
  */
301
310
  export async function composePullService(
302
311
  service: string,
303
- options: { files: string[]; envFiles?: string[] }
312
+ options: { files: string[]; envFiles?: string[]; profiles?: string[] }
304
313
  ): Promise<DockerResult> {
305
314
  await runPreflight(options);
306
315
  const args = buildComposeArgs(options);
316
+ for (const p of options.profiles ?? []) args.push("--profile", p);
307
317
  args.push("pull", service);
308
- return run(args, undefined, 300_000, collectEnvOverrides(options.envFiles));
318
+ return run(args, undefined, PULL_TIMEOUT_MS, collectEnvOverrides(options.envFiles));
309
319
  }
310
320
 
311
321
  export async function composePull(
312
- options: { files: string[]; envFiles?: string[] }
322
+ options: { files: string[]; envFiles?: string[]; profiles?: string[] }
313
323
  ): Promise<DockerResult> {
314
324
  await runPreflight(options);
315
325
  const args = buildComposeArgs(options);
326
+ for (const p of options.profiles ?? []) args.push("--profile", p);
316
327
  args.push("pull");
317
- return run(args, undefined, 300_000, collectEnvOverrides(options.envFiles));
328
+ return run(args, undefined, PULL_TIMEOUT_MS, collectEnvOverrides(options.envFiles));
318
329
  }
319
330
 
320
331
  /**
@@ -1,5 +1,5 @@
1
1
  import { parse as dotenvParse } from 'dotenv';
2
- import { readFileSync, existsSync } from 'node:fs';
2
+ import { readFileSync, existsSync, copyFileSync } from 'node:fs';
3
3
 
4
4
  export function parseEnvContent(content: string): Record<string, string> {
5
5
  return dotenvParse(content);
@@ -10,6 +10,9 @@ export function parseEnvFile(filePath: string): Record<string, string> {
10
10
  try {
11
11
  return dotenvParse(readFileSync(filePath, 'utf-8'));
12
12
  } catch {
13
+ // File is unreadable or malformed — back it up before returning empty so
14
+ // the next write doesn't silently discard all existing values.
15
+ try { copyFileSync(filePath, `${filePath}.corrupt-${Date.now()}`); } catch { /* best-effort */ }
13
16
  return {};
14
17
  }
15
18
  }
@@ -142,8 +142,8 @@ function seedMinimalEnvFiles(): void {
142
142
  "GROQ_API_KEY=",
143
143
  "MISTRAL_API_KEY=",
144
144
  "GOOGLE_API_KEY=",
145
- "OWNER_NAME=",
146
- "OWNER_EMAIL=",
145
+ "OP_OWNER_NAME=",
146
+ "OP_OWNER_EMAIL=",
147
147
  "",
148
148
  ].join("\n")
149
149
  );
@@ -187,7 +187,7 @@ describe("Fresh Install", () => {
187
187
  // API keys and owner info are seeded in state/stack.env.
188
188
  const stackContent = readFileSync(join(stackDir, "stack.env"), "utf-8");
189
189
  expect(stackContent).toContain("OPENAI_API_KEY=");
190
- expect(stackContent).toContain("OWNER_NAME=");
190
+ expect(stackContent).toContain("OP_OWNER_NAME=");
191
191
  });
192
192
 
193
193
  // Scenario 2: isSetupComplete returns false before setup
@@ -25,7 +25,7 @@ import { refreshCoreAssets } from "./core-assets.js";
25
25
  import { isSetupComplete } from "./setup-status.js";
26
26
  import { snapshotCurrentState } from "./rollback.js";
27
27
  import { checkDocker, composePreflight, composePull, composeUp, composeConfigServices, resolveComposeProjectName } from "./docker.js";
28
- import { acquireLock, releaseLock } from "./lock.js";
28
+ import { acquireInstallLock, releaseInstallLock } from "./install-lock.js";
29
29
  import { listEnabledAddonIds } from "./registry.js";
30
30
 
31
31
  const IMAGE_NAMESPACE_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
@@ -125,7 +125,8 @@ async function reconcileCore(
125
125
  }
126
126
 
127
127
  export async function applyInstall(state: ControlPlaneState): Promise<void> {
128
- const lock = acquireLock(state.homeDir, "install");
128
+ const lock = acquireInstallLock(state.stateDir);
129
+ if (!lock) throw new Error("Another install is already in progress");
129
130
  try {
130
131
  await reconcileCore(state, { activateServices: true });
131
132
  // Pre-create host-side volume mount targets as the current user so
@@ -133,25 +134,27 @@ export async function applyInstall(state: ControlPlaneState): Promise<void> {
133
134
  // non-root containers).
134
135
  ensureComposeVolumeTargets(state);
135
136
  } finally {
136
- releaseLock(lock);
137
+ releaseInstallLock(lock);
137
138
  }
138
139
  }
139
140
 
140
141
  export async function applyUpdate(state: ControlPlaneState): Promise<{ restarted: string[] }> {
141
- const lock = acquireLock(state.homeDir, "update");
142
+ const lock = acquireInstallLock(state.stateDir);
143
+ if (!lock) throw new Error("Another install is already in progress");
142
144
  try {
143
145
  return { restarted: await reconcileCore(state, {}) };
144
146
  } finally {
145
- releaseLock(lock);
147
+ releaseInstallLock(lock);
146
148
  }
147
149
  }
148
150
 
149
151
  export async function applyUninstall(state: ControlPlaneState): Promise<{ stopped: string[] }> {
150
- const lock = acquireLock(state.homeDir, "uninstall");
152
+ const lock = acquireInstallLock(state.stateDir);
153
+ if (!lock) throw new Error("Another install is already in progress");
151
154
  try {
152
155
  return { stopped: await reconcileCore(state, { deactivateServices: true }) };
153
156
  } finally {
154
- releaseLock(lock);
157
+ releaseInstallLock(lock);
155
158
  }
156
159
  }
157
160
 
@@ -218,13 +221,14 @@ export async function applyUpgrade(
218
221
  updated: string[];
219
222
  restarted: string[];
220
223
  }> {
221
- const lock = acquireLock(state.homeDir, "upgrade");
224
+ const lock = acquireInstallLock(state.stateDir);
225
+ if (!lock) throw new Error("Another install is already in progress");
222
226
  try {
223
227
  const { backupDir, updated } = await refreshCoreAssets();
224
228
  const restarted = await reconcileCore(state, {});
225
229
  return { backupDir, updated, restarted };
226
230
  } finally {
227
- releaseLock(lock);
231
+ releaseInstallLock(lock);
228
232
  }
229
233
  }
230
234
 
@@ -296,6 +300,24 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
296
300
  };
297
301
  }
298
302
 
303
+ /**
304
+ * Set a specific image tag in stack.env then pull images and restart containers.
305
+ * Used by the admin "set version" action — skips the auto-detect step in performUpgrade.
306
+ */
307
+ export async function applyTagChange(state: ControlPlaneState, tag: string): Promise<UpgradeResult> {
308
+ const stackEnvPath = `${state.stackDir}/stack.env`;
309
+ const currentContent = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
310
+ writeFileSync(stackEnvPath, mergeEnvContent(currentContent, { OP_IMAGE_TAG: tag }, { uncomment: true }));
311
+ const upgradeResult = await applyUpgrade(state);
312
+ return {
313
+ imageTag: tag,
314
+ namespace: "openpalm",
315
+ backupDir: upgradeResult.backupDir,
316
+ assetsUpdated: upgradeResult.updated,
317
+ restarted: upgradeResult.restarted,
318
+ };
319
+ }
320
+
299
321
  export function buildComposeFileList(state: ControlPlaneState): string[] {
300
322
  return discoverStackOverlays(state.stackDir);
301
323
  }
@@ -0,0 +1,130 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, rmSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { resolveOperatorIds, hasUsableOperatorId } from "./operator-ids.js";
6
+
7
+ let tempDir = "";
8
+
9
+ beforeEach(() => {
10
+ tempDir = mkdtempSync(join(tmpdir(), "openpalm-opids-"));
11
+ });
12
+
13
+ afterEach(() => {
14
+ rmSync(tempDir, { recursive: true, force: true });
15
+ });
16
+
17
+ describe("resolveOperatorIds", () => {
18
+ test("returns the homeDir's owner when it exists and is non-root", () => {
19
+ // A mkdtemp directory is owned by the current process — neither
20
+ // root (in any reasonable test env) nor a hard-coded 1000.
21
+ const expected = statSync(tempDir);
22
+ const ids = resolveOperatorIds(tempDir);
23
+ if (process.platform === "win32") {
24
+ expect(ids).toBeNull();
25
+ return;
26
+ }
27
+ expect(ids).not.toBeNull();
28
+ expect(ids!.uid).toBe(expected.uid);
29
+ expect(ids!.gid).toBe(expected.gid);
30
+ });
31
+
32
+ test("falls back to process UID when homeDir does not exist", () => {
33
+ const missing = join(tempDir, "does-not-exist");
34
+ const ids = resolveOperatorIds(missing);
35
+ if (process.platform === "win32") {
36
+ expect(ids).toBeNull();
37
+ return;
38
+ }
39
+ expect(ids).not.toBeNull();
40
+ // process.getuid is guaranteed on POSIX runtimes used by this test
41
+ expect(ids!.uid).toBe(process.getuid!());
42
+ expect(ids!.gid).toBe(process.getgid!());
43
+ });
44
+
45
+ test("never returns 0 (root) — falls back to process UID when homeDir is root-owned", () => {
46
+ // We can't easily chown a dir to root without root. Instead, exercise
47
+ // the branch via a faked statSync output: build a path that triggers
48
+ // the "owner is 0, prefer process UID" code path by ensuring real
49
+ // tempDir owner is the process UID and asserting the result for a
50
+ // missing path matches process UID (already covered above). The
51
+ // explicit 0-check is enforced by the implementation; this test
52
+ // documents that the function never *returns* 0 for any of the
53
+ // exercised inputs in a non-root test process.
54
+ const ids = resolveOperatorIds(tempDir);
55
+ if (process.platform === "win32") {
56
+ expect(ids).toBeNull();
57
+ return;
58
+ }
59
+ expect(ids).not.toBeNull();
60
+ expect(ids!.uid).toBeGreaterThan(0);
61
+ expect(ids!.gid).toBeGreaterThan(0);
62
+ });
63
+
64
+ test("returns null when BOTH homeDir owner and process UID/GID are 0 (root install on root-owned OP_HOME)", () => {
65
+ if (process.platform === "win32") {
66
+ // win32 short-circuits before any of this logic
67
+ expect(resolveOperatorIds(tempDir)).toBeNull();
68
+ return;
69
+ }
70
+
71
+ // Stub process.getuid / getgid to simulate running as root. On Linux,
72
+ // `/` is owned by uid=0 gid=0, so passing "/" gives us a root-owned
73
+ // homeDir. Combined with the stubbed process IDs, this hits the
74
+ // "both signals are root" branch that previously returned {0,0}.
75
+ const origGetuid = process.getuid;
76
+ const origGetgid = process.getgid;
77
+ try {
78
+ (process as unknown as { getuid: () => number }).getuid = () => 0;
79
+ (process as unknown as { getgid: () => number }).getgid = () => 0;
80
+ // Sanity-check the assumption that "/" is root-owned in this env
81
+ // before relying on it as a fixture. On macOS / Linux CI runners
82
+ // this holds; if a future weird env breaks it, the assertion
83
+ // surfaces clearly rather than producing a confusing pass.
84
+ const rootStat = statSync("/");
85
+ expect(rootStat.uid).toBe(0);
86
+ expect(rootStat.gid).toBe(0);
87
+
88
+ const ids = resolveOperatorIds("/");
89
+ expect(ids).toBeNull();
90
+ } finally {
91
+ (process as unknown as { getuid: typeof origGetuid }).getuid = origGetuid;
92
+ (process as unknown as { getgid: typeof origGetgid }).getgid = origGetgid;
93
+ }
94
+ });
95
+
96
+ test("returns null on win32", () => {
97
+ // This test is informational; on non-win32 it doesn't run the win32
98
+ // branch. The check is left here for documentation and runs as a
99
+ // no-op assertion on POSIX.
100
+ if (process.platform === "win32") {
101
+ expect(resolveOperatorIds(tempDir)).toBeNull();
102
+ } else {
103
+ // No-op: confirms the test compiles and the helper is callable.
104
+ expect(typeof resolveOperatorIds).toBe("function");
105
+ }
106
+ });
107
+ });
108
+
109
+ describe("hasUsableOperatorId", () => {
110
+ test("returns true for positive numeric values", () => {
111
+ expect(hasUsableOperatorId({ OP_UID: "1000" }, "OP_UID")).toBe(true);
112
+ expect(hasUsableOperatorId({ OP_GID: "501" }, "OP_GID")).toBe(true);
113
+ });
114
+
115
+ test("returns false for missing key", () => {
116
+ expect(hasUsableOperatorId({}, "OP_UID")).toBe(false);
117
+ });
118
+
119
+ test("returns false for empty string", () => {
120
+ expect(hasUsableOperatorId({ OP_UID: "" }, "OP_UID")).toBe(false);
121
+ });
122
+
123
+ test("returns false for zero", () => {
124
+ expect(hasUsableOperatorId({ OP_UID: "0" }, "OP_UID")).toBe(false);
125
+ });
126
+
127
+ test("returns false for non-numeric garbage", () => {
128
+ expect(hasUsableOperatorId({ OP_UID: "abc" }, "OP_UID")).toBe(false);
129
+ });
130
+ });