Parkstatic local deploy (agent-driven, no GitHub Actions)
Use this skill to deploy a static site to Parkstatic directly from a local machine or an AI agent, bypassing the ParkStatic/action GitHub workflow. The canonical deploy path is the deploy.sh script in ParkStatic/action; this skill reproduces its three meaningful steps — build → package → upload — as plain local commands, so an agent (or a developer) can ship a build from a laptop, a CI runner without the action installed, or an ad-hoc prompt.
The deploy target is the official Parkstatic deploy endpoint at https://deploy.parkstatic.site. It authenticates the upload, parks the artifact in private storage, signs a short-lived download URL, and hands it off to the WordPress site's receiver. The receiver pulls the zip, extracts it, and serves it. With X-Parkstatic-Wait: true the endpoint waits for WordPress to confirm the install, so a WP-side failure surfaces as a non-2xx here instead of going green silently.
When to use
- Deploying from a local clone with the Parkstatic deploy secret on hand.
- An AI agent shipping a build on behalf of the user (no GitHub Actions run available).
- Dry runs / hotfixes where spinning up a full Actions workflow is overkill.
Prerequisites
- A Parkstatic license and the deploy secret. The secret authorizes the upload; the agent cannot deploy without it. Obtain it by completing the official onboarding at ****:
- Buy a license and download the plugin from your account.
- Install + activate the plugin on your WordPress site (
Plugins → Add New → Upload Plugin), enter your license key. - The plugin's onboarding generates a unique
PARKSTATIC_SECRET(Step 4 of the get-started walkthrough). Copy it fromParkstatic → General → Deploy secretin WP admin. The secret is sent to the deploy endpoint asX-Parkstatic-Token. Treat it as a credential — never commit it, never paste it into committed files, redact it from logs and transcripts.
- Deploy URL — the official Parkstatic deploy endpoint:
https://deploy.parkstatic.site - A project that builds to static HTML. Parkstatic supports Vite, Astro, SvelteKit, Remix, React Router, Nuxt, Next.js (static export), Lovable, TanStack Start, plain React/Vue SPAs, and more. The build must emit an
index.htmlthe action's locator would recognise (see step 5). For SSR-only builds that need a headless crawl to materialise HTML, use the GitHub Action instead — this skill deploys the build output verbatim and does not crawl. zipandcurlon PATH, andgitin the repo (git only populates theX-Parkstatic-*metadata headers; deploy does not require a push).
Execution spine
Run every step from the repository root. The skill is package-manager- and framework-agnostic: it detects the manager from the lockfile and runs the project's own build script, then locates the output with the same candidate-list heuristic the action uses.
1. Detect the package manager
Pick the manager from the lockfile present at the repo root (a simplified detection that covers the common cases; for exact parity with CI use the GitHub Action):
if [ -f pnpm-lock.yaml ]; then
PM=pnpm
elif [ -f bun.lock ]; then
PM=bun
elif [ -f bun.lockb ]; then
PM=bun
elif [ -f yarn.lock ]; then
PM=yarn
elif [ -f package-lock.json ]; then
PM=npm
else
PM=npm # fallback: npm with no lockfile
fi
echo "Package manager: $PM"
2. Use the right Node version
Match the version pinned in .nvmrc / .node-version if present (the action defaults to Node 22):
if [ -f .nvmrc ] || [ -f .node-version ]; then
source ~/.nvm/nvm.sh 2>/dev/null && nvm use
fi
node --version
3. Install dependencies
Install with the detected manager. Use the frozen/ci install when a lockfile exists, falling back to a plain install if it's out of sync:
case "$PM" in
pnpm) pnpm install --frozen-lockfile || pnpm install ;;
npm) npm ci || npm install ;;
yarn) yarn install --frozen-lockfile || yarn install ;;
bun) bun install ;;
esac
4. Build
Run the project's own build script. Every framework Parkstatic supports exposes its build through the build script in package.json (vite build, astro build, nuxt build, next build, remix vite:build, react-router build, svelte-kit build, TanStack Start's vite build, Lovable's node scripts/generate-build-artifacts.mjs && vite build, etc.), so a single run build covers them all:
case "$PM" in
pnpm) pnpm run build ;;
npm) npm run build ;;
yarn) yarn build ;;
bun) bun run build ;;
esac
If package.json has no build script, fall back to vite build (the action does the same):
node -e 'const p=require("./package.json");process.exit(p.scripts&&p.scripts.build?0:1)' 2>/dev/null \
|| "$PM" exec vite build
Framework-specific notes (only when the project deviates from the standard build script):
| Framework | Build command | Typical output dir |
|---|---|---|
| Vite (SPA) | vite build | dist |
| TanStack Start (prerender) | vite build (with prerender: { enabled: true }) | dist/client (already-prerendered HTML) |
| TanStack Start (SSR, no prerender) | vite build | dist/server + dist/client — needs the action's crawl; do not deploy locally |
| Lovable (vite-tanstack-config) | node scripts/generate-build-artifacts.mjs && vite build | dist/client |
| Astro (static) | astro build | dist |
| Astro (SSR adapter) | astro build | adapter-specific — needs the action's crawl; do not deploy locally |
| SvelteKit (static adapter) | svelte-kit build (via build script) | build |
| Remix (Vite) | remix vite:build (via build script) | build/client |
| React Router (framework) | react-router build (via build script) | build/client |
Nuxt (static / nuxi generate) | nuxt generate (via build script) | .output/public |
| Next.js (static export) | next build (with output: 'export') | out |
5. Locate the static output directory
The action's find_output_dir heuristic checks, in order: dist/client, dist, build, build/client, .output/public, out — the first one containing an index.html wins. Replicate that locally (it already covers every framework in the table above):
OUTPUT_DIR=""
for candidate in dist/client dist build build/client .output/public out; do
if [ -f "$candidate/index.html" ]; then
OUTPUT_DIR="$candidate"
break
fi
done
[ -n "$OUTPUT_DIR" ] || { echo "No static output found (no index.html in any candidate dir)"; exit 1; }
echo "Output: $OUTPUT_DIR"
For TanStack Start prerendered builds, confirm the framework's own hydration bootstrap is present (the action keys off this marker and ships the build verbatim, skipping any crawl):
grep -qE '\$_TSR|\$tsr-stream-barrier' "$OUTPUT_DIR/index.html" \
&& echo "prerendered build — deploy verbatim" \
|| echo "static SPA build"
6. Package into dist.zip at the workspace root
Zip the contents of $OUTPUT_DIR (not the directory itself) into dist.zip at the repo root. Use an absolute path so the destination is correct regardless of how deep $OUTPUT_DIR is.
ZIP_PATH="$(pwd)/dist.zip"
rm -f "$ZIP_PATH"
( cd "$OUTPUT_DIR" && zip -qr "$ZIP_PATH" . )
7. Strip unsafe entries (WordPress zip validation)
WordPress's receiver rejects archives containing unsafe paths or file types with parkstatic_unsafe_zip_entry (wp_status: 400). The most common offender on macOS is .DS_Store; exclude all dotfiles to be safe:
rm -f "$ZIP_PATH"
( cd "$OUTPUT_DIR" && zip -qr "$ZIP_PATH" . -x '.DS_Store' '*/.DS_Store' '*/.*' '.*' )
# sanity check: no stray dotfiles in the archive
unzip -l "$ZIP_PATH" | grep -E '(^| +)\.' | grep -vE '\.(html|png|ico|svg|webp|txt|webmanifest|xml|css|js|json)$' \
&& { echo "unsafe dotfiles still present"; exit 1; } || echo "archive clean"
8. Upload to the deploy endpoint
Mirror the headers deploy.sh sends. X-Parkstatic-Wait: true makes the function block until WordPress confirms the install, so a WP-side failure returns non-2xx here.
DEPLOY_URL="https://deploy.parkstatic.site"
PARKSTATIC_SECRET="" # never commit this; export it in the shell instead
GH_SHA=$(git rev-parse HEAD)
GH_REF=$(git rev-parse --abbrev-ref HEAD)
GH_REPO=$(git config --get remote.origin.url | sed 's/\.git$//')
response=$(curl -sS -w '\n__HTTP_STATUS__:%{http_code}' -X POST \
-H "X-Parkstatic-Token: $PARKSTATIC_SECRET" \
-H "Content-Type: application/zip" \
-H "X-Parkstatic-Sha: $GH_SHA" \
-H "X-Parkstatic-Ref: $GH_REF" \
-H "X-Parkstatic-Repository: $GH_REPO" \
-H "X-Parkstatic-Wait: true" \
--data-binary "@dist.zip" \
"$DEPLOY_URL")
HTTP_STATUS="${response##*__HTTP_STATUS__:}"
HTTP_BODY="${response%$'\n'__HTTP_STATUS__:*}"
echo "HTTP $HTTP_STATUS"
echo "$HTTP_BODY"
9. Interpret the response
- 2xx with
"ok":true→ success.deploy_idis the server-side UUID for the artifact;wp_status: 200means WordPress confirmed the install. Example:{"ok":true,"deploy_id":"0e4b19aa-51d9-428e-94c1-e750b17df6e0","wp_status":200,"message":"WordPress installed 74 files."} - 401 →
X-Parkstatic-Tokenmissing or empty. The Worker didn't forward a token (you forgot to setPARKSTATIC_SECRET, or sent it on the wrong header). - 403 → no active paid license. Activate/renew in
Parkstatic → Account. - 404 → secret did not match any registered Parkstatic instance. Re-copy it from
Parkstatic → General → Deploy secretin WP admin, or complete setup first. - 400 → malformed upload. If the body says
parkstatic_unsafe_zip_entry, re-zip with the dotfile excludes from step 7. - 502 → WordPress rejected the deploy. The body carries
wp_statusand the receiver's error; common Cloudflare origin codes522/523/524mean the WP server isn't completing requests. - 5xx (other) → Parkstatic storage/signing layer error; usually transient, re-run.
Reference: what the action does that this skill skips
- Plan step (
plan.sh) — sends a repo fingerprint to the plan endpoint and receives a declarative build plan (deps to inject, build kind, whether to prerender). Locally you just run your ownbuildscript. - Prerender crawl (
prerender.sh) — for SSR/SPA builds with no framework prerender, the action boots the server entry (Miniflare for the Cloudflare preset, otherwise a Node fetch server) and crawls routes with headless Chromium to materialise HTML. This local skill does not crawl. Ship a build that already contains final HTML (TanStack Startprerender, Astro static, SvelteKit static adapter, Next.jsoutput: 'export', Nuxtnuxi generate, Vite SPA with a prerender plugin, or a pre-builtdist). For SSR-only builds that need crawl materialisation, use the GitHub Action. - Dependency injection — the action installs build-only scaffolding (e.g.
@tanstack/query-core) into the runner only. Locally yournode_modulesmust already build cleanly.
Verification
After a successful deploy, confirm the live site against your custom domain (the one you mapped to your Parkstatic site in WP admin, not parkstatic.site itself):
SITE_URL="https://your-custom-domain.example.com"
curl -sS -o /dev/null -w 'HTTP %{http_code}\n' -L "$SITE_URL"
curl -sS -L "$SITE_URL" | grep -oE '<title>[^<]*</title>' | head -1
House rules
- Never commit the secret. Pass it via an env var, a password manager, or an OS keychain. Redact it from logs and transcripts.
- Always exclude dotfiles before zipping; WordPress rejects
.DS_Storeand similar. - Ship final HTML only. This skill deploys the build output verbatim; it does not prerender. SSR-only builds that need a crawl must use the GitHub Action.
- One build → one zip → one upload. Don't reuse a stale
dist.zipacross rebuilds; re-run package + upload each time.