github-license-scanner 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,307 @@
1
+ Metadata-Version: 2.4
2
+ Name: github-license-scanner
3
+ Version: 0.1.0
4
+ Summary: Analyze GitHub repo and dependency licenses, copyleft risk, and closed-source sellability.
5
+ Project-URL: Homepage, https://github.com/NezbiT/github-license-scanner
6
+ Project-URL: Issues, https://github.com/NezbiT/github-license-scanner/issues
7
+ Author: Mario Alvarez
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agpl,compliance,copyleft,dependencies,gpl,license,sbom
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Quality Assurance
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: httpx>=0.27.0
20
+ Requires-Dist: packaging>=24.0
21
+ Requires-Dist: platformdirs>=4.0
22
+ Requires-Dist: python-dotenv>=1.0.0
23
+ Provides-Extra: ui
24
+ Requires-Dist: nicegui>=2.0.0; extra == 'ui'
25
+ Description-Content-Type: text/markdown
26
+
27
+ # GitHub License Scanner
28
+
29
+ <p align="center">
30
+ <img src="docs/images/hero.jpg" alt="GitHub License Scanner hero banner" width="100%" />
31
+ </p>
32
+
33
+ <p align="center">
34
+ <strong>Analyze GitHub repo licenses + dependencies</strong><br/>
35
+ Know if you can sell closed-source — or if copyleft (GPL / AGPL) forces open source.<br/>
36
+ <em>NiceGUI web UI · CLI · bilingual ES/EN · light &amp; dark mode</em>
37
+ </p>
38
+
39
+ <p align="center">
40
+ <a href="#quick-start"><img src="https://img.shields.io/badge/Python-3.11%2B-2c3e50?style=flat-square" alt="Python" /></a>
41
+ <a href="#web-ui"><img src="https://img.shields.io/badge/UI-NiceGUI-8b5e3c?style=flat-square" alt="NiceGUI" /></a>
42
+ <a href="#cli"><img src="https://img.shields.io/badge/CLI-supported-3f6f4e?style=flat-square" alt="CLI" /></a>
43
+ <a href="#disclaimer"><img src="https://img.shields.io/badge/Not-legal%20advice-9b2c2c?style=flat-square" alt="Disclaimer" /></a>
44
+ <a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-2c3e50?style=flat-square" alt="MIT License" /></a>
45
+ </p>
46
+
47
+ ---
48
+
49
+ ## Screenshots
50
+
51
+ ### Light workspace (full-width layout)
52
+
53
+ <p align="center">
54
+ <img src="docs/images/ui-light.jpg" alt="Light mode UI — scan workspace and results" width="100%" />
55
+ </p>
56
+
57
+ ### Dark mode
58
+
59
+ <p align="center">
60
+ <img src="docs/images/ui-dark.jpg" alt="Dark mode UI — copyleft warning and package groups" width="100%" />
61
+ </p>
62
+
63
+ ### How it works
64
+
65
+ <p align="center">
66
+ <img src="docs/images/flow.jpg" alt="Flow: URL → fetch → registries → verdict → deploy" width="100%" />
67
+ </p>
68
+
69
+ | Step | What happens |
70
+ |------|----------------|
71
+ | 1 | Paste a GitHub URL (`owner/repo`) |
72
+ | 2 | Read repo license + dependency manifests |
73
+ | 3 | Look up package licenses on npm, PyPI, crates.io, … |
74
+ | 4 | Verdict: closed sale OK vs strong copyleft |
75
+ | 5 | Deploy tips + copyright notice to copy |
76
+
77
+ ---
78
+
79
+ ## Features
80
+
81
+ - **Repo license** via GitHub REST API
82
+ - **Dependency scan** for `package.json`, `requirements.txt`, `pyproject.toml`, `Cargo.toml`, `go.mod`, `Gemfile`, `composer.json`, Maven/Gradle (best-effort)
83
+ - **Registry license lookup** (npm, PyPI, crates.io, RubyGems, Packagist)
84
+ - **Risk colors**: green (permissive) · orange (weak/unknown) · red (strong copyleft)
85
+ - **Closed-source sellability** signal + GPL/AGPL force-open flag
86
+ - **Permissive replacement** suggestions for problematic packages
87
+ - **Batch mode** (many URLs) + **scan history**
88
+ - **Copy copyright notice** button
89
+ - **Deploy advisor** (Vercel, Railway, Render, Fly.io, …)
90
+ - **ES / EN** UI · **light / dark** theme · **full-width** responsive layout
91
+
92
+ ---
93
+
94
+ ## Quick start
95
+
96
+ ```bash
97
+ pipx install github-license-scanner
98
+ gls scan psf/requests
99
+ ```
100
+
101
+ The web interface is an optional extra, so the CLI stays light:
102
+
103
+ ```bash
104
+ pipx install 'github-license-scanner[ui]'
105
+ gls ui
106
+ ```
107
+
108
+ Optional configuration (recommended):
109
+
110
+ ```bash
111
+ # PowerShell example
112
+ $env:GITHUB_TOKEN = "ghp_..."
113
+ $env:GLS_STORAGE_SECRET = "long-random-string"
114
+ ```
115
+
116
+ Environment variables are the source of truth. A `.env` file is only a local
117
+ convenience: one is read from the current directory or from your user config
118
+ directory if present, and real environment variables always win.
119
+
120
+ | Variable | Purpose |
121
+ |----------|---------|
122
+ | `GITHUB_TOKEN` | Higher GitHub API rate limits / private repos |
123
+ | `GLS_STORAGE_SECRET` | Signs session cookies (**required** for public deploys) |
124
+ | `GLS_HOST` / `GLS_PORT` | Bind address (default `127.0.0.1:8080`) |
125
+ | `GLS_MAX_BATCH_URLS` | Cap batch scans (default 15) |
126
+ | `GLS_RATE_LIMIT_SCANS` | Scans per window per client (default 20/hour) |
127
+ | `GLS_AUTH_ENABLED` | Require web login (`1` / `true`) |
128
+ | `GLS_USERS_FILE` | JSON users DB (default: `users.json` in the data dir) |
129
+ | `GLS_DATA_DIR` | Override where history and users are stored |
130
+
131
+ See [`.env.example`](.env.example) for the full list.
132
+
133
+ History and users live in your per-user data directory, never inside the
134
+ installed package:
135
+
136
+ | OS | Path |
137
+ |----|------|
138
+ | Windows | `%LOCALAPPDATA%\NezbiT\github-license-scanner` |
139
+ | macOS | `~/Library/Application Support/github-license-scanner` |
140
+ | Linux | `~/.local/share/github-license-scanner` |
141
+
142
+ ### Multi-user auth + private history
143
+
144
+ ```bash
145
+ gls user-add alice # interactive password (≥8 chars)
146
+
147
+ export GLS_AUTH_ENABLED=1
148
+ export GLS_STORAGE_SECRET=long-random-string
149
+ ```
150
+
151
+ Each user gets `history/<username>.json` in that data directory. Without auth,
152
+ history stays in a single `history.json`.
153
+
154
+ ---
155
+
156
+ ## Web UI
157
+
158
+ ```bash
159
+ pipx install 'github-license-scanner[ui]'
160
+ gls ui # or: gls ui --host 0.0.0.0 --port 9000
161
+ ```
162
+
163
+ Open **[http://127.0.0.1:8080](http://127.0.0.1:8080)** (binds to localhost by default)
164
+
165
+ - Switch **ES | EN** in the top bar
166
+ - Toggle **light / dark** with the sun/moon button
167
+ - Try example chips (`psf/requests`, `encode/httpx`, …)
168
+
169
+ ---
170
+
171
+ ## CLI
172
+
173
+ ```bash
174
+ # Single repository
175
+ gls scan https://github.com/psf/requests
176
+
177
+ # Shorthand
178
+ gls scan psf/requests
179
+
180
+ # Batch (one URL per line)
181
+ gls batch urls.example.txt
182
+
183
+ # History
184
+ gls history
185
+
186
+ # Markdown + SBOM export
187
+ gls scan psf/requests --markdown report.md --sbom bom.cdx.json
188
+ gls scan psf/requests --sbom bom.spdx.json --sbom-format spdx
189
+ ```
190
+
191
+ `github-license-scanner` is available as a longer alias for `gls`.
192
+
193
+ | Exit code | Meaning |
194
+ |-----------|---------|
195
+ | `0` | No strong copyleft force-open signal |
196
+ | `1` | Strong copyleft detected |
197
+ | `2` | Hard failure (bad URL, API error, …) |
198
+
199
+ ---
200
+
201
+ ## Project layout
202
+
203
+ ```text
204
+ github-license-scanner/
205
+ ├── pyproject.toml # Packaging (hatchling)
206
+ ├── gls/
207
+ │ ├── __init__.py # __version__
208
+ │ ├── cli.py # Command-line mode + entry point
209
+ │ ├── webui.py # NiceGUI interface (optional [ui] extra)
210
+ │ ├── config.py # Env-based configuration + data paths
211
+ │ ├── rate_limit.py # Scan rate limiter
212
+ │ ├── auth.py # Optional multi-user auth (PBKDF2)
213
+ │ ├── spdx_engine.py # SPDX expression parser + risk
214
+ │ ├── sbom_export.py # CycloneDX 1.5 + SPDX 2.3 JSON
215
+ │ ├── github_api.py # URL parse + GitHub REST
216
+ │ ├── dependency_scanner.py # Manifest parsers
217
+ │ ├── license_analyzer.py # Registry licenses + verdict
218
+ │ ├── deploy_advisor.py # Deploy recommendations
219
+ │ ├── history_store.py # JSON history (shared or per-user)
220
+ │ ├── models.py # Dataclasses
221
+ │ ├── i18n.py # ES/EN strings
222
+ │ ├── report.py # Markdown export
223
+ │ └── docs/ # Legal pages served by the web UI
224
+ │ ├── LEGAL_DISCLAIMER.md
225
+ │ ├── PRIVACY.md
226
+ │ └── TERMS.md
227
+ ├── scripts/ # Dev smoke tests / audit tooling
228
+ ├── .env.example
229
+ ├── urls.example.txt
230
+ └── docs/
231
+ ├── AUDIT_REPORT.pdf
232
+ └── images/ # README screenshots
233
+ ```
234
+
235
+ ---
236
+
237
+ ## License risk legend
238
+
239
+ | Color | Risk | Examples |
240
+ |-------|------|----------|
241
+ | Green | Permissive | MIT, Apache-2.0, BSD, ISC |
242
+ | Orange | Weak copyleft / unknown | LGPL, MPL, EUPL, missing metadata |
243
+ | Red | Strong copyleft | GPL, AGPL, SSPL |
244
+
245
+ ---
246
+
247
+ ## Security & privacy notes
248
+
249
+ - Default bind is **localhost only** (`GLS_HOST=127.0.0.1`).
250
+ - Set a strong **`GLS_STORAGE_SECRET`** before exposing the UI.
251
+ - Scan history is **instance-local** and shared if multi-user — use **Clear history** or prune via config.
252
+ - Rate limits and batch caps reduce GitHub API abuse.
253
+ - Docs: [Privacy](gls/docs/PRIVACY.md) · [Terms](gls/docs/TERMS.md) · [Legal disclaimer](gls/docs/LEGAL_DISCLAIMER.md)
254
+
255
+ ---
256
+
257
+ ## Disclaimer
258
+
259
+ This tool provides **automated heuristics only**. It is **not legal advice** and not a
260
+ license-compatibility opinion. Dual-licensing, linking models, SaaS (AGPL/SSPL),
261
+ attribution duties, and contracts can change obligations.
262
+ Always review with a qualified attorney before commercial closed-source distribution.
263
+ See [gls/docs/LEGAL_DISCLAIMER.md](gls/docs/LEGAL_DISCLAIMER.md).
264
+
265
+ ---
266
+
267
+ ## Development
268
+
269
+ ```bash
270
+ git clone https://github.com/NezbiT/github-license-scanner.git
271
+ cd github-license-scanner
272
+
273
+ python -m venv .venv
274
+ .venv\Scripts\activate # macOS / Linux: source .venv/bin/activate
275
+
276
+ pip install -e '.[ui]'
277
+ python -m gls.cli scan psf/requests
278
+ ```
279
+
280
+ Build and check a release locally before tagging:
281
+
282
+ ```bash
283
+ pip install build twine
284
+ python -m build
285
+ twine check dist/*
286
+ ```
287
+
288
+ Releases are published to PyPI by [`.github/workflows/publish.yml`](.github/workflows/publish.yml)
289
+ using Trusted Publishing (OIDC) — no API tokens are stored anywhere.
290
+
291
+ ---
292
+
293
+ ## License
294
+
295
+ Released under the **[MIT License](LICENSE)**.
296
+
297
+ You may use, modify, and redistribute this project commercially or privately,
298
+ as long as you keep the copyright and license notice. See `LICENSE` for full text.
299
+
300
+ > The MIT license applies to **this tool’s source code**.
301
+ > It does **not** change the licenses of the GitHub repositories or packages you scan.
302
+
303
+ ---
304
+
305
+ <p align="center">
306
+ Made with NiceGUI · httpx · packaging
307
+ </p>
@@ -0,0 +1,24 @@
1
+ gls/__init__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ gls/auth.py,sha256=vExnbGUgXacZC2aTwRx6kWkQIydsWKug7jGZGwRUaxg,6729
3
+ gls/cli.py,sha256=N7ZDBEHE7rbh8uPbkGR97xMuiDampspIpIKwLQZ0q2A,12125
4
+ gls/config.py,sha256=mrM0cGaMW_Jzk6Bgy8EFHiY7u4vOa-Oi8OGmJSl47y0,6071
5
+ gls/dependency_scanner.py,sha256=lDZURfxbUKPU1t38JEbcYEHKEiK6DahQRtwL8fUnSGg,21243
6
+ gls/deploy_advisor.py,sha256=RWDzCX0QrpauHdJ9X3KwFShM6K4ip12hXc-AarKTXww,10938
7
+ gls/github_api.py,sha256=BScDkBrLg6KaGCnCTiZiVGCp0GCSxD-OWX7o6y_nWrg,15951
8
+ gls/history_store.py,sha256=B-9RTpiQKRJ8kKkOB19-MwZvim06AA4A5Cq_ns5CVbs,5451
9
+ gls/i18n.py,sha256=lggZk-TL-PHV7xVP5RiCfZKH-MdW_57N8qJ1TW8Era8,18860
10
+ gls/license_analyzer.py,sha256=4tgpMsGIzyoLwmVqpXPguoSw_leNkElBa7QuX6x_R0g,31270
11
+ gls/models.py,sha256=6Vj8QdCnfn7J0F3sM6B79PpcNIRRQjQU_6M28g8E5Dg,4839
12
+ gls/rate_limit.py,sha256=QSzAP2Gk096dkPIlYTjz-y1JLtTGoBTQIEXjEnmysMg,2221
13
+ gls/report.py,sha256=vsmF9B_6GUXGcNT6ZCqJOIhd16m-DTTxGRlXaGu4cOY,5659
14
+ gls/sbom_export.py,sha256=EKltiV20i9Ne5ZZbLW6GMZ_xKOH_Uw0jZIQLbl5bqGQ,11009
15
+ gls/spdx_engine.py,sha256=qcuHe0dYY0P9U71Nt_SuuLfPyDZ0DjpDXQMvCdqPwwA,12487
16
+ gls/webui.py,sha256=vgjht82pJ2wqpgxFkwoGF8T0Q_3z_jQppHK8cQkxrkQ,70298
17
+ gls/docs/LEGAL_DISCLAIMER.md,sha256=zS1pWfTntaS_nwyBEd_y1JTbNAqj2Z8nEZeZSyztwIc,2870
18
+ gls/docs/PRIVACY.md,sha256=0tl39MuXUNnOLKvP9jdIVsU96nLDLZxutPSBBSy-JOk,3561
19
+ gls/docs/TERMS.md,sha256=siSl-uZ7lgQ_HvIY7a99UoJyPvOWxXCUKeB7GkjtGmg,2486
20
+ github_license_scanner-0.1.0.dist-info/METADATA,sha256=6FZ_c_bKPLGshOlHVGxbeSzMoapPMnnfpCjw4rIiIBs,10143
21
+ github_license_scanner-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
22
+ github_license_scanner-0.1.0.dist-info/entry_points.txt,sha256=NPdnZXh3qeBtU4vB5rdxZIiDHwMP4kEuZX0llI8uxpA,75
23
+ github_license_scanner-0.1.0.dist-info/licenses/LICENSE,sha256=iiCqcmQF6PZDr5vfURi3xqOG4b2K0ydcAvlGD2qaXAU,1063
24
+ github_license_scanner-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ github-license-scanner = gls.cli:main
3
+ gls = gls.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NezbiT
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.
gls/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
gls/auth.py ADDED
@@ -0,0 +1,229 @@
1
+ """
2
+ Optional multi-user authentication for the web UI.
3
+
4
+ When GLS_AUTH_ENABLED is true, the NiceGUI app requires a login.
5
+ Each user gets an isolated history file under <data_dir>/history/<user>.json.
6
+
7
+ Password storage uses PBKDF2-HMAC-SHA256 (stdlib only).
8
+ Users file format (JSON):
9
+ {
10
+ "alice": {"salt": "hex", "hash": "hex", "iterations": 200000},
11
+ ...
12
+ }
13
+
14
+ CLI:
15
+ gls user-add alice
16
+ gls user-list
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import getpass
22
+ import hashlib
23
+ import json
24
+ import os
25
+ import re
26
+ import secrets
27
+ import sys
28
+ import tempfile
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ try:
33
+ from .config import DATA_DIR # type: ignore
34
+ except Exception: # noqa: BLE001
35
+ from platformdirs import user_data_dir
36
+
37
+ DATA_DIR = Path(user_data_dir("github-license-scanner", "NezbiT"))
38
+
39
+ USERS_PATH = Path(os.environ.get("GLS_USERS_FILE") or (DATA_DIR / "users.json"))
40
+
41
+ DEFAULT_ITERATIONS = 200_000
42
+ USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
43
+
44
+
45
+ def auth_enabled() -> bool:
46
+ """Return True when multi-user auth is required for the web UI."""
47
+ return os.environ.get("GLS_AUTH_ENABLED", "").strip().lower() in {
48
+ "1",
49
+ "true",
50
+ "yes",
51
+ "on",
52
+ }
53
+
54
+
55
+ def _atomic_write(path: Path, text: str) -> None:
56
+ path.parent.mkdir(parents=True, exist_ok=True)
57
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".users_", suffix=".tmp")
58
+ try:
59
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
60
+ fh.write(text)
61
+ fh.flush()
62
+ os.fsync(fh.fileno())
63
+ Path(tmp).replace(path)
64
+ except Exception:
65
+ try:
66
+ os.unlink(tmp)
67
+ except OSError:
68
+ pass
69
+ raise
70
+
71
+
72
+ def load_users() -> dict[str, dict[str, Any]]:
73
+ """Load users database (empty dict if missing)."""
74
+ if not USERS_PATH.exists():
75
+ return {}
76
+ try:
77
+ data = json.loads(USERS_PATH.read_text(encoding="utf-8") or "{}")
78
+ return data if isinstance(data, dict) else {}
79
+ except (json.JSONDecodeError, OSError):
80
+ return {}
81
+
82
+
83
+ def save_users(users: dict[str, dict[str, Any]]) -> None:
84
+ _atomic_write(USERS_PATH, json.dumps(users, indent=2, ensure_ascii=False))
85
+
86
+
87
+ def hash_password(password: str, *, salt: bytes | None = None, iterations: int = DEFAULT_ITERATIONS) -> dict[str, Any]:
88
+ """Create a password record."""
89
+ if not password or len(password) < 8:
90
+ raise ValueError("Password must be at least 8 characters")
91
+ salt = salt or secrets.token_bytes(16)
92
+ dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
93
+ return {
94
+ "salt": salt.hex(),
95
+ "hash": dk.hex(),
96
+ "iterations": iterations,
97
+ }
98
+
99
+
100
+ def verify_password(password: str, record: dict[str, Any]) -> bool:
101
+ """Constant-time-ish verify of password against stored record."""
102
+ try:
103
+ salt = bytes.fromhex(record["salt"])
104
+ expected = bytes.fromhex(record["hash"])
105
+ iterations = int(record.get("iterations") or DEFAULT_ITERATIONS)
106
+ except (KeyError, ValueError, TypeError):
107
+ return False
108
+ got = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
109
+ return secrets.compare_digest(got, expected)
110
+
111
+
112
+ def authenticate(username: str, password: str) -> bool:
113
+ """Return True if credentials are valid."""
114
+ user = (username or "").strip()
115
+ if not user or not password:
116
+ return False
117
+ users = load_users()
118
+ rec = users.get(user)
119
+ if not rec:
120
+ # Also try case-insensitive match
121
+ for k, v in users.items():
122
+ if k.lower() == user.lower():
123
+ rec = v
124
+ user = k
125
+ break
126
+ if not rec:
127
+ # Dummy work to reduce timing oracle on missing users
128
+ hash_password("dummy-password-xx", salt=b"\x00" * 16)
129
+ return False
130
+ return verify_password(password, rec)
131
+
132
+
133
+ def add_user(username: str, password: str) -> None:
134
+ """Create or update a user password."""
135
+ user = (username or "").strip()
136
+ if not USERNAME_RE.match(user):
137
+ raise ValueError(
138
+ "Username must be 1–64 chars: letters, digits, _ . -"
139
+ )
140
+ users = load_users()
141
+ users[user] = hash_password(password)
142
+ save_users(users)
143
+
144
+
145
+ def delete_user(username: str) -> bool:
146
+ users = load_users()
147
+ if username in users:
148
+ del users[username]
149
+ save_users(users)
150
+ return True
151
+ return False
152
+
153
+
154
+ def list_usernames() -> list[str]:
155
+ return sorted(load_users().keys())
156
+
157
+
158
+ def normalize_username(username: str) -> str:
159
+ return (username or "").strip()
160
+
161
+
162
+ def history_user_key(username: str | None) -> str | None:
163
+ """
164
+ Return a filesystem-safe history key.
165
+
166
+ None means shared/local anonymous history (auth disabled).
167
+ """
168
+ if not username:
169
+ return None
170
+ u = normalize_username(username)
171
+ if not USERNAME_RE.match(u):
172
+ return None
173
+ return u.lower()
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # CLI helpers
178
+ # ---------------------------------------------------------------------------
179
+
180
+ def _cli(argv: list[str]) -> int:
181
+ if len(argv) < 2 or argv[1] in {"-h", "--help"}:
182
+ print(__doc__)
183
+ return 0
184
+ cmd = argv[1]
185
+ if cmd == "list-users":
186
+ names = list_usernames()
187
+ if not names:
188
+ print("No users. Create one with: python -m gls.auth add-user <name>")
189
+ print(f"Users file: {USERS_PATH}")
190
+ return 0
191
+ for n in names:
192
+ print(n)
193
+ return 0
194
+ if cmd == "add-user":
195
+ if len(argv) < 3:
196
+ print("Usage: python -m gls.auth add-user <username>", file=sys.stderr)
197
+ return 2
198
+ username = argv[2]
199
+ pw = getpass.getpass("Password: ")
200
+ pw2 = getpass.getpass("Confirm: ")
201
+ if pw != pw2:
202
+ print("Passwords do not match", file=sys.stderr)
203
+ return 2
204
+ try:
205
+ add_user(username, pw)
206
+ except ValueError as exc:
207
+ print(exc, file=sys.stderr)
208
+ return 2
209
+ print(f"User {username!r} saved to {USERS_PATH}")
210
+ print("Enable auth with GLS_AUTH_ENABLED=1")
211
+ return 0
212
+ if cmd == "hash-password":
213
+ pw = getpass.getpass("Password: ")
214
+ rec = hash_password(pw)
215
+ print(json.dumps(rec, indent=2))
216
+ return 0
217
+ if cmd == "delete-user":
218
+ if len(argv) < 3:
219
+ print("Usage: python -m gls.auth delete-user <username>", file=sys.stderr)
220
+ return 2
221
+ ok = delete_user(argv[2])
222
+ print("Deleted" if ok else "Not found")
223
+ return 0 if ok else 1
224
+ print(f"Unknown command: {cmd}", file=sys.stderr)
225
+ return 2
226
+
227
+
228
+ if __name__ == "__main__":
229
+ raise SystemExit(_cli(sys.argv))