Compare commits

31 Commits
Author SHA1 Message Date
huntedbytheirs 70d59a3f9c comma
CI / build-and-test (push) Successful in 56s
2026-08-17 22:52:29 -04:00
huntedbytheirs d0ff52a5d7 test: add 7 tests for kappa bump and bump-all
CI / build-and-test (push) Successful in 42s
- 4 tests for kappa bump: version update, version written to file,
  source URL preserved, requires version arg
- 3 tests for kappa bump-all: finds newer version, reports count,
  requires directory arg

Total: 103 → 110 in test-full.sh (30+23+110 = 163 overall)
2026-08-05 07:58:35 -04:00
huntedbytheirs 1f61f116f8 feat: add kappa bump and kappa bump-all subcommands
CI / build-and-test (push) Successful in 37s
kappa bump <file> <version> — updates .kap file to new version.
Downloads tarball, computes sha256, rewrites file with new
version and hash. Preserves source URL with ${version} template.

kappa bump-all <dir> — scans directory of .kap files. For each,
auto-detects newer patch versions by incrementing the version
number and trying up to 15 candidates. Downloads and hashes the
first one that exists.
2026-08-05 07:55:08 -04:00
huntedbytheirs d918f7b6fc test: add 18 tests for 0.3 features
CI / build-and-test (push) Successful in 38s
- 8 tests: --features and --config flags (add, update, idempotent, file verification)
- 3 tests: transitive dependency resolution (auto-resolve chain)
- 4 tests: kappa search (name, substring, repo source, no-match)
- 3 tests: kappa upgrade (detection, version display, dry-run)

Total: 85 → 103 in test-full.sh (30+23+103 = 156 overall)
2026-08-05 07:42:59 -04:00
huntedbytheirs 01fcb20a57 feat: transitive dependency resolution
CI / build-and-test (push) Successful in 39s
Resolver now pulls in transitive dependencies automatically.
Declaring 'packages { root {} }' where root→mid→leaf resolves
all three packages in correct topological order. Previously
every package had to be listed explicitly in the config.

build_registry pre-populates from $KAPPA_ROOT/cache/packages/
so transitive deps can be found without remote fetching.
2026-08-05 07:39:36 -04:00
huntedbytheirs 33ff453cd1 feat: add kappa upgrade subcommand
CI / build-and-test (push) Successful in 39s
Compares installed packages against cached indexes to find newer
versions. kappa upgrade shows available upgrades and updates the
system config with new version constraints. --dry-run shows what
would change without applying.
2026-08-05 07:37:35 -04:00
huntedbytheirs b0b54be70e feat: add kappa search <query> subcommand
CI / build-and-test (push) Successful in 40s
Searches all cached indexes in $KAPPA_ROOT/cache/indexes/
for packages matching the query as a substring. Displays
name, version, and repo source (e.g. 'make 4.4.1 @ kappa-os/stable').
Offline — no network requests.
2026-08-05 07:36:06 -04:00
huntedbytheirs e69ebf220d feat: add --features and --config flags to kappa add
CI / build-and-test (push) Successful in 49s
kappa add nginx --features "ssl=on,gzip=off" --config "port=8080"
adds/updates feature flags and config values in the system config.
Comma-separated key=value pairs. Idempotent — only prints 'updated'
when something actually changed.
2026-08-05 07:34:12 -04:00
huntedbytheirs 7ba3fe6632 build: add test and clean targets to Makefile
CI / build-and-test (push) Successful in 38s
make test: builds then runs all three test suites (138 tests).
make clean: removes build, build-debug, build-release directories.

