Skip to content
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.8.0-beta.6] - 2026-07-17

### Changed

- Paid identity checkout guidance puts the payment deadline and action before
the indented QR code and leaves the copyable payment link last.
- Identity apply and renew retain checkmarked local key generation, CSR request,
and committed installation or renewal milestones. Non-interactive execution
emits the same successful milestones without progress animation.
- New welcome services are mounted at `/welcome`. Immediately after default
identity selection, onboarding prints a compact, consistently indented block
with the platform-specific pishoo reload command and the copyable `genmeta
curl` command for the new identity.

### Components

- `genmeta` v0.8.0-beta.6
- `genmeta-identity` v0.4.0-beta.6
- All other gmutils workspace member versions are unchanged from v0.8.0-beta.5.

## [0.8.0-beta.5] - 2026-07-17

### Changed
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ dhttp = "0.6.0-beta.4"
genmeta-curl = { path = "genmeta-curl", version = "0.7.0-beta.4" }
genmeta-discover = { path = "genmeta-discover", version = "0.4.0-beta.3" }
genmeta-doctor = { path = "genmeta-doctor", version = "0.4.0-beta.3" }
genmeta-identity = { path = "genmeta-identity", version = "0.4.0-beta.5", default-features = false }
genmeta-identity = { path = "genmeta-identity", version = "0.4.0-beta.6", default-features = false }
genmeta-nat = { path = "genmeta-nat", version = "0.5.0-beta.3" }
genmeta-nslookup = { path = "genmeta-nslookup", version = "0.5.0-beta.3" }
genmeta-ssh = { path = "genmeta-ssh", version = "0.7.0-beta.4" }
Expand Down
2 changes: 1 addition & 1 deletion genmeta-identity/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
[package]
name = "genmeta-identity"
description = "managing identities for genmeta network"
version = "0.4.0-beta.5"
version = "0.4.0-beta.6"
edition.workspace = true
license.workspace = true
repository.workspace = true
Expand Down
64 changes: 42 additions & 22 deletions genmeta-identity/src/checkout.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,18 @@ fn render_terminal_qr(url: &str) -> Result<String, QrError> {
}

pub(crate) fn payment_instruction_block(url: &str) -> Result<String, QrError> {
let mut block = render_terminal_qr(url)?.trim_end().to_string();
block.push_str("\n\n");
let qr = render_terminal_qr(url)?;
let mut block = String::from(
"[!] Please complete your payment within 15 minutes.\n Open the link, or scan the QR code below\n\n",
);

for line in qr.trim_end().lines() {
block.push_str(" ");
block.push_str(line);
block.push('\n');
}

block.push_str("[!] Please complete your payment within 15 minutes.\n");
block.push_str(" Open the link below, or scan the QR code above\n\n");
block.push_str(" Link: ");
block.push_str("\n Link: ");
block.push_str(url);
Ok(block)
}
Expand Down Expand Up @@ -185,29 +191,43 @@ mod tests {
}

#[test]
fn payment_block_always_keeps_the_qr_above_the_link() {
let block = payment_instruction_block("https://pay.example.test/checkout/ckt_123").unwrap();

assert!(block.contains(['▀', '▄', '█']));
assert!(block.contains(
"Open the link below, or scan the QR code above\n\n Link: https://pay.example.test/checkout/ckt_123"
));
}

