@openpalm/lib 0.11.0-rc.3 → 0.11.0
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/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 +6 -4
- package/src/index.ts +9 -10
- package/src/control-plane/stack-spec.test.ts +0 -98
- package/src/control-plane/stack-spec.ts +0 -88
package/package.json
CHANGED
|
@@ -47,7 +47,9 @@ function seedAddon(name: string): void {
|
|
|
47
47
|
const stackDir = join(tempDir, "config", "stack");
|
|
48
48
|
mkdirSync(stackDir, { recursive: true });
|
|
49
49
|
writeFileSync(join(stackDir, "channels.compose.yml"), `services:\n ${name}:\n profiles: [\"addon.${name}\"]\n image: test\n`);
|
|
50
|
-
|
|
50
|
+
const envDir = join(tempDir, "knowledge", "env");
|
|
51
|
+
mkdirSync(envDir, { recursive: true });
|
|
52
|
+
writeFileSync(join(envDir, "stack.env"), `OP_ENABLED_ADDONS=${name}\n`);
|
|
51
53
|
}
|
|
52
54
|
|
|
53
55
|
beforeEach(() => {
|
|
@@ -70,7 +72,7 @@ describe("buildComposeOptions", () => {
|
|
|
70
72
|
expect(opts.files[0]).toContain("core.compose.yml");
|
|
71
73
|
});
|
|
72
74
|
|
|
73
|
-
it("includes fixed channel compose and profile from
|
|
75
|
+
it("includes fixed channel compose and profile from OP_ENABLED_ADDONS", () => {
|
|
74
76
|
seedCoreCompose();
|
|
75
77
|
seedAddon("chat");
|
|
76
78
|
|
|
@@ -10,9 +10,8 @@ import type { ControlPlaneState } from "./types.js";
|
|
|
10
10
|
import { buildComposeFileList } from "./lifecycle.js";
|
|
11
11
|
import { buildEnvFiles } from "./config-persistence.js";
|
|
12
12
|
import { resolveComposeProjectName } from "./docker.js";
|
|
13
|
-
import { parseEnvFile } from "./env.js";
|
|
13
|
+
import { parseEnvFile, parseEnabledAddons } from "./env.js";
|
|
14
14
|
import { canonicalAddonProfileSelection } from "./profile-ids.js";
|
|
15
|
-
import { listStackSpecAddons } from "./stack-spec.js";
|
|
16
15
|
|
|
17
16
|
// ── Types ────────────────────────────────────────────────────────────────
|
|
18
17
|
|
|
@@ -45,7 +44,7 @@ export function resolveActiveProfiles(state: ControlPlaneState): string[] {
|
|
|
45
44
|
if (ollamaProfile) profiles.push(ollamaProfile);
|
|
46
45
|
}
|
|
47
46
|
|
|
48
|
-
for (const addon of
|
|
47
|
+
for (const addon of parseEnabledAddons(env.OP_ENABLED_ADDONS)) {
|
|
49
48
|
if (addon === 'voice') {
|
|
50
49
|
profiles.push(canonicalAddonProfileSelection('voice', env.OP_VOICE_PROFILE ?? '') || 'addon.voice.cpu');
|
|
51
50
|
} else if (addon === 'ollama') {
|
|
@@ -14,7 +14,8 @@ import { ensureSecret } from './secrets-files.js';
|
|
|
14
14
|
import type { ControlPlaneState, ArtifactMeta } from "./types.js";
|
|
15
15
|
import { listEnabledAddonIds } from "./registry.js";
|
|
16
16
|
import { resolveOperatorIds, hasUsableOperatorId } from "./operator-ids.js";
|
|
17
|
-
import { SPEC_DEFAULTS } from "./
|
|
17
|
+
import { SPEC_DEFAULTS } from "./defaults.js";
|
|
18
|
+
import { CURRENT_LAYOUT_VERSION } from "./migrations.js";
|
|
18
19
|
|
|
19
20
|
import {
|
|
20
21
|
readCoreCompose,
|
|
@@ -128,6 +129,12 @@ function generateFallbackSystemEnv(state: ControlPlaneState): string {
|
|
|
128
129
|
`OP_IMAGE_NAMESPACE=${process.env.OP_IMAGE_NAMESPACE ?? "openpalm"}`,
|
|
129
130
|
`OP_IMAGE_TAG=${DEFAULT_IMAGE_TAG}`,
|
|
130
131
|
"",
|
|
132
|
+
"# ── Layout (on-disk schema version; managed by the migration harness) ──",
|
|
133
|
+
`OP_LAYOUT_VERSION=${CURRENT_LAYOUT_VERSION}`,
|
|
134
|
+
"",
|
|
135
|
+
"# ── Enabled addons (comma-separated; managed via the Add-ons UI / CLI) ──",
|
|
136
|
+
"OP_ENABLED_ADDONS=",
|
|
137
|
+
"",
|
|
131
138
|
"# ── Ports (38XX range) ──────────────────────────────────────────────",
|
|
132
139
|
"# Guardian is network-only (no host port) — channels reach it via",
|
|
133
140
|
"# http://guardian:8080 over the channel_lan Docker network.",
|
|
@@ -56,18 +56,25 @@ export function ensureOpenCodeSystemConfig(): void {
|
|
|
56
56
|
|
|
57
57
|
const REPO = "itlackey/openpalm";
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
59
|
+
// The version to download assets for is ALWAYS passed in by the caller (the
|
|
60
|
+
// upgrade flow resolves the canonical platform tag — the newest published
|
|
61
|
+
// `openpalm/assistant` Docker tag, e.g. "v0.11.0-rc.6" — and threads it here).
|
|
62
|
+
// This module intentionally does NOT resolve the version itself: no env var, no
|
|
63
|
+
// `import.meta.url` package.json read (which breaks when the lib is bundled into
|
|
64
|
+
// the UI/electron), and never a silent "main" fallback (main's asset layout can
|
|
65
|
+
// differ from a released install). Bundler-agnostic by construction.
|
|
66
|
+
|
|
67
|
+
function normalizeAssetRef(version: string): string {
|
|
68
|
+
const v = version.trim();
|
|
69
|
+
if (!v) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
"Cannot download OpenPalm stack assets: no version provided. " +
|
|
72
|
+
"The caller must pass the target release tag (e.g. \"v0.11.0-rc.6\")."
|
|
64
73
|
);
|
|
65
|
-
return `v${pkgJson.version}`;
|
|
66
|
-
} catch {
|
|
67
|
-
return "main";
|
|
68
74
|
}
|
|
75
|
+
// GitHub release/raw refs are `vX.Y.Z`; accept a bare semver and add the `v`.
|
|
76
|
+
return /^\d/.test(v) ? `v${v}` : v;
|
|
69
77
|
}
|
|
70
|
-
const VERSION = resolveAssetVersion();
|
|
71
78
|
|
|
72
79
|
// Persona files (openpalm.md, system.md), stash seeds, and user-editable config
|
|
73
80
|
// files are intentionally NOT in this list. They are seeded once (never
|
|
@@ -85,9 +92,9 @@ const SEEDED_ASSETS: { relPath: string; githubFilename: string }[] = [
|
|
|
85
92
|
{ relPath: "config/stack/custom.compose.yml", githubFilename: ".openpalm/config/stack/custom.compose.yml" },
|
|
86
93
|
];
|
|
87
94
|
|
|
88
|
-
async function downloadAsset(filename: string): Promise<string> {
|
|
89
|
-
const releaseUrl = `https://github.com/${REPO}/releases/download/${
|
|
90
|
-
const rawUrl = `https://raw.githubusercontent.com/${REPO}/${
|
|
95
|
+
async function downloadAsset(filename: string, version: string): Promise<string> {
|
|
96
|
+
const releaseUrl = `https://github.com/${REPO}/releases/download/${version}/${filename}`;
|
|
97
|
+
const rawUrl = `https://raw.githubusercontent.com/${REPO}/${version}/${filename}`;
|
|
91
98
|
|
|
92
99
|
for (const url of [releaseUrl, rawUrl]) {
|
|
93
100
|
try {
|
|
@@ -97,19 +104,20 @@ async function downloadAsset(filename: string): Promise<string> {
|
|
|
97
104
|
// try next URL
|
|
98
105
|
}
|
|
99
106
|
}
|
|
100
|
-
throw new Error(`Failed to download ${filename} from GitHub (tried release and raw URLs for version "${
|
|
107
|
+
throw new Error(`Failed to download ${filename} from GitHub (tried release and raw URLs for version "${version}")`);
|
|
101
108
|
}
|
|
102
109
|
|
|
103
|
-
export async function refreshCoreAssets(): Promise<{
|
|
110
|
+
export async function refreshCoreAssets(version: string): Promise<{
|
|
104
111
|
backupDir: string | null;
|
|
105
112
|
updated: string[];
|
|
106
113
|
}> {
|
|
114
|
+
const ref = normalizeAssetRef(version);
|
|
107
115
|
const homeDir = resolveOpenPalmHome();
|
|
108
116
|
const updated: string[] = [];
|
|
109
117
|
let backupDir: string | null = null;
|
|
110
118
|
|
|
111
119
|
for (const asset of MANAGED_ASSETS) {
|
|
112
|
-
const freshContent = await downloadAsset(asset.githubFilename);
|
|
120
|
+
const freshContent = await downloadAsset(asset.githubFilename, ref);
|
|
113
121
|
const targetPath = join(homeDir, asset.relPath);
|
|
114
122
|
|
|
115
123
|
if (existsSync(targetPath)) {
|
|
@@ -135,7 +143,7 @@ export async function refreshCoreAssets(): Promise<{
|
|
|
135
143
|
for (const asset of SEEDED_ASSETS) {
|
|
136
144
|
const targetPath = join(homeDir, asset.relPath);
|
|
137
145
|
if (existsSync(targetPath)) continue;
|
|
138
|
-
const freshContent = await downloadAsset(asset.githubFilename);
|
|
146
|
+
const freshContent = await downloadAsset(asset.githubFilename, ref);
|
|
139
147
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
140
148
|
writeFileSync(targetPath, freshContent);
|
|
141
149
|
updated.push(asset.relPath);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stack defaults (ports + image). Formerly in stack-spec.ts; kept after the
|
|
3
|
+
* stack.yml removal because these are the canonical fallback values used when a
|
|
4
|
+
* key is absent from stack.env.
|
|
5
|
+
*/
|
|
6
|
+
export const SPEC_DEFAULTS = {
|
|
7
|
+
ports: {
|
|
8
|
+
assistant: 3800,
|
|
9
|
+
hostUi: 3880,
|
|
10
|
+
assistantSsh: 2222,
|
|
11
|
+
},
|
|
12
|
+
image: {
|
|
13
|
+
namespace: "openpalm",
|
|
14
|
+
tag: "latest",
|
|
15
|
+
},
|
|
16
|
+
} as const;
|
package/src/control-plane/env.ts
CHANGED
|
@@ -82,6 +82,21 @@ export function upsertEnvValue(content: string, key: string, value: string): str
|
|
|
82
82
|
return `${content}${suffix}${line}\n`;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
+
/** Addon name shape (matches the former stack.yml validation). */
|
|
86
|
+
export const ADDON_NAME_RE = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Parse the `OP_ENABLED_ADDONS` stack.env value (comma-separated) into a
|
|
90
|
+
* validated, de-duplicated, sorted list of addon ids. Replaces the former
|
|
91
|
+
* stack.yml `addons[]` array as the authoritative enabled-addon record.
|
|
92
|
+
*/
|
|
93
|
+
export function parseEnabledAddons(value: string | undefined): string[] {
|
|
94
|
+
if (!value) return [];
|
|
95
|
+
return [...new Set(
|
|
96
|
+
value.split(',').map((v) => v.trim()).filter((v) => ADDON_NAME_RE.test(v)),
|
|
97
|
+
)].sort();
|
|
98
|
+
}
|
|
99
|
+
|
|
85
100
|
export const RELEASE_TAG_REGEX = /^v?\d+\.\d+\.\d+(?:[-+](?:[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*))?$/;
|
|
86
101
|
|
|
87
102
|
/**
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Single ~/.openpalm/ root:
|
|
5
5
|
* config/ — user-editable config + system config files (akm/)
|
|
6
|
-
* config/stack/ — compose
|
|
6
|
+
* config/stack/ — fixed compose files (no stack.env/secrets/stack.yml)
|
|
7
7
|
* data/ — persistent service data, logs, backups, rollback
|
|
8
|
-
* knowledge/ — akm knowledge (env, secrets, tasks)
|
|
8
|
+
* knowledge/ — akm knowledge (env, secrets, tasks); env/stack.env is the
|
|
9
|
+
* authoritative stack composition + versions record
|
|
9
10
|
* workspace/ — shared assistant work area
|
|
10
|
-
* config/stack/ — compose runtime assets + stack config (stack.env, stack.yml)
|
|
11
11
|
*/
|
|
12
12
|
import { mkdirSync } from "node:fs";
|
|
13
13
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -26,7 +26,6 @@ import {
|
|
|
26
26
|
} from "./setup.js";
|
|
27
27
|
import type { SetupSpec, SetupConnection } from "./setup.js";
|
|
28
28
|
import type { ControlPlaneState } from "./types.js";
|
|
29
|
-
import { STACK_SPEC_FILENAME, readStackSpec } from "./stack-spec.js";
|
|
30
29
|
import { readSecret } from './secrets-files.js';
|
|
31
30
|
|
|
32
31
|
// ── Helpers ──────────────────────────────────────────────────────────────
|
|
@@ -313,11 +312,6 @@ describe("Existing Install", () => {
|
|
|
313
312
|
})
|
|
314
313
|
);
|
|
315
314
|
|
|
316
|
-
// stack.yml is just a version marker now
|
|
317
|
-
const specAfterSecond = readStackSpec(stackDir);
|
|
318
|
-
expect(specAfterSecond).not.toBeNull();
|
|
319
|
-
expect(specAfterSecond!.version).toBe(2);
|
|
320
|
-
|
|
321
315
|
const auth = JSON.parse(readFileSync(join(homeDir, "knowledge", "secrets", "auth.json"), "utf-8"));
|
|
322
316
|
expect(auth.groq.key).toBe("gsk-test-key-456");
|
|
323
317
|
});
|
|
@@ -426,12 +420,6 @@ describe("Broken/Corrupt State", () => {
|
|
|
426
420
|
}
|
|
427
421
|
});
|
|
428
422
|
|
|
429
|
-
// Scenario 13: Missing stack.yml returns null
|
|
430
|
-
it("readStackSpec returns null when stack.yml missing", () => {
|
|
431
|
-
const spec = readStackSpec(stackDir);
|
|
432
|
-
expect(spec).toBeNull();
|
|
433
|
-
});
|
|
434
|
-
|
|
435
423
|
// Scenario 14: knowledge/tasks dir missing (performSetup should recreate it via ensureHomeDirs)
|
|
436
424
|
it("performSetup creates missing subdirectories", async () => {
|
|
437
425
|
// Seed the minimal env files first
|
|
@@ -453,16 +441,6 @@ describe("Broken/Corrupt State", () => {
|
|
|
453
441
|
expect(existsSync(join(homeDir, "knowledge", "tasks"))).toBe(true);
|
|
454
442
|
});
|
|
455
443
|
|
|
456
|
-
// Scenario 15: openpalm.yaml with old version
|
|
457
|
-
it("readStackSpec returns null for version 1 spec", () => {
|
|
458
|
-
writeFileSync(
|
|
459
|
-
join(stackDir, STACK_SPEC_FILENAME),
|
|
460
|
-
"version: 1\nconnections: []\n"
|
|
461
|
-
);
|
|
462
|
-
|
|
463
|
-
const spec = readStackSpec(stackDir);
|
|
464
|
-
expect(spec).toBeNull();
|
|
465
|
-
});
|
|
466
444
|
});
|
|
467
445
|
|
|
468
446
|
// =====================================================================
|
|
@@ -562,10 +540,6 @@ describe("Setup Input Variations", () => {
|
|
|
562
540
|
|
|
563
541
|
const result = await performSetup(input);
|
|
564
542
|
expect(result.ok).toBe(true);
|
|
565
|
-
|
|
566
|
-
const spec = readStackSpec(stackDir);
|
|
567
|
-
expect(spec).not.toBeNull();
|
|
568
|
-
expect(spec!.version).toBe(2);
|
|
569
543
|
});
|
|
570
544
|
|
|
571
545
|
// Scenario 21: Multiple providers map to correct env vars
|
|
@@ -626,12 +600,9 @@ describe("performSetup end-to-end artifacts", () => {
|
|
|
626
600
|
rmSync(homeDir, { recursive: true, force: true });
|
|
627
601
|
});
|
|
628
602
|
|
|
629
|
-
it("
|
|
603
|
+
it("does not create a stack.yml (addon state lives in stack.env)", async () => {
|
|
630
604
|
await performSetup(makeValidSpec());
|
|
631
|
-
|
|
632
|
-
const spec = readStackSpec(stackDir);
|
|
633
|
-
expect(spec).not.toBeNull();
|
|
634
|
-
expect(spec!.version).toBe(2);
|
|
605
|
+
expect(existsSync(join(stackDir, "stack.yml"))).toBe(false);
|
|
635
606
|
});
|
|
636
607
|
|
|
637
608
|
it("writes akm config with embedding dims from setup spec", async () => {
|
|
@@ -216,7 +216,9 @@ export async function updateStackEnvToLatestImageTag(state: ControlPlaneState):
|
|
|
216
216
|
}
|
|
217
217
|
|
|
218
218
|
export async function applyUpgrade(
|
|
219
|
-
state: ControlPlaneState
|
|
219
|
+
state: ControlPlaneState,
|
|
220
|
+
/** Release tag whose stack assets to fetch (e.g. "v0.11.0-rc.6"). Caller-supplied. */
|
|
221
|
+
version: string
|
|
220
222
|
): Promise<{
|
|
221
223
|
backupDir: string | null;
|
|
222
224
|
updated: string[];
|
|
@@ -225,7 +227,7 @@ export async function applyUpgrade(
|
|
|
225
227
|
const lock = acquireInstallLock(state.dataDir);
|
|
226
228
|
if (!lock) throw new Error("Another install is already in progress");
|
|
227
229
|
try {
|
|
228
|
-
const { backupDir, updated } = await refreshCoreAssets();
|
|
230
|
+
const { backupDir, updated } = await refreshCoreAssets(version);
|
|
229
231
|
const restarted = await reconcileCore(state, {});
|
|
230
232
|
return { backupDir, updated, restarted };
|
|
231
233
|
} finally {
|
|
@@ -269,7 +271,9 @@ export async function performUpgrade(state: ControlPlaneState): Promise<UpgradeR
|
|
|
269
271
|
const tagResult = await updateStackEnvToLatestImageTag(state);
|
|
270
272
|
imageTag = tagResult.tag;
|
|
271
273
|
namespace = tagResult.namespace;
|
|
272
|
-
|
|
274
|
+
// The resolved platform tag IS the version whose stack assets we fetch —
|
|
275
|
+
// keeps compose files and images in lockstep.
|
|
276
|
+
upgradeResult = await applyUpgrade(state, imageTag);
|
|
273
277
|
} catch (e) {
|
|
274
278
|
// Restore stack.env on failure
|
|
275
279
|
if (originalStackEnv !== null) {
|
|
@@ -308,7 +312,7 @@ export async function applyTagChange(state: ControlPlaneState, tag: string): Pro
|
|
|
308
312
|
const stackEnvPath = `${state.stashDir}/env/stack.env`;
|
|
309
313
|
const currentContent = existsSync(stackEnvPath) ? readFileSync(stackEnvPath, "utf-8") : "";
|
|
310
314
|
writeFileSync(stackEnvPath, mergeEnvContent(currentContent, { OP_IMAGE_TAG: tag }, { uncomment: true }));
|
|
311
|
-
const upgradeResult = await applyUpgrade(state);
|
|
315
|
+
const upgradeResult = await applyUpgrade(state, tag);
|
|
312
316
|
return {
|
|
313
317
|
imageTag: tag,
|
|
314
318
|
namespace: "openpalm",
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync, rmSync, readdirSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { ensureMigrated, MigrationError, CURRENT_LAYOUT_VERSION } from "./migrations.js";
|
|
6
|
+
|
|
7
|
+
// The harness resolves all paths from OP_HOME; point it at a synthetic 0.10 home.
|
|
8
|
+
let home: string;
|
|
9
|
+
let prevOpHome: string | undefined;
|
|
10
|
+
|
|
11
|
+
function seed010(h: string): void {
|
|
12
|
+
mkdirSync(join(h, "vault", "user"), { recursive: true });
|
|
13
|
+
mkdirSync(join(h, "vault", "stack", "services"), { recursive: true });
|
|
14
|
+
mkdirSync(join(h, "config"), { recursive: true });
|
|
15
|
+
mkdirSync(join(h, "data"), { recursive: true });
|
|
16
|
+
writeFileSync(join(h, "vault", "user", "user.env"), "MY_PREF=hello\n");
|
|
17
|
+
writeFileSync(
|
|
18
|
+
join(h, "vault", "stack", "stack.env"),
|
|
19
|
+
[
|
|
20
|
+
"# system env",
|
|
21
|
+
"OP_HOME=/x/.openpalm",
|
|
22
|
+
"OP_ADMIN_PORT=9000",
|
|
23
|
+
"OPENAI_API_KEY=sk-secret123",
|
|
24
|
+
"OP_CAP_LLM_MODEL=gpt-4",
|
|
25
|
+
"TTS_VOICE=alloy",
|
|
26
|
+
"OP_UI_LOGIN_PASSWORD=hunter2",
|
|
27
|
+
"OP_ASSISTANT_PORT=3800",
|
|
28
|
+
"",
|
|
29
|
+
].join("\n"),
|
|
30
|
+
);
|
|
31
|
+
writeFileSync(
|
|
32
|
+
join(h, "vault", "stack", "guardian.env"),
|
|
33
|
+
"CHANNEL_DISCORD_SECRET=disc-abc\nCHANNEL_SLACK_SECRET=slack-xyz\n",
|
|
34
|
+
);
|
|
35
|
+
writeFileSync(join(h, "vault", "stack", "services", "some.secret"), "svc-val\n");
|
|
36
|
+
writeFileSync(join(h, "vault", "user", "apprise.yaml"), "urls:\n - mailto://x\n");
|
|
37
|
+
writeFileSync(join(h, "config", "stack.yml"), "version: 1\ncapabilities:\n llm: openai\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Sorted top-level entry names under a directory. */
|
|
41
|
+
function entries(dir: string): string[] {
|
|
42
|
+
return readdirSync(dir).sort();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
beforeEach(() => {
|
|
46
|
+
prevOpHome = process.env.OP_HOME;
|
|
47
|
+
home = mkdtempSync(join(tmpdir(), "op-migrate-"));
|
|
48
|
+
process.env.OP_HOME = home;
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
if (prevOpHome === undefined) delete process.env.OP_HOME;
|
|
53
|
+
else process.env.OP_HOME = prevOpHome;
|
|
54
|
+
rmSync(home, { recursive: true, force: true });
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("ensureMigrated 0.10 → 0.11", () => {
|
|
58
|
+
it("migrates the vault layout, backs up, and stamps the layout version", () => {
|
|
59
|
+
seed010(home);
|
|
60
|
+
const report = ensureMigrated();
|
|
61
|
+
|
|
62
|
+
expect(report.migrated).toBe(true);
|
|
63
|
+
expect(report.from).toBe(0);
|
|
64
|
+
expect(report.to).toBe(CURRENT_LAYOUT_VERSION);
|
|
65
|
+
expect(report.backupDir).toBeTruthy();
|
|
66
|
+
expect(existsSync(report.backupDir!)).toBe(true);
|
|
67
|
+
|
|
68
|
+
const stackEnv = readFileSync(join(home, "knowledge", "env", "stack.env"), "utf-8");
|
|
69
|
+
expect(stackEnv).toContain("OP_HOST_UI_PORT=9000"); // renamed
|
|
70
|
+
expect(stackEnv).toContain("OP_TTS_VOICE=alloy"); // prefixed
|
|
71
|
+
expect(stackEnv).toContain("OP_ASSISTANT_PORT=3800"); // kept
|
|
72
|
+
expect(stackEnv).toContain(`OP_LAYOUT_VERSION=${CURRENT_LAYOUT_VERSION}`); // commit
|
|
73
|
+
expect(stackEnv).not.toContain("OPENAI_API_KEY"); // quarantined
|
|
74
|
+
expect(stackEnv).not.toContain("OP_CAP_LLM_MODEL"); // quarantined
|
|
75
|
+
|
|
76
|
+
expect(readFileSync(join(home, "knowledge", "env", "stack.env.removed-secrets.bak"), "utf-8"))
|
|
77
|
+
.toContain("OPENAI_API_KEY=sk-secret123");
|
|
78
|
+
expect(readFileSync(join(home, "knowledge", "secrets", "op_ui_login_password"), "utf-8").trim())
|
|
79
|
+
.toBe("hunter2");
|
|
80
|
+
expect(readFileSync(join(home, "knowledge", "secrets", "channel_discord_secret"), "utf-8").trim())
|
|
81
|
+
.toBe("disc-abc");
|
|
82
|
+
expect(readFileSync(join(home, "knowledge", "secrets", "channel_slack_secret"), "utf-8").trim())
|
|
83
|
+
.toBe("slack-xyz");
|
|
84
|
+
expect(existsSync(join(home, "knowledge", "secrets", "some.secret"))).toBe(true);
|
|
85
|
+
expect(existsSync(join(home, "knowledge", "secrets", "apprise.yaml"))).toBe(true);
|
|
86
|
+
// stack.yml is removed in 0.11.0 — the migration must NOT create one.
|
|
87
|
+
expect(existsSync(join(home, "config", "stack", "stack.yml"))).toBe(false);
|
|
88
|
+
expect(readFileSync(join(home, "knowledge", "env", "user.env"), "utf-8")).toContain("MY_PREF=hello");
|
|
89
|
+
|
|
90
|
+
// Non-destructive: originals untouched.
|
|
91
|
+
expect(existsSync(join(home, "vault", "stack", "stack.env"))).toBe(true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("ends with exactly the expected 0.11 directories and every datum in its proper location", () => {
|
|
95
|
+
seed010(home);
|
|
96
|
+
ensureMigrated();
|
|
97
|
+
|
|
98
|
+
// Only the expected top-level directories exist. The legacy vault/ is
|
|
99
|
+
// intentionally retained (copy-only recovery copy); nothing stray is created.
|
|
100
|
+
expect(entries(home)).toEqual(["config", "data", "knowledge", "vault"]);
|
|
101
|
+
|
|
102
|
+
// knowledge/ holds exactly the env + secrets stores.
|
|
103
|
+
expect(entries(join(home, "knowledge"))).toEqual(["env", "secrets"]);
|
|
104
|
+
|
|
105
|
+
// Every migrated datum landed in its proper 0.11 location — no missing, no extra.
|
|
106
|
+
expect(entries(join(home, "knowledge", "env"))).toEqual([
|
|
107
|
+
"stack.env",
|
|
108
|
+
"stack.env.removed-secrets.bak",
|
|
109
|
+
"user.env",
|
|
110
|
+
]);
|
|
111
|
+
expect(entries(join(home, "knowledge", "secrets"))).toEqual([
|
|
112
|
+
"apprise.yaml",
|
|
113
|
+
"channel_discord_secret",
|
|
114
|
+
"channel_slack_secret",
|
|
115
|
+
"op_ui_login_password",
|
|
116
|
+
"some.secret",
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
// The full backup landed under data/backups (and nowhere else top-level).
|
|
120
|
+
expect(existsSync(join(home, "data", "backups"))).toBe(true);
|
|
121
|
+
|
|
122
|
+
// The retained vault/ carries a safe-removal README.
|
|
123
|
+
expect(existsSync(join(home, "vault", "README.md"))).toBe(true);
|
|
124
|
+
|
|
125
|
+
// Nothing leaked into a wrong place: no 0.11 secrets under knowledge/env,
|
|
126
|
+
// and no plaintext login password left inside the migrated stack.env.
|
|
127
|
+
expect(existsSync(join(home, "knowledge", "env", "op_ui_login_password"))).toBe(false);
|
|
128
|
+
expect(readFileSync(join(home, "knowledge", "env", "stack.env"), "utf-8"))
|
|
129
|
+
.not.toContain("hunter2");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("migrates a minimal home (only stack.env) without creating stray files", () => {
|
|
133
|
+
mkdirSync(join(home, "vault", "stack"), { recursive: true });
|
|
134
|
+
mkdirSync(join(home, "data"), { recursive: true });
|
|
135
|
+
writeFileSync(
|
|
136
|
+
join(home, "vault", "stack", "stack.env"),
|
|
137
|
+
"OP_IMAGE_TAG=0.10.2\nOP_ASSISTANT_PORT=3800\n",
|
|
138
|
+
);
|
|
139
|
+
const report = ensureMigrated();
|
|
140
|
+
expect(report.migrated).toBe(true);
|
|
141
|
+
|
|
142
|
+
// env/ has only stack.env — no user.env, no removed-secrets.bak (there were
|
|
143
|
+
// no secrets/cap keys to quarantine).
|
|
144
|
+
expect(entries(join(home, "knowledge", "env"))).toEqual(["stack.env"]);
|
|
145
|
+
// secrets/ exists (created) but is empty — nothing to migrate.
|
|
146
|
+
expect(entries(join(home, "knowledge", "secrets"))).toEqual([]);
|
|
147
|
+
const stackEnv = readFileSync(join(home, "knowledge", "env", "stack.env"), "utf-8");
|
|
148
|
+
expect(stackEnv).toContain("OP_IMAGE_TAG=0.10.2");
|
|
149
|
+
expect(stackEnv).toContain(`OP_LAYOUT_VERSION=${CURRENT_LAYOUT_VERSION}`);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("does not write a removed-secrets.bak when stack.env has no secret/cap keys", () => {
|
|
153
|
+
mkdirSync(join(home, "vault", "stack"), { recursive: true });
|
|
154
|
+
mkdirSync(join(home, "data"), { recursive: true });
|
|
155
|
+
writeFileSync(join(home, "vault", "stack", "stack.env"), "OP_ASSISTANT_PORT=3800\n");
|
|
156
|
+
ensureMigrated();
|
|
157
|
+
expect(existsSync(join(home, "knowledge", "env", "stack.env.removed-secrets.bak"))).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("writes a safe-removal README into the retained vault/", () => {
|
|
161
|
+
seed010(home);
|
|
162
|
+
ensureMigrated();
|
|
163
|
+
const readme = readFileSync(join(home, "vault", "README.md"), "utf-8");
|
|
164
|
+
// It explains what the directory is and how to remove it safely.
|
|
165
|
+
expect(readme).toContain("RECOVERY COPY");
|
|
166
|
+
expect(readme).toContain("How to remove it safely");
|
|
167
|
+
expect(readme).toContain("gio trash");
|
|
168
|
+
expect(readme).toContain("data/backups");
|
|
169
|
+
// The original migrated files are still present (README is additive only).
|
|
170
|
+
expect(existsSync(join(home, "vault", "stack", "stack.env"))).toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("dry-run does not write the vault README", () => {
|
|
174
|
+
seed010(home);
|
|
175
|
+
ensureMigrated({ dryRun: true });
|
|
176
|
+
expect(existsSync(join(home, "vault", "README.md"))).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("does not clobber a pre-existing vault/README.md", () => {
|
|
180
|
+
seed010(home);
|
|
181
|
+
writeFileSync(join(home, "vault", "README.md"), "user's own notes\n");
|
|
182
|
+
ensureMigrated();
|
|
183
|
+
expect(readFileSync(join(home, "vault", "README.md"), "utf-8")).toBe("user's own notes\n");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("converts addons[] from a nested config/stack/stack.yml too", () => {
|
|
187
|
+
seed010(home);
|
|
188
|
+
rmSync(join(home, "config", "stack.yml"), { force: true });
|
|
189
|
+
mkdirSync(join(home, "config", "stack"), { recursive: true });
|
|
190
|
+
writeFileSync(join(home, "config", "stack", "stack.yml"), "version: 2\naddons:\n - voice\n");
|
|
191
|
+
ensureMigrated();
|
|
192
|
+
expect(readFileSync(join(home, "knowledge", "env", "stack.env"), "utf-8"))
|
|
193
|
+
.toContain("OP_ENABLED_ADDONS=voice");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("normalizes channel secret names to lowercase and skips invalid ones", () => {
|
|
197
|
+
mkdirSync(join(home, "vault", "stack"), { recursive: true });
|
|
198
|
+
mkdirSync(join(home, "data"), { recursive: true });
|
|
199
|
+
writeFileSync(join(home, "vault", "stack", "stack.env"), "OP_ASSISTANT_PORT=3800\n");
|
|
200
|
+
writeFileSync(
|
|
201
|
+
join(home, "vault", "stack", "guardian.env"),
|
|
202
|
+
// valid (mixed case → lowercase), and an invalid name with a space (skipped).
|
|
203
|
+
"CHANNEL_Discord_SECRET=abc\nCHANNEL_BAD NAME_SECRET=nope\n",
|
|
204
|
+
);
|
|
205
|
+
ensureMigrated();
|
|
206
|
+
expect(existsSync(join(home, "knowledge", "secrets", "channel_discord_secret"))).toBe(true);
|
|
207
|
+
expect(entries(join(home, "knowledge", "secrets"))).toEqual(["channel_discord_secret"]);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("preserves user-edited destination files (copy-only, skip-if-exists)", () => {
|
|
211
|
+
seed010(home);
|
|
212
|
+
// Simulate a partially-migrated home where the user already has a user.env.
|
|
213
|
+
mkdirSync(join(home, "knowledge", "env"), { recursive: true });
|
|
214
|
+
writeFileSync(join(home, "knowledge", "env", "user.env"), "MY_PREF=edited-by-user\n");
|
|
215
|
+
ensureMigrated();
|
|
216
|
+
// The existing destination must NOT be clobbered by the vault copy.
|
|
217
|
+
expect(readFileSync(join(home, "knowledge", "env", "user.env"), "utf-8"))
|
|
218
|
+
.toContain("edited-by-user");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("copies auth.json best-effort and surfaces a verify-providers note", () => {
|
|
222
|
+
seed010(home);
|
|
223
|
+
writeFileSync(join(home, "vault", "stack", "auth.json"), '{"openai":{"type":"api"}}');
|
|
224
|
+
const report = ensureMigrated();
|
|
225
|
+
expect(existsSync(join(home, "knowledge", "secrets", "auth.json"))).toBe(true);
|
|
226
|
+
expect(report.notes.join(" ")).toContain("auth.json");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("converts a legacy stack.yml addons[] into OP_ENABLED_ADDONS", () => {
|
|
230
|
+
seed010(home);
|
|
231
|
+
writeFileSync(join(home, "config", "stack.yml"), "version: 2\naddons:\n - voice\n - discord\n");
|
|
232
|
+
ensureMigrated();
|
|
233
|
+
const stackEnv = readFileSync(join(home, "knowledge", "env", "stack.env"), "utf-8");
|
|
234
|
+
expect(stackEnv).toContain("OP_ENABLED_ADDONS=discord,voice");
|
|
235
|
+
expect(existsSync(join(home, "config", "stack", "stack.yml"))).toBe(false);
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
it("is idempotent — a second run is a no-op", () => {
|
|
239
|
+
seed010(home);
|
|
240
|
+
ensureMigrated();
|
|
241
|
+
const second = ensureMigrated();
|
|
242
|
+
expect(second.migrated).toBe(false);
|
|
243
|
+
expect(second.to).toBe(CURRENT_LAYOUT_VERSION);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("dry-run writes nothing", () => {
|
|
247
|
+
seed010(home);
|
|
248
|
+
const report = ensureMigrated({ dryRun: true });
|
|
249
|
+
expect(report.migrated).toBe(true);
|
|
250
|
+
expect(existsSync(join(home, "knowledge", "env", "stack.env"))).toBe(false);
|
|
251
|
+
expect(report.backupDir).toBeNull();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("aborts (no changes) when the backup cannot be created", () => {
|
|
255
|
+
seed010(home);
|
|
256
|
+
// Make data/ a file so backupOpenPalmHome's mkdir of data/backups fails.
|
|
257
|
+
rmSync(join(home, "data"), { recursive: true, force: true });
|
|
258
|
+
writeFileSync(join(home, "data"), "not a dir");
|
|
259
|
+
expect(() => ensureMigrated()).toThrow(MigrationError);
|
|
260
|
+
expect(existsSync(join(home, "knowledge", "env", "stack.env"))).toBe(false);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("treats an already-0.11 home (no vault) as current and stamps it", () => {
|
|
264
|
+
mkdirSync(join(home, "knowledge", "env"), { recursive: true });
|
|
265
|
+
writeFileSync(join(home, "knowledge", "env", "stack.env"), "OP_IMAGE_TAG=0.11.0\n");
|
|
266
|
+
const report = ensureMigrated();
|
|
267
|
+
expect(report.migrated).toBe(false);
|
|
268
|
+
expect(report.to).toBe(CURRENT_LAYOUT_VERSION);
|
|
269
|
+
expect(readFileSync(join(home, "knowledge", "env", "stack.env"), "utf-8"))
|
|
270
|
+
.toContain(`OP_LAYOUT_VERSION=${CURRENT_LAYOUT_VERSION}`);
|
|
271
|
+
});
|
|
272
|
+
});
|