mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
v26.10.5 (#1710)
* feat: add an auto or fixed columns picker to card grids * feat: add safe mode that only runs layout and theme plugins * docs: add simple layout to plugin table * fix: ask for local network access in the macOS app * feat: allow plugin registries on listed private hosts (#1705) * chore: add private registry hosts to the changelog * feat: use the proxy for plugin registry downloads * fix: allow changing connection origin on shared hosts * chore: update translations and plugin pins * chore: lint, format, and bump version to 26.10.5 --------- Co-authored-by: Lukas <80832688+0skater0@users.noreply.github.com>
This commit is contained in:
1 parent
7b7b17e31f
commit
96b8520fb4
96 files changed
+1885
-474
No files matched your search
@@ -3,6 +3,8 @@
|
||||
*
|
||||
* getBundledPluginsDir() in src/backend/plugins/paths.ts resolves
|
||||
* dist/backend/backend/plugins -> dist/plugins, which is where this writes.
|
||||
* --profile minimal|standard|full (or TERMIX_PLUGIN_PROFILE, default standard)
|
||||
* picks which tiers of the file are staged.
|
||||
* Each .tmxplug is downloaded (or read from a path), checked against its
|
||||
* pinned sha256 and unpacked.
|
||||
*
|
||||
@@ -25,13 +27,16 @@ const {
|
||||
localSdkBuildCommand,
|
||||
copyLocalBuild,
|
||||
buildBundledIndex,
|
||||
resolveProfile,
|
||||
pluginsForProfile,
|
||||
} = require("./lib/bundled-plugins.cjs");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
const destination = path.join(root, "dist", "plugins");
|
||||
|
||||
async function main() {
|
||||
const plugins = loadBundledPlugins(root);
|
||||
const profile = resolveProfile();
|
||||
const plugins = pluginsForProfile(loadBundledPlugins(root), profile);
|
||||
const flag = process.argv.indexOf("--local");
|
||||
const localArg =
|
||||
flag !== -1 ? process.argv[flag + 1] : process.env.TERMIX_LOCAL_PLUGINS;
|
||||
@@ -74,7 +79,7 @@ async function main() {
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Bundled ${plugins.length + extra.length} plugin(s): ${[...pinned, ...extra].join(", ")}`,
|
||||
`Bundled ${plugins.length + extra.length} plugin(s) (${profile}): ${[...pinned, ...extra].join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
localSdkBuildCommand,
|
||||
rebuildLocalPlugins,
|
||||
copyLocalBuild,
|
||||
resolveProfile,
|
||||
pluginsForProfile,
|
||||
} from "./lib/bundled-plugins.cjs";
|
||||
|
||||
const cleanups: Array<() => void> = [];
|
||||
@@ -263,10 +265,52 @@ describe("pinsFromIndex", () => {
|
||||
source: "tmxplug",
|
||||
url: "https://example.test/beta-1.1.0.tmxplug",
|
||||
sha256: "b".repeat(64),
|
||||
tier: "full",
|
||||
});
|
||||
expect(parseBundledPlugins({ plugins: pins }).problems).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the tier of an existing pin", () => {
|
||||
const pins = pinsFromIndex(index, null, [
|
||||
{ id: "alpha", tier: "standard" },
|
||||
]);
|
||||
expect(pins[0].tier).toBe("standard");
|
||||
expect(pins[1].tier).toBe("full");
|
||||
});
|
||||
|
||||
it("skips releases whose engine.termix excludes the core version", () => {
|
||||
const withEngine = {
|
||||
plugins: [
|
||||
{
|
||||
id: "gamma",
|
||||
versions: [
|
||||
{
|
||||
version: "2.0.0",
|
||||
url: "https://example.test/gamma-2.0.0.tmxplug",
|
||||
sha256: "d".repeat(64),
|
||||
engine: { api: "^1", termix: ">=26.10.6" },
|
||||
},
|
||||
{
|
||||
version: "1.0.0",
|
||||
url: "https://example.test/gamma-1.0.0.tmxplug",
|
||||
sha256: "e".repeat(64),
|
||||
engine: { api: "^1", termix: ">=26.10.0" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(pinsFromIndex(withEngine, null, [], "26.10.5")[0].url).toBe(
|
||||
"https://example.test/gamma-1.0.0.tmxplug",
|
||||
);
|
||||
expect(pinsFromIndex(withEngine, null, [], "26.10.6")[0].url).toBe(
|
||||
"https://example.test/gamma-2.0.0.tmxplug",
|
||||
);
|
||||
expect(pinsFromIndex(withEngine, null)[0].url).toBe(
|
||||
"https://example.test/gamma-2.0.0.tmxplug",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the onboarding defaults of an existing pin", () => {
|
||||
const current = [
|
||||
{ id: "beta", onboarding: { recommended: true, consent: false } },
|
||||
@@ -511,3 +555,61 @@ describe("default look", () => {
|
||||
expect(fs.existsSync(path.join(out, "themes", "paper.css"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin profiles", () => {
|
||||
const entry = (id: string, tier?: string) => ({
|
||||
id,
|
||||
source: "tmxplug",
|
||||
url: `https://x/${id}.tmxplug`,
|
||||
sha256: SHA,
|
||||
...(tier ? { tier } : {}),
|
||||
});
|
||||
const { plugins } = parseBundledPlugins({
|
||||
plugins: [
|
||||
entry("look", "minimal"),
|
||||
entry("term", "standard"),
|
||||
entry("extra", "full"),
|
||||
entry("untiered"),
|
||||
],
|
||||
});
|
||||
const ids = (profile: string) =>
|
||||
pluginsForProfile(plugins, profile).map((p: { id: string }) => p.id);
|
||||
|
||||
it("ships each tier and every smaller one", () => {
|
||||
expect(ids("minimal")).toEqual(["look"]);
|
||||
expect(ids("standard")).toEqual(["look", "term"]);
|
||||
expect(ids("full")).toEqual(["look", "term", "extra", "untiered"]);
|
||||
});
|
||||
|
||||
it("rejects an unknown tier", () => {
|
||||
const { problems } = parseBundledPlugins({ plugins: [entry("x", "huge")] });
|
||||
expect(problems[0]).toMatch(/tier must be one of/);
|
||||
});
|
||||
|
||||
it("reads the profile from the flag, then the environment", () => {
|
||||
expect(resolveProfile(["node", "x"], {})).toBe("standard");
|
||||
expect(
|
||||
resolveProfile(["node", "x"], { TERMIX_PLUGIN_PROFILE: "full" }),
|
||||
).toBe("full");
|
||||
expect(
|
||||
resolveProfile(["node", "x", "--profile", "minimal"], {
|
||||
TERMIX_PLUGIN_PROFILE: "full",
|
||||
}),
|
||||
).toBe("minimal");
|
||||
expect(() =>
|
||||
resolveProfile(["node", "x", "--profile", "tiny"], {}),
|
||||
).toThrow(/unknown plugin profile/);
|
||||
});
|
||||
|
||||
it("keeps the real file valid with a default look in the minimal tier", () => {
|
||||
const real = loadBundledPlugins(path.resolve(__dirname, ".."));
|
||||
const minimal = pluginsForProfile(real, "minimal");
|
||||
expect(minimal.length).toBeGreaterThan(0);
|
||||
expect(minimal.every((p: { defaultLook: boolean }) => p.defaultLook)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(pluginsForProfile(real, "standard").length).toBeLessThan(
|
||||
real.length,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -151,6 +151,7 @@ const ALLOWED = [
|
||||
"GridCardCatalogEntry",
|
||||
"GridColSpan",
|
||||
"GridColumns",
|
||||
"GridSize",
|
||||
"GridLayout",
|
||||
"GridSlot",
|
||||
"GroupHeading",
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
* environment variables is set, for a server configured before first boot.
|
||||
* Plugins without it start unchecked.
|
||||
*
|
||||
* "tier" says which image profile ships the plugin: "minimal" (only what the
|
||||
* app cannot start without, the default look), "standard" (the default image)
|
||||
* or "full" (everything else). A profile includes its own tier and every
|
||||
* smaller one. A plugin with no tier counts as "full".
|
||||
*
|
||||
* "defaultLook": true marks the plugin whose layout (and that layout's
|
||||
* theme) a fresh install shows. Core finds the default look this way, so it
|
||||
* never names a plugin. Such a plugin always starts enabled and cannot be
|
||||
@@ -31,6 +36,8 @@ const ID = /^[a-z0-9][a-z0-9-]*$/;
|
||||
const ENV_NAME = /^[A-Z][A-Z0-9_]*$/;
|
||||
const ONBOARDING_KEYS = new Set(["recommended", "consent", "enabledByEnv"]);
|
||||
const SHA256 = /^[0-9a-f]{64}$/;
|
||||
const PROFILES = ["minimal", "standard", "full"];
|
||||
const DEFAULT_PROFILE = "standard";
|
||||
|
||||
function parseOnboarding(value, id, problems) {
|
||||
if (value === undefined) return null;
|
||||
@@ -122,6 +129,9 @@ function parseBundledPlugins(raw) {
|
||||
) {
|
||||
problems.push(`${entry.id} defaultLook must be a boolean`);
|
||||
}
|
||||
if (entry.tier !== undefined && !PROFILES.includes(entry.tier)) {
|
||||
problems.push(`${entry.id} tier must be one of ${PROFILES.join(", ")}`);
|
||||
}
|
||||
if (problems.length > before) continue;
|
||||
plugins.push({
|
||||
id: entry.id,
|
||||
@@ -131,6 +141,7 @@ function parseBundledPlugins(raw) {
|
||||
sha256: entry.sha256,
|
||||
onboarding,
|
||||
defaultLook: entry.defaultLook === true,
|
||||
tier: entry.tier ?? "full",
|
||||
});
|
||||
} else {
|
||||
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
|
||||
@@ -241,12 +252,35 @@ function buildBundledIndex(plugins, stagedIds) {
|
||||
return { version: 1, plugins: out };
|
||||
}
|
||||
|
||||
/** The profile to build: --profile, then TERMIX_PLUGIN_PROFILE, then standard. */
|
||||
function resolveProfile(argv = process.argv, env = process.env) {
|
||||
const flag = argv.indexOf("--profile");
|
||||
const value =
|
||||
(flag !== -1 ? argv[flag + 1] : env.TERMIX_PLUGIN_PROFILE) ||
|
||||
DEFAULT_PROFILE;
|
||||
if (!PROFILES.includes(value)) {
|
||||
throw new Error(
|
||||
`unknown plugin profile "${value}", use ${PROFILES.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The plugins a profile ships: its own tier and every smaller one. */
|
||||
function pluginsForProfile(plugins, profile) {
|
||||
const rank = PROFILES.indexOf(profile);
|
||||
return plugins.filter((plugin) => PROFILES.indexOf(plugin.tier) <= rank);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) carry over.
|
||||
* 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,
|
||||
* skips releases whose engine.termix range does not include it, so the image
|
||||
* never ships a plugin its own core cannot run.
|
||||
*/
|
||||
function pinsFromIndex(index, ids, current = []) {
|
||||
function pinsFromIndex(index, ids, current = [], coreVersion = null) {
|
||||
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]));
|
||||
@@ -257,15 +291,25 @@ function pinsFromIndex(index, ids, current = []) {
|
||||
const latest = [...(listed?.versions ?? []), ...(listed?.gated ?? [])]
|
||||
.filter((entry) => semver.valid(entry.version))
|
||||
.filter((entry) => !semver.prerelease(entry.version))
|
||||
.filter(
|
||||
(entry) =>
|
||||
!coreVersion ||
|
||||
!entry.engine?.termix ||
|
||||
semver.satisfies(coreVersion, entry.engine.termix, {
|
||||
includePrerelease: true,
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => semver.rcompare(a.version, b.version))[0];
|
||||
if (!latest) 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";
|
||||
return {
|
||||
id,
|
||||
source: "tmxplug",
|
||||
url: latest.url,
|
||||
sha256: latest.sha256,
|
||||
tier,
|
||||
...(onboarding ? { onboarding } : {}),
|
||||
...(defaultLook ? { defaultLook } : {}),
|
||||
};
|
||||
@@ -409,6 +453,9 @@ function copyLocalBuild(repo, destination) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROFILES,
|
||||
resolveProfile,
|
||||
pluginsForProfile,
|
||||
findLocalPluginBuilds,
|
||||
isLocalBuildStale,
|
||||
localSdkBuildCommand,
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
* re-released.
|
||||
*
|
||||
* node scripts/pin-bundled-plugins.cjs refresh the plugins already listed
|
||||
* node scripts/pin-bundled-plugins.cjs --all list every plugin in the index
|
||||
* node scripts/pin-bundled-plugins.cjs --all also add plugins new to the index (tier full)
|
||||
* --core-version X skip releases whose engine.termix excludes X
|
||||
*
|
||||
* The release workflow runs it with --all and the version being released.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
@@ -22,6 +25,8 @@ const configPath = path.join(root, "docker", "bundled-plugins.json");
|
||||
|
||||
async function main() {
|
||||
const all = process.argv.includes("--all");
|
||||
const flag = process.argv.indexOf("--core-version");
|
||||
const coreVersion = flag !== -1 ? process.argv[flag + 1] : null;
|
||||
const response = await fetch(INDEX_URL);
|
||||
if (!response.ok) {
|
||||
throw new Error(`registry index download failed with ${response.status}`);
|
||||
@@ -30,7 +35,7 @@ async function main() {
|
||||
|
||||
const current = loadBundledPlugins(root);
|
||||
const ids = all ? null : current.map((plugin) => plugin.id);
|
||||
const plugins = pinsFromIndex(index, ids, current);
|
||||
const plugins = pinsFromIndex(index, ids, current, coreVersion);
|
||||
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