bootsec 0.8.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.
Files changed (79) hide show
  1. bootsec/__init__.py +1 -0
  2. bootsec/checks.py +174 -0
  3. bootsec/cli.py +371 -0
  4. bootsec/i18n.py +114 -0
  5. bootsec/loader.py +27 -0
  6. bootsec/merge.py +26 -0
  7. bootsec/packs.py +297 -0
  8. bootsec/profiles.py +51 -0
  9. bootsec/renderer.py +281 -0
  10. bootsec/signals.py +191 -0
  11. bootsec/templates/__init__.py +0 -0
  12. bootsec/templates/common/__init__.py +0 -0
  13. bootsec/templates/common/checklist.md.tmpl +23 -0
  14. bootsec/templates/common/env.example.tmpl +16 -0
  15. bootsec/templates/common/github-actions-security.yml.tmpl +28 -0
  16. bootsec/templates/common/gitignore.block.tmpl +35 -0
  17. bootsec/templates/common/pre-commit-config.yaml.tmpl +10 -0
  18. bootsec/templates/common/pre-commit-with-secret-scanner.yaml.tmpl +15 -0
  19. bootsec/templates/common/security.md.tmpl +43 -0
  20. bootsec/templates/dotnet/gitignore.extra.tmpl +29 -0
  21. bootsec/templates/flutter/__init__.py +0 -0
  22. bootsec/templates/flutter/gitignore.extra.tmpl +19 -0
  23. bootsec/templates/go/checklist.extra.tmpl +13 -0
  24. bootsec/templates/go/gitignore.extra.tmpl +10 -0
  25. bootsec/templates/java/checklist.extra.tmpl +13 -0
  26. bootsec/templates/java/gitignore.extra.tmpl +30 -0
  27. bootsec/templates/node/__init__.py +0 -0
  28. bootsec/templates/node/checklist.extra.tmpl +13 -0
  29. bootsec/templates/node/gitignore.extra.tmpl +17 -0
  30. bootsec/templates/packs/api-backend/checklist.md.tmpl +12 -0
  31. bootsec/templates/packs/api-backend/security.md.tmpl +3 -0
  32. bootsec/templates/packs/cli-tool/checklist.md.tmpl +12 -0
  33. bootsec/templates/packs/cli-tool/security.md.tmpl +6 -0
  34. bootsec/templates/packs/core-baseline/checklist.md.tmpl +18 -0
  35. bootsec/templates/packs/core-baseline/security.md.tmpl +7 -0
  36. bootsec/templates/packs/enterprise-lite/baseline.md.tmpl +36 -0
  37. bootsec/templates/packs/enterprise-lite/checklist.md.tmpl +10 -0
  38. bootsec/templates/packs/enterprise-lite/evidence.md.tmpl +31 -0
  39. bootsec/templates/packs/enterprise-lite/security.md.tmpl +3 -0
  40. bootsec/templates/packs/global-baseline/baseline.md.tmpl +36 -0
  41. bootsec/templates/packs/global-baseline/checklist.md.tmpl +10 -0
  42. bootsec/templates/packs/global-baseline/evidence.md.tmpl +25 -0
  43. bootsec/templates/packs/global-baseline/security.md.tmpl +3 -0
  44. bootsec/templates/packs/health-data/baseline.md.tmpl +26 -0
  45. bootsec/templates/packs/health-data/checklist.md.tmpl +13 -0
  46. bootsec/templates/packs/health-data/evidence.md.tmpl +11 -0
  47. bootsec/templates/packs/health-data/security.md.tmpl +3 -0
  48. bootsec/templates/packs/mobile-app/checklist.md.tmpl +13 -0
  49. bootsec/templates/packs/mobile-app/security.md.tmpl +3 -0
  50. bootsec/templates/packs/payments/baseline.md.tmpl +26 -0
  51. bootsec/templates/packs/payments/checklist.md.tmpl +13 -0
  52. bootsec/templates/packs/payments/evidence.md.tmpl +11 -0
  53. bootsec/templates/packs/payments/security.md.tmpl +3 -0
  54. bootsec/templates/packs/pii/checklist.md.tmpl +14 -0
  55. bootsec/templates/packs/pii/security.md.tmpl +6 -0
  56. bootsec/templates/packs/sa-baseline/baseline.md.tmpl +33 -0
  57. bootsec/templates/packs/sa-baseline/checklist.md.tmpl +10 -0
  58. bootsec/templates/packs/sa-baseline/evidence.md.tmpl +25 -0
  59. bootsec/templates/packs/sa-baseline/security.md.tmpl +3 -0
  60. bootsec/templates/packs/startup-saas/checklist.md.tmpl +12 -0
  61. bootsec/templates/packs/startup-saas/security.md.tmpl +3 -0
  62. bootsec/templates/packs/web-app/checklist.md.tmpl +13 -0
  63. bootsec/templates/packs/web-app/security.md.tmpl +6 -0
  64. bootsec/templates/php/checklist.extra.tmpl +13 -0
  65. bootsec/templates/php/gitignore.extra.tmpl +18 -0
  66. bootsec/templates/python/__init__.py +0 -0
  67. bootsec/templates/python/checklist.extra.tmpl +13 -0
  68. bootsec/templates/python/gitignore.extra.tmpl +29 -0
  69. bootsec/templates/ruby/checklist.extra.tmpl +13 -0
  70. bootsec/templates/ruby/gitignore.extra.tmpl +34 -0
  71. bootsec/templates/rust/checklist.extra.tmpl +13 -0
  72. bootsec/templates/rust/gitignore.extra.tmpl +5 -0
  73. bootsec/ui.py +433 -0
  74. bootsec-0.8.0.dist-info/METADATA +145 -0
  75. bootsec-0.8.0.dist-info/RECORD +79 -0
  76. bootsec-0.8.0.dist-info/WHEEL +4 -0
  77. bootsec-0.8.0.dist-info/entry_points.txt +2 -0
  78. bootsec-0.8.0.dist-info/licenses/LICENSE +21 -0
  79. bootsec-0.8.0.dist-info/licenses/NOTICE +30 -0
