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
5 changes: 3 additions & 2 deletions docs/design/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ Format changes (detailed below):

Capability removals:

- **Remove ambient state.** Working-directory profile discovery and TTL-based metadata staleness.
- **Remove ambient state.** Working-directory profile discovery still makes one command select a profile for reasons not visible in the command. Repository refresh is no longer part of this gap: repository-dependent operations use an explicit automatic refresh policy described below.
- **Stop treating every installed package as a resolution root.** `mere install` moves every installed package, because nothing records which ones were asked for. Narrowing install is the removal; the additive half — recording intent, and a `mere upgrade` that moves the world deliberately — lands first. Detailed below.

Closing the remaining conformance gaps:
Expand Down Expand Up @@ -102,7 +102,8 @@ Existing generations have no `requested`, so everything in them reads as request
### Interface and automation

- **Interface contract and self-description.** Done: the contract section, and `mere describe`. Shipped early so the document's shape gets contact with real consumers while `schema_version: 1` is still free to revise.
- **Remove ambient state.** Working-directory profile discovery and TTL-based metadata staleness both make one command mean different things on different runs for reasons not visible in the command. Where the convenience is worth keeping for interactive use, it should at least be resolvable to an explicit form.
- **Repository refresh policy.** Repository-dependent operations automatically refresh signed metadata when a configured interval has elapsed, so rolling-release users do not need a separate update ritual. `--sync` forces an attempt while retaining verified-cache fallback for ordinary operations; `--no-sync` suppresses metadata refresh without suppressing package archive downloads. `mere sync [repositories...]` is the strict explicit form: it refreshes all enabled or named repositories regardless of interval, preserves the previous verified cache on failure, and reports failure rather than treating the stale cache as a successful refresh. Search uses the same policy and may return partial results from usable repositories. The canonical setting is `sync-interval`; `sync-ttl` remains a read-only compatibility alias because expiry triggers an attempt rather than invalidating cached metadata. Refresh publication is serialized per repository, and future last-sync timestamps never make a cache indefinitely fresh.
- **Remove ambient profile selection.** Working-directory profile discovery makes one command mean different things on different runs for a reason not visible in the command. Where the convenience is worth keeping for interactive use, it should at least be resolvable to an explicit form.
- **Attestation.** A signed, append-only record of what was done — who, which plan, which generation before and after.
- **Plans as artifacts.** Separate deciding from doing: resolve an operation into a content-addressed plan, then apply the plan by its hash. Gives idempotent retry, a reviewable diff before anything mutates, and a natural identity for crash recovery.
- **Concurrency.** Dropping the per-root lock for store admission waits for concurrent-mutator tests. Conditional activation is not scheduled: without a concrete split between observing state and applying a previously derived decision, an expected-generation argument merely asks callers to repeat state Mere can already read. Reconsider it if a real split-phase consumer or observed lost update establishes the need.
Expand Down
15 changes: 14 additions & 1 deletion docs/design/specification-details.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ Mere is driven by people at a terminal, by scripts, by CI, and by automated call

Because a consumer may not remember its own past and may not be the only one running, an invocation SHOULD depend only on its arguments and on state it can observe. Behaviour that varies with ambient context — the working directory, elapsed time since some earlier action, what a previous invocation happened to leave behind — makes the same command mean different things on different runs for reasons not visible in the command. Where such conveniences exist for interactive use, they SHOULD be resolvable to an explicit form.

### Repository metadata refresh

Repository-dependent operations use an availability-first automatic refresh policy suitable for a rolling distribution:

- By default, a remote repository is refreshed when its last successful refresh is older than `sync-interval`. A cache inside the interval is reused quietly.
- `--sync` forces a refresh attempt regardless of the interval. On ordinary operations, a failed attempt MAY fall back to the prior verified cache and MUST warn with its age.
- `--no-sync` performs no remote metadata refresh. It MUST still verify cached metadata before use and MUST fail or skip that repository when no verified cache exists. It does not prohibit downloading a selected package archive.
- `--sync` and `--no-sync` are mutually exclusive invocation errors.
- `mere sync [repositories...]` is strict: it refreshes all enabled repositories, or only the named enabled repositories, regardless of interval. Local repositories are signature-verified. Any requested repository that cannot be refreshed or found makes the command fail, while the previous verified cache remains intact.
- Search follows the same policy. It MAY return partial results from repositories with usable verified metadata, MUST warn about unavailable repositories, and MUST fail when none can be searched.

