vaultuner 0.1.6__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.
- vaultuner/__init__.py +2 -0
- vaultuner/cli.py +434 -0
- vaultuner/client.py +60 -0
- vaultuner/config.py +104 -0
- vaultuner/export.py +105 -0
- vaultuner/import_env.py +45 -0
- vaultuner/models.py +55 -0
- vaultuner-0.1.6.dist-info/METADATA +146 -0
- vaultuner-0.1.6.dist-info/RECORD +11 -0
- vaultuner-0.1.6.dist-info/WHEEL +4 -0
- vaultuner-0.1.6.dist-info/entry_points.txt +3 -0
vaultuner/__init__.py
ADDED
vaultuner/cli.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
# ABOUTME: Typer CLI for Bitwarden Secrets Manager.
|
|
2
|
+
# ABOUTME: Commands for listing, getting, setting, and deleting secrets.
|
|
3
|
+
|
|
4
|
+
from importlib.metadata import version
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated, Literal
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
|
|
12
|
+
from vaultuner.client import find_secret_by_key, get_client, get_or_create_project
|
|
13
|
+
from vaultuner.config import (
|
|
14
|
+
DEFAULT_PROJECT_NAME,
|
|
15
|
+
delete_keyring_value,
|
|
16
|
+
get_keyring_value,
|
|
17
|
+
get_settings,
|
|
18
|
+
set_keyring_value,
|
|
19
|
+
)
|
|
20
|
+
from vaultuner.models import (
|
|
21
|
+
SecretPath,
|
|
22
|
+
is_deleted,
|
|
23
|
+
mark_deleted,
|
|
24
|
+
unmark_deleted,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
__version__ = version("vaultuner")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def version_callback(value: bool) -> None:
|
|
31
|
+
if value:
|
|
32
|
+
print(f"vaultuner {__version__}")
|
|
33
|
+
raise typer.Exit()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
app = typer.Typer(
|
|
37
|
+
help=f"Bitwarden Secrets Manager CLI with PROJECT/[ENV/]SECRET naming. (v{__version__})",
|
|
38
|
+
no_args_is_help=True,
|
|
39
|
+
)
|
|
40
|
+
config_app = typer.Typer(help="Manage vaultuner credentials stored in system keychain.")
|
|
41
|
+
app.add_typer(config_app, name="config")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@app.callback()
|
|
45
|
+
def main(
|
|
46
|
+
version: Annotated[
|
|
47
|
+
bool, typer.Option("--version", "-V", callback=version_callback, is_eager=True)
|
|
48
|
+
] = False,
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Bitwarden Secrets Manager CLI."""
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
console = Console()
|
|
54
|
+
err_console = Console(stderr=True)
|
|
55
|
+
|
|
56
|
+
ConfigKey = Literal["access-token", "organization-id"]
|
|
57
|
+
KEYRING_MAP = {
|
|
58
|
+
"access-token": "bws_access_token",
|
|
59
|
+
"organization-id": "bws_organization_id",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@config_app.command("set")
|
|
64
|
+
def config_set(
|
|
65
|
+
key: ConfigKey = typer.Argument(..., help="Config key to set"),
|
|
66
|
+
value: str = typer.Argument(..., help="Value to store"),
|
|
67
|
+
):
|
|
68
|
+
"""Store a credential in the system keychain."""
|
|
69
|
+
keyring_key = KEYRING_MAP[key]
|
|
70
|
+
set_keyring_value(keyring_key, value)
|
|
71
|
+
console.print(f"[green]Stored:[/green] {key}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@config_app.command("show")
|
|
75
|
+
def config_show():
|
|
76
|
+
"""Show current configuration status."""
|
|
77
|
+
table = Table(show_header=True, header_style="bold")
|
|
78
|
+
table.add_column("Setting", style="cyan")
|
|
79
|
+
table.add_column("Status")
|
|
80
|
+
|
|
81
|
+
access_token = get_keyring_value(KEYRING_MAP["access-token"])
|
|
82
|
+
org_id = get_keyring_value(KEYRING_MAP["organization-id"])
|
|
83
|
+
|
|
84
|
+
table.add_row(
|
|
85
|
+
"access-token",
|
|
86
|
+
"[green]configured[/green]" if access_token else "[red]not set[/red]",
|
|
87
|
+
)
|
|
88
|
+
table.add_row(
|
|
89
|
+
"organization-id",
|
|
90
|
+
"[green]configured[/green]" if org_id else "[red]not set[/red]",
|
|
91
|
+
)
|
|
92
|
+
console.print(table)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@config_app.command("delete")
|
|
96
|
+
def config_delete(
|
|
97
|
+
key: ConfigKey = typer.Argument(..., help="Config key to delete"),
|
|
98
|
+
):
|
|
99
|
+
"""Remove a credential from the system keychain."""
|
|
100
|
+
keyring_key = KEYRING_MAP[key]
|
|
101
|
+
delete_keyring_value(keyring_key)
|
|
102
|
+
console.print(f"[red]Deleted:[/red] {key}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@app.command("list")
|
|
106
|
+
def list_secrets(
|
|
107
|
+
project: str | None = typer.Option(
|
|
108
|
+
None, "--project", "-p", help="Filter by project"
|
|
109
|
+
),
|
|
110
|
+
env: str | None = typer.Option(None, "--env", "-e", help="Filter by environment"),
|
|
111
|
+
deleted: bool = typer.Option(False, "--deleted", "-d", help="Show deleted secrets"),
|
|
112
|
+
):
|
|
113
|
+
"""List secrets. Optionally filter by project and/or environment."""
|
|
114
|
+
settings = get_settings()
|
|
115
|
+
client = get_client()
|
|
116
|
+
response = client.secrets().list(settings.organization_id)
|
|
117
|
+
if not response.data or not response.data.data:
|
|
118
|
+
console.print("[dim]No secrets found.[/dim]")
|
|
119
|
+
return
|
|
120
|
+
|
|
121
|
+
table = Table(show_header=True, header_style="bold")
|
|
122
|
+
table.add_column("Project", style="cyan")
|
|
123
|
+
table.add_column("Env", style="yellow")
|
|
124
|
+
table.add_column("Name", style="green")
|
|
125
|
+
if deleted:
|
|
126
|
+
table.add_column("Status", style="red")
|
|
127
|
+
|
|
128
|
+
count = 0
|
|
129
|
+
for secret in response.data.data:
|
|
130
|
+
key = secret.key
|
|
131
|
+
secret_deleted = is_deleted(key)
|
|
132
|
+
|
|
133
|
+
if secret_deleted and not deleted:
|
|
134
|
+
continue
|
|
135
|
+
|
|
136
|
+
display_key = unmark_deleted(key) if secret_deleted else key
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
path = SecretPath.parse(display_key)
|
|
140
|
+
except ValueError:
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
if project and path.project != project:
|
|
144
|
+
continue
|
|
145
|
+
if env and path.env != env:
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
if deleted:
|
|
149
|
+
status = "[red]deleted[/red]" if secret_deleted else "[green]active[/green]"
|
|
150
|
+
table.add_row(path.project, path.env or "-", path.name, status)
|
|
151
|
+
else:
|
|
152
|
+
table.add_row(path.project, path.env or "-", path.name)
|
|
153
|
+
count += 1
|
|
154
|
+
|
|
155
|
+
if count == 0:
|
|
156
|
+
console.print("[dim]No secrets found.[/dim]")
|
|
157
|
+
else:
|
|
158
|
+
console.print(table)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command()
|
|
162
|
+
def get(
|
|
163
|
+
path: str = typer.Argument(..., help="Secret path: PROJECT/[ENV/]NAME"),
|
|
164
|
+
value_only: bool = typer.Option(
|
|
165
|
+
False, "--value", "-v", help="Print only the value"
|
|
166
|
+
),
|
|
167
|
+
):
|
|
168
|
+
"""Get a secret by path."""
|
|
169
|
+
client = get_client()
|
|
170
|
+
secret_info = find_secret_by_key(client, path)
|
|
171
|
+
if not secret_info:
|
|
172
|
+
err_console.print(f"[red]Secret not found:[/red] {path}")
|
|
173
|
+
raise typer.Exit(1)
|
|
174
|
+
|
|
175
|
+
response = client.secrets().get(secret_info["id"])
|
|
176
|
+
if not response.data:
|
|
177
|
+
err_console.print(f"[red]Failed to retrieve secret:[/red] {path}")
|
|
178
|
+
raise typer.Exit(1)
|
|
179
|
+
|
|
180
|
+
if value_only:
|
|
181
|
+
console.print(response.data.value)
|
|
182
|
+
else:
|
|
183
|
+
table = Table(show_header=False, box=None)
|
|
184
|
+
table.add_column("Label", style="dim")
|
|
185
|
+
table.add_column("Value")
|
|
186
|
+
table.add_row("Path", f"[cyan]{response.data.key}[/cyan]")
|
|
187
|
+
table.add_row("Value", f"[green]{response.data.value}[/green]")
|
|
188
|
+
if response.data.note:
|
|
189
|
+
table.add_row("Note", f"[dim]{response.data.note}[/dim]")
|
|
190
|
+
console.print(table)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@app.command()
|
|
194
|
+
def set(
|
|
195
|
+
path: str = typer.Argument(..., help="Secret path: PROJECT/[ENV/]NAME"),
|
|
196
|
+
value: str = typer.Argument(..., help="Secret value"),
|
|
197
|
+
note: str | None = typer.Option(None, "--note", "-n", help="Optional note"),
|
|
198
|
+
):
|
|
199
|
+
"""Create or update a secret."""
|
|
200
|
+
settings = get_settings()
|
|
201
|
+
client = get_client()
|
|
202
|
+
|
|
203
|
+
existing = find_secret_by_key(client, path)
|
|
204
|
+
if existing:
|
|
205
|
+
response = client.secrets().update(
|
|
206
|
+
organization_id=settings.organization_id,
|
|
207
|
+
id=existing["id"],
|
|
208
|
+
key=path,
|
|
209
|
+
value=value,
|
|
210
|
+
note=note,
|
|
211
|
+
project_ids=None,
|
|
212
|
+
)
|
|
213
|
+
if not response.data:
|
|
214
|
+
err_console.print("[red]Failed to update secret.[/red]")
|
|
215
|
+
raise typer.Exit(1)
|
|
216
|
+
console.print(f"[yellow]Updated:[/yellow] {path}")
|
|
217
|
+
else:
|
|
218
|
+
project_id = get_or_create_project(client, DEFAULT_PROJECT_NAME)
|
|
219
|
+
response = client.secrets().create(
|
|
220
|
+
organization_id=settings.organization_id,
|
|
221
|
+
key=path,
|
|
222
|
+
value=value,
|
|
223
|
+
note=note,
|
|
224
|
+
project_ids=[project_id],
|
|
225
|
+
)
|
|
226
|
+
if not response.data:
|
|
227
|
+
err_console.print("[red]Failed to create secret.[/red]")
|
|
228
|
+
raise typer.Exit(1)
|
|
229
|
+
console.print(f"[green]Created:[/green] {path}")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@app.command()
|
|
233
|
+
def delete(
|
|
234
|
+
path: str = typer.Argument(..., help="Secret path: PROJECT/[ENV/]NAME"),
|
|
235
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation"),
|
|
236
|
+
permanent: bool = typer.Option(False, "--permanent", help="Permanently delete"),
|
|
237
|
+
):
|
|
238
|
+
"""Delete a secret (soft-delete by default)."""
|
|
239
|
+
settings = get_settings()
|
|
240
|
+
client = get_client()
|
|
241
|
+
secret_info = find_secret_by_key(client, path)
|
|
242
|
+
if not secret_info:
|
|
243
|
+
err_console.print(f"[red]Secret not found:[/red] {path}")
|
|
244
|
+
raise typer.Exit(1)
|
|
245
|
+
|
|
246
|
+
if not force:
|
|
247
|
+
action = "permanently delete" if permanent else "delete"
|
|
248
|
+
confirm = typer.confirm(f"{action.capitalize()} secret '{path}'?")
|
|
249
|
+
if not confirm:
|
|
250
|
+
raise typer.Abort()
|
|
251
|
+
|
|
252
|
+
if permanent:
|
|
253
|
+
client.secrets().delete([secret_info["id"]])
|
|
254
|
+
console.print(f"[red]Permanently deleted:[/red] {path}")
|
|
255
|
+
else:
|
|
256
|
+
response = client.secrets().get(secret_info["id"])
|
|
257
|
+
if not response.data:
|
|
258
|
+
err_console.print("[red]Failed to get secret for deletion.[/red]")
|
|
259
|
+
raise typer.Exit(1)
|
|
260
|
+
|
|
261
|
+
deleted_key = mark_deleted(path)
|
|
262
|
+
project_ids = (
|
|
263
|
+
[str(response.data.project_id)] if response.data.project_id else None
|
|
264
|
+
)
|
|
265
|
+
client.secrets().update(
|
|
266
|
+
organization_id=settings.organization_id,
|
|
267
|
+
id=secret_info["id"],
|
|
268
|
+
key=deleted_key,
|
|
269
|
+
value=response.data.value,
|
|
270
|
+
note=response.data.note,
|
|
271
|
+
project_ids=project_ids,
|
|
272
|
+
)
|
|
273
|
+
console.print(f"[red]Deleted:[/red] {path}")
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@app.command()
|
|
277
|
+
def restore(
|
|
278
|
+
path: str = typer.Argument(..., help="Secret path: PROJECT/[ENV/]NAME"),
|
|
279
|
+
):
|
|
280
|
+
"""Restore a soft-deleted secret."""
|
|
281
|
+
settings = get_settings()
|
|
282
|
+
client = get_client()
|
|
283
|
+
deleted_key = mark_deleted(path)
|
|
284
|
+
secret_info = find_secret_by_key(client, deleted_key)
|
|
285
|
+
if not secret_info:
|
|
286
|
+
err_console.print(f"[red]Deleted secret not found:[/red] {path}")
|
|
287
|
+
raise typer.Exit(1)
|
|
288
|
+
|
|
289
|
+
response = client.secrets().get(secret_info["id"])
|
|
290
|
+
if not response.data:
|
|
291
|
+
err_console.print("[red]Failed to get secret for restoration.[/red]")
|
|
292
|
+
raise typer.Exit(1)
|
|
293
|
+
|
|
294
|
+
project_ids = [str(response.data.project_id)] if response.data.project_id else None
|
|
295
|
+
client.secrets().update(
|
|
296
|
+
organization_id=settings.organization_id,
|
|
297
|
+
id=secret_info["id"],
|
|
298
|
+
key=path,
|
|
299
|
+
value=response.data.value,
|
|
300
|
+
note=response.data.note,
|
|
301
|
+
project_ids=project_ids,
|
|
302
|
+
)
|
|
303
|
+
console.print(f"[green]Restored:[/green] {path}")
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@app.command()
|
|
307
|
+
def projects():
|
|
308
|
+
"""List all projects."""
|
|
309
|
+
settings = get_settings()
|
|
310
|
+
client = get_client()
|
|
311
|
+
response = client.projects().list(settings.organization_id)
|
|
312
|
+
if not response.data or not response.data.data:
|
|
313
|
+
console.print("[dim]No projects found.[/dim]")
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
table = Table(show_header=True, header_style="bold")
|
|
317
|
+
table.add_column("Project", style="cyan")
|
|
318
|
+
|
|
319
|
+
for project in response.data.data:
|
|
320
|
+
table.add_row(project.name)
|
|
321
|
+
|
|
322
|
+
console.print(table)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@app.command()
|
|
326
|
+
def export(
|
|
327
|
+
project: str | None = typer.Option(
|
|
328
|
+
None,
|
|
329
|
+
"--project",
|
|
330
|
+
"-p",
|
|
331
|
+
help="Project name (defaults to current directory name)",
|
|
332
|
+
),
|
|
333
|
+
env: str | None = typer.Option(None, "--env", "-e", help="Filter by environment"),
|
|
334
|
+
output: Path = typer.Option(
|
|
335
|
+
Path(".env"), "--output", "-o", help="Output file path (default: .env)"
|
|
336
|
+
),
|
|
337
|
+
):
|
|
338
|
+
"""Export project secrets to a .env file."""
|
|
339
|
+
from vaultuner.export import export_secrets
|
|
340
|
+
|
|
341
|
+
project_name = project or Path.cwd().name
|
|
342
|
+
added_count, skipped_count = export_secrets(project_name, output, env)
|
|
343
|
+
|
|
344
|
+
if added_count == 0 and skipped_count == 0:
|
|
345
|
+
console.print(f"[dim]No secrets found for project '{project_name}'.[/dim]")
|
|
346
|
+
else:
|
|
347
|
+
console.print(
|
|
348
|
+
f"[green]Exported to {output}:[/green] {added_count} added, {skipped_count} already present"
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
@app.command("import")
|
|
353
|
+
def import_env(
|
|
354
|
+
project: str | None = typer.Option(
|
|
355
|
+
None,
|
|
356
|
+
"--project",
|
|
357
|
+
"-p",
|
|
358
|
+
help="Project name (defaults to current directory name)",
|
|
359
|
+
),
|
|
360
|
+
env: str | None = typer.Option(None, "--env", "-e", help="Environment for secrets"),
|
|
361
|
+
input_file: Path = typer.Option(
|
|
362
|
+
Path(".env"), "--input", "-i", help="Input file path (default: .env)"
|
|
363
|
+
),
|
|
364
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Import all without prompting"),
|
|
365
|
+
):
|
|
366
|
+
"""Import secrets from a .env file to the secret store."""
|
|
367
|
+
from vaultuner.import_env import (
|
|
368
|
+
build_secret_path,
|
|
369
|
+
env_var_to_secret_name,
|
|
370
|
+
parse_env_entries,
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
if not input_file.exists():
|
|
374
|
+
err_console.print(f"[red]File not found:[/red] {input_file}")
|
|
375
|
+
raise typer.Exit(1)
|
|
376
|
+
|
|
377
|
+
project_name = project or Path.cwd().name
|
|
378
|
+
entries = parse_env_entries(input_file)
|
|
379
|
+
|
|
380
|
+
if not entries:
|
|
381
|
+
console.print(f"[dim]No entries found in {input_file}.[/dim]")
|
|
382
|
+
return
|
|
383
|
+
|
|
384
|
+
settings = get_settings()
|
|
385
|
+
client = get_client()
|
|
386
|
+
|
|
387
|
+
# Phase 1: Collect secrets to import
|
|
388
|
+
to_import: list[tuple[str, str]] = []
|
|
389
|
+
skipped_count = 0
|
|
390
|
+
|
|
391
|
+
for var_name, value in entries:
|
|
392
|
+
secret_name = env_var_to_secret_name(var_name)
|
|
393
|
+
secret_path = build_secret_path(project_name, env, secret_name)
|
|
394
|
+
|
|
395
|
+
# Check if secret already exists
|
|
396
|
+
existing = find_secret_by_key(client, secret_path)
|
|
397
|
+
if existing:
|
|
398
|
+
console.print(f"[dim]Already exists:[/dim] {secret_path}")
|
|
399
|
+
skipped_count += 1
|
|
400
|
+
continue
|
|
401
|
+
|
|
402
|
+
if not yes:
|
|
403
|
+
console.print(f"\n[cyan]{var_name}[/cyan] [dim]({len(value)} chars)[/dim]")
|
|
404
|
+
console.print(f" → [green]{secret_path}[/green]")
|
|
405
|
+
if not typer.confirm("Store this secret?", default=True):
|
|
406
|
+
skipped_count += 1
|
|
407
|
+
continue
|
|
408
|
+
|
|
409
|
+
to_import.append((secret_path, value))
|
|
410
|
+
|
|
411
|
+
if not to_import:
|
|
412
|
+
console.print("\n[dim]Nothing to import.[/dim]")
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
# Phase 2: Import all approved secrets
|
|
416
|
+
console.print(f"\n[cyan]Importing {len(to_import)} secrets...[/cyan]")
|
|
417
|
+
project_id = get_or_create_project(client, DEFAULT_PROJECT_NAME)
|
|
418
|
+
created_count = 0
|
|
419
|
+
|
|
420
|
+
for secret_path, value in to_import:
|
|
421
|
+
response = client.secrets().create(
|
|
422
|
+
organization_id=settings.organization_id,
|
|
423
|
+
key=secret_path,
|
|
424
|
+
value=value,
|
|
425
|
+
note=None,
|
|
426
|
+
project_ids=[project_id],
|
|
427
|
+
)
|
|
428
|
+
if response.data:
|
|
429
|
+
created_count += 1
|
|
430
|
+
console.print(f"[green]Created:[/green] {secret_path}")
|
|
431
|
+
|
|
432
|
+
console.print(
|
|
433
|
+
f"\n[green]Import complete:[/green] {created_count} created, {skipped_count} skipped"
|
|
434
|
+
)
|
vaultuner/client.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# ABOUTME: Bitwarden Secrets Manager client wrapper.
|
|
2
|
+
# ABOUTME: Handles authentication and provides helper functions for secrets/projects.
|
|
3
|
+
|
|
4
|
+
import tempfile
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from bitwarden_sdk import BitwardenClient, DeviceType, client_settings_from_dict
|
|
8
|
+
|
|
9
|
+
from vaultuner.config import get_settings
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_client() -> BitwardenClient:
|
|
13
|
+
"""Create and authenticate a Bitwarden client."""
|
|
14
|
+
settings = get_settings()
|
|
15
|
+
client = BitwardenClient(
|
|
16
|
+
client_settings_from_dict(
|
|
17
|
+
{
|
|
18
|
+
"apiUrl": settings.api_url,
|
|
19
|
+
"identityUrl": settings.identity_url,
|
|
20
|
+
"deviceType": DeviceType.SDK,
|
|
21
|
+
"userAgent": "vaultuner",
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
)
|
|
25
|
+
with tempfile.NamedTemporaryFile(
|
|
26
|
+
mode="w", suffix=".json", delete=False, delete_on_close=False
|
|
27
|
+
) as f:
|
|
28
|
+
state_path = Path(f.name)
|
|
29
|
+
client.auth().login_access_token(
|
|
30
|
+
settings.access_token.get_secret_value(), str(state_path)
|
|
31
|
+
)
|
|
32
|
+
state_path.unlink(missing_ok=True)
|
|
33
|
+
return client
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_or_create_project(client: BitwardenClient, project_name: str) -> str:
|
|
37
|
+
"""Get project ID by name, creating it if it doesn't exist."""
|
|
38
|
+
settings = get_settings()
|
|
39
|
+
response = client.projects().list(settings.organization_id)
|
|
40
|
+
if response.data and response.data.data:
|
|
41
|
+
for project in response.data.data:
|
|
42
|
+
if project.name == project_name:
|
|
43
|
+
return str(project.id)
|
|
44
|
+
|
|
45
|
+
result = client.projects().create(settings.organization_id, project_name)
|
|
46
|
+
if not result.data:
|
|
47
|
+
raise RuntimeError(f"Failed to create project: {project_name}")
|
|
48
|
+
return str(result.data.id)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def find_secret_by_key(client: BitwardenClient, key: str) -> dict | None:
|
|
52
|
+
"""Find a secret by its key name."""
|
|
53
|
+
settings = get_settings()
|
|
54
|
+
response = client.secrets().list(settings.organization_id)
|
|
55
|
+
if not response.data or not response.data.data:
|
|
56
|
+
return None
|
|
57
|
+
for secret in response.data.data:
|
|
58
|
+
if secret.key == key:
|
|
59
|
+
return {"id": str(secret.id), "key": secret.key}
|
|
60
|
+
return None
|
vaultuner/config.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# ABOUTME: Configuration settings for Bitwarden Secrets Manager.
|
|
2
|
+
# ABOUTME: Loads credentials from keychain (preferred) or environment variables.
|
|
3
|
+
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import keyring
|
|
7
|
+
from pydantic import SecretStr
|
|
8
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
9
|
+
|
|
10
|
+
SERVICE_NAME = "vaultuner"
|
|
11
|
+
DEFAULT_PROJECT_NAME = "vaultuner"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _require_darwin() -> None:
|
|
15
|
+
"""Ensure we're running on macOS where keyring is supported."""
|
|
16
|
+
if sys.platform != "darwin":
|
|
17
|
+
raise SystemExit(
|
|
18
|
+
"Keyring storage is only supported on macOS. "
|
|
19
|
+
"Use environment variables BWS_ACCESS_TOKEN and BWS_ORGANIZATION_ID instead."
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def get_keyring_value(key: str) -> str | None:
|
|
24
|
+
"""Get a value from the system keychain. Returns None on non-darwin."""
|
|
25
|
+
if sys.platform != "darwin":
|
|
26
|
+
return None
|
|
27
|
+
return keyring.get_password(SERVICE_NAME, key)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def set_keyring_value(key: str, value: str) -> None:
|
|
31
|
+
"""Store a value in the system keychain."""
|
|
32
|
+
_require_darwin()
|
|
33
|
+
keyring.set_password(SERVICE_NAME, key, value)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def delete_keyring_value(key: str) -> None:
|
|
37
|
+
"""Delete a value from the system keychain."""
|
|
38
|
+
_require_darwin()
|
|
39
|
+
try:
|
|
40
|
+
keyring.delete_password(SERVICE_NAME, key)
|
|
41
|
+
except keyring.errors.PasswordDeleteError:
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Settings(BaseSettings):
|
|
46
|
+
model_config = SettingsConfigDict(env_prefix="BWS_")
|
|
47
|
+
|
|
48
|
+
access_token: SecretStr
|
|
49
|
+
organization_id: str
|
|
50
|
+
api_url: str = "https://vault.bitwarden.com/api"
|
|
51
|
+
identity_url: str = "https://vault.bitwarden.com/identity"
|
|
52
|
+
|
|
53
|
+
@classmethod
|
|
54
|
+
def settings_customise_sources(
|
|
55
|
+
cls,
|
|
56
|
+
settings_cls,
|
|
57
|
+
init_settings,
|
|
58
|
+
env_settings,
|
|
59
|
+
dotenv_settings,
|
|
60
|
+
file_secret_settings,
|
|
61
|
+
):
|
|
62
|
+
"""Load from keychain first, then fall back to env vars."""
|
|
63
|
+
return (
|
|
64
|
+
init_settings,
|
|
65
|
+
KeyringSettingsSource(settings_cls),
|
|
66
|
+
env_settings,
|
|
67
|
+
dotenv_settings,
|
|
68
|
+
file_secret_settings,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class KeyringSettingsSource:
|
|
73
|
+
"""Custom settings source that reads from system keychain."""
|
|
74
|
+
|
|
75
|
+
def __init__(self, settings_cls):
|
|
76
|
+
self.settings_cls = settings_cls
|
|
77
|
+
|
|
78
|
+
def __call__(self):
|
|
79
|
+
values = {}
|
|
80
|
+
access_token = get_keyring_value("bws_access_token")
|
|
81
|
+
if access_token:
|
|
82
|
+
values["access_token"] = access_token
|
|
83
|
+
org_id = get_keyring_value("bws_organization_id")
|
|
84
|
+
if org_id:
|
|
85
|
+
values["organization_id"] = org_id
|
|
86
|
+
return values
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
_settings: Settings | None = None
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def get_settings() -> Settings:
|
|
93
|
+
"""Load settings lazily, with helpful error message if not configured."""
|
|
94
|
+
global _settings
|
|
95
|
+
if _settings is None:
|
|
96
|
+
try:
|
|
97
|
+
_settings = Settings()
|
|
98
|
+
except Exception:
|
|
99
|
+
raise SystemExit(
|
|
100
|
+
"Credentials not configured. Run:\n"
|
|
101
|
+
" vaultuner config set access-token <token>\n"
|
|
102
|
+
" vaultuner config set organization-id <org-id>"
|
|
103
|
+
)
|
|
104
|
+
return _settings
|
vaultuner/export.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# ABOUTME: Export project secrets to .env file format.
|
|
2
|
+
# ABOUTME: Handles parsing existing .env files and appending new secrets.
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from vaultuner.client import get_client
|
|
7
|
+
from vaultuner.config import get_settings
|
|
8
|
+
from vaultuner.models import SecretPath, is_deleted
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def secret_name_to_env_var(name: str) -> str:
|
|
12
|
+
"""Convert a secret name to an environment variable name."""
|
|
13
|
+
return name.upper().replace("-", "_")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_env_file(path: Path) -> set[str]:
|
|
17
|
+
"""Parse a .env file and return the set of defined variable names."""
|
|
18
|
+
defined_vars: set[str] = set()
|
|
19
|
+
if not path.exists():
|
|
20
|
+
return defined_vars
|
|
21
|
+
|
|
22
|
+
for line in path.read_text().splitlines():
|
|
23
|
+
line = line.strip()
|
|
24
|
+
if not line or line.startswith("#"):
|
|
25
|
+
continue
|
|
26
|
+
if "=" in line:
|
|
27
|
+
var_name = line.split("=", 1)[0].strip()
|
|
28
|
+
defined_vars.add(var_name)
|
|
29
|
+
return defined_vars
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def export_secrets(
|
|
33
|
+
project_name: str,
|
|
34
|
+
output: Path,
|
|
35
|
+
env: str | None = None,
|
|
36
|
+
) -> tuple[int, int]:
|
|
37
|
+
"""
|
|
38
|
+
Export secrets for a project to a .env file.
|
|
39
|
+
|
|
40
|
+
Returns a tuple of (added_count, skipped_count).
|
|
41
|
+
"""
|
|
42
|
+
settings = get_settings()
|
|
43
|
+
client = get_client()
|
|
44
|
+
response = client.secrets().list(settings.organization_id)
|
|
45
|
+
|
|
46
|
+
if not response.data or not response.data.data:
|
|
47
|
+
return 0, 0
|
|
48
|
+
|
|
49
|
+
# Find secrets matching the project (and optionally env)
|
|
50
|
+
matching_secrets: list[tuple[SecretPath, str]] = []
|
|
51
|
+
for secret in response.data.data:
|
|
52
|
+
key = secret.key
|
|
53
|
+
if is_deleted(key):
|
|
54
|
+
continue
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
path = SecretPath.parse(key)
|
|
58
|
+
except ValueError:
|
|
59
|
+
continue
|
|
60
|
+
|
|
61
|
+
if path.project != project_name:
|
|
62
|
+
continue
|
|
63
|
+
if path.env != env:
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
# Fetch the actual value
|
|
67
|
+
secret_response = client.secrets().get(secret.id)
|
|
68
|
+
if secret_response.data:
|
|
69
|
+
matching_secrets.append((path, secret_response.data.value))
|
|
70
|
+
|
|
71
|
+
if not matching_secrets:
|
|
72
|
+
return 0, 0
|
|
73
|
+
|
|
74
|
+
# Parse existing .env to find already-defined variables
|
|
75
|
+
existing_vars = parse_env_file(output)
|
|
76
|
+
|
|
77
|
+
# Build the lines to append
|
|
78
|
+
lines_to_append: list[str] = []
|
|
79
|
+
added_count = 0
|
|
80
|
+
skipped_count = 0
|
|
81
|
+
|
|
82
|
+
for path, value in matching_secrets:
|
|
83
|
+
env_var = secret_name_to_env_var(path.name)
|
|
84
|
+
escaped_value = value.replace("\\", "\\\\").replace('"', '\\"')
|
|
85
|
+
env_line = f'{env_var}="{escaped_value}"'
|
|
86
|
+
|
|
87
|
+
if env_var in existing_vars:
|
|
88
|
+
lines_to_append.append(f"# Already defined above, from {path}:")
|
|
89
|
+
lines_to_append.append(f"# {env_line}")
|
|
90
|
+
skipped_count += 1
|
|
91
|
+
else:
|
|
92
|
+
lines_to_append.append(env_line)
|
|
93
|
+
existing_vars.add(env_var)
|
|
94
|
+
added_count += 1
|
|
95
|
+
|
|
96
|
+
# Append to file
|
|
97
|
+
if lines_to_append:
|
|
98
|
+
with output.open("a") as f:
|
|
99
|
+
if output.exists() and output.stat().st_size > 0:
|
|
100
|
+
# Ensure we start on a new line
|
|
101
|
+
f.write("\n")
|
|
102
|
+
f.write("\n".join(lines_to_append))
|
|
103
|
+
f.write("\n")
|
|
104
|
+
|
|
105
|
+
return added_count, skipped_count
|
vaultuner/import_env.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# ABOUTME: Import secrets from .env file to Bitwarden Secrets Manager.
|
|
2
|
+
# ABOUTME: Parses .env files and interactively stores secrets.
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def env_var_to_secret_name(var_name: str) -> str:
|
|
8
|
+
"""Convert an environment variable name to a secret name.
|
|
9
|
+
|
|
10
|
+
This is the inverse of secret_name_to_env_var() from export.py.
|
|
11
|
+
"""
|
|
12
|
+
return var_name.lower().replace("_", "-")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def parse_env_entries(path: Path) -> list[tuple[str, str]]:
|
|
16
|
+
"""Parse a .env file and return list of (name, value) tuples."""
|
|
17
|
+
entries: list[tuple[str, str]] = []
|
|
18
|
+
if not path.exists():
|
|
19
|
+
return entries
|
|
20
|
+
|
|
21
|
+
for line in path.read_text().splitlines():
|
|
22
|
+
line = line.strip()
|
|
23
|
+
if not line or line.startswith("#"):
|
|
24
|
+
continue
|
|
25
|
+
if "=" not in line:
|
|
26
|
+
continue
|
|
27
|
+
|
|
28
|
+
var_name, _, value = line.partition("=")
|
|
29
|
+
var_name = var_name.strip()
|
|
30
|
+
value = value.strip()
|
|
31
|
+
|
|
32
|
+
# Remove surrounding quotes if present
|
|
33
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
|
|
34
|
+
value = value[1:-1]
|
|
35
|
+
|
|
36
|
+
entries.append((var_name, value))
|
|
37
|
+
|
|
38
|
+
return entries
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def build_secret_path(project: str, env: str | None, name: str) -> str:
|
|
42
|
+
"""Build a secret path from components."""
|
|
43
|
+
if env:
|
|
44
|
+
return f"{project}/{env}/{name}"
|
|
45
|
+
return f"{project}/{name}"
|
vaultuner/models.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# ABOUTME: Data models for vaultuner.
|
|
2
|
+
# ABOUTME: SecretPath represents the PROJECT/[ENV/]SECRET naming convention.
|
|
3
|
+
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
|
|
6
|
+
DELETED_PREFIX = "_deleted_/"
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def is_deleted(key: str) -> bool:
|
|
10
|
+
"""Check if a secret key is marked as deleted."""
|
|
11
|
+
return key.startswith(DELETED_PREFIX)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def mark_deleted(key: str) -> str:
|
|
15
|
+
"""Mark a secret key as deleted by adding the prefix."""
|
|
16
|
+
return f"{DELETED_PREFIX}{key}"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def unmark_deleted(key: str) -> str:
|
|
20
|
+
"""Remove the deleted prefix from a secret key."""
|
|
21
|
+
return key.removeprefix(DELETED_PREFIX)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SecretPath(BaseModel):
|
|
25
|
+
project: str
|
|
26
|
+
name: str
|
|
27
|
+
env: str | None = None
|
|
28
|
+
|
|
29
|
+
@classmethod
|
|
30
|
+
def parse(cls, path: str) -> "SecretPath":
|
|
31
|
+
"""Parse a path like 'project/env/name' or 'project/name'."""
|
|
32
|
+
parts = path.split("/")
|
|
33
|
+
|
|
34
|
+
if any(not part for part in parts):
|
|
35
|
+
raise ValueError(
|
|
36
|
+
f"Invalid path format: {path}. Path segments cannot be empty."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
if len(parts) == 3:
|
|
40
|
+
return cls(project=parts[0], env=parts[1], name=parts[2])
|
|
41
|
+
elif len(parts) == 2:
|
|
42
|
+
return cls(project=parts[0], name=parts[1])
|
|
43
|
+
else:
|
|
44
|
+
raise ValueError(
|
|
45
|
+
f"Invalid path format: {path}. Expected PROJECT/[ENV/]NAME"
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def to_key(self) -> str:
|
|
49
|
+
"""Convert to Bitwarden secret key format."""
|
|
50
|
+
if self.env:
|
|
51
|
+
return f"{self.project}/{self.env}/{self.name}"
|
|
52
|
+
return f"{self.project}/{self.name}"
|
|
53
|
+
|
|
54
|
+
def __str__(self) -> str:
|
|
55
|
+
return self.to_key()
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: vaultuner
|
|
3
|
+
Version: 0.1.6
|
|
4
|
+
Summary: Bitwarden Secrets Manager CLI with PROJECT/[ENV/]SECRET naming
|
|
5
|
+
Keywords: bitwarden,secrets,cli,secrets-manager,devops
|
|
6
|
+
Author: David Poblador
|
|
7
|
+
License: MIT
|
|
8
|
+
Classifier: Development Status :: 4 - Beta
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: MacOS
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Topic :: Security
|
|
16
|
+
Classifier: Topic :: Utilities
|
|
17
|
+
Requires-Dist: bitwarden-sdk>=1.0.0
|
|
18
|
+
Requires-Dist: keyring>=25.7.0
|
|
19
|
+
Requires-Dist: pydantic>=2.12.5
|
|
20
|
+
Requires-Dist: pydantic-settings>=2.12.0
|
|
21
|
+
Requires-Dist: rich>=14.3.2
|
|
22
|
+
Requires-Dist: typer>=0.21.1
|
|
23
|
+
Requires-Python: >=3.11, <3.13
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
|
|
26
|
+
# vaultuner
|
|
27
|
+
|
|
28
|
+
[](https://pypi.org/project/vaultuner/)
|
|
29
|
+
[](https://opensource.org/licenses/MIT)
|
|
30
|
+
[](https://www.python.org/downloads/)
|
|
31
|
+
|
|
32
|
+
**Human-readable secrets for Bitwarden Secrets Manager.**
|
|
33
|
+
|
|
34
|
+
Vaultuner replaces cryptic UUIDs with intuitive paths like `myapp/prod/db-password`. Your secrets, organized the way you actually think about them.
|
|
35
|
+
|
|
36
|
+
## The Problem
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# Bitwarden's default CLI
|
|
40
|
+
bws secret get 550e8400-e29b-41d4-a716-446655440000
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
You shouldn't need to memorize UUIDs or dig through dashboards to find secrets.
|
|
44
|
+
|
|
45
|
+
## The Solution
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
# With vaultuner
|
|
49
|
+
vaultuner get myapp/prod/db-password
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Secrets organized by project and environment. Instantly memorable. Zero cognitive overhead.
|
|
53
|
+
|
|
54
|
+
## Features
|
|
55
|
+
|
|
56
|
+
- **Path-based naming** - `project/env/secret` instead of UUIDs
|
|
57
|
+
- **Environment isolation** - Keep dev, staging, and prod secrets separate
|
|
58
|
+
- **`.env` sync** - Export to and import from `.env` files seamlessly
|
|
59
|
+
- **Soft delete** - Recover accidentally deleted secrets
|
|
60
|
+
- **Keychain storage** - Credentials secured in macOS Keychain
|
|
61
|
+
|
|
62
|
+
## Quick Start
|
|
63
|
+
|
|
64
|
+
### Install
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
uv tool install vaultuner
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Or run without installing:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
uvx vaultuner list
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### Configure
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
vaultuner config set access-token <your-token>
|
|
80
|
+
vaultuner config set organization-id <your-org-id>
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Get credentials from [Bitwarden Secrets Manager](https://vault.bitwarden.com/).
|
|
84
|
+
|
|
85
|
+
### Use
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
# Create secrets
|
|
89
|
+
vaultuner set myapp/api-key "sk-abc123"
|
|
90
|
+
vaultuner set myapp/prod/db-password "hunter2"
|
|
91
|
+
|
|
92
|
+
# Retrieve
|
|
93
|
+
vaultuner get myapp/prod/db-password -v
|
|
94
|
+
|
|
95
|
+
# List everything
|
|
96
|
+
vaultuner list
|
|
97
|
+
|
|
98
|
+
# Export for local dev
|
|
99
|
+
vaultuner export -p myapp -e dev -o .env
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Commands
|
|
103
|
+
|
|
104
|
+
| Command | Description |
|
|
105
|
+
|---------|-------------|
|
|
106
|
+
| `list` | List secrets with project/env filtering |
|
|
107
|
+
| `get` | Retrieve a secret value |
|
|
108
|
+
| `set` | Create or update a secret |
|
|
109
|
+
| `delete` | Soft-delete (recoverable) |
|
|
110
|
+
| `restore` | Recover a deleted secret |
|
|
111
|
+
| `export` | Export to `.env` file |
|
|
112
|
+
| `import` | Import from `.env` file |
|
|
113
|
+
| `projects` | List all projects |
|
|
114
|
+
| `config` | Manage stored credentials |
|
|
115
|
+
|
|
116
|
+
## Naming Convention
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
PROJECT/SECRET # Project-level secret
|
|
120
|
+
PROJECT/ENV/SECRET # Environment-specific secret
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Examples:
|
|
124
|
+
```
|
|
125
|
+
myapp/api-key # Shared across environments
|
|
126
|
+
myapp/prod/db-password # Production only
|
|
127
|
+
myapp/dev/db-password # Development only
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Requirements
|
|
131
|
+
|
|
132
|
+
- Python 3.11 or 3.12 (bitwarden-sdk limitation)
|
|
133
|
+
- macOS (Keychain integration)
|
|
134
|
+
- Bitwarden Secrets Manager account
|
|
135
|
+
|
|
136
|
+
## Documentation
|
|
137
|
+
|
|
138
|
+
Full docs at [alltuner.github.io/vaultuner](https://alltuner.github.io/vaultuner)
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
MIT
|
|
143
|
+
|
|
144
|
+
---
|
|
145
|
+
|
|
146
|
+
Built at [All Tuner Labs](https://alltuner.com) by [David Poblador i Garcia](https://davidpoblador.com)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
vaultuner/__init__.py,sha256=-3HqB8FyWviChoTlnXIdqc7xneT_vQa2G0P8L7ffpcE,129
|
|
2
|
+
vaultuner/cli.py,sha256=yXvuovmVcZjAB2DsESN7MGwsveL9wzfaGTp7QLkGtek,13772
|
|
3
|
+
vaultuner/client.py,sha256=kJ4JI01MWOfKcmhBf3EqfRA8opMvL3bdIaWy-5Tp35A,2091
|
|
4
|
+
vaultuner/config.py,sha256=12aSjzPuwTG_3CHe8dwj56RkrKRg1_OZS8SWNfGjOq0,3030
|
|
5
|
+
vaultuner/export.py,sha256=U0zhXfdohC__PHLT8hKs0S2M8Ql_7p_ug2sJV-YwtQc,3143
|
|
6
|
+
vaultuner/import_env.py,sha256=XYnm-Tyo9h9a62K6LGibMMSYFNJBHSK3gzOmMqh8kOk,1353
|
|
7
|
+
vaultuner/models.py,sha256=X1oFKZ2171jJdsteIwiL7ZL-gkn_cW4Qxckz8C5vI8g,1595
|
|
8
|
+
vaultuner-0.1.6.dist-info/WHEEL,sha256=5DEXXimM34_d4Gx1AuF9ysMr1_maoEtGKjaILM3s4w4,80
|
|
9
|
+
vaultuner-0.1.6.dist-info/entry_points.txt,sha256=n6WLg_ojVObz5N6rB-w6SVS-4jsKkDBpJAu3IqNJx_Q,49
|
|
10
|
+
vaultuner-0.1.6.dist-info/METADATA,sha256=S3v-ptBya2YbVlzWuuWgBj51x90VjZ5L2yQXSFO67so,3801
|
|
11
|
+
vaultuner-0.1.6.dist-info/RECORD,,
|