Compare commits

21 Commits
Author SHA1 Message Date
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
26 changed files with 2790 additions and 74 deletions
+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
+4 -1
View File
@@ -33,7 +33,10 @@
*.app
# Build
build/
/build/
/build-debug/
/build-release/
/dist/
build-gcc/
compile_commands.json
vcpkg_installed/
+20 -14
View File
@@ -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,32 @@ 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)
+72 -28
View File
@@ -72,16 +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.
```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
@@ -113,41 +142,53 @@ kappa rebuild config.kap # boot.init = "openrc" — only 5 packages actually r
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
@@ -156,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.
+2
View File
@@ -67,6 +67,8 @@ system {
env {
CFLAGS = "-O2 -march=native"
// packages inherit this appended flag
CFLAGS += "-pipe"
LDFLAGS = "-Wl,--as-needed"
MAKEFLAGS = "-j8"
}
+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"
}
+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
+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
+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 {
+7
View File
@@ -1,5 +1,7 @@
#pragma once
#include "kappa/dsl/system.hpp"
#include <string>
#include <vector>
@@ -13,7 +15,12 @@ struct RecipeResult {
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
+1
View File
@@ -9,5 +9,6 @@ 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
+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_};
+8 -4
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);
+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
+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
+194 -4
View File
@@ -52,6 +52,9 @@ Subcommands:
rebuild <config> Compare config to installed state, rebuild changed
list List installed packages
rollback Show available generations
index <dir> Build an index.kap from .kap files in a directory
add <pkg> [v] Add a package to system config (optional version)
remove <pkg> Remove a package from system config
Options:
-h, --help Show this help message
@@ -131,7 +134,26 @@ static resolve::Registry build_registry(const dsl::SystemConfig& cfg) {
}
}
// If not found locally, try remotes
// If not found locally, try repos (index-aware) first, then legacy remotes
if (!found && !cfg.repos.empty()) {
auto result = fetch::fetch_recipe_from_repos(pref.name, cfg.repos);
if (result.ok && !result.path.empty()) {
std::ifstream in(result.path);
if (in) {
std::ostringstream buf;
buf << in.rdbuf();
try {
auto pkg = dsl::parse(buf.str());
registry[pkg.name] = std::move(pkg);
found = true;
} catch (const std::exception& e) {
std::cerr << "warning: parse error in remote recipe " << pref.name << ": " << e.what() << "\n";
} catch (...) {
std::cerr << "warning: unknown parse error in remote recipe " << pref.name << "\n";
}
}
}
}
if (!found && !cfg.remotes.empty()) {
auto result = fetch::fetch_recipe(pref.name, cfg.remotes);
if (result.ok && !result.path.empty()) {
@@ -197,7 +219,10 @@ int main(int argc, char* argv[]) {
|| (subcommand == "rebuild")
|| (subcommand == "list")
|| (subcommand == "fetch-package")
|| (subcommand == "rollback");
|| (subcommand == "rollback")
|| (subcommand == "index")
|| (subcommand == "add")
|| (subcommand == "remove");
if (!valid_subcommand) {
std::cerr << "error: unknown subcommand '" << subcommand << "'\n\n";
@@ -206,6 +231,7 @@ int main(int argc, char* argv[]) {
}
const char* file_arg = nullptr;
const char* version_arg = nullptr;
bool dry_run = false;
for (int i = arg_start + 1; i < argc; ++i) {
if (std::string_view(argv[i]) == "-h"
@@ -233,10 +259,14 @@ int main(int argc, char* argv[]) {
continue;
}
if (!is_flag(argv[i])) {
if (file_arg == nullptr) {
file_arg = argv[i];
} else if (version_arg == nullptr) {
version_arg = argv[i];
break;
}
}
}
if (subcommand == "list") {
auto entries = install::read_installed();
@@ -276,6 +306,125 @@ int main(int argc, char* argv[]) {
return 0;
}
if (subcommand == "index") {
if (file_arg == nullptr) {
std::cerr << "error: no directory specified\n";
return 1;
}
try {
auto idx = dsl::build_index(file_arg);
auto out_path = std::filesystem::path(file_arg) / "index.kap";
std::ofstream out(out_path);
if (!out) {
std::cerr << "error: cannot write " << out_path.string() << "\n";
return 1;
}
tools::format_index(out, idx);
std::cout << idx.packages.size() << " packages indexed → "
<< out_path.string() << "\n";
return 0;
} catch (const std::exception& e) {
std::cerr << "index error: " << e.what() << "\n";
return 1;
}
}
if (subcommand == "add") {
if (file_arg == nullptr) {
std::cerr << "error: no package name specified\n";
return 1;
}
try {
auto config_path = std::filesystem::path(paths::system_dir()) / "config.kap";
dsl::SystemConfig cfg;
if (std::filesystem::exists(config_path)) {
auto src = read_file(config_path.c_str());
cfg = dsl::parse_system_config(src);
}
bool found = false;
for (auto& p : cfg.packages) {
if (p.name == file_arg) {
found = true;
if (version_arg != nullptr) {
p.version = version_arg;
std::cout << "updated " << file_arg
<< " → version " << version_arg << "\n";
} else {
std::cout << file_arg << " already in packages\n";
}
break;
}
}
if (!found) {
dsl::PackageRef pref;
pref.name = file_arg;
if (version_arg != nullptr) {
pref.version = version_arg;
}
cfg.packages.push_back(std::move(pref));
std::cout << "added " << file_arg;
if (version_arg != nullptr) {
std::cout << " " << version_arg;
}
std::cout << "\n";
}
std::error_code ec;
std::filesystem::create_directories(paths::system_dir(), ec);
std::ofstream out(config_path);
if (!out) {
std::cerr << "error: cannot write " << config_path.string() << "\n";
return 1;
}
tools::format_config(out, cfg);
return 0;
} catch (const std::exception& e) {
std::cerr << "add error: " << e.what() << "\n";
return 1;
}
}
if (subcommand == "remove") {
if (file_arg == nullptr) {
std::cerr << "error: no package name specified\n";
return 1;
}
try {
auto config_path = std::filesystem::path(paths::system_dir()) / "config.kap";
if (!std::filesystem::exists(config_path)) {
std::cout << file_arg << " not found (no config)\n";
return 0;
}
auto src = read_file(config_path.c_str());
auto cfg = dsl::parse_system_config(src);
auto it = std::remove_if(cfg.packages.begin(), cfg.packages.end(),
[&](const dsl::PackageRef& p) { return p.name == file_arg; });
if (it == cfg.packages.end()) {
std::cout << file_arg << " not in packages\n";
return 0;
}
cfg.packages.erase(it, cfg.packages.end());
std::cout << "removed " << file_arg << "\n";
std::ofstream out(config_path);
if (!out) {
std::cerr << "error: cannot write " << config_path.string() << "\n";
return 1;
}
tools::format_config(out, cfg);
return 0;
} catch (const std::exception& e) {
std::cerr << "remove error: " << e.what() << "\n";
return 1;
}
}
if (subcommand == "fetch-package") {
if (file_arg == nullptr) {
std::cerr << "error: no package name specified\n";
@@ -283,19 +432,26 @@ int main(int argc, char* argv[]) {
}
try {
std::vector<std::string> remotes;
std::vector<dsl::RepoDef> repos;
auto config_path = std::filesystem::path(paths::system_dir()) / "config.kap";
if (std::filesystem::exists(config_path)) {
auto cfg_src = read_file(config_path.c_str());
try {
auto cfg = dsl::parse_system_config(cfg_src);
remotes = cfg.remotes;
repos = std::move(cfg.repos);
} catch (const std::exception& e) {
std::cerr << "warning: config parse error: " << e.what() << "\n";
} catch (...) {
std::cerr << "warning: unknown config parse error\n";
}
}
auto result = fetch::fetch_recipe(file_arg, remotes);
fetch::RecipeResult result;
if (!repos.empty()) {
result = fetch::fetch_recipe_from_repos(file_arg, repos);
} else {
result = fetch::fetch_recipe(file_arg, remotes);
}
if (result.ok) {
if (result.updated) {
std::cout << "fetched " << file_arg << " " << result.version
@@ -331,7 +487,8 @@ int main(int argc, char* argv[]) {
if (file_arg == nullptr
&& subcommand != "list"
&& subcommand != "rollback") {
&& subcommand != "rollback"
&& subcommand != "index") {
std::cerr << "error: no input file specified\n";
return 1;
}
@@ -378,6 +535,12 @@ int main(int argc, char* argv[]) {
}
std::cout << "\n";
}
if (!cfg.repos.empty()) {
int total_channels = 0;
for (auto& r : cfg.repos) total_channels += r.channels.size();
std::cout << " repos: " << cfg.repos.size()
<< " (" << total_channels << " channels)\n";
}
return 0;
} catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e);
@@ -414,12 +577,20 @@ int main(int argc, char* argv[]) {
return 1;
}
return 0;
} catch (const std::runtime_error&) {
try {
auto idx = dsl::parse_index(source);
std::cout << file_arg << ": valid index ("
<< idx.name << ", "
<< idx.packages.size() << " packages)\n";
return 0;
} catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e);
return 1;
}
}
}
}
if (subcommand == "build") {
try {
@@ -472,12 +643,18 @@ int main(int argc, char* argv[]) {
auto cfg = dsl::parse_system_config(source);
tools::format_config(std::cout, cfg);
return 0;
} catch (const std::runtime_error&) {
try {
auto idx = dsl::parse_index(source);
tools::format_index(std::cout, idx);
return 0;
} catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e);
return 1;
}
}
}
}
if (subcommand == "doctor") {
try {
@@ -537,6 +714,14 @@ int main(int argc, char* argv[]) {
return 1;
}
if (!plan.conflicts.empty()) {
std::cerr << "error: package conflicts detected:\n";
for (auto& c : plan.conflicts) {
std::cerr << " " << c << "\n";
}
return 1;
}
std::cout << plan.steps.size() << " packages in build order:\n";
for (auto& step : plan.steps) {
std::cout << " " << step.name << " (" << step.dependencies.size()
@@ -604,6 +789,11 @@ int main(int argc, char* argv[]) {
for (auto& c : plan.cycles) std::cerr << " " << c << "\n";
return 1;
}
if (!plan.conflicts.empty()) {
std::cerr << "error: package conflicts detected:\n";
for (auto& c : plan.conflicts) std::cerr << " " << c << "\n";
return 1;
}
if (plan.steps.empty()) {
std::cout << "nothing to build\n";
return 0;
+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
+992
View File
@@ -0,0 +1,992 @@
#!/bin/sh
# kappa comprehensive test suite — DSL, ops, error handling, edge cases
set -e
KAPPA="${KAPPA_BIN:-./build/kappa}"
PASS=0
FAIL=0
check() {
local desc="$1" cmd="$2" expect="$3"
local out
out=$(eval "$cmd" 2>&1) || true
if echo "$out" | grep -q "$expect"; then
echo " ✓ $desc"
PASS=$((PASS + 1))
else
echo " ✗ $desc"
echo " expected: $expect"
echo " got: $(echo "$out" | head -3 | tr '\n' ' ')"
FAIL=$((FAIL + 1))
fi
}
check_fail() {
local desc="$1" cmd="$2" expect="$3"
local out rc=0
out=$(eval "$cmd" 2>&1) || rc=$?
if [ "$rc" -ne 0 ] && echo "$out" | grep -q "$expect"; then
echo " ✓ $desc"
PASS=$((PASS + 1))
else
echo " ✗ $desc"
echo " expected exit≠0 with: $expect"
echo " exit: $rc, got: $(echo "$out" | head -3 | tr '\n' ' ')"
FAIL=$((FAIL + 1))
fi
}
TMPDIR=$(mktemp -d /tmp/kappa-full-test-XXXXX)
trap 'rm -rf $TMPDIR' EXIT
export KAPPA_ROOT="$TMPDIR/root"
mkdir -p "$KAPPA_ROOT"/{bin,temp,db,system,builds,cache/{packages,indexes}}
# ═══════════════════════════════════════════
echo ""
echo "=== CLI: help, version, error handling ==="
echo ""
check "kappa --help" \
"$KAPPA --help" \
'Usage'
check "kappa -h" \
"$KAPPA -h" \
'Subcommands'
check "kappa -V" \
"$KAPPA -V" \
'kappa 0.1.0'
check_fail "missing subcommand" \
"$KAPPA" \
'missing subcommand'
check_fail "unknown subcommand" \
"$KAPPA fakething" \
"unknown subcommand 'fakething'"
check_fail "missing file arg" \
"$KAPPA parse-package" \
'no input file'
check_fail "nonexistent file" \
"$KAPPA parse-package /nonexistent/file.kap" \
'cannot open'
# ═══════════════════════════════════════════
echo ""
echo "=== DSL: parse-package and validate ==="
echo ""
cat > "$TMPDIR/basic.kap" << 'KAPEOF'
package "testpkg" {
version = "1.0"
source = "https://example.com/test-1.0.tar.gz"
sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
license = "MIT"
provides = ["libtest.so.1"]
build { make -j${jobs} }
install { make DESTDIR=${destdir} install }
}
KAPEOF
check "basic package parse" \
"$KAPPA parse-package $TMPDIR/basic.kap" \
'package "testpkg" 1.0 — valid'
check "validate package" \
"$KAPPA validate $TMPDIR/basic.kap" \
'valid package'
check_fail "unclosed brace" \
"printf 'package \"bad\" {\n version = \"1\"\n' | $KAPPA parse-package /dev/stdin" \
'unclosed'
# ═══════════════════════════════════════════
echo ""
echo "=== DSL: all keywords parse and format ==="
echo ""
cat > "$TMPDIR/full.kap" << 'KAPEOF'
package "fullpkg" {
const version = "2.0"
const source = "https://example.com/full-v2.0.tar.gz"
sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
sha512 = "abc"
md5 = "def"
license = "BSD-2"
provides = ["full", "libfull.so"]
outputs = ["bin", "lib"]
conflicts = ["incompatible"]
depends = [{ name = "zlib", version = ">=1.2" }, "openssl"]
features {
ssl = { enabled = true, flag = "--with-ssl" }
debug = false
}
config {
file "etc/full.conf" mode = "default" {
port = "8080"
}
}
env {
CFLAGS = "-O2"
CFLAGS += "-pipe"
LDFLAGS ?= "-s"
}
patches = [{ url = "https://example.com/fix.patch", sha256 = "abc", level = 1 }]
service {
exec = "/usr/bin/full"
type = "forking"
ports = [80, 443]
}
assert {
"ssl required" : features.ssl == "true"
}
prepare { echo prepare }
build { make -j4 }
check { make check }
install { make DESTDIR=/tmp/dest install }
}
KAPEOF
check "full keyword parse" \
"$KAPPA parse-package $TMPDIR/full.kap" \
'package "fullpkg"'
check "format full roundtrip" \
"$KAPPA format $TMPDIR/full.kap" \
'package "fullpkg"'
check "format preserves provides" \
"$KAPPA format $TMPDIR/full.kap" \
'libfull.so'
check "format preserves features" \
"$KAPPA format $TMPDIR/full.kap" \
'features {'
check "format preserves env +=" \
"$KAPPA format $TMPDIR/full.kap" \
'CFLAGS +='
check "format preserves env ?=" \
"$KAPPA format $TMPDIR/full.kap" \
'LDFLAGS ?='
check "format preserves service" \
"$KAPPA format $TMPDIR/full.kap" \
'/usr/bin/full'
check "format preserves asserts" \
"$KAPPA format $TMPDIR/full.kap" \
'assert {'
# ═══════════════════════════════════════════
echo ""
echo "=== Config: parse-config and format ==="
echo ""
cat > "$TMPDIR/simple-config.kap" << 'KAPEOF'
system {
hostname = "test"
}
packages {
testpkg {}
}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "parse-config basic" \
"$KAPPA parse-config $TMPDIR/simple-config.kap" \
'valid'
check "parse-config shows init" \
"$KAPPA parse-config $TMPDIR/simple-config.kap" \
's6'
check "validate config" \
"$KAPPA validate $TMPDIR/simple-config.kap" \
'valid system configuration'
# ═══════════════════════════════════════════
echo ""
echo "=== Config: users and groups ==="
echo ""
cat > "$TMPDIR/users-config.kap" << 'KAPEOF'
system {
hostname = "test"
}
packages {}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
groups {
wheel { gid = 998 }
audio {}
}
users {
root {
shell = "/bin/zsh"
groups = ["wheel", "audio"]
}
}
KAPEOF
check "parse-config with users" \
"$KAPPA parse-config $TMPDIR/users-config.kap" \
'1 users'
check "format users roundtrip" \
"$KAPPA format $TMPDIR/users-config.kap" \
'/bin/zsh'
# ═══════════════════════════════════════════
echo ""
echo "=== Config: assertions ==="
echo ""
cat > "$TMPDIR/assert-pass.kap" << 'KAPEOF'
system { hostname = "test" }
packages { testpkg {} }
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
assert {
"root required" : boot.root != ""
"init must be s6" : boot.init == "s6"
}
KAPEOF
check "validate passes assertions" \
"$KAPPA validate $TMPDIR/assert-pass.kap" \
'valid system configuration'
cat > "$TMPDIR/assert-fail.kap" << 'KAPEOF'
system { hostname = "test" }
packages {}
services {}
boot {
kernel = "linux"
init = "openrc"
root = "/dev/sda1"
bootloader = "limine"
}
assert {
"must be s6" : boot.init == "s6"
}
KAPEOF
check_fail "validate fails assertion" \
"$KAPPA validate $TMPDIR/assert-fail.kap" \
'assertion failures'
# ═══════════════════════════════════════════
echo ""
echo "=== Config: imports ==="
echo ""
cat > "$TMPDIR/imported.kap" << 'KAPEOF'
system { hostname = "imported.local" }
packages {}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
cat > "$TMPDIR/importer.kap" << 'KAPEOF'
imports = ["imported.kap"]
packages {}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "imports merges hostname" \
"$KAPPA parse-config $TMPDIR/importer.kap" \
'valid'
# ═══════════════════════════════════════════
echo ""
echo "=== Doctor diagnostics ==="
echo ""
cat > "$TMPDIR/doctor-bad.kap" << 'KAPEOF'
package "bad" {
version = ""
source = ""
build { echo ok }
install { echo ok }
}
KAPEOF
check_fail "doctor catches empty version" \
"$KAPPA doctor $TMPDIR/doctor-bad.kap" \
'version is not set'
check_fail "doctor catches empty source" \
"$KAPPA doctor $TMPDIR/doctor-bad.kap" \
'source URL is not set'
check_fail "doctor warns missing license" \
"$KAPPA doctor $TMPDIR/doctor-bad.kap" \
'license is not specified'
cat > "$TMPDIR/doctor-badcfg.kap" << 'KAPEOF'
system {}
packages {}
services {}
boot {
kernel = "linux"
init = "fakenit"
root = "/dev/sda1"
bootloader = "lilo"
}
KAPEOF
check_fail "doctor warns missing hostname" \
"$KAPPA doctor $TMPDIR/doctor-badcfg.kap" \
'hostname is not set'
check_fail "doctor warns unknown init" \
"$KAPPA doctor $TMPDIR/doctor-badcfg.kap" \
'not a recognized init system'
check_fail "doctor warns unknown bootloader" \
"$KAPPA doctor $TMPDIR/doctor-badcfg.kap" \
'not a recognized bootloader'
# ═══════════════════════════════════════════
echo ""
echo "=== Repos: parsing and formatting ==="
echo ""
cat > "$TMPDIR/repos-config.kap" << 'KAPEOF'
system { hostname = "test" }
repos {
upstream {
url = "https://packages.example.com/"
channels = ["stable", "testing"]
mirrors = [
"https://cdn.example.com/",
"https://eu.example.com/",
]
priority = 100
}
local {
url = "file:///usr/local/kappa/repo/"
channels = ["default"]
}
}
packages { testpkg {} }
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "parse-config with repos" \
"$KAPPA parse-config $TMPDIR/repos-config.kap" \
'repos: 2'
check "format repos roundtrip" \
"$KAPPA format $TMPDIR/repos-config.kap" \
'upstream {'
check "format repos preserves channels" \
"$KAPPA format $TMPDIR/repos-config.kap" \
'channels = \['
check "format repos preserves mirrors" \
"$KAPPA format $TMPDIR/repos-config.kap" \
'cdn.example.com'
check "format repos preserves priority" \
"$KAPPA format $TMPDIR/repos-config.kap" \
'priority = 100'
# ═══════════════════════════════════════════
echo ""
echo "=== Backward compat: remotes flat list ==="
echo ""
cat > "$TMPDIR/remotes-config.kap" << 'KAPEOF'
remotes = [
"https://old.example.com/",
"https://old2.example.com/",
]
system { hostname = "test" }
packages {}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "flat remotes still parse" \
"$KAPPA parse-config $TMPDIR/remotes-config.kap" \
'valid'
check "format remotes roundtrip" \
"$KAPPA format $TMPDIR/remotes-config.kap" \
'remotes = \['
# ═══════════════════════════════════════════
echo ""
echo "=== Index: generation and validation ==="
echo ""
check "kappa index generates index.kap" \
"$KAPPA index $TMPDIR" \
'packages indexed'
check "validate index.kap" \
"$KAPPA validate $TMPDIR/index.kap" \
'valid index'
check "format index roundtrip" \
"$KAPPA format $TMPDIR/index.kap" \
'index "'
check_fail "index needs directory arg" \
"$KAPPA index" \
'no directory'
# ═══════════════════════════════════════════
echo ""
echo "=== Package operations: add and remove ==="
echo ""
rm -f "$KAPPA_ROOT/system/config.kap"
check "add creates config" \
"$KAPPA add newpkg" \
'added newpkg'
check "add with version" \
"$KAPPA add verpkg 2.0" \
'added verpkg 2.0'
check "add existing updates version" \
"$KAPPA add newpkg 3.0" \
'updated newpkg'
check "add existing idempotent" \
"$KAPPA add verpkg" \
'already in packages'
check "remove package" \
"$KAPPA remove verpkg" \
'removed verpkg'
check "remove nonexistent idempotent" \
"$KAPPA remove nonexistent" \
'not in packages'
check_fail "add needs name" \
"$KAPPA add" \
'no package name'
check_fail "remove needs name" \
"$KAPPA remove" \
'no package name'
# ═══════════════════════════════════════════
echo ""
echo "=== Install: list and rollback ==="
echo ""
check "list empty" \
"$KAPPA list" \
'no packages installed'
mkdir -p "$KAPPA_ROOT/db"
cat > "$KAPPA_ROOT/db/installed" << 'EOF'
testpkg 1.0 a1b2c3d4e5f6a7b8
otherpkg 2.0 b2c3d4e5f6a7b8c9 libother.so
EOF
check "list shows installed" \
"$KAPPA list" \
'2 packages installed'
check "list shows provides" \
"$KAPPA list" \
'libother.so'
mkdir -p "$KAPPA_ROOT/db/generations"
echo "a1b2c3d4e5f6a7b8" > "$KAPPA_ROOT/db/generations/gen-0001"
echo "b2c3d4e5f6a7b8c9" > "$KAPPA_ROOT/db/generations/gen-0002"
check "rollback shows generations" \
"$KAPPA rollback" \
'2 generations'
check "rollback shows gen IDs" \
"$KAPPA rollback" \
'gen-0001'
# ═══════════════════════════════════════════
echo ""
echo "=== Resolver: conflicts ==="
echo ""
mkdir -p "$KAPPA_ROOT/cache/packages"
cat > "$KAPPA_ROOT/cache/packages/a.kap" << 'KAPEOF'
package "a" {
version = "1.0"
source = "a.tar.gz"
conflicts = ["b"]
build { echo a }
install { echo a > ${destdir}/tmp/a }
}
KAPEOF
cat > "$KAPPA_ROOT/cache/packages/b.kap" << 'KAPEOF'
package "b" {
version = "1.0"
source = "b.tar.gz"
conflicts = ["a"]
build { echo b }
install { echo b > ${destdir}/tmp/b }
}
KAPEOF
cat > "$TMPDIR/conflict-config.kap" << 'KAPEOF'
system { hostname = "test" }
packages { a {} b {} }
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check_fail "resolve catches conflicts" \
"$KAPPA resolve $TMPDIR/conflict-config.kap" \
'conflicts detected'
check_fail "rebuild catches conflicts" \
"$KAPPA rebuild $TMPDIR/conflict-config.kap" \
'conflicts detected'
cat > "$TMPDIR/missing-config.kap" << 'KAPEOF'
system { hostname = "test" }
packages { nonexistent {} }
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "resolve warns on missing" \
"$KAPPA resolve $TMPDIR/missing-config.kap 2>&1" \
'not found in registry'
# ═══════════════════════════════════════════
echo ""
echo "=== Scheduler: deptree ordering ==="
echo ""
cat > "$KAPPA_ROOT/cache/packages/dep-a.kap" << 'KAPEOF'
package "dep-a" {
version = "1.0"
source = "a.tar.gz"
build { echo a }
install { echo a > ${destdir}/tmp/a }
}
KAPEOF
cat > "$KAPPA_ROOT/cache/packages/dep-b.kap" << 'KAPEOF'
package "dep-b" {
version = "1.0"
source = "b.tar.gz"
depends = [{ name = "dep-a" }]
build { echo b }
install { echo b > ${destdir}/tmp/b }
}
KAPEOF
cat > "$KAPPA_ROOT/cache/packages/dep-c.kap" << 'KAPEOF'
package "dep-c" {
version = "1.0"
source = "c.tar.gz"
depends = [{ name = "dep-b" }]
build { echo c }
install { echo c > ${destdir}/tmp/c }
}
KAPEOF
cat > "$TMPDIR/deptree-config.kap" << 'KAPEOF'
system { hostname = "test" }
packages { dep-a {} dep-b {} dep-c {} }
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "resolve deptree (a=0 deps)" \
"$KAPPA resolve $TMPDIR/deptree-config.kap 2>&1" \
'dep-a (0 deps)'
check "resolve deptree (c has dep)" \
"$KAPPA resolve $TMPDIR/deptree-config.kap 2>&1" \
'dep-c (1 deps)'
# ═══════════════════════════════════════════
echo ""
echo "=== Build: simple execution ==="
echo ""
cat > "$TMPDIR/simple-build.kap" << 'KAPEOF'
package "simple" {
version = "1.0"
source = "simple-1.0.tar.gz"
sha256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
license = "MIT"
build { echo built }
check { echo checked }
install {
mkdir -p ${destdir}/tmp
echo done > ${destdir}/tmp/simple
}
}
KAPEOF
check "build simple package" \
"$KAPPA build $TMPDIR/simple-build.kap" \
'build successful'
check "installed after build" \
"$KAPPA list" \
'simple'
# ═══════════════════════════════════════════
echo ""
echo "=== Build: error handling ==="
echo ""
cat > "$TMPDIR/fail-build.kap" << 'KAPEOF'
package "failpkg" {
version = "1.0"
source = "fail.tar.gz"
sha256 = "abc"
license = "MIT"
build { exit 42 }
install { echo ok }
}
KAPEOF
check_fail "build reports exit code" \
"$KAPPA build $TMPDIR/fail-build.kap" \
'exited with code 42'
check_fail "build reports failed phase" \
"$KAPPA build $TMPDIR/fail-build.kap" \
'build'
# ═══════════════════════════════════════════
echo ""
echo "=== Build: env operators ==="
echo ""
cat > "$TMPDIR/env-ops.kap" << 'KAPEOF'
package "envops" {
version = "1.0"
source = "envops.tar.gz"
sha256 = "abc"
license = "MIT"
env {
FLAGS = "base"
FLAGS += "extra"
FLAGS ?= "soft"
}
build { echo ok }
install { mkdir -p ${destdir}/tmp }
}
KAPEOF
check "format shows env =" \
"$KAPPA format $TMPDIR/env-ops.kap" \
'FLAGS = "base"'
check "format shows env +=" \
"$KAPPA format $TMPDIR/env-ops.kap" \
'FLAGS += "extra"'
check "format shows env ?=" \
"$KAPPA format $TMPDIR/env-ops.kap" \
'FLAGS ?= "soft"'
# ═══════════════════════════════════════════
echo ""
echo "=== Init systems: all 5 recognized ==="
echo ""
for init in systemd openrc s6 runit dinit; do
cat > "$TMPDIR/init-$init.kap" << KAPEOF
system { hostname = "test" }
packages {}
services {}
boot {
kernel = "linux"
init = "$init"
root = "/dev/sda1"
bootloader = "limine"
}
users {
root { shell = "/bin/sh" }
}
KAPEOF
check "parse-config recognizes $init" \
"$KAPPA parse-config $TMPDIR/init-$init.kap" \
'valid'
done
# ═══════════════════════════════════════════
echo ""
echo "=== Bootloaders: both recognized ==="
echo ""
for bl in grub limine; do
cat > "$TMPDIR/bl-$bl.kap" << KAPEOF
system { hostname = "test" }
packages {}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "$bl"
}
users {
root { shell = "/bin/sh" }
}
KAPEOF
check "parse-config recognizes $bl" \
"$KAPPA parse-config $TMPDIR/bl-$bl.kap" \
'valid'
done
# ═══════════════════════════════════════════
echo ""
echo "=== Variable interpolation: format preserves ==="
echo ""
cat > "$TMPDIR/vars.kap" << 'KAPEOF'
package "vars" {
version = "1.0"
source = "https://example.com/vars-1.0.tar.gz"
sha256 = "abc"
license = "MIT"
build {
./configure --prefix=${prefix}
make -j${jobs}
}
install {
make DESTDIR=${destdir} install
}
}
KAPEOF
check "format preserves prefix var" \
"$KAPPA format $TMPDIR/vars.kap" \
'prefix=${prefix}'
check "format preserves jobs var" \
"$KAPPA format $TMPDIR/vars.kap" \
'j${jobs}'
check "format preserves destdir var" \
"$KAPPA format $TMPDIR/vars.kap" \
'DESTDIR=${destdir}'
# ═══════════════════════════════════════════
echo ""
echo "=== Paths: --root override ==="
echo ""
ALTROOT="$TMPDIR/altroot"
mkdir -p "$ALTROOT/system"
cat > "$ALTROOT/system/config.kap" << 'KAPEOF'
system { hostname = "alt" }
packages {}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "--root flag works" \
"$KAPPA --root $ALTROOT parse-config $ALTROOT/system/config.kap" \
'valid'
$KAPPA --root "$ALTROOT" add rootpkg 2>/dev/null
check "verify add wrote to alt root" \
"grep -c rootpkg $ALTROOT/system/config.kap" \
'1'
# ═══════════════════════════════════════════
echo ""
echo "=== Config: rollback config ==="
echo ""
cat > "$TMPDIR/rollback-cfg.kap" << 'KAPEOF'
system {
hostname = "test"
rollback {
keep = 3
}
}
packages {}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "format preserves rollback keep" \
"$KAPPA format $TMPDIR/rollback-cfg.kap" \
'keep = 3'
# ═══════════════════════════════════════════
echo ""
echo "=== Config: system env block ==="
echo ""
cat > "$TMPDIR/sysenv-config.kap" << 'KAPEOF'
system {
hostname = "test"
env {
CFLAGS = "-O2"
CFLAGS += "-pipe"
}
}
packages {}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "parse-config with system env" \
"$KAPPA parse-config $TMPDIR/sysenv-config.kap" \
'valid'
# ═══════════════════════════════════════════
echo ""
echo "=== DSL: feature flags ==="
echo ""
cat > "$TMPDIR/features-config.kap" << 'KAPEOF'
system {
hostname = "test"
features {
ssl = true
wayland = false
}
}
packages {
testpkg {
features {
debug = true
}
}
}
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "parse-config with features" \
"$KAPPA parse-config $TMPDIR/features-config.kap" \
'valid'
# ═══════════════════════════════════════════
echo ""
echo "=== Rebuild: dry-run detection ==="
echo ""
# Package must be in registry + installed DB for rebuild
cp "$TMPDIR/basic.kap" "$KAPPA_ROOT/cache/packages/testpkg.kap"
mkdir -p "$KAPPA_ROOT/db"
echo "testpkg 1.0 0000000000000000" > "$KAPPA_ROOT/db/installed"
cat > "$TMPDIR/rebuild-config.kap" << 'KAPEOF'
system { hostname = "test" }
packages { testpkg {} }
services {}
boot {
kernel = "linux"
init = "s6"
root = "/dev/sda1"
bootloader = "limine"
}
KAPEOF
check "rebuild dry-run detects changes" \
"$KAPPA rebuild --dry-run $TMPDIR/rebuild-config.kap 2>&1" \
'nothing to rebuild'
check "resolve shows build plan" \
"$KAPPA resolve $TMPDIR/rebuild-config.kap 2>&1" \
'packages in build order'
# ═══════════════════════════════════════════
echo ""
echo "=========================================="
echo " Results: $PASS passed, $FAIL failed"
echo "=========================================="
[ "$FAIL" -eq 0 ] || exit 1
-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 {'