teams-cli 0.1.0__tar.gz → 0.1.2__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.0 → teams_cli-0.1.2}/.github/workflows/release.yml +5 -1
- {teams_cli-0.1.0 → teams_cli-0.1.2}/CHANGELOG.md +23 -0
- {teams_cli-0.1.0 → teams_cli-0.1.2}/Cargo.lock +1 -1
- {teams_cli-0.1.0 → teams_cli-0.1.2}/Cargo.toml +1 -1
- {teams_cli-0.1.0 → teams_cli-0.1.2}/PKG-INFO +27 -8
- {teams_cli-0.1.0 → teams_cli-0.1.2}/PRODUCT.md +2 -1
- {teams_cli-0.1.0 → teams_cli-0.1.2}/README.md +26 -7
- {teams_cli-0.1.0 → teams_cli-0.1.2}/src/auth.rs +67 -9
- {teams_cli-0.1.0 → teams_cli-0.1.2}/src/cli.rs +8 -1
- {teams_cli-0.1.0 → teams_cli-0.1.2}/src/config.rs +5 -1
- {teams_cli-0.1.0 → teams_cli-0.1.2}/src/graph.rs +40 -3
- {teams_cli-0.1.0 → teams_cli-0.1.2}/src/main.rs +102 -55
- {teams_cli-0.1.0 → teams_cli-0.1.2}/src/schema.rs +42 -6
- {teams_cli-0.1.0 → teams_cli-0.1.2}/tests/cli.rs +39 -10
- {teams_cli-0.1.0 → teams_cli-0.1.2}/.github/dependabot.yml +0 -0
- {teams_cli-0.1.0 → teams_cli-0.1.2}/.github/workflows/ci.yml +0 -0
- {teams_cli-0.1.0 → teams_cli-0.1.2}/.gitignore +0 -0
- {teams_cli-0.1.0 → teams_cli-0.1.2}/DESIGN.md +0 -0
- {teams_cli-0.1.0 → teams_cli-0.1.2}/LICENSE +0 -0
- {teams_cli-0.1.0 → teams_cli-0.1.2}/Makefile +0 -0
- {teams_cli-0.1.0 → teams_cli-0.1.2}/examples/validate_schema.rs +0 -0
- {teams_cli-0.1.0 → teams_cli-0.1.2}/pyproject.toml +0 -0
- {teams_cli-0.1.0 → teams_cli-0.1.2}/src/error.rs +0 -0
- {teams_cli-0.1.0 → teams_cli-0.1.2}/src/lib.rs +0 -0
- {teams_cli-0.1.0 → teams_cli-0.1.2}/src/output.rs +0 -0
- {teams_cli-0.1.0 → teams_cli-0.1.2}/src/tui.rs +0 -0
|
@@ -179,7 +179,11 @@ jobs:
|
|
|
179
179
|
shell: bash
|
|
180
180
|
run: |
|
|
181
181
|
version="${GITHUB_REF_NAME#v}"
|
|
182
|
-
|
|
182
|
+
response="$(curl --fail --silent --show-error --location \
|
|
183
|
+
--user-agent "teams-cli-release-workflow" \
|
|
184
|
+
"https://crates.io/api/v1/crates/teams-cli")"
|
|
185
|
+
if jq --exit-status --arg version "$version" \
|
|
186
|
+
'.versions[] | select(.num == $version)' <<< "$response" > /dev/null; then
|
|
183
187
|
echo "exists=true" >> "$GITHUB_OUTPUT"
|
|
184
188
|
else
|
|
185
189
|
echo "exists=false" >> "$GITHUB_OUTPUT"
|
|
@@ -5,6 +5,26 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [0.1.2] - 2026-08-24
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Bundled the maintained multitenant teams-cli Entra public-client registration for zero-ID onboarding.
|
|
15
|
+
- Added explicit `--channel-history` consent and machine-readable authentication capability metadata.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Normal login and token refresh now use only the user-consentable delegated permission baseline.
|
|
20
|
+
- Diagnostics now distinguish successful Microsoft identity access from an unlicensed or unprovisioned Teams tenant.
|
|
21
|
+
|
|
22
|
+
## [0.1.1] - 2026-08-24
|
|
23
|
+
|
|
24
|
+
### Fixed
|
|
25
|
+
|
|
26
|
+
- Made crates.io publication idempotent by checking the registry's version list with a privacy-preserving User-Agent before requesting publish credentials.
|
|
27
|
+
|
|
8
28
|
## [0.1.0] - 2026-08-24
|
|
9
29
|
|
|
10
30
|
### Added
|
|
@@ -16,4 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
16
36
|
- Guided onboarding, diagnostics, completions, bounded collections, and deterministic TUI snapshots.
|
|
17
37
|
- Distribution through crates.io, PyPI binary wheels, and signed GitHub release artifacts.
|
|
18
38
|
|
|
39
|
+
[Unreleased]: https://github.com/rvben/teams-cli/compare/v0.1.2...HEAD
|
|
40
|
+
[0.1.2]: https://github.com/rvben/teams-cli/compare/v0.1.1...v0.1.2
|
|
41
|
+
[0.1.1]: https://github.com/rvben/teams-cli/compare/v0.1.0...v0.1.1
|
|
19
42
|
[0.1.0]: https://github.com/rvben/teams-cli/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: teams-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.2
|
|
4
4
|
Classifier: Programming Language :: Rust
|
|
5
5
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
6
6
|
License-File: LICENSE
|
|
@@ -57,28 +57,44 @@ Run `teams` in a terminal on first use, or start explicitly:
|
|
|
57
57
|
teams init
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
`teams-cli` includes a maintained multitenant Microsoft Entra public-client registration, so normal setup does not require creating an app or copying a client ID:
|
|
61
61
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
62
|
+
```console
|
|
63
|
+
teams init
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Browser PKCE is the human-friendly default. The browser talks directly to Microsoft, and the CLI never receives or stores your password. Normal sign-in requests these delegated scopes:
|
|
65
67
|
|
|
66
68
|
```text
|
|
67
69
|
openid profile offline_access User.Read Team.ReadBasic.All
|
|
68
70
|
Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send
|
|
69
|
-
ChannelMessage.Send
|
|
71
|
+
ChannelMessage.Send
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Reading channel history is intentionally separate because `ChannelMessage.Read.All` requires administrator consent. Request it only when needed:
|
|
75
|
+
|
|
76
|
+
```console
|
|
77
|
+
teams auth login --channel-history
|
|
70
78
|
```
|
|
71
79
|
|
|
72
|
-
|
|
80
|
+
The CLI surfaces missing consent as a stable `permission_denied` error instead of silently reaching for undocumented Teams APIs. Tenant policy can still require an administrator to approve otherwise user-consentable permissions.
|
|
73
81
|
|
|
74
82
|
Headless setup is explicit and never prompts:
|
|
75
83
|
|
|
76
84
|
```console
|
|
77
|
-
teams init --
|
|
85
|
+
teams init --no-login
|
|
78
86
|
teams auth login --device-code
|
|
79
87
|
teams whoami --output json
|
|
80
88
|
```
|
|
81
89
|
|
|
90
|
+
Organizations that prefer their own Entra registration can override the bundled application:
|
|
91
|
+
|
|
92
|
+
```console
|
|
93
|
+
teams init --client-id "$TEAMS_CLIENT_ID" --tenant organizations --no-login
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The custom app must be a public client with `http://localhost` registered for browser login, public-client flows enabled for device-code login, and the baseline delegated permissions listed above.
|
|
97
|
+
|
|
82
98
|
For short-lived automation, `TEAMS_ACCESS_TOKEN` overrides the stored credential. Refresh credentials are never written to the config file.
|
|
83
99
|
|
|
84
100
|
## Everyday commands
|
|
@@ -88,6 +104,8 @@ teams list
|
|
|
88
104
|
teams channels list TEAM_ID
|
|
89
105
|
teams chats list --limit 25
|
|
90
106
|
teams messages list --chat CHAT_ID
|
|
107
|
+
teams auth login --channel-history # once, with administrator approval
|
|
108
|
+
teams messages list --team TEAM_ID --channel CHANNEL_ID
|
|
91
109
|
teams messages send --chat CHAT_ID --body "On it."
|
|
92
110
|
printf 'Status update' | teams messages send --chat CHAT_ID --body -
|
|
93
111
|
teams doctor
|
|
@@ -98,6 +116,7 @@ Collection commands expose `--limit` and `--fields`; Graph-native collections us
|
|
|
98
116
|
## Boundaries
|
|
99
117
|
|
|
100
118
|
- Microsoft 365 work or school accounts only. The relevant Teams Graph endpoints do not support personal Microsoft accounts.
|
|
119
|
+
- A work account can authenticate successfully before Microsoft Teams is licensed or provisioned; `teams doctor` distinguishes identity access from Teams availability.
|
|
101
120
|
- Calling and screen sharing are not available as a delegated user CLI; Graph calling is built around bot/application scenarios.
|
|
102
121
|
- No extracted desktop tokens, browser interception, or private Teams protocols.
|
|
103
122
|
- Tenant policy and admin consent can restrict delegated capabilities.
|
|
@@ -29,12 +29,13 @@ A human-first and agent-first Microsoft Teams terminal client: a polished TUI an
|
|
|
29
29
|
- Installed from PyPI, preferably with `pipx install teams-cli`, despite the implementation being Rust.
|
|
30
30
|
- Invoked as `teams` alongside sibling tools such as `jira` and `confluence`.
|
|
31
31
|
- Used interactively in a local terminal, non-interactively in shell pipelines, and by AI agents.
|
|
32
|
-
- Authenticates a Microsoft 365 work or school account through
|
|
32
|
+
- Authenticates a Microsoft 365 work or school account through the maintained teams-cli Entra public-client application, with an explicit override for customer-owned registrations.
|
|
33
33
|
- Opens the native or web Teams client for experiences that Microsoft Graph does not expose, such as normal user calls.
|
|
34
34
|
|
|
35
35
|
## Capabilities and Constraints
|
|
36
36
|
|
|
37
37
|
- Browser-based delegated OAuth with PKCE is the preferred login; device-code flow is an explicit headless fallback.
|
|
38
|
+
- Everyday commands use a user-consentable baseline; administrator-consented channel history is a deliberate login opt-in.
|
|
38
39
|
- Refresh credentials must use OS-protected storage and must never be written as plaintext tokens.
|
|
39
40
|
- Initial product surface covers onboarding, authentication diagnostics, identity, teams, channels, chats, messages, and the TUI foundation.
|
|
40
41
|
- Commands offer human text and structured JSON output, with clean stdout/stderr separation.
|
|
@@ -41,28 +41,44 @@ Run `teams` in a terminal on first use, or start explicitly:
|
|
|
41
41
|
teams init
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
|
|
44
|
+
`teams-cli` includes a maintained multitenant Microsoft Entra public-client registration, so normal setup does not require creating an app or copying a client ID:
|
|
45
45
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
46
|
+
```console
|
|
47
|
+
teams init
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Browser PKCE is the human-friendly default. The browser talks directly to Microsoft, and the CLI never receives or stores your password. Normal sign-in requests these delegated scopes:
|
|
49
51
|
|
|
50
52
|
```text
|
|
51
53
|
openid profile offline_access User.Read Team.ReadBasic.All
|
|
52
54
|
Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send
|
|
53
|
-
ChannelMessage.Send
|
|
55
|
+
ChannelMessage.Send
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Reading channel history is intentionally separate because `ChannelMessage.Read.All` requires administrator consent. Request it only when needed:
|
|
59
|
+
|
|
60
|
+
```console
|
|
61
|
+
teams auth login --channel-history
|
|
54
62
|
```
|
|
55
63
|
|
|
56
|
-
|
|
64
|
+
The CLI surfaces missing consent as a stable `permission_denied` error instead of silently reaching for undocumented Teams APIs. Tenant policy can still require an administrator to approve otherwise user-consentable permissions.
|
|
57
65
|
|
|
58
66
|
Headless setup is explicit and never prompts:
|
|
59
67
|
|
|
60
68
|
```console
|
|
61
|
-
teams init --
|
|
69
|
+
teams init --no-login
|
|
62
70
|
teams auth login --device-code
|
|
63
71
|
teams whoami --output json
|
|
64
72
|
```
|
|
65
73
|
|
|
74
|
+
Organizations that prefer their own Entra registration can override the bundled application:
|
|
75
|
+
|
|
76
|
+
```console
|
|
77
|
+
teams init --client-id "$TEAMS_CLIENT_ID" --tenant organizations --no-login
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The custom app must be a public client with `http://localhost` registered for browser login, public-client flows enabled for device-code login, and the baseline delegated permissions listed above.
|
|
81
|
+
|
|
66
82
|
For short-lived automation, `TEAMS_ACCESS_TOKEN` overrides the stored credential. Refresh credentials are never written to the config file.
|
|
67
83
|
|
|
68
84
|
## Everyday commands
|
|
@@ -72,6 +88,8 @@ teams list
|
|
|
72
88
|
teams channels list TEAM_ID
|
|
73
89
|
teams chats list --limit 25
|
|
74
90
|
teams messages list --chat CHAT_ID
|
|
91
|
+
teams auth login --channel-history # once, with administrator approval
|
|
92
|
+
teams messages list --team TEAM_ID --channel CHANNEL_ID
|
|
75
93
|
teams messages send --chat CHAT_ID --body "On it."
|
|
76
94
|
printf 'Status update' | teams messages send --chat CHAT_ID --body -
|
|
77
95
|
teams doctor
|
|
@@ -82,6 +100,7 @@ Collection commands expose `--limit` and `--fields`; Graph-native collections us
|
|
|
82
100
|
## Boundaries
|
|
83
101
|
|
|
84
102
|
- Microsoft 365 work or school accounts only. The relevant Teams Graph endpoints do not support personal Microsoft accounts.
|
|
103
|
+
- A work account can authenticate successfully before Microsoft Teams is licensed or provisioned; `teams doctor` distinguishes identity access from Teams availability.
|
|
85
104
|
- Calling and screen sharing are not available as a delegated user CLI; Graph calling is built around bot/application scenarios.
|
|
86
105
|
- No extracted desktop tokens, browser interception, or private Teams protocols.
|
|
87
106
|
- Tenant policy and admin consent can restrict delegated capabilities.
|
|
@@ -12,7 +12,9 @@ use crate::config::Profile;
|
|
|
12
12
|
use crate::error::AppError;
|
|
13
13
|
use crate::output::Output;
|
|
14
14
|
|
|
15
|
-
pub const
|
|
15
|
+
pub const BASE_SCOPES: &str = "openid profile offline_access User.Read Team.ReadBasic.All Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send ChannelMessage.Send";
|
|
16
|
+
pub const CHANNEL_HISTORY_SCOPE: &str = "ChannelMessage.Read.All";
|
|
17
|
+
pub const CHANNEL_HISTORY_SCOPES: &str = "openid profile offline_access User.Read Team.ReadBasic.All Channel.ReadBasic.All Chat.Read Chat.Create ChatMessage.Send ChannelMessage.Send ChannelMessage.Read.All";
|
|
16
18
|
const SERVICE: &str = "teams-cli";
|
|
17
19
|
|
|
18
20
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
@@ -59,6 +61,28 @@ pub fn has_token(profile_name: &str) -> bool {
|
|
|
59
61
|
.is_ok()
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
pub fn granted_scopes(profile_name: &str) -> Option<Vec<String>> {
|
|
65
|
+
if std::env::var("TEAMS_ACCESS_TOKEN").is_ok() {
|
|
66
|
+
return None;
|
|
67
|
+
}
|
|
68
|
+
load(profile_name).ok().and_then(|token| {
|
|
69
|
+
token.scope.map(|scope| {
|
|
70
|
+
scope
|
|
71
|
+
.split_whitespace()
|
|
72
|
+
.map(str::to_owned)
|
|
73
|
+
.collect::<Vec<_>>()
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
pub fn channel_history_granted(profile_name: &str) -> Option<bool> {
|
|
79
|
+
granted_scopes(profile_name).map(|scopes| {
|
|
80
|
+
scopes
|
|
81
|
+
.iter()
|
|
82
|
+
.any(|scope| scope.eq_ignore_ascii_case(CHANNEL_HISTORY_SCOPE))
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
62
86
|
fn store(profile_name: &str, token: &TokenBundle) -> Result<(), AppError> {
|
|
63
87
|
let encoded = serde_json::to_string(token).map_err(|e| AppError::Unexpected(e.to_string()))?;
|
|
64
88
|
entry(profile_name)?.set_password(&encoded).map_err(|e| {
|
|
@@ -105,7 +129,7 @@ pub async fn access_token(profile_name: &str, profile: &Profile) -> Result<Strin
|
|
|
105
129
|
("client_id", profile.client_id.as_str()),
|
|
106
130
|
("grant_type", "refresh_token"),
|
|
107
131
|
("refresh_token", refresh),
|
|
108
|
-
("scope",
|
|
132
|
+
("scope", bundle.scope.as_deref().unwrap_or(BASE_SCOPES)),
|
|
109
133
|
])
|
|
110
134
|
.send()
|
|
111
135
|
.await
|
|
@@ -123,18 +147,32 @@ pub async fn login(
|
|
|
123
147
|
profile_name: &str,
|
|
124
148
|
profile: &Profile,
|
|
125
149
|
device_code: bool,
|
|
150
|
+
channel_history: bool,
|
|
126
151
|
out: &Output,
|
|
127
152
|
) -> Result<TokenBundle, AppError> {
|
|
153
|
+
let scopes = scopes(channel_history);
|
|
128
154
|
let bundle = if device_code {
|
|
129
|
-
device_login(profile, out).await?
|
|
155
|
+
device_login(profile, scopes, out).await?
|
|
130
156
|
} else {
|
|
131
|
-
browser_login(profile, out).await?
|
|
157
|
+
browser_login(profile, scopes, out).await?
|
|
132
158
|
};
|
|
133
159
|
store(profile_name, &bundle)?;
|
|
134
160
|
Ok(bundle)
|
|
135
161
|
}
|
|
136
162
|
|
|
137
|
-
|
|
163
|
+
fn scopes(channel_history: bool) -> &'static str {
|
|
164
|
+
if channel_history {
|
|
165
|
+
CHANNEL_HISTORY_SCOPES
|
|
166
|
+
} else {
|
|
167
|
+
BASE_SCOPES
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async fn browser_login(
|
|
172
|
+
profile: &Profile,
|
|
173
|
+
scopes: &str,
|
|
174
|
+
out: &Output,
|
|
175
|
+
) -> Result<TokenBundle, AppError> {
|
|
138
176
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
139
177
|
let redirect = format!("http://localhost:{}", listener.local_addr()?.port());
|
|
140
178
|
let mut verifier_bytes = [0u8; 32];
|
|
@@ -156,7 +194,7 @@ async fn browser_login(profile: &Profile, out: &Output) -> Result<TokenBundle, A
|
|
|
156
194
|
.append_pair("response_type", "code")
|
|
157
195
|
.append_pair("redirect_uri", &redirect)
|
|
158
196
|
.append_pair("response_mode", "query")
|
|
159
|
-
.append_pair("scope",
|
|
197
|
+
.append_pair("scope", scopes)
|
|
160
198
|
.append_pair("code_challenge", &challenge)
|
|
161
199
|
.append_pair("code_challenge_method", "S256")
|
|
162
200
|
.append_pair("state", &state);
|
|
@@ -217,7 +255,7 @@ async fn browser_login(profile: &Profile, out: &Output) -> Result<TokenBundle, A
|
|
|
217
255
|
("code", code.as_str()),
|
|
218
256
|
("redirect_uri", redirect.as_str()),
|
|
219
257
|
("code_verifier", verifier.as_str()),
|
|
220
|
-
("scope",
|
|
258
|
+
("scope", scopes),
|
|
221
259
|
])
|
|
222
260
|
.send()
|
|
223
261
|
.await
|
|
@@ -235,13 +273,17 @@ struct DeviceCode {
|
|
|
235
273
|
message: Option<String>,
|
|
236
274
|
}
|
|
237
275
|
|
|
238
|
-
async fn device_login(
|
|
276
|
+
async fn device_login(
|
|
277
|
+
profile: &Profile,
|
|
278
|
+
scopes: &str,
|
|
279
|
+
out: &Output,
|
|
280
|
+
) -> Result<TokenBundle, AppError> {
|
|
239
281
|
let response = reqwest::Client::new()
|
|
240
282
|
.post(format!(
|
|
241
283
|
"https://login.microsoftonline.com/{}/oauth2/v2.0/devicecode",
|
|
242
284
|
profile.tenant
|
|
243
285
|
))
|
|
244
|
-
.form(&[("client_id", profile.client_id.as_str()), ("scope",
|
|
286
|
+
.form(&[("client_id", profile.client_id.as_str()), ("scope", scopes)])
|
|
245
287
|
.send()
|
|
246
288
|
.await
|
|
247
289
|
.map_err(|e| AppError::Unexpected(format!("sign-in service is unreachable: {e}")))?;
|
|
@@ -328,3 +370,19 @@ async fn oauth_response_error(response: reqwest::Response) -> AppError {
|
|
|
328
370
|
.unwrap_or_else(|| format!("Microsoft sign-in failed with {status}")),
|
|
329
371
|
)
|
|
330
372
|
}
|
|
373
|
+
|
|
374
|
+
#[cfg(test)]
|
|
375
|
+
mod tests {
|
|
376
|
+
use super::{BASE_SCOPES, CHANNEL_HISTORY_SCOPE, scopes};
|
|
377
|
+
|
|
378
|
+
#[test]
|
|
379
|
+
fn default_login_is_least_privilege() {
|
|
380
|
+
assert!(!BASE_SCOPES.contains(CHANNEL_HISTORY_SCOPE));
|
|
381
|
+
assert_eq!(scopes(false), BASE_SCOPES);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#[test]
|
|
385
|
+
fn channel_history_is_explicitly_opted_in() {
|
|
386
|
+
assert!(scopes(true).contains(CHANNEL_HISTORY_SCOPE));
|
|
387
|
+
}
|
|
388
|
+
}
|
|
@@ -72,7 +72,7 @@ pub enum Command {
|
|
|
72
72
|
|
|
73
73
|
#[derive(Debug, Args)]
|
|
74
74
|
pub struct InitArgs {
|
|
75
|
-
///
|
|
75
|
+
/// Override the maintained teams-cli public-client application ID
|
|
76
76
|
#[arg(long, env = "TEAMS_CLIENT_ID")]
|
|
77
77
|
pub client_id: Option<String>,
|
|
78
78
|
/// Tenant ID/domain, or `organizations`
|
|
@@ -84,14 +84,21 @@ pub struct InitArgs {
|
|
|
84
84
|
/// Use device-code sign-in instead of browser PKCE
|
|
85
85
|
#[arg(long)]
|
|
86
86
|
pub device_code: bool,
|
|
87
|
+
/// Request admin-consented access to read channel history
|
|
88
|
+
#[arg(long)]
|
|
89
|
+
pub channel_history: bool,
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
#[derive(Debug, Subcommand)]
|
|
90
93
|
pub enum AuthCommand {
|
|
91
94
|
/// Sign in with delegated OAuth
|
|
92
95
|
Login {
|
|
96
|
+
/// Use device-code sign-in for headless and remote environments
|
|
93
97
|
#[arg(long)]
|
|
94
98
|
device_code: bool,
|
|
99
|
+
/// Request admin-consented access to read channel history
|
|
100
|
+
#[arg(long)]
|
|
101
|
+
channel_history: bool,
|
|
95
102
|
},
|
|
96
103
|
/// Remove locally stored credentials
|
|
97
104
|
Logout,
|
|
@@ -5,6 +5,10 @@ use serde::{Deserialize, Serialize};
|
|
|
5
5
|
|
|
6
6
|
use crate::error::AppError;
|
|
7
7
|
|
|
8
|
+
/// The maintained, multitenant public-client registration shipped with teams-cli.
|
|
9
|
+
pub const DEFAULT_CLIENT_ID: &str = "66ebad71-1604-48fc-a086-0d4caa24988b";
|
|
10
|
+
pub const DEFAULT_TENANT: &str = "organizations";
|
|
11
|
+
|
|
8
12
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
9
13
|
pub struct Profile {
|
|
10
14
|
pub client_id: String,
|
|
@@ -13,7 +17,7 @@ pub struct Profile {
|
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
fn default_tenant() -> String {
|
|
16
|
-
|
|
20
|
+
DEFAULT_TENANT.into()
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
#[derive(Debug, Default, Serialize, Deserialize)]
|
|
@@ -47,6 +47,12 @@ impl GraphClient {
|
|
|
47
47
|
.await
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
pub async fn teams_available(&self) -> Result<(), AppError> {
|
|
51
|
+
self.get_value(&format!("{GRAPH}/me/joinedTeams"))
|
|
52
|
+
.await
|
|
53
|
+
.map(|_| ())
|
|
54
|
+
}
|
|
55
|
+
|
|
50
56
|
pub async fn channels(&self, team: &str, limit: u16, offset: usize) -> Result<Page, AppError> {
|
|
51
57
|
self.get_offset_page(
|
|
52
58
|
&format!(
|
|
@@ -242,15 +248,33 @@ impl GraphClient {
|
|
|
242
248
|
.unwrap_or("Microsoft Graph rejected the request");
|
|
243
249
|
match status.as_u16() {
|
|
244
250
|
401 => Err(AppError::Auth(format!("{message}; run `teams auth login`"))),
|
|
245
|
-
403 => Err(
|
|
246
|
-
"{message}; check delegated scopes and tenant consent with `teams doctor`"
|
|
247
|
-
))),
|
|
251
|
+
403 => Err(permission_error(message)),
|
|
248
252
|
404 => Err(AppError::NotFound(message.into())),
|
|
249
253
|
_ => Err(AppError::Api(format!("{message} ({status})"))),
|
|
250
254
|
}
|
|
251
255
|
}
|
|
252
256
|
}
|
|
253
257
|
|
|
258
|
+
fn permission_error(message: &str) -> AppError {
|
|
259
|
+
let message = message.trim();
|
|
260
|
+
let lower = message.to_ascii_lowercase();
|
|
261
|
+
let hint = if lower.contains("hasn't been provisioned")
|
|
262
|
+
|| lower.contains("has not been provisioned")
|
|
263
|
+
|| lower.contains("valid office365 subscription")
|
|
264
|
+
|| lower.contains("valid microsoft 365 subscription")
|
|
265
|
+
{
|
|
266
|
+
"sign in with a work or school account that has Microsoft Teams enabled"
|
|
267
|
+
} else {
|
|
268
|
+
"check delegated scopes and tenant consent with `teams doctor`"
|
|
269
|
+
};
|
|
270
|
+
let separator = if matches!(message.chars().last(), Some('.' | '!' | '?')) {
|
|
271
|
+
" "
|
|
272
|
+
} else {
|
|
273
|
+
"; "
|
|
274
|
+
};
|
|
275
|
+
AppError::Permission(format!("{message}{separator}{hint}"))
|
|
276
|
+
}
|
|
277
|
+
|
|
254
278
|
fn is_graph_url(raw: &str) -> bool {
|
|
255
279
|
url::Url::parse(raw).is_ok_and(|url| {
|
|
256
280
|
url.scheme() == "https"
|
|
@@ -305,4 +329,17 @@ mod tests {
|
|
|
305
329
|
"http://graph.microsoft.com/v1.0/me/chats"
|
|
306
330
|
));
|
|
307
331
|
}
|
|
332
|
+
|
|
333
|
+
#[test]
|
|
334
|
+
fn unprovisioned_tenants_get_a_teams_specific_hint() {
|
|
335
|
+
let error = super::permission_error(
|
|
336
|
+
"Microsoft Teams hasn't been provisioned on the tenant. Ensure the tenant has a valid Office365 subscription.",
|
|
337
|
+
);
|
|
338
|
+
assert!(
|
|
339
|
+
error
|
|
340
|
+
.to_string()
|
|
341
|
+
.contains("account that has Microsoft Teams enabled")
|
|
342
|
+
);
|
|
343
|
+
assert!(!error.to_string().contains(".;"));
|
|
344
|
+
}
|
|
308
345
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
use std::io::{self, IsTerminal, Read
|
|
1
|
+
use std::io::{self, IsTerminal, Read};
|
|
2
2
|
|
|
3
3
|
use clap::{CommandFactory, Parser};
|
|
4
4
|
use serde::Serialize;
|
|
@@ -59,6 +59,7 @@ async fn dispatch(cli: Cli, out: Output) -> Result<(), AppError> {
|
|
|
59
59
|
tenant: "organizations".into(),
|
|
60
60
|
no_login: false,
|
|
61
61
|
device_code: false,
|
|
62
|
+
channel_history: false,
|
|
62
63
|
})
|
|
63
64
|
}
|
|
64
65
|
None if io::stdin().is_terminal() && io::stdout().is_terminal() => Command::Tui(TuiArgs {
|
|
@@ -178,24 +179,13 @@ async fn init(profile_name: &str, args: InitArgs, out: Output) -> Result<(), App
|
|
|
178
179
|
let interactive = io::stdin().is_terminal() && io::stderr().is_terminal();
|
|
179
180
|
if interactive && !out.json() {
|
|
180
181
|
eprintln!("\n teams\n Microsoft Teams, without leaving your flow.\n");
|
|
181
|
-
eprintln!("
|
|
182
|
-
eprintln!(
|
|
183
|
-
|
|
184
|
-
);
|
|
185
|
-
eprintln!(" 2. Register a multitenant app for organizational accounts.");
|
|
186
|
-
eprintln!(" 3. Add Mobile and desktop redirect URI: http://localhost");
|
|
187
|
-
eprintln!(
|
|
188
|
-
" 4. Enable public client flows and grant the delegated permissions in README.md."
|
|
189
|
-
);
|
|
190
|
-
eprintln!(
|
|
191
|
-
" Work or school accounts are supported; personal Microsoft accounts are not.\n"
|
|
192
|
-
);
|
|
182
|
+
eprintln!(" We’ll open Microsoft sign-in in your browser.");
|
|
183
|
+
eprintln!(" No password or client secret is shared with teams-cli.");
|
|
184
|
+
eprintln!(" Work or school accounts are supported; personal accounts are not.\n");
|
|
193
185
|
}
|
|
194
|
-
let client_id =
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
None => return Err(AppError::NonInteractive("--client-id is required without a terminal; example: `teams init --client-id <uuid> --no-login`".into())),
|
|
198
|
-
};
|
|
186
|
+
let client_id = args
|
|
187
|
+
.client_id
|
|
188
|
+
.unwrap_or_else(|| config::DEFAULT_CLIENT_ID.into());
|
|
199
189
|
if client_id.trim().is_empty() {
|
|
200
190
|
return Err(AppError::InvalidInput("client ID cannot be empty".into()));
|
|
201
191
|
}
|
|
@@ -207,14 +197,21 @@ async fn init(profile_name: &str, args: InitArgs, out: Output) -> Result<(), App
|
|
|
207
197
|
let path = config::save(
|
|
208
198
|
profile_name,
|
|
209
199
|
Profile {
|
|
210
|
-
client_id,
|
|
211
|
-
tenant: args.tenant,
|
|
200
|
+
client_id: client_id.clone(),
|
|
201
|
+
tenant: args.tenant.clone(),
|
|
212
202
|
},
|
|
213
203
|
)?;
|
|
214
204
|
let mut signed_in = false;
|
|
215
205
|
if !args.no_login {
|
|
216
206
|
let (_, profile) = config::load(Some(profile_name))?;
|
|
217
|
-
auth::login(
|
|
207
|
+
auth::login(
|
|
208
|
+
profile_name,
|
|
209
|
+
&profile,
|
|
210
|
+
args.device_code,
|
|
211
|
+
args.channel_history,
|
|
212
|
+
&out,
|
|
213
|
+
)
|
|
214
|
+
.await?;
|
|
218
215
|
signed_in = true;
|
|
219
216
|
}
|
|
220
217
|
#[derive(Serialize)]
|
|
@@ -222,11 +219,17 @@ async fn init(profile_name: &str, args: InitArgs, out: Output) -> Result<(), App
|
|
|
222
219
|
profile: &'a str,
|
|
223
220
|
config_path: String,
|
|
224
221
|
signed_in: bool,
|
|
222
|
+
client_id: &'a str,
|
|
223
|
+
tenant: &'a str,
|
|
224
|
+
channel_history_requested: bool,
|
|
225
225
|
}
|
|
226
226
|
let result = InitResult {
|
|
227
227
|
profile: profile_name,
|
|
228
228
|
config_path: path.display().to_string(),
|
|
229
229
|
signed_in,
|
|
230
|
+
client_id: &client_id,
|
|
231
|
+
tenant: &args.tenant,
|
|
232
|
+
channel_history_requested: args.channel_history,
|
|
230
233
|
};
|
|
231
234
|
out.value(&result, || {
|
|
232
235
|
if signed_in {
|
|
@@ -243,7 +246,10 @@ async fn auth_command(
|
|
|
243
246
|
out: Output,
|
|
244
247
|
) -> Result<(), AppError> {
|
|
245
248
|
match command {
|
|
246
|
-
AuthCommand::Login {
|
|
249
|
+
AuthCommand::Login {
|
|
250
|
+
device_code,
|
|
251
|
+
channel_history,
|
|
252
|
+
} => {
|
|
247
253
|
if !device_code && (!io::stdin().is_terminal() || !io::stderr().is_terminal()) {
|
|
248
254
|
return Err(AppError::NonInteractive(
|
|
249
255
|
"browser sign-in requires a terminal; use `teams auth login --device-code` for headless sign-in"
|
|
@@ -251,8 +257,8 @@ async fn auth_command(
|
|
|
251
257
|
));
|
|
252
258
|
}
|
|
253
259
|
let (name, profile) = config::load(profile_arg)?;
|
|
254
|
-
let token = auth::login(&name, &profile, device_code, &out).await?;
|
|
255
|
-
let value = serde_json::json!({"profile":name,"expires_at":token.expires_at,"scope":token.scope});
|
|
260
|
+
let token = auth::login(&name, &profile, device_code, channel_history, &out).await?;
|
|
261
|
+
let value = serde_json::json!({"profile":name,"expires_at":token.expires_at,"scope":token.scope,"channel_history_requested":channel_history});
|
|
256
262
|
out.value(&value, || format!("Signed in profile '{name}'."))
|
|
257
263
|
}
|
|
258
264
|
AuthCommand::Logout => {
|
|
@@ -268,12 +274,15 @@ async fn auth_command(
|
|
|
268
274
|
.map(|(n, _)| n.as_str())
|
|
269
275
|
.unwrap_or(profile_arg.unwrap_or("default"));
|
|
270
276
|
let signed_in = auth::has_token(name);
|
|
271
|
-
let
|
|
277
|
+
let scopes = auth::granted_scopes(name);
|
|
278
|
+
let channel_history = auth::channel_history_granted(name);
|
|
279
|
+
let value = serde_json::json!({"profile":name,"configured":configured.is_some(),"signed_in":signed_in,"config_path":config::path(),"granted_scopes":scopes,"channel_history":channel_history});
|
|
272
280
|
out.value(&value, || {
|
|
273
281
|
format!(
|
|
274
|
-
"Profile: {name}\nConfigured: {}\nSigned in: {}\nConfig: {}",
|
|
282
|
+
"Profile: {name}\nConfigured: {}\nSigned in: {}\nChannel history: {}\nConfig: {}",
|
|
275
283
|
yes_no(configured.is_some()),
|
|
276
284
|
yes_no(signed_in),
|
|
285
|
+
optional_access(channel_history),
|
|
277
286
|
config::path().display()
|
|
278
287
|
)
|
|
279
288
|
})
|
|
@@ -401,32 +410,71 @@ async fn doctor(profile_arg: Option<&str>, out: Output) -> Result<(), AppError>
|
|
|
401
410
|
"run `teams auth login`".into()
|
|
402
411
|
},
|
|
403
412
|
});
|
|
404
|
-
let
|
|
405
|
-
|
|
413
|
+
let channel_history = auth::channel_history_granted(
|
|
414
|
+
configured
|
|
415
|
+
.as_ref()
|
|
416
|
+
.map(|(name, _)| name.as_str())
|
|
417
|
+
.unwrap_or("default"),
|
|
418
|
+
);
|
|
419
|
+
checks.push(Check {
|
|
420
|
+
name: "channel_history",
|
|
421
|
+
status: if channel_history == Some(true) {
|
|
422
|
+
"pass"
|
|
423
|
+
} else {
|
|
424
|
+
"optional"
|
|
425
|
+
},
|
|
426
|
+
detail: if channel_history == Some(true) {
|
|
427
|
+
"admin-consented channel reading is available".into()
|
|
428
|
+
} else {
|
|
429
|
+
"optional; run `teams auth login --channel-history` if an admin approves it".into()
|
|
430
|
+
},
|
|
431
|
+
});
|
|
432
|
+
let mut identity_ok = false;
|
|
433
|
+
let mut teams_ok = false;
|
|
434
|
+
let (identity_detail, teams_detail) = if configured.is_some() && signed_in {
|
|
406
435
|
match client(profile_arg).await {
|
|
407
|
-
Ok(client) =>
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
436
|
+
Ok(client) => {
|
|
437
|
+
let identity_detail = match client.me().await {
|
|
438
|
+
Ok(value) => {
|
|
439
|
+
identity_ok = true;
|
|
440
|
+
format!(
|
|
441
|
+
"signed in as {}",
|
|
442
|
+
string_at(&value, "/userPrincipalName")
|
|
443
|
+
.or_else(|| string_at(&value, "/displayName"))
|
|
444
|
+
.unwrap_or("unknown user")
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
Err(error) => error.to_string(),
|
|
448
|
+
};
|
|
449
|
+
let teams_detail = match client.teams_available().await {
|
|
450
|
+
Ok(_) => {
|
|
451
|
+
teams_ok = true;
|
|
452
|
+
"Microsoft Teams is available".into()
|
|
453
|
+
}
|
|
454
|
+
Err(error) => error.to_string(),
|
|
455
|
+
};
|
|
456
|
+
(identity_detail, teams_detail)
|
|
457
|
+
}
|
|
458
|
+
Err(error) => {
|
|
459
|
+
let detail = error.to_string();
|
|
460
|
+
(detail.clone(), detail)
|
|
461
|
+
}
|
|
420
462
|
}
|
|
421
463
|
} else {
|
|
422
|
-
"skipped until setup is complete".
|
|
464
|
+
let detail = "skipped until setup is complete".to_string();
|
|
465
|
+
(detail.clone(), detail)
|
|
423
466
|
};
|
|
424
467
|
checks.push(Check {
|
|
425
|
-
name: "
|
|
426
|
-
status: if
|
|
427
|
-
detail,
|
|
468
|
+
name: "microsoft_identity",
|
|
469
|
+
status: if identity_ok { "pass" } else { "fail" },
|
|
470
|
+
detail: identity_detail,
|
|
471
|
+
});
|
|
472
|
+
checks.push(Check {
|
|
473
|
+
name: "microsoft_teams",
|
|
474
|
+
status: if teams_ok { "pass" } else { "fail" },
|
|
475
|
+
detail: teams_detail,
|
|
428
476
|
});
|
|
429
|
-
let healthy = checks.iter().all(|check| check.status
|
|
477
|
+
let healthy = checks.iter().all(|check| check.status != "fail");
|
|
430
478
|
let value = serde_json::json!({"checks":checks,"healthy":healthy});
|
|
431
479
|
out.value(&value, || {
|
|
432
480
|
checks
|
|
@@ -443,19 +491,11 @@ fn capabilities(out: Output) -> Result<(), AppError> {
|
|
|
443
491
|
"unsupported":["personal Microsoft accounts","user calls and screen sharing","private Teams protocols","tenant administration"],
|
|
444
492
|
"account_types":["Microsoft 365 work or school"],
|
|
445
493
|
"api":"Microsoft Graph v1.0",
|
|
446
|
-
"notes":["Reading channel messages
|
|
494
|
+
"notes":["Normal sign-in uses only user-consentable delegated permissions.","Reading channel messages requires `teams auth login --channel-history` and admin consent for ChannelMessage.Read.All.","Calls require bot/application APIs and are outside this user client."]
|
|
447
495
|
});
|
|
448
496
|
out.value(&value, || "Supported\n Work/school delegated sign-in; teams, channels, chats, messages; send as yourself; TUI\n\nNot supported\n Personal Microsoft accounts; calls or screen sharing; private Teams protocols; tenant administration\n\nRun `teams schema` for the machine contract.".into())
|
|
449
497
|
}
|
|
450
498
|
|
|
451
|
-
fn prompt(label: &str) -> Result<String, AppError> {
|
|
452
|
-
eprint!(" {label}: ");
|
|
453
|
-
io::stderr().flush()?;
|
|
454
|
-
let mut value = String::new();
|
|
455
|
-
io::stdin().read_line(&mut value)?;
|
|
456
|
-
Ok(value.trim().into())
|
|
457
|
-
}
|
|
458
|
-
|
|
459
499
|
fn read_body(body: &str) -> Result<String, AppError> {
|
|
460
500
|
if body != "-" {
|
|
461
501
|
return Ok(body.into());
|
|
@@ -471,6 +511,13 @@ fn string_at<'a>(value: &'a Value, pointer: &str) -> Option<&'a str> {
|
|
|
471
511
|
fn yes_no(value: bool) -> &'static str {
|
|
472
512
|
if value { "yes" } else { "no" }
|
|
473
513
|
}
|
|
514
|
+
fn optional_access(value: Option<bool>) -> &'static str {
|
|
515
|
+
match value {
|
|
516
|
+
Some(true) => "granted",
|
|
517
|
+
Some(false) => "not granted (optional)",
|
|
518
|
+
None => "unknown",
|
|
519
|
+
}
|
|
520
|
+
}
|
|
474
521
|
fn identity_text(value: &Value) -> String {
|
|
475
522
|
format!(
|
|
476
523
|
"{}\n{}",
|
|
@@ -11,6 +11,9 @@ fn array_field(name: &str, item_kind: &str) -> Value {
|
|
|
11
11
|
fn nullable_field(name: &str, kind: &str) -> Value {
|
|
12
12
|
json!({"name": name, "type": kind, "nullable": true})
|
|
13
13
|
}
|
|
14
|
+
fn nullable_array_field(name: &str, item_kind: &str) -> Value {
|
|
15
|
+
json!({"name": name, "type": "array", "items": {"type": item_kind}, "nullable": true})
|
|
16
|
+
}
|
|
14
17
|
fn arg(name: &str, kind: &str, description: &str) -> Value {
|
|
15
18
|
json!({"name": name, "type": kind, "description": description})
|
|
16
19
|
}
|
|
@@ -67,17 +70,29 @@ pub fn generate(command_filter: Option<&str>) -> Value {
|
|
|
67
70
|
field("profile", "string"),
|
|
68
71
|
field("config_path", "string"),
|
|
69
72
|
field("signed_in", "boolean"),
|
|
73
|
+
field("client_id", "string"),
|
|
74
|
+
field("tenant", "string"),
|
|
75
|
+
field("channel_history_requested", "boolean"),
|
|
70
76
|
],
|
|
71
77
|
);
|
|
72
78
|
value["args"] = json!([
|
|
73
|
-
arg(
|
|
79
|
+
arg(
|
|
80
|
+
"--client-id",
|
|
81
|
+
"string",
|
|
82
|
+
"Override the maintained teams-cli public-client application ID"
|
|
83
|
+
),
|
|
74
84
|
arg("--tenant", "string", "Tenant ID, domain, or organizations"),
|
|
75
85
|
arg(
|
|
76
86
|
"--no-login",
|
|
77
87
|
"boolean",
|
|
78
88
|
"Save configuration without signing in"
|
|
79
89
|
),
|
|
80
|
-
arg("--device-code", "boolean", "Use device-code sign-in")
|
|
90
|
+
arg("--device-code", "boolean", "Use device-code sign-in"),
|
|
91
|
+
arg(
|
|
92
|
+
"--channel-history",
|
|
93
|
+
"boolean",
|
|
94
|
+
"Request admin-consented channel-history access"
|
|
95
|
+
)
|
|
81
96
|
]);
|
|
82
97
|
value
|
|
83
98
|
},
|
|
@@ -90,9 +105,21 @@ pub fn generate(command_filter: Option<&str>) -> Value {
|
|
|
90
105
|
field("profile", "string"),
|
|
91
106
|
field("expires_at", "integer"),
|
|
92
107
|
nullable_field("scope", "string"),
|
|
108
|
+
field("channel_history_requested", "boolean"),
|
|
93
109
|
],
|
|
94
110
|
);
|
|
95
|
-
value["args"] = json!([
|
|
111
|
+
value["args"] = json!([
|
|
112
|
+
arg(
|
|
113
|
+
"--device-code",
|
|
114
|
+
"boolean",
|
|
115
|
+
"Use device-code sign-in for headless and remote environments"
|
|
116
|
+
),
|
|
117
|
+
arg(
|
|
118
|
+
"--channel-history",
|
|
119
|
+
"boolean",
|
|
120
|
+
"Request admin-consented channel-history access"
|
|
121
|
+
)
|
|
122
|
+
]);
|
|
96
123
|
value
|
|
97
124
|
},
|
|
98
125
|
single(
|
|
@@ -110,6 +137,8 @@ pub fn generate(command_filter: Option<&str>) -> Value {
|
|
|
110
137
|
field("configured", "boolean"),
|
|
111
138
|
field("signed_in", "boolean"),
|
|
112
139
|
field("config_path", "string"),
|
|
140
|
+
nullable_array_field("granted_scopes", "string"),
|
|
141
|
+
nullable_field("channel_history", "boolean"),
|
|
113
142
|
],
|
|
114
143
|
),
|
|
115
144
|
single(
|
|
@@ -157,7 +186,7 @@ pub fn generate(command_filter: Option<&str>) -> Value {
|
|
|
157
186
|
{
|
|
158
187
|
let mut value = paged(
|
|
159
188
|
"messages list",
|
|
160
|
-
"List messages from one chat or channel",
|
|
189
|
+
"List messages from one chat, or from a channel with optional admin-consented access",
|
|
161
190
|
vec![
|
|
162
191
|
field("id", "string"),
|
|
163
192
|
field("createdDateTime", "string"),
|
|
@@ -198,7 +227,7 @@ pub fn generate(command_filter: Option<&str>) -> Value {
|
|
|
198
227
|
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")]}),
|
|
199
228
|
single(
|
|
200
229
|
"doctor",
|
|
201
|
-
"Check configuration, credential storage, and
|
|
230
|
+
"Check configuration, credential storage, Microsoft identity, and Teams provisioning",
|
|
202
231
|
"read_only",
|
|
203
232
|
vec![array_field("checks", "object"), field("healthy", "boolean")],
|
|
204
233
|
),
|
|
@@ -241,6 +270,13 @@ pub fn generate(command_filter: Option<&str>) -> Value {
|
|
|
241
270
|
],
|
|
242
271
|
"commands": commands,
|
|
243
272
|
"errors": error::ALL.iter().map(|e| json!({"kind":e.kind,"exit_code":e.exit_code,"retryable":e.retryable,"description":e.description})).collect::<Vec<_>>(),
|
|
244
|
-
"extensions": {
|
|
273
|
+
"extensions": {
|
|
274
|
+
"authentication":"delegated_oauth",
|
|
275
|
+
"api":"Microsoft Graph v1.0",
|
|
276
|
+
"default_client_id": crate::config::DEFAULT_CLIENT_ID,
|
|
277
|
+
"login_modes":["browser_pkce","device_code"],
|
|
278
|
+
"baseline_scopes": crate::auth::BASE_SCOPES.split_whitespace().collect::<Vec<_>>(),
|
|
279
|
+
"privileged_scopes":[crate::auth::CHANNEL_HISTORY_SCOPE]
|
|
280
|
+
}
|
|
245
281
|
})
|
|
246
282
|
}
|
|
@@ -106,13 +106,20 @@ fn no_args_never_prompts_when_piped() {
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
#[test]
|
|
109
|
-
fn
|
|
110
|
-
|
|
109
|
+
fn noninteractive_init_uses_the_maintained_client_id() {
|
|
110
|
+
let temp = tempfile::tempdir().unwrap();
|
|
111
|
+
let output = Command::cargo_bin("teams")
|
|
111
112
|
.unwrap()
|
|
113
|
+
.env("XDG_CONFIG_HOME", temp.path())
|
|
112
114
|
.args(["init", "--no-login"])
|
|
113
|
-
.
|
|
114
|
-
.
|
|
115
|
-
|
|
115
|
+
.output()
|
|
116
|
+
.unwrap();
|
|
117
|
+
assert!(output.status.success());
|
|
118
|
+
let result: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
119
|
+
assert_eq!(result["client_id"], "66ebad71-1604-48fc-a086-0d4caa24988b");
|
|
120
|
+
assert_eq!(result["tenant"], "organizations");
|
|
121
|
+
assert_eq!(result["channel_history_requested"], false);
|
|
122
|
+
assert!(temp.path().join("teams/config.toml").is_file());
|
|
116
123
|
}
|
|
117
124
|
|
|
118
125
|
#[test]
|
|
@@ -121,11 +128,7 @@ fn headless_browser_login_refuses_before_writing_config() {
|
|
|
121
128
|
Command::cargo_bin("teams")
|
|
122
129
|
.unwrap()
|
|
123
130
|
.env("XDG_CONFIG_HOME", temp.path())
|
|
124
|
-
.
|
|
125
|
-
"init",
|
|
126
|
-
"--client-id",
|
|
127
|
-
"00000000-0000-0000-0000-000000000000",
|
|
128
|
-
])
|
|
131
|
+
.arg("init")
|
|
129
132
|
.assert()
|
|
130
133
|
.code(8)
|
|
131
134
|
.stderr(predicate::str::contains(
|
|
@@ -133,3 +136,29 @@ fn headless_browser_login_refuses_before_writing_config() {
|
|
|
133
136
|
));
|
|
134
137
|
assert!(!temp.path().join("teams/config.toml").exists());
|
|
135
138
|
}
|
|
139
|
+
|
|
140
|
+
#[test]
|
|
141
|
+
fn schema_declares_default_app_and_privileged_scope() {
|
|
142
|
+
let output = Command::cargo_bin("teams")
|
|
143
|
+
.unwrap()
|
|
144
|
+
.arg("schema")
|
|
145
|
+
.output()
|
|
146
|
+
.unwrap();
|
|
147
|
+
assert!(output.status.success());
|
|
148
|
+
let schema: Value = serde_json::from_slice(&output.stdout).unwrap();
|
|
149
|
+
assert_eq!(
|
|
150
|
+
schema["extensions"]["default_client_id"],
|
|
151
|
+
"66ebad71-1604-48fc-a086-0d4caa24988b"
|
|
152
|
+
);
|
|
153
|
+
assert!(
|
|
154
|
+
schema["extensions"]["baseline_scopes"]
|
|
155
|
+
.as_array()
|
|
156
|
+
.unwrap()
|
|
157
|
+
.iter()
|
|
158
|
+
.all(|scope| scope != "ChannelMessage.Read.All")
|
|
159
|
+
);
|
|
160
|
+
assert_eq!(
|
|
161
|
+
schema["extensions"]["privileged_scopes"],
|
|
162
|
+
serde_json::json!(["ChannelMessage.Read.All"])
|
|
163
|
+
);
|
|
164
|
+
}
|
|
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
|