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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ that may never merge. They are not releases and are not listed here.

### Added

- `mapbox config` — `get`/`set` for settings that persist across shells and
sessions, written to `~/.mapbox/config.json` (or `$MAPBOX_CONFIG_DIR`)
rather than an environment variable that only lasts for the session it was
set in. One setting today: `update-check`, which `mapbox config set
update-check off` turns off for good, mirroring `MAPBOX_NO_UPDATE_CHECK`.

- The README now documents installing without the install script: the
archives are plain HTTP downloads, `manifest.json` lists every target with
its checksum, and the commands to verify and extract one are written out.
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,12 +418,16 @@ kept narrow:
| What it sends | A `GET` for the channel's `latest/manifest.json`, with no token, no account, no command, and nothing about you or your machine beyond `User-Agent: mapbox-cli/<version>` |
| When | At most once a day, and only when stderr is a terminal, so CI and piped runs never check and never print |
| Where | A detached background process. Your command never waits on it: offline, the timing is unchanged and nothing is printed |
| Off | `MAPBOX_NO_UPDATE_CHECK=1`, or `MAPBOX_CLI_NO_TELEMETRY=1`, which silences this too |
| Off | `MAPBOX_NO_UPDATE_CHECK=1`, or `MAPBOX_CLI_NO_TELEMETRY=1`, which silences this too, for the shell session it's set in |

`~/.mapbox/update-check.json` (or `$MAPBOX_CONFIG_DIR`) holds the answer
between runs. A build that names no release channel never checks at all, and
`cargo build` produces one.

