Coverage for src/onepass_env/cli.py: 0%
138 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-18 11:04 -0300
« prev ^ index » next coverage.py v7.10.6, created at 2025-09-18 11:04 -0300
1"""Main CLI interface for 1pass-env - Import command only."""
3import os
4import sys
5from pathlib import Path
6from typing import Optional
8import click
9from rich.console import Console
10from rich.panel import Panel
11from rich.table import Table
13from onepass_env.__about__ import __version__
14from onepass_env.exceptions import OnePassEnvError
16console = Console()
19def print_version(ctx: click.Context, param: click.Parameter, value: bool) -> None:
20 """Print version and exit."""
21 if not value or ctx.resilient_parsing:
22 return
23 console.print(f"1pass-env version {__version__}")
24 ctx.exit()
27@click.group(invoke_without_command=True)
28@click.option(
29 "--version",
30 is_flag=True,
31 callback=print_version,
32 expose_value=False,
33 is_eager=True,
34 help="Show version and exit.",
35)
36@click.pass_context
37def cli(ctx: click.Context) -> None:
38 """1pass-env: Import environment variables from 1Password.
40 A focused CLI tool that imports secrets from 1Password items into
41 environment files for your development workflow.
42 """
43 ctx.ensure_object(dict)
45 if ctx.invoked_subcommand is None:
46 console.print(Panel.fit(
47 "[bold blue]1pass-env[/bold blue]\n\n"
48 "Import environment variables from 1Password items.\n\n"
49 f"Version: {__version__}\n\n"
50 "Usage: [bold]1pass-env import [OPTIONS][/bold]\n"
51 "Run [bold]1pass-env import --help[/bold] for details.",
52 title="1pass-env"
53 ))
56@cli.command(name="import")
57@click.option(
58 "--vault",
59 "-v",
60 default="tokens",
61 help="1Password vault name to import from (default: 'tokens').",
62)
63@click.option(
64 "--name",
65 "-n",
66 help="Item name in vault (default: current folder name).",
67)
68@click.option(
69 "--fields",
70 "-f",
71 help="Specific fields to import (comma-separated, e.g., API_KEY,SECRET_KEY). If not specified, imports all fields.",
72)
73@click.option(
74 "--file",
75 "env_file",
76 default="1pass.env",
77 help="Output file name (default: '1pass.env').",
78)
79@click.option(
80 "--debug",
81 is_flag=True,
82 help="Enable debug logging.",
83)
84def import_command(
85 vault: str,
86 name: Optional[str],
87 fields: Optional[str],
88 env_file: str,
89 debug: bool
90) -> None:
91 """Import environment variables from a 1Password item.
93 This command imports secrets from an existing 1Password item into your
94 environment file. By default, it imports all fields from the item.
96 Examples:
98 \b
99 # Import all fields from current folder name
100 1pass-env import
102 # Import from specific item
103 1pass-env import --name my-app
105 # Import specific fields only
106 1pass-env import --name my-app --fields API_KEY,DB_PASSWORD
108 # Use different vault and output file
109 1pass-env import --vault secrets --name my-app --file .env.prod
111 # Enable debug logging
112 1pass-env import --name my-app --debug
113 """
114 # Check service account token
115 token = os.getenv("OP_SERVICE_ACCOUNT_TOKEN")
116 if not token:
117 console.print("[red]✗[/red] OP_SERVICE_ACCOUNT_TOKEN environment variable not set")
118 console.print("\n[yellow]To fix this:[/yellow]")
119 console.print("1. Create a service account at https://my.1password.com/developer-tools/infrastructure-secrets/serviceaccount")
120 console.print("2. Export the token: [bold]export OP_SERVICE_ACCOUNT_TOKEN='your-token-here'[/bold]")
121 sys.exit(1)
123 # Get current folder name as default for item name
124 if not name:
125 name = Path.cwd().name
126 if debug:
127 console.print(f"[dim]Using current folder name as item name: {name}[/dim]")
129 # Parse fields if provided
130 field_list = None
131 if fields:
132 field_list = [field.strip() for field in fields.split(",")]
133 if debug:
134 console.print(f"[dim]Will import specific fields: {', '.join(field_list)}[/dim]")
136 env_path = Path(env_file)
138 # Check if file exists and ask for permission
139 if env_path.exists():
140 console.print(f"[yellow]⚠️[/yellow] File '{env_file}' already exists")
141 if not click.confirm("Do you want to proceed and overwrite/merge with the existing file?"):
142 console.print("[yellow]Operation cancelled[/yellow]")
143 return
145 try:
146 from onepass_env.onepassword import OnePasswordClient
148 if debug:
149 console.print(f"[dim]Connecting to 1Password vault: {vault}[/dim]")
151 op_client = OnePasswordClient(vault=vault, verbose=debug)
153 # Check authentication
154 if not op_client.is_authenticated():
155 raise OnePassEnvError(
156 "Not authenticated with 1Password. Please check your OP_SERVICE_ACCOUNT_TOKEN "
157 "environment variable and ensure it has access to the specified vault."
158 )
160 # Search for the item by name
161 if debug:
162 console.print(f"[dim]Searching for item: {name}[/dim]")
164 item = op_client.get_item_by_title(name)
165 if not item:
166 raise OnePassEnvError(f"Item '{name}' not found in vault '{vault}'")
168 if debug:
169 console.print(f"[dim]Found item: {item.title} (ID: {item.id})[/dim]")
171 # Extract fields
172 imported_vars = {}
173 skipped_fields = []
175 for field in item.fields:
176 # Skip fields without titles or values
177 if not field.title or not field.value:
178 continue
180 # If specific fields requested, check if this field is in the list
181 if field_list and field.title not in field_list:
182 skipped_fields.append(field.title)
183 continue
185 imported_vars[field.title] = field.value
186 if debug:
187 console.print(f"[dim]Imported field: {field.title}[/dim]")
189 if not imported_vars:
190 console.print(f"[yellow]![/yellow] No fields found to import from item '{name}'")
191 if skipped_fields:
192 console.print(f"[dim]Available fields: {', '.join(skipped_fields)}[/dim]")
193 return
195 # Read existing env file if it exists
196 existing_vars = {}
197 if env_path.exists():
198 try:
199 from dotenv import dotenv_values
200 existing_vars = dict(dotenv_values(str(env_path)))
201 if debug:
202 console.print(f"[dim]Found {len(existing_vars)} existing variables in {env_file}[/dim]")
203 except Exception as e:
204 if debug:
205 console.print(f"[dim]Could not read existing env file: {e}[/dim]")
207 # Merge variables (imported variables take precedence)
208 final_vars = {**existing_vars, **imported_vars}
210 # Write to file
211 with open(env_path, 'w') as f:
212 # Add header comment
213 f.write(f"# Environment variables imported from 1Password\n")
214 f.write(f"# Vault: {vault}\n")
215 f.write(f"# Item: {name}\n")
216 f.write(f"# Generated by 1pass-env\n\n")
218 # Write variables
219 for key, value in final_vars.items():
220 # Escape quotes in values
221 escaped_value = str(value).replace('"', '\\"')
222 f.write(f'{key}="{escaped_value}"\n')
224 # Show summary
225 console.print(f"[green]✓[/green] Successfully imported {len(imported_vars)} variables from '{name}'")
226 console.print(f"[blue]ℹ[/blue] Saved to: {env_file}")
228 # Show imported variables
229 if debug or len(imported_vars) <= 10:
230 table = Table(title="Imported Variables")
231 table.add_column("Variable", style="cyan")
232 table.add_column("Value", style="green")
234 for key, value in imported_vars.items():
235 # Mask the value for security
236 masked_value = "*" * min(len(str(value)), 8) if not debug else str(value)
237 table.add_row(key, masked_value)
239 console.print(table)
240 else:
241 console.print(f"[dim]Use --debug to see all imported values[/dim]")
243 if skipped_fields:
244 console.print(f"[yellow]![/yellow] Skipped {len(skipped_fields)} fields not in filter")
245 if debug:
246 console.print(f"[dim]Skipped: {', '.join(skipped_fields)}[/dim]")
248 # Show usage instructions
249 console.print(f"\n[blue]💡[/blue] To use these variables:")
250 console.print(f" • Load manually: [bold]source {env_file}[/bold]")
251 console.print(f" • Use with dotenv: [bold]python-dotenv load {env_file}[/bold]")
252 console.print(f" • Docker: [bold]docker run --env-file {env_file} myapp[/bold]")
254 except OnePassEnvError as e:
255 console.print(f"[red]✗[/red] Error: {e}")
256 sys.exit(1)
257 except Exception as e:
258 console.print(f"[red]✗[/red] Unexpected error: {e}")
259 if debug:
260 import traceback
261 console.print(traceback.format_exc())
262 sys.exit(1)
265def main() -> None:
266 """Entry point for the CLI."""
267 try:
268 cli()
269 except KeyboardInterrupt:
270 console.print("\n[yellow]Operation cancelled by user[/yellow]")
271 sys.exit(1)
272 except Exception as e:
273 console.print(f"[red]Unexpected error: {e}[/red]")
274 sys.exit(1)
277if __name__ == "__main__":
278 main()