@follenfang/fupload 0.0.0-bootstrap.0 → 0.0.2

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.
Files changed (47) hide show
  1. package/README.md +236 -3
  2. package/fupload/SKILL.md +142 -0
  3. package/fupload/agents/openai.yaml +4 -0
  4. package/fupload/examples/curseforge-plugin-upload.json +21 -0
  5. package/fupload/examples/dd-config-delete.json +5 -0
  6. package/fupload/examples/dd-config-update.json +25 -0
  7. package/fupload/examples/dd-plugin-delete.json +5 -0
  8. package/fupload/examples/dd-plugin-update.json +9 -0
  9. package/fupload/examples/dd-wa-delete.json +5 -0
  10. package/fupload/examples/dd-wa-edit.json +9 -0
  11. package/fupload/examples/newbee-config-delete.json +5 -0
  12. package/fupload/examples/newbee-config-update.json +10 -0
  13. package/fupload/examples/newbee-plugin-create.json +14 -0
  14. package/fupload/examples/newbee-plugin-delete.json +5 -0
  15. package/fupload/examples/newbee-wa-delete.json +5 -0
  16. package/fupload/examples/newbee-wa-update.json +8 -0
  17. package/fupload/references/curseforge.md +233 -0
  18. package/fupload/references/dd.md +105 -0
  19. package/fupload/references/newbee-official-cli.md +288 -0
  20. package/fupload/references/newbee.md +80 -0
  21. package/fupload/references/workflow.md +67 -0
  22. package/fupload/scripts/fupload.py +17 -0
  23. package/fupload/scripts/fupload_cli/__init__.py +3 -0
  24. package/fupload/scripts/fupload_cli/cli.py +281 -0
  25. package/fupload/scripts/fupload_cli/curseforge.py +186 -0
  26. package/fupload/scripts/fupload_cli/dd.py +2406 -0
  27. package/fupload/scripts/fupload_cli/dd_broker.py +634 -0
  28. package/fupload/scripts/fupload_cli/dd_sidecar.py +860 -0
  29. package/fupload/scripts/fupload_cli/errors.py +94 -0
  30. package/fupload/scripts/fupload_cli/io.py +125 -0
  31. package/fupload/scripts/fupload_cli/newbee.py +1412 -0
  32. package/fupload/scripts/fupload_cli/newbee_auth.py +135 -0
  33. package/fupload/scripts/fupload_cli/schema.py +587 -0
  34. package/fupload/scripts/fupload_cli/transport.py +125 -0
  35. package/fupload/scripts/fupload_cli/trust.py +207 -0
  36. package/npm/bin/fupload.mjs +92 -0
  37. package/npm/lib/curseforge-config.mjs +36 -0
  38. package/npm/lib/managed-install.mjs +86 -0
  39. package/npm/lib/options.mjs +38 -0
  40. package/npm/lib/python.mjs +45 -0
  41. package/npm/lib/skill-installer.mjs +228 -0
  42. package/npm/lib/uninstall.mjs +211 -0
  43. package/npm/lib/update.mjs +102 -0
  44. package/npm/lib/versions.mjs +63 -0
  45. package/npm/postinstall.mjs +21 -0
  46. package/npm/skill-manifest.json +179 -0
  47. package/package.json +50 -6
