Codify
Plugin Development

Best practices

Write reliable, maintainable plugins

Best Practices for refresh()

  1. Return null for non-existent resources - This is how Codify knows to create the resource.

  2. Query only requested parameters - Check the parameters argument to see what the user configured. Don't query everything.

  3. Handle errors gracefully - If a command fails, return null rather than throwing (unless it's an unexpected error).

  4. Be efficient - This method is called during every plan operation. Avoid expensive operations if possible.

  5. Parse output carefully - System commands can return different formats. Use robust parsing logic.

create()

The create() method is called during the apply phase when Codify needs to install or configure a resource that doesn't currently exist on the system. This is where you execute the actual system commands to make the desired state a reality.

When it's called:

  • During codify apply when the resource operation is CREATE
  • After the user has approved the plan
  • In a sequential PTY context (commands run one at a time, in order)

What you receive:

  • plan.desiredConfig - The complete configuration the user wants
  • plan.isStateful - Whether this is a stateful operation
  • plan.id - Unique identifier for this specific resource instance
async create(plan: CreatePlan<MyConfig>): Promise<void> {
  const pty = getPty();
  const config = plan.desiredConfig;

  // Install the tool with the specified version
  await pty.spawn(`install-tool --version ${config.version}`);

  // Configure it if needed
  if (config.enableFeature) {
    await pty.spawn(`tool-config --enable ${config.enableFeature}`);
  }
}

Real-World Example: Homebrew Installation

async create(plan: CreatePlan<HomebrewConfig>): Promise<void> {
  const pty = getPty();

  // Install Homebrew in custom directory if specified
  if (plan.desiredConfig.directory) {
    return this.installBrewInCustomDir(plan.desiredConfig.directory);
  }

  // Standard installation using official script
  await pty.spawn(
    '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"',
    {
      stdin: true,  // Allow interactive input
      env: { NONINTERACTIVE: 1 }  // But run non-interactively
    }
  );

  // Add Homebrew to shell PATH
  const brewPath = Utils.isLinux()
    ? '/home/linuxbrew/.linuxbrew/bin/brew'
    : '/opt/homebrew/bin/brew';

  await FileUtils.addToShellRc(`eval "$(${brewPath} shellenv)"`);
}

Handling Stateful Parameters

If your resource has stateful parameters (like Homebrew's formulae), the framework automatically calls their add() methods after create() completes:

// In create(), just install the base resource
async create(plan: CreatePlan<HomebrewConfig>): Promise<void> {
  await this.installHomebrew();
  // Don't install formulae here - the FormulaeParameter.add() handles that
}

destroy()

The destroy() method removes a resource from the system. This is only called in stateful mode when a user removes the resource from their configuration.

When it's called:

  • During codify apply when the resource operation is DESTROY
  • Only in stateful mode (never in stateless mode)
  • After user approval of the plan

Important: Be very careful in destroy() implementations. You're deleting user data and system state. Consider:

  • Can this operation be safely reversed?
  • Should you prompt for additional confirmation?
  • Should you refuse to destroy if the resource has uncommitted changes?
async destroy(plan: DestroyPlan<MyConfig>): Promise<void> {
  const pty = getPty();
  const config = plan.currentConfig;

  // Uninstall the tool
  await pty.spawn(`uninstall-tool ${config.path}`);

  // Clean up configuration files
  await fs.rm(config.configPath, { recursive: true });
}

Real-World Example: Git Repository Resource

The git-repository resource refuses to delete directories because it could destroy user work:

async destroy(plan: DestroyPlan<GitRepositoryConfig>): Promise<void> {
  // Never automatically delete a git repository - too dangerous!
  throw new Error(
    `The git-repository resource doesn't automatically delete repositories. ` +
    `Please delete ${plan.currentConfig.directory} manually and re-apply.`
  );
}

This is a good pattern for resources that manage user data—force manual intervention rather than risking data loss.

Handling Stateful Parameters

If removeStatefulParametersBeforeDestroy is true in your resource settings, stateful parameters' remove() methods are called before destroy():

getSettings(): ResourceSettings<HomebrewConfig> {
  return {
    id: 'homebrew',
    removeStatefulParametersBeforeDestroy: true,  // Uninstall formulae first
    // ...
  };
}

async destroy(plan: DestroyPlan<HomebrewConfig>): Promise<void> {
  // All formulae have been uninstalled already
  // Now remove Homebrew itself
  await pty.spawn('brew cleanup');
  await pty.spawn('rm -rf /opt/homebrew', { requiresRoot: true });
}

modify()

The modify() method updates specific parameters of an existing resource without recreating it. This is optional—if you don't implement it, Codify will use a RECREATE operation (destroy then create) instead.

Implementing modify() is important for resources where recreation would be:

  • Slow (downloading large files, compiling code)
  • Destructive (losing data or state)
  • Disruptive (restarting services)

When it's called:

  • During codify apply when the resource operation is MODIFY
  • Once per parameter that changed
  • Only for parameters where canModify: true in parameter settings

What you receive:

  • parameterChange.name - Which parameter changed
  • parameterChange.operation - ADD, REMOVE, or MODIFY
  • parameterChange.newValue - The new value
  • parameterChange.previousValue - The old value
  • plan - Full plan context with desired and current configs
async modify(
  parameterChange: ParameterChange<MyConfig>,
  plan: ModifyPlan<MyConfig>
): Promise<void> {
  const pty = getPty();

  // Only handle version changes - other parameters require recreation
  if (parameterChange.name === 'version') {
    await pty.spawn(`update-tool --to ${plan.desiredConfig.version}`);
  }

  // For parameters we can't modify, do nothing
  // Codify will use RECREATE instead
}

Real-World Example: Alias Resource

The alias resource modifies the alias value by finding and replacing the line in the shell RC file:

async modify(
  pc: ParameterChange<AliasConfig>,
  plan: ModifyPlan<AliasConfig>
): Promise<void> {
  // Only the value can be modified; changing alias name requires recreate
  if (pc.name !== 'value') {
    return;
  }

  const { alias, value } = plan.currentConfig;

  // Find which file contains this alias
  const aliasInfo = await this.findAlias(alias, value);
  if (!aliasInfo) {
    throw new Error(
      `Unable to find alias: ${alias}. ` +
      `Please delete it manually and re-run Codify.`
    );
  }

  // Read file, find the line, replace it, write back
  const lines = aliasInfo.contents.split('\n');
  const aliasString = `alias ${alias}='${value}'`;

  const aliasLineNum = lines.findIndex(l => l.trim() === aliasString);
  if (aliasLineNum === -1) {
    throw new Error(`Cannot find line in ${aliasInfo.path}`);
  }

  const newAlias = `alias ${plan.desiredConfig.alias}='${plan.desiredConfig.value}'`;
  lines.splice(aliasLineNum, 1, newAlias);

  await fs.writeFile(aliasInfo.path, lines.join('\n'), 'utf8');
}

When NOT to Implement modify()

Don't implement modify() if:

  • Recreation is fast and safe
  • Parameters are deeply coupled (changing one requires changing others)
  • Modification is complex and error-prone

Let Codify use RECREATE instead—it's simpler and more reliable.

Schema Validation

Use JSON Schema or Zod for validation:

JSON Schema

// my-resource-schema.json
{
  "type": "object",
  "properties": {
    "version": { "type": "string" },
    "path": { "type": "string" }
  },
  "required": ["version"]
}

// my-resource.ts
import Schema from './my-resource-schema.json';

interface MyConfig extends StringIndexedObject {
  version: string;
  path?: string;
}

getSettings() {
  return { schema: Schema };
}

Zod (Preferred)

Zod provides type safety with a single source of truth:

import { z } from 'zod';

const schema = z.object({
  version: z.string(),
  path: z.string().optional(),
});

type MyConfig = z.infer<typeof schema>;

getSettings() {
  return { schema };
}

PTY Abstraction

The PTY (Pseudo-Terminal) abstraction is how plugins execute shell commands. Instead of using Node's child_process directly, you use getPty() to get a PTY instance that handles stdout/stderr streaming, error handling, and privilege escalation.

Why PTY Instead of child_process?

  1. Interactive Commands: PTY supports commands that require user input (like installers)
  2. Output Streaming: Real-time stdout/stderr is streamed to the user's terminal
  3. Privilege Escalation: Automatic handling of sudo password prompts
  4. Context Awareness: Different PTY implementations for planning vs applying

Getting the PTY Instance

import { getPty } from '@codifycli/plugin-core';

const pty = getPty();

The getPty() function uses async local storage to provide the correct PTY instance for the current context:

  • BackgroundPty during refresh() - Allows parallel execution for faster planning
  • SequentialPty during create(), modify(), destroy() - Executes commands sequentially with proper error handling

spawn() - Execute with Error Handling

Use spawn() when you expect the command to succeed. If it fails (non-zero exit code), an error is thrown and execution stops.

const pty = getPty();

// Throws on non-zero exit code
const result = await pty.spawn('brew install jq');
console.log(result.data); // stdout output as string

When to use spawn():

  • During create(), modify(), destroy() when failure should stop execution
  • When the command must succeed for the operation to be valid
  • When you want automatic error propagation

spawnSafe() - Execute Without Throwing

Use spawnSafe() when you need to check if something exists or when failure is an expected outcome.

const pty = getPty();

// Never throws - returns status object
const result = await pty.spawnSafe('which jq');

if (result.status === SpawnStatus.SUCCESS) {
  console.log('jq is installed at:', result.data);
} else {
  console.log('jq not found');
}

When to use spawnSafe():

  • During refresh() to check if resources exist
  • When checking prerequisites or system state
  • When multiple outcomes are valid

Spawn Options

Both spawn() and spawnSafe() accept an options object:

await pty.spawn('npm install', {
  // Working directory for the command
  cwd: '/path/to/project',

  // Environment variables (merged with current env)
  env: { NODE_ENV: 'production' },

  // Allow interactive input (prompts, confirmations)
  interactive: true,

  // Require root/sudo privileges
  requiresRoot: true,

  // Provide input to stdin
  stdin: true,
});

Option Details

cwd - Change working directory:

// Clone a repo into a specific directory
await pty.spawn('git clone https://github.com/user/repo.git', {
  cwd: '/Users/john/projects'
});

env - Set environment variables:

// Run installer non-interactively
await pty.spawn('./install.sh', {
  env: { NONINTERACTIVE: 1 }
});

interactive - Allow user input:

// Run installer that may prompt for choices
await pty.spawn('/bin/bash -c "$(curl -fsSL https://install.sh)"', {
  interactive: true,
  stdin: true
});

requiresRoot - Execute with sudo:

// Install to system directory
await pty.spawn('cp binary /usr/local/bin/', {
  requiresRoot: true
});

When requiresRoot: true, Codify:

  1. Sends a message to the parent CLI process
  2. CLI prompts user for their sudo password
  3. Password is sent back to plugin securely
  4. Command executes with sudo

This ensures plugins never have direct access to sudo—they must request it through the parent process.

Real-World Examples

Checking if a tool is installed:

async refresh(parameters: Partial<MyConfig>): Promise<Partial<MyConfig> | null> {
  const pty = getPty();

  // Check if tool exists
  const result = await pty.spawnSafe('which my-tool');
  if (result.status === SpawnStatus.ERROR) {
    return null; // Not installed
  }

  // Get version
  const versionResult = await pty.spawnSafe('my-tool --version');
  const version = versionResult.data.match(/v(\d+\.\d+\.\d+)/)?.[1];

  return { version };
}

Installing with platform-specific commands:

async create(plan: CreatePlan<MyConfig>): Promise<void> {
  const pty = getPty();

  if (Utils.isMacOS()) {
    await pty.spawn('brew install my-tool');
  } else if (Utils.isLinux()) {
    await pty.spawn('apt-get install -y my-tool', {
      requiresRoot: true
    });
  }
}

Running commands in a specific directory:

async create(plan: CreatePlan<ProjectConfig>): Promise<void> {
  const pty = getPty();
  const projectPath = plan.desiredConfig.directory;

  // Create directory
  await pty.spawn(`mkdir -p ${projectPath}`);

  // Initialize project in that directory
  await pty.spawn('npm init -y', { cwd: projectPath });
  await pty.spawn('npm install express', { cwd: projectPath });
}

PTY Implementation Details

Codify uses two PTY implementations:

BackgroundPty (during planning):

  • Allows parallel command execution for faster refresh operations
  • Automatically killed after planning completes
  • Used in refresh(), validate()

SequentialPty (during apply):

  • Executes commands one at a time, in order
  • Ensures proper error handling and output streaming
  • Used in create(), modify(), destroy()

You don't need to worry about which PTY you're using—getPty() provides the correct one automatically based on context.

Resource Patterns

Codify supports several resource patterns to handle different use cases. Understanding these patterns will help you design resources that match user expectations.

Simple Singleton

The simple singleton pattern is the most basic resource type. Each config entry creates exactly one resource instance on the system.

Use this pattern when:

  • Each resource is unique and independent
  • Users manage resources one at a time
  • No system-wide discovery is needed

Example use case: A single git global configuration per system.

One resource instance per config entry:

class AliasResource extends Resource<AliasConfig> {
  getSettings(): ResourceSettings<AliasConfig> {
    return {
      id: 'alias',
      operatingSystems: ['darwin', 'linux'],
      schema: {
        type: 'object',
        properties: {
          alias: { type: 'string' },
          value: { type: 'string' }
        },
        required: ['alias', 'value']
      },
      allowMultiple: {
        identifyingParameters: ['alias'] // Each unique alias = different resource
      }
    };
  }

  // ... implement refresh/create/destroy
}

Usage:

[
  { "type": "alias", "alias": "ll", "value": "ls -la" },
  { "type": "alias", "alias": "gs", "value": "git status" }
]

Each config entry creates a separate resource. The alias field uniquely identifies each resource—two aliases with the same name would conflict.

Multiple Instances with Custom Matcher

The custom matcher pattern allows resources to exist multiple times on the system with custom logic for matching desired configs to existing resources. This is more flexible than identifyingParameters when matching logic is complex.

Use this pattern when:

  • Multiple instances of a resource can coexist
  • Matching logic is more complex than simple field equality
  • You need platform-specific matching (e.g., case-insensitive paths on macOS)
  • You want to auto-discover existing resources on the system

Example use case: Git repositories can exist in multiple directories, and on macOS paths are case-insensitive.

Use custom logic to match desired configs with system state:

getSettings(): ResourceSettings<MyConfig> {
  return {
    id: 'my-resource',
    allowMultiple: {
      // Custom matching logic
      matcher: (desired, current) => {
        return desired.directory === current.directory;
      },

      // Auto-discovery for `codify import`
      async findAllParameters() {
        // Discover all instances on system
        const instances = await discoverInstances();
        return instances.map(i => ({ directory: i.path }));
      }
    }
  };
}

Real-World Example: Git Repository with Platform-Aware Matching

getSettings(): ResourceSettings<GitRepositoryConfig> {
  return {
    id: 'git-repository',
    allowMultiple: {
      matcher: (desired, current) => {
        // Get absolute paths for comparison
        const desiredPath = path.resolve(desired.directory);
        const currentPath = path.resolve(current.directory);

        // macOS is case-insensitive, Linux is case-sensitive
        if (process.platform === 'darwin') {
          return desiredPath.toLowerCase() === currentPath.toLowerCase();
        }

        return desiredPath === currentPath;
      },

      async findAllParameters() {
        const pty = getPty();

        // Find all git repos in home directory
        const { data } = await pty.spawnSafe(
          'find ~ -name .git -type d -not -path "*/Library/*"'
        );

        const directories = data
          .split('\n')
          .filter(Boolean)
          .map(p => path.dirname(p))
          .map(directory => ({ directory }));

        return directories;
      }
    }
  };
}

The findAllParameters() method enables codify import to discover existing resources automatically. When users run codify import, Codify calls this method and generates config entries for all found instances.

Multi-Declaration Resources

The multi-declaration pattern allows users to manage multiple related items in a single config entry. This is cleaner than requiring separate config entries for each item.

Use this pattern when:

  • Users typically manage groups of similar items together
  • Items are closely related and share common settings
  • Individual items don't need separate configurations
  • You want to offer both declarative and stateful modes

Example use case: Managing multiple shell aliases as a group.

Manage multiple items in a single resource:

const schema = z.object({
  aliases: z.array(z.object({
    alias: z.string(),
    value: z.string()
  }))
});

type AliasesConfig = z.infer<typeof schema>;

class AliasesResource extends Resource<AliasesConfig> {
  getSettings(): ResourceSettings<AliasesConfig> {
    return {
      id: 'aliases',
      schema,
      parameterSettings: {
        aliases: {
          type: 'array',
          canModify: true,
          isElementEqual: (a, b) => a.alias === b.alias,
          filterInStatelessMode: (desired, current) =>
            current.filter(c => desired.some(d => d.alias === c.alias))
        }
      }
    };
  }

  // ... implement refresh/create/modify/destroy
}

Usage:

{
  "type": "aliases",
  "aliases": [
    { "alias": "ll", "value": "ls -la" },
    { "alias": "gs", "value": "git status" }
  ]
}

Key Implementation Details

The critical part of multi-declaration resources is the filterInStatelessMode function:

parameterSettings: {
  aliases: {
    type: 'array',
    isElementEqual: (a, b) => a.alias === b.alias && a.value === b.value,

    // In stateless mode, only track aliases the user declared
    filterInStatelessMode: (desired, current) =>
      current.filter(c => desired.some(d => d.alias === c.alias))
  }
}

This ensures that in stateless mode, Codify only manages aliases explicitly declared in the config. If the user has 50 aliases but only declares 2 in Codify, only those 2 are managed.

For stateful mode, you don't need filtering—Codify tracks all changes and the full state.

Modify Implementation

Multi-declaration resources typically need a modify() implementation to handle array changes efficiently:

async modify(
  pc: ParameterChange<AliasesConfig>,
  plan: ModifyPlan<AliasesConfig>
): Promise<void> {
  const { isStateful } = plan;

  if (isStateful) {
    // In stateful mode, remove deleted items and add new ones
    const aliasesToRemove = pc.previousValue?.filter(
      a => !pc.newValue?.some(c => c.alias === a.alias)
    );
    const aliasesToAdd = pc.newValue?.filter(
      a => !pc.previousValue?.some(c => c.alias === a.alias)
    );

    await this.removeAliases(aliasesToRemove);
    await this.addAliases(aliasesToAdd);
  } else {
    // In stateless mode, only update changed values
    const aliasesToRemove = pc.previousValue?.filter(
      a => pc.newValue?.some(c => c.alias === a.alias && c.value !== a.value)
    );
    const aliasesToAdd = pc.newValue?.filter(
      a => !pc.previousValue?.some(c => c.alias === a.alias) ||
           pc.previousValue?.some(c => c.alias === a.alias && c.value !== a.value)
    );

    await this.removeAliases(aliasesToRemove);
    await this.addAliases(aliasesToAdd);
  }
}

Stateful Parameters

Stateful parameters are parameters that have their own lifecycle—they can be independently created, modified, and destroyed—but are still tied to the parent resource's lifecycle.

Use this pattern when:

  • A parameter represents installable sub-components (packages, versions, plugins)
  • Sub-components can be added/removed independently
  • The parent resource manages the environment for sub-components
  • You want granular control over parameter changes

Example use cases:

  • Homebrew formulae (packages installed within Homebrew)
  • NVM Node versions (versions managed within NVM)
  • Python pip packages (packages installed in a virtualenv)

Parameters with their own lifecycle, tied to the parent resource (e.g., Homebrew formulae, NVM Node versions):

import { StatefulParameter } from '@codifycli/plugin-core';

class FormulaeParameter extends StatefulParameter<HomebrewConfig, string[]> {
  async refresh(desired: string[] | null): Promise<string[] | null> {
    const pty = getPty();
    const result = await pty.spawnSafe('brew list --formula');
    if (result.status === 'error') return null;

    return result.data.split('\n').filter(Boolean);
  }

  async add(formulae: string[], plan: Plan<HomebrewConfig>): Promise<void> {
    const pty = getPty();
    await pty.spawn(`brew install --formula ${formulae.join(' ')}`);
  }

  async remove(formulae: string[], plan: Plan<HomebrewConfig>): Promise<void> {
    const pty = getPty();
    await pty.spawn(`brew uninstall --formula ${formulae.join(' ')}`);
  }

  async modify(newValue: string[], previousValue: string[]): Promise<void> {
    // Handle updates
  }
}

Register in resource settings:

getSettings(): ResourceSettings<HomebrewConfig> {
  return {
    id: 'homebrew',
    parameterSettings: {
      formulae: {
        type: 'stateful',
        definition: new FormulaeParameter(),
        order: 2  // Execute after taps (order: 1)
      }
    }
  };
}

How Stateful Parameters Work

When a user configures Homebrew with formulae:

{
  "type": "homebrew",
  "formulae": ["git", "node", "python"]
}

The framework:

  1. Refresh Phase: Calls FormulaeParameter.refresh() to get currently installed formulae
  2. Planning Phase: Compares desired ["git", "node", "python"] with current state
  3. Apply Phase:
    • If formulae is new → calls FormulaeParameter.add(["git", "node", "python"])
    • If formulae changed → calls add() for new items, remove() for deleted items
    • If formula values changed → calls modify() for changed items

Lifecycle Integration

Stateful parameters integrate with the parent resource lifecycle:

During CREATE:

1. Resource.create() is called
2. FormulaeParameter.add() is called (if formulae specified)

During MODIFY:

1. FormulaeParameter.add() for new formulae
2. FormulaeParameter.remove() for removed formulae
3. FormulaeParameter.modify() for changed formulae
4. Resource.modify() for other parameters

During DESTROY (if removeStatefulParametersBeforeDestroy: true):

1. FormulaeParameter.remove() for all formulae
2. Resource.destroy() is called

Real-World Example: NVM Node Versions

class NodeVersionsParameter extends StatefulParameter<NvmConfig, string[]> {
  async refresh(desired: string[] | null): Promise<string[] | null> {
    const pty = getPty();

    // List installed Node versions
    const result = await pty.spawnSafe('nvm list');
    if (result.status === 'error') return null;

    // Parse output like "v18.0.0", "v20.0.0"
    const versions = result.data
      .split('\n')
      .filter(line => line.includes('v'))
      .map(line => line.match(/v(\d+\.\d+\.\d+)/)?.[1])
      .filter(Boolean);

    return versions;
  }

  async add(versions: string[], plan: Plan<NvmConfig>): Promise<void> {
    const pty = getPty();

    for (const version of versions) {
      await pty.spawn(`nvm install ${version}`);
    }
  }

  async remove(versions: string[], plan: Plan<NvmConfig>): Promise<void> {
    const pty = getPty();

    for (const version of versions) {
      await pty.spawn(`nvm uninstall ${version}`);
    }
  }

  async modify(newValue: string[], previousValue: string[]): Promise<void> {
    // For version numbers, modification doesn't make sense
    // Versions are either added or removed
  }
}

Order of Execution

Use the order property to control the sequence of stateful parameter operations:

parameterSettings: {
  taps: {
    type: 'stateful',
    definition: new TapsParameter(),
    order: 1  // Install taps first
  },
  formulae: {
    type: 'stateful',
    definition: new FormulaeParameter(),
    order: 2  // Then install formulae (which may come from taps)
  },
  casks: {
    type: 'stateful',
    definition: new CasksParameter(),
    order: 3  // Finally install casks
  }
}

Lower order numbers execute first. This is important when parameters have dependencies on each other.

Parameter Settings

Configure parameter behavior:

parameterSettings: {
  // Modifiable parameter
  version: {
    canModify: true
  },

  // Directory path with transformation
  path: {
    type: 'directory',
    inputTransformation: {
      to: (input) => untildify(input),     // Expand ~
      from: (current) => tildify(current)  // Convert to ~
    }
  },

  // Sensitive parameter (hidden in output)
  apiKey: {
    isSensitive: true
  },

  // Array parameter with custom equality
  tags: {
    type: 'array',
    isElementEqual: (a, b) => a.name === b.name,
    filterInStatelessMode: (desired, current) =>
      current.filter(c => desired.some(d => d.name === c.name))
  },

  // Boolean setting (not tracked in state)
  skipCache: {
    type: 'boolean',
    default: true,
    setting: true
  }
}

Cross-Platform Support

Platform Detection

import { Utils } from '@codifycli/plugin-core';

if (Utils.isMacOS()) {
  // macOS-specific logic
} else if (Utils.isLinux()) {
  // Linux-specific logic
}

OS Declaration

getSettings(): ResourceSettings<MyConfig> {
  return {
    operatingSystems: ['darwin', 'linux'],
    linuxDistros: ['ubuntu', 'debian', 'fedora']
  };
}

Path Handling

import { tildify, untildify } from '@codifycli/plugin-core';

const absolutePath = untildify('~/projects');  // /Users/john/projects
const tildePath = tildify('/Users/john/projects'); // ~/projects

Testing

Testing is crucial for plugins because they interact with the real system. The @codifycli/plugin-test package provides utilities for writing comprehensive tests that verify the entire resource lifecycle.

Test Strategy

Codify plugins should have two types of tests:

Unit Tests (in src/**/*.test.ts):

  • Fast, isolated tests
  • Test parsing logic, utility functions, data transformations
  • No system calls or side effects
  • Run with every code change

Integration Tests (in test/**/*.test.ts):

  • Full lifecycle tests against the real system
  • Test create → modify → destroy flow
  • Verify actual system changes
  • Slower, require specific prerequisites

Integration Testing with PluginTester

The PluginTester.fullTest() method runs a complete resource lifecycle test:

import { PluginTester, testSpawn } from '@codifycli/plugin-test';
import { describe, it } from 'vitest';
import path from 'node:path';

describe('Alias resource', () => {
  const pluginPath = path.resolve('./src/index.ts');

  it('manages shell alias lifecycle', { timeout: 300000 }, async () => {
    await PluginTester.fullTest(
      pluginPath,
      [
        {
          type: 'alias',
          alias: 'my-alias',
          value: 'ls -l'
        }
      ],
      {
        // Validate the CREATE operation
        validateApply: async () => {
          const { data } = await testSpawn('alias');
          expect(data).toContain('my-alias');
          expect(data).toContain('ls -l');

          // Test that alias actually works
          const result = await testSpawn('my-alias');
          expect(result.status).toBe('success');
        },

        // Test the MODIFY operation
        testModify: {
          modifiedConfigs: [{
            type: 'alias',
            alias: 'my-alias',
            value: 'pwd'  // Changed value
          }],
          validateModify: async () => {
            const { data } = await testSpawn('alias');
            expect(data).toContain('my-alias');
            expect(data).toContain('pwd');
            expect(data).not.toContain('ls -l');
          }
        },

        // Validate the DESTROY operation
        validateDestroy: async () => {
          const { data } = await testSpawn('alias');
          expect(data).not.toContain('my-alias');
        }
      }
    );
  });
});

What fullTest() Does

The fullTest() method executes this sequence:

  1. Initial Refresh: Saves current system state
  2. Validate: Validates configs against schemas
  3. Plan (Create): Generates CREATE plan
  4. Apply (Create): Executes CREATE operation
  5. Validate Apply: Runs your validateApply() callback
  6. Plan (Modify): Generates MODIFY plan with modified configs
  7. Apply (Modify): Executes MODIFY operation
  8. Validate Modify: Runs your validateModify() callback
  9. Plan (Destroy): Generates DESTROY plan
  10. Apply (Destroy): Executes DESTROY operation
  11. Validate Destroy: Runs your validateDestroy() callback

This ensures your resource handles the complete lifecycle correctly.

Testing Configuration Variations

Test different configuration scenarios:

it('handles multiple aliases', async () => {
  await PluginTester.fullTest(
    pluginPath,
    [
      {
        type: 'aliases',
        aliases: [
          { alias: 'gs', value: 'git status' },
          { alias: 'gp', value: 'git pull' },
          { alias: 'gc', value: 'git commit' }
        ]
      }
    ],
    {
      validateApply: async () => {
        const { data } = await testSpawn('alias');
        expect(data).toContain('gs=');
        expect(data).toContain('gp=');
        expect(data).toContain('gc=');
      },
      testModify: {
        modifiedConfigs: [{
          type: 'aliases',
          aliases: [
            { alias: 'gs', value: 'git status' },
            // Removed gp
            { alias: 'gc', value: 'git commit -v' },  // Modified
            { alias: 'gd', value: 'git diff' }  // Added
          ]
        }],
        validateModify: async () => {
          const { data } = await testSpawn('alias');
          expect(data).toContain('gs=');
          expect(data).not.toContain('gp=');
          expect(data).toContain('gc=\'git commit -v\'');
          expect(data).toContain('gd=');
        }
      },
      validateDestroy: async () => {
        const { data } = await testSpawn('alias');
        expect(data).not.toContain('gs=');
        expect(data).not.toContain('gc=');
        expect(data).not.toContain('gd=');
      }
    }
  );
});

Testing Validation Errors

Test that your resource properly rejects invalid configurations:

it('rejects invalid alias names', async () => {
  await expect(async () => {
    await PluginTester.fullTest(
      pluginPath,
      [{
        type: 'alias',
        alias: 'invalid-name-with-$-symbols',
        value: 'ls'
      }]
    );
  }).rejects.toThrow();
});

it('requires both alias and value fields', async () => {
  await expect(async () => {
    await PluginTester.fullTest(
      pluginPath,
      [{
        type: 'alias',
        alias: 'myalias'
        // Missing value field
      }]
    );
  }).rejects.toThrow();
});

Platform-Specific Tests

Test different behaviors on different platforms:

import { Utils } from '@codifycli/plugin-core';

it('handles platform-specific installation', async () => {
  const expectedCommand = Utils.isMacOS()
    ? 'brew install my-tool'
    : 'apt-get install my-tool';

  await PluginTester.fullTest(
    pluginPath,
    [{ type: 'my-tool' }],
    {
      validateApply: async () => {
        // Verify tool was installed correctly
        const result = await testSpawn('which my-tool');
        expect(result.status).toBe('success');
      }
    }
  );
});

Test Setup and Teardown

Use Vitest's lifecycle hooks to set up and clean up test environments:

import { beforeAll, afterAll, beforeEach, describe, it } from 'vitest';

describe('Homebrew tests', () => {
  beforeAll(async () => {
    // Ensure Homebrew is installed before running tests
    const result = await testSpawn('which brew');
    if (result.status === 'error') {
      throw new Error('Homebrew must be installed to run these tests');
    }
  });

  afterAll(async () => {
    // Clean up any test artifacts
    await testSpawn('brew cleanup');
  });

  beforeEach(() => {
    // Reset state before each test
  });

  it('installs formulae', async () => {
    // Test implementation
  });
});

Testing with Real Files

When testing resources that modify files, use temporary directories:

import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs/promises';

it('creates config file', async () => {
  const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codify-test-'));

  try {
    await PluginTester.fullTest(
      pluginPath,
      [{
        type: 'my-config',
        configPath: path.join(tempDir, 'config.json'),
        setting: 'value'
      }],
      {
        validateApply: async () => {
          const configExists = await fs.access(
            path.join(tempDir, 'config.json')
          ).then(() => true).catch(() => false);

          expect(configExists).toBe(true);

          const config = JSON.parse(
            await fs.readFile(path.join(tempDir, 'config.json'), 'utf8')
          );
          expect(config.setting).toBe('value');
        },
        validateDestroy: async () => {
          const configExists = await fs.access(
            path.join(tempDir, 'config.json')
          ).then(() => true).catch(() => false);

          expect(configExists).toBe(false);
        }
      }
    );
  } finally {
    // Clean up temp directory
    await fs.rm(tempDir, { recursive: true, force: true });
  }
});

Unit Testing Utilities

For utility functions and parsing logic, write simple unit tests:

import { describe, it, expect } from 'vitest';
import { parseBrewList } from './homebrew-utils.js';

describe('parseBrewList', () => {
  it('parses brew list output', () => {
    const output = `git
node
python@3.11`;

    const result = parseBrewList(output);

    expect(result).toEqual(['git', 'node', 'python@3.11']);
  });

  it('handles empty output', () => {
    expect(parseBrewList('')).toEqual([]);
  });

  it('filters out warnings', () => {
    const output = `Warning: Some warning message
git
node`;

    const result = parseBrewList(output);

    expect(result).toEqual(['git', 'node']);
  });
});

Best Practices for Testing

  1. Use long timeouts for integration tests: System operations can be slow

    it('installs packages', { timeout: 300000 }, async () => {
      // Test implementation
    });
  2. Clean up after tests: Always restore system state

    afterAll(async () => {
      await cleanup();
    });
  3. Test edge cases: Empty configs, missing fields, invalid values

  4. Test error conditions: What happens when commands fail?

  5. Use descriptive test names: Make failures easy to understand

    it('installs Homebrew in custom directory and adds to PATH', async () => {
      // ...
    });
  6. Isolate tests: Each test should be independent and not rely on others

  7. Mock external dependencies in unit tests: Use mocking for fast, deterministic unit tests

Project Structure

my-plugin/
├── src/
│   ├── index.ts                 # Plugin entry point
│   ├── resources/
│   │   ├── my-resource/
│   │   │   ├── my-resource.ts
│   │   │   └── my-resource-schema.json
│   │   └── ...
│   └── utils/
├── test/
│   └── my-resource.test.ts
├── package.json
├── tsconfig.json
└── vitest.config.ts

package.json

{
  "name": "my-codify-plugin",
  "version": "1.0.0",
  "type": "module",
  "main": "dist/index.js",
  "dependencies": {
    "@codifycli/plugin-core": "^1.0.0",
    "@codifycli/schemas": "^1.0.0"
  },
  "devDependencies": {
    "@codifycli/plugin-test": "^1.0.0",
    "typescript": "^5.0.0",
    "vitest": "^2.0.0"
  }
}

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "dist",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "dist", "test"]
}

