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).
This commit is contained in:
Maurice
2026-07-04 20:09:59 +02:00
committed by Maurice
parent d110f8f0ce
commit 91217e83fa
11 changed files with 47 additions and 12 deletions
+3 -2
View File
@@ -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<string, unknown>;
@@ -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<Fixtures>(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<string, unknown>;
const manifest = readJsonFile<Record<string, unknown>>(manifestPath);
const id = String(manifest.id);
const grants = new Set(Array.isArray(manifest.permissions) ? (manifest.permissions as string[]) : []);
const fx = loadFixtures(abs);
+3 -2
View File
@@ -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<string, unknown>;
const manifest = readJsonFile<Record<string, unknown>>(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<Entry>(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.');
}
+11
View File
@@ -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<T = unknown>(p: string): T {
const text = fs.readFileSync(p, 'utf8');
return JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text) as T;
}
+2 -1
View File
@@ -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<EntryLike>(abs);
toWrite = mergeOnto(existing, entry);
action = 'update';
}
+2 -1
View File
@@ -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<void> {
}
} else if (cmd === 'preflight') {
const entry = flags.entry
? JSON.parse(fs.readFileSync(flags.entry, 'utf8'))
? readJsonFile<ReturnType<typeof buildEntry>>(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 <owner/name> --tag <vX>, or --entry <file.json>'));
+2 -1
View File
@@ -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<Record<string, unknown>>(manifestPath);
const res = validateManifest(raw);
errors.push(...res.errors);
manifestId = res.manifest?.id ?? String(raw.id ?? '');
+7
View File
@@ -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);
});
+2 -2
View File
@@ -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);
@@ -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<string, unknown>;
@@ -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');
@@ -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);