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

1"""Main CLI interface for 1pass-env - Import command only.""" 

2 

3import os 

4import sys 

5from pathlib import Path 

6from typing import Optional 

7 

8import click 

9from rich.console import Console 

10from rich.panel import Panel 

11from rich.table import Table 

12 

13from onepass_env.__about__ import __version__ 

14from onepass_env.exceptions import OnePassEnvError 

15 

16console = Console() 

17 

18 

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() 

25 

26 

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. 

39  

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) 

44 

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 )) 

54 

55 

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. 

92  

93 This command imports secrets from an existing 1Password item into your 

94 environment file. By default, it imports all fields from the item. 

95  

96 Examples: 

97  

98 \b 

99 # Import all fields from current folder name 

100 1pass-env import 

101  

102 # Import from specific item 

103 1pass-env import --name my-app 

104  

105 # Import specific fields only 

106 1pass-env import --name my-app --fields API_KEY,DB_PASSWORD 

107  

108 # Use different vault and output file 

109 1pass-env import --vault secrets --name my-app --file .env.prod 

110  

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) 

122 

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]") 

128 

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]") 

135 

136 env_path = Path(env_file) 

137 

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 

144 

145 try: 

146 from onepass_env.onepassword import OnePasswordClient 

147 

148 if debug: 

149 console.print(f"[dim]Connecting to 1Password vault: {vault}[/dim]") 

150 

151 op_client = OnePasswordClient(vault=vault, verbose=debug) 

152 

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 ) 

159 

160 # Search for the item by name 

161 if debug: 

162 console.print(f"[dim]Searching for item: {name}[/dim]") 

163 

164 item = op_client.get_item_by_title(name) 

165 if not item: 

166 raise OnePassEnvError(f"Item '{name}' not found in vault '{vault}'") 

167 

168 if debug: 

169 console.print(f"[dim]Found item: {item.title} (ID: {item.id})[/dim]") 

170 

171 # Extract fields 

172 imported_vars = {} 

173 skipped_fields = [] 

174 

175 for field in item.fields: 

176 # Skip fields without titles or values 

177 if not field.title or not field.value: 

178 continue 

179 

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 

184 

185 imported_vars[field.title] = field.value 

186 if debug: 

187 console.print(f"[dim]Imported field: {field.title}[/dim]") 

188 

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 

194 

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]") 

206 

207 # Merge variables (imported variables take precedence) 

208 final_vars = {**existing_vars, **imported_vars} 

209 

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") 

217 

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') 

223 

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}") 

227 

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") 

233 

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) 

238 

239 console.print(table) 

240 else: 

241 console.print(f"[dim]Use --debug to see all imported values[/dim]") 

242 

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]") 

247 

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]") 

253 

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) 

263 

264 

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) 

275 

276 

277if __name__ == "__main__": 

278 main()