Building and Deployment

Build Script

npm run build

Use Rollup to bundle your plugin:

// rollup.config.js
export default {
  input: 'src/index.ts',
  output: {
    file: 'dist/index.js',
    format: 'esm'
  },
  external: [
    '@codifycli/plugin-core',
    '@codifycli/schemas',
    /^node:.*/
  ]
};

Best Practices

Error Handling

Proper error handling makes your plugin reliable and helps users understand what went wrong.

Provide context in error messages:

async create(plan: CreatePlan<MyConfig>): Promise<void> {
  const pty = getPty();

  try {
    await pty.spawn('install-command');
  } catch (error) {
    // Bad: Generic error
    throw error;

    // Good: Contextual error with actionable information
    throw new Error(
      `Failed to install ${plan.desiredConfig.name}. ` +
      `Ensure you have internet connectivity and sufficient disk space. ` +
      `Original error: ${error.message}`
    );
  }
}

Validate prerequisites in refresh():

async refresh(parameters: Partial<MyConfig>): Promise<Partial<MyConfig> | null> {
  const pty = getPty();

  // Check if parent resource exists
  const brewInstalled = await pty.spawnSafe('which brew');
  if (brewInstalled.status === 'error') {
    throw new Error(
      'Homebrew is required but not installed. ' +
      'Add a homebrew resource to your config first.'
    );
  }

  // Continue with refresh...
}

