Skip to content

Architecture

Sigil has three layers:

┌─────────────────────────────────┐
│ CLI (bunli) │ sigil up / down / status
├─────────────────────────────────┤
│ SDK (Environment) │ sigil.config.ts API surface
├─────────────────────────────────┤
│ Core (Orchestrator, Docker, │ Lifecycle, containers, state
│ State) │
└─────────────────────────────────┘

src/core/orchestrator.ts

Manages entity lifecycle. Entities are started sequentially in registration order (so dependencies like databases start before the services that use them) and stopped in reverse order.

class Orchestrator {
register(entity: Entity): void // Adds entity (throws on duplicate name)
get(name: string): Entity // Retrieve by name
getAll(): Entity[] // All entities
startAll(): Promise<void> // Sequential start
stopAll(): Promise<void> // Reverse-order stop
getStatus(): Array<{ name: string; status: EntityStatus }>
}

Key design choice: stopAll() does not short-circuit on errors. If stopping one entity fails, it continues stopping the rest and logs the error.

src/core/container.ts

Thin wrapper around dockerode for Docker operations.

class ContainerManager {
create(config: ContainerConfig): Promise<Docker.Container>
start(container: Docker.Container): Promise<void>
stop(container: Docker.Container): Promise<void>
remove(container: Docker.Container, force?: boolean): Promise<void>
isRunning(container: Docker.Container): Promise<boolean>
pullImage(image: string): Promise<void>
}

Used internally by the Postgres entity to manage its Docker container.

src/core/state.ts

Tracks running sigil instances on disk at .sigil/instances/<name>.json. This enables sigil down (in a different terminal) to find and signal the sigil up process.

interface InstanceState {
name: string // Instance name (e.g., "default")
configPath: string // Absolute path to sigil.config.ts
pid: number // Process ID of the `sigil up` process
startedAt: string // ISO 8601 timestamp
entities: Array<{ name: string; status: string }>
}

PID liveness is checked via process.kill(pid, 0) — this sends no signal but throws if the process doesn’t exist.

  1. Resolve config path → dynamic import() → get Environment
  2. Write initial state file (PID, config path, timestamp)
  3. Register SIGINT/SIGTERM handlers for graceful shutdown
  4. Call env.up() → Orchestrator starts entities sequentially
  5. Update state file with entity statuses
  6. Block forever (await new Promise(() => {}))
  7. On signal: env.down() → remove state file → exit
  1. Read state file for the named instance
  2. Check PID liveness
  3. Send SIGTERM to the sigil up process
  4. Poll for process exit (500ms intervals, 30s timeout)
  5. Fallback to SIGKILL if still alive
  6. Remove state file

The down command does not re-import the config. It signals the up process, which handles its own teardown.

Entity (abstract)
├── name, status, config
├── interfaces: EntityInterface[]
├── start() / stop()
├── Postgres → Docker container
├── Service → Bun subprocess
└── Browser → (coming soon) Puppeteer

Each entity can hold one or more interfaces — objects describing how the entity is exposed (protocol, port, connection string). For example, Postgres exposes a PostgresInterface with a postgresql:// connection string.