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

+129
View File
@@ -18,6 +18,10 @@ import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { getPluginPublicHttpRoutes } from "../../plugins/http.js";
import { getPluginPublicWsRoutes } from "../../plugins/ws.js";
import { PluginManageError } from "../../plugins/manage.js";
import {
isPluginChoice,
type PluginChoice,
} from "../../../types/plugin-onboarding.js";
import {
findField,
getAllSettings,
@@ -398,6 +402,131 @@ async function refuseOnLinkedDesktop(res: Response): Promise<boolean> {
return true;
}
/**
* @openapi
* /plugins/onboarding:
* get:
* summary: List plugins for the onboarding plugin picker
* description: Every installed plugin with its category, hard dependencies, current state and the onboarding defaults from the bundled index (recommended, consent). pending is true on a fresh install until an admin confirms the picker. managedByLinkedServer is true on a desktop linked to a server, where the picker is not shown.
* tags:
* - Plugins
* responses:
* 200:
* description: The picker's plugin list.
* 403:
* description: The caller lacks admin.plugins.manage.
* 500:
* description: The list could not be read.
*/
router.get(
"/onboarding",
authenticateJWT,
requireManagePlugins,
async (_req: Request, res: Response) => {
try {
const { describeOnboardingPlugins } =
await import("../../plugins/onboarding.js");
res.json(await describeOnboardingPlugins());
} catch (error) {
sendManageError(
res,
error,
"Failed to list plugins for onboarding",
"plugin_onboarding_list",
);
}
},
);
/**
* @openapi
* /plugins/onboarding/apply:
* post:
* summary: Apply the onboarding plugin picker
* description: Sets every listed plugin to enabled, disabled or removed in one pass. Choices are first raised so that every kept plugin still has its hard dependencies (the adjustments list says which and why). Enabling runs dependencies first, disabling and removing run dependents first, and one plugin failing does not stop the rest. Removing a bundled plugin deletes it and its data; it can be reinstalled from the registry later. Clears the fresh-install pending flag. With dryRun nothing changes and the response lists what would.
* tags:
* - Plugins
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [choices]
* properties:
* choices:
* type: object
* additionalProperties:
* type: string
* enum: [enabled, disabled, remove]
* dryRun:
* type: boolean
* responses:
* 200:
* description: What was enabled, disabled, removed or failed.
* 400:
* description: The choices are not valid.
* 403:
* description: The caller lacks admin.plugins.manage.
* 409:
* description: Plugins on a linked desktop are managed by the server.
* 500:
* description: The choices could not be applied.
*/
router.post(
"/onboarding/apply",
authenticateJWT,
requireManagePlugins,
async (req: Request, res: Response) => {
const { choices, dryRun } = req.body ?? {};
if (
!choices ||
typeof choices !== "object" ||
Array.isArray(choices) ||
!Object.values(choices).every(isPluginChoice) ||
(dryRun !== undefined && typeof dryRun !== "boolean")
) {
res.status(400).json({ error: "Invalid plugin choices" });
return;
}
if (!dryRun && (await refuseOnLinkedDesktop(res))) return;
try {
const { applyOnboardingChoices } =
await import("../../plugins/onboarding.js");
const result = await applyOnboardingChoices(
choices as Record<string, PluginChoice>,
{ dryRun: dryRun === true },
);
if (!dryRun) {
for (const id of result.enabled) {
await auditPluginAction(req, "enable_plugin", id, {
via: "onboarding",
});
}
for (const id of result.disabled) {
await auditPluginAction(req, "disable_plugin", id, {
via: "onboarding",
});
}
for (const id of result.removed) {
await auditPluginAction(req, "uninstall_plugin", id, {
via: "onboarding",
});
}
}
res.json(result);
} catch (error) {
sendManageError(
res,
error,
"Failed to apply plugin choices",
"plugin_onboarding_apply",
);
}
},
);
/**
* @openapi
* /plugins/{id}/state:
+32 -12
View File
@@ -8,10 +8,11 @@ import {
} from "../repositories/factory.js";
import {
defaultUiPreferences,
sanitizeOnboarding,
sanitizeUiPreferences,
UI_ONBOARDING_VERSION,
type UiPreferences,
} from "../../../types/ui-preferences.js";
import { LEGACY_SEEN, mergeSeen } from "../../../types/onboarding.js";
const router = express.Router();
const authManager = AuthManager.getInstance();
@@ -23,8 +24,8 @@ const NEW_ACCOUNT_WINDOW_MS = 24 * 60 * 60 * 1000;
/**
* Existing users must never be ambushed by onboarding. A user who never
* finished it and registered more than a day ago predates this feature, so
* they are handed a completed onboarding state. This also repairs rows that an
* earlier PUT wrote with the not-completed default.
* they count as having seen every core step, and the client silently marks
* the plugin steps they have now as seen too (baselinePending).
*/
export function withOnboardingBackfill(
preferences: UiPreferences,
@@ -35,13 +36,21 @@ export function withOnboardingBackfill(
Number.isFinite(registeredMs) &&
Date.now() - registeredMs < NEW_ACCOUNT_WINDOW_MS;
if (isNewAccount || preferences.onboarding.completedAt) return preferences;
const { onboarding } = preferences;
if (
isNewAccount ||
onboarding.completedAt ||
Object.keys(onboarding.seen).length > 0
) {
return preferences;
}
return {
...preferences,
onboarding: {
...preferences.onboarding,
completedVersion: UI_ONBOARDING_VERSION,
...onboarding,
seen: { ...LEGACY_SEEN },
baselinePending: true,
},
};
}
@@ -51,7 +60,7 @@ export function withOnboardingBackfill(
* /ui-preferences:
* get:
* summary: Get the UI complexity preferences for the current user
* description: Returns the current user's interface preset (simple, balanced, advanced or custom), their per-area overrides, and their onboarding state. A first-time GET returns defaults without writing a row; a row is only created once the user actually changes something via PUT. Users who registered before this feature existed are returned an already-completed onboarding state so they are never shown the first-run flow.
* description: Returns the current user's interface preset (simple, balanced, advanced or custom), their per-area overrides, and their onboarding state (which onboarding steps they have seen, by version). A first-time GET returns defaults without writing a row; a row is only created once the user actually changes something via PUT. Users who registered more than a day ago and never finished onboarding are returned as having seen every core step, so they are never shown the first-run flow.
* tags:
* - UI Preferences
* responses:
@@ -89,7 +98,7 @@ router.get("/", authenticateJWT, async (req: Request, res: Response) => {
* /ui-preferences:
* put:
* summary: Update the UI complexity preferences for the current user
* description: Persists the current user's interface preset, per-area overrides and onboarding state as a single JSON document. Overrides are merged two levels deep, so a request only has to send the keys it changes. A null at a key clears that single override; a null at an area clears every override for that area; a null at overrides clears all of them.
* description: Persists the current user's interface preset, per-area overrides and onboarding state as a single JSON document. Overrides are merged two levels deep, so a request only has to send the keys it changes. A null at a key clears that single override; a null at an area clears every override for that area; a null at overrides clears all of them. Onboarding seen versions are merged per step and never go down, so two open tabs cannot undo each other.
* tags:
* - UI Preferences
* requestBody:
@@ -164,10 +173,7 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
// Must come after the body spread, or a raw overrides payload would
// clobber the merge above.
overrides: mergedOverrides,
onboarding: {
...base.onboarding,
...((body.onboarding as Record<string, unknown>) ?? {}),
},
onboarding: mergeOnboarding(base, body.onboarding),
});
await repository.upsert(userId, JSON.stringify(merged));
@@ -182,4 +188,18 @@ router.put("/", authenticateJWT, async (req: Request, res: Response) => {
}
});
function mergeOnboarding(base: UiPreferences, patch: unknown) {
if (!patch || typeof patch !== "object") return base.onboarding;
const incoming = sanitizeOnboarding({
...base.onboarding,
...(patch as Record<string, unknown>),
seen: (patch as Record<string, unknown>).seen ?? {},
});
return {
...incoming,
seen: mergeSeen(base.onboarding.seen, incoming.seen),
completedAt: incoming.completedAt ?? base.onboarding.completedAt,
};
}
export default router;
+101
View File
@@ -0,0 +1,101 @@
/**
* The onboarding defaults of the bundled plugins, from
* dist/plugins/bundled-index.json (written by scripts/build-plugins.cjs from
* docker/bundled-plugins.json). Core never names plugins, so which ones a
* fresh install keeps lives in that file and not here.
*/
import fs from "node:fs";
import path from "node:path";
import { getBundledPluginsDir } from "./paths.js";
export const BUNDLED_INDEX_FILE = "bundled-index.json";
export interface OnboardingDefaults {
recommended: boolean;
consent: boolean;
}
type Recommended = boolean | "desktop";
interface BundledIndex {
plugins: Record<
string,
{
onboarding?: {
recommended?: Recommended;
consent?: boolean;
enabledByEnv?: string[];
};
}
>;
}
let cached: BundledIndex | null | undefined;
export function readBundledIndex(): BundledIndex | null {
if (cached !== undefined) return cached;
try {
const raw = JSON.parse(
fs.readFileSync(
path.join(getBundledPluginsDir(), BUNDLED_INDEX_FILE),
"utf8",
),
);
cached =
raw && typeof raw.plugins === "object" && raw.plugins
? { plugins: raw.plugins }
: null;
} catch {
cached = null;
}
return cached;
}
export function resetBundledIndexCache(): void {
cached = undefined;
}
function isDesktop(): boolean {
return process.env.ELECTRON_EMBEDDED === "true";
}
/**
* Without an index (a build that skipped build-plugins) every plugin counts
* as recommended, which is how installs behaved before the picker. A plugin
* whose enabledByEnv variables are set counts as recommended too, so a server
* set up for it through the environment works from the first boot.
*/
export function onboardingDefaults(pluginId: string): OnboardingDefaults {
const index = readBundledIndex();
if (!index) return { recommended: true, consent: false };
const entry = index.plugins[pluginId]?.onboarding;
const recommended = entry?.recommended;
const configured = (entry?.enabledByEnv ?? []).some(
(name) => typeof name === "string" && !!process.env[name]?.trim(),
);
return {
recommended:
recommended === true ||
(recommended === "desktop" && isDesktop()) ||
configured,
consent: entry?.consent === true,
};
}
/**
* The state a newly seen plugin's row starts in. Bundled plugins start
* enabled, except on a fresh install, where only recommended ones do and
* consent plugins wait for the admin. A plugin dropped into the data
* directory always starts disabled.
*/
export function initialPluginState(
pluginId: string,
source: "bundled" | "user",
freshInstall: boolean,
): "enabled" | "disabled" {
if (source !== "bundled") return "disabled";
if (!freshInstall) return "enabled";
const { recommended, consent } = onboardingDefaults(pluginId);
return recommended && !consent ? "enabled" : "disabled";
}
+24 -6
View File
@@ -126,11 +126,21 @@ async function handlePluginFailed(plugin: LoadedPlugin): Promise<void> {
* `state` is only ever set on insert. Once the row exists whatever the user
* chose wins, because re-enabling a plugin they disabled on every restart
* would be a bug rather than a default.
*
* A fresh install (no users, no plugin rows) is different: only the bundled
* plugins marked recommended start enabled, and the first admin picks the
* rest in onboarding. Consent plugins wait for that choice too, even when
* the picker checks them by default.
*/
async function seedPlugins(loaded: LoadedPlugin[]): Promise<void> {
async function seedPlugins(
loaded: LoadedPlugin[],
freshInstall: boolean,
): Promise<boolean> {
const { createCurrentPluginRepository } =
await import("../database/repositories/factory.js");
const repository = createCurrentPluginRepository();
const fresh = freshInstall && (await repository.listAll()).length === 0;
const { initialPluginState } = await import("./bundled-index.js");
for (const plugin of loaded) {
const manifestJson = JSON.stringify(plugin.manifest);
@@ -155,7 +165,7 @@ async function seedPlugins(loaded: LoadedPlugin[]): Promise<void> {
version: plugin.manifest.version,
tier: plugin.source === "bundled" ? "bundled" : "community",
source: plugin.source,
state: plugin.source === "bundled" ? "enabled" : "disabled",
state: initialPluginState(plugin.id, plugin.source, fresh),
manifestJson,
});
@@ -163,6 +173,8 @@ async function seedPlugins(loaded: LoadedPlugin[]): Promise<void> {
operation: "plugin_seed",
});
}
return fresh;
}
/**
@@ -212,7 +224,9 @@ async function syncCapabilityGrants(loaded: LoadedPlugin[]): Promise<void> {
* Loads every plugin on disk and activates the ones marked enabled, in
* dependency order. Called once from the backend start-up sequence.
*/
export async function initializePlugins(): Promise<LoadedPlugin[]> {
export async function initializePlugins(
options: { freshInstall?: boolean } = {},
): Promise<LoadedPlugin[]> {
const { loader: pluginLoader } = getPluginRuntime();
const { applyStoredPluginChoices } = await import("./manage.js");
@@ -221,8 +235,12 @@ export async function initializePlugins(): Promise<LoadedPlugin[]> {
const loaded = await pluginLoader.loadAll();
if (loaded.length === 0) return [];
await seedPlugins(loaded);
const fresh = await seedPlugins(loaded, options.freshInstall === true);
await syncCapabilityGrants(loaded);
if (loaded.some((plugin) => plugin.source === "bundled")) {
const { offerPluginSetup } = await import("./onboarding.js");
await offerPluginSetup(fresh);
}
const { createCurrentPluginRepository } =
await import("../database/repositories/factory.js");
@@ -565,7 +583,7 @@ export async function installPluginArtifact(
file,
pluginLoader.bundledIds(),
);
await seedPlugins([plugin]);
await seedPlugins([plugin], false);
await syncCapabilityGrants([plugin]);
return plugin;
}
@@ -574,7 +592,7 @@ export async function installPluginArtifact(
export async function registerLoadedPlugins(
loaded: LoadedPlugin[],
): Promise<void> {
await seedPlugins(loaded);
await seedPlugins(loaded, false);
await syncCapabilityGrants(loaded);
}
+99 -88
View File
@@ -65,7 +65,7 @@ export class PluginManageError extends Error {
let queue: Promise<unknown> = Promise.resolve();
/** One change at a time: dependencies make concurrent changes unsafe. */
function serialized<T>(fn: () => Promise<T>): Promise<T> {
export function serialized<T>(fn: () => Promise<T>): Promise<T> {
const run = queue.then(fn, fn);
queue = run.catch(() => {});
return run;
@@ -543,41 +543,47 @@ export function setPluginState(
pluginId: string,
enabled: boolean,
): Promise<StateChangePlan & { state: string }> {
return serialized(async () => {
const { loader } = getPluginRuntime();
if (!loader.get(pluginId)) {
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
}
const plan = await planStateChange(pluginId, enabled);
return serialized(() => setPluginStateUnlocked(pluginId, enabled));
}
if (enabled) {
if (plan.missing.length > 0) {
throw new PluginManageError(
"Some dependencies are not installed",
409,
"MISSING_DEPENDENCIES",
{ missing: plan.missing },
/** setPluginState for a caller already inside serialized(). */
export async function setPluginStateUnlocked(
pluginId: string,
enabled: boolean,
): Promise<StateChangePlan & { state: string }> {
const { loader } = getPluginRuntime();
if (!loader.get(pluginId)) {
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
}
const plan = await planStateChange(pluginId, enabled);
if (enabled) {
if (plan.missing.length > 0) {
throw new PluginManageError(
"Some dependencies are not installed",
409,
"MISSING_DEPENDENCIES",
{ missing: plan.missing },
);
}
for (const id of plan.enable) {
if ((await startAndRecord(id)) === "failed") {
await recordState(
pluginId,
"blocked",
`requires plugin "${id}", which could not start`,
);
return { ...plan, state: "blocked" };
}
for (const id of plan.enable) {
if ((await startAndRecord(id)) === "failed") {
await recordState(
pluginId,
"blocked",
`requires plugin "${id}", which could not start`,
);
return { ...plan, state: "blocked" };
}
}
return { ...plan, state: await startAndRecord(pluginId) };
}
return { ...plan, state: await startAndRecord(pluginId) };
}
for (const id of [...plan.disable, pluginId]) {
await recordState(id, "disabled", null);
await deactivatePlugin(id);
}
return { ...plan, state: "stopped" };
});
for (const id of [...plan.disable, pluginId]) {
await recordState(id, "disabled", null);
await deactivatePlugin(id);
}
return { ...plan, state: "stopped" };
}
export interface InstallOptions {
@@ -858,68 +864,73 @@ export function updateAllPlugins(options: {
export function uninstallPlugin(
pluginId: string,
): Promise<{ id: string; removed: { tables: string[]; kvKeys: number } }> {
return serialized(async () => {
if (!ID_PATTERN.test(pluginId)) {
throw new PluginManageError("Invalid plugin id", 400, "INVALID_ID");
}
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
const record = await repository.findById(pluginId);
const { loader } = getPluginRuntime();
const live = loader.get(pluginId);
if (!record && !live) {
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
}
const shipped = loader.bundledIds().has(pluginId);
return serialized(() => uninstallPluginUnlocked(pluginId));
}
const dependents = dependentChain(pluginId, await enabledIds());
if (dependents.length > 0) {
throw new PluginManageError(
"Other plugins depend on this one",
409,
"HAS_DEPENDENTS",
{ dependents },
);
}
/** uninstallPlugin for a caller already inside serialized(). */
export async function uninstallPluginUnlocked(
pluginId: string,
): Promise<{ id: string; removed: { tables: string[]; kvKeys: number } }> {
if (!ID_PATTERN.test(pluginId)) {
throw new PluginManageError("Invalid plugin id", 400, "INVALID_ID");
}
const { createCurrentPluginRepository } = await repos();
const repository = createCurrentPluginRepository();
const record = await repository.findById(pluginId);
const { loader } = getPluginRuntime();
const live = loader.get(pluginId);
if (!record && !live) {
throw new PluginManageError("Plugin not found", 404, "NOT_FOUND");
}
const shipped = loader.bundledIds().has(pluginId);
if (live) await deactivatePlugin(pluginId);
const dependents = dependentChain(pluginId, await enabledIds());
if (dependents.length > 0) {
throw new PluginManageError(
"Other plugins depend on this one",
409,
"HAS_DEPENDENTS",
{ dependents },
);
}
const { removePluginData } = await import("./data.js");
const removed = await removePluginData(pluginId, {
knownPluginIds: loader.list().map((plugin) => plugin.id),
});
if (live) await deactivatePlugin(pluginId);
if (live) loader.forget(pluginId);
await removeArtifactFiles(pluginId, live?.artifact);
await removeUserFolder(pluginId, live);
await fs.promises.rm(path.join(getUnpackedPluginsDir(), pluginId), {
recursive: true,
force: true,
});
await fs.promises.rm(getPluginDataDir(pluginId), {
recursive: true,
force: true,
});
await repository.delete(pluginId);
await applyPin(pluginId, null);
// Ignored even when the files are gone: a recreated container brings
// the image's copy back, and it must not load in place of a reinstall.
if (shipped) {
const uninstalled = await readUninstalled();
uninstalled.add(pluginId);
await writeUninstalled(uninstalled);
await removeShippedCopy(pluginId);
}
pluginLogger.info(`Uninstalled plugin ${pluginId}`, {
operation: "plugin_uninstall",
});
return {
id: pluginId,
removed: { tables: removed.tables, kvKeys: removed.kvKeys },
};
const { removePluginData } = await import("./data.js");
const removed = await removePluginData(pluginId, {
knownPluginIds: loader.list().map((plugin) => plugin.id),
});
if (live) loader.forget(pluginId);
await removeArtifactFiles(pluginId, live?.artifact);
await removeUserFolder(pluginId, live);
await fs.promises.rm(path.join(getUnpackedPluginsDir(), pluginId), {
recursive: true,
force: true,
});
await fs.promises.rm(getPluginDataDir(pluginId), {
recursive: true,
force: true,
});
await repository.delete(pluginId);
await applyPin(pluginId, null);
// Ignored even when the files are gone: a recreated container brings
// the image's copy back, and it must not load in place of a reinstall.
if (shipped) {
const uninstalled = await readUninstalled();
uninstalled.add(pluginId);
await writeUninstalled(uninstalled);
await removeShippedCopy(pluginId);
}
pluginLogger.info(`Uninstalled plugin ${pluginId}`, {
operation: "plugin_uninstall",
});
return {
id: pluginId,
removed: { tables: removed.tables, kvKeys: removed.kvKeys },
};
}
export function setPluginOptions(
+242
View File
@@ -0,0 +1,242 @@
/**
* The onboarding plugin picker: what a fresh install's first admin sees and
* how their choices are applied in one go.
*/
import { pluginLogger } from "../utils/logger.js";
import { getPluginRuntime } from "./index.js";
import { onboardingDefaults } from "./bundled-index.js";
import {
isLinkedDesktop,
serialized,
setPluginStateUnlocked,
uninstallPluginUnlocked,
} from "./manage.js";
import {
applyOrder,
resolvePluginChoices,
type ChoiceAdjustment,
type ChoicePlugin,
type PluginChoice,
} from "../../types/plugin-onboarding.js";
/**
* Set until an admin confirms the picker: "fresh" on a new install, "upgrade"
* on one that existed before the picker (2.9 and earlier).
*/
export const PLUGIN_SETUP_PENDING_KEY = "onboarding_plugin_setup_pending";
/** Set once the picker has been offered, so it is offered only once. */
export const PLUGIN_SETUP_OFFERED_KEY = "onboarding_plugin_setup_offered";
export type PluginSetupReason = "fresh" | "upgrade";
export interface OnboardingPluginInfo {
id: string;
name: string;
description: string;
icon: string | null;
category: string;
version: string;
source: "bundled" | "user";
state: string;
dependencies: string[];
recommended: boolean;
consent: boolean;
}
export interface OnboardingPluginList {
pending: boolean;
/** Why the picker is pending. On an upgrade it starts from what is on. */
reason: PluginSetupReason | null;
managedByLinkedServer: boolean;
plugins: OnboardingPluginInfo[];
}
export interface OnboardingApplyResult {
resolved: Record<string, PluginChoice>;
adjustments: ChoiceAdjustment[];
enabled: string[];
disabled: string[];
removed: string[];
failed: { id: string; error: string; code?: string }[];
}
async function repos() {
return import("../database/repositories/factory.js");
}
export async function pluginSetupReason(): Promise<PluginSetupReason | null> {
const { createCurrentSettingsRepository } = await repos();
const value = await createCurrentSettingsRepository().get(
PLUGIN_SETUP_PENDING_KEY,
);
if (value === "fresh" || value === "1") return "fresh";
if (value === "upgrade") return "upgrade";
return null;
}
export async function isPluginSetupPending(): Promise<boolean> {
return (await pluginSetupReason()) !== null;
}
async function clearPluginSetupPending(): Promise<void> {
const { createCurrentSettingsRepository } = await repos();
const settings = createCurrentSettingsRepository();
await settings.delete(PLUGIN_SETUP_PENDING_KEY);
await settings.set(PLUGIN_SETUP_OFFERED_KEY, "1");
}
/**
* Called once at boot. Every install is offered the picker once: a fresh
* one on its first admin's first run, an upgraded one (which never saw it)
* the next time an admin signs in.
*/
export async function offerPluginSetup(fresh: boolean): Promise<void> {
const { createCurrentSettingsRepository } = await repos();
const settings = createCurrentSettingsRepository();
if (await settings.get(PLUGIN_SETUP_OFFERED_KEY)) return;
await settings.set(PLUGIN_SETUP_PENDING_KEY, fresh ? "fresh" : "upgrade");
await settings.set(PLUGIN_SETUP_OFFERED_KEY, "1");
}
function choicePlugins(): ChoicePlugin[] {
return getPluginRuntime()
.loader.list()
.map((plugin) => ({
id: plugin.id,
dependencies: Object.keys(plugin.manifest.dependencies ?? {}),
}));
}
export async function describeOnboardingPlugins(): Promise<OnboardingPluginList> {
const { createCurrentPluginRepository } = await repos();
const states = new Map(
(await createCurrentPluginRepository().listAll()).map((record) => [
record.id,
record.state,
]),
);
const plugins = getPluginRuntime()
.loader.list()
.map((plugin): OnboardingPluginInfo => {
const defaults =
plugin.source === "bundled"
? onboardingDefaults(plugin.id)
: { recommended: false, consent: false };
return {
id: plugin.id,
name: plugin.manifest.name,
description: plugin.manifest.description,
icon: plugin.manifest.icon ?? null,
category: plugin.manifest.category,
version: plugin.manifest.version,
source: plugin.source,
state: states.get(plugin.id) ?? "disabled",
dependencies: Object.keys(plugin.manifest.dependencies ?? {}),
...defaults,
};
})
.sort((a, b) => a.name.localeCompare(b.name));
const reason = await pluginSetupReason();
return {
pending: reason !== null,
reason,
managedByLinkedServer: await isLinkedDesktop(),
plugins,
};
}
/**
* Applies the picker in one serialized pass: enable (dependencies first),
* then disable and remove (dependents first). One plugin failing does not
* stop the rest. Ids that are not installed are ignored.
*/
export function applyOnboardingChoices(
choices: Record<string, PluginChoice>,
options: { dryRun?: boolean } = {},
): Promise<OnboardingApplyResult> {
return serialized(async () => {
const plugins = choicePlugins();
const installed = new Set(plugins.map((plugin) => plugin.id));
const wanted: Record<string, PluginChoice> = {};
for (const [id, choice] of Object.entries(choices)) {
if (installed.has(id)) wanted[id] = choice;
}
const { choices: resolved, adjustments } = resolvePluginChoices(
plugins,
wanted,
);
const result: OnboardingApplyResult = {
resolved,
adjustments,
enabled: [],
disabled: [],
removed: [],
failed: [],
};
const { createCurrentPluginRepository } = await repos();
const states = new Map(
(await createCurrentPluginRepository().listAll()).map((record) => [
record.id,
record.state,
]),
);
const order = applyOrder(plugins, resolved);
const enable = order.enable.filter((id) => states.get(id) !== "enabled");
const disable = order.disable.filter((id) => states.get(id) === "enabled");
if (options.dryRun) {
return {
...result,
enabled: enable,
disabled: disable,
removed: order.remove,
};
}
const attempt = async (
id: string,
run: () => Promise<unknown>,
done: string[],
) => {
try {
const outcome = (await run()) as { state?: string } | undefined;
if (outcome?.state === "failed" || outcome?.state === "blocked") {
result.failed.push({ id, error: outcome.state });
return;
}
done.push(id);
} catch (error) {
result.failed.push({
id,
error: error instanceof Error ? error.message : String(error),
code: (error as { code?: string })?.code,
});
}
};
for (const id of enable) {
await attempt(id, () => setPluginStateUnlocked(id, true), result.enabled);
}
for (const id of disable) {
await attempt(
id,
() => setPluginStateUnlocked(id, false),
result.disabled,
);
}
for (const id of order.remove) {
await attempt(id, () => uninstallPluginUnlocked(id), result.removed);
}
await clearPluginSetupPending();
pluginLogger.info(
`Onboarding plugin choices applied: ${result.enabled.length} enabled, ${result.disabled.length} disabled, ${result.removed.length} removed, ${result.failed.length} failed`,
{ operation: "plugin_onboarding" },
);
return result;
});
}
+7 -1
View File
@@ -204,6 +204,12 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
await import("./hosts/status/host-status-service.js");
hostStatusService.start();
// Counted before the desktop user is provisioned, so a first desktop
// run counts as a fresh install too.
const { createCurrentUserRepository } =
await import("./database/repositories/factory.js");
const freshInstall = (await createCurrentUserRepository().countAll()) === 0;
if (process.env.ELECTRON_EMBEDDED === "true") {
await provisionLocalDesktopUserIfNeeded();
}
@@ -238,7 +244,7 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
// fails to load must not stop the backend, so this never rejects.
try {
const { initializePlugins } = await import("./plugins/index.js");
const loaded = await initializePlugins();
const loaded = await initializePlugins({ freshInstall });
if (loaded.length > 0) {
systemLogger.info(`Loaded ${loaded.length} plugin(s)`, {
operation: "plugin_init",
@@ -192,6 +192,13 @@ vi.mock("../../../utils/audit-logger.js", () => ({
getRequestMeta: () => ({ ipAddress: "127.0.0.1", userAgent: "test" }),
}));
const onboardingMock = vi.hoisted(() => ({
describeOnboardingPlugins: vi.fn(),
applyOnboardingChoices: vi.fn(),
}));
vi.mock("../../../plugins/onboarding.js", () => onboardingMock);
const pluginRoutes = (await import("../../../database/routes/plugins.js"))
.default;
@@ -614,6 +621,87 @@ describe("plugins route", () => {
expect(state.grants).toHaveLength(1);
});
});
describe("onboarding plugin picker", () => {
const post = (body: unknown, user = "admin-1") =>
fetch(`${baseUrl}/plugins/onboarding/apply`, {
method: "POST",
headers: { "content-type": "application/json", "x-test-user-id": user },
body: JSON.stringify(body),
});
beforeEach(() => {
onboardingMock.describeOnboardingPlugins.mockReset();
onboardingMock.applyOnboardingChoices.mockReset();
manageMock.isLinkedDesktop.mockResolvedValue(false);
auditMock.logAudit.mockClear();
});
it("lists plugins for a manager only", async () => {
onboardingMock.describeOnboardingPlugins.mockResolvedValue({
pending: true,
managedByLinkedServer: false,
plugins: [],
});
const ok = await fetch(`${baseUrl}/plugins/onboarding`, {
headers: { "x-test-user-id": "admin-1" },
});
expect(ok.status).toBe(200);
expect(await ok.json()).toMatchObject({ pending: true });
const denied = await fetch(`${baseUrl}/plugins/onboarding`, {
headers: { "x-test-user-id": "user-2" },
});
expect(denied.status).toBe(403);
});
it("applies choices and audits each change", async () => {
onboardingMock.applyOnboardingChoices.mockResolvedValue({
resolved: {},
adjustments: [],
enabled: ["a"],
disabled: ["b"],
removed: ["c"],
failed: [],
});
const res = await post({
choices: { a: "enabled", b: "disabled", c: "remove" },
});
expect(res.status).toBe(200);
expect(onboardingMock.applyOnboardingChoices).toHaveBeenCalledWith(
{ a: "enabled", b: "disabled", c: "remove" },
{ dryRun: false },
);
const actions = auditMock.logAudit.mock.calls.map(
(call) => (call as unknown as [{ action: string }])[0].action,
);
expect(actions).toEqual([
"enable_plugin",
"disable_plugin",
"uninstall_plugin",
]);
});
it("rejects bad choices", async () => {
expect((await post({})).status).toBe(400);
expect((await post({ choices: ["a"] })).status).toBe(400);
expect((await post({ choices: { a: "delete" } })).status).toBe(400);
expect(
(await post({ choices: { a: "enabled" }, dryRun: "yes" })).status,
).toBe(400);
expect(onboardingMock.applyOnboardingChoices).not.toHaveBeenCalled();
});
it("refuses on a linked desktop and for non-managers", async () => {
manageMock.isLinkedDesktop.mockResolvedValue(true);
expect((await post({ choices: { a: "enabled" } })).status).toBe(409);
manageMock.isLinkedDesktop.mockResolvedValue(false);
expect((await post({ choices: { a: "enabled" } }, "user-2")).status).toBe(
403,
);
expect(onboardingMock.applyOnboardingChoices).not.toHaveBeenCalled();
});
});
describe("install, update, uninstall", () => {
const post = (path: string, body: unknown = {}, user = "admin-1") =>
fetch(`${baseUrl}${path}`, {
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import http from "node:http";
import type { AddressInfo } from "node:net";
import express from "express";
import { UI_ONBOARDING_VERSION } from "../../../../types/ui-preferences.js";
import { LEGACY_SEEN } from "../../../../types/onboarding.js";
const state = vi.hoisted(() => ({
row: null as { data: string } | null,
@@ -80,18 +80,16 @@ afterEach(async () => {
describe("ui-preferences onboarding backfill", () => {
it("treats an old account with no row as onboarded", async () => {
expect((await get()).onboarding.completedVersion).toBe(
UI_ONBOARDING_VERSION,
);
const onboarding = (await get()).onboarding;
expect(onboarding.seen).toEqual(LEGACY_SEEN);
expect(onboarding.baselinePending).toBe(true);
});
it("keeps an old account onboarded after its first write", async () => {
await put({ overrides: { "plugin:x": { a: 1 } } });
const stored = JSON.parse(state.row!.data);
expect(stored.onboarding.completedVersion).toBe(UI_ONBOARDING_VERSION);
expect((await get()).onboarding.completedVersion).toBe(
UI_ONBOARDING_VERSION,
);
expect(stored.onboarding.seen).toEqual(LEGACY_SEEN);
expect((await get()).onboarding.seen).toEqual(LEGACY_SEEN);
});
it("repairs a row an earlier write stored as not completed", async () => {
@@ -100,29 +98,61 @@ describe("ui-preferences onboarding backfill", () => {
onboarding: { completedVersion: 0, completedAt: null, skipped: false },
}),
};
expect((await get()).onboarding.completedVersion).toBe(
UI_ONBOARDING_VERSION,
);
expect((await get()).onboarding.seen).toEqual(LEGACY_SEEN);
});
it("migrates the old completedVersion shape", async () => {
state.registeredAt = NEW;
state.row = {
data: JSON.stringify({
onboarding: { completedVersion: 2, completedAt: NEW, skipped: false },
}),
};
const onboarding = (await get()).onboarding;
expect(onboarding.seen).toEqual(LEGACY_SEEN);
expect(onboarding.baselinePending).toBe(true);
});
it("still shows onboarding to a new account", async () => {
state.registeredAt = NEW;
await put({ preset: "simple" });
expect((await get()).onboarding.completedVersion).toBe(0);
expect((await get()).onboarding.seen).toEqual({});
});
});
it("does not hide a newer onboarding version from someone who finished an older one", async () => {
state.row = {
data: JSON.stringify({
onboarding: {
completedVersion: UI_ONBOARDING_VERSION - 1,
completedAt: OLD,
skipped: false,
},
}),
};
expect((await get()).onboarding.completedVersion).toBe(
UI_ONBOARDING_VERSION - 1,
);
describe("ui-preferences onboarding seen merge", () => {
beforeEach(() => {
state.registeredAt = NEW;
});
it("keeps the higher version per step", async () => {
await put({ onboarding: { seen: { welcome: 2, "rd:guacd": 1 } } });
await put({ onboarding: { seen: { welcome: 1, appearance: 1 } } });
expect((await get()).onboarding.seen).toEqual({
welcome: 2,
"rd:guacd": 1,
appearance: 1,
});
});
it("drops invalid seen entries", async () => {
await put({
onboarding: { seen: { ok: 1, zero: 0, text: "1", frac: 1.5 } },
});
expect((await get()).onboarding.seen).toEqual({ ok: 1 });
});
it("does not clear completedAt when a later write omits it", async () => {
await put({ onboarding: { completedAt: NEW, seen: { done: 1 } } });
await put({ onboarding: { seen: { "x:y": 1 } } });
expect((await get()).onboarding.completedAt).toBe(NEW);
});
it("clears baselinePending when told to", async () => {
state.registeredAt = OLD;
await put({ onboarding: { baselinePending: false, seen: { "x:y": 1 } } });
const onboarding = (await get()).onboarding;
expect(onboarding.baselinePending).toBe(false);
expect(onboarding.seen["x:y"]).toBe(1);
});
});
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
initialPluginState,
onboardingDefaults,
resetBundledIndexCache,
} from "../../plugins/bundled-index.js";
let dir: string;
function writeIndex(plugins: Record<string, unknown>) {
fs.writeFileSync(
path.join(dir, "bundled-index.json"),
JSON.stringify({ version: 1, plugins }),
);
resetBundledIndexCache();
}
beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-bundled-index-"));
process.env.TERMIX_BUNDLED_PLUGINS_DIR = dir;
resetBundledIndexCache();
});
afterEach(() => {
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
delete process.env.ELECTRON_EMBEDDED;
delete process.env.TEST_SSO_CLIENT;
fs.rmSync(dir, { recursive: true, force: true });
resetBundledIndexCache();
});
describe("onboardingDefaults", () => {
it("treats everything as recommended without an index", () => {
expect(onboardingDefaults("anything")).toEqual({
recommended: true,
consent: false,
});
});
it("reads recommended and consent from the index", () => {
writeIndex({
kept: { onboarding: { recommended: true, consent: false } },
ask: { onboarding: { recommended: true, consent: true } },
});
expect(onboardingDefaults("kept")).toEqual({
recommended: true,
consent: false,
});
expect(onboardingDefaults("ask").consent).toBe(true);
expect(onboardingDefaults("other")).toEqual({
recommended: false,
consent: false,
});
});
it("recommends desktop-only plugins in the desktop app only", () => {
writeIndex({ desk: { onboarding: { recommended: "desktop" } } });
expect(onboardingDefaults("desk").recommended).toBe(false);
process.env.ELECTRON_EMBEDDED = "true";
expect(onboardingDefaults("desk").recommended).toBe(true);
});
});
describe("enabledByEnv", () => {
it("recommends a plugin once its environment config is set", () => {
writeIndex({
sso: { onboarding: { enabledByEnv: ["TEST_SSO_CLIENT"] } },
});
expect(onboardingDefaults("sso").recommended).toBe(false);
expect(initialPluginState("sso", "bundled", true)).toBe("disabled");
process.env.TEST_SSO_CLIENT = " ";
expect(onboardingDefaults("sso").recommended).toBe(false);
process.env.TEST_SSO_CLIENT = "termix";
expect(onboardingDefaults("sso").recommended).toBe(true);
expect(initialPluginState("sso", "bundled", true)).toBe("enabled");
});
});
describe("initialPluginState", () => {
beforeEach(() => {
writeIndex({
kept: { onboarding: { recommended: true } },
ask: { onboarding: { recommended: true, consent: true } },
});
});
it("enables every bundled plugin on an existing install", () => {
expect(initialPluginState("other", "bundled", false)).toBe("enabled");
expect(initialPluginState("ask", "bundled", false)).toBe("enabled");
});
it("enables only recommended, non-consent plugins on a fresh install", () => {
expect(initialPluginState("kept", "bundled", true)).toBe("enabled");
expect(initialPluginState("ask", "bundled", true)).toBe("disabled");
expect(initialPluginState("other", "bundled", true)).toBe("disabled");
});
it("never enables a plugin dropped into the data directory", () => {
expect(initialPluginState("kept", "user", true)).toBe("disabled");
expect(initialPluginState("kept", "user", false)).toBe("disabled");
});
});
@@ -0,0 +1,284 @@
/**
* The onboarding plugin picker, against a real PluginLoader and fixture
* plugins on disk. The database is faked.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createFixturePlugin } from "./fixture-plugin.js";
import type { LoadedPlugin } from "../../plugins/loader.js";
const db = vi.hoisted(() => ({
rows: new Map<string, Record<string, unknown>>(),
settings: new Map<string, string>(),
}));
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
listAll: async () => [...db.rows.values()],
findById: async (id: string) => db.rows.get(id) ?? null,
update: async (id: string, input: Record<string, unknown>) => {
const row = db.rows.get(id);
if (!row) return null;
for (const [key, value] of Object.entries(input)) {
if (value !== undefined) row[key] = value;
}
return row;
},
delete: async (id: string) => db.rows.delete(id),
}),
createCurrentSettingsRepository: () => ({
get: async (key: string) => db.settings.get(key) ?? null,
set: async (key: string, value: string) => void db.settings.set(key, value),
delete: async (key: string) => void db.settings.delete(key),
}),
createCurrentPluginStorageRepository: () => ({
get: async () => null,
set: async () => {},
delete: async () => false,
listKeys: async () => [],
countKeys: async () => 0,
}),
}));
vi.mock("../../plugins/data.js", () => ({
migratePlugin: async () => [],
removePluginData: async () => ({ tables: [], kvKeys: 0, migrations: 0 }),
describePluginTables: async () => [],
registerTable: () => ({}),
forgetTables: () => {},
}));
vi.mock("../../plugins/permissions.js", () => ({
invalidatePluginPermissionCache: () => {},
}));
vi.mock("../../plugins/index.js", async () => {
const { PluginLoader } = await import("../../plugins/loader.js");
const loader = new PluginLoader();
return {
getPluginRuntime: () => ({ loader }),
activatePlugin: (id: string) => loader.activate(id),
deactivatePlugin: (id: string) => loader.deactivate(id),
registerLoadedPlugins: async (plugins: LoadedPlugin[]) => {
for (const plugin of plugins) {
db.rows.set(plugin.id, {
id: plugin.id,
name: plugin.manifest.name,
version: plugin.manifest.version,
source: plugin.source,
state: "enabled",
lastError: null,
pinnedVersion: null,
manifestJson: JSON.stringify(plugin.manifest),
});
}
},
unloadPlugin: async () => {},
};
});
const { getPluginRuntime, registerLoadedPlugins } =
await import("../../plugins/index.js");
const manage = await import("../../plugins/manage.js");
const onboarding = await import("../../plugins/onboarding.js");
const { resetBundledIndexCache } =
await import("../../plugins/bundled-index.js");
const roots: string[] = [];
let bundled: string;
let data: string;
function tempRoot(prefix: string): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
roots.push(root);
return root;
}
function writeIndex(plugins: Record<string, unknown>) {
fs.writeFileSync(
path.join(bundled, "bundled-index.json"),
JSON.stringify({ version: 1, plugins }),
);
resetBundledIndexCache();
}
async function chain() {
createFixturePlugin({ id: "base", root: bundled });
createFixturePlugin({
id: "addon",
root: bundled,
manifestOverrides: { dependencies: { base: "^1.0.0" } },
});
createFixturePlugin({ id: "extra", root: bundled });
const { loader } = getPluginRuntime();
await manage.applyStoredPluginChoices(loader);
const loaded = await loader.loadAll();
await registerLoadedPlugins(loaded);
for (const plugin of loaded) await loader.activate(plugin.id);
return loader;
}
beforeEach(() => {
bundled = tempRoot("termix-bundled-");
data = tempRoot("termix-data-");
process.env.TERMIX_BUNDLED_PLUGINS_DIR = bundled;
process.env.DATA_DIR = data;
db.rows.clear();
db.settings.clear();
resetBundledIndexCache();
});
afterEach(async () => {
const { loader } = getPluginRuntime();
await loader.shutdown();
for (const plugin of loader.list()) loader.forget(plugin.id);
delete process.env.TERMIX_BUNDLED_PLUGINS_DIR;
delete process.env.DATA_DIR;
while (roots.length) {
fs.rmSync(roots.pop()!, { recursive: true, force: true });
}
});
describe("offerPluginSetup", () => {
it("offers the picker once, as fresh or as an upgrade", async () => {
await onboarding.offerPluginSetup(false);
expect(db.settings.get(onboarding.PLUGIN_SETUP_PENDING_KEY)).toBe(
"upgrade",
);
expect(await onboarding.pluginSetupReason()).toBe("upgrade");
db.settings.delete(onboarding.PLUGIN_SETUP_PENDING_KEY);
await onboarding.offerPluginSetup(true);
expect(await onboarding.isPluginSetupPending()).toBe(false);
});
it("marks a fresh install", async () => {
await onboarding.offerPluginSetup(true);
expect(await onboarding.pluginSetupReason()).toBe("fresh");
});
it("is not offered again after the picker was applied", async () => {
await chain();
await onboarding.offerPluginSetup(false);
await onboarding.applyOnboardingChoices({});
db.settings.delete(onboarding.PLUGIN_SETUP_OFFERED_KEY);
await onboarding.applyOnboardingChoices({});
await onboarding.offerPluginSetup(false);
expect(await onboarding.isPluginSetupPending()).toBe(false);
});
});
describe("describeOnboardingPlugins", () => {
it("lists installed plugins with their defaults and the pending flag", async () => {
writeIndex({
base: { onboarding: { recommended: true } },
extra: { onboarding: { recommended: true, consent: true } },
});
await chain();
db.settings.set(onboarding.PLUGIN_SETUP_PENDING_KEY, "1");
const list = await onboarding.describeOnboardingPlugins();
expect(list.pending).toBe(true);
expect(list.reason).toBe("fresh");
expect(list.managedByLinkedServer).toBe(false);
const byId = Object.fromEntries(list.plugins.map((p) => [p.id, p]));
expect(byId.base).toMatchObject({
recommended: true,
consent: false,
source: "bundled",
state: "enabled",
});
expect(byId.addon).toMatchObject({
recommended: false,
dependencies: ["base"],
});
expect(byId.extra).toMatchObject({ recommended: true, consent: true });
});
});
describe("applyOnboardingChoices", () => {
it("enables, disables and removes, then clears the pending flag", async () => {
const loader = await chain();
db.rows.get("extra")!.state = "disabled";
await loader.deactivate("extra");
db.settings.set(onboarding.PLUGIN_SETUP_PENDING_KEY, "1");
const result = await onboarding.applyOnboardingChoices({
base: "disabled",
addon: "remove",
extra: "enabled",
});
expect(result.failed).toEqual([]);
expect(result.enabled).toEqual(["extra"]);
expect(result.disabled).toEqual(["base"]);
expect(result.removed).toEqual(["addon"]);
expect(loader.get("addon")).toBeUndefined();
expect(loader.get("base")?.state).toBe("stopped");
expect(loader.get("extra")?.state).toBe("active");
expect(JSON.parse(db.settings.get("plugins_uninstalled")!)).toEqual([
"addon",
]);
expect(db.settings.has(onboarding.PLUGIN_SETUP_PENDING_KEY)).toBe(false);
});
it("keeps a dependency that a kept plugin still needs", async () => {
const loader = await chain();
const result = await onboarding.applyOnboardingChoices({
base: "remove",
addon: "disabled",
});
expect(result.adjustments).toEqual([
{ id: "base", from: "remove", to: "disabled", requiredBy: ["addon"] },
]);
expect(result.removed).toEqual([]);
expect(loader.get("base")?.state).toBe("stopped");
expect(loader.get("addon")?.state).toBe("stopped");
});
it("removes dependents before what they depend on", async () => {
const loader = await chain();
const result = await onboarding.applyOnboardingChoices({
base: "remove",
addon: "remove",
});
expect(result.failed).toEqual([]);
expect(result.removed).toEqual(["addon", "base"]);
expect(loader.get("base")).toBeUndefined();
});
it("changes nothing on a dry run", async () => {
const loader = await chain();
db.settings.set(onboarding.PLUGIN_SETUP_PENDING_KEY, "1");
const result = await onboarding.applyOnboardingChoices(
{ extra: "remove", base: "disabled", addon: "disabled" },
{ dryRun: true },
);
expect(result.removed).toEqual(["extra"]);
expect(result.disabled).toEqual(["addon", "base"]);
expect(loader.get("extra")?.state).toBe("active");
expect(db.settings.get(onboarding.PLUGIN_SETUP_PENDING_KEY)).toBe("1");
});
it("ignores ids that are not installed", async () => {
await chain();
const result = await onboarding.applyOnboardingChoices({
ghost: "remove",
});
expect(result.resolved).toEqual({});
expect(result.removed).toEqual([]);
});
it("waits for a plugin change already in progress", async () => {
const loader = await chain();
const first = manage.setPluginState("extra", false);
const second = onboarding.applyOnboardingChoices({ extra: "enabled" });
await first;
const result = await second;
expect(result.enabled).toEqual(["extra"]);
expect(loader.get("extra")?.state).toBe("active");
});
});