Handle partial failures gracefully:

async create(plan: CreatePlan<MyConfig>): Promise<void> {
  const pty = getPty();
  const packages = plan.desiredConfig.packages;
  const failed: string[] = [];

  for (const pkg of packages) {
    try {
      await pty.spawn(`install ${pkg}`);
    } catch (error) {
      failed.push(pkg);
      console.error(`Failed to install ${pkg}: ${error.message}`);
    }
  }

  if (failed.length > 0) {
    throw new Error(
      `Failed to install packages: ${failed.join(', ')}. ` +
      `Successfully installed: ${packages.filter(p => !failed.includes(p)).join(', ')}`
    );
  }
}

Resource Dependencies

Dependencies ensure resources are applied in the correct order. Codify automatically sorts resources based on their dependency graph.

Declare dependencies explicitly:

getSettings(): ResourceSettings<GitRepositoryConfig> {
  return {
    id: 'git-repository',
    // These resources must exist before cloning repos
    dependencies: ['ssh-key', 'git'],
  };
}

Use dependencies for shared resources:

getSettings(): ResourceSettings<PipPackageConfig> {
  return {
    id: 'pip-package',
    // Ensure Python and pip are installed first
    dependencies: ['python', 'pyenv'],
  };
}

Dependencies are transitive:

