Skip to content

Latest commit

 

History

History
764 lines (623 loc) · 30 KB

plugin-development.md

File metadata and controls

764 lines (623 loc) · 30 KB

YAET Plugin Development Guide

Overview

YAET uses a plugin system to manage connection types (SSH, Telnet, FTP, etc.). Each plugin provides:

  • Backend: IPC handler + runtime connector (Node.js, runs in Electron main process)
  • Frontend: profile form component (Web Component or Angular component)
  • Manifest: Metadata declaration (IPC channels, profile type, category)

Plugins live in two locations:

  • Bundled: plugins/<id>/ at the project root (shipped with the app)
  • External: ~/.yaet/plugins/<id>/ (user-installed)
    • Security: external plugins are disabled by default and must be explicitly enabled via pluginManager.enablePlugin(id) (stored in ~/.yaet/plugins/enabled.json)
    • Security: external plugins that conflict with bundled plugin IDs are skipped entirely
    • Security: external plugins run with npm install --ignore-scripts (blocks postinstall scripts)
    • Security: IPC is restricted — plugins can only register channels declared in their manifest
    • Security: secretService is filtered by secretTypes — plugins only access declared secret types
    • Security: projectRequire is whitelisted — only modules declared in manifest dependencies + Node.js builtins
    • Security: manifest SHA-256 integrity check; optional HMAC-SHA256 signature
    • Security: CSP removes unsafe-inline/unsafe-eval; frontend loaded via blob URL

See ext-plugins-example/webdav-file-explorer/ for a working external WebDAV FILE_EXPLORER plugin example.

How It Works

Startup Flow

Electron app starts
  │
  ├─ PluginManager.discover()             ← scans plugins/ AND ~/.yaet/plugins/
  │   ├─ for each plugin dir with package.json:
  │   │   └─ auto-run npm install --ignore-scripts if node_modules missing or outdated
  │   └─ external plugins that conflict with bundled ids are SKIPPED (security)
  ├─ PluginManager.writeMergedManifest()  ← writes generated-plugin-manifest.json to both locations
  │
  ├─ new BrowserWindow(preload.js)
  │   └─ preload.js (sandboxed, no fs) fetches the merged manifest from the
  │       main process via sync IPC `plugins.getMergedManifestSync`
  │       └─ merges plugin IPC channels into whitelist (fail-closed: core only)
  │
  ├─ initHandlerBeforeSettingLoad()       ← core IPC handlers
  └─ PluginManager.loadAll(context)       ← plugin backends register their IPC handlers
      └─ context.projectRequire available for external plugin npm deps

Runtime Flow

User creates SSH connection
  → SessionService.create(profile, 'SSH_TERMINAL')
    → registry.getExternalPlugin('SSH_TERMINAL')  ← found? use PluginSession
    → new PluginSession(profile, ..., channels, profileData, secretStorage)
      → PluginSession.open()
        → resolveSecretToConfig()  ← resolve secrets before IPC
        → ipcRenderer.send('session.open.terminal.ssh', data)
          → preload.js checks whitelist (includes plugin channels) ✓
          → plugin backend handler receives event ✓
          → SshTerminalSession connects via ssh2 ✓
          → output streams back via 'terminal.output' ✓

How Backend Code Loads

Backend code runs in Electron's main process (Node.js). It is loaded at runtime via require().

electronMain.js
  │
  ├─ app.on('ready')
  │   │
  │   ├─ new PluginManager(__dirname, log)
  │   ├─ pluginManager.discover()
  │   │   └─ for each directory in plugins/:
  │   │       ├─ read manifest.json
  │   │       ├─ validate fields (id, category, profileType)
  │   │       └─ store in plugins Map
  │   │
  │   ├─ pluginManager.writeMergedManifest()
  │   │   └─ combine all plugin ipc channels → plugins/generated-plugin-manifest.json
  │   │      (served to the sandboxed preload via sync IPC; preload never reads files)
  │   │
  │   ├─ new BrowserWindow({ preload: 'preload.js' })
  │   │
  │   ├─ initHandlerBeforeSettingLoad()   ← core handlers (non-plugin)
  │   │
  │   └─ pluginManager.loadAll(context)
  │       └─ for each enabled plugin:
  │           ├─ require('plugins/<id>/backend/index.js')   ← Node.js dynamic require
  │           ├─ backendModule.register(context)            ← plugin registers its IPC handlers
  │           └─ done

