teams-cli 0.1.3__tar.gz → 0.1.5__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {teams_cli-0.1.3 → teams_cli-0.1.5}/CHANGELOG.md +12 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/Cargo.lock +1 -1
- {teams_cli-0.1.3 → teams_cli-0.1.5}/Cargo.toml +5 -1
- {teams_cli-0.1.3 → teams_cli-0.1.5}/PKG-INFO +9 -2
- {teams_cli-0.1.3 → teams_cli-0.1.5}/README.md +8 -1
- {teams_cli-0.1.3 → teams_cli-0.1.5}/src/config.rs +20 -5
- {teams_cli-0.1.3 → teams_cli-0.1.5}/src/main.rs +111 -1
- teams_cli-0.1.5/src/teams_cli_alias.rs +30 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/src/tui.rs +179 -14
- teams_cli-0.1.5/tests/package_alias.rs +20 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/.github/dependabot.yml +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/.github/workflows/ci.yml +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/.github/workflows/release.yml +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/.gitignore +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/.mise.toml +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/DESIGN.md +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/LICENSE +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/Makefile +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/PRODUCT.md +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/docs/releases.md +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/examples/validate_schema.rs +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/pyproject.toml +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/src/auth.rs +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/src/cli.rs +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/src/error.rs +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/src/graph.rs +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/src/lib.rs +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/src/output.rs +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/src/schema.rs +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/tests/cli.rs +0 -0
- {teams_cli-0.1.3 → teams_cli-0.1.5}/vership.toml +0 -0
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.5](https://github.com/rvben/teams-cli/compare/v0.1.4...v0.1.5) - 2026-08-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **tui**: recover Microsoft sign-in in place ([8266887](https://github.com/rvben/teams-cli/commit/8266887feef3feb651fa4ae5348752be2703d36b))
|
|
15
|
+
|
|
16
|
+
## [0.1.4](https://github.com/rvben/teams-cli/compare/v0.1.3...v0.1.4) - 2026-08-26
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **packaging**: add package-named launcher ([35a14d3](https://github.com/rvben/teams-cli/commit/35a14d329c5ca5a24f0e47c81cf29f30bad8773c))
|
|
21
|
+
|
|
10
22
|
## [0.1.3](https://github.com/rvben/teams-cli/compare/v0.1.2...v0.1.3) - 2026-08-26
|
|
11
23
|
|
|
12
24
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "teams-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.5"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
rust-version = "1.90"
|
|
6
6
|
description = "A Microsoft Teams CLI and TUI designed equally for humans and agents"
|
|
@@ -16,6 +16,10 @@ categories = ["command-line-utilities"]
|
|
|
16
16
|
name = "teams"
|
|
17
17
|
path = "src/main.rs"
|
|
18
18
|
|
|
19
|
+
[[bin]]
|
|
20
|
+
name = "teams-cli"
|
|
21
|
+
path = "src/teams_cli_alias.rs"
|
|
22
|
+
|
|
19
23
|
[dependencies]
|
|
20
24
|
base64 = "0.23"
|
|
21
25
|
clap = { version = "4", features = ["derive", "env"] }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: teams-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.5
|
|
4
4
|
Classifier: Programming Language :: Rust
|
|
5
5
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
6
6
|
License-File: LICENSE
|
|
@@ -28,9 +28,11 @@ For humans, no arguments open guided onboarding on first use and the TUI afterwa
|
|
|
28
28
|
pipx install teams-cli
|
|
29
29
|
# or
|
|
30
30
|
cargo install teams-cli --locked
|
|
31
|
+
# or run from PyPI without installing
|
|
32
|
+
uvx teams-cli --help
|
|
31
33
|
```
|
|
32
34
|
|
|
33
|
-
The PyPI distribution
|
|
35
|
+
The PyPI distribution provides both `teams` and `teams-cli` as executable names.
|
|
34
36
|
|
|
35
37
|
To work from source:
|
|
36
38
|
|
|
@@ -71,6 +73,11 @@ Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send
|
|
|
71
73
|
ChannelMessage.Send
|
|
72
74
|
```
|
|
73
75
|
|
|
76
|
+
The TUI is also the recovery path: an unconfigured profile, missing credential,
|
|
77
|
+
expired refresh session, or Microsoft `401` opens a focused connection screen.
|
|
78
|
+
Press `Enter` or `a` to restore the normal terminal, complete Microsoft sign-in,
|
|
79
|
+
and return directly to the inbox; `q` exits without changing anything.
|
|
80
|
+
|
|
74
81
|
Reading channel history is intentionally separate because `ChannelMessage.Read.All` requires administrator consent. Request it only when needed:
|
|
75
82
|
|
|
76
83
|
```console
|
|
@@ -12,9 +12,11 @@ For humans, no arguments open guided onboarding on first use and the TUI afterwa
|
|
|
12
12
|
pipx install teams-cli
|
|
13
13
|
# or
|
|
14
14
|
cargo install teams-cli --locked
|
|
15
|
+
# or run from PyPI without installing
|
|
16
|
+
uvx teams-cli --help
|
|
15
17
|
```
|
|
16
18
|
|
|
17
|
-
The PyPI distribution
|
|
19
|
+
The PyPI distribution provides both `teams` and `teams-cli` as executable names.
|
|
18
20
|
|
|
19
21
|
To work from source:
|
|
20
22
|
|
|
@@ -55,6 +57,11 @@ Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send
|
|
|
55
57
|
ChannelMessage.Send
|
|
56
58
|
```
|
|
57
59
|
|
|
60
|
+
The TUI is also the recovery path: an unconfigured profile, missing credential,
|
|
61
|
+
expired refresh session, or Microsoft `401` opens a focused connection screen.
|
|
62
|
+
Press `Enter` or `a` to restore the normal terminal, complete Microsoft sign-in,
|
|
63
|
+
and return directly to the inbox; `q` exits without changing anything.
|
|
64
|
+
|
|
58
65
|
Reading channel history is intentionally separate because `ChannelMessage.Read.All` requires administrator consent. Request it only when needed:
|
|
59
66
|
|
|
60
67
|
```console
|
|
@@ -83,11 +83,7 @@ pub fn save(profile_name: &str, profile: Profile) -> Result<PathBuf, AppError> {
|
|
|
83
83
|
|
|
84
84
|
pub fn load(requested: Option<&str>) -> Result<(String, Profile), AppError> {
|
|
85
85
|
let config = read_file()?;
|
|
86
|
-
let name = requested
|
|
87
|
-
.map(str::to_owned)
|
|
88
|
-
.or_else(|| std::env::var("TEAMS_PROFILE").ok())
|
|
89
|
-
.or(config.active_profile)
|
|
90
|
-
.unwrap_or_else(|| "default".into());
|
|
86
|
+
let name = selected_profile_name_from(requested, config.active_profile.as_deref());
|
|
91
87
|
let stored = config.profiles.get(&name);
|
|
92
88
|
let client_id = std::env::var("TEAMS_CLIENT_ID")
|
|
93
89
|
.ok()
|
|
@@ -118,6 +114,25 @@ pub fn load(requested: Option<&str>) -> Result<(String, Profile), AppError> {
|
|
|
118
114
|
Ok((name, profile))
|
|
119
115
|
}
|
|
120
116
|
|
|
117
|
+
/// Resolve the profile a command would use without requiring that profile to
|
|
118
|
+
/// be configured. Interactive recovery screens need the name even on first
|
|
119
|
+
/// use, before there is a profile for [`load`] to return.
|
|
120
|
+
pub fn selected_profile_name(requested: Option<&str>) -> Result<String, AppError> {
|
|
121
|
+
let config = read_file()?;
|
|
122
|
+
Ok(selected_profile_name_from(
|
|
123
|
+
requested,
|
|
124
|
+
config.active_profile.as_deref(),
|
|
125
|
+
))
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
fn selected_profile_name_from(requested: Option<&str>, active: Option<&str>) -> String {
|
|
129
|
+
requested
|
|
130
|
+
.map(str::to_owned)
|
|
131
|
+
.or_else(|| std::env::var("TEAMS_PROFILE").ok())
|
|
132
|
+
.or_else(|| active.map(str::to_owned))
|
|
133
|
+
.unwrap_or_else(|| "default".into())
|
|
134
|
+
}
|
|
135
|
+
|
|
121
136
|
fn parse_bool(name: &str, value: &str) -> Result<bool, AppError> {
|
|
122
137
|
match value.trim().to_ascii_lowercase().as_str() {
|
|
123
138
|
"1" | "true" | "yes" | "on" => Ok(true),
|
|
@@ -393,6 +393,59 @@ async fn tui_command(profile_arg: Option<&str>, args: TuiArgs) -> Result<(), App
|
|
|
393
393
|
if args.demo {
|
|
394
394
|
return tui::run(tui::demo_data());
|
|
395
395
|
}
|
|
396
|
+
let profile_name = config::selected_profile_name(profile_arg)?;
|
|
397
|
+
let mut reason_override = None;
|
|
398
|
+
loop {
|
|
399
|
+
match load_tui_data(profile_arg).await {
|
|
400
|
+
Ok(data) => return tui::run(data),
|
|
401
|
+
Err(error) if tui_auth_recoverable(&error) => {
|
|
402
|
+
let configured = config::configured_profile(profile_arg).is_some();
|
|
403
|
+
let reason = reason_override
|
|
404
|
+
.take()
|
|
405
|
+
.unwrap_or_else(|| tui_connection_reason(&error));
|
|
406
|
+
if tui::request_authentication(&profile_name, configured, &reason)?
|
|
407
|
+
== tui::TuiExit::Quit
|
|
408
|
+
{
|
|
409
|
+
return Ok(());
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
let auth_output = Output {
|
|
413
|
+
format: OutputFormat::Text,
|
|
414
|
+
quiet: false,
|
|
415
|
+
};
|
|
416
|
+
let result = if configured {
|
|
417
|
+
let (_, profile) = config::load(profile_arg)?;
|
|
418
|
+
auth::login(&profile_name, &profile, false, false, &auth_output)
|
|
419
|
+
.await
|
|
420
|
+
.map(|_| ())
|
|
421
|
+
} else {
|
|
422
|
+
init(
|
|
423
|
+
&profile_name,
|
|
424
|
+
InitArgs {
|
|
425
|
+
client_id: None,
|
|
426
|
+
tenant: config::DEFAULT_TENANT.into(),
|
|
427
|
+
no_login: false,
|
|
428
|
+
device_code: false,
|
|
429
|
+
channel_history: false,
|
|
430
|
+
read_only: false,
|
|
431
|
+
},
|
|
432
|
+
auth_output,
|
|
433
|
+
)
|
|
434
|
+
.await
|
|
435
|
+
};
|
|
436
|
+
if let Err(error) = result {
|
|
437
|
+
reason_override = Some(format!(
|
|
438
|
+
"Sign-in did not complete: {} You can try again.",
|
|
439
|
+
concise_error(&error)
|
|
440
|
+
));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
Err(error) => return Err(error),
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async fn load_tui_data(profile_arg: Option<&str>) -> Result<tui::TuiData, AppError> {
|
|
396
449
|
let graph = client(profile_arg).await?;
|
|
397
450
|
let me = graph.me().await?;
|
|
398
451
|
let teams = graph.teams(50, 0).await?;
|
|
@@ -416,7 +469,7 @@ async fn tui_command(profile_arg: Option<&str>, args: TuiArgs) -> Result<(), App
|
|
|
416
469
|
messages: vec![],
|
|
417
470
|
});
|
|
418
471
|
}
|
|
419
|
-
|
|
472
|
+
Ok(tui::TuiData {
|
|
420
473
|
account: string_at(&me, "/displayName")
|
|
421
474
|
.unwrap_or("Microsoft 365")
|
|
422
475
|
.into(),
|
|
@@ -424,6 +477,63 @@ async fn tui_command(profile_arg: Option<&str>, args: TuiArgs) -> Result<(), App
|
|
|
424
477
|
})
|
|
425
478
|
}
|
|
426
479
|
|
|
480
|
+
fn tui_auth_recoverable(error: &AppError) -> bool {
|
|
481
|
+
matches!(error, AppError::Auth(_))
|
|
482
|
+
|| matches!(error, AppError::InvalidInput(message) if message.contains("is not configured"))
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
fn tui_connection_reason(error: &AppError) -> String {
|
|
486
|
+
match error {
|
|
487
|
+
AppError::InvalidInput(message) if message.contains("is not configured") => {
|
|
488
|
+
"This profile has not been connected yet.".into()
|
|
489
|
+
}
|
|
490
|
+
AppError::Auth(message)
|
|
491
|
+
if message.contains("not signed in") || message.contains("unreadable") =>
|
|
492
|
+
{
|
|
493
|
+
"This profile does not have a usable Microsoft sign-in yet.".into()
|
|
494
|
+
}
|
|
495
|
+
AppError::Auth(message) if message.contains("expired") => {
|
|
496
|
+
"Your saved Microsoft session has expired.".into()
|
|
497
|
+
}
|
|
498
|
+
_ => "Microsoft rejected the current session.".into(),
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
fn concise_error(error: &AppError) -> String {
|
|
503
|
+
error.to_string().trim_end_matches('.').to_string()
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
#[cfg(test)]
|
|
507
|
+
mod tests {
|
|
508
|
+
use super::*;
|
|
509
|
+
|
|
510
|
+
#[test]
|
|
511
|
+
fn tui_recovers_configuration_and_authentication_only() {
|
|
512
|
+
assert!(tui_auth_recoverable(&AppError::InvalidInput(
|
|
513
|
+
"profile 'work' is not configured; run `teams init`".into()
|
|
514
|
+
)));
|
|
515
|
+
assert!(tui_auth_recoverable(&AppError::Auth(
|
|
516
|
+
"session expired".into()
|
|
517
|
+
)));
|
|
518
|
+
assert!(!tui_auth_recoverable(&AppError::InvalidInput(
|
|
519
|
+
"invalid field selection".into()
|
|
520
|
+
)));
|
|
521
|
+
assert!(!tui_auth_recoverable(&AppError::RateLimit(None)));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
#[test]
|
|
525
|
+
fn tui_connection_copy_is_actionable_without_shell_instructions() {
|
|
526
|
+
let reason = tui_connection_reason(&AppError::Auth(
|
|
527
|
+
"not signed in; run `teams auth login`".into(),
|
|
528
|
+
));
|
|
529
|
+
assert_eq!(
|
|
530
|
+
reason,
|
|
531
|
+
"This profile does not have a usable Microsoft sign-in yet."
|
|
532
|
+
);
|
|
533
|
+
assert!(!reason.contains("teams auth login"));
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
427
537
|
#[derive(Serialize)]
|
|
428
538
|
struct Check {
|
|
429
539
|
name: &'static str,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
use std::env;
|
|
2
|
+
use std::fmt::Display;
|
|
3
|
+
use std::process::{self, Command};
|
|
4
|
+
|
|
5
|
+
const COMMAND: &str = "teams";
|
|
6
|
+
|
|
7
|
+
fn fail(error: impl Display) -> ! {
|
|
8
|
+
eprintln!("teams-cli: failed to launch {COMMAND}: {error}");
|
|
9
|
+
process::exit(126);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
fn main() {
|
|
13
|
+
let mut executable = env::current_exe().unwrap_or_else(|error| fail(error));
|
|
14
|
+
executable.set_file_name(format!("{COMMAND}{}", env::consts::EXE_SUFFIX));
|
|
15
|
+
|
|
16
|
+
let mut command = Command::new(executable);
|
|
17
|
+
command.args(env::args_os().skip(1));
|
|
18
|
+
|
|
19
|
+
#[cfg(unix)]
|
|
20
|
+
{
|
|
21
|
+
use std::os::unix::process::CommandExt;
|
|
22
|
+
fail(command.exec());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#[cfg(not(unix))]
|
|
26
|
+
match command.status() {
|
|
27
|
+
Ok(status) => process::exit(status.code().unwrap_or(1)),
|
|
28
|
+
Err(error) => fail(error),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
use std::io::{self, IsTerminal};
|
|
2
2
|
use std::time::Duration;
|
|
3
3
|
|
|
4
|
-
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
|
|
4
|
+
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
|
|
5
5
|
use crossterm::execute;
|
|
6
6
|
use crossterm::terminal::{
|
|
7
7
|
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
|
|
@@ -21,6 +21,32 @@ const FOCUS: Color = Color::Rgb(102, 194, 255);
|
|
|
21
21
|
const LIVE: Color = Color::Rgb(255, 119, 107);
|
|
22
22
|
const SURFACE: Color = Color::Rgb(20, 23, 30);
|
|
23
23
|
|
|
24
|
+
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
25
|
+
pub enum TuiExit {
|
|
26
|
+
Quit,
|
|
27
|
+
Authenticate,
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
struct TerminalGuard;
|
|
31
|
+
|
|
32
|
+
impl TerminalGuard {
|
|
33
|
+
fn enter() -> Result<Self, AppError> {
|
|
34
|
+
enable_raw_mode()?;
|
|
35
|
+
if let Err(error) = execute!(io::stdout(), EnterAlternateScreen) {
|
|
36
|
+
let _ = disable_raw_mode();
|
|
37
|
+
return Err(error.into());
|
|
38
|
+
}
|
|
39
|
+
Ok(Self)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
impl Drop for TerminalGuard {
|
|
44
|
+
fn drop(&mut self) {
|
|
45
|
+
let _ = disable_raw_mode();
|
|
46
|
+
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
24
50
|
#[derive(Debug, Clone)]
|
|
25
51
|
pub struct TuiData {
|
|
26
52
|
pub account: String,
|
|
@@ -119,17 +145,46 @@ pub fn snapshot(width: u16, height: u16) -> Result<String, AppError> {
|
|
|
119
145
|
Ok(output)
|
|
120
146
|
}
|
|
121
147
|
|
|
148
|
+
pub fn connection_snapshot(
|
|
149
|
+
width: u16,
|
|
150
|
+
height: u16,
|
|
151
|
+
profile: &str,
|
|
152
|
+
configured: bool,
|
|
153
|
+
reason: &str,
|
|
154
|
+
) -> Result<String, AppError> {
|
|
155
|
+
let backend = TestBackend::new(width.max(48), height.max(18));
|
|
156
|
+
let mut terminal = Terminal::new(backend).expect("test backend is infallible");
|
|
157
|
+
terminal
|
|
158
|
+
.draw(|frame| draw_connection(frame, profile, configured, reason))
|
|
159
|
+
.expect("test backend is infallible");
|
|
160
|
+
Ok(buffer_text(terminal.backend().buffer()))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
fn buffer_text(buffer: &ratatui::buffer::Buffer) -> String {
|
|
164
|
+
let mut output = String::new();
|
|
165
|
+
for y in 0..buffer.area.height {
|
|
166
|
+
let mut line = String::new();
|
|
167
|
+
for x in 0..buffer.area.width {
|
|
168
|
+
line.push_str(buffer[(x, y)].symbol());
|
|
169
|
+
}
|
|
170
|
+
output.push_str(line.trim_end());
|
|
171
|
+
output.push('\n');
|
|
172
|
+
}
|
|
173
|
+
while output.ends_with("\n\n") {
|
|
174
|
+
output.pop();
|
|
175
|
+
}
|
|
176
|
+
output
|
|
177
|
+
}
|
|
178
|
+
|
|
122
179
|
pub fn run(data: TuiData) -> Result<(), AppError> {
|
|
123
180
|
if !io::stdout().is_terminal() || !io::stdin().is_terminal() {
|
|
124
181
|
return Err(AppError::NonInteractive("`teams tui` needs a terminal; use `teams tui --demo --snapshot` for an inspectable frame".into()));
|
|
125
182
|
}
|
|
126
|
-
|
|
127
|
-
let
|
|
128
|
-
execute!(stdout, EnterAlternateScreen)?;
|
|
129
|
-
let backend = CrosstermBackend::new(stdout);
|
|
183
|
+
let _terminal_guard = TerminalGuard::enter()?;
|
|
184
|
+
let backend = CrosstermBackend::new(io::stdout());
|
|
130
185
|
let mut terminal = Terminal::new(backend)?;
|
|
131
186
|
let mut app = App { data, selected: 0 };
|
|
132
|
-
|
|
187
|
+
(|| -> Result<(), AppError> {
|
|
133
188
|
loop {
|
|
134
189
|
terminal.draw(|frame| draw(frame, &app))?;
|
|
135
190
|
if event::poll(Duration::from_millis(150))?
|
|
@@ -140,6 +195,7 @@ pub fn run(data: TuiData) -> Result<(), AppError> {
|
|
|
140
195
|
}
|
|
141
196
|
match key.code {
|
|
142
197
|
KeyCode::Char('q') | KeyCode::Esc => break,
|
|
198
|
+
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break,
|
|
143
199
|
KeyCode::Down | KeyCode::Char('j') => {
|
|
144
200
|
app.selected =
|
|
145
201
|
(app.selected + 1).min(app.data.conversations.len().saturating_sub(1))
|
|
@@ -152,14 +208,111 @@ pub fn run(data: TuiData) -> Result<(), AppError> {
|
|
|
152
208
|
}
|
|
153
209
|
}
|
|
154
210
|
Ok(())
|
|
155
|
-
})()
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
211
|
+
})()
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/// Present an actionable connection state inside the same terminal experience.
|
|
215
|
+
/// Returning first restores the alternate screen before the caller opens a
|
|
216
|
+
/// browser or prints device-code instructions.
|
|
217
|
+
pub fn request_authentication(
|
|
218
|
+
profile: &str,
|
|
219
|
+
configured: bool,
|
|
220
|
+
reason: &str,
|
|
221
|
+
) -> Result<TuiExit, AppError> {
|
|
222
|
+
if !io::stdout().is_terminal() || !io::stdin().is_terminal() {
|
|
223
|
+
return Err(AppError::NonInteractive(
|
|
224
|
+
"`teams tui` needs a terminal; run `teams auth login --device-code` for headless sign-in"
|
|
225
|
+
.into(),
|
|
226
|
+
));
|
|
227
|
+
}
|
|
228
|
+
let _terminal_guard = TerminalGuard::enter()?;
|
|
229
|
+
let backend = CrosstermBackend::new(io::stdout());
|
|
230
|
+
let mut terminal = Terminal::new(backend)?;
|
|
231
|
+
loop {
|
|
232
|
+
terminal.draw(|frame| draw_connection(frame, profile, configured, reason))?;
|
|
233
|
+
if event::poll(Duration::from_millis(150))?
|
|
234
|
+
&& let Event::Key(key) = event::read()?
|
|
235
|
+
{
|
|
236
|
+
if key.kind != KeyEventKind::Press {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
match key.code {
|
|
240
|
+
KeyCode::Enter | KeyCode::Char('a') => return Ok(TuiExit::Authenticate),
|
|
241
|
+
KeyCode::Char('q') | KeyCode::Esc => return Ok(TuiExit::Quit),
|
|
242
|
+
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
|
243
|
+
return Ok(TuiExit::Quit);
|
|
244
|
+
}
|
|
245
|
+
_ => {}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
fn draw_connection(frame: &mut ratatui::Frame, profile: &str, configured: bool, reason: &str) {
|
|
252
|
+
let area = frame.area();
|
|
253
|
+
frame.render_widget(Block::default().style(Style::default().bg(SURFACE)), area);
|
|
254
|
+
let panel_width = area.width.saturating_sub(2).clamp(1, 74);
|
|
255
|
+
let panel_height = area.height.saturating_sub(2).clamp(1, 12);
|
|
256
|
+
let panel = Rect {
|
|
257
|
+
x: area.x + area.width.saturating_sub(panel_width) / 2,
|
|
258
|
+
y: area.y + area.height.saturating_sub(panel_height) / 2,
|
|
259
|
+
width: panel_width,
|
|
260
|
+
height: panel_height,
|
|
261
|
+
};
|
|
262
|
+
let title = if configured {
|
|
263
|
+
" SIGN-IN REQUIRED "
|
|
264
|
+
} else {
|
|
265
|
+
" CONNECT MICROSOFT TEAMS "
|
|
266
|
+
};
|
|
267
|
+
let action = if configured {
|
|
268
|
+
"Continue with Microsoft sign-in. Your password is never shared with teams-cli."
|
|
269
|
+
} else {
|
|
270
|
+
"Set up this profile and continue with secure Microsoft sign-in."
|
|
271
|
+
};
|
|
272
|
+
let content = vec![
|
|
273
|
+
Line::styled(
|
|
274
|
+
if configured {
|
|
275
|
+
"Your Teams session needs attention"
|
|
276
|
+
} else {
|
|
277
|
+
"Bring your Teams workspace into focus"
|
|
278
|
+
},
|
|
279
|
+
Style::default().fg(INK).add_modifier(Modifier::BOLD),
|
|
280
|
+
),
|
|
281
|
+
Line::styled(format!("Profile: {profile}"), Style::default().fg(MUTED)),
|
|
282
|
+
Line::raw(""),
|
|
283
|
+
Line::styled(reason, Style::default().fg(INK)),
|
|
284
|
+
Line::styled(action, Style::default().fg(MUTED)),
|
|
285
|
+
Line::raw(""),
|
|
286
|
+
Line::from(vec![
|
|
287
|
+
Span::styled(
|
|
288
|
+
" enter / a ",
|
|
289
|
+
Style::default()
|
|
290
|
+
.fg(Color::Black)
|
|
291
|
+
.bg(FOCUS)
|
|
292
|
+
.add_modifier(Modifier::BOLD),
|
|
293
|
+
),
|
|
294
|
+
Span::styled(
|
|
295
|
+
if configured {
|
|
296
|
+
" sign in "
|
|
297
|
+
} else {
|
|
298
|
+
" set up "
|
|
299
|
+
},
|
|
300
|
+
Style::default().fg(MUTED),
|
|
301
|
+
),
|
|
302
|
+
Span::styled("q", Style::default().fg(INK)),
|
|
303
|
+
Span::styled(" quit", Style::default().fg(MUTED)),
|
|
304
|
+
]),
|
|
305
|
+
];
|
|
306
|
+
frame.render_widget(
|
|
307
|
+
Paragraph::new(content).wrap(Wrap { trim: true }).block(
|
|
308
|
+
Block::default()
|
|
309
|
+
.title(title)
|
|
310
|
+
.borders(Borders::ALL)
|
|
311
|
+
.border_style(Style::default().fg(FOCUS))
|
|
312
|
+
.padding(ratatui::widgets::Padding::horizontal(2)),
|
|
313
|
+
),
|
|
314
|
+
panel,
|
|
315
|
+
);
|
|
163
316
|
}
|
|
164
317
|
|
|
165
318
|
fn draw(frame: &mut ratatui::Frame, app: &App) {
|
|
@@ -312,4 +465,16 @@ mod tests {
|
|
|
312
465
|
assert!(frame.contains("release candidate is green"));
|
|
313
466
|
assert!(frame.contains("q quit"));
|
|
314
467
|
}
|
|
468
|
+
|
|
469
|
+
#[test]
|
|
470
|
+
fn connection_snapshot_has_a_clear_recovery_action() {
|
|
471
|
+
let frame =
|
|
472
|
+
super::connection_snapshot(80, 22, "work", true, "Your saved session has expired.")
|
|
473
|
+
.unwrap();
|
|
474
|
+
assert!(frame.contains("SIGN-IN REQUIRED"));
|
|
475
|
+
assert!(frame.contains("Profile: work"));
|
|
476
|
+
assert!(frame.contains("enter / a"));
|
|
477
|
+
assert!(frame.contains("sign in"));
|
|
478
|
+
assert!(frame.contains("q quit"));
|
|
479
|
+
}
|
|
315
480
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
use std::process::{Command, Output};
|
|
2
|
+
|
|
3
|
+
fn run(executable: &str, argument: &str) -> Output {
|
|
4
|
+
Command::new(executable)
|
|
5
|
+
.arg(argument)
|
|
6
|
+
.output()
|
|
7
|
+
.expect("CLI should start")
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
#[test]
|
|
11
|
+
fn package_named_alias_forwards_arguments_output_and_status() {
|
|
12
|
+
for argument in ["--version", "--__alias-test-invalid"] {
|
|
13
|
+
let primary = run(env!("CARGO_BIN_EXE_teams"), argument);
|
|
14
|
+
let alias = run(env!("CARGO_BIN_EXE_teams-cli"), argument);
|
|
15
|
+
|
|
16
|
+
assert_eq!(alias.status.code(), primary.status.code());
|
|
17
|
+
assert_eq!(alias.stdout, primary.stdout);
|
|
18
|
+
assert_eq!(alias.stderr, primary.stderr);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|