@openpalm/lib 0.9.8 → 0.10.1

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 (56) hide show
  1. package/README.md +31 -71
  2. package/package.json +1 -1
  3. package/src/control-plane/audit.ts +4 -4
  4. package/src/control-plane/backup.ts +31 -0
  5. package/src/control-plane/channels.ts +88 -156
  6. package/src/control-plane/cleanup-guardrails.test.ts +289 -0
  7. package/src/control-plane/compose-args.test.ts +170 -0
  8. package/src/control-plane/compose-args.ts +57 -0
  9. package/src/control-plane/config-persistence.ts +270 -0
  10. package/src/control-plane/core-assets.ts +58 -234
  11. package/src/control-plane/crypto.ts +14 -0
  12. package/src/control-plane/docker.ts +94 -204
  13. package/src/control-plane/env-schema-validation.test.ts +118 -0
  14. package/src/control-plane/extends-support.test.ts +105 -0
  15. package/src/control-plane/home.ts +133 -0
  16. package/src/control-plane/install-edge-cases.test.ts +314 -717
  17. package/src/control-plane/lifecycle.ts +215 -233
  18. package/src/control-plane/lock.test.ts +194 -0
  19. package/src/control-plane/lock.ts +176 -0
  20. package/src/control-plane/memory-config.ts +34 -160
  21. package/src/control-plane/opencode-client.test.ts +154 -0
  22. package/src/control-plane/opencode-client.ts +113 -0
  23. package/src/control-plane/provider-config.ts +34 -0
  24. package/src/control-plane/redact-schema.ts +50 -0
  25. package/src/control-plane/registry-components.test.ts +313 -0
  26. package/src/control-plane/registry.test.ts +414 -0
  27. package/src/control-plane/registry.ts +418 -0
  28. package/src/control-plane/rollback.ts +128 -0
  29. package/src/control-plane/scheduler.ts +18 -190
  30. package/src/control-plane/secret-backend.test.ts +359 -0
  31. package/src/control-plane/secret-backend.ts +322 -0
  32. package/src/control-plane/secret-mappings.ts +185 -0
  33. package/src/control-plane/secrets.ts +186 -112
  34. package/src/control-plane/setup-config.schema.json +306 -0
  35. package/src/control-plane/setup-status.ts +15 -8
  36. package/src/control-plane/setup-validation.ts +90 -0
  37. package/src/control-plane/setup.test.ts +336 -929
  38. package/src/control-plane/setup.ts +159 -849
  39. package/src/control-plane/spec-to-env.test.ts +100 -0
  40. package/src/control-plane/spec-to-env.ts +195 -0
  41. package/src/control-plane/spec-validator.ts +159 -0
  42. package/src/control-plane/stack-spec.test.ts +150 -0
  43. package/src/control-plane/stack-spec.ts +101 -22
  44. package/src/control-plane/types.ts +6 -99
  45. package/src/control-plane/validate.ts +107 -0
  46. package/src/index.ts +101 -159
  47. package/src/provider-constants.ts +2 -31
  48. package/src/control-plane/connection-mapping.ts +0 -191
  49. package/src/control-plane/connection-migration-flags.ts +0 -40
  50. package/src/control-plane/connection-profiles.ts +0 -317
  51. package/src/control-plane/core-asset-provider.ts +0 -21
  52. package/src/control-plane/fs-asset-provider.ts +0 -65
  53. package/src/control-plane/fs-registry-provider.ts +0 -46
  54. package/src/control-plane/paths.ts +0 -77
  55. package/src/control-plane/registry-provider.ts +0 -19
  56. package/src/control-plane/staging.ts +0 -399
package/README.md CHANGED
@@ -1,83 +1,43 @@
1
1
  # @openpalm/lib
2
2
 
3
- Shared control-plane library consumed by both the CLI and the admin SvelteKit app. All portable logic for managing an OpenPalm stack lives here -- paths, secrets, staging, lifecycle, Docker Compose operations, channel/connection management, scheduling, and setup orchestration.
3
+ Shared control-plane library for OpenPalm.
4
+ CLI, admin, and scheduler use this package so stack behavior stays consistent.
4
5
 
5
- Admin is a thin UI layer that re-exports from this package. The CLI calls these functions directly.
6
+ The current model is direct-write over `~/.openpalm/` plus native Docker Compose.
7
+ Compose files in `stack/` and env files in `vault/` are the live runtime inputs.
6
8
 