If resource A depends on B, and B depends on C, Codify ensures C → B → A execution order.

Circular dependencies are detected:

Codify will throw an error if you create circular dependencies, preventing infinite loops.

Refresh Return Semantics

The value you return from refresh() determines how Codify plans operations. Follow these conventions:

Return null when resource doesn't exist:

async refresh(parameters: Partial<MyConfig>): Promise<Partial<MyConfig> | null> {
  const pty = getPty();
  const result = await pty.spawnSafe('check-installation');

  if (result.status === 'error') {
    return null; // Will trigger CREATE operation
  }

  return {
    version: parseVersion(result.data)
  };
}

Return {} for resources with no trackable parameters:

async refresh(parameters: Partial<DockerConfig>): Promise<Partial<DockerConfig> | null> {
  const pty = getPty();

  // Check if Docker is running
  const result = await pty.spawnSafe('docker info');
  if (result.status === 'error') {
    return null; // Docker not installed
  }

  // Docker is installed but has no parameters to track
  return {};
}

Return partial config for existing resources:

async refresh(parameters: Partial<GitConfig>): Promise<Partial<GitConfig> | null> {
  const pty = getPty();

  const nameResult = await pty.spawnSafe('git config --global user.name');
  const emailResult = await pty.spawnSafe('git config --global user.email');

  // Git is installed but not configured
  if (nameResult.status === 'error' && emailResult.status === 'error') {
    return {}; // Will trigger CREATE with all parameters
  }

  // Return only configured parameters
  return {
    userName: nameResult.status === 'success' ? nameResult.data.trim() : undefined,
    userEmail: emailResult.status === 'success' ? emailResult.data.trim() : undefined,
  };
}

