@openpalm/lib 0.10.2 → 0.11.0-beta.2

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 (59) hide show
  1. package/README.md +2 -2
  2. package/package.json +7 -3
  3. package/src/control-plane/admin-token.ts +73 -0
  4. package/src/control-plane/akm-vault.test.ts +105 -0
  5. package/src/control-plane/akm-vault.ts +307 -0
  6. package/src/control-plane/channels.ts +3 -3
  7. package/src/control-plane/cleanup-guardrails.test.ts +8 -9
  8. package/src/control-plane/compose-args.test.ts +25 -24
  9. package/src/control-plane/compose-errors.test.ts +106 -0
  10. package/src/control-plane/compose-errors.ts +117 -0
  11. package/src/control-plane/config-persistence.ts +103 -65
  12. package/src/control-plane/core-assets.test.ts +104 -0
  13. package/src/control-plane/core-assets.ts +54 -57
  14. package/src/control-plane/docker.ts +55 -21
  15. package/src/control-plane/env.test.ts +25 -1
  16. package/src/control-plane/env.ts +80 -0
  17. package/src/control-plane/home.ts +66 -69
  18. package/src/control-plane/host-opencode.test.ts +260 -0
  19. package/src/control-plane/host-opencode.ts +229 -0
  20. package/src/control-plane/install-edge-cases.test.ts +187 -289
  21. package/src/control-plane/install-lock.ts +157 -0
  22. package/src/control-plane/lifecycle.ts +34 -65
  23. package/src/control-plane/markdown-task.ts +200 -0
  24. package/src/control-plane/migrate-0110.test.ts +177 -0
  25. package/src/control-plane/migrate-0110.ts +99 -0
  26. package/src/control-plane/paths.ts +82 -0
  27. package/src/control-plane/provider-config.ts +2 -2
  28. package/src/control-plane/provider-models.ts +154 -0
  29. package/src/control-plane/registry-components.test.ts +105 -27
  30. package/src/control-plane/registry.test.ts +49 -47
  31. package/src/control-plane/registry.ts +71 -50
  32. package/src/control-plane/rollback.ts +17 -16
  33. package/src/control-plane/scheduler.ts +75 -262
  34. package/src/control-plane/secret-backend.test.ts +98 -111
  35. package/src/control-plane/secret-backend.ts +221 -181
  36. package/src/control-plane/secret-mappings.ts +4 -8
  37. package/src/control-plane/secrets.ts +93 -51
  38. package/src/control-plane/setup-config.schema.json +5 -17
  39. package/src/control-plane/setup-status.ts +9 -29
  40. package/src/control-plane/setup-validation.ts +23 -23
  41. package/src/control-plane/setup.test.ts +138 -239
  42. package/src/control-plane/setup.ts +215 -130
  43. package/src/control-plane/skeleton-guardrail.test.ts +151 -0
  44. package/src/control-plane/spec-to-env.test.ts +59 -58
  45. package/src/control-plane/spec-to-env.ts +52 -142
  46. package/src/control-plane/spec-validator.ts +2 -99
  47. package/src/control-plane/stack-spec.test.ts +21 -77
  48. package/src/control-plane/stack-spec.ts +7 -83
  49. package/src/control-plane/types.ts +12 -28
  50. package/src/control-plane/ui-assets.ts +349 -0
  51. package/src/control-plane/validate.ts +44 -79
  52. package/src/index.ts +86 -48
  53. package/src/logger.test.ts +228 -0
  54. package/src/logger.ts +71 -1
  55. package/src/provider-constants.ts +22 -1
  56. package/src/control-plane/audit.ts +0 -40
  57. package/src/control-plane/env-schema-validation.test.ts +0 -118
  58. package/src/control-plane/memory-config.ts +0 -298
  59. package/src/control-plane/redact-schema.ts +0 -50
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Registry catalog discovery and refresh.
3
3
  *
