@follenfang/fupload 0.0.10 → 0.0.12

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/fupload/SKILL.md CHANGED
@@ -2,7 +2,7 @@
2
2
  name: fupload
3
3
  description: Explicit author-publishing workflow for World of Warcraft plugins, configuration shares, and WA/strings on NewBeeBox, NetEase DD, CurseForge, Heybox Workshop, and ModUs.Creator, including local Creator login reuse and plugin ZIP publishing. Use only when the user explicitly invokes `$fupload`, explicitly asks to use the Fupload Skill, or loads this Skill by path. Do not trigger from ordinary mentions of publishing, NewBeeBox, DD, CurseForge, Heybox, ModUs, plugins, configurations, or WA.
4
4
  metadata:
5
- version: "0.0.10"
5
+ version: "0.0.12"
6
6
  ---
7
7
 
8
8
  # Fupload
@@ -19,6 +19,11 @@ game/data/config/detail` with a JSON `{"keys": [...]}` body. Repeat `--key`
19
19
  to request selected server-defined configuration keys; at least one key is
20
20
  required because the service rejects an empty list.
21
21
 
22
+ Main-client form choices are available through `modus options config-tags`,
23
+ `modus options wa-tags`, and `modus options wa-support-addons`. These commands
24
+ read the official ModUs static client-data origin without sending the local
25
+ login token.
26
+
22
27
  ## Project writes
23
28
 
24
29
  `modus project create|edit|delete` uses versioned JSON through `--input`.
@@ -27,10 +32,43 @@ The complete project model includes `project_id`, `name`, `alt_name`, `summary`,
27
32
  `required_tier_id`, `required_dependencies`, `cf_url`, returned `logo`, `status`,
28
33
  `game`, and `description`. Write inputs use `project_state`, `publish_platforms`,
29
34
  `logo_base64`, the compatibility `screenshot_base64s`, and edit-only `image_ops`.
35
+
36
+ ### Project field contract
37
+
38
+ | CLI/state field | Creator wire/readback | JSON type | Required/default | Source and state dependency | Successful readback |
39
+ | --- | --- | --- | --- | --- | --- |
40
+ | `schema` | none | string | required by every write document | versioned CLI envelope | not sent |
41
+ | `project_id` | `projectId` | positive integer | edit/delete required; create assigned by server | outer write document, not form state | `projectId` |
42
+ | `game` | none confirmed | object or non-empty string | required; no default | `choose_game`; object needs a stable ID/key | preserved in state snapshot |
43
+ | `name` | `name` | non-empty string | required; no default | `basic_info` | `name` |
44
+ | `alt_name` | `altName` | string/null | optional; omitted preserves value | `basic_info`; edit clear uses `<null>` | `altName` |
45
+ | `summary` | `summary` | non-empty string | required; no default | `basic_info` | `summary` |
46
+ | `categories` | comma-delimited `categories` | 1-5 positive integer IDs | required; no default | service `categories` enum; `basic_info`; ID 998 requires BigFoot only | comma-delimited `categories` |
47
+ | `publish_platforms` | derived `synchronizationType` | non-empty unique enum array | required; no default | `basic_info`; values `modus`, `bigfoot`, or both | integer `1`, `2`, or `3` |
48
+ | `synchronization_type` | `synchronizationType` | integer | derived; caller value is replaced | derived from platform toggles in `basic_info` | `synchronizationType` |
49
+ | `required_tier_id` | `requiredTierId` | nullable positive integer input | omitted means no tier | service `subscription-tiers` enum; current result is `[]`; BigFoot requires no tier | omitted from the wire for no tier |
50
+ | `repo_url` | `repoUrl` | string/null | optional; omitted preserves value | `basic_info`; create empty is omitted; edit clear uses `<null>` | `repoUrl` |
51
+ | `logo_base64` | `screenshotBase64sReqs.screenshotBase64s` | base64 string | optional; default empty string | create-only `basic_info`; sent as `logo.webp` | server-managed `logo` path |
52
+ | `screenshot_base64s` | same create logo payload | string array | compatibility alias; default empty array | create-only `basic_info`; first value is used when `logo_base64` is absent | server-managed `logo` path |
53
+ | `license.type` | `license` JSON string `type` | non-empty string | required; no default | separate `license` step | parsed `license` |
54
+ | `license.holder/year/content` | fields inside `license` JSON string | non-empty strings | optional except custom content | separate `license` step; custom requires content | parsed `license` |
55
+ | `description` | `description` | string/null | edit-only; omitted preserves value | edit `basic_info`; clear uses `<null>` | `description` |
56
+ | `required_dependencies` | `requiredDependencies` | string/null | edit-only; omitted preserves value | edit `basic_info`; clear uses `<null>`; dependency query is dynamic | `requiredDependencies` |
57
+ | `images` | `images` | non-negative integer | edit-only; required with `image_ops` | edit `basic_info` | `images` count |
58
+ | `image_ops` | `imagesOps` | operation array | edit-only; no default | edit `basic_info`; upload requires base64, delete forbids it | `images` count |
59
+ | `cf_url` | `cfUrl` | string/null | read-only | known detail field may be preserved in a snapshot but is never sent | `cfUrl` |
60
+ | `logo` | `logo` | string/object/null | read-only | server-managed detail field; create uses `logo_base64` instead | `logo` |
61
+ | `status` | `status` | integer/null | read-only | server-managed detail field | `status` |
62
+ | `project_state` | none | object | required and must be `complete` | wraps `choose_game -> basic_info -> license`; unknown nested fields are rejected | local resumable snapshot |
63
+
64
+ Create-only, edit-only, and read-only fields may be preserved in a resumable
65
+ snapshot, but `_project_wire` sends only fields supported by the selected API
66
+ operation. The state machine rejects unknown `basic_info` and `license` fields
67
+ before changing state.
30
68
  `publish_platforms` is a non-empty array containing `modus`,
31
69
  `bigfoot`, or both; the two values are not mutually exclusive. Creator derives
32
70
  wire `synchronizationType` as 1 for ModUs, 2 for BigFoot, and 3 for both.
33
- When BigFoot is selected, `required_tier_id` must be null. Category ID 998 is
71
+ When BigFoot is selected, `required_tier_id` must be null or omitted. Category ID 998 is
34
72
  BigFoot-exclusive and forces BigFoot as the only platform. `categories` contains
35
73
  one to five positive integer IDs.
36
74
 
@@ -51,8 +89,9 @@ local form state, not project update keys.
51
89
  `project_state` is a resumable `choose_game` -> `basic_info` -> `license` ->
52
90
  `complete` snapshot and is required for both create and edit; incomplete or
53
91
  missing snapshots are rejected before dry-run or network access. Each step preserves
54
- its state on validation failure. `required_tier_id` is null for the explicit
55
- no-tier branch or a positive dropdown ID.
92
+ its state on validation failure. `required_tier_id` is null or omitted for the
93
+ no-tier branch and is omitted from the HTTP payload; a selected tier is a
94
+ positive dropdown ID.
56
95
  The write document also accepts `confirm` for destructive operations.
57
96
  Deletion requires `confirm: "DELETE"`.
58
97
 
@@ -64,7 +103,10 @@ Deletion requires `confirm: "DELETE"`.
64
103
  `transaction_log` optionally selects the redacted transaction record path.
65
104
  Successful records include the completed `file_id`, metadata, signature, and
66
105
  binary upload stages that ran; failed records include `failed_stage`, a redacted
67
- error, and whether the ZIP was retained. The provider parses every ZIP `.toc`
106
+ error, and whether the ZIP was retained. The transaction record is established
107
+ before file-ID allocation and ZIP preflight, so those early failures are also
108
+ durable. Binary-upload errors expose only a query-free endpoint identifier,
109
+ status when available, and a bounded redacted response summary. The provider parses every ZIP `.toc`
68
110
  `Interface` value to derive
69
111
  `toc_version` and `supported_game_versions`; explicit caller values must match.
70
112
  The provider validates the ZIP, computes `md5`, `zip_size`, and `unzip_size`,
@@ -75,3 +117,78 @@ never substituted for the binary upload. Release deletion requires
75
117
 
76
118
  All write commands support `--dry-run`, which validates the versioned JSON and
77
119
  local files without constructing an authenticated provider or making a request.
120
+
121
+ ## Main ModUs client: configuration shares
122
+
123
+ The installed main client stores its Chromium session under `%APPDATA%\modus\Local Storage\leveldb`.
124
+ Fupload reads the persisted `token`/`deviceId` pair and calls the fixed
125
+ `https://app.modus.cool/api` origin with the raw `Authorization` value and
126
+ `X-Device-Id`; values are never printed. The Creator DPAPI store remains a
127
+ separate authentication source for `project` and `plugin` commands.
128
+
129
+ The configuration surface uses:
130
+
131
+ * `GET /system/user/backup/list`, `GET /system/user/backup/detail/{id}`
132
+ * `POST /system/user/backup/update`, `DELETE /system/user/backup/delete/{id}`
133
+ * `POST /system/user/share/list`, `POST /system/user/share/detail`
134
+ * `POST /system/user/share/create`, `PUT /system/user/share/update`,
135
+ `DELETE /system/user/share/delete/{id}`
136
+
137
+ Share write fields are `share_id`, `addons_id`, `account_name`, `backup_id`,
138
+ `content`, `content_text`, `image_url`, `is_paid`, `is_public`, `price`,
139
+ `share_type`, `tags`, `title`, `exclude_wtf`, `role_name`,
140
+ `required_tier_id`, `sub_type`, optional low-level `platform` /
141
+ `synchronization_type`, and
142
+ `server_type`. `exclude_wtf` is a binary state: when it is `1`, account and
143
+ role are empty; when it is `0`, an account is required and a selected role is
144
+ optional. `sub_type=0` is the normal subscription mode; `sub_type=1` is the
145
+ season mode. Both modes allow the "none" tier represented by null or omitted
146
+ `required_tier_id`; Fupload omits `requiredTierId` from the wire in that branch.
147
+ A selected tier must be a positive ID returned by the service. The official
148
+ configuration form's submit object omits `platform` and `synchronizationType`.
149
+ Fupload's low-level API surface nevertheless exposes the backend fields: list
150
+ defaults `platform=0`, create defaults `platform=1` and
151
+ `synchronizationType=1`, and explicit low-level values are passed through.
152
+ `backup_id`, content, cover, title, and tags are required for create;
153
+ update is presence-aware and uses `share_id`. `addons_id` must be non-empty,
154
+ the trimmed title must contain at least 6 characters, tags contain 1 to 3
155
+ comma-delimited IDs, and plain `content_text` must contain more than 20
156
+ characters.
157
+
158
+ Backup rename uses `backup_id`, `backup_name`, and `server_type`; backup
159
+ deletion uses `backup_id`, `confirm`, and `server_type`.
160
+
161
+ ## Main ModUs client: strings
162
+
163
+ String articles use:
164
+
165
+ * `POST /system/user/import/list`, `POST /system/user/import/detail`
166
+ * `POST /system/user/import/create`, `POST /system/user/import/update`
167
+ * `DELETE /system/user/import/delete/{id}`
168
+ * `POST /system/user/import/version/publish`
169
+ * `DELETE /system/user/import/version/delete?versionId={id}`
170
+
171
+ String fields are `import_id`, `code_text`, `content`, `addons_id`,
172
+ `content_text`, `file_path`, `image_url`, `is_paid`, `is_public`, `price`,
173
+ `share_type`, `support_addon`, `tags`, `title`, `version`, `required_tier_id`,
174
+ `sub_type`, and `server_type`. The client requires a supported addon,
175
+ non-empty rendered/plain article content, cover, title, tags, and version.
176
+ `sub_type` and `required_tier_id` follow the same normal/season state machine.
177
+ The current account uses the no-tier branch, so `requiredTierId` is absent from
178
+ the wire. The official string form's submit object omits `platform` and
179
+ `synchronizationType`; Fupload's low-level API surface defaults list
180
+ `platform=0` and create/update `platform=1` / `synchronizationType=1`, while
181
+ preserving explicit values.
182
+ The trimmed title must contain at least 6 characters, tags contain 1 to 3
183
+ comma-delimited IDs, plain `content_text` must contain more than 20 characters,
184
+ and `file_path` is the client's fixed empty string. Version history is separate
185
+ from article metadata:
186
+ `version-publish` takes `import_id`, `version`, `code_text`, and optional
187
+ `changelog`; `version-delete` takes `version_id` and `confirm: "DELETE"`.
188
+
189
+ Both main-client modules support explicit `confirm: "DELETE"` on destructive
190
+ commands. List operations send only server fields (`pageNum`, `pageSize`,
191
+ `keyword`, status/filter values), not the local CLI envelope or `server_type`.
192
+ ### Main-client resource IDs and deletion
193
+
194
+ Configuration-share and string/import IDs are opaque decimal strings (for example, 16-digit IDs), not the numeric backup/project IDs. Fupload preserves them as strings. ModUs delete is a soft-delete: a successful `DELETE` returns code `200`, and a subsequent detail may return the record with `status=4` and `isPublic=0`; regression treats that state as deleted.
@@ -1,3 +1,3 @@
1
1
  """Fupload Python CLI."""
