Codify
Plugin Development

PTY abstraction

Execute shell commands through the pseudo-terminal abstraction

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.

On this page