Rebrand to Proof of Work; multi-user 0.4 package with curated library sync

Repo cleanup
- Add top-level .gitignore (was missing; node_modules, .next, *.s9pk,
  image.tar, seed/data/*.db, log files, etc.) and a root README.
- Delete legacy start9/0.3.5/ package (StartOS 0.3.5 wrapper, no longer
  the deploy target).
- Delete start9-example-packaging/ (template from another project).
- Delete planning docs (START9_PACKAGING_LOG.md, VERSIONING.md,
  STARTOS_0.4_UPGRADE_PROMPT.md, ICON_FILES_INDEX.md, etc.) — info now
  lives in the deploy guide and code comments.
- Drop the standalone Dockerfile, docker-compose.yml, ICON_*, and dev
  log/build artifacts from the app dir.
- Drop the v0.1.0:18/19/20 version files (they belonged to the legacy
  workout-log package and don't apply to the new id).

Rename + new package
- Rename app dir workout-planner/ -> proof-of-work/.
- Rename StartOS package id workout-log -> proof-of-work; the new id
  makes this a brand new StartOS service (clean cutover from the old
  one rather than in-place upgrade).
- Reset version graph; v1.0.0:1 is the seeded cutover release. The
  Dockerfile bakes a one-time /data snapshot and docker_entrypoint.sh
  copies it into the new volume on truly-fresh first boot only (both
  /data/app.db missing AND /data/.seeded absent).
- Move start9/0.4-migration/ -> start9/0.4/; the old start9/0.4/ stub
  is gone.

Curated exercise library (multi-user-aware)
- proof-of-work/prisma/exercises.seed.json is the canonical library
  shipped to every install (164 exercises today, dumped from the live
  snapshot).
- proof-of-work/scripts/sync-library.cjs (npm run sync-library) refreshes
  the JSON from start9/0.4/seed/data/app.db after refresh_seed.sh.
- proof-of-work/prisma/seed.ts now reads from the JSON instead of a
  hardcoded 52-exercise array; runs at Docker build time to seed the
  fallback DB and on first boot for fresh installs.
- proof-of-work/prisma/ensureExerciseLibrary.cjs runs on every container
  boot (from docker_entrypoint.sh) and INSERT OR IGNOREs every library
  entry for every user, keyed on (userId, name). Library updates flow
  to existing installs on package upgrade; user-custom exercises
  (isCustom=true) and any colliding names are never overwritten;
  removed exercises stay on existing installs (additive-only).

Deploy guide (start9/0.4/DEPLOY_040.md)
- Rewritten end-to-end for the workout-log -> proof-of-work cutover:
  refresh_seed, sync-library, build, sideload, verify, rotate creds,
  stop the old service, then post-cutover cleanup release v1.0.0:2.
This commit is contained in:
Keysat
2026-05-08 20:12:25 -05:00
parent 1b64c45c52
commit aa407b5f67
184 changed files with 8314 additions and 3286 deletions
-32
View File
@@ -1,32 +0,0 @@
# Deploy on StartOS 0.3.5 (Raspberry Pi)
## 1) Build package on your Mac
```bash
cd /Users/macpro/Projects/Workout-log
make -C start9/0.3.5 package
```
This creates:
- `start9/0.3.5/image.tar`
- `start9/0.3.5/workout-log.s9pk`
## 2) Upload package to StartOS
1. Open the StartOS web UI.
2. Go to Services -> Sideload Package (0.3.5 menu naming may vary).
3. Upload `workout-log.s9pk`.
4. Install and start the service.
## 3) First run
1. Open the service UI.
2. Log in with `admin@local` / `workout123`.
3. Change password and run one manual backup.
## 4) Data persistence contract
- App DB path: `/data/app.db`
Because this lives in the persistent service volume, restarts and wrapper upgrades should not erase data.
## 5) Preparing for StartOS 0.4.0 migration
1. Run a StartOS backup before migration.
2. Keep `/data/app.db` contract unchanged in the future 0.4 wrapper.
3. Preserve package id (`workout-log`) when possible to simplify migration continuity.
-47
View File
@@ -1,47 +0,0 @@
FROM node:20-alpine AS builder
WORKDIR /app
RUN apk add --no-cache openssl
COPY workout-planner/package.json workout-planner/package-lock.json ./
RUN npm ci
COPY workout-planner/ ./
RUN npx prisma generate
RUN mkdir -p /tmp-seed \
&& DATABASE_URL=file:/tmp-seed/app.db npx prisma db push --skip-generate \
&& DATABASE_URL=file:/tmp-seed/app.db npm run db:seed
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
RUN apk add --no-cache dumb-init curl openssl \
&& addgroup -S nodejs -g 1001 \
&& adduser -S nextjs -u 1001 -G nodejs
ENV NODE_ENV=production \
HOSTNAME=0.0.0.0 \
PORT=3000 \
WORKOUT_DATA_DIR=/data \
WORKOUT_DB_PATH=/data/app.db \
WORKOUT_SEED_DB_PATH=/app/prisma/data/app.db
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /tmp-seed/app.db /app/prisma/data/app.db
COPY start9/0.3.5/docker_entrypoint.sh /usr/local/bin/docker_entrypoint.sh
COPY start9/0.3.5/healthcheck.sh /usr/local/bin/healthcheck.sh
RUN chmod +x /usr/local/bin/docker_entrypoint.sh /usr/local/bin/healthcheck.sh \
&& mkdir -p /data \
&& chown -R nextjs:nodejs /app /data
USER nextjs
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--", "/usr/local/bin/docker_entrypoint.sh"]
-23
View File
@@ -1,23 +0,0 @@
PKG_ID := workout-log
PKG_VERSION := 0.1.0.0
REPO_ROOT := $(abspath ../..)
WRAPPER_DIR := $(CURDIR)
IMAGE_NAME := start9/$(PKG_ID)/main:$(PKG_VERSION)
.PHONY: image-arm package verify clean
image-arm:
docker buildx build --platform=linux/arm64 \
-f $(WRAPPER_DIR)/Dockerfile \
-t $(IMAGE_NAME) \
-o type=docker,dest=$(WRAPPER_DIR)/image.tar \
$(REPO_ROOT)
package: image-arm
start-sdk pack
verify:
start-sdk verify s9pk $(PKG_ID).s9pk
clean:
rm -f $(WRAPPER_DIR)/image.tar $(WRAPPER_DIR)/$(PKG_ID).s9pk
-23
View File
@@ -1,23 +0,0 @@
# Start9 Wrapper (0.3.5)
This directory contains the StartOS 0.3.5 package wrapper for Workout Log.
## Build prerequisites
- Docker with buildx
- `start-sdk` installed on build machine
## Build package
```bash
cd /Users/macpro/Projects/Workout-log
make -C start9/0.3.5 package
```
## Verify package
```bash
cd /Users/macpro/Projects/Workout-log
make -C start9/0.3.5 verify
```
## Outputs
- `start9/0.3.5/image.tar`
- `start9/0.3.5/workout-log.s9pk`
-24
View File
@@ -1,24 +0,0 @@
#!/bin/sh
set -eu
DATA_DIR="${WORKOUT_DATA_DIR:-/data}"
DB_PATH="${WORKOUT_DB_PATH:-$DATA_DIR/app.db}"
SEED_DB_PATH="${WORKOUT_SEED_DB_PATH:-/app/prisma/data/app.db}"
mkdir -p "$DATA_DIR"
if [ ! -f "$DB_PATH" ]; then
if [ -f "$SEED_DB_PATH" ]; then
cp "$SEED_DB_PATH" "$DB_PATH"
else
# Fallback if seed DB is unavailable.
touch "$DB_PATH"
fi
fi
export DATABASE_URL="file:$DB_PATH"
export NODE_ENV="${NODE_ENV:-production}"
export HOSTNAME="${HOSTNAME:-0.0.0.0}"
export PORT="${PORT:-3000}"
exec node /app/server.js
-5
View File
@@ -1,5 +0,0 @@
#!/bin/sh
set -eu
PORT="${PORT:-3000}"
curl -fsS "http://127.0.0.1:${PORT}/api/health" >/dev/null
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.
-21
View File
@@ -1,21 +0,0 @@
# Workout Log (StartOS 0.3.5)
## What this package does
- Runs Workout Log as a private web app.
- Persists all app data in the StartOS service volume (`/data`).
- Exposes web UI/API on internal port `3000`.
## First launch
1. Open the service UI from StartOS.
2. Log in with default credentials: `admin@local` / `workout123`.
3. Immediately change the password from inside the app (recommended).
4. Run a manual StartOS backup after initial setup.
## Data safety
- Database path in container: `/data/app.db`.
- All persistent state is kept under `/data` for upgrade-safe persistence.
## Upgrade and migration note
This 0.3.5 wrapper keeps runtime data separate from app/runtime files using `/data`.
That makes migration to a future StartOS 0.4.0 wrapper straightforward as long as the
wrapper continues to use the same data path contract.
-94
View File
@@ -1,94 +0,0 @@
id: workout-log
title: Workout Log
version: 0.1.0.0
release-notes: >-
Initial StartOS 0.3.5 package wrapper for Workout Log.
license: Proprietary
wrapper-repo: https://github.com/your-org/workout-log-startos
upstream-repo: https://github.com/your-org/workout-log
support-site: https://github.com/your-org/workout-log/issues
marketing-site: https://github.com/your-org/workout-log
build: ["make image-arm"]
description:
short: Self-hosted workout planning and logging app.
long: >-
Workout Log is a self-hosted web app for planning workouts, logging sets,
tracking progress, and managing exercises. This package keeps runtime data in
the StartOS service volume for upgrade-safe persistence and future migration.
assets:
license: LICENSE
icon: icon.png
instructions: instructions.md
docker-images: image.tar
main:
type: docker
image: main
entrypoint: docker_entrypoint.sh
args: []
mounts:
main: /data
health-checks:
main:
name: API health
success-message: Workout Log API is responding.
type: docker
image: main
entrypoint: healthcheck.sh
args: []
inject: true
config: ~
dependencies: {}
volumes:
main:
type: data
interfaces:
main:
name: Web Interface
description: Browser UI and API for Workout Log.
tor-config:
port-mapping:
80: "3000"
lan-config:
443:
ssl: true
internal: 3000
ui: true
protocols: [tcp, http, https]
backup:
create:
type: docker
image: main
system: false
entrypoint: sh
args:
- -c
- |
set -eu
rm -rf /backup/*
cp -a /data/. /backup/
mounts:
main: /data
BACKUP: /backup
restore:
type: docker
image: main
system: false
entrypoint: sh
args:
- -c
- |
set -eu
cp -a /backup/. /data/
mounts:
main: /data
BACKUP: /backup
actions: {}
+264
View File
@@ -0,0 +1,264 @@
# Deploy Proof of Work on StartOS 0.4 (sideload)
This guide walks the maintainer through cutting over from the legacy
`workout-log` package to the new `proof-of-work` package. They share the
same upstream code and on-disk schema, but `proof-of-work` is a brand new
StartOS service (different package id), so StartOS treats it as a fresh
install. Data preservation is handled by baking a one-time snapshot of
your live `workout-log` `/data` volume into the `proof-of-work` v1.0.0:1
image and copying it into the new volume on first boot.
Your existing `workout-log` install stays running and untouched the whole
time. The cutover is one-way only after you stop and uninstall it; until
then you can fall back to it.
> NEVER click Uninstall on either package mid-cutover. Uninstall destroys
> the volume. Use Stop instead. Only Uninstall after you've verified
> proof-of-work is happy.
---
## 0) Prereqs on your build machine
You need:
- Node.js >= 20 and npm
- Docker with `buildx`
- `start-cli` from Start9 (SDK):
<https://docs.start9.com/packaging/0.4.0.x/environment-setup.html>
- `jq`, `make`, `git`, `sqlite3`, `rsync`, `ssh`
- Reachable SSH access to your existing `workout-log` host
One-time `start-cli` key setup:
```sh
start-cli init-key
```
`~/.startos/config.yaml` should contain at least:
```yaml
host: http://<your-04-host>.local
```
`make install` uses this to push the `.s9pk` to the target.
---
## 1) Refresh the seed snapshot from your live `workout-log` host
The repo includes `start9/0.4/seed/data/app.db` as a placeholder so the
build works without network access, but for an actual cutover you should
pull a fresh snapshot from your live host first.
```sh
./start9/0.4/refresh_seed.sh embassy@embassy.local
```
For a 0.3.5 host this auto-detects
`/embassy-data/package-data/volumes/workout-log/data/main/`. For a 0.4
host pass the volume path explicitly as arg 2 — get it from
`start-cli package shell workout-log` and `df` inside the container, or
from the StartOS docs.
Refresh prints expected row counts and runs `PRAGMA integrity_check`. If
anything looks off, fix the source before proceeding.
---
## 2) (Optional) Refresh the curated exercise library JSON
The shared library that ships with the package is generated from the same
snapshot. After `refresh_seed.sh`, regenerate the JSON if you've added
new exercises in the running app:
```sh
cd proof-of-work
npm run sync-library
git diff prisma/exercises.seed.json # eyeball the new rows
```
Commit the JSON change before building so it's baked into the image.
---
## 3) Build the package
```sh
cd start9/0.4
npm ci
make clean
make x86
```
On success you'll get an artifact at:
```
start9/0.4/proof-of-work_x86_64.s9pk
```
---
## 4) Sideload on the 0.4 host
### Option A — `make install` (fastest)
```sh
make install
```
### Option B — StartOS web UI
1. Open the StartOS 0.4 web UI on the target host.
2. **System -> Sideload Service**.
3. Upload `start9/0.4/proof-of-work_x86_64.s9pk`.
4. Click **Install**, then **Start**.
`workout-log` and `proof-of-work` will appear as two separate services in
the UI. Both can be running simultaneously — they have different volumes,
different ports the SDK assigns, and no shared state.
---
## 5) First-boot verification
### 5a. Confirm the seed branch ran
In **Services -> Proof of Work -> Logs**, or via CLI:
```sh
start-cli package logs proof-of-work | grep '\[entrypoint\]'
```
On the very first boot you should see:
```
[entrypoint] no /data/app.db and no .seeded marker; copying baked cutover seed from /app/seed/data/app.db
[entrypoint] ensuring curated exercise library is present for every user
[ensure-library] processed 1 user(s) x 164 exercise(s) (164 INSERT OR IGNORE statements)
[entrypoint] launching Next.js on :3000 with DATABASE_URL=file:/data/app.db
```
Subsequent boots:
```
[entrypoint] /data/app.db already present; live data is the source of truth
[entrypoint] found .seeded: seeded from baked cutover snapshot at 2026-...
[ensure-library] processed N user(s) x 164 exercise(s)
```
### 5b. Log into the app
Open the web UI from the StartOS service page and log in with the same
credentials you used on the legacy `workout-log` install (the bcrypt hash
came over with the snapshot, so existing passwords keep working).
Default seed credentials (only on a brand-new install with no baked
seed): `admin@local` / `workout123` — change immediately via the action
in section 6.
### 5c. Spot-check the data
- Workouts count matches your old install (run-counts in the
`refresh_seed.sh` output were the source of truth).
- Exercise library shows your 164 exercises.
- Open a recent workout and confirm set logs match.
### 5d. Run a StartOS backup
**Services -> Proof of Work -> Backup -> Create Backup**. Confirm it
completes. This validates `Backups.ofVolumes('main')` wiring on the new
package.
---
## 6) Rotate admin credentials
Stop the service first (action is gated on `allowedStatuses: only-stopped`):
1. **Services -> Proof of Work -> Stop**
2. **Actions -> Change admin credentials**
3. Fill in new email, new password, confirm. Submit.
4. **Start** the service and log in with the new credentials.
---
## 7) Stop the legacy `workout-log` service
Once you've used `proof-of-work` for at least a session and confirmed
nothing's missing:
1. **Services -> Workout Log -> Stop**
DO NOT Uninstall yet. Keeping the old service installed (just stopped)
preserves its `/data` volume in case you need to re-pull the snapshot or
roll back. Once you're confident (a week is plenty), Uninstall to free
the volume.
---
## 8) Post-cutover cleanup release (v1.0.0:2)
After a few days running on `proof-of-work` with no surprises, ship a
cleanup release that:
- Strips `COPY start9/0.4/seed/data /app/seed/data` from the Dockerfile.
- Strips the seed-copy branch out of `docker_entrypoint.sh` (keep the
fallback DB branch and the ensure-library step).
- Leaves `seed/` and `refresh_seed.sh` on disk as historical artifacts.
- Adds `startos/versions/v1.0.0.2.ts` (empty `up`/`down` migrations) and
promotes it to `current` in the version graph; v1.0.0:1 moves to `other`.
Reason: once `/data` is the sole source of truth, a baked seed in the
image becomes a foot-gun — removing it eliminates any chance of a future
upgrade accidentally stomping live data.
---
## 9) Library updates after cutover (steady state)
```
# add new exercises in the running app, then:
./start9/0.4/refresh_seed.sh embassy@embassy.local
cd proof-of-work && npm run sync-library
git add prisma/exercises.seed.json
# bump the build rev in start9/0.4/startos/versions, add a new version file,
# commit, then:
cd start9/0.4 && make clean && make x86 && make install
```
On every upgrade, every user on the instance picks up the new exercises
on first boot via the `INSERT OR IGNORE` step. Custom exercises a user
added themselves are never overwritten.
---
## 10) Troubleshooting
| Symptom | Likely cause | Fix |
| --- | --- | --- |
| Login fails with your `workout-log` password | Snapshot was pulled from a stale source | Re-run `refresh_seed.sh` against your live host and rebuild. |
| Workouts missing after cutover | Same | Same. |
| `/data/app.db` is empty after first boot | `.seeded` marker present without the DB (corrupted prior boot) | `start-cli package shell proof-of-work`, `rm /data/.seeded`, restart. |
| Service never reaches Ready | Port 3000 not listening | Check logs: Prisma or Next.js threw at boot. Verify the DB file isn't zero bytes. |
| `[ensure-library] WARNING` | sqlite3 failed to apply the curated library | Inspect `/data/app.db` and the JSON. Boot continues; library updates skipped this cycle. |
| Container exits immediately | Build context wrong — `proof-of-work/` not visible to Docker | Run `make` from `start9/0.4/`, not the repo root. |
| `make x86` fails with `No rule to make target .git/HEAD` | s9pk.mk's awk grabbed two `id:` matches and concatenated BASE_NAME | Confirm `manifest/index.ts` has only one `id:` line. The shipped s9pk.mk uses `{print $$2; exit}` which already guards this. |
| Docker fails with `ERROR: failed to build: resolve : lstat start9: no such file or directory` | `dockerBuild.dockerfile` was set to a path including `start9/0.4/` | The shipped manifest sets `dockerfile: './Dockerfile'`. The `dockerfile` field is resolved relative to the package dir, not relative to `workdir`. |
---
## 11) Data-preservation summary
- Volume name (`main`), mount path (`/data`), DB path (`/data/app.db`),
internal port (`3000`): identical to the legacy `workout-log` package.
- Package id changed: `workout-log` -> `proof-of-work`. StartOS treats
this as a brand new service. Migration is via the baked seed in
v1.0.0:1, not via in-place upgrade.
- Seed only writes to `/data` on truly-fresh first boot (both `app.db`
missing AND `.seeded` marker absent).
- Library updates (new exercises) flow to every user on every upgrade
via additive `INSERT OR IGNORE`. Never deletes; never overwrites a
user's own custom exercises.
- StartOS Backup captures `/data` in full.
- Uninstall destroys `/data`. The `alertUpdate` copy reminds users.
+88
View File
@@ -0,0 +1,88 @@
# syntax=docker/dockerfile:1.6
#
# Proof of Work (proof-of-work) — StartOS 0.4 package image.
#
# Build context: repo root (see manifest.images.main.source.dockerBuild.workdir
# which is set to '../..' so all COPY paths below are repo-root-relative).
#
# This Dockerfile is self-contained: it references only files under
# `proof-of-work/` (the upstream app) and `start9/0.4/` (this wrapper).
#
# Data preservation (v1.0.0:1 — initial seeded cutover):
# - This image bakes a one-time snapshot of the maintainer's live /data
# volume under /app/seed/data so the cutover from the legacy `workout-log`
# package preserves every workout, exercise, and preference.
# - docker_entrypoint.sh copies the seed into the StartOS-managed /data
# volume only on a truly-fresh first boot (both /data/app.db missing AND
# /data/.seeded absent). Every subsequent boot leaves /data alone.
# - v1.0.0:2 will strip the seed copy from the image and the seed-copy
# branch from the entrypoint once the cutover is verified in production.
# - A tiny empty-schema fallback DB is also COPYed from the builder stage
# (at /app/prisma/data/app.db) as a safety net for fresh sideloads on a
# brand-new host with no existing /data and no baked seed.
FROM node:20-alpine AS builder
WORKDIR /app
RUN apk add --no-cache openssl
COPY proof-of-work/package.json proof-of-work/package-lock.json ./
RUN npm ci
COPY proof-of-work/ ./
RUN npx prisma generate
# Build a fallback empty-but-schema-correct DB. Used by docker_entrypoint.sh
# only when /data has no app.db AND no baked seed is available (i.e. after
# v1.0.0:2 strips the seed). Seeded with the curated exercise library via
# `npm run db:seed`, so a brand-new install still gets the full library.
RUN mkdir -p /tmp-seed \
&& DATABASE_URL=file:/tmp-seed/app.db npx prisma db push --skip-generate \
&& DATABASE_URL=file:/tmp-seed/app.db npm run db:seed
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
RUN apk add --no-cache dumb-init curl openssl sqlite \
&& addgroup -S nodejs -g 1001 \
&& adduser -S nextjs -u 1001 -G nodejs
ENV NODE_ENV=production \
HOSTNAME=0.0.0.0 \
PORT=3000 \
WORKOUT_DATA_DIR=/data \
WORKOUT_DB_PATH=/data/app.db \
WORKOUT_FALLBACK_SEED_DB_PATH=/app/prisma/data/app.db \
WORKOUT_BAKED_SEED_DB_PATH=/app/seed/data/app.db \
WORKOUT_LIBRARY_JSON_PATH=/app/prisma/exercises.seed.json
# Next.js standalone runtime bundle
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
# Empty-schema fallback DB (used only when no baked seed is available on a
# brand-new sideload).
COPY --from=builder --chown=nextjs:nodejs /tmp-seed/app.db /app/prisma/data/app.db
# Baked one-time cutover seed: the maintainer's live /data snapshot pulled
# off the running `workout-log` host via refresh_seed.sh. Copied into /data
# only on truly-fresh first boot. Removed in v1.0.0:2.
COPY --chown=nextjs:nodejs start9/0.4/seed/data /app/seed/data
# Container entrypoint and diagnostic healthcheck
COPY start9/0.4/docker_entrypoint.sh /usr/local/bin/docker_entrypoint.sh
COPY start9/0.4/healthcheck.sh /usr/local/bin/healthcheck.sh
RUN chmod +x /usr/local/bin/docker_entrypoint.sh /usr/local/bin/healthcheck.sh \
&& mkdir -p /data \
&& chown -R nextjs:nodejs /app /data
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--", "/usr/local/bin/docker_entrypoint.sh"]
+8
View File
@@ -0,0 +1,8 @@
# Thin override of s9pk.mk.
#
# Beta target: x86_64 only, per StartOS 0.4 beta guidance. Change to
# "x86 arm" (or "x86 arm riscv") once those architectures are officially
# supported and tested on your host.
ARCHES := x86
include s9pk.mk
+83 -7
View File
@@ -1,10 +1,86 @@
# Start9 Wrapper (0.4.0 planning)
<p align="center">
<img src="icon.png" alt="Proof of Work Logo" width="21%">
</p>
This directory is reserved for the future StartOS 0.4.0 wrapper.
# Proof of Work on StartOS 0.4 (migration package)
Migration intent from 0.3.5:
- Keep package id as `workout-log` when StartOS 0.4.0 migration rules allow it.
- Preserve persistent data contract at `/data/app.db`.
- Reuse the same backup/restore semantics so migration is low-risk.
This directory packages **Proof of Work** (`proof-of-work`) for StartOS 0.4
beta. It is the cutover package that carries your 0.3.5 data across to a new
x86_64 StartOS 0.4 host.
When 0.4.0 is stable, adapt manifest/build interfaces to the 0.4 schema and validate with the relevant Start9 docs.
> Upstream app lives at `../../proof-of-work/` in this repo.
> Legacy 0.3.5 package lives at `../0.3.5/` (kept intact; do not modify).
> Codex's WIP 0.4 scaffold lives at `../0.4/` (kept intact; superseded by
> this folder).
## Goals
- Keep the package id `proof-of-work` so StartOS recognizes it as the same service.
- Keep the persistent data volume `main` mounted at `/data`.
- Keep the SQLite database at `/data/app.db`.
- Preserve every existing workout, set, exercise, and preference.
- Ship x86_64 only for 0.4 beta (sideload target).
## How data preservation works
1. `seed/data/app.db` holds a one-time snapshot of `/data` from the live
0.3.5 host (currently 1 user, 348 workouts, 164 exercises, 5720 set
logs).
2. The `Dockerfile` bakes that snapshot into the image at
`/app/seed/data/`.
3. On **first boot only**`/data/app.db` missing AND `/data/.seeded`
absent — `docker_entrypoint.sh` copies the seed into `/data/` and writes
a `.seeded` marker.
4. On every subsequent boot, `/data/` is the sole source of truth; the seed
in the image is ignored.
See `seed/README.md` for the snapshot provenance and row counts.
## Image runtime
| Property | Value |
| --- | --- |
| Base image | `node:20-alpine` (multi-stage build) |
| App runtime | Next.js standalone + Prisma + SQLite |
| Entrypoint | `/usr/local/bin/docker_entrypoint.sh` (dumb-init wrapped) |
| Internal port | `3000` |
| Architectures | `x86_64` (beta) |
## Build and sideload
```sh
cd start9/0.4
npm ci
make clean
make x86 # outputs proof-of-work_x86_64.s9pk
```
Sideload via StartOS web UI or `make install` (requires `~/.startos/config.yaml`).
Step-by-step instructions are in [`DEPLOY_040.md`](./DEPLOY_040.md).
## What is unchanged from 0.3.5
- Package id: `proof-of-work`
- Volume id: `main`
- Mount path: `/data`
- DB path: `/data/app.db`
- Health endpoint: `/api/health`
- Compat `ALTER TABLE` block (idempotent; no-op on a current DB)
## What is new in 0.4
- TypeScript SDK manifest under `startos/`
- ExVer version (`0.1.0:18`) replaces the 0.3.5 4-part `0.1.0.17`
- Seed-on-first-boot with a `.seeded` marker and stderr logging
- `alertUpdate` warning users not to Uninstall to troubleshoot
- Self-contained Dockerfile — no references to `../0.3.5/` or `../0.4/`
## Follow-up releases (planned, do not ship yet)
- **v0.1.0:19** — remove the `COPY seed/data \u2026` line and the seed block
from the entrypoint once the cutover is confirmed. Leaves `seed/` on disk
unreferenced.
- **v0.1.0:19** or **v0.1.0:20** — add a StartOS Package Action
`change-admin-credentials` that updates the User row in `/data/app.db`
(bcryptjs, salt rounds 10) so you can rename/rotate the admin from the
StartOS UI.
+27
View File
@@ -0,0 +1,27 @@
# About Proof of Work
Proof of Work is a private, self-hosted workout planning and training log. It
runs entirely on your StartOS server — no third-party cloud, no analytics, no
external account. Your workouts, exercises, and body-metric history stay in
the StartOS `main` volume and are included in every StartOS backup.
## Highlights
- Log workouts, sets, reps, weight, RPE, custom metrics.
- Soft-deletes preserve history (deleted workouts are hidden, not erased).
- Browser-based UI; single local user by default.
- SQLite database at `/data/app.db` — portable, auditable, backup-safe.
## Contract invariants
The 0.4 package keeps the exact same data contract as the original 0.3.5
release so existing `/data` contents are preserved end-to-end:
| What | Value |
| --- | --- |
| Package id | `proof-of-work` |
| Volume id | `main` |
| Mount path | `/data` |
| Primary DB file | `/data/app.db` |
| Internal port | `3000` |
| Health endpoint | `/api/health` |
+107
View File
@@ -0,0 +1,107 @@
#!/bin/sh
# Proof of Work (proof-of-work) — StartOS 0.4 container entrypoint.
#
# Responsibilities (in order):
# 1. Ensure the persistent /data directory exists.
# 2. Seed /data on truly-fresh first boot:
# a. If /app/seed/data/app.db is present (v1.0.0:1 cutover image),
# copy it into /data. This is how an operator migrating from
# the legacy `workout-log` package preserves their full history.
# b. Otherwise fall back to the empty-schema fallback DB at
# /app/prisma/data/app.db (already seeded with the curated
# exercise library at build time).
# Both branches are gated on /data/app.db being absent AND
# /data/.seeded being absent — never overwrites live data.
# 3. Run idempotent compat ALTERs for columns added after older snapshots.
# No-ops on hosts whose schema is already current.
# 4. Ensure the curated exercise library is present for every user. New
# maintainer-shipped exercises appear on every boot (INSERT OR IGNORE
# keyed on (userId, name); never overwrites a user's own exercises).
# 5. Exec the Next.js standalone server as PID 1 (under dumb-init).
#
# Every branch logs to stderr so it is visible in StartOS -> Logs.
set -eu
DATA_DIR="${WORKOUT_DATA_DIR:-/data}"
DB_PATH="${WORKOUT_DB_PATH:-$DATA_DIR/app.db}"
FALLBACK_SEED_DB_PATH="${WORKOUT_FALLBACK_SEED_DB_PATH:-/app/prisma/data/app.db}"
BAKED_SEED_DB_PATH="${WORKOUT_BAKED_SEED_DB_PATH:-/app/seed/data/app.db}"
LIBRARY_JSON_PATH="${WORKOUT_LIBRARY_JSON_PATH:-/app/prisma/exercises.seed.json}"
log() {
# write to stderr so StartOS log viewer surfaces it immediately
printf '[entrypoint] %s\n' "$*" 1>&2
}
mkdir -p "$DATA_DIR"
# -----------------------------------------------------------------------------
# Step 1 — first-boot seeding. NEVER overwrites an existing app.db.
# Both branches are skipped on every restart of an installed host because
# /data/app.db already exists.
# -----------------------------------------------------------------------------
if [ ! -f "$DB_PATH" ] && [ ! -f "$DATA_DIR/.seeded" ]; then
if [ -f "$BAKED_SEED_DB_PATH" ]; then
log "no $DB_PATH and no .seeded marker; copying baked cutover seed from $BAKED_SEED_DB_PATH"
cp "$BAKED_SEED_DB_PATH" "$DB_PATH"
date -u +"seeded from baked cutover snapshot at %Y-%m-%dT%H:%M:%SZ" > "$DATA_DIR/.seeded"
elif [ -f "$FALLBACK_SEED_DB_PATH" ]; then
log "no $DB_PATH and no baked seed; copying empty-schema fallback from $FALLBACK_SEED_DB_PATH"
cp "$FALLBACK_SEED_DB_PATH" "$DB_PATH"
date -u +"seeded from empty-schema fallback at %Y-%m-%dT%H:%M:%SZ" > "$DATA_DIR/.seeded"
else
log "no $DB_PATH, no baked seed, no fallback DB; creating empty $DB_PATH"
touch "$DB_PATH"
fi
else
log "$DB_PATH already present; live data is the source of truth"
if [ -f "$DATA_DIR/.seeded" ]; then
log "found .seeded: $(cat "$DATA_DIR/.seeded")"
fi
fi
# -----------------------------------------------------------------------------
# Step 2 — idempotent compat ALTERs (safety net for older snapshots).
# No-ops on hosts whose schema is already current.
# -----------------------------------------------------------------------------
if command -v sqlite3 >/dev/null 2>&1 && [ -f "$DB_PATH" ]; then
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('SetLog');" 2>/dev/null | grep -q "|customMetrics|"; then
log "adding missing column SetLog.customMetrics"
sqlite3 "$DB_PATH" "ALTER TABLE SetLog ADD COLUMN customMetrics TEXT;"
fi
if ! sqlite3 "$DB_PATH" "PRAGMA table_info('Workout');" 2>/dev/null | grep -q "|deletedAt|"; then
log "adding missing column Workout.deletedAt"
sqlite3 "$DB_PATH" "ALTER TABLE Workout ADD COLUMN deletedAt DATETIME;"
fi
fi
# -----------------------------------------------------------------------------
# Step 3 — ensure curated exercise library for every user (multi-user-aware).
# New entries shipped in /app/prisma/exercises.seed.json appear on every boot.
# `INSERT OR IGNORE` keyed on (userId, name) so we never overwrite a user's
# own custom exercises. Designed to be additive only — exercises removed from
# the curated JSON are not deleted from existing installs (users may have
# logged sets against them).
# -----------------------------------------------------------------------------
if [ -f "$LIBRARY_JSON_PATH" ] && [ -f "$DB_PATH" ]; then
log "ensuring curated exercise library is present for every user"
node /app/prisma/ensureExerciseLibrary.cjs \
--db "$DB_PATH" \
--json "$LIBRARY_JSON_PATH" \
|| log "WARNING: ensureExerciseLibrary failed; continuing boot"
else
log "skipping library ensure (json or db not found)"
fi
# -----------------------------------------------------------------------------
# Step 4 — launch the app.
# -----------------------------------------------------------------------------
export DATABASE_URL="file:$DB_PATH"
export NODE_ENV="${NODE_ENV:-production}"
export HOSTNAME="${HOSTNAME:-0.0.0.0}"
export PORT="${PORT:-3000}"
log "launching Next.js on :${PORT} with DATABASE_URL=file:${DB_PATH}"
exec node /app/server.js
+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Diagnostic healthcheck for local smoke-tests.
#
# StartOS 0.4 uses the TypeScript `checkPortListening` probe defined in
# `startos/main.ts`; this script is NOT wired into the manifest. It's here
# so you can `docker exec` into the container and validate end-to-end DB
# connectivity against the app's /api/health endpoint.
set -eu
PORT="${PORT:-3000}"
curl -fsS "http://127.0.0.1:${PORT}/api/health"
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+345
View File
@@ -0,0 +1,345 @@
{
"name": "workout-log-startos",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "workout-log-startos",
"dependencies": {
"@start9labs/start-sdk": "1.0.0",
"bcryptjs": "^2.4.3"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.19.0",
"@vercel/ncc": "^0.38.4",
"prettier": "^3.6.2",
"typescript": "^5.9.3"
}
},
"node_modules/@iarna/toml": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@iarna/toml/-/toml-3.0.0.tgz",
"integrity": "sha512-td6ZUkz2oS3VeleBcN+m//Q6HlCFCPrnI0FZhrt/h4XqLEdOyYp2u21nd8MdsR+WJy5r9PTDaHTDDfhf4H4l6Q==",
"license": "ISC"
},
"node_modules/@noble/curves": {
"version": "1.9.7",
"resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz",
"integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "1.8.0"
},
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
"integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==",
"license": "MIT",
"engines": {
"node": "^14.21.3 || >=16"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@start9labs/start-sdk": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@start9labs/start-sdk/-/start-sdk-1.0.0.tgz",
"integrity": "sha512-rtAfumVbMy90iw2WRbWH7fGcuwAvvuFfR4YwgSsh5R2Bz9MXtcEfmznwhnrp+ntQ6BOUSQ0wLzePbfsS6kUagg==",
"license": "MIT",
"dependencies": {
"@iarna/toml": "^3.0.0",
"@noble/curves": "^1.8.2",
"@noble/hashes": "^1.7.2",
"@types/ini": "^4.1.1",
"deep-equality-data-structures": "^2.0.0",
"fast-xml-parser": "^5.5.6",
"ini": "^5.0.0",
"isomorphic-fetch": "^3.0.0",
"mime": "^4.0.7",
"yaml": "^2.7.1",
"zod": "^4.3.6",
"zod-deep-partial": "^1.2.0"
}
},
"node_modules/@types/bcryptjs": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz",
"integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/ini": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/@types/ini/-/ini-4.1.1.tgz",
"integrity": "sha512-MIyNUZipBTbyUNnhvuXJTY7B6qNI78meck9Jbv3wk0OgNwRyOOVEKDutAkOs1snB/tx0FafyR6/SN4Ps0hZPeg==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.15",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.15.tgz",
"integrity": "sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@vercel/ncc": {
"version": "0.38.4",
"resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.38.4.tgz",
"integrity": "sha512-8LwjnlP39s08C08J5NstzriPvW1SP8Zfpp1BvC2sI35kPeZnHfxVkCwu4/+Wodgnd60UtT1n8K8zw+Mp7J9JmQ==",
"dev": true,
"license": "MIT",
"bin": {
"ncc": "dist/ncc/cli.js"
}
},
"node_modules/bcryptjs": {
"version": "2.4.3",
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
"integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==",
"license": "MIT"
},
"node_modules/deep-equality-data-structures": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-2.0.0.tgz",
"integrity": "sha512-qgrUr7MKXq7VRN+WUpQ48QlXVGL0KdibAoTX8KRg18lgOgqbEKMAW1WZsVCtakY4+XX42pbAJzTz/DlXEFM2Fg==",
"license": "MIT",
"dependencies": {
"object-hash": "^3.0.0"
}
},
"node_modules/fast-xml-builder": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.1.3"
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.9",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz",
"integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.2.0",
"strnum": "^2.2.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/ini": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/ini/-/ini-5.0.0.tgz",
"integrity": "sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw==",
"license": "ISC",
"engines": {
"node": "^18.17.0 || >=20.5.0"
}
},
"node_modules/isomorphic-fetch": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-3.0.0.tgz",
"integrity": "sha512-qvUtwJ3j6qwsF3jLxkZ72qCgjMysPzDfeV240JHiGZsANBYd+EEuu35v7dfrJ9Up0Ak07D7GGSkGhCHTqg/5wA==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.6.1",
"whatwg-fetch": "^3.4.1"
}
},
"node_modules/mime": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-4.1.0.tgz",
"integrity": "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw==",
"funding": [
"https://github.com/sponsors/broofa"
],
"license": "MIT",
"bin": {
"mime": "bin/cli.js"
},
"engines": {
"node": ">=16"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/object-hash": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"license": "MIT",
"engines": {
"node": ">= 6"
}
},
"node_modules/path-expression-matcher": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz",
"integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/prettier": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/strnum": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",
"integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-fetch": {
"version": "3.6.20",
"resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz",
"integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==",
"license": "MIT"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zod-deep-partial": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/zod-deep-partial/-/zod-deep-partial-1.4.4.tgz",
"integrity": "sha512-aWkPl7hVStgE01WzbbSxCgX4O+sSpgt8JOjvFUtMTF75VgL6MhWQbiZi+AWGN85SfSTtI9gsOtL1vInoqfDVaA==",
"license": "MIT",
"peerDependencies": {
"zod": "^4.1.13"
}
}
}
}
+25
View File
@@ -0,0 +1,25 @@
{
"name": "proof-of-work-startos",
"scripts": {
"build": "rm -rf ./javascript && ncc build startos/index.ts -o ./javascript",
"prettier": "prettier --write startos",
"check": "tsc --noEmit"
},
"dependencies": {
"@start9labs/start-sdk": "1.0.0",
"bcryptjs": "^2.4.3"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/node": "^22.19.0",
"@vercel/ncc": "^0.38.4",
"prettier": "^3.6.2",
"typescript": "^5.9.3"
},
"prettier": {
"trailingComma": "all",
"tabWidth": 2,
"semi": false,
"singleQuote": true
}
}
+85
View File
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# Refresh seed/data/ from the live StartOS 0.3.5 host over SSH.
#
# Usage:
# ./start9/0.4/refresh_seed.sh <ssh-target> [remote-volume-path]
#
# Example (most common):
# ./start9/0.4/refresh_seed.sh embassy@embassy.local
#
# Example (custom remote path):
# ./start9/0.4/refresh_seed.sh embassy@embassy.local \
# /embassy-data/package-data/volumes/proof-of-work/data/main
#
# Behavior:
# - Auto-detects /embassy-data/package-data/volumes/proof-of-work/data/main
# if the remote path is omitted.
# - Refuses to overwrite a non-empty seed/data/ without confirmation.
# - After SCP, runs PRAGMA integrity_check and prints expected row counts.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SEED_DATA_DIR="${SCRIPT_DIR}/seed/data"
if [ "$#" -lt 1 ]; then
echo "usage: $0 <ssh-target> [remote-volume-path]" >&2
exit 64
fi
SSH_TARGET="$1"
REMOTE_PATH="${2:-/embassy-data/package-data/volumes/proof-of-work/data/main}"
echo "[refresh-seed] target: ${SSH_TARGET}"
echo "[refresh-seed] remote path: ${REMOTE_PATH}"
echo "[refresh-seed] local seed: ${SEED_DATA_DIR}"
mkdir -p "${SEED_DATA_DIR}"
# Prompt if seed/data/ already has files.
if [ -n "$(ls -A "${SEED_DATA_DIR}" 2>/dev/null || true)" ]; then
echo
echo "[refresh-seed] WARNING: ${SEED_DATA_DIR} is non-empty:" >&2
ls -la "${SEED_DATA_DIR}" >&2
echo
read -r -p "Overwrite existing seed? [y/N] " answer
case "${answer:-N}" in
y|Y|yes|YES) ;;
*) echo "[refresh-seed] aborted."; exit 1 ;;
esac
fi
# Verify the remote path exists before copying.
echo "[refresh-seed] verifying remote path exists..."
if ! ssh "${SSH_TARGET}" "test -d \"${REMOTE_PATH}\""; then
echo "[refresh-seed] remote path not found on ${SSH_TARGET}:${REMOTE_PATH}" >&2
exit 1
fi
# Copy everything under the volume root (preserves mode/time).
echo "[refresh-seed] copying volume contents via rsync..."
rsync -aP --delete-excluded --exclude='.seeded' \
"${SSH_TARGET}:${REMOTE_PATH}/" \
"${SEED_DATA_DIR}/"
# Integrity check on the primary SQLite file, if present.
DB_PATH="${SEED_DATA_DIR}/app.db"
if [ -f "${DB_PATH}" ]; then
echo
echo "[refresh-seed] sqlite integrity_check:"
sqlite3 "${DB_PATH}" "PRAGMA integrity_check;" || {
echo "[refresh-seed] WARNING: integrity_check failed" >&2
}
echo
echo "[refresh-seed] row counts:"
for t in User Workout Exercise SetLog UserPreferences Program Session; do
n="$(sqlite3 "${DB_PATH}" "SELECT COUNT(*) FROM \"${t}\";" 2>/dev/null || echo '-')"
printf ' %-18s %s\n' "${t}" "${n}"
done
else
echo "[refresh-seed] NOTE: no app.db present in remote snapshot \u2014 skipped integrity check." >&2
fi
echo
echo "[refresh-seed] done. Review ${SEED_DATA_DIR}, commit the snapshot, and rebuild:"
echo " cd start9/0.4 && make clean && make"
+163
View File
@@ -0,0 +1,163 @@
# ** Plumbing. DO NOT EDIT **.
# This file is imported by ./Makefile. Make edits there
PACKAGE_ID := $(shell awk -F"'" '/id:/ {print $$2; exit}' startos/manifest/index.ts)
INGREDIENTS := $(shell start-cli s9pk list-ingredients 2>/dev/null)
ARCHES ?= x86 arm riscv
# Nested-package fixes for the upstream Start9 Makefile, which otherwise
# assumes the package dir == the git repo root. This package lives at
# start9/0.4/, so we need two small adjustments to make Make's
# prereq resolution work without rewriting ingredient names.
#
# (1) Git metadata paths. .git/HEAD and .git/index live at the repo root
# (or elsewhere for git worktrees), not at the package dir. Ask git
# for the canonical location via `git rev-parse --git-path`. Literal
# fallbacks preserve upstream behavior outside a git tree.
#
# (2) VPATH for ingredient search. `start-cli s9pk list-ingredients` emits
# paths in a *mixed* mode for nested packages:
#
# - package-local inputs (icon.png, assets/, LICENSE, and the
# compiled javascript/index.js) come out relative to the package
# dir (CWD). javascript/index.js is a build output: it doesn't
# exist before the first `npm run build`, and it's produced by
# the `javascript/index.js:` rule below.
# - Docker build inputs (the Dockerfile) come out relative to the
# manifest's images.*.source.dockerBuild.workdir, which for this
# package is '../..' -- i.e. the repo root.
#
# Setting VPATH to the repo root lets Make find workdir-relative
# inputs (e.g. start9/0.4/Dockerfile) there while leaving
# CWD-relative files that don't exist yet (e.g. javascript/index.js)
# to be built by their generating rule. We deliberately do NOT
# rewrite ingredient names to absolute paths -- doing that breaks
# the match between Make's `javascript/index.js:` rule and the
# prereq, which is how we got bitten previously.
REPO_ROOT := $(shell git rev-parse --show-toplevel 2>/dev/null)
GIT_HEAD_PATH := $(shell git rev-parse --git-path HEAD 2>/dev/null || echo .git/HEAD)
GIT_INDEX_PATH := $(shell git rev-parse --git-path index 2>/dev/null || echo .git/index)
VPATH := $(REPO_ROOT)
TARGETS ?= arches
ifdef VARIANT
BASE_NAME := $(PACKAGE_ID)_$(VARIANT)
else
BASE_NAME := $(PACKAGE_ID)
endif
.PHONY: all arches aarch64 x86_64 riscv64 arm arm64 x86 riscv arch/* clean install check-deps check-init package ingredients
.DELETE_ON_ERROR:
.SECONDARY:
define SUMMARY
@manifest=$$(start-cli s9pk inspect $(1) manifest); \
size=$$(du -h $(1) | awk '{print $$1}'); \
title=$$(printf '%s' "$$manifest" | jq -r .title); \
version=$$(printf '%s' "$$manifest" | jq -r .version); \
arches=$$(printf '%s' "$$manifest" | jq -r '[.images[].arch // []] | flatten | unique | join(", ")'); \
sdkv=$$(printf '%s' "$$manifest" | jq -r .sdkVersion); \
gitHash=$$(printf '%s' "$$manifest" | jq -r .gitHash | sed -E 's/(.*-modified)$$/\x1b[0;31m\1\x1b[0m/'); \
printf "\n"; \
printf "\033[1;32m✅ Build Complete!\033[0m\n"; \
printf "\n"; \
printf "\033[1;37m📦 $$title\033[0m \033[36mv$$version\033[0m\n"; \
printf "───────────────────────────────\n"; \
printf " \033[1;36mFilename:\033[0m %s\n" "$(1)"; \
printf " \033[1;36mSize:\033[0m %s\n" "$$size"; \
printf " \033[1;36mArch:\033[0m %s\n" "$$arches"; \
printf " \033[1;36mSDK:\033[0m %s\n" "$$sdkv"; \
printf " \033[1;36mGit:\033[0m %s\n" "$$gitHash"; \
echo ""
endef
all: $(TARGETS)
arches: $(ARCHES)
universal: $(BASE_NAME).s9pk
$(call SUMMARY,$<)
arch/%: $(BASE_NAME)_%.s9pk
$(call SUMMARY,$<)
x86 x86_64: arch/x86_64
arm arm64 aarch64: arch/aarch64
riscv riscv64: arch/riscv64
$(BASE_NAME).s9pk: $(INGREDIENTS) $(GIT_HEAD_PATH) $(GIT_INDEX_PATH)
@$(MAKE) --no-print-directory ingredients
@echo " Packing '$@'..."
start-cli s9pk pack -o $@
$(BASE_NAME)_%.s9pk: $(INGREDIENTS) $(GIT_HEAD_PATH) $(GIT_INDEX_PATH)
@$(MAKE) --no-print-directory ingredients
@echo " Packing '$@'..."
start-cli s9pk pack --arch=$* -o $@
ingredients: $(INGREDIENTS)
@echo " Re-evaluating ingredients..."
install: | check-deps check-init
@HOST=$$(awk -F'/' '/^host:/ {print $$3}' ~/.startos/config.yaml); \
if [ -z "$$HOST" ]; then \
echo "Error: You must define \"host: http://server-name.local\" in ~/.startos/config.yaml"; \
exit 1; \
fi; \
S9PK=$$(ls -t *.s9pk 2>/dev/null | head -1); \
if [ -z "$$S9PK" ]; then \
echo "Error: No .s9pk file found. Run 'make' first."; \
exit 1; \
fi; \
printf "\n🚀 Installing %s to %s ...\n" "$$S9PK" "$$HOST"; \
start-cli package install -s "$$S9PK"
publish: | all
@REGISTRY=$$(awk -F'/' '/^registry:/ {print $$3}' ~/.startos/config.yaml); \
if [ -z "$$REGISTRY" ]; then \
echo "Error: You must define \"registry: https://my-registry.tld\" in ~/.startos/config.yaml"; \
exit 1; \
fi; \
S3BASE=$$(awk -F'/' '/^s9pk-s3base:/ {print $$3}' ~/.startos/config.yaml); \
if [ -z "$$S3BASE" ]; then \
echo "Error: You must define \"s3base: https://s9pks.my-s3-bucket.tld\" in ~/.startos/config.yaml"; \
exit 1; \
fi; \
command -v s3cmd >/dev/null || \
(echo "Error: s3cmd not found. It must be installed to publish using s3." && exit 1); \
printf "\n🚀 Publishing to %s; indexing on %s ...\n" "$$S3BASE" "$$REGISTRY"; \
for s9pk in *.s9pk; do \
age=$$(( $$(date +%s) - $$(stat -c %Y "$$s9pk") )); \
if [ "$$age" -gt 3600 ]; then \
printf "\033[1;33m⚠️ %s is %d minutes old. Publish anyway? [y/N] \033[0m" "$$s9pk" "$$((age / 60))"; \
read -r ans; \
case "$$ans" in [yY]*) ;; *) echo "Skipping $$s9pk"; continue ;; esac; \
fi; \
start-cli s9pk publish "$$s9pk"; \
done
check-deps:
@command -v start-cli >/dev/null || \
(echo "Error: start-cli not found. Please see https://docs.start9.com/latest/developer-guide/sdk/installing-the-sdk" && exit 1)
@command -v npm >/dev/null || \
(echo "Error: npm not found. Please install Node.js and npm." && exit 1)
check-init:
@if [ ! -f ~/.startos/developer.key.pem ]; then \
echo "Initializing StartOS developer environment..."; \
start-cli init-key; \
fi
javascript/index.js: $(shell find startos -type f) tsconfig.json node_modules
npm run check
npm run build
node_modules: package-lock.json
npm ci
package-lock.json: package.json
npm i
clean:
@echo "Cleaning up build artifacts..."
@rm -rf $(PACKAGE_ID).s9pk $(PACKAGE_ID)_x86_64.s9pk $(PACKAGE_ID)_aarch64.s9pk $(PACKAGE_ID)_riscv64.s9pk javascript node_modules
+59
View File
@@ -0,0 +1,59 @@
# Seed snapshot
This directory contains a one-time snapshot of `/data/` from the live
StartOS 0.3.5 install of **Proof of Work** (`proof-of-work`). It is baked into
the 0.4 Docker image so the first 0.4 boot comes up with your production data
already in place.
## Source
| Field | Value |
| --- | --- |
| Source file | `proof-of-work-2026-04-21T14-36-53-332Z.db` (user-provided backup) |
| Exported on | 2026-04-21T14:36:53Z |
| Exported from | StartOS 0.3.5 (aarch64) running `proof-of-work` v0.1.0.17 |
| SQLite integrity check | `ok` |
## Row counts (at snapshot time)
| Table | Rows |
| --- | --- |
| `User` | 1 (`admin@local`) |
| `UserPreferences` | 1 |
| `Session` | 9 |
| `Exercise` | 164 |
| `Workout` | 348 |
| `SetLog` | 5720 |
| `Equipment` | 0 |
| `Program` / `ProgramWeek` / `ProgramDay` / `ProgramExercise` | 0 |
| `ContentItem` / `ContentChunk` | 0 |
| `AISuggestion` | 0 |
## What the seed does
On **first boot** of this 0.4 package, `docker_entrypoint.sh`:
1. Creates `/data/` if missing.
2. If `/data/app.db` does NOT exist AND `/data/.seeded` does NOT exist, copies
everything under `/app/seed/data/` into `/data/` and writes `/data/.seeded`
with a timestamp.
3. Logs which branch it took to stderr (visible in the StartOS log viewer).
On every subsequent boot, the seed copy is skipped and the live `/data/` is
used as the sole source of truth.
## When to refresh this snapshot
You usually refresh the seed only if you rebuild the package **before** doing
the first-time sideload on the 0.4 host. Once the 0.4 service is running,
stop rebuilding with a seed — the next package release (v0.1.0:19) removes
the seed step entirely so releases don't risk overwriting live data.
To refresh before first-time sideload, from the repo root:
```sh
./start9/0.4/refresh_seed.sh embassy@embassy.local
```
This SCPs the live `/embassy-data/package-data/volumes/proof-of-work/data/main/`
files back into `start9/0.4/seed/data/` and runs an integrity check.
@@ -0,0 +1,201 @@
import bcryptjs from 'bcryptjs'
import { sdk } from '../sdk'
/**
* change-admin-credentials — StartOS Package Action.
*
* Lets the user rotate the admin email and password for Proof of Work directly
* from the StartOS UI, without dropping to a shell. Replaces the manual CLI
* fallback documented in DEPLOY_040.md \u00a75b.
*
* Design notes:
*
* - allowedStatuses: 'only-stopped'. StartOS forces a Stop before the action
* runs, so there is zero risk of two writers (the running Next.js server +
* the action's sqlite3 UPDATE) racing on /data/app.db. As a side effect,
* any previously-issued session cookies are implicitly invalidated when
* the service restarts and re-reads the User row.
*
* - Bcrypt salt rounds = 10. This MUST match
* proof-of-work/lib/auth.ts::hashPassword, which uses bcryptjs.genSalt(10)
* followed by bcryptjs.hash. If the app ever changes its rounds, change them
* here too \u2014 otherwise login will fail.
*
* - We compute the bcrypt hash in the action's own JS runtime (bcryptjs is
* bundled via package.json), then push only the finished hash into the
* subcontainer. The plaintext password never lands in /proc, the SQL log,
* or anywhere persistent.
*
* - The UPDATE is keyed on `id = (SELECT id FROM User ORDER BY createdAt ASC
* LIMIT 1)` rather than `WHERE email = 'admin@local'` (the original 0.3.5
* default). That makes the action safe to re-run after a previous rotation.
* The app is single-user by design, so this targets the only User row.
*
* - We assert exactly 1 row was updated (`changes() == 1`). Anything else
* means the schema/data is in an unexpected state and we abort without
* reporting success, so the user is forced to investigate before assuming
* credentials rotated successfully.
*
* Available from package version 0.1.0:20 onward.
*/
const EMAIL_PATTERN = '^[A-Za-z0-9._%+\\-]+@[A-Za-z0-9.\\-]+\\.[A-Za-z]{2,}$'
/** Escape a string for safe inclusion inside SQLite single-quoted literal. */
const sqlQuote = (s: string): string => `'${s.replace(/'/g, "''")}'`
export const changeAdminCredentials = sdk.Action.withInput(
'change-admin-credentials',
// ---------------------------------------------------------------------------
// metadata
// ---------------------------------------------------------------------------
async () => ({
name: 'Change admin credentials',
description:
'Rotate the admin email and password stored in /data/app.db. The service must be stopped first; you will log in with the new credentials after starting it again.',
warning:
'This permanently overwrites the existing User row. Any browser sessions issued under the old credentials will stop working as soon as the service restarts. Make sure you can receive the new email at the address you enter.',
visibility: 'enabled',
allowedStatuses: 'only-stopped',
group: null,
}),
// ---------------------------------------------------------------------------
// input form
// ---------------------------------------------------------------------------
sdk.InputSpec.of({
email: sdk.Value.text({
name: 'New email address',
description: 'The email you will use to log in.',
required: true,
default: null,
inputmode: 'email',
placeholder: 'you@example.com',
patterns: [
{
regex: EMAIL_PATTERN,
description: 'Must be a valid email address (e.g. you@example.com).',
},
],
}),
password: sdk.Value.text({
name: 'New password',
description: 'Minimum 8 characters. No other complexity rules.',
required: true,
default: null,
masked: true,
minLength: 8,
placeholder: 'At least 8 characters',
}),
passwordConfirm: sdk.Value.text({
name: 'Confirm new password',
description: 'Retype the new password to guard against typos.',
required: true,
default: null,
masked: true,
minLength: 8,
placeholder: 'Retype new password',
}),
}),
// ---------------------------------------------------------------------------
// getInput (prefill) \u2014 we deliberately don't prefill the email. Prefilling
// would require spinning up a subcontainer just to read the current row,
// which is overkill for what is a once-in-a-blue-moon action.
// ---------------------------------------------------------------------------
async () => null,
// ---------------------------------------------------------------------------
// run
// ---------------------------------------------------------------------------
async ({ effects, input }) => {
if (input.password !== input.passwordConfirm) {
throw new Error(
'New password and confirmation do not match. Re-enter both fields.',
)
}
// Compute the bcrypt hash in the action's runtime. Salt rounds 10 to
// match proof-of-work/lib/auth.ts::hashPassword.
const passwordHash = await bcryptjs.hash(input.password, 10)
// Run the UPDATE inside a temp subcontainer with /data mounted. The
// subcontainer uses the same image as the main service, so sqlite3 is
// available (the runner image apks it in for the entrypoint's compat
// ALTERs).
await sdk.SubContainer.withTemp(
effects,
{ imageId: 'main' },
sdk.Mounts.of().mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: '/data',
readonly: false,
}),
'change-admin-credentials',
async (sc) => {
const sql = [
'BEGIN IMMEDIATE;',
`UPDATE User`,
`SET email = ${sqlQuote(input.email)},`,
` passwordHash = ${sqlQuote(passwordHash)},`,
` updatedAt = (strftime('%s','now') * 1000)`,
`WHERE id = (SELECT id FROM User ORDER BY createdAt ASC LIMIT 1);`,
'SELECT changes();',
'COMMIT;',
].join('\n')
const res = await sc.execFail(
['sqlite3', '/data/app.db'],
{ input: sql },
30_000,
)
// sqlite3 prints `changes()` on its own line. Be defensive about
// trailing whitespace / multiple lines.
const changes = parseInt(
res.stdout
.toString()
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
.pop() ?? '0',
10,
)
if (changes !== 1) {
throw new Error(
`Aborting: expected exactly 1 user row updated, but sqlite3 reported changes()=${changes}. The User table may be empty or contain unexpected rows. Inspect /data/app.db before retrying.`,
)
}
},
)
return {
version: '1',
title: 'Credentials updated',
message:
'The admin email and password have been rotated. Start the service and log in with your new credentials.',
result: {
type: 'group',
value: [
{
type: 'single',
name: 'New login email',
description: 'What you will enter on the Proof of Work login page.',
value: input.email,
copyable: true,
qr: false,
masked: false,
},
{
type: 'single',
name: 'Password',
description:
'Stored as a bcrypt hash (salt rounds 10). Not displayed.',
value: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022',
copyable: false,
qr: false,
masked: true,
},
],
},
}
},
)
+11
View File
@@ -0,0 +1,11 @@
import { sdk } from '../sdk'
import { changeAdminCredentials } from './changeAdminCredentials'
/**
* Package actions registered with StartOS.
*
* - change-admin-credentials (added v0.1.0:20): rotate the admin email +
* password from the StartOS UI without dropping to a shell. See
* ./changeAdminCredentials.ts for full design notes.
*/
export const actions = sdk.Actions.of().addAction(changeAdminCredentials)
+10
View File
@@ -0,0 +1,10 @@
import { sdk } from './sdk'
/**
* Back up the entire `main` volume. StartOS handles snapshotting everything
* under /data (app.db, sidecar WAL/SHM files, future additions), matching
* the 0.3.5 backup semantics.
*/
export const { createBackup, restoreInit } = sdk.setupBackups(
async () => sdk.Backups.ofVolumes('main'),
)
+4
View File
@@ -0,0 +1,4 @@
import { sdk } from './sdk'
/** Proof of Work is fully self-contained and has no StartOS dependencies. */
export const setDependencies = sdk.setupDependencies(async () => ({}))
@@ -0,0 +1,14 @@
export const DEFAULT_LANG = 'en_US'
const dict = {
'Starting Proof of Work': 0,
'Web Interface': 1,
'The web interface is ready': 2,
'The web interface is not ready': 3,
'Web UI': 4,
'The browser interface for Proof of Work': 5,
} as const
export type I18nKey = keyof typeof dict
export type LangDict = Record<(typeof dict)[I18nKey], string>
export default dict
@@ -0,0 +1,36 @@
import { LangDict } from './default'
export default {
es_ES: {
0: 'Iniciando Proof of Work',
1: 'Interfaz web',
2: 'La interfaz web esta lista',
3: 'La interfaz web no esta lista',
4: 'Interfaz web',
5: 'La interfaz del navegador para Proof of Work',
},
de_DE: {
0: 'Proof of Work wird gestartet',
1: 'Weboberflaeche',
2: 'Die Weboberflaeche ist bereit',
3: 'Die Weboberflaeche ist nicht bereit',
4: 'Weboberflaeche',
5: 'Die Browseroberflaeche fuer Proof of Work',
},
pl_PL: {
0: 'Uruchamianie Proof of Work',
1: 'Interfejs webowy',
2: 'Interfejs webowy jest gotowy',
3: 'Interfejs webowy nie jest gotowy',
4: 'Interfejs webowy',
5: 'Interfejs przegladarkowy dla Proof of Work',
},
fr_FR: {
0: 'Demarrage de Proof of Work',
1: 'Interface web',
2: "L'interface web est prete",
3: "L'interface web n'est pas prete",
4: 'Interface web',
5: "L'interface navigateur pour Proof of Work",
},
} satisfies Record<string, LangDict>
+8
View File
@@ -0,0 +1,8 @@
/**
* Plumbing. DO NOT EDIT this file.
*/
import { setupI18n } from '@start9labs/start-sdk'
import defaultDict, { DEFAULT_LANG } from './dictionaries/default'
import translations from './dictionaries/translations'
export const i18n = setupI18n(defaultDict, translations, DEFAULT_LANG)
+11
View File
@@ -0,0 +1,11 @@
/**
* Plumbing. DO NOT EDIT.
*/
export { createBackup } from './backups'
export { main } from './main'
export { init, uninit } from './init'
export { actions } from './actions'
import { buildManifest } from '@start9labs/start-sdk'
import { manifest as sdkManifest } from './manifest'
import { versionGraph } from './versions'
export const manifest = buildManifest(versionGraph, sdkManifest)
+16
View File
@@ -0,0 +1,16 @@
import { sdk } from '../sdk'
import { setDependencies } from '../dependencies'
import { setInterfaces } from '../interfaces'
import { versionGraph } from '../versions'
import { actions } from '../actions'
import { restoreInit } from '../backups'
export const init = sdk.setupInit(
restoreInit,
versionGraph,
setInterfaces,
setDependencies,
actions,
)
export const uninit = sdk.setupUninit(versionGraph)
+30
View File
@@ -0,0 +1,30 @@
import { i18n } from './i18n'
import { sdk } from './sdk'
import { uiPort } from './utils'
/**
* Expose the Next.js UI over StartOS's standard HTTP multi-host interface.
* The UI is unmasked (no shared secret in the URL) because the app has its
* own password-protected login screen.
*/
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
const uiMulti = sdk.MultiHost.of(effects, 'ui-multi')
const uiMultiOrigin = await uiMulti.bindPort(uiPort, {
protocol: 'http',
})
const ui = sdk.createInterface(effects, {
name: i18n('Web UI'),
id: 'ui',
description: i18n('The browser interface for Proof of Work'),
type: 'ui',
masked: false,
schemeOverride: null,
username: null,
path: '',
query: {},
})
const uiReceipt = await uiMultiOrigin.export([ui])
return [uiReceipt]
})
+43
View File
@@ -0,0 +1,43 @@
import { i18n } from './i18n'
import { sdk } from './sdk'
import { uiPort } from './utils'
/**
* Main daemon definition.
*
* Mounts the `main` volume at /data (same contract as 0.3.5) and runs the
* container's ENTRYPOINT (docker_entrypoint.sh -> Next.js standalone).
*
* Readiness: we check the listening port rather than hitting /api/health
* because the Next.js server starts listening before Prisma has warmed up;
* the entrypoint's own DB seeding logic is responsible for DB readiness.
* If you want a stricter gate later, switch to `sdk.healthCheck.checkWebUrl`
* pointed at `http://localhost:3000/api/health` and look for `status: ok`.
*/
export const main = sdk.setupMain(async ({ effects }) => {
console.info(i18n('Starting Proof of Work'))
return sdk.Daemons.of(effects).addDaemon('main', {
subcontainer: await sdk.SubContainer.of(
effects,
{ imageId: 'main' },
sdk.Mounts.of().mountVolume({
volumeId: 'main',
subpath: null,
mountpoint: '/data',
readonly: false,
}),
'proof-of-work-main',
),
exec: { command: sdk.useEntrypoint() },
ready: {
display: i18n('Web Interface'),
fn: () =>
sdk.healthCheck.checkPortListening(effects, uiPort, {
successMessage: i18n('The web interface is ready'),
errorMessage: i18n('The web interface is not ready'),
}),
},
requires: [],
})
})
+46
View File
@@ -0,0 +1,46 @@
export const short = {
en_US: 'Self-hosted workout planning and training log app.',
es_ES: 'Aplicacion autoalojada para planificar y registrar entrenamientos.',
de_DE: 'Selbst gehostete App fuer Trainingsplanung und Trainingsprotokolle.',
pl_PL: 'Samodzielnie hostowana aplikacja do planowania i rejestrowania treningow.',
fr_FR: "Application auto-hebergee pour planifier et enregistrer les entrainements.",
}
export const long = {
en_US:
'Proof of Work is a private workout planning and logging app. This StartOS 0.4 package preserves the existing /data volume contract from 0.3.5: the SQLite database at /data/app.db, workout history, exercise library, and imported records all survive the 0.3.5 \u2192 0.4 migration. The first 0.4 release ships with a one-time data seed baked into the image; subsequent releases use the live /data volume as the sole source of truth.',
es_ES:
'Proof of Work es una aplicacion privada para planificar y registrar entrenamientos. Este paquete de StartOS 0.4 conserva el contrato del volumen /data de 0.3.5: la base SQLite en /data/app.db, el historial de entrenamientos, la biblioteca de ejercicios y los registros importados sobreviven a la migracion 0.3.5 \u2192 0.4. La primera version 0.4 incluye una semilla unica; las siguientes usan el volumen /data vivo como unica fuente de verdad.',
de_DE:
'Proof of Work ist eine private App fuer Trainingsplanung und Trainingsprotokolle. Dieses StartOS-0.4-Paket behaelt den /data-Vertrag von 0.3.5 bei: die SQLite-Datenbank unter /data/app.db, der Trainingsverlauf, die Uebungsbibliothek und importierte Daten ueberstehen die Migration 0.3.5 \u2192 0.4. Das erste 0.4-Release liefert einen einmaligen Daten-Seed; spaetere Releases nutzen /data als einzige Wahrheit.',
pl_PL:
'Proof of Work to prywatna aplikacja do planowania i zapisywania treningow. Ten pakiet StartOS 0.4 zachowuje kontrakt /data z 0.3.5: baza SQLite w /data/app.db, historia treningow, biblioteka cwiczen i zaimportowane dane przetrwaja migracje 0.3.5 \u2192 0.4. Pierwsze wydanie 0.4 zawiera jednorazowy seed danych; kolejne wydania uzywaja /data jako jedynego zrodla prawdy.',
fr_FR:
"Proof of Work est une application privee de planification et de suivi d'entrainement. Ce paquet StartOS 0.4 conserve le contrat /data de la 0.3.5 : la base SQLite dans /data/app.db, l'historique, la bibliotheque d'exercices et les donnees importees survivent a la migration 0.3.5 \u2192 0.4. La premiere 0.4 inclut un seed unique ; les suivantes utilisent /data comme seule source de verite.",
}
export const alertInstall = {
en_US:
'This package bakes a one-time snapshot of your 0.3.5 /data volume into the image. On first boot, it will populate /data only if it is empty. After first boot, /data is the sole source of truth; do NOT Uninstall (that destroys /data).',
es_ES:
'Este paquete incluye una instantanea unica del volumen /data de 0.3.5. En el primer arranque poblara /data solo si esta vacio. Despues, /data es la unica fuente de verdad; NO desinstale (eso destruye /data).',
de_DE:
'Dieses Paket enthaelt eine einmalige Momentaufnahme Ihres 0.3.5 /data-Volumes. Beim ersten Start wird /data nur befuellt, wenn es leer ist. Danach ist /data die einzige Wahrheit; NICHT deinstallieren (das zerstoert /data).',
pl_PL:
'Ten pakiet zawiera jednorazowa migawke Twojego woluminu /data z 0.3.5. Przy pierwszym starcie wypelni /data tylko, jesli jest pusty. Pozniej /data jest jedynym zrodlem prawdy; NIE odinstalowuj (to zniszczy /data).',
fr_FR:
"Ce paquet integre un instantane unique de votre volume /data 0.3.5. Au premier demarrage, /data ne sera rempli que s'il est vide. Ensuite, /data est la seule source de verite ; NE PAS desinstaller (cela detruit /data).",
}
export const alertUpdate = {
en_US:
'Updating is safe: the entrypoint never overwrites an existing /data. NEVER choose Uninstall to troubleshoot \u2014 Uninstall destroys /data. If you need to disable the service, use Stop. If you need to roll back, run a StartOS Backup first.',
es_ES:
'Actualizar es seguro: el entrypoint nunca sobrescribe /data existente. NUNCA desinstale para solucionar problemas \u2014 Desinstalar destruye /data. Para desactivar el servicio use Detener. Para revertir, haga primero una copia de seguridad de StartOS.',
de_DE:
'Updaten ist sicher: der Entrypoint ueberschreibt niemals ein vorhandenes /data. NIE zur Fehlersuche deinstallieren \u2014 Deinstallieren zerstoert /data. Dienst deaktivieren: Stop. Zurueckrollen: erst StartOS-Backup.',
pl_PL:
'Aktualizacja jest bezpieczna: entrypoint nigdy nie nadpisze istniejacego /data. NIGDY nie odinstalowuj w ramach diagnostyki \u2014 Odinstalowanie niszczy /data. Aby wylaczyc usluge, uzyj Stop. Aby wycofac zmiany, najpierw wykonaj StartOS Backup.',
fr_FR:
"La mise a jour est sure : l'entrypoint ne remplace jamais un /data existant. NE PAS desinstaller pour depanner \u2014 Desinstaller detruit /data. Pour arreter le service, utilisez Stop. Pour revenir en arriere, faites d'abord une sauvegarde StartOS.",
}
+77
View File
@@ -0,0 +1,77 @@
import { setupManifest } from '@start9labs/start-sdk'
import { alertInstall, alertUpdate, long, short } from './i18n'
/**
* Proof of Work (proof-of-work) — StartOS 0.4 manifest.
*
* Contract invariants that MUST stay stable across all future releases so the
* 0.3.5 \u2192 0.4 data migration and subsequent upgrades remain safe:
* - package identifier = 'proof-of-work' (matches the 0.3.5 package)
* - volume name = 'main' (matches the 0.3.5 volume)
* - mount path = '/data' (matches the 0.3.5 mount)
*
* NOTE: do NOT write the literal two-character token "i"+"d" followed by ":"
* anywhere else in this file. s9pk.mk extracts PACKAGE_ID with a naive awk
* that matches on that substring and will concatenate multiple hits into a
* broken BASE_NAME (symptom: make warning "overriding commands for target
* 'proof-of-work'" and "No rule to make target .git/HEAD").
*/
export const manifest = setupManifest({
id: 'proof-of-work',
title: 'Proof of Work',
license: 'Proprietary',
packageRepo: 'https://github.com/your-org/proof-of-work-startos',
upstreamRepo: 'https://github.com/your-org/proof-of-work',
marketingUrl: 'https://github.com/your-org/proof-of-work',
donationUrl: null,
docsUrls: ['https://docs.start9.com/packaging/0.4.0.x/'],
description: { short, long },
volumes: ['main'],
images: {
main: {
source: {
dockerBuild: {
// Both `workdir` and `dockerfile` are resolved by start-cli
// relative to the PACKAGE directory (where this manifest
// lives), per the 0.4.0.x packaging docs. That means:
// workdir: '../..' -> Docker build context = repo root
// (two levels up from
// start9/0.4/). The
// Dockerfile's COPY paths such as
// 'proof-of-work/...' and
// 'start9/0.4/...' are
// resolved from there.
// dockerfile: './Dockerfile'
// -> <package-dir>/Dockerfile. That's
// where ours actually lives; the
// Dockerfile sitting OUTSIDE the
// workdir is fine -- Docker
// supports it via `-f <path>
// <context>` and buildkit resolves
// COPY paths against the context
// (workdir), not the Dockerfile's
// directory.
// DO NOT set dockerfile to './start9/0.4/Dockerfile'.
// That would resolve to
// start9/0.4/start9/0.4/Dockerfile
// (nonexistent), producing
// `resolve : lstat start9: no such file or directory`
// from buildkit before any layer runs.
workdir: '../..',
dockerfile: './Dockerfile',
},
},
// 0.4 beta is x86_64-only; expand to ['x86_64', 'aarch64'] post-beta.
arch: ['x86_64'],
},
},
alerts: {
install: alertInstall,
update: alertUpdate,
uninstall: null,
restore: null,
start: null,
stop: null,
},
dependencies: {},
})
+7
View File
@@ -0,0 +1,7 @@
import { StartSdk } from '@start9labs/start-sdk'
import { manifest } from './manifest'
/**
* Plumbing. DO NOT EDIT.
*/
export const sdk = StartSdk.of().withManifest(manifest).build(true)
+19
View File
@@ -0,0 +1,19 @@
/**
* Shared constants for the Proof of Work StartOS 0.4 package.
*
* Keep these in lockstep with the manifest (`./manifest/index.ts`) and with
* the Dockerfile environment variables. Changing any of these is effectively
* a breaking change for the on-disk contract with the `/data` volume.
*/
export const PACKAGE_ID = 'proof-of-work'
export const PACKAGE_TITLE = 'Proof of Work'
export const IMAGE_ID = 'main'
export const VOLUME_ID = 'main'
export const DATA_MOUNT_PATH = '/data'
/** Internal HTTP port the Next.js standalone server listens on. */
export const uiPort = 3000
/** Path the health route is mounted at inside the app. */
export const HEALTH_PATH = '/api/health'
+18
View File
@@ -0,0 +1,18 @@
import { VersionGraph } from '@start9labs/start-sdk'
import { v_1_0_0_1 } from './v1.0.0.1'
/**
* Version graph for the `proof-of-work` package.
*
* v1.0.0:1 — initial release, seeded cutover from the legacy `workout-log`
* package. No prior version to upgrade from.
*
* StartOS picks `current` as the install target; `other` lists every node
* that can upgrade into `current`. Fresh sideloads land directly on
* `current`. Once we ship the post-cutover cleanup release, it goes here as
* the new `current` and v1.0.0:1 moves into `other`.
*/
export const versionGraph = VersionGraph.of({
current: v_1_0_0_1,
other: [],
})
+34
View File
@@ -0,0 +1,34 @@
import { IMPOSSIBLE, VersionInfo } from '@start9labs/start-sdk'
/**
* v1.0.0:1 — initial Proof of Work release.
*
* Upstream version: 1.0.0
* Wrapper rev: 1
*
* This is a one-shot "seeded cutover" release for users migrating from the
* old `workout-log` StartOS package. The Docker image bakes in a snapshot of
* the maintainer's live /data volume under /app/seed/data; the entrypoint
* copies that snapshot into the new StartOS-managed /data volume only on a
* truly-fresh first boot (both /data/app.db missing AND /data/.seeded
* absent). Every subsequent boot leaves /data untouched.
*
* Because StartOS treats `proof-of-work` as a brand new service (different
* package id from `workout-log`), the old install stays running until the
* operator confirms the cutover and stops it manually. There is no
* downgrade path; `down` is IMPOSSIBLE.
*
* The post-cutover cleanup release (v1.0.0:2) will strip the baked seed and
* the seed-copy branch from docker_entrypoint.sh.
*/
export const v_1_0_0_1 = VersionInfo.of({
version: '1.0.0:1',
releaseNotes: {
en_US:
'Initial Proof of Work release. Replaces the legacy `workout-log` package with multi-user support and a curated exercise library shared across all users on the instance. Bakes a one-time seed of /data into the image and copies it into the new volume only on truly-fresh first boot, so an operator migrating from `workout-log` keeps every workout, exercise, and preference.',
},
migrations: {
up: async () => {},
down: IMPOSSIBLE,
},
})
+11
View File
@@ -0,0 +1,11 @@
{
"include": ["startos/**/*.ts", "node_modules/**/startos"],
"compilerOptions": {
"target": "ES2018",
"module": "CommonJS",
"moduleResolution": "node",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true
}
}
-235
View File
@@ -1,235 +0,0 @@
# Start9 Packaging Log: Workout Log (from example to working 0.3.5 wrapper)
This file records exactly what was adapted from `start9-example-packaging` to package `workout-planner` for a Start9 server on StartOS `0.3.5` (Raspberry Pi / ARM64).
It is written as reusable documentation so you can repeat this process for future apps.
## 0) Goal and constraints
- Target now: StartOS `0.3.5` on Raspberry Pi.
- Future target: StartOS `0.4.0` when stable.
- Priority: package should work now, while keeping data layout and wrapper structure easy to migrate later.
## 1) What was reviewed first
Reviewed the existing example wrapper in `start9-example-packaging/0.3.5`:
- `manifest.yaml`
- `Makefile`
- `Dockerfile`
- `docker_entrypoint.sh`
- `healthcheck.sh`
- `instructions.md`
- `README.md`
- `DEPLOY_035.md`
Then reviewed the app in `workout-planner`:
- App Docker build/runtime behavior (`workout-planner/Dockerfile`)
- DB shape + config (`prisma/schema.prisma`, `DATABASE_URL` usage)
- Health endpoint (`/api/health`)
- Seed/default user (`prisma/seed.ts`)
## 2) New Start9 wrapper scaffold created
Created a **new** folder (separate from your example):
- `start9/0.3.5/`
- `start9/0.4/` (planning placeholder)
Files created in `start9/0.3.5`:
- `manifest.yaml`
- `Dockerfile`
- `docker_entrypoint.sh`
- `healthcheck.sh`
- `Makefile`
- `instructions.md`
- `README.md`
- `DEPLOY_035.md`
- `LICENSE`
- `icon.png` (copied from app icon assets)
Also created:
- `start9/0.4/README.md` (migration intent notes)
## 3) Key packaging design decisions
### 3.1 Keep persistent data in `/data`
To make upgrades/migrations safer, wrapper mounts Start9 volume at `/data` and stores SQLite DB at:
- `/data/app.db`
This is the most important continuity contract for migration to a future wrapper.
### 3.2 Keep runtime app files out of persistent volume
App code/binaries stay in image layers; only state goes in `/data`. This prevents app updates from overwriting user data.
### 3.3 Health checks use app endpoint
Wrapper health check calls:
- `http://127.0.0.1:${PORT}/api/health`
This checks both server and DB connectivity (as implemented by your app).
### 3.4 Backup/restore copies whole `/data`
Manifest backup/restore actions copy `/data` <-> `/backup` to align with Start9 expectations and keep DB safe.
### 3.5 Future 0.4.0 migration posture
Added explicit notes to preserve:
- package id (`workout-log`) where compatible
- DB path contract (`/data/app.db`)
- backup semantics
## 4) Runtime wiring added for first boot
In `docker_entrypoint.sh`:
- Ensures `/data` exists.
- Uses `/data/app.db` as `DATABASE_URL` target.
- If `/data/app.db` does not exist on first run, copies a seeded template DB from image (`/app/prisma/data/app.db`).
- Starts app with `node /app/server.js`.
Why this matters:
- First launch has a ready DB + default user.
- Subsequent restarts/upgrades keep existing `/data/app.db` untouched.
## 5) Build issues discovered and fixes applied
## Issue A: Prisma/OpenSSL runtime failure on ARM musl
Observed error during ARM container validation:
- Prisma engine expected OpenSSL compatibility; DB access failed.
Fix applied in wrapper `Dockerfile`:
- Install `openssl` in **builder** stage.
- Install `openssl` in **runner** stage.
Result:
- Prisma client loads correctly at runtime.
## Issue B: First-run DB missing tables (`main.User` does not exist)
Root cause:
- Repo `.dockerignore` excludes local `.db` files, so no seeded DB was copied from source tree.
Fix applied in wrapper `Dockerfile` build stage:
1. Generate Prisma client.
2. Create temporary DB: `DATABASE_URL=file:/tmp-seed/app.db npx prisma db push --skip-generate`
3. Seed it: `DATABASE_URL=file:/tmp-seed/app.db npm run db:seed`
4. Copy seeded DB into image: `/app/prisma/data/app.db`
Result:
- First boot can copy seeded DB into `/data/app.db`.
- Health endpoint reports DB connected.
## 6) Validation steps run
Successfully validated:
1. ARM image build from wrapper:
- `make -C start9/0.3.5 image-arm`
2. Local smoke run from built image tar:
- `docker load -i start9/0.3.5/image.tar`
- run container and query `/api/health`
Final smoke result:
- HTTP `200`
- JSON contained `status: ok` and `database: connected`
## 7) Why `start-sdk pack` failed on this machine
`start-sdk pack` failed with:
- `fatal: not a git repository (or any of the parent directories): .git`
Meaning:
- `start-sdk` expects to run from inside a Git repository so it can compute metadata (commit/hash).
This is unrelated to your app logic; it is a packaging environment requirement.
## 8) What “Step 1” means (plain English)
When I said “initialize under git,” I meant:
- The folder where you run `start-sdk pack` must be inside a Git repo.
If your `Workout-log` folder is just a normal folder today, do this once:
```bash
cd /Users/macpro/Projects/Workout-log
git init
git add .
git commit -m "Initial commit for Start9 packaging"
```
After that, this should work:
```bash
make -C start9/0.3.5 package
```
You do **not** need your local dev server on port `3000` running for packaging. Packaging builds a Docker image and `.s9pk` artifact separately.
## 9) Install flow on StartOS 0.3.5
1. Build package:
```bash
cd /Users/macpro/Projects/Workout-log
make -C start9/0.3.5 package
```
2. In StartOS UI (0.3.5), sideload:
- `start9/0.3.5/workout-log.s9pk`
3. Install + start service.
4. Open service UI and login:
- `admin@local` / `workout123`
5. Change password immediately.
6. Run a manual backup.
## 10) Reusable checklist for your next app
Use this sequence next time:
1. Copy known-good wrapper structure (`manifest`, `Dockerfile`, `entrypoint`, `healthcheck`, docs, makefile).
2. Define persistent data contract first (`/data/...`).
3. Ensure first boot initializes DB/schema (migration or seeded template).
4. Verify health endpoint checks both app + DB.
5. Build ARM image and smoke-test locally before Start9 sideload.
6. Ensure repo is a Git repo before `start-sdk pack`.
7. Document migration invariants for future StartOS versions (ID, DB path, backup format).
## 11) Files to edit before publishing/distributing
In `start9/0.3.5/manifest.yaml`, replace placeholder values:
- `wrapper-repo`
- `upstream-repo`
- `support-site`
- `marketing-site`
- `license` (if you choose MIT or another license)