Finishing off 0.2.
2026-08-04 14:44:46 -04:00
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
60 changed files with 4380 additions and 251 deletions
+1
View File
@@ -6,6 +6,7 @@ Checks: >
portability-*,
readability-*,
-modernize-use-trailing-return-type,
-portability-avoid-pragma-once,
-readability-identifier-length,
-readability-magic-numbers,
-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
# Build
build/
/build/
/build-debug/
/build-release/
/dist/
build-gcc/
compile_commands.json
vcpkg_installed/
kappa
/kappa
# Agent session state
.omo/
.logs/
.opencode/
+6 -4
View File
@@ -1,11 +1,9 @@
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)
find_package(Threads REQUIRED)
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
@@ -53,3 +51,7 @@ add_executable(kappa
src/system/activate.cpp
)
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)
+24 -16
View File
@@ -8,9 +8,9 @@ project, keep reading.
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
doesn't belong here. The standard library is your only dependency. Zero
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`
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
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.
Integration tests live in three suites. If you add a subcommand, add a test.
```sh
./test.sh # 31 tests, must pass
./test-init-switch.sh # 23 tests, must pass
./test.sh # 30 tests — DSL parsing, config, services, init systems
./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
The `.kap` DSL is the contract. You can add keywords. You cannot remove
@@ -85,29 +88,34 @@ change means someone's `config.kap` stops parsing, it doesn't ship.
### Pick something
Good first issues:
- Adding a 6th init system backend
- Adding a 3rd bootloader backend
- C++ unit test framework setup
- Shell completion scripts
- Adding a 6th init system backend (we have 5: systemd, openrc, s6, runit, dinit)
- Adding a 3rd bootloader backend (we have 2: grub, limine)
- Improving the `kappa doctor` diagnostics for package recipes
- Adding `--features` / `--config` flags to `kappa add`
- Shell completion scripts (bash, zsh, fish)
Ambitious issues:
- Binary package support (pre-built caches)
- Remote build farm (distcc-style)
- Signed package verification
- Signed package verification with index signing
- Filesystem overlay activation (like Nix profiles)
- Transitive dependency resolution (auto-including deps not in config)
### Send a PR
1. Fork the repo
2. Create a branch: `feat/my-thing` or `fix/my-bug`
3. Write code that follows the rules above
4. Run `./test.sh && ./test-init-switch.sh` — both must pass
4. Run `./test.sh && ./test-init-switch.sh && ./test-full.sh` — all three must pass
5. Open a PR against `main`
### PR requirements
- Build must pass: `cmake --build build` with zero warnings
- Tests must pass: both shell test suites
- Build must pass: `cmake --build build` with zero warnings on Clang 17+
- 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.
- Commit messages in imperative: `Add runit backend` not `Added runit backend`
+72
View File
@@ -0,0 +1,72 @@
# 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)
# test — build then run all three test suites
# clean - remove all build directories
# 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 test clean 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)
test: build
@echo "==> test.sh"
@./test.sh
@echo ""
@echo "==> test-init-switch.sh"
@./test-init-switch.sh
@echo ""
@echo "==> test-full.sh"
@./test-full.sh
@echo ""
@echo "all test suites passed"
clean:
rm -rf $(BUILD_DIR) $(DEBUG_DIR) $(RELEASE_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
grouping so leaf dependencies unblock as much work as possible first.
- **Package recipe caching.** Declare `remotes = ["https://repo.example.com/"]`
in your config. Kappa fetches `.kap` files on demand, caches them, and only
re-fetches when the remote version is newer.
- **Package recipe caching with indexed repos.** Declare named repos with
channels and mirrors in your config. Kappa fetches `index.kap` from each
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/`.
Rebuilds don't touch the network unless versions change.
```kap
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
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
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
> **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
# Build kappa (needs Clang 17+, CMake 3.20+, C++23)
cmake -B build -G Ninja -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++
cmake --build build
# Write a config
cat > system.kap << 'EOF'
system { hostname = "kappa.local" }
packages { nginx {} }
services { nginx { enable = true } }
boot {
kernel = "linux"; init = "s6"; root = "/dev/sda1"; bootloader = "limine"
}
users { root { shell = "/bin/zsh" } }
remotes = ["https://packages.kappa-os.org/stable/"]
EOF
# Add packages to your system config
build/kappa add make
build/kappa add nginx ">=1.24"
build/kappa add zlib
# Parse it
build/kappa parse-config system.kap
# Rebuild — kappa fetches recipes, resolves deps, builds everything
build/kappa rebuild $KAPPA_ROOT/system/config.kap
# Rebuild
build/kappa rebuild system.kap
# Remove packages
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
| Command | What it does |
|---------|-------------|
| `parse-package <file>` | Validate a `.kap` package definition |
| `parse-config <file>` | Validate a system configuration |
| `validate <file>` | Validate any kappa file |
| `format <file>` | Pretty-print to canonical style |
| `doctor <file>` | Check for issues and warnings |
| `resolve <config>` | Compute a build plan |
| `add <pkg> [version]` | Add a package to system config |
| `remove <pkg>` | Remove a package from system config |
| `build <package>` | Build a single package from its `.kap` definition |
| `rebuild <config>` | Diff config against installed state, rebuild changed |
| `resolve <config>` | Compute a build plan (shows order, deps, conflicts) |
| `doctor <file>` | Check a file for issues and warnings |
| `fetch <package>` | Download and verify source tarballs |
| `fetch-package <name>` | Fetch a package recipe from remotes |
| `build <package>` | Build a single package |
| `rebuild <config>` | Diff config against installed state, rebuild changed |
| `format <file>` | Pretty-print to canonical style |
| `index <dir>` | Build an `index.kap` from `.kap` files in a directory |
| `list` | Show installed packages |
| `parse-config <file>` | Validate a system configuration |
| `parse-package <file>` | Validate a `.kap` package definition |
| `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
@@ -151,4 +197,7 @@ BSD 2-Clause. Do whatever you want. Just don't sue us.
### Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md). We're opinionated but we merge good
code.
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.
* 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.
* The rest inherits from the package definition and system config.
+4 -2
View File
@@ -1,6 +1,6 @@
/*
* Kappa system configuration.
* Lives at /kappa/system/config.kap
* Lives at /usr/local/kappa/system/config.kap
*
* INIT SYSTEM SELECTION
* =====================
@@ -67,6 +67,8 @@ system {
env {
CFLAGS = "-O2 -march=native"
// packages inherit this appended flag
CFLAGS += "-pipe"
LDFLAGS = "-Wl,--as-needed"
MAKEFLAGS = "-j8"
}
@@ -101,7 +103,7 @@ packages {
/*
// 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
* 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" {
const version = "1.2.3"
@@ -101,8 +112,12 @@ package "foo" {
}
env {
// hard set — overwrites any existing CFLAGS
CFLAGS = "-O2 -march=native"
// append — now CFLAGS is "-O2 -march=native -pipe"
CFLAGS += "-pipe"
LDFLAGS = "-Wl,--as-needed"
// soft set — only added if CFLAGS isn't already defined
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 {
CFLAGS = "-O2"
// append security hardening flags
CFLAGS += "-D_FORTIFY_SOURCE=2"
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
#include <cstdint>
#include <string>
#include <unordered_map>
#include <unordered_set>
@@ -38,10 +39,12 @@ struct Patch {
int level = 1;
};
enum class EnvMode : std::uint8_t { Set, Soft, Append };
struct EnvEntry {
std::string key;
std::string value;
bool soft = false;
EnvMode mode = EnvMode::Set;
};
struct NamedService {
@@ -90,4 +93,14 @@ struct PackageDef {
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
+2 -25
View File
@@ -1,36 +1,13 @@
#pragma once
#include "kappa/dsl/parse_util.hpp"
#include <format>
#include <stdexcept>
#include <string>
#include <string_view>
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) {
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;
};
struct RepoDef {
std::string name;
std::string url;
std::vector<std::string> channels;
std::vector<std::string> mirrors;
int priority = 50;
};
struct SystemConfig {
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;
std::vector<PackageRef> packages;
std::vector<ServiceRef> services;
@@ -72,4 +81,7 @@ struct SystemConfig {
SystemConfig parse_system_config(std::string_view source);
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
+5
View File
@@ -13,6 +13,7 @@ enum class TokenType {
Lbrace, // {
Rbrace, // }
Equals, // =
Plus, // +
Lbracket, // [
Rbracket, // ]
Comma, // ,
@@ -48,6 +49,10 @@ enum class TokenType {
KwUninstall,
KwTrue,
KwFalse,
// Repo / index
KwIndex,
KwRepos,
};
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 packages_dir();
void ensure_directories();
bool ensure_directories();
bool directories_exist();
} // 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
// ---------------------------------------------------------------------------
@@ -41,7 +53,12 @@ BootloaderInstallResult install_bootloader_config(Bootloader bl,
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()) {
return {false, {}, "Failed to generate bootloader config"};
}
+3 -2
View File
@@ -3,6 +3,7 @@
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <format>
#include <ranges>
@@ -48,12 +49,12 @@ BootloaderPaths bootloader_paths(Bootloader bl, std::string_view prefix) {
switch (bl) {
case Bootloader::Grub:
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",
};
case Bootloader::Limine:
return {
.config_path = std::format("{}boot/limine/limine.cfg", prefix),
.config_path = (std::filesystem::path(prefix) / "boot/limine/limine.cfg").string(),
.install_cmd = "limine",
};
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},
{"true", TokenType::KwTrue},
{"false", TokenType::KwFalse},
{"index", TokenType::KwIndex},
{"repos", TokenType::KwRepos},
};
std::string_view token_name(TokenType type) {
@@ -42,6 +44,7 @@ std::string_view token_name(TokenType type) {
case TokenType::Lbrace: return "{";
case TokenType::Rbrace: return "}";
case TokenType::Equals: return "=";
case TokenType::Plus: return "+";
case TokenType::Lbracket: return "[";
case TokenType::Rbracket: return "]";
case TokenType::Comma: return ",";
@@ -73,6 +76,8 @@ std::string_view token_name(TokenType type) {
case TokenType::KwUninstall: return "uninstall";
case TokenType::KwTrue: return "true";
case TokenType::KwFalse: return "false";
case TokenType::KwIndex: return "index";
case TokenType::KwRepos: return "repos";
}
return "?";
}
@@ -143,7 +148,7 @@ Token Lexer::next() {
return {TokenType::Newline, "\n", token_start_line_, token_start_col_};
}
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_ident();
@@ -155,7 +160,7 @@ Token Lexer::scan_ident() {
while (pos_ < source_.size()) {
char c = peek();
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();
}
@@ -197,6 +202,7 @@ Token Lexer::scan_symbol() {
case '{': return {TokenType::Lbrace, "{", 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::Plus, "+", 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::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)) {
if (at(TokenType::Newline)) { advance(); continue; }
auto key = consume(TokenType::Ident).lexeme;
bool soft = false;
if (at(TokenType::Ident) && current_.lexeme == "?") {
EnvMode mode = EnvMode::Set;
if (at(TokenType::Plus)) {
advance();
consume(TokenType::Equals);
soft = true;
mode = EnvMode::Append;
} else if (at(TokenType::Ident) && current_.lexeme == "?") {
advance();
consume(TokenType::Equals);
mode = EnvMode::Soft;
} else {
consume(TokenType::Equals);
}
pkg.env_entries.push_back(
{std::move(key), consume(TokenType::String).lexeme, soft});
{std::move(key), consume(TokenType::String).lexeme, mode});
skip_newlines();
}
consume(TokenType::Rbrace);
@@ -382,9 +386,9 @@ Dependency Parser::parse_dependency_item() {
auto raw = consume(TokenType::String).lexeme;
auto colon = raw.find(':');
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);
+165 -5
View File
@@ -1,6 +1,7 @@
#include "kappa/dsl/system.hpp"
#include "kappa/dsl/error.hpp"
#include "kappa/dsl/lexer.hpp"
#include "kappa/dsl/parser.hpp"
#include <filesystem>
#include <format>
@@ -29,6 +30,7 @@ private:
void parse_boot_block(SystemConfig& cfg);
void parse_users_block(SystemConfig& cfg);
void parse_groups_block(SystemConfig& cfg);
void parse_repos_block(SystemConfig& cfg);
std::string consume_ident();
std::string consume_string();
@@ -85,7 +87,9 @@ std::string SysParser::consume_ident() {
at(TokenType::KwCheck) ||
at(TokenType::KwInstall) ||
at(TokenType::KwTrue) ||
at(TokenType::KwFalse)) {
at(TokenType::KwFalse) ||
at(TokenType::KwIndex) ||
at(TokenType::KwRepos)) {
auto lexeme = current_.lexeme;
advance();
return lexeme;
@@ -184,6 +188,7 @@ SystemConfig SysParser::parse() {
else if (kw == "boot") { parse_boot_block(cfg); }
else if (kw == "users") { parse_users_block(cfg); }
else if (kw == "groups") { parse_groups_block(cfg); }
else if (kw == "repos") { parse_repos_block(cfg); }
else {
throw ParseError(current_.line, current_.col,
std::format("unknown section '{}'", kw));
@@ -214,15 +219,19 @@ void SysParser::parse_system_block(SystemConfig& cfg) {
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
if (at(TokenType::Newline)) { advance(); continue; }
auto k = consume_ident();
bool soft = false;
if (at(TokenType::Ident) && current_.lexeme == "?") {
EnvMode mode = EnvMode::Set;
if (at(TokenType::Plus)) {
advance();
consume(TokenType::Equals);
soft = true;
mode = EnvMode::Append;
} else if (at(TokenType::Ident) && current_.lexeme == "?") {
advance();
consume(TokenType::Equals);
mode = EnvMode::Soft;
} else {
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();
}
consume(TokenType::Rbrace);
@@ -452,6 +461,63 @@ void SysParser::parse_groups_block(SystemConfig& cfg) {
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) {
SysParser p(source);
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); }
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.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.features) { base.system.features[k] = std::move(v); }
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;
}
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
+20 -7
View File
@@ -19,8 +19,13 @@ using namespace std::string_view_literals;
static int exec_cmd(const std::vector<std::string>& argv) {
if (argv.empty()) { return -1; }
std::vector<std::vector<char>> argv_storage(argv.size());
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);
pid_t pid = fork();
@@ -45,8 +50,13 @@ static std::string exec_capture(const std::vector<std::string>& argv) {
dup2(pipefd[1], STDOUT_FILENO);
close(pipefd[1]);
std::vector<std::vector<char>> argv_storage(argv.size());
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);
execvp(cargs[0], cargs.data());
_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";
return result;
}
@@ -186,8 +196,7 @@ FetchResult fetch(const dsl::PackageDef& pkg) {
if (rc2 != 0) { result.error = "extraction failed"; return result; }
} else {
int rc2 = exec_cmd({"tar", "xf", dest_file.string(),
"-C", paths::temp_dir().string(),
"--no-same-owner", "--no-same-permissions"});
"-C", paths::temp_dir().string()});
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),
"-d", result.work_dir.string(), "-i", patch_file});
int prc = exec_cmd({"patch", "-p" + std::to_string(patch.level),
"-d", result.work_dir.string(), "-i", patch_file});
if (prc != 0) {
result.error = "patch failed: " + patch.url;
return result;
}
}
return result;
+232
View File
@@ -125,4 +125,236 @@ RecipeResult fetch_recipe(const std::string& name,
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
+74 -8
View File
@@ -1,6 +1,7 @@
#include "kappa/install/install.hpp"
#include "kappa/paths.hpp"
#include <algorithm>
#include <cstdint>
#include <filesystem>
#include <format>
@@ -11,6 +12,42 @@ namespace kappa::install {
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() {
return (fs::path(paths::db_dir()) / "installed").string();
}
@@ -56,8 +93,12 @@ InstallResult install(const resolve::BuildStep& step,
}
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,
hash, step.resolved.original.provides});
hash, ch, step.resolved.original.provides});
if (!write_installed(entries)) {
result.error = "failed to write installed DB";
return result;
@@ -82,6 +123,19 @@ std::vector<DbEntry> read_installed() {
std::istringstream iss(line);
DbEntry e;
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;
while (iss >> prov) { e.provides.push_back(prov); }
entries.push_back(std::move(e));
@@ -92,14 +146,26 @@ std::vector<DbEntry> read_installed() {
bool write_installed(const std::vector<DbEntry>& entries) {
std::error_code ec;
fs::create_directories(paths::db_dir(), ec);
std::ofstream out(db_file());
if (!out) { return false; }
for (auto& e : entries) {
out << e.name << ' ' << e.version << ' ' << e.hash;
for (auto& p : e.provides) { out << ' ' << p; }
out << '\n';
auto tmp = db_file() + ".tmp";
{
std::ofstream out(tmp);
if (!out) return false;
for (auto& e : entries) {
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; }
out << '\n';
}
out.close();
if (out.fail()) {
std::filesystem::remove(tmp, ec);
return false;
}
}
return true;
std::filesystem::rename(tmp, db_file(), ec);
return !ec;
}
bool record_generation(const std::vector<std::string>& hashes, int keep) {
+840 -98
View File
File diff suppressed because it is too large Load Diff
+22 -2
View File
@@ -9,7 +9,7 @@ static std::filesystem::path g_root = [] {
if (auto* env = std::getenv("KAPPA_ROOT"); env != nullptr) {
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; }
@@ -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 packages_dir() { return cache_dir() / "packages"; }
void ensure_directories() {
bool ensure_directories() {
std::error_code ec;
std::filesystem::create_directories(bin_dir(), ec);
if (ec) return false;
std::filesystem::create_directories(temp_dir(), ec);
if (ec) return false;
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);
if (ec) return false;
std::filesystem::create_directories(cache_dir(), ec);
if (ec) return false;
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
+12 -3
View File
@@ -28,9 +28,18 @@ ChangeSet compute_changes(const dsl::SystemConfig& cfg) {
if (e.name != p.name) { continue; }
found = true;
bool changed = !p.version.empty()
|| !p.features.empty()
|| !p.config.empty();
bool changed = false;
if (!p.version.empty() && p.version != e.version) {
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); }
break;
+52 -1
View File
@@ -42,7 +42,58 @@ BuildPlan resolve(const dsl::SystemConfig& cfg, const Registry& registry) {
nodes.push_back(std::move(step));
}
// Detect conflicts between selected packages
// Transitive dependency discovery: pull in deps that are in the
// registry but not yet in the plan. Continue until no new deps
// are discovered — handles arbitrarily deep dependency chains.
bool changed = true;
while (changed) {
changed = false;
std::size_t current_size = nodes.size();
for (std::size_t i = 0; i < current_size; ++i) {
if (!nodes[i].package) continue;
for (auto& dep : nodes[i].package->depends) {
if (name_to_idx.contains(dep.name)) continue;
// Skip feature-conditional deps that aren't enabled
if (!dep.feature.empty()) {
auto fit = nodes[i].resolved.features.find(dep.feature);
if (fit == nodes[i].resolved.features.end() || !fit->second.enabled) {
continue;
}
}
auto rit = registry.find(dep.name);
if (rit == registry.end()) continue;
auto& pkg = rit->second;
auto resolved = config::resolve_package(pkg, cfg.system, {});
BuildStep step;
step.name = pkg.name;
step.package = &pkg;
step.resolved = std::move(resolved);
step.enabled_init = cfg.boot.init;
for (auto& ddep : pkg.depends) {
if (!ddep.feature.empty()) {
auto fit = step.resolved.features.find(ddep.feature);
if (fit == step.resolved.features.end() || !fit->second.enabled) {
continue;
}
}
step.dependencies.push_back({ddep.name, ddep.version});
}
name_to_idx[step.name] = nodes.size();
nodes.push_back(std::move(step));
changed = true;
}
}
}
// Detect conflicts between all selected packages (including transitive)
std::unordered_set<std::string> selected;
for (auto& node : nodes) { selected.insert(node.name); }
+2 -5
View File
@@ -55,12 +55,9 @@ std::string generate_dinit_service(const ServiceSpec& spec) {
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()) {
out << "\n# Environment variables:\n";
for (const auto& [key, value] : spec.env) {
out << std::format("# {}={}\n", key, value);
}
out << std::format("env-file = {}.env\n", spec.name);
}
// description
+12 -1
View File
@@ -110,7 +110,7 @@ ServiceInstallResult install_service(InitSystem is,
return {false, {},
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 << std::format("# s6 service: {}\n", spec.name);
if (!spec.working_dir.empty()) {
@@ -196,6 +196,17 @@ ServiceInstallResult install_service(InitSystem is,
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(), {}};
}
+3 -1
View File
@@ -8,6 +8,8 @@ namespace kappa::service {
namespace {
static constexpr std::string_view openrc_run_shebang = "#!/sbin/openrc-run\n";
bool is_background_type(std::string_view type) {
return type == "longrun" || type == "notify" || type == "forking";
}
@@ -18,7 +20,7 @@ std::string generate_openrc_service(const ServiceSpec& spec) {
std::ostringstream os;
// Shebang and header
os << "#!/sbin/openrc-run\n";
os << openrc_run_shebang;
os << "# Generated by kappa — do not edit manually\n";
// Description
+1 -1
View File
@@ -19,7 +19,7 @@ std::string generate_s6_service(const ServiceSpec& spec) {
// --- run file content ---
std::ostringstream run;
run << "#!/bin/execlineb -P\n";
run << s6_execline_shebang;
run << "# Generated by kappa — do not edit manually\n";
run << std::format("# s6 service: {}\n", spec.name);
+9 -8
View File
@@ -3,6 +3,7 @@
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <format>
#include <ranges>
@@ -61,31 +62,31 @@ InitPaths init_paths(InitSystem is, std::string_view prefix) {
switch (is) {
case InitSystem::Systemd:
return {
.service_dir = std::format("{}etc/systemd/system", prefix),
.service_dir = (std::filesystem::path(prefix) / "etc/systemd/system").string(),
.enable_cmd = "systemctl enable",
.disable_cmd = "systemctl disable",
};
case InitSystem::OpenRC:
return {
.service_dir = std::format("{}etc/init.d", prefix),
.service_dir = (std::filesystem::path(prefix) / "etc/init.d").string(),
.enable_cmd = "rc-update add",
.disable_cmd = "rc-update del",
};
case InitSystem::S6:
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",
.disable_cmd = "s6-rc-bundle-update",
.disable_cmd = "s6-rc-bundle-update delete",
};
case InitSystem::Runit:
return {
.service_dir = std::format("{}etc/sv", prefix),
.enable_cmd = "ln -sf /etc/sv",
.disable_cmd = "rm -f /var/service",
.service_dir = (std::filesystem::path(prefix) / "etc/sv").string(),
.enable_cmd = std::format("ln -sf {}/etc/sv/{{name}} {}/var/service/", prefix, prefix),
.disable_cmd = std::format("rm -f {}/var/service/{{name}}", prefix),
};
case InitSystem::Dinit:
return {
.service_dir = std::format("{}etc/dinit.d", prefix),
.service_dir = (std::filesystem::path(prefix) / "etc/dinit.d").string(),
.enable_cmd = "dinitctl enable",
.disable_cmd = "dinitctl disable",
};
+29 -5
View File
@@ -1,8 +1,10 @@
#include "kappa/system/activate.hpp"
#include <array>
#include <filesystem>
#include <format>
#include <fstream>
#include <string_view>
namespace kappa::system {
@@ -33,15 +35,37 @@ ActivateResult write_timezone(const std::string& timezone,
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 zoneinfo = std::filesystem::path(prefix) / "usr/share/zoneinfo" / timezone;
std::error_code ec;
if (!std::filesystem::exists(zoneinfo, ec)) {
return {false, std::format("timezone data not found: {}", zoneinfo.string())};
static constexpr std::array<std::string_view, 4> zoneinfo_dirs = {
"usr/share/zoneinfo", // glibc/FHS standard
"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);
// Remove existing symlink/file if present
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()) {
diags.push_back({DiagSeverity::Warning,
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; }
os << Indent(d) << "env {\n";
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";
}
os << Indent(d) << "}\n";
@@ -231,6 +237,34 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) {
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()) {
os << "assert {\n";
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
Executable
+1201
View File
File diff suppressed because it is too large Load Diff
+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,
# so rebuild correctly shows it as a new package to build.
check "rebuild detects new init package" \
"$KAPPA_BIN rebuild /tmp/kappa-test-rebuild-s6.kap 2>&1" \
"$KAPPA_BIN rebuild --dry-run /tmp/kappa-test-rebuild-s6.kap 2>&1" \
'packages to rebuild'
# 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
# Note: this requires the installed DB to have the old init hash
check "rebuild detects init change" \
"$KAPPA_BIN rebuild /tmp/kappa-test-rebuild-systemd.kap 2>&1" \
"$KAPPA_BIN rebuild --dry-run /tmp/kappa-test-rebuild-systemd.kap 2>&1" \
'packages to rebuild'
echo ""
-4
View File
@@ -38,10 +38,6 @@ check "format foo.kap round-trips service block" \
"$KAPPA format examples/foo.kap" \
'service {'
check "format shows conflicts when present" \
"$KAPPA format /tmp/test_conflict.kap" \
'conflicts ='
check "format postgres.kap shows named services" \
"$KAPPA format examples/postgres.kap" \
'service checkpointer {'