feat: move snippets, keybinding actions and macros out of core into plugins

This commit is contained in:
LukeGus committed 2026-09-29 11:39:14 -05:00
1 parent d9bba10612
commit e0c30a4e6f
262 files changed
+9091 -10744

No files matched your search

@@ -305,10 +305,17 @@ describe("transformHostResponse", () => {
it("parses JSON array fields and defaults them to []", () => {
const result = transformHostResponse({
jumpHosts: '[{"hostId":8}]',
quickActions: null,
portKnockSequence: null,
});
expect(result.jumpHosts).toEqual([{ hostId: 8 }]);
expect(result.quickActions).toEqual([]);
expect(result.portKnockSequence).toEqual([]);
});
it("leaves the 2.8 quick_actions column out, for its plugin to put back", () => {
const result = transformHostResponse({
quickActions: '[{"name":"x","snippetId":1}]',
});
expect(result.quickActions).toBeUndefined();
});
it("passes the stored SSH switch through", () => {
@@ -440,10 +447,7 @@ describe("transformHostResponse terminal fields", () => {
}),
});
expect(host.sshOptions).toEqual({ keepaliveInterval: 20 });
expect(host.terminalConfig).toEqual({
keepaliveInterval: 20,
startupSnippetId: 3,
});
expect(host.terminalConfig).toEqual({ keepaliveInterval: 20 });
});
it("reads the options out of terminal_config before the boot copy", () => {
@@ -1,10 +1,44 @@
import { describe, it, expect } from "vitest";
import { afterEach, beforeEach, describe, it, expect } from "vitest";
import {
isValidKeyCombo,
isValidKeybindingAction,
isValidKeybinding,
setKeybindingActionSource,
} from "../../../database/routes/keybinding-validation.js";
// What installed plugins declare in contributes.keybindingActions.
beforeEach(() => {
setKeybindingActionSource(() => [
{ pluginId: "terminal-fixture", id: "copy" },
{ pluginId: "terminal-fixture", id: "paste" },
{
pluginId: "terminal-fixture",
id: "sendControlCode",
params: {
controlCode: { type: "string", required: true, pattern: "^[a-zA-Z]$" },
},
},
{
pluginId: "terminal-fixture",
id: "sendText",
params: {
text: { type: "string", required: true },
appendEnter: { type: "boolean" },
},
},
{
pluginId: "commands-fixture",
id: "runSnippet",
params: {
snippetId: { type: "string", required: true, pattern: "^[0-9]+$" },
appendEnter: { type: "boolean" },
},
},
]);
});
afterEach(() => setKeybindingActionSource(() => []));
const validCombo = {
key: "c",
isCode: false,
@@ -36,15 +70,26 @@ describe("isValidKeybindingAction", () => {
expect(isValidKeybindingAction({ type: "paste" })).toBe(true);
});
it.each(["nextTab", "previousTab", "openCommandPalette"])(
it.each(["nextTab", "previousTab", "openCommandPalette", "reconnectSession"])(
"accepts the global %s action",
(type) => {
expect(isValidKeybindingAction({ type })).toBe(true);
},
);
it("rejects an unknown action type", () => {
expect(isValidKeybindingAction({ type: "explode" })).toBe(false);
it("keeps a type no installed plugin declares, if it looks sane", () => {
expect(isValidKeybindingAction({ type: "gone.action", arg: "x" })).toBe(
true,
);
expect(isValidKeybindingAction({ type: "gone", nested: { a: 1 } })).toBe(
false,
);
expect(isValidKeybindingAction({ type: "bad type!" })).toBe(false);
expect(isValidKeybindingAction({ type: 5 })).toBe(false);
});
it("refuses a parameter the declaration does not list", () => {
expect(isValidKeybindingAction({ type: "copy", text: "x" })).toBe(false);
});
it("requires text for sendText", () => {
@@ -72,6 +117,9 @@ describe("isValidKeybindingAction", () => {
isValidKeybindingAction({ type: "runSnippet", snippetId: "42" }),
).toBe(true);
expect(isValidKeybindingAction({ type: "runSnippet" })).toBe(false);
expect(
isValidKeybindingAction({ type: "runSnippet", snippetId: "abc" }),
).toBe(false);
});
});
Binary file not shown.
+28
View File
@@ -199,7 +199,9 @@ const HOST_ROWS: Row[] = [
allowLegacyAlgorithms: false,
agentForwarding: true,
environmentVariables: [{ key: "D4", value: "yes" }],
startupSnippetId: 1,
}),
quick_actions: JSON.stringify([{ name: "d4-uptime", snippetId: 1 }]),
mac_address: "AA:BB:CC:DD:EE:04",
wol_broadcast_address: "10.4.0.255",
stats_config: JSON.stringify({
@@ -387,6 +389,32 @@ export const ROWS: Record<string, Row[]> = {
},
]),
command_autocomplete: 1,
confirm_snippet_execution: 1,
folders_collapsed: 0,
terminal_macros: JSON.stringify([
{
id: "d4-macro",
name: "d4 macro",
steps: [{ id: "s1", type: "send", text: "uptime", pressEnter: true }],
},
]),
custom_keybindings: JSON.stringify([
{
id: "d4-kb",
combo: {
key: "u",
isCode: false,
ctrl: true,
alt: true,
shift: false,
meta: false,
},
action: { type: "runSnippet", snippetId: "1", appendEnter: true },
enabled: true,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
},
]),
},
],
sso_providers: [
@@ -686,3 +686,76 @@ describe("contributes.http.legacyRedirects", () => {
expect(errors).toMatch(/extra/);
});
});
describe("contributes.http.adminImpersonation", () => {
it("accepts a boolean and refuses anything else", () => {
expect(
validateManifest(
base({ contributes: { http: { adminImpersonation: true } } }),
),
).toEqual([]);
expect(
validateManifest(
base({ contributes: { http: { adminImpersonation: "yes" } } }),
).join(),
).toMatch(/adminImpersonation" must be a boolean/);
});
});
describe("contributes.keybindingActions", () => {
const withActions = (keybindingActions: unknown) =>
validateManifest(base({ contributes: { keybindingActions } }));
it("accepts actions with typed parameters", () => {
expect(
withActions([
{ id: "sample.run" },
{
id: "sample.send",
params: {
itemId: {
type: "string",
required: true,
pattern: "^[0-9]+$",
maxLength: 20,
syncEntity: "items",
},
enter: { type: "boolean" },
},
},
]),
).toEqual([]);
});
it("refuses the shell's own actions, duplicates and bad ids", () => {
const errors = withActions([
{ id: "nextTab" },
{ id: "sample.run" },
{ id: "sample.run" },
{ id: "bad id" },
]).join();
expect(errors).toMatch(/one of the shell's own actions/);
expect(errors).toMatch(/duplicates "sample.run"/);
expect(errors).toMatch(/letters, digits, dots and dashes/);
});
it("refuses bad parameters", () => {
const errors = withActions([
{
id: "sample.run",
params: {
type: { type: "string" },
a: { type: "number" },
b: { type: "string", pattern: "(" },
c: { type: "boolean", syncEntity: "items" },
d: { type: "string", extra: 1 },
},
},
]).join();
expect(errors).toMatch(/params.type is not a valid parameter name/);
expect(errors).toMatch(/a.type must be "string" or "boolean"/);
expect(errors).toMatch(/b.pattern must be a regular expression/);
expect(errors).toMatch(/c.syncEntity needs a string parameter/);
expect(errors).toMatch(/d/);
});
});
@@ -9,7 +9,11 @@ import {
resetSyncRegistry,
unregisterByOwner,
} from "../../plugins/sync-registry.js";
import { registerCoreSyncEntities } from "../../sync/entities.js";
import {
mapKeybindingReferences,
registerCoreSyncEntities,
} from "../../sync/entities.js";
import { setKeybindingActionSource } from "../../database/routes/keybinding-validation.js";
beforeEach(() => {
resetSyncRegistry();
@@ -197,14 +201,51 @@ describe("core sync entities", () => {
);
});
it("translates every snippet a host points at", () => {
const fields = (getEntity("hosts")?.references ?? [])
.filter((reference) => reference.entityType === "commandSnippet")
.map((reference) => reference.field);
expect(fields).toEqual([
"quickActions[].snippetId",
"terminalConfig.startupSnippetId",
it("leaves plugin data out of the host row, which travels in pluginSettings", () => {
const hosts = getEntity("hosts");
expect(
(hosts?.references ?? []).map((reference) => reference.entityType),
).not.toContain("commandSnippet");
expect(hosts?.readOnlyFields).toContain("quickActions");
});
it("translates a keybinding parameter that names a sync entity", async () => {
setKeybindingActionSource(() => [
{
pluginId: "fixture",
id: "runThing",
params: {
thingId: { type: "string", syncEntity: "things" },
label: { type: "string" },
},
},
]);
try {
const row = {
customKeybindings: JSON.stringify([
{ id: "a", action: { type: "runThing", thingId: "4", label: "4" } },
{ id: "b", action: { type: "nextTab" } },
]),
};
const out = await mapKeybindingReferences(row, async (entity, value) =>
entity === "things" && value === "4" ? "sync-4" : null,
);
const bindings = JSON.parse(out.customKeybindings as string);
expect(bindings[0].action).toEqual({
type: "runThing",
thingId: "sync-4",
label: "4",
});
expect(bindings[1].action).toEqual({ type: "nextTab" });
expect(
await mapKeybindingReferences(
{ customKeybindings: null },
async () => "x",
),
).toEqual({ customKeybindings: null });
} finally {
setKeybindingActionSource(() => []);
}
});
it("encrypts host and credential secrets", () => {
@@ -326,6 +326,10 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
containerRuntime: "podman",
});
expect(await host("ai")).toMatchObject({ enableAiAssistant: true });
expect(await host("snippets")).toMatchObject({
startupSnippetId: 1,
quickActions: [{ name: "d4-uptime", snippetId: 1 }],
});
expect(await host("wake-on-lan")).toMatchObject({
macAddress: "AA:BB:CC:DD:EE:04",
broadcastAddress: "10.4.0.255",
@@ -473,6 +477,11 @@ function upgradeChecks(current: () => Booted, bootIndex: number) {
terminalDefaults: { fontSize: 18, cursorBlink: false },
customThemes: [expect.objectContaining({ id: "d4-theme" })],
commandAutocomplete: true,
macros: [expect.objectContaining({ id: "d4-macro", name: "d4 macro" })],
});
expect(await settings(booted, "snippets", "user")).toMatchObject({
confirmExecution: true,
foldersCollapsed: false,
});
expect(await settings(booted, "step-ca", "admin")).toMatchObject({
caUrl: "https://ca.d4.example",
@@ -0,0 +1,174 @@
/**
* The snippets settings migration: host quick actions, the startup snippet
* and the two snippet user preferences must come through into the snippets
* plugin's settings, and running it twice must change nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const pluginRows: Row[] = [];
const hostRows: Array<Record<string, unknown>> = [];
const preferenceRows: Array<Record<string, unknown>> = [];
let pluginInstalled = true;
const find = (scope: string, scopeId: string | null, key: string) =>
pluginRows.find(
(row) =>
row.pluginId === "snippets" &&
row.scope === scope &&
row.scopeId === scopeId &&
row.key === key,
);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) =>
pluginInstalled && id === "snippets" ? { id } : null,
}),
createCurrentPluginSettingsRepository: () => ({
get: async (
_pluginId: string,
scope: string,
scopeId: string | null,
key: string,
) => find(scope, scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string | null,
key: string,
value: string | null,
) => {
const existing = find(scope, scopeId, key);
if (existing) existing.value = value;
else pluginRows.push({ pluginId, scope, scopeId, key, value });
},
}),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({
all: async (query: unknown) =>
JSON.stringify(query).includes("user_preferences")
? preferenceRows
: hostRows,
}),
}));
const { runSnippetsSettingsMigration, snippetHostSettingsFromRow } =
await import("../../upgrade/snippets-settings-migration.js");
function value(scope: string, scopeId: string, key: string): unknown {
const row = find(scope, scopeId, key);
return row?.value == null ? undefined : JSON.parse(row.value);
}
beforeEach(() => {
pluginRows.length = 0;
hostRows.length = 0;
preferenceRows.length = 0;
pluginInstalled = true;
});
describe("snippetHostSettingsFromRow", () => {
it("reads quick actions and the startup snippet, dropping broken rows", () => {
expect(
snippetHostSettingsFromRow({
quick_actions: JSON.stringify([
{ name: "Restart", snippetId: 3 },
{ name: "Broken", snippetId: "x" },
{ name: "Str", snippetId: "4" },
]),
terminal_config: JSON.stringify({ fontSize: 14, startupSnippetId: 9 }),
}),
).toEqual({
quickActions: [
{ name: "Restart", snippetId: 3 },
{ name: "Str", snippetId: 4 },
],
startupSnippetId: 9,
});
});
it("reads nothing from a row without either", () => {
expect(
snippetHostSettingsFromRow({
quick_actions: "[]",
terminal_config: JSON.stringify({ fontSize: 14 }),
}),
).toEqual({});
});
});
describe("runSnippetsSettingsMigration", () => {
it("moves host and user values once", async () => {
hostRows.push(
{
id: 1,
quick_actions: JSON.stringify([{ name: "Up", snippetId: 2 }]),
terminal_config: JSON.stringify({ startupSnippetId: 5 }),
},
{ id: 2, quick_actions: null, terminal_config: "{}" },
);
preferenceRows.push(
{ user_id: "u1", confirm_snippet_execution: 1, folders_collapsed: 0 },
{
user_id: "u2",
confirm_snippet_execution: null,
folders_collapsed: null,
},
);
const first = await runSnippetsSettingsMigration();
expect(first).toEqual({ hostsMoved: 1, hostsSkipped: 0, usersMoved: 1 });
expect(value("host", "1", "quickActions")).toEqual([
{ name: "Up", snippetId: 2 },
]);
expect(value("host", "1", "startupSnippetId")).toBe(5);
expect(value("user", "u1", "confirmExecution")).toBe(true);
expect(value("user", "u1", "foldersCollapsed")).toBe(false);
expect(find("user", "u2", "confirmExecution")).toBeUndefined();
const snapshot = JSON.stringify(pluginRows);
const second = await runSnippetsSettingsMigration();
expect(second).toEqual({ hostsMoved: 0, hostsSkipped: 1, usersMoved: 0 });
expect(JSON.stringify(pluginRows)).toBe(snapshot);
});
it("never overwrites what the user already changed in the plugin", async () => {
pluginRows.push({
pluginId: "snippets",
scope: "host",
scopeId: "1",
key: "startupSnippetId",
value: "7",
});
hostRows.push({
id: 1,
quick_actions: JSON.stringify([{ name: "Up", snippetId: 2 }]),
terminal_config: JSON.stringify({ startupSnippetId: 5 }),
});
await runSnippetsSettingsMigration();
expect(value("host", "1", "startupSnippetId")).toBe(7);
expect(find("host", "1", "quickActions")).toBeUndefined();
});
it("does nothing while the plugin is not installed", async () => {
pluginInstalled = false;
hostRows.push({
id: 1,
quick_actions: JSON.stringify([{ name: "Up", snippetId: 2 }]),
terminal_config: null,
});
await runSnippetsSettingsMigration();
expect(pluginRows).toHaveLength(0);
});
});
@@ -0,0 +1,104 @@
/**
* The terminal macros migration: a user's saved macros must come through
* into the ssh-terminal plugin's macros setting, and running it twice must
* change nothing.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
interface Row {
pluginId: string;
scope: string;
scopeId: string | null;
key: string;
value: string | null;
}
const pluginRows: Row[] = [];
const preferenceRows: Array<Record<string, unknown>> = [];
const find = (scopeId: string, key: string) =>
pluginRows.find(
(row) =>
row.pluginId === "ssh-terminal" &&
row.scope === "user" &&
row.scopeId === scopeId &&
row.key === key,
);
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginRepository: () => ({
findById: async (id: string) => (id === "ssh-terminal" ? { id } : null),
}),
createCurrentPluginSettingsRepository: () => ({
get: async (_p: string, _s: string, scopeId: string, key: string) =>
find(scopeId, key) ?? null,
set: async (
pluginId: string,
scope: string,
scopeId: string,
key: string,
value: string | null,
) => {
const existing = find(scopeId, key);
if (existing) existing.value = value;
else pluginRows.push({ pluginId, scope, scopeId, key, value });
},
}),
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({ all: async () => preferenceRows }),
}));
const { runSshTerminalMacrosMigration, macrosFromPreferences } =
await import("../../upgrade/ssh-terminal-macros-migration.js");
const macro = { id: "m1", name: "Deploy", steps: [{ id: "s", type: "send" }] };
beforeEach(() => {
pluginRows.length = 0;
preferenceRows.length = 0;
});
describe("macrosFromPreferences", () => {
it("keeps valid macros and drops the rest", () => {
expect(
macrosFromPreferences(JSON.stringify([macro, { id: 1 }, "x"])),
).toEqual([macro]);
expect(macrosFromPreferences("[]")).toBeNull();
expect(macrosFromPreferences("{bad")).toBeNull();
});
});
describe("runSshTerminalMacrosMigration", () => {
it("moves each user's macros once", async () => {
preferenceRows.push(
{ user_id: "u1", terminal_macros: JSON.stringify([macro]) },
{ user_id: "u2", terminal_macros: "[]" },
);
expect(await runSshTerminalMacrosMigration()).toEqual({ usersMoved: 1 });
expect(JSON.parse(find("u1", "macros")!.value!)).toEqual([macro]);
expect(find("u2", "macros")).toBeUndefined();
const snapshot = JSON.stringify(pluginRows);
expect(await runSshTerminalMacrosMigration()).toEqual({ usersMoved: 0 });
expect(JSON.stringify(pluginRows)).toBe(snapshot);
});
it("leaves a user who already has macros in the plugin alone", async () => {
pluginRows.push({
pluginId: "ssh-terminal",
scope: "user",
scopeId: "u1",
key: "macros",
value: "[]",
});
preferenceRows.push({
user_id: "u1",
terminal_macros: JSON.stringify([macro]),
});
await runSshTerminalMacrosMigration();
expect(find("u1", "macros")!.value).toBe("[]");
});
});
@@ -66,7 +66,8 @@ vi.mock("../../utils/audit-logger.js", () => ({
getRequestMeta: () => ({ ipAddress: "", userAgent: "" }),
}));
const { AuthManager } = await import("../../utils/auth-manager.js");
const { AuthManager, setPluginImpersonationCheck, allowsAdminImpersonation } =
await import("../../utils/auth-manager.js");
const authManager = AuthManager.getInstance();
const middleware = authManager.createAuthMiddleware();
@@ -216,4 +217,48 @@ describe("AuthManager admin impersonation", () => {
expect(nexted).toBe(true);
expect((req as unknown as { userId: string }).userId).toBe("regular1");
});
it("rejects the header on a plugin route whose manifest did not opt in", async () => {
setPluginImpersonationCheck((pluginId) => pluginId === "opted-in");
try {
const { res, nexted } = await runMiddleware(token("admin1"), {
"x-admin-target-user": "target1",
__url: "/plugin-api/other/items",
});
expect(nexted).toBe(false);
expect(res.statusCode).toBe(403);
expect((res.body as { code?: string }).code).toBe(
"IMPERSONATION_NOT_ALLOWED",
);
} finally {
setPluginImpersonationCheck(() => false);
}
});
it("lets an admin act for a user on a plugin that opted in", async () => {
setPluginImpersonationCheck((pluginId) => pluginId === "opted-in");
try {
const { req, nexted } = await runMiddleware(token("admin1"), {
"x-admin-target-user": "target1",
__url: "/plugin-api/opted-in/items?x=1",
});
expect(nexted).toBe(true);
expect((req as unknown as { userId: string }).userId).toBe("target1");
} finally {
setPluginImpersonationCheck(() => false);
}
});
it("matches the plugin id exactly, not as a prefix", () => {
setPluginImpersonationCheck((pluginId) => pluginId === "opted-in");
try {
expect(allowsAdminImpersonation("/plugin-api/opted-in")).toBe(true);
expect(allowsAdminImpersonation("/plugin-api/opted-in-too/x")).toBe(
false,
);
expect(allowsAdminImpersonation("/users/sessions")).toBe(false);
} finally {
setPluginImpersonationCheck(() => false);
}
});
});