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 ADDED
@@ -0,0 +1,8 @@
1
+ """Tooig developer CLI."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("tooig")
7
+ except PackageNotFoundError: # Source-tree imports during development.
8
+ __version__ = "0.0.0"
tooig/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ main()
6
+
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
@@ -0,0 +1,7 @@
1
+ class TooigError(Exception):
2
+ """A user-facing CLI error that is safe to print."""
3
+
4
+
5
+ class CancelledError(TooigError):
6
+ """The developer cancelled an interactive prompt."""
7
+
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ tooig = tooig.cli:main