feat: new bundled plugins start on only if recommended, fetch missing shipped ones

This commit is contained in:
LukeGus committed 2026-11-04 15:30:14 -06:00
1 parent 9613b787a7
commit c7aa1c64b5
9 files changed
+240 -24

No files matched your search

+1 -1
View File
@@ -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)}
`,
);
+28 -1
View File
@@ -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: [
+13 -3
View File
@@ -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. */
+17
View File
@@ -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<string>,
records: { id: string }[],
presentIds: ReadonlySet<string>,
ignored: ReadonlySet<string>,
): string[] {
const known = new Set(records.map((record) => record.id));
return [...shipped]
.filter((id) => !known.has(id) && !presentIds.has(id) && !ignored.has(id))
.sort();
}
+49 -11
View File
@@ -18,7 +18,14 @@ export interface OnboardingDefaults {
type Recommended = boolean | "desktop";
export interface BundledCatalogEntry {
tier: string;
version?: string;
}
interface BundledIndex {
profile?: string;
catalog?: Record<string, BundledCatalogEntry>;
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<string, BundledCatalogEntry> {
const index = readBundledIndex();
const out = new Map<string, BundledCatalogEntry>();
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";
}
+3 -1
View File
@@ -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")) {
+67 -2
View File
@@ -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<LoadedPlugin[]> {
});
}
/**
* 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<LoadedPlugin[]> {
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,
@@ -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([]);
});
});
@@ -8,14 +8,18 @@ import {
isDefaultLookPlugin,
onboardingDefaults,
resetBundledIndexCache,
shippedCatalog,
} from "../../plugins/bundled-index.js";
let dir: string;
function writeIndex(plugins: Record<string, unknown>) {
function writeIndex(
plugins: Record<string, unknown>,
extra: Record<string, unknown> = {},
) {
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({