mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 15:06:32 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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>'));
|
||||
|
||||
@@ -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 ?? '');
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
Reference in New Issue
Block a user