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:
- Refresh Phase: Calls
FormulaeParameter.refresh()to get currently installed formulae - Planning Phase: Compares desired
["git", "node", "python"]with current state - 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
- If formulae is new → calls
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 parametersDuring DESTROY (if removeStatefulParametersBeforeDestroy: true):
1. FormulaeParameter.remove() for all formulae
2. Resource.destroy() is calledReal-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.