tooig 0.1.3__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.
- tooig/__init__.py +8 -0
- tooig/__main__.py +6 -0
- tooig/api.py +166 -0
- tooig/cli.py +68 -0
- tooig/constants.py +16 -0
- tooig/credentials.py +77 -0
- tooig/errors.py +7 -0
- tooig/installer.py +31 -0
- tooig/prompts.py +87 -0
- tooig/ui.py +25 -0
- tooig/workflow.py +84 -0
- tooig-0.1.3.dist-info/METADATA +282 -0
- tooig-0.1.3.dist-info/RECORD +15 -0
- tooig-0.1.3.dist-info/WHEEL +4 -0
- tooig-0.1.3.dist-info/entry_points.txt +2 -0
tooig/__init__.py
ADDED
tooig/__main__.py
ADDED
tooig/api.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any, Callable
|
|
6
|
+
from urllib.parse import urljoin, urlsplit
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from .errors import TooigError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def validate_api_url(value: str) -> str:
|
|
14
|
+
normalized = value.strip().rstrip("/")
|
|
15
|
+
parsed = urlsplit(normalized)
|
|
16
|
+
if not parsed.scheme or not parsed.hostname or parsed.username or parsed.password:
|
|
17
|
+
raise TooigError("The API URL must be an absolute URL without embedded credentials.")
|
|
18
|
+
is_local = parsed.hostname in {"localhost", "127.0.0.1", "::1"}
|
|
19
|
+
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_local):
|
|
20
|
+
raise TooigError("The API URL must use HTTPS (HTTP is allowed only for localhost).")
|
|
21
|
+
return normalized
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _safe_detail(response: httpx.Response) -> str:
|
|
25
|
+
try:
|
|
26
|
+
payload = response.json()
|
|
27
|
+
except ValueError:
|
|
28
|
+
return f"HTTP {response.status_code}"
|
|
29
|
+
detail = payload.get("detail") if isinstance(payload, dict) else None
|
|
30
|
+
if isinstance(detail, str) and detail.strip():
|
|
31
|
+
return detail.strip()[:240]
|
|
32
|
+
return f"HTTP {response.status_code}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class DeviceAuthorization:
|
|
37
|
+
authorization_id: str
|
|
38
|
+
authorization_url: str
|
|
39
|
+
interval_seconds: float
|
|
40
|
+
expires_in_seconds: float
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ApiClient:
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
base_url: str,
|
|
47
|
+
*,
|
|
48
|
+
client: httpx.Client | None = None,
|
|
49
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
50
|
+
monotonic: Callable[[], float] = time.monotonic,
|
|
51
|
+
) -> None:
|
|
52
|
+
self.base_url = validate_api_url(base_url)
|
|
53
|
+
self._client = client or httpx.Client(timeout=httpx.Timeout(15.0))
|
|
54
|
+
self._owns_client = client is None
|
|
55
|
+
self._sleep = sleep
|
|
56
|
+
self._monotonic = monotonic
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
if self._owns_client:
|
|
60
|
+
self._client.close()
|
|
61
|
+
|
|
62
|
+
def _url(self, path: str) -> str:
|
|
63
|
+
return urljoin(f"{self.base_url}/", path.lstrip("/"))
|
|
64
|
+
|
|
65
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> httpx.Response:
|
|
66
|
+
try:
|
|
67
|
+
return self._client.request(method, self._url(path), **kwargs)
|
|
68
|
+
except httpx.TimeoutException as exc:
|
|
69
|
+
raise TooigError("The Tooig service timed out. Try again shortly.") from exc
|
|
70
|
+
except httpx.HTTPError as exc:
|
|
71
|
+
raise TooigError("Could not connect securely to the Tooig service.") from exc
|
|
72
|
+
|
|
73
|
+
def signup(self, *, email: str, password: str) -> None:
|
|
74
|
+
response = self._request(
|
|
75
|
+
"POST", "auth/signup", json={"email": email, "password": password}
|
|
76
|
+
)
|
|
77
|
+
if response.status_code not in {200, 201}:
|
|
78
|
+
raise TooigError(f"Could not create the developer account: {_safe_detail(response)}")
|
|
79
|
+
|
|
80
|
+
def login(self, *, email: str, password: str) -> str:
|
|
81
|
+
response = self._request(
|
|
82
|
+
"POST", "auth/login", json={"email": email, "password": password}
|
|
83
|
+
)
|
|
84
|
+
if response.status_code != 200:
|
|
85
|
+
raise TooigError(f"Authentication failed: {_safe_detail(response)}")
|
|
86
|
+
try:
|
|
87
|
+
token = response.json().get("access_token", "")
|
|
88
|
+
except (AttributeError, ValueError) as exc:
|
|
89
|
+
raise TooigError("Authentication returned an invalid response.") from exc
|
|
90
|
+
if not isinstance(token, str) or not token:
|
|
91
|
+
raise TooigError("Authentication did not return an access token.")
|
|
92
|
+
return token
|
|
93
|
+
|
|
94
|
+
def validate_api_key(self, api_key: str) -> None:
|
|
95
|
+
if len(api_key.strip()) < 16:
|
|
96
|
+
raise TooigError("The API key is too short.")
|
|
97
|
+
response = self._request(
|
|
98
|
+
"GET",
|
|
99
|
+
"auth/session",
|
|
100
|
+
headers={"Authorization": f"Bearer {api_key.strip()}"},
|
|
101
|
+
)
|
|
102
|
+
if response.status_code != 200:
|
|
103
|
+
raise TooigError(f"The API key was rejected: {_safe_detail(response)}")
|
|
104
|
+
|
|
105
|
+
def begin_device_authorization(self, access_token: str) -> DeviceAuthorization:
|
|
106
|
+
response = self._request(
|
|
107
|
+
"POST",
|
|
108
|
+
"developer/authorizations",
|
|
109
|
+
headers={"Authorization": f"Bearer {access_token}"},
|
|
110
|
+
json={"client": "tooig-cli"},
|
|
111
|
+
)
|
|
112
|
+
if response.status_code not in {200, 201}:
|
|
113
|
+
raise TooigError(
|
|
114
|
+
f"Could not start browser authorization: {_safe_detail(response)}"
|
|
115
|
+
)
|
|
116
|
+
try:
|
|
117
|
+
payload = response.json()
|
|
118
|
+
authorization = DeviceAuthorization(
|
|
119
|
+
authorization_id=str(payload["id"]),
|
|
120
|
+
authorization_url=str(payload["authorization_url"]),
|
|
121
|
+
interval_seconds=max(1.0, min(float(payload.get("interval", 2)), 10.0)),
|
|
122
|
+
expires_in_seconds=max(
|
|
123
|
+
10.0, min(float(payload.get("expires_in", 300)), 900.0)
|
|
124
|
+
),
|
|
125
|
+
)
|
|
126
|
+
except (KeyError, TypeError, ValueError) as exc:
|
|
127
|
+
raise TooigError("Authorization returned an invalid response.") from exc
|
|
128
|
+
self._validate_authorization_url(authorization.authorization_url)
|
|
129
|
+
return authorization
|
|
130
|
+
|
|
131
|
+
def _validate_authorization_url(self, value: str) -> None:
|
|
132
|
+
candidate = urlsplit(value)
|
|
133
|
+
api = urlsplit(self.base_url)
|
|
134
|
+
if candidate.hostname != api.hostname:
|
|
135
|
+
raise TooigError("The service returned an authorization link for another host.")
|
|
136
|
+
is_local = candidate.hostname in {"localhost", "127.0.0.1", "::1"}
|
|
137
|
+
if candidate.scheme != "https" and not (candidate.scheme == "http" and is_local):
|
|
138
|
+
raise TooigError("The service returned an insecure authorization link.")
|
|
139
|
+
|
|
140
|
+
def wait_for_device_authorization(
|
|
141
|
+
self, authorization: DeviceAuthorization, access_token: str
|
|
142
|
+
) -> str:
|
|
143
|
+
deadline = self._monotonic() + authorization.expires_in_seconds
|
|
144
|
+
path = f"developer/authorizations/{authorization.authorization_id}"
|
|
145
|
+
while self._monotonic() < deadline:
|
|
146
|
+
response = self._request(
|
|
147
|
+
"GET", path, headers={"Authorization": f"Bearer {access_token}"}
|
|
148
|
+
)
|
|
149
|
+
if response.status_code != 200:
|
|
150
|
+
raise TooigError(f"Authorization check failed: {_safe_detail(response)}")
|
|
151
|
+
try:
|
|
152
|
+
payload = response.json()
|
|
153
|
+
status = str(payload.get("status", "")).lower()
|
|
154
|
+
except (AttributeError, ValueError) as exc:
|
|
155
|
+
raise TooigError("Authorization returned an invalid response.") from exc
|
|
156
|
+
if status == "approved":
|
|
157
|
+
api_key = payload.get("api_key", "")
|
|
158
|
+
if not isinstance(api_key, str) or len(api_key.strip()) < 16:
|
|
159
|
+
raise TooigError("Authorization did not return a valid API key.")
|
|
160
|
+
return api_key.strip()
|
|
161
|
+
if status in {"denied", "expired", "cancelled"}:
|
|
162
|
+
raise TooigError(f"Browser authorization was {status}.")
|
|
163
|
+
if status != "pending":
|
|
164
|
+
raise TooigError("Authorization returned an unknown status.")
|
|
165
|
+
self._sleep(authorization.interval_seconds)
|
|
166
|
+
raise TooigError("Browser authorization expired. Run `tooig developer` again.")
|
tooig/cli.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
|
|
5
|
+
from . import __version__
|
|
6
|
+
from .api import ApiClient
|
|
7
|
+
from .constants import configured_api_url
|
|
8
|
+
from .errors import CancelledError, TooigError
|
|
9
|
+
from .ui import TerminalUI
|
|
10
|
+
from .workflow import DeveloperSetup
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
app = typer.Typer(
|
|
14
|
+
add_completion=False,
|
|
15
|
+
help="Secure setup and SDK installation for Tooig developers.",
|
|
16
|
+
no_args_is_help=True,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _version_callback(value: bool) -> None:
|
|
21
|
+
if value:
|
|
22
|
+
typer.echo(__version__)
|
|
23
|
+
raise typer.Exit()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.callback()
|
|
27
|
+
def root(
|
|
28
|
+
version: bool = typer.Option(
|
|
29
|
+
False,
|
|
30
|
+
"--version",
|
|
31
|
+
callback=_version_callback,
|
|
32
|
+
is_eager=True,
|
|
33
|
+
help="Show the installed tooig version and exit.",
|
|
34
|
+
),
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Secure setup and SDK installation for Tooig developers."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@app.command()
|
|
40
|
+
def developer(
|
|
41
|
+
api_url: str = typer.Option(
|
|
42
|
+
None,
|
|
43
|
+
"--api-url",
|
|
44
|
+
help="Tooig API base URL. Defaults to TOOIG_API_URL or the production API.",
|
|
45
|
+
),
|
|
46
|
+
no_open: bool = typer.Option(
|
|
47
|
+
False, "--no-open", help="Print the authorization link without opening a browser."
|
|
48
|
+
),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Authenticate, authorize this build, and install a Tooig SDK."""
|
|
51
|
+
ui = TerminalUI()
|
|
52
|
+
client: ApiClient | None = None
|
|
53
|
+
try:
|
|
54
|
+
client = ApiClient(api_url or configured_api_url())
|
|
55
|
+
DeveloperSetup(api=client, ui=ui, open_browser=not no_open).run()
|
|
56
|
+
except CancelledError as exc:
|
|
57
|
+
ui.info(str(exc))
|
|
58
|
+
raise typer.Exit(code=130) from exc
|
|
59
|
+
except TooigError as exc:
|
|
60
|
+
ui.console.print(f"[red]error[/red] {exc}")
|
|
61
|
+
raise typer.Exit(code=1) from exc
|
|
62
|
+
finally:
|
|
63
|
+
if client is not None:
|
|
64
|
+
client.close()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main() -> None:
|
|
68
|
+
app()
|
tooig/constants.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
DEVELOPER_DOMAIN = "developer.tooig.com"
|
|
7
|
+
DEFAULT_API_URL = "https://developer.tooig.com/api"
|
|
8
|
+
GET_STARTED_URL = "https://developer.tooig.com/get_started"
|
|
9
|
+
FRAMEWORKS = {
|
|
10
|
+
"nineth": "nineth",
|
|
11
|
+
"bridge": "nineth-bridge",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def configured_api_url() -> str:
|
|
16
|
+
return os.getenv("TOOIG_API_URL", DEFAULT_API_URL).strip().rstrip("/")
|
tooig/credentials.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from platformdirs import user_config_path
|
|
10
|
+
|
|
11
|
+
from .errors import TooigError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def default_credentials_path() -> Path:
|
|
15
|
+
return user_config_path("tooig", appauthor=False, roaming=True) / "credentials.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CredentialStore:
|
|
19
|
+
"""Stores only the long-lived API key, never passwords or login tokens."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, path: Path | None = None) -> None:
|
|
22
|
+
self.path = path or default_credentials_path()
|
|
23
|
+
|
|
24
|
+
def save(self, *, email: str, api_key: str, api_base_url: str) -> Path:
|
|
25
|
+
if not api_key.strip():
|
|
26
|
+
raise TooigError("The API key is empty and was not stored.")
|
|
27
|
+
if self.path.is_symlink():
|
|
28
|
+
raise TooigError(f"Refusing to replace symlinked credential file: {self.path}")
|
|
29
|
+
|
|
30
|
+
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
31
|
+
try:
|
|
32
|
+
os.chmod(self.path.parent, 0o700)
|
|
33
|
+
except OSError:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
payload: dict[str, Any] = {
|
|
37
|
+
"version": 1,
|
|
38
|
+
"developer_email": email,
|
|
39
|
+
"api_base_url": api_base_url,
|
|
40
|
+
"api_key": api_key.strip(),
|
|
41
|
+
}
|
|
42
|
+
temporary_path: Path | None = None
|
|
43
|
+
try:
|
|
44
|
+
with tempfile.NamedTemporaryFile(
|
|
45
|
+
mode="w",
|
|
46
|
+
encoding="utf-8",
|
|
47
|
+
dir=self.path.parent,
|
|
48
|
+
prefix=".credentials-",
|
|
49
|
+
suffix=".tmp",
|
|
50
|
+
delete=False,
|
|
51
|
+
) as handle:
|
|
52
|
+
temporary_path = Path(handle.name)
|
|
53
|
+
os.chmod(temporary_path, 0o600)
|
|
54
|
+
json.dump(payload, handle, indent=2)
|
|
55
|
+
handle.write("\n")
|
|
56
|
+
handle.flush()
|
|
57
|
+
os.fsync(handle.fileno())
|
|
58
|
+
os.replace(temporary_path, self.path)
|
|
59
|
+
os.chmod(self.path, 0o600)
|
|
60
|
+
except OSError as exc:
|
|
61
|
+
if temporary_path is not None:
|
|
62
|
+
temporary_path.unlink(missing_ok=True)
|
|
63
|
+
raise TooigError(f"Could not store credentials at {self.path}: {exc}") from exc
|
|
64
|
+
return self.path
|
|
65
|
+
|
|
66
|
+
def load(self) -> dict[str, Any] | None:
|
|
67
|
+
if not self.path.exists():
|
|
68
|
+
return None
|
|
69
|
+
if self.path.is_symlink():
|
|
70
|
+
raise TooigError(f"Refusing to read symlinked credential file: {self.path}")
|
|
71
|
+
try:
|
|
72
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
73
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
74
|
+
raise TooigError(f"Could not read credentials at {self.path}: {exc}") from exc
|
|
75
|
+
if not isinstance(data, dict) or data.get("version") != 1:
|
|
76
|
+
raise TooigError(f"Unsupported credential file format at {self.path}")
|
|
77
|
+
return data
|
tooig/errors.py
ADDED
tooig/installer.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
|
|
7
|
+
from .constants import FRAMEWORKS
|
|
8
|
+
from .errors import TooigError
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PythonInstaller:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run,
|
|
15
|
+
) -> None:
|
|
16
|
+
self._runner = runner
|
|
17
|
+
|
|
18
|
+
def command_for(self, framework: str) -> list[str]:
|
|
19
|
+
try:
|
|
20
|
+
package = FRAMEWORKS[framework]
|
|
21
|
+
except KeyError as exc:
|
|
22
|
+
raise TooigError(f"Unsupported framework: {framework}") from exc
|
|
23
|
+
return [sys.executable, "-m", "pip", "install", "--upgrade", package]
|
|
24
|
+
|
|
25
|
+
def install(self, framework: str) -> Sequence[str]:
|
|
26
|
+
command = self.command_for(framework)
|
|
27
|
+
try:
|
|
28
|
+
self._runner(command, check=True)
|
|
29
|
+
except (OSError, subprocess.CalledProcessError) as exc:
|
|
30
|
+
raise TooigError(f"Could not install {FRAMEWORKS[framework]}.") from exc
|
|
31
|
+
return command
|
tooig/prompts.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
import questionary
|
|
6
|
+
from prompt_toolkit.layout.processors import PasswordProcessor
|
|
7
|
+
from questionary import Choice
|
|
8
|
+
|
|
9
|
+
from .errors import CancelledError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
LOCAL_PART = re.compile(r"^[a-z0-9](?:[a-z0-9._-]{0,62}[a-z0-9])?$", re.IGNORECASE)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_local_part(value: str) -> bool | str:
|
|
16
|
+
candidate = value.strip()
|
|
17
|
+
if not LOCAL_PART.fullmatch(candidate):
|
|
18
|
+
return "Use 1-64 letters, numbers, dots, underscores, or hyphens."
|
|
19
|
+
return True
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InteractivePrompter:
|
|
23
|
+
@staticmethod
|
|
24
|
+
def _required(value: object) -> object:
|
|
25
|
+
if value is None:
|
|
26
|
+
raise CancelledError("Setup cancelled.")
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
def email_local_part(self) -> str:
|
|
30
|
+
answer = questionary.text(
|
|
31
|
+
"email: [name]@developer.tooig.com",
|
|
32
|
+
validate=validate_local_part,
|
|
33
|
+
).ask()
|
|
34
|
+
return str(self._required(answer)).strip().lower()
|
|
35
|
+
|
|
36
|
+
def account_action(self) -> str:
|
|
37
|
+
answer = questionary.select(
|
|
38
|
+
"developer account",
|
|
39
|
+
choices=[
|
|
40
|
+
Choice("Sign in", "signin"),
|
|
41
|
+
Choice("Create developer account", "signup"),
|
|
42
|
+
],
|
|
43
|
+
).ask()
|
|
44
|
+
return str(self._required(answer))
|
|
45
|
+
|
|
46
|
+
def password(self, *, creating: bool) -> str:
|
|
47
|
+
minimum = 12 if creating else 1
|
|
48
|
+
|
|
49
|
+
def validate(value: str) -> bool | str:
|
|
50
|
+
if len(value) < minimum:
|
|
51
|
+
return f"Use at least {minimum} characters."
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
answer = questionary.password(
|
|
55
|
+
"password",
|
|
56
|
+
validate=validate,
|
|
57
|
+
input_processors=[PasswordProcessor(char="")],
|
|
58
|
+
).ask()
|
|
59
|
+
return str(self._required(answer))
|
|
60
|
+
|
|
61
|
+
def authorization_method(self) -> str:
|
|
62
|
+
answer = questionary.select(
|
|
63
|
+
"authorize this build",
|
|
64
|
+
choices=[
|
|
65
|
+
Choice("Open a secure authorization link", "browser"),
|
|
66
|
+
Choice("Use an existing API key", "api_key"),
|
|
67
|
+
],
|
|
68
|
+
).ask()
|
|
69
|
+
return str(self._required(answer))
|
|
70
|
+
|
|
71
|
+
def api_key(self) -> str:
|
|
72
|
+
answer = questionary.password(
|
|
73
|
+
"API key (input hidden)",
|
|
74
|
+
validate=lambda value: bool(value.strip()) or "Required.",
|
|
75
|
+
input_processors=[PasswordProcessor(char="")],
|
|
76
|
+
).ask()
|
|
77
|
+
return str(self._required(answer)).strip()
|
|
78
|
+
|
|
79
|
+
def framework(self) -> str:
|
|
80
|
+
answer = questionary.select(
|
|
81
|
+
"framework",
|
|
82
|
+
choices=[
|
|
83
|
+
Choice("nineth", "nineth"),
|
|
84
|
+
Choice("bridge (nineth-bridge)", "bridge"),
|
|
85
|
+
],
|
|
86
|
+
).ask()
|
|
87
|
+
return str(self._required(answer))
|
tooig/ui.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
from typing import Iterator
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TerminalUI:
|
|
10
|
+
def __init__(self, console: Console | None = None) -> None:
|
|
11
|
+
self.console = console or Console()
|
|
12
|
+
|
|
13
|
+
def heading(self) -> None:
|
|
14
|
+
self.console.print("\n[bold]tooig developer[/bold]\n")
|
|
15
|
+
|
|
16
|
+
def info(self, message: str) -> None:
|
|
17
|
+
self.console.print(message)
|
|
18
|
+
|
|
19
|
+
def success(self, message: str) -> None:
|
|
20
|
+
self.console.print(f"[green]ok[/green] {message}")
|
|
21
|
+
|
|
22
|
+
@contextmanager
|
|
23
|
+
def progress(self, message: str) -> Iterator[None]:
|
|
24
|
+
with self.console.status(message, spinner="dots"):
|
|
25
|
+
yield
|
tooig/workflow.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import webbrowser
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from contextlib import nullcontext
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .api import ApiClient
|
|
9
|
+
from .constants import DEVELOPER_DOMAIN, FRAMEWORKS, GET_STARTED_URL
|
|
10
|
+
from .credentials import CredentialStore
|
|
11
|
+
from .installer import PythonInstaller
|
|
12
|
+
from .prompts import InteractivePrompter
|
|
13
|
+
from .ui import TerminalUI
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DeveloperSetup:
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
*,
|
|
20
|
+
api: ApiClient,
|
|
21
|
+
prompts: InteractivePrompter | Any | None = None,
|
|
22
|
+
credentials: CredentialStore | Any | None = None,
|
|
23
|
+
installer: PythonInstaller | Any | None = None,
|
|
24
|
+
ui: TerminalUI | Any | None = None,
|
|
25
|
+
opener: Callable[[str], bool] = lambda url: webbrowser.open(url, new=2),
|
|
26
|
+
open_browser: bool = True,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.api = api
|
|
29
|
+
self.prompts = prompts or InteractivePrompter()
|
|
30
|
+
self.credentials = credentials or CredentialStore()
|
|
31
|
+
self.installer = installer or PythonInstaller()
|
|
32
|
+
self.ui = ui or TerminalUI()
|
|
33
|
+
self.opener = opener
|
|
34
|
+
self.open_browser = open_browser
|
|
35
|
+
|
|
36
|
+
def _progress(self, message: str):
|
|
37
|
+
progress = getattr(self.ui, "progress", None)
|
|
38
|
+
return progress(message) if progress else nullcontext()
|
|
39
|
+
|
|
40
|
+
def run(self) -> None:
|
|
41
|
+
self.ui.heading()
|
|
42
|
+
local_part = self.prompts.email_local_part()
|
|
43
|
+
email = f"{local_part}@{DEVELOPER_DOMAIN}"
|
|
44
|
+
action = self.prompts.account_action()
|
|
45
|
+
password = self.prompts.password(creating=action == "signup")
|
|
46
|
+
|
|
47
|
+
if action == "signup":
|
|
48
|
+
with self._progress("Creating developer account..."):
|
|
49
|
+
self.api.signup(email=email, password=password)
|
|
50
|
+
self.ui.success("Developer account created.")
|
|
51
|
+
|
|
52
|
+
with self._progress("Authenticating..."):
|
|
53
|
+
access_token = self.api.login(email=email, password=password)
|
|
54
|
+
self.ui.success(f"Authenticated as {email}.")
|
|
55
|
+
|
|
56
|
+
method = self.prompts.authorization_method()
|
|
57
|
+
if method == "api_key":
|
|
58
|
+
api_key = self.prompts.api_key()
|
|
59
|
+
with self._progress("Validating API key..."):
|
|
60
|
+
self.api.validate_api_key(api_key)
|
|
61
|
+
else:
|
|
62
|
+
with self._progress("Creating a signed authorization request..."):
|
|
63
|
+
authorization = self.api.begin_device_authorization(access_token)
|
|
64
|
+
self.ui.info(f"Authorize this build:\n{authorization.authorization_url}")
|
|
65
|
+
if self.open_browser:
|
|
66
|
+
self.opener(authorization.authorization_url)
|
|
67
|
+
with self._progress("Waiting for authorization..."):
|
|
68
|
+
api_key = self.api.wait_for_device_authorization(
|
|
69
|
+
authorization, access_token
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
credential_path = self.credentials.save(
|
|
73
|
+
email=email, api_key=api_key, api_base_url=self.api.base_url
|
|
74
|
+
)
|
|
75
|
+
self.ui.success(f"Authorization stored at {credential_path}.")
|
|
76
|
+
|
|
77
|
+
framework = self.prompts.framework()
|
|
78
|
+
with self._progress(f"Installing {FRAMEWORKS[framework]}..."):
|
|
79
|
+
self.installer.install(framework)
|
|
80
|
+
self.ui.success(f"Installed {FRAMEWORKS[framework]}.")
|
|
81
|
+
self.ui.info(
|
|
82
|
+
f"\nSetup complete. Start building with {FRAMEWORKS[framework]}.\n"
|
|
83
|
+
f"Documentation: {GET_STARTED_URL}"
|
|
84
|
+
)
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tooig
|
|
3
|
+
Version: 0.1.3
|
|
4
|
+
Summary: Interactive framework for tooig developer program
|
|
5
|
+
Project-URL: Documentation, https://developer.tooig.com/get_started
|
|
6
|
+
Project-URL: Issues, https://github.com/tooig/developer/issues
|
|
7
|
+
Project-URL: Repository, https://github.com/tooig/developer
|
|
8
|
+
Author-email: "Tooig, Inc" <tooighq@gmail.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: cli,developer,nineth,tooig
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: httpx<1,>=0.27
|
|
18
|
+
Requires-Dist: platformdirs<5,>=4.2
|
|
19
|
+
Requires-Dist: questionary<3,>=2.1
|
|
20
|
+
Requires-Dist: rich<15,>=13.9
|
|
21
|
+
Requires-Dist: typer<1,>=0.15
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# tooig CLI
|
|
25
|
+
|
|
26
|
+
`tooig` is the secure setup CLI for Tooig developers. It is published with the same name and version to PyPI and npm. Both distributions implement the same authentication, build authorization, credential-storage, and SDK-installation flow.
|
|
27
|
+
|
|
28
|
+
## Table of contents
|
|
29
|
+
|
|
30
|
+
1. [Requirements](#requirements)
|
|
31
|
+
2. [Installation](#installation)
|
|
32
|
+
3. [Developer setup walkthrough](#developer-setup-walkthrough)
|
|
33
|
+
4. [Authentication](#authentication)
|
|
34
|
+
5. [Build authorization](#build-authorization)
|
|
35
|
+
6. [Framework installation](#framework-installation)
|
|
36
|
+
7. [Credential storage](#credential-storage)
|
|
37
|
+
8. [Configuration](#configuration)
|
|
38
|
+
9. [Service contract](#service-contract)
|
|
39
|
+
10. [Security model](#security-model)
|
|
40
|
+
11. [Troubleshooting](#troubleshooting)
|
|
41
|
+
12. [Release process](#release-process)
|
|
42
|
+
|
|
43
|
+
## Requirements
|
|
44
|
+
|
|
45
|
+
Use either of these runtimes:
|
|
46
|
+
|
|
47
|
+
- Python 3.10 or newer and `pip`.
|
|
48
|
+
- Node.js 20 or newer and npm or pnpm.
|
|
49
|
+
|
|
50
|
+
The selected framework must exist in the same package registry as the installed CLI. The Python CLI installs from PyPI; the Node.js CLI installs from npm.
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
### Python and pip
|
|
55
|
+
|
|
56
|
+
```console
|
|
57
|
+
python -m pip install --upgrade tooig
|
|
58
|
+
tooig developer
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### npm
|
|
62
|
+
|
|
63
|
+
Install globally:
|
|
64
|
+
|
|
65
|
+
```console
|
|
66
|
+
npm install --global tooig
|
|
67
|
+
tooig developer
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Or run without a permanent global installation:
|
|
71
|
+
|
|
72
|
+
```console
|
|
73
|
+
npx tooig developer
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### pnpm
|
|
77
|
+
|
|
78
|
+
```console
|
|
79
|
+
pnpm add --global tooig
|
|
80
|
+
tooig developer
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Or:
|
|
84
|
+
|
|
85
|
+
```console
|
|
86
|
+
pnpm dlx tooig developer
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
`npx install --g tooig` is not a valid npm installation command. Use `npm install --global tooig` or `npx tooig developer` instead.
|
|
90
|
+
|
|
91
|
+
## Developer setup walkthrough
|
|
92
|
+
|
|
93
|
+
Run:
|
|
94
|
+
|
|
95
|
+
```console
|
|
96
|
+
tooig developer
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
The CLI performs these steps:
|
|
100
|
+
|
|
101
|
+
1. Prompts for the name portion of the developer email. Enter `ada` for `ada@developer.tooig.com`.
|
|
102
|
+
2. Offers **Sign in** and **Create developer account**.
|
|
103
|
+
3. Reads the password without echoing characters to the terminal.
|
|
104
|
+
4. Authenticates with the Tooig API.
|
|
105
|
+
5. Offers browser authorization or an existing API key.
|
|
106
|
+
6. Validates and stores the resulting API key.
|
|
107
|
+
7. Offers `nineth` and `bridge (nineth-bridge)`.
|
|
108
|
+
8. Installs the selected SDK using the CLI's package ecosystem.
|
|
109
|
+
9. Prints the getting-started URL: <https://developer.tooig.com/get_started>.
|
|
110
|
+
|
|
111
|
+
Use `--no-open` on remote machines to print the browser authorization URL without launching a browser:
|
|
112
|
+
|
|
113
|
+
```console
|
|
114
|
+
tooig developer --no-open
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Authentication
|
|
118
|
+
|
|
119
|
+
The prompt accepts only the local portion of a Tooig developer address:
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
email: [name]@developer.tooig.com
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The suffix is fixed by the CLI. Local parts are normalized to lowercase and may contain letters, numbers, dots, underscores, and hyphens.
|
|
126
|
+
|
|
127
|
+
For a new account, select **Create developer account** and use a password of at least 12 characters. The CLI submits the signup and then authenticates. If the account policy requires email verification, verify the address and run `tooig developer` again.
|
|
128
|
+
|
|
129
|
+
Passwords and short-lived login tokens are held in memory only. They are never written to the credential file or displayed in progress/error output.
|
|
130
|
+
|
|
131
|
+
## Build authorization
|
|
132
|
+
|
|
133
|
+
### Secure browser link
|
|
134
|
+
|
|
135
|
+
The CLI asks the Tooig API to create a signed, short-lived authorization request. The service returns the link; the CLI does not generate or sign authorization links locally.
|
|
136
|
+
|
|
137
|
+
```text
|
|
138
|
+
https://developer.tooig.com/{server-generated-id}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The link is opened in the default browser unless `--no-open` is set. The CLI polls the service until the request is approved, denied, cancelled, or expired. On approval, the service returns the build API key.
|
|
142
|
+
|
|
143
|
+
The CLI rejects an authorization URL that changes the configured API host or downgrades HTTPS. Localhost development may use HTTP.
|
|
144
|
+
|
|
145
|
+
### Existing API key
|
|
146
|
+
|
|
147
|
+
Select **Use an existing API key**. Input is hidden. Before storing the key, the CLI validates it against the authenticated session endpoint. Invalid or truncated keys are not saved.
|
|
148
|
+
|
|
149
|
+
## Framework installation
|
|
150
|
+
|
|
151
|
+
The framework choices and commands are:
|
|
152
|
+
|
|
153
|
+
| CLI distribution | Selection | Command |
|
|
154
|
+
| --- | --- | --- |
|
|
155
|
+
| PyPI | `nineth` | `python -m pip install --upgrade nineth` |
|
|
156
|
+
| PyPI | `bridge` | `python -m pip install --upgrade nineth-bridge` |
|
|
157
|
+
| npm | `nineth` | `npm install nineth` |
|
|
158
|
+
| npm | `bridge` | `npm install nineth-bridge` |
|
|
159
|
+
| pnpm | `nineth` | `pnpm add nineth` |
|
|
160
|
+
| pnpm | `bridge` | `pnpm add nineth-bridge` |
|
|
161
|
+
|
|
162
|
+
The Node.js CLI detects pnpm through `npm_config_user_agent`; all other npm/npx invocations use npm. Installer commands use argument arrays and never interpolate user input into a shell command.
|
|
163
|
+
|
|
164
|
+
## Credential storage
|
|
165
|
+
|
|
166
|
+
Python and Node.js use the same versioned JSON format so either CLI can reuse the authorization:
|
|
167
|
+
|
|
168
|
+
| Platform | Default location |
|
|
169
|
+
| --- | --- |
|
|
170
|
+
| Windows | `%APPDATA%\\tooig\\credentials.json` |
|
|
171
|
+
| macOS | `~/Library/Application Support/tooig/credentials.json` |
|
|
172
|
+
| Linux | `${XDG_CONFIG_HOME:-~/.config}/tooig/credentials.json` |
|
|
173
|
+
|
|
174
|
+
The file contains the developer email, API base URL, and API key. It never contains the password or login token.
|
|
175
|
+
|
|
176
|
+
Writes use a temporary file followed by an atomic rename. On POSIX systems the directory is mode `0700` and the file is mode `0600`. Both clients refuse to read or replace a symlinked credential file. Do not commit this file or copy it into a project directory.
|
|
177
|
+
|
|
178
|
+
## Configuration
|
|
179
|
+
|
|
180
|
+
The production API is used by default:
|
|
181
|
+
|
|
182
|
+
```text
|
|
183
|
+
https://developer.tooig.com/api
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Override it for development with `TOOIG_API_URL` or `--api-url`:
|
|
187
|
+
|
|
188
|
+
```console
|
|
189
|
+
tooig developer --api-url http://localhost:8000/api
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Non-local API URLs must use HTTPS and cannot contain embedded usernames or passwords.
|
|
193
|
+
|
|
194
|
+
## Service contract
|
|
195
|
+
|
|
196
|
+
The CLI expects these server endpoints relative to the API base URL:
|
|
197
|
+
|
|
198
|
+
| Method | Endpoint | Purpose |
|
|
199
|
+
| --- | --- | --- |
|
|
200
|
+
| `POST` | `/auth/signup` | Create a developer account. |
|
|
201
|
+
| `POST` | `/auth/login` | Return a short-lived `access_token`. |
|
|
202
|
+
| `GET` | `/auth/session` | Validate an existing bearer API key. |
|
|
203
|
+
| `POST` | `/developer/authorizations` | Create a signed browser authorization request. |
|
|
204
|
+
| `GET` | `/developer/authorizations/{id}` | Poll `pending`, `approved`, `denied`, `cancelled`, or `expired`. |
|
|
205
|
+
|
|
206
|
+
The authorization creation response is:
|
|
207
|
+
|
|
208
|
+
```json
|
|
209
|
+
{
|
|
210
|
+
"id": "server-generated-id",
|
|
211
|
+
"authorization_url": "https://developer.tooig.com/server-generated-id",
|
|
212
|
+
"interval": 2,
|
|
213
|
+
"expires_in": 300
|
|
214
|
+
}
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
An approved poll response is:
|
|
218
|
+
|
|
219
|
+
```json
|
|
220
|
+
{
|
|
221
|
+
"status": "approved",
|
|
222
|
+
"api_key": "server-issued-secret"
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
The repository currently supplies `/auth/signup`, `/auth/login`, and `/auth/session`. Browser authorization requires the `/developer/authorizations` service to be deployed before that option can complete. Existing API-key authorization is usable against the current API.
|
|
227
|
+
|
|
228
|
+
## Security model
|
|
229
|
+
|
|
230
|
+
- HTTPS is mandatory except for loopback development.
|
|
231
|
+
- Password and API-key prompts do not echo input.
|
|
232
|
+
- The server signs and issues authorization requests and keys.
|
|
233
|
+
- Authorization URLs are origin-checked before opening.
|
|
234
|
+
- Login tokens are not persisted.
|
|
235
|
+
- Existing keys are validated before persistence.
|
|
236
|
+
- Secrets are not passed as command-line arguments to package managers.
|
|
237
|
+
- Network failures are converted to bounded errors without response dumps.
|
|
238
|
+
- Framework names are selected from a fixed allowlist.
|
|
239
|
+
- Credential writes are atomic and symlink-resistant.
|
|
240
|
+
|
|
241
|
+
Local credential-file protection is not a substitute for full-disk encryption or a locked operating-system account. Rotate the API key immediately if the workstation or file is compromised.
|
|
242
|
+
|
|
243
|
+
## Troubleshooting
|
|
244
|
+
|
|
245
|
+
### Account creation succeeds but login fails
|
|
246
|
+
|
|
247
|
+
The account may require email verification. Complete verification and rerun `tooig developer` using **Sign in**.
|
|
248
|
+
|
|
249
|
+
### Browser authorization is unavailable
|
|
250
|
+
|
|
251
|
+
Use **Use an existing API key** until the browser-authorization service is deployed. For remote or headless environments, use `--no-open` and open the printed link on another trusted device.
|
|
252
|
+
|
|
253
|
+
### Package installation fails
|
|
254
|
+
|
|
255
|
+
Check registry access and install the selected package directly using the command in [Framework installation](#framework-installation). The authorization remains stored, so rerunning setup does not expose the password or key.
|
|
256
|
+
|
|
257
|
+
### PowerShell blocks `npm.ps1`
|
|
258
|
+
|
|
259
|
+
Use `npm.cmd` in a restricted PowerShell session or adjust the local execution policy according to your organization's policy. The published `tooig` executable itself does not require shell interpolation.
|
|
260
|
+
|
|
261
|
+
## Release process
|
|
262
|
+
|
|
263
|
+
Repository versioning is controlled by the first recognized dispatch token in a commit message:
|
|
264
|
+
|
|
265
|
+
| Dispatch | Version bump | Repository | PyPI/npm `tooig` |
|
|
266
|
+
| --- | --- | --- | --- |
|
|
267
|
+
| `build:` | Major | Bump and GitHub release | No publish |
|
|
268
|
+
| `feat:` | Minor | Bump and GitHub release | No publish |
|
|
269
|
+
| `chore:` | Patch | Bump and GitHub release | No publish |
|
|
270
|
+
| `build[tooig]:` | Major | Bump and GitHub release | Bump, build, validate, publish, release |
|
|
271
|
+
| `feat[tooig]:` | Minor | Bump and GitHub release | Bump, build, validate, publish, release |
|
|
272
|
+
| `chore[tooig]:` | Patch | Bump and GitHub release | Bump, build, validate, publish, release |
|
|
273
|
+
|
|
274
|
+
The PyPI and npm versions must match before a scoped release. The script updates both manifests and the npm lockfile together. Repository tags use `vX.Y.Z`; package tags use `tooig-vX.Y.Z`.
|
|
275
|
+
|
|
276
|
+
GitHub Actions requires:
|
|
277
|
+
|
|
278
|
+
- An npm automation token in the `NPM_TOKEN` repository secret.
|
|
279
|
+
- PyPI Trusted Publishing configured for this repository and workflow.
|
|
280
|
+
- `contents: write` and `id-token: write`, already declared by the workflow.
|
|
281
|
+
|
|
282
|
+
The repository release reads `.github/release_notes/repository.md`. The package release reads `.github/release_notes/tooig.md`.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
tooig/__init__.py,sha256=l4_tVHkx-y5Km7RN5xMoe7irbWVSJ1t-5g6eR9v4zb0,228
|
|
2
|
+
tooig/__main__.py,sha256=14FfnaF7zY550dRxcOV5XinMWDf5fRBJZkgTBoqeVj8,63
|
|
3
|
+
tooig/api.py,sha256=KShaF1tgfblh3DfTNFRRikCTY-L63_ubJCE8sVOT2Gw,7088
|
|
4
|
+
tooig/cli.py,sha256=KWzxb5Jtmayy5kb6z7MlU15MDjgoW1sMHty2WJjWzmM,1756
|
|
5
|
+
tooig/constants.py,sha256=4ttyFGhL9czemLZlIG4_aT8aXtn6-Pdv1we09rVXTos,383
|
|
6
|
+
tooig/credentials.py,sha256=yIEbnAgGrcDUGF9AZ24C7yS_ecndY_qWGzVerGEfEy8,2769
|
|
7
|
+
tooig/errors.py,sha256=RweSvvi-q9DOHNdiKDKylSnJMeDElmRGb7aFTT3Mxfo,180
|
|
8
|
+
tooig/installer.py,sha256=3Eqoh72w7bfvDbeLd7lSTf9_7XIZ8iKNdnzNbr93-KQ,997
|
|
9
|
+
tooig/prompts.py,sha256=rfdeeYagHBb_Jk2QtudU0MKFB6hhevsir3ZnQzcqB7U,2664
|
|
10
|
+
tooig/ui.py,sha256=1q4Sw0fFlxeuX2iHepdc2NOClaYhcy3nPVRTLhNkEnE,697
|
|
11
|
+
tooig/workflow.py,sha256=jxPgDsU8zMhlvOf3rIozEoz5ywAUMz5RjYSMLvxQpAM,3349
|
|
12
|
+
tooig-0.1.3.dist-info/METADATA,sha256=58vasoZeP9XxJNZK5tsC_hTlrvFekSkt9Tx32dz9CAQ,10485
|
|
13
|
+
tooig-0.1.3.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
14
|
+
tooig-0.1.3.dist-info/entry_points.txt,sha256=LFnVRxfJ9kiuHYMQ-lh0JtuRV8bQKB4OrszICYl_hBk,41
|
|
15
|
+
tooig-0.1.3.dist-info/RECORD,,
|