envdrift 4.2.1__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 (52) hide show
  1. envdrift/__init__.py +30 -0
  2. envdrift/_version.py +34 -0
  3. envdrift/api.py +192 -0
  4. envdrift/cli.py +42 -0
  5. envdrift/cli_commands/__init__.py +1 -0
  6. envdrift/cli_commands/diff.py +91 -0
  7. envdrift/cli_commands/encryption.py +630 -0
  8. envdrift/cli_commands/encryption_helpers.py +93 -0
  9. envdrift/cli_commands/hook.py +75 -0
  10. envdrift/cli_commands/init_cmd.py +117 -0
  11. envdrift/cli_commands/partial.py +222 -0
  12. envdrift/cli_commands/sync.py +1140 -0
  13. envdrift/cli_commands/validate.py +109 -0
  14. envdrift/cli_commands/vault.py +376 -0
  15. envdrift/cli_commands/version.py +15 -0
  16. envdrift/config.py +489 -0
  17. envdrift/constants.json +18 -0
  18. envdrift/core/__init__.py +30 -0
  19. envdrift/core/diff.py +233 -0
  20. envdrift/core/encryption.py +400 -0
  21. envdrift/core/parser.py +260 -0
  22. envdrift/core/partial_encryption.py +239 -0
  23. envdrift/core/schema.py +253 -0
  24. envdrift/core/validator.py +312 -0
  25. envdrift/encryption/__init__.py +117 -0
  26. envdrift/encryption/base.py +217 -0
  27. envdrift/encryption/dotenvx.py +236 -0
  28. envdrift/encryption/sops.py +458 -0
  29. envdrift/env_files.py +60 -0
  30. envdrift/integrations/__init__.py +21 -0
  31. envdrift/integrations/dotenvx.py +689 -0
  32. envdrift/integrations/precommit.py +266 -0
  33. envdrift/integrations/sops.py +85 -0
  34. envdrift/output/__init__.py +21 -0
  35. envdrift/output/rich.py +424 -0
  36. envdrift/py.typed +0 -0
  37. envdrift/sync/__init__.py +26 -0
  38. envdrift/sync/config.py +218 -0
  39. envdrift/sync/engine.py +383 -0
  40. envdrift/sync/operations.py +138 -0
  41. envdrift/sync/result.py +99 -0
  42. envdrift/vault/__init__.py +107 -0
  43. envdrift/vault/aws.py +282 -0
  44. envdrift/vault/azure.py +170 -0
  45. envdrift/vault/base.py +150 -0
  46. envdrift/vault/gcp.py +210 -0
  47. envdrift/vault/hashicorp.py +238 -0
  48. envdrift-4.2.1.dist-info/METADATA +160 -0
  49. envdrift-4.2.1.dist-info/RECORD +52 -0
  50. envdrift-4.2.1.dist-info/WHEEL +4 -0
  51. envdrift-4.2.1.dist-info/entry_points.txt +2 -0
  52. envdrift-4.2.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,1140 @@