Key points about backend loading:

  • require() is a synchronous Node.js call — plugins load one by one, in directory order
  • The context object provides access to core services (ipcMain, logger, sessionRegistry, etc.)
  • Some context values are lazy getters (functions) because they aren't initialized yet at load time
  • A plugin's register() function typically calls ipcMain.on(...) or ipcMain.handle(...) to listen for IPC events
  • If a plugin's backend/index.js has syntax errors or missing dependencies, require() throws and that plugin is skipped (other plugins continue loading)
  • External plugins can use context.projectRequire(moduleName) to resolve npm dependencies from the project's node_modules
PluginManager.loadAll(context)
  │
  ├─ plugins/ssh-terminal (bundled)
  │   ├─ require('./plugins/ssh-terminal/backend/index.js')
  │   └─ register({ ipcMain, logger, ... })
  │       └─ ipcMain.on('session.open.terminal.ssh', handler)
  │
  ├─ ~/.yaet/plugins/my-terminal (external, non-conflicting id)
  │   ├─ require('~/.yaet/plugins/my-terminal/backend/index.js')
  │   │   └─ const { MyTerminalSession } = require('./my-terminal.connector')
  │   │       └─ uses context.projectRequire('ssh2') for npm deps
  │   └─ register({ ipcMain, logger, projectRequire, ... })
  │       └─ ipcMain.on('session.open.terminal.my-terminal', handler)
  │           (an external id conflicting with a bundled plugin is SKIPPED, never overrides)
  │
  ├─ plugins/telnet-terminal (bundled)
  │   ├─ require('./plugins/telnet-terminal/backend/index.js')
  │   │   └─ const { TelnetSession } = require('./telnet.connector')  ← telnet-client loaded here
  │   └─ register({ ipcMain, logger, ... })
  │       └─ ipcMain.on('session.open.terminal.telnet', handler)
  │
  └─ ...

How Frontend Code Loads

Bundled plugins have their .ts files compiled into the Angular bundle via tsconfig.app.json.

External plugins ship a pre-built JS file (frontend/index.js) that is loaded at runtime:

App starts → app.component.ts → ngOnInit()
  │
  ├─ PluginLoaderService.loadExternalPlugins()
  │   ├─ Read ~/.yaet/plugins/generated-plugin-manifest.json (via IPC)
  │   ├─ For each plugin where source === 'external':
  │   │   ├─ Read frontend code via IPC: plugins.readFrontend(id)
  │   │   ├─ Execute via blob-URL <script> (CSP-safe, no unsafe-inline needed)
  │   │   ├─ Plugin registers: window.__<ID>_PLUGIN__ = { manifest, profileFormElement }
  │   │   └─ Register with PluginRegistryService.registerExternalPlugin()
  │   │
  │   └─ External plugins reuse RemoteTerminalProfileFormComponent for profile forms
  │
  ├─ pluginRegistry.getExternalPlugin('SSH_TERMINAL')
  │   └─ returns { id, name, category, profileType, profileFormElement, ipcChannels }
  │
  └─ Profile form: @default case in profile-form.component.html
      └─ <app-remote-terminal-profile-form> (shared component for all terminal types)

Why the difference?

Aspect Backend Frontend (Bundled) Frontend (External)
Runtime Node.js (main process) Browser (renderer process) Browser (renderer process)
Loading require() at app startup Compiled into bundle at build time Blob-URL <script> at runtime (CSP-safe)
Discovery PluginManager scans plugins/ tsconfig.app.json includes plugins/**/*.ts Reads merged manifest
Registration ipcMain.on/handle() in register() PluginRegistryService.register() window.__<ID>_PLUGIN__ + registerExternalPlugin()
npm deps context.projectRequire() for external N/A (bundled) N/A (backend handles deps)
Hot reload Possible (require cache) Requires rebuild (ng just reload page) Reload page