bootsec/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.7.0"
bootsec/checks.py ADDED
@@ -0,0 +1,174 @@
1
+ """Security checks for bootsec (free version).
2
+
3
+ FAST mode only - for guard command.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ # Limits
12
+ FILE_READ_CAP = 64 * 1024 # 64KB per file
13
+
14
+ # Directories to always skip
15
+ SKIP_DIRS = {".git", "node_modules", ".venv", "venv", "dist", "build", ".dart_tool", "__pycache__", ".next", ".nuxt"}
16
+
17
+ # Signal files for fast mode
18
+ SIGNAL_FILES = [
19
+ "package.json", "package-lock.json", "pnpm-lock.yaml", "yarn.lock",
20
+ "pyproject.toml", "requirements.txt", "poetry.lock",
21
+ "pubspec.yaml", "pubspec.lock",
22
+ "README.md",
23
+ ]
24
+
25
+ # Risky patterns (conservative for fast mode)
26
+ ENV_FILE_PATTERN = re.compile(r"^\.env(\..+)?$")
27
+ PRIVATE_KEY_PATTERN = re.compile(r"-----BEGIN\s+(RSA|DSA|EC|OPENSSH|PGP|ENCRYPTED)?\s*PRIVATE\s+KEY-----")
28
+
29
+ # Core secret patterns for fast mode
30
+ SECRET_PATTERNS = [
31
+ # AWS
32
+ (re.compile(r"AKIA[0-9A-Z]{16}"), "AWS Access Key", "high", 25),
33
+ # GitHub
34
+ (re.compile(r"ghp_[a-zA-Z0-9]{36}"), "GitHub PAT", "high", 25),
35
+ (re.compile(r"github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}"), "GitHub PAT (fine-grained)", "high", 25),
36
+ # Stripe
37
+ (re.compile(r"sk_live_[a-zA-Z0-9]{24,}"), "Stripe Live Key", "high", 25),
38
+ # Slack
39
+ (re.compile(r"xox[baprs]-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*"), "Slack Token", "high", 25),
40
+ # Database URLs
41
+ (re.compile(r"(?i)(mongodb(\+srv)?|postgres(ql)?|mysql|redis)://[^:\s]+:[^@\s]+@[^\s]+"), "Database URL with credentials", "high", 25),
42
+ # Private keys
43
+ (re.compile(r"-----BEGIN\s+(RSA|DSA|EC|OPENSSH)?\s*PRIVATE\s+KEY-----"), "Private Key", "high", 25),
44
+ ]
45
+
46
+ # False positive contexts
47
+ FALSE_POSITIVE_CONTEXTS = [
48
+ re.compile(r"(?i)(example|sample|test|dummy|fake|placeholder|your[_-]?|my[_-]?|xxx|replace[_-]?me)"),
49
+ re.compile(r"<[A-Z_]+>"),
50
+ re.compile(r"\$\{[^}]+\}"),
51
+ re.compile(r"\{\{[^}]+\}\}"),
52
+ ]
53
+
54
+
55
+ @dataclass
56
+ class Finding:
57
+ name: str
58
+ severity: str # "high", "med", "low"
59
+ points: int
60
+ source: str = ""
61
+
62
+
63
+ @dataclass
64
+ class CheckResult:
65
+ mode: str # "fast"
66
+ findings: list[Finding] = field(default_factory=list)
67
+ files_scanned: int = 0
68
+ bytes_scanned: int = 0
69
+
70
+ @property
71
+ def score(self) -> int:
72
+ total = sum(f.points for f in self.findings)
73
+ return max(0, min(100, 100 - total))
74
+
75
+ @property
76
+ def high_count(self) -> int:
77
+ return sum(1 for f in self.findings if f.severity == "high")
78
+
79
+ @property
80
+ def med_count(self) -> int:
81
+ return sum(1 for f in self.findings if f.severity == "med")
82
+
83
+ @property
84
+ def low_count(self) -> int:
85
+ return sum(1 for f in self.findings if f.severity == "low")
86
+
87
+
88
+ def fast_checks(directory: Path) -> CheckResult:
89
+ """Fast mode: signal files only, high-confidence issues."""
90
+ result = CheckResult(mode="fast")
91
+
92
+ # Check for .env files in root
93
+ for item in directory.iterdir():
94
+ if item.is_file() and ENV_FILE_PATTERN.match(item.name):
95
+ if item.name != ".env.example":
96
+ result.findings.append(Finding(
97
+ f".env file tracked: {item.name}",
98
+ "high", 25, item.name
99
+ ))
100
+
101
+ # Check baseline docs
102
+ _check_baseline_docs(directory, result)
103
+
104
+ # Check staged files for secrets
105
+ _check_staged_secrets(directory, result)
106
+
107
+ return result
108
+
109
+
110
+ def _check_baseline_docs(directory: Path, result: CheckResult) -> None:
111
+ """Check for missing baseline docs."""
112
+ checks = [
113
+ (".gitignore", "gitignore", "high", 20),
114
+ ("SECURITY.md", "security policy", "high", 20),
115
+ (".pre-commit-config.yaml", "commit guard", "med", 8),
116
+ ]
117
+
118
+ for path, name, severity, points in checks:
119
+ fpath = directory / path
120
+ if not fpath.exists():
121
+ result.findings.append(Finding(name, severity, points, path))
122
+
123
+
124
+ def _check_staged_secrets(directory: Path, result: CheckResult) -> None:
125
+ """Check staged files for secrets."""
126
+ import subprocess
127
+
128
+ try:
129
+ proc = subprocess.run(
130
+ ["git", "diff", "--cached", "--name-only"],
131
+ cwd=directory,
132
+ capture_output=True,
133
+ text=True,
134
+ timeout=5,
135
+ )
136
+ if proc.returncode != 0:
137
+ return
138
+
139
+ staged_files = [f for f in proc.stdout.strip().split("\n") if f]
140
+ except Exception:
141
+ return
142
+
143
+ for relpath in staged_files:
144
+ fpath = directory / relpath
145
+ if not fpath.exists() or not fpath.is_file():
146
+ continue
147
+
148
+ try:
149
+ content = fpath.read_text(errors="ignore")[:FILE_READ_CAP]
150
+ _check_content_secrets(content, relpath, result)
151
+ except Exception:
152
+ pass
153
+
154
+
155
+ def _check_content_secrets(content: str, filepath: str, result: CheckResult) -> None:
156
+ """Scan content for secrets."""
157
+ for pattern, name, severity, points in SECRET_PATTERNS:
158
+ matches = pattern.findall(content)
159
+ for match in matches:
160
+ match_str = match if isinstance(match, str) else match[0]
161
+
162
+ # Check for false positives
163
+ context_start = max(0, content.find(match_str) - 50)
164
+ context_end = min(len(content), content.find(match_str) + len(match_str) + 50)
165
+ context = content[context_start:context_end]
166
+
167
+ is_false_positive = any(fp.search(context) for fp in FALSE_POSITIVE_CONTEXTS)
168
+
169
+ if not is_false_positive:
170
+ result.findings.append(Finding(
171
+ f"{name} in staged file",
172
+ severity, points, filepath
173
+ ))
174
+ break # One finding per pattern per file
bootsec/cli.py ADDED
@@ -0,0 +1,371 @@
1
+ """Bootsec CLI - Free version.
2
+
3
+ Security baseline for your project. One command, you're set.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import random
9
+ import subprocess
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ from bootsec.checks import fast_checks
14
+ from bootsec.profiles import Profile, get_profile
15
+ from bootsec.renderer import detect_project_type, generate_files
16
+ from bootsec.packs import suggest_packs, select_packs_by_layer, list_packs
17
+
18
+ VERSION = "0.8.0"
19
+ BOOTSEC_DIR = ".bootsec"
20
+
21
+ # UI
22
+ BAR_WIDTH = 24
23
+ CHICK_SPLASH = "🐤"
24
+
25
+
26
+ def score_bar(score: int) -> str:
27
+ """Generate ASCII progress bar."""
28
+ filled = int((score / 100) * BAR_WIDTH)
29
+ filled = max(0, min(BAR_WIDTH, filled))
30
+ empty = BAR_WIDTH - filled
31
+ return "█" * filled + "░" * empty
32
+
33
+
34
+ def omar_comment(score: int) -> str:
35
+ """Quick feedback based on score."""
36
+ if score >= 90:
37
+ return "Beast mode."
38
+ elif score >= 75:
39
+ return "Solid."
40
+ elif score >= 60:
41
+ return "Decent. Fix the highs."
42
+ elif score >= 40:
43
+ return "Needs work."
44
+ else:
45
+ return "Fix the red items first."
46
+
47
+
48
+ def print_splash():
49
+ """Print minimal splash."""
50
+ print(f"\n {CHICK_SPLASH} bootsec v{VERSION}\n")
51
+
52
+
53
+ def print_seal():
54
+ """Print success seal."""
55
+ print(f" {CHICK_SPLASH}")
56
+
57
+
58
+ def print_header(cmd: str):
59
+ """Print minimal header."""
60
+ print(f"\n -- {cmd} --\n")
61
+
62
+
63
+ def print_help():
64
+ """Print help."""
65
+ print(f"""
66
+ bootsec v{VERSION} — security baseline for your project
67
+
68
+ Commands:
69
+ go Apply baseline docs + commit guard
70
+ guard Block commits with issues (pre-commit, <1s)
71
+ peek Preview what go would create
72
+ review Preview coverage layers
73
+ packs List all available packs
74
+
75
+ Flags:
76
+ --full Allow extra packs
77
+ --ci Include GitHub Actions
78
+
79
+ Examples:
80
+ bootsec go
81
+ bootsec go --ci
82
+ bootsec peek
83
+
84
+ ─────────────────────────────────────────────────
85
+ Want more? Deep scans, vulnerability checks, AI fixes
86
+
87
+ → Get bootsec Pro: https://bootsec.dev
88
+ ─────────────────────────────────────────────────
89
+ """)
90
+
91
+
92
+ def print_upsell():
93
+ """Show upsell message."""
94
+ print("""
95
+
96
+ 🔓 Unlock: deep scans, vuln checks, SBOM, AI fixes
97
+ → bootsec Pro: https://bootsec.dev
98
+ """)
99
+
100
+
101
+ def maybe_show_upsell():
102
+ """Show upsell 1 in 3 times."""
103
+ if random.randint(1, 3) == 1:
104
+ print_upsell()
105
+
106
+
107
+ def write_manifest(directory: Path, pack_names: list[str], project_type: str, reasons: list[str]) -> None:
108
+ """Write manifest file."""
109
+ baseline_dir = directory / "docs" / "baseline"
110
+ baseline_dir.mkdir(parents=True, exist_ok=True)
111
+
112
+ manifest = {
113
+ "version": VERSION,
114
+ "project_type": project_type,
115
+ "packs": pack_names,
116
+ "reasons": reasons,
117
+ }
118
+
119
+ manifest_path = baseline_dir / "manifest.json"
120
+ manifest_path.write_text(json.dumps(manifest, indent=2))
121
+
122
+
123
+ # ─────────────────────────────────────────────────────────────────────────────
124
+ # Commands
125
+ # ─────────────────────────────────────────────────────────────────────────────
126
+
127
+ def cmd_go(args: list[str]) -> int:
128
+ """Full setup: docs + commit guard."""
129
+ directory = Path.cwd()
130
+ full_mode = "--full" in args
131
+ include_ci = "--ci" in args
132
+
133
+ print_splash()
134
+
135
+ # Detect project type
136
+ project_type = detect_project_type(directory)
137
+ print(f" • Detected: {project_type}")
138
+
139
+ # Select packs
140
+ suggestions = suggest_packs(directory)
141
+ selection = select_packs_by_layer(suggestions, full_mode=full_mode)
142
+ pack_names = selection.all_packs()
143
+ reasons = []
144
+ for s in suggestions:
145
+ if s.pack_name in pack_names:
146
+ reasons.append(f"{s.reason} → {s.pack_name}")
147
+ print(f" • Packs: {', '.join(pack_names)}")
148
+
149
+ # Create profile
150
+ profile = Profile(
151
+ name="startup",
152
+ gitignore=True,
153
+ env_example=True,
154
+ security_md=True,
155
+ checklist=True,
156
+ pre_commit=True,
157
+ github_actions=include_ci,
158
+ with_secret_scanner=False,
159
+ )
160
+
161
+ # Generate files
162
+ results = generate_files(directory, profile, project_type, dry_run=False, pack_names=pack_names)
163
+ print(f" • Files: {len(results)} ready")
164
+
165
+ # Write manifest
166
+ write_manifest(directory, pack_names, project_type, reasons)
167
+
168
+ # Count actions
169
+ created = sum(1 for r in results if r.action == "create")
170
+ updated = sum(1 for r in results if r.action == "update")
171
+
172
+ # Install pre-commit
173
+ precommit_config = directory / ".pre-commit-config.yaml"
174
+ if precommit_config.exists():
175
+ try:
176
+ subprocess.run(
177
+ ["pre-commit", "install"],
178
+ cwd=directory,
179
+ capture_output=True,
180
+ timeout=30,
181
+ )
182
+ print(" • pre-commit installed")
183
+ except Exception:
184
+ pass
185
+
186
+ print()
187
+ print(f" Done. {created} created, {updated} updated.")
188
+ print_seal()
189
+ maybe_show_upsell()
190
+ return 0
191
+
192
+
193
+ def cmd_guard(args: list[str]) -> int:
194
+ """Pre-commit guard check."""
195
+ directory = Path.cwd()
196
+
197
+ result = fast_checks(directory)
198
+
199
+ if result.high_count > 0:
200
+ bar = score_bar(result.score)
201
+
202
+ print(f"\n ✗ Guard failed [{bar}] {result.score}")
203
+ print()
204
+
205
+ for f in result.findings[:5]:
206
+ severity_mark = "!" if f.severity == "high" else "~"
207
+ print(f" {severity_mark} {f.name}")
208
+
209
+ if len(result.findings) > 5:
210
+ print(f" ... and {len(result.findings) - 5} more")
211
+
212
+ print()
213
+ return 1
214
+ else:
215
+ print_seal()
216
+ return 0
217
+
218
+
219
+ def cmd_peek(args: list[str]) -> int:
220
+ """Preview what go would create."""
221
+ directory = Path.cwd()
222
+ full_mode = "--full" in args
223
+ include_ci = "--ci" in args
224
+
225
+ print_splash()
226
+
227
+ project_type = detect_project_type(directory)
228
+ print(f" • Stack: {project_type}")
229
+
230
+ suggestions = suggest_packs(directory)
231
+ selection = select_packs_by_layer(suggestions, full_mode=full_mode)
232
+ pack_names = selection.all_packs()
233
+ print(f" • Packs: {', '.join(pack_names)}")
234
+ print()
235
+
236
+ profile = Profile(
237
+ name="startup",
238
+ gitignore=True,
239
+ env_example=True,
240
+ security_md=True,
241
+ checklist=True,
242
+ pre_commit=True,
243
+ github_actions=include_ci,
244
+ with_secret_scanner=False,
245
+ )
246
+
247
+ results = generate_files(directory, profile, project_type, dry_run=True, pack_names=pack_names)
248
+
249
+ print(" Would create/update:")
250
+ for r in results:
251
+ print(f" [{r.action}] {r.path}")
252
+
253
+ print()
254
+ print(f" Total: {len(results)} files")
255
+ print()
256
+ maybe_show_upsell()
257
+ return 0
258
+
259
+
260
+ def cmd_review(args: list[str]) -> int:
261
+ """Preview coverage layers."""
262
+ directory = Path.cwd()
263
+ full_mode = "--full" in args
264
+
265
+ print_splash()
266
+
267
+ suggestions = suggest_packs(directory)
268
+ selection = select_packs_by_layer(suggestions, full_mode=full_mode)
269
+ pack_names = selection.all_packs()
270
+
271
+ print(" Coverage layers:\n")
272
+ for s in suggestions:
273
+ if s.pack_name in pack_names:
274
+ print(f" • {s.pack_name}")
275
+ print(f" {s.reason}")
276
+ print()
277
+
278
+ maybe_show_upsell()
279
+ return 0
280
+
281
+
282
+ def cmd_packs(args: list[str]) -> int:
283
+ """List all available packs."""
284
+ print_splash()
285
+ print(" Available packs:\n")
286
+
287
+ for pack in list_packs():
288
+ print(f" • {pack.name}")
289
+ if pack.description:
290
+ print(f" {pack.description}")
291
+
292
+ print()
293
+ maybe_show_upsell()
294
+ return 0
295
+
296
+
297
+ def cmd_version(args: list[str]) -> int:
298
+ """Show version."""
299
+ print(f"bootsec {VERSION}")
300
+ return 0
301
+
302
+
303
+ # ─────────────────────────────────────────────────────────────────────────────
304
+ # Main
305
+ # ─────────────────────────────────────────────────────────────────────────────
306
+
307
+ COMMANDS = {
308
+ "go": cmd_go,
309
+ "guard": cmd_guard,
310
+ "peek": cmd_peek,
311
+ "review": cmd_review,
312
+ "packs": cmd_packs,
313
+ "version": cmd_version,
314
+ # Aliases
315
+ "init": cmd_go,
316
+ }
317
+
318
+ # Pro commands that show upsell
319
+ PRO_COMMANDS = {"check", "scan", "deps", "sbom", "ai", "audit", "deep"}
320
+
321
+
322
+ def main() -> int:
323
+ """Main entry point."""
324
+ args = sys.argv[1:]
325
+
326
+ # Handle flags
327
+ if not args or args[0] in ("--help", "-h", "help", "hey"):
328
+ print_help()
329
+ return 0
330
+
331
+ if args[0] in ("--version", "-v", "-V"):
332
+ return cmd_version(args)
333
+
334
+ cmd = args[0].lower()
335
+ cmd_args = args[1:]
336
+
337
+ # Check for Pro command
338
+ if cmd in PRO_COMMANDS:
339
+ print(f"""
340
+ '{cmd}' is a Pro feature.
341
+
342
+ Get bootsec Pro for:
343
+ • Deep security scans
344
+ • Vulnerability detection (OSV)
345
+ • Dependency audits
346
+ • SBOM generation
347
+ • AI-powered fix suggestions
348
+
349
+ → https://bootsec.dev
350
+ """)
351
+ return 0
352
+
353
+ # Run command
354
+ if cmd in COMMANDS:
355
+ try:
356
+ return COMMANDS[cmd](cmd_args)
357
+ except KeyboardInterrupt:
358
+ print("\n Cancelled.")
359
+ return 130
360
+ except Exception as e:
361
+ print(f" Error: {e}")
362
+ return 1
363
+
364
+ # Unknown command
365
+ print(f" Unknown command: {cmd}")
366
+ print(" Run 'bootsec help' for usage.")
367
+ return 1
368
+
369
+
370
+ if __name__ == "__main__":
371
+ sys.exit(main())
bootsec/i18n.py ADDED
@@ -0,0 +1,114 @@
1
+ """Internationalization for Bootsec CLI output.
2
+
3
+ Omar voice: 27-year-old builder, nerdy, confident, friendly.
4
+ 80% clear + direct, 20% humor (max 1 line per section).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+
10
+ LANG_AR = "ar"
11
+ LANG_EN = "en"
12
+
13
+ # Phrasing library - consistent voice across all output
14
+ MESSAGES = {
15
+ # Success/status - only place for emojis
16
+ "all_good": {"en": "All good", "ar": "يا قوت"},
17
+ "almost_there": {"en": "Almost there", "ar": "قريب"},
18
+ "fix_and_retry": {"en": "Fix and try again", "ar": "أصلح وحاول مرة ثانية"},
19
+
20
+ # Score comments (Omar style - one per score range)
21
+ "score_90": {"en": "Beast mode. This is clean.", "ar": "يا وحش… هذا اسمه شغل."},
22
+ "score_75": {"en": "Solid. A couple tweaks and you're set.", "ar": "ممتاز… باقي كم لمسة وتكمل."},
23
+ "score_60": {"en": "Decent. Fix the items below.", "ar": "زين… بس سوّ اللي تحت."},
24
+ "score_40": {"en": "Needs work. We're not here to panic, just fix it.", "ar": "نحتاج نرتّب… بس لا تقلق."},
25
+ "score_0": {"en": "Hold up. We stopped you for a reason.", "ar": "وقف… وقفناك لسبب."},
26
+
27
+ # Section markers (◉ prefix)
28
+ "scan": {"en": "scan", "ar": "فحص"},
29
+ "plan": {"en": "plan", "ar": "خطة"},
30
+ "apply": {"en": "apply", "ar": "تطبيق"},
31
+ "guard": {"en": "guard", "ar": "حماية"},
32
+
33
+ # Action markers
34
+ "created": {"en": "created", "ar": "تم"},
35
+ "updated": {"en": "updated", "ar": "تحديث"},
36
+ "skipped": {"en": "skipped", "ar": "تخطّي"},
37
+
38
+ # Status messages
39
+ "commit_guard_enabled": {"en": "commit guard enabled", "ar": "حماية الكوميت مفعّلة"},
40
+ "install_failed": {"en": "install failed", "ar": "فشل التثبيت"},
41
+ "not_initialized": {"en": "Not initialized", "ar": "غير مهيّأ"},
42
+
43
+ # Labels
44
+ "detected": {"en": "Detected", "ar": "اكتشف"},
45
+ "packs": {"en": "Packs", "ar": "حزم"},
46
+ "why": {"en": "why", "ar": "لماذا"},
47
+ "suggested": {"en": "suggested", "ar": "مقترح"},
48
+ "issues": {"en": "issue(s)", "ar": "مشكلة"},
49
+ "file_s": {"en": "file(s)", "ar": "ملف"},
50
+
51
+ # Hint prefixes
52
+ "fix": {"en": "Fix", "ar": "الحل"},
53
+ "run": {"en": "Run", "ar": "شغّل"},
54
+ "warning": {"en": "heads up", "ar": "تنبيه"},
55
+
56
+ # Scan modes
57
+ "mode": {"en": "mode", "ar": "الوضع"},
58
+ "mode_fast": {"en": "fast", "ar": "سريع"},
59
+ "mode_deep": {"en": "deep", "ar": "عميق"},
60
+ }
61
+
62
+
63
+ _current_lang: str | None = None
64
+
65
+
66
+ def detect_lang() -> str:
67
+ """Detect language from environment. Priority: BOOTSEC_LANG > LANG/LC_ALL > en."""
68
+ # Check explicit setting
69
+ bootsec_lang = os.environ.get("BOOTSEC_LANG", "").lower()
70
+ if bootsec_lang in (LANG_AR, LANG_EN):
71
+ return bootsec_lang
72
+
73
+ # Check system locale
74
+ for var in ("LANG", "LC_ALL", "LC_MESSAGES"):
75
+ val = os.environ.get(var, "").lower()
76
+ if val.startswith("ar"):
77
+ return LANG_AR
78
+
79
+ return LANG_EN
80
+
81
+
82
+ def set_lang(lang: str) -> None:
83
+ """Override the detected language."""
84
+ global _current_lang
85
+ if lang in (LANG_AR, LANG_EN, "auto"):
86
+ _current_lang = None if lang == "auto" else lang
87
+
88
+
89
+ def get_lang() -> str:
90
+ """Get current language."""
91
+ if _current_lang is not None:
92
+ return _current_lang
93
+ return detect_lang()
94
+
95
+
96
+ def t(key: str) -> str:
97
+ """Translate a message key to current language."""
98
+ lang = get_lang()
99
+ msg = MESSAGES.get(key, {})
100
+ return msg.get(lang, msg.get("en", key))
101
+
102
+
103
+ def parse_lang_arg(args: list[str]) -> list[str]:
104
+ """Parse --lang flag from args and set language. Returns args without --lang."""
105
+ result = []
106
+ i = 0
107
+ while i < len(args):
108
+ if args[i] == "--lang" and i + 1 < len(args):
109
+ set_lang(args[i + 1].lower())
110
+ i += 2
111
+ else:
112
+ result.append(args[i])
113
+ i += 1
114
+ return result
bootsec/loader.py ADDED
@@ -0,0 +1,27 @@
1
+ from importlib import resources
2
+
3
+
4
+ def get_template(category: str, name: str) -> str:
5
+ try:
6
+ return resources.files("bootsec.templates").joinpath(category, name).read_text()
7
+ except FileNotFoundError:
8
+ pass
9
+
10
+ try:
11
+ return resources.files("bootsec.templates").joinpath("common", name).read_text()
12
+ except FileNotFoundError:
13
+ return ""
14
+
15
+
16
+ def render(template: str, **variables: str) -> str:
17
+ result = template
18
+ for key, value in variables.items():
19
+ result = result.replace(f"{{{{{key}}}}}", str(value))
20
+ return result
21
+
22
+
23
+ def get_pack_template(pack_name: str, name: str) -> str:
24
+ try:
25
+ return resources.files("bootsec.templates").joinpath("packs", pack_name, name).read_text()
26
+ except (FileNotFoundError, TypeError):
27
+ return ""