mirror of
https://github.com/aaPanel/aaPanel.git
synced 2026-08-17 21:25:47 +02:00
Update to v8.16.0
This commit is contained in:
@@ -0,0 +1,231 @@
|
||||
---
|
||||
name: aaPanel Site Design
|
||||
description: >
|
||||
Design a website's UI spec and author a complete static front-end (HTML/CSS/JS) — the model
|
||||
picks a style, fetches the UI spec, and writes clean static pages. The artifact is a static
|
||||
file set under the project dir (draft → generated). Use it to:
|
||||
design a website / landing page / portfolio / official site / business site / personal site /
|
||||
company homepage / brand site. Scope: UI design +
|
||||
static front-end ONLY (HTML/CSS/JS). Available in website-type sessions via the Skills tool (chat_type=website). NOT for: WordPress (use
|
||||
WordPress, empty/bare sites, deploying user-supplied source code, or any non-UI panel task —
|
||||
these are refused with a pointer to a chat session (this skill never exits the website session).
|
||||
---
|
||||
|
||||
# aaPanel Site Design
|
||||
|
||||
> **Single Source of Truth**: tool-usage limits (ProjectSave only for info docs; no Write/cp/echo on them; memory ProjectSave only, no NoteSave/DocSave; Hard Red Lines scope) live in the **website system_prompt** (`dynamic.py designer_prompts`, `prompts_name="website"`) — prompts are maintained centrally in `dynamic.py`. This document governs the **design workflow** (S0–S3, state machine, iteration). Changes to those limits must be made in `dynamic.py`, not here.
|
||||
|
||||
> **STOP. Read this document completely before doing anything.** Do NOT skip sections.
|
||||
> **1 session = 1 project**, bound under `memories/projects/<id>/` — this skill drives that project's design workflow (S0–S3). Scope, forbiddens, and Hard Red Lines are in the system prompt.
|
||||
|
||||
---
|
||||
|
||||
## Core Rules
|
||||
|
||||
1. **UI design + static front-end.** Always a **single-page UI** (one page, all
|
||||
sections + anchor nav; never multi-page). CSS hand-written inlined in `<style>`, JS inlined in
|
||||
`<script>`; all assets CDN-linked, zero framework, zero JS runtime (Lucide icons excepted).
|
||||
2. **Three writers, three jobs** (workflow spine): `ProjectSave` → project info docs + `meta_snapshot` + session binding; `Write` → the UI artifact; `RunCommand` → shell (`curl` style-spec fetch, image search). Info docs always overwrite with current factual content (not a changelog), on BOTH init AND iteration. **Strict per-writer limits live in the system prompt.**
|
||||
3. **One session = one project.** `project_id` is set at S2 and never changes; route by project
|
||||
existence (ITERATE vs NEW).
|
||||
4. **website session only** — runtime-enforced (chat sessions cannot load this skill); no check needed.
|
||||
5. **Track progress with `TodoWrite`** — create the task list before set-up, using **neutral step titles** (no internal phase codes or file names), e.g. *Plan · Recommend a style · Set up the project · Fetch images · Build the UI*; mark one `in_progress` at a time, flip to `completed` when done.
|
||||
6. **Iteration resets to `generated`.** ANY write to the UI (re-generate) forces `status→generated`;
|
||||
sync `meta_snapshot` accordingly.
|
||||
7. **Iteration spec judgment (autonomous).** Decide by the nature of the change (both overwrite
|
||||
the progress doc with current factual progress):
|
||||
- **Light edit** (color / copy / image swap) → edit the UI only; do NOT touch the spec / the design. Then `ProjectSave(project_id=<id>, files={"meta.md": <full, status=generated>, "progress.md": <full>}, meta_snapshot={"status":"generated"})`.
|
||||
- **Heavy edit** (style / layout / design system) → **re-fetch the style spec** (curl -o overwrite from the new stem) + rewrite the design + regenerate the UI, then `ProjectSave(project_id=<id>, files={"meta.md": <full, style field updated + status=generated>, "design.md": <full new>, "progress.md": <full>}, meta_snapshot={"style": <new-stem>, "status":"generated"})`. No new session needed.
|
||||
8. **UI spec = remote fetch + persist**: at S2 download the chosen style's design spec from
|
||||
`https://node.aapanel.com/aapanel/awesome/<Style-File>.md` (fallback host `jp1-node.aapanel.com`)
|
||||
and save it as the project's style spec — a self-contained copy. After that the project never
|
||||
needs the network again. See S1 for the style-file list, S2 step 2 for the fetch.
|
||||
9. **Every image slot gets a real photo** — fetch via the skill script BEFORE writing HTML (see S3 Images).
|
||||
|
||||
---
|
||||
|
||||
## Tooling
|
||||
|
||||
| step | tools (risk) |
|
||||
|---|---|
|
||||
| Load skill | `Skills` (low) |
|
||||
| S0 Route | `TodoWrite` (low) · `Read` (low, iterate only) |
|
||||
| S1 Style | — (chat-only; catalog self-contained) |
|
||||
| S2 Init | `RunCommand` (high, curl style-spec) · `ProjectSave` (low, internal) · `TodoWrite` (low) |
|
||||
| S3 Generate | `Read` (low) · `RunCommand` (high, images first) · `Write` (high) · `ProjectSave` (low) · `TodoWrite` (low) |
|
||||
|
||||
---
|
||||
|
||||
## State Machine
|
||||
|
||||
```
|
||||
draft ──S3 generate──→ generated
|
||||
↑ │
|
||||
└─ S3 iterate ──┘ re-generate forces generated
|
||||
```
|
||||
|
||||
- `draft`: project init done, artifact not yet generated.
|
||||
- `generated`: the static UI artifact has been written.
|
||||
- Any UI rewrite (S3 iteration) → forces `generated` (Core Rules #6).
|
||||
|
||||
---
|
||||
|
||||
## S0 — Intent + Route
|
||||
|
||||
> Session type is runtime-enforced (chat sessions cannot load this skill) — reaching here means a
|
||||
> website session. No session check needed.
|
||||
|
||||
**Scope & route** — one decision tree:
|
||||
|
||||
**Out of scope → refuse.** Scope + refusal wording live in the system prompt (UI design only; deploying THIS skill's generated artifact is the sole in-scope exception — only on explicit user request + Hard Red Lines consent; anything else — WordPress, empty/bare site, foreign source code, SSL/DB/firewall/service/file management — refuse and point the user to a new chat session, never exit). Route the in-scope request below.
|
||||
|
||||
**In scope → route by project existence** (check the injected `## Active project` block for current
|
||||
`project_id` + `status`):
|
||||
- **ITERATE** (project exists): `Read` the project info (meta for `status`, design, progress) + the style spec + the current UI. Skip S1/S2 — style and `project_id` are set. Pick up by `status`:
|
||||
- `draft` → read the existing UI to see what's done, then regenerate at S3 (overwrite it, preserving finished sections). If the UI is absent (init crashed mid-S2): **verify the style spec is complete** (Read it; if truncated — size well under ~30KB or cuts off mid-token — re-fetch via `curl -o`); **verify the design is well-formed** (if half-written, rewrite it); then generate fresh at S3.
|
||||
- `generated` → apply the iteration spec judgment at S3 (Core Rules #7).
|
||||
- Either way, Core Rules #7 decides at S3: re-fetch the spec (heavy) or edit the UI only (light).
|
||||
Listen for what the user wants changed (or ask one focused question), then go to S3. For a
|
||||
brand-new project, tell them to start a new website session (Core Rules #4).
|
||||
- **NEW** (no current project): extract **topic/subject** + **purpose** from the user's one line. If the intent is thin (you can't yet say what the site is for and who it's for), **keep asking focused questions to draw it out — do NOT jump to init (S2) on a guess**. One question at a time, plain language, never an interrogation. Only once topic + purpose are clear enough for a one-line summary → S1 → S2 → S3.
|
||||
|
||||
---
|
||||
|
||||
## S1 — Style Recommendation (before project init, chat-only)
|
||||
|
||||
Recommend **autonomously by intent** from the **Full Style Catalog** below (74 neutral style files) —
|
||||
you are NOT bound to any fixed subset, and this is independent of any front-end style API. Pick **2–3**
|
||||
that fit the user's topic / industry, present each as `file — one-line tone — best-for hint`. If the
|
||||
user doesn't like them, swap in a fresh batch from the catalog.
|
||||
|
||||
- Present 2–3; user picks one, swaps, or says "you decide" (you pick + say why).
|
||||
- The user may also name any style file directly (e.g. "use `Emerald-Devtool`") — accept verbatim.
|
||||
- Record the chosen **style file stem** (no `.md` suffix, e.g. `Brand-Marketing`) → it is the
|
||||
the project meta's `style` value. At S2 the fetch URL appends `.md`: `.../awesome/<stem>.md`.
|
||||
|
||||
### Full Style Catalog — 74 style files (hardcoded list below; fetch any as `<file>.md`)
|
||||
|
||||
| Theme | Style files |
|
||||
|---|---|
|
||||
| **Dev / AI tools** | `AI-Developer` `AI-Lab` `Agent-Engineering` `Refined-AI` `Sunset-AI` `Gradient-AI` `Frontier-AI` `Gradient-Infra` `Dark-Devtool` `Editorial-Devtool` `Emerald-Devtool` `Gradient-Devtool` `Midnight-Devtool` `Terminal-Devtool` `Warm-Devtool` `Terminal-Mono` `Docs-Platform` `Developer-Platform` `Dark-Builder` `Visual-Builder` `App-Showcase` `Content-Platform` `Playful-Devtool` |
|
||||
| **Minimal / Tech** | `Minimal-Tech` `Minimal-Docs` `Software-Craft` `Engineering-Tech` `Aerospace-Mission` `Tech-Corporate` `Enterprise` `Tech-Magazine` |
|
||||
| **Data / DB** | `Dark-Database` `Dual-Database` `Vibrant-Data` |
|
||||
| **Finance / Crypto** | `Crypto-Finance` `Institutional-Fintech` `Dark-Crypto` `Gradient-Finance` `Fintech-Brochure` `Fintech-Editorial` `Financial-Brand` |
|
||||
| **Auto / Mobility** | `Corporate-Auto` `Motorsport-Engineering` `Luxury-Automotive` `Cinematic-Luxury` `Supercar-Luxury` `Auto-Editorial` `Mobility-Mono` |
|
||||
| **Commerce / Retail** | `E-Commerce` `Cinematic-Commerce` `Product-Commerce` `Retail-Lifestyle` |
|
||||
| **Brand / Editorial / Media** | `Brand-Marketing` `Console-Marketing` `Telecom-Editorial` `Magazine-Editorial` `Voice-Magazine` `Warm-Editorial` `Productivity-Editorial` `Editorial-Workflow` `Museum-Gallery` `Studio-Monochrome` `Mono-Serif` `Generative-Studio` `Retro-Gaming` `Retro-Web` `Music-Streaming` |
|
||||
| **Workspace / SaaS** | `Friendly-SaaS` `Customer-SaaS` `Playful-Workspace` `Illustrated-Workspace` `Workplace-Messaging` `Visual-Discovery` `Workflow-Warm` |
|
||||
|
||||
---
|
||||
|
||||
## S2 — Init Project (the moment style is chosen)
|
||||
|
||||
> Init when topic + style are both settled. Not earlier (empty shell), not later (nowhere to put files).
|
||||
|
||||
1. **Generate `project_id`**: `snake_case` topic + timestamp suffix `%Y%m%d%H%M%S` (e.g. `photo_studio_20260630120000`). Must match `^[a-z0-9_]+(?:_[a-z0-9]+)*$` (underscores only; no consecutive/trailing). This is the only id — no separate uuid.
|
||||
2. **Fetch `ui_spec.md`** (remote style spec → write directly to disk, do NOT relay through the model):
|
||||
`mkdir -p /www/server/panel/data/agent/memories/projects/<id>/site && curl -fsSL --create-dirs https://node.aapanel.com/aapanel/awesome/<stem>.md -o /www/server/panel/data/agent/memories/projects/<id>/ui_spec.md`
|
||||
(`<stem>` from S1, e.g. `Brand-Marketing`). The `-o` writes the ~31KB spec straight to the project
|
||||
dir — it never enters the model context, so no truncation/distortion. Try `node` first; on failure
|
||||
retry once on `jp1-node.aapanel.com`; only if both fail, tell the user and pause. `Read` it back
|
||||
(large file ~31KB — use a large `limit`) only when you need its tokens for S3.
|
||||
3. **Build `meta.md`** with this exact flat frontmatter (parsed as plain `key: value` — no YAML lists/nesting;
|
||||
`status` is the single field the active-project scan filters on):
|
||||
|
||||
```
|
||||
---
|
||||
id: photo_studio_20260630120000
|
||||
subject: Photography Studio Official Site
|
||||
industry: photography / visual
|
||||
style: Brand-Marketing
|
||||
status: draft
|
||||
---
|
||||
# Project meta — authoritative source
|
||||
One-paragraph positioning. Sections planned. Current phase.
|
||||
```
|
||||
- `status` MUST be one of: `draft`, `generated`.
|
||||
|
||||
4. **Build `design.md`**: positioning + section tree + per-section outline + component list. It **references**
|
||||
`ui_spec.md` ("visual spec = see ui_spec.md") and does NOT duplicate it.
|
||||
5. **Build `progress.md`**: phases S0–S3 each marked pending; note current = S2 done.
|
||||
6. **Persist project info + bind session** — ONE `ProjectSave` call (batch all info docs + binding together):
|
||||
`ProjectSave(project_id=<id>, files={"meta.md": <full content>, "design.md": <full>, "progress.md": <full>}, meta_snapshot={"status": "draft", "subject": <topic>, "style": <stem>})`.
|
||||
This binds the session as a website session. (Per-writer limits — ProjectSave only for info docs — are in the system prompt.)
|
||||
7. Update `TodoWrite` (set-up complete), advance to build the UI.
|
||||
|
||||
---
|
||||
|
||||
## S3 — Generate Static Artifact (`draft` → `generated`)
|
||||
|
||||
Generate the pure-static UI. **Always a single-page UI** (all sections + anchor nav) — one page, never multi-page. Everything is
|
||||
**AI-authored + CDN-linked — zero local asset files**.
|
||||
|
||||
**Generation order (do not reorder):**
|
||||
1. Read the style spec for tokens (colors / type / components) — **large spec (~31KB): use a large `limit` (e.g. 3000+) to avoid truncation; read what you need, not necessarily all**.
|
||||
2. **Fetch images first** — run the skill script below for EVERY image slot, collect the direct
|
||||
image URLs. You must have these URLs in hand BEFORE writing the UI (Core Rules #9).
|
||||
3. Write the UI with all CSS inlined in `<style>` and JS inlined in `<script>` — no external files.
|
||||
|
||||
Do NOT author the UI with placeholder / empty image `src` intending to "fill in later" — fetch first, then author.
|
||||
|
||||
### Iteration spec judgment (autonomous — Core Rules #7)
|
||||
On re-entry (artifact already exists), judge the change:
|
||||
- **Light edit** (color / copy / image swap) → edit the UI only; do NOT touch the spec / the design.
|
||||
- **Heavy edit** (new style / layout / design system) → re-fetch the style spec (`curl -o` overwrite from
|
||||
the same or a new `<stem>.md`) + rewrite the design, then regenerate the UI.
|
||||
|
||||
Either way → `status→generated` (Core Rules #6).
|
||||
|
||||
### Asset policy — all external links, nothing local
|
||||
- **CSS**: hand-write per the style spec's tokens (colors / typography / components / spacing) as `:root`
|
||||
CSS variables + structured rules, all inlined in `<style>` inside the UI. No CSS framework.
|
||||
The UI spec is **internalized into CSS variables** (tokens → `:root`).
|
||||
- **Fonts**: Google Fonts via `<link>`. Map by **type**, NOT by the spec's font name — the fetched
|
||||
The style spec uses neutral fictitious names (e.g. `Brand Marketing Cereal VF`) that exist on no CDN.
|
||||
Translate the spec's *sans / serif / mono + weight* to reachable Google Fonts, then a system fallback:
|
||||
|
||||
| spec type | Google Font (primary → alt → fallback) |
|
||||
|---|---|
|
||||
| sans | Inter → Manrope → system-ui |
|
||||
| serif | Playfair Display → Merriweather → Georgia |
|
||||
| mono | JetBrains Mono → Fira Code → monospace |
|
||||
|
||||
**Weight map**: Thin 100 · Light 300 · Regular 400 · Medium 500 · SemiBold 600 · Bold 700 · Black 900.
|
||||
Always end the CSS `font-family` stack with the system fallback (e.g. `'Inter', system-ui, -apple-system, sans-serif`).
|
||||
- **Icons**: Lucide via CDN — `<script src="https://unpkg.com/lucide@latest"></script>`, mark icons with
|
||||
`<i data-lucide="<name>"></i>`, then call `lucide.createIcons()` after DOM ready. (Pin a specific
|
||||
version like `lucide@0.460.0` instead of `@latest` if reproducibility matters.)
|
||||
- **Images**: direct links via the skill script — see below.
|
||||
|
||||
### Images (MANDATORY, Core Rules #9)
|
||||
Every image slot gets a real photo — fetch via the skill script BEFORE writing HTML (generation order step 2). Call it **once**, one `--block` per image group. The script path, full command, parameter format (incl. `primary`/`secondary` main+fallback), and output format are all in the **system prompt** — run it directly and fill `<img src>` from the `=== label ===` output.
|
||||
**On repeated failure (both queries empty): drop that slot and render text-only — do not keep retrying or substitute placeholders. If ALL slots come back empty, stop and tell the user the image service is unavailable.**
|
||||
|
||||
### Persist + transition
|
||||
- The UI artifact was already written in step 3 — no extra write needed.
|
||||
- On completion: `ProjectSave(project_id=<id>, files={"meta.md": <full content>, "progress.md": <full>}, meta_snapshot={"status": "generated"})` (full overwrite; design.md unchanged since S2).
|
||||
- Mark the UI build done in `TodoWrite`.
|
||||
- **Then offer 2 next steps** (every generation): **preview** the UI · **iterate** (request changes). **Deployment (going live) is NOT a default suggestion** — proceed ONLY when the user explicitly asks, and ONLY after Hard Red Lines consent; at most a one-line nudge when they signal intent to publish, never push it. When it proceeds, use panel tools (`SiteCreate`/`RunCommand`); ensure every deployed file is owned `www:www` and mode `644` (directories `755`) — it does NOT exit the session, does NOT bind/track any address.
|
||||
|
||||
---
|
||||
|
||||
## Resume & Progress
|
||||
|
||||
- **The progress doc is the project's factual progress — the source of truth across sessions.** Every phase transition + iteration writes it (phase + iteration history). On re-entry, read it to know exactly where the project stands; it is authoritative — more precise than `status` alone.
|
||||
- **Entry routing is the S0 route** (NEW vs ITERATE by project existence). On any re-entry — same session
|
||||
resumed or a fresh turn — re-run the S0 route: if the project exists, `Read` the project meta for `status` and follow
|
||||
the ITERATE branch (status tells you where to pick up).
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
Report to the user in plain natural language:
|
||||
|
||||
```
|
||||
Style chosen: <name> — <tone>
|
||||
Sections: <list>
|
||||
Status: <draft | generated>
|
||||
Artifact: the project UI (open to preview)
|
||||
Next: preview · iterate (request changes)
|
||||
```
|
||||
@@ -0,0 +1,186 @@
|
||||
import argparse, json, os, sys, time, urllib.error, urllib.parse, urllib.request
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
CLIENT_ID = os.environ.get("UNSPLASH_CLIENT_ID", "")
|
||||
if not CLIENT_ID:
|
||||
print("ERROR: UNSPLASH_CLIENT_ID env not set (comMod should inject it)", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
API = "https://api.unsplash.com/search/photos"
|
||||
ORIENTATIONS = {"landscape", "portrait", "squarish"}
|
||||
RETRIES = 3
|
||||
RETRY_SLEEP = 1
|
||||
RETRY_CAP = 8
|
||||
FETCH_TIMEOUT = 6
|
||||
HEAD_TIMEOUT = 3
|
||||
HEAD_RETRIES = 1
|
||||
|
||||
PEXELS_API = "https://api.pexels.com/v1/search"
|
||||
PEXELS_KEY = os.environ.get("PEXELS_API_KEY", "")
|
||||
PEXELS_SIZE_W = {"regular": 1080, "small": 400}
|
||||
PEXELS_ORIENT = {"landscape": "landscape", "portrait": "portrait", "squarish": "square"} # squarish -> square
|
||||
|
||||
|
||||
def _backoff(i):
|
||||
return min(RETRY_SLEEP * (2 ** i), RETRY_CAP)
|
||||
|
||||
|
||||
def _fetch_json(url, headers=None):
|
||||
for i in range(RETRIES + 1):
|
||||
try:
|
||||
req = urllib.request.Request(url, headers=headers or {})
|
||||
with urllib.request.urlopen(req, timeout=FETCH_TIMEOUT) as r:
|
||||
return json.load(r)
|
||||
except urllib.error.HTTPError as e:
|
||||
if (e.code == 429 or 500 <= e.code < 600) and i < RETRIES:
|
||||
time.sleep(_backoff(i))
|
||||
continue
|
||||
print(f"ERROR: HTTP {e.code} {e.reason}", file=sys.stderr)
|
||||
return None
|
||||
except urllib.error.URLError as e:
|
||||
if i < RETRIES:
|
||||
time.sleep(_backoff(i))
|
||||
continue
|
||||
print(f"ERROR: network failed after {RETRIES + 1} tries: {e.reason}", file=sys.stderr)
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _head_ok(url):
|
||||
for i in range(HEAD_RETRIES + 1):
|
||||
try:
|
||||
req = urllib.request.Request(url, method="HEAD")
|
||||
if urllib.request.urlopen(req, timeout=HEAD_TIMEOUT).getcode() == 200:
|
||||
return True
|
||||
except Exception:
|
||||
if i < HEAD_RETRIES:
|
||||
time.sleep(_backoff(i))
|
||||
continue
|
||||
return False
|
||||
|
||||
|
||||
def _search(query, size, count, orientation):
|
||||
url = API + "?" + urllib.parse.urlencode({
|
||||
"query": query, "per_page": count,
|
||||
"orientation": orientation, "client_id": CLIENT_ID})
|
||||
data = _fetch_json(url)
|
||||
if data is None:
|
||||
return []
|
||||
if "errors" in data:
|
||||
print(f"ERROR: [{query}] API: {data['errors']}", file=sys.stderr)
|
||||
return []
|
||||
out = []
|
||||
for p in data.get("results", []):
|
||||
img = p.get("urls", {}).get(size, "")
|
||||
alt = (p.get("alt_description") or "").replace("\t", " ").replace("\n", " ").replace("\r", " ").strip()
|
||||
if not img:
|
||||
continue
|
||||
if _head_ok(img):
|
||||
out.append((img, alt))
|
||||
return out
|
||||
|
||||
|
||||
def _search_pexels(query, size, count, orientation):
|
||||
# Pexels fallback: Unsplash 无结果/失败时调用. key 缺失静默跳过.
|
||||
if not PEXELS_KEY:
|
||||
return []
|
||||
url = PEXELS_API + "?" + urllib.parse.urlencode({
|
||||
"query": query, "per_page": count,
|
||||
"orientation": PEXELS_ORIENT.get(orientation, orientation)})
|
||||
data = _fetch_json(url, {"Authorization": PEXELS_KEY, "User-Agent": "site_design-images/1.0"})
|
||||
if data is None:
|
||||
return []
|
||||
if data.get("errors"):
|
||||
print(f"ERROR: [{query}] Pexels: {data['errors']}", file=sys.stderr)
|
||||
return []
|
||||
w = PEXELS_SIZE_W.get(size, 1080)
|
||||
out = []
|
||||
for p in data.get("photos", []):
|
||||
original = p.get("src", {}).get("original", "")
|
||||
if not original:
|
||||
continue
|
||||
img = f"{original}?auto=compress&cs=tinysrgb&w={w}"
|
||||
alt = (p.get("alt") or "").replace("\t", " ").replace("\n", " ").replace("\r", " ").strip()
|
||||
if _head_ok(img):
|
||||
out.append((img, alt))
|
||||
return out
|
||||
|
||||
|
||||
def fetch_block(primary, secondary, size, count, orientation):
|
||||
for q in (primary, secondary):
|
||||
if not q:
|
||||
continue
|
||||
found = _search(q, size, count, orientation)
|
||||
if found:
|
||||
return found
|
||||
# fallback
|
||||
for q in (primary, secondary):
|
||||
if not q:
|
||||
continue
|
||||
found = _search_pexels(q, size, count, orientation)
|
||||
if found:
|
||||
return found
|
||||
return []
|
||||
|
||||
|
||||
def parse_block(s):
|
||||
p = [x.strip() for x in s.split("|")]
|
||||
if len(p) < 5:
|
||||
raise ValueError(f'--block needs label|primary|secondary|size|count (got: {s})')
|
||||
if len(p) > 6:
|
||||
raise ValueError(f'too many fields ({len(p)}): queries must not contain "|" (got: {s})')
|
||||
label, primary, secondary, size, count = p[0], p[1], p[2], p[3], p[4]
|
||||
orientation = p[5] if len(p) > 5 else "landscape"
|
||||
if not label:
|
||||
raise ValueError("label (1st field) must not be empty")
|
||||
if not primary and not secondary:
|
||||
raise ValueError("primary and secondary both empty — need at least one query")
|
||||
if size not in ("regular", "small"):
|
||||
raise ValueError(f"size must be regular|small (got {size})")
|
||||
if orientation not in ORIENTATIONS:
|
||||
raise ValueError(f"orientation must be landscape|portrait|squarish (got {orientation})")
|
||||
try:
|
||||
count = int(count)
|
||||
except ValueError:
|
||||
raise ValueError(f"count must be int (got {count})")
|
||||
if count < 1:
|
||||
raise ValueError(f"count must be >= 1 (got {count})")
|
||||
count = min(count, 30)
|
||||
return label, primary, secondary, size, count, orientation
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description="Multi-block image search for Site Design skill")
|
||||
ap.add_argument("--block", action="append", required=True,
|
||||
metavar='"label|primary|secondary|size|count[|orientation]"',
|
||||
help="one per image block; primary first, secondary as fallback")
|
||||
a = ap.parse_args()
|
||||
blocks = []
|
||||
for s in a.block:
|
||||
try:
|
||||
blocks.append(parse_block(s))
|
||||
except ValueError as e:
|
||||
print(f"ERROR: {e}", file=sys.stderr)
|
||||
sys.exit(2)
|
||||
|
||||
def _run(b):
|
||||
label, primary, secondary, size, count, orientation = b
|
||||
try:
|
||||
return label, fetch_block(primary, secondary, size, count, orientation)
|
||||
except Exception as e:
|
||||
print(f"ERROR: [{label}] {e}", file=sys.stderr)
|
||||
return label, []
|
||||
|
||||
with ThreadPoolExecutor(max_workers=min(8, len(blocks))) as ex:
|
||||
results = list(ex.map(_run, blocks))
|
||||
for label, rows in results:
|
||||
print(f"=== {label} ===")
|
||||
for img, alt in rows:
|
||||
print(f"{img}\t{alt}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,255 @@
|
||||
---
|
||||
name: aaPanel Website-Troubleshoot
|
||||
description: >
|
||||
Diagnose and fix aaPanel website access problems and service start failures.
|
||||
Use this skill when users report: site not opening, 403/404/500/502/503 errors,
|
||||
blank/white page, database connection errors, nginx or php-fpm start failure,
|
||||
service start failed with no specific reason, or any site down / access denied.
|
||||
Trigger keywords: site down, website not opening, website unreachable, 502,
|
||||
500, 403, 404, blank page, white page, nginx error, php-fpm error, start failed,
|
||||
database connection failed, connection refused, too many connections, access
|
||||
denied, site unreachable, service unavailable, bad gateway, site anomaly.
|
||||
---
|
||||
|
||||
# aaPanel Website Troubleshoot
|
||||
|
||||
> **STOP. Read this document completely before doing anything.**
|
||||
> **Your FIRST action is always `TodoWrite`** — create the diagnostic task list before running any probe or command.
|
||||
> Do NOT skip sections. Execute the decision tree in order.
|
||||
> Never show raw commands, btpython paths, or code blocks to users — report as plain text.
|
||||
|
||||
---
|
||||
|
||||
## Core Rules
|
||||
|
||||
1. **Always run the pre-check first** — never skip the `curl --resolve` loopback verification.
|
||||
2. **Read before fixing** — diagnose the root cause before executing any repair action.
|
||||
3. **Max 15 tool calls per task** — if exceeded, summarize findings and stop.
|
||||
4. **Never let users type commands** — always execute via `RunCommand`.
|
||||
5. **On error during fix** — report to user, do NOT retry automatically.
|
||||
6. **Always inspect raw service logs** when a service reports "start failed" — the panel hint is usually too vague to act on.
|
||||
7. **Forbidden operations**: delete site, drop database, delete files, `rm -rf`, `DROP TABLE/DATABASE`.
|
||||
8. **Track every diagnosis with TodoWrite** — before starting Step 0, create a todo list of the planned diagnostic steps; set exactly one to `in_progress` while working it, mark `completed` the moment it finishes. Use `TodoRead` to re-check state if interrupted.
|
||||
|
||||
---
|
||||
|
||||
## Step 0 — Pre-Check (mandatory)
|
||||
|
||||
Before any diagnosis, confirm the site actually belongs to this server and the request is reaching it.
|
||||
|
||||
1. **First action — create your task list**: call `TodoWrite` to lay out the steps for this case (pre-check → probes → status-code branch → fix). Do this before any other tool call.
|
||||
2. **Run**: `RunCommand: curl --resolve <domain>:80:127.0.0.1 -sS -o /dev/null -w '%{http_code}\\n' http://<domain>/`
|
||||
- Returns the actual HTTP status code reaching the panel's nginx.
|
||||
3. **List sites**: `SiteList()` → find the matching `id`, `path`, `project_type`.
|
||||
4. **If the site is not on this panel** (`curl` returns connection error or a different site): report to user that the domain may not be hosted here, do NOT proceed.
|
||||
|
||||
> Skip pre-check only if the user explicitly confirms the site is on this server, or provides a `site_id` directly.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Two Probes
|
||||
|
||||
These probes are read-only and side-effect free. Create them, test, then **delete them** when done.
|
||||
|
||||
### Probe A — Static file (verifies domain binding + nginx + root path)
|
||||
|
||||
1. `RunCommand: echo 'aaPanel probe $(date)' > <site_path>/.probe_static.html`
|
||||
2. `RunCommand: chown www:www <site_path>/.probe_static.html && chmod 644 <site_path>/.probe_static.html`
|
||||
3. `RunCommand: curl --resolve <domain>:80:127.0.0.1 -sS http://<domain>/.probe_static.html`
|
||||
4. **Expect**: returns the probe text with HTTP 200.
|
||||
5. Clean up: `RunCommand: rm -f <site_path>/.probe_static.html`
|
||||
|
||||
### Probe B — PHP file (verifies PHP runtime + php-fpm socket)
|
||||
|
||||
1. `RunCommand: echo '<?php echo "aaPanel PHP probe ".PHP_VERSION; ?>' > <site_path>/.probe_php.php`
|
||||
2. `RunCommand: chown www:www <site_path>/.probe_php.php && chmod 644 <site_path>/.probe_php.php`
|
||||
3. `RunCommand: curl --resolve <domain>:80:127.0.0.1 -sS http://<domain>/.probe_php.php`
|
||||
4. **Expect**: returns the probe text with the PHP version string.
|
||||
5. Clean up: `RunCommand: rm -f <site_path>/.probe_php.php`
|
||||
|
||||
> Get `site_path` from `SiteList` → matching `path` field. `GetSitesConf([site_id])` returns the **raw vhost config text** (not structured fields) — locate the `root`, `access_log`, and `include enable-php-<ver>.conf;` directives by reading that text to verify the root path.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Status Code Decision Tree
|
||||
|
||||
### 403 Forbidden — Permission / Ownership
|
||||
|
||||
**Goal**: confirm `www` owns the site directory tree, with 755/644 mode and readable parents.
|
||||
|
||||
1. `GetSitesConf([site_id])` → get `root` directory.
|
||||
2. `RunCommand: ls -la <root>/` and `RunCommand: ls -la <parent_of_root>/` (parent matters).
|
||||
3. Check:
|
||||
- Owner / group = `www:www` on root and all parents
|
||||
- Directory mode = `755`
|
||||
- File mode = `644`
|
||||
4. If any are wrong (auto-fix, low risk):
|
||||
- `RunCommand: chown -R www:www <root>`
|
||||
- `RunCommand: find <root> -type d -exec chmod 755 {} \;`
|
||||
- `RunCommand: find <root> -type f -exec chmod 644 {} \;`
|
||||
5. Re-test with the pre-check curl. If 403 persists, inspect the nginx error log:
|
||||
- `Read: /www/wwwlogs/<domain>.error.log` (last 50 lines, look for `directory index of "..." is forbidden` or `access denied`).
|
||||
|
||||
### 404 Not Found — Path / Rewrite
|
||||
|
||||
**First classify**: is the missing URL expected to be served dynamically (PHP route / framework route) or statically (a real file)?
|
||||
|
||||
- **Static 404** (e.g. `/favicon.ico`, `/uploads/foo.jpg`):
|
||||
1. `GetSitesConf([site_id])` → check `root` and `access_log` location.
|
||||
2. `RunCommand: ls -la <root>/<expected_path>` → file missing? wrong root?
|
||||
3. Verify `root` directive in vhost config matches the actual `site_path` from `SiteList`.
|
||||
|
||||
- **Dynamic 404** (e.g. `/category/news/`, `/article/123`, `/admin/login`):
|
||||
1. `GetSitesConf([site_id])` → look for `include .../rewrite/<domain>.conf;` line.
|
||||
2. `Read: /www/server/panel/vhost/rewrite/<domain>.conf` (if exists).
|
||||
3. **Common cause**: rewrite rules missing or wrong for the framework (WordPress / ThinkPHP / Laravel / Typecho / etc.).
|
||||
4. Fix (high risk — needs panel confirm): edit the rewrite file with `SearchReplace` per the framework's standard rules, then `RunCommand: nginx -t && nginx -s reload`.
|
||||
|
||||
### 500 Internal Server Error — Three Branches
|
||||
|
||||
Run the pre-check curl to confirm 500. Then check each branch:
|
||||
|
||||
#### Branch A — Database
|
||||
|
||||
1. Read `GetSitesConf` to find the project root, then look for the framework's DB config:
|
||||
- WordPress: `Read: <root>/wp-config.php` → extract `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_HOST`.
|
||||
- Other frameworks: look for `.env`, `config/database.php`, `application/database.php`, etc.
|
||||
2. `MysqlTestConnection(db_name=<extracted_name>)` → returns `connected` and `version` on success.
|
||||
3. If connection fails, the error message is auto-mapped by `mysql_engine.MYSQL_ERROR_MAP` — use that mapping directly:
|
||||
- `2002` / `2003` → MySQL is down. Go to **Service Start Failure → MySQL**.
|
||||
- `1045` → wrong password. Compare `DB_PASSWORD` in config with `databases` table; if mismatch, report to user (do NOT auto-reset DB password).
|
||||
- `1146` → table missing. `MysqlListDatabases` / `MysqlDescribeTable` to confirm, then report.
|
||||
- `1040` → too many connections. `MysqlQuery("SHOW GLOBAL STATUS LIKE 'Threads_connected'")` + `GetTopProcesses` to see who is connecting; suggest raising `max_connections` (high risk).
|
||||
|
||||
#### Branch B — Code (uncaught exception)
|
||||
|
||||
1. Enable PHP error display (high risk — needs confirm, and only if not in production):
|
||||
- `Read: /www/server/php/<ver>/etc/php.ini` → check `display_errors` and `error_reporting`.
|
||||
2. `Read: /www/wwwlogs/<domain>.error.log` (last 100 lines) → look for PHP `Fatal error` / `Parse error` / `Uncaught` lines.
|
||||
3. Report the file:line and error message to the user. Do NOT auto-edit site code.
|
||||
|
||||
#### Branch C — Config (HTTP 200 but blank page)
|
||||
|
||||
1. This is the **fastcgi.conf breakage** pattern: status is 200, body is empty, and the site worked before.
|
||||
2. `Read: /www/server/nginx/conf/fastcgi.conf` → check whether it has been emptied / over-commented.
|
||||
3. If abnormal, report to user. Fix requires restoring the panel's default `fastcgi.conf` (high risk — needs confirm and original file).
|
||||
|
||||
### 502 / 503 Bad Gateway / Service Unavailable — PHP-FPM Environment
|
||||
|
||||
1. `RunCommand: ps -ef \| grep php-fpm \| grep -v grep` → is php-fpm running?
|
||||
2. `ServiceStatus("php-fpm-<ver>")` → confirm install + run state. Identify which version from `GetSitesConf`'s `include enable-php-<ver>.conf;` line.
|
||||
3. `GetSitesConf([site_id])` → check the vhost actually includes the **same** `enable-php-<ver>.conf` as the running version.
|
||||
4. `RunCommand: curl -sS http://127.0.0.1/phpfpm_<ver>_status` → check active processes, listen queue, max children.
|
||||
- If `listen queue` is non-zero → process pool exhausted. See OOM in **Service Start Failure → php-fpm**.
|
||||
5. `GetSystemResources()` → if memory free < 200MB, OOM-killer may have hit php-fpm.
|
||||
6. For **reverse-proxy 502**: `RunCommand: curl -v <upstream_host>` from the panel host → check if the upstream is reachable (firewall / DNS / upstream down).
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Service Start Failure
|
||||
|
||||
> **Core principle**: when the panel says "start failed" with no useful detail, **always read the raw service log first**. The panel hint is too vague to act on.
|
||||
|
||||
### 3.1 nginx
|
||||
|
||||
```bash
|
||||
# 1. Syntax check — gives the exact file:line of any error
|
||||
/www/server/nginx/sbin/nginx -t
|
||||
```
|
||||
|
||||
Three common failure modes:
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `unknown directive "xxx" in /www/server/panel/vhost/nginx/<site>.conf:N` | Site vhost has a typo / unsupported directive | `Read` the line, fix with `SearchReplace` (high risk), then re-run `nginx -t` |
|
||||
| `bind() to 0.0.0.0:80 failed` / `bind() to [::]:443 failed` | Port occupied | `RunCommand: netstat -tlnp \| grep -E ':80\|:443'` → identify the conflicting process, report to user |
|
||||
| `host not found in upstream "<name>"` | Upstream DNS unresolved at startup | Verify with `RunCommand: dig +short <name>`; if not resolvable, report to user. Fix: add `resolver` directive to the vhost, or replace with an IP (high risk) |
|
||||
|
||||
After the fix, **always** run `nginx -t` again, then `RunCommand: /etc/init.d/nginx restart` (or `ServiceRestart("nginx")`, high risk — needs confirm).
|
||||
|
||||
### 3.2 php-fpm
|
||||
|
||||
Get the version from the failing site's `enable-php-<ver>.conf` reference, then:
|
||||
|
||||
```bash
|
||||
# 1. Tail the raw php-fpm error log — most informative
|
||||
tail -n 50 /www/server/php/<ver>/var/log/php-fpm.log
|
||||
```
|
||||
|
||||
| Symptom | Cause | Fix |
|
||||
|---|---|---|
|
||||
| `Unable to load dynamic library '<ext>.so'` / `segfault (core dumped)` | A newly installed PHP extension is broken | Bisect: in the panel go to App Store → PHP → Settings → Extensions, uninstall newly added extensions one by one, restarting (`RunCommand: /etc/init.d/php-fpm-<ver> restart`) after each |
|
||||
| `active (exited)` immediately after start | OOM-killer killed it | `RunCommand: dmesg \| grep -E "php\|oom" \| tail -20` + `GetSystemResources` → reduce `pm.max_children` in `/www/server/php/<ver>/etc/php-fpm.conf` (high risk) |
|
||||
| `bind() to /tmp/php-cgi-<ver>.sock failed: Address already in use` | Old php-fpm process still alive | `RunCommand: pkill php-fpm && sleep 2 && /etc/init.d/php-fpm-<ver> start` (high risk) |
|
||||
| `ERROR: [/www/server/php/<ver>/etc/php-fpm.conf:N] unknown parameter` | Manual edit broke a directive | `Read` the line, fix or reset via panel App Store → PHP → Settings → Config → Reset (high risk) |
|
||||
| `Unable to access php-fpm.sock: Permission denied` | Socket file owner wrong | `RunCommand: ls -la /tmp/php-cgi-<ver>.sock` → if not `www:www`, `RunCommand: chown www:www /tmp/php-cgi-<ver>.sock && chown -R www:www /www/server/php/<ver>/var/log/` (low risk) |
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Tool Risk Levels
|
||||
|
||||
Tools are gated by their registered `risk_level`, not by what the command does.
|
||||
|
||||
| risk_level | Tools | Typical use | Handling |
|
||||
|---|---|---|---|
|
||||
| **low** | `Read`, `GetSitesConf`, `GetSitesLogs`, `ServiceStatus`, `GetSystemResources`, `GetTopProcesses`, `MysqlTestConnection`, `MysqlQuery`, `MysqlListDatabases`, `MysqlDescribeTable`, `TodoRead`, `TodoWrite` | read configs/logs, check service state, test DB connection, list DBs/tables, track todos | Execute directly |
|
||||
| **high** | `RunCommand`, `ServiceRestart`, `SearchReplace`, `Write`, `MysqlExecute` | any shell command (incl. read-only `curl`/`ls`/`netstat`/`ps` and `chown`/`chmod`), restart service, edit nginx/php config, write file, SQL write | Every call goes through the panel confirmation gate |
|
||||
| **forbidden** | — | delete site, drop database, delete files, `rm -rf`, `DROP TABLE/DATABASE` | Never execute |
|
||||
|
||||
- `RunCommand` is `risk_level="high"` as a tool, so **even read-only probes** (`curl`, `ls`, `netstat`) trigger the confirmation gate. Prefer the low-risk read tools (`Read` for logs/configs, `ServiceStatus` for state) to gather facts without confirmation.
|
||||
- After any config edit (`SearchReplace`/`Write`), **must** run `nginx -t` to validate before reload.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Output Format
|
||||
|
||||
When reporting findings to the user, always use **plain natural language** in this shape:
|
||||
|
||||
```
|
||||
Root cause: <one-sentence root cause>
|
||||
|
||||
Executed steps:
|
||||
- <step 1>
|
||||
- <step 2>
|
||||
...
|
||||
|
||||
Recommendations:
|
||||
- <prevention or follow-up action>
|
||||
```
|
||||
|
||||
Rules:
|
||||
- **Never** paste raw shell commands, btpython paths, or config diffs into the user-facing message.
|
||||
- **Never** ask the user to type commands — if a fix is needed, describe it and let the user confirm.
|
||||
- If the diagnosis is incomplete (e.g. could not reproduce, logs unclear), say so explicitly. Do NOT fabricate a cause.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference — Common Error Codes
|
||||
|
||||
These are auto-mapped by `mysql_engine.MYSQL_ERROR_MAP`; the mapping is shown for agent's own reasoning, **do not echo the codes to the user verbatim**.
|
||||
|
||||
| SQLSTATE / Code | Friendly Meaning | Next Step |
|
||||
|---|---|---|
|
||||
| `2002` / `2003` | Cannot connect to MySQL socket / port | Check `ServiceStatus("mysql")` |
|
||||
| `1045` | Wrong password for DB user | Compare `wp-config.php` with panel's `databases` table |
|
||||
| `1146` | Table does not exist | `MysqlListDatabases` + `MysqlDescribeTable` to confirm |
|
||||
| `1040` | Too many connections | `MysqlQuery("SHOW GLOBAL STATUS LIKE 'Threads_connected'")` + `GetTopProcesses` |
|
||||
| `1452` | Foreign key constraint fails | Inspect the referenced row, report to user |
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference — File / Log Paths
|
||||
|
||||
| What | Path |
|
||||
|---|---|
|
||||
| Nginx vhost config | `/www/server/panel/vhost/nginx/<domain>.conf` |
|
||||
| Apache vhost config | `/www/server/panel/vhost/apache/<domain>.conf` |
|
||||
| Rewrite rules | `/www/server/panel/vhost/rewrite/<domain>.conf` |
|
||||
| Nginx main config | `/www/server/nginx/conf/nginx.conf` |
|
||||
| Nginx fastcgi config (whitespace-page culprit) | `/www/server/nginx/conf/fastcgi.conf` |
|
||||
| Site access log | `/www/wwwlogs/<domain>.log` |
|
||||
| Site error log | `/www/wwwlogs/<domain>.error.log` |
|
||||
| PHP-FPM error log | `/www/server/php/<ver>/var/log/php-fpm.log` |
|
||||
| PHP-FPM socket | `/tmp/php-cgi-<ver>.sock` |
|
||||
| PHP-FPM pool config | `/www/server/php/<ver>/etc/php-fpm.conf` |
|
||||
| WordPress DB config | `<site_root>/wp-config.php` |
|
||||
@@ -0,0 +1,215 @@
|
||||
---
|
||||
name: aaPanel WordPress
|
||||
description: >
|
||||
Create and manage WordPress sites on aaPanel servers. Use this skill when users want to:
|
||||
deploy new WordPress sites, install/activate themes and plugins via WP-CLI, get site info,
|
||||
list WordPress sites, manage WordPress installations, or perform any WP-CLI operations.
|
||||
Also use for: troubleshooting WP deployments, PHP version detection, or WordPress site management.
|
||||
Trigger keywords: WordPress, WP, deploy WP, install WP, WP theme, WP plugin, create site.
|
||||
---
|
||||
|
||||
# aaPanel WordPress
|
||||
|
||||
> **STOP. Do NOT read wp_helper.py. Do NOT run --help.**
|
||||
> All commands, parameters, and usage are in this document.
|
||||
> Execute commands directly using the format and tables below.
|
||||
> Reading the script file will waste time and provide no useful information.
|
||||
|
||||
---
|
||||
|
||||
## Script Path
|
||||
|
||||
The wp_helper.py script is located at:
|
||||
`/www/server/panel/data/agent/skills/wordpress_deploy/script/wp_helper.py`
|
||||
|
||||
Always use this absolute path when calling the script via btpython.
|
||||
|
||||
---
|
||||
|
||||
## Core Rules
|
||||
|
||||
1. **Read this SKILL.md completely before doing anything.** Do NOT skip sections — read the entire document first, then execute.
|
||||
2. **NEVER read wp_helper.py or run --help.** Everything you need is in this document.
|
||||
3. **NEVER show raw commands, code blocks, or btpython paths to users.** Execute directly, report as plain text.
|
||||
4. **Never let users type commands.** Always execute via RunCommand.
|
||||
5. **On error** (`"status": false`): report to user, do NOT retry automatically.
|
||||
6. **Max 8 RunCommand calls** per task.
|
||||
7. **Forbidden operations**: delete site, drop database, delete files.
|
||||
|
||||
---
|
||||
|
||||
## Two Modes
|
||||
|
||||
This skill operates in two modes. Determine which one applies before doing anything.
|
||||
|
||||
### Mode 1: Create a new WordPress site
|
||||
|
||||
**Steps — execute directly, do NOT read script, do NOT run --help, do NOT check existing sites:**
|
||||
|
||||
1. Collect parameters from user: domain (required), site title. Other params use defaults.
|
||||
2. Execute immediately:
|
||||
`btpython /www/server/panel/data/agent/skills/wordpress_deploy/script/wp_helper.py create-site '{"domain":"<domain>","weblog_title":"<title>","user_name":"admin","admin_email":"admin@example.com","admin_password":"<auto_or_user_provided>"}'`
|
||||
3. Report result to user: site URL, admin URL, admin_user, admin_password.
|
||||
|
||||
If `create-site` returns an error about missing MySQL, PHP, or web server → see Environment Check section below.
|
||||
|
||||
**Do NOT**: list sites first, check databases, read the script, run --help, or explore tools. Just execute create-site directly.
|
||||
|
||||
### Mode 2: Query or modify an existing WordPress site
|
||||
|
||||
For anything else on an existing site (themes, plugins, posts, pages, options, users, db, core update, etc.):
|
||||
|
||||
1. `btpython /www/server/panel/data/agent/skills/wordpress_deploy/script/wp_helper.py site-info '{"domain":"<domain>"}'` → get `s_id`
|
||||
2. `btpython /www/server/panel/data/agent/skills/wordpress_deploy/script/wp_helper.py site-wcli '{"s_id":<id>}'` → get `cmd_prefix`
|
||||
3. RunCommand: `{cmd_prefix} <wp-cli subcommand>`
|
||||
|
||||
The script does NOT have a `wp-cli` subcommand. Use `site-wcli` to get the prefix, then call WP-CLI directly.
|
||||
|
||||
---
|
||||
|
||||
## Default Values
|
||||
|
||||
| Parameter | Default |
|
||||
|-----------|---------|
|
||||
| user_name | admin |
|
||||
| admin_email | admin@example.com |
|
||||
| admin_password | Auto-generated 12-char strong password |
|
||||
| php_version | Auto-detect highest installed |
|
||||
| language | en_US |
|
||||
| prefix | wp_ |
|
||||
|
||||
**domain is always required.** Ask user if not provided.
|
||||
|
||||
---
|
||||
|
||||
## Commands
|
||||
|
||||
**Example execution pattern**:
|
||||
```
|
||||
btpython /www/server/panel/data/agent/skills/wordpress_deploy/script/wp_helper.py create-site '{"domain":"example.com","weblog_title":"My Blog","user_name":"admin","admin_email":"admin@example.com","admin_password":"StrongPass123!"}'
|
||||
```
|
||||
|
||||
### create-site
|
||||
|
||||
Create a new WordPress site with database, web server config (Nginx/Apache/OLS), and WP-CLI installation. Automatically generates wp-config.php, sets file permissions, and writes rewrite rules. Returns site ID, database name, and admin login URL.
|
||||
|
||||
| Parameter | Required | Type | Description |
|
||||
|-----------|----------|------|-------------|
|
||||
| domain | Yes | string | Domain name |
|
||||
| weblog_title | Yes | string | Site title |
|
||||
| user_name | Yes | string | Admin username |
|
||||
| admin_email | Yes | string | Admin email |
|
||||
| admin_password | Yes | string | Admin password (min 8 chars) |
|
||||
| php_version | No | string | PHP version (e.g. "82", "81") |
|
||||
| language | No | string | WP language, default "en_US" |
|
||||
| prefix | No | string | DB table prefix, default "wp_" |
|
||||
| ps | No | string | Site remark |
|
||||
|
||||
**Returns**: status, domain, path, s_id, d_id, database, admin_url, admin_user
|
||||
|
||||
### site-info
|
||||
|
||||
Get site details by domain or s_id.
|
||||
|
||||
| Parameter | Required | Type | Description |
|
||||
|-----------|----------|------|-------------|
|
||||
| domain | One of | string | Domain name |
|
||||
| s_id | One of | int | Site ID |
|
||||
|
||||
**Returns**: status, data.id, data.path, data.name, data.status, data.database, data.domains
|
||||
|
||||
### list-sites
|
||||
|
||||
List all WordPress sites (project_type=WP2). No parameters.
|
||||
|
||||
### ensure-wp-cli
|
||||
|
||||
Check and install WP-CLI if missing. Call before any WP-CLI operations.
|
||||
|
||||
### site-wcli
|
||||
|
||||
Returns the WP-CLI command prefix for a site. Required before any theme/plugin/core/option/db/user operation — different sites may use different PHP versions, so the prefix must be resolved per-site.
|
||||
|
||||
| Parameter | Required | Type | Description |
|
||||
|-----------|----------|------|-------------|
|
||||
| s_id | Yes | int | Site ID (from `site-info` or `list-sites`) |
|
||||
|
||||
**Returns**: `cmd_prefix` — a string like `/www/server/php/82/bin/php -d memory_limit=512M /usr/local/bin/wp --allow-root --path=/www/wwwroot/example.com`
|
||||
|
||||
**Usage**: Append WP-CLI subcommand to `cmd_prefix`, execute via RunCommand.
|
||||
|
||||
**Example flow** (install plugin on site ID 166):
|
||||
1. `btpython /www/server/panel/data/agent/skills/wordpress_deploy/script/wp_helper.py site-wcli '{"s_id":166}'` → get `cmd_prefix`
|
||||
2. RunCommand: `{cmd_prefix} plugin install redis-cache --activate`
|
||||
|
||||
**Common WP-CLI subcommands**:
|
||||
|
||||
| Operation | Append to cmd_prefix |
|
||||
|-----------|---------------------|
|
||||
| Install theme | `theme install <slug> --activate` |
|
||||
| List themes | `theme list` |
|
||||
| Activate theme | `theme activate <slug>` |
|
||||
| Install plugin | `plugin install <slug> --activate` |
|
||||
| List plugins | `plugin list` |
|
||||
| Activate plugin | `plugin activate <slug>` |
|
||||
| Deactivate plugin | `plugin deactivate <slug>` |
|
||||
| Update WP core | `core update` |
|
||||
| List users | `user list` |
|
||||
| Search-replace | `search-replace "old" "new" --dry-run` |
|
||||
| Get option | `option get <option_name>` |
|
||||
| Set option | `option update <option_name> <value>` |
|
||||
| Export DB | `db export /tmp/backup.sql` |
|
||||
| Import DB | `db import /tmp/backup.sql` |
|
||||
| List posts | `post list --post_type=post --fields=ID,post_title,post_status --format=table` |
|
||||
| Get post content | `post get <post_id> --field=post_content` |
|
||||
| Update post content | `post update <post_id> --post_content='<new content>'` |
|
||||
| List pages | `post list --post_type=page --fields=ID,post_title,post_status --format=table` |
|
||||
| Search posts | `post list --s='<keyword>' --fields=ID,post_title --format=table` |
|
||||
| Count posts | `post list --post_type=post --format=count` |
|
||||
|
||||
---
|
||||
|
||||
## Workflows
|
||||
|
||||
### Create new site
|
||||
1. Collect: domain, site title, admin info
|
||||
2. `create-site` → report site URL, admin URL, admin_user, admin_password
|
||||
|
||||
### Any existing site operation (Mode 2)
|
||||
1. `btpython /www/server/panel/data/agent/skills/wordpress_deploy/script/wp_helper.py site-info '{"domain":"<domain>"}'` → `s_id`
|
||||
2. `btpython /www/server/panel/data/agent/skills/wordpress_deploy/script/wp_helper.py site-wcli '{"s_id":<id>}'` → `cmd_prefix`
|
||||
3. RunCommand: `{cmd_prefix} <wp-cli subcommand>`
|
||||
|
||||
### Resolve Site ID
|
||||
- Domain given → `btpython ...wp_helper.py site-info '{"domain":"xxx"}'` → data.id
|
||||
- Vague request → `btpython ...wp_helper.py list-sites '{}'` → match by name/path
|
||||
- Just created → use returned s_id
|
||||
- Not found → ask whether to create
|
||||
|
||||
**Never fabricate post content or IDs. Always query first.**
|
||||
|
||||
---
|
||||
|
||||
## Environment Check & Error Handling
|
||||
|
||||
### Missing environment components (MySQL, PHP, Web Server)
|
||||
|
||||
When `create-site` returns an error about missing MySQL, PHP, or web server:
|
||||
|
||||
1. Read the error message to identify **which specific component** is missing or errored (MySQL / PHP / Nginx / Apache / OpenLiteSpeed).
|
||||
2. Report the missing component to the user with a clear message, for example:
|
||||
> "Creation failed: MySQL is not installed. Please go to aaPanel App Store to install MySQL and try again."
|
||||
> "Creation failed: PHP is not installed. Please go to aaPanel App Store to install PHP and try again."
|
||||
> "Creation failed: Web server (Nginx/Apache) is not installed. Please go to aaPanel App Store to install one and try again."
|
||||
3. **STOP immediately after reporting.** Do NOT try to fix the issue, do NOT suggest workarounds, do NOT check for alternatives, do NOT proceed with any other action. Just report and wait for the user.
|
||||
|
||||
**Key**: always name the exact missing component in the error message — do not use generic "environment error". Never attempt to resolve environment issues yourself.
|
||||
|
||||
### Other errors
|
||||
|
||||
- **PHP errors / PHP not working**: report to user: "PHP is not installed or has errors. Please go to aaPanel App Store to install PHP and try again."
|
||||
- **PHP version mismatch**: show available versions
|
||||
- **WP-CLI download failed**: provide manual install command
|
||||
- **Site already exists**: show existing site ID
|
||||
- **Database creation failed**: check MySQL root password
|
||||
- **WordPress download failed**: check network connectivity
|
||||
@@ -0,0 +1,707 @@
|
||||
#!/usr/bin/env btpython
|
||||
"""
|
||||
WordPress Helper - Site creation and WP-CLI management.
|
||||
Usage: btpython wp_helper.py <command> [JSON args]
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import subprocess
|
||||
import sqlite3
|
||||
import time
|
||||
import re
|
||||
import shutil
|
||||
import random
|
||||
import string
|
||||
import traceback
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple, Any
|
||||
|
||||
# ========== Configuration ==========
|
||||
PANEL_PATH = '/www/server/panel'
|
||||
PANEL_DB = os.path.join(PANEL_PATH, 'data/default.db')
|
||||
WP_CLI_PATH = '/usr/local/bin/wp'
|
||||
VHOST_NGINX = os.path.join(PANEL_PATH, 'vhost/nginx')
|
||||
VHOST_APACHE = os.path.join(PANEL_PATH, 'vhost/apache')
|
||||
VHOST_OLS = os.path.join(PANEL_PATH, 'vhost/openlitespeed')
|
||||
VHOST_OLS_DETAIL = os.path.join(PANEL_PATH, 'vhost/openlitespeed/detail')
|
||||
VHOST_REWRITE = os.path.join(PANEL_PATH, 'vhost/rewrite')
|
||||
WWWLOGS = '/www/wwwlogs'
|
||||
|
||||
# Logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||
handlers=[logging.StreamHandler(sys.stderr)]
|
||||
)
|
||||
logger = logging.getLogger('wp_helper')
|
||||
|
||||
|
||||
# ========== Output ==========
|
||||
def json_out(data: Dict[str, Any]) -> None:
|
||||
"""Output JSON to stdout."""
|
||||
print(json.dumps(data, ensure_ascii=False, default=str))
|
||||
|
||||
|
||||
def run_command(commands: Dict[str, callable]) -> None:
|
||||
"""Main command dispatcher."""
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: btpython wp_helper.py <command> [JSON args]")
|
||||
print(f"Available: {', '.join(commands.keys())}")
|
||||
sys.exit(1)
|
||||
cmd = sys.argv[1]
|
||||
if cmd not in commands:
|
||||
json_out({"status": False, "msg": f"Unknown command: {cmd}, available: {', '.join(commands.keys())}"})
|
||||
sys.exit(1)
|
||||
args: Dict[str, Any] = {}
|
||||
if len(sys.argv) > 2:
|
||||
try:
|
||||
args = json.loads(sys.argv[2])
|
||||
except json.JSONDecodeError:
|
||||
json_out({"status": False, "msg": f"JSON parse error: {sys.argv[2]}"})
|
||||
sys.exit(1)
|
||||
if not isinstance(args, dict):
|
||||
json_out({"status": False, "msg": f"Invalid args type: expected dict, got {type(args).__name__}"})
|
||||
sys.exit(1)
|
||||
try:
|
||||
logger.info(f"Executing command: {cmd}")
|
||||
json_out(commands[cmd](args))
|
||||
except Exception as e:
|
||||
logger.error(f"Command {cmd} failed: {e}\n{traceback.format_exc()}")
|
||||
json_out({"status": False, "msg": f"Execution error: {str(e)}", "traceback": traceback.format_exc()})
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ========== Database ==========
|
||||
def get_panel_db() -> sqlite3.Connection:
|
||||
"""Get panel database connection."""
|
||||
conn = sqlite3.connect(PANEL_DB)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def db_query(sql: str, params: Tuple = (), fetchone: bool = False) -> Any:
|
||||
"""Execute SELECT query."""
|
||||
conn = get_panel_db()
|
||||
try:
|
||||
cur = conn.execute(sql, params)
|
||||
if fetchone:
|
||||
row = cur.fetchone()
|
||||
return dict(row) if row else None
|
||||
return [dict(r) for r in cur.fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def db_execute(sql: str, params: Tuple = ()) -> int:
|
||||
"""Execute INSERT/UPDATE/DELETE query. Returns last rowid."""
|
||||
conn = get_panel_db()
|
||||
try:
|
||||
conn.execute(sql, params)
|
||||
conn.commit()
|
||||
return conn.execute('SELECT last_insert_rowid()').fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_mysql_root_password() -> str:
|
||||
"""Get MySQL root password from panel config."""
|
||||
row = db_query('SELECT mysql_root FROM config WHERE id=1', fetchone=True)
|
||||
return (row.get('mysql_root', '') if row else '') or ''
|
||||
|
||||
|
||||
def get_mysql_bin() -> str:
|
||||
"""Find MySQL binary path."""
|
||||
for p in ['/www/server/mysql/bin/mysql', '/usr/bin/mysql', '/usr/local/bin/mysql']:
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
return 'mysql'
|
||||
|
||||
|
||||
def run_mysql_sql(sql: str, root_password: Optional[str] = None) -> Tuple[bool, str]:
|
||||
"""Execute MySQL SQL statement."""
|
||||
if root_password is None:
|
||||
root_password = get_mysql_root_password()
|
||||
mysql_bin = get_mysql_bin()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[mysql_bin, '-uroot', f'-p{root_password}', '-e', sql],
|
||||
capture_output=True, text=True, timeout=30
|
||||
)
|
||||
return result.returncode == 0, result.stderr.strip()
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
# ========== Common Utilities ==========
|
||||
def get_php_bin(version: str) -> str:
|
||||
"""Get PHP binary path for version."""
|
||||
return f'/www/server/php/{version}/bin/php'
|
||||
|
||||
|
||||
def detect_php_version() -> Optional[str]:
|
||||
"""Detect highest installed PHP version."""
|
||||
php_base = "/www/server/php"
|
||||
if not os.path.exists(php_base):
|
||||
return None
|
||||
versions = []
|
||||
for d in os.listdir(php_base):
|
||||
if d.isdigit() and os.path.exists(os.path.join(php_base, d, 'bin/php')):
|
||||
versions.append(d)
|
||||
return max(versions) if versions else None
|
||||
|
||||
|
||||
def get_date() -> str:
|
||||
"""Get current datetime string."""
|
||||
return time.strftime('%Y-%m-%d %X')
|
||||
|
||||
|
||||
def run_cmd(cmd: List[str], timeout: int = 60) -> Tuple[bool, str, str]:
|
||||
"""Execute command safely (no shell=True)."""
|
||||
try:
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
||||
return result.returncode == 0, result.stdout.strip(), result.stderr.strip()
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, '', f'Command timed out after {timeout}s'
|
||||
except Exception as e:
|
||||
return False, '', str(e)
|
||||
|
||||
|
||||
def shell(cmd: str, timeout: int = 60) -> Tuple[bool, str, str]:
|
||||
"""Execute shell command (for simple operations only)."""
|
||||
try:
|
||||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
||||
return result.returncode == 0, result.stdout.strip(), result.stderr.strip()
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, '', f'Command timed out after {timeout}s'
|
||||
except Exception as e:
|
||||
return False, '', str(e)
|
||||
|
||||
|
||||
def write_file(path: str, content: str, mode: str = 'w') -> None:
|
||||
"""Write content to file, creating directories if needed."""
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, mode) as f:
|
||||
f.write(content)
|
||||
|
||||
|
||||
def is_multi_webservice() -> bool:
|
||||
"""Check if multiple web services are installed."""
|
||||
return (os.path.exists('/www/server/nginx/sbin/nginx')
|
||||
and os.path.exists('/www/server/apache/bin/apachectl')
|
||||
and os.path.exists('/usr/local/lsws/bin/lswsctrl'))
|
||||
|
||||
|
||||
def resolve_site(s_id: int) -> Tuple[Optional[Dict], Optional[str]]:
|
||||
"""Resolve site info and PHP version from site ID."""
|
||||
try:
|
||||
s_id = int(s_id)
|
||||
except (ValueError, TypeError):
|
||||
return None, None
|
||||
site = db_query('SELECT id,path,name FROM sites WHERE id=?', (s_id,), fetchone=True)
|
||||
if not site:
|
||||
return None, None
|
||||
conf_path = os.path.join(VHOST_NGINX, f'{site["name"]}.conf')
|
||||
php_ver = None
|
||||
if os.path.exists(conf_path):
|
||||
with open(conf_path, 'r') as f:
|
||||
m = re.search(r'enable-php-(\d+)\.conf', f.read())
|
||||
if m:
|
||||
php_ver = m.group(1)
|
||||
if not php_ver:
|
||||
php_ver = detect_php_version()
|
||||
return site, php_ver
|
||||
|
||||
|
||||
# ========== WP-CLI ==========
|
||||
def ensure_wp_cli() -> bool:
|
||||
"""Check and install WP-CLI if not present."""
|
||||
if os.path.exists(WP_CLI_PATH):
|
||||
return True
|
||||
try:
|
||||
logger.info("Installing WP-CLI...")
|
||||
subprocess.run(
|
||||
['curl', '-sSL', '-o', WP_CLI_PATH,
|
||||
'https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar'],
|
||||
capture_output=True, timeout=120
|
||||
)
|
||||
os.chmod(WP_CLI_PATH, 0o755)
|
||||
return os.path.exists(WP_CLI_PATH)
|
||||
except Exception as e:
|
||||
logger.error(f"WP-CLI installation failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def run_wp_cli(args: List[str], php_version: str, cwd: Optional[str] = None) -> Tuple[bool, str, str]:
|
||||
"""Execute WP-CLI command."""
|
||||
php_bin = get_php_bin(php_version)
|
||||
cmd = [php_bin, '-d', 'memory_limit=512M', WP_CLI_PATH, '--allow-root'] + args
|
||||
if cwd:
|
||||
cmd += ['--path=' + cwd]
|
||||
return run_cmd(cmd, timeout=300)
|
||||
|
||||
|
||||
# ========== Web Config Generation ==========
|
||||
def gen_nginx_conf(domain: str, php_version: str, site_path: str) -> str:
|
||||
"""Generate Nginx virtual host configuration."""
|
||||
rewrite_conf = os.path.join(VHOST_REWRITE, domain + '.conf')
|
||||
return f'''server
|
||||
{{
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name {domain};
|
||||
index index.php index.html index.htm default.php default.htm default.html;
|
||||
root {site_path};
|
||||
|
||||
#SSL-START
|
||||
#error_page 404/404.html;
|
||||
#SSL-END
|
||||
|
||||
#ERROR-PAGE-START
|
||||
error_page 404 /404.html;
|
||||
error_page 502 /502.html;
|
||||
#ERROR-PAGE-END
|
||||
|
||||
#PHP-INFO-START
|
||||
include enable-php-{php_version}.conf;
|
||||
#PHP-INFO-END
|
||||
|
||||
#REWRITE-START
|
||||
include {rewrite_conf};
|
||||
#REWRITE-END
|
||||
|
||||
location ~ ^/(\\.user\\.ini|\\.htaccess|\\.git|\\.env|\\.svn|\\.project|LICENSE|README.md)
|
||||
{{
|
||||
return 404;
|
||||
}}
|
||||
|
||||
location ~ \\.well-known{{
|
||||
allow all;
|
||||
}}
|
||||
|
||||
if ( $uri ~ "^/\\.well-known/.*\\.(php|jsp|py|js|css|lua|ts|go|zip|tar\\.gz|rar|7z|sql|bak)$" ) {{
|
||||
return 403;
|
||||
}}
|
||||
|
||||
location ~ .*\\.(gif|jpg|jpeg|png|bmp|swf)$
|
||||
{{
|
||||
expires 30d;
|
||||
error_log /dev/null;
|
||||
access_log /dev/null;
|
||||
}}
|
||||
|
||||
location ~ .*\\.(js|css)?$
|
||||
{{
|
||||
expires 12h;
|
||||
error_log /dev/null;
|
||||
access_log /dev/null;
|
||||
}}
|
||||
access_log {WWWLOGS}/{domain}.log;
|
||||
error_log {WWWLOGS}/{domain}.error.log;
|
||||
}}'''
|
||||
|
||||
|
||||
def gen_apache_conf(domain: str, php_version: str, site_path: str, port: str = '80') -> str:
|
||||
"""Generate Apache virtual host configuration."""
|
||||
php_proxy = f'unix:/tmp/php-cgi-{php_version}.sock|fcgi://localhost'
|
||||
return f'''<VirtualHost *:{port}>
|
||||
ServerAdmin webmaster@example.com
|
||||
DocumentRoot "{site_path}"
|
||||
ServerName {time.strftime('%Y%m%d%H%M%S')}.{domain}
|
||||
ServerAlias {domain}
|
||||
#errorDocument 404 /404.html
|
||||
ErrorLog "{WWWLOGS}/{domain}-error_log"
|
||||
CustomLog "{WWWLOGS}/{domain}-access_log" combined
|
||||
|
||||
#DENY FILES
|
||||
<Files ~ (\\.user\\.ini|\\.htaccess|\\.git|\\.env|\\.svn|\\.project|LICENSE|README.md)$>
|
||||
Order allow,deny
|
||||
Deny from all
|
||||
</Files>
|
||||
|
||||
#PHP
|
||||
<FilesMatch \\.php$>
|
||||
SetHandler "proxy:{php_proxy}"
|
||||
</FilesMatch>
|
||||
|
||||
#PATH
|
||||
<Directory "{site_path}">
|
||||
SetOutputFilter DEFLATE
|
||||
Options FollowSymLinks
|
||||
AllowOverride All
|
||||
Require all granted
|
||||
DirectoryIndex index.php index.html index.htm default.php default.html default.htm
|
||||
</Directory>
|
||||
</VirtualHost>'''
|
||||
|
||||
|
||||
def gen_ols_conf(domain: str, php_version: str, site_path: str) -> Tuple[str, str]:
|
||||
"""Generate OpenLiteSpeed configuration."""
|
||||
if not site_path.endswith('/'):
|
||||
site_path += '/'
|
||||
main_conf = f"""
|
||||
#VHOST {domain} START
|
||||
virtualhost {domain} {{
|
||||
vhRoot {site_path}
|
||||
configFile {PANEL_PATH}/vhost/openlitespeed/detail/{domain}.conf
|
||||
allowSymbolLink 1
|
||||
enableScript 1
|
||||
restrained 1
|
||||
setUIDMode 0
|
||||
}}
|
||||
#VHOST {domain} END
|
||||
"""
|
||||
detail_conf = f'''docRoot $VH_ROOT
|
||||
vhDomain $VH_NAME
|
||||
adminEmails example@example.com
|
||||
enableGzip 1
|
||||
enableIpGeo 1
|
||||
|
||||
index {{
|
||||
useServer 0
|
||||
indexFiles index.php,index.html
|
||||
}}
|
||||
|
||||
errorlog /www/wwwlogs/$VH_NAME_ols.error_log {{
|
||||
useServer 0
|
||||
logLevel ERROR
|
||||
rollingSize 10M
|
||||
}}
|
||||
|
||||
accesslog /www/wwwlogs/$VH_NAME_ols.access_log {{
|
||||
useServer 0
|
||||
logFormat '%{{X-Forwarded-For}}i %h %l %u %t "%r" %>s %b "%{{Referer}}i" "%{{User-Agent}}i"'
|
||||
logHeaders 5
|
||||
rollingSize 10M
|
||||
keepDays 10 compressArchive 1
|
||||
}}
|
||||
|
||||
scripthandler {{
|
||||
add lsapi:{domain} php
|
||||
}}
|
||||
|
||||
extprocessor {domain} {{
|
||||
type lsapi
|
||||
address UDS://tmp/lshttpd/{domain}.sock
|
||||
maxConns 300
|
||||
env LSAPI_CHILDREN=300
|
||||
env LSAPI_AVOID_FORK=1
|
||||
initTimeout 600
|
||||
retryTimeout 5
|
||||
persistConn 1
|
||||
pcKeepAliveTimeout 30
|
||||
respBuffer 0
|
||||
autoStart 1
|
||||
path /usr/local/lsws/lsphp{php_version}/bin/lsphp
|
||||
extUser www
|
||||
extGroup www
|
||||
memSoftLimit 2047M
|
||||
memHardLimit 2047M
|
||||
procSoftLimit 1000
|
||||
procHardLimit 1100
|
||||
}}
|
||||
|
||||
phpIniOverride {{
|
||||
php_admin_value open_basedir "/tmp/:{site_path}"
|
||||
}}
|
||||
|
||||
expires {{
|
||||
enableExpires 1
|
||||
expiresByType image/*=A43200,text/css=A43200,application/x-javascript=A43200,application/javascript=A43200,font/*=A43200,application/x-font-ttf=A43200
|
||||
}}
|
||||
|
||||
rewrite {{
|
||||
enable 1
|
||||
autoLoadHtaccess 1
|
||||
include {PANEL_PATH}/vhost/openlitespeed/proxy/{domain}/urlrewrite/*.conf
|
||||
include {PANEL_PATH}/vhost/apache/redirect/{domain}/*.conf
|
||||
include {PANEL_PATH}/vhost/openlitespeed/redirect/{domain}/*.conf
|
||||
}}
|
||||
include {PANEL_PATH}/vhost/openlitespeed/proxy/{domain}/*.conf
|
||||
'''
|
||||
return main_conf, detail_conf
|
||||
|
||||
|
||||
def write_wordpress_rewrite(domain: str) -> None:
|
||||
"""Write WordPress rewrite rules."""
|
||||
rewrite_path = os.path.join(VHOST_REWRITE, domain + '.conf')
|
||||
with open(rewrite_path, 'w') as f:
|
||||
f.write("location /\n{\n try_files $uri $uri/ /index.php?$args;\n}\n\n"
|
||||
"rewrite /wp-admin$ $scheme://$host$uri/ permanent;\n")
|
||||
|
||||
|
||||
def ensure_ols_listen_conf(domain: str) -> None:
|
||||
"""Ensure OpenLiteSpeed listen configuration includes domain."""
|
||||
listen_dir = os.path.join(PANEL_PATH, 'vhost', 'openlitespeed', 'listen')
|
||||
os.makedirs(listen_dir, exist_ok=True)
|
||||
listen_file = os.path.join(listen_dir, '80.conf')
|
||||
listen_conf = ''
|
||||
if os.path.exists(listen_file):
|
||||
with open(listen_file, 'r') as f:
|
||||
listen_conf = f.read()
|
||||
map_line = f'\tmap\t{domain} {domain}'
|
||||
if listen_conf:
|
||||
rep = r'map\s+{0}.*'.format(re.escape(domain))
|
||||
if not re.search(rep, listen_conf):
|
||||
listen_conf = re.sub(r'secure\s*0', 'secure 0\n' + map_line, listen_conf)
|
||||
else:
|
||||
listen_conf = f"\nlistener Default80{{\n address *:80\n secure 0\n{map_line}\n}}\n"
|
||||
with open(listen_file, 'w') as f:
|
||||
f.write(listen_conf)
|
||||
|
||||
|
||||
def write_all_web_configs(domain: str, php_version: str, site_path: str) -> None:
|
||||
"""Write all web server configurations (Nginx, Apache, OLS)."""
|
||||
multi = is_multi_webservice()
|
||||
apache_port = '8288' if multi else '80'
|
||||
write_file(os.path.join(VHOST_NGINX, domain + '.conf'), gen_nginx_conf(domain, php_version, site_path))
|
||||
write_file(os.path.join(VHOST_APACHE, domain + '.conf'), gen_apache_conf(domain, php_version, site_path, port=apache_port))
|
||||
htaccess = os.path.join(site_path, '.htaccess')
|
||||
if not os.path.exists(htaccess):
|
||||
write_file(htaccess, ' ')
|
||||
shell(f'chmod 644 {htaccess}')
|
||||
shell(f'chown www:www {htaccess}')
|
||||
ols_main, ols_detail = gen_ols_conf(domain, php_version, site_path)
|
||||
write_file(os.path.join(VHOST_OLS, domain + '.conf'), ols_main, mode='a+')
|
||||
write_file(os.path.join(VHOST_OLS_DETAIL, domain + '.conf'), ols_detail)
|
||||
ensure_ols_listen_conf(domain)
|
||||
write_wordpress_rewrite(domain)
|
||||
ok, _, _ = shell('nginx -t')
|
||||
if ok:
|
||||
shell('nginx -s reload')
|
||||
|
||||
|
||||
# ========== Site Cleanup ==========
|
||||
def _cleanup_site(s_id: int, d_id: int, domain: str, db_name: str, db_user: str, site_path: str) -> None:
|
||||
"""Clean up site resources on failure."""
|
||||
for fn, args in [
|
||||
(db_execute, ('DELETE FROM domain WHERE pid=?', (s_id,))),
|
||||
(db_execute, ('DELETE FROM wordpress_onekey WHERE s_id=?', (s_id,))),
|
||||
(db_execute, ('DELETE FROM databases WHERE id=?', (d_id,))),
|
||||
(db_execute, ('DELETE FROM sites WHERE id=?', (s_id,))),
|
||||
]:
|
||||
try:
|
||||
fn(*args)
|
||||
except Exception as e:
|
||||
logger.warning(f"Cleanup query failed: {e}")
|
||||
run_mysql_sql(
|
||||
f"DROP DATABASE IF EXISTS `{db_name}`; DROP USER IF EXISTS `{db_user}`@'localhost'; FLUSH PRIVILEGES;",
|
||||
get_mysql_root_password()
|
||||
)
|
||||
if os.path.exists(site_path):
|
||||
shutil.rmtree(site_path, ignore_errors=True)
|
||||
for p in [
|
||||
os.path.join(VHOST_NGINX, domain + '.conf'),
|
||||
os.path.join(VHOST_APACHE, domain + '.conf'),
|
||||
os.path.join(VHOST_OLS_DETAIL, domain + '.conf'),
|
||||
os.path.join(VHOST_REWRITE, domain + '.conf'),
|
||||
]:
|
||||
if os.path.exists(p):
|
||||
os.remove(p)
|
||||
ols_main = os.path.join(VHOST_OLS, domain + '.conf')
|
||||
if os.path.exists(ols_main):
|
||||
try:
|
||||
content = open(ols_main, 'r').read()
|
||||
content = re.sub(f'#VHOST {domain} START.*?#VHOST {domain} END\n?', '', content, flags=re.DOTALL)
|
||||
open(ols_main, 'w').write(content)
|
||||
except Exception as e:
|
||||
logger.warning(f"OLS cleanup failed: {e}")
|
||||
shell('nginx -s reload')
|
||||
|
||||
|
||||
# ========== Commands ==========
|
||||
|
||||
def cmd_create_site(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Create a new WordPress site."""
|
||||
required = ['weblog_title', 'user_name', 'admin_email', 'admin_password']
|
||||
for key in required:
|
||||
if key not in args or not args[key]:
|
||||
return {"status": False, "msg": f"Missing required parameter: {key}"}
|
||||
domain = args.get('domain', 'example.com')
|
||||
if not isinstance(domain, str):
|
||||
domain = str(domain)
|
||||
domain = domain.strip().lower()
|
||||
if not domain:
|
||||
domain = 'example.com'
|
||||
password = args['admin_password']
|
||||
if not isinstance(password, str):
|
||||
password = str(password)
|
||||
if len(password) < 8:
|
||||
return {"status": False, "msg": "Password must be at least 8 characters"}
|
||||
existing = db_query('SELECT id FROM sites WHERE name=?', (domain,), fetchone=True)
|
||||
if existing:
|
||||
return {"status": False, "msg": f"Site {domain} already exists (ID: {existing['id']})"}
|
||||
if not os.path.exists("/www/server/mysql"):
|
||||
return {"status": False, "msg": "MySQL is not installed. Please install MySQL first."}
|
||||
if not os.path.exists("/www/server/php"):
|
||||
return {"status": False, "msg": "PHP is not installed. Please install PHP first."}
|
||||
php_version = args.get('php_version', '')
|
||||
if not isinstance(php_version, str):
|
||||
php_version = str(php_version) if php_version else ''
|
||||
php_version = php_version.strip()
|
||||
if not php_version:
|
||||
php_version = detect_php_version()
|
||||
if not php_version:
|
||||
return {"status": False, "msg": "No installed PHP version detected"}
|
||||
if not os.path.exists(f"/www/server/php/{php_version}"):
|
||||
available = [d for d in os.listdir("/www/server/php/") if d.isdigit()]
|
||||
return {"status": False, "msg": f"PHP {php_version} not installed. Available: {', '.join(sorted(available))}"}
|
||||
if not ensure_wp_cli():
|
||||
return {"status": False, "msg": "WP-CLI download failed. Please install manually to /usr/local/bin/wp"}
|
||||
|
||||
site_path = f"/www/wwwroot/{domain}"
|
||||
db_name = 'wp_' + ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
|
||||
db_user = db_name
|
||||
db_password = password
|
||||
now = get_date()
|
||||
prefix = args.get('prefix', 'wp_')
|
||||
s_id = d_id = None
|
||||
|
||||
try:
|
||||
os.makedirs(site_path, exist_ok=True)
|
||||
ok, err = run_mysql_sql(
|
||||
f"CREATE DATABASE IF NOT EXISTS `{db_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; "
|
||||
f"CREATE USER `{db_user}`@'localhost' IDENTIFIED BY '{db_password}'; "
|
||||
f"GRANT ALL PRIVILEGES ON `{db_name}`.* TO `{db_user}`@'localhost'; FLUSH PRIVILEGES;"
|
||||
)
|
||||
if not ok:
|
||||
return {"status": False, "msg": f"Failed to create database: {err}"}
|
||||
|
||||
d_id = db_execute(
|
||||
'INSERT INTO databases (pid,sid,db_type,name,username,password,accept,type,ps,addtime) VALUES (?,?,?,?,?,?,?,?,?,?)',
|
||||
(0, 0, 0, db_name, db_user, db_password, '127.0.0.1', 'MySQL', domain, now)
|
||||
)
|
||||
s_id = db_execute(
|
||||
'INSERT INTO sites (name,path,status,ps,type_id,addtime,project_type,project_config) VALUES (?,?,?,?,?,?,?,?)',
|
||||
(domain, site_path, '1', args.get('ps', domain), 0, now, 'WP2', '{}')
|
||||
)
|
||||
db_execute('UPDATE databases SET pid=? WHERE id=?', (s_id, d_id))
|
||||
db_execute('INSERT INTO domain (pid,name,port,addtime) VALUES (?,?,?,?)', (s_id, domain, '80', now))
|
||||
|
||||
ok, out, err = run_wp_cli(['core', 'download', '--locale=' + args.get('language', 'en_US'), '--force'], php_version, cwd=site_path)
|
||||
if not ok:
|
||||
raise Exception(f"WordPress download failed: {err or out}")
|
||||
ok, out, err = run_wp_cli([
|
||||
'config', 'create', f'--dbname={db_name}', f'--dbuser={db_user}',
|
||||
f'--dbpass={db_password}', '--dbhost=localhost', f'--dbprefix={prefix}', '--force',
|
||||
], php_version, cwd=site_path)
|
||||
if not ok:
|
||||
raise Exception(f"wp-config.php creation failed: {err or out}")
|
||||
title = args['weblog_title']
|
||||
admin_user = args['user_name']
|
||||
admin_email = args['admin_email']
|
||||
ok, out, err = run_wp_cli([
|
||||
'core', 'install', f'--url=http://{domain}', f'--title={title}',
|
||||
f'--admin_user={admin_user}', f'--admin_password={password}',
|
||||
f'--admin_email={admin_email}', '--skip-email'
|
||||
], php_version, cwd=site_path)
|
||||
if not ok:
|
||||
raise Exception(f"WordPress installation failed: {err or out}")
|
||||
|
||||
write_all_web_configs(domain, php_version, site_path)
|
||||
shell(f'chown -R www:www {site_path}')
|
||||
shell(f'chmod -R 755 {site_path}')
|
||||
db_execute(
|
||||
'INSERT INTO wordpress_onekey (s_id,d_id,prefix,user,pass,site_type) VALUES (?,?,?,?,?,?)',
|
||||
(s_id, d_id, prefix, args['user_name'], password, 'Default category')
|
||||
)
|
||||
logger.info(f"WordPress site created: {domain} (ID: {s_id})")
|
||||
return {
|
||||
"status": True, "msg": "WordPress site created successfully", "domain": domain,
|
||||
"path": site_path, "s_id": s_id, "d_id": d_id, "database": db_name,
|
||||
"admin_url": f"http://{domain}/wp-admin", "admin_user": args['user_name'],
|
||||
}
|
||||
except Exception as e:
|
||||
if s_id is not None:
|
||||
_cleanup_site(s_id, d_id or 0, domain, db_name, db_user, site_path)
|
||||
return {"status": False, "msg": f"Deployment failed: {str(e)}"}
|
||||
|
||||
|
||||
def cmd_site_info(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get site details by domain or s_id."""
|
||||
domain = args.get('domain', '')
|
||||
if not isinstance(domain, str):
|
||||
domain = str(domain) if domain else ''
|
||||
domain = domain.strip().lower()
|
||||
s_id = args.get('s_id')
|
||||
if s_id is not None:
|
||||
try:
|
||||
s_id = int(s_id)
|
||||
except (ValueError, TypeError):
|
||||
return {"status": False, "msg": f"Invalid s_id: {s_id}"}
|
||||
site = db_query('SELECT id,path,name,status,ps,project_type FROM sites WHERE id=?', (s_id,), fetchone=True)
|
||||
elif domain:
|
||||
site = db_query('SELECT id,path,name,status,ps,project_type FROM sites WHERE name=?', (domain,), fetchone=True)
|
||||
else:
|
||||
return {"status": False, "msg": "Please provide domain or s_id"}
|
||||
if not site:
|
||||
return {"status": False, "msg": "Site not found"}
|
||||
site['database'] = db_query('SELECT name,username,password FROM databases WHERE pid=?', (site['id'],), fetchone=True)
|
||||
site['domains'] = db_query('SELECT name,port FROM domain WHERE pid=?', (site['id'],))
|
||||
return {"status": True, "data": site}
|
||||
|
||||
|
||||
def cmd_list_sites(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""List all WordPress sites."""
|
||||
return {"status": True, "data": db_query("SELECT id,name,path,status,ps FROM sites WHERE project_type='WP2'")}
|
||||
|
||||
|
||||
def cmd_ensure_wp_cli(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Check and install WP-CLI if not present."""
|
||||
if ensure_wp_cli():
|
||||
return {"status": True, "msg": "WP-CLI is ready", "path": WP_CLI_PATH}
|
||||
return {"status": False, "msg": "WP-CLI installation failed. Please install manually: curl -o /usr/local/bin/wp https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar && chmod +x /usr/local/bin/wp"}
|
||||
|
||||
|
||||
def cmd_site_wcli(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Get WP-CLI command prefix for a site."""
|
||||
s_id = args.get('s_id')
|
||||
if s_id is None:
|
||||
return {"status": False, "msg": "Please provide s_id"}
|
||||
try:
|
||||
s_id = int(s_id)
|
||||
except (ValueError, TypeError):
|
||||
return {"status": False, "msg": f"Invalid s_id: {s_id}"}
|
||||
site, php_ver = resolve_site(s_id)
|
||||
if not site:
|
||||
return {"status": False, "msg": "Site not found"}
|
||||
php_bin = get_php_bin(php_ver)
|
||||
site_path = site['path']
|
||||
prefix = f"{php_bin} -d memory_limit=512M {WP_CLI_PATH} --allow-root --path={site_path}"
|
||||
return {
|
||||
"status": True, "domain": site['name'], "path": site_path,
|
||||
"php_bin": php_bin, "wp_cli": WP_CLI_PATH, "cmd_prefix": prefix,
|
||||
}
|
||||
|
||||
|
||||
def cmd_wp_cli(args: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Execute WP-CLI command for a site."""
|
||||
s_id = args.get('s_id')
|
||||
wp_args = args.get('args', [])
|
||||
if s_id is None:
|
||||
return {"status": False, "msg": "Please provide s_id"}
|
||||
try:
|
||||
s_id = int(s_id)
|
||||
except (ValueError, TypeError):
|
||||
return {"status": False, "msg": f"Invalid s_id: {s_id}"}
|
||||
if not wp_args:
|
||||
return {"status": False, "msg": "Please provide args (WP-CLI subcommand argument list)"}
|
||||
site, php_ver = resolve_site(s_id)
|
||||
if not site:
|
||||
return {"status": False, "msg": "Site not found"}
|
||||
php_bin = get_php_bin(php_ver)
|
||||
site_path = site['path']
|
||||
cmd = [php_bin, '-d', 'memory_limit=512M', WP_CLI_PATH, '--allow-root', f'--path={site_path}'] + wp_args
|
||||
returncode, stdout, stderr = run_cmd(cmd, timeout=300)
|
||||
return {"status": returncode, "stdout": stdout, "stderr": stderr}
|
||||
|
||||
|
||||
# ========== Entry Point ==========
|
||||
run_command({
|
||||
'create-site': cmd_create_site,
|
||||
'site-info': cmd_site_info,
|
||||
'list-sites': cmd_list_sites,
|
||||
'ensure-wp-cli': cmd_ensure_wp_cli,
|
||||
'site-wcli': cmd_site_wcli,
|
||||
})
|
||||
+27
-51
@@ -4,23 +4,11 @@
|
||||
"versions": [
|
||||
{
|
||||
"status": false,
|
||||
"version": "1.24"
|
||||
"version": "1.30"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "1.25"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "1.26"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "1.27"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "1.28"
|
||||
"version": "1.31"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
@@ -28,7 +16,23 @@
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "1.30"
|
||||
"version": "1.28"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "1.27"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "1.26"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "1.25"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "1.24"
|
||||
}
|
||||
],
|
||||
"type": "Web server",
|
||||
@@ -119,6 +123,10 @@
|
||||
{
|
||||
"name": "PHP",
|
||||
"versions": [
|
||||
{
|
||||
"status": false,
|
||||
"version": "8.4"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "8.3"
|
||||
@@ -131,45 +139,13 @@
|
||||
"status": false,
|
||||
"version": "8.0"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "8.5"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "7.4"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "7.3"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "7.2"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "7.1"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "7.0"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "5.2"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "5.3"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "5.4"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "5.5"
|
||||
},
|
||||
{
|
||||
"status": false,
|
||||
"version": "5.6"
|
||||
}
|
||||
],
|
||||
"type": "PHP",
|
||||
|
||||
Reference in New Issue
Block a user