mystack-rig 1.0.0__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.
- cli/__init__.py +6 -0
- cli/__main__.py +8 -0
- cli/agents.py +575 -0
- cli/config.py +264 -0
- cli/encrypt.py +1182 -0
- cli/git.py +317 -0
- cli/last_env.py +64 -0
- cli/last_session.py +171 -0
- cli/log.py +105 -0
- cli/main.py +1234 -0
- cli/remote_dev.py +5547 -0
- cli/requirements.txt +4 -0
- cli/snapshots.py +627 -0
- cli/storage.py +348 -0
- cli/vault_keys.py +451 -0
- cli/vpn.py +323 -0
- mystack_rig-1.0.0.dist-info/METADATA +285 -0
- mystack_rig-1.0.0.dist-info/RECORD +22 -0
- mystack_rig-1.0.0.dist-info/WHEEL +5 -0
- mystack_rig-1.0.0.dist-info/entry_points.txt +2 -0
- mystack_rig-1.0.0.dist-info/licenses/LICENSE +21 -0
- mystack_rig-1.0.0.dist-info/top_level.txt +1 -0
cli/encrypt.py
ADDED
|
@@ -0,0 +1,1182 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ansible-compatible encryption for `rig vars encrypt` / decrypt.
|
|
3
|
+
|
|
4
|
+
Data keys are autogenerated and wrapped under a personal master key in
|
|
5
|
+
``.rig/vars.{identity}.yml``, then materialized under ``/tmp/.rig/`` for
|
|
6
|
+
``ansible-vault``. Shared secrets live in ``.rig/vars.yml``; personal secrets
|
|
7
|
+
in the identity file.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import getpass
|
|
13
|
+
import os
|
|
14
|
+
import re
|
|
15
|
+
import secrets
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
from typing import Callable, Optional, TextIO
|
|
19
|
+
|
|
20
|
+
from cli.vault_keys import (
|
|
21
|
+
DEFAULT_DATA_KEY_PATH,
|
|
22
|
+
PERSONAL_DATA_KEY_PATH,
|
|
23
|
+
SHARED_DATA_KEY_PATH,
|
|
24
|
+
WRAP_FIELD_NAMES,
|
|
25
|
+
is_personal_secret_name,
|
|
26
|
+
personal_vars_path,
|
|
27
|
+
read_data_key_file,
|
|
28
|
+
shared_vars_path,
|
|
29
|
+
unlock_data_keys,
|
|
30
|
+
write_data_key_file,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# Re-export for callers that imported these from encrypt.
|
|
34
|
+
__all__ = [
|
|
35
|
+
"DEFAULT_DATA_KEY_PATH",
|
|
36
|
+
"PERSONAL_DATA_KEY_PATH",
|
|
37
|
+
"SHARED_DATA_KEY_PATH",
|
|
38
|
+
"VSCODE_BROWSER_PASSWORD_VAR",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
_VAR_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
42
|
+
|
|
43
|
+
# Vaulted password for code-server browser login (``rig dev --browser``).
|
|
44
|
+
VSCODE_BROWSER_PASSWORD_VAR = "vscode_browser_password"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def read_data_key(path: str = DEFAULT_DATA_KEY_PATH) -> Optional[str]:
|
|
48
|
+
return read_data_key_file(path)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def write_data_key(data_key: str, path: str = DEFAULT_DATA_KEY_PATH) -> None:
|
|
52
|
+
write_data_key_file(data_key, path)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def ensure_data_key(
|
|
56
|
+
path: str = DEFAULT_DATA_KEY_PATH,
|
|
57
|
+
input_fn: Optional[Callable[..., str]] = None,
|
|
58
|
+
secret_fn: Optional[Callable[..., str]] = None,
|
|
59
|
+
project_dir: Optional[str] = None,
|
|
60
|
+
) -> str:
|
|
61
|
+
"""
|
|
62
|
+
Ensure ephemeral data keys exist under ``/tmp/.rig``.
|
|
63
|
+
|
|
64
|
+
Prefer unlocking wraps from ``vars.{identity}.yml`` with the personal master
|
|
65
|
+
key. Falls back to legacy prompt-only creation when no identity wraps exist
|
|
66
|
+
and ``path`` was requested explicitly.
|
|
67
|
+
"""
|
|
68
|
+
existing = read_data_key(path)
|
|
69
|
+
if existing is not None:
|
|
70
|
+
return existing
|
|
71
|
+
|
|
72
|
+
root = resolve_project_dir(project_dir)
|
|
73
|
+
# Prefer unwrapping master-key wraps when present.
|
|
74
|
+
try:
|
|
75
|
+
from cli.vault_keys import read_wrap_fields, personal_vars_path, resolve_identity
|
|
76
|
+
|
|
77
|
+
wraps = read_wrap_fields(personal_vars_path(root, resolve_identity()))
|
|
78
|
+
if wraps:
|
|
79
|
+
personal_path, shared_path = unlock_data_keys(
|
|
80
|
+
root,
|
|
81
|
+
input_fn=input_fn,
|
|
82
|
+
secret_fn=secret_fn,
|
|
83
|
+
require_wraps=True,
|
|
84
|
+
)
|
|
85
|
+
if path == SHARED_DATA_KEY_PATH:
|
|
86
|
+
key = read_data_key(shared_path)
|
|
87
|
+
else:
|
|
88
|
+
key = read_data_key(personal_path)
|
|
89
|
+
if key:
|
|
90
|
+
if path not in (personal_path, shared_path, DEFAULT_DATA_KEY_PATH):
|
|
91
|
+
write_data_key(key, path)
|
|
92
|
+
return key
|
|
93
|
+
except ValueError:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
ask_secret = secret_fn or getpass.getpass
|
|
97
|
+
ask = input_fn or input
|
|
98
|
+
print(f"Temporary data key not found at {path}")
|
|
99
|
+
print("This path is under /tmp and will not survive a reboot.")
|
|
100
|
+
print("Prefer 'rig init' to create master-key-wrapped data keys.")
|
|
101
|
+
try:
|
|
102
|
+
data_key = ask_secret("Enter data key to create: ").strip()
|
|
103
|
+
except Exception:
|
|
104
|
+
data_key = ask("Enter data key to create: ").strip()
|
|
105
|
+
if not data_key:
|
|
106
|
+
raise ValueError("Data key is required")
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
confirm = ask_secret("Confirm data key: ").strip()
|
|
110
|
+
except Exception:
|
|
111
|
+
confirm = ask("Confirm data key: ").strip()
|
|
112
|
+
if confirm != data_key:
|
|
113
|
+
raise ValueError("Data keys do not match")
|
|
114
|
+
|
|
115
|
+
write_data_key(data_key, path)
|
|
116
|
+
# Also materialize dual keys for callers that expect both paths.
|
|
117
|
+
write_data_key(data_key, PERSONAL_DATA_KEY_PATH)
|
|
118
|
+
write_data_key(data_key, SHARED_DATA_KEY_PATH)
|
|
119
|
+
write_data_key(data_key, DEFAULT_DATA_KEY_PATH)
|
|
120
|
+
print(f"Saved temporary data key to {path}")
|
|
121
|
+
return data_key
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def validate_var_name(name: str) -> str:
|
|
125
|
+
name = name.strip()
|
|
126
|
+
if not name or not _VAR_NAME_RE.match(name):
|
|
127
|
+
raise ValueError(
|
|
128
|
+
"Variable name must match [A-Za-z_][A-Za-z0-9_]* "
|
|
129
|
+
f"(got {name!r})"
|
|
130
|
+
)
|
|
131
|
+
return name
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def resolve_project_dir(start: Optional[str] = None) -> str:
|
|
135
|
+
"""Prefer the nearest ancestor with ``rig.yml`` (cwd or git subfolder).
|
|
136
|
+
|
|
137
|
+
Falls back to the git toplevel when no ``rig.yml`` is found above ``start``,
|
|
138
|
+
then to cwd. This matches ``rig init``, which writes into the current
|
|
139
|
+
directory even when that directory is nested inside a larger git repo.
|
|
140
|
+
"""
|
|
141
|
+
start = os.path.abspath(start or os.getcwd())
|
|
142
|
+
cursor = start
|
|
143
|
+
while True:
|
|
144
|
+
if os.path.isfile(os.path.join(cursor, "rig.yml")):
|
|
145
|
+
return cursor
|
|
146
|
+
parent = os.path.dirname(cursor)
|
|
147
|
+
if parent == cursor:
|
|
148
|
+
break
|
|
149
|
+
cursor = parent
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
result = subprocess.run(
|
|
153
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
154
|
+
cwd=start,
|
|
155
|
+
capture_output=True,
|
|
156
|
+
text=True,
|
|
157
|
+
timeout=10,
|
|
158
|
+
check=False,
|
|
159
|
+
)
|
|
160
|
+
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
|
|
161
|
+
return start
|
|
162
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
163
|
+
return os.path.abspath(result.stdout.strip())
|
|
164
|
+
return start
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def vars_path_for_project(
|
|
168
|
+
project_dir: Optional[str] = None,
|
|
169
|
+
*,
|
|
170
|
+
personal: bool = False,
|
|
171
|
+
identity: Optional[str] = None,
|
|
172
|
+
) -> str:
|
|
173
|
+
"""Return shared ``vars.yml`` or identity ``vars.{id}.yml`` path."""
|
|
174
|
+
root = resolve_project_dir(project_dir)
|
|
175
|
+
if personal:
|
|
176
|
+
return personal_vars_path(root, identity)
|
|
177
|
+
return shared_vars_path(root)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _remove_top_level_key(content: str, name: str) -> str:
|
|
181
|
+
"""Remove a top-level YAML key and its indented block from vars.yml text."""
|
|
182
|
+
if not content:
|
|
183
|
+
return ""
|
|
184
|
+
pattern = re.compile(
|
|
185
|
+
rf"^{re.escape(name)}:.*?(?=^\S|\Z)",
|
|
186
|
+
re.MULTILINE | re.DOTALL,
|
|
187
|
+
)
|
|
188
|
+
cleaned = pattern.sub("", content)
|
|
189
|
+
# Collapse excessive blank lines left behind.
|
|
190
|
+
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned)
|
|
191
|
+
return cleaned.lstrip("\n")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def upsert_vault_var(vars_path: str, name: str, vault_block: str) -> None:
|
|
195
|
+
"""Insert or replace an ansible-vault variable block in vars.yml."""
|
|
196
|
+
name = validate_var_name(name)
|
|
197
|
+
directory = os.path.dirname(vars_path)
|
|
198
|
+
if directory:
|
|
199
|
+
os.makedirs(directory, mode=0o700, exist_ok=True)
|
|
200
|
+
|
|
201
|
+
existing = ""
|
|
202
|
+
if os.path.exists(vars_path):
|
|
203
|
+
with open(vars_path, "r", encoding="utf-8") as handle:
|
|
204
|
+
existing = handle.read()
|
|
205
|
+
|
|
206
|
+
cleaned = _remove_top_level_key(existing, name).rstrip()
|
|
207
|
+
block = vault_block.strip() + "\n"
|
|
208
|
+
if cleaned:
|
|
209
|
+
new_content = cleaned + "\n" + block
|
|
210
|
+
else:
|
|
211
|
+
new_content = block
|
|
212
|
+
|
|
213
|
+
with open(vars_path, "w", encoding="utf-8") as handle:
|
|
214
|
+
handle.write(new_content)
|
|
215
|
+
try:
|
|
216
|
+
os.chmod(vars_path, 0o600)
|
|
217
|
+
except OSError:
|
|
218
|
+
pass
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _vault_id_for_password_file(vault_password_file: str) -> str:
|
|
222
|
+
if os.path.abspath(vault_password_file) == os.path.abspath(SHARED_DATA_KEY_PATH):
|
|
223
|
+
return "shared"
|
|
224
|
+
return "personal"
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def encrypt_named_value(
|
|
228
|
+
plaintext: str,
|
|
229
|
+
name: str,
|
|
230
|
+
vault_password_file: str = DEFAULT_DATA_KEY_PATH,
|
|
231
|
+
vault_id: Optional[str] = None,
|
|
232
|
+
) -> str:
|
|
233
|
+
"""Encrypt plaintext as an Ansible inventory/vars entry named `name`."""
|
|
234
|
+
name = validate_var_name(name)
|
|
235
|
+
if plaintext == "":
|
|
236
|
+
raise ValueError("Value to encrypt is required")
|
|
237
|
+
|
|
238
|
+
label = vault_id or _vault_id_for_password_file(vault_password_file)
|
|
239
|
+
cmd = [
|
|
240
|
+
"ansible-vault",
|
|
241
|
+
"encrypt_string",
|
|
242
|
+
"--vault-id",
|
|
243
|
+
f"{label}@{vault_password_file}",
|
|
244
|
+
"--encrypt-vault-id",
|
|
245
|
+
label,
|
|
246
|
+
"--stdin-name",
|
|
247
|
+
name,
|
|
248
|
+
]
|
|
249
|
+
try:
|
|
250
|
+
result = subprocess.run(
|
|
251
|
+
cmd,
|
|
252
|
+
input=plaintext,
|
|
253
|
+
capture_output=True,
|
|
254
|
+
text=True,
|
|
255
|
+
timeout=60,
|
|
256
|
+
check=False,
|
|
257
|
+
)
|
|
258
|
+
except FileNotFoundError as exc:
|
|
259
|
+
raise RuntimeError(
|
|
260
|
+
"ansible-vault not found. Install Ansible to use rig vars encrypt."
|
|
261
|
+
) from exc
|
|
262
|
+
|
|
263
|
+
if result.returncode != 0:
|
|
264
|
+
detail = (result.stderr or result.stdout or "unknown error").strip()
|
|
265
|
+
raise RuntimeError(f"ansible-vault encrypt_string failed: {detail}")
|
|
266
|
+
return result.stdout.rstrip("\n")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def encrypt_stdin(
|
|
270
|
+
vault_password_file: str = DEFAULT_DATA_KEY_PATH,
|
|
271
|
+
stdin_name: str = "",
|
|
272
|
+
stdin: Optional[TextIO] = None,
|
|
273
|
+
vault_id: Optional[str] = None,
|
|
274
|
+
) -> str:
|
|
275
|
+
"""Encrypt stdin until EOF (Ctrl+D) using ansible-vault --stdin-name."""
|
|
276
|
+
name = validate_var_name(stdin_name)
|
|
277
|
+
source = stdin if stdin is not None else sys.stdin
|
|
278
|
+
if source.isatty():
|
|
279
|
+
print(
|
|
280
|
+
f"Reading multiline value for '{name}'. Finish with Ctrl+D (EOF).",
|
|
281
|
+
file=sys.stderr,
|
|
282
|
+
)
|
|
283
|
+
plaintext = source.read()
|
|
284
|
+
if plaintext == "":
|
|
285
|
+
raise ValueError("Value to encrypt is required")
|
|
286
|
+
return encrypt_named_value(
|
|
287
|
+
plaintext, name, vault_password_file, vault_id=vault_id
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def read_variable_name(
|
|
292
|
+
default: Optional[str] = None,
|
|
293
|
+
input_fn: Optional[Callable[..., str]] = None,
|
|
294
|
+
) -> str:
|
|
295
|
+
ask = input_fn or input
|
|
296
|
+
suffix = f" [{default}]" if default else ""
|
|
297
|
+
answer = ask(f"Variable name{suffix}: ").strip()
|
|
298
|
+
if not answer:
|
|
299
|
+
if default:
|
|
300
|
+
return validate_var_name(default)
|
|
301
|
+
raise ValueError("Variable name is required")
|
|
302
|
+
return validate_var_name(answer)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def read_single_line_value(input_fn: Optional[Callable[..., str]] = None) -> str:
|
|
306
|
+
"""Read one line to encrypt (default encrypt mode)."""
|
|
307
|
+
ask = input_fn or input
|
|
308
|
+
value = ask("Enter value to encrypt: ")
|
|
309
|
+
if value is None or value == "":
|
|
310
|
+
raise ValueError("Value to encrypt is required")
|
|
311
|
+
return value
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def store_encrypted_var(
|
|
315
|
+
name: str,
|
|
316
|
+
plaintext: str,
|
|
317
|
+
project_dir: Optional[str] = None,
|
|
318
|
+
data_key_path: Optional[str] = None,
|
|
319
|
+
*,
|
|
320
|
+
personal: Optional[bool] = None,
|
|
321
|
+
) -> str:
|
|
322
|
+
"""Encrypt plaintext as `name` into shared or personal vars file."""
|
|
323
|
+
if personal is None:
|
|
324
|
+
personal = is_personal_secret_name(name)
|
|
325
|
+
if data_key_path is None:
|
|
326
|
+
data_key_path = PERSONAL_DATA_KEY_PATH if personal else SHARED_DATA_KEY_PATH
|
|
327
|
+
root = resolve_project_dir(project_dir)
|
|
328
|
+
unlock_data_keys(root, require_wraps=False)
|
|
329
|
+
vault_block = encrypt_named_value(
|
|
330
|
+
plaintext,
|
|
331
|
+
name=name,
|
|
332
|
+
vault_password_file=data_key_path,
|
|
333
|
+
)
|
|
334
|
+
vars_path = vars_path_for_project(root, personal=personal)
|
|
335
|
+
upsert_vault_var(vars_path, name, vault_block)
|
|
336
|
+
print(f"Added '{name}' to {vars_path}")
|
|
337
|
+
return vault_block
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _prompt_secret_with_env_fallback(
|
|
341
|
+
label: str,
|
|
342
|
+
env_name: str,
|
|
343
|
+
input_fn: Optional[Callable[..., str]] = None,
|
|
344
|
+
secret_fn: Optional[Callable[..., str]] = None,
|
|
345
|
+
) -> str:
|
|
346
|
+
ask_secret = secret_fn or getpass.getpass
|
|
347
|
+
ask = input_fn or input
|
|
348
|
+
env_value = os.getenv(env_name, "").strip()
|
|
349
|
+
hint = f" [Enter keeps {env_name} from env]" if env_value else " [Enter skips]"
|
|
350
|
+
try:
|
|
351
|
+
value = ask_secret(f"{label}{hint}: ").strip()
|
|
352
|
+
except Exception:
|
|
353
|
+
value = ask(f"{label}{hint}: ").strip()
|
|
354
|
+
if value:
|
|
355
|
+
return value
|
|
356
|
+
if env_value:
|
|
357
|
+
print(f"Using {env_name} from the environment")
|
|
358
|
+
return env_value
|
|
359
|
+
return ""
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
PROVIDER_ENV_VARS = {
|
|
363
|
+
"github": "RIG_GITHUB_PAT",
|
|
364
|
+
"gitlab": "RIG_GITLAB_PAT",
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def detect_git_provider(url: str) -> Optional[str]:
|
|
369
|
+
"""Return 'github' or 'gitlab' when the remote URL is a known host."""
|
|
370
|
+
if not url:
|
|
371
|
+
return None
|
|
372
|
+
lower = url.strip().lower()
|
|
373
|
+
if "github.com" in lower or lower.startswith("git@github.com:"):
|
|
374
|
+
return "github"
|
|
375
|
+
if "gitlab.com" in lower or lower.startswith("git@gitlab.com:"):
|
|
376
|
+
return "gitlab"
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def git_providers_from_repos(repos: Optional[list]) -> list:
|
|
381
|
+
"""Unique git providers (github/gitlab) referenced by workspace repos."""
|
|
382
|
+
found: list = []
|
|
383
|
+
for repo in repos or []:
|
|
384
|
+
if not isinstance(repo, dict):
|
|
385
|
+
continue
|
|
386
|
+
url = (repo.get("url") or repo.get("repo") or "").strip()
|
|
387
|
+
provider = detect_git_provider(url)
|
|
388
|
+
if provider and provider not in found:
|
|
389
|
+
found.append(provider)
|
|
390
|
+
return found
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def project_pat_var_name(project_name: str) -> str:
|
|
394
|
+
"""Repo-specific PAT variable: ``{project_name}_git_pat`` (YAML-safe)."""
|
|
395
|
+
safe = re.sub(r"[^A-Za-z0-9_]", "_", (project_name or "").strip())
|
|
396
|
+
if not safe:
|
|
397
|
+
safe = "project"
|
|
398
|
+
if safe[0].isdigit():
|
|
399
|
+
safe = f"p_{safe}"
|
|
400
|
+
return validate_var_name(f"{safe}_git_pat")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def provider_pat_var_name(provider: str) -> str:
|
|
404
|
+
return validate_var_name(f"{provider}_pat")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _is_ssh_git_url(url: str) -> bool:
|
|
408
|
+
value = (url or "").strip()
|
|
409
|
+
return value.startswith("git@") or value.startswith("ssh://")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def to_https_git_url(url: str) -> str:
|
|
413
|
+
"""
|
|
414
|
+
Convert SSH git remotes to HTTPS.
|
|
415
|
+
|
|
416
|
+
Examples:
|
|
417
|
+
git@github.com:org/repo.git → https://github.com/org/repo.git
|
|
418
|
+
ssh://git@gitlab.com/org/repo.git → https://gitlab.com/org/repo.git
|
|
419
|
+
"""
|
|
420
|
+
value = (url or "").strip()
|
|
421
|
+
if not value:
|
|
422
|
+
return value
|
|
423
|
+
if value.startswith("https://") or value.startswith("http://"):
|
|
424
|
+
return value
|
|
425
|
+
|
|
426
|
+
scp = re.match(r"^git@([^:]+):(.+)$", value)
|
|
427
|
+
if scp:
|
|
428
|
+
host, path = scp.group(1), scp.group(2).lstrip("/")
|
|
429
|
+
if not path.endswith(".git"):
|
|
430
|
+
path = f"{path}.git"
|
|
431
|
+
return f"https://{host}/{path}"
|
|
432
|
+
|
|
433
|
+
ssh = re.match(r"^ssh://(?:git@)?([^/]+)/(.+)$", value)
|
|
434
|
+
if ssh:
|
|
435
|
+
host, path = ssh.group(1), ssh.group(2).lstrip("/")
|
|
436
|
+
if not path.endswith(".git"):
|
|
437
|
+
path = f"{path}.git"
|
|
438
|
+
return f"https://{host}/{path}"
|
|
439
|
+
|
|
440
|
+
return value
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _prompt_yes_no(
|
|
444
|
+
message: str,
|
|
445
|
+
default: bool = True,
|
|
446
|
+
input_fn: Optional[Callable[..., str]] = None,
|
|
447
|
+
) -> bool:
|
|
448
|
+
ask = input_fn or input
|
|
449
|
+
suffix = " [Y/n]" if default else " [y/N]"
|
|
450
|
+
answer = ask(f"{message}{suffix}: ").strip().lower()
|
|
451
|
+
if not answer:
|
|
452
|
+
return default
|
|
453
|
+
return answer in ("y", "yes")
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def collect_repo_pats(
|
|
457
|
+
repos: list,
|
|
458
|
+
input_fn: Optional[Callable[..., str]] = None,
|
|
459
|
+
secret_fn: Optional[Callable[..., str]] = None,
|
|
460
|
+
) -> tuple:
|
|
461
|
+
"""
|
|
462
|
+
Ask for a PAT for each GitHub/GitLab repo (HTTPS or SSH).
|
|
463
|
+
|
|
464
|
+
SSH remotes still need a PAT for CLI auth (MR review, etc.); clone URLs
|
|
465
|
+
are converted to HTTPS separately. After the first PAT for a provider,
|
|
466
|
+
asks whether to store it as the default ``github_pat`` / ``gitlab_pat``.
|
|
467
|
+
Later repos on that provider can Enter to reuse the shared value.
|
|
468
|
+
|
|
469
|
+
Returns ``(assignments, default_pats)`` where assignments is
|
|
470
|
+
``{repo_name: plaintext}`` and default_pats is
|
|
471
|
+
``{provider: plaintext}`` for PATs the user marked as default.
|
|
472
|
+
"""
|
|
473
|
+
ask_secret = secret_fn or getpass.getpass
|
|
474
|
+
ask = input_fn or input
|
|
475
|
+
provider_shared: dict = {}
|
|
476
|
+
default_pats: dict = {}
|
|
477
|
+
assignments: dict = {}
|
|
478
|
+
|
|
479
|
+
for repo in repos:
|
|
480
|
+
if not isinstance(repo, dict):
|
|
481
|
+
continue
|
|
482
|
+
name = (repo.get("name") or "").strip()
|
|
483
|
+
url = (repo.get("url") or "").strip()
|
|
484
|
+
if not name or not url:
|
|
485
|
+
continue
|
|
486
|
+
provider = detect_git_provider(url)
|
|
487
|
+
if not provider:
|
|
488
|
+
print(f"Skipping PAT for '{name}' (unsupported git host; GitHub/GitLab only)")
|
|
489
|
+
continue
|
|
490
|
+
if _is_ssh_git_url(url):
|
|
491
|
+
print(
|
|
492
|
+
f"Note: '{name}' uses SSH remote; "
|
|
493
|
+
f"will clone via {to_https_git_url(url)} and use PAT for {provider} CLI"
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
if provider in provider_shared:
|
|
497
|
+
shared_label = (
|
|
498
|
+
f"default {provider}_pat"
|
|
499
|
+
if provider in default_pats
|
|
500
|
+
else f"shared {provider} PAT"
|
|
501
|
+
)
|
|
502
|
+
label = f"PAT for '{name}' ({provider}) [Enter reuses {shared_label}]"
|
|
503
|
+
try:
|
|
504
|
+
value = ask_secret(f"{label}: ").strip()
|
|
505
|
+
except Exception:
|
|
506
|
+
value = ask(f"{label}: ").strip()
|
|
507
|
+
if not value:
|
|
508
|
+
assignments[name] = provider_shared[provider]
|
|
509
|
+
print(f"Reusing {shared_label} for '{name}'")
|
|
510
|
+
else:
|
|
511
|
+
assignments[name] = value
|
|
512
|
+
continue
|
|
513
|
+
|
|
514
|
+
env_name = PROVIDER_ENV_VARS[provider]
|
|
515
|
+
env_value = os.getenv(env_name, "").strip()
|
|
516
|
+
hint = f" [Enter keeps {env_name} from env]" if env_value else " [Enter skips]"
|
|
517
|
+
label = f"PAT for '{name}' ({provider}){hint}"
|
|
518
|
+
try:
|
|
519
|
+
value = ask_secret(f"{label}: ").strip()
|
|
520
|
+
except Exception:
|
|
521
|
+
value = ask(f"{label}: ").strip()
|
|
522
|
+
if not value and env_value:
|
|
523
|
+
print(f"Using {env_name} from the environment")
|
|
524
|
+
value = env_value
|
|
525
|
+
if not value:
|
|
526
|
+
print(f"Skipping PAT for '{name}'")
|
|
527
|
+
continue
|
|
528
|
+
assignments[name] = value
|
|
529
|
+
provider_shared[provider] = value
|
|
530
|
+
|
|
531
|
+
var_default = provider_pat_var_name(provider)
|
|
532
|
+
if _prompt_yes_no(
|
|
533
|
+
f"Use this as the default {provider} PAT ({var_default})?",
|
|
534
|
+
default=True,
|
|
535
|
+
input_fn=ask,
|
|
536
|
+
):
|
|
537
|
+
default_pats[provider] = value
|
|
538
|
+
print(f"Will store as {var_default}")
|
|
539
|
+
else:
|
|
540
|
+
print(f"Will store as {project_pat_var_name(name)} (repo-specific)")
|
|
541
|
+
|
|
542
|
+
return assignments, default_pats
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def resolve_repo_pat_var_names(
|
|
546
|
+
assignments: dict,
|
|
547
|
+
repos: list,
|
|
548
|
+
default_pats: Optional[dict] = None,
|
|
549
|
+
) -> tuple:
|
|
550
|
+
"""
|
|
551
|
+
Map repo PATs to variable names.
|
|
552
|
+
|
|
553
|
+
- Provider default (user opted in) → ``github_pat`` / ``gitlab_pat``
|
|
554
|
+
- Same PAT shared by multiple repos → provider var
|
|
555
|
+
- Otherwise → ``{project_name}_git_pat``
|
|
556
|
+
|
|
557
|
+
Returns ``(password_vars, to_store)`` where password_vars is
|
|
558
|
+
``{repo_name: var_name}`` and to_store is ``{var_name: plaintext}``.
|
|
559
|
+
"""
|
|
560
|
+
default_pats = dict(default_pats or {})
|
|
561
|
+
providers = {}
|
|
562
|
+
for repo in repos:
|
|
563
|
+
if not isinstance(repo, dict):
|
|
564
|
+
continue
|
|
565
|
+
name = (repo.get("name") or "").strip()
|
|
566
|
+
url = (repo.get("url") or "").strip()
|
|
567
|
+
if name and url:
|
|
568
|
+
providers[name] = detect_git_provider(url)
|
|
569
|
+
|
|
570
|
+
groups: dict = {}
|
|
571
|
+
for name, plaintext in assignments.items():
|
|
572
|
+
provider = providers.get(name)
|
|
573
|
+
if not provider or not plaintext:
|
|
574
|
+
continue
|
|
575
|
+
groups.setdefault((provider, plaintext), []).append(name)
|
|
576
|
+
|
|
577
|
+
password_vars: dict = {}
|
|
578
|
+
to_store: dict = {}
|
|
579
|
+
for (provider, plaintext), names in groups.items():
|
|
580
|
+
is_default = default_pats.get(provider) == plaintext
|
|
581
|
+
if is_default or len(names) > 1:
|
|
582
|
+
var_name = provider_pat_var_name(provider)
|
|
583
|
+
else:
|
|
584
|
+
var_name = project_pat_var_name(names[0])
|
|
585
|
+
to_store[var_name] = plaintext
|
|
586
|
+
for name in names:
|
|
587
|
+
password_vars[name] = var_name
|
|
588
|
+
return password_vars, to_store
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def prompt_store_as_personal(
|
|
592
|
+
kind: str,
|
|
593
|
+
identity: Optional[str] = None,
|
|
594
|
+
input_fn: Optional[Callable[..., str]] = None,
|
|
595
|
+
) -> bool:
|
|
596
|
+
"""
|
|
597
|
+
Confirm whether to store a secret in ``vars.{identity}.yml`` (default yes).
|
|
598
|
+
|
|
599
|
+
``kind`` is a short label like ``git PAT(s)`` or ``coding agent API key``.
|
|
600
|
+
"""
|
|
601
|
+
from cli.vault_keys import resolve_identity
|
|
602
|
+
|
|
603
|
+
ident = resolve_identity(identity)
|
|
604
|
+
return _prompt_yes_no(
|
|
605
|
+
f"Store {kind} in .rig/vars.{ident}.yml (personal, not shared vars.yml)?",
|
|
606
|
+
default=True,
|
|
607
|
+
input_fn=input_fn,
|
|
608
|
+
)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def store_repo_pats(
|
|
612
|
+
to_store: dict,
|
|
613
|
+
project_dir: Optional[str] = None,
|
|
614
|
+
data_key_path: Optional[str] = None,
|
|
615
|
+
input_fn: Optional[Callable[..., str]] = None,
|
|
616
|
+
secret_fn: Optional[Callable[..., str]] = None,
|
|
617
|
+
*,
|
|
618
|
+
personal: Optional[bool] = None,
|
|
619
|
+
) -> bool:
|
|
620
|
+
"""Encrypt and upsert each PAT (default: identity/personal vars file)."""
|
|
621
|
+
if not to_store:
|
|
622
|
+
return False
|
|
623
|
+
root = resolve_project_dir(project_dir)
|
|
624
|
+
unlock_data_keys(
|
|
625
|
+
root, input_fn=input_fn, secret_fn=secret_fn, require_wraps=False
|
|
626
|
+
)
|
|
627
|
+
if personal is None:
|
|
628
|
+
personal = prompt_store_as_personal("git PAT(s)", input_fn=input_fn)
|
|
629
|
+
key_path = data_key_path or (
|
|
630
|
+
PERSONAL_DATA_KEY_PATH if personal else SHARED_DATA_KEY_PATH
|
|
631
|
+
)
|
|
632
|
+
for var_name, plaintext in to_store.items():
|
|
633
|
+
store_encrypted_var(
|
|
634
|
+
var_name,
|
|
635
|
+
plaintext,
|
|
636
|
+
project_dir=root,
|
|
637
|
+
data_key_path=key_path,
|
|
638
|
+
personal=personal,
|
|
639
|
+
)
|
|
640
|
+
scope = "personal" if personal else "shared"
|
|
641
|
+
print(f"Stored git PAT(s) as {scope} secrets")
|
|
642
|
+
return True
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def prompt_and_store_git_pat(
|
|
646
|
+
project_dir: Optional[str] = None,
|
|
647
|
+
data_key_path: str = DEFAULT_DATA_KEY_PATH,
|
|
648
|
+
input_fn: Optional[Callable[..., str]] = None,
|
|
649
|
+
secret_fn: Optional[Callable[..., str]] = None,
|
|
650
|
+
repos: Optional[list] = None,
|
|
651
|
+
) -> dict:
|
|
652
|
+
"""
|
|
653
|
+
Prompt for GitHub/GitLab PATs for the given repos and store them encrypted.
|
|
654
|
+
|
|
655
|
+
When ``repos`` is omitted, falls back to a single shared ``github_pat``
|
|
656
|
+
prompt (legacy). Returns ``{repo_name: var_name}`` for assigned PATs.
|
|
657
|
+
"""
|
|
658
|
+
root = resolve_project_dir(project_dir)
|
|
659
|
+
unlock_data_keys(
|
|
660
|
+
root, input_fn=input_fn, secret_fn=secret_fn, require_wraps=False
|
|
661
|
+
)
|
|
662
|
+
key_path = data_key_path or PERSONAL_DATA_KEY_PATH
|
|
663
|
+
if not repos:
|
|
664
|
+
pat = _prompt_secret_with_env_fallback(
|
|
665
|
+
"GitHub PAT for private repo clones",
|
|
666
|
+
"RIG_GITHUB_PAT",
|
|
667
|
+
input_fn=input_fn,
|
|
668
|
+
secret_fn=secret_fn,
|
|
669
|
+
)
|
|
670
|
+
if not pat:
|
|
671
|
+
print("Skipping github_pat (no PAT provided)")
|
|
672
|
+
return {}
|
|
673
|
+
store_encrypted_var(
|
|
674
|
+
"github_pat",
|
|
675
|
+
pat,
|
|
676
|
+
project_dir=root,
|
|
677
|
+
data_key_path=key_path,
|
|
678
|
+
personal=True,
|
|
679
|
+
)
|
|
680
|
+
return {}
|
|
681
|
+
|
|
682
|
+
assignments, default_pats = collect_repo_pats(
|
|
683
|
+
repos, input_fn=input_fn, secret_fn=secret_fn
|
|
684
|
+
)
|
|
685
|
+
password_vars, to_store = resolve_repo_pat_var_names(
|
|
686
|
+
assignments, repos, default_pats=default_pats
|
|
687
|
+
)
|
|
688
|
+
store_repo_pats(
|
|
689
|
+
to_store,
|
|
690
|
+
project_dir=root,
|
|
691
|
+
data_key_path=key_path,
|
|
692
|
+
input_fn=input_fn,
|
|
693
|
+
secret_fn=secret_fn,
|
|
694
|
+
)
|
|
695
|
+
return password_vars
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def prompt_and_store_coding_agent_credentials(
|
|
699
|
+
project_dir: Optional[str] = None,
|
|
700
|
+
data_key_path: Optional[str] = None,
|
|
701
|
+
input_fn: Optional[Callable[..., str]] = None,
|
|
702
|
+
secret_fn: Optional[Callable[..., str]] = None,
|
|
703
|
+
agent_type: Optional[str] = None,
|
|
704
|
+
*,
|
|
705
|
+
personal: Optional[bool] = None,
|
|
706
|
+
var_name: Optional[str] = None,
|
|
707
|
+
env_name: Optional[str] = None,
|
|
708
|
+
) -> bool:
|
|
709
|
+
"""
|
|
710
|
+
Ask for one coding agent API key and store it.
|
|
711
|
+
|
|
712
|
+
The default variable is ``coding_agent_api_key`` (single-agent workspaces).
|
|
713
|
+
Pass ``var_name`` / ``env_name`` when each agent has its own key.
|
|
714
|
+
"""
|
|
715
|
+
from cli.agents import (
|
|
716
|
+
AGENT_API_KEY_ENVS,
|
|
717
|
+
AGENT_LABELS,
|
|
718
|
+
LEGACY_CODING_AGENT_API_KEY,
|
|
719
|
+
normalize_agent_type,
|
|
720
|
+
)
|
|
721
|
+
|
|
722
|
+
label = "Coding agent API key"
|
|
723
|
+
agent_normalized = None
|
|
724
|
+
if agent_type:
|
|
725
|
+
try:
|
|
726
|
+
agent_normalized = normalize_agent_type(agent_type)
|
|
727
|
+
label = f"{AGENT_LABELS.get(agent_normalized, agent_normalized)} API key"
|
|
728
|
+
except ValueError:
|
|
729
|
+
agent_normalized = None
|
|
730
|
+
stored_name = var_name or LEGACY_CODING_AGENT_API_KEY
|
|
731
|
+
if env_name:
|
|
732
|
+
env_var = env_name
|
|
733
|
+
elif agent_normalized and var_name:
|
|
734
|
+
env_var = AGENT_API_KEY_ENVS.get(agent_normalized, "RIG_CODING_AGENT_API_KEY")
|
|
735
|
+
else:
|
|
736
|
+
env_var = "RIG_CODING_AGENT_API_KEY"
|
|
737
|
+
root = resolve_project_dir(project_dir)
|
|
738
|
+
unlock_data_keys(
|
|
739
|
+
root, input_fn=input_fn, secret_fn=secret_fn, require_wraps=False
|
|
740
|
+
)
|
|
741
|
+
if agent_normalized == "opencode":
|
|
742
|
+
skip_hint = " [Enter = OpenCode free models]"
|
|
743
|
+
elif agent_normalized == "cursor":
|
|
744
|
+
skip_hint = " [Enter = skip; Cursor CLI needs CURSOR_API_KEY on the VM]"
|
|
745
|
+
elif agent_normalized == "claude":
|
|
746
|
+
skip_hint = " [Enter = skip; Claude Code needs an Anthropic API key]"
|
|
747
|
+
else:
|
|
748
|
+
skip_hint = ""
|
|
749
|
+
api_key = _prompt_secret_with_env_fallback(
|
|
750
|
+
f"{label}{skip_hint}",
|
|
751
|
+
env_var,
|
|
752
|
+
input_fn=input_fn,
|
|
753
|
+
secret_fn=secret_fn,
|
|
754
|
+
)
|
|
755
|
+
if not api_key:
|
|
756
|
+
if agent_normalized == "opencode":
|
|
757
|
+
print(
|
|
758
|
+
f"Skipping {stored_name} (no key provided); "
|
|
759
|
+
"OpenCode will use free models"
|
|
760
|
+
)
|
|
761
|
+
elif agent_normalized == "cursor":
|
|
762
|
+
print(
|
|
763
|
+
f"Skipping {stored_name} (no key provided); "
|
|
764
|
+
"set a Cursor user API key before rig attach"
|
|
765
|
+
)
|
|
766
|
+
else:
|
|
767
|
+
print(f"Skipping {stored_name} (no key provided)")
|
|
768
|
+
return False
|
|
769
|
+
|
|
770
|
+
if personal is None:
|
|
771
|
+
personal = prompt_store_as_personal(
|
|
772
|
+
"coding agent API key", input_fn=input_fn
|
|
773
|
+
)
|
|
774
|
+
key_path = data_key_path or (
|
|
775
|
+
PERSONAL_DATA_KEY_PATH if personal else SHARED_DATA_KEY_PATH
|
|
776
|
+
)
|
|
777
|
+
store_encrypted_var(
|
|
778
|
+
stored_name,
|
|
779
|
+
api_key,
|
|
780
|
+
project_dir=root,
|
|
781
|
+
data_key_path=key_path,
|
|
782
|
+
personal=personal,
|
|
783
|
+
)
|
|
784
|
+
scope = "personal" if personal else "shared"
|
|
785
|
+
print(f"Stored {stored_name} as a {scope} secret")
|
|
786
|
+
return True
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def prompt_and_store_coding_agents_credentials(
|
|
790
|
+
agent_types: list,
|
|
791
|
+
project_dir: Optional[str] = None,
|
|
792
|
+
data_key_path: Optional[str] = None,
|
|
793
|
+
input_fn: Optional[Callable[..., str]] = None,
|
|
794
|
+
secret_fn: Optional[Callable[..., str]] = None,
|
|
795
|
+
*,
|
|
796
|
+
personal: Optional[bool] = None,
|
|
797
|
+
) -> dict:
|
|
798
|
+
"""
|
|
799
|
+
Ask for a distinct API key per selected coding agent.
|
|
800
|
+
|
|
801
|
+
Returns ``{agent_type: stored}``. Personal-vs-shared is confirmed once
|
|
802
|
+
after the keys are collected, then every non-empty key is stored.
|
|
803
|
+
"""
|
|
804
|
+
from cli.agents import (
|
|
805
|
+
AGENT_API_KEY_ENVS,
|
|
806
|
+
AGENT_API_KEY_VARS,
|
|
807
|
+
AGENT_LABELS,
|
|
808
|
+
coerce_agent_types,
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
selected = coerce_agent_types(agent_types)
|
|
812
|
+
root = resolve_project_dir(project_dir)
|
|
813
|
+
unlock_data_keys(
|
|
814
|
+
root, input_fn=input_fn, secret_fn=secret_fn, require_wraps=False
|
|
815
|
+
)
|
|
816
|
+
collected: dict = {}
|
|
817
|
+
for agent in selected:
|
|
818
|
+
label = f"{AGENT_LABELS.get(agent, agent)} API key"
|
|
819
|
+
if agent == "opencode":
|
|
820
|
+
skip_hint = " [Enter = OpenCode free models]"
|
|
821
|
+
elif agent == "cursor":
|
|
822
|
+
skip_hint = " [Enter = skip; Cursor CLI needs CURSOR_API_KEY on the VM]"
|
|
823
|
+
else:
|
|
824
|
+
skip_hint = " [Enter = skip; Claude Code needs an Anthropic API key]"
|
|
825
|
+
api_key = _prompt_secret_with_env_fallback(
|
|
826
|
+
f"{label}{skip_hint}",
|
|
827
|
+
AGENT_API_KEY_ENVS[agent],
|
|
828
|
+
input_fn=input_fn,
|
|
829
|
+
secret_fn=secret_fn,
|
|
830
|
+
)
|
|
831
|
+
if not api_key:
|
|
832
|
+
if agent == "opencode":
|
|
833
|
+
print(
|
|
834
|
+
f"Skipping {AGENT_API_KEY_VARS[agent]} (no key provided); "
|
|
835
|
+
"OpenCode will use free models"
|
|
836
|
+
)
|
|
837
|
+
elif agent == "cursor":
|
|
838
|
+
print(
|
|
839
|
+
f"Skipping {AGENT_API_KEY_VARS[agent]} (no key provided); "
|
|
840
|
+
"set a Cursor user API key before rig attach"
|
|
841
|
+
)
|
|
842
|
+
else:
|
|
843
|
+
print(f"Skipping {AGENT_API_KEY_VARS[agent]} (no key provided)")
|
|
844
|
+
continue
|
|
845
|
+
collected[agent] = api_key
|
|
846
|
+
|
|
847
|
+
if collected and personal is None:
|
|
848
|
+
personal = prompt_store_as_personal(
|
|
849
|
+
"coding agent API keys", input_fn=input_fn
|
|
850
|
+
)
|
|
851
|
+
if collected:
|
|
852
|
+
key_path = data_key_path or (
|
|
853
|
+
PERSONAL_DATA_KEY_PATH if personal else SHARED_DATA_KEY_PATH
|
|
854
|
+
)
|
|
855
|
+
scope = "personal" if personal else "shared"
|
|
856
|
+
for agent, api_key in collected.items():
|
|
857
|
+
var_name = AGENT_API_KEY_VARS[agent]
|
|
858
|
+
store_encrypted_var(
|
|
859
|
+
var_name,
|
|
860
|
+
api_key,
|
|
861
|
+
project_dir=root,
|
|
862
|
+
data_key_path=key_path,
|
|
863
|
+
personal=bool(personal),
|
|
864
|
+
)
|
|
865
|
+
print(f"Stored {var_name} as a {scope} secret")
|
|
866
|
+
return {agent: agent in collected for agent in selected}
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def read_vscode_browser_password(
|
|
870
|
+
project_dir: Optional[str] = None,
|
|
871
|
+
data_key_path: Optional[str] = None,
|
|
872
|
+
) -> Optional[str]:
|
|
873
|
+
"""Decrypt ``vscode_browser_password`` when present and decryptable."""
|
|
874
|
+
root = resolve_project_dir(project_dir)
|
|
875
|
+
try:
|
|
876
|
+
unlock_data_keys(root, require_wraps=False)
|
|
877
|
+
except ValueError:
|
|
878
|
+
pass
|
|
879
|
+
key_path = data_key_path or SHARED_DATA_KEY_PATH
|
|
880
|
+
vars_path = vars_path_for_project(root, personal=False)
|
|
881
|
+
if VSCODE_BROWSER_PASSWORD_VAR not in list_vault_var_names(vars_path=vars_path):
|
|
882
|
+
return None
|
|
883
|
+
try:
|
|
884
|
+
return decrypt_var(
|
|
885
|
+
VSCODE_BROWSER_PASSWORD_VAR,
|
|
886
|
+
vars_path=vars_path,
|
|
887
|
+
vault_password_file=key_path,
|
|
888
|
+
)
|
|
889
|
+
except (ValueError, RuntimeError):
|
|
890
|
+
return None
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def ensure_vscode_browser_password(
|
|
894
|
+
project_dir: Optional[str] = None,
|
|
895
|
+
data_key_path: Optional[str] = None,
|
|
896
|
+
*,
|
|
897
|
+
force: bool = False,
|
|
898
|
+
) -> bool:
|
|
899
|
+
"""
|
|
900
|
+
Ensure ``vscode_browser_password`` exists in ``.rig/vars.yml`` (vaulted).
|
|
901
|
+
|
|
902
|
+
Autogenerates a high-entropy password on first use. Returns ``True`` when a
|
|
903
|
+
new value was written.
|
|
904
|
+
"""
|
|
905
|
+
root = resolve_project_dir(project_dir)
|
|
906
|
+
unlock_data_keys(root, require_wraps=False)
|
|
907
|
+
key_path = data_key_path or SHARED_DATA_KEY_PATH
|
|
908
|
+
vars_path = vars_path_for_project(root, personal=False)
|
|
909
|
+
existing = list_vault_var_names(vars_path=vars_path)
|
|
910
|
+
if VSCODE_BROWSER_PASSWORD_VAR in existing and not force:
|
|
911
|
+
return False
|
|
912
|
+
|
|
913
|
+
plaintext = secrets.token_urlsafe(32)
|
|
914
|
+
store_encrypted_var(
|
|
915
|
+
VSCODE_BROWSER_PASSWORD_VAR,
|
|
916
|
+
plaintext,
|
|
917
|
+
project_dir=root,
|
|
918
|
+
data_key_path=key_path,
|
|
919
|
+
personal=False,
|
|
920
|
+
)
|
|
921
|
+
return True
|
|
922
|
+
|
|
923
|
+
|
|
924
|
+
def interactive_encrypt(
|
|
925
|
+
path: Optional[str] = None,
|
|
926
|
+
stdin_name: Optional[str] = None,
|
|
927
|
+
project_dir: Optional[str] = None,
|
|
928
|
+
input_fn: Optional[Callable[..., str]] = None,
|
|
929
|
+
secret_fn: Optional[Callable[..., str]] = None,
|
|
930
|
+
stdin: Optional[TextIO] = None,
|
|
931
|
+
*,
|
|
932
|
+
personal: Optional[bool] = None,
|
|
933
|
+
) -> str:
|
|
934
|
+
"""
|
|
935
|
+
Unlock data keys, encrypt a value, and store it in shared or personal vars.
|
|
936
|
+
|
|
937
|
+
Default: ask for variable name, then a single-line value.
|
|
938
|
+
With stdin_name: use that variable name and read stdin until EOF.
|
|
939
|
+
"""
|
|
940
|
+
root = resolve_project_dir(project_dir)
|
|
941
|
+
use_personal_hint = bool(personal) if personal is not None else False
|
|
942
|
+
default_path = PERSONAL_DATA_KEY_PATH if use_personal_hint else SHARED_DATA_KEY_PATH
|
|
943
|
+
target_path = path or default_path
|
|
944
|
+
ensure_data_key(
|
|
945
|
+
path=target_path,
|
|
946
|
+
input_fn=input_fn,
|
|
947
|
+
secret_fn=secret_fn,
|
|
948
|
+
project_dir=root,
|
|
949
|
+
)
|
|
950
|
+
unlock_data_keys(root, require_wraps=False)
|
|
951
|
+
|
|
952
|
+
if stdin_name:
|
|
953
|
+
name = validate_var_name(stdin_name)
|
|
954
|
+
else:
|
|
955
|
+
name = read_variable_name(input_fn=input_fn)
|
|
956
|
+
|
|
957
|
+
use_personal = is_personal_secret_name(name) if personal is None else personal
|
|
958
|
+
key_path = path or (PERSONAL_DATA_KEY_PATH if use_personal else SHARED_DATA_KEY_PATH)
|
|
959
|
+
if not os.path.exists(key_path):
|
|
960
|
+
src = read_data_key(PERSONAL_DATA_KEY_PATH) or read_data_key(SHARED_DATA_KEY_PATH)
|
|
961
|
+
if src:
|
|
962
|
+
write_data_key(src, key_path)
|
|
963
|
+
|
|
964
|
+
if stdin_name:
|
|
965
|
+
vault_block = encrypt_stdin(
|
|
966
|
+
vault_password_file=key_path,
|
|
967
|
+
stdin_name=name,
|
|
968
|
+
stdin=stdin,
|
|
969
|
+
)
|
|
970
|
+
else:
|
|
971
|
+
plaintext = read_single_line_value(input_fn=input_fn)
|
|
972
|
+
vault_block = encrypt_named_value(
|
|
973
|
+
plaintext,
|
|
974
|
+
name=name,
|
|
975
|
+
vault_password_file=key_path,
|
|
976
|
+
)
|
|
977
|
+
|
|
978
|
+
vars_path = vars_path_for_project(root, personal=use_personal)
|
|
979
|
+
upsert_vault_var(vars_path, name, vault_block)
|
|
980
|
+
print(f"Added '{name}' to {vars_path}")
|
|
981
|
+
return vault_block
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def require_data_key_file(path: str = DEFAULT_DATA_KEY_PATH) -> str:
|
|
985
|
+
"""Return the data key path, or raise if the temporary key is missing."""
|
|
986
|
+
if not os.path.exists(path):
|
|
987
|
+
raise ValueError(
|
|
988
|
+
f"Temporary data key not found at {path}. "
|
|
989
|
+
"Run 'rig vars encrypt' first to create it."
|
|
990
|
+
)
|
|
991
|
+
return path
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def extract_vault_ciphertext(block: str) -> str:
|
|
995
|
+
"""Normalize an ansible-vault blob from a !vault YAML block."""
|
|
996
|
+
match = re.search(r"(\$ANSIBLE_VAULT;.*)", block, re.DOTALL)
|
|
997
|
+
if not match:
|
|
998
|
+
raise ValueError("No ansible-vault ciphertext found")
|
|
999
|
+
lines = [line.strip() for line in match.group(1).splitlines() if line.strip()]
|
|
1000
|
+
return "\n".join(lines) + "\n"
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def extract_var_block(vars_content: str, name: str) -> str:
|
|
1004
|
+
"""Return the top-level YAML block for a variable name."""
|
|
1005
|
+
name = validate_var_name(name)
|
|
1006
|
+
pattern = re.compile(
|
|
1007
|
+
rf"^{re.escape(name)}:.*?(?=^\S|\Z)",
|
|
1008
|
+
re.MULTILINE | re.DOTALL,
|
|
1009
|
+
)
|
|
1010
|
+
match = pattern.search(vars_content)
|
|
1011
|
+
if not match:
|
|
1012
|
+
raise ValueError(f"Variable '{name}' not found in vars.yml")
|
|
1013
|
+
return match.group(0).rstrip()
|
|
1014
|
+
|
|
1015
|
+
|
|
1016
|
+
def decrypt_vault_blob(
|
|
1017
|
+
ciphertext: str,
|
|
1018
|
+
vault_password_file: Optional[str] = None,
|
|
1019
|
+
) -> str:
|
|
1020
|
+
"""Decrypt ansible-vault ciphertext using personal and/or shared data keys."""
|
|
1021
|
+
body = (
|
|
1022
|
+
extract_vault_ciphertext(ciphertext)
|
|
1023
|
+
if "$ANSIBLE_VAULT" in ciphertext
|
|
1024
|
+
else ciphertext
|
|
1025
|
+
)
|
|
1026
|
+
|
|
1027
|
+
import tempfile
|
|
1028
|
+
|
|
1029
|
+
vault_ids = []
|
|
1030
|
+
for label, path in (
|
|
1031
|
+
("personal", PERSONAL_DATA_KEY_PATH),
|
|
1032
|
+
("shared", SHARED_DATA_KEY_PATH),
|
|
1033
|
+
("legacy", DEFAULT_DATA_KEY_PATH),
|
|
1034
|
+
):
|
|
1035
|
+
if os.path.exists(path):
|
|
1036
|
+
vault_ids.extend(["--vault-id", f"{label}@{path}"])
|
|
1037
|
+
if vault_password_file and os.path.exists(vault_password_file):
|
|
1038
|
+
label = _vault_id_for_password_file(vault_password_file)
|
|
1039
|
+
pair = f"{label}@{vault_password_file}"
|
|
1040
|
+
if pair not in " ".join(vault_ids):
|
|
1041
|
+
vault_ids.extend(["--vault-id", pair])
|
|
1042
|
+
if not vault_ids:
|
|
1043
|
+
raise ValueError(
|
|
1044
|
+
"No data keys under /tmp/.rig. Unlock with your personal master key first."
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
fd, temp_path = tempfile.mkstemp(prefix="rig-vault-", suffix=".txt")
|
|
1048
|
+
try:
|
|
1049
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
1050
|
+
handle.write(body)
|
|
1051
|
+
cmd = ["ansible-vault", "decrypt", temp_path, *vault_ids, "--output", "-"]
|
|
1052
|
+
try:
|
|
1053
|
+
result = subprocess.run(
|
|
1054
|
+
cmd,
|
|
1055
|
+
capture_output=True,
|
|
1056
|
+
text=True,
|
|
1057
|
+
timeout=60,
|
|
1058
|
+
check=False,
|
|
1059
|
+
)
|
|
1060
|
+
except FileNotFoundError as exc:
|
|
1061
|
+
raise RuntimeError(
|
|
1062
|
+
"ansible-vault not found. Install Ansible to use rig vars decrypt."
|
|
1063
|
+
) from exc
|
|
1064
|
+
if result.returncode != 0:
|
|
1065
|
+
detail = (result.stderr or result.stdout or "unknown error").strip()
|
|
1066
|
+
raise RuntimeError(f"ansible-vault decrypt failed: {detail}")
|
|
1067
|
+
return result.stdout
|
|
1068
|
+
finally:
|
|
1069
|
+
try:
|
|
1070
|
+
os.unlink(temp_path)
|
|
1071
|
+
except OSError:
|
|
1072
|
+
pass
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def decrypt_var(
|
|
1076
|
+
name: str,
|
|
1077
|
+
vars_path: Optional[str] = None,
|
|
1078
|
+
vault_password_file: Optional[str] = None,
|
|
1079
|
+
project_dir: Optional[str] = None,
|
|
1080
|
+
) -> str:
|
|
1081
|
+
"""Decrypt a named variable from shared or personal vars using data keys."""
|
|
1082
|
+
name = validate_var_name(name)
|
|
1083
|
+
root = resolve_project_dir(project_dir)
|
|
1084
|
+
if vars_path:
|
|
1085
|
+
path = vars_path
|
|
1086
|
+
key_file = vault_password_file or (
|
|
1087
|
+
PERSONAL_DATA_KEY_PATH
|
|
1088
|
+
if os.path.basename(path).startswith("vars.")
|
|
1089
|
+
and os.path.basename(path) != "vars.yml"
|
|
1090
|
+
else SHARED_DATA_KEY_PATH
|
|
1091
|
+
)
|
|
1092
|
+
if not os.path.exists(path):
|
|
1093
|
+
raise ValueError(f"vars file not found at {path}")
|
|
1094
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
1095
|
+
content = handle.read()
|
|
1096
|
+
block = extract_var_block(content, name)
|
|
1097
|
+
return decrypt_vault_blob(block, vault_password_file=key_file)
|
|
1098
|
+
|
|
1099
|
+
unlock_data_keys(root, require_wraps=False)
|
|
1100
|
+
# Prefer personal, then shared.
|
|
1101
|
+
candidates = [
|
|
1102
|
+
(vars_path_for_project(root, personal=True), PERSONAL_DATA_KEY_PATH),
|
|
1103
|
+
(vars_path_for_project(root, personal=False), SHARED_DATA_KEY_PATH),
|
|
1104
|
+
]
|
|
1105
|
+
last_err: Optional[Exception] = None
|
|
1106
|
+
for path, key_file in candidates:
|
|
1107
|
+
if not os.path.exists(path):
|
|
1108
|
+
continue
|
|
1109
|
+
try:
|
|
1110
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
1111
|
+
content = handle.read()
|
|
1112
|
+
block = extract_var_block(content, name)
|
|
1113
|
+
key = vault_password_file or key_file
|
|
1114
|
+
return decrypt_vault_blob(block, vault_password_file=key)
|
|
1115
|
+
except ValueError as exc:
|
|
1116
|
+
last_err = exc
|
|
1117
|
+
continue
|
|
1118
|
+
if last_err:
|
|
1119
|
+
raise last_err
|
|
1120
|
+
raise ValueError(f"Variable '{name}' not found in personal or shared vars")
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def list_vault_var_names(vars_path: Optional[str] = None, project_dir: Optional[str] = None) -> list[str]:
|
|
1124
|
+
path = vars_path or vars_path_for_project(project_dir)
|
|
1125
|
+
if not os.path.exists(path):
|
|
1126
|
+
return []
|
|
1127
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
1128
|
+
content = handle.read()
|
|
1129
|
+
names = []
|
|
1130
|
+
for match in re.finditer(r"^([A-Za-z_][A-Za-z0-9_]*):\s*!vault\b", content, re.MULTILINE):
|
|
1131
|
+
names.append(match.group(1))
|
|
1132
|
+
return names
|
|
1133
|
+
|
|
1134
|
+
|
|
1135
|
+
def list_all_vault_vars(project_dir: Optional[str] = None) -> list[tuple[str, str]]:
|
|
1136
|
+
"""Return ``(name, scope)`` for vaulted vars (scope: shared|identity)."""
|
|
1137
|
+
root = resolve_project_dir(project_dir)
|
|
1138
|
+
out: list[tuple[str, str]] = []
|
|
1139
|
+
shared = vars_path_for_project(root, personal=False)
|
|
1140
|
+
for name in list_vault_var_names(vars_path=shared):
|
|
1141
|
+
out.append((name, "shared"))
|
|
1142
|
+
personal = vars_path_for_project(root, personal=True)
|
|
1143
|
+
for name in list_vault_var_names(vars_path=personal):
|
|
1144
|
+
if name in WRAP_FIELD_NAMES:
|
|
1145
|
+
continue
|
|
1146
|
+
out.append((name, "identity"))
|
|
1147
|
+
return out
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def interactive_decrypt(
|
|
1151
|
+
path: Optional[str] = None,
|
|
1152
|
+
name: Optional[str] = None,
|
|
1153
|
+
project_dir: Optional[str] = None,
|
|
1154
|
+
input_fn: Optional[Callable[..., str]] = None,
|
|
1155
|
+
) -> str:
|
|
1156
|
+
"""Decrypt a variable from personal or shared vars after unlock."""
|
|
1157
|
+
root = resolve_project_dir(project_dir)
|
|
1158
|
+
unlock_data_keys(root, input_fn=input_fn, require_wraps=False)
|
|
1159
|
+
if name:
|
|
1160
|
+
var_name = validate_var_name(name)
|
|
1161
|
+
else:
|
|
1162
|
+
available = [n for n, _ in list_all_vault_vars(root)]
|
|
1163
|
+
default = available[0] if len(available) == 1 else None
|
|
1164
|
+
if available:
|
|
1165
|
+
labeled = ", ".join(f"{n} ({s})" for n, s in list_all_vault_vars(root))
|
|
1166
|
+
print("Encrypted variables: " + labeled)
|
|
1167
|
+
var_name = read_variable_name(default=default, input_fn=input_fn)
|
|
1168
|
+
return decrypt_var(var_name, project_dir=root, vault_password_file=path)
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def export_shared_data_key(
|
|
1172
|
+
project_dir: Optional[str] = None,
|
|
1173
|
+
input_fn: Optional[Callable[..., str]] = None,
|
|
1174
|
+
secret_fn: Optional[Callable[..., str]] = None,
|
|
1175
|
+
) -> str:
|
|
1176
|
+
"""Unlock and return the plaintext shared data key (for teammate onboarding)."""
|
|
1177
|
+
root = resolve_project_dir(project_dir)
|
|
1178
|
+
unlock_data_keys(root, input_fn=input_fn, secret_fn=secret_fn)
|
|
1179
|
+
key = read_data_key(SHARED_DATA_KEY_PATH)
|
|
1180
|
+
if not key:
|
|
1181
|
+
raise ValueError("Shared data key not available after unlock")
|
|
1182
|
+
return key
|