@openpalm/lib 0.11.0-beta.9 → 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/README.md +1 -1
- package/package.json +1 -1
- package/src/control-plane/akm-sources.test.ts +206 -0
- package/src/control-plane/akm-sources.ts +234 -0
- package/src/control-plane/akm-user-env.test.ts +142 -0
- package/src/control-plane/akm-user-env.ts +167 -0
- package/src/control-plane/backup.ts +14 -5
- package/src/control-plane/channels.ts +48 -29
- package/src/control-plane/cleanup-guardrails.test.ts +1 -1
- package/src/control-plane/compose-args.test.ts +69 -30
- package/src/control-plane/compose-args.ts +62 -8
- package/src/control-plane/config-persistence.ts +102 -136
- package/src/control-plane/core-assets.ts +45 -60
- package/src/control-plane/defaults.ts +16 -0
- package/src/control-plane/docker.ts +15 -14
- package/src/control-plane/env.test.ts +10 -10
- package/src/control-plane/env.ts +16 -1
- package/src/control-plane/extends-support.test.ts +8 -8
- package/src/control-plane/fs-atomic.ts +15 -0
- package/src/control-plane/home.ts +34 -46
- package/src/control-plane/host-akm-sharing.test.ts +145 -0
- package/src/control-plane/host-akm-sharing.ts +129 -0
- package/src/control-plane/host-opencode.test.ts +82 -10
- package/src/control-plane/host-opencode.ts +42 -13
- package/src/control-plane/install-edge-cases.test.ts +100 -136
- package/src/control-plane/install-lock.ts +7 -7
- package/src/control-plane/lifecycle.ts +45 -40
- package/src/control-plane/markdown-task.ts +30 -50
- package/src/control-plane/migrations.test.ts +272 -0
- package/src/control-plane/migrations.ts +423 -0
- package/src/control-plane/opencode-client.ts +1 -1
- package/src/control-plane/paths.ts +61 -46
- package/src/control-plane/profile-ids.ts +21 -0
- package/src/control-plane/provider-models.ts +3 -3
- package/src/control-plane/registry.test.ts +107 -90
- package/src/control-plane/registry.ts +301 -110
- package/src/control-plane/rollback.ts +8 -38
- package/src/control-plane/scheduler.ts +10 -7
- package/src/control-plane/secret-audit.test.ts +159 -0
- package/src/control-plane/secret-audit.ts +255 -0
- package/src/control-plane/secret-mappings.ts +2 -2
- package/src/control-plane/secrets-files.test.ts +99 -0
- package/src/control-plane/secrets-files.ts +113 -0
- package/src/control-plane/secrets.ts +113 -86
- package/src/control-plane/setup-config.schema.json +1 -1
- package/src/control-plane/setup-status.ts +6 -11
- package/src/control-plane/setup.test.ts +137 -61
- package/src/control-plane/setup.ts +82 -63
- package/src/control-plane/skeleton-guardrail.test.ts +66 -56
- package/src/control-plane/spec-to-env.test.ts +63 -26
- package/src/control-plane/spec-to-env.ts +51 -14
- package/src/control-plane/task-files.test.ts +45 -0
- package/src/control-plane/task-files.ts +51 -0
- package/src/control-plane/types.ts +2 -4
- package/src/control-plane/ui-assets.test.ts +333 -0
- package/src/control-plane/ui-assets.ts +290 -142
- package/src/control-plane/validate.ts +13 -15
- package/src/index.ts +96 -26
- package/src/control-plane/akm-vault.test.ts +0 -105
- package/src/control-plane/akm-vault.ts +0 -311
- package/src/control-plane/core-assets.test.ts +0 -104
- package/src/control-plane/migrate-0110.test.ts +0 -177
- package/src/control-plane/migrate-0110.ts +0 -99
- package/src/control-plane/registry-components.test.ts +0 -391
- package/src/control-plane/stack-spec.test.ts +0 -94
- package/src/control-plane/stack-spec.ts +0 -67
|
@@ -4,21 +4,22 @@
|
|
|
4
4
|
* These functions are consumed by both the CLI and the Electron shell — they
|
|
5
5
|
* must use only Node.js-compatible APIs (no Bun.spawn, Bun.write, etc.).
|
|
6
6
|
*
|
|
7
|
-
* Source resolution order (
|
|
7
|
+
* Source resolution order (UI build and .openpalm/ skeleton):
|
|
8
8
|
* 1. OPENPALM_REPO_ROOT env var — explicit dev override
|
|
9
9
|
* 2. Relative to import.meta.url — works for `bun run` / source installs
|
|
10
10
|
* 3. Relative to process.execPath — works for compiled Bun binary in repo
|
|
11
|
-
* 4. null →
|
|
11
|
+
* 4. null → remote download (the UI build from the @openpalm/ui npm registry
|
|
12
|
+
* tarball; the .openpalm skeleton from the GitHub repo tarball)
|
|
12
13
|
*/
|
|
13
14
|
import {
|
|
14
15
|
existsSync, mkdirSync, readdirSync, copyFileSync,
|
|
15
|
-
|
|
16
|
+
writeFileSync, readFileSync, rmSync, realpathSync, renameSync,
|
|
16
17
|
} from 'node:fs';
|
|
17
18
|
import { join, dirname, relative } from 'node:path';
|
|
18
19
|
import { fileURLToPath } from 'node:url';
|
|
19
20
|
import { createHash } from 'node:crypto';
|
|
20
21
|
import { x as tarExtract } from 'tar';
|
|
21
|
-
import {
|
|
22
|
+
import { resolveBackupsDir, resolveDataDir } from './home.js';
|
|
22
23
|
import { createLogger } from '../logger.js';
|
|
23
24
|
|
|
24
25
|
const logger = createLogger('lib:ui-assets');
|
|
@@ -87,13 +88,15 @@ export function resolveLocalOpenpalmDir(): string | null {
|
|
|
87
88
|
() => process.env.OPENPALM_REPO_ROOT
|
|
88
89
|
? join(process.env.OPENPALM_REPO_ROOT, '.openpalm')
|
|
89
90
|
: null,
|
|
90
|
-
// 2.
|
|
91
|
+
// 2. Electron extraResources — openpalm-skeleton/ placed alongside the asar
|
|
92
|
+
() => process.env.OPENPALM_SKELETON_DIR ?? null,
|
|
93
|
+
// 3. Relative to this source file (dev / bun run)
|
|
91
94
|
() => {
|
|
92
95
|
const meta = fileURLToPath(import.meta.url);
|
|
93
96
|
if (meta.startsWith('/$bunfs/')) return null;
|
|
94
97
|
return join(dirname(meta), '..', '..', '..', '..', '.openpalm');
|
|
95
98
|
},
|
|
96
|
-
//
|
|
99
|
+
// 4. Relative to the compiled binary on disk
|
|
97
100
|
() => join(dirname(realpathSync(process.execPath)), '..', '..', '..', '.openpalm'),
|
|
98
101
|
);
|
|
99
102
|
}
|
|
@@ -105,18 +108,44 @@ export function resolveLocalOpenpalmDir(): string | null {
|
|
|
105
108
|
* Falls back to downloading the repo tarball from GitHub when no local
|
|
106
109
|
* skeleton is found (production binary, packaged Electron app).
|
|
107
110
|
*/
|
|
111
|
+
/** Version stamp recording which skeleton version OP_HOME was last seeded from. */
|
|
112
|
+
export const SKELETON_VERSION_STAMP = '.skeleton-version';
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Seed the bundled `.openpalm/` skeleton into OP_HOME — ONCE PER VERSION.
|
|
116
|
+
*
|
|
117
|
+
* Electron calls this on every launch; without a guard it re-copied the entire
|
|
118
|
+
* skeleton tree each time (wasteful, and it re-materialized files a user/process
|
|
119
|
+
* had deliberately removed). We stamp OP_HOME/.skeleton-version with `repoRef`
|
|
120
|
+
* after a successful seed and skip the copy when it already matches — so a given
|
|
121
|
+
* version seeds once and an upgrade re-seeds (skipExisting still preserves any
|
|
122
|
+
* user edits). To force a re-seed, delete the stamp.
|
|
123
|
+
*/
|
|
108
124
|
export async function seedOpenPalmDir(
|
|
109
125
|
repoRef: string,
|
|
110
126
|
homeDir: string,
|
|
111
127
|
_configDir: string,
|
|
112
|
-
|
|
128
|
+
_dataDir: string,
|
|
113
129
|
): Promise<void> {
|
|
130
|
+
const stampPath = join(homeDir, SKELETON_VERSION_STAMP);
|
|
131
|
+
if (existsSync(stampPath)) {
|
|
132
|
+
try {
|
|
133
|
+
if (readFileSync(stampPath, 'utf-8').trim() === repoRef.trim()) {
|
|
134
|
+
logger.debug('skeleton already seeded for this version — skipping', { repoRef });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
} catch { /* unreadable stamp → re-seed */ }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const stamp = (): void => {
|
|
141
|
+
try { writeFileSync(stampPath, `${repoRef}\n`); } catch { /* best-effort */ }
|
|
142
|
+
};
|
|
143
|
+
|
|
114
144
|
const local = resolveLocalOpenpalmDir();
|
|
115
145
|
if (local) {
|
|
116
|
-
logger.debug('seeding .openpalm from local source', { src: local });
|
|
146
|
+
logger.debug('seeding .openpalm from local source', { src: local, repoRef });
|
|
117
147
|
copyTree(local, homeDir, { skipExisting: true });
|
|
118
|
-
|
|
119
|
-
copyTree(join(local, 'state', 'registry'), join(homeDir, 'state', 'registry'));
|
|
148
|
+
stamp();
|
|
120
149
|
return;
|
|
121
150
|
}
|
|
122
151
|
|
|
@@ -137,8 +166,7 @@ export async function seedOpenPalmDir(
|
|
|
137
166
|
const srcOpenpalm = join(tmpDir, '.openpalm');
|
|
138
167
|
if (!existsSync(srcOpenpalm)) throw new Error('.openpalm/ not found in tarball');
|
|
139
168
|
copyTree(srcOpenpalm, homeDir, { skipExisting: true });
|
|
140
|
-
|
|
141
|
-
copyTree(join(srcOpenpalm, 'state', 'registry'), join(homeDir, 'state', 'registry'));
|
|
169
|
+
stamp();
|
|
142
170
|
} finally {
|
|
143
171
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
144
172
|
}
|
|
@@ -148,7 +176,7 @@ export async function seedOpenPalmDir(
|
|
|
148
176
|
|
|
149
177
|
/**
|
|
150
178
|
* Locate the compiled SvelteKit UI build on disk.
|
|
151
|
-
* Returns null when not found — triggers
|
|
179
|
+
* Returns null when not found — triggers the npm registry download in seedUiBuild.
|
|
152
180
|
*/
|
|
153
181
|
export function resolveLocalUiBuild(): string | null {
|
|
154
182
|
return resolveLocalCandidate(
|
|
@@ -179,140 +207,258 @@ export function resolveLocalUiBuild(): string | null {
|
|
|
179
207
|
);
|
|
180
208
|
}
|
|
181
209
|
|
|
182
|
-
function readUiVersionFile(dir: string): string | null {
|
|
183
|
-
try { return readFileSync(join(dir, 'version.txt'), 'utf-8').trim(); } catch { return null; }
|
|
184
|
-
}
|
|
185
|
-
|
|
186
210
|
/**
|
|
187
211
|
* Resolve the best available UI build directory at runtime.
|
|
188
212
|
*
|
|
189
213
|
* Priority:
|
|
190
|
-
* 1. OP_HOME/
|
|
191
|
-
* 2. Bundled / local build (Electron extraResources, source checkout)
|
|
192
|
-
|
|
214
|
+
* 1. OP_HOME/data/ui/ — user-installed or auto-updated build
|
|
215
|
+
* 2. Bundled / local build (Electron extraResources, OPENPALM_REPO_ROOT, source checkout)
|
|
216
|
+
*/
|
|
217
|
+
/** Filename of the build-time version stamp written into the UI build root. */
|
|
218
|
+
export const UI_VERSION_STAMP = '.openpalm-ui-version';
|
|
219
|
+
|
|
220
|
+
/** Read the stamped UI version from a build dir, or null if absent/unreadable. */
|
|
221
|
+
export function readUiBuildVersion(dir: string): string | null {
|
|
222
|
+
try {
|
|
223
|
+
const v = readFileSync(join(dir, UI_VERSION_STAMP), 'utf-8').trim();
|
|
224
|
+
return v || null;
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Resolve which UI build to run.
|
|
232
|
+
*
|
|
233
|
+
* Two channels exist: the bundled build (shipped inside the AppImage / source
|
|
234
|
+
* tree) and `data/ui` (operator-updatable, seeded from the @openpalm/ui npm
|
|
235
|
+
* registry tarball). To fix
|
|
236
|
+
* the stale-`data/ui` shadowing bug AND stay forward-compatible with updating the
|
|
237
|
+
* UI without shipping a new app (D5), selection is VERSION-AWARE:
|
|
193
238
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
239
|
+
* - If only one channel has a build → use it.
|
|
240
|
+
* - If both exist → use `data/ui` ONLY when it is strictly NEWER than the
|
|
241
|
+
* bundled build (per the version stamp); otherwise prefer the bundled build.
|
|
242
|
+
* An unstamped/older `data/ui` never shadows a newer bundled build.
|
|
243
|
+
*
|
|
244
|
+
* This means a fresh app runs its bundled UI, and a future "update UI only" flow
|
|
245
|
+
* (seed a newer-stamped build into data/ui) is picked up automatically — no app
|
|
246
|
+
* reinstall required.
|
|
196
247
|
*/
|
|
197
248
|
export function resolveUiBuildDir(): string {
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
249
|
+
const dataBuild = join(resolveDataDir(), 'ui');
|
|
250
|
+
const hasData = existsSync(join(dataBuild, 'index.js'));
|
|
251
|
+
// resolveLocalUiBuild()'s env/resourcesPath candidates only check the dir
|
|
252
|
+
// exists, not that it holds a runnable build — require index.js before trusting it.
|
|
253
|
+
const bundledRaw = resolveLocalUiBuild();
|
|
254
|
+
const bundled = bundledRaw && existsSync(join(bundledRaw, 'index.js')) ? bundledRaw : null;
|
|
255
|
+
|
|
256
|
+
if (hasData && bundled) {
|
|
257
|
+
const dataVer = readUiBuildVersion(dataBuild);
|
|
258
|
+
const bundledVer = readUiBuildVersion(bundled);
|
|
259
|
+
// data/ui wins only when we can prove it's strictly newer.
|
|
260
|
+
if (dataVer && bundledVer && compareVersionTags(dataVer, bundledVer) > 0) return dataBuild;
|
|
261
|
+
return bundled;
|
|
208
262
|
}
|
|
263
|
+
if (hasData) return dataBuild;
|
|
264
|
+
if (bundled) return bundled;
|
|
265
|
+
return dataBuild; // nothing present yet → caller triggers seedUiBuild
|
|
266
|
+
}
|
|
209
267
|
|
|
210
|
-
|
|
211
|
-
|
|
268
|
+
/**
|
|
269
|
+
* The UI ships as `@openpalm/ui` on npm — a self-contained `adapter-node`
|
|
270
|
+
* bundle (only `build/` is published; no `node_modules` is needed at runtime,
|
|
271
|
+
* because the build bundles every dependency). The desktop and host updaters
|
|
272
|
+
* fetch the registry TARBALL over plain HTTPS and verify its integrity hash;
|
|
273
|
+
* they never invoke a package manager (the Electron runtime has none). npm gives
|
|
274
|
+
* us, for free, the four things the GitHub-release path forced us to hand-roll:
|
|
275
|
+
* an independent version line, `latest`/`next` dist-tag channels (prerelease-
|
|
276
|
+
* aware — unlike `releases/latest`, which silently excludes prereleases),
|
|
277
|
+
* immutable versions, and a sha512 integrity we verify fail-closed.
|
|
278
|
+
*/
|
|
279
|
+
const NPM_REGISTRY = 'https://registry.npmjs.org';
|
|
280
|
+
const UI_PACKAGE = '@openpalm/ui';
|
|
281
|
+
|
|
282
|
+
interface NpmUiManifest {
|
|
283
|
+
version: string;
|
|
284
|
+
tarball: string;
|
|
285
|
+
/** Subresource-integrity string ("sha512-<base64>"); null if the registry omitted it. */
|
|
286
|
+
integrity: string | null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Strip a single leading 'v' so a release ref (v1.2.3) becomes an npm version (1.2.3). */
|
|
290
|
+
function toNpmVersion(repoRef: string): string {
|
|
291
|
+
return repoRef.replace(/^v/, '');
|
|
212
292
|
}
|
|
213
293
|
|
|
214
294
|
/**
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
* state/ui/ is automatically included in backups because
|
|
222
|
-
* backupOpenPalmHome() copies all of OP_HOME/state/.
|
|
295
|
+
* The npm dist-tag channel a release stream tracks: prereleases ride `next`,
|
|
296
|
+
* stable rides `latest`. `@openpalm/ui` is independently versioned (it publishes
|
|
297
|
+
* on its own `publish-ui.yml` workflow, like the channel adapters), so the
|
|
298
|
+
* desktop/host updaters can't compare a UI version against the app version —
|
|
299
|
+
* they pick the CHANNEL from the app's release stream and then resolve the
|
|
300
|
+
* newest UI on that channel.
|
|
223
301
|
*/
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
return createHash('sha256').update(data).digest('hex');
|
|
302
|
+
export function uiUpdateChannel(appVersion: string): 'latest' | 'next' {
|
|
303
|
+
return appVersion.includes('-') ? 'next' : 'latest';
|
|
227
304
|
}
|
|
228
305
|
|
|
229
306
|
/**
|
|
230
|
-
*
|
|
231
|
-
*
|
|
307
|
+
* Resolve the npm manifest for `@openpalm/ui` by exact version OR dist-tag.
|
|
308
|
+
* `GET <registry>/@openpalm/ui/<version-or-tag>` returns the abbreviated
|
|
309
|
+
* manifest (version + dist.tarball + dist.integrity). Throws on non-OK.
|
|
232
310
|
*/
|
|
233
|
-
function
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
311
|
+
async function fetchNpmUiManifest(versionOrTag: string): Promise<NpmUiManifest> {
|
|
312
|
+
const url = `${NPM_REGISTRY}/${UI_PACKAGE}/${versionOrTag}`;
|
|
313
|
+
const res = await fetchWithRetry(url);
|
|
314
|
+
if (!res.ok) throw new Error(`npm registry returned HTTP ${res.status} for ${UI_PACKAGE}@${versionOrTag}`);
|
|
315
|
+
const m = await res.json() as { version?: string; dist?: { tarball?: string; integrity?: string } };
|
|
316
|
+
if (!m.version || !m.dist?.tarball) {
|
|
317
|
+
throw new Error(`npm manifest for ${UI_PACKAGE}@${versionOrTag} is missing version/dist.tarball`);
|
|
240
318
|
}
|
|
241
|
-
return
|
|
319
|
+
return { version: m.version, tarball: m.dist.tarball, integrity: m.dist.integrity ?? null };
|
|
242
320
|
}
|
|
243
321
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
322
|
+
/**
|
|
323
|
+
* Verify a Subresource-Integrity string against the bytes. FAIL-CLOSED: a
|
|
324
|
+
* present-but-wrong hash throws (the corruption / tamper case). A registry that
|
|
325
|
+
* omits the hash entirely (legacy metadata) is logged and allowed — modern npm
|
|
326
|
+
* always provides one, so this only affects pathological registry responses.
|
|
327
|
+
*/
|
|
328
|
+
function verifyNpmIntegrity(data: Uint8Array, integrity: string): void {
|
|
329
|
+
const entries = integrity.trim().split(/\s+/);
|
|
330
|
+
const entry = entries.find(e => e.startsWith('sha512-')) ?? entries.find(e => e.startsWith('sha256-'));
|
|
331
|
+
if (!entry) throw new Error(`unrecognized integrity format: ${integrity}`);
|
|
332
|
+
const dash = entry.indexOf('-');
|
|
333
|
+
const algo = entry.slice(0, dash);
|
|
334
|
+
const expected = entry.slice(dash + 1);
|
|
335
|
+
const actual = createHash(algo).update(data).digest('base64');
|
|
336
|
+
if (actual !== expected) throw new Error(`UI bundle integrity mismatch (${algo})`);
|
|
248
337
|
}
|
|
249
338
|
|
|
250
|
-
|
|
251
|
-
|
|
339
|
+
/**
|
|
340
|
+
* Download `@openpalm/ui`'s npm tarball, verify integrity, and install its
|
|
341
|
+
* `build/` contents into `uiDir`. npm tarballs nest everything under `package/`
|
|
342
|
+
* and we publish `files: ["build"]`, so the bundle lives at `package/build/**` —
|
|
343
|
+
* strip 2 path components and filter to that subtree.
|
|
344
|
+
*
|
|
345
|
+
* FAIL-CLOSED + non-destructive: we throw if integrity is missing or mismatched
|
|
346
|
+
* (the contract is that npm always provides a sha512), and we extract into a
|
|
347
|
+
* STAGING dir and validate it has a runnable `index.js` before swapping it over
|
|
348
|
+
* `uiDir` — so a truncated download or bad tarball never leaves `uiDir` empty.
|
|
349
|
+
*/
|
|
350
|
+
async function downloadNpmUiBundle(manifest: NpmUiManifest, uiDir: string, dataDir: string): Promise<void> {
|
|
351
|
+
const res = await fetchWithRetry(manifest.tarball);
|
|
352
|
+
if (!res.ok) throw new Error(`Failed to download UI bundle (HTTP ${res.status})`);
|
|
353
|
+
const data = new Uint8Array(await res.arrayBuffer());
|
|
354
|
+
|
|
355
|
+
// Verify BEFORE touching anything. Fail closed: a missing hash is treated as a
|
|
356
|
+
// verification failure, not a warning — modern npm always supplies dist.integrity,
|
|
357
|
+
// so its absence means a non-canonical/altered registry response.
|
|
358
|
+
if (!manifest.integrity) {
|
|
359
|
+
throw new Error(`npm manifest for ${UI_PACKAGE}@${manifest.version} has no integrity hash — refusing to install unverified`);
|
|
360
|
+
}
|
|
361
|
+
verifyNpmIntegrity(data, manifest.integrity);
|
|
362
|
+
logger.debug('UI bundle integrity verified', { version: manifest.version });
|
|
363
|
+
|
|
364
|
+
const tmpTar = join(dataDir, '.ui-build.tgz.tmp');
|
|
365
|
+
const staging = join(dataDir, '.ui-build.staging');
|
|
366
|
+
try {
|
|
367
|
+
rmSync(staging, { recursive: true, force: true });
|
|
368
|
+
mkdirSync(staging, { recursive: true });
|
|
369
|
+
writeFileSync(tmpTar, data);
|
|
370
|
+
await tarExtract({
|
|
371
|
+
file: tmpTar,
|
|
372
|
+
cwd: staging,
|
|
373
|
+
strip: 2,
|
|
374
|
+
filter: (p) => p.startsWith('package/build/'),
|
|
375
|
+
});
|
|
376
|
+
// Validate the staged build is runnable before destroying the live one.
|
|
377
|
+
if (!existsSync(join(staging, 'index.js'))) {
|
|
378
|
+
throw new Error('downloaded UI bundle is missing build/index.js');
|
|
379
|
+
}
|
|
380
|
+
// Swap: only now do we remove the existing build and move staging into place.
|
|
381
|
+
rmSync(uiDir, { recursive: true, force: true });
|
|
382
|
+
renameSync(staging, uiDir);
|
|
383
|
+
} finally {
|
|
384
|
+
rmSync(tmpTar, { force: true });
|
|
385
|
+
rmSync(staging, { recursive: true, force: true });
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Install the UI build to OP_HOME/data/ui/.
|
|
391
|
+
*
|
|
392
|
+
* Copies from the local/bundled `packages/ui/build/` when available, otherwise
|
|
393
|
+
* downloads the `@openpalm/ui` bundle from npm (by exact version; `repoRef` may
|
|
394
|
+
* be a tag like `latest`/`next` via the admin route). Always replaces existing
|
|
395
|
+
* content. data/ui/ is included in backups because backupOpenPalmHome() copies
|
|
396
|
+
* all of OP_HOME/data/.
|
|
397
|
+
*/
|
|
398
|
+
export async function seedUiBuild(repoRef: string, dataDir: string, options?: { forceRemote?: boolean }): Promise<void> {
|
|
399
|
+
const uiDir = join(dataDir, 'ui');
|
|
252
400
|
mkdirSync(uiDir, { recursive: true });
|
|
253
401
|
|
|
254
402
|
const local = options?.forceRemote ? null : resolveLocalUiBuild();
|
|
255
403
|
if (local) {
|
|
256
404
|
logger.debug('seeding UI build from local source', { src: local });
|
|
257
405
|
copyTree(local, uiDir);
|
|
258
|
-
|
|
406
|
+
// The build script (stamp-version.mjs) writes .openpalm-ui-version into build/.
|
|
407
|
+
// A local build missing it would seed an UNSTAMPED data/ui, which makes the
|
|
408
|
+
// update check unable to read the running UI version. Surface it loudly rather
|
|
409
|
+
// than silently degrade update behavior.
|
|
410
|
+
if (!readUiBuildVersion(uiDir)) {
|
|
411
|
+
logger.warn('seeded UI build has no version stamp — auto-update comparison will be unreliable', { src: local });
|
|
412
|
+
}
|
|
259
413
|
return;
|
|
260
414
|
}
|
|
261
415
|
|
|
262
|
-
const
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
logger.debug('downloading UI build', { url: tarballUrl });
|
|
266
|
-
|
|
267
|
-
const tmpTar = join(stateDir, '.ui-build.tar.gz.tmp');
|
|
268
|
-
try {
|
|
269
|
-
// Download tarball and checksums file in parallel (checksums best-effort)
|
|
270
|
-
const [tarRes, csRes] = await Promise.all([
|
|
271
|
-
fetchWithRetry(tarballUrl),
|
|
272
|
-
fetchWithRetry(checksumUrl).catch(() => null),
|
|
273
|
-
]);
|
|
274
|
-
if (!tarRes.ok) throw new Error(`Failed to download UI build (HTTP ${tarRes.status})`);
|
|
275
|
-
|
|
276
|
-
const tarData = new Uint8Array(await tarRes.arrayBuffer());
|
|
277
|
-
|
|
278
|
-
// Verify SHA-256 if the checksums file was available
|
|
279
|
-
if (csRes?.ok) {
|
|
280
|
-
const checksums = parseChecksumsFile(await csRes.text());
|
|
281
|
-
const expected = checksums.get('ui-build.tar.gz');
|
|
282
|
-
if (expected) {
|
|
283
|
-
const actual = sha256Hex(tarData);
|
|
284
|
-
if (actual !== expected) {
|
|
285
|
-
throw new Error(`UI build checksum mismatch (expected ${expected}, got ${actual})`);
|
|
286
|
-
}
|
|
287
|
-
logger.debug('UI build checksum verified', { sha256: actual });
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
writeFileSync(tmpTar, tarData);
|
|
292
|
-
|
|
293
|
-
// Clear stale files before extracting so old build files don't persist
|
|
294
|
-
rmSync(uiDir, { recursive: true, force: true });
|
|
295
|
-
mkdirSync(uiDir, { recursive: true });
|
|
296
|
-
// Cross-platform extraction via the `tar` npm package — no shell dependency
|
|
297
|
-
await tarExtract({ file: tmpTar, cwd: uiDir, strip: 1 });
|
|
298
|
-
writeFileSync(join(uiDir, 'version.txt'), repoRef.replace(/^v/, ''));
|
|
299
|
-
} finally {
|
|
300
|
-
rmSync(tmpTar, { force: true });
|
|
301
|
-
}
|
|
416
|
+
const manifest = await fetchNpmUiManifest(toNpmVersion(repoRef));
|
|
417
|
+
logger.debug('downloading UI build from npm', { version: manifest.version });
|
|
418
|
+
await downloadNpmUiBundle(manifest, uiDir, dataDir);
|
|
302
419
|
}
|
|
303
420
|
|
|
304
421
|
// ── UI update check ──────────────────────────────────────────────────────────
|
|
305
422
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
/** Returns 1 if a > b, -1 if a < b, 0 if equal. Strips leading 'v'. */
|
|
423
|
+
/** Returns 1 if a > b, -1 if a < b, 0 if equal. Strips leading 'v'. Handles pre-release tags. */
|
|
309
424
|
function compareVersionTags(a: string, b: string): number {
|
|
310
|
-
const parse = (v: string) =>
|
|
311
|
-
|
|
312
|
-
|
|
425
|
+
const parse = (v: string): [number, number, number, string | null] => {
|
|
426
|
+
const clean = v.replace(/^v/, '');
|
|
427
|
+
const dashIdx = clean.indexOf('-');
|
|
428
|
+
const main = dashIdx === -1 ? clean : clean.slice(0, dashIdx);
|
|
429
|
+
const pre = dashIdx === -1 ? null : clean.slice(dashIdx + 1);
|
|
430
|
+
const parts = main.split('.').map(Number);
|
|
431
|
+
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0, pre];
|
|
432
|
+
};
|
|
433
|
+
const comparePre = (x: string, y: string): number => {
|
|
434
|
+
const xp = x.split('.');
|
|
435
|
+
const yp = y.split('.');
|
|
436
|
+
for (let i = 0; i < Math.max(xp.length, yp.length); i++) {
|
|
437
|
+
if (i >= xp.length) return -1;
|
|
438
|
+
if (i >= yp.length) return 1;
|
|
439
|
+
const xn = Number(xp[i]);
|
|
440
|
+
const yn = Number(yp[i]);
|
|
441
|
+
const xIsNum = !isNaN(xn);
|
|
442
|
+
const yIsNum = !isNaN(yn);
|
|
443
|
+
if (xIsNum && yIsNum) {
|
|
444
|
+
if (xn !== yn) return xn > yn ? 1 : -1;
|
|
445
|
+
} else if (xIsNum !== yIsNum) {
|
|
446
|
+
return xIsNum ? -1 : 1; // numeric < alphanumeric per semver
|
|
447
|
+
} else {
|
|
448
|
+
if (xp[i] !== yp[i]) return xp[i]! > yp[i]! ? 1 : -1;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return 0;
|
|
452
|
+
};
|
|
453
|
+
const [aM, am, ap, aPre] = parse(a);
|
|
454
|
+
const [bM, bm, bp, bPre] = parse(b);
|
|
313
455
|
if (aM !== bM) return aM > bM ? 1 : -1;
|
|
314
456
|
if (am !== bm) return am > bm ? 1 : -1;
|
|
315
457
|
if (ap !== bp) return ap > bp ? 1 : -1;
|
|
458
|
+
// Same numeric version: stable > pre-release (semver spec)
|
|
459
|
+
if (aPre === null && bPre !== null) return 1;
|
|
460
|
+
if (aPre !== null && bPre === null) return -1;
|
|
461
|
+
if (aPre !== null && bPre !== null) return comparePre(aPre, bPre);
|
|
316
462
|
return 0;
|
|
317
463
|
}
|
|
318
464
|
|
|
@@ -323,58 +469,60 @@ export interface UiBuildUpdateResult {
|
|
|
323
469
|
}
|
|
324
470
|
|
|
325
471
|
/**
|
|
326
|
-
* Check
|
|
472
|
+
* Check npm for a newer `@openpalm/ui` build and apply it if one exists.
|
|
473
|
+
*
|
|
474
|
+
* `@openpalm/ui` is INDEPENDENTLY versioned, so we do NOT compare against the
|
|
475
|
+
* app/platform version. We pick the dist-tag CHANNEL from the app's release
|
|
476
|
+
* stream (`appVersion`: prerelease → `next`, stable → `latest`) and compare the
|
|
477
|
+
* newest UI on that channel against the version actually on disk (the stamp in
|
|
478
|
+
* the resolved build). This tracks prerelease UIs for prerelease apps and fixes
|
|
479
|
+
* the `releases/latest`-excludes-prereleases blind spot.
|
|
327
480
|
*
|
|
328
481
|
* When an update is available:
|
|
329
|
-
* 1. Move
|
|
330
|
-
* 2. Download
|
|
482
|
+
* 1. Move data/ui/ → data/backups/ui-{timestamp}/ (preserves the old build)
|
|
483
|
+
* 2. Download the npm bundle (integrity-verified) and extract to data/ui/
|
|
331
484
|
*
|
|
332
485
|
* Non-fatal: any network or extraction error returns { updated: false, error }.
|
|
333
486
|
* The caller should proceed with the existing build on failure.
|
|
334
487
|
*/
|
|
335
488
|
export async function checkAndUpdateUiBuild(
|
|
336
|
-
|
|
337
|
-
|
|
489
|
+
appVersion: string,
|
|
490
|
+
dataDir: string,
|
|
338
491
|
): Promise<UiBuildUpdateResult> {
|
|
339
492
|
try {
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
const
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const latestTag = release.tag_name; // e.g. "v0.11.0"
|
|
356
|
-
const latestVersion = latestTag.replace(/^v/, '');
|
|
357
|
-
|
|
358
|
-
if (compareVersionTags(latestTag, currentVersion) <= 0) {
|
|
359
|
-
logger.debug('UI build is up to date', { current: currentVersion, latest: latestVersion });
|
|
493
|
+
const channel = uiUpdateChannel(appVersion);
|
|
494
|
+
const manifest = await fetchNpmUiManifest(channel);
|
|
495
|
+
const latestVersion = manifest.version;
|
|
496
|
+
|
|
497
|
+
// Compare against the UI build currently on disk, NOT the app version — the
|
|
498
|
+
// UI floats on its own version line, so the platform/app version is not
|
|
499
|
+
// comparable. If the build is unstamped (e.g. a legacy data/ui seeded by the
|
|
500
|
+
// old GitHub-asset path before npm distribution), we cannot compare, so we
|
|
501
|
+
// refresh once from npm — the npm bundle is stamped, so it self-heals to the
|
|
502
|
+
// normal compare path on the next launch. Do NOT fall back to appVersion:
|
|
503
|
+
// comparing two independent version lines silently suppresses real updates.
|
|
504
|
+
const currentUiVersion = readUiBuildVersion(resolveUiBuildDir());
|
|
505
|
+
|
|
506
|
+
if (currentUiVersion && compareVersionTags(latestVersion, currentUiVersion) <= 0) {
|
|
507
|
+
logger.debug('UI build is up to date', { currentUi: currentUiVersion, latest: latestVersion, channel });
|
|
360
508
|
return { updated: false, latestVersion };
|
|
361
509
|
}
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
return { updated: false, latestVersion, error: 'Latest release has no ui-build.tar.gz' };
|
|
510
|
+
if (!currentUiVersion) {
|
|
511
|
+
logger.debug('UI build is unstamped — refreshing from npm to re-establish a known version', { latest: latestVersion, channel });
|
|
365
512
|
}
|
|
366
513
|
|
|
367
|
-
// Back up the existing UI build before replacing it
|
|
368
|
-
|
|
514
|
+
// Back up the existing UI build before replacing it. (Automatic rollback on
|
|
515
|
+
// a failed start is deferred — see ui-distribution-gap-analysis.md G1.)
|
|
516
|
+
const uiDir = join(dataDir, 'ui');
|
|
369
517
|
if (existsSync(join(uiDir, 'index.js'))) {
|
|
370
|
-
const backupDir = join(
|
|
371
|
-
mkdirSync(
|
|
518
|
+
const backupDir = join(resolveBackupsDir(), `ui-${Date.now()}`);
|
|
519
|
+
mkdirSync(resolveBackupsDir(), { recursive: true });
|
|
372
520
|
renameSync(uiDir, backupDir);
|
|
373
521
|
logger.debug('backed up UI build before update', { backup: backupDir });
|
|
374
522
|
}
|
|
375
523
|
|
|
376
|
-
await
|
|
377
|
-
logger.debug('UI build updated', { from:
|
|
524
|
+
await downloadNpmUiBundle(manifest, uiDir, dataDir);
|
|
525
|
+
logger.debug('UI build updated', { from: currentUiVersion ?? '(unstamped)', to: latestVersion });
|
|
378
526
|
|
|
379
527
|
return { updated: true, latestVersion };
|
|
380
528
|
} catch (err) {
|
|
@@ -2,26 +2,26 @@
|
|
|
2
2
|
* Runtime configuration validation for the OpenPalm control plane.
|
|
3
3
|
*
|
|
4
4
|
* Validation is a presence check on the canonical env keys we expect in
|
|
5
|
-
* the live config/stack
|
|
5
|
+
* the live config/stack files. The
|
|
6
6
|
* historical schema files and external validation binary were retired in
|
|
7
7
|
* #391; everything advisory is surfaced as a non-blocking warning. The
|
|
8
8
|
* function never shells out and never reads schemas.
|
|
9
9
|
*/
|
|
10
10
|
import { existsSync } from "node:fs";
|
|
11
|
-
import {
|
|
11
|
+
import { readStackRuntimeEnv } from "./secrets.js";
|
|
12
12
|
import { getCoreSecretMappings } from "./secret-mappings.js";
|
|
13
13
|
import type { ControlPlaneState } from "./types.js";
|
|
14
14
|
|
|
15
15
|
// Stack-scoped env keys that must always exist and carry a non-empty value
|
|
16
16
|
// for the platform to boot. Keep this list small — anything optional
|
|
17
17
|
// belongs in the warning bucket instead.
|
|
18
|
-
const
|
|
18
|
+
const REQUIRED_SECRET_KEYS = ["OP_UI_LOGIN_PASSWORD"] as const;
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
21
|
* Validate the live configuration files.
|
|
22
22
|
*
|
|
23
23
|
* Checks:
|
|
24
|
-
* 1.
|
|
24
|
+
* 1. knowledge/env/stack.env exists and carries every required key with a
|
|
25
25
|
* non-empty value.
|
|
26
26
|
* 2. Every secret env key in getCoreSecretMappings() is present (key only
|
|
27
27
|
* — blank values are warned about, never erred on, because operators
|
|
@@ -38,32 +38,30 @@ export async function validateProposedState(state: ControlPlaneState): Promise<{
|
|
|
38
38
|
const errors: string[] = [];
|
|
39
39
|
const warnings: string[] = [];
|
|
40
40
|
|
|
41
|
-
const stackEnvPath = `${state.
|
|
41
|
+
const stackEnvPath = `${state.stashDir}/env/stack.env`;
|
|
42
42
|
|
|
43
43
|
if (!existsSync(stackEnvPath)) {
|
|
44
44
|
errors.push(`ERROR: stack env file missing at ${stackEnvPath}`);
|
|
45
45
|
return { ok: false, errors, warnings };
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
const
|
|
49
|
-
const userEnv: Record<string, string> = {};
|
|
48
|
+
const runtimeEnv = readStackRuntimeEnv(state.stackDir);
|
|
50
49
|
|
|
51
|
-
for (const key of
|
|
52
|
-
const value =
|
|
50
|
+
for (const key of REQUIRED_SECRET_KEYS) {
|
|
51
|
+
const value = runtimeEnv[key];
|
|
53
52
|
if (!value || value.trim().length === 0) {
|
|
54
|
-
errors.push(`ERROR: required
|
|
53
|
+
errors.push(`ERROR: required secret ${key} is missing or empty in knowledge/secrets/${key.toLowerCase()}`);
|
|
55
54
|
}
|
|
56
55
|
}
|
|
57
56
|
|
|
58
57
|
// Every canonical secret should at least appear as a key somewhere in
|
|
59
58
|
// the env files so the operator sees the slot. Missing slots warn (not
|
|
60
59
|
// error) since not every provider is in use on every install.
|
|
61
|
-
for (const mapping of getCoreSecretMappings(
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
if (!inStack && !inUser) {
|
|
60
|
+
for (const mapping of getCoreSecretMappings(runtimeEnv)) {
|
|
61
|
+
const inRuntime = Object.prototype.hasOwnProperty.call(runtimeEnv, mapping.envKey);
|
|
62
|
+
if (!inRuntime) {
|
|
65
63
|
warnings.push(
|
|
66
|
-
`WARN: ${mapping.envKey} (akm ${mapping.secretKey}) is not declared in
|
|
64
|
+
`WARN: ${mapping.envKey} (akm ${mapping.secretKey}) is not declared in knowledge/secrets/${mapping.envKey.toLowerCase()}`,
|
|
67
65
|
);
|
|
68
66
|
}
|
|
69
67
|
}
|