Compare commits
21
Commits
486a476b90
..
0.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ba3fe6632 | ||
|
|
5607be0f1c | ||
|
|
fa1aa42a9c | ||
|
|
76b5a78e75 | ||
|
|
e3d41dcd20 | ||
|
|
ac530e94a7 | ||
|
|
71d2eadcb9 | ||
|
|
dc782e941c | ||
|
|
3302712336 | ||
|
|
4a8b87e2cd | ||
|
|
5668708fed | ||
|
|
07a485a535 | ||
|
|
d10679d267 | ||
|
|
09dde98159 | ||
|
|
7345b026cc | ||
|
|
7b0538c392 | ||
|
|
4f1ce17ec8 | ||
|
|
86f578bd4a | ||
|
|
d02ab4b7b6 | ||
|
|
e817e58698 | ||
|
|
0aca4b49ca |
@@ -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
@@ -33,7 +33,10 @@
|
||||
*.app
|
||||
|
||||
# Build
|
||||
build/
|
||||
/build/
|
||||
/build-debug/
|
||||
/build-release/
|
||||
/dist/
|
||||
build-gcc/
|
||||
compile_commands.json
|
||||
vcpkg_installed/
|
||||
|
||||
+20
-14
@@ -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`
|
||||
|
||||
|
||||
@@ -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,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
@@ -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.
|
||||
@@ -67,6 +67,8 @@ system {
|
||||
|
||||
env {
|
||||
CFLAGS = "-O2 -march=native"
|
||||
// packages inherit this appended flag
|
||||
CFLAGS += "-pipe"
|
||||
LDFLAGS = "-Wl,--as-needed"
|
||||
MAKEFLAGS = "-j8"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
index "local" {
|
||||
foo { version = "1.2.3" }
|
||||
postgresql { version = "16.3" }
|
||||
make { version = "4.4.1" }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,8 @@ package "postgresql" {
|
||||
|
||||
env {
|
||||
CFLAGS = "-O2"
|
||||
// append security hardening flags
|
||||
CFLAGS += "-D_FORTIFY_SOURCE=2"
|
||||
LDFLAGS = "-Wl,--as-needed"
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+203
-13
@@ -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()) {
|
||||
@@ -196,8 +218,11 @@ int main(int argc, char* argv[]) {
|
||||
|| (subcommand == "build")
|
||||
|| (subcommand == "rebuild")
|
||||
|| (subcommand == "list")
|
||||
|| (subcommand == "fetch-package")
|
||||
|| (subcommand == "rollback");
|
||||
|| (subcommand == "fetch-package")
|
||||
|| (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,8 +259,12 @@ int main(int argc, char* argv[]) {
|
||||
continue;
|
||||
}
|
||||
if (!is_flag(argv[i])) {
|
||||
file_arg = argv[i];
|
||||
break;
|
||||
if (file_arg == nullptr) {
|
||||
file_arg = argv[i];
|
||||
} else if (version_arg == nullptr) {
|
||||
version_arg = argv[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +577,17 @@ int main(int argc, char* argv[]) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
} catch (const std::runtime_error& e) {
|
||||
handle_parse_error(file_arg, source, e);
|
||||
return 1;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -472,9 +643,15 @@ 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& e) {
|
||||
handle_parse_error(file_arg, source, e);
|
||||
return 1;
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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 {'
|
||||
|
||||
Reference in New Issue
Block a user