oncecheck 0.1.0__tar.gz

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.
Files changed (41) hide show
  1. oncecheck-0.1.0/LICENSE +21 -0
  2. oncecheck-0.1.0/MANIFEST.in +3 -0
  3. oncecheck-0.1.0/PKG-INFO +198 -0
  4. oncecheck-0.1.0/README.md +162 -0
  5. oncecheck-0.1.0/oncecheck/__init__.py +3 -0
  6. oncecheck-0.1.0/oncecheck/__main__.py +5 -0
  7. oncecheck-0.1.0/oncecheck/auth/__init__.py +0 -0
  8. oncecheck-0.1.0/oncecheck/auth/api.py +46 -0
  9. oncecheck-0.1.0/oncecheck/auth/login.py +141 -0
  10. oncecheck-0.1.0/oncecheck/auth/token_store.py +85 -0
  11. oncecheck-0.1.0/oncecheck/cli.py +416 -0
  12. oncecheck-0.1.0/oncecheck/engine/__init__.py +0 -0
  13. oncecheck-0.1.0/oncecheck/engine/config.py +123 -0
  14. oncecheck-0.1.0/oncecheck/engine/reporter.py +105 -0
  15. oncecheck-0.1.0/oncecheck/engine/runner.py +126 -0
  16. oncecheck-0.1.0/oncecheck/py.typed +0 -0
  17. oncecheck-0.1.0/oncecheck/rules/__init__.py +0 -0
  18. oncecheck-0.1.0/oncecheck/rules/android_rules.yaml +243 -0
  19. oncecheck-0.1.0/oncecheck/rules/common_rules.yaml +110 -0
  20. oncecheck-0.1.0/oncecheck/rules/ios_rules.yaml +267 -0
  21. oncecheck-0.1.0/oncecheck/rules/loader.py +87 -0
  22. oncecheck-0.1.0/oncecheck/rules/web_rules.yaml +325 -0
  23. oncecheck-0.1.0/oncecheck/scanners/__init__.py +0 -0
  24. oncecheck-0.1.0/oncecheck/scanners/android.py +516 -0
  25. oncecheck-0.1.0/oncecheck/scanners/common.py +308 -0
  26. oncecheck-0.1.0/oncecheck/scanners/detector.py +159 -0
  27. oncecheck-0.1.0/oncecheck/scanners/ios.py +504 -0
  28. oncecheck-0.1.0/oncecheck/scanners/web.py +554 -0
  29. oncecheck-0.1.0/oncecheck/ui/__init__.py +0 -0
  30. oncecheck-0.1.0/oncecheck/ui/interactive.py +166 -0
  31. oncecheck-0.1.0/oncecheck/ui/rich_console.py +293 -0
  32. oncecheck-0.1.0/oncecheck.egg-info/PKG-INFO +198 -0
  33. oncecheck-0.1.0/oncecheck.egg-info/SOURCES.txt +39 -0
  34. oncecheck-0.1.0/oncecheck.egg-info/dependency_links.txt +1 -0
  35. oncecheck-0.1.0/oncecheck.egg-info/entry_points.txt +2 -0
  36. oncecheck-0.1.0/oncecheck.egg-info/requires.txt +8 -0
  37. oncecheck-0.1.0/oncecheck.egg-info/top_level.txt +1 -0
  38. oncecheck-0.1.0/pyproject.toml +61 -0
  39. oncecheck-0.1.0/setup.cfg +4 -0
  40. oncecheck-0.1.0/tests/test_rules.py +167 -0
  41. oncecheck-0.1.0/tests/test_scans.py +572 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oncecheck
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,3 @@
1
+ include LICENSE
2
+ include README.md
3
+ recursive-include oncecheck/rules *.yaml *.yml
@@ -0,0 +1,198 @@
1
+ Metadata-Version: 2.4
2
+ Name: oncecheck
3
+ Version: 0.1.0
4
+ Summary: A terminal-first CLI tool that scans iOS, Android, and Web projects for launch-critical compliance risks.
5
+ Author-email: Oncecheck <hello@oncecheck.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://oncecheck.com
8
+ Project-URL: Documentation, https://oncecheck.com
9
+ Project-URL: Repository, https://github.com/oncecheck/oncecheck-cli
10
+ Project-URL: Issues, https://github.com/oncecheck/oncecheck-cli/issues
11
+ Keywords: compliance,scanner,ios,android,web,app-store,play-store,owasp,privacy,accessibility
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Software Development :: Quality Assurance
24
+ Classifier: Topic :: Software Development :: Testing
25
+ Requires-Python: >=3.9
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: click>=8.1
29
+ Requires-Dist: rich>=13.0
30
+ Requires-Dist: pyyaml>=6.0
31
+ Requires-Dist: readchar>=4.0
32
+ Provides-Extra: dev
33
+ Requires-Dist: pytest>=8.0; extra == "dev"
34
+ Requires-Dist: pytest-cov>=5.0; extra == "dev"
35
+ Dynamic: license-file
36
+
37
+ # Oncecheck CLI
38
+
39
+ A terminal-first CLI tool that scans iOS, Android, and Web projects for launch-critical compliance risks.
40
+
41
+ ## Features
42
+
43
+ - **73+ compliance rules** across iOS, Android, Web, and cross-platform categories
44
+ - **Auto-detection** — identifies your project type automatically
45
+ - **Interactive browser** — arrow-key driven UI to explore findings
46
+ - **Multiple export formats** — JSON, SARIF 2.1, plain text
47
+ - **CI/CD ready** — exit codes: 0 (pass), 1 (warnings), 2 (failures)
48
+ - **Cross-platform checks** — COPPA, HIPAA, PCI-DSS, accessibility, supply chain
49
+ - **Rule suppression** — `.oncecheckignore` file and `.oncecheckrc` config
50
+ - **Shell completions** — bash, zsh, and fish
51
+
52
+ ## Rule Categories
53
+
54
+ | Platform | Rules | Covers |
55
+ |----------|-------|--------|
56
+ | iOS | 20 | Info.plist, ATS, entitlements, privacy manifests, HealthKit, Keychain |
57
+ | Android | 18 | AndroidManifest, target SDK, permissions, ProGuard, Play Integrity |
58
+ | Web | 27 | CSP, CORS, OWASP Top 10, accessibility, privacy, cookies |
59
+ | Common | 8 | COPPA, HIPAA, PCI-DSS, color contrast, data retention, supply chain |
60
+
61
+ ## Installation
62
+
63
+ ```bash
64
+ pip install oncecheck
65
+ ```
66
+
67
+ ### Development
68
+
69
+ ```bash
70
+ git clone <repo-url>
71
+ cd oncecheck-cli
72
+ python -m venv .venv
73
+ source .venv/bin/activate
74
+ pip install -e ".[dev]"
75
+ ```
76
+
77
+ ## Usage
78
+
79
+ ### Interactive mode
80
+
81
+ ```bash
82
+ oncecheck
83
+ ```
84
+
85
+ Launches the full interactive UI with welcome screen, menu navigation, and findings browser.
86
+
87
+ ### Direct scan
88
+
89
+ ```bash
90
+ # Auto-detect platform
91
+ oncecheck scan ./my-project
92
+
93
+ # Force a specific platform
94
+ oncecheck scan ./my-project --platform ios
95
+
96
+ # Export as JSON (Team plan)
97
+ oncecheck scan ./my-project --format json --output results.json
98
+
99
+ # Export as SARIF (Team plan — for GitHub/VS Code)
100
+ oncecheck scan ./my-project --format sarif --output results.sarif
101
+
102
+ # Interactive findings browser
103
+ oncecheck scan ./my-project --interactive
104
+
105
+ # Fail CI on warnings or higher
106
+ oncecheck scan ./my-project --fail-on WARN
107
+ ```
108
+
109
+ ### Authentication
110
+
111
+ ```bash
112
+ # Sign in (opens browser)
113
+ oncecheck login
114
+
115
+ # Check status
116
+ oncecheck status
117
+
118
+ # Sign out
119
+ oncecheck logout
120
+ ```
121
+
122
+ ### Configuration
123
+
124
+ ```bash
125
+ # Generate config files in your project
126
+ oncecheck init ./my-project
127
+ ```
128
+
129
+ This creates:
130
+ - `.oncecheckrc` — YAML config for disabled rules, severity overrides, and fail threshold
131
+ - `.oncecheckignore` — one rule ID per line to suppress
132
+
133
+ Example `.oncecheckrc`:
134
+ ```yaml
135
+ disabled_rules:
136
+ - IOS-SEC-001
137
+ - WEB-OWASP-003
138
+
139
+ severity_overrides:
140
+ WEB-A11Y-001: INFO
141
+
142
+ fail_on: FAIL
143
+ ```
144
+
145
+ ### Shell Completions
146
+
147
+ ```bash
148
+ # Bash
149
+ oncecheck completions bash >> ~/.bashrc
150
+
151
+ # Zsh
152
+ oncecheck completions zsh >> ~/.zshrc
153
+
154
+ # Fish
155
+ oncecheck completions fish > ~/.config/fish/completions/oncecheck.fish
156
+ ```
157
+
158
+ ## CI/CD Integration
159
+
160
+ ```yaml
161
+ # GitHub Actions example
162
+ - name: Compliance scan
163
+ run: |
164
+ pip install oncecheck
165
+ oncecheck login
166
+ oncecheck scan . --fail-on WARN
167
+
168
+ - name: Upload SARIF (Team plan)
169
+ run: oncecheck scan . --format sarif --output results.sarif
170
+ ```
171
+
172
+ ### Exit Codes
173
+
174
+ | Code | Meaning |
175
+ |------|---------|
176
+ | 0 | No issues (or only INFO) |
177
+ | 1 | Warnings found |
178
+ | 2 | Failures found |
179
+
180
+ ## Plans
181
+
182
+ | Feature | Starter (Free) | Team ($19/mo) |
183
+ |---------|----------------|---------------|
184
+ | Scans per day | 3 | Unlimited |
185
+ | Terminal output | Yes | Yes |
186
+ | JSON/text export | — | Yes |
187
+ | SARIF export | — | Yes |
188
+ | File export (`--output`) | — | Yes |
189
+
190
+ ## Testing
191
+
192
+ ```bash
193
+ python -m pytest tests/ -v
194
+ ```
195
+
196
+ ## License
197
+
198
+ MIT
@@ -0,0 +1,162 @@
1
+ # Oncecheck CLI
2
+
3
+ A terminal-first CLI tool that scans iOS, Android, and Web projects for launch-critical compliance risks.
4
+
5
+ ## Features
6
+
7
+ - **73+ compliance rules** across iOS, Android, Web, and cross-platform categories
8
+ - **Auto-detection** — identifies your project type automatically
9
+ - **Interactive browser** — arrow-key driven UI to explore findings
10
+ - **Multiple export formats** — JSON, SARIF 2.1, plain text
11
+ - **CI/CD ready** — exit codes: 0 (pass), 1 (warnings), 2 (failures)
12
+ - **Cross-platform checks** — COPPA, HIPAA, PCI-DSS, accessibility, supply chain
13
+ - **Rule suppression** — `.oncecheckignore` file and `.oncecheckrc` config
14
+ - **Shell completions** — bash, zsh, and fish
15
+
16
+ ## Rule Categories
17
+
18
+ | Platform | Rules | Covers |
19
+ |----------|-------|--------|
20
+ | iOS | 20 | Info.plist, ATS, entitlements, privacy manifests, HealthKit, Keychain |
21
+ | Android | 18 | AndroidManifest, target SDK, permissions, ProGuard, Play Integrity |
22
+ | Web | 27 | CSP, CORS, OWASP Top 10, accessibility, privacy, cookies |
23
+ | Common | 8 | COPPA, HIPAA, PCI-DSS, color contrast, data retention, supply chain |
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ pip install oncecheck
29
+ ```
30
+
31
+ ### Development
32
+
33
+ ```bash
34
+ git clone <repo-url>
35
+ cd oncecheck-cli
36
+ python -m venv .venv
37
+ source .venv/bin/activate
38
+ pip install -e ".[dev]"
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ ### Interactive mode
44
+
45
+ ```bash
46
+ oncecheck
47
+ ```
48
+
49
+ Launches the full interactive UI with welcome screen, menu navigation, and findings browser.
50
+
51
+ ### Direct scan
52
+
53
+ ```bash
54
+ # Auto-detect platform
55
+ oncecheck scan ./my-project
56
+
57
+ # Force a specific platform
58
+ oncecheck scan ./my-project --platform ios
59
+
60
+ # Export as JSON (Team plan)
61
+ oncecheck scan ./my-project --format json --output results.json
62
+
63
+ # Export as SARIF (Team plan — for GitHub/VS Code)
64
+ oncecheck scan ./my-project --format sarif --output results.sarif
65
+
66
+ # Interactive findings browser
67
+ oncecheck scan ./my-project --interactive
68
+
69
+ # Fail CI on warnings or higher
70
+ oncecheck scan ./my-project --fail-on WARN
71
+ ```
72
+
73
+ ### Authentication
74
+
75
+ ```bash
76
+ # Sign in (opens browser)
77
+ oncecheck login
78
+
79
+ # Check status
80
+ oncecheck status
81
+
82
+ # Sign out
83
+ oncecheck logout
84
+ ```
85
+
86
+ ### Configuration
87
+
88
+ ```bash
89
+ # Generate config files in your project
90
+ oncecheck init ./my-project
91
+ ```
92
+
93
+ This creates:
94
+ - `.oncecheckrc` — YAML config for disabled rules, severity overrides, and fail threshold
95
+ - `.oncecheckignore` — one rule ID per line to suppress
96
+
97
+ Example `.oncecheckrc`:
98
+ ```yaml
99
+ disabled_rules:
100
+ - IOS-SEC-001
101
+ - WEB-OWASP-003
102
+
103
+ severity_overrides:
104
+ WEB-A11Y-001: INFO
105
+
106
+ fail_on: FAIL
107
+ ```
108
+
109
+ ### Shell Completions
110
+
111
+ ```bash
112
+ # Bash
113
+ oncecheck completions bash >> ~/.bashrc
114
+
115
+ # Zsh
116
+ oncecheck completions zsh >> ~/.zshrc
117
+
118
+ # Fish
119
+ oncecheck completions fish > ~/.config/fish/completions/oncecheck.fish
120
+ ```
121
+
122
+ ## CI/CD Integration
123
+
124
+ ```yaml
125
+ # GitHub Actions example
126
+ - name: Compliance scan
127
+ run: |
128
+ pip install oncecheck
129
+ oncecheck login
130
+ oncecheck scan . --fail-on WARN
131
+
132
+ - name: Upload SARIF (Team plan)
133
+ run: oncecheck scan . --format sarif --output results.sarif
134
+ ```
135
+
136
+ ### Exit Codes
137
+
138
+ | Code | Meaning |
139
+ |------|---------|
140
+ | 0 | No issues (or only INFO) |
141
+ | 1 | Warnings found |
142
+ | 2 | Failures found |
143
+
144
+ ## Plans
145
+
146
+ | Feature | Starter (Free) | Team ($19/mo) |
147
+ |---------|----------------|---------------|
148
+ | Scans per day | 3 | Unlimited |
149
+ | Terminal output | Yes | Yes |
150
+ | JSON/text export | — | Yes |
151
+ | SARIF export | — | Yes |
152
+ | File export (`--output`) | — | Yes |
153
+
154
+ ## Testing
155
+
156
+ ```bash
157
+ python -m pytest tests/ -v
158
+ ```
159
+
160
+ ## License
161
+
162
+ MIT
@@ -0,0 +1,3 @@
1
+ """Oncecheck — CLI compliance scanner for iOS, Android, and Web projects."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as `python -m oncecheck`."""
2
+
3
+ from .cli import cli
4
+
5
+ cli()
File without changes
@@ -0,0 +1,46 @@
1
+ """Server-side scan quota check via the Oncecheck API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import urllib.error
7
+ import urllib.request
8
+ from typing import TypedDict
9
+
10
+
11
+ WEB_APP_URL = "https://oncecheck.com"
12
+
13
+
14
+ class QuotaResult(TypedDict):
15
+ allowed: bool
16
+ plan: str
17
+ used: int
18
+ limit: int
19
+
20
+
21
+ def check_scan_quota(access_token: str) -> QuotaResult:
22
+ """Check scan quota with the server and consume a scan slot if allowed.
23
+
24
+ Raises ConnectionError if the server is unreachable,
25
+ PermissionError if the token is invalid/expired.
26
+ """
27
+ url = f"{WEB_APP_URL}/api/scan-check"
28
+ req = urllib.request.Request(
29
+ url,
30
+ method="POST",
31
+ headers={
32
+ "Authorization": f"Bearer {access_token}",
33
+ "Content-Type": "application/json",
34
+ },
35
+ )
36
+
37
+ try:
38
+ with urllib.request.urlopen(req, timeout=10) as resp:
39
+ return json.loads(resp.read())
40
+ except urllib.error.HTTPError as exc:
41
+ if exc.code == 401:
42
+ raise PermissionError("Token expired or invalid. Run `oncecheck login` to re-authenticate.")
43
+ body = exc.read().decode(errors="replace")
44
+ raise ConnectionError(f"Scan check failed ({exc.code}): {body}")
45
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
46
+ raise ConnectionError(f"Could not reach Oncecheck server: {exc}")
@@ -0,0 +1,141 @@
1
+ """CLI authentication via localhost callback — opens browser, receives tokens."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import socket
6
+ import threading
7
+ import webbrowser
8
+ from http.server import HTTPServer, BaseHTTPRequestHandler
9
+ from typing import Optional
10
+ from urllib.parse import urlparse, parse_qs
11
+
12
+ from rich.console import Console
13
+
14
+ from .token_store import load_token, save_token, clear_token
15
+
16
+ console = Console()
17
+
18
+ WEB_APP_URL = "https://oncecheck.com"
19
+
20
+
21
+ def _find_free_port() -> int:
22
+ """Find an available port on localhost."""
23
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
24
+ s.bind(("127.0.0.1", 0))
25
+ return s.getsockname()[1]
26
+
27
+
28
+ class _CallbackHandler(BaseHTTPRequestHandler):
29
+ """Handles the OAuth callback from the web app."""
30
+
31
+ token_data: Optional[dict] = None
32
+
33
+ def do_GET(self) -> None:
34
+ parsed = urlparse(self.path)
35
+ if parsed.path != "/callback":
36
+ self.send_response(404)
37
+ self.end_headers()
38
+ return
39
+
40
+ params = parse_qs(parsed.query)
41
+ access_token = params.get("access_token", [None])[0]
42
+ refresh_token = params.get("refresh_token", [None])[0]
43
+ email = params.get("email", [None])[0]
44
+ plan = params.get("plan", ["starter"])[0]
45
+
46
+ if not access_token:
47
+ self.send_response(400)
48
+ self.send_header("Content-Type", "text/html")
49
+ self.end_headers()
50
+ self.wfile.write(b"<html><body><h2>Authentication failed.</h2>"
51
+ b"<p>No token received. Please try again.</p></body></html>")
52
+ return
53
+
54
+ # Store on the class so the caller can retrieve it
55
+ _CallbackHandler.token_data = {
56
+ "access_token": access_token,
57
+ "refresh_token": refresh_token or "",
58
+ "email": email or "",
59
+ "plan": plan,
60
+ }
61
+
62
+ self.send_response(200)
63
+ self.send_header("Content-Type", "text/html")
64
+ self.end_headers()
65
+ self.wfile.write(
66
+ b"<html><body style='font-family:system-ui;text-align:center;padding:60px'>"
67
+ b"<h2 style='color:#22c55e'>Authenticated!</h2>"
68
+ b"<p>You can close this tab and return to the terminal.</p>"
69
+ b"</body></html>"
70
+ )
71
+
72
+ # Shutdown the server in a separate thread to avoid deadlock
73
+ threading.Thread(target=self.server.shutdown, daemon=True).start()
74
+
75
+ def log_message(self, format: str, *args: object) -> None:
76
+ """Suppress default HTTP logging."""
77
+ pass
78
+
79
+
80
+ def login_flow() -> dict:
81
+ """Run the browser-based login flow with localhost callback.
82
+
83
+ 1. Starts a temporary HTTP server on a free port.
84
+ 2. Opens the web app login page with a cli_redirect parameter.
85
+ 3. Waits for the browser to redirect back with tokens.
86
+ 4. Saves the tokens and returns the token data.
87
+ """
88
+ port = _find_free_port()
89
+ redirect_url = f"http://localhost:{port}/callback"
90
+ login_url = f"{WEB_APP_URL}/login?cli_redirect={redirect_url}"
91
+
92
+ _CallbackHandler.token_data = None
93
+
94
+ server = HTTPServer(("127.0.0.1", port), _CallbackHandler)
95
+
96
+ console.print()
97
+ console.print("[bold yellow]Authentication required.[/bold yellow]")
98
+ console.print("Opening your browser to sign in...\n")
99
+ console.print(f" [bold cyan]{login_url}[/bold cyan]\n")
100
+ console.print("[dim]Waiting for authentication...[/dim]")
101
+
102
+ try:
103
+ webbrowser.open(login_url)
104
+ except OSError:
105
+ console.print("[dim]Could not open browser. Please visit the URL above manually.[/dim]")
106
+
107
+ # Run server in background thread with a 5-minute timeout
108
+ server_thread = threading.Thread(target=server.serve_forever, daemon=True)
109
+ server_thread.start()
110
+ server_thread.join(timeout=300)
111
+
112
+ if server_thread.is_alive():
113
+ server.shutdown()
114
+ server.server_close()
115
+ console.print("\n[bold red]Login timed out.[/bold red] Please try again.")
116
+ raise SystemExit(1)
117
+
118
+ server.server_close()
119
+
120
+ token_data = _CallbackHandler.token_data
121
+ if not token_data or not token_data.get("access_token"):
122
+ console.print("[bold red]Authentication failed.[/bold red] No token received.")
123
+ raise SystemExit(1)
124
+
125
+ save_token(token_data)
126
+ console.print(f"[bold green]Authenticated![/bold green] ({token_data.get('email', '')} — {token_data.get('plan', 'starter').capitalize()} plan)")
127
+ return token_data
128
+
129
+
130
+ def ensure_authenticated() -> dict:
131
+ """Return token data if already authenticated, or trigger login flow."""
132
+ token = load_token()
133
+ if token and token.get("access_token"):
134
+ return token
135
+ return login_flow()
136
+
137
+
138
+ def logout() -> None:
139
+ """Clear stored credentials."""
140
+ clear_token()
141
+ console.print("[green]Logged out successfully.[/green]")
@@ -0,0 +1,85 @@
1
+ """Persistent token storage for CLI authentication."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import platform
8
+ import stat
9
+ import time
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+
14
+ APP_NAME = "oncecheck"
15
+ TOKEN_TTL_SECONDS = 7 * 24 * 3600 # 7 days
16
+
17
+
18
+ def _token_dir() -> Path:
19
+ system = platform.system()
20
+ if system == "Darwin":
21
+ base = Path.home() / "Library" / "Application Support"
22
+ elif system == "Windows":
23
+ base = Path(os.environ.get("APPDATA", str(Path.home() / "AppData" / "Roaming")))
24
+ else:
25
+ base = Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
26
+ return base / APP_NAME
27
+
28
+
29
+ def _token_path() -> Path:
30
+ return _token_dir() / "token.json"
31
+
32
+
33
+ def _set_file_permissions(path: Path, is_dir: bool = False) -> None:
34
+ """Restrict to owner-only: 0o700 for dirs, 0o600 for files."""
35
+ try:
36
+ if platform.system() != "Windows":
37
+ if is_dir:
38
+ os.chmod(path, stat.S_IRWXU) # 0o700
39
+ else:
40
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # 0o600
41
+ except OSError:
42
+ pass
43
+
44
+
45
+ def save_token(data: dict) -> Path:
46
+ """Write token data to disk with restricted permissions and return the path."""
47
+ # Stamp the token with an issued-at time for expiration checks
48
+ if "issued_at" not in data:
49
+ data["issued_at"] = time.time()
50
+
51
+ path = _token_path()
52
+ path.parent.mkdir(parents=True, exist_ok=True)
53
+
54
+ # Set restrictive permissions on directory (needs execute for traversal)
55
+ _set_file_permissions(path.parent, is_dir=True)
56
+
57
+ path.write_text(json.dumps(data, indent=2))
58
+ _set_file_permissions(path)
59
+ return path
60
+
61
+
62
+ def load_token() -> Optional[dict]:
63
+ """Load saved token, or return None if absent / invalid / expired."""
64
+ path = _token_path()
65
+ if not path.exists():
66
+ return None
67
+ try:
68
+ data = json.loads(path.read_text())
69
+ except (json.JSONDecodeError, OSError):
70
+ return None
71
+
72
+ # Check token expiration
73
+ issued_at = data.get("issued_at", 0)
74
+ if time.time() - issued_at > TOKEN_TTL_SECONDS:
75
+ clear_token()
76
+ return None
77
+
78
+ return data
79
+
80
+
81
+ def clear_token() -> None:
82
+ """Delete the saved token file."""
83
+ path = _token_path()
84
+ if path.exists():
85
+ path.unlink()