feat(infra): Phase 0 provisioning + dev status dashboard

- scripts/phase0-provision.sh: idempotent root setup (nginx, PostgreSQL,
  Redis, certbot/TLS, UFW). Opens 22/2222/80/443 before enabling UFW so SSH
  and Gitea git-SSH can't be locked out. Redis/Postgres stay localhost-only.
- infra/nginx/feedthepyre.com.conf: vhost serving the status page; commented
  web(:3000)/api(:4000) reverse-proxy blocks ready for app deploy.
- infra/status/: data-driven dev status dashboard (status.json + gen-status.mjs
  + prebuilt index.html), served at feedthepyre.com.
- ecosystem.config.cjs (PM2), infra/systemd/pm2-pyre.service, infra/logrotate/pyre,
  scripts/backup.sh — process mgmt + ops (inert until apps are built).

Built by 4 parallel agents, reviewed by 2 audit agents; audit fixes applied
(logs dir creation, port-citation accuracy, status truthfulness). pm2 installed
user-level. Privileged steps gated on `sudo bash scripts/phase0-provision.sh`.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 02:34:13 +00:00
parent c20094ab56
commit 571e5d04d2
13 changed files with 1629 additions and 0 deletions

View File

@@ -2,6 +2,8 @@
> **Burn the dead. Feed the PYRE. Claim the Spawn.**
**Links:** [feedthepyre.com](https://feedthepyre.com) · repo: `git.lumiai.dev/RogueWave/pyre` · dev status: [feedthepyre.com](https://feedthepyre.com) (status dashboard)
PYRE is a **Solana wallet-cleanup and ritual meme-rebirth protocol**. You connect
a wallet; PYRE scans your SPL token accounts, classifies them conservatively, and
helps you safely close empty associated token accounts (ATAs) and burn obvious

74
ecosystem.config.cjs Normal file
View File

@@ -0,0 +1,74 @@
// PYRE / Prometheus Protocol — PM2 ecosystem (process manager) config
//
// ⚠️ INERT / NOT YET RUNNABLE: the apps are NOT implemented yet. This file
// defines how the three PYRE processes WILL run once apps/web, apps/api,
// and apps/worker are built/deployed. Starting it now will fail because
// the apps (and their builds) do not exist yet.
//
// Process names match docs/PYRE_MVP_DESIGN.md §12: pyre-web, pyre-api, pyre-worker.
//
// Once apps exist, start with:
// pm2 start ecosystem.config.cjs
// pm2 save # persist process list so `pm2 resurrect` works on boot
//
// PM2 is installed at user level: ~/.local/share/pnpm/bin/pm2
// Logs go to /home/pyre/pyre/logs/ (rotated by infra/logrotate/pyre).
//
// Note on memory: the 8GB VPS is shared with postgres, redis, nginx, etc.,
// so each process is capped at 400M via max_memory_restart.
module.exports = {
apps: [
{
// Next.js frontend (production) — port 3000 per .env.example (WEB_PORT).
name: "pyre-web",
cwd: "apps/web",
script: "pnpm",
args: "start", // runs `next start` (requires a prior `pnpm build`)
instances: 1,
autorestart: true,
max_memory_restart: "400M",
env: {
NODE_ENV: "production",
PORT: 3000,
},
out_file: "/home/pyre/pyre/logs/pyre-web-out.log",
error_file: "/home/pyre/pyre/logs/pyre-web-err.log",
},
{
// Fastify HTTP API — port 4000 per .env.example (API_PORT).
// Runs the compiled server. Until a build exists you can temporarily
// swap to a dev runner: script: "pnpm", args: "dev"
name: "pyre-api",
cwd: "apps/api",
script: "node",
args: "dist/index.js",
instances: 1,
autorestart: true,
max_memory_restart: "400M",
env: {
NODE_ENV: "production",
PORT: 4000,
},
out_file: "/home/pyre/pyre/logs/pyre-api-out.log",
error_file: "/home/pyre/pyre/logs/pyre-api-err.log",
},
{
// BullMQ background worker (no HTTP port).
// Runs the compiled worker. Until a build exists you can temporarily
// swap to a dev runner: script: "pnpm", args: "dev"
name: "pyre-worker",
cwd: "apps/worker",
script: "node",
args: "dist/index.js",
instances: 1,
autorestart: true,
max_memory_restart: "400M",
env: {
NODE_ENV: "production",
},
out_file: "/home/pyre/pyre/logs/pyre-worker-out.log",
error_file: "/home/pyre/pyre/logs/pyre-worker-err.log",
},
],
};

34
infra/logrotate/pyre Normal file
View File

@@ -0,0 +1,34 @@
# PYRE / Prometheus Protocol — logrotate config.
#
# INERT until logs are actually being produced (PM2 apps running / nginx
# serving). The paths below will simply be skipped (missingok) until then.
#
# Install (run as a privileged user):
# sudo cp /home/pyre/pyre/infra/logrotate/pyre /etc/logrotate.d/pyre
# sudo logrotate --debug /etc/logrotate.d/pyre # dry-run to validate
#
# copytruncate is used so PM2 and nginx keep writing to the same file handle
# (no restart/reopen needed after rotation).
# ---- PYRE app logs (written by PM2) ----------------------------------------
/home/pyre/pyre/logs/*.log {
su pyre pyre
daily
rotate 14
compress
delaycompress
missingok
notifempty
copytruncate
}
# ---- nginx logs for feedthepyre ---------------------------------------------
/var/log/nginx/feedthepyre.*.log {
daily
rotate 14
compress
delaycompress
missingok
notifempty
copytruncate
}

98
infra/nginx/README.md Normal file
View File

@@ -0,0 +1,98 @@
# nginx — feedthepyre.com virtual host
This directory holds the nginx virtual host config for **feedthepyre.com**, the
public domain for the PYRE / Prometheus Protocol MVP.
- [`feedthepyre.com.conf`](feedthepyre.com.conf) — the vhost.
## What it does now
Right now the vhost serves a **static status dashboard** (a holding/status page)
for both `feedthepyre.com` and `www.feedthepyre.com`.
- Site root (`location /`) serves files from the webroot
`/var/www/feedthepyre/status` (with `index.html`), using
`try_files $uri $uri/ /index.html`.
- The Next.js web app and Fastify API are **not** proxied yet — those blocks are
present but commented out.
- An explicit `/.well-known/acme-challenge/` location is served from the same
webroot so Let's Encrypt HTTP-01 validation works even before certbot applies
its `--nginx` changes.
- gzip is enabled for common text content types.
Ports (per `.env.example`; design §11 names the processes but not their ports):
| service | bind | env var |
| -------------- | --------------- | ---------- |
| web (Next.js) | 127.0.0.1:3000 | `WEB_PORT` |
| api (Fastify) | 127.0.0.1:4000 | `API_PORT` |
## How the provision script installs it
The provisioning script (run with sudo on the PYRE VPS) is expected to:
1. Copy `feedthepyre.com.conf` to `/etc/nginx/sites-available/feedthepyre.com`.
2. Symlink it into the enabled set:
`ln -s /etc/nginx/sites-available/feedthepyre.com /etc/nginx/sites-enabled/feedthepyre.com`
3. Ensure the webroot exists and has an index page:
`mkdir -p /var/www/feedthepyre/status` (drop an `index.html` in it).
4. Validate and reload: `nginx -t && systemctl reload nginx`.
These exact paths are a contract the script relies on — do not rename the
install path or the webroot without updating the script too.
> This config is file-only. Do not run nginx/sudo from this repo; the provision
> script owns that.
## How certbot adds TLS
After the HTTP vhost is installed and nginx is reloaded, obtain certificates:
```bash
sudo certbot --nginx -d feedthepyre.com -d www.feedthepyre.com
```
certbot will **edit `feedthepyre.com.conf` in place**, adding:
- a `listen 443 ssl;` server block,
- `ssl_certificate` / `ssl_certificate_key` directives (and Let's Encrypt
includes),
- an HTTP->HTTPS redirect on the `listen 80;` server.
That is why the `:80` server is written as a plain `listen 80;` block with both
domains in `server_name` — it is the shape certbot expects to augment. Renewals
are handled automatically by certbot's systemd timer/cron.
## Flipping from the static status page to the live app + API proxy
Once `apps/web` (Next.js, port 3000) and `apps/api` (Fastify, port 4000) are
deployed and running on the VPS:
1. Edit `/etc/nginx/sites-available/feedthepyre.com`.
2. **Enable the API proxy:** uncomment the `location /api/ { ... }` block. The
trailing slash on `proxy_pass http://127.0.0.1:4000/;` strips the `/api/`
prefix so the backend sees `/scan`, `/receipt`, etc.
3. **Switch the root to the web app:** in `location / { ... }`, replace the
`try_files ...` line with the `proxy_pass http://127.0.0.1:3000;` block shown
in the inline comment (Host / X-Real-IP / X-Forwarded-For / X-Forwarded-Proto
plus the websocket Upgrade/Connection headers).
4. **Add the websocket map** (one time) to the `http{}` block of
`/etc/nginx/nginx.conf`:
```nginx
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
```
5. **Optional global hardening:** add `server_tokens off;` to the same `http{}`
block (kept out of the vhost on purpose so it is not duplicated).
6. Validate and reload:
```bash
sudo nginx -t && sudo systemctl reload nginx
```
Keep the `/.well-known/acme-challenge/` location in place so certificate
renewals continue to work after the cutover.

View File

@@ -0,0 +1,109 @@
# ============================================================================
# PYRE / Prometheus Protocol — nginx virtual host for feedthepyre.com
# ----------------------------------------------------------------------------
# Install path: /etc/nginx/sites-available/feedthepyre.com
# (the provision script symlinks this into sites-enabled/)
#
# TLS: Managed by certbot. Run `certbot --nginx` AFTER this config is
# installed — it will inject the listen 443 ssl server block,
# the ssl_certificate / ssl_certificate_key lines, and the
# HTTP->HTTPS redirect automatically. Do NOT hand-edit those in.
#
# App ports (see docs/PYRE_MVP_DESIGN.md §11 and .env.example):
# web (Next.js) -> 127.0.0.1:3000 (WEB_PORT)
# api (Fastify) -> 127.0.0.1:4000 (API_PORT)
#
# Current behaviour: serves the static status dashboard from
# /var/www/feedthepyre/status. The reverse-proxy blocks below
# are commented out until the apps are deployed.
# ============================================================================
server {
listen 80;
listen [::]:80;
server_name feedthepyre.com www.feedthepyre.com;
# --- Static status site (current site root) -----------------------------
root /var/www/feedthepyre/status;
index index.html;
# --- Logging ------------------------------------------------------------
access_log /var/log/nginx/feedthepyre.access.log;
error_log /var/log/nginx/feedthepyre.error.log;
# --- ACME HTTP-01 challenge --------------------------------------------
# Explicit so certbot's HTTP-01 validation works even before its --nginx
# tweaks are applied. ^~ ensures this wins over the regex/proxy locations.
location ^~ /.well-known/acme-challenge/ {
root /var/www/feedthepyre/status;
allow all;
}
# --- Basic hardening ----------------------------------------------------
# gzip for text-ish content types.
gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_proxied any;
gzip_vary on;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/json
application/xml
application/rss+xml
image/svg+xml;
# NOTE: `server_tokens off;` is intentionally NOT set here — it belongs in
# the http{} block of /etc/nginx/nginx.conf so it applies globally. Set it
# there once rather than duplicating it per-vhost.
# --- Site root ----------------------------------------------------------
# Serve the static status dashboard for now.
#
# LATER: when apps/web (Next.js) is deployed, switch this location from the
# static status page to a reverse proxy. Replace the try_files body with:
#
# proxy_pass http://127.0.0.1:3000;
# proxy_http_version 1.1;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection $connection_upgrade;
#
location / {
try_files $uri $uri/ /index.html;
}
# ------------------------------------------------------------------------
# REVERSE-PROXY BLOCKS — enable when apps are running
# ------------------------------------------------------------------------
# Uncomment the /api/ block below once apps/api (Fastify, port 4000) is up.
# The trailing slash on proxy_pass strips the /api/ prefix so the backend
# sees /scan, /receipt, etc.
#
# location /api/ {
# proxy_pass http://127.0.0.1:4000/;
# proxy_http_version 1.1;
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection $connection_upgrade;
# }
#
# The websocket Upgrade/Connection headers above rely on a $connection_upgrade
# map. Add this once in the http{} block of /etc/nginx/nginx.conf:
#
# map $http_upgrade $connection_upgrade {
# default upgrade;
# '' close;
# }
# ------------------------------------------------------------------------
}

38
infra/status/README.md Normal file
View File

@@ -0,0 +1,38 @@
# PYRE Status Dashboard
A static, self-contained dark "ember"-themed page the team uses to track PYRE MVP
progress. It is a **snapshot**, not live telemetry.
## Files
- **`status.json`** — the single source of truth. All content on the page
(phases, checklists, infra, dates, links) comes from here.
- **`index.html`** — the prebuilt rendered page. Committed so the page works even
before anyone runs the generator. Self-contained: inline CSS, no external
requests, no JS.
- **`README.md`** — this file.
## Editing & regenerating
1. Edit **`status.json`** — flip an item's `"done"` to `true`, update a phase
`"state"` (`todo` / `in_progress` / `done`), or bump `"updated"`.
2. Regenerate the page from the repo root:
```bash
node scripts/gen-status.mjs
```
The generator is dependency-free (plain Node ESM, no npm install). It reads
`infra/status/status.json` and rewrites `infra/status/index.html`, recomputing
the overall % complete from the item done-counts. It prints the output path
when finished.
3. Commit the regenerated `index.html` alongside the `status.json` change so the
prebuilt page stays consistent with the data.
## Deployment
The provision script deploys `infra/status/*` to `/var/www/feedthepyre/status`,
and nginx serves it as the site root (`feedthepyre.com`) until the real PYRE app
ships. Because `index.html` is prebuilt and self-contained, deployment is a plain
file copy — no build step or generator run is required on the server.

306
infra/status/index.html Normal file
View File

@@ -0,0 +1,306 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PYRE / Prometheus Protocol — Status Dashboard</title>
<style>
:root {
--bg: #0a0a0a;
--panel: #141210;
--panel-2: #1b1815;
--border: #2a241e;
--ember: #ff5a1f;
--ember-soft: #ff8a4f;
--text: #ede6df;
--muted: #9a8f84;
--ok: #6ad17a;
--todo: #6b6258;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: radial-gradient(1200px 600px at 50% -200px, #1a0f08 0%, var(--bg) 60%);
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: var(--ember-soft); text-decoration: none; }
a:hover { text-decoration: underline; }
.wrap { max-width: 1040px; margin: 0 auto; padding: 2.5rem 1.25rem 4rem; }
header.site {
border-bottom: 1px solid var(--border);
padding-bottom: 1.5rem;
margin-bottom: 2rem;
}
.title {
font-size: clamp(1.8rem, 5vw, 2.8rem);
margin: 0;
letter-spacing: 0.5px;
background: linear-gradient(90deg, var(--ember), var(--ember-soft));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.tagline { color: var(--muted); font-style: italic; margin: 0.4rem 0 1rem; }
.links a {
display: inline-block;
margin-right: 1rem;
font-size: 0.9rem;
border: 1px solid var(--border);
padding: 0.35rem 0.7rem;
border-radius: 6px;
background: var(--panel);
}
.links a:hover { border-color: var(--ember); text-decoration: none; }
.overall {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.25rem 1.5rem;
margin-bottom: 2rem;
}
.overall-head {
display: flex; justify-content: space-between; align-items: baseline;
margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.5rem;
}
.overall-head h2 { margin: 0; font-size: 1.05rem; letter-spacing: 1px; text-transform: uppercase; color: var(--muted); }
.overall-pct { font-size: 1.6rem; font-weight: 700; color: var(--ember); }
.bar {
height: 16px; width: 100%;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 999px; overflow: hidden;
}
.bar > span {
display: block; height: 100%;
background: linear-gradient(90deg, var(--ember), var(--ember-soft));
box-shadow: 0 0 18px rgba(255, 90, 31, 0.6);
}
h2.section {
font-size: 1rem; letter-spacing: 1.5px; text-transform: uppercase;
color: var(--muted); margin: 2.25rem 0 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-left: 3px solid var(--todo);
border-radius: 10px;
padding: 1.1rem 1.25rem;
}
.card.in_progress { border-left-color: var(--ember); }
.card.done { border-left-color: var(--ok); }
.card-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.75rem; }
.card-head h3 { margin: 0; font-size: 1.05rem; font-weight: 600; }
.phase-id { color: var(--ember); font-weight: 700; display: block; font-size: 0.75rem; letter-spacing: 1px; text-transform: uppercase; }
.badge {
flex: none;
font-size: 0.65rem; letter-spacing: 1px; font-weight: 700;
padding: 0.25rem 0.55rem; border-radius: 999px;
border: 1px solid var(--border); color: var(--muted);
text-transform: uppercase; white-space: nowrap;
}
.badge.in_progress { color: #0a0a0a; background: var(--ember); border-color: var(--ember); }
.badge.done { color: #0a0a0a; background: var(--ok); border-color: var(--ok); }
.badge.todo { color: var(--todo); }
.count { color: var(--muted); font-size: 0.8rem; margin: 0.5rem 0 0.75rem; }
ul.checklist { list-style: none; margin: 0; padding: 0; }
.item { display: flex; gap: 0.55rem; align-items: flex-start; padding: 0.2rem 0; font-size: 0.92rem; color: var(--muted); }
.item.done { color: var(--text); }
.item .mark { color: var(--todo); font-weight: 700; flex: none; width: 1rem; text-align: center; }
.item.done .mark { color: var(--ok); }
.infra-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem 1.5rem;
}
.infra-panel .count { margin-top: 0; }
.infra-grid { columns: 2; column-gap: 2rem; }
@media (max-width: 520px) { .infra-grid { columns: 1; } }
footer.site {
margin-top: 3rem; padding-top: 1.5rem;
border-top: 1px solid var(--border);
color: var(--muted); font-size: 0.85rem;
}
footer.site .disclaimer { color: var(--ember-soft); margin-top: 0.35rem; }
</style>
</head>
<body>
<div class="wrap">
<header class="site">
<h1 class="title">PYRE / Prometheus Protocol</h1>
<p class="tagline">Burn the dead. Feed the PYRE. Claim the Spawn.</p>
<nav class="links">
<a href="https://git.lumiai.dev/RogueWave/pyre">Repository</a>
<a href="https://feedthepyre.com">feedthepyre.com</a>
<a href="../../docs/PYRE_MVP_DESIGN.md">Design Doc</a>
</nav>
</header>
<section class="overall">
<div class="overall-head">
<h2>Overall MVP Progress</h2>
<span class="overall-pct">12%</span>
</div>
<div class="bar"><span style="width: 12%"></span></div>
<p class="count">6 of 50 phase deliverables complete</p>
</section>
<h2 class="section">Development Phases</h2>
<div class="grid">
<article class="card in_progress">
<header class="card-head">
<h3><span class="phase-id">Phase 0</span> Server &amp; Repo Setup</h3>
<span class="badge in_progress">IN PROGRESS</span>
</header>
<p class="count">6 / 8 complete</p>
<ul class="checklist">
<li class="item done"><span class="mark"></span><span>VPS configured (pyre user, SSH key, root disabled, UFW, Fail2ban)</span></li>
<li class="item done"><span class="mark"></span><span>Claude Code installed</span></li>
<li class="item done"><span class="mark"></span><span>Repo initialized</span></li>
<li class="item done"><span class="mark"></span><span>pnpm workspace created</span></li>
<li class="item done"><span class="mark"></span><span>web/api/worker skeleton</span></li>
<li class="item"><span class="mark"></span><span>Postgres + Redis running</span></li>
<li class="item"><span class="mark"></span><span>nginx configured</span></li>
<li class="item done"><span class="mark"></span><span>Environment templates</span></li>
</ul>
</article>
<article class="card todo">
<header class="card-head">
<h3><span class="phase-id">Phase 1</span> Wallet Scanner</h3>
<span class="badge todo">TODO</span>
</header>
<p class="count">0 / 6 complete</p>
<ul class="checklist">
<li class="item"><span class="mark"></span><span>Wallet connect frontend</span></li>
<li class="item"><span class="mark"></span><span>Scan endpoint</span></li>
<li class="item"><span class="mark"></span><span>Token account fetch</span></li>
<li class="item"><span class="mark"></span><span>Basic classification</span></li>
<li class="item"><span class="mark"></span><span>Scan results UI</span></li>
<li class="item"><span class="mark"></span><span>Protected/skipped UI</span></li>
</ul>
</article>
<article class="card todo">
<header class="card-head">
<h3><span class="phase-id">Phase 2</span> Close Empty ATAs</h3>
<span class="badge todo">TODO</span>
</header>
<p class="count">0 / 6 complete</p>
<ul class="checklist">
<li class="item"><span class="mark"></span><span>Identify empty token accounts</span></li>
<li class="item"><span class="mark"></span><span>Build close-account tx</span></li>
<li class="item"><span class="mark"></span><span>Decode tx preview</span></li>
<li class="item"><span class="mark"></span><span>Wallet signing</span></li>
<li class="item"><span class="mark"></span><span>Confirmation tracking</span></li>
<li class="item"><span class="mark"></span><span>Receipt page</span></li>
</ul>
</article>
<article class="card todo">
<header class="card-head">
<h3><span class="phase-id">Phase 3</span> Burn Junk</h3>
<span class="badge todo">TODO</span>
</header>
<p class="count">0 / 5 complete</p>
<ul class="checklist">
<li class="item"><span class="mark"></span><span>Incinerate-only classification</span></li>
<li class="item"><span class="mark"></span><span>Burn transaction builder</span></li>
<li class="item"><span class="mark"></span><span>Burn-then-close flow</span></li>
<li class="item"><span class="mark"></span><span>Stronger confirmations</span></li>
<li class="item"><span class="mark"></span><span>Receipt update</span></li>
</ul>
</article>
<article class="card todo">
<header class="card-head">
<h3><span class="phase-id">Phase 4</span> Prometheus Generator</h3>
<span class="badge todo">TODO</span>
</header>
<p class="count">0 / 6 complete</p>
<ul class="checklist">
<li class="item"><span class="mark"></span><span>Generation input from receipt</span></li>
<li class="item"><span class="mark"></span><span>Meta mixer</span></li>
<li class="item"><span class="mark"></span><span>Spawn name/ticker/lore generation</span></li>
<li class="item"><span class="mark"></span><span>Image prompt generation</span></li>
<li class="item"><span class="mark"></span><span>Safety checks</span></li>
<li class="item"><span class="mark"></span><span>Admin approval UI</span></li>
</ul>
</article>
<article class="card todo">
<header class="card-head">
<h3><span class="phase-id">Phase 5</span> Manual Pump.fun Launch Workflow</h3>
<span class="badge todo">TODO</span>
</header>
<p class="count">0 / 5 complete</p>
<ul class="checklist">
<li class="item"><span class="mark"></span><span>Approved Spawn package</span></li>
<li class="item"><span class="mark"></span><span>Metadata JSON</span></li>
<li class="item"><span class="mark"></span><span>Operator launch checklist</span></li>
<li class="item"><span class="mark"></span><span>Mint/url/tx record input</span></li>
<li class="item"><span class="mark"></span><span>Public Spawn record page</span></li>
</ul>
</article>
<article class="card todo">
<header class="card-head">
<h3><span class="phase-id">Phase 6</span> Essence / Round Prototype</h3>
<span class="badge todo">TODO</span>
</header>
<p class="count">0 / 6 complete</p>
<ul class="checklist">
<li class="item"><span class="mark"></span><span>Safe swap candidate detection</span></li>
<li class="item"><span class="mark"></span><span>Route quote preview</span></li>
<li class="item"><span class="mark"></span><span>Net Essence estimate</span></li>
<li class="item"><span class="mark"></span><span>Round dashboard</span></li>
<li class="item"><span class="mark"></span><span>Contribution database record</span></li>
<li class="item"><span class="mark"></span><span>No claim promises until on-chain logic exists</span></li>
</ul>
</article>
<article class="card todo">
<header class="card-head">
<h3><span class="phase-id">Phase 7</span> PYRE Core Program</h3>
<span class="badge todo">TODO</span>
</header>
<p class="count">0 / 8 complete</p>
<ul class="checklist">
<li class="item"><span class="mark"></span><span>Anchor program — create round</span></li>
<li class="item"><span class="mark"></span><span>Contribute Essence</span></li>
<li class="item"><span class="mark"></span><span>Contribution receipt PDA</span></li>
<li class="item"><span class="mark"></span><span>Lock round</span></li>
<li class="item"><span class="mark"></span><span>Register Spawn</span></li>
<li class="item"><span class="mark"></span><span>Claim Spawn</span></li>
<li class="item"><span class="mark"></span><span>Refund failed round</span></li>
<li class="item"><span class="mark"></span><span>Tests</span></li>
</ul>
</article>
</div>
<h2 class="section">Infrastructure</h2>
<div class="infra-panel">
<p class="count">6 / 11 provisioned</p>
<ul class="checklist infra-grid">
<li class="item done"><span class="mark"></span><span>Node.js 22</span></li>
<li class="item done"><span class="mark"></span><span>pnpm</span></li>
<li class="item done"><span class="mark"></span><span>Git + Gitea remote</span></li>
<li class="item done"><span class="mark"></span><span>DNS (feedthepyre.com)</span></li>
<li class="item done"><span class="mark"></span><span>Monorepo scaffold + docs</span></li>
<li class="item done"><span class="mark"></span><span>pnpm install + typecheck clean</span></li>
<li class="item"><span class="mark"></span><span>nginx</span></li>
<li class="item"><span class="mark"></span><span>PostgreSQL</span></li>
<li class="item"><span class="mark"></span><span>Redis</span></li>
<li class="item"><span class="mark"></span><span>PM2</span></li>
<li class="item"><span class="mark"></span><span>TLS (Let&#39;s Encrypt)</span></li>
</ul>
</div>
<footer class="site">
<div>Last updated: 2026-05-31</div>
<div class="disclaimer">Static snapshot generated from status.json — not live telemetry.</div>
</footer>
</div>
</body>
</html>

128
infra/status/status.json Normal file
View File

@@ -0,0 +1,128 @@
{
"project": "PYRE / Prometheus Protocol",
"tagline": "Burn the dead. Feed the PYRE. Claim the Spawn.",
"repo": "https://git.lumiai.dev/RogueWave/pyre",
"domain": "https://feedthepyre.com",
"updated": "2026-05-31",
"phases": [
{
"id": 0,
"name": "Server & Repo Setup",
"state": "in_progress",
"items": [
{ "label": "VPS configured (pyre user, SSH key, root disabled, UFW, Fail2ban)", "done": true },
{ "label": "Claude Code installed", "done": true },
{ "label": "Repo initialized", "done": true },
{ "label": "pnpm workspace created", "done": true },
{ "label": "web/api/worker skeleton", "done": true },
{ "label": "Postgres + Redis running", "done": false },
{ "label": "nginx configured", "done": false },
{ "label": "Environment templates", "done": true }
]
},
{
"id": 1,
"name": "Wallet Scanner",
"state": "todo",
"items": [
{ "label": "Wallet connect frontend", "done": false },
{ "label": "Scan endpoint", "done": false },
{ "label": "Token account fetch", "done": false },
{ "label": "Basic classification", "done": false },
{ "label": "Scan results UI", "done": false },
{ "label": "Protected/skipped UI", "done": false }
]
},
{
"id": 2,
"name": "Close Empty ATAs",
"state": "todo",
"items": [
{ "label": "Identify empty token accounts", "done": false },
{ "label": "Build close-account tx", "done": false },
{ "label": "Decode tx preview", "done": false },
{ "label": "Wallet signing", "done": false },
{ "label": "Confirmation tracking", "done": false },
{ "label": "Receipt page", "done": false }
]
},
{
"id": 3,
"name": "Burn Junk",
"state": "todo",
"items": [
{ "label": "Incinerate-only classification", "done": false },
{ "label": "Burn transaction builder", "done": false },
{ "label": "Burn-then-close flow", "done": false },
{ "label": "Stronger confirmations", "done": false },
{ "label": "Receipt update", "done": false }
]
},
{
"id": 4,
"name": "Prometheus Generator",
"state": "todo",
"items": [
{ "label": "Generation input from receipt", "done": false },
{ "label": "Meta mixer", "done": false },
{ "label": "Spawn name/ticker/lore generation", "done": false },
{ "label": "Image prompt generation", "done": false },
{ "label": "Safety checks", "done": false },
{ "label": "Admin approval UI", "done": false }
]
},
{
"id": 5,
"name": "Manual Pump.fun Launch Workflow",
"state": "todo",
"items": [
{ "label": "Approved Spawn package", "done": false },
{ "label": "Metadata JSON", "done": false },
{ "label": "Operator launch checklist", "done": false },
{ "label": "Mint/url/tx record input", "done": false },
{ "label": "Public Spawn record page", "done": false }
]
},
{
"id": 6,
"name": "Essence / Round Prototype",
"state": "todo",
"items": [
{ "label": "Safe swap candidate detection", "done": false },
{ "label": "Route quote preview", "done": false },
{ "label": "Net Essence estimate", "done": false },
{ "label": "Round dashboard", "done": false },
{ "label": "Contribution database record", "done": false },
{ "label": "No claim promises until on-chain logic exists", "done": false }
]
},
{
"id": 7,
"name": "PYRE Core Program",
"state": "todo",
"items": [
{ "label": "Anchor program — create round", "done": false },
{ "label": "Contribute Essence", "done": false },
{ "label": "Contribution receipt PDA", "done": false },
{ "label": "Lock round", "done": false },
{ "label": "Register Spawn", "done": false },
{ "label": "Claim Spawn", "done": false },
{ "label": "Refund failed round", "done": false },
{ "label": "Tests", "done": false }
]
}
],
"infra": [
{ "label": "Node.js 22", "done": true },
{ "label": "pnpm", "done": true },
{ "label": "Git + Gitea remote", "done": true },
{ "label": "DNS (feedthepyre.com)", "done": true },
{ "label": "Monorepo scaffold + docs", "done": true },
{ "label": "pnpm install + typecheck clean", "done": true },
{ "label": "nginx", "done": false },
{ "label": "PostgreSQL", "done": false },
{ "label": "Redis", "done": false },
{ "label": "PM2", "done": false },
{ "label": "TLS (Let's Encrypt)", "done": false }
]
}

View File

@@ -0,0 +1,40 @@
# PYRE / Prometheus Protocol — systemd unit to resurrect PM2 on boot.
#
# INERT until apps are deployed AND `pm2 save` has been run at least once:
# `pm2 resurrect` only restores processes from the saved dump
# (~/.pm2/dump.pm2). Without that dump it starts nothing.
#
# Install (run as a privileged user, once):
# sudo cp /home/pyre/pyre/infra/systemd/pm2-pyre.service /etc/systemd/system/pm2-pyre.service
# sudo systemctl daemon-reload
# sudo systemctl enable pm2-pyre
#
# Then, after the apps are built and started:
# pm2 start /home/pyre/pyre/ecosystem.config.cjs
# pm2 save # <-- REQUIRED: writes the dump that resurrect reads
#
# This unit runs PM2 as the unprivileged `pyre` user (no root daemon).
[Unit]
Description=PM2 process manager for PYRE (user pyre)
Documentation=https://pm2.keymetrics.io/
After=network-online.target postgresql.service redis-server.service
Wants=network-online.target
[Service]
Type=forking
User=pyre
LimitNOFILE=infinity
LimitNPROC=infinity
LimitCORE=infinity
Environment=PATH=/home/pyre/.local/share/pnpm:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
Environment=PM2_HOME=/home/pyre/.pm2
PIDFile=/home/pyre/.pm2/pm2.pid
Restart=on-failure
ExecStart=/home/pyre/.local/share/pnpm/bin/pm2 resurrect
ExecReload=/home/pyre/.local/share/pnpm/bin/pm2 reload all
ExecStop=/home/pyre/.local/share/pnpm/bin/pm2 kill
[Install]
WantedBy=multi-user.target

102
scripts/README.md Normal file
View File

@@ -0,0 +1,102 @@
# PYRE — `scripts/`
Operational scripts for the PYRE VPS (Ubuntu 24.04). These cover **Phase 0**
(server + repo setup) per `docs/PYRE_MVP_DESIGN.md` §12, §18, §19.
| Script | Purpose | Who runs it |
| --- | --- | --- |
| [`phase0-provision.sh`](./phase0-provision.sh) | Idempotent **root** provisioning: nginx, PostgreSQL, Redis, certbot/TLS, UFW, systemd pm2 unit, logrotate, status page. | root (`sudo`) |
| `gen-status.mjs` | Generates the public `status.json` for the status page. *(Authored by another agent — see that file's header for usage.)* | `pyre` user |
| `backup.sh` | Database / config backup routine. *(Authored by another agent — see that file's header for usage.)* | `pyre` user / cron |
---
## `phase0-provision.sh`
Provisions all **system-level** services PYRE depends on. It is
**idempotent and safe to re-run** — every step checks current state before
changing anything.
### What it does NOT do
- Does **not** install Node.js 22, pnpm, the repo, or app `.env` files
(those are handled separately / by the `pyre` user).
- Does **not** run a Solana validator/RPC node or any local LLM/image model
(explicitly out of scope per design §11/§12).
- Does **not** change Postgres `listen_addresses` or expose Redis — both stay
**localhost-only**.
- Does **not** store any wallet private key. PYRE never holds keys; there is
intentionally no key/mnemonic variable anywhere.
### Steps
1. `apt-get update` + install `nginx postgresql postgresql-contrib redis-server certbot python3-certbot-nginx ufw`.
2. Enable + start `postgresql` and `redis-server`.
3. Create Postgres role `pyre` (LOGIN, NOCREATEDB) and database `pyre` owned by it — only if absent. Matches `DATABASE_URL=postgresql://pyre:pyre@localhost:5432/pyre` from `.env.example`.
4. Harden Redis: `bind 127.0.0.1 ::1` and `maxmemory-policy noeviction`, then restart.
5. UFW: allow `22`, `2222`, `80`, `443` **before** enabling the firewall.
6. Install the nginx vhost, remove the default site, validate, reload.
7. Create the status-page webroot and copy in `index.html` / `status.json`.
8. Obtain/renew the TLS certificate via certbot (nginx plugin), with redirect.
9. Install the `pm2-pyre` systemd unit (enable, don't start) and the logrotate config.
10. Print versions, service statuses, a checklist, and a reminder to set real secrets.
### Prerequisites
- Ubuntu 24.04 with the base setup already done (`pyre` user, SSH key auth,
root login disabled, Fail2ban) — per design §12.
- The repo present at **`/home/pyre/pyre`**, owned by `pyre`.
- The `infra/` config sources present (authored by other agents):
- `infra/nginx/feedthepyre.com.conf`
- `infra/status/index.html`, `infra/status/status.json`
- `infra/systemd/pm2-pyre.service`
- `infra/logrotate/pyre`
- If any are missing the script **warns and continues** rather than failing,
so you can re-run it once the sources exist.
- **DNS must already point `feedthepyre.com` and `www.feedthepyre.com` at this
box** before the TLS step can succeed. If DNS isn't live yet, certbot is
skipped gracefully — just re-run the script later.
### How to run
```bash
# Default (dev) — uses DB password "pyre" and the built-in certbot email:
sudo bash scripts/phase0-provision.sh
```
It must run as **root**. If you forget `sudo`, it re-execs itself under `sudo`
(or errors with instructions if `sudo` is unavailable).
### Overridable environment variables
| Variable | Default | Notes |
| --- | --- | --- |
| `PYRE_DB_PASSWORD` | `pyre` | Postgres password for role `pyre`. The default is **dev-only**; the script prints a loud warning when it's used. Set a real one for production. Only applied when the role is first created — re-runs do not change an existing role's password. |
| `CERTBOT_EMAIL` | `a31s15.roguewave@gmail.com` | Let's Encrypt registration / expiry-notice email. |
Pass overrides via `sudo -E` so the environment is preserved:
```bash
PYRE_DB_PASSWORD='a-strong-secret' \
CERTBOT_EMAIL='ops@feedthepyre.com' \
sudo -E bash scripts/phase0-provision.sh
```
If you set a non-default `PYRE_DB_PASSWORD`, update `DATABASE_URL` in the
per-app `.env` files to match.
### ⚠️ UFW / SSH warning
This script runs **over SSH** and enables the firewall. It deliberately allows
the SSH ports **before** enabling UFW:
- **`22/tcp` (system SSH)** and **`2222/tcp` (Gitea git-SSH)** MUST stay open.
If you remove these allow rules, you will **permanently lose remote access**
to the box. The script never removes existing allow rules; if you edit UFW
by hand, keep 22 and 2222 open.
### After provisioning
Set **real secrets** in the per-app `.env` files (copy from `.env.example`):
`DATABASE_URL`, `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / `IMAGE_GEN_API_KEY`,
`ADMIN_API_TOKEN`, `SOLANA_RPC_URL`, etc. Never commit a real `.env`.

48
scripts/backup.sh Normal file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
#
# PYRE / Prometheus Protocol — PostgreSQL backup script.
#
# INERT until the `pyre` database exists and has data. Until then a run will
# simply fail at pg_dump (no DB), which is harmless — nothing is deleted unless
# pg_dump succeeds.
#
# What it does:
# - dumps the `pyre` database (gzip) to /home/pyre/backups/
# - prunes backups older than 14 days
# Idempotent and safe to run from cron.
#
# DATABASE_URL is read from the environment if set, otherwise defaults to the
# local pyre DB. Do NOT hardcode real passwords here — for non-trivial passwords
# prefer a ~/.pgpass file (chmod 600) so the URL can omit the password.
#
# Example crontab (run as the `pyre` user, daily at 03:30):
# 30 3 * * * /home/pyre/pyre/scripts/backup.sh >> /home/pyre/pyre/logs/backup.log 2>&1
set -euo pipefail
DATABASE_URL="${DATABASE_URL:-postgresql://pyre:pyre@localhost:5432/pyre}"
BACKUP_DIR="${BACKUP_DIR:-/home/pyre/backups}"
RETENTION_DAYS="${RETENTION_DAYS:-14}"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
OUTFILE="${BACKUP_DIR}/pyre-${TIMESTAMP}.sql.gz"
mkdir -p "${BACKUP_DIR}"
echo "[backup] $(date -Is) dumping database -> ${OUTFILE}"
# --no-owner / --no-acl keeps the dump portable across roles when restoring.
# Write to a temp file first, then atomically rename, so cron never prunes or
# leaves behind a half-written archive.
TMPFILE="${OUTFILE}.partial"
pg_dump --no-owner --no-acl --dbname="${DATABASE_URL}" | gzip -c > "${TMPFILE}"
mv "${TMPFILE}" "${OUTFILE}"
echo "[backup] $(date -Is) wrote $(du -h "${OUTFILE}" | cut -f1) backup"
# Prune backups older than RETENTION_DAYS (only PYRE dumps).
echo "[backup] $(date -Is) pruning backups older than ${RETENTION_DAYS} days"
find "${BACKUP_DIR}" -maxdepth 1 -type f -name 'pyre-*.sql.gz' \
-mtime "+${RETENTION_DAYS}" -print -delete || true
echo "[backup] $(date -Is) done"

256
scripts/gen-status.mjs Normal file
View File

@@ -0,0 +1,256 @@
#!/usr/bin/env node
// PYRE status dashboard generator.
// Dependency-free: reads ../infra/status/status.json (relative to this file)
// and renders ../infra/status/index.html.
//
// Usage (from repo root): node scripts/gen-status.mjs
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const dataPath = resolve(__dirname, "../infra/status/status.json");
const outPath = resolve(__dirname, "../infra/status/index.html");
/** Escape a string for safe insertion into HTML text/attribute context. */
function esc(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
const data = JSON.parse(readFileSync(dataPath, "utf8"));
// --- compute overall % complete from item done-counts ---
let totalItems = 0;
let doneItems = 0;
for (const phase of data.phases) {
for (const item of phase.items) {
totalItems += 1;
if (item.done) doneItems += 1;
}
}
const overallPct = totalItems === 0 ? 0 : Math.round((doneItems / totalItems) * 100);
// --- state badge helpers ---
const STATE_LABEL = {
done: "DONE",
in_progress: "IN PROGRESS",
todo: "TODO",
};
function stateLabel(state) {
return STATE_LABEL[state] || String(state).toUpperCase();
}
function renderItems(items) {
return items
.map((item) => {
const cls = item.done ? "item done" : "item";
const mark = item.done ? "✓" : "○"; // ✓ / ○
return ` <li class="${cls}"><span class="mark">${mark}</span><span>${esc(item.label)}</span></li>`;
})
.join("\n");
}
function renderPhase(phase) {
const doneCount = phase.items.filter((i) => i.done).length;
const stateClass = esc(phase.state);
return ` <article class="card ${stateClass}">
<header class="card-head">
<h3><span class="phase-id">Phase ${esc(phase.id)}</span> ${esc(phase.name)}</h3>
<span class="badge ${stateClass}">${esc(stateLabel(phase.state))}</span>
</header>
<p class="count">${doneCount} / ${phase.items.length} complete</p>
<ul class="checklist">
${renderItems(phase.items)}
</ul>
</article>`;
}
const phasesHtml = data.phases.map(renderPhase).join("\n");
const infraHtml = renderItems(data.infra);
const infraDone = data.infra.filter((i) => i.done).length;
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${esc(data.project)} — Status Dashboard</title>
<style>
:root {
--bg: #0a0a0a;
--panel: #141210;
--panel-2: #1b1815;
--border: #2a241e;
--ember: #ff5a1f;
--ember-soft: #ff8a4f;
--text: #ede6df;
--muted: #9a8f84;
--ok: #6ad17a;
--todo: #6b6258;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: radial-gradient(1200px 600px at 50% -200px, #1a0f08 0%, var(--bg) 60%);
color: var(--text);
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: var(--ember-soft); text-decoration: none; }
a:hover { text-decoration: underline; }
.wrap { max-width: 1040px; margin: 0 auto; padding: 2.5rem 1.25rem 4rem; }
header.site {
border-bottom: 1px solid var(--border);
padding-bottom: 1.5rem;
margin-bottom: 2rem;
}
.title {
font-size: clamp(1.8rem, 5vw, 2.8rem);
margin: 0;
letter-spacing: 0.5px;
background: linear-gradient(90deg, var(--ember), var(--ember-soft));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.tagline { color: var(--muted); font-style: italic; margin: 0.4rem 0 1rem; }
.links a {
display: inline-block;
margin-right: 1rem;
font-size: 0.9rem;
border: 1px solid var(--border);
padding: 0.35rem 0.7rem;
border-radius: 6px;
background: var(--panel);
}
.links a:hover { border-color: var(--ember); text-decoration: none; }
.overall {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 1.25rem 1.5rem;
margin-bottom: 2rem;
}
.overall-head {
display: flex; justify-content: space-between; align-items: baseline;
margin-bottom: 0.75rem; flex-wrap: wrap; gap: 0.5rem;
}
.overall-head h2 { margin: 0; font-size: 1.05rem; letter-spacing: 1px; text-transform: uppercase; color: var(--muted); }
.overall-pct { font-size: 1.6rem; font-weight: 700; color: var(--ember); }
.bar {
height: 16px; width: 100%;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 999px; overflow: hidden;
}
.bar > span {
display: block; height: 100%;
background: linear-gradient(90deg, var(--ember), var(--ember-soft));
box-shadow: 0 0 18px rgba(255, 90, 31, 0.6);
}
h2.section {
font-size: 1rem; letter-spacing: 1.5px; text-transform: uppercase;
color: var(--muted); margin: 2.25rem 0 1rem;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 1rem;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-left: 3px solid var(--todo);
border-radius: 10px;
padding: 1.1rem 1.25rem;
}
.card.in_progress { border-left-color: var(--ember); }
.card.done { border-left-color: var(--ok); }
.card-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 0.75rem; }
.card-head h3 { margin: 0; font-size: 1.05rem; font-weight: 600; }
.phase-id { color: var(--ember); font-weight: 700; display: block; font-size: 0.75rem; letter-spacing: 1px; text-transform: uppercase; }
.badge {
flex: none;
font-size: 0.65rem; letter-spacing: 1px; font-weight: 700;
padding: 0.25rem 0.55rem; border-radius: 999px;
border: 1px solid var(--border); color: var(--muted);
text-transform: uppercase; white-space: nowrap;
}
.badge.in_progress { color: #0a0a0a; background: var(--ember); border-color: var(--ember); }
.badge.done { color: #0a0a0a; background: var(--ok); border-color: var(--ok); }
.badge.todo { color: var(--todo); }
.count { color: var(--muted); font-size: 0.8rem; margin: 0.5rem 0 0.75rem; }
ul.checklist { list-style: none; margin: 0; padding: 0; }
.item { display: flex; gap: 0.55rem; align-items: flex-start; padding: 0.2rem 0; font-size: 0.92rem; color: var(--muted); }
.item.done { color: var(--text); }
.item .mark { color: var(--todo); font-weight: 700; flex: none; width: 1rem; text-align: center; }
.item.done .mark { color: var(--ok); }
.infra-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem 1.5rem;
}
.infra-panel .count { margin-top: 0; }
.infra-grid { columns: 2; column-gap: 2rem; }
@media (max-width: 520px) { .infra-grid { columns: 1; } }
footer.site {
margin-top: 3rem; padding-top: 1.5rem;
border-top: 1px solid var(--border);
color: var(--muted); font-size: 0.85rem;
}
footer.site .disclaimer { color: var(--ember-soft); margin-top: 0.35rem; }
</style>
</head>
<body>
<div class="wrap">
<header class="site">
<h1 class="title">${esc(data.project)}</h1>
<p class="tagline">${esc(data.tagline)}</p>
<nav class="links">
<a href="${esc(data.repo)}">Repository</a>
<a href="${esc(data.domain)}">${esc(data.domain.replace(/^https?:\/\//, ""))}</a>
<a href="../../docs/PYRE_MVP_DESIGN.md">Design Doc</a>
</nav>
</header>
<section class="overall">
<div class="overall-head">
<h2>Overall MVP Progress</h2>
<span class="overall-pct">${overallPct}%</span>
</div>
<div class="bar"><span style="width: ${overallPct}%"></span></div>
<p class="count">${doneItems} of ${totalItems} phase deliverables complete</p>
</section>
<h2 class="section">Development Phases</h2>
<div class="grid">
${phasesHtml}
</div>
<h2 class="section">Infrastructure</h2>
<div class="infra-panel">
<p class="count">${infraDone} / ${data.infra.length} provisioned</p>
<ul class="checklist infra-grid">
${infraHtml}
</ul>
</div>
<footer class="site">
<div>Last updated: ${esc(data.updated)}</div>
<div class="disclaimer">Static snapshot generated from status.json — not live telemetry.</div>
</footer>
</div>
</body>
</html>
`;
writeFileSync(outPath, html, "utf8");
console.log(`Wrote ${outPath} (${overallPct}% complete, ${doneItems}/${totalItems} items)`);

394
scripts/phase0-provision.sh Executable file
View File

@@ -0,0 +1,394 @@
#!/usr/bin/env bash
# =============================================================================
# PYRE — Phase 0 root provisioning script (Ubuntu 24.04)
# =============================================================================
# Idempotent, re-runnable. Provisions the system services PYRE needs:
# nginx, PostgreSQL, Redis, certbot (TLS), UFW firewall, systemd unit for
# pm2, logrotate, and the public status page.
#
# This script ONLY touches system-level config. It does NOT install Node.js,
# pnpm, the repo, or app .env files (those are handled elsewhere / by the
# pyre user). It must be run as root.
#
# Usage: sudo bash scripts/phase0-provision.sh
#
# Overridable environment variables:
# PYRE_DB_PASSWORD Postgres password for role 'pyre' (default: "pyre")
# CERTBOT_EMAIL email for Let's Encrypt (default below)
#
# Server base setup (pyre user, SSH key auth, root disabled, Fail2ban) is
# assumed ALREADY DONE per design doc §12/§19. DNS for feedthepyre.com and
# www.feedthepyre.com must already point at this box (required for TLS step).
# =============================================================================
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
# -----------------------------------------------------------------------------
# Path / value contracts — other agents depend on these EXACT values.
# -----------------------------------------------------------------------------
PYRE_USER="pyre"
REPO_DIR="/home/pyre/pyre"
# Postgres — matches DATABASE_URL=postgresql://pyre:pyre@localhost:5432/pyre
PYRE_DB_ROLE="pyre"
PYRE_DB_NAME="pyre"
PYRE_DB_PASSWORD="${PYRE_DB_PASSWORD:-pyre}" # dev-default; flagged loudly below
# Domain / TLS
DOMAIN="feedthepyre.com"
WWW_DOMAIN="www.feedthepyre.com"
CERTBOT_EMAIL="${CERTBOT_EMAIL:-a31s15.roguewave@gmail.com}"
# nginx vhost
NGINX_VHOST_SRC="${REPO_DIR}/infra/nginx/${DOMAIN}.conf"
NGINX_VHOST_AVAIL="/etc/nginx/sites-available/${DOMAIN}"
NGINX_VHOST_ENABLED="/etc/nginx/sites-enabled/${DOMAIN}"
NGINX_DEFAULT_ENABLED="/etc/nginx/sites-enabled/default"
# Status page
STATUS_SRC_DIR="${REPO_DIR}/infra/status"
STATUS_WEBROOT="/var/www/feedthepyre/status"
STATUS_WEBROOT_PARENT="/var/www/feedthepyre"
# systemd / logrotate sources
SYSTEMD_UNIT_SRC="${REPO_DIR}/infra/systemd/pm2-pyre.service"
SYSTEMD_UNIT_DST="/etc/systemd/system/pm2-pyre.service"
LOGROTATE_SRC="${REPO_DIR}/infra/logrotate/pyre"
LOGROTATE_DST="/etc/logrotate.d/pyre"
REDIS_CONF="/etc/redis/redis.conf"
# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
section() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
info() { printf ' %s\n' "$*"; }
warn() { printf '\033[1;33m[WARN] %s\033[0m\n' "$*" >&2; }
err() { printf '\033[1;31m[ERROR] %s\033[0m\n' "$*" >&2; }
# -----------------------------------------------------------------------------
# Root check — re-exec under sudo if possible, else fail with instructions.
# -----------------------------------------------------------------------------
require_root() {
if [[ "${EUID}" -eq 0 ]]; then
return 0
fi
if command -v sudo >/dev/null 2>&1; then
warn "Not running as root — re-executing under sudo (you may be prompted for a password)..."
exec sudo -E bash "$0" "$@"
fi
err "This script must be run as root, and 'sudo' was not found."
err "Re-run as: sudo bash ${0}"
exit 1
}
# -----------------------------------------------------------------------------
# 1. APT update + install base packages (apt install is idempotent).
# -----------------------------------------------------------------------------
step_apt() {
section "1/10 apt-get update + install base packages"
apt-get update -y
apt-get install -y \
nginx \
postgresql \
postgresql-contrib \
redis-server \
certbot \
python3-certbot-nginx \
ufw
info "Base packages installed (nginx, postgresql(+contrib), redis-server, certbot, python3-certbot-nginx, ufw)."
}
# -----------------------------------------------------------------------------
# 2. Enable + start postgresql and redis-server.
# -----------------------------------------------------------------------------
step_services() {
section "2/10 Enable + start postgresql and redis-server"
systemctl enable --now postgresql
systemctl enable --now redis-server
info "postgresql: $(systemctl is-active postgresql 2>/dev/null || true)"
info "redis-server: $(systemctl is-active redis-server 2>/dev/null || true)"
}
# -----------------------------------------------------------------------------
# 3. Postgres role + database (idempotent; localhost-only, do NOT change
# listen_addresses).
# -----------------------------------------------------------------------------
step_postgres() {
section "3/10 PostgreSQL role + database"
if [[ "${PYRE_DB_PASSWORD}" == "pyre" ]]; then
warn "PYRE_DB_PASSWORD is the dev DEFAULT ('pyre'). This is fine for local/dev,"
warn "but set a real password for production: PYRE_DB_PASSWORD=... sudo -E bash $0"
fi
# Create role only if absent. LOGIN, no CREATEDB (db is created here as owner).
local role_exists
role_exists="$(sudo -u postgres psql -tAc \
"SELECT 1 FROM pg_roles WHERE rolname='${PYRE_DB_ROLE}'" || true)"
if [[ "${role_exists}" == "1" ]]; then
info "Role '${PYRE_DB_ROLE}' already exists — leaving it as-is (not changing password)."
else
info "Creating role '${PYRE_DB_ROLE}' (LOGIN, NOCREATEDB)."
# Password is passed via a parameter to avoid SQL-injection / quoting issues.
sudo -u postgres psql -v ON_ERROR_STOP=1 \
-v pw="${PYRE_DB_PASSWORD}" <<'SQL'
CREATE ROLE pyre WITH LOGIN NOCREATEDB NOSUPERUSER NOCREATEROLE PASSWORD :'pw';
SQL
fi
# Create database owned by pyre only if absent.
local db_exists
db_exists="$(sudo -u postgres psql -tAc \
"SELECT 1 FROM pg_database WHERE datname='${PYRE_DB_NAME}'" || true)"
if [[ "${db_exists}" == "1" ]]; then
info "Database '${PYRE_DB_NAME}' already exists — leaving it as-is."
else
info "Creating database '${PYRE_DB_NAME}' owned by '${PYRE_DB_ROLE}'."
sudo -u postgres createdb -O "${PYRE_DB_ROLE}" "${PYRE_DB_NAME}"
fi
info "DATABASE_URL should be: postgresql://${PYRE_DB_ROLE}:<password>@localhost:5432/${PYRE_DB_NAME}"
info "(listen_addresses left untouched — PostgreSQL stays localhost-only.)"
}
# -----------------------------------------------------------------------------
# 4. Redis — bind localhost-only + noeviction. Never expose publicly.
# -----------------------------------------------------------------------------
ensure_conf_line() {
# ensure_conf_line <file> <key-regex> <desired-line>
# If a line matching the key exists (commented or not), replace it.
# Otherwise append the desired line. Idempotent.
local file="$1" key="$2" line="$3"
if grep -Eq "^[#[:space:]]*${key}" "${file}"; then
# Replace any existing (active or commented) occurrence with the desired line.
sed -i -E "s|^[#[:space:]]*${key}.*|${line}|" "${file}"
else
printf '%s\n' "${line}" >>"${file}"
fi
}
step_redis() {
section "4/10 Redis hardening (localhost-only, noeviction)"
if [[ ! -f "${REDIS_CONF}" ]]; then
err "Redis config not found at ${REDIS_CONF} — is redis-server installed?"
return 1
fi
# Bind to loopback only (IPv4 + IPv6). NEVER expose Redis to the public net.
ensure_conf_line "${REDIS_CONF}" "bind" "bind 127.0.0.1 ::1"
# PYRE uses Redis as a durable job queue (BullMQ); do not silently evict keys.
ensure_conf_line "${REDIS_CONF}" "maxmemory-policy" "maxmemory-policy noeviction"
systemctl restart redis-server
info "Redis bound to 127.0.0.1/::1 with maxmemory-policy noeviction; restarted."
}
# -----------------------------------------------------------------------------
# 5. UFW firewall.
# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
# !! THIS SCRIPT RUNS OVER SSH. Ports 22 (system SSH) and 2222 (Gitea !!
# !! git-SSH) MUST stay open, or we permanently lose remote access to !!
# !! the box. Allow rules are added BEFORE enabling the firewall. !!
# !! Never remove existing allow rules here. !!
# !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
# -----------------------------------------------------------------------------
step_ufw() {
section "5/10 UFW firewall (open SSH BEFORE enabling)"
# Open required ports first. 'ufw allow' is idempotent (re-adding is a no-op).
ufw allow 22/tcp comment 'system SSH - MUST stay open'
ufw allow 2222/tcp comment 'Gitea git-SSH - MUST stay open'
ufw allow 80/tcp comment 'HTTP (nginx / ACME)'
ufw allow 443/tcp comment 'HTTPS (nginx)'
# Enable only if currently inactive so we never toggle/reset an active firewall.
if ufw status 2>/dev/null | grep -q "Status: active"; then
info "UFW already active — left enabled, rules ensured."
else
warn "Enabling UFW now. SSH (22) and Gitea (2222) are already allowed above."
ufw --force enable
fi
ufw status verbose || true
}
# -----------------------------------------------------------------------------
# 6. nginx vhost.
# -----------------------------------------------------------------------------
step_nginx() {
section "6/10 nginx vhost for ${DOMAIN}"
if [[ ! -f "${NGINX_VHOST_SRC}" ]]; then
err "nginx vhost source not found: ${NGINX_VHOST_SRC}"
err "(It is authored under infra/nginx/ by another agent. Skipping nginx config.)"
return 1
fi
install -m 0644 "${NGINX_VHOST_SRC}" "${NGINX_VHOST_AVAIL}"
ln -sfn "${NGINX_VHOST_AVAIL}" "${NGINX_VHOST_ENABLED}"
info "Installed vhost -> ${NGINX_VHOST_AVAIL} and symlinked into sites-enabled."
# Remove the stock default site if present.
if [[ -e "${NGINX_DEFAULT_ENABLED}" || -L "${NGINX_DEFAULT_ENABLED}" ]]; then
rm -f "${NGINX_DEFAULT_ENABLED}"
info "Removed default enabled site (${NGINX_DEFAULT_ENABLED})."
fi
nginx -t
systemctl reload nginx
info "nginx config valid; reloaded."
}
# -----------------------------------------------------------------------------
# 7. Status page webroot.
# -----------------------------------------------------------------------------
step_status_page() {
section "7/10 Status page webroot (${STATUS_WEBROOT})"
install -d -m 0755 "${STATUS_WEBROOT}"
local copied=0
local f
for f in index.html status.json; do
if [[ -f "${STATUS_SRC_DIR}/${f}" ]]; then
install -m 0644 "${STATUS_SRC_DIR}/${f}" "${STATUS_WEBROOT}/${f}"
copied=$((copied + 1))
else
warn "Status source missing: ${STATUS_SRC_DIR}/${f} (authored by another agent)."
fi
done
chown -R www-data:www-data "${STATUS_WEBROOT_PARENT}"
info "Status webroot ready; copied ${copied} file(s); chowned to www-data."
}
# -----------------------------------------------------------------------------
# 8. TLS via certbot (nginx plugin). Re-run-safe.
# -----------------------------------------------------------------------------
step_tls() {
section "8/10 TLS certificate via certbot (${DOMAIN}, ${WWW_DOMAIN})"
if ! command -v certbot >/dev/null 2>&1; then
err "certbot not found — skipping TLS."
return 1
fi
# certbot --nginx is re-run-safe: if a cert already covers these domains it
# will reuse/expand rather than fail. --non-interactive prevents prompts.
if certbot --nginx \
-d "${DOMAIN}" -d "${WWW_DOMAIN}" \
--non-interactive --agree-tos \
-m "${CERTBOT_EMAIL}" \
--redirect \
--keep-until-expiring; then
info "certbot succeeded (cert obtained or already present)."
systemctl reload nginx
info "nginx reloaded with TLS."
else
warn "certbot did not complete successfully. This is usually because DNS for"
warn "${DOMAIN}/${WWW_DOMAIN} is not yet pointing at this box, or port 80 is"
warn "unreachable. Provisioning continues; re-run this script after DNS is live."
fi
}
# -----------------------------------------------------------------------------
# 9. systemd unit (pm2) + logrotate.
# -----------------------------------------------------------------------------
step_systemd_logrotate() {
section "9/10 systemd unit (pm2-pyre) + logrotate"
# App log directory — pm2 processes and scripts/backup.sh write here, and
# infra/logrotate/pyre rotates it. Created up-front so those don't fail.
install -d -m 0755 -o "${PYRE_USER}" -g "${PYRE_USER}" "${REPO_DIR}/logs"
info "Ensured app log dir ${REPO_DIR}/logs (owner ${PYRE_USER})."
# --- systemd pm2 unit ---
if [[ -f "${SYSTEMD_UNIT_SRC}" ]]; then
install -m 0644 "${SYSTEMD_UNIT_SRC}" "${SYSTEMD_UNIT_DST}"
systemctl daemon-reload
# Enable so it starts on boot. Do NOT fail if pm2 currently has no apps:
# 'enable' only registers the unit; we deliberately do not start it here.
if systemctl enable pm2-pyre.service 2>/dev/null; then
info "Installed + enabled pm2-pyre.service (not started here; ok if pm2 has no apps yet)."
else
warn "Could not enable pm2-pyre.service (continuing; check the unit later)."
fi
else
warn "systemd unit source missing: ${SYSTEMD_UNIT_SRC} (authored by another agent)."
fi
# --- logrotate ---
if [[ -f "${LOGROTATE_SRC}" ]]; then
install -m 0644 "${LOGROTATE_SRC}" "${LOGROTATE_DST}"
info "Installed logrotate config -> ${LOGROTATE_DST}."
else
warn "logrotate source missing: ${LOGROTATE_SRC} (authored by another agent)."
fi
}
# -----------------------------------------------------------------------------
# 10. Final summary.
# -----------------------------------------------------------------------------
svc_status() { systemctl is-active "$1" 2>/dev/null || echo "unknown"; }
step_summary() {
section "10/10 Summary"
echo " Versions:"
command -v nginx >/dev/null 2>&1 && info "nginx: $(nginx -v 2>&1)"
command -v psql >/dev/null 2>&1 && info "postgres: $(psql --version 2>/dev/null)"
command -v redis-server >/dev/null 2>&1 && info "redis-server: $(redis-server --version 2>/dev/null)"
command -v certbot >/dev/null 2>&1 && info "certbot: $(certbot --version 2>&1)"
echo
echo " Service status:"
info "postgresql: $(svc_status postgresql)"
info "redis-server: $(svc_status redis-server)"
info "nginx: $(svc_status nginx)"
info "ufw: $(ufw status 2>/dev/null | head -n1 || echo unknown)"
info "pm2-pyre: enabled=$(systemctl is-enabled pm2-pyre.service 2>/dev/null || echo no) active=$(svc_status pm2-pyre)"
echo
echo " Checklist (this run):"
info "[x] base packages installed (nginx, postgres, redis, certbot, ufw)"
info "[x] postgresql + redis-server enabled & started"
info "[x] postgres role '${PYRE_DB_ROLE}' + db '${PYRE_DB_NAME}' ensured (localhost-only)"
info "[x] redis bound to 127.0.0.1/::1, maxmemory-policy noeviction"
info "[x] UFW: 22, 2222, 80, 443 allowed (22 & 2222 MUST stay open)"
info "[x] nginx vhost installed + default site removed (if sources present)"
info "[x] status page webroot ${STATUS_WEBROOT} (if sources present)"
info "[x] TLS via certbot (skipped gracefully if DNS not ready)"
info "[x] pm2-pyre.service + logrotate installed (if sources present)"
echo
warn "REMINDER: set REAL secrets in the per-app .env files (copy from .env.example):"
warn " - DATABASE_URL (with the real PYRE_DB_PASSWORD if you changed it)"
warn " - ANTHROPIC_API_KEY / OPENAI_API_KEY / IMAGE_GEN_API_KEY"
warn " - ADMIN_API_TOKEN, SOLANA_RPC_URL, etc."
warn " PYRE never stores wallet private keys — there is intentionally no key var."
echo
info "Done. Safe to re-run this script any time."
}
# -----------------------------------------------------------------------------
# Main
# -----------------------------------------------------------------------------
main() {
require_root "$@"
section "PYRE Phase 0 provisioning — $(date -u '+%Y-%m-%dT%H:%M:%SZ')"
info "Repo: ${REPO_DIR} (owner: ${PYRE_USER}) Domain: ${DOMAIN}"
step_apt
step_services
step_postgres
step_redis
step_ufw
step_nginx
step_status_page
step_tls
step_systemd_logrotate
step_summary
}
main "$@"