7
- ## Modules
9
+ ## What lives here
8
10
 
9
- | Module | Purpose |
10
- |---|---|
11
- | `types` | Core type definitions -- `ControlPlaneState`, `CoreServiceName`, `ChannelInfo`, `CanonicalConnectionProfile`, etc. |
12
- | `paths` | XDG directory resolution (`resolveConfigHome`, `resolveDataHome`, `resolveStateHome`, `ensureXdgDirs`) |
13
- | `env` | `.env` file parsing and merging (`parseEnvContent`, `parseEnvFile`, `mergeEnvContent`) |
14
- | `secrets` | Secrets management -- ensure, read, patch, mask `secrets.env` |
15
- | `setup-status` | First-boot detection (`isSetupComplete`, `readSecretsKeys`, `detectUserId`) |
16
- | `setup` | Setup wizard backend (`performSetup`, `detectProviders`, `validateSetupInput`) |
17
- | `staging` | Artifact staging pipeline (`stageArtifacts`, `persistArtifacts`, `buildEnvFiles`) |
18
- | `lifecycle` | Install/update/uninstall/upgrade orchestration (`applyInstall`, `applyUpdate`, `createState`) |
19
- | `docker` | Docker Compose CLI wrapper (`composeUp`, `composeDown`, `composePs`, `composeLogs`, etc.) |
20
- | `channels` | Channel discovery, install, uninstall from registry or filesystem |
21
- | `connection-profiles` | CRUD for connection profiles (LLM providers, embedding, etc.) |
22
- | `connection-mapping` | Build OpenCode and mem0 config from connection profiles |
23
- | `memory-config` | Read/write memory service config, push to running memory container |
24
- | `scheduler` | Automation YAML parsing, Croner-based scheduler, execution log |
25
- | `model-runner` | Local provider detection (Ollama, Docker Model Runner, LM Studio) |
26
- | `core-assets` | Seed and read core infrastructure files (compose, Caddyfile, schemas) |
27
- | `connection-migration-flags` | Migration compatibility detection (`readConnectionMigrationFlags`, `detectConnectionCompatibilityMode`) |
28
- | `audit` | Append to the audit log |
29
- | `logger` | Structured logger factory (`createLogger`) |
30
- | `provider-constants` | LLM provider metadata (`LLM_PROVIDERS`, `PROVIDER_DEFAULT_URLS`, `EMBEDDING_DIMS`) |
31
-
32
- ## Dependency Injection
33
-
34
- Asset loading differs between CLI and admin. Two interfaces abstract this:
35
-
36
- ### CoreAssetProvider
37
-
38
- Returns the content of bundled infrastructure files (compose, Caddyfile, schemas, automations). Functions that need these files accept a `CoreAssetProvider` parameter.
39
-
40
- | Implementation | Consumer | Source |
41
- |---|---|---|
42
- | `FilesystemAssetProvider` | CLI | Reads from `DATA_HOME` on disk |
43
- | `ViteAssetProvider` | Admin | Reads from Vite `$assets` imports (defined in `packages/admin`, not in lib) |
11
+ - OpenPalm home/path helpers
12
+ - Env parsing and secret management
13
+ - Addon install/uninstall and registry helpers
14
+ - Compose lifecycle wrappers
15
+ - Memory and connection profile helpers
16
+ - Automation parsing used by the scheduler
17
+ - Shared structured logging
44
18
 
45
- ### RegistryProvider
19
+ ## Important context
46
20
 
47
- Returns channel and automation definitions from the registry catalog.
21
+ - Some filenames still use legacy names like `staging`; those modules now support the direct-write compose model
22
+ - `config/` is user-owned, `vault/stack/stack.env` is system-managed, `registry/` is catalog-only, and `stack/addons/` contains enabled runtime overlays
23
+ - New reusable control-plane logic belongs here, not duplicated in consumers
48
24
 
49
- | Implementation | Consumer | Source |
50
- |---|---|---|
51
- | `FilesystemRegistryProvider` | CLI | Reads from `registry/` directory |
52
- | `ViteRegistryProvider` | Admin | Reads via `import.meta.glob` (defined in `packages/admin`, not in lib) |
25
+ ## Main module areas
53
26
 
