shipcheck-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.
shipcheck/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ShipCheck package."""
2
+
3
+ __version__ = "0.1.0"
shipcheck/cli.py ADDED
@@ -0,0 +1,311 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import re
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import typer
9
+ import yaml
10
+ from rich.console import Console
11
+ from rich.panel import Panel
12
+
13
+ app = typer.Typer(help="Pre-deployment health checks for software projects.")
14
+ console = Console()
15
+ PATH_ARGUMENT = typer.Argument(None, exists=True, file_okay=False, dir_okay=True)
16
+
17
+ SECRET_PATTERNS = {
18
+ "AWS access key": re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
19
+ "GitHub token": re.compile(r"\bgh[pousr]_[A-Za-z0-9_]{20,}\b"),
20
+ "Private key": re.compile(r"-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----"),
21
+ "Google API key": re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b"),
22
+ "Slack token": re.compile(r"\bxox[baprs]-[0-9A-Za-z-]{10,}\b"),
23
+ "Stripe live key": re.compile(r"\bsk_live_[0-9A-Za-z]{16,}\b"),
24
+ "Generic API key": re.compile(r"(?i)(api[_-]?key|secret[_-]?key|access[_-]?token)\s*[:=]\s*[\"']([^\"']{16,})[\"']"),
25
+ }
26
+
27
+ IGNORED_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", ".pytest_cache", "dist", "build", ".mypy_cache", ".ruff_cache"}
28
+ SCANNABLE_SUFFIXES = {".py", ".js", ".jsx", ".ts", ".tsx", ".json", ".yaml", ".yml", ".toml", ".ini", ".env", ".txt", ".cfg", ".conf"}
29
+ PLACEHOLDER_VALUES = {"example-placeholder", "changeme", "change-me", "your-api-key", "your-secret-key", "replace-me"}
30
+
31
+ CHECK_WEIGHTS = {
32
+ "Project directory": 5,
33
+ "Git repository": 10,
34
+ "Git working tree": 10,
35
+ "Framework detection": 5,
36
+ "README": 5,
37
+ ".gitignore": 10,
38
+ "Environment configuration": 10,
39
+ "Dependency manifest": 10,
40
+ "Deployment config": 10,
41
+ "Provider validation": 5,
42
+ "Tests": 10,
43
+ "Secrets scan": 10,
44
+ }
45
+ DEFAULT_THRESHOLD = 80
46
+
47
+
48
+ def _git_clean(path: Path) -> str:
49
+ try:
50
+ result = subprocess.run(["git", "-C", str(path), "status", "--porcelain"], capture_output=True, text=True, timeout=5, check=False)
51
+ except (OSError, subprocess.SubprocessError):
52
+ return "WARN"
53
+ if result.returncode != 0:
54
+ return "WARN"
55
+ return "PASS" if not result.stdout.strip() else "WARN"
56
+
57
+
58
+ def _has_dependency_manifest(path: Path) -> str:
59
+ manifests = ("pyproject.toml", "requirements.txt", "poetry.lock", "uv.lock", "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "go.mod", "go.sum", "Cargo.toml", "Cargo.lock")
60
+ return "PASS" if any((path / name).exists() for name in manifests) else "WARN"
61
+
62
+
63
+ def _has_tests(path: Path) -> str:
64
+ if any((path / name).is_dir() for name in ("tests", "test", "spec")):
65
+ return "PASS"
66
+ return "PASS" if any(p.name.startswith(("test_", "spec_")) for p in path.rglob("*") if p.is_file()) else "WARN"
67
+
68
+
69
+ def _framework(path: Path) -> str:
70
+ if (path / "manage.py").exists():
71
+ return "Django"
72
+ if (path / "pyproject.toml").exists() or (path / "requirements.txt").exists():
73
+ try:
74
+ files = [p for p in (path / "pyproject.toml", path / "requirements.txt") if p.exists()]
75
+ text = "\n".join(p.read_text(encoding="utf-8", errors="ignore") for p in files).lower()
76
+ for name, label in (("fastapi", "FastAPI"), ("flask", "Flask"), ("django", "Django")):
77
+ if name in text:
78
+ return label
79
+ except OSError:
80
+ pass
81
+ return "Python"
82
+ package = path / "package.json"
83
+ if package.exists():
84
+ try:
85
+ data = json.loads(package.read_text(encoding="utf-8", errors="ignore"))
86
+ deps = {**data.get("dependencies", {}), **data.get("devDependencies", {})}
87
+ for name, label in (("next", "Next.js"), ("react", "React"), ("vue", "Vue"), ("express", "Express")):
88
+ if name in deps:
89
+ return label
90
+ except (OSError, json.JSONDecodeError):
91
+ pass
92
+ return "Node.js"
93
+ return "Unknown"
94
+
95
+
96
+ def _env_status(path: Path) -> str:
97
+ env = path / ".env"
98
+ template = next((path / name for name in (".env.example", ".env.template") if (path / name).exists()), None)
99
+ if not env.exists() and template is None:
100
+ return "WARN"
101
+ if not env.exists():
102
+ return "WARN"
103
+ if template is None:
104
+ return "PASS"
105
+ try:
106
+ env_keys = {line.split("=", 1)[0].strip() for line in env.read_text(errors="ignore").splitlines() if "=" in line and line.strip() and not line.lstrip().startswith("#")}
107
+ template_keys = {line.split("=", 1)[0].strip() for line in template.read_text(errors="ignore").splitlines() if "=" in line and line.strip() and not line.lstrip().startswith("#")}
108
+ return "PASS" if template_keys <= env_keys else "WARN"
109
+ except OSError:
110
+ return "WARN"
111
+
112
+
113
+ def _deployment_files(path: Path) -> str:
114
+ return "PASS" if _deployment_provider(path) != "Unknown" else "WARN"
115
+
116
+
117
+ def _deployment_provider(path: Path) -> str:
118
+ if (path / "vercel.json").exists() or (path / ".vercel").is_dir():
119
+ return "Vercel"
120
+ if any((path / name).exists() for name in ("Dockerfile", "docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml")):
121
+ return "Docker"
122
+ if (path / "Procfile").exists():
123
+ return "Procfile-compatible"
124
+ workflows = path / ".github" / "workflows"
125
+ if workflows.is_dir() and any(p.suffix in {".yml", ".yaml"} for p in workflows.iterdir() if p.is_file()):
126
+ return "GitHub Actions"
127
+ return "Unknown"
128
+
129
+
130
+ def _read_text(path: Path) -> str | None:
131
+ try:
132
+ return path.read_text(encoding="utf-8", errors="ignore")
133
+ except OSError:
134
+ return None
135
+
136
+
137
+ def _load_yaml_mapping(path: Path) -> dict | None:
138
+ text = _read_text(path)
139
+ if text is None:
140
+ return None
141
+ try:
142
+ data = yaml.safe_load(text)
143
+ except yaml.YAMLError:
144
+ return None
145
+ return data if isinstance(data, dict) else None
146
+
147
+
148
+ def _validate_vercel(path: Path) -> str:
149
+ config = path / "vercel.json"
150
+ if not config.exists():
151
+ return "WARN"
152
+ try:
153
+ data = json.loads(config.read_text(encoding="utf-8"))
154
+ except (OSError, json.JSONDecodeError):
155
+ return "FAIL"
156
+ return "PASS" if isinstance(data, dict) else "FAIL"
157
+
158
+
159
+ def _validate_docker(path: Path) -> str:
160
+ dockerfile = path / "Dockerfile"
161
+ if dockerfile.exists():
162
+ text = _read_text(dockerfile)
163
+ if text is None:
164
+ return "WARN"
165
+ return "PASS" if re.search(r"(?m)^\s*FROM\s+\S+", text) else "FAIL"
166
+ compose = next((path / name for name in ("docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml") if (path / name).exists()), None)
167
+ if compose is None:
168
+ return "WARN"
169
+ data = _load_yaml_mapping(compose)
170
+ return "PASS" if data is not None and isinstance(data.get("services"), dict) and data["services"] else "FAIL"
171
+
172
+
173
+ def _validate_github_actions(path: Path) -> str:
174
+ workflows = path / ".github" / "workflows"
175
+ files = [p for p in workflows.iterdir() if p.is_file() and p.suffix in {".yml", ".yaml"}] if workflows.is_dir() else []
176
+ if not files:
177
+ return "WARN"
178
+ for workflow in files:
179
+ data = _load_yaml_mapping(workflow)
180
+ if data is None:
181
+ return "FAIL"
182
+ if not isinstance(data.get("name"), str) or not data.get("name", "").strip():
183
+ return "FAIL"
184
+ if "on" not in data and True not in data:
185
+ return "FAIL"
186
+ jobs = data.get("jobs")
187
+ if not isinstance(jobs, dict) or not jobs:
188
+ return "FAIL"
189
+ return "PASS"
190
+
191
+
192
+ def _provider_validation(path: Path, provider: str) -> str:
193
+ if provider == "Vercel":
194
+ return _validate_vercel(path)
195
+ if provider == "Docker":
196
+ return _validate_docker(path)
197
+ if provider == "GitHub Actions":
198
+ return _validate_github_actions(path)
199
+ if provider == "Procfile-compatible":
200
+ procfile = path / "Procfile"
201
+ text = _read_text(procfile)
202
+ return "PASS" if text and any(re.match(r"^\s*[A-Za-z][A-Za-z0-9_-]*\s*:", line) for line in text.splitlines()) else "FAIL"
203
+ return "WARN"
204
+
205
+
206
+ def _secret_findings(path: Path) -> list[str]:
207
+ findings: list[str] = []
208
+ seen: set[tuple[str, str]] = set()
209
+ for file in path.rglob("*"):
210
+ if not file.is_file() or any(part in IGNORED_DIRS for part in file.parts) or file.suffix.lower() not in SCANNABLE_SUFFIXES:
211
+ continue
212
+ try:
213
+ text = file.read_text(encoding="utf-8", errors="ignore")
214
+ except OSError:
215
+ continue
216
+ for label, pattern in SECRET_PATTERNS.items():
217
+ match = pattern.search(text)
218
+ if match and not (label == "Generic API key" and match.group(2).strip().lower() in PLACEHOLDER_VALUES):
219
+ finding = (str(file.relative_to(path)), label)
220
+ if finding not in seen:
221
+ findings.append(f"{finding[0]}: {finding[1]}")
222
+ seen.add(finding)
223
+ return findings
224
+
225
+
226
+ def check_project(path: Path) -> list[tuple[str, str]]:
227
+ provider = _deployment_provider(path)
228
+ return [
229
+ ("Project directory", "PASS" if path.is_dir() else "FAIL"),
230
+ ("Git repository", "PASS" if (path / ".git").exists() else "WARN"),
231
+ ("Git working tree", _git_clean(path) if (path / ".git").exists() else "WARN"),
232
+ ("Framework detection", "PASS" if _framework(path) != "Unknown" else "WARN"),
233
+ ("README", "PASS" if any((path / name).exists() for name in ("README.md", "README.rst", "README")) else "WARN"),
234
+ (".gitignore", "PASS" if (path / ".gitignore").exists() else "WARN"),
235
+ ("Environment configuration", _env_status(path)),
236
+ ("Dependency manifest", _has_dependency_manifest(path)),
237
+ ("Deployment config", _deployment_files(path)),
238
+ ("Provider validation", _provider_validation(path, provider)),
239
+ ("Tests", _has_tests(path)),
240
+ ("Secrets scan", "FAIL" if _secret_findings(path) else "PASS"),
241
+ ]
242
+
243
+
244
+ def calculate_score(checks: list[tuple[str, str]]) -> int:
245
+ total = sum(CHECK_WEIGHTS.get(name, 0) for name, _ in checks)
246
+ earned = sum(CHECK_WEIGHTS.get(name, 0) for name, status in checks if status == "PASS")
247
+ return round((earned / total) * 100) if total else 0
248
+
249
+
250
+ def is_deployable(checks: list[tuple[str, str]], score: int | None = None, threshold: int = DEFAULT_THRESHOLD) -> bool:
251
+ if any(status == "FAIL" for _, status in checks):
252
+ return False
253
+ return (calculate_score(checks) if score is None else score) >= threshold
254
+
255
+
256
+ def _configured_threshold(path: Path) -> int:
257
+ config = path / ".shipcheck.toml"
258
+ if not config.exists():
259
+ return DEFAULT_THRESHOLD
260
+ try:
261
+ for line in config.read_text(encoding="utf-8", errors="ignore").splitlines():
262
+ if line.strip().startswith("threshold") and "=" in line:
263
+ value = int(line.split("=", 1)[1].strip())
264
+ if 0 <= value <= 100:
265
+ return value
266
+ except (OSError, ValueError):
267
+ pass
268
+ return DEFAULT_THRESHOLD
269
+
270
+
271
+ @app.command()
272
+ def scan(
273
+ path: Path | None = PATH_ARGUMENT,
274
+ json_output: bool = typer.Option(False, "--json", help="Print machine-readable JSON."),
275
+ gate: bool = typer.Option(False, "--gate", help="Exit with code 1 when the deployment gate fails."),
276
+ threshold: int | None = typer.Option(None, min=0, max=100, help="Minimum readiness score required by --gate."),
277
+ ) -> None:
278
+ """Scan PATH and report deployment readiness checks."""
279
+ path = (path or Path(".")).resolve()
280
+ checks = check_project(path)
281
+ secrets = _secret_findings(path)
282
+ score = calculate_score(checks)
283
+ configured_threshold = _configured_threshold(path)
284
+ effective_threshold = configured_threshold if threshold is None else threshold
285
+ provider = _deployment_provider(path)
286
+ deployable = is_deployable(checks, score, effective_threshold)
287
+ payload = {"project": path.name, "framework": _framework(path), "deployment_provider": provider, "score": score, "threshold": effective_threshold, "deployable": deployable, "checks": [{"name": n, "status": s} for n, s in checks], "secret_findings": secrets}
288
+
289
+ if json_output:
290
+ typer.echo(json.dumps(payload, indent=2))
291
+ else:
292
+ console.print(Panel.fit("[bold]ShipCheck[/bold]\nPre-deployment health check"))
293
+ console.print(f"\n[bold]Project:[/bold] {path.name}")
294
+ console.print(f"[bold]Framework:[/bold] {_framework(path)}")
295
+ console.print(f"[bold]Deployment target:[/bold] {provider}\n")
296
+ for name, status in checks:
297
+ icon = {"PASS": "[green]✓[/green]", "WARN": "[yellow]⚠[/yellow]", "FAIL": "[red]✗[/red]"}[status]
298
+ console.print(f" {icon} {name}")
299
+ if secrets:
300
+ console.print("\n[bold red]Potential secrets:[/bold red]")
301
+ for finding in secrets[:10]:
302
+ console.print(f" [red]•[/red] {finding}")
303
+ verdict = "READY TO DEPLOY" if deployable else "BLOCKED"
304
+ console.print(f"\n[bold]Deployment Readiness:[/bold] {score}% / {effective_threshold}% — {verdict}")
305
+
306
+ if gate and not deployable:
307
+ raise typer.Exit(code=1)
308
+
309
+
310
+ if __name__ == "__main__":
311
+ app()
@@ -0,0 +1,118 @@
1
+ Metadata-Version: 2.5
2
+ Name: shipcheck-cli
3
+ Version: 0.1.0
4
+ Summary: Pre-deployment health checks for software projects.
5
+ Author: Faruk Islam
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: pyyaml>=6.0
10
+ Requires-Dist: rich>=13.7
11
+ Requires-Dist: typer>=0.12
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=8.0; extra == 'dev'
14
+ Requires-Dist: ruff>=0.6; extra == 'dev'
15
+ Description-Content-Type: text/markdown
16
+
17
+ # ShipCheck
18
+
19
+ > Pre-deployment health checks for modern software projects.
20
+
21
+ ShipCheck is a developer-first CLI that scans a project before deployment and highlights configuration problems, missing files, exposed secrets, dependency issues, and other deployment risks.
22
+
23
+ ## Status
24
+
25
+ 🚀 v0.1.0 — first public release.
26
+
27
+ ## What it checks
28
+
29
+ - Git repository and working-tree status
30
+ - Framework detection
31
+ - `.gitignore` and environment configuration
32
+ - Secret detection
33
+ - Dependency manifests
34
+ - Deployment configuration
35
+ - Provider-specific deployment configuration
36
+ - Tests and CI/CD signals
37
+ - Project documentation
38
+
39
+ ## Installation
40
+
41
+ ### From PyPI
42
+
43
+ ```bash
44
+ python -m pip install shipcheck-cli
45
+ ```
46
+
47
+ ### From source
48
+
49
+ ```bash
50
+ git clone https://github.com/farukislamyt/ShipCheck.git
51
+ cd ShipCheck
52
+ python -m pip install -e ".[dev]"
53
+ ```
54
+
55
+ ## Usage
56
+
57
+ Run a readiness report:
58
+
59
+ ```bash
60
+ shipcheck .
61
+ ```
62
+
63
+ Machine-readable JSON output:
64
+
65
+ ```bash
66
+ shipcheck . --json
67
+ ```
68
+
69
+ Use the deployment gate in CI/CD. It exits with status `1` when a check fails or the readiness score is below the configured threshold:
70
+
71
+ ```bash
72
+ shipcheck . --gate
73
+ ```
74
+
75
+ Override the default readiness threshold of 80:
76
+
77
+ ```bash
78
+ shipcheck . --gate --threshold 90
79
+ ```
80
+
81
+ ## Configuration
82
+
83
+ Create `.shipcheck.toml` in the project root to persist the readiness threshold:
84
+
85
+ ```toml
86
+ [shipcheck]
87
+ threshold = 80
88
+ ```
89
+
90
+ The CLI `--threshold` option takes precedence over the configuration file.
91
+
92
+ ## Release process
93
+
94
+ Releases are tag-driven. The GitHub Actions release workflow validates that the tag version matches `pyproject.toml`, builds the package, runs `twine check`, creates a GitHub Release, and publishes distributions to PyPI using trusted publishing.
95
+
96
+ For example:
97
+
98
+ ```bash
99
+ git tag v0.1.0
100
+ git push origin v0.1.0
101
+ ```
102
+
103
+ See [CHANGELOG.md](CHANGELOG.md) for release history.
104
+
105
+ ## Development
106
+
107
+ Install development dependencies and run the checks locally:
108
+
109
+ ```bash
110
+ python -m pip install -e ".[dev]"
111
+ ruff check .
112
+ pytest -q
113
+ python -m build
114
+ ```
115
+
116
+ ## License
117
+
118
+ MIT License.
@@ -0,0 +1,7 @@
1
+ shipcheck/__init__.py,sha256=_CAD3sbwQqWV4ArBjeQ-yYUMVYYb4FH4EcP7TiJ4WJw,48
2
+ shipcheck/cli.py,sha256=Rt00GjDqjILxzib1CI6KGmaoHBMiFl-bVfm09us3tV0,13012
3
+ shipcheck_cli-0.1.0.dist-info/METADATA,sha256=GVre5yrEVIroqmXYFOQcK4z09vMx9Ja7S2XIIJNJ5OM,2426
4
+ shipcheck_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
5
+ shipcheck_cli-0.1.0.dist-info/entry_points.txt,sha256=hPvP0C78kMUgWy7iws-1_BhT8rVdtyCr3zK3A0IxraU,48
6
+ shipcheck_cli-0.1.0.dist-info/licenses/LICENSE,sha256=HpBtwZGjwUwYiTZjCczMem_J03OZM5xTuvbIgm1cq4I,1068
7
+ shipcheck_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ shipcheck = shipcheck.cli:app
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Faruk Islam
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.