@adgine/geo-cli 1.2.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +93 -0
- package/bin/adgine-geo.js +224 -0
- package/package.json +38 -0
- package/skills/.env.example +11 -0
- package/skills/README.md +247 -0
- package/skills/SKILL.md +117 -0
- package/skills/VERSION +1 -0
- package/skills/adgine-geo-aiagent/SKILL.md +200 -0
- package/skills/adgine-geo-aiagent/scripts/_client.py +341 -0
- package/skills/adgine-geo-aiagent/scripts/bot_traffic.py +278 -0
- package/skills/adgine-geo-aiagent/scripts/human_traffic.py +357 -0
- package/skills/adgine-geo-aiagent/scripts/page_analytics.py +317 -0
- package/skills/adgine-geo-aiagent/scripts/page_detail.py +273 -0
- package/skills/adgine-geo-analytics/API.md +89 -0
- package/skills/adgine-geo-analytics/SKILL.md +191 -0
- package/skills/adgine-geo-analytics/scripts/_client.py +341 -0
- package/skills/adgine-geo-analytics/scripts/get_overview.py +129 -0
- package/skills/adgine-geo-billing/SKILL.md +118 -0
- package/skills/adgine-geo-billing/scripts/_client.py +341 -0
- package/skills/adgine-geo-billing/scripts/get_credits.py +51 -0
- package/skills/adgine-geo-billing/scripts/get_credits_pricing.py +52 -0
- package/skills/adgine-geo-billing/scripts/get_subscription.py +48 -0
- package/skills/adgine-geo-billing/scripts/list_plans.py +66 -0
- package/skills/adgine-geo-brand/SKILL.md +193 -0
- package/skills/adgine-geo-brand/WORKFLOW.md +66 -0
- package/skills/adgine-geo-brand/scripts/_client.py +341 -0
- package/skills/adgine-geo-brand/scripts/generate_brand.py +95 -0
- package/skills/adgine-geo-brand/scripts/get_brand.py +67 -0
- package/skills/adgine-geo-brand/scripts/list_jobs.py +153 -0
- package/skills/adgine-geo-brand/scripts/update_brand.py +61 -0
- package/skills/adgine-geo-citation/SKILL.md +220 -0
- package/skills/adgine-geo-citation/scripts/_client.py +341 -0
- package/skills/adgine-geo-citation/scripts/create_tests.py +62 -0
- package/skills/adgine-geo-citation/scripts/get_aggregate.py +108 -0
- package/skills/adgine-geo-citation/scripts/get_results.py +179 -0
- package/skills/adgine-geo-content/SKILL.md +224 -0
- package/skills/adgine-geo-content/WORKFLOW.md +100 -0
- package/skills/adgine-geo-content/scripts/_client.py +341 -0
- package/skills/adgine-geo-content/scripts/generate_article.py +108 -0
- package/skills/adgine-geo-content/scripts/generate_outline.py +113 -0
- package/skills/adgine-geo-content/scripts/generate_titles.py +51 -0
- package/skills/adgine-geo-content/scripts/list_content.py +65 -0
- package/skills/adgine-geo-content/scripts/manage_content.py +90 -0
- package/skills/adgine-geo-content/scripts/manage_jobs.py +184 -0
- package/skills/adgine-geo-dashboard/SKILL.md +181 -0
- package/skills/adgine-geo-dashboard/scripts/_client.py +341 -0
- package/skills/adgine-geo-dashboard/scripts/check_integrations.py +139 -0
- package/skills/adgine-geo-dashboard/scripts/get_overview.py +155 -0
- package/skills/adgine-geo-dashboard/scripts/get_visibility_overview.py +93 -0
- package/skills/adgine-geo-docs/SKILL.md +60 -0
- package/skills/adgine-geo-domains/SKILL.md +223 -0
- package/skills/adgine-geo-domains/scripts/_client.py +341 -0
- package/skills/adgine-geo-domains/scripts/get_domain.py +75 -0
- package/skills/adgine-geo-domains/scripts/list_domains.py +66 -0
- package/skills/adgine-geo-domains/scripts/search_domains.py +85 -0
- package/skills/adgine-geo-integrations/SKILL.md +188 -0
- package/skills/adgine-geo-integrations/scripts/_client.py +341 -0
- package/skills/adgine-geo-integrations/scripts/cloudflare_connect.py +155 -0
- package/skills/adgine-geo-integrations/scripts/cloudflare_worker.py +212 -0
- package/skills/adgine-geo-integrations/scripts/ga4_data.py +192 -0
- package/skills/adgine-geo-integrations/scripts/ga4_setup.py +120 -0
- package/skills/adgine-geo-opportunities/SKILL.md +162 -0
- package/skills/adgine-geo-opportunities/scripts/_client.py +341 -0
- package/skills/adgine-geo-opportunities/scripts/get_opportunity.py +90 -0
- package/skills/adgine-geo-opportunities/scripts/list_opportunities.py +92 -0
- package/skills/adgine-geo-performance/SKILL.md +177 -0
- package/skills/adgine-geo-performance/scripts/_client.py +341 -0
- package/skills/adgine-geo-performance/scripts/analyze_page.py +100 -0
- package/skills/adgine-geo-projects/SKILL.md +279 -0
- package/skills/adgine-geo-projects/scripts/_client.py +341 -0
- package/skills/adgine-geo-projects/scripts/check_auth.py +45 -0
- package/skills/adgine-geo-projects/scripts/list_projects.py +47 -0
- package/skills/adgine-geo-projects/scripts/manage_competitors.py +122 -0
- package/skills/adgine-geo-projects/scripts/manage_project.py +88 -0
- package/skills/adgine-geo-saas/SKILL.md +144 -0
- package/skills/adgine-geo-saas/scripts/_client.py +341 -0
- package/skills/adgine-geo-saas/scripts/check_domain.py +43 -0
- package/skills/adgine-geo-saas/scripts/create_website.py +56 -0
- package/skills/adgine-geo-saas/scripts/get_task.py +94 -0
- package/skills/adgine-geo-site-audit/README.md +281 -0
- package/skills/adgine-geo-site-audit/SKILL.md +449 -0
- package/skills/adgine-geo-site-audit/requirements.txt +5 -0
- package/skills/adgine-geo-site-audit/scripts/_client.py +341 -0
- package/skills/adgine-geo-site-audit/scripts/_geo_notice.py +55 -0
- package/skills/adgine-geo-site-audit/scripts/export_skill_package.py +197 -0
- package/skills/adgine-geo-site-audit/scripts/geo_collect.py +2443 -0
- package/skills/adgine-geo-site-audit/scripts/geo_score.py +490 -0
- package/skills/adgine-geo-site-audit/scripts/geo_timing.py +399 -0
- package/skills/adgine-geo-site-audit/scripts/geo_visibility.py +718 -0
- package/skills/adgine-geo-site-audit/scripts/render_report_pdf.py +1502 -0
- package/skills/adgine-geo-topics/SKILL.md +232 -0
- package/skills/adgine-geo-topics/scripts/_client.py +341 -0
- package/skills/adgine-geo-topics/scripts/generate_prompts.py +140 -0
- package/skills/adgine-geo-topics/scripts/manage_prompts.py +153 -0
- package/skills/adgine-geo-topics/scripts/manage_topics.py +145 -0
- package/skills/adgine-geo-visibility/SKILL.md +168 -0
- package/skills/adgine-geo-visibility/scripts/_client.py +341 -0
- package/skills/adgine-geo-visibility/scripts/get_execution.py +151 -0
- package/skills/adgine-geo-visibility/scripts/get_matrix.py +111 -0
- package/skills/adgine-geo-visibility/scripts/get_prompt_metrics.py +104 -0
- package/skills/adgine-geo-visibility/scripts/get_topic_metrics.py +201 -0
- package/skills/adgine-geo-visibility/scripts/get_visibility.py +166 -0
- package/skills/adgine-geo-wordpress/SKILL.md +151 -0
- package/skills/adgine-geo-wordpress/scripts/_client.py +341 -0
- package/skills/adgine-geo-wordpress/scripts/list_publishable.py +62 -0
- package/skills/adgine-geo-wordpress/scripts/manage_credentials.py +139 -0
- package/skills/adgine-geo-wordpress/scripts/manage_publishes.py +108 -0
- package/skills/adgine-geo-wordpress/scripts/publish.py +79 -0
- package/skills/scripts/check_version.py +290 -0
- package/skills/setup.py +248 -0
- package/src/config.js +126 -0
- package/src/doctor.js +137 -0
- package/src/install.js +90 -0
- package/src/manifest.js +99 -0
- package/src/runner.js +159 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Shared HTTP client utilities for geo-skills scripts.
|
|
3
|
+
|
|
4
|
+
Used by all scripts in this skill. Uses Python stdlib only — no external dependencies.
|
|
5
|
+
|
|
6
|
+
This file is the canonical source. It must be copied verbatim into every
|
|
7
|
+
adgine-geo-* skill folder's scripts/ directory. The sync linter at
|
|
8
|
+
scripts/sync-skills/sync-skills.sh (in the GEOAgent repo) flags any drift.
|
|
9
|
+
"""
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
import atexit
|
|
15
|
+
import unicodedata
|
|
16
|
+
import urllib.request as _req
|
|
17
|
+
import urllib.error as _uerr
|
|
18
|
+
import urllib.parse as _up
|
|
19
|
+
|
|
20
|
+
def _load_dot_env():
|
|
21
|
+
"""Load .env from the repo root (adgine-geo-skills/) into os.environ.
|
|
22
|
+
|
|
23
|
+
Resolved relative to this file's location, so it works regardless of the
|
|
24
|
+
agent's working directory (e.g. Hermes, OpenClaw, or any other runtime).
|
|
25
|
+
Existing environment variables are never overwritten.
|
|
26
|
+
"""
|
|
27
|
+
if os.environ.get("GEO_API_KEY"):
|
|
28
|
+
return
|
|
29
|
+
# _client.py is at <repo_root>/adgine-geo-<skill>/scripts/_client.py
|
|
30
|
+
repo_root = os.path.dirname(
|
|
31
|
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
32
|
+
)
|
|
33
|
+
env_path = os.path.join(repo_root, ".env")
|
|
34
|
+
if not os.path.isfile(env_path):
|
|
35
|
+
return
|
|
36
|
+
with open(env_path) as _f:
|
|
37
|
+
for _line in _f:
|
|
38
|
+
_line = _line.strip()
|
|
39
|
+
if not _line or _line.startswith("#") or "=" not in _line:
|
|
40
|
+
continue
|
|
41
|
+
_k, _, _v = _line.partition("=")
|
|
42
|
+
_k = _k.strip()
|
|
43
|
+
_v = _v.strip()
|
|
44
|
+
if _k and _k not in os.environ:
|
|
45
|
+
os.environ[_k] = _v
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_load_dot_env()
|
|
49
|
+
|
|
50
|
+
_VERSION_UNCHECKED = object()
|
|
51
|
+
_version_state = _VERSION_UNCHECKED
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _repo_root():
|
|
55
|
+
return os.path.dirname(
|
|
56
|
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _load_check_version_module():
|
|
61
|
+
import importlib.util
|
|
62
|
+
path = os.path.join(_repo_root(), "scripts", "check_version.py")
|
|
63
|
+
if not os.path.isfile(path):
|
|
64
|
+
return None
|
|
65
|
+
spec = importlib.util.spec_from_file_location("geo_check_version", path)
|
|
66
|
+
mod = importlib.util.module_from_spec(spec)
|
|
67
|
+
spec.loader.exec_module(mod)
|
|
68
|
+
return mod
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _load_version_state():
|
|
72
|
+
global _version_state
|
|
73
|
+
if _version_state is not _VERSION_UNCHECKED:
|
|
74
|
+
return _version_state
|
|
75
|
+
_version_state = None
|
|
76
|
+
try:
|
|
77
|
+
mod = _load_check_version_module()
|
|
78
|
+
if mod is not None:
|
|
79
|
+
_version_state = mod.get_state()
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
return _version_state
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _print_version_notice():
|
|
86
|
+
"""Emit machine + human hints at import if a newer version exists."""
|
|
87
|
+
state = _load_version_state()
|
|
88
|
+
if not state or not state.get("update_available"):
|
|
89
|
+
return
|
|
90
|
+
cur, lat = state["current"], state["latest"]
|
|
91
|
+
try:
|
|
92
|
+
mod = _load_check_version_module()
|
|
93
|
+
if mod is not None:
|
|
94
|
+
inline = mod.format_user_inline(state)
|
|
95
|
+
if inline:
|
|
96
|
+
sys.stdout.write(inline + "\n\n")
|
|
97
|
+
sys.stdout.flush()
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
if state.get("install_type") == "git":
|
|
101
|
+
msg = (f"adgine-geo-skills {lat} available (current {cur}). "
|
|
102
|
+
"Tell me: 请帮我更新 adgine-geo-skills 到最新版本")
|
|
103
|
+
else:
|
|
104
|
+
msg = (f"adgine-geo-skills {lat} available (current {cur}). "
|
|
105
|
+
f"Download: {state.get('release_url', '')}")
|
|
106
|
+
notice = {"update": {"current": cur, "latest": lat, "message": msg}}
|
|
107
|
+
try:
|
|
108
|
+
line = f"_notice: {json.dumps(notice, ensure_ascii=False)}\n\n"
|
|
109
|
+
sys.stdout.write(line)
|
|
110
|
+
sys.stdout.flush()
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _print_version_footer():
|
|
116
|
+
"""Emit human-readable footer at process exit (WorkBuddy-compatible)."""
|
|
117
|
+
state = _load_version_state()
|
|
118
|
+
if not state or not state.get("update_available"):
|
|
119
|
+
return
|
|
120
|
+
try:
|
|
121
|
+
mod = _load_check_version_module()
|
|
122
|
+
if mod is None:
|
|
123
|
+
return
|
|
124
|
+
footer = mod.format_user_footer(state)
|
|
125
|
+
if footer:
|
|
126
|
+
sys.stdout.write("\n" + footer + "\n")
|
|
127
|
+
sys.stdout.flush()
|
|
128
|
+
except Exception:
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
_print_version_notice()
|
|
133
|
+
atexit.register(_print_version_footer)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def get_api_config():
|
|
137
|
+
"""Read GEO_API_KEY and GEO_API_BASE_URL from environment.
|
|
138
|
+
|
|
139
|
+
Exits with a helpful message if GEO_API_KEY is not set.
|
|
140
|
+
Returns (key, base_url).
|
|
141
|
+
"""
|
|
142
|
+
key = os.environ.get("GEO_API_KEY", "")
|
|
143
|
+
base = os.environ.get("GEO_API_BASE_URL", "https://platform.adgine.ai").rstrip("/")
|
|
144
|
+
if not key:
|
|
145
|
+
repo_root = os.path.dirname(
|
|
146
|
+
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
147
|
+
)
|
|
148
|
+
env_path = os.path.join(repo_root, ".env")
|
|
149
|
+
example_path = os.path.join(repo_root, ".env.example")
|
|
150
|
+
setup_path = os.path.join(repo_root, "setup.py")
|
|
151
|
+
print("ERROR: GEO_API_KEY is not set.")
|
|
152
|
+
print(f" Expected location: {env_path}")
|
|
153
|
+
print()
|
|
154
|
+
print(" One-shot fix (recommended):")
|
|
155
|
+
print(f" python3 {setup_path} <YOUR_KEY>")
|
|
156
|
+
print()
|
|
157
|
+
print(" Or set it manually:")
|
|
158
|
+
if not os.path.isfile(env_path):
|
|
159
|
+
print(f" cp {example_path} {env_path}")
|
|
160
|
+
print(f" edit {env_path} and set GEO_API_KEY=geo_sk_live_xxx")
|
|
161
|
+
print()
|
|
162
|
+
print(" Get your key at: https://platform.adgine.ai")
|
|
163
|
+
print(" The .env file is gitignored — your key stays local and private.")
|
|
164
|
+
print()
|
|
165
|
+
print(" NOTE: adgine-geo-site-audit does NOT require an API key.")
|
|
166
|
+
print(" Run directly: python3 adgine-geo-site-audit/scripts/geo_collect.py <url>")
|
|
167
|
+
sys.exit(1)
|
|
168
|
+
return key, base
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_project_id(arg_value=None):
|
|
172
|
+
"""Resolve project ID from argument or GEO_PROJECT_ID env var.
|
|
173
|
+
|
|
174
|
+
Exits with instructions if neither is set.
|
|
175
|
+
"""
|
|
176
|
+
pid = arg_value or os.environ.get("GEO_PROJECT_ID", "")
|
|
177
|
+
if not pid:
|
|
178
|
+
print("ERROR: Project ID is required but not set.")
|
|
179
|
+
print(" Option 1: export GEO_PROJECT_ID=<project-id>")
|
|
180
|
+
print(" Option 2: pass --project-id <id> to this script")
|
|
181
|
+
print(" Run list_projects.py from the adgine-geo-projects skill to see your available projects.")
|
|
182
|
+
sys.exit(1)
|
|
183
|
+
return pid
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _do_request(method, url, key, body=None, timeout=30):
|
|
187
|
+
"""Execute an HTTP request and return parsed JSON.
|
|
188
|
+
|
|
189
|
+
Exits with an error message on HTTP errors or network failures.
|
|
190
|
+
"""
|
|
191
|
+
data = json.dumps(body).encode("utf-8") if body is not None else None
|
|
192
|
+
headers = {
|
|
193
|
+
"Authorization": f"Bearer {key}",
|
|
194
|
+
"Content-Type": "application/json",
|
|
195
|
+
"Accept": "application/json",
|
|
196
|
+
"User-Agent": "geo-skills/1.0",
|
|
197
|
+
}
|
|
198
|
+
request = _req.Request(url, data=data, headers=headers, method=method)
|
|
199
|
+
try:
|
|
200
|
+
with _req.urlopen(request, timeout=timeout) as resp:
|
|
201
|
+
raw = resp.read().decode("utf-8")
|
|
202
|
+
return json.loads(raw) if raw else {}
|
|
203
|
+
except _uerr.HTTPError as e:
|
|
204
|
+
raw = e.read().decode("utf-8")[:400]
|
|
205
|
+
try:
|
|
206
|
+
err_data = json.loads(raw)
|
|
207
|
+
msg = err_data.get("message") or err_data.get("detail") or raw
|
|
208
|
+
except Exception:
|
|
209
|
+
msg = raw
|
|
210
|
+
if e.code == 401:
|
|
211
|
+
print("ERROR: Unauthorized — API key is invalid or revoked.")
|
|
212
|
+
print(" Generate a new key: https://platform.adgine.ai")
|
|
213
|
+
elif e.code == 403:
|
|
214
|
+
print("ERROR: Forbidden — insufficient permissions for this operation.")
|
|
215
|
+
elif e.code == 404:
|
|
216
|
+
print(f"ERROR: Not found — {msg}")
|
|
217
|
+
else:
|
|
218
|
+
print(f"ERROR: HTTP {e.code} — {msg}")
|
|
219
|
+
sys.exit(1)
|
|
220
|
+
except Exception as e:
|
|
221
|
+
print(f"ERROR: Request failed — {e}")
|
|
222
|
+
sys.exit(1)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def api_get(path, key, base, params=None, timeout=30):
|
|
226
|
+
url = f"{base}{path}"
|
|
227
|
+
if params:
|
|
228
|
+
clean = {k: str(v) for k, v in params.items() if v is not None}
|
|
229
|
+
if clean:
|
|
230
|
+
url += "?" + _up.urlencode(clean)
|
|
231
|
+
return _do_request("GET", url, key, timeout=timeout)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def api_post(path, key, base, body=None, timeout=30):
|
|
235
|
+
return _do_request("POST", f"{base}{path}", key, body, timeout=timeout)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def api_patch(path, key, base, body=None, timeout=30):
|
|
239
|
+
return _do_request("PATCH", f"{base}{path}", key, body, timeout=timeout)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def api_put(path, key, base, body=None, timeout=30):
|
|
243
|
+
return _do_request("PUT", f"{base}{path}", key, body, timeout=timeout)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def api_delete(path, key, base, timeout=30):
|
|
247
|
+
return _do_request("DELETE", f"{base}{path}", key, timeout=timeout)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def extract_data(result):
|
|
251
|
+
"""Extract the .data field from a standard geo-api envelope response.
|
|
252
|
+
|
|
253
|
+
On success the API returns {"code": 0, "data": <payload>, "message": "ok"}.
|
|
254
|
+
On non-zero code, this prints the message and exits(1).
|
|
255
|
+
"""
|
|
256
|
+
if not isinstance(result, dict):
|
|
257
|
+
return result
|
|
258
|
+
if "code" in result and result.get("code") not in (0, None):
|
|
259
|
+
msg = result.get("message") or result.get("detail") or "request failed"
|
|
260
|
+
print(f"ERROR: API returned code={result.get('code')} — {msg}")
|
|
261
|
+
sys.exit(1)
|
|
262
|
+
return result.get("data", result)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def poll_job(status_path, key, base, interval=3, max_wait=300,
|
|
266
|
+
terminal=("completed", "failed", "done", "success", "error")):
|
|
267
|
+
"""Poll a job/task endpoint until it reaches a terminal state.
|
|
268
|
+
|
|
269
|
+
Prints an inline spinner with the current phase. Returns the final job dict.
|
|
270
|
+
"""
|
|
271
|
+
elapsed = 0
|
|
272
|
+
frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
273
|
+
idx = 0
|
|
274
|
+
last_job = {}
|
|
275
|
+
while elapsed < max_wait:
|
|
276
|
+
result = api_get(status_path, key, base)
|
|
277
|
+
last_job = extract_data(result) or {}
|
|
278
|
+
status = (last_job.get("status") or "").lower()
|
|
279
|
+
phase = last_job.get("current_phase") or last_job.get("phase") or status or "pending"
|
|
280
|
+
print(f"\r {frames[idx % len(frames)]} {phase}... ({elapsed}s)", end="", flush=True)
|
|
281
|
+
idx += 1
|
|
282
|
+
if status in terminal:
|
|
283
|
+
print() # newline after spinner
|
|
284
|
+
return last_job
|
|
285
|
+
time.sleep(interval)
|
|
286
|
+
elapsed += interval
|
|
287
|
+
print()
|
|
288
|
+
print(f"WARNING: Job is still running after {max_wait}s. Check status manually.")
|
|
289
|
+
return last_job
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def print_json(data):
|
|
293
|
+
"""Print data as formatted JSON (UTF-8 safe)."""
|
|
294
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def fmt_change(value):
|
|
298
|
+
"""Format a numeric change indicator per CONVENTIONS.md §4.
|
|
299
|
+
|
|
300
|
+
Returns: '+N' / '-N' / '0' / '--' (None becomes '--').
|
|
301
|
+
"""
|
|
302
|
+
if value is None:
|
|
303
|
+
return "--"
|
|
304
|
+
try:
|
|
305
|
+
n = float(value)
|
|
306
|
+
except (TypeError, ValueError):
|
|
307
|
+
return "--"
|
|
308
|
+
if n == 0:
|
|
309
|
+
return "0"
|
|
310
|
+
sign = "+" if n > 0 else ""
|
|
311
|
+
if n == int(n):
|
|
312
|
+
return f"{sign}{int(n):,}"
|
|
313
|
+
return f"{sign}{n:,.1f}"
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def truncate(text, n=60, ellipsis="…"):
|
|
317
|
+
"""Truncate text to n characters, adding ellipsis if cut. ASCII-safe for tables."""
|
|
318
|
+
if text is None:
|
|
319
|
+
return "--"
|
|
320
|
+
s = str(text)
|
|
321
|
+
if len(s) <= n:
|
|
322
|
+
return s
|
|
323
|
+
return s[: max(0, n - len(ellipsis))] + ellipsis
|
|
324
|
+
|
|
325
|
+
def display_width(s):
|
|
326
|
+
"""Return the display width of a string (CJK chars count as 2 columns)."""
|
|
327
|
+
w = 0
|
|
328
|
+
for c in str(s):
|
|
329
|
+
eaw = unicodedata.east_asian_width(c)
|
|
330
|
+
w += 2 if eaw in ('W', 'F') else 1
|
|
331
|
+
return w
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def pad(s, width, align='left'):
|
|
335
|
+
"""Pad string to display width for CJK-aware table alignment.
|
|
336
|
+
Replaces f'{s:<N}' (align='left') and f'{s:>N}' (align='right').
|
|
337
|
+
"""
|
|
338
|
+
s = str(s)
|
|
339
|
+
dw = display_width(s)
|
|
340
|
+
spaces = max(0, width - dw) * ' '
|
|
341
|
+
return spaces + s if align == 'right' else s + spaces
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Check whether a SaaS subdomain is available.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python3 scripts/check_domain.py --subdomain mysite [--json]
|
|
6
|
+
"""
|
|
7
|
+
import sys
|
|
8
|
+
import os
|
|
9
|
+
import argparse
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, os.path.dirname(__file__))
|
|
12
|
+
from _client import get_api_config, api_get, extract_data, print_json
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main():
|
|
16
|
+
parser = argparse.ArgumentParser(description="Check SaaS subdomain availability")
|
|
17
|
+
parser.add_argument("--subdomain", required=True, help="Subdomain to check (without dots)")
|
|
18
|
+
parser.add_argument("--json", action="store_true", help="Output raw JSON")
|
|
19
|
+
args = parser.parse_args()
|
|
20
|
+
|
|
21
|
+
key, base = get_api_config()
|
|
22
|
+
result = api_get("/api/saas/domain/check", key, base,
|
|
23
|
+
params={"subdomain": args.subdomain})
|
|
24
|
+
data = extract_data(result) or {}
|
|
25
|
+
|
|
26
|
+
if args.json:
|
|
27
|
+
print_json(data)
|
|
28
|
+
return
|
|
29
|
+
|
|
30
|
+
available = data.get("available")
|
|
31
|
+
if available is True:
|
|
32
|
+
print(f"Available: {args.subdomain}")
|
|
33
|
+
elif available is False:
|
|
34
|
+
reason = data.get("reason") or data.get("message") or "already in use"
|
|
35
|
+
print(f"Unavailable: {args.subdomain} ({reason})")
|
|
36
|
+
else:
|
|
37
|
+
print(f"Result for {args.subdomain}:")
|
|
38
|
+
for k, v in data.items():
|
|
39
|
+
print(f" {k}: {v}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if __name__ == "__main__":
|
|
43
|
+
main()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Create a new SaaS-hosted website (async deployment).
|
|
3
|
+
|
|
4
|
+
This kicks off an async deployment job. Use get_task.py to poll the returned
|
|
5
|
+
task_id until it reaches a terminal state.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python3 scripts/create_website.py --subdomain mysite \\
|
|
9
|
+
--brand-name "My Site" --industry "SaaS" \\
|
|
10
|
+
--description "An AI-first content platform" \\
|
|
11
|
+
--language English [--json]
|
|
12
|
+
"""
|
|
13
|
+
import sys
|
|
14
|
+
import os
|
|
15
|
+
import argparse
|
|
16
|
+
|
|
17
|
+
sys.path.insert(0, os.path.dirname(__file__))
|
|
18
|
+
from _client import get_api_config, api_post, extract_data, print_json
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def main():
|
|
22
|
+
parser = argparse.ArgumentParser(description="Create a SaaS-hosted website")
|
|
23
|
+
parser.add_argument("--subdomain", required=True, help="Subdomain (e.g. mysite)")
|
|
24
|
+
parser.add_argument("--brand-name", required=True, help="Brand name")
|
|
25
|
+
parser.add_argument("--industry", required=True, help="Industry")
|
|
26
|
+
parser.add_argument("--description", required=True, help="Short brand description")
|
|
27
|
+
parser.add_argument("--language", default="English", help="Content language")
|
|
28
|
+
parser.add_argument("--json", action="store_true", help="Output raw JSON")
|
|
29
|
+
args = parser.parse_args()
|
|
30
|
+
|
|
31
|
+
key, base = get_api_config()
|
|
32
|
+
body = {
|
|
33
|
+
"subdomain": args.subdomain,
|
|
34
|
+
"brand_name": args.brand_name,
|
|
35
|
+
"industry": args.industry,
|
|
36
|
+
"description": args.description,
|
|
37
|
+
"language": args.language,
|
|
38
|
+
}
|
|
39
|
+
result = api_post("/api/saas/websites", key, base, body=body)
|
|
40
|
+
data = extract_data(result) or {}
|
|
41
|
+
|
|
42
|
+
if args.json:
|
|
43
|
+
print_json(data)
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
task_id = data.get("task_id") or data.get("id")
|
|
47
|
+
print(f"Website deployment started.")
|
|
48
|
+
if task_id:
|
|
49
|
+
print(f" Task ID: {task_id}")
|
|
50
|
+
print(f" Poll status: python3 scripts/get_task.py --task-id {task_id}")
|
|
51
|
+
else:
|
|
52
|
+
print(" (no task_id returned — inspect raw response with --json)")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
main()
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Query the status of a SaaS deployment task.
|
|
3
|
+
|
|
4
|
+
With --poll, this will block and poll every few seconds until the task reaches
|
|
5
|
+
a terminal state (completed / failed / success / error).
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
python3 scripts/get_task.py --task-id <id> [--poll] [--json]
|
|
9
|
+
"""
|
|
10
|
+
import sys
|
|
11
|
+
import os
|
|
12
|
+
import argparse
|
|
13
|
+
|
|
14
|
+
sys.path.insert(0, os.path.dirname(__file__))
|
|
15
|
+
from _client import (
|
|
16
|
+
get_api_config, api_get, extract_data, print_json, poll_job, truncate,
|
|
17
|
+
pad,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _norm_status(s):
|
|
22
|
+
s = (s or "").lower()
|
|
23
|
+
if s in ("completed", "complete", "success", "done"):
|
|
24
|
+
return "Completed"
|
|
25
|
+
if s in ("failed", "error"):
|
|
26
|
+
return "Failed"
|
|
27
|
+
if s in ("running", "in_progress", "generating", "deploying"):
|
|
28
|
+
return "Generating"
|
|
29
|
+
if s in ("pending", "queued", "created"):
|
|
30
|
+
return "Pending"
|
|
31
|
+
return s.title() if s else "--"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main():
|
|
35
|
+
parser = argparse.ArgumentParser(description="Query SaaS deployment task status")
|
|
36
|
+
parser.add_argument("--task-id", required=True, help="Task ID returned by create_website.py")
|
|
37
|
+
parser.add_argument("--poll", action="store_true", help="Block and poll until terminal")
|
|
38
|
+
parser.add_argument("--json", action="store_true", help="Output raw JSON")
|
|
39
|
+
args = parser.parse_args()
|
|
40
|
+
|
|
41
|
+
key, base = get_api_config()
|
|
42
|
+
|
|
43
|
+
if args.poll:
|
|
44
|
+
data = poll_job(f"/api/saas/task/{args.task_id}", key, base,
|
|
45
|
+
interval=10, max_wait=1200) or {}
|
|
46
|
+
else:
|
|
47
|
+
result = api_get(f"/api/saas/task/{args.task_id}", key, base)
|
|
48
|
+
data = extract_data(result) or {}
|
|
49
|
+
|
|
50
|
+
if args.json:
|
|
51
|
+
print_json(data)
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
norm_status = _norm_status(data.get('status'))
|
|
55
|
+
|
|
56
|
+
print(f"SaaS task: {args.task_id}")
|
|
57
|
+
print()
|
|
58
|
+
print("```")
|
|
59
|
+
print("┌────────────────────┬──────────────────────────────┐")
|
|
60
|
+
print("│ Field │ Value │")
|
|
61
|
+
print("├────────────────────┼──────────────────────────────┤")
|
|
62
|
+
print(f"│ {pad('status', 18)} │ {pad(norm_status, 28)} │")
|
|
63
|
+
for k in ("phase", "current_phase", "progress", "url", "subdomain",
|
|
64
|
+
"started_at", "completed_at", "error_message"):
|
|
65
|
+
if k in data:
|
|
66
|
+
print(f"│ {pad(k, 18)} │ {pad(truncate(data.get(k), 28), 28)} │")
|
|
67
|
+
print("└────────────────────┴──────────────────────────────┘")
|
|
68
|
+
print("```")
|
|
69
|
+
|
|
70
|
+
# When deployment succeeds, show WordPress login credentials
|
|
71
|
+
if norm_status == "Completed":
|
|
72
|
+
domain = data.get("domain", "")
|
|
73
|
+
wp_user = data.get("wp_username", "")
|
|
74
|
+
wp_pass = data.get("wp_password", "")
|
|
75
|
+
if domain and (wp_user or wp_pass):
|
|
76
|
+
wp_login_url = f"https://{domain}/wp-login.php"
|
|
77
|
+
print()
|
|
78
|
+
print("🎉 Your website is live! WordPress login details:")
|
|
79
|
+
print()
|
|
80
|
+
print("```")
|
|
81
|
+
print("┌────────────────────┬──────────────────────────────┐")
|
|
82
|
+
print("│ WordPress │ Info │")
|
|
83
|
+
print("├────────────────────┼──────────────────────────────┤")
|
|
84
|
+
print(f"│ {pad('login URL', 18)} │ {pad(truncate(wp_login_url, 28), 28)} │")
|
|
85
|
+
if wp_user:
|
|
86
|
+
print(f"│ {pad('username', 18)} │ {pad(truncate(wp_user, 28), 28)} │")
|
|
87
|
+
if wp_pass:
|
|
88
|
+
print(f"│ {pad('password', 18)} │ {pad(truncate(wp_pass, 28), 28)} │")
|
|
89
|
+
print("└────────────────────┴──────────────────────────────┘")
|
|
90
|
+
print("```")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
if __name__ == "__main__":
|
|
94
|
+
main()
|