@follenfang/fupload 0.0.9 → 0.0.10

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,346 @@
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
+ _LICENSE_KEYS = {
45
+ "type", "template", "license_template", "licenseTemplate",
46
+ "holder", "copyright_holder", "copyrightHolder",
47
+ "year", "copyright_year", "copyrightYear",
48
+ "content", "license_content", "licenseContent",
49
+ }
50
+
51
+
52
+ def _nonempty_text(value: Any, *, path: str) -> str:
53
+ if not isinstance(value, str) or not value.strip():
54
+ raise ValidationError("value must be a non-empty string", path=path)
55
+ return value.strip()
56
+
57
+
58
+ def _required_tier(value: Any, *, path: str = "$.required_tier_id") -> Optional[int]:
59
+ """Validate Creator's optional subscription-tier selection.
60
+
61
+ ``None`` is the explicit "no required tier" branch. The API contract
62
+ uses a positive integer for a selected tier; booleans and numeric strings
63
+ are rejected to avoid silently selecting the wrong dropdown item.
64
+ """
65
+ if value is None:
66
+ return None
67
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
68
+ raise ValidationError("required_tier_id must be null or a positive integer", path=path)
69
+ return value
70
+
71
+
72
+ def _platforms(value: Any, *, path: str = "$.publish_platforms") -> list[str]:
73
+ if not isinstance(value, (list, tuple)) or not value:
74
+ raise ValidationError("publish_platforms must contain at least one platform", path=path)
75
+ result: list[str] = []
76
+ for index, item in enumerate(value):
77
+ if not isinstance(item, str) or item not in _PLATFORMS:
78
+ raise ValidationError("platform must be modus or bigfoot", path=f"{path}[{index}]")
79
+ if item in result:
80
+ raise ValidationError("publish_platforms must not contain duplicates", path=path)
81
+ result.append(item)
82
+ return result
83
+
84
+
85
+ def _categories(value: Any, *, path: str = "$.categories") -> list[int]:
86
+ if not isinstance(value, list) or not value:
87
+ raise ValidationError("categories must contain at least one category ID", path=path)
88
+ if len(value) > 5:
89
+ raise ValidationError("categories must contain at most five category IDs", path=path)
90
+ result: list[int] = []
91
+ for index, item in enumerate(value):
92
+ if isinstance(item, bool) or not isinstance(item, int) or item <= 0:
93
+ raise ValidationError("category ID must be a positive integer", path=f"{path}[{index}]")
94
+ if item in result:
95
+ raise ValidationError("categories must not contain duplicates", path=path)
96
+ result.append(item)
97
+ return result
98
+
99
+
100
+ def _game(value: Any) -> Any:
101
+ """Require a game object/value that can be serialized and read back."""
102
+ if isinstance(value, Mapping):
103
+ if not value:
104
+ raise ValidationError("game selection must not be empty", path="$.game")
105
+ # Creator game entries have an ID/key and a display name. Preserve
106
+ # unknown fields for forward compatibility, but require one stable
107
+ # identity field so a resumed snapshot remains addressable.
108
+ identity = ("id", "gameId", "game_id", "key", "gameVersion", "game_version", "server")
109
+ if not any(value.get(name) not in (None, "") for name in identity):
110
+ raise ValidationError("game selection must include an identifier", path="$.game")
111
+ return copy.deepcopy(dict(value))
112
+ if isinstance(value, str) and value.strip():
113
+ return value.strip()
114
+ raise ValidationError("game selection must be a non-empty object or string", path="$.game")
115
+
116
+
117
+ def _license(value: Any) -> Dict[str, Any]:
118
+ if isinstance(value, str):
119
+ value = {"type": value}
120
+ if not isinstance(value, Mapping):
121
+ raise ValidationError("license must be a non-empty object or string", path="$.license")
122
+ unknown = sorted(set(value) - _LICENSE_KEYS)
123
+ if unknown:
124
+ raise ValidationError("unknown license field(s): %s" % ", ".join(unknown), path="$.license.%s" % unknown[0])
125
+ aliases = {
126
+ "type": ("type", "template", "license_template", "licenseTemplate"),
127
+ "holder": ("holder", "copyright_holder", "copyrightHolder"),
128
+ "year": ("year", "copyright_year", "copyrightYear"),
129
+ "content": ("content", "license_content", "licenseContent"),
130
+ }
131
+ result: Dict[str, Any] = {}
132
+ for target, names in aliases.items():
133
+ for name in names:
134
+ if name in value and value[name] is not None:
135
+ result[target] = value[name]
136
+ break
137
+ result["type"] = _nonempty_text(result.get("type"), path="$.license.type")
138
+ for name in ("holder", "year", "content"):
139
+ if name in result and result[name] is not None:
140
+ result[name] = _nonempty_text(result[name], path="$.license.%s" % name)
141
+ if result["type"].lower() in {"custom", "自定义"} and not result.get("content"):
142
+ raise ValidationError("custom license content must not be empty", path="$.license.content")
143
+ return result
144
+
145
+
146
+ class ProjectStateMachine:
147
+ """Sequenced, resumable ModUs project creation/edit form."""
148
+
149
+ schema = _SCHEMA
150
+
151
+ def __init__(self, snapshot: Optional[Mapping[str, Any]] = None) -> None:
152
+ self._step = ProjectStep.CHOOSE_GAME.value
153
+ self._game: Any = None
154
+ self._basic_info: Dict[str, Any] = {}
155
+ self._license: Dict[str, Any] = {}
156
+ if snapshot is not None:
157
+ self._restore(snapshot)
158
+
159
+ @property
160
+ def state(self) -> str:
161
+ return self._step
162
+
163
+ @property
164
+ def current_step(self) -> str:
165
+ return self._step
166
+
167
+ @property
168
+ def game(self) -> Any:
169
+ return copy.deepcopy(self._game)
170
+
171
+ @property
172
+ def basic_info(self) -> Dict[str, Any]:
173
+ return copy.deepcopy(self._basic_info)
174
+
175
+ @property
176
+ def license(self) -> Dict[str, Any]:
177
+ return copy.deepcopy(self._license)
178
+
179
+ def _require(self, expected: ProjectStep) -> None:
180
+ if self._step != expected.value:
181
+ raise ValidationError(
182
+ "project state %s cannot submit step %s"
183
+ % (self._step, expected.value),
184
+ path="$.state",
185
+ )
186
+
187
+ def select_game(self, game: Any) -> Dict[str, Any]:
188
+ self._require(ProjectStep.CHOOSE_GAME)
189
+ self._game = _game(game)
190
+ self._step = ProjectStep.BASIC_INFO.value
191
+ return self.snapshot()
192
+
193
+ def submit_basic_info(self, info: Optional[Mapping[str, Any]] = None, **fields: Any) -> Dict[str, Any]:
194
+ self._require(ProjectStep.BASIC_INFO)
195
+ value: Dict[str, Any] = dict(info or {})
196
+ value.update(fields)
197
+ name = value.get("name", value.get("project_name"))
198
+ summary = value.get("summary")
199
+ _nonempty_text(name, path="$.basic_info.name")
200
+ _nonempty_text(summary, path="$.basic_info.summary")
201
+ selected = value.get("publish_platforms", value.get("publishPlatforms"))
202
+ value["publish_platforms"] = _platforms(selected, path="$.basic_info.publish_platforms")
203
+ value.pop("publishPlatforms", None)
204
+ value["categories"] = _categories(value.get("categories"), path="$.basic_info.categories")
205
+ if _BIGFOOT_EXCLUSIVE_CATEGORY_ID in value["categories"] and value["publish_platforms"] != ["bigfoot"]:
206
+ raise ValidationError(
207
+ "category 998 requires bigfoot as the only publish platform",
208
+ path="$.basic_info.publish_platforms",
209
+ )
210
+ if "required_tier_id" in value:
211
+ value["required_tier_id"] = _required_tier(value["required_tier_id"], path="$.basic_info.required_tier_id")
212
+ elif "requiredTierId" in value:
213
+ value["required_tier_id"] = _required_tier(value.pop("requiredTierId"), path="$.basic_info.requiredTierId")
214
+ else:
215
+ # Explicitly persist the no-tier branch so reloads are stable.
216
+ value["required_tier_id"] = None
217
+ if "bigfoot" in value["publish_platforms"] and value["required_tier_id"] is not None:
218
+ raise ValidationError(
219
+ "required_tier_id must be null when bigfoot is selected",
220
+ path="$.basic_info.required_tier_id",
221
+ )
222
+ derived_sync_type = (1 if "modus" in value["publish_platforms"] else 0) | (
223
+ 2 if "bigfoot" in value["publish_platforms"] else 0
224
+ )
225
+ # Creator exposes platform toggles, not synchronizationType itself.
226
+ # Always replace a stale caller value with the UI-derived bit mask.
227
+ value["synchronization_type"] = derived_sync_type
228
+ if "project_name" in value:
229
+ value["name"] = value.pop("project_name")
230
+ self._basic_info = copy.deepcopy(value)
231
+ self._step = ProjectStep.LICENSE.value
232
+ return self.snapshot()
233
+
234
+ def submit_license(self, license_value: Any = None, **fields: Any) -> Dict[str, Any]:
235
+ self._require(ProjectStep.LICENSE)
236
+ value: Any = license_value
237
+ if value is None and fields:
238
+ value = fields
239
+ elif fields:
240
+ if not isinstance(value, Mapping):
241
+ raise ValidationError("license fields require an object", path="$.license")
242
+ merged = dict(value)
243
+ merged.update(fields)
244
+ value = merged
245
+ self._license = _license(value)
246
+ self._step = ProjectStep.COMPLETE.value
247
+ return self.snapshot()
248
+
249
+ # Friendly aliases used by form adapters that name the first tab
250
+ # "select game" and the second tab "general".
251
+ choose_game = select_game
252
+ set_game = select_game
253
+ set_basic_info = submit_basic_info
254
+ set_license = submit_license
255
+
256
+ def submit(self, step: str, payload: Any = None, **fields: Any) -> Dict[str, Any]:
257
+ """Dispatch a named step for generic CLI adapters."""
258
+ normalized = str(step).strip().lower().replace("-", "_")
259
+ if normalized in {"choose_game", "game"}:
260
+ return self.select_game(payload)
261
+ if normalized in {"basic_info", "general", "basic"}:
262
+ return self.submit_basic_info(payload, **fields)
263
+ if normalized == "license":
264
+ return self.submit_license(payload, **fields)
265
+ raise ValidationError("unsupported project state step", path="$.state")
266
+
267
+ def snapshot(self) -> Dict[str, Any]:
268
+ return {
269
+ "schema": self.schema,
270
+ "state": self._step,
271
+ "game": copy.deepcopy(self._game),
272
+ "basic_info": copy.deepcopy(self._basic_info),
273
+ "license": copy.deepcopy(self._license),
274
+ }
275
+
276
+ to_dict = snapshot
277
+
278
+ @classmethod
279
+ def from_snapshot(cls, snapshot: Mapping[str, Any]) -> "ProjectStateMachine":
280
+ return cls(snapshot)
281
+
282
+ def _restore(self, snapshot: Mapping[str, Any]) -> None:
283
+ if not isinstance(snapshot, Mapping) or snapshot.get("schema") != self.schema:
284
+ raise ValidationError("invalid project state snapshot", path="$.schema")
285
+ state = snapshot.get("state")
286
+ if state not in {item.value for item in ProjectStep}:
287
+ raise ValidationError("invalid project state", path="$.state")
288
+ game = snapshot.get("game")
289
+ basic = snapshot.get("basic_info") or {}
290
+ lic = snapshot.get("license") or {}
291
+ if state != CHOOSE_GAME:
292
+ self._game = _game(game)
293
+ if state in {LICENSE, COMPLETE}:
294
+ if not isinstance(basic, Mapping):
295
+ 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"]:
303
+ 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:
305
+ 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:
308
+ raise ValidationError("synchronization_type must match publish_platforms", path="$.basic_info.synchronization_type")
309
+ if state == COMPLETE:
310
+ self._license = _license(lic)
311
+ self._step = str(state)
312
+
313
+ def save(self, path: Union[str, os.PathLike[str]]) -> Path:
314
+ target = Path(path)
315
+ target.parent.mkdir(parents=True, exist_ok=True)
316
+ fd, temporary = tempfile.mkstemp(prefix=target.name + ".", suffix=".tmp", dir=str(target.parent))
317
+ try:
318
+ with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
319
+ json.dump(self.snapshot(), handle, ensure_ascii=False, indent=2)
320
+ handle.write("\n")
321
+ handle.flush()
322
+ os.fsync(handle.fileno())
323
+ os.replace(temporary, target)
324
+ finally:
325
+ if os.path.exists(temporary):
326
+ os.unlink(temporary)
327
+ return target
328
+
329
+ @classmethod
330
+ def load(cls, path: Union[str, os.PathLike[str]]) -> "ProjectStateMachine":
331
+ target = Path(path)
332
+ try:
333
+ with target.open("r", encoding="utf-8") as handle:
334
+ snapshot = json.load(handle)
335
+ except (OSError, ValueError) as exc:
336
+ raise ValidationError("project state snapshot could not be read", path=str(target)) from exc
337
+ return cls.from_snapshot(snapshot)
338
+
339
+ save_state = save
340
+ load_state = load
341
+
342
+
343
+ __all__ = [
344
+ "ProjectStep", "ProjectStateMachine", "CHOOSE_GAME", "SELECT_GAME",
345
+ "BASIC_INFO", "GENERAL", "LICENSE", "COMPLETE",
346
+ ]
@@ -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.10",
5
+ "skill_version": "0.0.10",
6
+ "tree_sha256": "38621aa4709de68a1cc298c8183fe0a5647c8291b870defdbbb5f54d184fa94a",
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": 4492,
96
+ "sha256": "0b9cf6c4b3bc7aa37a3649ec6ccb6c8b8aa3d077c8febc2635548546150df356"
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": "3f1afb95137eaedc622bb437c9c8a01bdaa729b911f3749e400a429d24abaaa2"
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": 38821,
171
+ "sha256": "f5edb6898ce515a7e34f0fcab3dc022c2c5185f548644eabf5291e38b22dae22"
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": 15325,
191
+ "sha256": "02f37b745bbb70860c09563b6778014bd1c69ca06ba28777415ad7ce84d02610"
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": "2e20b91ded57f4d91dbb658abf6fedc636785cc1b3ada23f985c9241c53cd3e6"
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.10",
4
4
  "description": "Install and run the Fuploader Agent Skill and Python CLI.",
5
5
  "type": "module",
6
6
  "bin": {