M2c-dev: development compose stack, backup job, Firebase setup docs

infra/dev: compose stack on the Mac (server kvitt-server:dev, on-demand backup),
.env.example, build/up/logs/backup/copy-google-services scripts. No Caddy, no TLS
(debug builds accept http to private LAN addresses, AC-2.8). Bind-mounted data.
infra/backup: alpine 3.24.2 image and backup.sh (SPEC 10.9; Drive upload skipped
when RCLONE_FOLDER_ID is empty). infra/README.md for the dev stack and Firebase.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 21:18:36 +02:00
parent 5e1be38364
commit c885aba313
13 changed files with 523 additions and 0 deletions
+7
View File
@@ -27,3 +27,10 @@ infra/secrets/
*service-account*.json
google-services.json
*.apk
# Development stack on the Mac (infra/dev): local env, bind-mounted data and local backups
infra/dev/.env
infra/dev/data/
infra/dev/backups/
*.sqlite.gz
*.sqlite-wal
*.sqlite-shm
+3
View File
@@ -0,0 +1,3 @@
# shellcheck settings for every script under infra/ (found by searching upward from each script).
external-sources=true
source-path=SCRIPTDIR
+143
View File
@@ -0,0 +1,143 @@
# infra: Kvitt infrastructure
This file covers the **development stack on the owner's Mac** (`infra/dev/`, milestone M2c-dev, D-001) and the
**Firebase setup** (SPEC §10.7). The Pi stack (`infra/compose.yaml`, Caddy with the Let's Encrypt certificate,
cert-sync, the Gitea registry, Drive backups, deploy and APK publishing) is built by M2c-pi, which adds its own
sections here.
## Layout
| Path | What |
|---|---|
| `dev/compose.yaml` | Dev stack: `server` (image `kvitt-server:dev`) and the on-demand `backup` job |
| `dev/.env.example` | Dev settings, no secret values; copy to `dev/.env` (git-ignored) to override |
| `dev/build.sh` | Builds `kvitt-server:dev` from `server/Dockerfile` and `kvitt-backup:dev` from `backup/Dockerfile` |
| `dev/up.sh` | Starts the server, waits for `/health`, prints the Mac and phone URLs; `--down` stops the stack |
| `dev/logs.sh` | Follows the logs (`dev/logs.sh --tail 100 server`) |
| `dev/backup.sh` | Runs the backup job once and checks the snapshot with `PRAGMA integrity_check` |
| `dev/copy-google-services.sh` | Copies `secrets/google-services.json` to `android/app/google-services.json` |
| `dev/lib.sh` | Helpers sourced by the scripts |
| `backup/Dockerfile`, `backup/backup.sh` | Backup image and job of SPEC §10.9 (shared with the Pi stack) |
| `secrets/` | Git-ignored: `fcm-service-account.json`, `google-services.json`. Never committed |
All commands below run from the repository root.
## Development stack
**Choices.** No Caddy and no TLS: debug builds accept `http://` to private LAN addresses (AC-2.8), so the server
publishes its own port (default 8080) on all of the Mac's interfaces. The server keeps the hardening of SPEC §8.9
(uid 10001, read-only root, no capabilities, `no-new-privileges`), the 160 MiB limit, `json-file` logging
(10 MB x 3) and `TZ=Europe/Stockholm`. Data lives in the bind-mounted folder `infra/dev/data/` (git-ignored) so it
is easy to inspect and reset. The secrets folder is mounted read-only at `/secrets`; if
`fcm-service-account.json` is missing the server runs with push disabled (§10.4).
On this Mac `docker` is Podman (`docker compose` runs `podman-compose`); the files work with Docker Desktop as well.
### Build
```bash
infra/dev/build.sh # both images
infra/dev/build.sh server # only kvitt-server:dev; needs server/Dockerfile (milestone M2a)
infra/dev/build.sh backup # only kvitt-backup:dev
```
The server build is `docker build --platform linux/arm64 -f server/Dockerfile -t kvitt-server:dev server`
(override the context with `KVITT_SERVER_BUILD_CONTEXT`). The script refuses to run while a Gradle build holds
`/tmp/kvitt-gradle.lock` (CLAUDE.md).
### Start, stop, logs
```bash
infra/dev/up.sh # start; prints health, the Mac URL and the phone URL
infra/dev/logs.sh # follow logs (Ctrl-C to stop following)
infra/dev/up.sh --down # stop and remove the containers (data stays in infra/dev/data/)
```
`KVITT_PUBLIC_URL` (used in invite links) is left empty in `.env.example`; `up.sh` sets it to
`http://<Mac LAN address>:<port>`. Pin it in `infra/dev/.env` if the Mac's address changes often.
### Finding the Mac's LAN address
`up.sh` prints it. By hand: `ipconfig getifaddr en0` (Wi-Fi on most Macs), or the interface of the default route:
`route -n get default | grep interface`, then `ipconfig getifaddr <interface>`. It must be a private address
(`192.168.*`, `10.*`, `172.16.*` to `172.31.*`) for a debug build to accept `http://` (AC-2.8).
The first time a phone connects, macOS may ask whether the container runtime (Podman's `gvproxy`, or Docker)
may accept incoming network connections: answer Allow.
### Bootstrap and pairing a debug build
The `bootstrap` and `pair-device` subcommands (SPEC §10.4, AC-1.1, AC-1.6) arrive with the server milestones.
```bash
C="docker compose -f infra/dev/compose.yaml --env-file infra/dev/.env.example" # or --env-file infra/dev/.env
$C exec server kvitt-server bootstrap # first admin invite: code, link, expiry
$C exec server kvitt-server pair-device --user <user_id> # extra device invite for an existing user
$C run --rm server migrate # when the server is stopped (entrypoint is kvitt-server)
```
On the phone (home Wi-Fi, debug build `se.hagstedt.kvitt.debug`): open the pairing screen, enter the server URL
`http://<Mac LAN address>:8080` and the printed code (AC-2.7). Check reachability first by opening
`http://<Mac LAN address>:8080/health` in Chrome on the phone: it shows `"status":"ok"`.
### Backup (local, no Drive upload)
```bash
infra/dev/backup.sh
```
Runs `infra/backup/backup.sh` once in the `backup` image (compose profile `backup`, so `up` never starts it) with
`RCLONE_FOLDER_ID` empty: `.backup` snapshot, `PRAGMA integrity_check` must print `ok`, then
`infra/dev/backups/kvitt-<UTC stamp>.sqlite.gz`, `pictures-<stamp>.tar.gz` and `last-success`; the Drive upload
is skipped with a log line and the files are kept (SPEC §10.9, D-001). The wrapper then checks the new snapshot
again. To check by hand: `gunzip -c infra/dev/backups/kvitt-<stamp>.sqlite.gz > /tmp/k.sqlite && sqlite3 /tmp/k.sqlite "PRAGMA integrity_check"`.
### Resetting the dev data
```bash
infra/dev/up.sh --down
rm -rf infra/dev/data # the dev database, pictures and APKs; never the phones' data
infra/dev/up.sh # fresh database; run bootstrap again
```
Paired debug builds then hold a token the new database does not know: re-pair them (clear the debug app's data on
the phone, or reinstall the debug build).
### Worktrees
Agents work in `.claude/worktrees/agent-<id>`, which has no `infra/secrets/` unless copied (CLAUDE.md). Either copy
it from the main checkout, or set `KVITT_SECRETS_DIR=/Users/jhag/projects/kvitt/infra/secrets` in `infra/dev/.env`.
`infra/dev/copy-google-services.sh /Users/jhag/projects/kvitt` copies the Firebase config from the main checkout
into the worktree's `android/app/`.
## Firebase (SPEC §10.7)
Project `kvitt-1337` (Firebase added to the existing Google Cloud project). Free tier only; nothing billable is
created by this setup.
| Resource | Name |
|---|---|
| APIs enabled | `firebase.googleapis.com`, `fcm.googleapis.com`, `cloudresourcemanager.googleapis.com`, `iam.googleapis.com` |
| Android app | "Kvitt", package `se.hagstedt.kvitt` |
| Android app | "Kvitt debug", package `se.hagstedt.kvitt.debug` |
| Service account | `kvitt-fcm@kvitt-1337.iam.gserviceaccount.com` ("Kvitt FCM sender"), role `roles/firebasecloudmessaging.admin` (the narrowest predefined role holding `cloudmessaging.messages.create`) |
| Key | `infra/secrets/fcm-service-account.json` (git-ignored, mode 600) |
| Config | `infra/secrets/google-services.json`, one file with both Android clients (git-ignored) |
Put the config where Gradle reads it: `infra/dev/copy-google-services.sh`.
The setup used only `gcloud` and the Firebase Management REST API. To repeat or inspect it:
```bash
T=$(gcloud auth print-access-token)
H=(-H "Authorization: Bearer $T" -H "x-goog-user-project: kvitt-1337")
API=https://firebase.googleapis.com/v1beta1/projects/kvitt-1337
curl -sS "${H[@]}" "$API/androidApps" # list the apps (ids, packages)
curl -sS "${H[@]}" "$API/androidApps/<appId>/config" # configFileContents is base64 google-services.json
gcloud iam service-accounts keys list --iam-account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com --project kvitt-1337
```
Rotating the key: create a new one with
`gcloud iam service-accounts keys create infra/secrets/fcm-service-account.json --iam-account kvitt-fcm@kvitt-1337.iam.gserviceaccount.com --project kvitt-1337`,
restart the server, then delete the old key id with `gcloud iam service-accounts keys delete <key id> ...`.
Copying the key to the Pi is M2c-pi (§10.7 step 7).
+20
View File
@@ -0,0 +1,20 @@
# Kvitt backup job image (SPEC §10.3, §10.9): alpine with sqlite, gzip, tar, rclone and backup.sh run by crond.
# Versions resolved 2026-10-17: alpine 3.24.2 (Docker Hub official image, multi-arch index digest below);
# packages from Alpine v3.24 main/community: sqlite 3.53.4, rclone 1.74.1, tar 1.35, tzdata 2026e.
FROM docker.io/library/alpine:3.24.2@sha256:294b683cb724975bec92580e1e685676bd4b50bda910ddb8c51d4cabeaec77e6
RUN apk add --no-cache \
"sqlite~3.53" \
"rclone~1.74" \
"tar~1.35" \
gzip \
tzdata
COPY backup.sh /backup.sh
RUN chmod 0755 /backup.sh \
&& echo '30 3 * * * /backup.sh' > /etc/crontabs/root
ENV TZ=Europe/Stockholm
# Daily at 03:30 Europe/Stockholm. The development stack runs it on demand instead
# (`docker compose run --rm backup /backup.sh`, see infra/README.md).
CMD ["crond", "-f", "-l", "8"]
+59
View File
@@ -0,0 +1,59 @@
#!/bin/sh
# Kvitt backup job (SPEC §10.9). Runs inside the backup image with /data (server data) and /backups mounted.
# 1. online, consistent snapshot of /data/kvitt.sqlite with sqlite3 .backup
# 2. PRAGMA integrity_check on the snapshot must print ok, else abort
# 3. gzip -9 the snapshot and tar the pictures, both with the same UTC stamp
# 4. Drive upload: skipped when RCLONE_FOLDER_ID is empty (the development stack, D-001);
# the upload itself and the D-002 local deletion are built by M2c-pi
# 5. write /backups/last-success with the stamp
# Logs carry file names and sizes only, never database content (SPEC §8.7).
set -eu
DATA_DIR=${KVITT_DATA_DIR:-/data}
BACKUP_DIR=${BACKUP_DIR:-/backups}
DB="$DATA_DIR/kvitt.sqlite"
STAGING="$BACKUP_DIR/staging"
log() { echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) backup: $*"; }
fail() { log "FAILED: $*"; rm -f "$STAGING/kvitt.sqlite"; exit 1; }
[ -f "$DB" ] || fail "no database at $DB"
mkdir -p "$STAGING"
rm -f "$STAGING/kvitt.sqlite"
# 1. Snapshot
sqlite3 -batch "$DB" ".backup '$STAGING/kvitt.sqlite'" || fail "sqlite3 .backup returned an error"
# 2. Integrity check
result=$(sqlite3 -batch -noheader -list "$STAGING/kvitt.sqlite" "PRAGMA integrity_check") || fail "integrity_check could not run"
[ "$result" = "ok" ] || fail "integrity_check did not print ok"
# 3. Compress with one UTC stamp
stamp=$(date -u +%Y%m%dT%H%M%SZ)
db_out="$BACKUP_DIR/kvitt-$stamp.sqlite.gz"
pics_out="$BACKUP_DIR/pictures-$stamp.tar.gz"
gzip -9 -c "$STAGING/kvitt.sqlite" > "$db_out.tmp" || fail "gzip failed"
mv "$db_out.tmp" "$db_out"
rm -f "$STAGING/kvitt.sqlite"
if [ -d "$DATA_DIR/pictures" ]; then
tar -czf "$pics_out.tmp" -C "$DATA_DIR" pictures || fail "tar of pictures failed"
else
log "no pictures directory yet; writing an empty pictures archive"
tar -czf "$pics_out.tmp" -T /dev/null || fail "tar of empty pictures archive failed"
fi
mv "$pics_out.tmp" "$pics_out"
log "wrote $(basename "$db_out") ($(wc -c < "$db_out") bytes) and $(basename "$pics_out") ($(wc -c < "$pics_out") bytes)"
# 4. Drive upload
if [ -z "${RCLONE_FOLDER_ID:-}" ]; then
log "RCLONE_FOLDER_ID is empty: Drive upload skipped, local files kept"
else
# TODO(M2c-pi): rclone copy --immutable to "${RCLONE_REMOTE:-gdrive:}" --drive-root-folder-id, confirm with
# rclone lsf, then delete the local files (D-002). Not part of the development stack (D-001).
fail "RCLONE_FOLDER_ID is set but the Drive upload is not built yet (M2c-pi); local files kept"
fi
# 5. Success marker
echo "$stamp" > "$BACKUP_DIR/last-success.tmp"
mv "$BACKUP_DIR/last-success.tmp" "$BACKUP_DIR/last-success"
log "ok $stamp"
+31
View File
@@ -0,0 +1,31 @@
# Kvitt development stack settings (infra/dev/compose.yaml). No secret values here, ever.
# Copy to infra/dev/.env (git-ignored) to change anything; the scripts use .env when it exists,
# otherwise this file. Paths are relative to infra/dev/.
# Images built by infra/dev/build.sh.
KVITT_SERVER_IMAGE=kvitt-server:dev
KVITT_BACKUP_IMAGE=kvitt-backup:dev
# Port published on the Mac (all interfaces, so the phone on the home Wi-Fi can reach it).
KVITT_DEV_PORT=8080
# URL used in invite links. Leave empty: up.sh sets http://<Mac LAN address>:<KVITT_DEV_PORT>.
# Set it only to pin an address, for example http://192.168.1.20:8080 (private LAN address, plain http).
KVITT_PUBLIC_URL=
# Firebase project for FCM HTTP v1 (SPEC §10.4, §10.7).
KVITT_FCM_PROJECT_ID=kvitt-1337
# tracing filter (SPEC §10.4).
RUST_LOG=info
# Bind-mounted folders (git-ignored). Deleting data/ resets the dev server.
KVITT_DEV_DATA_DIR=./data
KVITT_DEV_BACKUP_DIR=./backups
# Folder holding fcm-service-account.json (git-ignored). Default: infra/secrets of this checkout.
# In a worktree without copied secrets, point it at the main checkout's infra/secrets.
KVITT_SECRETS_DIR=../secrets
# rclone remote name; unused on the dev stack because RCLONE_FOLDER_ID is always empty here.
RCLONE_REMOTE=gdrive:
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Runs the backup job once against the development data (SPEC §10.9 with RCLONE_FOLDER_ID empty: no Drive upload).
# Writes kvitt-<UTC stamp>.sqlite.gz and pictures-<stamp>.tar.gz into the backups folder (default infra/dev/backups/)
# and checks the newest snapshot with PRAGMA integrity_check, using the backup image's own sqlite3.
set -euo pipefail
# shellcheck source=lib.sh
. "$(dirname "$0")/lib.sh"
backup_dir="$(dev_path "$(env_value KVITT_DEV_BACKUP_DIR)")"
mkdir -p "$backup_dir"
compose --profile backup run --rm -T backup /backup.sh
latest="$(find "$backup_dir" -maxdepth 1 -name 'kvitt-*.sqlite.gz' | sort | tail -n 1)"
[[ -n "$latest" ]] || { echo "backup.sh: no snapshot found in $backup_dir" >&2; exit 1; }
name="$(basename "$latest")"
result="$(compose --profile backup run --rm -T --entrypoint sh backup -c \
"gunzip -c '/backups/$name' > /tmp/check.sqlite && sqlite3 -batch -noheader -list /tmp/check.sqlite 'PRAGMA integrity_check'")"
echo "$name: integrity_check $result"
[[ "$result" == "ok" ]]
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Builds the development images on this Mac (native linux/arm64, nothing pushed):
# kvitt-server:dev from server/Dockerfile (milestone M2a)
# kvitt-backup:dev from infra/backup/Dockerfile
# Usage: infra/dev/build.sh [server|backup|all] (default: all)
# Refuses to run while a Gradle build holds the gradle-serial lock (CLAUDE.md: never build images during a Gradle build).
set -euo pipefail
# shellcheck source=lib.sh
. "$(dirname "$0")/lib.sh"
what="${1:-all}"
lock=/tmp/kvitt-gradle.lock
if [[ -d "$lock" ]] && pid="$(cat "$lock/pid" 2>/dev/null)" && [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null; then
echo "build.sh: a Gradle build is running (pid $pid, $lock); try again when it is done." >&2
exit 1
fi
server_image="$(env_value KVITT_SERVER_IMAGE)"; server_image="${server_image:-kvitt-server:dev}"
backup_image="$(env_value KVITT_BACKUP_IMAGE)"; backup_image="${backup_image:-kvitt-backup:dev}"
build_server() {
local dockerfile="$repo_root/server/Dockerfile"
local context="${KVITT_SERVER_BUILD_CONTEXT:-$repo_root/server}"
if [[ ! -f "$dockerfile" ]]; then
echo "build.sh: $dockerfile does not exist yet (it comes with milestone M2a)." >&2
return 1
fi
echo "Building $server_image from server/Dockerfile"
docker build --platform linux/arm64 -f "$dockerfile" -t "$server_image" "$context"
}
build_backup() {
echo "Building $backup_image from infra/backup/Dockerfile"
docker build --platform linux/arm64 -f "$repo_root/infra/backup/Dockerfile" -t "$backup_image" "$repo_root/infra/backup"
}
case "$what" in
server) build_server ;;
backup) build_backup ;;
all) build_backup; build_server ;;
*) echo "usage: $0 [server|backup|all]" >&2; exit 2 ;;
esac
+86
View File
@@ -0,0 +1,86 @@
# Kvitt development stack on the owner's Mac (SPEC §10 "Development first", D-001).
# Not the Pi stack: that is infra/compose.yaml, built by M2c-pi.
#
# No Caddy and no TLS here: debug builds accept http:// to private LAN addresses (AC-2.8), so the
# server publishes its own port on the Mac. Release builds never talk to this stack.
#
# Use the scripts next to this file (see infra/README.md):
# infra/dev/build.sh build kvitt-server:dev (from server/Dockerfile) and kvitt-backup:dev
# infra/dev/up.sh start the server, wait for /health, print the LAN URL
# infra/dev/logs.sh follow the logs
# infra/dev/backup.sh run the backup job once (local snapshot, no Drive upload)
# Relative paths below resolve against this directory (infra/dev/).
name: kvitt-dev
x-logging: &logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
server:
image: ${KVITT_SERVER_IMAGE:-kvitt-server:dev}
command: ["serve"]
restart: unless-stopped
user: "10001:10001"
read_only: true
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
mem_limit: 160m
ports:
# The only published port of the dev stack; reachable from the phone on the home Wi-Fi.
- "${KVITT_DEV_PORT:-8080}:8080"
environment:
TZ: Europe/Stockholm
KVITT_DATA_DIR: /data
KVITT_BIND: 0.0.0.0:8080
# Invite links and the landing page point here; up.sh fills it from the Mac's LAN address when empty.
KVITT_PUBLIC_URL: ${KVITT_PUBLIC_URL:-http://localhost:8080}
KVITT_FCM_CREDENTIALS: /secrets/fcm-service-account.json
KVITT_FCM_PROJECT_ID: ${KVITT_FCM_PROJECT_ID:-kvitt-1337}
RUST_LOG: ${RUST_LOG:-info}
volumes:
# SQLite database, pictures and APKs: a bind-mounted folder on the Mac (git-ignored).
- ${KVITT_DEV_DATA_DIR:-./data}:/data
# The secrets folder, read-only, so the server finds /secrets/fcm-service-account.json (git-ignored).
# The folder is mounted rather than the file: a missing source file would make Docker create a directory
# in its place, while a missing key must simply leave push disabled with a start-up warning (§10.4).
- ${KVITT_SECRETS_DIR:-../secrets}:/secrets:ro
healthcheck:
test: ["CMD", "kvitt-server", "healthcheck"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
logging: *logging
networks: [kvitt]
backup:
image: ${KVITT_BACKUP_IMAGE:-kvitt-backup:dev}
# On demand only: `docker compose run --rm backup /backup.sh` (infra/dev/backup.sh). `up` skips it.
profiles: [backup]
command: ["/backup.sh"]
restart: "no"
# Same uid as the server, so it can open the WAL database files the server owns.
user: "10001:10001"
cap_drop: [ALL]
security_opt:
- no-new-privileges:true
mem_limit: 96m
environment:
TZ: Europe/Stockholm
# Empty on the dev stack: the Drive upload is skipped and the local files are kept (§10.9, D-001).
RCLONE_FOLDER_ID: ""
RCLONE_REMOTE: ${RCLONE_REMOTE:-gdrive:}
volumes:
- ${KVITT_DEV_DATA_DIR:-./data}:/data
- ${KVITT_DEV_BACKUP_DIR:-./backups}:/backups
logging: *logging
networks: [kvitt]
networks:
kvitt:
driver: bridge
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env bash
# Copies the Firebase config (both Android clients, se.hagstedt.kvitt and se.hagstedt.kvitt.debug) from
# infra/secrets/google-services.json to android/app/google-services.json in this checkout. Both are git-ignored.
# Usage: infra/dev/copy-google-services.sh [source checkout] (default: this checkout; in a worktree, pass the main checkout)
set -euo pipefail
# shellcheck source=lib.sh
. "$(dirname "$0")/lib.sh"
src_root="${1:-$repo_root}"
src="$src_root/infra/secrets/google-services.json"
dst="$repo_root/android/app/google-services.json"
[[ -f "$src" ]] || { echo "copy-google-services.sh: $src not found (created by the M2c-dev Firebase setup)." >&2; exit 1; }
git -C "$repo_root" check-ignore -q "android/app/google-services.json" || {
echo "copy-google-services.sh: android/app/google-services.json is not git-ignored; refusing to copy." >&2
exit 1
}
mkdir -p "$(dirname "$dst")"
cp "$src" "$dst"
chmod 600 "$dst"
echo "Copied google-services.json to $dst"
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# Shared helpers for the infra/dev scripts. Sourced, not run.
# shellcheck disable=SC2034 # variables are used by the scripts that source this file
dev_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$dev_dir/../.." && pwd)"
# infra/dev/.env (git-ignored) when it exists, otherwise the committed example.
if [[ -f "$dev_dir/.env" ]]; then env_file="$dev_dir/.env"; else env_file="$dev_dir/.env.example"; fi
# Value of KEY in the env file (empty when unset or blank).
env_value() {
sed -n "s/^$1=//p" "$env_file" | tail -n 1
}
compose() {
docker compose -f "$dev_dir/compose.yaml" --env-file "$env_file" "$@"
}
# Resolve a path from the env file the way compose does: relative to infra/dev/.
dev_path() {
case "$1" in
/*) printf '%s\n' "$1" ;;
*) printf '%s\n' "$dev_dir/$1" ;;
esac
}
# The Mac's address on the interface that carries the default route (the home Wi-Fi).
lan_address() {
local iface
iface="$(route -n get default 2>/dev/null | awk '/interface:/ {print $2}')"
[[ -n "$iface" ]] && ipconfig getifaddr "$iface" 2>/dev/null
}
dev_port() {
local p
p="$(env_value KVITT_DEV_PORT)"
printf '%s\n' "${p:-8080}"
}
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Follows the development stack's logs. Extra arguments go to `docker compose logs`
# (for example: infra/dev/logs.sh --tail 100 server).
set -euo pipefail
# shellcheck source=lib.sh
. "$(dirname "$0")/lib.sh"
compose logs -f "$@"
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Starts the development server (infra/dev/compose.yaml), waits for GET /health and prints the URLs.
# KVITT_PUBLIC_URL defaults to http://<Mac LAN address>:<port> when the env file leaves it empty.
# Usage: infra/dev/up.sh Stop with: infra/dev/up.sh --down
set -euo pipefail
# shellcheck source=lib.sh
. "$(dirname "$0")/lib.sh"
if [[ "${1:-}" == "--down" ]]; then
compose down
exit 0
fi
port="$(dev_port)"
lan="$(lan_address || true)"
public_url="$(env_value KVITT_PUBLIC_URL)"
if [[ -z "$public_url" ]]; then
if [[ -n "$lan" ]]; then public_url="http://$lan:$port"; else public_url="http://localhost:$port"; fi
fi
export KVITT_PUBLIC_URL="$public_url"
data_dir="$(dev_path "$(env_value KVITT_DEV_DATA_DIR)")"
secrets_dir="$(dev_path "$(env_value KVITT_SECRETS_DIR)")"
mkdir -p "$data_dir"
if [[ ! -f "$secrets_dir/fcm-service-account.json" ]]; then
echo "up.sh: no $secrets_dir/fcm-service-account.json; the server starts with push disabled." >&2
fi
compose up -d server
echo "Waiting for http://localhost:$port/health ..."
for _ in $(seq 1 30); do
if body="$(curl -fsS "http://localhost:$port/health" 2>/dev/null)"; then
echo "health: $body"
echo "Mac: http://localhost:$port"
[[ -n "$lan" ]] && echo "Phone: http://$lan:$port (home Wi-Fi)"
echo "Invites: $public_url"
exit 0
fi
sleep 1
done
echo "up.sh: /health did not answer within 30 s; see infra/dev/logs.sh" >&2
exit 1