appmanager: replace svelte implementation with go based one
diff --git a/core/installer/welcome/appmanager-tmpl/app.html b/core/installer/welcome/appmanager-tmpl/app.html
new file mode 100644
index 0000000..df7501b
--- /dev/null
+++ b/core/installer/welcome/appmanager-tmpl/app.html
@@ -0,0 +1,214 @@
+{{ define "schema-form" }}
+  {{ $readonly := .ReadOnly }}
+  {{ $networks := .AvailableNetworks }}
+  {{ $data := .Data }}
+  {{ range $name, $schema := .Schema.properties }}
+    {{ if eq $schema.type "string" }}
+      <label for="{{ $name }}">
+        <span>{{ $name }}</span>
+      </label>
+      {{ if eq (index $schema "role") "network" }}
+        <select oninput="valueChanged({{ $name }}, this.value)" {{ if $readonly }}disabled{{ end }} >
+          {{ if not $readonly }}<option disabled selected value> -- select an option -- </option>{{ end }}
+          {{ range $networks }}
+            <option {{if eq .Name (index $data $name) }}selected{{ end }}>{{ .Name }}</option>
+          {{ end }}
+        </select>
+      {{ else }}
+        <input type="text" name="{{ $name }}" oninput="valueChanged({{ $name }}, this.value)" {{ if $readonly }}disabled{{ end }} value="{{ index $data $name }}"/>
+      {{ end }}
+    {{ end }}
+  {{ end }}
+{{ end }}
+
+{{ define "main" }}
+{{ $instance := .Instance }}
+<h1>{{ .App.Icon }}{{ .App.Name }}</h1>
+<pre id="readme"></pre>
+
+{{ $schema := .App.ConfigSchema }}
+{{ $networks := .AvailableNetworks }}
+
+<form id="config-form">
+    {{ if $instance }}
+      {{ template "schema-form" (dict "Schema" $schema "AvailableNetworks" $networks "ReadOnly" false "Data" $instance.Config) }}
+    {{ else }}
+      {{ template "schema-form" (dict "Schema" $schema "AvailableNetworks" $networks "ReadOnly" false "Data" (dict)) }}
+    {{ end }}
+    {{ if $instance }}
+      <div class="grid">
+        <button type="submit" id="submit" name="update">Update</button>
+        <button type="submit" id="uninstall" name="remove">Uninstall</button>
+      </div>
+    {{ else }}
+      <button type="submit" id="submit">{{ if $instance }}Update{{ else }}Install{{ end }}</button>
+    {{ end }}
+</form>
+
+{{ range .Instances }}
+  {{ if or (not $instance) (ne $instance.Id .Id)}}
+    <details>
+      <summary>{{ .Id }}</summary>
+      {{ template "schema-form" (dict "Schema" $schema "AvailableNetworks" $networks "ReadOnly" true "Data" .Config ) }}
+      <a href="/instance/{{ .Id }}" role="button" class="secondary">View</a>
+    </details>
+  {{ end }}
+{{ end }}
+
+
+<div id="toast-success" class="toast hidden">
+  <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36"><path fill="currentColor" d="M18 2a16 16 0 1 0 16 16A16 16 0 0 0 18 2Zm0 30a14 14 0 1 1 14-14a14 14 0 0 1-14 14Z" class="clr-i-outline clr-i-outline-path-1"/><path fill="currentColor" d="M28 12.1a1 1 0 0 0-1.41 0l-11.1 11.05l-6-6A1 1 0 0 0 8 18.53L15.49 26L28 13.52a1 1 0 0 0 0-1.42Z" class="clr-i-outline clr-i-outline-path-2"/><path fill="none" d="M0 0h36v36H0z"/></svg> {{ if $instance }}Update succeeded{{ else }}Install succeeded{{ end}}
+</div>
+
+<div id="toast-failure" class="toast hidden">
+  <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10Zm3-6L9 8m0 8l6-8"/></svg> {{ if $instance }}Update failed{{ else}}Install failed{{ end }}
+</div>
+
+<div id="toast-uninstall-success" class="toast hidden">
+  <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 36 36"><path fill="currentColor" d="M18 2a16 16 0 1 0 16 16A16 16 0 0 0 18 2Zm0 30a14 14 0 1 1 14-14a14 14 0 0 1-14 14Z" class="clr-i-outline clr-i-outline-path-1"/><path fill="currentColor" d="M28 12.1a1 1 0 0 0-1.41 0l-11.1 11.05l-6-6A1 1 0 0 0 8 18.53L15.49 26L28 13.52a1 1 0 0 0 0-1.42Z" class="clr-i-outline clr-i-outline-path-2"/><path fill="none" d="M0 0h36v36H0z"/></svg> Uninstalled application
+</div>
+
+<div id="toast-uninstall-failure" class="toast hidden">
+  <svg xmlns="http://www.w3.org/2000/svg" width="36" height="36" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 22c5.523 0 10-4.477 10-10S17.523 2 12 2S2 6.477 2 12s4.477 10 10 10Zm3-6L9 8m0 8l6-8"/></svg> Failed to uninstall application
+</div>
+
+<style>
+  pre {
+    white-space: pre-wrap;       /* Since CSS 2.1 */
+    white-space: -moz-pre-wrap;  /* Mozilla, since 1999 */
+    white-space: -pre-wrap;      /* Opera 4-6 */
+    white-space: -o-pre-wrap;    /* Opera 7 */
+    word-wrap: break-word;       /* Internet Explorer 5.5+ */
+    background-color: transparent;
+  }
+
+ .hidden {
+     visibility: hidden;
+ }
+
+ .toast {
+     position: fixed;
+     z-index: 999;
+     bottom: 10px;
+ }
+</style>
+
+<script>
+ let readme = "";
+ let config = {{ if $instance }}JSON.parse({{ toJson $instance.Config }}){{ else }}{}{{ end }};
+
+ function valueChanged(name, value) {
+     config[name] = value;
+     renderReadme();
+ }
+
+ async function renderReadme() {
+	 const resp = await fetch("/api/app/{{ .App.Name }}/render", {
+         method: "POST",
+         headers: {
+             "Content-Type": "application/json",
+             "Accept": "application/json",
+         },
+         body: JSON.stringify(config),
+     });
+     const app = await resp.json();
+     document.getElementById("readme").innerHTML = app.readme;
+ }
+
+ {{ if $instance }}renderReadme();{{ end }}
+
+ function disableForm() {
+     document.querySelectorAll("#config-form input").forEach((i) => i.setAttribute("disabled", ""));
+     document.querySelectorAll("#config-form select").forEach((i) => i.setAttribute("disabled", ""));
+     document.querySelectorAll("#config-form button").forEach((i) => i.setAttribute("disabled", ""));
+ }
+
+ function enableForm() {
+     document.querySelectorAll("[aria-busy]").forEach((i) => i.removeAttribute("aria-busy"));
+     document.querySelectorAll("#config-form input").forEach((i) => i.removeAttribute("disabled"));
+     document.querySelectorAll("#config-form select").forEach((i) => i.removeAttribute("disabled"));
+     document.querySelectorAll("#config-form button").forEach((i) => i.removeAttribute("disabled"));
+ }
+
+ function installStarted() {
+     const submit = document.getElementById("submit");
+     submit.setAttribute("aria-busy", true);
+     submit.innerHTML = {{ if $instance }}"Updating ..."{{ else }}"Installing ..."{{ end }};
+     disableForm();
+ }
+
+ function uninstallStarted() {
+     const submit = document.getElementById("uninstall");
+     submit.setAttribute("aria-busy", true);
+     submit.innerHTML = "Uninstalling ...";
+     disableForm();
+ }
+
+ function actionFinished(toast) {
+     enableForm();
+     toast.classList.remove("hidden");
+     setTimeout(
+         () => toast.classList.add("hidden"),
+         2000,
+     );
+ }
+
+ function installSucceeded() {
+     actionFinished(document.getElementById("toast-success"));
+ }
+
+ function installFailed() {
+     actionFinished(document.getElementById("toast-failure"));
+ }
+
+ function uninstallSucceeded() {
+     actionFinished(document.getElementById("toast-uninstall-success"));
+ }
+
+ function uninstallFailed() {
+     actionFinished(document.getElementById("toast-uninstall-failure"));
+ }
+
+ const submitAddr = {{ if $instance }}"/api/instance/{{ $instance.Id }}/update"{{ else }}"/api/app/{{ .App.Name }}/install"{{ end }};
+
+ async function install() {
+     installStarted();
+	 const resp = await fetch(submitAddr, {
+         method: "POST",
+         headers: {
+             "Content-Type": "application/json",
+             "Accept": "application/json",
+         },
+         body: JSON.stringify(config),
+     });
+     if (resp.status === 200) {
+         installSucceeded();
+     } else {
+         installFailed();
+     }
+ }
+
+ async function uninstall() {
+     {{ if $instance }}
+     uninstallStarted();
+	 const resp = await fetch("/api/instance/{{ $instance.Id }}/remove", {
+         method: "POST",
+     });
+     if (resp.status === 200) {
+         uninstallSucceeded();
+     } else {
+         uninstallFailed();
+     }
+     {{ end }}
+ }
+
+ document.getElementById("config-form").addEventListener("submit", (event) => {
+     event.preventDefault();
+     if (event.submitter.id === "submit") {
+         install();
+     } if (event.submitter.id === "uninstall") {
+         uninstall();
+     }
+ });
+</script>
+{{ end }}
diff --git a/core/installer/welcome/appmanager-tmpl/base.html b/core/installer/welcome/appmanager-tmpl/base.html
new file mode 100644
index 0000000..9e4bb49
--- /dev/null
+++ b/core/installer/welcome/appmanager-tmpl/base.html
@@ -0,0 +1,22 @@
+<!DOCTYPE html>
+<html lang="en" data-theme="light">
+	<head>
+		<meta charset="utf-8" />
+        <link rel="stylesheet" href="/static/pico.min.css">
+		<meta name="viewport" content="width=device-width, initial-scale=1" />
+        <style>
+          #main {
+            margin-top: 10px;
+          }
+
+          @media screen and (min-width:801px) {
+            #main {
+              width: 600px;
+            }
+          }
+        </style>
+	</head>
+	<body>
+		<div id="main" class="container">{{ block "main" . }}CHILD MUST OVERRIDE THIS{{ end }}</div>
+	</body>
+</html>
diff --git a/core/installer/welcome/appmanager-tmpl/index.html b/core/installer/welcome/appmanager-tmpl/index.html
new file mode 100644
index 0000000..02150ff
--- /dev/null
+++ b/core/installer/welcome/appmanager-tmpl/index.html
@@ -0,0 +1,50 @@
+{{ define "main" }}
+<form>
+  <input type="search" placeholder="Search" />
+</form>
+
+<aside>
+  <nav>
+    <ul>
+      {{ range . }}
+        <li>
+          <article>
+                  <div>
+                      <a href="/app/{{ .Slug }}" class="logo">
+                          {{ .Icon }}
+                      </a>
+                  </div>
+                  <div>
+                      <a href="/app/{{ .Slug }}">{{ .Name }}</a>
+                      {{ .ShortDescription }}
+                  </div>
+          </article>
+        </li>
+      {{ end }}
+    </ul>
+  </nav>
+</aside>
+
+<style>
+  article {
+    margin: 0.3em;
+    margin-bottom: 0.3em;
+
+    display: flex;
+    flex-direction: row;
+  }
+
+  .logo {
+    display: table-cell;
+    vertical-align: middle;
+  }
+  nav li {
+    padding-top: 0;
+    padding-bottom: 0;
+  }
+
+  input[type="search"] {
+    margin-bottom: 0;
+  }
+</style>
+{{ end }}
diff --git a/core/installer/welcome/appmanager.go b/core/installer/welcome/appmanager.go
new file mode 100644
index 0000000..242faed
--- /dev/null
+++ b/core/installer/welcome/appmanager.go
@@ -0,0 +1,370 @@
+package welcome
+
+import (
+	"bytes"
+	"embed"
+	"encoding/json"
+	"fmt"
+	"html/template"
+	"io/ioutil"
+	"log"
+	"net/http"
+	// "net/http/httputil"
+	// "net/url"
+
+	"github.com/Masterminds/sprig/v3"
+	"github.com/labstack/echo/v4"
+
+	"github.com/giolekva/pcloud/core/installer"
+)
+
+//go:embed appmanager-tmpl
+var mgrTmpl embed.FS
+
+//go:embed appmanager-tmpl/base.html
+var baseHtmlTmpl string
+
+//go:embed appmanager-tmpl/app.html
+var appHtmlTmpl string
+
+type AppManagerServer struct {
+	port       int
+	webAppAddr string
+	m          *installer.AppManager
+	r          installer.AppRepository[installer.StoreApp]
+}
+
+func NewAppManagerServer(
+	port int,
+	webAppAddr string,
+	m *installer.AppManager,
+	r installer.AppRepository[installer.StoreApp],
+) *AppManagerServer {
+	return &AppManagerServer{
+		port,
+		webAppAddr,
+		m,
+		r,
+	}
+}
+
+func (s *AppManagerServer) Start() {
+	e := echo.New()
+	e.StaticFS("/static", echo.MustSubFS(staticAssets, "static"))
+	e.GET("/api/app-repo", s.handleAppRepo)
+	e.POST("/api/app/:slug/render", s.handleAppRender)
+	e.POST("/api/app/:slug/install", s.handleAppInstall)
+	e.GET("/api/app/:slug", s.handleApp)
+	e.GET("/api/instance/:slug", s.handleInstance)
+	e.POST("/api/instance/:slug/update", s.handleAppUpdate)
+	e.POST("/api/instance/:slug/remove", s.handleAppRemove)
+	e.GET("/", s.handleIndex)
+	e.GET("/app/:slug", s.handleAppUI)
+	e.GET("/instance/:slug", s.handleInstanceUI)
+	fmt.Printf("Starting HTTP server on port: %d\n", s.port)
+	log.Fatal(e.Start(fmt.Sprintf(":%d", s.port)))
+}
+
+type app struct {
+	Name             string                `json:"name"`
+	Icon             template.HTML         `json:"icon"`
+	ShortDescription string                `json:"shortDescription"`
+	Slug             string                `json:"slug"`
+	Schema           string                `json:"schema"`
+	Instances        []installer.AppConfig `json:"instances,omitempty"`
+}
+
+func (s *AppManagerServer) handleAppRepo(c echo.Context) error {
+	all, err := s.r.GetAll()
+	if err != nil {
+		return err
+	}
+	resp := make([]app, len(all))
+	for i, a := range all {
+		resp[i] = app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, nil}
+	}
+	return c.JSON(http.StatusOK, resp)
+}
+
+func (s *AppManagerServer) handleApp(c echo.Context) error {
+	slug := c.Param("slug")
+	a, err := s.r.Find(slug)
+	if err != nil {
+		return err
+	}
+	instances, err := s.m.FindAllInstances(slug)
+	if err != nil {
+		return err
+	}
+	for _, instance := range instances {
+		values, ok := instance.Config["Values"].(map[string]any)
+		if !ok {
+			return fmt.Errorf("Expected map")
+		}
+		for k, v := range values {
+			if k == "Network" {
+				n, ok := v.(map[string]any)
+				if !ok {
+					return fmt.Errorf("Expected map")
+				}
+				values["Network"], ok = n["Name"]
+				if !ok {
+					return fmt.Errorf("Missing Name")
+				}
+				break
+			}
+		}
+	}
+	return c.JSON(http.StatusOK, app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, instances})
+}
+
+func (s *AppManagerServer) handleInstance(c echo.Context) error {
+	slug := c.Param("slug")
+	instance, err := s.m.FindInstance(slug)
+	if err != nil {
+		return err
+	}
+	values, ok := instance.Config["Values"].(map[string]any)
+	if !ok {
+		return fmt.Errorf("Expected map")
+	}
+	for k, v := range values {
+		if k == "Network" {
+			n, ok := v.(map[string]any)
+			if !ok {
+				return fmt.Errorf("Expected map")
+			}
+			values["Network"], ok = n["Name"]
+			if !ok {
+				return fmt.Errorf("Missing Name")
+			}
+			break
+		}
+	}
+	a, err := s.r.Find(instance.AppId)
+	if err != nil {
+		return err
+	}
+	return c.JSON(http.StatusOK, app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, []installer.AppConfig{instance}})
+}
+
+type file struct {
+	Name     string `json:"name"`
+	Contents string `json:"contents"`
+}
+
+type rendered struct {
+	Readme string `json:"readme"`
+	Files  []file `json:"files"`
+}
+
+func (s *AppManagerServer) handleAppRender(c echo.Context) error {
+	slug := c.Param("slug")
+	contents, err := ioutil.ReadAll(c.Request().Body)
+	if err != nil {
+		return err
+	}
+	global, err := s.m.Config()
+	if err != nil {
+		return err
+	}
+	var values map[string]any
+	if err := json.Unmarshal(contents, &values); err != nil {
+		return err
+	}
+	if network, ok := values["Network"]; ok {
+		for _, n := range installer.CreateNetworks(global) {
+			if n.Name == network { // TODO(giolekva): handle not found
+				values["Network"] = n
+			}
+		}
+	}
+	all := map[string]any{
+		"Global": global.Values,
+		"Values": values,
+	}
+	a, err := s.r.Find(slug)
+	if err != nil {
+		return err
+	}
+	var readme bytes.Buffer
+	if err := a.Readme.Execute(&readme, all); err != nil {
+		return err
+	}
+	var resp rendered
+	resp.Readme = readme.String()
+	for _, tmpl := range a.Templates { // TODO(giolekva): deduplicate with Install
+		var f bytes.Buffer
+		if err := tmpl.Execute(&f, all); err != nil {
+			fmt.Printf("%+v\n", all)
+			fmt.Println(err.Error())
+			return err
+		} else {
+			resp.Files = append(resp.Files, file{tmpl.Name(), f.String()})
+		}
+	}
+	out, err := json.Marshal(resp)
+	if err != nil {
+		return err
+	}
+	if _, err := c.Response().Writer.Write(out); err != nil {
+		return err
+	}
+	return nil
+}
+
+func (s *AppManagerServer) handleAppInstall(c echo.Context) error {
+	slug := c.Param("slug")
+	contents, err := ioutil.ReadAll(c.Request().Body)
+	if err != nil {
+		return err
+	}
+	var values map[string]any
+	if err := json.Unmarshal(contents, &values); err != nil {
+		return err
+	}
+	fmt.Println(values)
+	a, err := s.r.Find(slug)
+	if err != nil {
+		return err
+	}
+	config, err := s.m.Config()
+	if err != nil {
+		return err
+	}
+	nsGen := installer.NewPrefixGenerator(config.Values.NamespacePrefix)
+	suffixGen := installer.NewFixedLengthRandomSuffixGenerator(3)
+	if err := s.m.Install(a.App, nsGen, suffixGen, values); err != nil {
+		return err
+	}
+	return c.String(http.StatusOK, "Installed")
+}
+
+func (s *AppManagerServer) handleAppUpdate(c echo.Context) error {
+	slug := c.Param("slug")
+	appConfig, err := s.m.AppConfig(slug)
+	if err != nil {
+		return err
+	}
+	contents, err := ioutil.ReadAll(c.Request().Body)
+	if err != nil {
+		return err
+	}
+	var values map[string]any
+	if err := json.Unmarshal(contents, &values); err != nil {
+		return err
+	}
+	a, err := s.r.Find(appConfig.AppId)
+	if err != nil {
+		return err
+	}
+	if err := s.m.Update(a.App, slug, values); err != nil {
+		return err
+	}
+	return c.String(http.StatusOK, "Installed")
+}
+
+func (s *AppManagerServer) handleAppRemove(c echo.Context) error {
+	slug := c.Param("slug")
+	if err := s.m.Remove(slug); err != nil {
+		return err
+	}
+	return c.String(http.StatusOK, "Installed")
+}
+
+func (s *AppManagerServer) handleIndex(c echo.Context) error {
+	tmpl, err := template.ParseFS(mgrTmpl, "appmanager-tmpl/base.html", "appmanager-tmpl/index.html")
+	if err != nil {
+		return err
+	}
+	all, err := s.r.GetAll()
+	if err != nil {
+		return err
+	}
+	resp := make([]app, len(all))
+	for i, a := range all {
+		resp[i] = app{a.Name, a.Icon, a.ShortDescription, a.Name, a.Schema, nil}
+	}
+	return tmpl.Execute(c.Response(), resp)
+}
+
+type appContext[T any] struct {
+	App               *T
+	Instance          *installer.AppConfig
+	Instances         []installer.AppConfig
+	AvailableNetworks []installer.Network
+}
+
+func (s *AppManagerServer) handleAppUI(c echo.Context) error {
+	baseTmpl, err := newTemplate().Parse(baseHtmlTmpl)
+	if err != nil {
+		return err
+	}
+	appTmpl, err := template.Must(baseTmpl.Clone()).Parse(appHtmlTmpl)
+	if err != nil {
+		fmt.Println(err)
+		return err
+	}
+	global, err := s.m.Config()
+	if err != nil {
+		return err
+	}
+	slug := c.Param("slug")
+	a, err := s.r.Find(slug)
+	if err != nil {
+		return err
+	}
+	instances, err := s.m.FindAllInstances(slug)
+	if err != nil {
+		return err
+	}
+	err = appTmpl.Execute(c.Response(), appContext[installer.StoreApp]{
+		App:               a,
+		Instances:         instances,
+		AvailableNetworks: installer.CreateNetworks(global),
+	})
+	fmt.Println(err)
+	return err
+}
+
+func (s *AppManagerServer) handleInstanceUI(c echo.Context) error {
+	baseTmpl, err := newTemplate().Parse(baseHtmlTmpl)
+	if err != nil {
+		return err
+	}
+	appTmpl, err := template.Must(baseTmpl.Clone()).Parse(appHtmlTmpl)
+	// tmpl, err := newTemplate().ParseFS(mgrTmpl, "appmanager-tmpl/base.html", "appmanager-tmpl/app.html")
+	if err != nil {
+		fmt.Println(err)
+		return err
+	}
+	global, err := s.m.Config()
+	if err != nil {
+		return err
+	}
+	slug := c.Param("slug")
+	instance, err := s.m.FindInstance(slug)
+	if err != nil {
+		return err
+	}
+	a, err := s.r.Find(instance.AppId)
+	if err != nil {
+		return err
+	}
+	instances, err := s.m.FindAllInstances(a.Name)
+	if err != nil {
+		return err
+	}
+	err = appTmpl.Execute(c.Response(), appContext[installer.StoreApp]{
+		App:               a,
+		Instance:          &instance,
+		Instances:         instances,
+		AvailableNetworks: installer.CreateNetworks(global),
+	})
+	fmt.Println(err)
+	return err
+}
+
+func newTemplate() *template.Template {
+	return template.New("base").Funcs(template.FuncMap(sprig.FuncMap()))
+}
diff --git a/core/installer/welcome/env.go b/core/installer/welcome/env.go
index ab1504d..6779035 100644
--- a/core/installer/welcome/env.go
+++ b/core/installer/welcome/env.go
@@ -262,6 +262,29 @@
 			return err
 		}
 	}
+	{
+		keys, err := installer.NewSSHKeyPair()
+		if err != nil {
+			return err
+		}
+		user := fmt.Sprintf("%s-appmanager", req.Name)
+		if err := ss.AddUser(user, keys.Public); err != nil {
+			return err
+		}
+		if err := ss.AddCollaborator(req.Name, user); err != nil {
+			return err
+		}
+		app, err := appsRepo.Find("app-manager") // TODO(giolekva): configure
+		if err != nil {
+			return err
+		}
+		if err := appManager.Install(*app, nsGen, suffixGen, map[string]any{
+			"RepoAddr":      ss.GetRepoAddress(req.Name),
+			"SSHPrivateKey": keys.Private,
+		}); err != nil {
+			return err
+		}
+	}
 	return nil
 }