From 91217e83fa3ed0945070cfc3a46054258b307ebc Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 4 Jul 2026 20:09:59 +0200 Subject: [PATCH] fix(plugins): tolerate a UTF-8 BOM in trek-plugin.json Windows editors love to prepend a BOM, and a bare JSON.parse then dies with an "Unexpected token" pointing at an invisible character - in the SDK CLIs (dev/validate/entry/submit) and, worse, server-side: a BOM in an author repo travels through pack into the artifact and fails discovery and registry install. Strip it at every manifest/JSON read (readJsonFile in the SDK, parseJsonText in the installer). --- plugin-sdk/src/cli/dev.ts | 5 +++-- plugin-sdk/src/cli/entry.ts | 5 +++-- plugin-sdk/src/cli/json.ts | 11 +++++++++++ plugin-sdk/src/cli/submit.ts | 3 ++- plugin-sdk/src/cli/trek-plugin.ts | 3 ++- plugin-sdk/src/cli/validate.ts | 3 ++- plugin-sdk/test/sdk.test.ts | 7 +++++++ server/src/nest/plugins/install/discovery.ts | 4 ++-- server/src/nest/plugins/install/manifest.ts | 5 +++++ server/src/nest/plugins/registry/registry.service.ts | 6 +++--- server/tests/unit/plugins/discovery.test.ts | 7 +++++++ 11 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 plugin-sdk/src/cli/json.ts diff --git a/plugin-sdk/src/cli/dev.ts b/plugin-sdk/src/cli/dev.ts index 6112d1334..545062573 100644 --- a/plugin-sdk/src/cli/dev.ts +++ b/plugin-sdk/src/cli/dev.ts @@ -14,6 +14,7 @@ import path from 'node:path'; import http from 'node:http'; import { createRequire } from 'node:module'; import * as sdk from '../index.js'; +import { readJsonFile } from './json.js'; interface Fixtures { config?: Record; @@ -123,7 +124,7 @@ export function installSdkInjection(): void { function loadFixtures(dir: string): Fixtures { const p = path.join(dir, 'dev-fixtures.json'); - if (fs.existsSync(p)) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { console.warn('warning: dev-fixtures.json is not valid JSON — ignoring'); } } + if (fs.existsSync(p)) { try { return readJsonFile(p); } catch { console.warn('warning: dev-fixtures.json is not valid JSON — ignoring'); } } return {}; } @@ -143,7 +144,7 @@ export async function runDev(dir: string, opts: { port?: number } = {}): Promise const abs = path.resolve(dir); const manifestPath = path.join(abs, 'trek-plugin.json'); if (!fs.existsSync(manifestPath)) throw new Error(`no trek-plugin.json in ${abs}`); - const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as Record; + const manifest = readJsonFile>(manifestPath); const id = String(manifest.id); const grants = new Set(Array.isArray(manifest.permissions) ? (manifest.permissions as string[]) : []); const fx = loadFixtures(abs); diff --git a/plugin-sdk/src/cli/entry.ts b/plugin-sdk/src/cli/entry.ts index fa39c8d3c..ba508a6dd 100644 --- a/plugin-sdk/src/cli/entry.ts +++ b/plugin-sdk/src/cli/entry.ts @@ -14,6 +14,7 @@ import path from 'node:path'; import { createHash } from 'node:crypto'; import { execFileSync } from 'node:child_process'; import { loadPrivateKey, signArtifact, publicKeyBase64 } from './sign.js'; +import { readJsonFile } from './json.js'; interface Version { version: string; gitTag: string; commitSha: string; downloadUrl: string; @@ -47,7 +48,7 @@ export function buildEntry(opts: { /** Optional Ed25519 private-key file — signs the artifact and pins the author key. */ signKeyPath?: string; }): Entry { - const manifest = JSON.parse(fs.readFileSync(path.join(opts.dir, 'trek-plugin.json'), 'utf8')) as Record; + const manifest = readJsonFile>(path.join(opts.dir, 'trek-plugin.json')); if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(opts.repo)) throw new Error(`--repo must be "owner/name", got "${opts.repo}"`); const minTrek = minTrekFrom(manifest.trek); if (!minTrek) throw new Error('manifest has no "trek" version range to derive minTrekVersion from (e.g. "trek": ">=3.2.0 <4.0.0")'); @@ -77,7 +78,7 @@ export function buildEntry(opts: { } if (opts.mergePath) { - const existing = JSON.parse(fs.readFileSync(opts.mergePath, 'utf8')) as Entry; + const existing = readJsonFile(opts.mergePath); if (authorPublicKey && existing.authorPublicKey && existing.authorPublicKey !== authorPublicKey) { throw new Error('this signing key differs from the one already published for this plugin — TREK would reject the update. Use the original key.'); } diff --git a/plugin-sdk/src/cli/json.ts b/plugin-sdk/src/cli/json.ts new file mode 100644 index 000000000..eb379225e --- /dev/null +++ b/plugin-sdk/src/cli/json.ts @@ -0,0 +1,11 @@ +import fs from 'node:fs'; + +/** + * Read + parse a JSON file, tolerating a UTF-8 BOM (0xFEFF) — Windows editors + * love to add one, and a bare JSON.parse then fails with a cryptic + * "Unexpected token" that points at an invisible character. + */ +export function readJsonFile(p: string): T { + const text = fs.readFileSync(p, 'utf8'); + return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text) as T; +} diff --git a/plugin-sdk/src/cli/submit.ts b/plugin-sdk/src/cli/submit.ts index d4204438e..f2971ef59 100644 --- a/plugin-sdk/src/cli/submit.ts +++ b/plugin-sdk/src/cli/submit.ts @@ -10,6 +10,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { execFileSync } from 'node:child_process'; +import { readJsonFile } from './json.js'; const DEFAULT_REGISTRY = 'mauriceboe/TREK-Plugins'; @@ -69,7 +70,7 @@ export function submitEntry(entry: EntryLike, opts: { registry?: string; branch? let toWrite = entry; let action = 'add'; if (fs.existsSync(abs)) { - const existing = JSON.parse(fs.readFileSync(abs, 'utf8')) as EntryLike; + const existing = readJsonFile(abs); toWrite = mergeOnto(existing, entry); action = 'update'; } diff --git a/plugin-sdk/src/cli/trek-plugin.ts b/plugin-sdk/src/cli/trek-plugin.ts index f4537b7bc..9d9282649 100644 --- a/plugin-sdk/src/cli/trek-plugin.ts +++ b/plugin-sdk/src/cli/trek-plugin.ts @@ -33,6 +33,7 @@ import { preflight } from './preflight.js'; import { submitEntry } from './submit.js'; import { publishPlugin } from './publish.js'; import { generateKeypair, loadPrivateKey, signArtifact, publicKeyBase64, defaultKeyPath } from './sign.js'; +import { readJsonFile } from './json.js'; const [cmd, ...args] = process.argv.slice(2); @@ -124,7 +125,7 @@ async function main(): Promise { } } else if (cmd === 'preflight') { const entry = flags.entry - ? JSON.parse(fs.readFileSync(flags.entry, 'utf8')) + ? readJsonFile>(flags.entry) : (flags.repo && flags.tag ? buildEntry({ dir: pos[0] || '.', repo: flags.repo, tag: flags.tag, zipPath: flags.zip || 'plugin.zip', commit: flags.commit, signKeyPath: signKey(), now: new Date().toISOString() }) : fail('preflight needs --repo --tag , or --entry ')); diff --git a/plugin-sdk/src/cli/validate.ts b/plugin-sdk/src/cli/validate.ts index a4163a17b..ad4c2f192 100644 --- a/plugin-sdk/src/cli/validate.ts +++ b/plugin-sdk/src/cli/validate.ts @@ -8,6 +8,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { validateManifest } from '../manifest.js'; +import { readJsonFile } from './json.js'; export interface ValidateReport { ok: boolean; @@ -24,7 +25,7 @@ export function validatePluginDir(dir: string): ValidateReport { let manifestId = ''; try { - const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf8')); + const raw = readJsonFile>(manifestPath); const res = validateManifest(raw); errors.push(...res.errors); manifestId = res.manifest?.id ?? String(raw.id ?? ''); diff --git a/plugin-sdk/test/sdk.test.ts b/plugin-sdk/test/sdk.test.ts index 72b2823c0..78b12fe04 100644 --- a/plugin-sdk/test/sdk.test.ts +++ b/plugin-sdk/test/sdk.test.ts @@ -126,6 +126,13 @@ describe('scaffold + validate CLIs', () => { expect(() => scaffold('Bad Name', 'widget', tmp)).toThrow(/invalid plugin id/); }); + it('tolerates a UTF-8 BOM in trek-plugin.json (Windows editors add one)', () => { + scaffold('bom-plug', 'integration', tmp); + const mp = path.join(tmp, 'bom-plug', 'trek-plugin.json'); + fs.writeFileSync(mp, '\uFEFF' + fs.readFileSync(mp, 'utf8')); + expect(validatePluginDir(path.join(tmp, 'bom-plug')).ok).toBe(true); + }); + it('validatePluginDir flags a missing manifest', () => { expect(validatePluginDir(tmp).ok).toBe(false); }); diff --git a/server/src/nest/plugins/install/discovery.ts b/server/src/nest/plugins/install/discovery.ts index 4cd05af7e..6794f5e49 100644 --- a/server/src/nest/plugins/install/discovery.ts +++ b/server/src/nest/plugins/install/discovery.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import path from 'node:path'; import type BetterSqlite3 from 'better-sqlite3'; import { pluginsCodeRoot, pluginCodeDir } from '../paths'; -import { parseManifest, type PluginManifest } from './manifest'; +import { parseJsonText, parseManifest, type PluginManifest } from './manifest'; import { scanForNativeBinaries } from './native-scan'; /** @@ -26,7 +26,7 @@ export function discoverPlugins(db: BetterSqlite3.Database): { discovered: strin if (!fs.existsSync(manifestPath)) continue; try { - const manifest = parseManifest(JSON.parse(fs.readFileSync(manifestPath, 'utf8'))); + const manifest = parseManifest(parseJsonText(fs.readFileSync(manifestPath, 'utf8'))); if (manifest.id !== entry.name) throw new Error(`manifest id "${manifest.id}" != directory "${entry.name}"`); if (scanForNativeBinaries(dir).length) throw new Error('directory contains native binaries'); upsert(db, manifest); diff --git a/server/src/nest/plugins/install/manifest.ts b/server/src/nest/plugins/install/manifest.ts index 100e6caeb..095dc64f1 100644 --- a/server/src/nest/plugins/install/manifest.ts +++ b/server/src/nest/plugins/install/manifest.ts @@ -65,6 +65,11 @@ const TYPES = new Set(['integration', 'page', 'widget']); export class ManifestError extends Error {} +/** JSON.parse that tolerates a UTF-8 BOM (0xFEFF) — manifests written on Windows often carry one. */ +export function parseJsonText(text: string): unknown { + return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text); +} + export function parseManifest(raw: unknown): PluginManifest { if (!raw || typeof raw !== 'object') throw new ManifestError('manifest is not an object'); const m = raw as Record; diff --git a/server/src/nest/plugins/registry/registry.service.ts b/server/src/nest/plugins/registry/registry.service.ts index 54feaeb14..7f87af3f5 100644 --- a/server/src/nest/plugins/registry/registry.service.ts +++ b/server/src/nest/plugins/registry/registry.service.ts @@ -7,7 +7,7 @@ import { safeDownload, sha256Matches } from '../install/safe-fetch'; import { verifyAuthorSignature } from '../install/verify-signature'; import { extractArchive } from '../install/safe-extract'; import { scanForNativeBinaries } from '../install/native-scan'; -import { parseManifest } from '../install/manifest'; +import { parseJsonText, parseManifest } from '../install/manifest'; import { discoverPlugins } from '../install/discovery'; /** @@ -132,7 +132,7 @@ export class PluginRegistryService { if (latest) { try { const { bytes } = await safeDownload(rawFileUrl(entry.repo, latest.commitSha, 'trek-plugin.json'), MANIFEST_MAX_BYTES); - manifest = previewManifest(JSON.parse(bytes.toString('utf8'))); + manifest = previewManifest(parseJsonText(bytes.toString('utf8'))); } catch { // Soft-fail: the detail view still renders from registry metadata alone. } @@ -178,7 +178,7 @@ export class PluginRegistryService { if (!pluginRoot) throw new RegistryError('archive contains no trek-plugin.json'); // 4. re-validate the bundled manifest + 5. native re-scan - const manifest = parseManifest(JSON.parse(fs.readFileSync(path.join(pluginRoot, 'trek-plugin.json'), 'utf8'))); + const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(pluginRoot, 'trek-plugin.json'), 'utf8'))); if (manifest.id !== id) throw new RegistryError(`manifest id "${manifest.id}" != "${id}"`); if (scanForNativeBinaries(pluginRoot).length) throw new RegistryError('artifact contains native binaries'); diff --git a/server/tests/unit/plugins/discovery.test.ts b/server/tests/unit/plugins/discovery.test.ts index ab035c54d..3ae2a7ab2 100644 --- a/server/tests/unit/plugins/discovery.test.ts +++ b/server/tests/unit/plugins/discovery.test.ts @@ -72,6 +72,13 @@ describe('discoverPlugins', () => { expect(JSON.parse(row.granted_permissions)).toEqual(['db:own']); // grants preserved }); + it('tolerates a UTF-8 BOM in trek-plugin.json (Windows-authored plugins)', () => { + writePlugin('bom-plug', { type: 'integration' }); + const mp = path.join(codeRoot, 'bom-plug', 'trek-plugin.json'); + fs.writeFileSync(mp, '\uFEFF' + fs.readFileSync(mp, 'utf8')); + expect(discoverPlugins(db).discovered).toEqual(['bom-plug']); + }); + it('skips an invalid manifest and logs the reason', () => { writePlugin('bad', { type: 'not-a-type' }); const res = discoverPlugins(db);