Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions src/cli/commands/install.zig
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ const install_meta = command.CommandMeta{
.description = "Force repository sync even if cache is fresh",
.flag_type = .bool,
},
.{
.name = "dry-run",
.description = "Show changes without creating or activating a generation",
.flag_type = .bool,
},
},
};

Expand All @@ -48,6 +53,7 @@ fn handleInstall(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!ty
const profile_name = args.getString("profile") orelse "system";
const verify_store = args.getBool("verify-store");
const force_sync = args.getBool("sync");
const dry_run = args.getBool("dry-run");

// Set initial diagnostic context - the subject is the package being installed
const diagnostic_ctx = mere.errors.DiagnosticContext.init()
Expand All @@ -58,7 +64,7 @@ fn handleInstall(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!ty
defer ctx.releaseStoreLock();

// Error boundary: catch all errors and map them to user-friendly messages at CLI boundary
const success_message = performInstallation(ctx, package_names, profile_name, verify_store, force_sync) catch |err| {
const success_message = performInstallation(ctx, package_names, profile_name, verify_store, force_sync, dry_run) catch |err| {
return try command.errorResult(ctx, err, null);
};

Expand All @@ -77,6 +83,7 @@ fn performInstallation(
profile_name: []const u8,
verify_store: bool,
force_sync: bool,
dry_run: bool,
) !?[]const u8 {
// Ensure configuration is loaded (no logging - errors propagate)
_ = try ctx.getConfig();
Expand All @@ -87,7 +94,7 @@ fn performInstallation(
const client = curl_client.client();

// Perform installation (no logging - errors propagate)
const outcome = try mere.install.installPackagesFromConfig(ctx, package_names, client, false, verify_store, force_sync, profile_name);
const outcome = try mere.install.installPackagesFromConfigWithPreview(ctx, package_names, client, false, verify_store, force_sync, profile_name, dry_run);
return switch (outcome) {
.completed => null,
.store_only_system_activation_deferred => null,
Expand Down
82 changes: 82 additions & 0 deletions src/cli/commands/upgrade.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
const std = @import("std");
const mere = @import("mere");
const download = mere.download;
const types = @import("../types.zig");
const command = @import("../command.zig");
const MereError = types.MereError;

const upgrade_meta = command.CommandMeta{
.group = "Package Management",
.order = 31,
.name = "upgrade",
.description = "Upgrade requested packages in a profile",
.args = &[_]types.Arg{
.{
.name = "package",
.description = "Requested package name(s); omit to upgrade all roots",
.required = false,
},
},
.flags = &[_]types.Flag{
.{
.name = "profile",
.short = 'p',
.description = "Profile to upgrade (default: system)",
.flag_type = .string,
},
.{
.name = "verify-store",
.description = "Verify store content hashes during activation (slow)",
.flag_type = .bool,
},
.{
.name = "sync",
.description = "Force repository sync even if cache is fresh",
.flag_type = .bool,
},
.{
.name = "dry-run",
.description = "Show changes without creating or activating a generation",
.flag_type = .bool,
},
},
};

fn handleUpgrade(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!types.CommandResult {
const package_names = args.positional;
const profile_name = args.getString("profile") orelse "system";
const verify_store = args.getBool("verify-store");
const force_sync = args.getBool("sync");
const dry_run = args.getBool("dry-run");

ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(
if (package_names.len > 0) package_names[0] else profile_name,
));

if (try command.acquireStoreLockOrResult(ctx)) |result| return result;
defer ctx.releaseStoreLock();

_ = ctx.getConfig() catch |err| return try command.errorResult(ctx, err, null);
var curl_client = download.CurlTransferClient.init(ctx, command.user_agent) catch |err| {
return try command.errorResult(ctx, err, null);
};
defer download.CurlTransferClient.cleanupFn(ctx, curl_client);

_ = mere.install.upgradePackagesFromConfig(
ctx,
package_names,
curl_client.client(),
verify_store,
force_sync,
profile_name,
dry_run,
) catch |err| return try command.errorResult(ctx, err, null);

return .{ .success = true };
}

pub fn createCommand(allocator: std.mem.Allocator) !*command.Command {
const cmd = try allocator.create(command.Command);
cmd.* = command.Command.init(allocator, upgrade_meta, handleUpgrade);
return cmd;
}
6 changes: 6 additions & 0 deletions src/cli/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const MereError = mere.errors.MereError;

// Import command implementations
const install = @import("commands/install.zig");
const upgrade = @import("commands/upgrade.zig");
const uninstall = @import("commands/uninstall.zig");
const dev = @import("commands/dev.zig");
const release_cmd = @import("commands/release.zig");
Expand Down Expand Up @@ -139,6 +140,11 @@ fn registerCommands(allocator: std.mem.Allocator, cli_system: *cli.CLI, root_com
try cli_system.registerCommand(install_command);
try root_command.addSubcommand(install_command);

// Create upgrade command
const upgrade_command = try upgrade.createCommand(allocator);
try cli_system.registerCommand(upgrade_command);
try root_command.addSubcommand(upgrade_command);

// Create uninstall command
const uninstall_command = try uninstall.createCommand(allocator);
try cli_system.registerCommand(uninstall_command);
Expand Down
104 changes: 99 additions & 5 deletions src/generation.zig
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,16 @@ pub const PackageEntry = struct {
arch: []const u8,
store_path: []const u8,
content_hash: []const u8, // 64 hex chars
requested: bool = true,
constraint_expr: ?[]const u8 = null,

pub fn deinit(self: *PackageEntry, allocator: std.mem.Allocator) void {
allocator.free(self.name);
allocator.free(self.version);
allocator.free(self.arch);
allocator.free(self.store_path);
allocator.free(self.content_hash);
if (self.constraint_expr) |constraint| allocator.free(constraint);
}
};

Expand All @@ -48,11 +51,14 @@ pub const PackageSpec = struct {
version: ?[]const u8 = null,
release: ?u32 = null,
content_hash: ?[]const u8 = null,
requested: bool = true,
constraint_expr: ?[]const u8 = null,

pub fn deinit(self: *PackageSpec, allocator: std.mem.Allocator) void {
allocator.free(self.name);
if (self.version) |v| allocator.free(v);
if (self.content_hash) |h| allocator.free(h);
if (self.constraint_expr) |constraint| allocator.free(constraint);
}
};

Expand Down Expand Up @@ -258,6 +264,20 @@ pub const GenerationManifest = struct {
arch: []const u8,
store_path: []const u8,
content_hash: []const u8,
) GenerationError!void {
return self.addPackageWithIntent(name, version, release, arch, store_path, content_hash, true, null);
}

pub fn addPackageWithIntent(
self: *GenerationManifest,
name: []const u8,
version: []const u8,
release: u32,
arch: []const u8,
store_path: []const u8,
content_hash: []const u8,
requested: bool,
constraint_expr: ?[]const u8,
) GenerationError!void {
const entry = PackageEntry{
.name = self.allocator.dupe(u8, name) catch return GenerationError.OutOfMemory,
Expand All @@ -266,6 +286,11 @@ pub const GenerationManifest = struct {
.arch = self.allocator.dupe(u8, arch) catch return GenerationError.OutOfMemory,
.store_path = self.allocator.dupe(u8, store_path) catch return GenerationError.OutOfMemory,
.content_hash = self.allocator.dupe(u8, content_hash) catch return GenerationError.OutOfMemory,
.requested = requested,
.constraint_expr = if (constraint_expr) |constraint|
self.allocator.dupe(u8, constraint) catch return GenerationError.OutOfMemory
else
null,
};

self.packages.append(self.allocator, entry) catch return GenerationError.OutOfMemory;
Expand Down Expand Up @@ -337,11 +362,19 @@ pub const GenerationManifest = struct {
try append(&buffer, a, pkg.name);
try append(&buffer, a, "\"");

const props = std.fmt.allocPrint(a, " version=\"{s}\" release={d} content-hash=\"{s}\"", .{
pkg.version, pkg.release, pkg.content_hash,
const props = std.fmt.allocPrint(a, " version=\"{s}\" release={d} content-hash=\"{s}\" requested=#{s}", .{
pkg.version,
pkg.release,
pkg.content_hash,
if (pkg.requested) "true" else "false",
}) catch return GenerationError.OutOfMemory;
defer a.free(props);
try append(&buffer, a, props);
if (pkg.constraint_expr) |constraint| {
try append(&buffer, a, " constraint=\"");
try append(&buffer, a, constraint);
try append(&buffer, a, "\"");
}
try append(&buffer, a, "\n");
}

Expand Down Expand Up @@ -434,6 +467,12 @@ pub const GenerationManifest = struct {
break :blk @intCast(v);
};
const content_hash = child.getStringProperty("content-hash") orelse return GenerationError.InvalidManifest;
const requested = if (child.getProperty("requested")) |value|
value.getBoolean() orelse return GenerationError.InvalidManifest
else
true;
const constraint_expr = child.getStringProperty("constraint");
if (!requested and constraint_expr != null) return GenerationError.InvalidManifest;

const store_dir_name = std.fmt.allocPrint(allocator, "{s}-{s}-{s}", .{ content_hash, name, version }) catch return GenerationError.OutOfMemory;
defer allocator.free(store_dir_name);
Expand All @@ -442,7 +481,7 @@ pub const GenerationManifest = struct {

const host_arch = @tagName(builtin.cpu.arch);

try manifest.addPackage(name, version, release, host_arch, store_path, content_hash);
try manifest.addPackageWithIntent(name, version, release, host_arch, store_path, content_hash, requested, constraint_expr);
}

return manifest;
Expand Down Expand Up @@ -514,6 +553,13 @@ pub fn parseProfilePackageSpecs(allocator: std.mem.Allocator, input: []const u8)
if (child.getStringProperty("content-hash")) |h| {
spec.content_hash = allocator.dupe(u8, h) catch return GenerationError.OutOfMemory;
}
if (child.getProperty("requested")) |value| {
spec.requested = value.getBoolean() orelse return GenerationError.InvalidManifest;
}
if (child.getStringProperty("constraint")) |constraint| {
if (!spec.requested) return GenerationError.InvalidManifest;
spec.constraint_expr = allocator.dupe(u8, constraint) catch return GenerationError.OutOfMemory;
}

specs.append(allocator, spec) catch return GenerationError.OutOfMemory;
}
Expand Down Expand Up @@ -1067,22 +1113,26 @@ test "GenerationManifest encode and parse roundtrip" {
manifest.notes = try allocator.dupe(u8, "test generation");
manifest.tool_version = try allocator.dupe(u8, "test-version");

try manifest.addPackage(
try manifest.addPackageWithIntent(
"nginx",
"1.24.0",
1,
"x86_64",
"/mere/store/abc123-nginx-1.24.0/",
"abc123def456789012345678901234567890123456789012345678901234",
true,
">=1.24 <2",
);

try manifest.addPackage(
try manifest.addPackageWithIntent(
"musl",
"1.2.4",
1,
"x86_64",
"/mere/store/def456-musl-1.2.4/",
"def456abc123789012345678901234567890123456789012345678901234",
false,
null,
);

const encoded = try manifest.encode(allocator);
Expand All @@ -1102,13 +1152,57 @@ test "GenerationManifest encode and parse roundtrip" {
try std.testing.expectEqualStrings("musl", parsed.packages.items[0].name);
try std.testing.expectEqualStrings("1.2.4", parsed.packages.items[0].version);
try std.testing.expectEqual(@as(u32, 1), parsed.packages.items[0].release);
try std.testing.expect(!parsed.packages.items[0].requested);
try std.testing.expect(parsed.packages.items[0].constraint_expr == null);
// Verify store path is derived from content-hash + name + version
try std.testing.expectEqualStrings(
"/mere/store/def456abc123789012345678901234567890123456789012345678901234-musl-1.2.4",
parsed.packages.items[0].store_path,
);
try std.testing.expectEqualStrings("nginx", parsed.packages.items[1].name);
try std.testing.expectEqualStrings("1.24.0", parsed.packages.items[1].version);
try std.testing.expect(parsed.packages.items[1].requested);
try std.testing.expectEqualStrings(">=1.24 <2", parsed.packages.items[1].constraint_expr.?);
}

test "legacy profile packages default to requested roots" {
const allocator = std.testing.allocator;
const input =
\\profile {
\\ schema-version 2
\\ created-at 1234567890
\\ package "demo" version="1.0.0" release=1 content-hash="aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
\\}
;

var parsed = try GenerationManifest.parse(allocator, "/mere/store", input);
defer parsed.deinit();

try std.testing.expectEqual(@as(usize, 1), parsed.packages.items.len);
try std.testing.expect(parsed.packages.items[0].requested);
try std.testing.expect(parsed.packages.items[0].constraint_expr == null);
}

test "profile package specs preserve intent" {
const allocator = std.testing.allocator;
const input =
\\profile {
\\ package "app" version="2.0.0" release=3 content-hash="bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" requested=#true constraint=">=2 <3"
\\ package "lib" version="1.0.0" release=1 content-hash="cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" requested=#false
\\}
;

const specs = try parseProfilePackageSpecs(allocator, input);
defer {
for (specs) |*spec| spec.deinit(allocator);
allocator.free(specs);
}

try std.testing.expectEqual(@as(usize, 2), specs.len);
try std.testing.expect(specs[0].requested);
try std.testing.expectEqualStrings(">=2 <3", specs[0].constraint_expr.?);
try std.testing.expect(!specs[1].requested);
try std.testing.expect(specs[1].constraint_expr == null);
}

test "RealizationData encode and decode roundtrip" {
Expand Down
Loading