Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,8 +149,9 @@ same for the completion scripts.
responses are quoted rather than written, and the `cancelled` error code,
which is a compatibility promise rather than a spelling.
- **Four rules the compiler holds rather than a reviewer**, declared in
`Cargo.toml` with the reasoning beside each: no `unsafe`, no `println!`, no
`dbg!`, no `todo!`/`unimplemented!`. `print_stderr` is deliberately *not*
`Cargo.toml` with the reasoning beside each: no `unsafe` (one `#[allow]`,
in `src/detach.rs`, says why), no `println!`, no `dbg!`, no
`todo!`/`unimplemented!`. `print_stderr` is deliberately *not*
denied — progress belongs there.
- **The toolchain is pinned, and the floor is a different number.**
`rust-toolchain.toml` names the exact Rust every clone and every workflow
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,15 @@ that may never merge. They are not releases and are not listed here.
code; it does find a `~/.mapbox/history` directory it didn't before, and
`mapbox config list` now reports a second key, `history`.

- Each run sends one `cli.command` telemetry event to Mapbox, from a
background process the command doesn't wait for, with your own token
(`--token`, `MAPBOX_ACCESS_TOKEN` or your login) or, when you have none,
one built into the CLI. A build from source has no built-in token, so with
no token of your own the event is dropped. It never touches stdout, the
exit code or how long a command takes, so a script sees no difference; a
machine that already has `~/.mapbox` finds a `.telemetry` directory in it.
`MAPBOX_CLI_NO_TELEMETRY=1` turns it off.

- `MAPBOX_CLI_EXTRA_QUERY` appends raw query parameters to every request, in
the same `k1=v1&k2=v2` shape as a URL's own query string — for an API
parameter this CLI's specs don't declare a flag for.
Expand Down
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ installers end to end without touching the network, and
`scripts/test-completion.sh` does the same for the completion scripts.

Four rules the compiler holds rather than a reviewer, declared in
`Cargo.toml` with the reasoning beside each: no `unsafe`, no `println!`
`Cargo.toml` with the reasoning beside each: no `unsafe` (outside one
`#[allow]` in `src/detach.rs`), no `println!`
(stdout belongs to `output::emit`, the single place `--output` is honored),
no `dbg!`, no `todo!`/`unimplemented!`.

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 8 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ path = "src/main.rs"
# until somebody writes the obvious thing at two in the morning.
[lints.rust]
# There is no `unsafe` in this crate outside a couple of `env::set_var` calls
# in tests, and there is no reason for there to be: nothing here does FFI,
# manual memory management or lock-free anything. Denying it means adding some
# in tests and two Win32 calls in `src/detach.rs` (the reason is there), and
# there is no reason for more: nothing else here does FFI, manual memory
# management or lock-free anything. Denying it means adding some
# is a deliberate `#[allow]` with a reason next to it rather than a diff nobody
# looks twice at.
unsafe_code = "deny"
Expand Down Expand Up @@ -111,6 +112,11 @@ jsonc-parser = { version = "0.34.0", features = ["serde", "serde_json"] }
# that shows help; see src/output/theme.rs.
terminal-colorsaurus = "1"

[target.'cfg(windows)'.dependencies]
# For `src/detach.rs`: keeping the parent's standard handles out of a detached
# child. Already in the graph through tokio and others; the same version.
windows-sys = { version = "0.61", features = ["Win32_Foundation", "Win32_System_Console"] }

[dev-dependencies]
# Integration tests build fake Mapbox tokens; same crate the CLI already uses.
base64 = "0.22"
53 changes: 47 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,9 +281,31 @@ deeper where that reads better, as in `mapbox styles draft get`.
[docs/commands.md](./docs/commands.md) lists every command with its
parameters and sample output.

Every request sends `User-Agent: mapbox-cli/<version>` and nothing else
about you or your machine. `MAPBOX_CLI_NO_TELEMETRY=1` keeps even future
markers out of that header.
Every API request a command makes sends `User-Agent: mapbox-cli/<version>`
and nothing else about you or your machine. `MAPBOX_CLI_NO_TELEMETRY=1`
keeps even future markers out of that header.

Each run also sends one event to Mapbox:

- the command's name and its options: a value only for a flag, a choice from
a fixed list, a number, or a language, country or feature-type code;
otherwise just its length or size. A coordinate or tile address is sent
by name only, and a JSON body only by the field names the API defines;
- how the run ended, how long it took, and how many requests it made;
- your OS and architecture, how the CLI was installed, the previous version
after an upgrade, and whether it ran in CI or under a detected AI coding
agent;
- which kind of token the command used and the Mapbox account it belongs to.

