@follenfang/fupload 0.0.1 → 0.0.4
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 +48 -11
- package/fupload/SKILL.md +26 -10
- package/fupload/agents/openai.yaml +2 -2
- package/fupload/examples/curseforge-plugin-upload.json +21 -0
- package/fupload/references/blackbox.md +37 -0
- package/fupload/references/curseforge.md +233 -0
- package/fupload/scripts/fupload_cli/__init__.py +1 -1
- package/fupload/scripts/fupload_cli/blackbox.py +151 -0
- package/fupload/scripts/fupload_cli/blackbox_auth.py +54 -0
- package/fupload/scripts/fupload_cli/cli.py +42 -5
- package/fupload/scripts/fupload_cli/curseforge.py +186 -0
- package/fupload/scripts/fupload_cli/schema.py +66 -0
- package/npm/bin/fupload.mjs +2 -0
- package/npm/lib/curseforge-config.mjs +36 -0
- package/npm/lib/update.mjs +3 -0
- package/npm/postinstall.mjs +3 -0
- package/npm/skill-manifest.json +42 -12
- package/package.json +1 -1
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Heybox Workshop plugin provider."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import hashlib, json, secrets, time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import urlencode
|
|
6
|
+
from urllib.request import Request, urlopen
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
from .errors import FuploadError, ValidationError
|
|
9
|
+
from .blackbox_auth import API_BASE, hkey, load_session
|
|
10
|
+
|
|
11
|
+
class Blackbox:
|
|
12
|
+
def __init__(self, config: Mapping[str, Any] | None = None, transport=None):
|
|
13
|
+
self.config = dict(config or {})
|
|
14
|
+
self.profile = Path(self.config.get("client_profile") or Path.home() / "AppData/Roaming/heybox-pc-launcher")
|
|
15
|
+
self._transport = transport
|
|
16
|
+
def _request(self, method, path, body=None, query=None):
|
|
17
|
+
if self._transport: return self._transport(method, path, body or {}, query or {})
|
|
18
|
+
cookies, identity = load_session(self.profile); ts=int(time.time()); nonce=hashlib.md5((str(ts)+secrets.token_hex(16)).encode()).hexdigest().upper()
|
|
19
|
+
params={**identity,"version":identity.get("version","1.12.0"),"hkey":hkey(path,ts,nonce),"_time":ts,"_chat_time":int(time.time()*1000),"nonce":nonce,**(query or {})}
|
|
20
|
+
headers={"User-Agent":"HeyboxApp/1.12.0","x_xhh_tokenid":cookies["x_xhh_tokenid"],"Cookie":"; ".join(f"{k}={v}" for k,v in cookies.items()),"Content-Type":"application/x-www-form-urlencoded"}
|
|
21
|
+
url=API_BASE+path+"?"+urlencode(params); data=urlencode(body or {},doseq=True).encode() if method=="POST" else None
|
|
22
|
+
try:
|
|
23
|
+
with urlopen(Request(url,data=data,headers=headers,method=method),timeout=60) as r: result=json.loads(r.read().decode())
|
|
24
|
+
except Exception as exc: raise FuploadError("Workshop API request failed", endpoint=path, verification_required=method=="POST") from exc
|
|
25
|
+
if not isinstance(result,dict) or result.get("status")!="ok": raise FuploadError("Workshop API rejected request", endpoint=path, details={"status":result.get("status") if isinstance(result,dict) else None})
|
|
26
|
+
return result
|
|
27
|
+
@staticmethod
|
|
28
|
+
def _result(payload): return payload.get("result") or {}
|
|
29
|
+
def execute_read(self, resource: str, action: str, args: Any):
|
|
30
|
+
if resource == "plugin" and action in ("list", "get", "versions"):
|
|
31
|
+
if action == "list": return self.plugin_list()
|
|
32
|
+
module_id = getattr(args,"module_id",None) if not isinstance(args,Mapping) else args.get("module_id")
|
|
33
|
+
result = self.plugin_get(int(module_id))
|
|
34
|
+
return result["versions"] if action == "versions" else result
|
|
35
|
+
raise ValidationError("unsupported blackbox read operation")
|
|
36
|
+
def execute_write(self, resource: str, action: str, doc: Mapping[str,Any]):
|
|
37
|
+
if doc.get("dry_run"): return {"dry_run":True,"operation":f"{resource}.{action}","fields":sorted(k for k in doc if k not in {"dry_run","schema"})}
|
|
38
|
+
if resource == "plugin" and action == "edit": return self.module_edit(doc)
|
|
39
|
+
if resource == "plugin" and action == "update": return self.version_upsert(doc)
|
|
40
|
+
if resource=="version" and action in ("create","update","edit"): return self.version_upsert(doc)
|
|
41
|
+
if resource=="version" and action=="delete": return self.version_delete(doc)
|
|
42
|
+
raise ValidationError("unsupported blackbox write operation")
|
|
43
|
+
def plugin_list(self):
|
|
44
|
+
rows=self._result(self._request("GET","/wow/open_platform/module/list/")).get("moduleList") or []
|
|
45
|
+
return {"total_count":len(rows),"plugins":[self._redact(x) for x in rows if isinstance(x,dict)]}
|
|
46
|
+
def plugin_get(self,module_id:int):
|
|
47
|
+
detail=self._result(self._request("GET","/wow/open_platform/module/detail/",query={"moduleId":module_id}))
|
|
48
|
+
versions=self._result(self._request("GET","/wow/open_platform/module_version/list/",query={"moduleId":module_id,"offset":0,"limit":100}))
|
|
49
|
+
return {"module":self._redact(detail.get("module") or detail),"versions":[self._redact(x) for x in (versions.get("versionList") or [])]}
|
|
50
|
+
def module_edit(self,doc):
|
|
51
|
+
aliases={"module_id":"id","logo_url":"logoUrl","category_ids":"categoryIds","official_url":"officialUrl","core_folders":"coreFolders"}
|
|
52
|
+
normalized={aliases.get(k,k):v for k,v in doc.items() if k not in {"schema","dry_run"}}
|
|
53
|
+
if "id" not in normalized: raise ValidationError("id is required",path="$.id")
|
|
54
|
+
module_id = int(normalized["id"])
|
|
55
|
+
current = self.plugin_get(module_id)["module"]
|
|
56
|
+
# The web client sends a complete module object; preserve omitted fields.
|
|
57
|
+
defaults = {"name":"", "logoUrl":"", "id":module_id, "categoryIds":[],
|
|
58
|
+
"type":1, "desc":"", "official":"", "officialUrl":"", "coreFolders":""}
|
|
59
|
+
fields={key: normalized.get(key, current.get(key, defaults[key]))
|
|
60
|
+
for key in ("name","logoUrl","id","categoryIds","type","desc","official","officialUrl","coreFolders")}
|
|
61
|
+
fields["id"] = module_id
|
|
62
|
+
if isinstance(fields.get("coreFolders"),list): fields["coreFolders"]=",".join(map(str,fields["coreFolders"]))
|
|
63
|
+
response=self._request("POST","/wow/open_platform/module/update/",body=fields)
|
|
64
|
+
actual=self.plugin_get(module_id)["module"]
|
|
65
|
+
mismatches=[]
|
|
66
|
+
for key,wanted in ((aliases.get(key,key), value) for key,value in normalized.items() if key in aliases or key in fields):
|
|
67
|
+
observed=actual.get(key)
|
|
68
|
+
if key=="coreFolders" and isinstance(observed,list): wanted=[x for x in str(wanted).split(",") if x]
|
|
69
|
+
if observed != wanted: mismatches.append(key)
|
|
70
|
+
if mismatches: raise FuploadError("module update readback mismatch",kind="verification_required",verification_required=True,details={"fields":mismatches})
|
|
71
|
+
return {"accepted":True,"verified":True,"module_id":module_id,"response":self._redact(response)}
|
|
72
|
+
def version_upsert(self,doc):
|
|
73
|
+
aliases={"module_id":"moduleId","version_id":"versionId","game_versions":"gameVersions","file_url":"fileUrl"}
|
|
74
|
+
normalized={aliases.get(k,k):v for k,v in doc.items() if k not in {"schema","dry_run"}}
|
|
75
|
+
if "file" in normalized and "fileUrl" not in normalized:
|
|
76
|
+
normalized["fileUrl"]=self.upload_zip(int(normalized["moduleId"]),str(normalized["file"]))["url"]
|
|
77
|
+
if "fileUrl" not in normalized and "versionId" in normalized:
|
|
78
|
+
existing = self._find_version(int(normalized["moduleId"]), int(normalized["versionId"]))
|
|
79
|
+
if existing and existing.get("fileUrlHeybox"):
|
|
80
|
+
normalized["fileUrl"] = existing["fileUrlHeybox"]
|
|
81
|
+
required=("moduleId","name","type","gameVersions","fileUrl")
|
|
82
|
+
missing=[k for k in required if k not in normalized]
|
|
83
|
+
if missing: raise ValidationError("missing field(s): %s"%", ".join(missing))
|
|
84
|
+
games=normalized["gameVersions"] if isinstance(normalized["gameVersions"],list) else [x for x in str(normalized["gameVersions"]).split(",") if x]
|
|
85
|
+
body={"moduleId":int(normalized["moduleId"]),"name":normalized["name"],"type":int(normalized["type"]),"gameVersions":",".join(map(str,games)),"fileUrl":normalized["fileUrl"]}
|
|
86
|
+
if "versionId" in normalized: body["versionId"]=int(normalized["versionId"])
|
|
87
|
+
response=self._request("POST","/wow/open_platform/module_version/upsert/",body=body)
|
|
88
|
+
found=self._wait_version(body["moduleId"],body.get("versionId"),body["name"],
|
|
89
|
+
expected={"name":body["name"],"type":body["type"],"gameVersions":list(map(str,games))})
|
|
90
|
+
mismatches=[key for key,wanted in {"name":body["name"],"type":body["type"],"gameVersions":list(map(str,games))}.items() if found.get(key)!=wanted]
|
|
91
|
+
if not found.get("fileUrlHeybox"):
|
|
92
|
+
mismatches.append("fileUrl")
|
|
93
|
+
if mismatches: raise FuploadError("version upsert readback mismatch",kind="verification_required",verification_required=True,details={"fields":mismatches})
|
|
94
|
+
return {"accepted":True,"verified":True,"module_id":body["moduleId"],"version_id":found.get("id"),"readback":self._redact(found),"response":self._redact(response)}
|
|
95
|
+
def version_delete(self,doc):
|
|
96
|
+
version_id=doc.get("versionId",doc.get("version_id")); module_id=doc.get("moduleId",doc.get("module_id"))
|
|
97
|
+
if version_id is None or module_id is None: raise ValidationError("versionId and moduleId are required")
|
|
98
|
+
response=self._request("POST","/wow/open_platform/module_version/delete/",body={"versionId":int(version_id),"moduleId":int(module_id)})
|
|
99
|
+
found=self._wait_version(int(module_id),int(version_id),None,deleted=True)
|
|
100
|
+
retry = False
|
|
101
|
+
settle = float(self.config.get("delete_settle_seconds", 10))
|
|
102
|
+
if settle:
|
|
103
|
+
time.sleep(settle)
|
|
104
|
+
current = self._find_version(int(module_id), int(version_id))
|
|
105
|
+
if current is not None and current.get("auditState") != 4:
|
|
106
|
+
retry = True
|
|
107
|
+
response = self._request("POST","/wow/open_platform/module_version/delete/",body={"versionId":int(version_id),"moduleId":int(module_id)})
|
|
108
|
+
found = self._wait_version(int(module_id),int(version_id),None,deleted=True)
|
|
109
|
+
return {"accepted":True,"verified":True,"module_id":int(module_id),"version_id":int(version_id),"audit_state":found.get("auditState") if found else None,"retry":retry,"response":self._redact(response)}
|
|
110
|
+
def _version_rows(self,module_id):
|
|
111
|
+
return self._result(self._request("GET","/wow/open_platform/module_version/list/",query={"moduleId":module_id,"offset":0,"limit":100})).get("versionList") or []
|
|
112
|
+
|
|
113
|
+
def _find_version(self, module_id, version_id):
|
|
114
|
+
return next((x for x in self._version_rows(module_id) if x.get("id") == version_id), None)
|
|
115
|
+
def _wait_version(self,module_id,version_id,name,deleted=False,expected=None):
|
|
116
|
+
for attempt in range(int(self.config.get("verify_attempts",20))):
|
|
117
|
+
rows=self._version_rows(module_id)
|
|
118
|
+
found=next((x for x in rows if (version_id is not None and x.get("id")==version_id) or (version_id is None and x.get("name")==name)),None)
|
|
119
|
+
matches_expected = found and all(
|
|
120
|
+
(list(map(str, found.get(key) or [])) if key == "gameVersions" else found.get(key)) == wanted
|
|
121
|
+
for key, wanted in (expected or {}).items()
|
|
122
|
+
)
|
|
123
|
+
if (deleted and (found is None or found.get("auditState")==4)) or (not deleted and found and matches_expected): return found or {}
|
|
124
|
+
if attempt+1<int(self.config.get("verify_attempts",20)): time.sleep(float(self.config.get("verify_interval",2)))
|
|
125
|
+
raise FuploadError("version write was not confirmed by readback",kind="verification_required",verification_required=True)
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _redact(value):
|
|
128
|
+
if isinstance(value,dict): return {k:("<redacted>" if any(s in k.lower() for s in ("token","secret","cookie","nonce","hkey")) else Blackbox._redact(v)) for k,v in value.items()}
|
|
129
|
+
if isinstance(value,list): return [Blackbox._redact(v) for v in value]
|
|
130
|
+
return value
|
|
131
|
+
|
|
132
|
+
def upload_zip(self, module_id: int, file_path: str, *, dry_run=False):
|
|
133
|
+
path=Path(file_path)
|
|
134
|
+
if not path.is_file(): raise ValidationError("file does not exist",path="$.file")
|
|
135
|
+
if dry_run: return {"dry_run":True,"bytes":path.stat().st_size,"sha256":hashlib.sha256(path.read_bytes()).hexdigest()}
|
|
136
|
+
size_mb=path.stat().st_size/1024/1024
|
|
137
|
+
token=self._result(self._request("POST","/wow/cos/upload/token/",body={"jsonData":json.dumps({"upload_infos":[{"filename":path.name,"file_size":size_mb,"type":"module"}]},separators=(",",":"))}))
|
|
138
|
+
info=token["info"]; f=token["files"][0]; creds=info["Credentials"].get("Credentials",info["Credentials"])
|
|
139
|
+
try:
|
|
140
|
+
from qcloud_cos import CosConfig, CosS3Client
|
|
141
|
+
except ModuleNotFoundError as exc:
|
|
142
|
+
raise FuploadError(
|
|
143
|
+
"Heybox ZIP upload requires cos-python-sdk-v5",
|
|
144
|
+
kind="environment_error",
|
|
145
|
+
details={"install_command":"python -m pip install cos-python-sdk-v5"},
|
|
146
|
+
) from exc
|
|
147
|
+
try:
|
|
148
|
+
cos=CosS3Client(CosConfig(Region=info.get("region") or "ap-shanghai",SecretId=creds["TmpSecretID"],SecretKey=creds["TmpSecretKey"],Token=creds["Token"],Scheme="https"))
|
|
149
|
+
with path.open("rb") as stream: cos.put_object(Bucket=info["bucket"],Key=f["key"],Body=stream)
|
|
150
|
+
except Exception as exc: raise FuploadError("COS upload failed",verification_required=True) from exc
|
|
151
|
+
return {"url":"https://%s/%s"%(info["host"],f["key"]),"bytes":path.stat().st_size,"sha256":hashlib.sha256(path.read_bytes()).hexdigest()}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Authentication helpers for the Heybox desktop Workshop client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import hashlib, json, secrets, sqlite3, time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import parse_qs, urlparse
|
|
6
|
+
from .errors import FuploadError
|
|
7
|
+
|
|
8
|
+
API_BASE = "https://workshopapi.xiaoheihe.cn"
|
|
9
|
+
_ALPHABET = "AB45STUVWZEFGJ6CH01D237IXYPQRKLMN89"
|
|
10
|
+
|
|
11
|
+
def _n8(value: str, limit: int) -> str:
|
|
12
|
+
table = _ALPHABET[:limit]
|
|
13
|
+
return "".join(table[ord(ch) % len(table)] for ch in value)
|
|
14
|
+
def _i8(value: str) -> str:
|
|
15
|
+
return "".join(_ALPHABET[ord(ch) % len(_ALPHABET)] for ch in value)
|
|
16
|
+
def _interleave(parts):
|
|
17
|
+
return "".join(part[i] for i in range(max(map(len, parts))) for part in parts if i < len(part))
|
|
18
|
+
def _mix(values):
|
|
19
|
+
def p(x): return ((x << 1) ^ 27) & 255 if x & 128 else (x << 1) & 255
|
|
20
|
+
def hm(x): return p(x) ^ x
|
|
21
|
+
def qg(x): return hm(p(x))
|
|
22
|
+
def dx(x): return qg(hm(p(x)))
|
|
23
|
+
def mw(x): return dx(x) ^ qg(x) ^ hm(x)
|
|
24
|
+
a,b,c,d,*rest=values
|
|
25
|
+
return [mw(a)^dx(b)^qg(c)^hm(d), hm(a)^mw(b)^dx(c)^qg(d), qg(a)^hm(b)^mw(c)^dx(d), dx(a)^qg(b)^hm(c)^mw(d), *rest]
|
|
26
|
+
def hkey(path: str, timestamp: int, nonce: str) -> str:
|
|
27
|
+
normalized = "/" + "/".join(x for x in path.split("/") if x) + "/"
|
|
28
|
+
digest = hashlib.md5(_interleave([_n8(str(timestamp), -2), _i8(normalized), _i8(nonce)]).encode()).hexdigest()
|
|
29
|
+
return _n8(digest[:5], -4) + "%02d" % (sum(_mix([ord(x) for x in digest[-6:]])) % 100)
|
|
30
|
+
|
|
31
|
+
def load_session(profile: Path | None = None):
|
|
32
|
+
profile = profile or Path.home() / "AppData/Roaming/heybox-pc-launcher"
|
|
33
|
+
db = profile / "Network/Cookies"
|
|
34
|
+
try:
|
|
35
|
+
with sqlite3.connect("file:%s?mode=ro" % db, uri=True) as con:
|
|
36
|
+
rows = con.execute("select name,value from cookies where host_key like '%xiaoheihe.cn'").fetchall()
|
|
37
|
+
except (OSError, sqlite3.Error) as exc:
|
|
38
|
+
raise FuploadError("Heybox desktop Cookie DB is missing", kind="authentication_error") from exc
|
|
39
|
+
cookies = {str(k): str(v) for k,v in rows if k in {"user_heybox_id","user_pkey","x_xhh_tokenid"} and v}
|
|
40
|
+
if not {"user_heybox_id","user_pkey","x_xhh_tokenid"} <= set(cookies):
|
|
41
|
+
raise FuploadError("Heybox desktop login state is incomplete", kind="authentication_error")
|
|
42
|
+
identity = {}
|
|
43
|
+
scope = profile / "sentry/scope_v3.json"
|
|
44
|
+
try:
|
|
45
|
+
crumbs = json.loads(scope.read_text(encoding="utf-8")).get("scope",{}).get("breadcrumbs",[])
|
|
46
|
+
for crumb in reversed(crumbs):
|
|
47
|
+
raw = crumb.get("data",{}).get("url","")
|
|
48
|
+
if "x_app=heybox_pc" in raw:
|
|
49
|
+
query = parse_qs(urlparse(raw).query)
|
|
50
|
+
allowed = {"x_client_type","x_os_type","x_app","version","exe_version","os_version","device_id","channel","heybox_id"}
|
|
51
|
+
identity = {k:v[-1] for k,v in query.items() if k in allowed and v}; break
|
|
52
|
+
except (OSError, ValueError, TypeError):
|
|
53
|
+
pass
|
|
54
|
+
return cookies, identity
|
|
@@ -10,6 +10,8 @@ from typing import Any, Callable, Dict, Optional, Sequence, Tuple
|
|
|
10
10
|
|
|
11
11
|
from . import __version__
|
|
12
12
|
from .dd import DD
|
|
13
|
+
from .curseforge import CurseForge
|
|
14
|
+
from .blackbox import Blackbox
|
|
13
15
|
from .errors import FuploadError, ValidationError
|
|
14
16
|
from .io import read_json, write_error, write_output
|
|
15
17
|
from .newbee import NewBee
|
|
@@ -57,9 +59,10 @@ def _list_flags(parser: argparse.ArgumentParser, *, offset: bool = False, game_t
|
|
|
57
59
|
parser.add_argument("--game-type", type=_positive, required=True, help="DD game type selected from `dd options game-types`.")
|
|
58
60
|
|
|
59
61
|
|
|
60
|
-
def _write_leaf(parent: argparse._SubParsersAction, platform: str, resource: str, action: str, summary: str) -> None:
|
|
62
|
+
def _write_leaf(parent: argparse._SubParsersAction, platform: str, resource: str, action: str, summary: str, *, command: Optional[str] = None) -> None:
|
|
63
|
+
command = command or action
|
|
61
64
|
leaf = parent.add_parser(
|
|
62
|
-
|
|
65
|
+
command, help=summary, description=summary + "\n\n" + WRITE_HELP,
|
|
63
66
|
epilog=schema_help(platform, resource, action),
|
|
64
67
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
65
68
|
)
|
|
@@ -186,16 +189,50 @@ def _dd_tree(platforms: argparse._SubParsersAction) -> None:
|
|
|
186
189
|
leaf = _read_leaf(wa, "categories", "List DD WA category choices for a game type.", platform="dd", resource="wa", action="categories"); leaf.add_argument("--game-type", type=_positive, required=True)
|
|
187
190
|
|
|
188
191
|
|
|
192
|
+
def _curseforge_tree(platforms: argparse._SubParsersAction) -> None:
|
|
193
|
+
root = platforms.add_parser("curseforge", help="CurseForge public project lookup and author uploads")
|
|
194
|
+
groups = root.add_subparsers(dest="resource_command", required=True)
|
|
195
|
+
session = groups.add_parser("session", help="Configuration diagnostics").add_subparsers(dest="action_command", required=True)
|
|
196
|
+
_read_leaf(session, "doctor", "Check whether the fixed CurseForge configuration fields exist without revealing their values.", platform="curseforge", resource="session", action="doctor")
|
|
197
|
+
project = groups.add_parser("project", help="Public project lookup").add_subparsers(dest="action_command", required=True)
|
|
198
|
+
leaf = _read_leaf(project, "list", "List public WoW projects for one CurseForge author ID.", platform="curseforge", resource="project", action="list")
|
|
199
|
+
leaf.add_argument("--author-id", type=_positive, help="Override CURSEFORGE_AUTHOR_ID for this lookup.")
|
|
200
|
+
plugin = groups.add_parser("plugin", help="WoW plugin versions and uploads").add_subparsers(dest="action_command", required=True)
|
|
201
|
+
_read_leaf(plugin, "game-versions", "List CurseForge Upload API game-version choices.", platform="curseforge", resource="plugin", action="game-versions")
|
|
202
|
+
_write_leaf(plugin, "curseforge", "plugin", "upload", "Upload one plugin archive to an existing CurseForge project.")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _blackbox_tree(platforms: argparse._SubParsersAction) -> None:
|
|
206
|
+
root = platforms.add_parser(
|
|
207
|
+
"blackbox",
|
|
208
|
+
help="Heybox Workshop plugin management",
|
|
209
|
+
description="Reuse the signed-in Heybox desktop client login state; no credential input is accepted.",
|
|
210
|
+
)
|
|
211
|
+
groups = root.add_subparsers(dest="resource_command", required=True)
|
|
212
|
+
plugin = groups.add_parser("plugin", help="Heybox Workshop plugin metadata and versions").add_subparsers(dest="action_command", required=True)
|
|
213
|
+
_read_leaf(plugin, "list", "List plugins managed by the current Heybox Workshop account.", platform="blackbox", resource="plugin", action="list")
|
|
214
|
+
leaf = _read_leaf(plugin, "get", "Read one Heybox Workshop plugin and its versions.", platform="blackbox", resource="plugin", action="get")
|
|
215
|
+
leaf.add_argument("--module-id", type=_positive, required=True)
|
|
216
|
+
leaf = _read_leaf(plugin, "versions", "List versions for one Heybox Workshop plugin.", platform="blackbox", resource="plugin", action="versions")
|
|
217
|
+
leaf.add_argument("--module-id", type=_positive, required=True)
|
|
218
|
+
_write_leaf(plugin, "blackbox", "plugin", "edit", "Edit Heybox Workshop plugin metadata and verify the module readback.")
|
|
219
|
+
_write_leaf(plugin, "blackbox", "plugin", "update", "Upload a ZIP and create a new Heybox Workshop plugin version.")
|
|
220
|
+
_write_leaf(plugin, "blackbox", "version", "edit", "Edit an existing Heybox Workshop plugin version and verify its readback.", command="version-edit")
|
|
221
|
+
_write_leaf(plugin, "blackbox", "version", "delete", "Soft-delete one Heybox Workshop plugin version and verify its deleted state.", command="version-delete")
|
|
222
|
+
|
|
223
|
+
|
|
189
224
|
def build_parser() -> argparse.ArgumentParser:
|
|
190
225
|
parser = _parser(
|
|
191
226
|
prog="fupload",
|
|
192
|
-
description="Atomic World of Warcraft author publishing CLI for NewBeeBox
|
|
227
|
+
description="Atomic World of Warcraft author publishing CLI for NewBeeBox, NetEase DD, and CurseForge.",
|
|
193
228
|
epilog="All output is JSON. Write commands require versioned JSON through --input and never prompt.",
|
|
194
229
|
)
|
|
195
230
|
parser.add_argument("--version", action="version", version="%(prog)s " + __version__)
|
|
196
231
|
platforms = parser.add_subparsers(dest="platform_command", required=True)
|
|
197
232
|
_newbee_tree(platforms)
|
|
198
233
|
_dd_tree(platforms)
|
|
234
|
+
_curseforge_tree(platforms)
|
|
235
|
+
_blackbox_tree(platforms)
|
|
199
236
|
return parser
|
|
200
237
|
|
|
201
238
|
|
|
@@ -243,14 +280,14 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
243
280
|
if args.dry_run:
|
|
244
281
|
write_output(platform, operation, _dry_run_data(doc, schema.name), dry_run=True)
|
|
245
282
|
return 0
|
|
246
|
-
provider = NewBee() if platform == "newbee" else DD()
|
|
283
|
+
provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else CurseForge()))
|
|
247
284
|
if platform == "dd":
|
|
248
285
|
data = provider.execute_write(resource, action, doc, getattr(args, "session", None))
|
|
249
286
|
else:
|
|
250
287
|
data = provider.execute_write(resource, action, doc)
|
|
251
288
|
write_output(platform, operation, data)
|
|
252
289
|
return 0
|
|
253
|
-
provider = NewBee() if platform == "newbee" else DD()
|
|
290
|
+
provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else CurseForge()))
|
|
254
291
|
if platform == "dd":
|
|
255
292
|
data = provider.execute_read(resource, action, args, getattr(args, "session", None))
|
|
256
293
|
else:
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""CurseForge public project lookup and author upload provider."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import urllib.parse
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Dict, Mapping, Optional
|
|
10
|
+
|
|
11
|
+
from .errors import FuploadError, ValidationError
|
|
12
|
+
from .transport import json_request, multipart_request
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
CORE_BASE = "https://api.curseforge.com"
|
|
16
|
+
UPLOAD_BASE = "https://wow.curseforge.com"
|
|
17
|
+
CONFIG_KEYS = (
|
|
18
|
+
"CURSEFORGE_AUTHOR_ID",
|
|
19
|
+
"CURSEFORGE_API_KEY",
|
|
20
|
+
"CURSEFORGE_UPLOAD_TOKEN",
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def config_path() -> Path:
|
|
25
|
+
return Path.home() / ".fupload" / "curseforge.env"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def load_config(path: Optional[Path] = None) -> Dict[str, str]:
|
|
29
|
+
"""Load only the fixed CurseForge fields, with process env taking precedence."""
|
|
30
|
+
source = path or config_path()
|
|
31
|
+
values: Dict[str, str] = {}
|
|
32
|
+
if source.is_file():
|
|
33
|
+
try:
|
|
34
|
+
lines = source.read_text(encoding="utf-8-sig").splitlines()
|
|
35
|
+
except OSError as exc:
|
|
36
|
+
raise FuploadError("cannot read CurseForge configuration: %s" % exc, stage="dependency_get") from exc
|
|
37
|
+
for number, raw in enumerate(lines, 1):
|
|
38
|
+
line = raw.strip()
|
|
39
|
+
if not line or line.startswith("#"):
|
|
40
|
+
continue
|
|
41
|
+
if "=" not in line:
|
|
42
|
+
raise ValidationError("expected NAME=VALUE", path="%s:%d" % (source, number))
|
|
43
|
+
name, value = line.split("=", 1)
|
|
44
|
+
name, value = name.strip(), value.strip()
|
|
45
|
+
if name not in CONFIG_KEYS:
|
|
46
|
+
raise ValidationError("unknown CurseForge configuration field", path="%s:%d" % (source, number))
|
|
47
|
+
if name in values:
|
|
48
|
+
raise ValidationError("duplicate CurseForge configuration field", path="%s:%d" % (source, number))
|
|
49
|
+
values[name] = value
|
|
50
|
+
for name in CONFIG_KEYS:
|
|
51
|
+
environment_value = os.environ.get(name, "").strip()
|
|
52
|
+
if environment_value:
|
|
53
|
+
values[name] = environment_value
|
|
54
|
+
return values
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _required(config: Mapping[str, str], *names: str) -> None:
|
|
58
|
+
missing = [name for name in names if not config.get(name)]
|
|
59
|
+
if missing:
|
|
60
|
+
raise FuploadError(
|
|
61
|
+
"missing CurseForge configuration field(s): %s" % ", ".join(missing),
|
|
62
|
+
kind="authentication_error", stage="dependency_get",
|
|
63
|
+
details={"config_path": str(config_path()), "missing": missing},
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _author_id(value: Any) -> int:
|
|
68
|
+
try:
|
|
69
|
+
result = int(value)
|
|
70
|
+
except (TypeError, ValueError) as exc:
|
|
71
|
+
raise ValidationError("author ID must be a positive integer", path="--author-id") from exc
|
|
72
|
+
if result <= 0:
|
|
73
|
+
raise ValidationError("author ID must be a positive integer", path="--author-id")
|
|
74
|
+
return result
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class CurseForge:
|
|
78
|
+
def __init__(self, config: Optional[Mapping[str, str]] = None) -> None:
|
|
79
|
+
self.config = dict(config) if config is not None else load_config()
|
|
80
|
+
|
|
81
|
+
def execute_read(self, resource: str, action: str, args: Any) -> Any:
|
|
82
|
+
if resource == "session" and action == "doctor":
|
|
83
|
+
return self.doctor()
|
|
84
|
+
if resource == "project" and action == "list":
|
|
85
|
+
return self.project_list(getattr(args, "author_id", None))
|
|
86
|
+
if resource == "plugin" and action == "game-versions":
|
|
87
|
+
return self.game_versions()
|
|
88
|
+
raise ValidationError("unsupported CurseForge read operation")
|
|
89
|
+
|
|
90
|
+
def execute_write(self, resource: str, action: str, doc: Mapping[str, Any]) -> Any:
|
|
91
|
+
if resource == "plugin" and action == "upload":
|
|
92
|
+
return self.upload(doc)
|
|
93
|
+
raise ValidationError("unsupported CurseForge write operation")
|
|
94
|
+
|
|
95
|
+
def doctor(self) -> Dict[str, Any]:
|
|
96
|
+
fields = [{"name": name, "present": bool(self.config.get(name))} for name in CONFIG_KEYS]
|
|
97
|
+
return {
|
|
98
|
+
"config_path": str(config_path()),
|
|
99
|
+
"fields": fields,
|
|
100
|
+
"ready": all(field["present"] for field in fields),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
def project_list(self, author_id: Optional[int]) -> Dict[str, Any]:
|
|
104
|
+
_required(self.config, "CURSEFORGE_API_KEY")
|
|
105
|
+
selected = _author_id(author_id if author_id is not None else self.config.get("CURSEFORGE_AUTHOR_ID"))
|
|
106
|
+
query = urllib.parse.urlencode({"gameId": 1, "authorId": selected, "index": 0, "pageSize": 50})
|
|
107
|
+
url = CORE_BASE + "/v1/mods/search?" + query
|
|
108
|
+
payload = json_request(url, headers={"x-api-key": self.config["CURSEFORGE_API_KEY"]})
|
|
109
|
+
if not isinstance(payload, dict) or not isinstance(payload.get("data"), list):
|
|
110
|
+
raise FuploadError("CurseForge project response did not contain a data array", kind="platform_data_error", endpoint=url)
|
|
111
|
+
pagination = payload.get("pagination") if isinstance(payload.get("pagination"), dict) else {}
|
|
112
|
+
projects = []
|
|
113
|
+
for item in payload["data"]:
|
|
114
|
+
if not isinstance(item, dict):
|
|
115
|
+
raise FuploadError("CurseForge project response contained a non-object item", kind="platform_data_error", endpoint=url)
|
|
116
|
+
projects.append({
|
|
117
|
+
"id": item.get("id"),
|
|
118
|
+
"name": item.get("name"),
|
|
119
|
+
"slug": item.get("slug"),
|
|
120
|
+
"status": item.get("status"),
|
|
121
|
+
"dateCreated": item.get("dateCreated"),
|
|
122
|
+
"dateModified": item.get("dateModified"),
|
|
123
|
+
})
|
|
124
|
+
total_count = pagination.get("totalCount")
|
|
125
|
+
if isinstance(total_count, bool) or not isinstance(total_count, int) or total_count < 0:
|
|
126
|
+
total_count = len(projects)
|
|
127
|
+
return {
|
|
128
|
+
"author_id": selected,
|
|
129
|
+
"game_id": 1,
|
|
130
|
+
"total_count": total_count,
|
|
131
|
+
"projects": projects,
|
|
132
|
+
"pagination": pagination,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
def game_versions(self) -> Any:
|
|
136
|
+
_required(self.config, "CURSEFORGE_UPLOAD_TOKEN")
|
|
137
|
+
return json_request(
|
|
138
|
+
UPLOAD_BASE + "/api/game/versions",
|
|
139
|
+
headers={"X-Api-Token": self.config["CURSEFORGE_UPLOAD_TOKEN"]},
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
def upload(self, doc: Mapping[str, Any]) -> Dict[str, Any]:
|
|
143
|
+
_required(self.config, "CURSEFORGE_UPLOAD_TOKEN")
|
|
144
|
+
project_id = int(doc["project_id"])
|
|
145
|
+
file_path = str(doc["file"])
|
|
146
|
+
field_names = {
|
|
147
|
+
"changelog": "changelog",
|
|
148
|
+
"changelog_type": "changelogType",
|
|
149
|
+
"display_name": "displayName",
|
|
150
|
+
"game_versions": "gameVersions",
|
|
151
|
+
"game_version_names": "gameVersionNames",
|
|
152
|
+
"release_type": "releaseType",
|
|
153
|
+
"parent_file_id": "parentFileID",
|
|
154
|
+
"is_marked_for_manual_release": "isMarkedForManualRelease",
|
|
155
|
+
}
|
|
156
|
+
metadata = {wire: doc[name] for name, wire in field_names.items() if name in doc}
|
|
157
|
+
if "relations" in doc:
|
|
158
|
+
projects = []
|
|
159
|
+
for item in doc["relations"]["projects"]:
|
|
160
|
+
relation = {"slug": item["slug"], "type": item["type"]}
|
|
161
|
+
if "project_id" in item:
|
|
162
|
+
relation["projectID"] = item["project_id"]
|
|
163
|
+
projects.append(relation)
|
|
164
|
+
metadata["relations"] = {"projects": projects}
|
|
165
|
+
url = UPLOAD_BASE + "/api/projects/%d/upload-file" % project_id
|
|
166
|
+
response = multipart_request(
|
|
167
|
+
url, file_path, file_field="file",
|
|
168
|
+
fields={"metadata": json.dumps(metadata, ensure_ascii=False, separators=(",", ":"))},
|
|
169
|
+
headers={"X-Api-Token": self.config["CURSEFORGE_UPLOAD_TOKEN"]},
|
|
170
|
+
)
|
|
171
|
+
if (
|
|
172
|
+
not isinstance(response, dict)
|
|
173
|
+
or isinstance(response.get("id"), bool)
|
|
174
|
+
or not isinstance(response.get("id"), int)
|
|
175
|
+
or response["id"] <= 0
|
|
176
|
+
):
|
|
177
|
+
raise FuploadError(
|
|
178
|
+
"CurseForge upload response did not contain a positive integer id",
|
|
179
|
+
kind="platform_data_error", endpoint=url,
|
|
180
|
+
)
|
|
181
|
+
return {
|
|
182
|
+
"file_id": response["id"],
|
|
183
|
+
"project_id": project_id,
|
|
184
|
+
"archive": Path(file_path).name,
|
|
185
|
+
"status": "uploaded",
|
|
186
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import os
|
|
6
|
+
import zipfile
|
|
6
7
|
from dataclasses import dataclass, field
|
|
7
8
|
from typing import Any, Dict, Iterable, Mapping, Optional, Sequence, Tuple
|
|
8
9
|
|
|
@@ -306,6 +307,39 @@ class Schema:
|
|
|
306
307
|
raise ValidationError("expected nonempty string or null", path="$.retail_ui_config.default_edit_mode_selector")
|
|
307
308
|
if "enable_dd_setup_wizard" in retail and not isinstance(retail["enable_dd_setup_wizard"], bool):
|
|
308
309
|
raise ValidationError("expected boolean", path="$.retail_ui_config.enable_dd_setup_wizard")
|
|
310
|
+
if self.name == "fupload.v1.curseforge.plugin.upload":
|
|
311
|
+
if not zipfile.is_zipfile(value["file"]):
|
|
312
|
+
raise ValidationError("file must be a valid ZIP archive", path="$.file")
|
|
313
|
+
if "game_versions" in value and any(isinstance(item, bool) or not isinstance(item, int) or item <= 0 for item in value["game_versions"]):
|
|
314
|
+
raise ValidationError("array must contain positive integer IDs", path="$.game_versions")
|
|
315
|
+
if "game_version_names" in value and any(not isinstance(item, str) or not item.strip() for item in value["game_version_names"]):
|
|
316
|
+
raise ValidationError("array must contain nonempty strings", path="$.game_version_names")
|
|
317
|
+
relations = value.get("relations")
|
|
318
|
+
if relations is not None:
|
|
319
|
+
if set(relations) != {"projects"}:
|
|
320
|
+
unknown = sorted(set(relations) - {"projects"})
|
|
321
|
+
message = "unknown field(s): %s" % ", ".join(unknown) if unknown else "projects is required"
|
|
322
|
+
raise ValidationError(message, path="$.relations")
|
|
323
|
+
if not isinstance(relations["projects"], list):
|
|
324
|
+
raise ValidationError("expected array", path="$.relations.projects")
|
|
325
|
+
for index, relation in enumerate((relations or {}).get("projects") or []):
|
|
326
|
+
if not isinstance(relation, dict):
|
|
327
|
+
raise ValidationError("expected object", path="$.relations.projects[%d]" % index)
|
|
328
|
+
unknown = sorted(set(relation) - {"slug", "type", "project_id"})
|
|
329
|
+
if unknown:
|
|
330
|
+
raise ValidationError("unknown field(s): %s" % ", ".join(unknown), path="$.relations.projects[%d].%s" % (index, unknown[0]))
|
|
331
|
+
if not {"slug", "type"}.issubset(relation):
|
|
332
|
+
raise ValidationError("slug and type are required", path="$.relations.projects[%d]" % index)
|
|
333
|
+
if not isinstance(relation["slug"], str) or not relation["slug"].strip():
|
|
334
|
+
raise ValidationError("expected nonempty string", path="$.relations.projects[%d].slug" % index)
|
|
335
|
+
if relation["type"] not in ("embeddedLibrary", "incompatible", "optionalDependency", "requiredDependency", "tool"):
|
|
336
|
+
raise ValidationError("unsupported relation type", path="$.relations.projects[%d].type" % index)
|
|
337
|
+
if "project_id" in relation and (isinstance(relation["project_id"], bool) or not isinstance(relation["project_id"], int) or relation["project_id"] <= 0):
|
|
338
|
+
raise ValidationError("must be a positive integer", path="$.relations.projects[%d].project_id" % index)
|
|
339
|
+
if "parent_file_id" in value:
|
|
340
|
+
for field_name in ("game_versions", "game_version_names"):
|
|
341
|
+
if field_name in value:
|
|
342
|
+
raise ValidationError("must be omitted when parent_file_id is set", path="$.%s" % field_name)
|
|
309
343
|
|
|
310
344
|
|
|
311
345
|
def f(type_name: str, **kwargs: Any) -> Field:
|
|
@@ -504,6 +538,38 @@ for _resource in ("plugin", "config", "wa"):
|
|
|
504
538
|
"confirm_delete": f("boolean", choices=(True,)),
|
|
505
539
|
}, ("sn", "confirm_delete")))
|
|
506
540
|
|
|
541
|
+
register("curseforge", "plugin", "upload", required({
|
|
542
|
+
"project_id": f("integer", minimum=1),
|
|
543
|
+
"file": f("string", local_file=True),
|
|
544
|
+
"changelog": f("string"),
|
|
545
|
+
"changelog_type": f("string", choices=("text", "html", "markdown")),
|
|
546
|
+
"display_name": f("string", nonempty=True),
|
|
547
|
+
"game_versions": f("array", nonempty=True),
|
|
548
|
+
"game_version_names": f("array"),
|
|
549
|
+
"release_type": f("string", choices=("alpha", "beta", "release")),
|
|
550
|
+
"parent_file_id": f("integer", minimum=1),
|
|
551
|
+
"relations": f("object"),
|
|
552
|
+
"is_marked_for_manual_release": f("boolean"),
|
|
553
|
+
}, ("project_id", "file", "changelog", "release_type")))
|
|
554
|
+
|
|
555
|
+
register("blackbox", "plugin", "edit", required({
|
|
556
|
+
"id": f("integer"), "name": f("string"), "logo_url": f("string"), "category_ids": f("array"),
|
|
557
|
+
"type": f("integer", choices=(1, 9)), "desc": f("string"), "official": f("string"),
|
|
558
|
+
"official_url": f("string"), "core_folders": f("array"),
|
|
559
|
+
}, ("id",)))
|
|
560
|
+
register("blackbox", "plugin", "update", required({
|
|
561
|
+
"module_id": f("integer"), "name": f("string", nonempty=True), "type": f("integer", choices=(1, 2, 3)),
|
|
562
|
+
"game_versions": f("array", nonempty=True), "file": f("string", local_file=True), "file_url": f("string"),
|
|
563
|
+
}, ("module_id", "name", "type", "game_versions", "file")))
|
|
564
|
+
register("blackbox", "version", "edit", required({
|
|
565
|
+
"version_id": f("integer"), "module_id": f("integer"), "name": f("string", nonempty=True),
|
|
566
|
+
"type": f("integer", choices=(1, 2, 3)), "game_versions": f("array", nonempty=True),
|
|
567
|
+
"file": f("string", local_file=True), "file_url": f("string"),
|
|
568
|
+
}, ("version_id", "module_id", "name", "type", "game_versions")))
|
|
569
|
+
register("blackbox", "version", "delete", required({
|
|
570
|
+
"version_id": f("integer"), "module_id": f("integer"),
|
|
571
|
+
}, ("version_id", "module_id")))
|
|
572
|
+
|
|
507
573
|
|
|
508
574
|
def get_schema(platform: str, resource: str, action: str) -> Schema:
|
|
509
575
|
try:
|
package/npm/bin/fupload.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
|
+
import { ensureCurseForgeEnv } from "../lib/curseforge-config.mjs";
|
|
6
7
|
import { recordManagedSkill } from "../lib/managed-install.mjs";
|
|
7
8
|
import { parseLauncherOptions, resolveSkillDirectory } from "../lib/options.mjs";
|
|
8
9
|
import { discoverPython, runPython } from "../lib/python.mjs";
|
|
@@ -52,6 +53,7 @@ async function main() {
|
|
|
52
53
|
|
|
53
54
|
let ensured;
|
|
54
55
|
try {
|
|
56
|
+
ensureCurseForgeEnv();
|
|
55
57
|
ensured = await ensureSkill({ packageRoot, target });
|
|
56
58
|
recordManagedSkill(target);
|
|
57
59
|
} catch (error) {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const CURSEFORGE_ENV_TEMPLATE = [
|
|
6
|
+
"CURSEFORGE_AUTHOR_ID=",
|
|
7
|
+
"CURSEFORGE_API_KEY=",
|
|
8
|
+
"CURSEFORGE_UPLOAD_TOKEN=",
|
|
9
|
+
"",
|
|
10
|
+
].join("\n");
|
|
11
|
+
|
|
12
|
+
export function curseForgeEnvPath({ home = os.homedir() } = {}) {
|
|
13
|
+
return path.join(home, ".fupload", "curseforge.env");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ensureCurseForgeEnv({ home = os.homedir(), platform = process.platform } = {}) {
|
|
17
|
+
const filename = curseForgeEnvPath({ home });
|
|
18
|
+
fs.mkdirSync(path.dirname(filename), { recursive: true, mode: 0o700 });
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
fs.writeFileSync(filename, CURSEFORGE_ENV_TEMPLATE, {
|
|
22
|
+
encoding: "utf8",
|
|
23
|
+
flag: "wx",
|
|
24
|
+
mode: 0o600,
|
|
25
|
+
});
|
|
26
|
+
if (platform !== "win32") {
|
|
27
|
+
fs.chmodSync(filename, 0o600);
|
|
28
|
+
}
|
|
29
|
+
return { path: filename, status: "created" };
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error?.code === "EEXIST") {
|
|
32
|
+
return { path: filename, status: "preserved" };
|
|
33
|
+
}
|
|
34
|
+
throw error;
|
|
35
|
+
}
|
|
36
|
+
}
|