aboutsummaryrefslogtreecommitdiffhomepage
path: root/cmd/app
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/app')
-rw-r--r--cmd/app/app.go26
-rw-r--r--cmd/app/app_merged.go15
-rw-r--r--cmd/app/app_sep.go15
-rw-r--r--cmd/app/app_test.go10
-rw-r--r--cmd/app/doc.go362
-rw-r--r--cmd/app/lock.go19
-rw-r--r--cmd/app/main.go137
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())