Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 6 additions & 8 deletions .agents/skills/catalog-deploy/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@ description: How to add or change a mod in fedibtc/catalog and how to deploy mas

# Catalog PRs and deploys

Two branches. `master` is the source of truth and deploys to the Vercel `staging` environment on every push. `vercel/production` serves https://fedi-catalog.vercel.app and only ever advances by merging master.
Two branches. `master` is the source of truth and deploys to the Vercel `staging` environment on every push. `vercel/production` serves https://fedi-catalog.vercel.app and only ever advances by merging master. Merging that PR is the production deploy and a human does it on GitHub. An agent opens the PR and never merges it, not with `gh pr merge`, not with `--admin`, and never pushes to vercel/production directly.

## Adding or changing a mod

- one PR per mod into master, from a branch off master. never open a second PR into vercel/production
- the mod lives at `mods/<category>/<id>/meta.json` and must pass the zod schema in `app/lib/schemas.ts`. a mod that fails the schema is silently dropped from the catalog, so check the Vercel preview of the PR before asking for review
- `iconUrl` is a url on the mod's own site. do not copy the image into this repo. pick one a browser can embed from another origin, a favicon or apple touch icon usually works, and check it renders on the Vercel preview. a host that blocks cross-origin embeds with `cross-origin-resource-policy` serves the file fine to curl and still shows a blank card, so trust the preview over a status code
- a PR that adds a mod has its icon loaded in a browser from another origin, so a url only that mod's own site can display fails the PR. every icon is checked the same way on a weekly schedule, and a broken one opens an issue naming the mod, to fix its url or take the mod out
- `newMods.json` is a sliding window of six ids, oldest first. drop the first entry and append the new id. sibling PRs cut from the same base all conflict on this file, so merge them in the order they were opened and merge master into the next one after each merge
- master needs no approvals. a merge commit or a squash are both fine on master

Expand All @@ -30,18 +32,14 @@ Two branches. `master` is the source of truth and deploys to the Vercel `staging
gh pr create --base vercel/production --head master --title "build: deploy master to production"
```

3. merge it with a merge commit, never squash or rebase, and never cherry-pick into vercel/production. each of those creates a commit master does not have, the branches stop sharing history, and the next deploy conflicts on every file touched since. production needs one approval from someone other than the last pusher, or an admin merge:
3. wait for its checks, then hand over the link. a human merges it on GitHub with a merge commit, never squash or rebase, and never cherry-pick into vercel/production. each of those creates a commit master does not have, the branches stop sharing history, and the next deploy conflicts on every file touched since. production needs one approval from someone other than the last pusher, or the admin bypass in the merge dialog

```
gh pr merge <n> --merge --admin
```

4. confirm the deploy the same way as staging, with `environment=Production` and https://fedi-catalog.vercel.app as the target
4. once it is merged, confirm the deploy the same way as staging, with `environment=Production` and https://fedi-catalog.vercel.app as the target

## Lockfile and dependabot

The project uses bun. `bun.lock` is the only lockfile and dependabot runs on the `bun` ecosystem, so its PRs update bun.lock directly. If the `build` check fails with ` M bun.lock` in its log, package.json changed without the lockfile. Run `nix develop -c bun install`, commit bun.lock, and merge that first, because every open PR is red until it lands.

## Running the e2e suite

`nix develop -c bun test:e2e` builds and serves the app locally. `E2E_BASE_URL=<url>` runs the same suite against a deployed url without building. Against a deployed url a url-filtering test can flake, because third-party mod icons load slowly and a few tests race them. A retry that passes is fine, a repeat failure is not.
`nix develop -c bun test:e2e` builds and serves the app locally on port 3023, and reuses whatever already listens there, so check the port is free first. `E2E_BASE_URL=<url>` runs the same suite against a deployed url without building. On a cold deployment the first page load can exceed the navigation timeout. A retry that passes is fine, a repeat failure is not.
16 changes: 16 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,22 @@ jobs:
- name: Run code quality checks
run: nix develop -c ./scripts/check-ui-code.sh

