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
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 editindex.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.
| Field | Type | Description |
|---|---|---|
hostname | string | The 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:
| Key | Install command | Remove command |
|---|---|---|
paru | paru -S <pkgs> | paru -Rs <pkgs> |
cargo | cargo install --locked | cargo 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.
| Field | Type | Description |
|---|---|---|
name | string | The username |
groups | string[] | 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.
| Field | Type | Description |
|---|---|---|
source | string | Path to the source file or directory |
target | string | Path 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.
| Section | Rule |
|---|---|
system | hostname must not contain whitespace or /. An empty string is allowed and means “no change”. |
packages | The package manager name and each package name must be non-empty. |
services | Each service name must be non-empty. |
users | The user name and every group name must be non-empty. |
dotfiles | Both 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]
| Flag | Description |
|---|---|
-p, --path <PATH> | Directory where the files will be created. Defaults to the current directory. |
--overwrite | Overwrite both files if they already exist. |
--update-types | Only 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]
| Flag | Description |
|---|---|
-f, --file <FILE> | Path to the configuration file. Required unless --stdin is used. |
-n, --name <NAME> | Name of the configuration to apply. |
-d, --dry-run | Print the list of changes without applying them. Sections with no pending changes are omitted from the output. Skips the confirmation prompt. |
--no-confirm | Skip the confirmation prompt and apply immediately. |
--overwrite-symlink | Allow overwriting already existing dotfile symlinks. |
--from-json | Parse the configuration as JSON (from file or stdin). |
--from-yaml | Parse the configuration as YAML (from file or stdin). |
--stdin | Read 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:
| Section | On add | On remove |
|---|---|---|
system | Sets the hostname | — |
packages | Installs via the package manager | Removes via the package manager |
services | systemctl start + systemctl enable | systemctl stop + systemctl disable |
users | usermod --append --groups | usermod --remove --groups |
dotfiles | Creates symlinks | Removes 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]
| Flag | Description |
|---|---|
-f, --file <FILE> | Path to the TypeScript configuration file. |
--json | Print the output in JSON format. |
--yaml | Print 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]
| Flag | Description |
|---|---|
-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.