teams-cli 0.1.5__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.5 → teams_cli-0.1.6}/CHANGELOG.md +10 -0
- {teams_cli-0.1.5 → teams_cli-0.1.6}/Cargo.lock +1 -1
- {teams_cli-0.1.5 → teams_cli-0.1.6}/Cargo.toml +1 -1
- {teams_cli-0.1.5 → teams_cli-0.1.6}/PKG-INFO +8 -2
- {teams_cli-0.1.5 → teams_cli-0.1.6}/README.md +7 -1
- {teams_cli-0.1.5 → teams_cli-0.1.6}/src/auth.rs +27 -0
- {teams_cli-0.1.5 → teams_cli-0.1.6}/src/cli.rs +29 -3
- {teams_cli-0.1.5 → teams_cli-0.1.6}/src/config.rs +56 -2
- {teams_cli-0.1.5 → teams_cli-0.1.6}/src/main.rs +87 -10
- {teams_cli-0.1.5 → teams_cli-0.1.6}/src/schema.rs +64 -18
- {teams_cli-0.1.5 → teams_cli-0.1.6}/tests/cli.rs +52 -0
- teams_cli-0.1.5/.github/dependabot.yml +0 -6
- {teams_cli-0.1.5 → teams_cli-0.1.6}/.github/workflows/ci.yml +0 -0
- {teams_cli-0.1.5 → teams_cli-0.1.6}/.github/workflows/release.yml +0 -0
- {teams_cli-0.1.5 → teams_cli-0.1.6}/.gitignore +0 -0
- {teams_cli-0.1.5 → teams_cli-0.1.6}/.mise.toml +0 -0
- {teams_cli-0.1.5 → teams_cli-0.1.6}/DESIGN.md +0 -0
- {teams_cli-0.1.5 → teams_cli-0.1.6}/LICENSE +0 -0
- {teams_cli-0.1.5 → teams_cli-0.1.6}/Makefile +0 -0
- {teams_cli-0.1.5 → teams_cli-0.1.6}/PRODUCT.md +0 -0
- {teams_cli-0.1.5 → teams_cli-0.1.6}/docs/releases.md +0 -0
- {teams_cli-0.1.5 → teams_cli-0.1.6}/examples/validate_schema.rs +0 -0
- {teams_cli-0.1.5 → teams_cli-0.1.6}/pyproject.toml +0 -0
- {teams_cli-0.1.5 → teams_cli-0.1.6}/src/error.rs +0 -0
- {teams_cli-0.1.5 → teams_cli-0.1.6}/src/graph.rs +0 -0
- {teams_cli-0.1.5 → teams_cli-0.1.6}/src/lib.rs +0 -0
- {teams_cli-0.1.5 → teams_cli-0.1.6}/src/output.rs +0 -0
- {teams_cli-0.1.5 → teams_cli-0.1.6}/src/teams_cli_alias.rs +0 -0
- {teams_cli-0.1.5 → teams_cli-0.1.6}/src/tui.rs +0 -0
- {teams_cli-0.1.5 → teams_cli-0.1.6}/tests/package_alias.rs +0 -0
- {teams_cli-0.1.5 → 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,16 @@ 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
|
+
|
|
10
20
|
## [0.1.5](https://github.com/rvben/teams-cli/compare/v0.1.4...v0.1.5) - 2026-08-26
|
|
11
21
|
|
|
12
22
|
### 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
|
|
@@ -115,8 +115,14 @@ teams auth login --channel-history # once, with administrator approval
|
|
|
115
115
|
teams messages list --team TEAM_ID --channel CHANNEL_ID
|
|
116
116
|
teams messages send --chat CHAT_ID --body "On it."
|
|
117
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
|
|
118
123
|
teams config show
|
|
119
|
-
teams
|
|
124
|
+
teams config path
|
|
125
|
+
teams doctor --offline
|
|
120
126
|
```
|
|
121
127
|
|
|
122
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`.
|
|
@@ -99,8 +99,14 @@ teams auth login --channel-history # once, with administrator approval
|
|
|
99
99
|
teams messages list --team TEAM_ID --channel CHANNEL_ID
|
|
100
100
|
teams messages send --chat CHAT_ID --body "On it."
|
|
101
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
|
|
102
107
|
teams config show
|
|
103
|
-
teams
|
|
108
|
+
teams config path
|
|
109
|
+
teams doctor --offline
|
|
104
110
|
```
|
|
105
111
|
|
|
106
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,8 +91,8 @@ 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> {
|
|
@@ -159,6 +173,46 @@ pub fn configured_profile(requested: Option<&str>) -> Option<(String, Profile)>
|
|
|
159
173
|
load(requested).ok()
|
|
160
174
|
}
|
|
161
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
|
+
|
|
162
216
|
fn read_file() -> Result<ConfigFile, AppError> {
|
|
163
217
|
match std::fs::read_to_string(path()) {
|
|
164
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?;
|
|
@@ -541,7 +601,7 @@ struct Check {
|
|
|
541
601
|
detail: String,
|
|
542
602
|
}
|
|
543
603
|
|
|
544
|
-
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> {
|
|
545
605
|
let mut checks = Vec::new();
|
|
546
606
|
let configured = config::configured_profile(profile_arg);
|
|
547
607
|
checks.push(Check {
|
|
@@ -593,7 +653,7 @@ async fn doctor(profile_arg: Option<&str>, out: Output) -> Result<(), AppError>
|
|
|
593
653
|
});
|
|
594
654
|
let mut identity_ok = false;
|
|
595
655
|
let mut teams_ok = false;
|
|
596
|
-
let (identity_detail, teams_detail) = if configured.is_some() && signed_in {
|
|
656
|
+
let (identity_detail, teams_detail) = if !offline && configured.is_some() && signed_in {
|
|
597
657
|
match client(profile_arg).await {
|
|
598
658
|
Ok(client) => {
|
|
599
659
|
let identity_detail = match client.me().await {
|
|
@@ -623,21 +683,38 @@ async fn doctor(profile_arg: Option<&str>, out: Output) -> Result<(), AppError>
|
|
|
623
683
|
}
|
|
624
684
|
}
|
|
625
685
|
} else {
|
|
626
|
-
let detail =
|
|
686
|
+
let detail = if offline {
|
|
687
|
+
"network check skipped"
|
|
688
|
+
} else {
|
|
689
|
+
"skipped until setup is complete"
|
|
690
|
+
}
|
|
691
|
+
.to_string();
|
|
627
692
|
(detail.clone(), detail)
|
|
628
693
|
};
|
|
629
694
|
checks.push(Check {
|
|
630
695
|
name: "microsoft_identity",
|
|
631
|
-
status: if
|
|
696
|
+
status: if offline {
|
|
697
|
+
"skip"
|
|
698
|
+
} else if identity_ok {
|
|
699
|
+
"pass"
|
|
700
|
+
} else {
|
|
701
|
+
"fail"
|
|
702
|
+
},
|
|
632
703
|
detail: identity_detail,
|
|
633
704
|
});
|
|
634
705
|
checks.push(Check {
|
|
635
706
|
name: "microsoft_teams",
|
|
636
|
-
status: if
|
|
707
|
+
status: if offline {
|
|
708
|
+
"skip"
|
|
709
|
+
} else if teams_ok {
|
|
710
|
+
"pass"
|
|
711
|
+
} else {
|
|
712
|
+
"fail"
|
|
713
|
+
},
|
|
637
714
|
detail: teams_detail,
|
|
638
715
|
});
|
|
639
716
|
let healthy = checks.iter().all(|check| check.status != "fail");
|
|
640
|
-
let value = serde_json::json!({"checks":checks,"healthy":healthy});
|
|
717
|
+
let value = serde_json::json!({"checks":checks,"healthy":healthy,"offline":offline});
|
|
641
718
|
out.value(&value, || {
|
|
642
719
|
checks
|
|
643
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<_>>(),
|
|
@@ -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
|
|
File without changes
|