Directory Structure

Bundled Plugins (shipped with app)

plugins/
└── my-connection-type/
    ├── manifest.json              # Plugin metadata
    ├── backend/
    │   ├── index.js               # register(context) — entry point
    │   └── my.connector.js        # Runtime connector (optional, can be inline)
    └── frontend/
        ├── my-electron.service.ts # Frontend IPC service
        └── my.plugin.ts           # Frontend registration

External Plugins (user-installed at ~/.yaet/plugins/)

~/.yaet/plugins/my-connection-type/
├── package.json              # Optional: npm dependencies (auto-installed on startup)
├── manifest.json              # Plugin metadata
├── backend/
│   ├── index.js               # register(context) — entry point
│   └── my.connector.js        # Runtime connector (self-contained, uses context.projectRequire for npm deps)
└── frontend/
    └── index.js               # Pre-built JS bundle (Web Component)

See ext-plugins-example/webdav-file-explorer/ for a complete working example.

Important: the directory name MUST match manifest.json → id. PluginManager constructs backend path as path.join(baseDir, id, backend).

Self-Managed Dependencies

External plugins can declare npm dependencies in their own package.json. On startup, PluginManager detects the package.json and runs npm install --ignore-scripts in the plugin directory if node_modules is missing or out of date (scripts are never executed — postinstall hooks are blocked).

When to use:

  • Your plugin needs a build-time dependency (e.g. spice-client for frontend bundling)
  • You prefer self-contained dependency management over context.projectRequire()
  • Your plugin has npm packages not available in the core application

Example (~/.yaet/plugins/my-plugin/package.json):

{
  "name": "my-plugin",
  "version": "1.0.0",
  "private": true,
  "dependencies": {
    "some-runtime-pkg": "^1.0.0"
  },
  "devDependencies": {
    "some-build-tool": "^2.0.0"
  }
}

Both runtime (dependencies) and build-time (devDependencies) packages are installed. The frontend bundle should be pre-built before distribution — only runtime dependencies are needed for the backend at load time.

Mix and match: You can combine self-managed deps with context.projectRequire(). The plugin's own node_modules is checked first by Node.js require(), falling back to projectRequire for core packages.

Writing a Plugin

Step 1: Create manifest.json

{
  "id": "my-connection",
  "name": "My Connection",
  "version": "1.0.0",
  "description": "Description of what this plugin does",
  "author": "Your Name",
  "category": "TERMINAL",
  "profileType": "MY_CONNECTION",
  "defaultPort": 22,
  "icon": "terminal",
  "enabled": true,
  "dependencies": [],
  "ipc": {
    "send": ["session.open.my-connection", "session.close.my-connection"],
    "invoke": [],
    "on": ["session.disconnect.my-connection"]
  },
  "backend": "./backend/index.js",
  "frontend": {
    "entry": "./frontend/index.js",
    "profileFormElement": "my-connection-profile-form"
  },
  "secretTypes": ["LOGIN_PASSWORD"],
  "supportedAuthTypes": ["N/A", "login", "secret"]
}

Manifest fields:

Field Required Description
id Yes Unique identifier (kebab-case)
name Yes Display name
version Yes Semver version
category Yes TERMINAL, FILE_EXPLORER, REMOTE_DESKTOP, or CUSTOM
profileType Yes Must match a value in ProfileType enum
icon Yes Material icon name
enabled Yes Whether the plugin loads on startup
ipc.send No IPC channels the plugin listens on (renderer → main)
ipc.invoke No IPC channels the plugin handles (renderer → main, request/response)
ipc.on No IPC channels the plugin sends (main → renderer)
secretTypes No Which secret types this plugin supports
supportedAuthTypes No N/A, login, secret

Step 2: Write the Backend

Create backend/index.js:

const { MyConnector } = require('./my.connector');

