-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathyolo.rugo
More file actions
1872 lines (1739 loc) · 53.8 KB
/
Copy pathyolo.rugo
File metadata and controls
1872 lines (1739 loc) · 53.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# yolo.rugo — fast persistent matchlock VMs, one per directory.
#
# Usage:
# yolo Attach (or create) the VM for $PWD; drop into bash.
# yolo -- CMD ARGS... Run CMD inside that VM (auto-create if needed).
# yolo -n NAME [...] Use a named VM instead of the per-CWD one.
# yolo ls List all yolo-tracked VMs with live status.
# yolo stop [-n NAME] Stop the VM (state preserved on disk).
# yolo rm [-n NAME] Stop + remove the VM and its name binding.
# yolo logs [-n NAME] Show the VM's log.
# yolo id [-n NAME] Print the vm-id only (for scripting).
# yolo status [-n NAME] Print "<state> (<vm-id>)".
# yolo prune Drop dead name bindings (VM gone).
#
# Env tweaks:
# YOLO_IMAGE (default: fedora:44)
# YOLO_ALLOW (default: *) comma list of allowed hostnames
# YOLO_WORKSPACE (default: /work) guest mount point for $PWD
# YOLO_NAME (default: cwd-<sha1>) override the auto-derived name
# YOLO_USER (default: unset) pass --user to matchlock
use "os"
use "str"
use "color"
use "crypto"
use "fmt"
use "filepath"
use "conv"
use "time"
# ---------------- Backends ----------------
# Each backend lives at `backends/<name>.rugo` and exposes a `make()` factory
# returning a handle (hash of constants + closure-valued methods). The
# contract is documented in backends/INTERFACE.md.
#
# Adding a new backend: drop a `backends/<name>.rugo` file, then add it to
# both the `require` line below and the BACKENDS registry.
require "backends" with matchlock, podman
# CLI parsing + help text and Yolofile parsing / front-matter validation
# both live in sibling modules to keep this file from sprawling further.
# They're pure (no state) — yolo.rugo's main block passes in the bits of
# context they need (BACKENDS, AI_AGENTS, VERSION) since Rugo modules can
# only see what they require, never the other direction.
require "cli"
require "yolofile"
BACKENDS = {
"matchlock" => matchlock.make(),
"podman" => podman.make()
}
DEFAULT_BACKEND = "matchlock"
# ---------------- Embedded provisioners ----------------
# Each provisioner is a plain bash script that runs INSIDE the guest VM as
# root, piped through the backend's exec_provision channel. Drop additional
# ones at provisioners/provisioner-<name>.sh and embed them here.
embed "provisioners/provisioner-fedora-go.sh" as prov_fedora_go
embed "provisioners/provisioner-fedora-rust.sh" as prov_fedora_rust
embed "provisioners/provisioner-fedora-ruby.sh" as prov_fedora_ruby
embed "provisioners/provisioner-fedora-android.sh" as prov_fedora_android
PROVISIONERS = {
"fedora-go" => prov_fedora_go,
"fedora-rust" => prov_fedora_rust,
"fedora-ruby" => prov_fedora_ruby,
"fedora-android" => prov_fedora_android
}
# No hard default — when no provisioner is explicitly requested and no
# Yolofile exists, we sniff $PWD for language signals (see detect_provisioner).
# An empty result means "skip provisioning".
DEFAULT_PROVISIONER = ""
# ---------------- AI agent installers ----------------
# Layered on top of any language provisioner via the --ai-agent flag.
# Each is a plain bash script that runs as root in the guest.
embed "provisioners/ai-agents/copilot.sh" as agent_copilot
embed "provisioners/ai-agents/opencode.sh" as agent_opencode
AI_AGENTS = {
"copilot" => agent_copilot,
"opencode" => agent_opencode
}
DEFAULT_AI_AGENT = "opencode"
# ---------------- Embedded skills ----------------
# Agent skills shipped with yolo. `yolo install-skills` writes each into
# ~/.agents/skills/<name>/SKILL.md so AI coding agents can load them. Add new
# ones under skills/<name>/SKILL.md and embed them here.
embed "skills/yolo-user/SKILL.md" as skill_yolo_user
embed "skills/yolofile-author/SKILL.md" as skill_yolofile_author
embed "skills/yolo-developer/SKILL.md" as skill_yolo_developer
SKILLS = {
"yolo-user" => skill_yolo_user,
"yolofile-author" => skill_yolofile_author,
"yolo-developer" => skill_yolo_developer
}
# ---------------- Config ----------------
# Bump this on every release tag (also bumped by hand on `main` after a
# release so `main` builds advertise a `-dev` suffix).
VERSION = "0.2.1"
def env_or(name, fallback)
v = os.getenv(name)
if v == ""
return fallback
end
return v
end
IMAGE = env_or("YOLO_IMAGE", "")
# Setting YOLO_ALLOW enables matchlock's MITM proxy and restricts egress to the
# listed hosts. Leave empty (default) for plain NAT — the guest gets real
# Internet cert chains, so dnf/curl/go-install work out of the box. Once you
# set this you'll also need to trust matchlock's per-VM CA in the guest (or
# use --no-check-cert flags), since MITM rewrites every TLS cert chain.
ALLOW = env_or("YOLO_ALLOW", "")
WORKSPACE = env_or("YOLO_WORKSPACE", "/work")
# Resource defaults — bigger than matchlock's stock 1 CPU / 512 MB / 5 GB,
# because building Go dev tools (or anything similar) on the stock allocation
# runs out of disk and is painfully slow. Override per env var if needed.
CPUS = env_or("YOLO_CPUS", "2")
MEM_MB = env_or("YOLO_MEM_MB", "2048")
DISK_MB = env_or("YOLO_DISK_MB", "32768")
RUNTIME = env_or("XDG_RUNTIME_DIR", "/tmp")
STATE_DIR = filepath.join(RUNTIME, "yolo")
USER_FLAG = env_or("YOLO_USER", "")
BACKEND_ENV = env_or("YOLO_BACKEND", "")
# Mutable per-invocation overrides for the VM-creation resource settings.
# Initialized to the env-var-or-default constants above. Yolofile front matter
# (parsed in main for vm-creating subcommands) and per-flag CLI overrides
# (e.g. `--disk-size`) replace these before the first backend `start()` call.
disk_mb = DISK_MB
mem_mb = MEM_MB
cpus = CPUS
image = IMAGE
# Mutable backend selection. Empty means "let resolve_backend_name() fall back
# through the cascade". Set by --backend / YOLO_BACKEND / Yolofile front matter
# before any vm-creating subcommand runs. Sticky bindings (<name>.backend) take
# precedence over this in resolve_backend_name().
backend_sel = BACKEND_ENV
# GUI mode requested for this invocation. Honoured by backends where
# SUPPORTS_GUI is true; refused at the CLI layer otherwise.
gui_enabled = false
# Audio passthrough requested for this invocation. Honoured by backends
# where SUPPORTS_AUDIO is true; refused at the CLI layer otherwise.
audio_enabled = false
# Host directory mounted at WORKSPACE. Normal runs mount the invoking cwd;
# --ephemeral replaces this with a fresh temporary directory.
host_workspace = os.cwd()
# Optional explicit Yolofile source, set by --yolofile. May be a local path or
# an https:// URL; resolved once in main before provisioning/resource parsing.
yolofile_override = ""
yolofile_override_display = ""
yolofile_override_content = ""
# Ephemeral runs use generated state names and are removed when attach exits.
ephemeral_mode = false
ephemeral_workspace = ""
mkdir -p "#{STATE_DIR}"
# ---------------- Logging ----------------
def log(msg)
prefix = color.dim("[yolo]")
puts "#{prefix} #{msg}"
end
def warn(msg)
prefix = color.dim("[yolo]")
puts "#{prefix} #{color.yellow(msg)}"
end
def err_(msg)
prefix = color.dim("[yolo]")
puts "#{prefix} #{color.red(msg)}"
end
# ---------------- Backend dispatch ----------------
# Every per-name state file has a sibling <name>.backend recording which
# backend owns it. read_backend(name) falls back to DEFAULT_BACKEND so
# pre-existing yolo state (no .backend file) keeps working as matchlock.
def backend_file(name)
return filepath.join(STATE_DIR, "#{name}.backend")
end
def read_backend(name)
path = backend_file(name)
if !os.file_exists(path)
return ""
end
return str.trim(os.read_file(path))
end
def write_backend(name, b)
os.write_file(backend_file(name), b + "\n")
end
def remove_backend_file(name)
path = backend_file(name)
if os.file_exists(path)
os.remove(path)
end
end
# Selection cascade (highest wins):
# 1. <name>.backend if already recorded (sticky once a VM has been created)
# 2. --backend CLI flag (parsed.backend, mutable global)
# 3. YOLO_BACKEND env var
# 4. Yolofile front matter `backend:` (applied to global `backend_sel`
# from main, like cpus/mem_mb/etc.)
# 5. DEFAULT_BACKEND
def resolve_backend_name(name)
existing = read_backend(name)
if existing != ""
if BACKENDS[existing] == nil
err_("name '#{name}' was created with unknown backend '#{existing}' — install it or `yolo rm -n #{name}`")
os.exit(1)
end
return existing
end
if backend_sel != ""
if BACKENDS[backend_sel] == nil
err_("unknown backend '#{backend_sel}' (known: #{known_backends()})")
os.exit(2)
end
return backend_sel
end
return DEFAULT_BACKEND
end
def backend_for(name)
return BACKENDS[resolve_backend_name(name)]
end
def known_backends
names = []
for n in BACKENDS
names = append(names, n)
end
return str.join(names, ", ")
end
def vm_status(id, table)
if id == "" || id == nil
return ""
end
row = table[id]
if row == nil
return ""
end
return row[0]
end
# POSIX shell-quote: wrap in single quotes; escape internal `'` as `'\''`.
# Lossless under bash word-splitting — the original string round-trips.
def shell_quote(s)
return "'" + str.replace(s, "'", "'\\''") + "'"
end
# Quote each element of an argv and join with spaces. The result is a bash
# command line that, when re-parsed by bash, yields the original argv.
def shell_join(args)
parts = []
for a in args
parts = append(parts, shell_quote(a))
end
return str.join(parts, " ")
end
def safe_ephemeral_name
seed = "#{os.cwd()}:#{os.pid()}:#{time.now()}"
h = crypto.sha1(seed)
return "tmp-" + str.slice(h, 0, 12)
end
def make_temp_workspace
base = os.tmp_dir()
seed = "#{os.pid()}-#{str.slice(crypto.sha1(os.cwd() + conv.to_s(time.now())), 0, 8)}"
p = filepath.join(base, "yolo-ephemeral-#{seed}")
made = try `bash -c "mkdir -p #{shell_quote(p)} && echo OK"` or ""
if str.trim(made) != "OK"
err_("failed to create ephemeral workspace at #{p}")
os.exit(1)
end
return p
end
def cleanup_ephemeral(name)
if ephemeral_mode != true
return
end
log("cleaning up ephemeral VM #{color.bold(name)}")
cmd_rm(name)
if ephemeral_workspace != ""
try `bash -c "rm -rf #{shell_quote(ephemeral_workspace)}"` or nil
end
end
def release_and_exit(name, code)
release_lock(name)
cleanup_ephemeral(name)
os.exit(code)
end
def read_yolofile_source(src)
if src == ""
return {display: "", content: "", err: ""}
end
if str.starts_with(src, "http://")
return {display: src, content: "", err: "Yolofile URLs must use https://"}
end
if str.starts_with(src, "https://")
cmd = "curl -fsSL --proto '=https' --tlsv1.2 " + shell_quote(src)
body = try os.exec(cmd) or msg
return {display: src, content: "", err: "failed to fetch #{src}: #{msg}"}
end
return {display: src, content: body, err: ""}
end
p = src
if !str.starts_with(p, "/")
p = filepath.join(os.cwd(), p)
end
if !yolofile.exists_at(p)
return {display: p, content: "", err: "Yolofile not found: #{p}"}
end
return {display: p, content: os.read_file(p), err: ""}
end
def active_yolofile_exists
if yolofile_override != ""
return true
end
if ephemeral_mode == true
return false
end
return yolofile.exists()
end
def active_yolofile_display
if yolofile_override != ""
return yolofile_override_display
end
return yolofile.path()
end
def parse_active_yolofile
if yolofile_override != ""
return yolofile.parse_content(yolofile_override_content)
end
return yolofile.read_and_parse()
end
# Start a fresh VM bound to $name. Returns the new vm-id.
def start_vm(name)
be = backend_for(name)
# Refuse incompatible combinations before touching state or logging.
if gui_enabled && be.SUPPORTS_GUI == false
err_("backend '#{be.NAME}' does not support GUI mode (use --backend podman)")
os.exit(2)
end
if audio_enabled && be.SUPPORTS_AUDIO == false
err_("backend '#{be.NAME}' does not support audio passthrough (use --backend podman)")
os.exit(2)
end
# Per-name image pin (from `yolo import`) overrides the global default so
# an imported VM boots from its custom OCI image instead of the backend
# default. If neither is set, use the global `image` (which may itself
# have been set from a Yolofile / env / default).
img = read_image(name)
if img == ""
img = image
end
if img == ""
img = be.DEFAULT_IMAGE
end
log("starting #{color.bold(img)} VM #{color.bold(name)} for #{host_workspace} [#{be.NAME}]")
opts = {
"name" => name,
"image" => img,
"cpus" => cpus,
"mem_mb" => mem_mb,
"disk_mb" => disk_mb,
"workspace" => WORKSPACE,
"cwd" => host_workspace,
"user" => USER_FLAG,
"allow" => ALLOW,
"gui" => gui_enabled,
"audio" => audio_enabled
}
id = be.start(opts)
os.write_file(state_file(name), id)
write_backend(name, be.NAME)
record_cwd(name)
# If an imported `.applied` file is waiting with the __IMPORTED__ sentinel,
# rewrite its first line to the new vm-id so the markers stick to this VM
# and provisioners skip re-running (their effects are already in the image).
rebind_imported_applied(name, id)
be.wait_ready(id)
return id
end
def state_file(name)
return filepath.join(STATE_DIR, "#{name}.vmid")
end
def applied_file(name)
return filepath.join(STATE_DIR, "#{name}.applied")
end
def cwd_file(name)
return filepath.join(STATE_DIR, "#{name}.cwd")
end
# Per-name image pin (set by `yolo import`). When present, start_vm uses this
# image ref instead of $YOLO_IMAGE / fedora:44 — this is how an imported VM's
# rootfs modifications survive: the rootfs is registered as a matchlock OCI
# image and pinned here.
def image_file(name)
return filepath.join(STATE_DIR, "#{name}.image")
end
def read_image(name)
path = image_file(name)
if !os.file_exists(path)
return ""
end
return str.trim(os.read_file(path))
end
def write_image(name, tag)
os.write_file(image_file(name), tag + "\n")
end
def remove_image(name)
path = image_file(name)
if os.file_exists(path)
os.remove(path)
end
end
# Sentinel used in <name>.applied right after import: the real vm-id is not
# known yet (the VM doesn't exist), so we store this and let start_vm rewrite
# line 1 once the VM is created. See rebind_imported_applied().
IMPORTED_SENTINEL = "__IMPORTED__"
# If <name>.applied was written by `yolo import` with the IMPORTED_SENTINEL as
# line 1, replace that line with the freshly-allocated vm-id so the existing
# applied_list()/is_applied() logic recognises the markers and skips re-running
# provisioners (their effects are already baked into the imported image).
def rebind_imported_applied(name, vm_id)
path = applied_file(name)
if !os.file_exists(path)
return
end
content = os.read_file(path)
lines = []
for line in str.each_line(content)
t = str.trim(line)
if t != ""
lines = append(lines, t)
end
end
if len(lines) == 0
return
end
if lines[0] != IMPORTED_SENTINEL
return
end
body = vm_id
i = 1
while i < len(lines)
body = body + "\n" + lines[i]
i = i + 1
end
os.write_file(path, body + "\n")
log("rebound imported provisioner markers to #{vm_id}")
end
def read_cwd(name)
path = cwd_file(name)
if !os.file_exists(path)
return ""
end
return str.trim(os.read_file(path))
end
def record_cwd(name)
os.write_file(cwd_file(name), host_workspace)
end
def read_state(name)
path = state_file(name)
if !os.file_exists(path)
return ""
end
return str.trim(os.read_file(path))
end
# Read provisioners already applied to the *current* vm-id. If the marker is
# stale (vm-id mismatch, e.g. after auto-heal recreated the VM), returns [].
def applied_list(name, vm_id)
path = applied_file(name)
if !os.file_exists(path)
return []
end
content = os.read_file(path)
lines = []
for line in str.each_line(content)
trimmed = str.trim(line)
if trimmed != ""
lines = append(lines, trimmed)
end
end
if len(lines) < 1
return []
end
if lines[0] != vm_id
return []
end
out = []
i = 1
while i < len(lines)
out = append(out, lines[i])
i = i + 1
end
return out
end
def is_applied(name, vm_id, prov_name)
for p in applied_list(name, vm_id)
if p == prov_name
return true
end
end
return false
end
def mark_applied(name, vm_id, prov_name)
list = applied_list(name, vm_id)
found = false
for p in list
if p == prov_name
found = true
end
end
if !found
list = append(list, prov_name)
end
body = vm_id
for p in list
body = body + "\n" + p
end
os.write_file(applied_file(name), body + "\n")
end
# ---------------- Inter-process lock (per name) ----------------
# Prevents two concurrent `yolo`s on the same name from racing during VM
# creation / provisioning. We use atomic mkdir() as the lock primitive: it
# returns success only if it created the directory, failure if it existed.
# On stale locks (process died), the PID liveness check reclaims them.
def lock_dir(name)
return filepath.join(STATE_DIR, "#{name}.lock")
end
def acquire_lock(name, label)
ld = lock_dir(name)
warned = false
iter = 0
while true
made = try `bash -c "mkdir #{ld} 2>/dev/null && echo OK"` or ""
if str.trim(made) == "OK"
os.write_file(filepath.join(ld, "pid"), "#{os.pid()}")
os.write_file(filepath.join(ld, "label"), label)
return
end
# Lock is held; check if the holder is still alive.
pid_file = filepath.join(ld, "pid")
holder_pid = ""
holder_label = "?"
if os.file_exists(pid_file)
holder_pid = str.trim(os.read_file(pid_file))
end
lf = filepath.join(ld, "label")
if os.file_exists(lf)
holder_label = str.trim(os.read_file(lf))
end
if holder_pid != ""
raw = try `bash -c "/bin/kill -0 #{holder_pid} 2>/dev/null && echo y || echo n"` or "n"
alive = str.trim(raw)
if alive == "n"
warn("removing stale lock for '#{name}' (pid #{holder_pid} dead)")
try `bash -c "rm -rf #{ld}"` or nil
warned = false
iter = 0
next
end
end
if !warned
log("waiting for another yolo (#{holder_label}, pid #{holder_pid})...")
warned = true
end
sleep 0.5
iter = iter + 1
end
end
def release_lock(name)
ld = lock_dir(name)
try `bash -c "rm -rf #{ld}"` or nil
end
# Ensure a running VM bound to $name; auto-heal stopped/missing.
# Behavior depends on the backend's PERSISTS_ON_STOP capability:
# - false (matchlock): anything not "running" => recreate from scratch.
# - true (podman): a stopped/exited container is resumed via be.resume()
# which preserves in-container state; recreation only
# happens when the binding is missing entirely.
def ensure_vm(name)
id = read_state(name)
be = backend_for(name)
table = be.list_table()
status = vm_status(id, table)
if status == "running"
return id
end
if status != "" && be.PERSISTS_ON_STOP == true && id != ""
log("resuming stopped #{id} for '#{name}'")
be.resume(id)
be.wait_ready(id)
return id
end
if status == ""
if id != ""
warn("tracked VM #{id} is gone; recreating")
os.remove(state_file(name))
end
else
warn("VM #{id} is in state '#{status}'; recreating")
be.remove(id)
os.remove(state_file(name))
end
return start_vm(name)
end
# CLI arg parsing, --help, --version, and --disk-size value parsing all
# live in cli.rugo. See that module for the full flag table and the
# returned hash shape.
def default_name
override = env_or("YOLO_NAME", "")
if override != ""
return override
end
h = crypto.sha1(os.cwd())
return "cwd-" + str.slice(h, 0, 10)
end
# ---------------- Subcommands ----------------
# Collapse $HOME to ~ for display.
def display_path(p)
if p == ""
return color.dim("—")
end
home = os.getenv("HOME")
if home != "" && str.starts_with(p, home)
return "~" + str.slice(p, len(home), len(p))
end
return p
end
# Build a {backend_name => list_table} cache so multi-name commands (ls, du,
# prune) make at most one runtime call per backend even when names span
# different backends.
def collect_tables
out = {}
files = try filepath.glob(filepath.join(STATE_DIR, "*.vmid")) or []
for f in files
n = str.trim_suffix(filepath.base(f), ".vmid")
bn = read_backend(n)
if bn == ""
bn = DEFAULT_BACKEND
end
if out[bn] == nil
be = BACKENDS[bn]
if be == nil
out[bn] = {}
else
out[bn] = be.list_table()
end
end
end
return out
end
def cmd_ls
tables = collect_tables()
printf_hdr()
seen = {}
files = try filepath.glob(filepath.join(STATE_DIR, "*.vmid")) or []
for f in files
n = str.trim_suffix(filepath.base(f), ".vmid")
seen[n] = true
id = str.trim(os.read_file(f))
bn = read_backend(n)
if bn == ""
bn = DEFAULT_BACKEND
end
table = tables[bn]
if table == nil
table = {}
end
row = table[id]
if row == nil
st = "gone"
im = "—"
else
st = row[0]
im = row[1]
end
cwd = read_cwd(n)
print_row(n, id, st, im, display_path(cwd))
end
# Imported-but-not-yet-started names: have <name>.image but no <name>.vmid.
img_files = try filepath.glob(filepath.join(STATE_DIR, "*.image")) or []
for f in img_files
n = str.trim_suffix(filepath.base(f), ".image")
if seen[n] == true
# already printed (running/stopped/gone)
else
tag = str.trim(os.read_file(f))
cwd = read_cwd(n)
print_row(n, "—", "imported", tag, display_path(cwd))
end
end
end
def printf_hdr
fmt.printf("%-25s %-20s %-12s %-18s %s\n", "NAME", "VM-ID", "STATUS", "IMAGE", "CWD")
end
def print_row(name, id, st, im, cwd_disp)
if st == "running"
st_c = color.green(fmt.sprintf("%-12s", st))
elsif st == "gone"
st_c = color.red(fmt.sprintf("%-12s", st))
elsif st == "imported"
st_c = color.yellow(fmt.sprintf("%-12s", st))
else
st_c = color.dim(fmt.sprintf("%-12s", st))
end
fmt.printf("%-25s %-20s %s %-18s %s\n", name, id, st_c, im, cwd_disp)
end
def cmd_stop(name)
id = read_state(name)
if id == ""
warn("no VM tracked for '#{name}'")
return
end
be = backend_for(name)
be.stop(id)
log("stopped #{id} (#{name})")
end
def cmd_rm(name)
id = read_state(name)
pinned_image = read_image(name)
if id == "" && pinned_image == ""
warn("no VM tracked for '#{name}'")
return
end
be = backend_for(name)
if id != ""
be.remove(id)
os.remove(state_file(name))
end
ap = applied_file(name)
if os.file_exists(ap)
os.remove(ap)
end
cf = cwd_file(name)
if os.file_exists(cf)
os.remove(cf)
end
remove_image(name)
# If the pinned image was created by `yolo import`, drop it from the
# backend's image store too — but only when no other yolo name still pins
# the same tag. We restrict this to yolo-import/* to avoid removing
# user-supplied images.
if pinned_image != "" && str.starts_with(pinned_image, "yolo-import/")
if !image_pinned_elsewhere(pinned_image)
be.image_remove(pinned_image)
log("removed imported image #{pinned_image}")
end
end
remove_backend_file(name)
# Best-effort: drop any stale lock dir for this name.
ld = lock_dir(name)
try `bash -c "rm -rf #{ld}"` or nil
if id != ""
log("removed #{id} (#{name})")
else
log("removed imported binding (#{name})")
end
end
# Scan all *.image state files to see if any name pins `tag`. Used to decide
# whether `image_remove` is safe in cmd_rm (we call this AFTER removing the
# binding for the name being deleted, so a `true` result means another
# binding still references it).
def image_pinned_elsewhere(tag)
files = try filepath.glob(filepath.join(STATE_DIR, "*.image")) or []
for f in files
other = str.trim(os.read_file(f))
if other == tag
return true
end
end
return false
end
def cmd_logs(name, extra)
id = read_state(name)
if id == ""
err_("no VM tracked for '#{name}'")
os.exit(1)
end
be = backend_for(name)
be.logs(id, extra)
end
def cmd_id(name)
id = read_state(name)
if id != ""
puts id
end
end
def cmd_status(name)
id = read_state(name)
pinned = read_image(name)
if id == ""
if pinned != ""
puts "imported (no vm yet) image: #{pinned}"
else
puts "untracked"
end
return
end
be = backend_for(name)
table = be.list_table()
st = vm_status(id, table)
if st == ""
st = "gone"
end
applied = applied_list(name, id)
suffix = ""
if pinned != ""
suffix = " image: #{pinned}"
end
if len(applied) == 0
puts "#{st} (#{id}) applied: -#{suffix}"
else
csv = str.join(applied, ",")
puts "#{st} (#{id}) applied: #{csv}#{suffix}"
end
end
def cmd_prune
files = try filepath.glob(filepath.join(STATE_DIR, "*.vmid")) or []
tables = collect_tables()
for f in files
id = str.trim(os.read_file(f))
n = str.trim_suffix(filepath.base(f), ".vmid")
bn = read_backend(n)
if bn == ""
bn = DEFAULT_BACKEND
end
table = tables[bn]
if table == nil
table = {}
end
if vm_status(id, table) == ""
log("pruning #{n} → #{id} (gone)")
os.remove(f)
ap = applied_file(n)
if os.file_exists(ap)
os.remove(ap)
end
cf = cwd_file(n)
if os.file_exists(cf)
os.remove(cf)
end
remove_backend_file(n)
end
end
end
# Format a byte count as a human-readable string.
def human_bytes(n)
if n < 1024
return fmt.sprintf("%d B", n)
end
k = n / 1024.0
if k < 1024.0
return fmt.sprintf("%.1f KiB", k)
end
m = k / 1024.0
if m < 1024.0
return fmt.sprintf("%.1f MiB", m)
end
g = m / 1024.0
return fmt.sprintf("%.2f GiB", g)
end
def cmd_du
tables = collect_tables()
fmt.printf("%-25s %-20s %-10s %-12s %s\n", "NAME", "VM-ID", "STATUS", "USED", "ALLOCATED")
files = try filepath.glob(filepath.join(STATE_DIR, "*.vmid")) or []
total_real = 0
total_app = 0
for f in files
n = str.trim_suffix(filepath.base(f), ".vmid")
id = str.trim(os.read_file(f))
bn = read_backend(n)
if bn == ""
bn = DEFAULT_BACKEND
end
be = BACKENDS[bn]
real_b = -1
app_b = -1
if be != nil
real_b = be.disk_real(id)
app_b = be.disk_apparent(id)
end
if real_b < 0
real_str = color.dim(fmt.sprintf("%-12s", "—"))
else
total_real = total_real + real_b
real_str = fmt.sprintf("%-12s", human_bytes(real_b))
end
if app_b < 0
app_str = color.dim("—")
else
total_app = total_app + app_b
app_str = human_bytes(app_b)
end
table = tables[bn]
if table == nil
table = {}
end
st = vm_status(id, table)
if st == ""
st = "gone"
end
if st == "running"
st_c = color.green(fmt.sprintf("%-10s", st))
elsif st == "gone"
st_c = color.red(fmt.sprintf("%-10s", st))
else
st_c = color.dim(fmt.sprintf("%-10s", st))
end
n_pad = fmt.sprintf("%-25s", n)
id_pad = fmt.sprintf("%-20s", id)
puts "#{n_pad} #{id_pad} #{st_c} #{real_str} #{app_str}"
end
# Total row: pad the whole prefix to the USED column, then write both
# totals without further format-string padding (ANSI codes throw it off).
prefix = fmt.sprintf("%-61s", "TOTAL")
real_total_s = color.bold(fmt.sprintf("%-12s", human_bytes(total_real)))
app_total_s = color.bold(human_bytes(total_app))
puts "#{prefix}#{real_total_s} #{app_total_s}"
end
def cmd_attach(name, passthrough, prov_name, no_prov, agent_name)
acquire_lock(name, "setup")
id = ensure_vm(name)
be = backend_for(name)
if !no_prov
# Resolve once. marker is content-hashed so source edits trigger a re-run.
resolved = resolve_provisioner(prov_name)
pname = resolved.name
if pname == ""
if ephemeral_mode == true
log("ephemeral run has no provisioner — skipping (use --provisioner NAME or --yolofile PATH|URL)")
else
log("no provisioner detected for #{os.cwd()} — skipping (use --provisioner NAME or add a Yolofile)")
end
elsif resolved.src == ""
err_("unknown provisioner '#{prov_name}'")
release_and_exit(name, 1)
elsif !is_applied(name, id, resolved.marker)