Creating Plugins

Learn how to build custom plugins for MediaProc to extend its functionality with your own commands and features.

Plugin Architecture

MediaProc plugins follow a standardized structure that makes them easy to create, maintain, and distribute.

Plugin Structure

my-plugin/
  ├── package.json
  ├── tsconfig.json
  ├── bin/
  │   └── cli.js
  └── src/
      ├── index.ts
      ├── cli.ts
      ├── register.ts
      ├── types.ts
      └── commands/
          ├── command1.ts
          ├── command2.ts
          └── command3.ts

Getting Started

1. Initialize Plugin

Create a new directory and initialize package.json:

Terminal
$ mkdir my-plugin
$ cd my-plugin
$ npm init -y
✓ Created package.json

2. Install Dependencies

Terminal
$ npm install commander chalk ora
✓ Installed runtime dependencies
Terminal
$ npm install -D typescript @types/node
✓ Installed dev dependencies

3. Configure TypeScript

Create tsconfig.json:

{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist",
    "rootDir": "./src"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist"]
}

Core Files

package.json

Configure your plugin package:

{
  "name": "@mediaproc/my-plugin",
  "version": "1.0.0",
  "description": "My custom MediaProc plugin",
  "type": "module",
  "main": "dist/index.js",
  "types": "dist/index.d.ts",
  "bin": {
    "mediaproc-my-plugin": "bin/cli.js"
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch"
  },
  "keywords": ["mediaproc", "plugin"],
  "author": "Your Name",
  "license": "MIT",
  "dependencies": {
    "commander": "^11.0.0",
    "chalk": "^5.3.0",
    "ora": "^7.0.1"
  },
  "devDependencies": {
    "typescript": "^5.0.0",
    "@types/node": "^20.0.0"
  }
}

src/types.ts

Define plugin types:

export interface PluginOptions {
  dryRun?: boolean;
  verbose?: boolean;
  quality?: number;
  output?: string;
}

export interface CommandResult {
  success: boolean;
  message?: string;
  error?: Error;
}

src/index.ts

Main plugin entry point:

import type { Command } from "commander";
import { myCommand } from "./commands/myCommand.js";

export function registerPlugin(program: Command): void {
  const pluginCmd = program
    .command("my-plugin")
    .description("My custom plugin for MediaProc");

  // Register commands
  myCommand(pluginCmd);
}

export * from "./types.js";

src/cli.ts

CLI initialization:

#!/usr/bin/env node
import { Command } from "commander";
import { registerPlugin } from "./index.js";

const program = new Command();

program
  .name("mediaproc-my-plugin")
  .description("My custom MediaProc plugin")
  .version("1.0.0");

registerPlugin(program);

program.parse(process.argv);

src/register.ts

Plugin registration:

import type { Command } from "commander";
import { registerPlugin } from "./index.js";

export default function register(program: Command): void {
  registerPlugin(program);
}

bin/cli.js

Executable wrapper:

#!/usr/bin/env node
import("../dist/cli.js").catch((err) => {
  console.error(err);
  process.exit(1);
});

Creating Commands

Basic Command Structure

Create src/commands/myCommand.ts:

import type { Command } from "commander";
import chalk from "chalk";
import ora from "ora";

interface MyCommandOptions {
  output?: string;
  verbose?: boolean;
  dryRun?: boolean;
}

export function myCommand(pluginCmd: Command): void {
  pluginCmd
    .command("process <input>")
    .description("Process input file")
    .option("-o, --output <path>", "Output file path")
    .option("-v, --verbose", "Verbose output")
    .option("--dry-run", "Preview without executing")
    .action(async (input: string, options: MyCommandOptions) => {
      const spinner = ora("Processing...").start();

      try {
        if (options.dryRun) {
          spinner.info(chalk.blue("Dry run - no changes will be made"));
          console.log(chalk.dim(`Would process: ${input}`));
          return;
        }

        // Your processing logic here
        await processFile(input, options);

        spinner.succeed(chalk.green("Processing complete!"));
      } catch (error) {
        spinner.fail(chalk.red("Processing failed"));
        if (options.verbose) {
          console.error(chalk.red("Error details:"), error);
        }
        process.exit(1);
      }
    });
}

async function processFile(
  input: string,
  options: MyCommandOptions,
): Promise<void> {
  // Implement your processing logic
}

Command Patterns

File Processing

import fs from "node:fs/promises";
import path from "node:path";

async function processFiles(pattern: string): Promise<string[]> {
  // Validate input paths
  const files = await findFiles(pattern);

  // Process each file
  for (const file of files) {
    await processFile(file);
  }

  return files;
}

Batch Operations

async function batchProcess(files: string[], options: Options): Promise<void> {
  let successCount = 0;
  let failCount = 0;

  for (const [index, file] of files.entries()) {
    try {
      spinner.start(`Processing ${index + 1}/${files.length}: ${file}`);
      await processFile(file, options);
      spinner.succeed();
      successCount++;
    } catch (error) {
      spinner.fail(`Failed: ${file}`);
      failCount++;
    }
  }

  console.log(chalk.green(`Success: ${successCount}`));
  if (failCount > 0) {
    console.log(chalk.red(`Failed: ${failCount}`));
  }
}

Progress Reporting

import ora from "ora";
import chalk from "chalk";

