git-glimpse-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.
@@ -0,0 +1,83 @@
1
+ Metadata-Version: 2.4
2
+ Name: git-glimpse-cli
3
+ Version: 0.1.0
4
+ Summary: CLI tool to manage and update shared GitHub repos with GitGlimpse.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: httpx>=0.28.1
9
+ Requires-Dist: rich>=15.0.0
10
+ Requires-Dist: typer>=0.27.2
11
+ Dynamic: license-file
12
+
13
+ # GitGlimpse CLI
14
+
15
+ ![GitHub release](https://img.shields.io/github/v/release/sutaC/git-glimpse-cli)
16
+ ![GitHub last commit](https://img.shields.io/github/last-commit/sutaC/git-glimpse-cli)
17
+ ![License](https://img.shields.io/github/license/sutaC/git-glimpse-cli)
18
+
19
+ A command-line interface to manage, synchronize, and monitor shared GitHub repositories via the GitGlimpse platform.
20
+
21
+ ---
22
+
23
+ **GitGlimpse website:** [gitglimpse.sutac.pl](https://gitglimpse.sutac.pl/)
24
+
25
+ **GitGlimpse on GitHub:** [github.com/sutaC/git-glimpse-cli](https://github.com/sutaC/git-glimpse-cli/)
26
+
27
+ **Author:** [sutaC](https://github.com/sutaC)
28
+
29
+ ## Installation
30
+
31
+ Install the latest version globally via pip:
32
+
33
+ ```bash
34
+ pip install git-glimpse-cli
35
+ ```
36
+
37
+ ## Quickstart
38
+
39
+ 1. Log in with your GitGlimpse CLI token. It will be saved to your global user configuration.
40
+ > You will be able to create one on your GitGlimpse user panel at https://gitglimpse.sutac.pl/user#hCliTokens
41
+ ```bash
42
+ glimpse login <YOUR_API_TOKEN>
43
+ ```
44
+ 2. Initialize a Repository.
45
+ > Navigate to your local Git repository, ensure it is uplaoded to GitHub and link it to GitGlimpse. This creates a .shared-repo.json file to track the project.
46
+ ```bash
47
+ cd my-project
48
+ glimpse init <new/link>
49
+ ```
50
+ 3. Check status.
51
+ ```bash
52
+ glimpse status
53
+ ```
54
+ 4. Schedule the build to update GitGlimpse display.
55
+ ```bash
56
+ glimpse build
57
+ ```
58
+
59
+ ## Configuration
60
+
61
+ GitGlimpse CLI uses holds your configuration at `~/.config/glimpsecli/config.json`, and local repository settings are stored at `./.shared-repo.json`.
62
+
63
+ ### Advanced Settings
64
+
65
+ | Command | Description |
66
+ | ------------------------ | --------------------------------------------------------------------------- |
67
+ | `glimpse config debug` | Enables verbose debug mode, printing stack traces. |
68
+ | `glimpse config set-url` | Overrides the default production server with a custom backend or localhost. |
69
+
70
+ ## Development
71
+
72
+ 1. Clone the repository and install it in editable mode:
73
+ ```bash
74
+ git clone https://github.com/sutaC/git-glimpse-cli.git
75
+ cd git-glimpse-cli
76
+ python3 -m venv .venv
77
+ source .venv/bin/activate
78
+ pip install -e .
79
+ ```
80
+ 2. Point the CLI to your local development server:
81
+ ```bash
82
+ glimpse config set-url http://localhost:5000
83
+ ```
@@ -0,0 +1,12 @@
1
+ git_glimpse_cli-0.1.0.dist-info/licenses/LICENSE,sha256=SA_CO5U6vlMWqdujOQQk937USavr3DPXj0_bPykzIFA,1082
2
+ glimpsecli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ glimpsecli/api.py,sha256=bqlaNn8dLhCqwVcZA7HAOjInbPoWSwidgC8tqjOWY0U,2808
4
+ glimpsecli/config.py,sha256=bJcCfwr8pIxJq93lXdTIV7-i_DrTyzGHsW6V6mA2mdQ,3163
5
+ glimpsecli/helpers.py,sha256=yoX6luwJm8pB5SNJ43dNGB1iPTvrzbX-xjKZMI6JXUE,3820
6
+ glimpsecli/main.py,sha256=RrEAUG6jkG6mDpXRs76ZPGJZaIzGiV8rBDLXKHBeEq4,11030
7
+ glimpsecli/utils.py,sha256=SB7hPXeODxzGjRcBDxv_HmyntXZvwlohSal5IOLbpnQ,3784
8
+ git_glimpse_cli-0.1.0.dist-info/METADATA,sha256=hvAa_TCgpG722XrfX__6uRYUdUTWr7rH6U0aBWbMGqI,2722
9
+ git_glimpse_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
10
+ git_glimpse_cli-0.1.0.dist-info/entry_points.txt,sha256=Je6nkOo21o_sdUM62LH8Ga1xyIkz2P3_Y7WVBE_L3SI,48
11
+ git_glimpse_cli-0.1.0.dist-info/top_level.txt,sha256=XFyNCeBJ8s5QzHm848zADPh0fjK6EOgV9o2wvJGMhV8,11
12
+ git_glimpse_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ glimpse = glimpsecli.main:cli
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Antoni "sutaC" Siekański
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ glimpsecli
glimpsecli/__init__.py ADDED
File without changes
glimpsecli/api.py ADDED
@@ -0,0 +1,71 @@
1
+ from rich.progress import Progress, SpinnerColumn, TextColumn
2
+ from glimpsecli import config, helpers
3
+ from contextlib import nullcontext
4
+ from json import JSONDecodeError
5
+ from rich import print
6
+ import typer
7
+ import httpx
8
+
9
+ def request_api(
10
+ endpoint: str,
11
+ method: str = "GET",
12
+ token: str | None = None,
13
+ handle_codes: list[int] = [200],
14
+ payload: dict | None = None,
15
+ quiet=False
16
+ ) -> httpx.Response:
17
+ """Requests GitGlimpse api.
18
+
19
+ Args:
20
+ endpoint: Path for api call (should not include `{API_BASE}/cli`).
21
+ method: Http metod to use.
22
+ token: GitGlimpse api token (if not provided will prompt user).
23
+ handle_codes: Response codes on which function will return response.
24
+ payload: Json payload.
25
+ quiet: Flag for disabling default progess spinner.
26
+
27
+ Returns:
28
+ Response from api.
29
+ """
30
+ if not token:
31
+ token = helpers.get_token()
32
+ url = f"{config.get_api_url()}{endpoint}"
33
+ with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), transient=True) if not quiet else nullcontext() as progress:
34
+ if progress:
35
+ progress.add_task(description="[bold green]Connecting to server...[/bold green]", total=None)
36
+ try:
37
+ response = httpx.request(
38
+ method,
39
+ url,
40
+ headers={"Authorization": f"Bearer {token}"},
41
+ timeout=10.0,
42
+ json=payload
43
+ )
44
+ except httpx.RequestError:
45
+ print("[bold red]Connection error: Could not reach the server.")
46
+ raise typer.Exit(1)
47
+ if response.status_code in handle_codes:
48
+ return response
49
+ elif response.status_code == 401:
50
+ print("[bold red]Authentication failed: Invalid or revoked token.[/bold red]")
51
+ try:
52
+ error = response.json().get("error")
53
+ if error: print(f"[dim]Error reason: {error}[/dim]")
54
+ except JSONDecodeError: pass
55
+ config.cliconf_save(token="")
56
+ print("[yellow]Your session has expired. Please run 'glimpse login' again [/yellow].")
57
+ raise typer.Exit(1)
58
+ elif response.status_code == 403:
59
+ print("[bold red]Authentication failed: Invalid or revoked token.[/bold red]")
60
+ try:
61
+ error = response.json().get("error")
62
+ if error: print(f"[dim]Error reason: {error}[/dim]")
63
+ except JSONDecodeError: pass
64
+ raise typer.Exit(1)
65
+ else:
66
+ print(f"[bold red]Unexpected error ocurred.\n[dim](status code: {response.status_code})[/dim][/bold red]")
67
+ try:
68
+ error = response.json().get("error")
69
+ if error: print(f"[dim red]Error reason: {error}[/dim red]")
70
+ except JSONDecodeError: pass
71
+ raise typer.Exit(1)
glimpsecli/config.py ADDED
@@ -0,0 +1,116 @@
1
+ from typing import TypedDict
2
+ from pathlib import Path
3
+ import json
4
+ import os
5
+
6
+ CLI_CONFIG_DIR = Path.home() / ".config" / "glimpsecli"
7
+ CLI_CONFIG_FILE = CLI_CONFIG_DIR / "config.json"
8
+ CONFIG_FILENAME = ".shared-repo.json"
9
+ DEFAULT_API_URL = "https://gitglimpse.sutac.pl"
10
+
11
+ # --- repository config
12
+ class RepoConfig(TypedDict):
13
+ repo_id: str
14
+
15
+ def repoconf_save(repo_id: str, path: Path = Path(".")) -> None:
16
+ """Saves repository config to file.
17
+
18
+ Args:
19
+ repo_id: Repository GitGlimpse id.
20
+ path: Path to directory holding config file.
21
+ """
22
+ config_path = path / CONFIG_FILENAME
23
+ conf: RepoConfig = {"repo_id": repo_id}
24
+ with open(config_path, "w") as f:
25
+ json.dump(conf, f, indent=2)
26
+
27
+ def repoconf_load(path: Path = Path(".")) -> RepoConfig | None:
28
+ """Loads repository config from file.
29
+
30
+ Args:
31
+ path: Path to directory holding config file.
32
+
33
+ Returns:
34
+ RepoConfig if avaliable, else None.
35
+ """
36
+ config_path = path / CONFIG_FILENAME
37
+ if not config_path.exists():
38
+ return None
39
+ try:
40
+ with open(config_path, "r") as f:
41
+ return json.load(f)
42
+ except Exception:
43
+ return None
44
+
45
+ def repoconf_remove(path: Path = Path(".")) -> None:
46
+ """Removes repository config file if exists.
47
+
48
+ Args:
49
+ path: Path to directory holding config file.
50
+ """
51
+ config_path = path / CONFIG_FILENAME
52
+ config_path.unlink(missing_ok=True)
53
+
54
+ # --- cli config
55
+ class CliConfig(TypedDict, total=False):
56
+ token: str
57
+ api_url: str
58
+ debug: bool
59
+
60
+ def cliconf_save(
61
+ token: str | None = None,
62
+ api_url: str | None = None,
63
+ debug: bool | None = None
64
+ ) -> None:
65
+ """Saves or updates cli config safely.
66
+
67
+ Args:
68
+ token: GitGlimpse api token.
69
+ """
70
+ CLI_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
71
+ conf = cliconf_load() or {}
72
+ if token is not None:
73
+ if token: conf["token"] = token
74
+ elif "token" in conf: conf.pop("token")
75
+ if api_url is not None:
76
+ if api_url: conf["api_url"] = api_url.rstrip("/")
77
+ elif "api_url" in conf: conf.pop("api_url")
78
+ if debug is not None:
79
+ conf["debug"] = debug
80
+ with open(CLI_CONFIG_FILE, "w") as f:
81
+ json.dump(conf, f, indent=2)
82
+
83
+ def cliconf_load() -> CliConfig | None:
84
+ """Loads cli configfrom file.
85
+
86
+ Returns:
87
+ CliConfig if avaliable, else None.
88
+ """
89
+ if not CLI_CONFIG_FILE.exists():
90
+ return None
91
+ try:
92
+ with open(CLI_CONFIG_FILE, "r") as f:
93
+ return json.load(f)
94
+ except Exception:
95
+ return None
96
+
97
+ def cliconf_remove() -> None:
98
+ """Removes cli config file."""
99
+ CLI_CONFIG_FILE.unlink(missing_ok=True)
100
+
101
+ def get_api_url() -> str:
102
+ """Resolves active api url
103
+
104
+ Returns:
105
+ Active api url.
106
+ """
107
+ if conf := cliconf_load():
108
+ if user_url := conf.get("api_url"):
109
+ return user_url.rstrip("/")
110
+ return DEFAULT_API_URL
111
+
112
+ def get_debug_mode() -> bool:
113
+ """Returns True if debug is enabled in user config, else False."""
114
+ if conf := cliconf_load():
115
+ return conf.get("debug", False)
116
+ return False
glimpsecli/helpers.py ADDED
@@ -0,0 +1,97 @@
1
+ from glimpsecli import utils, config, api
2
+ from rich.progress import Progress, SpinnerColumn, TextColumn
3
+ from pathlib import Path
4
+ from rich import print
5
+ import typer
6
+ import time
7
+
8
+
9
+ def get_or_prompt_url(url: str | None = None, detect: bool = True) -> str:
10
+ """Helper to get GitHub repository url.
11
+
12
+ Args:
13
+ url: GitHub repository url.
14
+ detect: Flag for automatic detection.
15
+
16
+ Returns:
17
+ GitHub repository url.
18
+ """
19
+ if detect and not url:
20
+ if not Path(".git").exists():
21
+ print("[red]Error: Current directory is not a Git repository.[/red]")
22
+ raise typer.Exit(1)
23
+ url = utils.get_git_remote_url()
24
+ if not url:
25
+ print("[dim]Could not detect GitHub url.[/dim]")
26
+ if not url:
27
+ while True:
28
+ url = typer.prompt("Enter GitHub repository URL (e.g., https://github.com/user/repo.git)")
29
+ if url: url = url.strip()
30
+ if url and utils.is_valid_repo_url(url): break
31
+ print("[bold red]Invalid url, try again...[/bold red]")
32
+ return url
33
+
34
+ def finalize_init(repo_id: str) -> None:
35
+ """Helper to save config and update gitignore.
36
+
37
+ Args:
38
+ repo_id: Repository GitGlimpse id.
39
+ """
40
+ # Add repo id to local file
41
+ config.repoconf_save(repo_id)
42
+ print("[dim]Created local .shared-repo.json configuration file for this repository.[/dim]")
43
+ # Auto-add to .gitignore
44
+ gitignore = Path(".gitignore")
45
+ ignore_entry = "\n.shared-repo.json\n"
46
+ if gitignore.exists():
47
+ if ".shared-repo.json" not in gitignore.read_text():
48
+ with open(gitignore, "a") as f:
49
+ f.write(ignore_entry)
50
+ print("[dim]Added .shared-repo.json to .gitignore[/dim]")
51
+ else:
52
+ gitignore.write_text(ignore_entry)
53
+ print("[dim]Created .gitignore and added .shared-repo.json[/dim]")
54
+
55
+ def poll_build_status(token: str, repo_id: str) -> None:
56
+ """Helper to poll build status from server.
57
+
58
+ Args:
59
+ token: GitGlimpse api token.
60
+ repo_id: Repository GitGlimpse id.
61
+ """
62
+ print(f"[dim]Repository build was queued.[/dim]")
63
+ try:
64
+ with Progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}"), transient=True) as progress:
65
+ task_id = progress.add_task(description="Connecting to server...", total=None)
66
+ last_status = None
67
+ while True:
68
+ poll_response = api.request_api(f"/repos/build/{repo_id}/status", token=token, quiet=True)
69
+ status = poll_response.json().get("status")
70
+ if status != last_status:
71
+ progress.update(task_id, description=f"Build status: {utils.enrich_status(status)}")
72
+ last_status = status
73
+ if status in ["success", "failed", "violation"]: break
74
+ time.sleep(2)
75
+ except KeyboardInterrupt:
76
+ print("\n[yellow]Polling interrupted. The build is still running on the server.[/yellow]")
77
+ print(f"Check status later at: {config.get_api_url()}/repos/details/{repo_id}")
78
+ raise typer.Exit(0)
79
+ print(f"Build has finished with status: {utils.enrich_status(status)}")
80
+ print(f"View details at: {config.get_api_url()}/repos/details/{repo_id}")
81
+
82
+ def get_repo_config() -> config.RepoConfig:
83
+ """Helper for getting repository config."""
84
+ conf = config.repoconf_load()
85
+ if not conf:
86
+ print("[yellow]Not initialised. Run 'glimpse init' first.[/yellow]")
87
+ raise typer.Exit(1)
88
+ return conf
89
+
90
+ def get_token() -> str:
91
+ """Helper for getting api token."""
92
+ conf = config.cliconf_load() or {}
93
+ token = conf.get("token")
94
+ if not conf or not token:
95
+ print("[yellow]Not logged in. Run 'glimpse login' first.[/yellow]")
96
+ raise typer.Exit(code=1)
97
+ return token
glimpsecli/main.py ADDED
@@ -0,0 +1,270 @@
1
+ from glimpsecli import config, utils, api, helpers
2
+ from json import JSONDecodeError
3
+ from rich.panel import Panel
4
+ from rich import print
5
+ import importlib.metadata
6
+ import typer
7
+ import sys
8
+
9
+ _DEBUG_MODE = True
10
+
11
+ app = typer.Typer(help="CLI tool to manage and update shared GitHub repos with GitGlimpse.", no_args_is_help=True)
12
+
13
+ # --- main
14
+ def version_callback(value: bool) -> None:
15
+ """Display version of package."""
16
+ if not value: return
17
+ version = importlib.metadata.metadata("git-glimpse-cli").get("version")
18
+ print(f"GitGlimpse CLI version: {version or "?"}")
19
+ raise typer.Exit(0)
20
+
21
+ @app.callback()
22
+ def main(
23
+ version: bool = typer.Option(None, "--version", callback=version_callback, is_eager=True, help="Display version of package.")
24
+ ):
25
+ """GitGlimpse CLI."""
26
+ pass
27
+
28
+ # --- login
29
+ @app.command()
30
+ def login(
31
+ token: str = typer.Option(
32
+ ...,
33
+ prompt="Enter your CLI token",
34
+ hide_input=True,
35
+ help="Your personal access cli token from dashboard."),
36
+ force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing session without prompting.")
37
+ ):
38
+ """Authenticate cli with server."""
39
+ conf = config.cliconf_load()
40
+ if not force and conf and conf.get("token"):
41
+ print("[yellow]You are already logged in.[/yellow]")
42
+ if not typer.confirm("Do you want to overwrite your existing session?"):
43
+ print("[dim]Login cancelled.[/dim]")
44
+ raise typer.Exit(0)
45
+ response = api.request_api("/cli/user", token=token)
46
+ config.cliconf_save(token)
47
+ username = response.json().get("username")
48
+ print(f"[bold green]Success![/bold green] Authenticated as [cyan]{username}[/cyan].")
49
+
50
+ # --- whoami
51
+ @app.command()
52
+ def whoami():
53
+ """Check the currently logged-in user."""
54
+ response = api.request_api("/cli/user")
55
+ username = response.json().get("username")
56
+ print(f"Authenticated as [cyan]{username}[/cyan].")
57
+
58
+ # --- logout
59
+ @app.command()
60
+ def logout():
61
+ """Clear stored local credentials."""
62
+ conf = config.cliconf_load()
63
+ if not conf or not conf.get("token"):
64
+ print("[yellow]You are not currently logged in.[/yellow]")
65
+ raise typer.Exit(0)
66
+ config.cliconf_save(token="")
67
+ print("[bold green]Logged out successfully.[/bold green]\nStored credentials removed.")
68
+
69
+ # --- init
70
+ init_app = typer.Typer(help="Link or upload the current Git repository to GitGlimpse server.", no_args_is_help=True)
71
+ app.add_typer(init_app, name="init")
72
+
73
+ @init_app.command("link")
74
+ def init_link(
75
+ url: str | None = typer.Option(None, help="GitHub repository URL."),
76
+ detect: bool = typer.Option(True, help="Do you want to detect repository url automatically.")
77
+ ):
78
+ """Link current Git repository to existing one on GitGlimpse."""
79
+ token = helpers.get_token()
80
+ if config.repoconf_load():
81
+ print("Shared repository already initialised.")
82
+ raise typer.Exit(0)
83
+ url = helpers.get_or_prompt_url(url, detect)
84
+ print("[dim]Linking to existing GitGlimpse repository.[/dim]")
85
+ response = api.request_api("/cli/repos/fetch", method="POST", payload={"url": url}, token=token, handle_codes=[200, 404])
86
+ if response.status_code == 404:
87
+ print(f"[bold red]Could not find this repository ([cyan]'{url}'[/cyan]) on GitGlimpse server, provide a valid url.[/bold red]")
88
+ raise typer.Exit(1)
89
+ # response.status_code == 200
90
+ repo_id = response.json().get('repo_id')
91
+ print("[green]Repo was found on GitGlimpse server.[/green]")
92
+ helpers.finalize_init(repo_id)
93
+ print(f"Repository details: {config.get_api_url()}/repos/details/{repo_id}")
94
+
95
+ @init_app.command("new")
96
+ def init_new(
97
+ url: str | None = typer.Option(None, help="GitHub repository URL."),
98
+ is_private: bool | None = typer.Option(None, "--private/--public", help="Is repository public or private?"),
99
+ force: bool = typer.Option(False, "--force", "-f", help="Upload without prompting about GitHub publish."),
100
+ detect: bool = typer.Option(True, help="Do you want to detect repository url automatically.")
101
+ ):
102
+ """Upload and register current Git repository to GitGlimpse."""
103
+ token = helpers.get_token()
104
+ if config.repoconf_load():
105
+ print("Shared repository already initialised.")
106
+ raise typer.Exit(0)
107
+ url = helpers.get_or_prompt_url(url, detect)
108
+ print("[dim]Adding repository to GitGlimpse.[/dim]")
109
+ if not force:
110
+ print("[bold]This repository have to be uploaded to GitHub under given url first.[/bold]")
111
+ typer.confirm("Continue?", abort=True)
112
+ # Private/Public
113
+ if is_private is None:
114
+ is_private = typer.confirm("Are you uploading a private repository? (This will require SSH key generation)")
115
+ if is_private: # Private
116
+ print("[dim]Generating local SSH keys at '.git/shared_repo_keys/'.[/dim]")
117
+ try:
118
+ private_key, public_key = utils.generate_local_ssh_key()
119
+ except Exception as e:
120
+ print(f"[red]Failed to generate local SSH key: {e}[/red]")
121
+ raise typer.Exit(code=1)
122
+ print("\n[bold yellow]Action Required:[/bold yellow] Add this deploy key to your GitHub repository settings ([dim]Settings > Deploy keys > Add deploy key[/dim]):")
123
+ print(Panel(public_key, title="Public Deploy Key", border_style="yellow"))
124
+ typer.confirm("Continue?", abort=True)
125
+ if url.startswith("https://"):
126
+ url = utils.create_alt_repo_url(url)
127
+ else: # Public
128
+ if not url.startswith("https://"):
129
+ url = utils.create_alt_repo_url(url)
130
+ private_key = None
131
+ # Upload
132
+ response = api.request_api(
133
+ "/cli/repos/add",
134
+ method="POST",
135
+ payload={"url": url, "ssh_key": private_key},
136
+ token=token,
137
+ handle_codes=[202, 409, 420]
138
+ )
139
+ if response.status_code == 409:
140
+ print(f"[yellow]This repository is already registered on the server.[/yellow]")
141
+ raise typer.Exit(1)
142
+ elif response.status_code == 420:
143
+ print(f"[yellow]You have reached your usage limits.[/yellow]")
144
+ try:
145
+ error = response.json().get("error")
146
+ if error: print(f"[dim]Error reason: {error}[/dim]")
147
+ except JSONDecodeError: pass
148
+ raise typer.Exit(1)
149
+ # response.status_code == 202
150
+ data = response.json()
151
+ repo_id = data.get('repo_id')
152
+ helpers.finalize_init(repo_id)
153
+ helpers.poll_build_status(token, repo_id)
154
+
155
+ # --- limits
156
+ @app.command()
157
+ def limits():
158
+ """Display user build and repository limits."""
159
+ response = api.request_api("/cli/user/limits")
160
+ data = response.json()
161
+ repo_limit = data.get("repo_limit")
162
+ repo_count = data.get("repo_count")
163
+ build_limit = data.get("build_limit")
164
+ build_count = data.get("build_count")
165
+ print("[bold]User limits:[/bold]")
166
+ repo_str = f"[red]{repo_count}[/red]/[red]{repo_limit}[/red]" if repo_count == repo_limit else f"{repo_count}/{repo_limit}"
167
+ print(f"Repositories: {repo_str}")
168
+ build_str = f"[red]{build_count}[/red]/[red]{build_limit}[/red]" if build_count == build_limit else f"{build_count}/{build_limit}"
169
+ print(f"Builds: {build_str}")
170
+
171
+ # --- status
172
+ @app.command()
173
+ def status():
174
+ """Display status info of current repository."""
175
+ token = helpers.get_token()
176
+ conf = helpers.get_repo_config()
177
+ response = api.request_api(f"/cli/repos/build/{conf['repo_id']}/status", token=token)
178
+ data = response.json()
179
+ build_status = data.get("status", "")
180
+ print(f"Repository id: [dark_cyan]{conf['repo_id']}[/dark_cyan]")
181
+ print(f"Build status: {utils.enrich_status(build_status)}")
182
+ print(f"Repository details: {config.get_api_url()}/repos/details/{conf['repo_id']}")
183
+
184
+ # --- remove
185
+ @app.command()
186
+ def remove(
187
+ force: bool = typer.Option(False, "--force", "-f", help="Remove without prompting.")
188
+ ):
189
+ """Remove current repository from GitGlimpse."""
190
+ token = helpers.get_token()
191
+ conf = helpers.get_repo_config()
192
+ print(f"[dim]Current repository id: '{conf['repo_id']}'[/dim]")
193
+ if not force: typer.confirm(f"Are you sure, you want to remove this repository from GitGlimpse?", abort=True)
194
+ api.request_api(f"/cli/repos/remove/{conf['repo_id']}", method="POST", token=token, handle_codes=[204])
195
+ config.repoconf_remove()
196
+ print("Removed repository from GitGlimpse.")
197
+
198
+ # --- build
199
+ @app.command()
200
+ def build(
201
+ force: bool = typer.Option(False, "--force", "-f", help="Schedule without prompting.")
202
+ ):
203
+ """Schedule a build for current repository."""
204
+ token = helpers.get_token()
205
+ conf = helpers.get_repo_config()
206
+ if not force:
207
+ print("Ensure that you pushed recent changes to GitHub.")
208
+ typer.confirm("Continue?", abort=True)
209
+ print(f"[dim]Scheduling a build for current repository (id: '{conf['repo_id']}')[/dim]")
210
+ response = api.request_api(f"/cli/repos/build/{conf['repo_id']}", method="POST", token=token, handle_codes=[202, 420, 425])
211
+ if response.status_code == 420:
212
+ print(f"[yellow]You have reached your usage limits.[/yellow]")
213
+ try:
214
+ error = response.json().get("error")
215
+ if error: print(f"[dim]Error reason: {error}[/dim]")
216
+ except JSONDecodeError: pass
217
+ raise typer.Exit(1)
218
+ elif response.status_code == 425:
219
+ print(f"[yellow]This repository already has a pending build.[/yellow]")
220
+ raise typer.Exit(1)
221
+ # response.status_code == 202
222
+ helpers.poll_build_status(token, conf['repo_id'])
223
+
224
+ # --- config
225
+ config_app = typer.Typer(help="Configure your cli.", no_args_is_help=True)
226
+ app.add_typer(config_app, name="config")
227
+
228
+ @config_app.command("set-url")
229
+ def config_set_url(
230
+ url: str = typer.Argument(..., help="Api base url (e.g., http://localhost:8000 or https://gitglimpse.sutac.pl)")
231
+ ):
232
+ """Set the active api server url."""
233
+ config.cliconf_save(api_url=url)
234
+ if url:
235
+ print(f"Api url updated to: {url}")
236
+ else:
237
+ print("Api url unset.")
238
+
239
+ @config_app.command("get-url")
240
+ def config_get_url():
241
+ """Get the active api server url."""
242
+ conf = config.cliconf_load() or {}
243
+ url = conf.get("api_url")
244
+ if url: print(url)
245
+
246
+ @config_app.command("debug")
247
+ def config_debug(
248
+ enabled: bool = typer.Argument(..., help="True to enable debug mode, False to disable.")
249
+ ):
250
+ """Enable or disable verbose debug output and full crash stack traces."""
251
+ config.cliconf_save(debug=enabled)
252
+ status = "[bold green]enabled[/bold green]" if enabled else "[bold yellow]disabled[/bold yellow]"
253
+ print(f"Debug mode {status}.")
254
+
255
+ # ---
256
+ def cli():
257
+ try:
258
+ app()
259
+ except Exception as exc:
260
+ if isinstance(exc, typer.Exit):
261
+ raise exc
262
+ if config.get_debug_mode():
263
+ print("[bold yellow][DEBUG MODE ACTIVE] Full stack trace below:[/bold yellow]\n")
264
+ raise exc
265
+ print(f"[bold red]Fatal Error:[/bold red] {str(exc)}")
266
+ print("[dim]Enable debug with 'glimpse config debug' for full diagnostic details.[/dim]")
267
+ sys.exit(1)
268
+
269
+ if __name__ == "__main__":
270
+ cli()
glimpsecli/utils.py ADDED
@@ -0,0 +1,130 @@
1
+ from pathlib import Path
2
+ import subprocess
3
+ import re
4
+
5
+ _GITHUB_URL_REGEX = re.compile(r'^(?:https:\/\/github\.com\/|git@github\.com:)[\w\-]+\/[\w\-]+(?:\.git)?$')
6
+ _GITHUB_URL_OWNER_REPO = r"(?:https?://github\.com/|git@github\.com:)([\w-]+)/([\w-]+?)(?:\.git)?$"
7
+
8
+ def _parse_github_owner_repo(url: str) -> tuple[str, str]:
9
+ """Gets owner and repository name from GitHub url.
10
+
11
+ [!] Requires valid GitHub url.
12
+
13
+ Args:
14
+ url: GitHub repository url.
15
+
16
+ Returns:
17
+ Tuple of (owner, repository).
18
+ """
19
+ match = re.match(_GITHUB_URL_OWNER_REPO, url.strip())
20
+ assert match
21
+ owner, repo = match.groups()
22
+ return owner, repo
23
+
24
+ # --- exported
25
+ def get_git_remote_url() -> str | None:
26
+ """Get git remote repository url from git.
27
+
28
+ [!] Uses `git` command.
29
+
30
+ Returns:
31
+ Git remote repository url if was able to fetch it, else `None`.
32
+ """
33
+ try:
34
+ result = subprocess.run(
35
+ ["git", "remote", "get-url", "origin"],
36
+ capture_output=True,
37
+ text=True,
38
+ check=True,
39
+ )
40
+ return result.stdout.strip()
41
+ except (subprocess.CalledProcessError, FileNotFoundError):
42
+ return None
43
+
44
+ def is_valid_repo_url(url: str) -> bool:
45
+ """Validate repository URL.
46
+
47
+ Valid URL will follow this pattern:
48
+ - `https://github.com/user/repo.git`
49
+ - `git@github.com:user/repo.git`.
50
+
51
+ Args:
52
+ url: URL to validate.
53
+
54
+ Returns:
55
+ True if URL is valid.
56
+
57
+ Notes:
58
+ This function checks only format. It does not verify
59
+ URL resource existence.
60
+ """
61
+ return bool(_GITHUB_URL_REGEX.match(url))
62
+
63
+ def create_alt_repo_url(url: str):
64
+ """Creates alternative GitHub repository url.
65
+
66
+ For https urls returns ssh url and the oposite way.
67
+ [!] Requires valid Github url.
68
+
69
+ Args:
70
+ url: Github repository url.
71
+
72
+ Returns:
73
+ Alternative GitHub repository url.
74
+ """
75
+ assert is_valid_repo_url(url)
76
+ owner, repo = _parse_github_owner_repo(url)
77
+ if url.startswith("https://"):
78
+ return f"git@github.com:{owner}/{repo}.git"
79
+ else:
80
+ return f"https://github.com/{owner}/{repo}.git"
81
+
82
+ def generate_local_ssh_key() -> tuple[str, str]:
83
+ """Generates a temporary project-specific SSH key pair locally if it doesn't exist.
84
+
85
+ Keys are generated in directory `.git/shared_repo_keys/` using rsa algorithm.
86
+ [!] Uses `ssh-keygen` command.
87
+
88
+ Returns:
89
+ Tuple of (private_key, public_key).
90
+ """
91
+ key_dir = Path(".git") / "shared_repo_keys"
92
+ key_dir.mkdir(parents=True, exist_ok=True)
93
+ private_key_path = key_dir / "id_rsa"
94
+ public_key_path = key_dir / "id_rsa.pub"
95
+ if not private_key_path.exists():
96
+ subprocess.run(
97
+ [
98
+ "ssh-keygen",
99
+ "-t",
100
+ "rsa",
101
+ "-N",
102
+ "",
103
+ "-f",
104
+ str(private_key_path),
105
+ "-C",
106
+ "git-glimpse-cli",
107
+ ],
108
+ check=True,
109
+ capture_output=True,
110
+ )
111
+ private_key = private_key_path.read_text()
112
+ public_key = public_key_path.read_text().strip()
113
+ return private_key, public_key
114
+
115
+ def enrich_status(status: str) -> str:
116
+ """Adds color coding for build status codes.
117
+
118
+ Args:
119
+ status: Build status code.
120
+
121
+ Returns:
122
+ Color coded build status code.
123
+ """
124
+ match status:
125
+ case "pending": return "[bold blue]pending[/bold blue]"
126
+ case "success": return "[bold green]success[/bold green]"
127
+ case "failed": return "[bold red]failed[/bold red]"
128
+ case "violation": return "[bold magenta]violation[/bold magenta]"
129
+ case "running": return "[/bold cyan]running[/bold cyan]"
130
+ case _: return "[bold dim]?[/bold dim]"