tunectl 1.0.0 → 1.1.0

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tunectl",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Battle-tested VPS performance tuning toolkit for Ubuntu 24.04 LTS",
5
5
  "bin": {
6
6
  "tunectl": "./bin/tunectl"
@@ -96,41 +96,35 @@ while [[ $# -gt 0 ]]; do
96
96
  done
97
97
 
98
98
  # -------------------------------------------------------
99
- # Check dependencies
99
+ # Check dependencies — graceful degradation
100
+ # Sets HAS_SYSBENCH and HAS_FIO globals (0=available, 1=missing)
101
+ # Exits 1 only if BOTH tools are missing
100
102
  # -------------------------------------------------------
103
+ HAS_SYSBENCH=1
104
+ HAS_FIO=1
105
+
101
106
  check_dependencies() {
102
107
  local missing=()
103
108
 
104
- if ! command -v sysbench &>/dev/null; then
109
+ if command -v sysbench &>/dev/null; then
110
+ HAS_SYSBENCH=0
111
+ else
105
112
  missing+=("sysbench")
113
+ echo "▲ sysbench not found. Install: sudo apt install sysbench" >&2
106
114
  fi
107
115
 
108
- if ! command -v fio &>/dev/null; then
116
+ if command -v fio &>/dev/null; then
117
+ HAS_FIO=0
118
+ else
109
119
  missing+=("fio")
120
+ echo "▲ fio not found. Install: sudo apt install fio" >&2
110
121
  fi
111
122
 
112
- if [[ ${#missing[@]} -gt 0 ]]; then
113
- echo "Installing missing dependencies: ${missing[*]}..." >&2
114
- if command -v apt-get &>/dev/null; then
115
- if [[ $EUID -eq 0 ]]; then
116
- apt-get update -qq >/dev/null 2>&1
117
- apt-get install -y -qq "${missing[@]}" >/dev/null 2>&1
118
- else
119
- echo "Error: Cannot install ${missing[*]} without root. Run: sudo apt-get install ${missing[*]}" >&2
120
- exit 1
121
- fi
122
- else
123
- echo "Error: apt-get not available. Install manually: ${missing[*]}" >&2
124
- exit 1
125
- fi
126
-
127
- # Verify installation
128
- for tool in "${missing[@]}"; do
129
- if ! command -v "$tool" &>/dev/null; then
130
- echo "Error: Failed to install $tool" >&2
131
- exit 1
132
- fi
133
- done
123
+ if [[ ${#missing[@]} -eq 2 ]]; then
124
+ echo "" >&2
125
+ echo "Error: No benchmark tools available. Install at least one:" >&2
126
+ echo " sudo apt install sysbench fio" >&2
127
+ exit 1
134
128
  fi
135
129
  }
136
130
 
@@ -327,37 +321,53 @@ except Exception:
327
321
  # -------------------------------------------------------
328
322
  # Run all benchmarks and collect results
329
323
  # Returns results as key=value pairs
330
- # Exits 1 if any benchmark command failed (ERROR sentinel)
324
+ # Skips benchmarks for missing tools (uses HAS_SYSBENCH/HAS_FIO)
325
+ # Exits 1 if any executed benchmark command failed (ERROR sentinel)
331
326
  # -------------------------------------------------------
332
327
  run_all_benchmarks() {
333
328
  local failed_benchmarks=()
329
+ local step=0 total=0
334
330
 
335
- echo "Running benchmarks..." >&2
336
-
337
- echo " [1/5] CPU single-thread..." >&2
338
- local cpu_st
339
- cpu_st=$(run_cpu_single)
340
- [[ "$cpu_st" == "ERROR" ]] && failed_benchmarks+=("CPU single-thread")
331
+ # Count total benchmarks to run
332
+ [[ $HAS_SYSBENCH -eq 0 ]] && total=$((total + 4))
333
+ [[ $HAS_FIO -eq 0 ]] && total=$((total + 1))
341
334
 
342
- echo " [2/5] CPU multi-thread..." >&2
343
- local cpu_mt
344
- cpu_mt=$(run_cpu_multi)
345
- [[ "$cpu_mt" == "ERROR" ]] && failed_benchmarks+=("CPU multi-thread")
346
-
347
- echo " [3/5] Memory read..." >&2
348
- local mem_read
349
- mem_read=$(run_mem_read)
350
- [[ "$mem_read" == "ERROR" ]] && failed_benchmarks+=("Memory read")
335
+ echo "Running benchmarks..." >&2
351
336
 
352
- echo " [4/5] Memory write..." >&2
353
- local mem_write
354
- mem_write=$(run_mem_write)
355
- [[ "$mem_write" == "ERROR" ]] && failed_benchmarks+=("Memory write")
337
+ local cpu_st="SKIPPED" cpu_mt="SKIPPED" mem_read="SKIPPED" mem_write="SKIPPED" disk_rr="SKIPPED"
338
+
339
+ if [[ $HAS_SYSBENCH -eq 0 ]]; then
340
+ step=$((step + 1))
341
+ echo " [$step/$total] CPU single-thread..." >&2
342
+ cpu_st=$(run_cpu_single)
343
+ [[ "$cpu_st" == "ERROR" ]] && failed_benchmarks+=("CPU single-thread")
344
+
345
+ step=$((step + 1))
346
+ echo " [$step/$total] CPU multi-thread..." >&2
347
+ cpu_mt=$(run_cpu_multi)
348
+ [[ "$cpu_mt" == "ERROR" ]] && failed_benchmarks+=("CPU multi-thread")
349
+
350
+ step=$((step + 1))
351
+ echo " [$step/$total] Memory read..." >&2
352
+ mem_read=$(run_mem_read)
353
+ [[ "$mem_read" == "ERROR" ]] && failed_benchmarks+=("Memory read")
354
+
355
+ step=$((step + 1))
356
+ echo " [$step/$total] Memory write..." >&2
357
+ mem_write=$(run_mem_write)
358
+ [[ "$mem_write" == "ERROR" ]] && failed_benchmarks+=("Memory write")
359
+ else
360
+ echo " Skipping sysbench tests (sysbench not installed)" >&2
361
+ fi
356
362
 
357
- echo " [5/5] Disk random read..." >&2
358
- local disk_rr
359
- disk_rr=$(run_disk_rand_read)
360
- [[ "$disk_rr" == "ERROR" ]] && failed_benchmarks+=("Disk random read")
363
+ if [[ $HAS_FIO -eq 0 ]]; then
364
+ step=$((step + 1))
365
+ echo " [$step/$total] Disk random read..." >&2
366
+ disk_rr=$(run_disk_rand_read)
367
+ [[ "$disk_rr" == "ERROR" ]] && failed_benchmarks+=("Disk random read")
368
+ else
369
+ echo " Skipping fio tests (fio not installed)" >&2
370
+ fi
361
371
 
362
372
  echo "Done." >&2
363
373
 
@@ -406,8 +416,10 @@ display_results() {
406
416
  disk_rand_read_iops) label="Disk Random Read" ;;
407
417
  *) label="$metric" ;;
408
418
  esac
409
- # Show N/A for ERROR values instead of the sentinel
410
- if [[ "$value" == "ERROR" ]]; then
419
+ # Show N/A for ERROR values, show skipped for SKIPPED
420
+ if [[ "$value" == "SKIPPED" ]]; then
421
+ printf " %-30s %15s %s\n" "$label" "N/A" "(skipped)"
422
+ elif [[ "$value" == "ERROR" ]]; then
411
423
  printf " %-30s %15s %s\n" "$label" "N/A" "(failed)"
412
424
  else
413
425
  printf " %-30s %15s %s\n" "$label" "$value" "$unit"
@@ -442,8 +454,8 @@ for line in sys.stdin:
442
454
  parts = line.strip().split(None, 2)
443
455
  if len(parts) >= 2:
444
456
  metric = parts[0]
445
- if parts[1] == 'ERROR':
446
- results[metric] = {'value': None, 'unit': 'ERROR'}
457
+ if parts[1] in ('ERROR', 'SKIPPED'):
458
+ results[metric] = {'value': None, 'unit': parts[1]}
447
459
  continue
448
460
  value = float(parts[1])
449
461
  unit = parts[2] if len(parts) > 2 else ''
@@ -476,8 +488,8 @@ try:
476
488
  val = data['value']
477
489
  unit = data.get('unit', '')
478
490
  # Handle ERROR metrics saved with None value
479
- if val is None or unit == 'ERROR':
480
- print(f'{metric} ERROR {unit}')
491
+ if val is None or unit in ('ERROR', 'SKIPPED'):
492
+ print(f'{metric} {unit} {unit}')
481
493
  else:
482
494
  print(f'{metric} {val} {unit}')
483
495
  else:
@@ -523,16 +535,16 @@ display_comparison() {
523
535
  *) label="$metric" ;;
524
536
  esac
525
537
 
526
- # Handle ERROR in current value — show N/A instead of bogus computation
527
- if [[ "$value" == "ERROR" ]]; then
538
+ # Handle SKIPPED or ERROR in current value — show N/A instead of bogus computation
539
+ if [[ "$value" == "SKIPPED" || "$value" == "ERROR" ]]; then
528
540
  local bval="${baseline_vals[$metric]:-N/A}"
529
541
  printf " %-25s %12s %12s %12s %8s\n" "$label" "$bval" "N/A" "N/A" "N/A"
530
542
  continue
531
543
  fi
532
544
 
533
545
  local bval="${baseline_vals[$metric]:-}"
534
- # Handle missing or ERROR baseline — show N/A for comparison
535
- if [[ -z "$bval" || "$bval" == "None" || "$bval" == "ERROR" ]]; then
546
+ # Handle missing, ERROR, or SKIPPED baseline — show N/A for comparison
547
+ if [[ -z "$bval" || "$bval" == "None" || "$bval" == "ERROR" || "$bval" == "SKIPPED" ]]; then
536
548
  printf " %-25s %12s %12s %12s %8s\n" "$label" "N/A" "$value" "N/A" "N/A"
537
549
  continue
538
550
  fi
@@ -1,3 +1,3 @@
1
1
  """tunectl — VPS Performance Tuning Toolkit."""
2
2
 
3
- __version__ = "1.0.0"
3
+ __version__ = "1.1.0"
package/tunectl/cli.py CHANGED
@@ -18,6 +18,7 @@ import subprocess
18
18
  import sys
19
19
 
20
20
  from tunectl import __version__
21
+ from tunectl.ui import print_banner, Pipeline
21
22
 
22
23
  # Valid tier choices for plan/apply
23
24
  VALID_TIERS = ("conservative", "balanced", "aggressive")
@@ -129,7 +130,8 @@ def _format_discover_output(data: dict) -> str:
129
130
  ("Virt type", data.get("virt_type", "unknown")),
130
131
  ]
131
132
  for label, value in info_fields:
132
- lines.append(f" {_cyan(label + ':'):>30s} {value}")
133
+ padded = f"{label + ':':>28}"
134
+ lines.append(f" {_cyan(padded)} {value}")
133
135
 
134
136
  # --- Swap status ---
135
137
  lines.append("")
@@ -141,11 +143,15 @@ def _format_discover_output(data: dict) -> str:
141
143
  swap_total = data.get("swap_total_mb", 0)
142
144
 
143
145
  if swap_configured:
144
- lines.append(f" {_cyan('Configured:'):>30s} {_green('yes')}")
145
- lines.append(f" {_cyan('Type:'):>30s} {swap_type}")
146
- lines.append(f" {_cyan('Total:'):>30s} {swap_total} MB")
146
+ padded = f"{'Configured:':>28}"
147
+ lines.append(f" {_cyan(padded)} {_green('yes')}")
148
+ padded = f"{'Type:':>28}"
149
+ lines.append(f" {_cyan(padded)} {swap_type}")
150
+ padded = f"{'Total:':>28}"
151
+ lines.append(f" {_cyan(padded)} {swap_total} MB")
147
152
  else:
148
- lines.append(f" {_cyan('Configured:'):>30s} {_yellow('no')}")
153
+ padded = f"{'Configured:':>28}"
154
+ lines.append(f" {_cyan(padded)} {_yellow('no')}")
149
155
 
150
156
  # --- Key sysctl values ---
151
157
  sysctl = data.get("sysctl_values", {})
@@ -173,7 +179,8 @@ def _format_discover_output(data: dict) -> str:
173
179
  ]
174
180
  for param in key_params:
175
181
  if param in sysctl:
176
- lines.append(f" {_cyan(param + ':'):>48s} {sysctl[param]}")
182
+ padded = f"{param + ':':>42}"
183
+ lines.append(f" {_cyan(padded)} {sysctl[param]}")
177
184
 
178
185
  # --- Mount options ---
179
186
  mount_opts = data.get("mount_options", "")
@@ -186,6 +193,88 @@ def _format_discover_output(data: dict) -> str:
186
193
  return "\n".join(lines)
187
194
 
188
195
 
196
+ # -------------------------------------------------------
197
+ # Interactive tier selection
198
+ # -------------------------------------------------------
199
+
200
+ # Entry counts per tier (from tune-manifest.json tiered filtering)
201
+ _TIER_INFO = {
202
+ "conservative": {"entries": 51, "desc": "safe, minimal changes"},
203
+ "balanced": {"entries": 80, "desc": "recommended balance"},
204
+ "aggressive": {"entries": 86, "desc": "maximum performance"},
205
+ }
206
+
207
+
208
+ def _detect_ram_mb() -> int:
209
+ """Run discover.sh and extract RAM in MB from the JSON output."""
210
+ rc, stdout, _stderr = _run_script("discover.sh", capture=True)
211
+ if rc != 0:
212
+ return 0
213
+ try:
214
+ data = json.loads(stdout)
215
+ return int(data.get("ram_mb", 0))
216
+ except (json.JSONDecodeError, ValueError, TypeError):
217
+ return 0
218
+
219
+
220
+ def _recommend_tier(ram_mb: int) -> str:
221
+ """Return the recommended tier name based on system RAM."""
222
+ if ram_mb < 2048:
223
+ return "conservative"
224
+ elif ram_mb <= 8192:
225
+ return "balanced"
226
+ else:
227
+ return "aggressive"
228
+
229
+
230
+ def _select_tier_interactive() -> str:
231
+ """Auto-discover system RAM and present an interactive tier menu.
232
+
233
+ Returns the selected tier string. Uses only ``input()`` for prompts
234
+ (no external dependencies).
235
+ """
236
+ ram_mb = _detect_ram_mb()
237
+ recommended = _recommend_tier(ram_mb)
238
+
239
+ # Map tier names to a stable ordered list
240
+ tier_order = ["conservative", "balanced", "aggressive"]
241
+ rec_idx = tier_order.index(recommended) # 0-based
242
+ default_choice = rec_idx + 1 # 1-based for display
243
+
244
+ # Header
245
+ if ram_mb > 0:
246
+ print(f"\nSystem RAM: {ram_mb} MB\n")
247
+ else:
248
+ print()
249
+
250
+ print("Select tuning tier:")
251
+ for i, tier in enumerate(tier_order, start=1):
252
+ info = _TIER_INFO[tier]
253
+ label = f"{tier:14s} ({info['entries']} entries - {info['desc']})"
254
+ if tier == recommended:
255
+ label = f"{tier:14s} ({info['entries']} entries - recommended for this system)"
256
+ print(f" {i}) {label}")
257
+
258
+ # Prompt
259
+ print()
260
+ raw = input(f"Enter choice [1-3] (default: {default_choice}): ").strip()
261
+
262
+ if raw == "":
263
+ chosen = default_choice
264
+ else:
265
+ try:
266
+ chosen = int(raw)
267
+ except ValueError:
268
+ chosen = default_choice
269
+
270
+ if chosen < 1 or chosen > 3:
271
+ chosen = default_choice
272
+
273
+ selected = tier_order[chosen - 1]
274
+ print(f"\n→ Selected tier: {selected}\n")
275
+ return selected
276
+
277
+
189
278
  # -------------------------------------------------------
190
279
  # Subcommand handlers
191
280
  # -------------------------------------------------------
@@ -194,40 +283,60 @@ def cmd_discover(args: argparse.Namespace) -> int:
194
283
  """Run discover.sh and show formatted system info."""
195
284
  rc, stdout, stderr = _run_script("discover.sh", capture=True)
196
285
 
197
- if rc != 0:
198
- # Pass through errors from the script
199
- if stderr:
200
- print(stderr, end="", file=sys.stderr)
201
- if stdout:
202
- print(stdout, end="")
203
- return rc
204
-
205
- # --json: emit raw JSON
286
+ # --json: emit raw JSON (no banner/pipeline)
206
287
  if getattr(args, "json", False):
288
+ if rc != 0:
289
+ if stderr:
290
+ print(stderr, end="", file=sys.stderr)
207
291
  print(stdout, end="")
208
- return 0
292
+ return rc
209
293
 
210
- # Parse JSON and render formatted summary
211
- try:
212
- data = json.loads(stdout)
213
- except json.JSONDecodeError as exc:
214
- print(f"Error: Failed to parse discover output: {exc}",
215
- file=sys.stderr)
216
- # Fall back to raw output so the user still sees something
217
- print(stdout, end="")
218
- return 1
294
+ with Pipeline("Discover") as p:
295
+ if rc != 0:
296
+ if stderr:
297
+ p.fail(stderr.strip().split("\n")[0])
298
+ return rc
299
+
300
+ try:
301
+ data = json.loads(stdout)
302
+ except json.JSONDecodeError as exc:
303
+ p.fail(f"Failed to parse discover output: {exc}")
304
+ print(stdout, end="")
305
+ return 1
306
+
307
+ p.step(f"OS: {data.get('os_version', 'unknown')}")
308
+ p.step(f"Kernel: {data.get('kernel_version', 'unknown')}")
309
+ p.step(f"RAM: {data.get('ram_mb', 0)} MB ({data.get('cpu_count', 0)} CPUs)")
310
+
311
+ swap = data.get("swap_configured", False)
312
+ if swap:
313
+ p.step(f"Swap: {data.get('swap_type', 'unknown')} ({data.get('swap_total_mb', 0)} MB)")
314
+ else:
315
+ p.step("Swap: not configured")
316
+
317
+ p.step(f"Disk: {data.get('disk_type', 'unknown')} ({data.get('virt_type', 'unknown')})")
318
+ p.ok("Discovery complete")
219
319
 
220
- print(_format_discover_output(data))
221
320
  return 0
222
321
 
223
322
 
224
323
  def cmd_plan(args: argparse.Namespace) -> int:
225
324
  """Run tune.sh in dry-run mode for the specified tier."""
325
+ if args.tier is None:
326
+ args.tier = _select_tier_interactive()
327
+ with Pipeline(f"Plan ({args.tier})") as p:
328
+ p.step(f"Previewing {args.tier} tier changes...")
329
+ print()
226
330
  return _run_script("tune.sh", ["--dry-run", "--tier", args.tier])
227
331
 
228
332
 
229
333
  def cmd_apply(args: argparse.Namespace) -> int:
230
334
  """Run tune.sh in apply mode for the specified tier."""
335
+ if args.tier is None:
336
+ args.tier = _select_tier_interactive()
337
+ with Pipeline(f"Apply ({args.tier})") as p:
338
+ p.step(f"Applying {args.tier} tier tuning...")
339
+ print()
231
340
  return _run_script("tune.sh", ["--apply", "--tier", args.tier])
232
341
 
233
342
 
@@ -236,13 +345,26 @@ def cmd_rollback(args: argparse.Namespace) -> int:
236
345
  script_args: list[str] = []
237
346
  if args.list:
238
347
  script_args.append("--list")
348
+ with Pipeline("Rollback (list)") as p:
349
+ p.step("Listing available backups...")
350
+ print()
239
351
  elif args.backup:
240
352
  script_args.extend(["--backup", args.backup])
353
+ with Pipeline(f"Rollback ({args.backup})") as p:
354
+ p.step(f"Restoring from backup {args.backup}...")
355
+ print()
356
+ else:
357
+ with Pipeline("Rollback (latest)") as p:
358
+ p.step("Restoring from most recent backup...")
359
+ print()
241
360
  return _run_script("rollback.sh", script_args)
242
361
 
243
362
 
244
363
  def cmd_audit(args: argparse.Namespace) -> int:
245
364
  """Run audit.sh to verify applied tuning."""
365
+ with Pipeline("Audit") as p:
366
+ p.step("Verifying 86 manifest entries against live system...")
367
+ print()
246
368
  return _run_script("audit.sh")
247
369
 
248
370
 
@@ -251,8 +373,15 @@ def cmd_benchmark(args: argparse.Namespace) -> int:
251
373
  script_args: list[str] = []
252
374
  if args.baseline:
253
375
  script_args.append("--baseline")
376
+ label = "Benchmark (baseline)"
254
377
  elif args.compare:
255
378
  script_args.append("--compare")
379
+ label = "Benchmark (compare)"
380
+ else:
381
+ label = "Benchmark"
382
+ with Pipeline(label) as p:
383
+ p.step("Running sysbench + fio benchmarks...")
384
+ print()
256
385
  return _run_script("benchmark.sh", script_args)
257
386
 
258
387
 
@@ -327,9 +456,10 @@ def build_parser() -> argparse.ArgumentParser:
327
456
  sub_plan.add_argument(
328
457
  "--tier",
329
458
  type=_validate_tier,
330
- required=True,
459
+ required=False,
460
+ default=None,
331
461
  metavar="TIER",
332
- help="Tuning tier: conservative, balanced, or aggressive",
462
+ help="Tuning tier: conservative, balanced, or aggressive (interactive if omitted)",
333
463
  )
334
464
  sub_plan.set_defaults(func=cmd_plan)
335
465
 
@@ -345,9 +475,10 @@ def build_parser() -> argparse.ArgumentParser:
345
475
  sub_apply.add_argument(
346
476
  "--tier",
347
477
  type=_validate_tier,
348
- required=True,
478
+ required=False,
479
+ default=None,
349
480
  metavar="TIER",
350
- help="Tuning tier: conservative, balanced, or aggressive",
481
+ help="Tuning tier: conservative, balanced, or aggressive (interactive if omitted)",
351
482
  )
352
483
  sub_apply.set_defaults(func=cmd_apply)
353
484
 
@@ -422,6 +553,7 @@ def main() -> None:
422
553
  args = parser.parse_args()
423
554
 
424
555
  if args.command is None:
556
+ print_banner()
425
557
  parser.print_help()
426
558
  sys.exit(2)
427
559
 
package/tunectl/ui.py ADDED
@@ -0,0 +1,282 @@
1
+ """tunectl UI — Unicode banner and pipeline output in the opencode style.
2
+
3
+ Provides:
4
+ - print_banner() — block-letter "tunectl" banner with two-tone coloring
5
+ - Pipeline context — ┌/│/●/◇/▲/└ vertical status output
6
+
7
+ Style reference: opencode CLI (https://github.com/nicholasgriffintn/opencode)
8
+ - First half "tune" rendered in dim gray (ANSI 90)
9
+ - Second half "ctl" rendered in bright white (ANSI 0/reset)
10
+ - Hollow letter interiors use 256-color backgrounds (235/238)
11
+ - Shadow accents on bottom row use fg 235
12
+ - Pipeline uses box-drawing characters with double-space indent
13
+
14
+ Letter alphabet (4-wide cells, 4 rows each):
15
+ Row 0 = top accent (only special letters like 'l' use ▄)
16
+ Row 1 = upper body (█▀▀█, █▀▀▄, █▀▀▀, etc.)
17
+ Row 2 = middle body (█ █, █▀▀▀, █ , etc.) — hollows get bg fill
18
+ Row 3 = bottom (▀▀▀▀, █▀▀▀, etc.)
19
+ """
20
+
21
+ import sys
22
+
23
+ # ---------------------------------------------------------------------------
24
+ # ANSI helpers
25
+ # ---------------------------------------------------------------------------
26
+
27
+ def _use_color() -> bool:
28
+ return sys.stdout.isatty()
29
+
30
+
31
+ def _esc(code: str) -> str:
32
+ return f"\033[{code}m" if _use_color() else ""
33
+
34
+
35
+ R = property(lambda self: _esc("0")) # reset
36
+ DIM = property(lambda self: _esc("90")) # dim gray
37
+
38
+ # We use module-level functions so the color check happens at call time.
39
+
40
+ def _r():
41
+ return _esc("0")
42
+
43
+ def _d():
44
+ return _esc("90")
45
+
46
+ def _bg_dim():
47
+ return _esc("48;5;235")
48
+
49
+ def _bg_bright():
50
+ return _esc("48;5;238")
51
+
52
+ def _fg_shadow():
53
+ return _esc("38;5;235")
54
+
55
+ def _green():
56
+ return _esc("32")
57
+
58
+ def _yellow():
59
+ return _esc("33")
60
+
61
+ def _red():
62
+ return _esc("31")
63
+
64
+ def _cyan():
65
+ return _esc("36")
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # Block-letter alphabet (4 cols x 4 rows per glyph)
70
+ #
71
+ # Each letter is a tuple of 4 strings. Characters:
72
+ # █ = U+2588 full block ▀ = U+2580 upper half
73
+ # ▄ = U+2584 lower half ' ' = space (hollow interior)
74
+ #
75
+ # Row 0 is the top-accent row (usually blank).
76
+ # Hollow interiors (spaces inside row 1-2) get background-color fills
77
+ # at render time — the alphabet stores plain spaces.
78
+ # ---------------------------------------------------------------------------
79
+
80
+ LETTERS = {
81
+ "t": (
82
+ " ",
83
+ "█▀▀█",
84
+ "▀██▀",
85
+ " ▀▀ ",
86
+ ),
87
+ "u": (
88
+ " ",
89
+ "█ █",
90
+ "█ █",
91
+ "▀▀▀█",
92
+ ),
93
+ "n": (
94
+ " ",
95
+ "█▀▀▄",
96
+ "█ █",
97
+ "▀ ▀",
98
+ ),
99
+ "e": (
100
+ " ",
101
+ "█▀▀█",
102
+ "█▀▀▀",
103
+ "▀▀▀▀",
104
+ ),
105
+ "c": (
106
+ " ",
107
+ "█▀▀▀",
108
+ "█ ",
109
+ "▀▀▀▀",
110
+ ),
111
+ "l": (
112
+ " ▄",
113
+ "█▀▀█",
114
+ "█ █",
115
+ "▀▀▀▀",
116
+ ),
117
+ }
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Banner renderer
121
+ # ---------------------------------------------------------------------------
122
+
123
+ # "tune" = dim half, "ctl" = bright half
124
+ _DIM_LETTERS = "tune"
125
+ _BRIGHT_LETTERS = "ctl"
126
+ _WORD = _DIM_LETTERS + _BRIGHT_LETTERS # "tunectl"
127
+
128
+
129
+ def _render_char(ch: str, fg_fn, bg_fn, shadow_fn, row: int) -> str:
130
+ """Render one character of a letter cell with proper ANSI coloring.
131
+
132
+ fg_fn — returns the foreground escape for solid glyphs (█ ▀ ▄)
133
+ bg_fn — returns the background escape for hollow interiors (spaces)
134
+ shadow_fn — returns the fg escape for shadow accents on the bottom row
135
+ row — which row (0-3) we are rendering
136
+ """
137
+ r = _r()
138
+
139
+ if ch == " ":
140
+ # Hollow interior — fill with background color (rows 1-2 only)
141
+ if row in (1, 2):
142
+ return f"{bg_fn()}{ch}{r}"
143
+ return ch
144
+
145
+ if ch == "▀" and row == 3:
146
+ # Bottom row: check if this is an interior accent (shadow)
147
+ # We handle this at a higher level; default to fg
148
+ return f"{fg_fn()}{ch}{r}"
149
+
150
+ # Solid glyph
151
+ return f"{fg_fn()}{ch}{r}"
152
+
153
+
154
+ def _render_row(row_idx: int) -> str:
155
+ """Render one row of the full 'tunectl' banner."""
156
+ r = _r()
157
+ parts = []
158
+
159
+ for i, letter_ch in enumerate(_WORD):
160
+ glyph = LETTERS[letter_ch]
161
+ row_str = glyph[row_idx]
162
+
163
+ if letter_ch in _DIM_LETTERS and _WORD.index(letter_ch) == i and i < len(_DIM_LETTERS):
164
+ fg_fn = _d
165
+ bg_fn = _bg_dim
166
+ shadow_fn = _fg_shadow
167
+ elif i >= len(_DIM_LETTERS):
168
+ fg_fn = _r
169
+ bg_fn = _bg_bright
170
+ shadow_fn = _r
171
+ else:
172
+ # Duplicate letter in dim section
173
+ fg_fn = _d
174
+ bg_fn = _bg_dim
175
+ shadow_fn = _fg_shadow
176
+
177
+ cell = ""
178
+ for j, ch in enumerate(row_str):
179
+ if ch == " " and row_idx in (1, 2):
180
+ cell += f"{bg_fn()}{ch}{_r()}"
181
+ elif ch == "▀" and row_idx == 3:
182
+ # Shadow: inner chars of dim-half bottom row get subtle shadow
183
+ if fg_fn == _d and 0 < j < 3:
184
+ cell += f"{shadow_fn()}{ch}{_r()}"
185
+ else:
186
+ cell += f"{fg_fn()}{ch}{_r()}"
187
+ else:
188
+ if ch == " ":
189
+ cell += ch
190
+ else:
191
+ cell += f"{fg_fn()}{ch}{_r()}"
192
+
193
+ parts.append(cell)
194
+
195
+ return " " + " ".join(parts)
196
+
197
+
198
+ def banner() -> str:
199
+ """Return the full tunectl banner string (4 rows + surrounding newlines)."""
200
+ lines = [""]
201
+ for row_idx in range(4):
202
+ lines.append(_render_row(row_idx))
203
+ lines.append("")
204
+ return "\n".join(lines)
205
+
206
+
207
+ def print_banner() -> None:
208
+ """Print the tunectl banner to stdout."""
209
+ print(banner())
210
+
211
+
212
+ # ---------------------------------------------------------------------------
213
+ # Pipeline output (┌ │ ● ◇ ▲ └)
214
+ # ---------------------------------------------------------------------------
215
+
216
+ _SYM_START = "\u250C" # ┌
217
+ _SYM_PIPE = "\u2502" # │
218
+ _SYM_DOT = "\u25CF" # ●
219
+ _SYM_OK = "\u25C7" # ◇
220
+ _SYM_WARN = "\u25B2" # ▲
221
+ _SYM_END = "\u2514" # └
222
+
223
+
224
+ class Pipeline:
225
+ """Context-manager for styled vertical pipeline output.
226
+
227
+ Usage:
228
+ with Pipeline("Discover") as p:
229
+ p.step("OS: Ubuntu 24.04 LTS")
230
+ p.step("Kernel: 6.8.0-101-generic")
231
+ p.ok("Discovery complete")
232
+ """
233
+
234
+ def __init__(self, title: str, show_banner: bool = True):
235
+ self.title = title
236
+ self.show_banner = show_banner
237
+ self._started = False
238
+
239
+ def __enter__(self):
240
+ if self.show_banner:
241
+ print_banner()
242
+ print(f"{_SYM_START} {self.title}")
243
+ self._started = True
244
+ return self
245
+
246
+ def __exit__(self, exc_type, exc_val, exc_tb):
247
+ if exc_type is not None:
248
+ self.fail(f"Error: {exc_val}")
249
+ self._end()
250
+ return False
251
+
252
+ def _line(self):
253
+ print(_SYM_PIPE)
254
+
255
+ def step(self, msg: str) -> None:
256
+ self._line()
257
+ c = _cyan() if _use_color() else ""
258
+ r = _r() if _use_color() else ""
259
+ print(f"{_SYM_DOT} {msg}")
260
+
261
+ def ok(self, msg: str) -> None:
262
+ self._line()
263
+ g = _green() if _use_color() else ""
264
+ r = _r() if _use_color() else ""
265
+ print(f"{g}{_SYM_OK}{r} {msg}")
266
+
267
+ def warn(self, msg: str) -> None:
268
+ self._line()
269
+ y = _yellow() if _use_color() else ""
270
+ r = _r() if _use_color() else ""
271
+ print(f"{y}{_SYM_WARN}{r} {msg}")
272
+
273
+ def fail(self, msg: str) -> None:
274
+ self._line()
275
+ rd = _red() if _use_color() else ""
276
+ r = _r() if _use_color() else ""
277
+ print(f"{rd}{_SYM_DOT}{r} {msg}")
278
+
279
+ def _end(self) -> None:
280
+ self._line()
281
+ print(f"{_SYM_END} Done")
282
+ print()