NobGit
public anord read

Ledger

Why work hard when you can work easier?

Languages

Repository composition by tracked source files.

TypeScript
TypeScript 86% CSS 10% SQL 3% Shell 1% HTML 0%
Create file Wiki Documentation
Clone
https://nobgit.com/orgs/anord/ledger.git
ssh://[email protected]:2222/orgs/anord/ledger.git

Commit

UI Fixes and added update script

8c705f9
Alex Nord <[email protected]> 3 months ago
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 "$@"