It never includes a file path or free text you typed. It carries a random ID
that is replaced on the first run a day after it was created. It is sent in
the background, so a command never waits for it, and dropped if it can't be
delivered. It is sent with your own token (`--token`, `MAPBOX_ACCESS_TOKEN`
or your login) when you have one, and with a token built into the CLI
otherwise; Mapbox Events keeps the token an event was sent with, and the
account it belongs to, alongside the event. A build from source has no
built-in token, so with no token of your own the event is dropped.
`MAPBOX_CLI_NO_TELEMETRY=1` turns it off.

### Diagnostics and settings

Expand Down Expand Up @@ -451,6 +473,14 @@ process sends, in the same `k1=v1&k2=v2` shape as a URL's own query string —
for an API parameter this CLI's specs don't declare a flag for. `--debug`
and `--dry-run` show it alongside everything else on the request.

### The CLI's own requests

Requests the CLI makes for itself rather than for your commands use your own
token whenever you have one: `--token` or `MAPBOX_ACCESS_TOKEN`, then your
login. Only when you have none does it use the CLI's token:
`MAPBOX_CLI_TOKEN`, or one built into the binary. The CLI's token is only
used for what `src/cli_token.rs` lists for it, today just telemetry.

### Proxies

`HTTPS_PROXY`, `HTTP_PROXY`, `ALL_PROXY` and `NO_PROXY` are all honored, so
Expand Down Expand Up @@ -598,8 +628,18 @@ Mapbox collects telemetry data from our CLIs to better understand how our
tools are used and how to improve our products.

