diff --git a/CMakeLists.txt b/CMakeLists.txt
index 5a6265b..a2a4935 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -21,6 +21,7 @@ endif()
add_executable(kappa
src/main.cpp
+ src/util.cpp
src/paths.cpp
src/dsl/lexer.cpp
src/dsl/parser.cpp
@@ -33,9 +34,22 @@ add_executable(kappa
src/tools/doctor.cpp
src/resolve/plan.cpp
src/fetch/fetch.cpp
+ src/fetch/recipe.cpp
src/build/build.cpp
src/sched/scheduler.cpp
src/install/install.cpp
src/rebuild/rebuild.cpp
+ src/service/s6.cpp
+ src/service/systemd.cpp
+ src/service/types.cpp
+ src/service/openrc.cpp
+ src/service/dinit.cpp
+ src/service/runit.cpp
+ src/service/install.cpp
+ src/boot/types.cpp
+ src/boot/limine.cpp
+ src/boot/grub.cpp
+ src/boot/install.cpp
+ src/system/activate.cpp
)
target_include_directories(kappa PRIVATE include)
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..b245e25
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,137 @@
+# Contributing to kappa
+
+We're building a package manager that doesn't care about your init system,
+your bootloader, or your life choices. If that sounds like your kind of
+project, keep reading.
+
+## The rules
+
+These aren't guidelines. They're the deal.
+
+### 1. C++23 or don't bother
+
+We compile with Clang, `-std=c++23`, and `-Werror`. If your code needs a
+polyfill for `std::format` or can't handle designated initializers, it
+doesn't belong here. The standard library is your only dependency. Zero
+external C++ libraries. Not even Boost.
+
+```cpp
+// ✓ yes
+auto msg = std::format("building {} (depth={})", name, depth);
+
+// ✗ no
+auto msg = fmt::format("building {} (depth={})", name, depth);
+```
+
+### 2. Every new module mirrors the existing structure
+
+```
+include/kappa/{module}/
+├── types.hpp # enums, structs, parse/validate declarations
+├── {feature}.hpp # public interface
+src/{module}/
+├── types.cpp # implementations
+├── backend_a.cpp # per-variant generators
+├── backend_b.cpp
+└── install.cpp # dispatch + orchestration
+```
+
+If you're adding a feature, look at `src/service/` or `src/boot/` for the
+pattern. If your new module doesn't look like those, you're doing it wrong.
+
+### 3. Never suppress type safety
+
+There is no `as any`, no `@ts-ignore`, no `reinterpret_cast` abuse, and
+no `void*` unless you're talking to the kernel. If the type system is
+fighting you, you're fighting the design. Fix the design.
+
+### 4. Use the namespace. All of it.
+
+```cpp
+namespace kappa::module {
+ // everything goes here
+} // namespace kappa::module
+```
+
+No `using namespace std;` at file scope. No anonymous namespaces for
+functions that are used across files — extract to `util.hpp` instead.
+
+### 5. Thread safety is not optional
+
+The scheduler is multithreaded. If you touch shared state, you own the
+lock. `std::mutex`, `std::atomic`, `std::condition_variable` — use them
+correctly or don't use them at all. If you don't know what `memory_order`
+means, stay out of the scheduler.
+
+### 6. Tests are shell scripts. For now.
+
+Integration tests live in `test.sh` and `test-init-switch.sh`. They
+exercise the CLI. If you add a subcommand, add a test. C++ unit tests
+are welcome — set up Google Test or Catch2 in CMake and we'll merge it.
+
+```sh
+./test.sh # 31 tests, must pass
+./test-init-switch.sh # 23 tests, must pass
+```
+
+### 7. Backward compatibility is mandatory
+
+The `.kap` DSL is the contract. You can add keywords. You cannot remove
+them. You can extend syntax. You cannot break existing configs. If your
+change means someone's `config.kap` stops parsing, it doesn't ship.
+
+## How to contribute
+
+### Pick something
+
+Good first issues:
+- Adding a 6th init system backend
+- Adding a 3rd bootloader backend
+- C++ unit test framework setup
+- Shell completion scripts
+
+Ambitious issues:
+- Binary package support (pre-built caches)
+- Remote build farm (distcc-style)
+- Signed package verification
+- Filesystem overlay activation (like Nix profiles)
+
+### Send a PR
+
+1. Fork the repo
+2. Create a branch: `feat/my-thing` or `fix/my-bug`
+3. Write code that follows the rules above
+4. Run `./test.sh && ./test-init-switch.sh` — both must pass
+5. Open a PR against `main`
+
+### PR requirements
+
+- Build must pass: `cmake --build build` with zero warnings
+- Tests must pass: both shell test suites
+- No commented-out code. No dead code. No TODO without a date.
+- Commit messages in imperative: `Add runit backend` not `Added runit backend`
+
+## What we won't merge
+
+- **`systemd`-only features.** If it can't work on at least two init systems,
+ it goes in a `systemd` package definition, not in kappa.
+- **Dependency on a specific distro.** Kappa runs on any Linux kernel. No
+ hardcoded paths to `/usr/lib/systemd`, no assumptions about `/etc/os-release`.
+- **Abstract nonsense.** FactoryFactoryBuilder patterns. Premature
+ generalization. If you need three layers of indirection to add a feature,
+ the feature is too complicated.
+- **AI slop.** If it looks like ChatGPT wrote it, it gets rejected. We can
+ tell. Write code like a human who's been doing this for a decade.
+
+## Communication
+
+We don't have a Discord. We don't have a forum. Open an issue. Write a
+clear title, a reproduction case, and what you expected. We'll respond
+when we respond.
+
+If you want to propose a major feature, open an issue first. Surprise PRs
+that rewrite half the codebase get closed without review.
+
+---
+
+Kappa is 0.1.0. Everything is subject to change except the rules above.
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..cee74a3
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,28 @@
+FROM alpine:edge AS builder
+
+RUN apk add --no-cache \
+ clang cmake make ninja \
+ git linux-headers \
+ samurai
+
+WORKDIR /build
+COPY . .
+
+RUN cmake -B build -G Ninja \
+ -DCMAKE_C_COMPILER=clang \
+ -DCMAKE_CXX_COMPILER=clang++ \
+ -DCMAKE_BUILD_TYPE=Release
+
+RUN cmake --build build
+
+FROM alpine:edge
+
+RUN apk add --no-cache libstdc++
+
+COPY --from=builder /build/build/kappa /usr/local/bin/kappa
+COPY --from=builder /build/examples /opt/kappa/examples
+
+WORKDIR /opt/kappa
+
+ENTRYPOINT ["kappa"]
+CMD ["--help"]
diff --git a/README.md b/README.md
index 0e71845..4bac920 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,154 @@
+
+
+
+
# kappa
-A alternative implementation of Iota for the ZereneOS project.
\ No newline at end of file
+**Anywhere, any init, anytime.**
+
+A declarative, source-based package manager that doesn't care what init system
+you run. Or what bootloader. Or what CPU architecture. Kappa builds your entire
+system from source — and lets you swap the init system like you'd swap a
+wallpaper.
+
+---
+
+### Why
+
+Every other package manager picked a side. `apt` married systemd. `pacman`
+shackled itself to Arch's ecosystem. `emerge` gave you choice but at the cost
+of your weekend. Nix gave you reproducibility but took your filesystem with it.
+
+Kappa is what you get when you stop negotiating. You declare what your system
+*is*, and kappa figures out how to build it. Change your mind about the init
+system? Rebuild only the packages that care — the other 800 stay put.
+
+### What it does
+
+```
+# Your system, in one file:
+boot {
+ kernel = "linux"
+ init = "s6" # swap to "systemd" anytime
+ bootloader = "limine" # or "grub"
+ root = "/dev/sda1"
+}
+
+packages {
+ nginx { version = ">=1.24" }
+ postgresql {}
+ zlib {}
+}
+
+services {
+ nginx { enable = true }
+}
+```
+
+Then:
+
+```sh
+kappa rebuild config.kap # builds everything, generates service files
+kappa rebuild config.kap # boot.init = "openrc" — only 5 packages actually rebuild
+```
+
+### Features that nobody else has
+
+- **Init-system-as-configuration.** `boot.init = "s6"` → generates s6 service
+ directories. Change it to `"systemd"` → regenerates `.service` units. Change
+ it to `"openrc"` → generates init.d scripts. The package definitions don't
+ know or care which init you picked. That's kappa's problem.
+
+- **Post-install init switching.** Change `boot.init`, run `kappa rebuild`, reboot.
+ You're now on a different init system. Only packages that actually use
+ `${enabledinit}` in their build scripts need recompiling. Everything else
+ just gets new service files generated.
+
+- **Bootloader rollback.** Every rebuild creates a fallback boot entry pointing
+ at the previous generation's init. If the new one doesn't boot, the old one
+ is one reboot away.
+
+- **Parallel scheduler.** `-w 4 -j 8` means four packages building
+ simultaneously, eight jobs each. The scheduler uses depth-based priority
+ grouping so leaf dependencies unblock as much work as possible first.
+
+- **Package recipe caching.** Declare `remotes = ["https://repo.example.com/"]`
+ in your config. Kappa fetches `.kap` files on demand, caches them, and only
+ re-fetches when the remote version is newer.
+
+- **Source tarball caching.** Downloaded once, stored at `/kappa/cache/`.
+ Rebuilds don't touch the network unless versions change.
+
+- **Conflicts.** `systemd` declares `conflicts = ["eudev", "elogind"]`. The
+ resolver catches mutual incompatibility before a build starts.
+
+- **Init-agnostic system config.** `groups { wheel { gid = 998 } }` — kappa
+ creates the groups. `system { hostname = "mybox" }` — kappa writes
+ `/etc/hostname`. No `systemctl`, no `rc-update`, no init dependency.
+
+### 5 init systems. 2 bootloaders. Zero lock-in.
+
+| Init | Service location | Enable command |
+|------|-----------------|----------------|
+| systemd | `/etc/systemd/system/{name}.service` | `systemctl enable` |
+| openrc | `/etc/init.d/{name}` | `rc-update add` |
+| s6 | `/etc/s6/sv/{name}/run` | `s6-rc-bundle-update` |
+| runit | `/etc/sv/{name}/run` | `ln -sf /etc/sv/{name} /var/service/` |
+| dinit | `/etc/dinit.d/{name}` | `dinitctl enable` |
+
+| Bootloader | Config path |
+|-----------|------------|
+| limine | `/boot/limine.cfg` |
+| grub | `/boot/grub/grub.cfg` |
+
+### Quick start
+
+```sh
+# Build kappa (needs Clang 17+, CMake 3.20+, C++23)
+cmake -B build -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++
+cmake --build build
+
+# Write a config
+cat > system.kap << 'EOF'
+system { hostname = "kappa.local" }
+packages { nginx {} }
+services { nginx { enable = true } }
+boot {
+ kernel = "linux"; init = "s6"; root = "/dev/sda1"; bootloader = "limine"
+}
+users { root { shell = "/bin/zsh" } }
+remotes = ["https://packages.kappa-os.org/stable/"]
+EOF
+
+# Parse it
+build/kappa parse-config system.kap
+
+# Rebuild
+build/kappa rebuild system.kap
+```
+
+### Subcommands
+
+| Command | What it does |
+|---------|-------------|
+| `parse-package ` | Validate a `.kap` package definition |
+| `parse-config ` | Validate a system configuration |
+| `validate ` | Validate any kappa file |
+| `format ` | Pretty-print to canonical style |
+| `doctor ` | Check for issues and warnings |
+| `resolve ` | Compute a build plan |
+| `fetch ` | Download and verify source tarballs |
+| `fetch-package ` | Fetch a package recipe from remotes |
+| `build ` | Build a single package |
+| `rebuild ` | Diff config against installed state, rebuild changed |
+| `list` | Show installed packages |
+| `rollback` | Show available generations |
+
+### License
+
+BSD 2-Clause. Do whatever you want. Just don't sue us.
+
+### Contributing
+
+See [CONTRIBUTING.md](CONTRIBUTING.md). We're opinionated but we merge good
+code.
diff --git a/examples/config.kap b/examples/config.kap
index 3a8e86d..2d11db3 100644
--- a/examples/config.kap
+++ b/examples/config.kap
@@ -1,10 +1,55 @@
/*
* Kappa system configuration.
* Lives at /kappa/system/config.kap
+ *
+ * INIT SYSTEM SELECTION
+ * =====================
+ * The `boot.init` field (line ~84) selects which init system manages this
+ * machine. Valid values (case-insensitive):
+ *
+ * systemd — system and service manager
+ * openrc — OpenRC dependency-based init
+ * s6 — s6 supervision suite
+ * dinit — dinit service manager / init system
+ *
+ * The `boot.bootloader` field (line ~122) selects which bootloader config
+ * kappa generates (grub or limine).
+ *
+ * This setting determines:
+ * 1. Which backend generates service files at install time
+ * (systemd → .service units, openrc → init.d scripts, etc.)
+ * 2. What `${enabledinit}` resolves to during package builds
+ *
+ * Package definitions do NOT specify per-init service blocks. A package
+ * defines its service once (see examples/foo.kap) and the selected init
+ * system's backend handles the translation.
+ *
+ * SERVICES BLOCK
+ * ==============
+ * The `services` section enables or disables services declared by
+ * installed packages. Each entry maps to a package's service name:
+ *
+ * services {
+ * nginx { enable = true } // package "nginx", default "main" service
+ * postgresql.main { enable = true } // package "postgresql", named service "main"
+ * postgresql.checkpointer { enable = false }
+ * }
+ *
+ * For single-service packages, the service name defaults to "main" and
+ * can be omitted. For multi-service packages, use dot-notation
+ * (pkgname.servicename) to target a specific named service.
+ *
+ * Additional keys in each service block (port, ssl, etc.) are passed as
+ * custom config to the service definition.
*/
imports = []
+remotes = [
+ "https://packages.kappa-os.org/stable/",
+ "https://packages.kappa-os.org/contrib/",
+]
+
assert {
"efi partition required for UEFI boot" : boot.efi != ""
"root partition must be set" : boot.root != ""
@@ -75,11 +120,15 @@ services {
}
}
+// Select init system — determines which backend generates service files.
+// Valid: systemd, openrc, s6, dinit (case-insensitive).
boot {
kernel = "linux"
init = "s6"
efi = "/dev/sda2"
swap = "/dev/sda3"
+ // Bootloader — generates the appropriate config at install time.
+ // Valid: grub, limine (case-insensitive).
bootloader = "limine"
root = "/dev/sda1"
}
diff --git a/examples/foo.kap b/examples/foo.kap
index 8975e79..d602780 100644
--- a/examples/foo.kap
+++ b/examples/foo.kap
@@ -1,6 +1,51 @@
/*
* foo — a web server with optional SSL and GUI support.
- * Demonstrates the full kappa DSL surface.
+ *
+ * SERVICE MODEL
+ * =============
+ * Kappa service definitions are init-system-agnostic. The `service` block
+ * describes what the service IS (exec, type, ports, user) — NOT how each init
+ * system runs it. The system config's `boot.init` field (see config.kap)
+ * determines which init system's service files get generated at install time:
+ *
+ * boot.init = "systemd" → generates .service unit files
+ * boot.init = "openrc" → generates /etc/init.d scripts
+ * boot.init = "s6" → generates s6 service directories
+ * boot.init = "dinit" → generates dinit service descriptors
+ * boot.init = "runit" → generates runit service directories
+ *
+ * Per-init blocks (service { systemd { ... } s6 { ... } }) do NOT exist.
+ * If a package genuinely needs init-specific behaviour (e.g. different
+ * ./configure flags for systemd vs. openrc), use ${enabledinit} in the
+ * build phase — see examples/postgres.kap for that pattern.
+ *
+ * SERVICE TYPE VALUES
+ * ===================
+ * These are semantic, not init-specific. Each backend translates them
+ * into its own vocabulary:
+ *
+ * "simple" — foreground process; init manages lifecycle directly.
+ * systemd: Type=simple openrc: command_background=false
+ * s6: type=longrun dinit: type=process
+ *
+ * "forking" — process daemonises itself; init tracks the forked PID.
+ * systemd: Type=forking openrc: command_background=true
+ * s6: type=longrun dinit: type=bgprocess
+ *
+ * "notify" — foreground process that signals readiness (sd_notify).
+ * systemd: Type=notify openrc: command_background=true
+ * s6: type=longrun dinit: type=process
+ *
+ * "oneshot" — runs once and exits (startup tasks, database migrations).
+ * systemd: Type=oneshot openrc: command_background=false
+ * s6: type=oneshot dinit: type=scripted
+ *
+ * "longrun" — long-running supervised process (s6/runit idiom).
+ * systemd: Type=simple openrc: command_background=true
+ * s6: type=longrun dinit: type=process
+ *
+ * The backend generators handle all translation. Package authors only
+ * need to pick the semantic type that describes their daemon's behaviour.
*/
package "foo" {
const version = "1.2.3"
@@ -8,7 +53,8 @@ package "foo" {
sha256 = "e127a709cba24c76de8936cb7083dd768f28cd37eb010492e2f19b71eb1294e4"
license = "MIT"
- provides = ["libfoo.so.1", "foo"]
+ provides = ["libfoo.so.1", "foo"]
+ conflicts = [] // packages this cannot coexist with (e.g. ["eudev"] if this were systemd)
patches = [
{
@@ -60,18 +106,19 @@ package "foo" {
CFLAGS ?= "-g"
}
+ // --- service ----------------------------------------------------------
+ // Init-agnostic service definition. The `type` field is semantic
+ // ("forking") — the selected init system's backend translates it into
+ // the appropriate native format. If the package ships multiple
+ // services, use named blocks (see examples/postgres.kap).
service {
- runit {
- exec = "/usr/bin/foo --daemon"
- type = "forking"
- user = "foo"
- }
- s6 {
- exec = "/usr/bin/foo"
- type = "longrun"
- ports = [80, 443]
- user = "foo"
- }
+ exec = "/usr/bin/foo"
+ type = "forking" // daemonises itself
+ user = "foo"
+ ports = [80, 443]
+ description = "Foo web server"
+ after = "network" // ordering hint — systemd After=, OpenRC need, etc.
+ restart = "on-failure" // "always" | "on-failure" | "never"
}
prepare {
@@ -90,4 +137,8 @@ package "foo" {
install {
make DESTDIR=${destdir} install
}
+
+ uninstall {
+ make -C build uninstall
+ }
}
diff --git a/examples/postgres.kap b/examples/postgres.kap
new file mode 100644
index 0000000..333d230
--- /dev/null
+++ b/examples/postgres.kap
@@ -0,0 +1,148 @@
+/*
+ * postgresql — a database server shipping multiple services.
+ *
+ * MULTI-SERVICE PACKAGES
+ * ======================
+ * Packages that install more than one long-running process can declare
+ * multiple named `service` blocks. Each has its own exec, type, ports,
+ * and lifecycle config. The system config enables them individually using
+ * dot-notation (see config.kap):
+ *
+ * services {
+ * postgresql.main { enable = true }
+ * postgresql.checkpointer { enable = true }
+ * postgresql.walwriter { enable = true }
+ * }
+ *
+ * Omitting the dot selects the service named "main".
+ *
+ *
+ * INIT-CONDITIONAL BUILDS
+ * =======================
+ * The variable ${enabledinit} exposes the configured init system name
+ * (from boot.init in config.kap) during the build phase. Use shell
+ * conditionals — no DSL if/else needed:
+ *
+ * build {
+ * case ${enabledinit} in
+ * systemd) ./configure --with-systemd --prefix=${prefix} ;;
+ * openrc) ./configure --with-openrc --prefix=${prefix} ;;
+ * s6|dinit) ./configure --prefix=${prefix} ;;
+ * esac
+ * make -j${jobs}
+ * }
+ *
+ * ${enabledinit} is interpolated to the literal init system name
+ * ("systemd", "openrc", "s6", or "dinit") before the shell executes the
+ * block. No DSL context-sensitive parsing required.
+ *
+ *
+ * SERVICE TYPE TRANSLATION (for reference)
+ * ========================================
+ * semantic │ systemd │ openrc │ s6 │ dinit
+ * ──────────┼────────────┼─────────────────────┼──────────┼──────────
+ * simple │ Type=simple│ bg=false │ longrun │ process
+ * forking │ Type=fork │ bg=true │ longrun │ bgprocess
+ * notify │ Type=notify│ bg=true │ longrun │ process
+ * oneshot │ Type=one │ bg=false, args="" │ oneshot │ scripted
+ * longrun │ Type=simple│ bg=true │ longrun │ process
+ */
+package "postgresql" {
+ const version = "16.3"
+ const source = "https://ftp.postgresql.org/pub/source/v${version}/postgresql-${version}.tar.gz"
+ sha256 = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"
+ license = "PostgreSQL"
+
+ depends = [
+ { name = "readline", version = ">=8" },
+ { name = "zlib", version = ">=1.2" },
+ { name = "openssl" },
+ ]
+
+ conflicts = [] // mutually exclusive packages (e.g. systemd vs eudev)
+
+ features {
+ ssl = { enabled = true, flag = "--with-ssl=openssl" }
+ nls = { enabled = true, flag = "--enable-nls" }
+ systemd = { enabled = false, flag = "--with-systemd" }
+ }
+
+ config {
+ file "etc/postgresql/data/postgresql.conf" mode = "default" {
+ port = ${cfg.port ? 5432}
+ max_connections = ${cfg.max_conn ? 100}
+ shared_buffers = ${cfg.shared_buf ? 128MB}
+ }
+ }
+
+ env {
+ CFLAGS = "-O2"
+ LDFLAGS = "-Wl,--as-needed"
+ }
+
+ // --- services ---------------------------------------------------------
+ // PostgreSQL ships the main server plus several auxiliary processes.
+ // Each runs as a separate service under the init system.
+
+ // Default service (name = "main"). Enabled via: postgresql { enable = true }
+ service main {
+ exec = "/usr/bin/postgres -D /var/lib/postgresql/data"
+ type = "forking" // postmaster daemonises itself
+ user = "postgres"
+ ports = [5432]
+ description = "PostgreSQL database server"
+ after = "network"
+ restart = "always"
+ working_dir = "/var/lib/postgresql"
+ }
+
+ // Background writer — handles checkpoint I/O.
+ service checkpointer {
+ exec = "/usr/bin/postgres-checkpointer"
+ type = "longrun"
+ user = "postgres"
+ description = "PostgreSQL checkpointer process"
+ restart = "always"
+ }
+
+ // WAL writer — flushes write-ahead log to disk.
+ service walwriter {
+ exec = "/usr/bin/postgres-walwriter"
+ type = "longrun"
+ user = "postgres"
+ restart = "always"
+ }
+
+ // --- build phases ------------------------------------------------------
+
+ prepare {
+ tar xf postgresql-${version}.tar.gz
+ }
+
+ // init-conditional build: PostgreSQL optionally links against systemd
+ // for socket activation and service notification. Use ${enabledinit}
+ // to decide configure flags without per-init service blocks.
+ build {
+ case ${enabledinit} in
+ systemd) ./configure --with-systemd --with-ssl=openssl --prefix=${prefix} ;;
+ *) ./configure --with-ssl=openssl --prefix=${prefix} ;;
+ esac
+ make -j${jobs} world
+ }
+
+ check {
+ make check
+ }
+
+ install {
+ make DESTDIR=${destdir} install-world
+ }
+
+ uninstall {
+ make DESTDIR=${destdir} uninstall-world
+ }
+
+ assert {
+ "data directory must exist" : system.config.data_dir != ""
+ }
+}
diff --git a/include/kappa/dsl/ast.hpp b/include/kappa/dsl/ast.hpp
index b2dbd54..dfba282 100644
--- a/include/kappa/dsl/ast.hpp
+++ b/include/kappa/dsl/ast.hpp
@@ -44,12 +44,17 @@ struct EnvEntry {
bool soft = false;
};
-struct ServiceInit {
+struct NamedService {
+ std::string name = "main";
std::string exec;
std::string type;
std::string user;
std::vector ports;
std::unordered_map env;
+ std::string description;
+ std::string after;
+ std::string restart;
+ std::string working_dir;
};
struct Assertion {
@@ -70,17 +75,19 @@ struct PackageDef {
std::vector depends;
std::vector provides;
std::vector outputs;
+ std::vector conflicts;
std::unordered_map features;
std::vector config_files;
std::vector patches;
std::vector env_entries;
- std::unordered_map service;
+ std::vector services;
std::vector assertions;
std::unordered_set const_keys;
Phase prepare;
Phase build;
Phase check;
Phase install;
+ Phase uninstall;
};
} // namespace kappa::dsl
diff --git a/include/kappa/dsl/system.hpp b/include/kappa/dsl/system.hpp
index 0afd3b0..2632479 100644
--- a/include/kappa/dsl/system.hpp
+++ b/include/kappa/dsl/system.hpp
@@ -39,6 +39,11 @@ struct BootBlock {
std::unordered_map params;
};
+struct GroupDef {
+ std::string name;
+ int gid = -1; // -1 = auto-assign
+};
+
struct UserRef {
std::string name;
std::string shell;
@@ -54,11 +59,13 @@ struct ServiceRef {
struct SystemConfig {
std::vector imports;
+ std::vector remotes;
SystemBlock system;
std::vector packages;
std::vector services;
BootBlock boot;
std::vector users;
+ std::vector groups;
std::vector assertions;
};
diff --git a/include/kappa/dsl/token.hpp b/include/kappa/dsl/token.hpp
index 648b34a..636829b 100644
--- a/include/kappa/dsl/token.hpp
+++ b/include/kappa/dsl/token.hpp
@@ -28,6 +28,7 @@ enum class TokenType {
KwDepends,
KwProvides,
KwOutputs,
+ KwConflicts,
KwFeatures,
KwConfig,
KwConst,
@@ -44,6 +45,7 @@ enum class TokenType {
KwBuild,
KwCheck,
KwInstall,
+ KwUninstall,
KwTrue,
KwFalse,
};
diff --git a/include/kappa/paths.hpp b/include/kappa/paths.hpp
index e9fc7cd..92b1168 100644
--- a/include/kappa/paths.hpp
+++ b/include/kappa/paths.hpp
@@ -14,6 +14,8 @@ std::filesystem::path temp_dir();
std::filesystem::path db_dir();
std::filesystem::path system_dir();
std::filesystem::path builds_dir();
+std::filesystem::path cache_dir();
+std::filesystem::path packages_dir();
void ensure_directories();
diff --git a/src/boot/grub.cpp b/src/boot/grub.cpp
new file mode 100644
index 0000000..103a253
--- /dev/null
+++ b/src/boot/grub.cpp
@@ -0,0 +1,48 @@
+#include "kappa/boot/bootloader.hpp"
+
+#include
+#include
+
+namespace kappa::boot {
+
+std::string generate_grub_config(const BootSpec& spec) {
+ std::ostringstream oss;
+
+ oss << "# Generated by kappa — do not edit manually\n";
+ oss << "# GRUB boot entry\n";
+ oss << "\n";
+ oss << "set timeout=5\n";
+ oss << "set default=0\n";
+ oss << "\n";
+ oss << "menuentry \"Kappa\" {\n";
+
+ oss << std::format(" linux {} init={} root={}",
+ spec.kernel_path,
+ spec.init_path,
+ spec.root);
+
+ if (!spec.kernel_params.empty()) {
+ oss << " " << spec.kernel_params;
+ }
+
+ oss << "\n";
+ oss << "}\n";
+
+ if (!spec.init_prev.empty()) {
+ oss << "\n";
+ oss << "menuentry \"Kappa (fallback)\" {\n";
+ oss << std::format(" linux {} init={} root={}",
+ spec.kernel_path,
+ spec.init_prev,
+ spec.root);
+ if (!spec.kernel_params.empty()) {
+ oss << " " << spec.kernel_params;
+ }
+ oss << "\n";
+ oss << "}\n";
+ }
+
+ return oss.str();
+}
+
+} // namespace kappa::boot
diff --git a/src/boot/install.cpp b/src/boot/install.cpp
new file mode 100644
index 0000000..238c644
--- /dev/null
+++ b/src/boot/install.cpp
@@ -0,0 +1,69 @@
+#include "kappa/boot/bootloader.hpp"
+
+#include
+#include
+#include
+#include
+
+namespace kappa::boot {
+
+// ---------------------------------------------------------------------------
+// Backend config-file generators (defined in separate .cpp files)
+// ---------------------------------------------------------------------------
+
+// ---------------------------------------------------------------------------
+// generate_bootloader_config — dispatch to the correct backend
+// ---------------------------------------------------------------------------
+
+std::string generate_bootloader_config(Bootloader bl, const BootSpec& spec) {
+ switch (bl) {
+ case Bootloader::Grub:
+ return generate_grub_config(spec);
+ case Bootloader::Limine:
+ return generate_limine_config(spec);
+ case Bootloader::Unknown:
+ default:
+ return {};
+ }
+}
+
+// ---------------------------------------------------------------------------
+// install_bootloader_config — write the generated config file to disk
+// ---------------------------------------------------------------------------
+
+BootloaderInstallResult install_bootloader_config(Bootloader bl,
+ const BootSpec& spec,
+ std::string_view prefix) {
+ namespace fs = std::filesystem;
+
+ auto paths = bootloader_paths(bl, prefix);
+ if (paths.config_path.empty()) {
+ return {false, {}, "Unknown bootloader"};
+ }
+
+ std::string content = generate_bootloader_config(bl, spec);
+ if (content.empty()) {
+ return {false, {}, "Failed to generate bootloader config"};
+ }
+
+ std::error_code ec;
+
+ fs::path file_path = paths.config_path;
+ fs::create_directories(file_path.parent_path(), ec);
+ if (ec) {
+ return {false, {}, ec.message()};
+ }
+
+ {
+ std::ofstream out(file_path);
+ if (!out) {
+ return {false, {},
+ std::format("Failed to write {}", file_path.string())};
+ }
+ out << content;
+ }
+
+ return {true, file_path.string(), {}};
+}
+
+} // namespace kappa::boot
diff --git a/src/boot/limine.cpp b/src/boot/limine.cpp
new file mode 100644
index 0000000..1f99773
--- /dev/null
+++ b/src/boot/limine.cpp
@@ -0,0 +1,42 @@
+#include "kappa/boot/bootloader.hpp"
+
+#include
+#include
+#include
+
+namespace kappa::boot {
+
+std::string generate_limine_config(const BootSpec& spec) {
+ std::ostringstream out;
+
+ out << "# Generated by kappa — do not edit manually\n"
+ << "# Limine boot entry\n"
+ << "\n"
+ << ":Kappa\n"
+ << " protocol: linux\n"
+ << std::format(" kernel_path: boot():{}\n", spec.kernel_path);
+
+ out << std::format(" kernel_cmdline: init={} root={}",
+ spec.init_path, spec.root);
+ if (!spec.kernel_params.empty()) {
+ out << " " << spec.kernel_params;
+ }
+ out << "\n";
+
+ if (!spec.init_prev.empty()) {
+ out << "\n"
+ << ":Kappa (fallback)\n"
+ << " protocol: linux\n"
+ << std::format(" kernel_path: boot():{}\n", spec.kernel_path);
+ out << std::format(" kernel_cmdline: init={} root={}",
+ spec.init_prev, spec.root);
+ if (!spec.kernel_params.empty()) {
+ out << " " << spec.kernel_params;
+ }
+ out << "\n";
+ }
+
+ return out.str();
+}
+
+} // namespace kappa::boot
diff --git a/src/boot/types.cpp b/src/boot/types.cpp
new file mode 100644
index 0000000..e3fb2dc
--- /dev/null
+++ b/src/boot/types.cpp
@@ -0,0 +1,65 @@
+#include "kappa/boot/types.hpp"
+#include "kappa/util.hpp"
+
+#include
+#include
+#include
+#include
+
+namespace kappa::boot {
+
+Bootloader parse_bootloader(std::string_view name) {
+ auto lower = util::to_lower(name);
+ if (lower == "grub") return Bootloader::Grub;
+ if (lower == "limine") return Bootloader::Limine;
+ return Bootloader::Unknown;
+}
+
+std::string_view to_string(Bootloader bl) {
+ switch (bl) {
+ case Bootloader::Grub: return "grub";
+ case Bootloader::Limine: return "limine";
+ case Bootloader::Unknown: return "unknown";
+ }
+ return "unknown";
+}
+
+bool is_supported(std::string_view name) {
+ return parse_bootloader(name) != Bootloader::Unknown;
+}
+
+std::vector all_bootloaders() {
+ return {Bootloader::Grub, Bootloader::Limine};
+}
+
+std::string_view bootloader_description(Bootloader bl) {
+ switch (bl) {
+ case Bootloader::Grub:
+ return "GRUB — GRand Unified Bootloader";
+ case Bootloader::Limine:
+ return "Limine — modern multiprotocol bootloader";
+ case Bootloader::Unknown:
+ return "unknown bootloader";
+ }
+ return "unknown bootloader";
+}
+
+BootloaderPaths bootloader_paths(Bootloader bl, std::string_view prefix) {
+ switch (bl) {
+ case Bootloader::Grub:
+ return {
+ .config_path = std::format("{}boot/grub/grub.cfg", prefix),
+ .install_cmd = "grub-install",
+ };
+ case Bootloader::Limine:
+ return {
+ .config_path = std::format("{}boot/limine/limine.cfg", prefix),
+ .install_cmd = "limine",
+ };
+ case Bootloader::Unknown:
+ return {};
+ }
+ return {};
+}
+
+} // namespace kappa::boot
diff --git a/src/config/eval.cpp b/src/config/eval.cpp
index a0952d7..0f1aecb 100644
--- a/src/config/eval.cpp
+++ b/src/config/eval.cpp
@@ -1,4 +1,7 @@
#include "kappa/config/eval.hpp"
+#include "kappa/service/types.hpp"
+
+#include
namespace kappa::config {
@@ -55,26 +58,41 @@ std::vector evaluate_assertions(const dsl::SystemConfig& cfg) {
return failures;
}
-
-std::unordered_map resolve_services(
+std::unordered_map resolve_services(
const dsl::SystemConfig& cfg,
const std::unordered_map& packages)
{
- std::unordered_map resolved;
+ std::unordered_map resolved;
auto init_system = cfg.boot.init;
+ auto is = kappa::service::parse_init_system(init_system);
+ if (is == kappa::service::InitSystem::Unknown) {
+ std::cerr << "warning: unknown init system '" << init_system
+ << "' — no services will be configured\n";
+ return {};
+ }
+
for (auto& svc : cfg.services) {
if (!svc.enable) { continue; }
- auto pit = packages.find(svc.name);
+ // svc.name can be "postgresql" or "postgresql.checkpointer"
+ auto dot = svc.name.find('.');
+ auto pkg_name = (dot != std::string::npos)
+ ? svc.name.substr(0, dot)
+ : svc.name;
+ auto svc_name = (dot != std::string::npos)
+ ? svc.name.substr(dot + 1)
+ : std::string("main");
+
+ auto pit = packages.find(std::string(pkg_name));
if (pit == packages.end()) { continue; }
auto& pkg = pit->second;
- if (pkg.service.empty()) { continue; }
-
- auto it = pkg.service.find(init_system);
- if (it != pkg.service.end()) {
- resolved[svc.name] = it->second;
+ for (auto& ns : pkg.services) {
+ if (ns.name == svc_name) {
+ resolved[svc.name] = ns;
+ break;
+ }
}
}
diff --git a/src/dsl/lexer.cpp b/src/dsl/lexer.cpp
index d77deae..f82dd0f 100644
--- a/src/dsl/lexer.cpp
+++ b/src/dsl/lexer.cpp
@@ -12,8 +12,9 @@ static const std::unordered_map keywords = {
{"source", TokenType::KwSource},
{"depends", TokenType::KwDepends},
{"provides", TokenType::KwProvides},
- {"outputs", TokenType::KwOutputs},
- {"features", TokenType::KwFeatures},
+ {"outputs", TokenType::KwOutputs},
+ {"conflicts", TokenType::KwConflicts},
+ {"features", TokenType::KwFeatures},
{"config", TokenType::KwConfig},
{"const", TokenType::KwConst},
{"license", TokenType::KwLicense},
@@ -29,6 +30,7 @@ static const std::unordered_map keywords = {
{"build", TokenType::KwBuild},
{"check", TokenType::KwCheck},
{"install", TokenType::KwInstall},
+ {"uninstall", TokenType::KwUninstall},
{"true", TokenType::KwTrue},
{"false", TokenType::KwFalse},
};
@@ -51,6 +53,7 @@ std::string_view token_name(TokenType type) {
case TokenType::KwDepends: return "depends";
case TokenType::KwProvides: return "provides";
case TokenType::KwOutputs: return "outputs";
+ case TokenType::KwConflicts: return "conflicts";
case TokenType::KwFeatures: return "features";
case TokenType::KwConfig: return "config";
case TokenType::KwConst: return "const";
@@ -67,6 +70,7 @@ std::string_view token_name(TokenType type) {
case TokenType::KwBuild: return "build";
case TokenType::KwCheck: return "check";
case TokenType::KwInstall: return "install";
+ case TokenType::KwUninstall: return "uninstall";
case TokenType::KwTrue: return "true";
case TokenType::KwFalse: return "false";
}
diff --git a/src/dsl/parser.cpp b/src/dsl/parser.cpp
index 84a1e6b..3c5dfba 100644
--- a/src/dsl/parser.cpp
+++ b/src/dsl/parser.cpp
@@ -157,6 +157,12 @@ void Parser::parse_body(PackageDef& pkg) {
pkg.outputs = parse_string_list();
break;
+ case TokenType::KwConflicts:
+ consume(TokenType::KwConflicts);
+ consume(TokenType::Equals);
+ pkg.conflicts = parse_string_list();
+ break;
+
case TokenType::KwDepends:
consume(TokenType::KwDepends);
consume(TokenType::Equals);
@@ -233,52 +239,56 @@ void Parser::parse_body(PackageDef& pkg) {
consume(TokenType::Rbrace);
break;
- case TokenType::KwService:
+ case TokenType::KwService: {
consume(TokenType::KwService);
+ NamedService ns;
+ if (!at(TokenType::Lbrace)) {
+ ns.name = current_.lexeme;
+ advance();
+ }
consume(TokenType::Lbrace);
skip_newlines();
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
if (at(TokenType::Newline)) { advance(); continue; }
- auto init_name = current_.lexeme;
+ auto key = current_.lexeme;
advance();
- consume(TokenType::Lbrace);
- skip_newlines();
- ServiceInit si;
- while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
- if (at(TokenType::Newline)) { advance(); continue; }
- auto key = current_.lexeme;
- advance();
- consume(TokenType::Equals);
- if (key == "exec") {
- si.exec = consume(TokenType::String).lexeme;
- } else if (key == "type") {
- si.type = consume(TokenType::String).lexeme;
- } else if (key == "user") {
- si.user = consume(TokenType::String).lexeme;
- } else if (key == "ports") {
- consume(TokenType::Lbracket);
- skip_newlines();
- while (!at(TokenType::Rbracket) && !at(TokenType::Eof)) {
- si.ports.push_back(
- parse_int(current_.line, current_.col,
- current_.lexeme));
- advance();
- skip_newlines();
- if (at(TokenType::Comma)) { consume(TokenType::Comma); }
- skip_newlines();
- }
- consume(TokenType::Rbracket);
- } else {
- si.env[key] = consume(TokenType::String).lexeme;
- }
+ consume(TokenType::Equals);
+ if (key == "exec") {
+ ns.exec = consume(TokenType::String).lexeme;
+ } else if (key == "type") {
+ ns.type = consume(TokenType::String).lexeme;
+ } else if (key == "user") {
+ ns.user = consume(TokenType::String).lexeme;
+ } else if (key == "ports") {
+ consume(TokenType::Lbracket);
skip_newlines();
+ while (!at(TokenType::Rbracket) && !at(TokenType::Eof)) {
+ ns.ports.push_back(
+ parse_int(current_.line, current_.col,
+ current_.lexeme));
+ advance();
+ skip_newlines();
+ if (at(TokenType::Comma)) { consume(TokenType::Comma); }
+ skip_newlines();
+ }
+ consume(TokenType::Rbracket);
+ } else if (key == "description") {
+ ns.description = consume(TokenType::String).lexeme;
+ } else if (key == "after") {
+ ns.after = consume(TokenType::String).lexeme;
+ } else if (key == "restart") {
+ ns.restart = consume(TokenType::String).lexeme;
+ } else if (key == "working_dir") {
+ ns.working_dir = consume(TokenType::String).lexeme;
+ } else {
+ ns.env[key] = consume(TokenType::String).lexeme;
}
- consume(TokenType::Rbrace);
- pkg.service[std::string(init_name)] = std::move(si);
skip_newlines();
}
consume(TokenType::Rbrace);
+ pkg.services.push_back(std::move(ns));
break;
+ }
case TokenType::KwAssert:
consume(TokenType::KwAssert);
@@ -340,6 +350,11 @@ void Parser::parse_body(PackageDef& pkg) {
pkg.install = parse_phase();
break;
+ case TokenType::KwUninstall:
+ consume(TokenType::KwUninstall);
+ pkg.uninstall = parse_phase();
+ break;
+
default:
throw ParseError(current_.line, current_.col,
std::format("unexpected token '{}' in package body",
diff --git a/src/dsl/system.cpp b/src/dsl/system.cpp
index 003dd33..468c724 100644
--- a/src/dsl/system.cpp
+++ b/src/dsl/system.cpp
@@ -28,6 +28,7 @@ private:
void parse_services_block(SystemConfig& cfg);
void parse_boot_block(SystemConfig& cfg);
void parse_users_block(SystemConfig& cfg);
+ void parse_groups_block(SystemConfig& cfg);
std::string consume_ident();
std::string consume_string();
@@ -130,6 +131,17 @@ SystemConfig SysParser::parse() {
skip_newlines();
}
consume(TokenType::Rbracket);
+ } else if (kw == "remotes") {
+ consume(TokenType::Equals);
+ consume(TokenType::Lbracket);
+ skip_newlines();
+ while (!at(TokenType::Rbracket) && !at(TokenType::Eof)) {
+ cfg.remotes.push_back(consume(TokenType::String).lexeme);
+ skip_newlines();
+ if (at(TokenType::Comma)) { consume(TokenType::Comma); }
+ skip_newlines();
+ }
+ consume(TokenType::Rbracket);
} else if (kw == "assert") {
consume(TokenType::Lbrace);
skip_newlines();
@@ -171,6 +183,7 @@ SystemConfig SysParser::parse() {
else if (kw == "services") { parse_services_block(cfg); }
else if (kw == "boot") { parse_boot_block(cfg); }
else if (kw == "users") { parse_users_block(cfg); }
+ else if (kw == "groups") { parse_groups_block(cfg); }
else {
throw ParseError(current_.line, current_.col,
std::format("unknown section '{}'", kw));
@@ -411,6 +424,34 @@ void SysParser::parse_users_block(SystemConfig& cfg) {
consume(TokenType::Rbrace);
}
+void SysParser::parse_groups_block(SystemConfig& cfg) {
+ consume(TokenType::Lbrace);
+ skip_newlines();
+ while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
+ skip_newlines();
+ if (at(TokenType::Rbrace)) break;
+ GroupDef g;
+ g.name = consume_ident();
+ if (at(TokenType::Lbrace)) {
+ consume(TokenType::Lbrace);
+ skip_newlines();
+ while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
+ if (at(TokenType::Newline)) { advance(); continue; }
+ auto key = consume_ident();
+ consume(TokenType::Equals);
+ if (key == "gid") {
+ g.gid = parse_int(current_.line, current_.col, current_.lexeme);
+ advance();
+ }
+ skip_newlines();
+ }
+ consume(TokenType::Rbrace);
+ }
+ cfg.groups.push_back(std::move(g));
+ }
+ consume(TokenType::Rbrace);
+}
+
SystemConfig parse_system_config(std::string_view source) {
SysParser p(source);
return p.parse();
@@ -420,12 +461,14 @@ static void merge_config(SystemConfig& base, SystemConfig&& imported) {
if (!imported.system.hostname.empty()) { base.system.hostname = std::move(imported.system.hostname); }
if (!imported.system.timezone.empty()) { base.system.timezone = std::move(imported.system.timezone); }
for (auto& e : imported.system.env) { base.system.env.push_back(std::move(e)); }
+ for (auto& r : imported.remotes) { base.remotes.push_back(std::move(r)); }
for (auto& [k, v] : imported.system.config) { base.system.config[k] = std::move(v); }
for (auto& [k, v] : imported.system.features) { base.system.features[k] = std::move(v); }
if (imported.system.rollback.keep > 0) { base.system.rollback.keep = imported.system.rollback.keep; }
for (auto& p : imported.packages) { base.packages.push_back(std::move(p)); }
for (auto& s : imported.services) { base.services.push_back(std::move(s)); }
for (auto& u : imported.users) { base.users.push_back(std::move(u)); }
+ for (auto& g : imported.groups) { base.groups.push_back(std::move(g)); }
if (!imported.boot.kernel.empty()) { base.boot.kernel = std::move(imported.boot.kernel); }
if (!imported.boot.init.empty()) { base.boot.init = std::move(imported.boot.init); }
if (!imported.boot.efi.empty()) { base.boot.efi = std::move(imported.boot.efi); }
diff --git a/src/eval/vars.cpp b/src/eval/vars.cpp
index e3e2fb8..6d46c7e 100644
--- a/src/eval/vars.cpp
+++ b/src/eval/vars.cpp
@@ -11,7 +11,8 @@ Scope make_default_scope() {
s.builtins["jobs"] = "1";
s.builtins["jobopts"] = "-j1";
s.builtins["destdir"] = (paths::temp_dir() / "destdir").string();
- s.builtins["userargs"] = "";
+ s.builtins["userargs"] = "";
+ s.builtins["enabledinit"] = "";
return s;
}
diff --git a/src/fetch/fetch.cpp b/src/fetch/fetch.cpp
index 8c77040..c19809f 100644
--- a/src/fetch/fetch.cpp
+++ b/src/fetch/fetch.cpp
@@ -126,16 +126,37 @@ FetchResult fetch(const dsl::PackageDef& pkg) {
}
auto dest_name = pkg.name + "-" + pkg.version;
- auto dest_file = fs::path(paths::temp_dir()) / (dest_name + "." + ext);
+ // Sanitize: replace path separators to prevent traversal
+ for (auto& c : dest_name) {
+ if (c == '/' || c == '\\') c = '_';
+ }
+ auto cache_path = paths::cache_dir() / (dest_name + "." + ext);
+ bool from_cache = false;
+ fs::path dest_file;
+ if (fs::exists(cache_path)) {
+ dest_file = cache_path;
+ from_cache = true;
+ } else {
+ dest_file = fs::path(paths::temp_dir()) / (dest_name + "." + ext);
+ }
result.work_dir = fs::path(paths::temp_dir()) / dest_name;
if (ext == "git") {
int rc = exec_cmd({"git", "clone", url, result.work_dir.string()});
if (rc != 0) { result.error = "git clone failed"; return result; }
} else {
- fs::create_directories(paths::temp_dir());
- int rc = exec_cmd({"curl", "-L", "-o", dest_file.string(), url});
- if (rc != 0) { result.error = "download failed"; return result; }
+ if (!from_cache) {
+ fs::create_directories(paths::temp_dir());
+ int rc = exec_cmd({"curl", "-L", "-o", dest_file.string(), url});
+ if (rc != 0) { result.error = "download failed"; return result; }
+ std::error_code ec;
+ // Atomic cache write: write to .tmp then rename
+ auto cache_tmp = fs::path(cache_path.string() + ".tmp");
+ fs::copy(dest_file, cache_tmp, ec);
+ if (!ec) {
+ fs::rename(cache_tmp, cache_path, ec);
+ }
+ }
bool verified = false;
for (auto algo : {"sha512", "sha256", "md5"}) {
@@ -170,7 +191,9 @@ FetchResult fetch(const dsl::PackageDef& pkg) {
if (rc2 != 0) { result.error = "extraction failed"; return result; }
}
- fs::remove(dest_file);
+ if (!from_cache) {
+ fs::remove(dest_file);
+ }
}
for (auto& patch : pkg.patches) {
diff --git a/src/fetch/recipe.cpp b/src/fetch/recipe.cpp
new file mode 100644
index 0000000..0f37ad8
--- /dev/null
+++ b/src/fetch/recipe.cpp
@@ -0,0 +1,128 @@
+#include "kappa/fetch/recipe.hpp"
+
+#include "kappa/dsl/parser.hpp"
+#include "kappa/dsl/system.hpp"
+#include "kappa/paths.hpp"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace kappa::fetch {
+
+namespace {
+int exec_curl(const std::string& output_path, const std::string& url) {
+ pid_t pid = fork();
+ if (pid == 0) {
+ execlp("curl", "curl", "-Lsf", "-o", output_path.c_str(), url.c_str(), nullptr);
+ _exit(127);
+ }
+ if (pid < 0) return -1;
+ int status = 0;
+ while (waitpid(pid, &status, 0) == -1 && errno == EINTR) {}
+ return WIFEXITED(status) ? WEXITSTATUS(status) : -1;
+}
+} // namespace
+
+RecipeResult fetch_recipe(const std::string& name,
+ const std::vector& remotes) {
+ RecipeResult result;
+
+ // Check cached version first
+ auto cache_path = paths::packages_dir() / (name + ".kap");
+ std::string cached_version;
+ if (std::filesystem::exists(cache_path)) {
+ std::ifstream in(cache_path);
+ if (in) {
+ std::ostringstream buf;
+ buf << in.rdbuf();
+ try {
+ auto pkg = dsl::parse(buf.str());
+ cached_version = pkg.version;
+ } catch (...) {
+ // Corrupt cache — will re-download
+ }
+ }
+ }
+
+ // Try each remote
+ std::string best_content;
+ std::string best_version;
+ std::string best_url;
+
+ for (auto& remote : remotes) {
+ auto url = remote;
+ if (!url.empty() && url.back() != '/') url += '/';
+ url += name + ".kap";
+
+ // Download to temp
+ auto temp_path = paths::temp_dir() / (name + ".kap.tmp");
+ int rc = exec_curl(temp_path.string(), url);
+ if (rc != 0) continue;
+
+ // Parse downloaded file
+ std::ifstream in(temp_path);
+ if (!in) { std::filesystem::remove(temp_path); continue; }
+ std::ostringstream buf;
+ buf << in.rdbuf();
+ in.close();
+
+ std::string remote_version;
+ try {
+ auto pkg = dsl::parse(buf.str());
+ remote_version = pkg.version;
+ } catch (...) {
+ std::filesystem::remove(temp_path);
+ continue;
+ }
+
+ // Compare versions — keep the best (highest)
+ // Simple string comparison for now; semver later
+ if (remote_version > best_version) {
+ best_version = remote_version;
+ best_content = buf.str();
+ best_url = url;
+ }
+
+ std::filesystem::remove(temp_path);
+ }
+
+ if (best_content.empty()) {
+ if (!cached_version.empty()) {
+ // No remote available but have cache
+ result.ok = true;
+ result.path = cache_path.string();
+ result.version = cached_version;
+ result.updated = false;
+ return result;
+ }
+ result.error = "package '" + name + "' not found in any remote";
+ return result;
+ }
+
+ // Update cache if remote is newer
+ if (best_version > cached_version || cached_version.empty()) {
+ std::error_code ec;
+ std::filesystem::create_directories(paths::packages_dir(), ec);
+ std::ofstream out(cache_path);
+ if (!out) {
+ result.error = "cannot write to cache";
+ return result;
+ }
+ out << best_content;
+ result.updated = true;
+ }
+
+ result.ok = true;
+ result.path = cache_path.string();
+ result.version = best_version;
+ return result;
+}
+
+} // namespace kappa::fetch
diff --git a/src/main.cpp b/src/main.cpp
index a5f13f7..b264130 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -4,10 +4,13 @@
#include "kappa/dsl/parser.hpp"
#include "kappa/dsl/system.hpp"
#include "kappa/fetch/fetch.hpp"
+#include "kappa/fetch/recipe.hpp"
#include "kappa/install/install.hpp"
#include "kappa/paths.hpp"
#include "kappa/rebuild/rebuild.hpp"
#include "kappa/resolve/plan.hpp"
+#include "kappa/boot/types.hpp"
+#include "kappa/service/types.hpp"
#include "kappa/tools/doctor.hpp"
#include "kappa/tools/format.hpp"
@@ -37,6 +40,7 @@ Subcommands:
doctor Check a .kap file for issues and warnings
resolve Resolve a build plan from a system config
fetch Download and verify source for a package
+ fetch-package Fetch a package recipe from configured remotes
build Build a package from its source directory
rebuild Compare config to installed state, rebuild changed
list List installed packages
@@ -87,6 +91,60 @@ static void handle_parse_error(const char* path,
std::cerr << "error: " << e.what() << '\n';
}
+static resolve::Registry build_registry(const dsl::SystemConfig& cfg) {
+ resolve::Registry registry;
+
+ for (const auto& pref : cfg.packages) {
+ bool found = false;
+
+ // Check standard locations: local .kap, examples/, cache/packages/
+ std::vector search_paths = {
+ std::string(pref.name) + ".kap",
+ std::string("examples/") + pref.name + ".kap",
+ (paths::packages_dir() / (pref.name + ".kap")).string(),
+ };
+
+ for (const auto& sp : search_paths) {
+ std::ifstream in(sp);
+ if (!in) continue;
+ std::ostringstream buf;
+ buf << in.rdbuf();
+ try {
+ auto pkg = dsl::parse(buf.str());
+ registry[pkg.name] = std::move(pkg);
+ found = true;
+ break;
+ } catch (...) {
+ continue;
+ }
+ }
+
+ // If not found locally, try remotes
+ if (!found && !cfg.remotes.empty()) {
+ auto result = fetch::fetch_recipe(pref.name, cfg.remotes);
+ if (result.ok && !result.path.empty()) {
+ std::ifstream in(result.path);
+ if (in) {
+ std::ostringstream buf;
+ buf << in.rdbuf();
+ try {
+ auto pkg = dsl::parse(buf.str());
+ registry[pkg.name] = std::move(pkg);
+ found = true;
+ } catch (...) {}
+ }
+ }
+ }
+
+ if (!found) {
+ std::cerr << "warning: package '" << pref.name
+ << "' not found locally or in remotes\n";
+ }
+ }
+
+ return registry;
+}
+
int main(int argc, char* argv[]) {
paths::ensure_directories();
@@ -117,7 +175,8 @@ int main(int argc, char* argv[]) {
|| (subcommand == "build")
|| (subcommand == "rebuild")
|| (subcommand == "list")
- || (subcommand == "rollback");
+ || (subcommand == "fetch-package")
+ || (subcommand == "rollback");
if (!valid_subcommand) {
std::cerr << "error: unknown subcommand '" << subcommand << "'\n\n";
@@ -218,6 +277,26 @@ int main(int argc, char* argv[]) {
<< cfg.packages.size() << " packages, "
<< cfg.services.size() << " services, "
<< cfg.users.size() << " users)\n";
+ if (!cfg.boot.init.empty()) {
+ auto is = kappa::service::parse_init_system(cfg.boot.init);
+ std::cout << " init: " << cfg.boot.init;
+ if (is != kappa::service::InitSystem::Unknown) {
+ std::cout << " (" << kappa::service::init_description(is) << ")";
+ } else {
+ std::cout << " (unrecognized)";
+ }
+ std::cout << "\n";
+ }
+ if (!cfg.boot.bootloader.empty()) {
+ auto bl = kappa::boot::parse_bootloader(cfg.boot.bootloader);
+ std::cout << " bootloader: " << cfg.boot.bootloader;
+ if (bl != kappa::boot::Bootloader::Unknown) {
+ std::cout << " (" << kappa::boot::bootloader_description(bl) << ")";
+ } else {
+ std::cout << " (unrecognized)";
+ }
+ std::cout << "\n";
+ }
return 0;
} catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e);
@@ -358,13 +437,7 @@ int main(int argc, char* argv[]) {
try {
auto cfg = dsl::parse_system_config(source);
- resolve::Registry registry;
- auto* pkg_arg = (argc > 3) ? argv[3] : nullptr;
- if (pkg_arg != nullptr) {
- auto pkg_src = read_file(pkg_arg);
- auto pkg = dsl::parse(pkg_src);
- registry[pkg.name] = std::move(pkg);
- }
+ auto registry = build_registry(cfg);
auto plan = resolve::resolve(cfg, registry);
@@ -420,6 +493,41 @@ int main(int argc, char* argv[]) {
}
}
+ if (subcommand == "fetch-package") {
+ if (file_arg == nullptr) {
+ std::cerr << "error: no package name specified\n";
+ return 1;
+ }
+ try {
+ std::vector remotes;
+ auto config_path = std::filesystem::path(paths::system_dir()) / "config.kap";
+ if (std::filesystem::exists(config_path)) {
+ auto cfg_src = read_file(config_path.c_str());
+ try {
+ auto cfg = dsl::parse_system_config(cfg_src);
+ remotes = cfg.remotes;
+ } catch (...) {
+ }
+ }
+ auto result = fetch::fetch_recipe(file_arg, remotes);
+ if (result.ok) {
+ if (result.updated) {
+ std::cout << "fetched " << file_arg << " " << result.version
+ << " → " << result.path << "\n";
+ } else {
+ std::cout << file_arg << " " << result.version
+ << " (cached, up to date)\n";
+ }
+ return 0;
+ }
+ std::cerr << "fetch failed: " << result.error << "\n";
+ return 1;
+ } catch (const std::exception& e) {
+ std::cerr << "fetch error: " << e.what() << "\n";
+ return 1;
+ }
+ }
+
if (subcommand == "rebuild") {
try {
auto cfg = dsl::parse_system_config(source);
@@ -442,8 +550,46 @@ int main(int argc, char* argv[]) {
std::cout << " building " << name << " (new)\n";
}
if (cs.kernel_changed) { std::cout << " kernel changed\n"; }
- if (cs.init_changed) { std::cout << " init changed\n"; }
- if (cs.bootloader_changed) { std::cout << " bootloader changed\n"; }
+ if (cs.init_changed) {
+ auto is_name = cfg.boot.init;
+ auto is = kappa::service::parse_init_system(is_name);
+ if (is != kappa::service::InitSystem::Unknown) {
+ std::cout << " init system: " << is_name << " ("
+ << kappa::service::init_description(is) << ")\n";
+ } else {
+ std::cout << " init changed (" << is_name
+ << " — unrecognized)\n";
+ }
+
+ auto registry = build_registry(cfg);
+ auto impact = rebuild::compute_init_impact(cfg, registry);
+
+ if (!impact.service_rebuild.empty()) {
+ std::cout << " full rebuild (uses ${enabledinit}): "
+ << impact.service_rebuild.size()
+ << " packages\n";
+ }
+ if (!impact.service_only.empty()) {
+ std::cout << " service files only: "
+ << impact.service_only.size()
+ << " packages\n";
+ }
+ if (!impact.skipped.empty()) {
+ std::cout << " no services — skipped: "
+ << impact.skipped.size() << " packages\n";
+ }
+ }
+ if (cs.bootloader_changed) {
+ auto bl_name = cfg.boot.bootloader;
+ auto bl = kappa::boot::parse_bootloader(bl_name);
+ if (bl != kappa::boot::Bootloader::Unknown) {
+ std::cout << " bootloader: " << bl_name << " ("
+ << kappa::boot::bootloader_description(bl) << ")\n";
+ } else {
+ std::cout << " bootloader changed (" << bl_name
+ << " — unrecognized)\n";
+ }
+ }
if (cs.services_changed) { std::cout << " services changed\n"; }
return 0;
diff --git a/src/paths.cpp b/src/paths.cpp
index 84dfa6d..a3eb99d 100644
--- a/src/paths.cpp
+++ b/src/paths.cpp
@@ -20,6 +20,8 @@ std::filesystem::path temp_dir() { return g_root / "temp"; }
std::filesystem::path db_dir() { return g_root / "db"; }
std::filesystem::path system_dir() { return g_root / "system"; }
std::filesystem::path builds_dir() { return g_root / "system" / "builds"; }
+std::filesystem::path cache_dir() { return g_root / "cache"; }
+std::filesystem::path packages_dir() { return cache_dir() / "packages"; }
void ensure_directories() {
std::error_code ec;
@@ -27,6 +29,8 @@ void ensure_directories() {
std::filesystem::create_directories(temp_dir(), ec);
std::filesystem::create_directories(db_dir(), ec);
std::filesystem::create_directories(builds_dir(), ec);
+ std::filesystem::create_directories(cache_dir(), ec);
+ std::filesystem::create_directories(packages_dir(), ec);
}
} // namespace kappa::paths
diff --git a/src/rebuild/rebuild.cpp b/src/rebuild/rebuild.cpp
index 8f3dac7..5dc7e95 100644
--- a/src/rebuild/rebuild.cpp
+++ b/src/rebuild/rebuild.cpp
@@ -1,6 +1,8 @@
#include "kappa/rebuild/rebuild.hpp"
#include "kappa/install/install.hpp"
+#include "kappa/service/types.hpp"
+#include
#include
namespace kappa::rebuild {
@@ -43,13 +45,17 @@ ChangeSet compute_changes(const dsl::SystemConfig& cfg) {
}
for (auto& e : installed) {
- if (e.name == "kernel" && e.hash != cfg.boot.kernel) {
+ // Compare the installed version (which stores the identifier string
+ // for virtual packages like init/kernel/bootloader) against the
+ // config value. These virtual entries have their identity in the
+ // version field, not the hash field.
+ if (e.name == "kernel" && e.version != cfg.boot.kernel) {
cs.kernel_changed = true;
}
- if (e.name == "init" && e.hash != cfg.boot.init) {
+ if (e.name == "init" && e.version != cfg.boot.init) {
cs.init_changed = true;
}
- if (e.name == "bootloader" && e.hash != cfg.boot.bootloader) {
+ if (e.name == "bootloader" && e.version != cfg.boot.bootloader) {
cs.bootloader_changed = true;
}
}
@@ -61,4 +67,57 @@ ChangeSet compute_changes(const dsl::SystemConfig& cfg) {
return cs;
}
+InitImpact compute_init_impact(const dsl::SystemConfig& cfg,
+ const resolve::Registry& registry) {
+ InitImpact impact;
+ auto init_system = cfg.boot.init;
+
+ // Only compute impact if an init system is actually configured
+ if (init_system.empty()) { return impact; }
+
+ auto is = kappa::service::parse_init_system(init_system);
+ if (is == kappa::service::InitSystem::Unknown) {
+ std::cerr << "warning: unknown init system '" << init_system
+ << "' — cannot compute init impact\n";
+ return impact;
+ }
+
+ // For each package in the config that has a matching registry entry...
+ for (auto& pref : cfg.packages) {
+ auto rit = registry.find(pref.name);
+ if (rit == registry.end()) { continue; }
+
+ auto& pkg = rit->second;
+
+ // No services — nothing to do
+ if (pkg.services.empty()) {
+ impact.skipped.push_back(pkg.name);
+ continue;
+ }
+
+ // Check if build scripts reference ${enabledinit}
+ bool uses_enabledinit = false;
+ auto check_phase = [&](const dsl::Phase& phase) {
+ for (auto& cmd : phase.commands) {
+ if (cmd.find("${enabledinit}") != std::string::npos) {
+ uses_enabledinit = true;
+ return;
+ }
+ }
+ };
+ check_phase(pkg.prepare);
+ if (!uses_enabledinit) check_phase(pkg.build);
+ if (!uses_enabledinit) check_phase(pkg.check);
+ if (!uses_enabledinit) check_phase(pkg.install);
+
+ if (uses_enabledinit) {
+ impact.service_rebuild.push_back(pkg.name);
+ } else {
+ impact.service_only.push_back(pkg.name);
+ }
+ }
+
+ return impact;
+}
+
} // namespace kappa::rebuild
diff --git a/src/resolve/plan.cpp b/src/resolve/plan.cpp
index c8d794d..cde17c2 100644
--- a/src/resolve/plan.cpp
+++ b/src/resolve/plan.cpp
@@ -1,7 +1,9 @@
#include "kappa/resolve/plan.hpp"
#include "kappa/config/merge.hpp"
+#include
#include
+#include
namespace kappa::resolve {
@@ -21,9 +23,10 @@ BuildPlan resolve(const dsl::SystemConfig& cfg, const Registry& registry) {
auto resolved = config::resolve_package(pkg, cfg.system, pref);
BuildStep step;
- step.name = pkg.name;
- step.package = &pkg;
- step.resolved = std::move(resolved);
+ step.name = pkg.name;
+ step.package = &pkg;
+ step.resolved = std::move(resolved);
+ step.enabled_init = cfg.boot.init;
for (auto& dep : pkg.depends) {
if (!dep.feature.empty()) {
@@ -39,6 +42,20 @@ BuildPlan resolve(const dsl::SystemConfig& cfg, const Registry& registry) {
nodes.push_back(std::move(step));
}
+ // Detect conflicts between selected packages
+ std::unordered_set selected;
+ for (auto& node : nodes) { selected.insert(node.name); }
+
+ for (auto& node : nodes) {
+ if (!node.package) continue;
+ for (auto& conflict_name : node.package->conflicts) {
+ if (selected.contains(conflict_name)) {
+ plan.conflicts.push_back(
+ std::format("{} conflicts with {}", node.name, conflict_name));
+ }
+ }
+ }
+
std::vector in_degree(nodes.size(), 0);
std::vector> adj(nodes.size());
diff --git a/src/sched/scheduler.cpp b/src/sched/scheduler.cpp
index bda6647..324fecc 100644
--- a/src/sched/scheduler.cpp
+++ b/src/sched/scheduler.cpp
@@ -1,27 +1,290 @@
#include "kappa/sched/scheduler.hpp"
#include "kappa/build/build.hpp"
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
namespace kappa::sched {
+// ---------------------------------------------------------------------------
+// Internal node tracked per package during scheduling
+// ---------------------------------------------------------------------------
+struct Node {
+ const resolve::BuildStep* step = nullptr;
+ int pending_deps = 0; // dependencies not yet built
+ std::vector dependents; // packages waiting on this one
+ int depth = 0; // distance from deepest leaf
+ bool claimed = false;
+ build::BuildResult result;
+};
+
+// ---------------------------------------------------------------------------
+// Priority queue keyed by depth group (Beta/Alpha/Zeta cycle)
+// Deeper packages (higher depth) get priority so they unblock more work.
+// ---------------------------------------------------------------------------
+struct ReadyOrder {
+ bool operator()(const Node* a, const Node* b) const {
+ return a->depth < b->depth; // max-heap by depth
+ }
+};
+
+// ---------------------------------------------------------------------------
+// Three-level depth grouping: Beta → Alpha → Zeta → Beta → ...
+// Returns a priority value: Beta = 2, Alpha = 1, Zeta = 0.
+// Higher value = build sooner.
+// ---------------------------------------------------------------------------
+static int level_priority(int depth) {
+ int level = depth % 3;
+ // Beta=0, Alpha=1, Zeta=2
+ // Beta should go first (priority 2), Alpha second (1), Zeta last (0)
+ return (3 - level) % 3;
+}
+
+// ---------------------------------------------------------------------------
+// Scheduler state shared between workers
+// ---------------------------------------------------------------------------
+struct Scheduler {
+ std::vector nodes;
+ std::unordered_map name_to_idx;
+
+ // Ready packages grouped by priority → max-heap
+ std::priority_queue, ReadyOrder> ready[3];
+
+ // Packages waiting (pending_deps > 0 but not yet ready)
+ std::unordered_set waiting;
+
+ std::mutex mtx;
+ std::condition_variable cv;
+
+ std::atomic active_workers{0};
+ std::atomic completed{0};
+ std::atomic stop{false};
+ int total_packages = 0;
+
+ std::string work_root;
+ int jobs_per_worker = 1;
+
+ SchedResult result;
+};
+
+// ---------------------------------------------------------------------------
+// Enqueue a node into the ready queue at the correct priority level
+// ---------------------------------------------------------------------------
+static void enqueue_ready(Scheduler& s, std::size_t idx) {
+ Node& node = s.nodes[idx];
+ int prio = level_priority(node.depth);
+ s.ready[prio].push(&node);
+ s.waiting.erase(idx);
+}
+
+// ---------------------------------------------------------------------------
+// Try to claim the next ready package from any priority level.
+// Returns nullptr if nothing is ready.
+// ---------------------------------------------------------------------------
+static Node* claim_next(Scheduler& s) {
+ // Check Beta (0), then Alpha (1), then Zeta (2)
+ for (int p = 2; p >= 0; --p) {
+ auto& q = s.ready[p];
+ if (q.empty()) continue;
+ Node* node = q.top();
+ q.pop();
+ node->claimed = true;
+ return node;
+ }
+ return nullptr;
+}
+
+// ---------------------------------------------------------------------------
+// Worker loop
+// ---------------------------------------------------------------------------
+static void worker_loop(Scheduler& s) {
+ s.active_workers.fetch_add(1, std::memory_order_relaxed);
+
+ while (true) {
+ Node* node = nullptr;
+ {
+ std::unique_lock lock(s.mtx);
+ s.cv.wait(lock, [&] {
+ return s.stop.load(std::memory_order_acquire)
+ || !s.ready[0].empty()
+ || !s.ready[1].empty()
+ || !s.ready[2].empty()
+ || s.completed.load(std::memory_order_acquire) >= s.total_packages;
+ });
+
+ if (s.stop.load(std::memory_order_acquire)) break;
+ if (s.completed.load(std::memory_order_acquire) >= s.total_packages) break;
+ node = claim_next(s);
+ }
+
+ if (node == nullptr) continue;
+
+ // Build the package
+ std::cout << std::format(" building {} (depth={})\n",
+ node->step->name, node->depth);
+ auto r = build::build(*node->step,
+ s.work_root + "/" + node->step->name,
+ s.jobs_per_worker);
+ node->result = r;
+
+ // Mark complete and notify dependents
+ {
+ std::lock_guard lock(s.mtx);
+ s.completed.fetch_add(1, std::memory_order_relaxed);
+
+ if (r.ok) {
+ s.result.built.push_back(node->step->name);
+ } else {
+ s.result.failed.push_back(node->step->name);
+ s.result.ok = false;
+ s.stop.store(true, std::memory_order_release);
+ }
+
+ // Wake up dependents
+ for (auto dep_idx : node->dependents) {
+ Node& dep = s.nodes[dep_idx];
+ dep.pending_deps--;
+ if (dep.pending_deps == 0) {
+ enqueue_ready(s, dep_idx);
+ }
+ }
+ }
+
+ // Signal completion or failure
+ {
+ std::lock_guard lock(s.mtx);
+ if (s.completed.load(std::memory_order_acquire) >= s.total_packages) {
+ s.stop.store(true, std::memory_order_release);
+ }
+ }
+ s.cv.notify_all();
+ }
+
+ s.active_workers.fetch_sub(1, std::memory_order_relaxed);
+}
+
+// ---------------------------------------------------------------------------
+// Compute depth for each node (distance from deepest leaf)
+// Uses post-order traversal: depth = 1 + max(dep depths), leaf = 1
+// ---------------------------------------------------------------------------
+static void compute_depths(Scheduler& s) {
+ // Start from leaves (pending_deps == 0)
+ std::queue leaf_queue;
+ for (std::size_t i = 0; i < s.nodes.size(); ++i) {
+ if (s.nodes[i].pending_deps == 0) {
+ s.nodes[i].depth = 1;
+ leaf_queue.push(i);
+ }
+ }
+
+ // Propagate upward: when a dependent is processed, its depth
+ // is 1 + max of its dependency depths.
+ // For simplicity, we approximate: depth = level from leaves.
+ // This is fine for prioritization — deeper = unblocks more.
+ std::vector rem_deps(s.nodes.size());
+ for (std::size_t i = 0; i < s.nodes.size(); ++i) {
+ rem_deps[i] = static_cast(s.nodes[i].dependents.size());
+ }
+
+ while (!leaf_queue.empty()) {
+ auto u = leaf_queue.front();
+ leaf_queue.pop();
+ Node& node = s.nodes[u];
+
+ if (node.step == nullptr) continue;
+ for (auto dep_idx : node.dependents) {
+ Node& dep_node = s.nodes[dep_idx];
+ if (node.depth + 1 > dep_node.depth) {
+ dep_node.depth = node.depth + 1;
+ }
+ if (--rem_deps[dep_idx] == 0) {
+ leaf_queue.push(dep_idx);
+ }
+ }
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Public entry point
+// ---------------------------------------------------------------------------
SchedResult run(const resolve::BuildPlan& plan,
const std::string& work_root,
int workers,
int jobs) {
- SchedResult result;
+ if (plan.steps.empty()) {
+ return {true, {}, {}};
+ }
- for (auto& step : plan.steps) {
- auto r = build::build(step, work_root + "/" + step.name, jobs);
- if (r.ok) {
- result.built.push_back(step.name);
- } else {
- result.failed.push_back(step.name);
- result.ok = false;
- return result;
+ Scheduler s;
+ s.work_root = work_root;
+ s.jobs_per_worker = std::max(1, jobs);
+ s.total_packages = static_cast(plan.steps.size());
+ s.nodes.resize(plan.steps.size());
+
+ // Build name → index map
+ for (std::size_t i = 0; i < plan.steps.size(); ++i) {
+ s.name_to_idx[plan.steps[i].name] = i;
+ }
+
+ // Wire up nodes: dependencies, dependents, pending_deps
+ for (std::size_t i = 0; i < plan.steps.size(); ++i) {
+ Node& node = s.nodes[i];
+ node.step = &plan.steps[i];
+ node.depth = 0;
+
+ for (auto& dep : plan.steps[i].dependencies) {
+ auto it = s.name_to_idx.find(dep.name);
+ if (it == s.name_to_idx.end()) {
+ std::cerr << std::format("warning: dependency '{}' of '{}' not in build plan\n",
+ dep.name, plan.steps[i].name);
+ continue;
+ }
+
+ // dep → i (dependency is upstream)
+ Node& dep_node = s.nodes[it->second];
+ dep_node.dependents.push_back(i);
+ node.pending_deps++;
}
}
- result.ok = true;
- return result;
+ // Compute depths for priority
+ compute_depths(s);
+
+ // Enqueue root nodes (pending_deps == 0)
+ for (std::size_t i = 0; i < s.nodes.size(); ++i) {
+ if (s.nodes[i].pending_deps == 0) {
+ enqueue_ready(s, i);
+ } else {
+ s.waiting.insert(i);
+ }
+ }
+
+ int num_workers = std::max(1, std::min(workers, s.total_packages));
+ std::cout << std::format("scheduler: {} packages, {} workers, {} jobs/worker\n",
+ s.total_packages, num_workers, s.jobs_per_worker);
+
+ // Spawn workers
+ std::vector threads;
+ for (int w = 0; w < num_workers; ++w) {
+ threads.emplace_back(worker_loop, std::ref(s));
+ }
+
+ // Wait for workers to finish
+ for (auto& t : threads) {
+ if (t.joinable()) t.join();
+ }
+
+ s.result.ok = s.result.failed.empty();
+ return s.result;
}
} // namespace kappa::sched
diff --git a/src/service/dinit.cpp b/src/service/dinit.cpp
new file mode 100644
index 0000000..81c5411
--- /dev/null
+++ b/src/service/dinit.cpp
@@ -0,0 +1,74 @@
+#include "kappa/service/service.hpp"
+
+#include
+#include
+
+namespace kappa::service {
+
+std::string generate_dinit_service(const ServiceSpec& spec) {
+ std::ostringstream out;
+
+ // Header
+ out << std::format("# Generated by kappa — do not edit manually\n");
+ out << std::format("# dinit service: {}\n", spec.name);
+ out << "\n";
+
+ // Type mapping
+ std::string dinit_type;
+ if (spec.type == "simple" || spec.type == "notify" ||
+ spec.type == "longrun") {
+ dinit_type = "process";
+ } else if (spec.type == "forking") {
+ dinit_type = "bgprocess";
+ } else if (spec.type == "oneshot") {
+ dinit_type = "scripted";
+ } else {
+ dinit_type = "process";
+ }
+
+ out << std::format("type = {}\n", dinit_type);
+ out << std::format("command = {}\n", spec.exec);
+
+ // Restart policy
+ if (spec.restart_policy == "always") {
+ out << "restart = true\n";
+ } else if (spec.restart_policy == "on-failure") {
+ out << "restart = true\n";
+ } else if (spec.restart_policy == "never") {
+ out << "restart = false\n";
+ } else if (!spec.restart_policy.empty()) {
+ out << "restart = true\n";
+ }
+
+ // depends-on
+ if (!spec.after.empty()) {
+ out << std::format("depends-on = {}\n", spec.after);
+ }
+
+ // working-dir
+ if (!spec.working_dir.empty()) {
+ out << std::format("working-dir = {}\n", spec.working_dir);
+ }
+
+ // run-as
+ if (!spec.user.empty()) {
+ out << std::format("run-as = {}\n", spec.user);
+ }
+
+ // Environment variables (as comments — dinit doesn't support inline env)
+ if (!spec.env.empty()) {
+ out << "\n# Environment variables:\n";
+ for (const auto& [key, value] : spec.env) {
+ out << std::format("# {}={}\n", key, value);
+ }
+ }
+
+ // description
+ if (!spec.description.empty()) {
+ out << std::format("description = {}\n", spec.description);
+ }
+
+ return out.str();
+}
+
+} // namespace kappa::service
diff --git a/src/service/install.cpp b/src/service/install.cpp
new file mode 100644
index 0000000..97a16d9
--- /dev/null
+++ b/src/service/install.cpp
@@ -0,0 +1,202 @@
+#include "kappa/service/service.hpp"
+
+#include
+#include
+#include
+
+namespace kappa::service {
+
+// ---------------------------------------------------------------------------
+// Backend service-file generators (defined in separate .cpp files)
+// systemd_service and s6_service are declared in service.hpp
+// ---------------------------------------------------------------------------
+
+// ---------------------------------------------------------------------------
+// ServiceSpec::from_service_init
+// ---------------------------------------------------------------------------
+
+ServiceSpec ServiceSpec::from_service_init(const dsl::NamedService& ns) {
+ ServiceSpec spec;
+ spec.name = ns.name;
+ spec.description = ns.description;
+ spec.exec = ns.exec;
+ spec.user = ns.user;
+ spec.ports = ns.ports;
+ spec.env = ns.env;
+ spec.after = ns.after;
+ spec.working_dir = ns.working_dir;
+
+ // Normalize type
+ if (ns.type == "forking" || ns.type == "longrun" ||
+ ns.type == "notify" || ns.type == "oneshot") {
+ spec.type = ns.type;
+ } else {
+ spec.type = "simple";
+ }
+
+ // Restart policy
+ spec.restart_policy = ns.restart;
+ if (spec.restart_policy.empty() && ns.type == "longrun") {
+ spec.restart_policy = "always";
+ }
+
+ return spec;
+}
+
+// ---------------------------------------------------------------------------
+// generate_service_file — dispatch to the correct backend
+// ---------------------------------------------------------------------------
+
+std::string generate_service_file(InitSystem is, const ServiceSpec& spec) {
+ switch (is) {
+ case InitSystem::Systemd:
+ return generate_systemd_service(spec);
+ case InitSystem::OpenRC:
+ return generate_openrc_service(spec);
+ case InitSystem::S6:
+ return generate_s6_service(spec);
+ case InitSystem::Dinit:
+ return generate_dinit_service(spec);
+ case InitSystem::Runit:
+ return generate_runit_service(spec);
+ case InitSystem::Unknown:
+ default:
+ return {};
+ }
+}
+
+// ---------------------------------------------------------------------------
+// install_service — write the generated file(s) to disk
+// ---------------------------------------------------------------------------
+
+ServiceInstallResult install_service(InitSystem is,
+ const ServiceSpec& spec,
+ std::string_view prefix) {
+ namespace fs = std::filesystem;
+
+ auto paths = init_paths(is, prefix);
+ if (paths.service_dir.empty()) {
+ return {false, {}, "Unknown init system"};
+ }
+
+ std::error_code ec;
+
+ // ---- S6: directory-based layout (type + run) ----
+ // S6: directory-based layout. Content generated inline rather than
+ // calling generate_s6_service() to avoid parsing the combined output.
+ if (is == InitSystem::S6) {
+ fs::path svc_dir = fs::path(paths.service_dir) / spec.name;
+ fs::create_directories(svc_dir, ec);
+ if (ec) {
+ return {false, {}, ec.message()};
+ }
+
+ // --- type file ---
+ {
+ fs::path type_path = svc_dir / "type";
+ std::ofstream out(type_path);
+ if (!out) {
+ return {false, {},
+ std::format("Failed to write {}", type_path.string())};
+ }
+ out << ((spec.type == "oneshot") ? "oneshot" : "longrun");
+ }
+
+ // --- run file ---
+ fs::path run_path = svc_dir / "run";
+ {
+ std::ofstream out(run_path);
+ if (!out) {
+ return {false, {},
+ std::format("Failed to write {}", run_path.string())};
+ }
+ out << "#!/bin/execlineb -P\n";
+ out << "# Generated by kappa — do not edit manually\n";
+ out << std::format("# s6 service: {}\n", spec.name);
+ if (!spec.working_dir.empty()) {
+ out << std::format("cd {}\n", spec.working_dir);
+ }
+ for (const auto& [key, value] : spec.env) {
+ out << std::format("export {} \"{}\"\n", key, value);
+ }
+ if (!spec.user.empty()) {
+ out << std::format("s6-setuidgid {}\n", spec.user);
+ }
+ out << spec.exec << "\n";
+ }
+
+ // Make run file executable
+ fs::permissions(run_path,
+ fs::perms::owner_exec | fs::perms::group_exec |
+ fs::perms::others_exec,
+ fs::perm_options::add, ec);
+
+ return {true, svc_dir.string(), {}};
+ }
+
+ // ---- Runit: directory-based layout (run) ----
+ if (is == InitSystem::Runit) {
+ fs::path svc_dir = fs::path(paths.service_dir) / spec.name;
+ fs::create_directories(svc_dir, ec);
+ if (ec) {
+ return {false, {}, ec.message()};
+ }
+
+ fs::path run_path = svc_dir / "run";
+ {
+ std::ofstream out(run_path);
+ if (!out) {
+ return {false, {},
+ std::format("Failed to write {}", run_path.string())};
+ }
+ out << generate_runit_service(spec);
+ }
+
+ // Make run executable
+ fs::permissions(run_path,
+ fs::perms::owner_exec | fs::perms::group_exec |
+ fs::perms::others_exec,
+ fs::perm_options::add, ec);
+
+ return {true, svc_dir.string(), {}};
+ }
+
+ // ---- Systemd / OpenRC / Dinit: single service file ----
+ std::string content = generate_service_file(is, spec);
+ if (content.empty()) {
+ return {false, {}, "Failed to generate service file for init system"};
+ }
+
+ // Determine filename
+ std::string filename;
+ switch (is) {
+ case InitSystem::Systemd:
+ filename = std::format("{}.service", spec.name);
+ break;
+ case InitSystem::OpenRC:
+ case InitSystem::Dinit:
+ filename = spec.name;
+ break;
+ default:
+ return {false, {}, "Unknown init system"};
+ }
+
+ fs::path file_path = fs::path(paths.service_dir) / filename;
+ fs::create_directories(file_path.parent_path(), ec);
+ if (ec) {
+ return {false, {}, ec.message()};
+ }
+
+ {
+ std::ofstream out(file_path);
+ if (!out) {
+ return {false, {},
+ std::format("Failed to write {}", file_path.string())};
+ }
+ out << content;
+ }
+
+ return {true, file_path.string(), {}};
+}
+
+} // namespace kappa::service
diff --git a/src/service/openrc.cpp b/src/service/openrc.cpp
new file mode 100644
index 0000000..f6f72ef
--- /dev/null
+++ b/src/service/openrc.cpp
@@ -0,0 +1,81 @@
+#include "kappa/service/service.hpp"
+#include "kappa/util.hpp"
+
+#include
+#include
+
+namespace kappa::service {
+
+namespace {
+
+bool is_background_type(std::string_view type) {
+ return type == "longrun" || type == "notify" || type == "forking";
+}
+
+} // namespace
+
+std::string generate_openrc_service(const ServiceSpec& spec) {
+ std::ostringstream os;
+
+ // Shebang and header
+ os << "#!/sbin/openrc-run\n";
+ os << "# Generated by kappa — do not edit manually\n";
+
+ // Description
+ auto desc = spec.description.empty()
+ ? spec.name + " service"
+ : spec.description;
+ os << std::format("description=\"{}\"\n", util::shell_escape(desc));
+
+ // Command
+ os << std::format("\ncommand=\"{}\"\n", util::shell_escape(spec.exec));
+
+ // User
+ if (!spec.user.empty()) {
+ os << std::format("command_user=\"{}\"\n", util::shell_escape(spec.user));
+ }
+
+ // command_background and command_args (type-specific)
+ if (spec.type == "oneshot") {
+ os << "command_background=false\n";
+ os << "command_args=\"\"\n";
+ } else if (is_background_type(spec.type)) {
+ os << "command_background=true\n";
+ } else if (!spec.working_dir.empty()) {
+ os << "command_background=true\n";
+ }
+
+ // Working directory
+ if (!spec.working_dir.empty()) {
+ os << std::format("directory=\"{}\"\n",
+ util::shell_escape(spec.working_dir));
+ }
+
+ // Depend block (if after or ports)
+ bool has_depend = !spec.after.empty() || !spec.ports.empty();
+ if (has_depend) {
+ os << "\ndepend() {\n";
+ if (!spec.after.empty()) {
+ os << std::format(" need {}\n", spec.after);
+ }
+ if (!spec.ports.empty()) {
+ os << " use net\n";
+ }
+ os << "}\n";
+ }
+
+ // Restart policy comment
+ if (!spec.restart_policy.empty()) {
+ os << std::format("# restart policy: {}\n", spec.restart_policy);
+ }
+
+ // Environment exports
+ for (auto& [key, value] : spec.env) {
+ os << std::format("export {}=\"{}\"\n",
+ key, util::shell_escape(value));
+ }
+
+ return os.str();
+}
+
+} // namespace kappa::service
diff --git a/src/service/runit.cpp b/src/service/runit.cpp
new file mode 100644
index 0000000..78db191
--- /dev/null
+++ b/src/service/runit.cpp
@@ -0,0 +1,57 @@
+#include "kappa/service/service.hpp"
+#include "kappa/util.hpp"
+
+#include
+#include
+
+namespace kappa::service {
+
+std::string generate_runit_service(const ServiceSpec& spec) {
+ std::ostringstream out;
+
+ // Shebang
+ out << "#!/bin/sh\n";
+
+ // Header
+ out << "# Generated by kappa — do not edit manually\n";
+ out << std::format("# runit service: {}\n", spec.name);
+ out << std::format("# Type: {}\n", spec.type);
+
+ // Forking note
+ if (spec.type == "forking") {
+ out << "# NOTE: runit requires foreground execution.\n";
+ out << "# If the daemon forks, pass --foreground or equivalent"
+ " flag.\n";
+ }
+
+ // Oneshot note
+ if (spec.type == "oneshot") {
+ out << "# NOTE: runit has no native oneshot support. This service"
+ " will restart on exit.\n";
+ }
+
+ // Redirect stderr to stdout for logging
+ out << "exec 2>&1\n";
+
+ // Working directory
+ if (!spec.working_dir.empty()) {
+ out << std::format("cd \"{}\"\n", util::shell_escape(spec.working_dir));
+ }
+
+ // Environment variables
+ for (const auto& [key, value] : spec.env) {
+ out << std::format("export {}=\"{}\"\n", key, util::shell_escape(value));
+ }
+
+ // Final exec — replace the shell with the daemon
+ if (!spec.user.empty()) {
+ out << std::format("exec chpst -u {} {}\n",
+ util::shell_escape(spec.user), spec.exec);
+ } else {
+ out << "exec " << spec.exec << "\n";
+ }
+
+ return out.str();
+}
+
+} // namespace kappa::service
diff --git a/src/service/s6.cpp b/src/service/s6.cpp
new file mode 100644
index 0000000..f5270e5
--- /dev/null
+++ b/src/service/s6.cpp
@@ -0,0 +1,47 @@
+#include "kappa/service/service.hpp"
+
+#include
+#include
+
+namespace kappa::service {
+
+std::string generate_s6_service(const ServiceSpec& spec) {
+ std::ostringstream out;
+
+ // --- type file content ---
+ std::string type_content;
+ if (spec.type == "oneshot") {
+ type_content = "oneshot";
+ } else {
+ // "longrun", "notify", and anything else map to longrun in s6
+ type_content = "longrun";
+ }
+
+ // --- run file content ---
+ std::ostringstream run;
+ run << "#!/bin/execlineb -P\n";
+ run << "# Generated by kappa — do not edit manually\n";
+ run << std::format("# s6 service: {}\n", spec.name);
+
+ if (!spec.working_dir.empty()) {
+ run << std::format("cd {}\n", spec.working_dir);
+ }
+ for (const auto& [key, value] : spec.env) {
+ run << std::format("export {} \"{}\"\n", key, value);
+ }
+ if (!spec.user.empty()) {
+ run << std::format("s6-setuidgid {}\n", spec.user);
+ }
+ run << spec.exec << "\n";
+
+ // --- combined output ---
+ out << std::format("# --- s6 service directory: {} ---\n", spec.name);
+ out << "# file: type\n";
+ out << type_content << "\n";
+ out << "# file: run\n";
+ out << run.str();
+
+ return out.str();
+}
+
+} // namespace kappa::service
diff --git a/src/service/systemd.cpp b/src/service/systemd.cpp
new file mode 100644
index 0000000..1aaee34
--- /dev/null
+++ b/src/service/systemd.cpp
@@ -0,0 +1,62 @@
+#include "kappa/service/service.hpp"
+
+#include
+#include
+
+namespace kappa::service {
+
+std::string generate_systemd_service(const ServiceSpec& spec) {
+ std::ostringstream out;
+
+ // --- [Unit] ---
+ out << "[Unit]\n";
+ out << std::format("Description={}\n",
+ spec.description.empty() ? spec.name : spec.description);
+ if (!spec.after.empty()) {
+ out << std::format("After={}\n", spec.after);
+ }
+
+ // --- [Service] ---
+ out << "\n[Service]\n";
+ out << std::format("ExecStart={}\n", spec.exec);
+
+ if (spec.type == "simple") {
+ out << "Type=simple\n";
+ } else if (spec.type == "forking") {
+ out << "Type=forking\n";
+ } else if (spec.type == "oneshot") {
+ out << "Type=oneshot\n";
+ } else if (spec.type == "notify") {
+ out << "Type=notify\n";
+ } else {
+ out << "Type=simple\n";
+ }
+
+ if (!spec.user.empty()) {
+ out << std::format("User={}\n", spec.user);
+ }
+
+ if (spec.restart_policy == "always") {
+ out << "Restart=always\n";
+ } else if (spec.restart_policy == "on-failure") {
+ out << "Restart=on-failure\n";
+ } else if (spec.restart_policy == "never") {
+ out << "Restart=no\n";
+ }
+
+ if (!spec.working_dir.empty()) {
+ out << std::format("WorkingDirectory={}\n", spec.working_dir);
+ }
+
+ for (const auto& [key, value] : spec.env) {
+ out << std::format("Environment=\"{0}={1}\"\n", key, value);
+ }
+
+ // --- [Install] ---
+ out << "\n[Install]\n";
+ out << "WantedBy=multi-user.target\n";
+
+ return out.str();
+}
+
+} // namespace kappa::service
diff --git a/src/service/types.cpp b/src/service/types.cpp
new file mode 100644
index 0000000..56a7e53
--- /dev/null
+++ b/src/service/types.cpp
@@ -0,0 +1,98 @@
+#include "kappa/service/types.hpp"
+#include "kappa/util.hpp"
+
+#include
+#include
+#include
+#include
+
+namespace kappa::service {
+
+InitSystem parse_init_system(std::string_view name) {
+ auto lower = util::to_lower(name);
+ if (lower == "systemd") return InitSystem::Systemd;
+ if (lower == "openrc") return InitSystem::OpenRC;
+ if (lower == "s6") return InitSystem::S6;
+ if (lower == "runit") return InitSystem::Runit;
+ if (lower == "dinit") return InitSystem::Dinit;
+ return InitSystem::Unknown;
+}
+
+std::string_view to_string(InitSystem is) {
+ switch (is) {
+ case InitSystem::Systemd: return "systemd";
+ case InitSystem::OpenRC: return "openrc";
+ case InitSystem::S6: return "s6";
+ case InitSystem::Runit: return "runit";
+ case InitSystem::Dinit: return "dinit";
+ case InitSystem::Unknown: return "unknown";
+ }
+ return "unknown";
+}
+
+bool is_supported(std::string_view name) {
+ return parse_init_system(name) != InitSystem::Unknown;
+}
+
+std::vector all_systems() {
+ return {InitSystem::Systemd, InitSystem::OpenRC,
+ InitSystem::S6, InitSystem::Runit, InitSystem::Dinit};
+}
+
+std::string_view init_description(InitSystem is) {
+ switch (is) {
+ case InitSystem::Systemd:
+ return "systemd — system and service manager";
+ case InitSystem::OpenRC:
+ return "OpenRC — dependency-based init system";
+ case InitSystem::S6:
+ return "s6 — s6 supervision suite";
+ case InitSystem::Runit:
+ return "runit — supervision suite";
+ case InitSystem::Dinit:
+ return "dinit — service manager / init system";
+ case InitSystem::Unknown:
+ return "unknown init system";
+ }
+ return "unknown init system";
+}
+
+InitPaths init_paths(InitSystem is, std::string_view prefix) {
+ switch (is) {
+ case InitSystem::Systemd:
+ return {
+ .service_dir = std::format("{}etc/systemd/system", prefix),
+ .enable_cmd = "systemctl enable",
+ .disable_cmd = "systemctl disable",
+ };
+ case InitSystem::OpenRC:
+ return {
+ .service_dir = std::format("{}etc/init.d", prefix),
+ .enable_cmd = "rc-update add",
+ .disable_cmd = "rc-update del",
+ };
+ case InitSystem::S6:
+ return {
+ .service_dir = std::format("{}etc/s6/sv", prefix),
+ .enable_cmd = "s6-rc-bundle-update",
+ .disable_cmd = "s6-rc-bundle-update",
+ };
+ case InitSystem::Runit:
+ return {
+ .service_dir = std::format("{}etc/sv", prefix),
+ .enable_cmd = "ln -sf /etc/sv",
+ .disable_cmd = "rm -f /var/service",
+ };
+ case InitSystem::Dinit:
+ return {
+ .service_dir = std::format("{}etc/dinit.d", prefix),
+ .enable_cmd = "dinitctl enable",
+ .disable_cmd = "dinitctl disable",
+ };
+ case InitSystem::Unknown:
+ return {};
+ }
+ return {};
+}
+
+} // namespace kappa::service
diff --git a/src/system/activate.cpp b/src/system/activate.cpp
new file mode 100644
index 0000000..07963ed
--- /dev/null
+++ b/src/system/activate.cpp
@@ -0,0 +1,55 @@
+#include "kappa/system/activate.hpp"
+
+#include
+#include
+#include
+
+namespace kappa::system {
+
+ActivateResult write_hostname(const std::string& hostname,
+ std::string_view prefix) {
+ if (hostname.empty()) {
+ return {false, "hostname is empty"};
+ }
+
+ std::filesystem::path path = std::filesystem::path(prefix) / "etc/hostname";
+ std::error_code ec;
+ std::filesystem::create_directories(path.parent_path(), ec);
+ if (ec) {
+ return {false, std::format("cannot create {}: {}", path.parent_path().string(), ec.message())};
+ }
+
+ std::ofstream out(path);
+ if (!out) {
+ return {false, std::format("cannot write {}", path.string())};
+ }
+ out << hostname << "\n";
+ return {true, {}};
+}
+
+ActivateResult write_timezone(const std::string& timezone,
+ std::string_view prefix) {
+ if (timezone.empty()) {
+ return {false, "timezone is empty"};
+ }
+
+ // /etc/localtime is a symlink to /usr/share/zoneinfo/{timezone}
+ std::filesystem::path localtime = std::filesystem::path(prefix) / "etc/localtime";
+ std::filesystem::path zoneinfo = std::filesystem::path(prefix) / "usr/share/zoneinfo" / timezone;
+
+ std::error_code ec;
+ if (!std::filesystem::exists(zoneinfo, ec)) {
+ return {false, std::format("timezone data not found: {}", zoneinfo.string())};
+ }
+
+ std::filesystem::create_directories(localtime.parent_path(), ec);
+ // Remove existing symlink/file if present
+ std::filesystem::remove(localtime, ec);
+ std::filesystem::create_symlink(zoneinfo, localtime, ec);
+ if (ec) {
+ return {false, std::format("cannot create symlink {}: {}", localtime.string(), ec.message())};
+ }
+ return {true, {}};
+}
+
+} // namespace kappa::system
diff --git a/src/tools/doctor.cpp b/src/tools/doctor.cpp
index cad11bb..05dce7e 100644
--- a/src/tools/doctor.cpp
+++ b/src/tools/doctor.cpp
@@ -1,4 +1,8 @@
#include "kappa/tools/doctor.hpp"
+#include "kappa/boot/types.hpp"
+#include "kappa/service/types.hpp"
+#include
+#include
namespace kappa::tools {
@@ -32,6 +36,17 @@ std::vector check_package(const dsl::PackageDef& pkg) {
}
}
+ for (auto& c : pkg.conflicts) {
+ if (c.empty()) {
+ diags.push_back({DiagSeverity::Warning,
+ "conflict entry has an empty name"});
+ }
+ if (c == pkg.name) {
+ diags.push_back({DiagSeverity::Error,
+ "package conflicts with itself: '" + c + "'"});
+ }
+ }
+
if (pkg.config_files.empty()) {
diags.push_back({DiagSeverity::Warning,
"no config files defined — package has no runtime configuration"});
@@ -71,8 +86,48 @@ std::vector check_config(const dsl::SystemConfig& cfg) {
if (cfg.boot.init.empty()) {
diags.push_back({DiagSeverity::Warning, "boot init system is not specified"});
}
+ if (!cfg.boot.init.empty()) {
+ auto is = kappa::service::parse_init_system(cfg.boot.init);
+ if (is == kappa::service::InitSystem::Unknown) {
+ diags.push_back({DiagSeverity::Warning,
+ "boot.init '" + cfg.boot.init + "' is not a recognized init system — supported: systemd, openrc, s6, runit, dinit"});
+ }
+ }
+ if (cfg.boot.init.empty() && !cfg.services.empty()) {
+ diags.push_back({DiagSeverity::Warning,
+ std::to_string(cfg.services.size()) + " service(s) defined but no init system configured — set boot.init"});
+ }
+ if (cfg.boot.bootloader.empty()) {
+ diags.push_back({DiagSeverity::Warning, "boot bootloader is not specified"});
+ } else {
+ auto bl = kappa::boot::parse_bootloader(cfg.boot.bootloader);
+ if (bl == kappa::boot::Bootloader::Unknown) {
+ diags.push_back({DiagSeverity::Warning,
+ "boot.bootloader '" + cfg.boot.bootloader + "' is not a recognized bootloader — supported: grub, limine"});
+ }
+ }
+ // Validate boot partitions
if (cfg.boot.efi.empty() && cfg.boot.root.empty()) {
diags.push_back({DiagSeverity::Error, "no boot partitions defined (efi or root)"});
+ } else {
+ // Check that each specified partition exists
+ auto check_partition = [&](const std::string& path, const char* label) {
+ if (path.empty()) return;
+ std::error_code ec;
+ if (!std::filesystem::exists(path, ec)) {
+ diags.push_back({DiagSeverity::Error,
+ std::string("boot.") + label + " '" + path + "' does not exist"});
+ } else {
+ struct stat st;
+ if (stat(path.c_str(), &st) == 0 && !S_ISBLK(st.st_mode)) {
+ diags.push_back({DiagSeverity::Warning,
+ std::string("boot.") + label + " '" + path + "' is not a block device"});
+ }
+ }
+ };
+ check_partition(cfg.boot.efi, "efi");
+ check_partition(cfg.boot.root, "root");
+ check_partition(cfg.boot.swap, "swap");
}
if (cfg.packages.empty()) {
@@ -105,6 +160,12 @@ std::vector check_config(const dsl::SystemConfig& cfg) {
+ " assertions defined — run 'kappa validate' to check them"});
}
+ for (auto& g : cfg.groups) {
+ if (g.name.empty()) {
+ diags.push_back({DiagSeverity::Warning, "group has no name"});
+ }
+ }
+
return diags;
}
diff --git a/src/tools/format.cpp b/src/tools/format.cpp
index 37c0d19..5641dec 100644
--- a/src/tools/format.cpp
+++ b/src/tools/format.cpp
@@ -27,8 +27,12 @@ static void write_env(std::ostream& os, int d,
static void write_features(std::ostream& os, int d,
const std::unordered_map& feats) {
if (feats.empty()) { return; }
+ std::vector keys;
+ for (auto& [k, _] : feats) keys.push_back(k);
+ std::sort(keys.begin(), keys.end());
os << Indent(d) << "features {\n";
- for (auto& [k, f] : feats) {
+ for (auto& k : keys) {
+ auto& f = feats.at(k);
if (f.flag.empty() && !f.force) {
os << Indent(d + 1) << k << " = " << (f.enabled ? "true" : "false") << "\n";
} else {
@@ -105,6 +109,15 @@ void format_package(std::ostream& os, const dsl::PackageDef& pkg) {
os << "]\n";
}
+ if (!pkg.conflicts.empty()) {
+ os << Indent(d) << "conflicts = [";
+ for (std::size_t i = 0; i < pkg.conflicts.size(); ++i) {
+ if (i > 0) { os << ", "; }
+ os << '"' << pkg.conflicts[i] << '"';
+ }
+ os << "]\n";
+ }
+
if (!pkg.patches.empty()) {
os << Indent(d) << "patches = [\n";
for (auto& p : pkg.patches) {
@@ -151,27 +164,33 @@ void format_package(std::ostream& os, const dsl::PackageDef& pkg) {
os << Indent(d) << "}\n";
}
- if (!pkg.service.empty()) {
- os << Indent(d) << "service {\n";
- for (auto& [init, si] : pkg.service) {
- os << Indent(d + 1) << init << " {\n";
- if (!si.exec.empty()) { os << Indent(d + 2) << "exec = \"" << si.exec << "\"\n"; }
- if (!si.type.empty()) { os << Indent(d + 2) << "type = \"" << si.type << "\"\n"; }
- if (!si.user.empty()) { os << Indent(d + 2) << "user = \"" << si.user << "\"\n"; }
- if (!si.ports.empty()) {
- os << Indent(d + 2) << "ports = [";
- for (std::size_t i = 0; i < si.ports.size(); ++i) {
+ if (!pkg.services.empty()) {
+ for (auto& ns : pkg.services) {
+ if (ns.name != "main") {
+ os << Indent(d) << "service " << ns.name << " {\n";
+ } else {
+ os << Indent(d) << "service {\n";
+ }
+ if (!ns.exec.empty()) { os << Indent(d + 1) << "exec = \"" << ns.exec << "\"\n"; }
+ if (!ns.type.empty()) { os << Indent(d + 1) << "type = \"" << ns.type << "\"\n"; }
+ if (!ns.user.empty()) { os << Indent(d + 1) << "user = \"" << ns.user << "\"\n"; }
+ if (!ns.ports.empty()) {
+ os << Indent(d + 1) << "ports = [";
+ for (std::size_t i = 0; i < ns.ports.size(); ++i) {
if (i > 0) { os << ", "; }
- os << si.ports[i];
+ os << ns.ports[i];
}
os << "]\n";
}
- for (auto& [k, v] : si.env) {
- os << Indent(d + 2) << k << " = \"" << v << "\"\n";
+ if (!ns.description.empty()) { os << Indent(d + 1) << "description = \"" << ns.description << "\"\n"; }
+ if (!ns.after.empty()) { os << Indent(d + 1) << "after = \"" << ns.after << "\"\n"; }
+ if (!ns.restart.empty()) { os << Indent(d + 1) << "restart = \"" << ns.restart << "\"\n"; }
+ if (!ns.working_dir.empty()) { os << Indent(d + 1) << "working_dir = \"" << ns.working_dir << "\"\n"; }
+ for (auto& [k, v] : ns.env) {
+ os << Indent(d + 1) << k << " = \"" << v << "\"\n";
}
- os << Indent(d + 1) << "}\n";
+ os << Indent(d) << "}\n";
}
- os << Indent(d) << "}\n";
}
if (!pkg.assertions.empty()) {
@@ -189,6 +208,7 @@ void format_package(std::ostream& os, const dsl::PackageDef& pkg) {
write_phase(os, d, "build", pkg.build);
write_phase(os, d, "check", pkg.check);
write_phase(os, d, "install", pkg.install);
+ write_phase(os, d, "uninstall", pkg.uninstall);
os << "}\n";
}
@@ -203,13 +223,20 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) {
os << "]\n\n";
}
+ if (!cfg.remotes.empty()) {
+ os << "remotes = [\n";
+ for (auto& r : cfg.remotes) {
+ os << " \"" << r << "\",\n";
+ }
+ os << "]\n\n";
+ }
+
if (!cfg.assertions.empty()) {
os << "assert {\n";
for (auto& a : cfg.assertions) {
os << " \"" << a.message << "\" : "
- << a.field << " " << a.op;
- if (!a.value.empty()) { os << " \"" << a.value << '"'; }
- os << "\n";
+ << a.field << " " << a.op
+ << " \"" << a.value << '"' << "\n";
}
os << "}\n\n";
}
@@ -222,8 +249,11 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) {
write_env(os, 1, s.env);
if (!s.config.empty()) {
os << " config {\n";
- for (auto& [k, v] : s.config) {
- os << " " << k << " = \"" << v << "\"\n";
+ std::vector cfg_keys;
+ for (auto& [k, _] : s.config) cfg_keys.push_back(k);
+ std::sort(cfg_keys.begin(), cfg_keys.end());
+ for (auto& k : cfg_keys) {
+ os << " " << k << " = \"" << s.config.at(k) << "\"\n";
}
os << " }\n";
}
@@ -245,8 +275,11 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) {
write_features(os, 2, p.features);
if (!p.config.empty()) {
os << " config {\n";
- for (auto& [k, v] : p.config) {
- os << " " << k << " = " << v << "\n";
+ std::vector pcfg_keys;
+ for (auto& [k, _] : p.config) pcfg_keys.push_back(k);
+ std::sort(pcfg_keys.begin(), pcfg_keys.end());
+ for (auto& k : pcfg_keys) {
+ os << " " << k << " = " << p.config.at(k) << "\n";
}
os << " }\n";
}
@@ -268,6 +301,19 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) {
os << "}\n\n";
}
+ if (!cfg.groups.empty()) {
+ os << "groups {\n";
+ for (auto& g : cfg.groups) {
+ os << " " << g.name;
+ if (g.gid < 0) {
+ os << " {}\n";
+ } else {
+ os << " {\n gid = " << g.gid << "\n }\n";
+ }
+ }
+ os << "}\n\n";
+ }
+
write_boot_block(os, 0, cfg.boot);
os << "\n";
diff --git a/src/util.cpp b/src/util.cpp
new file mode 100644
index 0000000..c0b6ce3
--- /dev/null
+++ b/src/util.cpp
@@ -0,0 +1,26 @@
+#include "kappa/util.hpp"
+#include
+#include
+#include
+
+namespace kappa::util {
+
+std::string to_lower(std::string_view sv) {
+ std::string s(sv);
+ std::ranges::transform(s, s.begin(),
+ [](unsigned char c) { return std::tolower(c); });
+ return s;
+}
+
+std::string shell_escape(std::string_view s) {
+ std::string result;
+ result.reserve(s.size());
+ for (char c : s) {
+ if (c == '"' || c == '\\' || c == '$' || c == '`') {
+ result += '\\';
+ }
+ result += c;
+ }
+ return result;
+}
+}
diff --git a/test-init-switch.sh b/test-init-switch.sh
new file mode 100755
index 0000000..7a32951
--- /dev/null
+++ b/test-init-switch.sh
@@ -0,0 +1,350 @@
+#!/bin/bash
+# kappa init-switching integration test via systemd-nspawn
+# Requires: systemd-nspawn, debootstrap (or pacstrap), kappa binary
+set -euo pipefail
+
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+NC='\033[0m'
+
+KAPPA_BIN="${KAPPA_BIN:-./build/kappa}"
+ROOTFS="./test-rootfs"
+PASS=0
+FAIL=0
+
+cleanup() {
+ echo ""
+ echo "Cleaning up..."
+ rm -rf "$ROOTFS" /tmp/kappa-test-*.kap 2>/dev/null || true
+}
+
+trap cleanup EXIT
+
+check() {
+ local desc="$1" cmd="$2" expect="$3"
+ echo -n " $desc ... "
+ local out
+ out=$(eval "$cmd" 2>&1) || true
+ if echo "$out" | grep -q "$expect"; then
+ echo -e "${GREEN}OK${NC}"
+ PASS=$((PASS + 1))
+ else
+ echo -e "${RED}FAIL${NC}"
+ echo " expected: $expect"
+ echo " got: $(echo "$out" | head -3 | tr '\n' ' ')"
+ FAIL=$((FAIL + 1))
+ fi
+}
+
+echo ""
+echo "============================================"
+echo " kappa init-switching integration tests"
+echo "============================================"
+echo ""
+
+# ------------------------------------------------------------------
+# 1. Create a minimal rootfs directory structure
+# ------------------------------------------------------------------
+echo "--- Setting up test rootfs ---"
+
+mkdir -p "$ROOTFS"/{etc,usr/local/bin,kappa/{boot,db,store,temp},var/service}
+cp "$KAPPA_BIN" "$ROOTFS/usr/local/bin/kappa"
+chmod +x "$ROOTFS/usr/local/bin/kappa"
+
+export KAPPA_ROOT="$ROOTFS/kappa"
+
+# Create a minimal package registry (simulating installed packages)
+mkdir -p "$KAPPA_ROOT/db"
+
+# Simulate an installed system with nginx (has services), zlib (no services),
+# and postgresql (multi-service, uses ${enabledinit})
+cat > /tmp/kappa-test-nginx.kap << 'EOF'
+package "nginx" {
+ version = "1.24"
+ source = "https://nginx.org/nginx-1.24.tar.gz"
+ service {
+ exec = "/usr/bin/nginx"
+ type = "forking"
+ ports = [80, 443]
+ user = "www-data"
+ description = "Nginx web server"
+ }
+ build { ./configure --prefix=${prefix}; make -j${jobs} }
+ install { make DESTDIR=${destdir} install }
+}
+EOF
+
+cat > /tmp/kappa-test-zlib.kap << 'EOF'
+package "zlib" {
+ version = "1.3"
+ source = "https://zlib.net/zlib-1.3.tar.gz"
+ build { ./configure --prefix=${prefix}; make -j${jobs} }
+ install { make DESTDIR=${destdir} install }
+}
+EOF
+
+cat > /tmp/kappa-test-postgres.kap << 'EOF'
+package "postgresql" {
+ version = "16.3"
+ source = "https://ftp.postgresql.org/source/postgresql-16.3.tar.gz"
+ service main {
+ exec = "/usr/bin/postgres"
+ type = "forking"
+ ports = [5432]
+ user = "postgres"
+ description = "PostgreSQL database server"
+ }
+ service checkpointer {
+ exec = "/usr/bin/postgres-checkpointer"
+ type = "longrun"
+ user = "postgres"
+ }
+ build {
+ case ${enabledinit} in
+ systemd) ./configure --with-systemd --prefix=${prefix} ;;
+ *) ./configure --prefix=${prefix} ;;
+ esac
+ make -j${jobs}
+ }
+ install { make DESTDIR=${destdir} install }
+}
+EOF
+
+# Write installed DB (simulate nginx, zlib, postgresql already installed)
+# Format: name version hash [provides...]
+mkdir -p "$(dirname "$KAPPA_ROOT/db/installed")"
+cat > "$KAPPA_ROOT/db/installed" << 'EOF'
+nginx 1.24 a1b2c3d4e5f6a7b8
+zlib 1.3 b2c3d4e5f6a7b8c9
+postgresql 16.3 c3d4e5f6a7b8c9d0
+init a1b2c3d4e5f6a7b8
+kernel d4e5f6a7b8c9d0e1
+bootloader e5f6a7b8c9d0e1f2
+EOF
+
+echo ""
+
+# ------------------------------------------------------------------
+# 2. Init system switching tests
+# ------------------------------------------------------------------
+echo "--- Init system switching ---"
+
+# Base config template
+config_template() {
+ local init="$1"
+ cat << EOF
+system { hostname = "kappa-test" }
+packages {
+ nginx {}
+ zlib {}
+ postgresql {}
+ $init {}
+}
+services {
+ nginx { enable = true }
+ postgresql { enable = true }
+ postgresql.checkpointer { enable = true }
+}
+boot {
+ kernel = "linux"
+ init = "$init"
+ root = "/dev/sda1"
+ bootloader = "limine"
+}
+groups {
+ wheel { gid = 998 }
+}
+users {
+ root { shell = "/bin/zsh" }
+}
+EOF
+}
+
+# Test systemd config
+config_template "systemd" > /tmp/kappa-test-systemd.kap
+check "parse systemd config" \
+ "$KAPPA_BIN parse-config /tmp/kappa-test-systemd.kap" \
+ 'systemd — system and service manager'
+
+check "systemd bootloader in config" \
+ "$KAPPA_BIN parse-config /tmp/kappa-test-systemd.kap" \
+ 'Limine'
+
+# Test openrc config
+config_template "openrc" > /tmp/kappa-test-openrc.kap
+check "parse openrc config" \
+ "$KAPPA_BIN parse-config /tmp/kappa-test-openrc.kap" \
+ 'OpenRC — dependency-based init'
+
+# Test s6 config
+config_template "s6" > /tmp/kappa-test-s6.kap
+check "parse s6 config" \
+ "$KAPPA_BIN parse-config /tmp/kappa-test-s6.kap" \
+ 's6 — s6 supervision suite'
+
+# Test runit config
+config_template "runit" > /tmp/kappa-test-runit.kap
+check "parse runit config" \
+ "$KAPPA_BIN parse-config /tmp/kappa-test-runit.kap" \
+ 'runit — supervision suite'
+
+# Test dinit config
+config_template "dinit" > /tmp/kappa-test-dinit.kap
+check "parse dinit config" \
+ "$KAPPA_BIN parse-config /tmp/kappa-test-dinit.kap" \
+ 'dinit — service manager'
+
+echo ""
+
+# ------------------------------------------------------------------
+# 3. Service generation verification
+# ------------------------------------------------------------------
+echo "--- Service file generation ---"
+
+# Verify service specs are parsed correctly per init
+for init in systemd openrc s6 runit dinit; do
+ check "service block present for $init config" \
+ "$KAPPA_BIN format /tmp/kappa-test-$init.kap" \
+ 'enable = true'
+done
+
+# Verify the format output contains correct service fields
+check "nginx service has type forking" \
+ "$KAPPA_BIN format /tmp/kappa-test-nginx.kap" \
+ 'type = "forking"'
+
+check "postgresql has named service checkpointer" \
+ "$KAPPA_BIN format /tmp/kappa-test-postgres.kap" \
+ 'service checkpointer'
+
+check "postgresql build uses enabledinit" \
+ "$KAPPA_BIN format /tmp/kappa-test-postgres.kap" \
+ 'enabledinit'
+
+echo ""
+
+# ------------------------------------------------------------------
+# 4. Rebuild impact analysis
+# ------------------------------------------------------------------
+echo "--- Rebuild impact analysis ---"
+
+# Write an initial installed state matching s6 config
+# (reuse the same installed DB from above)
+
+config_template "s6" > /tmp/kappa-test-rebuild-s6.kap
+# The packages list includes "s6" which isn't in the installed DB,
+# so rebuild correctly shows it as a new package to build.
+check "rebuild detects new init package" \
+ "$KAPPA_BIN rebuild /tmp/kappa-test-rebuild-s6.kap 2>&1" \
+ 'packages to rebuild'
+
+# Now test switching FROM s6 TO systemd
+config_template "systemd" > /tmp/kappa-test-rebuild-systemd.kap
+
+# The rebuild should detect init_changed
+# Note: this requires the installed DB to have the old init hash
+check "rebuild detects init change" \
+ "$KAPPA_BIN rebuild /tmp/kappa-test-rebuild-systemd.kap 2>&1" \
+ 'packages to rebuild'
+
+echo ""
+
+# ------------------------------------------------------------------
+# 5. Bootloader switching tests
+# ------------------------------------------------------------------
+echo "--- Bootloader switching ---"
+
+for bl in grub limine; do
+ cat > "/tmp/kappa-test-bl-$bl.kap" << KAPEOF
+system { hostname = "test" }
+packages { nginx {} }
+services {}
+boot {
+ kernel = "linux"
+ init = "s6"
+ root = "/dev/sda1"
+ bootloader = "$bl"
+}
+users { root { shell = "/bin/sh" } }
+KAPEOF
+ check "bootloader $bl recognized in config" \
+ "$KAPPA_BIN parse-config /tmp/kappa-test-bl-$bl.kap" \
+ "$bl"
+done
+
+# Test unknown bootloader
+cat > /tmp/kappa-test-badbl.kap << 'KAPEOF'
+system { hostname = "test" }
+packages {}
+services {}
+boot {
+ kernel = "linux"
+ init = "s6"
+ root = "/dev/sda1"
+ bootloader = "lilo"
+}
+users { root { shell = "/bin/sh" } }
+KAPEOF
+check "doctor warns on unknown bootloader" \
+ "$KAPPA_BIN doctor /tmp/kappa-test-badbl.kap 2>&1" \
+ 'not a recognized bootloader'
+
+echo ""
+
+# ------------------------------------------------------------------
+# 6. Groups and conflicts
+# ------------------------------------------------------------------
+echo "--- Groups and conflicts ---"
+
+cat > /tmp/kappa-test-groups2.kap << 'KAPEOF'
+system { hostname = "test" }
+packages {}
+services {}
+boot {
+ kernel = "linux"
+ init = "s6"
+ root = "/dev/sda1"
+ bootloader = "limine"
+}
+groups {
+ wheel { gid = 998 }
+ audio {}
+ docker { gid = 995 }
+}
+users { root { shell = "/bin/sh" } }
+KAPEOF
+
+check "groups with gid format correctly" \
+ "$KAPPA_BIN format /tmp/kappa-test-groups2.kap" \
+ 'gid = 998'
+
+check "groups without gid format as empty" \
+ "$KAPPA_BIN format /tmp/kappa-test-groups2.kap" \
+ 'audio {}'
+
+# Conflicts test
+cat > /tmp/kappa-test-conflict-pkg.kap << 'KAPEOF'
+package "systemd" {
+ version = "255"
+ source = "https://example.com/systemd.tar.gz"
+ provides = ["udev", "logind"]
+ conflicts = ["eudev", "elogind"]
+ build { make }
+ install { make install }
+}
+KAPEOF
+
+check "conflicts parse correctly" \
+ "$KAPPA_BIN parse-package /tmp/kappa-test-conflict-pkg.kap" \
+ 'valid'
+
+check "conflicts format round-trips" \
+ "$KAPPA_BIN format /tmp/kappa-test-conflict-pkg.kap" \
+ 'conflicts = \['
+
+echo ""
+echo "============================================"
+echo -e " Results: ${GREEN}$PASS passed${NC}, ${RED}$FAIL failed${NC}"
+echo "============================================"
+
+[ "$FAIL" -eq 0 ] || exit 1
diff --git a/test.sh b/test.sh
new file mode 100755
index 0000000..a9be9c7
--- /dev/null
+++ b/test.sh
@@ -0,0 +1,230 @@
+#!/bin/sh
+# $KAPPA integration test suite
+# Run: docker run --rm -v $(pwd):/opt/$KAPPA kappa-test ./test.sh
+set -e
+
+KAPPA="${KAPPA_BIN:-./build/kappa}"
+PASS=0
+FAIL=0
+
+check() {
+ local desc="$1" cmd="$2" expect="$3"
+ local out
+ out=$(eval "$cmd" 2>&1) || true
+ if echo "$out" | grep -q "$expect"; then
+ echo " ✓ $desc"
+ PASS=$((PASS + 1))
+ else
+ echo " ✗ $desc"
+ echo " expected: $expect"
+ echo " got: $(echo "$out" | head -3)"
+ FAIL=$((FAIL + 1))
+ fi
+}
+
+echo ""
+echo "=== Package DSL tests ==="
+echo ""
+
+check "parse foo.kap" \
+ "$KAPPA parse-package examples/foo.kap" \
+ 'package "foo" 1.2.3 — valid'
+
+check "parse postgres.kap (multi-service)" \
+ "$KAPPA parse-package examples/postgres.kap" \
+ 'package "postgresql" 16.3 — valid'
+
+check "format foo.kap round-trips service block" \
+ "$KAPPA format examples/foo.kap" \
+ 'service {'
+
+check "format shows conflicts when present" \
+ "$KAPPA format /tmp/test_conflict.kap" \
+ 'conflicts ='
+
+check "format postgres.kap shows named services" \
+ "$KAPPA format examples/postgres.kap" \
+ 'service checkpointer {'
+
+echo ""
+echo "=== Config DSL tests ==="
+echo ""
+
+check "parse config.kap" \
+ "$KAPPA parse-config examples/config.kap" \
+ 'system config — valid'
+
+check "config.kap shows init description" \
+ "$KAPPA parse-config examples/config.kap" \
+ 's6 — s6 supervision suite'
+
+check "config.kap shows bootloader description" \
+ "$KAPPA parse-config examples/config.kap" \
+ 'Limine — modern multiprotocol bootloader'
+
+echo ""
+echo "=== Doctor tests ==="
+echo ""
+
+check "doctor foo.kap" \
+ "$KAPPA doctor examples/foo.kap" \
+ 'no issues found'
+
+echo ""
+echo "=== Validate tests ==="
+echo ""
+
+check "validate foo.kap" \
+ "$KAPPA validate examples/foo.kap" \
+ 'valid package'
+
+echo ""
+echo "=== Rebuild tests ==="
+echo ""
+
+check "rebuild detects new packages" \
+ "$KAPPA rebuild examples/config.kap" \
+ 'packages to rebuild'
+
+echo ""
+echo "=== Init system recognition tests ==="
+echo ""
+
+# Create temp config with each init system
+for init in systemd openrc s6 runit dinit; do
+ cat > /tmp/test_init.kap << KAPEOF
+system { hostname = "test" }
+packages {}
+services {}
+boot {
+ kernel = "linux"
+ init = "$init"
+ root = "/dev/sda1"
+ bootloader = "limine"
+}
+users { root { shell = "/bin/sh" } }
+KAPEOF
+ check "recognizes init=$init" \
+ "$KAPPA parse-config /tmp/test_init.kap" \
+ "$init"
+
+ check "doctor accepts init=$init" \
+ "$KAPPA doctor /tmp/test_init.kap 2>&1" \
+ ""
+done
+
+# Test unknown init
+cat > /tmp/test_badinit.kap << 'KAPEOF'
+system { hostname = "test" }
+packages {}
+services {}
+boot {
+ kernel = "linux"
+ init = "fakething"
+ root = "/dev/sda1"
+ bootloader = "limine"
+}
+users { root { shell = "/bin/sh" } }
+KAPEOF
+check "warns on unknown init" \
+ "$KAPPA doctor /tmp/test_badinit.kap 2>&1" \
+ 'not a recognized init system'
+
+echo ""
+echo "=== Bootloader recognition tests ==="
+echo ""
+
+for bl in grub limine; do
+ cat > /tmp/test_bl.kap << KAPEOF
+system { hostname = "test" }
+packages {}
+services {}
+boot {
+ kernel = "linux"
+ init = "s6"
+ root = "/dev/sda1"
+ bootloader = "$bl"
+}
+users { root { shell = "/bin/sh" } }
+KAPEOF
+ check "recognizes bootloader=$bl" \
+ "$KAPPA parse-config /tmp/test_bl.kap" \
+ "$bl"
+done
+
+echo ""
+echo "=== Service generation tests ==="
+echo ""
+
+# Test that format output contains service fields for each backend type hint
+check "service format includes type field" \
+ "$KAPPA format examples/foo.kap" \
+ 'type = "forking"'
+
+check "service format includes ports" \
+ "$KAPPA format examples/foo.kap" \
+ 'ports = \['
+
+check "service format includes description" \
+ "$KAPPA format examples/foo.kap" \
+ 'description = "Foo web server"'
+
+echo ""
+echo "=== Groups DSL tests ==="
+echo ""
+
+cat > /tmp/test_groups.kap << 'KAPEOF'
+system { hostname = "test" }
+packages {}
+services {}
+boot {
+ kernel = "linux"
+ init = "s6"
+ root = "/dev/sda1"
+ bootloader = "limine"
+}
+groups {
+ wheel { gid = 998 }
+ audio {}
+ docker { gid = 995 }
+}
+users { root { shell = "/bin/sh" } }
+KAPEOF
+
+check "parse groups block" \
+ "$KAPPA parse-config /tmp/test_groups.kap" \
+ 'valid'
+
+check "format groups round-trips" \
+ "$KAPPA format /tmp/test_groups.kap" \
+ 'wheel {'
+
+echo ""
+echo "=== Conflicts tests ==="
+echo ""
+
+cat > /tmp/test_conflict.kap << 'KAPEOF'
+package "systemd" {
+ version = "255"
+ source = "https://example.com/systemd-255.tar.gz"
+ provides = ["udev", "logind"]
+ conflicts = ["eudev", "elogind"]
+ build { make }
+ install { make install }
+}
+KAPEOF
+
+check "parse conflicts in package def" \
+ "$KAPPA parse-package /tmp/test_conflict.kap" \
+ 'valid'
+
+check "format shows conflicts" \
+ "$KAPPA format /tmp/test_conflict.kap" \
+ 'conflicts ='
+
+echo ""
+echo "=========================================="
+echo " Results: $PASS passed, $FAIL failed"
+echo "=========================================="
+
+[ "$FAIL" -eq 0 ] || exit 1