mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: full screen onboarding with a plugin picker and per step versions
This commit is contained in:
1 parent
1e74ce4827
commit
b9dd3488a5
59 files changed
+5413
-643
No files matched your search
@@ -22,6 +22,7 @@ const {
|
||||
findLocalPluginBuilds,
|
||||
rebuildLocalPlugins,
|
||||
copyLocalBuild,
|
||||
buildBundledIndex,
|
||||
} = require("./lib/bundled-plugins.cjs");
|
||||
|
||||
const root = path.resolve(__dirname, "..");
|
||||
@@ -62,6 +63,13 @@ async function main() {
|
||||
console.log(`copied local build of ${id} (not pinned)`);
|
||||
}
|
||||
|
||||
const staged = [...pinned, ...extra];
|
||||
fs.writeFileSync(
|
||||
path.join(destination, "bundled-index.json"),
|
||||
`${JSON.stringify(buildBundledIndex(plugins, staged), null, 2)}
|
||||
`,
|
||||
);
|
||||
|
||||
console.log(
|
||||
`Bundled ${plugins.length + extra.length} plugin(s): ${[...pinned, ...extra].join(", ")}`,
|
||||
);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
fetchArtifact,
|
||||
extractArtifact,
|
||||
pinsFromIndex,
|
||||
buildBundledIndex,
|
||||
findLocalPluginBuilds,
|
||||
isLocalBuildStale,
|
||||
rebuildLocalPlugins,
|
||||
@@ -83,6 +84,73 @@ describe("parseBundledPlugins", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads onboarding defaults", () => {
|
||||
const { plugins, problems } = parseBundledPlugins({
|
||||
plugins: [
|
||||
{
|
||||
id: "kept",
|
||||
source: "tmxplug",
|
||||
path: "a",
|
||||
sha256: SHA,
|
||||
onboarding: { recommended: true },
|
||||
},
|
||||
{
|
||||
id: "desk",
|
||||
source: "tmxplug",
|
||||
path: "a",
|
||||
sha256: SHA,
|
||||
onboarding: { recommended: "desktop" },
|
||||
},
|
||||
{
|
||||
id: "ask",
|
||||
source: "tmxplug",
|
||||
path: "a",
|
||||
sha256: SHA,
|
||||
onboarding: { recommended: true, consent: true },
|
||||
},
|
||||
{
|
||||
id: "env",
|
||||
source: "tmxplug",
|
||||
path: "a",
|
||||
sha256: SHA,
|
||||
onboarding: { enabledByEnv: ["OIDC_CLIENT_ID"] },
|
||||
},
|
||||
{ id: "plain", source: "tmxplug", path: "a", sha256: SHA },
|
||||
],
|
||||
});
|
||||
expect(problems).toEqual([]);
|
||||
expect(plugins.map((p: { onboarding: unknown }) => p.onboarding)).toEqual([
|
||||
{ recommended: true, consent: false },
|
||||
{ recommended: "desktop", consent: false },
|
||||
{ recommended: true, consent: true },
|
||||
{ recommended: false, consent: false, enabledByEnv: ["OIDC_CLIENT_ID"] },
|
||||
null,
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects bad onboarding defaults", () => {
|
||||
const entry = { source: "tmxplug", path: "a", sha256: SHA };
|
||||
const { plugins, problems } = parseBundledPlugins({
|
||||
plugins: [
|
||||
{ ...entry, id: "a", onboarding: "yes" },
|
||||
{ ...entry, id: "b", onboarding: { recommended: "web" } },
|
||||
{ ...entry, id: "c", onboarding: { consent: 1 } },
|
||||
{ ...entry, id: "d", onboarding: { pinned: true } },
|
||||
{ ...entry, id: "e", onboarding: { enabledByEnv: ["lower"] } },
|
||||
{ ...entry, id: "f", onboarding: { enabledByEnv: [] } },
|
||||
],
|
||||
});
|
||||
expect(plugins).toEqual([]);
|
||||
expect(problems).toEqual([
|
||||
"a has an onboarding field that is not an object",
|
||||
'b onboarding.recommended must be true, false or "desktop"',
|
||||
"c onboarding.consent must be a boolean",
|
||||
'd has an unknown onboarding field "pinned"',
|
||||
"e onboarding.enabledByEnv must be a list of environment variable names",
|
||||
"f onboarding.enabledByEnv must be a list of environment variable names",
|
||||
]);
|
||||
});
|
||||
|
||||
it("reads the list in this repo", () => {
|
||||
const root = path.resolve(__dirname, "..");
|
||||
expect(() => loadBundledPlugins(root)).not.toThrow();
|
||||
@@ -141,6 +209,20 @@ describe("tmxplug entries", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBundledIndex", () => {
|
||||
it("lists the onboarding defaults of staged plugins only", () => {
|
||||
const plugins = [
|
||||
{ id: "a", onboarding: { recommended: true, consent: false } },
|
||||
{ id: "b", onboarding: { recommended: "desktop", consent: false } },
|
||||
{ id: "c", onboarding: null },
|
||||
];
|
||||
expect(buildBundledIndex(plugins, ["a", "c", "local"])).toEqual({
|
||||
version: 1,
|
||||
plugins: { a: { onboarding: { recommended: true, consent: false } } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("pinsFromIndex", () => {
|
||||
const index = {
|
||||
plugins: [
|
||||
@@ -184,6 +266,15 @@ describe("pinsFromIndex", () => {
|
||||
expect(parseBundledPlugins({ plugins: pins }).problems).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps the onboarding defaults of an existing pin", () => {
|
||||
const current = [
|
||||
{ id: "beta", onboarding: { recommended: true, consent: false } },
|
||||
];
|
||||
const pins = pinsFromIndex(index, null, current);
|
||||
expect(pins[1].onboarding).toEqual({ recommended: true, consent: false });
|
||||
expect(pins[0]).not.toHaveProperty("onboarding");
|
||||
});
|
||||
|
||||
it("pins only the ids asked for", () => {
|
||||
expect(pinsFromIndex(index, ["beta"]).map((pin) => pin.id)).toEqual([
|
||||
"beta",
|
||||
|
||||
@@ -7,6 +7,14 @@
|
||||
*
|
||||
* A tmxplug entry is pinned by sha256 in this file, which is reviewed like
|
||||
* any other change, so the build does not need the registry signature.
|
||||
*
|
||||
* An entry can also carry "onboarding": { "recommended": true | "desktop",
|
||||
* "consent": true, "enabledByEnv": ["NAME"] }. The first-run plugin picker
|
||||
* keeps recommended plugins by default ("desktop" only in the desktop app),
|
||||
* and shows consent ones (like telemetry) in their own opt-in section.
|
||||
* enabledByEnv counts the plugin as recommended when any of those
|
||||
* environment variables is set, for a server configured before first boot.
|
||||
* Plugins without it start unchecked.
|
||||
*/
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
@@ -14,8 +22,54 @@ const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
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}$/;
|
||||
|
||||
function parseOnboarding(value, id, problems) {
|
||||
if (value === undefined) return null;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
problems.push(`${id} has an onboarding field that is not an object`);
|
||||
return null;
|
||||
}
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!ONBOARDING_KEYS.has(key)) {
|
||||
problems.push(`${id} has an unknown onboarding field "${key}"`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
const { recommended = false, consent = false } = value;
|
||||
if (
|
||||
recommended !== true &&
|
||||
recommended !== false &&
|
||||
recommended !== "desktop"
|
||||
) {
|
||||
problems.push(
|
||||
`${id} onboarding.recommended must be true, false or "desktop"`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (typeof consent !== "boolean") {
|
||||
problems.push(`${id} onboarding.consent must be a boolean`);
|
||||
return null;
|
||||
}
|
||||
const { enabledByEnv } = value;
|
||||
if (enabledByEnv === undefined) return { recommended, consent };
|
||||
if (
|
||||
!Array.isArray(enabledByEnv) ||
|
||||
enabledByEnv.length === 0 ||
|
||||
!enabledByEnv.every(
|
||||
(name) => typeof name === "string" && ENV_NAME.test(name),
|
||||
)
|
||||
) {
|
||||
problems.push(
|
||||
`${id} onboarding.enabledByEnv must be a list of environment variable names`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return { recommended, consent, enabledByEnv };
|
||||
}
|
||||
|
||||
function parseBundledPlugins(raw) {
|
||||
const problems = [];
|
||||
const list = raw && Array.isArray(raw.plugins) ? raw.plugins : null;
|
||||
@@ -54,12 +108,16 @@ function parseBundledPlugins(raw) {
|
||||
problems.push(`${entry.id} needs a lowercase hex sha256`);
|
||||
continue;
|
||||
}
|
||||
const before = problems.length;
|
||||
const onboarding = parseOnboarding(entry.onboarding, entry.id, problems);
|
||||
if (problems.length > before) continue;
|
||||
plugins.push({
|
||||
id: entry.id,
|
||||
source: "tmxplug",
|
||||
url: hasUrl ? entry.url : null,
|
||||
path: hasPath ? entry.path : null,
|
||||
sha256: entry.sha256,
|
||||
onboarding,
|
||||
});
|
||||
} else {
|
||||
problems.push(`${entry.id} has an unknown source "${entry.source}"`);
|
||||
@@ -153,21 +211,39 @@ async function extractArtifact(buffer, outDir, id) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pins the newest registry version of each wanted plugin. `ids` is the list
|
||||
* to pin, or null for every plugin in the index.
|
||||
* What dist/plugins/bundled-index.json holds: the onboarding defaults of each
|
||||
* staged plugin, read by the backend when it seeds a fresh install.
|
||||
*/
|
||||
function pinsFromIndex(index, ids) {
|
||||
function buildBundledIndex(plugins, stagedIds) {
|
||||
const staged = new Set(stagedIds);
|
||||
const out = {};
|
||||
for (const plugin of plugins) {
|
||||
if (!staged.has(plugin.id) || !plugin.onboarding) continue;
|
||||
out[plugin.id] = { onboarding: plugin.onboarding };
|
||||
}
|
||||
return { version: 1, plugins: out };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function pinsFromIndex(index, ids, current = []) {
|
||||
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]));
|
||||
const wanted = ids ?? plugins.map((plugin) => plugin.id);
|
||||
return [...wanted].sort().map((id) => {
|
||||
const latest = byId.get(id)?.versions?.[0];
|
||||
if (!latest) throw new Error(`${id} is not in the registry index`);
|
||||
const onboarding = currentById.get(id)?.onboarding;
|
||||
return {
|
||||
id,
|
||||
source: "tmxplug",
|
||||
url: latest.url,
|
||||
sha256: latest.sha256,
|
||||
...(onboarding ? { onboarding } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -273,6 +349,7 @@ module.exports = {
|
||||
rebuildLocalPlugins,
|
||||
copyLocalBuild,
|
||||
parseBundledPlugins,
|
||||
buildBundledIndex,
|
||||
pinsFromIndex,
|
||||
loadBundledPlugins,
|
||||
checkSha256,
|
||||
|
||||
@@ -28,8 +28,9 @@ async function main() {
|
||||
}
|
||||
const index = await response.json();
|
||||
|
||||
const ids = all ? null : loadBundledPlugins(root).map((plugin) => plugin.id);
|
||||
const plugins = pinsFromIndex(index, ids);
|
||||
const current = loadBundledPlugins(root);
|
||||
const ids = all ? null : current.map((plugin) => plugin.id);
|
||||
const plugins = pinsFromIndex(index, ids, current);
|
||||
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