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:
jubnl
2026-09-04 00:43:06 +02:00
committed by Maurice
parent fa406b708f
commit 61bef334d5
53 changed files with 1378 additions and 337 deletions
@@ -5,6 +5,8 @@ import {
isValidTrekRange,
minTrekOf,
hostSatisfies,
trekRangeBypassed,
bypassedRange,
__resetHostWarningForTests,
} from '../../../src/nest/plugins/install/host-compat';
@@ -18,6 +20,7 @@ const APP_VERSION = process.env.APP_VERSION;
afterEach(() => {
if (APP_VERSION === undefined) delete process.env.APP_VERSION;
else process.env.APP_VERSION = APP_VERSION;
delete process.env.TREK_PLUGINS_IGNORE_TREK_RANGE;
__resetHostWarningForTests();
});
@@ -114,3 +117,40 @@ describe('hostSatisfies', () => {
expect(hostVersion()).toMatch(/^\d+\.\d+\.\d+/); // package.json
});
});
/**
* TREK_PLUGINS_IGNORE_TREK_RANGE: an operator's explicit "install it anyway" for a plugin
* whose author has not updated its `trek` range. The gates warn instead of refusing, but
* the truth (hostSatisfies) never changes — the UI still has to say the plugin is
* outside its range.
*/
describe('range bypass (TREK_PLUGINS_IGNORE_TREK_RANGE)', () => {
it('is off by default and only an explicit truthy value turns it on', () => {
expect(trekRangeBypassed()).toBe(false);
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '0';
expect(trekRangeBypassed()).toBe(false);
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
expect(trekRangeBypassed()).toBe(true);
});
it('reports a bypassed mismatch only when the bypass is on AND the range does not admit the host', () => {
process.env.APP_VERSION = '3.3.0';
expect(bypassedRange('>=4.0.0')).toBeNull(); // off → the gate refuses, nothing to report
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = 'true';
expect(bypassedRange('>=4.0.0')).toEqual({ trekRange: '>=4.0.0', hostVersion: '3.3.0' });
expect(bypassedRange('>=3.2.0 <4.0.0')).toBeNull(); // fits → not a bypass, a normal install
});
it('covers a plugin that declared no range at all — the same author oversight', () => {
process.env.APP_VERSION = '3.3.0';
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
expect(bypassedRange(null)).toEqual({ trekRange: null, hostVersion: '3.3.0' });
expect(bypassedRange(undefined)).toEqual({ trekRange: null, hostVersion: '3.3.0' });
});
it('leaves hostSatisfies telling the truth while bypassed', () => {
process.env.APP_VERSION = '3.3.0';
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
expect(hostSatisfies('>=4.0.0')).toBe(false);
});
});
@@ -105,16 +105,16 @@ describe('signature refusal codes survive to the client', () => {
// must install exactly that one — the compat gate stays server-side in selectVersion.
describe('POST :id/update — explicit version', () => {
it('forwards the requested version to the runtime', async () => {
vi.mocked(runtime.update).mockResolvedValue({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [] });
vi.mocked(runtime.update).mockResolvedValue({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [], trekRangeBypassed: null });
const res = await controller.update('flight-tracker', { version: '1.2.0' });
expect(vi.mocked(runtime.update).mock.calls[0]).toEqual(['flight-tracker', { version: '1.2.0' }]);
expect(res).toEqual({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [] });
expect(res).toEqual({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [], trekRangeBypassed: null });
});
it('an omitted body keeps the default newest-compatible resolution', async () => {
vi.mocked(runtime.update).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
vi.mocked(runtime.update).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [], trekRangeBypassed: null });
await controller.update('flight-tracker');
@@ -128,7 +128,7 @@ describe('POST :id/update — explicit version', () => {
// must not touch it.
describe('update hold wiring', () => {
it('an explicit-version update recomputes the hold as deliberate', async () => {
vi.mocked(runtime.update).mockResolvedValue({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [] });
vi.mocked(runtime.update).mockResolvedValue({ version: '1.2.0', activated: true, newPermissions: [], newEgress: [], trekRangeBypassed: null });
await controller.update('flight-tracker', { version: '1.2.0' });
@@ -136,7 +136,7 @@ describe('update hold wiring', () => {
});
it('a default update recomputes as non-deliberate (clears a stale hold)', async () => {
vi.mocked(runtime.update).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [] });
vi.mocked(runtime.update).mockResolvedValue({ version: '2.0.0', activated: true, newPermissions: [], newEgress: [], trekRangeBypassed: null });
await controller.update('flight-tracker');
@@ -144,7 +144,7 @@ describe('update hold wiring', () => {
});
it('an explicit-version install recomputes the hold as deliberate', async () => {
vi.mocked(registry.install).mockResolvedValue({ id: 'flight-tracker', version: '1.2.0' });
vi.mocked(registry.install).mockResolvedValue({ id: 'flight-tracker', version: '1.2.0', trekRangeBypassed: null });
await controller.install({ id: 'flight-tracker', version: '1.2.0' });
@@ -152,7 +152,7 @@ describe('update hold wiring', () => {
});
it('a dependency-resolution install does NOT touch the hold', async () => {
vi.mocked(registry.installWithDependencies).mockResolvedValue({ installed: ['flight-tracker'], requiredAddons: [] });
vi.mocked(registry.installWithDependencies).mockResolvedValue({ installed: ['flight-tracker'], requiredAddons: [], trekRangeBypassed: null });
await controller.install({ id: 'flight-tracker', withDependencies: true });
@@ -48,6 +48,47 @@ describe('PluginsService.list', () => {
expect(out.plugins[0]).toMatchObject({ id: 'flight', name: 'Flight', status: 'inactive' });
});
describe('TREK-range bypass', () => {
const APP_VERSION = process.env.APP_VERSION;
afterEach(() => {
delete process.env.TREK_PLUGINS_IGNORE_TREK_RANGE;
if (APP_VERSION === undefined) delete process.env.APP_VERSION;
else process.env.APP_VERSION = APP_VERSION;
});
const seed = () =>
testDb
.prepare("INSERT INTO plugins (id, name, type, status, version, trek_range) VALUES ('old','Old','widget','inactive','1.0.0','>=3.0.0 <4.0.0')")
.run();
it('reports the switch off and an outgrown plugin as hostIncompatible by default', () => {
process.env.APP_VERSION = '4.1.0';
seed();
const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
expect(out.ignoreTrekRange).toBe(false);
expect(out.plugins[0]).toMatchObject({ dependencyStatus: 'hostIncompatible', trekRangeBypassed: null });
});
it('with the switch on, the plugin may activate but the row still says it is outside its range', () => {
process.env.APP_VERSION = '4.1.0';
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
seed();
const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
expect(out.ignoreTrekRange).toBe(true);
expect(out.plugins[0]).toMatchObject({
dependencyStatus: 'ok',
trekRangeBypassed: { trekRange: '>=3.0.0 <4.0.0', hostVersion: '4.1.0' },
});
});
it('a plugin inside its range carries no marker even with the switch on', () => {
process.env.APP_VERSION = '3.5.0';
process.env.TREK_PLUGINS_IGNORE_TREK_RANGE = '1';
seed();
const out = new PluginsService(new DatabaseService(dbConn), new AddonsService(new DatabaseService(dbConn))).list();
expect(out.plugins[0]).toMatchObject({ dependencyStatus: 'ok', trekRangeBypassed: null });
});
});
it('surfaces updateHold as a boolean (held plugins leave the update banner)', () => {
testDb
.prepare("INSERT INTO plugins (id, name, type, status, version, update_hold) VALUES ('held','Held','widget','inactive','1.0.0',1)")