DodoApp: API to create new app

Change-Id: I20d73ef17cc03073c913fceb4f3bed7a26754cea
diff --git a/charts/dodo-app/templates/install.yaml b/charts/dodo-app/templates/install.yaml
index 30a6553..b8c1359 100644
--- a/charts/dodo-app/templates/install.yaml
+++ b/charts/dodo-app/templates/install.yaml
@@ -1,23 +1,35 @@
 apiVersion: rbac.authorization.k8s.io/v1
-kind: Role
+kind: ClusterRole
 metadata:
-  name: job-creator
+  name: {{ .Values.clusterRoleName }}
 rules:
 - apiGroups:
+  - ""
+  resources:
+  - namespaces
+  verbs:
+  - create
+- apiGroups:
   - "batch"
   resources:
   - jobs
   verbs:
   - create
+- apiGroups:
+  - "helm.toolkit.fluxcd.io"
+  resources:
+  - helmreleases
+  verbs:
+  - get
 ---
 apiVersion: rbac.authorization.k8s.io/v1
-kind: RoleBinding
+kind: ClusterRoleBinding
 metadata:
-  name: job-creator
+  name: {{ .Values.clusterRoleName }}
 roleRef:
   apiGroup: rbac.authorization.k8s.io
-  kind: Role
-  name: job-creator
+  kind: ClusterRole
+  name: {{ .Values.clusterRoleName }}
 subjects:
 - kind: ServiceAccount
   name: default
diff --git a/charts/dodo-app/values.yaml b/charts/dodo-app/values.yaml
index c606c50..66588cf 100644
--- a/charts/dodo-app/values.yaml
+++ b/charts/dodo-app/values.yaml
@@ -2,6 +2,7 @@
   repository: giolekva/pcloud-installer
   tag: latest
   pullPolicy: Always
+clusterRoleName: dodo-app-creator
 repoAddr: 192.168.0.11
 sshPrivateKey: key
 self: ""
diff --git a/core/installer/app_configs/dodo_app.cue b/core/installer/app_configs/dodo_app.cue
index e3add4f..32e0c45 100644
--- a/core/installer/app_configs/dodo_app.cue
+++ b/core/installer/app_configs/dodo_app.cue
@@ -6,6 +6,7 @@
 
 input: {
 	repoAddr: string
+	registerWorkerAddr: string
 	appId: string
 	sshPrivateKey: string
 }
@@ -158,7 +159,7 @@
 			repoAddr: input.repoAddr
 			sshPrivateKey: base64.Encode(null, input.sshPrivateKey)
 			runCfg: base64.Encode(null, json.Marshal(_app.runConfiguration))