function register(context) {
  const { ipcMain, logger, sessionRegistry, terminalMap } = context;

  ipcMain.on('session.open.my-connection', async (event, data) => {
    const session = new MyConnector(logger, context.projectRequire);

    // Wire up events
    session.on('output', ({ data: output }) => {
      event.sender.send('terminal.output', { id: data.terminalId, data: output });
    });

    session.on('error', ({ error }) => {
      event.sender.send('error', { category: 'my-connection', id: data.terminalId, error });
    });

    session.on('disconnect', ({ error }) => {
      event.sender.send('session.disconnect.my-connection', { id: data.terminalId, error: !!error });
    });

    try {
      // Resolve proxy if needed
      let proxy = null;
      if (data.proxyId && context.proxyService) {
        const proxies = typeof context.proxyService === 'function'
          ? context.proxyService() : context.proxyService;
        if (proxies?.proxies) {
          proxy = proxies.proxies.find(p => p.id === data.proxyId);
        }
      }

      // Resolve secrets if needed
      const secretRepo = typeof context.secretService === 'function'
        ? context.secretService() : context.secretService;

      await session.connect({
        ...data.config,
        proxy,
        secretRepo,
        id: data.terminalId,
      });

      // Register in session registry (for AI tools)
      const registry = typeof sessionRegistry === 'function'
        ? sessionRegistry() : sessionRegistry;
      if (registry) registry.register(data.terminalId, 'my-connection', 'user', session);

      // Register in terminalMap (for shared terminalHandler.js resize/input)
      if (terminalMap) {
        terminalMap.set(data.terminalId, {
          type: 'my-connection',
          process: session.conn,
          stream: session.stream,
          callback: (input) => session.write(input),
        });
      }
    } catch (error) {
      event.sender.send('error', {
        category: 'my-connection',
        id: data.terminalId,
        error: error.message,
      });
    }
  });

  ipcMain.on('session.close.my-connection', (event, data) => {
    const registry = typeof sessionRegistry === 'function'
      ? sessionRegistry() : sessionRegistry;
    const entry = registry?.get(data.terminalId);
    if (entry?.session) entry.session.close();
    registry?.unregister(data.terminalId);
  });

  logger.info('[my-connection] Plugin registered');
}

module.exports = { register };

Context object available to plugins:

Property Type Description
ipcMain Restricted wrapper (handle/on/removeHandler/removeAllListeners) Register IPC handlers — only for channels declared in the manifest's ipc section
logger electron-log Logging
terminalMap Map Shared terminal session map (for resize/input routing)
sessionRegistry SessionRegistry or () => SessionRegistry Session tracking for AI tools
runtimeAPI Restricted wrapper registerConnector() only for the manifest's declared profileType
proxyService object or () => object Proxy configuration
secretService Restricted wrapper Secrets filtered by the manifest's declared secretTypes
expressApp Express app or () => Express Backend REST API (for file explorers)
projectRequire Function createRequire() from project root — use for npm deps like require('ssh2')
appRoot string Absolute path to the project root (for reference)

Step 3: Write the Runtime Connector

Create backend/my.connector.js:

const { EventEmitter } = require('events');

class MyConnector extends EventEmitter {
  constructor(log, projectRequire) {
    super();
    this.log = log;
    this._projectRequire = projectRequire;
    this.conn = null;
    this.stream = null;
  }

  async connect(options) {
    // Use projectRequire to resolve npm deps (works for both bundled and external plugins)
    const { SomeClient } = this._projectRequire('some-npm-package');
    const { host, port, /* ... */ } = options;

    // Establish connection using your protocol library
    this.conn = await createConnection({ host, port });
    this.stream = this.conn;

    // Forward output events
    this.conn.on('data', (data) => {
      this.emit('output', { data: data.toString() });
    });

    this.conn.on('close', () => {
      this.emit('disconnect', { error: false });
    });
  }

  async write(data) {
    if (this.stream) {
      this.stream.write(data);
      return true;
    }
    return false;
  }

  async resize(cols, rows) {
    // Protocol-specific resize
  }

