1 import Base 2 import ../lib/effs/io.bend as X 3 import ../lib/vstr.bend as S 4 import ../lib/mac_types.bend as T 5 import ./project.bend as P 6 import ./vazure.bend as Azure 7 import bend-net-json@0.3.0.0/json.bend as Json 8 9 # V's command line, run in a project's folder. All logic is Bend; other programs run through 10 # X.Proc.run (a C effect). The project's own programs (facts, plan, flake, laws...) run fresh, as `bend `. 11 12 def tailscale() -> String: 13 "/Applications/Tailscale.app/Contents/MacOS/Tailscale" 14 15 # ---- snapshot: the Mac as it is, into bend/machine.bend ---- 16 def pid_if(hit: Bool, l: String) -> String: 17 match hit: 18 case True{}: 19 String.drop(l, 6n) 20 case False{}: 21 "" 22 23 def pid_in(ls: List<&2, String>) -> String: 24 match ls: 25 case []: 26 "" 27 case +l <> rest: 28 pid_if(String.starts_with(String.trim(l), "pid = "), String.trim(l)) ++ pid_in(rest) 29 30 def pids(labels: List<&2, String>, +uid: String) -> IO(List<&2, String>): 31 match labels: 32 case []: 33 IO.pure(List<&2, String>, []) 34 case l <> rest: 35 do IO>: 36 out : String <- X.sh("launchctl print gui/" ++ uid ++ "/" ++ l ++ " 2>/dev/null") 37 more : List<&2, String> <- pids(rest, uid) 38 return pid_in(String.lines(out)) <> more 39 40 def port_if(keep: Bool, p: String) -> List<&2, String>: 41 match keep: 42 case True{}: 43 [p] 44 case False{}: 45 [] 46 47 # ---- listening sockets, from `lsof -F pcn` (one field per line: p, c, n) ---- 48 type Listen is Data: 49 Listen{pid: String, cmd: String, addr: String} 50 51 def field(+l: String) -> String: 52 String.drop(l, 1n) 53 54 def emit_if(name: Bool, +l: String, +pid: String, +cmd: String) -> List<&2, Listen>: 55 match name: 56 case True{}: 57 [Listen{pid, cmd, field(l)}] 58 case False{}: 59 [] 60 61 def emit(+l: String, +pid: String, +cmd: String) -> List<&2, Listen>: 62 emit_if(String.starts_with(l, "n"), l, pid, cmd) 63 64 def listens(ls: List<&2, String>, +pid: String, +cmd: String) -> List<&2, Listen>: 65 match ls: 66 case []: 67 [] 68 case +l <> rest: 69 List.append(&2, Listen, emit(l, pid, cmd), listens(rest, S.pick(String.starts_with(l, "p"), field(l), pid), S.pick(String.starts_with(l, "c"), field(l), cmd))) 70 71 def port_of_addr(+addr: String) -> String: 72 S.last(String.split(addr, ':')) 73 74 def host_of(+addr: String) -> String: 75 String.join(List.take(&2, String, String.split(addr, ':'), Nat.sub(S.count(String.split(addr, ':')), 1n)), ":") 76 77 def loopback(+addr: String) -> Bool: 78 String.eq(host_of(addr), "127.0.0.1") || String.eq(host_of(addr), "[::1]") 79 80 # Ports held by anything but Tailscale's own listener and V's processes (declared or V-installed). 81 def foreign_ports(xs: List<&2, Listen>, +owned: List<&2, String>) -> List<&2, String>: 82 match xs: 83 case []: 84 [] 85 case x <> rest: 86 match x: 87 case Listen{+pid, +cmd, +addr}: 88 S.append(port_if(Bool.not(String.eq(cmd, "IPNExtension")) && Bool.not(T.has(pid, owned)), port_of_addr(addr)), foreign_ports(rest, owned)) 89 90 # Ports that V's own processes listen on. 91 def owned_ports(xs: List<&2, Listen>, +owned: List<&2, String>) -> List<&2, String>: 92 match xs: 93 case []: 94 [] 95 case x <> rest: 96 match x: 97 case Listen{+pid, _, +addr}: 98 S.append(port_if(T.has(pid, owned), port_of_addr(addr)), owned_ports(rest, owned)) 99 100 # "process port" for listeners beyond 127.0.0.1 / [::1]: reachable from other machines. 101 def exposed(xs: List<&2, Listen>) -> List<&2, String>: 102 match xs: 103 case []: 104 [] 105 case x <> rest: 106 match x: 107 case Listen{_, +cmd, +addr}: 108 S.append(port_if(Bool.not(loopback(addr)), cmd ++ " " ++ port_of_addr(addr)), exposed(rest)) 109 110 # Tailscale Serve status as JSON (bend-net-json): 111 # {"Web": {":": {"Handlers": {"/": {"Proxy": "http://127.0.0.1:[/path]"}}}}} 112 def url_port(w: String) -> String: 113 S.digits(S.last(String.split(w, ':'))) 114 115 def json_or_null(m: Maybe<&2, Json.Val>) -> Json.Val: 116 match m: 117 case None{}: 118 Json.Null{} 119 case Some{v}: 120 v 121 122 def at(v: Json.Val, k: String) -> Json.Val: 123 json_or_null(Json.get(v, k)) 124 125 def str_of(v: Json.Val) -> String: 126 match v: 127 case Json.Str{s}: 128 s 129 case _: 130 "" 131 132 def obj_keys(v: Json.Val) -> List<&2, String>: 133 match v: 134 case Json.Obj{m}: 135 Map.keys(&2, Json.Val, m) 136 case _: 137 [] 138 139 # The Serve "Web" object of `tailscale serve status --json`. 140 def web_of(s: String) -> Json.Val: 141 at(json_or_null(Json.parse(s)), "Web") 142 143 def proxy(+web: Json.Val, k: String) -> String: 144 str_of(at(at(at(at(web, k), "Handlers"), "/"), "Proxy")) 145 146 # Served ports whose proxy target is not one of `declared` local ports. 147 def web_serves(+web: Json.Val, ks: List<&2, String>, +declared: List<&2, String>) -> List<&2, String>: 148 match ks: 149 case []: 150 [] 151 case +k <> rest: 152 S.append(port_if(Bool.not(T.has(url_port(proxy(web, k)), declared)), url_port(k)), web_serves(web, rest, declared)) 153 154 # Whether `port` is already served by exactly `target`. 155 def serving(+web: Json.Val, ks: List<&2, String>, +port: String, +target: String) -> Bool: 156 match ks: 157 case []: 158 False{} 159 case +k <> rest: 160 (String.eq(url_port(k), port) && String.eq(proxy(web, k), target)) || serving(web, rest, port, target) 161 162 163 # launchctl list: third column is the label; macOS's own are left out. 164 def agent_if(+label: String) -> List<&2, String>: 165 port_if(Bool.not(String.is_empty(label)) && Bool.not(String.starts_with(label, "com.apple.")) && Bool.not(String.starts_with(label, "application.")) && Bool.not(String.eq(label, "com.openssh.ssh-agent")), label) 166 167 def agent_labels(ls: List<&2, String>) -> List<&2, String>: 168 match ls: 169 case []: 170 [] 171 case l <> rest: 172 S.append(agent_if(S.nth(String.split(l, '\t'), 2n)), agent_labels(rest)) 173 174 # Repository evidence is captured beside the machine facts. A source line is "key folder". 175 type Stack is Data: 176 Stack{deps: List<&2, String>, extensions: List<&2, String>} 177 178 def prefix(+key: String, xs: List<&2, String>) -> List<&2, String>: 179 match xs: 180 case []: 181 [] 182 case x <> rest: 183 (key ++ ":" ++ x) <> prefix(key, rest) 184 185 def package_deps(+v: Json.Val) -> List<&2, String>: 186 List.append(&2, String, obj_keys(at(v, "dependencies")), obj_keys(at(v, "devDependencies"))) 187 188 def choose_package(empty: Bool, local: String, root: Json.Val) -> Json.Val: 189 match empty: 190 case True{}: 191 root 192 case False{}: 193 json_or_null(Json.parse(local)) 194 195 def package_or_root(+local: String, root: Json.Val) -> Json.Val: 196 choose_package(String.is_empty(local), local, root) 197 198 def module_name(line: String) -> String: 199 +words : List<&2, String> = S.words(String.trim(line)) 200 +first : String = S.nth(words, 0n) 201 +second : String = S.nth(words, 1n) 202 S.pick(String.eq(first, "require") && String.starts_with(S.nth(words, 2n), "v"), second, 203 S.pick(String.starts_with(second, "v") && Bool.not(String.eq(first, "module")), first, "")) 204 205 def go_deps(ls: List<&2, String>) -> List<&2, String>: 206 match ls: 207 case []: 208 [] 209 case +l <> rest: 210 S.append(port_if(Bool.not(String.is_empty(module_name(l))), module_name(l)), go_deps(rest)) 211 212 def source_ext(path: String) -> String: 213 +name : String = S.last(String.split(path, '/')) 214 +ext : String = "." ++ S.last(String.split(name, '.')) 215 S.pick(Bool.not(String.contains(name, ".generated.")) && 216 (String.eq(ext, ".ts") || String.eq(ext, ".tsx") || String.eq(ext, ".js") || 217 String.eq(ext, ".jsx") || String.eq(ext, ".mjs") || String.eq(ext, ".go") || String.eq(ext, ".py")), ext, "") 218 219 def source_extensions(paths: List<&2, String>) -> List<&2, String>: 220 match paths: 221 case []: 222 [] 223 case +p <> rest: 224 S.append(port_if(Bool.not(String.is_empty(source_ext(p))), source_ext(p)), source_extensions(rest)) 225 226 def stack_source(+line: String, root_json: Json.Val, +go: List<&2, String>) -> IO(Stack): 227 do IO: 228 +key : String = S.nth(S.words(line), 0n) 229 +source : String = S.nth(S.words(line), 1n) 230 local_json : String <- X.sh("cat '../" ++ source ++ "/../package.json' 2>/dev/null") 231 components : String <- X.sh("test -f '../" ++ source ++ "/../components.json' && echo shadcn/ui") 232 files : String <- X.sh("find '../" ++ source ++ "' -type d \\( -name node_modules -o -name _generated -o -name .next \\) -prune -o -type f -print 2>/dev/null") 233 +json : Json.Val = package_or_root(local_json, root_json) 234 +deps : List<&2, String> = S.dedupe(List.append(&2, String, package_deps(json), List.append(&2, String, go, S.nonempty(String.lines(components))))) 235 return Stack{prefix(key, deps), prefix(key, S.dedupe(source_extensions(String.lines(files))))} 236 237 def join_stack(a: Stack, b: Stack) -> Stack: 238 match a: 239 case Stack{ds, es}: 240 match b: 241 case Stack{mds, mes}: 242 Stack{List.append(&2, String, ds, mds), List.append(&2, String, es, mes)} 243 244 def stack_sources(lines: List<&2, String>, +root_json: Json.Val, +go: List<&2, String>) -> IO(Stack): 245 match lines: 246 case []: 247 IO.pure(Stack, Stack{[], []}) 248 case line <> rest: 249 do IO: 250 +one : Stack <- stack_source(line, root_json, go) 251 +more : Stack <- stack_sources(rest, root_json, go) 252 return join_stack(one, more) 253 254 def repository_stack(sources: List<&2, String>) -> IO(Stack): 255 do IO: 256 package : String <- X.sh("cat ../package.json 2>/dev/null") 257 go : String <- X.sh("cat ../go.mod 2>/dev/null") 258 stack_sources(sources, json_or_null(Json.parse(package)), go_deps(String.lines(go))) 259 260 def stack_text(stack: Stack) -> String: 261 match stack: 262 case Stack{deps, extensions}: 263 "# Package dependencies and source extensions by container key.\n" ++ 264 "def deps() -> List<&2, String>:\n [" ++ S.quote_all(deps) ++ "]\n\n" ++ 265 "def extensions() -> List<&2, String>:\n [" ++ S.quote_all(extensions) ++ "]\n" 266 267 def machine_text(ports: List<&2, String>, serves: List<&2, String>, agents: List<&2, String>, installed: List<&2, String>, exposed: List<&2, String>, stack: Stack) -> String: 268 "import Base\n\n# The machine as it was when `v system snapshot` ran. Generated; do not edit.\n\n" ++ 269 "def foreign_ports() -> List<&2, U32>:\n [" ++ String.join(ports, ", ") ++ "]\n\n" ++ 270 "def foreign_serves() -> List<&2, U32>:\n [" ++ String.join(serves, ", ") ++ "]\n\n" ++ 271 "def agents() -> List<&2, String>:\n [" ++ S.quote_all(agents) ++ "]\n\n" ++ 272 "# Launch agents V itself installed at the last deploy (nix-darwin's current generation); the next deploy reconciles them.\n" ++ 273 "def installed() -> List<&2, String>:\n [" ++ S.quote_all(installed) ++ "]\n\n" ++ 274 "# Programs listening beyond 127.0.0.1 (\"process port\"): reachable from other machines.\n" ++ 275 "def exposed() -> List<&2, String>:\n [" ++ S.quote_all(exposed) ++ "]\n\n" ++ stack_text(stack) 276 277 def snapshot(+f: P.Facts) -> IO(Unit): 278 do IO: 279 uid : String <- X.sh("id -u") 280 +inst : String <- X.sh("ls /run/current-system/user/Library/LaunchAgents 2>/dev/null | sed 's/[.]plist$//'") 281 +owned : List<&2, String> <- pids(S.append(P.labels_of(f), S.nonempty(String.lines(inst))), String.trim(uid)) 282 lsof : String <- X.sh("lsof +c 0 -nP -iTCP -sTCP:LISTEN -F pcn") 283 +sockets : List<&2, Listen> = listens(String.lines(lsof), "", "") 284 serve : String <- X.sh(tailscale() ++ " serve status --json") 285 list : String <- X.sh("launchctl list") 286 +ports : List<&2, String> = S.dedupe(foreign_ports(sockets, owned)) 287 +web : Json.Val = web_of(serve) 288 +serves : List<&2, String> = web_serves(web, obj_keys(web), S.append(P.ports_of(f), owned_ports(sockets, owned))) 289 +agents : List<&2, String> = agent_labels(List.drop(&2, String, String.lines(list), 1n)) 290 +stack : Stack <- repository_stack(P.sources_of(f)) 291 X.write_file("machine.bend", machine_text(ports, serves, agents, S.nonempty(String.lines(inst)), S.dedupe(exposed(sockets)), stack)) 292 IO.print("snapshot: " ++ Nat.show(S.count(ports)) ++ " foreign ports, " ++ Nat.show(S.count(serves)) ++ " foreign serves, " ++ Nat.show(S.count(agents)) ++ " launch agents") 293 294 # ---- check, plan, diagram ---- 295 def fail_or(ok: Bool, out: String, then: IO(Unit)) -> IO(Unit): 296 match ok: 297 case True{}: 298 then 299 case False{}: 300 IO.die(Unit, 1, out) 301 302 # Prove the laws against the current snapshot. Anything unproven stops V. 303 def prove() -> IO(Unit): 304 do IO: 305 +out : String <- P.check_bend("PROOF.bend") 306 fail_or(String.contains(out, "All terms check."), out, IO.print("laws: all proven")) 307 308 # The machine is snapshotted only for a project that configures it (a nix-darwin target). 309 def check_with(+f: P.Facts) -> IO(Unit): 310 do IO: 311 P.when(P.has_target(f, "nix-darwin"), snapshot(f)) 312 P.when(P.has_target(f, "azure"), Azure.snapshot(f)) 313 prove() 314 315 def check() -> IO(Unit): 316 do IO: 317 f : P.Facts <- P.read_facts() 318 check_with(f) 319 320 def snapshot_only() -> IO(Unit): 321 do IO: 322 +f : P.Facts <- P.read_facts() 323 P.when(P.has_target(f, "nix-darwin"), snapshot(f)) 324 P.when(P.has_target(f, "azure"), Azure.snapshot(f)) 325 326 def diagram(+out: String) -> IO(Unit): 327 do IO: 328 d2 : String <- P.run_bend("diagram.bend") 329 X.write_file("/tmp/v-diagram.d2", d2) 330 +ok : Bool <- X.ok("d2 --layout elk /tmp/v-diagram.d2 " ++ out ++ " >/dev/null 2>&1") 331 fail_or(ok, "d2 failed", IO.print(out)) 332 333 # ---- deploy ---- 334 # One "serve " plan line: make sure Tailscale serves it. 335 def serve_apply(current: Bool, +port: String, +local: String) -> IO(Unit): 336 match current: 337 case True{}: 338 IO.print("current serve :" ++ port ++ " -> " ++ local) 339 case False{}: 340 do IO: 341 IO.print("deploy serve :" ++ port ++ " -> " ++ local) 342 +ok : Bool <- X.ok(tailscale() ++ " serve --bg --yes --https=" ++ port ++ " http://127.0.0.1:" ++ local) 343 fail_or(ok, "failed: serve " ++ port, IO.pure(Unit, Unit{})) 344 345 def serve_do(is_serve: Bool, current: Bool, +port: String, +local: String) -> IO(Unit): 346 match is_serve: 347 case False{}: 348 IO.pure(Unit, Unit{}) 349 case True{}: 350 serve_apply(current, port, local) 351 352 def serve_step(+w: List<&2, String>, +web: Json.Val) -> IO(Unit): 353 serve_do(String.eq(S.nth(w, 0n), "serve"), serving(web, obj_keys(web), S.nth(w, 1n), "http://127.0.0.1:" ++ S.nth(w, 2n)), S.nth(w, 1n), S.nth(w, 2n)) 354 355 def serve_steps(ls: List<&2, String>, +web: Json.Val) -> IO(Unit): 356 match ls: 357 case []: 358 IO.pure(Unit, Unit{}) 359 case l <> rest: 360 do IO: 361 serve_step(S.words(l), web) 362 serve_steps(rest, web) 363 364 def applied_text(vlib: String, url: String, stores: String) -> String: 365 "import Base\nimport " ++ vlib ++ "/deploy.bend as D\n\n# What is running now (written by `v system deploy`). Generated; do not edit.\ndef applied() -> D.Deployment:\n D.Deployment{\"" ++ url ++ "\", [" ++ stores ++ "]}\n" 366 367 def current_parts(vlib: String, +c: String) -> String: 368 applied_text(vlib, String.trim(S.nth(String.lines(c), 0n)), String.trim(S.nth(String.lines(c), 1n))) 369 370 # ---- pushing the project's repository to GitHub ---- 371 # The probe answers "current" (GitHub already has HEAD), "pushed ", or "failed ". 372 # It refuses a repository whose real visibility differs from the declared one. 373 def push_script(+r: String, +vis: String) -> String: 374 "r='" ++ r ++ "'; w='" ++ vis ++ "'; t=$(git rev-parse --show-toplevel 2>/dev/null) || { echo 'failed not a git repository'; exit 0; }; " ++ 375 "u=$(git -C \"$t\" remote get-url origin 2>/dev/null); case \"$u\" in *github.com/$r|*github.com/$r.git|*github.com:$r.git) ;; *) echo \"failed origin is $u, not $r\"; exit 0;; esac; " ++ 376 "a=$(gh repo view \"$r\" --json visibility --jq .visibility 2>/dev/null | tr A-Z a-z); [ \"$a\" = \"$w\" ] || { echo \"failed $r is ${a:-unreachable}, declared $w\"; exit 0; }; " ++ 377 "[ -z \"$(git -C \"$t\" status --porcelain)\" ] || { echo 'failed uncommitted changes'; exit 0; }; " ++ 378 "git -C \"$t\" fetch -q origin 2>/dev/null || { echo 'failed fetch'; exit 0; }; " ++ 379 "if git -C \"$t\" rev-parse -q --verify origin/main >/dev/null && git -C \"$t\" merge-base --is-ancestor HEAD origin/main; then echo current; " ++ 380 "elif git -C \"$t\" push -q origin HEAD:main 2>/dev/null; then echo \"pushed $(git -C \"$t\" rev-parse --short HEAD)\"; else echo 'failed push rejected'; fi" 381 382 def push_current(current: Bool, +r: String, +repo: String) -> IO(Unit): 383 match current: 384 case True{}: 385 IO.print("current github " ++ repo) 386 case False{}: 387 fail_or(String.starts_with(r, "pushed"), "github " ++ repo ++ ": " ++ r, IO.print("deploy github " ++ repo ++ " " ++ String.drop(r, 7n))) 388 389 def github_push(+repo: String, +vis: String) -> IO(Unit): 390 do IO: 391 +r : String <- X.sh(push_script(repo, vis)) 392 push_current(String.starts_with(String.trim(r), "current"), String.trim(r), repo) 393 394 # ---- publishing a package to the Bend hub ---- 395 # The probe answers "current" (published, same files), "changed ", "published", or "failed ". 396 def publish_script(+n: String, +v: String, +e: String) -> String: 397 "n='" ++ n ++ "'; v='" ++ v ++ "'; e='" ++ e ++ "'; b=$(basename \"$e\"); d=$(dirname \"$e\"); " ++ 398 "printf 'import Base\\nimport %s@%s/%s as P\\n\\ndef main() -> IO(Unit):\\n IO.print(\"ok\")\\n' \"$n\" \"$v\" \"$b\" > /tmp/v-probe.bend; " ++ 399 "if $HOME/.bend/bin/bend /tmp/v-probe.bend --check-only >/dev/null 2>&1; then " ++ 400 "c=\"$HOME/.bend/lib/$(tr -d '\\n' < \"$HOME/.bend/lib/names/$n@$v\")\"; " ++ 401 "for f in \"$c\"/*; do cmp -s \"$f\" \"$d/$(basename \"$f\")\" || { echo \"changed $(basename \"$f\")\"; exit 0; }; done; echo current; " ++ 402 "else [ -f \"$d/LICENSE\" ] || { echo 'failed no LICENSE beside the entry'; exit 0; }; " ++ 403 "out=$($HOME/.bend/bin/bend \"$e\" --publish \"$n@$v\" 2>&1); case \"$out\" in *\"published $n@$v\"*) echo published;; *) echo \"failed $(echo \"$out\" | tail -1)\";; esac; fi" 404 405 def publish_other(published: Bool, +r: String, +id: String) -> IO(Unit): 406 match published: 407 case True{}: 408 IO.print("deploy package " ++ id) 409 case False{}: 410 fail_or(False{}, "package " ++ id ++ ": " ++ S.pick(String.starts_with(r, "changed"), String.drop(r, 8n) ++ " changed since it was published; bump the version", r), IO.pure(Unit, Unit{})) 411 412 def publish_current(current: Bool, +r: String, +id: String) -> IO(Unit): 413 match current: 414 case True{}: 415 IO.print("current package " ++ id) 416 case False{}: 417 publish_other(String.starts_with(r, "published"), r, id) 418 419 def publish_result(+r: String, +id: String) -> IO(Unit): 420 publish_current(String.starts_with(r, "current"), r, id) 421 422 def publish(+p: String) -> IO(Unit): 423 do IO: 424 +r : String <- X.sh(publish_script(S.nth(S.words(p), 0n), S.nth(S.words(p), 1n), S.nth(S.words(p), 2n))) 425 publish_result(String.trim(r), S.nth(S.words(p), 0n) ++ "@" ++ S.nth(S.words(p), 1n)) 426 427 def publish_all(ps: List<&2, String>) -> IO(Unit): 428 match ps: 429 case []: 430 IO.pure(Unit, Unit{}) 431 case p <> rest: 432 do IO: 433 publish(p) 434 publish_all(rest) 435 436 # ---- deploy: prove, then run each target the project declares ---- 437 def nix_darwin(+f: P.Facts) -> IO(Unit): 438 do IO: 439 flake : String <- P.run_bend("flake.bend") 440 X.write_file("nix/flake.nix", flake) 441 mac : String <- P.run_bend("mac.gen.bend") 442 X.write_file("nix/mac.nix", mac) 443 staged : Bool <- X.ok("git add nix") 444 IO.print("deploy nix-darwin (" ++ P.flake_host(f) ++ ")") 445 +ok : Bool <- X.ok("sudo -n /run/current-system/sw/bin/darwin-rebuild switch --flake \"$(pwd)/nix#" ++ P.flake_host(f) ++ "\" >/tmp/v-rebuild.log 2>&1") 446 fail_or(ok, "failed: darwin-rebuild switch (see /tmp/v-rebuild.log)", IO.pure(Unit, Unit{})) 447 448 def tailscale_serve() -> IO(Unit): 449 do IO: 450 status : String <- X.sh(tailscale() ++ " serve status --json") 451 plan : String <- P.run_bend("plan.bend") 452 serve_steps(String.lines(plan), web_of(status)) 453 454 # A project with a deployment (current.bend) records what is now running, for the breaks law. 455 def record(has: Bool, +vlib: String) -> IO(Unit): 456 match has: 457 case True{}: 458 do IO: 459 +cur : String <- P.run_bend("current.bend") 460 X.write_file("applied.bend", current_parts(vlib, cur)) 461 IO.print("deployed: " ++ String.trim(S.nth(String.lines(cur), 0n))) 462 case False{}: 463 IO.print("deployed") 464 465 def deploy() -> IO(Unit): 466 do IO: 467 +f : P.Facts <- P.read_facts() 468 check_with(f) 469 P.when(P.has_target(f, "nix-darwin"), nix_darwin(f)) 470 P.when(P.has_target(f, "tailscale-serve"), tailscale_serve()) 471 P.when(P.has_target(f, "github"), github_push(P.github_repo(f), P.github_visibility(f))) 472 P.when(P.has_target(f, "bend-hub"), publish_all(P.packages_of(f))) 473 has : Bool <- X.ok("test -f current.bend") 474 record(has, P.vlib_of(f))