auths_cli/commands/device/pair/
mod.rs1mod common;
7mod join;
8#[cfg(feature = "lan-pairing")]
9mod lan;
10#[cfg(feature = "lan-pairing")]
11mod lan_server;
12mod offline;
13mod online;
14
15#[cfg(feature = "lan-pairing")]
16pub use lan::handle_receive_shared_rot;
17
18use std::sync::Arc;
19
20use anyhow::Result;
21use auths_sdk::core_config::EnvironmentConfig;
22use auths_sdk::signing::PassphraseProvider;
23use chrono::Utc;
24use clap::Parser;
25
26#[cfg(not(feature = "lan-pairing"))]
28const DEFAULT_REGISTRY: &str = "http://localhost:3000";
29
30#[derive(Parser, Debug, Clone)]
31#[command(
32 about = "Link devices to your identity",
33 after_help = "Examples:
34 auths pair # Start LAN pairing session (shows QR code)
35 auths pair --join CODE # Join an existing pairing session using short code
36 auths pair --registry URL # Pair via relay server
37 auths pair --offline # Offline mode (for testing, no network)
38
39Modes:
40 LAN mode (default) — Local pairing via mDNS, fastest and most secure
41 Online mode — Via relay server, works across networks
42 Offline mode — For testing, no network required
43
44Related:
45 auths status — Check linked devices
46 auths device — Manage device authorizations
47 auths init — Initial setup wizard"
48)]
49pub struct PairCommand {
50 #[clap(long, value_name = "CODE")]
52 pub join: Option<String>,
53
54 #[clap(long, value_name = "URL")]
56 pub registry: Option<String>,
57
58 #[clap(long, hide_short_help = true)]
60 pub no_qr: bool,
61
62 #[clap(long, hide_short_help = true)]
65 pub print_uri: bool,
66
67 #[clap(
69 long,
70 visible_alias = "expiry",
71 value_name = "SECONDS",
72 default_value = "300"
73 )]
74 pub timeout: u64,
75
76 #[clap(long, hide_short_help = true)]
78 pub offline: bool,
79
80 #[clap(
82 long,
83 value_delimiter = ',',
84 default_value = "sign_commit",
85 hide_short_help = true
86 )]
87 pub capabilities: Vec<auths_keri::Capability>,
88
89 #[cfg(feature = "lan-pairing")]
91 #[clap(long, hide_short_help = true)]
92 pub no_mdns: bool,
93
94 #[clap(long)]
102 pub verify: bool,
103
104 #[clap(long, value_name = "OLD_DID")]
109 pub recover: Option<String>,
110}
111
112pub fn handle_pair(
122 cmd: PairCommand,
123 passphrase_provider: Arc<dyn PassphraseProvider + Send + Sync>,
124 env_config: &EnvironmentConfig,
125) -> Result<()> {
126 #[allow(clippy::disallowed_methods)]
127 let now = Utc::now();
128
129 if let Err(e) = crate::commands::agent::ensure_agent_running(true) {
135 log::debug!("auths-agent auto-start skipped: {e}");
136 }
137
138 match (&cmd.join, &cmd.registry, cmd.offline) {
139 (None, _, true) => offline::handle_initiate_offline(
141 now,
142 cmd.no_qr,
143 cmd.print_uri,
144 cmd.timeout,
145 &cmd.capabilities,
146 ),
147
148 (Some(code), Some(registry), _) => {
150 let rt = tokio::runtime::Runtime::new()?;
151 rt.block_on(join::handle_join(now, code, registry, env_config))
152 }
153
154 #[cfg(feature = "lan-pairing")]
156 (Some(code), None, _) => {
157 let rt = tokio::runtime::Runtime::new()?;
158 rt.block_on(lan::handle_join_lan(now, code, env_config))
159 }
160
161 #[cfg(not(feature = "lan-pairing"))]
163 (Some(code), None, _) => {
164 let rt = tokio::runtime::Runtime::new()?;
165 rt.block_on(join::handle_join(now, code, DEFAULT_REGISTRY, env_config))
166 }
167
168 (None, Some(registry), _) => {
170 let rt = tokio::runtime::Runtime::new()?;
171 rt.block_on(online::handle_initiate_online(
172 now,
173 registry,
174 cmd.no_qr,
175 cmd.print_uri,
176 cmd.timeout,
177 &cmd.capabilities,
178 env_config,
179 cmd.recover.clone(),
180 ))
181 }
182
183 #[cfg(feature = "lan-pairing")]
185 (None, None, false) => {
186 let rt = tokio::runtime::Runtime::new()?;
187 rt.block_on(lan::handle_initiate_lan(
188 now,
189 cmd.no_qr,
190 cmd.print_uri,
191 cmd.no_mdns,
192 cmd.verify,
193 cmd.recover.clone(),
194 cmd.timeout,
195 &cmd.capabilities,
196 passphrase_provider,
197 env_config,
198 ))
199 }
200
201 #[cfg(not(feature = "lan-pairing"))]
203 (None, None, false) => {
204 let rt = tokio::runtime::Runtime::new()?;
205 rt.block_on(online::handle_initiate_online(
206 now,
207 DEFAULT_REGISTRY,
208 cmd.no_qr,
209 cmd.print_uri,
210 cmd.timeout,
211 &cmd.capabilities,
212 env_config,
213 cmd.recover.clone(),
214 ))
215 }
216 }
217}
218
219#[cfg(test)]
220mod tests {
221 use auths_sdk::pairing::normalize_short_code;
222
223 #[test]
224 fn test_print_uri_flag_parses_and_composes_with_no_qr() {
225 use clap::Parser;
226 let cmd = super::PairCommand::parse_from(["pair", "--print-uri", "--no-qr"]);
227 assert!(cmd.print_uri);
228 assert!(cmd.no_qr);
229 let cmd = super::PairCommand::parse_from(["pair"]);
230 assert!(!cmd.print_uri);
231 }
232
233 #[test]
234 fn test_print_uri_visible_in_long_help() {
235 use clap::CommandFactory;
236 let help = super::PairCommand::command().render_long_help().to_string();
237 assert!(
238 help.contains("--print-uri"),
239 "pair --help must advertise --print-uri"
240 );
241 }
242
243 #[test]
244 fn test_code_normalization() {
245 let codes = vec![
246 ("AB3DEF", "AB3DEF"),
247 ("ab3def", "AB3DEF"),
248 ("AB3 DEF", "AB3DEF"),
249 ("AB3-DEF", "AB3DEF"),
250 ("a b 3 d e f", "AB3DEF"),
251 ];
252
253 for (input, expected) in codes {
254 let normalized = normalize_short_code(input);
255 assert_eq!(normalized, expected, "Input: '{}'", input);
256 }
257 }
258}