  async close() {
    if (this.conn) {
      this.conn.end();
      this.conn = null;
      this.stream = null;
    }
  }
}

module.exports = { MyConnector };

Important: Pass projectRequire to the connector constructor so it can resolve npm deps. This is required for external plugins — without it, require('some-npm-package') will fail because the plugin lives in ~/.yaet/plugins/ where node_modules doesn't exist.

Step 4: Write the Frontend

For external plugins, create frontend/index.js — a pre-built JS bundle that registers a Web Component:

class MyConnectionProfileForm extends HTMLElement {
  constructor() {
    super();
    this._shadow = this.attachShadow({mode: 'open'});
  }

  connectedCallback() {
    this._shadow.innerHTML = `
      <style>
        :host { display: block; padding: 16px; }
        .form-field { margin-bottom: 12px; }
        label { display: block; font-size: 12px; color: #999; margin-bottom: 4px; }
        input { width: 100%; padding: 8px; border: 1px solid #444; border-radius: 4px;
                background: #1e1e1e; color: #fff; box-sizing: border-box; }
      </style>
      <div class="form-field">
        <label>Host</label>
        <input type="text" id="host" placeholder="hostname or IP" />
      </div>
      <div class="form-field">
        <label>Port</label>
        <input type="number" id="port" value="22" />
      </div>
    `;
  }

  setForm(form) { /* pass form data to the element */ }
  setProfile(profile) { /* pass profile data to the element */ }
}

// Register the custom element
customElements.define('my-connection-profile-form', MyConnectionProfileForm);

// Expose metadata for the host app
// Key MUST be __<ID>_PLUGIN__ where ID = manifest.id with hyphens → underscores, uppercased
window.__MY_CONNECTION_PLUGIN__ = {
  manifest: { id: 'my-connection', category: 'TERMINAL', profileType: 'MY_CONNECTION' },
  profileFormElement: 'my-connection-profile-form',
};

Auth integration: The core provides a reusable <app-auth-form> component for auth type selection (None / Login / Secret). External plugins declare supportedAuthTypes and secretTypes in their manifest — the app automatically renders the auth component below the Web Component form. The Web Component only needs to handle plugin-specific fields (like URL for WebDAV).

Alternatively, for terminal-type plugins, you can skip the custom element and reuse the shared RemoteTerminalProfileFormComponent — no frontend code needed at all. The @default case in profile-form.component.html handles this automatically for external terminal plugins.

Step 5: Register the Plugin

For external plugins, registration happens automatically:

  1. PluginManager discovers the plugin in ~/.yaet/plugins/<id>/
  2. PluginLoaderService loads frontend/index.js via IPC
  3. The plugin's window.__<ID>_PLUGIN__ metadata is read
  4. PluginRegistryService.registerExternalPlugin() stores the plugin info

For bundled plugins, create frontend/my.plugin.ts:

import { inject } from '@angular/core';
import { PluginRegistryService } from '../../../src/app/plugin/services/plugin-registry.service';
import { ProfileCategory, ProfileType } from '../../../src/app/domain/profile/Profile';

const MY_MANIFEST = {
  id: 'my-connection',
  name: 'My Connection',
  version: '1.0.0',
  category: ProfileCategory.TERMINAL,
  profileType: ProfileType.MY_CONNECTION,
  defaultPort: 22,
  icon: 'terminal',
  enabled: true,
  secretTypes: ['LOGIN_PASSWORD'],
  supportedAuthTypes: ['N/A', 'login', 'secret'],
};

export function registerMyPlugin() {
  const registry = inject(PluginRegistryService);

  import('./my-electron.service').then(({ MyElectronService }) => {
    import('../../../src/app/components/terminal/terminal.component').then(({ TerminalComponent }) => {
      registry.register({
        manifest: MY_MANIFEST,
        profileFormComponent: TerminalComponent, // or a custom form
        sessionComponent: TerminalComponent,      // shared terminal view
      });
    });
  });
}

Step 6: Wire into Core