1
+ """Vault sync-related commands for envdrift."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Annotated, Any
8
+
9
+ import typer
10
+ from rich.panel import Panel
11
+
12
+ from envdrift.env_files import detect_env_file
13
+ from envdrift.output.rich import console, print_error, print_success, print_warning
14
+ from envdrift.vault.base import SecretNotFoundError, VaultError
15
+
16
+ if TYPE_CHECKING:
17
+ from envdrift.sync.config import SyncConfig
18
+
19
+
20
+ def load_sync_config_and_client(
21
+ config_file: Path | None,
22
+ provider: str | None,
23
+ vault_url: str | None,
24
+ region: str | None,
25
+ project_id: str | None,
26
+ ) -> tuple[SyncConfig, Any, str, str | None, str | None, str | None]:
27
+ """
28
+ Load sync configuration and instantiate a vault client using CLI arguments, discovered project config, or an explicit config file.
29
+
30
+ This resolves effective provider, vault URL, and region by preferring CLI arguments over project defaults (from a provided TOML file, discovered envdrift.toml/pyproject.toml, or an explicit legacy config), constructs a SyncConfig (from a TOML, legacy pair file, or project sync mappings), validates required provider-specific options, and returns the SyncConfig along with a ready-to-use vault client and the resolved provider/URL/region.
31
+
32
+ Parameters:
33
+ config_file (Path | None): Path provided via --config. If a TOML file is given, it is used for defaults and/or as the sync config source; other extensions may be treated as legacy pair files.
34
+ provider (str | None): CLI provider override (e.g., "azure", "aws", "hashicorp", "gcp"). If omitted, the provider from project config is used when available.
35
+ vault_url (str | None): CLI vault URL override for providers that require it (Azure, HashiCorp). If omitted, the value from project config is used when present.
36
+ region (str | None): CLI region override for AWS. If omitted, the value from project config is used when present.
37
+ project_id (str | None): CLI project ID override for GCP Secret Manager. If omitted, the value from project config is used when present.
38
+
39
+ Returns:
40
+ tuple[SyncConfig, Any, str, str | None, str | None]: A tuple containing:
41
+ - SyncConfig: the resolved synchronization configuration with mappings.
42
+ - vault_client: an instantiated vault client for the resolved provider.
43
+ - effective_provider: the resolved provider string.
44
+ - effective_vault_url: the resolved vault URL when applicable, otherwise None.
45
+ - effective_region: the resolved region when applicable, otherwise None.
46
+ - effective_project_id: the resolved GCP project ID when applicable, otherwise None.
47
+
48
+ Raises:
49
+ typer.Exit: Exits with a non-zero code if no valid sync configuration can be found, required provider options are missing, the config file is invalid or unreadable, or the vault client cannot be created.
50
+ """
51
+ import tomllib
52
+
53
+ from envdrift.config import ConfigNotFoundError, find_config, load_config
54
+ from envdrift.sync.config import ServiceMapping, SyncConfig, SyncConfigError
55
+ from envdrift.vault import get_vault_client
56
+
57
+ # Determine config source for defaults:
58
+ # 1. If --config points to a TOML file, use it for defaults
59
+ # 2. Otherwise, use auto-discovery (find_config)
60
+ # Note: discovery only runs when --config is not provided. If --config points
61
+ # to a non-TOML file (e.g., pair.txt), we skip discovery to avoid pulling
62
+ # defaults from unrelated projects.
63
+ envdrift_config = None
64
+ config_path = None
65
+
66
+ if config_file is not None and config_file.suffix.lower() == ".toml":
67
+ # Use the explicitly provided TOML file for defaults
68
+ config_path = config_file
69
+ try:
70
+ envdrift_config = load_config(config_path)
71
+ except tomllib.TOMLDecodeError as e:
72
+ print_error(f"TOML syntax error in {config_path}: {e}")
73
+ raise typer.Exit(code=1) from None
74
+ except ConfigNotFoundError:
75
+ pass
76
+ elif config_file is None:
77
+ # Auto-discover config from envdrift.toml or pyproject.toml
78
+ config_path = find_config()
79
+ if config_path:
80
+ try:
81
+ envdrift_config = load_config(config_path)
82
+ except ConfigNotFoundError:
83
+ pass
84
+ except tomllib.TOMLDecodeError as e:
85
+ print_warning(f"TOML syntax error in {config_path}: {e}")
86
+
87
+ vault_config = getattr(envdrift_config, "vault", None)
88
+
89
+ # Determine effective provider (CLI overrides config)
90
+ effective_provider = provider or getattr(vault_config, "provider", None)
91
+
92
+ # Determine effective vault URL (CLI overrides config)
93
+ effective_vault_url = vault_url
94
+ if effective_vault_url is None and vault_config:
95
+ if effective_provider == "azure":
96
+ effective_vault_url = getattr(vault_config, "azure_vault_url", None)
97
+ elif effective_provider == "hashicorp":
98
+ effective_vault_url = getattr(vault_config, "hashicorp_url", None)
99
+
100
+ # Determine effective region (CLI overrides config)
101
+ effective_region = region
102
+ if effective_region is None and vault_config:
103
+ effective_region = getattr(vault_config, "aws_region", None)
104
+
105
+ effective_project_id = project_id
106
+ if effective_project_id is None and vault_config:
107
+ effective_project_id = getattr(vault_config, "gcp_project_id", None)
108
+
109
+ vault_sync = getattr(vault_config, "sync", None)
110
+
111
+ # Load sync config from file or project config
112
+ sync_config: SyncConfig | None = None
113
+
114
+ if config_file is not None:
115
+ # Explicit config file provided
116
+ if not config_file.exists():
117
+ print_error(f"Config file not found: {config_file}")
118
+ raise typer.Exit(code=1)
119
+
120
+ try:
121
+ # Detect format by extension
122
+ if config_file.suffix.lower() == ".toml":
123
+ sync_config = SyncConfig.from_toml_file(config_file)
124
+ else:
125
+ # Legacy pair.txt format
126
+ sync_config = SyncConfig.from_file(config_file)
127
+ except SyncConfigError as e:
128
+ print_error(f"Invalid config file: {e}")
129
+ raise typer.Exit(code=1) from None
130
+ elif vault_sync and vault_sync.mappings:
131
+ # Use mappings from project config
132
+ sync_config = SyncConfig(
133
+ mappings=[
134
+ ServiceMapping(
135
+ secret_name=m.secret_name,
136
+ folder_path=Path(m.folder_path),
137
+ vault_name=m.vault_name,
138
+ environment=m.environment,
139
+ profile=m.profile,
140
+ activate_to=Path(m.activate_to) if m.activate_to else None,
141
+ )
142
+ for m in vault_sync.mappings
143
+ ],
144
+ default_vault_name=vault_sync.default_vault_name,
145
+ env_keys_filename=vault_sync.env_keys_filename,
146
+ )
147
+ elif config_path and config_path.suffix.lower() == ".toml":
148
+ # Try to load sync config from discovered TOML
149
+ try:
150
+ sync_config = SyncConfig.from_toml_file(config_path)
151
+ except SyncConfigError as e:
152
+ print_warning(f"Could not load sync config from {config_path}: {e}")
153
+
154
+ if sync_config is None or not sync_config.mappings:
155
+ print_error(
156
+ "No sync configuration found. Provide one of:\n"
157
+ " --config <file.toml> TOML config with [vault.sync] section\n"
158
+ " --config <pair.txt> Legacy format: secret=folder\n"
159
+ " [tool.envdrift.vault.sync] section in pyproject.toml"
160
+ )
161
+ raise typer.Exit(code=1)
162
+
163
+ # Validate provider is set
164
+ if effective_provider is None:
165
+ print_error(
166
+ "--provider is required (or set [vault] provider in config). "
167
+ "Options: azure, aws, hashicorp, gcp"
168
+ )
169
+ raise typer.Exit(code=1)
170
+
171
+ # Validate provider-specific options
172
+ if effective_provider == "azure" and not effective_vault_url:
173
+ print_error("Azure provider requires --vault-url (or [vault.azure] vault_url in config)")
174
+ raise typer.Exit(code=1)
175
+
176
+ if effective_provider == "hashicorp" and not effective_vault_url:
177
+ print_error("HashiCorp provider requires --vault-url (or [vault.hashicorp] url in config)")
178
+ raise typer.Exit(code=1)
179
+
180
+ if effective_provider == "gcp" and not effective_project_id:
181
+ print_error("GCP provider requires --project-id (or [vault.gcp] project_id in config)")
182
+ raise typer.Exit(code=1)
183
+
184
+ # Create vault client
185
+ try:
186
+ vault_kwargs: dict = {}
187
+ if effective_provider == "azure":
188
+ vault_kwargs["vault_url"] = effective_vault_url
189
+ elif effective_provider == "aws":
190
+ vault_kwargs["region"] = effective_region or "us-east-1"
191
+ elif effective_provider == "hashicorp":
192
+ vault_kwargs["url"] = effective_vault_url
193
+ elif effective_provider == "gcp":
194
+ vault_kwargs["project_id"] = effective_project_id
195
+
196
+ vault_client = get_vault_client(effective_provider, **vault_kwargs)
197
+ except ImportError as e:
198
+ print_error(str(e))
199
+ raise typer.Exit(code=1) from None
200
+ except ValueError as e:
201
+ print_error(str(e))
202
+ raise typer.Exit(code=1) from None
203
+
204
+ return (
205
+ sync_config,
206
+ vault_client,
207
+ effective_provider,
208
+ effective_vault_url,
209
+ effective_region,
210
+ effective_project_id,
211
+ )
212
+
213
+
214
+ def sync(
215
+ config_file: Annotated[
216
+ Path | None,
217
+ typer.Option(
218
+ "--config",
219
+ "-c",
220
+ help="Path to sync config file (TOML or legacy pair.txt format)",
221
+ ),
222
+ ] = None,
223
+ provider: Annotated[
224
+ str | None,
225
+ typer.Option("--provider", "-p", help="Vault provider: azure, aws, hashicorp, gcp"),
226
+ ] = None,
227
+ vault_url: Annotated[
228
+ str | None,
229
+ typer.Option("--vault-url", help="Vault URL (Azure Key Vault or HashiCorp Vault)"),
230
+ ] = None,
231
+ region: Annotated[
232
+ str | None,
233
+ typer.Option("--region", help="AWS region (default: us-east-1)"),
234
+ ] = None,
235
+ project_id: Annotated[
236
+ str | None,
237
+ typer.Option("--project-id", help="GCP project ID (Secret Manager)"),
238
+ ] = None,
239
+ verify: Annotated[
240
+ bool,
241
+ typer.Option("--verify", help="Check only, don't modify files"),
242
+ ] = False,
243
+ force: Annotated[
244
+ bool,
245
+ typer.Option("--force", "-f", help="Update all mismatches without prompting"),
246
+ ] = False,
247
+ check_decryption: Annotated[
248
+ bool,
249
+ typer.Option("--check-decryption", help="Verify keys can decrypt .env files"),
250
+ ] = False,
251
+ validate_schema: Annotated[
252
+ bool,
253
+ typer.Option("--validate-schema", help="Run schema validation after sync"),
254
+ ] = False,
255
+ schema: Annotated[
256
+ str | None,
257
+ typer.Option("--schema", "-s", help="Schema path for validation"),
258
+ ] = None,
259
+ service_dir: Annotated[
260
+ Path | None,
261
+ typer.Option("--service-dir", "-d", help="Service directory for schema imports"),
262
+ ] = None,
263
+ ci: Annotated[
264
+ bool,
265
+ typer.Option("--ci", help="CI mode: exit with code 1 on errors"),
266
+ ] = False,
267
+ ) -> None:
268
+ """
269
+ Sync encryption keys from a configured vault to local .env.keys files for each service.
270
+
271
+ Loads sync configuration and a vault client, fetches DOTENV_PRIVATE_KEY_* secrets for configured mappings, and writes/updates local key files; optionally verifies keys, forces updates, checks decryption, and runs schema validation after sync. In interactive mode the command may prompt before updating individual services; --force, --verify, and --ci disable prompts.
272
+
273
+ Exits with code 1 on vault or sync configuration errors, and when run with --ci if any sync errors occurred.
274
+ """
275
+ from envdrift.output.rich import print_service_sync_status, print_sync_result
276
+ from envdrift.sync.config import SyncConfigError
277
+
278
+ sync_config, vault_client, effective_provider, _, _, _ = load_sync_config_and_client(
279
+ config_file=config_file,
280
+ provider=provider,
281
+ vault_url=vault_url,
282
+ region=region,
283
+ project_id=project_id,
284
+ )
285
+
286
+ # Create sync engine
287
+ from envdrift.sync.engine import SyncEngine, SyncMode
288
+
289
+ mode = SyncMode(
290
+ verify_only=verify,
291
+ force_update=force,
292
+ check_decryption=check_decryption,
293
+ validate_schema=validate_schema,
294
+ schema_path=schema,
295
+ service_dir=service_dir,
296
+ )
297
+
298
+ # Progress callback for non-CI mode
299
+ def progress_callback(msg: str) -> None:
300
+ if not ci:
301
+ console.print(f"[dim]{msg}[/dim]")
302
+
303
+ # Prompt callback (disabled in force/verify/ci modes)
304
+ def prompt_callback(msg: str) -> bool:
305
+ if force or verify or ci:
306
+ return force
307
+ response = console.input(f"{msg} (y/N): ").strip().lower()
308
+ return response in ("y", "yes")
309
+
310
+ engine = SyncEngine(
311
+ config=sync_config,
312
+ vault_client=vault_client,
313
+ mode=mode,
314
+ prompt_callback=prompt_callback,
315
+ progress_callback=progress_callback,
316
+ )
317
+
318
+ # Print header
319
+ console.print()
320
+ mode_str = "VERIFY" if verify else ("FORCE" if force else "Interactive")
321
+ console.print(f"[bold]Vault Sync[/bold] - Mode: {mode_str}")
322
+ console.print(
323
+ f"[dim]Provider: {effective_provider} | Services: {len(sync_config.mappings)}[/dim]"
324
+ )
325
+ console.print()
326
+
327
+ # Run sync
328
+ try:
329
+ result = engine.sync_all()
330
+ except (VaultError, SyncConfigError, SecretNotFoundError) as e:
331
+ print_error(f"Sync failed: {e}")
332
+ raise typer.Exit(code=1) from None
333
+
334
+ # Print results
335
+ for service_result in result.services:
336
+ print_service_sync_status(service_result)
337
+
338
+ print_sync_result(result)
339
+
340
+ # Exit with appropriate code
341
+ if ci and result.has_errors:
342
+ raise typer.Exit(code=1)
343
+
344
+
345
+ def pull(
346
+ config_file: Annotated[
347
+ Path | None,
348
+ typer.Option(
349
+ "--config",
350
+ "-c",
351
+ help="Path to sync config file (TOML or legacy pair.txt format)",
352
+ ),
353
+ ] = None,
354
+ provider: Annotated[
355
+ str | None,
356
+ typer.Option("--provider", "-p", help="Vault provider: azure, aws, hashicorp, gcp"),
357
+ ] = None,
358
+ vault_url: Annotated[
359
+ str | None,
360
+ typer.Option("--vault-url", help="Vault URL (Azure Key Vault or HashiCorp Vault)"),
361
+ ] = None,
362
+ region: Annotated[
363
+ str | None,
364
+ typer.Option("--region", help="AWS region (default: us-east-1)"),
365
+ ] = None,
366
+ project_id: Annotated[
367
+ str | None,
368
+ typer.Option("--project-id", help="GCP project ID (Secret Manager)"),
369
+ ] = None,
370
+ force: Annotated[
371
+ bool,
372
+ typer.Option("--force", "-f", help="Update all mismatches without prompting"),
373
+ ] = False,
374
+ profile: Annotated[
375
+ str | None,
376
+ typer.Option("--profile", help="Only process mappings for this profile"),
377
+ ] = None,
378
+ skip_sync: Annotated[
379
+ bool,
380
+ typer.Option("--skip-sync", help="Skip syncing keys from vault, only decrypt files"),
381
+ ] = False,
382
+ ) -> None:
383
+ """
384
+ Pull keys from vault and decrypt all env files (one-command developer setup).
385
+
386
+ Reads your TOML configuration, fetches encryption keys from your cloud vault,
387
+ writes them to local .env.keys files, and decrypts all corresponding .env files.
388
+
389
+ This is the recommended command for onboarding new developers - just run
390
+ `envdrift pull` and all encrypted environment files are ready to use.
391
+
392
+ Use --profile to filter mappings and activate a specific environment:
393
+ - Without --profile: processes all mappings without a profile tag
394
+ - With --profile: processes regular mappings + the matching profile,
395
+ and copies the decrypted file to the activate_to path if configured
396
+
397
+ Configuration is read from:
398
+ - pyproject.toml [tool.envdrift.vault.sync] section
399
+ - envdrift.toml [vault.sync] section
400
+ - Explicit --config file
401
+
402
+ Examples:
403
+ # Auto-discover config and pull everything (non-profile mappings only)
404
+ envdrift pull
405
+
406
+ # Pull with a specific profile (regular mappings + profile, activates env)
407
+ envdrift pull --profile local
408
+
409
+ # Use explicit config file
410
+ envdrift pull -c envdrift.toml
411
+
412
+ # Override provider settings
413
+ envdrift pull -p azure --vault-url https://myvault.vault.azure.net/
414
+
415
+ # Force update without prompts
416
+ envdrift pull --force
417
+
418
+ # Skip vault sync, only decrypt files (useful when keys are already local)
419
+ envdrift pull --skip-sync
420
+ """
421
+ from envdrift.output.rich import print_service_sync_status, print_sync_result
422
+ from envdrift.sync.config import SyncConfigError
423
+
424
+ sync_config, vault_client, effective_provider, _, _, _ = load_sync_config_and_client(
425
+ config_file=config_file,
426
+ provider=provider,
427
+ vault_url=vault_url,
428
+ region=region,
429
+ project_id=project_id,
430
+ )
431
+
432
+ # === FILTER MAPPINGS BY PROFILE ===
433
+ from envdrift.sync.engine import SyncEngine, SyncMode
434
+
435
+ filtered_mappings = sync_config.filter_by_profile(profile)
436
+
437
+ if not filtered_mappings:
438
+ if profile:
439
+ print_error(f"No mappings found for profile '{profile}'")
440
+ else:
441
+ print_warning("No non-profile mappings found. Use --profile to specify one.")
442
+ raise typer.Exit(code=1)
443
+
444
+ # Create a filtered config for the sync engine
445
+ from envdrift.sync.config import SyncConfig as SyncConfigClass
446
+
447
+ filtered_config = SyncConfigClass(
448
+ mappings=filtered_mappings,
449
+ default_vault_name=sync_config.default_vault_name,
450
+ env_keys_filename=sync_config.env_keys_filename,
451
+ )
452
+
453
+ # === STEP 1: SYNC KEYS FROM VAULT ===
454
+ mode = SyncMode(force_update=force)
455
+
456
+ def progress_callback(msg: str) -> None:
457
+ console.print(f"[dim]{msg}[/dim]")
458
+
459
+ def prompt_callback(msg: str) -> bool:
460
+ if force:
461
+ return True
462
+ response = console.input(f"{msg} (y/N): ").strip().lower()
463
+ return response in ("y", "yes")
464
+
465
+ engine = SyncEngine(
466
+ config=filtered_config,
467
+ vault_client=vault_client,
468
+ mode=mode,
469
+ prompt_callback=prompt_callback,
470
+ progress_callback=progress_callback,
471
+ )
472
+
473
+ console.print()
474
+ profile_info = f" (profile: {profile})" if profile else ""
475
+ action = "Decrypting env files" if skip_sync else "Syncing keys and decrypting env files"
476
+ console.print(f"[bold]Pull[/bold] - {action}{profile_info}")
477
+ console.print(f"[dim]Provider: {effective_provider} | Services: {len(filtered_mappings)}[/dim]")
478
+ console.print()
479
+
480
+ # === STEP 1: SYNC KEYS FROM VAULT (unless --skip-sync) ===
481
+ if skip_sync:
482
+ console.print("[dim]Step 1: Skipped (--skip-sync)[/dim]")
483
+ else:
484
+ console.print("[bold cyan]Step 1:[/bold cyan] Syncing keys from vault...")
485
+ console.print()
486
+
487
+ try:
488
+ sync_result = engine.sync_all()
489
+ except (VaultError, SyncConfigError, SecretNotFoundError) as e:
490
+ print_error(f"Sync failed: {e}")
491
+ raise typer.Exit(code=1) from None
492
+
493
+ for service_result in sync_result.services:
494
+ print_service_sync_status(service_result)
495
+
496
+ print_sync_result(sync_result)
497
+
498
+ if sync_result.has_errors:
499
+ print_error("Setup incomplete due to sync errors")
500
+ raise typer.Exit(code=1)
501
+
502
+ # === STEP 2: DECRYPT ENV FILES ===
503
+ console.print()
504
+ console.print("[bold cyan]Step 2:[/bold cyan] Decrypting environment files...")
505
+ console.print()
506
+
507
+ try:
508
+ from envdrift.cli_commands.encryption_helpers import (
509
+ is_encrypted_content,
510
+ resolve_encryption_backend,
511
+ )
512
+ from envdrift.encryption import (
513
+ EncryptionBackendError,
514
+ EncryptionNotFoundError,
515
+ EncryptionProvider,
516
+ detect_encryption_provider,
517
+ )
518
+
519
+ encryption_backend, backend_provider, _ = resolve_encryption_backend(config_file)
520
+ if not encryption_backend.is_installed():
521
+ print_error(f"{encryption_backend.name} is not installed")
522
+ console.print(encryption_backend.install_instructions())
523
+ raise typer.Exit(code=1)
524
+ except ValueError as e:
525
+ print_error(f"Unsupported encryption backend: {e}")
526
+ raise typer.Exit(code=1) from None
527
+
528
+ decrypted_count = 0
529
+ skipped_count = 0
530
+ error_count = 0
531
+ activated_count = 0
532
+
533
+ for mapping in filtered_mappings:
534
+ effective_env = mapping.effective_environment
535
+ env_file = mapping.folder_path / f".env.{effective_env}"
536
+
537
+ if not env_file.exists():
538
+ # Try to auto-detect .env.* file
539
+ detection = detect_env_file(mapping.folder_path)
540
+ if detection.status == "found" and detection.path is not None:
541
+ env_file = detection.path
542
+ elif detection.status == "multiple_found":
543
+ console.print(
544
+ f" [yellow]?[/yellow] {mapping.folder_path} "
545
+ f"[yellow]- skipped (multiple .env.* files, specify environment)[/yellow]"
546
+ )
547
+ skipped_count += 1
548
+ continue
549
+ else:
550
+ console.print(f" [dim]=[/dim] {env_file} [dim]- skipped (not found)[/dim]")
551
+ skipped_count += 1
552
+ continue
553
+
554
+ # Check if file is encrypted
555
+ content = env_file.read_text()
556
+ if not is_encrypted_content(backend_provider, encryption_backend, content):
557
+ detected_provider = detect_encryption_provider(env_file)
558
+ if detected_provider and detected_provider != backend_provider:
559
+ if (
560
+ detected_provider == EncryptionProvider.DOTENVX
561
+ and backend_provider != EncryptionProvider.DOTENVX
562
+ ):
563
+ console.print(
564
+ f" [red]![/red] {env_file} "
565
+ f"[red]- encrypted with dotenvx, but config uses "
566
+ f"{backend_provider.value}[/red]"
567
+ )
568
+ error_count += 1
569
+ continue
570
+ console.print(
571
+ f" [dim]=[/dim] {env_file} "
572
+ f"[dim]- skipped (encrypted with {detected_provider.value}, "
573
+ f"config uses {backend_provider.value})[/dim]"
574
+ )
575
+ skipped_count += 1
576
+ continue
577
+ console.print(f" [dim]=[/dim] {env_file} [dim]- skipped (not encrypted)[/dim]")
578
+ skipped_count += 1
579
+ continue
580
+
581
+ try:
582
+ result = encryption_backend.decrypt(env_file.resolve())
583
+ if not result.success:
584
+ console.print(f" [red]![/red] {env_file} [red]- error: {result.message}[/red]")
585
+ error_count += 1
586
+ continue
587
+
588
+ console.print(f" [green]+[/green] {env_file} [dim]- decrypted[/dim]")
589
+ decrypted_count += 1
590
+
591
+ # Activate profile: copy decrypted file to activate_to path if configured
592
+ if profile and mapping.profile == profile and mapping.activate_to:
593
+ activate_path = (mapping.folder_path / mapping.activate_to).resolve()
594
+ # Validate path is within folder_path to prevent directory traversal
595
+ try:
596
+ activate_path.relative_to(mapping.folder_path.resolve())
597
+ except ValueError:
598
+ console.print(
599
+ f" [red]![/red] {mapping.activate_to} [red]- invalid path (escapes folder)[/red]"
600
+ )
601
+ error_count += 1
602
+ continue
603
+
604
+ try:
605
+ shutil.copy2(env_file, activate_path)
606
+ console.print(
607
+ f" [cyan]→[/cyan] {activate_path} [dim]- activated from {env_file.name}[/dim]"
608
+ )
609
+ activated_count += 1
610
+ except OSError as e:
611
+ console.print(
612
+ f" [red]![/red] {activate_path} [red]- activation failed: {e}[/red]"
613
+ )
614
+ error_count += 1
615
+
616
+ except (EncryptionNotFoundError, EncryptionBackendError) as e:
617
+ console.print(f" [red]![/red] {env_file} [red]- error: {e}[/red]")
618
+ error_count += 1
619
+
620
+ # === SUMMARY ===
621
+ console.print()
622
+ summary_lines = [
623
+ f"Decrypted: {decrypted_count}",
624
+ f"Skipped: {skipped_count}",
625
+ f"Errors: {error_count}",
626
+ ]
627
+ if activated_count > 0:
628
+ summary_lines.append(f"Activated: {activated_count}")
629
+ console.print(
630
+ Panel(
631
+ "\n".join(summary_lines),
632
+ title="Decrypt Summary",
633
+ expand=False,
634
+ )
635
+ )
636
+
637
+ if error_count > 0:
638
+ print_warning("Some files could not be decrypted")
639
+ raise typer.Exit(code=1)
640
+
641
+ console.print()
642
+ print_success("Setup complete! Your environment files are ready to use.")
643
+
644
+
645
+ def lock(
646
+ config_file: Annotated[
647
+ Path | None,
648
+ typer.Option(
649
+ "--config",
650
+ "-c",
651
+ help="Path to sync config file (TOML or legacy pair.txt format)",
652
+ ),
653
+ ] = None,
654
+ provider: Annotated[
655
+ str | None,
656
+ typer.Option("--provider", "-p", help="Vault provider: azure, aws, hashicorp, gcp"),
657
+ ] = None,
658
+ vault_url: Annotated[
659
+ str | None,
660
+ typer.Option("--vault-url", help="Vault URL (Azure Key Vault or HashiCorp Vault)"),
661
+ ] = None,
662
+ region: Annotated[
663
+ str | None,
664
+ typer.Option("--region", help="AWS region (default: us-east-1)"),
665
+ ] = None,
666
+ project_id: Annotated[
667
+ str | None,
668
+ typer.Option("--project-id", help="GCP project ID (Secret Manager)"),
669
+ ] = None,
670
+ force: Annotated[
671
+ bool,
672
+ typer.Option("--force", "-f", help="Force encryption without prompting"),
673
+ ] = False,
674
+ profile: Annotated[
675
+ str | None,
676
+ typer.Option("--profile", help="Only process mappings for this profile"),
677
+ ] = None,
678
+ verify_vault: Annotated[
679
+ bool,
680
+ typer.Option("--verify-vault", help="Verify local keys match vault before encrypting"),
681
+ ] = False,
682
+ sync_keys: Annotated[
683
+ bool,
684
+ typer.Option(
685
+ "--sync-keys", help="Sync keys from vault before encrypting (implies --verify-vault)"
686
+ ),
687
+ ] = False,
688
+ check_only: Annotated[
689
+ bool,
690
+ typer.Option("--check", help="Only check encryption status, don't encrypt"),
691
+ ] = False,
692
+ ) -> None:
693
+ """
694
+ Verify keys and encrypt all env files (opposite of pull - prepares for commit).
695
+
696
+ The lock command ensures your environment files are properly encrypted before
697
+ committing. It can optionally verify that local keys match vault keys to prevent
698
+ key drift, and then encrypts all decrypted .env files.
699
+
700
+ This is the recommended command before committing changes to ensure:
701
+ 1. Local encryption keys are in sync with the team's vault keys
702
+ 2. All .env files are properly encrypted
703
+ 3. No plaintext secrets are accidentally committed
704
+
705
+ Workflow:
706
+ - With --verify-vault: Check if local .env.keys match vault secrets
707
+ - With --sync-keys: Fetch keys from vault to ensure consistency
708
+ - Then: Encrypt all .env files that are currently decrypted
709
+
710
+ Use --profile to filter mappings for a specific environment.
711
+
712
+ Configuration is read from:
713
+ - pyproject.toml [tool.envdrift.vault.sync] section
714
+ - envdrift.toml [vault.sync] section
715
+ - Explicit --config file
716
+
717
+ Examples:
718
+ # Encrypt all env files (basic usage)
719
+ envdrift lock
720
+
721
+ # Verify keys match vault, then encrypt
722
+ envdrift lock --verify-vault
723
+
724
+ # Sync keys from vault first, then encrypt
725
+ envdrift lock --sync-keys
726
+
727
+ # Check encryption status only (dry run)
728
+ envdrift lock --check
729
+
730
+ # Lock with a specific profile
731
+ envdrift lock --profile local
732
+
733
+ # Force encryption without prompts
734
+ envdrift lock --force
735
+ """
736
+ from envdrift.output.rich import print_service_sync_status, print_sync_result
737
+ from envdrift.sync.config import SyncConfigError
738
+
739
+ # If sync_keys is requested, it implies verify_vault
740
+ if sync_keys:
741
+ verify_vault = True
742
+
743
+ sync_config, vault_client, effective_provider, _, _, _ = load_sync_config_and_client(
744
+ config_file=config_file,
745
+ provider=provider,
746
+ vault_url=vault_url,
747
+ region=region,
748
+ project_id=project_id,
749
+ )
750
+
751
+ # === FILTER MAPPINGS BY PROFILE ===
752
+ from envdrift.sync.config import SyncConfig as SyncConfigClass
753
+ from envdrift.sync.engine import SyncEngine, SyncMode
754
+
755
+ filtered_mappings = sync_config.filter_by_profile(profile)
756
+
757
+ if not filtered_mappings:
758
+ if profile:
759
+ print_error(f"No mappings found for profile '{profile}'")
760
+ else:
761
+ print_warning("No non-profile mappings found. Use --profile to specify one.")
762
+ raise typer.Exit(code=1)
763
+
764
+ # Create a filtered config for the sync engine
765
+ filtered_config = SyncConfigClass(
766
+ mappings=filtered_mappings,
767
+ default_vault_name=sync_config.default_vault_name,
768
+ env_keys_filename=sync_config.env_keys_filename,
769
+ )
770
+
771
+ console.print()
772
+ profile_info = f" (profile: {profile})" if profile else ""
773
+ mode_str = "CHECK" if check_only else ("FORCE" if force else "Interactive")
774
+ console.print(f"[bold]Lock[/bold] - Verifying keys and encrypting env files{profile_info}")
775
+ console.print(
776
+ f"[dim]Provider: {effective_provider} | Mode: {mode_str} | Services: {len(filtered_mappings)}[/dim]"
777
+ )
778
+ console.print()
779
+
780
+ # Tracking for summary
781
+ warnings: list[str] = []
782
+ errors: list[str] = []
783
+
784
+ # === STEP 1: VERIFY/SYNC KEYS (OPTIONAL) ===
785
+ if verify_vault:
786
+ console.print("[bold cyan]Step 1:[/bold cyan] Verifying keys with vault...")
787
+ console.print()
788
+
789
+ if sync_keys:
790
+ # Actually sync keys from vault
791
+ mode = SyncMode(force_update=force)
792
+
793
+ def progress_callback(msg: str) -> None:
794
+ console.print(f"[dim]{msg}[/dim]")
795
+
796
+ def prompt_callback(msg: str) -> bool:
797
+ if force:
798
+ return True
799
+ response = console.input(f"{msg} (y/N): ").strip().lower()
800
+ return response in ("y", "yes")
801
+
802
+ engine = SyncEngine(
803
+ config=filtered_config,
804
+ vault_client=vault_client,
805
+ mode=mode,
806
+ prompt_callback=prompt_callback,
807
+ progress_callback=progress_callback,
808
+ )
809
+
810
+ try:
811
+ sync_result = engine.sync_all()
812
+ except (VaultError, SyncConfigError, SecretNotFoundError) as e:
813
+ print_error(f"Key sync failed: {e}")
814
+ raise typer.Exit(code=1) from None
815
+
816
+ for service_result in sync_result.services:
817
+ print_service_sync_status(service_result)
818
+
819
+ print_sync_result(sync_result)
820
+
821
+ if sync_result.has_errors:
822
+ errors.append("Key synchronization had errors")
823
+ if not force:
824
+ print_error("Cannot proceed with encryption due to key sync errors")
825
+ raise typer.Exit(code=1)
826
+ else:
827
+ # Just verify (compare local keys with vault)
828
+ from envdrift.sync.operations import EnvKeysFile
829
+
830
+ verification_issues = 0
831
+
832
+ for mapping in filtered_mappings:
833
+ effective_env = mapping.effective_environment
834
+ env_keys_file = mapping.folder_path / (sync_config.env_keys_filename or ".env.keys")
835
+ key_name = f"DOTENV_PRIVATE_KEY_{effective_env.upper()}"
836
+
837
+ # Check if local key exists
838
+ if not env_keys_file.exists():
839
+ console.print(
840
+ f" [yellow]![/yellow] {mapping.folder_path} "
841
+ f"[yellow]- warning: .env.keys not found[/yellow]"
842
+ )
843
+ warnings.append(f"{mapping.folder_path}: .env.keys file missing")
844
+ continue
845
+
846
+ local_keys = EnvKeysFile(env_keys_file)
847
+ local_key = local_keys.read_key(key_name)
848
+
849
+ if not local_key:
850
+ console.print(
851
+ f" [yellow]![/yellow] {mapping.folder_path} "
852
+ f"[yellow]- warning: {key_name} not found in .env.keys[/yellow]"
853
+ )
854
+ warnings.append(f"{mapping.folder_path}: {key_name} missing from .env.keys")
855
+ continue
856
+
857
+ # Fetch key from vault for comparison
858
+ try:
859
+ vault_client.ensure_authenticated()
860
+ vault_secret = vault_client.get_secret(mapping.secret_name)
861
+
862
+ if not vault_secret or not vault_secret.value:
863
+ console.print(
864
+ f" [yellow]![/yellow] {mapping.folder_path} "
865
+ f"[yellow]- warning: vault secret '{mapping.secret_name}' is empty[/yellow]"
866
+ )
867
+ warnings.append(f"{mapping.folder_path}: vault secret is empty")
868
+ continue
869
+
870
+ vault_value = vault_secret.value
871
+
872
+ # Parse vault value (format: KEY_NAME=value)
873
+ if "=" in vault_value and vault_value.startswith("DOTENV_PRIVATE_KEY"):
874
+ vault_key = vault_value.split("=", 1)[1]
875
+ else:
876
+ vault_key = vault_value
877
+
878
+ # Compare keys
879
+ if local_key == vault_key:
880
+ console.print(
881
+ f" [green]✓[/green] {mapping.folder_path} "
882
+ f"[dim]- keys match vault[/dim]"
883
+ )
884
+ else:
885
+ console.print(
886
+ f" [red]✗[/red] {mapping.folder_path} "
887
+ f"[red]- KEY MISMATCH: local key differs from vault![/red]"
888
+ )
889
+ errors.append(
890
+ f"{mapping.folder_path}: local key does not match vault "
891
+ f"(run 'envdrift lock --sync-keys' to fix)"
892
+ )
893
+ verification_issues += 1
894
+
895
+ except SecretNotFoundError:
896
+ console.print(
897
+ f" [yellow]![/yellow] {mapping.folder_path} "
898
+ f"[yellow]- warning: vault secret '{mapping.secret_name}' not found[/yellow]"
899
+ )
900
+ warnings.append(f"{mapping.folder_path}: vault secret not found")
901
+ except VaultError as e:
902
+ console.print(
903
+ f" [red]![/red] {mapping.folder_path} "
904
+ f"[red]- error: vault access failed: {e}[/red]"
905
+ )
906
+ errors.append(f"{mapping.folder_path}: vault error - {e}")
907
+
908
+ console.print()
909
+
910
+ if verification_issues > 0 and not force:
911
+ print_error(
912
+ f"Found {verification_issues} key mismatch(es). "
913
+ "Run with --sync-keys to update local keys, or --force to encrypt anyway."
914
+ )
915
+ raise typer.Exit(code=1)
916
+
917
+ # === STEP 2: ENCRYPT ENV FILES ===
918
+ step_num = "Step 2" if verify_vault else "Step 1"
919
+ console.print(f"[bold cyan]{step_num}:[/bold cyan] Encrypting environment files...")
920
+ console.print()
921
+
922
+ try:
923
+ from envdrift.cli_commands.encryption_helpers import (
924
+ build_sops_encrypt_kwargs,
925
+ is_encrypted_content,
926
+ resolve_encryption_backend,
927
+ )
928
+ from envdrift.encryption import (
929
+ EncryptionBackendError,
930
+ EncryptionNotFoundError,
931
+ EncryptionProvider,
932
+ detect_encryption_provider,
933
+ )
934
+
935
+ encryption_backend, backend_provider, encryption_config = resolve_encryption_backend(
936
+ config_file
937
+ )
938
+ if not encryption_backend.is_installed():
939
+ print_error(f"{encryption_backend.name} is not installed")
940
+ console.print(encryption_backend.install_instructions())
941
+ raise typer.Exit(code=1)
942
+ except ValueError as e:
943
+ print_error(f"Unsupported encryption backend: {e}")
944
+ raise typer.Exit(code=1) from None
945
+
946
+ sops_encrypt_kwargs = {}
947
+ if backend_provider == EncryptionProvider.SOPS:
948
+ sops_encrypt_kwargs = build_sops_encrypt_kwargs(encryption_config)
949
+
950
+ encrypted_count = 0
951
+ skipped_count = 0
952
+ error_count = 0
953
+ already_encrypted_count = 0
954
+
955
+ for mapping in filtered_mappings:
956
+ effective_env = mapping.effective_environment
957
+ env_file = mapping.folder_path / f".env.{effective_env}"
958
+
959
+ # Check if env file exists
960
+ if not env_file.exists():
961
+ # Try to auto-detect .env.* file
962
+ detection = detect_env_file(mapping.folder_path)
963
+ if detection.status == "found" and detection.path is not None:
964
+ env_file = detection.path
965
+ elif detection.status == "multiple_found":
966
+ console.print(
967
+ f" [yellow]?[/yellow] {mapping.folder_path} "
968
+ f"[yellow]- skipped (multiple .env.* files, specify environment)[/yellow]"
969
+ )
970
+ warnings.append(f"{mapping.folder_path}: multiple .env files found")
971
+ skipped_count += 1
972
+ continue
973
+ else:
974
+ console.print(f" [dim]=[/dim] {env_file} [dim]- skipped (not found)[/dim]")
975
+ warnings.append(f"{env_file}: file not found")
976
+ skipped_count += 1
977
+ continue
978
+
979
+ # Check if .env.keys file exists (needed for encryption)
980
+ env_keys_file = mapping.folder_path / (sync_config.env_keys_filename or ".env.keys")
981
+ if backend_provider == EncryptionProvider.DOTENVX and not env_keys_file.exists():
982
+ console.print(
983
+ f" [yellow]![/yellow] {env_file} "
984
+ f"[yellow]- warning: no .env.keys file, will generate new key[/yellow]"
985
+ )
986
+ warnings.append(f"{env_file}: no .env.keys file found, new key will be generated")
987
+
988
+ # Check if file is already encrypted
989
+ content = env_file.read_text()
990
+ if not is_encrypted_content(backend_provider, encryption_backend, content):
991
+ detected_provider = detect_encryption_provider(env_file)
992
+ if detected_provider and detected_provider != backend_provider:
993
+ if (
994
+ detected_provider == EncryptionProvider.DOTENVX
995
+ and backend_provider != EncryptionProvider.DOTENVX
996
+ ):
997
+ console.print(
998
+ f" [red]![/red] {env_file} "
999
+ f"[red]- encrypted with dotenvx, but config uses "
1000
+ f"{backend_provider.value}[/red]"
1001
+ )
1002
+ errors.append(
1003
+ f"{env_file}: encrypted with dotenvx, but config uses "
1004
+ f"{backend_provider.value}"
1005
+ )
1006
+ error_count += 1
1007
+ continue
1008
+ console.print(
1009
+ f" [dim]=[/dim] {env_file} "
1010
+ f"[dim]- skipped (encrypted with {detected_provider.value}, "
1011
+ f"config uses {backend_provider.value})[/dim]"
1012
+ )
1013
+ warnings.append(
1014
+ f"{env_file}: encrypted with {detected_provider.value}, "
1015
+ f"config uses {backend_provider.value}"
1016
+ )
1017
+ skipped_count += 1
1018
+ continue
1019
+ else:
1020
+ if backend_provider == EncryptionProvider.DOTENVX:
1021
+ # Check encryption ratio
1022
+ encrypted_lines = sum(
1023
+ 1 for line in content.splitlines() if "encrypted:" in line.lower()
1024
+ )
1025
+ total_value_lines = sum(
1026
+ 1
1027
+ for line in content.splitlines()
1028
+ if line.strip() and not line.strip().startswith("#") and "=" in line
1029
+ )
1030
+
1031
+ if total_value_lines > 0:
1032
+ ratio = encrypted_lines / total_value_lines
1033
+ if ratio >= 0.9: # 90%+ encrypted = fully encrypted
1034
+ console.print(
1035
+ f" [dim]=[/dim] {env_file} [dim]- skipped (already encrypted)[/dim]"
1036
+ )
1037
+ already_encrypted_count += 1
1038
+ continue
1039
+ else:
1040
+ # Partially encrypted - re-encrypt to catch new values
1041
+ console.print(
1042
+ f" [yellow]~[/yellow] {env_file} "
1043
+ f"[dim]- partially encrypted ({int(ratio*100)}%), "
1044
+ "re-encrypting...[/dim]"
1045
+ )
1046
+ warnings.append(f"{env_file}: was only {int(ratio*100)}% encrypted")
1047
+ else:
1048
+ console.print(
1049
+ f" [dim]=[/dim] {env_file} [dim]- skipped (already encrypted)[/dim]"
1050
+ )
1051
+ already_encrypted_count += 1
1052
+ continue
1053
+ else:
1054
+ console.print(f" [dim]=[/dim] {env_file} [dim]- skipped (already encrypted)[/dim]")
1055
+ already_encrypted_count += 1
1056
+ continue
1057
+
1058
+ if check_only:
1059
+ # Just report what would be encrypted
1060
+ console.print(f" [cyan]?[/cyan] {env_file} [dim]- would be encrypted[/dim]")
1061
+ encrypted_count += 1
1062
+ continue
1063
+
1064
+ # Prompt before encrypting (unless force mode)
1065
+ if not force:
1066
+ response = console.input(f" Encrypt {env_file}? (y/N): ").strip().lower()
1067
+ if response not in ("y", "yes"):
1068
+ console.print(f" [dim]=[/dim] {env_file} [dim]- skipped (user declined)[/dim]")
1069
+ skipped_count += 1
1070
+ continue
1071
+
1072
+ # Perform encryption
1073
+ try:
1074
+ result = encryption_backend.encrypt(env_file.resolve(), **sops_encrypt_kwargs)
1075
+ if not result.success:
1076
+ console.print(f" [red]![/red] {env_file} [red]- error: {result.message}[/red]")
1077
+ errors.append(f"{env_file}: encryption failed - {result.message}")
1078
+ error_count += 1
1079
+ continue
1080
+ console.print(f" [green]+[/green] {env_file} [dim]- encrypted[/dim]")
1081
+ encrypted_count += 1
1082
+
1083
+ except (EncryptionNotFoundError, EncryptionBackendError) as e:
1084
+ console.print(f" [red]![/red] {env_file} [red]- error: {e}[/red]")
1085
+ errors.append(f"{env_file}: encryption failed - {e}")
1086
+ error_count += 1
1087
+
1088
+ # === SUMMARY ===
1089
+ console.print()
1090
+ summary_lines = []
1091
+
1092
+ if check_only:
1093
+ summary_lines.append(f"Would encrypt: {encrypted_count}")
1094
+ else:
1095
+ summary_lines.append(f"Encrypted: {encrypted_count}")
1096
+
1097
+ summary_lines.append(f"Already encrypted: {already_encrypted_count}")
1098
+ summary_lines.append(f"Skipped: {skipped_count}")
1099
+ summary_lines.append(f"Errors: {error_count}")
1100
+
1101
+ console.print(
1102
+ Panel(
1103
+ "\n".join(summary_lines),
1104
+ title="Lock Summary",
1105
+ expand=False,
1106
+ )
1107
+ )
1108
+
1109
+ # Print warnings
1110
+ if warnings:
1111
+ console.print()
1112
+ console.print("[bold yellow]Warnings:[/bold yellow]")
1113
+ for warning in warnings:
1114
+ console.print(f" [yellow]•[/yellow] {warning}")
1115
+
1116
+ # Print errors
1117
+ if errors:
1118
+ console.print()
1119
+ console.print("[bold red]Errors:[/bold red]")
1120
+ for error in errors:
1121
+ console.print(f" [red]•[/red] {error}")
1122
+
1123
+ if error_count > 0 or errors:
1124
+ print_warning("Some files could not be encrypted or had issues")
1125
+ raise typer.Exit(code=1)
1126
+
1127
+ console.print()
1128
+ if check_only:
1129
+ if encrypted_count > 0:
1130
+ # In check mode, if files would be encrypted, this is a failure
1131
+ # (useful for CI/pre-commit hooks to ensure all files are encrypted)
1132
+ print_warning(
1133
+ f"Found {encrypted_count} file(s) that need encryption. "
1134
+ "Run 'envdrift lock' to encrypt them."
1135
+ )
1136
+ raise typer.Exit(code=1)
1137
+ else:
1138
+ print_success("Check complete! All files are already encrypted.")
1139
+ else:
1140
+ print_success("Lock complete! Your environment files are encrypted and ready to commit.")