function reportProgress(current: number, total: number, item: string): void {
  const percentage = Math.round((current / total) * 100);
  spinner.text = `Processing ${current}/${total} (${percentage}%): ${item}`;
}

Best Practices

1. Error Handling

Always handle errors gracefully:

try {
  await processFile(input);
} catch (error) {
  if (error instanceof ValidationError) {
    console.error(chalk.red("Invalid input:", error.message));
  } else if (error instanceof FileNotFoundError) {
    console.error(chalk.red("File not found:", error.path));
  } else {
    console.error(chalk.red("Unexpected error:", error));
  }
  process.exit(1);
}

2. Input Validation

Validate inputs early:

function validateInput(input: string, options: Options): void {
  if (!input) {
    throw new Error("Input file is required");
  }

  if (options.quality && (options.quality < 1 || options.quality > 100)) {
    throw new Error("Quality must be between 1 and 100");
  }
}

3. Verbose Logging

Support verbose mode:

if (options.verbose) {
  console.log(chalk.blue("Configuration:"));
  console.log(chalk.dim(`  Input: ${input}`));
  console.log(chalk.dim(`  Output: ${output}`));
  console.log(chalk.dim(`  Quality: ${options.quality}`));
}

4. Dry Run Mode

Always implement dry-run:

if (options.dryRun) {
  console.log(chalk.yellow("Dry run mode - no changes will be made"));
  console.log(chalk.green(`Would process: ${input} → ${output}`));
  return;
}

5. Consistent Output

Use consistent formatting:

// Success
spinner.succeed(chalk.green("✓ Operation completed"));

// Error
spinner.fail(chalk.red("✗ Operation failed"));

// Info
spinner.info(chalk.blue("ℹ Additional information"));

// Warning
spinner.warn(chalk.yellow("⚠ Warning message"));

Testing Your Plugin

Local Testing

# Build plugin
npm run build

# Test locally
node bin/cli.js process input.txt

# Link for testing
npm link
mediaproc my-plugin process input.txt

Integration Testing

import { describe, it, expect } from "vitest";
import { processFile } from "../src/commands/myCommand.js";

describe("myCommand", () => {
  it("should process file correctly", async () => {
    const result = await processFile("test.txt", {});
    expect(result.success).toBe(true);
  });

  it("should handle invalid input", async () => {
    await expect(processFile("", {})).rejects.toThrow();
  });
});

Publishing Your Plugin

1. Prepare for Publishing

# Build
npm run build

# Test
npm test

# Update version
npm version patch

2. Publish to npm

npm publish --access public

3. Document Your Plugin

Create a comprehensive README.md:

# @mediaproc/my-plugin

Description of your plugin

## Installation

\`\`\`bash
npm install -g @mediaproc/my-plugin
\`\`\`

## Usage

\`\`\`bash
mediaproc my-plugin process input.txt
\`\`\`

## Commands

### process

Process input files

## Options

- `-o, --output <path>` - Output file
- `-v, --verbose` - Verbose output

Example Plugins

Simple Text Processor

export function textCommand(pluginCmd: Command): void {
  pluginCmd
    .command("uppercase <input>")
    .description("Convert text to uppercase")
    .option("-o, --output <path>", "Output file")
    .action(async (input: string, options) => {
      const content = await fs.readFile(input, "utf-8");
      const result = content.toUpperCase();

      const output = options.output || input.replace(/\.txt$/, "-upper.txt");
      await fs.writeFile(output, result);

      console.log(chalk.green(`✓ Converted ${input} → ${output}`));
    });
}

File Converter

export function convertCommand(pluginCmd: Command): void {
  pluginCmd
    .command("convert <input>")
    .description("Convert file format")
    .option("-f, --format <format>", "Target format")
    .option("-o, --output <path>", "Output file")
    .action(async (input: string, options) => {
      const spinner = ora("Converting...").start();

      try {
        const data = await readFile(input);
        const converted = await convertFormat(data, options.format);
        await writeFile(options.output, converted);

        spinner.succeed(chalk.green("Conversion complete!"));
      } catch (error) {
        spinner.fail(chalk.red("Conversion failed"));
        throw error;
      }
    });
}

Advanced Features

Custom Helpers

// src/utils/helpers.ts
export function formatSize(bytes: number): string {
  const units = ["B", "KB", "MB", "GB"];
  let size = bytes;
  let unitIndex = 0;

  while (size >= 1024 && unitIndex < units.length - 1) {
    size /= 1024;
    unitIndex++;
  }

  return `${size.toFixed(2)} ${units[unitIndex]}`;
}

export function formatDuration(ms: number): string {
  if (ms < 1000) return `${ms}ms`;
  if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
  return `${(ms / 60000).toFixed(1)}m`;
}

Configuration Support

import fs from "node:fs/promises";
import path from "node:path";

interface PluginConfig {
  defaultQuality: number;
  outputFormat: string;
}

export async function loadConfig(): Promise<PluginConfig> {
  const configPath = path.join(process.cwd(), ".mypluginrc.json");

  try {
    const content = await fs.readFile(configPath, "utf-8");
    return JSON.parse(content);
  } catch {
    return {
      defaultQuality: 90,
      outputFormat: "png",
    };
  }
}

Resources

Support

Found an issue? Help us improve this page.

Edit on GitHub →