11"""AWS Secrets Manager CLI."""
22
33import json
4+ import secrets as stdlib_secrets
5+ import string
46import sys
57
68import click
9+ import questionary
710
811from deploy import aws
912from deploy .utils import echo
1013
1114
15+ def _generate_password (length : int ) -> str :
16+ """Letters + digits only — avoids shell/quoting issues across services."""
17+ alphabet = string .ascii_letters + string .digits
18+ return "" .join (stdlib_secrets .choice (alphabet ) for _ in range (length ))
19+
20+
21+ def _prompt_password_length () -> int :
22+ return int (
23+ questionary .text (
24+ "Password length:" ,
25+ default = "32" ,
26+ validate = lambda x : x .isdigit () and int (x ) > 0 ,
27+ ).unsafe_ask ()
28+ )
29+
30+
31+ def _parse_dotenv (text : str ) -> dict [str , str ]:
32+ """Parse dotenv-style KEY=VALUE lines. Skips blanks and `#` comments."""
33+ updates = {}
34+ for line_num , raw_line in enumerate (text .strip ().split ("\n " ), 1 ):
35+ line = raw_line .strip ()
36+ if not line or line .startswith ("#" ):
37+ continue
38+ if "=" not in line :
39+ raise ValueError (f"Line { line_num } : missing '=' separator" )
40+ key , value = line .split ("=" , 1 )
41+ key = key .strip ()
42+ value = value .strip ()
43+ if not key :
44+ raise ValueError (f"Line { line_num } : empty key" )
45+ if (value .startswith ('"' ) and value .endswith ('"' )) or (value .startswith ("'" ) and value .endswith ("'" )):
46+ value = value [1 :- 1 ]
47+ updates [key ] = value
48+ return updates
49+
50+
1251@click .group ()
1352def secrets ():
1453 """Manage values in AWS Secrets Manager."""
@@ -28,26 +67,103 @@ def get(secret_id, key):
2867 click .echo (json .dumps (secrets_dict , indent = 2 , sort_keys = True ))
2968
3069
31- @secrets .command (help = "Securely store key-value pair in AWS Secrets Manager " )
70+ @secrets .command (help = "Set one or more secrets " )
3271@click .option ("--secret-id" , default = "production-env" )
33- @click .argument ("key" )
34- @click .argument ("value" )
35- def set (key , value , secret_id ):
36- aws .store_secret (key , value , secret_id )
72+ @click .option ("--generate" , is_flag = True , help = "Generate a secure random password" )
73+ @click .argument ("key" , required = False )
74+ @click .argument ("value" , required = False )
75+ def set (key , value , generate , secret_id ):
76+ """Set secret(s).
77+
78+ \b
79+ Single key:
80+ secrets set KEY VALUE
81+ secrets set KEY --generate
82+
83+ Batch (no args): paste dotenv-style KEY=VALUE lines.
84+ """
85+ if key is None :
86+ if generate :
87+ echo ("--generate requires a KEY" )
88+ sys .exit (1 )
89+
90+ echo ("Enter KEY=VALUE pairs (one per line, Ctrl+D when done):" )
91+ text = questionary .text ("Paste KEY=VALUE pairs:" , multiline = True ).unsafe_ask ()
92+ if not text :
93+ echo ("No input provided." )
94+ sys .exit (1 )
3795
96+ try :
97+ updates = _parse_dotenv (text )
98+ except ValueError as e :
99+ echo (f"Error parsing input: { e } " )
100+ sys .exit (1 )
101+
102+ if not updates :
103+ echo ("No valid KEY=VALUE pairs found." )
104+ sys .exit (1 )
105+ else :
106+ if generate and value :
107+ echo ("Cannot specify both VALUE and --generate" )
108+ sys .exit (1 )
109+ if not generate and value is None :
110+ echo ("Must provide VALUE or use --generate" )
111+ sys .exit (1 )
112+ if generate :
113+ length = _prompt_password_length ()
114+ value = _generate_password (length )
115+ echo (f"Generated password: { value } " )
116+ updates = {key : value }
117+
118+ current = aws .get_secrets (secret_id )
119+ overrides = sorted (k for k in updates if k in current )
120+ if overrides :
121+ echo ("Will overwrite existing key(s):" )
122+ for k in overrides :
123+ echo (f" - { k } " )
124+ if not questionary .confirm ("Continue?" , default = False ).unsafe_ask ():
125+ echo ("Cancelled." )
126+ sys .exit (0 )
38127
39- @secrets .command ("set-from-file" , help = "Securely store value from file in AWS Secrets Manager" )
128+ aws .store_secrets (updates , secret_id )
129+ echo (f"Updated { len (updates )} key(s):" )
130+ for k in updates :
131+ echo (f" - { k } " )
132+
133+
134+ @secrets .command ("set-from-file" , help = "Store the contents of a file as a single secret value" )
40135@click .option ("--secret-id" , default = "production-env" )
41136@click .argument ("key" )
42137@click .argument ("filename" )
43138def set_from_file (key , filename , secret_id ):
44139 with open (filename ) as source :
45140 value = source .read ()
46- aws .store_secret (key , value , secret_id )
47141
142+ current = aws .get_secrets (secret_id )
143+ if key in current and not questionary .confirm (f"{ key } already in secrets. Overwrite?" , default = False ).unsafe_ask ():
144+ echo ("Cancelled." )
145+ sys .exit (0 )
146+
147+ aws .store_secrets ({key : value }, secret_id )
148+ echo (f"Updated: { key } " )
48149
49- @secrets .command (help = "Remove a secret from AWS Secrets Manager" )
150+
151+ @secrets .command (help = "Remove one or more secrets" )
50152@click .option ("--secret-id" , default = "production-env" )
51- @click .argument ("key" )
52- def delete (key , secret_id ):
53- aws .remove_secret (key , secret_id )
153+ @click .argument ("keys" , nargs = - 1 , required = True )
154+ def delete (keys , secret_id ):
155+ echo ("Keys to delete:" )
156+ for k in keys :
157+ echo (f" - { k } " )
158+ if not questionary .confirm (f"Delete { len (keys )} key(s)?" , default = False ).unsafe_ask ():
159+ echo ("Cancelled." )
160+ sys .exit (0 )
161+
162+ removed = aws .remove_secrets (keys , secret_id )
163+ missing = [k for k in keys if k not in removed ]
164+ if removed :
165+ echo (f"Deleted { len (removed )} key(s):" )
166+ for k in removed :
167+ echo (f" - { k } " )
168+ for k in missing :
169+ echo (f"Not found, skipped: { k } " )
0 commit comments