For external plugins, no core changes are needed — the plugin is discovered automatically.

For bundled plugins, you may need:

6a. Add ProfileType (if new type):

In src/app/domain/profile/Profile.ts:

export enum ProfileType {
  // ... existing types
  MY_CONNECTION = 'MY_CONNECTION',
}

6b. Add Session class (if needed):

In src/app/domain/session/MySession.ts:

import { Profile, ProfileType } from '../profile/Profile';
import { TabService } from '../../services/tab.service';
import { Session } from './Session';

export class MySession extends Session {
  private myService: any;

  constructor(profile: Profile, profileType: ProfileType,
              tabService: TabService, myService: any) {
    super(profile, profileType, tabService);
    this.myService = myService;
  }

  override open(): void {
    this.myService.openSession(this);
    super.open();
  }

  override close(): void {
    this.myService.closeSession(this);
    super.close();
  }
}

6c. Update SessionService:

In src/app/services/session.service.ts:

import { MyElectronService } from '../../../plugins/my-connection/frontend/my-electron.service';
import { MySession } from '../domain/session/MySession';

// In constructor:
private myService: MyElectronService,

// In create() switch:
case ProfileType.MY_CONNECTION:
  return new MySession(profile, profileType, this.tabService, this.myService);

Packaging & Distribution

External Plugin Packaging

External plugins are distributed as directories that users copy to ~/.yaet/plugins/<id>/. There is no special archive format — just a folder with the correct structure.

Simple plugin (no build step)

For plugins like webdav-file-explorer that have no npm dependencies and no frontend build step:

my-plugin/
├── manifest.json
├── backend/
│   ├── index.js
│   └── my.connector.js
└── frontend/
    └── index.js          # Hand-written Web Component (no build needed)

Distribution: Zip the directory and share. Users extract to ~/.yaet/plugins/my-plugin/.

Plugin with npm dependencies (self-managed)

For plugins that need npm packages for the backend (e.g. ssh2, telnet-client):

my-plugin/
├── package.json          # Declares npm dependencies
├── manifest.json
├── backend/
│   ├── index.js
│   └── my.connector.js
└── frontend/
    └── index.js

How it works: On startup, PluginManager detects package.json and runs npm install --ignore-scripts automatically if node_modules is missing or outdated. No manual install step needed by the user.

Distribution: Zip the directory without node_modules/. Users extract to ~/.yaet/plugins/my-plugin/, and the app handles npm install --ignore-scripts on first launch.

Plugin with frontend build step

For plugins that have frontend dependencies (e.g. a JS library like spice-client), you need a build script to bundle everything into a single frontend/index.js:

my-plugin/
├── package.json          # devDependencies for build, optional dependencies
├── build.js              # Build script (concatenates sources into frontend/index.js)
├── manifest.json
├── src/
│   ├── my-profile-form.js
│   └── my-session.js
├── backend/
│   ├── index.js
│   └── my.connector.js
└── frontend/
    └── index.js          # Generated by build.js (do NOT hand-edit)

Build script example (from spice-remote-desktop/build.js):

const fs = require('fs');
const path = require('path');

const SRC_DIR = path.resolve(__dirname, 'src');
const OUT_DIR = path.resolve(__dirname, 'frontend');

const files = [
  path.join(SRC_DIR, 'my-profile-form.js'),
  path.join(SRC_DIR, 'my-session.js'),
];

if (!fs.existsSync(OUT_DIR)) {
  fs.mkdirSync(OUT_DIR, { recursive: true });
}

let bundle = '(function(){\n"use strict";\n\n';
for (const file of files) {
  bundle += fs.readFileSync(file, 'utf8');
  bundle += '\n\n';
}
bundle += '})();\n';

// Add plugin metadata
bundle += '\nwindow.__MY_PLUGIN_PLUGIN__ = {\n';
bundle += '  manifest: {\n';
bundle += '    id: "my-plugin",\n';
bundle += '    name: "My Plugin",\n';
bundle += '    category: "TERMINAL",\n';
bundle += '    profileType: "MY_PLUGIN",\n';
bundle += '    supportedAuthTypes: ["N/A", "login", "secret"],\n';
bundle += '    secretTypes: ["LOGIN_PASSWORD"],\n';
bundle += '  },\n';
bundle += '  profileFormElement: "my-profile-form",\n';
bundle += '};\n';

