mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning (#2226)
* feat(plugins): TREK_PLUGINS_IGNORE_TREK_RANGE turns the version gate into a warning A plugin whose author has not updated its `trek` range for the running TREK can now be installed, updated and activated when the operator sets TREK_PLUGINS_IGNORE_TREK_RANGE to a truthy value. Every gate (registry picker, post-extraction re-check, sideload, dev-link, activation) warns instead of refusing; a missing range is tolerated the same way; "install latest" takes the newest published version. The plugin-API version gate is not lifted and the Discover compatibility verdict stays truthful. The admin is warned at every step: each bypass is logged, install/upload/link/ update responses carry a `trekRangeBypassed` marker, and both admin panels show a "Version checks off" pill, an "Install anyway" confirm dialog before a registry install, the same notice after a path that could not ask first, and a persistent chip on the row while the plugin runs outside its range. Nine new i18n keys in every locale; wiki pages updated (Environment-Variables, Admin-Plugins, Plugin-Development, Security-Hardening). * refactor(plugins): one useRangeBypass hook behind both admin shells SonarQube flagged PR 2226 at 10.8% duplicated new code: the range-bypass logic had been added to the desktop and phone plugin panels as identical copies. The marker/warning types, the row chip, the "Install anyway" offer, the dialog copy and the confirm-or-notice state now live in useRangeBypass (next to useInstanceSettings, the existing shared-logic precedent); each panel keeps only its own dialog/sheet markup. * docs(claude): record the SonarQube gate rules and the no-English-placeholder i18n rule The CI SonarCloud gate (Sonar way, on new code: >=80% coverage, <=3% duplication, A ratings, hotspots reviewed) was only discoverable by waiting 20-25 minutes for the workflow. The root CLAUDE.md now states the conditions, the desktop/mobile shell-mirror duplication trap and its shared-hook fix, and how to check a PR with the sonarqube MCP first; client and server CLAUDE.md point at it. shared/CLAUDE.md says every locale needs a real translation — parity cannot tell an English placeholder from a translation. * docs(claude): keep CLAUDE.md to commands, invariants, gates and philosophy Verified every concrete claim in the five CLAUDE.md files against the code and fixed the stale ones: the websocket server is the Nest realtime gateway, MCP tools live in <domain>.mcp.ts files, the client "native shell" section described code that was never written, offline maps also prefetch vector basemaps, plus several renamed files and wrong table names. Then trimmed everything that drifts on its own: dates, migration history, counts, and inventories of stores, slices, domains, sync files and CLI commands. What stays is the commands, the invariants with the file that enforces each one, what CI actually runs, the reference implementations to copy, and the rules for new code. Each file now opens with that rule.
This commit is contained in:
@@ -78,7 +78,7 @@ below.)
|
||||
**Runtime-toggled** (read live on every access via `readEnv()` /
|
||||
`RuntimeEnvService`; tests mutate these mid-lifetime):
|
||||
TREK_MANAGED, PLACES_API_BASE, PLACES_API_KEY, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY, DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED,
|
||||
TREK_PLUGINS_ENABLED / _DEV_LINK / _DIR / _DATA_DIR / TREK_PLUGIN_PERMISSIONS,
|
||||
TREK_PLUGINS_ENABLED / _DEV_LINK / _IGNORE_TREK_RANGE / _DIR / _DATA_DIR / TREK_PLUGIN_PERMISSIONS,
|
||||
OIDC_*, SMTP_*, FORCE_HTTPS, COOKIE_SECURE, HSTS_INCLUDE_SUBDOMAINS,
|
||||
ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN_*, TZ, ADMIN_EMAIL,
|
||||
ADMIN_PASSWORD, IDEMPOTENCY_TTL_SECONDS, MCP_RATE_LIMIT (request-path check).
|
||||
|
||||
@@ -200,6 +200,12 @@ export function derivePlugins(raw: RawEnv) {
|
||||
/** Kill-switch is default-on: only an explicit falsy value disables (plugins/kill-switch.ts). */
|
||||
enabled: parseBool(raw.TREK_PLUGINS_ENABLED) !== false,
|
||||
devLink: parseBool(raw.TREK_PLUGINS_DEV_LINK) === true,
|
||||
/**
|
||||
* Range bypass is default-OFF: only an explicit truthy value turns the TREK-version
|
||||
* gates into warnings (plugins/install/host-compat.ts). Never inferred from any
|
||||
* other switch — an admin has to ask for "install it anyway" by name.
|
||||
*/
|
||||
ignoreTrekRange: parseBool(raw.TREK_PLUGINS_IGNORE_TREK_RANGE) === true,
|
||||
dir: raw.TREK_PLUGINS_DIR,
|
||||
dataDir: raw.TREK_PLUGINS_DATA_DIR,
|
||||
/** Permission jail is default-on: only an explicit falsy value turns it off. */
|
||||
|
||||
@@ -150,6 +150,7 @@ export const envSchema = z.object({
|
||||
TREK_API_DOCS_ENABLED: boolStr,
|
||||
TREK_PLUGINS_ENABLED: boolStr,
|
||||
TREK_PLUGINS_DEV_LINK: boolStr,
|
||||
TREK_PLUGINS_IGNORE_TREK_RANGE: boolStr,
|
||||
TREK_PLUGINS_DIR: anyString,
|
||||
TREK_PLUGINS_DATA_DIR: anyString,
|
||||
TREK_PLUGIN_PERMISSIONS: boolStr,
|
||||
|
||||
@@ -105,3 +105,46 @@ export function hostSatisfies(range: string | null | undefined, host: string | n
|
||||
if (!isValidTrekRange(range)) return false;
|
||||
return semver.satisfies(release, range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether TREK_PLUGINS_IGNORE_TREK_RANGE is set: the operator's explicit "install it
|
||||
* anyway" for a plugin whose author has not updated its `trek` range. While it is on,
|
||||
* every TREK-version gate (registry picker, post-extraction re-check, sideload, dev-link,
|
||||
* activation) WARNS instead of refusing. Nothing else changes — {@link hostSatisfies}
|
||||
* keeps telling the truth, so the admin UI can still say the plugin is running outside
|
||||
* the range its author vouched for. Read live, like the other plugin switches.
|
||||
*/
|
||||
export function trekRangeBypassed(): boolean {
|
||||
return readEnv().plugins.ignoreTrekRange;
|
||||
}
|
||||
|
||||
/** What a bypassed gate reports back to the admin: the range that was ignored and the host it was ignored on. */
|
||||
export interface TrekRangeBypass {
|
||||
/** The plugin's declared range, or null when it declared none at all. */
|
||||
trekRange: string | null;
|
||||
hostVersion: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-null exactly when `range` would have been refused and only the bypass lets it
|
||||
* through: the marker that makes an install/activate response say "this worked, but
|
||||
* only because you told TREK to ignore the version check". Null for a normal install
|
||||
* (the range fits) and null when the bypass is off (the gate refuses instead).
|
||||
*/
|
||||
export function bypassedRange(range: string | null | undefined): TrekRangeBypass | null {
|
||||
if (hostSatisfies(range)) return null;
|
||||
if (!trekRangeBypassed()) return null;
|
||||
return { trekRange: range ?? null, hostVersion: hostVersion() };
|
||||
}
|
||||
|
||||
/**
|
||||
* The one log line every bypassed gate writes. It names the plugin, the range that was
|
||||
* ignored and the host it was ignored on, so an operator reading the log after a plugin
|
||||
* misbehaves can connect the two without the UI.
|
||||
*/
|
||||
export function warnRangeBypass(id: string, b: TrekRangeBypass): void {
|
||||
const declared = b.trekRange ? `declares TREK ${b.trekRange}` : 'declares no TREK version range';
|
||||
console.warn(
|
||||
`[plugins] ${id} ${declared} — this is TREK ${b.hostVersion}; continuing because TREK_PLUGINS_IGNORE_TREK_RANGE is set. The author has not verified it on this version: it may not work, and it could corrupt TREK data.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import semver from 'semver';
|
||||
import { isKnownPermission, PLUGIN_API_VERSION } from '../protocol/envelope';
|
||||
import { isValidTrekRange, minTrekOf } from './host-compat';
|
||||
import { isValidTrekRange, minTrekOf, trekRangeBypassed } from './host-compat';
|
||||
import {
|
||||
MCP_TOOLS_MAX,
|
||||
McpToolSchemaError,
|
||||
@@ -252,7 +252,9 @@ export function parseManifest(raw: unknown, opts?: { requireTrek?: boolean }): P
|
||||
|
||||
const trek = optStr(m.trek);
|
||||
const trekRange = isValidTrekRange(trek) ? trek : null;
|
||||
if (opts?.requireTrek && !trekRange) {
|
||||
// Under TREK_PLUGINS_IGNORE_TREK_RANGE a missing range is the same author oversight as a
|
||||
// stale one, so it is reported by the gate (as a null range) instead of refused here.
|
||||
if (opts?.requireTrek && !trekRange && !trekRangeBypassed()) {
|
||||
throw new ManifestError(
|
||||
trek
|
||||
? `invalid "trek" version range "${trek}" (expected a satisfiable semver range, e.g. ">=3.2.0 <4.0.0")`
|
||||
|
||||
@@ -26,7 +26,8 @@ import { scanForNativeBinaries } from './install/native-scan';
|
||||
import { devLinkEnabled, DEV_LINK_SOURCE } from './dev-link';
|
||||
import { pluginCodeDir, pluginDataDir } from './paths';
|
||||
import { assertHostCompatible, PluginRegistryService, RegistryError } from './registry/registry.service';
|
||||
import { hostSatisfies, hostVersion } from './install/host-compat';
|
||||
import { hostSatisfies, hostVersion, bypassedRange, warnRangeBypass } from './install/host-compat';
|
||||
import type { TrekRangeBypass } from './install/host-compat';
|
||||
import { keyFingerprint } from './signature-status';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
@@ -504,14 +505,18 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
// it would not make the plugin start. It is also the gate that catches the case
|
||||
// install can't — TREK was upgraded PAST the plugin's declared upper bound, so code
|
||||
// that was legitimately installed no longer supports the host it's sitting on.
|
||||
if (!row.trek_range) {
|
||||
const bypass = bypassedRange(row.trek_range);
|
||||
if (bypass) {
|
||||
// TREK_PLUGINS_IGNORE_TREK_RANGE: the operator chose to run it anyway. Say so in the
|
||||
// log every time it starts — the plugin list carries the same marker for the UI.
|
||||
warnRangeBypass(id, bypass);
|
||||
} else if (!row.trek_range) {
|
||||
throw new PluginDependencyError(
|
||||
`plugin ${id} does not declare which TREK versions it supports`,
|
||||
'TREK_VERSION_UNKNOWN',
|
||||
{ trekRange: null, hostVersion: hostVersion() },
|
||||
);
|
||||
}
|
||||
if (!hostSatisfies(row.trek_range)) {
|
||||
} else if (!hostSatisfies(row.trek_range)) {
|
||||
throw new PluginDependencyError(
|
||||
`plugin ${id} requires TREK ${row.trek_range} — this is TREK ${hostVersion()}`,
|
||||
'TREK_VERSION_INCOMPATIBLE',
|
||||
@@ -679,7 +684,10 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
* Install runs first so a failed download/signature/integrity check leaves the
|
||||
* currently-running child untouched (it keeps serving the old code from memory).
|
||||
*/
|
||||
async update(id: string, opts?: { version?: string; retrustKey?: string }): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[] }> {
|
||||
async update(
|
||||
id: string,
|
||||
opts?: { version?: string; retrustKey?: string },
|
||||
): Promise<{ version: string; activated: boolean; newPermissions: string[]; newEgress: string[]; trekRangeBypassed: TrekRangeBypass | null }> {
|
||||
const before = this.db.prepare('SELECT enabled, granted_permissions, version FROM plugins WHERE id = ?').get(id) as
|
||||
| { enabled: number; granted_permissions: string; version: string | null }
|
||||
| undefined;
|
||||
@@ -708,11 +716,11 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
if (wasEnabled) await this.deactivate(id); // stop the old child now that new code is in place
|
||||
if (newGrants.length === 0 && wasEnabled) {
|
||||
await this.activate(id); // no wider rights → transparent restart on the new code
|
||||
return { version: res.version, activated: true, newPermissions, newEgress };
|
||||
return { version: res.version, activated: true, newPermissions, newEgress, trekRangeBypassed: res.trekRangeBypassed };
|
||||
}
|
||||
// New rights requested (or it was already disabled): leave it inactive until
|
||||
// an admin explicitly consents by activating it.
|
||||
return { version: res.version, activated: false, newPermissions, newEgress };
|
||||
return { version: res.version, activated: false, newPermissions, newEgress, trekRangeBypassed: res.trekRangeBypassed };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -788,7 +796,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
* commits it as an INACTIVE sideloaded plugin. Never auto-activates — the admin
|
||||
* re-activates (and re-consents to permissions) explicitly.
|
||||
*/
|
||||
async sideload(bytes: Buffer): Promise<{ id: string; version: string; replaced: boolean }> {
|
||||
async sideload(bytes: Buffer): Promise<{ id: string; version: string; replaced: boolean; trekRangeBypassed: TrekRangeBypass | null }> {
|
||||
if (!this.registry) throw new Error('registry service unavailable');
|
||||
const staged = this.registry.stageUpload(bytes);
|
||||
try {
|
||||
@@ -800,7 +808,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
// a plugin that isn't running.
|
||||
if (replaced) await this.deactivate(staged.id);
|
||||
this.registry.commitUpload(staged); // moves code + registers INACTIVE, then clears staging
|
||||
return { id: staged.id, version: staged.version, replaced };
|
||||
return { id: staged.id, version: staged.version, replaced, trekRangeBypassed: staged.trekRangeBypassed };
|
||||
} catch (e) {
|
||||
// A failure before commitUpload leaves staging behind — clean it up.
|
||||
try { fs.rmSync(staged.stagingDir, { recursive: true, force: true }); } catch {}
|
||||
@@ -816,13 +824,13 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
* and starts an fs.watch that re-forks on rebuild. Gated behind TREK_PLUGINS_DEV_LINK
|
||||
* on top of the controller's admin + kill-switch gates — see dev-link.ts for why.
|
||||
*/
|
||||
async link(sourceDir: string): Promise<{ id: string; version: string; replaced: boolean }> {
|
||||
async link(sourceDir: string): Promise<{ id: string; version: string; replaced: boolean; trekRangeBypassed: TrekRangeBypass | null }> {
|
||||
if (!devLinkEnabled()) throw new Error('dev-link is disabled (set TREK_PLUGINS_DEV_LINK=1)');
|
||||
if (!path.isAbsolute(sourceDir)) throw new Error('the dev-link path must be absolute');
|
||||
const manifestPath = path.join(sourceDir, 'trek-plugin.json');
|
||||
if (!fs.existsSync(manifestPath)) throw new Error(`no trek-plugin.json at ${sourceDir}`);
|
||||
const manifest = parseManifest(parseJsonText(fs.readFileSync(manifestPath, 'utf8')), { requireTrek: true });
|
||||
assertHostCompatible(manifest.trekRange, manifest.id);
|
||||
const trekRangeBypassed = assertHostCompatible(manifest.trekRange, manifest.id);
|
||||
if (!fs.existsSync(path.join(sourceDir, 'server', 'index.js'))) {
|
||||
throw new Error('no built server/index.js — build the plugin first (the loader runs the compiled artifact, not TS source)');
|
||||
}
|
||||
@@ -852,7 +860,7 @@ export class PluginRuntimeService implements OnApplicationBootstrap, OnModuleDes
|
||||
WHERE id = ?`,
|
||||
).run(DEV_LINK_SOURCE, id);
|
||||
this.watchLinked(id, sourceDir);
|
||||
return { id, version: manifest.version, replaced };
|
||||
return { id, version: manifest.version, replaced, trekRangeBypassed };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,8 @@ import { safeParseConfig as safeParse } from './plugin-config-parse';
|
||||
import { isFilled, parseDefaultValue, settingDefaults } from './settings-defaults';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { parseDependencies, disabledRequiredAddons, resolveDependencyState, type PluginDepRow, type PluginDependencies, type VersionMismatch } from './dependencies';
|
||||
import { hostSatisfies, hostVersion } from './install/host-compat';
|
||||
import { bypassedRange, hostSatisfies, hostVersion, trekRangeBypassed } from './install/host-compat';
|
||||
import type { TrekRangeBypass } from './install/host-compat';
|
||||
import type { PluginDependency } from './install/manifest';
|
||||
import type { PluginSettingsField } from '@trek/shared';
|
||||
|
||||
@@ -90,6 +91,12 @@ export interface PluginListItem {
|
||||
trekRange: string | null;
|
||||
/** The running TREK, so the UI can say "needs X, you have Y" without doing semver. */
|
||||
hostVersion: string;
|
||||
/**
|
||||
* Non-null when the plugin is outside its declared range (or declared none) and only
|
||||
* TREK_PLUGINS_IGNORE_TREK_RANGE lets it activate. The card shows it as a warning
|
||||
* rather than a blocker: the admin chose this, but must keep seeing what they chose.
|
||||
*/
|
||||
trekRangeBypassed: TrekRangeBypass | null;
|
||||
/** The concrete blockers, so the UI can render chips + the resolve dialog. */
|
||||
dependencyIssues: { disabledAddons: string[]; missing: PluginDependency[]; versionMismatch: VersionMismatch[] };
|
||||
/**
|
||||
@@ -149,7 +156,7 @@ export class PluginsService {
|
||||
}
|
||||
}
|
||||
|
||||
list(): { enabled: boolean; devLink: boolean; plugins: PluginListItem[] } {
|
||||
list(): { enabled: boolean; devLink: boolean; ignoreTrekRange: boolean; plugins: PluginListItem[] } {
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT id, name, description, type, icon, version, status, enabled, last_error, reviewed_at, source_repo,
|
||||
@@ -168,7 +175,8 @@ export class PluginsService {
|
||||
const state = resolveDependencyState(deps, installed);
|
||||
// Mirrors the order of assertActivatable's gate, so the card explains the same
|
||||
// blocker the activate call would hit rather than a second, lesser one.
|
||||
const dependencyStatus: PluginDependencyStatus = !hostSatisfies(r.trek_range)
|
||||
const trekBypass = bypassedRange(r.trek_range);
|
||||
const dependencyStatus: PluginDependencyStatus = !hostSatisfies(r.trek_range) && !trekBypass
|
||||
? 'hostIncompatible'
|
||||
: disabledAddons.length
|
||||
? 'addonDisabled'
|
||||
@@ -196,6 +204,7 @@ export class PluginsService {
|
||||
dependencyStatus,
|
||||
trekRange: trek_range,
|
||||
hostVersion: hostVersion(),
|
||||
trekRangeBypassed: trekBypass,
|
||||
dependencyIssues: { disabledAddons, missing: state.missing, versionMismatch: state.versionMismatch },
|
||||
signed: !!author_pubkey,
|
||||
keyFingerprint: keyFingerprint(author_pubkey),
|
||||
@@ -205,7 +214,7 @@ export class PluginsService {
|
||||
updateHold: update_hold === 1,
|
||||
};
|
||||
});
|
||||
return { enabled: pluginsEnabled(), devLink: devLinkEnabled(), plugins };
|
||||
return { enabled: pluginsEnabled(), devLink: devLinkEnabled(), ignoreTrekRange: trekRangeBypassed(), plugins };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { readEnv } from '../../../app-config';
|
||||
import { DatabaseService } from '../../database/database.service';
|
||||
import { discoverPlugins } from '../install/discovery';
|
||||
import { hostSatisfies, hostVersion, normalizedHost } from '../install/host-compat';
|
||||
import { bypassedRange, hostSatisfies, hostVersion, normalizedHost, trekRangeBypassed, warnRangeBypass } from '../install/host-compat';
|
||||
import type { TrekRangeBypass } from '../install/host-compat';
|
||||
import type { PluginDependency } from '../install/manifest';
|
||||
import { parseJsonText, parseManifest } from '../install/manifest';
|
||||
import { scanForNativeBinaries } from '../install/native-scan';
|
||||
@@ -257,7 +258,9 @@ export class PluginRegistryService {
|
||||
private hostCompat(entry: RegistryEntry): HostCompat {
|
||||
const latest = entry.versions[0] ?? null;
|
||||
const compatible = latest ? hostCompatible(latest, normalizedHost()) : false;
|
||||
const fallback = compatible ? null : this.latestCompatible(entry);
|
||||
// The verdict is the TRUTH, so it asks the strict predicate even when the picker is
|
||||
// bypassed: "latest compatible" must keep naming a version the author vouched for.
|
||||
const fallback = compatible ? null : this.latestCompatible(entry, undefined, hostCompatible);
|
||||
return {
|
||||
trek: latest?.trek ?? null,
|
||||
hostVersion: hostVersion(),
|
||||
@@ -358,12 +361,20 @@ export class PluginRegistryService {
|
||||
return v;
|
||||
}
|
||||
|
||||
/** The newest version of `entry` that satisfies `constraint` AND admits the running TREK. */
|
||||
private latestCompatible(entry: RegistryEntry, constraint?: string): RegistryVersion | null {
|
||||
/**
|
||||
* The newest version of `entry` that satisfies `constraint` AND admits the running TREK.
|
||||
* `admits` defaults to the picker's rule (which honours TREK_PLUGINS_IGNORE_TREK_RANGE);
|
||||
* the UI verdict passes the strict {@link hostCompatible} instead.
|
||||
*/
|
||||
private latestCompatible(
|
||||
entry: RegistryEntry,
|
||||
constraint?: string,
|
||||
admits: (v: RegistryVersion, host: string | null) => boolean = installable,
|
||||
): RegistryVersion | null {
|
||||
const host = normalizedHost();
|
||||
const candidates = entry.versions.filter((v) => {
|
||||
if (constraint && !semver.satisfies(v.version, constraint, { includePrerelease: true })) return false;
|
||||
return hostCompatible(v, host);
|
||||
return admits(v, host);
|
||||
});
|
||||
if (!candidates.length) return null;
|
||||
return [...candidates].sort((a, b) => semver.rcompare(a.version, b.version))[0];
|
||||
@@ -380,7 +391,7 @@ export class PluginRegistryService {
|
||||
if (opts?.version) {
|
||||
const ver = entry.versions.find((v) => v.version === opts.version);
|
||||
if (!ver) throw new RegistryError(`version ${opts.version} not found for ${entry.id}`);
|
||||
if (!hostCompatible(ver, normalizedHost())) {
|
||||
if (!installable(ver, normalizedHost())) {
|
||||
throw new RegistryError(
|
||||
`${entry.id} ${ver.version} requires TREK ${trekRequirement(ver)} — this is TREK ${hostVersion()}`,
|
||||
'TREK_VERSION_INCOMPATIBLE',
|
||||
@@ -419,7 +430,7 @@ export class PluginRegistryService {
|
||||
async install(
|
||||
id: string,
|
||||
opts?: { version?: string; constraint?: string; retrustKey?: string },
|
||||
): Promise<{ id: string; version: string }> {
|
||||
): Promise<{ id: string; version: string; trekRangeBypassed: TrekRangeBypass | null }> {
|
||||
const reg = await this.fetchRegistry();
|
||||
const entry = reg.plugins.find((p) => p.id === id);
|
||||
if (!entry) throw new RegistryError(`plugin ${id} not in registry`);
|
||||
@@ -459,7 +470,7 @@ export class PluginRegistryService {
|
||||
// published entries usually carry a lower bound and no upper one at all, so a
|
||||
// plugin that declares "<4.0.0" passes the pre-download filter on TREK 4 and is
|
||||
// caught only here.
|
||||
assertHostCompatible(manifest.trekRange, id);
|
||||
const trekRangeBypassed = assertHostCompatible(manifest.trekRange, id);
|
||||
if (scanForNativeBinaries(pluginRoot).length) throw new RegistryError('artifact contains native binaries');
|
||||
|
||||
// 6. atomic move into place
|
||||
@@ -487,7 +498,7 @@ export class PluginRegistryService {
|
||||
// The plugin is now on new code that passed every check — whatever refusal was
|
||||
// recorded before no longer describes reality.
|
||||
clearUpdateBlock(this.dbs.connection, id);
|
||||
return { id, version: ver.version };
|
||||
return { id, version: ver.version, trekRangeBypassed };
|
||||
} finally {
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
}
|
||||
@@ -503,7 +514,8 @@ export class PluginRegistryService {
|
||||
async installWithDependencies(
|
||||
id: string,
|
||||
constraint?: string,
|
||||
): Promise<{ installed: string[]; requiredAddons: string[] }> {
|
||||
): Promise<{ installed: string[]; requiredAddons: string[]; trekRangeBypassed: TrekRangeBypass | null }> {
|
||||
let trekRangeBypassed: TrekRangeBypass | null = null;
|
||||
const installedNow = new Set(
|
||||
(this.db.prepare('SELECT id FROM plugins').all() as Array<{ id: string }>).map((r) => r.id),
|
||||
);
|
||||
@@ -519,7 +531,10 @@ export class PluginRegistryService {
|
||||
// Install the version we just RESOLVED. Passing the range back (or nothing, for
|
||||
// the root) made install() re-pick on its own and land on entry.versions[0] —
|
||||
// so the compatible version resolveVersion had chosen was computed and discarded.
|
||||
await this.install(pid, { version: ver.version });
|
||||
const res = await this.install(pid, { version: ver.version });
|
||||
// The admin asked for the root; that is the bypass they are warned about (a
|
||||
// dependency's own bypass still lands in the log).
|
||||
if (pid === id) trekRangeBypassed = res.trekRangeBypassed;
|
||||
installed.push(pid);
|
||||
installedNow.add(pid);
|
||||
}
|
||||
@@ -528,7 +543,7 @@ export class PluginRegistryService {
|
||||
for (const dep of ver.pluginDependencies ?? []) await visit(dep.id, dep.version, [...stack, pid]);
|
||||
};
|
||||
await visit(id, constraint, []);
|
||||
return { installed, requiredAddons: [...requiredAddons] };
|
||||
return { installed, requiredAddons: [...requiredAddons], trekRangeBypassed };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -563,7 +578,7 @@ export class PluginRegistryService {
|
||||
* binaries) — only the registry sha256/signature checks are absent, because a
|
||||
* sideload has no registry entry. Throws (and self-cleans staging) on failure.
|
||||
*/
|
||||
stageUpload(bytes: Buffer): { id: string; version: string; root: string; stagingDir: string } {
|
||||
stageUpload(bytes: Buffer): { id: string; version: string; root: string; stagingDir: string; trekRangeBypassed: TrekRangeBypass | null } {
|
||||
if (bytes.length > MAX_UPLOAD_BYTES) throw new RegistryError('archive exceeds the 50MB limit');
|
||||
const stagingDir = path.join(pluginsDataRoot(), '.staging', `upload-${Date.now()}`);
|
||||
try {
|
||||
@@ -573,9 +588,9 @@ export class PluginRegistryService {
|
||||
const manifest = parseManifest(parseJsonText(fs.readFileSync(path.join(root, 'trek-plugin.json'), 'utf8')), {
|
||||
requireTrek: true,
|
||||
});
|
||||
assertHostCompatible(manifest.trekRange, manifest.id);
|
||||
const trekRangeBypassed = assertHostCompatible(manifest.trekRange, manifest.id);
|
||||
if (scanForNativeBinaries(root).length) throw new RegistryError('artifact contains native binaries');
|
||||
return { id: manifest.id, version: manifest.version, root, stagingDir };
|
||||
return { id: manifest.id, version: manifest.version, root, stagingDir, trekRangeBypassed };
|
||||
} catch (e) {
|
||||
fs.rmSync(stagingDir, { recursive: true, force: true });
|
||||
throw e;
|
||||
@@ -746,13 +761,32 @@ function hostCompatible(v: RegistryVersion, host: string | null): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the registry picker may offer `v`: it fits the host, or the operator set
|
||||
* TREK_PLUGINS_IGNORE_TREK_RANGE and asked for the newest published version regardless.
|
||||
* Only the PICKER uses this — the compat verdict the UI shows (hostCompat/detail) stays
|
||||
* on {@link hostCompatible}, so a bypassed install is still labelled as outside its range.
|
||||
*/
|
||||
function installable(v: RegistryVersion, host: string | null): boolean {
|
||||
return trekRangeBypassed() || hostCompatible(v, host);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse an artifact whose declared TREK range doesn't admit the running host. Shared by
|
||||
* every install front door (registry, sideload, dev-link) so they all fail the same way,
|
||||
* with a code the admin UI can act on rather than prose it would have to string-match.
|
||||
*
|
||||
* Under TREK_PLUGINS_IGNORE_TREK_RANGE the refusal becomes a log warning, and the
|
||||
* returned marker is what the front door hands back so the admin is told too. Null
|
||||
* means the range fits and nothing was bypassed.
|
||||
*/
|
||||
export function assertHostCompatible(range: string | null, id: string): void {
|
||||
if (hostSatisfies(range)) return;
|
||||
export function assertHostCompatible(range: string | null, id: string): TrekRangeBypass | null {
|
||||
if (hostSatisfies(range)) return null;
|
||||
const bypass = bypassedRange(range);
|
||||
if (bypass) {
|
||||
warnRangeBypass(id, bypass);
|
||||
return bypass;
|
||||
}
|
||||
throw new RegistryError(`${id} requires TREK ${range} — this is TREK ${hostVersion()}`, 'TREK_VERSION_INCOMPATIBLE');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user