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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steve-cli
3
- Version: 0.3.1
3
+ Version: 0.3.5
4
4
  Summary: A simple CLI tool to run jobs from jobs.yaml with proper environment setup
5
5
  Author: Frank
6
6
  License: MIT
@@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "steve-cli"
8
- version = "0.3.1"
8
+ version = "0.3.5"
9
9
  description = "A simple CLI tool to run jobs from jobs.yaml with proper environment setup"
10
10
  readme = "README.md"
11
11
  license = {text = "MIT"}
@@ -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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: steve-cli
3
- Version: 0.3.1
3
+ Version: 0.3.5
4
4
  Summary: A simple CLI tool to run jobs from jobs.yaml with proper environment setup
5
5
  Author: Frank
6
6
  License: MIT
@@ -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