@follenfang/fupload 0.0.9 → 0.0.11

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.
@@ -0,0 +1,374 @@
1
+ """ModUs Creator project form state machine.
2
+
3
+ The desktop Creator exposes three form tabs in order: choose a game, enter
4
+ general project information, and select/build a license. This module keeps
5
+ that sequencing explicit for CLI callers and provides a small JSON-safe
6
+ snapshot that can be resumed after an interrupted form submission.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import copy
12
+ import json
13
+ import os
14
+ import tempfile
15
+ from enum import Enum
16
+ from pathlib import Path
17
+ from typing import Any, Dict, Mapping, Optional, Union
18
+
19
+ from .errors import ValidationError
20
+
21
+
22
+ class ProjectStep(str, Enum):
23
+ """Persisted steps exposed by the Creator form."""
24
+
25
+ CHOOSE_GAME = "choose_game"
26
+ SELECT_GAME = "choose_game"
27
+ BASIC_INFO = "basic_info"
28
+ GENERAL = "basic_info"
29
+ LICENSE = "license"
30
+ COMPLETE = "complete"
31
+
32
+
33
+ # String aliases make the state machine convenient for JSON/CLI consumers.
34
+ CHOOSE_GAME = ProjectStep.CHOOSE_GAME.value
35
+ SELECT_GAME = CHOOSE_GAME
36
+ BASIC_INFO = ProjectStep.BASIC_INFO.value
37
+ GENERAL = BASIC_INFO
38
+ LICENSE = ProjectStep.LICENSE.value
39
+ COMPLETE = ProjectStep.COMPLETE.value
40
+
41
+ _SCHEMA = "fupload.v1.modus.project-state"
42
+ _PLATFORMS = ("modus", "bigfoot")
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
+ }
55
+ _LICENSE_KEYS = {
56
+ "type", "template", "license_template", "licenseTemplate",
57
+ "holder", "copyright_holder", "copyrightHolder",
58
+ "year", "copyright_year", "copyrightYear",
59
+ "content", "license_content", "licenseContent",
60
+ }
61
+
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
+
72
+ def _nonempty_text(value: Any, *, path: str) -> str:
73
+ if not isinstance(value, str) or not value.strip():
74
+ raise ValidationError("value must be a non-empty string", path=path)
75
+ return value.strip()
76
+
77
+
78
+ def _required_tier(value: Any, *, path: str = "$.required_tier_id") -> Optional[int]:
79
+ """Validate Creator's optional subscription-tier selection.
80
+
81
+ ``None`` is the explicit "no required tier" branch. The API contract
82
+ uses a positive integer for a selected tier; booleans and numeric strings
83
+ are rejected to avoid silently selecting the wrong dropdown item.
84
+ """
85
+ if value is None:
86
+ return None
87
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
88
+ raise ValidationError("required_tier_id must be null or a positive integer", path=path)
89
+ return value
90
+
91
+
92
+ def _platforms(value: Any, *, path: str = "$.publish_platforms") -> list[str]:
93
+ if not isinstance(value, (list, tuple)) or not value:
94
+ raise ValidationError("publish_platforms must contain at least one platform", path=path)
95
+ result: list[str] = []
96
+ for index, item in enumerate(value):
97
+ if not isinstance(item, str) or item not in _PLATFORMS:
98
+ raise ValidationError("platform must be modus or bigfoot", path=f"{path}[{index}]")
99
+ if item in result:
100
+ raise ValidationError("publish_platforms must not contain duplicates", path=path)
101
+ result.append(item)
102
+ return result
103
+
104
+
105
+ def _categories(value: Any, *, path: str = "$.categories") -> list[int]:
106
+ if not isinstance(value, list) or not value:
107
+ raise ValidationError("categories must contain at least one category ID", path=path)
108
+ if len(value) > 5:
109
+ raise ValidationError("categories must contain at most five category IDs", path=path)
110
+ result: list[int] = []
111
+ for index, item in enumerate(value):
112
+ if isinstance(item, bool) or not isinstance(item, int) or item <= 0:
113
+ raise ValidationError("category ID must be a positive integer", path=f"{path}[{index}]")
114
+ if item in result:
115
+ raise ValidationError("categories must not contain duplicates", path=path)
116
+ result.append(item)
117
+ return result
118
+
119
+
120
+ def _game(value: Any) -> Any:
121
+ """Require a game object/value that can be serialized and read back."""
122
+ if isinstance(value, Mapping):
123
+ if not value:
124
+ raise ValidationError("game selection must not be empty", path="$.game")
125
+ # Creator game entries have an ID/key and a display name. Preserve
126
+ # unknown fields for forward compatibility, but require one stable
127
+ # identity field so a resumed snapshot remains addressable.
128
+ identity = ("id", "gameId", "game_id", "key", "gameVersion", "game_version", "server")
129
+ if not any(value.get(name) not in (None, "") for name in identity):
130
+ raise ValidationError("game selection must include an identifier", path="$.game")
131
+ return copy.deepcopy(dict(value))
132
+ if isinstance(value, str) and value.strip():
133
+ return value.strip()
134
+ raise ValidationError("game selection must be a non-empty object or string", path="$.game")
135
+
136
+
137
+ def _license(value: Any) -> Dict[str, Any]:
138
+ if isinstance(value, str):
139
+ value = {"type": value}
140
+ if not isinstance(value, Mapping):
141
+ raise ValidationError("license must be a non-empty object or string", path="$.license")
142
+ unknown = sorted(set(value) - _LICENSE_KEYS)
143
+ if unknown:
144
+ raise ValidationError("unknown license field(s): %s" % ", ".join(unknown), path="$.license.%s" % unknown[0])
145
+ aliases = {
146
+ "type": ("type", "template", "license_template", "licenseTemplate"),
147
+ "holder": ("holder", "copyright_holder", "copyrightHolder"),
148
+ "year": ("year", "copyright_year", "copyrightYear"),
149
+ "content": ("content", "license_content", "licenseContent"),
150
+ }
151
+ result: Dict[str, Any] = {}
152
+ for target, names in aliases.items():
153
+ for name in names:
154
+ if name in value and value[name] is not None:
155
+ result[target] = value[name]
156
+ break
157
+ result["type"] = _nonempty_text(result.get("type"), path="$.license.type")
158
+ for name in ("holder", "year", "content"):
159
+ if name in result and result[name] is not None:
160
+ result[name] = _nonempty_text(result[name], path="$.license.%s" % name)
161
+ if result["type"].lower() in {"custom", "自定义"} and not result.get("content"):
162
+ raise ValidationError("custom license content must not be empty", path="$.license.content")
163
+ return result
164
+
165
+
166
+ class ProjectStateMachine:
167
+ """Sequenced, resumable ModUs project creation/edit form."""
168
+
169
+ schema = _SCHEMA
170
+
171
+ def __init__(self, snapshot: Optional[Mapping[str, Any]] = None) -> None:
172
+ self._step = ProjectStep.CHOOSE_GAME.value
173
+ self._game: Any = None
174
+ self._basic_info: Dict[str, Any] = {}
175
+ self._license: Dict[str, Any] = {}
176
+ if snapshot is not None:
177
+ self._restore(snapshot)
178
+
179
+ @property
180
+ def state(self) -> str:
181
+ return self._step
182
+
183
+ @property
184
+ def current_step(self) -> str:
185
+ return self._step
186
+
187
+ @property
188
+ def game(self) -> Any:
189
+ return copy.deepcopy(self._game)
190
+
191
+ @property
192
+ def basic_info(self) -> Dict[str, Any]:
193
+ return copy.deepcopy(self._basic_info)
194
+
195
+ @property
196
+ def license(self) -> Dict[str, Any]:
197
+ return copy.deepcopy(self._license)
198
+
199
+ def _require(self, expected: ProjectStep) -> None:
200
+ if self._step != expected.value:
201
+ raise ValidationError(
202
+ "project state %s cannot submit step %s"
203
+ % (self._step, expected.value),
204
+ path="$.state",
205
+ )
206
+
207
+ def select_game(self, game: Any) -> Dict[str, Any]:
208
+ self._require(ProjectStep.CHOOSE_GAME)
209
+ self._game = _game(game)
210
+ self._step = ProjectStep.BASIC_INFO.value
211
+ return self.snapshot()
212
+
213
+ def submit_basic_info(self, info: Optional[Mapping[str, Any]] = None, **fields: Any) -> Dict[str, Any]:
214
+ self._require(ProjectStep.BASIC_INFO)
215
+ value: Dict[str, Any] = dict(info or {})
216
+ value.update(fields)
217
+ _basic_info_fields(value)
218
+ name = value.get("name", value.get("project_name"))
219
+ summary = value.get("summary")
220
+ _nonempty_text(name, path="$.basic_info.name")
221
+ _nonempty_text(summary, path="$.basic_info.summary")
222
+ selected = value.get("publish_platforms", value.get("publishPlatforms"))
223
+ value["publish_platforms"] = _platforms(selected, path="$.basic_info.publish_platforms")
224
+ value.pop("publishPlatforms", None)
225
+ value["categories"] = _categories(value.get("categories"), path="$.basic_info.categories")
226
+ if _BIGFOOT_EXCLUSIVE_CATEGORY_ID in value["categories"] and value["publish_platforms"] != ["bigfoot"]:
227
+ raise ValidationError(
228
+ "category 998 requires bigfoot as the only publish platform",
229
+ path="$.basic_info.publish_platforms",
230
+ )
231
+ if "required_tier_id" in value:
232
+ value["required_tier_id"] = _required_tier(value["required_tier_id"], path="$.basic_info.required_tier_id")
233
+ elif "requiredTierId" in value:
234
+ value["required_tier_id"] = _required_tier(value.pop("requiredTierId"), path="$.basic_info.requiredTierId")
235
+ else:
236
+ # Explicitly persist the no-tier branch so reloads are stable.
237
+ value["required_tier_id"] = None
238
+ if "bigfoot" in value["publish_platforms"] and value["required_tier_id"] is not None:
239
+ raise ValidationError(
240
+ "required_tier_id must be null when bigfoot is selected",
241
+ path="$.basic_info.required_tier_id",
242
+ )
243
+ derived_sync_type = (1 if "modus" in value["publish_platforms"] else 0) | (
244
+ 2 if "bigfoot" in value["publish_platforms"] else 0
245
+ )
246
+ # Creator exposes platform toggles, not synchronizationType itself.
247
+ # Always replace a stale caller value with the UI-derived bit mask.
248
+ value["synchronization_type"] = derived_sync_type
249
+ if "project_name" in value:
250
+ value["name"] = value.pop("project_name")
251
+ self._basic_info = copy.deepcopy(value)
252
+ self._step = ProjectStep.LICENSE.value
253
+ return self.snapshot()
254
+
255
+ def submit_license(self, license_value: Any = None, **fields: Any) -> Dict[str, Any]:
256
+ self._require(ProjectStep.LICENSE)
257
+ value: Any = license_value
258
+ if value is None and fields:
259
+ value = fields
260
+ elif fields:
261
+ if not isinstance(value, Mapping):
262
+ raise ValidationError("license fields require an object", path="$.license")
263
+ merged = dict(value)
264
+ merged.update(fields)
265
+ value = merged
266
+ self._license = _license(value)
267
+ self._step = ProjectStep.COMPLETE.value
268
+ return self.snapshot()
269
+
270
+ # Friendly aliases used by form adapters that name the first tab
271
+ # "select game" and the second tab "general".
272
+ choose_game = select_game
273
+ set_game = select_game
274
+ set_basic_info = submit_basic_info
275
+ set_license = submit_license
276
+
277
+ def submit(self, step: str, payload: Any = None, **fields: Any) -> Dict[str, Any]:
278
+ """Dispatch a named step for generic CLI adapters."""
279
+ normalized = str(step).strip().lower().replace("-", "_")
280
+ if normalized in {"choose_game", "game"}:
281
+ return self.select_game(payload)
282
+ if normalized in {"basic_info", "general", "basic"}:
283
+ return self.submit_basic_info(payload, **fields)
284
+ if normalized == "license":
285
+ return self.submit_license(payload, **fields)
286
+ raise ValidationError("unsupported project state step", path="$.state")
287
+
288
+ def snapshot(self) -> Dict[str, Any]:
289
+ return {
290
+ "schema": self.schema,
291
+ "state": self._step,
292
+ "game": copy.deepcopy(self._game),
293
+ "basic_info": copy.deepcopy(self._basic_info),
294
+ "license": copy.deepcopy(self._license),
295
+ }
296
+
297
+ to_dict = snapshot
298
+
299
+ @classmethod
300
+ def from_snapshot(cls, snapshot: Mapping[str, Any]) -> "ProjectStateMachine":
301
+ return cls(snapshot)
302
+
303
+ def _restore(self, snapshot: Mapping[str, Any]) -> None:
304
+ if not isinstance(snapshot, Mapping) or snapshot.get("schema") != self.schema:
305
+ raise ValidationError("invalid project state snapshot", path="$.schema")
306
+ state = snapshot.get("state")
307
+ if state not in {item.value for item in ProjectStep}:
308
+ raise ValidationError("invalid project state", path="$.state")
309
+ game = snapshot.get("game")
310
+ basic = snapshot.get("basic_info") or {}
311
+ lic = snapshot.get("license") or {}
312
+ restored_game: Any = None
313
+ restored_basic: Dict[str, Any] = {}
314
+ restored_license: Dict[str, Any] = {}
315
+ if state != CHOOSE_GAME:
316
+ restored_game = _game(game)
317
+ if state in {LICENSE, COMPLETE}:
318
+ if not isinstance(basic, Mapping):
319
+ raise ValidationError("basic_info must be an object", path="$.basic_info")
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"]:
328
+ raise ValidationError("category 998 requires bigfoot as the only publish platform", path="$.basic_info.publish_platforms")
329
+ if "bigfoot" in restored_basic["publish_platforms"] and restored_basic["required_tier_id"] is not None:
330
+ raise ValidationError("required_tier_id must be null when bigfoot is selected", path="$.basic_info.required_tier_id")
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:
333
+ raise ValidationError("synchronization_type must match publish_platforms", path="$.basic_info.synchronization_type")
334
+ if state == COMPLETE:
335
+ restored_license = _license(lic)
336
+ self._game = restored_game
337
+ self._basic_info = restored_basic
338
+ self._license = restored_license
339
+ self._step = str(state)
340
+
341
+ def save(self, path: Union[str, os.PathLike[str]]) -> Path:
342
+ target = Path(path)
343
+ target.parent.mkdir(parents=True, exist_ok=True)
344
+ fd, temporary = tempfile.mkstemp(prefix=target.name + ".", suffix=".tmp", dir=str(target.parent))
345
+ try:
346
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
347
+ json.dump(self.snapshot(), handle, ensure_ascii=False, indent=2)
348
+ handle.write("\n")
349
+ handle.flush()
350
+ os.fsync(handle.fileno())
351
+ os.replace(temporary, target)
352
+ finally:
353
+ if os.path.exists(temporary):
354
+ os.unlink(temporary)
355
+ return target
356
+
357
+ @classmethod
358
+ def load(cls, path: Union[str, os.PathLike[str]]) -> "ProjectStateMachine":
359
+ target = Path(path)
360
+ try:
361
+ with target.open("r", encoding="utf-8") as handle:
362
+ snapshot = json.load(handle)
363
+ except (OSError, ValueError) as exc:
364
+ raise ValidationError("project state snapshot could not be read", path=str(target)) from exc
365
+ return cls.from_snapshot(snapshot)
366
+
367
+ save_state = save
368
+ load_state = load
369
+
370
+
371
+ __all__ = [
372
+ "ProjectStep", "ProjectStateMachine", "CHOOSE_GAME", "SELECT_GAME",
373
+ "BASIC_INFO", "GENERAL", "LICENSE", "COMPLETE",
374
+ ]
@@ -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.9",
5
- "skill_version": "0.0.9",
6
- "tree_sha256": "515a7cdff306e466484f7974521025ca9072a3869cc021207a0e692dae977676",
4
+ "package_version": "0.0.11",
5
+ "skill_version": "0.0.11",
6
+ "tree_sha256": "a0d589cb5d67c7bfbd34862ee4c3109e88068662ae3b7430c0635e71ace9a8d9",
7
7
  "files": [
8
8
  {
9
9
  "path": "agents/openai.yaml",
@@ -90,6 +90,11 @@
90
90
  "bytes": 16543,
91
91
  "sha256": "782d7c89d865df545fb6ffe4d7ee4492e6e5b192a3f71e4aef6424183a6a4f24"
92
92
  },
93
+ {
94
+ "path": "references/modus.md",
95
+ "bytes": 8705,
96
+ "sha256": "10c2db10ade101c6738d9007865db68da54892da59fc1cc0fc1a422296a4bf07"
97
+ },
93
98
  {
94
99
  "path": "references/newbee-official-cli.md",
95
100
  "bytes": 24919,
@@ -107,8 +112,8 @@
107
112
  },
108
113
  {
109
114
  "path": "scripts/fupload_cli/__init__.py",
110
- "bytes": 49,
111
- "sha256": "7762ec22ff37c14cc8942b1f136910050aea07e4e6904f98b3ab8434e6b89800"
115
+ "bytes": 50,
116
+ "sha256": "c7d5308bddd21b0e8042689cdeaba91ba45170a998f42844d42f88b7248fc057"
112
117
  },
113
118
  {
114
119
  "path": "scripts/fupload_cli/blackbox_web.py",
@@ -122,8 +127,8 @@
122
127
  },
123
128
  {
124
129
  "path": "scripts/fupload_cli/cli.py",
125
- "bytes": 25982,
126
- "sha256": "79c5de939e3a7e7d0ed49d79fcce6c22e78c1a5abcf8eae5422dbd78f7c7bf2d"
130
+ "bytes": 31958,
131
+ "sha256": "897c2e7a7fea00125023d7fc977aaa6e3b7729b50a468e7deadfacc04d89f9b8"
127
132
  },
128
133
  {
129
134
  "path": "scripts/fupload_cli/curseforge.py",
@@ -152,8 +157,18 @@
152
157
  },
153
158
  {
154
159
  "path": "scripts/fupload_cli/io.py",
155
- "bytes": 4353,
156
- "sha256": "b63791254e4f63787d1380c86553ee18308069986df8e2558e6df5a9244d6602"
160
+ "bytes": 4515,
161
+ "sha256": "dafcfcaa493def7bc42719f357416208a3cabbb163cf651fb18307b501b46e4b"
162
+ },
163
+ {
164
+ "path": "scripts/fupload_cli/modus_zip.py",
165
+ "bytes": 7833,
166
+ "sha256": "3b35b30266dd48874f679b40d12739534ede3dc58f0c34cdd35ac2deca4e07db"
167
+ },
168
+ {
169
+ "path": "scripts/fupload_cli/modus.py",
170
+ "bytes": 42241,
171
+ "sha256": "ee298b064f4b3c4a25081f76e93d48b18f1b810f647437e78d78a4a20ab5bd5c"
157
172
  },
158
173
  {
159
174
  "path": "scripts/fupload_cli/newbee_auth.py",
@@ -167,8 +182,13 @@
167
182
  },
168
183
  {
169
184
  "path": "scripts/fupload_cli/schema.py",
170
- "bytes": 37399,
171
- "sha256": "5538eaf099bf9e7cae5f8142faecf827d41da698ae574b5597d17756da23e331"
185
+ "bytes": 46291,
186
+ "sha256": "025d6f4af1e8adda87aed14a075028c370e78bf0def6738ab303e2f1798aefd8"
187
+ },
188
+ {
189
+ "path": "scripts/fupload_cli/state_machine.py",
190
+ "bytes": 16450,
191
+ "sha256": "da26b63be4e085b9b39aea4d13a2c58206836d98fa46400b243113ce4b1ff4a9"
172
192
  },
173
193
  {
174
194
  "path": "scripts/fupload_cli/transport.py",
@@ -187,8 +207,8 @@
187
207
  },
188
208
  {
189
209
  "path": "SKILL.md",
190
- "bytes": 23242,
191
- "sha256": "1b9e444846724b03ee3e2e0ab89f50b85d33c1dfc4edbcb19d64118af2412571"
210
+ "bytes": 25720,
211
+ "sha256": "cfc3ce770c3594c5a787b25da03cb2e92e0f6ea6fbd162f26f4b6b3c44295a89"
192
212
  }
193
213
  ]
194
214
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@follenfang/fupload",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "Install and run the Fuploader Agent Skill and Python CLI.",
5
5
  "type": "module",
6
6
  "bin": {