Only query requested parameters:

async refresh(parameters: Partial<HomebrewConfig>): Promise<Partial<HomebrewConfig> | null> {
  const result: Partial<HomebrewConfig> = {};

  // Only check directory if user specified it
  if (parameters.directory) {
    result.directory = await this.getCurrentDirectory();
  }

  // Don't query other parameters that weren't requested
  return result;
}

Declarative Mode Filtering

For array parameters in stateless mode, use filterInStatelessMode to implement declarative behavior:

parameterSettings: {
  paths: {
    type: 'array',
    canModify: true,
    // Only track paths that user explicitly declared
    filterInStatelessMode: (desired, current) =>
      current.filter(c => desired.some(d => d === c))
  }
}

This pattern ensures Codify only manages items the user explicitly declared, ignoring other system state.

For complex objects:

parameterSettings: {
  aliases: {
    type: 'array',
    itemType: 'object',
    // Match by alias name, not value
    isElementEqual: (a, b) => a.alias === b.alias,
    filterInStatelessMode: (desired, current) =>
      current.filter(c => desired.some(d => d.alias === c.alias))
  }
}

Idempotency

Ensure your resources can be applied multiple times safely:

async create(plan: CreatePlan<MyConfig>): Promise<void> {
  const pty = getPty();

  // Bad: Fails if directory already exists
  await pty.spawn(`mkdir ${plan.desiredConfig.directory}`);

  // Good: Idempotent - safe to run multiple times
  await pty.spawn(`mkdir -p ${plan.desiredConfig.directory}`);
}

