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
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,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.
|