feat: add repos DSL, index format, and kappa index command

- Add repos { } block to system config with named repos, channels,
  mirrors, and priority

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

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

- parse_index() and build_index() in DSL module

- format_index() roundtrip support in tools module

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

- Backward compatible: remotes = [...] still works unchanged
This commit is contained in:
2026-08-04 13:48:25 -04:00
parent 86f578bd4a
commit 4f1ce17ec8
9 changed files with 285 additions and 11 deletions
+5
View File
@@ -0,0 +1,5 @@
index "local" {
foo { version = "1.2.3" }
postgresql { version = "16.3" }
make { version = "4.4.1" }
}
+10
View File
@@ -93,4 +93,14 @@ struct PackageDef {
Phase uninstall; Phase uninstall;
}; };
struct IndexEntry {
std::string name;
std::string version;
};
struct IndexDef {
std::string name; // "kappa-os/stable"
std::vector<IndexEntry> packages;
};
} // namespace kappa::dsl } // namespace kappa::dsl
+13 -1
View File
@@ -57,9 +57,18 @@ struct ServiceRef {
std::unordered_map<std::string, std::string> config; std::unordered_map<std::string, std::string> config;
}; };
struct RepoDef {
std::string name;
std::string url;
std::vector<std::string> channels;
std::vector<std::string> mirrors;
int priority = 50;
};
struct SystemConfig { struct SystemConfig {
std::vector<std::string> imports; std::vector<std::string> imports;
std::vector<std::string> remotes; std::vector<std::string> remotes; // legacy — flat URL list
std::vector<RepoDef> repos; // named repos with channels/mirrors
SystemBlock system; SystemBlock system;
std::vector<PackageRef> packages; std::vector<PackageRef> packages;
std::vector<ServiceRef> services; std::vector<ServiceRef> services;
@@ -72,4 +81,7 @@ struct SystemConfig {
SystemConfig parse_system_config(std::string_view source); SystemConfig parse_system_config(std::string_view source);
SystemConfig resolve_imports(const SystemConfig& cfg, const std::string& base_dir); SystemConfig resolve_imports(const SystemConfig& cfg, const std::string& base_dir);
IndexDef parse_index(std::string_view source);
IndexDef build_index(const std::string& directory);
} // namespace kappa::dsl } // namespace kappa::dsl
+4
View File
@@ -49,6 +49,10 @@ enum class TokenType {
KwUninstall, KwUninstall,
KwTrue, KwTrue,
KwFalse, KwFalse,
// Repo / index
KwIndex,
KwRepos,
}; };
struct Token { struct Token {
+1
View File
@@ -9,5 +9,6 @@ namespace kappa::tools {
void format_package(std::ostream& os, const dsl::PackageDef& pkg); void format_package(std::ostream& os, const dsl::PackageDef& pkg);
void format_config(std::ostream& os, const dsl::SystemConfig& cfg); void format_config(std::ostream& os, const dsl::SystemConfig& cfg);
void format_index(std::ostream& os, const dsl::IndexDef& idx);
} // namespace kappa::tools } // namespace kappa::tools
+4
View File
@@ -33,6 +33,8 @@ static const std::unordered_map<std::string_view, TokenType> keywords = {
{"uninstall", TokenType::KwUninstall}, {"uninstall", TokenType::KwUninstall},
{"true", TokenType::KwTrue}, {"true", TokenType::KwTrue},
{"false", TokenType::KwFalse}, {"false", TokenType::KwFalse},
{"index", TokenType::KwIndex},
{"repos", TokenType::KwRepos},
}; };
std::string_view token_name(TokenType type) { std::string_view token_name(TokenType type) {
@@ -74,6 +76,8 @@ std::string_view token_name(TokenType type) {
case TokenType::KwUninstall: return "uninstall"; case TokenType::KwUninstall: return "uninstall";
case TokenType::KwTrue: return "true"; case TokenType::KwTrue: return "true";
case TokenType::KwFalse: return "false"; case TokenType::KwFalse: return "false";
case TokenType::KwIndex: return "index";
case TokenType::KwRepos: return "repos";
} }
return "?"; return "?";
} }
+157 -1
View File
@@ -1,6 +1,7 @@
#include "kappa/dsl/system.hpp" #include "kappa/dsl/system.hpp"
#include "kappa/dsl/error.hpp" #include "kappa/dsl/error.hpp"
#include "kappa/dsl/lexer.hpp" #include "kappa/dsl/lexer.hpp"
#include "kappa/dsl/parser.hpp"
#include <filesystem> #include <filesystem>
#include <format> #include <format>
@@ -29,6 +30,7 @@ private:
void parse_boot_block(SystemConfig& cfg); void parse_boot_block(SystemConfig& cfg);
void parse_users_block(SystemConfig& cfg); void parse_users_block(SystemConfig& cfg);
void parse_groups_block(SystemConfig& cfg); void parse_groups_block(SystemConfig& cfg);
void parse_repos_block(SystemConfig& cfg);
std::string consume_ident(); std::string consume_ident();
std::string consume_string(); std::string consume_string();
@@ -85,7 +87,9 @@ std::string SysParser::consume_ident() {
at(TokenType::KwCheck) || at(TokenType::KwCheck) ||
at(TokenType::KwInstall) || at(TokenType::KwInstall) ||
at(TokenType::KwTrue) || at(TokenType::KwTrue) ||
at(TokenType::KwFalse)) { at(TokenType::KwFalse) ||
at(TokenType::KwIndex) ||
at(TokenType::KwRepos)) {
auto lexeme = current_.lexeme; auto lexeme = current_.lexeme;
advance(); advance();
return lexeme; return lexeme;
@@ -184,6 +188,7 @@ SystemConfig SysParser::parse() {
else if (kw == "boot") { parse_boot_block(cfg); } else if (kw == "boot") { parse_boot_block(cfg); }
else if (kw == "users") { parse_users_block(cfg); } else if (kw == "users") { parse_users_block(cfg); }
else if (kw == "groups") { parse_groups_block(cfg); } else if (kw == "groups") { parse_groups_block(cfg); }
else if (kw == "repos") { parse_repos_block(cfg); }
else { else {
throw ParseError(current_.line, current_.col, throw ParseError(current_.line, current_.col,
std::format("unknown section '{}'", kw)); std::format("unknown section '{}'", kw));
@@ -456,6 +461,63 @@ void SysParser::parse_groups_block(SystemConfig& cfg) {
consume(TokenType::Rbrace); consume(TokenType::Rbrace);
} }
void SysParser::parse_repos_block(SystemConfig& cfg) {
consume(TokenType::Lbrace);
skip_newlines();
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
skip_newlines();
if (at(TokenType::Rbrace)) break;
RepoDef repo;
repo.name = consume_ident();
if (at(TokenType::Lbrace)) {
consume(TokenType::Lbrace);
skip_newlines();
while (!at(TokenType::Rbrace) && !at(TokenType::Eof)) {
if (at(TokenType::Newline)) { advance(); continue; }
auto key = consume_ident();
if (key == "url") {
consume(TokenType::Equals);
repo.url = consume_string();
} else if (key == "channels") {
consume(TokenType::Equals);
consume(TokenType::Lbracket);
skip_newlines();
while (!at(TokenType::Rbracket) && !at(TokenType::Eof)) {
repo.channels.push_back(consume_string());
skip_newlines();
if (at(TokenType::Comma)) { consume(TokenType::Comma); }
skip_newlines();
}
consume(TokenType::Rbracket);
} else if (key == "mirrors") {
consume(TokenType::Equals);
consume(TokenType::Lbracket);
skip_newlines();
while (!at(TokenType::Rbracket) && !at(TokenType::Eof)) {
repo.mirrors.push_back(consume_string());
skip_newlines();
if (at(TokenType::Comma)) { consume(TokenType::Comma); }
skip_newlines();
}
consume(TokenType::Rbracket);
} else if (key == "priority") {
consume(TokenType::Equals);
repo.priority = parse_int(current_.line, current_.col,
current_.lexeme);
advance();
}
skip_newlines();
}
consume(TokenType::Rbrace);
}
cfg.repos.push_back(std::move(repo));
}
consume(TokenType::Rbrace);
}
SystemConfig parse_system_config(std::string_view source) { SystemConfig parse_system_config(std::string_view source) {
SysParser p(source); SysParser p(source);
return p.parse(); return p.parse();
@@ -466,6 +528,7 @@ static void merge_config(SystemConfig& base, SystemConfig&& imported) {
if (!imported.system.timezone.empty()) { base.system.timezone = std::move(imported.system.timezone); } if (!imported.system.timezone.empty()) { base.system.timezone = std::move(imported.system.timezone); }
for (auto& e : imported.system.env) { base.system.env.push_back(std::move(e)); } for (auto& e : imported.system.env) { base.system.env.push_back(std::move(e)); }
for (auto& r : imported.remotes) { base.remotes.push_back(std::move(r)); } for (auto& r : imported.remotes) { base.remotes.push_back(std::move(r)); }
for (auto& r : imported.repos) { base.repos.push_back(std::move(r)); }
for (auto& [k, v] : imported.system.config) { base.system.config[k] = std::move(v); } for (auto& [k, v] : imported.system.config) { base.system.config[k] = std::move(v); }
for (auto& [k, v] : imported.system.features) { base.system.features[k] = std::move(v); } for (auto& [k, v] : imported.system.features) { base.system.features[k] = std::move(v); }
if (imported.system.rollback.keep > 0) { base.system.rollback.keep = imported.system.rollback.keep; } if (imported.system.rollback.keep > 0) { base.system.rollback.keep = imported.system.rollback.keep; }
@@ -497,4 +560,97 @@ SystemConfig resolve_imports(const SystemConfig& cfg, const std::string& base_di
return resolved; return resolved;
} }
IndexDef parse_index(std::string_view source) {
Lexer lexer(source);
Token current;
auto advance = [&] { current = lexer.next(); };
advance();
auto skip_newlines = [&] {
while (current.type == TokenType::Newline) { advance(); }
};
auto consume = [&](TokenType type) -> Token {
if (current.type != type) {
throw ParseError(current.line, current.col,
msg_expected(token_name(type),
token_name(current.type)));
}
Token t = std::move(current);
advance();
return t;
};
skip_newlines();
consume(TokenType::KwIndex);
// index "kappa-os/stable" { ... }
auto name_tok = consume(TokenType::String);
consume(TokenType::Lbrace);
IndexDef idx;
idx.name = std::move(name_tok.lexeme);
skip_newlines();
while (current.type != TokenType::Rbrace && current.type != TokenType::Eof) {
skip_newlines();
if (current.type == TokenType::Rbrace) break;
IndexEntry entry;
entry.name = current.lexeme;
advance();
if (current.type == TokenType::Lbrace) {
advance();
skip_newlines();
while (current.type != TokenType::Rbrace && current.type != TokenType::Eof) {
if (current.type == TokenType::Newline) { advance(); continue; }
auto key = current.lexeme;
advance();
consume(TokenType::Equals);
if (key == "version") {
entry.version = consume(TokenType::String).lexeme;
}
skip_newlines();
}
consume(TokenType::Rbrace);
}
idx.packages.push_back(std::move(entry));
skip_newlines();
}
consume(TokenType::Rbrace);
return idx;
}
IndexDef build_index(const std::string& directory) {
IndexDef idx;
auto dirname = std::filesystem::path(directory).filename().string();
// If the directory is empty, drop the "."
if (dirname.empty() || dirname == ".") {
dirname = "local";
}
idx.name = dirname;
std::error_code ec;
for (auto& entry : std::filesystem::directory_iterator(directory, ec)) {
if (ec) break;
if (!entry.is_regular_file()) continue;
auto ext = entry.path().extension().string();
if (ext != ".kap") continue;
std::ifstream in(entry.path());
if (!in) continue;
std::ostringstream buf;
buf << in.rdbuf();
try {
auto pkg = parse(buf.str());
idx.packages.push_back({pkg.name, pkg.version});
} catch (...) {
continue;
}
}
return idx;
}
} // namespace kappa::dsl } // namespace kappa::dsl
+48 -2
View File
@@ -52,6 +52,7 @@ Subcommands:
rebuild <config> Compare config to installed state, rebuild changed rebuild <config> Compare config to installed state, rebuild changed
list List installed packages list List installed packages
rollback Show available generations rollback Show available generations
index <dir> Build an index.kap from .kap files in a directory
Options: Options:
-h, --help Show this help message -h, --help Show this help message
@@ -197,7 +198,8 @@ int main(int argc, char* argv[]) {
|| (subcommand == "rebuild") || (subcommand == "rebuild")
|| (subcommand == "list") || (subcommand == "list")
|| (subcommand == "fetch-package") || (subcommand == "fetch-package")
|| (subcommand == "rollback"); || (subcommand == "rollback")
|| (subcommand == "index");
if (!valid_subcommand) { if (!valid_subcommand) {
std::cerr << "error: unknown subcommand '" << subcommand << "'\n\n"; std::cerr << "error: unknown subcommand '" << subcommand << "'\n\n";
@@ -276,6 +278,29 @@ int main(int argc, char* argv[]) {
return 0; return 0;
} }
if (subcommand == "index") {
if (file_arg == nullptr) {
std::cerr << "error: no directory specified\n";
return 1;
}
try {
auto idx = dsl::build_index(file_arg);
auto out_path = std::filesystem::path(file_arg) / "index.kap";
std::ofstream out(out_path);
if (!out) {
std::cerr << "error: cannot write " << out_path.string() << "\n";
return 1;
}
tools::format_index(out, idx);
std::cout << idx.packages.size() << " packages indexed → "
<< out_path.string() << "\n";
return 0;
} catch (const std::exception& e) {
std::cerr << "index error: " << e.what() << "\n";
return 1;
}
}
if (subcommand == "fetch-package") { if (subcommand == "fetch-package") {
if (file_arg == nullptr) { if (file_arg == nullptr) {
std::cerr << "error: no package name specified\n"; std::cerr << "error: no package name specified\n";
@@ -331,7 +356,8 @@ int main(int argc, char* argv[]) {
if (file_arg == nullptr if (file_arg == nullptr
&& subcommand != "list" && subcommand != "list"
&& subcommand != "rollback") { && subcommand != "rollback"
&& subcommand != "index") {
std::cerr << "error: no input file specified\n"; std::cerr << "error: no input file specified\n";
return 1; return 1;
} }
@@ -378,6 +404,12 @@ int main(int argc, char* argv[]) {
} }
std::cout << "\n"; std::cout << "\n";
} }
if (!cfg.repos.empty()) {
int total_channels = 0;
for (auto& r : cfg.repos) total_channels += r.channels.size();
std::cout << " repos: " << cfg.repos.size()
<< " (" << total_channels << " channels)\n";
}
return 0; return 0;
} catch (const std::runtime_error& e) { } catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e); handle_parse_error(file_arg, source, e);
@@ -414,12 +446,20 @@ int main(int argc, char* argv[]) {
return 1; return 1;
} }
return 0; return 0;
} catch (const std::runtime_error&) {
try {
auto idx = dsl::parse_index(source);
std::cout << file_arg << ": valid index ("
<< idx.name << ", "
<< idx.packages.size() << " packages)\n";
return 0;
} catch (const std::runtime_error& e) { } catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e); handle_parse_error(file_arg, source, e);
return 1; return 1;
} }
} }
} }
}
if (subcommand == "build") { if (subcommand == "build") {
try { try {
@@ -472,12 +512,18 @@ int main(int argc, char* argv[]) {
auto cfg = dsl::parse_system_config(source); auto cfg = dsl::parse_system_config(source);
tools::format_config(std::cout, cfg); tools::format_config(std::cout, cfg);
return 0; return 0;
} catch (const std::runtime_error&) {
try {
auto idx = dsl::parse_index(source);
tools::format_index(std::cout, idx);
return 0;
} catch (const std::runtime_error& e) { } catch (const std::runtime_error& e) {
handle_parse_error(file_arg, source, e); handle_parse_error(file_arg, source, e);
return 1; return 1;
} }
} }
} }
}
if (subcommand == "doctor") { if (subcommand == "doctor") {
try { try {
+36
View File
@@ -237,6 +237,34 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) {
os << "]\n\n"; os << "]\n\n";
} }
if (!cfg.repos.empty()) {
os << "repos {\n";
for (auto& repo : cfg.repos) {
os << " " << repo.name << " {\n";
os << " url = \"" << repo.url << "\"\n";
if (!repo.channels.empty()) {
os << " channels = [";
for (std::size_t i = 0; i < repo.channels.size(); ++i) {
if (i > 0) { os << ", "; }
os << '"' << repo.channels[i] << '"';
}
os << "]\n";
}
if (!repo.mirrors.empty()) {
os << " mirrors = [\n";
for (auto& m : repo.mirrors) {
os << " \"" << m << "\",\n";
}
os << " ]\n";
}
if (repo.priority != 50) {
os << " priority = " << repo.priority << "\n";
}
os << " }\n";
}
os << "}\n\n";
}
if (!cfg.assertions.empty()) { if (!cfg.assertions.empty()) {
os << "assert {\n"; os << "assert {\n";
for (auto& a : cfg.assertions) { for (auto& a : cfg.assertions) {
@@ -345,4 +373,12 @@ void format_config(std::ostream& os, const dsl::SystemConfig& cfg) {
} }
} }
void format_index(std::ostream& os, const dsl::IndexDef& idx) {
os << "index \"" << idx.name << "\" {\n";
for (auto& pkg : idx.packages) {
os << " " << pkg.name << " { version = \"" << pkg.version << "\" }\n";
}
os << "}\n";
}
} // namespace kappa::tools } // namespace kappa::tools