Files
TREK/plugin-sdk/src/cli/pack.ts
T
Maurice 52d1a0720f fix: say out loud what every sort was already doing
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.
2026-08-25 19:26:38 +02:00

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)),
};
}