feat: full screen onboarding with a plugin picker and per step versions

This commit is contained in:
LukeGus committed 2026-10-06 17:44:25 -05:00
1 parent 1e74ce4827
commit b9dd3488a5
59 files changed
+5413 -643

No files matched your search

+8
View File
@@ -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(", ")}`,
);
+91
View File
@@ -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",
+80 -3
View File
@@ -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,
+3 -2
View File
@@ -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`,