Check before modifying:

async create(plan: CreatePlan<AliasConfig>): Promise<void> {
  const shellRcPath = Utils.getPrimaryShellRc();

  // Create file if it doesn't exist
  if (!(await fileExists(shellRcPath))) {
    await fs.writeFile(shellRcPath, '', 'utf8');
  }

  // Check if alias already exists
  const contents = await fs.readFile(shellRcPath, 'utf8');
  const aliasString = `alias ${plan.desiredConfig.alias}='${plan.desiredConfig.value}'`;

  if (!contents.includes(aliasString)) {
    await FileUtils.addToStartupFile(aliasString);
  }
}

Performance Optimization

Use spawnSafe() efficiently:

// Bad: Sequential checks
const hasGit = await pty.spawnSafe('which git');
const hasNode = await pty.spawnSafe('which node');
const hasPython = await pty.spawnSafe('which python');

// Good: Single command
const result = await pty.spawnSafe('which git node python');

Cache expensive operations:

private cachedBrewInfo: string | null = null;

async getBrewInfo(): Promise<string> {
  if (this.cachedBrewInfo) {
    return this.cachedBrewInfo;
  }

  const pty = getPty();
  const result = await pty.spawn('brew config');
  this.cachedBrewInfo = result.data;

  return result.data;
}

