Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Demiurge

Demiurge is a declarative configuration tool for Arch Linux. You describe your system state in TypeScript (hostname, packages, services, users, and dotfiles) and dmrg makes the changes.

Features

  • TypeScript configuration: write your config with full type checking and editor support
  • Named configurations: keep multiple configs (e.g. per machine) in a single file and choose which to apply
  • Packages: install and remove packages via paru or cargo
  • Services: enable and disable systemd services
  • Users: manage user group membership
  • Dotfiles: manage dotfiles as symlinks from a source directory to their target locations
  • System: configure system settings like the hostname
  • Dry run: preview changes before applying them
  • Static configs: export your config to JSON or YAML and apply it from a file or stdin

Disclaimer

This project is currently tailored for a specific personal setup and is in an early stage. The API may change drastically.

  • Requires paru on the system for AUR package management
  • Only tested on CachyOS / Arch Linux

Installation

Prerequisites

  • Rust (with cargo)
  • paru — required for package management

Install from source

Clone the repository and install with cargo:

git clone https://github.com/JorgeMayoral/demiurge
cd demiurge
cargo install --path .

This installs the dmrg binary into your cargo bin directory (typically ~/.cargo/bin/). Make sure that directory is in your PATH.

Verify the installation

dmrg --version

Configuration

Demiurge configurations are written in TypeScript. A configuration file exports a function that returns a Demiurge object — a map of named configurations, each describing a desired system state.

Getting started

Run dmrg init in any directory to generate the starter files:

dmrg init

This creates two files:

  • index.ts — your configuration, ready to edit
  • index.d.ts — type declarations for editor support and type checking

Configuration format

A configuration file must have a default export: a function with no arguments that returns a Demiurge object.

const config: DemiurgeConfig = {
  system: {
    hostname: "my-machine",
  },
  packages: {
    paru: ["git", "curl", "neovim"],
    cargo: ["cargo-watch"],
  },
  services: ["docker", "bluetooth"],
  users: [
    { name: "alice", groups: ["docker", "video"] },
  ],
  dotfiles: [
    { source: "~/dotfiles/nvim", target: "~/.config/nvim" },
  ],
};

export default (): Demiurge => ({
  "my-config": config,
});

Named configurations

The top-level Demiurge object is a key-value map where each key is the name of a configuration. This lets you keep multiple configurations in a single file — for example, one per machine:

export default (): Demiurge => ({
  "desktop": desktopConfig,
  "laptop": laptopConfig,
});

When applying, you select which configuration to use with the --name flag:

dmrg apply --file index.ts --name desktop

Configuration sections

system

Controls system-level settings.

FieldTypeDescription
hostnamestringThe desired system hostname
system: {
  hostname: "my-machine",
}

packages

Declares packages to be installed, grouped by package manager. The key is the package manager name and the value is the list of package names.

packages: {
  paru: ["git", "curl", "neovim"],
  cargo: ["cargo-watch", "cargo-expand"],
}

Supported package managers:

KeyInstall commandRemove command
paruparu -S <pkgs>paru -Rs <pkgs>
cargocargo install --lockedcargo uninstall

Packages present in the previously applied config but absent from the new one will be removed.

services

Declares systemd services to keep enabled. Each entry is a service name string.

services: ["docker", "bluetooth", "sshd"],

Services are started and enabled with systemctl. Services present in the previously applied config but removed from the new one will be stopped and disabled.

users

Manages user group membership.

FieldTypeDescription
namestringThe username
groupsstring[]Groups the user should belong to
users: [
  { name: "alice", groups: ["docker", "video", "input"] },
],

Groups that don’t exist yet will be created automatically. Groups present in the previously applied config but removed from the new one will be removed from the user.

dotfiles

Manages dotfiles as symlinks. Each entry maps a source directory or file to a target location.

FieldTypeDescription
sourcestringPath to the source file or directory
targetstringPath where the symlink(s) will be created
dotfiles: [
  { source: "~/dotfiles/nvim", target: "~/.config/nvim" },
  { source: "~/dotfiles/zsh/.zshrc", target: "~/.zshrc" },
]

