mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: add the snippets plugin (B2)
This commit is contained in:
1 parent
1eac0f54b0
commit
3656caf168
29 files changed
+5293
No files matched your search
@@ -0,0 +1,98 @@
|
||||
{
|
||||
"nav": {
|
||||
"snippets": "Snippets"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_snippets": "Snippets",
|
||||
"feature_snippets_desc": "Save commands you run often and send them to a terminal in one click."
|
||||
},
|
||||
"permissions": {
|
||||
"view": {
|
||||
"title": "View snippets",
|
||||
"description": "See your own snippets, notes and shared snippets."
|
||||
},
|
||||
"create": {
|
||||
"title": "Create snippets",
|
||||
"description": "Create new snippets, notes and folders."
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit snippets",
|
||||
"description": "Rename or change existing snippets and folders."
|
||||
},
|
||||
"delete": {
|
||||
"title": "Delete snippets",
|
||||
"description": "Delete snippets and folders."
|
||||
},
|
||||
"share": {
|
||||
"title": "Share snippets",
|
||||
"description": "Share snippets and folders with other users or roles."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"foldersCollapsed": {
|
||||
"label": "Collapse folders by default",
|
||||
"description": "Start each snippet folder collapsed when you open the Snippets panel."
|
||||
}
|
||||
},
|
||||
"cancel": "Cancel",
|
||||
"loading": "Loading...",
|
||||
"searchPlaceholder": "Search snippets",
|
||||
"newFolder": "New folder",
|
||||
"newSnippet": "New snippet",
|
||||
"noFolder": "No folder",
|
||||
"noSnippetsInFolder": "No snippets in this folder",
|
||||
"noTerminalTabsOpen": "No terminal tab is open",
|
||||
"run": "Run",
|
||||
"pasteToTerminal": "Paste to terminal",
|
||||
"copySuccess": "Copied to clipboard",
|
||||
"shareTitle": "Shared",
|
||||
"importExport": "Import / Export",
|
||||
"importFailed": "Failed to import snippets",
|
||||
|
||||
"nameLabel": "Name",
|
||||
"namePlaceholder": "Snippet name",
|
||||
"descriptionLabel": "Description",
|
||||
"descriptionPlaceholder": "Optional description",
|
||||
"folderLabel": "Folder",
|
||||
"typeLabel": "Type",
|
||||
"typeCommand": "Command",
|
||||
"typeNote": "Note",
|
||||
"commandLabel": "Command",
|
||||
"commandPlaceholder": "Command to run, e.g. systemctl status $HOST",
|
||||
"noteLabel": "Note",
|
||||
"notePlaceholder": "Text to paste into the terminal",
|
||||
|
||||
"createSnippetTitle": "Create snippet",
|
||||
"createSnippetDescription": "Save a command or note you can run or paste into a terminal.",
|
||||
"createSnippetButton": "Create snippet",
|
||||
"createSuccess": "Snippet created",
|
||||
"createFailed": "Failed to create snippet",
|
||||
|
||||
"editSnippetTitle": "Edit snippet",
|
||||
"editSnippetDescription": "Change this snippet's name, content or folder.",
|
||||
"saveSnippetButton": "Save snippet",
|
||||
"updateSuccess": "Snippet updated",
|
||||
"updateFailed": "Failed to update snippet",
|
||||
"deleteFailed": "Failed to delete snippet",
|
||||
|
||||
"createFolderTitle": "Create folder",
|
||||
"createFolderButton": "Create folder",
|
||||
"folderCreateSuccess": "Folder created",
|
||||
"folderCreateFailed": "Failed to create folder",
|
||||
"editFolderTitle": "Edit folder",
|
||||
"saveFolderButton": "Save folder",
|
||||
"folderEditSuccess": "Folder updated",
|
||||
"folderEditFailed": "Failed to update folder",
|
||||
"folderNameLabel": "Folder name",
|
||||
"folderNamePlaceholder": "Folder name",
|
||||
"folderIconLabel": "Icon",
|
||||
"folderDeleteSuccess": "Folder deleted",
|
||||
"folderDeleteFailed": "Failed to delete folder",
|
||||
|
||||
"confirmRunMessage": "Run \"{{name}}\"?",
|
||||
"confirmRunButton": "Run",
|
||||
"runSuccess": "Ran \"{{name}}\" on {{count}} terminal(s)",
|
||||
"pasteSuccess": "Pasted \"{{name}}\" into {{count}} terminal(s)",
|
||||
|
||||
"foldersCollapsedLabel": "Collapse folders by default"
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"id": "snippets",
|
||||
"name": "Snippets",
|
||||
"version": "1.0.0",
|
||||
"description": "Saved commands and notes you can run or paste into a terminal, with variables and sharing.",
|
||||
"author": {
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Productivity",
|
||||
"icon": "Play",
|
||||
"engine": {
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": [
|
||||
"db:own",
|
||||
"network:serve",
|
||||
"ssh:connect",
|
||||
"credentials:use",
|
||||
"ui:surface"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
"service": "snippets.access",
|
||||
"version": "1.0.0",
|
||||
"permission": "snippets.view"
|
||||
}
|
||||
],
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"],
|
||||
"contributes": {
|
||||
"panels": [
|
||||
{
|
||||
"id": "snippets",
|
||||
"titleKey": "nav.snippets",
|
||||
"icon": "Play"
|
||||
}
|
||||
],
|
||||
"permissions": [
|
||||
{
|
||||
"name": "view",
|
||||
"titleKey": "permissions.view.title",
|
||||
"descriptionKey": "permissions.view.description",
|
||||
"defaultRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"name": "create",
|
||||
"titleKey": "permissions.create.title",
|
||||
"descriptionKey": "permissions.create.description",
|
||||
"defaultRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"name": "edit",
|
||||
"titleKey": "permissions.edit.title",
|
||||
"descriptionKey": "permissions.edit.description",
|
||||
"defaultRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"name": "delete",
|
||||
"titleKey": "permissions.delete.title",
|
||||
"descriptionKey": "permissions.delete.description",
|
||||
"defaultRoles": ["user"]
|
||||
},
|
||||
{
|
||||
"name": "share",
|
||||
"titleKey": "permissions.share.title",
|
||||
"descriptionKey": "permissions.share.description",
|
||||
"defaultRoles": ["user"]
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"user": [
|
||||
{
|
||||
"key": "foldersCollapsed",
|
||||
"type": "boolean",
|
||||
"labelKey": "settings.foldersCollapsed.label",
|
||||
"descriptionKey": "settings.foldersCollapsed.description",
|
||||
"default": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
-- snippets 0001: adopt_snippets_tables
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `snippets` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`content` text NOT NULL,
|
||||
`description` text,
|
||||
`folder` text,
|
||||
`order` int NOT NULL DEFAULT 0,
|
||||
`sync_id` varchar(255) UNIQUE,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`host_filter` text,
|
||||
`is_note` boolean NOT NULL DEFAULT false,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `snippets` TO `p_snippets_snippets`;
|
||||
CREATE TABLE IF NOT EXISTS `snippet_folders` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`name` text NOT NULL,
|
||||
`color` text,
|
||||
`icon` text,
|
||||
`sync_id` varchar(255) UNIQUE,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `snippet_folders` TO `p_snippets_snippet_folders`;
|
||||
CREATE TABLE IF NOT EXISTS `snippet_access` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`snippet_id` int NOT NULL,
|
||||
`user_id` varchar(255),
|
||||
`role_id` int,
|
||||
`granted_by` varchar(255) NOT NULL,
|
||||
`permission_level` text NOT NULL DEFAULT ('view'),
|
||||
`expires_at` text,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY (`granted_by`) REFERENCES `users` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `snippet_access` TO `p_snippets_snippet_access`;
|
||||
@@ -0,0 +1,47 @@
|
||||
-- snippets 0001: adopt_snippets_tables
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "snippets" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"description" text,
|
||||
"folder" text,
|
||||
"order" integer NOT NULL DEFAULT 0,
|
||||
"sync_id" varchar(255) UNIQUE,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"host_filter" text,
|
||||
"is_note" boolean NOT NULL DEFAULT false,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_snippets_user_id" ON "snippets" ("user_id");
|
||||
ALTER TABLE "snippets" RENAME TO "p_snippets_snippets";
|
||||
CREATE TABLE IF NOT EXISTS "snippet_folders" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"color" text,
|
||||
"icon" text,
|
||||
"sync_id" varchar(255) UNIQUE,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
ALTER TABLE "snippet_folders" RENAME TO "p_snippets_snippet_folders";
|
||||
CREATE TABLE IF NOT EXISTS "snippet_access" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"snippet_id" integer NOT NULL,
|
||||
"user_id" varchar(255),
|
||||
"role_id" integer,
|
||||
"granted_by" varchar(255) NOT NULL,
|
||||
"permission_level" text NOT NULL DEFAULT 'view',
|
||||
"expires_at" text,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("granted_by") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_snippet_access_user_id" ON "snippet_access" ("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_snippet_access_snippet_id" ON "snippet_access" ("snippet_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_snippet_access_role_id" ON "snippet_access" ("role_id");
|
||||
ALTER TABLE "snippet_access" RENAME TO "p_snippets_snippet_access";
|
||||
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"version": 1,
|
||||
"tables": {
|
||||
"snippets": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"content": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"description": {
|
||||
"type": "text"
|
||||
},
|
||||
"folder": {
|
||||
"type": "text"
|
||||
},
|
||||
"order": {
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"defaultValue": 0
|
||||
},
|
||||
"syncId": {
|
||||
"type": "varchar",
|
||||
"length": 255,
|
||||
"unique": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"hostFilter": {
|
||||
"type": "text"
|
||||
},
|
||||
"isNote": {
|
||||
"type": "boolean",
|
||||
"notNull": true,
|
||||
"defaultValue": false
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_snippets_user_id",
|
||||
"columns": ["userId"]
|
||||
}
|
||||
],
|
||||
"adopts": "snippets"
|
||||
},
|
||||
"snippet_folders": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"color": {
|
||||
"type": "text"
|
||||
},
|
||||
"icon": {
|
||||
"type": "text"
|
||||
},
|
||||
"syncId": {
|
||||
"type": "varchar",
|
||||
"length": 255,
|
||||
"unique": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
}
|
||||
},
|
||||
"indexes": [],
|
||||
"adopts": "snippet_folders"
|
||||
},
|
||||
"snippet_access": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"snippetId": {
|
||||
"type": "integer",
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "varchar",
|
||||
"length": 255
|
||||
},
|
||||
"roleId": {
|
||||
"type": "integer"
|
||||
},
|
||||
"grantedBy": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"permissionLevel": {
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"defaultValue": "view"
|
||||
},
|
||||
"expiresAt": {
|
||||
"type": "timestamp"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_snippet_access_user_id",
|
||||
"columns": ["userId"]
|
||||
},
|
||||
{
|
||||
"name": "idx_snippet_access_snippet_id",
|
||||
"columns": ["snippetId"]
|
||||
},
|
||||
{
|
||||
"name": "idx_snippet_access_role_id",
|
||||
"columns": ["roleId"]
|
||||
}
|
||||
],
|
||||
"adopts": "snippet_access"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
-- snippets 0001: adopt_snippets_tables
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "snippets" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"description" text,
|
||||
"folder" text,
|
||||
"order" integer NOT NULL DEFAULT 0,
|
||||
"sync_id" text UNIQUE,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"host_filter" text,
|
||||
"is_note" integer NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_snippets_user_id" ON "snippets" ("user_id");
|
||||
ALTER TABLE "snippets" RENAME TO "p_snippets_snippets";
|
||||
CREATE TABLE IF NOT EXISTS "snippet_folders" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"color" text,
|
||||
"icon" text,
|
||||
"sync_id" text UNIQUE,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
ALTER TABLE "snippet_folders" RENAME TO "p_snippets_snippet_folders";
|
||||
CREATE TABLE IF NOT EXISTS "snippet_access" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"snippet_id" integer NOT NULL,
|
||||
"user_id" text,
|
||||
"role_id" integer,
|
||||
"granted_by" text NOT NULL,
|
||||
"permission_level" text NOT NULL DEFAULT 'view',
|
||||
"expires_at" text,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("granted_by") REFERENCES "users" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_snippet_access_user_id" ON "snippet_access" ("user_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_snippet_access_snippet_id" ON "snippet_access" ("snippet_id");
|
||||
CREATE INDEX IF NOT EXISTS "idx_snippet_access_role_id" ON "snippet_access" ("role_id");
|
||||
ALTER TABLE "snippet_access" RENAME TO "p_snippets_snippet_access";
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@termix-plugin/snippets",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "termix-plugin build",
|
||||
"test": "termix-plugin test",
|
||||
"typecheck": "tsc -p tsconfig.json",
|
||||
"validate": "termix-plugin validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
export interface SnippetExecutionResult {
|
||||
success: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SnippetHostVars {
|
||||
ip?: string;
|
||||
username?: string;
|
||||
port?: number | string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const INPUT_PATTERN =
|
||||
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
|
||||
|
||||
function replaceVar(content: string, name: string, value?: string): string {
|
||||
if (value === undefined) return content;
|
||||
const pattern = new RegExp(`\\$\\{?${name}\\}?`, "g");
|
||||
return content.replace(pattern, value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors src/ui/lib/snippet-variables.ts resolveSnippetContent. Frontend and
|
||||
* backend are separate builds, so this is kept as a small standalone copy
|
||||
* rather than a shared package for one pure function.
|
||||
*/
|
||||
export function resolveSnippetCommand(
|
||||
content: string,
|
||||
host: SnippetHostVars | null,
|
||||
inputValues: Record<string, string> = {},
|
||||
): string {
|
||||
let resolved = content;
|
||||
|
||||
resolved = replaceVar(resolved, "HOST", host?.ip);
|
||||
resolved = replaceVar(resolved, "USER", host?.username);
|
||||
resolved = replaceVar(
|
||||
resolved,
|
||||
"PORT",
|
||||
host?.port !== undefined ? String(host.port) : undefined,
|
||||
);
|
||||
resolved = replaceVar(resolved, "NAME", host?.name);
|
||||
|
||||
resolved = resolved.replace(
|
||||
INPUT_PATTERN,
|
||||
(
|
||||
fullMatch,
|
||||
braceDigits: string | undefined,
|
||||
_label,
|
||||
plainDigits: string | undefined,
|
||||
) => {
|
||||
const key = `INPUT_${braceDigits ?? plainDigits}`;
|
||||
return key in inputValues ? inputValues[key] : fullMatch;
|
||||
},
|
||||
);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function getSnippetExecutionTimeoutMs(
|
||||
value = process.env.SNIPPET_EXECUTION_TIMEOUT_SECONDS,
|
||||
): number | undefined {
|
||||
if (value === undefined || value.trim() === "") return undefined;
|
||||
|
||||
const seconds = Number(value);
|
||||
if (!Number.isFinite(seconds) || seconds <= 0) return undefined;
|
||||
|
||||
return seconds * 1000;
|
||||
}
|
||||
|
||||
export function createSnippetExecutionResult(
|
||||
exitCode: number | null,
|
||||
output: string,
|
||||
errorOutput: string,
|
||||
): SnippetExecutionResult {
|
||||
const success = exitCode === 0 || (exitCode === null && !errorOutput);
|
||||
return {
|
||||
success,
|
||||
output,
|
||||
...(errorOutput ? { error: errorOutput } : {}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { snippetAccess, snippetFolders, snippets } from "./tables.js";
|
||||
import { createSnippetRepository } from "./repository.js";
|
||||
import { registerSnippetRoutes } from "./routes.js";
|
||||
import { resolveSnippetCommand } from "./execution.js";
|
||||
|
||||
/** What other plugins get from ctx.services.get("snippets.access", {userId}). */
|
||||
export interface SnippetsService {
|
||||
/** The calling user's own snippets (not shared ones), full content included. */
|
||||
list: () => Promise<
|
||||
Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
description: string | null;
|
||||
isNote: boolean;
|
||||
folder: string | null;
|
||||
}>
|
||||
>;
|
||||
/** A snippet the calling user owns or was shared with them. */
|
||||
get: (id: number) => Promise<{
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
isNote: boolean;
|
||||
} | null>;
|
||||
resolveCommand: (
|
||||
id: number,
|
||||
vars: { ip?: string; username?: string; port?: number; name?: string },
|
||||
inputValues?: Record<string, string>,
|
||||
) => Promise<string | null>;
|
||||
/** Creates a snippet owned by the calling user. */
|
||||
create: (input: {
|
||||
name: string;
|
||||
content: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
}) => Promise<{ id: number; name: string }>;
|
||||
/** Updates a snippet the calling user owns. Throws if not found. */
|
||||
update: (
|
||||
id: number,
|
||||
changes: {
|
||||
name?: string;
|
||||
content?: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
},
|
||||
) => Promise<void>;
|
||||
/** Deletes a snippet the calling user owns. Returns whether one was deleted. */
|
||||
remove: (id: number) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
const snippetsTable = await ctx.db.define(snippets);
|
||||
const foldersTable = await ctx.db.define(snippetFolders);
|
||||
const accessTable = await ctx.db.define(snippetAccess);
|
||||
|
||||
const repo = createSnippetRepository(
|
||||
ctx.db,
|
||||
snippetsTable,
|
||||
foldersTable,
|
||||
accessTable,
|
||||
);
|
||||
|
||||
registerSnippetRoutes(ctx.http.router<Router>(), repo, ctx);
|
||||
|
||||
ctx.sync.registerEntity({
|
||||
type: "snippetFolders",
|
||||
table: foldersTable,
|
||||
order: 40,
|
||||
});
|
||||
ctx.sync.registerEntity({
|
||||
type: "snippets",
|
||||
table: snippetsTable,
|
||||
order: 60,
|
||||
});
|
||||
|
||||
// The user row survives a password-reset data wipe, so it never triggers
|
||||
// our refUser() cascade; this topic is core's way of telling us to drop
|
||||
// our copy of that user's data too.
|
||||
ctx.events.on("user.data_wiped", (payload) => {
|
||||
const userId = (payload as { userId?: string } | null)?.userId;
|
||||
if (userId) void repo.deleteByUserId(userId);
|
||||
});
|
||||
|
||||
async function findAccessible(userId: string, id: number) {
|
||||
const owned = await repo.findOwnedById(userId, id);
|
||||
if (owned) return owned;
|
||||
const roleIds = await repo.listUserRoleIds(userId);
|
||||
return repo.findAccessibleSharedSnippet(id, userId, roleIds);
|
||||
}
|
||||
|
||||
const service: SnippetsService = {
|
||||
list: async () => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) return [];
|
||||
const owned = await repo.listOwnedSnippets(userId);
|
||||
return owned.map((s) => ({
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
content: s.content,
|
||||
description: s.description,
|
||||
isNote: s.isNote,
|
||||
folder: s.folder,
|
||||
}));
|
||||
},
|
||||
get: async (id) => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) return null;
|
||||
const snippet = await findAccessible(userId, id);
|
||||
if (!snippet) return null;
|
||||
return {
|
||||
id: snippet.id,
|
||||
name: snippet.name,
|
||||
content: snippet.content,
|
||||
isNote: snippet.isNote,
|
||||
};
|
||||
},
|
||||
resolveCommand: async (id, vars, inputValues = {}) => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) return null;
|
||||
const snippet = await findAccessible(userId, id);
|
||||
if (!snippet) return null;
|
||||
return resolveSnippetCommand(snippet.content, vars, inputValues);
|
||||
},
|
||||
create: async (input) => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) throw new Error("snippets.access.create needs an actor");
|
||||
const created = await repo.createSnippet(userId, input);
|
||||
return { id: created.id, name: created.name };
|
||||
},
|
||||
update: async (id, changes) => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) throw new Error("snippets.access.update needs an actor");
|
||||
const result = await repo.updateSnippet(userId, id, changes);
|
||||
if (!result) throw new Error("Snippet not found");
|
||||
},
|
||||
remove: async (id) => {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) throw new Error("snippets.access.remove needs an actor");
|
||||
const existing = await repo.deleteSnippet(userId, id);
|
||||
if (existing?.syncId) {
|
||||
await ctx.sync.recordTombstone(userId, "snippets", existing.syncId);
|
||||
}
|
||||
return !!existing;
|
||||
},
|
||||
};
|
||||
ctx.services.provide("snippets.access", service);
|
||||
|
||||
ctx.log.info("Snippets routes mounted at /plugin-api/snippets");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
// Everything above was registered through ctx and is disposed by core.
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { SnippetReorderUpdate } from "./repository.js";
|
||||
|
||||
type SnippetReorderRequestBody = {
|
||||
snippets?: unknown;
|
||||
updates?: unknown;
|
||||
};
|
||||
|
||||
export function extractSnippetReorderUpdates(
|
||||
body: unknown,
|
||||
): SnippetReorderUpdate[] | null {
|
||||
if (!body || typeof body !== "object") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = body as SnippetReorderRequestBody;
|
||||
// Keep accepting the legacy `updates` key so older clients do not break
|
||||
// while the web and desktop helpers converge on `snippets`.
|
||||
const snippetsUpdates = Array.isArray(payload.snippets)
|
||||
? payload.snippets
|
||||
: Array.isArray(payload.updates)
|
||||
? payload.updates
|
||||
: null;
|
||||
|
||||
if (!snippetsUpdates) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return snippetsUpdates as SnippetReorderUpdate[];
|
||||
}
|
||||
@@ -0,0 +1,896 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
desc,
|
||||
eq,
|
||||
gte,
|
||||
inArray,
|
||||
isNull,
|
||||
like,
|
||||
or,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export interface SnippetRecord {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
content: string;
|
||||
description: string | null;
|
||||
folder: string | null;
|
||||
order: number;
|
||||
syncId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
hostFilter: string | null;
|
||||
isNote: boolean;
|
||||
}
|
||||
|
||||
export interface SnippetFolderRecord {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
syncId: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RenameSnippetFolderResult {
|
||||
status: "renamed" | "missing" | "conflict";
|
||||
}
|
||||
|
||||
export interface SnippetReorderUpdate {
|
||||
id: number;
|
||||
order: number;
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
export interface NewSnippetInput {
|
||||
name: string;
|
||||
content: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
order?: number | null;
|
||||
hostFilter?: unknown;
|
||||
isNote?: boolean;
|
||||
}
|
||||
|
||||
export interface SnippetUpdateInput {
|
||||
name?: string;
|
||||
content?: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
order?: number;
|
||||
hostFilter?: unknown;
|
||||
isNote?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateSnippetResult {
|
||||
existing: SnippetRecord;
|
||||
updated: SnippetRecord;
|
||||
}
|
||||
|
||||
export interface SnippetBulkImportResult {
|
||||
snippetsImported: number;
|
||||
snippetsSkipped: number;
|
||||
snippetsUpdated: number;
|
||||
foldersImported: number;
|
||||
foldersSkipped: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
interface BulkImportSnippetInput {
|
||||
name?: unknown;
|
||||
content?: unknown;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
order?: unknown;
|
||||
hostFilter?: string | null;
|
||||
}
|
||||
|
||||
interface BulkImportFolderInput {
|
||||
name?: unknown;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
export interface RbacAccessListItem {
|
||||
id: number;
|
||||
targetType: "user" | "role";
|
||||
userId: string | null;
|
||||
roleId: number | null;
|
||||
username: string | null;
|
||||
roleName: string | null;
|
||||
roleDisplayName: string | null;
|
||||
grantedBy: string;
|
||||
grantedByUsername: string | null;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type SnippetAccessTarget =
|
||||
| { targetType: "user"; targetUserId: string }
|
||||
| { targetType: "role"; targetRoleId: number };
|
||||
|
||||
export type UpsertSnippetAccessInput = SnippetAccessTarget & {
|
||||
snippetId: number;
|
||||
grantedBy: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
export interface RbacSharedSnippet {
|
||||
id: number;
|
||||
name: string;
|
||||
content: string;
|
||||
description: string | null;
|
||||
folder: string | null;
|
||||
ownerUsername: string;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
export interface RbacVisibleSharedSnippet extends RbacSharedSnippet {
|
||||
userId: string;
|
||||
order: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isNote: boolean;
|
||||
}
|
||||
|
||||
export interface RbacAccessibleSnippet extends RbacVisibleSharedSnippet {
|
||||
hostFilter: string | null;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// The tables come from ctx.db.define/ctx.db.refs, which the SDK hands back
|
||||
// untyped, and the drizzle handle is the server's own. Typed at this module's
|
||||
// edge instead.
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
function isNonEmptyString(value: unknown): value is string {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
function toAccessListItem(
|
||||
row: Omit<RbacAccessListItem, "targetType">,
|
||||
): RbacAccessListItem {
|
||||
return { ...row, targetType: row.userId ? "user" : "role" };
|
||||
}
|
||||
|
||||
export type SnippetRepository = ReturnType<typeof createSnippetRepository>;
|
||||
|
||||
/**
|
||||
* Snippet, folder and sharing storage. Written without RETURNING so it runs
|
||||
* the same on all three engines: an insert or update is read back by id/sync
|
||||
* id rather than relying on `.returning()`.
|
||||
*/
|
||||
export function createSnippetRepository(
|
||||
db: PluginDatabase,
|
||||
snippets: Table,
|
||||
folders: Table,
|
||||
access: Table,
|
||||
) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
const refs = () =>
|
||||
db.refs<{ users: Table; roles: Table; userRoles: Table }>();
|
||||
|
||||
async function findOwnedById(
|
||||
userId: string,
|
||||
snippetId: number,
|
||||
): Promise<SnippetRecord | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function findFolderByName(
|
||||
userId: string,
|
||||
name: string,
|
||||
): Promise<SnippetFolderRecord | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(and(eq(folders.userId, userId), eq(folders.name, name)))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async function maxOrderForFolder(
|
||||
userId: string,
|
||||
folder: string | null,
|
||||
): Promise<number> {
|
||||
const drizzle = await client();
|
||||
const result = await drizzle
|
||||
.select({ maxOrder: sql<number>`MAX(${snippets.order})` })
|
||||
.from(snippets)
|
||||
.where(
|
||||
and(
|
||||
eq(snippets.userId, userId),
|
||||
folder
|
||||
? eq(snippets.folder, folder)
|
||||
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
|
||||
),
|
||||
);
|
||||
return result[0]?.maxOrder ?? -1;
|
||||
}
|
||||
|
||||
async function nextOrderForFolder(
|
||||
userId: string,
|
||||
folder: string,
|
||||
): Promise<number> {
|
||||
return (await maxOrderForFolder(userId, folder || null)) + 1;
|
||||
}
|
||||
|
||||
async function findByNameAndFolder(
|
||||
userId: string,
|
||||
name: string,
|
||||
folder: string | null,
|
||||
): Promise<SnippetRecord | null> {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(
|
||||
and(
|
||||
eq(snippets.userId, userId),
|
||||
eq(snippets.name, name),
|
||||
folder
|
||||
? eq(snippets.folder, folder)
|
||||
: sql`(${snippets.folder} IS NULL OR ${snippets.folder} = '')`,
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
function userOrRoleAccessFilter(userId: string, roleIds: number[]) {
|
||||
if (roleIds.length === 0) return eq(access.userId, userId);
|
||||
return or(eq(access.userId, userId), inArray(access.roleId, roleIds));
|
||||
}
|
||||
|
||||
async function findSnippetAccess(
|
||||
snippetId: number,
|
||||
target: SnippetAccessTarget,
|
||||
) {
|
||||
const drizzle = await client();
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(access)
|
||||
.where(
|
||||
and(
|
||||
eq(access.snippetId, snippetId),
|
||||
target.targetType === "user"
|
||||
? eq(access.userId, target.targetUserId)
|
||||
: eq(access.roleId, target.targetRoleId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
return {
|
||||
findOwnedById,
|
||||
|
||||
async listFolders(userId: string): Promise<SnippetFolderRecord[]> {
|
||||
const drizzle = await client();
|
||||
return drizzle
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(eq(folders.userId, userId))
|
||||
.orderBy(asc(folders.name));
|
||||
},
|
||||
|
||||
async listSnippetsForExport(userId: string): Promise<SnippetRecord[]> {
|
||||
const drizzle = await client();
|
||||
return drizzle
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(eq(snippets.userId, userId))
|
||||
.orderBy(sql`coalesce(${snippets.folder}, '')`, asc(snippets.order));
|
||||
},
|
||||
|
||||
async listFoldersForExport(userId: string): Promise<SnippetFolderRecord[]> {
|
||||
const drizzle = await client();
|
||||
return drizzle
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(eq(folders.userId, userId))
|
||||
.orderBy(asc(folders.name));
|
||||
},
|
||||
|
||||
async listOwnedSnippetsInFolder(
|
||||
userId: string,
|
||||
folder: string,
|
||||
): Promise<SnippetRecord[]> {
|
||||
const drizzle = await client();
|
||||
return drizzle
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(
|
||||
and(
|
||||
eq(snippets.userId, userId),
|
||||
or(
|
||||
eq(snippets.folder, folder),
|
||||
like(snippets.folder, `${folder} / %`),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
async listOwnedSnippets(userId: string): Promise<SnippetRecord[]> {
|
||||
const drizzle = await client();
|
||||
return drizzle
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(eq(snippets.userId, userId))
|
||||
.orderBy(
|
||||
sql`CASE WHEN ${snippets.folder} IS NULL OR ${snippets.folder} = '' THEN 0 ELSE 1 END`,
|
||||
asc(snippets.folder),
|
||||
asc(snippets.order),
|
||||
sql`${snippets.updatedAt} DESC`,
|
||||
);
|
||||
},
|
||||
|
||||
async reorderSnippets(
|
||||
userId: string,
|
||||
updates: SnippetReorderUpdate[],
|
||||
): Promise<void> {
|
||||
const drizzle = await client();
|
||||
for (const update of updates) {
|
||||
const { id, order, folder } = update;
|
||||
if (!id || order === undefined) continue;
|
||||
|
||||
const updateFields: Partial<{ order: number; folder: string | null }> =
|
||||
{
|
||||
order,
|
||||
};
|
||||
if (folder !== undefined) updateFields.folder = folder?.trim() || null;
|
||||
|
||||
await drizzle
|
||||
.update(snippets)
|
||||
.set(updateFields)
|
||||
.where(and(eq(snippets.id, id), eq(snippets.userId, userId)));
|
||||
}
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async createSnippet(
|
||||
userId: string,
|
||||
input: NewSnippetInput,
|
||||
): Promise<SnippetRecord> {
|
||||
const folderValue = input.folder?.trim() || "";
|
||||
const order =
|
||||
input.order === undefined || input.order === null
|
||||
? await nextOrderForFolder(userId, folderValue)
|
||||
: input.order;
|
||||
|
||||
const syncId = randomUUID();
|
||||
const drizzle = await client();
|
||||
await drizzle.insert(snippets).values({
|
||||
syncId,
|
||||
userId,
|
||||
name: input.name.trim(),
|
||||
content: input.content.trim(),
|
||||
description: input.description?.trim() || null,
|
||||
folder: input.folder?.trim() || null,
|
||||
order,
|
||||
hostFilter: input.hostFilter ? JSON.stringify(input.hostFilter) : null,
|
||||
isNote: input.isNote ?? false,
|
||||
});
|
||||
await db.persist();
|
||||
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(snippets)
|
||||
.where(eq(snippets.syncId, syncId))
|
||||
.limit(1);
|
||||
return rows[0];
|
||||
},
|
||||
|
||||
async updateSnippet(
|
||||
userId: string,
|
||||
snippetId: number,
|
||||
input: SnippetUpdateInput,
|
||||
): Promise<UpdateSnippetResult | null> {
|
||||
const existing = await findOwnedById(userId, snippetId);
|
||||
if (!existing) return null;
|
||||
|
||||
const updateFields: Record<string, unknown> = {
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
};
|
||||
if (input.name !== undefined) updateFields.name = input.name.trim();
|
||||
if (input.content !== undefined)
|
||||
updateFields.content = input.content.trim();
|
||||
if (input.description !== undefined)
|
||||
updateFields.description = input.description?.trim() || null;
|
||||
if (input.folder !== undefined)
|
||||
updateFields.folder = input.folder?.trim() || null;
|
||||
if (input.order !== undefined) updateFields.order = input.order;
|
||||
if (input.hostFilter !== undefined)
|
||||
updateFields.hostFilter = input.hostFilter
|
||||
? JSON.stringify(input.hostFilter)
|
||||
: null;
|
||||
if (input.isNote !== undefined) updateFields.isNote = input.isNote;
|
||||
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.update(snippets)
|
||||
.set(updateFields)
|
||||
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)));
|
||||
await db.persist();
|
||||
|
||||
const updated = await findOwnedById(userId, snippetId);
|
||||
return { existing, updated: updated as SnippetRecord };
|
||||
},
|
||||
|
||||
async deleteSnippet(
|
||||
userId: string,
|
||||
snippetId: number,
|
||||
): Promise<SnippetRecord | null> {
|
||||
const existing = await findOwnedById(userId, snippetId);
|
||||
if (!existing) return null;
|
||||
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(snippets)
|
||||
.where(and(eq(snippets.id, snippetId), eq(snippets.userId, userId)));
|
||||
await db.persist();
|
||||
return existing;
|
||||
},
|
||||
|
||||
async bulkImport(
|
||||
userId: string,
|
||||
snippetsToImport: unknown[] | undefined,
|
||||
foldersToImport: unknown[] | undefined,
|
||||
overwrite: boolean,
|
||||
): Promise<SnippetBulkImportResult> {
|
||||
const results: SnippetBulkImportResult = {
|
||||
snippetsImported: 0,
|
||||
snippetsSkipped: 0,
|
||||
snippetsUpdated: 0,
|
||||
foldersImported: 0,
|
||||
foldersSkipped: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
const drizzle = await client();
|
||||
let changed = false;
|
||||
|
||||
if (Array.isArray(foldersToImport)) {
|
||||
for (const rawFolder of foldersToImport) {
|
||||
const folder = rawFolder as BulkImportFolderInput;
|
||||
if (!isNonEmptyString(folder.name)) {
|
||||
results.failed++;
|
||||
results.errors.push(`Folder missing name`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await findFolderByName(userId, folder.name);
|
||||
if (existing) {
|
||||
results.foldersSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await drizzle.insert(folders).values({
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
name: folder.name.trim(),
|
||||
color: folder.color?.trim() || null,
|
||||
icon: folder.icon?.trim() || null,
|
||||
});
|
||||
changed = true;
|
||||
results.foldersImported++;
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(snippetsToImport)) {
|
||||
for (let i = 0; i < snippetsToImport.length; i++) {
|
||||
const snippet = snippetsToImport[i] as BulkImportSnippetInput;
|
||||
|
||||
if (
|
||||
!isNonEmptyString(snippet.name) ||
|
||||
!isNonEmptyString(snippet.content)
|
||||
) {
|
||||
results.failed++;
|
||||
results.errors.push(
|
||||
`Snippet ${i + 1}: name and content are required`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const folderVal = snippet.folder?.trim() || null;
|
||||
const existing = await findByNameAndFolder(
|
||||
userId,
|
||||
snippet.name.trim(),
|
||||
folderVal,
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
if (!overwrite) {
|
||||
results.snippetsSkipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await drizzle
|
||||
.update(snippets)
|
||||
.set({
|
||||
content: snippet.content.trim(),
|
||||
description: snippet.description?.trim() || null,
|
||||
folder: folderVal,
|
||||
order:
|
||||
typeof snippet.order === "number"
|
||||
? snippet.order
|
||||
: existing.order,
|
||||
hostFilter: snippet.hostFilter || null,
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
})
|
||||
.where(
|
||||
and(eq(snippets.id, existing.id), eq(snippets.userId, userId)),
|
||||
);
|
||||
changed = true;
|
||||
results.snippetsUpdated++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const maxOrder = await maxOrderForFolder(userId, folderVal);
|
||||
await drizzle.insert(snippets).values({
|
||||
syncId: randomUUID(),
|
||||
userId,
|
||||
name: snippet.name.trim(),
|
||||
content: snippet.content.trim(),
|
||||
description: snippet.description?.trim() || null,
|
||||
folder: folderVal,
|
||||
order:
|
||||
typeof snippet.order === "number" ? snippet.order : maxOrder + 1,
|
||||
hostFilter: snippet.hostFilter || null,
|
||||
});
|
||||
changed = true;
|
||||
results.snippetsImported++;
|
||||
}
|
||||
}
|
||||
|
||||
if (changed) await db.persist();
|
||||
return results;
|
||||
},
|
||||
|
||||
async createFolder(
|
||||
userId: string,
|
||||
name: string,
|
||||
color: string | null | undefined,
|
||||
icon: string | null | undefined,
|
||||
): Promise<SnippetFolderRecord | null> {
|
||||
const existing = await findFolderByName(userId, name);
|
||||
if (existing) return null;
|
||||
|
||||
const syncId = randomUUID();
|
||||
const drizzle = await client();
|
||||
await drizzle.insert(folders).values({
|
||||
syncId,
|
||||
userId,
|
||||
name: name.trim(),
|
||||
color: color?.trim() || null,
|
||||
icon: icon?.trim() || null,
|
||||
});
|
||||
await db.persist();
|
||||
|
||||
const rows = await drizzle
|
||||
.select()
|
||||
.from(folders)
|
||||
.where(eq(folders.syncId, syncId))
|
||||
.limit(1);
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
async updateFolderMetadata(
|
||||
userId: string,
|
||||
name: string,
|
||||
color: string | null | undefined,
|
||||
icon: string | null | undefined,
|
||||
): Promise<SnippetFolderRecord | null> {
|
||||
const existing = await findFolderByName(userId, name);
|
||||
if (!existing) return null;
|
||||
|
||||
const updateFields: Record<string, unknown> = {
|
||||
updatedAt: sql`CURRENT_TIMESTAMP`,
|
||||
};
|
||||
if (color !== undefined) updateFields.color = color?.trim() || null;
|
||||
if (icon !== undefined) updateFields.icon = icon?.trim() || null;
|
||||
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.update(folders)
|
||||
.set(updateFields)
|
||||
.where(and(eq(folders.userId, userId), eq(folders.name, name)));
|
||||
await db.persist();
|
||||
|
||||
return findFolderByName(userId, name);
|
||||
},
|
||||
|
||||
async renameFolder(
|
||||
userId: string,
|
||||
oldName: string,
|
||||
newName: string,
|
||||
): Promise<RenameSnippetFolderResult> {
|
||||
const existing = await findFolderByName(userId, oldName);
|
||||
if (!existing) return { status: "missing" };
|
||||
|
||||
const nameExists = await findFolderByName(userId, newName);
|
||||
if (nameExists) return { status: "conflict" };
|
||||
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.update(folders)
|
||||
.set({ name: newName, updatedAt: sql`CURRENT_TIMESTAMP` })
|
||||
.where(and(eq(folders.userId, userId), eq(folders.name, oldName)));
|
||||
|
||||
await drizzle
|
||||
.update(snippets)
|
||||
.set({ folder: newName })
|
||||
.where(and(eq(snippets.userId, userId), eq(snippets.folder, oldName)));
|
||||
|
||||
await db.persist();
|
||||
return { status: "renamed" };
|
||||
},
|
||||
|
||||
async deleteFolder(
|
||||
userId: string,
|
||||
name: string,
|
||||
): Promise<{ syncId: string | null } | null> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.update(snippets)
|
||||
.set({ folder: null })
|
||||
.where(and(eq(snippets.userId, userId), eq(snippets.folder, name)));
|
||||
|
||||
const existing = await findFolderByName(userId, name);
|
||||
if (!existing) {
|
||||
await db.persist();
|
||||
return null;
|
||||
}
|
||||
|
||||
await drizzle
|
||||
.delete(folders)
|
||||
.where(and(eq(folders.userId, userId), eq(folders.name, name)));
|
||||
await db.persist();
|
||||
return { syncId: existing.syncId };
|
||||
},
|
||||
|
||||
/** Wipes a user's snippets and folders, for the password-reset data-wipe flow. */
|
||||
async deleteByUserId(userId: string): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle.delete(snippets).where(eq(snippets.userId, userId));
|
||||
await drizzle.delete(folders).where(eq(folders.userId, userId));
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
// Sharing (moved from core's RbacAccessRepository)
|
||||
|
||||
async listSnippetAccess(snippetId: number): Promise<RbacAccessListItem[]> {
|
||||
const drizzle = await client();
|
||||
const { users, roles } = await refs();
|
||||
const rows = await drizzle
|
||||
.select({
|
||||
id: access.id,
|
||||
userId: access.userId,
|
||||
roleId: access.roleId,
|
||||
username: users.username,
|
||||
roleName: roles.name,
|
||||
roleDisplayName: roles.displayName,
|
||||
grantedBy: access.grantedBy,
|
||||
grantedByUsername: sql<
|
||||
string | null
|
||||
>`(SELECT username FROM users WHERE id = ${access.grantedBy})`,
|
||||
permissionLevel: access.permissionLevel,
|
||||
expiresAt: access.expiresAt,
|
||||
createdAt: access.createdAt,
|
||||
})
|
||||
.from(access)
|
||||
.leftJoin(users, eq(access.userId, users.id))
|
||||
.leftJoin(roles, eq(access.roleId, roles.id))
|
||||
.where(eq(access.snippetId, snippetId))
|
||||
.orderBy(desc(access.createdAt));
|
||||
|
||||
return rows.map(toAccessListItem);
|
||||
},
|
||||
|
||||
async upsertSnippetAccess(
|
||||
input: UpsertSnippetAccessInput,
|
||||
): Promise<{ id: number; created: boolean }> {
|
||||
const existing = await findSnippetAccess(input.snippetId, input);
|
||||
const drizzle = await client();
|
||||
|
||||
if (existing) {
|
||||
await drizzle
|
||||
.update(access)
|
||||
.set({ expiresAt: input.expiresAt })
|
||||
.where(eq(access.id, existing.id));
|
||||
await db.persist();
|
||||
return { id: existing.id, created: false };
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
await drizzle.insert(access).values({
|
||||
snippetId: input.snippetId,
|
||||
userId: input.targetType === "user" ? input.targetUserId : null,
|
||||
roleId: input.targetType === "role" ? input.targetRoleId : null,
|
||||
grantedBy: input.grantedBy,
|
||||
permissionLevel: "view",
|
||||
expiresAt: input.expiresAt,
|
||||
createdAt: now,
|
||||
});
|
||||
await db.persist();
|
||||
|
||||
const created = await findSnippetAccess(input.snippetId, input);
|
||||
return { id: created!.id, created: true };
|
||||
},
|
||||
|
||||
async revokeSnippetAccess(
|
||||
accessId: number,
|
||||
snippetId: number,
|
||||
): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(access)
|
||||
.where(and(eq(access.id, accessId), eq(access.snippetId, snippetId)));
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async listSharedSnippets(
|
||||
userId: string,
|
||||
roleIds: number[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<RbacSharedSnippet[]> {
|
||||
const drizzle = await client();
|
||||
const { users } = await refs();
|
||||
|
||||
const directShared = await drizzle
|
||||
.select({
|
||||
id: snippets.id,
|
||||
name: snippets.name,
|
||||
content: snippets.content,
|
||||
description: snippets.description,
|
||||
folder: snippets.folder,
|
||||
ownerUsername: users.username,
|
||||
permissionLevel: access.permissionLevel,
|
||||
expiresAt: access.expiresAt,
|
||||
})
|
||||
.from(access)
|
||||
.innerJoin(snippets, eq(access.snippetId, snippets.id))
|
||||
.innerJoin(users, eq(snippets.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(access.userId, userId),
|
||||
or(isNull(access.expiresAt), gte(access.expiresAt, now)),
|
||||
),
|
||||
);
|
||||
|
||||
if (roleIds.length === 0) return directShared;
|
||||
|
||||
const directIds = new Set(directShared.map((s: { id: number }) => s.id));
|
||||
const roleShared = await drizzle
|
||||
.select({
|
||||
id: snippets.id,
|
||||
name: snippets.name,
|
||||
content: snippets.content,
|
||||
description: snippets.description,
|
||||
folder: snippets.folder,
|
||||
ownerUsername: users.username,
|
||||
permissionLevel: access.permissionLevel,
|
||||
expiresAt: access.expiresAt,
|
||||
})
|
||||
.from(access)
|
||||
.innerJoin(snippets, eq(access.snippetId, snippets.id))
|
||||
.innerJoin(users, eq(snippets.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
or(isNull(access.expiresAt), gte(access.expiresAt, now)),
|
||||
inArray(access.roleId, roleIds),
|
||||
),
|
||||
);
|
||||
|
||||
return [
|
||||
...directShared,
|
||||
...roleShared.filter((s: { id: number }) => !directIds.has(s.id)),
|
||||
];
|
||||
},
|
||||
|
||||
async listVisibleSharedSnippets(
|
||||
userId: string,
|
||||
roleIds: number[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<RbacVisibleSharedSnippet[]> {
|
||||
const drizzle = await client();
|
||||
const { users } = await refs();
|
||||
return drizzle
|
||||
.select({
|
||||
id: snippets.id,
|
||||
userId: snippets.userId,
|
||||
name: snippets.name,
|
||||
content: snippets.content,
|
||||
description: snippets.description,
|
||||
folder: snippets.folder,
|
||||
order: snippets.order,
|
||||
createdAt: snippets.createdAt,
|
||||
updatedAt: snippets.updatedAt,
|
||||
isNote: snippets.isNote,
|
||||
ownerUsername: users.username,
|
||||
permissionLevel: access.permissionLevel,
|
||||
expiresAt: access.expiresAt,
|
||||
})
|
||||
.from(access)
|
||||
.innerJoin(snippets, eq(access.snippetId, snippets.id))
|
||||
.innerJoin(users, eq(snippets.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
userOrRoleAccessFilter(userId, roleIds),
|
||||
or(isNull(access.expiresAt), gte(access.expiresAt, now)),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
async findAccessibleSharedSnippet(
|
||||
snippetId: number,
|
||||
userId: string,
|
||||
roleIds: number[],
|
||||
now = new Date().toISOString(),
|
||||
): Promise<RbacAccessibleSnippet | null> {
|
||||
const drizzle = await client();
|
||||
const { users } = await refs();
|
||||
const rows = await drizzle
|
||||
.select({
|
||||
id: snippets.id,
|
||||
userId: snippets.userId,
|
||||
name: snippets.name,
|
||||
content: snippets.content,
|
||||
description: snippets.description,
|
||||
folder: snippets.folder,
|
||||
order: snippets.order,
|
||||
createdAt: snippets.createdAt,
|
||||
updatedAt: snippets.updatedAt,
|
||||
hostFilter: snippets.hostFilter,
|
||||
isNote: snippets.isNote,
|
||||
ownerUsername: users.username,
|
||||
permissionLevel: access.permissionLevel,
|
||||
expiresAt: access.expiresAt,
|
||||
})
|
||||
.from(access)
|
||||
.innerJoin(snippets, eq(access.snippetId, snippets.id))
|
||||
.innerJoin(users, eq(snippets.userId, users.id))
|
||||
.where(
|
||||
and(
|
||||
eq(access.snippetId, snippetId),
|
||||
userOrRoleAccessFilter(userId, roleIds),
|
||||
or(isNull(access.expiresAt), gte(access.expiresAt, now)),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
return rows[0] ?? null;
|
||||
},
|
||||
|
||||
/** A user's role ids, for the sharing filters above. */
|
||||
async listUserRoleIds(userId: string): Promise<number[]> {
|
||||
const drizzle = await client();
|
||||
const { userRoles } = await refs();
|
||||
const rows = await drizzle
|
||||
.select({ roleId: userRoles.roleId })
|
||||
.from(userRoles)
|
||||
.where(eq(userRoles.userId, userId));
|
||||
return rows.map((r: { roleId: number }) => r.roleId);
|
||||
},
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
boolean,
|
||||
defineTable,
|
||||
id,
|
||||
integer,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
export const snippets = adoptLegacyTable(
|
||||
"snippets",
|
||||
defineTable(
|
||||
"snippets",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
name: text().notNull(),
|
||||
content: text().notNull(),
|
||||
description: text(),
|
||||
folder: text(),
|
||||
order: integer().notNull().default(0),
|
||||
syncId: varchar().unique(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
hostFilter: text(),
|
||||
isNote: boolean().notNull().default(false),
|
||||
},
|
||||
{ indexes: [{ name: "idx_snippets_user_id", columns: ["userId"] }] },
|
||||
),
|
||||
);
|
||||
|
||||
export const snippetFolders = adoptLegacyTable(
|
||||
"snippet_folders",
|
||||
defineTable("snippet_folders", {
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
name: text().notNull(),
|
||||
color: text(),
|
||||
icon: text(),
|
||||
syncId: varchar().unique(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
}),
|
||||
);
|
||||
|
||||
export const snippetAccess = adoptLegacyTable(
|
||||
"snippet_access",
|
||||
defineTable(
|
||||
"snippet_access",
|
||||
{
|
||||
id: id(),
|
||||
snippetId: integer().notNull(),
|
||||
userId: varchar(),
|
||||
roleId: integer(),
|
||||
grantedBy: refUser(),
|
||||
permissionLevel: text().notNull().default("view"),
|
||||
expiresAt: timestamp(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{ name: "idx_snippet_access_user_id", columns: ["userId"] },
|
||||
{ name: "idx_snippet_access_snippet_id", columns: ["snippetId"] },
|
||||
{ name: "idx_snippet_access_role_id", columns: ["roleId"] },
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const tables = [snippets, snippetFolders, snippetAccess];
|
||||
@@ -0,0 +1,742 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
usePluginApi,
|
||||
useTranslation,
|
||||
useSettings,
|
||||
usePermission,
|
||||
type PanelProps,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Download,
|
||||
Folder,
|
||||
FolderPlus,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
Search,
|
||||
Share2,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Input,
|
||||
Select2,
|
||||
Switch,
|
||||
Textarea,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { createSnippetsApi } from "./snippets-api";
|
||||
import { useSnippetRunner } from "./use-snippet-runner";
|
||||
import {
|
||||
errorMessage,
|
||||
FOLDER_ICONS,
|
||||
type Snippet,
|
||||
type SnippetFolder,
|
||||
} from "./types";
|
||||
|
||||
const ROOT_FOLDER = "__root__";
|
||||
|
||||
interface EditState {
|
||||
id: number | null;
|
||||
name: string;
|
||||
content: string;
|
||||
description: string;
|
||||
folder: string;
|
||||
isNote: boolean;
|
||||
}
|
||||
|
||||
const EMPTY_EDIT: EditState = {
|
||||
id: null,
|
||||
name: "",
|
||||
content: "",
|
||||
description: "",
|
||||
folder: "",
|
||||
isNote: false,
|
||||
};
|
||||
|
||||
function groupByFolder(snippets: Snippet[]): Map<string, Snippet[]> {
|
||||
const groups = new Map<string, Snippet[]>();
|
||||
for (const snippet of snippets) {
|
||||
const key = snippet.folder || ROOT_FOLDER;
|
||||
const list = groups.get(key) ?? [];
|
||||
list.push(snippet);
|
||||
groups.set(key, list);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function SnippetEditDialog({
|
||||
open,
|
||||
initial,
|
||||
folders,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
initial: EditState;
|
||||
folders: SnippetFolder[];
|
||||
onClose: () => void;
|
||||
onSave: (state: EditState) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<EditState>(initial);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setState(initial);
|
||||
}, [open, initial]);
|
||||
|
||||
const isEdit = initial.id !== null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t(isEdit ? "editSnippetTitle" : "createSnippetTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(isEdit ? "editSnippetDescription" : "createSnippetDescription")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">{t("nameLabel")}</label>
|
||||
<Input
|
||||
value={state.name}
|
||||
placeholder={t("namePlaceholder")}
|
||||
onChange={(e) =>
|
||||
setState((s) => ({ ...s, name: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("descriptionLabel")}
|
||||
</label>
|
||||
<Input
|
||||
value={state.description}
|
||||
placeholder={t("descriptionPlaceholder")}
|
||||
onChange={(e) =>
|
||||
setState((s) => ({ ...s, description: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">{t("folderLabel")}</label>
|
||||
<Select2
|
||||
value={state.folder}
|
||||
onChange={(e) =>
|
||||
setState((s) => ({ ...s, folder: e.target.value }))
|
||||
}
|
||||
>
|
||||
<option value="">{t("noFolder")}</option>
|
||||
{folders.map((f) => (
|
||||
<option key={f.id} value={f.name}>
|
||||
{f.name}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-semibold">{t("typeLabel")}</label>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span>{t("typeCommand")}</span>
|
||||
<Switch
|
||||
checked={state.isNote}
|
||||
onCheckedChange={(checked) =>
|
||||
setState((s) => ({ ...s, isNote: checked }))
|
||||
}
|
||||
/>
|
||||
<span>{t("typeNote")}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t(state.isNote ? "noteLabel" : "commandLabel")}
|
||||
</label>
|
||||
<Textarea
|
||||
className="min-h-32 font-mono text-xs"
|
||||
value={state.content}
|
||||
placeholder={t(
|
||||
state.isNote ? "notePlaceholder" : "commandPlaceholder",
|
||||
)}
|
||||
onChange={(e) =>
|
||||
setState((s) => ({ ...s, content: e.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saving || !state.name.trim() || !state.content.trim()}
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(state);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t(isEdit ? "saveSnippetButton" : "createSnippetButton")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function FolderEditDialog({
|
||||
open,
|
||||
initial,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
initial: {
|
||||
name: string;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
isEdit: boolean;
|
||||
};
|
||||
onClose: () => void;
|
||||
onSave: (
|
||||
name: string,
|
||||
color: string | null,
|
||||
icon: string | null,
|
||||
) => Promise<void>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = useState(initial.name);
|
||||
const [icon, setIcon] = useState(initial.icon ?? FOLDER_ICONS[0]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initial.name);
|
||||
setIcon(initial.icon ?? FOLDER_ICONS[0]);
|
||||
}
|
||||
}, [open, initial]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||
<DialogContent className="sm:max-w-sm">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t(initial.isEdit ? "editFolderTitle" : "createFolderTitle")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("folderNameLabel")}
|
||||
</label>
|
||||
<Input
|
||||
value={name}
|
||||
placeholder={t("folderNamePlaceholder")}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={initial.isEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-semibold">
|
||||
{t("folderIconLabel")}
|
||||
</label>
|
||||
<Select2 value={icon} onChange={(e) => setIcon(e.target.value)}>
|
||||
{FOLDER_ICONS.map((id) => (
|
||||
<option key={id} value={id}>
|
||||
{id}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={saving || !name.trim()}
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(name.trim(), null, icon);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t(initial.isEdit ? "saveFolderButton" : "createFolderButton")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function SnippetsPanel({ active: _active }: PanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const api = usePluginApi();
|
||||
const client = useMemo(() => createSnippetsApi(api), [api]);
|
||||
const canView = usePermission("view");
|
||||
const canCreate = usePermission("create");
|
||||
const canEdit = usePermission("edit");
|
||||
const canDelete = usePermission("delete");
|
||||
const { runOnActive, dialog: runnerDialog } = useSnippetRunner();
|
||||
|
||||
const collapsedSetting = useSettings("user");
|
||||
const [collapsedFolders, setCollapsedFolders] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const defaultCollapsed = collapsedSetting.values.foldersCollapsed !== false;
|
||||
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||
const [folders, setFolders] = useState<SnippetFolder[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState("");
|
||||
|
||||
const [editState, setEditState] = useState<EditState | null>(null);
|
||||
const [folderEditState, setFolderEditState] = useState<{
|
||||
name: string;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
isEdit: boolean;
|
||||
} | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!canView) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const [snippetList, folderList] = await Promise.all([
|
||||
client.list(),
|
||||
client.listFolders(),
|
||||
]);
|
||||
setSnippets(snippetList);
|
||||
setFolders(folderList);
|
||||
} catch {
|
||||
toast.error(t("loading"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canView, client, t]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
if (!query) return snippets;
|
||||
return snippets.filter(
|
||||
(s) =>
|
||||
s.name.toLowerCase().includes(query) ||
|
||||
s.description?.toLowerCase().includes(query),
|
||||
);
|
||||
}, [snippets, search]);
|
||||
|
||||
const grouped = useMemo(() => groupByFolder(filtered), [filtered]);
|
||||
|
||||
function isCollapsed(folder: string): boolean {
|
||||
if (collapsedFolders.has(folder)) return !defaultCollapsed ? false : true;
|
||||
return defaultCollapsed;
|
||||
}
|
||||
|
||||
function toggleFolder(folder: string) {
|
||||
setCollapsedFolders((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(folder)) next.delete(folder);
|
||||
else next.add(folder);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSaveSnippet(state: EditState) {
|
||||
try {
|
||||
if (state.id !== null) {
|
||||
await client.update(state.id, {
|
||||
name: state.name.trim(),
|
||||
content: state.content.trim(),
|
||||
description: state.description.trim() || null,
|
||||
folder: state.folder || null,
|
||||
isNote: state.isNote,
|
||||
});
|
||||
toast.success(t("updateSuccess"));
|
||||
} else {
|
||||
await client.create({
|
||||
name: state.name.trim(),
|
||||
content: state.content.trim(),
|
||||
description: state.description.trim() || null,
|
||||
folder: state.folder || null,
|
||||
isNote: state.isNote,
|
||||
});
|
||||
toast.success(t("createSuccess"));
|
||||
}
|
||||
setEditState(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
errorMessage(
|
||||
err,
|
||||
t(state.id !== null ? "updateFailed" : "createFailed"),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSnippet(snippet: Snippet) {
|
||||
try {
|
||||
await client.remove(snippet.id);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, t("deleteFailed")));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveFolder(
|
||||
name: string,
|
||||
color: string | null,
|
||||
icon: string | null,
|
||||
) {
|
||||
try {
|
||||
if (folderEditState?.isEdit) {
|
||||
await client.updateFolderMetadata(name, { color, icon });
|
||||
toast.success(t("folderEditSuccess"));
|
||||
} else {
|
||||
await client.createFolder({ name, color, icon });
|
||||
toast.success(t("folderCreateSuccess"));
|
||||
}
|
||||
setFolderEditState(null);
|
||||
await load();
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
errorMessage(
|
||||
err,
|
||||
t(
|
||||
folderEditState?.isEdit ? "folderEditFailed" : "folderCreateFailed",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteFolder(folder: SnippetFolder) {
|
||||
try {
|
||||
await client.deleteFolder(folder.name);
|
||||
toast.success(t("folderDeleteSuccess"));
|
||||
await load();
|
||||
} catch {
|
||||
toast.error(t("folderDeleteFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy(snippet: Snippet) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(snippet.content);
|
||||
toast.success(t("copySuccess"));
|
||||
} catch {
|
||||
// Clipboard access denied; nothing further to do.
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
try {
|
||||
const data = await client.export();
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
||||
type: "application/json",
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "snippets-export.json";
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch {
|
||||
// Export failed silently; nothing to recover client-side.
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport(file: File) {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text);
|
||||
await client.bulkImport({
|
||||
snippets: data.snippets,
|
||||
folders: data.folders,
|
||||
overwrite: false,
|
||||
});
|
||||
await load();
|
||||
} catch {
|
||||
toast.error(t("importFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!canView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const folderKeys = Array.from(grouped.keys()).sort((a, b) => {
|
||||
if (a === ROOT_FOLDER) return -1;
|
||||
if (b === ROOT_FOLDER) return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-h-0 flex-col">
|
||||
<div className="flex items-center gap-2 p-2 border-b border-border">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
className="pl-7 h-8"
|
||||
value={search}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title={t("newFolder")}
|
||||
onClick={() =>
|
||||
setFolderEditState({
|
||||
name: "",
|
||||
color: null,
|
||||
icon: null,
|
||||
isEdit: false,
|
||||
})
|
||||
}
|
||||
>
|
||||
<FolderPlus className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
title={t("newSnippet")}
|
||||
onClick={() => setEditState({ ...EMPTY_EDIT })}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border text-xs">
|
||||
<Button size="sm" variant="ghost" onClick={handleExport}>
|
||||
<Download className="size-3.5 mr-1" />
|
||||
{t("importExport")}
|
||||
</Button>
|
||||
<label className="inline-flex">
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) void handleImport(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
<Button size="sm" variant="ghost" asChild>
|
||||
<span>
|
||||
<Upload className="size-3.5 mr-1" />
|
||||
{t("importExport")}
|
||||
</span>
|
||||
</Button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="p-4 text-xs text-muted-foreground">
|
||||
{t("loading")}
|
||||
</div>
|
||||
) : (
|
||||
folderKeys.map((folderKey) => {
|
||||
const items = grouped.get(folderKey) ?? [];
|
||||
const folder = folders.find((f) => f.name === folderKey);
|
||||
const isRoot = folderKey === ROOT_FOLDER;
|
||||
const collapsed = isCollapsed(folderKey);
|
||||
|
||||
return (
|
||||
<div key={folderKey}>
|
||||
{!isRoot && (
|
||||
<div className="flex items-center justify-between px-2 py-1.5 hover:bg-muted/40 group">
|
||||
<button
|
||||
className="flex items-center gap-1.5 flex-1 text-left text-xs font-semibold"
|
||||
onClick={() => toggleFolder(folderKey)}
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="size-3.5" />
|
||||
) : (
|
||||
<ChevronDown className="size-3.5" />
|
||||
)}
|
||||
<Folder className="size-3.5" />
|
||||
{folderKey}
|
||||
<Badge variant="outline" className="ml-1 text-[10px]">
|
||||
{items.length}
|
||||
</Badge>
|
||||
</button>
|
||||
{canEdit && folder && (
|
||||
<div className="hidden group-hover:flex items-center gap-1">
|
||||
<button
|
||||
onClick={() =>
|
||||
setFolderEditState({
|
||||
name: folder.name,
|
||||
color: folder.color,
|
||||
icon: folder.icon,
|
||||
isEdit: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={() => void handleDeleteFolder(folder)}
|
||||
>
|
||||
<Trash2 className="size-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!collapsed && items.length === 0 && (
|
||||
<div className="px-4 py-2 text-xs text-muted-foreground">
|
||||
{t("noSnippetsInFolder")}
|
||||
</div>
|
||||
)}
|
||||
{!collapsed &&
|
||||
items.map((snippet) => (
|
||||
<div
|
||||
key={snippet.id}
|
||||
className="flex items-center justify-between gap-2 px-3 py-1.5 hover:bg-muted/40 group"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5 text-xs font-medium truncate">
|
||||
{snippet.name}
|
||||
{snippet.isShared && (
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{t("shareTitle")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{snippet.description && (
|
||||
<div className="text-[11px] text-muted-foreground truncate">
|
||||
{snippet.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100">
|
||||
{!snippet.isNote && (
|
||||
<button
|
||||
title={t("run")}
|
||||
onClick={() => runOnActive(snippet, null)}
|
||||
>
|
||||
<Play className="size-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{snippet.isNote && (
|
||||
<button
|
||||
title={t("pasteToTerminal")}
|
||||
onClick={() => runOnActive(snippet, null)}
|
||||
>
|
||||
<Copy className="size-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
title={t("copySuccess")}
|
||||
onClick={() => void handleCopy(snippet)}
|
||||
>
|
||||
<Copy className="size-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
{canEdit && !snippet.isShared && (
|
||||
<button
|
||||
title={t("editSnippetTitle")}
|
||||
onClick={() =>
|
||||
setEditState({
|
||||
id: snippet.id,
|
||||
name: snippet.name,
|
||||
content: snippet.content,
|
||||
description: snippet.description ?? "",
|
||||
folder: snippet.folder ?? "",
|
||||
isNote: snippet.isNote,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Pencil className="size-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{!snippet.isShared && (
|
||||
<button title={t("shareTitle")}>
|
||||
<Share2 className="size-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{canDelete && !snippet.isShared && (
|
||||
<button
|
||||
title={t("deleteFailed")}
|
||||
onClick={() => void handleDeleteSnippet(snippet)}
|
||||
>
|
||||
<Trash2 className="size-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-2 py-1.5 border-t border-border text-xs">
|
||||
<span>{t("foldersCollapsedLabel")}</span>
|
||||
<Switch
|
||||
checked={defaultCollapsed}
|
||||
onCheckedChange={(checked) => {
|
||||
setCollapsedFolders(new Set());
|
||||
void collapsedSetting.save({ foldersCollapsed: checked });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{editState && (
|
||||
<SnippetEditDialog
|
||||
open
|
||||
initial={editState}
|
||||
folders={folders}
|
||||
onClose={() => setEditState(null)}
|
||||
onSave={handleSaveSnippet}
|
||||
/>
|
||||
)}
|
||||
{folderEditState && (
|
||||
<FolderEditDialog
|
||||
open
|
||||
initial={folderEditState}
|
||||
onClose={() => setFolderEditState(null)}
|
||||
onSave={handleSaveFolder}
|
||||
/>
|
||||
)}
|
||||
{runnerDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Play } from "lucide-react";
|
||||
import type { PanelProps, TermixApp } from "@termix/plugin-sdk/frontend";
|
||||
import { SnippetsPanel } from "./SnippetsPanel";
|
||||
import { createSnippetsApi } from "./snippets-api";
|
||||
import { resolveSnippetContent, hasSnippetInputs } from "./snippet-variables";
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
app.registerRailItem({
|
||||
id: "snippets",
|
||||
icon: Play,
|
||||
titleKey: "nav.snippets",
|
||||
permission: "view",
|
||||
});
|
||||
|
||||
app.registerPanel("snippets", (props: PanelProps) => (
|
||||
<SnippetsPanel {...props} />
|
||||
));
|
||||
|
||||
app.registerPaletteEntry({
|
||||
id: "snippets.run",
|
||||
titleKey: "nav.snippets",
|
||||
scope: "global",
|
||||
icon: Play,
|
||||
run: async (shell) => {
|
||||
shell.openRailView("snippets");
|
||||
},
|
||||
});
|
||||
|
||||
app.registerSlotContribution("onboarding.features", {
|
||||
actionId: "snippets.feature",
|
||||
titleKey: "onboarding.feature_snippets",
|
||||
descriptionKey: "onboarding.feature_snippets_desc",
|
||||
icon: Play,
|
||||
});
|
||||
|
||||
// Resolves a snippet by id for core's terminal (startup snippet, custom
|
||||
// keybindings), so core never imports snippet code or data directly.
|
||||
app.registerAction("snippets.resolveForTerminal", (async (
|
||||
snippetId: number,
|
||||
host: {
|
||||
ip?: string;
|
||||
username?: string;
|
||||
port?: number;
|
||||
name?: string;
|
||||
} | null,
|
||||
inputValues?: Record<string, string>,
|
||||
) => {
|
||||
const api = createSnippetsApi(app.api);
|
||||
const snippet = await api.get(snippetId).catch(() => null);
|
||||
if (!snippet) return null;
|
||||
if (
|
||||
hasSnippetInputs(snippet.content) &&
|
||||
(!inputValues || Object.keys(inputValues).length === 0)
|
||||
) {
|
||||
return {
|
||||
needsInputs: true,
|
||||
content: snippet.content,
|
||||
isNote: snippet.isNote,
|
||||
};
|
||||
}
|
||||
return {
|
||||
needsInputs: false,
|
||||
content: resolveSnippetContent(snippet.content, host, inputValues ?? {}),
|
||||
isNote: snippet.isNote,
|
||||
};
|
||||
}) as never);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
export interface SnippetInput {
|
||||
key: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface SnippetHostContext {
|
||||
ip?: string;
|
||||
username?: string;
|
||||
port?: number | string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
const INPUT_PATTERN =
|
||||
/\$\{INPUT_(\d+)(?::([^}$]+))?\}|\$INPUT_(\d+)(?![a-zA-Z0-9_])/g;
|
||||
|
||||
export function extractSnippetInputs(content: string): SnippetInput[] {
|
||||
const seen = new Map<string, SnippetInput>();
|
||||
let match: RegExpExecArray | null;
|
||||
INPUT_PATTERN.lastIndex = 0;
|
||||
while ((match = INPUT_PATTERN.exec(content)) !== null) {
|
||||
const digits = match[1] ?? match[3];
|
||||
const key = `INPUT_${digits}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.set(key, { key, label: match[2]?.trim() || `Input ${digits}` });
|
||||
}
|
||||
}
|
||||
return Array.from(seen.values());
|
||||
}
|
||||
|
||||
export function hasSnippetInputs(content: string): boolean {
|
||||
INPUT_PATTERN.lastIndex = 0;
|
||||
return INPUT_PATTERN.test(content);
|
||||
}
|
||||
|
||||
function replaceVar(
|
||||
content: string,
|
||||
name: string,
|
||||
value: string | undefined,
|
||||
): string {
|
||||
if (value === undefined) return content;
|
||||
const pattern = new RegExp(`\\$\\{?${name}\\}?`, "g");
|
||||
return content.replace(pattern, value);
|
||||
}
|
||||
|
||||
export function resolveSnippetContent(
|
||||
content: string,
|
||||
host: SnippetHostContext | null,
|
||||
inputValues: Record<string, string> = {},
|
||||
): string {
|
||||
let resolved = content;
|
||||
|
||||
resolved = replaceVar(resolved, "HOST", host?.ip);
|
||||
resolved = replaceVar(resolved, "USER", host?.username);
|
||||
resolved = replaceVar(
|
||||
resolved,
|
||||
"PORT",
|
||||
host?.port !== undefined ? String(host.port) : undefined,
|
||||
);
|
||||
resolved = replaceVar(resolved, "NAME", host?.name);
|
||||
|
||||
resolved = resolved.replace(
|
||||
INPUT_PATTERN,
|
||||
(
|
||||
fullMatch,
|
||||
braceDigits: string | undefined,
|
||||
_label,
|
||||
plainDigits: string | undefined,
|
||||
) => {
|
||||
const key = `INPUT_${braceDigits ?? plainDigits}`;
|
||||
return key in inputValues ? inputValues[key] : fullMatch;
|
||||
},
|
||||
);
|
||||
|
||||
return resolved;
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import type { Snippet, SnippetAccessEntry, SnippetFolder } from "./types";
|
||||
|
||||
export interface NewSnippetInput {
|
||||
name: string;
|
||||
content: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
order?: number | null;
|
||||
hostFilter?: unknown;
|
||||
isNote?: boolean;
|
||||
}
|
||||
|
||||
export interface SnippetUpdateInput {
|
||||
name?: string;
|
||||
content?: string;
|
||||
description?: string | null;
|
||||
folder?: string | null;
|
||||
order?: number;
|
||||
hostFilter?: unknown;
|
||||
isNote?: boolean;
|
||||
}
|
||||
|
||||
export interface SnippetReorderUpdate {
|
||||
id: number;
|
||||
order: number;
|
||||
folder?: string;
|
||||
}
|
||||
|
||||
export interface SnippetExecutionResult {
|
||||
success: boolean;
|
||||
output: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SnippetShareInput {
|
||||
targetType: "user" | "role";
|
||||
targetUserId?: string;
|
||||
targetRoleId?: number;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The snippets routes, through the plugin's own client. Paths are relative
|
||||
* to /plugin-api/snippets/, which the client already points at.
|
||||
*/
|
||||
export function createSnippetsApi(api: PluginApiClient) {
|
||||
const data = async <T>(request: Promise<{ data: T }>) => (await request).data;
|
||||
|
||||
return {
|
||||
list: () => data(api.get<Snippet[]>("/")),
|
||||
get: (id: number) => data(api.get<Snippet>(`/${id}`)),
|
||||
create: (body: NewSnippetInput) => data(api.post<Snippet>("/", body)),
|
||||
update: (id: number, body: SnippetUpdateInput) =>
|
||||
data(api.put<Snippet>(`/${id}`, body)),
|
||||
remove: (id: number) => data(api.delete<{ success: boolean }>(`/${id}`)),
|
||||
execute: (
|
||||
snippetId: number,
|
||||
hostId: number,
|
||||
inputValues?: Record<string, string>,
|
||||
) =>
|
||||
data(
|
||||
api.post<SnippetExecutionResult>("/execute", {
|
||||
snippetId,
|
||||
hostId,
|
||||
inputValues,
|
||||
}),
|
||||
),
|
||||
reorder: (updates: SnippetReorderUpdate[]) =>
|
||||
data(api.put<{ success: boolean }>("/reorder", { snippets: updates })),
|
||||
export: () =>
|
||||
data(api.get<{ snippets: unknown[]; folders: unknown[] }>("/export")),
|
||||
bulkImport: (body: {
|
||||
snippets?: unknown[];
|
||||
folders?: unknown[];
|
||||
overwrite?: boolean;
|
||||
}) => data(api.post<{ success: boolean }>("/bulk-import", body)),
|
||||
|
||||
listFolders: () => data(api.get<SnippetFolder[]>("/folders")),
|
||||
createFolder: (body: {
|
||||
name: string;
|
||||
color?: string | null;
|
||||
icon?: string | null;
|
||||
}) => data(api.post<SnippetFolder>("/folders", body)),
|
||||
updateFolderMetadata: (
|
||||
name: string,
|
||||
body: { color?: string | null; icon?: string | null },
|
||||
) =>
|
||||
data(
|
||||
api.put<SnippetFolder>(
|
||||
`/folders/${encodeURIComponent(name)}/metadata`,
|
||||
body,
|
||||
),
|
||||
),
|
||||
renameFolder: (oldName: string, newName: string) =>
|
||||
data(
|
||||
api.put<{ success: boolean }>("/folders/rename", { oldName, newName }),
|
||||
),
|
||||
deleteFolder: (name: string) =>
|
||||
data(
|
||||
api.delete<{ success: boolean }>(
|
||||
`/folders/${encodeURIComponent(name)}`,
|
||||
),
|
||||
),
|
||||
|
||||
share: (snippetId: number, body: SnippetShareInput) =>
|
||||
data(api.post<{ success: boolean }>(`/${snippetId}/share`, body)),
|
||||
shareFolder: (folder: string, body: SnippetShareInput) =>
|
||||
data(
|
||||
api.put<{ success: boolean; snippetsShared: number }>("/folder/share", {
|
||||
folder,
|
||||
...body,
|
||||
}),
|
||||
),
|
||||
getAccess: (snippetId: number) =>
|
||||
data(api.get<SnippetAccessEntry[]>(`/${snippetId}/access`)),
|
||||
revokeAccess: (snippetId: number, accessId: number) =>
|
||||
data(
|
||||
api.delete<{ success: boolean }>(`/${snippetId}/access/${accessId}`),
|
||||
),
|
||||
listShared: () => data(api.get<{ sharedSnippets: Snippet[] }>("/shared")),
|
||||
};
|
||||
}
|
||||
|
||||
export type SnippetsApi = ReturnType<typeof createSnippetsApi>;
|
||||
@@ -0,0 +1,71 @@
|
||||
export const FOLDER_ICONS = [
|
||||
"folder",
|
||||
"server",
|
||||
"cloud",
|
||||
"database",
|
||||
"box",
|
||||
"network",
|
||||
"copy",
|
||||
"settings",
|
||||
"cpu",
|
||||
"globe",
|
||||
] as const;
|
||||
export type FolderIconId = (typeof FOLDER_ICONS)[number];
|
||||
|
||||
export interface Snippet {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
content: string;
|
||||
description: string | null;
|
||||
folder: string | null;
|
||||
order: number;
|
||||
hostFilter: string | null;
|
||||
isNote: boolean;
|
||||
isShared?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SnippetFolder {
|
||||
id: number;
|
||||
userId: string;
|
||||
name: string;
|
||||
color: string | null;
|
||||
icon: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface SnippetAccessEntry {
|
||||
id: number;
|
||||
targetType: "user" | "role";
|
||||
userId: string | null;
|
||||
roleId: number | null;
|
||||
username: string | null;
|
||||
roleName: string | null;
|
||||
roleDisplayName: string | null;
|
||||
grantedBy: string;
|
||||
grantedByUsername: string | null;
|
||||
permissionLevel: string;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown, fallback: string): string {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"response" in error &&
|
||||
error.response &&
|
||||
typeof error.response === "object" &&
|
||||
"data" in error.response &&
|
||||
error.response.data &&
|
||||
typeof error.response.data === "object" &&
|
||||
"error" in error.response.data &&
|
||||
typeof error.response.data.error === "string"
|
||||
) {
|
||||
return error.response.data.error;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation, invokeAction } from "@termix/plugin-sdk/frontend";
|
||||
import { toast } from "sonner";
|
||||
import { SnippetVariablesDialog } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
hasSnippetInputs,
|
||||
resolveSnippetContent,
|
||||
type SnippetHostContext,
|
||||
} from "./snippet-variables";
|
||||
import type { Snippet } from "./types";
|
||||
|
||||
export interface RunTarget {
|
||||
sessionId: string;
|
||||
host: SnippetHostContext | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared "run this snippet against these terminal sessions" flow: resolves
|
||||
* $HOST-style vars per target, prompts once for $INPUT_n placeholders when
|
||||
* present, and gates on the confirm-before-running setting. Sends through
|
||||
* the ssh-terminal plugin's terminal.sendToActive/sendToSession actions
|
||||
* rather than touching a terminal ref directly, since core's tab state is
|
||||
* not part of the plugin surface.
|
||||
*/
|
||||
export function useSnippetRunner() {
|
||||
const { t } = useTranslation();
|
||||
const [runningSnippet, setRunningSnippet] = useState<{
|
||||
snippet: Snippet;
|
||||
host: SnippetHostContext | null;
|
||||
onConfirm: (
|
||||
resolvedContent: string,
|
||||
inputValues: Record<string, string>,
|
||||
) => void;
|
||||
} | null>(null);
|
||||
|
||||
const handleConfirmRun = useCallback(
|
||||
(snippet: Snippet, execute: () => void) => {
|
||||
const shouldConfirm =
|
||||
localStorage.getItem("confirmSnippetExecution") === "true";
|
||||
if (!shouldConfirm) {
|
||||
execute();
|
||||
return;
|
||||
}
|
||||
toast(t("confirmRunMessage", { name: snippet.name }), {
|
||||
action: {
|
||||
label: t("confirmRunButton"),
|
||||
onClick: execute,
|
||||
},
|
||||
duration: 6000,
|
||||
});
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
async function sendResolvedToTarget(
|
||||
target: RunTarget,
|
||||
snippet: Snippet,
|
||||
inputValues: Record<string, string>,
|
||||
) {
|
||||
const content = resolveSnippetContent(
|
||||
snippet.content,
|
||||
target.host,
|
||||
inputValues,
|
||||
);
|
||||
const run = !snippet.isNote;
|
||||
await invokeAction("terminal.sendToSession", target.sessionId, content, {
|
||||
run,
|
||||
});
|
||||
}
|
||||
|
||||
const runSnippet = useCallback(
|
||||
(snippet: Snippet, targets: RunTarget[]) => {
|
||||
const runWithInputs = (inputValues: Record<string, string>) => {
|
||||
const doSend = () => {
|
||||
Promise.all(
|
||||
targets.map((target) =>
|
||||
sendResolvedToTarget(target, snippet, inputValues),
|
||||
),
|
||||
)
|
||||
.then(() => {
|
||||
toast.success(
|
||||
t(snippet.isNote ? "pasteSuccess" : "runSuccess", {
|
||||
name: snippet.name,
|
||||
count: targets.length,
|
||||
}),
|
||||
);
|
||||
})
|
||||
.catch(() => {});
|
||||
};
|
||||
if (snippet.isNote) doSend();
|
||||
else handleConfirmRun(snippet, doSend);
|
||||
};
|
||||
|
||||
if (hasSnippetInputs(snippet.content)) {
|
||||
setRunningSnippet({
|
||||
snippet,
|
||||
host: targets[0]?.host ?? null,
|
||||
onConfirm: (_resolvedContent, inputValues) => {
|
||||
setRunningSnippet(null);
|
||||
runWithInputs(inputValues);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
runWithInputs({});
|
||||
}
|
||||
},
|
||||
[handleConfirmRun, t],
|
||||
);
|
||||
|
||||
const runOnActive = useCallback(
|
||||
(snippet: Snippet, host: SnippetHostContext | null) => {
|
||||
const runWithInputs = (inputValues: Record<string, string>) => {
|
||||
const doSend = () => {
|
||||
const content = resolveSnippetContent(
|
||||
snippet.content,
|
||||
host,
|
||||
inputValues,
|
||||
);
|
||||
void invokeAction("terminal.sendToActive", content, {
|
||||
run: !snippet.isNote,
|
||||
}).then((sent) => {
|
||||
if (sent === false) {
|
||||
toast.error(t("noTerminalTabsOpen"));
|
||||
return;
|
||||
}
|
||||
toast.success(
|
||||
t(snippet.isNote ? "pasteSuccess" : "runSuccess", {
|
||||
name: snippet.name,
|
||||
count: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
if (snippet.isNote) doSend();
|
||||
else handleConfirmRun(snippet, doSend);
|
||||
};
|
||||
|
||||
if (hasSnippetInputs(snippet.content)) {
|
||||
setRunningSnippet({
|
||||
snippet,
|
||||
host,
|
||||
onConfirm: (_resolvedContent, inputValues) => {
|
||||
setRunningSnippet(null);
|
||||
runWithInputs(inputValues);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
runWithInputs({});
|
||||
}
|
||||
},
|
||||
[handleConfirmRun, t],
|
||||
);
|
||||
|
||||
const dialog = runningSnippet ? (
|
||||
<SnippetVariablesDialog
|
||||
snippet={runningSnippet.snippet as never}
|
||||
host={runningSnippet.host}
|
||||
onCancel={() => setRunningSnippet(null)}
|
||||
onConfirm={runningSnippet.onConfirm}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return { runSnippet, runOnActive, handleConfirmRun, dialog };
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import express from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
import { manifest, pluginDir, startServer, type TestServer } from "./helpers";
|
||||
|
||||
let db: TestDb | null = null;
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
db?.close();
|
||||
db = null;
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("snippets activate", () => {
|
||||
it("registers snippets and snippetFolders sync entities at the legacy order values", async () => {
|
||||
server = await startServer();
|
||||
const types = server.mock.syncEntities.map((e) => e.type);
|
||||
expect(types).toContain("snippets");
|
||||
expect(types).toContain("snippetFolders");
|
||||
|
||||
const snippetsEntity = server.mock.syncEntities.find(
|
||||
(e) => e.type === "snippets",
|
||||
);
|
||||
const foldersEntity = server.mock.syncEntities.find(
|
||||
(e) => e.type === "snippetFolders",
|
||||
);
|
||||
expect(snippetsEntity?.order).toBe(60);
|
||||
expect(foldersEntity?.order).toBe(40);
|
||||
});
|
||||
|
||||
it("offers the snippets.access service, scoped to the calling user", async () => {
|
||||
server = await startServer();
|
||||
await server.request("POST", "/", {
|
||||
body: { name: "Deploy", content: "echo deploy" },
|
||||
});
|
||||
|
||||
const service = server.mock.services.get("snippets.access") as {
|
||||
list: () => Promise<unknown>;
|
||||
};
|
||||
const listed = await server.mock.ctx.asUser("user-1", () => service.list());
|
||||
expect(listed).toEqual([
|
||||
expect.objectContaining({ name: "Deploy", content: "echo deploy" }),
|
||||
]);
|
||||
|
||||
const otherUsersList = await server.mock.ctx.asUser("user-2", () =>
|
||||
service.list(),
|
||||
);
|
||||
expect(otherUsersList).toEqual([]);
|
||||
});
|
||||
|
||||
it("wipes a user's snippets when user.data_wiped fires", async () => {
|
||||
// events:core only to simulate core firing the topic in this test; the
|
||||
// plugin's own manifest never declares it, since it only subscribes.
|
||||
server = await startServer({
|
||||
capabilities: [...manifest.capabilities, "events:core"],
|
||||
});
|
||||
await server.request("POST", "/", {
|
||||
body: { name: "Deploy", content: "echo deploy" },
|
||||
});
|
||||
|
||||
const before = (await server.request("GET", "/")).body;
|
||||
expect(before).toHaveLength(1);
|
||||
|
||||
server.mock.ctx.events.emit("user.data_wiped", { userId: "user-1" });
|
||||
// The listener's delete is fire-and-forget; give its microtasks a turn.
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
const after = (await server.request("GET", "/")).body;
|
||||
expect(after).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("fails closed without db:own", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: [
|
||||
"network:serve",
|
||||
"ssh:connect",
|
||||
"credentials:use",
|
||||
"ui:surface",
|
||||
],
|
||||
db: db.database,
|
||||
router: () => express.Router(),
|
||||
});
|
||||
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails closed without network:serve", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: ["db:own", "ssh:connect", "credentials:use", "ui:surface"],
|
||||
db: db.database,
|
||||
router: () => express.Router(),
|
||||
});
|
||||
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import express from "express";
|
||||
import { EventEmitter } from "node:events";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import { manifest, pluginDir } from "./helpers";
|
||||
|
||||
let db: TestDb | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
function fakeSshClient(output: string, exitCode: number | null = 0) {
|
||||
const client = new EventEmitter() as EventEmitter & {
|
||||
exec: (
|
||||
command: string,
|
||||
cb: (err: Error | null, stream: unknown) => void,
|
||||
) => void;
|
||||
};
|
||||
client.exec = (command, cb) => {
|
||||
const stream = new EventEmitter() as EventEmitter & {
|
||||
stderr: EventEmitter;
|
||||
};
|
||||
stream.stderr = new EventEmitter();
|
||||
cb(null, stream);
|
||||
setImmediate(() => {
|
||||
stream.emit("data", Buffer.from(output));
|
||||
stream.emit("close", exitCode);
|
||||
});
|
||||
};
|
||||
return client;
|
||||
}
|
||||
|
||||
async function startExecuteServer(sshClient: unknown) {
|
||||
db = await createTestDb(pluginDir);
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES ('user-1', 'user-1')")
|
||||
.run();
|
||||
|
||||
let router: import("express").Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
sshClient,
|
||||
permissions: ["snippets.view", "snippets.create"],
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const http = await import("node:http");
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as import("node:net").AddressInfo;
|
||||
|
||||
return {
|
||||
async request(method: string, path: string, body?: unknown) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-test-user": "user-1",
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("POST /execute", () => {
|
||||
it("runs the resolved command over the ssh client and reports success", async () => {
|
||||
const client = fakeSshClient("deployed ok", 0);
|
||||
const server = await startExecuteServer(client);
|
||||
try {
|
||||
const created = await server.request("POST", "/", {
|
||||
name: "Deploy",
|
||||
content: "echo $HOST",
|
||||
});
|
||||
const result = await server.request("POST", "/execute", {
|
||||
snippetId: created.body.id,
|
||||
hostId: 42,
|
||||
});
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual({ success: true, output: "deployed ok" });
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports failure from a nonzero exit code", async () => {
|
||||
const client = fakeSshClient("", 1);
|
||||
const server = await startExecuteServer(client);
|
||||
try {
|
||||
const created = await server.request("POST", "/", {
|
||||
name: "Fails",
|
||||
content: "exit 1",
|
||||
});
|
||||
const result = await server.request("POST", "/execute", {
|
||||
snippetId: created.body.id,
|
||||
hostId: 42,
|
||||
});
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body.success).toBe(false);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
createTestDb,
|
||||
type MockPluginContext,
|
||||
type TestDb,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export interface TestServer {
|
||||
db: TestDb;
|
||||
mock: MockPluginContext;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; body?: unknown },
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function startServer(
|
||||
options: {
|
||||
permissions?: string[];
|
||||
users?: string[];
|
||||
capabilities?: string[];
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const db = await createTestDb(pluginDir);
|
||||
for (const user of options.users ?? ["user-1", "user-2"]) {
|
||||
db.sqlite
|
||||
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
||||
.run(user, user);
|
||||
}
|
||||
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: options.capabilities ?? manifest.capabilities,
|
||||
db: db.database,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions ?? [
|
||||
"snippets.view",
|
||||
"snippets.create",
|
||||
"snippets.edit",
|
||||
"snippets.delete",
|
||||
"snippets.share",
|
||||
],
|
||||
});
|
||||
|
||||
const { activate } = await import("../../src/backend/index.js");
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
db,
|
||||
mock,
|
||||
async request(method, path, { user = "user-1", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import { pluginDir } from "./helpers";
|
||||
|
||||
// snippets / snippet_folders / snippet_access as core's schema.ts declared
|
||||
// them before 2.9.0, plus the indexes performance-indexes.ts added.
|
||||
const LEGACY_DDL = `
|
||||
CREATE TABLE snippets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
description TEXT,
|
||||
folder TEXT,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
sync_id TEXT UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
host_filter TEXT,
|
||||
is_note INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX idx_snippets_user_id ON snippets (user_id);
|
||||
|
||||
CREATE TABLE snippet_folders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
icon TEXT,
|
||||
sync_id TEXT UNIQUE,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE snippet_access (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
snippet_id INTEGER NOT NULL REFERENCES snippets(id) ON DELETE CASCADE,
|
||||
user_id TEXT REFERENCES users(id) ON DELETE CASCADE,
|
||||
role_id INTEGER REFERENCES roles(id) ON DELETE CASCADE,
|
||||
granted_by TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
permission_level TEXT NOT NULL DEFAULT 'view',
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE INDEX idx_snippet_access_user_id ON snippet_access (user_id);
|
||||
CREATE INDEX idx_snippet_access_snippet_id ON snippet_access (snippet_id);
|
||||
CREATE INDEX idx_snippet_access_role_id ON snippet_access (role_id);
|
||||
`;
|
||||
|
||||
let db: TestDb | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
const tableExists = (name: string) =>
|
||||
!!db!.sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(name);
|
||||
|
||||
const indexOn = (table: string) =>
|
||||
(
|
||||
db!.sqlite
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
|
||||
)
|
||||
.all(table) as { name: string }[]
|
||||
).map((row) => row.name);
|
||||
|
||||
describe("adopting snippets, snippet_folders and snippet_access", () => {
|
||||
it("keeps every existing row when the legacy tables are there", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
|
||||
sqlite.exec(
|
||||
"INSERT INTO roles (id, name, display_name) VALUES (7, 'ops', 'Operations')",
|
||||
);
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO snippets (id, user_id, name, content, folder, sync_id) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(99, "user-1", "Deploy", "echo deploy", "prod", "sync-snippet-a");
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO snippet_folders (user_id, name, sync_id) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run("user-1", "prod", "sync-folder-a");
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO snippet_access (snippet_id, user_id, granted_by) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run(99, "user-2", "user-1");
|
||||
},
|
||||
});
|
||||
|
||||
expect(db.applied).toEqual(["0001_adopt_snippets_tables"]);
|
||||
expect(tableExists("snippets")).toBe(false);
|
||||
expect(tableExists("snippet_folders")).toBe(false);
|
||||
expect(tableExists("snippet_access")).toBe(false);
|
||||
expect(tableExists("p_snippets_snippets")).toBe(true);
|
||||
expect(tableExists("p_snippets_snippet_folders")).toBe(true);
|
||||
expect(tableExists("p_snippets_snippet_access")).toBe(true);
|
||||
|
||||
const snippetRows = db.sqlite
|
||||
.prepare(
|
||||
"SELECT user_id, name, content, folder, sync_id FROM p_snippets_snippets",
|
||||
)
|
||||
.all();
|
||||
expect(snippetRows).toEqual([
|
||||
{
|
||||
user_id: "user-1",
|
||||
name: "Deploy",
|
||||
content: "echo deploy",
|
||||
folder: "prod",
|
||||
sync_id: "sync-snippet-a",
|
||||
},
|
||||
]);
|
||||
|
||||
const folderRows = db.sqlite
|
||||
.prepare("SELECT user_id, name, sync_id FROM p_snippets_snippet_folders")
|
||||
.all();
|
||||
expect(folderRows).toEqual([
|
||||
{ user_id: "user-1", name: "prod", sync_id: "sync-folder-a" },
|
||||
]);
|
||||
|
||||
const accessRows = db.sqlite
|
||||
.prepare(
|
||||
"SELECT snippet_id, user_id, granted_by FROM p_snippets_snippet_access",
|
||||
)
|
||||
.all();
|
||||
expect(accessRows).toEqual([
|
||||
{ snippet_id: 99, user_id: "user-2", granted_by: "user-1" },
|
||||
]);
|
||||
|
||||
expect(indexOn("p_snippets_snippets")).toEqual(["idx_snippets_user_id"]);
|
||||
expect(indexOn("p_snippets_snippet_access").sort()).toEqual(
|
||||
[
|
||||
"idx_snippet_access_user_id",
|
||||
"idx_snippet_access_snippet_id",
|
||||
"idx_snippet_access_role_id",
|
||||
].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates the tables fresh when there is no legacy one", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
|
||||
expect(tableExists("snippets")).toBe(false);
|
||||
expect(tableExists("p_snippets_snippets")).toBe(true);
|
||||
expect(tableExists("p_snippets_snippet_folders")).toBe(true);
|
||||
expect(tableExists("p_snippets_snippet_access")).toBe(true);
|
||||
expect(indexOn("p_snippets_snippets")).toEqual(["idx_snippets_user_id"]);
|
||||
expect(
|
||||
db.sqlite.prepare("SELECT * FROM p_snippets_snippets").all(),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("cascades deletes when the owning user is removed", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
|
||||
},
|
||||
});
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"INSERT INTO p_snippets_snippets (user_id, name, content) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run("user-1", "Mine", "echo mine");
|
||||
db.sqlite
|
||||
.prepare(
|
||||
"INSERT INTO p_snippets_snippets (user_id, name, content) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run("user-2", "Theirs", "echo theirs");
|
||||
|
||||
db.sqlite.exec("DELETE FROM users WHERE id = 'user-1'");
|
||||
|
||||
const names = (
|
||||
db.sqlite.prepare("SELECT name FROM p_snippets_snippets").all() as {
|
||||
name: string;
|
||||
}[]
|
||||
).map((row) => row.name);
|
||||
expect(names).toEqual(["Theirs"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,186 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import {
|
||||
snippetAccess,
|
||||
snippetFolders,
|
||||
snippets,
|
||||
} from "../../src/backend/tables.js";
|
||||
import {
|
||||
createSnippetRepository,
|
||||
type SnippetRepository,
|
||||
} from "../../src/backend/repository.js";
|
||||
import { pluginDir } from "./helpers";
|
||||
|
||||
let db: TestDb;
|
||||
let repo: SnippetRepository;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec("INSERT INTO users (id) VALUES ('user-1'), ('user-2')");
|
||||
sqlite.exec(
|
||||
"INSERT INTO roles (id, name, display_name) VALUES (7, 'ops', 'Operations')",
|
||||
);
|
||||
},
|
||||
});
|
||||
repo = createSnippetRepository(
|
||||
db.database,
|
||||
await db.database.define(snippets),
|
||||
await db.database.define(snippetFolders),
|
||||
await db.database.define(snippetAccess),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => db.close());
|
||||
|
||||
describe("snippet repository", () => {
|
||||
it("reads a new row back by its own sync id", async () => {
|
||||
const a = await repo.createSnippet("user-1", {
|
||||
name: "A",
|
||||
content: "echo a",
|
||||
});
|
||||
const b = await repo.createSnippet("user-1", {
|
||||
name: "B",
|
||||
content: "echo b",
|
||||
});
|
||||
expect(a.name).toBe("A");
|
||||
expect(b.name).toBe("B");
|
||||
expect(a.syncId).not.toBe(b.syncId);
|
||||
});
|
||||
|
||||
it("auto-assigns the next order within a folder", async () => {
|
||||
await repo.createSnippet("user-1", {
|
||||
name: "A",
|
||||
content: "a",
|
||||
folder: "prod",
|
||||
});
|
||||
const second = await repo.createSnippet("user-1", {
|
||||
name: "B",
|
||||
content: "b",
|
||||
folder: "prod",
|
||||
});
|
||||
const rootLevel = await repo.createSnippet("user-1", {
|
||||
name: "C",
|
||||
content: "c",
|
||||
});
|
||||
expect(second.order).toBe(1);
|
||||
expect(rootLevel.order).toBe(0);
|
||||
});
|
||||
|
||||
it("renames a folder and re-points its snippets, refusing a name collision", async () => {
|
||||
await repo.createFolder("user-1", "prod", null, null);
|
||||
await repo.createFolder("user-1", "staging", null, null);
|
||||
const snippet = await repo.createSnippet("user-1", {
|
||||
name: "A",
|
||||
content: "a",
|
||||
folder: "prod",
|
||||
});
|
||||
|
||||
const conflict = await repo.renameFolder("user-1", "prod", "staging");
|
||||
expect(conflict.status).toBe("conflict");
|
||||
|
||||
const missing = await repo.renameFolder("user-1", "ghost", "new");
|
||||
expect(missing.status).toBe("missing");
|
||||
|
||||
const renamed = await repo.renameFolder("user-1", "prod", "production");
|
||||
expect(renamed.status).toBe("renamed");
|
||||
|
||||
const updated = await repo.findOwnedById("user-1", snippet.id);
|
||||
expect(updated?.folder).toBe("production");
|
||||
});
|
||||
|
||||
it("moves snippets to the root when their folder is deleted", async () => {
|
||||
await repo.createFolder("user-1", "prod", null, null);
|
||||
const snippet = await repo.createSnippet("user-1", {
|
||||
name: "A",
|
||||
content: "a",
|
||||
folder: "prod",
|
||||
});
|
||||
|
||||
const result = await repo.deleteFolder("user-1", "prod");
|
||||
expect(result).not.toBeNull();
|
||||
|
||||
const updated = await repo.findOwnedById("user-1", snippet.id);
|
||||
expect(updated?.folder).toBeNull();
|
||||
});
|
||||
|
||||
it("bulk imports, skipping existing snippets unless overwrite is set", async () => {
|
||||
await repo.createSnippet("user-1", { name: "A", content: "old" });
|
||||
|
||||
const skipped = await repo.bulkImport(
|
||||
"user-1",
|
||||
[{ name: "A", content: "new" }],
|
||||
[],
|
||||
false,
|
||||
);
|
||||
expect(skipped.snippetsSkipped).toBe(1);
|
||||
expect(skipped.snippetsImported).toBe(0);
|
||||
|
||||
const overwritten = await repo.bulkImport(
|
||||
"user-1",
|
||||
[{ name: "A", content: "new" }],
|
||||
[],
|
||||
true,
|
||||
);
|
||||
expect(overwritten.snippetsUpdated).toBe(1);
|
||||
|
||||
const list = await repo.listOwnedSnippets("user-1");
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].content).toBe("new");
|
||||
});
|
||||
|
||||
it("shares a snippet and lists it for the recipient, then revokes it", async () => {
|
||||
const snippet = await repo.createSnippet("user-1", {
|
||||
name: "A",
|
||||
content: "a",
|
||||
});
|
||||
|
||||
const created = await repo.upsertSnippetAccess({
|
||||
snippetId: snippet.id,
|
||||
targetType: "user",
|
||||
targetUserId: "user-2",
|
||||
grantedBy: "user-1",
|
||||
expiresAt: null,
|
||||
});
|
||||
expect(created.created).toBe(true);
|
||||
|
||||
const again = await repo.upsertSnippetAccess({
|
||||
snippetId: snippet.id,
|
||||
targetType: "user",
|
||||
targetUserId: "user-2",
|
||||
grantedBy: "user-1",
|
||||
expiresAt: "2099-01-01T00:00:00.000Z",
|
||||
});
|
||||
expect(again.created).toBe(false);
|
||||
expect(again.id).toBe(created.id);
|
||||
|
||||
const roleIds = await repo.listUserRoleIds("user-2");
|
||||
const shared = await repo.listSharedSnippets("user-2", roleIds);
|
||||
expect(shared).toEqual([
|
||||
expect.objectContaining({ id: snippet.id, name: "A" }),
|
||||
]);
|
||||
|
||||
const accessible = await repo.findAccessibleSharedSnippet(
|
||||
snippet.id,
|
||||
"user-2",
|
||||
roleIds,
|
||||
);
|
||||
expect(accessible?.id).toBe(snippet.id);
|
||||
|
||||
await repo.revokeSnippetAccess(created.id, snippet.id);
|
||||
const afterRevoke = await repo.listSharedSnippets("user-2", roleIds);
|
||||
expect(afterRevoke).toEqual([]);
|
||||
});
|
||||
|
||||
it("wipes a user's snippets and folders on deleteByUserId", async () => {
|
||||
await repo.createSnippet("user-1", { name: "A", content: "a" });
|
||||
await repo.createFolder("user-1", "prod", null, null);
|
||||
await repo.createSnippet("user-2", { name: "B", content: "b" });
|
||||
|
||||
await repo.deleteByUserId("user-1");
|
||||
|
||||
expect(await repo.listOwnedSnippets("user-1")).toEqual([]);
|
||||
expect(await repo.listFolders("user-1")).toEqual([]);
|
||||
expect(await repo.listOwnedSnippets("user-2")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { startServer, type TestServer } from "./helpers";
|
||||
|
||||
let server: TestServer;
|
||||
|
||||
beforeEach(async () => {
|
||||
server = await startServer();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
async function create(
|
||||
overrides: Record<string, unknown> = {},
|
||||
user = "user-1",
|
||||
) {
|
||||
const res = await server.request("POST", "/", {
|
||||
user,
|
||||
body: { name: "Deploy", content: "echo deploy", ...overrides },
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
return res.body;
|
||||
}
|
||||
|
||||
describe("snippet routes", () => {
|
||||
it("creates and lists snippets, tagging owned ones as not shared", async () => {
|
||||
const created = await create();
|
||||
expect(created).toMatchObject({ name: "Deploy", content: "echo deploy" });
|
||||
expect(created.syncId).toEqual(expect.any(String));
|
||||
|
||||
const list = await server.request("GET", "/");
|
||||
expect(list.body).toEqual([
|
||||
expect.objectContaining({ id: created.id, isShared: false }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a missing name or content", async () => {
|
||||
expect(
|
||||
(await server.request("POST", "/", { body: { content: "x" } })).status,
|
||||
).toBe(400);
|
||||
expect(
|
||||
(await server.request("POST", "/", { body: { name: "x" } })).status,
|
||||
).toBe(400);
|
||||
});
|
||||
|
||||
it("gets, updates and deletes a snippet", async () => {
|
||||
const created = await create();
|
||||
|
||||
const got = await server.request("GET", `/${created.id}`);
|
||||
expect(got.status).toBe(200);
|
||||
expect(got.body.id).toBe(created.id);
|
||||
|
||||
const updated = await server.request("PUT", `/${created.id}`, {
|
||||
body: { content: "echo updated" },
|
||||
});
|
||||
expect(updated.status).toBe(200);
|
||||
expect(updated.body.content).toBe("echo updated");
|
||||
|
||||
const deleted = await server.request("DELETE", `/${created.id}`);
|
||||
expect(deleted.status).toBe(200);
|
||||
expect((await server.request("GET", `/${created.id}`)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("keeps snippets isolated per user", async () => {
|
||||
await create({ name: "Owner snippet" }, "user-1");
|
||||
const list = await server.request("GET", "/", { user: "user-2" });
|
||||
expect(list.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates, renames and deletes a folder, moving its snippets to the root", async () => {
|
||||
const folder = await server.request("POST", "/folders", {
|
||||
body: { name: "prod", color: "#fff", icon: "server" },
|
||||
});
|
||||
expect(folder.status).toBe(201);
|
||||
|
||||
const conflict = await server.request("POST", "/folders", {
|
||||
body: { name: "prod" },
|
||||
});
|
||||
expect(conflict.status).toBe(409);
|
||||
|
||||
const rename = await server.request("PUT", "/folders/rename", {
|
||||
body: { oldName: "prod", newName: "production" },
|
||||
});
|
||||
expect(rename.status).toBe(200);
|
||||
|
||||
const snippet = await create({ folder: "production" });
|
||||
expect(snippet.folder).toBe("production");
|
||||
|
||||
const deleteFolder = await server.request("DELETE", "/folders/production");
|
||||
expect(deleteFolder.status).toBe(200);
|
||||
|
||||
const afterDelete = await server.request("GET", `/${snippet.id}`);
|
||||
expect(afterDelete.body.folder).toBeNull();
|
||||
});
|
||||
|
||||
it("reorders snippets, accepting both the snippets and legacy updates keys", async () => {
|
||||
const a = await create({ name: "a" });
|
||||
const b = await create({ name: "b" });
|
||||
|
||||
const reordered = await server.request("PUT", "/reorder", {
|
||||
body: {
|
||||
snippets: [
|
||||
{ id: a.id, order: 2 },
|
||||
{ id: b.id, order: 1 },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(reordered.status).toBe(200);
|
||||
|
||||
const legacy = await server.request("PUT", "/reorder", {
|
||||
body: { updates: [{ id: a.id, order: 5 }] },
|
||||
});
|
||||
expect(legacy.status).toBe(200);
|
||||
|
||||
expect((await server.request("PUT", "/reorder", { body: {} })).status).toBe(
|
||||
400,
|
||||
);
|
||||
});
|
||||
|
||||
it("exports and bulk-imports snippets and folders", async () => {
|
||||
await create({ name: "Exportable" });
|
||||
const exported = await server.request("GET", "/export");
|
||||
expect(exported.status).toBe(200);
|
||||
expect(exported.body.snippets).toHaveLength(1);
|
||||
|
||||
const imported = await server.request("POST", "/bulk-import", {
|
||||
body: {
|
||||
snippets: [{ name: "Imported", content: "echo hi" }],
|
||||
folders: [],
|
||||
overwrite: false,
|
||||
},
|
||||
});
|
||||
expect(imported.status).toBe(200);
|
||||
expect(imported.body.snippetsImported).toBe(1);
|
||||
});
|
||||
|
||||
it("shares a snippet with another user, who can then read it as shared", async () => {
|
||||
const snippet = await create();
|
||||
|
||||
const share = await server.request("POST", `/${snippet.id}/share`, {
|
||||
body: { targetType: "user", targetUserId: "user-2" },
|
||||
});
|
||||
expect(share.status).toBe(200);
|
||||
|
||||
const sharedList = await server.request("GET", "/shared", {
|
||||
user: "user-2",
|
||||
});
|
||||
expect(sharedList.body.sharedSnippets).toEqual([
|
||||
expect.objectContaining({ id: snippet.id, name: snippet.name }),
|
||||
]);
|
||||
|
||||
const visibleToOwner = await server.request("GET", `/${snippet.id}/access`);
|
||||
expect(visibleToOwner.status).toBe(200);
|
||||
expect(visibleToOwner.body).toHaveLength(1);
|
||||
|
||||
const revoke = await server.request(
|
||||
"DELETE",
|
||||
`/${snippet.id}/access/${visibleToOwner.body[0].id}`,
|
||||
);
|
||||
expect(revoke.status).toBe(200);
|
||||
|
||||
const afterRevoke = await server.request("GET", "/shared", {
|
||||
user: "user-2",
|
||||
});
|
||||
expect(afterRevoke.body.sharedSnippets).toEqual([]);
|
||||
});
|
||||
|
||||
it("refuses sharing a snippet the caller does not own", async () => {
|
||||
const snippet = await create({}, "user-1");
|
||||
const attempt = await server.request("POST", `/${snippet.id}/share`, {
|
||||
user: "user-2",
|
||||
body: { targetType: "user", targetUserId: "user-1" },
|
||||
});
|
||||
expect(attempt.status).toBe(403);
|
||||
});
|
||||
|
||||
it("shares every snippet in a folder at once", async () => {
|
||||
await create({ name: "s1", folder: "team" });
|
||||
await create({ name: "s2", folder: "team" });
|
||||
|
||||
const share = await server.request("PUT", "/folder/share", {
|
||||
body: { folder: "team", targetType: "user", targetUserId: "user-2" },
|
||||
});
|
||||
expect(share.status).toBe(200);
|
||||
expect(share.body.snippetsShared).toBe(2);
|
||||
});
|
||||
|
||||
it("denies every route without its permission", async () => {
|
||||
const denied = await startServer({ permissions: [] });
|
||||
try {
|
||||
expect((await denied.request("GET", "/")).status).toBe(403);
|
||||
expect((await denied.request("POST", "/", { body: {} })).status).toBe(
|
||||
403,
|
||||
);
|
||||
expect((await denied.request("GET", "/folders")).status).toBe(403);
|
||||
expect(
|
||||
(await denied.request("POST", "/folders", { body: { name: "x" } }))
|
||||
.status,
|
||||
).toBe(403);
|
||||
} finally {
|
||||
await denied.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("lets execute run without any snippets.* permission", async () => {
|
||||
const noPerms = await startServer({ permissions: [] });
|
||||
try {
|
||||
const res = await noPerms.request("POST", "/execute", {
|
||||
body: { snippetId: 999, hostId: 1 },
|
||||
});
|
||||
// 404 (no accessible snippet), not 403: execute needs no grant.
|
||||
expect(res.status).toBe(404);
|
||||
} finally {
|
||||
await noPerms.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects executing a note", async () => {
|
||||
const note = await server.request("POST", "/", {
|
||||
body: { name: "Note", content: "remember this", isNote: true },
|
||||
});
|
||||
const res = await server.request("POST", "/execute", {
|
||||
body: { snippetId: note.body.id, hostId: 1 },
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("404s executing a snippet the caller cannot see", async () => {
|
||||
const res = await server.request("POST", "/execute", {
|
||||
body: { snippetId: 999, hostId: 1 },
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
const declared = (views?: Array<{ id: string }>) =>
|
||||
(views ?? []).map((view) => view.id).sort();
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
describe(`${manifest.id} activate`, () => {
|
||||
it("activates and registers only views its manifest declares", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
const contributes = manifest.contributes as unknown as Record<
|
||||
string,
|
||||
Array<{ id: string }> | undefined
|
||||
>;
|
||||
const panels = declared(contributes.panels);
|
||||
for (const id of rendered.registered.panels()) expect(panels).toContain(id);
|
||||
});
|
||||
|
||||
it("registers the rail item gated on the view permission", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
const railItem = rendered.registered
|
||||
.railItems()
|
||||
.find((i) => i.id === "snippets");
|
||||
expect(railItem).toBeDefined();
|
||||
expect(railItem?.permission).toBe("snippets.view");
|
||||
});
|
||||
|
||||
it("registers the snippets.resolveForTerminal action", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.actions()).toContain(
|
||||
"snippets.resolveForTerminal",
|
||||
);
|
||||
});
|
||||
|
||||
it("removes everything it registered on deactivate", async () => {
|
||||
const app = await renderWithApp(plugin, { manifest, locales });
|
||||
await app.deactivate();
|
||||
expect(app.registered.panels()).toEqual([]);
|
||||
expect(app.registered.railItems()).toEqual([]);
|
||||
expect(app.registered.actions()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("snippets panel", () => {
|
||||
it("renders the empty state when the user has no snippets", async () => {
|
||||
const api = {
|
||||
get: vi.fn(async () => ({ data: [] })),
|
||||
};
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: api as never,
|
||||
permissions: ["snippets.view", "snippets.create"],
|
||||
});
|
||||
rendered.renderPanel("snippets", { active: true });
|
||||
await expect(screen.findByPlaceholderText(/search/i)).resolves.toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders nothing without the view permission", async () => {
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
permissions: [],
|
||||
});
|
||||
const panel = rendered.renderPanel("snippets", { active: true });
|
||||
expect(panel.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
extractSnippetInputs,
|
||||
hasSnippetInputs,
|
||||
resolveSnippetContent,
|
||||
} from "../../src/frontend/snippet-variables";
|
||||
|
||||
describe("extractSnippetInputs", () => {
|
||||
it("extracts a single unlabeled input", () => {
|
||||
expect(extractSnippetInputs("ping $INPUT_1")).toEqual([
|
||||
{ key: "INPUT_1", label: "Input 1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("extracts a labeled input using brace syntax", () => {
|
||||
expect(
|
||||
extractSnippetInputs("nc -zv $HOST ${INPUT_1:Port to check}"),
|
||||
).toEqual([{ key: "INPUT_1", label: "Port to check" }]);
|
||||
});
|
||||
|
||||
it("plain (non-braced) references have no label", () => {
|
||||
expect(extractSnippetInputs("echo $INPUT_2")).toEqual([
|
||||
{ key: "INPUT_2", label: "Input 2" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("de-duplicates repeated references and preserves first-seen order", () => {
|
||||
expect(
|
||||
extractSnippetInputs("$INPUT_2 then $INPUT_1 then $INPUT_2 again"),
|
||||
).toEqual([
|
||||
{ key: "INPUT_2", label: "Input 2" },
|
||||
{ key: "INPUT_1", label: "Input 1" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns an empty array when there are no placeholders", () => {
|
||||
expect(extractSnippetInputs("uptime")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasSnippetInputs", () => {
|
||||
it("returns true when a snippet has an input placeholder", () => {
|
||||
expect(hasSnippetInputs("echo $INPUT_1")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when a snippet has no input placeholder", () => {
|
||||
expect(hasSnippetInputs("echo $HOST")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSnippetContent", () => {
|
||||
const host = { ip: "10.0.0.5", username: "root", port: 22, name: "web-01" };
|
||||
|
||||
it("substitutes host variables", () => {
|
||||
expect(
|
||||
resolveSnippetContent("ssh $USER@$HOST -p $PORT # $NAME", host),
|
||||
).toBe("ssh root@10.0.0.5 -p 22 # web-01");
|
||||
});
|
||||
|
||||
it("supports brace syntax for host variables", () => {
|
||||
expect(resolveSnippetContent("ping ${HOST}", host)).toBe("ping 10.0.0.5");
|
||||
});
|
||||
|
||||
it("substitutes input placeholders", () => {
|
||||
expect(
|
||||
resolveSnippetContent("nc -zv $HOST ${INPUT_1:Port}", host, {
|
||||
INPUT_1: "8080",
|
||||
}),
|
||||
).toBe("nc -zv 10.0.0.5 8080");
|
||||
});
|
||||
|
||||
it("leaves host variables literal when no host context is given", () => {
|
||||
expect(resolveSnippetContent("ping $HOST", null)).toBe("ping $HOST");
|
||||
});
|
||||
|
||||
it("leaves input placeholders literal when no value was supplied", () => {
|
||||
expect(resolveSnippetContent("echo $INPUT_1", null)).toBe("echo $INPUT_1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
Reference in new issue
Block a user