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/main.py
ADDED
|
@@ -0,0 +1,1234 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Main entry point for the remote development environment CLI
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import sys
|
|
8
|
+
import os
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
# Add the src directory to the path so we can import our modules
|
|
12
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)) + '/..')
|
|
13
|
+
|
|
14
|
+
from cli.storage import StorageManager
|
|
15
|
+
from cli.config import RigConfig
|
|
16
|
+
from cli.encrypt import interactive_decrypt, interactive_encrypt
|
|
17
|
+
from cli import DISTRIBUTION_NAME
|
|
18
|
+
from cli import log as log
|
|
19
|
+
from cli.last_env import clear_last_env, remember_last_env, resolve_env_name
|
|
20
|
+
from cli.last_session import (
|
|
21
|
+
agent_args_request_session,
|
|
22
|
+
clear_last_session,
|
|
23
|
+
read_last_session,
|
|
24
|
+
resume_args_for_agent,
|
|
25
|
+
)
|
|
26
|
+
from cli.remote_dev import (
|
|
27
|
+
IMPLEMENTED_CLOUD_PROVIDERS,
|
|
28
|
+
REMOTE_WORKSPACE_ROOT,
|
|
29
|
+
ProjectConfig,
|
|
30
|
+
RemoteDevEnvironment,
|
|
31
|
+
host_block,
|
|
32
|
+
interactive_env_init,
|
|
33
|
+
normalize_cloud_provider,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _load_credentials() -> bool:
|
|
38
|
+
"""Load ~/.rig/config into the environment when vars are unset."""
|
|
39
|
+
try:
|
|
40
|
+
RigConfig().apply_to_environ()
|
|
41
|
+
return True
|
|
42
|
+
except Exception as exc:
|
|
43
|
+
log.warning("Could not load ~/.rig/config: %s", exc)
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
# Import package version. The two libraries export the same function name;
|
|
47
|
+
# bind them separately so the type checker does not see a redefinition.
|
|
48
|
+
try:
|
|
49
|
+
from importlib.metadata import version as package_version
|
|
50
|
+
__version__ = package_version(DISTRIBUTION_NAME)
|
|
51
|
+
except ImportError:
|
|
52
|
+
try:
|
|
53
|
+
from importlib_metadata import version as package_version_fallback
|
|
54
|
+
__version__ = package_version_fallback(DISTRIBUTION_NAME)
|
|
55
|
+
except ImportError:
|
|
56
|
+
__version__ = "0.1.0"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _cmd_create(args: argparse.Namespace) -> int:
|
|
60
|
+
_load_credentials()
|
|
61
|
+
|
|
62
|
+
storage_manager = None
|
|
63
|
+
try:
|
|
64
|
+
storage_manager = StorageManager()
|
|
65
|
+
except ValueError:
|
|
66
|
+
log.warning("Storage manager not available; state will not be saved")
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
project = ProjectConfig(os.getcwd())
|
|
70
|
+
project.load()
|
|
71
|
+
host_cfg = host_block(project.config or {})
|
|
72
|
+
if getattr(args, "provider", None):
|
|
73
|
+
host_cfg["provider"] = normalize_cloud_provider(args.provider)
|
|
74
|
+
provider = host_cfg.get("provider")
|
|
75
|
+
if provider not in IMPLEMENTED_CLOUD_PROVIDERS:
|
|
76
|
+
available = ", ".join(sorted(IMPLEMENTED_CLOUD_PROVIDERS))
|
|
77
|
+
log.error(
|
|
78
|
+
"Cloud provider '%s' is not available yet (supported now: %s)",
|
|
79
|
+
provider,
|
|
80
|
+
available,
|
|
81
|
+
)
|
|
82
|
+
return 1
|
|
83
|
+
hetzner_token = os.getenv('HETZNER_CLOUD_API_TOKEN')
|
|
84
|
+
if not hetzner_token:
|
|
85
|
+
log.error(
|
|
86
|
+
"Hetzner credentials missing. "
|
|
87
|
+
"Run 'rig init' or set HETZNER_CLOUD_API_TOKEN"
|
|
88
|
+
)
|
|
89
|
+
return 1
|
|
90
|
+
if getattr(args, "type", None):
|
|
91
|
+
host_cfg["server_type"] = args.type
|
|
92
|
+
if getattr(args, "availability_zone", None):
|
|
93
|
+
host_cfg["availability_zone"] = args.availability_zone
|
|
94
|
+
host_cfg.pop("region", None)
|
|
95
|
+
if getattr(args, "image", None):
|
|
96
|
+
host_cfg["image"] = args.image
|
|
97
|
+
|
|
98
|
+
env = RemoteDevEnvironment(
|
|
99
|
+
name=args.name,
|
|
100
|
+
config={"host": host_cfg},
|
|
101
|
+
storage_manager=storage_manager,
|
|
102
|
+
)
|
|
103
|
+
force_system = bool(
|
|
104
|
+
getattr(args, "force_system", False)
|
|
105
|
+
or getattr(args, "force_playbooks", False)
|
|
106
|
+
)
|
|
107
|
+
force_workspace = bool(
|
|
108
|
+
getattr(args, "force_workspace", False)
|
|
109
|
+
or getattr(args, "force_playbooks", False)
|
|
110
|
+
)
|
|
111
|
+
if not env.setup(project_dir=os.getcwd()):
|
|
112
|
+
return 1
|
|
113
|
+
|
|
114
|
+
# Provision via Ansible (system.yml then workspace.yml) with live output.
|
|
115
|
+
# Matching snapshots skip covered playbooks unless --force-* is set.
|
|
116
|
+
if not env.provision(
|
|
117
|
+
project_dir=os.getcwd(),
|
|
118
|
+
stream=True,
|
|
119
|
+
force_system=force_system,
|
|
120
|
+
force_workspace=force_workspace,
|
|
121
|
+
):
|
|
122
|
+
return 1
|
|
123
|
+
|
|
124
|
+
log.info("Environment '%s' created", env.name)
|
|
125
|
+
remember_last_env(env.name)
|
|
126
|
+
if getattr(args, "no_attach", False):
|
|
127
|
+
log.info("Skipped agent attach; reconnect with: rig attach / rig dev")
|
|
128
|
+
log.debug("Reconnect: rig attach %s", env.name)
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
return env.attach_to_agent(project_dir=os.getcwd())
|
|
132
|
+
except Exception as e:
|
|
133
|
+
log.error("Error creating environment: %s", e)
|
|
134
|
+
return 1
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _merge_agent_args(
|
|
138
|
+
continue_: bool = False,
|
|
139
|
+
session=None,
|
|
140
|
+
resume=None,
|
|
141
|
+
remainder=None,
|
|
142
|
+
) -> list:
|
|
143
|
+
"""Merge convenience flags with remainder args for the remote agent."""
|
|
144
|
+
agent_args: list = []
|
|
145
|
+
if continue_:
|
|
146
|
+
agent_args.append("--continue")
|
|
147
|
+
if session:
|
|
148
|
+
agent_args.extend(["--session", session])
|
|
149
|
+
if resume is not None:
|
|
150
|
+
# ``--resume`` alone opens the picker; ``--resume ID`` picks a session.
|
|
151
|
+
agent_args.append("--resume")
|
|
152
|
+
if resume:
|
|
153
|
+
agent_args.append(resume)
|
|
154
|
+
extra = list(remainder or [])
|
|
155
|
+
if extra and extra[0] == "--":
|
|
156
|
+
extra = extra[1:]
|
|
157
|
+
agent_args.extend(extra)
|
|
158
|
+
return agent_args
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _attach_name_and_remainder(
|
|
162
|
+
args: argparse.Namespace,
|
|
163
|
+
remainder_attr: str = "agent_args",
|
|
164
|
+
):
|
|
165
|
+
"""
|
|
166
|
+
Split env name from trailing argv.
|
|
167
|
+
|
|
168
|
+
Argparse can put flag-like tokens into the optional ``name`` positional
|
|
169
|
+
when users write ``rig attach -- --continue``; move those into remainder.
|
|
170
|
+
"""
|
|
171
|
+
name = getattr(args, "name", None)
|
|
172
|
+
remainder = list(getattr(args, remainder_attr, None) or [])
|
|
173
|
+
if name and str(name).startswith("-"):
|
|
174
|
+
remainder = [name] + remainder
|
|
175
|
+
name = None
|
|
176
|
+
if remainder and remainder[0] == "--":
|
|
177
|
+
remainder = remainder[1:]
|
|
178
|
+
return name, remainder
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _cmd_attach(args: argparse.Namespace) -> int:
|
|
182
|
+
_load_credentials()
|
|
183
|
+
hetzner_token = os.getenv('HETZNER_CLOUD_API_TOKEN')
|
|
184
|
+
if not hetzner_token:
|
|
185
|
+
log.error("Hetzner credentials missing. Run 'rig init' or set HETZNER_CLOUD_API_TOKEN")
|
|
186
|
+
return 1
|
|
187
|
+
|
|
188
|
+
positional_name, remainder = _attach_name_and_remainder(args)
|
|
189
|
+
name = resolve_env_name(
|
|
190
|
+
positional_name,
|
|
191
|
+
getattr(args, "name_flag", None),
|
|
192
|
+
)
|
|
193
|
+
if not name:
|
|
194
|
+
log.error("Environment name is required (no last environment remembered)")
|
|
195
|
+
log.info("Usage: rig attach [name] [-c|--continue] [-- …agent args]")
|
|
196
|
+
return 1
|
|
197
|
+
|
|
198
|
+
agent_args = _merge_agent_args(
|
|
199
|
+
continue_=getattr(args, "agent_continue", False),
|
|
200
|
+
session=getattr(args, "agent_session", None),
|
|
201
|
+
resume=getattr(args, "agent_resume", None),
|
|
202
|
+
remainder=remainder,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
storage_manager = None
|
|
206
|
+
try:
|
|
207
|
+
storage_manager = StorageManager()
|
|
208
|
+
except ValueError:
|
|
209
|
+
log.debug("Storage manager not available; resolving resources via Hetzner API")
|
|
210
|
+
|
|
211
|
+
try:
|
|
212
|
+
env = RemoteDevEnvironment(name=name, storage_manager=storage_manager)
|
|
213
|
+
if not env.resolve_resources() or not env.vm_ip:
|
|
214
|
+
log.error("Could not resolve environment '%s' (no VM IP found)", name)
|
|
215
|
+
return 1
|
|
216
|
+
|
|
217
|
+
ssh_key_path = f"/tmp/{env.ssh_key_name}"
|
|
218
|
+
if not os.path.exists(ssh_key_path):
|
|
219
|
+
log.error("SSH private key not found at %s", ssh_key_path)
|
|
220
|
+
log.error("Re-run create on this machine, or restore the key before attaching")
|
|
221
|
+
return 1
|
|
222
|
+
|
|
223
|
+
project = ProjectConfig(os.getcwd())
|
|
224
|
+
project.load()
|
|
225
|
+
from cli.remote_dev import configured_agents
|
|
226
|
+
from cli.agents import normalize_agent_type
|
|
227
|
+
|
|
228
|
+
configured, default_agent = configured_agents(project)
|
|
229
|
+
requested = getattr(args, "agent", None)
|
|
230
|
+
if requested:
|
|
231
|
+
try:
|
|
232
|
+
agent_type = normalize_agent_type(requested)
|
|
233
|
+
except ValueError as exc:
|
|
234
|
+
log.error("%s", exc)
|
|
235
|
+
return 1
|
|
236
|
+
if agent_type not in configured:
|
|
237
|
+
log.error(
|
|
238
|
+
"Coding agent '%s' is not enabled for this workspace "
|
|
239
|
+
"(enabled: %s)",
|
|
240
|
+
agent_type,
|
|
241
|
+
", ".join(configured),
|
|
242
|
+
)
|
|
243
|
+
return 1
|
|
244
|
+
else:
|
|
245
|
+
agent_type = default_agent
|
|
246
|
+
|
|
247
|
+
# Bare ``rig attach`` reopens the last remembered session when present.
|
|
248
|
+
# With several agents installed, resume the one that owns that session
|
|
249
|
+
# unless --agent picked a different one.
|
|
250
|
+
if not agent_args_request_session(agent_args):
|
|
251
|
+
stored = read_last_session(env.name)
|
|
252
|
+
if stored:
|
|
253
|
+
session_id, stored_agent = stored
|
|
254
|
+
resume_agent = stored_agent or agent_type
|
|
255
|
+
if (
|
|
256
|
+
not requested
|
|
257
|
+
and stored_agent
|
|
258
|
+
and stored_agent in configured
|
|
259
|
+
):
|
|
260
|
+
agent_type = stored_agent
|
|
261
|
+
resume_agent = stored_agent
|
|
262
|
+
if requested and stored_agent and stored_agent != agent_type:
|
|
263
|
+
resume_agent = None
|
|
264
|
+
elif stored_agent and stored_agent != agent_type:
|
|
265
|
+
resume_agent = None
|
|
266
|
+
if resume_agent:
|
|
267
|
+
injected = resume_args_for_agent(resume_agent, session_id)
|
|
268
|
+
agent_args = injected + list(agent_args)
|
|
269
|
+
log.info(
|
|
270
|
+
"Resuming last %s session %s",
|
|
271
|
+
resume_agent,
|
|
272
|
+
session_id,
|
|
273
|
+
)
|
|
274
|
+
if len(configured) > 1:
|
|
275
|
+
log.info(
|
|
276
|
+
"Workspace coding agents: %s (attaching to %s)",
|
|
277
|
+
", ".join(configured),
|
|
278
|
+
agent_type,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
env.ensure_firewall_access(project_dir=os.getcwd())
|
|
282
|
+
remember_last_env(env.name)
|
|
283
|
+
if agent_args:
|
|
284
|
+
log.debug("Agent args: %s", " ".join(agent_args))
|
|
285
|
+
return env.attach_to_agent(
|
|
286
|
+
project_dir=os.getcwd(),
|
|
287
|
+
agent_type=str(agent_type),
|
|
288
|
+
agent_args=agent_args,
|
|
289
|
+
)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
log.error("Error attaching to environment: %s", e)
|
|
292
|
+
return 1
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _cmd_ssh(args: argparse.Namespace) -> int:
|
|
296
|
+
"""Open a shell on an existing environment, or run one remote command."""
|
|
297
|
+
_load_credentials()
|
|
298
|
+
hetzner_token = os.getenv('HETZNER_CLOUD_API_TOKEN')
|
|
299
|
+
if not hetzner_token:
|
|
300
|
+
log.error("Hetzner credentials missing. Run 'rig init' or set HETZNER_CLOUD_API_TOKEN")
|
|
301
|
+
return 1
|
|
302
|
+
|
|
303
|
+
tokens = list(getattr(args, "ssh_args", None) or [])
|
|
304
|
+
positional_name = None
|
|
305
|
+
remainder: list = []
|
|
306
|
+
if tokens and tokens[0] == "--":
|
|
307
|
+
remainder = tokens[1:]
|
|
308
|
+
elif tokens:
|
|
309
|
+
positional_name = tokens[0]
|
|
310
|
+
remainder = tokens[1:]
|
|
311
|
+
if remainder and remainder[0] == "--":
|
|
312
|
+
remainder = remainder[1:]
|
|
313
|
+
name = resolve_env_name(
|
|
314
|
+
positional_name,
|
|
315
|
+
getattr(args, "name_flag", None),
|
|
316
|
+
)
|
|
317
|
+
if not name:
|
|
318
|
+
log.error("Environment name is required (no last environment remembered)")
|
|
319
|
+
log.info("Usage: rig ssh [name] [-- command]")
|
|
320
|
+
return 1
|
|
321
|
+
|
|
322
|
+
storage_manager = None
|
|
323
|
+
try:
|
|
324
|
+
storage_manager = StorageManager()
|
|
325
|
+
except ValueError:
|
|
326
|
+
log.debug("Storage manager not available; resolving resources via Hetzner API")
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
env = RemoteDevEnvironment(name=name, storage_manager=storage_manager)
|
|
330
|
+
if not env.resolve_resources() or not env.vm_ip:
|
|
331
|
+
log.error("Could not resolve environment '%s' (no VM IP found)", name)
|
|
332
|
+
return 1
|
|
333
|
+
|
|
334
|
+
ssh_key_path = f"/tmp/{env.ssh_key_name}"
|
|
335
|
+
if not os.path.exists(ssh_key_path):
|
|
336
|
+
log.error("SSH private key not found at %s", ssh_key_path)
|
|
337
|
+
log.error("Re-run create on this machine, or restore the key before connecting")
|
|
338
|
+
return 1
|
|
339
|
+
|
|
340
|
+
env.ensure_firewall_access(project_dir=os.getcwd())
|
|
341
|
+
remember_last_env(env.name)
|
|
342
|
+
import shlex
|
|
343
|
+
remote = shlex.join(remainder) if remainder else None
|
|
344
|
+
return env.open_ssh(remote_command=remote)
|
|
345
|
+
except Exception as e:
|
|
346
|
+
log.error("Error connecting to environment: %s", e)
|
|
347
|
+
return 1
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _cmd_apply(args: argparse.Namespace) -> int:
|
|
351
|
+
"""Re-run lifecycle playbooks on an existing environment."""
|
|
352
|
+
_load_credentials()
|
|
353
|
+
hetzner_token = os.getenv('HETZNER_CLOUD_API_TOKEN')
|
|
354
|
+
if not hetzner_token:
|
|
355
|
+
log.error("Hetzner credentials missing. Run 'rig init' or set HETZNER_CLOUD_API_TOKEN")
|
|
356
|
+
return 1
|
|
357
|
+
|
|
358
|
+
name = resolve_env_name(
|
|
359
|
+
getattr(args, "name", None),
|
|
360
|
+
getattr(args, "name_flag", None),
|
|
361
|
+
)
|
|
362
|
+
if not name:
|
|
363
|
+
log.error("Environment name is required (no last environment remembered)")
|
|
364
|
+
log.info(
|
|
365
|
+
"Usage: rig apply [name] "
|
|
366
|
+
"[--provision|--configure|--authorize|--materialize]"
|
|
367
|
+
)
|
|
368
|
+
return 1
|
|
369
|
+
|
|
370
|
+
# Legacy flag aliases.
|
|
371
|
+
want_provision = getattr(args, "provision", False) or getattr(args, "system", False)
|
|
372
|
+
want_configure = getattr(args, "configure", False)
|
|
373
|
+
want_authorize = getattr(args, "authorize", False) or getattr(args, "authnz", False)
|
|
374
|
+
want_materialize = getattr(args, "materialize", False)
|
|
375
|
+
|
|
376
|
+
selected = []
|
|
377
|
+
if want_provision:
|
|
378
|
+
selected.extend(["provision", "configure", "authorize"])
|
|
379
|
+
if want_configure and "configure" not in selected:
|
|
380
|
+
selected.append("configure")
|
|
381
|
+
if want_authorize and not want_provision and not want_configure and not want_materialize:
|
|
382
|
+
selected = ["authorize"]
|
|
383
|
+
elif want_authorize and "authorize" not in selected:
|
|
384
|
+
selected.append("authorize")
|
|
385
|
+
if want_materialize:
|
|
386
|
+
if "authorize" not in selected and not want_provision:
|
|
387
|
+
selected.append("authorize")
|
|
388
|
+
if "materialize" not in selected:
|
|
389
|
+
selected.append("materialize")
|
|
390
|
+
if not selected:
|
|
391
|
+
# Default apply: re-authorize then materialize.
|
|
392
|
+
selected = ["authorize", "materialize"]
|
|
393
|
+
|
|
394
|
+
# Preserve lifecycle order.
|
|
395
|
+
order = ("provision", "configure", "authorize", "materialize")
|
|
396
|
+
playbooks = tuple(stage for stage in order if stage in selected)
|
|
397
|
+
|
|
398
|
+
storage_manager = None
|
|
399
|
+
try:
|
|
400
|
+
storage_manager = StorageManager()
|
|
401
|
+
except ValueError:
|
|
402
|
+
log.debug("Storage manager not available; resolving resources via Hetzner API")
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
env = RemoteDevEnvironment(name=name, storage_manager=storage_manager)
|
|
406
|
+
if not env.resolve_resources() or not env.vm_ip:
|
|
407
|
+
log.error("Could not resolve environment '%s' (no VM IP found)", name)
|
|
408
|
+
return 1
|
|
409
|
+
|
|
410
|
+
ssh_key_path = f"/tmp/{env.ssh_key_name}"
|
|
411
|
+
if not os.path.exists(ssh_key_path):
|
|
412
|
+
log.error("SSH private key not found at %s", ssh_key_path)
|
|
413
|
+
log.error("Re-run create on this machine, or restore the key before applying")
|
|
414
|
+
return 1
|
|
415
|
+
|
|
416
|
+
env.ensure_firewall_access(project_dir=os.getcwd())
|
|
417
|
+
remember_last_env(env.name)
|
|
418
|
+
ok = env.apply_playbooks(
|
|
419
|
+
project_dir=os.getcwd(),
|
|
420
|
+
stream=True,
|
|
421
|
+
playbooks=playbooks,
|
|
422
|
+
soft_fail_materialize=False,
|
|
423
|
+
action="apply",
|
|
424
|
+
)
|
|
425
|
+
return 0 if ok else 1
|
|
426
|
+
except Exception as e:
|
|
427
|
+
log.error("Error applying playbooks: %s", e)
|
|
428
|
+
return 1
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _cmd_dev(args: argparse.Namespace) -> int:
|
|
432
|
+
_load_credentials()
|
|
433
|
+
hetzner_token = os.getenv('HETZNER_CLOUD_API_TOKEN')
|
|
434
|
+
if not hetzner_token:
|
|
435
|
+
log.error("Hetzner credentials missing. Run 'rig init' or set HETZNER_CLOUD_API_TOKEN")
|
|
436
|
+
return 1
|
|
437
|
+
|
|
438
|
+
name = resolve_env_name(
|
|
439
|
+
getattr(args, "name", None),
|
|
440
|
+
getattr(args, "name_flag", None),
|
|
441
|
+
)
|
|
442
|
+
if not name:
|
|
443
|
+
log.error("Environment name is required (no last environment remembered)")
|
|
444
|
+
log.info("Usage: rig dev [name] or rig dev --name <name>")
|
|
445
|
+
return 1
|
|
446
|
+
|
|
447
|
+
storage_manager = None
|
|
448
|
+
try:
|
|
449
|
+
storage_manager = StorageManager()
|
|
450
|
+
except ValueError:
|
|
451
|
+
log.debug("Storage manager not available; resolving resources via Hetzner API")
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
env = RemoteDevEnvironment(name=name, storage_manager=storage_manager)
|
|
455
|
+
if not env.resolve_resources() or not env.vm_ip:
|
|
456
|
+
log.error("Could not resolve environment '%s' (no VM IP found)", name)
|
|
457
|
+
return 1
|
|
458
|
+
|
|
459
|
+
env.ensure_firewall_access(project_dir=os.getcwd())
|
|
460
|
+
remember_last_env(env.name)
|
|
461
|
+
from cli.remote_dev import configured_editors, normalize_editor_type
|
|
462
|
+
|
|
463
|
+
project = ProjectConfig(os.getcwd())
|
|
464
|
+
project.load()
|
|
465
|
+
enabled, default_editor = configured_editors(project)
|
|
466
|
+
requested = getattr(args, "editor", None)
|
|
467
|
+
editor: Optional[str] = None
|
|
468
|
+
editor_fallbacks: Optional[List[str]] = None
|
|
469
|
+
if requested:
|
|
470
|
+
try:
|
|
471
|
+
editor = normalize_editor_type(requested)
|
|
472
|
+
except ValueError as exc:
|
|
473
|
+
log.error("%s", exc)
|
|
474
|
+
return 1
|
|
475
|
+
if enabled and editor not in enabled:
|
|
476
|
+
log.error(
|
|
477
|
+
"Editor '%s' is not enabled for this workspace (enabled: %s)",
|
|
478
|
+
editor,
|
|
479
|
+
", ".join(enabled),
|
|
480
|
+
)
|
|
481
|
+
return 1
|
|
482
|
+
editor_fallbacks = []
|
|
483
|
+
else:
|
|
484
|
+
editor = default_editor
|
|
485
|
+
editor_fallbacks = list(enabled) if enabled else None
|
|
486
|
+
if len(enabled) > 1 and editor:
|
|
487
|
+
log.info(
|
|
488
|
+
"Workspace editors: %s (opening %s)",
|
|
489
|
+
", ".join(enabled),
|
|
490
|
+
editor,
|
|
491
|
+
)
|
|
492
|
+
return env.open_vscode(
|
|
493
|
+
remote_path=getattr(args, "path", None) or REMOTE_WORKSPACE_ROOT,
|
|
494
|
+
project_dir=os.getcwd(),
|
|
495
|
+
browser=bool(getattr(args, "browser", False)),
|
|
496
|
+
editor=editor,
|
|
497
|
+
editor_fallbacks=editor_fallbacks,
|
|
498
|
+
)
|
|
499
|
+
except Exception as e:
|
|
500
|
+
log.error("Error opening VS Code: %s", e)
|
|
501
|
+
return 1
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _cmd_list(_args: argparse.Namespace) -> int:
|
|
505
|
+
_load_credentials()
|
|
506
|
+
hetzner_token = os.getenv('HETZNER_CLOUD_API_TOKEN')
|
|
507
|
+
if not hetzner_token:
|
|
508
|
+
log.error("Hetzner credentials missing. Run 'rig init' or set HETZNER_CLOUD_API_TOKEN")
|
|
509
|
+
return 1
|
|
510
|
+
|
|
511
|
+
storage_manager = None
|
|
512
|
+
try:
|
|
513
|
+
storage_manager = StorageManager()
|
|
514
|
+
except ValueError:
|
|
515
|
+
pass
|
|
516
|
+
|
|
517
|
+
try:
|
|
518
|
+
env = RemoteDevEnvironment(storage_manager=storage_manager)
|
|
519
|
+
environments = env.list_environments()
|
|
520
|
+
if not environments:
|
|
521
|
+
log.info("No remote environments")
|
|
522
|
+
return 0
|
|
523
|
+
log.info("Remote environments:")
|
|
524
|
+
for env_info in environments:
|
|
525
|
+
log.info(" - %s (%s)", env_info.get("name"), env_info.get("status"))
|
|
526
|
+
return 0
|
|
527
|
+
except Exception as e:
|
|
528
|
+
log.error("Error listing environments: %s", e)
|
|
529
|
+
return 1
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _cmd_destroy(args: argparse.Namespace) -> int:
|
|
533
|
+
_load_credentials()
|
|
534
|
+
hetzner_token = os.getenv('HETZNER_CLOUD_API_TOKEN')
|
|
535
|
+
if not hetzner_token:
|
|
536
|
+
log.error("Hetzner credentials missing. Run 'rig init' or set HETZNER_CLOUD_API_TOKEN")
|
|
537
|
+
return 1
|
|
538
|
+
|
|
539
|
+
names: list = []
|
|
540
|
+
for value in (getattr(args, "names", None) or []):
|
|
541
|
+
if value and value not in names:
|
|
542
|
+
names.append(value)
|
|
543
|
+
# Back-compat: --name can still be passed (once or repeated).
|
|
544
|
+
for value in (getattr(args, "name_flags", None) or []):
|
|
545
|
+
if value and value not in names:
|
|
546
|
+
names.append(value)
|
|
547
|
+
if getattr(args, "name", None):
|
|
548
|
+
if args.name not in names:
|
|
549
|
+
names.append(args.name)
|
|
550
|
+
|
|
551
|
+
if not names:
|
|
552
|
+
last = resolve_env_name()
|
|
553
|
+
if last:
|
|
554
|
+
names.append(last)
|
|
555
|
+
log.info("Using last environment '%s'", last)
|
|
556
|
+
else:
|
|
557
|
+
log.error("At least one environment name is required (no last environment remembered)")
|
|
558
|
+
log.info("Usage: rig destroy [name ...] or rig destroy --name <name>")
|
|
559
|
+
return 1
|
|
560
|
+
|
|
561
|
+
storage_manager = None
|
|
562
|
+
try:
|
|
563
|
+
storage_manager = StorageManager()
|
|
564
|
+
except ValueError:
|
|
565
|
+
log.debug("Storage manager not available; resolving resources via Hetzner API")
|
|
566
|
+
|
|
567
|
+
failures = []
|
|
568
|
+
for name in names:
|
|
569
|
+
try:
|
|
570
|
+
env = RemoteDevEnvironment(name=name, storage_manager=storage_manager)
|
|
571
|
+
env.cleanup(project_dir=os.getcwd())
|
|
572
|
+
clear_last_env(name)
|
|
573
|
+
clear_last_session(name)
|
|
574
|
+
log.info("Environment '%s' destroyed", name)
|
|
575
|
+
except Exception as e:
|
|
576
|
+
log.error("Error destroying environment '%s': %s", name, e)
|
|
577
|
+
failures.append(name)
|
|
578
|
+
|
|
579
|
+
if failures:
|
|
580
|
+
log.error("Failed to destroy: %s", ", ".join(failures))
|
|
581
|
+
return 1
|
|
582
|
+
return 0
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _cache_scope_flags(args: argparse.Namespace) -> tuple:
|
|
586
|
+
"""Return ``(include_system, include_workspace)`` from CLI flags."""
|
|
587
|
+
want_system = bool(getattr(args, "system", False))
|
|
588
|
+
want_workspace = bool(getattr(args, "workspace", False))
|
|
589
|
+
if want_system and not want_workspace:
|
|
590
|
+
return True, False
|
|
591
|
+
if want_workspace and not want_system:
|
|
592
|
+
return False, True
|
|
593
|
+
# Neither or both → both kinds.
|
|
594
|
+
return True, True
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _cache_select_snapshots(
|
|
598
|
+
images,
|
|
599
|
+
args: argparse.Namespace,
|
|
600
|
+
) -> tuple:
|
|
601
|
+
"""
|
|
602
|
+
Build ``(keep, targets, label)`` for cache list/clear.
|
|
603
|
+
|
|
604
|
+
Without ``--all``: targets are unused (current omitted into keep).
|
|
605
|
+
With ``--all``: targets include current; keep is empty.
|
|
606
|
+
``--system`` / ``--workspace`` narrow the kind(s).
|
|
607
|
+
"""
|
|
608
|
+
from cli.snapshots import (
|
|
609
|
+
describe_snapshot,
|
|
610
|
+
find_unused_snapshots,
|
|
611
|
+
package_version,
|
|
612
|
+
system_content_hash,
|
|
613
|
+
system_hash_inputs_from_project,
|
|
614
|
+
workspace_content_hash,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
include_system, include_workspace = _cache_scope_flags(args)
|
|
618
|
+
clear_all = bool(getattr(args, "all", False))
|
|
619
|
+
|
|
620
|
+
scope_bits = []
|
|
621
|
+
if include_system:
|
|
622
|
+
scope_bits.append("system")
|
|
623
|
+
if include_workspace:
|
|
624
|
+
scope_bits.append("workspace")
|
|
625
|
+
scope = "+".join(scope_bits) or "snapshot"
|
|
626
|
+
|
|
627
|
+
if clear_all:
|
|
628
|
+
targets = []
|
|
629
|
+
for image in images:
|
|
630
|
+
if image.get("type") and image.get("type") != "snapshot":
|
|
631
|
+
continue
|
|
632
|
+
meta = describe_snapshot(image)
|
|
633
|
+
kind = meta.get("kind")
|
|
634
|
+
if kind == "system" and include_system:
|
|
635
|
+
meta["reason"] = "all"
|
|
636
|
+
targets.append(meta)
|
|
637
|
+
elif kind == "workspace" and include_workspace:
|
|
638
|
+
# --all --workspace: every workspace snapshot (any project).
|
|
639
|
+
# Narrow to current project name when rig.yml is available.
|
|
640
|
+
meta["reason"] = "all"
|
|
641
|
+
targets.append(meta)
|
|
642
|
+
# Optionally restrict workspace --all to the current project when possible.
|
|
643
|
+
if include_workspace and not include_system:
|
|
644
|
+
project_dir = os.getcwd()
|
|
645
|
+
project = ProjectConfig(project_dir)
|
|
646
|
+
if project.load() and project.config:
|
|
647
|
+
ws_name = project.config.get("name") or os.path.basename(
|
|
648
|
+
project_dir.rstrip(os.sep)
|
|
649
|
+
)
|
|
650
|
+
from cli.snapshots import sanitize_snapshot_token
|
|
651
|
+
|
|
652
|
+
cur = sanitize_snapshot_token(str(ws_name), max_len=40)
|
|
653
|
+
targets = [
|
|
654
|
+
m
|
|
655
|
+
for m in targets
|
|
656
|
+
if m.get("kind") != "workspace" or m.get("workspace") == cur
|
|
657
|
+
]
|
|
658
|
+
label = f"{scope} snapshot(s)"
|
|
659
|
+
return [], targets, label
|
|
660
|
+
|
|
661
|
+
project_dir = os.getcwd()
|
|
662
|
+
project = ProjectConfig(project_dir)
|
|
663
|
+
if not project.load() or not project.config:
|
|
664
|
+
raise RuntimeError(
|
|
665
|
+
f"No rig.yml in {project_dir}; cannot determine current snapshots "
|
|
666
|
+
"(use --all to include every matching snapshot)"
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
workspace_name = project.config.get("name") or os.path.basename(
|
|
670
|
+
project_dir.rstrip(os.sep)
|
|
671
|
+
)
|
|
672
|
+
workspace_hash = workspace_content_hash(project_dir, project=project)
|
|
673
|
+
version = package_version()
|
|
674
|
+
sys_inputs = system_hash_inputs_from_project(project)
|
|
675
|
+
system_hash = system_content_hash(
|
|
676
|
+
project=project,
|
|
677
|
+
var_map=sys_inputs.get("var_map"),
|
|
678
|
+
project_dir=project_dir,
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
split = find_unused_snapshots(
|
|
682
|
+
images,
|
|
683
|
+
current_version=version,
|
|
684
|
+
current_system_hash=system_hash,
|
|
685
|
+
current_workspace_name=str(workspace_name),
|
|
686
|
+
current_workspace_hash=workspace_hash,
|
|
687
|
+
include_system=include_system,
|
|
688
|
+
include_workspace=include_workspace,
|
|
689
|
+
)
|
|
690
|
+
label = f"unused {scope} snapshot(s)"
|
|
691
|
+
return split["keep"], split["unused"], label
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _cmd_cache(args: argparse.Namespace) -> int:
|
|
695
|
+
"""List or clear Hetzner snapshots (unused by default; --all includes current)."""
|
|
696
|
+
action = getattr(args, "action", None) or "clear"
|
|
697
|
+
if action not in ("clear", "list"):
|
|
698
|
+
log.error("Unknown cache action %r (supported: clear, list)", action)
|
|
699
|
+
return 1
|
|
700
|
+
|
|
701
|
+
_load_credentials()
|
|
702
|
+
hetzner_token = os.getenv("HETZNER_CLOUD_API_TOKEN")
|
|
703
|
+
if not hetzner_token:
|
|
704
|
+
log.error(
|
|
705
|
+
"Hetzner credentials missing. Run 'rig init' or set HETZNER_CLOUD_API_TOKEN"
|
|
706
|
+
)
|
|
707
|
+
return 1
|
|
708
|
+
|
|
709
|
+
assume_yes = bool(getattr(args, "yes", False))
|
|
710
|
+
|
|
711
|
+
try:
|
|
712
|
+
env = RemoteDevEnvironment(storage_manager=None)
|
|
713
|
+
images = env.manager.list_snapshots()
|
|
714
|
+
except Exception as exc:
|
|
715
|
+
log.error("Failed to list snapshots: %s", exc)
|
|
716
|
+
return 1
|
|
717
|
+
|
|
718
|
+
try:
|
|
719
|
+
keep, targets, label = _cache_select_snapshots(images, args)
|
|
720
|
+
except RuntimeError as exc:
|
|
721
|
+
log.error("%s", exc)
|
|
722
|
+
return 1
|
|
723
|
+
|
|
724
|
+
if keep:
|
|
725
|
+
log.info("Current (kept) snapshot(s):")
|
|
726
|
+
for meta in keep:
|
|
727
|
+
log.info(
|
|
728
|
+
" %s (id=%s)",
|
|
729
|
+
meta.get("description"),
|
|
730
|
+
meta.get("id"),
|
|
731
|
+
)
|
|
732
|
+
|
|
733
|
+
if not targets and not keep:
|
|
734
|
+
log.info("No matching snapshots")
|
|
735
|
+
return 0
|
|
736
|
+
|
|
737
|
+
if targets:
|
|
738
|
+
heading = "All" if getattr(args, "all", False) else "Unused"
|
|
739
|
+
log.info("%s (%s):", heading, len(targets))
|
|
740
|
+
for meta in targets:
|
|
741
|
+
reason = meta.get("reason")
|
|
742
|
+
if reason and reason != "all":
|
|
743
|
+
log.info(
|
|
744
|
+
" %s (id=%s) — %s",
|
|
745
|
+
meta.get("description"),
|
|
746
|
+
meta.get("id"),
|
|
747
|
+
reason,
|
|
748
|
+
)
|
|
749
|
+
else:
|
|
750
|
+
log.info(
|
|
751
|
+
" %s (id=%s)",
|
|
752
|
+
meta.get("description"),
|
|
753
|
+
meta.get("id"),
|
|
754
|
+
)
|
|
755
|
+
elif action == "list":
|
|
756
|
+
log.info("No unused snapshots")
|
|
757
|
+
return 0
|
|
758
|
+
|
|
759
|
+
if action == "list":
|
|
760
|
+
return 0
|
|
761
|
+
|
|
762
|
+
if not targets:
|
|
763
|
+
log.info("No %s to clear", label)
|
|
764
|
+
return 0
|
|
765
|
+
|
|
766
|
+
if not assume_yes:
|
|
767
|
+
try:
|
|
768
|
+
answer = input(
|
|
769
|
+
f"Delete {len(targets)} {label}? [y/N]: "
|
|
770
|
+
).strip().lower()
|
|
771
|
+
except EOFError:
|
|
772
|
+
answer = ""
|
|
773
|
+
if answer not in ("y", "yes"):
|
|
774
|
+
log.info("Cancelled")
|
|
775
|
+
return 0
|
|
776
|
+
|
|
777
|
+
failures = []
|
|
778
|
+
deleted = 0
|
|
779
|
+
for meta in targets:
|
|
780
|
+
image_id = meta.get("id")
|
|
781
|
+
description = meta.get("description") or image_id
|
|
782
|
+
if image_id is None:
|
|
783
|
+
continue
|
|
784
|
+
log.info("Deleting snapshot %s (id=%s)", description, image_id)
|
|
785
|
+
if not env.manager.delete_image(str(image_id)):
|
|
786
|
+
failures.append(str(description))
|
|
787
|
+
else:
|
|
788
|
+
deleted += 1
|
|
789
|
+
|
|
790
|
+
if failures:
|
|
791
|
+
log.error("Failed to delete: %s", ", ".join(failures))
|
|
792
|
+
return 1
|
|
793
|
+
log.info("Cleared %s %s", deleted, label)
|
|
794
|
+
return 0
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def _cmd_encrypt(args: argparse.Namespace) -> int:
|
|
798
|
+
try:
|
|
799
|
+
personal = bool(getattr(args, "personal", False))
|
|
800
|
+
ciphertext = interactive_encrypt(
|
|
801
|
+
stdin_name=args.stdin_name,
|
|
802
|
+
personal=personal if personal else None,
|
|
803
|
+
)
|
|
804
|
+
except (ValueError, RuntimeError) as exc:
|
|
805
|
+
log.error("%s", exc)
|
|
806
|
+
return 1
|
|
807
|
+
if ciphertext:
|
|
808
|
+
print(ciphertext)
|
|
809
|
+
return 0
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _cmd_decrypt(args: argparse.Namespace) -> int:
|
|
813
|
+
try:
|
|
814
|
+
plaintext = interactive_decrypt(name=args.name)
|
|
815
|
+
except (ValueError, RuntimeError) as exc:
|
|
816
|
+
log.error("%s", exc)
|
|
817
|
+
return 1
|
|
818
|
+
print(plaintext, end="" if plaintext.endswith("\n") else "\n")
|
|
819
|
+
return 0
|
|
820
|
+
|
|
821
|
+
|
|
822
|
+
def _cmd_vars_list(args: argparse.Namespace) -> int:
|
|
823
|
+
from cli.encrypt import list_all_vault_vars
|
|
824
|
+
|
|
825
|
+
rows = list_all_vault_vars()
|
|
826
|
+
if not rows:
|
|
827
|
+
print("No vaulted variables found")
|
|
828
|
+
return 0
|
|
829
|
+
for name, scope in rows:
|
|
830
|
+
print(f"{name}\t{scope}")
|
|
831
|
+
return 0
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _cmd_vars_export_shared_key(args: argparse.Namespace) -> int:
|
|
835
|
+
from cli.encrypt import export_shared_data_key
|
|
836
|
+
|
|
837
|
+
try:
|
|
838
|
+
key = export_shared_data_key()
|
|
839
|
+
except (ValueError, RuntimeError) as exc:
|
|
840
|
+
log.error("%s", exc)
|
|
841
|
+
return 1
|
|
842
|
+
print(key)
|
|
843
|
+
return 0
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def main(argv=None):
|
|
847
|
+
"""Main CLI entry point"""
|
|
848
|
+
parser = argparse.ArgumentParser(
|
|
849
|
+
description="RIG - Remote Development Environment CLI"
|
|
850
|
+
)
|
|
851
|
+
parser.add_argument("--version", action="version", version=f"rig {__version__}")
|
|
852
|
+
parser.add_argument(
|
|
853
|
+
"-v",
|
|
854
|
+
"--verbose",
|
|
855
|
+
action="count",
|
|
856
|
+
default=0,
|
|
857
|
+
help=(
|
|
858
|
+
"More detail. -v shows each playbook task and debug logs; "
|
|
859
|
+
"-vv, -vvv, and -vvvv pass that verbosity to ansible-playbook"
|
|
860
|
+
),
|
|
861
|
+
)
|
|
862
|
+
parser.add_argument(
|
|
863
|
+
"-q",
|
|
864
|
+
"--quiet",
|
|
865
|
+
action="store_true",
|
|
866
|
+
help="Show warnings and errors only",
|
|
867
|
+
)
|
|
868
|
+
|
|
869
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
870
|
+
|
|
871
|
+
subparsers.add_parser(
|
|
872
|
+
"init",
|
|
873
|
+
help="Interactively initialize a multi-repo environment from nearby git projects",
|
|
874
|
+
)
|
|
875
|
+
|
|
876
|
+
create_parser = subparsers.add_parser(
|
|
877
|
+
"create",
|
|
878
|
+
help="Create a remote environment, run Ansible playbooks, attach to agent",
|
|
879
|
+
)
|
|
880
|
+
create_parser.add_argument("--name", help="Environment name")
|
|
881
|
+
create_parser.add_argument(
|
|
882
|
+
"--provider",
|
|
883
|
+
default=None,
|
|
884
|
+
help=(
|
|
885
|
+
"Cloud provider (default: from rig.yml host.provider, else hetzner). "
|
|
886
|
+
"hetzner is available; aws and gcp are recognized but not yet"
|
|
887
|
+
),
|
|
888
|
+
)
|
|
889
|
+
create_parser.add_argument(
|
|
890
|
+
"--type",
|
|
891
|
+
default=None,
|
|
892
|
+
help="Hetzner server type (default: from rig.yml host.server_type, else cax11)",
|
|
893
|
+
)
|
|
894
|
+
create_parser.add_argument(
|
|
895
|
+
"--availability-zone",
|
|
896
|
+
"--region",
|
|
897
|
+
dest="availability_zone",
|
|
898
|
+
default=None,
|
|
899
|
+
help="Hetzner location / AZ (default: from rig.yml host.availability_zone, else hel1)",
|
|
900
|
+
)
|
|
901
|
+
create_parser.add_argument(
|
|
902
|
+
"--image",
|
|
903
|
+
default=None,
|
|
904
|
+
help="Boot image (default: from rig.yml host.image, else ubuntu-24.04)",
|
|
905
|
+
)
|
|
906
|
+
create_parser.add_argument(
|
|
907
|
+
"--no-attach",
|
|
908
|
+
action="store_true",
|
|
909
|
+
help="Skip attaching to the coding agent TTY after provisioning",
|
|
910
|
+
)
|
|
911
|
+
create_parser.add_argument(
|
|
912
|
+
"--force-playbooks",
|
|
913
|
+
action="store_true",
|
|
914
|
+
help=(
|
|
915
|
+
"Run provision/configure/materialize even when booting from a "
|
|
916
|
+
"matching snapshot (authorize always runs)"
|
|
917
|
+
),
|
|
918
|
+
)
|
|
919
|
+
create_parser.add_argument(
|
|
920
|
+
"--force-system",
|
|
921
|
+
action="store_true",
|
|
922
|
+
help="Force provision+configure even when booting from a system snapshot",
|
|
923
|
+
)
|
|
924
|
+
create_parser.add_argument(
|
|
925
|
+
"--force-workspace",
|
|
926
|
+
action="store_true",
|
|
927
|
+
help="Force materialize even when it would otherwise be skipped",
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
apply_parser = subparsers.add_parser(
|
|
931
|
+
"apply",
|
|
932
|
+
help=(
|
|
933
|
+
"Re-run lifecycle playbooks on an existing env "
|
|
934
|
+
"(default: authorize then materialize)"
|
|
935
|
+
),
|
|
936
|
+
)
|
|
937
|
+
apply_parser.add_argument(
|
|
938
|
+
"name",
|
|
939
|
+
nargs="?",
|
|
940
|
+
help="Environment name (defaults to last handled env in /tmp/.rig/last-env)",
|
|
941
|
+
)
|
|
942
|
+
apply_parser.add_argument(
|
|
943
|
+
"--name",
|
|
944
|
+
dest="name_flag",
|
|
945
|
+
help="Environment name (kept for compatibility)",
|
|
946
|
+
)
|
|
947
|
+
apply_parser.add_argument(
|
|
948
|
+
"--provision",
|
|
949
|
+
action="store_true",
|
|
950
|
+
help="Run provision → configure → authorize",
|
|
951
|
+
)
|
|
952
|
+
apply_parser.add_argument(
|
|
953
|
+
"--configure",
|
|
954
|
+
action="store_true",
|
|
955
|
+
help="Run configure.yml",
|
|
956
|
+
)
|
|
957
|
+
apply_parser.add_argument(
|
|
958
|
+
"--authorize",
|
|
959
|
+
action="store_true",
|
|
960
|
+
help="Run authorize.yml only (re-apply credentials)",
|
|
961
|
+
)
|
|
962
|
+
apply_parser.add_argument(
|
|
963
|
+
"--materialize",
|
|
964
|
+
action="store_true",
|
|
965
|
+
help="Run authorize then materialize (or materialize with other flags)",
|
|
966
|
+
)
|
|
967
|
+
# Legacy aliases
|
|
968
|
+
apply_parser.add_argument(
|
|
969
|
+
"--system",
|
|
970
|
+
action="store_true",
|
|
971
|
+
help=argparse.SUPPRESS,
|
|
972
|
+
)
|
|
973
|
+
apply_parser.add_argument(
|
|
974
|
+
"--authnz",
|
|
975
|
+
action="store_true",
|
|
976
|
+
help=argparse.SUPPRESS,
|
|
977
|
+
)
|
|
978
|
+
|
|
979
|
+
attach_parser = subparsers.add_parser(
|
|
980
|
+
"attach",
|
|
981
|
+
help=(
|
|
982
|
+
"Attach to a remote coding-agent TTY (uses last env if name omitted; "
|
|
983
|
+
"reopens the last remembered agent session when no --resume/--session/-c)"
|
|
984
|
+
),
|
|
985
|
+
)
|
|
986
|
+
attach_parser.add_argument(
|
|
987
|
+
"name",
|
|
988
|
+
nargs="?",
|
|
989
|
+
help="Environment name (defaults to last handled env in /tmp/.rig/last-env)",
|
|
990
|
+
)
|
|
991
|
+
attach_parser.add_argument(
|
|
992
|
+
"--name",
|
|
993
|
+
dest="name_flag",
|
|
994
|
+
help="Environment name (kept for compatibility)",
|
|
995
|
+
)
|
|
996
|
+
attach_parser.add_argument(
|
|
997
|
+
"-c",
|
|
998
|
+
"--continue",
|
|
999
|
+
dest="agent_continue",
|
|
1000
|
+
action="store_true",
|
|
1001
|
+
help="Continue the last coding-agent session (forwards --continue)",
|
|
1002
|
+
)
|
|
1003
|
+
attach_parser.add_argument(
|
|
1004
|
+
"-s",
|
|
1005
|
+
"--session",
|
|
1006
|
+
dest="agent_session",
|
|
1007
|
+
metavar="ID",
|
|
1008
|
+
help="Continue a specific OpenCode session id (forwards --session)",
|
|
1009
|
+
)
|
|
1010
|
+
attach_parser.add_argument(
|
|
1011
|
+
"--resume",
|
|
1012
|
+
dest="agent_resume",
|
|
1013
|
+
nargs="?",
|
|
1014
|
+
const="",
|
|
1015
|
+
metavar="ID",
|
|
1016
|
+
help="Resume a Claude Code / Cursor session (forwards --resume [ID])",
|
|
1017
|
+
)
|
|
1018
|
+
attach_parser.add_argument(
|
|
1019
|
+
"--agent",
|
|
1020
|
+
dest="agent",
|
|
1021
|
+
help=(
|
|
1022
|
+
"Coding agent to open when several are configured "
|
|
1023
|
+
"(opencode, claude, or cursor). Defaults to agent.default"
|
|
1024
|
+
),
|
|
1025
|
+
)
|
|
1026
|
+
attach_parser.add_argument(
|
|
1027
|
+
"agent_args",
|
|
1028
|
+
nargs=argparse.REMAINDER,
|
|
1029
|
+
help="Extra args forwarded to opencode/claude/agent (put -- before flags)",
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
ssh_parser = subparsers.add_parser(
|
|
1033
|
+
"ssh",
|
|
1034
|
+
help="Open an SSH shell on the remote env (uses last env if name omitted)",
|
|
1035
|
+
)
|
|
1036
|
+
ssh_parser.add_argument(
|
|
1037
|
+
"--name",
|
|
1038
|
+
dest="name_flag",
|
|
1039
|
+
help="Environment name (kept for compatibility)",
|
|
1040
|
+
)
|
|
1041
|
+
ssh_parser.add_argument(
|
|
1042
|
+
"ssh_args",
|
|
1043
|
+
nargs=argparse.REMAINDER,
|
|
1044
|
+
help="[name] [-- command]. Default: login shell in /workspace",
|
|
1045
|
+
)
|
|
1046
|
+
|
|
1047
|
+
dev_parser = subparsers.add_parser(
|
|
1048
|
+
"dev",
|
|
1049
|
+
help="Open VS Code / Cursor on the remote env (uses last env if name omitted)",
|
|
1050
|
+
description=(
|
|
1051
|
+
"Open VS Code or Cursor against the remote environment via Remote-SSH "
|
|
1052
|
+
"(default). Pass --browser to open code-server in a browser instead."
|
|
1053
|
+
),
|
|
1054
|
+
)
|
|
1055
|
+
dev_parser.add_argument(
|
|
1056
|
+
"name",
|
|
1057
|
+
nargs="?",
|
|
1058
|
+
help="Environment name (defaults to last handled env in /tmp/.rig/last-env)",
|
|
1059
|
+
)
|
|
1060
|
+
dev_parser.add_argument(
|
|
1061
|
+
"--name",
|
|
1062
|
+
dest="name_flag",
|
|
1063
|
+
help="Environment name (kept for compatibility)",
|
|
1064
|
+
)
|
|
1065
|
+
dev_parser.add_argument(
|
|
1066
|
+
"--path",
|
|
1067
|
+
default=REMOTE_WORKSPACE_ROOT,
|
|
1068
|
+
help=f"Remote folder to open (default: {REMOTE_WORKSPACE_ROOT})",
|
|
1069
|
+
)
|
|
1070
|
+
dev_parser.add_argument(
|
|
1071
|
+
"--browser",
|
|
1072
|
+
action="store_true",
|
|
1073
|
+
help="Open code-server in the browser instead of local VS Code/Cursor",
|
|
1074
|
+
)
|
|
1075
|
+
dev_parser.add_argument(
|
|
1076
|
+
"--editor",
|
|
1077
|
+
help=(
|
|
1078
|
+
"Local editor to open when several are configured "
|
|
1079
|
+
"(code, code-insiders, cursor, or codium). "
|
|
1080
|
+
"Defaults to editor.default in rig.yml"
|
|
1081
|
+
),
|
|
1082
|
+
)
|
|
1083
|
+
|
|
1084
|
+
subparsers.add_parser("list", help="List remote environments")
|
|
1085
|
+
|
|
1086
|
+
cache_parser = subparsers.add_parser(
|
|
1087
|
+
"cache",
|
|
1088
|
+
help="Manage Hetzner snapshots created after the configure (build) stage",
|
|
1089
|
+
description=(
|
|
1090
|
+
"List or clear VM snapshots after showing them. "
|
|
1091
|
+
"Default clear: unused only (older version or different hash); "
|
|
1092
|
+
"current snapshots are kept. "
|
|
1093
|
+
"--all includes current. "
|
|
1094
|
+
"--system / --workspace narrow the kind "
|
|
1095
|
+
"(combine with or without --all)."
|
|
1096
|
+
),
|
|
1097
|
+
)
|
|
1098
|
+
cache_parser.add_argument(
|
|
1099
|
+
"--system",
|
|
1100
|
+
action="store_true",
|
|
1101
|
+
help="Only system snapshots (rig-{version}-{hash})",
|
|
1102
|
+
)
|
|
1103
|
+
cache_parser.add_argument(
|
|
1104
|
+
"--workspace",
|
|
1105
|
+
action="store_true",
|
|
1106
|
+
help="Only workspace snapshots (workspace-{name}-{hash}) for this project",
|
|
1107
|
+
)
|
|
1108
|
+
cache_parser.add_argument(
|
|
1109
|
+
"--all",
|
|
1110
|
+
action="store_true",
|
|
1111
|
+
help="Include current snapshots (default omits them / unused only)",
|
|
1112
|
+
)
|
|
1113
|
+
cache_parser.add_argument(
|
|
1114
|
+
"-y",
|
|
1115
|
+
"--yes",
|
|
1116
|
+
action="store_true",
|
|
1117
|
+
help="Delete without confirmation (clear only)",
|
|
1118
|
+
)
|
|
1119
|
+
cache_parser.add_argument(
|
|
1120
|
+
"action",
|
|
1121
|
+
nargs="?",
|
|
1122
|
+
default="clear",
|
|
1123
|
+
choices=["clear", "list"],
|
|
1124
|
+
help="list = show only; clear = list, confirm, delete (default: clear)",
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
destroy_parser = subparsers.add_parser(
|
|
1128
|
+
"destroy",
|
|
1129
|
+
help=(
|
|
1130
|
+
"Destroy remote environments (runs finalize then destroy playbooks first; "
|
|
1131
|
+
"uses last env if name omitted)"
|
|
1132
|
+
),
|
|
1133
|
+
)
|
|
1134
|
+
destroy_parser.add_argument(
|
|
1135
|
+
"names",
|
|
1136
|
+
nargs="*",
|
|
1137
|
+
metavar="name",
|
|
1138
|
+
help="Environment name(s) to destroy (defaults to last handled env in /tmp/.rig/last-env)",
|
|
1139
|
+
)
|
|
1140
|
+
destroy_parser.add_argument(
|
|
1141
|
+
"--name",
|
|
1142
|
+
action="append",
|
|
1143
|
+
dest="name_flags",
|
|
1144
|
+
help="Environment name (repeatable; kept for compatibility)",
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
vars_parser = subparsers.add_parser(
|
|
1148
|
+
"vars",
|
|
1149
|
+
help="Manage vaulted values in .rig/vars.yml and vars.{identity}.yml",
|
|
1150
|
+
)
|
|
1151
|
+
vars_subparsers = vars_parser.add_subparsers(
|
|
1152
|
+
dest="vars_command",
|
|
1153
|
+
help="Vars actions",
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
encrypt_parser = vars_subparsers.add_parser(
|
|
1157
|
+
"encrypt",
|
|
1158
|
+
help=(
|
|
1159
|
+
"Encrypt a value (shared vars.yml, or identity file with --personal / PAT names)"
|
|
1160
|
+
),
|
|
1161
|
+
)
|
|
1162
|
+
encrypt_parser.add_argument(
|
|
1163
|
+
"--stdin-name",
|
|
1164
|
+
help="Variable name; read multiline value from stdin until Ctrl+D",
|
|
1165
|
+
)
|
|
1166
|
+
encrypt_parser.add_argument(
|
|
1167
|
+
"--personal",
|
|
1168
|
+
action="store_true",
|
|
1169
|
+
help="Store in .rig/vars.{identity}.yml (personal vault)",
|
|
1170
|
+
)
|
|
1171
|
+
|
|
1172
|
+
decrypt_parser = vars_subparsers.add_parser(
|
|
1173
|
+
"decrypt",
|
|
1174
|
+
help="Decrypt a variable from shared or personal vars (after master-key unlock)",
|
|
1175
|
+
)
|
|
1176
|
+
decrypt_parser.add_argument(
|
|
1177
|
+
"name",
|
|
1178
|
+
nargs="?",
|
|
1179
|
+
help="Variable name to decrypt (prompted if omitted)",
|
|
1180
|
+
)
|
|
1181
|
+
|
|
1182
|
+
vars_subparsers.add_parser(
|
|
1183
|
+
"list",
|
|
1184
|
+
help="List vaulted variable names and whether they are shared or identity",
|
|
1185
|
+
)
|
|
1186
|
+
vars_subparsers.add_parser(
|
|
1187
|
+
"export-shared-key",
|
|
1188
|
+
help="Print the shared data key (for teammate onboarding; requires unlock)",
|
|
1189
|
+
)
|
|
1190
|
+
|
|
1191
|
+
args = parser.parse_args(argv)
|
|
1192
|
+
verbosity = int(getattr(args, "verbose", 0) or 0)
|
|
1193
|
+
log.configure_logging(
|
|
1194
|
+
verbose=verbosity > 0,
|
|
1195
|
+
quiet=bool(getattr(args, "quiet", False)),
|
|
1196
|
+
verbosity=verbosity,
|
|
1197
|
+
)
|
|
1198
|
+
|
|
1199
|
+
if args.command == "init":
|
|
1200
|
+
return 0 if interactive_env_init() else 1
|
|
1201
|
+
if args.command == "create":
|
|
1202
|
+
return _cmd_create(args)
|
|
1203
|
+
if args.command == "apply":
|
|
1204
|
+
return _cmd_apply(args)
|
|
1205
|
+
if args.command == "attach":
|
|
1206
|
+
return _cmd_attach(args)
|
|
1207
|
+
if args.command == "ssh":
|
|
1208
|
+
return _cmd_ssh(args)
|
|
1209
|
+
if args.command == "dev":
|
|
1210
|
+
return _cmd_dev(args)
|
|
1211
|
+
if args.command == "list":
|
|
1212
|
+
return _cmd_list(args)
|
|
1213
|
+
if args.command == "cache":
|
|
1214
|
+
return _cmd_cache(args)
|
|
1215
|
+
if args.command == "destroy":
|
|
1216
|
+
return _cmd_destroy(args)
|
|
1217
|
+
if args.command == "vars":
|
|
1218
|
+
if args.vars_command == "encrypt":
|
|
1219
|
+
return _cmd_encrypt(args)
|
|
1220
|
+
if args.vars_command == "decrypt":
|
|
1221
|
+
return _cmd_decrypt(args)
|
|
1222
|
+
if args.vars_command == "list":
|
|
1223
|
+
return _cmd_vars_list(args)
|
|
1224
|
+
if args.vars_command == "export-shared-key":
|
|
1225
|
+
return _cmd_vars_export_shared_key(args)
|
|
1226
|
+
vars_parser.print_help()
|
|
1227
|
+
return 1
|
|
1228
|
+
|
|
1229
|
+
parser.print_help()
|
|
1230
|
+
return 1
|
|
1231
|
+
|
|
1232
|
+
|
|
1233
|
+
if __name__ == "__main__":
|
|
1234
|
+
raise SystemExit(main())
|