Skip to content

Commit aa3296c

Browse files
committed
chore(release): v0.1.0
1 parent d7bfd88 commit aa3296c

10 files changed

Lines changed: 1424 additions & 9 deletions

File tree

README.md

Lines changed: 91 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,99 @@
22

33
A CLI dependency manager for Zig projects.
44

5-
> ⚠️ Early development. Not ready for use yet.
5+
depz wraps `zig fetch` with npm-style ergonomics: short repo specs, version
6+
tags, update checking, and registry selection — so managing `build.zig.zon`
7+
dependencies feels less manual.
68

7-
## Status
9+
> **Early but usable.** The core commands work and are tested. Expect breaking
10+
> changes before 1.0 — the CLI surface and `build.zig.zon` metadata may still shift.
811
9-
Currently building core functionality. See [issues](../../issues) for progress.
12+
## Requirements
13+
14+
- **Zig `0.17.0-dev`** — depz is built against a development build and pins it deliberately.
15+
- **`git`** on your `PATH` — depz shells out to `zig fetch` and `git ls-remote`.
16+
17+
## Install
18+
19+
There are no prebuilt binaries yet. Build from source:
20+
21+
```sh
22+
git clone https://github.com/depz-org/depz-cli
23+
cd depz-cli
24+
zig build
25+
```
26+
27+
The binary lands at `zig-out/bin/depz`. Put it on your `PATH`, or run it directly.
28+
29+
## Usage
30+
31+
Run inside a Zig project (one with a `build.zig.zon`).
32+
33+
### Add a dependency
34+
35+
```sh
36+
# a specific tag
37+
depz add depz-org/example@v1.0.0
38+
39+
# latest commit on the default branch
40+
depz add depz-org/example
41+
42+
# from a non-GitHub host
43+
depz add foreverzer0/klack@v1.1.0 --registry=codeberg.org
44+
```
45+
46+
depz builds the `git+https://…` URL, runs `zig fetch --save`, and lets Zig
47+
resolve and pin the exact commit into `build.zig.zon`. GitHub is the default
48+
host; `--registry` overrides it per command, and a project-level
49+
`.depz = .{ .registry = "…" }` in `build.zig.zon` sets a default for the project.
50+
51+
### List dependencies
52+
53+
```sh
54+
depz list
55+
```
56+
57+
```
58+
2 dependencies
59+
60+
example v1.0.0
61+
httpz git (52eb187c)
62+
```
63+
64+
### Check for updates
65+
66+
```sh
67+
depz list --check
68+
```
69+
70+
```
71+
Checking 2 dependencies
72+
73+
example v1.0.0 → v2.0.0
74+
75+
1 up to date. Run with --all to show them.
76+
```
77+
78+
By default only outdated dependencies are shown. Options:
79+
80+
- `--all` — also list dependencies that are up to date
81+
- `--target=<latest|minor|patch>` — how far to look for updates
82+
(`latest` is the default; `minor` stays within the current major; `patch`
83+
stays within the current minor)
84+
85+
Tag-pinned dependencies are compared by version; dependencies tracking a branch
86+
are compared by commit.
87+
88+
## How it works
89+
90+
depz is a thin layer over Zig's own tooling — it does not host packages or run a
91+
registry. `add` delegates downloading and hashing to `zig fetch --save`; update
92+
checks read the pinned ref from each dependency's URL and query the upstream git
93+
host with `git ls-remote`. Everything lives in your existing `build.zig.zon`.
1094

1195
## License
1296

13-
Dual-licensed under [Apache-2.0](LICENSE-APACHE) or [MIT](LICENSE-MIT), at your option.
97+
Dual-licensed under [Apache-2.0](LICENSE-APACHE) or [MIT](LICENSE-MIT), at your option.
98+
99+
Contributions are welcome and, unless you state otherwise, are understood to be
100+
dual-licensed under the same terms.

