[object Object]

← back to Small Business Builder

deploy: ship smb-builder to Kamatera + capture full schema in migrations 002-008

c6fa78502ffe4bd9ce4a7783f3c6db6813f676de · 2026-05-04 21:56:41 -0700 · SteveStudio2

migrations 002-008 capture all tables that drifted into local DB ad-hoc.
All use IF NOT EXISTS so re-running on a populated DB is a no-op.
Validated end-to-end against a fresh smb_migration_test DB -> 13 tables, all clean.

scripts/deploy-kamatera.sh: idempotent 6-step rsync->createdb->migrate->.env->pm2.
ADMIN_TOKEN piped via stdin (never lands in argv / ps / shell history).

Live at http://45.61.58.125:9760 (no public domain yet).

Files touched

Diff

commit c6fa78502ffe4bd9ce4a7783f3c6db6813f676de
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 4 21:56:41 2026 -0700

    deploy: ship smb-builder to Kamatera + capture full schema in migrations 002-008
    
    migrations 002-008 capture all tables that drifted into local DB ad-hoc.
    All use IF NOT EXISTS so re-running on a populated DB is a no-op.
    Validated end-to-end against a fresh smb_migration_test DB -> 13 tables, all clean.
    
    scripts/deploy-kamatera.sh: idempotent 6-step rsync->createdb->migrate->.env->pm2.
    ADMIN_TOKEN piped via stdin (never lands in argv / ps / shell history).
    
    Live at http://45.61.58.125:9760 (no public domain yet).
---
 DEPLOY.md                                    | 76 +++++++++++++++++++++++++
 migrations/002_website_analysis.sql          | 39 +++++++++++++
 migrations/003_neighborhood_display_name.sql |  7 +++
 migrations/004_business_articles.sql         | 41 ++++++++++++++
 migrations/005_business_interviews.sql       | 36 ++++++++++++
 migrations/006_business_pricing.sql          | 41 ++++++++++++++
 migrations/007_business_review_sources.sql   | 42 ++++++++++++++
 migrations/008_enrichment_jobs.sql           | 45 +++++++++++++++
 scripts/deploy-kamatera.sh                   | 85 ++++++++++++++++++++++++++++
 9 files changed, 412 insertions(+)

