openhack-cli 0.1.0__py3-none-any.whl
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.
- openhack_cli/__init__.py +3 -0
- openhack_cli/cli.py +88 -0
- openhack_cli/client.py +124 -0
- openhack_cli/commands/__init__.py +1 -0
- openhack_cli/commands/auth.py +185 -0
- openhack_cli/commands/config_cmd.py +56 -0
- openhack_cli/commands/orgs.py +71 -0
- openhack_cli/commands/pentest.py +603 -0
- openhack_cli/commands/projects.py +130 -0
- openhack_cli/commands/scans.py +154 -0
- openhack_cli/commands/vulns.py +341 -0
- openhack_cli/config.py +137 -0
- openhack_cli/context.py +76 -0
- openhack_cli/output.py +104 -0
- openhack_cli-0.1.0.dist-info/METADATA +164 -0
- openhack_cli-0.1.0.dist-info/RECORD +20 -0
- openhack_cli-0.1.0.dist-info/WHEEL +5 -0
- openhack_cli-0.1.0.dist-info/entry_points.txt +2 -0
- openhack_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- openhack_cli-0.1.0.dist-info/top_level.txt +1 -0
openhack_cli/__init__.py
ADDED
openhack_cli/cli.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""OpenHack CLI entrypoint.
|
|
2
|
+
|
|
3
|
+
Wires the command groups together, sets up the shared Click context (config +
|
|
4
|
+
output mode), and provides uniform error handling for API/auth failures.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
|
|
13
|
+
from . import __version__, output
|
|
14
|
+
from .client import APIError, AuthError
|
|
15
|
+
from .commands.auth import auth
|
|
16
|
+
from .commands.config_cmd import config
|
|
17
|
+
from .commands.orgs import orgs
|
|
18
|
+
from .commands.pentest import pentest
|
|
19
|
+
from .commands.projects import projects
|
|
20
|
+
from .commands.scans import scans
|
|
21
|
+
from .commands.vulns import vulns
|
|
22
|
+
from .config import Config, LOCAL_APP_URL
|
|
23
|
+
|
|
24
|
+
CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.group(context_settings=CONTEXT_SETTINGS)
|
|
28
|
+
@click.version_option(__version__, "-v", "--version", prog_name="openhack-cli")
|
|
29
|
+
@click.option("--json", "json_output", is_flag=True,
|
|
30
|
+
help="Output raw JSON (for scripts and agents).")
|
|
31
|
+
@click.option("--app-url", default=None, help="Override the OpenHack app URL.")
|
|
32
|
+
@click.option("--local", "use_local", is_flag=True,
|
|
33
|
+
help=f"Target the local dev server ({LOCAL_APP_URL}).")
|
|
34
|
+
@click.option("--token", default=None,
|
|
35
|
+
help="Override the API token (else uses stored credentials).")
|
|
36
|
+
@click.pass_context
|
|
37
|
+
def cli(ctx: click.Context, json_output: bool, app_url: str | None,
|
|
38
|
+
use_local: bool, token: str | None) -> None:
|
|
39
|
+
"""OpenHack CLI — authenticated, programmatic access to OpenHack.
|
|
40
|
+
|
|
41
|
+
Start with `openhack-cli auth login`, then explore `orgs`, `projects`,
|
|
42
|
+
`scans`, `vulns`, and `pentest`.
|
|
43
|
+
"""
|
|
44
|
+
cfg = Config.load()
|
|
45
|
+
# --local is a shorthand for the dev server; --app-url wins if both given.
|
|
46
|
+
# These are runtime overrides (beat OPENHACK_DEV / OPENHACK_APP_URL / config).
|
|
47
|
+
if use_local:
|
|
48
|
+
cfg.set_override_app_url(LOCAL_APP_URL)
|
|
49
|
+
if app_url:
|
|
50
|
+
cfg.set_override_app_url(app_url.rstrip("/"))
|
|
51
|
+
if token:
|
|
52
|
+
cfg.set("token", token)
|
|
53
|
+
ctx.obj = {"config": cfg, "json": json_output}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
cli.add_command(auth)
|
|
57
|
+
cli.add_command(orgs)
|
|
58
|
+
cli.add_command(projects)
|
|
59
|
+
cli.add_command(scans)
|
|
60
|
+
cli.add_command(vulns)
|
|
61
|
+
cli.add_command(pentest)
|
|
62
|
+
cli.add_command(config)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main() -> None:
|
|
66
|
+
try:
|
|
67
|
+
cli(standalone_mode=False)
|
|
68
|
+
except AuthError as exc:
|
|
69
|
+
output.error(f"Authentication failed ({exc.status}): {exc.message}")
|
|
70
|
+
output.info("Run `openhack-cli auth login` to (re)authorize, or check "
|
|
71
|
+
"that this endpoint accepts CLI tokens.")
|
|
72
|
+
sys.exit(2)
|
|
73
|
+
except APIError as exc:
|
|
74
|
+
output.error(exc.message if exc.status else str(exc))
|
|
75
|
+
sys.exit(1)
|
|
76
|
+
except click.ClickException as exc:
|
|
77
|
+
exc.show()
|
|
78
|
+
sys.exit(exc.exit_code)
|
|
79
|
+
except click.Abort:
|
|
80
|
+
output.error("Aborted.")
|
|
81
|
+
sys.exit(130)
|
|
82
|
+
except KeyboardInterrupt:
|
|
83
|
+
output.error("Interrupted.")
|
|
84
|
+
sys.exit(130)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
main()
|
openhack_cli/client.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""HTTP client for the OpenHack API.
|
|
2
|
+
|
|
3
|
+
Wraps ``requests`` with the CLI's Bearer-token auth and uniform error
|
|
4
|
+
handling. Every authenticated endpoint expects::
|
|
5
|
+
|
|
6
|
+
Authorization: Bearer openhack_<hex>
|
|
7
|
+
|
|
8
|
+
which is the token issued by the device-code login flow.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Optional
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from . import __version__
|
|
18
|
+
|
|
19
|
+
# Endpoints that the CLI talks to are JSON unless noted (e.g. PDF reports).
|
|
20
|
+
USER_AGENT = f"openhack-cli/{__version__}"
|
|
21
|
+
DEFAULT_TIMEOUT = 30
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class APIError(Exception):
|
|
25
|
+
"""Raised when the API returns a non-2xx response."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, status: int, message: str, body: Any = None):
|
|
28
|
+
self.status = status
|
|
29
|
+
self.message = message
|
|
30
|
+
self.body = body
|
|
31
|
+
super().__init__(f"[{status}] {message}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class AuthError(APIError):
|
|
35
|
+
"""Raised on 401/403 so the CLI can prompt the user to re-login."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class Client:
|
|
39
|
+
def __init__(self, app_url: str, token: Optional[str] = None,
|
|
40
|
+
timeout: int = DEFAULT_TIMEOUT):
|
|
41
|
+
self.app_url = app_url.rstrip("/")
|
|
42
|
+
self.token = token
|
|
43
|
+
self.timeout = timeout
|
|
44
|
+
self._session = requests.Session()
|
|
45
|
+
|
|
46
|
+
# ----- low-level --------------------------------------------------------
|
|
47
|
+
def _headers(self, extra: Optional[dict] = None, auth: bool = True) -> dict:
|
|
48
|
+
headers = {"User-Agent": USER_AGENT, "Accept": "application/json"}
|
|
49
|
+
if auth and self.token:
|
|
50
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
51
|
+
if extra:
|
|
52
|
+
headers.update(extra)
|
|
53
|
+
return headers
|
|
54
|
+
|
|
55
|
+
def request(
|
|
56
|
+
self,
|
|
57
|
+
method: str,
|
|
58
|
+
path: str,
|
|
59
|
+
*,
|
|
60
|
+
json: Any = None,
|
|
61
|
+
params: Optional[dict] = None,
|
|
62
|
+
auth: bool = True,
|
|
63
|
+
raw: bool = False,
|
|
64
|
+
) -> Any:
|
|
65
|
+
"""Perform a request and return parsed JSON (or the raw Response).
|
|
66
|
+
|
|
67
|
+
``raw=True`` returns the underlying ``requests.Response`` so callers can
|
|
68
|
+
stream binary payloads (e.g. PDF reports).
|
|
69
|
+
"""
|
|
70
|
+
url = path if path.startswith("http") else f"{self.app_url}{path}"
|
|
71
|
+
try:
|
|
72
|
+
resp = self._session.request(
|
|
73
|
+
method,
|
|
74
|
+
url,
|
|
75
|
+
json=json,
|
|
76
|
+
params=params,
|
|
77
|
+
headers=self._headers(auth=auth),
|
|
78
|
+
timeout=self.timeout,
|
|
79
|
+
)
|
|
80
|
+
except requests.RequestException as exc:
|
|
81
|
+
raise APIError(0, f"Network error: {exc}") from exc
|
|
82
|
+
|
|
83
|
+
if resp.status_code >= 400:
|
|
84
|
+
self._raise_for_status(resp)
|
|
85
|
+
|
|
86
|
+
if raw:
|
|
87
|
+
return resp
|
|
88
|
+
if not resp.content:
|
|
89
|
+
return None
|
|
90
|
+
ctype = resp.headers.get("Content-Type", "")
|
|
91
|
+
if "application/json" in ctype:
|
|
92
|
+
return resp.json()
|
|
93
|
+
return resp.content
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _raise_for_status(resp: requests.Response) -> None:
|
|
97
|
+
message = resp.reason or "Request failed"
|
|
98
|
+
body: Any = None
|
|
99
|
+
try:
|
|
100
|
+
body = resp.json()
|
|
101
|
+
if isinstance(body, dict):
|
|
102
|
+
message = body.get("error") or body.get("message") or message
|
|
103
|
+
if body.get("message") and body.get("error"):
|
|
104
|
+
message = f"{body['error']}: {body['message']}"
|
|
105
|
+
except ValueError:
|
|
106
|
+
text = resp.text.strip()
|
|
107
|
+
if text:
|
|
108
|
+
message = text[:300]
|
|
109
|
+
if resp.status_code in (401, 403):
|
|
110
|
+
raise AuthError(resp.status_code, message, body)
|
|
111
|
+
raise APIError(resp.status_code, message, body)
|
|
112
|
+
|
|
113
|
+
# ----- verb helpers -----------------------------------------------------
|
|
114
|
+
def get(self, path: str, **kw) -> Any:
|
|
115
|
+
return self.request("GET", path, **kw)
|
|
116
|
+
|
|
117
|
+
def post(self, path: str, **kw) -> Any:
|
|
118
|
+
return self.request("POST", path, **kw)
|
|
119
|
+
|
|
120
|
+
def patch(self, path: str, **kw) -> Any:
|
|
121
|
+
return self.request("PATCH", path, **kw)
|
|
122
|
+
|
|
123
|
+
def delete(self, path: str, **kw) -> Any:
|
|
124
|
+
return self.request("DELETE", path, **kw)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Command groups for the OpenHack CLI."""
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""`openhack-cli auth` — device-code login, logout, and status.
|
|
2
|
+
|
|
3
|
+
Implements the same device-code flow the app exposes for the CLI:
|
|
4
|
+
|
|
5
|
+
1. POST /api/cli/auth -> { device_code, user_code, verification_url }
|
|
6
|
+
2. open verification_url in the browser; user signs in + picks an org + approves
|
|
7
|
+
3. POST /api/cli/auth/poll -> { status: "pending" } ... until
|
|
8
|
+
{ status: "approved", token, org, user }
|
|
9
|
+
|
|
10
|
+
The returned ``token`` (``openhack_<hex>``) is a long-lived, org-scoped API key
|
|
11
|
+
sent as ``Authorization: Bearer`` on every later request.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import time
|
|
17
|
+
import webbrowser
|
|
18
|
+
|
|
19
|
+
import click
|
|
20
|
+
|
|
21
|
+
from .. import output
|
|
22
|
+
from ..client import APIError, Client
|
|
23
|
+
from ..config import Config
|
|
24
|
+
from ..context import get_config
|
|
25
|
+
|
|
26
|
+
POLL_INTERVAL = 2.0 # seconds between poll attempts
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@click.group()
|
|
30
|
+
def auth() -> None:
|
|
31
|
+
"""Log in to OpenHack and manage CLI credentials."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@auth.command()
|
|
35
|
+
@click.option("--no-browser", is_flag=True,
|
|
36
|
+
help="Don't auto-open the browser; just print the URL.")
|
|
37
|
+
@click.option("--app-url", default=None,
|
|
38
|
+
help="Override the OpenHack app URL for this login.")
|
|
39
|
+
@click.pass_context
|
|
40
|
+
def login(ctx: click.Context, no_browser: bool, app_url: str | None) -> None:
|
|
41
|
+
"""Authorize this CLI against your OpenHack account."""
|
|
42
|
+
cfg: Config = get_config(ctx)
|
|
43
|
+
base_url = (app_url or cfg.app_url).rstrip("/")
|
|
44
|
+
# The auth-start + poll endpoints are public (no token needed).
|
|
45
|
+
client = Client(base_url, token=None)
|
|
46
|
+
|
|
47
|
+
if cfg.is_authenticated:
|
|
48
|
+
output.warn("Already logged in. Re-authorizing will replace the current token.")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
start = client.post("/api/cli/auth", auth=False)
|
|
52
|
+
except APIError as exc:
|
|
53
|
+
raise click.ClickException(f"Failed to start login: {exc.message}")
|
|
54
|
+
|
|
55
|
+
device_code = start["device_code"]
|
|
56
|
+
user_code = start["user_code"]
|
|
57
|
+
verification_url = start["verification_url"]
|
|
58
|
+
expires_in = int(start.get("expires_in", 900))
|
|
59
|
+
|
|
60
|
+
output.console.print()
|
|
61
|
+
output.console.print("To authorize the OpenHack CLI, visit:\n")
|
|
62
|
+
output.console.print(f" [bold cyan]{verification_url}[/bold cyan]\n")
|
|
63
|
+
output.console.print("and confirm this code:\n")
|
|
64
|
+
output.console.print(f" [bold]{user_code}[/bold]\n")
|
|
65
|
+
|
|
66
|
+
if not no_browser:
|
|
67
|
+
try:
|
|
68
|
+
webbrowser.open(verification_url)
|
|
69
|
+
except Exception:
|
|
70
|
+
pass
|
|
71
|
+
|
|
72
|
+
output.info("Waiting for approval in the browser… (Ctrl-C to cancel)")
|
|
73
|
+
|
|
74
|
+
deadline = time.time() + expires_in
|
|
75
|
+
result = None
|
|
76
|
+
while time.time() < deadline:
|
|
77
|
+
time.sleep(POLL_INTERVAL)
|
|
78
|
+
try:
|
|
79
|
+
poll = client.post(
|
|
80
|
+
"/api/cli/auth/poll", json={"device_code": device_code}, auth=False
|
|
81
|
+
)
|
|
82
|
+
except APIError as exc:
|
|
83
|
+
if exc.status == 410:
|
|
84
|
+
raise click.ClickException(
|
|
85
|
+
"Login session expired. Run `openhack-cli auth login` again."
|
|
86
|
+
)
|
|
87
|
+
raise click.ClickException(f"Login failed: {exc.message}")
|
|
88
|
+
if poll.get("status") == "approved":
|
|
89
|
+
result = poll
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
if not result:
|
|
93
|
+
raise click.ClickException("Login timed out before approval.")
|
|
94
|
+
|
|
95
|
+
# Persist the token + bound org/user context.
|
|
96
|
+
cfg.set("app_url", base_url)
|
|
97
|
+
cfg.set("token", result["token"])
|
|
98
|
+
cfg.set("user", result.get("user"))
|
|
99
|
+
cfg.set("org", result.get("org"))
|
|
100
|
+
cfg.save()
|
|
101
|
+
|
|
102
|
+
org = result.get("org") or {}
|
|
103
|
+
user = result.get("user") or {}
|
|
104
|
+
name = _display_name(user)
|
|
105
|
+
output.console.print()
|
|
106
|
+
output.success(
|
|
107
|
+
f"Logged in as [bold]{name}[/bold]"
|
|
108
|
+
+ (f" — org [bold]{org.get('name')}[/bold]" if org.get("name") else "")
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@auth.command()
|
|
113
|
+
@click.pass_context
|
|
114
|
+
def logout(ctx: click.Context) -> None:
|
|
115
|
+
"""Remove stored credentials from this machine."""
|
|
116
|
+
cfg: Config = get_config(ctx)
|
|
117
|
+
if not cfg.is_authenticated:
|
|
118
|
+
output.warn("Not logged in.")
|
|
119
|
+
return
|
|
120
|
+
cfg.clear_auth()
|
|
121
|
+
cfg.save()
|
|
122
|
+
output.success("Logged out. Token removed from this machine.")
|
|
123
|
+
output.info("(The API key still exists server-side; revoke it in org settings.)")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@auth.command()
|
|
127
|
+
@click.pass_context
|
|
128
|
+
def status(ctx: click.Context) -> None:
|
|
129
|
+
"""Show the current login status and active context."""
|
|
130
|
+
cfg: Config = get_config(ctx)
|
|
131
|
+
payload = {
|
|
132
|
+
"authenticated": cfg.is_authenticated,
|
|
133
|
+
"app_url": cfg.app_url,
|
|
134
|
+
"user": cfg.user,
|
|
135
|
+
"org": cfg.org,
|
|
136
|
+
"project": cfg.project,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
def render(_data):
|
|
140
|
+
if not cfg.is_authenticated:
|
|
141
|
+
output.warn("Not logged in. Run `openhack-cli auth login`.")
|
|
142
|
+
output.console.print(f"App URL: [dim]{cfg.app_url}[/dim]")
|
|
143
|
+
return
|
|
144
|
+
user = cfg.user or {}
|
|
145
|
+
output.success(f"Logged in as [bold]{_display_name(user)}[/bold]")
|
|
146
|
+
if user.get("email"):
|
|
147
|
+
output.console.print(f" Email: {user['email']}")
|
|
148
|
+
output.console.print(f" App URL: {cfg.app_url}")
|
|
149
|
+
if cfg.org:
|
|
150
|
+
output.console.print(
|
|
151
|
+
f" Org: {cfg.org.get('name')} "
|
|
152
|
+
f"[dim]({cfg.org.get('slug') or cfg.org.get('id')})[/dim]"
|
|
153
|
+
)
|
|
154
|
+
if cfg.project:
|
|
155
|
+
output.console.print(
|
|
156
|
+
f" Project: {cfg.project.get('name')} "
|
|
157
|
+
f"[dim]({cfg.project.get('slug') or cfg.project.get('id')})[/dim]"
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
output.emit(ctx.obj, payload, render)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@auth.command(name="whoami")
|
|
164
|
+
@click.pass_context
|
|
165
|
+
def whoami(ctx: click.Context) -> None:
|
|
166
|
+
"""Alias for `auth status`."""
|
|
167
|
+
ctx.invoke(status)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@auth.command()
|
|
171
|
+
@click.pass_context
|
|
172
|
+
def token(ctx: click.Context) -> None:
|
|
173
|
+
"""Print the raw API token (for scripting). Keep it secret."""
|
|
174
|
+
cfg: Config = get_config(ctx)
|
|
175
|
+
if not cfg.token:
|
|
176
|
+
raise click.ClickException("Not logged in.")
|
|
177
|
+
click.echo(cfg.token)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _display_name(user: dict) -> str:
|
|
181
|
+
if not user:
|
|
182
|
+
return "unknown"
|
|
183
|
+
parts = [user.get("firstName"), user.get("lastName")]
|
|
184
|
+
name = " ".join(p for p in parts if p).strip()
|
|
185
|
+
return name or user.get("email") or "unknown"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""`openhack-cli config` — inspect and set CLI configuration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .. import output
|
|
8
|
+
from ..config import config_path
|
|
9
|
+
from ..context import get_config
|
|
10
|
+
|
|
11
|
+
# Keys the user is allowed to set directly. `token` is managed by auth login.
|
|
12
|
+
SETTABLE = {"app_url"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.group()
|
|
16
|
+
def config() -> None:
|
|
17
|
+
"""View and change CLI configuration."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@config.command(name="path")
|
|
21
|
+
def show_path() -> None:
|
|
22
|
+
"""Print the path to the config file."""
|
|
23
|
+
click.echo(str(config_path()))
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@config.command(name="show")
|
|
27
|
+
@click.pass_context
|
|
28
|
+
def show(ctx: click.Context) -> None:
|
|
29
|
+
"""Show the current configuration (token redacted)."""
|
|
30
|
+
cfg = get_config(ctx)
|
|
31
|
+
data = cfg.as_dict()
|
|
32
|
+
if data.get("token"):
|
|
33
|
+
data["token"] = data["token"][:14] + "…"
|
|
34
|
+
data["app_url"] = cfg.app_url # resolved value (env may override)
|
|
35
|
+
|
|
36
|
+
def render(d):
|
|
37
|
+
for key, value in d.items():
|
|
38
|
+
output.console.print(f" [bold]{key}[/bold]: {value}")
|
|
39
|
+
|
|
40
|
+
output.emit(ctx.obj, data, render)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@config.command(name="set")
|
|
44
|
+
@click.argument("key")
|
|
45
|
+
@click.argument("value")
|
|
46
|
+
@click.pass_context
|
|
47
|
+
def set_value(ctx: click.Context, key: str, value: str) -> None:
|
|
48
|
+
"""Set a config value (e.g. `config set app_url https://...`)."""
|
|
49
|
+
if key not in SETTABLE:
|
|
50
|
+
raise click.ClickException(
|
|
51
|
+
f"'{key}' is not settable. Allowed: {', '.join(sorted(SETTABLE))}."
|
|
52
|
+
)
|
|
53
|
+
cfg = get_config(ctx)
|
|
54
|
+
cfg.set(key, value.rstrip("/") if key == "app_url" else value)
|
|
55
|
+
cfg.save()
|
|
56
|
+
output.success(f"Set {key} = {value}")
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""`openhack-cli orgs` — list organizations and pick the active one."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
from .. import output
|
|
8
|
+
from ..context import get_client, get_config, match_resource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group()
|
|
12
|
+
def orgs() -> None:
|
|
13
|
+
"""List and select organizations."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _fetch_orgs(ctx: click.Context) -> list[dict]:
|
|
17
|
+
client = get_client(ctx)
|
|
18
|
+
data = client.get("/api/orgs")
|
|
19
|
+
return data.get("orgs", []) if isinstance(data, dict) else (data or [])
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@orgs.command(name="list")
|
|
23
|
+
@click.pass_context
|
|
24
|
+
def list_orgs(ctx: click.Context) -> None:
|
|
25
|
+
"""List organizations you belong to."""
|
|
26
|
+
items = _fetch_orgs(ctx)
|
|
27
|
+
cfg = get_config(ctx)
|
|
28
|
+
active_id = (cfg.org or {}).get("id")
|
|
29
|
+
|
|
30
|
+
def render(rows):
|
|
31
|
+
if not rows:
|
|
32
|
+
output.warn("No organizations found.")
|
|
33
|
+
return
|
|
34
|
+
table = output.make_table("Organizations",
|
|
35
|
+
["", "Name", "Slug", "Type", "Plan", "ID"])
|
|
36
|
+
for o in rows:
|
|
37
|
+
marker = "[green]●[/green]" if o.get("id") == active_id else ""
|
|
38
|
+
table.add_row(
|
|
39
|
+
marker,
|
|
40
|
+
o.get("name", "-"),
|
|
41
|
+
o.get("slug") or "-",
|
|
42
|
+
o.get("orgType") or "-",
|
|
43
|
+
o.get("subscriptionPlan") or "-",
|
|
44
|
+
output.short(o.get("id"), 38),
|
|
45
|
+
)
|
|
46
|
+
output.console.print(table)
|
|
47
|
+
|
|
48
|
+
output.emit(ctx.obj, items, render)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@orgs.command()
|
|
52
|
+
@click.argument("identifier")
|
|
53
|
+
@click.pass_context
|
|
54
|
+
def use(ctx: click.Context, identifier: str) -> None:
|
|
55
|
+
"""Set the active organization (by id, slug, or name)."""
|
|
56
|
+
items = _fetch_orgs(ctx)
|
|
57
|
+
match = match_resource(items, identifier)
|
|
58
|
+
if not match:
|
|
59
|
+
raise click.ClickException(
|
|
60
|
+
f"No organization matching '{identifier}'. Try `openhack-cli orgs list`."
|
|
61
|
+
)
|
|
62
|
+
cfg = get_config(ctx)
|
|
63
|
+
cfg.set("org", {
|
|
64
|
+
"id": match.get("id"),
|
|
65
|
+
"slug": match.get("slug"),
|
|
66
|
+
"name": match.get("name"),
|
|
67
|
+
})
|
|
68
|
+
# Switching orgs invalidates the previously-selected project.
|
|
69
|
+
cfg.set("project", None)
|
|
70
|
+
cfg.save()
|
|
71
|
+
output.success(f"Active org set to [bold]{match.get('name')}[/bold]")
|