mod-icons:
runs-on: ubuntu-latest

steps:
- name: Checkout repo
uses: actions/checkout@v4
with:
fetch-depth: 0

- uses: cachix/install-nix-action@v30
with:
nix_path: nixpkgs=channel:nixos-25.05

- name: Check the icons of mods added in this PR
run: nix develop -c ./scripts/check-new-mod-icons.sh ${{ github.event.pull_request.base.sha }}

e2e:
runs-on: ubuntu-latest

Expand Down
32 changes: 32 additions & 0 deletions .github/workflows/mod-icons.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: Mod icons

on:
schedule:
- cron: "0 9 * * 1"
workflow_dispatch:

jobs:
icons:
runs-on: ubuntu-latest

permissions:
contents: read
issues: write

steps:
- name: Checkout repo
uses: actions/checkout@v4

- uses: cachix/install-nix-action@v30
with:
nix_path: nixpkgs=channel:nixos-25.05

- name: Check every mod icon against production
id: icons
run: nix develop -c ./scripts/check-mod-icons.sh

- name: Report the broken ones
if: failure() && steps.icons.outcome == 'failure'
run: ./scripts/report-broken-mod-icons.sh
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,6 @@ next-env.d.ts
/playwright-report/
/blob-report/
/playwright/.cache/

# icon check
icon-check.log
2 changes: 1 addition & 1 deletion app/content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export default function PageContent({
id: miniApp.id,
title: miniApp.name,
url: miniApp.url,
imageUrl: miniApp.iconUrl,
imageUrl: new URL(miniApp.iconUrl, window.location.href).href,
description: miniApp.description,
})

Expand Down
20 changes: 20 additions & 0 deletions e2e/fedi-mode/install-flow.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, injectFediMock, test } from "../fixtures/base"
import { getInstallCalls } from "../helpers/fedi-mock"