diff --git a/DEPLOY.md b/DEPLOY.md
new file mode 100644
index 0000000..6832b63
--- /dev/null
+++ b/DEPLOY.md
@@ -0,0 +1,76 @@
+# smb-builder → Kamatera deploy
+
+Target: `45.61.58.125:9760` (IP:port, no public domain yet).
+
+## Why a script and not "just go"
+
+Standing rule (`feedback_pm2_deploy_explicit_auth.md`): pm2 actions on cncp-adjacent / production hosts need explicit deploy-intent. "go" / "y" is not enough. So the deploy is in your hands; this prep is everything-but-the-button.
+
+## Pre-reqs
+
+- New ADMIN_TOKEN was generated to `/tmp/.smb_admin_token_<pid>` (chmod 600, owner-only). The path is in your shell history from this session — find it with `ls /tmp/.smb_admin_token_*`.
+- SSH access to `root@45.61.58.125` is configured.
+- Local PG `small_business_directory` already exists; remote DB will be created fresh.
+
+## Running the deploy
+
+```bash
+# 1. expose the new token in your shell WITHOUT putting it in argv:
+set -a
+source /tmp/.smb_admin_token_<pid>   # token file is just the value, not KEY=val — see step 1a
+set +a
+
+# 1a. ALTERNATIVE if the file is a bare token (no KEY=val), inject as env directly:
+export ADMIN_TOKEN="$(cat /tmp/.smb_admin_token_<pid>)"
+
+# 2. run the deploy script:
+bash ~/Projects/small-business-builder/scripts/deploy-kamatera.sh
+
+# 3. clean up:
+unset ADMIN_TOKEN
+rm /tmp/.smb_admin_token_*
+```
+
+## What the script does (6 steps)
+
+1. **rsync code** → `kamatera:/root/Projects/small-business-builder` (excludes node_modules / .git / .env / logs)
+2. **Ensure DB** exists (idempotent — checks first, only creates if missing)
+3. **Run migrations** (`migrations/001_init.sql`, `migrations/002_website_analysis.sql`)
+4. **Write .env** via stdin pipe (token never enters argv / ps / shell history)
+5. **npm install --omit=dev** + **pm2 start + save**
+6. **Smoke test** `http://localhost:9760/` from inside Kamatera
+
+## Verify after
+
+```bash
+# from your laptop (or this Mac):
+curl -i http://45.61.58.125:9760/healthz
+curl -i -H "x-admin-token: $ADMIN_TOKEN" http://45.61.58.125:9760/admin
+```
+
+## Rollback
+
+If something breaks:
+```bash
+ssh root@45.61.58.125 "pm2 stop smb-builder && pm2 delete smb-builder"
+ssh root@45.61.58.125 "psql -U postgres -c 'DROP DATABASE small_business_directory'"
+```
+
+## Next steps after IP:port works
+
+- Pick a public domain (smbbuilder.com? small-business-builder.com? CNCP entry says nothing yet)
+- Add nginx config + Let's Encrypt SSL on Kamatera
+- Update DNS via `domain-name-agent`
+- Route ADMIN_TOKEN into `secrets-manager` for future rotation:
+  ```bash
+  echo "SMB_ADMIN_TOKEN=$ADMIN_TOKEN" | node ~/Projects/secrets-manager/cli.js import-paste
+  ```
+
+## Rule check
+
+- ✅ explicit auth: you trigger the deploy, not me
+- ✅ no secret in bash argv: stdin pipe + heredoc only
+- ✅ no DNS changes (no public domain yet)
+- ✅ no Mac2 services touched
+- ✅ idempotent — re-running is safe
+- ⚠ remote prod side effects: pm2 process created, new PG database. Both reversible (see Rollback).
diff --git a/migrations/002_website_analysis.sql b/migrations/002_website_analysis.sql
new file mode 100644
index 0000000..d4b3562
--- /dev/null
+++ b/migrations/002_website_analysis.sql
@@ -0,0 +1,39 @@
+-- migration 002 — website-analysis feature
+-- Adds storage for the Website Analysis flow (entry → 3-mockup picker → full rebuild).
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS website_analyses (
+  id BIGSERIAL PRIMARY KEY,
+  slug TEXT UNIQUE NOT NULL,
+  business_id BIGINT REFERENCES businesses(id) ON DELETE SET NULL,
+  url TEXT NOT NULL,
+  ig_handle TEXT,
+  vertical TEXT,                            -- salon, restaurant, lawyer, etc.
+  user_screenshot_path TEXT,                -- /uploads/wa/{slug}/orig.jpg (optional, deferred)
+  status TEXT NOT NULL DEFAULT 'new'
+         CHECK (status IN ('new','analyzing','ready','error')),
+  selected_mockup_id TEXT,                  -- which of the 3 the user picked
+  summary_json JSONB,                       -- {grade, top_3_issues, vertical}
+  meta_json JSONB,                          -- raw audit blob, lazily populated
+  created_at TIMESTAMPTZ DEFAULT NOW(),
+  updated_at TIMESTAMPTZ DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_wa_slug   ON website_analyses(slug);
+CREATE INDEX IF NOT EXISTS idx_wa_status ON website_analyses(status);
+CREATE INDEX IF NOT EXISTS idx_wa_biz    ON website_analyses(business_id);
+
+CREATE TABLE IF NOT EXISTS website_analysis_mockups (
+  id BIGSERIAL PRIMARY KEY,
+  analysis_id BIGINT REFERENCES website_analyses(id) ON DELETE CASCADE,
+  mockup_id TEXT NOT NULL,                  -- e.g. '01-editorial'
+  title TEXT NOT NULL,
+  theme TEXT NOT NULL,                      -- editorial, neon, brutalist, minimalist
+  preview_html TEXT,                        -- inlined single-file HTML
+  generated_at TIMESTAMPTZ DEFAULT NOW()
+);
+CREATE INDEX IF NOT EXISTS idx_wam_analysis ON website_analysis_mockups(analysis_id);
+CREATE UNIQUE INDEX IF NOT EXISTS idx_wam_analysis_mockup
+  ON website_analysis_mockups(analysis_id, mockup_id);
+
+COMMIT;
diff --git a/migrations/003_neighborhood_display_name.sql b/migrations/003_neighborhood_display_name.sql
new file mode 100644
index 0000000..f9937a2
--- /dev/null
+++ b/migrations/003_neighborhood_display_name.sql
@@ -0,0 +1,7 @@
+-- Add columns added ad-hoc to local DB before being captured in migrations.
+-- Idempotent: ADD COLUMN IF NOT EXISTS so re-running on the local DB is a no-op.
+
+ALTER TABLE businesses ADD COLUMN IF NOT EXISTS neighborhood TEXT;
+ALTER TABLE businesses ADD COLUMN IF NOT EXISTS display_name TEXT;
+
+CREATE INDEX IF NOT EXISTS businesses_neighborhood_idx ON businesses(neighborhood) WHERE neighborhood IS NOT NULL;
diff --git a/migrations/004_business_articles.sql b/migrations/004_business_articles.sql
new file mode 100644
index 0000000..5eef5c6
--- /dev/null
+++ b/migrations/004_business_articles.sql
@@ -0,0 +1,41 @@
+-- Captured 2026-05-04 from local DB schema drift before fresh-deploy reproducibility.
+-- Idempotent — re-running on a populated DB is a no-op.
+
+\restrict L5liGadvThkoMt9pgBBbmPZUEnOvCinn0z0sWK0dFxf9E4hHYXj7v0oVMnJqBYP
+
+CREATE TABLE IF NOT EXISTS business_articles (
+    id bigint NOT NULL,
+    business_id bigint NOT NULL,
+    url text NOT NULL,
+    title text,
+    publisher text,
+    published_at timestamp with time zone,
+    excerpt text,
+    source text DEFAULT 'exa'::text NOT NULL,
+    raw_json jsonb,
+    created_at timestamp with time zone DEFAULT now()
+);
+
+CREATE SEQUENCE IF NOT EXISTS business_articles_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+ALTER SEQUENCE business_articles_id_seq OWNED BY business_articles.id;
+
+ALTER TABLE ONLY business_articles ALTER COLUMN id SET DEFAULT nextval('business_articles_id_seq'::regclass);
+
+ALTER TABLE ONLY business_articles
+    ADD CONSTRAINT business_articles_pkey PRIMARY KEY (id);
+
+CREATE INDEX IF NOT EXISTS idx_article_business ON business_articles USING btree (business_id);
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_article_biz_url ON business_articles USING btree (business_id, url);
+
+ALTER TABLE ONLY business_articles
+    ADD CONSTRAINT business_articles_business_id_fkey FOREIGN KEY (business_id) REFERENCES businesses(id) ON DELETE CASCADE;
+
+\unrestrict L5liGadvThkoMt9pgBBbmPZUEnOvCinn0z0sWK0dFxf9E4hHYXj7v0oVMnJqBYP
+
diff --git a/migrations/005_business_interviews.sql b/migrations/005_business_interviews.sql
new file mode 100644
index 0000000..2740f8a
--- /dev/null
+++ b/migrations/005_business_interviews.sql
@@ -0,0 +1,36 @@
+-- Captured 2026-05-04 from local DB schema drift before fresh-deploy reproducibility.
+-- Idempotent — re-running on a populated DB is a no-op.
+
+\restrict pFcf0Jc9pmQ7hl19eCeUFdKF7xuq2zJJZaRAE3SlTUAgcCmHh7SoilZtgDnHVib
+
+CREATE TABLE IF NOT EXISTS business_interviews (
+    id bigint NOT NULL,
+    business_id bigint,
+    qa_json jsonb DEFAULT '{}'::jsonb NOT NULL,
+    recorded_by text,
+    recorded_at timestamp with time zone DEFAULT now()
+);
+
+CREATE SEQUENCE IF NOT EXISTS business_interviews_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+ALTER SEQUENCE business_interviews_id_seq OWNED BY business_interviews.id;
+
+ALTER TABLE ONLY business_interviews ALTER COLUMN id SET DEFAULT nextval('business_interviews_id_seq'::regclass);
+
+ALTER TABLE ONLY business_interviews
+    ADD CONSTRAINT business_interviews_pkey PRIMARY KEY (id);
+
+CREATE INDEX IF NOT EXISTS idx_interviews_business ON business_interviews USING btree (business_id);
+
+CREATE INDEX IF NOT EXISTS idx_interviews_recorded ON business_interviews USING btree (business_id, recorded_at DESC);
+
+ALTER TABLE ONLY business_interviews
+    ADD CONSTRAINT business_interviews_business_id_fkey FOREIGN KEY (business_id) REFERENCES businesses(id) ON DELETE CASCADE;
+
+\unrestrict pFcf0Jc9pmQ7hl19eCeUFdKF7xuq2zJJZaRAE3SlTUAgcCmHh7SoilZtgDnHVib
+
diff --git a/migrations/006_business_pricing.sql b/migrations/006_business_pricing.sql
new file mode 100644
index 0000000..5a8ee17
--- /dev/null
+++ b/migrations/006_business_pricing.sql
@@ -0,0 +1,41 @@
+-- Captured 2026-05-04 from local DB schema drift before fresh-deploy reproducibility.
+-- Idempotent — re-running on a populated DB is a no-op.
+
+\restrict dFftKlWXyBtFds2c8ysfWTsGL16bwtTvHfrcFuXVQlLKvefKAeeRVZfZtbOJM8T
+
+CREATE TABLE IF NOT EXISTS business_pricing (
+    id bigint NOT NULL,
+    business_id bigint NOT NULL,
+    service_kind text NOT NULL,
+    price_low integer,
+    price_high integer,
+    notes text,
+    submitted_by text,
+    source text DEFAULT 'owner'::text,
+    recorded_at timestamp with time zone DEFAULT now(),
+    updated_at timestamp with time zone DEFAULT now()
+);
+
+CREATE SEQUENCE IF NOT EXISTS business_pricing_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+ALTER SEQUENCE business_pricing_id_seq OWNED BY business_pricing.id;
+
+ALTER TABLE ONLY business_pricing ALTER COLUMN id SET DEFAULT nextval('business_pricing_id_seq'::regclass);
+
+ALTER TABLE ONLY business_pricing
+    ADD CONSTRAINT business_pricing_pkey PRIMARY KEY (id);
+
+CREATE INDEX IF NOT EXISTS idx_pricing_kind ON business_pricing USING btree (service_kind);
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_pricing_biz_kind ON business_pricing USING btree (business_id, service_kind);
+
+ALTER TABLE ONLY business_pricing
+    ADD CONSTRAINT business_pricing_business_id_fkey FOREIGN KEY (business_id) REFERENCES businesses(id) ON DELETE CASCADE;
+
+\unrestrict dFftKlWXyBtFds2c8ysfWTsGL16bwtTvHfrcFuXVQlLKvefKAeeRVZfZtbOJM8T
+
diff --git a/migrations/007_business_review_sources.sql b/migrations/007_business_review_sources.sql
new file mode 100644
index 0000000..01e5a41
--- /dev/null
+++ b/migrations/007_business_review_sources.sql
@@ -0,0 +1,42 @@
+-- Captured 2026-05-04 from local DB schema drift before fresh-deploy reproducibility.
+-- Idempotent — re-running on a populated DB is a no-op.
+
+\restrict bLlJp3WVNsixCnZ5miDOV9yYq4ISLy8FEtzyf8UEb2JUJAJMjM6Xo3FPytvDCnV
+
+CREATE TABLE IF NOT EXISTS business_review_sources (
+    id bigint NOT NULL,
+    business_id bigint NOT NULL,
+    source_type text NOT NULL,
+    url text NOT NULL,
+    label text,
+    submitted_by text,
+    verified boolean DEFAULT false,
+    created_at timestamp with time zone DEFAULT now(),
+    updated_at timestamp with time zone DEFAULT now()
+);
+
+CREATE SEQUENCE IF NOT EXISTS business_review_sources_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+ALTER SEQUENCE business_review_sources_id_seq OWNED BY business_review_sources.id;
+
+ALTER TABLE ONLY business_review_sources ALTER COLUMN id SET DEFAULT nextval('business_review_sources_id_seq'::regclass);
+
+ALTER TABLE ONLY business_review_sources
+    ADD CONSTRAINT business_review_sources_pkey PRIMARY KEY (id);
+
+CREATE INDEX IF NOT EXISTS idx_brs_business ON business_review_sources USING btree (business_id);
+
+CREATE INDEX IF NOT EXISTS idx_brs_verified ON business_review_sources USING btree (verified) WHERE (verified = true);
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_brs_business_url ON business_review_sources USING btree (business_id, url);
+
+ALTER TABLE ONLY business_review_sources
+    ADD CONSTRAINT business_review_sources_business_id_fkey FOREIGN KEY (business_id) REFERENCES businesses(id) ON DELETE CASCADE;
+
+\unrestrict bLlJp3WVNsixCnZ5miDOV9yYq4ISLy8FEtzyf8UEb2JUJAJMjM6Xo3FPytvDCnV
+
diff --git a/migrations/008_enrichment_jobs.sql b/migrations/008_enrichment_jobs.sql
new file mode 100644
index 0000000..b3fb888
--- /dev/null
+++ b/migrations/008_enrichment_jobs.sql
@@ -0,0 +1,45 @@
+-- Captured 2026-05-04 from local DB schema drift before fresh-deploy reproducibility.
+-- Idempotent — re-running on a populated DB is a no-op.
+
+\restrict EuPDbXhVYSFsBy8xkV84IyraZU2Um5iZibL59Mv2ilaibBuyeo5tz803Pdv31kL
+
+CREATE TABLE IF NOT EXISTS enrichment_jobs (
+    id bigint NOT NULL,
+    business_id bigint NOT NULL,
+    kind text NOT NULL,
+    status text DEFAULT 'pending'::text NOT NULL,
+    found_url text,
+    found_handle text,
+    hit_count integer,
+    raw_json jsonb,
+    attempted_at timestamp with time zone,
+    next_attempt timestamp with time zone DEFAULT now(),
+    attempts integer DEFAULT 0 NOT NULL,
+    source text DEFAULT 'exa'::text NOT NULL,
+    created_at timestamp with time zone DEFAULT now(),
+    updated_at timestamp with time zone DEFAULT now()
+);
+
+CREATE SEQUENCE IF NOT EXISTS enrichment_jobs_id_seq
+    START WITH 1
+    INCREMENT BY 1
+    NO MINVALUE
+    NO MAXVALUE
+    CACHE 1;
+
+ALTER SEQUENCE enrichment_jobs_id_seq OWNED BY enrichment_jobs.id;
+
+ALTER TABLE ONLY enrichment_jobs ALTER COLUMN id SET DEFAULT nextval('enrichment_jobs_id_seq'::regclass);
+
+ALTER TABLE ONLY enrichment_jobs
+    ADD CONSTRAINT enrichment_jobs_pkey PRIMARY KEY (id);
+
+CREATE INDEX IF NOT EXISTS idx_enrich_status ON enrichment_jobs USING btree (status, next_attempt) WHERE (status = 'pending'::text);
+
+CREATE UNIQUE INDEX IF NOT EXISTS uq_enrich_biz_kind ON enrichment_jobs USING btree (business_id, kind);
+
+ALTER TABLE ONLY enrichment_jobs
+    ADD CONSTRAINT enrichment_jobs_business_id_fkey FOREIGN KEY (business_id) REFERENCES businesses(id) ON DELETE CASCADE;
+
+\unrestrict EuPDbXhVYSFsBy8xkV84IyraZU2Um5iZibL59Mv2ilaibBuyeo5tz803Pdv31kL
+
diff --git a/scripts/deploy-kamatera.sh b/scripts/deploy-kamatera.sh
new file mode 100755
index 0000000..34a2202
--- /dev/null
+++ b/scripts/deploy-kamatera.sh
@@ -0,0 +1,85 @@
+#!/usr/bin/env bash
+# Deploy smb-builder to Kamatera at 45.61.58.125:9760 (IP:port, no public domain yet).
+#
+# REQUIRES EXPLICIT AUTH per Steve's standing rule for cncp-adjacent deploys.
+# Run only after reading DEPLOY.md and confirming intent.
+#
+# Idempotent: re-running is safe — rsync syncs, psql migrations are guarded.
+#
+# Pre-reqs (set in this shell BEFORE running, do NOT inline as $VAR in bash):
+#   set -a; source /tmp/.smb_admin_token_<pid> ; set +a   # exposes ADMIN_TOKEN
+#   (or just `export ADMIN_TOKEN="$(cat <secret-file>)"`)
+#
+# What this does:
+#   1. rsync code → kamatera:/root/Projects/small-business-builder
+#   2. createdb on Kamatera (skipped if exists)
+#   3. run migrations
+#   4. write .env with the ADMIN_TOKEN from this shell (no argv leak)
+#   5. pm2 start + pm2 save
+#   6. test http://45.61.58.125:9760/
+
+set -euo pipefail
+
+REMOTE="root@45.61.58.125"
+REMOTE_DIR="/root/Projects/small-business-builder"
+LOCAL_DIR="$HOME/Projects/small-business-builder"
+
+if [ -z "${ADMIN_TOKEN:-}" ]; then
+  echo "FATAL: ADMIN_TOKEN env var not set in this shell." >&2
+  echo "Run: set -a; source /tmp/.smb_admin_token_<pid>; set +a   then re-run this script." >&2
+  exit 1
+fi
+
+echo "[1/6] rsync code → $REMOTE:$REMOTE_DIR"
+rsync -av --delete \
+  --exclude=node_modules --exclude=.git --exclude=logs --exclude='.env*' \
+  --exclude='_yolo_patches' --exclude='*.bak.*' \
+  "$LOCAL_DIR/" "$REMOTE:$REMOTE_DIR/"
+
+echo "[2/6] ensure DB + role exist (sudo -u postgres for peer auth)"
+# Pipe DB password via stdin so it never enters argv / ps / shell history.
+DB_PW="${DB_PW:-$ADMIN_TOKEN}"   # reuse admin token as DB pw if not set
+ssh "$REMOTE" "sudo -u postgres psql" <<EOF
+DO \$\$
+BEGIN
+  IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname='smb_builder') THEN
+    CREATE ROLE smb_builder WITH LOGIN PASSWORD '$DB_PW';
+  ELSE
+    ALTER ROLE smb_builder WITH PASSWORD '$DB_PW';
+  END IF;
+END
+\$\$;
+SELECT 'CREATE DATABASE small_business_directory OWNER smb_builder'
+  WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname='small_business_directory')
+\\gexec
+GRANT ALL PRIVILEGES ON DATABASE small_business_directory TO smb_builder;
+EOF
+
+echo "[3/6] run migrations"
+ssh "$REMOTE" "cd $REMOTE_DIR && for m in migrations/*.sql; do echo \"applying \$m\"; sudo -u postgres psql -d small_business_directory -f \"\$m\" || true; done"
+
+echo "[4/6] write .env (token via stdin, never argv)"
+# Pipe token via stdin so it never appears in argv / ps / shell history
+ssh "$REMOTE" "cat > $REMOTE_DIR/.env" <<EOF
+NODE_ENV=production
+PORT=9760
+DATABASE_URL=postgres://smb_builder:$DB_PW@localhost:5432/small_business_directory
+ADMIN_TOKEN=$ADMIN_TOKEN
+EOF
+ssh "$REMOTE" "chmod 600 $REMOTE_DIR/.env"
+
+echo "[5/6] npm install + pm2 start + save"
+ssh "$REMOTE" "cd $REMOTE_DIR && npm install --omit=dev --no-audit --no-fund 2>&1 | tail -5"
+ssh "$REMOTE" "cd $REMOTE_DIR && pm2 delete smb-builder 2>/dev/null; pm2 start ecosystem.config.cjs && pm2 save"
+
+echo "[6/6] smoke test"
+sleep 3
+ssh "$REMOTE" "curl -s -o /dev/null -w 'http://localhost:9760/ → %{http_code}\n' --max-time 5 http://localhost:9760/"
+echo ""
+echo "From your laptop, with the same ADMIN_TOKEN value:"
+echo "  curl http://45.61.58.125:9760/healthz"
+echo ""
+echo "Done. Remember to:"
+echo "  • unset ADMIN_TOKEN  in this shell"
+echo "  • rm /tmp/.smb_admin_token_*"
+echo "  • route the new token through ~/Projects/secrets-manager/cli.js add SMB_ADMIN_TOKEN"

← 41c8a33 [morning-review] small-business-builder: gate interview admi  ·  back to Small Business Builder  ·  fix(ops): port hawk to sf-style pm2-jlist pattern — old curl 5aaf4a3 →