Skip to content

Latest commit

 

History

History
417 lines (327 loc) · 11.1 KB

File metadata and controls

417 lines (327 loc) · 11.1 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project Overview

This is a Codify plugin that provides 50+ declarative system configuration resources (Homebrew, Git, shell aliases, Python environments, etc.) built on the @codifycli/plugin-core framework. Users write JSON configurations describing their desired system state, and the framework generates and executes plans to achieve that state.

Build and Test Commands

# Build the plugin (compiles TypeScript, bundles with Rollup, generates schemas.json)
npm run build

# Run all tests (unit + integration)
npm test

# Run unit tests only (fast - tests in src/**/*.test.ts)
npm run test:unit

# Run integration tests only (slow - full lifecycle tests in test/**/*.test.ts)
npm run test:integration

# Run integration tests in development mode
npm run test:integration:dev

# Deploy to Cloudflare R2
npm run deploy

# Deploy beta version
npm run deploy:beta

Running a single test:

# Unit test
npx vitest src/resources/shell/path/path-resource.test.ts

# Integration test
npx vitest test/shell/path.test.ts

Core Architecture

Plugin System

This plugin uses a Resource-based architecture where:

  1. Each resource type (git, homebrew, alias, etc.) extends the Resource<ConfigType> base class from @codifycli/plugin-core
  2. Resources are registered in src/index.ts via Plugin.create('default', [resource instances])
  3. Resources implement 5 core lifecycle methods:
    • getSettings() - Define schema, parameters, dependencies, OS support
    • refresh() - Read current system state
    • create() - Create new resource
    • modify() - Modify existing resource (optional)
    • destroy() - Remove resource

Resource Registration

All resources are registered in /src/index.ts:

runPlugin(Plugin.create('default', [
  new GitResource(),
  new HomebrewResource(),
  new AliasResource(),
  // ... 50+ more resources
]))

Resource Lifecycle Pattern

Every resource follows this pattern:

export class MyResource extends Resource<MyConfig> {
  getSettings(): ResourceSettings<MyConfig> {
    return {
      id: 'unique-id',
      operatingSystems: [OS.Darwin, OS.Linux],
      schema: JSONSchema or ZodSchema,
      parameterSettings: { /* ... */ },
      dependencies: ['other-resource-ids'],
      allowMultiple: { /* ... */ }
    }
  }

  async refresh(params): Promise<Partial<MyConfig> | null> {
    // Returns null if resource doesn't exist
    // Returns object with current state if it exists
  }

  async create(plan): Promise<void> { /* ... */ }
  async modify(pc, plan): Promise<void> { /* ... */ }
  async destroy(plan): Promise<void> { /* ... */ }
}

Three Resource Complexity Levels

1. Simple Singleton (e.g., shell/alias/alias-resource.ts):

  • One resource instance per config entry
  • Uses allowMultiple.identifyingParameters: ['alias'] to support multiple aliases
  • Each unique identifying parameter value becomes a separate resource

2. Multi-Declaration (e.g., shell/aliases/aliases-resource.ts):

  • Manages multiple items in a single resource (array of aliases)
  • Uses Zod schemas for type safety
  • Implements declarationsOnly mode for stateless/stateful behavior

3. Complex with Auto-Discovery (e.g., git/repository/git-repository.ts):

  • Supports multiple configuration modes (single repo vs multiple repos)
  • Uses allowMultiple.matcher() for custom matching logic
  • Uses allowMultiple.findAllParameters() for system discovery
  • Declares dependencies to ensure prerequisites are met

Declarative vs Stateful Resources

CRITICAL DISTINCTION:

Declarative Mode (Default)

  • Framework only manages explicitly declared items in the config
  • System state is filtered to match declarations
  • Safer default - won't accidentally capture unwanted system state
  • Example: Only manage the paths/aliases the user explicitly listed

Implementation:

parameterSettings: {
  paths: {
    filterInStatelessMode: (desired, current) =>
      current.filter((c) => desired.some((d) => d === c))
  }
}

Stateful Mode (Opt-in)

  • Framework manages complete state of resource
  • Tracks what changed over time (additions/removals)
  • Uses StatefulParameter classes with add(), modify(), remove() methods
  • Example: Homebrew formulae - track all installed packages

Implementation:

parameterSettings: {
  formulae: {
    type: 'stateful',
    definition: new FormulaeParameter(),
    order: 2
  }
}

Schema Validation

Two approaches are supported:

1. JSON Schema (traditional):

// Separate .json file
import Schema from './my-resource-schema.json'

export interface MyConfig extends StringIndexedObject {
  field: string
}

getSettings() {
  return { schema: Schema }
}

2. Zod Schema (preferred):

// Single source of truth - schema and types in sync
export const schema = z.object({
  field: z.string(),
  optional: z.boolean().optional(),
})

export type MyConfig = z.infer<typeof schema>

getSettings() {
  return { schema }
}

Zod is preferred because types are automatically inferred from the schema, preventing drift between validation and TypeScript types.

Testing Strategy

Unit Tests (src/**/*.test.ts)

  • Fast tests for parsing, regex, data transformation
  • No system calls
  • Test individual functions in isolation

Integration Tests (test/**/*.test.ts)

  • Full lifecycle tests against real system
  • Uses PluginTester.fullTest() from @codifycli/plugin-test
  • Tests create → modify → destroy flow
  • Includes validation callbacks

Integration Test Pattern:

import { PluginTester } from '@codifycli/plugin-test'

