public
anord
read
Ledger
Why work hard when you can work easier?
Languages
Repository composition by tracked source files.
TypeScript
86%
CSS
10%
SQL
3%
Shell
1%
HTML
0%
Commit
UI Fixes and added update script
8c705f9
README.md | 41 +++++++
apps/web/src/components/AdminConsole.tsx | 1 +
apps/web/src/docs-theme.css | 204 ++++++++++++++++---------------
infra/scripts/dev-bootstrap.sh | 17 ++-
infra/scripts/install-vm-updater.sh | 14 +++
infra/scripts/vm-update.sh | 122 ++++++++++++++++++
6 files changed, 299 insertions(+), 100 deletions(-)
create mode 100644 infra/scripts/install-vm-updater.sh
create mode 100644 infra/scripts/vm-update.sh
Diff
diff --git a/README.md b/README.md
index 06a78a1..625316b 100644
--- a/README.md
+++ b/README.md
@@ -78,6 +78,47 @@ Services:
On first boot, open the frontend and complete the setup wizard.
+## Updating a VM install
+
+Ledger now includes an in-place updater so you do not need to reflash the VM for every change.
+
+Directly on the VM:
+
+```bash
+cd /opt/ledger
+./infra/scripts/vm-update.sh
+```
+
+Optional flags:
+
+- `APP_DIR=/opt/ledger`
+- `BRANCH=main`
+- `REPO_URL=https://github.com/Anord-cc/ledger.git`
+- `FORCE_RESET=1`
+
+What the updater does:
+
+- Clones Ledger if the VM does not have the repo yet
+- Pulls the selected Git branch
+- Preserves `.env`, uploads, and Docker volumes
+- Pulls base container images
+- Runs the shared dependency bootstrap once
+- Restarts Postgres, Redis, API, web, and worker services
+
+By default the Linux updater refuses to overwrite local repo changes on the VM. If you intentionally want the VM to match Git exactly, rerun with `FORCE_RESET=1`.
+
+### Optional VM command install
+
+If you want a single server command instead of changing into the repo first:
+
+```bash
+cd /opt/ledger
+./infra/scripts/install-vm-updater.sh
+ledger-update
+```
+
+That installs `/usr/local/bin/ledger-update`, which runs the same in-place update flow with sensible defaults.
+
## Without Docker
```bash
diff --git a/apps/web/src/components/AdminConsole.tsx b/apps/web/src/components/AdminConsole.tsx
index b32d98c..24918da 100644
--- a/apps/web/src/components/AdminConsole.tsx
+++ b/apps/web/src/components/AdminConsole.tsx
@@ -313,6 +313,7 @@ export function AdminConsole({ user, spaces }: { user: SessionUser; spaces: Spac
return (
<div className="admin-shell">
<aside className="admin-sidebar panel">
+ <Link to="/spaces" className="admin-backlink">Back to app</Link>
<div className="panel__header">
<div>
<p className="eyebrow">Admin</p>
diff --git a/apps/web/src/docs-theme.css b/apps/web/src/docs-theme.css
index 087010a..bee25c0 100644
--- a/apps/web/src/docs-theme.css
+++ b/apps/web/src/docs-theme.css
@@ -1,25 +1,24 @@
:root {
- --bg: #f4f6f2;
- --bg-elevated: rgba(255, 255, 255, 0.82);
- --bg-sidebar: #eef2ea;
- --bg-panel: #ffffff;
- --bg-soft: #f6f8f4;
- --text: #172118;
- --text-muted: #607065;
- --text-soft: #7b897f;
- --line: #dce5db;
- --line-strong: #cad7cb;
- --brand: #2d6a4f;
- --brand-soft: #e3f1e9;
- --shadow: 0 18px 45px rgba(23, 33, 24, 0.08);
- --radius-lg: 24px;
- --radius-md: 16px;
- --radius-sm: 12px;
- --sidebar-width: 320px;
+ --bg: #111318;
+ --bg-elevated: #15181f;
+ --bg-sidebar: #0d1015;
+ --bg-panel: #15181f;
+ --bg-soft: #0f1319;
+ --bg-soft-2: #171c24;
+ --text: #edf1f7;
+ --text-muted: #9aa4b2;
+ --text-soft: #6f7b8d;
+ --line: #252c37;
+ --line-strong: #343d4b;
+ --brand: #f59e0b;
+ --brand-soft: rgba(245, 158, 11, 0.12);
+ --shadow: none;
+ --radius-lg: 4px;
+ --radius-md: 4px;
+ --radius-sm: 3px;
+ --sidebar-width: 260px;
color: var(--text);
- background:
- radial-gradient(circle at top left, rgba(45, 106, 79, 0.11), transparent 28%),
- linear-gradient(180deg, #f7f9f5 0%, #eef2ea 100%);
+ background: var(--bg);
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
@@ -62,9 +61,9 @@ textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
- background: #fff;
+ background: var(--bg-soft);
color: var(--text);
- padding: 0.85rem 0.95rem;
+ padding: 0.8rem 0.9rem;
}
textarea {
@@ -98,7 +97,7 @@ a:focus {
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
- color: var(--brand);
+ color: var(--text-soft);
}
.muted {
@@ -107,8 +106,8 @@ a:focus {
.lede {
font-size: 1.05rem;
- line-height: 1.75;
- color: #344238;
+ line-height: 1.65;
+ color: var(--text-muted);
}
.button-ghost,
@@ -117,22 +116,22 @@ a:focus {
align-items: center;
gap: 0.55rem;
justify-content: center;
- border-radius: 999px;
- padding: 0.7rem 1rem;
+ border-radius: var(--radius-sm);
+ padding: 0.62rem 0.85rem;
background: transparent;
color: var(--text);
}
.button-secondary {
border: 1px solid var(--line);
- background: #fff;
+ background: var(--bg-soft);
}
button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-link):not(.collection-card):not(.search-launcher) {
- border-radius: 999px;
- padding: 0.82rem 1.15rem;
+ border-radius: var(--radius-sm);
+ padding: 0.78rem 1rem;
background: var(--brand);
- color: white;
+ color: #111318;
font-weight: 600;
}
@@ -152,11 +151,10 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
position: sticky;
top: 0;
height: 100vh;
- padding: 1.15rem 1rem 1rem;
- background: rgba(238, 242, 234, 0.92);
- border-right: 1px solid rgba(202, 215, 203, 0.85);
+ padding: 0.9rem 0.75rem 1rem;
+ background: var(--bg-sidebar);
+ border-right: 1px solid var(--line);
overflow-y: auto;
- backdrop-filter: blur(16px);
}
.sidebar__top,
@@ -211,7 +209,7 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
display: flex;
align-items: center;
gap: 0.7rem;
- padding: 0.72rem 0.85rem;
+ padding: 0.55rem 0.65rem;
border-radius: var(--radius-sm);
color: var(--text-muted);
}
@@ -219,7 +217,7 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
.sidebar-nav__item.is-current,
.sidebar-doc.is-current,
.collection-link.is-current {
- background: rgba(255, 255, 255, 0.72);
+ background: var(--bg-soft-2);
color: var(--text);
}
@@ -240,9 +238,9 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
}
.collection-group {
- border: 1px solid rgba(202, 215, 203, 0.8);
+ border: 1px solid var(--line);
border-radius: var(--radius-md);
- background: rgba(255, 255, 255, 0.5);
+ background: transparent;
}
.collection-group__header,
@@ -310,7 +308,7 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
height: 1.5rem;
display: inline-grid;
place-items: center;
- border-radius: 999px;
+ border-radius: var(--radius-sm);
background: transparent;
color: var(--text-soft);
}
@@ -325,38 +323,39 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
display: inline-flex;
align-items: center;
gap: 0.35rem;
- border-radius: 999px;
- padding: 0.28rem 0.62rem;
+ border-radius: var(--radius-sm);
+ padding: 0.22rem 0.42rem;
font-size: 0.78rem;
font-weight: 600;
}
.badge-public {
- background: #e6f3ea;
- color: #276247;
+ background: rgba(34, 197, 94, 0.14);
+ color: #7ee0a2;
}
.badge-internal,
.badge-restricted {
- background: #f9f0d5;
- color: #8a6208;
+ background: rgba(245, 158, 11, 0.14);
+ color: #f8bf54;
}
.badge-muted,
.pill {
- background: #edf2ec;
- color: #56685b;
+ background: var(--bg-soft-2);
+ color: var(--text-muted);
}
.app-header,
.app-content,
.app-footer {
- width: min(1320px, calc(100% - 2rem));
+ width: min(1240px, calc(100% - 2.5rem));
margin: 0 auto;
}
.app-header {
- padding: 1rem 0;
+ padding: 0.8rem 0;
+ border-bottom: 1px solid var(--line);
}
.brand {
@@ -373,31 +372,29 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
.brand-mark {
width: 1.15rem;
height: 1.15rem;
- border-radius: 0.35rem;
- box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.35);
+ border-radius: 2px;
}
.search-launcher {
min-width: min(560px, 100%);
justify-content: space-between;
- padding: 0.82rem 1rem;
- border-radius: 999px;
+ padding: 0.7rem 0.85rem;
+ border-radius: var(--radius-sm);
border: 1px solid var(--line);
- background: rgba(255, 255, 255, 0.82);
+ background: var(--bg-soft);
color: var(--text-muted);
}
.search-launcher kbd {
border: 1px solid var(--line-strong);
- border-bottom-width: 2px;
- border-radius: 8px;
+ border-radius: 3px;
padding: 0.18rem 0.45rem;
font-size: 0.76rem;
- background: var(--bg-soft);
+ background: var(--bg-elevated);
}
.app-content {
- padding-bottom: 2rem;
+ padding: 1.5rem 0 2.5rem;
}
.app-footer {
@@ -418,11 +415,9 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
.rail-card,
.auth-form,
.setup-form {
- border: 1px solid rgba(220, 229, 219, 0.88);
+ border: 1px solid var(--line);
border-radius: var(--radius-lg);
- background: rgba(255, 255, 255, 0.84);
- box-shadow: var(--shadow);
- backdrop-filter: blur(18px);
+ background: var(--bg-panel);
}
.overview-hero,
@@ -431,7 +426,7 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
.panel,
.auth-form,
.setup-form {
- padding: 1.4rem;
+ padding: 1.1rem 1.2rem;
}
.overview-hero {
@@ -470,9 +465,10 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
.article-list__item {
width: 100%;
justify-content: space-between;
- padding: 1rem;
+ padding: 0.85rem 0.95rem;
border-radius: var(--radius-md);
- background: var(--bg-soft);
+ background: transparent;
+ border: 1px solid var(--line);
color: inherit;
}
@@ -481,7 +477,7 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
height: 2.5rem;
display: grid;
place-items: center;
- border-radius: 12px;
+ border-radius: 3px;
background: var(--brand-soft);
color: var(--brand);
}
@@ -575,9 +571,9 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
.markdown-prose {
max-width: 76ch;
- font-family: Georgia, "Times New Roman", serif;
- font-size: 1.05rem;
- line-height: 1.9;
+ font-family: ui-serif, Georgia, "Times New Roman", serif;
+ font-size: 1rem;
+ line-height: 1.8;
}
.markdown-prose :is(h1, h2, h3, h4) {
@@ -619,7 +615,6 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
width: 100%;
border-collapse: collapse;
overflow: hidden;
- border-radius: var(--radius-sm);
}
.markdown-prose th,
@@ -645,18 +640,18 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
.markdown-prose :not(pre) > code {
padding: 0.16rem 0.38rem;
- border-radius: 8px;
- background: #edf2ec;
- color: #244a35;
+ border-radius: 3px;
+ background: var(--bg-soft-2);
+ color: #cdd7e3;
}
.code-copy {
position: absolute;
top: 0.75rem;
right: 0.75rem;
- border-radius: 999px;
+ border-radius: var(--radius-sm);
padding: 0.35rem 0.7rem;
- background: rgba(255, 255, 255, 0.12);
+ background: rgba(15, 19, 25, 0.9);
color: white;
}
@@ -688,7 +683,7 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
padding: 0.72rem 0.9rem;
border: 1px solid var(--line);
border-radius: var(--radius-md);
- background: rgba(255, 255, 255, 0.65);
+ background: var(--bg-soft);
}
.field {
@@ -757,9 +752,9 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
opacity: 0;
pointer-events: none;
border: 1px solid var(--line);
- border-radius: 24px;
- background: rgba(255, 255, 255, 0.96);
- box-shadow: 0 28px 70px rgba(17, 28, 18, 0.18);
+ border-radius: 4px;
+ background: var(--bg-elevated);
+ box-shadow: none;
z-index: 30;
overflow: hidden;
transition: opacity 160ms ease, transform 160ms ease;
@@ -777,6 +772,7 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
gap: 0.85rem;
padding: 1rem 1.1rem;
border-bottom: 1px solid var(--line);
+ background: var(--bg-panel);
}
.command-palette__input input {
@@ -818,7 +814,7 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
height: 2rem;
display: grid;
place-items: center;
- border-radius: 10px;
+ border-radius: 3px;
background: var(--brand-soft);
color: var(--brand);
}
@@ -834,10 +830,10 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
display: flex;
align-items: center;
gap: 0.65rem;
- padding: 0.35rem 0.35rem 0.35rem 0.9rem;
+ padding: 0.3rem 0.3rem 0.3rem 0.75rem;
border: 1px solid var(--line);
- border-radius: 999px;
- background: rgba(255, 255, 255, 0.92);
+ border-radius: var(--radius-sm);
+ background: var(--bg-soft);
}
.search-bar input {
@@ -851,8 +847,8 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
}
.skeleton {
- border-radius: 999px;
- background: linear-gradient(90deg, #e7ede6 0%, #f4f7f3 50%, #e7ede6 100%);
+ border-radius: var(--radius-sm);
+ background: linear-gradient(90deg, #171c24 0%, #1e2430 50%, #171c24 100%);
background-size: 240px 100%;
animation: pulse 1.5s linear infinite;
}
@@ -884,8 +880,8 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
.preview-item,
.delivery-item {
border: 1px solid var(--line);
- border-radius: 18px;
- background: rgba(255, 255, 255, 0.92);
+ border-radius: var(--radius-md);
+ background: var(--bg-panel);
padding: 1rem 1.1rem;
}
@@ -901,9 +897,10 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
display: inline-flex;
align-items: center;
padding: 0.45rem 0.75rem;
- border-radius: 999px;
- background: rgba(36, 92, 255, 0.08);
- color: var(--accent-strong);
+ border-radius: var(--radius-sm);
+ background: var(--bg-soft-2);
+ color: var(--text);
+ border: 1px solid var(--line);
}
.import-grid,
@@ -959,6 +956,15 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
position: sticky;
top: 1rem;
align-self: start;
+ background: var(--bg-sidebar);
+}
+
+.admin-backlink {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.4rem 0;
+ color: var(--text-muted);
+ margin-bottom: 0.9rem;
}
.admin-nav {
@@ -970,14 +976,14 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
display: flex;
align-items: center;
min-height: 2.8rem;
- padding: 0.75rem 0.9rem;
- border-radius: 14px;
+ padding: 0.65rem 0.8rem;
+ border-radius: var(--radius-sm);
color: var(--text-soft);
}
.admin-nav__item.is-current {
- background: rgba(36, 92, 255, 0.08);
- color: var(--accent-strong);
+ background: var(--bg-soft-2);
+ color: var(--text);
}
.admin-content,
@@ -996,8 +1002,8 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
gap: 0.25rem;
padding: 0.95rem 1rem;
border: 1px solid var(--line);
- border-radius: 16px;
- background: rgba(255, 255, 255, 0.88);
+ border-radius: var(--radius-md);
+ background: transparent;
}
.integration-card--selectable {
@@ -1006,8 +1012,8 @@ button:not(.button-ghost):not(.button-secondary):not(.tree-toggle):not(.space-li
}
.integration-card--selectable.is-selected {
- border-color: rgba(36, 92, 255, 0.35);
- box-shadow: 0 0 0 1px rgba(36, 92, 255, 0.12);
+ border-color: var(--brand);
+ box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.24);
}
.mobile-only {
diff --git a/infra/scripts/dev-bootstrap.sh b/infra/scripts/dev-bootstrap.sh
index ee6d7e9..f877622 100644
--- a/infra/scripts/dev-bootstrap.sh
+++ b/infra/scripts/dev-bootstrap.sh
@@ -5,8 +5,23 @@ cd /app
mkdir -p /app/node_modules
-if [ ! -f /app/node_modules/.ledger-ready ]; then
+MANIFEST_HASH="$(
+ find /app -maxdepth 3 -name package.json -type f \
+ ! -path '*/node_modules/*' \
+ | sort \
+ | xargs cat \
+ | sha256sum \
+ | awk '{print $1}'
+)"
+
+CURRENT_HASH=""
+if [ -f /app/node_modules/.ledger-deps-hash ]; then
+ CURRENT_HASH="$(cat /app/node_modules/.ledger-deps-hash)"
+fi
+
+if [ "$MANIFEST_HASH" != "$CURRENT_HASH" ] || [ ! -f /app/node_modules/.ledger-ready ]; then
npm install
npm run build --workspace @ledger/shared
+ printf '%s' "$MANIFEST_HASH" > /app/node_modules/.ledger-deps-hash
touch /app/node_modules/.ledger-ready
fi
diff --git a/infra/scripts/install-vm-updater.sh b/infra/scripts/install-vm-updater.sh
new file mode 100644
index 0000000..0704e1c
--- /dev/null
+++ b/infra/scripts/install-vm-updater.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+APP_DIR="${APP_DIR:-/opt/ledger}"
+TARGET_BIN="${TARGET_BIN:-/usr/local/bin/ledger-update}"
+
+if [ ! -f "$APP_DIR/infra/scripts/vm-update.sh" ]; then
+ printf 'Expected updater at %s/infra/scripts/vm-update.sh\n' "$APP_DIR" >&2
+ exit 1
+fi
+
+install -m 0755 "$APP_DIR/infra/scripts/vm-update.sh" "$TARGET_BIN"
+printf 'Installed Ledger updater to %s\n' "$TARGET_BIN"
+printf 'You can now run: %s\n' "$TARGET_BIN"
diff --git a/infra/scripts/vm-update.sh b/infra/scripts/vm-update.sh
new file mode 100644
index 0000000..9fabe45
--- /dev/null
+++ b/infra/scripts/vm-update.sh
@@ -0,0 +1,122 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+DEFAULT_REPO_URL="https://github.com/Anord-cc/ledger.git"
+DEFAULT_BRANCH="main"
+DEFAULT_APP_DIR="/opt/ledger"
+
+resolve_default_app_dir() {
+ local script_dir repo_root
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+ repo_root="$(cd "$script_dir/../.." && pwd)"
+
+ if [ -f "$repo_root/docker-compose.yml" ] && [ -d "$repo_root/.git" ]; then
+ printf '%s' "$repo_root"
+ return
+ fi
+
+ printf '%s' "$DEFAULT_APP_DIR"
+}
+
+APP_DIR="${APP_DIR:-$(resolve_default_app_dir)}"
+REPO_URL="${REPO_URL:-$DEFAULT_REPO_URL}"
+BRANCH="${BRANCH:-$DEFAULT_BRANCH}"
+FORCE_RESET="${FORCE_RESET:-0}"
+
+log() {
+ printf '\n[%s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1"
+}
+
+require_cmd() {
+ if ! command -v "$1" >/dev/null 2>&1; then
+ printf 'Missing required command: %s\n' "$1" >&2
+ exit 1
+ fi
+}
+
+ensure_repo() {
+ if [ -d "$APP_DIR/.git" ]; then
+ return
+ fi
+
+ log "Cloning Ledger into $APP_DIR"
+ rm -rf "$APP_DIR"
+ git clone --branch "$BRANCH" "$REPO_URL" "$APP_DIR"
+}
+
+update_repo() {
+ cd "$APP_DIR"
+
+ if [ -n "$(git status --porcelain)" ] && [ "$FORCE_RESET" != "1" ]; then
+ printf 'Refusing to update because %s has local changes. Re-run with FORCE_RESET=1 to discard them.\n' "$APP_DIR" >&2
+ exit 1
+ fi
+
+ log "Fetching latest code from $BRANCH"
+ git fetch origin "$BRANCH" --prune
+ git checkout "$BRANCH"
+
+ if [ "$FORCE_RESET" = "1" ]; then
+ git reset --hard "origin/$BRANCH"
+ else
+ git pull --ff-only origin "$BRANCH"
+ fi
+}
+
+ensure_env() {
+ cd "$APP_DIR"
+
+ if [ -f .env ]; then
+ return
+ fi
+
+ if [ -f .env.example ]; then
+ cp .env.example .env
+ log "Created .env from .env.example. Review it before exposing Ledger publicly."
+ return
+ fi
+
+ printf 'Missing .env and .env.example in %s\n' "$APP_DIR" >&2
+ exit 1
+}
+
+update_stack() {
+ cd "$APP_DIR"
+
+ log "Pulling base images"
+ docker compose pull bootstrap postgres redis api web worker
+
+ log "Starting database and cache"
+ docker compose up -d postgres redis
+
+ log "Refreshing dependencies"
+ docker compose run --rm bootstrap
+
+ log "Restarting Ledger services"
+ docker compose up -d --remove-orphans api web worker
+
+ log "Current container status"
+ docker compose ps
+}
+
+print_summary() {
+ log "Ledger update complete"
+ printf 'App dir: %s\n' "$APP_DIR"
+ printf 'Branch: %s\n' "$BRANCH"
+ printf 'API health: http://127.0.0.1:4000/health\n'
+ printf 'Web app: http://127.0.0.1:5173\n'
+}
+
+main() {
+ require_cmd git
+ require_cmd docker
+
+ ensure_repo
+ update_repo
+ ensure_env
+ update_stack
+
+ print_summary
+}
+
+main "$@"