Best practices
Write reliable, maintainable plugins
Best Practices for refresh()
-
Return
nullfor non-existent resources - This is how Codify knows to create the resource. -
Query only requested parameters - Check the
parametersargument to see what the user configured. Don't query everything. -
Handle errors gracefully - If a command fails, return
nullrather than throwing (unless it's an unexpected error). -
Be efficient - This method is called during every plan operation. Avoid expensive operations if possible.
-
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 applywhen the resource operation isCREATE - 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 wantsplan.isStateful- Whether this is a stateful operationplan.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 applywhen the resource operation isDESTROY - 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 applywhen the resource operation isMODIFY - Once per parameter that changed
- Only for parameters where
canModify: truein parameter settings
What you receive:
parameterChange.name- Which parameter changedparameterChange.operation- ADD, REMOVE, or MODIFYparameterChange.newValue- The new valueparameterChange.previousValue- The old valueplan- 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?
- Interactive Commands: PTY supports commands that require user input (like installers)
- Output Streaming: Real-time stdout/stderr is streamed to the user's terminal
- Privilege Escalation: Automatic handling of
sudopassword prompts - 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 stringWhen 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:
- Sends a message to the parent CLI process
- CLI prompts user for their sudo password
- Password is sent back to plugin securely
- 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:
- 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.
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'); // ~/projectsTesting
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 → destroyflow - 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:
- Initial Refresh: Saves current system state
- Validate: Validates configs against schemas
- Plan (Create): Generates CREATE plan
- Apply (Create): Executes CREATE operation
- Validate Apply: Runs your
validateApply()callback - Plan (Modify): Generates MODIFY plan with modified configs
- Apply (Modify): Executes MODIFY operation
- Validate Modify: Runs your
validateModify()callback - Plan (Destroy): Generates DESTROY plan
- Apply (Destroy): Executes DESTROY operation
- 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
-
Use long timeouts for integration tests: System operations can be slow
it('installs packages', { timeout: 300000 }, async () => { // Test implementation }); -
Clean up after tests: Always restore system state
afterAll(async () => { await cleanup(); }); -
Test edge cases: Empty configs, missing fields, invalid values
-
Test error conditions: What happens when commands fail?
-
Use descriptive test names: Make failures easy to understand
it('installs Homebrew in custom directory and adds to PATH', async () => { // ... }); -
Isolate tests: Each test should be independent and not rely on others
-
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.tspackage.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 buildUse 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
}
}
};
}