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.
Files changed (87) hide show
  1. {avr_cli-0.1.6 → avr_cli-0.2.0}/PKG-INFO +21 -1
  2. {avr_cli-0.1.6 → avr_cli-0.2.0}/README.md +20 -0
  3. {avr_cli-0.1.6 → avr_cli-0.2.0}/avr_cli.egg-info/PKG-INFO +21 -1
  4. {avr_cli-0.1.6 → avr_cli-0.2.0}/avr_cli.egg-info/SOURCES.txt +8 -0
  5. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/_build_info.py +1 -1
  6. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/api_client.py +33 -2
  7. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/auth.py +71 -8
  8. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/auth_cmd.py +16 -6
  9. avr_cli-0.2.0/avrea_cli/commands/org.py +870 -0
  10. avr_cli-0.2.0/avrea_cli/commands/repo.py +545 -0
  11. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/docs_gen.py +31 -2
  12. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/firewall.py +59 -12
  13. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/helpers.py +4 -1
  14. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/main.py +3 -1
  15. avr_cli-0.2.0/avrea_cli/vm.py +2776 -0
  16. {avr_cli-0.1.6 → avr_cli-0.2.0}/pyproject.toml +1 -1
  17. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_api_client.py +41 -0
  18. avr_cli-0.2.0/tests/test_auth_sso.py +207 -0
  19. avr_cli-0.2.0/tests/test_docs_gen.py +46 -0
  20. avr_cli-0.2.0/tests/test_firewall.py +114 -0
  21. avr_cli-0.2.0/tests/test_org_email_domain.py +121 -0
  22. avr_cli-0.2.0/tests/test_org_saml.py +248 -0
  23. avr_cli-0.2.0/tests/test_public_mirrors.py +381 -0
  24. avr_cli-0.2.0/tests/test_vm.py +2104 -0
  25. avr_cli-0.1.6/avrea_cli/commands/org.py +0 -414
  26. avr_cli-0.1.6/avrea_cli/commands/repo.py +0 -154
  27. {avr_cli-0.1.6 → avr_cli-0.2.0}/LICENSE +0 -0
  28. {avr_cli-0.1.6 → avr_cli-0.2.0}/avr_cli.egg-info/dependency_links.txt +0 -0
  29. {avr_cli-0.1.6 → avr_cli-0.2.0}/avr_cli.egg-info/entry_points.txt +0 -0
  30. {avr_cli-0.1.6 → avr_cli-0.2.0}/avr_cli.egg-info/requires.txt +0 -0
  31. {avr_cli-0.1.6 → avr_cli-0.2.0}/avr_cli.egg-info/top_level.txt +0 -0
  32. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/__init__.py +0 -0
  33. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/assets/logo_mark.svg +0 -0
  34. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/audit.py +0 -0
  35. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/billing.py +0 -0
  36. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/click_ext.py +0 -0
  37. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/__init__.py +0 -0
  38. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/cache.py +0 -0
  39. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/config_cmd.py +0 -0
  40. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/health.py +0 -0
  41. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/internal.py +0 -0
  42. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/job.py +0 -0
  43. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/log.py +0 -0
  44. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/run.py +0 -0
  45. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/settings.py +0 -0
  46. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/status.py +0 -0
  47. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/commands/workflow.py +0 -0
  48. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/config.py +0 -0
  49. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/display.py +0 -0
  50. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/json_output.py +0 -0
  51. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/log_display.py +0 -0
  52. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/metrics_display.py +0 -0
  53. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/output.py +0 -0
  54. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/paths.py +0 -0
  55. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/repo_context.py +0 -0
  56. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/transport.py +0 -0
  57. {avr_cli-0.1.6 → avr_cli-0.2.0}/avrea_cli/version.py +0 -0
  58. {avr_cli-0.1.6 → avr_cli-0.2.0}/setup.cfg +0 -0
  59. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_auth.py +0 -0
  60. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_auth_login.py +0 -0
  61. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_cache.py +0 -0
  62. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_config.py +0 -0
  63. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_config_cmd.py +0 -0
  64. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_display.py +0 -0
  65. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_env_conventions.py +0 -0
  66. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_global_flags.py +0 -0
  67. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_helpers.py +0 -0
  68. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_job_list.py +0 -0
  69. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_job_metrics_cmd.py +0 -0
  70. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_job_view_logs.py +0 -0
  71. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_json_output.py +0 -0
  72. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_lazy_registry.py +0 -0
  73. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_log_display.py +0 -0
  74. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_metrics_display.py +0 -0
  75. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_output.py +0 -0
  76. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_piped_output.py +0 -0
  77. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_repo_context.py +0 -0
  78. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_run_attempts.py +0 -0
  79. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_run_list_flags.py +0 -0
  80. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_run_logs.py +0 -0
  81. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_run_view.py +0 -0
  82. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_settings.py +0 -0
  83. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_transport.py +0 -0
  84. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_web_flag.py +0 -0
  85. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_workflow_actions.py +0 -0
  86. {avr_cli-0.1.6 → avr_cli-0.2.0}/tests/test_workflow_run.py +0 -0
  87. {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.1.6
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.1.6
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 = "d1c368547a8ca31fad2ec0513c6a2cfc33e3cb80"
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, path: str, json: dict[str, Any] | None = None, timeout: float | None = None
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(url, headers=self.config.get_api_headers(), timeout=effective_timeout, json=json)
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 login(public_api_url: str, *, provider: str = "github") -> str:
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
- Perform browser OAuth login and return the API key.
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)