@@ -0,0 +1,281 @@
1
+ """Argparse command tree for Fupload."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any, Callable, Dict, Optional, Sequence, Tuple
10
+
11
+ from . import __version__
12
+ from .dd import DD
13
+ from .curseforge import CurseForge
14
+ from .errors import FuploadError, ValidationError
15
+ from .io import read_json, write_error, write_output
16
+ from .newbee import NewBee
17
+ from .schema import get_schema, schema_help
18
+
19
+
20
+ WRITE_HELP = """This command performs one remote business action from a versioned JSON document.
21
+
22
+ It is non-interactive. Unknown fields and fields belonging to another action are rejected.
23
+ On edit/update, an omitted field preserves the remote value; an explicit empty value clears it
24
+ only when the field contract permits. The provider GETs current detail and dynamic options before
25
+ building an allowlisted wire payload, then reads the result back after the write.
26
+
27
+ Public/review changes are never implicit. Set public and submit_for_review explicitly where the
28
+ schema exposes them. The calling Skill must show the complete plan and obtain confirmation first.
29
+ """
30
+
31
+
32
+ def _parser(**kwargs: Any) -> argparse.ArgumentParser:
33
+ return argparse.ArgumentParser(
34
+ formatter_class=argparse.RawDescriptionHelpFormatter,
35
+ **kwargs,
36
+ )
37
+
38
+
39
+ def _positive(value: str) -> int:
40
+ number = int(value)
41
+ if number <= 0:
42
+ raise argparse.ArgumentTypeError("must be greater than zero")
43
+ return number
44
+
45
+
46
+ def _page_flags(parser: argparse.ArgumentParser, *, offset: bool = False) -> None:
47
+ if offset:
48
+ parser.add_argument("--offset", type=int, default=0, help="Zero-based result offset (technical default: 0).")
49
+ else:
50
+ parser.add_argument("--page", type=_positive, default=1, help="One-based page number (technical default: 1).")
51
+ parser.add_argument("--page-size", type=_positive, default=50, help="Page size, capped by the platform (technical default: 50).")
52
+
53
+
54
+ def _list_flags(parser: argparse.ArgumentParser, *, offset: bool = False, game_type: bool = False) -> None:
55
+ parser.add_argument("--keyword", default="", help="Optional name/title filter; empty means all current-author records.")
56
+ _page_flags(parser, offset=offset)
57
+ if game_type:
58
+ parser.add_argument("--game-type", type=_positive, required=True, help="DD game type selected from `dd options game-types`.")
59
+
60
+
61
+ def _write_leaf(parent: argparse._SubParsersAction, platform: str, resource: str, action: str, summary: str) -> None:
62
+ leaf = parent.add_parser(
63
+ action, help=summary, description=summary + "\n\n" + WRITE_HELP,
64
+ epilog=schema_help(platform, resource, action),
65
+ formatter_class=argparse.RawDescriptionHelpFormatter,
66
+ )
67
+ leaf.add_argument("--input", required=True, metavar="PATH|-", help="Versioned JSON file, or - to read one JSON object from stdin.")
68
+ leaf.add_argument("--dry-run", action="store_true", help="Validate schema and local files only; do not authenticate, upload, or write remotely.")
69
+ if platform == "dd":
70
+ leaf.add_argument("--session", help="Opaque task session ID returned by `dd session start`; required for a live DD operation.")
71
+ leaf.set_defaults(handler="write", platform=platform, resource=resource, action=action)
72
+
73
+
74
+ def _read_leaf(parent: argparse._SubParsersAction, name: str, summary: str, **defaults: Any) -> argparse.ArgumentParser:
75
+ leaf = parent.add_parser(name, help=summary, description=summary + "\n\nThis is a read-only command and emits stable JSON.")
76
+ leaf.set_defaults(handler="read", **defaults)
77
+ if defaults.get("platform") == "dd" and defaults.get("resource") != "session":
78
+ leaf.add_argument("--session", help="Opaque task session ID returned by `dd session start`; required for this live DD read.")
79
+ return leaf
80
+
81
+
82
+ def _newbee_relationship_tree(parent: argparse.ArgumentParser, resource: str, label: str) -> None:
83
+ authors = parent.add_parser("co-author", help="Search, list, or replace %s co-authors" % label).add_subparsers(dest="author_action", required=True)
84
+ leaf = _read_leaf(authors, "search", "Search current co-author candidates.", platform="newbee", resource=resource, action="co-author-search"); leaf.add_argument("--keyword", required=True)
85
+ leaf = _read_leaf(authors, "list", "List current co-authors for one %s." % label, platform="newbee", resource=resource, action="co-author-list"); leaf.add_argument("--id", type=_positive, required=True)
86
+ _write_leaf(authors, "newbee", resource + "-co-author", "set", "Replace the complete %s co-author list; an empty array clears it." % label)
87
+ references = parent.add_parser("reference", help="Search, list, or replace %s content references" % label).add_subparsers(dest="reference_action", required=True)
88
+ leaf = _read_leaf(references, "search", "Search content that can be referenced by this %s." % label, platform="newbee", resource=resource, action="reference-search"); leaf.add_argument("--keyword", required=True)
89
+ leaf = _read_leaf(references, "list", "List current references for one %s." % label, platform="newbee", resource=resource, action="reference-list"); leaf.add_argument("--id", type=_positive, required=True)
90
+ _write_leaf(references, "newbee", resource + "-reference", "set", "Replace the complete %s reference list; an empty array clears it." % label)
91
+
92
+
93
+ def _newbee_tree(platforms: argparse._SubParsersAction) -> None:
94
+ root = platforms.add_parser("newbee", help="NewBeeBox Creator operations", description="Reuse the signed-in NewBeeBox desktop auth-store; no token input is accepted.")
95
+ groups = root.add_subparsers(dest="resource_command", required=True)
96
+
97
+ session = groups.add_parser("session", help="Authentication diagnostics").add_subparsers(dest="action_command", required=True)
98
+ _read_leaf(session, "doctor", "Verify Windows Known Folder credentials, fixed official origins, and the Creator token exchange.", platform="newbee", resource="session", action="doctor")
99
+ options = groups.add_parser("options", help="Read dynamic business choices before writing").add_subparsers(dest="option_action", required=True)
100
+ for action, text in (
101
+ ("content-origins", "List current content-origin values."),
102
+ ("subscribe-plans", "List current author subscription plan levels."),
103
+ ("time-ranges", "List current one-time purchase durations."),
104
+ ):
105
+ _read_leaf(options, action, text, platform="newbee", resource="options", action=action)
106
+
107
+ plugin = groups.add_parser("plugin", help="Plugin create, version update, metadata edit, and reads").add_subparsers(dest="action_command", required=True)
108
+ for action, text in (("create", "Create a private plugin record; public review is applied only after a version exists."), ("update", "Upload one immutable plugin version."), ("edit", "Edit plugin metadata or explicit public/review state."), ("delete", "Delete one explicitly confirmed plugin record.")):
109
+ _write_leaf(plugin, "newbee", "plugin", action, text)
110
+ leaf = _read_leaf(plugin, "list", "List plugins owned by the current author.", platform="newbee", resource="plugin", action="list"); _list_flags(leaf)
111
+ leaf = _read_leaf(plugin, "get", "Read one plugin detail by numeric Creator ID.", platform="newbee", resource="plugin", action="get"); leaf.add_argument("--id", type=_positive, required=True)
112
+ _read_leaf(plugin, "categories", "List current plugin categories and IDs.", platform="newbee", resource="plugin", action="categories")
113
+ _read_leaf(plugin, "game-versions", "List current game branches/build IDs, including retail and classic variants.", platform="newbee", resource="plugin", action="game-versions")
114
+ leaf = _read_leaf(plugin, "versions", "List uploaded versions for one plugin.", platform="newbee", resource="plugin", action="versions"); leaf.add_argument("--id", type=_positive, required=True); _page_flags(leaf)
115
+ changelog = plugin.add_parser("changelog", help="Read or edit plugin version logs").add_subparsers(dest="changelog_action", required=True)
116
+ leaf = _read_leaf(changelog, "list", "List version log records for one plugin.", platform="newbee", resource="plugin", action="changelog-list"); leaf.add_argument("--id", type=_positive, required=True); _page_flags(leaf)
117
+ leaf = _read_leaf(changelog, "get", "Read one plugin version log by file ID.", platform="newbee", resource="plugin", action="changelog-get"); leaf.add_argument("--id", type=_positive, required=True)
118
+ _write_leaf(changelog, "newbee", "plugin-changelog", "edit", "Edit or explicitly clear one existing plugin version log.")
119
+ _newbee_relationship_tree(plugin, "plugin", "plugin")
120
+
121
+ config = groups.add_parser("config", help="Configuration share create, backup update, metadata edit, and reads").add_subparsers(dest="action_command", required=True)
122
+ for action, text in (("create", "Create a configuration share from an existing desktop cloud backup."), ("update", "Replace cloud-backup content selections without changing metadata."), ("edit", "Edit configuration metadata, business settings, channel, or review state."), ("delete", "Delete one explicitly confirmed configuration record.")):
123
+ _write_leaf(config, "newbee", "config", action, text)
124
+ leaf = _read_leaf(config, "list", "List configuration shares owned by the current author.", platform="newbee", resource="config", action="list"); _list_flags(leaf, offset=True)
125
+ leaf = _read_leaf(config, "get", "Read a safe configuration-share detail without raw backup paths.", platform="newbee", resource="config", action="get"); leaf.add_argument("--id", type=_positive, required=True)
126
+ _read_leaf(config, "backups", "List cloud backups already uploaded by the NewBeeBox desktop client.", platform="newbee", resource="config", action="backups")
127
+ leaf = _read_leaf(config, "backup-get", "Read selectable plugins, ignored items, fonts, materials, and roles from one cloud backup.", platform="newbee", resource="config", action="backup-get"); leaf.add_argument("--id", type=_positive, required=True, help="Cloud backup ID.")
128
+ _newbee_relationship_tree(config, "config", "configuration share")
129
+
130
+ wa = groups.add_parser("wa", help="WA/string create, version update, metadata edit, and attached operations").add_subparsers(dest="action_command", required=True)
131
+ for action, text in (("create", "Create a WA/string record and first string version."), ("update", "Publish one new immutable WA/string version."), ("edit", "Edit WA metadata, media, categories, attachments, business settings, or review state."), ("delete", "Delete one explicitly confirmed WA/string record.")):
132
+ _write_leaf(wa, "newbee", "wa", action, text)
133
+ leaf = _read_leaf(wa, "list", "List WA/string records owned by the current author; raw strings are redacted.", platform="newbee", resource="wa", action="list"); _list_flags(leaf, offset=True)
134
+ leaf = _read_leaf(wa, "get", "Read one WA metadata detail; raw strings are replaced with length and SHA-256.", platform="newbee", resource="wa", action="get"); leaf.add_argument("--id", type=_positive, required=True)
135
+ leaf = _read_leaf(wa, "categories", "List WA categories for a selected game version.", platform="newbee", resource="wa", action="categories"); leaf.add_argument("--game-version-id", type=_positive, required=True)
136
+ _read_leaf(wa, "attachment-paths", "List platform-provided attachment install types and paths.", platform="newbee", resource="wa", action="attachment-paths")
137
+ media = wa.add_parser("media", help="Upload one WA image or verified attachment material").add_subparsers(dest="media_action", required=True)
138
+ _write_leaf(media, "newbee", "wa-media", "upload", "Upload one WA media file and return its reusable platform reference.")
139
+ logs = wa.add_parser("changelog", help="Read or edit WA version logs").add_subparsers(dest="log_action", required=True)
140
+ leaf = _read_leaf(logs, "latest", "Read the latest WA version summary.", platform="newbee", resource="wa", action="changelog-latest"); leaf.add_argument("--id", type=_positive, required=True)
141
+ leaf = _read_leaf(logs, "list", "List WA version log records.", platform="newbee", resource="wa", action="changelog-list"); leaf.add_argument("--id", type=_positive, required=True); _page_flags(leaf)
142
+ _write_leaf(logs, "newbee", "wa-changelog", "edit", "Edit or explicitly clear one WA version log.")
143
+ _newbee_relationship_tree(wa, "wa", "WA")
144
+ share_code = wa.add_parser("share-code", help="Set or refresh the NewBeeBox WA share code").add_subparsers(dest="share_code_action", required=True)
145
+ _write_leaf(share_code, "newbee", "wa-share-code", "set", "Set or refresh the share code for one WA module.")
146
+
147
+
148
+ def _dd_tree(platforms: argparse._SubParsersAction) -> None:
149
+ root = platforms.add_parser("dd", help="NetEase DD author operations", description="Use DD's official netease_dd.exe, credentials, native login, and NEP signer. No token input is accepted.")
150
+ groups = root.add_subparsers(dest="resource_command", required=True)
151
+ session = groups.add_parser("session", help="Installation and task-session lifecycle").add_subparsers(dest="action_command", required=True)
152
+ _read_leaf(session, "doctor", "Discover DD, verify its official Authenticode publisher, and diagnose GUI/broker state without logging in.", platform="dd", resource="session", action="doctor")
153
+ leaf = _read_leaf(session, "start", "Close confirmed official DD GUI instances, then start one task-scoped native login session.", platform="dd", resource="session", action="start")
154
+ leaf.add_argument("--confirm-close-gui", action="store_true", help="Required only when doctor reports a running official DD GUI; the Skill obtains user consent before using it.")
155
+ leaf = _read_leaf(session, "status", "Read the local task-broker status without creating a login.", platform="dd", resource="session", action="status")
156
+ leaf.add_argument("--session", help="Optional opaque session ID; omitted selects the single active local session.")
157
+ leaf = _read_leaf(session, "stop", "Log out and stop one DD task session.", platform="dd", resource="session", action="stop")
158
+ leaf.add_argument("--session", required=True, help="Opaque session ID returned by `dd session start`.")
159
+ options = groups.add_parser("options", help="Read dynamic business choices before writing").add_subparsers(dest="option_action", required=True)
160
+ for action, text in (("game-types", "List DD game types."), ("channels", "List selectable DD rooms/channels for room association."), ("life-types", "List share-code and purchase life types."), ("vip-levels", "List available anchor VIP levels."), ("associated-acts", "List current-author content eligible for association.")):
161
+ leaf = _read_leaf(options, action, text, platform="dd", resource="options", action=action)
162
+ if action == "associated-acts":
163
+ leaf.add_argument("--game-type", type=_positive, required=True)
164
+
165
+ plugin = groups.add_parser("plugin", help="DD plugin create, version update, metadata edit, and reads").add_subparsers(dest="action_command", required=True)
166
+ for action, text in (("create", "Create a DD plugin with its first selected version."), ("update", "Publish a DD plugin version while preserving first-publication metadata."), ("edit", "Edit DD plugin commercial, association, room/channel, and creation-statement settings."), ("delete", "Delete one explicitly confirmed DD plugin record.")):
167
+ _write_leaf(plugin, "dd", "plugin", action, text)
168
+ leaf = _read_leaf(plugin, "list", "List plugins owned by the current DD author account.", platform="dd", resource="plugin", action="list"); _list_flags(leaf, game_type=True)
169
+ leaf = _read_leaf(plugin, "get", "Read one DD plugin detail by share SN.", platform="dd", resource="plugin", action="get"); leaf.add_argument("--sn", required=True)
170
+ _read_leaf(plugin, "categories", "List DD plugin category choices.", platform="dd", resource="plugin", action="categories")
171
+ leaf = _read_leaf(plugin, "game-versions", "List build choices for one DD game type.", platform="dd", resource="plugin", action="game-versions"); leaf.add_argument("--game-type", type=_positive, required=True)
172
+ leaf = _read_leaf(plugin, "versions", "List versions for one DD plugin.", platform="dd", resource="plugin", action="versions"); leaf.add_argument("--sn", required=True); leaf.add_argument("--game-type", type=_positive, required=True); leaf.add_argument("--page", type=_positive, default=1)
173
+
174
+ config = groups.add_parser("config", help="DD configuration create, backup-content update, metadata edit, and reads").add_subparsers(dest="action_command", required=True)
175
+ for action, text in (("create", "Create a DD configuration share from an existing DD cloud backup."), ("update", "Update selected backup content and inner versions."), ("edit", "Edit DD configuration metadata and commercial/association settings."), ("delete", "Delete one explicitly confirmed DD configuration record.")):
176
+ _write_leaf(config, "dd", "config", action, text)
177
+ leaf = _read_leaf(config, "list", "List configuration shares owned by the current DD author.", platform="dd", resource="config", action="list"); _list_flags(leaf, game_type=True)
178
+ leaf = _read_leaf(config, "get", "Read one DD configuration detail by share SN.", platform="dd", resource="config", action="get"); leaf.add_argument("--sn", required=True)
179
+ _read_leaf(config, "backups", "List DD cloud backups available to the current account.", platform="dd", resource="config", action="backups")
180
+ leaf = _read_leaf(config, "backup-get", "Read one DD backup's complete selectable content.", platform="dd", resource="config", action="backup-get"); leaf.add_argument("--sn", required=True)
181
+
182
+ wa = groups.add_parser("wa", help="DD WA/string create, content update, metadata edit, and reads").add_subparsers(dest="action_command", required=True)
183
+ for action, text in (("create", "Create a DD WA/string record."), ("update", "Publish updated DD WA content/version/material while preserving metadata."), ("edit", "Edit DD WA metadata and commercial/association settings."), ("delete", "Delete one explicitly confirmed DD WA/string record.")):
184
+ _write_leaf(wa, "dd", "wa", action, text)
185
+ leaf = _read_leaf(wa, "list", "List WA/string records owned by the current DD author.", platform="dd", resource="wa", action="list"); _list_flags(leaf, game_type=True)
186
+ leaf = _read_leaf(wa, "get", "Read one DD WA detail by share SN.", platform="dd", resource="wa", action="get"); leaf.add_argument("--sn", required=True)
187
+ 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)
188
+
189
+
190
+ def _curseforge_tree(platforms: argparse._SubParsersAction) -> None:
191
+ root = platforms.add_parser("curseforge", help="CurseForge public project lookup and author uploads")
192
+ groups = root.add_subparsers(dest="resource_command", required=True)
193
+ session = groups.add_parser("session", help="Configuration diagnostics").add_subparsers(dest="action_command", required=True)
194
+ _read_leaf(session, "doctor", "Check whether the fixed CurseForge configuration fields exist without revealing their values.", platform="curseforge", resource="session", action="doctor")
195
+ project = groups.add_parser("project", help="Public project lookup").add_subparsers(dest="action_command", required=True)
196
+ leaf = _read_leaf(project, "list", "List public WoW projects for one CurseForge author ID.", platform="curseforge", resource="project", action="list")
197
+ leaf.add_argument("--author-id", type=_positive, help="Override CURSEFORGE_AUTHOR_ID for this lookup.")
198
+ plugin = groups.add_parser("plugin", help="WoW plugin versions and uploads").add_subparsers(dest="action_command", required=True)
199
+ _read_leaf(plugin, "game-versions", "List CurseForge Upload API game-version choices.", platform="curseforge", resource="plugin", action="game-versions")
200
+ _write_leaf(plugin, "curseforge", "plugin", "upload", "Upload one plugin archive to an existing CurseForge project.")
201
+
202
+
203
+ def build_parser() -> argparse.ArgumentParser:
204
+ parser = _parser(
205
+ prog="fupload",
206
+ description="Atomic World of Warcraft author publishing CLI for NewBeeBox, NetEase DD, and CurseForge.",
207
+ epilog="All output is JSON. Write commands require versioned JSON through --input and never prompt.",
208
+ )
209
+ parser.add_argument("--version", action="version", version="%(prog)s " + __version__)
210
+ platforms = parser.add_subparsers(dest="platform_command", required=True)
211
+ _newbee_tree(platforms)
212
+ _dd_tree(platforms)
213
+ _curseforge_tree(platforms)
214
+ return parser
215
+
216
+
217
+ def _validate_nested_files(doc: Dict[str, Any]) -> None:
218
+ for name in ("screenshot_files", "picture_files", "image_files", "detail_img_files", "display_img_files"):
219
+ if name not in doc:
220
+ continue
221
+ if not isinstance(doc[name], list):
222
+ raise ValidationError("expected array", path="$.%s" % name)
223
+ for index, value in enumerate(doc[name]):
224
+ if not isinstance(value, str) or not os.path.isfile(value):
225
+ raise ValidationError("file does not exist or is not a regular file", path="$.%s[%d]" % (name, index))
226
+
227
+
228
+ def _dry_run_data(doc: Dict[str, Any], schema_name: str) -> Dict[str, Any]:
229
+ files = {}
230
+ for name, value in doc.items():
231
+ if name == "file" or name.endswith("_file"):
232
+ if isinstance(value, str) and value:
233
+ files[name] = {"name": Path(value).name, "size": Path(value).stat().st_size}
234
+ elif name.endswith("_files") and isinstance(value, list):
235
+ files[name] = [{"name": Path(path).name, "size": Path(path).stat().st_size} for path in value]
236
+ return {
237
+ "schema_valid": True,
238
+ "input_schema": schema_name,
239
+ "present_fields": sorted(set(doc) - {"schema"}),
240
+ "local_files": files,
241
+ "remote_validation_performed": False,
242
+ "note": "Remote IDs, permissions, current state, and dynamic choices are checked only during execution.",
243
+ }
244
+
245
+
246
+ def main(argv: Optional[Sequence[str]] = None) -> int:
247
+ parser = build_parser()
248
+ args = parser.parse_args(argv)
249
+ platform = getattr(args, "platform", getattr(args, "platform_command", "unknown"))
250
+ resource = getattr(args, "resource", "unknown")
251
+ action = getattr(args, "action", "unknown")
252
+ operation = "%s.%s" % (resource, action)
253
+ try:
254
+ if args.handler == "write":
255
+ schema = get_schema(platform, resource, action)
256
+ doc = schema.validate(read_json(args.input))
257
+ _validate_nested_files(doc)
258
+ if args.dry_run:
259
+ write_output(platform, operation, _dry_run_data(doc, schema.name), dry_run=True)
260
+ return 0
261
+ provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else CurseForge())
262
+ if platform == "dd":
263
+ data = provider.execute_write(resource, action, doc, getattr(args, "session", None))
264
+ else:
265
+ data = provider.execute_write(resource, action, doc)
266
+ write_output(platform, operation, data)
267
+ return 0
268
+ provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else CurseForge())
269
+ if platform == "dd":
270
+ data = provider.execute_read(resource, action, args, getattr(args, "session", None))
271
+ else:
272
+ data = provider.execute_read(resource, action, args)
273
+ write_output(platform, operation, data)
274
+ return 0
275
+ except (FuploadError, OSError, ValueError) as exc:
276
+ write_error(platform, operation, exc)
277
+ return 2
278
+
279
+
280
+ if __name__ == "__main__":
281
+ raise SystemExit(main())
@@ -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
+ }