54
- ## Usage
55
-
56
- ```ts
57
- import {
58
- createState,
59
- stageArtifacts,
60
- persistArtifacts,
61
- applyInstall,
62
- FilesystemAssetProvider,
63
- } from "@openpalm/lib";
64
-
65
- const assets = new FilesystemAssetProvider(dataHome);
66
- const state = createState(configHome, dataHome, stateHome);
67
-
68
- stageArtifacts(state, assets);
69
- persistArtifacts(state, assets);
70
- await applyInstall(state, assets, "cli");
71
- ```
72
-
73
- Sub-path imports are also available:
27
+ | Module area | Purpose |
28
+ |---|---|
29
+ | `control-plane/home` and `control-plane/paths` | Resolve the OpenPalm home layout |
30
+ | `control-plane/env` and `control-plane/secrets` | Read, merge, and patch env files |
31
+ | `control-plane/lifecycle` and `control-plane/docker` | Compose operations and stack lifecycle helpers |
32
+ | `control-plane/channels` and `control-plane/components` | Addon discovery and install/uninstall logic |
33
+ | `control-plane/memory-config` | Memory service configuration helpers |
34
+ | `control-plane/scheduler` | Automation parsing and scheduler helpers |
35
+ | `logger` | Shared structured logger |
74
36
 
75
- ```ts
76
- import { resolveConfigHome } from "@openpalm/lib/control-plane/paths";
77
- import { createLogger } from "@openpalm/lib/shared/logger";
78
- import { LLM_PROVIDERS } from "@openpalm/lib/provider-constants";
79
- ```
37
+ ## Consumer model
80
38
 
81
- ## Architecture
39
+ - CLI: direct host-side orchestrator
40
+ - Admin: optional UI/API wrapper
41
+ - Scheduler: automation runner without Docker socket access
82
42
 
83
- See [`docs/technical/core-principles.md`](../../docs/technical/core-principles.md) for the filesystem contract and security invariants that govern this library.
43
+ See `docs/technical/core-principles.md` for the authoritative filesystem contract and security rules.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openpalm/lib",
3
- "version": "0.9.8",
3
+ "version": "0.10.1",
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",
@@ -3,7 +3,8 @@
3
3
  */
4
4
  import { mkdirSync, appendFileSync } from "node:fs";
5
5
  import type { ControlPlaneState, AuditEntry, CallerType } from "./types.js";
6
- import { MAX_AUDIT_MEMORY } from "./types.js";
6
+
7
+ const MAX_AUDIT_MEMORY = 1000;
7
8
 
