dev-vault 0.1.2__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.
dev_vault/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """dev-vault -- Developer secret vault + OIDC token provider."""
2
+
3
+ __version__ = "0.1.0"
dev_vault/cli.py ADDED
@@ -0,0 +1,177 @@
1
+ """
2
+ dev-vault CLI entry point.
3
+
4
+ Routes subcommands to their respective handlers.
5
+ """
6
+
7
+ import argparse
8
+ import logging
9
+ import os
10
+ import sys
11
+
12
+ from rich.console import Console
13
+ from rich.logging import RichHandler
14
+
15
+ from . import __version__
16
+
17
+ console = Console()
18
+ logger = logging.getLogger("dev_vault")
19
+
20
+
21
+ def _setup_logging(verbose: bool) -> None:
22
+ enabled = verbose or os.environ.get("DV_DEBUG", "").strip() in ("1", "true", "yes")
23
+ if not enabled:
24
+ return
25
+ logging.basicConfig(
26
+ level=logging.DEBUG,
27
+ format="%(message)s",
28
+ datefmt="[%X]",
29
+ handlers=[RichHandler(
30
+ console=Console(stderr=True),
31
+ rich_tracebacks=True,
32
+ show_path=False,
33
+ )],
34
+ )
35
+ logger.debug("Verbose mode enabled")
36
+
37
+
38
+ def _build_parser() -> argparse.ArgumentParser:
39
+ parser = argparse.ArgumentParser(
40
+ prog="dv",
41
+ description="dev-vault -- Developer secret vault + OIDC token provider",
42
+ )
43
+ parser.add_argument("--version", action="version", version=f"dev-vault {__version__}")
44
+ parser.add_argument("-v", "--verbose", action="store_true", help="enable debug output")
45
+ parser.add_argument("--json", action="store_true", help="JSON output")
46
+
47
+ sub = parser.add_subparsers(dest="command")
48
+
49
+ # dv get -- supports both:
50
+ # dv get prod caetano (positional: vault item)
51
+ # dv get prod caetano password (positional: vault item field)
52
+ # dv get caetano (just item, default vault)
53
+ # dv get prod/caetano (slash shorthand still works)
54
+ p_get = sub.add_parser("get", help="retrieve a secret or OIDC token")
55
+ p_get.add_argument("args", nargs="+", help="[vault] item [field] or vault/item[/field]")
56
+
57
+ # dv set -- supports:
58
+ # dv set item field [value]
59
+ # dv set vault item field [value]
60
+ p_set = sub.add_parser("set", help="store a secret")
61
+ p_set.add_argument("args", nargs="+", help="[vault] item field [value]")
62
+
63
+ # dv run
64
+ p_run = sub.add_parser("run", help="run command with secrets injected as env vars")
65
+ p_run.add_argument("-s", "--secret", action="append", help="KEY=ref mapping")
66
+ p_run.add_argument("run_command", nargs=argparse.REMAINDER, help="command to run (after --)")
67
+
68
+ # dv inject
69
+ sub.add_parser("inject", help="replace {{dv://...}} refs in stdin")
70
+
71
+ # dv item
72
+ p_item = sub.add_parser("item", help="manage items")
73
+ item_sub = p_item.add_subparsers(dest="item_action")
74
+
75
+ item_sub.add_parser("list", help="list items")
76
+ p_ic = item_sub.add_parser("create", help="create item")
77
+ p_ic.add_argument("name", help="item name")
78
+ p_ic.add_argument("--kind", choices=["static", "oidc"], default="static")
79
+ p_ic.add_argument("--vault", help="override vault")
80
+
81
+ p_is = item_sub.add_parser("show", help="show item details")
82
+ p_is.add_argument("name", help="item name")
83
+ p_is.add_argument("--vault", help="override vault")
84
+
85
+ p_id = item_sub.add_parser("delete", help="delete item")
86
+ p_id.add_argument("name", help="item name")
87
+ p_id.add_argument("--vault", help="override vault")
88
+
89
+ # dv vault
90
+ p_vault = sub.add_parser("vault", help="manage vaults")
91
+ vault_sub = p_vault.add_subparsers(dest="vault_action")
92
+ vault_sub.add_parser("list", help="list vaults")
93
+ vc = vault_sub.add_parser("create", help="create vault")
94
+ vc.add_argument("name", help="vault name")
95
+ vd = vault_sub.add_parser("delete", help="delete vault")
96
+ vd.add_argument("name", help="vault name")
97
+
98
+ # dv setup
99
+ p_setup = sub.add_parser("setup", help="interactive setup wizard")
100
+ p_setup.add_argument("--reset", action="store_true", help="backup config and start fresh")
101
+
102
+ # dv migrate
103
+ p_migrate = sub.add_parser("migrate", help="import from another tool")
104
+ p_migrate.add_argument("source", nargs="?", default="sso-cli", help="source tool (default: sso-cli)")
105
+
106
+ # dv config
107
+ p_config = sub.add_parser("config", help="show configuration")
108
+ config_sub = p_config.add_subparsers(dest="config_action")
109
+ config_sub.add_parser("show", help="display current config")
110
+
111
+ return parser
112
+
113
+
114
+ def cli() -> None:
115
+ parser = _build_parser()
116
+ args = parser.parse_args()
117
+
118
+ _setup_logging(args.verbose)
119
+
120
+ # Strip leading '--' from run command
121
+ if args.command == "run" and hasattr(args, "run_command"):
122
+ cmd = getattr(args, "run_command", [])
123
+ if cmd and cmd[0] == "--":
124
+ args.run_command = cmd[1:]
125
+
126
+ if not args.command:
127
+ parser.print_help()
128
+ return
129
+
130
+ # Propagate --json to args
131
+ try:
132
+ _dispatch(args)
133
+ except KeyboardInterrupt:
134
+ print("\nInterrupted.", file=sys.stderr)
135
+ sys.exit(130)
136
+ except Exception as e:
137
+ logger.debug("Error: %s", e, exc_info=True)
138
+ print(f"Error: {e}", file=sys.stderr)
139
+ sys.exit(1)
140
+
141
+
142
+ def _dispatch(args) -> None:
143
+ cmd = args.command
144
+ if cmd == "get":
145
+ from .commands.get import run
146
+ run(args)
147
+ elif cmd == "set":
148
+ from .commands.set_cmd import run
149
+ run(args)
150
+ elif cmd == "run":
151
+ from .commands.run import run as run_cmd
152
+ run_cmd(args)
153
+ elif cmd == "inject":
154
+ from .commands.inject import run
155
+ run(args)
156
+ elif cmd == "item":
157
+ from .commands.item import run
158
+ run(args)
159
+ elif cmd == "vault":
160
+ from .commands.vault import run
161
+ run(args)
162
+ elif cmd == "setup":
163
+ from .commands.setup import run
164
+ run(args)
165
+ elif cmd == "migrate":
166
+ from .commands.migrate import run
167
+ run(args)
168
+ elif cmd == "config":
169
+ from .commands.config_cmd import run
170
+ run(args)
171
+ else:
172
+ print(f"Unknown command: {cmd}", file=sys.stderr)
173
+ sys.exit(1)
174
+
175
+
176
+ if __name__ == "__main__":
177
+ cli()
File without changes
@@ -0,0 +1,42 @@
1
+ """
2
+ dv config show -- display current configuration.
3
+
4
+ Usage:
5
+ dv config show
6
+ """
7
+
8
+ import json as json_mod
9
+ import logging
10
+ import sys
11
+
12
+ import yaml
13
+
14
+ from ..core.config import find_config_path, load_config
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def run(args) -> None:
20
+ """Execute the config command."""
21
+ action = getattr(args, "config_action", "show")
22
+ if action == "show":
23
+ _show(args)
24
+ else:
25
+ print(f"Unknown config action: {action}", file=sys.stderr)
26
+ sys.exit(1)
27
+
28
+
29
+ def _show(args) -> None:
30
+ config_path = find_config_path()
31
+ try:
32
+ config = load_config()
33
+ except FileNotFoundError:
34
+ print(f"No config found at: {config_path}", file=sys.stderr)
35
+ sys.exit(1)
36
+
37
+ if getattr(args, "json", False):
38
+ print(json_mod.dumps({"path": config_path, "config": config}, indent=2))
39
+ return
40
+
41
+ print(f"Config: {config_path}\n")
42
+ print(yaml.dump(config, default_flow_style=False, sort_keys=False), end="")
@@ -0,0 +1,134 @@
1
+ """
2
+ dv get -- retrieve a secret or OIDC token.
3
+
4
+ Usage:
5
+ dv get <item> # default vault, primary field
6
+ dv get <vault> <item> # specific vault
7
+ dv get <vault> <item> <field> # specific vault + field
8
+ dv get vault/item/field # slash shorthand
9
+ """
10
+
11
+ import asyncio
12
+ import json as json_mod
13
+ import logging
14
+ import sys
15
+
16
+ from ..core.config import ensure_config, get_item, get_default_vault, list_all_items
17
+ from ..core.keyring_store import get_secret
18
+ from ..core.resolver import resolve, resolve_prefix
19
+ from ..providers import PROVIDERS
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ def run(args) -> None:
25
+ asyncio.run(_get(args))
26
+
27
+
28
+ def _parse_args(args) -> tuple:
29
+ """Parse positional args into (vault, item, field).
30
+
31
+ Supports:
32
+ ["item"] -> (default, item, None)
33
+ ["vault", "item"] -> (vault, item, None)
34
+ ["vault", "item", "f"] -> (vault, item, f)
35
+ ["vault/item"] -> resolve via slash
36
+ ["vault/item/field"] -> resolve via slash
37
+ """
38
+ parts = args.args
39
+ config = ensure_config()
40
+ default_vault = get_default_vault(config)
41
+
42
+ # If single arg with slashes, use resolver
43
+ if len(parts) == 1 and "/" in parts[0]:
44
+ return resolve(parts[0], default_vault)
45
+
46
+ if len(parts) == 1:
47
+ # Could be just an item name (default vault)
48
+ # Or could be a vault name if it matches a vault
49
+ vaults = list(config.get("vaults", {}).keys())
50
+ if parts[0] in vaults:
51
+ # Ambiguous: is it a vault or an item in default vault?
52
+ # Check if it's also an item in default vault
53
+ default_items = config.get("vaults", {}).get(default_vault, {}).get("items", {})
54
+ if parts[0] in default_items:
55
+ return default_vault, parts[0], None
56
+ # It's a vault name but no item specified
57
+ print(f"Error: vault '{parts[0]}' specified but no item name.", file=sys.stderr)
58
+ print(f"Usage: dv get {parts[0]} <item>", file=sys.stderr)
59
+ sys.exit(1)
60
+ return default_vault, parts[0], None
61
+ elif len(parts) == 2:
62
+ return parts[0], parts[1], None
63
+ elif len(parts) == 3:
64
+ return parts[0], parts[1], parts[2]
65
+ else:
66
+ print("Error: too many arguments. Usage: dv get [vault] <item> [field]", file=sys.stderr)
67
+ sys.exit(1)
68
+
69
+
70
+ async def _get(args) -> None:
71
+ config = ensure_config()
72
+ vault, item_name, field = _parse_args(args)
73
+ logger.debug("Resolved: vault=%s item=%s field=%s", vault, item_name, field)
74
+
75
+ item = get_item(config, vault, item_name)
76
+ if not item:
77
+ print(f"Error: item '{item_name}' not found in vault '{vault}'.", file=sys.stderr)
78
+ sys.exit(1)
79
+
80
+ kind = item.get("kind", "static")
81
+
82
+ if kind == "oidc" and field is None:
83
+ value = await _get_oidc_token(item, vault, item_name)
84
+ elif kind == "oidc" and field == "access_token":
85
+ value = await _get_oidc_token(item, vault, item_name)
86
+ else:
87
+ resolved_field = field or _primary_field(item)
88
+ if not resolved_field:
89
+ print(f"Error: item '{item_name}' has no fields.", file=sys.stderr)
90
+ sys.exit(1)
91
+ value = get_secret(vault, item_name, resolved_field)
92
+ if value is None:
93
+ print(
94
+ f"Error: no secret for {vault}/{item_name}/{resolved_field}. "
95
+ "Run 'dv set' to store it.",
96
+ file=sys.stderr,
97
+ )
98
+ sys.exit(1)
99
+
100
+ if getattr(args, "json", False):
101
+ print(json_mod.dumps({"vault": vault, "item": item_name, "field": field, "value": value}))
102
+ else:
103
+ print(value)
104
+
105
+
106
+ async def _get_oidc_token(item: dict, vault: str, item_name: str) -> str:
107
+ provider_name = item.get("provider", "keycloak")
108
+ provider = PROVIDERS.get(provider_name)
109
+ if not provider:
110
+ print(f"Error: unknown OIDC provider '{provider_name}'.", file=sys.stderr)
111
+ sys.exit(1)
112
+
113
+ item_config = item.get("config", {})
114
+ auth_type = item_config.get("auth_type", "user")
115
+ secret_field = "client_secret" if auth_type == "client" else "password"
116
+ secret = get_secret(vault, item_name, secret_field)
117
+ if secret is None:
118
+ print(
119
+ f"Error: no {secret_field} in keyring for {vault}/{item_name}. "
120
+ "Run 'dv setup' to configure.",
121
+ file=sys.stderr,
122
+ )
123
+ sys.exit(1)
124
+
125
+ return await provider.get_token(item_config, secret)
126
+
127
+
128
+ def _primary_field(item: dict) -> str:
129
+ fields = item.get("fields", [])
130
+ if isinstance(fields, list) and fields:
131
+ return fields[0]
132
+ if isinstance(fields, dict) and fields:
133
+ return next(iter(fields))
134
+ return "value"
@@ -0,0 +1,91 @@
1
+ """
2
+ dv inject -- replace {{dv://...}} references in stdin.
3
+
4
+ Usage:
5
+ dv inject < template.env > .env
6
+ cat template.yaml | dv inject > output.yaml
7
+ """
8
+
9
+ import asyncio
10
+ import logging
11
+ import re
12
+ import sys
13
+
14
+ from ..core.config import ensure_config, get_item, get_default_vault
15
+ from ..core.keyring_store import get_secret
16
+ from ..core.resolver import resolve
17
+ from ..providers import PROVIDERS
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ _REF_PATTERN = re.compile(r"\{\{(dv://[^}]+)\}\}")
22
+
23
+
24
+ def run(args) -> None:
25
+ """Execute the inject command."""
26
+ asyncio.run(_inject(args))
27
+
28
+
29
+ async def _inject(args) -> None:
30
+ config = ensure_config()
31
+ default_vault = get_default_vault(config)
32
+
33
+ template = sys.stdin.read()
34
+ refs = _REF_PATTERN.findall(template)
35
+
36
+ if not refs:
37
+ print(template, end="")
38
+ return
39
+
40
+ # Resolve all references
41
+ resolved = {}
42
+ for ref in set(refs):
43
+ known_vaults = list(config.get("vaults", {}).keys())
44
+ vault, item_name, field = resolve(ref, default_vault, known_vaults=known_vaults)
45
+ item = get_item(config, vault, item_name)
46
+ if not item:
47
+ print(f"Error: item '{item_name}' not found in vault '{vault}'.", file=sys.stderr)
48
+ sys.exit(1)
49
+
50
+ kind = item.get("kind", "static")
51
+ if kind == "oidc" and field is None:
52
+ value = await _get_oidc_token(item, vault, item_name)
53
+ else:
54
+ resolved_field = field or _primary_field(item)
55
+ value = get_secret(vault, item_name, resolved_field)
56
+ if value is None:
57
+ print(f"Error: no secret for {vault}/{item_name}/{resolved_field}.", file=sys.stderr)
58
+ sys.exit(1)
59
+
60
+ resolved[ref] = value
61
+
62
+ # Replace all references
63
+ def replacer(match):
64
+ return resolved[match.group(1)]
65
+
66
+ output = _REF_PATTERN.sub(replacer, template)
67
+ print(output, end="")
68
+
69
+
70
+ async def _get_oidc_token(item: dict, vault: str, item_name: str) -> str:
71
+ provider_name = item.get("provider", "keycloak")
72
+ provider = PROVIDERS.get(provider_name)
73
+ if not provider:
74
+ print(f"Error: unknown provider '{provider_name}'.", file=sys.stderr)
75
+ sys.exit(1)
76
+
77
+ item_config = item.get("config", {})
78
+ secret_field = "client_secret" if item_config.get("auth_type") == "client" else "password"
79
+ secret = get_secret(vault, item_name, secret_field)
80
+ if secret is None:
81
+ print(f"Error: no {secret_field} for {vault}/{item_name}.", file=sys.stderr)
82
+ sys.exit(1)
83
+
84
+ return await provider.get_token(item_config, secret)
85
+
86
+
87
+ def _primary_field(item: dict) -> str:
88
+ fields = item.get("fields", [])
89
+ if isinstance(fields, list) and fields:
90
+ return fields[0]
91
+ return "value"
@@ -0,0 +1,122 @@
1
+ """
2
+ dv item -- manage items in vaults.
3
+
4
+ Usage:
5
+ dv item list [-v vault]
6
+ dv item create <name> --kind static|oidc [-v vault]
7
+ dv item show <name> [-v vault]
8
+ dv item delete <name> [-v vault]
9
+ """
10
+
11
+ import json as json_mod
12
+ import logging
13
+ import sys
14
+
15
+ from ..core.config import ensure_config, get_vault, get_item, list_all_items, save_config
16
+ from ..core.keyring_store import delete_secret
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def run(args) -> None:
22
+ """Route item subcommands."""
23
+ action = args.item_action
24
+ if action == "list":
25
+ _list_items(args)
26
+ elif action == "create":
27
+ _create_item(args)
28
+ elif action == "show":
29
+ _show_item(args)
30
+ elif action == "delete":
31
+ _delete_item(args)
32
+ else:
33
+ print(f"Unknown item action: {action}", file=sys.stderr)
34
+ sys.exit(1)
35
+
36
+
37
+ def _list_items(args) -> None:
38
+ config = ensure_config()
39
+ vault_filter = getattr(args, "vault", None)
40
+
41
+ items = list_all_items(config)
42
+ if vault_filter:
43
+ items = [i for i in items if i["vault"] == vault_filter]
44
+
45
+ if getattr(args, "json", False):
46
+ print(json_mod.dumps(items, indent=2))
47
+ return
48
+
49
+ if not items:
50
+ print("No items found.", file=sys.stderr)
51
+ return
52
+
53
+ for i in items:
54
+ print(f" {i['vault']}/{i['item']} [{i['kind']}]")
55
+
56
+
57
+ def _create_item(args) -> None:
58
+ config = ensure_config()
59
+ vault_name = args.vault or config.get("defaults", {}).get("vault", "default")
60
+ vault = get_vault(config, vault_name)
61
+ items = vault.setdefault("items", {})
62
+
63
+ if args.name in items:
64
+ print(f"Error: item '{args.name}' already exists in vault '{vault_name}'.", file=sys.stderr)
65
+ sys.exit(1)
66
+
67
+ kind = getattr(args, "kind", "static") or "static"
68
+ items[args.name] = {"kind": kind, "fields": []}
69
+ save_config(config)
70
+
71
+ if getattr(args, "json", False):
72
+ print(json_mod.dumps({"vault": vault_name, "item": args.name, "kind": kind, "created": True}))
73
+ else:
74
+ print(f"Created: {vault_name}/{args.name} [{kind}]", file=sys.stderr)
75
+
76
+
77
+ def _show_item(args) -> None:
78
+ config = ensure_config()
79
+ vault_name = args.vault or config.get("defaults", {}).get("vault", "default")
80
+ item = get_item(config, vault_name, args.name)
81
+ if not item:
82
+ print(f"Error: item '{args.name}' not found in vault '{vault_name}'.", file=sys.stderr)
83
+ sys.exit(1)
84
+
85
+ if getattr(args, "json", False):
86
+ print(json_mod.dumps({"vault": vault_name, "item": args.name, **item}, indent=2))
87
+ return
88
+
89
+ kind = item.get("kind", "static")
90
+ fields = item.get("fields", [])
91
+ print(f" {vault_name}/{args.name} [{kind}]")
92
+ print(f" Fields: {', '.join(fields) if fields else '(none)'}")
93
+ if kind == "oidc":
94
+ provider = item.get("provider", "keycloak")
95
+ oidc_config = item.get("config", {})
96
+ print(f" Provider: {provider}")
97
+ for k, v in oidc_config.items():
98
+ print(f" {k}: {v}")
99
+
100
+
101
+ def _delete_item(args) -> None:
102
+ config = ensure_config()
103
+ vault_name = args.vault or config.get("defaults", {}).get("vault", "default")
104
+ vault_data = config.get("vaults", {}).get(vault_name, {})
105
+ items = vault_data.get("items", {})
106
+
107
+ if args.name not in items:
108
+ print(f"Error: item '{args.name}' not found in vault '{vault_name}'.", file=sys.stderr)
109
+ sys.exit(1)
110
+
111
+ item = items[args.name]
112
+ fields = item.get("fields", [])
113
+ for field in fields:
114
+ delete_secret(vault_name, args.name, field)
115
+
116
+ del items[args.name]
117
+ save_config(config)
118
+
119
+ if getattr(args, "json", False):
120
+ print(json_mod.dumps({"vault": vault_name, "item": args.name, "deleted": True}))
121
+ else:
122
+ print(f"Deleted: {vault_name}/{args.name}", file=sys.stderr)
@@ -0,0 +1,108 @@
1
+ """
2
+ dv migrate sso-cli -- import config and secrets from sso-cli.
3
+
4
+ Reads ~/sso_config.yaml and copies secrets from the sso-cli keyring
5
+ to the dev-vault keyring.
6
+ """
7
+
8
+ import json as json_mod
9
+ import logging
10
+ import os
11
+ import sys
12
+
13
+ import yaml
14
+
15
+ from ..core.config import ensure_config, save_config
16
+ from ..core.keyring_store import store_secret, get_secret_legacy
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ def run(args) -> None:
22
+ """Execute the migrate command."""
23
+ source = getattr(args, "source", "sso-cli")
24
+ if source != "sso-cli":
25
+ print(f"Error: unknown migration source '{source}'. Only 'sso-cli' is supported.", file=sys.stderr)
26
+ sys.exit(1)
27
+
28
+ _migrate_sso_cli(args)
29
+
30
+
31
+ def _migrate_sso_cli(args) -> None:
32
+ """Import sso-cli config and secrets into dev-vault."""
33
+ # Find sso-cli config
34
+ sso_config_path = os.environ.get("SSO_CONFIG_PATH", "")
35
+ if not sso_config_path or not os.path.exists(sso_config_path):
36
+ sso_config_path = os.path.expanduser("~/sso_config.yaml")
37
+ if not os.path.exists(sso_config_path):
38
+ print(f"Error: sso-cli config not found at {sso_config_path}.", file=sys.stderr)
39
+ sys.exit(1)
40
+
41
+ print(f"Reading sso-cli config from: {sso_config_path}", file=sys.stderr)
42
+
43
+ with open(sso_config_path, "r", encoding="utf-8") as f:
44
+ raw = yaml.safe_load(f) or {}
45
+
46
+ env_cfg = raw.get("environments", raw)
47
+ if not isinstance(env_cfg, dict):
48
+ print("Error: invalid sso-cli config format.", file=sys.stderr)
49
+ sys.exit(1)
50
+
51
+ config = ensure_config()
52
+ migrated = 0
53
+
54
+ _LEGACY = {"password": "user", "client_credentials": "client"}
55
+
56
+ for env_key, env_data in env_cfg.items():
57
+ if not isinstance(env_data, dict) or not env_data.get("sso_url"):
58
+ print(f" Skipping invalid environment: {env_key}", file=sys.stderr)
59
+ continue
60
+
61
+ # Create vault for this environment
62
+ vault = config.setdefault("vaults", {}).setdefault(env_key, {"items": {}})
63
+ items = vault.setdefault("items", {})
64
+
65
+ users = env_data.get("users", {})
66
+ for user_key, user_data in users.items():
67
+ if not isinstance(user_data, dict):
68
+ continue
69
+
70
+ auth_type = _LEGACY.get(user_data.get("auth_type", ""), user_data.get("auth_type", "user"))
71
+
72
+ # Create OIDC item
73
+ if auth_type == "client":
74
+ secret_field = "client_secret"
75
+ item_config = {
76
+ "sso_url": env_data["sso_url"],
77
+ "auth_type": "client",
78
+ "client_id": user_data.get("client_id", user_key),
79
+ }
80
+ else:
81
+ secret_field = "password"
82
+ item_config = {
83
+ "sso_url": env_data["sso_url"],
84
+ "auth_type": "user",
85
+ "email": user_data.get("email", user_key),
86
+ }
87
+
88
+ items[user_key] = {
89
+ "kind": "oidc",
90
+ "provider": "keycloak",
91
+ "config": item_config,
92
+ "fields": [secret_field],
93
+ }
94
+
95
+ # Migrate secret from sso-cli keyring
96
+ old_secret = get_secret_legacy(env_key, user_key)
97
+ if old_secret:
98
+ store_secret(env_key, user_key, secret_field, old_secret)
99
+ print(f" Migrated: {env_key}/{user_key} ({auth_type})", file=sys.stderr)
100
+ migrated += 1
101
+ else:
102
+ print(f" Warning: no keyring secret for {env_key}/{user_key}", file=sys.stderr)
103
+
104
+ path = save_config(config)
105
+ print(f"\nMigrated {migrated} item(s). Config saved to: {path}", file=sys.stderr)
106
+
107
+ if getattr(args, "json", False):
108
+ print(json_mod.dumps({"migrated": migrated, "config_path": path}))