Codify
Plugin Development

Resource lifecycle

Implement the resource lifecycle methods

Resource Lifecycle Methods

getSettings()

The getSettings() method is the first thing Codify calls when initializing your plugin. It returns a ResourceSettings object that defines everything Codify needs to know about your resource.

This method is called once during plugin initialization and the settings are cached. It should be pure and deterministic—always return the same settings for the same resource.

getSettings(): ResourceSettings<MyConfig> {
  return {
    // Required: Unique identifier for this resource type
    // This becomes the "type" field users use in their configs
    id: 'my-resource',

    // Required: Which operating systems this resource supports
    // Options: 'darwin' (macOS), 'linux', 'win32' (Windows)
    operatingSystems: ['darwin', 'linux'],

    // Required: JSON Schema or Zod schema for validation
    // Validates user input before any operations are performed
    schema: { /* JSON Schema or Zod */ },

    // Optional: Specific Linux distributions supported
    // Only checked when operatingSystems includes 'linux'
    linuxDistros: ['ubuntu', 'debian', 'fedora'],

    // Optional: Other resources this resource depends on
    // Codify ensures dependencies are applied first
    dependencies: ['homebrew', 'git'],

    // Optional: Allow multiple instances of this resource
    // See "Resource Patterns" section for details
    allowMultiple: true,

    // Optional: Whether this resource can be destroyed
    // Set to false for critical system resources
    canDestroy: true,

    // Optional: Mark resource as sensitive
    // Prevents auto-import and hides values in output
    isSensitive: false,

    // Optional: Per-parameter configuration
    // Controls how individual parameters behave
    parameterSettings: {
      myParam: {
        canModify: true,        // Can be changed without recreating
        type: 'directory',      // Parameter type hint
        isSensitive: false,     // Hide this parameter in plans
        default: 'value',       // Default value if not provided
        // ... more settings (see Parameter Settings section)
      }
    }
  };
}

Common Configuration Patterns

Simple resource with no special behavior:

getSettings(): ResourceSettings<MyConfig> {
  return {
    id: 'simple-tool',
    operatingSystems: ['darwin', 'linux'],
    schema: MySchema,
  };
}

Resource with dependencies:

getSettings(): ResourceSettings<GitRepositoryConfig> {
  return {
    id: 'git-repository',
    operatingSystems: ['darwin', 'linux'],
    schema: GitRepoSchema,
    // Ensure SSH and git are set up before cloning repositories
    dependencies: ['ssh-key', 'git'],
  };
}

Sensitive resource (e.g., API keys):

getSettings(): ResourceSettings<AwsProfileConfig> {
  return {
    id: 'aws-profile',
    operatingSystems: ['darwin', 'linux'],
    schema: AwsSchema,
    isSensitive: true,  // Prevents auto-discovery and import
    parameterSettings: {
      awsSecretAccessKey: {
        isSensitive: true,  // Hides value in plan output
      }
    }
  };
}

refresh()

The refresh() method is called during the planning phase to query the current state of the resource on the system. This is one of the most important methods in your resource—it tells Codify what currently exists so it can calculate what changes are needed.

When it's called:

  • During codify plan to generate the change set
  • During codify apply before execution
  • During codify import to discover existing resources

What to return:

  • null - Resource doesn't exist on the system
  • {} - Resource exists but has no trackable parameters
  • Partial<MyConfig> - Resource exists with these parameter values

Important: Only query parameters that are passed in the parameters argument. Don't query all possible parameters—Codify tells you what it cares about based on the user's config.

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

  // Use spawnSafe() to avoid throwing on errors
  const result = await pty.spawnSafe('check-if-installed');

  if (result.status === 'error') {
    return null; // Resource doesn't exist
  }

  // Parse the output and return current state
  return {
    version: parseVersion(result.data),
    path: parsePath(result.data)
  };
}

Real-World Example: Homebrew Resource

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

  // Check if Homebrew is installed
  const homebrewInfo = await pty.spawnSafe('brew config');
  if (homebrewInfo.status === SpawnStatus.ERROR) {
    return null; // Homebrew not installed
  }

  const result: Partial<HomebrewConfig> = {}

  // Only query directory if user specified it in their config
  if (parameters.directory) {
    result.directory = this.getCurrentLocation(homebrewInfo.data);
  }

  // Stateful parameters (formulae, casks) are handled automatically
  // by StatefulParameter classes - no need to query them here

  return result;
}

Refresh for Resources with allowMultiple

When your resource allows multiple instances (like multiple git repositories), refresh() can return an array:

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

  if (parameters.parentDirectory) {
    // Find all git repos in parent directory
    const { data } = await pty.spawnSafe(
      `find "${parameters.parentDirectory}" -maxdepth 2 -type d -name .git`
    );

    const gitDirs = data?.split(/\n/)?.filter(Boolean) ?? [];
    if (gitDirs.length === 0) {
      return null;
    }

    // Query each repository's remote URL
    const repositories: string[] = [];
    for (const gitDir of gitDirs) {
      const repoPath = path.dirname(gitDir);
      const { data: url } = await pty.spawnSafe(
        'git config --get remote.origin.url',
        { cwd: repoPath }
      );
      if (url) repositories.push(url.trim());
    }

    return {
      parentDirectory: parameters.parentDirectory,
      repositories,
    };
  }

  // Single repository case
  if (parameters.directory) {
    const exists = await fileExists(parameters.directory);
    if (!exists) return null;

    const { data: url } = await pty.spawn(
      'git config --get remote.origin.url',
      { cwd: parameters.directory }
    );

    return {
      directory: parameters.directory,
      repository: url.trim(),
    };
  }

  throw new Error('Either directory or parentDirectory must be supplied');
}

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.

On this page