@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
@@ -8,25 +8,23 @@ import {
8
8
  resolveConfigDir,
9
9
  resolveStashDir,
10
10
  resolveWorkspaceDir,
11
- resolveCacheDir,
12
- resolveStateDir,
11
+ resolveDataDir,
13
12
  resolveStackDir,
14
13
  } from "./home.js";
15
- import { ensureSecrets } from "./secrets.js";
14
+ import { ensureSecrets, readStackSecretEnv } from "./secrets.js";
16
15
  import {
17
16
  resolveRuntimeFiles,
18
17
  writeRuntimeFiles,
19
- buildEnvFiles,
20
18
  discoverStackOverlays,
21
19
  ensureComposeVolumeTargets,
22
20
  } from "./config-persistence.js";
23
- import { readStackSpec } from "./stack-spec.js";
24
21
  import { refreshCoreAssets } from "./core-assets.js";
25
22
  import { isSetupComplete } from "./setup-status.js";
26
23
  import { snapshotCurrentState } from "./rollback.js";
27
24
  import { checkDocker, composePreflight, composePull, composeUp, composeConfigServices, resolveComposeProjectName } from "./docker.js";
25
+ import { buildComposeOptions } from "./compose-args.js";
28
26
  import { acquireInstallLock, releaseInstallLock } from "./install-lock.js";
29
- import { listEnabledAddonIds } from "./registry.js";
27
+ import { getAddonServiceNames, listEnabledAddonIds } from "./registry.js";
30
28
 
31
29
  const IMAGE_NAMESPACE_RE = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
32
30
  const SEMVER_TAG_RE = /^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