- **What Telemetry Data We Collect:** Usage metrics include installs, the
service a Mapbox API command belongs to (e.g. `styles` or `geocoder`,
never the operation or its arguments), CLI version, OS/architecture,
service a Mapbox API command belongs to (e.g. `styles` or `geocoder`), the
command that ran and the shape of its options as described under
[API commands](#api-commands) (values only for flags, fixed choices,
numbers and language, country or feature-type codes; never coordinates,
file paths or free text), the global options it used (such as `--output`,
`--dry-run` or whether a named profile was used), how it ended and how
long it took, how many requests it made with their HTTP status and sizes,
the kind of token it used and the Mapbox account that token belongs to,
the Mapbox access token the event itself was sent with, a random
identifier that is replaced every day, which run started this one when it
ran inside another, CLI version, how the CLI was installed, the version
it was updated from and any newer one it offered, OS/architecture,
whether stdin and stdout are attached to a terminal, an identifier for
the detected AI coding agent (if any) running the command (based on
signals such as the presence of the `CLAUDECODE` or `COPILOT_MODEL`
Expand All @@ -612,7 +652,8 @@ tools are used and how to improve our products.
adoption, prioritize investments, and improve the reliability,
performance, and developer experience of our CLIs.
- **What We Do Not Collect:** Code completion outputs, source code, project
file names, directory contents, non-Mapbox API keys, or credentials.
file names, directory contents, non-Mapbox API keys, or credentials other
than the Mapbox access token an event is sent with.
- **Who has Access:** Telemetry data will not be disclosed to, or accessed
by, third parties other than Mapbox affiliates and passive cloud storage
and hosting providers necessary to maintain our infrastructure.
Expand Down
16 changes: 16 additions & 0 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@
use std::path::Path;

fn main() {
check_bundled_token();

let manifest = Path::new("../internal/openapi-command-config/PINNED_SOURCE");

// The vendored specs and their provenance are ordinary build inputs now,
Expand Down Expand Up @@ -91,6 +93,20 @@ fn main() {
warn_if_stale(committed_at);
}

/// `src/cli_token.rs` compiles `MAPBOX_CLI_BUNDLED_TOKEN` into the binary,
/// where `strings` can read it. Only a public `pk.` token may go there, so
/// anything else fails the build rather than shipping a secret.
fn check_bundled_token() {
println!("cargo:rerun-if-env-changed=MAPBOX_CLI_BUNDLED_TOKEN");
let Ok(token) = std::env::var("MAPBOX_CLI_BUNDLED_TOKEN") else {
return;
};
let token = token.trim();
if !token.is_empty() && !token.starts_with("pk.") {
panic!("MAPBOX_CLI_BUNDLED_TOKEN must be a public pk. token");
}
}

/// New API surface lands in `openapi-specs` at something closer to a monthly
/// rate, and the maintainer-only sync that regenerates `openapi/` from it
/// runs weekly, so two weeks without one is already long enough to be worth a
Expand Down
13 changes: 7 additions & 6 deletions docs/commands.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Implemented commands

Every command the CLI ships: five auth commands, 39 API operations (35 across 9
Every command the CLI ships, including five auth commands, 39 API operations (35 across 9
command groups, plus `directions`, `isochrone`, `map-match` and `matrix`), the
tilesets-cli proxy, `completion` and `generate-skills`. Each is
shown in both of its renderings. Which one you get is decided by `--output`, whose default
Expand All @@ -11,8 +11,8 @@ gets the right one. See
Account names, style ids and tokens in the examples are replaced; everything
else is as the API sent it.

**35 of the 39 were run against the live API and show what came back:** 25
on 2026-09-01, `fonts list`, `fonts upload` and `fonts delete` on
**35 of the 39 were run against the live API and show what came back:** 24
on 2026-09-01, `styles download` by 2026-10-01, `fonts list`, `fonts upload` and `fonts delete` on
2026-09-08, once `fonts:list`/`fonts:write` became registrable,
`directions`, `isochrone` and `map-match` on 2026-09-23, `matrix`,
`feedback list` and `feedback get` on 2026-09-24, and `feedback create` on
Expand Down Expand Up @@ -121,6 +121,7 @@ nests, and is typed `mapbox styles draft get`.
[styles.get](#mapbox-styles-get) · [styles.create](#mapbox-styles-create) ·
[styles.update](#mapbox-styles-update) ·
[styles.delete](#mapbox-styles-delete) ·
[styles.download](#mapbox-styles-download) ·
[styles.draft.get](#mapbox-styles-draft-get) ·
[styles.draft.update](#mapbox-styles-draft-update) ·
[styles.draft.delete](#mapbox-styles-draft-delete)
Expand Down Expand Up @@ -674,8 +675,8 @@ properties directly. A conforming response never reaches that case:
`geocoder` requires `name`/`feature_type` on every feature, `tilesets query`
requires `tilequery.layer`.

Two of the nine command groups can answer with bytes — `static` and
`tilesets`. Those bypass `--output` in both modes:
Three of the nine command groups can answer with bytes — `static`,
`styles` (`download`) and `tilesets`. Those bypass `--output` in both modes:

<table>
<tr><th width="50%">Terminal — refuses</th><th width="50%">Redirected — raw bytes</th></tr>
Expand Down Expand Up @@ -1600,7 +1601,7 @@ mapbox isochrone mapbox/walking "-122.42,37.78" --contours-minutes 5,10 --polygo
Captured live against `mapbox/walking`, two 5- and 10-minute contours as
polygons. This response is a real GeoJSON `FeatureCollection`, unlike
`mapbox directions`'s response, but isochrone isn't one of the three
services (`search`, `geocoder`, `tilequery`) this CLI has a bespoke
services (`search`, `geocoder`, `tilesets`) this CLI has a bespoke
list-per-feature rendering for yet (`output/render.rs`'s `list_rendering` is an
exact service allow-list, not a "looks like GeoJSON" test), so both output
modes print the same JSON, `-o text` pretty-printed and `-o json` on one
Expand Down
10 changes: 7 additions & 3 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -479,8 +479,9 @@ fn credentials_path_readonly(profile: Option<&str>) -> Option<PathBuf> {
/// directory as a side effect of reading it — see
/// [`credentials_path_readonly`]. What [`profiles`] reads each stored
/// profile through, since listing what exists must not be the reason a
/// directory starts to exist or its permissions change.
fn load_credentials_readonly(profile: Option<&str>) -> Option<Credentials> {
/// directory starts to exist or its permissions change, and what
/// [`crate::cli_token`] reads the login through for the same reason.
pub(crate) fn load_credentials_readonly(profile: Option<&str>) -> Option<Credentials> {
let data = std::fs::read_to_string(credentials_path_readonly(profile)?).ok()?;
serde_json::from_str(&data).ok()
}
Expand Down Expand Up @@ -717,7 +718,7 @@ pub fn warn_if_environment_token_shadows_login(profile: Option<&str>, remedy: &s
}
}

fn token_needs_refresh(token: &str) -> bool {
pub(crate) fn token_needs_refresh(token: &str) -> bool {
match token_expires_at(token) {
Some(exp) => {
let now = std::time::SystemTime::now()
Expand Down Expand Up @@ -1067,6 +1068,9 @@ pub fn logout(profile: Option<&str>, mode: Mode) -> Result<()> {
let path = credentials_path(profile)?;
let had_credentials = path.exists();
if had_credentials {
// Waits out a refresh in flight, such as the telemetry sender's from
// the previous run, which would otherwise write the login back.
let _lock = CredentialLock::acquire(profile).ok();
crate::run_record::set_auth_step("remove_credentials");
std::fs::remove_file(&path)?;
}
Expand Down
Loading
Loading