steve-cli 0.3.1__tar.gz → 0.3.5__tar.gz
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.
- {steve_cli-0.3.1 → steve_cli-0.3.5}/PKG-INFO +1 -1
- {steve_cli-0.3.1 → steve_cli-0.3.5}/pyproject.toml +1 -1
- steve_cli-0.3.5/steve_cli/cli.py +586 -0
- {steve_cli-0.3.1 → steve_cli-0.3.5}/steve_cli/storage.py +8 -0
- {steve_cli-0.3.1 → steve_cli-0.3.5}/steve_cli.egg-info/PKG-INFO +1 -1
- steve_cli-0.3.1/steve_cli/cli.py +0 -237
- {steve_cli-0.3.1 → steve_cli-0.3.5}/README.md +0 -0
- {steve_cli-0.3.1 → steve_cli-0.3.5}/setup.cfg +0 -0
- {steve_cli-0.3.1 → steve_cli-0.3.5}/steve_cli/__init__.py +0 -0
- {steve_cli-0.3.1 → steve_cli-0.3.5}/steve_cli.egg-info/SOURCES.txt +0 -0
- {steve_cli-0.3.1 → steve_cli-0.3.5}/steve_cli.egg-info/dependency_links.txt +0 -0
- {steve_cli-0.3.1 → steve_cli-0.3.5}/steve_cli.egg-info/entry_points.txt +0 -0
- {steve_cli-0.3.1 → steve_cli-0.3.5}/steve_cli.egg-info/requires.txt +0 -0
- {steve_cli-0.3.1 → steve_cli-0.3.5}/steve_cli.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,586 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Steve CLI - A simple tool to run jobs from jobs.yaml with proper environment setup.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
steve jobs ls List all available jobs
|
|
7
|
+
steve jobs run <job-name> Run a job with its environment variables
|
|
8
|
+
steve setup all Decrypt SOPS-encoded .env files
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import signal
|
|
13
|
+
import socket
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Dict, List, Any, Optional
|
|
19
|
+
|
|
20
|
+
import click
|
|
21
|
+
import questionary
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class JobsConfig:
|
|
26
|
+
def __init__(self, jobs_file: Optional[Path] = None):
|
|
27
|
+
self.jobs_file = jobs_file or Path.cwd() / "jobs.yaml"
|
|
28
|
+
self.jobs: List[Dict[str, Any]] = []
|
|
29
|
+
self._load_jobs()
|
|
30
|
+
|
|
31
|
+
def _load_jobs(self) -> None:
|
|
32
|
+
try:
|
|
33
|
+
if not self.jobs_file.exists():
|
|
34
|
+
click.echo(f"❌ Error: jobs.yaml not found at {self.jobs_file}", err=True)
|
|
35
|
+
click.echo("Make sure you're in a directory with a jobs.yaml file", err=True)
|
|
36
|
+
sys.exit(1)
|
|
37
|
+
|
|
38
|
+
with open(self.jobs_file, 'r') as f:
|
|
39
|
+
data = yaml.safe_load(f)
|
|
40
|
+
|
|
41
|
+
if not data or 'jobs' not in data:
|
|
42
|
+
click.echo("❌ Error: Invalid jobs.yaml format. Expected 'jobs' key at root", err=True)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
|
|
45
|
+
self.jobs = data['jobs']
|
|
46
|
+
|
|
47
|
+
except yaml.YAMLError as e:
|
|
48
|
+
click.echo(f"❌ Error parsing jobs.yaml: {e}", err=True)
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
except Exception as e:
|
|
51
|
+
click.echo(f"❌ Error reading jobs.yaml: {e}", err=True)
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
|
|
54
|
+
def get_job(self, job_name: str) -> Optional[Dict[str, Any]]:
|
|
55
|
+
for job in self.jobs:
|
|
56
|
+
if job.get('name') == job_name:
|
|
57
|
+
return job
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
def list_jobs(self) -> List[str]:
|
|
61
|
+
return [job.get('name', 'unnamed') for job in self.jobs]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run_job_command(job: Dict[str, Any]) -> int:
|
|
65
|
+
command = job.get('command', [])
|
|
66
|
+
if not command:
|
|
67
|
+
click.echo(f"❌ Error: No command specified for job '{job.get('name')}'", err=True)
|
|
68
|
+
return 1
|
|
69
|
+
|
|
70
|
+
env = os.environ.copy()
|
|
71
|
+
job_env = job.get('env', {})
|
|
72
|
+
for key, value in job_env.items():
|
|
73
|
+
env[key] = str(value)
|
|
74
|
+
|
|
75
|
+
job_name = job.get('name', 'unnamed')
|
|
76
|
+
click.echo(f"🚀 Running job: {click.style(job_name, fg='blue', bold=True)}")
|
|
77
|
+
click.echo(f"📝 Command: {click.style(' '.join(command), fg='cyan')}")
|
|
78
|
+
|
|
79
|
+
if job_env:
|
|
80
|
+
click.echo("🌍 Environment variables:")
|
|
81
|
+
for key, value in job_env.items():
|
|
82
|
+
click.echo(f" {click.style(key, fg='green')}={click.style(str(value), fg='yellow')}")
|
|
83
|
+
|
|
84
|
+
click.echo()
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
result = subprocess.run(command, env=env, cwd=Path.cwd())
|
|
88
|
+
return result.returncode
|
|
89
|
+
except FileNotFoundError:
|
|
90
|
+
click.echo(f"❌ Error: Command not found: {command[0]}", err=True)
|
|
91
|
+
return 127
|
|
92
|
+
except Exception as e:
|
|
93
|
+
click.echo(f"❌ Error running command: {e}", err=True)
|
|
94
|
+
return 1
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@click.group()
|
|
98
|
+
def main():
|
|
99
|
+
"""Steve CLI - Run jobs and manage environment setup."""
|
|
100
|
+
pass
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@main.group(invoke_without_command=True)
|
|
104
|
+
@click.option('--jobs-file', '-f', type=click.Path(exists=True, path_type=Path),
|
|
105
|
+
help='Path to jobs.yaml file (default: ./jobs.yaml)')
|
|
106
|
+
@click.pass_context
|
|
107
|
+
def jobs(ctx: click.Context, jobs_file: Optional[Path]):
|
|
108
|
+
"""Manage and run jobs from jobs.yaml."""
|
|
109
|
+
if ctx.invoked_subcommand is not None:
|
|
110
|
+
return
|
|
111
|
+
config = JobsConfig(jobs_file)
|
|
112
|
+
job_names = config.list_jobs()
|
|
113
|
+
if not job_names:
|
|
114
|
+
click.echo("❌ No jobs found in jobs.yaml")
|
|
115
|
+
return
|
|
116
|
+
choice = questionary.select(
|
|
117
|
+
"Select a job to run:",
|
|
118
|
+
choices=job_names,
|
|
119
|
+
).ask()
|
|
120
|
+
if choice is None:
|
|
121
|
+
sys.exit(0)
|
|
122
|
+
job = config.get_job(choice)
|
|
123
|
+
sys.exit(run_job_command(job))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@jobs.command('ls')
|
|
127
|
+
@click.option('--jobs-file', '-f', type=click.Path(exists=True, path_type=Path),
|
|
128
|
+
help='Path to jobs.yaml file (default: ./jobs.yaml)')
|
|
129
|
+
def jobs_list(jobs_file: Optional[Path]):
|
|
130
|
+
"""List all available jobs."""
|
|
131
|
+
config = JobsConfig(jobs_file)
|
|
132
|
+
job_names = config.list_jobs()
|
|
133
|
+
|
|
134
|
+
if not job_names:
|
|
135
|
+
click.echo("❌ No jobs found in jobs.yaml")
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
click.echo(f"📋 Available jobs in {click.style(str(config.jobs_file), fg='cyan')}:")
|
|
139
|
+
click.echo()
|
|
140
|
+
|
|
141
|
+
for job_data in config.jobs:
|
|
142
|
+
name = job_data.get('name', 'unnamed')
|
|
143
|
+
command = job_data.get('command', [])
|
|
144
|
+
cron = job_data.get('cron')
|
|
145
|
+
depends_on = job_data.get('dependsOn', [])
|
|
146
|
+
env_vars = job_data.get('env', {})
|
|
147
|
+
|
|
148
|
+
click.echo(f" {click.style(name, fg='blue', bold=True)}")
|
|
149
|
+
if command:
|
|
150
|
+
click.echo(f" Command: {click.style(' '.join(command), fg='cyan')}")
|
|
151
|
+
if cron:
|
|
152
|
+
click.echo(f" Schedule: {click.style(cron, fg='yellow')}")
|
|
153
|
+
if depends_on:
|
|
154
|
+
click.echo(f" Depends on: {click.style(', '.join(depends_on), fg='magenta')}")
|
|
155
|
+
if env_vars:
|
|
156
|
+
click.echo(f" Environment: {click.style(f'{len(env_vars)} variables', fg='green')}")
|
|
157
|
+
for key, value in env_vars.items():
|
|
158
|
+
click.echo(f" {key}={value}")
|
|
159
|
+
click.echo()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@jobs.command('run')
|
|
163
|
+
@click.argument('job_name')
|
|
164
|
+
@click.option('--jobs-file', '-f', type=click.Path(exists=True, path_type=Path),
|
|
165
|
+
help='Path to jobs.yaml file (default: ./jobs.yaml)')
|
|
166
|
+
def jobs_run(job_name: str, jobs_file: Optional[Path]):
|
|
167
|
+
"""Run a job by name."""
|
|
168
|
+
config = JobsConfig(jobs_file)
|
|
169
|
+
job = config.get_job(job_name)
|
|
170
|
+
if not job:
|
|
171
|
+
available = config.list_jobs()
|
|
172
|
+
click.echo(f"❌ Error: Job '{job_name}' not found", err=True)
|
|
173
|
+
if available:
|
|
174
|
+
click.echo(f"Available jobs: {', '.join(available)}", err=True)
|
|
175
|
+
sys.exit(1)
|
|
176
|
+
sys.exit(run_job_command(job))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
APPS_DIR = Path.home() / ".steve" / "apps"
|
|
180
|
+
AUTH_PROXY_INTERNAL_URL = "http://auth-proxy-service"
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class AppsConfig:
|
|
184
|
+
def __init__(self, apps_file: Optional[Path] = None):
|
|
185
|
+
self.apps_file = apps_file or Path.cwd() / "apps.yaml"
|
|
186
|
+
self.apps: List[Dict[str, Any]] = []
|
|
187
|
+
self._load_apps()
|
|
188
|
+
|
|
189
|
+
def _load_apps(self) -> None:
|
|
190
|
+
try:
|
|
191
|
+
if not self.apps_file.exists():
|
|
192
|
+
click.echo(f"❌ Error: apps.yaml not found at {self.apps_file}", err=True)
|
|
193
|
+
click.echo("Make sure you're in a directory with an apps.yaml file", err=True)
|
|
194
|
+
sys.exit(1)
|
|
195
|
+
|
|
196
|
+
with open(self.apps_file, 'r') as f:
|
|
197
|
+
data = yaml.safe_load(f)
|
|
198
|
+
|
|
199
|
+
if not data or 'apps' not in data:
|
|
200
|
+
click.echo("❌ Error: Invalid apps.yaml format. Expected 'apps' key at root", err=True)
|
|
201
|
+
sys.exit(1)
|
|
202
|
+
|
|
203
|
+
self.apps = data['apps']
|
|
204
|
+
|
|
205
|
+
except yaml.YAMLError as e:
|
|
206
|
+
click.echo(f"❌ Error parsing apps.yaml: {e}", err=True)
|
|
207
|
+
sys.exit(1)
|
|
208
|
+
except Exception as e:
|
|
209
|
+
click.echo(f"❌ Error reading apps.yaml: {e}", err=True)
|
|
210
|
+
sys.exit(1)
|
|
211
|
+
|
|
212
|
+
def get_app(self, app_name: str) -> Optional[Dict[str, Any]]:
|
|
213
|
+
for app in self.apps:
|
|
214
|
+
if app.get('name') == app_name:
|
|
215
|
+
return app
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
def list_apps(self) -> List[str]:
|
|
219
|
+
return [app.get('name', 'unnamed') for app in self.apps]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _get_session_name() -> Optional[str]:
|
|
223
|
+
return os.environ.get('SESSION_NAME') or socket.gethostname()
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _pid_file(app_name: str) -> Path:
|
|
227
|
+
return APPS_DIR / f"{app_name}.pid"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _get_running_pid(app_name: str) -> Optional[int]:
|
|
231
|
+
pf = _pid_file(app_name)
|
|
232
|
+
if not pf.exists():
|
|
233
|
+
return None
|
|
234
|
+
try:
|
|
235
|
+
pid = int(pf.read_text().strip())
|
|
236
|
+
os.kill(pid, 0)
|
|
237
|
+
return pid
|
|
238
|
+
except (ValueError, OSError):
|
|
239
|
+
pf.unlink(missing_ok=True)
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _register_app(session_name: str, app_name: str, port: int) -> Optional[Dict[str, Any]]:
|
|
244
|
+
import urllib.request
|
|
245
|
+
import json
|
|
246
|
+
url = f"{AUTH_PROXY_INTERNAL_URL}/api/internal/register-app"
|
|
247
|
+
payload = json.dumps({"sessionName": session_name, "appName": app_name, "port": port}).encode()
|
|
248
|
+
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"}, method="POST")
|
|
249
|
+
try:
|
|
250
|
+
with urllib.request.urlopen(req, timeout=5) as resp:
|
|
251
|
+
return json.loads(resp.read())
|
|
252
|
+
except Exception as e:
|
|
253
|
+
click.echo(f"⚠️ Failed to register app with auth-proxy: {e}", err=True)
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _deregister_app(session_name: str, app_name: str) -> None:
|
|
258
|
+
import urllib.request
|
|
259
|
+
import json
|
|
260
|
+
url = f"{AUTH_PROXY_INTERNAL_URL}/api/internal/register-app"
|
|
261
|
+
payload = json.dumps({"sessionName": session_name, "appName": app_name}).encode()
|
|
262
|
+
req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"}, method="DELETE")
|
|
263
|
+
try:
|
|
264
|
+
urllib.request.urlopen(req, timeout=5)
|
|
265
|
+
except Exception:
|
|
266
|
+
pass
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@main.group(invoke_without_command=True)
|
|
270
|
+
@click.option('--apps-file', '-f', type=click.Path(exists=True, path_type=Path),
|
|
271
|
+
help='Path to apps.yaml file (default: ./apps.yaml)')
|
|
272
|
+
@click.pass_context
|
|
273
|
+
def apps(ctx: click.Context, apps_file: Optional[Path]):
|
|
274
|
+
"""Manage and run apps from apps.yaml inside the terminal."""
|
|
275
|
+
if ctx.invoked_subcommand is not None:
|
|
276
|
+
return
|
|
277
|
+
config = AppsConfig(apps_file)
|
|
278
|
+
app_names = config.list_apps()
|
|
279
|
+
if not app_names:
|
|
280
|
+
click.echo("❌ No apps found in apps.yaml")
|
|
281
|
+
return
|
|
282
|
+
choice = questionary.select(
|
|
283
|
+
"Select an app to start:",
|
|
284
|
+
choices=app_names,
|
|
285
|
+
).ask()
|
|
286
|
+
if choice is None:
|
|
287
|
+
sys.exit(0)
|
|
288
|
+
app_def = config.get_app(choice)
|
|
289
|
+
ctx.invoke(apps_start, app_name=choice, apps_file=apps_file)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@apps.command('ls')
|
|
293
|
+
@click.option('--apps-file', '-f', type=click.Path(exists=True, path_type=Path),
|
|
294
|
+
help='Path to apps.yaml file (default: ./apps.yaml)')
|
|
295
|
+
def apps_list(apps_file: Optional[Path]):
|
|
296
|
+
"""List all available apps."""
|
|
297
|
+
config = AppsConfig(apps_file)
|
|
298
|
+
if not config.apps:
|
|
299
|
+
click.echo("❌ No apps found in apps.yaml")
|
|
300
|
+
return
|
|
301
|
+
|
|
302
|
+
click.echo(f"📋 Available apps in {click.style(str(config.apps_file), fg='cyan')}:")
|
|
303
|
+
click.echo()
|
|
304
|
+
|
|
305
|
+
for app_data in config.apps:
|
|
306
|
+
name = app_data.get('name', 'unnamed')
|
|
307
|
+
command = app_data.get('command', [])
|
|
308
|
+
port = app_data.get('port', '?')
|
|
309
|
+
env_vars = app_data.get('env', {})
|
|
310
|
+
pid = _get_running_pid(name)
|
|
311
|
+
status = click.style('● running', fg='green') if pid else click.style('○ stopped', fg='yellow')
|
|
312
|
+
|
|
313
|
+
click.echo(f" {click.style(name, fg='blue', bold=True)} {status}")
|
|
314
|
+
if command:
|
|
315
|
+
click.echo(f" Command: {click.style(' '.join(command), fg='cyan')}")
|
|
316
|
+
click.echo(f" Port: {click.style(str(port), fg='magenta')}")
|
|
317
|
+
if env_vars:
|
|
318
|
+
click.echo(f" Environment: {click.style(f'{len(env_vars)} variables', fg='green')}")
|
|
319
|
+
click.echo()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@apps.command('start')
|
|
323
|
+
@click.argument('app_name')
|
|
324
|
+
@click.option('--apps-file', '-f', type=click.Path(exists=True, path_type=Path),
|
|
325
|
+
help='Path to apps.yaml file (default: ./apps.yaml)')
|
|
326
|
+
def apps_start(app_name: str, apps_file: Optional[Path]):
|
|
327
|
+
"""Start an app by name and register it with the auth-proxy."""
|
|
328
|
+
config = AppsConfig(apps_file)
|
|
329
|
+
app_def = config.get_app(app_name)
|
|
330
|
+
if not app_def:
|
|
331
|
+
available = config.list_apps()
|
|
332
|
+
click.echo(f"❌ Error: App '{app_name}' not found", err=True)
|
|
333
|
+
if available:
|
|
334
|
+
click.echo(f"Available apps: {', '.join(available)}", err=True)
|
|
335
|
+
sys.exit(1)
|
|
336
|
+
|
|
337
|
+
session_name = _get_session_name()
|
|
338
|
+
if not session_name:
|
|
339
|
+
click.echo("❌ Error: SESSION_NAME environment variable not set. Are you running inside the terminal?", err=True)
|
|
340
|
+
sys.exit(1)
|
|
341
|
+
|
|
342
|
+
existing_pid = _get_running_pid(app_name)
|
|
343
|
+
if existing_pid:
|
|
344
|
+
click.echo(f"⚠️ App '{app_name}' is already running (PID {existing_pid})")
|
|
345
|
+
return
|
|
346
|
+
|
|
347
|
+
command = app_def.get('command', [])
|
|
348
|
+
port = app_def.get('port')
|
|
349
|
+
env_vars = app_def.get('env', {})
|
|
350
|
+
|
|
351
|
+
if not command:
|
|
352
|
+
click.echo(f"❌ Error: No command specified for app '{app_name}'", err=True)
|
|
353
|
+
sys.exit(1)
|
|
354
|
+
if not port:
|
|
355
|
+
click.echo(f"❌ Error: No port specified for app '{app_name}'", err=True)
|
|
356
|
+
sys.exit(1)
|
|
357
|
+
|
|
358
|
+
env = os.environ.copy()
|
|
359
|
+
for key, value in env_vars.items():
|
|
360
|
+
env[key] = str(value)
|
|
361
|
+
|
|
362
|
+
APPS_DIR.mkdir(parents=True, exist_ok=True)
|
|
363
|
+
log_file = APPS_DIR / f"{app_name}.log"
|
|
364
|
+
|
|
365
|
+
click.echo(f"🚀 Starting app: {click.style(app_name, fg='blue', bold=True)}")
|
|
366
|
+
click.echo(f"📝 Command: {click.style(' '.join(command), fg='cyan')}")
|
|
367
|
+
click.echo(f"🔌 Port: {click.style(str(port), fg='magenta')}")
|
|
368
|
+
|
|
369
|
+
app_cwd = config.apps_file.parent
|
|
370
|
+
with open(log_file, 'a') as lf:
|
|
371
|
+
proc = subprocess.Popen(command, env=env, cwd=app_cwd, stdout=lf, stderr=lf)
|
|
372
|
+
|
|
373
|
+
time.sleep(1)
|
|
374
|
+
if proc.poll() is not None:
|
|
375
|
+
click.echo(f"❌ App '{app_name}' exited immediately (code {proc.returncode})", err=True)
|
|
376
|
+
click.echo(f"📄 Last log output:", err=True)
|
|
377
|
+
try:
|
|
378
|
+
with open(log_file) as lf:
|
|
379
|
+
click.echo(lf.read(), err=True)
|
|
380
|
+
except Exception:
|
|
381
|
+
pass
|
|
382
|
+
sys.exit(1)
|
|
383
|
+
|
|
384
|
+
_pid_file(app_name).write_text(str(proc.pid))
|
|
385
|
+
|
|
386
|
+
result = _register_app(session_name, app_name, port)
|
|
387
|
+
|
|
388
|
+
if result:
|
|
389
|
+
click.echo(f"✅ App started (PID {proc.pid}) and registered with auth-proxy")
|
|
390
|
+
public_url = result.get('url')
|
|
391
|
+
else:
|
|
392
|
+
click.echo(f"✅ App started (PID {proc.pid}), but registration with auth-proxy failed")
|
|
393
|
+
public_url = None
|
|
394
|
+
|
|
395
|
+
if public_url:
|
|
396
|
+
click.echo(f"🌐 Access at: {click.style(public_url, fg='cyan')}")
|
|
397
|
+
else:
|
|
398
|
+
click.echo(f"🌐 Access at (local): {click.style(f'http://localhost:{port}', fg='yellow')}")
|
|
399
|
+
|
|
400
|
+
click.echo(f"📄 Logs: {click.style(str(log_file), fg='yellow')}")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@apps.command('stop')
|
|
404
|
+
@click.argument('app_name')
|
|
405
|
+
@click.option('--apps-file', '-f', type=click.Path(exists=True, path_type=Path),
|
|
406
|
+
help='Path to apps.yaml file (default: ./apps.yaml)')
|
|
407
|
+
def apps_stop(app_name: str, apps_file: Optional[Path]):
|
|
408
|
+
"""Stop a running app and deregister it from the auth-proxy."""
|
|
409
|
+
session_name = _get_session_name()
|
|
410
|
+
|
|
411
|
+
pid = _get_running_pid(app_name)
|
|
412
|
+
if not pid:
|
|
413
|
+
click.echo(f"⚠️ App '{app_name}' is not running")
|
|
414
|
+
return
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
os.kill(pid, signal.SIGTERM)
|
|
418
|
+
click.echo(f"🛑 Stopped app '{app_name}' (PID {pid})")
|
|
419
|
+
except OSError as e:
|
|
420
|
+
click.echo(f"❌ Failed to stop process: {e}", err=True)
|
|
421
|
+
|
|
422
|
+
_pid_file(app_name).unlink(missing_ok=True)
|
|
423
|
+
|
|
424
|
+
if session_name:
|
|
425
|
+
_deregister_app(session_name, app_name)
|
|
426
|
+
click.echo(f"🔌 Deregistered from auth-proxy")
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
SETUP_IGNORE = [".env.example", ".env.template"]
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@main.group()
|
|
433
|
+
def setup():
|
|
434
|
+
"""Setup commands for environment configuration."""
|
|
435
|
+
pass
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
@setup.command("env")
|
|
439
|
+
def setup_env():
|
|
440
|
+
"""Decrypt SOPS-encoded .env files in current directory and write plaintext .env files."""
|
|
441
|
+
cwd = Path.cwd()
|
|
442
|
+
found: List[Path] = []
|
|
443
|
+
for pattern in ["*.enc.env", "*.encoded.env"]:
|
|
444
|
+
found.extend(sorted(cwd.glob(pattern)))
|
|
445
|
+
|
|
446
|
+
found = [f for f in found if f.name not in SETUP_IGNORE]
|
|
447
|
+
|
|
448
|
+
if not found:
|
|
449
|
+
click.echo("No *.enc.env or *.encoded.env files found.")
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
for enc_file in found:
|
|
453
|
+
click.echo(f"\n📄 Found: {click.style(enc_file.name, fg='blue', bold=True)}")
|
|
454
|
+
|
|
455
|
+
content = enc_file.read_text()
|
|
456
|
+
if "ENC[" not in content and "sops:" not in content:
|
|
457
|
+
click.echo(" ⚠️ Not SOPS encrypted, skipping.")
|
|
458
|
+
continue
|
|
459
|
+
|
|
460
|
+
result = subprocess.run(
|
|
461
|
+
["sops", "-d", str(enc_file)],
|
|
462
|
+
capture_output=True,
|
|
463
|
+
text=True,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
if result.returncode != 0:
|
|
467
|
+
click.echo(f" ❌ Decryption failed: {result.stderr.strip()}", err=True)
|
|
468
|
+
continue
|
|
469
|
+
|
|
470
|
+
keys = []
|
|
471
|
+
for line in result.stdout.splitlines():
|
|
472
|
+
line = line.strip()
|
|
473
|
+
if line and not line.startswith("#") and "=" in line:
|
|
474
|
+
keys.append(line.split("=", 1)[0])
|
|
475
|
+
|
|
476
|
+
if keys:
|
|
477
|
+
click.echo(f" 🔑 Keys: {click.style(', '.join(keys), fg='green')}")
|
|
478
|
+
|
|
479
|
+
stem = enc_file.name.replace(".encoded.env", "").replace(".enc.env", "")
|
|
480
|
+
out_file = cwd / f"{stem}.env"
|
|
481
|
+
out_file.write_text(result.stdout)
|
|
482
|
+
|
|
483
|
+
click.echo(f" ✅ Decrypted -> {click.style(out_file.name, fg='cyan')}")
|
|
484
|
+
cmd = f"""set -a
|
|
485
|
+
source {out_file.name}
|
|
486
|
+
set +a"""
|
|
487
|
+
|
|
488
|
+
click.echo(f" 💡 Run:\n{click.style(cmd, fg='yellow')}")
|
|
489
|
+
|
|
490
|
+
def _detect_workspaces() -> List[str]:
|
|
491
|
+
tiers = {"BRONZE", "SILVER", "GOLD"}
|
|
492
|
+
workspaces = set()
|
|
493
|
+
for key in os.environ:
|
|
494
|
+
if key.endswith("_ACCESS_KEY"):
|
|
495
|
+
prefix = key[: -len("_ACCESS_KEY")]
|
|
496
|
+
if prefix not in tiers:
|
|
497
|
+
workspaces.add(prefix)
|
|
498
|
+
return sorted(workspaces)
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _build_tree(keys: List[str]) -> Dict[str, Any]:
|
|
502
|
+
tree: Dict[str, Any] = {}
|
|
503
|
+
for key in keys:
|
|
504
|
+
parts = key.split("/")
|
|
505
|
+
node = tree
|
|
506
|
+
for part in parts[:-1]:
|
|
507
|
+
node = node.setdefault(part, {})
|
|
508
|
+
node[parts[-1]] = None
|
|
509
|
+
return tree
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _print_tree(node: Dict[str, Any], prefix: str = "", is_last: bool = True) -> None:
|
|
513
|
+
items = sorted(node.items())
|
|
514
|
+
for i, (name, child) in enumerate(items):
|
|
515
|
+
last = i == len(items) - 1
|
|
516
|
+
connector = "└── " if last else "├── "
|
|
517
|
+
click.echo(f"{prefix}{connector}{name}")
|
|
518
|
+
if child is not None:
|
|
519
|
+
extension = " " if last else "│ "
|
|
520
|
+
_print_tree(child, prefix + extension, last)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _list_bucket(storage_kwargs: dict, label: str, bucket_name: str) -> None:
|
|
524
|
+
click.echo(f" {click.style(label, fg='cyan')} ({bucket_name})")
|
|
525
|
+
try:
|
|
526
|
+
from steve_cli.storage import S3Storage
|
|
527
|
+
storage = S3Storage(**storage_kwargs)
|
|
528
|
+
keys = storage.list_all()
|
|
529
|
+
if not keys:
|
|
530
|
+
click.echo(" (empty)")
|
|
531
|
+
else:
|
|
532
|
+
tree = _build_tree(keys)
|
|
533
|
+
_print_tree(tree, prefix=" ")
|
|
534
|
+
except EnvironmentError as e:
|
|
535
|
+
click.echo(f" ⚠️ {e}", err=True)
|
|
536
|
+
except Exception as e:
|
|
537
|
+
click.echo(f" ❌ {e}", err=True)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
@main.command("buckets")
|
|
541
|
+
def buckets():
|
|
542
|
+
"""List all S3 buckets detected from env variables and show their files as a tree."""
|
|
543
|
+
tiers = ["bronze", "silver", "gold"]
|
|
544
|
+
|
|
545
|
+
options: List[Dict[str, Any]] = []
|
|
546
|
+
|
|
547
|
+
bare_tiers = [t for t in tiers if os.getenv(f"{t.upper()}_ACCESS_KEY")]
|
|
548
|
+
for tier in bare_tiers:
|
|
549
|
+
bucket_name = os.getenv(f"{tier.upper()}_BUCKET", "")
|
|
550
|
+
options.append({
|
|
551
|
+
"label": f"default / {tier} ({bucket_name})",
|
|
552
|
+
"kwargs": {"tier": tier},
|
|
553
|
+
"bucket_name": bucket_name,
|
|
554
|
+
"tier": tier,
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
for ws in _detect_workspaces():
|
|
558
|
+
for tier in tiers:
|
|
559
|
+
bucket_name = os.getenv(f"{ws}_BUCKET_{tier.upper()}")
|
|
560
|
+
if not bucket_name:
|
|
561
|
+
continue
|
|
562
|
+
options.append({
|
|
563
|
+
"label": f"{ws} / {tier} ({bucket_name})",
|
|
564
|
+
"kwargs": {"tier": tier, "workspace": ws.lower().replace("_", "-")},
|
|
565
|
+
"bucket_name": bucket_name,
|
|
566
|
+
"tier": tier,
|
|
567
|
+
})
|
|
568
|
+
|
|
569
|
+
if not options:
|
|
570
|
+
click.echo("No bucket env variables found (expected: BRONZE_ACCESS_KEY or {WORKSPACE}_ACCESS_KEY).")
|
|
571
|
+
return
|
|
572
|
+
|
|
573
|
+
choice = questionary.select(
|
|
574
|
+
"Select a bucket to list:",
|
|
575
|
+
choices=[o["label"] for o in options],
|
|
576
|
+
).ask()
|
|
577
|
+
|
|
578
|
+
if choice is None:
|
|
579
|
+
sys.exit(0)
|
|
580
|
+
|
|
581
|
+
selected = next(o for o in options if o["label"] == choice)
|
|
582
|
+
_list_bucket(selected["kwargs"], selected["tier"], selected["bucket_name"])
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
if __name__ == '__main__':
|
|
586
|
+
main()
|
|
@@ -161,6 +161,14 @@ class S3Storage:
|
|
|
161
161
|
for obj in resp.get("Contents", [])
|
|
162
162
|
]
|
|
163
163
|
|
|
164
|
+
def list_all(self) -> List[str]:
|
|
165
|
+
keys = []
|
|
166
|
+
paginator = self.client.get_paginator("list_objects_v2")
|
|
167
|
+
for page in paginator.paginate(Bucket=self.bucket):
|
|
168
|
+
for obj in page.get("Contents", []):
|
|
169
|
+
keys.append(obj["Key"])
|
|
170
|
+
return keys
|
|
171
|
+
|
|
164
172
|
|
|
165
173
|
def get_storage(tier: str = "bronze", workspace: str | None = None) -> Storage:
|
|
166
174
|
return S3Storage(tier=tier, workspace=workspace)
|
steve_cli-0.3.1/steve_cli/cli.py
DELETED
|
@@ -1,237 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Steve CLI - A simple tool to run jobs from jobs.yaml with proper environment setup.
|
|
4
|
-
|
|
5
|
-
Usage:
|
|
6
|
-
steve jobs ls List all available jobs
|
|
7
|
-
steve jobs run <job-name> Run a job with its environment variables
|
|
8
|
-
steve setup all Decrypt SOPS-encoded .env files
|
|
9
|
-
"""
|
|
10
|
-
|
|
11
|
-
import os
|
|
12
|
-
import subprocess
|
|
13
|
-
import sys
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
from typing import Dict, List, Any, Optional
|
|
16
|
-
|
|
17
|
-
import click
|
|
18
|
-
import questionary
|
|
19
|
-
import yaml
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
class JobsConfig:
|
|
23
|
-
def __init__(self, jobs_file: Optional[Path] = None):
|
|
24
|
-
self.jobs_file = jobs_file or Path.cwd() / "jobs.yaml"
|
|
25
|
-
self.jobs: List[Dict[str, Any]] = []
|
|
26
|
-
self._load_jobs()
|
|
27
|
-
|
|
28
|
-
def _load_jobs(self) -> None:
|
|
29
|
-
try:
|
|
30
|
-
if not self.jobs_file.exists():
|
|
31
|
-
click.echo(f"❌ Error: jobs.yaml not found at {self.jobs_file}", err=True)
|
|
32
|
-
click.echo("Make sure you're in a directory with a jobs.yaml file", err=True)
|
|
33
|
-
sys.exit(1)
|
|
34
|
-
|
|
35
|
-
with open(self.jobs_file, 'r') as f:
|
|
36
|
-
data = yaml.safe_load(f)
|
|
37
|
-
|
|
38
|
-
if not data or 'jobs' not in data:
|
|
39
|
-
click.echo("❌ Error: Invalid jobs.yaml format. Expected 'jobs' key at root", err=True)
|
|
40
|
-
sys.exit(1)
|
|
41
|
-
|
|
42
|
-
self.jobs = data['jobs']
|
|
43
|
-
|
|
44
|
-
except yaml.YAMLError as e:
|
|
45
|
-
click.echo(f"❌ Error parsing jobs.yaml: {e}", err=True)
|
|
46
|
-
sys.exit(1)
|
|
47
|
-
except Exception as e:
|
|
48
|
-
click.echo(f"❌ Error reading jobs.yaml: {e}", err=True)
|
|
49
|
-
sys.exit(1)
|
|
50
|
-
|
|
51
|
-
def get_job(self, job_name: str) -> Optional[Dict[str, Any]]:
|
|
52
|
-
for job in self.jobs:
|
|
53
|
-
if job.get('name') == job_name:
|
|
54
|
-
return job
|
|
55
|
-
return None
|
|
56
|
-
|
|
57
|
-
def list_jobs(self) -> List[str]:
|
|
58
|
-
return [job.get('name', 'unnamed') for job in self.jobs]
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def run_job_command(job: Dict[str, Any]) -> int:
|
|
62
|
-
command = job.get('command', [])
|
|
63
|
-
if not command:
|
|
64
|
-
click.echo(f"❌ Error: No command specified for job '{job.get('name')}'", err=True)
|
|
65
|
-
return 1
|
|
66
|
-
|
|
67
|
-
env = os.environ.copy()
|
|
68
|
-
job_env = job.get('env', {})
|
|
69
|
-
for key, value in job_env.items():
|
|
70
|
-
env[key] = str(value)
|
|
71
|
-
|
|
72
|
-
job_name = job.get('name', 'unnamed')
|
|
73
|
-
click.echo(f"🚀 Running job: {click.style(job_name, fg='blue', bold=True)}")
|
|
74
|
-
click.echo(f"📝 Command: {click.style(' '.join(command), fg='cyan')}")
|
|
75
|
-
|
|
76
|
-
if job_env:
|
|
77
|
-
click.echo("🌍 Environment variables:")
|
|
78
|
-
for key, value in job_env.items():
|
|
79
|
-
click.echo(f" {click.style(key, fg='green')}={click.style(str(value), fg='yellow')}")
|
|
80
|
-
|
|
81
|
-
click.echo()
|
|
82
|
-
|
|
83
|
-
try:
|
|
84
|
-
result = subprocess.run(command, env=env, cwd=Path.cwd())
|
|
85
|
-
return result.returncode
|
|
86
|
-
except FileNotFoundError:
|
|
87
|
-
click.echo(f"❌ Error: Command not found: {command[0]}", err=True)
|
|
88
|
-
return 127
|
|
89
|
-
except Exception as e:
|
|
90
|
-
click.echo(f"❌ Error running command: {e}", err=True)
|
|
91
|
-
return 1
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
@click.group()
|
|
95
|
-
def main():
|
|
96
|
-
"""Steve CLI - Run jobs and manage environment setup."""
|
|
97
|
-
pass
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
@main.group(invoke_without_command=True)
|
|
101
|
-
@click.option('--jobs-file', '-f', type=click.Path(exists=True, path_type=Path),
|
|
102
|
-
help='Path to jobs.yaml file (default: ./jobs.yaml)')
|
|
103
|
-
@click.pass_context
|
|
104
|
-
def jobs(ctx: click.Context, jobs_file: Optional[Path]):
|
|
105
|
-
"""Manage and run jobs from jobs.yaml."""
|
|
106
|
-
if ctx.invoked_subcommand is not None:
|
|
107
|
-
return
|
|
108
|
-
config = JobsConfig(jobs_file)
|
|
109
|
-
job_names = config.list_jobs()
|
|
110
|
-
if not job_names:
|
|
111
|
-
click.echo("❌ No jobs found in jobs.yaml")
|
|
112
|
-
return
|
|
113
|
-
click.echo(ctx.get_help())
|
|
114
|
-
click.echo()
|
|
115
|
-
choice = questionary.select(
|
|
116
|
-
"Select a job to run:",
|
|
117
|
-
choices=job_names,
|
|
118
|
-
).ask()
|
|
119
|
-
if choice is None:
|
|
120
|
-
sys.exit(0)
|
|
121
|
-
job = config.get_job(choice)
|
|
122
|
-
sys.exit(run_job_command(job))
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
@jobs.command('ls')
|
|
126
|
-
@click.option('--jobs-file', '-f', type=click.Path(exists=True, path_type=Path),
|
|
127
|
-
help='Path to jobs.yaml file (default: ./jobs.yaml)')
|
|
128
|
-
def jobs_list(jobs_file: Optional[Path]):
|
|
129
|
-
"""List all available jobs."""
|
|
130
|
-
config = JobsConfig(jobs_file)
|
|
131
|
-
job_names = config.list_jobs()
|
|
132
|
-
|
|
133
|
-
if not job_names:
|
|
134
|
-
click.echo("❌ No jobs found in jobs.yaml")
|
|
135
|
-
return
|
|
136
|
-
|
|
137
|
-
click.echo(f"📋 Available jobs in {click.style(str(config.jobs_file), fg='cyan')}:")
|
|
138
|
-
click.echo()
|
|
139
|
-
|
|
140
|
-
for job_data in config.jobs:
|
|
141
|
-
name = job_data.get('name', 'unnamed')
|
|
142
|
-
command = job_data.get('command', [])
|
|
143
|
-
cron = job_data.get('cron')
|
|
144
|
-
depends_on = job_data.get('dependsOn', [])
|
|
145
|
-
env_vars = job_data.get('env', {})
|
|
146
|
-
|
|
147
|
-
click.echo(f" {click.style(name, fg='blue', bold=True)}")
|
|
148
|
-
if command:
|
|
149
|
-
click.echo(f" Command: {click.style(' '.join(command), fg='cyan')}")
|
|
150
|
-
if cron:
|
|
151
|
-
click.echo(f" Schedule: {click.style(cron, fg='yellow')}")
|
|
152
|
-
if depends_on:
|
|
153
|
-
click.echo(f" Depends on: {click.style(', '.join(depends_on), fg='magenta')}")
|
|
154
|
-
if env_vars:
|
|
155
|
-
click.echo(f" Environment: {click.style(f'{len(env_vars)} variables', fg='green')}")
|
|
156
|
-
for key, value in env_vars.items():
|
|
157
|
-
click.echo(f" {key}={value}")
|
|
158
|
-
click.echo()
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
@jobs.command('run')
|
|
162
|
-
@click.argument('job_name')
|
|
163
|
-
@click.option('--jobs-file', '-f', type=click.Path(exists=True, path_type=Path),
|
|
164
|
-
help='Path to jobs.yaml file (default: ./jobs.yaml)')
|
|
165
|
-
def jobs_run(job_name: str, jobs_file: Optional[Path]):
|
|
166
|
-
"""Run a job by name."""
|
|
167
|
-
config = JobsConfig(jobs_file)
|
|
168
|
-
job = config.get_job(job_name)
|
|
169
|
-
if not job:
|
|
170
|
-
available = config.list_jobs()
|
|
171
|
-
click.echo(f"❌ Error: Job '{job_name}' not found", err=True)
|
|
172
|
-
if available:
|
|
173
|
-
click.echo(f"Available jobs: {', '.join(available)}", err=True)
|
|
174
|
-
sys.exit(1)
|
|
175
|
-
sys.exit(run_job_command(job))
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
SETUP_IGNORE = [".env.example", ".env.template"]
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
@main.group()
|
|
182
|
-
def setup():
|
|
183
|
-
"""Setup commands for environment configuration."""
|
|
184
|
-
pass
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
@setup.command("env")
|
|
188
|
-
def setup_env():
|
|
189
|
-
"""Decrypt SOPS-encoded .env files in current directory and write plaintext .env files."""
|
|
190
|
-
cwd = Path.cwd()
|
|
191
|
-
found: List[Path] = []
|
|
192
|
-
for pattern in ["*.enc.env", "*.encoded.env"]:
|
|
193
|
-
found.extend(sorted(cwd.glob(pattern)))
|
|
194
|
-
|
|
195
|
-
found = [f for f in found if f.name not in SETUP_IGNORE]
|
|
196
|
-
|
|
197
|
-
if not found:
|
|
198
|
-
click.echo("No *.enc.env or *.encoded.env files found.")
|
|
199
|
-
return
|
|
200
|
-
|
|
201
|
-
for enc_file in found:
|
|
202
|
-
click.echo(f"\n📄 Found: {click.style(enc_file.name, fg='blue', bold=True)}")
|
|
203
|
-
|
|
204
|
-
content = enc_file.read_text()
|
|
205
|
-
if "ENC[" not in content and "sops:" not in content:
|
|
206
|
-
click.echo(" ⚠️ Not SOPS encrypted, skipping.")
|
|
207
|
-
continue
|
|
208
|
-
|
|
209
|
-
result = subprocess.run(
|
|
210
|
-
["sops", "-d", str(enc_file)],
|
|
211
|
-
capture_output=True,
|
|
212
|
-
text=True,
|
|
213
|
-
)
|
|
214
|
-
|
|
215
|
-
if result.returncode != 0:
|
|
216
|
-
click.echo(f" ❌ Decryption failed: {result.stderr.strip()}", err=True)
|
|
217
|
-
continue
|
|
218
|
-
|
|
219
|
-
keys = []
|
|
220
|
-
for line in result.stdout.splitlines():
|
|
221
|
-
line = line.strip()
|
|
222
|
-
if line and not line.startswith("#") and "=" in line:
|
|
223
|
-
keys.append(line.split("=", 1)[0])
|
|
224
|
-
|
|
225
|
-
if keys:
|
|
226
|
-
click.echo(f" 🔑 Keys: {click.style(', '.join(keys), fg='green')}")
|
|
227
|
-
|
|
228
|
-
stem = enc_file.name.replace(".encoded.env", "").replace(".enc.env", "")
|
|
229
|
-
out_file = cwd / f"{stem}.env"
|
|
230
|
-
out_file.write_text(result.stdout)
|
|
231
|
-
|
|
232
|
-
click.echo(f" ✅ Decrypted -> {click.style(out_file.name, fg='cyan')}")
|
|
233
|
-
click.echo(f" 💡 Run: {click.style(f'source {out_file.name}', fg='yellow')}")
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if __name__ == '__main__':
|
|
237
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|