kryptorious-draftguard 1.0.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.
- draftguard/__init__.py +8 -0
- draftguard/__main__.py +6 -0
- draftguard/cli.py +482 -0
- draftguard/differ.py +250 -0
- draftguard/parser.py +371 -0
- draftguard/reporters.py +354 -0
- draftguard/rules.py +515 -0
- draftguard/scanner.py +173 -0
- kryptorious_draftguard-1.0.0.dist-info/METADATA +340 -0
- kryptorious_draftguard-1.0.0.dist-info/RECORD +12 -0
- kryptorious_draftguard-1.0.0.dist-info/WHEEL +4 -0
- kryptorious_draftguard-1.0.0.dist-info/entry_points.txt +2 -0
draftguard/__init__.py
ADDED
draftguard/__main__.py
ADDED
draftguard/cli.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
"""Command-line interface for DraftGuard.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
draftguard scan [DIR] Scan directory tree for config files and compare envs
|
|
5
|
+
draftguard compare DIR1 DIR2 Compare two specific config sets
|
|
6
|
+
draftguard audit DIR [ENV] Audit a single environment for issues
|
|
7
|
+
draftguard diff DIR1 DIR2 Show detailed diff between environments
|
|
8
|
+
|
|
9
|
+
Options:
|
|
10
|
+
--envs TEXT Environments to check [default: dev,prod]
|
|
11
|
+
--format FORMAT Output format (table, json, markdown, summary)
|
|
12
|
+
--fail-on SEVERITY Exit with error if findings at or above severity
|
|
13
|
+
-o, --output FILE Write report to file
|
|
14
|
+
-r, --recursive Recurse into subdirectories [default: true]
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import List, Optional, Tuple
|
|
22
|
+
|
|
23
|
+
import click
|
|
24
|
+
|
|
25
|
+
from . import __version__
|
|
26
|
+
from .differ import Differ
|
|
27
|
+
from .parser import ConfigParser
|
|
28
|
+
from .reporters import get_reporter
|
|
29
|
+
from .rules import Severity
|
|
30
|
+
from .scanner import ConfigScanner
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _parse_envs(ctx, param, value: Optional[str]) -> List[str]:
|
|
34
|
+
"""Parse --envs comma-separated list."""
|
|
35
|
+
if not value:
|
|
36
|
+
return ["dev", "prod"]
|
|
37
|
+
return [e.strip() for e in value.split(",")]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _parse_severity(value: str) -> Severity:
|
|
41
|
+
"""Parse a severity string."""
|
|
42
|
+
mapping = {
|
|
43
|
+
"critical": Severity.CRITICAL,
|
|
44
|
+
"crit": Severity.CRITICAL,
|
|
45
|
+
"high": Severity.HIGH,
|
|
46
|
+
"medium": Severity.MEDIUM,
|
|
47
|
+
"med": Severity.MEDIUM,
|
|
48
|
+
"warning": Severity.WARNING,
|
|
49
|
+
"warn": Severity.WARNING,
|
|
50
|
+
"info": Severity.INFO,
|
|
51
|
+
}
|
|
52
|
+
v = value.lower()
|
|
53
|
+
if v not in mapping:
|
|
54
|
+
raise click.BadParameter(f"Invalid severity: {value}. "
|
|
55
|
+
f"Choose from: critical, high, medium, warning, info")
|
|
56
|
+
return mapping[v]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _print_banner() -> None:
|
|
60
|
+
"""Print the DraftGuard banner."""
|
|
61
|
+
try:
|
|
62
|
+
from rich.console import Console
|
|
63
|
+
from rich.text import Text
|
|
64
|
+
console = Console()
|
|
65
|
+
banner = Text("""╔══════════════════════════════════════════════════╗
|
|
66
|
+
║ 🔍 KRYPTORIOUS DRAFTGUARD v{} ║
|
|
67
|
+
║ Detect config drift before it causes outages ║
|
|
68
|
+
╚══════════════════════════════════════════════════╝""".format(__version__),
|
|
69
|
+
style="bold cyan")
|
|
70
|
+
console.print(banner)
|
|
71
|
+
except ImportError:
|
|
72
|
+
click.echo(f"Kryptorious DraftGuard v{__version__}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@click.group(invoke_without_command=True)
|
|
76
|
+
@click.version_option(__version__, prog_name="draftguard")
|
|
77
|
+
@click.pass_context
|
|
78
|
+
def main(ctx: click.Context) -> None:
|
|
79
|
+
"""Kryptorious DraftGuard — Environment Configuration Drift Detection.
|
|
80
|
+
|
|
81
|
+
Detect missing keys, value mismatches, type drift, default leakage,
|
|
82
|
+
and secret exposure across dev/staging/prod environments.
|
|
83
|
+
"""
|
|
84
|
+
if ctx.invoked_subcommand is None:
|
|
85
|
+
_print_banner()
|
|
86
|
+
click.echo()
|
|
87
|
+
click.echo(ctx.get_help())
|
|
88
|
+
click.echo()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@main.command("scan")
|
|
92
|
+
@click.argument("directory", type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
93
|
+
default=Path.cwd())
|
|
94
|
+
@click.option("--envs", "-e", callback=_parse_envs, default="dev,prod",
|
|
95
|
+
help="Comma-separated environments to compare (default: dev,prod)")
|
|
96
|
+
@click.option("--format", "-f", "output_format", default="table",
|
|
97
|
+
type=click.Choice(["table", "json", "markdown", "md", "summary"]),
|
|
98
|
+
help="Output format")
|
|
99
|
+
@click.option("--fail-on", default=None, help="Exit code 1 if findings at or above this severity")
|
|
100
|
+
@click.option("--output", "-o", "output_file", type=click.Path(path_type=Path),
|
|
101
|
+
help="Write report to file")
|
|
102
|
+
@click.option("--recursive/--no-recursive", "-r", default=True,
|
|
103
|
+
help="Recurse into subdirectories")
|
|
104
|
+
def scan_command(
|
|
105
|
+
directory: Path,
|
|
106
|
+
envs: List[str],
|
|
107
|
+
output_format: str,
|
|
108
|
+
fail_on: Optional[str],
|
|
109
|
+
output_file: Optional[Path],
|
|
110
|
+
recursive: bool,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Scan a directory tree for config files and compare environments.
|
|
113
|
+
|
|
114
|
+
DIR is the root directory to scan (defaults to current directory).
|
|
115
|
+
|
|
116
|
+
Examples:
|
|
117
|
+
draftguard scan .
|
|
118
|
+
draftguard scan /app --envs dev,staging,prod
|
|
119
|
+
draftguard scan . --format json -o report.json
|
|
120
|
+
"""
|
|
121
|
+
is_json = output_format == "json"
|
|
122
|
+
if not is_json:
|
|
123
|
+
_print_banner()
|
|
124
|
+
click.echo()
|
|
125
|
+
|
|
126
|
+
parser = ConfigParser()
|
|
127
|
+
scanner = ConfigScanner(parser=parser)
|
|
128
|
+
differ = Differ(parser=parser, scanner=scanner)
|
|
129
|
+
|
|
130
|
+
if not is_json:
|
|
131
|
+
click.echo(f"📂 Scanning: {directory}")
|
|
132
|
+
configs = scanner.scan(directory, recursive=recursive)
|
|
133
|
+
|
|
134
|
+
if not configs:
|
|
135
|
+
if not is_json:
|
|
136
|
+
click.echo("⚠️ No config files found.")
|
|
137
|
+
sys.exit(0)
|
|
138
|
+
|
|
139
|
+
# Group by environment
|
|
140
|
+
groups = scanner.group_by_environment(configs)
|
|
141
|
+
|
|
142
|
+
if not is_json:
|
|
143
|
+
click.echo(f" Found {len(configs)} config files across {len(groups)} environments:")
|
|
144
|
+
for env_name, cfgs in sorted(groups.items()):
|
|
145
|
+
click.echo(f" • {env_name}: {len(cfgs)} file(s) "
|
|
146
|
+
f"({', '.join(c.filepath.name for c in cfgs)})")
|
|
147
|
+
click.echo()
|
|
148
|
+
|
|
149
|
+
if len(envs) < 2:
|
|
150
|
+
click.echo("❌ Need at least 2 environments to compare. Use --envs or audit command.")
|
|
151
|
+
sys.exit(1)
|
|
152
|
+
|
|
153
|
+
# Use first two envs as source/target
|
|
154
|
+
source_env = envs[0]
|
|
155
|
+
target_env = envs[1]
|
|
156
|
+
|
|
157
|
+
source_configs = groups.get(source_env, [])
|
|
158
|
+
target_configs = groups.get(target_env, [])
|
|
159
|
+
|
|
160
|
+
if not source_configs:
|
|
161
|
+
click.echo(f"⚠️ No config files found for environment '{source_env}'")
|
|
162
|
+
click.echo(f" Available environments: {', '.join(sorted(groups.keys()))}")
|
|
163
|
+
sys.exit(1)
|
|
164
|
+
|
|
165
|
+
if not target_configs:
|
|
166
|
+
click.echo(f"⚠️ No config files found for environment '{target_env}'")
|
|
167
|
+
click.echo(f" Available environments: {', '.join(sorted(groups.keys()))}")
|
|
168
|
+
sys.exit(1)
|
|
169
|
+
|
|
170
|
+
# Run comparison
|
|
171
|
+
result = differ.compare_environments(source_env, target_env, source_configs, target_configs)
|
|
172
|
+
|
|
173
|
+
# Generate report
|
|
174
|
+
reporter = get_reporter(output_format)
|
|
175
|
+
report = reporter.report(result)
|
|
176
|
+
|
|
177
|
+
# Rich reporter prints itself; others return a string to print
|
|
178
|
+
if output_format in ("table",) and report == "":
|
|
179
|
+
pass # RichReporter printed directly
|
|
180
|
+
else:
|
|
181
|
+
click.echo(report)
|
|
182
|
+
|
|
183
|
+
if output_file:
|
|
184
|
+
output_file.write_text(report, encoding="utf-8")
|
|
185
|
+
click.echo(f"\n✅ Report written to: {output_file}")
|
|
186
|
+
|
|
187
|
+
# Determine exit code
|
|
188
|
+
exit_code = 0
|
|
189
|
+
if fail_on:
|
|
190
|
+
threshold = _parse_severity(fail_on)
|
|
191
|
+
has_failures = any(
|
|
192
|
+
Severity(sev).level >= threshold.level
|
|
193
|
+
for sev in result.summary
|
|
194
|
+
if result.summary.get(sev, 0) > 0
|
|
195
|
+
)
|
|
196
|
+
if has_failures:
|
|
197
|
+
exit_code = 1
|
|
198
|
+
|
|
199
|
+
if exit_code != 0:
|
|
200
|
+
sys.exit(exit_code)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@main.command("compare")
|
|
204
|
+
@click.argument("dir1", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
|
205
|
+
@click.argument("dir2", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
|
206
|
+
@click.option("--envs", "-e", callback=_parse_envs, default=None,
|
|
207
|
+
help="Environment labels for the two directories (default: inferred from paths)")
|
|
208
|
+
@click.option("--format", "-f", "output_format", default="table",
|
|
209
|
+
type=click.Choice(["table", "json", "markdown", "md", "summary"]),
|
|
210
|
+
help="Output format")
|
|
211
|
+
@click.option("--fail-on", default=None, help="Exit code 1 if findings at or above this severity")
|
|
212
|
+
@click.option("--output", "-o", "output_file", type=click.Path(path_type=Path),
|
|
213
|
+
help="Write report to file")
|
|
214
|
+
def compare_command(
|
|
215
|
+
dir1: Path,
|
|
216
|
+
dir2: Path,
|
|
217
|
+
envs: Optional[List[str]],
|
|
218
|
+
output_format: str,
|
|
219
|
+
fail_on: Optional[str],
|
|
220
|
+
output_file: Optional[Path],
|
|
221
|
+
) -> None:
|
|
222
|
+
"""Compare config files from two directories.
|
|
223
|
+
|
|
224
|
+
DIR1 and DIR2 are paths to environment config directories.
|
|
225
|
+
|
|
226
|
+
Examples:
|
|
227
|
+
draftguard compare ./envs/dev ./envs/prod
|
|
228
|
+
draftguard compare ./dev ./staging --envs development,staging
|
|
229
|
+
"""
|
|
230
|
+
is_json = output_format == "json"
|
|
231
|
+
if not is_json:
|
|
232
|
+
_print_banner()
|
|
233
|
+
click.echo()
|
|
234
|
+
|
|
235
|
+
if envs and len(envs) >= 2:
|
|
236
|
+
source_env, target_env = envs[0], envs[1]
|
|
237
|
+
else:
|
|
238
|
+
source_env = dir1.name
|
|
239
|
+
target_env = dir2.name
|
|
240
|
+
|
|
241
|
+
parser = ConfigParser()
|
|
242
|
+
scanner = ConfigScanner(parser=parser)
|
|
243
|
+
differ = Differ(parser=parser, scanner=scanner)
|
|
244
|
+
|
|
245
|
+
if not is_json:
|
|
246
|
+
click.echo(f"📂 Scanning source: {dir1}")
|
|
247
|
+
source_configs = scanner.scan(dir1, recursive=True)
|
|
248
|
+
if not is_json:
|
|
249
|
+
click.echo(f" Found {len(source_configs)} config file(s)")
|
|
250
|
+
|
|
251
|
+
if not is_json:
|
|
252
|
+
click.echo(f"📂 Scanning target: {dir2}")
|
|
253
|
+
target_configs = scanner.scan(dir2, recursive=True)
|
|
254
|
+
if not is_json:
|
|
255
|
+
click.echo(f" Found {len(target_configs)} config file(s)")
|
|
256
|
+
click.echo()
|
|
257
|
+
|
|
258
|
+
if not source_configs or not target_configs:
|
|
259
|
+
click.echo("❌ Both directories must contain config files.")
|
|
260
|
+
sys.exit(1)
|
|
261
|
+
|
|
262
|
+
result = differ.compare_environments(source_env, target_env, source_configs, target_configs)
|
|
263
|
+
|
|
264
|
+
reporter = get_reporter(output_format)
|
|
265
|
+
report = reporter.report(result)
|
|
266
|
+
|
|
267
|
+
if output_format in ("table",) and report == "":
|
|
268
|
+
pass
|
|
269
|
+
else:
|
|
270
|
+
click.echo(report)
|
|
271
|
+
|
|
272
|
+
if output_file:
|
|
273
|
+
output_file.write_text(report, encoding="utf-8")
|
|
274
|
+
click.echo(f"\n✅ Report written to: {output_file}")
|
|
275
|
+
|
|
276
|
+
exit_code = 0
|
|
277
|
+
if fail_on:
|
|
278
|
+
threshold = _parse_severity(fail_on)
|
|
279
|
+
has_failures = any(
|
|
280
|
+
Severity(sev).level >= threshold.level
|
|
281
|
+
for sev in result.summary
|
|
282
|
+
if result.summary.get(sev, 0) > 0
|
|
283
|
+
)
|
|
284
|
+
if has_failures:
|
|
285
|
+
exit_code = 1
|
|
286
|
+
|
|
287
|
+
if exit_code != 0:
|
|
288
|
+
sys.exit(exit_code)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@main.command("audit")
|
|
292
|
+
@click.argument("directory", type=click.Path(exists=True, file_okay=False, path_type=Path),
|
|
293
|
+
default=Path.cwd())
|
|
294
|
+
@click.option("--env", "-e", default=None, help="Environment to audit "
|
|
295
|
+
"(default: auto-detect from directory)")
|
|
296
|
+
@click.option("--format", "-f", "output_format", default="table",
|
|
297
|
+
type=click.Choice(["table", "json", "markdown", "md", "summary"]),
|
|
298
|
+
help="Output format")
|
|
299
|
+
@click.option("--fail-on", default=None, help="Exit code 1 if findings at or above this severity")
|
|
300
|
+
@click.option("--output", "-o", "output_file", type=click.Path(path_type=Path),
|
|
301
|
+
help="Write report to file")
|
|
302
|
+
def audit_command(
|
|
303
|
+
directory: Path,
|
|
304
|
+
env: Optional[str],
|
|
305
|
+
output_format: str,
|
|
306
|
+
fail_on: Optional[str],
|
|
307
|
+
output_file: Optional[Path],
|
|
308
|
+
) -> None:
|
|
309
|
+
"""Audit a single environment for configuration issues.
|
|
310
|
+
|
|
311
|
+
Checks for empty values, default placeholders, and weak secrets.
|
|
312
|
+
|
|
313
|
+
Examples:
|
|
314
|
+
draftguard audit .
|
|
315
|
+
draftguard audit ./prod --env production
|
|
316
|
+
"""
|
|
317
|
+
is_json = output_format == "json"
|
|
318
|
+
if not is_json:
|
|
319
|
+
_print_banner()
|
|
320
|
+
click.echo()
|
|
321
|
+
|
|
322
|
+
parser = ConfigParser()
|
|
323
|
+
scanner = ConfigScanner(parser=parser)
|
|
324
|
+
differ = Differ(parser=parser, scanner=scanner)
|
|
325
|
+
|
|
326
|
+
if not is_json:
|
|
327
|
+
click.echo(f"📂 Auditing: {directory}")
|
|
328
|
+
configs = scanner.scan(directory, recursive=True)
|
|
329
|
+
|
|
330
|
+
if not configs:
|
|
331
|
+
if not is_json:
|
|
332
|
+
click.echo("⚠️ No config files found.")
|
|
333
|
+
sys.exit(0)
|
|
334
|
+
|
|
335
|
+
env_name = env or configs[0].env_name
|
|
336
|
+
if not is_json:
|
|
337
|
+
click.echo(f" Environment: {env_name}")
|
|
338
|
+
click.echo(f" Config files: {len(configs)}")
|
|
339
|
+
click.echo()
|
|
340
|
+
|
|
341
|
+
findings = differ.audit_environment(env_name, configs)
|
|
342
|
+
|
|
343
|
+
reporter = get_reporter(output_format)
|
|
344
|
+
report = reporter.report_audit(findings, env_name)
|
|
345
|
+
|
|
346
|
+
if output_format in ("table",) and report == "":
|
|
347
|
+
pass
|
|
348
|
+
else:
|
|
349
|
+
click.echo(report)
|
|
350
|
+
|
|
351
|
+
if output_file:
|
|
352
|
+
output_file.write_text(report, encoding="utf-8")
|
|
353
|
+
click.echo(f"\n✅ Report written to: {output_file}")
|
|
354
|
+
|
|
355
|
+
exit_code = 0
|
|
356
|
+
if fail_on:
|
|
357
|
+
threshold = _parse_severity(fail_on)
|
|
358
|
+
has_failures = any(
|
|
359
|
+
f.severity.level >= threshold.level for f in findings
|
|
360
|
+
)
|
|
361
|
+
if has_failures:
|
|
362
|
+
exit_code = 1
|
|
363
|
+
|
|
364
|
+
if exit_code != 0:
|
|
365
|
+
sys.exit(exit_code)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
@main.command("diff")
|
|
369
|
+
@click.argument("dir1", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
|
370
|
+
@click.argument("dir2", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
|
371
|
+
@click.option("--format", "-f", "output_format", default="table",
|
|
372
|
+
type=click.Choice(["table", "json", "markdown", "md"]),
|
|
373
|
+
help="Output format")
|
|
374
|
+
@click.option("--output", "-o", "output_file", type=click.Path(path_type=Path),
|
|
375
|
+
help="Write report to file")
|
|
376
|
+
def diff_command(
|
|
377
|
+
dir1: Path,
|
|
378
|
+
dir2: Path,
|
|
379
|
+
output_format: str,
|
|
380
|
+
output_file: Optional[Path],
|
|
381
|
+
) -> None:
|
|
382
|
+
"""Show detailed key-by-key diff between two config directories.
|
|
383
|
+
|
|
384
|
+
Examples:
|
|
385
|
+
draftguard diff ./dev ./prod
|
|
386
|
+
draftguard diff ./staging ./prod --format markdown
|
|
387
|
+
"""
|
|
388
|
+
is_json = output_format == "json"
|
|
389
|
+
if not is_json:
|
|
390
|
+
_print_banner()
|
|
391
|
+
click.echo()
|
|
392
|
+
|
|
393
|
+
parser = ConfigParser()
|
|
394
|
+
scanner = ConfigScanner(parser=parser)
|
|
395
|
+
differ = Differ(parser=parser, scanner=scanner)
|
|
396
|
+
|
|
397
|
+
if not is_json:
|
|
398
|
+
click.echo(f"📂 Scanning: {dir1}")
|
|
399
|
+
configs1 = scanner.scan(dir1, recursive=True)
|
|
400
|
+
|
|
401
|
+
if not is_json:
|
|
402
|
+
click.echo(f"📂 Scanning: {dir2}")
|
|
403
|
+
configs2 = scanner.scan(dir2, recursive=True)
|
|
404
|
+
|
|
405
|
+
if not configs1 or not configs2:
|
|
406
|
+
click.echo("❌ Both directories must contain config files.")
|
|
407
|
+
sys.exit(1)
|
|
408
|
+
|
|
409
|
+
# Merge configs
|
|
410
|
+
merged1 = scanner.merge_env_configs(configs1)
|
|
411
|
+
merged2 = scanner.merge_env_configs(configs2)
|
|
412
|
+
|
|
413
|
+
diffs = differ.diff_values(
|
|
414
|
+
merged1.values, merged2.values,
|
|
415
|
+
env_source=dir1.name, env_target=dir2.name,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
if not is_json:
|
|
419
|
+
click.echo(f"🔍 Diff: {dir1.name} ↔ {dir2.name}")
|
|
420
|
+
click.echo(f" Keys in {dir1.name}: {len(merged1.values)}")
|
|
421
|
+
click.echo(f" Keys in {dir2.name}: {len(merged2.values)}")
|
|
422
|
+
click.echo(f" Differences: {len(diffs)}")
|
|
423
|
+
click.echo()
|
|
424
|
+
|
|
425
|
+
# Display diff
|
|
426
|
+
if output_format == "json":
|
|
427
|
+
import json
|
|
428
|
+
|
|
429
|
+
diff_data = {
|
|
430
|
+
"source": str(dir1),
|
|
431
|
+
"target": str(dir2),
|
|
432
|
+
"differences": [
|
|
433
|
+
{"status": status, "key": key, "source_value": sv, "target_value": tv}
|
|
434
|
+
for status, key, sv, tv in diffs
|
|
435
|
+
],
|
|
436
|
+
}
|
|
437
|
+
output = json.dumps(diff_data, indent=2, default=str)
|
|
438
|
+
click.echo(output)
|
|
439
|
+
elif output_format in ("markdown", "md"):
|
|
440
|
+
lines = [
|
|
441
|
+
f"# Diff: `{dir1.name}` ↔ `{dir2.name}`",
|
|
442
|
+
"",
|
|
443
|
+
"| Status | Key | Source | Target |",
|
|
444
|
+
"|--------|-----|--------|--------|",
|
|
445
|
+
]
|
|
446
|
+
for status, key, sv, tv in diffs:
|
|
447
|
+
sv_str = f"`{sv}`" if sv else "—"
|
|
448
|
+
tv_str = f"`{tv}`" if tv else "—"
|
|
449
|
+
icon = {"added": "➕", "removed": "➖", "changed": "🔄"}.get(status, "•")
|
|
450
|
+
lines.append(f"| {icon} {status} | `{key}` | {sv_str} | {tv_str} |")
|
|
451
|
+
output = "\n".join(lines) + "\n"
|
|
452
|
+
click.echo(output)
|
|
453
|
+
else:
|
|
454
|
+
# Table format
|
|
455
|
+
try:
|
|
456
|
+
from rich.console import Console
|
|
457
|
+
from rich.table import Table
|
|
458
|
+
console = Console()
|
|
459
|
+
table = Table(title=f"Diff: {dir1.name} ↔ {dir2.name}",
|
|
460
|
+
show_header=True, header_style="bold")
|
|
461
|
+
table.add_column("Status", style="bold", width=10)
|
|
462
|
+
table.add_column("Key", style="cyan", width=35)
|
|
463
|
+
table.add_column(str(dir1.name), width=30)
|
|
464
|
+
table.add_column(str(dir2.name), width=30)
|
|
465
|
+
|
|
466
|
+
for status, key, sv, tv in diffs:
|
|
467
|
+
color = {"added": "green", "removed": "red", "changed": "yellow"}.get(status, "")
|
|
468
|
+
sv_str = str(sv)[:28] if sv else "—"
|
|
469
|
+
tv_str = str(tv)[:28] if tv else "—"
|
|
470
|
+
table.add_row(
|
|
471
|
+
f"[{color}]{status}[/{color}]",
|
|
472
|
+
key,
|
|
473
|
+
sv_str,
|
|
474
|
+
tv_str,
|
|
475
|
+
)
|
|
476
|
+
console.print(table)
|
|
477
|
+
except ImportError:
|
|
478
|
+
for status, key, sv, tv in diffs:
|
|
479
|
+
click.echo(f" [{status}] {key}: {sv} → {tv}")
|
|
480
|
+
|
|
481
|
+
if output_file:
|
|
482
|
+
output_file.write_text(output if 'output' in dir() else "", encoding="utf-8")
|