2
2
 
3
- __version__ = "0.0.10"
3
+ __version__ = "0.0.12"
@@ -30,11 +30,11 @@ schema exposes them. The calling Skill must show the complete plan and obtain co
30
30
  """
31
31
 
32
32
 
33
- def _modus_provider(*, authenticate: bool = True) -> Any:
33
+ def _modus_provider(*, authenticate: bool = True, main_session: bool = False) -> Any:
34
34
  # Keep ModUs optional at import time so existing platform commands remain
35
35
  # usable while the provider is installed or upgraded independently.
36
36
  from .modus import Modus
37
- return Modus(authenticate=authenticate)
37
+ return Modus(authenticate=authenticate, main_session=main_session)
38
38
 
39
39
 
40
40
  def _parser(**kwargs: Any) -> argparse.ArgumentParser:
@@ -88,6 +88,19 @@ def _read_leaf(parent: argparse._SubParsersAction, name: str, summary: str, **de
88
88
  return leaf
89
89
 
90
90
 
91
+ def _modus_server_flag(parser: argparse.ArgumentParser) -> None:
92
+ """Select the WoW Build sent as ``server`` and ``X-Server-Type``.
93
+
94
+ ``None`` deliberately means "use the main client's current Build". Build
95
+ zero is valid, so this is an integer flag rather than ``_positive``.
96
+ ``--build`` is a concise alias; both names write the same input field.
97
+ """
98
+ parser.add_argument(
99
+ "--server-type", "--build", dest="server_type", type=int, default=None,
100
+ metavar="BUILD_ID", help="WoW Build id; omitted follows the local ModUs current Build (0-4).",
101
+ )
102
+
103
+
91
104
  def _newbee_relationship_tree(parent: argparse.ArgumentParser, resource: str, label: str) -> None:
92
105
  authors = parent.add_parser("co-author", help="Search, list, or replace %s co-authors" % label).add_subparsers(dest="author_action", required=True)
93
106
  leaf = _read_leaf(authors, "search", "Search current co-author candidates.", platform="newbee", resource=resource, action="co-author-search"); leaf.add_argument("--keyword", required=True)
@@ -236,6 +249,7 @@ def _modus_tree(platforms: argparse._SubParsersAction) -> None:
236
249
  groups = root.add_subparsers(dest="resource_command", required=True)
237
250
  session = groups.add_parser("session", help="Local authentication diagnostics").add_subparsers(dest="action_command", required=True)
238
251
  _read_leaf(session, "doctor", "Check the local ModUs.Creator token store, DPAPI decryption, and authenticated API readiness without exposing credentials.", platform="modus", resource="session", action="doctor")
252
+ _read_leaf(groups, "builds", "List the fixed ModUs WoW Build ids, codes, names, and the locally selected current Build.", platform="modus", resource="builds", action="list")
239
253
 
240
254
  account = groups.add_parser("account", help="ModUs author account and statistics").add_subparsers(dest="action_command", required=True)
241
255
  _read_leaf(account, "info", "Read the current ModUs account capability flags.", platform="modus", resource="account", action="info")
@@ -245,13 +259,13 @@ def _modus_tree(platforms: argparse._SubParsersAction) -> None:
245
259
  addon = groups.add_parser("addon", help="ModUs addon discovery and history APIs").add_subparsers(dest="action_command", required=True)
246
260
  leaf = _read_leaf(addon, "info", "Resolve addon directories to project records.", platform="modus", resource="addon", action="info")
247
261
  leaf.add_argument("--directory", dest="directories", action="append", required=True)
248
- leaf.add_argument("--server-type", type=int, default=1)
262
+ _modus_server_flag(leaf)
249
263
  leaf = _read_leaf(addon, "project-info", "Resolve addon project IDs to names.", platform="modus", resource="addon", action="project-info")
250
264
  leaf.add_argument("--project-id", dest="project_ids", action="append", type=_positive, required=True)
251
- leaf.add_argument("--server-type", type=int, default=1)
265
+ _modus_server_flag(leaf)
252
266
  leaf = _read_leaf(addon, "history", "Read addon project version history.", platform="modus", resource="addon", action="history")
253
267
  leaf.add_argument("--project-id", type=_positive, required=True)
254
- leaf.add_argument("--server-type", type=int, default=1)
268
+ _modus_server_flag(leaf)
255
269
  _page_flags(leaf)
256
270
 
257
271
  options = groups.add_parser("options", help="Read ModUs dynamic choices").add_subparsers(dest="option_action", required=True)
@@ -259,6 +273,9 @@ def _modus_tree(platforms: argparse._SubParsersAction) -> None:
259
273
  game_versions = _read_leaf(options, "game-versions", "List supported ModUs game versions.", platform="modus", resource="options", action="game-versions")
260
274
  game_versions.add_argument("--key", dest="keys", action="append", required=True, help="Request one game config key; repeatable.")
261
275
  _read_leaf(options, "subscription-tiers", "List author subscription tiers returned by ModUs.", platform="modus", resource="options", action="subscription-tiers")
276
+ _read_leaf(options, "config-tags", "List configuration-share tag choices from the official ModUs client data.", platform="modus", resource="options", action="config-tags")
277
+ _read_leaf(options, "wa-tags", "List string tag choices from the official ModUs client data.", platform="modus", resource="options", action="wa-tags")
278
+ _read_leaf(options, "wa-support-addons", "List applicable-addon choices from the official ModUs client data.", platform="modus", resource="options", action="wa-support-addons")
262
279
 
263
280
  project = groups.add_parser("project", help="ModUs plugin project records").add_subparsers(dest="action_command", required=True)
264
281
  for action, text in (
@@ -282,9 +299,23 @@ def _modus_tree(platforms: argparse._SubParsersAction) -> None:
282
299
  ("delete", "Delete one explicitly confirmed ModUs plugin release."),
283
300
  ):
284
301
  _write_leaf(plugin, "modus", "plugin", action, text)
285
- leaf = _read_leaf(plugin, "list", "List releases for one ModUs plugin project.", platform="modus", resource="plugin", action="list"); leaf.add_argument("--project-id", type=_positive, required=True); _page_flags(leaf)
286
- leaf = _read_leaf(plugin, "get", "Read one ModUs plugin release detail.", platform="modus", resource="plugin", action="get"); leaf.add_argument("--project-id", type=_positive, required=True); leaf.add_argument("--file-id", type=_positive, required=True)
287
- leaf = _read_leaf(plugin, "versions", "List releases for one ModUs plugin project.", platform="modus", resource="plugin", action="versions"); leaf.add_argument("--project-id", type=_positive, required=True); _page_flags(leaf)
302
+ leaf = _read_leaf(plugin, "list", "List releases for one ModUs plugin project.", platform="modus", resource="plugin", action="list"); leaf.add_argument("--project-id", type=_positive, required=True); _modus_server_flag(leaf); _page_flags(leaf)
303
+ leaf = _read_leaf(plugin, "get", "Read one ModUs plugin release detail.", platform="modus", resource="plugin", action="get"); leaf.add_argument("--project-id", type=_positive, required=True); leaf.add_argument("--file-id", type=_positive, required=True); _modus_server_flag(leaf)
304
+ leaf = _read_leaf(plugin, "versions", "List releases for one ModUs plugin project.", platform="modus", resource="plugin", action="versions"); leaf.add_argument("--project-id", type=_positive, required=True); _modus_server_flag(leaf); _page_flags(leaf)
305
+
306
+ config = groups.add_parser("config", help="ModUs main-client configuration shares and backups").add_subparsers(dest="action_command", required=True)
307
+ for action, text in (("create", "Create a configuration share from an existing ModUs cloud backup."), ("update", "Update a configuration share."), ("edit", "Edit a configuration share."), ("delete", "Delete one explicitly confirmed configuration share."), ("backup-edit", "Rename an existing ModUs cloud backup."), ("backup-delete", "Delete one explicitly confirmed ModUs cloud backup.")):
308
+ _write_leaf(config, "modus", "config", action, text)
309
+ leaf = _read_leaf(config, "list", "List ModUs configuration shares.", platform="modus", resource="config", action="list"); _modus_server_flag(leaf); leaf.add_argument("--page-num", type=int); leaf.add_argument("--page-size", type=int); leaf.add_argument("--keyword"); leaf.add_argument("--status", type=int); leaf.add_argument("--share-type", type=int); leaf.add_argument("--order-by"); leaf.add_argument("--mine", action=argparse.BooleanOptionalAction); leaf.add_argument("--is-public", type=int, choices=(0, 1)); leaf.add_argument("--is-paid", type=int, choices=(0, 1)); leaf.add_argument("--tags")
310
+ leaf = _read_leaf(config, "get", "Read a ModUs configuration share detail.", platform="modus", resource="config", action="get"); leaf.add_argument("--share-id", required=True); _modus_server_flag(leaf)
311
+ leaf = _read_leaf(config, "backups", "List existing ModUs cloud backups.", platform="modus", resource="config", action="backups"); _modus_server_flag(leaf)
312
+ leaf = _read_leaf(config, "backup-get", "Read one ModUs cloud backup detail.", platform="modus", resource="config", action="backup-get"); leaf.add_argument("--backup-id", type=_positive, required=True); _modus_server_flag(leaf)
313
+
314
+ wa = groups.add_parser("wa", help="ModUs main-client string articles").add_subparsers(dest="action_command", required=True)
315
+ for action, text in (("create", "Create a ModUs string article."), ("update", "Update a ModUs string article."), ("edit", "Edit a ModUs string article."), ("delete", "Delete one explicitly confirmed string article."), ("version-publish", "Publish a new string article version."), ("version-delete", "Delete one explicitly confirmed historical string version.")):
316
+ _write_leaf(wa, "modus", "wa", action, text)
317
+ leaf = _read_leaf(wa, "list", "List ModUs string articles.", platform="modus", resource="wa", action="list"); _modus_server_flag(leaf); leaf.add_argument("--page-num", type=int); leaf.add_argument("--page-size", type=int); leaf.add_argument("--keyword"); leaf.add_argument("--status", type=int); leaf.add_argument("--order-by"); leaf.add_argument("--mine", action=argparse.BooleanOptionalAction); leaf.add_argument("--support-addon"); leaf.add_argument("--tags"); leaf.add_argument("--is-paid", type=int, choices=(0, 1))
318
+ leaf = _read_leaf(wa, "get", "Read one ModUs string article detail.", platform="modus", resource="wa", action="get"); leaf.add_argument("--import-id", required=True); _modus_server_flag(leaf)
288
319
 
289
320
 
290
321
  def build_parser() -> argparse.ArgumentParser:
@@ -347,7 +378,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
347
378
  if args.dry_run:
348
379
  write_output(platform, operation, _dry_run_data(doc, schema.name), dry_run=True)
349
380
  return 0
350
- provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else (CurseForge() if platform == "curseforge" else _modus_provider())))
381
+ provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else (CurseForge() if platform == "curseforge" else _modus_provider(main_session=resource in ("config", "wa")))))
351
382
  try:
352
383
  if platform == "dd":
353
384
  data = provider.execute_write(resource, action, doc, getattr(args, "session", None))
@@ -360,10 +391,15 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
360
391
  write_output(platform, operation, data)
361
392
  return 0
362
393
  modus_doctor = platform == "modus" and resource == "session" and action == "doctor"
363
- provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else (CurseForge() if platform == "curseforge" else _modus_provider(authenticate=not modus_doctor))))
394
+ provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else (CurseForge() if platform == "curseforge" else _modus_provider(authenticate=not modus_doctor, main_session=resource in ("addon", "plugin", "config", "wa", "builds")))))
364
395
  try:
365
396
  if platform == "dd":
366
397
  data = provider.execute_read(resource, action, args, getattr(args, "session", None))
398
+ elif platform == "modus" and resource == "builds" and action == "list":
399
+ # Build discovery is local and deterministic; it must not
400
+ # accidentally hit a platform endpoint or confuse platform
401
+ # selection with the Build id sent on CRUD requests.
402
+ data = provider.builds()
367
403
  else:
368
404
  data = provider.execute_read(resource, action, args)
369
405
  finally:
@@ -25,12 +25,22 @@ from typing import Any, Dict, Mapping, Optional
25
25
  from .errors import FuploadError, ValidationError, redact
26
26
  from .state_machine import COMPLETE, ProjectStateMachine
27
27
  from .modus_zip import parse_modus_zip
28
+ from .transport import json_request
28
29
 
29
30
 
30
31
  API_BASE = "https://app.modus.cool/api/"
32
+ STATIC_API_BASE = "https://cdn.modus.cool/modus/client_static_api/"
31
33
  TOKEN_PATH = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "ModUs.Creator" / "auth" / "token.dat"
32
34
  TOKEN_ENTROPY = b"ModUs.Creator.TokenStore.v1"
35
+ MODUS_APPDATA = Path(os.environ.get("APPDATA", Path.home() / "AppData/Roaming")) / "modus"
33
36
  MAX_PACKAGE_BYTES = 200 * 1024 * 1024
37
+ MODUS_BUILDS = (
38
+ {"id": 0, "code": "retail", "name": "至暗之夜", "label": "正式服-至暗之夜"},
39
+ {"id": 1, "code": "classic_era", "name": "经典旧世", "label": "怀旧服-经典旧世"},
40
+ {"id": 2, "code": "classic", "name": "熊猫人之谜", "label": "怀旧服-熊猫人之谜"},
41
+ {"id": 3, "code": "classic_titan", "name": "泰坦重铸", "label": "时光服-泰坦重铸"},
42
+ {"id": 4, "code": "anniversary", "name": "燃烧的远征", "label": "周年服-燃烧的远征"},
43
+ )
34
44
  _SECRET_KEYS = {"token", "access_token", "authorization", "cookie", "signedurl", "signed_url", "upload_url"}
35
45
  _WIRE_NAMES = {
36
46
  "project_id": "projectId", "file_id": "fileId", "alt_name": "altName",
@@ -158,6 +168,16 @@ def _positive_id(value: Any, *, field: str) -> int:
158
168
  return result
159
169
 
160
170
 
171
+ def _resource_id(value: Any, *, field: str) -> str:
172
+ """ModUs share/import IDs are opaque positive decimal strings."""
173
+ if isinstance(value, bool) or value is None:
174
+ raise ValidationError("%s must be a non-empty identifier" % field, path=field)
175
+ text = str(value).strip()
176
+ if not text or (text.isdigit() and int(text) <= 0):
177
+ raise ValidationError("%s must be a non-empty identifier" % field, path=field)
178
+ return text
179
+
180
+
161
181
  def _supported_game_versions(value: Any) -> list[Dict[str, str]]:
162
182
  """Map CLI aliases to Creator's anonymous {gameVersion, server} objects.