8
9
  export function appendAudit(
9
10
  state: ControlPlaneState,
@@ -28,10 +29,9 @@ export function appendAudit(
28
29
  state.audit = state.audit.slice(-MAX_AUDIT_MEMORY);
29
30
  }
30
31
  try {
31
- const auditDir = `${state.stateDir}/audit`;
32
- mkdirSync(auditDir, { recursive: true });
32
+ mkdirSync(state.logsDir, { recursive: true });
33
33
  appendFileSync(
34
- `${auditDir}/admin-audit.jsonl`,
34
+ `${state.logsDir}/admin-audit.jsonl`,
35
35
  JSON.stringify(entry) + "\n"
36
36
  );
37
37
  } catch {
@@ -0,0 +1,31 @@
1
+ import { cpSync, existsSync, mkdirSync, readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ function timestampDirName(now = new Date()): string {
5
+ return now.toISOString().replace(/[:.]/g, "-");
6
+ }
7
+
8
+ /**
9
+ * Create a durable backup snapshot of the current OP_HOME contents.
10
+ *
11
+ * The backup is written under OP_HOME/backups/<timestamp>/ and excludes the
12
+ * backups directory itself to avoid recursive copies.
13
+ */
14
+ export function backupOpenPalmHome(homeDir: string): string | null {
15
+ if (!existsSync(homeDir)) return null;
16
+
17
+ const backupDir = join(homeDir, "backups", timestampDirName());
18
+ mkdirSync(backupDir, { recursive: true });
19
+
20
+ let copiedAny = false;
21
+ for (const entry of readdirSync(homeDir, { withFileTypes: true })) {
22
+ if (entry.name === "backups") continue;
23
+
24
+ const sourcePath = join(homeDir, entry.name);
25
+ const targetPath = join(backupDir, entry.name);
26
+ cpSync(sourcePath, targetPath, { recursive: true });
27
+ copiedAny = true;
28
+ }
29
+
30
+ return copiedAny ? backupDir : null;
31
+ }
@@ -1,13 +1,11 @@
1
1
  /**
2
- * Channel validation, discovery, install, and uninstall for the OpenPalm control plane.
3
- *
4
- * Install/uninstall functions accept a RegistryProvider for catalog access,
5
- * decoupling from Vite-specific imports.
2
+ * Channel validation, discovery, and allowlist checks for the OpenPalm control plane.
6
3
  */
7
- import { mkdirSync, writeFileSync, existsSync, readdirSync, rmSync } from "node:fs";
4
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
5
+ import { dirname } from "node:path";
6
+ import { parse as yamlParse } from "yaml";
8
7
  import type { ChannelInfo } from "./types.js";
9
8
  import { CORE_SERVICES } from "./types.js";
10
- import type { RegistryProvider } from "./registry-provider.js";
11
9
 
12
10
  // ── Channel Name Validation ───────────────────────────────────────────
13
11
 
@@ -21,32 +19,61 @@ function isValidChannelName(name: string): boolean {
21
19
  // ── Channel Discovery ─────────────────────────────────────────────────
22
20
 
23
21
  /**
24
- * Discover installed channels by scanning CONFIG_HOME/channels/.
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.
25
+ */
26
+ export function isChannelAddon(composePath: string): boolean {
27
+ try {
28
+ const content = readFileSync(composePath, "utf-8");
29
+ const doc = yamlParse(content);
30
+ if (typeof doc !== "object" || doc === null) return false;
31
+ const services = (doc as Record<string, unknown>).services;
32
+ if (typeof services !== "object" || services === null) return false;
33
+
34
+ for (const svcDef of Object.values(services as Record<string, unknown>)) {
35
+ if (typeof svcDef !== "object" || svcDef === null) continue;
36
+ const env = (svcDef as Record<string, unknown>).environment;
37
+ if (typeof env === "object" && env !== null) {
38
+ if (Array.isArray(env)) {
39
+ if (env.some((e: unknown) => typeof e === "string" && (e.startsWith("CHANNEL_NAME=") || e.startsWith("GUARDIAN_URL=")))) return true;
40
+ } else {
41
+ if ("CHANNEL_NAME" in (env as Record<string, unknown>) || "GUARDIAN_URL" in (env as Record<string, unknown>)) return true;
42
+ }
43
+ }
44
+ }
45
+ return false;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Discover installed channels by scanning stack/addons/ for channel addons.
53
+ * A channel addon is identified by compose-derived truth: its compose.yml
54
+ * defines services with CHANNEL_NAME or GUARDIAN_URL environment variables.
25
55
  *
26
- * A channel is any .yml file in the channels directory.
27
- * A .caddy file is optional — if present, the channel gets Caddy HTTP routing.
28
- * If absent, the channel is only accessible on the Docker network (host + containers).
56
+ * Non-channel addons (admin, ollama, etc.) are excluded.
57
+ *
58
+ * @param configDir - The config directory (~/.openpalm/config). The stack
59
+ * directory is derived from the parent (homeDir).
29
60
  */
30
61
  export function discoverChannels(configDir: string): ChannelInfo[] {
31
- const channelsDir = `${configDir}/channels`;
32
- if (!existsSync(channelsDir)) return [];
33
-
34
- const files = readdirSync(channelsDir);
35
- const ymlFiles = files.filter((f) => f.endsWith(".yml"));
36
- const caddyFiles = new Set(files.filter((f) => f.endsWith(".caddy")));
37
-
38
- return ymlFiles
39
- .map((ymlFile) => {
40
- const name = ymlFile.replace(/\.yml$/, "");
41
- const caddyFile = `${name}.caddy`;
42
- const hasCaddy = caddyFiles.has(caddyFile);
43
- return {
44
- name,
45
- hasRoute: hasCaddy,
46
- ymlPath: `${channelsDir}/${ymlFile}`,
47
- caddyPath: hasCaddy ? `${channelsDir}/${caddyFile}` : null
48
- };
62
+ const homeDir = dirname(configDir);
63
+ const addonsDir = `${homeDir}/stack/addons`;
64
+ if (!existsSync(addonsDir)) return [];
65
+
66
+ const entries = readdirSync(addonsDir, { withFileTypes: true });
67
+ return entries
68
+ .filter((entry) => {
69
+ if (!entry.isDirectory()) return false;
70
+ const composePath = `${addonsDir}/${entry.name}/compose.yml`;
71
+ return existsSync(composePath) && isChannelAddon(composePath);
49
72
  })
73
+ .map((entry) => ({
74
+ name: entry.name,
75
+ ymlPath: `${addonsDir}/${entry.name}/compose.yml`,
76
+ }))
50
77
  .filter((ch) => isValidChannelName(ch.name));
51
78
  }
52
79
 
@@ -54,146 +81,51 @@ export function discoverChannels(configDir: string): ChannelInfo[] {
54
81
 
55
82
  /**
56
83
  * Check if a service name is allowed. Core services are always allowed.
57
- * Ollama is allowed when its compose overlay is staged.
58
- * Channel services (channel-*) are allowed if a corresponding staged .yml exists
59
- * in STATE_HOME/artifacts/channels/.
84
+ * Addon services are allowed if they appear as a compose service defined in
85
+ * any addon compose file under stack/addons/. This is compose-derived: the
86
+ * actual compose content is checked, not directory naming conventions.
60
87
  */
61
- export function isAllowedService(value: string, stateDir?: string): boolean {
88
+ export function isAllowedService(value: string, configDir?: string): boolean {
62
89
  if (!value || !value.trim() || value !== value.toLowerCase()) return false;
63
90
  if ((CORE_SERVICES as string[]).includes(value)) return true;
64
- if (value === "ollama" && stateDir) {
65
- return existsSync(`${stateDir}/artifacts/ollama.yml`);
66
- }
67
- if ((value === "admin" || value === "caddy" || value === "docker-socket-proxy") && stateDir) {
68
- return existsSync(`${stateDir}/artifacts/admin.yml`);
69
- }
70
- if (value.startsWith("channel-")) {
71
- const ch = value.slice("channel-".length);
72
- if (!isValidChannelName(ch)) return false;
73
- if (stateDir) {
74
- return existsSync(`${stateDir}/artifacts/channels/${ch}.yml`);
91
+
92
+ if (configDir) {
93
+ const homeDir = dirname(configDir);
94
+ const addonsDir = `${homeDir}/stack/addons`;
95
+ if (!existsSync(addonsDir)) return false;
96
+
97
+ // Check if any addon compose.yml defines this service name (YAML-parsed)
98
+ for (const entry of readdirSync(addonsDir, { withFileTypes: true })) {
99
+ if (!entry.isDirectory()) continue;
100
+ const composePath = `${addonsDir}/${entry.name}/compose.yml`;
101
+ if (!existsSync(composePath)) continue;
102
+ try {
103
+ const content = readFileSync(composePath, "utf-8");
104
+ const doc = yamlParse(content);
105
+ if (typeof doc === "object" && doc !== null) {
106
+ const services = (doc as Record<string, unknown>).services;
107
+ if (typeof services === "object" && services !== null && value in (services as Record<string, unknown>)) {
108
+ return true;
109
+ }
110
+ }
111
+ } catch {
112
+ continue;
113
+ }
75
114
  }
76
115
  }
77
116
  return false;
78
117
  }
79
118
 
80
119
  /**
81
- * Check if a channel name is valid. Accepts any channel with a staged
82
- * .yml file in STATE_HOME/artifacts/channels/.
120
+ * Check if a channel name is valid and installed.
121
+ * Accepts any channel with a compose.yml in stack/addons/<name>/.
83
122
  */
84
- export function isValidChannel(value: string, stateDir?: string): boolean {
123
+ export function isValidChannel(value: string, configDir?: string): boolean {
85
124
  if (!value || !value.trim()) return false;
86
125
  if (!isValidChannelName(value)) return false;
87
- if (stateDir) {
88
- return existsSync(`${stateDir}/artifacts/channels/${value}.yml`);
126
+ if (configDir) {
127
+ const homeDir = dirname(configDir);
128
+ return existsSync(`${homeDir}/stack/addons/${value}/compose.yml`);
89
129
  }
90
130
  return false;
91
131
  }
92
-
93
- // ── Channel Install / Uninstall ─────────────────────────────────────────
94
-
95
- /**
96
- * Install a channel from the registry catalog into CONFIG_HOME/channels/.
97
- * Copies the .yml (and optional .caddy) from the registry provider.
98
- * Refuses if the channel is already installed (files already exist).
99
- */
100
- export function installChannelFromRegistry(
101
- name: string,
102
- configDir: string,
103
- registry: RegistryProvider
104
- ): { ok: true } | { ok: false; error: string } {
105
- if (!isValidChannelName(name)) {
106
- return { ok: false, error: `Invalid channel name: ${name}` };
107
- }
108
- const channelYml = registry.channelYml();
109
- if (!(name in channelYml)) {
110
- return { ok: false, error: `Channel "${name}" not found in registry` };
111
- }
112
- const channelsDir = `${configDir}/channels`;
113
- mkdirSync(channelsDir, { recursive: true });
114
-
115
- const ymlPath = `${channelsDir}/${name}.yml`;
116
- if (existsSync(ymlPath)) {
117
- return { ok: false, error: `Channel "${name}" is already installed` };
118
- }
119
-
120
- writeFileSync(ymlPath, channelYml[name]);
121
- const channelCaddy = registry.channelCaddy();
122
- if (name in channelCaddy) {
123
- writeFileSync(`${channelsDir}/${name}.caddy`, channelCaddy[name]);
124
- }
125
- return { ok: true };
126
- }
127
-
128
- /**
129
- * Uninstall a channel by removing its .yml (and .caddy) from CONFIG_HOME/channels/.
130
- */
131
- export function uninstallChannel(
132
- name: string,
133
- configDir: string
134
- ): { ok: true } | { ok: false; error: string } {
135
- if (!isValidChannelName(name)) {
136
- return { ok: false, error: `Invalid channel name: ${name}` };
137
- }
138
- const channelsDir = `${configDir}/channels`;
139
- const ymlPath = `${channelsDir}/${name}.yml`;
140
- if (!existsSync(ymlPath)) {
141
- return { ok: false, error: `Channel "${name}" is not installed` };
142
- }
143
-
144
- rmSync(ymlPath, { force: true });
145
- rmSync(`${channelsDir}/${name}.caddy`, { force: true });
146
- return { ok: true };
147
- }
148
-
149
- // ── Automation Install / Uninstall ──────────────────────────────────────
150
-
151
- /** Strict automation name: same rules as channel names */
152
- const AUTOMATION_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
153
-
154
- /**
155
- * Install an automation from the registry catalog into CONFIG_HOME/automations/.
156
- */
157
- export function installAutomationFromRegistry(
158
- name: string,
159
- configDir: string,
160
- registry: RegistryProvider
161
- ): { ok: true } | { ok: false; error: string } {
162
- if (!AUTOMATION_NAME_RE.test(name)) {
163
- return { ok: false, error: `Invalid automation name: ${name}` };
164
- }
165
- const automationYml = registry.automationYml();
166
- if (!(name in automationYml)) {
167
- return { ok: false, error: `Automation "${name}" not found in registry` };
168
- }
169
- const automationsDir = `${configDir}/automations`;
170
- mkdirSync(automationsDir, { recursive: true });
171
-
172
- const ymlPath = `${automationsDir}/${name}.yml`;
173
- if (existsSync(ymlPath)) {
174
- return { ok: false, error: `Automation "${name}" is already installed` };
175
- }
176
-
177
- writeFileSync(ymlPath, automationYml[name]);
178
- return { ok: true };
179
- }
180
-
181
- /**
182
- * Uninstall an automation by removing its .yml from CONFIG_HOME/automations/.
183
- */
184
- export function uninstallAutomation(
185
- name: string,
186
- configDir: string
187
- ): { ok: true } | { ok: false; error: string } {
188
- if (!AUTOMATION_NAME_RE.test(name)) {
189
- return { ok: false, error: `Invalid automation name: ${name}` };
190
- }
191
- const automationsDir = `${configDir}/automations`;
192
- const ymlPath = `${automationsDir}/${name}.yml`;
193
- if (!existsSync(ymlPath)) {
194
- return { ok: false, error: `Automation "${name}" is not installed` };
195
- }
196
-
197
- rmSync(ymlPath, { force: true });
198
- return { ok: true };
199
- }