Codify
Plugin Development

Resource patterns

Common plugin patterns and architectures

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.

On this page