diff options
Diffstat (limited to 'cmd/app')
| -rw-r--r-- | cmd/app/app.go | 26 | ||||
| -rw-r--r-- | cmd/app/app_merged.go | 15 | ||||
| -rw-r--r-- | cmd/app/app_sep.go | 15 | ||||
| -rw-r--r-- | cmd/app/app_test.go | 10 | ||||
| -rw-r--r-- | cmd/app/doc.go | 362 | ||||
| -rw-r--r-- | cmd/app/lock.go | 19 | ||||
| -rw-r--r-- | cmd/app/main.go | 137 |
7 files changed, 547 insertions, 37 deletions
diff --git a/cmd/app/app.go b/cmd/app/app.go index 9679413f..ca809334 100644 --- a/cmd/app/app.go +++ b/cmd/app/app.go @@ -14,6 +14,24 @@ import ( "hakurei.app/hst" ) +// base is the root of the persistent state directory. +type base check.Absolute + +// append calls filepath.Join with b as the first element. +func (b *base) append(elem ...string) *check.Absolute { + return (*check.Absolute)(b).Append(elem...) +} + +// initial returns the pathname of the bottom layer. +func (b *base) initial() *check.Absolute { + return b.append("initial") +} + +// state returns the pathname of the home directory for id. +func (b *base) state(id string) *check.Absolute { + return b.append("state", id) +} + // parsePair parses a NUL-delimited quoted paths pair. func parsePair(s string) (source, target *check.Absolute, err error) { var p string @@ -35,7 +53,7 @@ func parsePair(s string) (source, target *check.Absolute, err error) { // corresponding [hst.Config]. func parse( id string, - base *check.Absolute, + base *base, r io.Reader, templateP *string, ) (*hst.Config, error) { @@ -44,7 +62,7 @@ func parse( root := hst.FSOverlay{ Target: fhs.AbsRoot, - Lower: []*check.Absolute{base.Append("initial")}, + Lower: []*check.Absolute{base.initial()}, } c := hst.Config{ ID: id, @@ -65,7 +83,7 @@ func parse( {FilesystemConfig: &root}, {FilesystemConfig: &hst.FSBind{ Target: home, - Source: base.Append("state", id), + Source: base.state(id), Write: true, Ensure: true, }}, @@ -110,7 +128,7 @@ func parse( *templateP = template } c.Identity = v - root.Upper = base.Append("template", template) + root.Upper = base.template(template) } if err := scanOnce(); err != nil { diff --git a/cmd/app/app_merged.go b/cmd/app/app_merged.go new file mode 100644 index 00000000..e0a31f94 --- /dev/null +++ b/cmd/app/app_merged.go @@ -0,0 +1,15 @@ +//go:build merge + +package main + +import "hakurei.app/check" + +// template returns the pathname of an upperdir. +func (b *base) template(template string) *check.Absolute { + return b.append("template", template, "upper") +} + +// work returns the pathname of a workdir. +func (b *base) work(template string) *check.Absolute { + return b.append("template", template, "work") +} diff --git a/cmd/app/app_sep.go b/cmd/app/app_sep.go new file mode 100644 index 00000000..9ec98e41 --- /dev/null +++ b/cmd/app/app_sep.go @@ -0,0 +1,15 @@ +//go:build !merge + +package main + +import "hakurei.app/check" + +// template returns the pathname of an upperdir. +func (b *base) template(template string) *check.Absolute { + return b.append("template", template) +} + +// work returns the pathname of a workdir. +func (b *base) work(template string) *check.Absolute { + return b.append("work", template) +} diff --git a/cmd/app/app_test.go b/cmd/app/app_test.go index 1aab4759..90c0068e 100644 --- a/cmd/app/app_test.go +++ b/cmd/app/app_test.go @@ -13,7 +13,7 @@ import ( func TestParse(t *testing.T) { t.Parallel() - base := fhs.AbsProc.Append("nonexistent") + b := (*base)(fhs.AbsProc.Append("nonexistent")) testCases := []struct { name string data string @@ -74,13 +74,13 @@ talk com.canonical.Unity {FilesystemConfig: &hst.FSOverlay{ Target: fhs.AbsRoot, Lower: []*check.Absolute{ - base.Append("initial"), + b.initial(), }, - Upper: base.Append("template", "nonfree"), + Upper: b.template("nonfree"), }}, {FilesystemConfig: &hst.FSBind{ Target: hst.AbsPrivateTmp.Append("home"), - Source: base.Append("state", "com.discordapp.Discord"), + Source: b.state("com.discordapp.Discord"), Write: true, Ensure: true, }}, @@ -128,7 +128,7 @@ talk com.canonical.Unity got, err := parse( tc.name, - base, + b, strings.NewReader(tc.data), nil, ) diff --git a/cmd/app/doc.go b/cmd/app/doc.go new file mode 100644 index 00000000..ea91315e --- /dev/null +++ b/cmd/app/doc.go @@ -0,0 +1,362 @@ +/* +The app program is a proof-of-concept frontend for cmd/hakurei. + +This program is not covered by the compatibility promise. The command line +interface and configuration syntax may change at any time. + +# Installation + +Compile cmd/app with these arguments: + + go build -trimpath \ + -ldflags='-s -w + -buildid= + -X hakurei.app/internal/info.hsuPath=/usr/local/bin/hsu' \ + ./cmd/app + +Replace /usr/local/bin/hsu with the hakurei installation's absolute hsu +pathname. + +# Sharing files + +Sharing files between apps is only possible with the permissionless shared +filesystem ([cmd/sharefs]), as apps generally do not share credentials. It can +be built without any special linker flags. Create a symlink at +/usr/bin/mount.fuse.sharefs pointing to the sharefs binary when mounting sharefs +via fstab: + + sharefs /sdcard fuse.sharefs rw,noexec,nosuid,nodev,noatime,allow_other,mkdir,source=/var/lib/sdcard,setuid=1023,setgid=1023 0 0 + +Replace /var/lib/sdcard with the location of the backing directory. User and +group id must be specified in numerical form, it must own the backing directory. +The mount point does not have to be /sdcard. + +# General setup + +The entire directory structure containing persistent app data must be created +manually for now, due to the different ownership requirements being impossible +to set up directly through cmd/hsu. + +The environment variable ROSA_APP_PATH should be set to the absolute pathname of +the persistent state directory. This document will use $ROSA_APP_PATH to refer +to the persistent state directory. The $ROSA_APP_PATH directory should be owned +by the user declared in /etc/hsurc and invoking cmd/app, and must be readable +and executable by all subordinate users. This can be done by making it +world-readable and executable, or by adding a group directive to the common +file. + +The $ROSA_APP_PATH/app directory contains app configuration files, named after +their reverse-DNS style application identifier string. It should be owned by +the user invoking cmd/app. + +The $ROSA_APP_PATH/initial directory contains the read-only base of every +app template. Everything within it, including the directory itself, should be +owned by the reserved user, its user and group id obtained by the command: + + app id + +This document assumes a [Void Linux rootfs tarball] is unpacked here. It is +possible to use other Linux distributions; Void linux is selected here because +its package manager works correctly out of the box in the hakurei container +environment. If the use case does not require glibc, [Alpine Linux] or +[Chimera Linux] might be more suitable. + +Once the tarball is unpacked at $ROSA_APP_PATH/initial, create a directory named +chronos in $ROSA_APP_PATH/initial/home, and directories block, bus, class, dev, +and devices in $ROSA_APP_PATH/initial/sys. Then, recursively change ownerships +of files and directories in $ROSA_APP_PATH/initial to the reserved user/group. + +The $ROSA_APP_PATH/lock directory contains lock files guarding entry into +mutable and app containers of every template. It must be owned by the user +invoking cmd/app. + +The $ROSA_APP_PATH/state directory contains app home directories, named after +their reverse-DNS style application identifier string. The directory itself +should be owned by the user invoking cmd/app; each directory inside must be +owned by the subordinate user/group id of its app, obtained by the command: + + app id "$APPID" + +where "$APPID" is the second component of the first line in the configuration +file of the corresponding app. It is considered good practice, but not required, +for these directory to have the permission bits set to 0700. + +The $ROSA_APP_PATH/template directory contains templates that apps are based +on, which are all derived from the initial layer. The directory itself +should be owned by the user invoking cmd/app, and each directory inside should +be owned by the reserved user/group. Their names are used in the first component +of the first line in the configuration file of each app, to specify that the +file is derived from that template. + +The $ROSA_APP_PATH/work directory contains overlay work directories for mutable +containers. It must contain one manually created, empty directory for each +template, named after the template directory itself, owned by the reserved +user/group. The directory itself and its contents should have the permission +bits set to 0700. + +# Snapshots and cloning + +To reduce disk space used by templates, [ZFS clones] or an equivalent can be +used. In such a setup, where each template occupies a different filesystem, the +"merge" build tag is required, and the work directory can be omitted during +directory creation. Instead, the individual template directories contain both +upper and work directories. + +# Configuring the base template + +This section describes basic setup required for the typical desktop use case. +Generally, multiple templates are created to mitigate the global nature of +conventional package managers. The setup described in this section applies to +all templates. For convenience, a "base" template should be created, containing +this setup, and all future templates should be copied from the base template. +This section assumes the template is named "base". + +Create an empty directory at $ROSA_APP_PATH/template/base, owned by the reserved +user, with permission bits set to 0755. Create its corresponding work directory +at $ROSA_APP_PATH/work/base, also owned by the reserved user, with permission +bits set to 0700. Once this is complete, enter its mutable container with the +command: + + app enter --shell=/bin/sh base + +This command overrides the container configuration to use the shell program at +/bin/sh. Normally, cmd/app uses zsh, which is not yet installed. + +Once in the container, populate /etc/resolv.conf with some well-known DNS +service, for example: + + nameserver 8.8.8.8 + nameserver 8.8.4.4 + +The mutable container does not bind the host /etc/resolv.conf. It is generally +recommended to populate it with a well-known service here to avoid trouble in +future template changes. A later section configures regular app containers to +use host configuration. + +After populating nameserver configuration, install zsh using the package manager +provided by the distribution unpacked earlier. On Void Linux, this is done by +the command: + + xbps-install zsh + +Wait for the package installation to complete, configure zsh if necessary, then +exit from the shell, and re-enter the container without overriding the login +shell: + + app enter base + +Some packages are generally required in the container for the typical desktop +use case: xdg-user-dirs to reconfigure "well known" user directories to point +to the inner sharefs mount point, mesa to interact with the GPU, dconf to +configure GTK, xdg-desktop-portal-gtk to have gtk talk to dconf. Fonts generally +need to be installed as well. Additionally, a text editor can be installed for +editing configuration files later on. On Void Linux, these packages are +installed by the command: + + xbps-install \ + vim \ + mesa \ + mesa-dri \ + mesa-intel-dri \ + mesa-vaapi \ + mesa-vulkan-intel \ + mesa-vulkan-radeon \ + mesa-vulkan-overlay-layer \ + dconf \ + xdg-user-dirs \ + xdg-desktop-portal-gtk \ + noto-fonts-ttf \ + noto-fonts-ttf-variable \ + noto-fonts-ttf-extra \ + noto-fonts-emoji \ + noto-fonts-cjk \ + noto-fonts-cjk-variable \ + noto-fonts-cjk-sans \ + noto-fonts-cjk-sans-variable \ + noto-fonts-cjk-serif \ + noto-fonts-cjk-serif-variable + +Add -32bit variants of mesa packages if multilib support is required. Adjust +the package selection based on use case. + +Edit /etc/xdg/user-dirs.defaults and point directories to the inner sharefs +mount point as required. The resulting file should look like this: + + # Default settings for user directories + # + # The values are relative pathnames from the home directory and + # will be translated on a per-path-element basis into the users locale + DESKTOP=../../sdcard/Desktop + DOWNLOAD=../../sdcard/Download + TEMPLATES=../../sdcard/Templates + PUBLICSHARE=../../sdcard/Public + DOCUMENTS=../../sdcard/Documents + MUSIC=../../sdcard/Music + PICTURES=../../sdcard/Pictures + VIDEOS=../../sdcard/Movies + PROJECTS=../../sdcard/Projects + # Another alternative is: + #MUSIC=Documents/Music + #PICTURES=Documents/Pictures + #VIDEOS=Documents/Videos + +If this is configured, it must be applied to the home directory of each app +individually. This can be done for all apps via the shell expression: + + app run | xargs -n 1 echo app run --command=xdg-user-dirs-update + +This must also be done for every newly created app. + +The /etc/dconf directory can be set up to provide defaults across all apps. To +do so, edit /etc/dconf/profile/user: + + user-db:user + system-db:local + system-db:site + system-db:distro + +Then, place files in /etc/dconf/db/local.d containing dconf configuration. For +example: + + [org/gnome/desktop/interface] + gtk-enable-primary-paste=true + color-scheme='prefer-dark' + gtk-theme='adw-gtk3-dark' + icon-theme='Papirus-Dark' + +Themes must be installed in the container. Change colour-scheme and themes +accordingly. Setting gtk-enable-primary-paste restores clipboard behaviour that +GNOME maintainers decided to break. + +After editing these configuration files, update dconf system databases: + + dconf update + +# Common configuration + +The optional $ROSA_APP_PATH/common file contains common configuration included +after the specific configuration of each app. Some bind mounts are required for +almost every graphical program, and many widely used libraries are configured +through the environment. For most setups, these directives are generally +required: + + ; for libudev + ro "/sys/block" + ro "/sys/bus" + ro "/sys/class" + ro "/sys/dev" + ro "/sys/devices" + + env EDITOR=vim + ; for apps to play nice with xdg-dbus-proxy + ro+ "/etc/machine-id" + ; template must have a /etc/resolv.conf file + ro+ "/etc/resolv.conf" + ; group name of the sharefs group configured on host + group media_rw + ; for sharefs + rw "/sdcard" + ; refer to /usr/share/zoneinfo + env TZ=Asia/Tokyo + + ; must be installed first + env XCURSOR_THEME=volantes_cursors + ; must be generated first if using glibc + env LANG=en_GB.UTF-8 + env LC_COLLATE=C + +# Installing an app + +An app is defined by a configuration file in $ROSA_APP_PATH/app, and a +persistent state directory in $ROSA_APP_PATH/state. Before creating an app, its +identity must be decided. Different apps should generally not share an identity. +The next unused identity can be found using the command: + + app next + +If the -v argument is added before the "next" command, cmd/app will additionally +show apps sharing the same identity. + +Once the identity is decided, the subordinate user/group id can be obtained by +the command: + + app id "$APPID" + +where "$APPID" is the identity of this app. A directory must be created under +$ROSA_APP_PATH/state, owned by this user and group id. Its permission bits +should be set to 0700. A configuration file with the same name, owned by the +user invoking cmd/app, must be placed in $ROSA_APP_PATH/app. Its contents are +described in the next section. The reverse-DNS style application identifier +string is submitted to the Wayland display server, and used as part of the +dbus preset if enabled, so the correct identifier must be obtained. If no such +identifier exist, use the domain of the app home page. + +# Configuring an app + +The configuration file starts with two structural directives, and the remaining +lines are freestanding directives applied in order. + +The first structural directive is a line containing two components separated by +the ':' byte. The first component is the template used by the app. the second +component is the decimal representation of the app identity. The second +structural directive is a shell expression passed to the shell serving as the +initial process of the app. + +After the structural directives, each line contains exactly one directive or +comment. Comment lines begin with a ';' byte: these lines are not interpreted by +cmd/app in any way. + +The following section documents currently available freestanding directives. + +# Directives + + interactive start initial process as an interactive shell + gpu expose GPU devices to the container + system_bus enable system bus in the dbus proxy + + wayland expose a Wayland pathname socket via security-context-v1 + x11 expose the X11 pathname socket + dbus enable the per-container xdg-dbus-proxy daemon + pipewire expose a pipewire pathname socket via SecurityContext + + multiarch unblock system calls required for multiarch to work on + multiarch-enabled targets (amd64, arm64) + devel unblock ptrace and friends + userns unblock userns creation and container setup syscalls + net enable network access + abstract enable access to external abstract unix sockets + tty unblock dangerous terminal I/O (faking input) + mapuid map the target user id to the user id of the user + invoking cmd/app in the container user namespace + device mount /dev/ from the init mount namespace as is in the + container mount namespace + + share_runtime share XDG_RUNTIME_DIR between containers under the same + identity + share_tmpdir share TMPDIR between containers under the same identity + + username <name> set username of the emulated user + hostname <name> set container hostname + env KEY=VALUE set an environment variable for the initial process + + ro "pathname" make a host path available to the container; the string + must be presented in Go string literal syntax, to + specify a different inner pathname, end the outer + pathname with NUL and specify the inner pathname after + the NUL byte + rw "pathname" like ro, but the resulting mount entry is made writable + ro+ "pathname" like ro, but is skipped if pathname does not exist + rw+ "pathname" like ro+, but the resulting mount entry is made writable + + own name add an own policy for the dbus proxy + own_system name like own, but for the system bus if enabled + talk name add a talk policy for the dbus proxy + talk_system name like talk, but for the system bus if enabled + +[cmd/sharefs]: https://pkg.go.dev/hakurei.app/cmd/sharefs +[Void Linux rootfs tarball]: https://voidlinux.org/download +[Alpine Linux]: https://alpinelinux.org +[Chimera Linux]: https://chimera-linux.org +[ZFS clones]: https://openzfs.github.io/openzfs-docs/man/v2.4/8/zfs-clone.8.html +*/ +package main diff --git a/cmd/app/lock.go b/cmd/app/lock.go index 88068381..2a419ea1 100644 --- a/cmd/app/lock.go +++ b/cmd/app/lock.go @@ -8,7 +8,6 @@ import ( "strings" "syscall" - "hakurei.app/check" "hakurei.app/fhs" "hakurei.app/hst" "hakurei.app/internal/env" @@ -24,15 +23,15 @@ func (e MutationConflictError) Error() string { } // informTemplate guards intention of a template or its derivatives. -func informTemplate(base *check.Absolute, name string, mutable bool) (func() error, error) { - mu := lockedfile.MutexAt(base.Append("lock", name).String()) +func informTemplate(b *base, name string, mutable bool) (func() error, error) { + mu := lockedfile.MutexAt(b.append("lock", name).String()) if unlock, err := mu.Lock(); err != nil { return nil, err } else { defer unlock() } - marker := base.Append("lock", "."+name) + marker := b.append("lock", "."+name) if p, err := os.ReadFile(marker.String()); err == nil { if _, err = os.Stat(fhs.AbsProc.Append(string(p)).String()); err == nil { return nil, MutationConflictError(p) @@ -72,8 +71,8 @@ func informTemplate(base *check.Absolute, name string, mutable bool) (func() err if !root.Target.Is(fhs.AbsRoot) || len(root.Lower) != 1 || - !root.Lower[0].Is(base.Append("initial")) || - !root.Upper.Is(base.Append("template", name)) || + !root.Lower[0].Is(b.initial()) || + !root.Upper.Is(b.template(name)) || root.Work != nil { continue } @@ -102,12 +101,12 @@ func informTemplate(base *check.Absolute, name string, mutable bool) (func() err } // acquireTemplate obtains exclusivity of a template. -func acquireTemplate(base *check.Absolute, name string) (remove func() error, err error) { - return informTemplate(base, name, true) +func acquireTemplate(b *base, name string) (remove func() error, err error) { + return informTemplate(b, name, true) } // enterTemplate checks against exclusivity of a template. -func enterTemplate(base *check.Absolute, name string) error { - _, err := informTemplate(base, name, false) +func enterTemplate(b *base, name string) error { + _, err := informTemplate(b, name, false) return err } diff --git a/cmd/app/main.go b/cmd/app/main.go index 00bceb57..9b51d601 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -1,24 +1,25 @@ -// The app program is a proof-of-concept frontend for cmd/hakurei. -// -// This program is not covered by the compatibility promise. The command line -// interface and configuration syntax may change at any time. package main import ( "context" "errors" + "fmt" "io" "log" "os" "os/exec" "os/signal" "path/filepath" + "slices" + "strconv" + "strings" "syscall" "hakurei.app/check" "hakurei.app/command" "hakurei.app/fhs" "hakurei.app/hst" + "hakurei.app/internal/outcome" "hakurei.app/message" ) @@ -36,7 +37,7 @@ func main() { flagBase string flagInsecure bool - base, template, initial *check.Absolute + b *base ) c := command.New(os.Stderr, log.Printf, "app", func([]string) (err error) { msg.SwapVerbose(flagVerbose) @@ -44,14 +45,15 @@ func main() { if flagBase == "" { flagBase = "state" } + + var a *check.Absolute if flagBase, err = filepath.Abs(flagBase); err != nil { return - } else if base, err = check.NewAbs(flagBase); err != nil { + } else if a, err = check.NewAbs(flagBase); err != nil { return } + b = (*base)(a) - template = base.Append("template") - initial = base.Append("initial") return }).Flag( &flagVerbose, @@ -76,7 +78,7 @@ func main() { "enter", "Enter mutable state template", func(args []string) error { if len(args) != 1 { - return list(template, true) + return list(b.append("template"), true) } config := hst.Config{ @@ -86,9 +88,9 @@ func main() { Filesystem: []hst.FilesystemConfigJSON{ {FilesystemConfig: &hst.FSOverlay{ Target: fhs.AbsRoot, - Lower: []*check.Absolute{initial}, - Upper: template.Append(args[0]), - Work: base.Append("work", args[0]), + Lower: []*check.Absolute{b.initial()}, + Upper: b.template(args[0]), + Work: b.work(args[0]), }}, {FilesystemConfig: &hst.FSEphemeral{ Target: fhs.AbsTmp, @@ -122,7 +124,7 @@ func main() { config.Container.Home = a } - remove, err := acquireTemplate(base, args[0]) + remove, err := acquireTemplate(b, args[0]) if err != nil { return err } @@ -148,19 +150,19 @@ func main() { "run", "Start the named application", func(args []string) error { if len(args) < 1 { - return list(base.Append("app"), false) + return list(b.append("app"), false) } var config *hst.Config var r io.Reader - f, err := os.Open(base.Append("app", args[0]).String()) + f, err := os.Open(b.append("app", args[0]).String()) if err != nil { return err } r = f var common *os.File - if common, err = os.Open(base.Append("common").String()); err != nil { + if common, err = os.Open(b.append("common").String()); err != nil { if !errors.Is(err, os.ErrNotExist) { _ = f.Close() return err @@ -170,7 +172,7 @@ func main() { } var name string - config, err = parse(args[0], base, r, &name) + config, err = parse(args[0], b, r, &name) if closeErr := f.Close(); err == nil { err = closeErr } @@ -187,7 +189,7 @@ func main() { config.Container.Args[2] = flagCommand } - if err = enterTemplate(base, name); err != nil { + if err = enterTemplate(b, name); err != nil { return err } return run(ctx, msg, flagInsecure, config, args[1:]...) @@ -200,6 +202,105 @@ func main() { ) } + c.NewCommand( + "id", "Show user/group id of the specified appid", + func(args []string) error { + var appid int + switch len(args) { + case 0: + log.Println("appid not specified, assuming reserved user") + break + + case 1: + var err error + appid, err = strconv.Atoi(args[0]) + if err != nil { + return os.ErrInvalid + } + break + + default: + return errors.New("id requires 1 argument") + } + + fmt.Println(hst.ToUser(outcome.Info().User, appid)) + return nil + }, + ) + + c.NewCommand( + "next", "Find next unused identity", + func([]string) error { + var names []string + if dents, err := os.ReadDir(b.append("app").String()); err != nil { + return err + } else { + names = make([]string, 0, len(dents)) + for _, dent := range dents { + name := dent.Name() + if dent.IsDir() || (len(name) > 0 && name[0] == '.') { + continue + } + names = append(names, name) + } + } + + apps := make([]*hst.Config, len(names)) + for i, name := range names { + r, err := os.Open(b.append("app", name).String()) + if err != nil { + return err + } + + apps[i], err = parse(name, b, r, nil) + if closeErr := r.Close(); err == nil { + err = closeErr + } + if err != nil { + return err + } + } + + p := make(map[int][]*hst.Config) + for _, config := range apps { + p[config.Identity] = append(p[config.Identity], config) + } + + identities := make([]int, 0, len(p)) + for identity, a := range p { + identities = append(identities, identity) + if msg.IsVerbose() && len(a) != 1 { + ids := make([]string, len(a)) + for i, config := range a { + ids[i] = config.ID + } + slices.Sort(ids) + msg.Verbosef( + "%s shares identity %d", + strings.Join(ids, ", "), identity, + ) + } + } + + if len(identities) == 0 { + // 0 is the reserved identity + fmt.Println(1) + return nil + } + + slices.Sort(identities) + next := identities[0] - 1 + for _, identity := range identities { + if identity != next+1 { + break + } + next = identity + } + fmt.Println(next + 1) + return nil + }, + ) + c.MustParse(os.Args[1:], func(err error) { if e, ok := errors.AsType[*exec.ExitError](err); ok && e != nil { os.Exit(e.ExitCode()) |