163
183
 
@@ -407,6 +427,94 @@ def load_token(path: Optional[Path] = None) -> str:
407
427
  return token
408
428
 
409
429
 
430
+ def load_main_session(root: Optional[Path] = None) -> Dict[str, str]:
431
+ """Recover the main ModUs renderer session from Chromium local storage.
432
+
433
+ Chromium LevelDB values are length-prefixed and may be locked by the
434
+ running client. We therefore scan shared-read bytes for JSON fragments,
435
+ accepting only the known persisted token/device keys and never returning
436
+ unrelated storage values.
437
+ """
438
+ base = root or MODUS_APPDATA
439
+ leveldb = base / "Local Storage" / "leveldb"
440
+ if not leveldb.is_dir():
441
+ raise FuploadError("ModUs main-client local storage was not found", kind="authentication_error")
442
+ token = ""
443
+ device = ""
444
+ for path in sorted(leveldb.iterdir()):
445
+ if not path.is_file() or path.name in {"LOCK", "LOG", "LOG.old", "CURRENT", "MANIFEST-000001"}:
446
+ continue
447
+ try:
448
+ with path.open("rb", buffering=0) as handle:
449
+ raw = handle.read()
450
+ except OSError:
451
+ continue
452
+ text = raw.decode("utf-8", errors="ignore")
453
+ # Pinia persistence is JSON, but the LevelDB record can contain a
454
+ # prefix/suffix. Try JSON objects first, then bounded key/value pairs.
455
+ candidates = [text]
456
+ # Persisted Pinia user JSON contains nested wallet objects, so a
457
+ # shallow-brace regex is insufficient. Extract only scalar credential
458
+ # fields from the record instead of attempting to parse the whole log.
459
+ import re
460
+ token_match = re.search(r'"(?:token|accessToken|access_token)"\s*:\s*"([^"\\]*(?:\\.[^"\\]*)*)"', text)
461
+ device_match = re.search(r'"(?:deviceId|device_id)"\s*:\s*"([^"\\]*)"', text)
462
+ if token_match and not token:
463
+ token = bytes(token_match.group(1), "utf-8").decode("unicode_escape")
464
+ if device_match and not device:
465
+ device = device_match.group(1)
466
+ for candidate in candidates:
467
+ try:
468
+ parsed = json.loads(candidate)
469
+ except (TypeError, ValueError):
470
+ continue
471
+ stack = [parsed]
472
+ while stack:
473
+ item = stack.pop()
474
+ if isinstance(item, Mapping):
475
+ for key, value in item.items():
476
+ normalized = str(key).lower()
477
+ if isinstance(value, str) and value.strip():
478
+ if normalized in {"token", "accesstoken", "access_token"} and not token:
479
+ token = value.strip()
480
+ elif normalized in {"deviceid", "device_id"} and not device:
481
+ device = value.strip()
482
+ elif isinstance(value, (Mapping, list)):
483
+ stack.append(value)
484
+ elif isinstance(item, list):
485
+ stack.extend(item)
486
+ if token and device:
487
+ break
488
+ if not token:
489
+ raise FuploadError("ModUs main-client login token was not found", kind="authentication_error")
490
+ return {"token": token, **({"device_id": device} if device else {})}
491
+
492
+
493
+ def load_current_build(root: Optional[Path] = None) -> Optional[int]:
494
+ """Read the persisted main-client currentGameWow id when available."""
495
+ base = root or MODUS_APPDATA
496
+ leveldb = base / "Local Storage" / "leveldb"
497
+ if not leveldb.is_dir():
498
+ return None
499
+ import re
500
+ for path in sorted(leveldb.iterdir()):
501
+ if not path.is_file() or path.name in {"LOCK", "LOG", "LOG.old", "CURRENT", "MANIFEST-000001"}:
502
+ continue
503
+ try:
504
+ with path.open("rb", buffering=0) as handle:
505
+ text = handle.read().decode("utf-8", errors="ignore")
506
+ except OSError:
507
+ continue
508
+ # Pinia persistence may be split across records; accept only the scalar id.
509
+ for pattern in (r'currentGameWow[^{}]{0,120}?"id"\s*:\s*(\d+)', r'"currentGameWowId"\s*:\s*(\d+)'):
510
+ match = re.search(pattern, text)
511
+ if match:
512
+ value = int(match.group(1))
513
+ if 0 <= value <= 4:
514
+ return value
515
+ return None
516
+
517
+
410
518
  def _unwrap(payload: Any) -> Any:
411
519
  if isinstance(payload, Mapping) and "data" in payload:
412
520
  return payload["data"]
@@ -423,12 +531,28 @@ class ModUs:
423
531
  base_url: str = API_BASE,
424
532
  timeout: int = 60,
425
533
  token_path: Optional[Path] = None,
534
+ device_id: Optional[str] = None,
535
+ main_session: bool = False,
426
536
  authenticate: bool = True,
427
537
  ) -> None:
428
538
  self.base_url = base_url.rstrip("/") + "/"
429
539
  self.timeout = timeout
430
540
  self.token_path = token_path or TOKEN_PATH
431
- self.token = token if token is not None else (load_token(self.token_path) if authenticate else "")
541
+ self.device_id = device_id
542
+ self.main_session = main_session
543
+ self.current_build = load_current_build() if main_session else None
544
+ self.authorization_scheme = ""
545
+ if token is not None:
546
+ self.token = token
547
+ elif authenticate:
548
+ if main_session:
549
+ session = load_main_session()
550
+ self.token, self.device_id = session["token"], session.get("device_id")
551
+ self.authorization_scheme = ""
552
+ else:
553
+ self.token = load_token(self.token_path)
554
+ else:
555
+ self.token = ""
432
556
 
433
557
  def _url(self, path: str) -> str:
434
558
  return urllib.parse.urljoin(self.base_url, path.lstrip("/"))
@@ -454,7 +578,10 @@ class ModUs:
454
578
  def _request(self, method: str, path: str, body: Any = None, *, headers: Optional[Mapping[str, str]] = None) -> Any:
455
579
  url = self._url(path)
456
580
  stage = self._request_stage(method, path)
457
- request_headers = {"Accept": "application/json", "Authorization": "Bearer " + self.token}
581
+ authorization = self.token if self.authorization_scheme == "" else self.authorization_scheme + self.token
582
+ request_headers = {"Accept": "application/json", "Authorization": authorization}
583
+ if self.device_id:
584
+ request_headers["X-Device-Id"] = self.device_id
458
585
  if headers:
459
586
  request_headers.update(headers)
460
587
  data = None
@@ -576,31 +703,57 @@ class ModUs:
576
703
  def user_info(self) -> Any:
577
704
  return _safe(_unwrap(self._request("GET", "system/user/getInfo")))
578
705
 
706
+ def builds(self) -> Any:
707
+ """Return the fixed WoW Build choices used by the ModUs main client."""
708
+ current = self.current_build
709
+ return {"current": current, "builds": [dict(item) for item in MODUS_BUILDS]}
710
+
711
+ def _build(self, value: Optional[int]) -> int:
712
+ selected = self.current_build if value is None else value
713
+ if selected is None:
714
+ # The desktop wrapper uses getCurrentWow?.id || 0.
715
+ selected = 0
716
+ selected = int(selected)
717
+ if selected < 0 or selected > 4:
718
+ raise ValidationError("server_type must be a known ModUs Build id", path="server_type")
719
+ return selected
720
+
579
721
  def active_subscription_count(self) -> Any:
580
722
  return _safe(_unwrap(self._request("GET", "user/author/subscription/active/count")))
581
723
 
582
724
  def project_statistics(self) -> Any:
583
725
  return _safe(_unwrap(self._request("GET", "game/data/author/project/statistics")))
584
726
 
585
- def addon_info(self, directories: Any, *, server_type: int = 1) -> Any:
727
+ def addon_info(self, directories: Any, *, server_type: Optional[int] = None) -> Any:
586
728
  values = directories if isinstance(directories, (list, tuple)) else [directories]
587
729
  body = {"pluginList": [str(value) for value in values]}
588
- return _safe(_unwrap(self._request("POST", "plugin/list/info", body, headers={"X-Server-Type": str(int(server_type))})))
730
+ return _safe(_unwrap(self._request("POST", "plugin/list/info", body, headers={"X-Server-Type": str(self._build(server_type))})))
589
731
 
590
- def addon_project_info(self, project_ids: Any, *, server_type: int = 1) -> Any:
732
+ def addon_project_info(self, project_ids: Any, *, server_type: Optional[int] = None) -> Any:
591
733
  values = project_ids if isinstance(project_ids, (list, tuple)) else [project_ids]
592
734
  body = {"projectIds": [int(value) for value in values]}
593
- return _safe(_unwrap(self._request("POST", "plugin/list/detail", body, headers={"X-Server-Type": str(int(server_type))})))
735
+ return _safe(_unwrap(self._request("POST", "plugin/list/detail", body, headers={"X-Server-Type": str(self._build(server_type))})))
594
736
 
595
- def addon_history(self, project_id: int, *, page_num: int = 1, page_size: int = 5, server_type: int = 1) -> Any:
737
+ def addon_history(self, project_id: int, *, page_num: int = 1, page_size: int = 5, server_type: Optional[int] = None) -> Any:
596
738
  body = {"projectIds": [int(project_id)], "pageNum": int(page_num), "pageSize": int(page_size)}
597
- return _safe(_unwrap(self._request("POST", "plugin/project/history", body, headers={"X-Server-Type": str(int(server_type))})))
739
+ return _safe(_unwrap(self._request("POST", "plugin/project/history", body, headers={"X-Server-Type": str(self._build(server_type))})))
598
740
 
599
741
  def project_dependencies(self, query: Any) -> Any:
600
742
  body = _dependency_query_wire(query)
601
743
  return _safe(_unwrap(self._request("POST", "game/data/author/project/dependency/query", body)))
602
744
 
603
745
  def options(self, action: str, *, keys: Optional[Any] = None) -> Any:
746
+ static_files = {
747
+ "config-tags": "share_tags.json",
748
+ "wa-tags": "imports_tags.json",
749
+ "wa-support-addons": "imports_support_addons.json",
750
+ }
751
+ if action in static_files:
752
+ url = urllib.parse.urljoin(STATIC_API_BASE, static_files[action])
753
+ payload = json_request(url)
754
+ if isinstance(payload, Mapping) and "rows" in payload:
755
+ payload = payload["rows"]
756
+ return _safe(_unwrap(payload))
604
757
  routes = {
605
758
  # These paths are the routes used by ModUs.Creator's ApiService.
606
759
  "categories": "plugin/list/Categories",
@@ -618,6 +771,123 @@ class ModUs:
618
771
  raise ValidationError("unsupported ModUs options operation")
619
772
  return _safe(_unwrap(self._request("GET", routes[action])))
620
773
 
774
+ # Main ModUs client: configuration shares and string articles.
775
+ def cloud_backups(self, *, server_type: Optional[int] = None) -> Any:
776
+ return _safe(_unwrap(self._request("GET", "system/user/backup/list", headers={"X-Server-Type": str(self._build(server_type))})))
777
+
778
+ def cloud_backup_detail(self, backup_id: int, *, server_type: Optional[int] = None) -> Any:
779
+ return _safe(_unwrap(self._request("GET", "system/user/backup/detail/%s" % _positive_id(backup_id, field="backup_id"), headers={"X-Server-Type": str(self._build(server_type))})))
780
+
781
+ def cloud_backup_update(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
782
+ body = {"id": _positive_id(doc["backup_id"], field="backup_id"), "backupName": doc["backup_name"]}
783
+ return _safe(_unwrap(self._request("POST", "system/user/backup/update", body, headers={"X-Server-Type": str(self._build(server_type))})))
784
+
785
+ def cloud_backup_delete(self, backup_id: int, *, server_type: Optional[int] = None) -> Any:
786
+ return _safe(_unwrap(self._request("DELETE", "system/user/backup/delete/%s" % _positive_id(backup_id, field="backup_id"), headers={"X-Server-Type": str(self._build(server_type))})))
787
+
788
+ @staticmethod
789
+ def _share_wire(doc: Mapping[str, Any]) -> Dict[str, Any]:
790
+ aliases = {"share_id": "id", "addons_id": "addonsId", "backup_id": "backupId", "account_name": "accountName", "content_text": "contentText", "image_url": "imageUrl", "is_paid": "isPaid", "is_public": "isPublic", "share_type": "shareType", "exclude_wtf": "excludeWtf", "role_name": "roleName", "required_tier_id": "requiredTierId", "sub_type": "subType", "synchronization_type": "synchronizationType"}
791
+ allowed = {"id", "addonsId", "backupId", "accountName", "content", "contentText", "imageUrl", "isPaid", "isPublic", "price", "shareType", "tags", "title", "excludeWtf", "roleName", "requiredTierId", "subType", "platform", "synchronizationType"}
792
+ result = {}
793
+ for key, value in doc.items():
794
+ wire = aliases.get(key, key)
795
+ if wire in allowed and value is not None:
796
+ result[wire] = value
797
+ result.setdefault("platform", 1)
798
+ exclude = result.get("excludeWtf", 0)
799
+ result["excludeWtf"] = 1 if str(exclude).strip().lower() in {"1", "true"} else 0
800
+ return result
801
+
802
+ def share_list(self, body: Optional[Mapping[str, Any]] = None, *, server_type: Optional[int] = None) -> Any:
803
+ source = body or {}
804
+ selected_build = self._build(source.get("server", server_type))
805
+ mine = source.get("mine")
806
+ share_type = source.get("share_type")
807
+ wire = {
808
+ "pageNum": int(source.get("page_num") or 1),
809
+ "pageSize": int(source.get("page_size") or 20),
810
+ "server": selected_build,
811
+ "mine": False if mine is None else bool(mine),
812
+ "shareType": 0 if share_type is None else share_type,
813
+ }
814
+ platform = source.get("platform")
815
+ wire["platform"] = int(platform) if isinstance(platform, (int, float)) and not isinstance(platform, bool) else 0
816
+ for key in ("keyword", "status", "tags", "order_by", "is_public", "is_paid"):
817
+ if key in source and source[key] is not None:
818
+ wire[{"order_by": "orderBy", "is_public": "isPublic", "is_paid": "isPaid"}.get(key, key)] = source[key]
819
+ return _safe(_unwrap(self._request("POST", "system/user/share/list", wire, headers={"X-Server-Type": str(selected_build)})))
820
+
821
+ def share_detail(self, share_ids: Any, *, server_type: Optional[int] = None) -> Any:
822
+ ids = share_ids if isinstance(share_ids, list) else [share_ids]
823
+ return _safe(_unwrap(self._request("POST", "system/user/share/detail", {"shareIds": [_resource_id(x, field="share_id") for x in ids]}, headers={"X-Server-Type": str(self._build(server_type))})))
824
+
825
+ def share_create(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
826
+ wire = self._share_wire(doc)
827
+ wire.setdefault("synchronizationType", 3 if wire["platform"] == 3 else 1)
828
+ return _safe(_unwrap(self._request("POST", "system/user/share/create", wire, headers={"X-Server-Type": str(self._build(server_type))})))
829
+
830
+ def share_update(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
831
+ return _safe(_unwrap(self._request("PUT", "system/user/share/update", self._share_wire(doc), headers={"X-Server-Type": str(self._build(server_type))})))
832
+
833
+ def share_delete(self, share_id: Any, *, server_type: Optional[int] = None) -> Any:
834
+ return _safe(_unwrap(self._request("DELETE", "system/user/share/delete/%s" % urllib.parse.quote(_resource_id(share_id, field="share_id"), safe=""), headers={"X-Server-Type": str(self._build(server_type))})))
835
+
836
+ @staticmethod
837
+ def _import_wire(doc: Mapping[str, Any]) -> Dict[str, Any]:
838
+ aliases = {"import_id": "id", "code_text": "codeText", "addons_id": "addonsId", "content_text": "contentText", "file_path": "filePath", "image_url": "imageUrl", "is_paid": "isPaid", "is_public": "isPublic", "share_type": "shareType", "support_addon": "supportAddon", "required_tier_id": "requiredTierId", "sub_type": "subType", "synchronization_type": "synchronizationType"}
839
+ allowed = {"id", "codeText", "content", "addonsId", "contentText", "filePath", "imageUrl", "isPaid", "isPublic", "price", "shareType", "supportAddon", "tags", "title", "version", "requiredTierId", "subType", "platform", "synchronizationType"}
840
+ result = {aliases.get(key, key): value for key, value in doc.items() if aliases.get(key, key) in allowed and value is not None}
841
+ result.setdefault("platform", 1)
842
+ result.setdefault("synchronizationType", 3 if result["platform"] == 3 else 1)
843
+ return result
844
+
845
+ def import_list(self, body: Optional[Mapping[str, Any]] = None, *, server_type: Optional[int] = None) -> Any:
846
+ source = body or {}
847
+ selected_build = self._build(source.get("server", server_type))
848
+ mine = source.get("mine")
849
+ status = source.get("status")
850
+ wire = {
851
+ "pageNum": int(source.get("page_num") or 1),
852
+ "pageSize": int(source.get("page_size") or 10),
853
+ "server": selected_build,
854
+ "mine": False if mine is None else bool(mine),
855
+ "status": 1 if status is None else status,
856
+ }
857
+ platform = source.get("platform")
858
+ wire["platform"] = int(platform) if isinstance(platform, (int, float)) and not isinstance(platform, bool) else 0
859
+ for key in ("keyword", "support_addon", "tags", "is_paid", "order_by"):
860
+ if key in source and source[key] is not None:
861
+ wire[{"support_addon": "supportAddon", "is_paid": "isPaid", "order_by": "orderBy"}.get(key, key)] = source[key]
862
+ return _safe(_unwrap(self._request("POST", "system/user/import/list", wire, headers={"X-Server-Type": str(selected_build)})))
863
+
864
+ def import_detail(self, import_ids: Any, *, server_type: Optional[int] = None) -> Any:
865
+ ids = import_ids if isinstance(import_ids, list) else [import_ids]
866
+ return _safe(_unwrap(self._request("POST", "system/user/import/detail", {"importIds": [_resource_id(x, field="import_id") for x in ids]}, headers={"X-Server-Type": str(self._build(server_type))})))
867
+
868
+ def import_create(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
869
+ return _safe(_unwrap(self._request("POST", "system/user/import/create", self._import_wire(doc), headers={"X-Server-Type": str(self._build(server_type))})))
870
+
871
+ def import_update(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
872
+ return _safe(_unwrap(self._request("POST", "system/user/import/update", self._import_wire(doc), headers={"X-Server-Type": str(self._build(server_type))})))
873
+
874
+ def import_delete(self, import_id: Any, *, server_type: Optional[int] = None) -> Any:
875
+ return _safe(_unwrap(self._request("DELETE", "system/user/import/delete/%s" % urllib.parse.quote(_resource_id(import_id, field="import_id"), safe=""), headers={"X-Server-Type": str(self._build(server_type))})))
876
+
877
+ def import_version_publish(self, doc: Mapping[str, Any], *, server_type: Optional[int] = None) -> Any:
878
+ body = {
879
+ "importId": _resource_id(doc["import_id"], field="import_id"),
880
+ "version": str(doc["version"]).strip(),
881
+ "codeText": str(doc["code_text"]).strip(),
882
+ }
883
+ changelog = str(doc.get("changelog") or "").strip()
884
+ if changelog:
885
+ body["changelog"] = changelog
886
+ return _safe(_unwrap(self._request("POST", "system/user/import/version/publish", body, headers={"X-Server-Type": str(self._build(server_type))})))
887
+
888
+ def import_version_delete(self, version_id: Any, *, server_type: Optional[int] = None) -> Any:
889
+ return _safe(_unwrap(self._request("DELETE", "system/user/import/version/delete?versionId=%s" % urllib.parse.quote(str(version_id), safe=""), headers={"X-Server-Type": str(self._build(server_type))})))
890
+
621
891
  def release_signature(self, project_id: int, file_id: int) -> str:
622
892
  result = _unwrap(self._request("GET", "game/data/author/project/file/upload/signature/%s/%s" % (_positive_id(project_id, field="project_id"), _positive_id(file_id, field="file_id"))))
623
893
  url = result.get("signedUrl") if isinstance(result, Mapping) else result
@@ -633,12 +903,21 @@ class ModUs:
633
903
  if size > MAX_PACKAGE_BYTES:
634
904
  raise ValidationError("release ZIP exceeds the 200 MB upload limit", path=file_path)
635
905
  parsed = urllib.parse.urlsplit(signed_url)
906
+ if parsed.scheme not in ("http", "https") or not parsed.hostname:
907
+ raise ValidationError("release upload URL must be HTTP(S)", path="signed_url")
908
+ # The query carries the object-store signature. Keep a useful endpoint
909
+ # in errors without ever persisting that credential material.
910
+ endpoint_host = parsed.hostname
911
+ if parsed.port is not None:
912
+ endpoint_host = "%s:%d" % (endpoint_host, parsed.port)
913
+ endpoint = urllib.parse.urlunsplit((parsed.scheme, endpoint_host, parsed.path or "/", "", ""))
636
914
  conn_cls = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
637
- if parsed.scheme == "https":
638
- conn = conn_cls(parsed.hostname, parsed.port, timeout=max(self.timeout, 600), context=ssl.create_default_context())
639
- else:
640
- conn = conn_cls(parsed.hostname, parsed.port, timeout=max(self.timeout, 600))
915
+ conn = None
641
916
  try:
917
+ if parsed.scheme == "https":
918
+ conn = conn_cls(parsed.hostname, parsed.port, timeout=max(self.timeout, 600), context=ssl.create_default_context())
919
+ else:
920
+ conn = conn_cls(parsed.hostname, parsed.port, timeout=max(self.timeout, 600))
642
921
  conn.putrequest("PUT", urllib.parse.urlunsplit(("", "", parsed.path or "/", parsed.query, "")))
643
922
  conn.putheader("Content-Type", "application/zip")
644
923
  conn.putheader("Content-Length", str(size))
@@ -650,15 +929,33 @@ class ModUs:
650
929
  break
651
930
  conn.send(chunk)
652
931
  response = conn.getresponse()
653
- response.read()
932
+ raw = response.read(4096)
654
933
  if response.status < 200 or response.status >= 300:
655
- raise FuploadError("ModUs binary upload returned HTTP %d" % response.status, http_status=response.status, verification_required=True, stage="binary_upload")
934
+ summary = redact(raw.decode("utf-8", errors="replace")) if raw else "<empty>"
935
+ raise FuploadError(
936
+ "ModUs binary upload returned HTTP %d" % response.status,
937
+ endpoint=endpoint,
938
+ http_status=response.status,
939
+ verification_required=True,
940
+ stage="binary_upload",
941
+ details={"response_summary": summary},
942
+ )
656
943
  except FuploadError:
657
944
  raise
658
- except OSError as exc:
659
- raise FuploadError("ModUs binary upload failed: %s" % exc, verification_required=True, stage="binary_upload") from exc
945
+ except (OSError, http.client.HTTPException) as exc:
946
+ raise FuploadError(
947
+ "ModUs binary upload failed",
948
+ endpoint=endpoint,
949
+ verification_required=True,
950
+ stage="binary_upload",
951
+ details={"response_summary": redact(str(exc)) or exc.__class__.__name__},
952
+ ) from exc
660
953
  finally:
661
- conn.close()
954
+ if conn is not None:
955
+ try:
956
+ conn.close()
957
+ except OSError:
958
+ pass
662
959
  return {"status": "uploaded", "archive": path.name, "bytes": size}
663
960
 
664
961
  def release_delete(self, project_id: int, file_id: int) -> Any:
@@ -666,70 +963,108 @@ class ModUs:
666
963
 
667
964
  def publish(self, doc: Mapping[str, Any], *, update: bool = False) -> Dict[str, Any]:
668
965
  project_id = _positive_id(doc["project_id"], field="project_id")
669
- allocated_file_id = not bool(doc.get("file_id"))
670
- file_id = _positive_id(doc.get("file_id") or self.release_file_id(project_id), field="file_id")
671
- metadata = {k: doc[k] for k in ("project_id", "version", "type", "supported_game_versions", "toc_version", "changelog", "path") if k in doc}
672
- metadata["file_id"] = file_id
673
966
  file_path = doc.get("file")
674
- if file_path is None and not update:
675
- raise ValidationError("release ZIP file is required", path="file")
676
967
  archive = Path(str(file_path)) if file_path is not None else None
677
- if archive is not None:
678
- if not archive.is_file():
679
- raise ValidationError("release ZIP file does not exist", path=str(file_path))
680
- size = archive.stat().st_size
681
- if size > MAX_PACKAGE_BYTES:
682
- raise ValidationError("release ZIP exceeds the 200 MB upload limit", path=str(file_path))
683
- md5 = hashlib.md5(archive.read_bytes()).hexdigest()
684
- try:
685
- unzip_size = sum(info.file_size for info in zipfile.ZipFile(str(archive)).infolist())
686
- except (OSError, zipfile.BadZipFile) as exc:
687
- raise ValidationError("release file is not a valid ZIP", path=str(file_path)) from exc
688
- derived = parse_modus_zip(archive)
689
- supplied_toc = doc.get("toc_version")
690
- supplied_games = doc.get("supported_game_versions")
691
- if supplied_toc is not None and str(supplied_toc) != derived["toc_version"]:
692
- raise ValidationError("toc_version does not match the ZIP Interface field", path="$.toc_version")
693
- if supplied_games is not None and _supported_game_versions(supplied_games) != derived["supported_game_versions"]:
694
- raise ValidationError("supported_game_versions does not match the ZIP Interface field", path="$.supported_game_versions")
695
- metadata.update({
696
- "md5": md5,
697
- "zip_size": size,
698
- "unzip_size": unzip_size,
699
- "toc_version": derived["toc_version"],
700
- "supported_game_versions": derived["supported_game_versions"],
701
- "path": doc.get("path") or archive.name,
702
- })
968
+ supplied_file_id = doc.get("file_id")
703
969
  transaction: Dict[str, Any] = {
704
970
  "schema": "fupload.v1.modus.upload-transaction",
705
971
  "created_at": int(time.time()),
706
972
  "project_id": project_id,
707
- "file_id": file_id,
973
+ "file_id": None,
708
974
  "update": bool(update),
709
975
  "archive": str(archive) if archive is not None else None,
710
- "stages": ["file_id"] if allocated_file_id else [],
976
+ "stages": [],
711
977
  }
712
978
  transaction_path = Path(str(doc.get("transaction_log") or ((str(archive) + ".modus-transaction.json") if archive else "modus-transaction.json")))
713
979
 
714
980
  def save_transaction() -> None:
715
981
  transaction_path.write_text(json.dumps(transaction, ensure_ascii=False, indent=2), encoding="utf-8")
716
982
 
983
+ # Establish the audit record before reserving a file ID or validating
984
+ # the archive so every publish failure has a durable transaction.
985
+ try:
986
+ save_transaction()
987
+ except OSError as exc:
988
+ raise FuploadError(
989
+ "ModUs upload transaction could not be written",
990
+ stage="transaction_log",
991
+ details={"path": str(transaction_path)},
992
+ ) from exc
993
+
994
+ current_stage = "prepare"
717
995
  try:
718
- transaction["stages"].append("release_metadata_update" if update else "release_metadata")
996
+ allocated_file_id = not bool(supplied_file_id)
997
+ current_stage = "file_id"
998
+ transaction["active_stage"] = current_stage
999
+ save_transaction()
1000
+ if allocated_file_id:
1001
+ transaction["stages"].append("file_id")
1002
+ file_id = _positive_id(self.release_file_id(project_id), field="file_id")
1003
+ else:
1004
+ file_id = _positive_id(supplied_file_id, field="file_id")
1005
+ transaction["file_id"] = file_id
1006
+
1007
+ metadata = {k: doc[k] for k in ("project_id", "version", "type", "supported_game_versions", "toc_version", "changelog", "path") if k in doc}
1008
+ metadata["file_id"] = file_id
1009
+ current_stage = "zip_preflight"
1010
+ transaction["active_stage"] = current_stage
1011
+ save_transaction()
1012
+ if archive is None and not update:
1013
+ raise ValidationError("release ZIP file is required", path="file")
1014
+ if archive is not None:
1015
+ if not archive.is_file():
1016
+ raise ValidationError("release ZIP file does not exist", path=str(file_path))
1017
+ size = archive.stat().st_size
1018
+ if size > MAX_PACKAGE_BYTES:
1019
+ raise ValidationError("release ZIP exceeds the 200 MB upload limit", path=str(file_path))
1020
+ md5 = hashlib.md5(archive.read_bytes()).hexdigest()
1021
+ try:
1022
+ unzip_size = sum(info.file_size for info in zipfile.ZipFile(str(archive)).infolist())
1023
+ except (OSError, zipfile.BadZipFile) as exc:
1024
+ raise ValidationError("release file is not a valid ZIP", path=str(file_path)) from exc
1025
+ derived = parse_modus_zip(archive)
1026
+ supplied_toc = doc.get("toc_version")
1027
+ supplied_games = doc.get("supported_game_versions")
1028
+ if supplied_toc is not None and str(supplied_toc) != derived["toc_version"]:
1029
+ raise ValidationError("toc_version does not match the ZIP Interface field", path="$.toc_version")
1030
+ if supplied_games is not None and _supported_game_versions(supplied_games) != derived["supported_game_versions"]:
1031
+ raise ValidationError("supported_game_versions does not match the ZIP Interface field", path="$.supported_game_versions")
1032
+ metadata.update({
1033
+ "md5": md5,
1034
+ "zip_size": size,
1035
+ "unzip_size": unzip_size,
1036
+ "toc_version": derived["toc_version"],
1037
+ "supported_game_versions": derived["supported_game_versions"],
1038
+ "path": doc.get("path") or archive.name,
1039
+ })
1040
+ current_stage = "release_metadata_update" if update else "release_metadata"
1041
+ transaction["stages"].append(current_stage)
1042
+ transaction["active_stage"] = current_stage
1043
+ save_transaction()
719
1044
  result = self.release_metadata(metadata, update=update)
720
1045
  if archive is None:
1046
+ transaction.pop("active_stage", None)
721
1047
  transaction.update({"completed": True, "upload": None})
722
1048
  save_transaction()
723
1049
  return {"project_id": project_id, "file_id": file_id, "metadata": result, "upload": None, "transaction": _safe(transaction)}
724
- transaction["stages"].append("signature")
1050
+ current_stage = "signature"
1051
+ transaction["stages"].append(current_stage)
1052
+ transaction["active_stage"] = current_stage
1053
+ save_transaction()
725
1054
  signed = self.release_signature(project_id, file_id)
726
- transaction["stages"].append("binary_upload")
1055
+ current_stage = "binary_upload"
1056
+ transaction["stages"].append(current_stage)
1057
+ transaction["active_stage"] = current_stage
1058
+ save_transaction()
727
1059
  uploaded = self.upload_zip(signed, str(archive))
1060
+ transaction.pop("active_stage", None)
728
1061
  transaction.update({"completed": True, "upload": uploaded})
729
1062
  save_transaction()
730
1063
  return {"project_id": project_id, "file_id": file_id, "metadata": result, "upload": uploaded, "transaction": _safe(transaction)}
731
1064
  except FuploadError as exc:
732
- transaction["failed_stage"] = exc.stage or transaction["stages"][-1] if transaction["stages"] else "prepare"
1065
+ if exc.stage in (None, "dependency_get"):
1066
+ exc.stage = current_stage
1067
+ transaction["failed_stage"] = exc.stage or current_stage
733
1068
  transaction["error"] = exc.as_dict()
734
1069
  transaction["retained_archive"] = bool(archive and archive.is_file())
735
1070
  try:
@@ -737,18 +1072,40 @@ class ModUs:
737
1072
  except OSError:
738
1073
  pass
739
1074
  raise
1075
+ except OSError as exc:
1076
+ wrapped = FuploadError(
1077
+ "ModUs upload preparation failed",
1078
+ stage=current_stage,
1079
+ details={"response_summary": redact(str(exc)) or exc.__class__.__name__},
1080
+ )
1081
+ transaction["failed_stage"] = current_stage
1082
+ transaction["error"] = wrapped.as_dict()
1083
+ transaction["retained_archive"] = bool(archive and archive.is_file())
1084
+ try:
1085
+ save_transaction()
1086
+ except OSError:
1087
+ pass
1088
+ raise wrapped from exc
740
1089
 
741
1090
  def execute_read(self, resource: str, action: str, args: Any = None) -> Any:
742
1091
  if resource == "session" and action == "doctor": return self.doctor()
1092
+ if resource == "options" and action == "builds": return self.builds()
1093
+ if resource == "builds" and action == "list": return self.builds()
743
1094
  doc = vars(args) if args is not None and hasattr(args, "__dict__") else (args or {})
744
1095
  if resource == "account" and action == "info": return self.user_info()
745
1096
  if resource == "account" and action == "subscription-count": return self.active_subscription_count()
746
1097
  if resource == "account" and action == "statistics": return self.project_statistics()
747
- if resource == "addon" and action == "info": return self.addon_info(doc.get("directories", []), server_type=doc.get("server_type", 1))
748
- if resource == "addon" and action == "project-info": return self.addon_project_info(doc.get("project_ids", []), server_type=doc.get("server_type", 1))
749
- if resource == "addon" and action == "history": return self.addon_history(doc["project_id"], page_num=doc.get("page_num", 1), page_size=doc.get("page_size", 5), server_type=doc.get("server_type", 1))
1098
+ if resource == "addon" and action == "info": return self.addon_info(doc.get("directories", []), server_type=doc.get("server_type"))
1099
+ if resource == "addon" and action == "project-info": return self.addon_project_info(doc.get("project_ids", []), server_type=doc.get("server_type"))
1100
+ if resource == "addon" and action == "history": return self.addon_history(doc["project_id"], page_num=doc.get("page_num", 1), page_size=doc.get("page_size", 5), server_type=doc.get("server_type"))
750
1101
  if resource == "project" and action == "dependencies": return self.project_dependencies(doc.get("query") or doc.get("project_ids") or doc.get("project_id"))
751
1102
  if resource == "options": return self.options(action, keys=doc.get("keys"))
1103
+ if resource == "config" and action == "backups": return self.cloud_backups(server_type=doc.get("server_type"))
1104
+ if resource == "config" and action == "backup-get": return self.cloud_backup_detail(doc["backup_id"], server_type=doc.get("server_type"))
1105
+ if resource == "config" and action == "list": return self.share_list(doc, server_type=doc.get("server_type"))
1106
+ if resource == "config" and action == "get": return self.share_detail(doc["share_id"], server_type=doc.get("server_type"))
1107
+ if resource == "wa" and action == "list": return self.import_list(doc, server_type=doc.get("server_type"))
1108
+ if resource == "wa" and action == "get": return self.import_detail(doc["import_id"], server_type=doc.get("server_type"))
752
1109
  if resource == "project" and action == "list": return self.project_list(**doc)
753
1110
  if resource == "project" and action in ("get", "detail"): return self.project_detail(doc["project_id"])
754
1111
  if resource in ("plugin", "release") and action in ("list", "versions"): return self.release_list(doc["project_id"], page_num=doc.get("page_num", 1), page_size=doc.get("page_size", 50))
@@ -756,6 +1113,16 @@ class ModUs:
756
1113
  raise ValidationError("unsupported ModUs read operation")
757
1114
 
758
1115
  def execute_write(self, resource: str, action: str, doc: Mapping[str, Any]) -> Any:
1116
+ if resource == "config" and action == "create": return self.share_create(doc, server_type=doc.get("server_type"))
1117
+ if resource == "config" and action in ("update", "edit"): return self.share_update(doc, server_type=doc.get("server_type"))
1118
+ if resource == "config" and action == "delete": return self.share_delete(doc["share_id"], server_type=doc.get("server_type"))
1119
+ if resource == "config" and action == "backup-edit": return self.cloud_backup_update(doc, server_type=doc.get("server_type"))
1120
+ if resource == "config" and action == "backup-delete": return self.cloud_backup_delete(doc["backup_id"], server_type=doc.get("server_type"))
1121
+ if resource == "wa" and action == "create": return self.import_create(doc, server_type=doc.get("server_type"))
1122
+ if resource == "wa" and action in ("update", "edit"): return self.import_update(doc, server_type=doc.get("server_type"))
1123
+ if resource == "wa" and action == "delete": return self.import_delete(doc["import_id"], server_type=doc.get("server_type"))
1124
+ if resource == "wa" and action == "version-publish": return self.import_version_publish(doc, server_type=doc.get("server_type"))
1125
+ if resource == "wa" and action == "version-delete": return self.import_version_delete(doc["version_id"], server_type=doc.get("server_type"))
759
1126
  if resource == "project" and action in ("create", "release"): return self.project_create(doc)
760
1127
  if resource == "project" and action in ("update", "edit"): return self.project_update(doc)
761
1128
  if resource == "project" and action == "delete": return self.project_delete(int(doc["project_id"]))
@@ -17,6 +17,7 @@ JSON_TYPES = {
17
17
  "boolean": (bool,),
18
18
  "array": (list,),
19
19
  "object": (dict,),
20
+ "identifier": (int, str),
20
21
  }
21
22
 
22
23
 
@@ -98,6 +99,21 @@ class Schema:
98
99
  return checked
99
100
 
100
101
  def _validate_conditionals(self, value: Dict[str, Any]) -> None:
102
+ if self.name.startswith("fupload.v1.modus.config"):
103
+ exclude = value.get("exclude_wtf")
104
+ if exclude == 1 and (value.get("account_name") or value.get("role_name")):
105
+ raise ValidationError("account_name and role_name must be empty when exclude_wtf=1", path="$.account_name")
106
+ if exclude == 0 and not str(value.get("account_name") or "").strip():
107
+ raise ValidationError("account_name is required when exclude_wtf=0", path="$.account_name")
108
+ if self.name.startswith(("fupload.v1.modus.config", "fupload.v1.modus.wa")):
109
+ if "title" in value and len(str(value["title"]).strip()) < 6:
110
+ raise ValidationError("must contain at least 6 characters", path="$.title")
111
+ if "content_text" in value and len(str(value["content_text"]).strip()) <= 20:
112
+ raise ValidationError("must contain more than 20 characters", path="$.content_text")
113
+ if "tags" in value:
114
+ tags = [item.strip() for item in str(value["tags"]).split(",")]
115
+ if any(not item for item in tags) or not 1 <= len(tags) <= 3:
116
+ raise ValidationError("must contain 1 to 3 comma-delimited tag IDs", path="$.tags")
101
117
  if self.name in ("fupload.v1.modus.project.create", "fupload.v1.modus.project.edit"):
102
118
  snapshot = value.get("project_state")
103
119
  if snapshot is None:
@@ -693,6 +709,31 @@ MODUS_RELEASE = {
693
709
  "file": f("string", local_file=True),
694
710
  "transaction_log": f("string", max_length=1000),
695
711
  }
712
+ MODUS_BUILD_ID = f("integer", minimum=0, maximum=4)
713
+ MODUS_SHARE = {
714
+ "share_id": f("identifier", nonempty=True), "addons_id": f("string", nonempty=True, max_length=10000),
715
+ "account_name": f("string", nullable=True, max_length=120), "backup_id": f("integer", minimum=1),
716
+ "content": f("string", nonempty=True), "content_text": f("string", nonempty=True),
717
+ "image_url": f("string", nonempty=True, max_length=1000), "is_paid": f("integer", choices=(0, 1)),
718
+ "is_public": f("integer", choices=(0, 1)), "price": f("number", minimum=0),
719
+ "share_type": f("integer", minimum=0), "tags": f("string", nonempty=True, max_length=1000),
720
+ "title": f("string", nonempty=True, max_length=120), "exclude_wtf": f("integer", choices=(0, 1)),
721
+ "role_name": f("string", nullable=True, max_length=120), "required_tier_id": f("integer", nullable=True, minimum=1),
722
+ "sub_type": f("integer", choices=(0, 1)), "platform": f("integer", choices=(1, 3)),
723
+ "synchronization_type": f("integer", choices=(1, 3)), "server_type": MODUS_BUILD_ID,
724
+ }
725
+ MODUS_IMPORT = {
726
+ "import_id": f("identifier", nonempty=True), "code_text": f("string", nonempty=True),
727
+ "content": f("string", nonempty=True), "addons_id": f("string", nonempty=True, max_length=10000),
728
+ "content_text": f("string", nonempty=True), "file_path": f("string", choices=("",)),
729
+ "image_url": f("string", nonempty=True, max_length=1000), "is_paid": f("integer", choices=(0, 1)),
730
+ "is_public": f("integer", choices=(0, 1)), "price": f("number", minimum=0),
731
+ "share_type": f("integer", minimum=0), "support_addon": f("string", nonempty=True, max_length=120),
732
+ "tags": f("string", nonempty=True, max_length=1000), "title": f("string", nonempty=True, max_length=120),
733
+ "version": f("string", nonempty=True, max_length=120), "required_tier_id": f("integer", nullable=True, minimum=1),
734
+ "sub_type": f("integer", choices=(0, 1)), "platform": f("integer", choices=(1, 3)),
735
+ "synchronization_type": f("integer", choices=(1, 3)), "server_type": MODUS_BUILD_ID,
736
+ }
696
737
  register("modus", "project", "create", required(MODUS_PROJECT_CREATE, ("project_state",)))
697
738
  register("modus", "project", "edit", required(with_id(MODUS_PROJECT_EDIT, "project_id"), ("project_id", "project_state")))
698
739
  register("modus", "project", "delete", required({"project_id": f("integer", minimum=1), "confirm": f("string", choices=("DELETE",))}, ("project_id", "confirm")))
@@ -701,7 +742,18 @@ register("modus", "plugin", "upload", required(MODUS_RELEASE, ("project_id", "fi
701
742
  register("modus", "plugin", "update", required(MODUS_RELEASE, ("project_id", "file_id")))
702
743
  register("modus", "plugin", "edit", required(MODUS_RELEASE, ("project_id", "file_id")))
703
744
  register("modus", "plugin", "delete", required({"project_id": f("integer", minimum=1), "file_id": f("integer", minimum=1), "confirm": f("string", choices=("DELETE",))}, ("project_id", "file_id", "confirm")))
704
-
745
+ register("modus", "config", "create", required(MODUS_SHARE, ("addons_id", "backup_id", "content", "content_text", "image_url", "tags", "title", "exclude_wtf")))
746
+ register("modus", "config", "update", required(MODUS_SHARE, ("share_id",)))
747
+ register("modus", "config", "edit", required(MODUS_SHARE, ("share_id",)))
748
+ register("modus", "config", "delete", required({"share_id": f("identifier", nonempty=True), "confirm": f("string", choices=("DELETE",)), "server_type": MODUS_BUILD_ID}, ("share_id", "confirm")))
749
+ register("modus", "config", "backup-edit", required({"backup_id": f("integer", minimum=1), "backup_name": f("string", nonempty=True, max_length=200), "server_type": MODUS_BUILD_ID}, ("backup_id", "backup_name")))
750
+ register("modus", "config", "backup-delete", required({"backup_id": f("integer", minimum=1), "confirm": f("string", choices=("DELETE",)), "server_type": MODUS_BUILD_ID}, ("backup_id", "confirm")))
751
+ register("modus", "wa", "create", required(MODUS_IMPORT, ("code_text", "content", "addons_id", "content_text", "image_url", "support_addon", "tags", "title", "version")))
752
+ register("modus", "wa", "update", required(MODUS_IMPORT, ("import_id",)))
753
+ register("modus", "wa", "edit", required(MODUS_IMPORT, ("import_id",)))
754
+ register("modus", "wa", "delete", required({"import_id": f("identifier", nonempty=True), "confirm": f("string", choices=("DELETE",)), "server_type": MODUS_BUILD_ID}, ("import_id", "confirm")))
755
+ register("modus", "wa", "version-publish", required({"import_id": f("identifier", nonempty=True), "version": f("string", nonempty=True, max_length=120), "code_text": f("string", nonempty=True), "changelog": f("string", max_length=10000), "server_type": MODUS_BUILD_ID}, ("import_id", "version", "code_text")))
756
+ register("modus", "wa", "version-delete", required({"version_id": f("identifier", nonempty=True), "confirm": f("string", choices=("DELETE",)), "server_type": MODUS_BUILD_ID}, ("version_id", "confirm")))
705
757
  register("blackbox", "plugin", "edit", required({
706
758
  "id": f("integer"), "name": f("string"), "logo_url": f("string"), "category_ids": f("array"),
707
759
  "type": f("integer", choices=(1, 9)), "desc": f("string"), "official": f("string"),
@@ -41,6 +41,17 @@ COMPLETE = ProjectStep.COMPLETE.value
41
41
  _SCHEMA = "fupload.v1.modus.project-state"
42
42
  _PLATFORMS = ("modus", "bigfoot")
43
43
  _BIGFOOT_EXCLUSIVE_CATEGORY_ID = 998
44
+ _BASIC_INFO_KEYS = {
45
+ # Shared create/edit form fields.
46
+ "schema", "name", "project_name", "alt_name", "summary", "categories",
47
+ "synchronization_type", "publish_platforms", "publishPlatforms",
48
+ "required_tier_id", "requiredTierId", "repo_url",
49
+ # Create-only image fields and edit-only detail/image fields.
50
+ "logo_base64", "screenshot_base64s", "description",
51
+ "required_dependencies", "images", "image_ops",
52
+ # Known project-detail readback fields preserved in resumable snapshots.
53
+ "cf_url", "logo", "status",
54
+ }
44
55
  _LICENSE_KEYS = {
45
56
  "type", "template", "license_template", "licenseTemplate",
46
57
  "holder", "copyright_holder", "copyrightHolder",
@@ -49,6 +60,15 @@ _LICENSE_KEYS = {
49
60
  }
50
61
 
51
62
 
63
+ def _basic_info_fields(value: Mapping[str, Any]) -> None:
64
+ unknown = sorted(set(value) - _BASIC_INFO_KEYS)
65
+ if unknown:
66
+ raise ValidationError(
67
+ "unknown basic_info field(s): %s" % ", ".join(unknown),
68
+ path="$.basic_info.%s" % unknown[0],
69
+ )
70
+
71
+
52
72
  def _nonempty_text(value: Any, *, path: str) -> str:
53
73
  if not isinstance(value, str) or not value.strip():
54
74
  raise ValidationError("value must be a non-empty string", path=path)
@@ -194,6 +214,7 @@ class ProjectStateMachine:
194
214
  self._require(ProjectStep.BASIC_INFO)
195
215
  value: Dict[str, Any] = dict(info or {})
196
216
  value.update(fields)
217
+ _basic_info_fields(value)
197
218
  name = value.get("name", value.get("project_name"))
198
219
  summary = value.get("summary")
199
220
  _nonempty_text(name, path="$.basic_info.name")
@@ -288,26 +309,33 @@ class ProjectStateMachine:
288
309
  game = snapshot.get("game")
289
310
  basic = snapshot.get("basic_info") or {}
290
311
  lic = snapshot.get("license") or {}
312
+ restored_game: Any = None
313
+ restored_basic: Dict[str, Any] = {}
314
+ restored_license: Dict[str, Any] = {}
291
315
  if state != CHOOSE_GAME:
292
- self._game = _game(game)
316
+ restored_game = _game(game)
293
317
  if state in {LICENSE, COMPLETE}:
294
318
  if not isinstance(basic, Mapping):
295
319
  raise ValidationError("basic_info must be an object", path="$.basic_info")
296
- self._basic_info = dict(basic)
297
- _nonempty_text(self._basic_info.get("name"), path="$.basic_info.name")
298
- _nonempty_text(self._basic_info.get("summary"), path="$.basic_info.summary")
299
- self._basic_info["publish_platforms"] = _platforms(self._basic_info.get("publish_platforms"))
300
- self._basic_info["categories"] = _categories(self._basic_info.get("categories"))
301
- self._basic_info["required_tier_id"] = _required_tier(self._basic_info.get("required_tier_id"))
302
- if _BIGFOOT_EXCLUSIVE_CATEGORY_ID in self._basic_info["categories"] and self._basic_info["publish_platforms"] != ["bigfoot"]:
320
+ restored_basic = dict(basic)
321
+ _basic_info_fields(restored_basic)
322
+ _nonempty_text(restored_basic.get("name"), path="$.basic_info.name")
323
+ _nonempty_text(restored_basic.get("summary"), path="$.basic_info.summary")
324
+ restored_basic["publish_platforms"] = _platforms(restored_basic.get("publish_platforms"))
325
+ restored_basic["categories"] = _categories(restored_basic.get("categories"))
326
+ restored_basic["required_tier_id"] = _required_tier(restored_basic.get("required_tier_id"))
327
+ if _BIGFOOT_EXCLUSIVE_CATEGORY_ID in restored_basic["categories"] and restored_basic["publish_platforms"] != ["bigfoot"]:
303
328
  raise ValidationError("category 998 requires bigfoot as the only publish platform", path="$.basic_info.publish_platforms")
304
- if "bigfoot" in self._basic_info["publish_platforms"] and self._basic_info["required_tier_id"] is not None:
329
+ if "bigfoot" in restored_basic["publish_platforms"] and restored_basic["required_tier_id"] is not None:
305
330
  raise ValidationError("required_tier_id must be null when bigfoot is selected", path="$.basic_info.required_tier_id")
306
- derived_sync_type = (1 if "modus" in self._basic_info["publish_platforms"] else 0) | (2 if "bigfoot" in self._basic_info["publish_platforms"] else 0)
307
- if self._basic_info.get("synchronization_type") != derived_sync_type:
331
+ derived_sync_type = (1 if "modus" in restored_basic["publish_platforms"] else 0) | (2 if "bigfoot" in restored_basic["publish_platforms"] else 0)
332
+ if restored_basic.get("synchronization_type") != derived_sync_type:
308
333
  raise ValidationError("synchronization_type must match publish_platforms", path="$.basic_info.synchronization_type")
309
334
  if state == COMPLETE:
310
- self._license = _license(lic)
335
+ restored_license = _license(lic)
336
+ self._game = restored_game
337
+ self._basic_info = restored_basic
338
+ self._license = restored_license
311
339
  self._step = str(state)
312
340
 
313
341
  def save(self, path: Union[str, os.PathLike[str]]) -> Path:
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "schema": "fupload.npm-skill-manifest.v1",
3
3
  "package_name": "@follenfang/fupload",
4
- "package_version": "0.0.10",
5
- "skill_version": "0.0.10",
6
- "tree_sha256": "38621aa4709de68a1cc298c8183fe0a5647c8291b870defdbbb5f54d184fa94a",
4
+ "package_version": "0.0.12",
5
+ "skill_version": "0.0.12",
6
+ "tree_sha256": "f1e559aef665537c4b2b24f15dab01b9e4140378bd3a70a00331d56358d2071a",
7
7
  "files": [
8
8
  {
9
9
  "path": "agents/openai.yaml",
@@ -92,8 +92,8 @@
92
92
  },
93
93
  {
94
94
  "path": "references/modus.md",
95
- "bytes": 4492,
96
- "sha256": "0b9cf6c4b3bc7aa37a3649ec6ccb6c8b8aa3d077c8febc2635548546150df356"
95
+ "bytes": 13492,
96
+ "sha256": "919b8b097fd1ec14d38583e84f4d33a5320d24af03703dc1c06a072dbab33e68"
97
97
  },
98
98
  {
99
99
  "path": "references/newbee-official-cli.md",
@@ -113,7 +113,7 @@
113
113
  {
114
114
  "path": "scripts/fupload_cli/__init__.py",
115
115
  "bytes": 50,
116
- "sha256": "3f1afb95137eaedc622bb437c9c8a01bdaa729b911f3749e400a429d24abaaa2"
116
+ "sha256": "cad1ed5cd2ed3020e1fc98c2036d22b47e1138772e9829f5dd61c39cc3bd810f"
117
117
  },
118
118
  {
119
119
  "path": "scripts/fupload_cli/blackbox_web.py",
@@ -127,8 +127,8 @@
127
127
  },
128
128
  {
129
129
  "path": "scripts/fupload_cli/cli.py",
130
- "bytes": 31958,
131
- "sha256": "897c2e7a7fea00125023d7fc977aaa6e3b7729b50a468e7deadfacc04d89f9b8"
130
+ "bytes": 36811,
131
+ "sha256": "bdd159c7d86c009685417e7b4a25194d98a49547e036606b920ff44090b1619e"
132
132
  },
133
133
  {
134
134
  "path": "scripts/fupload_cli/curseforge.py",
@@ -167,8 +167,8 @@
167
167
  },
168
168
  {
169
169
  "path": "scripts/fupload_cli/modus.py",
170
- "bytes": 38821,
171
- "sha256": "f5edb6898ce515a7e34f0fcab3dc022c2c5185f548644eabf5291e38b22dae22"
170
+ "bytes": 60792,
171
+ "sha256": "9cf3d8127e2b7583f1760a74f88512b81ab272ea116bf9ca185c7531eba960d2"
172
172
  },
173
173
  {
174
174
  "path": "scripts/fupload_cli/newbee_auth.py",
@@ -182,13 +182,13 @@
182
182
  },
183
183
  {
184
184
  "path": "scripts/fupload_cli/schema.py",
185
- "bytes": 46291,
186
- "sha256": "025d6f4af1e8adda87aed14a075028c370e78bf0def6738ab303e2f1798aefd8"
185
+ "bytes": 51541,
186
+ "sha256": "aca30dc7997f47645b3da154fa3656fefa1179733e1d61e7ff0de326734f2575"
187
187
  },
188
188
  {
189
189
  "path": "scripts/fupload_cli/state_machine.py",
190
- "bytes": 15325,
191
- "sha256": "02f37b745bbb70860c09563b6778014bd1c69ca06ba28777415ad7ce84d02610"
190
+ "bytes": 16450,
191
+ "sha256": "da26b63be4e085b9b39aea4d13a2c58206836d98fa46400b243113ce4b1ff4a9"
192
192
  },
193
193
  {
194
194
  "path": "scripts/fupload_cli/transport.py",
@@ -208,7 +208,7 @@
208
208
  {
209
209
  "path": "SKILL.md",
210
210
  "bytes": 25720,
211
- "sha256": "2e20b91ded57f4d91dbb658abf6fedc636785cc1b3ada23f985c9241c53cd3e6"
211
+ "sha256": "fe14cba8f02167dce36034ce8b73784793c92ed3de37dda1ab982a5a8a9cc8de"
212
212
  }
213
213
  ]
214
214
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@follenfang/fupload",
3
- "version": "0.0.10",
3
+ "version": "0.0.12",
4
4
  "description": "Install and run the Fuploader Agent Skill and Python CLI.",
5
5
  "type": "module",
6
6
  "bin": {