`sync-interval` is expressed in seconds and MAY be configured globally or per repository. `sync-ttl` remains accepted as a read-only compatibility alias; Mere writes only `sync-interval`, because reaching the interval triggers an attempt rather than invalidating cached metadata. A last-refresh timestamp in the future MUST NOT suppress refresh. Repository selection, policy application, verification, and per-repository outcomes MUST be implemented by one core repository-set operation shared by package resolution, search, and standalone sync; command handlers only select policy and render those outcomes. Refresh check, download, verification, and publication MUST serialize per repository so unrelated repositories do not block one another.

### Self-description

`mere describe` writes a machine-readable description of the command surface to stdout, so a consumer can learn the interface at runtime instead of being told about it out of band. It reports the program name, the running version, global flags, and the command tree with each command's group, positional arguments, and flags — including each flag's type, short form, value name, default, and whether it is required.
Expand Down Expand Up @@ -72,7 +85,7 @@ Requirements:

This contract is a design commitment, and Mere does not yet meet all of it. Known gaps, recorded here so they are not mistaken for intent:

- **Ambient state.** `mere shell` resolves a profile from the working directory (§15.12), and repository sync is skipped on a TTL, so `mere install` may resolve against different metadata depending only on elapsed time. Neither is expressible as an explicit argument.
- **Ambient profile selection.** `mere shell` still resolves a profile from the working directory (§15.12), so the selected profile is not always visible in the invocation. Repository refresh is explicit: repository-dependent commands automatically refresh on a configured interval and expose `--sync` and `--no-sync`; `mere sync` is the strict standalone operation.
- **Concurrency granularity.** All mutating operations serialize on one exclusive lock per root. This is correct but coarse: unrelated work blocks, and there is no way to express "apply only if the profile is still at generation N".
- **Self-description.** The command surface is machine-readable via `mere describe`. The schemas of on-disk artifacts are not, and the gap is uneven. A generation's `profile.kdl` (§6) is KDL, so a consumer can read which packages a generation selected and at which content hashes without being told anything. A package manifest (§17) and a realization manifest are hand-rolled binary encodings behind `MEREMFST` and `MERERLZ1` headers, and neither can be read without being told its shape out of band.
- **No record of action.** Mere keeps no account of what it did, so a consumer cannot reconstruct its own prior operations, and an operator cannot audit another consumer's.
Expand Down
1 change: 1 addition & 0 deletions src/all_tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ test {
_ = @import("recipe.zig");
_ = @import("repodb.zig");
_ = @import("repo_history.zig");
_ = @import("repo_sync.zig");
_ = @import("repo_sources.zig");
_ = @import("repocache.zig");
_ = @import("repository.zig");
Expand Down
16 changes: 11 additions & 5 deletions src/cli/commands/install.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const mere = @import("mere");
const download = mere.download;
const types = @import("../types.zig");
const command = @import("../command.zig");
const sync_options = @import("../sync_options.zig");
const MereError = types.MereError;

/// Install command metadata
Expand Down Expand Up @@ -32,7 +33,12 @@ const install_meta = command.CommandMeta{
},
.{
.name = "sync",
.description = "Force repository sync even if cache is fresh",
.description = "Refresh repository metadata now, retaining verified-cache fallback",
.flag_type = .bool,
},
.{
.name = "no-sync",
.description = "Use verified cached repository metadata without refreshing it",
.flag_type = .bool,
},
.{
Expand All @@ -52,7 +58,7 @@ fn handleInstall(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!ty
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 sync_policy = sync_options.repositorySyncPolicy(args) catch return MereError.InvalidInput;
const dry_run = args.getBool("dry-run");

// Set initial diagnostic context - the subject is the package being installed
Expand All @@ -64,7 +70,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, dry_run) catch |err| {
const success_message = performInstallation(ctx, package_names, profile_name, verify_store, sync_policy, dry_run) catch |err| {
return try command.errorResult(ctx, err, null);
};

Expand All @@ -82,7 +88,7 @@ fn performInstallation(
package_names: []const []const u8,
profile_name: []const u8,
verify_store: bool,
force_sync: bool,
sync_policy: mere.repo_sync.SyncPolicy,
dry_run: bool,
) !?[]const u8 {
// Ensure configuration is loaded (no logging - errors propagate)
Expand All @@ -94,7 +100,7 @@ fn performInstallation(
const client = curl_client.client();

// Perform installation (no logging - errors propagate)
const outcome = try mere.install.installPackagesFromConfigWithPreview(ctx, package_names, client, false, verify_store, force_sync, profile_name, dry_run);
const outcome = try mere.install.installPackagesFromConfigWithPreview(ctx, package_names, client, false, verify_store, sync_policy, profile_name, dry_run);
return switch (outcome) {
.completed => null,
.store_only_system_activation_deferred => null,
Expand Down
16 changes: 15 additions & 1 deletion src/cli/commands/search.zig
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const std = @import("std");
const mere = @import("mere");
const types = @import("../types.zig");
const command = @import("../command.zig");
const sync_options = @import("../sync_options.zig");
const MereError = mere.errors.MereError;
const ui = mere.ui;
const emit = ui.emit;
Expand All @@ -18,6 +19,18 @@ const search_meta = command.CommandMeta{
.required = true,
},
},
.flags = &[_]types.Flag{
.{
.name = "sync",
.description = "Refresh repository metadata now, retaining verified-cache fallback",
.flag_type = .bool,
},
.{
.name = "no-sync",
.description = "Use verified cached repository metadata without refreshing it",
.flag_type = .bool,
},
},
};

fn handleSearch(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!types.CommandResult {
Expand All @@ -26,12 +39,13 @@ fn handleSearch(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!typ
}

const term = args.positional[0];
const sync_policy = sync_options.repositorySyncPolicy(args) catch return MereError.InvalidInput;

var curl_client = try mere.download.CurlTransferClient.init(ctx, command.user_agent);
defer mere.download.CurlTransferClient.cleanupFn(ctx, curl_client);
const client = curl_client.client();

var results = mere.search.searchPackages(ctx, term, client) catch |err| {
var results = mere.search.searchPackagesWithPolicy(ctx, term, client, sync_policy) catch |err| {
const user_message = mere.errors.getUserFriendlyMessage(err);
return types.CommandResult{
.success = false,
Expand Down
76 changes: 76 additions & 0 deletions src/cli/commands/sync.zig
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
const std = @import("std");
const mere = @import("mere");
const types = @import("../types.zig");
const command = @import("../command.zig");
const MereError = types.MereError;

const sync_meta = command.CommandMeta{
.group = "Package Management",
.order = 21,
.name = "sync",
.description = "Refresh and verify repository metadata",
.args = &[_]types.Arg{
.{
.name = "repository",
.description = "Enabled repository name(s); omit to refresh all",
.required = false,
},
},
};

fn handleSync(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!types.CommandResult {
const config = ctx.getConfig() catch |err| return try command.errorResult(ctx, err, null);
var caches = mere.repo_sources.createCaches(ctx, config) catch |err| return try command.errorResult(ctx, err, null);
defer {
for (caches.items) |cache| {
cache.deinit();
ctx.allocator.destroy(cache);
}
caches.deinit(ctx.allocator);
}

var loaded_keys = mere.sign.loadAllKeys(ctx) catch |err| return try command.errorResult(ctx, err, null);
defer {
for (loaded_keys.items) |*key| key.deinit(ctx.allocator);
loaded_keys.deinit(ctx.allocator);
}

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

var sync_result = mere.repo_sync.synchronize(ctx, caches.items, curl_client.client(), .{
.policy = .force,
.repositories = args.positional,
.strict = true,
}, loaded_keys.items) catch |err| return try command.errorResult(ctx, err, null);
defer sync_result.deinit(ctx.allocator);

for (sync_result.outcomes.items) |outcome| {
switch (outcome.status) {
.ready => {},
.failed => mere.ui.emit.logFmtSeverity(ctx, null, .err, "failed to refresh repository {s}: {s}", .{ outcome.name, @errorName(outcome.failure.?) }),
.not_found => mere.ui.emit.logFmtSeverity(ctx, null, .err, "enabled repository not found: {s}", .{outcome.name}),
}
}

if (sync_result.selected_count == 0 and args.positional.len == 0) {
return .{ .success = true, .message = "no enabled repositories configured" };
}
const failures = sync_result.failureCount();
if (failures > 0) {
return .{
.success = false,
.exit_code = 1,
.message = try std.fmt.allocPrint(ctx.allocator, "repository synchronization failed for {d} requested source(s)", .{failures}),
};
}
return .{ .success = true };
}

pub fn createCommand(allocator: std.mem.Allocator) !*command.Command {
const cmd = try allocator.create(command.Command);
cmd.* = command.Command.init(allocator, sync_meta, handleSync);
return cmd;
}
16 changes: 11 additions & 5 deletions src/cli/commands/uninstall.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const mere = @import("mere");
const download = mere.download;
const types = @import("../types.zig");
const command = @import("../command.zig");
const sync_options = @import("../sync_options.zig");
const MereError = types.MereError;

const uninstall_meta = command.CommandMeta{
Expand Down Expand Up @@ -41,7 +42,12 @@ const uninstall_meta = command.CommandMeta{
},
.{
.name = "sync",
.description = "Force repository sync even if cache is fresh",
.description = "Refresh repository metadata now, retaining verified-cache fallback",
.flag_type = .bool,
},
.{
.name = "no-sync",
.description = "Use verified cached repository metadata without refreshing it",
.flag_type = .bool,
},
},
Expand All @@ -55,7 +61,7 @@ fn handleUninstall(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!
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 sync_policy = sync_options.repositorySyncPolicy(args) catch return MereError.InvalidInput;
const cascade = args.getBool("cascade");
const dry_run = args.getBool("dry-run");

Expand All @@ -66,7 +72,7 @@ fn handleUninstall(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!
if (try command.acquireStoreLockOrResult(ctx)) |result| return result;
defer ctx.releaseStoreLock();

const result = performUninstall(ctx, package_names, profile_name, verify_store, force_sync, cascade, dry_run) catch |err| {
const result = performUninstall(ctx, package_names, profile_name, verify_store, sync_policy, cascade, dry_run) catch |err| {
return try command.errorResult(ctx, err, null);
};

Expand All @@ -89,7 +95,7 @@ fn performUninstall(
package_names: []const []const u8,
profile_name: []const u8,
verify_store: bool,
force_sync: bool,
sync_policy: mere.repo_sync.SyncPolicy,
cascade: bool,
dry_run: bool,
) !?[]const u8 {
Expand All @@ -104,7 +110,7 @@ fn performUninstall(
package_names,
client,
verify_store,
force_sync,
sync_policy,
profile_name,
cascade,
dry_run,
Expand Down
12 changes: 9 additions & 3 deletions src/cli/commands/upgrade.zig
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const mere = @import("mere");
const download = mere.download;
const types = @import("../types.zig");
const command = @import("../command.zig");
const sync_options = @import("../sync_options.zig");
const MereError = types.MereError;

const upgrade_meta = command.CommandMeta{
Expand Down Expand Up @@ -31,7 +32,12 @@ const upgrade_meta = command.CommandMeta{
},
.{
.name = "sync",
.description = "Force repository sync even if cache is fresh",
.description = "Refresh repository metadata now, retaining verified-cache fallback",
.flag_type = .bool,
},
.{
.name = "no-sync",
.description = "Use verified cached repository metadata without refreshing it",
.flag_type = .bool,
},
.{
Expand All @@ -46,7 +52,7 @@ fn handleUpgrade(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!ty
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 sync_policy = sync_options.repositorySyncPolicy(args) catch return MereError.InvalidInput;
const dry_run = args.getBool("dry-run");

ctx.withDiagnosticContext(mere.errors.DiagnosticContext.init().withSubject(
Expand All @@ -67,7 +73,7 @@ fn handleUpgrade(ctx: *mere.Context, args: *const types.ParsedArgs) MereError!ty
package_names,
curl_client.client(),
verify_store,
force_sync,
sync_policy,
profile_name,
dry_run,
) catch |err| return try command.errorResult(ctx, err, null);
Expand Down
Loading