chore: tier map for plugins that are not pinned yet

This commit is contained in:
LukeGus committed 2026-11-06 18:18:51 -06:00
1 parent 950e5398c9
commit 76d41a2cbb
6 files changed
+179 -12

No files matched your search

+49
View File
@@ -10,6 +10,7 @@ import {
fetchArtifact,
extractArtifact,
pinsFromIndex,
parsePluginTiers,
buildBundledIndex,
findLocalPluginBuilds,
isLocalBuildStale,
@@ -253,6 +254,34 @@ describe("buildBundledIndex", () => {
});
});
describe("parsePluginTiers", () => {
it("reads tiers and onboarding", () => {
expect(
parsePluginTiers({
plugins: {
a: { tier: "full" },
b: { tier: "standard", onboarding: { recommended: true } },
},
}),
).toEqual({
tiers: {
a: { tier: "full" },
b: {
tier: "standard",
onboarding: { recommended: true, consent: false },
},
},
problems: [],
});
});
it("reports a bad tier", () => {
expect(
parsePluginTiers({ plugins: { a: { tier: "huge" } } }).problems,
).toEqual(["a tier must be one of minimal, standard, full"]);
});
});
describe("pinsFromIndex", () => {
const index = {
plugins: [
@@ -305,6 +334,26 @@ describe("pinsFromIndex", () => {
expect(pins[1].tier).toBe("full");
});
it("takes tier and onboarding for a new plugin from the tier map", () => {
const defaults = {
beta: {
tier: "standard",
onboarding: { recommended: true, consent: false },
},
alpha: { tier: "minimal" },
};
const pins = pinsFromIndex(
index,
null,
[{ id: "alpha", tier: "full" }],
null,
defaults,
);
expect(pins[0].tier).toBe("full");
expect(pins[1].tier).toBe("standard");
expect(pins[1].onboarding).toEqual({ recommended: true, consent: false });
});
it("skips releases whose engine.termix excludes the core version", () => {
const withEngine = {
plugins: [
+18 -5
View File
@@ -9,7 +9,7 @@
* so /^\/plugin-api\/docker/ counts the same as "/plugin-api/docker".
*
* Plugins live in their own repos, so the ids are the official ones below
* plus anything in docker/bundled-plugins.json. electron/ and vite.config.ts
* plus anything in docker/bundled-plugins.json and docker/plugin-tiers.json. electron/ and vite.config.ts
* are read as well.
*
* src/backend/tests, src/ui/tests and src/ui/locales are exempt, and so is
@@ -21,12 +21,17 @@
* One plugin id is also an SSH term: "totp" is the one-time-code prompt kind
* in keyboard-interactive auth, which the connect pipeline in
* src/backend/hosts/connect/ classifies. It is not the login plugin.
*
* A few ids are also plain core words: the host "notes" column, the data
* "backups" folder and the node "dns" module. On their own they are not
* counted, but their routes and dotted ids ("notes.list") still are.
*/
const fs = require("node:fs");
const path = require("node:path");
const SSH_TERMS = new Set(["totp"]);
const CORE_WORDS = new Set(["notes", "backups", "dns"]);
const OFFICIAL_IDS = [
"acme-ssl",
@@ -77,6 +82,7 @@ function paths(root) {
root,
src,
bundled: path.join(root, "docker", "bundled-plugins.json"),
tiers: path.join(root, "docker", "plugin-tiers.json"),
extra: [path.join(root, "electron"), path.join(root, "vite.config.ts")],
exempt: [
path.join(src, "backend", "upgrade"),
@@ -88,8 +94,15 @@ function paths(root) {
};
}
function pluginIds(bundledFile) {
function pluginIds(bundledFile, tiersFile) {
const ids = new Set(OFFICIAL_IDS);
if (tiersFile && fs.existsSync(tiersFile)) {
for (const id of Object.keys(
JSON.parse(fs.readFileSync(tiersFile, "utf8")).plugins ?? {},
)) {
ids.add(id);
}
}
if (bundledFile && fs.existsSync(bundledFile)) {
for (const entry of JSON.parse(fs.readFileSync(bundledFile, "utf8"))
.plugins ?? []) {
@@ -146,8 +159,8 @@ function regexLiterals(source) {
}
function scan(root = path.resolve(__dirname, "..")) {
const { src, bundled, extra, exempt, sshConnect } = paths(root);
const ids = pluginIds(bundled);
const { src, bundled, tiers, extra, exempt, sshConnect } = paths(root);
const ids = pluginIds(bundled, tiers);
const found = {};
const add = (file, what) => {
const key = path.relative(root, file).replaceAll("\\", "/");
@@ -167,7 +180,7 @@ function scan(root = path.resolve(__dirname, "..")) {
const inConnect = file.startsWith(sshConnect + path.sep);
for (const text of literals(source)) {
const sshTerm = inConnect && SSH_TERMS.has(text);
if (ids.has(text) && !sshTerm) add(file, text);
if (ids.has(text) && !sshTerm && !CORE_WORDS.has(text)) add(file, text);
for (const route of text.matchAll(
/\/plugin-(?:api|ws|assets)\/([a-z0-9-]+)/g,
)) {
+14
View File
@@ -86,6 +86,20 @@ describe("scan beyond src", () => {
expect(scan(dir)["src/ui/x.ts"]).toContain("/plugin-ws/extra-thing");
});
it("knows ids from plugin-tiers.json, but not core words alone", () => {
const dir = fixture({
"docker/plugin-tiers.json": JSON.stringify({
plugins: { "next-thing": { tier: "full" }, notes: { tier: "full" } },
}),
"src/ui/x.ts": 'const a = "next-thing";\nconst b = "notes";\n',
"src/ui/y.ts": 'const route = "/plugin-api/notes/list";\n',
});
expect(scan(dir)).toEqual({
"src/ui/x.ts": ["next-thing"],
"src/ui/y.ts": ["/plugin-api/notes"],
});
});
it("reads electron", () => {
const dir = fixture({
"electron/main.cjs": 'invoke("/plugin-api/tunnels/list");\n',
+57 -5
View File
@@ -161,6 +161,46 @@ function loadBundledPlugins(root) {
return plugins;
}
/**
* docker/plugin-tiers.json: tier and onboarding for official plugins that are
* not pinned yet, used the first time `--all` pins them.
*/
function parsePluginTiers(raw) {
const problems = [];
const tiers = {};
const map = raw?.plugins;
if (!map || typeof map !== "object" || Array.isArray(map)) {
return { tiers, problems: ['plugin-tiers.json needs a "plugins" object'] };
}
for (const [id, entry] of Object.entries(map)) {
if (!ID.test(id) || !entry || typeof entry !== "object") {
problems.push(`${id} needs an object`);
continue;
}
if (!PROFILES.includes(entry.tier)) {
problems.push(`${id} tier must be one of ${PROFILES.join(", ")}`);
continue;
}
const before = problems.length;
const onboarding = parseOnboarding(entry.onboarding, id, problems);
if (problems.length > before) continue;
tiers[id] = { tier: entry.tier, ...(onboarding ? { onboarding } : {}) };
}
return { tiers, problems };
}
function loadPluginTiers(root) {
const file = path.join(root, "docker", "plugin-tiers.json");
if (!fs.existsSync(file)) return {};
const { tiers, problems } = parsePluginTiers(
JSON.parse(fs.readFileSync(file, "utf8")),
);
if (problems.length > 0) {
throw new Error(`docker/plugin-tiers.json:\n ${problems.join("\n ")}`);
}
return tiers;
}
/** Refuses an artifact whose bytes are not the ones the config pinned. */
function checkSha256(buffer, expected, id) {
const actual = crypto.createHash("sha256").update(buffer).digest("hex");
@@ -286,12 +326,19 @@ function pluginsForProfile(plugins, profile) {
* Pins the newest registry version of each wanted plugin. `ids` is the list
* to pin, or null for every plugin in the index. `current` is the existing
* pin list, so fields the registry does not know (onboarding, tier) carry over;
* a plugin new to the list starts in the full tier. coreVersion, when given,
* a plugin new to the list takes them from `defaults` (plugin-tiers.json),
* else starts in the full tier. coreVersion, when given,
* skips releases whose engine.termix range does not include it, so the image
* never ships a plugin its own core cannot run. With ids null, a plugin new to
* the list that has no usable release (only betas, say) is left out.
*/
function pinsFromIndex(index, ids, current = [], coreVersion = null) {
function pinsFromIndex(
index,
ids,
current = [],
coreVersion = null,
defaults = {},
) {
const plugins = Array.isArray(index?.plugins) ? index.plugins : [];
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
const currentById = new Map(current.map((entry) => [entry.id, entry]));
@@ -315,9 +362,12 @@ function pinsFromIndex(index, ids, current = [], coreVersion = null) {
if (ids === null && !currentById.has(id)) return null;
throw new Error(`${id} is not in the registry index`);
}
const onboarding = currentById.get(id)?.onboarding;
const defaultLook = currentById.get(id)?.defaultLook === true;
const tier = currentById.get(id)?.tier ?? "full";
const existing = currentById.get(id);
const onboarding = existing
? existing.onboarding
: defaults[id]?.onboarding;
const defaultLook = existing?.defaultLook === true;
const tier = existing?.tier ?? defaults[id]?.tier ?? "full";
return {
id,
source: "tmxplug",
@@ -485,6 +535,8 @@ module.exports = {
buildBundledIndex,
pinsFromIndex,
loadBundledPlugins,
parsePluginTiers,
loadPluginTiers,
checkSha256,
fetchArtifact,
extractArtifact,
+9 -2
View File
@@ -4,7 +4,7 @@
* re-released.
*
* node scripts/pin-bundled-plugins.cjs refresh the plugins already listed
* node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index that have a stable release (tier full)
* node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index that have a stable release (tier from docker/plugin-tiers.json, else full)
* --core-version X skip releases whose engine.termix excludes X
*
* The release workflow runs it with --all and the version being released.
@@ -14,6 +14,7 @@ const fs = require("node:fs");
const path = require("node:path");
const {
loadBundledPlugins,
loadPluginTiers,
pinsFromIndex,
} = require("./lib/bundled-plugins.cjs");
@@ -35,7 +36,13 @@ async function main() {
const current = loadBundledPlugins(root);
const ids = all ? null : current.map((plugin) => plugin.id);
const plugins = pinsFromIndex(index, ids, current, coreVersion);
const plugins = pinsFromIndex(
index,
ids,
current,
coreVersion,
loadPluginTiers(root),
);
fs.writeFileSync(configPath, `${JSON.stringify({ plugins }, null, 2)}\n`);
console.log(
`Pinned ${plugins.length} plugin(s) in docker/bundled-plugins.json`,