@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.
- package/README.md +31 -71
- package/package.json +1 -1
- package/src/control-plane/audit.ts +4 -4
- package/src/control-plane/backup.ts +31 -0
- package/src/control-plane/channels.ts +88 -156
- package/src/control-plane/cleanup-guardrails.test.ts +289 -0
- package/src/control-plane/compose-args.test.ts +170 -0
- package/src/control-plane/compose-args.ts +57 -0
- package/src/control-plane/config-persistence.ts +270 -0
- package/src/control-plane/core-assets.ts +58 -234
- package/src/control-plane/crypto.ts +14 -0
- package/src/control-plane/docker.ts +94 -204
- package/src/control-plane/env-schema-validation.test.ts +118 -0
- package/src/control-plane/extends-support.test.ts +105 -0
- package/src/control-plane/home.ts +133 -0
- package/src/control-plane/install-edge-cases.test.ts +314 -717
- package/src/control-plane/lifecycle.ts +215 -233
- package/src/control-plane/lock.test.ts +194 -0
- package/src/control-plane/lock.ts +176 -0
- package/src/control-plane/memory-config.ts +34 -160
- package/src/control-plane/opencode-client.test.ts +154 -0
- package/src/control-plane/opencode-client.ts +113 -0
- package/src/control-plane/provider-config.ts +34 -0
- package/src/control-plane/redact-schema.ts +50 -0
- package/src/control-plane/registry-components.test.ts +313 -0
- package/src/control-plane/registry.test.ts +414 -0
- package/src/control-plane/registry.ts +418 -0
- package/src/control-plane/rollback.ts +128 -0
- package/src/control-plane/scheduler.ts +18 -190
- package/src/control-plane/secret-backend.test.ts +359 -0
- package/src/control-plane/secret-backend.ts +322 -0
- package/src/control-plane/secret-mappings.ts +185 -0
- package/src/control-plane/secrets.ts +186 -112
- package/src/control-plane/setup-config.schema.json +306 -0
- package/src/control-plane/setup-status.ts +15 -8
- package/src/control-plane/setup-validation.ts +90 -0
- package/src/control-plane/setup.test.ts +336 -929
- package/src/control-plane/setup.ts +159 -849
- package/src/control-plane/spec-to-env.test.ts +100 -0
- package/src/control-plane/spec-to-env.ts +195 -0
- package/src/control-plane/spec-validator.ts +159 -0
- package/src/control-plane/stack-spec.test.ts +150 -0
- package/src/control-plane/stack-spec.ts +101 -22
- package/src/control-plane/types.ts +6 -99
- package/src/control-plane/validate.ts +107 -0
- package/src/index.ts +101 -159
- package/src/provider-constants.ts +2 -31
- package/src/control-plane/connection-mapping.ts +0 -191
- package/src/control-plane/connection-migration-flags.ts +0 -40
- package/src/control-plane/connection-profiles.ts +0 -317
- package/src/control-plane/core-asset-provider.ts +0 -21
- package/src/control-plane/fs-asset-provider.ts +0 -65
- package/src/control-plane/fs-registry-provider.ts +0 -46
- package/src/control-plane/paths.ts +0 -77
- package/src/control-plane/registry-provider.ts +0 -19
- 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
|
|
3
|
+
Shared control-plane library for OpenPalm.
|
|
4
|
+
CLI, admin, and scheduler use this package so stack behavior stays consistent.
|
|
4
5
|
|
|
5
|
-
|
|
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
|
-
##
|
|
9
|
+
## What lives here
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
19
|
+
## Important context
|
|
46
20
|
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
+
- CLI: direct host-side orchestrator
|
|
40
|
+
- Admin: optional UI/API wrapper
|
|
41
|
+
- Scheduler: automation runner without Docker socket access
|
|
82
42
|
|
|
83
|
-
See
|
|
43
|
+
See `docs/technical/core-principles.md` for the authoritative filesystem contract and security rules.
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
32
|
-
mkdirSync(auditDir, { recursive: true });
|
|
32
|
+
mkdirSync(state.logsDir, { recursive: true });
|
|
33
33
|
appendFileSync(
|
|
34
|
-
`${
|
|
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,
|
|
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 {
|
|
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
|
-
*
|
|
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
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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,
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
|
82
|
-
* .yml
|
|
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,
|
|
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 (
|
|
88
|
-
|
|
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
|
-
}
|