4
- * `OP_HOME/registry` is the only persistent catalog location.
4
+ * `OP_HOME/state/registry` is the only persistent catalog location.
5
5
  * Install seeds it once; refresh replaces it explicitly.
6
6
  */
7
7
  import { cpSync, existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
@@ -46,10 +46,10 @@ export function isValidComponentName(name: string): boolean {
46
46
 
47
47
  const DEFAULT_REPO = 'itlackey/openpalm';
48
48
 
49
- export interface RegistryConfig {
49
+ export type RegistryConfig = {
50
50
  repoUrl: string;
51
51
  branch: string;
52
- }
52
+ };
53
53
 
54
54
  export function getRegistryConfig(): RegistryConfig {
55
55
  return {
@@ -63,7 +63,7 @@ export type RegistryAutomationEntry = {
63
63
  type: 'automation';
64
64
  description: string;
65
65
  schedule: string;
66
- ymlContent: string;
66
+ content: string;
67
67
  };
68
68
 
69
69
  export type RegistryComponentEntry = {
@@ -95,7 +95,10 @@ function countValidAddons(rootDir: string): number {
95
95
  return readdirSync(addonsDir, { withFileTypes: true }).filter((entry) => {
96
96
  if (!entry.isDirectory() || !isValidComponentName(entry.name)) return false;
97
97
  const addonDir = join(addonsDir, entry.name);
98
- return existsSync(join(addonDir, 'compose.yml')) && existsSync(join(addonDir, '.env.schema'));
98
+ // An addon is valid if it has a compose.yml. Overlay-only addons that only
99
+ // patch existing services (ports, env, volumes) do not need an .env.schema;
100
+ // full addons that introduce services and env vars do.
101
+ return existsSync(join(addonDir, 'compose.yml'));
99
102
  }).length;
100
103
  }
101
104
 
@@ -103,8 +106,8 @@ function countValidAutomations(rootDir: string): number {
103
106
  const automationsDir = join(rootDir, 'automations');
104
107
  if (!existsSync(automationsDir)) return 0;
105
108
  return readdirSync(automationsDir).filter((file) => {
106
- if (!file.endsWith('.yml')) return false;
107
- return isValidComponentName(file.replace(/\.yml$/, ''));
109
+ if (!file.endsWith('.md')) return false;
110
+ return isValidComponentName(file.replace(/\.md$/, ''));
108
111
  }).length;
109
112
  }
110
113
 
@@ -123,8 +126,8 @@ export function verifyRegistryCatalog(rootDir = resolveRegistryDir()): RegistryC
123
126
  }
124
127
 
125
128
  export function materializeRegistryCatalog(sourceRoot: string): string {
126
- const sourceAddonsDir = join(sourceRoot, '.openpalm', 'registry', 'addons');
127
- const sourceAutomationsDir = join(sourceRoot, '.openpalm', 'registry', 'automations');
129
+ const sourceAddonsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'addons');
130
+ const sourceAutomationsDir = join(sourceRoot, '.openpalm', 'state', 'registry', 'automations');
128
131
  const tempRoot = mkdtempSync(join(tmpdir(), 'openpalm-registry-materialize-'));
129
132
 
130
133
  try {
@@ -184,37 +187,46 @@ export function discoverRegistryComponents(): Record<string, RegistryComponentEn
184
187
  if (!entry.isDirectory() || !VALID_NAME_RE.test(entry.name)) continue;
185
188
  const addonDir = join(addonsDir, entry.name);
186
189
  const composeFile = join(addonDir, 'compose.yml');
190
+ if (!existsSync(composeFile)) continue;
191
+
192
+ // .env.schema is optional: overlay-only addons (e.g. a port toggle) do
193
+ // not introduce new env vars, so they ship just compose.yml.
187
194
  const schemaFile = join(addonDir, '.env.schema');
188
- if (!existsSync(composeFile) || !existsSync(schemaFile)) continue;
195
+ const schema = existsSync(schemaFile) ? readFileSync(schemaFile, 'utf-8') : '';
189
196
 
190
197
  result[entry.name] = {
191
198
  compose: readFileSync(composeFile, 'utf-8'),
192
- schema: readFileSync(schemaFile, 'utf-8'),
199
+ schema,
193
200
  };
194
201
  }
195
202
 
196
203
  return result;
197
204
  }
198
205
 
199
- export function discoverRegistryAutomations(): RegistryAutomationEntry[] {
206
+ export function discoverRegistryAutomations(stashDir: string): RegistryAutomationEntry[] {
200
207
  const automationsDir = resolveRegistryAutomationsDir();
201
208
  if (!existsSync(automationsDir)) return [];
202
209
 
203
210
  return readdirSync(automationsDir)
204
- .filter((file) => file.endsWith('.yml'))
211
+ .filter((file) => file.endsWith('.md'))
205
212
  .map((file) => {
206
- const name = file.replace(/\.yml$/, '');
213
+ const name = file.replace(/\.md$/, '');
207
214
  if (!VALID_NAME_RE.test(name)) return null;
208
215
 
209
- const ymlContent = readFileSync(join(automationsDir, file), 'utf-8');
216
+ const content = readFileSync(join(automationsDir, file), 'utf-8');
210
217
  let description = '';
211
218
  let schedule = '';
212
219
 
220
+ // Extract frontmatter metadata (between --- delimiters)
213
221
  try {
214
- const parsed = parseYaml(ymlContent);
215
- if (parsed && typeof parsed === 'object') {
216
- description = parsed.description ?? '';
217
- schedule = parsed.schedule ?? '';
222
+ const after = content.startsWith('---') ? content.slice(3) : '';
223
+ const end = after.indexOf('\n---');
224
+ if (end !== -1) {
225
+ const parsed = parseYaml(after.slice(0, end));
226
+ if (parsed && typeof parsed === 'object') {
227
+ description = (parsed as Record<string, unknown>).description as string ?? '';
228
+ schedule = (parsed as Record<string, unknown>).schedule as string ?? '';
229
+ }
218
230
  }
219
231
  } catch {
220
232
  // best-effort metadata extraction
@@ -225,7 +237,7 @@ export function discoverRegistryAutomations(): RegistryAutomationEntry[] {
225
237
  type: 'automation' as const,
226
238
  description,
227
239
  schedule,
228
- ymlContent,
240
+ content,
229
241
  };
230
242
  })
231
243
  .filter((entry): entry is RegistryAutomationEntry => entry !== null);
@@ -233,9 +245,9 @@ export function discoverRegistryAutomations(): RegistryAutomationEntry[] {
233
245
 
234
246
  export function getRegistryAutomation(name: string): string | null {
235
247
  if (!VALID_NAME_RE.test(name)) return null;
236
- const ymlPath = join(resolveRegistryAutomationsDir(), `${name}.yml`);
237
- if (!existsSync(ymlPath)) return null;
238
- return readFileSync(ymlPath, 'utf-8');
248
+ const mdPath = join(resolveRegistryAutomationsDir(), `${name}.md`);
249
+ if (!existsSync(mdPath)) return null;
250
+ return readFileSync(mdPath, 'utf-8');
239
251
  }
240
252
 
241
253
  export function getRegistryAddonConfig(homeDir: string, name: string): RegistryAddonConfig {
@@ -243,11 +255,14 @@ export function getRegistryAddonConfig(homeDir: string, name: string): RegistryA
243
255
  throw new Error(`Invalid addon name: ${name}`);
244
256
  }
245
257
 
246
- const schemaPath = `registry/addons/${name}/.env.schema`;
258
+ // Overlay-only addons (compose.yml only, no .env.schema) have no env vars
259
+ // to render, so the schema reads as an empty string.
260
+ const schemaPath = `state/registry/addons/${name}/.env.schema`;
261
+ const schemaFile = join(homeDir, schemaPath);
247
262
  return {
248
263
  schemaPath,
249
- userEnvPath: 'vault/user/user.env',
250
- envSchema: readFileSync(join(homeDir, schemaPath), 'utf-8'),
264
+ userEnvPath: 'config/stack/stack.env',
265
+ envSchema: existsSync(schemaFile) ? readFileSync(schemaFile, 'utf-8') : '',
251
266
  };
252
267
  }
253
268
 
@@ -261,7 +276,7 @@ export function listAvailableAddonIds(): string[] {
261
276
  }
262
277
 
263
278
  export function listEnabledAddonIds(homeDir: string): string[] {
264
- const addonsDir = join(homeDir, 'stack', 'addons');
279
+ const addonsDir = join(homeDir, 'config', 'stack', 'addons');
265
280
  if (!existsSync(addonsDir)) return [];
266
281
 
267
282
  return readdirSync(addonsDir, { withFileTypes: true })
@@ -274,19 +289,21 @@ function copyAddonFromRegistry(homeDir: string, name: string): void {
274
289
  if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
275
290
 
276
291
  const sourceDir = join(resolveRegistryAddonsDir(), name);
277
- if (!existsSync(join(sourceDir, 'compose.yml')) || !existsSync(join(sourceDir, '.env.schema'))) {
292
+ // compose.yml is the only required file. Overlay-only addons may omit
293
+ // .env.schema entirely.
294
+ if (!existsSync(join(sourceDir, 'compose.yml'))) {
278
295
  throw new Error(`Addon "${name}" not found in registry`);
279
296
  }
280
297
 
281
- const targetDir = join(homeDir, 'stack', 'addons', name);
298
+ const targetDir = join(homeDir, 'config', 'stack', 'addons', name);
282
299
  rmSync(targetDir, { recursive: true, force: true });
283
- mkdirSync(join(homeDir, 'stack', 'addons'), { recursive: true });
300
+ mkdirSync(join(homeDir, 'config', 'stack', 'addons'), { recursive: true });
284
301
  cpSync(sourceDir, targetDir, { recursive: true });
285
302
  }
286
303
 
287
304
  function removeEnabledAddon(homeDir: string, name: string): void {
288
305
  if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
289
- rmSync(join(homeDir, 'stack', 'addons', name), { recursive: true, force: true });
306
+ rmSync(join(homeDir, 'config', 'stack', 'addons', name), { recursive: true, force: true });
290
307
  }
291
308
 
292
309
  function readAddonServiceNames(composePath: string): string[] {
@@ -310,8 +327,8 @@ export function getAddonServiceNames(homeDir: string, name: string): string[] {
310
327
  if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
311
328
 
312
329
  const composeCandidates = [
313
- join(homeDir, "stack", "addons", name, "compose.yml"),
314
- join(homeDir, "registry", "addons", name, "compose.yml"),
330
+ join(homeDir, "config", "stack", "addons", name, "compose.yml"),
331
+ join(homeDir, "state", "registry", "addons", name, "compose.yml"),
315
332
  ];
316
333
 
317
334
  for (const composePath of composeCandidates) {
@@ -325,8 +342,8 @@ export function getAddonServiceNames(homeDir: string, name: string): string[] {
325
342
  export function enableAddon(homeDir: string, name: string): MutationResult {
326
343
  try {
327
344
  copyAddonFromRegistry(homeDir, name);
328
- // Pre-create the addon data directory so Docker doesn't create it as root
329
- mkdirSync(join(homeDir, 'data', name), { recursive: true });
345
+ // Pre-create the addon services directory so Docker doesn't create it as root
346
+ mkdirSync(join(homeDir, 'services', name), { recursive: true });
330
347
  return { ok: true };
331
348
  } catch (error) {
332
349
  return { ok: false, error: error instanceof Error ? error.message : String(error) };
@@ -342,7 +359,7 @@ export function disableAddonByName(homeDir: string, name: string): MutationResul
342
359
  }
343
360
  }
344
361
 
345
- export function setAddonEnabled(homeDir: string, vaultDir: string, name: string, enabled: boolean): AddonMutationResult {
362
+ export function setAddonEnabled(homeDir: string, stackDir: string, name: string, enabled: boolean): AddonMutationResult {
346
363
  if (!VALID_NAME_RE.test(name)) {
347
364
  return { ok: false, error: `Invalid addon name: ${name}` };
348
365
  }
@@ -367,9 +384,9 @@ export function setAddonEnabled(homeDir: string, vaultDir: string, name: string,
367
384
  if (!mutation.ok) return mutation;
368
385
 
369
386
  if (enabled) {
370
- const composePath = join(homeDir, "stack", "addons", name, "compose.yml");
387
+ const composePath = join(homeDir, "config", "stack", "addons", name, "compose.yml");
371
388
  if (isChannelAddon(composePath)) {
372
- writeChannelSecrets(vaultDir, { [name]: randomHex(16) });
389
+ writeChannelSecrets(stackDir, { [name]: randomHex(16) });
373
390
  }
374
391
  }
375
392
 
@@ -381,38 +398,42 @@ export function setAddonEnabled(homeDir: string, vaultDir: string, name: string,
381
398
  };
382
399
  }
383
400
 
384
- export function installAutomationFromRegistry(name: string, configDir: string): MutationResult {
401
+ export function installAutomationFromRegistry(name: string, stashDir: string): MutationResult {
385
402
  if (!VALID_NAME_RE.test(name)) {
386
403
  return { ok: false, error: `Invalid automation name: ${name}` };
387
404
  }
388
405
 
389
- const automationYml = getRegistryAutomation(name);
390
- if (!automationYml) {
406
+ const markdownContent = getRegistryAutomation(name);
407
+ if (!markdownContent) {
391
408
  return { ok: false, error: `Automation "${name}" not found in registry` };
392
409
  }
393
410
 
394
- const automationsDir = join(configDir, 'automations');
395
- mkdirSync(automationsDir, { recursive: true });
411
+ const tasksDir = join(stashDir, 'tasks');
412
+ mkdirSync(tasksDir, { recursive: true });
396
413
 
397
- const ymlPath = join(automationsDir, `${name}.yml`);
398
- if (existsSync(ymlPath)) {
414
+ const mdPath = join(tasksDir, `${name}.md`);
415
+ if (existsSync(mdPath)) {
399
416
  return { ok: false, error: `Automation "${name}" is already installed` };
400
417
  }
401
418
 
402
- writeFileSync(ymlPath, automationYml);
419
+ writeFileSync(mdPath, markdownContent);
420
+ // The assistant container's 60-second akm tasks sync loop picks up the new
421
+ // file from the shared stash mount and registers it with OS cron.
403
422
  return { ok: true };
404
423
  }
405
424
 
406
- export function uninstallAutomation(name: string, configDir: string): MutationResult {
425
+ export function uninstallAutomation(name: string, stashDir: string): MutationResult {
407
426
  if (!VALID_NAME_RE.test(name)) {
408
427
  return { ok: false, error: `Invalid automation name: ${name}` };
409
428
  }
410
429
 
411
- const ymlPath = join(configDir, 'automations', `${name}.yml`);
412
- if (!existsSync(ymlPath)) {
430
+ const mdPath = join(stashDir, 'tasks', `${name}.md`);
431
+ if (!existsSync(mdPath)) {
413
432
  return { ok: false, error: `Automation "${name}" is not installed` };
414
433
  }
415
434
 
416
- rmSync(ymlPath, { force: true });
435
+ rmSync(mdPath, { force: true });
436
+ // The assistant container's 60-second akm tasks sync will notice the file
437
+ // is gone and deregister it from OS cron on next sync.
417
438
  return { ok: true };
418
439
  }
@@ -11,11 +11,12 @@ import type { ControlPlaneState } from "./types.js";
11
11
  import { resolveRollbackDir } from "./home.js";
12
12
 
13
13
  /** Files that are tracked for rollback (relative to homeDir).
14
- * Only vault/stack/ files are included — vault/user/ and config/ are
15
- * user-owned and never overwritten by lifecycle operations. */
14
+ * Only config/ system files are included — user-editable config files
15
+ * are never overwritten by lifecycle operations. */
16
16
  const SNAPSHOT_FILES = [
17
- "vault/stack/stack.env",
18
- "vault/stack/guardian.env",
17
+ "config/stack/stack.env",
18
+ "config/stack/guardian.env",
19
+ "config/auth.json",
19
20
  ];
20
21
 
21
22
  /**
@@ -43,12 +44,12 @@ export function snapshotCurrentState(state: ControlPlaneState): void {
43
44
  safeCopy(src, dest);
44
45
  }
45
46
 
46
- // Snapshot stack/core.compose.yml
47
- const coreCompose = join(state.homeDir, "stack/core.compose.yml");
48
- safeCopy(coreCompose, join(rollbackDir, "stack/core.compose.yml"));
47
+ // Snapshot config/stack/core.compose.yml
48
+ const coreCompose = join(state.homeDir, "config/stack/core.compose.yml");
49
+ safeCopy(coreCompose, join(rollbackDir, "config/stack/core.compose.yml"));
49
50
 
50
- // Snapshot stack/addons/*/compose.yml
51
- const addonsDir = join(state.homeDir, "stack/addons");
51
+ // Snapshot config/stack/addons/*/compose.yml
52
+ const addonsDir = join(state.homeDir, "config/stack/addons");
52
53
  if (existsSync(addonsDir)) {
53
54
  for (const entry of readdirSync(addonsDir, { withFileTypes: true })) {
54
55
  if (entry.isDirectory()) {
@@ -56,7 +57,7 @@ export function snapshotCurrentState(state: ControlPlaneState): void {
56
57
  if (existsSync(addonCompose)) {
57
58
  safeCopy(
58
59
  addonCompose,
59
- join(rollbackDir, "stack/addons", entry.name, "compose.yml"),
60
+ join(rollbackDir, "config/stack/addons", entry.name, "compose.yml"),
60
61
  );
61
62
  }
62
63
  }
@@ -87,14 +88,14 @@ export function restoreSnapshot(state: ControlPlaneState): void {
87
88
  safeCopy(src, dest);
88
89
  }
89
90
 
90
- // Restore stack/core.compose.yml
91
- const srcCoreCompose = join(rollbackDir, "stack/core.compose.yml");
91
+ // Restore config/stack/core.compose.yml
92
+ const srcCoreCompose = join(rollbackDir, "config/stack/core.compose.yml");
92
93
  if (existsSync(srcCoreCompose)) {
93
- safeCopy(srcCoreCompose, join(state.homeDir, "stack/core.compose.yml"));
94
+ safeCopy(srcCoreCompose, join(state.homeDir, "config/stack/core.compose.yml"));
94
95
  }
95
96
 
96
- // Restore stack/addons/*/compose.yml
97
- const srcAddons = join(rollbackDir, "stack/addons");
97
+ // Restore config/stack/addons/*/compose.yml
98
+ const srcAddons = join(rollbackDir, "config/stack/addons");
98
99
  if (existsSync(srcAddons)) {
99
100
  for (const entry of readdirSync(srcAddons, { withFileTypes: true })) {
100
101
  if (entry.isDirectory()) {
@@ -102,7 +103,7 @@ export function restoreSnapshot(state: ControlPlaneState): void {
102
103
  if (existsSync(srcAddonCompose)) {
103
104
  safeCopy(
104
105
  srcAddonCompose,
105
- join(state.homeDir, "stack/addons", entry.name, "compose.yml"),
106
+ join(state.homeDir, "config/stack/addons", entry.name, "compose.yml"),
106
107
  );
107
108
  }
108
109
  }