await PluginTester.fullTest(pluginPath, [
  { type: 'alias', alias: 'my-alias', value: 'ls -l' }
], {
  validateApply: async () => {
    // Verify resource was created
  },
  testModify: {
    modifiedConfigs: [{ type: 'alias', alias: 'my-alias', value: 'pwd' }],
    validateModify: async () => {
      // Verify modification succeeded
    }
  },
  validateDestroy: async () => {
    // Verify resource was removed
  }
})

Test Setup (test/setup.ts)

  • Global beforeAll saves shell RC state and ensures prerequisites (Xcode, Homebrew on macOS)
  • Global afterAll restores shell RC to original state
  • Platform-specific setup using TestUtils

Framework Utilities

The @codifycli/plugin-core framework provides:

Shell/PTY Access:

const $ = getPty()

// Safe spawn (never throws, returns status)
const { data, status } = await $.spawnSafe('command')
if (status === SpawnStatus.ERROR) { /* handle */ }

// Regular spawn (throws on error)
const { data } = await $.spawn('command', {
  interactive: true,
  cwd: '/path',
  requiresRoot: true,
  env: { VAR: 'value' }
})

File Operations:

await FileUtils.addToStartupFile(lineToAdd)
await FileUtils.addToShellRc(lineToAdd)
await FileUtils.addPathToPrimaryShellRc(pathValue, prepend)
await FileUtils.removeLineFromFile(filePath, lineContent)
await FileUtils.fileExists(path)
await FileUtils.dirExists(path)

OS Detection:

Utils.isMacOS()
Utils.isLinux()
Utils.isWindows()

Build Process

The build process (scripts/build.ts) does:

  1. Removes dist/ folder
  2. Runs Rollup to compile TypeScript → ES modules
  3. Forks the built plugin and queries it for all resource schemas
  4. Merges each resource schema with base ResourceSchema
  5. Rebuilds with Rollup → CommonJS
  6. Writes dist/schemas.json containing all resource schemas

The dist/schemas.json file is used by the CLI for validation and documentation.

Deploy Process

Deployment (scripts/deploy.ts) uploads the built plugin to Cloudflare R2:

  • Production: plugins/{name}/{version}/index.js
  • Beta: plugins/{name}/beta/index.js

Key Patterns

allowMultiple Configuration

Simple boolean:

allowMultiple: true

With identifying parameters:

allowMultiple: {
  identifyingParameters: ['path']  // Each unique 'path' = different resource
}

With custom matcher and auto-discovery:

allowMultiple: {
  matcher: (desired, current) => desired.directory === current.directory,
  async findAllParameters() {
    // Discover all instances on system
    return [{ directory: '...' }, ...]
  }
}

Parameter Settings

parameterSettings: {
  // Boolean setting (not tracked in state)
  skipAlreadyInstalledCasks: {
    type: 'boolean',
    default: true,
    setting: true
  },

  // Directory path
  directory: {
    type: 'directory'
  },

  // Modifiable array
  paths: {
    type: 'array',
    itemType: 'directory',
    canModify: true,
    isElementEqual: (a, b) => a === b,
    filterInStatelessMode: (desired, current) => /* ... */
  },

  // Stateful parameter with custom handler
  formulae: {
    type: 'stateful',
    definition: new FormulaeParameter(),
    order: 2
  }
}

Dependencies

Resources can declare dependencies on other resources:

getSettings() {
  return {
    dependencies: ['ssh-key', 'git']  // Apply these first
  }
}

The framework automatically validates dependencies exist and orders execution.

Return Semantics in refresh()

  • null = Resource doesn't exist on system
  • {} = Resource exists with no state to track
  • Return null if refresh fails or resource not found

Platform-Specific Development

macOS Considerations

  • File paths are case-insensitive
  • Use .toLowerCase() when comparing paths in allowMultiple.matcher()
  • Xcode Command Line Tools required for many operations
  • Homebrew commonly used for package management

Linux Considerations

  • File paths are case-sensitive
  • Multiple package managers (apt, yum, dnf, snap)
  • Shell RC files vary by distribution

Cross-Platform Patterns

  • Always declare operatingSystems in getSettings()
  • Use Utils.isMacOS(), Utils.isLinux() for platform-specific logic
  • Use FileUtils for cross-platform file operations
  • Test on both macOS and Linux when possible

Adding a New Resource

  1. Create directory: src/resources/category/resource-name/
  2. Create schema file (JSON or Zod): resource-name-schema.json or inline Zod
  3. Create resource class extending Resource<ConfigType>
  4. Implement all required lifecycle methods
  5. Register in src/index.ts
  6. Create integration test in test/category/resource-name.test.ts
  7. Run npm run test to validate

Important Files

Core:

  • /src/index.ts - Resource registration
  • /codify.json - Example configuration

Build:

  • /scripts/build.ts - Build process with schema collection
  • /scripts/deploy.ts - Deployment to Cloudflare R2
  • /rollup.config.js - Bundling configuration
  • /tsconfig.json - TypeScript config (ES2024, strict mode)
  • /vitest.config.ts - Test runner config

Testing:

  • /test/setup.ts - Global test setup/teardown
  • /test/test-utils.ts - Test helpers

Example Resources (by complexity):

  • Simple: src/resources/shell/alias/alias-resource.ts
  • Multi-item: src/resources/shell/aliases/aliases-resource.ts
  • Complex: src/resources/git/repository/git-repository.ts
  • Stateful: src/resources/homebrew/homebrew.ts