Security Considerations

Sanitize user input:

async create(plan: CreatePlan<MyConfig>): Promise<void> {
  const pty = getPty();
  const { directory, name } = plan.desiredConfig;

  // Bad: Command injection vulnerability
  await pty.spawn(`mkdir ${directory}/${name}`);

  // Good: Validate and sanitize
  if (name.includes('/') || name.includes('..')) {
    throw new Error('Invalid name: must not contain / or ..');
  }

  await pty.spawn(`mkdir -p "${path.join(directory, name)}"`);
}

Use requiresRoot judiciously:

// Only require root when absolutely necessary
async create(plan: CreatePlan<MyConfig>): Promise<void> {
  const pty = getPty();

  // Install to user directory - no sudo needed
  if (plan.desiredConfig.directory.startsWith(os.homedir())) {
    await pty.spawn(`./install.sh --prefix ${plan.desiredConfig.directory}`);
  } else {
    // System directory - need sudo
    await pty.spawn(
      `./install.sh --prefix ${plan.desiredConfig.directory}`,
      { requiresRoot: true }
    );
  }
}

Mark sensitive parameters:

getSettings(): ResourceSettings<AwsConfig> {
  return {
    id: 'aws-profile',
    isSensitive: true,  // Prevents auto-import
    parameterSettings: {
      awsAccessKeyId: {
        isSensitive: true  // Hides value in plan output
      },
      awsSecretAccessKey: {
        isSensitive: true
      }
    }
  };
}

