teams-cli 0.1.4__tar.gz → 0.1.6__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.6/.github/workflows/dependencies.yml +28 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/CHANGELOG.md +16 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/Cargo.lock +1 -1
- {teams_cli-0.1.4 → teams_cli-0.1.6}/Cargo.toml +1 -1
- {teams_cli-0.1.4 → teams_cli-0.1.6}/PKG-INFO +13 -2
- {teams_cli-0.1.4 → teams_cli-0.1.6}/README.md +12 -1
- {teams_cli-0.1.4 → teams_cli-0.1.6}/src/auth.rs +27 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/src/cli.rs +29 -3
- {teams_cli-0.1.4 → teams_cli-0.1.6}/src/config.rs +76 -7
- {teams_cli-0.1.4 → teams_cli-0.1.6}/src/main.rs +198 -11
- {teams_cli-0.1.4 → teams_cli-0.1.6}/src/schema.rs +64 -18
- {teams_cli-0.1.4 → teams_cli-0.1.6}/src/tui.rs +179 -14
- {teams_cli-0.1.4 → teams_cli-0.1.6}/tests/cli.rs +52 -0
- teams_cli-0.1.4/.github/dependabot.yml +0 -6
- {teams_cli-0.1.4 → teams_cli-0.1.6}/.github/workflows/ci.yml +0 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/.github/workflows/release.yml +0 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/.gitignore +0 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/.mise.toml +0 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/DESIGN.md +0 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/LICENSE +0 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/Makefile +0 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/PRODUCT.md +0 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/docs/releases.md +0 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/examples/validate_schema.rs +0 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/pyproject.toml +0 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/src/error.rs +0 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/src/graph.rs +0 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/src/lib.rs +0 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/src/output.rs +0 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/src/teams_cli_alias.rs +0 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/tests/package_alias.rs +0 -0
- {teams_cli-0.1.4 → teams_cli-0.1.6}/vership.toml +0 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
name: Dependency health
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
schedule:
|
|
5
|
+
- cron: "7 6 * * 1"
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
id-token: write
|
|
11
|
+
packages: read
|
|
12
|
+
pull-requests: read
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
update:
|
|
16
|
+
uses: rvben/upd/.github/workflows/dependency-health.yml@ecb605d32c4edc8dee4091aeb30b3d37ebdd290a # v0.10.0
|
|
17
|
+
with:
|
|
18
|
+
langs: rust,actions
|
|
19
|
+
lock: true
|
|
20
|
+
min-age: 7d
|
|
21
|
+
max-bump: minor
|
|
22
|
+
prepare-command: >-
|
|
23
|
+
rustup toolchain install 1.96.0 --profile minimal --component clippy,rustfmt &&
|
|
24
|
+
rustup default 1.96.0
|
|
25
|
+
validation-command: make check
|
|
26
|
+
fail-on-blocked: false
|
|
27
|
+
branch: deps/upd
|
|
28
|
+
commit-message: "chore(deps): weekly dependency update"
|
|
@@ -7,6 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.6](https://github.com/rvben/teams-cli/compare/v0.1.5...v0.1.6) - 2026-09-03
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **auth**: standardize authentication workflow ([bb0019f](https://github.com/rvben/teams-cli/commit/bb0019f0ceb86dbb963f3499348c58a95922081e))
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **auth**: tolerate missing Linux credential service ([7764383](https://github.com/rvben/teams-cli/commit/776438359f3c913d0514de921042d770c6a88bf0))
|
|
19
|
+
|
|
20
|
+
## [0.1.5](https://github.com/rvben/teams-cli/compare/v0.1.4...v0.1.5) - 2026-08-26
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- **tui**: recover Microsoft sign-in in place ([8266887](https://github.com/rvben/teams-cli/commit/8266887feef3feb651fa4ae5348752be2703d36b))
|
|
25
|
+
|
|
10
26
|
## [0.1.4](https://github.com/rvben/teams-cli/compare/v0.1.3...v0.1.4) - 2026-08-26
|
|
11
27
|
|
|
12
28
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: teams-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.6
|
|
4
4
|
Classifier: Programming Language :: Rust
|
|
5
5
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
6
6
|
License-File: LICENSE
|
|
@@ -73,6 +73,11 @@ Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send
|
|
|
73
73
|
ChannelMessage.Send
|
|
74
74
|
```
|
|
75
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
|
+
|
|
76
81
|
Reading channel history is intentionally separate because `ChannelMessage.Read.All` requires administrator consent. Request it only when needed:
|
|
77
82
|
|
|
78
83
|
```console
|
|
@@ -110,8 +115,14 @@ teams auth login --channel-history # once, with administrator approval
|
|
|
110
115
|
teams messages list --team TEAM_ID --channel CHANNEL_ID
|
|
111
116
|
teams messages send --chat CHAT_ID --body "On it."
|
|
112
117
|
printf 'Status update' | teams messages send --chat CHAT_ID --body -
|
|
118
|
+
teams auth status
|
|
119
|
+
teams auth status --offline
|
|
120
|
+
teams profile list
|
|
121
|
+
teams profile use work
|
|
122
|
+
teams profile remove old --yes
|
|
113
123
|
teams config show
|
|
114
|
-
teams
|
|
124
|
+
teams config path
|
|
125
|
+
teams doctor --offline
|
|
115
126
|
```
|
|
116
127
|
|
|
117
128
|
Collection commands expose `--limit` and `--fields`; Graph-native collections use `--cursor`, while joined teams and channels use `--offset` because those endpoints do not accept `$top`. With a terminal they render concise text; when stdout is piped they emit JSON envelopes with continuation metadata and `truncated`.
|
|
@@ -57,6 +57,11 @@ Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send
|
|
|
57
57
|
ChannelMessage.Send
|
|
58
58
|
```
|
|
59
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
|
+
|
|
60
65
|
Reading channel history is intentionally separate because `ChannelMessage.Read.All` requires administrator consent. Request it only when needed:
|
|
61
66
|
|
|
62
67
|
```console
|
|
@@ -94,8 +99,14 @@ teams auth login --channel-history # once, with administrator approval
|
|
|
94
99
|
teams messages list --team TEAM_ID --channel CHANNEL_ID
|
|
95
100
|
teams messages send --chat CHAT_ID --body "On it."
|
|
96
101
|
printf 'Status update' | teams messages send --chat CHAT_ID --body -
|
|
102
|
+
teams auth status
|
|
103
|
+
teams auth status --offline
|
|
104
|
+
teams profile list
|
|
105
|
+
teams profile use work
|
|
106
|
+
teams profile remove old --yes
|
|
97
107
|
teams config show
|
|
98
|
-
teams
|
|
108
|
+
teams config path
|
|
109
|
+
teams doctor --offline
|
|
99
110
|
```
|
|
100
111
|
|
|
101
112
|
Collection commands expose `--limit` and `--fields`; Graph-native collections use `--cursor`, while joined teams and channels use `--offset` because those endpoints do not accept `$top`. With a terminal they render concise text; when stdout is piped they emit JSON envelopes with continuation metadata and `truncated`.
|
|
@@ -104,12 +104,29 @@ fn load(profile_name: &str) -> Result<TokenBundle, AppError> {
|
|
|
104
104
|
pub fn logout(profile_name: &str) -> Result<(), AppError> {
|
|
105
105
|
match entry(profile_name)?.delete_credential() {
|
|
106
106
|
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
|
|
107
|
+
Err(e) if secret_service_is_unavailable(&e) => Ok(()),
|
|
107
108
|
Err(e) => Err(AppError::Unexpected(format!(
|
|
108
109
|
"could not remove stored credentials: {e}"
|
|
109
110
|
))),
|
|
110
111
|
}
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
#[cfg(target_os = "linux")]
|
|
115
|
+
fn secret_service_is_unavailable(error: &keyring::Error) -> bool {
|
|
116
|
+
match error {
|
|
117
|
+
keyring::Error::PlatformFailure(source) => {
|
|
118
|
+
let message = source.to_string();
|
|
119
|
+
message.contains("org.freedesktop.secrets") && message.contains("not provided")
|
|
120
|
+
}
|
|
121
|
+
_ => false,
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#[cfg(not(target_os = "linux"))]
|
|
126
|
+
fn secret_service_is_unavailable(_error: &keyring::Error) -> bool {
|
|
127
|
+
false
|
|
128
|
+
}
|
|
129
|
+
|
|
113
130
|
pub async fn access_token(profile_name: &str, profile: &Profile) -> Result<String, AppError> {
|
|
114
131
|
if let Ok(token) = std::env::var("TEAMS_ACCESS_TOKEN") {
|
|
115
132
|
return Ok(token);
|
|
@@ -385,4 +402,14 @@ mod tests {
|
|
|
385
402
|
fn channel_history_is_explicitly_opted_in() {
|
|
386
403
|
assert!(scopes(true).contains(CHANNEL_HISTORY_SCOPE));
|
|
387
404
|
}
|
|
405
|
+
|
|
406
|
+
#[cfg(target_os = "linux")]
|
|
407
|
+
#[test]
|
|
408
|
+
fn missing_secret_service_is_treated_as_an_empty_store() {
|
|
409
|
+
let error = keyring::Error::PlatformFailure(Box::new(std::io::Error::other(
|
|
410
|
+
"The name org.freedesktop.secrets was not provided by any .service files",
|
|
411
|
+
)));
|
|
412
|
+
|
|
413
|
+
assert!(super::secret_service_is_unavailable(&error));
|
|
414
|
+
}
|
|
388
415
|
}
|
|
@@ -20,6 +20,9 @@ pub struct Cli {
|
|
|
20
20
|
/// Suppress progress and informational messages
|
|
21
21
|
#[arg(long, global = true)]
|
|
22
22
|
pub quiet: bool,
|
|
23
|
+
/// Skip confirmation prompts for destructive operations
|
|
24
|
+
#[arg(long, short = 'y', global = true)]
|
|
25
|
+
pub yes: bool,
|
|
23
26
|
/// Alias for --output json
|
|
24
27
|
#[arg(long, global = true, hide = true)]
|
|
25
28
|
pub json: bool,
|
|
@@ -36,6 +39,11 @@ pub enum Command {
|
|
|
36
39
|
#[command(subcommand)]
|
|
37
40
|
command: AuthCommand,
|
|
38
41
|
},
|
|
42
|
+
/// List, select, or remove configuration profiles
|
|
43
|
+
Profile {
|
|
44
|
+
#[command(subcommand)]
|
|
45
|
+
command: ProfileCommand,
|
|
46
|
+
},
|
|
39
47
|
/// Inspect resolved, secret-free configuration
|
|
40
48
|
Config {
|
|
41
49
|
#[command(subcommand)]
|
|
@@ -63,7 +71,11 @@ pub enum Command {
|
|
|
63
71
|
/// Open the keyboard-first terminal interface
|
|
64
72
|
Tui(TuiArgs),
|
|
65
73
|
/// Check configuration, credential storage, and Graph access
|
|
66
|
-
Doctor
|
|
74
|
+
Doctor {
|
|
75
|
+
/// Check local state without contacting Microsoft Graph
|
|
76
|
+
#[arg(long)]
|
|
77
|
+
offline: bool,
|
|
78
|
+
},
|
|
67
79
|
/// Describe supported and deliberately unsupported capabilities
|
|
68
80
|
Capabilities,
|
|
69
81
|
/// Emit the offline CLI Spec v0.3 contract
|
|
@@ -118,8 +130,22 @@ pub enum AuthCommand {
|
|
|
118
130
|
},
|
|
119
131
|
/// Remove locally stored credentials
|
|
120
132
|
Logout,
|
|
121
|
-
///
|
|
122
|
-
Status
|
|
133
|
+
/// Verify authentication and show the selected profile's status
|
|
134
|
+
Status {
|
|
135
|
+
/// Inspect local credential state without contacting Microsoft Graph
|
|
136
|
+
#[arg(long)]
|
|
137
|
+
offline: bool,
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
#[derive(Debug, Subcommand)]
|
|
142
|
+
pub enum ProfileCommand {
|
|
143
|
+
/// List configured profiles and identify the active one
|
|
144
|
+
List,
|
|
145
|
+
/// Select the default profile for future commands
|
|
146
|
+
Use { name: String },
|
|
147
|
+
/// Remove a profile and its stored credential
|
|
148
|
+
Remove { name: String },
|
|
123
149
|
}
|
|
124
150
|
|
|
125
151
|
#[derive(Debug, Subcommand)]
|
|
@@ -44,6 +44,15 @@ struct ConfigFile {
|
|
|
44
44
|
profiles: BTreeMap<String, Profile>,
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
#[derive(Debug, Serialize)]
|
|
48
|
+
pub struct ProfileSummary {
|
|
49
|
+
pub name: String,
|
|
50
|
+
pub active: bool,
|
|
51
|
+
pub tenant: String,
|
|
52
|
+
pub client_id: String,
|
|
53
|
+
pub read_only: bool,
|
|
54
|
+
}
|
|
55
|
+
|
|
47
56
|
pub fn path() -> PathBuf {
|
|
48
57
|
if let Some(base) = std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) {
|
|
49
58
|
PathBuf::from(base).join("teams").join("config.toml")
|
|
@@ -61,6 +70,11 @@ pub fn save(profile_name: &str, profile: Profile) -> Result<PathBuf, AppError> {
|
|
|
61
70
|
let mut config = read_file()?;
|
|
62
71
|
config.profiles.insert(profile_name.to_string(), profile);
|
|
63
72
|
config.active_profile = Some(profile_name.to_string());
|
|
73
|
+
write_file(&path, &config)?;
|
|
74
|
+
Ok(path)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fn write_file(path: &std::path::Path, config: &ConfigFile) -> Result<(), AppError> {
|
|
64
78
|
let parent = path
|
|
65
79
|
.parent()
|
|
66
80
|
.ok_or_else(|| AppError::Unexpected("configuration path has no parent".into()))?;
|
|
@@ -77,17 +91,13 @@ pub fn save(profile_name: &str, profile: Profile) -> Result<PathBuf, AppError> {
|
|
|
77
91
|
use std::os::unix::fs::PermissionsExt;
|
|
78
92
|
std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o600))?;
|
|
79
93
|
}
|
|
80
|
-
temp.persist(
|
|
81
|
-
Ok(
|
|
94
|
+
temp.persist(path).map_err(|error| error.error)?;
|
|
95
|
+
Ok(())
|
|
82
96
|
}
|
|
83
97
|
|
|
84
98
|
pub fn load(requested: Option<&str>) -> Result<(String, Profile), AppError> {
|
|
85
99
|
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());
|
|
100
|
+
let name = selected_profile_name_from(requested, config.active_profile.as_deref());
|
|
91
101
|
let stored = config.profiles.get(&name);
|
|
92
102
|
let client_id = std::env::var("TEAMS_CLIENT_ID")
|
|
93
103
|
.ok()
|
|
@@ -118,6 +128,25 @@ pub fn load(requested: Option<&str>) -> Result<(String, Profile), AppError> {
|
|
|
118
128
|
Ok((name, profile))
|
|
119
129
|
}
|
|
120
130
|
|
|
131
|
+
/// Resolve the profile a command would use without requiring that profile to
|
|
132
|
+
/// be configured. Interactive recovery screens need the name even on first
|
|
133
|
+
/// use, before there is a profile for [`load`] to return.
|
|
134
|
+
pub fn selected_profile_name(requested: Option<&str>) -> Result<String, AppError> {
|
|
135
|
+
let config = read_file()?;
|
|
136
|
+
Ok(selected_profile_name_from(
|
|
137
|
+
requested,
|
|
138
|
+
config.active_profile.as_deref(),
|
|
139
|
+
))
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
fn selected_profile_name_from(requested: Option<&str>, active: Option<&str>) -> String {
|
|
143
|
+
requested
|
|
144
|
+
.map(str::to_owned)
|
|
145
|
+
.or_else(|| std::env::var("TEAMS_PROFILE").ok())
|
|
146
|
+
.or_else(|| active.map(str::to_owned))
|
|
147
|
+
.unwrap_or_else(|| "default".into())
|
|
148
|
+
}
|
|
149
|
+
|
|
121
150
|
fn parse_bool(name: &str, value: &str) -> Result<bool, AppError> {
|
|
122
151
|
match value.trim().to_ascii_lowercase().as_str() {
|
|
123
152
|
"1" | "true" | "yes" | "on" => Ok(true),
|
|
@@ -144,6 +173,46 @@ pub fn configured_profile(requested: Option<&str>) -> Option<(String, Profile)>
|
|
|
144
173
|
load(requested).ok()
|
|
145
174
|
}
|
|
146
175
|
|
|
176
|
+
pub fn profile_summaries() -> Result<Vec<ProfileSummary>, AppError> {
|
|
177
|
+
let config = read_file()?;
|
|
178
|
+
Ok(config
|
|
179
|
+
.profiles
|
|
180
|
+
.iter()
|
|
181
|
+
.map(|(name, profile)| ProfileSummary {
|
|
182
|
+
name: name.clone(),
|
|
183
|
+
active: config.active_profile.as_deref() == Some(name.as_str()),
|
|
184
|
+
tenant: profile.tenant.clone(),
|
|
185
|
+
client_id: profile.client_id.clone(),
|
|
186
|
+
read_only: profile.read_only,
|
|
187
|
+
})
|
|
188
|
+
.collect())
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
pub fn use_profile(name: &str) -> Result<(), AppError> {
|
|
192
|
+
let path = path();
|
|
193
|
+
let mut config = read_file()?;
|
|
194
|
+
if !config.profiles.contains_key(name) {
|
|
195
|
+
return Err(AppError::InvalidInput(format!(
|
|
196
|
+
"profile '{name}' is not configured"
|
|
197
|
+
)));
|
|
198
|
+
}
|
|
199
|
+
config.active_profile = Some(name.into());
|
|
200
|
+
write_file(&path, &config)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
pub fn remove_profile(name: &str) -> Result<bool, AppError> {
|
|
204
|
+
let path = path();
|
|
205
|
+
let mut config = read_file()?;
|
|
206
|
+
let removed = config.profiles.remove(name).is_some();
|
|
207
|
+
if removed {
|
|
208
|
+
if config.active_profile.as_deref() == Some(name) {
|
|
209
|
+
config.active_profile = config.profiles.keys().next().cloned();
|
|
210
|
+
}
|
|
211
|
+
write_file(&path, &config)?;
|
|
212
|
+
}
|
|
213
|
+
Ok(removed)
|
|
214
|
+
}
|
|
215
|
+
|
|
147
216
|
fn read_file() -> Result<ConfigFile, AppError> {
|
|
148
217
|
match std::fs::read_to_string(path()) {
|
|
149
218
|
Ok(body) => toml::from_str(&body)
|
|
@@ -7,7 +7,7 @@ use serde_json::Value;
|
|
|
7
7
|
use teams_cli::auth;
|
|
8
8
|
use teams_cli::cli::{
|
|
9
9
|
AuthCommand, ChannelsCommand, ChatsCommand, Cli, Command, ConfigCommand, InitArgs,
|
|
10
|
-
MessagesCommand, OffsetPageArgs, PageArgs, TuiArgs,
|
|
10
|
+
MessagesCommand, OffsetPageArgs, PageArgs, ProfileCommand, TuiArgs,
|
|
11
11
|
};
|
|
12
12
|
use teams_cli::config::{self, Profile};
|
|
13
13
|
use teams_cli::error::AppError;
|
|
@@ -51,6 +51,7 @@ async fn main() {
|
|
|
51
51
|
|
|
52
52
|
async fn dispatch(cli: Cli, out: Output) -> Result<(), AppError> {
|
|
53
53
|
let profile_arg = cli.profile.as_deref();
|
|
54
|
+
let yes = cli.yes;
|
|
54
55
|
let command = match cli.command {
|
|
55
56
|
Some(command) => command,
|
|
56
57
|
None if io::stdin().is_terminal() && io::stdout().is_terminal() && !config::exists() => {
|
|
@@ -78,6 +79,7 @@ async fn dispatch(cli: Cli, out: Output) -> Result<(), AppError> {
|
|
|
78
79
|
match command {
|
|
79
80
|
Command::Init(args) => init(profile_arg.unwrap_or("default"), args, out).await,
|
|
80
81
|
Command::Auth { command } => auth_command(profile_arg, command, out).await,
|
|
82
|
+
Command::Profile { command } => profile_command(command, yes, out),
|
|
81
83
|
Command::Config { command } => config_command(profile_arg, command, out),
|
|
82
84
|
Command::Whoami => {
|
|
83
85
|
let client = client(profile_arg).await?;
|
|
@@ -154,7 +156,7 @@ async fn dispatch(cli: Cli, out: Output) -> Result<(), AppError> {
|
|
|
154
156
|
})
|
|
155
157
|
}
|
|
156
158
|
Command::Tui(args) => tui_command(profile_arg, args).await,
|
|
157
|
-
Command::Doctor => doctor(profile_arg, out).await,
|
|
159
|
+
Command::Doctor { offline } => doctor(profile_arg, offline, out).await,
|
|
158
160
|
Command::Capabilities => capabilities(out),
|
|
159
161
|
Command::Schema { command } => {
|
|
160
162
|
let value = schema::generate(command.as_deref());
|
|
@@ -307,7 +309,7 @@ async fn auth_command(
|
|
|
307
309
|
let value = serde_json::json!({"profile":name,"signed_in":false});
|
|
308
310
|
out.value(&value, || format!("Signed out profile '{name}'."))
|
|
309
311
|
}
|
|
310
|
-
AuthCommand::Status => {
|
|
312
|
+
AuthCommand::Status { offline } => {
|
|
311
313
|
let configured = config::configured_profile(profile_arg);
|
|
312
314
|
let name = configured
|
|
313
315
|
.as_ref()
|
|
@@ -316,7 +318,12 @@ async fn auth_command(
|
|
|
316
318
|
let signed_in = auth::has_token(name);
|
|
317
319
|
let scopes = auth::granted_scopes(name);
|
|
318
320
|
let channel_history = auth::channel_history_granted(name);
|
|
319
|
-
let
|
|
321
|
+
let identity = if !offline && configured.is_some() && signed_in {
|
|
322
|
+
Some(client(Some(name)).await?.me().await?)
|
|
323
|
+
} else {
|
|
324
|
+
None
|
|
325
|
+
};
|
|
326
|
+
let value = serde_json::json!({"profile":name,"configured":configured.is_some(),"signed_in":signed_in,"verified":identity.is_some(),"identity":identity,"config_path":config::path(),"read_only":configured.as_ref().is_some_and(|(_, profile)| profile.read_only),"granted_scopes":scopes,"channel_history":channel_history});
|
|
320
327
|
out.value(&value, || {
|
|
321
328
|
format!(
|
|
322
329
|
"Profile: {name}\nConfigured: {}\nSigned in: {}\nRead only: {}\nChannel history: {}\nConfig: {}",
|
|
@@ -331,6 +338,59 @@ async fn auth_command(
|
|
|
331
338
|
}
|
|
332
339
|
}
|
|
333
340
|
|
|
341
|
+
fn profile_command(command: ProfileCommand, yes: bool, out: Output) -> Result<(), AppError> {
|
|
342
|
+
match command {
|
|
343
|
+
ProfileCommand::List => {
|
|
344
|
+
let profiles = config::profile_summaries()?;
|
|
345
|
+
out.value(
|
|
346
|
+
&serde_json::json!({"items": profiles, "total": profiles.len()}),
|
|
347
|
+
|| {
|
|
348
|
+
if profiles.is_empty() {
|
|
349
|
+
"No profiles configured. Run `teams init`.".into()
|
|
350
|
+
} else {
|
|
351
|
+
profiles
|
|
352
|
+
.iter()
|
|
353
|
+
.map(|profile| {
|
|
354
|
+
format!(
|
|
355
|
+
"{} {} {}",
|
|
356
|
+
if profile.active { "*" } else { " " },
|
|
357
|
+
profile.name,
|
|
358
|
+
profile.tenant
|
|
359
|
+
)
|
|
360
|
+
})
|
|
361
|
+
.collect::<Vec<_>>()
|
|
362
|
+
.join("\n")
|
|
363
|
+
}
|
|
364
|
+
},
|
|
365
|
+
)
|
|
366
|
+
}
|
|
367
|
+
ProfileCommand::Use { name } => {
|
|
368
|
+
config::use_profile(&name)?;
|
|
369
|
+
out.value(
|
|
370
|
+
&serde_json::json!({"profile": name, "active": true}),
|
|
371
|
+
|| format!("Active profile set to '{name}'."),
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
ProfileCommand::Remove { name } => {
|
|
375
|
+
if !yes {
|
|
376
|
+
return Err(AppError::InvalidInput(
|
|
377
|
+
"profile removal requires --yes".into(),
|
|
378
|
+
));
|
|
379
|
+
}
|
|
380
|
+
auth::logout(&name)?;
|
|
381
|
+
if !config::remove_profile(&name)? {
|
|
382
|
+
return Err(AppError::InvalidInput(format!(
|
|
383
|
+
"profile '{name}' is not configured"
|
|
384
|
+
)));
|
|
385
|
+
}
|
|
386
|
+
out.value(
|
|
387
|
+
&serde_json::json!({"profile": name, "removed": true}),
|
|
388
|
+
|| format!("Removed profile '{name}'."),
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
334
394
|
async fn client(profile_arg: Option<&str>) -> Result<GraphClient, AppError> {
|
|
335
395
|
let (name, profile) = config::load(profile_arg)?;
|
|
336
396
|
let token = auth::access_token(&name, &profile).await?;
|
|
@@ -393,6 +453,59 @@ async fn tui_command(profile_arg: Option<&str>, args: TuiArgs) -> Result<(), App
|
|
|
393
453
|
if args.demo {
|
|
394
454
|
return tui::run(tui::demo_data());
|
|
395
455
|
}
|
|
456
|
+
let profile_name = config::selected_profile_name(profile_arg)?;
|
|
457
|
+
let mut reason_override = None;
|
|
458
|
+
loop {
|
|
459
|
+
match load_tui_data(profile_arg).await {
|
|
460
|
+
Ok(data) => return tui::run(data),
|
|
461
|
+
Err(error) if tui_auth_recoverable(&error) => {
|
|
462
|
+
let configured = config::configured_profile(profile_arg).is_some();
|
|
463
|
+
let reason = reason_override
|
|
464
|
+
.take()
|
|
465
|
+
.unwrap_or_else(|| tui_connection_reason(&error));
|
|
466
|
+
if tui::request_authentication(&profile_name, configured, &reason)?
|
|
467
|
+
== tui::TuiExit::Quit
|
|
468
|
+
{
|
|
469
|
+
return Ok(());
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
let auth_output = Output {
|
|
473
|
+
format: OutputFormat::Text,
|
|
474
|
+
quiet: false,
|
|
475
|
+
};
|
|
476
|
+
let result = if configured {
|
|
477
|
+
let (_, profile) = config::load(profile_arg)?;
|
|
478
|
+
auth::login(&profile_name, &profile, false, false, &auth_output)
|
|
479
|
+
.await
|
|
480
|
+
.map(|_| ())
|
|
481
|
+
} else {
|
|
482
|
+
init(
|
|
483
|
+
&profile_name,
|
|
484
|
+
InitArgs {
|
|
485
|
+
client_id: None,
|
|
486
|
+
tenant: config::DEFAULT_TENANT.into(),
|
|
487
|
+
no_login: false,
|
|
488
|
+
device_code: false,
|
|
489
|
+
channel_history: false,
|
|
490
|
+
read_only: false,
|
|
491
|
+
},
|
|
492
|
+
auth_output,
|
|
493
|
+
)
|
|
494
|
+
.await
|
|
495
|
+
};
|
|
496
|
+
if let Err(error) = result {
|
|
497
|
+
reason_override = Some(format!(
|
|
498
|
+
"Sign-in did not complete: {} You can try again.",
|
|
499
|
+
concise_error(&error)
|
|
500
|
+
));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
Err(error) => return Err(error),
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async fn load_tui_data(profile_arg: Option<&str>) -> Result<tui::TuiData, AppError> {
|
|
396
509
|
let graph = client(profile_arg).await?;
|
|
397
510
|
let me = graph.me().await?;
|
|
398
511
|
let teams = graph.teams(50, 0).await?;
|
|
@@ -416,7 +529,7 @@ async fn tui_command(profile_arg: Option<&str>, args: TuiArgs) -> Result<(), App
|
|
|
416
529
|
messages: vec![],
|
|
417
530
|
});
|
|
418
531
|
}
|
|
419
|
-
|
|
532
|
+
Ok(tui::TuiData {
|
|
420
533
|
account: string_at(&me, "/displayName")
|
|
421
534
|
.unwrap_or("Microsoft 365")
|
|
422
535
|
.into(),
|
|
@@ -424,6 +537,63 @@ async fn tui_command(profile_arg: Option<&str>, args: TuiArgs) -> Result<(), App
|
|
|
424
537
|
})
|
|
425
538
|
}
|
|
426
539
|
|
|
540
|
+
fn tui_auth_recoverable(error: &AppError) -> bool {
|
|
541
|
+
matches!(error, AppError::Auth(_))
|
|
542
|
+
|| matches!(error, AppError::InvalidInput(message) if message.contains("is not configured"))
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
fn tui_connection_reason(error: &AppError) -> String {
|
|
546
|
+
match error {
|
|
547
|
+
AppError::InvalidInput(message) if message.contains("is not configured") => {
|
|
548
|
+
"This profile has not been connected yet.".into()
|
|
549
|
+
}
|
|
550
|
+
AppError::Auth(message)
|
|
551
|
+
if message.contains("not signed in") || message.contains("unreadable") =>
|
|
552
|
+
{
|
|
553
|
+
"This profile does not have a usable Microsoft sign-in yet.".into()
|
|
554
|
+
}
|
|
555
|
+
AppError::Auth(message) if message.contains("expired") => {
|
|
556
|
+
"Your saved Microsoft session has expired.".into()
|
|
557
|
+
}
|
|
558
|
+
_ => "Microsoft rejected the current session.".into(),
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
fn concise_error(error: &AppError) -> String {
|
|
563
|
+
error.to_string().trim_end_matches('.').to_string()
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
#[cfg(test)]
|
|
567
|
+
mod tests {
|
|
568
|
+
use super::*;
|
|
569
|
+
|
|
570
|
+
#[test]
|
|
571
|
+
fn tui_recovers_configuration_and_authentication_only() {
|
|
572
|
+
assert!(tui_auth_recoverable(&AppError::InvalidInput(
|
|
573
|
+
"profile 'work' is not configured; run `teams init`".into()
|
|
574
|
+
)));
|
|
575
|
+
assert!(tui_auth_recoverable(&AppError::Auth(
|
|
576
|
+
"session expired".into()
|
|
577
|
+
)));
|
|
578
|
+
assert!(!tui_auth_recoverable(&AppError::InvalidInput(
|
|
579
|
+
"invalid field selection".into()
|
|
580
|
+
)));
|
|
581
|
+
assert!(!tui_auth_recoverable(&AppError::RateLimit(None)));
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
#[test]
|
|
585
|
+
fn tui_connection_copy_is_actionable_without_shell_instructions() {
|
|
586
|
+
let reason = tui_connection_reason(&AppError::Auth(
|
|
587
|
+
"not signed in; run `teams auth login`".into(),
|
|
588
|
+
));
|
|
589
|
+
assert_eq!(
|
|
590
|
+
reason,
|
|
591
|
+
"This profile does not have a usable Microsoft sign-in yet."
|
|
592
|
+
);
|
|
593
|
+
assert!(!reason.contains("teams auth login"));
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
427
597
|
#[derive(Serialize)]
|
|
428
598
|
struct Check {
|
|
429
599
|
name: &'static str,
|
|
@@ -431,7 +601,7 @@ struct Check {
|
|
|
431
601
|
detail: String,
|
|
432
602
|
}
|
|
433
603
|
|
|
434
|
-
async fn doctor(profile_arg: Option<&str>, out: Output) -> Result<(), AppError> {
|
|
604
|
+
async fn doctor(profile_arg: Option<&str>, offline: bool, out: Output) -> Result<(), AppError> {
|
|
435
605
|
let mut checks = Vec::new();
|
|
436
606
|
let configured = config::configured_profile(profile_arg);
|
|
437
607
|
checks.push(Check {
|
|
@@ -483,7 +653,7 @@ async fn doctor(profile_arg: Option<&str>, out: Output) -> Result<(), AppError>
|
|
|
483
653
|
});
|
|
484
654
|
let mut identity_ok = false;
|
|
485
655
|
let mut teams_ok = false;
|
|
486
|
-
let (identity_detail, teams_detail) = if configured.is_some() && signed_in {
|
|
656
|
+
let (identity_detail, teams_detail) = if !offline && configured.is_some() && signed_in {
|
|
487
657
|
match client(profile_arg).await {
|
|
488
658
|
Ok(client) => {
|
|
489
659
|
let identity_detail = match client.me().await {
|
|
@@ -513,21 +683,38 @@ async fn doctor(profile_arg: Option<&str>, out: Output) -> Result<(), AppError>
|
|
|
513
683
|
}
|
|
514
684
|
}
|
|
515
685
|
} else {
|
|
516
|
-
let detail =
|
|
686
|
+
let detail = if offline {
|
|
687
|
+
"network check skipped"
|
|
688
|
+
} else {
|
|
689
|
+
"skipped until setup is complete"
|
|
690
|
+
}
|
|
691
|
+
.to_string();
|
|
517
692
|
(detail.clone(), detail)
|
|
518
693
|
};
|
|
519
694
|
checks.push(Check {
|
|
520
695
|
name: "microsoft_identity",
|
|
521
|
-
status: if
|
|
696
|
+
status: if offline {
|
|
697
|
+
"skip"
|
|
698
|
+
} else if identity_ok {
|
|
699
|
+
"pass"
|
|
700
|
+
} else {
|
|
701
|
+
"fail"
|
|
702
|
+
},
|
|
522
703
|
detail: identity_detail,
|
|
523
704
|
});
|
|
524
705
|
checks.push(Check {
|
|
525
706
|
name: "microsoft_teams",
|
|
526
|
-
status: if
|
|
707
|
+
status: if offline {
|
|
708
|
+
"skip"
|
|
709
|
+
} else if teams_ok {
|
|
710
|
+
"pass"
|
|
711
|
+
} else {
|
|
712
|
+
"fail"
|
|
713
|
+
},
|
|
527
714
|
detail: teams_detail,
|
|
528
715
|
});
|
|
529
716
|
let healthy = checks.iter().all(|check| check.status != "fail");
|
|
530
|
-
let value = serde_json::json!({"checks":checks,"healthy":healthy});
|
|
717
|
+
let value = serde_json::json!({"checks":checks,"healthy":healthy,"offline":offline});
|
|
531
718
|
out.value(&value, || {
|
|
532
719
|
checks
|
|
533
720
|
.iter()
|
|
@@ -130,20 +130,57 @@ pub fn generate(command_filter: Option<&str>) -> Value {
|
|
|
130
130
|
"idempotent",
|
|
131
131
|
vec![field("profile", "string"), field("signed_in", "boolean")],
|
|
132
132
|
),
|
|
133
|
+
{
|
|
134
|
+
let mut value = single(
|
|
135
|
+
"auth status",
|
|
136
|
+
"Verify authentication and inspect credential state",
|
|
137
|
+
"read_only",
|
|
138
|
+
vec![
|
|
139
|
+
field("profile", "string"),
|
|
140
|
+
field("configured", "boolean"),
|
|
141
|
+
field("signed_in", "boolean"),
|
|
142
|
+
field("verified", "boolean"),
|
|
143
|
+
nullable_field("identity", "object"),
|
|
144
|
+
field("config_path", "string"),
|
|
145
|
+
field("read_only", "boolean"),
|
|
146
|
+
nullable_array_field("granted_scopes", "string"),
|
|
147
|
+
nullable_field("channel_history", "boolean"),
|
|
148
|
+
],
|
|
149
|
+
);
|
|
150
|
+
value["args"] = json!([arg(
|
|
151
|
+
"--offline",
|
|
152
|
+
"boolean",
|
|
153
|
+
"Inspect local credential state without contacting Microsoft Graph"
|
|
154
|
+
)]);
|
|
155
|
+
value
|
|
156
|
+
},
|
|
133
157
|
single(
|
|
134
|
-
"
|
|
135
|
-
"
|
|
158
|
+
"profile list",
|
|
159
|
+
"List configured profiles and identify the active one",
|
|
136
160
|
"read_only",
|
|
137
|
-
vec![
|
|
138
|
-
field("profile", "string"),
|
|
139
|
-
field("configured", "boolean"),
|
|
140
|
-
field("signed_in", "boolean"),
|
|
141
|
-
field("config_path", "string"),
|
|
142
|
-
field("read_only", "boolean"),
|
|
143
|
-
nullable_array_field("granted_scopes", "string"),
|
|
144
|
-
nullable_field("channel_history", "boolean"),
|
|
145
|
-
],
|
|
161
|
+
vec![array_field("items", "object"), field("total", "integer")],
|
|
146
162
|
),
|
|
163
|
+
{
|
|
164
|
+
let mut value = single(
|
|
165
|
+
"profile use",
|
|
166
|
+
"Select the default profile for future commands",
|
|
167
|
+
"idempotent",
|
|
168
|
+
vec![field("profile", "string"), field("active", "boolean")],
|
|
169
|
+
);
|
|
170
|
+
value["args"] = json!([required_arg("name", "string", "Profile name")]);
|
|
171
|
+
value
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
let mut value = single(
|
|
175
|
+
"profile remove",
|
|
176
|
+
"Remove a profile and its stored credential",
|
|
177
|
+
"idempotent",
|
|
178
|
+
vec![field("profile", "string"), field("removed", "boolean")],
|
|
179
|
+
);
|
|
180
|
+
value["args"] = json!([required_arg("name", "string", "Profile name")]);
|
|
181
|
+
value["confirmation_bypass_arg"] = json!("--yes");
|
|
182
|
+
value
|
|
183
|
+
},
|
|
147
184
|
single(
|
|
148
185
|
"config show",
|
|
149
186
|
"Print the resolved profile without credentials",
|
|
@@ -246,12 +283,20 @@ pub fn generate(command_filter: Option<&str>) -> Value {
|
|
|
246
283
|
value
|
|
247
284
|
},
|
|
248
285
|
json!({"name":"tui", "description":"Open the keyboard-first terminal interface", "effects":"read_only", "output_kind":"opaque", "media_type":"text/plain", "requires_tty":true, "args":[arg("--demo", "boolean", "Use deterministic sample data"), arg("--snapshot", "boolean", "Print one ANSI-free demo frame")]}),
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
286
|
+
{
|
|
287
|
+
let mut value = single(
|
|
288
|
+
"doctor",
|
|
289
|
+
"Check configuration, credential storage, Microsoft identity, and Teams provisioning",
|
|
290
|
+
"read_only",
|
|
291
|
+
vec![array_field("checks", "object"), field("healthy", "boolean")],
|
|
292
|
+
);
|
|
293
|
+
value["args"] = json!([arg(
|
|
294
|
+
"--offline",
|
|
295
|
+
"boolean",
|
|
296
|
+
"Check local state without contacting Microsoft Graph"
|
|
297
|
+
)]);
|
|
298
|
+
value
|
|
299
|
+
},
|
|
255
300
|
single(
|
|
256
301
|
"capabilities",
|
|
257
302
|
"Describe supported and deliberately unsupported capabilities",
|
|
@@ -287,7 +332,8 @@ pub fn generate(command_filter: Option<&str>) -> Value {
|
|
|
287
332
|
"global_args": [
|
|
288
333
|
{"name":"--output", "short":"-o", "type":"string", "enum":["auto","text","json"], "default":"auto", "description":"Output format"},
|
|
289
334
|
{"name":"--profile", "type":"string", "description":"Configuration profile"},
|
|
290
|
-
{"name":"--quiet", "type":"boolean", "description":"Suppress informational stderr output"}
|
|
335
|
+
{"name":"--quiet", "type":"boolean", "description":"Suppress informational stderr output"},
|
|
336
|
+
{"name":"--yes", "short":"-y", "type":"boolean", "description":"Skip confirmation prompts for destructive operations"}
|
|
291
337
|
],
|
|
292
338
|
"commands": commands,
|
|
293
339
|
"errors": error::ALL.iter().map(|e| json!({"kind":e.kind,"exit_code":e.exit_code,"retryable":e.retryable,"description":e.description})).collect::<Vec<_>>(),
|
|
@@ -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
|
}
|
|
@@ -142,6 +142,58 @@ fn init_can_create_a_read_only_profile_and_config_show_resolves_it() {
|
|
|
142
142
|
.stdout(predicate::str::contains("\"read_only\": true"));
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
#[test]
|
|
146
|
+
fn profile_lifecycle_and_offline_auth_status_are_local() {
|
|
147
|
+
let temp = tempfile::tempdir().unwrap();
|
|
148
|
+
for name in ["work", "sandbox"] {
|
|
149
|
+
Command::cargo_bin("teams")
|
|
150
|
+
.unwrap()
|
|
151
|
+
.env("XDG_CONFIG_HOME", temp.path())
|
|
152
|
+
.args(["--profile", name, "init", "--no-login"])
|
|
153
|
+
.assert()
|
|
154
|
+
.success();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
Command::cargo_bin("teams")
|
|
158
|
+
.unwrap()
|
|
159
|
+
.env("XDG_CONFIG_HOME", temp.path())
|
|
160
|
+
.args(["profile", "use", "work"])
|
|
161
|
+
.assert()
|
|
162
|
+
.success();
|
|
163
|
+
|
|
164
|
+
let list = Command::cargo_bin("teams")
|
|
165
|
+
.unwrap()
|
|
166
|
+
.env("XDG_CONFIG_HOME", temp.path())
|
|
167
|
+
.args(["--output", "json", "profile", "list"])
|
|
168
|
+
.output()
|
|
169
|
+
.unwrap();
|
|
170
|
+
assert!(list.status.success());
|
|
171
|
+
let value: Value = serde_json::from_slice(&list.stdout).unwrap();
|
|
172
|
+
assert_eq!(value["total"], 2);
|
|
173
|
+
assert!(
|
|
174
|
+
value["items"]
|
|
175
|
+
.as_array()
|
|
176
|
+
.unwrap()
|
|
177
|
+
.iter()
|
|
178
|
+
.any(|profile| { profile["name"] == "work" && profile["active"] == true })
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
Command::cargo_bin("teams")
|
|
182
|
+
.unwrap()
|
|
183
|
+
.env("XDG_CONFIG_HOME", temp.path())
|
|
184
|
+
.args(["auth", "status", "--offline"])
|
|
185
|
+
.assert()
|
|
186
|
+
.success()
|
|
187
|
+
.stdout(predicate::str::contains("\"verified\": false"));
|
|
188
|
+
|
|
189
|
+
Command::cargo_bin("teams")
|
|
190
|
+
.unwrap()
|
|
191
|
+
.env("XDG_CONFIG_HOME", temp.path())
|
|
192
|
+
.args(["profile", "remove", "sandbox", "--yes"])
|
|
193
|
+
.assert()
|
|
194
|
+
.success();
|
|
195
|
+
}
|
|
196
|
+
|
|
145
197
|
#[test]
|
|
146
198
|
fn suite_exit_codes_and_error_kinds_are_stable() {
|
|
147
199
|
let schema: Value = serde_json::from_slice(
|
|
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
|