test.describe("Fedi Mode - Install Flow", () => {
test("click Add on card changes to Added", async ({
Expand Down Expand Up @@ -100,4 +101,23 @@ test.describe("Fedi Mode - Install Flow", () => {
boltzCard.getByRole("button", { name: "Added", exact: true }),
).toBeVisible()
})

test("install hands the app an absolute icon url", async ({
page,
catalogPage,
}) => {
await injectFediMock(page)
await page.goto("/", { waitUntil: "domcontentloaded" })
await catalogPage.waitForCatalogReady()

const card = catalogPage.getMiniAppCard("Sats Converter")
await card.getByRole("button", { name: "Add", exact: true }).click()
await expect(
card.getByRole("button", { name: "Added", exact: true }),
).toBeVisible()

const [call] = await getInstallCalls(page)
expect(call.imageUrl).toMatch(/^https?:\/\//)
expect(new URL(call.imageUrl).pathname).toBe("/satsconverter.png")
})
})
36 changes: 27 additions & 9 deletions e2e/helpers/fedi-mock.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,22 @@
import { Page } from "@playwright/test"

export type InstallMiniAppCall = {
id: string
title: string
url: string
imageUrl: string
description: string
}

type FediInternalMock = {
version: number
getInstalledMiniApps: () => Promise<Array<{ url: string }>>
installMiniApp: (miniApp: { url: string }) => Promise<void>
installMiniApp: (miniApp: InstallMiniAppCall) => Promise<void>
}

type MockWindow = Window & {
fediInternal?: FediInternalMock
fediInstallCalls?: InstallMiniAppCall[]
}

export async function injectFediMock(
Expand All @@ -14,13 +27,18 @@ export async function injectFediMock(
) {
await page.addInitScript((apps) => {
const installed = [...apps]
;(window as Window & { fediInternal?: FediInternalMock }).fediInternal =
{
version: 2,
getInstalledMiniApps: async () => [...installed],
installMiniApp: async (miniApp: { url: string }) => {
installed.push({ url: miniApp.url })
},
}
const mockWindow = window as MockWindow
mockWindow.fediInstallCalls = []
mockWindow.fediInternal = {
version: 2,
getInstalledMiniApps: async () => [...installed],
installMiniApp: async (miniApp: InstallMiniAppCall) => {
installed.push({ url: miniApp.url })
mockWindow.fediInstallCalls?.push(miniApp)
},
}
}, options?.installedApps ?? [])
}

export const getInstallCalls = (page: Page) =>
page.evaluate(() => (window as MockWindow).fediInstallCalls ?? [])
2 changes: 1 addition & 1 deletion mods/cash-in-cash-out/cashwyre/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "Cashwyre",
"id": "cashwyre",
"url": "https://offramp.cashwyre.com/offramp",
"iconUrl": "https://cashwyre.io/img/icons/apple-touch-icon.png",
"iconUrl": "https://cashwyre.io/favicon.ico",
"description": "Use Bitcoin to make instant payments in local currencies across Kenya 🇰🇪 , South Africa 🇿🇦 , Nigeria 🇳🇬 , Uganda 🇺🇬 , Rwanda 🇷🇼 , Ghana 🇬🇭, Guinea 🇬🇳, and more.",
"privacyPolicyUrl": "https://cashwyre.com/privacy"
}
2 changes: 1 addition & 1 deletion mods/productivities/retiro-bitcoin/meta.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "Retiro Bitcoin",
"id": "retiro-bitcoin",
"url": "https://calculadora-de-retiro-bitcoin-fedi.vercel.app/",
"iconUrl": "https://calculadora-de-retiro-bitcoin-fedi.vercel.app/assets/logo-512.png",
"iconUrl": "https://calculadora-de-retiro-bitcoin-fedi.vercel.app/assets/logo-fedi-512.png?v=3",
"description": "Plan your financial independence with Bitcoin savings & projections",
"extendedDescription": "A Bitcoin retirement planning calculator designed as a Fedi Mini App. Users input their initial savings (BTC or fiat), periodic contributions (daily/weekly/monthly), time horizon, expected annual return, and optional local inflation rate to project their financial independence path. Features include: interactive growth charts, real-time BTC price conversion via CoinGecko, multi-language support (ES/EN/PT/FR), dark/light theme, PDF report export, Safe Withdrawal Rate (SWR 4%) simulation, Google Calendar savings reminders, and CoinGecko price alerts setup guide. Integrates with Fedi via WebLN for Lightning payments (premium plan), Nostr NIP-07/NIP-78 for identity and saving plans, and window.fedi for currency/language preferences. Freemium model: free tier includes single scenario with basic results; premium ($1 USD/month or $10 USD lifetime via Lightning) unlocks 3 scenario comparison, PDF export, plan saving, and SWR simulation. Includes a referral CTA for buying BTC through Aureo (Mexico users). Fully mobile-first, works standalone outside Fedi.",
"privacyPolicyUrl": "https://calculadora-de-retiro-bitcoin-fedi.vercel.app/aviso-privacidad",
Expand Down
12 changes: 12 additions & 0 deletions scripts/check-mod-icons.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
#!/usr/bin/env bash

set -uo pipefail

REPO_ROOT=$(git rev-parse --show-toplevel)
cd "$REPO_ROOT"

bun install
bunx playwright install --with-deps chromium

bun scripts/check-mod-icons.ts --all 2>&1 | tee icon-check.log
exit "${PIPESTATUS[0]}"
86 changes: 86 additions & 0 deletions scripts/check-mod-icons.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { chromium, Page } from "@playwright/test"
import { readdirSync, readFileSync, statSync } from "fs"
import path from "path"

const OTHER_ORIGIN = "https://example.com/"
const CATALOG_URL = process.env.CATALOG_URL ?? "https://fedi-catalog.vercel.app"
const ATTEMPTS = 2

const modFiles = () =>
readdirSync("mods")
.map((group) => path.join("mods", group))
.filter((group) => statSync(group).isDirectory())
.flatMap((group) =>
readdirSync(group)
.map((mod) => path.join(group, mod))
.filter((mod) => statSync(mod).isDirectory())
.map((mod) => path.join(mod, "meta.json")),
)

const loadInBrowser = (page: Page, url: string) =>
page.evaluate(
(src) =>
new Promise<string>((resolve) => {
const image = new Image()
image.onload = () =>
resolve(
image.naturalWidth > 0 ? "ok" : "loaded with no pixels",
)
image.onerror = () => resolve("did not load")
setTimeout(() => resolve("timed out after 30s"), 30_000)
image.src = src
}),
url,
)

async function main() {
const args = process.argv.slice(2)
const files = args[0] === "--all" ? modFiles() : args

if (files.length === 0) {
console.log("No mods to check")
return
}

const browser = await chromium.launch()
const page = await browser.newPage()
await page.goto(OTHER_ORIGIN)

const failures: string[] = []
for (const file of files) {
const { name, iconUrl } = JSON.parse(readFileSync(file, "utf8"))
const url = new URL(iconUrl, CATALOG_URL).href
let result = "did not load"
for (
let attempt = 0;
attempt < ATTEMPTS && result !== "ok";
attempt++
) {
result = await loadInBrowser(page, url)
}
console.log(`${result === "ok" ? "ok " : "FAIL"} ${name} ${url}`)
if (result !== "ok") failures.push(`${name}: ${url} ${result}`)
}

await browser.close()

if (failures.length > 0) {
console.error(
[
"",
"BROKEN ICONS",
...failures.map((failure) => ` ${failure}`),
"",
"Point each one at a url its own site serves to everyone, a favicon or apple",
"touch icon usually works. A host that sends cross-origin-resource-policy",
"answers curl fine and still shows a blank card, so check a Vercel preview.",
].join("\n"),
)
process.exit(1)
}
}

main().catch((error) => {
console.error(error)
process.exit(1)
})
22 changes: 22 additions & 0 deletions scripts/check-new-mod-icons.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
#!/usr/bin/env bash

set -euo pipefail

REPO_ROOT=$(git rev-parse --show-toplevel)
cd "$REPO_ROOT"

BASE_REF=${1:-origin/master}

mapfile -t added < <(git diff --name-only --diff-filter=A "$BASE_REF" HEAD -- 'mods/*/*/meta.json')

if [[ ${#added[@]} -eq 0 ]]; then
echo "No mods added in this branch"
exit 0
fi

echo "Mods added in this branch:"
printf ' %s\n' "${added[@]}"

bun install
bunx playwright install --with-deps chromium
bun scripts/check-mod-icons.ts "${added[@]}"
34 changes: 34 additions & 0 deletions scripts/report-broken-mod-icons.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/usr/bin/env bash

set -euo pipefail

REPO_ROOT=$(git rev-parse --show-toplevel)
cd "$REPO_ROOT"

TITLE="Mod icons are not loading"
RUN_URL="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY:-fedibtc/catalog}/actions/runs/${GITHUB_RUN_ID:-}"

broken=$(grep '^FAIL ' icon-check.log | sed 's/^FAIL /- /' | sort -u)

if [[ -z "$broken" ]]; then
broken="- the icon check failed without naming a mod, read the run log"
fi

body="These mods show a blank card on https://fedi-catalog.vercel.app:

$broken

Point each one at a url its own site serves to everyone, or take the mod out. A host that blocks cross-origin embeds answers curl fine and still renders blank, so check a Vercel preview rather than a status code.

$RUN_URL"

existing=$(gh issue list --state open --search "$TITLE in:title" --json number,title \
--jq "[.[] | select(.title == \"$TITLE\")][0].number")

if [[ -n "$existing" && "$existing" != "null" ]]; then
echo "Commenting on issue #$existing"
gh issue comment "$existing" --body "$body"
else
echo "Opening a new issue"
gh issue create --title "$TITLE" --body "$body"
fi
Loading