When source is a directory, Demiurge recursively walks it and creates a symlink for each file at the corresponding path under target. Tilde (~) expansion is supported in both fields.

Validation

Before applying any changes, dmrg apply validates the configuration and reports all violations together. Nothing is applied until the configuration passes validation.

SectionRule
systemhostname must not contain whitespace or /. An empty string is allowed and means “no change”.
packagesThe package manager name and each package name must be non-empty.
servicesEach service name must be non-empty.
usersThe user name and every group name must be non-empty.
dotfilesBoth source and target must be non-empty.

Alternative formats

If you prefer not to use TypeScript at runtime, you can export your configuration as JSON or YAML using dmrg eval, then apply it from a file or via stdin with --from-json or --from-yaml. See the Commands page for details.

Commands

The dmrg binary exposes five subcommands.

dmrg init

Creates the initial configuration files (index.ts and index.d.ts) in the target directory.

dmrg init [OPTIONS]
FlagDescription
-p, --path <PATH>Directory where the files will be created. Defaults to the current directory.
--overwriteOverwrite both files if they already exist.
--update-typesOnly update index.d.ts to the latest version, leaving index.ts untouched.

Examples:

# Initialize in the current directory
dmrg init

# Initialize in a specific directory
dmrg init --path ~/dotfiles

# Overwrite existing files
dmrg init --overwrite

# Update only the type definitions
dmrg init --update-types

--overwrite and --update-types cannot be used together.


dmrg apply

Evaluates the configuration, computes the diff against the last applied state, and applies the changes. A confirmation prompt is shown before applying unless --no-confirm is passed. The configuration is validated before any changes are applied, see Configuration → Validation for the rules.

dmrg apply --name <NAME> [--file <FILE> | --stdin] [OPTIONS]
FlagDescription
-f, --file <FILE>Path to the configuration file. Required unless --stdin is used.
-n, --name <NAME>Name of the configuration to apply.
-d, --dry-runPrint the list of changes without applying them. Sections with no pending changes are omitted from the output. Skips the confirmation prompt.
--no-confirmSkip the confirmation prompt and apply immediately.
--overwrite-symlinkAllow overwriting already existing dotfile symlinks.
--from-jsonParse the configuration as JSON (from file or stdin).
--from-yamlParse the configuration as YAML (from file or stdin).
--stdinRead the configuration from stdin instead of a file. Requires --from-json or --from-yaml.

Examples:

# Apply a TypeScript configuration
dmrg apply --file ~/dotfiles/index.ts --name desktop

# Preview changes without applying
dmrg apply --file ~/dotfiles/index.ts --name desktop --dry-run

# Apply without confirmation prompt
dmrg apply --file ~/dotfiles/index.ts --name desktop --no-confirm

# Apply and overwrite existing symlinks
dmrg apply --file ~/dotfiles/index.ts --name desktop --overwrite-symlink

# Apply from a JSON file
dmrg apply --file config.json --name desktop --from-json

# Apply from stdin (useful for piping from another program)
my-config-generator | dmrg apply --stdin --from-json --name desktop

Demiurge compares the desired configuration against the previously applied state and only performs the necessary changes:

SectionOn addOn remove
systemSets the hostname
packagesInstalls via the package managerRemoves via the package manager
servicessystemctl start + systemctl enablesystemctl stop + systemctl disable
usersusermod --append --groupsusermod --remove --groups
dotfilesCreates symlinksRemoves symlinks

After applying, the result is saved per subsystem: subsystems that succeeded advance to the new state so future runs skip them; subsystems that failed retain their previous state so future runs will retry them. If any subsystem failed the command exits with an error after saving.


dmrg eval

Evaluates the TypeScript configuration file and prints the resulting configuration. Useful for debugging or for exporting to JSON/YAML to be consumed by other tools or applied via stdin.

dmrg eval --file <FILE> [OPTIONS]
FlagDescription
-f, --file <FILE>Path to the TypeScript configuration file.
--jsonPrint the output in JSON format.
--yamlPrint the output in YAML format.

Examples:

# Print the parsed configuration as a Rust debug struct
dmrg eval --file ~/dotfiles/index.ts

# Export to JSON
dmrg eval --file ~/dotfiles/index.ts --json > config.json

