use std::io::Write;
use anyhow::{Context, Result};
use clap::{Arg, ArgAction, ArgMatches, Command};
use serde_json::json;
use termcolor::{Color, ColorChoice, ColorSpec, StandardStream, WriteColor};
use crate::{
CliCommand,
core::command::command,
managed::{
client::{Missing, post_json_public, print_dryrun},
types::{ManagedInstance, dash},
},
};
#[derive(Debug)]
pub(super) struct ClaimCommand;
impl ClaimCommand {
pub(super) fn new() -> Self {
Self
}
}
impl CliCommand for ClaimCommand {
fn command(&self) -> Command {
command(
"claim",
"CONSUME a one-time claim link to take ownership of an instance (customer-side, no login)",
)
.long_about(
"Take ownership of a managed instance using the one-time claim link you were given.\n\n\
THIS IS THE CUSTOMER-SIDE COMMAND, AND IT NEEDS NO FORKLAUNCH ACCOUNT.\n\
Do not confuse it with `instance claim-link`:\n\
\x20 `instance claim-link` REVEALS the link. Operator-side, requires login. Run by\n\
\x20 the organization that launched the instance.\n\
\x20 `instance claim` CONSUMES the link. Customer-side, requires no login. Run\n\
\x20 by the person taking ownership.\n\n\
─────────────────────────────────────────────────────────────────────────────\n\
READ THIS BEFORE YOU RUN IT — YOUR BACKUP PASSPHRASE CANNOT BE RECOVERED\n\
─────────────────────────────────────────────────────────────────────────────\n\
--backup-public-key is an age recipient (a public key) that you derive from a\n\
passphrase you choose. Your instance's backups are encrypted to it.\n\n\
The platform stores ONLY THE PUBLIC HALF. It cannot decrypt your backups, and\n\
neither can ForkLaunch support, now or ever. That is the point of the design —\n\
but it means the consequence is absolute:\n\n\
\x20 IF YOU LOSE THE PASSPHRASE, YOUR BACKUPS ARE UNREADABLE. PERMANENTLY.\n\
\x20 NOBODY CAN RECOVER THEM FOR YOU. THE DATA IS GONE.\n\n\
Store the passphrase somewhere durable — a password manager, not a terminal\n\
scrollback — BEFORE running this command. Claiming can only be done once.\n\n\
The claim also fails as a single indistinguishable error whether the token is\n\
wrong, expired, already used, or the instance id is unknown. That is deliberate\n\
on the platform's side, so that the error cannot be used to probe which links\n\
are still live.",
)
.arg(
Arg::new("id")
.long("id")
.required(false)
.help(
"Ignored. The token alone identifies the instance; accepted so older \
instructions that pass --id keep working",
),
)
.arg(
Arg::new("token")
.long("token")
.required(true)
.help("The one-time claim token (from the claim link you were given)"),
)
.arg(
Arg::new("backup_public_key")
.long("backup-public-key")
.required(true)
.help(
"Your age recipient (public key) for backup encryption — derived from a \
passphrase only you hold; a lost passphrase means unrecoverable backups",
),
)
.arg(
Arg::new("dryrun")
.long("dryrun")
.help("Print the request that would be sent without sending it — does NOT consume the claim")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("json")
.long("json")
.help("Output raw JSON instead of formatted terminal output")
.action(ArgAction::SetTrue),
)
}
fn handler(&self, matches: &ArgMatches) -> Result<()> {
let mut stdout = StandardStream::stdout(ColorChoice::Always);
let token = matches
.get_one::<String>("token")
.context("--token is required")?;
let backup_public_key = matches
.get_one::<String>("backup_public_key")
.context("--backup-public-key is required")?;
let path = "/claim".to_string();
let body = json!({ "token": token, "backupPublicKey": backup_public_key });
if matches.get_flag("dryrun") {
println!(
"[DRYRUN] this would CONSUME the one-time claim for the instance this token names."
);
let redacted = json!({
"token": "<redacted — the real token would be sent here>",
"backupPublicKey": backup_public_key
});
return print_dryrun("POST", &path, Some(&redacted));
}
let instance: ManagedInstance = post_json_public(
&path,
body,
Missing::Custom(
"this claim link is not valid — it may be mistyped, expired, or already used, \
or the instance id may not exist. The platform deliberately does not say which."
.to_string(),
),
)?;
if matches.get_flag("json") {
println!("{}", serde_json::to_string_pretty(&instance)?);
return Ok(());
}
writeln!(stdout)?;
log_ok!(stdout, "Instance claimed — it is now yours.");
writeln!(stdout)?;
writeln!(stdout, " Host {}", dash(&instance.host))?;
writeln!(stdout, " Template {}", dash(&instance.template_slug))?;
writeln!(stdout, " Region {}", dash(&instance.region))?;
writeln!(stdout, " State {}", dash(&instance.state))?;
if let Some(version) = instance.current_version_semver.as_deref() {
writeln!(stdout, " Version {}", version)?;
}
writeln!(stdout)?;
stdout.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)).set_bold(true))?;
writeln!(
stdout,
" Keep your backup passphrase safe. The platform holds only the public half"
)?;
writeln!(
stdout,
" of your key and cannot decrypt your backups. If you lose the passphrase,"
)?;
writeln!(stdout, " your backups cannot be recovered by anyone.")?;
stdout.reset()?;
writeln!(stdout)?;
Ok(())
}
}