diff --git a/optional-skills/web-development/cloudflare-temporary-deploy/SKILL.md b/optional-skills/web-development/cloudflare-temporary-deploy/SKILL.md new file mode 100644 index 00000000000..187a0482113 --- /dev/null +++ b/optional-skills/web-development/cloudflare-temporary-deploy/SKILL.md @@ -0,0 +1,127 @@ +--- +name: cloudflare-temporary-deploy +description: Deploy a Worker live, no account, via wrangler --temporary. +version: 1.0.0 +author: Hermes Agent +license: MIT +platforms: [linux, macos, windows] +metadata: + hermes: + tags: [cloudflare, workers, wrangler, deploy, temporary, agent, serverless, web-development] + category: web-development +--- + +# Cloudflare Temporary Deploy Skill + +Deploy a Cloudflare Worker to a live `workers.dev` URL with zero account setup, using `wrangler deploy --temporary`. Cloudflare provisions a throwaway account, deploys, and prints a claim URL valid for 60 minutes; unclaimed accounts auto-delete. This gives an agent a tight write → deploy → verify loop without any OAuth, signup, or token copy-paste. + +This skill does NOT cover production deploys (use `wrangler login` + a permanent account for those), nor non-Worker Cloudflare products beyond the temporary-account limits below. + +## When to Use + +Load this skill when the user wants to: + +- **Ship agent-written code to a live URL** without first creating a Cloudflare account — "deploy this and give me a link" +- **Iterate in a background/autonomous session** where a browser OAuth step would be a hard stop +- **Prototype or evaluate Workers** quickly with a throwaway, claimable target +- **Build a self-verifying deploy loop** — deploy, `curl` the live URL, confirm output matches the code, redeploy + +## When NOT to Use + +- **Production or CI/CD** → use a permanent account (`wrangler login` or `CLOUDFLARE_API_TOKEN`). `--temporary` errors out if any credential is present. +- **Wrangler is already authenticated** → `--temporary` returns an error by design. Run `wrangler logout` first only if the user explicitly wants a throwaway deploy. +- **Long-lived hosting** → temporary deployments are deleted after 60 minutes unless claimed. + +## Prerequisites + +- **Wrangler 4.102.0 or later.** This is the version that introduced `--temporary`. Earlier versions do not have it. Verify with `npx wrangler@latest --version`. +- **Node 18+ / npm** (or `npx`, `yarn`, `pnpm`). No global install needed — `npx wrangler@latest` works. +- **No Cloudflare credentials present.** `--temporary` only works when Wrangler is unauthenticated: no OAuth login, no `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_API_KEY` env var, no `~/.wrangler` / `~/.config/.wrangler` cached OAuth. Use the `terminal` tool's environment as-is; do not set those vars. +- Network egress to `cloudflare.com` and `workers.dev`. +- Using `--temporary` accepts Cloudflare's Terms of Service and Privacy Policy. + +## How to Run + +Use the `terminal` tool for every step. Always pin the version (`wrangler@latest` or `wrangler@4.102.0` or newer) so you don't accidentally run an old global wrangler that lacks the flag. + +1. **Scaffold a minimal Worker** (skip if the project already exists). A Worker needs a `wrangler.toml` (or `wrangler.jsonc`) and an entry script. Minimal TypeScript example — write these with `write_file`: + + `wrangler.jsonc`: + ```jsonc + { + "name": "hello-agent", + "main": "src/index.ts", + "compatibility_date": "2025-01-01" + } + ``` + + `src/index.ts`: + ```typescript + export default { + async fetch(): Promise { + return new Response("hello cloudflare"); + }, + }; + ``` + +2. **Deploy with `--temporary`** from the project directory: + ``` + npx wrangler@latest deploy --temporary + ``` + The proof-of-work check adds a short automatic delay. On success Wrangler prints an `Account: (created)` (or `(reused)`) line, a `Claim URL`, and the live `https://..workers.dev` URL. + +3. **Parse the URLs** from that output. Run the helper to extract them reliably instead of eyeballing: + ``` + npx wrangler@latest deploy --temporary 2>&1 | python3 scripts/parse_deploy_output.py + ``` + (Resolve `scripts/parse_deploy_output.py` to this skill's absolute path.) It prints JSON: `{"live_url", "claim_url", "account", "account_state", "expires_minutes", "deployed"}`. + +4. **Verify the deploy is actually live** — do not trust the deploy log alone. `curl` the live URL and confirm the body matches what the code returns: + ``` + curl -sS + ``` + +5. **Iterate.** Edit the code, redeploy with the same `npx wrangler@latest deploy --temporary`. Within the 60-minute window Wrangler reuses the cached temporary account (`Account: (reused)`), so the URL stays stable. `curl` again to confirm the change. + +6. **Hand the claim URL to the user.** Tell them: open it within 60 minutes to keep the deployment and any resources; if they don't claim it, everything auto-deletes. Treat the claim URL as a secret — it grants ownership of the account. + +## Quick Reference + +| Step | Command | +|---|---| +| Check version (need 4.102.0+) | `npx wrangler@latest --version` | +| Deploy (no account) | `npx wrangler@latest deploy --temporary` | +| Deploy + parse URLs | `npx wrangler@latest deploy --temporary 2>&1 \| python3 scripts/parse_deploy_output.py` | +| Verify live | `curl -sS ` | +| Clear cached temp account | `npx wrangler@latest logout` | + +### Temporary account product limits + +| Product | Limit on a temporary account | +|---|---| +| Workers | Deploys to `workers.dev` | +| Static Assets | Up to 1,000 files, 5 MiB each | +| KV | Allowed | +| D1 | 1 database, 100 MB per DB / 100 MB total | +| Durable Objects | Allowed | +| Hyperdrive | 2 configs, 10 connections | +| Queues | Up to 10 | +| SSL/TLS certs | Allowed | + +## Pitfalls + +- **`--temporary` is not in `wrangler deploy --help` and is not a global flag.** It is intentionally hidden and surfaced dynamically: when an unauthenticated `wrangler deploy` fails, Wrangler prints "rerun with `--temporary`". Don't conclude the flag is missing just because `--help` omits it — check the version instead. +- **Old global wrangler.** A stale globally-installed `wrangler` (`< 4.102.0`) silently lacks the flag. Always invoke `npx wrangler@latest` (or a pinned `>=4.102.0`) so you control the version. +- **Auth present → hard error.** If `wrangler login` was ever run, or `CLOUDFLARE_API_TOKEN`/`CLOUDFLARE_API_KEY` is set, `--temporary` errors. Either unset the var for this shell or `wrangler logout`. Never strip a user's real credentials without telling them. +- **Rate limiting.** Creating temporary accounts too fast fails. Reuse the cached account (just redeploy) within the 60-minute window instead of forcing a new one; if rate-limited, wait or use a permanent account. +- **60-minute hard expiry, not extendable.** If the deploy must outlive an hour, the user must claim it. Surface this clearly. +- **`curl` may briefly serve the old body after a redeploy.** `workers.dev` has a short edge cache; the `(reused)` line plus a new `Current Version ID` confirm the deploy succeeded even if `curl` shows stale content for a few seconds. Re-curl, or add a cache-busting query string, before concluding a redeploy failed. +- **Don't log the claim URL into shared transcripts as "just a link."** It is credential-equivalent. + +## Verification + +- `npx wrangler@latest --version` returns `>= 4.102.0`. +- `npx wrangler@latest deploy --temporary` prints a `workers.dev` live URL and a `claim-preview?claimToken=` claim URL. +- `curl -sS ` returns the exact body the Worker code produces. +- A second deploy reports `Account: (reused)` and the live URL is unchanged. +- The parser script's self-test passes: `python3 scripts/parse_deploy_output.py --selftest`. diff --git a/optional-skills/web-development/cloudflare-temporary-deploy/scripts/parse_deploy_output.py b/optional-skills/web-development/cloudflare-temporary-deploy/scripts/parse_deploy_output.py new file mode 100644 index 00000000000..978f0a06ed7 --- /dev/null +++ b/optional-skills/web-development/cloudflare-temporary-deploy/scripts/parse_deploy_output.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Parse `wrangler deploy --temporary` output into structured JSON. + +Reads wrangler's stdout/stderr from STDIN and extracts the live workers.dev +URL, the claim URL, the temporary account name/state, the claim window, and +whether a deploy actually happened. Stdlib only — no dependencies. + +Usage: + npx wrangler@latest deploy --temporary 2>&1 | python3 parse_deploy_output.py + python3 parse_deploy_output.py --selftest +""" + +from __future__ import annotations + +import json +import re +import sys + +# Match the live workers.dev URL (subdomain.subdomain.workers.dev). +_LIVE_URL = re.compile(r"https://[A-Za-z0-9._-]+\.workers\.dev\S*") +# Match the claim URL. Cloudflare uses dash.cloudflare.com/claim-preview?claimToken=... +# Keep it broad enough to survive minor path changes while still requiring a claim token. +_CLAIM_URL = re.compile(r"https://\S*claim\S*claimToken=\S+", re.IGNORECASE) +# "Account: Serene Temple (created)" / "Account: example-name (reused)" +# Account names can contain spaces (e.g. "Serene Temple"), so capture everything +# up to the trailing "(state)" marker rather than a single token. +_ACCOUNT = re.compile( + r"Account:\s*(?P.+?)\s*\((?Pcreated|reused)\)", re.IGNORECASE +) +# "Claim within: 60 minutes" +_CLAIM_WITHIN = re.compile(r"Claim within:\s*(?P\d+)\s*minutes?", re.IGNORECASE) +# A successful deploy prints a "Deployed" / "Uploaded" line. +_DEPLOYED = re.compile(r"^\s*(Deployed|Uploaded)\b", re.IGNORECASE | re.MULTILINE) + + +def _first(pattern: re.Pattern, text: str) -> str | None: + m = pattern.search(text) + if not m: + return None + # Strip trailing punctuation that often clings to a URL in log lines. + return m.group(0).rstrip(".,);]") + + +def parse(text: str) -> dict: + """Extract deploy facts from wrangler output text.""" + account = _ACCOUNT.search(text) + claim_within = _CLAIM_WITHIN.search(text) + return { + "live_url": _first(_LIVE_URL, text), + "claim_url": _first(_CLAIM_URL, text), + "account": account.group("name") if account else None, + "account_state": account.group("state").lower() if account else None, + "expires_minutes": int(claim_within.group("minutes")) if claim_within else None, + "deployed": bool(_DEPLOYED.search(text)), + } + + +_SAMPLE = """\ +Continuing means you accept Cloudflare's Terms of Service and Privacy Policy. + +Temporary account ready: + Account: example-name (created) + Claim within: 60 minutes + Claim URL: https://dash.cloudflare.com/claim-preview?claimToken=abc123XYZ + +Uploaded example-worker +Deployed example-worker triggers + https://example-worker.example-name.workers.dev +""" + +_SAMPLE_REUSED = """\ +Temporary account ready: + Account: example-name (reused) + Claim within: 42 minutes + Claim URL: https://dash.cloudflare.com/claim-preview?claimToken=def456 +Deployed example-worker triggers + https://example-worker.example-name.workers.dev +""" + +_SAMPLE_NO_TEMP = """\ +✘ [ERROR] You are not logged in. + +To continue without logging in, rerun this command with `--temporary`. +""" + + +def _selftest() -> int: + r = parse(_SAMPLE) + assert r["live_url"] == "https://example-worker.example-name.workers.dev", r + assert r["claim_url"] == "https://dash.cloudflare.com/claim-preview?claimToken=abc123XYZ", r + assert r["account"] == "example-name", r + assert r["account_state"] == "created", r + assert r["expires_minutes"] == 60, r + assert r["deployed"] is True, r + + r2 = parse(_SAMPLE_REUSED) + assert r2["account_state"] == "reused", r2 + assert r2["expires_minutes"] == 42, r2 + assert r2["deployed"] is True, r2 + + r3 = parse(_SAMPLE_NO_TEMP) + assert r3["live_url"] is None, r3 + assert r3["claim_url"] is None, r3 + assert r3["account"] is None, r3 + assert r3["deployed"] is False, r3 + + print("selftest: OK") + return 0 + + +def main(argv: list[str]) -> int: + if "--selftest" in argv: + return _selftest() + text = sys.stdin.read() + result = parse(text) + print(json.dumps(result, indent=2)) + # Non-zero exit if no live URL was found, so callers can branch on it. + return 0 if result["live_url"] else 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tests/skills/test_cloudflare_temporary_deploy_skill.py b/tests/skills/test_cloudflare_temporary_deploy_skill.py new file mode 100644 index 00000000000..c7bd3c3acdb --- /dev/null +++ b/tests/skills/test_cloudflare_temporary_deploy_skill.py @@ -0,0 +1,164 @@ +"""Tests for optional-skills/web-development/cloudflare-temporary-deploy/scripts/parse_deploy_output.py""" + +import json +import sys +from pathlib import Path +from unittest import mock + +import pytest + +SCRIPTS_DIR = ( + Path(__file__).resolve().parents[2] + / "optional-skills" + / "web-development" + / "cloudflare-temporary-deploy" + / "scripts" +) +sys.path.insert(0, str(SCRIPTS_DIR)) + +import parse_deploy_output as pdo + + +CREATED = """\ +Continuing means you accept Cloudflare's Terms of Service and Privacy Policy. + +Temporary account ready: + Account: swift-otter (created) + Claim within: 60 minutes + Claim URL: https://dash.cloudflare.com/claim-preview?claimToken=TOKEN_AAA + +Uploaded my-worker +Deployed my-worker triggers + https://my-worker.swift-otter.workers.dev +""" + +REUSED = """\ +Temporary account ready: + Account: swift-otter (reused) + Claim within: 17 minutes + Claim URL: https://dash.cloudflare.com/claim-preview?claimToken=TOKEN_BBB +Deployed my-worker triggers + https://my-worker.swift-otter.workers.dev +""" + +NOT_LOGGED_IN = """\ +✘ [ERROR] You are not logged in. + +To continue without logging in, rerun this command with `--temporary`. +""" + +AUTH_PRESENT_ERROR = """\ +✘ [ERROR] The --temporary flag cannot be used while Wrangler is authenticated. +Run `wrangler logout` first, or remove CLOUDFLARE_API_TOKEN. +""" + + +class TestParseCreated: + def test_live_url(self): + assert pdo.parse(CREATED)["live_url"] == "https://my-worker.swift-otter.workers.dev" + + def test_claim_url(self): + assert ( + pdo.parse(CREATED)["claim_url"] + == "https://dash.cloudflare.com/claim-preview?claimToken=TOKEN_AAA" + ) + + def test_account_and_state(self): + r = pdo.parse(CREATED) + assert r["account"] == "swift-otter" + assert r["account_state"] == "created" + + def test_expiry_and_deployed(self): + r = pdo.parse(CREATED) + assert r["expires_minutes"] == 60 + assert r["deployed"] is True + + +class TestParseReused: + def test_state_is_reused(self): + assert pdo.parse(REUSED)["account_state"] == "reused" + + def test_expiry_window_can_shrink(self): + assert pdo.parse(REUSED)["expires_minutes"] == 17 + + def test_live_url_stable(self): + assert pdo.parse(REUSED)["live_url"] == "https://my-worker.swift-otter.workers.dev" + + +class TestNoDeploy: + def test_not_logged_in_has_no_urls(self): + r = pdo.parse(NOT_LOGGED_IN) + assert r["live_url"] is None + assert r["claim_url"] is None + assert r["account"] is None + assert r["deployed"] is False + + def test_auth_present_error_has_no_urls(self): + r = pdo.parse(AUTH_PRESENT_ERROR) + assert r["live_url"] is None + assert r["claim_url"] is None + assert r["deployed"] is False + + +class TestRealWorldOutput: + """Regression: real wrangler output uses tab-indent + multi-word account names.""" + + REAL = ( + "⛅️ wrangler 4.103.0\n" + "Continuing means you accept Cloudflare's Terms of Service and Privacy Policy.\n" + "Solving proof-of-work challenge…\n" + "Temporary account ready:\n" + "\tAccount: Serene Temple (created)\n" + "\tClaim within: 60 minutes\n" + "\tClaim URL: https://dash.cloudflare.com/claim-preview?claimToken=fxLzyAD-vlTzMQmClpg\n" + "Total Upload: 0.19 KiB / gzip: 0.16 KiB\n" + "Uploaded hermes-temp-hello (0.74 sec)\n" + "Deployed hermes-temp-hello triggers (0.42 sec)\n" + " https://hermes-temp-hello.serene-temple.workers.dev\n" + ) + + def test_multiword_account_name(self): + r = pdo.parse(self.REAL) + assert r["account"] == "Serene Temple" + assert r["account_state"] == "created" + + def test_all_fields_from_real_output(self): + r = pdo.parse(self.REAL) + assert r["live_url"] == "https://hermes-temp-hello.serene-temple.workers.dev" + assert r["claim_url"].endswith("claimToken=fxLzyAD-vlTzMQmClpg") + assert r["expires_minutes"] == 60 + assert r["deployed"] is True + + +class TestUrlHygiene: + def test_trailing_punctuation_stripped(self): + text = "Deployed\n see https://w.acct.workers.dev. for details" + assert pdo.parse(text)["live_url"] == "https://w.acct.workers.dev" + + def test_does_not_match_plain_cloudflare_com(self): + # A generic cloudflare.com link without a claimToken must not be taken as the claim URL. + text = "Privacy Policy: https://www.cloudflare.com/privacypolicy/\nDeployed x" + assert pdo.parse(text)["claim_url"] is None + + +class TestCli: + def test_selftest_exits_zero(self): + assert pdo.main(["--selftest"]) == 0 + + def test_main_prints_json_and_exit_zero_on_live(self, capsys): + with mock.patch.object(sys.stdin, "read", return_value=CREATED): + rc = pdo.main([]) + out = json.loads(capsys.readouterr().out) + assert rc == 0 + assert out["live_url"] == "https://my-worker.swift-otter.workers.dev" + + def test_main_exit_one_when_no_live_url(self, capsys): + with mock.patch.object(sys.stdin, "read", return_value=NOT_LOGGED_IN): + rc = pdo.main([]) + out = json.loads(capsys.readouterr().out) + assert rc == 1 + assert out["live_url"] is None + + +if __name__ == "__main__": + raise SystemExit(pytest.main([__file__, "-q"]))