# Export to YAML
dmrg eval --file ~/dotfiles/index.ts --yaml > config.yaml

# Pipe directly into apply
dmrg eval --file ~/dotfiles/index.ts --json | dmrg apply --stdin --from-json --name desktop

dmrg status

Displays the configuration that was last successfully applied, reading the persisted state from the data directory.

dmrg status

No options are available for this command.

Example output:

Applied Configuration
─────────────────────

System
  Hostname: my-machine

Packages
  cargo: ripgrep
  paru: git, vim

Dotfiles
  /home/alice/.dotfiles/nvim → /home/alice/.config/nvim

Services
  bluetooth, docker

Users
  alice: docker, wheel

If no configuration has been applied yet, the command prints an informational message and exits successfully:

No configuration has been applied yet.

dmrg schema

Prints the JSON Schema for the Demiurge configuration object. Useful for validating configurations or setting up editor schema support.

dmrg schema [OPTIONS]
FlagDescription
-o, --output <PATH>Directory where the schema will be saved as schema.json. Prints to stdout if omitted.

Examples:

# Print the schema to stdout
dmrg schema

# Save the schema to a directory
dmrg schema --output ~/dotfiles

Implementation

Demiurge is a Rust CLI that embeds a TypeScript runtime, evaluates user configurations, computes a diff against persisted state, and applies changes via system commands. This page walks through the key technical decisions that make that work.

TypeScript evaluation via an embedded runtime

The rustyscript crate wraps Deno’s V8-based JavaScript/TypeScript runtime and embeds it directly in the binary, so no Node.js or Deno installation required.

When dmrg apply runs, the config file is loaded as a TypeScript ES module. Demiurge calls the default export (a zero-argument function) and receives a plain JavaScript object back. That object is then deserialized into Rust structs via serde_json, bridging the JS world into Rust’s type system.

This approach keeps the user-facing surface simple, a plain TypeScript function, while giving the runtime full control over evaluation.

Type-driven configuration with serde and schemars

All configuration structs (DemiurgeConfig, Packages, Services, Users, Dotfiles, System) derive serde::Serialize and serde::Deserialize. This gives JSON and YAML import/export for free, which is what powers dmrg eval --json and dmrg apply --from-json.

The same structs also derive schemars::JsonSchema. The dmrg schema command outputs a JSON Schema generated directly from these types, so the schema is always in sync with the actual implementation, so no manual maintenance needed.

Embedded TypeScript type declarations

The index.d.ts file that dmrg init writes to disk is embedded in the binary at compile time using include_str!. This means the type declarations always match the exact version of the tool, with no separate distribution step.

dmrg init --update-types rewrites only the type file, leaving the user’s index.ts untouched. This keeps editor autocompletion and type checking accurate after upgrades.

Declarative, diff-based apply

Each domain has a *Changes struct that takes the new desired state alongside the last applied state and produces a delta (what to add and what to remove). Only the delta is applied. This keeps the operation idempotent: running dmrg apply twice in a row with the same config produces no changes on the second run.

After applying, each subsystem that succeeded has its new state serialized using bitcode (a compact binary format) and stored in the XDG data directory, resolved via the directories crate. Subsystems that failed retain their previous persisted state so the next run will retry them. This per-subsystem granularity means a partial success (e.g., dotfiles applied but packages failed) still advances the succeeded parts, avoiding redundant re-application on the next run.

External process orchestration

System changes are executed via duct, which provides a composable API for spawning and chaining processes. Commands are expressed as data structures rather than shell strings, which eliminates shell injection risks and keeps the orchestration layer explicit and testable.

The external tools invoked are: paru, cargo, systemctl, hostname, usermod, and groupadd.

CLI design

The dmrg binary is built with clap derive macros. Subcommands, argument groups, and conflicts between flags (such as --stdin requiring --from-json or --from-yaml, or --overwrite and --update-types being mutually exclusive) are declared as struct and field attributes, keeping the CLI definition close to its implementation.

The --stdin flag combined with --from-json or --from-yaml allows any external program to pipe a configuration directly into dmrg apply, making Demiurge composable in larger automation pipelines.