skills/migrate-to-astro/SKILL.md
--- name: migrate-to-astro description: Migrate an existing static-leaning web app (Vercel-hosted Astro, VPS-hosted Astro, Next.js marketing site, plain HTML site, etc.) to Astro 5 on Cloudflare Workers + Sentry EU + PostHog EU. Walks the cutover sequence in the order that actually works (not the order the docs suggest), with explicit checks for every one of the twelve gotchas captured in the LLM wiki research vault. Sister skill to /ro:migrate-to-tanstack — for sites that don't need a real serv
npx skillsauth add RonanCodes/ronan-skills skills/migrate-to-astroInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Move an existing site (Astro on Vercel/VPS, plain Next.js marketing, hand-rolled HTML) onto the canonical Astro 5 + Cloudflare Workers stack. Defers everything that's the same as a fresh scaffold to /ro:new-astro-app; this skill's value is the cutover sequence for an already-live domain.
The full backstory and per-gotcha rationale lives in [[astro-cf-workers-migration-gotchas]] (LLM wiki research vault, 2026-05-04). That doc is this skill's spec — every numbered step below references the gotcha number. Read it first if you've never run this skill.
/ro:migrate-to-astro # interactive, picks up the cwd as the source
/ro:migrate-to-astro --strategy fresh # start a new repo, port content over
/ro:migrate-to-astro --strategy branch # in-place on a feature branch (default)
Before anything else, make sure ro is resolving the right context for this repo. Three options, cleanest first:
# Option A — drop a .ro-context file at the repo root (committed, auto-resolves
# for everyone who clones it; preferred for any repo with a stable owner/account)
echo simplicity > .ro-context
# Option B — already declared via a cwd-glob rule in ~/.claude/contexts.json
# (e.g. `~/Github-Simplicity/**` → `simplicity`)
ro context where # confirm the rule is hitting
# Option C — manual session override, useful for one-off ports outside any rule
ro context use <client-name>
Then load the env:
set -a; source "$(ro context env)"; set +a
If the source domain is in a different CF account than the one currently active, the deploy will fail with cryptic auth errors and the cutover will partially break. Always verify:
curl -s "https://api.cloudflare.com/client/v4/zones?name=<host>" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq '.result[] | {id, account_id, account_name}'
The account_id returned must match $CLOUDFLARE_ACCOUNT_ID. If not, switch context.
What's there now? Run in the source repo:
# What's the current adapter?
grep -E "@astrojs/(vercel|netlify|cloudflare|node)" package.json
# What output mode?
grep -E "output:\s*['\"](static|server|hybrid)" astro.config.*
# Any old observability we're replacing?
grep -rn "umami\|glitchtip\|sentry\|posthog" src/ | head
# What's the deploy mechanism?
ls .github/workflows/ vercel.json netlify.toml Dockerfile 2>/dev/null
Capture the output — the user needs to know which migrations they're signing up for.
Run the same edits /ro:new-astro-app would on a fresh project, but in-place:
@astrojs/cloudflare, output: 'server', imageService: 'compile' — gotcha #3dist/.assetsignore — gotcha #4pnpm add sharp — gotcha #12wrangler.jsonc (with routes: [{ custom_domain: true }] blocks for the live host)export const prerender = true; to every page under src/pages/ except api/ — gotcha #11If the source has Umami / GlitchTip / Plausible / native Vercel analytics, rip out and replace with the runtime-injected Sentry + PostHog setup:
Layout.astrosrc/lib/{sentry,posthog,runtime-config}.ts + src/pages/api/config.tssrc/env.d.ts with the CloudflareEnv types/ro:sentry "Footer-attached feedback button" section.If retaining historical analytics data is a concern, leave the source's Umami running on a analytics.<host> subdomain for ~30 days while PostHog ingestion stabilises, then export Umami CSV before tearing down.
If migrating from Vercel:
vercel.json, .github/workflows/preview.yml, any _middleware.ts Vercel-edge codecontext.locals.vercel?.edge?.geo?.country with request.headers.get("CF-IPCountry") — gotcha #8@astrojs/vercel, @vercel/analytics, @vercel/speed-insights from package.jsonsrc/env.d.ts: drop EdgeLocals import; declare CloudflareEnv insteadIf migrating from a VPS-hosted Astro: usually less to delete — the source repo has just astro + a deploy script. Drop the Dockerfile, the deploy script, and any health-check endpoints that won't make sense at the edge.
The pre-Astro-5 codebase likely has import { type ImageMetadata } from "astro:assets" in multiple files (gotcha #2). Rewrite globally:
grep -rl 'import.*type ImageMetadata.*from "astro:assets"' src/ \
| xargs sed -i '' -E 's|import \{ ?Image, type ImageMetadata ?\} from "astro:assets";|import { Image } from "astro:assets";\nimport type { ImageMetadata } from "astro";|'
Also: <!-- HTML comments --> inside JSX expressions ({condition ? (...), .map(() => (...))) cause prettier-plugin-astro and eslint-plugin-astro to choke (gotcha #1). Strip them or convert to {/* ... */}. Bulk-strip:
sed -i '' -E '/^[[:space:]]*\{\/\*.*\*\/\}[[:space:]]*$/d' <files>
Run pnpm exec astro check until it returns 0 errors. Don't skip this — pnpm quality-checks:ci calls it.
--skip-ci)Mirror /ro:new-astro-app's step 8: .github/workflows/ci.yml with three jobs (quality, e2e, deploy).
Provision the Production environment + 5 repo secrets:
gh api -X PUT repos/$OWNER/$REPO/environments/Production
for secret in CLOUDFLARE_API_TOKEN CLOUDFLARE_ACCOUNT_ID SENTRY_AUTH_TOKEN SENTRY_DSN POSTHOG_PROJECT_KEY; do
printf '%s' "${!secret}" | gh secret set "$secret" --env Production -R "$OWNER/$REPO"
done
Use the sntrys_ org auth token for SENTRY_AUTH_TOKEN in CI (org-scoped, source-map upload), not the sntryu_ user token.
--strategy branch, default)This is the only part of the skill that's destructive. Triple-check everything is committed before this step.
7a. Verify the target token can do everything it needs. Run all four probe calls — fail fast if any return code: 10000:
ZONE_ID=$(curl -s "https://api.cloudflare.com/client/v4/zones?name=<host>" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq -r '.result[0].id')
# Workers Scripts:Edit
curl -s "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/scripts" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq '.success'
# Workers Domains (custom domain attach)
curl -s "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/domains?zone_id=$ZONE_ID" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq '.success'
# Zone:Read
curl -s "https://api.cloudflare.com/client/v4/zones/$ZONE_ID" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" | jq '.success'
If Worker Routes:Edit is missing, that's fine — gotcha #5 has a workaround (skip routes in wrangler.jsonc the first time, attach domains by hand, add routes back).
7b. Upload the Worker WITHOUT routes. Temporarily remove the routes block from wrangler.jsonc (or --no-routes), set workers_dev: true, deploy:
pnpm exec wrangler deploy
# expect: "Uploaded <name>" + "register a workers.dev subdomain" prompt or success
The Worker now exists at <name>.<subdomain>.workers.dev (or just exists on the account). Test it directly via that URL before touching DNS.
7c. Cutover — delete VPS DNS records, attach custom domains. Need a token with Zone DNS:Edit (the CLOUDFLARE_API_TOKEN_DNS_ONLY token in the simplicity context worked):
# 1. List the live A/CNAME records
curl -s "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records?type=A&name=<host>" \
-H "Authorization: Bearer $DNS_TOKEN" | jq '.result[] | {id, content}'
# 2. DELETE each one (gotcha #6 — override_existing_dns_record does NOT work, must delete manually)
curl -s -X DELETE "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records/<record_id>" \
-H "Authorization: Bearer $DNS_TOKEN" | jq '.success'
# 3. Attach Workers custom domains for apex + www
for host in "<host>" "www.<host>"; do
curl -s -X PUT "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/workers/domains" \
-H "Authorization: Bearer $CLOUDFLARE_API_TOKEN" -H "Content-Type: application/json" \
-d "{\"environment\":\"production\",\"hostname\":\"$host\",\"service\":\"<worker-name>\",\"zone_id\":\"$ZONE_ID\"}" \
| jq '{success, errors, hostname: .result.hostname, cert_status: .result.cert_status}'
done
The instant step 2 completes, the live site stops resolving from the VPS. Provisioning of the new TLS cert + DNS records on the CF side is usually <60s. Have the rollback script ready (re-create the deleted A records pointing to the original IP) just in case.
7d. Add routes back to wrangler.jsonc (gotcha #7) and redeploy with full vars:
pnpm exec wrangler deploy \
--var SENTRY_DSN:"$SENTRY_DSN" \
--var POSTHOG_PROJECT_KEY:"$POSTHOG_PROJECT_KEY" \
--var POSTHOG_INGEST_HOST:"$POSTHOG_INGEST_HOST"
Now wrangler's deploy guard is satisfied (routes block exists, custom domains pre-bound), and every CI deploy is a pure code refresh.
# Live site headers — confirm CF (server: cloudflare in headers)
curl -sI "https://<host>/"
# Runtime config — DSN and phc_ should be present
curl -s "https://<host>/api/config" | jq '.'
# No /_image refs in homepage HTML (gotcha #3 + gotcha #11 verification)
curl -s "https://<host>/" | grep -ic "_image\?" || echo OK
# Logo loads from compiled WebP path
curl -s "https://<host>/" | grep -oE "/_astro/[^\"]+\.webp" | head -3
# Sentry initialised — open in browser, look for `[sentry] initialised` in console
If any of these fail, don't roll forward — debug. CF cache may serve the old VPS HTML for ~5 min on the same edge; if you need to bust faster, hit the cache-purge endpoint (needs Zone:Cache Purge:Purge scope on the token).
# Stop the old VPS (or unbind the domain at Vercel/Netlify/Pages)
# Remove the analytics subdomain tunnel (if Umami was self-hosted via CF Tunnel)
# Delete any CF Pages project that previously served this domain
# DON'T delete the source git tag yet — keep `pre-cloudflare-migration` as a rollback point
/ro:commit then /ro:gh-ship to open the migration PR. The commit/PR body should reference [[astro-cf-workers-migration-gotchas]] so future readers can trace decisions.
0. context → 1. audit → 2. scaffold → 3. observability → 4. framework cleanup
→ 5. fix typecheck → 6. CI + secrets → 7a. probe token → 7b. upload Worker
→ 7c. CUTOVER (delete A, attach domain) → 7d. routes back + redeploy
→ 8. verify → 9. decommission → 10. commit
git tag pre-cloudflare-migrationSame list as /ro:new-astro-app, plus the migration-specific ones:
override_existing_dns_record flag (gotcha #6 — the flag is documented but doesn't work; manually delete records first)wrangler deploy with the routes block on first cutover before domains are attached (causes the 10000 auth error from gotcha #5)pnpm exec astro check cleanup pass (gotchas #1 + #2 will surface as eslint/format failures in CI later, harder to diagnose)[[astro-cf-workers-migration-gotchas]] — the spec this skill implements/ro:new-astro-app — when you're starting fresh, no existing DNS to displace/ro:cf-ship — the deploy step (this skill calls it via pnpm run deploy)/ro:cloudflare-dns — for any extra subdomains beyond apex + www/ro:sentry — the "Footer-attached feedback button" section is the Ronan default for the <button id="sentry-feedback-trigger"> placement/ro:posthog — for the project create + phc_ provisioning/ro:migrate-to-tanstack — sister skill for sites that need a server runtime, not just edge HTMLtesting
--- name: linear-pipeline description: The Fable orchestrator for a single dispatched Linear ticket. Holds almost no context itself; it receives `--issue <ID> --detached`, decides the stage sequence, and fans out a sub-agent per stage, passing forward only each stage's artifact (never re-derived, never inlined into its own context). Step zero, before any planning or stage routing, is a boundary triage against `canon/security-boundary.md` (#199): a match tags Ronan Connolly and stops the run, no
development
--- name: in-your-face description: Capture a chat-only answer into a durable artifact (markdown + HTML, PDF when cheap) and launch it automatically so the user cannot miss it. Use when user says "in your face", "don't let me lose this", "save that answer", "make that durable", or right after answering a substantive side question (a recipe, comparison, how-to, or generated prompt) that would otherwise die with the context. category: workflow argument-hint: [--no-open] [--vault <short>] [hint of
tools
One-shot headless OpenAI Codex CLI calls for background/admin AI tasks — summaries, classification, extraction, admin glue. The default engine for anything that runs AI constantly in the background (daemon-driven, per-event), because it bills the flat ChatGPT subscription instead of Claude usage or per-token API spend, and it keeps working while Claude is rate-limited. NEVER for coding — coding stays Claude. Use when a skill or daemon needs a cheap always-on AI call, when the user says "use codex", "ask codex", "codex as backup", or when building a background summarizer/classifier into a listener or loop. Reads auth from ~/.codex/auth.json (ChatGPT account, no API key).
research
Turn a warranty rejection, repair quote, or RMA email into a cited decision brief — legal read (NL/EU consumer law), is the part user-serviceable, live part and new-unit prices, repair-vs-DIY-vs-new economics, before-you-send-it checklist, deadlines. Use when the user pastes or screenshots a repair quote, warranty rejection, "not covered" email, onderzoekskosten fee, or asks "should I repair or replace this".