-			manager: "http://dodo-app.\(release.namespace).svc.cluster.local/register-worker"
+			manager: input.registerWorkerAddr
 			volumes: [
 				for key, value in _app.volumes {
 					name: value.name
diff --git a/core/installer/app_test.go b/core/installer/app_test.go
index db8d5b8..44d2dbd 100644
--- a/core/installer/app_test.go
+++ b/core/installer/app_test.go
@@ -315,9 +315,10 @@
 		AppDir:        "/foo/bar",
 	}
 	_, err = app.Render(release, env, map[string]any{
-		"repoAddr":      "",
-		"appId":         "",
-		"sshPrivateKey": "",
+		"repoAddr":           "",
+		"registerWorkerAddr": "",
+		"appId":              "",
+		"sshPrivateKey":      "",
 	}, nil)
 	if err != nil {
 		t.Fatal(err)
@@ -337,8 +338,8 @@
 		Namespace: "foo",
 	}
 	values := map[string]any{
-		"appName":          "",
 		"repoAddr":         "",
+		"repoHost":         "",
 		"gitRepoPublicKey": "",
 	}
 	rendered, err := a.Render(release, env, values, nil)
diff --git a/core/installer/cmd/dodo_app.go b/core/installer/cmd/dodo_app.go
index 3e4f6bd..5d54147 100644
--- a/core/installer/cmd/dodo_app.go
+++ b/core/installer/cmd/dodo_app.go
@@ -2,8 +2,6 @@
 
 import (
 	"encoding/json"
-	"errors"
-	"fmt"
 	"log"
 	"os"
 
@@ -95,7 +93,8 @@
 	if err != nil {
 		return err
 	}
-	softClient, err := soft.NewClient(dodoAppFlags.repoAddr, sshKey, log.Default())
+	cg := soft.RealClientGetter{}
+	softClient, err := cg.Get(dodoAppFlags.repoAddr, sshKey, log.Default())
 	if err != nil {
 		return err
 	}
@@ -103,144 +102,25 @@
 	if err != nil {
 		return err
 	}
-	if err := softClient.AddRepository("config"); err == nil {
-		repo, err := softClient.GetRepo("config")
-		if err != nil {
-			return err
-		}
-		appRepo := installer.NewInMemoryAppRepository(installer.CreateAllApps())
-		app, err := installer.FindEnvApp(appRepo, "dodo-app-instance")
-		if err != nil {
-			return err
-		}
-		nsc := installer.NewNoOpNamespaceCreator()
-		if err != nil {
-			return err
-		}
-		hf := installer.NewGitHelmFetcher()
-		m, err := installer.NewAppManager(repo, nsc, jc, hf, "/")
-		if err != nil {
-			return err
-		}
-		if _, err := m.Install(app, "app", "/app", dodoAppFlags.namespace, map[string]any{
-			"appName":          "app",
-			"repoAddr":         softClient.GetRepoAddress("app"),
-			"gitRepoPublicKey": dodoAppFlags.gitRepoPublicKey,
-		}, installer.WithConfig(&env)); err != nil {
-			return err
-		}
-		if cfg, err := m.FindInstance("app"); err != nil {
-			return err
-		} else {
-			fluxKeys, ok := cfg.Input["fluxKeys"]
-			if !ok {
-				return fmt.Errorf("Fluxcd keys not found")
-			}
-			fluxPublicKey, ok := fluxKeys.(map[string]any)["public"]
-			if !ok {
-				return fmt.Errorf("Fluxcd keys not found")
-			}
-			if err := softClient.AddUser("fluxcd", fluxPublicKey.(string)); err != nil {
-				return err
-			}
-			if err := softClient.AddReadOnlyCollaborator("app", "fluxcd"); err != nil {
-				return err
-			}
-		}
-	} else if !errors.Is(err, soft.ErrorAlreadyExists) {
+	nsc, err := newNSCreator()
+	if err != nil {
 		return err
 	}
-	if err := softClient.AddRepository("app"); err == nil {
-		repo, err := softClient.GetRepo("app")
-		if err != nil {
+	s := welcome.NewDodoAppServer(
+		dodoAppFlags.port,
+		dodoAppFlags.self,
+		string(sshKey),
+		dodoAppFlags.gitRepoPublicKey,
+		softClient,
+		dodoAppFlags.namespace,
+		nsc,
+		jc,
+		env,
+	)
+	if dodoAppFlags.appAdminKey != "" {
+		if err := s.CreateApp("app", dodoAppFlags.appAdminKey); err != nil {
 			return err
 		}
-		if err := initRepo(repo); err != nil {
-			return err
-		}
-		if err := welcome.UpdateDodoApp("app", softClient, dodoAppFlags.namespace, string(sshKey), jc, &env); err != nil {
-			return err
-		}
-		if err := softClient.AddWebhook("app", fmt.Sprintf("http://%s/update", dodoAppFlags.self), "--active=true", "--events=push", "--content-type=json"); err != nil {
-			return err
-		}
-		if err := softClient.AddUser("app", dodoAppFlags.appAdminKey); err != nil {
-			return err
-		}
-		if err := softClient.AddReadWriteCollaborator("app", "app"); err != nil {
-			return err
-		}
-	} else if !errors.Is(err, soft.ErrorAlreadyExists) {
-		return err
 	}
-	s := welcome.NewDodoAppServer(dodoAppFlags.port, string(sshKey), softClient, dodoAppFlags.namespace, jc, env)
 	return s.Start()
 }
-
-const goMod = `module dodo.app
-
-go 1.18
-`
-
-const mainGo = `package main
-
-import (
-	"flag"
-	"fmt"
-	"log"
-	"net/http"
-)
-
-var port = flag.Int("port", 8080, "Port to listen on")
-
-func handler(w http.ResponseWriter, r *http.Request) {
-	fmt.Fprintln(w, "Hello from Dodo App!")
-}
-
-func main() {
-	flag.Parse()
-	http.HandleFunc("/", handler)
-	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
-}
-`
-
-const appCue = `app: {
-	type: "golang:1.22.0"
-	run: "main.go"
-	ingress: {
-		network: "Private" // or Public
-		subdomain: "testapp"
-		auth: enabled: false
-	}
-}
-`
-
-func initRepo(repo soft.RepoIO) error {
-	return repo.Do(func(fs soft.RepoFS) (string, error) {
-		{
-			w, err := fs.Writer("go.mod")
-			if err != nil {
-				return "", err
-			}
-			defer w.Close()
-			fmt.Fprint(w, goMod)
-		}
-		{
-			w, err := fs.Writer("main.go")
-			if err != nil {
-				return "", err
-			}
-			defer w.Close()
-			fmt.Fprintf(w, "%s", mainGo)
-		}
-		{
-			w, err := fs.Writer("app.cue")
-			if err != nil {
-				return "", err
-			}
-			defer w.Close()
-			fmt.Fprint(w, appCue)
-		}
-		return "go web app template", nil
-	})
-}
diff --git a/core/installer/soft/client.go b/core/installer/soft/client.go
index 473efeb..4a5021e 100644
--- a/core/installer/soft/client.go
+++ b/core/installer/soft/client.go
@@ -26,9 +26,12 @@
 	Address() string
 	Signer() ssh.Signer
 	GetPublicKeys() ([]string, error)
+	RepoExists(name string) (bool, error)
 	GetRepo(name string) (RepoIO, error)
 	GetRepoAddress(name string) string
 	AddRepository(name string) error
+	UserExists(name string) (bool, error)
+	FindUser(pubKey string) (string, error)
 	AddUser(name, pubKey string) error
 	AddPublicKey(user string, pubKey string) error
 	RemovePublicKey(user string, pubKey string) error
@@ -92,6 +95,34 @@
 	return ss.signer
 }
 
+func (ss *realClient) UserExists(name string) (bool, error) {
+	log.Printf("Adding user %s", name)
+	out, err := ss.RunCommand("user", "list")
+	if err != nil {
+		return false, err
+	}
+	return slices.Contains(strings.Fields(out), name), nil
+}
+
+func (ss *realClient) FindUser(pubKey string) (string, error) {
+	log.Printf("Finding user %s", pubKey)
+	pk := strings.Join(strings.Fields(pubKey)[:2], " ")
+	out, err := ss.RunCommand("user", "list")
+	if err != nil {
+		return "", err
+	}
+	for _, user := range strings.Fields(out) {
+		info, err := ss.RunCommand("user", "info", user)
+		if err != nil {
+			return "", err
+		}
+		if strings.Contains(info, pk) {
+			return user, nil
+		}
+	}
+	return "", nil
+}
+
 func (ss *realClient) AddUser(name, pubKey string) error {
 	log.Printf("Adding user %s", name)
 	if _, err := ss.RunCommand("user", "create", name); err != nil {
@@ -138,10 +169,7 @@
 	return buf.String(), err
 }
 
-func (ss *realClient) repoExists(name string) (bool, error) {
-	// if err := ss.RunCommand("repo", "info", name); err == nil {
-	// 	return ErrorAlreadyExists
-	// }
+func (ss *realClient) RepoExists(name string) (bool, error) {
 	out, err := ss.RunCommand("repo", "list")
 	if err != nil {
 		return false, err
@@ -151,7 +179,7 @@
 
 func (ss *realClient) AddRepository(name string) error {
 	log.Printf("Adding repository %s", name)
-	if ok, err := ss.repoExists(name); ok {
+	if ok, err := ss.RepoExists(name); ok {
 		return ErrorAlreadyExists
 	} else if err != nil {
 		return err
diff --git a/core/installer/values-tmpl/dodo-app-instance.cue b/core/installer/values-tmpl/dodo-app-instance.cue
index e783ffe..e22f177 100644
--- a/core/installer/values-tmpl/dodo-app-instance.cue
+++ b/core/installer/values-tmpl/dodo-app-instance.cue
@@ -3,8 +3,8 @@
 )
 
 input: {
-	appName: string
 	repoAddr: string
+	repoHost: string
 	gitRepoPublicKey: string
 	// TODO(gio): auto generate
 	fluxKeys: #SSHKey
@@ -22,7 +22,7 @@
 		apiVersion: "kustomize.toolkit.fluxcd.io/v1"
 		kind: "Kustomization"
 		metadata: {
-			name: input.appName
+			name: "app"
 			namespace: release.namespace
 		}
 		spec: {
@@ -41,26 +41,26 @@
 		kind: "Secret"
 		type: "Opaque"
 		metadata: {
-			name: input.appName
+			name: "app"
 			namespace: release.namespace
 		}
 		data: {
 			identity: base64.Encode(null, input.fluxKeys.private)
 			"identity.pub": base64.Encode(null, input.fluxKeys.public)
-			known_hosts: base64.Encode(null, "soft-serve.\(release.namespace).svc.cluster.local \(input.gitRepoPublicKey)")
+			known_hosts: base64.Encode(null, "\(input.repoHost) \(input.gitRepoPublicKey)")
 		}
 	}
 	"config-source": {
 		apiVersion: "source.toolkit.fluxcd.io/v1"
 		kind: "GitRepository"
 		metadata: {
-			name: input.appName
+			name: "app"
 			namespace: release.namespace
 		}
 		spec: {
 			interval: "1m0s"
 			ref: branch: "dodo"
-			secretRef: name: input.appName
+			secretRef: name: "app"
 			timeout: "60s"
 			url: input.repoAddr
 		}
diff --git a/core/installer/welcome/dodo_app.go b/core/installer/welcome/dodo_app.go
index e032393..f903d9b 100644
--- a/core/installer/welcome/dodo_app.go
+++ b/core/installer/welcome/dodo_app.go
@@ -10,42 +10,58 @@
 
 	"github.com/giolekva/pcloud/core/installer"
 	"github.com/giolekva/pcloud/core/installer/soft"
+
+	"github.com/gorilla/mux"
 )
 
 type DodoAppServer struct {
-	port      int
-	sshKey    string
-	client    soft.Client
-	namespace string
-	env       installer.EnvConfig
-	jc        installer.JobCreator
-	workers   map[string]map[string]struct{}
+	port             int
+	self             string
+	sshKey           string
+	gitRepoPublicKey string
+	client           soft.Client
+	namespace        string
+	env              installer.EnvConfig
+	nsc              installer.NamespaceCreator
+	jc               installer.JobCreator
+	workers          map[string]map[string]struct{}
+	appNs            map[string]string
 }
 
+// TODO(gio): Initialize appNs on startup
 func NewDodoAppServer(
 	port int,
+	self string,
 	sshKey string,
+	gitRepoPublicKey string,
 	client soft.Client,
 	namespace string,
+	nsc installer.NamespaceCreator,
 	jc installer.JobCreator,
 	env installer.EnvConfig,
 ) *DodoAppServer {
 	return &DodoAppServer{
 		port,
+		self,
 		sshKey,
+		gitRepoPublicKey,
 		client,
 		namespace,
 		env,
+		nsc,
 		jc,
 		map[string]map[string]struct{}{},
+		map[string]string{},
 	}
 }
 
 func (s *DodoAppServer) Start() error {
-	http.HandleFunc("/update", s.handleUpdate)
-	http.HandleFunc("/register-worker", s.handleRegisterWorker)
-	http.HandleFunc("/api/add-admin-key", s.handleAddAdminKey)
-	return http.ListenAndServe(fmt.Sprintf(":%d", s.port), nil)
+	r := mux.NewRouter()
+	r.HandleFunc("/update", s.handleUpdate)
+	r.HandleFunc("/register-worker", s.handleRegisterWorker).Methods(http.MethodPost)
+	r.HandleFunc("/api/apps", s.handleCreateApp).Methods(http.MethodPost)
+	r.HandleFunc("/api/add-admin-key", s.handleAddAdminKey).Methods(http.MethodPost)
+	return http.ListenAndServe(fmt.Sprintf(":%d", s.port), r)
 }
 
 type updateReq struct {
@@ -66,12 +82,12 @@
 		fmt.Println(err)
 		return
 	}
-	if req.Ref != "refs/heads/master" || strings.HasPrefix(req.Repository.Name, "dodo") {
+	if req.Ref != "refs/heads/master" || strings.HasPrefix(req.Repository.Name, "config") {
 		return
 	}
 	go func() {
 		time.Sleep(20 * time.Second)
-		if err := UpdateDodoApp(req.Repository.Name, s.client, s.namespace, s.sshKey, s.jc, &s.env); err != nil {
+		if err := s.updateDodoApp(req.Repository.Name, s.appNs[req.Repository.Name]); err != nil {
 			fmt.Println(err)
 		}
 	}()
@@ -100,6 +116,138 @@
 	s.workers[req.AppId][req.Address] = struct{}{}
 }
 
+type createAppReq struct {
+	AdminPublicKey string `json:"adminPublicKey"`
+}
+
+type createAppResp struct {
+	AppName string `json:"appName"`
+}
+
+func (s *DodoAppServer) handleCreateApp(w http.ResponseWriter, r *http.Request) {
+	var req createAppReq
+	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+		http.Error(w, err.Error(), http.StatusBadRequest)
+		return
+	}
+	g := installer.NewFixedLengthRandomNameGenerator(3)
+	appName, err := g.Generate()
+	if err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	if err := s.CreateApp(appName, req.AdminPublicKey); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	resp := createAppResp{appName}
+	if err := json.NewEncoder(w).Encode(resp); err != nil {
+		http.Error(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+func (s *DodoAppServer) CreateApp(appName, adminPublicKey string) error {
+	fmt.Printf("Creating app: %s\n", appName)
+	if ok, err := s.client.RepoExists(appName); err != nil {
+		return err
+	} else if ok {
+		return nil
+	}
+	if err := s.client.AddRepository(appName); err != nil {
+		return err
+	}
+	appRepo, err := s.client.GetRepo(appName)
+	if err != nil {
+		return err
+	}
+	if err := InitRepo(appRepo); err != nil {
+		return err
+	}
+	apps := installer.NewInMemoryAppRepository(installer.CreateAllApps())
+	app, err := installer.FindEnvApp(apps, "dodo-app-instance")
+	if err != nil {
+		return err
+	}
+	suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
+	suffix, err := suffixGen.Generate()
+	if err != nil {
+		return err
+	}
+	namespace := fmt.Sprintf("%s%s%s", s.env.NamespacePrefix, app.Namespace(), suffix)
+	s.appNs[appName] = namespace
+	if err := s.updateDodoApp(appName, namespace); err != nil {
+		return err
+	}
+	if ok, err := s.client.RepoExists("config"); err != nil {
+		return err
+	} else if !ok {
+		if err := s.client.AddRepository("config"); err != nil {
+			return err
+		}
+	}
+	repo, err := s.client.GetRepo("config")
+	if err != nil {
+		return err
+	}
+	hf := installer.NewGitHelmFetcher()
+	m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, "/")
+	if err != nil {
+		return err
+	}
+	if _, err := m.Install(app, appName, "/"+appName, namespace, map[string]any{
+		"repoAddr":         s.client.GetRepoAddress(appName),
+		"repoHost":         strings.Split(s.client.Address(), ":")[0],
+		"gitRepoPublicKey": s.gitRepoPublicKey,
+	}, installer.WithConfig(&s.env)); err != nil {
+		return err
+	}
+	cfg, err := m.FindInstance(appName)
+	if err != nil {
+		return err
+	}
+	fluxKeys, ok := cfg.Input["fluxKeys"]
+	if !ok {
+		return fmt.Errorf("Fluxcd keys not found")
+	}
+	fluxPublicKey, ok := fluxKeys.(map[string]any)["public"]
+	if !ok {
+		return fmt.Errorf("Fluxcd keys not found")
+	}
+	if ok, err := s.client.UserExists("fluxcd"); err != nil {
+		return err
+	} else if ok {
+		if err := s.client.AddPublicKey("fluxcd", fluxPublicKey.(string)); err != nil {
+			return err
+		}
+	} else {
+		if err := s.client.AddUser("fluxcd", fluxPublicKey.(string)); err != nil {
+			return err
+		}
+	}
+	if err := s.client.AddReadOnlyCollaborator(appName, "fluxcd"); err != nil {
+		return err
+	}
+	if err := s.client.AddWebhook(appName, fmt.Sprintf("http://%s/update", s.self), "--active=true", "--events=push", "--content-type=json"); err != nil {
+		return err
+	}
+	if user, err := s.client.FindUser(adminPublicKey); err != nil {
+		return err
+	} else if user != "" {
+		if err := s.client.AddReadWriteCollaborator(appName, user); err != nil {
+			return err
+		}
+	} else {
+		if err := s.client.AddUser(appName, adminPublicKey); err != nil {
+			return err
+		}
+		if err := s.client.AddReadWriteCollaborator(appName, appName); err != nil {
+			return err
+		}
+	}
+	return nil
+}
+
 type addAdminKeyReq struct {
 	Public string `json:"public"`
 }
@@ -116,22 +264,17 @@
 	}
 }
 
-func UpdateDodoApp(name string, client soft.Client, namespace string, sshKey string, jc installer.JobCreator, env *installer.EnvConfig) error {
-	repo, err := client.GetRepo(name)
-	if err != nil {
-		return err
-	}
-	nsc := installer.NewNoOpNamespaceCreator()
+func (s *DodoAppServer) updateDodoApp(name, namespace string) error {
+	repo, err := s.client.GetRepo(name)
 	if err != nil {
 		return err
 	}
 	hf := installer.NewGitHelmFetcher()
-	m, err := installer.NewAppManager(repo, nsc, jc, hf, "/.dodo")
+	m, err := installer.NewAppManager(repo, s.nsc, s.jc, hf, "/.dodo")
 	if err != nil {
 		return err
 	}
 	appCfg, err := soft.ReadFile(repo, "app.cue")
-	fmt.Println(string(appCfg))
 	if err != nil {
 		return err
 	}
@@ -146,11 +289,12 @@
 		"/.dodo/app",
 		namespace,
 		map[string]any{
-			"repoAddr":      repo.FullAddress(),
-			"appId":         name,
-			"sshPrivateKey": sshKey,
+			"repoAddr":           repo.FullAddress(),
+			"registerWorkerAddr": fmt.Sprintf("http://%s/register-worker", s.self),
+			"appId":              name,
+			"sshPrivateKey":      s.sshKey,
 		},
-		installer.WithConfig(env),
+		installer.WithConfig(&s.env),
 		installer.WithLocalChartGenerator(lg),
 		installer.WithBranch("dodo"),
 		installer.WithForce(),
@@ -159,3 +303,71 @@
 	}
 	return nil
 }
+
+const goMod = `module dodo.app
+
+go 1.18
+`
+
+const mainGo = `package main
+
+import (
+	"flag"
+	"fmt"
+	"log"
+	"net/http"
+)
+
+var port = flag.Int("port", 8080, "Port to listen on")
+
+func handler(w http.ResponseWriter, r *http.Request) {
+	fmt.Fprintln(w, "Hello from Dodo App!")
+}
+
+func main() {
+	flag.Parse()
+	http.HandleFunc("/", handler)
+	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
+}
+`
+
+const appCue = `app: {
+	type: "golang:1.22.0"
+	run: "main.go"
+	ingress: {
+		network: "Private" // or Public
+		subdomain: "testapp"
+		auth: enabled: false
+	}
+}
+`
+
+func InitRepo(repo soft.RepoIO) error {
+	return repo.Do(func(fs soft.RepoFS) (string, error) {
+		{
+			w, err := fs.Writer("go.mod")
+			if err != nil {
+				return "", err
+			}
+			defer w.Close()
+			fmt.Fprint(w, goMod)
+		}
+		{
+			w, err := fs.Writer("main.go")
+			if err != nil {
+				return "", err
+			}
+			defer w.Close()
+			fmt.Fprintf(w, "%s", mainGo)
+		}
+		{
+			w, err := fs.Writer("app.cue")
+			if err != nil {
+				return "", err
+			}
+			defer w.Close()
+			fmt.Fprint(w, appCue)
+		}
+		return "go web app template", nil
+	})
+}
diff --git a/core/installer/welcome/env_test.go b/core/installer/welcome/env_test.go
index d3f5d42..4acc576 100644
--- a/core/installer/welcome/env_test.go
+++ b/core/installer/welcome/env_test.go
@@ -109,6 +109,10 @@
 	return []string{}, nil
 }
 
+func (f fakeSoftServeClient) RepoExists(name string) (bool, error) {
+	return false, nil
+}
+
 func (f fakeSoftServeClient) GetRepo(name string) (soft.RepoIO, error) {
 	var l sync.Mutex
 	return mockRepoIO{soft.NewBillyRepoFS(f.envFS), "foo.bar", f.t, &l}, nil
@@ -122,6 +126,13 @@
 	return nil
 }
 
+func (f fakeSoftServeClient) UserExists(name string) (bool, error) {
+	return false, nil
+}
+func (f fakeSoftServeClient) FindUser(pubKey string) (string, error) {
+	return "", nil
+}
+
 func (f fakeSoftServeClient) AddUser(name, pubKey string) error {
 	return nil
 }