fs.writeFileSync(path.join(OUT_DIR, 'index.js'), bundle, 'utf8');
console.log('Built frontend/index.js (' + (bundle.length / 1024).toFixed(1) + ' KB)');

package.json:

{
  "name": "my-plugin",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "build": "node build.js"
  },
  "devDependencies": {
    "some-frontend-lib": "^1.0.0"
  }
}

Build workflow:

# 1. Install build dependencies
npm install

# 2. Run build script
npm run build

# 3. The frontend/index.js is now ready for distribution

Distribution: Zip the directory including frontend/index.js but excluding node_modules/. Users extract to ~/.yaet/plugins/my-plugin/. The app runs npm install --ignore-scripts on first launch to restore backend dependencies.

Bundled Plugin Packaging

Bundled plugins ship with the app and are compiled into the Angular bundle at build time. No separate packaging is needed — just place the plugin directory under plugins/ and it is automatically discovered on the next npm run build or npm start.

The build script scripts/generate-plugin-barrel.js scans all plugins/*/manifest.json files and generates plugins/generated-plugin-registry.ts, which statically imports all plugin modules.

Distribution Summary

Plugin Type Build Step Distribution User Install
Simple external None Zip directory Extract to ~/.yaet/plugins/<id>/
External with deps npm install --ignore-scripts (auto on startup) Zip directory (no node_modules/) Extract to ~/.yaet/plugins/<id>/
External with frontend build npm run build → frontend/index.js Zip directory (include frontend/index.js, no node_modules/) Extract to ~/.yaet/plugins/<id>/
Bundled npm run build (Angular build) Git commit to plugins/ Ships with app

Checklist for New Plugins

External Plugins (recommended)

  • manifest.json with correct id, category, profileType
  • Directory name matches manifest.id
  • backend/index.js with register(context) function
  • Backend connector uses context.projectRequire() for npm deps (or package.json for self-managed deps)
  • Frontend index.js exposes window.__<ID>_PLUGIN__ metadata
  • IPC channels added to manifest ipc section
  • Plugin works without any core code changes
  • Plugin must be explicitly enabled by user (disabled by default for security)

Bundled Plugins

  • All of the above, plus:
  • ProfileType enum entry
  • Session class (thin wrapper delegating to plugin service)
  • SessionService updated to create the session
  • Build passes (npx ng build)

Notes

  • External plugin npm deps (option 1 — projectRequire): Use context.projectRequire(moduleName) in your connector to resolve npm packages from the project's node_modules. Direct require() will fail for external plugins since they live in ~/.yaet/plugins/.
  • External plugin npm deps (option 2 — self-managed): Add package.json to your plugin directory. PluginManager runs npm install --ignore-scripts automatically on startup. This is useful for build-time dependencies (e.g. spice-client) or when you need packages not available in the core project. Both approaches can be mixed — Node.js resolves from plugin's node_modules first, then falls through to projectRequire.
  • Session view sharing: Terminal plugins (SSH, Telnet, Local, WinRM) all share TerminalComponent for the UI. External plugins reuse it automatically via PluginSession.
  • Profile form sharing: RemoteTerminalProfileFormComponent is shared between SSH, Telnet, WinRM. External terminal plugins use it automatically via the @default case in profile-form.component.html.
  • Preload whitelist: IPC channels are automatically added to the preload whitelist from the plugin manifest. No manual whitelist editing needed.
  • terminalMap: If your plugin uses terminal output, register with terminalMap so the shared terminalHandler.js can route input/resize.
  • sessionRegistry: Register sessions here for AI tool integration (session_write, session_read).
  • Plugin id = directory name: PluginManager constructs backend path as path.join(baseDir, id, backend). The directory MUST match manifest.id.