Compare commits

22 Commits
Author SHA1 Message Date
huntedbytheirs 5607be0f1c docs: update README and CONTRIBUTING for v0.2 features
CI / build-and-test (push) Successful in 37s
README:
- Quick Start shows kappa add workflow instead of manual configs
- New features: indexed repos, env operators (+=, ?=), conflict reporting
- Updated subcommand table (add, remove, index, validate-index)
- Repo maintenance section (kappa index)
- Link to STYLEGUIDE.md, test counts, CI info

CONTRIBUTING:
- Updated test counts (30+23+85 instead of 31+23)
- Added test-full.sh to mandatory test suite
- Referenced STYLEGUIDE.md in PR requirements
- Updated good-first/ambitious issues for current state
2026-08-04 14:40:24 -04:00
huntedbytheirs fa1aa42a9c fix: remove duplicate conflicts test referencing uncreated file
CI / build-and-test (push) Successful in 43s
The 'format shows conflicts' test in the Package DSL section ran
before /tmp/test_conflict.kap was created (it's created later in the
Conflicts section). Removed the premature copy — the Conflicts
section already has this test after file creation.
2026-08-04 14:37:16 -04:00
huntedbytheirs 76b5a78e75 ci: use manual git checkout in Arch container
CI / build-and-test (push) Failing after 39s
actions/checkout requires Node.js, which isn't in archlinux:latest.
Manual git clone + checkout FETCH_HEAD instead. Adds git to pacman.
2026-08-04 14:34:45 -04:00
huntedbytheirs e3d41dcd20 ci: switch to Arch Linux container for modern toolchain
CI / build-and-test (push) Failing after 12s
Debian 11's libstdc++ (GCC 10) lacks <format> even with clang-18.
Arch ships clang-18 and the latest libstdc++ out of the box.
Container-based runner with pacman for dead-simple dependency install.
2026-08-04 14:32:58 -04:00
huntedbytheirs ac530e94a7 fix: install software-properties-common for llvm.sh on Debian
CI / build-and-test (push) Failing after 1m13s
Debian 11 (bullseye) doesn't have add-apt-repository by default.
llvm.sh needs it to add the LLVM apt repo.
2026-08-04 14:28:33 -04:00
huntedbytheirs 71d2eadcb9 fix: force clang-18 via LLVM script, prefer highest version
CI / build-and-test (push) Failing after 19s
<llvm.sh 18 all> installs clang-18 with full C++23 support.
sort -V ensures the highest clang version is selected when
multiple are installed. Removes -stdlib=libc++ (clang-18
provides <format> via its own header, no flag needed).
2026-08-04 14:27:13 -04:00
huntedbytheirs dc782e941c fix: install libc++ alongside clang for <format> support
CI / build-and-test (push) Failing after 39s
Clang 16 on Ubuntu 22.04 uses libstdc++ 12 which lacks <format>.
Install libc++-dev and pass -stdlib=libc++ to CMake.
2026-08-04 14:25:41 -04:00
huntedbytheirs 3302712336 fix: correct C++ compiler naming in CI (clang++-16 not clang-16++)
CI / build-and-test (push) Failing after 34s
Clang installs the C++ compiler as clang++-16, not clang-16++.
Use sed to properly transform the C compiler path.
2026-08-04 14:24:09 -04:00
huntedbytheirs 4a8b87e2cd fix: use find instead of ls for clang detection in CI
CI / build-and-test (push) Failing after 33s
ls returns exit code 2 when glob has no matches, which fails the
build step under set -e + pipefail. find returns 0 even when no
files are found.
2026-08-04 14:22:46 -04:00
huntedbytheirs 5668708fed fix: detect and install Clang 15-18 with LLVM fallback
CI / build-and-test (push) Failing after 30s
- Tries apt clang-18/17/16/15 first (Ubuntu 22.04+)
- Falls back to LLVM repo install for Ubuntu 20.04
- Auto-detects installed clang version for CMake compiler flags
- Both CMake 3.28 (binary download) and C++23 compiler required
2026-08-04 14:21:10 -04:00
huntedbytheirs 07a485a535 fix: install CMake 3.28 in CI (runner has 3.18, we need 3.20+)
CI / build-and-test (push) Failing after 26s
Download official CMake binary from Kitware releases instead of
relying on the distro package manager which provides an older version.
2026-08-04 14:17:52 -04:00
huntedbytheirs d10679d267 test: add 85 comprehensive tests and Gitea CI/CD workflow
CI / build-and-test (push) Failing after 56s
- test-full.sh: 85 tests covering CLI, DSL parsing (all keywords),
  config parsing, users/groups, assertions, imports, doctor diagnostics,
  repos/remotes, index generation, add/remove operations, install DB,
  resolver (conflicts + missing), scheduler (deptree ordering),
  build execution + error handling, env operators,
  init systems, bootloaders, variable interpolation, --root override,
  rollback config, system env, feature flags, rebuild dry-run

- .gitea/workflows/ci.yml: builds on Ubuntu with clang+cmake+ninja,
  runs all three test suites on push/PR to main

Total test count: 31 (DSL) + 23 (init-switch) + 85 (comprehensive) = 139
2026-08-04 14:15:50 -04:00
huntedbytheirs 09dde98159 fix: report package conflicts in resolve and rebuild handlers
Conflicts were detected in the resolver but silently ignored in both
the resolve and rebuild subcommands. Both now print conflict errors
and abort before any builds start.
2026-08-04 14:05:16 -04:00
huntedbytheirs 7345b026cc feat: add kappa add and kappa remove subcommands
- kappa add <pkg> [version] — adds/updates package in system config
  packages{} block; creates config if it doesn't exist

