Compare commits

...
Author SHA1 Message Date
Tanish Manku b9ee643bf6 Stop the bootstrap scripts claiming the dev webapp targets the local backend
It never has: setDevelopment() is commented out in main.tsx, so the dev server
falls through to its /v1 proxy at api.storyteller.ai. Both bootstrap epilogues
and the seed script now say so and print the VITE_DEV_API_HOST needed to
actually reach the local backend.
2026-08-03 17:24:39 -07:00
Tanish Manku 07e9a35044 Add portable local MySQL/Redis bootstrap scripts for backend dev
Brings up a fully local storyteller-web: portable MySQL 8.4 and Redis under a
gitignored .devstack/ (plain user processes -- no admin, no services, no WSL,
no Docker), all 281 migrations, role/badge seeds, and a demo login with
credits. Windows and Linux variants, plus a doctor that reports what is and
isn't healthy.

Scripts and docs only. Nothing here is compiled into any binary, and no
application behaviour changes: the backend it starts is the one that ships.

Integrations that cannot be provided locally -- object storage, generation
providers, email -- get placeholder credentials, so those endpoints fail when
exercised. Swap in a real dev credential for the one you are working on. The
doc records exactly which areas that leaves working.

An earlier revision closed those gaps with development-only behaviour inside
storyteller-web (DEV_FAKE_GENERATION, a filesystem bucket mode, a CDN_BASE_URL
override). That is deliberately not here: a switch that exists in the shipped
binary can be flipped. Frontend work that wants a populated, working stack
should use frontend/apps/fake-storyteller-web instead.
2026-08-03 14:41:14 -07:00
15 changed files with 2186 additions and 0 deletions
+5
View File
@@ -1,3 +1,8 @@
# Shell scripts and dotenv config files are executed/parsed on Linux (WSL2, CI)
# even when the repo is cloned on Windows — they must stay LF in the worktree.
*.sh text eol=lf
*.env text eol=lf
# Ignore certain code for Github language statistics
# https://github.com/github/linguist/blob/master/docs/overrides.md
old_code/trumpet-trumped.com/dictionary/cmudict-0.7b linguist-vendored
+3
View File
@@ -104,3 +104,6 @@ windows/sqlite/sqlite3.lib
crates/desktop/src-tauri/*.dll
windows_dlls/
# Portable local dev stack (script/bootstrap) - MySQL/Redis binaries, data, logs
.devstack/
+293
View File
@@ -0,0 +1,293 @@
# Local Dev Stack — Requirements & Bootstrap
How to run `storyteller-web` on your own machine against a local MySQL and
Redis — no shared dev database, no Docker, no admin rights. This is for **work
on the backend itself**.
> **Working on the frontend?** You probably want
> `frontend/apps/fake-storyteller-web` instead — an in-memory stand-in for this
> API that needs no database, no build, and no setup. Everything below is
> unnecessary for webapp work. Come back here when you need the real server.
These scripts install and supervise infrastructure. They change no application
behaviour, and nothing in them is compiled into any binary: the backend they
start is the same one that ships. Integrations they cannot provide locally
(object storage, generation providers, email) get placeholder credentials, so
the endpoints that use them fail when exercised. Swap in a real dev credential
for whichever one you're actually working on.
The automated path on **Windows** (primary platform, native — no WSL, no
Docker, no admin rights for the stack itself):
```powershell
.\script\bootstrap\windows\bootstrap_dev_stack.ps1 # one-time setup, idempotent, re-run anytime
.\script\bootstrap\windows\run_backend_dev.ps1 # terminal 1: API on http://localhost:12345
.\script\bootstrap\windows\seed_demo_user.ps1 # terminal 2: create demo login (localdev1 / localdev1pass)
$env:VITE_DEV_API_HOST = "http://localhost:12345" # terminal 2: aim the webapp at your backend
cd frontend; npx nx dev artcraft-webapp # webapp on http://localhost:4201
.\script\bootstrap\windows\dev_stack_doctor.ps1 # health check, any time
.\script\bootstrap\windows\stop_dev_services.ps1 # stop MySQL/Redis when done
```
The equivalent on **Linux** (Ubuntu 22.04+ — native, WSL2, or CI):
```bash
./script/bootstrap/bootstrap_dev_stack.sh # one-time setup, idempotent, re-run anytime
./script/bootstrap/run_backend_dev.sh # terminal 1: API on http://localhost:12345
./script/bootstrap/seed_demo_user.sh # terminal 2: create demo login (localdev1 / localdev1pass)
cd frontend && VITE_DEV_API_HOST=http://localhost:12345 npx nx dev artcraft-webapp # terminal 2: webapp on :4201
./script/bootstrap/dev_stack_doctor.sh # health check, any time
```
This document records the underlying requirements so the scripts aren't a
black box, and corrects some stale claims in older docs.
## Supported platforms
| Platform | Status |
|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Windows 10/11 | Primary. `script/bootstrap/windows/*.ps1` run a **portable stack**: MySQL 8.4 (official zip) and Redis live under the gitignored `.devstack/` and run as plain user processes — no admin, no services, no WSL, no Docker. Build tools (cmake, perl, nasm, llvm) are offered via winget |
| Ubuntu 22.04+ | Supported by `script/bootstrap/*.sh` (native, WSL2, or CI runner/container); services via apt |
| macOS | Not covered by the scripts; follow `_docs/dev_setup_server.md` manually |
### Windows specifics
- Everything stateful lives in `.devstack/` (gitignored): `mysql/` (extracted
zip), `mysql-data/`, `redis/`, `logs/`, `pids/`, `downloads/` (cached zips),
`my.ini`. Delete the directory to fully reset; re-run bootstrap to rebuild.
- Pinned services: MySQL **8.4.11** from `cdn.mysql.com` (the 8.x series is
required; only some patch versions exist on the CDN) and Redis **5.0.14.1**
(tporadowski build — old but sufficient for the backend's keepalive/TTL/
rate-limit usage; override `REDIS_ZIP_URL` to use Memurai/Garnet instead).
- The portable MySQL root user has **no password** (`--initialize-insecure`) —
local dev only, bound to 127.0.0.1.
- diesel-cli is compiled against the portable MySQL's own client library
(`MYSQLCLIENT_LIB_DIR`/`MYSQLCLIENT_VERSION`); `libmysql.dll` must be on
`PATH` to *run* it — the scripts handle both.
- Compiling the backend needs cmake, Strawberry Perl, NASM, and LLVM/libclang
(the `wreq` HTTP client builds BoringSSL, and `aws-lc-sys`/`ring` need
NASM/clang on MSVC). The bootstrap checks and offers winget installs.
- The generated secrets file additionally sets `TEMP_DIR` (the upload temp-dir
helper defaults to `/tmp`, which doesn't exist on Windows).
## Requirements matrix
| Requirement | Version / detail | Needed for |
|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------|
| MySQL server | 8.x series (9.x unsupported by tooling) | Backend boot (eager connect) + migrations |
| Redis server | any recent | Backend boot (r2d2 pool connects eagerly) |
| Rust (stable) | no pinned MSRV; Docker builds use 1.93.0 | Building `storyteller-web`, diesel_cli |
| diesel_cli | `cargo install diesel_cli --no-default-features --features mysql,sqlite` | Running the 281 migrations |
| Node.js | 20+ (enforced by `script/common/frontend_preflight.sh`) | Frontend (Nx 21 / Vite 6) |
| npm | ships with Node; **pnpm must not be used** | Frontend install (`frontend/package-lock.json`) |
| System libs (apt) | build-essential, cmake, libssl-dev, libclang-dev, libfontconfig1-dev, fontconfig, perl, pkg-config, ffmpeg, libmysqlclient-dev, libsqlite3-dev, curl, jq | Compiling the workspace + diesel_cli; seeding scripts |
| Elasticsearch | optional (7.x) | Only 4 search endpoints; server boots without it |
| Docker | **not required** — no docker-compose exists | — |
Database identity (used consistently by docs, `.env`, and defaults):
database `storyteller`, user `storyteller`, password `password`, host
`localhost`.
## Backend: what `storyteller-web` needs to boot
Binary: `storyteller-web` in `crates/service/web/storyteller_web`
(binaries: `storyteller-web`, `docs` — Swagger UI on :8989, `docs-cli` — emits
`api.json`). Binds `0.0.0.0:12345` (`BIND_ADDRESS`), plain HTTP.
**Run it from the repo root** — the config search path and `includes/` paths
are cwd-relative.
### Config loading (corrected facts)
- Mode is selected by the `SERVER_ENVIRONMENT` **process env var only**
(default: Development). Setting it inside an env file does nothing — it is
read before any file loads.
- In Development mode, files load from
`crates/service/web/storyteller_web/config/` in this order:
`storyteller-web.common.env` → `storyteller-web.development.env` →
`storyteller-web.development-secrets.env` (gitignored; the bootstrap
generates it).
- **Precedence is first-value-wins** (dotenv never overrides an existing key):
real process env > common.env > development.env > development-secrets.env.
The secrets file therefore cannot override anything already set in
`development.env`.
- The repo-root `.env` is **NOT loaded by the server** (it passes
`ignore_legacy_dot_env_file: true`). Root `.env`'s `DATABASE_URL` feeds
diesel-cli/sqlx-cli only; the server reads **`MYSQL_URL`** (default
`mysql://storyteller:password@localhost/storyteller`).
### Hard requirements at boot
| Requirement | Behavior if missing |
|-------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------|
| MySQL reachable via `MYSQL_URL` | Boot fails (sqlx pool connects eagerly; the DB must exist and be migrated — health-check and ban-poller threads query immediately) |
| Redis reachable via `REDIS_0_URL` (default `redis://localhost/0`) | Boot fails (r2d2 pool connects eagerly) |
| ~30 `get_env_string_required` vars present | Boot fails on the first absent var |
Of the required-present vars, 19 are supplied by **no** checked-in file and
live in the generated secrets file: `ACCESS_KEY`, `SECRET_KEY`, `REGION_NAME`,
`W2L_PRIVATE_DOWNLOAD_BUCKET_NAME`, `W2L_PUBLIC_DOWNLOAD_BUCKET_NAME`,
`RESEND_API_KEY`, `FAL_API_KEY`, `GMICLOUD_API_KEY`, `GROK_API_KEY`,
`BEEBLE_API_KEY`, `OPENAI_API_KEY`, `WORLDLABS_API_KEY`,
`SEEDANCE2PRO_COOKIES`, `SEEDANCE2PRO_WHITELIST_COOKIES`,
`SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES`, `STRIPE_FAKEYOU_ACCOUNT_ID`,
`STRIPE_ARTCRAFT_ACCOUNT_ID`, `STRIPE_ARTCRAFT_SECRET_KEY`,
`STRIPE_ARTCRAFT_SECRET_WEBHOOK_KEY`.
**Placeholder values are safe at boot**: none of these services is contacted
at boot (clients are constructed offline; Elasticsearch, S3/R2, Stripe,
Resend, and Google-cert fetches are all lazy). But two earlier claims here
were wrong and are corrected:
- **The omni_gen endpoints call providers synchronously in the HTTP handler**
(`/v1/omni_gen/generate/{image,video}` — the path the webapp uses). With
placeholder keys the provider call fails and the submit returns HTTP 500
*after* billing the wallet (the image pipeline has no refund; video refunds
only Kinovi). Only the legacy polling providers (GmiCloud, Grok, Kinovi
orders, WorldLabs) are worker-driven.
- **Uploads fail hard with placeholder S3 creds** — every media upload endpoint
uploads to the bucket *before* inserting the DB row, and `rust-s3` is built
with `fail-on-err`.
### What does and doesn't work locally
| Area | Works with placeholder credentials? |
|----------------------------------------------|-------------------------------------|
| Login, sessions, account, profile | Yes |
| Wallets, credits, billing reads | Yes |
| Folders, tags, prompts, characters, projects | Yes |
| Media listing (rows) | Yes — but the images 404, see below |
| Media uploads | No — needs real R2/S3 credentials |
| Image/video/mesh/splat generation | No — needs a real provider key |
| Transactional email | No — needs a real Resend key |
| Elasticsearch-backed search | No — needs a local ES + reindex |
The rows-but-no-images case is the one that surprises people: `media_files`
rows carry a bucket path, and the URL the API builds from it points at the
shared R2 bucket, which does not have your local objects. Put real dev R2
credentials in the secrets file if you need media to render, or use
`fake-storyteller-web`, which serves its own bytes.
### Auth on localhost
- `POST /v1/login` takes `{"username_or_email": "...", "password": "..."}`;
success returns `{"success": true, "signed_session": "<jwt>"}` **and** a
`session` cookie.
- With the checked-in dev config (`COOKIE_DOMAIN=.jungle.horse`), the session
cookie is issued non-Secure and host-only (the Domain attribute is never
set), so plain-HTTP localhost login works as-is. The frontend additionally
stores `signed_session` in localStorage and sends it as a `session` header.
- Dev-mode CORS allows any `localhost`/`127.0.0.1` origin on any port, with
credentials.
- Health endpoint for scripts/CI: `GET /_status`.
### Build
- Compile with `SQLX_OFFLINE=true` (uses the checked-in `.sqlx/` cache; 483
queries). Without it, the sqlx macros open a **live DB connection at compile
time** using `DATABASE_URL` from the root `.env`. `SQLX_OFFLINE` has no
runtime effect — ignore older notes saying not to set it when running.
- First build takes several minutes; the crate has ~370 pre-existing warnings.
## Database: migrations and seed data
- 281 Diesel migrations in `_database/sql/migrations/` (`diesel.toml` at repo
root points there). Run from the repo root: `diesel migration run`.
`"Encountered unknown type for Mysql: enum"` warnings are harmless.
`migrations_squashed/` is documentation only — not executable.
- Seed data: `_database/sql/seed/sql/system_roles.sql` (roles `user`, `mod`,
`admin`) and `user_badges.sql`. **The `user` role is mandatory** — account
creation hardcodes `user_role_slug='user'`. The legacy wrapper
`bootstrap_inserts_roles_etc.sh` must run with cwd `_database/sql/` and is
not idempotent; the bootstrap script applies the same SQL files with
idempotency guards instead.
- Demo user: created through **`POST /v1/create_account`** (no captcha, no
invite code, no email-verification gate; password just needs 6+ chars). This
exercises the real signup path instead of hand-rolled SQL. Gotcha: usernames
like `demo`, `admin`, `test`, `dev`, `artcraft` are reserved
(`includes/binary_includes/usernames/reserved_usernames.txt`) — the default
demo user is `localdev1`. The Rust seeder
(`cargo run --bin dev-database-seed`) exists but hard-requires a
`.env-secrets` file and also seeds model weights, so the HTTP path is
preferred.
- Demo credits: signup creates **no wallet**, and the webapp gates the
generate button on wallet balance — so `seed_demo_user` also seeds an
`artcraft`-namespace wallet with `DEMO_CREDITS` (default 100,000) banked
credits, plus matching ledger rows. Idempotent: re-runs top the balance
back up to `DEMO_CREDITS` but never lower it. (Raw SQL, because the only
real credit-granting paths are Stripe webhooks and moderator tooling.)
- Demo gallery: **not seeded**. Media rows without reachable bytes render as
broken tiles, so seeding them locally would be worse than an empty library.
Use `fake-storyteller-web` for a populated one.
## Frontend: `artcraft-webapp`
- Nx workspace at repo-root `frontend/` (npm workspaces; `npm install` from
`frontend/`). The FontAwesome Pro registry token is committed in
`frontend/.npmrc` — no manual auth step.
- Serve: `npx nx dev artcraft-webapp` → `http://localhost:4201`. No lib
pre-build is needed in dev (libs resolve to TS source via the
`"development"` export condition); production `nx build` does need lib
builds.
- **By default the dev webapp targets production.** `src/main.tsx` points the
API host at the Vite dev origin, and Vite proxies `/v1` to
`https://api.storyteller.ai`. The adjacent `setDevelopment()` call — which
would point it at `localhost:12345` — is commented out. To aim the webapp at
your local backend, set `VITE_DEV_API_HOST=http://localhost:12345` before
`nx dev` (that override ships with the `fake-storyteller-web` change; until
it lands, uncomment `setDevelopment()` locally instead).
- Optional: `VITE_GOOGLE_CLIENT_ID` in `frontend/apps/artcraft-webapp/.env`
enables the Google SSO button; email/password login needs nothing.
- Other dev ports if you run more apps: website 4200, dashboard 4202 (separate
pnpm app, excluded from Nx), video-info-website 4250, genhub 4300, Tauri
frontend 5173.
## What still touches production (known, deliberate seams)
The bootstrap changes no runtime behaviour, so these remain open. Each is
closed by supplying a real credential, not by a code path:
| Seam | Today |
|---------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Media/CDN URLs in API responses | The compiled-in `cdn_link.rs` constants point at the public `pub-….r2.dev` bucket, so a locally-created row's media URL resolves to an object that isn't there |
| Media uploads | 500 with placeholder credentials; supply real dev R2/S3 keys to exercise them |
| Generation | 500 with placeholder keys, *after* the wallet is billed; supply a real key for the provider under test |
| Vite `/v1` proxy in webapp | Targets `https://api.storyteller.ai`, so a dev webapp with no host override reads and writes production |
| Elasticsearch-backed search | `search_featured` / `search_session` media files, `weights/search`, legacy `tts/search` fail without a local ES + `elasticsearch-cli` reindex (optional) |
## CI usage
The same bootstrap works on a fresh Ubuntu runner/container:
```bash
export MYSQL_ROOT_PASSWORD=root # GitHub Actions' preinstalled MySQL uses password auth
./script/bootstrap/bootstrap_dev_stack.sh --yes
./script/bootstrap/run_backend_dev.sh &
./script/bootstrap/seed_demo_user.sh # waits for /_status
./script/bootstrap/dev_stack_doctor.sh
```
`--yes` answers all prompts; `--skip-frontend` / `--skip-rust-build` /
`--skip-packages` slice the work for focused jobs.
## Verification checklist
- `./script/bootstrap/dev_stack_doctor.sh` exits 0 with no FAILs.
- `POST /v1/login` with the demo user returns `success: true` plus a
`signed_session` (done automatically by `seed_demo_user.sh`).
- `GET /_status` returns 200, and MySQL has the full migration set applied.
- With `VITE_DEV_API_HOST` pointed at `localhost:12345`, the webapp logs in as
`localdev1` and shows its credits in the top bar. The library is empty and
generation 500s — both expected, see the table above.
## Corrections to older docs
- `crates/service/web/storyteller_web/CLAUDE.md`: the repo-root `.env` is
*not* auto-loaded by the server, and `SQLX_OFFLINE=true` is fine (and
recommended) when running — it only affects compile-time query checking.
- `_docs/dev_setup_server.md`: `./script/dev_mysql_connect.sh` doesn't exist —
it's `./script/dev_database_connect.sh`.
- `diesel.toml`'s comment about needing an unreleased diesel CLI is stale —
any current `diesel_cli` works.
- `TESTING_INFRA_HANDOFF.md` (untracked): the server env var is `MYSQL_URL`,
not `DATABASE_URL`; migration count is 281; crate path is
`crates/service/web/storyteller_web`.
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env bash
# Bootstrap the full local dev stack (backend + frontend) on a fresh clone.
#
# Target platform: Ubuntu 22.04+ — native, WSL2, or a CI runner/container.
# (Windows users: the native PowerShell variant in script/bootstrap/windows/
# is the primary path — no WSL needed. macOS users: follow
# _docs/dev_setup_server.md manually. See _docs/dev_setup_local_stack.md.)
#
# Idempotent: safe to re-run at any time; each step detects work already done.
#
# Usage:
# ./script/bootstrap/bootstrap_dev_stack.sh [options]
#
# Options:
# --yes, -y Non-interactive: assume "yes" for prompts (CI mode)
# --skip-packages Don't apt-install system packages
# --skip-rust-build Don't build the storyteller-web binary
# --skip-frontend Don't run the frontend npm install
#
# After it succeeds:
# ./script/bootstrap/run_backend_dev.sh # start the API on :12345
# ./script/bootstrap/seed_demo_user.sh # create the demo login
# cd frontend && npx nx dev artcraft-webapp # webapp on :4201
set -euo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "${root_dir}/script/bootstrap/common.sh"
SECRETS_ENV_FILE="${root_dir}/crates/service/web/storyteller_web/config/storyteller-web.development-secrets.env"
# System packages: MySQL + Redis servers, the native libs needed to compile the
# Rust workspace (per build/service_cpu.Dockerfile) and diesel_cli
# (libmysqlclient-dev, libsqlite3-dev), plus curl/jq for the seeding scripts.
SYSTEM_PACKAGES=(
build-essential
ca-certificates
cmake
curl
ffmpeg
fontconfig
git
jq
libclang-dev
libfontconfig1-dev
libmysqlclient-dev
libsqlite3-dev
libssl-dev
mysql-server
perl
pkg-config
redis-server
)
ASSUME_YES=false
SKIP_PACKAGES=false
SKIP_RUST_BUILD=false
SKIP_FRONTEND=false
main() {
parse_args "$@"
ensure_supported_platform
install_system_packages
ensure_mysql_running
provision_mysql_database
ensure_redis_running
ensure_rust_toolchain
ensure_diesel_cli
run_migrations
seed_roles_and_badges
write_secrets_env_if_missing
build_backend
setup_frontend
print_next_steps
}
parse_args() {
for arg in "$@"; do
case "${arg}" in
--yes|-y) ASSUME_YES=true ;;
--skip-packages) SKIP_PACKAGES=true ;;
--skip-rust-build) SKIP_RUST_BUILD=true ;;
--skip-frontend) SKIP_FRONTEND=true ;;
--help|-h) sed -n '2,22p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) die "Unknown option: ${arg} (try --help)" ;;
esac
done
}
ensure_supported_platform() {
case "$(uname -s)" in
Linux) ;;
Darwin)
die "This script targets Ubuntu/WSL2. On macOS, follow _docs/dev_setup_server.md."
;;
*)
die "Unsupported platform '$(uname -s)'. On Windows use script/bootstrap/windows/bootstrap_dev_stack.ps1 (native), or WSL2 Ubuntu for this script."
;;
esac
if grep -qi microsoft /proc/version 2>/dev/null && [[ "${root_dir}" == /mnt/* ]]; then
warn "This checkout lives on the Windows filesystem (${root_dir})."
warn "Rust + npm builds are dramatically slower through /mnt. Consider cloning"
warn "into the WSL filesystem (e.g. ~/storyteller-rust) instead."
fi
}
install_system_packages() {
step "System packages"
if [ "${SKIP_PACKAGES}" = "true" ]; then
log "Skipped (--skip-packages)."
return
fi
local missing=()
for pkg in "${SYSTEM_PACKAGES[@]}"; do
dpkg -s "${pkg}" >/dev/null 2>&1 || missing+=("${pkg}")
done
if [ "${#missing[@]}" -eq 0 ]; then
log "All required apt packages are already installed."
return
fi
log "Missing packages: ${missing[*]}"
confirm "Install them with apt-get?" || die "Cannot continue without system packages (or pass --skip-packages if you manage them yourself)."
maybe_sudo env DEBIAN_FRONTEND=noninteractive apt-get update
maybe_sudo env DEBIAN_FRONTEND=noninteractive apt-get install -y "${missing[@]}"
}
ensure_mysql_running() {
step "MySQL server"
if mysqladmin ping --silent 2>/dev/null; then
log "MySQL is running."
else
log "Starting MySQL..."
maybe_sudo service mysql start 2>/dev/null \
|| maybe_sudo systemctl start mysql 2>/dev/null \
|| die "Could not start MySQL (tried 'service mysql start' and 'systemctl start mysql')."
mysqladmin ping --silent 2>/dev/null || sleep 3
mysqladmin ping --silent 2>/dev/null || die "MySQL did not come up."
fi
# The tooling supports the MySQL 8.x series; 9.x is known-unsupported
# (_docs/dev_setup_server.md).
local version
version="$(mysqld --version 2>/dev/null || true)"
case "${version}" in
*" 8."*) ;;
"") warn "Could not determine MySQL server version." ;;
*) warn "MySQL 8.x is expected; found: ${version}" ;;
esac
}
provision_mysql_database() {
step "MySQL database + user (${DEV_MYSQL_DB} / ${DEV_MYSQL_USER}@localhost)"
if mysql_app_reachable; then
log "Database and app user already provisioned."
return
fi
# Same DDL as _docs/dev_setup_server.md, made idempotent.
mysql_admin <<SQL
CREATE DATABASE IF NOT EXISTS ${DEV_MYSQL_DB};
CREATE USER IF NOT EXISTS '${DEV_MYSQL_USER}'@'localhost' IDENTIFIED BY '${DEV_MYSQL_PASSWORD}';
GRANT ALL PRIVILEGES ON ${DEV_MYSQL_DB}.* TO '${DEV_MYSQL_USER}'@'localhost';
FLUSH PRIVILEGES;
SQL
mysql_app_reachable || die "Provisioning ran but '${DEV_MYSQL_USER}' still cannot reach '${DEV_MYSQL_DB}'."
log "Provisioned."
}
ensure_redis_running() {
step "Redis server"
if redis_reachable; then
log "Redis is running."
return
fi
log "Starting Redis..."
maybe_sudo service redis-server start 2>/dev/null \
|| maybe_sudo systemctl start redis-server 2>/dev/null \
|| die "Could not start Redis."
redis_reachable || die "Redis did not come up."
}
ensure_rust_toolchain() {
step "Rust toolchain"
ensure_cargo_on_path
if command -v cargo >/dev/null 2>&1; then
log "Found $(rustc --version 2>/dev/null || echo 'cargo (rustc missing?)')."
return
fi
confirm "Rust is not installed. Install stable via rustup?" || die "Rust is required to build the backend."
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
ensure_cargo_on_path
command -v cargo >/dev/null 2>&1 || die "rustup install finished but cargo is not on PATH."
}
ensure_diesel_cli() {
step "diesel_cli (migration runner)"
if command -v diesel >/dev/null 2>&1; then
log "Found $(diesel --version)."
return
fi
# Same install command as _docs/dev_setup_server.md. Needs libmysqlclient-dev
# and libsqlite3-dev (installed above).
log "Installing diesel_cli (this compiles from source; a few minutes)..."
cargo install diesel_cli --no-default-features --features mysql,sqlite
}
run_migrations() {
step "Database migrations (_database/sql/migrations)"
# diesel-cli reads DATABASE_URL; the repo-root .env provides it, but export a
# default so this also works if that file is ever absent (e.g. sparse CI).
export DATABASE_URL="${DATABASE_URL:-mysql://${DEV_MYSQL_USER}:${DEV_MYSQL_PASSWORD}@${DEV_MYSQL_HOST}/${DEV_MYSQL_DB}}"
cd "${root_dir}"
# NB: "Encountered unknown type for Mysql: enum" warnings are harmless
# (_docs/dev_setup_server.md).
diesel migration run
log "Migrations up to date."
}
seed_roles_and_badges() {
step "Seed data (system roles + badges)"
# Same data as _database/sql/seed/bootstrap_inserts_roles_etc.sh, but guarded
# so re-runs don't hit duplicate-key errors. The 'user' role is mandatory:
# account creation hardcodes user_role_slug='user'.
local role_count badge_count
role_count="$(mysql_app -N -e "SELECT COUNT(*) FROM user_roles" 2>/dev/null || echo 0)"
if [ "${role_count}" -gt 0 ]; then
log "user_roles already seeded (${role_count} rows)."
else
mysql_app < "${root_dir}/_database/sql/seed/sql/system_roles.sql"
log "Inserted system roles (user, mod, admin)."
fi
badge_count="$(mysql_app -N -e "SELECT COUNT(*) FROM badges" 2>/dev/null || echo 0)"
if [ "${badge_count}" -gt 0 ]; then
log "badges already seeded (${badge_count} rows)."
else
mysql_app < "${root_dir}/_database/sql/seed/sql/user_badges.sql"
log "Inserted badges."
fi
}
write_secrets_env_if_missing() {
step "Development secrets file"
if [ -f "${SECRETS_ENV_FILE}" ]; then
log "Already exists: ${SECRETS_ENV_FILE} (leaving it untouched)."
return
fi
# Every env var below is read with get_env_string_required at server startup
# (build_dependencies.rs / setup_inference_providers.rs / setup_stripe_*.rs)
# but is NOT supplied by the checked-in config files. None of them is
# contacted at boot — placeholder values are enough to run the server; the
# endpoints that would use them fail lazily if exercised.
#
# NOTE: config loading is FIRST-VALUE-WINS (dotenv never overrides an
# already-set key), so values here cannot override anything already set in
# storyteller-web.development.env — only real process env vars can.
cat > "${SECRETS_ENV_FILE}" <<'EOF'
# Local-development secrets for storyteller-web. Generated by
# script/bootstrap/bootstrap_dev_stack.sh — gitignored; edit freely.
#
# Placeholders below satisfy required-at-boot checks without enabling any
# external integration. Replace individual values with real dev credentials
# only if you need that specific integration locally.
# R2/S3 object storage (clients are constructed offline)
ACCESS_KEY=dummy-local-dev
SECRET_KEY=dummy-local-dev
REGION_NAME=us-east-1
W2L_PRIVATE_DOWNLOAD_BUCKET_NAME=dummy-local-dev-private
W2L_PUBLIC_DOWNLOAD_BUCKET_NAME=dummy-local-dev-public
# Email (Resend)
RESEND_API_KEY=dummy-local-dev
# Generation providers. NB: the omni_gen endpoints (webapp image/video
# generation) contact providers SYNCHRONOUSLY in the request handler, so a real
# submit 500s until you put a real key in for the provider you are testing.
# The legacy polling providers are only contacted by worker binaries, which
# local dev does not run.
FAL_API_KEY=dummy-local-dev
GMICLOUD_API_KEY=dummy-local-dev
GROK_API_KEY=dummy-local-dev
BEEBLE_API_KEY=dummy-local-dev
OPENAI_API_KEY=dummy-local-dev
WORLDLABS_API_KEY=dummy-local-dev
SEEDANCE2PRO_COOKIES=dummy-local-dev
SEEDANCE2PRO_WHITELIST_COOKIES=dummy-local-dev
SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES=dummy-local-dev
# Stripe account ids (test-mode secret keys are already checked into
# storyteller-web.development.env; only the account ids are missing there)
STRIPE_FAKEYOU_ACCOUNT_ID=acct_dummylocaldev
STRIPE_ARTCRAFT_ACCOUNT_ID=acct_dummylocaldev
STRIPE_ARTCRAFT_SECRET_KEY=sk_test_dummylocaldev
STRIPE_ARTCRAFT_SECRET_WEBHOOK_KEY=whsec_dummylocaldev
EOF
log "Wrote ${SECRETS_ENV_FILE}"
}
build_backend() {
step "Backend build (storyteller-web)"
if [ "${SKIP_RUST_BUILD}" = "true" ]; then
log "Skipped (--skip-rust-build)."
return
fi
cd "${root_dir}"
# SQLX_OFFLINE only affects compile-time query verification (uses the
# checked-in .sqlx/ cache instead of a live DB); it has no runtime effect.
log "Building (first build takes several minutes)..."
SQLX_OFFLINE=true cargo build -p storyteller-web --bin storyteller-web
log "Built target/debug/storyteller-web."
}
setup_frontend() {
step "Frontend install (frontend/)"
if [ "${SKIP_FRONTEND}" = "true" ]; then
log "Skipped (--skip-frontend)."
return
fi
source "${root_dir}/script/common/frontend_preflight.sh"
frontend_preflight "${root_dir}/frontend"
pushd "${root_dir}/frontend" >/dev/null
frontend_npm_install
popd >/dev/null
log "Frontend dependencies installed."
}
print_next_steps() {
step "Done"
cat <<EOF
The dev stack is bootstrapped. Next steps:
1. Start the backend (binds ${DEV_BACKEND_URL}):
./script/bootstrap/run_backend_dev.sh
2. In another terminal, create the demo login (idempotent):
./script/bootstrap/seed_demo_user.sh
Credentials: ${DEMO_USERNAME} / ${DEMO_PASSWORD}
3. Start the webapp (binds http://localhost:4201). NB: by default the dev
webapp proxies to PRODUCTION — aim it at your backend explicitly:
cd frontend && VITE_DEV_API_HOST=${DEV_BACKEND_URL} npx nx dev artcraft-webapp
Health checks at any time:
./script/bootstrap/dev_stack_doctor.sh
EOF
}
main "$@"
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env bash
# Shared helpers for the dev-stack bootstrap scripts.
#
# Usage (from a script that has set ${root_dir}):
#
# source "${root_dir}/script/bootstrap/common.sh"
#
# See _docs/dev_setup_local_stack.md for the full picture.
# Canonical local-dev database identity. These match _docs/dev_setup_server.md,
# the diesel DATABASE_URL in the repo-root .env, and the server's MYSQL_URL
# default (crates/schema/config/shared_env_var_config/src/mysql.rs). Override
# via environment only if your local MySQL differs — most tooling assumes them.
DEV_MYSQL_DB="${DEV_MYSQL_DB:-storyteller}"
DEV_MYSQL_USER="${DEV_MYSQL_USER:-storyteller}"
DEV_MYSQL_PASSWORD="${DEV_MYSQL_PASSWORD:-password}"
DEV_MYSQL_HOST="${DEV_MYSQL_HOST:-localhost}"
# Where the backend listens (BIND_ADDRESS default in
# storyteller_web/src/startup/build_dependencies.rs).
DEV_BACKEND_URL="${DEV_BACKEND_URL:-http://localhost:12345}"
# Default demo account. The username must NOT be on the reserved-usernames list
# (includes/binary_includes/usernames/reserved_usernames.txt — "demo", "admin",
# "test", and "dev" are all reserved) and must match ^[A-Za-z0-9_\-]{3,16}$.
DEMO_USERNAME="${DEMO_USERNAME:-localdev1}"
DEMO_PASSWORD="${DEMO_PASSWORD:-localdev1pass}"
DEMO_EMAIL="${DEMO_EMAIL:-localdev1@example.com}"
# Banked credits the demo user's artcraft wallet is seeded/topped-up to.
# Generation submits are gated on wallet balance, so 0 credits means the
# generate button silently refuses in the webapp.
DEMO_CREDITS="${DEMO_CREDITS:-100000}"
log() { echo "[bootstrap] $*"; }
warn() { echo "[bootstrap] WARNING: $*" >&2; }
die() { echo "[bootstrap] ERROR: $*" >&2; exit 1; }
step() {
echo ""
echo "=== $* ==="
}
# sudo unless we already are root (CI containers often run as root without sudo).
maybe_sudo() {
if [ "$(id -u)" -eq 0 ]; then
"$@"
else
sudo "$@"
fi
}
confirm() {
local prompt="$1"
if [ "${ASSUME_YES:-false}" = "true" ]; then
return 0
fi
local reply
read -r -p "${prompt} [y/N] " reply
[[ "${reply}" =~ ^[Yy] ]]
}
# Generate a token in the backend's format: {prefix}{crockford-lower entropy}
# padded to the same TOTAL length the Rust generators use (tokens crate,
# impl_crockford_generator!). Alphabet excludes i/l/o/u like Crockford base32.
new_dev_token() {
local prefix="$1" total_length="$2"
local charset="0123456789abcdefghjkmnpqrstvwxyz"
local count=$((total_length - ${#prefix}))
local entropy=""
local i
for ((i = 0; i < count; i++)); do
entropy+="${charset:RANDOM%32:1}"
done
echo "${prefix}${entropy}"
}
# Run a statement (or stdin) as the storyteller app user against the dev DB.
# MYSQL_PWD keeps the password off the process list.
mysql_app() {
MYSQL_PWD="${DEV_MYSQL_PASSWORD}" mysql \
-u "${DEV_MYSQL_USER}" -h "${DEV_MYSQL_HOST}" -D "${DEV_MYSQL_DB}" "$@"
}
# Run a statement as an administrator, for provisioning the DB and user.
# On a fresh Ubuntu/WSL install, root uses auth_socket, so `sudo mysql` works.
# On GitHub Actions runners the preinstalled MySQL root password is "root" —
# export MYSQL_ROOT_PASSWORD to use password auth instead.
mysql_admin() {
if [ -n "${MYSQL_ROOT_PASSWORD:-}" ]; then
MYSQL_PWD="${MYSQL_ROOT_PASSWORD}" mysql -u root -h "${DEV_MYSQL_HOST}" "$@"
else
maybe_sudo mysql "$@"
fi
}
mysql_app_reachable() {
mysql_app -e "SELECT 1" >/dev/null 2>&1
}
redis_reachable() {
redis-cli ping 2>/dev/null | grep -q PONG
}
backend_reachable() {
curl -sf -o /dev/null --max-time 3 "${DEV_BACKEND_URL}/_status"
}
# Make ~/.cargo/bin visible when rustup was installed but the shell profile
# hasn't been re-sourced (fresh installs, CI).
ensure_cargo_on_path() {
if ! command -v cargo >/dev/null 2>&1 && [ -x "${HOME}/.cargo/bin/cargo" ]; then
export PATH="${HOME}/.cargo/bin:${PATH}"
fi
}
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env bash
# Diagnose the local dev stack: checks every prerequisite the bootstrap sets up
# and reports PASS / WARN / FAIL per item.
#
# FAIL = the stack cannot work until fixed (exit code 1).
# WARN = optional or run-time-only (e.g. backend not currently running).
set -uo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "${root_dir}/script/bootstrap/common.sh"
SECRETS_ENV_FILE="${root_dir}/crates/service/web/storyteller_web/config/storyteller-web.development-secrets.env"
failures=0
warnings=0
pass() { printf ' PASS %s\n' "$1"; }
fail() { printf ' FAIL %s\n' "$1"; failures=$((failures + 1)); }
warn_check() { printf ' WARN %s\n' "$1"; warnings=$((warnings + 1)); }
echo "Dev stack doctor (${root_dir})"
echo ""
echo "--- Toolchains ---"
ensure_cargo_on_path
if command -v cargo >/dev/null 2>&1; then
pass "rust: $(rustc --version 2>/dev/null | head -1)"
else
fail "rust: cargo not on PATH (bootstrap installs via rustup)"
fi
if command -v diesel >/dev/null 2>&1; then
pass "diesel_cli: $(diesel --version 2>/dev/null | head -1)"
else
fail "diesel_cli: not installed (cargo install diesel_cli --no-default-features --features mysql,sqlite)"
fi
if command -v node >/dev/null 2>&1; then
node_major="$(node --version | sed -E 's/^v([0-9]+).*/\1/')"
if [ "${node_major}" -ge 20 ]; then
pass "node: $(node --version) (>= 20 required by Nx 21 / Vite 6)"
else
fail "node: $(node --version) is too old — Node 20+ required"
fi
else
fail "node: not installed (Node 20+ required for the frontend)"
fi
echo ""
echo "--- Services ---"
if mysql_app_reachable; then
pass "mysql: reachable as '${DEV_MYSQL_USER}' on database '${DEV_MYSQL_DB}'"
migration_dirs="$(find "${root_dir}/_database/sql/migrations" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
applied="$(mysql_app -N -e "SELECT COUNT(*) FROM __diesel_schema_migrations" 2>/dev/null || echo 0)"
if [ "${applied}" -ge "${migration_dirs}" ] && [ "${applied}" -gt 0 ]; then
pass "migrations: ${applied} applied (${migration_dirs} in _database/sql/migrations)"
elif [ "${applied}" -gt 0 ]; then
fail "migrations: only ${applied}/${migration_dirs} applied — run 'diesel migration run'"
else
fail "migrations: none applied — run 'diesel migration run'"
fi
role_count="$(mysql_app -N -e "SELECT COUNT(*) FROM user_roles" 2>/dev/null || echo 0)"
if [ "${role_count}" -ge 3 ]; then
pass "seed: user_roles has ${role_count} rows (user/mod/admin present)"
else
fail "seed: user_roles has ${role_count} rows — account creation needs the 'user' role (re-run bootstrap)"
fi
badge_count="$(mysql_app -N -e "SELECT COUNT(*) FROM badges" 2>/dev/null || echo 0)"
if [ "${badge_count}" -gt 0 ]; then
pass "seed: badges has ${badge_count} rows"
else
warn_check "seed: badges table is empty (cosmetic; re-run bootstrap to fill)"
fi
demo_count="$(mysql_app -N -e "SELECT COUNT(*) FROM users WHERE username='${DEMO_USERNAME}'" 2>/dev/null || echo 0)"
if [ "${demo_count}" -gt 0 ]; then
pass "demo user: '${DEMO_USERNAME}' exists"
credits="$(mysql_app -N -e "SELECT COALESCE(MAX(banked_credits + monthly_credits), 0) FROM wallets WHERE wallet_namespace='artcraft' AND owner_user_token=(SELECT token FROM users WHERE username='${DEMO_USERNAME}')" 2>/dev/null || echo 0)"
if [ "${credits}" -gt 0 ]; then
pass "demo wallet: '${DEMO_USERNAME}' has ${credits} credits"
else
warn_check "demo wallet: '${DEMO_USERNAME}' has no credits — generation flows are blocked (re-run seed_demo_user.sh)"
fi
else
warn_check "demo user: '${DEMO_USERNAME}' not created yet (./script/bootstrap/seed_demo_user.sh, backend must be running)"
fi
else
fail "mysql: cannot connect as '${DEV_MYSQL_USER}'@'${DEV_MYSQL_HOST}' to '${DEV_MYSQL_DB}' (is MySQL running? run bootstrap)"
fi
if redis_reachable; then
pass "redis: PONG"
else
fail "redis: not reachable on localhost (the backend's r2d2 pool connects eagerly at boot)"
fi
echo ""
echo "--- Backend ---"
if [ -f "${SECRETS_ENV_FILE}" ]; then
pass "secrets env: ${SECRETS_ENV_FILE#"${root_dir}"/} exists"
else
fail "secrets env: missing — the server aborts at boot on ~19 required vars (re-run bootstrap)"
fi
if [ -x "${root_dir}/target/debug/storyteller-web" ] || [ -x "${root_dir}/target/release/storyteller-web" ]; then
pass "binary: storyteller-web is built"
else
warn_check "binary: storyteller-web not built yet (bootstrap builds it; cargo run will build on demand)"
fi
if backend_reachable; then
pass "server: ${DEV_BACKEND_URL}/_status responds"
else
warn_check "server: not running (./script/bootstrap/run_backend_dev.sh)"
fi
echo ""
echo "--- Frontend ---"
if [ -d "${root_dir}/frontend/node_modules" ]; then
pass "frontend: node_modules present"
else
warn_check "frontend: node_modules missing (cd frontend && npm install)"
fi
if [ -e "${root_dir}/frontend/pnpm-lock.yaml" ] || [ -d "${root_dir}/frontend/node_modules/.pnpm" ]; then
fail "frontend: stale pnpm artifacts detected (see frontend/README.md — the workspace uses npm)"
else
pass "frontend: no stale pnpm artifacts"
fi
echo ""
echo "Summary: ${failures} failure(s), ${warnings} warning(s)."
if [ "${failures}" -gt 0 ]; then
echo "Fix failures with: ./script/bootstrap/bootstrap_dev_stack.sh (safe to re-run)"
exit 1
fi
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Run the storyteller-web backend for local development (binds 0.0.0.0:12345).
#
# Must run from a bootstrapped environment (./script/bootstrap/bootstrap_dev_stack.sh).
# Always runs from the repo root: the server's config search path
# (crates/service/web/storyteller_web/config/*.env) and its includes/ defaults
# are cwd-relative.
set -euo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "${root_dir}/script/bootstrap/common.sh"
SECRETS_ENV_FILE="${root_dir}/crates/service/web/storyteller_web/config/storyteller-web.development-secrets.env"
mysql_app_reachable || die "MySQL is not reachable as '${DEV_MYSQL_USER}'. Run ./script/bootstrap/bootstrap_dev_stack.sh first."
redis_reachable || die "Redis is not reachable. Run ./script/bootstrap/bootstrap_dev_stack.sh first."
[ -f "${SECRETS_ENV_FILE}" ] || die "Missing ${SECRETS_ENV_FILE}. Run ./script/bootstrap/bootstrap_dev_stack.sh first."
ensure_cargo_on_path
cd "${root_dir}"
# SERVER_ENVIRONMENT defaults to Development when unset (bootstrap.rs), and the
# dev config files are picked up from the search path automatically.
# SQLX_OFFLINE only affects compile-time query checking, never runtime.
log "Starting storyteller-web on ${DEV_BACKEND_URL} (Ctrl-C to stop)..."
exec env SQLX_OFFLINE=true cargo run -p storyteller-web --bin storyteller-web
+127
View File
@@ -0,0 +1,127 @@
#!/usr/bin/env bash
# Create (or verify) the local demo user via the running backend, then verify
# that login works. Idempotent: an already-existing user is a success.
#
# Requires the backend to be up (./script/bootstrap/run_backend_dev.sh) — this
# script waits up to --wait-seconds (default 60) for it.
#
# The account is created through POST /v1/create_account rather than raw SQL so
# it exercises the real signup path (bcrypt hash, session, firehose row) and
# stays correct if the users schema evolves.
#
# Override the credentials via DEMO_USERNAME / DEMO_PASSWORD / DEMO_EMAIL.
# Username rules: 3-16 chars of [A-Za-z0-9_-], not on the reserved list
# (includes/binary_includes/usernames/reserved_usernames.txt); password >= 6 chars.
set -euo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
source "${root_dir}/script/bootstrap/common.sh"
WAIT_SECONDS=60
for arg in "$@"; do
case "${arg}" in
--wait-seconds=*) WAIT_SECONDS="${arg#*=}" ;;
--help|-h) sed -n '2,15p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) die "Unknown option: ${arg} (try --help)" ;;
esac
done
command -v jq >/dev/null 2>&1 || die "jq is required (apt-get install jq)."
log "Waiting for backend at ${DEV_BACKEND_URL} (up to ${WAIT_SECONDS}s)..."
deadline=$((SECONDS + WAIT_SECONDS))
until backend_reachable; do
if [ "${SECONDS}" -ge "${deadline}" ]; then
die "Backend not reachable at ${DEV_BACKEND_URL}/_status. Start it with ./script/bootstrap/run_backend_dev.sh"
fi
sleep 2
done
log "Backend is up."
log "Creating account '${DEMO_USERNAME}'..."
create_response="$(curl -sS -X POST "${DEV_BACKEND_URL}/v1/create_account" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg u "${DEMO_USERNAME}" \
--arg p "${DEMO_PASSWORD}" \
--arg e "${DEMO_EMAIL}" \
'{username: $u, password: $p, password_confirmation: $p, email_address: $e}')")"
if [ "$(echo "${create_response}" | jq -r '.success // false')" = "true" ]; then
log "Account created."
else
error_code="$(echo "${create_response}" | jq -r '.error_type // .error_code // .error_code_str // empty')"
case "${error_code}" in
*[Uu]sername[Tt]aken*|*[Ee]mail[Tt]aken*)
log "Account already exists (${error_code}) — continuing."
;;
*)
die "Account creation failed: ${create_response}"
;;
esac
fi
log "Verifying login..."
login_response="$(curl -sS -X POST "${DEV_BACKEND_URL}/v1/login" \
-H "Content-Type: application/json" \
-d "$(jq -n \
--arg u "${DEMO_USERNAME}" \
--arg p "${DEMO_PASSWORD}" \
'{username_or_email: $u, password: $p}')")"
if [ "$(echo "${login_response}" | jq -r '.success // false')" != "true" ]; then
die "Login verification failed: ${login_response}"
fi
if [ -z "$(echo "${login_response}" | jq -r '.signed_session // empty')" ]; then
die "Login succeeded but returned no signed_session: ${login_response}"
fi
# --- Credits wallet ---------------------------------------------------------
# Generation submits deduct from the user's artcraft wallet and the webapp
# gates the generate button on the balance, so a user without credits cannot
# exercise any generation flow. Signup does not create a wallet; seed one.
# Raw SQL (not an endpoint): the only credit-granting paths are Stripe
# webhooks and moderator tooling, neither of which works locally.
log "Seeding credits wallet (${DEMO_CREDITS} banked credits)..."
user_token="$(mysql_app -N -e "SELECT token FROM users WHERE username = '${DEMO_USERNAME}' LIMIT 1")"
[ -n "${user_token}" ] || die "Could not find users row for '${DEMO_USERNAME}'."
wallet_row="$(mysql_app -N -e "SELECT token, banked_credits FROM wallets WHERE wallet_namespace = 'artcraft' AND owner_user_token = '${user_token}' LIMIT 1")"
if [ -z "${wallet_row}" ]; then
wallet_token="$(new_dev_token "wallet_" 32)"
create_ledger_token="$(new_dev_token "wle_" 32)"
credit_ledger_token="$(new_dev_token "wle_" 32)"
mysql_app -e "
INSERT INTO wallets SET token='${wallet_token}', wallet_namespace='artcraft', owner_user_token='${user_token}', banked_credits=${DEMO_CREDITS}, monthly_credits=0;
INSERT INTO wallet_ledger_entries SET token='${create_ledger_token}', wallet_token='${wallet_token}', entry_type='create', credits_delta=0, banked_credits_before=0, banked_credits_after=0, monthly_credits_before=0, monthly_credits_after=0;
INSERT INTO wallet_ledger_entries SET token='${credit_ledger_token}', wallet_token='${wallet_token}', entry_type='credit_banked', maybe_entity_ref='dev_bootstrap_seed', credits_delta=${DEMO_CREDITS}, banked_credits_before=0, banked_credits_after=${DEMO_CREDITS}, monthly_credits_before=0, monthly_credits_after=0;
" || die "Failed to insert the demo wallet."
log "Wallet created with ${DEMO_CREDITS} banked credits."
else
wallet_token="$(echo "${wallet_row}" | cut -f1)"
balance="$(echo "${wallet_row}" | cut -f2)"
if [ "${balance}" -lt "${DEMO_CREDITS}" ]; then
credit_ledger_token="$(new_dev_token "wle_" 32)"
delta=$((DEMO_CREDITS - balance))
mysql_app -e "
UPDATE wallets SET banked_credits=${DEMO_CREDITS}, version=version+1 WHERE token='${wallet_token}' LIMIT 1;
INSERT INTO wallet_ledger_entries SET token='${credit_ledger_token}', wallet_token='${wallet_token}', entry_type='credit_banked', maybe_entity_ref='dev_bootstrap_topup', credits_delta=${delta}, banked_credits_before=${balance}, banked_credits_after=${DEMO_CREDITS}, monthly_credits_before=0, monthly_credits_after=0;
" || die "Failed to top up the demo wallet."
log "Wallet topped up: ${balance} -> ${DEMO_CREDITS} banked credits."
else
log "Wallet already has ${balance} banked credits — leaving as-is."
fi
fi
# NB: no gallery media is seeded. A media_files row is only useful if the bytes
# behind it are reachable, and serving those locally needs real R2 credentials
# in the secrets env — rows on their own render as broken tiles. For a populated
# library with no backend at all, run frontend/apps/fake-storyteller-web.
log "Demo user is ready."
log " Username: ${DEMO_USERNAME}"
log " Password: ${DEMO_PASSWORD}"
log "Log in at the webapp /login page, or via curl:"
log " curl -c /tmp/cookies.txt -X POST ${DEV_BACKEND_URL}/v1/login -H 'Content-Type: application/json' -d '{\"username_or_email\":\"${DEMO_USERNAME}\",\"password\":\"${DEMO_PASSWORD}\"}'"
@@ -0,0 +1,473 @@
# Bootstrap the full local dev stack (backend + frontend) on native Windows.
#
# Everything service-shaped is PORTABLE and lives under the gitignored
# .devstack\ directory: MySQL 8.4 (official zip) and Redis run as plain user
# processes - no admin rights, no Windows services, no WSL, no Docker.
# Build tools (cmake, perl, nasm, llvm) are checked and offered via winget.
#
# Idempotent: safe to re-run at any time; each step detects work already done.
#
# Usage (from anywhere; paths are self-resolving):
# .\script\bootstrap\windows\bootstrap_dev_stack.ps1 [options]
#
# Options:
# -Yes Non-interactive: assume "yes" for prompts (CI mode)
# -SkipTools Don't check/install build tools via winget
# -SkipRustBuild Don't build the storyteller-web binary
# -SkipFrontend Don't run the frontend npm install
#
# After it succeeds:
# .\script\bootstrap\windows\run_backend_dev.ps1 # API on :12345
# .\script\bootstrap\windows\seed_demo_user.ps1 # demo login
# cd frontend; npx nx dev artcraft-webapp # webapp on :4201, but see
# # VITE_DEV_API_HOST below
param(
[switch]$Yes,
[switch]$SkipTools,
[switch]$SkipRustBuild,
[switch]$SkipFrontend
)
. (Join-Path $PSScriptRoot "common.ps1")
. (Join-Path $PSScriptRoot "start_dev_services.ps1")
$script:AssumeYes = [bool]$Yes
# Tools needed to compile the Rust workspace on Windows (wreq's BoringSSL build
# needs cmake + nasm + perl + libclang; see the workspace Cargo.toml notes).
# Each entry: name, exe to probe, winget id, dirs the installer typically uses
# (appended to this session's PATH after install).
$BuildTools = @(
@{ Name = "CMake"; Exe = "cmake"; WingetId = "Kitware.CMake"; PathDirs = @("$env:ProgramFiles\CMake\bin") },
@{ Name = "Strawberry Perl"; Exe = "perl"; WingetId = "StrawberryPerl.StrawberryPerl"; PathDirs = @("C:\Strawberry\perl\bin") },
@{ Name = "NASM"; Exe = "nasm"; WingetId = "NASM.NASM"; PathDirs = @("$env:ProgramFiles\NASM", "$env:LOCALAPPDATA\bin\NASM") },
@{ Name = "LLVM (libclang)"; Exe = "clang"; WingetId = "LLVM.LLVM"; PathDirs = @("$env:ProgramFiles\LLVM\bin") }
)
function Main {
Step "Preflight"
Ensure-CoreTools
if (-not $SkipTools) { Ensure-BuildTools } else { Write-Log "Build-tool check skipped (-SkipTools)." }
Step "Portable MySQL $MySqlVersion"
Install-PortableMySql
Start-DevMySql
Provision-MySqlDatabase
Step "Portable Redis"
Install-PortableRedis
Start-DevRedis
Step "diesel_cli (migration runner)"
Ensure-DieselCli
Step "Database migrations (_database/sql/migrations)"
Run-Migrations
Step "Seed data (system roles + badges)"
Seed-RolesAndBadges
Step "Development secrets file"
Write-SecretsEnvIfMissing
Step "Backend build (storyteller-web)"
Build-Backend
Step "Frontend install (frontend/)"
Setup-Frontend
Print-NextSteps
}
function Ensure-CoreTools {
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
$cargoBin = Join-Path $env:USERPROFILE ".cargo\bin"
Add-SessionPath @($cargoBin)
}
if (-not (Get-Command cargo -ErrorAction SilentlyContinue)) {
Die "Rust is not installed. Install via winget (winget install Rustlang.Rustup) or https://rustup.rs, then re-run."
}
Write-Log "rust: $(rustc --version)"
if (-not (Get-Command node -ErrorAction SilentlyContinue)) {
Die "Node.js is not installed. Install Node 20+ (winget install OpenJS.NodeJS.LTS), then re-run."
}
$nodeMajor = [int]((node --version) -replace '^v(\d+).*', '$1')
if ($nodeMajor -lt 20) {
Die "Node.js 20+ is required (found $(node --version)) - Nx 21 / Vite 6 requirement."
}
Write-Log "node: $(node --version)"
}
function Ensure-BuildTools {
# Tools installed by a previous run land in dirs this shell may not have on
# PATH yet - map them in before probing, so we don't re-invoke winget.
Add-BuildToolsToSession
$missing = @($BuildTools | Where-Object { -not (Get-Command $_.Exe -ErrorAction SilentlyContinue) })
if ($missing.Count -eq 0) {
Write-Log "All native build tools present (cmake, perl, nasm, clang)."
return
}
$names = ($missing | ForEach-Object { $_.Name }) -join ", "
Write-Log "Missing build tools: $names"
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
Die "winget is unavailable; install the tools manually: $names"
}
if (-not (Confirm-Or-Skip "Install them via winget? (installers may raise a UAC prompt)")) {
Die "Cannot build the backend without: $names (or re-run with -SkipTools if you manage them yourself)."
}
foreach ($tool in $missing) {
Write-Log "winget install $($tool.WingetId)..."
Invoke-Native "winget install --id $($tool.WingetId) -e --accept-source-agreements --accept-package-agreements --disable-interactivity"
if ($LASTEXITCODE -ne 0) {
Write-Warn2 "winget exited with $LASTEXITCODE for $($tool.Name); checking whether the tool landed anyway..."
}
Add-SessionPath $tool.PathDirs
if (-not (Get-Command $tool.Exe -ErrorAction SilentlyContinue)) {
Die "$($tool.Name) still not found after install. Open a new terminal (PATH refresh) and re-run, or install manually."
}
Write-Log "$($tool.Name) OK."
}
}
function Install-PortableMySql {
if (Test-Path (Join-Path $MySqlBaseDir "bin\mysqld.exe")) {
Write-Log "Portable MySQL already extracted."
} elseif ((Test-TcpPort 3306)) {
# Something else already serves 3306 (e.g. a system-wide MySQL install).
# Use it rather than fighting over the port; provisioning below needs root
# credentials via MYSQL_ROOT_PASSWORD if that server has a root password.
Write-Warn2 "Port 3306 is already in use by an existing MySQL server - using it instead of the portable install."
if (-not (Get-Command mysql -ErrorAction SilentlyContinue)) {
Die "A server owns port 3306 but no 'mysql' client is on PATH. Add the client or stop that server."
}
return
} else {
New-Item -ItemType Directory -Force $DownloadsDir | Out-Null
$zipPath = Join-Path $DownloadsDir "mysql-$MySqlVersion-winx64.zip"
if (-not (Test-Path $zipPath)) {
Write-Log "Downloading MySQL $MySqlVersion (~250 MB, official cdn.mysql.com)..."
Download-File $MySqlZipUrl $zipPath
}
Write-Log "Extracting..."
Expand-Archive -Path $zipPath -DestinationPath (Join-Path $DevStackDir "mysql") -Force
if (-not (Test-Path (Join-Path $MySqlBaseDir "bin\mysqld.exe"))) {
Die "Extraction finished but $MySqlBaseDir\bin\mysqld.exe is missing."
}
}
if (-not (Test-Path $MySqlIniPath)) {
# Minimal config; utf8mb4 collation comes from each migration's DDL.
# MySQL's ini parser escape-processes backslashes - use forward slashes,
# and quote the values in case the clone path contains spaces.
$baseForward = $MySqlBaseDir -replace '\\', '/'
$dataForward = $MySqlDataDir -replace '\\', '/'
$ini = @"
[mysqld]
basedir="$baseForward"
datadir="$dataForward"
port=3306
bind-address=127.0.0.1
"@
New-Item -ItemType Directory -Force $DevStackDir | Out-Null
Set-Content -Path $MySqlIniPath -Value $ini -Encoding ascii
}
if (-not (Test-Path (Join-Path $MySqlDataDir "mysql"))) {
Write-Log "Initializing MySQL data directory (root without password, local dev only)..."
$mysqld = Join-Path $MySqlBaseDir "bin\mysqld.exe"
Invoke-Native "`"$mysqld`" --defaults-file=`"$MySqlIniPath`" --initialize-insecure --console"
if ($LASTEXITCODE -ne 0) { Die "mysqld --initialize-insecure failed (exit $LASTEXITCODE). See output above." }
}
}
function Provision-MySqlDatabase {
if (Test-MySqlApp) {
Write-Log "Database '$DevMySqlDb' and user '$DevMySqlUser' already provisioned."
return
}
$mysql = Get-MySqlTool "mysql"
if (-not $mysql) { Die "mysql client not found (portable install missing and none on PATH)." }
# Portable install: root has no password. External server: honor MYSQL_ROOT_PASSWORD.
$prev = $env:MYSQL_PWD
if ($env:MYSQL_ROOT_PASSWORD) { $env:MYSQL_PWD = $env:MYSQL_ROOT_PASSWORD } else { $env:MYSQL_PWD = "" }
try {
# Same DDL as _docs/dev_setup_server.md, made idempotent. The extra
# '127.0.0.1' account covers hosts where reverse-DNS of 127.0.0.1 doesn't
# resolve to 'localhost'. Fed through a temp file + `source` so mysql's
# stderr can never become a terminating PowerShell error mid-provision.
$ddl = @"
CREATE DATABASE IF NOT EXISTS $DevMySqlDb;
CREATE USER IF NOT EXISTS '$DevMySqlUser'@'localhost' IDENTIFIED BY '$DevMySqlPassword';
CREATE USER IF NOT EXISTS '$DevMySqlUser'@'127.0.0.1' IDENTIFIED BY '$DevMySqlPassword';
GRANT ALL PRIVILEGES ON $DevMySqlDb.* TO '$DevMySqlUser'@'localhost';
GRANT ALL PRIVILEGES ON $DevMySqlDb.* TO '$DevMySqlUser'@'127.0.0.1';
FLUSH PRIVILEGES;
"@
$ddlFile = Join-Path $env:TEMP "artcraft_provision_ddl.sql"
Set-Content -Path $ddlFile -Value $ddl -Encoding ascii
$ddlForward = $ddlFile -replace '\\', '/'
$out = Invoke-Native "`"$mysql`" -u root -h 127.0.0.1 -e `"source $ddlForward`""
Remove-Item $ddlFile -Force -Confirm:$false -ErrorAction SilentlyContinue
if ($LASTEXITCODE -ne 0) {
if ($out) { Write-Host ($out -join "`n") }
Die "Provisioning failed. For an external MySQL server, set MYSQL_ROOT_PASSWORD and re-run."
}
} finally {
$env:MYSQL_PWD = $prev
}
if (-not (Test-MySqlApp)) { Die "Provisioning ran but '$DevMySqlUser' still cannot reach '$DevMySqlDb'." }
Write-Log "Provisioned."
}
function Install-PortableRedis {
if (Test-Path (Join-Path $RedisDir "redis-server.exe")) {
Write-Log "Portable Redis already extracted."
return
}
if (Test-TcpPort 6379) {
Write-Warn2 "Port 6379 is already served (existing Redis/Memurai?) - using it."
return
}
New-Item -ItemType Directory -Force $DownloadsDir | Out-Null
$zipPath = Join-Path $DownloadsDir "redis-win-x64.zip"
if (-not (Test-Path $zipPath)) {
Write-Log "Downloading Redis for Windows (~12 MB)..."
Download-File $RedisZipUrl $zipPath
}
Write-Log "Extracting..."
Expand-Archive -Path $zipPath -DestinationPath $RedisDir -Force
if (-not (Test-Path (Join-Path $RedisDir "redis-server.exe"))) {
Die "Extraction finished but $RedisDir\redis-server.exe is missing."
}
}
function Ensure-DieselCli {
Add-MySqlClientToSession
$needInstall = $true
$forceFlag = ""
if (Get-Command diesel -ErrorAction SilentlyContinue) {
# A diesel.exe built against a client lib that has since moved fails at
# load time - probe it rather than trusting its presence.
$version = Invoke-Native "diesel --version"
if ($LASTEXITCODE -eq 0) {
Write-Log "Found $version."
$needInstall = $false
} else {
Write-Warn2 "diesel is on PATH but broken (missing libmysql.dll?) - reinstalling."
$forceFlag = " --force"
}
}
if (-not $needInstall) { return }
# diesel_cli's mysql feature links against libmysqlclient. The portable MySQL
# ships its own client lib - point mysqlclient-sys at it instead of requiring
# vcpkg. sqlite is bundled so no system sqlite is needed.
if (-not (Test-Path (Join-Path $MySqlBaseDir "lib\libmysql.lib"))) {
Die "MySQL client lib not found under $MySqlBaseDir\lib (needed to compile diesel_cli)."
}
Write-Log "Installing diesel_cli (compiles from source; a few minutes)..."
$env:MYSQLCLIENT_LIB_DIR = Join-Path $MySqlBaseDir "lib"
$env:MYSQLCLIENT_VERSION = $MySqlVersion
Invoke-Native "cargo install diesel_cli --no-default-features --features mysql,sqlite-bundled$forceFlag"
if ($LASTEXITCODE -ne 0) { Die "cargo install diesel_cli failed (exit $LASTEXITCODE)." }
Write-Log "Installed $(Invoke-Native 'diesel --version')."
}
function Run-Migrations {
# diesel-cli reads DATABASE_URL (also provided by the repo-root .env, which
# diesel auto-loads from the cwd). diesel.exe needs libmysql.dll at runtime -
# Add-MySqlClientToSession put the portable lib dirs on PATH.
if (-not $env:DATABASE_URL) {
$env:DATABASE_URL = "mysql://${DevMySqlUser}:${DevMySqlPassword}@localhost/${DevMySqlDb}"
}
Push-Location $RootDir
try {
# NB: "Encountered unknown type for Mysql: enum" warnings are harmless.
Invoke-Native "diesel migration run"
if ($LASTEXITCODE -ne 0) { Die "diesel migration run failed (exit $LASTEXITCODE)." }
} finally {
Pop-Location
}
Write-Log "Migrations up to date."
}
function Seed-RolesAndBadges {
# Same data as _database/sql/seed/bootstrap_inserts_roles_etc.sh, but guarded
# so re-runs don't hit duplicate-key errors. The 'user' role is mandatory:
# account creation hardcodes user_role_slug='user'.
$roleCount = Get-MySqlCount "SELECT COUNT(*) FROM user_roles"
if ($roleCount -lt 0) { Die "Could not query user_roles - are migrations applied? (re-run bootstrap)" }
if ($roleCount -gt 0) {
Write-Log "user_roles already seeded ($roleCount rows)."
} else {
Invoke-MySqlApp -SourceFile (Join-Path $RootDir "_database\sql\seed\sql\system_roles.sql")
if ($LASTEXITCODE -ne 0) { Die "Seeding system_roles.sql failed." }
Write-Log "Inserted system roles (user, mod, admin)."
}
$badgeCount = Get-MySqlCount "SELECT COUNT(*) FROM badges"
if ($badgeCount -lt 0) { Die "Could not query badges - are migrations applied? (re-run bootstrap)" }
if ($badgeCount -gt 0) {
Write-Log "badges already seeded ($badgeCount rows)."
} else {
Invoke-MySqlApp -SourceFile (Join-Path $RootDir "_database\sql\seed\sql\user_badges.sql")
if ($LASTEXITCODE -ne 0) { Die "Seeding user_badges.sql failed." }
Write-Log "Inserted badges."
}
}
function Write-SecretsEnvIfMissing {
if (Test-Path $SecretsEnvFile) {
Write-Log "Already exists: $SecretsEnvFile (leaving it untouched)."
return
}
# Every var below is read with get_env_string_required at server startup but
# is NOT supplied by the checked-in config files. None is contacted at boot -
# placeholders are enough to run the server.
#
# NOTE: config loading is FIRST-VALUE-WINS (dotenv never overrides a set
# key), so values here cannot override storyteller-web.development.env.
#
# TEMP_DIR: the upload temp-dir helper defaults to /tmp, which doesn't exist
# on Windows - point it at the user temp dir (forward slashes work fine).
$tempDirForward = $env:TEMP -replace '\\', '/'
$content = @"
# Local-development secrets for storyteller-web. Generated by
# script/bootstrap/windows/bootstrap_dev_stack.ps1 - gitignored; edit freely.
#
# Placeholders below satisfy required-at-boot checks without enabling any
# external integration. Replace individual values with real dev credentials
# only if you need that specific integration locally.
# Windows: scoped upload temp dirs (code default is /tmp)
TEMP_DIR=$tempDirForward
# R2/S3 object storage (clients are constructed offline)
ACCESS_KEY=dummy-local-dev
SECRET_KEY=dummy-local-dev
REGION_NAME=us-east-1
W2L_PRIVATE_DOWNLOAD_BUCKET_NAME=dummy-local-dev-private
W2L_PUBLIC_DOWNLOAD_BUCKET_NAME=dummy-local-dev-public
# Email (Resend)
RESEND_API_KEY=dummy-local-dev
# Generation providers. NB: the omni_gen endpoints (webapp image/video
# generation) contact providers SYNCHRONOUSLY in the request handler, so a real
# submit 500s until you put a real key in for the provider you are testing.
# The legacy polling providers are only contacted by worker binaries, which
# local dev does not run.
FAL_API_KEY=dummy-local-dev
GMICLOUD_API_KEY=dummy-local-dev
GROK_API_KEY=dummy-local-dev
BEEBLE_API_KEY=dummy-local-dev
OPENAI_API_KEY=dummy-local-dev
WORLDLABS_API_KEY=dummy-local-dev
SEEDANCE2PRO_COOKIES=dummy-local-dev
SEEDANCE2PRO_WHITELIST_COOKIES=dummy-local-dev
SEEDANCE2PRO_BYTEPLUS_ULTRA_COOKIES=dummy-local-dev
# Stripe account ids (test-mode secret keys are already checked into
# storyteller-web.development.env; only the account ids are missing there)
STRIPE_FAKEYOU_ACCOUNT_ID=acct_dummylocaldev
STRIPE_ARTCRAFT_ACCOUNT_ID=acct_dummylocaldev
STRIPE_ARTCRAFT_SECRET_KEY=sk_test_dummylocaldev
STRIPE_ARTCRAFT_SECRET_WEBHOOK_KEY=whsec_dummylocaldev
"@
Set-Content -Path $SecretsEnvFile -Value $content -Encoding ascii
Write-Log "Wrote $SecretsEnvFile"
}
function Build-Backend {
if ($SkipRustBuild) {
Write-Log "Skipped (-SkipRustBuild)."
return
}
Add-BuildToolsToSession
Push-Location $RootDir
try {
# SQLX_OFFLINE only affects compile-time query verification (checked-in
# .sqlx/ cache instead of a live DB); it has no runtime effect.
$env:SQLX_OFFLINE = "true"
Write-Log "Building (first build takes several minutes)..."
Invoke-Native "cargo build -p storyteller-web --bin storyteller-web"
if ($LASTEXITCODE -ne 0) { Die "cargo build failed (exit $LASTEXITCODE)." }
} finally {
Pop-Location
}
Write-Log "Built target\debug\storyteller-web.exe."
}
function Setup-Frontend {
if ($SkipFrontend) {
Write-Log "Skipped (-SkipFrontend)."
return
}
$frontendPath = Join-Path $RootDir "frontend"
# Mirror script/common/frontend_preflight.sh: the workspace uses npm; stale
# pnpm artifacts break installs with ENOTEMPTY.
if ((Test-Path (Join-Path $frontendPath "pnpm-lock.yaml")) -or
(Test-Path (Join-Path $frontendPath "pnpm-workspace.yaml")) -or
(Test-Path (Join-Path $frontendPath "node_modules\.pnpm"))) {
Die "Stale pnpm artifacts detected in frontend\. Clean up (see frontend/README.md): remove node_modules, pnpm-lock.yaml, pnpm-workspace.yaml, then re-run."
}
Push-Location $frontendPath
try {
Invoke-Native "npm install"
if ($LASTEXITCODE -ne 0) {
Write-Warn2 "npm install failed; wiping node_modules + .nx and retrying once..."
Remove-Item -Recurse -Force -Confirm:$false node_modules, .nx -ErrorAction SilentlyContinue
Invoke-Native "npm install"
if ($LASTEXITCODE -ne 0) { Die "npm install failed again. See frontend/README.md troubleshooting." }
}
} finally {
Pop-Location
}
Write-Log "Frontend dependencies installed."
}
function Download-File([string]$Url, [string]$Destination) {
# curl.exe ships with Windows 10+ and handles redirects/resume better than
# Invoke-WebRequest (which is also painfully slow on large files in PS 5.1).
# -sS: no progress meter (it goes to stderr, which pollutes captured logs),
# but errors still print.
$out = Invoke-Native "curl.exe -fsSL --retry 3 -o `"$Destination.partial`" `"$Url`""
if ($LASTEXITCODE -ne 0) {
if ($out) { Write-Host ($out -join "`n") }
Remove-Item "$Destination.partial" -Force -Confirm:$false -ErrorAction SilentlyContinue
Die "Download failed: $Url"
}
Move-Item "$Destination.partial" $Destination -Force
}
function Print-NextSteps {
Step "Done"
Write-Host @"
The dev stack is bootstrapped. Next steps:
1. Start the backend (binds $DevBackendUrl):
.\script\bootstrap\windows\run_backend_dev.ps1
2. In another terminal, create the demo login (idempotent):
.\script\bootstrap\windows\seed_demo_user.ps1
Credentials: $DemoUsername / $DemoPassword
3. Start the webapp (binds http://localhost:4201). NB: by default the dev
webapp proxies to PRODUCTION - aim it at your backend explicitly:
`$env:VITE_DEV_API_HOST = "$DevBackendUrl"
cd frontend; npx nx dev artcraft-webapp
Health checks at any time:
.\script\bootstrap\windows\dev_stack_doctor.ps1
Services (MySQL/Redis) start on demand and persist; stop them with:
.\script\bootstrap\windows\stop_dev_services.ps1
"@
}
Main
+234
View File
@@ -0,0 +1,234 @@
# Shared helpers for the Windows dev-stack bootstrap scripts.
#
# Dot-source from a sibling script:
# . (Join-Path $PSScriptRoot "common.ps1")
#
# Design: the stack is PORTABLE - MySQL and Redis live under the gitignored
# .devstack/ directory and run as plain user processes. No admin rights, no
# Windows services, no system-wide installs (build tools excepted).
# See _docs/dev_setup_local_stack.md.
$ErrorActionPreference = "Stop"
$script:RootDir = (Resolve-Path (Join-Path $PSScriptRoot "..\..\..")).Path
# Canonical local-dev database identity - must match _docs/dev_setup_server.md,
# the repo-root .env DATABASE_URL, and the server's MYSQL_URL default.
$script:DevMySqlDb = if ($env:DEV_MYSQL_DB) { $env:DEV_MYSQL_DB } else { "storyteller" }
$script:DevMySqlUser = if ($env:DEV_MYSQL_USER) { $env:DEV_MYSQL_USER } else { "storyteller" }
$script:DevMySqlPassword = if ($env:DEV_MYSQL_PASSWORD) { $env:DEV_MYSQL_PASSWORD } else { "password" }
$script:DevBackendUrl = if ($env:DEV_BACKEND_URL) { $env:DEV_BACKEND_URL } else { "http://localhost:12345" }
# Default demo account (see seed_demo_user.ps1). "demo"/"admin"/"test"/"dev"
# are on the backend's reserved-usernames list - don't use those.
$script:DemoUsername = if ($env:DEMO_USERNAME) { $env:DEMO_USERNAME } else { "localdev1" }
$script:DemoPassword = if ($env:DEMO_PASSWORD) { $env:DEMO_PASSWORD } else { "localdev1pass" }
$script:DemoEmail = if ($env:DEMO_EMAIL) { $env:DEMO_EMAIL } else { "localdev1@example.com" }
# Banked credits the demo user's artcraft wallet is seeded/topped-up to.
# Generation submits are gated on wallet balance, so 0 credits means the
# generate button silently refuses in the webapp.
$script:DemoCredits = if ($env:DEMO_CREDITS) { [uint32]$env:DEMO_CREDITS } else { 100000 }
# --- Portable service pins -------------------------------------------------
# MySQL 8.4 LTS (the 8.x series is required; 9.x is unsupported by tooling).
# cdn.mysql.com hosts only some patch versions; 8.4.11 verified available.
$script:MySqlVersion = "8.4.11"
$script:MySqlZipUrl = "https://cdn.mysql.com/Downloads/MySQL-8.4/mysql-$MySqlVersion-winx64.zip"
# Redis for Windows (tporadowski build). Old (5.0) but stable and sufficient
# for the backend's usage (keepalive/TTL/rate-limiter commands). Swap to
# Memurai or Microsoft Garnet by pointing REDIS_ZIP_URL elsewhere.
$script:RedisZipUrl = if ($env:REDIS_ZIP_URL) { $env:REDIS_ZIP_URL } else {
"https://github.com/tporadowski/redis/releases/download/v5.0.14.1/Redis-x64-5.0.14.1.zip"
}
$script:DevStackDir = Join-Path $RootDir ".devstack"
$script:DownloadsDir = Join-Path $DevStackDir "downloads"
$script:MySqlBaseDir = Join-Path $DevStackDir "mysql\mysql-$MySqlVersion-winx64"
$script:MySqlDataDir = Join-Path $DevStackDir "mysql-data"
$script:MySqlIniPath = Join-Path $DevStackDir "my.ini"
$script:RedisDir = Join-Path $DevStackDir "redis"
$script:LogsDir = Join-Path $DevStackDir "logs"
$script:PidsDir = Join-Path $DevStackDir "pids"
$script:SecretsEnvFile = Join-Path $RootDir "crates\service\web\storyteller_web\config\storyteller-web.development-secrets.env"
function Write-Log([string]$Message) { Write-Host "[bootstrap] $Message" }
function Write-Warn2([string]$Message) { Write-Host "[bootstrap] WARNING: $Message" -ForegroundColor Yellow }
function Die([string]$Message) { Write-Host "[bootstrap] ERROR: $Message" -ForegroundColor Red; exit 1 }
function Step([string]$Title) { Write-Host ""; Write-Host "=== $Title ===" -ForegroundColor Cyan }
function Confirm-Or-Skip([string]$Prompt) {
if ($script:AssumeYes) { return $true }
$reply = Read-Host "$Prompt [y/N]"
return ($reply -match '^[Yy]')
}
function Test-TcpPort([int]$Port) {
$client = New-Object System.Net.Sockets.TcpClient
try {
$async = $client.BeginConnect("127.0.0.1", $Port, $null, $null)
if (-not $async.AsyncWaitHandle.WaitOne(1500)) { return $false }
$client.EndConnect($async)
return $true
} catch {
return $false
} finally {
$client.Close()
}
}
# Resolve a client/server exe: prefer the portable install, fall back to PATH.
function Get-MySqlTool([string]$Name) {
$portable = Join-Path $MySqlBaseDir "bin\$Name.exe"
if (Test-Path $portable) { return $portable }
$cmd = Get-Command $Name -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
return $null
}
function Get-RedisTool([string]$Name) {
$portable = Join-Path $RedisDir "$Name.exe"
if (Test-Path $portable) { return $portable }
$cmd = Get-Command $Name -ErrorAction SilentlyContinue
if ($cmd) { return $cmd.Source }
return $null
}
# Generate a token in the backend's format: {prefix}{crockford-lower entropy}
# padded to the same TOTAL length the Rust generators use (tokens crate,
# impl_crockford_generator!). Alphabet excludes i/l/o/u like Crockford base32.
function New-DevToken([string]$Prefix, [int]$TotalLength) {
$charset = "0123456789abcdefghjkmnpqrstvwxyz"
$count = $TotalLength - $Prefix.Length
$chars = 1..$count | ForEach-Object { $charset[(Get-Random -Maximum $charset.Length)] }
return "$Prefix$(-join $chars)"
}
# Run SQL as the app user against the dev database. Returns output lines
# (stdout and stderr merged via Invoke-Native, so a caller capturing our
# streams can never turn a mysql warning into a terminating error).
# Password goes via MYSQL_PWD so it stays off the command line.
# -Quiet discards output (for probes that are expected to fail, e.g.
# Test-MySqlApp before provisioning). Check $LASTEXITCODE for success.
function Invoke-MySqlApp([string]$Sql, [string]$SourceFile, [switch]$Quiet) {
$mysql = Get-MySqlTool "mysql"
if (-not $mysql) { Die "mysql client not found (run bootstrap_dev_stack.ps1 first)." }
$prev = $env:MYSQL_PWD
$env:MYSQL_PWD = $DevMySqlPassword
try {
if ($SourceFile) {
$forward = $SourceFile -replace '\\', '/'
$out = Invoke-Native "`"$mysql`" -u $DevMySqlUser -h 127.0.0.1 -D $DevMySqlDb -e `"source $forward`""
} else {
$out = Invoke-Native "`"$mysql`" -u $DevMySqlUser -h 127.0.0.1 -D $DevMySqlDb -N -e `"$Sql`""
}
if (-not $Quiet) { return $out }
} finally {
$env:MYSQL_PWD = $prev
}
}
# Run a query expected to return a single non-negative integer (e.g. COUNT(*)).
# Returns -1 if the query failed or produced no numeric line, so callers can
# distinguish "table is empty" (0) from "query failed" (-1).
function Get-MySqlCount([string]$Sql) {
$out = @(Invoke-MySqlApp -Sql $Sql)
if ($LASTEXITCODE -ne 0) { return -1 }
$line = $out | Where-Object { "$_" -match '^\d+$' } | Select-Object -First 1
if ($null -eq $line) { return -1 }
return [int]"$line"
}
function Test-MySqlApp {
try {
Invoke-MySqlApp -Sql "SELECT 1" -Quiet
return ($LASTEXITCODE -eq 0)
} catch {
return $false
}
}
function Test-Redis {
$cli = Get-RedisTool "redis-cli"
if (-not $cli) { return $false }
try {
$pong = Invoke-Native "`"$cli`" -h 127.0.0.1 ping"
return ("$pong" -match "PONG")
} catch {
return $false
}
}
function Test-Backend {
try {
$r = Invoke-WebRequest -Uri "$DevBackendUrl/_status" -UseBasicParsing -TimeoutSec 3
return ($r.StatusCode -eq 200)
} catch {
return $false
}
}
# Start a detached background process, remembering its PID for stop_dev_services.
function Start-DevProcess([string]$Name, [string]$Exe, [string[]]$Arguments) {
New-Item -ItemType Directory -Force $PidsDir | Out-Null
New-Item -ItemType Directory -Force $LogsDir | Out-Null
$out = Join-Path $LogsDir "$Name.out.log"
$err = Join-Path $LogsDir "$Name.err.log"
$proc = Start-Process -FilePath $Exe -ArgumentList $Arguments -WindowStyle Hidden `
-RedirectStandardOutput $out -RedirectStandardError $err -PassThru
Set-Content -Path (Join-Path $PidsDir "$Name.pid") -Value $proc.Id -Encoding ascii
return $proc
}
function Wait-ForCondition([scriptblock]$Condition, [int]$TimeoutSeconds, [string]$What) {
$deadline = (Get-Date).AddSeconds($TimeoutSeconds)
while ((Get-Date) -lt $deadline) {
if (& $Condition) { return $true }
Start-Sleep -Seconds 1
}
Die "Timed out after ${TimeoutSeconds}s waiting for: $What"
}
# Run a native command line via cmd so its stderr is merged OUTSIDE PowerShell.
# PS 5.1 wraps captured native stderr lines in ErrorRecords; combined with our
# $ErrorActionPreference = "Stop", a harmless warning (e.g. from cargo) would
# otherwise abort the script whenever a caller captures/redirects our output.
# Quote embedded paths: Invoke-Native "`"$exe`" --flag value"
function Invoke-Native([string]$CommandLine) {
cmd /s /c "$CommandLine 2>&1"
}
# Make freshly-installed tools visible in this session without a new shell.
function Add-SessionPath([string[]]$Dirs) {
foreach ($d in $Dirs) {
if ((Test-Path $d) -and (($env:Path -split ';') -notcontains $d)) {
$env:Path = "$d;$env:Path"
}
}
}
# The portable MySQL's client DLL + import lib, needed to build AND run
# diesel-cli with the mysql feature.
function Add-MySqlClientToSession {
Add-SessionPath @((Join-Path $MySqlBaseDir "bin"), (Join-Path $MySqlBaseDir "lib"))
}
# Native build tools (cmake/perl/nasm/llvm) land in these dirs when installed
# via winget; a shell opened before the install won't have them on PATH yet.
# bindgen (used by boring2 / aws-lc-sys) additionally wants LIBCLANG_PATH.
function Add-BuildToolsToSession {
Add-SessionPath @(
"$env:ProgramFiles\CMake\bin",
"C:\Strawberry\perl\bin",
"$env:ProgramFiles\NASM",
"$env:LOCALAPPDATA\bin\NASM",
"$env:ProgramFiles\LLVM\bin"
)
if (-not $env:LIBCLANG_PATH) {
$clang = Get-Command clang -ErrorAction SilentlyContinue
if ($clang) { $env:LIBCLANG_PATH = Split-Path $clang.Source }
}
}
@@ -0,0 +1,160 @@
# Diagnose the local dev stack on Windows: checks every prerequisite the
# bootstrap sets up and reports PASS / WARN / FAIL per item.
#
# FAIL = the stack cannot work until fixed (exit code 1).
# WARN = optional or run-time-only (e.g. backend not currently running).
param()
. (Join-Path $PSScriptRoot "common.ps1")
$ErrorActionPreference = "Continue"
$script:Failures = 0
$script:Warnings = 0
function Pass([string]$Message) { Write-Host " PASS $Message" }
function Fail([string]$Message) { Write-Host " FAIL $Message" -ForegroundColor Red; $script:Failures++ }
function WarnCheck([string]$Message) { Write-Host " WARN $Message" -ForegroundColor Yellow; $script:Warnings++ }
Write-Host "Dev stack doctor ($RootDir)"
Write-Host ""
Write-Host "--- Toolchains ---"
Add-SessionPath @((Join-Path $env:USERPROFILE ".cargo\bin"))
Add-MySqlClientToSession
# Freshly-installed build tools may not be on this session's PATH yet.
Add-BuildToolsToSession
if (Get-Command cargo -ErrorAction SilentlyContinue) {
Pass "rust: $(rustc --version)"
} else {
Fail "rust: cargo not on PATH (winget install Rustlang.Rustup)"
}
if (Get-Command diesel -ErrorAction SilentlyContinue) {
$dv = Invoke-Native "diesel --version"
if ($LASTEXITCODE -eq 0) { Pass "diesel_cli: $dv" }
else { Fail "diesel_cli: present but broken (libmysql.dll missing from PATH? re-run bootstrap)" }
} else {
Fail "diesel_cli: not installed (bootstrap installs it against the portable MySQL client lib)"
}
if (Get-Command node -ErrorAction SilentlyContinue) {
$nodeMajor = [int]((node --version) -replace '^v(\d+).*', '$1')
if ($nodeMajor -ge 20) { Pass "node: $(node --version) (>= 20 required by Nx 21 / Vite 6)" }
else { Fail "node: $(node --version) is too old - Node 20+ required" }
} else {
Fail "node: not installed (Node 20+ required for the frontend)"
}
foreach ($tool in @("cmake", "perl", "nasm", "clang")) {
if (Get-Command $tool -ErrorAction SilentlyContinue) {
Pass "${tool}: found"
} else {
WarnCheck "${tool}: not on PATH (needed to COMPILE the backend; bootstrap offers winget installs)"
}
}
Write-Host ""
Write-Host "--- Services (portable, under .devstack\) ---"
if (Test-Path (Join-Path $MySqlBaseDir "bin\mysqld.exe")) {
Pass "mysql: portable install present ($MySqlVersion)"
} elseif (Test-TcpPort 3306) {
WarnCheck "mysql: no portable install, but something serves port 3306 (external server assumed)"
} else {
Fail "mysql: not installed (run bootstrap_dev_stack.ps1)"
}
if (Test-TcpPort 3306) {
if (Test-MySqlApp) {
Pass "mysql: reachable as '$DevMySqlUser' on database '$DevMySqlDb'"
$migrationDirs = (Get-ChildItem -Directory (Join-Path $RootDir "_database\sql\migrations")).Count
$applied = Get-MySqlCount "SELECT COUNT(*) FROM __diesel_schema_migrations"
if (($applied -ge $migrationDirs) -and ($applied -gt 0)) {
Pass "migrations: $applied applied ($migrationDirs in _database\sql\migrations)"
} elseif ($applied -gt 0) {
Fail "migrations: only $applied/$migrationDirs applied - run 'diesel migration run'"
} else {
Fail "migrations: none applied - run 'diesel migration run' (or re-run bootstrap)"
}
$roleCount = Get-MySqlCount "SELECT COUNT(*) FROM user_roles"
if ($roleCount -ge 3) {
Pass "seed: user_roles has $roleCount rows (user/mod/admin present)"
} else {
Fail "seed: user_roles has $roleCount rows - account creation needs the 'user' role (re-run bootstrap)"
}
$badgeCount = Get-MySqlCount "SELECT COUNT(*) FROM badges"
if ($badgeCount -gt 0) { Pass "seed: badges has $badgeCount rows" }
else { WarnCheck "seed: badges table is empty (cosmetic; re-run bootstrap to fill)" }
$demoCount = Get-MySqlCount "SELECT COUNT(*) FROM users WHERE username='$DemoUsername'"
if ($demoCount -gt 0) { Pass "demo user: '$DemoUsername' exists" }
else { WarnCheck "demo user: '$DemoUsername' not created yet (seed_demo_user.ps1, backend must be running)" }
if ($demoCount -gt 0) {
$credits = Get-MySqlCount "SELECT COALESCE(MAX(banked_credits + monthly_credits), 0) FROM wallets WHERE wallet_namespace='artcraft' AND owner_user_token=(SELECT token FROM users WHERE username='$DemoUsername')"
if ($credits -gt 0) { Pass "demo wallet: '$DemoUsername' has $credits credits" }
else { WarnCheck "demo wallet: '$DemoUsername' has no credits - generation flows are blocked (re-run seed_demo_user.ps1)" }
}
} else {
Fail "mysql: port 3306 is up but '$DevMySqlUser' cannot access '$DevMySqlDb' (re-run bootstrap provisioning)"
}
} else {
Fail "mysql: nothing listening on 3306 (start_dev_services.ps1)"
}
if (Test-Redis) {
Pass "redis: PONG"
} elseif (Test-TcpPort 6379) {
WarnCheck "redis: port 6379 open but redis-cli not found to verify"
} else {
Fail "redis: not reachable on 6379 (the backend's r2d2 pool connects eagerly at boot; start_dev_services.ps1)"
}
Write-Host ""
Write-Host "--- Backend ---"
if (Test-Path $SecretsEnvFile) {
Pass "secrets env: storyteller-web.development-secrets.env exists"
} else {
Fail "secrets env: missing - the server aborts at boot on ~19 required vars (re-run bootstrap)"
}
if ((Test-Path (Join-Path $RootDir "target\debug\storyteller-web.exe")) -or
(Test-Path (Join-Path $RootDir "target\release\storyteller-web.exe"))) {
Pass "binary: storyteller-web.exe is built"
} else {
WarnCheck "binary: storyteller-web not built yet (bootstrap builds it; cargo run builds on demand)"
}
if (Test-Backend) {
Pass "server: $DevBackendUrl/_status responds"
} else {
WarnCheck "server: not running (run_backend_dev.ps1)"
}
Write-Host ""
Write-Host "--- Frontend ---"
if (Test-Path (Join-Path $RootDir "frontend\node_modules")) {
Pass "frontend: node_modules present"
} else {
WarnCheck "frontend: node_modules missing (cd frontend; npm install)"
}
if ((Test-Path (Join-Path $RootDir "frontend\pnpm-lock.yaml")) -or
(Test-Path (Join-Path $RootDir "frontend\node_modules\.pnpm"))) {
Fail "frontend: stale pnpm artifacts detected (see frontend/README.md - the workspace uses npm)"
} else {
Pass "frontend: no stale pnpm artifacts"
}
Write-Host ""
Write-Host "Summary: $Failures failure(s), $Warnings warning(s)."
if ($Failures -gt 0) {
Write-Host "Fix failures with: .\script\bootstrap\windows\bootstrap_dev_stack.ps1 (safe to re-run)"
exit 1
}
@@ -0,0 +1,33 @@
# Run the storyteller-web backend for local development (binds 0.0.0.0:12345).
#
# Requires a bootstrapped environment (bootstrap_dev_stack.ps1). Starts the
# portable MySQL/Redis if they aren't running. Always runs from the repo root:
# the server's config search path (crates/service/web/storyteller_web/config)
# and its includes/ defaults are cwd-relative.
param()
. (Join-Path $PSScriptRoot "common.ps1")
. (Join-Path $PSScriptRoot "start_dev_services.ps1")
Start-DevMySql
Start-DevRedis
if (-not (Test-MySqlApp)) { Die "MySQL is up but '$DevMySqlUser' cannot reach '$DevMySqlDb'. Run bootstrap_dev_stack.ps1." }
if (-not (Test-Path $SecretsEnvFile)) { Die "Missing $SecretsEnvFile. Run bootstrap_dev_stack.ps1." }
Add-SessionPath @((Join-Path $env:USERPROFILE ".cargo\bin"))
# cargo run may need to (re)compile, which needs the native build toolchain.
Add-BuildToolsToSession
Push-Location $RootDir
try {
# SERVER_ENVIRONMENT defaults to Development when unset; dev config files are
# picked up from the search path automatically. SQLX_OFFLINE only affects
# compile-time query checking, never runtime. TEMP_DIR belt-and-suspenders in
# case an older generated secrets file predates the Windows TEMP_DIR line.
$env:SQLX_OFFLINE = "true"
if (-not $env:TEMP_DIR) { $env:TEMP_DIR = $env:TEMP -replace '\\', '/' }
Write-Log "Starting storyteller-web on $DevBackendUrl (Ctrl-C to stop)..."
Invoke-Native "cargo run -p storyteller-web --bin storyteller-web"
} finally {
Pop-Location
}
+124
View File
@@ -0,0 +1,124 @@
# Create (or verify) the local demo user via the running backend, then verify
# that login works. Idempotent: an already-existing user is a success.
#
# Requires the backend to be up (run_backend_dev.ps1) - waits up to
# -WaitSeconds (default 60) for it.
#
# The account is created through POST /v1/create_account rather than raw SQL so
# it exercises the real signup path (bcrypt hash, session, firehose row) and
# stays correct if the users schema evolves.
#
# Override credentials via DEMO_USERNAME / DEMO_PASSWORD / DEMO_EMAIL env vars.
# Username rules: 3-16 chars of [A-Za-z0-9_-], not on the reserved list
# (includes/binary_includes/usernames/reserved_usernames.txt); password >= 6.
param(
[int]$WaitSeconds = 60
)
. (Join-Path $PSScriptRoot "common.ps1")
Write-Log "Waiting for backend at $DevBackendUrl (up to ${WaitSeconds}s)..."
Wait-ForCondition { Test-Backend } $WaitSeconds "backend /_status to respond (start it with run_backend_dev.ps1)" | Out-Null
Write-Log "Backend is up."
# curl.exe + ConvertFrom-Json: Invoke-RestMethod in PS 5.1 throws on non-2xx
# and hides the response body, which we need for the already-exists case.
# Invoke-Native keeps curl's stderr from becoming a terminating PS error.
function Invoke-JsonPost([string]$Url, [hashtable]$Body) {
$json = $Body | ConvertTo-Json -Compress
$tmp = Join-Path $env:TEMP "bootstrap_post_body.json"
Set-Content -Path $tmp -Value $json -Encoding ascii
$response = Invoke-Native "curl.exe -sS -X POST `"$Url`" -H `"Content-Type: application/json`" --data `"@$tmp`""
$curlExit = $LASTEXITCODE
Remove-Item $tmp -Force -Confirm:$false -ErrorAction SilentlyContinue
$joined = ($response -join "`n")
if ($curlExit -ne 0) { Die "Request to $Url failed (curl exit $curlExit): $joined" }
try {
return $joined | ConvertFrom-Json
} catch {
Die "Non-JSON response from ${Url}: $joined"
}
}
Write-Log "Creating account '$DemoUsername'..."
$create = Invoke-JsonPost "$DevBackendUrl/v1/create_account" @{
username = $DemoUsername
password = $DemoPassword
password_confirmation = $DemoPassword
email_address = $DemoEmail
}
if ($create.success -eq $true) {
Write-Log "Account created."
} else {
$errorCode = "$($create.error_type)$($create.error_code)$($create.error_code_str)"
if ($errorCode -match "(?i)username_?taken|email_?taken") {
Write-Log "Account already exists ($errorCode) - continuing."
} else {
Die "Account creation failed: $($create | ConvertTo-Json -Compress)"
}
}
Write-Log "Verifying login..."
$login = Invoke-JsonPost "$DevBackendUrl/v1/login" @{
username_or_email = $DemoUsername
password = $DemoPassword
}
if ($login.success -ne $true) { Die "Login verification failed: $($login | ConvertTo-Json -Compress)" }
if (-not $login.signed_session) { Die "Login succeeded but returned no signed_session." }
# --- Credits wallet ---------------------------------------------------------
# Generation submits deduct from the user's artcraft wallet and the webapp
# gates the generate button on the balance, so a user without credits cannot
# exercise any generation flow. Signup does not create a wallet; seed one.
# Raw SQL (not an endpoint): the only credit-granting paths are Stripe
# webhooks and moderator tooling, neither of which works locally.
Write-Log "Seeding credits wallet ($DemoCredits banked credits)..."
$userToken = @(Invoke-MySqlApp -Sql "SELECT token FROM users WHERE username = '$DemoUsername' LIMIT 1") |
Where-Object { "$_" -match '^user_' } | Select-Object -First 1
if (-not $userToken) { Die "Could not find users row for '$DemoUsername'." }
$walletRow = @(Invoke-MySqlApp -Sql "SELECT token, banked_credits FROM wallets WHERE wallet_namespace = 'artcraft' AND owner_user_token = '$userToken' LIMIT 1") |
Where-Object { "$_" -match '^wallet_' } | Select-Object -First 1
if (-not $walletRow) {
$walletToken = New-DevToken "wallet_" 32
$createLedgerToken = New-DevToken "wle_" 32
$creditLedgerToken = New-DevToken "wle_" 32
Invoke-MySqlApp -Sql (
"INSERT INTO wallets SET token='$walletToken', wallet_namespace='artcraft', owner_user_token='$userToken', banked_credits=$DemoCredits, monthly_credits=0; " +
"INSERT INTO wallet_ledger_entries SET token='$createLedgerToken', wallet_token='$walletToken', entry_type='create', credits_delta=0, banked_credits_before=0, banked_credits_after=0, monthly_credits_before=0, monthly_credits_after=0; " +
"INSERT INTO wallet_ledger_entries SET token='$creditLedgerToken', wallet_token='$walletToken', entry_type='credit_banked', maybe_entity_ref='dev_bootstrap_seed', credits_delta=$DemoCredits, banked_credits_before=0, banked_credits_after=$DemoCredits, monthly_credits_before=0, monthly_credits_after=0;"
) | Out-Null
if ($LASTEXITCODE -ne 0) { Die "Failed to insert the demo wallet." }
Write-Log "Wallet created with $DemoCredits banked credits."
} else {
$parts = "$walletRow" -split "`t"
$walletToken = $parts[0]
$balance = [uint64]$parts[1]
if ($balance -lt $DemoCredits) {
$creditLedgerToken = New-DevToken "wle_" 32
$delta = [uint64]$DemoCredits - $balance
Invoke-MySqlApp -Sql (
"UPDATE wallets SET banked_credits=$DemoCredits, version=version+1 WHERE token='$walletToken' LIMIT 1; " +
"INSERT INTO wallet_ledger_entries SET token='$creditLedgerToken', wallet_token='$walletToken', entry_type='credit_banked', maybe_entity_ref='dev_bootstrap_topup', credits_delta=$delta, banked_credits_before=$balance, banked_credits_after=$DemoCredits, monthly_credits_before=0, monthly_credits_after=0;"
) | Out-Null
if ($LASTEXITCODE -ne 0) { Die "Failed to top up the demo wallet." }
Write-Log "Wallet topped up: $balance -> $DemoCredits banked credits."
} else {
Write-Log "Wallet already has $balance banked credits - leaving as-is."
}
}
# NB: no gallery media is seeded. A media_files row is only useful if the bytes
# behind it are reachable, and serving those locally needs real R2 credentials
# in the secrets env - rows on their own render as broken tiles. For a populated
# library with no backend at all, run frontend/apps/fake-storyteller-web.
Write-Log "Demo user is ready."
Write-Log " Username: $DemoUsername"
Write-Log " Password: $DemoPassword"
Write-Log "Log in at http://localhost:4201/login once the webapp is running - start it with"
Write-Log "VITE_DEV_API_HOST=$DevBackendUrl set, or it will talk to production instead."
@@ -0,0 +1,52 @@
# Start (or verify) the portable MySQL and Redis processes under .devstack/.
# Idempotent: already-running services are left alone. Safe to call from other
# scripts via dot-sourcing (defines Start-DevMySql / Start-DevRedis) or run
# directly.
param()
. (Join-Path $PSScriptRoot "common.ps1")
function Start-DevMySql {
if (Test-TcpPort 3306) {
Write-Log "MySQL already listening on 3306."
return
}
$mysqld = Join-Path $MySqlBaseDir "bin\mysqld.exe"
if (-not (Test-Path $mysqld)) {
Die "Portable MySQL not installed at $MySqlBaseDir. Run bootstrap_dev_stack.ps1 first."
}
if (-not (Test-Path (Join-Path $MySqlDataDir "mysql"))) {
Die "MySQL data directory not initialized at $MySqlDataDir. Run bootstrap_dev_stack.ps1 first."
}
Write-Log "Starting mysqld (portable, data in .devstack\mysql-data)..."
Start-DevProcess -Name "mysqld" -Exe $mysqld -Arguments @("--defaults-file=`"$MySqlIniPath`"") | Out-Null
Wait-ForCondition { Test-TcpPort 3306 } 60 "MySQL to listen on 3306 (see $LogsDir\mysqld.err.log and the .err file in $MySqlDataDir)" | Out-Null
Write-Log "MySQL is up."
}
function Start-DevRedis {
if (Test-TcpPort 6379) {
Write-Log "Redis already listening on 6379."
return
}
$redis = Join-Path $RedisDir "redis-server.exe"
if (-not (Test-Path $redis)) {
Die "Portable Redis not installed at $RedisDir. Run bootstrap_dev_stack.ps1 first."
}
Write-Log "Starting redis-server (portable)..."
# Keep the working dir (dump.rdb etc.) inside .devstack. NB: Start-Process
# rejects empty-string ArgumentList elements, so pass `--save ""` as one
# token to disable snapshotting.
Start-DevProcess -Name "redis" -Exe $redis -Arguments @(
"--port", "6379",
"--save", "`"`"",
"--dir", "`"$RedisDir`""
) | Out-Null
Wait-ForCondition { Test-TcpPort 6379 } 30 "Redis to listen on 6379 (see $LogsDir\redis.err.log)" | Out-Null
Write-Log "Redis is up."
}
if ($MyInvocation.InvocationName -ne '.') {
Start-DevMySql
Start-DevRedis
}
@@ -0,0 +1,41 @@
# Stop the portable MySQL and Redis processes started by start_dev_services.ps1.
param()
. (Join-Path $PSScriptRoot "common.ps1")
# Graceful MySQL shutdown (flushes InnoDB) - root has no password in the
# portable install (initialized with --initialize-insecure).
if (Test-TcpPort 3306) {
$mysqladmin = Get-MySqlTool "mysqladmin"
if ($mysqladmin) {
Write-Log "Shutting down MySQL..."
try { & $mysqladmin -u root -h 127.0.0.1 shutdown 2>$null } catch {}
$deadline = (Get-Date).AddSeconds(30)
while ((Test-TcpPort 3306) -and ((Get-Date) -lt $deadline)) { Start-Sleep -Seconds 1 }
}
}
if (Test-TcpPort 6379) {
$cli = Get-RedisTool "redis-cli"
if ($cli) {
Write-Log "Shutting down Redis..."
try { & $cli -h 127.0.0.1 shutdown nosave 2>$null } catch {}
}
}
# Last resort: kill anything left via recorded PIDs.
if (Test-Path $PidsDir) {
foreach ($pidFile in Get-ChildItem $PidsDir -Filter "*.pid" -ErrorAction SilentlyContinue) {
$procId = Get-Content $pidFile.FullName -ErrorAction SilentlyContinue
if ($procId) {
$proc = Get-Process -Id $procId -ErrorAction SilentlyContinue
if ($proc -and -not $proc.HasExited) {
Write-Log "Force-stopping $($pidFile.BaseName) (pid $procId)..."
try { Stop-Process -Id $procId -Force -Confirm:$false -ErrorAction Stop } catch {}
}
}
Remove-Item $pidFile.FullName -Force -Confirm:$false -ErrorAction SilentlyContinue
}
}
Write-Log "Dev services stopped."