pushmetrics-cli 0.1.0__tar.gz → 0.1.1__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.
- {pushmetrics_cli-0.1.0/pushmetrics_cli.egg-info → pushmetrics_cli-0.1.1}/PKG-INFO +12 -8
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/README.md +11 -7
- pushmetrics_cli-0.1.1/pm/__init__.py +1 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/auth/session.py +6 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/auth/token.py +7 -1
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/cli.py +23 -2
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/auth.py +7 -6
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/help.py +2 -2
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/config.py +62 -2
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/errors.py +10 -1
- pushmetrics_cli-0.1.1/pm/user_agent.py +67 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1/pushmetrics_cli.egg-info}/PKG-INFO +12 -8
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pushmetrics_cli.egg-info/SOURCES.txt +5 -0
- pushmetrics_cli-0.1.1/tests/test_config_atomic.py +127 -0
- pushmetrics_cli-0.1.1/tests/test_e2_no_domain.py +23 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_first_run.py +145 -1
- pushmetrics_cli-0.1.1/tests/test_help_encoding.py +50 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_keyring_namespace.py +0 -1
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_session.py +29 -0
- pushmetrics_cli-0.1.1/tests/test_user_agent.py +55 -0
- pushmetrics_cli-0.1.0/pm/__init__.py +0 -1
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/MANIFEST.in +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/__main__.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/_alias.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/auth/__init__.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/auth/device_flow.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/auth/exchange.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/auth/jwt_cache.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/auth/secret_files.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/auth/store.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/__init__.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/access_tokens.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/base.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/blocks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/databases.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/favorites.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/folders.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/integrations.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/organizations.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/recipients.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/reports.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/roles.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/runs.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/saved_blocks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/tableau.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/users.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/webhooks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/client/workspaces.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/__init__.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/_block_factory.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/_helpers.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/block.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/chart.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/db.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/email.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/explore.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/favorites.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/folders.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/integrations.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/metrics_view.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/organizations.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/recipient.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/reports.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/roles.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/runs.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/saved_blocks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/sftp.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/slack.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/slides.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/sql.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/tableau.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/token.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/users.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/webhooks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/commands/workspace.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/log.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/__init__.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/access_tokens.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/blocks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/databases.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/favorites.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/folders.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/integrations.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/organizations.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/recipients.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/reports.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/roles.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/runs.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/saved_blocks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/users.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/webhooks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/models/workspaces.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/output.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/types.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pm/validators.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pushmetrics_cli.egg-info/dependency_links.txt +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pushmetrics_cli.egg-info/entry_points.txt +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pushmetrics_cli.egg-info/requires.txt +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pushmetrics_cli.egg-info/top_level.txt +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/pyproject.toml +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/setup.cfg +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/__init__.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/_runner.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/conftest.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_agent_profile.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_aliases.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_block_factory.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_access_tokens.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_blocks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_favorites.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_folders.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_integrations.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_organizations.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_recipients.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_reports.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_runs.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_saved_blocks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_tableau.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_users.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_client_webhooks.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_credential_safety.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_device_flow.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_did_you_mean.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_domain_groups.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_errors.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_hardening.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_help_command.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_integration_commands.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_jwt_cache.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_list_limit.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_metrics_view_commands.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_no_backend_imports.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_packaging.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_pm_client.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_prod_run_fixes.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_profile_pat_only.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_quiet_flag.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_recipient_commands.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_report_preview_commands.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_secret_files.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_slides_commands.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_tableau_commands.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_token_auth.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_token_commands.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_types.py +0 -0
- {pushmetrics_cli-0.1.0 → pushmetrics_cli-0.1.1}/tests/test_validators.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pushmetrics-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Command-line interface for PushMetrics
|
|
5
5
|
Requires-Python: >=3.9
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -28,18 +28,20 @@ The complete user guide is on the website: [PushMetrics CLI](https://pushmetrics
|
|
|
28
28
|
|
|
29
29
|
## Install
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
`pm` is published on PyPI as `pushmetrics-cli`. Install it with [pipx](https://pipx.pypa.io/):
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
|
|
34
|
+
pipx install pushmetrics-cli
|
|
35
35
|
pm --version
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
Requires Python 3.9 or newer
|
|
38
|
+
or with `uv tool install pushmetrics-cli` or `pip install --user pushmetrics-cli`. Requires Python 3.9 or newer and runs on macOS, Linux and Windows. Python from Homebrew and some Linux distributions refuses `pip install --user`; use pipx or uv there.
|
|
39
|
+
|
|
40
|
+
`pm` needs an active PushMetrics subscription for your workspace's organization. Without one, PushMetrics refuses your personal access token: `pm` cannot log in, its commands stop working when the current session expires or sooner (within an hour at most), and no new tokens can be created.
|
|
39
41
|
|
|
40
42
|
## Quick start
|
|
41
43
|
|
|
42
|
-
1. In the PushMetrics app, open **Settings →
|
|
44
|
+
1. In the PushMetrics app, open **Settings → CLI & API** (`/a/tokens`), click **New token**, and copy the `pmpat_...` value. It is shown once, and it is bound to the workspace that was active when you created it.
|
|
43
45
|
2. Log in with the token. `pm` reads it from a hidden prompt, or from stdin when piped, and stores it in your OS credential store:
|
|
44
46
|
|
|
45
47
|
```bash
|
|
@@ -60,7 +62,7 @@ Requires Python 3.9 or newer. Runs on macOS, Linux and Windows.
|
|
|
60
62
|
|
|
61
63
|
In CI, skip `pm login` and set `PM_API_TOKEN` instead.
|
|
62
64
|
|
|
63
|
-
|
|
65
|
+
`pm` logs in with personal access tokens only; `pm login` without `--token` tells you where to create one.
|
|
64
66
|
|
|
65
67
|
## Commands
|
|
66
68
|
|
|
@@ -158,7 +160,9 @@ pm --profile acme report list
|
|
|
158
160
|
|
|
159
161
|
`pm` also caches the short-lived access token it exchanges from your personal access token, under the service name `pushmetrics-cli-jwt` or in `~/.pm/jwt_cache.json` with mode `0600`.
|
|
160
162
|
|
|
161
|
-
`pm logout` removes both for the active profile. It does not revoke the token on the server; use `pm token revoke` or the
|
|
163
|
+
`pm logout` removes both for the active profile. It does not revoke the token on the server; use `pm token revoke` or the CLI & API page for that.
|
|
164
|
+
|
|
165
|
+
Profiles (api_url, auth0 settings) live in `$PM_CONFIG_HOME/config.toml` (default `~/.pm/config.toml`). `pm config set` writes it atomically (a temp file in the same directory is renamed into place), so an interrupted write never leaves a torn file. A config.toml created fresh by `pm` is mode `0600`; an existing file's mode is preserved. A symlinked config.toml keeps its link (the link target is updated).
|
|
162
166
|
|
|
163
167
|
`pm` never takes a token on the command line and never prints one, in `-v` output or in errors. It only sends tokens over `https://`, except to `localhost`.
|
|
164
168
|
|
|
@@ -172,7 +176,7 @@ pm --profile acme report list
|
|
|
172
176
|
|
|
173
177
|
**`Could not reach https://... (ConnectionError)…`** Network, or the wrong address: `pm config list` shows it.
|
|
174
178
|
|
|
175
|
-
**`Unknown profile: 'acme'…`** or **`Profile 'acme' has no api_url…`** Each message
|
|
179
|
+
**`Unknown profile: 'acme'…`** or **`Profile 'acme' has no api_url…`** Each message names the `pm config set` command that fixes it; see also [Several workspaces](#several-workspaces).
|
|
176
180
|
|
|
177
181
|
**`API ... failed: 403` / `404` with a `Hint:` line.** The hint names the next step: permissions in the token's workspace, or an id that is not visible there.
|
|
178
182
|
|
|
@@ -8,18 +8,20 @@ The complete user guide is on the website: [PushMetrics CLI](https://pushmetrics
|
|
|
8
8
|
|
|
9
9
|
## Install
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
`pm` is published on PyPI as `pushmetrics-cli`. Install it with [pipx](https://pipx.pypa.io/):
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
|
|
14
|
+
pipx install pushmetrics-cli
|
|
15
15
|
pm --version
|
|
16
16
|
```
|
|
17
17
|
|
|
18
|
-
Requires Python 3.9 or newer
|
|
18
|
+
or with `uv tool install pushmetrics-cli` or `pip install --user pushmetrics-cli`. Requires Python 3.9 or newer and runs on macOS, Linux and Windows. Python from Homebrew and some Linux distributions refuses `pip install --user`; use pipx or uv there.
|
|
19
|
+
|
|
20
|
+
`pm` needs an active PushMetrics subscription for your workspace's organization. Without one, PushMetrics refuses your personal access token: `pm` cannot log in, its commands stop working when the current session expires or sooner (within an hour at most), and no new tokens can be created.
|
|
19
21
|
|
|
20
22
|
## Quick start
|
|
21
23
|
|
|
22
|
-
1. In the PushMetrics app, open **Settings →
|
|
24
|
+
1. In the PushMetrics app, open **Settings → CLI & API** (`/a/tokens`), click **New token**, and copy the `pmpat_...` value. It is shown once, and it is bound to the workspace that was active when you created it.
|
|
23
25
|
2. Log in with the token. `pm` reads it from a hidden prompt, or from stdin when piped, and stores it in your OS credential store:
|
|
24
26
|
|
|
25
27
|
```bash
|
|
@@ -40,7 +42,7 @@ Requires Python 3.9 or newer. Runs on macOS, Linux and Windows.
|
|
|
40
42
|
|
|
41
43
|
In CI, skip `pm login` and set `PM_API_TOKEN` instead.
|
|
42
44
|
|
|
43
|
-
|
|
45
|
+
`pm` logs in with personal access tokens only; `pm login` without `--token` tells you where to create one.
|
|
44
46
|
|
|
45
47
|
## Commands
|
|
46
48
|
|
|
@@ -138,7 +140,9 @@ pm --profile acme report list
|
|
|
138
140
|
|
|
139
141
|
`pm` also caches the short-lived access token it exchanges from your personal access token, under the service name `pushmetrics-cli-jwt` or in `~/.pm/jwt_cache.json` with mode `0600`.
|
|
140
142
|
|
|
141
|
-
`pm logout` removes both for the active profile. It does not revoke the token on the server; use `pm token revoke` or the
|
|
143
|
+
`pm logout` removes both for the active profile. It does not revoke the token on the server; use `pm token revoke` or the CLI & API page for that.
|
|
144
|
+
|
|
145
|
+
Profiles (api_url, auth0 settings) live in `$PM_CONFIG_HOME/config.toml` (default `~/.pm/config.toml`). `pm config set` writes it atomically (a temp file in the same directory is renamed into place), so an interrupted write never leaves a torn file. A config.toml created fresh by `pm` is mode `0600`; an existing file's mode is preserved. A symlinked config.toml keeps its link (the link target is updated).
|
|
142
146
|
|
|
143
147
|
`pm` never takes a token on the command line and never prints one, in `-v` output or in errors. It only sends tokens over `https://`, except to `localhost`.
|
|
144
148
|
|
|
@@ -152,7 +156,7 @@ pm --profile acme report list
|
|
|
152
156
|
|
|
153
157
|
**`Could not reach https://... (ConnectionError)…`** Network, or the wrong address: `pm config list` shows it.
|
|
154
158
|
|
|
155
|
-
**`Unknown profile: 'acme'…`** or **`Profile 'acme' has no api_url…`** Each message
|
|
159
|
+
**`Unknown profile: 'acme'…`** or **`Profile 'acme' has no api_url…`** Each message names the `pm config set` command that fixes it; see also [Several workspaces](#several-workspaces).
|
|
156
160
|
|
|
157
161
|
**`API ... failed: 403` / `404` with a `Hint:` line.** The hint names the next step: permissions in the token's workspace, or an id that is not visible there.
|
|
158
162
|
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.1"
|
|
@@ -16,6 +16,7 @@ from pm.auth import device_flow, exchange, jwt_cache, store
|
|
|
16
16
|
from pm.auth import token as token_auth
|
|
17
17
|
from pm.config import Profile
|
|
18
18
|
from pm.log import log_request, log_response
|
|
19
|
+
from pm.user_agent import user_agent
|
|
19
20
|
|
|
20
21
|
logger = logging.getLogger(__name__)
|
|
21
22
|
|
|
@@ -231,6 +232,11 @@ class PMSession:
|
|
|
231
232
|
url = f"{self.profile.api_url}{path}"
|
|
232
233
|
kwargs.setdefault("timeout", 60)
|
|
233
234
|
headers = dict(kwargs.get("headers") or {})
|
|
235
|
+
# Declare the calling tool (PM_CLIENT) on every request, so the
|
|
236
|
+
# server can attribute usage to Claude Code / Copilot / raw CLI
|
|
237
|
+
# instead of a bare python-requests UA. setdefault: an explicit
|
|
238
|
+
# per-call User-Agent still wins.
|
|
239
|
+
headers.setdefault("User-Agent", user_agent())
|
|
234
240
|
# Forward agent identity to the API for backend scope filters.
|
|
235
241
|
if self._agent_uid:
|
|
236
242
|
headers.setdefault(_AGENT_UID_HEADER, self._agent_uid)
|
|
@@ -31,6 +31,7 @@ from pm.auth import jwt_cache
|
|
|
31
31
|
from pm.auth.secret_files import read_private_json, write_private_json
|
|
32
32
|
from pm.config import CONFIG_DIR, keyring_account
|
|
33
33
|
from pm.log import log_request, log_response, redact_secrets, timed
|
|
34
|
+
from pm.user_agent import user_agent
|
|
34
35
|
|
|
35
36
|
try:
|
|
36
37
|
import keyring
|
|
@@ -144,7 +145,12 @@ def exchange_pat_for_jwt(
|
|
|
144
145
|
try:
|
|
145
146
|
resp = session.post(
|
|
146
147
|
url,
|
|
147
|
-
headers={
|
|
148
|
+
headers={
|
|
149
|
+
"Authorization": f"Bearer {raw_token}",
|
|
150
|
+
# Declares the calling tool (PM_CLIENT) so the server's
|
|
151
|
+
# pat_exchanged audit row names it; see pm.user_agent.
|
|
152
|
+
"User-Agent": user_agent(),
|
|
153
|
+
},
|
|
148
154
|
timeout=30,
|
|
149
155
|
# Never follow a redirect: ``requests`` would re-attach the
|
|
150
156
|
# Authorization header (and thus the raw PAT) on a same-host 302.
|
|
@@ -11,6 +11,8 @@ import click
|
|
|
11
11
|
|
|
12
12
|
from pm._alias import AliasedGroup
|
|
13
13
|
from pm import __version__
|
|
14
|
+
from pm import config as pm_config
|
|
15
|
+
from pm.auth.session import NotAuthenticatedError
|
|
14
16
|
from pm.client.base import PMApiError
|
|
15
17
|
from pm.commands._helpers import info, json_option
|
|
16
18
|
from pm.config import (
|
|
@@ -56,6 +58,15 @@ class RootGroup(AliasedGroup):
|
|
|
56
58
|
def invoke(self, ctx: click.Context):
|
|
57
59
|
try:
|
|
58
60
|
return super().invoke(ctx)
|
|
61
|
+
except NotAuthenticatedError as exc:
|
|
62
|
+
# A login made under another config home lives in another keyring
|
|
63
|
+
# slot (security S9); say which home this command used, so the
|
|
64
|
+
# mismatch is diagnosable. The default home needs no explanation.
|
|
65
|
+
if exc.message.startswith("Not logged in.") and not pm_config.is_default_config_home():
|
|
66
|
+
raise click.ClickException(
|
|
67
|
+
f"{exc.message}\nConfig home: {pm_config.describe_config_home()}."
|
|
68
|
+
)
|
|
69
|
+
raise
|
|
59
70
|
except PMApiError as exc:
|
|
60
71
|
raise click.ClickException(
|
|
61
72
|
describe_api_error(exc, group=ctx.invoked_subcommand)
|
|
@@ -64,7 +75,7 @@ class RootGroup(AliasedGroup):
|
|
|
64
75
|
|
|
65
76
|
_GETTING_STARTED = """\b
|
|
66
77
|
Get started with a personal access token:
|
|
67
|
-
1. Create a token in the
|
|
78
|
+
1. Create a token in the web app under Settings > CLI & API.
|
|
68
79
|
2. pm login --token paste the token at the prompt (stored securely)
|
|
69
80
|
3. pm whoami then: pm report list, pm run list, pm report run <uid>
|
|
70
81
|
"""
|
|
@@ -182,13 +193,18 @@ def config_list(ctx: click.Context) -> None:
|
|
|
182
193
|
("PM_API_URL", os.environ.get("PM_API_URL")),
|
|
183
194
|
("PM_AUTH0_DOMAIN", os.environ.get("PM_AUTH0_DOMAIN")),
|
|
184
195
|
("PM_AUTH0_CLIENT_ID", os.environ.get("PM_AUTH0_CLIENT_ID")),
|
|
196
|
+
("PM_CONFIG_HOME", os.environ.get("PM_CONFIG_HOME")),
|
|
185
197
|
)
|
|
186
198
|
if value
|
|
187
199
|
}
|
|
188
200
|
|
|
189
201
|
if ctx.obj["json"]:
|
|
190
202
|
emit(
|
|
191
|
-
{
|
|
203
|
+
{
|
|
204
|
+
"profiles": rows,
|
|
205
|
+
"env_overrides": env_overrides,
|
|
206
|
+
"config_home": str(pm_config.CONFIG_DIR),
|
|
207
|
+
},
|
|
192
208
|
json_output=True,
|
|
193
209
|
)
|
|
194
210
|
return
|
|
@@ -205,6 +221,11 @@ def config_list(ctx: click.Context) -> None:
|
|
|
205
221
|
],
|
|
206
222
|
)
|
|
207
223
|
|
|
224
|
+
# Which config home is active decides which stored credentials (keyring
|
|
225
|
+
# slot) commands can see (security S9).
|
|
226
|
+
click.echo("")
|
|
227
|
+
click.echo(f"Config home: {pm_config.describe_config_home()}")
|
|
228
|
+
|
|
208
229
|
if env_overrides:
|
|
209
230
|
click.echo("")
|
|
210
231
|
click.echo("Active env-var overrides (apply to all profiles):")
|
|
@@ -29,7 +29,7 @@ def _read_token(ctx: click.Context) -> str:
|
|
|
29
29
|
|
|
30
30
|
def tokens_page_url(api_url: str) -> str:
|
|
31
31
|
"""The web app page where personal access tokens are created and revoked
|
|
32
|
-
(frontend route ``/a/tokens``, menu label "
|
|
32
|
+
(frontend route ``/a/tokens``, menu label "CLI & API")."""
|
|
33
33
|
return f"{api_url}/a/tokens"
|
|
34
34
|
|
|
35
35
|
|
|
@@ -41,9 +41,9 @@ def _token_login_failure(exc: token_auth.TokenExchangeError, api_url: str) -> st
|
|
|
41
41
|
"Too many login attempts from this network address "
|
|
42
42
|
"(10 per minute). Wait a minute and run `pm login --token` again."
|
|
43
43
|
)
|
|
44
|
-
if exc.status is None:
|
|
45
|
-
# Raised before any HTTP (malformed token, unreachable host)
|
|
46
|
-
# message already names the next action.
|
|
44
|
+
if exc.status is None or exc.status == 402:
|
|
45
|
+
# Raised before any HTTP (malformed token, unreachable host), or the
|
|
46
|
+
# subscription gate: the message already names the next action.
|
|
47
47
|
return str(exc)
|
|
48
48
|
return f"{exc}\nHint: tokens are created and revoked at {tokens_page_url(api_url)}"
|
|
49
49
|
|
|
@@ -91,8 +91,9 @@ def _login_with_token(ctx: click.Context, profile) -> None:
|
|
|
91
91
|
def login(ctx: click.Context, no_browser: bool, use_token: bool) -> None:
|
|
92
92
|
"""Log in with a personal access token.
|
|
93
93
|
|
|
94
|
-
pm login --token
|
|
95
|
-
|
|
94
|
+
Run pm login --token and paste the token when asked, or pipe it in from
|
|
95
|
+
your secrets manager. It is stored for this profile. Without --token,
|
|
96
|
+
pm login prints where to create a token.
|
|
96
97
|
"""
|
|
97
98
|
profile = ctx.obj["profile"]
|
|
98
99
|
|
|
@@ -187,7 +187,7 @@ _GROUP_PAGES: Dict[str, Dict] = {
|
|
|
187
187
|
"schema": "Available measures/dimensions. <uid>.",
|
|
188
188
|
},
|
|
189
189
|
"body_fields": (
|
|
190
|
-
" --measure NAME repeatable,
|
|
190
|
+
" --measure NAME repeatable, 1 or more required\n"
|
|
191
191
|
" --dimension NAME repeatable\n"
|
|
192
192
|
" --time-range last_30d | 2026-01-01:2026-04-01\n"
|
|
193
193
|
" --limit N, --offset N, --no-cache\n"
|
|
@@ -460,7 +460,7 @@ _PLAIN_PAGES: Dict[str, str] = {
|
|
|
460
460
|
" pm login --token paste the token at the hidden prompt\n"
|
|
461
461
|
" <secrets-cmd> | pm login --token pipe it from a secrets manager\n"
|
|
462
462
|
" PM_API_TOKEN=... use it without storing (CI, scripts)\n\n"
|
|
463
|
-
"Tokens are created in the web app under
|
|
463
|
+
"Tokens are created in the web app under Settings > CLI & API."
|
|
464
464
|
),
|
|
465
465
|
"logout": (
|
|
466
466
|
"pm logout — Forget the stored token or session for the profile.\n\n"
|
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
import hashlib
|
|
14
14
|
import os
|
|
15
15
|
import sys
|
|
16
|
+
import tempfile
|
|
16
17
|
from dataclasses import dataclass
|
|
17
18
|
from pathlib import Path
|
|
18
19
|
from typing import Any, Dict, Optional
|
|
@@ -109,6 +110,51 @@ def _ensure_dir() -> None:
|
|
|
109
110
|
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
110
111
|
|
|
111
112
|
|
|
113
|
+
def _atomic_write_text(path: Path, text: str) -> None:
|
|
114
|
+
"""Write ``text`` to ``path`` atomically.
|
|
115
|
+
|
|
116
|
+
A direct ``path.write_text`` truncates the file first, so an interrupted
|
|
117
|
+
write (crash, Ctrl-C, full disk) or two concurrent ``pm config set`` runs
|
|
118
|
+
leaves a torn config.toml that the next command cannot parse. Instead
|
|
119
|
+
write a temp file in the same directory, flush+fsync it, then
|
|
120
|
+
``os.replace`` it over the target -- an atomic rename on the same
|
|
121
|
+
filesystem -- so a reader sees either the old file or the whole new one,
|
|
122
|
+
never a half-written one. The existing file's mode is preserved (the
|
|
123
|
+
fresh temp file is created 0600 by mkstemp regardless, so a brand-new
|
|
124
|
+
config.toml is 0600).
|
|
125
|
+
|
|
126
|
+
If ``path`` is a symlink (dotfiles setups often link ~/.pm/config.toml
|
|
127
|
+
into a tracked dir), ``os.replace`` would otherwise swap the link itself
|
|
128
|
+
for a regular file. Resolve to the link target first and write there, so
|
|
129
|
+
the link is kept and its destination filesystem is the one the temp file
|
|
130
|
+
lands on.
|
|
131
|
+
"""
|
|
132
|
+
target = path.resolve()
|
|
133
|
+
directory = target.parent
|
|
134
|
+
mode: Optional[int] = None
|
|
135
|
+
try:
|
|
136
|
+
mode = os.stat(target).st_mode & 0o777
|
|
137
|
+
except FileNotFoundError:
|
|
138
|
+
mode = None
|
|
139
|
+
|
|
140
|
+
fd, tmp_name = tempfile.mkstemp(dir=directory, prefix=".config.", suffix=".toml.tmp")
|
|
141
|
+
try:
|
|
142
|
+
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
143
|
+
f.write(text)
|
|
144
|
+
f.flush()
|
|
145
|
+
os.fsync(f.fileno())
|
|
146
|
+
if mode is not None:
|
|
147
|
+
os.chmod(tmp_name, mode)
|
|
148
|
+
os.replace(tmp_name, target)
|
|
149
|
+
except BaseException:
|
|
150
|
+
# Leave the existing config.toml untouched and clean up the temp file.
|
|
151
|
+
try:
|
|
152
|
+
os.unlink(tmp_name)
|
|
153
|
+
except FileNotFoundError:
|
|
154
|
+
pass
|
|
155
|
+
raise
|
|
156
|
+
|
|
157
|
+
|
|
112
158
|
def _serialize_toml(data: Dict[str, Dict[str, Any]]) -> str:
|
|
113
159
|
"""Tiny TOML writer for our flat profile schema. Avoids pulling in tomli_w."""
|
|
114
160
|
lines = []
|
|
@@ -132,12 +178,26 @@ def load_all() -> Dict[str, Dict[str, Any]]:
|
|
|
132
178
|
if not CONFIG_FILE.exists():
|
|
133
179
|
return {}
|
|
134
180
|
with CONFIG_FILE.open("rb") as f:
|
|
135
|
-
|
|
181
|
+
raw = f.read()
|
|
182
|
+
try:
|
|
183
|
+
return tomllib.loads(raw.decode("utf-8"))
|
|
184
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
|
|
185
|
+
# A partially-written or corrupted config.toml must not take down
|
|
186
|
+
# every `pm` command with a raw traceback. load_all() runs at the
|
|
187
|
+
# start of almost all commands (get_profile -> load_all), so name the
|
|
188
|
+
# file and the one-line recovery (built-in profiles still work) in a
|
|
189
|
+
# clean error instead. save_all writes atomically, so an unparsable
|
|
190
|
+
# file now only comes from an external edit or a pre-fix torn write.
|
|
191
|
+
raise click.ClickException(
|
|
192
|
+
f"Config file {CONFIG_FILE} is not valid TOML ({exc}). "
|
|
193
|
+
f"Fix or remove it (e.g. `rm {CONFIG_FILE}`) and re-run; "
|
|
194
|
+
f"built-in profiles work without it."
|
|
195
|
+
) from exc
|
|
136
196
|
|
|
137
197
|
|
|
138
198
|
def save_all(data: Dict[str, Dict[str, Any]]) -> None:
|
|
139
199
|
_ensure_dir()
|
|
140
|
-
CONFIG_FILE
|
|
200
|
+
_atomic_write_text(CONFIG_FILE, _serialize_toml(data))
|
|
141
201
|
|
|
142
202
|
|
|
143
203
|
def get_profile(name: str) -> Profile:
|
|
@@ -148,6 +148,11 @@ def _hint_for_status(status: int, group: str | None) -> str | None:
|
|
|
148
148
|
f"Nothing with that id is visible in the active workspace. "
|
|
149
149
|
f"{listing} shows what exists."
|
|
150
150
|
)
|
|
151
|
+
if status == 402:
|
|
152
|
+
return (
|
|
153
|
+
"pm needs an active PushMetrics subscription for this workspace's "
|
|
154
|
+
"organization. Start a trial or upgrade in the web app."
|
|
155
|
+
)
|
|
151
156
|
if status == 429:
|
|
152
157
|
return "Too many requests. Wait a minute and retry."
|
|
153
158
|
if status >= 500:
|
|
@@ -166,6 +171,9 @@ _BARE_STATUS_MESSAGES = frozenset({
|
|
|
166
171
|
# The one textual 403 reachable from pm (non-member workspace switch):
|
|
167
172
|
# a fact with no next action, so the hint follows it.
|
|
168
173
|
"you don't belong to this workspace",
|
|
174
|
+
# The legacy 402 from subscription-gated endpoints (kept server-side for
|
|
175
|
+
# old clients): states the fact, names no action.
|
|
176
|
+
"no subscription found for this organization",
|
|
169
177
|
})
|
|
170
178
|
|
|
171
179
|
|
|
@@ -224,5 +232,6 @@ def describe_api_error(exc: Exception, *, group: str | None = None) -> str:
|
|
|
224
232
|
else:
|
|
225
233
|
first_line = str(exc)
|
|
226
234
|
explained = bool(message) and not _is_bare_status_message(message)
|
|
227
|
-
|
|
235
|
+
# A 402/403 whose server sentence explains itself needs no generic hint.
|
|
236
|
+
hint = None if (explained and status in (402, 403)) else _hint_for_status(status, group)
|
|
228
237
|
return f"{first_line}\nHint: {hint}" if hint else first_line
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""The ``User-Agent`` the pm CLI sends on every request.
|
|
2
|
+
|
|
3
|
+
Why this exists: the server records the HTTP User-Agent on each
|
|
4
|
+
``pat_exchanged`` audit row (``security_event.user_agent``), and the ops
|
|
5
|
+
Access Tokens page shows it. A bare ``requests.Session`` sends
|
|
6
|
+
``python-requests/<ver>``, which cannot tell the pm CLI apart from any
|
|
7
|
+
other Python client, nor say WHICH tool is driving pm — Claude Code,
|
|
8
|
+
Copilot, Cursor, CI, or a human at a shell.
|
|
9
|
+
|
|
10
|
+
pm is driven *by* an AI coding agent as shell commands; the agent is
|
|
11
|
+
upstream of the process and leaves no wire signal of its own. So the only
|
|
12
|
+
way the server can learn the calling tool is for pm to declare it. The
|
|
13
|
+
tool sets ``PM_CLIENT`` (e.g. ``claude-code``, ``copilot``, ``cursor``,
|
|
14
|
+
``ci``); unset means a plain CLI invocation.
|
|
15
|
+
|
|
16
|
+
Shape (stable, parseable, no PII):
|
|
17
|
+
|
|
18
|
+
pm/0.1.0 (client=claude-code; py/3.11.6)
|
|
19
|
+
|
|
20
|
+
``client`` is sanitised to ``[a-z0-9._-]`` and capped, so a hostile or
|
|
21
|
+
accidental value can neither inject into the header nor blow the server's
|
|
22
|
+
512-char column. The value carries no secret — it is a tool name only.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
import platform
|
|
28
|
+
import re
|
|
29
|
+
|
|
30
|
+
from pm import __version__
|
|
31
|
+
|
|
32
|
+
CLIENT_ENV_VAR = "PM_CLIENT"
|
|
33
|
+
|
|
34
|
+
# Header-safe, low-cardinality, no PII. Anything else is dropped to "cli".
|
|
35
|
+
_CLIENT_RE = re.compile(r"[^a-z0-9._-]+")
|
|
36
|
+
_MAX_CLIENT_LEN = 40
|
|
37
|
+
_DEFAULT_CLIENT = "cli"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def client_name() -> str:
|
|
41
|
+
"""The calling tool, from ``PM_CLIENT``, normalised. ``"cli"`` when unset.
|
|
42
|
+
|
|
43
|
+
Lowercased, non-``[a-z0-9._-]`` runs collapsed to ``-``, trimmed and
|
|
44
|
+
capped. An empty or all-invalid value falls back to ``"cli"`` rather
|
|
45
|
+
than emitting a blank ``client=``.
|
|
46
|
+
"""
|
|
47
|
+
raw = (os.environ.get(CLIENT_ENV_VAR) or "").strip().lower()
|
|
48
|
+
if not raw:
|
|
49
|
+
return _DEFAULT_CLIENT
|
|
50
|
+
cleaned = _CLIENT_RE.sub("-", raw).strip("-")[:_MAX_CLIENT_LEN]
|
|
51
|
+
return cleaned or _DEFAULT_CLIENT
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _python_version() -> str:
|
|
55
|
+
try:
|
|
56
|
+
return platform.python_version()
|
|
57
|
+
except Exception: # noqa: BLE001 - never let UA construction fail a request
|
|
58
|
+
return "unknown"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def user_agent() -> str:
|
|
62
|
+
"""The full ``User-Agent`` value for this process.
|
|
63
|
+
|
|
64
|
+
e.g. ``pm/0.1.0 (client=claude-code; py/3.11.6)``. Pure function of the
|
|
65
|
+
version and ``PM_CLIENT``; safe to call per request.
|
|
66
|
+
"""
|
|
67
|
+
return f"pm/{__version__} (client={client_name()}; py/{_python_version()})"
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pushmetrics-cli
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.1
|
|
4
4
|
Summary: Command-line interface for PushMetrics
|
|
5
5
|
Requires-Python: >=3.9
|
|
6
6
|
Description-Content-Type: text/markdown
|
|
@@ -28,18 +28,20 @@ The complete user guide is on the website: [PushMetrics CLI](https://pushmetrics
|
|
|
28
28
|
|
|
29
29
|
## Install
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
`pm` is published on PyPI as `pushmetrics-cli`. Install it with [pipx](https://pipx.pypa.io/):
|
|
32
32
|
|
|
33
33
|
```bash
|
|
34
|
-
|
|
34
|
+
pipx install pushmetrics-cli
|
|
35
35
|
pm --version
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
Requires Python 3.9 or newer
|
|
38
|
+
or with `uv tool install pushmetrics-cli` or `pip install --user pushmetrics-cli`. Requires Python 3.9 or newer and runs on macOS, Linux and Windows. Python from Homebrew and some Linux distributions refuses `pip install --user`; use pipx or uv there.
|
|
39
|
+
|
|
40
|
+
`pm` needs an active PushMetrics subscription for your workspace's organization. Without one, PushMetrics refuses your personal access token: `pm` cannot log in, its commands stop working when the current session expires or sooner (within an hour at most), and no new tokens can be created.
|
|
39
41
|
|
|
40
42
|
## Quick start
|
|
41
43
|
|
|
42
|
-
1. In the PushMetrics app, open **Settings →
|
|
44
|
+
1. In the PushMetrics app, open **Settings → CLI & API** (`/a/tokens`), click **New token**, and copy the `pmpat_...` value. It is shown once, and it is bound to the workspace that was active when you created it.
|
|
43
45
|
2. Log in with the token. `pm` reads it from a hidden prompt, or from stdin when piped, and stores it in your OS credential store:
|
|
44
46
|
|
|
45
47
|
```bash
|
|
@@ -60,7 +62,7 @@ Requires Python 3.9 or newer. Runs on macOS, Linux and Windows.
|
|
|
60
62
|
|
|
61
63
|
In CI, skip `pm login` and set `PM_API_TOKEN` instead.
|
|
62
64
|
|
|
63
|
-
|
|
65
|
+
`pm` logs in with personal access tokens only; `pm login` without `--token` tells you where to create one.
|
|
64
66
|
|
|
65
67
|
## Commands
|
|
66
68
|
|
|
@@ -158,7 +160,9 @@ pm --profile acme report list
|
|
|
158
160
|
|
|
159
161
|
`pm` also caches the short-lived access token it exchanges from your personal access token, under the service name `pushmetrics-cli-jwt` or in `~/.pm/jwt_cache.json` with mode `0600`.
|
|
160
162
|
|
|
161
|
-
`pm logout` removes both for the active profile. It does not revoke the token on the server; use `pm token revoke` or the
|
|
163
|
+
`pm logout` removes both for the active profile. It does not revoke the token on the server; use `pm token revoke` or the CLI & API page for that.
|
|
164
|
+
|
|
165
|
+
Profiles (api_url, auth0 settings) live in `$PM_CONFIG_HOME/config.toml` (default `~/.pm/config.toml`). `pm config set` writes it atomically (a temp file in the same directory is renamed into place), so an interrupted write never leaves a torn file. A config.toml created fresh by `pm` is mode `0600`; an existing file's mode is preserved. A symlinked config.toml keeps its link (the link target is updated).
|
|
162
166
|
|
|
163
167
|
`pm` never takes a token on the command line and never prints one, in `-v` output or in errors. It only sends tokens over `https://`, except to `localhost`.
|
|
164
168
|
|
|
@@ -172,7 +176,7 @@ pm --profile acme report list
|
|
|
172
176
|
|
|
173
177
|
**`Could not reach https://... (ConnectionError)…`** Network, or the wrong address: `pm config list` shows it.
|
|
174
178
|
|
|
175
|
-
**`Unknown profile: 'acme'…`** or **`Profile 'acme' has no api_url…`** Each message
|
|
179
|
+
**`Unknown profile: 'acme'…`** or **`Profile 'acme' has no api_url…`** Each message names the `pm config set` command that fixes it; see also [Several workspaces](#several-workspaces).
|
|
176
180
|
|
|
177
181
|
**`API ... failed: 403` / `404` with a `Hint:` line.** The hint names the next step: permissions in the token's workspace, or an id that is not visible there.
|
|
178
182
|
|
|
@@ -10,6 +10,7 @@ pm/errors.py
|
|
|
10
10
|
pm/log.py
|
|
11
11
|
pm/output.py
|
|
12
12
|
pm/types.py
|
|
13
|
+
pm/user_agent.py
|
|
13
14
|
pm/validators.py
|
|
14
15
|
pm/auth/__init__.py
|
|
15
16
|
pm/auth/device_flow.py
|
|
@@ -107,14 +108,17 @@ tests/test_client_saved_blocks.py
|
|
|
107
108
|
tests/test_client_tableau.py
|
|
108
109
|
tests/test_client_users.py
|
|
109
110
|
tests/test_client_webhooks.py
|
|
111
|
+
tests/test_config_atomic.py
|
|
110
112
|
tests/test_credential_safety.py
|
|
111
113
|
tests/test_device_flow.py
|
|
112
114
|
tests/test_did_you_mean.py
|
|
113
115
|
tests/test_domain_groups.py
|
|
116
|
+
tests/test_e2_no_domain.py
|
|
114
117
|
tests/test_errors.py
|
|
115
118
|
tests/test_first_run.py
|
|
116
119
|
tests/test_hardening.py
|
|
117
120
|
tests/test_help_command.py
|
|
121
|
+
tests/test_help_encoding.py
|
|
118
122
|
tests/test_integration_commands.py
|
|
119
123
|
tests/test_jwt_cache.py
|
|
120
124
|
tests/test_keyring_namespace.py
|
|
@@ -135,4 +139,5 @@ tests/test_tableau_commands.py
|
|
|
135
139
|
tests/test_token_auth.py
|
|
136
140
|
tests/test_token_commands.py
|
|
137
141
|
tests/test_types.py
|
|
142
|
+
tests/test_user_agent.py
|
|
138
143
|
tests/test_validators.py
|