- kappa remove <pkg> — removes package from config; idempotent
  (no error if package isn't present)

- Supports --root flag for custom KAPPA_ROOT

- User workflow: kappa add make && kappa rebuild
  No manual config editing needed.
2026-08-04 14:00:51 -04:00
huntedbytheirs 7b0538c392 feat: add index-aware recipe resolution with repo support
- fetch_recipe_from_repos() iterates repos by priority, channels, with
  mirror fallback — caches indexes at /cache/indexes/

- Conditional HTTP downloads (curl -z) to only re-fetch stale indexes

- build_registry and fetch-package prefer repos { } over remotes = [...]
  when both are present

- Fallback chain: local .kap → repos (index-aware) → remotes (legacy)
2026-08-04 13:50:29 -04:00
huntedbytheirs 4f1ce17ec8 feat: add repos DSL, index format, and kappa index command
- Add repos { } block to system config with named repos, channels,
  mirrors, and priority

- Add index "name" { } file format for package indexes

- Add kappa index <dir> subcommand that scans .kap files and
  generates index.kap

- parse_index() and build_index() in DSL module

- format_index() roundtrip support in tools module

- validate and format subcommands handle all three formats
  (package, config, index) with automatic detection

- Backward compatible: remotes = [...] still works unchanged
2026-08-04 13:48:25 -04:00
huntedbytheirs 86f578bd4a feat: add GNU Make 4.4.1 package definition
Full autotools build (./configure, make, make check, make install)
verified end-to-end — builds, installs, produces working binary.
2026-08-04 13:29:31 -04:00
huntedbytheirs d02ab4b7b6 docs: update examples with += env operator and env usage docs
- foo.kap: document all three env operators (=, ?=, +=) in header,
  demonstrate += in env block with comments

- postgres.kap: demonstrate += for appending security hardening flags

- config.kap: demonstrate system-level += for inherited flags
2026-08-04 13:19:59 -04:00
huntedbytheirs e817e58698 feat: implement build backend and += env operator, add style guide
- Replace build stub with full phase execution (prepare/build/check/install)
  via /bin/sh with variable interpolation, config file generation, and
  env handling (hard-set, soft/?=, append/+=)

- Add += append operator for environment variables across DSL, parser,
  system config parser, build backend, and formatter

- Add STYLEGUIDE.md documenting all codebase conventions

- Replace EnvEntry bool soft with EnvMode enum (Set/Soft/Append)

- Add Plus token type to lexer for += parsing
2026-08-04 12:58:38 -04:00
huntedbytheirs 0aca4b49ca qol: implement easier building and distribution 2026-08-04 12:19:52 -04:00
huntedbytheirs 486a476b90 feat: wire rebuild pipeline (scheduler \u2192 build \u2192 install \u2192 services \u2192 bootloader)
The rebuild subcommand now executes the full pipeline:
- build_registry() -> resolve() -> sched::run()
- install::install() for each built package
- service::install_service() for enabled services
- boot::install_bootloader_config() for bootloader config
- system::write_hostname() / write_timezone() for system activation

Added --dry-run flag for report-only behavior (backward compat with
test suite). Added -w flag for worker parallelism and --dry-run to help.
Fixed .gitignore: 'kappa' -> '/kappa' (was matching include/kappa/,
preventing header files from being tracked since initial commit).
This commit also adds all previously-gitignored header files.
2026-07-31 08:38:53 -04:00
huntedbytheirs 7e93db2d07 feat: portability, correctness, and quality improvements for v0.2
Portability (Linux distro-agnostic):
- Remove hardcoded Clang compiler enforcement; GCC now builds
- Add find_package(Threads REQUIRED) for older glibc
- Add cmake install() target
- FHS 3.0 default root: /kappa -> /usr/local/kappa
- fs::path operator/ for all init/bootloader paths (fixes prefix fragility)
- Multi-distro zoneinfo search (FHS, NixOS, Guix, alt)
- Portable tar extraction (drop GNU-only --no-same-permissions)
- Runit enable/disable commands now prefix-aware
- --root CLI flag before/after subcommand, lazy directory creation
- Shebang constants de-duplicated to types.hpp

Correctness (race conditions, UB, corruption):
- Fix CWD race in scheduler: per-child chdir() instead of process-global
- Fix UB const_cast in exec_cmd/exec_capture: mutable argv buffers
- Fix non-atomic installed DB writes: tmp+rename pattern
- Fix read_file() no longer calls exit(1), throws instead
- Fix silent catch(...) parse errors now print diagnostics
- Fix rebuild false positives with config_hash change detection
- Fix s6 disable_cmd copy-paste bug (was identical to enable)
- Fix runit enable_cmd incomplete, disable_cmd wrong target
- Fix dinit env vars: functional env-file + companion .env

Quality:
- Add -Wall -Wextra -Wpedantic to CMake, fix 2 pre-existing warnings
- Move parse_int from error.hpp to parse_util.hpp
- Fix hash verification guard checks all three hash types
- Check patch return code in fetch.cpp
- Add explicit system_dir creation in ensure_directories()
- Add resolve to needs_dirs for build_registry() consistency
- Update stale /kappa path references in examples
- Remove inaccurate -Werror claim in CONTRIBUTING.md
- Add build-gcc/ and agent dirs to .gitignore
- Suppress clang-tidy portability-avoid-pragma-once
- Fix .gitignore /kappa pattern (was matching include/kappa/)
- Delete stale vcpkg_installed/ directory

54/54 tests pass. Builds on Clang and GCC with 0 warnings.
2026-07-31 08:28:49 -04:00
59 changed files with 3643 additions and 249 deletions
+1
View File
@@ -6,6 +6,7 @@ Checks: >
portability-*, portability-*,
readability-*, readability-*,
-modernize-use-trailing-return-type, -modernize-use-trailing-return-type,
-portability-avoid-pragma-once,
-readability-identifier-length, -readability-identifier-length,
-readability-magic-numbers, -readability-magic-numbers,
-readability-identifier-naming -readability-identifier-naming
+39
View File
@@ -0,0 +1,39 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build-and-test:
runs-on: ubuntu-latest
container: archlinux:latest
steps:
- name: Install dependencies
run: |
pacman -Syu --noconfirm
pacman -S --noconfirm clang cmake ninja git
- name: Checkout
run: |
git clone --depth 1 "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" .
git fetch origin "$GITHUB_REF"
git checkout FETCH_HEAD
- name: Build kappa
run: |
cmake -B build -G Ninja \
-DCMAKE_C_COMPILER=clang \
-DCMAKE_CXX_COMPILER=clang++
cmake --build build
- name: DSL and CLI tests
run: ./test.sh
- name: Init-switching tests
run: ./test-init-switch.sh
- name: Comprehensive tests
run: ./test-full.sh
+11 -2
View File
@@ -33,8 +33,17 @@
*.app *.app
# Build # Build
build/ /build/
/build-debug/
/build-release/
/dist/
build-gcc/
compile_commands.json compile_commands.json
vcpkg_installed/ vcpkg_installed/
kappa /kappa
# Agent session state
.omo/
.logs/
.opencode/
+6 -4
View File
@@ -1,11 +1,9 @@
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
# Enforce Clang
set(CMAKE_C_COMPILER clang)
set(CMAKE_CXX_COMPILER clang++)
project(kappa VERSION 0.1.0 LANGUAGES CXX) project(kappa VERSION 0.1.0 LANGUAGES CXX)
find_package(Threads REQUIRED)
set(CMAKE_CXX_STANDARD 23) set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_EXTENSIONS OFF)
@@ -53,3 +51,7 @@ add_executable(kappa
src/system/activate.cpp src/system/activate.cpp
) )
target_include_directories(kappa PRIVATE include) target_include_directories(kappa PRIVATE include)
target_link_libraries(kappa PRIVATE Threads::Threads)
target_compile_options(kappa PRIVATE -Wall -Wextra -Wpedantic)
install(TARGETS kappa RUNTIME DESTINATION bin)
+21 -15
View File
@@ -10,7 +10,7 @@ These aren't guidelines. They're the deal.
### 1. C++23 or don't bother ### 1. C++23 or don't bother
We compile with Clang, `-std=c++23`, and `-Werror`. If your code needs a We compile with Clang, `-std=c++23`, and zero warnings. If your code needs a
polyfill for `std::format` or can't handle designated initializers, it polyfill for `std::format` or can't handle designated initializers, it
doesn't belong here. The standard library is your only dependency. Zero doesn't belong here. The standard library is your only dependency. Zero
external C++ libraries. Not even Boost. external C++ libraries. Not even Boost.
@@ -63,17 +63,20 @@ 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` correctly or don't use them at all. If you don't know what `memory_order`
means, stay out of the scheduler. means, stay out of the scheduler.
### 6. Tests are shell scripts. For now. ### 6. Tests are shell scripts.
Integration tests live in `test.sh` and `test-init-switch.sh`. They Integration tests live in three suites. If you add a subcommand, add a test.
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 ```sh
./test.sh # 31 tests, must pass ./test.sh # 30 tests — DSL parsing, config, services, init systems
./test-init-switch.sh # 23 tests, must pass ./test-init-switch.sh # 23 tests — init-switching, rebuild impact, bootloaders
./test-full.sh # 85 tests — CLI, all keywords, repos, add/remove,
# resolver conflicts, deptrees, build, env ops,
# index generation, --root, imports, doctor, and more
``` ```
All three must pass. CI enforces this on every push to `main`.
### 7. Backward compatibility is mandatory ### 7. Backward compatibility is mandatory
The `.kap` DSL is the contract. You can add keywords. You cannot remove The `.kap` DSL is the contract. You can add keywords. You cannot remove
@@ -85,29 +88,32 @@ change means someone's `config.kap` stops parsing, it doesn't ship.
### Pick something ### Pick something
Good first issues: Good first issues:
- Adding a 6th init system backend - Adding a 6th init system backend (we have 5: systemd, openrc, s6, runit, dinit)
- Adding a 3rd bootloader backend - Adding a 3rd bootloader backend (we have 2: grub, limine)
- C++ unit test framework setup - Improving the `kappa doctor` diagnostics for package recipes
- Shell completion scripts - Adding `--features` / `--config` flags to `kappa add`
- Shell completion scripts (bash, zsh, fish)
Ambitious issues: Ambitious issues:
- Binary package support (pre-built caches) - Binary package support (pre-built caches)
- Remote build farm (distcc-style) - Remote build farm (distcc-style)
- Signed package verification - Signed package verification with index signing
- Filesystem overlay activation (like Nix profiles) - Filesystem overlay activation (like Nix profiles)
- Transitive dependency resolution (auto-including deps not in config)
### Send a PR ### Send a PR
1. Fork the repo 1. Fork the repo
2. Create a branch: `feat/my-thing` or `fix/my-bug` 2. Create a branch: `feat/my-thing` or `fix/my-bug`
3. Write code that follows the rules above 3. Write code that follows the rules above
4. Run `./test.sh && ./test-init-switch.sh` — both must pass 4. Run `./test.sh && ./test-init-switch.sh && ./test-full.sh` — all three must pass
5. Open a PR against `main` 5. Open a PR against `main`
### PR requirements ### PR requirements
- Build must pass: `cmake --build build` with zero warnings - Build must pass: `cmake --build build` with zero warnings on Clang 17+
- Tests must pass: both shell test suites - Tests must pass: all three shell test suites
- Follow the conventions in [STYLEGUIDE.md](STYLEGUIDE.md)
- No commented-out code. No dead code. No TODO without a date. - No commented-out code. No dead code. No TODO without a date.
- Commit messages in imperative: `Add runit backend` not `Added runit backend` - Commit messages in imperative: `Add runit backend` not `Added runit backend`
+55
View File
@@ -0,0 +1,55 @@
# kappa — build convenience Makefile
#
# Targets:
# build — default build (build/)
# debug — Debug build (build-debug/)
# release - Release build (build-release/)
# dist - tar.zst tarball of the release build (requires zstd)
# dist-clean - remove everything under dist/
#
# Overridable: make release CXX=g++ GENERATOR="Unix Makefiles"
CC ?= clang
CXX ?= clang++
GENERATOR ?= Ninja
BUILD_DIR ?= build
DEBUG_DIR ?= build-debug
RELEASE_DIR ?= build-release
DIST_DIR ?= dist
GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)
GIT_TAG := $(shell git describe --tags --abbrev=0 2>/dev/null || echo 0.1.0)
STAMP := $(shell date +%Y-%M)
DIST_NAME := kappa-$(GIT_COMMIT)-$(GIT_TAG)-$(STAMP)
CMAKE := cmake
.PHONY: build debug release dist dist-clean
build:
$(CMAKE) -S . -B $(BUILD_DIR) -G $(GENERATOR) \
-DCMAKE_C_COMPILER=$(CC) -DCMAKE_CXX_COMPILER=$(CXX)
$(CMAKE) --build $(BUILD_DIR)
debug:
$(CMAKE) -S . -B $(DEBUG_DIR) -G $(GENERATOR) \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_C_COMPILER=$(CC) -DCMAKE_CXX_COMPILER=$(CXX)
$(CMAKE) --build $(DEBUG_DIR)
release:
$(CMAKE) -S . -B $(RELEASE_DIR) -G $(GENERATOR) \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_COMPILER=$(CC) -DCMAKE_CXX_COMPILER=$(CXX)
$(CMAKE) --build $(RELEASE_DIR)
dist: release
rm -rf $(DIST_DIR)/$(DIST_NAME)
$(CMAKE) --install $(RELEASE_DIR) --prefix $(DIST_DIR)/$(DIST_NAME)
tar --zstd -cf $(DIST_DIR)/$(DIST_NAME).tar.zst -C $(DIST_DIR) $(DIST_NAME)
rm -rf $(DIST_DIR)/$(DIST_NAME)
@echo "dist: $(DIST_DIR)/$(DIST_NAME).tar.zst"
dist-clean:
rm -rf $(DIST_DIR)
+79 -30
View File
@@ -72,15 +72,45 @@ kappa rebuild config.kap # boot.init = "openrc" — only 5 packages actually r
simultaneously, eight jobs each. The scheduler uses depth-based priority simultaneously, eight jobs each. The scheduler uses depth-based priority
grouping so leaf dependencies unblock as much work as possible first. grouping so leaf dependencies unblock as much work as possible first.
- **Package recipe caching.** Declare `remotes = ["https://repo.example.com/"]` - **Package recipe caching with indexed repos.** Declare named repos with
in your config. Kappa fetches `.kap` files on demand, caches them, and only channels and mirrors in your config. Kappa fetches `index.kap` from each
re-fetches when the remote version is newer. repo, caches it, and only re-fetches when the remote changes. Package recipes
are resolved from the index — fast, offline-tolerant, and mirror-aware.
- **Source tarball caching.** Downloaded once, stored at `/kappa/cache/`. ```kap
Rebuilds don't touch the network unless versions change. repos {
kappa-os {
url = "https://packages.kappa-os.org/"
channels = ["stable"]
mirrors = [
"https://cdn.kappa-os.org/",
"https://eu.kappa-os.org/",
]
}
}
```
`remotes = [...]` still works. Repos are tried first, then legacy remotes.
- **Source tarball caching.** Downloaded once, stored at `$KAPPA_ROOT/cache/`
(default: `/usr/local/kappa/cache/`). Rebuilds don't touch the network
unless versions change.
- **Env operators.** Three ways to set build environment variables:
`=` (hard set), `+=` (append with space), `?=` (soft set — only if not
already defined). System-level env propagates to all packages.
```kap
env {
CFLAGS = "-O2 -march=native" # overwrite
CFLAGS += "-pipe" # append → "-O2 -march=native -pipe"
CFLAGS ?= "-g" # soft — only if not set
}
```
- **Conflicts.** `systemd` declares `conflicts = ["eudev", "elogind"]`. The - **Conflicts.** `systemd` declares `conflicts = ["eudev", "elogind"]`. The
resolver catches mutual incompatibility before a build starts. resolver catches mutual incompatibility before a build starts — and now
actually reports it, rather than silently ignoring it.
- **Init-agnostic system config.** `groups { wheel { gid = 998 } }` — kappa - **Init-agnostic system config.** `groups { wheel { gid = 998 } }` — kappa
creates the groups. `system { hostname = "mybox" }` — kappa writes creates the groups. `system { hostname = "mybox" }` — kappa writes
@@ -103,46 +133,62 @@ kappa rebuild config.kap # boot.init = "openrc" — only 5 packages actually r
### Quick start ### Quick start
> **Note for 0.1.x users**: The default store root has moved from `/kappa` to
> `/usr/local/kappa` (FHS 3.0). Set `KAPPA_ROOT` to your existing `/kappa`
> directory to keep using the old location.
```sh ```sh
# Build kappa (needs Clang 17+, CMake 3.20+, C++23) # Build kappa (needs Clang 17+, CMake 3.20+, C++23)
cmake -B build -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ cmake -B build -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++
cmake --build build cmake --build build
# Write a config # Add packages to your system config
cat > system.kap << 'EOF' build/kappa add make
system { hostname = "kappa.local" } build/kappa add nginx ">=1.24"
packages { nginx {} } build/kappa add zlib
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 # Rebuild — kappa fetches recipes, resolves deps, builds everything
build/kappa parse-config system.kap build/kappa rebuild $KAPPA_ROOT/system/config.kap
# Rebuild # Remove packages
build/kappa rebuild system.kap build/kappa remove zlib
``` ```
No config files to write by hand. `kappa add` writes the `packages {}` block
for you. `kappa rebuild` handles the rest.
### Subcommands ### Subcommands
| Command | What it does | | Command | What it does |
|---------|-------------| |---------|-------------|
| `parse-package <file>` | Validate a `.kap` package definition | | `add <pkg> [version]` | Add a package to system config |
| `parse-config <file>` | Validate a system configuration | | `remove <pkg>` | Remove a package from system config |
| `validate <file>` | Validate any kappa file | | `build <package>` | Build a single package from its `.kap` definition |
| `format <file>` | Pretty-print to canonical style | | `rebuild <config>` | Diff config against installed state, rebuild changed |
| `doctor <file>` | Check for issues and warnings | | `resolve <config>` | Compute a build plan (shows order, deps, conflicts) |
| `resolve <config>` | Compute a build plan | | `doctor <file>` | Check a file for issues and warnings |
| `fetch <package>` | Download and verify source tarballs | | `fetch <package>` | Download and verify source tarballs |
| `fetch-package <name>` | Fetch a package recipe from remotes | | `fetch-package <name>` | Fetch a package recipe from remotes |
| `build <package>` | Build a single package | | `format <file>` | Pretty-print to canonical style |
| `rebuild <config>` | Diff config against installed state, rebuild changed | | `index <dir>` | Build an `index.kap` from `.kap` files in a directory |
| `list` | Show installed packages | | `list` | Show installed packages |
| `parse-config <file>` | Validate a system configuration |
| `parse-package <file>` | Validate a `.kap` package definition |
| `rollback` | Show available generations | | `rollback` | Show available generations |
| `validate <file>` | Validate any kappa file (package, config, or index) |
### Repo maintenance
```sh
# Generate an index from a directory of .kap files
kappa index ./packages/
# → packages/index.kap
# Host the directory behind any HTTP server. That's your repo.
```
The index is a tiny text file listing every package and version.
Clients fetch it once, cache it, and check for updates via HTTP headers.
### License ### License
@@ -151,4 +197,7 @@ BSD 2-Clause. Do whatever you want. Just don't sue us.
### Contributing ### Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md). We're opinionated but we merge good See [CONTRIBUTING.md](CONTRIBUTING.md). We're opinionated but we merge good
code. code. See [STYLEGUIDE.md](STYLEGUIDE.md) for code conventions.
Tests: 138 integration tests across three suites. CI runs on Arch Linux.
Everything passes or nothing merges.
+484
View File
@@ -0,0 +1,484 @@
# Style Guide
If you're reading this because you thought kappa's C++ looked different from
what you're used to — good. That's the point.
This isn't a suggestion box. It's what the codebase looks like, and it's what
your code will look like after you've rewritten it three times because the PR
reviewer sent it back. Save yourself the rewrite. Read this first.
---
## The Philosophy
We write C++ like it's the year 2026 and the committee finally shipped
something usable. No polyfills. No third-party libraries. No Boost. The
standard library is sufficient for a package manager. If you disagree, you
haven't read `<format>` closely enough.
Every line of kappa assumes the reader is competent. We don't explain what
`std::string_view` is. We don't annotate obvious control flow. Comments exist
to explain *why*, never *what*. If your code needs a comment to be
understood, the code is wrong.
Simplicity is a moral position. The scheduler is the hardest thing in this
codebase, and it's 290 lines. If your feature adds more than that, you're
building the wrong feature.
---
## Naming
### Structs, classes, enums
```cpp
// PascalCase. Always.
struct BuildResult { };
enum class TokenType : std::uint8_t { };
// Enum values are PascalCase too. This isn't Java.
enum class EnvMode : std::uint8_t { Set, Soft, Append };
// Acronyms stay capitalized. B-Tree is BTree, not Btree.
// Two-letter acronyms stay capitalized. ID, not Id.
```
Type names state what the thing *is*, not what it's *for*. `SchedResult`, not
`ResultForScheduler`. `InitPaths`, not `PathsForInitSystems`.
### Variables and functions
```cpp
// snake_case. No Hungarian notation. No m_ prefix. No s_ prefix.
int pending_deps = 0;
void compute_depths(Scheduler& s);
std::string_view token_name(TokenType type);
```
Member variables and locals look identical. If you can't tell them apart,
your functions are too long. Fix the function.
### Files
```
src/service/openrc.cpp # snake_case, lowercase
include/kappa/service/types.hpp # .hpp for headers, .cpp for source
```
One public class per header is a myth invented by Java developers. Group
related declarations. `types.hpp` holds all enums and structs for a module.
If a module has one public struct and one public function, they go in the
same header.
---
## Formatting
### Indentation and braces
Four spaces. Attached braces (a K&R variant).
```cpp
// ✓ yes — brace on the same line as the control structure
if (pid < 0) {
return -1;
}
// ✗ no — Allman/BSD braces on their own line
if (pid < 0)
{
return -1;
}
// ✗ no — missing braces on single-statement bodies
if (pid < 0) return -1;
```
Always braces. Even for single statements. The compiler doesn't care.
The human reviewing your diff at 2 AM does. clang-tidy will flag bare
bodies — apply the fix every time.
### Line length
100 columns. Not 80 — we're not teletypes. Not 120 — if you need 120
characters to express a thought, your thought is too complicated. Split it.
### Section separators
```cpp
// --- Section description ---
// or
// ---------------------------------------------------------------------------
// Longer section description spanning the full runway
// ---------------------------------------------------------------------------
```
Thin lines (`---`) for sub-sections within a file. Thick lines
(`-----------`) for top-level section boundaries. The difference communicates
hierarchy without nesting.
### Switch cases
```cpp
switch (is) {
case InitSystem::Systemd:
return generate_systemd_service(spec);
case InitSystem::S6:
return generate_s6_service(spec);
case InitSystem::Unknown:
default:
return {};
}
```
Cases at the same indentation as the switch. No blocks around single-return
cases. Break or return in every non-fallthrough case. If you're falling
through intentionally, wrap it with `[[fallthrough]];` on a line by itself.
---
## Types
### Use `auto` when the type is obvious, explicit when it isn't
```cpp
// ✓ yes — type is obvious from initialization
auto scope = eval::make_default_scope();
auto& pkg = registry.at(name);
// ✓ yes — structured bindings, type is obvious
for (auto& [key, val] : features) { }
// ✓ yes — explicit where the type carries meaning
std::unique_lock lock(s.mtx); // not auto lock = ...
std::uint64_t h = 14695981039346656037ULL; // not auto h = ...
```
### `const` is the default
Everything is `const` until proven mutable.
```cpp
// ✓ yes
const auto& step = plan.steps[i];
for (const auto& entry : entries) { }
// ✗ no — mutable when it shouldn't be
auto& step = plan.steps[i];
```
### View types over owning types in parameters
```cpp
// ✓ yes
void set_root(std::string_view path);
bool is_supported(std::string_view name);
// ✗ no
void set_root(const std::string& path);
```
Return owning types. Accept views. The caller decides ownership. You decide
what you need to read.
### Strong enums only
```cpp
// ✓ yes
enum class InitSystem : std::uint8_t { Systemd, OpenRC, S6 };
// ✗ no
enum InitSystem { INIT_SYSTEMD, INIT_OPENRC, INIT_S6 };
```
No unscoped enums. No ALL_CAPS enum values. No integer conversions without
explicit intent. If you need to serialize an enum to an integer, write a
`to_string` function. The enum's numeric value is an implementation detail,
not an interface.
---
## Functions
### One responsibility per function
If your function name contains the word "and", it does at least two things
and needs to be split. If the body doesn't fit on one screen, it does too
much. "One screen" means approximately 30 lines. The scheduler's
`compute_depths` is 30 lines. So is the resolver's `resolve`. They're at the
upper bound. If yours is longer, you're doing something wrong.
### Error handling: return, don't throw
```cpp
// ✓ yes
struct FetchResult {
std::filesystem::path work_dir;
std::string error;
bool ok() const { return error.empty(); }
};
FetchResult fetch(const PackageDef& pkg);
// ✗ no
void fetch(const PackageDef& pkg); // throws on error
```
Exceptions are for unrecoverable programmer errors — out-of-memory, null
dereference, violated invariants. They belong in constructors and in the
parser (where `ParseError` is the only way to unwind back to diagnostics).
Every operational failure — network down, disk full, configure script failed,
hash mismatch — is a return value. A struct with `bool ok` and
`std::string error`. Check the `ok` field, read the `error` string, don't
catch exceptions for normal operation.
### Return early, return often
```cpp
// ✓ yes
if (to.empty()) {
return {false, "destination is empty"};
}
// ... main logic ...
// ✗ no
if (!to.empty()) {
// ... 40 lines of nesting ...
} else {
return {false, "destination is empty"};
}
```
Guard clauses at the top. Happy path straight down the left margin. If your
code has three levels of nesting, you missed an early return opportunity.
### Static helpers over lambdas
If a helper is more than 5 lines, extract it to a file-static function above
the public API. Named functions are greppable. Named functions show up in
stack traces. Lambdas don't. The one exception is a `run_phase` lambda in
`build()` — it captures local state that would require a 5-parameter helper
and it's clearly a one-off control flow wrapper, not a reusable abstraction.
---
## Namespaces
```cpp
namespace kappa::module {
// Everything goes here.
} // namespace kappa::module
```
C++17 nested namespace syntax. Closing brace gets a comment with the
namespace name. These comments survive diffs, refactors, and editors that
collapse braces. They cost one line and save ten minutes of scrolling up to
figure out which brace closes what.
### No `using namespace` at file scope
```cpp
// ✓ yes — inside a function
namespace fs = std::filesystem;
// ✗ no — at file scope
using namespace std;
```
Namespace aliases are acceptable inside functions — `namespace fs =
std::filesystem;` is fine when the file does a lot of path manipulation. But
at file scope? No. You're not writing `using namespace std;` at the top of a
header and you're not doing the subtler version of the same sin.
---
## Headers
```cpp
#pragma once
#include "kappa/resolve/plan.hpp"
#include "kappa/dsl/ast.hpp"
#include <string>
#include <vector>
namespace kappa::build {
struct BuildResult { };
BuildResult build(const resolve::BuildStep& step,
const std::string& work_dir,
int jobs);
} // namespace kappa::build
```
`#pragma once` at the top. No include guards. This is 2026.
Project headers first, in quotes. System headers second, in angle brackets.
Blank line between the two groups. Alphabetical within each group.
Headers include only what they need to compile. If `build.hpp` uses
`resolve::BuildStep` by reference, it includes `resolve/plan.hpp`. It does
not forward-declare `BuildStep` — we don't forward-declare across module
boundaries. The include is the contract: "this module depends on that one."
Headers never contain implementation. No `inline` functions. No
template definitions in headers (we don't use templates). The one exception
is `parse_util.hpp`, which defines `ParseError` inline because it's a thin
exception wrapper and splitting it would be ceremony for ceremony's sake.
One exception per codebase is a pattern. Two is a problem.
---
## Modules
Every module follows this structure:
```
include/kappa/{module}/
├── types.hpp # enums, structs, parse/validate declarations
├── {feature}.hpp # public function declarations
src/{module}/
├── types.cpp # parse/validate/describe implementations
├── backend_a.cpp # per-variant generation (if applicable)
├── backend_b.cpp
└── install.cpp # dispatch + orchestration (if applicable)
```
If a module doesn't need `types.hpp` (single struct, single function), both
go in `{feature}.hpp`. If a module has no backends, skip them. But don't
invent a third pattern. `service/` and `boot/` are the templates. Copy them.
---
## Strings and formatting
```cpp
// ✓ yes
auto msg = std::format("building {} (depth={})", name, depth);
result.error = std::format("command exited with code {}: {}", rc, cmd);
// ✗ no — ostringstream for trivial concatenation
std::ostringstream oss;
oss << "building " << name << " (depth=" << depth << ")";
// ✓ yes — ostringstream for incremental construction
std::ostringstream out;
out << "[Unit]\n";
out << std::format("Description={}\n", desc);
```
`std::format` for one-shot strings. `std::ostringstream` for building up
output incrementally (service files, bootloader configs, formatter output).
String concatenation with `+` is acceptable for two or three pieces.
Anything more goes through `std::format`.
### String views for parameters
```cpp
// ✓ yes
InitSystem parse_init_system(std::string_view name);
void print_error(std::ostream& os, std::string_view source,
SourceLocation loc, std::string_view message);
// ✗ no
InitSystem parse_init_system(const std::string& name);
```
Views everywhere, except when you need to store the string.
---
## The DSL
The `.kap` DSL grammar is the contract. You can extend it. You cannot break
existing configs. Every new token type requires:
1. An entry in `TokenType`
2. A case in `token_name()`
3. Parsing logic in the appropriate parser
4. A formatting case in `format.cpp`
5. At least one test in `test.sh` that exercises the new syntax
If you're adding a keyword, think twice. The lexer already has 29 token
types. Every new one increases parse time and mental overhead. Can this be
expressed with the existing grammar? If yes, don't add a keyword.
---
## Thread safety
The scheduler is multithreaded. If you touch shared state, you own the lock.
```cpp
{
std::unique_lock lock(s.mtx);
s.waiting.erase(idx);
}
// lock released here — no shared state access beyond this point
```
Use scoped locks. Never lock/unlock manually. Never hold a lock across a
condition variable wait without understanding why. If you think you need
`memory_order_release`, you probably need `memory_order_acq_rel` and you
should document why in a three-line comment above the operation.
If a data structure is touched by multiple threads, its access pattern must
be documented at the declaration site, not in a PR description. "This is
only written under the lock, read atomically elsewhere" goes in the header.
---
## What clang-tidy enforces
We run with `-Wall -Wextra -Wpedantic` and a `.clang-tidy` config. Zero
warnings. Not "zero warnings except for that one file." Zero.
The following are non-negotiable:
- Every `if`/`for`/`while` body has braces
- `auto` variables that are never modified are `const auto`
- Variables are initialized at declaration
- No unused includes
- No redundant declarations
If clang-tidy suggests a fix and you disagree, you're wrong. Apply the fix.
The only acceptable override is `// NOLINT` with a justification comment —
and if you write that more than twice in a file, the reviewer will ask you to
rethink your design.
---
## What we reject
- **Comments that narrate the code.** `// Increment counter` above `i++`
is an insult. Delete it.
- **Dead code.** No commented-out blocks. No `#if 0`. If it's not used,
it doesn't exist. Git remembers.
- **Premature abstraction.** Three identical lines do not need a
function. Ten do. The threshold is somewhere in between and you should
err on the side of duplication.
- **C heritage.** `printf`, `malloc`, `NULL`, raw `char*` strings,
`#define` constants. The 1970s called. Don't answer.
- **Over-engineering.** The build backend doesn't need a plugin
architecture. The lexer doesn't need a state machine framework.
Solve the problem in front of you, not the one you imagine someone
might have in three years.
- **Cleverness.** If your solution makes you feel smart, it's wrong.
The best code is the code you forget about because it never breaks.
---
Kappa does one thing: build your system from source, init-agnostically.
Everything in this style guide exists to keep that codebase small, fast, and
comprehensible. If a rule conflicts with that goal, the goal wins — but
you'd better have a good reason, and you'd better write it down.
+1 -1
View File
@@ -1,6 +1,6 @@
/* /*
* Per-package override for foo. * Per-package override for foo.
* Lives at /kappa/system/builds/foo.kap * Lives at /usr/local/kappa/system/builds/foo.kap
* *
* Uses system config syntax — only features/config need to be specified. * Uses system config syntax — only features/config need to be specified.
* The rest inherits from the package definition and system config. * The rest inherits from the package definition and system config.
+4 -2
View File
@@ -1,6 +1,6 @@
/* /*
* Kappa system configuration. * Kappa system configuration.
* Lives at /kappa/system/config.kap * Lives at /usr/local/kappa/system/config.kap
* *
* INIT SYSTEM SELECTION * INIT SYSTEM SELECTION
* ===================== * =====================
@@ -67,6 +67,8 @@ system {
env { env {
CFLAGS = "-O2 -march=native" CFLAGS = "-O2 -march=native"
// packages inherit this appended flag
CFLAGS += "-pipe"
LDFLAGS = "-Wl,--as-needed" LDFLAGS = "-Wl,--as-needed"
MAKEFLAGS = "-j8" MAKEFLAGS = "-j8"
} }
@@ -101,7 +103,7 @@ packages {
/* /*
// Per-package overrides can also live in // Per-package overrides can also live in
// /kappa/system/builds/<name>.kap // /usr/local/kappa/system/builds/<name>.kap
*/ */
} }
+15
View File
@@ -46,6 +46,17 @@
* *
* The backend generators handle all translation. Package authors only * The backend generators handle all translation. Package authors only
* need to pick the semantic type that describes their daemon's behaviour. * need to pick the semantic type that describes their daemon's behaviour.
*
*
* ENV OPERATORS
* =============
* key = "val" hard set — overwrites any existing value
* key ?= "val" soft set — only applied if key is not already set
* key += "val" append — adds to existing value with a space separator
*
* Order matters. Subsequent entries in the same env block can append to
* earlier ones. System-level env (from config.kap) is applied before
* package env, so packages can += to flags set globally.
*/ */
package "foo" { package "foo" {
const version = "1.2.3" const version = "1.2.3"
@@ -101,8 +112,12 @@ package "foo" {
} }
env { env {
// hard set — overwrites any existing CFLAGS
CFLAGS = "-O2 -march=native" CFLAGS = "-O2 -march=native"
// append — now CFLAGS is "-O2 -march=native -pipe"
CFLAGS += "-pipe"
LDFLAGS = "-Wl,--as-needed" LDFLAGS = "-Wl,--as-needed"
// soft set — only added if CFLAGS isn't already defined
CFLAGS ?= "-g" CFLAGS ?= "-g"
} }
+5
View File
@@ -0,0 +1,5 @@
index "local" {
foo { version = "1.2.3" }
postgresql { version = "16.3" }
make { version = "4.4.1" }
}
+38
View File
@@ -0,0 +1,38 @@
/*
* GNU Make — the build tool that builds everything else.
*
* This is the quintessential autotools package: ./configure, make, make install.
* If kappa can build this, it can build anything downstream.
*
* INIT-AGNOSTIC
* =============
* Make itself doesn't care about init systems — no service definitions,
* no boot requirements. It's a pure build tool.
*/
package "make" {
const version = "4.4.1"
const source = "https://ftp.gnu.org/gnu/make/make-${version}.tar.gz"
sha256 = "dd16fb1d67bfab79a72f5e8390735c49e3e8e70b4945a15ab1f81ddb78658fb3"
license = "GPL-3.0-or-later"
provides = ["make", "gmake"]
env {
CFLAGS = "-O2"
CFLAGS += "-Wno-error"
}
build {
touch aclocal.m4 configure Makefile.in */Makefile.in
./configure --prefix=${prefix}
make -j${jobs}
}
check {
make check
}
install {
make DESTDIR=${destdir} install
}
}
+2
View File
@@ -77,6 +77,8 @@ package "postgresql" {
env { env {
CFLAGS = "-O2" CFLAGS = "-O2"
// append security hardening flags
CFLAGS += "-D_FORTIFY_SOURCE=2"
LDFLAGS = "-Wl,--as-needed" LDFLAGS = "-Wl,--as-needed"
} }
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include <string>
#include <string_view>
#include "kappa/boot/types.hpp"
namespace kappa::boot {
struct BootSpec {
std::string kernel_path; // e.g. "/kappa/boot/kernel"
std::string init_path; // e.g. "/kappa/boot/init" (the symlink, stable across init swaps)
std::string root; // e.g. "/dev/sda1" or "PARTUUID=xxx"
std::string kernel_params; // extra kernel cmdline params
std::string init_prev; // previous init symlink for fallback entry; empty = no fallback
};
std::string generate_bootloader_config(Bootloader bl, const BootSpec& spec);
std::string generate_limine_config(const BootSpec& spec);
std::string generate_grub_config(const BootSpec& spec);
struct BootloaderInstallResult {
bool ok;
std::string path;
std::string error;
};
BootloaderInstallResult install_bootloader_config(Bootloader bl,
const BootSpec& spec,
std::string_view prefix = "/");
} // namespace kappa::boot
+33
View File
@@ -0,0 +1,33 @@
#pragma once
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
namespace kappa::boot {
enum class Bootloader : std::uint8_t {
Grub,
Limine,
Unknown
};
Bootloader parse_bootloader(std::string_view name);
std::string_view to_string(Bootloader bl);
bool is_supported(std::string_view name);
std::vector<Bootloader> all_bootloaders();
std::string_view bootloader_description(Bootloader bl);
struct BootloaderPaths {
std::string config_path;
std::string install_cmd;
};
BootloaderPaths bootloader_paths(Bootloader bl, std::string_view prefix = "/");
} // namespace kappa::boot
+22
View File
@@ -0,0 +1,22 @@
#pragma once
#include "kappa/resolve/plan.hpp"
#include <string>
namespace kappa::build {
struct BuildResult {
bool ok = false;
std::string phase; // phase that failed, e.g. "prepare" or "build"
std::string error;
};
// Build a single package step into work_dir.
// Runs prepare, build, check, and install phases in order,
// with full variable interpolation and config file generation.
BuildResult build(const resolve::BuildStep& step,
const std::string& work_dir,
int jobs);
} // namespace kappa::build
+28
View File
@@ -0,0 +1,28 @@
#pragma once
#include "kappa/dsl/ast.hpp"
#include "kappa/dsl/system.hpp"
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace kappa::config {
struct AssertFailure {
std::string message;
std::string field;
std::string expected;
std::string actual;
};
std::string resolve_field(const dsl::SystemConfig& cfg, std::string_view path);
std::vector<AssertFailure> evaluate_assertions(const dsl::SystemConfig& cfg);
std::unordered_map<std::string, dsl::NamedService> resolve_services(
const dsl::SystemConfig& cfg,
const std::unordered_map<std::string, dsl::PackageDef>& packages);
} // namespace kappa::config
+14 -1
View File
@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <cstdint>
#include <string> #include <string>
#include <unordered_map> #include <unordered_map>
#include <unordered_set> #include <unordered_set>
@@ -38,10 +39,12 @@ struct Patch {
int level = 1; int level = 1;
}; };
enum class EnvMode : std::uint8_t { Set, Soft, Append };
struct EnvEntry { struct EnvEntry {
std::string key; std::string key;
std::string value; std::string value;
bool soft = false; EnvMode mode = EnvMode::Set;
}; };
struct NamedService { struct NamedService {
@@ -90,4 +93,14 @@ struct PackageDef {
Phase uninstall; Phase uninstall;
}; };
struct IndexEntry {
std::string name;
std::string version;
};
struct IndexDef {
std::string name; // "kappa-os/stable"
std::vector<IndexEntry> packages;
};
} // namespace kappa::dsl } // namespace kappa::dsl
+2 -25
View File
@@ -1,36 +1,13 @@
#pragma once #pragma once
#include "kappa/dsl/parse_util.hpp"
#include <format> #include <format>
#include <stdexcept>
#include <string> #include <string>
#include <string_view> #include <string_view>
namespace kappa::dsl { namespace kappa::dsl {
class ParseError : public std::runtime_error {
public:
ParseError(int line, int col, const std::string& msg)
: std::runtime_error(std::format("{}:{}: {}", line, col, msg)) {}
};
inline int parse_int(int line, int col, const std::string& lexeme) {
try {
std::size_t pos = 0;
int val = std::stoi(lexeme, &pos);
if (pos != lexeme.size()) {
throw ParseError(line, col,
std::format("expected an integer, got '{}'", lexeme));
}
return val;
} catch (const std::invalid_argument&) {
throw ParseError(line, col,
std::format("expected an integer, got '{}'", lexeme));
} catch (const std::out_of_range&) {
throw ParseError(line, col,
std::format("integer out of range: '{}'", lexeme));
}
}
inline std::string msg_expected(std::string_view expected, std::string_view got) { inline std::string msg_expected(std::string_view expected, std::string_view got) {
return std::format("expected {}, got '{}'", expected, got); return std::format("expected {}, got '{}'", expected, got);
} }
+35
View File
@@ -0,0 +1,35 @@
#pragma once
#include <format>
#include <stdexcept>
#include <string>
namespace kappa::dsl {
class ParseError : public std::runtime_error {
public:
ParseError(int line, int col, const std::string& msg)
: std::runtime_error(std::format("{}:{}: {}", line, col, msg)) {}
};
/// Parse an integer from a DSL token lexeme.
/// Throws ParseError on invalid input or overflow.
inline int parse_int(int line, int col, const std::string& lexeme) {
try {
std::size_t pos = 0;
int val = std::stoi(lexeme, &pos);
if (pos != lexeme.size()) {
throw ParseError(line, col,
std::format("expected an integer, got '{}'", lexeme));
}
return val;
} catch (const std::invalid_argument&) {
throw ParseError(line, col,
std::format("expected an integer, got '{}'", lexeme));
} catch (const std::out_of_range&) {
throw ParseError(line, col,
std::format("integer out of range: '{}'", lexeme));
}
}
} // namespace kappa::dsl
+13 -1
View File
@@ -57,9 +57,18 @@ struct ServiceRef {
std::unordered_map<std::string, std::string> config; std::unordered_map<std::string, std::string> config;
}; };
struct RepoDef {
std::string name;
std::string url;
std::vector<std::string> channels;
std::vector<std::string> mirrors;
int priority = 50;
};
struct SystemConfig { struct SystemConfig {
std::vector<std::string> imports; std::vector<std::string> imports;
std::vector<std::string> remotes; std::vector<std::string> remotes; // legacy — flat URL list
std::vector<RepoDef> repos; // named repos with channels/mirrors
SystemBlock system; SystemBlock system;
std::vector<PackageRef> packages; std::vector<PackageRef> packages;
std::vector<ServiceRef> services; std::vector<ServiceRef> services;
@@ -72,4 +81,7 @@ struct SystemConfig {
SystemConfig parse_system_config(std::string_view source); SystemConfig parse_system_config(std::string_view source);
SystemConfig resolve_imports(const SystemConfig& cfg, const std::string& base_dir); SystemConfig resolve_imports(const SystemConfig& cfg, const std::string& base_dir);
IndexDef parse_index(std::string_view source);
IndexDef build_index(const std::string& directory);
} // namespace kappa::dsl } // namespace kappa::dsl
+5
View File
@@ -13,6 +13,7 @@ enum class TokenType {
Lbrace, // { Lbrace, // {
Rbrace, // } Rbrace, // }
Equals, // = Equals, // =
Plus, // +
Lbracket, // [ Lbracket, // [
Rbracket, // ] Rbracket, // ]
Comma, // , Comma, // ,
@@ -48,6 +49,10 @@ enum class TokenType {
KwUninstall, KwUninstall,
KwTrue, KwTrue,
KwFalse, KwFalse,
// Repo / index
KwIndex,
KwRepos,
}; };
struct Token { struct Token {
+18
View File
@@ -0,0 +1,18 @@
#pragma once
#include "kappa/dsl/ast.hpp"
#include <filesystem>
#include <string>
namespace kappa::fetch {
struct FetchResult {
std::filesystem::path work_dir;
std::string error;
bool ok() const { return error.empty(); }
};
FetchResult fetch(const dsl::PackageDef& pkg);
} // namespace kappa::fetch
+26
View File
@@ -0,0 +1,26 @@
#pragma once
#include "kappa/dsl/system.hpp"
#include <string>
#include <vector>
namespace kappa::fetch {
struct RecipeResult {
bool ok = false;
std::string path; // path to the cached .kap file
std::string version; // version of the fetched package
bool updated = false; // true if the cache was updated (remote was newer)
std::string error;
};
// Legacy: flat URL list (backward compat with remotes = [...])
RecipeResult fetch_recipe(const std::string& name,
const std::vector<std::string>& remotes);
// Index-aware: named repos with channels, mirrors, and cached indexes
RecipeResult fetch_recipe_from_repos(const std::string& name,
const std::vector<dsl::RepoDef>& repos);
} // namespace kappa::fetch
+38
View File
@@ -0,0 +1,38 @@
#pragma once
#include "kappa/resolve/plan.hpp"
#include <filesystem>
#include <string>
#include <unordered_map>
#include <vector>
namespace kappa::install {
struct InstallResult {
bool ok = false;
std::string hash;
std::filesystem::path store_path;
std::string error;
};
InstallResult install(const resolve::BuildStep& step,
const std::filesystem::path& work_dir);
struct DbEntry {
std::string name;
std::string version;
std::string hash;
std::string config_hash; // FNV-1a hash of features+config, empty if unknown
std::vector<std::string> provides;
};
std::vector<DbEntry> read_installed();
bool write_installed(const std::vector<DbEntry>& entries);
bool record_generation(const std::vector<std::string>& hashes, int keep);
std::string compute_config_hash(
const std::unordered_map<std::string, dsl::FeatureDef>& features,
const std::unordered_map<std::string, std::string>& config);
} // namespace kappa::install
+2 -1
View File
@@ -17,6 +17,7 @@ std::filesystem::path builds_dir();
std::filesystem::path cache_dir(); std::filesystem::path cache_dir();
std::filesystem::path packages_dir(); std::filesystem::path packages_dir();
void ensure_directories(); bool ensure_directories();
bool directories_exist();
} // namespace kappa::paths } // namespace kappa::paths
+32
View File
@@ -0,0 +1,32 @@
#pragma once
#include "kappa/dsl/system.hpp"
#include "kappa/resolve/plan.hpp"
#include <string>
#include <vector>
namespace kappa::rebuild {
struct ChangeSet {
std::vector<std::string> added;
std::vector<std::string> changed;
std::vector<std::string> removed;
bool kernel_changed = false;
bool init_changed = false;
bool services_changed = false;
bool bootloader_changed = false;
};
struct InitImpact {
std::vector<std::string> service_rebuild; // packages needing full rebuild (use ${enabledinit})
std::vector<std::string> service_only; // packages needing only service file regeneration
std::vector<std::string> skipped; // packages with no services — no action needed
};
ChangeSet compute_changes(const dsl::SystemConfig& cfg);
InitImpact compute_init_impact(const dsl::SystemConfig& cfg,
const resolve::Registry& registry);
} // namespace kappa::rebuild
+37
View File
@@ -0,0 +1,37 @@
#pragma once
#include "kappa/config/merge.hpp"
#include "kappa/dsl/ast.hpp"
#include "kappa/dsl/system.hpp"
#include <string>
#include <unordered_map>
#include <vector>
namespace kappa::resolve {
struct ResolvedDep {
std::string name;
std::string version_constraint;
};
struct BuildStep {
std::string name;
const dsl::PackageDef* package = nullptr;
config::ResolvedPackage resolved;
std::vector<ResolvedDep> dependencies;
std::string enabled_init; // boot.init value for ${enabledinit}
};
struct BuildPlan {
std::vector<BuildStep> steps;
std::vector<std::string> cycles;
std::vector<std::string> conflicts;
std::vector<std::string> missing;
};
using Registry = std::unordered_map<std::string, dsl::PackageDef>;
BuildPlan resolve(const dsl::SystemConfig& cfg, const Registry& registry);
} // namespace kappa::resolve
+30
View File
@@ -0,0 +1,30 @@
#pragma once
#include "kappa/resolve/plan.hpp"
#include <string>
#include <vector>
namespace kappa::sched {
struct SchedResult {
bool ok = false;
std::vector<std::string> built;
std::vector<std::string> failed;
};
// Run the build plan with worker-level (-w) and job-level (-j) parallelism.
// Workers atomically claim ready packages, build them, and notify dependents.
// The same package is never built twice — deduplicated in the dependency graph.
//
// -w N package-level parallelism (concurrent package builds)
// -j N job-level parallelism (make -jN per package)
//
// The scheduler uses depth-based priority groups (Beta → Alpha → Zeta)
// so that deep dependencies unblock as many packages as possible first.
SchedResult run(const resolve::BuildPlan& plan,
const std::string& work_root,
int workers = 1,
int jobs = 1);
} // namespace kappa::sched
+46
View File
@@ -0,0 +1,46 @@
#pragma once
#include "kappa/dsl/ast.hpp"
#include "kappa/service/types.hpp"
#include <string>
#include <string_view>
#include <unordered_map>
#include <vector>
namespace kappa::service {
struct ServiceSpec {
std::string name;
std::string description;
std::string exec;
std::string type;
std::string user;
std::vector<int> ports;
std::unordered_map<std::string, std::string> env;
std::string after;
std::string restart_policy;
std::string working_dir;
static ServiceSpec from_service_init(const dsl::NamedService& ns);
};
std::string generate_systemd_service(const ServiceSpec& spec);
std::string generate_s6_service(const ServiceSpec& spec);
std::string generate_openrc_service(const ServiceSpec& spec);
std::string generate_dinit_service(const ServiceSpec& spec);
std::string generate_runit_service(const ServiceSpec& spec);
std::string generate_service_file(InitSystem is, const ServiceSpec& spec);
struct ServiceInstallResult {
bool ok;
std::string path;
std::string error;
};
ServiceInstallResult install_service(
InitSystem is,
const ServiceSpec& spec,
std::string_view prefix = "/");
} // namespace kappa::service
+41
View File
@@ -0,0 +1,41 @@
#pragma once
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
namespace kappa::service {
enum class InitSystem : std::uint8_t {
Systemd,
OpenRC,
S6,
Runit,
Dinit,
Unknown
};
InitSystem parse_init_system(std::string_view name);
std::string_view to_string(InitSystem is);
bool is_supported(std::string_view name);
std::vector<InitSystem> all_systems();
std::string_view init_description(InitSystem is);
struct InitPaths {
std::string service_dir;
std::string enable_cmd;
std::string disable_cmd;
};
InitPaths init_paths(InitSystem is, std::string_view prefix = "/");
// Shebangs for s6 execline-based run scripts.
// Extracted here so backends share a single definition.
inline constexpr std::string_view s6_execline_shebang = "#!/bin/execlineb -P\n";
} // namespace kappa::service
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include "kappa/dsl/system.hpp"
#include <string>
namespace kappa::system {
struct ActivateResult {
bool ok;
std::string error;
};
ActivateResult write_hostname(const std::string& hostname,
std::string_view prefix = "/");
ActivateResult write_timezone(const std::string& timezone,
std::string_view prefix = "/");
} // namespace kappa::system
+21
View File
@@ -0,0 +1,21 @@
#pragma once
#include "kappa/dsl/ast.hpp"
#include "kappa/dsl/system.hpp"
#include <string>
#include <vector>
namespace kappa::tools {
enum class DiagSeverity { Warning, Error };
struct Diagnostic {
DiagSeverity severity;
std::string message;
};
std::vector<Diagnostic> check_package(const dsl::PackageDef& pkg);
std::vector<Diagnostic> check_config(const dsl::SystemConfig& cfg);
} // namespace kappa::tools
+14
View File
@@ -0,0 +1,14 @@
#pragma once
#include "kappa/dsl/ast.hpp"
#include "kappa/dsl/system.hpp"
#include <ostream>
namespace kappa::tools {
void format_package(std::ostream& os, const dsl::PackageDef& pkg);
void format_config(std::ostream& os, const dsl::SystemConfig& cfg);
void format_index(std::ostream& os, const dsl::IndexDef& idx);
} // namespace kappa::tools
+8
View File
@@ -0,0 +1,8 @@
#pragma once
#include <string>
#include <string_view>
namespace kappa::util {
std::string to_lower(std::string_view sv);
std::string shell_escape(std::string_view s);
}
+18 -1
View File
@@ -27,6 +27,18 @@ std::string generate_bootloader_config(Bootloader bl, const BootSpec& spec) {
} }
} }
namespace {
std::string read_current_init(std::string_view prefix) {
namespace fs = std::filesystem;
fs::path init_link = fs::path(prefix) / "boot/init";
std::error_code ec;
if (!fs::is_symlink(init_link, ec)) return {};
auto target = fs::read_symlink(init_link, ec);
if (ec) return {};
return target.string();
}
} // namespace
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// install_bootloader_config — write the generated config file to disk // install_bootloader_config — write the generated config file to disk
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -41,7 +53,12 @@ BootloaderInstallResult install_bootloader_config(Bootloader bl,
return {false, {}, "Unknown bootloader"}; return {false, {}, "Unknown bootloader"};
} }
std::string content = generate_bootloader_config(bl, spec); BootSpec spec_copy = spec;
if (spec_copy.init_prev.empty()) {
spec_copy.init_prev = read_current_init(prefix);
}
std::string content = generate_bootloader_config(bl, spec_copy);
if (content.empty()) { if (content.empty()) {
return {false, {}, "Failed to generate bootloader config"}; return {false, {}, "Failed to generate bootloader config"};
} }
+3 -2
View File
@@ -3,6 +3,7 @@
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <filesystem>
#include <format> #include <format>
#include <ranges> #include <ranges>
@@ -48,12 +49,12 @@ BootloaderPaths bootloader_paths(Bootloader bl, std::string_view prefix) {
switch (bl) { switch (bl) {
case Bootloader::Grub: case Bootloader::Grub:
return { return {
.config_path = std::format("{}boot/grub/grub.cfg", prefix), .config_path = (std::filesystem::path(prefix) / "boot/grub/grub.cfg").string(),
.install_cmd = "grub-install", .install_cmd = "grub-install",
}; };
case Bootloader::Limine: case Bootloader::Limine:
return { return {
.config_path = std::format("{}boot/limine/limine.cfg", prefix), .config_path = (std::filesystem::path(prefix) / "boot/limine/limine.cfg").string(),
.install_cmd = "limine", .install_cmd = "limine",
}; };
case Bootloader::Unknown: case Bootloader::Unknown:
+324
View File
@@ -0,0 +1,324 @@
#include "kappa/build/build.hpp"
#include "kappa/eval/vars.hpp"
#include <cstdlib>
#include <filesystem>
#include <format>
#include <fstream>
#include <iostream>
#include <string_view>
#include <sys/wait.h>
#include <unistd.h>
#include <unordered_map>
namespace kappa::build {
namespace fs = std::filesystem;
// ---------------------------------------------------------------------------
// Shell execution — runs a command through /bin/sh in the given cwd.
// Environment variables are applied before exec.
// Returns exit code, or -1 if fork/exec fails.
// ---------------------------------------------------------------------------
static int exec_sh(const std::string& cmd,
const std::unordered_map<std::string, std::string>& env,
const fs::path& cwd) {
pid_t pid = fork();
if (pid == 0) {
for (const auto& [k, v] : env) {
setenv(k.c_str(), v.c_str(), 1);
}
if (!cwd.empty()) {
std::error_code ec;
fs::current_path(cwd, ec);
}
execl("/bin/sh", "sh", "-c", cmd.c_str(), nullptr);
_exit(127);
}
if (pid < 0) { return -1; }
int status = 0;
pid_t w;
do {
w = waitpid(pid, &status, 0);
} while (w == -1 && errno == EINTR);
return WIFEXITED(status) ? WEXITSTATUS(status) : -1;
}
// ---------------------------------------------------------------------------
// Config-file variable resolution helpers
// ---------------------------------------------------------------------------
struct CfResolved {
std::string value;
bool skip_line = false;
};
// Parse "${cfg.port ? 8080}" suffix after the variable name.
// Extracts the modifier (?, !) and default value from inner text after a space.
static void parse_cf_suffix(std::string_view& inner,
bool& optional,
bool& required,
std::string_view& default_val) {
auto space = inner.find(' ');
if (space == std::string_view::npos) { return; }
auto suffix = inner.substr(space + 1);
while (!suffix.empty() && suffix.front() == ' ') {
suffix.remove_prefix(1);
}
if (suffix.empty()) { return; }
if (suffix.front() == '?') {
optional = true;
default_val = suffix.substr(1);
while (!default_val.empty() && default_val.front() == ' ') {
default_val.remove_prefix(1);
}
} else if (suffix.front() == '!') {
required = true;
}
inner = inner.substr(0, space);
}
static void trim_trailing_spaces(std::string_view& s) {
while (!s.empty() && s.back() == ' ') {
s.remove_suffix(1);
}
}
static CfResolved resolve_cf_value(std::string_view raw,
const eval::Scope& scope) {
CfResolved result;
std::string out;
out.reserve(raw.size());
std::size_t i = 0;
while (i < raw.size()) {
if (raw[i] != '$' || i + 1 >= raw.size() || raw[i + 1] != '{') {
out += raw[i];
++i;
continue;
}
auto end = raw.find('}', i + 2);
if (end == std::string_view::npos) {
out += raw.substr(i);
break;
}
auto inner = raw.substr(i + 2, end - (i - 2));
bool optional = false;
bool required = false;
std::string_view default_val;
parse_cf_suffix(inner, optional, required, default_val);
trim_trailing_spaces(inner);
auto rv = eval::resolve(inner, scope);
if (rv.kind == eval::VarKind::Unknown) {
if (optional) {
out += default_val;
} else if (required) {
result.skip_line = true;
return result;
}
} else {
out += rv.value;
}
i = end + 1;
}
result.value = std::move(out);
return result;
}
// ---------------------------------------------------------------------------
// Write a config file to destdir.
// "default" → skip if file exists; "replace" → always overwrite;
// "merge" → not yet implemented, falls back to replace.
// ---------------------------------------------------------------------------
static void write_config_file(const dsl::ConfigFile& cf,
const eval::Scope& scope,
const fs::path& destdir) {
auto path = destdir / cf.path;
if (cf.mode == "default" && fs::exists(path)) { return; }
std::error_code ec;
fs::create_directories(path.parent_path(), ec);
if (ec) { return; }
std::ofstream out(path);
if (!out) { return; }
out << "# Generated by kappa — do not edit manually\n";
for (const auto& [key, raw_val] : cf.entries) {
auto resolved = resolve_cf_value(raw_val, scope);
if (resolved.skip_line) { continue; }
const auto& val = resolved.value;
if (val.contains(' ') || val.empty()) {
out << key << " = \"" << val << "\"\n";
} else {
out << key << " = " << val << "\n";
}
}
}
// ---------------------------------------------------------------------------
// Build variable scope from a resolved build step.
// ---------------------------------------------------------------------------
static eval::Scope build_scope(const resolve::BuildStep& step,
const fs::path& destdir,
int jobs) {
eval::Scope scope = eval::make_default_scope();
const auto& pkg = *step.package;
const auto& resolved = step.resolved;
scope.builtins["prefix"] = resolved.config.contains("prefix")
? resolved.config.at("prefix") : "/usr";
scope.builtins["jobs"] = std::to_string(jobs);
scope.builtins["jobopts"] = std::format("-j{}", jobs);
scope.builtins["destdir"] = destdir.string();
scope.builtins["enabledinit"] = step.enabled_init;
scope.config = resolved.config;
if (!scope.config.contains("prefix")) {
scope.config["prefix"] = scope.builtins["prefix"];
}
scope.features = resolved.features;
scope.package["name"] = pkg.name;
scope.package["version"] = pkg.version;
scope.package["source"] = pkg.source;
return scope;
}
// ---------------------------------------------------------------------------
// Snapshot current process environment into a map.
// ---------------------------------------------------------------------------
static std::unordered_map<std::string, std::string> capture_env() {
std::unordered_map<std::string, std::string> env;
for (char** envp = ::environ; *envp != nullptr; ++envp) {
std::string_view entry(*envp);
auto eq = entry.find('=');
if (eq != std::string_view::npos) {
env.emplace(std::string(entry.substr(0, eq)),
std::string(entry.substr(eq + 1)));
}
}
return env;
}
// ---------------------------------------------------------------------------
// Apply package env block to a mutable env map.
// ---------------------------------------------------------------------------
static void apply_package_env(
std::unordered_map<std::string, std::string>& env,
const std::vector<dsl::EnvEntry>& entries) {
for (const auto& e : entries) {
switch (e.mode) {
case dsl::EnvMode::Append: {
auto it = env.find(e.key);
if (it != env.end() && !it->second.empty()) {
it->second += ' ';
}
env[e.key] += e.value;
break;
}
case dsl::EnvMode::Soft:
env.try_emplace(e.key, e.value);
break;
case dsl::EnvMode::Set:
env[e.key] = e.value;
break;
}
}
}
// ---------------------------------------------------------------------------
// Public API — build a single package.
// ---------------------------------------------------------------------------
BuildResult build(const resolve::BuildStep& step,
const std::string& work_dir,
int jobs) {
BuildResult result;
const auto& pkg = *step.package;
// Workspace
fs::path work(work_dir);
fs::path destdir = work / "destdir";
std::error_code ec;
fs::create_directories(destdir, ec);
if (ec) {
result.phase = "setup";
result.error = std::format("cannot create destdir {}: {}",
destdir.string(), ec.message());
return result;
}
// Variable scope
auto scope = build_scope(step, destdir, jobs);
// Environment
auto env = capture_env();
apply_package_env(env, pkg.env_entries);
for (const auto& e : pkg.env_entries) {
switch (e.mode) {
case dsl::EnvMode::Append: {
const char* existing = getenv(e.key.c_str());
std::string val = existing ? std::string(existing) + " " + e.value : e.value;
setenv(e.key.c_str(), val.c_str(), 1);
break;
}
case dsl::EnvMode::Soft:
setenv(e.key.c_str(), e.value.c_str(), 0);
break;
case dsl::EnvMode::Set:
setenv(e.key.c_str(), e.value.c_str(), 1);
break;
}
}
// Config files
for (const auto& cf : pkg.config_files) {
write_config_file(cf, scope, destdir);
}
// Phases
auto run_phase = [&](const dsl::Phase& phase,
const char* phase_name) -> bool {
if (phase.commands.empty()) { return true; }
for (const auto& raw_cmd : phase.commands) {
auto cmd = eval::interpolate(raw_cmd, scope);
if (cmd.empty()) { continue; }
std::cout << std::format(" [{}] {}\n", phase_name, cmd);
int rc = exec_sh(cmd, env, work);
if (rc != 0) {
result.phase = phase_name;
result.error = std::format(
"command exited with code {}: {}", rc, cmd);
return false;
}
}
return true;
};
if (!run_phase(pkg.prepare, "prepare")) { return result; }
if (!run_phase(pkg.build, "build")) { return result; }
if (!run_phase(pkg.check, "check")) { return result; }
if (!run_phase(pkg.install, "install")) { return result; }
result.ok = true;
return result;
}
} // namespace kappa::build
+8 -2
View File
@@ -33,6 +33,8 @@ static const std::unordered_map<std::string_view, TokenType> keywords = {
{"uninstall", TokenType::KwUninstall}, {"uninstall", TokenType::KwUninstall},
{"true", TokenType::KwTrue}, {"true", TokenType::KwTrue},
{"false", TokenType::KwFalse}, {"false", TokenType::KwFalse},
{"index", TokenType::KwIndex},
{"repos", TokenType::KwRepos},
}; };
std::string_view token_name(TokenType type) { std::string_view token_name(TokenType type) {
@@ -42,6 +44,7 @@ std::string_view token_name(TokenType type) {
case TokenType::Lbrace: return "{"; case TokenType::Lbrace: return "{";
case TokenType::Rbrace: return "}"; case TokenType::Rbrace: return "}";
case TokenType::Equals: return "="; case TokenType::Equals: return "=";
case TokenType::Plus: return "+";
case TokenType::Lbracket: return "["; case TokenType::Lbracket: return "[";
case TokenType::Rbracket: return "]"; case TokenType::Rbracket: return "]";
case TokenType::Comma: return ","; case TokenType::Comma: return ",";
@@ -73,6 +76,8 @@ std::string_view token_name(TokenType type) {
case TokenType::KwUninstall: return "uninstall"; case TokenType::KwUninstall: return "uninstall";
case TokenType::KwTrue: return "true"; case TokenType::KwTrue: return "true";
case TokenType::KwFalse: return "false"; case TokenType::KwFalse: return "false";
case TokenType::KwIndex: return "index";
case TokenType::KwRepos: return "repos";
} }
return "?"; return "?";
} }
@@ -143,7 +148,7 @@ Token Lexer::next() {
return {TokenType::Newline, "\n", token_start_line_, token_start_col_}; return {TokenType::Newline, "\n", token_start_line_, token_start_col_};
} }
if (c == '"') { return scan_string(); } if (c == '"') { return scan_string(); }
if (c == '{' || c == '}' || c == '=' || c == '[' || c == ']' || c == ',') { if (c == '{' || c == '}' || c == '=' || c == '+' || c == '[' || c == ']' || c == ',') {
return scan_symbol(); return scan_symbol();
} }
return scan_ident(); return scan_ident();
@@ -155,7 +160,7 @@ Token Lexer::scan_ident() {
while (pos_ < source_.size()) { while (pos_ < source_.size()) {
char c = peek(); char c = peek();
if (std::isspace(static_cast<unsigned char>(c))) { break; } if (std::isspace(static_cast<unsigned char>(c))) { break; }
if (c == '"' || c == '=' || c == '[' || c == ']' || c == ',') { break; } if (c == '"' || c == '=' || c == '+' || c == '[' || c == ']' || c == ',') { break; }
lexeme += advance(); lexeme += advance();
} }
@@ -197,6 +202,7 @@ Token Lexer::scan_symbol() {
case '{': return {TokenType::Lbrace, "{", token_start_line_, token_start_col_}; case '{': return {TokenType::Lbrace, "{", token_start_line_, token_start_col_};
case '}': return {TokenType::Rbrace, "}", token_start_line_, token_start_col_}; case '}': return {TokenType::Rbrace, "}", token_start_line_, token_start_col_};
case '=': return {TokenType::Equals, "=", token_start_line_, token_start_col_}; case '=': return {TokenType::Equals, "=", token_start_line_, token_start_col_};
case '+': return {TokenType::Plus, "+", token_start_line_, token_start_col_};
case '[': return {TokenType::Lbracket, "[", token_start_line_, token_start_col_}; case '[': return {TokenType::Lbracket, "[", token_start_line_, token_start_col_};
case ']': return {TokenType::Rbracket, "]", token_start_line_, token_start_col_}; case ']': return {TokenType::Rbracket, "]", token_start_line_, token_start_col_};
case ',': return {TokenType::Comma, ",", token_start_line_, token_start_col_}; case ',': return {TokenType::Comma, ",", token_start_line_, token_start_col_};
+10 -6
View File
@@ -224,16 +224,20 @@ void Parser::parse_body(PackageDef& pkg) {
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) { while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
if (at(TokenType::Newline)) { advance(); continue; } if (at(TokenType::Newline)) { advance(); continue; }
auto key = consume(TokenType::Ident).lexeme; auto key = consume(TokenType::Ident).lexeme;
bool soft = false; EnvMode mode = EnvMode::Set;
if (at(TokenType::Ident) && current_.lexeme == "?") { if (at(TokenType::Plus)) {
advance(); advance();
consume(TokenType::Equals); consume(TokenType::Equals);
soft = true; mode = EnvMode::Append;
} else if (at(TokenType::Ident) && current_.lexeme == "?") {
advance();
consume(TokenType::Equals);
mode = EnvMode::Soft;
} else { } else {
consume(TokenType::Equals); consume(TokenType::Equals);
} }
pkg.env_entries.push_back( pkg.env_entries.push_back(
{std::move(key), consume(TokenType::String).lexeme, soft}); {std::move(key), consume(TokenType::String).lexeme, mode});
skip_newlines(); skip_newlines();
} }
consume(TokenType::Rbrace); consume(TokenType::Rbrace);
@@ -382,9 +386,9 @@ Dependency Parser::parse_dependency_item() {
auto raw = consume(TokenType::String).lexeme; auto raw = consume(TokenType::String).lexeme;
auto colon = raw.find(':'); auto colon = raw.find(':');
if (colon != std::string::npos) { if (colon != std::string::npos) {
return {raw.substr(0, colon), "", raw.substr(colon + 1)}; return {raw.substr(0, colon), "", raw.substr(colon + 1), ""};
} }
return {std::move(raw), "", ""}; return {std::move(raw), "", "", ""};
} }
consume(TokenType::Lbrace); consume(TokenType::Lbrace);
+165 -5
View File
@@ -1,6 +1,7 @@
#include "kappa/dsl/system.hpp" #include "kappa/dsl/system.hpp"
#include "kappa/dsl/error.hpp" #include "kappa/dsl/error.hpp"
#include "kappa/dsl/lexer.hpp" #include "kappa/dsl/lexer.hpp"
#include "kappa/dsl/parser.hpp"
#include <filesystem> #include <filesystem>
#include <format> #include <format>
@@ -29,6 +30,7 @@ private:
void parse_boot_block(SystemConfig& cfg); void parse_boot_block(SystemConfig& cfg);
void parse_users_block(SystemConfig& cfg); void parse_users_block(SystemConfig& cfg);
void parse_groups_block(SystemConfig& cfg); void parse_groups_block(SystemConfig& cfg);
void parse_repos_block(SystemConfig& cfg);
std::string consume_ident(); std::string consume_ident();
std::string consume_string(); std::string consume_string();
@@ -85,7 +87,9 @@ std::string SysParser::consume_ident() {
at(TokenType::KwCheck) || at(TokenType::KwCheck) ||
at(TokenType::KwInstall) || at(TokenType::KwInstall) ||
at(TokenType::KwTrue) || at(TokenType::KwTrue) ||
at(TokenType::KwFalse)) { at(TokenType::KwFalse) ||
at(TokenType::KwIndex) ||
at(TokenType::KwRepos)) {
auto lexeme = current_.lexeme; auto lexeme = current_.lexeme;
advance(); advance();
return lexeme; return lexeme;
@@ -184,6 +188,7 @@ SystemConfig SysParser::parse() {
else if (kw == "boot") { parse_boot_block(cfg); } else if (kw == "boot") { parse_boot_block(cfg); }
else if (kw == "users") { parse_users_block(cfg); } else if (kw == "users") { parse_users_block(cfg); }
else if (kw == "groups") { parse_groups_block(cfg); } else if (kw == "groups") { parse_groups_block(cfg); }
else if (kw == "repos") { parse_repos_block(cfg); }
else { else {
throw ParseError(current_.line, current_.col, throw ParseError(current_.line, current_.col,
std::format("unknown section '{}'", kw)); std::format("unknown section '{}'", kw));
@@ -214,15 +219,19 @@ void SysParser::parse_system_block(SystemConfig& cfg) {
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) { while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
if (at(TokenType::Newline)) { advance(); continue; } if (at(TokenType::Newline)) { advance(); continue; }
auto k = consume_ident(); auto k = consume_ident();
bool soft = false; EnvMode mode = EnvMode::Set;
if (at(TokenType::Ident) && current_.lexeme == "?") { if (at(TokenType::Plus)) {
advance(); advance();
consume(TokenType::Equals); consume(TokenType::Equals);
soft = true; mode = EnvMode::Append;
} else if (at(TokenType::Ident) && current_.lexeme == "?") {
advance();
consume(TokenType::Equals);
mode = EnvMode::Soft;
} else { } else {
consume(TokenType::Equals); consume(TokenType::Equals);
} }
cfg.system.env.push_back({std::move(k), consume_string(), soft}); cfg.system.env.push_back({std::move(k), consume_string(), mode});
skip_newlines(); skip_newlines();
} }
consume(TokenType::Rbrace); consume(TokenType::Rbrace);
@@ -452,6 +461,63 @@ void SysParser::parse_groups_block(SystemConfig& cfg) {
consume(TokenType::Rbrace); consume(TokenType::Rbrace);
} }
void SysParser::parse_repos_block(SystemConfig& cfg) {
consume(TokenType::Lbrace);
skip_newlines();
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
skip_newlines();
if (at(TokenType::Rbrace)) break;
RepoDef repo;
repo.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();
if (key == "url") {
consume(TokenType::Equals);
repo.url = consume_string();
} else if (key == "channels") {
consume(TokenType::Equals);
consume(TokenType::Lbracket);
skip_newlines();
while (!at(TokenType::Rbracket) && !at(TokenType::Eof)) {
repo.channels.push_back(consume_string());
skip_newlines();
if (at(TokenType::Comma)) { consume(TokenType::Comma); }
skip_newlines();
}
consume(TokenType::Rbracket);
} else if (key == "mirrors") {
consume(TokenType::Equals);
consume(TokenType::Lbracket);
skip_newlines();
while (!at(TokenType::Rbracket) && !at(TokenType::Eof)) {
repo.mirrors.push_back(consume_string());
skip_newlines();
if (at(TokenType::Comma)) { consume(TokenType::Comma); }
skip_newlines();
}
consume(TokenType::Rbracket);
} else if (key == "priority") {
consume(TokenType::Equals);
repo.priority = parse_int(current_.line, current_.col,
current_.lexeme);
advance();
}
skip_newlines();
}
consume(TokenType::Rbrace);
}
cfg.repos.push_back(std::move(repo));
}
consume(TokenType::Rbrace);
}
SystemConfig parse_system_config(std::string_view source) { SystemConfig parse_system_config(std::string_view source) {
SysParser p(source); SysParser p(source);
return p.parse(); return p.parse();
@@ -462,6 +528,7 @@ static void merge_config(SystemConfig& base, SystemConfig&& imported) {
if (!imported.system.timezone.empty()) { base.system.timezone = std::move(imported.system.timezone); } 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& 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& r : imported.remotes) { base.remotes.push_back(std::move(r)); }
for (auto& r : imported.repos) { base.repos.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.config) { base.system.config[k] = std::move(v); }
for (auto& [k, v] : imported.system.features) { base.system.features[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; } if (imported.system.rollback.keep > 0) { base.system.rollback.keep = imported.system.rollback.keep; }
@@ -493,4 +560,97 @@ SystemConfig resolve_imports(const SystemConfig& cfg, const std::string& base_di
return resolved; return resolved;
} }
IndexDef parse_index(std::string_view source) {
Lexer lexer(source);
Token current;
auto advance = [&] { current = lexer.next(); };
advance();
auto skip_newlines = [&] {
while (current.type == TokenType::Newline) { advance(); }
};
auto consume = [&](TokenType type) -> Token {
if (current.type != type) {
throw ParseError(current.line, current.col,
msg_expected(token_name(type),
token_name(current.type)));
}
Token t = std::move(current);
advance();
return t;
};
skip_newlines();
consume(TokenType::KwIndex);
// index "kappa-os/stable" { ... }
auto name_tok = consume(TokenType::String);
consume(TokenType::Lbrace);
IndexDef idx;
idx.name = std::move(name_tok.lexeme);
skip_newlines();
while (current.type != TokenType::Rbrace && current.type != TokenType::Eof) {
skip_newlines();
if (current.type == TokenType::Rbrace) break;
IndexEntry entry;
entry.name = current.lexeme;
advance();
if (current.type == TokenType::Lbrace) {
advance();
skip_newlines();
while (current.type != TokenType::Rbrace && current.type != TokenType::Eof) {
if (current.type == TokenType::Newline) { advance(); continue; }
auto key = current.lexeme;
advance();
consume(TokenType::Equals);
if (key == "version") {
entry.version = consume(TokenType::String).lexeme;
}
skip_newlines();
}
consume(TokenType::Rbrace);
}
idx.packages.push_back(std::move(entry));
skip_newlines();
}
consume(TokenType::Rbrace);
return idx;
}
IndexDef build_index(const std::string& directory) {
IndexDef idx;
auto dirname = std::filesystem::path(directory).filename().string();
// If the directory is empty, drop the "."
if (dirname.empty() || dirname == ".") {
dirname = "local";
}
idx.name = dirname;
std::error_code ec;
for (auto& entry : std::filesystem::directory_iterator(directory, ec)) {
if (ec) break;
if (!entry.is_regular_file()) continue;
auto ext = entry.path().extension().string();
if (ext != ".kap") continue;
std::ifstream in(entry.path());
if (!in) continue;
std::ostringstream buf;
buf << in.rdbuf();
try {
auto pkg = parse(buf.str());
idx.packages.push_back({pkg.name, pkg.version});
} catch (...) {
continue;
}
}
return idx;
}
} // namespace kappa::dsl } // namespace kappa::dsl
+19 -6
View File
@@ -19,8 +19,13 @@ using namespace std::string_view_literals;
static int exec_cmd(const std::vector<std::string>& argv) { static int exec_cmd(const std::vector<std::string>& argv) {
if (argv.empty()) { return -1; } if (argv.empty()) { return -1; }
std::vector<std::vector<char>> argv_storage(argv.size());
std::vector<char*> cargs; std::vector<char*> cargs;
for (auto& a : argv) { cargs.push_back(const_cast<char*>(a.c_str())); } for (size_t i = 0; i < argv.size(); ++i) {
argv_storage[i].assign(argv[i].begin(), argv[i].end());
argv_storage[i].push_back('\0');
cargs.push_back(argv_storage[i].data());
}
cargs.push_back(nullptr); cargs.push_back(nullptr);
pid_t pid = fork(); pid_t pid = fork();
@@ -45,8 +50,13 @@ static std::string exec_capture(const std::vector<std::string>& argv) {
dup2(pipefd[1], STDOUT_FILENO); dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[1]); close(pipefd[1]);
std::vector<std::vector<char>> argv_storage(argv.size());
std::vector<char*> cargs; std::vector<char*> cargs;
for (auto& a : argv) { cargs.push_back(const_cast<char*>(a.c_str())); } for (size_t i = 0; i < argv.size(); ++i) {
argv_storage[i].assign(argv[i].begin(), argv[i].end());
argv_storage[i].push_back('\0');
cargs.push_back(argv_storage[i].data());
}
cargs.push_back(nullptr); cargs.push_back(nullptr);
execvp(cargs[0], cargs.data()); execvp(cargs[0], cargs.data());
_exit(127); _exit(127);
@@ -174,7 +184,7 @@ FetchResult fetch(const dsl::PackageDef& pkg) {
} }
} }
} }
if (!verified && !pkg.sha256.empty()) { if (!verified && (!pkg.sha256.empty() || !pkg.sha512.empty() || !pkg.md5.empty())) {
result.error = "hash verification failed"; result.error = "hash verification failed";
return result; return result;
} }
@@ -186,8 +196,7 @@ FetchResult fetch(const dsl::PackageDef& pkg) {
if (rc2 != 0) { result.error = "extraction failed"; return result; } if (rc2 != 0) { result.error = "extraction failed"; return result; }
} else { } else {
int rc2 = exec_cmd({"tar", "xf", dest_file.string(), int rc2 = exec_cmd({"tar", "xf", dest_file.string(),
"-C", paths::temp_dir().string(), "-C", paths::temp_dir().string()});
"--no-same-owner", "--no-same-permissions"});
if (rc2 != 0) { result.error = "extraction failed"; return result; } if (rc2 != 0) { result.error = "extraction failed"; return result; }
} }
@@ -215,8 +224,12 @@ FetchResult fetch(const dsl::PackageDef& pkg) {
} }
} }
exec_cmd({"patch", "-p" + std::to_string(patch.level), int prc = exec_cmd({"patch", "-p" + std::to_string(patch.level),
"-d", result.work_dir.string(), "-i", patch_file}); "-d", result.work_dir.string(), "-i", patch_file});
if (prc != 0) {
result.error = "patch failed: " + patch.url;
return result;
}
} }
return result; return result;
+232
View File
@@ -125,4 +125,236 @@ RecipeResult fetch_recipe(const std::string& name,
return result; return result;
} }
// ---------------------------------------------------------------------------
// Index-aware recipe fetching
// ---------------------------------------------------------------------------
static std::string sanitize_filename(const std::string& s) {
std::string out = s;
for (auto& c : out) {
if (c == '/' || c == '\\' || c == ':') c = '_';
}
return out;
}
static int exec_curl_conditional(const std::string& output_path,
const std::string& url,
const std::string& time_cond_path) {
std::vector<std::string> argv = {"curl", "-Lsf", "-o", output_path};
if (!time_cond_path.empty()) {
argv.push_back("-z");
argv.push_back(time_cond_path);
}
argv.push_back(url);
std::vector<std::vector<char>> argv_storage(argv.size());
std::vector<char*> cargs;
for (size_t i = 0; i < argv.size(); ++i) {
argv_storage[i].assign(argv[i].begin(), argv[i].end());
argv_storage[i].push_back('\0');
cargs.push_back(argv_storage[i].data());
}
cargs.push_back(nullptr);
pid_t pid = fork();
if (pid == 0) {
execvp(cargs[0], cargs.data());
_exit(127);
}
if (pid < 0) return -1;
int status = 0;
while (waitpid(pid, &status, 0) == -1 && errno == EINTR) {}
return WIFEXITED(status) ? WEXITSTATUS(status) : -1;
}
static std::optional<dsl::IndexDef> load_cached_index(
const std::string& repo_name,
const std::string& channel) {
auto key = sanitize_filename(repo_name + "-" + channel) + ".kap";
auto path = paths::cache_dir() / "indexes" / key;
if (!std::filesystem::exists(path)) return std::nullopt;
std::ifstream in(path);
if (!in) return std::nullopt;
std::ostringstream buf;
buf << in.rdbuf();
try {
return dsl::parse_index(buf.str());
} catch (...) {
return std::nullopt;
}
}
static bool download_index(const dsl::RepoDef& repo,
const std::string& channel,
const std::filesystem::path& cache_path) {
auto url = repo.url;
if (!url.empty() && url.back() != '/') url += '/';
url += channel + "/index.kap";
auto temp = cache_path.string() + ".tmp";
// Try primary URL
int rc = exec_curl_conditional(temp, url, cache_path.string());
if (rc != 0) {
// Try mirrors
for (auto& mirror : repo.mirrors) {
auto murl = mirror;
if (!murl.empty() && murl.back() != '/') murl += '/';
murl += channel + "/index.kap";
rc = exec_curl_conditional(temp, murl, "");
if (rc == 0) break;
}
}
if (rc != 0) return false;
// If curl returned 0 but the file might be empty (304 not modified),
// move temp to cache only if it has content
std::error_code ec;
if (std::filesystem::file_size(temp, ec) > 0 || ec) {
std::filesystem::rename(temp, cache_path, ec);
return !ec;
}
// 304: no change, remove temp
std::filesystem::remove(temp, ec);
return true; // cache is still valid
}
static std::optional<std::string> download_recipe(
const std::string& name,
const dsl::RepoDef& repo,
const std::string& channel) {
auto remote_path = channel + "/" + name + ".kap";
auto temp = paths::temp_dir() / (name + ".kap.tmp");
// Build URL list: primary + mirrors
std::vector<std::string> urls;
auto url = repo.url;
if (!url.empty() && url.back() != '/') url += '/';
urls.push_back(url + remote_path);
for (auto& mirror : repo.mirrors) {
auto murl = mirror;
if (!murl.empty() && murl.back() != '/') murl += '/';
urls.push_back(murl + remote_path);
}
for (auto& u : urls) {
int rc = exec_curl_conditional(temp.string(), u, "");
if (rc == 0) {
std::ifstream in(temp);
if (!in) { std::filesystem::remove(temp); continue; }
std::ostringstream buf;
buf << in.rdbuf();
in.close();
std::filesystem::remove(temp);
return buf.str();
}
}
return std::nullopt;
}
RecipeResult fetch_recipe_from_repos(
const std::string& name,
const std::vector<dsl::RepoDef>& repos) {
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 (...) {}
}
}
// Ensure indexes cache directory exists
std::error_code ec;
std::filesystem::create_directories(paths::cache_dir() / "indexes", ec);
// Sort repos by priority (descending)
std::vector<const dsl::RepoDef*> sorted;
for (auto& r : repos) sorted.push_back(&r);
std::sort(sorted.begin(), sorted.end(),
[](auto* a, auto* b) { return a->priority > b->priority; });
std::string best_content;
std::string best_version;
for (auto* repo : sorted) {
for (auto& channel : repo->channels) {
// Check/update the index for this repo+channel
auto idx_key = sanitize_filename(repo->name + "-" + channel);
auto idx_cache = paths::cache_dir() / "indexes" / (idx_key + ".kap");
// Download fresh index (conditional on cache)
if (!download_index(*repo, channel, idx_cache)) continue;
// Load and search the index
auto idx = load_cached_index(repo->name, channel);
if (!idx) continue;
bool found = false;
for (auto& entry : idx->packages) {
if (entry.name != name) continue;
found = true;
break;
}
if (!found) continue;
// Download the recipe
auto content = download_recipe(name, *repo, channel);
if (!content) continue;
// Parse the downloaded recipe to get version
std::string remote_version;
try {
auto pkg = dsl::parse(*content);
remote_version = pkg.version;
} catch (...) {
continue;
}
if (remote_version > best_version) {
best_version = remote_version;
best_content = std::move(*content);
}
}
}
if (best_content.empty()) {
if (!cached_version.empty()) {
result.ok = true;
result.path = cache_path.string();
result.version = cached_version;
return result;
}
result.error = "package '" + name + "' not found in any repo";
return result;
}
// Update cache if remote is newer
if (best_version > cached_version || cached_version.empty()) {
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 } // namespace kappa::fetch
+70 -4
View File
@@ -1,6 +1,7 @@
#include "kappa/install/install.hpp" #include "kappa/install/install.hpp"
#include "kappa/paths.hpp" #include "kappa/paths.hpp"
#include <algorithm>
#include <cstdint> #include <cstdint>
#include <filesystem> #include <filesystem>
#include <format> #include <format>
@@ -11,6 +12,42 @@ namespace kappa::install {
namespace fs = std::filesystem; namespace fs = std::filesystem;
std::string compute_config_hash(
const std::unordered_map<std::string, dsl::FeatureDef>& features,
const std::unordered_map<std::string, std::string>& config) {
// Serialize features (sorted by key): "key1=enabled/flag|key2=..."
std::vector<std::pair<std::string_view, const dsl::FeatureDef*>> feat_sorted;
for (auto& [k, v] : features) feat_sorted.emplace_back(k, &v);
std::sort(feat_sorted.begin(), feat_sorted.end());
std::string serialized;
for (auto& [k, f] : feat_sorted) {
if (!serialized.empty()) serialized += '|';
serialized += k;
serialized += '=';
if (f->force) serialized += "force:";
serialized += f->enabled ? "1:" : "0:";
serialized += f->flag;
}
serialized += '\n';
// Serialize config (sorted by key): "key1=val1|key2=val2"
std::vector<std::pair<std::string_view, std::string_view>> cfg_sorted;
for (auto& [k, v] : config) cfg_sorted.emplace_back(k, v);
std::sort(cfg_sorted.begin(), cfg_sorted.end());
for (auto& [k, v] : cfg_sorted) {
serialized += k;
serialized += '=';
serialized += v;
serialized += '|';
}
// FNV-1a 64-bit hash
std::uint64_t h = 14695981039346656037ULL;
for (char c : serialized) {
h ^= static_cast<std::uint64_t>(static_cast<unsigned char>(c));
h *= 1099511628211ULL;
}
return std::format("{:016x}", h);
}
static std::string db_file() { static std::string db_file() {
return (fs::path(paths::db_dir()) / "installed").string(); return (fs::path(paths::db_dir()) / "installed").string();
} }
@@ -56,8 +93,12 @@ InstallResult install(const resolve::BuildStep& step,
} }
auto entries = read_installed(); auto entries = read_installed();
std::string ch = "0000000000000000";
if (!step.resolved.features.empty() || !step.resolved.config.empty()) {
ch = compute_config_hash(step.resolved.features, step.resolved.config);
}
entries.push_back({step.name, step.resolved.original.version, entries.push_back({step.name, step.resolved.original.version,
hash, step.resolved.original.provides}); hash, ch, step.resolved.original.provides});
if (!write_installed(entries)) { if (!write_installed(entries)) {
result.error = "failed to write installed DB"; result.error = "failed to write installed DB";
return result; return result;
@@ -82,6 +123,19 @@ std::vector<DbEntry> read_installed() {
std::istringstream iss(line); std::istringstream iss(line);
DbEntry e; DbEntry e;
iss >> e.name >> e.version >> e.hash; iss >> e.name >> e.version >> e.hash;
std::string token;
auto pos = iss.tellg();
if (iss >> token && token.size() == 16
&& std::all_of(token.begin(), token.end(), [](char c) {
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f');
})) {
e.config_hash = token;
} else {
if (pos != std::streampos(-1)) {
iss.clear();
iss.seekg(pos);
}
}
std::string prov; std::string prov;
while (iss >> prov) { e.provides.push_back(prov); } while (iss >> prov) { e.provides.push_back(prov); }
entries.push_back(std::move(e)); entries.push_back(std::move(e));
@@ -92,14 +146,26 @@ std::vector<DbEntry> read_installed() {
bool write_installed(const std::vector<DbEntry>& entries) { bool write_installed(const std::vector<DbEntry>& entries) {
std::error_code ec; std::error_code ec;
fs::create_directories(paths::db_dir(), ec); fs::create_directories(paths::db_dir(), ec);
std::ofstream out(db_file()); auto tmp = db_file() + ".tmp";
if (!out) { return false; } {
std::ofstream out(tmp);
if (!out) return false;
for (auto& e : entries) { for (auto& e : entries) {
out << e.name << ' ' << e.version << ' ' << e.hash; out << e.name << ' ' << e.version << ' ' << e.hash;
if (!e.config_hash.empty() && e.config_hash != "0000000000000000") {
out << ' ' << e.config_hash;
}
for (auto& p : e.provides) { out << ' ' << p; } for (auto& p : e.provides) { out << ' ' << p; }
out << '\n'; out << '\n';
} }
return true; out.close();
if (out.fail()) {
std::filesystem::remove(tmp, ec);
return false;
}
}
std::filesystem::rename(tmp, db_file(), ec);
return !ec;
} }
bool record_generation(const std::vector<std::string>& hashes, int keep) { bool record_generation(const std::vector<std::string>& hashes, int keep) {
+372 -86
View File
@@ -9,16 +9,23 @@
#include "kappa/paths.hpp" #include "kappa/paths.hpp"
#include "kappa/rebuild/rebuild.hpp" #include "kappa/rebuild/rebuild.hpp"
#include "kappa/resolve/plan.hpp" #include "kappa/resolve/plan.hpp"
#include "kappa/boot/bootloader.hpp"
#include "kappa/boot/types.hpp" #include "kappa/boot/types.hpp"
#include "kappa/sched/scheduler.hpp"
#include "kappa/service/service.hpp"
#include "kappa/service/types.hpp" #include "kappa/service/types.hpp"
#include "kappa/system/activate.hpp"
#include "kappa/tools/doctor.hpp" #include "kappa/tools/doctor.hpp"
#include "kappa/tools/format.hpp" #include "kappa/tools/format.hpp"
#include <algorithm>
#include <cstdlib> #include <cstdlib>
#include <filesystem> #include <filesystem>
#include <format>
#include <fstream> #include <fstream>
#include <iostream> #include <iostream>
#include <sstream> #include <sstream>
#include <stdexcept>
#include <string_view> #include <string_view>
using namespace std::string_view_literals; using namespace std::string_view_literals;
@@ -45,10 +52,17 @@ Subcommands:
rebuild <config> Compare config to installed state, rebuild changed rebuild <config> Compare config to installed state, rebuild changed
list List installed packages list List installed packages
rollback Show available generations rollback Show available generations
index <dir> Build an index.kap from .kap files in a directory
add <pkg> [v] Add a package to system config (optional version)
remove <pkg> Remove a package from system config
Options: Options:
-h, --help Show this help message -h, --help Show this help message
-V, --version Show version information -V, --version Show version information
-j, --jobs <n> Jobs per package (default: 1)
-w <n> Concurrent package builds (default: 1)
--root <path> Set kappa root directory (default: /usr/local/kappa)
--dry-run Report changes without building (rebuild only)
)"; )";
} }
@@ -59,8 +73,7 @@ static bool is_flag(std::string_view arg) {
static std::string read_file(const char* path) { static std::string read_file(const char* path) {
std::ifstream in(path); std::ifstream in(path);
if (!in) { if (!in) {
std::cerr << "error: cannot open '" << path << "'\n"; throw std::runtime_error(std::format("cannot open '{}'", path));
std::exit(1);
} }
std::ostringstream buf; std::ostringstream buf;
buf << in.rdbuf(); buf << in.rdbuf();
@@ -114,12 +127,33 @@ static resolve::Registry build_registry(const dsl::SystemConfig& cfg) {
registry[pkg.name] = std::move(pkg); registry[pkg.name] = std::move(pkg);
found = true; found = true;
break; break;
} catch (const std::exception& e) {
std::cerr << "warning: parse error in " << sp << ": " << e.what() << "\n";
} catch (...) { } catch (...) {
continue; std::cerr << "warning: unknown parse error in " << sp << "\n";
} }
} }
// If not found locally, try remotes // If not found locally, try repos (index-aware) first, then legacy remotes
if (!found && !cfg.repos.empty()) {
auto result = fetch::fetch_recipe_from_repos(pref.name, cfg.repos);
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 (const std::exception& e) {
std::cerr << "warning: parse error in remote recipe " << pref.name << ": " << e.what() << "\n";
} catch (...) {
std::cerr << "warning: unknown parse error in remote recipe " << pref.name << "\n";
}
}
}
}
if (!found && !cfg.remotes.empty()) { if (!found && !cfg.remotes.empty()) {
auto result = fetch::fetch_recipe(pref.name, cfg.remotes); auto result = fetch::fetch_recipe(pref.name, cfg.remotes);
if (result.ok && !result.path.empty()) { if (result.ok && !result.path.empty()) {
@@ -131,7 +165,11 @@ static resolve::Registry build_registry(const dsl::SystemConfig& cfg) {
auto pkg = dsl::parse(buf.str()); auto pkg = dsl::parse(buf.str());
registry[pkg.name] = std::move(pkg); registry[pkg.name] = std::move(pkg);
found = true; found = true;
} catch (...) {} } catch (const std::exception& e) {
std::cerr << "warning: parse error in remote recipe " << pref.name << ": " << e.what() << "\n";
} catch (...) {
std::cerr << "warning: unknown parse error in remote recipe " << pref.name << "\n";
}
} }
} }
} }
@@ -146,15 +184,20 @@ static resolve::Registry build_registry(const dsl::SystemConfig& cfg) {
} }
int main(int argc, char* argv[]) { int main(int argc, char* argv[]) {
paths::ensure_directories();
if (argc < 2) { if (argc < 2) {
std::cerr << "kappa: missing subcommand\n\n"; std::cerr << "kappa: missing subcommand\n\n";
print_usage(); print_usage();
return 1; return 1;
} }
auto subcommand = std::string_view(argv[1]); // Parse leading --root before subcommand
int arg_start = 1;
while (arg_start < argc && std::string_view(argv[arg_start]) == "--root" && arg_start + 1 < argc) {
paths::set_root(argv[arg_start + 1]);
arg_start += 2;
}
auto subcommand = std::string_view(argv[arg_start]);
if (subcommand == "-h" || subcommand == "--help") { if (subcommand == "-h" || subcommand == "--help") {
print_usage(); print_usage();
@@ -176,7 +219,10 @@ int main(int argc, char* argv[]) {
|| (subcommand == "rebuild") || (subcommand == "rebuild")
|| (subcommand == "list") || (subcommand == "list")
|| (subcommand == "fetch-package") || (subcommand == "fetch-package")
|| (subcommand == "rollback"); || (subcommand == "rollback")
|| (subcommand == "index")
|| (subcommand == "add")
|| (subcommand == "remove");
if (!valid_subcommand) { if (!valid_subcommand) {
std::cerr << "error: unknown subcommand '" << subcommand << "'\n\n"; std::cerr << "error: unknown subcommand '" << subcommand << "'\n\n";
@@ -185,7 +231,9 @@ int main(int argc, char* argv[]) {
} }
const char* file_arg = nullptr; const char* file_arg = nullptr;
for (int i = 2; i < argc; ++i) { const char* version_arg = nullptr;
bool dry_run = false;
for (int i = arg_start + 1; i < argc; ++i) {
if (std::string_view(argv[i]) == "-h" if (std::string_view(argv[i]) == "-h"
|| std::string_view(argv[i]) == "--help") { || std::string_view(argv[i]) == "--help") {
print_usage(); print_usage();
@@ -197,7 +245,8 @@ int main(int argc, char* argv[]) {
return 0; return 0;
} }
if (std::string_view(argv[i]) == "-j" if (std::string_view(argv[i]) == "-j"
|| std::string_view(argv[i]) == "--jobs") { || std::string_view(argv[i]) == "--jobs"
|| (std::string_view(argv[i]) == "-w" && i + 1 < argc)) {
++i; ++i;
continue; continue;
} }
@@ -205,11 +254,19 @@ int main(int argc, char* argv[]) {
paths::set_root(argv[++i]); paths::set_root(argv[++i]);
continue; continue;
} }
if (std::string_view(argv[i]) == "--dry-run") {
dry_run = true;
continue;
}
if (!is_flag(argv[i])) { if (!is_flag(argv[i])) {
if (file_arg == nullptr) {
file_arg = argv[i]; file_arg = argv[i];
} else if (version_arg == nullptr) {
version_arg = argv[i];
break; break;
} }
} }
}
if (subcommand == "list") { if (subcommand == "list") {
auto entries = install::read_installed(); auto entries = install::read_installed();
@@ -249,13 +306,194 @@ int main(int argc, char* argv[]) {
return 0; return 0;
} }
if (subcommand == "index") {
if (file_arg == nullptr) {
std::cerr << "error: no directory specified\n";
return 1;
}
try {
auto idx = dsl::build_index(file_arg);
auto out_path = std::filesystem::path(file_arg) / "index.kap";
std::ofstream out(out_path);
if (!out) {
std::cerr << "error: cannot write " << out_path.string() << "\n";
return 1;
}
tools::format_index(out, idx);
std::cout << idx.packages.size() << " packages indexed → "
<< out_path.string() << "\n";
return 0;
} catch (const std::exception& e) {
std::cerr << "index error: " << e.what() << "\n";
return 1;
}
}
if (subcommand == "add") {
if (file_arg == nullptr) {
std::cerr << "error: no package name specified\n";
return 1;
}
try {
auto config_path = std::filesystem::path(paths::system_dir()) / "config.kap";
dsl::SystemConfig cfg;
if (std::filesystem::exists(config_path)) {
auto src = read_file(config_path.c_str());
cfg = dsl::parse_system_config(src);
}
bool found = false;
for (auto& p : cfg.packages) {
if (p.name == file_arg) {
found = true;
if (version_arg != nullptr) {
p.version = version_arg;
std::cout << "updated " << file_arg
<< " → version " << version_arg << "\n";
} else {
std::cout << file_arg << " already in packages\n";
}
break;
}
}
if (!found) {
dsl::PackageRef pref;
pref.name = file_arg;
if (version_arg != nullptr) {
pref.version = version_arg;
}
cfg.packages.push_back(std::move(pref));
std::cout << "added " << file_arg;
if (version_arg != nullptr) {
std::cout << " " << version_arg;
}
std::cout << "\n";
}
std::error_code ec;
std::filesystem::create_directories(paths::system_dir(), ec);
std::ofstream out(config_path);
if (!out) {
std::cerr << "error: cannot write " << config_path.string() << "\n";
return 1;
}
tools::format_config(out, cfg);
return 0;
} catch (const std::exception& e) {
std::cerr << "add error: " << e.what() << "\n";
return 1;
}
}
if (subcommand == "remove") {
if (file_arg == nullptr) {
std::cerr << "error: no package name specified\n";
return 1;
}
try {
auto config_path = std::filesystem::path(paths::system_dir()) / "config.kap";
if (!std::filesystem::exists(config_path)) {
std::cout << file_arg << " not found (no config)\n";
return 0;
}
auto src = read_file(config_path.c_str());
auto cfg = dsl::parse_system_config(src);
auto it = std::remove_if(cfg.packages.begin(), cfg.packages.end(),
[&](const dsl::PackageRef& p) { return p.name == file_arg; });
if (it == cfg.packages.end()) {
std::cout << file_arg << " not in packages\n";
return 0;
}
cfg.packages.erase(it, cfg.packages.end());
std::cout << "removed " << file_arg << "\n";
std::ofstream out(config_path);
if (!out) {
std::cerr << "error: cannot write " << config_path.string() << "\n";
return 1;
}
tools::format_config(out, cfg);
return 0;
} catch (const std::exception& e) {
std::cerr << "remove error: " << e.what() << "\n";
return 1;
}
}
if (subcommand == "fetch-package") {
if (file_arg == nullptr) {
std::cerr << "error: no package name specified\n";
return 1;
}
try {
std::vector<std::string> remotes;
std::vector<dsl::RepoDef> repos;
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;
repos = std::move(cfg.repos);
} catch (const std::exception& e) {
std::cerr << "warning: config parse error: " << e.what() << "\n";
} catch (...) {
std::cerr << "warning: unknown config parse error\n";
}
}
fetch::RecipeResult result;
if (!repos.empty()) {
result = fetch::fetch_recipe_from_repos(file_arg, repos);
} else {
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;
}
}
// Subcommands that MUST have writable directories to function.
bool needs_write = (subcommand == "build"
|| subcommand == "fetch" || subcommand == "fetch-package");
// rebuild benefits from cache dirs for registry lookups but
// degrades gracefully — it only reports, never writes.
bool needs_dirs = needs_write || subcommand == "rebuild" || subcommand == "resolve";
if (needs_dirs && !paths::directories_exist()) {
if (!paths::ensure_directories()) {
if (needs_write) {
std::cerr << "error: cannot create kappa directories (check permissions)\n";
return 1;
}
}
}
if (file_arg == nullptr if (file_arg == nullptr
&& subcommand != "list" && subcommand != "list"
&& subcommand != "rollback") { && subcommand != "rollback"
&& subcommand != "index") {
std::cerr << "error: no input file specified\n"; std::cerr << "error: no input file specified\n";
return 1; return 1;
} }
try {
auto source = file_arg ? read_file(file_arg) : ""; auto source = file_arg ? read_file(file_arg) : "";
if (subcommand == "parse-package") { if (subcommand == "parse-package") {
@@ -297,6 +535,12 @@ int main(int argc, char* argv[]) {
} }
std::cout << "\n"; std::cout << "\n";
} }
if (!cfg.repos.empty()) {
int total_channels = 0;
for (auto& r : cfg.repos) total_channels += r.channels.size();
std::cout << " repos: " << cfg.repos.size()
<< " (" << total_channels << " channels)\n";
}
return 0; return 0;
} catch (const std::runtime_error& e) { } catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e); handle_parse_error(file_arg, source, e);
@@ -333,19 +577,27 @@ int main(int argc, char* argv[]) {
return 1; return 1;
} }
return 0; return 0;
} catch (const std::runtime_error&) {
try {
auto idx = dsl::parse_index(source);
std::cout << file_arg << ": valid index ("
<< idx.name << ", "
<< idx.packages.size() << " packages)\n";
return 0;
} catch (const std::runtime_error& e) { } catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e); handle_parse_error(file_arg, source, e);
return 1; return 1;
} }
} }
} }
}
if (subcommand == "build") { if (subcommand == "build") {
try { try {
auto pkg = dsl::parse(source); auto pkg = dsl::parse(source);
int jobs = 1; int jobs = 1;
for (int i = 2; i < argc; ++i) { for (int i = arg_start + 1; i < argc; ++i) {
auto arg = std::string_view(argv[i]); auto arg = std::string_view(argv[i]);
if ((arg == "-j" || arg == "--jobs") && i + 1 < argc) { if ((arg == "-j" || arg == "--jobs") && i + 1 < argc) {
jobs = std::stoi(argv[++i]); jobs = std::stoi(argv[++i]);
@@ -391,12 +643,18 @@ int main(int argc, char* argv[]) {
auto cfg = dsl::parse_system_config(source); auto cfg = dsl::parse_system_config(source);
tools::format_config(std::cout, cfg); tools::format_config(std::cout, cfg);
return 0; return 0;
} catch (const std::runtime_error&) {
try {
auto idx = dsl::parse_index(source);
tools::format_index(std::cout, idx);
return 0;
} catch (const std::runtime_error& e) { } catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e); handle_parse_error(file_arg, source, e);
return 1; return 1;
} }
} }
} }
}
if (subcommand == "doctor") { if (subcommand == "doctor") {
try { try {
@@ -456,6 +714,14 @@ int main(int argc, char* argv[]) {
return 1; return 1;
} }
if (!plan.conflicts.empty()) {
std::cerr << "error: package conflicts detected:\n";
for (auto& c : plan.conflicts) {
std::cerr << " " << c << "\n";
}
return 1;
}
std::cout << plan.steps.size() << " packages in build order:\n"; std::cout << plan.steps.size() << " packages in build order:\n";
for (auto& step : plan.steps) { for (auto& step : plan.steps) {
std::cout << " " << step.name << " (" << step.dependencies.size() std::cout << " " << step.name << " (" << step.dependencies.size()
@@ -493,41 +759,6 @@ 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<std::string> 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") { if (subcommand == "rebuild") {
try { try {
auto cfg = dsl::parse_system_config(source); auto cfg = dsl::parse_system_config(source);
@@ -540,58 +771,108 @@ int main(int argc, char* argv[]) {
return 0; return 0;
} }
if (dry_run) {
// Dry run: report changes without executing
std::cout << cs.added.size() + cs.changed.size()
<< " packages to rebuild\n";
return 0;
}
auto registry = build_registry(cfg);
auto plan = resolve::resolve(cfg, registry);
if (!plan.missing.empty()) {
for (auto& m : plan.missing)
std::cerr << "warning: package '" << m << "' not found in registry\n";
}
if (!plan.cycles.empty()) {
std::cerr << "error: dependency cycle detected:\n";
for (auto& c : plan.cycles) std::cerr << " " << c << "\n";
return 1;
}
if (!plan.conflicts.empty()) {
std::cerr << "error: package conflicts detected:\n";
for (auto& c : plan.conflicts) std::cerr << " " << c << "\n";
return 1;
}
if (plan.steps.empty()) {
std::cout << "nothing to build\n";
return 0;
}
// Parse -w/-j flags after subcommand
int workers = 1, jobs = 1;
for (int i = arg_start + 1; i < argc; ++i) {
auto arg = std::string_view(argv[i]);
if (arg == "-w" && i + 1 < argc) workers = std::stoi(argv[++i]);
if ((arg == "-j" || arg == "--jobs") && i + 1 < argc) jobs = std::stoi(argv[++i]);
}
// Build
std::cout << cs.added.size() + cs.changed.size() std::cout << cs.added.size() + cs.changed.size()
<< " packages to rebuild\n"; << " packages to rebuild\n";
for (auto& name : cs.changed) { auto work_root = paths::temp_dir();
std::cout << " rebuilding " << name << " (changed)\n"; auto sr = sched::run(plan, work_root.string(), workers, jobs);
}
for (auto& name : cs.added) { if (!sr.ok) {
std::cout << " building " << name << " (new)\n"; std::cerr << sr.failed.size() << " packages failed to build\n";
} for (auto& f : sr.failed) std::cerr << " " << f << "\n";
if (cs.kernel_changed) { std::cout << " kernel changed\n"; } return 1;
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); // Install built packages
auto impact = rebuild::compute_init_impact(cfg, registry); for (auto& step : plan.steps) {
if (std::find(sr.built.begin(), sr.built.end(), step.name) == sr.built.end())
continue;
auto work_dir = work_root / step.name;
auto inst = install::install(step, work_dir);
if (!inst.ok) {
std::cerr << "install failed for " << step.name << ": " << inst.error << "\n";
return 1;
}
}
if (!impact.service_rebuild.empty()) { // Generate service files for enabled services
std::cout << " full rebuild (uses ${enabledinit}): " auto is = kappa::service::parse_init_system(cfg.boot.init);
<< impact.service_rebuild.size() if (is != kappa::service::InitSystem::Unknown && !cfg.services.empty()) {
<< " packages\n"; auto resolved = config::resolve_services(cfg, registry);
for (auto& sref : cfg.services) {
if (!sref.enable) continue;
auto it = resolved.find(sref.name);
if (it == resolved.end()) {
std::cerr << "warning: service '" << sref.name << "' not found in any package\n";
continue;
} }
if (!impact.service_only.empty()) { auto spec = service::ServiceSpec::from_service_init(it->second);
std::cout << " service files only: " auto svc_result = service::install_service(is, spec);
<< impact.service_only.size() if (!svc_result.ok)
<< " packages\n"; std::cerr << "service generation failed for " << sref.name << ": " << svc_result.error << "\n";
}
if (!impact.skipped.empty()) {
std::cout << " no services — skipped: "
<< impact.skipped.size() << " packages\n";
} }
} }
// Generate bootloader config
if (cs.bootloader_changed) { if (cs.bootloader_changed) {
auto bl_name = cfg.boot.bootloader; auto bl = kappa::boot::parse_bootloader(cfg.boot.bootloader);
auto bl = kappa::boot::parse_bootloader(bl_name);
if (bl != kappa::boot::Bootloader::Unknown) { if (bl != kappa::boot::Bootloader::Unknown) {
std::cout << " bootloader: " << bl_name << " (" boot::BootSpec boot_spec;
<< kappa::boot::bootloader_description(bl) << ")\n"; boot_spec.kernel_path = cfg.boot.kernel;
} else { boot_spec.init_path = cfg.boot.init;
std::cout << " bootloader changed (" << bl_name boot_spec.root = cfg.boot.root;
<< " — unrecognized)\n"; for (auto& [k, v] : cfg.boot.params)
boot_spec.kernel_params += k + "=" + v + " ";
auto boot_result = boot::install_bootloader_config(bl, boot_spec);
if (!boot_result.ok)
std::cerr << "bootloader config failed: " << boot_result.error << "\n";
} }
} }
if (cs.services_changed) { std::cout << " services changed\n"; }
// Activate system configuration
if (!cfg.system.hostname.empty())
system::write_hostname(cfg.system.hostname);
if (!cfg.system.timezone.empty())
system::write_timezone(cfg.system.timezone);
std::cout << "rebuild complete — " << sr.built.size() << " packages built\n";
return 0; return 0;
} catch (const std::runtime_error& e) { } catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e); handle_parse_error(file_arg, source, e);
@@ -599,5 +880,10 @@ int main(int argc, char* argv[]) {
} }
} }
} catch (const std::exception& e) {
std::cerr << "error: " << e.what() << "\n";
return 1;
}
return 1; // unreachable return 1; // unreachable
} }
+22 -2
View File
@@ -9,7 +9,7 @@ static std::filesystem::path g_root = [] {
if (auto* env = std::getenv("KAPPA_ROOT"); env != nullptr) { if (auto* env = std::getenv("KAPPA_ROOT"); env != nullptr) {
return std::filesystem::path{env}; return std::filesystem::path{env};
} }
return std::filesystem::path{"/kappa"}; return std::filesystem::path{"/usr/local/kappa"};
}(); }();
void set_root(std::string_view path) { g_root = path; } void set_root(std::string_view path) { g_root = path; }
@@ -23,14 +23,34 @@ std::filesystem::path builds_dir() { return g_root / "system" / "builds"; }
std::filesystem::path cache_dir() { return g_root / "cache"; } std::filesystem::path cache_dir() { return g_root / "cache"; }
std::filesystem::path packages_dir() { return cache_dir() / "packages"; } std::filesystem::path packages_dir() { return cache_dir() / "packages"; }
void ensure_directories() { bool ensure_directories() {
std::error_code ec; std::error_code ec;
std::filesystem::create_directories(bin_dir(), ec); std::filesystem::create_directories(bin_dir(), ec);
if (ec) return false;
std::filesystem::create_directories(temp_dir(), ec); std::filesystem::create_directories(temp_dir(), ec);
if (ec) return false;
std::filesystem::create_directories(db_dir(), ec); std::filesystem::create_directories(db_dir(), ec);
if (ec) return false;
std::filesystem::create_directories(system_dir(), ec);
if (ec) return false;
std::filesystem::create_directories(builds_dir(), ec); std::filesystem::create_directories(builds_dir(), ec);
if (ec) return false;
std::filesystem::create_directories(cache_dir(), ec); std::filesystem::create_directories(cache_dir(), ec);
if (ec) return false;
std::filesystem::create_directories(packages_dir(), ec); std::filesystem::create_directories(packages_dir(), ec);
if (ec) return false;
return true;
}
bool directories_exist() {
return std::filesystem::exists(g_root)
&& std::filesystem::exists(bin_dir())
&& std::filesystem::exists(temp_dir())
&& std::filesystem::exists(db_dir())
&& std::filesystem::exists(system_dir())
&& std::filesystem::exists(builds_dir())
&& std::filesystem::exists(cache_dir())
&& std::filesystem::exists(packages_dir());
} }
} // namespace kappa::paths } // namespace kappa::paths
+12 -3
View File
@@ -28,9 +28,18 @@ ChangeSet compute_changes(const dsl::SystemConfig& cfg) {
if (e.name != p.name) { continue; } if (e.name != p.name) { continue; }
found = true; found = true;
bool changed = !p.version.empty() bool changed = false;
|| !p.features.empty() if (!p.version.empty() && p.version != e.version) {
|| !p.config.empty(); changed = true;
}
if (!changed && (!p.features.empty() || !p.config.empty())) {
if (e.config_hash.empty()) {
changed = true;
} else {
auto ch = install::compute_config_hash(p.features, p.config);
changed = (ch != e.config_hash);
}
}
if (changed) { cs.changed.push_back(p.name); } if (changed) { cs.changed.push_back(p.name); }
break; break;
+2 -5
View File
@@ -55,12 +55,9 @@ std::string generate_dinit_service(const ServiceSpec& spec) {
out << std::format("run-as = {}\n", spec.user); out << std::format("run-as = {}\n", spec.user);
} }
// Environment variables (as comments — dinit doesn't support inline env) // Environment variables — dinit uses env-file directive
if (!spec.env.empty()) { if (!spec.env.empty()) {
out << "\n# Environment variables:\n"; out << std::format("env-file = {}.env\n", spec.name);
for (const auto& [key, value] : spec.env) {
out << std::format("# {}={}\n", key, value);
}
} }
// description // description
+12 -1
View File
@@ -110,7 +110,7 @@ ServiceInstallResult install_service(InitSystem is,
return {false, {}, return {false, {},
std::format("Failed to write {}", run_path.string())}; std::format("Failed to write {}", run_path.string())};
} }
out << "#!/bin/execlineb -P\n"; out << s6_execline_shebang;
out << "# Generated by kappa — do not edit manually\n"; out << "# Generated by kappa — do not edit manually\n";
out << std::format("# s6 service: {}\n", spec.name); out << std::format("# s6 service: {}\n", spec.name);
if (!spec.working_dir.empty()) { if (!spec.working_dir.empty()) {
@@ -196,6 +196,17 @@ ServiceInstallResult install_service(InitSystem is,
out << content; out << content;
} }
// Dinit: also write companion .env file
if (is == InitSystem::Dinit && !spec.env.empty()) {
fs::path env_path = fs::path(paths.service_dir) / std::format("{}.env", spec.name);
std::ofstream env_out(env_path);
if (env_out) {
for (const auto& [key, value] : spec.env) {
env_out << key << "=" << value << "\n";
}
}
}
return {true, file_path.string(), {}}; return {true, file_path.string(), {}};
} }
+3 -1
View File
@@ -8,6 +8,8 @@ namespace kappa::service {
namespace { namespace {
static constexpr std::string_view openrc_run_shebang = "#!/sbin/openrc-run\n";
bool is_background_type(std::string_view type) { bool is_background_type(std::string_view type) {
return type == "longrun" || type == "notify" || type == "forking"; return type == "longrun" || type == "notify" || type == "forking";
} }
@@ -18,7 +20,7 @@ std::string generate_openrc_service(const ServiceSpec& spec) {
std::ostringstream os; std::ostringstream os;
// Shebang and header // Shebang and header
os << "#!/sbin/openrc-run\n"; os << openrc_run_shebang;
os << "# Generated by kappa — do not edit manually\n"; os << "# Generated by kappa — do not edit manually\n";
// Description // Description
+1 -1
View File
@@ -19,7 +19,7 @@ std::string generate_s6_service(const ServiceSpec& spec) {
// --- run file content --- // --- run file content ---
std::ostringstream run; std::ostringstream run;
run << "#!/bin/execlineb -P\n"; run << s6_execline_shebang;
run << "# Generated by kappa — do not edit manually\n"; run << "# Generated by kappa — do not edit manually\n";
run << std::format("# s6 service: {}\n", spec.name); run << std::format("# s6 service: {}\n", spec.name);
+9 -8
View File
@@ -3,6 +3,7 @@
#include <algorithm> #include <algorithm>
#include <cctype> #include <cctype>
#include <filesystem>
#include <format> #include <format>
#include <ranges> #include <ranges>
@@ -61,31 +62,31 @@ InitPaths init_paths(InitSystem is, std::string_view prefix) {
switch (is) { switch (is) {
case InitSystem::Systemd: case InitSystem::Systemd:
return { return {
.service_dir = std::format("{}etc/systemd/system", prefix), .service_dir = (std::filesystem::path(prefix) / "etc/systemd/system").string(),
.enable_cmd = "systemctl enable", .enable_cmd = "systemctl enable",
.disable_cmd = "systemctl disable", .disable_cmd = "systemctl disable",
}; };
case InitSystem::OpenRC: case InitSystem::OpenRC:
return { return {
.service_dir = std::format("{}etc/init.d", prefix), .service_dir = (std::filesystem::path(prefix) / "etc/init.d").string(),
.enable_cmd = "rc-update add", .enable_cmd = "rc-update add",
.disable_cmd = "rc-update del", .disable_cmd = "rc-update del",
}; };
case InitSystem::S6: case InitSystem::S6:
return { return {
.service_dir = std::format("{}etc/s6/sv", prefix), .service_dir = (std::filesystem::path(prefix) / "etc/s6/sv").string(),
.enable_cmd = "s6-rc-bundle-update", .enable_cmd = "s6-rc-bundle-update",
.disable_cmd = "s6-rc-bundle-update", .disable_cmd = "s6-rc-bundle-update delete",
}; };
case InitSystem::Runit: case InitSystem::Runit:
return { return {
.service_dir = std::format("{}etc/sv", prefix), .service_dir = (std::filesystem::path(prefix) / "etc/sv").string(),
.enable_cmd = "ln -sf /etc/sv", .enable_cmd = std::format("ln -sf {}/etc/sv/{{name}} {}/var/service/", prefix, prefix),
.disable_cmd = "rm -f /var/service", .disable_cmd = std::format("rm -f {}/var/service/{{name}}", prefix),
}; };
case InitSystem::Dinit: case InitSystem::Dinit:
return { return {
.service_dir = std::format("{}etc/dinit.d", prefix), .service_dir = (std::filesystem::path(prefix) / "etc/dinit.d").string(),
.enable_cmd = "dinitctl enable", .enable_cmd = "dinitctl enable",
.disable_cmd = "dinitctl disable", .disable_cmd = "dinitctl disable",
}; };
+29 -5
View File
@@ -1,8 +1,10 @@
#include "kappa/system/activate.hpp" #include "kappa/system/activate.hpp"
#include <array>
#include <filesystem> #include <filesystem>
#include <format> #include <format>
#include <fstream> #include <fstream>
#include <string_view>
namespace kappa::system { namespace kappa::system {
@@ -33,15 +35,37 @@ ActivateResult write_timezone(const std::string& timezone,
return {false, "timezone is empty"}; return {false, "timezone is empty"};
} }
// /etc/localtime is a symlink to /usr/share/zoneinfo/{timezone} // /etc/localtime is a symlink to zoneinfo data
std::filesystem::path localtime = std::filesystem::path(prefix) / "etc/localtime"; 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; static constexpr std::array<std::string_view, 4> zoneinfo_dirs = {
if (!std::filesystem::exists(zoneinfo, ec)) { "usr/share/zoneinfo", // glibc/FHS standard
return {false, std::format("timezone data not found: {}", zoneinfo.string())}; "etc/zoneinfo", // NixOS
"share/zoneinfo", // Guix, some prefix installs
"usr/lib/zoneinfo", // alternative
};
std::filesystem::path zoneinfo;
for (auto dir : zoneinfo_dirs) {
auto candidate = std::filesystem::path(prefix) / dir / timezone;
if (std::filesystem::exists(candidate)) {
zoneinfo = candidate;
break;
}
} }
if (zoneinfo.empty()) {
std::string tried;
for (size_t i = 0; i < zoneinfo_dirs.size(); ++i) {
if (i > 0) tried += ", ";
tried += (std::filesystem::path(prefix) / zoneinfo_dirs[i] / timezone).string();
}
return {false,
std::format("timezone data '{}' not found in any known location: tried {}",
timezone, tried)};
}
std::error_code ec;
std::filesystem::create_directories(localtime.parent_path(), ec); std::filesystem::create_directories(localtime.parent_path(), ec);
// Remove existing symlink/file if present // Remove existing symlink/file if present
std::filesystem::remove(localtime, ec); std::filesystem::remove(localtime, ec);
-5
View File
@@ -59,11 +59,6 @@ std::vector<Diagnostic> check_package(const dsl::PackageDef& pkg) {
} }
} }
bool has_forced_features = false;
for (auto& [_, f] : pkg.features) {
if (f.force) { has_forced_features = true; break; }
}
if (!pkg.assertions.empty()) { if (!pkg.assertions.empty()) {
diags.push_back({DiagSeverity::Warning, diags.push_back({DiagSeverity::Warning,
std::to_string(pkg.assertions.size()) std::to_string(pkg.assertions.size())
+43 -1
View File
@@ -18,7 +18,13 @@ static void write_env(std::ostream& os, int d,
if (entries.empty()) { return; } if (entries.empty()) { return; }
os << Indent(d) << "env {\n"; os << Indent(d) << "env {\n";
for (auto& e : entries) { for (auto& e : entries) {
os << Indent(d + 1) << e.key << (e.soft ? " ?= " : " = ") const char* op;
switch (e.mode) {
case dsl::EnvMode::Soft: op = " ?= "; break;
case dsl::EnvMode::Append: op = " += "; break;
default: op = " = "; break;
}
os << Indent(d + 1) << e.key << op
<< '"' << e.value << "\"\n"; << '"' << e.value << "\"\n";
} }
os << Indent(d) << "}\n"; os << Indent(d) << "}\n";
@@ -231,6 +237,34 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) {
os << "]\n\n"; os << "]\n\n";
} }
if (!cfg.repos.empty()) {
os << "repos {\n";
for (auto& repo : cfg.repos) {
os << " " << repo.name << " {\n";
os << " url = \"" << repo.url << "\"\n";
if (!repo.channels.empty()) {
os << " channels = [";
for (std::size_t i = 0; i < repo.channels.size(); ++i) {
if (i > 0) { os << ", "; }
os << '"' << repo.channels[i] << '"';
}
os << "]\n";
}
if (!repo.mirrors.empty()) {
os << " mirrors = [\n";
for (auto& m : repo.mirrors) {
os << " \"" << m << "\",\n";
}
os << " ]\n";
}
if (repo.priority != 50) {
os << " priority = " << repo.priority << "\n";
}
os << " }\n";
}
os << "}\n\n";
}
if (!cfg.assertions.empty()) { if (!cfg.assertions.empty()) {
os << "assert {\n"; os << "assert {\n";
for (auto& a : cfg.assertions) { for (auto& a : cfg.assertions) {
@@ -339,4 +373,12 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) {
} }
} }
void format_index(std::ostream& os, const dsl::IndexDef& idx) {
os << "index \"" << idx.name << "\" {\n";
for (auto& pkg : idx.packages) {
os << " " << pkg.name << " { version = \"" << pkg.version << "\" }\n";
}
os << "}\n";
}
} // namespace kappa::tools } // namespace kappa::tools
Executable
+992
View File
@@ -0,0 +1,992 @@
#!/bin/sh
# kappa comprehensive test suite — DSL, ops, error handling, edge cases
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 | tr '\n' ' ')"
FAIL=$((FAIL + 1))
fi
}
check_fail() {
local desc="$1" cmd="$2" expect="$3"
local out rc=0
out=$(eval "$cmd" 2>&1) || rc=$?
if [ "$rc" -ne 0 ] && echo "$out" | grep -q "$expect"; then
echo " ✓ $desc"
PASS=$((PASS + 1))
else
echo " ✗ $desc"
echo " expected exit≠0 with: $expect"
echo " exit: $rc, got: $(echo "$out" | head -3 | tr '\n' ' ')"
FAIL=$((FAIL + 1))
fi
}
TMPDIR=$(mktemp -d /tmp/kappa-full-test-XXXXX)
trap 'rm -rf $TMPDIR' EXIT
export KAPPA_ROOT="$TMPDIR/root"
mkdir -p "$KAPPA_ROOT"/{bin,temp,db,system,builds,cache/{packages,indexes}}
# ═══════════════════════════════════════════
echo ""
echo "=== CLI: help, version, error handling ==="
echo ""
check "kappa --help" \
"$KAPPA --help" \
'Usage'
check "kappa -h" \
"$KAPPA -h" \
'Subcommands'
check "kappa -V" \
"$KAPPA -V" \
'kappa 0.1.0'
check_fail "missing subcommand" \
"$KAPPA" \
'missing subcommand'
check_fail "unknown subcommand" \
"$KAPPA fakething" \
"unknown subcommand 'fakething'"
check_fail "missing file arg" \
"$KAPPA parse-package" \
'no input file'
check_fail "nonexistent file" \
"$KAPPA parse-package /nonexistent/file.kap" \
'cannot open'
# ═══════════════════════════════════════════
echo ""
echo "=== DSL: parse-package and validate ==="
echo ""
cat > "$TMPDIR/basic.kap" << 'KAPEOF'
package "testpkg" {
version = "1.0"
source = "https://example.com/test-1.0.tar.gz"
sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
license = "MIT"
provides = ["libtest.so.1"]
build { make -j${jobs} }
install { make DESTDIR=${destdir} install }
}
KAPEOF
check "basic package parse" \
"$KAPPA parse-package $TMPDIR/basic.kap" \
'package "testpkg" 1.0 — valid'
check "validate package" \
"$KAPPA validate $TMPDIR/basic.kap" \
'valid package'
check_fail "unclosed brace" \
"printf 'package \"bad\" {\n version = \"1\"\n' | $KAPPA parse-package /dev/stdin" \
'unclosed'
# ═══════════════════════════════════════════
echo ""
echo "=== DSL: all keywords parse and format ==="
echo ""
cat > "$TMPDIR/full.kap" << 'KAPEOF'
package "fullpkg" {
const version = "2.0"
const source = "https://example.com/full-v2.0.tar.gz"
sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
sha512 = "abc"
md5 = "def"
license = "BSD-2"
provides = ["full", "libfull.so"]
outputs = ["bin", "lib"]
conflicts = ["incompatible"]
depends = [{ name = "zlib", version = ">=1.2" }, "openssl"]
features {
ssl = { enabled = true, flag = "--with-ssl" }
debug = false
}
config {
file "etc/full.conf" mode = "default" {
port = "8080"
}
}
env {
CFLAGS = "-O2"
CFLAGS += "-pipe"
LDFLAGS ?= "-s"
}
patches = [{ url = "https://example.com/fix.patch", sha256 = "abc", level = 1 }]
service {
exec = "/usr/bin/full"
type = "forking"
ports = [80, 443]
}
assert {
"ssl required" : features.ssl == "true"
}
prepare { echo prepare }
build { make -j4 }
check { make check }
install { make DESTDIR=/tmp/dest install }
}
KAPEOF
check "full keyword parse" \
"$KAPPA parse-package $TMPDIR/full.kap" \
'package "fullpkg"'
check "format full roundtrip" \
"$KAPPA format $TMPDIR/full.kap" \
'package "fullpkg"'
check "format preserves provides" \
"$KAPPA format $TMPDIR/full.kap" \
'libfull.so'
check "format preserves features" \
"$KAPPA format $TMPDIR/full.kap" \
'features {'
check "format preserves env +=" \
"$KAPPA format $TMPDIR/full.kap" \
'CFLAGS +='
check "format preserves env ?=" \
"$KAPPA format $TMPDIR/full.kap" \
'LDFLAGS ?='
check "format preserves service" \
"$KAPPA format $TMPDIR/full.kap" \
'/usr/bin/full'
check "format preserves asserts" \
"$KAPPA format $TMPDIR/full.kap" \
'assert {'
# ═══════════════════════════════════════════
echo ""
echo "=== Config: parse-config and format ==="
echo ""
cat > "$TMPDIR/simple-config.kap" << 'KAPEOF'
system {
hostname = "test"
}
packages {
testpkg {}
}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "parse-config basic" \
"$KAPPA parse-config $TMPDIR/simple-config.kap" \
'valid'
check "parse-config shows init" \
"$KAPPA parse-config $TMPDIR/simple-config.kap" \
's6'
check "validate config" \
"$KAPPA validate $TMPDIR/simple-config.kap" \
'valid system configuration'
# ═══════════════════════════════════════════
echo ""
echo "=== Config: users and groups ==="
echo ""
cat > "$TMPDIR/users-config.kap" << 'KAPEOF'
system {
hostname = "test"
}
packages {}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
groups {
wheel { gid = 998 }
audio {}
}
users {
root {
shell = "/bin/zsh"
groups = ["wheel", "audio"]
}
}
KAPEOF
check "parse-config with users" \
"$KAPPA parse-config $TMPDIR/users-config.kap" \
'1 users'
check "format users roundtrip" \
"$KAPPA format $TMPDIR/users-config.kap" \
'/bin/zsh'
# ═══════════════════════════════════════════
echo ""
echo "=== Config: assertions ==="
echo ""
cat > "$TMPDIR/assert-pass.kap" << 'KAPEOF'
system { hostname = "test" }
packages { testpkg {} }
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
assert {
"root required" : boot.root != ""
"init must be s6" : boot.init == "s6"
}
KAPEOF
check "validate passes assertions" \
"$KAPPA validate $TMPDIR/assert-pass.kap" \
'valid system configuration'
cat > "$TMPDIR/assert-fail.kap" << 'KAPEOF'
system { hostname = "test" }
packages {}
services {}
boot {
kernel = "linux"
init = "openrc"
root = "/dev/sda1"
bootloader = "limine"
}
assert {
"must be s6" : boot.init == "s6"
}
KAPEOF
check_fail "validate fails assertion" \
"$KAPPA validate $TMPDIR/assert-fail.kap" \
'assertion failures'
# ═══════════════════════════════════════════
echo ""
echo "=== Config: imports ==="
echo ""
cat > "$TMPDIR/imported.kap" << 'KAPEOF'
system { hostname = "imported.local" }
packages {}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
cat > "$TMPDIR/importer.kap" << 'KAPEOF'
imports = ["imported.kap"]
packages {}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "imports merges hostname" \
"$KAPPA parse-config $TMPDIR/importer.kap" \
'valid'
# ═══════════════════════════════════════════
echo ""
echo "=== Doctor diagnostics ==="
echo ""
cat > "$TMPDIR/doctor-bad.kap" << 'KAPEOF'
package "bad" {
version = ""
source = ""
build { echo ok }
install { echo ok }
}
KAPEOF
check_fail "doctor catches empty version" \
"$KAPPA doctor $TMPDIR/doctor-bad.kap" \
'version is not set'
check_fail "doctor catches empty source" \
"$KAPPA doctor $TMPDIR/doctor-bad.kap" \
'source URL is not set'
check_fail "doctor warns missing license" \
"$KAPPA doctor $TMPDIR/doctor-bad.kap" \
'license is not specified'
cat > "$TMPDIR/doctor-badcfg.kap" << 'KAPEOF'
system {}
packages {}
services {}
boot {
kernel = "linux"
init = "fakenit"
root = "/dev/sda1"
bootloader = "lilo"
}
KAPEOF
check_fail "doctor warns missing hostname" \
"$KAPPA doctor $TMPDIR/doctor-badcfg.kap" \
'hostname is not set'
check_fail "doctor warns unknown init" \
"$KAPPA doctor $TMPDIR/doctor-badcfg.kap" \
'not a recognized init system'
check_fail "doctor warns unknown bootloader" \
"$KAPPA doctor $TMPDIR/doctor-badcfg.kap" \
'not a recognized bootloader'
# ═══════════════════════════════════════════
echo ""
echo "=== Repos: parsing and formatting ==="
echo ""
cat > "$TMPDIR/repos-config.kap" << 'KAPEOF'
system { hostname = "test" }
repos {
upstream {
url = "https://packages.example.com/"
channels = ["stable", "testing"]
mirrors = [
"https://cdn.example.com/",
"https://eu.example.com/",
]
priority = 100
}
local {
url = "file:///usr/local/kappa/repo/"
channels = ["default"]
}
}
packages { testpkg {} }
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "parse-config with repos" \
"$KAPPA parse-config $TMPDIR/repos-config.kap" \
'repos: 2'
check "format repos roundtrip" \
"$KAPPA format $TMPDIR/repos-config.kap" \
'upstream {'
check "format repos preserves channels" \
"$KAPPA format $TMPDIR/repos-config.kap" \
'channels = \['
check "format repos preserves mirrors" \
"$KAPPA format $TMPDIR/repos-config.kap" \
'cdn.example.com'
check "format repos preserves priority" \
"$KAPPA format $TMPDIR/repos-config.kap" \
'priority = 100'
# ═══════════════════════════════════════════
echo ""
echo "=== Backward compat: remotes flat list ==="
echo ""
cat > "$TMPDIR/remotes-config.kap" << 'KAPEOF'
remotes = [
"https://old.example.com/",
"https://old2.example.com/",
]
system { hostname = "test" }
packages {}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "flat remotes still parse" \
"$KAPPA parse-config $TMPDIR/remotes-config.kap" \
'valid'
check "format remotes roundtrip" \
"$KAPPA format $TMPDIR/remotes-config.kap" \
'remotes = \['
# ═══════════════════════════════════════════
echo ""
echo "=== Index: generation and validation ==="
echo ""
check "kappa index generates index.kap" \
"$KAPPA index $TMPDIR" \
'packages indexed'
check "validate index.kap" \
"$KAPPA validate $TMPDIR/index.kap" \
'valid index'
check "format index roundtrip" \
"$KAPPA format $TMPDIR/index.kap" \
'index "'
check_fail "index needs directory arg" \
"$KAPPA index" \
'no directory'
# ═══════════════════════════════════════════
echo ""
echo "=== Package operations: add and remove ==="
echo ""
rm -f "$KAPPA_ROOT/system/config.kap"
check "add creates config" \
"$KAPPA add newpkg" \
'added newpkg'
check "add with version" \
"$KAPPA add verpkg 2.0" \
'added verpkg 2.0'
check "add existing updates version" \
"$KAPPA add newpkg 3.0" \
'updated newpkg'
check "add existing idempotent" \
"$KAPPA add verpkg" \
'already in packages'
check "remove package" \
"$KAPPA remove verpkg" \
'removed verpkg'
check "remove nonexistent idempotent" \
"$KAPPA remove nonexistent" \
'not in packages'
check_fail "add needs name" \
"$KAPPA add" \
'no package name'
check_fail "remove needs name" \
"$KAPPA remove" \
'no package name'
# ═══════════════════════════════════════════
echo ""
echo "=== Install: list and rollback ==="
echo ""
check "list empty" \
"$KAPPA list" \
'no packages installed'
mkdir -p "$KAPPA_ROOT/db"
cat > "$KAPPA_ROOT/db/installed" << 'EOF'
testpkg 1.0 a1b2c3d4e5f6a7b8
otherpkg 2.0 b2c3d4e5f6a7b8c9 libother.so
EOF
check "list shows installed" \
"$KAPPA list" \
'2 packages installed'
check "list shows provides" \
"$KAPPA list" \
'libother.so'
mkdir -p "$KAPPA_ROOT/db/generations"
echo "a1b2c3d4e5f6a7b8" > "$KAPPA_ROOT/db/generations/gen-0001"
echo "b2c3d4e5f6a7b8c9" > "$KAPPA_ROOT/db/generations/gen-0002"
check "rollback shows generations" \
"$KAPPA rollback" \
'2 generations'
check "rollback shows gen IDs" \
"$KAPPA rollback" \
'gen-0001'
# ═══════════════════════════════════════════
echo ""
echo "=== Resolver: conflicts ==="
echo ""
mkdir -p "$KAPPA_ROOT/cache/packages"
cat > "$KAPPA_ROOT/cache/packages/a.kap" << 'KAPEOF'
package "a" {
version = "1.0"
source = "a.tar.gz"
conflicts = ["b"]
build { echo a }
install { echo a > ${destdir}/tmp/a }
}
KAPEOF
cat > "$KAPPA_ROOT/cache/packages/b.kap" << 'KAPEOF'
package "b" {
version = "1.0"
source = "b.tar.gz"
conflicts = ["a"]
build { echo b }
install { echo b > ${destdir}/tmp/b }
}
KAPEOF
cat > "$TMPDIR/conflict-config.kap" << 'KAPEOF'
system { hostname = "test" }
packages { a {} b {} }
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check_fail "resolve catches conflicts" \
"$KAPPA resolve $TMPDIR/conflict-config.kap" \
'conflicts detected'
check_fail "rebuild catches conflicts" \
"$KAPPA rebuild $TMPDIR/conflict-config.kap" \
'conflicts detected'
cat > "$TMPDIR/missing-config.kap" << 'KAPEOF'
system { hostname = "test" }
packages { nonexistent {} }
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "resolve warns on missing" \
"$KAPPA resolve $TMPDIR/missing-config.kap 2>&1" \
'not found in registry'
# ═══════════════════════════════════════════
echo ""
echo "=== Scheduler: deptree ordering ==="
echo ""
cat > "$KAPPA_ROOT/cache/packages/dep-a.kap" << 'KAPEOF'
package "dep-a" {
version = "1.0"
source = "a.tar.gz"
build { echo a }
install { echo a > ${destdir}/tmp/a }
}
KAPEOF
cat > "$KAPPA_ROOT/cache/packages/dep-b.kap" << 'KAPEOF'
package "dep-b" {
version = "1.0"
source = "b.tar.gz"
depends = [{ name = "dep-a" }]
build { echo b }
install { echo b > ${destdir}/tmp/b }
}
KAPEOF
cat > "$KAPPA_ROOT/cache/packages/dep-c.kap" << 'KAPEOF'
package "dep-c" {
version = "1.0"
source = "c.tar.gz"
depends = [{ name = "dep-b" }]
build { echo c }
install { echo c > ${destdir}/tmp/c }
}
KAPEOF
cat > "$TMPDIR/deptree-config.kap" << 'KAPEOF'
system { hostname = "test" }
packages { dep-a {} dep-b {} dep-c {} }
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "resolve deptree (a=0 deps)" \
"$KAPPA resolve $TMPDIR/deptree-config.kap 2>&1" \
'dep-a (0 deps)'
check "resolve deptree (c has dep)" \
"$KAPPA resolve $TMPDIR/deptree-config.kap 2>&1" \
'dep-c (1 deps)'
# ═══════════════════════════════════════════
echo ""
echo "=== Build: simple execution ==="
echo ""
cat > "$TMPDIR/simple-build.kap" << 'KAPEOF'
package "simple" {
version = "1.0"
source = "simple-1.0.tar.gz"
sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
license = "MIT"
build { echo built }
check { echo checked }
install {
mkdir -p ${destdir}/tmp
echo done > ${destdir}/tmp/simple
}
}
KAPEOF
check "build simple package" \
"$KAPPA build $TMPDIR/simple-build.kap" \
'build successful'
check "installed after build" \
"$KAPPA list" \
'simple'
# ═══════════════════════════════════════════
echo ""
echo "=== Build: error handling ==="
echo ""
cat > "$TMPDIR/fail-build.kap" << 'KAPEOF'
package "failpkg" {
version = "1.0"
source = "fail.tar.gz"
sha256 = "abc"
license = "MIT"
build { exit 42 }
install { echo ok }
}
KAPEOF
check_fail "build reports exit code" \
"$KAPPA build $TMPDIR/fail-build.kap" \
'exited with code 42'
check_fail "build reports failed phase" \
"$KAPPA build $TMPDIR/fail-build.kap" \
'build'
# ═══════════════════════════════════════════
echo ""
echo "=== Build: env operators ==="
echo ""
cat > "$TMPDIR/env-ops.kap" << 'KAPEOF'
package "envops" {
version = "1.0"
source = "envops.tar.gz"
sha256 = "abc"
license = "MIT"
env {
FLAGS = "base"
FLAGS += "extra"
FLAGS ?= "soft"
}
build { echo ok }
install { mkdir -p ${destdir}/tmp }
}
KAPEOF
check "format shows env =" \
"$KAPPA format $TMPDIR/env-ops.kap" \
'FLAGS = "base"'
check "format shows env +=" \
"$KAPPA format $TMPDIR/env-ops.kap" \
'FLAGS += "extra"'
check "format shows env ?=" \
"$KAPPA format $TMPDIR/env-ops.kap" \
'FLAGS ?= "soft"'
# ═══════════════════════════════════════════
echo ""
echo "=== Init systems: all 5 recognized ==="
echo ""
for init in systemd openrc s6 runit dinit; do
cat > "$TMPDIR/init-$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 "parse-config recognizes $init" \
"$KAPPA parse-config $TMPDIR/init-$init.kap" \
'valid'
done
# ═══════════════════════════════════════════
echo ""
echo "=== Bootloaders: both recognized ==="
echo ""
for bl in grub limine; do
cat > "$TMPDIR/bl-$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 "parse-config recognizes $bl" \
"$KAPPA parse-config $TMPDIR/bl-$bl.kap" \
'valid'
done
# ═══════════════════════════════════════════
echo ""
echo "=== Variable interpolation: format preserves ==="
echo ""
cat > "$TMPDIR/vars.kap" << 'KAPEOF'
package "vars" {
version = "1.0"
source = "https://example.com/vars-1.0.tar.gz"
sha256 = "abc"
license = "MIT"
build {
./configure --prefix=${prefix}
make -j${jobs}
}
install {
make DESTDIR=${destdir} install
}
}
KAPEOF
check "format preserves prefix var" \
"$KAPPA format $TMPDIR/vars.kap" \
'prefix=${prefix}'
check "format preserves jobs var" \
"$KAPPA format $TMPDIR/vars.kap" \
'j${jobs}'
check "format preserves destdir var" \
"$KAPPA format $TMPDIR/vars.kap" \
'DESTDIR=${destdir}'
# ═══════════════════════════════════════════
echo ""
echo "=== Paths: --root override ==="
echo ""
ALTROOT="$TMPDIR/altroot"
mkdir -p "$ALTROOT/system"
cat > "$ALTROOT/system/config.kap" << 'KAPEOF'
system { hostname = "alt" }
packages {}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "--root flag works" \
"$KAPPA --root $ALTROOT parse-config $ALTROOT/system/config.kap" \
'valid'
$KAPPA --root "$ALTROOT" add rootpkg 2>/dev/null
check "verify add wrote to alt root" \
"grep -c rootpkg $ALTROOT/system/config.kap" \
'1'
# ═══════════════════════════════════════════
echo ""
echo "=== Config: rollback config ==="
echo ""
cat > "$TMPDIR/rollback-cfg.kap" << 'KAPEOF'
system {
hostname = "test"
rollback {
keep = 3
}
}
packages {}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "format preserves rollback keep" \
"$KAPPA format $TMPDIR/rollback-cfg.kap" \
'keep = 3'
# ═══════════════════════════════════════════
echo ""
echo "=== Config: system env block ==="
echo ""
cat > "$TMPDIR/sysenv-config.kap" << 'KAPEOF'
system {
hostname = "test"
env {
CFLAGS = "-O2"
CFLAGS += "-pipe"
}
}
packages {}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "parse-config with system env" \
"$KAPPA parse-config $TMPDIR/sysenv-config.kap" \
'valid'
# ═══════════════════════════════════════════
echo ""
echo "=== DSL: feature flags ==="
echo ""
cat > "$TMPDIR/features-config.kap" << 'KAPEOF'
system {
hostname = "test"
features {
ssl = true
wayland = false
}
}
packages {
testpkg {
features {
debug = true
}
}
}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "parse-config with features" \
"$KAPPA parse-config $TMPDIR/features-config.kap" \
'valid'
# ═══════════════════════════════════════════
echo ""
echo "=== Rebuild: dry-run detection ==="
echo ""
# Package must be in registry + installed DB for rebuild
cp "$TMPDIR/basic.kap" "$KAPPA_ROOT/cache/packages/testpkg.kap"
mkdir -p "$KAPPA_ROOT/db"
echo "testpkg 1.0 0000000000000000" > "$KAPPA_ROOT/db/installed"
cat > "$TMPDIR/rebuild-config.kap" << 'KAPEOF'
system { hostname = "test" }
packages { testpkg {} }
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "rebuild dry-run detects changes" \
"$KAPPA rebuild --dry-run $TMPDIR/rebuild-config.kap 2>&1" \
'nothing to rebuild'
check "resolve shows build plan" \
"$KAPPA resolve $TMPDIR/rebuild-config.kap 2>&1" \
'packages in build order'
# ═══════════════════════════════════════════
echo ""
echo "=========================================="
echo " Results: $PASS passed, $FAIL failed"
echo "=========================================="
[ "$FAIL" -eq 0 ] || exit 1
+2 -2
View File
@@ -235,7 +235,7 @@ config_template "s6" > /tmp/kappa-test-rebuild-s6.kap
# The packages list includes "s6" which isn't in the installed DB, # The packages list includes "s6" which isn't in the installed DB,
# so rebuild correctly shows it as a new package to build. # so rebuild correctly shows it as a new package to build.
check "rebuild detects new init package" \ check "rebuild detects new init package" \
"$KAPPA_BIN rebuild /tmp/kappa-test-rebuild-s6.kap 2>&1" \ "$KAPPA_BIN rebuild --dry-run /tmp/kappa-test-rebuild-s6.kap 2>&1" \
'packages to rebuild' 'packages to rebuild'
# Now test switching FROM s6 TO systemd # Now test switching FROM s6 TO systemd
@@ -244,7 +244,7 @@ config_template "systemd" > /tmp/kappa-test-rebuild-systemd.kap
# The rebuild should detect init_changed # The rebuild should detect init_changed
# Note: this requires the installed DB to have the old init hash # Note: this requires the installed DB to have the old init hash
check "rebuild detects init change" \ check "rebuild detects init change" \
"$KAPPA_BIN rebuild /tmp/kappa-test-rebuild-systemd.kap 2>&1" \ "$KAPPA_BIN rebuild --dry-run /tmp/kappa-test-rebuild-systemd.kap 2>&1" \
'packages to rebuild' 'packages to rebuild'
echo "" echo ""
-4
View File
@@ -38,10 +38,6 @@ check "format foo.kap round-trips service block" \
"$KAPPA format examples/foo.kap" \ "$KAPPA format examples/foo.kap" \
'service {' 'service {'
check "format shows conflicts when present" \
"$KAPPA format /tmp/test_conflict.kap" \
'conflicts ='
check "format postgres.kap shows named services" \ check "format postgres.kap shows named services" \
"$KAPPA format examples/postgres.kap" \ "$KAPPA format examples/postgres.kap" \
'service checkpointer {' 'service checkpointer {'