build.zig.zon

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
.{
22
.name = .depz_cli,
3-
.version = "0.0.0",
3+
.version = "0.1.0",
44
.fingerprint = 0x7494701f7c1ad910,
55
.minimum_zig_version = "0.17.0-dev.1158+1d1193aa7",
66
.dependencies = .{},

src/args.zig

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
//! Schema-free argv classifier: splits tokens into positionals and flags.
2+
//! Per-command validation lives at the call site, not here.
3+
4+
const std = @import("std");
5+
6+
/// A parsed command-line flag.
7+
///
8+
/// `value` is `""` both for flags written without `=` (`--verbose`) and for
9+
/// flags written with a trailing `=` and nothing after it (`--output=`). This
10+
/// classifier does not distinguish the two: read `value` only for flags you
11+
/// know take an argument, and test boolean presence via `Parsed.has`.
12+
pub const Flag = struct { name: []const u8, value: []const u8 };
13+
14+
/// Result of classifying an argv slice.
15+
///
16+
/// Ownership: the outer slices (`positionals`, `flags`) are owned by the arena
17+
/// passed to `classify`. The string *contents* — each name, value, and
18+
/// positional — alias the original `argv` and are NOT copied. A `Parsed` thus
19+
/// borrows both the arena and `argv`, and must not outlive either.
20+
pub const Parsed = struct {
21+
positionals: []const []const u8,
22+
flags: []const Flag,
23+
24+
/// True if a flag named `name` is present, with or without a value.
25+
/// Use this for boolean flags.
26+
pub fn has(self: Parsed, name: []const u8) bool {
27+
for (self.flags) |f| if (std.mem.eql(u8, f.name, name)) return true;
28+
return false;
29+
}
30+
31+
/// Value of the first flag matching `name`, or `null` if absent.
32+
/// A present flag with no `=` yields `""`, not `null` — only absence is `null`.
33+
pub fn get(self: Parsed, name: []const u8) ?[]const u8 {
34+
for (self.flags) |f| if (std.mem.eql(u8, f.name, name)) return f.value;
35+
return null;
36+
}
37+
};
38+
39+
/// Splits `argv` into positionals and flags.
40+
///
41+
/// A token is a flag when it begins with `-`; all leading dashes are stripped
42+
/// from the name. The first `=` separates name from value, so `--foo=a=b`
43+
/// yields value `a=b`; a token with no `=` gets an empty value.
44+
///
45+
/// The returned `Parsed` borrows `arena` (outer slices) and `argv` (contents);
46+
/// see `Parsed` for details.
47+
pub fn classify(arena: std.mem.Allocator, argv: []const []const u8) !Parsed {
48+
var pos: std.ArrayList([]const u8) = .empty;
49+
var flags: std.ArrayList(Flag) = .empty;
50+
51+
for (argv) |tok| {
52+
if (tok.len > 0 and tok[0] == '-') {
53+
const body = std.mem.trimStart(u8, tok, "-");
54+
if (std.mem.indexOfScalar(u8, body, '=')) |i|
55+
try flags.append(arena, .{ .name = body[0..i], .value = body[i + 1 ..] })
56+
else
57+
try flags.append(arena, .{ .name = body, .value = "" });
58+
} else try pos.append(arena, tok);
59+
}
60+
61+
return .{ .positionals = pos.items, .flags = flags.items };
62+
}
63+
64+
// ───────────────────────── tests ─────────────────────────
65+
test "positionals only" {
66+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
67+
defer arena.deinit();
68+
69+
const p = try classify(arena.allocator(), &.{ "add", "foo", "bar" });
70+
try std.testing.expectEqual(@as(usize, 3), p.positionals.len);
71+
try std.testing.expectEqual(@as(usize, 0), p.flags.len);
72+
try std.testing.expectEqualStrings("bar", p.positionals[2]);
73+
}
74+
75+
test "long and short flags, with and without value" {
76+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
77+
defer arena.deinit();
78+
79+
const p = try classify(arena.allocator(), &.{ "--save", "-u", "--out=dist" });
80+
try std.testing.expect(p.has("save"));
81+
try std.testing.expect(p.has("u"));
82+
try std.testing.expectEqualStrings("", p.get("save").?);
83+
try std.testing.expectEqualStrings("dist", p.get("out").?);
84+
}
85+
86+
test "trailing '=' collapses to empty value" {
87+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
88+
defer arena.deinit();
89+
90+
const p = try classify(arena.allocator(), &.{"--out="});
91+
try std.testing.expect(p.has("out"));
92+
try std.testing.expectEqualStrings("", p.get("out").?);
93+
}
94+
95+
test "first '=' wins" {
96+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
97+
defer arena.deinit();
98+
99+
const p = try classify(arena.allocator(), &.{"--filter=a=b"});
100+
try std.testing.expectEqualStrings("a=b", p.get("filter").?);
101+
}
102+
103+
test "mixed order is preserved within each bucket" {
104+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
105+
defer arena.deinit();
106+
107+
const p = try classify(arena.allocator(), &.{ "add", "--save", "pkg", "-u" });
108+
try std.testing.expectEqualStrings("add", p.positionals[0]);
109+
try std.testing.expectEqualStrings("pkg", p.positionals[1]);
110+
try std.testing.expect(p.has("save") and p.has("u"));
111+
}
112+
113+
test "absent flag is null, not empty" {
114+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
115+
defer arena.deinit();
116+
117+
const p = try classify(arena.allocator(), &.{"add"});
118+
try std.testing.expect(!p.has("nope"));
119+
try std.testing.expectEqual(@as(?[]const u8, null), p.get("nope"));
120+
}

src/cli.zig

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
1+
const std = @import("std");
12
pub const Context = @import("Context.zig");
3+
const command = @import("./command.zig");
4+
const addCommand = @import("./commands/add.zig");
5+
const listCommand = @import("./commands/list.zig");
26

37
pub fn run(ctx: Context, args: []const [:0]const u8) !void {
4-
_ = ctx;
5-
_ = args;
8+
if (args.len < 2) return printUsage(ctx.io);
9+
10+
const cmd = std.meta.stringToEnum(command.Command, args[1]) orelse {
11+
return printUsage(ctx.io);
12+
};
13+
14+
switch (cmd) {
15+
.add => try addCommand.run(ctx, args),
16+
.list => try listCommand.run(ctx, args),
17+
.help => return printUsage(ctx.io),
18+
}
19+
}
20+
21+
fn printUsage(io: std.Io) !void {
22+
try std.Io.File.stdout().writeStreamingAll(io, command.usage);
623
}

src/command.zig

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,38 @@ pub const Command = enum {
99

1010
fn meta(self: Command) Meta {
1111
return switch (self) {
12-
.add => .{ .args = "<url>", .desc = "Add a dependency to build.zig.zon (wraps `zig fetch --save`)" },
13-
.list => .{ .args = "", .desc = "List current dependencies (not yet implemented)" },
12+
.add => .{ .args = "<owner>/<repo>[@<tag>] [--registry=<host>]", .desc = "Add a dependency to build.zig.zon (wraps `zig fetch --save`)" },
13+
.list => .{ .args = "[--check] [--all] [--target=<latest|minor|patch>]", .desc = "List dependencies, or check for updates with --check" },
1414
.help => .{ .args = "", .desc = "Show this help text" },
1515
};
1616
}
1717
};
18+
19+
pub const usage = blk: {
20+
var cmds: []const u8 = "";
21+
const fieldNames = std.meta.fieldNames(Command);
22+
for (fieldNames) |fieldName| {
23+
const m = (@field(Command, fieldName)).meta();
24+
const left: []const u8 = if (m.args.len <= 0) fieldName else std.fmt.comptimePrint("{s} {s}", .{ fieldName, m.args });
25+
cmds = cmds ++ std.fmt.comptimePrint(" {s:<14}{s}\n", .{ left, m.desc });
26+
}
27+
break :blk std.fmt.comptimePrint(
28+
\\depz — ergonomic dependency management for Zig
29+
\\
30+
\\Usage:
31+
\\ depz <command> [args]
32+
\\
33+
\\Commands:
34+
\\{s}
35+
\\Examples:
36+
\\ depz add depz-org/example@v1.0.0
37+
\\ depz add depz-org/example
38+
\\ depz add foreverzer0/klack@v1.1.0 --registry=codeberg.org
39+
\\ depz list --check
40+
\\
41+
, .{cmds});
42+
};
43+
44+
pub fn isHelpFlag(arg: []const u8) bool {
45+
return std.mem.eql(u8, arg, "help") or std.mem.eql(u8, arg, "-h") or std.mem.eql(u8, arg, "--help");
46+
}

src/commands/add.zig

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
const std = @import("std");
2+
const args = @import("../args.zig");
3+
const Context = @import("../Context.zig");
4+
const manifest = @import("../manifest.zig");
5+
const semver = @import("../semver.zig");
6+
const source = @import("../source.zig");
7+
8+
/// Adds a dependency to the current project's `build.zig.zon`.
9+
///
10+
/// Thin wrapper around `zig fetch --save`, which downloads, hashes, and writes
11+
/// the dependency entry itself. depz builds the `git+https://…` URL — choosing
12+
/// the host from `--registry`, the project's `.depz.registry`, or the default —
13+
/// and lets `zig fetch` resolve and pin the exact commit.
14+
///
15+
/// A trailing `@<tag>` pins a version; without it, the default branch's latest
16+
/// commit is tracked. Range constraints (`^1.0.0`) need upstream tag
17+
/// enumeration to pick a match; that's phase two and is rejected here for now.
18+
pub fn run(ctx: Context, argv: []const []const u8) !void {
19+
const parsed = try args.classify(ctx.arena, argv[2..]);
20+
if (parsed.positionals.len == 0)
21+
std.process.fatal("`add` needs a package, e.g. `depz add <owner>/<repo>@<version>`", .{});
22+
23+
const src = try ctx.root.readFileAllocOptions(ctx.io, "build.zig.zon", ctx.arena, .unlimited, .of(u8), 0);
24+
const man = try manifest.parse(ctx.arena, src);
25+
const target = parsed.positionals[0];
26+
const registry = parsed.get("registry");
27+
const host = source.resolveHost(registry, man.depz.registry);
28+
29+
// Split repo from optional @version on the LAST '@', so an ssh-style
30+
// git@host:owner/repo keeps its leading git@ in the repo part.
31+
const at = std.mem.lastIndexOfScalar(u8, target, '@');
32+
const repo = if (at) |i| target[0..i] else target;
33+
const version: ?[]const u8 = if (at) |i| target[i + 1 ..] else null;
34+
35+
if (version) |v| {
36+
// Phase one: reject ranges rather than pretend to honor them.
37+
if (isRange(v))
38+
std.process.fatal(
39+
"`add` needs a concrete version like @v1.2.3 for now, not a range ('{s}'). Range resolution is coming.",
40+
.{v},
41+
);
42+
43+
// Concrete tag: git+https://host/repo#<tag>. zig fetch resolves the tag
44+
// to a commit and records both in .url as ?ref=<tag>#<commit>, so the
45+
// URL carries the pinned version — no .depz block needed.
46+
const url = try source.buildGitUrl(ctx.arena, host, repo, v);
47+
try fetchSave(ctx, url);
48+
} else {
49+
// Latest: track the default branch. zig fetch resolves it to a concrete
50+
// commit and writes it into .url (git+https://…#<commit>), so the URL is
51+
// the single source of truth — no .depz block to add.
52+
const url = try source.buildGitUrl(ctx.arena, host, repo, null);
53+
try fetchSave(ctx, url);
54+
}
55+
}
56+
57+
/// True if `v` is a range constraint rather than a concrete version.
58+
/// Concrete = a plain tag zig fetch can resolve directly (`v1.2.3`, `1.2.3`).
59+
fn isRange(v: []const u8) bool {
60+
if (v.len == 0) return false;
61+
return switch (v[0]) {
62+
'^', '~', '>', '<', '=' => true,
63+
else => false,
64+
};
65+
}
66+
67+
/// Run `zig fetch --save <url>`; it downloads, hashes, and writes the entry.
68+
fn fetchSave(ctx: Context, url: []const u8) !void {
69+
const result = try std.process.run(ctx.arena, ctx.io, .{
70+
.argv = &.{ "zig", "fetch", "--save", url },
71+
});
72+
if (result.term != .exited or result.term.exited != 0)
73+
std.process.fatal("`zig fetch` failed for {s}:\n{s}", .{ url, result.stderr });
74+
}

0 commit comments

Comments
 (0)