diff --git a/.gitattributes b/.gitattributes index 3b9631fee1..1a7f5222ee 100644 --- a/.gitattributes +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore index 986d68eaf9..1aa7a2cfff 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/_docs/dev_setup_local_stack.md b/_docs/dev_setup_local_stack.md new file mode 100644 index 0000000000..49fbca6f45 --- /dev/null +++ b/_docs/dev_setup_local_stack.md @@ -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": ""}` **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`. diff --git a/script/bootstrap/bootstrap_dev_stack.sh b/script/bootstrap/bootstrap_dev_stack.sh new file mode 100755 index 0000000000..ca501909cf --- /dev/null +++ b/script/bootstrap/bootstrap_dev_stack.sh @@ -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 </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 <&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 +} diff --git a/script/bootstrap/dev_stack_doctor.sh b/script/bootstrap/dev_stack_doctor.sh new file mode 100755 index 0000000000..214443049e --- /dev/null +++ b/script/bootstrap/dev_stack_doctor.sh @@ -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 diff --git a/script/bootstrap/run_backend_dev.sh b/script/bootstrap/run_backend_dev.sh new file mode 100755 index 0000000000..f2babc89b0 --- /dev/null +++ b/script/bootstrap/run_backend_dev.sh @@ -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 diff --git a/script/bootstrap/seed_demo_user.sh b/script/bootstrap/seed_demo_user.sh new file mode 100755 index 0000000000..ed8344262d --- /dev/null +++ b/script/bootstrap/seed_demo_user.sh @@ -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}\"}'" diff --git a/script/bootstrap/windows/bootstrap_dev_stack.ps1 b/script/bootstrap/windows/bootstrap_dev_stack.ps1 new file mode 100644 index 0000000000..0d11a02a72 --- /dev/null +++ b/script/bootstrap/windows/bootstrap_dev_stack.ps1 @@ -0,0 +1,471 @@ +# 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 +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; in dev it talks to + $DevBackendUrl automatically): + 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 diff --git a/script/bootstrap/windows/common.ps1 b/script/bootstrap/windows/common.ps1 new file mode 100644 index 0000000000..3b9c75817d --- /dev/null +++ b/script/bootstrap/windows/common.ps1 @@ -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 } + } +} diff --git a/script/bootstrap/windows/dev_stack_doctor.ps1 b/script/bootstrap/windows/dev_stack_doctor.ps1 new file mode 100644 index 0000000000..3a7ef8c62a --- /dev/null +++ b/script/bootstrap/windows/dev_stack_doctor.ps1 @@ -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 +} diff --git a/script/bootstrap/windows/run_backend_dev.ps1 b/script/bootstrap/windows/run_backend_dev.ps1 new file mode 100644 index 0000000000..f8427d0da4 --- /dev/null +++ b/script/bootstrap/windows/run_backend_dev.ps1 @@ -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 +} diff --git a/script/bootstrap/windows/seed_demo_user.ps1 b/script/bootstrap/windows/seed_demo_user.ps1 new file mode 100644 index 0000000000..efa76051dc --- /dev/null +++ b/script/bootstrap/windows/seed_demo_user.ps1 @@ -0,0 +1,123 @@ +# 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 the webapp /login page (http://localhost:4201/login once 'nx dev artcraft-webapp' is running)." diff --git a/script/bootstrap/windows/start_dev_services.ps1 b/script/bootstrap/windows/start_dev_services.ps1 new file mode 100644 index 0000000000..d260682297 --- /dev/null +++ b/script/bootstrap/windows/start_dev_services.ps1 @@ -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 +} diff --git a/script/bootstrap/windows/stop_dev_services.ps1 b/script/bootstrap/windows/stop_dev_services.ps1 new file mode 100644 index 0000000000..b74684a07b --- /dev/null +++ b/script/bootstrap/windows/stop_dev_services.ps1 @@ -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."