avr-cli 0.1.6__tar.gz → 0.2.0__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.
- {avr_cli-0.1.6 → avr_cli-0.2.0}/PKG-INFO +21 -1
- {avr_cli-0.1.6 → avr_cli-0.2.0}/README.md +20 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avr_cli.egg-info/PKG-INFO +21 -1
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avr_cli.egg-info/SOURCES.txt +8 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/_build_info.py +1 -1
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/api_client.py +33 -2
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/auth.py +71 -8
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/auth_cmd.py +16 -6
- avr_cli-0.2.0/avrea_cli/commands/org.py +870 -0
- avr_cli-0.2.0/avrea_cli/commands/repo.py +545 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/docs_gen.py +31 -2
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/firewall.py +59 -12
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/helpers.py +4 -1
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/main.py +3 -1
- avr_cli-0.2.0/avrea_cli/vm.py +2776 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/pyproject.toml +1 -1
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_api_client.py +41 -0
- avr_cli-0.2.0/tests/test_auth_sso.py +207 -0
- avr_cli-0.2.0/tests/test_docs_gen.py +46 -0
- avr_cli-0.2.0/tests/test_firewall.py +114 -0
- avr_cli-0.2.0/tests/test_org_email_domain.py +121 -0
- avr_cli-0.2.0/tests/test_org_saml.py +248 -0
- avr_cli-0.2.0/tests/test_public_mirrors.py +381 -0
- avr_cli-0.2.0/tests/test_vm.py +2104 -0
- avr_cli-0.1.6/avrea_cli/commands/org.py +0 -414
- avr_cli-0.1.6/avrea_cli/commands/repo.py +0 -154
- {avr_cli-0.1.6 → avr_cli-0.2.0}/LICENSE +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avr_cli.egg-info/dependency_links.txt +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avr_cli.egg-info/entry_points.txt +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avr_cli.egg-info/requires.txt +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avr_cli.egg-info/top_level.txt +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/__init__.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/assets/logo_mark.svg +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/audit.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/billing.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/click_ext.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/__init__.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/cache.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/config_cmd.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/health.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/internal.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/job.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/log.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/run.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/settings.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/status.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/workflow.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/config.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/display.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/json_output.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/log_display.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/metrics_display.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/output.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/paths.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/repo_context.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/transport.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/version.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/setup.cfg +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_auth.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_auth_login.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_cache.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_config.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_config_cmd.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_display.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_env_conventions.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_global_flags.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_helpers.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_job_list.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_job_metrics_cmd.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_job_view_logs.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_json_output.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_lazy_registry.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_log_display.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_metrics_display.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_output.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_piped_output.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_repo_context.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_run_attempts.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_run_list_flags.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_run_logs.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_run_view.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_settings.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_transport.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_web_flag.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_workflow_actions.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_workflow_run.py +0 -0
- {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_workflow_view.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: avr-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Avrea command-line client
|
|
5
5
|
Author-email: Avrea <support@avrea.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -157,6 +157,26 @@ avr job metrics <job-id> --watch # CPU/memory/IO gauges, refreshed every f
|
|
|
157
157
|
avr job metrics <job-id> # static post-mortem after the job ended
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
+
**Long-running dev VMs**
|
|
161
|
+
|
|
162
|
+
Durable, org-scoped VMs reachable over SSH (plus RDP/VNC for a desktop), separate from job VMs. Create one with your key, then set it up in a single command:
|
|
163
|
+
|
|
164
|
+
```sh
|
|
165
|
+
avr vm create --name dev --os linux --size 2-vcpu \
|
|
166
|
+
--ssh-key @~/.ssh/id_ed25519.pub --ephemeral --wait # boot, wait for SSH, print connect
|
|
167
|
+
avr vm create --name ci --os linux --size 8-vcpu --ephemeral \
|
|
168
|
+
--repo org/project --disable-cache gha,packages # attach a repo, narrow which caches it uses
|
|
169
|
+
avr vm bootstrap <vm-id> --setup-github --install claude,codex \
|
|
170
|
+
--repo https://github.com/org/project --env AWS_REGION=eu-north-1
|
|
171
|
+
avr vm ssh <vm-id> # SSH session, host key pinned when published (or: -- <cmd>)
|
|
172
|
+
avr vm ssh <vm-id> --login -- claude -p 'summarize the repo' # one-off command in a login shell (sees forwarded env)
|
|
173
|
+
avr vm ssh <vm-id> --session dev # persistent tmux session, survives a dropped connection
|
|
174
|
+
avr vm ssh-config <vm-id> --append # add `ssh <alias>` (scp/rsync, VS Code Remote-SSH) to ~/.ssh/config
|
|
175
|
+
avr vm list ; avr vm stop <vm-id> # lifecycle
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`bootstrap` runs each step over SSH and streams it live, feeding secrets (GitHub token, env values, agent credentials) on stdin rather than argv. `--forward-agent-creds` carries your local `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`, or a Claude subscription token — if none is set it offers to run `claude setup-token` and forwards the pasted `CLAUDE_CODE_OAUTH_TOKEN`. Disks are ephemeral, so re-run it after every `avr vm start`. `avr vm ssh` pins the VM's host key when its endpoint publishes one; if it doesn't, `avr` warns and falls back to trust-on-first-use — pass `--session <name>` to attach to (or create) a persistent tmux session that survives a dropped connection. A one-off `avr vm ssh -- <cmd>` runs in a non-login shell that sources no startup files, so pass `--login` when the command needs bootstrap-forwarded env (e.g. `claude`'s subscription token). `avr vm ssh-config` emits a host-key-pinned SSH config block so plain `ssh`, scp/rsync, and VS Code / Cursor Remote-SSH reach the VM without wrapping each tool — `--append` writes it into `~/.ssh/config` for you (idempotent: re-run after a restart to refresh the endpoint in place). For a desktop, `avr vm rdp` / `avr vm vnc` tunnel over the same SSH endpoint, and `avr vm port-forward` is the generic primitive (repeat `--port` to forward several ports over one connection). A VM created with `--repo` inherits that repository's build/CI caches; `--disable-cache <name>` narrows them per VM (it can turn an inherited cache off, never on).
|
|
179
|
+
|
|
160
180
|
**Cancel or rerun a run**
|
|
161
181
|
|
|
162
182
|
```sh
|
|
@@ -126,6 +126,26 @@ avr job metrics <job-id> --watch # CPU/memory/IO gauges, refreshed every f
|
|
|
126
126
|
avr job metrics <job-id> # static post-mortem after the job ended
|
|
127
127
|
```
|
|
128
128
|
|
|
129
|
+
**Long-running dev VMs**
|
|
130
|
+
|
|
131
|
+
Durable, org-scoped VMs reachable over SSH (plus RDP/VNC for a desktop), separate from job VMs. Create one with your key, then set it up in a single command:
|
|
132
|
+
|
|
133
|
+
```sh
|
|
134
|
+
avr vm create --name dev --os linux --size 2-vcpu \
|
|
135
|
+
--ssh-key @~/.ssh/id_ed25519.pub --ephemeral --wait # boot, wait for SSH, print connect
|
|
136
|
+
avr vm create --name ci --os linux --size 8-vcpu --ephemeral \
|
|
137
|
+
--repo org/project --disable-cache gha,packages # attach a repo, narrow which caches it uses
|
|
138
|
+
avr vm bootstrap <vm-id> --setup-github --install claude,codex \
|
|
139
|
+
--repo https://github.com/org/project --env AWS_REGION=eu-north-1
|
|
140
|
+
avr vm ssh <vm-id> # SSH session, host key pinned when published (or: -- <cmd>)
|
|
141
|
+
avr vm ssh <vm-id> --login -- claude -p 'summarize the repo' # one-off command in a login shell (sees forwarded env)
|
|
142
|
+
avr vm ssh <vm-id> --session dev # persistent tmux session, survives a dropped connection
|
|
143
|
+
avr vm ssh-config <vm-id> --append # add `ssh <alias>` (scp/rsync, VS Code Remote-SSH) to ~/.ssh/config
|
|
144
|
+
avr vm list ; avr vm stop <vm-id> # lifecycle
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
`bootstrap` runs each step over SSH and streams it live, feeding secrets (GitHub token, env values, agent credentials) on stdin rather than argv. `--forward-agent-creds` carries your local `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`, or a Claude subscription token — if none is set it offers to run `claude setup-token` and forwards the pasted `CLAUDE_CODE_OAUTH_TOKEN`. Disks are ephemeral, so re-run it after every `avr vm start`. `avr vm ssh` pins the VM's host key when its endpoint publishes one; if it doesn't, `avr` warns and falls back to trust-on-first-use — pass `--session <name>` to attach to (or create) a persistent tmux session that survives a dropped connection. A one-off `avr vm ssh -- <cmd>` runs in a non-login shell that sources no startup files, so pass `--login` when the command needs bootstrap-forwarded env (e.g. `claude`'s subscription token). `avr vm ssh-config` emits a host-key-pinned SSH config block so plain `ssh`, scp/rsync, and VS Code / Cursor Remote-SSH reach the VM without wrapping each tool — `--append` writes it into `~/.ssh/config` for you (idempotent: re-run after a restart to refresh the endpoint in place). For a desktop, `avr vm rdp` / `avr vm vnc` tunnel over the same SSH endpoint, and `avr vm port-forward` is the generic primitive (repeat `--port` to forward several ports over one connection). A VM created with `--repo` inherits that repository's build/CI caches; `--disable-cache <name>` narrows them per VM (it can turn an inherited cache off, never on).
|
|
148
|
+
|
|
129
149
|
**Cancel or rerun a run**
|
|
130
150
|
|
|
131
151
|
```sh
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: avr-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Avrea command-line client
|
|
5
5
|
Author-email: Avrea <support@avrea.com>
|
|
6
6
|
License-Expression: Apache-2.0
|
|
@@ -157,6 +157,26 @@ avr job metrics <job-id> --watch # CPU/memory/IO gauges, refreshed every f
|
|
|
157
157
|
avr job metrics <job-id> # static post-mortem after the job ended
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
+
**Long-running dev VMs**
|
|
161
|
+
|
|
162
|
+
Durable, org-scoped VMs reachable over SSH (plus RDP/VNC for a desktop), separate from job VMs. Create one with your key, then set it up in a single command:
|
|
163
|
+
|
|
164
|
+
```sh
|
|
165
|
+
avr vm create --name dev --os linux --size 2-vcpu \
|
|
166
|
+
--ssh-key @~/.ssh/id_ed25519.pub --ephemeral --wait # boot, wait for SSH, print connect
|
|
167
|
+
avr vm create --name ci --os linux --size 8-vcpu --ephemeral \
|
|
168
|
+
--repo org/project --disable-cache gha,packages # attach a repo, narrow which caches it uses
|
|
169
|
+
avr vm bootstrap <vm-id> --setup-github --install claude,codex \
|
|
170
|
+
--repo https://github.com/org/project --env AWS_REGION=eu-north-1
|
|
171
|
+
avr vm ssh <vm-id> # SSH session, host key pinned when published (or: -- <cmd>)
|
|
172
|
+
avr vm ssh <vm-id> --login -- claude -p 'summarize the repo' # one-off command in a login shell (sees forwarded env)
|
|
173
|
+
avr vm ssh <vm-id> --session dev # persistent tmux session, survives a dropped connection
|
|
174
|
+
avr vm ssh-config <vm-id> --append # add `ssh <alias>` (scp/rsync, VS Code Remote-SSH) to ~/.ssh/config
|
|
175
|
+
avr vm list ; avr vm stop <vm-id> # lifecycle
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
`bootstrap` runs each step over SSH and streams it live, feeding secrets (GitHub token, env values, agent credentials) on stdin rather than argv. `--forward-agent-creds` carries your local `ANTHROPIC_API_KEY` / `OPENAI_API_KEY`, or a Claude subscription token — if none is set it offers to run `claude setup-token` and forwards the pasted `CLAUDE_CODE_OAUTH_TOKEN`. Disks are ephemeral, so re-run it after every `avr vm start`. `avr vm ssh` pins the VM's host key when its endpoint publishes one; if it doesn't, `avr` warns and falls back to trust-on-first-use — pass `--session <name>` to attach to (or create) a persistent tmux session that survives a dropped connection. A one-off `avr vm ssh -- <cmd>` runs in a non-login shell that sources no startup files, so pass `--login` when the command needs bootstrap-forwarded env (e.g. `claude`'s subscription token). `avr vm ssh-config` emits a host-key-pinned SSH config block so plain `ssh`, scp/rsync, and VS Code / Cursor Remote-SSH reach the VM without wrapping each tool — `--append` writes it into `~/.ssh/config` for you (idempotent: re-run after a restart to refresh the endpoint in place). For a desktop, `avr vm rdp` / `avr vm vnc` tunnel over the same SSH endpoint, and `avr vm port-forward` is the generic primitive (repeat `--port` to forward several ports over one connection). A VM created with `--repo` inherits that repository's build/CI caches; `--disable-cache <name>` narrows them per VM (it can turn an inherited cache off, never on).
|
|
179
|
+
|
|
160
180
|
**Cancel or rerun a run**
|
|
161
181
|
|
|
162
182
|
```sh
|
|
@@ -28,6 +28,7 @@ avrea_cli/paths.py
|
|
|
28
28
|
avrea_cli/repo_context.py
|
|
29
29
|
avrea_cli/transport.py
|
|
30
30
|
avrea_cli/version.py
|
|
31
|
+
avrea_cli/vm.py
|
|
31
32
|
avrea_cli/assets/logo_mark.svg
|
|
32
33
|
avrea_cli/commands/__init__.py
|
|
33
34
|
avrea_cli/commands/auth_cmd.py
|
|
@@ -46,11 +47,14 @@ avrea_cli/commands/workflow.py
|
|
|
46
47
|
tests/test_api_client.py
|
|
47
48
|
tests/test_auth.py
|
|
48
49
|
tests/test_auth_login.py
|
|
50
|
+
tests/test_auth_sso.py
|
|
49
51
|
tests/test_cache.py
|
|
50
52
|
tests/test_config.py
|
|
51
53
|
tests/test_config_cmd.py
|
|
52
54
|
tests/test_display.py
|
|
55
|
+
tests/test_docs_gen.py
|
|
53
56
|
tests/test_env_conventions.py
|
|
57
|
+
tests/test_firewall.py
|
|
54
58
|
tests/test_global_flags.py
|
|
55
59
|
tests/test_helpers.py
|
|
56
60
|
tests/test_job_list.py
|
|
@@ -60,8 +64,11 @@ tests/test_json_output.py
|
|
|
60
64
|
tests/test_lazy_registry.py
|
|
61
65
|
tests/test_log_display.py
|
|
62
66
|
tests/test_metrics_display.py
|
|
67
|
+
tests/test_org_email_domain.py
|
|
68
|
+
tests/test_org_saml.py
|
|
63
69
|
tests/test_output.py
|
|
64
70
|
tests/test_piped_output.py
|
|
71
|
+
tests/test_public_mirrors.py
|
|
65
72
|
tests/test_repo_context.py
|
|
66
73
|
tests/test_run_attempts.py
|
|
67
74
|
tests/test_run_list_flags.py
|
|
@@ -69,6 +76,7 @@ tests/test_run_logs.py
|
|
|
69
76
|
tests/test_run_view.py
|
|
70
77
|
tests/test_settings.py
|
|
71
78
|
tests/test_transport.py
|
|
79
|
+
tests/test_vm.py
|
|
72
80
|
tests/test_web_flag.py
|
|
73
81
|
tests/test_workflow_actions.py
|
|
74
82
|
tests/test_workflow_run.py
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
# Auto-generated by .github/workflows/release.yml; do not edit.
|
|
2
|
-
GIT_COMMIT = "
|
|
2
|
+
GIT_COMMIT = "0ee3d6ede53b90032c4a40561338c3e1b03d2c2b"
|
|
@@ -46,14 +46,45 @@ class ApiClient:
|
|
|
46
46
|
response.raise_for_status()
|
|
47
47
|
return response.json()
|
|
48
48
|
|
|
49
|
+
def public_get_text(self, path: str, params: dict[str, Any] | None = None) -> str:
|
|
50
|
+
"""GET a non-JSON public API response, such as SAML SP metadata."""
|
|
51
|
+
url = f"{self.config.public_api_url}{path}"
|
|
52
|
+
try:
|
|
53
|
+
response = self._http.get(url, headers=self.config.get_api_headers(), timeout=self.timeout, params=params)
|
|
54
|
+
except httpx.HTTPError as exc:
|
|
55
|
+
self._log("GET", url, type(exc).__name__)
|
|
56
|
+
raise
|
|
57
|
+
self._log("GET", str(response.request.url), response.status_code)
|
|
58
|
+
response.raise_for_status()
|
|
59
|
+
return response.text
|
|
60
|
+
|
|
49
61
|
def public_post(
|
|
50
|
-
self,
|
|
62
|
+
self,
|
|
63
|
+
path: str,
|
|
64
|
+
json: dict[str, Any] | None = None,
|
|
65
|
+
timeout: float | None = None,
|
|
66
|
+
*,
|
|
67
|
+
content: str | bytes | None = None,
|
|
68
|
+
params: dict[str, Any] | None = None,
|
|
69
|
+
content_type: str | None = None,
|
|
51
70
|
) -> dict[str, Any]:
|
|
52
71
|
"""POST request to public API."""
|
|
72
|
+
if json is not None and content is not None:
|
|
73
|
+
raise ValueError("public_post accepts either json or content, not both")
|
|
53
74
|
url = f"{self.config.public_api_url}{path}"
|
|
54
75
|
effective_timeout = timeout if timeout is not None else self.timeout
|
|
76
|
+
headers = self.config.get_api_headers()
|
|
77
|
+
if content_type is not None:
|
|
78
|
+
headers = {**headers, "Content-Type": content_type}
|
|
55
79
|
try:
|
|
56
|
-
response = self._http.post(
|
|
80
|
+
response = self._http.post(
|
|
81
|
+
url,
|
|
82
|
+
headers=headers,
|
|
83
|
+
timeout=effective_timeout,
|
|
84
|
+
json=json,
|
|
85
|
+
content=content,
|
|
86
|
+
params=params,
|
|
87
|
+
)
|
|
57
88
|
except httpx.HTTPError as exc:
|
|
58
89
|
self._log("POST", url, type(exc).__name__)
|
|
59
90
|
raise
|
|
@@ -25,6 +25,7 @@ from pathlib import Path
|
|
|
25
25
|
from typing import Any
|
|
26
26
|
from typing import override
|
|
27
27
|
from urllib.parse import parse_qs
|
|
28
|
+
from urllib.parse import urlencode
|
|
28
29
|
from urllib.parse import urlparse
|
|
29
30
|
import click
|
|
30
31
|
import html as html_module
|
|
@@ -191,6 +192,9 @@ class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
|
191
192
|
|
|
192
193
|
session_cookie = None
|
|
193
194
|
csrf_token = None
|
|
195
|
+
# Set when the control plane bounces an OAuth sign-in because the email's
|
|
196
|
+
# domain enforces SAML. Carries the org slug and arrives without a session.
|
|
197
|
+
sso_required_slug: str | None = None
|
|
194
198
|
# Set by login() before the server runs so the handler can call /users/me
|
|
195
199
|
# with the just-issued session cookie and surface the email on the page.
|
|
196
200
|
public_api_url: str | None = None
|
|
@@ -207,6 +211,8 @@ class OAuthCallbackHandler(BaseHTTPRequestHandler):
|
|
|
207
211
|
OAuthCallbackHandler.session_cookie = query["session"][0]
|
|
208
212
|
if "csrf_token" in query:
|
|
209
213
|
OAuthCallbackHandler.csrf_token = query["csrf_token"][0]
|
|
214
|
+
if "sso_required" in query:
|
|
215
|
+
OAuthCallbackHandler.sso_required_slug = query["sso_required"][0]
|
|
210
216
|
if not OAuthCallbackHandler.session_cookie:
|
|
211
217
|
cookie_header = self.headers.get("Cookie", "")
|
|
212
218
|
if "avrea_session=" in cookie_header:
|
|
@@ -354,9 +360,55 @@ code {{
|
|
|
354
360
|
"""
|
|
355
361
|
|
|
356
362
|
|
|
357
|
-
def
|
|
363
|
+
def discover_sso_login_path(public_api_url: str, email: str) -> str | None:
|
|
364
|
+
"""The org's SAML login path when ``email``'s domain enforces SSO, else None.
|
|
365
|
+
|
|
366
|
+
Mirrors what the console does before offering OAuth buttons. The email goes
|
|
367
|
+
in the POST body, not the query, to keep it out of access logs.
|
|
358
368
|
"""
|
|
359
|
-
|
|
369
|
+
try:
|
|
370
|
+
response = httpx.post(
|
|
371
|
+
f"{public_api_url}/sso/discover",
|
|
372
|
+
json={"email": email},
|
|
373
|
+
timeout=10.0,
|
|
374
|
+
)
|
|
375
|
+
response.raise_for_status()
|
|
376
|
+
body = response.json()
|
|
377
|
+
except httpx.HTTPError as e:
|
|
378
|
+
raise click.ClickException(f"Could not reach {public_api_url} to check for SSO: {e}") from None
|
|
379
|
+
except ValueError:
|
|
380
|
+
raise click.ClickException(f"Unexpected SSO discovery response from {public_api_url}") from None
|
|
381
|
+
# Valid JSON that isn't an object (a bare list, or null from a proxy) would
|
|
382
|
+
# otherwise blow up on .get with an AttributeError instead of this message.
|
|
383
|
+
if not isinstance(body, dict):
|
|
384
|
+
raise click.ClickException(f"Unexpected SSO discovery response from {public_api_url}")
|
|
385
|
+
if body.get("method") != "saml":
|
|
386
|
+
return None
|
|
387
|
+
path = body.get("saml_login_path")
|
|
388
|
+
if not path:
|
|
389
|
+
raise click.ClickException(f"{public_api_url} reported SSO for {email} but returned no login path")
|
|
390
|
+
return path
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _auth_url(public_api_url: str, callback_uri: str, *, provider: str, saml_login_path: str | None) -> str:
|
|
394
|
+
"""Where the browser starts the flow. Every branch lands back on
|
|
395
|
+
``callback_uri`` with session/csrf_token in the query."""
|
|
396
|
+
if saml_login_path is not None:
|
|
397
|
+
return f"{public_api_url}{saml_login_path}?{urlencode({'next': callback_uri})}"
|
|
398
|
+
if provider == "google":
|
|
399
|
+
return f"{public_api_url}/oauth/google/login?final_redirect={callback_uri}"
|
|
400
|
+
if provider == "github":
|
|
401
|
+
return f"{public_api_url}/oauth/github/login?next={callback_uri}"
|
|
402
|
+
raise click.ClickException(f"Unsupported auth provider '{provider}'")
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def login(public_api_url: str, *, provider: str = "github", email: str | None = None) -> str:
|
|
406
|
+
"""
|
|
407
|
+
Perform browser login and return the API key.
|
|
408
|
+
|
|
409
|
+
Routes through the org's SAML IdP when ``email`` is given and its domain
|
|
410
|
+
enforces SSO; otherwise through ``provider``'s OAuth flow. Both land on the
|
|
411
|
+
same local callback, so the handling below is shared.
|
|
360
412
|
Raises click.Abort on failure.
|
|
361
413
|
"""
|
|
362
414
|
# Control plane 302s here after the upstream OAuth callback completes;
|
|
@@ -371,8 +423,15 @@ def login(public_api_url: str, *, provider: str = "github") -> str:
|
|
|
371
423
|
# otherwise leave stale session/csrf values behind.
|
|
372
424
|
OAuthCallbackHandler.session_cookie = None
|
|
373
425
|
OAuthCallbackHandler.csrf_token = None
|
|
426
|
+
OAuthCallbackHandler.sso_required_slug = None
|
|
374
427
|
OAuthCallbackHandler.public_api_url = public_api_url
|
|
375
428
|
|
|
429
|
+
# Resolve the destination before binding the port: discovery is a network
|
|
430
|
+
# call and an unknown provider raises, so doing this first keeps either
|
|
431
|
+
# failure from leaving a stray listener on 8765.
|
|
432
|
+
saml_login_path = discover_sso_login_path(public_api_url, email) if email else None
|
|
433
|
+
auth_url = _auth_url(public_api_url, callback_uri, provider=provider, saml_login_path=saml_login_path)
|
|
434
|
+
|
|
376
435
|
try:
|
|
377
436
|
server = HTTPServer(("localhost", port), OAuthCallbackHandler)
|
|
378
437
|
except OSError as e:
|
|
@@ -380,12 +439,6 @@ def login(public_api_url: str, *, provider: str = "github") -> str:
|
|
|
380
439
|
click.echo("Ensure no other process is using this port and try again.")
|
|
381
440
|
raise click.Abort() from None
|
|
382
441
|
|
|
383
|
-
if provider == "google":
|
|
384
|
-
auth_url = f"{public_api_url}/oauth/google/login?final_redirect={callback_uri}"
|
|
385
|
-
elif provider == "github":
|
|
386
|
-
auth_url = f"{public_api_url}/oauth/github/login?next={callback_uri}"
|
|
387
|
-
else:
|
|
388
|
-
raise click.ClickException(f"Unsupported auth provider '{provider}'")
|
|
389
442
|
click.echo(f"Opening browser to: {auth_url}")
|
|
390
443
|
click.echo("Waiting for authentication...")
|
|
391
444
|
|
|
@@ -418,6 +471,16 @@ def login(public_api_url: str, *, provider: str = "github") -> str:
|
|
|
418
471
|
server.server_close()
|
|
419
472
|
|
|
420
473
|
if not OAuthCallbackHandler.session_cookie:
|
|
474
|
+
if OAuthCallbackHandler.sso_required_slug:
|
|
475
|
+
# The control plane refuses OAuth for SSO-enforced domains and
|
|
476
|
+
# bounces back without a session. Only an email gets us the IdP.
|
|
477
|
+
click.echo(
|
|
478
|
+
f"Error: {OAuthCallbackHandler.sso_required_slug} requires single sign-on.",
|
|
479
|
+
err=True,
|
|
480
|
+
)
|
|
481
|
+
click.echo("Retry with your work email so the CLI can find your identity provider:", err=True)
|
|
482
|
+
click.echo(" avr auth login --email you@yourcompany.com", err=True)
|
|
483
|
+
raise click.Abort()
|
|
421
484
|
click.echo("Error: Authentication failed - no session received", err=True)
|
|
422
485
|
raise click.Abort()
|
|
423
486
|
|
|
@@ -67,10 +67,10 @@ def _maybe_auto_pin_default_org(api_url: str, api_key: str) -> tuple[str, str] |
|
|
|
67
67
|
return slug, org_id
|
|
68
68
|
|
|
69
69
|
|
|
70
|
-
def _do_login(ctx, provider):
|
|
70
|
+
def _do_login(ctx, provider, email=None):
|
|
71
71
|
config: CliConfig = ctx.obj["config"]
|
|
72
72
|
try:
|
|
73
|
-
api_key = auth.login(config.public_api_url, provider=provider)
|
|
73
|
+
api_key = auth.login(config.public_api_url, provider=provider, email=email)
|
|
74
74
|
except click.ClickException as exc:
|
|
75
75
|
# Click's exception carries a useful `.message`; surface it under a
|
|
76
76
|
# consistent "Login failed:" prefix and exit 1.
|
|
@@ -138,10 +138,15 @@ def auth_group(ctx):
|
|
|
138
138
|
show_default=True,
|
|
139
139
|
help="OAuth provider to use for CLI login.",
|
|
140
140
|
)
|
|
141
|
+
@click.option(
|
|
142
|
+
"--email",
|
|
143
|
+
default=None,
|
|
144
|
+
help="Work email. Routes through your company's SSO if its domain requires it, ignoring --provider.",
|
|
145
|
+
)
|
|
141
146
|
@click.pass_context
|
|
142
|
-
def auth_login(ctx, provider: str):
|
|
147
|
+
def auth_login(ctx, provider: str, email: str | None):
|
|
143
148
|
"""Authenticate via browser and store credentials."""
|
|
144
|
-
_do_login(ctx, provider)
|
|
149
|
+
_do_login(ctx, provider, email)
|
|
145
150
|
|
|
146
151
|
|
|
147
152
|
@auth_group.command("logout")
|
|
@@ -283,10 +288,15 @@ def _format_token(token: str | None, show: bool) -> str:
|
|
|
283
288
|
show_default=True,
|
|
284
289
|
help="OAuth provider to use for CLI login.",
|
|
285
290
|
)
|
|
291
|
+
@click.option(
|
|
292
|
+
"--email",
|
|
293
|
+
default=None,
|
|
294
|
+
help="Work email. Routes through your company's SSO if its domain requires it, ignoring --provider.",
|
|
295
|
+
)
|
|
286
296
|
@click.pass_context
|
|
287
|
-
def login_alias(ctx, provider: str):
|
|
297
|
+
def login_alias(ctx, provider: str, email: str | None):
|
|
288
298
|
"""Authenticate via browser and store credentials."""
|
|
289
|
-
_do_login(ctx, provider)
|
|
299
|
+
_do_login(ctx, provider, email)
|
|
290
300
|
|
|
291
301
|
|
|
292
302
|
@click.command("logout", hidden=True)
|