mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-23 10:42:00 +00:00
feat(skills): add cloudflare-temporary-deploy optional skill (#50849)
* chore: re-trigger CI (workflows did not dispatch on prior head) * feat(skills): add cloudflare-temporary-deploy optional skill Optional web-development skill teaching the agent to deploy a Worker to a live workers.dev URL with no Cloudflare account via 'wrangler deploy --temporary' (Wrangler 4.102.0+). Cloudflare provisions a throwaway, claimable account valid for 60 minutes — ideal for an autonomous write->deploy->verify loop with no OAuth/signup hard stop. - SKILL.md: when/when-not, prereqs (unauth requirement, version floor), step-by-step deploy + verify flow, product limits table, pitfalls (hidden flag, stale global wrangler, auth-present error, rate limits, workers.dev edge cache), verification. - scripts/parse_deploy_output.py: stdlib-only parser extracting live URL, claim URL, account name/state, expiry, deploy status from wrangler output. - tests/skills/test_cloudflare_temporary_deploy_skill.py: 16 tests incl. a real-output regression case. Verified live end-to-end: temporary account created with no creds, deployed to a live URL, curl confirmed body, redeploy reused the account.
This commit is contained in:
parent
7dece1d933
commit
ff08e60c63
3 changed files with 413 additions and 0 deletions
|
|
@ -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<Response> {
|
||||
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: <name> (created)` (or `(reused)`) line, a `Claim URL`, and the live `https://<worker>.<account>.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 <live_url>
|
||||
```
|
||||
|
||||
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: <name> (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 <live_url>` |
|
||||
| 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 <live_url>` returns the exact body the Worker code produces.
|
||||
- A second deploy reports `Account: <name> (reused)` and the live URL is unchanged.
|
||||
- The parser script's self-test passes: `python3 scripts/parse_deploy_output.py --selftest`.
|
||||
|
|
@ -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<name>.+?)\s*\((?P<state>created|reused)\)", re.IGNORECASE
|
||||
)
|
||||
# "Claim within: 60 minutes"
|
||||
_CLAIM_WITHIN = re.compile(r"Claim within:\s*(?P<minutes>\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:]))
|
||||
164
tests/skills/test_cloudflare_temporary_deploy_skill.py
Normal file
164
tests/skills/test_cloudflare_temporary_deploy_skill.py
Normal file
|
|
@ -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"]))
|
||||
Loading…
Add table
Add a link
Reference in a new issue