mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-07 02:55:24 +00:00
chore: tier map for plugins that are not pinned yet
This commit is contained in:
1 parent
950e5398c9
commit
76d41a2cbb
6 files changed
+179
-12
No files matched your search
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
)) {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in new issue
Block a user