mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 13:36:48 +00:00
52d1a0720f
Sonar's S2871 flags a bare .sort(), which orders by UTF-16 code unit rather than by value. All 30 remaining sites turned out to sort ASCII strings — ISO dates, currency and country codes, setting keys, route names, OAuth scopes, zip entry names — where that order is already the intended one. Each says so explicitly now, with a comparator that reproduces it exactly rather than a localeCompare that would quietly reorder things. That mattered more than it sounds. Several of these feed something other than a display: useAtlas joins the sorted codes into the signature that decides whether to rebuild the region layer, daysSlice re-pins sorted dates positionally onto reordered days, and three timeline views pick a day's colour by indexOf into the sorted array. Switching any of them to a locale collator would have been a silent regression. Two smaller ones came with it. The .map(placeBtn) calls in FileManagerAssignModal pass the index as a second argument, which placeBtn declares and uses for its React key — correct already, now written out as .map((p, i) => placeBtn(p, i)). And budgetSlice's optimistic paid-toggle stored the raw boolean behind `paid as unknown as number`, while the schema declares z.number() and both the REST response and the WebSocket event carry 0/1. So the optimistic state and the confirmed state disagreed on type until a refresh. It normalises now, and the two tests that asserted `true` assert the contract. Four `| 0` truncations are listed in sonar-project.properties instead of changed. Two are a djb2 string hash where the 32-bit wrap is the algorithm. The other two look like plain truncation of a row id, but `| 0` is also coercing undefined to 0 there — the settlement flows the API returns carry no user_id, so that is the normal path, and Math.trunc returns NaN and crashes the lookup.
106 lines
4.6 KiB
JavaScript
106 lines
4.6 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* trek-plugin pack [dir] [--out plugin.zip] [--json]
|
|
*
|
|
* Turns a built plugin directory into the exact plugin.zip the TREK installer
|
|
* expects, and prints its sha256 + byte size — the two values authors otherwise
|
|
* compute by hand for the registry entry. Validates the manifest first, refuses
|
|
* native binaries (same rule as the installer), and enforces the same size
|
|
* limits, so a local pack that succeeds installs cleanly.
|
|
*/
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { createHash } from 'node:crypto';
|
|
import { makeZip, type ZipInput } from '../zip.js';
|
|
import { injectTrekUi } from '../ui/kit.js';
|
|
import { loadContext } from './checks/context.js';
|
|
import { runOffline } from './checks/index.js';
|
|
import { blocking } from './checks/types.js';
|
|
|
|
const MAX_TOTAL = 50 * 1024 * 1024;
|
|
const MAX_FILE = 25 * 1024 * 1024;
|
|
const MAX_ENTRIES = 4000;
|
|
|
|
// Files kept at the archive root; everything the installer needs plus light docs.
|
|
const ROOT_FILES = ['trek-plugin.json', 'README.md', 'LICENSE', 'LICENSE.md', 'package.json'];
|
|
// Directories walked recursively into the archive.
|
|
const DIRS = ['server', 'client'];
|
|
// Never shipped: dep trees, VCS, source maps, TypeScript sources, the output zip.
|
|
const SKIP_DIR = new Set(['node_modules', '.git']);
|
|
const NATIVE_RE = /\.node$|(^|\/)binding\.gyp$|(^|\/)prebuilds?\//i;
|
|
|
|
export interface PackResult {
|
|
artifact: string;
|
|
sha256: string;
|
|
size: number;
|
|
files: string[];
|
|
}
|
|
|
|
function walk(base: string, rel: string, out: ZipInput[]): void {
|
|
for (const entry of fs.readdirSync(path.join(base, rel), { withFileTypes: true })) {
|
|
const childRel = rel ? `${rel}/${entry.name}` : entry.name;
|
|
if (entry.isDirectory()) {
|
|
if (SKIP_DIR.has(entry.name)) continue;
|
|
walk(base, childRel, out);
|
|
} else if (entry.isFile()) {
|
|
if (entry.name.endsWith('.map') || entry.name.endsWith('.ts')) continue;
|
|
// Expand the `<!-- trek:ui -->` marker into the inlined design kit as the file
|
|
// enters the archive, so the source stays a one-line opt-in. A no-op on HTML
|
|
// without the marker, so it's safe to run over every .html.
|
|
if (entry.name.endsWith('.html')) {
|
|
out.push({ name: childRel, data: Buffer.from(injectTrekUi(fs.readFileSync(path.join(base, childRel), 'utf8')), 'utf8') });
|
|
} else {
|
|
out.push({ name: childRel, data: fs.readFileSync(path.join(base, childRel)) });
|
|
}
|
|
}
|
|
// symlinks and other non-regular entries are skipped (never shipped)
|
|
}
|
|
}
|
|
|
|
export function packPluginDir(dir: string, outPath: string): PackResult {
|
|
// Only the gates that make a plugin UNBUILDABLE — a broken manifest, no server entry, a native
|
|
// binary. Not the registry's publish gates: packing is how you install a plugin into a local
|
|
// TREK to try it, and refusing to build a zip because the README is still a stub would block
|
|
// the dev loop over something that only matters at publish time. `validate`/`status`/`publish`
|
|
// apply the full set; see checks/types.ts, CheckBlocks.
|
|
const report = blocking(runOffline(loadContext(dir)), 'artifact');
|
|
if (!report.ok) {
|
|
throw new Error(
|
|
'plugin is not valid:\n - ' +
|
|
report.errors.map((e) => (e.detail ? `${e.title} — ${e.detail}` : e.title)).join('\n - '),
|
|
);
|
|
}
|
|
|
|
const outAbs = path.resolve(outPath);
|
|
const files: ZipInput[] = [];
|
|
for (const f of ROOT_FILES) {
|
|
const p = path.join(dir, f);
|
|
if (fs.existsSync(p) && fs.statSync(p).isFile()) files.push({ name: f, data: fs.readFileSync(p) });
|
|
}
|
|
for (const d of DIRS) {
|
|
if (fs.existsSync(path.join(dir, d))) walk(dir, d, files);
|
|
}
|
|
// Drop an existing output zip if it happens to sit in the tree.
|
|
const filtered = files.filter((f) => path.resolve(dir, f.name) !== outAbs);
|
|
|
|
// Same gates the installer enforces, surfaced locally.
|
|
let total = 0;
|
|
for (const f of filtered) {
|
|
if (NATIVE_RE.test(f.name)) throw new Error(`native binaries are not allowed: ${f.name}`);
|
|
if (f.data.length > MAX_FILE) throw new Error(`file too large (>25MB): ${f.name}`);
|
|
total += f.data.length;
|
|
}
|
|
if (total > MAX_TOTAL) throw new Error('archive exceeds the 50MB limit');
|
|
if (filtered.length > MAX_ENTRIES) throw new Error('too many files (>4000)');
|
|
if (!filtered.some((f) => f.name === 'trek-plugin.json')) throw new Error('trek-plugin.json must be at the plugin root');
|
|
|
|
const buf = makeZip(filtered);
|
|
fs.writeFileSync(outAbs, buf);
|
|
return {
|
|
artifact: outAbs,
|
|
sha256: createHash('sha256').update(buf).digest('hex'),
|
|
size: buf.length,
|
|
files: filtered.map((f) => f.name).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)),
|
|
};
|
|
}
|