@@ -37,8 +35,7 @@ export function createState(): ControlPlaneState {
37
35
  const configDir = resolveConfigDir();
38
36
  const stashDir = resolveStashDir();
39
37
  const workspaceDir = resolveWorkspaceDir();
40
- const cacheDir = resolveCacheDir();
41
- const stateDir = resolveStateDir();
38
+ const dataDir = resolveDataDir();
42
39
  const stackDir = resolveStackDir();
43
40
 
44
41
  const services: Record<string, "running" | "stopped"> = {};
@@ -51,8 +48,7 @@ export function createState(): ControlPlaneState {
51
48
  configDir,
52
49
  stashDir,
53
50
  workspaceDir,
54
- cacheDir,
55
- stateDir,
51
+ dataDir,
56
52
  stackDir,
57
53
  services,
58
54
  artifacts: { compose: "" },
@@ -60,6 +56,7 @@ export function createState(): ControlPlaneState {
60
56
  };
61
57
 
62
58
  ensureSecrets(bootstrapState);
59
+ Object.assign(process.env, readStackSecretEnv(stackDir));
63
60
 
64
61
  return bootstrapState;
65
62
  }
@@ -74,7 +71,7 @@ async function reconcileCore(
74
71
  }
75
72
 
76
73
  for (const addonName of listEnabledAddonIds(state.homeDir)) {
77
- mkdirSync(`${state.stateDir}/${addonName}`, { recursive: true });
74
+ mkdirSync(`${state.dataDir}/${addonName}`, { recursive: true });
78
75
  }
79
76
 
80
77
  const active: string[] = [];
@@ -89,8 +86,7 @@ async function reconcileCore(
89
86
  // Preflight: validate compose merge before mutation.
90
87
  // Mandatory when compose files exist and OP_SKIP_COMPOSE_PREFLIGHT is not set.
91
88
  // Fails if Docker is unavailable (Docker is required for any compose operation).
92
- const files = buildComposeFileList(state);
93
- const envFiles = buildEnvFiles(state);
89
+ const { files, envFiles, profiles } = buildComposeOptions(state);
94
90
  if (files.length > 0 && !process.env.OP_SKIP_COMPOSE_PREFLIGHT) {
95
91
  const dockerCheck = await checkDocker();
96
92
  if (!dockerCheck.ok) {
@@ -99,12 +95,13 @@ async function reconcileCore(
99
95
  "Docker must be running before install/update/apply operations."
100
96
  );
101
97
  }
102
- const preflight = await composePreflight({ files, envFiles });
98
+ const preflight = await composePreflight({ files, envFiles, profiles });
103
99
  if (!preflight.ok) {
104
- const projectName = resolveComposeProjectName();
100
+ const projectName = resolveComposeProjectName(Object.assign({}, ...envFiles.map((f) => parseEnvFile(f))));
105
101
  const fileArgs = files.flatMap((f) => ["-f", f]).join(" ");
106
102
  const envArgs = envFiles.filter(existsSync).flatMap((f) => ["--env-file", f]).join(" ");
107
- const resolvedCmd = `docker compose ${fileArgs} --project-name ${projectName} ${envArgs} config --quiet`;
103
+ const profileArgs = profiles.flatMap((p) => ["--profile", p]).join(" ");
104
+ const resolvedCmd = `docker compose ${fileArgs} --project-name ${projectName} ${envArgs} ${profileArgs} config --quiet`;
108
105
  throw new Error(
109
106
  `Compose preflight failed: ${preflight.stderr}\n` +
110
107
  `Resolved command: ${resolvedCmd}\n` +
@@ -125,7 +122,7 @@ async function reconcileCore(
125
122
  }
126
123
 
127
124
  export async function applyInstall(state: ControlPlaneState): Promise<void> {
128
- const lock = acquireInstallLock(state.stateDir);
125
+ const lock = acquireInstallLock(state.dataDir);
129
126
  if (!lock) throw new Error("Another install is already in progress");
130
127
  try {
131
128
  await reconcileCore(state, { activateServices: true });
@@ -139,7 +136,7 @@ export async function applyInstall(state: ControlPlaneState): Promise<void> {
139
136
  }
140
137
 
141
138
  export async function applyUpdate(state: ControlPlaneState): Promise<{ restarted: string[] }> {
142
- const lock = acquireInstallLock(state.stateDir);
139
+ const lock = acquireInstallLock(state.dataDir);
143
140
  if (!lock) throw new Error("Another install is already in progress");
144
141
  try {
145
142
  return { restarted: await reconcileCore(state, {}) };
@@ -149,7 +146,7 @@ export async function applyUpdate(state: ControlPlaneState): Promise<{ restarted
149
146
  }
150
147
 
151
148
  export async function applyUninstall(state: ControlPlaneState): Promise<{ stopped: string[] }> {
152
- const lock = acquireInstallLock(state.stateDir);
149
+ const lock = acquireInstallLock(state.dataDir);
153
150
  if (!lock) throw new Error("Another install is already in progress");
154
151
  try {
155
152
  return { stopped: await reconcileCore(state, { deactivateServices: true }) };
@@ -179,7 +176,7 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
179
176
  namespace: string;
180
177
  tag: string;
181
178
  }> {
182
- const systemEnvPath = `${state.stackDir}/stack.env`;
179
+ const systemEnvPath = `${state.stashDir}/env/stack.env`;
183
180
  const parsed = parseEnvFile(systemEnvPath);
184
181
  const namespace = (parsed.OP_IMAGE_NAMESPACE ?? process.env.OP_IMAGE_NAMESPACE ?? "openpalm").trim().toLowerCase();
185
182
 
@@ -187,10 +184,14 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
187
184
  throw new Error(`Invalid image namespace in system.env: ${namespace}`);
188
185
  }
189
186
 
187
+ // `assistant` is the version-of-record image: all platform images
188
+ // (assistant, guardian, channel, voice) are published in lockstep under the
189
+ // same OP_IMAGE_TAG, so its newest tag is the canonical platform version.
190
+
190
191
  let response: Response;
191
192
  try {
192
193
  response = await fetch(
193
- `https://registry.hub.docker.com/v2/repositories/${namespace}/admin/tags?page_size=25&ordering=last_updated`,
194
+ `https://registry.hub.docker.com/v2/repositories/${namespace}/assistant/tags?page_size=25&ordering=last_updated`,
194
195
  { headers: { Accept: "application/json" } }
195
196
  );
196
197
  } catch (e) {
@@ -215,16 +216,18 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
215
216
  }
216
217
 
217
218
  export async function applyUpgrade(
218
- state: ControlPlaneState
219
+ state: ControlPlaneState,
220
+ /** Release tag whose stack assets to fetch (e.g. "v0.11.0-rc.6"). Caller-supplied. */
221
+ version: string
219
222
  ): Promise<{
220
223
  backupDir: string | null;
221
224
  updated: string[];
222
225
  restarted: string[];
223
226
  }> {
224
- const lock = acquireInstallLock(state.stateDir);
227
+ const lock = acquireInstallLock(state.dataDir);
225
228
  if (!lock) throw new Error("Another install is already in progress");
226
229
  try {
227
- const { backupDir, updated } = await refreshCoreAssets();
230
+ const { backupDir, updated } = await refreshCoreAssets(version);
228
231
  const restarted = await reconcileCore(state, {});
229
232
  return { backupDir, updated, restarted };
230
233
  } finally {
@@ -247,15 +250,14 @@ export type UpgradeResult = {
247
250
  * Callers handle their own audit logging and admin self-recreation.
248
251
  */
249
252
  export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeResult> {
250
- const files = buildComposeFileList(state);
251
- const envFiles = buildEnvFiles(state);
253
+ const composeOpts = buildComposeOptions(state);
252
254
 
253
255
  // Compose preflight runs inside `applyUpgrade` -> `reconcileCore`, so we
254
256
  // skip the redundant top-level call. Any merge failure aborts before
255
257
  // mutation just the same.
256
258
 
257
259
  // 1. Snapshot stack.env for rollback on failure
258
- const stackEnvPath = `${state.stackDir}/stack.env`;
260
+ const stackEnvPath = `${state.stashDir}/env/stack.env`;
259
261
  let originalStackEnv: string | null = null;
260
262
  try {
261
263
  originalStackEnv = readFileSync(stackEnvPath, "utf-8");
@@ -269,7 +271,9 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
269
271
  const tagResult = await updateStackEnvToLatestImageTag(state);
270
272
  imageTag = tagResult.tag;
271
273
  namespace = tagResult.namespace;
272
- upgradeResult = await applyUpgrade(state);
274
+ // The resolved platform tag IS the version whose stack assets we fetch —
275
+ // keeps compose files and images in lockstep.
276
+ upgradeResult = await applyUpgrade(state, imageTag);
273
277
  } catch (e) {
274
278
  // Restore stack.env on failure
275
279
  if (originalStackEnv !== null) {
@@ -278,15 +282,15 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
278
282
  throw e;
279
283
  }
280
284
 
281
- // 3. Pull images
282
- const pullResult = await composePull({ files, envFiles });
285
+ // 3. Pull all images (core + addons, including profile-gated voice)
286
+ const pullResult = await composePull(composeOpts);
283
287
  if (!pullResult.ok) {
284
288
  throw new Error(`Failed to pull images: ${pullResult.stderr}`);
285
289
  }
286
290
 
287
- // 4. Recreate containers
291
+ // 4. Recreate containers (includes profiles for voice addon)
288
292
  const services = await buildManagedServices(state);
289
- const upResult = await composeUp({ files, envFiles, services, removeOrphans: true });
293
+ const upResult = await composeUp({ ...composeOpts, services, removeOrphans: true });
290
294
  if (!upResult.ok) {
291
295
  throw new Error(`Images pulled but failed to recreate containers: ${upResult.stderr}`);
292
296
  }
@@ -305,10 +309,10 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
305
309
  * Used by the admin "set version" action — skips the auto-detect step in performUpgrade.
306
310
  */
307
311
  export async function applyTagChange(state: ControlPlaneState, tag: string): Promise<UpgradeResult> {
308
- const stackEnvPath = `${state.stackDir}/stack.env`;
312
+ const stackEnvPath = `${state.stashDir}/env/stack.env`;
309
313
  const currentContent = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
310
314
  writeFileSync(stackEnvPath, mergeEnvContent(currentContent, { OP_IMAGE_TAG: tag }, { uncomment: true }));
311
- const upgradeResult = await applyUpgrade(state);
315
+ const upgradeResult = await applyUpgrade(state, tag);
312
316
  return {
313
317
  imageTag: tag,
314
318
  namespace: "openpalm",
@@ -319,16 +323,15 @@ export async function applyTagChange(state: ControlPlaneState, tag: string): Pro
319
323
  }
320
324
 
321
325
  export function buildComposeFileList(state: ControlPlaneState): string[] {
322
- return discoverStackOverlays(state.stackDir);
326
+ return discoverStackOverlays(state.stackDir, state.homeDir);
323
327
  }
324
328
 
325
329
  export async function buildManagedServices(state: ControlPlaneState): Promise<string[]> {
326
- const files = buildComposeFileList(state);
327
- const envFiles = buildEnvFiles(state);
330
+ const composeOpts = buildComposeOptions(state);
328
331
 
329
332
  // Prefer compose-derived service list when Docker is available
330
- if (files.length > 0 && !process.env.OP_SKIP_COMPOSE_PREFLIGHT) {
331
- const result = await composeConfigServices({ files, envFiles });
333
+ if (composeOpts.files.length > 0 && !process.env.OP_SKIP_COMPOSE_PREFLIGHT) {
334
+ const result = await composeConfigServices(composeOpts);
332
335
  if (result.ok && result.services.length > 0) {
333
336
  return result.services;
334
337
  }
@@ -336,7 +339,9 @@ export async function buildManagedServices(state: ControlPlaneState): Promise<st
336
339
 
337
340
  // Fallback: static inference from CORE_SERVICES + active addon overlays
338
341
  const services: string[] = [...CORE_SERVICES];
339
- services.push(...listEnabledAddonIds(state.homeDir));
342
+ for (const addon of listEnabledAddonIds(state.homeDir)) {
343
+ services.push(...getAddonServiceNames(state.homeDir, addon));
344
+ }
340
345
  return services;
341
346
  }
342
347
 
@@ -1,21 +1,18 @@
1
1
  /**
2
- * AKM markdown task parser.
2
+ * AKM task parser.
3
3
  *
4
- * Task files are markdown with YAML frontmatter. The frontmatter defines the
5
- * schedule and target; for inline-prompt tasks the markdown body is the prompt.
6
- *
7
- * Supported target types:
8
- * command — `command: [...]` YAML array (argv), run via Bun.spawn / akm tasks run
9
- * prompt — `prompt: inline` + markdown body as the prompt text
4
+ * Task files are YAML documents in knowledge/tasks/. Supported target types:
5
+ * command — `command: [...]` YAML array (argv)
6
+ * prompt — `prompt: <text>` inline prompt text
10
7
  * workflow — `workflow: workflow:<ref>` + optional `params` map
11
8
  */
12
9
  import { parse as parseYaml } from "yaml";
13
10
  import { existsSync, readdirSync, readFileSync } from "node:fs";
14
- import { join } from "node:path";
11
+ import { basename, join } from "node:path";
15
12
  import type { AutomationConfig } from "./scheduler.js";
16
13
  import { createLogger } from "../logger.js";
17
14
 
18
- const logger = createLogger("markdown-task");
15
+ const logger = createLogger("task-file");
19
16
 
20
17
  // ── Types ─────────────────────────────────────────────────────────────────
21
18
 
@@ -35,29 +32,11 @@ export type MarkdownTaskTarget =
35
32
  | { kind: "prompt"; profile?: string; body: string }
36
33
  | { kind: "workflow"; ref: string; params: Record<string, unknown> };
37
34
 
38
- // ── Frontmatter splitter ──────────────────────────────────────────────────
39
-
40
- interface ParsedFile {
41
- frontmatter: string;
42
- body: string;
43
- }
44
-
45
- function splitFrontmatter(content: string): ParsedFile | null {
46
- // Must start with ---
47
- if (!content.startsWith("---")) return null;
48
- const after = content.slice(3);
49
- const end = after.indexOf("\n---");
50
- if (end === -1) return null;
51
- return {
52
- frontmatter: after.slice(0, end).trim(),
53
- body: after.slice(end + 4).trim(),
54
- };
55
- }
56
-
57
35
  // ── Parser ────────────────────────────────────────────────────────────────
58
36
 
59
37
  export function parseMarkdownTask(filePath: string): MarkdownTask | null {
60
- const id = filePath.replace(/.*[\\/]/, "").replace(/\.md$/, "");
38
+ const fileName = basename(filePath);
39
+ const id = fileName.replace(/\.(?:ya?ml|md)$/, "");
61
40
  let raw: string;
62
41
  try {
63
42
  raw = readFileSync(filePath, "utf-8");
@@ -66,22 +45,17 @@ export function parseMarkdownTask(filePath: string): MarkdownTask | null {
66
45
  return null;
67
46
  }
68
47
 
69
- const parts = splitFrontmatter(raw);
70
- if (!parts) {
71
- logger.warn("task file missing frontmatter delimiters", { filePath });
72
- return null;
73
- }
74
-
48
+ const { frontmatter, body } = splitTaskSource(raw);
75
49
  let fm: Record<string, unknown>;
76
50
  try {
77
- fm = parseYaml(parts.frontmatter) as Record<string, unknown>;
51
+ const parsed = parseYaml(frontmatter);
52
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
53
+ logger.warn("task YAML is not an object", { filePath });
54
+ return null;
55
+ }
56
+ fm = parsed as Record<string, unknown>;
78
57
  } catch (err) {
79
- logger.warn("failed to parse task frontmatter", { filePath, error: String(err) });
80
- return null;
81
- }
82
-
83
- if (!fm || typeof fm !== "object") {
84
- logger.warn("task frontmatter is not an object", { filePath });
58
+ logger.warn("failed to parse task YAML", { filePath, error: String(err) });
85
59
  return null;
86
60
  }
87
61
 
@@ -106,19 +80,19 @@ export function parseMarkdownTask(filePath: string): MarkdownTask | null {
106
80
  }
107
81
  target = { kind: "command", cmd };
108
82
  } else if (fm.prompt !== undefined) {
109
- if (fm.prompt !== "inline") {
110
- // Future: handle asset-ref and file-path prompt sources
111
- logger.warn("task 'prompt' supports only 'inline' currently", { filePath });
83
+ if (typeof fm.prompt !== "string" || !fm.prompt.trim()) {
84
+ logger.warn("task 'prompt' must be a non-empty string", { filePath });
112
85
  return null;
113
86
  }
114
- if (!parts.body) {
115
- logger.warn("prompt:inline task has no markdown body", { filePath });
87
+ const promptBody = fm.prompt.trim() === "inline" ? body.trim() : fm.prompt.trim();
88
+ if (!promptBody) {
89
+ logger.warn("task prompt body is empty", { filePath });
116
90
  return null;
117
91
  }
118
92
  target = {
119
93
  kind: "prompt",
120
94
  profile: typeof fm.profile === "string" ? fm.profile : undefined,
121
- body: parts.body,
95
+ body: promptBody,
122
96
  };
123
97
  } else if (fm.workflow !== undefined) {
124
98
  if (typeof fm.workflow !== "string") {
@@ -149,13 +123,19 @@ export function parseMarkdownTask(filePath: string): MarkdownTask | null {
149
123
  };
150
124
  }
151
125
 
126
+ function splitTaskSource(raw: string): { frontmatter: string; body: string } {
127
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
128
+ if (!match) return { frontmatter: raw, body: "" };
129
+ return { frontmatter: match[1] ?? "", body: match[2] ?? "" };
130
+ }
131
+
152
132
  export function loadMarkdownTasks(stashDir: string): MarkdownTask[] {
153
133
  const dir = join(stashDir, "tasks");
154
134
  if (!existsSync(dir)) return [];
155
135
 
156
136
  const tasks: MarkdownTask[] = [];
157
137
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
158
- if (!entry.isFile() || !entry.name.endsWith(".md")) continue;
138
+ if (!entry.isFile() || (!entry.name.endsWith(".md") && !entry.name.endsWith(".yml") && !entry.name.endsWith(".yaml"))) continue;
159
139
  const task = parseMarkdownTask(join(dir, entry.name));
160
140
  if (task) tasks.push(task);
161
141
  }
@@ -195,6 +175,6 @@ export function taskToAutomationConfig(task: MarkdownTask): AutomationConfig {
195
175
  agent,
196
176
  },
197
177
  on_failure: "log",
198
- fileName: `${task.id}.md`,
178
+ fileName: basename(task.source.path),
199
179
  };
200
180
  }
@@ -0,0 +1,272 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, readdirSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { ensureMigrated, MigrationError, CURRENT_LAYOUT_VERSION } from "./migrations.js";
6
+
7
+ // The harness resolves all paths from OP_HOME; point it at a synthetic 0.10 home.
8
+ let home: string;
9
+ let prevOpHome: string | undefined;
10
+
11
+ function seed010(h: string): void {
12
+ mkdirSync(join(h, "vault", "user"), { recursive: true });
13
+ mkdirSync(join(h, "vault", "stack", "services"), { recursive: true });
14
+ mkdirSync(join(h, "config"), { recursive: true });
15
+ mkdirSync(join(h, "data"), { recursive: true });
16
+ writeFileSync(join(h, "vault", "user", "user.env"), "MY_PREF=hello\n");
17
+ writeFileSync(
18
+ join(h, "vault", "stack", "stack.env"),
19
+ [
20
+ "# system env",
21
+ "OP_HOME=/x/.openpalm",
22
+ "OP_ADMIN_PORT=9000",
23
+ "OPENAI_API_KEY=sk-secret123",
24
+ "OP_CAP_LLM_MODEL=gpt-4",
25
+ "TTS_VOICE=alloy",
26
+ "OP_UI_LOGIN_PASSWORD=hunter2",
27
+ "OP_ASSISTANT_PORT=3800",
28
+ "",
29
+ ].join("\n"),
30
+ );
31
+ writeFileSync(
32
+ join(h, "vault", "stack", "guardian.env"),
33
+ "CHANNEL_DISCORD_SECRET=disc-abc\nCHANNEL_SLACK_SECRET=slack-xyz\n",
34
+ );
35
+ writeFileSync(join(h, "vault", "stack", "services", "some.secret"), "svc-val\n");
36
+ writeFileSync(join(h, "vault", "user", "apprise.yaml"), "urls:\n - mailto://x\n");
37
+ writeFileSync(join(h, "config", "stack.yml"), "version: 1\ncapabilities:\n llm: openai\n");
38
+ }
39
+
40
+ /** Sorted top-level entry names under a directory. */
41
+ function entries(dir: string): string[] {
42
+ return readdirSync(dir).sort();
43
+ }
44
+
45
+ beforeEach(() => {
46
+ prevOpHome = process.env.OP_HOME;
47
+ home = mkdtempSync(join(tmpdir(), "op-migrate-"));
48
+ process.env.OP_HOME = home;
49
+ });
50
+
51
+ afterEach(() => {
52
+ if (prevOpHome === undefined) delete process.env.OP_HOME;
53
+ else process.env.OP_HOME = prevOpHome;
54
+ rmSync(home, { recursive: true, force: true });
55
+ });
56
+
57
+ describe("ensureMigrated 0.10 → 0.11", () => {
58
+ it("migrates the vault layout, backs up, and stamps the layout version", () => {
59
+ seed010(home);
60
+ const report = ensureMigrated();
61
+
62
+ expect(report.migrated).toBe(true);
63
+ expect(report.from).toBe(0);
64
+ expect(report.to).toBe(CURRENT_LAYOUT_VERSION);
65
+ expect(report.backupDir).toBeTruthy();
66
+ expect(existsSync(report.backupDir!)).toBe(true);
67
+
68
+ const stackEnv = readFileSync(join(home, "knowledge", "env", "stack.env"), "utf-8");
69
+ expect(stackEnv).toContain("OP_HOST_UI_PORT=9000"); // renamed
70
+ expect(stackEnv).toContain("OP_TTS_VOICE=alloy"); // prefixed
71
+ expect(stackEnv).toContain("OP_ASSISTANT_PORT=3800"); // kept
72
+ expect(stackEnv).toContain(`OP_LAYOUT_VERSION=${CURRENT_LAYOUT_VERSION}`); // commit
73
+ expect(stackEnv).not.toContain("OPENAI_API_KEY"); // quarantined
74
+ expect(stackEnv).not.toContain("OP_CAP_LLM_MODEL"); // quarantined
75
+
76
+ expect(readFileSync(join(home, "knowledge", "env", "stack.env.removed-secrets.bak"), "utf-8"))
77
+ .toContain("OPENAI_API_KEY=sk-secret123");
78
+ expect(readFileSync(join(home, "knowledge", "secrets", "op_ui_login_password"), "utf-8").trim())
79
+ .toBe("hunter2");
80
+ expect(readFileSync(join(home, "knowledge", "secrets", "channel_discord_secret"), "utf-8").trim())
81
+ .toBe("disc-abc");
82
+ expect(readFileSync(join(home, "knowledge", "secrets", "channel_slack_secret"), "utf-8").trim())
83
+ .toBe("slack-xyz");
84
+ expect(existsSync(join(home, "knowledge", "secrets", "some.secret"))).toBe(true);
85
+ expect(existsSync(join(home, "knowledge", "secrets", "apprise.yaml"))).toBe(true);
86
+ // stack.yml is removed in 0.11.0 — the migration must NOT create one.
87
+ expect(existsSync(join(home, "config", "stack", "stack.yml"))).toBe(false);
88
+ expect(readFileSync(join(home, "knowledge", "env", "user.env"), "utf-8")).toContain("MY_PREF=hello");
89
+
90
+ // Non-destructive: originals untouched.
91
+ expect(existsSync(join(home, "vault", "stack", "stack.env"))).toBe(true);
92
+ });
93
+
94
+ it("ends with exactly the expected 0.11 directories and every datum in its proper location", () => {
95
+ seed010(home);
96
+ ensureMigrated();
97
+
98
+ // Only the expected top-level directories exist. The legacy vault/ is
99
+ // intentionally retained (copy-only recovery copy); nothing stray is created.
100
+ expect(entries(home)).toEqual(["config", "data", "knowledge", "vault"]);
101
+
102
+ // knowledge/ holds exactly the env + secrets stores.
103
+ expect(entries(join(home, "knowledge"))).toEqual(["env", "secrets"]);
104
+
105
+ // Every migrated datum landed in its proper 0.11 location — no missing, no extra.
106
+ expect(entries(join(home, "knowledge", "env"))).toEqual([
107
+ "stack.env",
108
+ "stack.env.removed-secrets.bak",
109
+ "user.env",
110
+ ]);
111
+ expect(entries(join(home, "knowledge", "secrets"))).toEqual([
112
+ "apprise.yaml",
113
+ "channel_discord_secret",
114
+ "channel_slack_secret",
115
+ "op_ui_login_password",
116
+ "some.secret",
117
+ ]);
118
+
119
+ // The full backup landed under data/backups (and nowhere else top-level).
120
+ expect(existsSync(join(home, "data", "backups"))).toBe(true);
121
+
122
+ // The retained vault/ carries a safe-removal README.
123
+ expect(existsSync(join(home, "vault", "README.md"))).toBe(true);
124
+
125
+ // Nothing leaked into a wrong place: no 0.11 secrets under knowledge/env,
126
+ // and no plaintext login password left inside the migrated stack.env.
127
+ expect(existsSync(join(home, "knowledge", "env", "op_ui_login_password"))).toBe(false);
128
+ expect(readFileSync(join(home, "knowledge", "env", "stack.env"), "utf-8"))
129
+ .not.toContain("hunter2");
130
+ });
131
+
132
+ it("migrates a minimal home (only stack.env) without creating stray files", () => {
133
+ mkdirSync(join(home, "vault", "stack"), { recursive: true });
134
+ mkdirSync(join(home, "data"), { recursive: true });
135
+ writeFileSync(
136
+ join(home, "vault", "stack", "stack.env"),
137
+ "OP_IMAGE_TAG=0.10.2\nOP_ASSISTANT_PORT=3800\n",
138
+ );
139
+ const report = ensureMigrated();
140
+ expect(report.migrated).toBe(true);
141
+
142
+ // env/ has only stack.env — no user.env, no removed-secrets.bak (there were
143
+ // no secrets/cap keys to quarantine).
144
+ expect(entries(join(home, "knowledge", "env"))).toEqual(["stack.env"]);
145
+ // secrets/ exists (created) but is empty — nothing to migrate.
146
+ expect(entries(join(home, "knowledge", "secrets"))).toEqual([]);
147
+ const stackEnv = readFileSync(join(home, "knowledge", "env", "stack.env"), "utf-8");
148
+ expect(stackEnv).toContain("OP_IMAGE_TAG=0.10.2");
149
+ expect(stackEnv).toContain(`OP_LAYOUT_VERSION=${CURRENT_LAYOUT_VERSION}`);
150
+ });
151
+
152
+ it("does not write a removed-secrets.bak when stack.env has no secret/cap keys", () => {
153
+ mkdirSync(join(home, "vault", "stack"), { recursive: true });
154
+ mkdirSync(join(home, "data"), { recursive: true });
155
+ writeFileSync(join(home, "vault", "stack", "stack.env"), "OP_ASSISTANT_PORT=3800\n");
156
+ ensureMigrated();
157
+ expect(existsSync(join(home, "knowledge", "env", "stack.env.removed-secrets.bak"))).toBe(false);
158
+ });
159
+
160
+ it("writes a safe-removal README into the retained vault/", () => {
161
+ seed010(home);
162
+ ensureMigrated();
163
+ const readme = readFileSync(join(home, "vault", "README.md"), "utf-8");
164
+ // It explains what the directory is and how to remove it safely.
165
+ expect(readme).toContain("RECOVERY COPY");
166
+ expect(readme).toContain("How to remove it safely");
167
+ expect(readme).toContain("gio trash");
168
+ expect(readme).toContain("data/backups");
169
+ // The original migrated files are still present (README is additive only).
170
+ expect(existsSync(join(home, "vault", "stack", "stack.env"))).toBe(true);
171
+ });
172
+
173
+ it("dry-run does not write the vault README", () => {
174
+ seed010(home);
175
+ ensureMigrated({ dryRun: true });
176
+ expect(existsSync(join(home, "vault", "README.md"))).toBe(false);
177
+ });
178
+
179
+ it("does not clobber a pre-existing vault/README.md", () => {
180
+ seed010(home);
181
+ writeFileSync(join(home, "vault", "README.md"), "user's own notes\n");
182
+ ensureMigrated();
183
+ expect(readFileSync(join(home, "vault", "README.md"), "utf-8")).toBe("user's own notes\n");
184
+ });
185
+
186
+ it("converts addons[] from a nested config/stack/stack.yml too", () => {
187
+ seed010(home);
188
+ rmSync(join(home, "config", "stack.yml"), { force: true });
189
+ mkdirSync(join(home, "config", "stack"), { recursive: true });
190
+ writeFileSync(join(home, "config", "stack", "stack.yml"), "version: 2\naddons:\n - voice\n");
191
+ ensureMigrated();
192
+ expect(readFileSync(join(home, "knowledge", "env", "stack.env"), "utf-8"))
193
+ .toContain("OP_ENABLED_ADDONS=voice");
194
+ });
195
+
196
+ it("normalizes channel secret names to lowercase and skips invalid ones", () => {
197
+ mkdirSync(join(home, "vault", "stack"), { recursive: true });
198
+ mkdirSync(join(home, "data"), { recursive: true });
199
+ writeFileSync(join(home, "vault", "stack", "stack.env"), "OP_ASSISTANT_PORT=3800\n");
200
+ writeFileSync(
201
+ join(home, "vault", "stack", "guardian.env"),
202
+ // valid (mixed case → lowercase), and an invalid name with a space (skipped).
203
+ "CHANNEL_Discord_SECRET=abc\nCHANNEL_BAD NAME_SECRET=nope\n",
204
+ );
205
+ ensureMigrated();
206
+ expect(existsSync(join(home, "knowledge", "secrets", "channel_discord_secret"))).toBe(true);
207
+ expect(entries(join(home, "knowledge", "secrets"))).toEqual(["channel_discord_secret"]);
208
+ });
209
+
210
+ it("preserves user-edited destination files (copy-only, skip-if-exists)", () => {
211
+ seed010(home);
212
+ // Simulate a partially-migrated home where the user already has a user.env.
213
+ mkdirSync(join(home, "knowledge", "env"), { recursive: true });
214
+ writeFileSync(join(home, "knowledge", "env", "user.env"), "MY_PREF=edited-by-user\n");
215
+ ensureMigrated();
216
+ // The existing destination must NOT be clobbered by the vault copy.
217
+ expect(readFileSync(join(home, "knowledge", "env", "user.env"), "utf-8"))
218
+ .toContain("edited-by-user");
219
+ });
220
+
221
+ it("copies auth.json best-effort and surfaces a verify-providers note", () => {
222
+ seed010(home);
223
+ writeFileSync(join(home, "vault", "stack", "auth.json"), '{"openai":{"type":"api"}}');
224
+ const report = ensureMigrated();
225
+ expect(existsSync(join(home, "knowledge", "secrets", "auth.json"))).toBe(true);
226
+ expect(report.notes.join(" ")).toContain("auth.json");
227
+ });
228
+
229
+ it("converts a legacy stack.yml addons[] into OP_ENABLED_ADDONS", () => {
230
+ seed010(home);
231
+ writeFileSync(join(home, "config", "stack.yml"), "version: 2\naddons:\n - voice\n - discord\n");
232
+ ensureMigrated();
233
+ const stackEnv = readFileSync(join(home, "knowledge", "env", "stack.env"), "utf-8");
234
+ expect(stackEnv).toContain("OP_ENABLED_ADDONS=discord,voice");
235
+ expect(existsSync(join(home, "config", "stack", "stack.yml"))).toBe(false);
236
+ });
237
+
238
+ it("is idempotent — a second run is a no-op", () => {
239
+ seed010(home);
240
+ ensureMigrated();
241
+ const second = ensureMigrated();
242
+ expect(second.migrated).toBe(false);
243
+ expect(second.to).toBe(CURRENT_LAYOUT_VERSION);
244
+ });
245
+
246
+ it("dry-run writes nothing", () => {
247
+ seed010(home);
248
+ const report = ensureMigrated({ dryRun: true });
249
+ expect(report.migrated).toBe(true);
250
+ expect(existsSync(join(home, "knowledge", "env", "stack.env"))).toBe(false);
251
+ expect(report.backupDir).toBeNull();
252
+ });
253
+
254
+ it("aborts (no changes) when the backup cannot be created", () => {
255
+ seed010(home);
256
+ // Make data/ a file so backupOpenPalmHome's mkdir of data/backups fails.
257
+ rmSync(join(home, "data"), { recursive: true, force: true });
258
+ writeFileSync(join(home, "data"), "not a dir");
259
+ expect(() => ensureMigrated()).toThrow(MigrationError);
260
+ expect(existsSync(join(home, "knowledge", "env", "stack.env"))).toBe(false);
261
+ });
262
+
263
+ it("treats an already-0.11 home (no vault) as current and stamps it", () => {
264
+ mkdirSync(join(home, "knowledge", "env"), { recursive: true });
265
+ writeFileSync(join(home, "knowledge", "env", "stack.env"), "OP_IMAGE_TAG=0.11.0\n");
266
+ const report = ensureMigrated();
267
+ expect(report.migrated).toBe(false);
268
+ expect(report.to).toBe(CURRENT_LAYOUT_VERSION);
269
+ expect(readFileSync(join(home, "knowledge", "env", "stack.env"), "utf-8"))
270
+ .toContain(`OP_LAYOUT_VERSION=${CURRENT_LAYOUT_VERSION}`);
271
+ });
272
+ });