@openpalm/lib 0.11.0-rc.1 → 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.
- package/package.json +1 -1
- package/src/control-plane/akm-sources.ts +1 -1
- package/src/control-plane/compose-args.test.ts +4 -2
- package/src/control-plane/compose-args.ts +2 -3
- package/src/control-plane/config-persistence.ts +8 -1
- package/src/control-plane/core-assets.ts +24 -16
- package/src/control-plane/defaults.ts +16 -0
- package/src/control-plane/env.ts +15 -0
- package/src/control-plane/home.ts +3 -3
- package/src/control-plane/install-edge-cases.test.ts +2 -31
- package/src/control-plane/lifecycle.ts +8 -4
- package/src/control-plane/migrations.test.ts +272 -0
- package/src/control-plane/migrations.ts +423 -0
- package/src/control-plane/paths.ts +1 -1
- package/src/control-plane/registry.ts +22 -10
- package/src/control-plane/setup.test.ts +2 -22
- package/src/control-plane/setup.ts +0 -4
- package/src/control-plane/skeleton-guardrail.test.ts +3 -2
- package/src/control-plane/spec-to-env.ts +2 -2
- package/src/control-plane/ui-assets.test.ts +205 -2
- package/src/control-plane/ui-assets.ts +167 -94
- package/src/index.ts +10 -10
- package/src/control-plane/stack-spec.test.ts +0 -98
- package/src/control-plane/stack-spec.ts +0 -88
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk layout migration harness.
|
|
3
|
+
*
|
|
4
|
+
* `stack.env` carries `OP_LAYOUT_VERSION` — the authoritative marker of the
|
|
5
|
+
* home-directory layout schema. `ensureMigrated()` runs BEFORE any state
|
|
6
|
+
* validation (createState/resolveRuntimeFiles assume the current layout), so it
|
|
7
|
+
* must resolve its own paths rather than take a built ControlPlaneState.
|
|
8
|
+
*
|
|
9
|
+
* Contract (the fail-safe invariant):
|
|
10
|
+
* - Fast path: if the layout is already current, return immediately — no lock,
|
|
11
|
+
* no backup, zero overhead on routine updates.
|
|
12
|
+
* - Otherwise: acquire the install lock, take a FULL-HOME backup first, and
|
|
13
|
+
* abort the whole upgrade if the backup fails (never migrate without a
|
|
14
|
+
* verified safety copy).
|
|
15
|
+
* - Migrations are COPY-ONLY / additive (never delete the old layout), so a
|
|
16
|
+
* mid-run failure leaves the home fully recoverable.
|
|
17
|
+
* - The OP_LAYOUT_VERSION bump is the LAST step (the commit point); a crash
|
|
18
|
+
* before it just re-runs next time (idempotent).
|
|
19
|
+
* - On failure, throw MigrationError carrying the backup path + recovery
|
|
20
|
+
* guidance for the CLI/UI to surface.
|
|
21
|
+
*/
|
|
22
|
+
import {
|
|
23
|
+
existsSync, mkdirSync, readFileSync, writeFileSync,
|
|
24
|
+
readdirSync, statSync, chmodSync, cpSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import { join } from "node:path";
|
|
27
|
+
import { parse as yamlParse } from "yaml";
|
|
28
|
+
import {
|
|
29
|
+
resolveOpenPalmHome, resolveDataDir, resolveStackDir, resolveStashDir, resolveConfigDir,
|
|
30
|
+
} from "./home.js";
|
|
31
|
+
import { acquireInstallLock, releaseInstallLock } from "./install-lock.js";
|
|
32
|
+
import { backupOpenPalmHome } from "./backup.js";
|
|
33
|
+
import { upsertEnvValue } from "./env.js";
|
|
34
|
+
|
|
35
|
+
export const LAYOUT_VERSION_KEY = "OP_LAYOUT_VERSION";
|
|
36
|
+
/** Bump when the on-disk layout changes and add a Migration to MIGRATIONS. */
|
|
37
|
+
export const CURRENT_LAYOUT_VERSION = 1;
|
|
38
|
+
|
|
39
|
+
const ADDON_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
40
|
+
|
|
41
|
+
export interface MigrationReport {
|
|
42
|
+
migrated: boolean;
|
|
43
|
+
from: number;
|
|
44
|
+
to: number;
|
|
45
|
+
applied: string[];
|
|
46
|
+
backupDir: string | null;
|
|
47
|
+
notes: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export class MigrationError extends Error {
|
|
51
|
+
constructor(
|
|
52
|
+
message: string,
|
|
53
|
+
readonly guidance: string,
|
|
54
|
+
readonly backupDir: string | null,
|
|
55
|
+
) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = "MigrationError";
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface MigrationCtx {
|
|
62
|
+
homeDir: string;
|
|
63
|
+
dataDir: string;
|
|
64
|
+
stackDir: string;
|
|
65
|
+
stashDir: string;
|
|
66
|
+
configDir: string;
|
|
67
|
+
dryRun: boolean;
|
|
68
|
+
log: (m: string) => void;
|
|
69
|
+
notes: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface Migration {
|
|
73
|
+
from: number;
|
|
74
|
+
to: number;
|
|
75
|
+
describe: string;
|
|
76
|
+
apply(ctx: MigrationCtx): void;
|
|
77
|
+
verify(ctx: MigrationCtx): void;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Layout-version read/write (stack.env is the single source of truth) ───────
|
|
81
|
+
|
|
82
|
+
function stackEnvFile(stashDir: string): string {
|
|
83
|
+
return join(stashDir, "env", "stack.env");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve the current on-disk layout version.
|
|
88
|
+
* - explicit OP_LAYOUT_VERSION in knowledge/env/stack.env wins
|
|
89
|
+
* - else a top-level `vault/` directory ⇒ 0.10.x layout (version 0)
|
|
90
|
+
* - else assume the current layout (a pre-marker 0.11 install) — caller stamps it
|
|
91
|
+
*/
|
|
92
|
+
function readLayoutVersion(ctx: { homeDir: string; stashDir: string }): number {
|
|
93
|
+
const envPath = stackEnvFile(ctx.stashDir);
|
|
94
|
+
if (existsSync(envPath)) {
|
|
95
|
+
for (const line of readFileSync(envPath, "utf-8").split("\n")) {
|
|
96
|
+
const m = line.match(/^OP_LAYOUT_VERSION=(\d+)\s*$/);
|
|
97
|
+
if (m) return Number(m[1]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (existsSync(join(ctx.homeDir, "vault"))) return 0;
|
|
101
|
+
return CURRENT_LAYOUT_VERSION;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function stampLayoutVersion(stashDir: string, version: number): void {
|
|
105
|
+
const envPath = stackEnvFile(stashDir);
|
|
106
|
+
if (!existsSync(envPath)) return; // nothing to stamp; not a usable install
|
|
107
|
+
const next = upsertEnvValue(readFileSync(envPath, "utf-8"), LAYOUT_VERSION_KEY, String(version));
|
|
108
|
+
writeFileSync(envPath, next);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── helpers (non-destructive: copy, never delete the source) ──────────────────
|
|
112
|
+
|
|
113
|
+
function ensureDir(ctx: MigrationCtx, dir: string): void {
|
|
114
|
+
if (ctx.dryRun) { ctx.log(`[dry-run] mkdir ${rel(ctx, dir)}`); return; }
|
|
115
|
+
mkdirSync(dir, { recursive: true });
|
|
116
|
+
try { chmodSync(dir, 0o700); } catch { /* Windows / best-effort */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function rel(ctx: MigrationCtx, p: string): string {
|
|
120
|
+
return p.startsWith(ctx.homeDir) ? p.slice(ctx.homeDir.length + 1) : p;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Copy src→dest; skip if dest exists; chmod 600. Returns true if copied. */
|
|
124
|
+
function copyIfAbsent(ctx: MigrationCtx, src: string, dest: string): boolean {
|
|
125
|
+
if (!existsSync(src)) return false;
|
|
126
|
+
if (existsSync(dest)) { ctx.log(`skip (exists): ${rel(ctx, dest)}`); return false; }
|
|
127
|
+
if (ctx.dryRun) { ctx.log(`[dry-run] copy ${rel(ctx, src)} -> ${rel(ctx, dest)}`); return true; }
|
|
128
|
+
cpSync(src, dest, { recursive: true });
|
|
129
|
+
try { if (statSync(dest).isFile()) chmodSync(dest, 0o600); } catch { /* best-effort */ }
|
|
130
|
+
ctx.log(`copied: ${rel(ctx, src)} -> ${rel(ctx, dest)}`);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function writeFile600(ctx: MigrationCtx, path: string, content: string): void {
|
|
135
|
+
if (ctx.dryRun) { ctx.log(`[dry-run] write ${rel(ctx, path)}`); return; }
|
|
136
|
+
writeFileSync(path, content);
|
|
137
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Migration 0 → 1: 0.10.x `vault/` layout → 0.11.0 knowledge/ layout ────────
|
|
141
|
+
|
|
142
|
+
const SECRET_KEY_RE = /(_API_KEY|_TOKEN|_SECRET|_PASSWORD)$/;
|
|
143
|
+
const CONFIG_KEY_RE = /^(OP_CAP_|SYSTEM_LLM_|EMBEDDING_)/;
|
|
144
|
+
|
|
145
|
+
function migrate010to011(ctx: MigrationCtx): void {
|
|
146
|
+
const vault = join(ctx.homeDir, "vault");
|
|
147
|
+
const newEnv = join(ctx.stashDir, "env");
|
|
148
|
+
const newSecrets = join(ctx.stashDir, "secrets");
|
|
149
|
+
ensureDir(ctx, newEnv);
|
|
150
|
+
ensureDir(ctx, newSecrets);
|
|
151
|
+
|
|
152
|
+
// user.env → knowledge/env/user.env
|
|
153
|
+
copyIfAbsent(ctx, join(vault, "user", "user.env"), join(newEnv, "user.env"));
|
|
154
|
+
|
|
155
|
+
// stack.env transform → knowledge/env/stack.env
|
|
156
|
+
const srcStack = join(vault, "stack", "stack.env");
|
|
157
|
+
const destStack = join(newEnv, "stack.env");
|
|
158
|
+
if (existsSync(srcStack) && !existsSync(destStack)) {
|
|
159
|
+
const kept: string[] = [];
|
|
160
|
+
const removed: string[] = [];
|
|
161
|
+
for (const line of readFileSync(srcStack, "utf-8").split("\n")) {
|
|
162
|
+
if (line === "" || line.startsWith("#")) { kept.push(line); continue; }
|
|
163
|
+
const eq = line.indexOf("=");
|
|
164
|
+
const key = eq >= 0 ? line.slice(0, eq) : line;
|
|
165
|
+
const val = eq >= 0 ? line.slice(eq + 1) : "";
|
|
166
|
+
if (key === "OP_UI_LOGIN_PASSWORD") {
|
|
167
|
+
writeFile600(ctx, join(newSecrets, "op_ui_login_password"), val + "\n");
|
|
168
|
+
ctx.log("extracted OP_UI_LOGIN_PASSWORD -> knowledge/secrets/op_ui_login_password");
|
|
169
|
+
} else if (key === "OP_ADMIN_PORT") {
|
|
170
|
+
kept.push(`OP_HOST_UI_PORT=${val}`);
|
|
171
|
+
ctx.log("renamed OP_ADMIN_PORT -> OP_HOST_UI_PORT");
|
|
172
|
+
} else if (key === "OP_ADMIN_OPENCODE_PORT" || key === "OP_GUARDIAN_PORT") {
|
|
173
|
+
ctx.log(`dropped removed var: ${key}`);
|
|
174
|
+
} else if (key.startsWith("TTS_") || key.startsWith("STT_")) {
|
|
175
|
+
kept.push(`OP_${key}=${val}`);
|
|
176
|
+
ctx.log(`renamed ${key} -> OP_${key}`);
|
|
177
|
+
} else if (CONFIG_KEY_RE.test(key) || SECRET_KEY_RE.test(key)) {
|
|
178
|
+
removed.push(line);
|
|
179
|
+
ctx.log(`quarantined: ${key}`);
|
|
180
|
+
} else {
|
|
181
|
+
kept.push(line);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
writeFile600(ctx, destStack, kept.join("\n") + "\n");
|
|
185
|
+
if (removed.length > 0) {
|
|
186
|
+
writeFile600(ctx, join(newEnv, "stack.env.removed-secrets.bak"), removed.join("\n") + "\n");
|
|
187
|
+
ctx.notes.push(
|
|
188
|
+
"Secret/capability keys were removed from stack.env (saved to knowledge/env/stack.env.removed-secrets.bak) — re-enter provider keys via the Connections tab and LLM config via config/akm/config.json; do not put them back in stack.env.",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// provider creds (best-effort) + service secrets
|
|
194
|
+
if (copyIfAbsent(ctx, join(vault, "stack", "auth.json"), join(newSecrets, "auth.json"))) {
|
|
195
|
+
ctx.notes.push("Copied auth.json best-effort — verify providers in the Connections tab and re-add any that are missing (the OpenCode auth format changed).");
|
|
196
|
+
}
|
|
197
|
+
const servicesDir = join(vault, "stack", "services");
|
|
198
|
+
if (existsSync(servicesDir)) {
|
|
199
|
+
for (const name of readdirSync(servicesDir)) {
|
|
200
|
+
copyIfAbsent(ctx, join(servicesDir, name), join(newSecrets, name));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// channel HMAC secrets: split CHANNEL_<NAME>_SECRET into per-secret files
|
|
205
|
+
const guardianEnv = join(vault, "stack", "guardian.env");
|
|
206
|
+
if (existsSync(guardianEnv)) {
|
|
207
|
+
for (const line of readFileSync(guardianEnv, "utf-8").split("\n")) {
|
|
208
|
+
const m = line.match(/^CHANNEL_(.+)_SECRET=(.*)$/);
|
|
209
|
+
if (!m) continue;
|
|
210
|
+
const name = m[1].toLowerCase();
|
|
211
|
+
if (!ADDON_NAME_RE.test(name)) continue;
|
|
212
|
+
const dest = join(newSecrets, `channel_${name}_secret`);
|
|
213
|
+
if (existsSync(dest)) { ctx.log(`skip (exists): knowledge/secrets/channel_${name}_secret`); continue; }
|
|
214
|
+
writeFile600(ctx, dest, m[2] + "\n");
|
|
215
|
+
ctx.log(`channel secret: ${m[1]} -> knowledge/secrets/channel_${name}_secret`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// user credential files mounted into the assistant at /etc/openpalm
|
|
220
|
+
for (const n of ["apprise.yaml", "apprise.conf", "gcloud-credentials.json"]) {
|
|
221
|
+
copyIfAbsent(ctx, join(vault, "user", n), join(newSecrets, n));
|
|
222
|
+
}
|
|
223
|
+
for (const d of [".gws", ".gcloud", ".mgc"]) {
|
|
224
|
+
copyIfAbsent(ctx, join(vault, "user", d), join(newSecrets, d));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Leave a README in the retained legacy vault/ explaining that it is now a
|
|
228
|
+
// recovery copy and how to remove it safely once 0.11.x is confirmed working.
|
|
229
|
+
writeVaultReadme(ctx, vault);
|
|
230
|
+
|
|
231
|
+
// Addon enablement: stack.yml is removed in 0.11.0. Convert any addons[] from
|
|
232
|
+
// a legacy stack.yml (config/stack.yml or config/stack/stack.yml) into
|
|
233
|
+
// OP_ENABLED_ADDONS in stack.env. Do NOT create stack.yml.
|
|
234
|
+
const addons = readLegacyStackYmlAddons(ctx);
|
|
235
|
+
if (addons.length > 0) {
|
|
236
|
+
const envPath = stackEnvFile(ctx.stashDir);
|
|
237
|
+
if (ctx.dryRun) {
|
|
238
|
+
ctx.log(`[dry-run] set OP_ENABLED_ADDONS=${addons.join(",")}`);
|
|
239
|
+
} else if (existsSync(envPath)) {
|
|
240
|
+
writeFile600(ctx, envPath, upsertEnvValue(readFileSync(envPath, "utf-8"), "OP_ENABLED_ADDONS", addons.join(",")));
|
|
241
|
+
ctx.log(`set OP_ENABLED_ADDONS=${addons.join(",")}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const VAULT_README = `# This \`vault/\` directory is from OpenPalm 0.10.x — it is now a RECOVERY COPY
|
|
247
|
+
|
|
248
|
+
OpenPalm 0.11.0 changed the on-disk layout. The upgrade **copied** everything out
|
|
249
|
+
of this \`vault/\` directory into the new locations and left these originals here,
|
|
250
|
+
untouched, as a safety net:
|
|
251
|
+
|
|
252
|
+
- \`vault/user/user.env\` → \`knowledge/env/user.env\`
|
|
253
|
+
- \`vault/stack/stack.env\` → \`knowledge/env/stack.env\` (transformed)
|
|
254
|
+
- \`vault/stack/guardian.env\` → \`knowledge/secrets/channel_<name>_secret\`
|
|
255
|
+
- \`vault/stack/services/*\` → \`knowledge/secrets/\`
|
|
256
|
+
- \`vault/stack/auth.json\` → \`knowledge/secrets/auth.json\` (best-effort)
|
|
257
|
+
- other user credential files → \`knowledge/secrets/\`
|
|
258
|
+
|
|
259
|
+
A full backup of your home was also taken under \`data/backups/\` before migrating.
|
|
260
|
+
|
|
261
|
+
Nothing in 0.11.x reads this directory anymore. You can delete it once you have
|
|
262
|
+
confirmed the new version works.
|
|
263
|
+
|
|
264
|
+
## How to remove it safely
|
|
265
|
+
|
|
266
|
+
1. Confirm 0.11.x is healthy: the stack starts (\`openpalm status\`), you can sign
|
|
267
|
+
in to the UI, your providers are connected (Connections tab), and your
|
|
268
|
+
channels still work.
|
|
269
|
+
2. Spot-check that your data is in the new layout:
|
|
270
|
+
- \`knowledge/env/stack.env\` and \`knowledge/env/user.env\` look right
|
|
271
|
+
- your secrets are under \`knowledge/secrets/\` (login password, channel
|
|
272
|
+
secrets, \`auth.json\`, etc.)
|
|
273
|
+
- if \`knowledge/env/stack.env.removed-secrets.bak\` exists, re-enter those
|
|
274
|
+
provider keys (Connections) and LLM config (\`config/akm/config.json\`) — do
|
|
275
|
+
not put secrets back into \`stack.env\`.
|
|
276
|
+
3. Only then remove this directory. Prefer your OS trash (reversible):
|
|
277
|
+
- Linux: \`gio trash ~/.openpalm/vault\` (or \`trash-put ~/.openpalm/vault\`)
|
|
278
|
+
- macOS: \`trash ~/.openpalm/vault\`
|
|
279
|
+
- Windows: delete \`%USERPROFILE%\\.openpalm\\vault\` (sends to Recycle Bin)
|
|
280
|
+
- Last resort (irreversible): \`rm -rf ~/.openpalm/vault\`
|
|
281
|
+
|
|
282
|
+
If anything looks wrong, do NOT delete this directory — restore from it or from
|
|
283
|
+
\`data/backups/\`. Full guide: docs/operations/upgrade-0.10-to-0.11.md
|
|
284
|
+
`;
|
|
285
|
+
|
|
286
|
+
/** Drop a safe-removal README into the retained legacy vault/ (skip if present). */
|
|
287
|
+
function writeVaultReadme(ctx: MigrationCtx, vault: string): void {
|
|
288
|
+
const dest = join(vault, "README.md");
|
|
289
|
+
if (existsSync(dest)) { ctx.log("skip (exists): vault/README.md"); return; }
|
|
290
|
+
if (ctx.dryRun) { ctx.log("[dry-run] write vault/README.md (safe-removal guide)"); return; }
|
|
291
|
+
writeFileSync(dest, VAULT_README);
|
|
292
|
+
ctx.log("wrote vault/README.md (safe-removal guide)");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Extract a validated addons[] list from any legacy stack.yml, or []. */
|
|
296
|
+
function readLegacyStackYmlAddons(ctx: MigrationCtx): string[] {
|
|
297
|
+
for (const p of [join(ctx.configDir, "stack.yml"), join(ctx.stackDir, "stack.yml")]) {
|
|
298
|
+
if (!existsSync(p)) continue;
|
|
299
|
+
try {
|
|
300
|
+
const raw = yamlParse(readFileSync(p, "utf-8")) as { addons?: unknown };
|
|
301
|
+
if (Array.isArray(raw?.addons)) {
|
|
302
|
+
return [...new Set(raw.addons.filter((v): v is string => typeof v === "string" && ADDON_NAME_RE.test(v)))].sort();
|
|
303
|
+
}
|
|
304
|
+
} catch { /* ignore unparseable */ }
|
|
305
|
+
}
|
|
306
|
+
return [];
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const MIGRATIONS: Migration[] = [
|
|
310
|
+
{
|
|
311
|
+
from: 0,
|
|
312
|
+
to: 1,
|
|
313
|
+
describe: "0.10.x vault/ layout → 0.11.0 knowledge/ layout",
|
|
314
|
+
apply: migrate010to011,
|
|
315
|
+
verify(ctx) {
|
|
316
|
+
// The migration must have produced a usable 0.11 stack.env (unless a
|
|
317
|
+
// dry-run, where nothing was written).
|
|
318
|
+
if (ctx.dryRun) return;
|
|
319
|
+
if (!existsSync(stackEnvFile(ctx.stashDir))) {
|
|
320
|
+
throw new Error("post-migration check failed: knowledge/env/stack.env is missing");
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
];
|
|
325
|
+
|
|
326
|
+
// ── Public entry point ────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
const RECOVERY_GUIDANCE =
|
|
329
|
+
"Your original files were left untouched and a full backup was taken first. " +
|
|
330
|
+
"To recover, restore the backup (see docs/operations/backup-restore.md) or run " +
|
|
331
|
+
"the standalone migrator with --dry-run (scripts/migrate-0.10-to-0.11.sh / .ps1). " +
|
|
332
|
+
"Full guide: docs/operations/upgrade-0.10-to-0.11.md";
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Ensure the home directory is migrated to the current layout. Safe to call at
|
|
336
|
+
* the top of any upgrade/install entry point. Resolves its own paths (must run
|
|
337
|
+
* before createState, which assumes the current layout).
|
|
338
|
+
*/
|
|
339
|
+
export function ensureMigrated(opts: { homeDir?: string; dryRun?: boolean; log?: (m: string) => void } = {}): MigrationReport {
|
|
340
|
+
const homeDir = opts.homeDir ?? resolveOpenPalmHome();
|
|
341
|
+
const dryRun = opts.dryRun ?? false;
|
|
342
|
+
const log = opts.log ?? (() => {});
|
|
343
|
+
const stashDir = resolveStashDir();
|
|
344
|
+
const ctxBase = {
|
|
345
|
+
homeDir,
|
|
346
|
+
dataDir: resolveDataDir(),
|
|
347
|
+
stackDir: resolveStackDir(),
|
|
348
|
+
stashDir,
|
|
349
|
+
configDir: resolveConfigDir(),
|
|
350
|
+
dryRun,
|
|
351
|
+
log,
|
|
352
|
+
notes: [] as string[],
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const from = readLayoutVersion(ctxBase);
|
|
356
|
+
const empty: MigrationReport = { migrated: false, from, to: from, applied: [], backupDir: null, notes: [] };
|
|
357
|
+
|
|
358
|
+
// Fast path: already current → just ensure the marker is stamped, no lock/backup.
|
|
359
|
+
if (from >= CURRENT_LAYOUT_VERSION) {
|
|
360
|
+
if (!dryRun) stampLayoutVersion(stashDir, CURRENT_LAYOUT_VERSION);
|
|
361
|
+
return { ...empty, to: CURRENT_LAYOUT_VERSION };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const pending = MIGRATIONS
|
|
365
|
+
.filter((m) => m.from >= from && m.to <= CURRENT_LAYOUT_VERSION)
|
|
366
|
+
.sort((a, b) => a.from - b.from);
|
|
367
|
+
if (pending.length === 0) {
|
|
368
|
+
if (!dryRun) stampLayoutVersion(stashDir, CURRENT_LAYOUT_VERSION);
|
|
369
|
+
return { ...empty, to: CURRENT_LAYOUT_VERSION };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
let lock: ReturnType<typeof acquireInstallLock> = null;
|
|
373
|
+
let backupDir: string | null = null;
|
|
374
|
+
try {
|
|
375
|
+
// Mutual exclusion + backup gate: never migrate without a verified safety
|
|
376
|
+
// copy. Any failure here aborts with no changes made.
|
|
377
|
+
if (!dryRun) {
|
|
378
|
+
try {
|
|
379
|
+
mkdirSync(ctxBase.dataDir, { recursive: true });
|
|
380
|
+
} catch (e) {
|
|
381
|
+
throw new MigrationError(`Could not prepare the data directory: ${e instanceof Error ? e.message : String(e)}`, RECOVERY_GUIDANCE, null);
|
|
382
|
+
}
|
|
383
|
+
lock = acquireInstallLock(ctxBase.dataDir);
|
|
384
|
+
if (!lock) {
|
|
385
|
+
throw new MigrationError("Another install/upgrade is in progress.", RECOVERY_GUIDANCE, null);
|
|
386
|
+
}
|
|
387
|
+
log("Taking a full backup before migrating…");
|
|
388
|
+
try {
|
|
389
|
+
backupDir = backupOpenPalmHome(homeDir);
|
|
390
|
+
} catch (e) {
|
|
391
|
+
throw new MigrationError(`Could not create a safety backup; upgrade aborted (no changes made): ${e instanceof Error ? e.message : String(e)}`, RECOVERY_GUIDANCE, null);
|
|
392
|
+
}
|
|
393
|
+
if (!backupDir) {
|
|
394
|
+
throw new MigrationError("Could not create a safety backup; upgrade aborted (no changes made).", RECOVERY_GUIDANCE, null);
|
|
395
|
+
}
|
|
396
|
+
log(`Backup: ${backupDir}`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const applied: string[] = [];
|
|
400
|
+
const notes: string[] = [];
|
|
401
|
+
for (const m of pending) {
|
|
402
|
+
const ctx: MigrationCtx = { ...ctxBase, notes };
|
|
403
|
+
log(`Migrating layout ${m.from} → ${m.to}: ${m.describe}`);
|
|
404
|
+
m.apply(ctx);
|
|
405
|
+
m.verify(ctx);
|
|
406
|
+
applied.push(`${m.from}->${m.to}`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Commit point: bump the layout version LAST.
|
|
410
|
+
if (!dryRun) stampLayoutVersion(stashDir, CURRENT_LAYOUT_VERSION);
|
|
411
|
+
|
|
412
|
+
return { migrated: true, from, to: CURRENT_LAYOUT_VERSION, applied, backupDir, notes };
|
|
413
|
+
} catch (e) {
|
|
414
|
+
if (e instanceof MigrationError) throw e;
|
|
415
|
+
throw new MigrationError(
|
|
416
|
+
`Migration failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
417
|
+
RECOVERY_GUIDANCE,
|
|
418
|
+
backupDir,
|
|
419
|
+
);
|
|
420
|
+
} finally {
|
|
421
|
+
releaseInstallLock(lock);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
*
|
|
7
7
|
* Layout:
|
|
8
8
|
* config/ — user-editable config + system config files (akm/)
|
|
9
|
-
* config/stack/ — compose
|
|
9
|
+
* config/stack/ — fixed compose files only (stack.env, secrets, auth.json live under knowledge/; no stack.yml)
|
|
10
10
|
* data/ — persistent service data, logs, backups, rollback
|
|
11
11
|
* knowledge/ — akm knowledge (skills, env, secrets, agents)
|
|
12
12
|
* workspace/ — shared work area
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Built-in addon/profile discovery and legacy registry helpers.
|
|
3
3
|
*
|
|
4
|
-
* Runtime addon enablement is recorded in stack.
|
|
5
|
-
* profiles. The fixed compose files under config/stack are
|
|
6
|
-
* of truth.
|
|
4
|
+
* Runtime addon enablement is recorded as OP_ENABLED_ADDONS in stack.env and
|
|
5
|
+
* resolved to Compose profiles. The fixed compose files under config/stack are
|
|
6
|
+
* the runtime source of truth.
|
|
7
7
|
*/
|
|
8
8
|
import { cpSync, existsSync, mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
9
9
|
import { execFile, execFileSync } from 'node:child_process';
|
|
@@ -16,7 +16,7 @@ import { ensureChannelSecret } from './config-persistence.js';
|
|
|
16
16
|
import { patchSecretsEnvFile, readStackEnv } from './secrets.js';
|
|
17
17
|
import { readBundledStackAsset } from './core-assets.js';
|
|
18
18
|
import { canonicalAddonProfileSelection, resolveHardwareProfileVariant } from './profile-ids.js';
|
|
19
|
-
import {
|
|
19
|
+
import { parseEnabledAddons } from './env.js';
|
|
20
20
|
import type { ControlPlaneState } from './types.js';
|
|
21
21
|
import {
|
|
22
22
|
resolveRegistryAddonsDir,
|
|
@@ -430,8 +430,8 @@ export function listAvailableAddonIds(): string[] {
|
|
|
430
430
|
}
|
|
431
431
|
|
|
432
432
|
export function listEnabledAddonIds(homeDir: string): string[] {
|
|
433
|
-
const enabled = new Set(listStackSpecAddons(join(homeDir, 'config', 'stack')));
|
|
434
433
|
const env = readStackEnv(join(homeDir, 'config', 'stack'));
|
|
434
|
+
const enabled = new Set(parseEnabledAddons(env.OP_ENABLED_ADDONS));
|
|
435
435
|
const profiles = new Set((env.COMPOSE_PROFILES ?? '').split(',').map((p) => p.trim()).filter(Boolean));
|
|
436
436
|
for (const key of ['OP_VOICE_PROFILE', 'OP_OLLAMA_PROFILE']) {
|
|
437
437
|
const profile = env[key]?.trim();
|
|
@@ -574,14 +574,18 @@ function execFileNoThrow(
|
|
|
574
574
|
/**
|
|
575
575
|
* Compute the openpalm/voice image ref for a given GPU variant, matching
|
|
576
576
|
* the substitution chain in the addon compose file:
|
|
577
|
-
* ${OP_IMAGE_NAMESPACE:-openpalm}/voice:${OP_VOICE_IMAGE_TAG
|
|
577
|
+
* ${OP_IMAGE_NAMESPACE:-openpalm}/voice:${OP_VOICE_IMAGE_TAG:-latest-<variant>}
|
|
578
|
+
*
|
|
579
|
+
* Voice images are published OUT OF BAND (publish-voice.yml), decoupled from the
|
|
580
|
+
* platform OP_IMAGE_TAG — they are heavy and rarely change. So the default is
|
|
581
|
+
* the moving `latest-<variant>` voice tag; operators pin a specific build by
|
|
582
|
+
* setting OP_VOICE_IMAGE_TAG (e.g. `v1.0.0-cpu`).
|
|
578
583
|
*/
|
|
579
584
|
function voiceImageRef(variant: 'cpu' | 'cu121' | 'rocm6'): string {
|
|
580
585
|
const namespace = process.env.OP_IMAGE_NAMESPACE?.trim() || 'openpalm';
|
|
581
586
|
const explicit = process.env.OP_VOICE_IMAGE_TAG?.trim();
|
|
582
587
|
if (explicit) return `${namespace}/voice:${explicit}`;
|
|
583
|
-
|
|
584
|
-
return `${namespace}/voice:${baseTag}-${variant}`;
|
|
588
|
+
return `${namespace}/voice:latest-${variant}`;
|
|
585
589
|
}
|
|
586
590
|
|
|
587
591
|
/**
|
|
@@ -843,10 +847,18 @@ export function setAddonProfileSelection(stackDir: string, name: string, profile
|
|
|
843
847
|
patchSecretsEnvFile(stackDir, { [profileEnvKey(name)]: trimmed });
|
|
844
848
|
}
|
|
845
849
|
|
|
850
|
+
/** Add/remove an addon id in the OP_ENABLED_ADDONS list in stack.env. */
|
|
851
|
+
function setEnabledAddonState(stackDir: string, name: string, enabled: boolean): void {
|
|
852
|
+
const current = new Set(parseEnabledAddons(readStackEnv(stackDir).OP_ENABLED_ADDONS));
|
|
853
|
+
if (enabled) current.add(name);
|
|
854
|
+
else current.delete(name);
|
|
855
|
+
patchSecretsEnvFile(stackDir, { OP_ENABLED_ADDONS: [...current].sort().join(',') });
|
|
856
|
+
}
|
|
857
|
+
|
|
846
858
|
function enableAddon(homeDir: string, stackDir: string, name: string): MutationResult {
|
|
847
859
|
try {
|
|
848
860
|
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
849
|
-
|
|
861
|
+
setEnabledAddonState(stackDir, name, true);
|
|
850
862
|
if (name === 'ssh') patchSecretsEnvFile(stackDir, { OPENCODE_ENABLE_SSH: '1' });
|
|
851
863
|
return { ok: true };
|
|
852
864
|
} catch (error) {
|
|
@@ -857,7 +869,7 @@ function enableAddon(homeDir: string, stackDir: string, name: string): MutationR
|
|
|
857
869
|
function disableAddonByName(homeDir: string, stackDir: string, name: string): MutationResult {
|
|
858
870
|
try {
|
|
859
871
|
if (!VALID_NAME_RE.test(name)) throw new Error(`Invalid addon name: ${name}`);
|
|
860
|
-
|
|
872
|
+
setEnabledAddonState(stackDir, name, false);
|
|
861
873
|
if (name === 'ssh') patchSecretsEnvFile(stackDir, { OPENCODE_ENABLE_SSH: '0' });
|
|
862
874
|
return { ok: true };
|
|
863
875
|
} catch (error) {
|
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
performSetup,
|
|
11
11
|
} from "./setup.js";
|
|
12
12
|
import type { SetupSpec, SetupConnection } from "./setup.js";
|
|
13
|
-
import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
|
|
14
13
|
import { readSecret } from './secrets-files.js';
|
|
15
14
|
|
|
16
15
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
@@ -431,15 +430,6 @@ describe("performSetup", () => {
|
|
|
431
430
|
expect(config.llm).toBeUndefined();
|
|
432
431
|
});
|
|
433
432
|
|
|
434
|
-
it("writes stack.yml v2 version marker", async () => {
|
|
435
|
-
const result = await performSetup(makeValidSpec());
|
|
436
|
-
expect(result.ok).toBe(true);
|
|
437
|
-
|
|
438
|
-
const spec = readStackSpec(stackDir);
|
|
439
|
-
expect(spec).not.toBeNull();
|
|
440
|
-
expect(spec!.version).toBe(2);
|
|
441
|
-
});
|
|
442
|
-
|
|
443
433
|
it("writes core compose file to stack/", async () => {
|
|
444
434
|
const result = await performSetup(makeValidSpec());
|
|
445
435
|
expect(result.ok).toBe(true);
|
|
@@ -522,16 +512,10 @@ describe("performSetup", () => {
|
|
|
522
512
|
}
|
|
523
513
|
});
|
|
524
514
|
|
|
525
|
-
it("
|
|
515
|
+
it("does not create a stack.yml (addon state lives in stack.env)", async () => {
|
|
526
516
|
const result = await performSetup(makeValidSpec());
|
|
527
517
|
expect(result.ok).toBe(true);
|
|
528
|
-
|
|
529
|
-
const specPath = join(stackDir, STACK_SPEC_FILENAME);
|
|
530
|
-
expect(existsSync(specPath)).toBe(true);
|
|
531
|
-
|
|
532
|
-
const spec = readStackSpec(stackDir);
|
|
533
|
-
expect(spec).not.toBeNull();
|
|
534
|
-
expect(spec!.version).toBe(2);
|
|
518
|
+
expect(existsSync(join(stackDir, "stack.yml"))).toBe(false);
|
|
535
519
|
});
|
|
536
520
|
|
|
537
521
|
it("completes setup with multiple connections", async () => {
|
|
@@ -545,10 +529,6 @@ describe("performSetup", () => {
|
|
|
545
529
|
const result = await performSetup(input);
|
|
546
530
|
expect(result.ok).toBe(true);
|
|
547
531
|
|
|
548
|
-
const spec = readStackSpec(stackDir);
|
|
549
|
-
expect(spec).not.toBeNull();
|
|
550
|
-
expect(spec!.version).toBe(2);
|
|
551
|
-
|
|
552
532
|
const stackEnv = readFileSync(join(homeDir, "knowledge", "env", "stack.env"), 'utf-8');
|
|
553
533
|
expect(stackEnv).not.toContain('OPENAI_API_KEY=');
|
|
554
534
|
expect(readSecret(stackDir, 'openai_api_key')).toBeNull();
|
|
@@ -24,7 +24,6 @@ import {
|
|
|
24
24
|
writeAuthJsonProviderKeys,
|
|
25
25
|
} from "./secrets.js";
|
|
26
26
|
import { createState } from "./lifecycle.js";
|
|
27
|
-
import { readStackSpec, writeStackSpec } from "./stack-spec.js";
|
|
28
27
|
import { writeVoiceVars } from "./spec-to-env.js";
|
|
29
28
|
import type { ControlPlaneState } from "./types.js";
|
|
30
29
|
import { validateSetupSpec } from "./setup-validation.js";
|
|
@@ -221,9 +220,6 @@ export async function performSetup(
|
|
|
221
220
|
// single try/catch so that a disk-full or permission-denied mid-way returns a
|
|
222
221
|
// clean error rather than leaving a broken half-installed ~/.openpalm/.
|
|
223
222
|
try {
|
|
224
|
-
// Preserve addon enablement while refreshing the stack schema marker.
|
|
225
|
-
writeStackSpec(state.stackDir, readStackSpec(state.stackDir) ?? { version: 2 });
|
|
226
|
-
|
|
227
223
|
// Write image tag and AKM mount paths to stack.env — atomic to avoid
|
|
228
224
|
// partial writes if the process is interrupted mid-write.
|
|
229
225
|
const systemEnvForAkm = existsSync(`${state.stashDir}/env/stack.env`)
|
|
@@ -57,12 +57,13 @@ describe("skeleton: helper scripts", () => {
|
|
|
57
57
|
// ── config/ subdirectory ──────────────────────────────────────────────
|
|
58
58
|
|
|
59
59
|
describe("skeleton: .openpalm/config/ structure", () => {
|
|
60
|
-
test("config/stack/ exists with fixed compose files
|
|
60
|
+
test("config/stack/ exists with fixed compose files (no stack.yml)", () => {
|
|
61
61
|
expect(existsSync(join(SKELETON_DIR, "config", "stack", "core.compose.yml"))).toBe(true);
|
|
62
62
|
expect(existsSync(join(SKELETON_DIR, "config", "stack", "services.compose.yml"))).toBe(true);
|
|
63
63
|
expect(existsSync(join(SKELETON_DIR, "config", "stack", "channels.compose.yml"))).toBe(true);
|
|
64
64
|
expect(existsSync(join(SKELETON_DIR, "config", "stack", "custom.compose.yml"))).toBe(true);
|
|
65
|
-
|
|
65
|
+
// stack.yml removed in 0.11.0 — addon enablement lives in stack.env.
|
|
66
|
+
expect(existsSync(join(SKELETON_DIR, "config", "stack", "stack.yml"))).toBe(false);
|
|
66
67
|
});
|
|
67
68
|
|
|
68
69
|
test("config/stack/addons/ does not exist", () => {
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Voice channel vars (TTS/STT) are written separately via writeVoiceVars.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { SPEC_DEFAULTS } from "./
|
|
8
|
+
import { SPEC_DEFAULTS } from "./defaults.js";
|
|
9
9
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
10
10
|
import { dirname } from "node:path";
|
|
11
11
|
import { mergeEnvContent } from "./env.js";
|
|
@@ -14,7 +14,7 @@ import { assertNoSecretLikeStackEnvKeys } from './secrets.js';
|
|
|
14
14
|
import { stackEnvPathFromStackDir } from './paths.js';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Derive the system.env key-value pairs from the
|
|
17
|
+
* Derive the system.env key-value pairs from the setup spec + defaults.
|
|
18
18
|
* Secrets (tokens, API keys, HMAC) are NOT included — the caller merges them.
|
|
19
19
|
*/
|
|
20
20
|
export function deriveSystemEnvFromSpec(homeDir: string): Record<string, string> {
|