teams-cli 0.1.2__tar.gz → 0.1.4__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.4/.github/workflows/ci.yml +54 -0
- {teams_cli-0.1.2 → teams_cli-0.1.4}/.github/workflows/release.yml +78 -25
- teams_cli-0.1.4/.mise.toml +22 -0
- {teams_cli-0.1.2 → teams_cli-0.1.4}/CHANGELOG.md +17 -5
- {teams_cli-0.1.2 → teams_cli-0.1.4}/Cargo.lock +1 -1
- {teams_cli-0.1.2 → teams_cli-0.1.4}/Cargo.toml +6 -2
- {teams_cli-0.1.2 → teams_cli-0.1.4}/Makefile +10 -1
- {teams_cli-0.1.2 → teams_cli-0.1.4}/PKG-INFO +20 -2
- {teams_cli-0.1.2 → teams_cli-0.1.4}/README.md +19 -1
- teams_cli-0.1.4/docs/releases.md +38 -0
- {teams_cli-0.1.2 → teams_cli-0.1.4}/src/cli.rs +16 -0
- {teams_cli-0.1.2 → teams_cli-0.1.4}/src/config.rs +48 -3
- {teams_cli-0.1.2 → teams_cli-0.1.4}/src/error.rs +16 -6
- {teams_cli-0.1.2 → teams_cli-0.1.4}/src/main.rs +58 -6
- {teams_cli-0.1.2 → teams_cli-0.1.4}/src/output.rs +2 -2
- {teams_cli-0.1.2 → teams_cli-0.1.4}/src/schema.rs +23 -1
- teams_cli-0.1.4/src/teams_cli_alias.rs +30 -0
- {teams_cli-0.1.2 → teams_cli-0.1.4}/tests/cli.rs +47 -2
- teams_cli-0.1.4/tests/package_alias.rs +20 -0
- teams_cli-0.1.4/vership.toml +10 -0
- teams_cli-0.1.2/.github/workflows/ci.yml +0 -47
- {teams_cli-0.1.2 → teams_cli-0.1.4}/.github/dependabot.yml +0 -0
- {teams_cli-0.1.2 → teams_cli-0.1.4}/.gitignore +0 -0
- {teams_cli-0.1.2 → teams_cli-0.1.4}/DESIGN.md +0 -0
- {teams_cli-0.1.2 → teams_cli-0.1.4}/LICENSE +0 -0
- {teams_cli-0.1.2 → teams_cli-0.1.4}/PRODUCT.md +0 -0
- {teams_cli-0.1.2 → teams_cli-0.1.4}/examples/validate_schema.rs +0 -0
- {teams_cli-0.1.2 → teams_cli-0.1.4}/pyproject.toml +0 -0
- {teams_cli-0.1.2 → teams_cli-0.1.4}/src/auth.rs +0 -0
- {teams_cli-0.1.2 → teams_cli-0.1.4}/src/graph.rs +0 -0
- {teams_cli-0.1.2 → teams_cli-0.1.4}/src/lib.rs +0 -0
- {teams_cli-0.1.2 → teams_cli-0.1.4}/src/tui.rs +0 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
pull_request:
|
|
6
|
+
workflow_call:
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: read
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
test:
|
|
13
|
+
name: Test (${{ matrix.os }})
|
|
14
|
+
strategy:
|
|
15
|
+
fail-fast: false
|
|
16
|
+
matrix:
|
|
17
|
+
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
18
|
+
runs-on: ${{ matrix.os }}
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
|
21
|
+
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
|
|
22
|
+
with:
|
|
23
|
+
toolchain: 1.96.0
|
|
24
|
+
components: rustfmt, clippy
|
|
25
|
+
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
|
|
26
|
+
- run: cargo test --locked --all-targets
|
|
27
|
+
|
|
28
|
+
quality:
|
|
29
|
+
name: Formatting and lints
|
|
30
|
+
runs-on: ubuntu-latest
|
|
31
|
+
steps:
|
|
32
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
|
33
|
+
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
|
|
34
|
+
with:
|
|
35
|
+
toolchain: 1.96.0
|
|
36
|
+
components: rustfmt, clippy
|
|
37
|
+
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
|
|
38
|
+
- run: cargo fmt -- --check
|
|
39
|
+
- run: cargo clippy --locked --all-targets -- -D warnings
|
|
40
|
+
|
|
41
|
+
security:
|
|
42
|
+
name: Dependency audit
|
|
43
|
+
runs-on: ubuntu-latest
|
|
44
|
+
steps:
|
|
45
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
|
46
|
+
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
|
|
47
|
+
with:
|
|
48
|
+
toolchain: 1.96.0
|
|
49
|
+
components: rustfmt, clippy
|
|
50
|
+
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
|
|
51
|
+
- name: Install cargo-audit
|
|
52
|
+
run: cargo install cargo-audit --version 0.22.2 --locked
|
|
53
|
+
- name: Audit Cargo.lock
|
|
54
|
+
run: cargo audit
|
|
@@ -3,6 +3,17 @@ name: Release
|
|
|
3
3
|
on:
|
|
4
4
|
push:
|
|
5
5
|
tags: ["v[0-9]*.[0-9]*.[0-9]*"]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
inputs:
|
|
8
|
+
version:
|
|
9
|
+
description: "Version to rehearse (defaults to Cargo.toml)"
|
|
10
|
+
required: false
|
|
11
|
+
type: string
|
|
12
|
+
dry_run:
|
|
13
|
+
description: "Build and validate without publishing"
|
|
14
|
+
required: true
|
|
15
|
+
default: true
|
|
16
|
+
type: boolean
|
|
6
17
|
|
|
7
18
|
permissions:
|
|
8
19
|
contents: read
|
|
@@ -12,11 +23,23 @@ jobs:
|
|
|
12
23
|
name: Verify release version
|
|
13
24
|
runs-on: ubuntu-latest
|
|
14
25
|
steps:
|
|
15
|
-
- uses: actions/checkout@
|
|
26
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
|
16
27
|
- name: Match tag to package version
|
|
28
|
+
env:
|
|
29
|
+
REQUESTED_VERSION: ${{ inputs.version }}
|
|
30
|
+
DRY_RUN: ${{ inputs.dry_run }}
|
|
17
31
|
run: |
|
|
18
|
-
|
|
19
|
-
|
|
32
|
+
manifest_version="$(sed -n 's/^version = "\([^"]*\)"/\1/p' Cargo.toml | head -1)"
|
|
33
|
+
version="${REQUESTED_VERSION#v}"
|
|
34
|
+
version="${version:-$manifest_version}"
|
|
35
|
+
[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([+-][0-9A-Za-z.-]+)?$ ]]
|
|
36
|
+
test "$version" = "$manifest_version"
|
|
37
|
+
if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then
|
|
38
|
+
test "v${version}" = "$GITHUB_REF_NAME"
|
|
39
|
+
elif [[ "$DRY_RUN" != "true" ]]; then
|
|
40
|
+
test "$GITHUB_REF_TYPE" = "tag"
|
|
41
|
+
test "v${version}" = "$GITHUB_REF_NAME"
|
|
42
|
+
fi
|
|
20
43
|
|
|
21
44
|
verify:
|
|
22
45
|
uses: ./.github/workflows/ci.yml
|
|
@@ -46,11 +69,13 @@ jobs:
|
|
|
46
69
|
id-token: write
|
|
47
70
|
attestations: write
|
|
48
71
|
steps:
|
|
49
|
-
- uses: actions/checkout@
|
|
50
|
-
- uses: dtolnay/rust-toolchain@
|
|
72
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
|
73
|
+
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
|
|
51
74
|
with:
|
|
75
|
+
toolchain: 1.96.0
|
|
76
|
+
components: rustfmt, clippy
|
|
52
77
|
targets: ${{ matrix.target }}
|
|
53
|
-
- uses: Swatinem/rust-cache@
|
|
78
|
+
- uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6
|
|
54
79
|
with:
|
|
55
80
|
key: ${{ matrix.target }}
|
|
56
81
|
- run: cargo build --locked --release --target ${{ matrix.target }}
|
|
@@ -63,10 +88,11 @@ jobs:
|
|
|
63
88
|
tar -C dist/package -czf "dist/teams-cli-${GITHUB_REF_NAME}-${{ matrix.target }}.tar.gz" .
|
|
64
89
|
rm -rf dist/package
|
|
65
90
|
- name: Attest build provenance
|
|
66
|
-
|
|
91
|
+
if: ${{ inputs.dry_run != true }}
|
|
92
|
+
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6
|
|
67
93
|
with:
|
|
68
94
|
subject-path: dist/*.tar.gz
|
|
69
|
-
- uses: actions/upload-artifact@
|
|
95
|
+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
|
70
96
|
with:
|
|
71
97
|
name: binary-${{ matrix.target }}
|
|
72
98
|
path: dist/*.tar.gz
|
|
@@ -93,7 +119,7 @@ jobs:
|
|
|
93
119
|
manylinux: auto
|
|
94
120
|
runs-on: ${{ matrix.os }}
|
|
95
121
|
steps:
|
|
96
|
-
- uses: actions/checkout@
|
|
122
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
|
97
123
|
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
|
|
98
124
|
with:
|
|
99
125
|
maturin-version: v1.14.1
|
|
@@ -101,7 +127,12 @@ jobs:
|
|
|
101
127
|
target: ${{ matrix.target }}
|
|
102
128
|
manylinux: ${{ matrix.manylinux }}
|
|
103
129
|
args: --release --locked --compatibility pypi --out dist
|
|
104
|
-
- uses:
|
|
130
|
+
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d
|
|
131
|
+
if: ${{ inputs.dry_run == true }}
|
|
132
|
+
- name: Validate wheel metadata
|
|
133
|
+
if: ${{ inputs.dry_run == true }}
|
|
134
|
+
run: uvx --from twine==6.2.0 twine check --strict dist/*.whl
|
|
135
|
+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
|
105
136
|
with:
|
|
106
137
|
name: wheel-${{ matrix.target }}
|
|
107
138
|
path: dist/*.whl
|
|
@@ -112,16 +143,27 @@ jobs:
|
|
|
112
143
|
needs: [version, verify]
|
|
113
144
|
runs-on: ubuntu-latest
|
|
114
145
|
steps:
|
|
115
|
-
- uses: actions/checkout@
|
|
116
|
-
- uses: dtolnay/rust-toolchain@
|
|
146
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
|
147
|
+
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
|
|
148
|
+
with:
|
|
149
|
+
toolchain: 1.96.0
|
|
150
|
+
components: rustfmt, clippy
|
|
117
151
|
- run: cargo package --locked
|
|
152
|
+
- name: Validate crates.io package
|
|
153
|
+
if: ${{ inputs.dry_run == true }}
|
|
154
|
+
run: cargo publish --locked --dry-run
|
|
118
155
|
- uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
|
|
119
156
|
with:
|
|
120
157
|
maturin-version: v1.14.1
|
|
121
158
|
command: sdist
|
|
122
159
|
args: --out dist
|
|
123
160
|
- run: cp target/package/*.crate dist/
|
|
124
|
-
- uses:
|
|
161
|
+
- uses: astral-sh/setup-uv@20cfd1bf945f4377ade1205e4dbc17946fc9a30d
|
|
162
|
+
if: ${{ inputs.dry_run == true }}
|
|
163
|
+
- name: Validate source distribution metadata
|
|
164
|
+
if: ${{ inputs.dry_run == true }}
|
|
165
|
+
run: uvx --from twine==6.2.0 twine check --strict dist/*.tar.gz
|
|
166
|
+
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
|
|
125
167
|
with:
|
|
126
168
|
name: source-distributions
|
|
127
169
|
path: dist/*
|
|
@@ -136,13 +178,13 @@ jobs:
|
|
|
136
178
|
id-token: write
|
|
137
179
|
attestations: write
|
|
138
180
|
steps:
|
|
139
|
-
- uses: actions/checkout@
|
|
140
|
-
- uses: actions/download-artifact@
|
|
181
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
|
182
|
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
|
141
183
|
with:
|
|
142
184
|
pattern: binary-*
|
|
143
185
|
path: dist
|
|
144
186
|
merge-multiple: true
|
|
145
|
-
- uses: actions/download-artifact@
|
|
187
|
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
|
146
188
|
with:
|
|
147
189
|
name: source-distributions
|
|
148
190
|
path: dist
|
|
@@ -155,11 +197,13 @@ jobs:
|
|
|
155
197
|
working-directory: dist
|
|
156
198
|
run: sha256sum ./*.tar.gz ./*.crate ./*.json > SHA256SUMS
|
|
157
199
|
- name: Attest artifacts and SBOM
|
|
158
|
-
|
|
200
|
+
if: ${{ inputs.dry_run != true }}
|
|
201
|
+
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6
|
|
159
202
|
with:
|
|
160
203
|
subject-path: dist/*
|
|
161
204
|
sbom-path: dist/teams-cli.cdx.json
|
|
162
205
|
- name: Create GitHub release
|
|
206
|
+
if: ${{ inputs.dry_run != true }}
|
|
163
207
|
env:
|
|
164
208
|
GH_TOKEN: ${{ github.token }}
|
|
165
209
|
run: gh release create "$GITHUB_REF_NAME" dist/* --verify-tag --generate-notes
|
|
@@ -167,13 +211,17 @@ jobs:
|
|
|
167
211
|
crates-io:
|
|
168
212
|
name: Publish crates.io
|
|
169
213
|
needs: github-release
|
|
214
|
+
if: ${{ inputs.dry_run != true }}
|
|
170
215
|
runs-on: ubuntu-latest
|
|
171
216
|
environment: crates-io
|
|
172
217
|
permissions:
|
|
173
218
|
id-token: write
|
|
174
219
|
steps:
|
|
175
|
-
- uses: actions/checkout@
|
|
176
|
-
- uses: dtolnay/rust-toolchain@
|
|
220
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
|
221
|
+
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
|
|
222
|
+
with:
|
|
223
|
+
toolchain: 1.96.0
|
|
224
|
+
components: rustfmt, clippy
|
|
177
225
|
- name: Check whether this version is already published
|
|
178
226
|
id: crate
|
|
179
227
|
shell: bash
|
|
@@ -191,7 +239,7 @@ jobs:
|
|
|
191
239
|
- name: Get temporary crates.io token
|
|
192
240
|
if: steps.crate.outputs.exists != 'true'
|
|
193
241
|
id: crates-auth
|
|
194
|
-
uses: rust-lang/crates-io-auth-action@
|
|
242
|
+
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18
|
|
195
243
|
- if: steps.crate.outputs.exists != 'true'
|
|
196
244
|
run: cargo publish --locked
|
|
197
245
|
env:
|
|
@@ -202,6 +250,7 @@ jobs:
|
|
|
202
250
|
pypi:
|
|
203
251
|
name: Publish PyPI
|
|
204
252
|
needs: github-release
|
|
253
|
+
if: ${{ inputs.dry_run != true }}
|
|
205
254
|
runs-on: ubuntu-latest
|
|
206
255
|
environment:
|
|
207
256
|
name: pypi
|
|
@@ -209,28 +258,32 @@ jobs:
|
|
|
209
258
|
permissions:
|
|
210
259
|
id-token: write
|
|
211
260
|
steps:
|
|
212
|
-
- uses: actions/download-artifact@
|
|
261
|
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
|
213
262
|
with:
|
|
214
263
|
pattern: wheel-*
|
|
215
264
|
path: dist
|
|
216
265
|
merge-multiple: true
|
|
217
|
-
- uses: actions/download-artifact@
|
|
266
|
+
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c
|
|
218
267
|
with:
|
|
219
268
|
name: source-distributions
|
|
220
269
|
path: source
|
|
221
270
|
- name: Collect Python distributions
|
|
222
271
|
run: find source -name '*.tar.gz' -exec cp {} dist/ \;
|
|
223
|
-
- uses: pypa/gh-action-pypi-publish@
|
|
272
|
+
- uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33
|
|
224
273
|
with:
|
|
225
274
|
packages-dir: dist
|
|
226
275
|
|
|
227
276
|
post-publish-smoke:
|
|
228
277
|
name: Verify public packages
|
|
229
278
|
needs: [crates-io, pypi]
|
|
279
|
+
if: ${{ inputs.dry_run != true }}
|
|
230
280
|
runs-on: ubuntu-latest
|
|
231
281
|
steps:
|
|
232
|
-
- uses: dtolnay/rust-toolchain@
|
|
233
|
-
|
|
282
|
+
- uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c
|
|
283
|
+
with:
|
|
284
|
+
toolchain: 1.96.0
|
|
285
|
+
components: rustfmt, clippy
|
|
286
|
+
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
|
|
234
287
|
with:
|
|
235
288
|
python-version: "3.x"
|
|
236
289
|
- name: Install and run the crates.io package
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Reproducible local and CI tooling.
|
|
2
|
+
min_version = "2026.4.19"
|
|
3
|
+
|
|
4
|
+
[env]
|
|
5
|
+
RUST_BACKTRACE = "1"
|
|
6
|
+
BINSTALL_DISABLE_TELEMETRY = "true"
|
|
7
|
+
|
|
8
|
+
[tools]
|
|
9
|
+
rust = "1.96.0"
|
|
10
|
+
actionlint = "1.7.12"
|
|
11
|
+
cargo-binstall = "1.22.0"
|
|
12
|
+
"cargo:cargo-nextest" = "0.9.143"
|
|
13
|
+
|
|
14
|
+
[settings]
|
|
15
|
+
cargo.binstall = true
|
|
16
|
+
|
|
17
|
+
[tasks.setup]
|
|
18
|
+
description = "Install the exact development toolchain"
|
|
19
|
+
run = """
|
|
20
|
+
mise install rust actionlint cargo-binstall
|
|
21
|
+
mise install cargo:cargo-nextest
|
|
22
|
+
"""
|
|
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.1.4](https://github.com/rvben/teams-cli/compare/v0.1.3...v0.1.4) - 2026-08-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **packaging**: add package-named launcher ([35a14d3](https://github.com/rvben/teams-cli/commit/35a14d329c5ca5a24f0e47c81cf29f30bad8773c))
|
|
15
|
+
|
|
16
|
+
## [0.1.3](https://github.com/rvben/teams-cli/compare/v0.1.2...v0.1.3) - 2026-08-26
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- **cli**: align safety and configuration contract ([c564f63](https://github.com/rvben/teams-cli/commit/c564f63f7deeb4e8a7c8faa359e32f3ea0da8556))
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- **config**: persist profiles atomically ([ababe5e](https://github.com/rvben/teams-cli/commit/ababe5e46f24a52d86007de5737cd7601a1ae0c3))
|
|
25
|
+
- **ci**: install pinned Rust components ([f5ecdcc](https://github.com/rvben/teams-cli/commit/f5ecdccfb4ca697ff23cc828393e519a788cbea8))
|
|
26
|
+
|
|
10
27
|
## [0.1.2] - 2026-08-24
|
|
11
28
|
|
|
12
29
|
### Added
|
|
@@ -35,8 +52,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
35
52
|
- Commands for identity, joined teams, channels, chats, and reading or sending messages through Microsoft Graph.
|
|
36
53
|
- Guided onboarding, diagnostics, completions, bounded collections, and deterministic TUI snapshots.
|
|
37
54
|
- Distribution through crates.io, PyPI binary wheels, and signed GitHub release artifacts.
|
|
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
|
|
42
|
-
[0.1.0]: https://github.com/rvben/teams-cli/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "teams-cli"
|
|
3
|
-
version = "0.1.
|
|
3
|
+
version = "0.1.4"
|
|
4
4
|
edition = "2024"
|
|
5
5
|
rust-version = "1.90"
|
|
6
6
|
description = "A Microsoft Teams CLI and TUI designed equally for humans and agents"
|
|
@@ -16,6 +16,10 @@ categories = ["command-line-utilities"]
|
|
|
16
16
|
name = "teams"
|
|
17
17
|
path = "src/main.rs"
|
|
18
18
|
|
|
19
|
+
[[bin]]
|
|
20
|
+
name = "teams-cli"
|
|
21
|
+
path = "src/teams_cli_alias.rs"
|
|
22
|
+
|
|
19
23
|
[dependencies]
|
|
20
24
|
base64 = "0.23"
|
|
21
25
|
clap = { version = "4", features = ["derive", "env"] }
|
|
@@ -31,6 +35,7 @@ serde = { version = "1", features = ["derive"] }
|
|
|
31
35
|
serde_json = "1"
|
|
32
36
|
sha2 = "0.10"
|
|
33
37
|
thiserror = "2"
|
|
38
|
+
tempfile = "3"
|
|
34
39
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "io-util", "time"] }
|
|
35
40
|
toml = "1.1"
|
|
36
41
|
url = "2"
|
|
@@ -39,5 +44,4 @@ url = "2"
|
|
|
39
44
|
assert_cmd = "2"
|
|
40
45
|
jsonschema = "0.49.8"
|
|
41
46
|
predicates = "3"
|
|
42
|
-
tempfile = "3"
|
|
43
47
|
wiremock = "0.6"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
.PHONY: build test lint fmt check release install clean
|
|
1
|
+
.PHONY: build test lint fmt check release install clean release-patch release-minor release-major
|
|
2
2
|
|
|
3
3
|
build:
|
|
4
4
|
cargo build --locked
|
|
@@ -23,3 +23,12 @@ install: check release
|
|
|
23
23
|
|
|
24
24
|
clean:
|
|
25
25
|
cargo clean
|
|
26
|
+
|
|
27
|
+
release-patch:
|
|
28
|
+
vership bump patch
|
|
29
|
+
|
|
30
|
+
release-minor:
|
|
31
|
+
vership bump minor
|
|
32
|
+
|
|
33
|
+
release-major:
|
|
34
|
+
vership bump major
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: teams-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Classifier: Programming Language :: Rust
|
|
5
5
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
6
6
|
License-File: LICENSE
|
|
@@ -28,9 +28,11 @@ For humans, no arguments open guided onboarding on first use and the TUI afterwa
|
|
|
28
28
|
pipx install teams-cli
|
|
29
29
|
# or
|
|
30
30
|
cargo install teams-cli --locked
|
|
31
|
+
# or run from PyPI without installing
|
|
32
|
+
uvx teams-cli --help
|
|
31
33
|
```
|
|
32
34
|
|
|
33
|
-
The PyPI distribution
|
|
35
|
+
The PyPI distribution provides both `teams` and `teams-cli` as executable names.
|
|
34
36
|
|
|
35
37
|
To work from source:
|
|
36
38
|
|
|
@@ -108,11 +110,22 @@ teams auth login --channel-history # once, with administrator approval
|
|
|
108
110
|
teams messages list --team TEAM_ID --channel CHANNEL_ID
|
|
109
111
|
teams messages send --chat CHAT_ID --body "On it."
|
|
110
112
|
printf 'Status update' | teams messages send --chat CHAT_ID --body -
|
|
113
|
+
teams config show
|
|
111
114
|
teams doctor
|
|
112
115
|
```
|
|
113
116
|
|
|
114
117
|
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`.
|
|
115
118
|
|
|
119
|
+
Profiles can block every remote write while keeping reads, sign-in, diagnostics, and the TUI available:
|
|
120
|
+
|
|
121
|
+
```console
|
|
122
|
+
teams init --no-login --read-only
|
|
123
|
+
# or for one invocation/environment
|
|
124
|
+
TEAMS_READ_ONLY=true teams messages send --chat CHAT_ID --body "Blocked"
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Like the other tools in the suite, the config file is selected with `--profile`, overridden by `TEAMS_*` environment variables, and inspectable without exposing credentials through `teams config show` and `teams config path`.
|
|
128
|
+
|
|
116
129
|
## Boundaries
|
|
117
130
|
|
|
118
131
|
- Microsoft 365 work or school accounts only. The relevant Teams Graph endpoints do not support personal Microsoft accounts.
|
|
@@ -125,3 +138,8 @@ Collection commands expose `--limit` and `--fields`; Graph-native collections us
|
|
|
125
138
|
|
|
126
139
|
`teams schema` is offline, unauthenticated, and follows [CLI Spec v0.3](https://clispec.dev/): flat invocable command paths, declared effects and cardinality, bounded collections, stable error kinds and exit codes, stdout/stderr separation, and safe noninteractive behavior.
|
|
127
140
|
|
|
141
|
+
## Releasing
|
|
142
|
+
|
|
143
|
+
Vership owns versioning, changelog generation, release commits, and tags. See
|
|
144
|
+
[the release runbook](docs/releases.md) for the verified workflow and recovery policy.
|
|
145
|
+
|
|
@@ -12,9 +12,11 @@ For humans, no arguments open guided onboarding on first use and the TUI afterwa
|
|
|
12
12
|
pipx install teams-cli
|
|
13
13
|
# or
|
|
14
14
|
cargo install teams-cli --locked
|
|
15
|
+
# or run from PyPI without installing
|
|
16
|
+
uvx teams-cli --help
|
|
15
17
|
```
|
|
16
18
|
|
|
17
|
-
The PyPI distribution
|
|
19
|
+
The PyPI distribution provides both `teams` and `teams-cli` as executable names.
|
|
18
20
|
|
|
19
21
|
To work from source:
|
|
20
22
|
|
|
@@ -92,11 +94,22 @@ teams auth login --channel-history # once, with administrator approval
|
|
|
92
94
|
teams messages list --team TEAM_ID --channel CHANNEL_ID
|
|
93
95
|
teams messages send --chat CHAT_ID --body "On it."
|
|
94
96
|
printf 'Status update' | teams messages send --chat CHAT_ID --body -
|
|
97
|
+
teams config show
|
|
95
98
|
teams doctor
|
|
96
99
|
```
|
|
97
100
|
|
|
98
101
|
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
102
|
|
|
103
|
+
Profiles can block every remote write while keeping reads, sign-in, diagnostics, and the TUI available:
|
|
104
|
+
|
|
105
|
+
```console
|
|
106
|
+
teams init --no-login --read-only
|
|
107
|
+
# or for one invocation/environment
|
|
108
|
+
TEAMS_READ_ONLY=true teams messages send --chat CHAT_ID --body "Blocked"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Like the other tools in the suite, the config file is selected with `--profile`, overridden by `TEAMS_*` environment variables, and inspectable without exposing credentials through `teams config show` and `teams config path`.
|
|
112
|
+
|
|
100
113
|
## Boundaries
|
|
101
114
|
|
|
102
115
|
- Microsoft 365 work or school accounts only. The relevant Teams Graph endpoints do not support personal Microsoft accounts.
|
|
@@ -108,3 +121,8 @@ Collection commands expose `--limit` and `--fields`; Graph-native collections us
|
|
|
108
121
|
## CLI Spec
|
|
109
122
|
|
|
110
123
|
`teams schema` is offline, unauthenticated, and follows [CLI Spec v0.3](https://clispec.dev/): flat invocable command paths, declared effects and cardinality, bounded collections, stable error kinds and exit codes, stdout/stderr separation, and safe noninteractive behavior.
|
|
124
|
+
|
|
125
|
+
## Releasing
|
|
126
|
+
|
|
127
|
+
Vership owns versioning, changelog generation, release commits, and tags. See
|
|
128
|
+
[the release runbook](docs/releases.md) for the verified workflow and recovery policy.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Releases
|
|
2
|
+
|
|
3
|
+
Vership is the release control plane for `teams-cli`. GitHub Actions is the
|
|
4
|
+
execution plane for cross-platform builds, publication, provenance, and
|
|
5
|
+
downstream packaging.
|
|
6
|
+
|
|
7
|
+
## Create a release
|
|
8
|
+
|
|
9
|
+
Start from a clean, up-to-date `main` branch and run:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
vership preflight
|
|
13
|
+
vership bump patch # or minor/major
|
|
14
|
+
tarry cmd --timeout 20m -- vership verify
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Vership runs `make check`, updates the package version and changelog, creates
|
|
18
|
+
the Conventional Commit release commit and tag, and pushes both. The tag starts
|
|
19
|
+
the release workflow. Use `vership release` only when the on-disk version was
|
|
20
|
+
intentionally set in advance.
|
|
21
|
+
|
|
22
|
+
Cargo and Python package versions are kept synchronized as one release unit.
|
|
23
|
+
|
|
24
|
+
## Failure policy
|
|
25
|
+
|
|
26
|
+
If a release fails before any release, artifact, package, checksum, or
|
|
27
|
+
attestation becomes public, delete and recreate the brief tag and retry the
|
|
28
|
+
same version. Once anything was published, preserve the tag and issue a patch
|
|
29
|
+
release for release-content corrections. Registry and downstream-packaging
|
|
30
|
+
jobs are separate recovery domains; retry only the failed job when possible.
|
|
31
|
+
|
|
32
|
+
## Dry runs
|
|
33
|
+
|
|
34
|
+
The Release workflow can be dispatched from `main` with `dry_run` enabled. It
|
|
35
|
+
uses the package version unless an explicit `version` is supplied, builds the
|
|
36
|
+
real artifacts, and exercises registry validation without publishing packages,
|
|
37
|
+
creating a GitHub release, generating attestations, or updating downstream
|
|
38
|
+
packaging.
|
|
@@ -36,6 +36,11 @@ pub enum Command {
|
|
|
36
36
|
#[command(subcommand)]
|
|
37
37
|
command: AuthCommand,
|
|
38
38
|
},
|
|
39
|
+
/// Inspect resolved, secret-free configuration
|
|
40
|
+
Config {
|
|
41
|
+
#[command(subcommand)]
|
|
42
|
+
command: ConfigCommand,
|
|
43
|
+
},
|
|
39
44
|
/// Show the signed-in Microsoft 365 identity
|
|
40
45
|
Whoami,
|
|
41
46
|
/// List teams joined by the signed-in user
|
|
@@ -87,6 +92,17 @@ pub struct InitArgs {
|
|
|
87
92
|
/// Request admin-consented access to read channel history
|
|
88
93
|
#[arg(long)]
|
|
89
94
|
pub channel_history: bool,
|
|
95
|
+
/// Block remote write operations for this profile
|
|
96
|
+
#[arg(long, env = "TEAMS_READ_ONLY")]
|
|
97
|
+
pub read_only: bool,
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#[derive(Debug, Subcommand)]
|
|
101
|
+
pub enum ConfigCommand {
|
|
102
|
+
/// Print the resolved profile without credentials
|
|
103
|
+
Show,
|
|
104
|
+
/// Print the absolute configuration file path
|
|
105
|
+
Path,
|
|
90
106
|
}
|
|
91
107
|
|
|
92
108
|
#[derive(Debug, Subcommand)]
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
use std::collections::BTreeMap;
|
|
2
|
+
use std::io::Write;
|
|
2
3
|
use std::path::PathBuf;
|
|
3
4
|
|
|
4
5
|
use serde::{Deserialize, Serialize};
|
|
@@ -14,6 +15,21 @@ pub struct Profile {
|
|
|
14
15
|
pub client_id: String,
|
|
15
16
|
#[serde(default = "default_tenant")]
|
|
16
17
|
pub tenant: String,
|
|
18
|
+
#[serde(default)]
|
|
19
|
+
pub read_only: bool,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
impl Profile {
|
|
23
|
+
pub fn require_writable(&self) -> Result<(), AppError> {
|
|
24
|
+
if self.read_only {
|
|
25
|
+
Err(AppError::ReadOnly(
|
|
26
|
+
"read-only mode is enabled (unset TEAMS_READ_ONLY or disable read_only in the active profile to allow writes)"
|
|
27
|
+
.into(),
|
|
28
|
+
))
|
|
29
|
+
} else {
|
|
30
|
+
Ok(())
|
|
31
|
+
}
|
|
32
|
+
}
|
|
17
33
|
}
|
|
18
34
|
|
|
19
35
|
fn default_tenant() -> String {
|
|
@@ -50,12 +66,18 @@ pub fn save(profile_name: &str, profile: Profile) -> Result<PathBuf, AppError> {
|
|
|
50
66
|
.ok_or_else(|| AppError::Unexpected("configuration path has no parent".into()))?;
|
|
51
67
|
std::fs::create_dir_all(parent)?;
|
|
52
68
|
let body = toml::to_string_pretty(&config).map_err(|e| AppError::Unexpected(e.to_string()))?;
|
|
53
|
-
|
|
69
|
+
let mut temp = tempfile::Builder::new()
|
|
70
|
+
.prefix(".config-")
|
|
71
|
+
.suffix(".toml.tmp")
|
|
72
|
+
.tempfile_in(parent)?;
|
|
73
|
+
temp.write_all(body.as_bytes())?;
|
|
74
|
+
temp.flush()?;
|
|
54
75
|
#[cfg(unix)]
|
|
55
76
|
{
|
|
56
77
|
use std::os::unix::fs::PermissionsExt;
|
|
57
|
-
std::fs::set_permissions(
|
|
78
|
+
std::fs::set_permissions(temp.path(), std::fs::Permissions::from_mode(0o600))?;
|
|
58
79
|
}
|
|
80
|
+
temp.persist(&path).map_err(|error| error.error)?;
|
|
59
81
|
Ok(path)
|
|
60
82
|
}
|
|
61
83
|
|
|
@@ -79,10 +101,33 @@ pub fn load(requested: Option<&str>) -> Result<(String, Profile), AppError> {
|
|
|
79
101
|
.ok()
|
|
80
102
|
.or_else(|| stored.map(|profile| profile.tenant.clone()))
|
|
81
103
|
.unwrap_or_else(default_tenant);
|
|
82
|
-
let
|
|
104
|
+
let read_only = match std::env::var("TEAMS_READ_ONLY") {
|
|
105
|
+
Ok(value) => parse_bool("TEAMS_READ_ONLY", &value)?,
|
|
106
|
+
Err(std::env::VarError::NotPresent) => stored.is_some_and(|profile| profile.read_only),
|
|
107
|
+
Err(error) => {
|
|
108
|
+
return Err(AppError::InvalidInput(format!(
|
|
109
|
+
"cannot read TEAMS_READ_ONLY: {error}"
|
|
110
|
+
)));
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
let profile = Profile {
|
|
114
|
+
client_id,
|
|
115
|
+
tenant,
|
|
116
|
+
read_only,
|
|
117
|
+
};
|
|
83
118
|
Ok((name, profile))
|
|
84
119
|
}
|
|
85
120
|
|
|
121
|
+
fn parse_bool(name: &str, value: &str) -> Result<bool, AppError> {
|
|
122
|
+
match value.trim().to_ascii_lowercase().as_str() {
|
|
123
|
+
"1" | "true" | "yes" | "on" => Ok(true),
|
|
124
|
+
"0" | "false" | "no" | "off" => Ok(false),
|
|
125
|
+
_ => Err(AppError::InvalidInput(format!(
|
|
126
|
+
"{name} must be true or false"
|
|
127
|
+
))),
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
86
131
|
#[cfg(windows)]
|
|
87
132
|
fn config_base() -> PathBuf {
|
|
88
133
|
dirs::config_dir().unwrap_or_else(|| PathBuf::from(".config"))
|
|
@@ -8,6 +8,8 @@ pub enum AppError {
|
|
|
8
8
|
#[error("{0}")]
|
|
9
9
|
Auth(String),
|
|
10
10
|
#[error("{0}")]
|
|
11
|
+
ReadOnly(String),
|
|
12
|
+
#[error("{0}")]
|
|
11
13
|
Permission(String),
|
|
12
14
|
#[error("{0}")]
|
|
13
15
|
NotFound(String),
|
|
@@ -32,7 +34,7 @@ pub struct ErrorContract {
|
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
pub const INVALID_INPUT: ErrorContract = ErrorContract {
|
|
35
|
-
kind: "
|
|
37
|
+
kind: "invalid_input",
|
|
36
38
|
exit_code: 2,
|
|
37
39
|
retryable: false,
|
|
38
40
|
description: "Arguments or local configuration are invalid",
|
|
@@ -45,13 +47,13 @@ pub const AUTH: ErrorContract = ErrorContract {
|
|
|
45
47
|
};
|
|
46
48
|
pub const PERMISSION: ErrorContract = ErrorContract {
|
|
47
49
|
kind: "permission_denied",
|
|
48
|
-
exit_code:
|
|
50
|
+
exit_code: 5,
|
|
49
51
|
retryable: false,
|
|
50
52
|
description: "The tenant or granted scopes do not permit the operation",
|
|
51
53
|
};
|
|
52
54
|
pub const NOT_FOUND: ErrorContract = ErrorContract {
|
|
53
55
|
kind: "not_found",
|
|
54
|
-
exit_code:
|
|
56
|
+
exit_code: 4,
|
|
55
57
|
retryable: false,
|
|
56
58
|
description: "The requested Teams resource does not exist",
|
|
57
59
|
};
|
|
@@ -63,26 +65,33 @@ pub const RATE_LIMIT: ErrorContract = ErrorContract {
|
|
|
63
65
|
};
|
|
64
66
|
pub const API: ErrorContract = ErrorContract {
|
|
65
67
|
kind: "api_error",
|
|
66
|
-
exit_code:
|
|
68
|
+
exit_code: 5,
|
|
67
69
|
retryable: false,
|
|
68
70
|
description: "Microsoft Graph returned an API error",
|
|
69
71
|
};
|
|
70
72
|
pub const NON_INTERACTIVE: ErrorContract = ErrorContract {
|
|
71
73
|
kind: "tty_required",
|
|
72
|
-
exit_code:
|
|
74
|
+
exit_code: 2,
|
|
73
75
|
retryable: false,
|
|
74
76
|
description: "An interactive command was invoked without a terminal",
|
|
75
77
|
};
|
|
76
78
|
pub const UNEXPECTED: ErrorContract = ErrorContract {
|
|
77
|
-
kind: "
|
|
79
|
+
kind: "unexpected_error",
|
|
78
80
|
exit_code: 1,
|
|
79
81
|
retryable: false,
|
|
80
82
|
description: "An unexpected local or transport error occurred",
|
|
81
83
|
};
|
|
84
|
+
pub const READ_ONLY: ErrorContract = ErrorContract {
|
|
85
|
+
kind: "read_only",
|
|
86
|
+
exit_code: 2,
|
|
87
|
+
retryable: false,
|
|
88
|
+
description: "The active profile blocks remote write operations",
|
|
89
|
+
};
|
|
82
90
|
|
|
83
91
|
pub const ALL: &[ErrorContract] = &[
|
|
84
92
|
INVALID_INPUT,
|
|
85
93
|
AUTH,
|
|
94
|
+
READ_ONLY,
|
|
86
95
|
PERMISSION,
|
|
87
96
|
NOT_FOUND,
|
|
88
97
|
RATE_LIMIT,
|
|
@@ -96,6 +105,7 @@ impl AppError {
|
|
|
96
105
|
match self {
|
|
97
106
|
Self::InvalidInput(_) => INVALID_INPUT,
|
|
98
107
|
Self::Auth(_) => AUTH,
|
|
108
|
+
Self::ReadOnly(_) => READ_ONLY,
|
|
99
109
|
Self::Permission(_) => PERMISSION,
|
|
100
110
|
Self::NotFound(_) => NOT_FOUND,
|
|
101
111
|
Self::RateLimit(_) => RATE_LIMIT,
|
|
@@ -6,8 +6,8 @@ use serde_json::Value;
|
|
|
6
6
|
|
|
7
7
|
use teams_cli::auth;
|
|
8
8
|
use teams_cli::cli::{
|
|
9
|
-
AuthCommand, ChannelsCommand, ChatsCommand, Cli, Command,
|
|
10
|
-
OffsetPageArgs, PageArgs, TuiArgs,
|
|
9
|
+
AuthCommand, ChannelsCommand, ChatsCommand, Cli, Command, ConfigCommand, InitArgs,
|
|
10
|
+
MessagesCommand, OffsetPageArgs, PageArgs, TuiArgs,
|
|
11
11
|
};
|
|
12
12
|
use teams_cli::config::{self, Profile};
|
|
13
13
|
use teams_cli::error::AppError;
|
|
@@ -36,7 +36,7 @@ async fn main() {
|
|
|
36
36
|
}
|
|
37
37
|
};
|
|
38
38
|
let out = Output {
|
|
39
|
-
format: if cli.json {
|
|
39
|
+
format: if cli.json && cli.output == OutputFormat::Auto {
|
|
40
40
|
OutputFormat::Json
|
|
41
41
|
} else {
|
|
42
42
|
cli.output
|
|
@@ -60,6 +60,7 @@ async fn dispatch(cli: Cli, out: Output) -> Result<(), AppError> {
|
|
|
60
60
|
no_login: false,
|
|
61
61
|
device_code: false,
|
|
62
62
|
channel_history: false,
|
|
63
|
+
read_only: false,
|
|
63
64
|
})
|
|
64
65
|
}
|
|
65
66
|
None if io::stdin().is_terminal() && io::stdout().is_terminal() => Command::Tui(TuiArgs {
|
|
@@ -77,6 +78,7 @@ async fn dispatch(cli: Cli, out: Output) -> Result<(), AppError> {
|
|
|
77
78
|
match command {
|
|
78
79
|
Command::Init(args) => init(profile_arg.unwrap_or("default"), args, out).await,
|
|
79
80
|
Command::Auth { command } => auth_command(profile_arg, command, out).await,
|
|
81
|
+
Command::Config { command } => config_command(profile_arg, command, out),
|
|
80
82
|
Command::Whoami => {
|
|
81
83
|
let client = client(profile_arg).await?;
|
|
82
84
|
let value = client.me().await?;
|
|
@@ -137,7 +139,10 @@ async fn dispatch(cli: Cli, out: Output) -> Result<(), AppError> {
|
|
|
137
139
|
"message body cannot be empty".into(),
|
|
138
140
|
));
|
|
139
141
|
}
|
|
140
|
-
let
|
|
142
|
+
let (name, profile) = config::load(profile_arg)?;
|
|
143
|
+
profile.require_writable()?;
|
|
144
|
+
let token = auth::access_token(&name, &profile).await?;
|
|
145
|
+
let client = GraphClient::new(token);
|
|
141
146
|
let value = client
|
|
142
147
|
.send(chat.as_deref(), team.as_deref(), channel.as_deref(), &body)
|
|
143
148
|
.await?;
|
|
@@ -199,6 +204,7 @@ async fn init(profile_name: &str, args: InitArgs, out: Output) -> Result<(), App
|
|
|
199
204
|
Profile {
|
|
200
205
|
client_id: client_id.clone(),
|
|
201
206
|
tenant: args.tenant.clone(),
|
|
207
|
+
read_only: args.read_only,
|
|
202
208
|
},
|
|
203
209
|
)?;
|
|
204
210
|
let mut signed_in = false;
|
|
@@ -222,6 +228,7 @@ async fn init(profile_name: &str, args: InitArgs, out: Output) -> Result<(), App
|
|
|
222
228
|
client_id: &'a str,
|
|
223
229
|
tenant: &'a str,
|
|
224
230
|
channel_history_requested: bool,
|
|
231
|
+
read_only: bool,
|
|
225
232
|
}
|
|
226
233
|
let result = InitResult {
|
|
227
234
|
profile: profile_name,
|
|
@@ -230,6 +237,7 @@ async fn init(profile_name: &str, args: InitArgs, out: Output) -> Result<(), App
|
|
|
230
237
|
client_id: &client_id,
|
|
231
238
|
tenant: &args.tenant,
|
|
232
239
|
channel_history_requested: args.channel_history,
|
|
240
|
+
read_only: args.read_only,
|
|
233
241
|
};
|
|
234
242
|
out.value(&result, || {
|
|
235
243
|
if signed_in {
|
|
@@ -240,6 +248,38 @@ async fn init(profile_name: &str, args: InitArgs, out: Output) -> Result<(), App
|
|
|
240
248
|
})
|
|
241
249
|
}
|
|
242
250
|
|
|
251
|
+
fn config_command(
|
|
252
|
+
profile_arg: Option<&str>,
|
|
253
|
+
command: ConfigCommand,
|
|
254
|
+
out: Output,
|
|
255
|
+
) -> Result<(), AppError> {
|
|
256
|
+
match command {
|
|
257
|
+
ConfigCommand::Path => out.value(&config::path().display().to_string(), || {
|
|
258
|
+
config::path().display().to_string()
|
|
259
|
+
}),
|
|
260
|
+
ConfigCommand::Show => {
|
|
261
|
+
let (name, profile) = config::load(profile_arg)?;
|
|
262
|
+
let value = serde_json::json!({
|
|
263
|
+
"profile": name,
|
|
264
|
+
"client_id": profile.client_id,
|
|
265
|
+
"tenant": profile.tenant,
|
|
266
|
+
"read_only": profile.read_only,
|
|
267
|
+
"config_path": config::path(),
|
|
268
|
+
});
|
|
269
|
+
out.value(&value, || {
|
|
270
|
+
format!(
|
|
271
|
+
"Profile: {}\nTenant: {}\nClient ID: {}\nRead only: {}\nConfig: {}",
|
|
272
|
+
value["profile"].as_str().unwrap_or_default(),
|
|
273
|
+
value["tenant"].as_str().unwrap_or_default(),
|
|
274
|
+
value["client_id"].as_str().unwrap_or_default(),
|
|
275
|
+
yes_no(value["read_only"].as_bool().unwrap_or(false)),
|
|
276
|
+
config::path().display(),
|
|
277
|
+
)
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
243
283
|
async fn auth_command(
|
|
244
284
|
profile_arg: Option<&str>,
|
|
245
285
|
command: AuthCommand,
|
|
@@ -276,12 +316,13 @@ async fn auth_command(
|
|
|
276
316
|
let signed_in = auth::has_token(name);
|
|
277
317
|
let scopes = auth::granted_scopes(name);
|
|
278
318
|
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});
|
|
319
|
+
let value = serde_json::json!({"profile":name,"configured":configured.is_some(),"signed_in":signed_in,"config_path":config::path(),"read_only":configured.as_ref().is_some_and(|(_, profile)| profile.read_only),"granted_scopes":scopes,"channel_history":channel_history});
|
|
280
320
|
out.value(&value, || {
|
|
281
321
|
format!(
|
|
282
|
-
"Profile: {name}\nConfigured: {}\nSigned in: {}\nChannel history: {}\nConfig: {}",
|
|
322
|
+
"Profile: {name}\nConfigured: {}\nSigned in: {}\nRead only: {}\nChannel history: {}\nConfig: {}",
|
|
283
323
|
yes_no(configured.is_some()),
|
|
284
324
|
yes_no(signed_in),
|
|
325
|
+
yes_no(value["read_only"].as_bool().unwrap_or(false)),
|
|
285
326
|
optional_access(channel_history),
|
|
286
327
|
config::path().display()
|
|
287
328
|
)
|
|
@@ -398,6 +439,17 @@ async fn doctor(profile_arg: Option<&str>, out: Output) -> Result<(), AppError>
|
|
|
398
439
|
status: if configured.is_some() { "pass" } else { "fail" },
|
|
399
440
|
detail: config::path().display().to_string(),
|
|
400
441
|
});
|
|
442
|
+
if let Some((_, profile)) = &configured {
|
|
443
|
+
checks.push(Check {
|
|
444
|
+
name: "write_safety",
|
|
445
|
+
status: "pass",
|
|
446
|
+
detail: if profile.read_only {
|
|
447
|
+
"read-only mode is enabled".into()
|
|
448
|
+
} else {
|
|
449
|
+
"remote writes are enabled".into()
|
|
450
|
+
},
|
|
451
|
+
});
|
|
452
|
+
}
|
|
401
453
|
let signed_in = configured
|
|
402
454
|
.as_ref()
|
|
403
455
|
.is_some_and(|(name, _)| auth::has_token(name));
|
|
@@ -5,7 +5,7 @@ use serde::Serialize;
|
|
|
5
5
|
|
|
6
6
|
use crate::error::AppError;
|
|
7
7
|
|
|
8
|
-
#[derive(Debug, Clone, Copy, ValueEnum)]
|
|
8
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
|
9
9
|
pub enum OutputFormat {
|
|
10
10
|
Auto,
|
|
11
11
|
Text,
|
|
@@ -62,7 +62,7 @@ pub fn print_error(error: &AppError, structured: bool) {
|
|
|
62
62
|
"retryable": contract.retryable,
|
|
63
63
|
}
|
|
64
64
|
});
|
|
65
|
-
eprintln!("{}", serde_json::to_string(&envelope).unwrap_or_else(|_| r#"{"error":{"kind":"
|
|
65
|
+
eprintln!("{}", serde_json::to_string(&envelope).unwrap_or_else(|_| r#"{"error":{"kind":"unexpected_error","message":"error serialization failed","retryable":false}}"#.into()));
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
@@ -73,6 +73,7 @@ pub fn generate(command_filter: Option<&str>) -> Value {
|
|
|
73
73
|
field("client_id", "string"),
|
|
74
74
|
field("tenant", "string"),
|
|
75
75
|
field("channel_history_requested", "boolean"),
|
|
76
|
+
field("read_only", "boolean"),
|
|
76
77
|
],
|
|
77
78
|
);
|
|
78
79
|
value["args"] = json!([
|
|
@@ -92,7 +93,8 @@ pub fn generate(command_filter: Option<&str>) -> Value {
|
|
|
92
93
|
"--channel-history",
|
|
93
94
|
"boolean",
|
|
94
95
|
"Request admin-consented channel-history access"
|
|
95
|
-
)
|
|
96
|
+
),
|
|
97
|
+
arg("--read-only", "boolean", "Block remote write operations")
|
|
96
98
|
]);
|
|
97
99
|
value
|
|
98
100
|
},
|
|
@@ -137,10 +139,29 @@ pub fn generate(command_filter: Option<&str>) -> Value {
|
|
|
137
139
|
field("configured", "boolean"),
|
|
138
140
|
field("signed_in", "boolean"),
|
|
139
141
|
field("config_path", "string"),
|
|
142
|
+
field("read_only", "boolean"),
|
|
140
143
|
nullable_array_field("granted_scopes", "string"),
|
|
141
144
|
nullable_field("channel_history", "boolean"),
|
|
142
145
|
],
|
|
143
146
|
),
|
|
147
|
+
single(
|
|
148
|
+
"config show",
|
|
149
|
+
"Print the resolved profile without credentials",
|
|
150
|
+
"read_only",
|
|
151
|
+
vec![
|
|
152
|
+
field("profile", "string"),
|
|
153
|
+
field("client_id", "string"),
|
|
154
|
+
field("tenant", "string"),
|
|
155
|
+
field("read_only", "boolean"),
|
|
156
|
+
field("config_path", "string"),
|
|
157
|
+
],
|
|
158
|
+
),
|
|
159
|
+
single(
|
|
160
|
+
"config path",
|
|
161
|
+
"Print the absolute configuration file path",
|
|
162
|
+
"read_only",
|
|
163
|
+
vec![],
|
|
164
|
+
),
|
|
144
165
|
single(
|
|
145
166
|
"whoami",
|
|
146
167
|
"Show the signed-in Microsoft 365 identity",
|
|
@@ -275,6 +296,7 @@ pub fn generate(command_filter: Option<&str>) -> Value {
|
|
|
275
296
|
"api":"Microsoft Graph v1.0",
|
|
276
297
|
"default_client_id": crate::config::DEFAULT_CLIENT_ID,
|
|
277
298
|
"login_modes":["browser_pkce","device_code"],
|
|
299
|
+
"read_only_env":"TEAMS_READ_ONLY",
|
|
278
300
|
"baseline_scopes": crate::auth::BASE_SCOPES.split_whitespace().collect::<Vec<_>>(),
|
|
279
301
|
"privileged_scopes":[crate::auth::CHANNEL_HISTORY_SCOPE]
|
|
280
302
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
use std::env;
|
|
2
|
+
use std::fmt::Display;
|
|
3
|
+
use std::process::{self, Command};
|
|
4
|
+
|
|
5
|
+
const COMMAND: &str = "teams";
|
|
6
|
+
|
|
7
|
+
fn fail(error: impl Display) -> ! {
|
|
8
|
+
eprintln!("teams-cli: failed to launch {COMMAND}: {error}");
|
|
9
|
+
process::exit(126);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
fn main() {
|
|
13
|
+
let mut executable = env::current_exe().unwrap_or_else(|error| fail(error));
|
|
14
|
+
executable.set_file_name(format!("{COMMAND}{}", env::consts::EXE_SUFFIX));
|
|
15
|
+
|
|
16
|
+
let mut command = Command::new(executable);
|
|
17
|
+
command.args(env::args_os().skip(1));
|
|
18
|
+
|
|
19
|
+
#[cfg(unix)]
|
|
20
|
+
{
|
|
21
|
+
use std::os::unix::process::CommandExt;
|
|
22
|
+
fail(command.exec());
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
#[cfg(not(unix))]
|
|
26
|
+
match command.status() {
|
|
27
|
+
Ok(status) => process::exit(status.code().unwrap_or(1)),
|
|
28
|
+
Err(error) => fail(error),
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -101,7 +101,7 @@ fn no_args_never_prompts_when_piped() {
|
|
|
101
101
|
Command::cargo_bin("teams")
|
|
102
102
|
.unwrap()
|
|
103
103
|
.assert()
|
|
104
|
-
.code(
|
|
104
|
+
.code(2)
|
|
105
105
|
.stderr(predicate::str::contains("\"kind\":\"tty_required\""));
|
|
106
106
|
}
|
|
107
107
|
|
|
@@ -119,9 +119,54 @@ fn noninteractive_init_uses_the_maintained_client_id() {
|
|
|
119
119
|
assert_eq!(result["client_id"], "66ebad71-1604-48fc-a086-0d4caa24988b");
|
|
120
120
|
assert_eq!(result["tenant"], "organizations");
|
|
121
121
|
assert_eq!(result["channel_history_requested"], false);
|
|
122
|
+
assert_eq!(result["read_only"], false);
|
|
122
123
|
assert!(temp.path().join("teams/config.toml").is_file());
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
#[test]
|
|
127
|
+
fn init_can_create_a_read_only_profile_and_config_show_resolves_it() {
|
|
128
|
+
let temp = tempfile::tempdir().unwrap();
|
|
129
|
+
Command::cargo_bin("teams")
|
|
130
|
+
.unwrap()
|
|
131
|
+
.env("XDG_CONFIG_HOME", temp.path())
|
|
132
|
+
.args(["init", "--no-login", "--read-only"])
|
|
133
|
+
.assert()
|
|
134
|
+
.success();
|
|
135
|
+
|
|
136
|
+
Command::cargo_bin("teams")
|
|
137
|
+
.unwrap()
|
|
138
|
+
.env("XDG_CONFIG_HOME", temp.path())
|
|
139
|
+
.args(["config", "show"])
|
|
140
|
+
.assert()
|
|
141
|
+
.success()
|
|
142
|
+
.stdout(predicate::str::contains("\"read_only\": true"));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
#[test]
|
|
146
|
+
fn suite_exit_codes_and_error_kinds_are_stable() {
|
|
147
|
+
let schema: Value = serde_json::from_slice(
|
|
148
|
+
&Command::cargo_bin("teams")
|
|
149
|
+
.unwrap()
|
|
150
|
+
.arg("schema")
|
|
151
|
+
.output()
|
|
152
|
+
.unwrap()
|
|
153
|
+
.stdout,
|
|
154
|
+
)
|
|
155
|
+
.unwrap();
|
|
156
|
+
let errors = schema["errors"].as_array().unwrap();
|
|
157
|
+
let code = |kind: &str| {
|
|
158
|
+
errors.iter().find(|error| error["kind"] == kind).unwrap()["exit_code"]
|
|
159
|
+
.as_i64()
|
|
160
|
+
.unwrap()
|
|
161
|
+
};
|
|
162
|
+
assert_eq!(code("invalid_input"), 2);
|
|
163
|
+
assert_eq!(code("auth"), 3);
|
|
164
|
+
assert_eq!(code("not_found"), 4);
|
|
165
|
+
assert_eq!(code("api_error"), 5);
|
|
166
|
+
assert_eq!(code("rate_limit"), 6);
|
|
167
|
+
assert_eq!(code("read_only"), 2);
|
|
168
|
+
}
|
|
169
|
+
|
|
125
170
|
#[test]
|
|
126
171
|
fn headless_browser_login_refuses_before_writing_config() {
|
|
127
172
|
let temp = tempfile::tempdir().unwrap();
|
|
@@ -130,7 +175,7 @@ fn headless_browser_login_refuses_before_writing_config() {
|
|
|
130
175
|
.env("XDG_CONFIG_HOME", temp.path())
|
|
131
176
|
.arg("init")
|
|
132
177
|
.assert()
|
|
133
|
-
.code(
|
|
178
|
+
.code(2)
|
|
134
179
|
.stderr(predicate::str::contains(
|
|
135
180
|
"browser sign-in requires a terminal",
|
|
136
181
|
));
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
use std::process::{Command, Output};
|
|
2
|
+
|
|
3
|
+
fn run(executable: &str, argument: &str) -> Output {
|
|
4
|
+
Command::new(executable)
|
|
5
|
+
.arg(argument)
|
|
6
|
+
.output()
|
|
7
|
+
.expect("CLI should start")
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
#[test]
|
|
11
|
+
fn package_named_alias_forwards_arguments_output_and_status() {
|
|
12
|
+
for argument in ["--version", "--__alias-test-invalid"] {
|
|
13
|
+
let primary = run(env!("CARGO_BIN_EXE_teams"), argument);
|
|
14
|
+
let alias = run(env!("CARGO_BIN_EXE_teams-cli"), argument);
|
|
15
|
+
|
|
16
|
+
assert_eq!(alias.status.code(), primary.status.code());
|
|
17
|
+
assert_eq!(alias.stdout, primary.stdout);
|
|
18
|
+
assert_eq!(alias.stderr, primary.stderr);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
name: CI
|
|
2
|
-
|
|
3
|
-
on:
|
|
4
|
-
push:
|
|
5
|
-
pull_request:
|
|
6
|
-
workflow_call:
|
|
7
|
-
|
|
8
|
-
permissions:
|
|
9
|
-
contents: read
|
|
10
|
-
|
|
11
|
-
jobs:
|
|
12
|
-
test:
|
|
13
|
-
name: Test (${{ matrix.os }})
|
|
14
|
-
strategy:
|
|
15
|
-
fail-fast: false
|
|
16
|
-
matrix:
|
|
17
|
-
os: [ubuntu-latest, macos-latest, windows-latest]
|
|
18
|
-
runs-on: ${{ matrix.os }}
|
|
19
|
-
steps:
|
|
20
|
-
- uses: actions/checkout@v7
|
|
21
|
-
- uses: dtolnay/rust-toolchain@stable
|
|
22
|
-
- uses: Swatinem/rust-cache@v2
|
|
23
|
-
- run: cargo test --locked --all-targets
|
|
24
|
-
|
|
25
|
-
quality:
|
|
26
|
-
name: Formatting and lints
|
|
27
|
-
runs-on: ubuntu-latest
|
|
28
|
-
steps:
|
|
29
|
-
- uses: actions/checkout@v7
|
|
30
|
-
- uses: dtolnay/rust-toolchain@stable
|
|
31
|
-
with:
|
|
32
|
-
components: rustfmt, clippy
|
|
33
|
-
- uses: Swatinem/rust-cache@v2
|
|
34
|
-
- run: cargo fmt -- --check
|
|
35
|
-
- run: cargo clippy --locked --all-targets -- -D warnings
|
|
36
|
-
|
|
37
|
-
security:
|
|
38
|
-
name: Dependency audit
|
|
39
|
-
runs-on: ubuntu-latest
|
|
40
|
-
steps:
|
|
41
|
-
- uses: actions/checkout@v7
|
|
42
|
-
- uses: dtolnay/rust-toolchain@stable
|
|
43
|
-
- uses: Swatinem/rust-cache@v2
|
|
44
|
-
- name: Install cargo-audit
|
|
45
|
-
run: cargo install cargo-audit --version 0.22.2 --locked
|
|
46
|
-
- name: Audit Cargo.lock
|
|
47
|
-
run: cargo audit
|
|
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
|