diff --git a/.env.example b/.env.example index ca586b8..fa03c07 100644 --- a/.env.example +++ b/.env.example @@ -103,11 +103,13 @@ HTTP_CLIENT_CONNECT_TIMEOUT=10 HTTP_CLIENT_RETRY=2 HTTP_CLIENT_MAX_RESPONSE_BYTES=33554432 # 32 MiB OOM guard -# ── Multi-tenancy (Tenancy plugin — control plane) ─────────────────────────── -# Only needed when deploying the multi-tenant control plane. -# TENANCY_MODE=subdomain -# TENANCY_BASE_DOMAINS=example.com -# TENANCY_CONTROL_PLANE=admin.example.com +# ── Multi-tenancy (Tenancy plugin) ─────────────────────────────────────────── +# Only needed when the Tenancy plugin is enabled. See that plugin's README for +# the authoritative list — these are the ones a domain-mode deployment needs. +# TENANCY_MODE=domain # claim | domain | host +# TENANCY_BASE_DOMAINS=example.com # domain mode: tenant label hangs off these +# TENANCY_CENTRAL_DOMAINS=example.com,admin.example.com # hosts served CENTRAL (else 404 under strict routing) +# TENANCY_CONTROL_PLANE=false # bool — true disables tenant routing entirely # ── Views / Frontend (View + ViteManifest plugins) ─────────────────────────── # VIEW_PATHS= # extra template roots, prepended to the cascade diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b777f4..37ee7cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,87 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.13.0] - 2026-09-03 + +### Fixed +- **`hkm install --owner=` left every plugin file owned by the deploying user.** + A project's plugins are not in the project: `hkm plugins install` keeps one + copy per (plugin, version, origin) in the global store and links the project + at it, so `plugins/Logger` is a symlink out of the tree. Both halves of the + hardening pass stopped at that boundary by design — `hardenTree` skips + symlinks because a chmod would follow one and rewrite a target outside the + project, and the chown only walked the project root. The result was a project + that verified clean and could not serve: every file the pool has to read + first, every Provider and every controller a route resolves to, still belonged + to whoever ran the command, under a report that said `Project owned by + deploy:www-data`. `--owner` now also chowns the store versions the project + links to, plus the directories between them and the store root so the trees it + just chowned can be reached. Only the versions THIS project links to: the store + is shared by every project on the machine, and claiming all of it for one + project's web account is not that command's call. +- **`--production` reported a reachable project while the plugins were + unreachable.** The traversal check walked the parents of the project root only. + Since the store moved out of the project it defaults to `$HOME/.cache`, which a + deploy under sudo resolves to `/root/.cache` — 0700 on every mainstream distro + — so the chown succeeded on every entry and the site still could not read one + of them. The check now covers the store's own parents, with its own remedy: + relocate the store (`hkm plugins store --set=`, or `HKM_PLUGIN_STORE`) rather + than widen a home directory to reach a cache. +- **A plugin that gained an env var never got it.** `hkm plugins enable` returns + early when the plugin and its dependencies are already wired, so a plugin + declaring a new `config[]` entry in a later version left an `.env` block that + was now incomplete — and the boot failed on the missing key with nothing + pointing at the cause. Enabling an already-enabled plugin now tops up its + block. Safe by construction: the seeder only ever ADDS keys the file does not + already mention, in any form, so a real secret is never rewritten. +- **`.env.example` documented the Tenancy control-plane switch as a hostname.** + `TENANCY_CONTROL_PLANE=admin.example.com` reads as "the control plane lives + here"; the plugin declares the key as `type: bool`, where any non-empty string + is truthy — so the example value silently turned tenant routing OFF for anyone + who uncommented it. Corrected to a bool, with `TENANCY_CENTRAL_DOMAINS` (a + real declared key that was missing) added beside it and the mode values named. + The plugin's own `module.json` stays the authority; this is the example + catching up to it. +- **Re-seeding wrote a second block for the same plugin.** The append was + unconditional, so a plugin seeded twice got two `# ─── Auth ───` headings, and + three after that. Every key was still present exactly once, so nothing broke — + the grouping the block exists to provide just quietly stopped being true. New + keys are now merged into the block the plugin already owns, keeping the blank + line that separates it from the next one. + +### Added +- **`hkm env` — audit and tidy a project's `.env`.** A dotenv file accumulates: + a plugin seeds its block on enable, someone appends a key at the bottom to try + something, a second plugin declares a variable the first one already did. None + of that is an error anywhere. The loader resolves a repeated key silently, the + boot succeeds, and the value in effect is whichever line happens to be last — + a file that works and does not say what it is doing. + - `hkm env` reports duplicates with every occurrence's line number and marks + which one is live. That marker is the point: `LoadEnvironment::setVar` + overwrites on each call and the cascade reads a file top to bottom, so the + LAST active assignment wins — the opposite of what most people assume when + they append a key to the bottom of a .env. + - `hkm env dedupe` asks per key rather than choosing. The right survivor is + not derivable: `DB_HOST=localhost` on line 12 and `DB_HOST=10.0.0.4` on line + 88 are both plausible, and the one in effect is as likely to be the accident + as the intent. `--keep=effective` is the scriptable form that cannot change + behaviour; `--keep=first` / `--keep=last` are positional. + - `hkm env group` reorders the file into blocks: a key a plugin declares in its + `module.json` `config[]` goes under that plugin, otherwise under the feature + its prefix names, otherwise `Ungrouped`. Comments attached to a key move with + it, comments attached to nothing are rescued into a `Notes` block rather than + dropped, and the pass refuses to write unless every key AND every + informational comment that went in comes out again. + - Every write leaves the previous file beside it as `.env.bak`, at 0600. +- **A project is found from anywhere inside it.** `resolveRoot` checked the exact + working directory, so `hkm env` in `/app` answered "'.' is neither a + project folder (with proj.json) nor a registered name" about a project one + directory up. It now walks up to the filesystem root, the way git, composer and + npm all find theirs — for every command that takes a `[path|name]`, not just + `env`. An EXPLICIT path stays exact: the same resolver backs + `hkm install --owner`, and a command that chowns a tree must never quietly + retarget itself above where it was pointed. + ## [1.12.1] - 2026-09-02 ### Fixed diff --git a/projects/Http/Controllers/Concerns/InteractsWithGraphSeo.php b/projects/Http/Controllers/Concerns/InteractsWithGraphSeo.php index a84fe43..440bae1 100644 --- a/projects/Http/Controllers/Concerns/InteractsWithGraphSeo.php +++ b/projects/Http/Controllers/Concerns/InteractsWithGraphSeo.php @@ -157,6 +157,7 @@ protected function seoFor( ?string $searchUrl = null, string $locale = 'en_US', bool $index = true, + ?string $alt = null, ?Request $request = null, ): string { $siteName ??= (string) (env('APP_NAME') ?: ''); @@ -200,7 +201,7 @@ protected function seoFor( $og->siteName($siteName); } if ($image !== null && $image !== '') { - $og->image($this->ogImage($image, 1200, 630, $title, $request)); + $og->image($this->ogImage($image, 1200, 630, $alt ?? $title, $request)); } if ($og instanceof Article) { if (($data['authorUrl'] ?? '') !== '') { diff --git a/tools/src/commands/env.zig b/tools/src/commands/env.zig new file mode 100644 index 0000000..f98dc94 --- /dev/null +++ b/tools/src/commands/env.zig @@ -0,0 +1,646 @@ +//! `hkm env` — audit and tidy a project's `.env`. +//! +//! hkm env [path|name] what is in it: duplicates, groups, orphans +//! hkm env dedupe [path|name] resolve duplicate keys, one prompt each +//! hkm env group [path|name] reorder it into blocks, by plugin then feature +//! +//! ## Why this exists +//! +//! A `.env` accumulates. A plugin seeds its block on enable, someone appends a +//! key at the bottom to try something, a second plugin declares a variable the +//! first one already did — and none of it is an error anywhere. The loader +//! resolves a repeated key silently, the boot succeeds, and the value in effect +//! is whichever line happens to be last. That is the failure this command is +//! for: not a file that is broken, a file that works and does not say what it +//! is doing. +//! +//! Which is also why `dedupe` asks instead of picking. The right survivor is +//! not derivable — `DB_HOST=localhost` on line 12 and `DB_HOST=10.0.0.4` on +//! line 88 are both plausible, and the one currently in effect is as likely to +//! be the accident as the intent. The command's job is to show which is live +//! and let the person who knows decide. +//! +//! Nothing is written without a `.env.bak` beside it. + +const std = @import("std"); +const prompt = @import("../lib/prompt.zig"); +const util = @import("../lib/util.zig"); +const services = @import("../lib/services.zig"); +const envfile = @import("../lib/env_file.zig"); +const plugin_env = @import("../lib/plugin_env.zig"); + +const Dir = std.Io.Dir; +const Io = std.Io; +const EnvMap = std.process.Environ.Map; + +const Action = enum { audit, dedupe, group }; + +const Options = struct { + action: Action = .audit, + target: []const u8 = "", + dry_run: bool = false, + /// Non-interactive resolution: keep the first or the last occurrence. + keep: ?Keep = null, + help: bool = false, +}; + +const Keep = enum { first, last, effective }; + +fn parse(args: []const []const u8) Options { + var o = Options{}; + var i: usize = 2; + while (i < args.len) : (i += 1) { + const a = args[i]; + if (std.mem.eql(u8, a, "--help") or std.mem.eql(u8, a, "-h")) { + o.help = true; + } else if (std.mem.eql(u8, a, "--dry-run") or std.mem.eql(u8, a, "-n")) { + o.dry_run = true; + } else if (std.mem.eql(u8, a, "--keep=first")) { + o.keep = .first; + } else if (std.mem.eql(u8, a, "--keep=last")) { + o.keep = .last; + } else if (std.mem.eql(u8, a, "--keep=effective")) { + o.keep = .effective; + } else if (std.mem.startsWith(u8, a, "--")) { + continue; + } else if (i == 2 and isAction(a)) { + o.action = actionOf(a); + } else if (o.target.len == 0) { + o.target = a; + } + } + return o; +} + +fn isAction(a: []const u8) bool { + return std.mem.eql(u8, a, "audit") or std.mem.eql(u8, a, "analyse") or + std.mem.eql(u8, a, "analyze") or std.mem.eql(u8, a, "dedupe") or + std.mem.eql(u8, a, "dedup") or std.mem.eql(u8, a, "group"); +} + +fn actionOf(a: []const u8) Action { + if (std.mem.eql(u8, a, "dedupe") or std.mem.eql(u8, a, "dedup")) return .dedupe; + if (std.mem.eql(u8, a, "group")) return .group; + return .audit; +} + +fn printHelp() void { + prompt.intro("hkm env — audit and tidy a project's .env"); + prompt.section("Usage"); + prompt.item("hkm env [path|name]", "what is in it: duplicates, groups, keys no plugin declares"); + prompt.item("hkm env dedupe [path|name]", "resolve duplicate keys — one prompt per key"); + prompt.item("hkm env group [path|name]", "reorder into blocks, by declaring plugin then by feature"); + prompt.blank(); + prompt.section("Options"); + prompt.item("--dry-run, -n", "show the result without writing"); + prompt.item("--keep=effective", "dedupe without prompting: keep the line the loader actually uses"); + prompt.item("--keep=first", "dedupe without prompting: keep the topmost occurrence"); + prompt.item("--keep=last", "dedupe without prompting: keep the bottom occurrence"); + prompt.item("--help, -h", "show this help"); + prompt.blank(); + prompt.section("Notes"); + prompt.muted("The LAST active assignment wins at load time, not the first — so a key"); + prompt.muted("appended at the bottom silently overrides the one in its proper block."); + prompt.muted("Every write leaves the previous file beside it as .env.bak."); +} + +pub fn run(allocator: std.mem.Allocator, io: Io, env: *EnvMap, args: []const []const u8) !u8 { + const opts = parse(args); + if (opts.help) { + printHelp(); + return 0; + } + + const root = (try services.resolveRoot(allocator, io, env, opts.target)) orelse { + prompt.err(try std.fmt.allocPrint( + allocator, + "'{s}' is neither a project folder (with proj.json) nor a registered name.", + .{if (opts.target.len == 0) "." else opts.target}, + )); + return 1; + }; + + const f = try envfile.read(allocator, io, root); + if (f.content.len == 0) { + prompt.intro("hkm env"); + prompt.err(try std.fmt.allocPrint(allocator, "no .env at {s}", .{f.path})); + prompt.muted("create one with: hkm install"); + return 1; + } + + const file = try envfile.parse(allocator, f.content); + const claims = try readClaims(allocator, io, root); + + return switch (opts.action) { + .audit => try audit(allocator, io, f.path, file, claims), + .dedupe => try dedupe(allocator, io, f.path, f.content, file, opts), + .group => try group(allocator, io, f.path, f.content, file, claims, opts), + }; +} + +// ── which plugin declares which key ────────────────────────────────────────── + +const Claim = struct { key: []const u8, plugin: []const u8 }; + +/// Map every key declared in an installed plugin's `module.json` `config[]` to +/// that plugin. This is the authoritative half of the grouping: a key a plugin +/// declares belongs to that plugin, whatever its prefix happens to spell. +fn readClaims(allocator: std.mem.Allocator, io: Io, root: []const u8) ![]const Claim { + var out: std.ArrayList(Claim) = .empty; + + const plugins_dir = try std.fmt.allocPrint(allocator, "{s}/plugins", .{root}); + var dir = Dir.cwd().openDir(io, plugins_dir, .{ .iterate = true }) catch return out.items; + defer dir.close(io); + + var it = dir.iterate(); + while (it.next(io) catch null) |entry| { + // A plugin is a directory or a symlink into the store — both resolve. + if (entry.name.len == 0 or entry.name[0] == '.') continue; + // `entry.name` points into the iterator's own buffer and is overwritten + // by the next next() call — it has to be duped before it outlives this + // iteration, or the stored group name is whatever the next entry is. + const name = try allocator.dupe(u8, entry.name); + const vars = plugin_env.readVars(allocator, io, plugins_dir, name) catch continue; + for (vars) |v| try out.append(allocator, .{ .key = v.key, .plugin = name }); + } + + return out.items; +} + +fn claimOf(claims: []const Claim, key: []const u8) ?[]const u8 { + for (claims) |c| { + if (std.mem.eql(u8, c.key, key)) return c.plugin; + } + return null; +} + +/// The block a key belongs in: its declaring plugin, else its prefix's feature, +/// else Ungrouped. +fn groupOf(claims: []const Claim, key: []const u8) []const u8 { + if (claimOf(claims, key)) |p| return p; + return envfile.prefixGroup(key) orelse envfile.ungrouped; +} + +// ── audit ──────────────────────────────────────────────────────────────────── + +fn audit( + allocator: std.mem.Allocator, + io: Io, + path: []const u8, + file: envfile.File, + claims: []const Claim, +) !u8 { + _ = io; + prompt.intro("hkm env"); + prompt.muted(path); + + var active: usize = 0; + for (file.records) |r| { + if (r.active) active += 1; + } + prompt.blank(); + prompt.item("keys", try std.fmt.allocPrint( + allocator, + "{d} ({d} set, {d} commented)", + .{ file.records.len, active, file.records.len - active }, + )); + + const dups = try envfile.duplicates(allocator, file); + + // ── duplicates ── + prompt.blank(); + prompt.section("Duplicates"); + if (dups.len == 0) { + prompt.ok("no key appears twice"); + } else { + for (dups) |d| { + const live = envfile.effective(file, d); + prompt.warn(try std.fmt.allocPrint(allocator, "{s} — {d} occurrences", .{ d.key, d.at.len })); + for (d.at, 0..) |rec, i| { + const r = file.records[rec]; + prompt.muted(try std.fmt.allocPrint( + allocator, + " line {d:>4} {s}{s}={s}{s}", + .{ + r.line + 1, + if (r.active) "" else "# ", + r.key, + elide(r.value), + if (live != null and live.? == i) " ← in effect" else "", + }, + )); + } + } + prompt.blank(); + prompt.muted("resolve them with: hkm env dedupe"); + } + + // ── groups ── + prompt.blank(); + prompt.section("Groups"); + const names = try groupNames(allocator, file, claims); + for (names) |g| { + var n: usize = 0; + for (file.records) |r| { + if (std.mem.eql(u8, groupOf(claims, r.key), g)) n += 1; + } + prompt.item(g, try std.fmt.allocPrint(allocator, "{d} key(s)", .{n})); + } + prompt.blank(); + prompt.muted("reorder the file into these blocks with: hkm env group"); + + prompt.outro(if (dups.len == 0) "no duplicates" else "duplicates found"); + return if (dups.len == 0) 0 else 1; +} + +/// Shorten a value for display. A .env is full of secrets; an audit that prints +/// a 400-character key into a terminal — and a scrollback, and a screen share — +/// has widened the blast radius of the thing it was asked to tidy. +fn elide(value: []const u8) []const u8 { + if (value.len <= 24) return value; + return value[0..24]; +} + +/// Every group present, plugins first (alphabetically), then features, with +/// Ungrouped last so the keys nothing claims are where you look for them. +fn groupNames(allocator: std.mem.Allocator, file: envfile.File, claims: []const Claim) ![]const []const u8 { + var out: std.ArrayList([]const u8) = .empty; + for (file.records) |r| { + const g = groupOf(claims, r.key); + var seen = false; + for (out.items) |o| { + if (std.mem.eql(u8, o, g)) { + seen = true; + break; + } + } + if (!seen) try out.append(allocator, g); + } + + // Plugin-declared groups sort before heuristic ones; Ungrouped goes last. + const rank = struct { + fn of(claims_: []const Claim, name: []const u8) u8 { + if (std.mem.eql(u8, name, envfile.ungrouped)) return 2; + for (claims_) |c| { + if (std.mem.eql(u8, c.plugin, name)) return 0; + } + return 1; + } + }; + + const Ctx = struct { claims: []const Claim }; + std.mem.sort([]const u8, out.items, Ctx{ .claims = claims }, struct { + fn lt(ctx: Ctx, a: []const u8, b: []const u8) bool { + const ra = rank.of(ctx.claims, a); + const rb = rank.of(ctx.claims, b); + if (ra != rb) return ra < rb; + return std.mem.order(u8, a, b) == .lt; + } + }.lt); + + return out.items; +} + +// ── dedupe ─────────────────────────────────────────────────────────────────── + +fn dedupe( + allocator: std.mem.Allocator, + io: Io, + path: []const u8, + before: []const u8, + file: envfile.File, + opts: Options, +) !u8 { + prompt.intro("hkm env dedupe"); + prompt.muted(path); + + const dups = try envfile.duplicates(allocator, file); + if (dups.len == 0) { + prompt.ok("no duplicate keys — nothing to do"); + return 0; + } + + var drop: std.ArrayList(usize) = .empty; + var resolved: usize = 0; + + for (dups) |d| { + const live = envfile.effective(file, d); + + const choice = if (opts.keep) |k| autoChoice(file, d, live, k) else blk: { + prompt.blank(); + var items: std.ArrayList([]const u8) = .empty; + for (d.at, 0..) |rec, i| { + const r = file.records[rec]; + try items.append(allocator, try std.fmt.allocPrint( + allocator, + "line {d:>4} {s}{s}={s}{s}", + .{ + r.line + 1, + if (r.active) "" else "# ", + r.key, + elide(r.value), + if (live != null and live.? == i) " (in effect now)" else "", + }, + )); + } + try items.append(allocator, "leave this key alone"); + + const label = try std.fmt.allocPrint( + allocator, + "{s} appears {d} times — which line should remain?", + .{ d.key, d.at.len }, + ); + break :blk prompt.select(label, items.items) orelse items.items.len - 1; + }; + + // The extra trailing option, or a cancelled prompt: change nothing. + if (choice >= d.at.len) continue; + + resolved += 1; + for (d.at, 0..) |rec, i| { + if (i == choice) continue; + try drop.append(allocator, file.records[rec].line); + } + } + + if (drop.items.len == 0) { + prompt.blank(); + prompt.muted("nothing selected — file unchanged"); + return 0; + } + + const after = try envfile.withoutLines(allocator, file, drop.items); + + prompt.blank(); + prompt.ok(try std.fmt.allocPrint( + allocator, + "{d} key(s) resolved, {d} line(s) removed", + .{ resolved, drop.items.len }, + )); + + if (opts.dry_run) { + prompt.muted("dry run — nothing written"); + return 0; + } + + try envfile.write(allocator, io, path, before, after); + prompt.ok(try std.fmt.allocPrint(allocator, "written — previous file kept at {s}.bak", .{path})); + return 0; +} + +fn autoChoice(file: envfile.File, d: envfile.Duplicate, live: ?usize, keep: Keep) usize { + return switch (keep) { + .first => 0, + .last => d.at.len - 1, + // Preserving the value the application is running on right now is the + // only automatic answer that cannot change behaviour. With nothing + // active there is nothing in effect to preserve, so keep the last. + .effective => live orelse blk: { + _ = file; + break :blk d.at.len - 1; + }, + }; +} + +// ── group ──────────────────────────────────────────────────────────────────── + +fn group( + allocator: std.mem.Allocator, + io: Io, + path: []const u8, + before: []const u8, + file: envfile.File, + claims: []const Claim, + opts: Options, +) !u8 { + prompt.intro("hkm env group"); + prompt.muted(path); + + const dups = try envfile.duplicates(allocator, file); + if (dups.len > 0) { + // Reordering a file with duplicates would move the losing copies next + // to the winner, where they look deliberate. Worse, "last wins" is + // positional, so the reorder can change WHICH ONE the loader picks — + // a rewrite that silently alters the running configuration. + prompt.err(try std.fmt.allocPrint( + allocator, + "{d} duplicate key(s) — resolve them before grouping.", + .{dups.len}, + )); + prompt.muted("grouping moves lines, and the last assignment is the one that wins,"); + prompt.muted("so reordering a duplicated key can change which value is in effect."); + prompt.muted("run: hkm env dedupe"); + return 1; + } + + const names = try groupNames(allocator, file, claims); + + var out: std.ArrayList(u8) = .empty; + + // Every line this rewrite has placed somewhere. What is left over at the + // end is what would otherwise be silently dropped — see the rescue pass. + const used = try allocator.alloc(bool, file.lines.len); + @memset(used, false); + + // Preamble — whatever a person put at the top of the file, kept verbatim. + for (file.lines[0..file.preamble], 0..) |line, i| { + try out.appendSlice(allocator, line); + try out.append(allocator, '\n'); + used[i] = true; + } + trimTrailingBlanks(&out); + + for (names) |g| { + if (out.items.len > 0) try out.appendSlice(allocator, "\n\n"); + try out.appendSlice(allocator, try std.fmt.allocPrint( + allocator, + "# ─── {s} ───────────────────────────────────────────────\n", + .{g}, + )); + + for (file.records) |r| { + if (!std.mem.eql(u8, groupOf(claims, r.key), g)) continue; + + // Carry the record's attached explanation with it. Only two kinds + // of line are dropped: a wordless rule, and a banner whose label is + // a group this pass is re-emitting anyway. A labelled rule that + // says something — `# --- s3 driver (MinIO) ---` — is information, + // and survives. + var i = r.first; + while (i < r.line) : (i += 1) { + used[i] = true; + if (envfile.isRule(file.lines[i])) continue; + if (envfile.headerLabel(file.lines[i])) |label| { + if (isGroupName(names, label)) continue; + } + try out.appendSlice(allocator, file.lines[i]); + try out.append(allocator, '\n'); + } + try out.appendSlice(allocator, file.lines[r.line]); + try out.append(allocator, '\n'); + used[r.line] = true; + } + } + + // Rescue pass. A comment block separated from every key by a blank line — + // or trailing after the last one — belongs to no record and would simply + // cease to exist. These are routinely the most important lines in the file + // ("NEVER commit actual values for these"), so they are kept verbatim, in + // order, under a heading that says why they are no longer where they were. + var orphans: std.ArrayList([]const u8) = .empty; + for (file.lines, 0..) |line, i| { + if (used[i]) continue; + const t = std.mem.trim(u8, line, " \t\r"); + if (t.len == 0 or envfile.isRule(line)) continue; + // This pass's OWN heading from a previous run. Without this the Notes + // block orphans itself and grows by two lines every time the command + // is run — which is the difference between a tidy-up you can run twice + // and one you can run once. + if (envfile.headerLabel(line)) |label| { + if (std.mem.eql(u8, label, notes_label)) continue; + } + if (std.mem.eql(u8, t, notes_note)) continue; + try orphans.append(allocator, line); + } + + if (orphans.items.len > 0) { + try out.appendSlice(allocator, "\n\n"); + try out.appendSlice(allocator, "# ─── " ++ notes_label ++ " ───────────────────────────────────────────────\n"); + try out.appendSlice(allocator, notes_note ++ "\n"); + for (orphans.items) |line| { + try out.appendSlice(allocator, line); + try out.append(allocator, '\n'); + } + } + + trimTrailingBlanks(&out); + try out.append(allocator, '\n'); + + prompt.blank(); + for (names) |g| { + var n: usize = 0; + for (file.records) |r| { + if (std.mem.eql(u8, groupOf(claims, r.key), g)) n += 1; + } + prompt.item(g, try std.fmt.allocPrint(allocator, "{d} key(s)", .{n})); + } + + // Everything that went in must come out. A reorder that drops a key takes a + // secret with it, and one that drops a comment takes the only explanation + // of a setting — so both are counted rather than trusted. This check is + // what caught the rescue pass being necessary in the first place. + const check = try envfile.parse(allocator, out.items); + if (check.records.len != file.records.len) { + prompt.err(try std.fmt.allocPrint( + allocator, + "refusing to write: {d} keys in, {d} out.", + .{ file.records.len, check.records.len }, + )); + return 1; + } + + const before_notes = try envfile.informationalComments(allocator, file); + const after_notes = try envfile.informationalComments(allocator, check); + if (try lostComments(allocator, before_notes, after_notes, names)) |lost| { + prompt.err("refusing to write: the rewrite would drop comment lines."); + prompt.muted(lost); + return 1; + } + + prompt.blank(); + if (opts.dry_run) { + prompt.muted("dry run — nothing written"); + return 0; + } + + try envfile.write(allocator, io, path, before, out.items); + prompt.ok(try std.fmt.allocPrint( + allocator, + "{d} keys regrouped into {d} block(s) — previous file kept at {s}.bak", + .{ file.records.len, names.len, path }, + )); + return 0; +} + +/// Heading this pass writes over the comments that belong to no single key. +const notes_label = "Notes"; +const notes_note = "# Comments that were not attached to any single key."; + +fn isGroupName(names: []const []const u8, label: []const u8) bool { + for (names) |n| { + if (std.mem.eql(u8, n, label)) return true; + } + return false; +} + +/// The first informational comment present before the rewrite and absent after, +/// or null when none was lost. A banner this pass re-emits is not a loss. +fn lostComments( + allocator: std.mem.Allocator, + before: []const []const u8, + after: []const []const u8, + names: []const []const u8, +) !?[]const u8 { + for (before) |b| { + if (envfile.headerLabel(b)) |label| { + if (isGroupName(names, label)) continue; + } + var found = false; + for (after) |a| { + if (std.mem.eql(u8, a, b)) { + found = true; + break; + } + } + if (!found) return try std.fmt.allocPrint(allocator, " first missing: {s}", .{b}); + } + return null; +} + +fn trimTrailingBlanks(out: *std.ArrayList(u8)) void { + while (out.items.len > 0 and (out.items[out.items.len - 1] == '\n' or out.items[out.items.len - 1] == '\r')) { + _ = out.pop(); + } +} + +// ── tests ──────────────────────────────────────────────────────────────────── + +test "the action word is optional and the target survives it" { + try std.testing.expectEqual(Action.audit, parse(&.{ "hkm", "env" }).action); + try std.testing.expectEqual(Action.dedupe, parse(&.{ "hkm", "env", "dedupe" }).action); + try std.testing.expectEqual(Action.group, parse(&.{ "hkm", "env", "group", "shop" }).action); + try std.testing.expectEqualStrings("shop", parse(&.{ "hkm", "env", "group", "shop" }).target); + // No action word — the bare argument is the project, not a typo'd verb. + try std.testing.expectEqualStrings("shop", parse(&.{ "hkm", "env", "shop" }).target); + try std.testing.expectEqual(Action.audit, parse(&.{ "hkm", "env", "shop" }).action); +} + +test "keep flags parse" { + try std.testing.expectEqual(Keep.effective, parse(&.{ "hkm", "env", "dedupe", "--keep=effective" }).keep.?); + try std.testing.expectEqual(Keep.first, parse(&.{ "hkm", "env", "dedupe", "--keep=first" }).keep.?); + try std.testing.expect(parse(&.{ "hkm", "env", "dedupe" }).keep == null); + try std.testing.expect(parse(&.{ "hkm", "env", "-n" }).dry_run); +} + +test "a plugin's claim beats the prefix table" { + const claims = [_]Claim{.{ .key = "DB_HOST", .plugin = "Tenancy" }}; + // The prefix table would say Database; the plugin that declares it wins. + try std.testing.expectEqualStrings("Tenancy", groupOf(&claims, "DB_HOST")); + try std.testing.expectEqualStrings("Database", groupOf(&claims, "DB_PORT")); + try std.testing.expectEqualStrings(envfile.ungrouped, groupOf(&claims, "STRIPE_KEY")); +} + +test "autoChoice keeps what is running when asked for the effective line" { + const a = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + const al = arena.allocator(); + + const f = try envfile.parse(al, "A=1\nA=2\n# A=3\n"); + const d = (try envfile.duplicates(al, f))[0]; + const live = envfile.effective(f, d); + + try std.testing.expectEqual(@as(usize, 1), autoChoice(f, d, live, .effective)); // A=2 + try std.testing.expectEqual(@as(usize, 0), autoChoice(f, d, live, .first)); + try std.testing.expectEqual(@as(usize, 2), autoChoice(f, d, live, .last)); +} diff --git a/tools/src/commands/install.zig b/tools/src/commands/install.zig index 4bdc637..cbb9fe3 100644 --- a/tools/src/commands/install.zig +++ b/tools/src/commands/install.zig @@ -17,7 +17,8 @@ //! 6. fetch every plugin the project's own bootstrap wires (mirrors what //! `hkm new` does right after scaffolding — see lib/plugin_provision.zig) //! 7. with --production / --owner: chown and chmod the WHOLE project for the -//! web server's account — last, because steps 5 and 6 create vendor/ and +//! web server's account, plus the plugin-store versions its plugins/ +//! symlinks point at — last, because steps 5 and 6 create vendor/ and //! plugins/ as whoever ran the command //! //! Every step besides directory creation can be skipped with a --no-* flag, for @@ -36,6 +37,8 @@ const services = @import("../lib/services.zig"); const plugin_assets = @import("../lib/plugin_assets.zig"); const plugin_provision = @import("../lib/plugin_provision.zig"); const plugins_cmd = @import("plugins.zig"); +const installer = @import("../lib/plugin_install.zig"); +const pstore = @import("../lib/plugin_store.zig"); const Dir = std.Io.Dir; const Io = std.Io; @@ -74,7 +77,8 @@ const Options = struct { /// group-and-world-readable dev modes (0775/0664). production: bool = false, /// --owner=[:] (also HKM_PROD_OWNER) — chown the whole - /// project to this user[:group], typically `deploy:www-data`: the deploy + /// project, AND the plugin-store versions its plugins/ symlinks resolve to, + /// to this user[:group], typically `deploy:www-data`: the deploy /// account keeps the code, the web server / PHP-FPM pool reaches it through /// the group. Passed straight to the system `chown`, so `user`, /// `user:group` and `:group` (group-only) all work. Requires root/sudo @@ -141,7 +145,7 @@ fn printHelp() void { prompt.item("--no-chmod", "skip fixing var/ and userdata/ mode bits"); prompt.item("--verify-plugins", "run each plugin's own test suite while installing (slow)"); prompt.item("--production, --prod", "harden the WHOLE tree: code 0750/0640, var+userdata 2770/0660"); - prompt.item("--owner=[:]", "chown the whole project to this user[:group] (needs root/sudo)"); + prompt.item("--owner=[:]", "chown the project AND its linked plugin store entries to this user[:group] (needs root/sudo)"); prompt.item("--help, -h", "show this help"); prompt.blank(); prompt.section("Environment"); @@ -468,7 +472,10 @@ fn hardenProject( prompt.note(""); if (owner) |o| { - if (o.len > 0) fixOwnership(allocator, io, env, root, o); + if (o.len > 0) { + fixOwnership(allocator, io, env, root, o); + fixPluginStoreOwnership(allocator, io, env, root, o); + } } else { prompt.warn("--production: no --owner given (and HKM_PROD_OWNER is unset) — ownership left unchanged."); prompt.muted("pass --owner=[:] — typically your web server's account, e.g. deploy:www-data."); @@ -483,7 +490,7 @@ fn hardenProject( ) catch "Permissions applied"); verifyModes(allocator, io, root, m); - reportTraversal(allocator, io, root); + reportTraversal(allocator, io, env, root); } /// Re-stat the paths that decide whether the application boots, and say so when @@ -657,6 +664,114 @@ fn fixOwnership(allocator: std.mem.Allocator, io: Io, env: *EnvMap, root: []cons } } +/// chown the plugin-store entries this project's `plugins/*` symlinks point at. +/// +/// A project's plugins are not IN the project. `hkm plugins install` keeps one +/// copy per (plugin, version, origin) in the global store and links the project +/// at it (lib/plugin_store.zig), so `plugins/Logger` is a symlink out of the +/// tree. Both halves of the hardening pass stop at that boundary by design: +/// `hardenTree` skips symlinks because a chmod would follow one and rewrite a +/// target outside the project, and `fixOwnership` only walks the project root. +/// +/// The result, before this pass, was a project that verified clean and could +/// not serve: every file the pool had to READ FIRST — every Provider, every +/// controller a route resolves to — was still owned by whoever ran the command, +/// and the report said "Project owned by deploy:www-data". +/// +/// Only the versions THIS project links to are touched. The store is shared by +/// every project on the machine, and taking ownership of all of it on behalf of +/// one project's web account is not this command's call. +fn fixPluginStoreOwnership( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + root: []const u8, + owner: []const u8, +) void { + const plugins_dir = std.fmt.allocPrint(allocator, "{s}/plugins", .{root}) catch return; + var dir = Dir.cwd().openDir(io, plugins_dir, .{ .iterate = true }) catch return; + defer dir.close(io); + + // The resolved store, used ONLY to bound how far up a target we may walk. + // A link pointing somewhere else entirely — a working copy someone is + // editing — gets its own tree chown'd and nothing above it. + const store: ?[]const u8 = blk: { + const fallback = fb: { + const p = installer.pluginsRoot(allocator, io, env, root) catch break :fb root; + break :fb util.parentOf(p) orelse root; + }; + break :blk pstore.root(allocator, env, fallback) catch null; + }; + + var done: std.ArrayList([]const u8) = .empty; + var linked: usize = 0; + var failed: usize = 0; + + var it = dir.iterate(); + while (it.next(io) catch null) |entry| { + const link = std.fmt.allocPrint(allocator, "{s}/{s}", .{ plugins_dir, entry.name }) catch continue; + // A real directory is inside the project — fixOwnership already had it. + if (!util.isSymlink(io, link)) continue; + const target = util.linkTarget(allocator, io, link) orelse continue; + // Relative targets stay inside the project, absolute ones are the store. + if (target.len == 0 or target[0] != '/') continue; + // A dangling link has nothing to chown; `hkm plugins verify` reports it. + if (!util.dirExists(Dir.cwd(), io, target)) continue; + linked += 1; + + if (!chownOnce(allocator, io, env, &done, target, owner, true)) failed += 1; + + // Everything between the store root and the version directory has to be + // traversable by the new owner too, or the tree just chown'd cannot be + // reached. Walk up only INSIDE the store, never above it: the store's + // own parents are a user's cache or home, and chowning those to a web + // account on behalf of one project would be a machine-wide surprise. + const s_root = store orelse continue; + if (!util.isInside(target, s_root)) continue; + var cursor: ?[]const u8 = util.parentOf(target); + while (cursor) |dir_path| : (cursor = util.parentOf(dir_path)) { + if (!util.isInside(dir_path, s_root)) break; + if (!chownOnce(allocator, io, env, &done, dir_path, owner, false)) failed += 1; + if (std.mem.eql(u8, util.trimSlash(dir_path), util.trimSlash(s_root))) break; + } + } + + if (linked == 0) return; + + if (failed == 0) { + prompt.ok(std.fmt.allocPrint( + allocator, + "{d} linked plugin store entr{s} owned by {s}", + .{ linked, if (linked == 1) @as([]const u8, "y") else "ies", owner }, + ) catch "Plugin store ownership fixed"); + } else { + prompt.warn(std.fmt.allocPrint( + allocator, + "chown {s} failed on {d} plugin store path(s) — the pool cannot read those plugins.", + .{ owner, failed }, + ) catch "chown failed on the plugin store — the pool cannot read those plugins."); + } +} + +/// chown `path`, remembering it so a path reached through several links — the +/// store root, a plugin directory holding two pinned versions — is chown'd once +/// rather than once per link. +fn chownOnce( + allocator: std.mem.Allocator, + io: Io, + env: *EnvMap, + done: *std.ArrayList([]const u8), + path: []const u8, + owner: []const u8, + recursive: bool, +) bool { + for (done.items) |p| { + if (std.mem.eql(u8, p, path)) return true; + } + done.append(allocator, path) catch {}; + return chownPath(io, env, path, owner, recursive); +} + /// `chown [-R] `. Shells out rather than resolving the user/group /// name to a uid/gid natively — the OS's own NSS already knows how to do that /// correctly (files, LDAP, whatever `/etc/nsswitch.conf` says), and `chown` @@ -700,27 +815,96 @@ fn chownPath(io: Io, env: *EnvMap, path: []const u8, owner: []const u8, recursiv /// /// Reported, never changed: widening a directory that is not part of the /// project is the operator's call, not this command's. -fn reportTraversal(allocator: std.mem.Allocator, io: Io, root: []const u8) void { +fn reportTraversal(allocator: std.mem.Allocator, io: Io, env: *EnvMap, root: []const u8) void { var blocked: std.ArrayList([]const u8) = .empty; + collectBlocked(allocator, io, util.parentOf(root), &blocked); + + if (blocked.items.len > 0) { + prompt.warn("The web server may not be able to REACH the project — these parent directories deny traversal to others:"); + for (blocked.items) |item| prompt.muted(std.fmt.allocPrint(allocator, " {s}", .{item}) catch item); + prompt.muted("Each one needs execute for the pool's account: `chmod o+x `, add the account to its group, or"); + prompt.muted("move the project somewhere the web server already reaches (/var/www, /srv)."); + } - var cursor: ?[]const u8 = util.parentOf(root); + reportStoreTraversal(allocator, io, env, root); +} + +/// The same check for the PLUGIN STORE, which the project reaches by symlink. +/// +/// Worth its own pass and its own advice: the store defaults to `$HOME/.cache` +/// (lib/plugin_store.zig), and a deploy run under sudo resolves that to +/// `/root/.cache` — a directory that is 0700 on every mainstream distro. The +/// chown above then succeeds on every entry and the site still cannot read one +/// of them, because the denial is a level above anything this command owns. +/// +/// The remedy differs too. Widening a home directory to reach a cache is the +/// wrong trade; the store is relocatable precisely so it does not have to be. +fn reportStoreTraversal(allocator: std.mem.Allocator, io: Io, env: *EnvMap, root: []const u8) void { + const fallback = fb: { + const p = installer.pluginsRoot(allocator, io, env, root) catch break :fb root; + break :fb util.parentOf(p) orelse root; + }; + const store = pstore.root(allocator, env, fallback) catch return; + // Nothing installed from the store — no reason to talk about it. + if (!util.dirExists(Dir.cwd(), io, store)) return; + // A store INSIDE the project is covered by the project's own walk above. + if (util.isInside(store, root)) return; + // Say nothing about a store this project does not actually reach into — + // the warning below asserts that its plugins/ links point there. + if (!linksIntoStore(allocator, io, root, store)) return; + + var blocked: std.ArrayList([]const u8) = .empty; + collectBlocked(allocator, io, store, &blocked); + if (blocked.items.len == 0) return; + + prompt.warn("The web server cannot REACH the plugin store — the project's plugins/ symlinks point into it:"); + for (blocked.items) |item| prompt.muted(std.fmt.allocPrint(allocator, " {s}", .{item}) catch item); + prompt.muted(std.fmt.allocPrint( + allocator, + " store: {s}", + .{store}, + ) catch ""); + prompt.muted("Move it somewhere the pool already reaches rather than widening a home directory:"); + prompt.muted(" hkm plugins store --set=/var/lib/hkm/plugin-store (or: export HKM_PLUGIN_STORE=…)"); + prompt.muted("then re-point this project's links with: hkm plugins lock"); +} + +/// True when at least one `plugins/*` entry is a symlink resolving into +/// `store`. Cheap enough to run unconditionally: a project has a handful of +/// plugins, and this reads only the link targets, never the trees behind them. +fn linksIntoStore(allocator: std.mem.Allocator, io: Io, root: []const u8, store: []const u8) bool { + const plugins_dir = std.fmt.allocPrint(allocator, "{s}/plugins", .{root}) catch return false; + var dir = Dir.cwd().openDir(io, plugins_dir, .{ .iterate = true }) catch return false; + defer dir.close(io); + + var it = dir.iterate(); + while (it.next(io) catch null) |entry| { + const link = std.fmt.allocPrint(allocator, "{s}/{s}", .{ plugins_dir, entry.name }) catch continue; + if (!util.isSymlink(io, link)) continue; + const target = util.linkTarget(allocator, io, link) orelse continue; + if (util.isInside(target, store)) return true; + } + return false; +} + +/// Walk from `start` up to `/`, collecting every directory that denies +/// traversal to "other" — reachable only by its owner or a member of its +/// group, which a web server account rarely is. +fn collectBlocked( + allocator: std.mem.Allocator, + io: Io, + start: ?[]const u8, + blocked: *std.ArrayList([]const u8), +) void { + var cursor: ?[]const u8 = start; while (cursor) |path| : (cursor = util.parentOf(path)) { if (util.statMode(io, path)) |mode| { - // No execute for "other" — reachable only by the owner or a member - // of the directory's group, which a web server account rarely is. if ((mode & 0o001) == 0) { blocked.append(allocator, std.fmt.allocPrint(allocator, "{s} ({o})", .{ path, mode }) catch path) catch {}; } } if (std.mem.eql(u8, path, "/")) break; } - - if (blocked.items.len == 0) return; - - prompt.warn("The web server may not be able to REACH the project — these parent directories deny traversal to others:"); - for (blocked.items) |item| prompt.muted(std.fmt.allocPrint(allocator, " {s}", .{item}) catch item); - prompt.muted("Each one needs execute for the pool's account: `chmod o+x `, add the account to its group, or"); - prompt.muted("move the project somewhere the web server already reaches (/var/www, /srv)."); } // -------------------------------------------------------------------------- diff --git a/tools/src/commands/plugins.zig b/tools/src/commands/plugins.zig index 28dc5a1..41c6509 100644 --- a/tools/src/commands/plugins.zig +++ b/tools/src/commands/plugins.zig @@ -851,7 +851,14 @@ fn enableWithDeps( if (steps.items.len == 0) { prompt.warn(try std.fmt.allocPrint(allocator, "{s} and all its dependencies are already enabled.", .{folder})); if (missing.items.len > 0) noteMissingDomains(allocator, missing.items); - prompt.outro("No changes made"); + + // Wiring is unchanged, but the plugin's config[] may not be. A plugin + // that gains a variable in a later version has an .env block that is + // now incomplete, and the boot fails on the missing key with nothing + // pointing at the cause. Re-seeding here is safe by construction: seed() + // only ever ADDS keys the file does not already mention, in any form. + const added = reseedEnv(allocator, io, root, folder, if (located) |l| l.dir else null, dry_run); + prompt.outro(if (added > 0) "Env block updated" else "No changes made"); return 0; } @@ -974,6 +981,45 @@ pub fn supportHelpersExpr(allocator: std.mem.Allocator, io: Io, env: *EnvMap, ro /// Enable ONE plugin into `source`, returning the updated text (no file write). /// Publishes assets + runs migrations as a side effect (skipped on dry-run). +/// Top up a plugin's `.env` block with variables its module.json declares and +/// the file does not have yet, merging into the block it already owns. +/// +/// Returns how many were added. Every failure is a warning rather than an error: +/// this runs on a command whose job was already done, and a .env that could not +/// be written is not a reason to report the enable itself as failed. +fn reseedEnv( + allocator: std.mem.Allocator, + io: Io, + root: []const u8, + folder: []const u8, + dir: ?[]const u8, + dry_run: bool, +) usize { + const d = dir orelse return 0; + const vars = penv.readVars(allocator, io, d, folder) catch return 0; + if (vars.len == 0) return 0; + + const seeded = penv.seed(allocator, io, root, folder, vars, dry_run) catch |e| { + prompt.warn(std.fmt.allocPrint( + allocator, + "could not update .env ({t}) — add {s}'s new config[] variables by hand.", + .{ e, folder }, + ) catch folder); + return 0; + }; + if (seeded.added.len == 0) return 0; + + prompt.ok(std.fmt.allocPrint(allocator, "{s} {d} new env var(s) to {s}'s block:", .{ + if (dry_run) "would add" else "Added", + seeded.added.len, + folder, + }) catch "Added new env vars"); + for (seeded.added) |v| { + prompt.muted(std.fmt.allocPrint(allocator, " {s}", .{v.key}) catch v.key); + } + return seeded.added.len; +} + fn enableOne( allocator: std.mem.Allocator, io: Io, diff --git a/tools/src/lib/env_file.zig b/tools/src/lib/env_file.zig new file mode 100644 index 0000000..b9b263b --- /dev/null +++ b/tools/src/lib/env_file.zig @@ -0,0 +1,453 @@ +//! Read a project's `.env` as records rather than lines, so it can be audited +//! and rewritten without losing what a person wrote in it. +//! +//! ## Why a record, not a line +//! +//! A dotenv file is not a key/value store on disk — it is a document. The +//! comment above a key explains it, the blank line below it separates a +//! section, and a key that appears twice is a bug that no parser reports +//! because the loader silently resolves it. Anything that rewrites the file has +//! to preserve the first two while surfacing the third. +//! +//! So a `Record` is an assignment plus the contiguous comment block directly +//! above it, and everything before the first record is a preamble that stays +//! put. Moving a record moves its explanation with it. +//! +//! ## Which duplicate is in effect +//! +//! LoadEnvironment::setVar overwrites `$_ENV[$name]` on every call and the +//! cascade walks a file top to bottom, so within one file the LAST active +//! assignment wins. That is the opposite of what most people assume when they +//! append a key to the bottom of a .env "to try something", and it is why +//! `effective()` exists: an audit that cannot say which line is actually live +//! is not an audit. +//! +//! A commented assignment (`# KEY=`) is parsed as a record too. The seeder +//! writes optional variables that way, so treating them as prose would make +//! every optional plugin knob invisible to the grouping and re-seed it forever. + +const std = @import("std"); +const util = @import("util.zig"); + +const Io = std.Io; +const Dir = std.Io.Dir; + +/// One `KEY=value` assignment, with the comment block attached above it. +pub const Record = struct { + key: []const u8, + /// Everything right of the first `=`, untrimmed of trailing comments. + value: []const u8, + /// False when the line is commented out (`# KEY=…`). + active: bool, + /// Index into `File.lines` of the assignment itself. + line: usize, + /// First line of the attached comment block — equals `line` when none. + first: usize, +}; + +pub const File = struct { + /// Every line of the file, in order, without terminators. + lines: []const []const u8, + records: []const Record, + /// Lines before the first record: the file's banner. Never reordered. + preamble: usize, + /// True when the file ended with a newline, so a rewrite can match it. + trailing_newline: bool, +}; + +/// A key that appears more than once, with every place it appears. +pub const Duplicate = struct { + key: []const u8, + /// Indices into `File.records`, in file order. + at: []const usize, +}; + +/// True when `name` is a syntactically valid environment key. +fn isKeyChar(c: u8, first: bool) bool { + if (c == '_') return true; + if (c >= 'A' and c <= 'Z') return true; + if (c >= 'a' and c <= 'z') return true; + if (!first and c >= '0' and c <= '9') return true; + return false; +} + +/// Split `line` into a key and the text right of `=`, or null when it is not an +/// assignment. Handles the commented form by reporting `active = false`. +pub fn assignment(line: []const u8) ?struct { key: []const u8, value: []const u8, active: bool } { + var s = std.mem.trim(u8, line, " \t\r"); + if (s.len == 0) return null; + + var active = true; + if (s[0] == '#') { + active = false; + // Step past the marker and any run of them: `## KEY=` is still a + // commented assignment, and a person writing one means it. + while (s.len > 0 and (s[0] == '#' or s[0] == ' ' or s[0] == '\t')) s = s[1..]; + if (s.len == 0) return null; + } + + // Optional `export ` prefix — valid dotenv, and dropping it silently would + // make `export DB_HOST=` invisible to a duplicate check that sees `DB_HOST=`. + if (std.mem.startsWith(u8, s, "export ")) s = std.mem.trimStart(u8, s["export ".len..], " \t"); + + const eq = std.mem.indexOfScalar(u8, s, '=') orelse return null; + const key = std.mem.trim(u8, s[0..eq], " \t"); + if (key.len == 0) return null; + + for (key, 0..) |c, i| { + if (!isKeyChar(c, i == 0)) return null; + } + + return .{ .key = key, .value = std.mem.trim(u8, s[eq + 1 ..], " \t"), .active = active }; +} + +const rule_chars = "-=_\u{2500}\u{2501}\u{2550}#*"; + +/// True for a comment carrying no words at all: a bare `#`, or a rule of +/// dashes / box characters. Always safe to drop and regenerate. +pub fn isRule(line: []const u8) bool { + var s = std.mem.trim(u8, line, " \t\r"); + if (s.len == 0 or s[0] != '#') return false; + s = std.mem.trim(u8, s[1..], " \t"); + if (s.len == 0) return true; + return std.mem.trim(u8, s, rule_chars).len == 0; +} + +/// The label of a `# ─── Name ───` header, or null when the line is not one. +/// +/// A LABELLED rule is not decoration — `# --- s3 driver (MinIO) ---` is the +/// only place that fact is written down, and an earlier version of this code +/// deleted three such lines from a real .env because they matched the shape of +/// a banner. So the label is returned rather than judged here, and the caller +/// drops the line only when the label is one it is about to re-emit itself. +pub fn headerLabel(line: []const u8) ?[]const u8 { + var s = std.mem.trim(u8, line, " \t\r"); + if (s.len == 0 or s[0] != '#') return null; + s = std.mem.trim(u8, s[1..], " \t"); + if (s.len == 0) return null; + + const head = std.mem.trimStart(u8, s, rule_chars); + if (head.len == s.len) return null; // no leading rule — ordinary prose + const label = std.mem.trim(u8, std.mem.trimEnd(u8, head, rule_chars), " \t"); + if (label.len == 0) return null; // pure rule — isRule's business + return label; +} + +/// Comment lines that carry information: not blank, not a bare rule. The unit +/// a rewrite must never lose. +pub fn informationalComments(allocator: std.mem.Allocator, file: File) ![]const []const u8 { + var out: std.ArrayList([]const u8) = .empty; + for (file.lines) |line| { + const t = std.mem.trim(u8, line, " \t\r"); + if (t.len == 0 or t[0] != '#') continue; + if (isRule(line)) continue; + if (assignment(line) != null) continue; // a commented-out key is a record + try out.append(allocator, t); + } + return out.items; +} + +/// Parse `content` into records. Never fails: a line that is not an assignment +/// is simply not a record, which is what makes this safe to run on any file. +pub fn parse(allocator: std.mem.Allocator, content: []const u8) !File { + var lines: std.ArrayList([]const u8) = .empty; + var it = std.mem.splitScalar(u8, content, '\n'); + while (it.next()) |l| try lines.append(allocator, std.mem.trimEnd(u8, l, "\r")); + + // splitScalar yields a trailing empty field for a file ending in a newline. + const trailing = lines.items.len > 0 and lines.items[lines.items.len - 1].len == 0; + if (trailing) _ = lines.pop(); + + var records: std.ArrayList(Record) = .empty; + var block: ?usize = null; + var preamble: usize = 0; + + for (lines.items, 0..) |line, i| { + const trimmed = std.mem.trim(u8, line, " \t\r"); + + if (assignment(line)) |a| { + try records.append(allocator, .{ + .key = a.key, + .value = a.value, + .active = a.active, + .line = i, + .first = block orelse i, + }); + if (records.items.len == 1) preamble = block orelse i; + block = null; + continue; + } + + if (trimmed.len == 0) { + // A blank line breaks the attachment: a comment separated from a + // key by whitespace is a section note, not that key's explanation. + block = null; + continue; + } + + if (trimmed[0] == '#') { + if (block == null) block = i; + continue; + } + + block = null; + } + + if (records.items.len == 0) preamble = lines.items.len; + + return .{ + .lines = lines.items, + .records = records.items, + .preamble = preamble, + .trailing_newline = trailing, + }; +} + +/// Keys appearing in more than one record, in first-appearance order. +pub fn duplicates(allocator: std.mem.Allocator, file: File) ![]const Duplicate { + var out: std.ArrayList(Duplicate) = .empty; + var seen: std.ArrayList([]const u8) = .empty; + + for (file.records, 0..) |r, i| { + var already = false; + for (seen.items) |k| { + if (std.mem.eql(u8, k, r.key)) { + already = true; + break; + } + } + if (already) continue; + try seen.append(allocator, r.key); + + var at: std.ArrayList(usize) = .empty; + try at.append(allocator, i); + for (file.records[i + 1 ..], i + 1..) |other, j| { + if (std.mem.eql(u8, other.key, r.key)) try at.append(allocator, j); + } + if (at.items.len > 1) try out.append(allocator, .{ .key = r.key, .at = at.items }); + } + + return out.items; +} + +/// Index into `dup.at` of the record actually in effect: the LAST active one. +/// Null when every occurrence is commented out — then nothing is in effect and +/// the key's value comes from the plugin's own default. +pub fn effective(file: File, dup: Duplicate) ?usize { + var found: ?usize = null; + for (dup.at, 0..) |rec, i| { + if (file.records[rec].active) found = i; + } + return found; +} + +/// Rebuild the file with the assignment lines at `drop` removed. +/// +/// ONLY the assignment lines. The comment block above a dropped key stays, on +/// purpose: it is routinely a section header that belongs to the whole block +/// below it, and deleting a `# ─── Database ───` because the first key under it +/// lost a duplicate vote would be a silent, unrelated edit. +pub fn withoutLines(allocator: std.mem.Allocator, file: File, drop: []const usize) ![]const u8 { + var out: std.ArrayList(u8) = .empty; + for (file.lines, 0..) |line, i| { + var skip = false; + for (drop) |d| { + if (d == i) { + skip = true; + break; + } + } + if (skip) continue; + try out.appendSlice(allocator, line); + try out.append(allocator, '\n'); + } + if (!file.trailing_newline and out.items.len > 0) _ = out.pop(); + return out.items; +} + +// ── grouping ───────────────────────────────────────────────────────────────── + +/// A key prefix and the feature it belongs to. Longest match wins, so the table +/// is ordered longest-first and `matchPrefix` does not have to sort it. +/// +/// This is the FALLBACK. A key a plugin declares in its module.json `config[]` +/// is grouped under that plugin instead — that mapping is authoritative, this +/// one is a guess about a key nothing claims. +pub const prefix_groups = [_]struct { prefix: []const u8, group: []const u8 }{ + .{ .prefix = "DATABASE_", .group = "Database" }, + .{ .prefix = "SESSION_", .group = "Session" }, + .{ .prefix = "STORAGE_", .group = "Storage" }, + .{ .prefix = "TENANCY_", .group = "Tenancy" }, + .{ .prefix = "TENANT_", .group = "Tenancy" }, + .{ .prefix = "SECURITY_", .group = "Security" }, + .{ .prefix = "COOKIE_", .group = "Cookie" }, + .{ .prefix = "LOGGER_", .group = "Logging" }, + .{ .prefix = "REDIS_", .group = "Redis" }, + .{ .prefix = "QUEUE_", .group = "Queue" }, + .{ .prefix = "CACHE_", .group = "Cache" }, + .{ .prefix = "ROUTE_", .group = "Routing" }, + .{ .prefix = "MAIL_", .group = "Mail" }, + .{ .prefix = "SMTP_", .group = "Mail" }, + .{ .prefix = "VIEW_", .group = "Views" }, + .{ .prefix = "EDGE_", .group = "Edge" }, + .{ .prefix = "AUTH_", .group = "Security" }, + .{ .prefix = "CSRF_", .group = "Security" }, + .{ .prefix = "CORS_", .group = "Security" }, + .{ .prefix = "JWT_", .group = "Security" }, + .{ .prefix = "LOG_", .group = "Logging" }, + .{ .prefix = "JOB_", .group = "Queue" }, + .{ .prefix = "SMS_", .group = "SMS" }, + .{ .prefix = "SEO_", .group = "SEO" }, + .{ .prefix = "AWS_", .group = "Storage" }, + .{ .prefix = "S3_", .group = "Storage" }, + .{ .prefix = "HKM_", .group = "Platform" }, + .{ .prefix = "APP_", .group = "Application" }, + .{ .prefix = "DB_", .group = "Database" }, +}; + +/// The group a key falls into when no plugin declares it. +pub fn prefixGroup(key: []const u8) ?[]const u8 { + var best: ?[]const u8 = null; + var best_len: usize = 0; + for (prefix_groups) |g| { + if (g.prefix.len <= best_len) continue; + if (std.mem.startsWith(u8, key, g.prefix)) { + best = g.group; + best_len = g.prefix.len; + } + } + return best; +} + +/// Name used for everything the plugins and the prefix table both disown. +pub const ungrouped = "Ungrouped"; + +/// Read `.env` from a project root. Returns "" when there is none, so callers +/// can report an empty analysis rather than an error. +pub fn read(allocator: std.mem.Allocator, io: Io, projectRoot: []const u8) !struct { path: []const u8, content: []const u8 } { + const path = try std.fs.path.join(allocator, &.{ projectRoot, ".env" }); + const content = Dir.cwd().readFileAlloc(io, path, allocator, .limited(8 * 1024 * 1024)) catch ""; + return .{ .path = path, .content = content }; +} + +/// Write `content` to the project's `.env`, keeping a `.env.bak` of what was +/// there. The backup is not politeness: this file holds the only copy of every +/// secret the application has, and a rewrite that loses one costs a great deal +/// more than the disk the copy takes. +pub fn write(allocator: std.mem.Allocator, io: Io, path: []const u8, before: []const u8, content: []const u8) !void { + const backup = try std.fmt.allocPrint(allocator, "{s}.bak", .{path}); + if (before.len > 0) { + try util.writeFileAtomic(io, backup, before); + util.chmod600(io, backup); + } + try util.writeFileAtomic(io, path, content); + util.chmod600(io, path); +} + +// ── tests ──────────────────────────────────────────────────────────────────── + +test "assignment parses active, commented and exported forms" { + try std.testing.expectEqualStrings("A", assignment("A=1").?.key); + try std.testing.expect(assignment("A=1").?.active); + try std.testing.expect(!assignment("# A=1").?.active); + try std.testing.expect(!assignment("## A=1").?.active); + try std.testing.expectEqualStrings("A", assignment("export A=1").?.key); + try std.testing.expectEqualStrings("1", assignment("A = 1").?.value); +} + +test "prose and rules are not assignments" { + try std.testing.expect(assignment("# see APP_KEY for details") == null); + try std.testing.expect(assignment("") == null); + try std.testing.expect(assignment("# ─── Auth ───") == null); + // A key with a dash is not a valid env name, so this is prose. + try std.testing.expect(assignment("not-a-key=1") == null); +} + +test "a comment directly above a key attaches, one across a blank line does not" { + const a = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + + const f = try parse(arena.allocator(), "# banner\n\n# explains A\nA=1\n\n# loose\n\nB=2\n"); + try std.testing.expectEqual(@as(usize, 2), f.records.len); + try std.testing.expectEqual(@as(usize, 2), f.records[0].first); // "# explains A" + try std.testing.expectEqual(@as(usize, 3), f.records[0].line); + try std.testing.expectEqual(f.records[1].line, f.records[1].first); // nothing attached + // Lines 0-1 ("# banner" + the blank) are the banner; line 2 belongs to A. + try std.testing.expectEqual(@as(usize, 2), f.preamble); +} + +test "the LAST active assignment is the one in effect" { + const a = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + const al = arena.allocator(); + + const f = try parse(al, "A=1\n# A=2\nA=3\nB=1\n"); + const dups = try duplicates(al, f); + try std.testing.expectEqual(@as(usize, 1), dups.len); + try std.testing.expectEqualStrings("A", dups[0].key); + try std.testing.expectEqual(@as(usize, 3), dups[0].at.len); + // Index 2 within `at` — the third occurrence, `A=3`. + try std.testing.expectEqual(@as(usize, 2), effective(f, dups[0]).?); +} + +test "a key whose every occurrence is commented has nothing in effect" { + const a = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + const al = arena.allocator(); + + const f = try parse(al, "# A=1\n# A=2\n"); + const dups = try duplicates(al, f); + try std.testing.expect(effective(f, dups[0]) == null); +} + +test "APP_KEY_ID and APP_KEY are different keys" { + const a = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + const al = arena.allocator(); + + const f = try parse(al, "APP_KEY=1\nAPP_KEY_ID=2\n"); + try std.testing.expectEqual(@as(usize, 0), (try duplicates(al, f)).len); +} + +test "withoutLines drops the assignment and keeps the header above it" { + const a = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + const al = arena.allocator(); + + const src = "# ─── Database ───\nDB_HOST=old\nDB_PORT=3306\nDB_HOST=new\n"; + const f = try parse(al, src); + const out = try withoutLines(al, f, &.{1}); + try std.testing.expectEqualStrings("# ─── Database ───\nDB_PORT=3306\nDB_HOST=new\n", out); +} + +test "prefixGroup takes the longest match" { + try std.testing.expectEqualStrings("Database", prefixGroup("DB_HOST").?); + try std.testing.expectEqualStrings("Database", prefixGroup("DATABASE_URL").?); + try std.testing.expectEqualStrings("Application", prefixGroup("APP_ENV").?); + try std.testing.expectEqualStrings("Session", prefixGroup("SESSION_DRIVER").?); + try std.testing.expect(prefixGroup("STRIPE_SECRET") == null); +} + +test "isRule matches only wordless comments" { + try std.testing.expect(isRule("# ─────────────")); + try std.testing.expect(isRule("#")); + try std.testing.expect(isRule("# ---------")); + try std.testing.expect(!isRule("# ─── Auth ───")); + try std.testing.expect(!isRule("# set this before booting")); + try std.testing.expect(!isRule("DB_HOST=1")); +} + +test "headerLabel reads a banner's label and leaves prose alone" { + try std.testing.expectEqualStrings("Auth", headerLabel("# ─── Auth ───").?); + try std.testing.expectEqualStrings( + "s3 driver (MinIO)", + headerLabel("# --- s3 driver (MinIO) ---").?, + ); + try std.testing.expect(headerLabel("# set this before booting") == null); + try std.testing.expect(headerLabel("# ─────") == null); +} diff --git a/tools/src/lib/plugin_env.zig b/tools/src/lib/plugin_env.zig index 6e2201f..aa5ef51 100644 --- a/tools/src/lib/plugin_env.zig +++ b/tools/src/lib/plugin_env.zig @@ -25,6 +25,7 @@ const std = @import("std"); const util = @import("util.zig"); +const envfile = @import("env_file.zig"); const Io = std.Io; const Dir = std.Io.Dir; @@ -150,8 +151,45 @@ pub fn hasKey(content: []const u8, key: []const u8) bool { return false; } +/// Byte offset just past the last non-blank line of this plugin's existing +/// block, or null when the file has no block for it yet. +/// +/// Without this, a plugin that gains a variable in a later version seeds a +/// SECOND `# ─── Auth ───` block on the next enable, and a third after that. +/// The file still works — every key is present exactly once — but the grouping +/// it was written to provide quietly stops being true, which is the whole point +/// of the block. +fn insertionPoint(content: []const u8, pluginName: []const u8) ?usize { + var found = false; + var end: ?usize = null; + var pos: usize = 0; + + while (pos <= content.len) { + const nl = std.mem.indexOfScalarPos(u8, content, pos, '\n') orelse content.len; + const line = content[pos..nl]; + + if (envfile.headerLabel(line)) |label| { + if (found) break; // the next block starts here + if (std.mem.eql(u8, label, pluginName)) found = true; + } else if (found and std.mem.trim(u8, line, " \t\r").len > 0) { + end = nl; + } + + if (nl == content.len) break; + pos = nl + 1; + } + + return if (found) (end orelse null) else null; +} + /// Append every variable of `vars` that the project's `.env` does not already /// mention, under a labelled block. Creates the file when absent. +/// +/// Two rules, both load-bearing. A key already in the file — set, or commented +/// out — is never rewritten, so a real secret is never clobbered by a re-enable +/// or by a second plugin that happens to declare the same variable. And the new +/// keys go into this plugin's OWN block, merged into it when one already +/// exists. pub fn seed( allocator: std.mem.Allocator, io: Io, @@ -179,27 +217,12 @@ pub fn seed( return .{ .added = missing.items, .skipped = skipped, .path = path, .created = created }; } - var out: std.ArrayList(u8) = .empty; - try out.appendSlice(allocator, existing); - - // Exactly one blank line before the block, whatever the file ended with. - if (out.items.len > 0) { - while (out.items.len > 0 and (out.items[out.items.len - 1] == '\n' or out.items[out.items.len - 1] == '\r')) { - _ = out.pop(); - } - try out.appendSlice(allocator, "\n\n"); - } - - try out.appendSlice(allocator, try std.fmt.allocPrint( - allocator, - "# ─── {s} ─────────────────────────────────────────────────\n" ++ - "# Declared in the plugin's module.json config[]. Added by `hkm plugins enable`.\n", - .{pluginName}, - )); - + // The variable lines themselves, built once — they go either into this + // plugin's existing block or into a fresh one. + var body: std.ArrayList(u8) = .empty; for (missing.items) |v| { if (v.default) |d| { - try out.appendSlice(allocator, try std.fmt.allocPrint(allocator, "{s}={s}\n", .{ v.key, d })); + try body.appendSlice(allocator, try std.fmt.allocPrint(allocator, "{s}={s}\n", .{ v.key, d })); continue; } @@ -207,7 +230,7 @@ pub fn seed( // Active but empty. The kernel counts '' as missing, so the boot // still stops here until a real value is supplied — which is the // correct outcome for something like an API key. - try out.appendSlice(allocator, try std.fmt.allocPrint( + try body.appendSlice(allocator, try std.fmt.allocPrint( allocator, "{s}= # REQUIRED{s} — set this before booting\n", .{ v.key, typeSuffix(allocator, v.type_name) }, @@ -217,13 +240,50 @@ pub fn seed( // Optional with no default: COMMENTED. Writing it empty would be read as // the string '' and would quietly beat the plugin's own default. - try out.appendSlice(allocator, try std.fmt.allocPrint( + try body.appendSlice(allocator, try std.fmt.allocPrint( allocator, "# {s}= # optional{s}\n", .{ v.key, typeSuffix(allocator, v.type_name) }, )); } + var out: std.ArrayList(u8) = .empty; + + if (insertionPoint(existing, pluginName)) |at| { + // Merge into the block this plugin already owns. + const rest = existing[at..]; + const tail = std.mem.trimStart(u8, rest, "\n"); + // How the block was separated from whatever follows it. The inserted + // lines go INSIDE the block, so that separation has to be put back — + // otherwise every re-seed pulls the next block up by one line. + const newlines = rest.len - tail.len; + + try out.appendSlice(allocator, existing[0..at]); + try out.append(allocator, '\n'); + try out.appendSlice(allocator, body.items); + var n: usize = 1; + while (n < newlines) : (n += 1) try out.append(allocator, '\n'); + try out.appendSlice(allocator, tail); + } else { + try out.appendSlice(allocator, existing); + + // Exactly one blank line before the block, whatever the file ended with. + if (out.items.len > 0) { + while (out.items.len > 0 and (out.items[out.items.len - 1] == '\n' or out.items[out.items.len - 1] == '\r')) { + _ = out.pop(); + } + try out.appendSlice(allocator, "\n\n"); + } + + try out.appendSlice(allocator, try std.fmt.allocPrint( + allocator, + "# ─── {s} ─────────────────────────────────────────────────\n" ++ + "# Declared in the plugin's module.json config[]. Added by `hkm plugins enable`.\n", + .{pluginName}, + )); + try out.appendSlice(allocator, body.items); + } + Dir.cwd().writeFile(io, .{ .sub_path = path, .data = out.items }) catch |e| return e; // A .env holds secrets; a freshly created one should not be world-readable. @@ -257,3 +317,26 @@ test "hasKey does not match a longer key with the same prefix" { test "hasKey ignores a key mentioned only in prose" { try std.testing.expect(!hasKey("# see APP_KEY for details\n", "APP_KEY")); } + +test "insertionPoint finds a plugin's own block and ignores the next one" { + const src = + "APP_KEY=x\n\n" ++ + "# ─── Auth ───\n" ++ + "AUTH_TTL=60\n\n" ++ + "# ─── Mail ───\n" ++ + "MAIL_HOST=smtp\n"; + + // Just past `AUTH_TTL=60` — inside Auth, not swallowing the Mail block. + const at = insertionPoint(src, "Auth").?; + try std.testing.expectEqualStrings("APP_KEY=x\n\n# ─── Auth ───\nAUTH_TTL=60", src[0..at]); + + try std.testing.expect(insertionPoint(src, "Storage") == null); +} + +test "a key already in the file is never rewritten, set or commented" { + // Both forms count as present: rewriting a commented one would grow the + // file on every enable, and rewriting a set one would clobber a real secret. + try std.testing.expect(hasKey("DEMO_SECRET=live-value\n", "DEMO_SECRET")); + try std.testing.expect(hasKey("# DEMO_MODE=\n", "DEMO_MODE")); + try std.testing.expect(!hasKey("DEMO_MODE_X=1\n", "DEMO_MODE")); +} diff --git a/tools/src/lib/services.zig b/tools/src/lib/services.zig index 47f33eb..5e5c91f 100644 --- a/tools/src/lib/services.zig +++ b/tools/src/lib/services.zig @@ -15,6 +15,20 @@ const EnvMap = std.process.Environ.Map; /// Resolve the project root directory from a path or registered name. /// Returns an absolute path to a folder that contains a proj.json. +/// `path` and every directory above it, nearest first, ending at "/". +/// +/// Split out from `resolveRoot` so the walk itself is testable without a +/// filesystem: the interesting part is the sequence, not the stat. +pub fn ancestors(allocator: std.mem.Allocator, path: []const u8) ![]const []const u8 { + var out: std.ArrayList([]const u8) = .empty; + var cursor: ?[]const u8 = util.trimSlash(path); + while (cursor) |p| : (cursor = util.parentOf(p)) { + try out.append(allocator, if (p.len == 0) "/" else p); + if (p.len == 0 or std.mem.eql(u8, p, "/")) break; + } + return out.items; +} + pub fn resolveRoot(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: []const u8) !?[]const u8 { // No target → current working directory. const candidate = if (target.len == 0) (env.get("PWD") orelse ".") else target; @@ -25,6 +39,21 @@ pub fn resolveRoot(allocator: std.mem.Allocator, io: Io, env: *EnvMap, target: [ return abs; } + // CWD MODE: walk up. Every other tool open in that same terminal — git, + // composer, npm — finds its project from anywhere inside it, and being told + // "'.' is neither a project folder nor a registered name" while standing in + // `/app` is a worse answer than the directory above holds. + // + // Only when no target was given. An EXPLICIT path stays exact: `hkm install + // ./tools` quietly hardening the parent project instead of failing is the + // kind of help nobody wants from a command that chowns things. + if (target.len == 0) { + for ((try ancestors(allocator, abs))[1..]) |dir| { + const marker = try std.fmt.allocPrint(allocator, "{s}/proj.json", .{dir}); + if (util.fileExists(io, marker)) return dir; + } + } + // NAME MODE: look the name up in the kernel registry. if (target.len > 0) { if (try registry.resolvePath(allocator, io, env)) |jsonPath| { @@ -160,3 +189,39 @@ pub fn replace(allocator: std.mem.Allocator, input: []const u8, needle: []const try out.appendSlice(allocator, rest); return out.toOwnedSlice(allocator); } + +// ── tests ────────────────────────────────────────────────────── + +test "ancestors walks from the directory up to the root, nearest first" { + const a = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + + const got = try ancestors(arena.allocator(), "/srv/app/src/Http"); + try std.testing.expectEqual(@as(usize, 5), got.len); + try std.testing.expectEqualStrings("/srv/app/src/Http", got[0]); + try std.testing.expectEqualStrings("/srv/app/src", got[1]); + try std.testing.expectEqualStrings("/srv/app", got[2]); + try std.testing.expectEqualStrings("/srv", got[3]); + try std.testing.expectEqualStrings("/", got[4]); +} + +test "ancestors terminates on the root itself" { + const a = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + + const got = try ancestors(arena.allocator(), "/"); + try std.testing.expectEqual(@as(usize, 1), got.len); + try std.testing.expectEqualStrings("/", got[0]); +} + +test "a trailing slash does not produce a duplicate first entry" { + const a = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(a); + defer arena.deinit(); + + const got = try ancestors(arena.allocator(), "/srv/app/"); + try std.testing.expectEqualStrings("/srv/app", got[0]); + try std.testing.expectEqualStrings("/srv", got[1]); +} diff --git a/tools/src/main.zig b/tools/src/main.zig index 1be382b..26ec0de 100644 --- a/tools/src/main.zig +++ b/tools/src/main.zig @@ -5,6 +5,7 @@ const update_cmd = @import("commands/update.zig"); const run_cmd = @import("commands/run.zig"); const list_cmd = @import("commands/list.zig"); const discover_cmd = @import("commands/discover.zig"); +const env_cmd = @import("commands/env.zig"); const plugins_cmd = @import("commands/plugins.zig"); const module_cmd = @import("commands/module.zig"); const ui_cmd = @import("commands/ui.zig"); @@ -32,6 +33,7 @@ fn printHelp(allocator: std.mem.Allocator, io: std.Io, env: *std.process.Environ prompt.item("hkm list", "list registered projects (alias: ls)"); prompt.item("hkm discover [root]", "find projects on disk and register them (alias: scan)"); prompt.item("hkm plugins [path|name]", "analyse a project's enabled plugins/modules"); + prompt.item("hkm env [audit|dedupe|group]", "audit a project's .env: duplicate keys, grouping"); prompt.item("hkm module [create|delete]", "scaffold a first-party kernel package (modules/)"); prompt.item("hkm ui [sync|list|link|clean]", "federate enabled plugins' UIs into the frontend"); prompt.item("hkm update ", "refresh a project's kernel registry entry"); @@ -385,6 +387,11 @@ fn dispatch(init: std.process.Init.Minimal, mm: *memory.Manager) !u8 { defer scope.end(); return try discover_cmd.run(scope.allocator(), io, &env_map, args); } + if (std.mem.eql(u8, cmd, "env")) { + var scope = CmdScope.begin(mm, "env"); + defer scope.end(); + return try env_cmd.run(scope.allocator(), io, &env_map, args); + } if (std.mem.eql(u8, cmd, "module")) { var scope = CmdScope.begin(mm, "module"); defer scope.end(); diff --git a/tools/src/tests.zig b/tools/src/tests.zig index 027083b..477dcb1 100644 --- a/tools/src/tests.zig +++ b/tools/src/tests.zig @@ -29,6 +29,7 @@ test { _ = @import("commands/discover.zig"); _ = @import("commands/install.zig"); _ = @import("commands/doctor.zig"); + _ = @import("commands/env.zig"); _ = @import("commands/list.zig"); _ = @import("commands/module.zig"); _ = @import("commands/new.zig"); @@ -43,6 +44,7 @@ test { _ = @import("constants.zig"); _ = @import("lib/banner.zig"); _ = @import("lib/composer_version.zig"); + _ = @import("lib/env_file.zig"); _ = @import("lib/install_scope.zig"); _ = @import("lib/inspector/dashboard.zig"); _ = @import("lib/inspector/meminspector.zig");