domain/architecture/system/effects.bend

near-v-framework@0.7.0.0 · 532 lines · 0 definitions

1import Base
2import bend-kit-files@0.1.0.0/files.bend as Fs
3import bend-net-json@0.3.0.0/json.bend as Json
4import near-v-framework@0.7.0.0/domain/architecture/system/effs/io.bend as X
5import near-v-framework@0.7.0.0/domain/architecture/type.bend as A
6import near-v-framework@0.7.0.0/domain/architecture/ops.bend as O
7import near-v-framework@0.7.0.0/domain/architecture/system/container/effects.bend as CtE
8import near-v-framework@0.7.0.0/domain/architecture/system/deployment/type.bend as D
9import near-v-framework@0.7.0.0/domain/architecture/system/deployment/ops.bend as DO
10import near-v-framework@0.7.0.0/domain/architecture/system/deployment/azure/effects.bend as AzE
11import near-v-framework@0.7.0.0/domain/architecture/system/deployment/launchd/effects.bend as LdE
12import near-v-framework@0.7.0.0/domain/architecture/system/deployment/nix_darwin/type.bend as ND
13import near-v-framework@0.7.0.0/domain/architecture/system/deployment/nix_darwin/ops.bend as NDO
14import near-v-framework@0.7.0.0/domain/architecture/system/deployment/nix_darwin/effects.bend as NDE
15import near-v-framework@0.7.0.0/domain/architecture/system/deployment/nix_darwin/setting/type.bend as St
16import near-v-framework@0.7.0.0/domain/architecture/system/deployment/nix_darwin/setting/ops.bend as StO
17import near-v-framework@0.7.0.0/domain/architecture/system/deployment/nix_darwin/brew/type.bend as Br
18import near-v-framework@0.7.0.0/domain/architecture/system/deployment/nix_darwin/setting/effects.bend as StE
19import near-v-framework@0.7.0.0/domain/architecture/system/deployment/nix_darwin/brew/effects.bend as BrE
20import near-v-framework@0.7.0.0/domain/architecture/system/deployment/nix_darwin/service/ops.bend as SvO
21import near-v-framework@0.7.0.0/domain/architecture/system/release/type.bend as Rl
22import near-v-framework@0.7.0.0/domain/architecture/system/release/ops.bend as RlO
23import near-v-framework@0.7.0.0/domain/architecture/system/release/plugin/ops.bend as PlO
24import near-v-framework@0.7.0.0/domain/architecture/system/plan/type.bend as P
25import near-v-framework@0.7.0.0/domain/architecture/system/plan/ops.bend as PO
26import near-v-framework@0.7.0.0/domain/architecture/system/ops.bend as SO
27
28# The system's effects: reading the repository, the Bend hub and GitHub (a snapshot of the world), and
29# changing them (a deploy). Programs run with their arguments, never through a shell; files are read with
30# Base's File API.
31
32# ---- the repository's files ----
33def files(+root: String) -> IO(List<&2, String>):
34  do IO<List<&2, String>>:
35    out : String <- X.run("git", ["-C", root, "ls-files", "-co", "--exclude-standard"])
36    return X.nonempty(String.lines(out))
37
38# ---- what an entry reaches: it and every file it imports, followed in Bend ----
39def second_word(+t: String) -> String:
40  Maybe.default(&2, String, List.get(&2, String, X.nonempty(String.split(t, ' ')), 1n), "")
41
42def unquote(+w: String) -> String:
43  Bool.pick(String, String.starts_with(w, "\""), Maybe.default(&2, String, List.get(&2, String, String.split(w, '"'), 1n), ""), w)
44
45# The file a line imports, relative to the repository, or "" when it imports none of the repository's.
46def import_of(+line: String, +dir: String) -> String:
47  +w = unquote(second_word(String.trim(line)))
48  Bool.pick(String, String.starts_with(String.trim(line), "import ") && (String.starts_with(w, "./") || String.starts_with(w, "../")),
49    Fs.path.normalize(Fs.path.join(dir, w)), "")
50
51def imports(ls: List<&2, String>, +dir: String) -> List<&2, String>:
52  match ls:
53    case []:
54      []
55    case l <> rest:
56      X.nonempty(import_of(l, dir) <> imports(rest, dir))
57
58# Each step reads one file; the number of files squared bounds the walk.
59def reach(fuel: Nat, +todo: List<&2, String>, +seen: List<&2, String>, +root: String) -> IO(List<&2, String>):
60  match fuel:
61    case 0n:
62      IO.pure(List<&2, String>, seen)
63    case 1n+k:
64      match todo:
65        case []:
66          IO.pure(List<&2, String>, seen)
67        case +f <> rest:
68          do IO<List<&2, String>>:
69            text : String <- X.read_head(root ++ "/" ++ f, 4096)
70            +known : Bool = List.contains(~String, ~String.eq, seen, f)
71            reach(k, List.append(&2, String, rest, Bool.pick(List<&2, String>, known, [], imports(String.lines(text), Fs.path.parent(f)))), Bool.pick(List<&2, String>, known, seen, f <> seen), root)
72
73# ---- the Bend hub: has it published this package, with these exact files? ----
74def differing(fs: List<&2, String>, +local: String, +hub: String) -> IO(String):
75  match fs:
76    case []:
77      IO.pure(String, "")
78    case +f <> rest:
79      do IO<String>:
80        same : Bool <- X.succeeds("cmp", ["-s", local ++ "/" ++ f, hub ++ "/" ++ f])
81        more : String <- differing(rest, local, hub)
82        return Bool.pick(String, same, more, f)
83
84def hub_of(+published: String, +f: String) -> P.Hub:
85  Bool.pick(P.Hub, String.is_empty(published), P.Unpublished{}, Bool.pick(P.Hub, String.is_empty(f), P.Same{}, P.Differs{f}))
86
87# The package's hash, as Bend's local cache names it. A package not cached yet is fetched first by
88# checking a file that imports it: the hub has it exactly when that succeeds.
89def fetch_if(missing: Bool, +home: String, +n: String, +v: String, +e: String, +cached: String) -> IO(String):
90  match missing:
91    case False{}:
92      IO.pure(String, cached)
93    case True{}:
94      do IO<String>:
95        X.write("/tmp/v-probe.bend", "import Base\nimport " ++ n ++ "@" ++ v ++ "/" ++ Fs.path.file_name(e) ++ " as P\n")
96        _ : Bool <- X.succeeds(home ++ "/.bend/bin/bend", ["/tmp/v-probe.bend", "--check-only"])
97        X.read(home ++ "/.bend/lib/names/" ++ n ++ "@" ++ v)
98
99def fetched(+home: String, +n: String, +v: String, +e: String, +cached: String) -> IO(String):
100  fetch_if(String.is_empty(String.trim(cached)), home, n, v, e, cached)
101
102def hub_state(+home: String, +root: String, p: Rl.Package) -> IO(P.Hub):
103  match p:
104    case Rl.Package{+n, +v, +e, _}:
105      do IO<P.Hub>:
106        cached : String <- X.read(home ++ "/.bend/lib/names/" ++ n ++ "@" ++ v)
107        +hash : String <- fetched(home, n, v, e, cached)
108        +files : List<&2, String> <- reach(1000n, [e], [], root)
109        f : String <- differing(files, root, home ++ "/.bend/lib/" ++ String.trim(hash))
110        return hub_of(String.trim(hash), f)
111
112def hub_states(+home: String, +root: String, ps: List<&2, Rl.Package>) -> IO(List<&2, P.Hub>):
113  match ps:
114    case []:
115      IO.pure(List<&2, P.Hub>, [])
116    case p <> rest:
117      do IO<List<&2, P.Hub>>:
118        h : P.Hub <- hub_state(home, root, p)
119        hs : List<&2, P.Hub> <- hub_states(home, root, rest)
120        return h <> hs
121
122# ---- GitHub ----
123def first_word(+t: String) -> String:
124  Maybe.default(&2, String, List.head(&2, String, X.nonempty(String.split(t, '\t'))), "")
125
126def pushed(+root: String, +sha: String) -> IO(Bool):
127  Bool.pick(IO(Bool), String.is_empty(sha), IO.pure(Bool, False{}), X.succeeds("git", ["-C", root, "merge-base", "--is-ancestor", "HEAD", sha]))
128
129def remote(+root: String, r: Rl.Repository) -> IO(P.Remote):
130  match r:
131    case Rl.Repository{+name, _}:
132      do IO<P.Remote>:
133        origin : String <- X.run("git", ["-C", root, "remote", "get-url", "origin"])
134        vis : String <- X.run("gh", ["repo", "view", name, "--json", "visibility", "--jq", ".visibility"])
135        status : String <- X.run("git", ["-C", root, "status", "--porcelain"])
136        head : String <- X.run("git", ["-C", root, "ls-remote", "origin", "refs/heads/main"])
137        up : Bool <- pushed(root, first_word(head))
138        return P.Remote{String.trim(origin), String.to_lower(String.trim(vis)), String.is_empty(String.trim(status)), up}
139
140# ---- a plugin: a fresh build of it, next to what its marketplace has tagged for its version ----
141# Built under .git/v-plugin/<name>, never committed: build/ is what a release would hold at
142# plugins/<name>, tag/ is the marketplace at the tag, repo/ the marketplace a release writes.
143def plugin_dir(+root: String, +n: String) -> String:
144  root ++ "/.git/v-plugin/" ++ n
145
146def tag_of(+n: String, +v: String) -> String:
147  n ++ "-v" ++ v
148
149def last_segment(xs: List<&2, String>) -> String:
150  Maybe.default(&2, String, List.last(&2, String, xs), "")
151
152def owner_of(+r: String) -> String:
153  Maybe.default(&2, String, List.head(&2, String, String.split(r, '/')), "")
154
155def str_of(m: Maybe<&2, Json.Val>) -> String:
156  match m:
157    case Some{Json.Str{s}}:
158      s
159    case _:
160      ""
161
162def version_in(m: Maybe<&2, Json.Val>) -> String:
163  match m:
164    case Some{v}:
165      str_of(Json.get(v, "version"))
166    case None{}:
167      ""
168
169def obj.go(kvs: List<&2, Sigma<&2, &2, String, _ => Json.Val>>, m: Map<&2, Json.Val>) -> Map<&2, Json.Val>:
170  match kvs:
171    case []:
172      m
173    case (k, v) <> rest:
174      obj.go(rest, Map.set(&2, Json.Val, m, k, v))
175
176def obj(kvs: List<&2, Sigma<&2, &2, String, _ => Json.Val>>) -> Json.Val:
177  Json.Obj{obj.go(kvs, Map.new(&2, Json.Val))}
178
179# The marketplace's other plugins, as listed now.
180def others(vs: List<&2, Json.Val>, +n: String) -> List<&2, Json.Val>:
181  match vs:
182    case []:
183      []
184    case +v <> rest:
185      +kept = others(rest, n)
186      Bool.pick(List<&2, Json.Val>, String.eq(str_of(Json.get(v, "name")), n), kept, v <> kept)
187
188def listed(m: Maybe<&2, Json.Val>) -> List<&2, Json.Val>:
189  match m:
190    case Some{Json.Arr{vs}}:
191      vs
192    case _:
193      []
194
195# The marketplace's listing with this plugin at this version, every other plugin as it was.
196def marketplace(+now: String, +n: String, +v: String, +r: String) -> String:
197  +m = Json.parse(now)
198  Json.encode(obj([
199    ("name", Json.Str{last_segment(String.split(r, '/'))}),
200    ("owner", obj([("name", Json.Str{owner_of(r)})])),
201    ("plugins", Json.Arr{List.append(&2, Json.Val, others(listed(Bool.pick(Maybe<&2, Json.Val>, Maybe.is_some(&2, Json.Val, m), Json.get(Maybe.default(&2, Json.Val, m, Json.Null{}), "plugins"), None{})), n),
202      [obj([("name", Json.Str{n}), ("source", Json.Str{"./plugins/" ++ n}), ("version", Json.Str{v})])])})])) ++ "\n"
203
204def build_plugin(+root: String, +bend: String, +n: String, +e: String) -> IO(String):
205  do IO<String>:
206    +build : String = plugin_dir(root, n) ++ "/build"
207    _ : Bool <- X.succeeds("rm", ["-rf", plugin_dir(root, n)])
208    _ : Bool <- X.succeeds("mkdir", ["-p", build])
209    ok : Bool <- X.succeeds(bend, [root ++ "/" ++ e, "plugin", build])
210    manifest : String <- X.read(build ++ "/.claude-plugin/plugin.json")
211    return Bool.pick(String, ok, version_in(Json.parse(manifest)), "")
212
213def tag_differs(tagged: Bool, +root: String, +n: String, +v: String, +r: String) -> IO(Bool):
214  match tagged:
215    case False{}:
216      IO.pure(Bool, False{})
217    case True{}:
218      do IO<Bool>:
219        _ : Bool <- X.succeeds("git", ["clone", "-q", "--depth", "1", "--branch", tag_of(n, v), "https://github.com/" ++ r, plugin_dir(root, n) ++ "/tag"])
220        same : Bool <- X.succeeds("diff", ["-rq", plugin_dir(root, n) ++ "/build", plugin_dir(root, n) ++ "/tag/plugins/" ++ n])
221        return Bool.not(same)
222
223def tag_state(+home: String, +root: String, g: Rl.Plugin) -> IO(P.Tag):
224  match g:
225    case Rl.Plugin{+n, ver, +e, _, Rl.Repository{+r, _}}:
226      do IO<P.Tag>:
227        +v : String = PlO.version_show(ver)
228        built : String <- build_plugin(root, home ++ "/.bend/bin/bend", n, e)
229        valid : Bool <- X.succeeds("claude", ["plugin", "validate", "--strict", plugin_dir(root, n) ++ "/build"])
230        +repo : Bool <- X.succeeds("gh", ["repo", "view", r, "--json", "name"])
231        tags : String <- X.run("git", ["ls-remote", "--tags", "https://github.com/" ++ r, "refs/tags/" ++ tag_of(n, v)])
232        +tagged : Bool = repo && Bool.not(String.is_empty(String.trim(tags)))
233        differs : Bool <- tag_differs(tagged, root, n, v, r)
234        return P.Tag{repo, tagged, built, valid, differs}
235
236def tag_states(+home: String, +root: String, gs: List<&2, Rl.Plugin>) -> IO(List<&2, P.Tag>):
237  match gs:
238    case []:
239      IO.pure(List<&2, P.Tag>, [])
240    case g <> rest:
241      do IO<List<&2, P.Tag>>:
242        t : P.Tag <- tag_state(home, root, g)
243        ts : List<&2, P.Tag> <- tag_states(home, root, rest)
244        return t <> ts
245
246# Codex's listing of the same marketplace (.agents/plugins/marketplace.json), with this plugin available and
247# every other plugin as it was.
248def codex_marketplace(+now: String, +n: String, +r: String) -> String:
249  +m = Json.parse(now)
250  Json.encode(obj([
251    ("name", Json.Str{last_segment(String.split(r, '/'))}),
252    ("interface", obj([("displayName", Json.Str{last_segment(String.split(r, '/'))})])),
253    ("plugins", Json.Arr{List.append(&2, Json.Val, others(listed(Bool.pick(Maybe<&2, Json.Val>, Maybe.is_some(&2, Json.Val, m), Json.get(Maybe.default(&2, Json.Val, m, Json.Null{}), "plugins"), None{})), n),
254      [obj([("name", Json.Str{n}), ("source", obj([("source", Json.Str{"local"}), ("path", Json.Str{"./plugins/" ++ n})])),
255        ("policy", obj([("installation", Json.Str{"AVAILABLE"}), ("authentication", Json.Str{"ON_USE"})])),
256        ("category", Json.Str{"Productivity"})])])})])) ++ "\n"
257
258# A release: the build into plugins/<name>, both listings updated, committed, tagged and pushed.
259def release_plugin(+root: String, +n: String, +v: String, +r: String, create: Bool) -> IO(Bool):
260  do IO<Bool>:
261    +repo : String = plugin_dir(root, n) ++ "/repo"
262    _ : Bool <- Bool.pick(IO(Bool), create, X.succeeds("gh", ["repo", "create", r, "--public", "--description", "Claude Code plugins, built and released by V."]), IO.pure(Bool, True{}))
263    _ : Bool <- X.succeeds("rm", ["-rf", repo])
264    _ : Bool <- X.succeeds("git", ["clone", "-q", "https://github.com/" ++ r, repo])
265    _ : Bool <- X.succeeds("mkdir", ["-p", repo ++ "/plugins/" ++ n, repo ++ "/.claude-plugin", repo ++ "/.agents/plugins"])
266    _ : Bool <- X.succeeds("rsync", ["-a", "--delete", plugin_dir(root, n) ++ "/build/", repo ++ "/plugins/" ++ n ++ "/"])
267    now : String <- X.read(repo ++ "/.claude-plugin/marketplace.json")
268    X.write(repo ++ "/.claude-plugin/marketplace.json", marketplace(now, n, v, r))
269    codex_now : String <- X.read(repo ++ "/.agents/plugins/marketplace.json")
270    X.write(repo ++ "/.agents/plugins/marketplace.json", codex_marketplace(codex_now, n, r))
271    _ : Bool <- X.succeeds("git", ["-C", repo, "add", "-A"])
272    _ : Bool <- X.succeeds("git", ["-C", repo, "commit", "-q", "-m", n ++ " " ++ v])
273    _ : Bool <- X.succeeds("git", ["-C", repo, "tag", tag_of(n, v)])
274    X.succeeds("git", ["-C", repo, "push", "-q", "origin", "HEAD:main", tag_of(n, v)])
275
276# ---- a snapshot: everything a plan needs ----
277def root() -> IO(String):
278  do IO<String>:
279    t : String <- X.run("git", ["rev-parse", "--show-toplevel"])
280    return String.trim(t)
281
282# The files, what the packages' entries and the system's own entry reach, the hub, and GitHub.
283def observe(+root: String, +entry: String, r: Rl.Release) -> IO(P.Observed):
284  match r:
285    case Rl.Release{repo, +ps, +gs}:
286      do IO<P.Observed>:
287        +h : String <- X.home()
288        +fs : List<&2, String> <- files(root)
289        reached : List<&2, String> <- reach(Nat.mul(1n+List.length(&2, String, fs), 1n+List.length(&2, String, fs)), entry <> List.append(&2, String, RlO.entries(ps), RlO.plugin_entries(gs)), [], root)
290        hs : List<&2, P.Hub> <- hub_states(h, root, ps)
291        rm : P.Remote <- remote(root, repo)
292        ts : List<&2, P.Tag> <- tag_states(h, root, gs)
293        return P.Observed{fs, reached, hs, rm, ts}
294
295# ---- a deploy: carry out a plan with nothing blocked ----
296# Loading a launch agent: `launchctl bootout` returns before the old job is gone, and loading it again fails
297# until it is, so this tries every quarter second, up to `fuel` times.
298def bootstrap_after(fuel: Nat, ok: Bool, +domain: String, +path: String) -> IO(Bool):
299  match fuel:
300    case 0n:
301      IO.pure(Bool, ok)
302    case 1n+k:
303      match ok:
304        case True{}:
305          IO.pure(Bool, True{})
306        case False{}:
307          do IO<Bool>:
308            IO.sleep(250)
309            again : Bool <- X.succeeds("/bin/launchctl", ["bootstrap", domain, path])
310            bootstrap_after(k, again, domain, path)
311
312def bootstrap(fuel: Nat, +domain: String, +path: String) -> IO(Bool):
313  do IO<Bool>:
314    first : Bool <- X.succeeds("/bin/launchctl", ["bootstrap", domain, path])
315    bootstrap_after(fuel, first, domain, path)
316
317def apply(+root: String, +bend: String, s: P.Step) -> IO(Unit):
318  match s:
319    case P.Current{_}:
320      IO.print(PO.show(s))
321    case P.Publish{+n, +v, +e}:
322      do IO<Unit>:
323        out : String <- X.run(bend, [root ++ "/" ++ e, "--publish", n ++ "@" ++ v])
324        IO.print(Bool.pick(String, String.contains(out, "published"), "published " ++ n ++ "@" ++ v, "failed   publish " ++ n ++ "@" ++ v))
325    case P.Push{+r}:
326      do IO<Unit>:
327        ok : Bool <- X.succeeds("git", ["-C", root, "push", "-q", "origin", "HEAD:main"])
328        IO.print(Bool.pick(String, ok, "pushed   github " ++ r, "failed   push github " ++ r))
329    case P.ReleasePlugin{+n, +v, +r, create}:
330      do IO<Unit>:
331        ok : Bool <- release_plugin(root, n, v, r, create)
332        IO.print(Bool.pick(String, ok, "released plugin " ++ n ++ "@" ++ v, "failed   release plugin " ++ n ++ "@" ++ v))
333    case P.BuildNext{+f, +tree, +dir}:
334      do IO<Unit>:
335        +src : String = root ++ "/.git/v-launchd/next-src"
336        _ : Bool <- X.succeeds("/bin/rm", ["-rf", src])
337        _ : Bool <- X.succeeds("/bin/mkdir", ["-p", src])
338        cloned : Bool <- X.succeeds("/usr/bin/rsync", ["-a", "--exclude", "node_modules", "--exclude", ".next", root ++ "/" ++ f ++ "/", src ++ "/"])
339        deps : Bool <- X.succeeds("/run/current-system/sw/bin/npm", ["--prefix", src, "ci", "--no-audit", "--no-fund"])
340        built : Bool <- X.succeeds("/run/current-system/sw/bin/npm", ["--prefix", src, "run", "build"])
341        _ : Bool <- X.succeeds("/bin/mkdir", ["-p", dir ++ "/.next"])
342        copied : Bool <- X.succeeds("/usr/bin/rsync", ["-a", "--delete", "--exclude", ".next/static", src ++ "/.next/standalone/", dir ++ "/"])
343        _ : Bool <- X.succeeds("/usr/bin/rsync", ["-a", "--delete", src ++ "/.next/static/", dir ++ "/.next/static/"])
344        +ok : Bool = cloned && deps && built && copied
345        Bool.pick(IO(Unit), ok, X.write(dir ++ "/.v-commit", tree), IO.pure(Unit, Unit{}))
346        IO.print(Bool.pick(String, ok, "built    " ++ f ++ " into " ++ dir, "failed   build " ++ f))
347    case P.BuildNode{+f, +tree, +dir}:
348      do IO<Unit>:
349        _ : Bool <- X.succeeds("/bin/mkdir", ["-p", dir])
350        copied : Bool <- X.succeeds("/usr/bin/rsync", ["-a", "--delete", "--exclude", "node_modules", root ++ "/" ++ f ++ "/", dir ++ "/"])
351        deps : Bool <- X.succeeds("/run/current-system/sw/bin/npm", ["--prefix", dir, "ci", "--omit=dev", "--no-audit", "--no-fund"])
352        +ok : Bool = copied && deps
353        Bool.pick(IO(Unit), ok, X.write(dir ++ "/.v-commit", tree), IO.pure(Unit, Unit{}))
354        IO.print(Bool.pick(String, ok, "built    " ++ f ++ " into " ++ dir, "failed   build " ++ f))
355    case P.Install{+from, +to}:
356      do IO<Unit>:
357        _ : Bool <- X.succeeds("/bin/mkdir", ["-p", Fs.path.parent(to)])
358        ok : Bool <- X.succeeds("/bin/cp", [from, to])
359        IO.print(Bool.pick(String, ok, "installed " ++ to, "failed   install " ++ to))
360    case P.Launch{+l, +path, +plist, +domain}:
361      do IO<Unit>:
362        _ : Bool <- X.succeeds("/bin/mkdir", ["-p", Fs.path.parent(path)])
363        X.write(path, plist)
364        _ : Bool <- X.succeeds("/bin/launchctl", ["bootout", domain ++ "/" ++ l])
365        ok : Bool <- bootstrap(20n, domain, path)
366        IO.print(Bool.pick(String, ok, "launched " ++ l, "failed   launch " ++ l))
367    case P.Rebuild{+h, +fl, +mac}:
368      do IO<Unit>:
369        _ : Result<&1, &1, U32 & String, Unit> <- Fs.mkdir(root ++ "/nix")
370        X.write(root ++ "/nix/flake.nix", fl)
371        X.write(root ++ "/nix/mac.nix", mac)
372        ok : Bool <- X.succeeds("sudo", ["-n", "/run/current-system/sw/bin/darwin-rebuild", "switch", "--flake", root ++ "/nix#" ++ h])
373        IO.print(Bool.pick(String, ok, "rebuilt  nix-darwin " ++ h, "failed   darwin-rebuild switch " ++ h))
374    case P.ServePort{+p, +t, ts}:
375      do IO<Unit>:
376        ok : Bool <- X.succeeds(ts, ["serve", "--bg", "--yes", "--https=" ++ U32.show(p), t])
377        IO.print(Bool.pick(String, ok, "served   :" ++ U32.show(p) ++ " -> " ++ t, "failed   serve :" ++ U32.show(p)))
378    case P.Change{_, _, _}:
379      IO.pure(Unit, Unit{})
380    case P.Blocked{_, _}:
381      IO.print(PO.show(s))
382
383def apply_all(+root: String, +bend: String, ss: List<&2, P.Step>) -> IO(Unit):
384  match ss:
385    case []:
386      IO.pure(Unit, Unit{})
387    case s <> rest:
388      do IO<Unit>:
389        apply(root, bend, s)
390        apply_all(root, bend, rest)
391
392def deploy_if(blocked: Bool, +root: String, +ss: List<&2, P.Step>) -> IO(Unit):
393  match blocked:
394    case True{}:
395      IO.die(Unit, 1, PO.show_all(ss) ++ "nothing deployed: the plan is blocked")
396    case False{}:
397      do IO<Unit>:
398        +h : String <- X.home()
399        apply_all(root, h ++ "/.bend/bin/bend", ss)
400
401def deploy(+root: String, +ss: List<&2, P.Step>) -> IO(Unit):
402  deploy_if(PO.any_blocked(ss), root, ss)
403
404# ---- the whole plan: the release, the stack, each deployment node, and what the deploy would break ----
405# ---- a nix-darwin node: its checks, what a rebuild would change, and the rebuild when anything would ----
406def has_change(ss: List<&2, P.Step>) -> Bool:
407  match ss:
408    case []:
409      False{}
410    case s <> rest:
411      match s:
412        case P.Change{_, _, _}:
413          True{}
414        case _:
415          has_change(rest)
416
417def mac_of(nd: ND.NixDarwin) -> String:
418  match nd:
419    case ND.NixDarwin{_, _, _, _, _, st, fw, br, _, _, _, _, _, _}:
420      StO.mac_nix(st, fw, br)
421
422def host_of(nd: ND.NixDarwin) -> String:
423  match nd:
424    case ND.NixDarwin{h, _, _, _, _, _, _, _, _, _, _, _, _, _}:
425      h
426
427def rebuild_if(c: Bool, +h: String, +fl: String, +mac: String) -> List<&2, P.Step>:
428  match c:
429    case True{}:
430      [P.Rebuild{h, fl, mac}]
431    case False{}:
432      [P.Current{"nix-darwin " ++ h}]
433
434# Whether a generated file differs from the one in the repository (compared by cmp, not in memory).
435def differs(+path: String, +text: String) -> IO(Bool):
436  do IO<Bool>:
437    X.write("/tmp/v-generated", text)
438    same : Bool <- X.succeeds("cmp", ["-s", "/tmp/v-generated", path])
439    return Bool.not(same)
440
441def darwin_steps(+root: String, +k: String, +nd: ND.NixDarwin) -> IO(List<&2, P.Step>):
442  match nd:
443    case ND.NixDarwin{_, _, _, _, _, +st, +fw, +br, _, _, _, _, _, _}:
444      do IO<List<&2, P.Step>>:
445        checks : List<&2, P.Step> <- NDE.checks(k, nd)
446        +settings : List<&2, P.Step> <- StE.changes(st, fw)
447        +brews : List<&2, P.Step> <- BrE.changes(br)
448        flake_new : Bool <- differs(root ++ "/nix/flake.nix", NDO.flake(nd))
449        mac_new : Bool <- differs(root ++ "/nix/mac.nix", mac_of(nd))
450        return List.append(&2, P.Step, checks, List.append(&2, P.Step, settings, List.append(&2, P.Step, brews,
451          rebuild_if(flake_new || mac_new || has_change(settings) || has_change(brews), host_of(nd), NDO.flake(nd), mac_of(nd)))))
452
453def platform_steps(p: D.Platform, +root: String, +k: String, +a: A.Architecture) -> IO(List<&2, P.Step>):
454  match p:
455    case D.Unmanaged{}:
456      IO.pure(List<&2, P.Step>, [])
457    case D.Darwin{+nd}:
458      darwin_steps(root, k, nd)
459    case D.AzureCloud{az}:
460      AzE.checks(az, O.agreement(a))
461    case D.Launchd{ld}:
462      LdE.checks(root, k, ld)
463
464def node_steps(ns: List<&2, D.Node>, +root: String, +a: A.Architecture) -> IO(List<&2, P.Step>):
465  match ns:
466    case []:
467      IO.pure(List<&2, P.Step>, [])
468    case n <> rest:
469      match n:
470        case D.Node{+k, _, _, _, _, pf, kids, _}:
471          do IO<List<&2, P.Step>>:
472            here : List<&2, P.Step> <- platform_steps(pf, root, k, a)
473            below : List<&2, P.Step> <- node_steps(kids, root, a)
474            after : List<&2, P.Step> <- node_steps(rest, root, a)
475            return List.append(&2, P.Step, here, List.append(&2, P.Step, below, after))
476
477def deployed_path(+root: String) -> String:
478  root ++ "/.git/v-deployed"
479
480def breaks_step(+bs: List<&2, D.Break>, +d: D.Deployment) -> List<&2, P.Step>:
481  Bool.pick(List<&2, P.Step>, DO.breaks_eq(bs, DO.accepted(d)), [],
482    [P.Blocked{"deployment", DO.show_breaks(bs) ++ " (declare it as accepted to deploy)"}])
483
484# What the deploy would break, against what this repository last deployed (recorded in .git, never committed).
485def deployed_steps(+root: String, +d: D.Deployment) -> IO(List<&2, P.Step>):
486  do IO<List<&2, P.Step>>:
487    +text : String <- X.read(deployed_path(root))
488    return breaks_step(DO.breaks(DO.recorded_address(text), DO.recorded_stores(text), d), d)
489
490def plan_all(+root: String, +entry: String, +a: A.Architecture) -> IO(List<&2, P.Step>):
491  do IO<List<&2, P.Step>>:
492    o : P.Observed <- observe(root, entry, SO.release(O.system(a)))
493    stack : List<&2, P.Step> <- CtE.checks(root, O.containers(a), O.externals(a))
494    nodes : List<&2, P.Step> <- node_steps(O.nodes(a), root, a)
495    breaks : List<&2, P.Step> <- deployed_steps(root, O.deployment(a))
496    return List.append(&2, P.Step, stack, List.append(&2, P.Step, nodes, List.append(&2, P.Step, breaks, PO.plan(SO.release(O.system(a)), o))))
497
498# A deploy of the whole plan; afterwards it records the deployment, so the next plan compares against it.
499def deploy_all(+root: String, +entry: String, +a: A.Architecture) -> IO(Unit):
500  do IO<Unit>:
501    +steps : List<&2, P.Step> <- plan_all(root, entry, a)
502    deploy(root, steps)
503    X.write(deployed_path(root), DO.record(O.deployment(a)))
504
505# ---- import: each nix-darwin node's Mac as it is now, as Bend to declare it with ----
506def import_node(p: D.Platform, +root: String) -> IO(Unit):
507  match p:
508    case D.Darwin{nd}:
509      match nd:
510        case ND.NixDarwin{h, _, _, _, _, _, _, _, svs, _, _, _, _, _}:
511          do IO<Unit>:
512            st : List<&2, St.Setting> <- StE.observed(root ++ "/nix/options.tsv", SvO.labels(svs))
513            fw : St.Firewall <- StE.firewall_now()
514            br : List<&2, Br.Brew> <- BrE.observed()
515            IO.print("# " ++ h ++ "\n" ++ StO.import_text(st, fw, br))
516    case _:
517      IO.pure(Unit, Unit{})
518
519def import_nodes(ns: List<&2, D.Node>, +root: String) -> IO(Unit):
520  match ns:
521    case []:
522      IO.pure(Unit, Unit{})
523    case n <> rest:
524      match n:
525        case D.Node{_, _, _, _, _, pf, kids, _}:
526          do IO<Unit>:
527            import_node(pf, root)
528            import_nodes(kids, root)
529            import_nodes(rest, root)
530
531def import_all(+root: String, a: A.Architecture) -> IO(Unit):
532  import_nodes(O.nodes(a), root)