#[test]
fn payment_block_places_compact_qr_before_the_link() {
let rendered = payment_instruction_block("https://pay.example.test/checkout").unwrap();
let qr = rendered.find('█').unwrap();
fn payment_block_keeps_instruction_qr_and_link_in_order() {
let rendered =
payment_instruction_block("https://pay.example.test/checkout/ckt_123").unwrap();
let notice = rendered
.find("[!] Please complete your payment within 15 minutes.")
.unwrap();
let instruction = rendered
.find("Open the link, or scan the QR code below")
.unwrap();
let qr = rendered.find('█').unwrap();
let link = rendered
.find("Link: https://pay.example.test/checkout")
.find("Link: https://pay.example.test/checkout/ckt_123")
.unwrap();

assert!(qr < notice && notice < link, "{rendered}");
assert!(rendered.contains("Open the link below, or scan the QR code above"));
assert!(!rendered.contains("Open link:"));
assert!(
notice < instruction && instruction < qr && qr < link,
"{rendered}"
);
assert!(!rendered.contains("Open the link below, or scan the QR code above"));
}

#[test]
fn payment_block_indents_every_qr_row_by_four_spaces() {
let rendered = payment_instruction_block("https://pay.example.test/checkout").unwrap();
let qr = rendered
.split_once("Open the link, or scan the QR code below\n\n")
.unwrap()
.1
.split_once("\n\n Link: https://pay.example.test/checkout")
.unwrap()
.0;

assert!(!qr.is_empty());
assert!(
qr.lines().all(|line| line.starts_with(" ")),
"{rendered}"
);
}

#[test]
Expand Down
24 changes: 15 additions & 9 deletions genmeta-identity/src/cli/flow/apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use crate::{
};

const APPLY_OPENING: &str = "Applying identity, generating ECC key pair locally, then requesting and deploying certificate.";
const INSTALLED: &str = "Identity successfully installed on this device.";
const INSTALLED: &str = "Identity successfully installed on this device.";
const NEW_NAME_FREE: &str = "This new name is yours now.";

fn interactive_name_unavailable_message() -> &'static str {
Expand Down Expand Up @@ -505,23 +505,26 @@ async fn install_and_finish(
IdentityKind::Primary => super::local::IdentityUsage::BothClientAndServer,
IdentityKind::Secondary => super::local::IdentityUsage::ClientOnly,
};
match super::welcome::maybe_create_welcome_service(
let welcome = super::welcome::maybe_create_welcome_service(
dhttp_home,
resolved.target.dhttp_name(),
usage,
)
.await
{
Ok(Some(_)) => super::transcript::print_line(
super::welcome::format_welcome_service_created(resolved.target.short_name()),
.await;

super::epilogue::run_lifecycle_epilogue(dhttp_home, resolved.target.dhttp_name(), interactive)
.await?;

match welcome {
Ok(Some(_)) => super::transcript::print_block(
&super::welcome::format_welcome_service_created(resolved.target.short_name()),
),
Ok(None) => {}
Err(error) => super::transcript::print_warning(&format!(
"The identity was installed, but the sample welcome page could not be created: {error}"
)),
}
super::epilogue::run_lifecycle_epilogue(dhttp_home, resolved.target.dhttp_name(), interactive)
.await
Ok(())
}

pub(crate) async fn run(
Expand Down Expand Up @@ -635,7 +638,10 @@ mod tests {
"Applying identity, generating ECC key pair locally, then requesting and deploying certificate."
);
assert_eq!(NEW_NAME_FREE, "This new name is yours now.");
assert_eq!(INSTALLED, "Identity successfully installed on this device.");
assert_eq!(
INSTALLED,
"✔ Identity successfully installed on this device."
);
}

#[test]
Expand Down
116 changes: 63 additions & 53 deletions genmeta-identity/src/cli/flow/progress.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use tracing_indicatif::span_ext::IndicatifSpanExt;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CompletionPolicy {
Clear,
RetainOnTty,
Retain,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
Expand All @@ -28,42 +28,56 @@ impl ProgressCopy {
}
}

const fn retain_on_tty(running: &'static str, success: &'static str) -> Self {
const fn retain(running: &'static str, success: &'static str) -> Self {
Self {
running,
success,
completion: CompletionPolicy::RetainOnTty,
completion: CompletionPolicy::Retain,
}
}

fn completion_message(self, is_terminal: bool) -> Option<&'static str> {
if !is_terminal {
return None;
}
fn completion_message(self, _is_terminal: bool) -> Option<&'static str> {
match self.completion {
CompletionPolicy::Clear => None,
CompletionPolicy::RetainOnTty => Some(self.success),
CompletionPolicy::Retain => Some(self.success),
}
}
}

const KEY_GENERATED: &str = "✔ Generate secp384r1 ECC key pair locally.";
const CERTIFICATE_REQUESTED: &str = "✔ Generate CSR locally and request certificate.";

pub(crate) const CHECK_NAME: ProgressCopy =
ProgressCopy::clear("Checking the validity of this name...");
pub(crate) const SEND_CODE: ProgressCopy = ProgressCopy::clear("Sending verification code...");
pub(crate) const VERIFY_EMAIL: ProgressCopy = ProgressCopy::clear("Verifying with email...");
pub(crate) const GENERATE_KEY: ProgressCopy = ProgressCopy::retain_on_tty(
pub(crate) const GENERATE_KEY: ProgressCopy = ProgressCopy::retain(
"Generating secp384r1 ECC key pair locally...",
"Generated secp384r1 ECC key pair locally.",
KEY_GENERATED,
);
pub(crate) const REQUEST_CERT: ProgressCopy = ProgressCopy::retain_on_tty(
pub(crate) const REQUEST_CERT: ProgressCopy = ProgressCopy::retain(
"Generating CSR locally and requesting certificate...",
"Generated CSR locally and requested certificate.",
CERTIFICATE_REQUESTED,
);
pub(crate) const WAIT_FOR_PAYMENT: ProgressCopy =
ProgressCopy::clear("Waiting for payment completion...");
pub(crate) const RENEW_IDENTITY: ProgressCopy = ProgressCopy::clear("Renewing identity...");
pub(crate) const RENEW_IDENTITY: ProgressCopy =
ProgressCopy::retain("Renewing identity...", CERTIFICATE_REQUESTED);
pub(crate) const SAVE_DEFAULT: ProgressCopy = ProgressCopy::clear("Saving default identity...");

fn finish_success(span: &tracing::Span, copy: ProgressCopy) {
let is_terminal = io::stderr().is_terminal();
let Some(success) = copy.completion_message(is_terminal) else {
return;
};

if is_terminal {
span.pb_set_finish_message(success);
} else {
super::transcript::print_line(success);
}
}

pub(crate) async fn run<T, E>(
copy: ProgressCopy,
future: impl Future<Output = Result<T, E>>,
Expand All @@ -72,10 +86,8 @@ pub(crate) async fn run<T, E>(
span.pb_set_message(copy.running);
span.pb_start();
let result = future.await;
if result.is_ok()
&& let Some(success) = copy.completion_message(io::stderr().is_terminal())
{
span.pb_set_finish_message(success);
if result.is_ok() {
finish_success(&span, copy);
}
drop(span);
result
Expand All @@ -89,10 +101,8 @@ pub(crate) fn run_sync<T, E>(
span.pb_set_message(copy.running);
span.pb_start();
let result = operation();
if result.is_ok()
&& let Some(success) = copy.completion_message(io::stderr().is_terminal())
{
span.pb_set_finish_message(success);
if result.is_ok() {
finish_success(&span, copy);
}
drop(span);
result
Expand All @@ -115,45 +125,45 @@ mod tests {
};

#[test]
fn only_key_and_certificate_request_retain_success_on_tty() {
assert_eq!(
GENERATE_KEY.completion_message(true),
Some("Generated secp384r1 ECC key pair locally.")
);
fn key_and_certificate_completions_are_stable_in_every_output_mode() {
for is_terminal in [true, false] {
assert_eq!(
GENERATE_KEY.completion_message(is_terminal),
Some("✔ Generate secp384r1 ECC key pair locally.")
);
assert_eq!(
REQUEST_CERT.completion_message(is_terminal),
Some("✔ Generate CSR locally and request certificate.")
);
assert_eq!(
RENEW_IDENTITY.completion_message(is_terminal),
Some("✔ Generate CSR locally and request certificate.")
);
}

assert_eq!(
REQUEST_CERT.running,
"Generating CSR locally and requesting certificate..."
);
assert_eq!(
REQUEST_CERT.completion_message(true),
Some("Generated CSR locally and requested certificate.")
);

for copy in [
CHECK_NAME,
SEND_CODE,
VERIFY_EMAIL,
WAIT_FOR_PAYMENT,
RENEW_IDENTITY,
SAVE_DEFAULT,
] {
assert_eq!(copy.completion_message(true), None, "copy: {copy:?}");
}
assert_eq!(RENEW_IDENTITY.running, "Renewing identity...");
}

#[test]
fn non_tty_progress_has_no_completion_copy() {
for copy in [
CHECK_NAME,
SEND_CODE,
VERIFY_EMAIL,
GENERATE_KEY,
REQUEST_CERT,
WAIT_FOR_PAYMENT,
RENEW_IDENTITY,
SAVE_DEFAULT,
] {
assert_eq!(copy.completion_message(false), None, "copy: {copy:?}");
fn transient_progress_has_no_completion_copy() {
for is_terminal in [true, false] {
for copy in [
CHECK_NAME,
SEND_CODE,
VERIFY_EMAIL,
WAIT_FOR_PAYMENT,
SAVE_DEFAULT,
] {
assert_eq!(
copy.completion_message(is_terminal),
None,
"copy: {copy:?}, terminal: {is_terminal}"
);
}
}
}

Expand Down
Loading