`mapbox config set update-check off` turns it off for good, in every shell —
see [Config](docs/commands.md#config) — rather than just the session an
environment variable happens to be set in.

### Privacy

**YOUR PRIVACY - COLLECTION OF TELEMETRY**
Expand Down
95 changes: 95 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ nests, and is typed `mapbox styles draft get`.

**[Uninstall](#uninstall)** — [uninstall](#mapbox-uninstall)

**[Config](#config)** — [config.get](#mapbox-config-get) ·
[config.set](#mapbox-config-set)

**[Usage](#usage)** — [usage](#mapbox-usage)

**[Accounts](#accounts)** —
Expand Down Expand Up @@ -3103,6 +3106,98 @@ Removed /home/user/.local/bin/mapbox.

---

## Config

Settings that persist across shells and sessions — `~/.mapbox/config.json`
(or `$MAPBOX_CONFIG_DIR`), written the same way credentials are. One setting
today, `update-check`, which mirrors `MAPBOX_NO_UPDATE_CHECK` (see [Update
notices](../README.md#update-notices)) but stays off in every future shell
rather than only the one the environment variable was set in.

### `mapbox config get`

Prints a setting's current value: `on` in `text` mode, `true`/`false` in
`json`. Reading an unset `update-check` reports `on` — its default — rather
than failing, the same forgiving read the update-check cache itself uses.

#### Parameters

| Parameter | Effect |
| --- | --- |
| `<key>` | Which setting to read. Only `update-check` exists today. |

#### Examples

```sh
mapbox config get update-check
```

#### Outputs

<table>
<tr><th width="50%"><code>text</code></th><th width="50%"><code>json</code></th></tr>
<tr><td>

```
on
```

</td><td>

```json
{
"key": "update-check",
"value": true
}
```

</td></tr>
</table>

### `mapbox config set`

Persists a setting to `~/.mapbox/config.json`, so it survives across shells
without an environment variable.

#### Parameters

| Parameter | Effect |
| --- | --- |
| `<key>` | Which setting to change. Only `update-check` exists today. |
| `<value>` | `on` or `off`. |

#### Examples

```sh
mapbox config set update-check off

mapbox config set update-check on
```

#### Outputs

<table>
<tr><th width="50%"><code>text</code></th><th width="50%"><code>json</code></th></tr>
<tr><td>

```
update-check set to off.
```

</td><td>

```json
{
"key": "update-check",
"value": false
}
```

</td></tr>
</table>

---

## Usage

### `mapbox usage`
Expand Down
184 changes: 184 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
//! `mapbox config` — settings that persist across shells and sessions.
//!
//! `MAPBOX_NO_UPDATE_CHECK=1` silences the update notice, but only for the
//! shell session that set it — there is no way to turn the check off once
//! and have it stay off. This is the persisted alternative: a small JSON
//! file beside the credentials, written through the same
//! [`crate::auth::write_private`] so it gets the same `0600` treatment.
//!
//! One setting today — `update-check` — with room for more: `get`/`set` take
//! a `key`, restricted by clap to [`KEYS`], so adding a second setting is a
//! new key and a new match arm rather than a new pair of subcommands.

use std::path::PathBuf;

use anyhow::{Context, Result};
use clap::builder::PossibleValuesParser;
use clap::{Arg, ArgMatches, Command};
use serde::{Deserialize, Serialize};
use serde_json::json;

use crate::auth;
use crate::output::{self, Mode};

pub const COMMAND: &str = "config";

const CONFIG_FILE: &str = "config.json";

const UPDATE_CHECK_KEY: &str = "update-check";
const KEYS: &[&str] = &[UPDATE_CHECK_KEY];

const ON: &str = "on";
const OFF: &str = "off";

/// What's persisted. `None` means "never set" for a setting whose CLI-visible
/// default is `on` — distinct from `Some(true)`, which is someone turning it
/// back on after having turned it off, but read identically by
/// [`update_check_setting`].
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Config {
#[serde(default, skip_serializing_if = "Option::is_none")]
update_check: Option<bool>,
}

fn config_path() -> Option<PathBuf> {
Some(auth::config_dir_path()?.join(CONFIG_FILE))
}

/// The persisted config, or the all-default one when there is nothing on
/// disk yet or what's there doesn't parse — the same forgiving read
/// [`crate::update_check`]'s cache uses, and for the same reason: a
/// malformed file here should cost nothing more than falling back to
/// defaults, never a failing command.
fn read_config() -> Config {
config_path()
.and_then(|path| std::fs::read_to_string(path).ok())
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default()
}

/// Best-effort in the read, deliberate in the write: `config set` is the one
/// command whose entire job is writing this file, so unlike the cache, a
/// failure here is reported rather than swallowed.
fn write_config(config: &Config) -> Result<()> {
let dir = auth::config_dir()?;
let text = serde_json::to_string(config).context("could not serialize the config")?;
auth::write_private(&dir.join(CONFIG_FILE), &text)
}

/// Pure half of [`update_check_enabled`], so the default can be pinned
/// without going through the filesystem.
fn update_check_setting(config: &Config) -> bool {
config.update_check.unwrap_or(true)
}

/// Whether the update check may run at all, per the persisted setting.
/// [`crate::update_check`] checks this alongside `MAPBOX_NO_UPDATE_CHECK` —
/// either one saying no is enough to stop it.
pub fn update_check_enabled() -> bool {
update_check_setting(&read_config())
}

fn on_off(enabled: bool) -> &'static str {
if enabled {
ON
} else {
OFF
}
}

pub fn command() -> Command {
let key_arg = || {
Arg::new("key")
.required(true)
.value_parser(PossibleValuesParser::new(KEYS))
};

Command::new(COMMAND)
.about("Get or set a persisted mapbox setting")
.long_about(
"Get or set a mapbox setting that persists across shells and sessions, \
written to a file beside the stored credentials rather than an \
environment variable that only lasts for the session it was set in.",
)
.subcommand_required(true)
.subcommand(
Command::new("get")
.about("Print a setting's current value")
.arg(key_arg()),
)
.subcommand(
Command::new("set")
.about("Persist a setting")
.arg(key_arg())
.arg(Arg::new("value").required(true).value_parser([ON, OFF])),
)
}

pub fn get(matches: &ArgMatches, mode: Mode) -> Result<()> {
let key = matches.get_one::<String>("key").expect("required");
let config = read_config();

match key.as_str() {
UPDATE_CHECK_KEY => {
let enabled = update_check_setting(&config);
output::emit(
mode,
on_off(enabled),
json!({ "key": key, "value": enabled }),
)
}
_ => unreachable!("clap's value_parser restricts `key` to {KEYS:?}"),
}
}

pub fn set(matches: &ArgMatches, mode: Mode) -> Result<()> {
let key = matches.get_one::<String>("key").expect("required");
let value = matches.get_one::<String>("value").expect("required");
let enabled = value == ON;

let mut config = read_config();
match key.as_str() {
UPDATE_CHECK_KEY => config.update_check = Some(enabled),
_ => unreachable!("clap's value_parser restricts `key` to {KEYS:?}"),
}
write_config(&config)?;

output::emit(
mode,
&format!("{key} set to {}.", on_off(enabled)),
json!({ "key": key, "value": enabled }),
)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn an_unset_config_reads_update_check_as_on() {
assert!(update_check_setting(&Config::default()));
}

#[test]
fn the_config_round_trips_and_tolerates_an_empty_one() {
let off = Config {
update_check: Some(false),
};
let text = serde_json::to_string(&off).expect("serialize");
assert_eq!(text, r#"{"update_check":false}"#);
let read: Config = serde_json::from_str(&text).expect("deserialize");
assert_eq!(read, off);

// A file from before this key existed, or one with nothing set yet.
let empty: Config = serde_json::from_str("{}").expect("an empty object");
assert_eq!(empty.update_check, None);
assert!(update_check_setting(&empty));
}

#[test]
fn on_and_off_round_trip_through_on_off() {
assert_eq!(on_off(true), ON);
assert_eq!(on_off(false), OFF);
}
}
13 changes: 13 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ mod agent_skills;
mod api_command_surface;
mod auth;
mod completion;
mod config;
mod confirm;
mod deprecation;
mod executor;
Expand Down Expand Up @@ -591,6 +592,10 @@ fn build_app(specs: &[ServiceSpec]) -> Command {

app = app.subcommand(uninstall::command());

// Beside `uninstall`: the other command that only ever touches this
// machine, never the network.
app = app.subcommand(config::command());

app = app.subcommand(account_usage::command());

app.subcommand(tilesets_cli::command())
Expand Down Expand Up @@ -1041,6 +1046,14 @@ fn run(app: &Command, specs: &[ServiceSpec], matches: &ArgMatches, mode: Mode) -
uninstall::run(assume_yes, mode)?
}
}
// Also ahead of the generic service arm, and for the same reason as
// `uninstall`: this reads and writes a file on this machine and
// makes no request.
Some((config::COMMAND, config_matches)) => match config_matches.subcommand() {
Some(("get", get_matches)) => config::get(get_matches, mode)?,
Some(("set", set_matches)) => config::set(set_matches, mode)?,
_ => unreachable!("`config` sets subcommand_required(true)"),
},
// Token resolution mirrors the service arm below, minus path
// placeholders, a request body, and `--dry-run` — this GET always refreshes.
Some((account_usage::COMMAND, usage_matches)) => {
Expand Down
8 changes: 8 additions & 0 deletions src/schema.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,14 @@ fn commands(app: &Command, specs: &[ServiceSpec], path: &[String]) -> Vec<Comman
out.extend(builtin_leaf_command(app, crate::uninstall::COMMAND));
}

if wants(crate::config::COMMAND) {
out.extend(builtin_commands(
app,
crate::config::COMMAND,
wanted_command,
));
}

// No flag check needed: builtin_leaf_command's find_subcommand returns
// None (a no-op .extend) when the flag left it out of `app`.
if wants(crate::account_usage::COMMAND) && wanted_command.is_none() {
Expand Down
Loading
Loading