mod agents;
pub(crate) use agents::AgentCommand;
mod profile;
mod source;
pub(crate) use source::SourceConfigurationArguments;
mod users;
pub(crate) use profile::{
ProfileCommand, ProfileInvocation, TipRecipientCommand, TipRecipientInvocation,
};
pub(crate) use users::{
CreateUserArguments, InitialCredentials, LoginCredentialArguments, UserCommand,
UserCredentialCommand,
};
use std::path::PathBuf;
use clap::{Args, Parser, Subcommand, ValueEnum};
use maincopy_shared::publication::PreviewDigest;
use time::{OffsetDateTime, UtcOffset, format_description::well_known::Rfc3339};
use uuid::Uuid;
#[derive(Debug, Parser)]
#[command(
name = "maincopy",
version,
about = "Operate a running Maincopy server."
)]
pub(crate) struct Arguments {
#[arg(long, global = true)]
pub(crate) json: bool,
#[arg(
long,
global = true,
value_name = "HTTPS_ORIGIN",
default_value = "https://admin.localhost"
)]
pub(crate) admin_origin: Box<str>,
#[arg(long, global = true, value_name = "PEM_PATH")]
pub(crate) admin_ca_file: Option<PathBuf>,
#[arg(long, global = true, value_enum, default_value_t = AuthenticationContext::Human)]
pub(crate) auth_context: AuthenticationContext,
#[command(subcommand)]
pub(crate) command: Command,
}
#[derive(Debug, Subcommand)]
pub(crate) enum Command {
Agents {
#[command(subcommand)]
command: AgentCommand,
},
Users {
#[command(subcommand)]
command: UserCommand,
},
Profile {
#[command(subcommand)]
command: ProfileCommand,
},
TipRecipient {
#[command(subcommand)]
command: TipRecipientCommand,
},
Login {
#[arg(long, value_name = "USERNAME")]
username: Box<str>,
},
LoginNostr,
Logout,
AgentKey {
#[command(subcommand)]
command: AgentKeyCommand,
},
Capabilities,
Posts,
Releases {
#[command(subcommand)]
command: ReleaseCommand,
},
Source {
#[command(subcommand)]
command: SourceCommand,
},
Preview {
#[arg(value_name = "POST_ID")]
post_id: Uuid,
#[arg(long, value_name = "PATH")]
output: PathBuf,
#[arg(long, value_name = "DIGEST")]
revision: Option<String>,
#[arg(long, value_name = "DIGEST")]
content_digest: Option<String>,
},
PublishNow {
#[arg(value_name = "POST_ID")]
post_id: Uuid,
#[arg(long, value_name = "DIGEST")]
preview_digest: PreviewDigest,
#[arg(long, value_name = "DIGEST")]
revision: Option<String>,
#[arg(long, value_name = "UUID")]
idempotency_key: Option<Uuid>,
},
Schedule {
#[arg(value_name = "POST_ID")]
post_id: Uuid,
#[arg(long, value_name = "DIGEST")]
preview_digest: PreviewDigest,
#[arg(long, value_name = "UTC_RFC3339", value_parser = parse_utc_rfc3339)]
at: OffsetDateTime,
#[arg(long, value_name = "DIGEST")]
revision: Option<String>,
#[arg(long, value_name = "UUID")]
idempotency_key: Option<Uuid>,
},
}
#[derive(Debug, Subcommand)]
pub(crate) enum ReleaseCommand {
List {
#[arg(long)]
cursor: Option<Uuid>,
},
Inspect { publication_id: Uuid },
Operation { operation_id: Uuid },
Reschedule {
#[command(flatten)]
target: ReleaseTarget,
#[arg(long, value_name = "UTC_RFC3339", value_parser = parse_utc_rfc3339)]
at: OffsetDateTime,
},
Cancel(ReleaseTarget),
Retry(ReleaseTarget),
}
#[derive(Debug, Args)]
pub(crate) struct ReleaseTarget {
pub(crate) publication_id: Uuid,
#[arg(long, value_parser = clap::value_parser!(u64).range(1..i64::MAX as u64))]
pub(crate) expected_version: u64,
#[arg(long, value_name = "UUID")]
pub(crate) idempotency_key: Option<Uuid>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub(crate) enum AuthenticationContext {
Human,
Agent,
}
#[derive(Debug, Subcommand)]
pub(crate) enum AgentKeyCommand {
Inspect,
Set,
Remove,
}
#[derive(Debug, Subcommand)]
pub(crate) enum SourceCommand {
DeployKey,
Configure(SourceConfigurationArguments),
Status,
Sync(SourceSyncArguments),
}
#[derive(Debug, Args)]
#[group(skip)]
pub(crate) struct SourceSyncArguments {
#[arg(
long,
required_unless_present = "asynchronous",
conflicts_with = "asynchronous"
)]
wait: bool,
#[arg(long = "async", required_unless_present = "wait")]
asynchronous: bool,
#[arg(long, value_name = "UUID")]
idempotency_key: Option<Uuid>,
}
impl SourceSyncArguments {
pub(crate) fn into_invocation(self) -> SourceSyncInvocation {
let disposition = match (self.wait, self.asynchronous) {
(true, false) => SourceSyncDisposition::Wait,
(false, true) => SourceSyncDisposition::Async,
_ => unreachable!("clap enforces exactly one source-sync disposition"),
};
SourceSyncInvocation {
disposition,
idempotency_key: self.idempotency_key,
}
}
}
pub(crate) struct SourceSyncInvocation {
pub(crate) disposition: SourceSyncDisposition,
pub(crate) idempotency_key: Option<Uuid>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum SourceSyncDisposition {
Wait,
Async,
}
fn parse_utc_rfc3339(value: &str) -> Result<OffsetDateTime, String> {
let timestamp = OffsetDateTime::parse(value, &Rfc3339)
.map_err(|_| "must be a valid RFC3339 timestamp".to_owned())?;
if timestamp.offset() != UtcOffset::UTC {
return Err("must use the UTC offset (Z or +00:00)".to_owned());
}
Ok(timestamp)
}
#[cfg(test)]
mod tests {
use super::*;
const PREVIEW_DIGEST: &str =
"preview-b3-v1-4444444444444444444444444444444444444444444444444444444444444444";
#[test]
fn release_controls_require_a_version_and_accept_a_repeatable_operation() {
let id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
for action in ["cancel", "retry", "reschedule"] {
let mut args = vec![
"maincopy",
"releases",
action,
id,
"--expected-version",
"7",
"--idempotency-key",
id,
];
if action == "reschedule" {
args.extend(["--at", "2026-09-06T12:00:00Z"]);
}
let command = Arguments::try_parse_from(&args).unwrap().command;
let Command::Releases { command } = command else {
panic!("release command expected");
};
let target = match command {
ReleaseCommand::Cancel(target)
| ReleaseCommand::Retry(target)
| ReleaseCommand::Reschedule { target, .. } => target,
_ => panic!("release mutation expected"),
};
assert_eq!(target.expected_version, 7);
assert_eq!(target.idempotency_key, Some(target.publication_id));
assert!(Arguments::try_parse_from(["maincopy", "releases", action, id]).is_err());
}
for version in ["0", "-1", "9223372036854775807"] {
assert!(
Arguments::try_parse_from([
"maincopy",
"releases",
"cancel",
id,
"--expected-version",
version
])
.is_err()
);
}
assert!(
Arguments::try_parse_from([
"maincopy",
"releases",
"reschedule",
id,
"--expected-version",
"1",
"--at",
"2026-09-06T12:00:00+01:00"
])
.is_err()
);
}
#[test]
fn release_inspection_commands_parse_explicit_identities() {
let id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
assert!(matches!(
Arguments::try_parse_from(["maincopy", "releases", "list"])
.unwrap()
.command,
Command::Releases {
command: ReleaseCommand::List { cursor: None }
}
));
assert!(matches!(
Arguments::try_parse_from(["maincopy", "releases", "list", "--cursor", id])
.unwrap()
.command,
Command::Releases {
command: ReleaseCommand::List { cursor: Some(_) }
}
));
assert!(matches!(
Arguments::try_parse_from(["maincopy", "releases", "inspect", id])
.unwrap()
.command,
Command::Releases {
command: ReleaseCommand::Inspect { .. }
}
));
assert!(matches!(
Arguments::try_parse_from(["maincopy", "releases", "operation", id])
.unwrap()
.command,
Command::Releases {
command: ReleaseCommand::Operation { .. }
}
));
assert!(Arguments::try_parse_from(["maincopy", "releases", "inspect", "bad"]).is_err());
}
#[test]
fn client_arguments_select_capabilities_without_a_transport_flag() {
let arguments = Arguments::try_parse_from(["maincopy", "capabilities"]).unwrap();
assert!(!arguments.json);
assert!(arguments.admin_ca_file.is_none());
assert!(matches!(arguments.command, Command::Capabilities));
}
#[test]
fn removed_socket_option_is_rejected() {
assert!(
Arguments::try_parse_from(["maincopy", "--socket", "admin.sock", "capabilities",])
.is_err()
);
}
#[test]
fn global_options_are_accepted_after_the_command() {
let arguments = Arguments::try_parse_from([
"maincopy",
"capabilities",
"--json",
"--admin-origin",
"https://admin.example.test",
"--admin-ca-file",
"development-ca.pem",
"--auth-context",
"agent",
])
.unwrap();
assert!(arguments.json);
assert_eq!(
arguments.admin_origin.as_ref(),
"https://admin.example.test"
);
assert_eq!(
arguments.admin_ca_file.as_deref(),
Some(std::path::Path::new("development-ca.pem"))
);
assert_eq!(arguments.auth_context, AuthenticationContext::Agent);
}
#[test]
fn login_and_agent_key_commands_never_accept_secrets_on_argv() {
let login =
Arguments::try_parse_from(["maincopy", "login", "--username", "publisher"]).unwrap();
assert!(matches!(
login.command,
Command::Login { ref username } if username.as_ref() == "publisher"
));
assert!(
Arguments::try_parse_from([
"maincopy",
"login",
"--username",
"publisher",
"--password",
"secret"
])
.is_err()
);
assert!(
Arguments::try_parse_from(["maincopy", "agent-key", "set", "--private-key", "secret"])
.is_err()
);
let inspect = Arguments::try_parse_from(["maincopy", "agent-key", "inspect"]).unwrap();
assert!(matches!(
inspect.command,
Command::AgentKey {
command: AgentKeyCommand::Inspect
}
));
}
#[test]
fn posts_selects_the_loaded_post_listing_command() {
let arguments = Arguments::try_parse_from(["maincopy", "posts"]).unwrap();
assert!(matches!(arguments.command, Command::Posts));
}
#[test]
fn source_status_selects_the_read_only_source_command() {
let arguments = Arguments::try_parse_from(["maincopy", "source", "status"]).unwrap();
assert!(matches!(
arguments.command,
Command::Source {
command: SourceCommand::Status
}
));
}
#[test]
fn source_sync_requires_exactly_one_wait_disposition() {
let key = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
let wait = Arguments::try_parse_from([
"maincopy",
"source",
"sync",
"--wait",
"--idempotency-key",
key,
])
.unwrap();
let Command::Source {
command: SourceCommand::Sync(wait),
} = wait.command
else {
panic!("source sync must select the durable source command");
};
let wait = wait.into_invocation();
assert_eq!(wait.disposition, SourceSyncDisposition::Wait);
assert_eq!(wait.idempotency_key, Some(Uuid::parse_str(key).unwrap()));
let asynchronous =
Arguments::try_parse_from(["maincopy", "source", "sync", "--async"]).unwrap();
let Command::Source {
command: SourceCommand::Sync(asynchronous),
} = asynchronous.command
else {
panic!("source sync must select the durable source command");
};
let asynchronous = asynchronous.into_invocation();
assert_eq!(asynchronous.disposition, SourceSyncDisposition::Async);
assert!(asynchronous.idempotency_key.is_none());
assert!(Arguments::try_parse_from(["maincopy", "source", "sync"]).is_err());
assert!(
Arguments::try_parse_from(["maincopy", "source", "sync", "--wait", "--async"]).is_err()
);
}
#[test]
fn preview_parses_required_output_and_optional_exact_selectors() {
let arguments = Arguments::try_parse_from([
"maincopy",
"preview",
"11111111-1111-4111-8111-111111111111",
"--output",
"ready.html",
"--revision",
"post-b3-v1-1111111111111111111111111111111111111111111111111111111111111111",
"--content-digest",
"content-b3-v1-3333333333333333333333333333333333333333333333333333333333333333",
])
.unwrap();
let Command::Preview {
post_id,
output,
revision,
content_digest,
} = arguments.command
else {
panic!("preview must select the private preview command");
};
assert_eq!(
post_id,
Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap()
);
assert_eq!(output, PathBuf::from("ready.html"));
assert_eq!(
revision.as_deref(),
Some("post-b3-v1-1111111111111111111111111111111111111111111111111111111111111111")
);
assert_eq!(
content_digest.as_deref(),
Some("content-b3-v1-3333333333333333333333333333333333333333333333333333333333333333")
);
}
#[test]
fn preview_requires_an_explicit_output_path() {
let error = Arguments::try_parse_from([
"maincopy",
"preview",
"11111111-1111-4111-8111-111111111111",
])
.unwrap_err();
assert_eq!(
error.kind(),
clap::error::ErrorKind::MissingRequiredArgument
);
}
#[test]
fn publication_commands_require_a_typed_reviewed_preview() {
for command in ["publish-now", "schedule"] {
let mut arguments = vec!["maincopy", command, "11111111-1111-4111-8111-111111111111"];
if command == "schedule" {
arguments.extend(["--at", "2026-09-01T12:30:00Z"]);
}
let error = Arguments::try_parse_from(arguments).unwrap_err();
assert_eq!(
error.kind(),
clap::error::ErrorKind::MissingRequiredArgument
);
}
}
#[test]
fn publish_now_parses_optional_revision_and_retry_identity() {
let arguments = Arguments::try_parse_from([
"maincopy",
"publish-now",
"11111111-1111-4111-8111-111111111111",
"--preview-digest",
PREVIEW_DIGEST,
"--revision",
"post-b3-v1-1111111111111111111111111111111111111111111111111111111111111111",
"--idempotency-key",
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
])
.unwrap();
let Command::PublishNow {
post_id,
preview_digest,
revision,
idempotency_key,
} = arguments.command
else {
panic!("publish-now must select the publication command");
};
assert_eq!(
post_id,
Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap()
);
assert_eq!(
preview_digest,
PreviewDigest::parse(PREVIEW_DIGEST).unwrap()
);
assert_eq!(
revision.as_deref(),
Some("post-b3-v1-1111111111111111111111111111111111111111111111111111111111111111")
);
assert_eq!(
idempotency_key,
Some(Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").unwrap())
);
}
#[test]
fn publish_now_generates_the_retry_identity_only_at_execution() {
let arguments = Arguments::try_parse_from([
"maincopy",
"publish-now",
"11111111-1111-4111-8111-111111111111",
"--preview-digest",
PREVIEW_DIGEST,
])
.unwrap();
assert!(matches!(
arguments.command,
Command::PublishNow {
revision: None,
idempotency_key: None,
..
}
));
}
#[test]
fn schedule_parses_an_exact_utc_time_revision_and_retry_identity() {
let arguments = Arguments::try_parse_from([
"maincopy",
"schedule",
"11111111-1111-4111-8111-111111111111",
"--preview-digest",
PREVIEW_DIGEST,
"--at",
"2026-09-01T12:30:00Z",
"--revision",
"post-b3-v1-1111111111111111111111111111111111111111111111111111111111111111",
"--idempotency-key",
"bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
])
.unwrap();
let Command::Schedule {
post_id,
preview_digest,
at,
revision,
idempotency_key,
} = arguments.command
else {
panic!("schedule must select the scheduled approval command");
};
assert_eq!(
post_id,
Uuid::parse_str("11111111-1111-4111-8111-111111111111").unwrap()
);
assert_eq!(at.offset(), UtcOffset::UTC);
assert_eq!(
preview_digest,
PreviewDigest::parse(PREVIEW_DIGEST).unwrap()
);
assert_eq!(
at,
OffsetDateTime::parse("2026-09-01T12:30:00Z", &Rfc3339).unwrap()
);
assert_eq!(
revision.as_deref(),
Some("post-b3-v1-1111111111111111111111111111111111111111111111111111111111111111")
);
assert_eq!(
idempotency_key,
Some(Uuid::parse_str("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb").unwrap())
);
}
#[test]
fn schedule_rejects_non_utc_and_malformed_times() {
for at in ["2026-09-01T14:30:00+02:00", "tomorrow"] {
let error = Arguments::try_parse_from([
"maincopy",
"schedule",
"11111111-1111-4111-8111-111111111111",
"--preview-digest",
PREVIEW_DIGEST,
"--at",
at,
])
.unwrap_err();
assert_eq!(error.kind(), clap::error::ErrorKind::ValueValidation);
}
}
}