On this page

Best Practices for refresh()create()Real-World Example: Homebrew InstallationHandling Stateful Parametersdestroy()Real-World Example: Git Repository ResourceHandling Stateful Parametersmodify()Real-World Example: Alias ResourceWhen NOT to Implement modify()Schema ValidationJSON SchemaZod (Preferred)PTY AbstractionWhy PTY Instead of child_process?Getting the PTY Instancespawn() - Execute with Error HandlingspawnSafe() - Execute Without ThrowingSpawn OptionsOption DetailsReal-World ExamplesChecking if a tool is installed:Installing with platform-specific commands:Running commands in a specific directory:PTY Implementation DetailsResource PatternsSimple SingletonMultiple Instances with Custom MatcherReal-World Example: Git Repository with Platform-Aware MatchingMulti-Declaration ResourcesKey Implementation DetailsModify ImplementationStateful ParametersHow Stateful Parameters WorkLifecycle IntegrationReal-World Example: NVM Node VersionsOrder of ExecutionParameter SettingsCross-Platform SupportPlatform DetectionOS DeclarationPath HandlingTestingTest StrategyIntegration Testing with PluginTesterWhat fullTest() DoesTesting Configuration VariationsTesting Validation ErrorsPlatform-Specific TestsTest Setup and TeardownTesting with Real FilesUnit Testing UtilitiesBest Practices for TestingProject Structurepackage.jsontsconfig.jsonBuilding and DeploymentBuild ScriptBest PracticesError HandlingResource DependenciesRefresh Return SemanticsDeclarative Mode FilteringIdempotencyPerformance OptimizationSecurity Considerations