From c7aa1c64b56ee24cadfce91ae620e24bfb1e1291 Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 4 Nov 2026 15:30:14 -0600 Subject: [PATCH] feat: new bundled plugins start on only if recommended, fetch missing shipped ones --- scripts/build-plugins.cjs | 2 +- scripts/bundled-plugins.test.ts | 29 +++++++- scripts/lib/bundled-plugins.cjs | 16 ++++- src/backend/plugins/boot-state.ts | 17 +++++ src/backend/plugins/bundled-index.ts | 60 +++++++++++++--- src/backend/plugins/index.ts | 4 +- src/backend/plugins/manage.ts | 69 ++++++++++++++++++- src/backend/tests/plugins/boot-state.test.ts | 18 +++++ .../tests/plugins/bundled-index.test.ts | 49 +++++++++++-- 9 files changed, 240 insertions(+), 24 deletions(-) diff --git a/scripts/build-plugins.cjs b/scripts/build-plugins.cjs index c6109dfac..109ae34c1 100644 --- a/scripts/build-plugins.cjs +++ b/scripts/build-plugins.cjs @@ -74,7 +74,7 @@ async function main() { const staged = [...pinned, ...extra]; fs.writeFileSync( path.join(destination, "bundled-index.json"), - `${JSON.stringify(buildBundledIndex(plugins, staged), null, 2)} + `${JSON.stringify(buildBundledIndex(plugins, staged, profile), null, 2)} `, ); diff --git a/scripts/bundled-plugins.test.ts b/scripts/bundled-plugins.test.ts index 541d95b5e..3a9aa02a1 100644 --- a/scripts/bundled-plugins.test.ts +++ b/scripts/bundled-plugins.test.ts @@ -221,7 +221,34 @@ describe("buildBundledIndex", () => { ]; expect(buildBundledIndex(plugins, ["a", "c", "local"])).toEqual({ version: 1, + profile: "standard", plugins: { a: { onboarding: { recommended: true, consent: false } } }, + catalog: { + a: { tier: "full" }, + b: { tier: "full" }, + c: { tier: "full" }, + }, + }); + }); + + it("lists every pin with its tier and version", () => { + const plugins = [ + { + id: "a", + tier: "standard", + url: "https://example.com/v1.2.0/a-1.2.0.tmxplug", + onboarding: null, + }, + { + id: "b", + tier: "minimal", + path: "x/b-2.0.0-beta.1.tmxplug", + onboarding: null, + }, + ]; + expect(buildBundledIndex(plugins, [], "minimal").catalog).toEqual({ + a: { tier: "standard", version: "1.2.0" }, + b: { tier: "minimal", version: "2.0.0-beta.1" }, }); }); }); @@ -520,7 +547,7 @@ describe("default look", () => { ], ["look", "plain"], ), - ).toEqual({ version: 1, plugins: { look: { defaultLook: true } } }); + ).toMatchObject({ version: 1, plugins: { look: { defaultLook: true } } }); const index = { plugins: [ diff --git a/scripts/lib/bundled-plugins.cjs b/scripts/lib/bundled-plugins.cjs index 00262c736..16eaf7163 100644 --- a/scripts/lib/bundled-plugins.cjs +++ b/scripts/lib/bundled-plugins.cjs @@ -236,12 +236,22 @@ async function extractArtifact(buffer, outDir, id) { /** * What dist/plugins/bundled-index.json holds: the onboarding defaults of each - * staged plugin, read by the backend when it seeds a fresh install. + * staged plugin, read by the backend when it seeds a fresh install, plus the + * profile and every pin it ships, so an existing install can fetch a shipped + * plugin it finds missing. */ -function buildBundledIndex(plugins, stagedIds) { +function buildBundledIndex(plugins, stagedIds, profile = DEFAULT_PROFILE) { const staged = new Set(stagedIds); const out = {}; + const catalog = {}; for (const plugin of plugins) { + const version = /-(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\.tmxplug$/.exec( + plugin.url ?? plugin.path ?? "", + )?.[1]; + catalog[plugin.id] = { + tier: plugin.tier ?? "full", + ...(version ? { version } : {}), + }; if (!staged.has(plugin.id)) continue; if (!plugin.onboarding && !plugin.defaultLook) continue; out[plugin.id] = { @@ -249,7 +259,7 @@ function buildBundledIndex(plugins, stagedIds) { ...(plugin.defaultLook ? { defaultLook: true } : {}), }; } - return { version: 1, plugins: out }; + return { version: 1, profile, plugins: out, catalog }; } /** The profile to build: --profile, then TERMIX_PLUGIN_PROFILE, then standard. */ diff --git a/src/backend/plugins/boot-state.ts b/src/backend/plugins/boot-state.ts index 5b1030d51..a8227c5a1 100644 --- a/src/backend/plugins/boot-state.ts +++ b/src/backend/plugins/boot-state.ts @@ -35,3 +35,20 @@ export function missingShippedPluginIds( .map((record) => record.id) .sort(); } + +/** + * Plugins this image's profile ships that the install has never seen and + * that are not on disk. They get fetched from the registry once, the first + * boot that can reach it. + */ +export function newShippedPluginIds( + shipped: Iterable, + records: { id: string }[], + presentIds: ReadonlySet, + ignored: ReadonlySet, +): string[] { + const known = new Set(records.map((record) => record.id)); + return [...shipped] + .filter((id) => !known.has(id) && !presentIds.has(id) && !ignored.has(id)) + .sort(); +} diff --git a/src/backend/plugins/bundled-index.ts b/src/backend/plugins/bundled-index.ts index 46333cf8c..055462cb9 100644 --- a/src/backend/plugins/bundled-index.ts +++ b/src/backend/plugins/bundled-index.ts @@ -18,7 +18,14 @@ export interface OnboardingDefaults { type Recommended = boolean | "desktop"; +export interface BundledCatalogEntry { + tier: string; + version?: string; +} + interface BundledIndex { + profile?: string; + catalog?: Record; plugins: Record< string, { @@ -45,7 +52,14 @@ export function readBundledIndex(): BundledIndex | null { ); cached = raw && typeof raw.plugins === "object" && raw.plugins - ? { plugins: raw.plugins } + ? { + plugins: raw.plugins, + profile: typeof raw.profile === "string" ? raw.profile : undefined, + catalog: + raw.catalog && typeof raw.catalog === "object" + ? raw.catalog + : undefined, + } : null; } catch { cached = null; @@ -101,21 +115,45 @@ export function onboardingDefaults(pluginId: string): OnboardingDefaults { }; } +const PROFILES = ["minimal", "standard", "full"]; + /** - * The state a newly seen plugin's row starts in. Bundled plugins start - * enabled, except on a fresh install, where only recommended ones do and - * consent plugins wait for the admin. A plugin dropped into the data - * directory always starts disabled. + * Plugins the image's profile ships (its tier and every smaller one), with + * the version it pins. Read from the bundled index, so core never names them. + */ +export function shippedCatalog(): Map { + const index = readBundledIndex(); + const out = new Map(); + if (!index?.catalog) return out; + const rank = PROFILES.indexOf(index.profile ?? "standard"); + for (const [id, entry] of Object.entries(index.catalog)) { + if (!entry || typeof entry !== "object") continue; + const tier = PROFILES.indexOf(entry.tier); + if (tier === -1 || rank === -1 || tier > rank) continue; + out.set(id, entry); + } + return out; +} + +/** Whether a shipped plugin should be on when an install first sees it. */ +export function startsEnabled(pluginId: string): boolean { + // The default look is what the app is drawn with. + if (isDefaultLookPlugin(pluginId)) return true; + const { recommended, consent } = onboardingDefaults(pluginId); + return recommended && !consent; +} + +/** + * The state a newly seen plugin's row starts in. A bundled plugin starts on + * only when it is recommended, so a plugin a later release adds to the image + * does not switch itself on everywhere. Consent plugins wait for the admin. + * A plugin dropped into the data directory always starts disabled. */ export function initialPluginState( pluginId: string, source: "bundled" | "user", - freshInstall: boolean, + _freshInstall: boolean, ): "enabled" | "disabled" { if (source !== "bundled") return "disabled"; - if (!freshInstall) return "enabled"; - // The default look is what the app is drawn with. - if (isDefaultLookPlugin(pluginId)) return "enabled"; - const { recommended, consent } = onboardingDefaults(pluginId); - return recommended && !consent ? "enabled" : "disabled"; + return startsEnabled(pluginId) ? "enabled" : "disabled"; } diff --git a/src/backend/plugins/index.ts b/src/backend/plugins/index.ts index 4d8c170e5..1717c35b1 100644 --- a/src/backend/plugins/index.ts +++ b/src/backend/plugins/index.ts @@ -243,8 +243,10 @@ export async function initializePlugins( const fresh = await seedPlugins(loaded, options.freshInstall === true); if (!fresh) { - const { restoreMissingPlugins } = await import("./manage.js"); + const { installNewShippedPlugins, restoreMissingPlugins } = + await import("./manage.js"); loaded.push(...(await restoreMissingPlugins())); + loaded.push(...(await installNewShippedPlugins())); } await syncCapabilityGrants(loaded); if (loaded.some((plugin) => plugin.source === "bundled")) { diff --git a/src/backend/plugins/manage.ts b/src/backend/plugins/manage.ts index 674bd74a6..4c8d03ee8 100644 --- a/src/backend/plugins/manage.ts +++ b/src/backend/plugins/manage.ts @@ -17,7 +17,11 @@ import fs from "node:fs"; import path from "node:path"; import semver from "semver"; import { pluginLogger } from "../utils/logger.js"; -import { isDefaultLookPlugin } from "./bundled-index.js"; +import { + isDefaultLookPlugin, + shippedCatalog, + startsEnabled, +} from "./bundled-index.js"; import { mayRunInSafeMode } from "./safe-mode.js"; import { activatePlugin, @@ -26,7 +30,7 @@ import { registerLoadedPlugins, unloadPlugin, } from "./index.js"; -import { missingShippedPluginIds } from "./boot-state.js"; +import { missingShippedPluginIds, newShippedPluginIds } from "./boot-state.js"; import type { LoadedPlugin, PluginLoader } from "./loader.js"; import { getBundledPluginsDir, @@ -1099,6 +1103,67 @@ export function restoreMissingPlugins(): Promise { }); } +/** + * Called at boot on an existing install. A plugin this image's profile ships + * that the install never had and that is not on disk is fetched from the + * official registry and starts on only if it is recommended. Offline, nothing + * is recorded, so the next start tries again. + */ +export function installNewShippedPlugins(): Promise { + return serialized(async () => { + if (await isLinkedDesktop()) return []; + const catalog = shippedCatalog(); + if (catalog.size === 0) return []; + const { createCurrentPluginRepository } = await repos(); + const repository = createCurrentPluginRepository(); + const { loader } = getPluginRuntime(); + const ids = newShippedPluginIds( + catalog.keys(), + await repository.listAll(), + new Set(loader.list().map((plugin) => plugin.id)), + await readUninstalled(), + ); + const added: LoadedPlugin[] = []; + for (const id of ids) { + try { + const { source, release } = await requireRelease( + id, + catalog.get(id)?.version, + "stable", + OFFICIAL_REGISTRY_ID, + ); + const plugin = await swapIn(id, { release, source }); + const enabled = startsEnabled(id); + await repository.update(id, { + registryId: source.id, + tier: "bundled", + state: enabled ? "enabled" : "disabled", + lastError: null, + }); + await grantDeclared(plugin, null); + added.push(plugin); + pluginLogger.info( + `Added ${id}@${release.version} from the registry (${enabled ? "on" : "off"})`, + { operation: "plugin_install_shipped" }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + pluginLogger.warn(`Could not add ${id}: ${message}`, { + operation: "plugin_install_shipped", + }); + // A half made row would hide the plugin from the next try. + if (!loader.get(id)) await repository.delete(id).catch(() => false); + if ( + error instanceof PluginManageError && + error.code === "REGISTRY_UNREACHABLE" + ) + break; + } + } + return added; + }); +} + export function updatePlugin( pluginId: string, options: InstallOptions, diff --git a/src/backend/tests/plugins/boot-state.test.ts b/src/backend/tests/plugins/boot-state.test.ts index 43343a157..d7bb93323 100644 --- a/src/backend/tests/plugins/boot-state.test.ts +++ b/src/backend/tests/plugins/boot-state.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { missingShippedPluginIds, + newShippedPluginIds, startablePluginIds, } from "../../plugins/boot-state.js"; @@ -38,3 +39,20 @@ describe("missingShippedPluginIds", () => { expect(missingShippedPluginIds(records, present, new Set())).toEqual([]); }); }); + +describe("newShippedPluginIds", () => { + it("lists shipped plugins the install never had and that are not on disk", () => { + expect( + newShippedPluginIds( + ["new", "known", "here", "removed", "another"], + [{ id: "known" }], + new Set(["here"]), + new Set(["removed"]), + ), + ).toEqual(["another", "new"]); + }); + + it("finds nothing for an empty catalog", () => { + expect(newShippedPluginIds([], [], new Set(), new Set())).toEqual([]); + }); +}); diff --git a/src/backend/tests/plugins/bundled-index.test.ts b/src/backend/tests/plugins/bundled-index.test.ts index bbf30d5f4..79861d002 100644 --- a/src/backend/tests/plugins/bundled-index.test.ts +++ b/src/backend/tests/plugins/bundled-index.test.ts @@ -8,14 +8,18 @@ import { isDefaultLookPlugin, onboardingDefaults, resetBundledIndexCache, + shippedCatalog, } from "../../plugins/bundled-index.js"; let dir: string; -function writeIndex(plugins: Record) { +function writeIndex( + plugins: Record, + extra: Record = {}, +) { fs.writeFileSync( path.join(dir, "bundled-index.json"), - JSON.stringify({ version: 1, plugins }), + JSON.stringify({ version: 1, plugins, ...extra }), ); resetBundledIndexCache(); } @@ -89,9 +93,10 @@ describe("initialPluginState", () => { }); }); - it("enables every bundled plugin on an existing install", () => { - expect(initialPluginState("other", "bundled", false)).toBe("enabled"); - expect(initialPluginState("ask", "bundled", false)).toBe("enabled"); + it("enables only recommended plugins an existing install sees for the first time", () => { + expect(initialPluginState("kept", "bundled", false)).toBe("enabled"); + expect(initialPluginState("other", "bundled", false)).toBe("disabled"); + expect(initialPluginState("ask", "bundled", false)).toBe("disabled"); }); it("enables only recommended, non-consent plugins on a fresh install", () => { @@ -106,6 +111,40 @@ describe("initialPluginState", () => { }); }); +describe("shippedCatalog", () => { + it("is empty for an index without a catalog", () => { + writeIndex({}); + expect(shippedCatalog().size).toBe(0); + }); + + it("keeps the pins of the image's tier and smaller ones", () => { + writeIndex( + {}, + { + profile: "standard", + catalog: { + look: { tier: "minimal", version: "1.0.0" }, + ops: { tier: "standard", version: "1.0.0" }, + big: { tier: "full", version: "2.0.0" }, + odd: { tier: "huge" }, + }, + }, + ); + expect([...shippedCatalog().keys()].sort()).toEqual(["look", "ops"]); + }); + + it("follows a minimal image", () => { + writeIndex( + {}, + { + profile: "minimal", + catalog: { look: { tier: "minimal" }, ops: { tier: "standard" } }, + }, + ); + expect([...shippedCatalog().keys()]).toEqual(["look"]); + }); +}); + describe("default look", () => { it("lists the plugins flagged as the default look", () => { writeIndex({