cli/v.bend

near-v-framework@0.2.0.0 · 477 lines · 0 definitions

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