@follenfang/fupload 0.0.2 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -5
- package/fupload/SKILL.md +9 -6
- package/fupload/references/blackbox.md +37 -0
- package/fupload/scripts/fupload_cli/__init__.py +1 -1
- package/fupload/scripts/fupload_cli/blackbox.py +151 -0
- package/fupload/scripts/fupload_cli/blackbox_auth.py +54 -0
- package/fupload/scripts/fupload_cli/cli.py +26 -4
- package/fupload/scripts/fupload_cli/schema.py +18 -0
- package/npm/bin/fupload.mjs +8 -6
- package/npm/lib/managed-install.mjs +7 -3
- package/npm/lib/python-requirements.txt +1 -0
- package/npm/lib/python.mjs +227 -0
- package/npm/lib/uninstall.mjs +12 -0
- package/npm/lib/update.mjs +13 -0
- package/npm/postinstall.mjs +4 -1
- package/npm/skill-manifest.json +25 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# Fuploader
|
|
2
2
|
|
|
3
|
-
Fuploader 是一个面向 Agent 的《魔兽世界》作者发布 Skill 和 CLI。npm 安装后统一使用 `fupload` 命令;命令内部运行随 Skill 分发的纯 Python 实现。新手盒子(NewBeeBox)默认优先使用官方 `ncc` CLI,用户显式要求第三方管理工具时才使用 Fuploader 通道;网易 DD 使用 Fuploader 调用官方无头客户端;CurseForge 使用 Core API 查询作者公开项目,并使用 Authors Upload API 上传插件 ZIP
|
|
3
|
+
Fuploader 是一个面向 Agent 的《魔兽世界》作者发布 Skill 和 CLI。npm 安装后统一使用 `fupload` 命令;命令内部运行随 Skill 分发的纯 Python 实现。新手盒子(NewBeeBox)默认优先使用官方 `ncc` CLI,用户显式要求第三方管理工具时才使用 Fuploader 通道;网易 DD 使用 Fuploader 调用官方无头客户端;CurseForge 使用 Core API 查询作者公开项目,并使用 Authors Upload API 上传插件 ZIP;Heybox Workshop 复用本机客户端登录态管理插件元数据和版本。
|
|
4
4
|
|
|
5
5
|
项目强调显式调用、完整字段收集、写入前确认和写入后读回验证。CLI 只负责单次原子读写,业务选择、执行计划和异常恢复由 Agent 在对话中完成。
|
|
6
6
|
|
|
7
7
|
## 功能
|
|
8
8
|
|
|
9
|
-
- 支持新手盒子官方 `ncc`、第三方 Python 管理通道、网易 DD 和
|
|
9
|
+
- 支持新手盒子官方 `ncc`、第三方 Python 管理通道、网易 DD、CurseForge 和 Heybox Workshop。
|
|
10
10
|
- CurseForge 支持按作者 ID 查询 WoW 公开项目、读取上传用游戏版本,以及向已有项目上传插件 ZIP;不创建 Project,也不枚举私有、草稿或待审 Project。
|
|
11
11
|
- 支持插件、配置分享、WA/字符串的创建、内容更新与元数据编辑。
|
|
12
12
|
- 官方通道严格采用已安装 `ncc` 暴露的能力;第三方 Python 通道覆盖版本、游戏分支、分类、媒体、可见性、审核、商业设置、频道、关联内容和配置备份选择等页面字段。
|
|
@@ -40,6 +40,9 @@ fupload/
|
|
|
40
40
|
- 新手盒子官方通道:Windows、macOS 或 Linux,Node.js >= 18、官方 `ncc` CLI 与本机登录态
|
|
41
41
|
- 新手盒子第三方 Python 通道:Windows、Python 3、已安装并登录新手盒子桌面客户端
|
|
42
42
|
- 网易 DD:Windows、Python 3、已安装并登录官方客户端
|
|
43
|
+
- Heybox Workshop:Windows、Python 3、已安装并登录 Heybox 桌面客户端
|
|
44
|
+
|
|
45
|
+
npm 安装和 `fupload update` 会在用户状态目录中创建 Fuploader 专用 Python venv,并自动安装 Heybox ZIP 上传所需的腾讯官方 COS SDK;不会修改系统 Python。使用 `npm --ignore-scripts` 安装时,首次运行实际 CLI 命令会校验并补齐该 runtime。
|
|
43
46
|
|
|
44
47
|
官方 `ncc` 的安装命令为 `npm i -g @newbeebox/newbeebox-creator-center-cli@latest`。Fuploader 不读取其凭据文件;用户在自己的终端完成 `ncc login`,Agent 仅用 `ncc whoami -o json` 验证。自动化可在启动 Agent 前注入 `NCC_TOKEN`,令牌不得写入命令参数、项目文件或 Git。
|
|
45
48
|
|
|
@@ -73,7 +76,7 @@ CURSEFORGE_UPLOAD_TOKEN=
|
|
|
73
76
|
$fupload
|
|
74
77
|
```
|
|
75
78
|
|
|
76
|
-
该 Skill 不会因普通提及“发布”“新手盒子”“DD”或“
|
|
79
|
+
该 Skill 不会因普通提及“发布”“新手盒子”“DD”“CurseForge”或“Heybox”而自动触发。Agent 会先询问平台、资源和动作。新手盒子会自动检测 `ncc`:已安装即默认使用官方通道;未安装时先询问是否安装;只有用户显式选择第三方 Python 管理工具才进入项目内 CLI。CurseForge 上传缺少作者 ID 时,Agent 会主动询问;缺少 API Key 或 Upload Token 时,只会引导用户在本机填写 `~/.fupload/curseforge.env`,不会要求在对话中提供秘密。Heybox 命令复用桌面客户端登录态,支持模块元数据和版本增删改,不支持整体删除模块。Agent 在被发布项目中创建 `publish/<时间>-<平台>-<资源>-<动作>/`,同一次发布的脱敏 JSON 按原子步骤保存为 `01-<动作>.json`、`02-<动作>.json`。展示完整写入计划并得到确认后才会真实写入。
|
|
77
80
|
|
|
78
81
|
## CLI 使用
|
|
79
82
|
|
|
@@ -88,7 +91,7 @@ ncc whoami -o json
|
|
|
88
91
|
|
|
89
92
|
创建令牌和本机登录说明见[官方 CLI 文档](https://creator.newbeebox.com/cli-docs)。不要把令牌粘贴到 Agent 对话;在自己的终端完成 `ncc login`。
|
|
90
93
|
|
|
91
|
-
以下为 Fuploader 第三方 NewBeeBox、DD 与
|
|
94
|
+
以下为 Fuploader 第三方 NewBeeBox、DD、CurseForge 与 Heybox Workshop 执行层。
|
|
92
95
|
|
|
93
96
|
查看总帮助:
|
|
94
97
|
|
|
@@ -117,6 +120,9 @@ fupload curseforge plugin upload --input publish\20260807-120000-curseforge-plug
|
|
|
117
120
|
```powershell
|
|
118
121
|
fupload newbee plugin create --help
|
|
119
122
|
fupload dd config update --help
|
|
123
|
+
fupload blackbox plugin edit --help
|
|
124
|
+
fupload blackbox plugin update --help
|
|
125
|
+
fupload blackbox plugin version-delete --help
|
|
120
126
|
```
|
|
121
127
|
|
|
122
128
|
检查桌面登录与安装状态:
|
|
@@ -124,6 +130,8 @@ fupload dd config update --help
|
|
|
124
130
|
```powershell
|
|
125
131
|
fupload newbee session doctor
|
|
126
132
|
fupload dd session doctor
|
|
133
|
+
fupload blackbox plugin list
|
|
134
|
+
fupload blackbox plugin get --module-id 101149612
|
|
127
135
|
```
|
|
128
136
|
|
|
129
137
|
DD doctor 不会登录。若输出 `gui_running=true`,需先取得用户同意,随后关闭已验证的官方 GUI 并建立一个任务会话:
|
|
@@ -161,7 +169,7 @@ fupload uninstall
|
|
|
161
169
|
|
|
162
170
|
`fupload update` 固定安装 `@follenfang/fupload@latest`,随后把默认位置和所有登记有效的自定义 Skill 同步到同一版本。未知目录不会被覆盖。
|
|
163
171
|
|
|
164
|
-
`fupload uninstall` 先删除所有仍有有效 npm 管理标记的 Fuploader Skill,再调用 npm 删除 `@follenfang/fupload` 和 `fupload` 命令。Skill 清理失败时 npm 包保持可用,处理占用或权限问题后可重试。项目中的 `publish/`、新手盒子/DD 登录数据、DD 日志和 `~/.fupload/curseforge.env` 始终保留。
|
|
172
|
+
`fupload uninstall` 先删除所有仍有有效 npm 管理标记的 Fuploader Skill 和专用 Python runtime,再调用 npm 删除 `@follenfang/fupload` 和 `fupload` 命令。Skill 或 runtime 清理失败时 npm 包保持可用,处理占用或权限问题后可重试。项目中的 `publish/`、新手盒子/DD 登录数据、DD 日志和 `~/.fupload/curseforge.env` 始终保留。
|
|
165
173
|
|
|
166
174
|
npm 7 及以上不执行卸载 lifecycle,因此直接运行 `npm uninstall -g @follenfang/fupload` 只删除 npm 包和 CLI,可能留下 Skill;它不属于完整卸载流程。
|
|
167
175
|
|
|
@@ -182,6 +190,7 @@ npm 7 及以上不执行卸载 lifecycle,因此直接运行 `npm uninstall -g
|
|
|
182
190
|
- [新手盒子第三方 Python 字段参考](fupload/references/newbee.md)
|
|
183
191
|
- [网易 DD 字段参考](fupload/references/dd.md)
|
|
184
192
|
- [CurseForge API、字段与上传参考](fupload/references/curseforge.md)
|
|
193
|
+
- [Heybox Workshop 黑盒字段与版本参考](fupload/references/blackbox.md)
|
|
185
194
|
|
|
186
195
|
## 测试
|
|
187
196
|
|
package/fupload/SKILL.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: fupload
|
|
3
|
-
description: Explicit author-publishing workflow for World of Warcraft plugins, configuration shares, and WA/strings on NewBeeBox, NetEase DD, and
|
|
3
|
+
description: Explicit author-publishing workflow for World of Warcraft plugins, configuration shares, and WA/strings on NewBeeBox, NetEase DD, CurseForge, and Heybox Workshop, including CurseForge author project lookup, plugin ZIP upload, and Heybox client-session version management. 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, plugins, configurations, or WA.
|
|
4
4
|
metadata:
|
|
5
|
-
version: "0.0.
|
|
5
|
+
version: "0.0.5"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Fupload
|
|
9
9
|
|
|
10
|
-
Act as the publishing operator. For NewBeeBox, prefer the official `ncc` CLI whenever it is installed unless the user explicitly requests the third-party Python management tool. For DD, CurseForge, and an explicitly selected third-party NewBeeBox workflow, use the bundled Python CLI. Keep investigation, choices, planning, confirmation, and recovery in the conversation.
|
|
10
|
+
Act as the publishing operator. For NewBeeBox, prefer the official `ncc` CLI whenever it is installed unless the user explicitly requests the third-party Python management tool. For DD, CurseForge, Heybox Workshop, and an explicitly selected third-party NewBeeBox workflow, use the bundled Python CLI. Keep investigation, choices, planning, confirmation, and recovery in the conversation.
|
|
11
11
|
|
|
12
12
|
When installed from npm, interpret `<fupload-cli>` as `fupload`. Only while maintaining the source repository, directly installing this Skill, or when `fupload` is absent from PATH, use `python <skill-root>/scripts/fupload.py`. Keep the user's current project as the working directory in either mode.
|
|
13
13
|
|
|
@@ -15,7 +15,7 @@ When installed from npm, interpret `<fupload-cli>` as `fupload`. Only while main
|
|
|
15
15
|
|
|
16
16
|
When the user has not already stated both dimensions, ask in one short message:
|
|
17
17
|
|
|
18
|
-
1. Platform: NewBeeBox, DD, or
|
|
18
|
+
1. Platform: NewBeeBox, DD, CurseForge, or Heybox Workshop.
|
|
19
19
|
2. Resource and action: plugin/configuration/WA, then create/content update/metadata edit/delete. CurseForge supports public project listing and uploading a plugin ZIP to an existing project only.
|
|
20
20
|
|
|
21
21
|
Map natural language consistently:
|
|
@@ -25,7 +25,7 @@ Map natural language consistently:
|
|
|
25
25
|
- `edit`: change fields allowed by the target platform's action allowlist. For DD plugins, this is existing-record commercial, association, room/channel, and creation-statement settings; first-publication metadata, categories, media, and version fields are create/update-only.
|
|
26
26
|
- `delete`: delete one main record only. It never deletes an individual version, media object, or associated record.
|
|
27
27
|
|
|
28
|
-
Never offer bulk delete,
|
|
28
|
+
Never offer bulk delete, drafts, guides, messages, or GUI control automation. Heybox supports individual version deletion with asynchronous readback; whole-module deletion is not supported. The DD task-session command may close verified official GUI processes only after the consent flow below.
|
|
29
29
|
|
|
30
30
|
CurseForge cannot create a project or enumerate private, draft, or pending-review projects. Direct project creation to the Authors website; use the Core API list only as the public-project view for one numeric author ID.
|
|
31
31
|
|
|
@@ -60,7 +60,7 @@ Run `<fupload-cli> --help` from the user's project. The npm launcher resolves an
|
|
|
60
60
|
|
|
61
61
|
When the npm command is absent and the source/direct-Skill fallback is required, resolve `<skill-root>` from this `SKILL.md` and use `python <skill-root>/scripts/fupload.py`. On Windows, prefer `py -3` only if `python` is unavailable. Do not use a binary, Go source, repository-relative fallback, browser automation, Computer Use, or direct handcrafted HTTP calls. NewBeeBox's official `ncc` is not this bundled CLI and must not be wrapped as a Python `--input` command.
|
|
62
62
|
|
|
63
|
-
For npm package maintenance, use `fupload update` to update the CLI and every registered managed Skill to `@follenfang/fupload@latest`. For a complete removal, use `fupload uninstall`; it removes every registered Skill that still has a valid Fuploader npm ownership marker, then
|
|
63
|
+
For npm package maintenance, use `fupload update` to update the CLI, managed Python runtime, and every registered managed Skill to `@follenfang/fupload@latest`. The npm installer and launcher create a Fuploader-only venv and install the pinned Python dependencies there; never ask for a system-wide `pip install`. For a complete removal, use `fupload uninstall`; it removes every registered Skill that still has a valid Fuploader npm ownership marker, the managed Python runtime, then the npm package and command. Do not use direct `npm install -g` or `npm uninstall -g` as the normal Agent-managed update/removal workflow.
|
|
64
64
|
|
|
65
65
|
## Load only the needed contract
|
|
66
66
|
|
|
@@ -69,6 +69,7 @@ For npm package maintenance, use `fupload update` to update the CLI and every re
|
|
|
69
69
|
- Read [references/newbee.md](references/newbee.md) only when the user explicitly selected the third-party Python NewBeeBox channel.
|
|
70
70
|
- Read [references/dd.md](references/dd.md) only for DD.
|
|
71
71
|
- Read [references/curseforge.md](references/curseforge.md) for every CurseForge lookup or upload.
|
|
72
|
+
- Read [references/blackbox.md](references/blackbox.md) for Heybox Workshop plugin list/detail, metadata edit, version upload/edit/delete, and client-session authentication.
|
|
72
73
|
- For Python, run the exact leaf command with `--help` before creating its input. Treat help as the executable schema contract.
|
|
73
74
|
|
|
74
75
|
## Investigate
|
|
@@ -87,6 +88,8 @@ For DD, use the one active task session to read game types/builds and the resour
|
|
|
87
88
|
|
|
88
89
|
For every existing DD update or edit, GET the detail first and rebuild the official full form before preparing the write. If a legacy record lacks a field now required by the official submit validator, such as `creation_statement`, stop before upload and collect that value. When the missing field belongs to a different action allowlist, plan and confirm the prerequisite edit first, read it back, then run the content update. Do not send `null`, create defaults, or guessed values to bypass the missing field.
|
|
89
90
|
|
|
91
|
+
For Heybox Workshop, use the signed-in desktop client's local session automatically. `blackbox plugin list`, `get`, and `versions` are read-only; `plugin edit` preserves omitted module fields by reading the complete module first; `plugin update` and `version-edit` upload or reuse a ZIP URL and verify the version readback; `version-delete` waits for asynchronous soft deletion and retries once when the audit state temporarily returns active. Do not accept or print cookies, tokens, signatures, or temporary COS credentials, and do not delete a whole module.
|
|
92
|
+
|
|
90
93
|
For DD WA create, collect user choices before generating JSON, then let the Python CLI supply only the official create defaults for omitted form values: seven-day share and purchase lifetimes, `need_buy=false`, category `ui_original`, `Interface/Addons`, empty VIP levels, and version `0`. Submit category IDs as strings even when a discovery response represents them numerically. WA create/update versions contain digits only; update must be numerically greater than the current remote value. Every `!WA:2!` create/update/edit is reparsed by the installed official `WowUIInterface.parseWa` chain, including unchanged edit content. Do not carry create defaults into WA update/edit; omitted existing fields preserve their remote value.
|
|
91
94
|
|
|
92
95
|
For official NewBeeBox, use only `ncc whoami -o json` to verify authentication; do not inspect its credential files. For third-party Python NewBeeBox, run `newbee session doctor`; credentials must come from the Windows Known Folder auth-store and all authenticated requests must use fixed official HTTPS origins. For DD, run `dd session doctor`; discovery must accept only an Authenticode-valid executable from an allowed official NetEase publisher. Doctor is local-only and must not start a native login. Do not set endpoint, credential-directory, or DD executable-path environment overrides.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Heybox Workshop
|
|
2
|
+
|
|
3
|
+
The blackbox provider reuses the signed-in Heybox desktop client's local login state. It never accepts credentials, cookies, tokens, signatures, or COS temporary credentials as input.
|
|
4
|
+
|
|
5
|
+
The npm installer manages Tencent's official COS SDK in a Fuploader-only Python venv. `fupload update` synchronizes it, the launcher repairs a missing or stale runtime before executing the Python CLI, and `fupload uninstall` removes it. No system-wide `pip install` is required.
|
|
6
|
+
|
|
7
|
+
## `blackbox plugin edit`
|
|
8
|
+
|
|
9
|
+
Edits existing module metadata and verifies the module detail readback. Input schema: `fupload.v1.blackbox.plugin.edit`.
|
|
10
|
+
|
|
11
|
+
Fields: `id`, `name`, `logo_url`, `category_ids`, `type`, `desc`, `official`, `official_url`, `core_folders`.
|
|
12
|
+
|
|
13
|
+
`id` is required. Omitted metadata fields preserve their current remote values. `category_ids` contains positive category IDs; `core_folders` is an array in the input and is sent as the platform's comma-separated value.
|
|
14
|
+
|
|
15
|
+
## `blackbox plugin update`
|
|
16
|
+
|
|
17
|
+
Uploads a local ZIP and creates one new plugin version. Input schema: `fupload.v1.blackbox.plugin.update`.
|
|
18
|
+
|
|
19
|
+
Fields: `module_id`, `name`, `type`, `game_versions`, `file`, `file_url`.
|
|
20
|
+
|
|
21
|
+
`module_id`, `name`, `type`, `game_versions`, and `file` are required. The provider uploads the ZIP through the official COS token flow, sends the server-normalized URL to the version upsert endpoint, and verifies `name`, `type`, `gameVersions`, and non-empty `fileUrlHeybox`.
|
|
22
|
+
|
|
23
|
+
## `blackbox plugin version-edit`
|
|
24
|
+
|
|
25
|
+
Edits one existing version. Input schema: `fupload.v1.blackbox.version.edit`.
|
|
26
|
+
|
|
27
|
+
Fields: `version_id`, `module_id`, `name`, `type`, `game_versions`, `file`, `file_url`.
|
|
28
|
+
|
|
29
|
+
`version_id`, `module_id`, `name`, `type`, and `game_versions` are required. Provide `file` to upload a replacement ZIP or `file_url` to reuse an already uploaded package; when both are omitted, the current server file URL is preserved.
|
|
30
|
+
|
|
31
|
+
## `blackbox plugin version-delete`
|
|
32
|
+
|
|
33
|
+
Soft-deletes one version and verifies `auditState=4` or absence after the platform's asynchronous processing. Input schema: `fupload.v1.blackbox.version.delete`.
|
|
34
|
+
|
|
35
|
+
Fields: `version_id`, `module_id`.
|
|
36
|
+
|
|
37
|
+
The provider may retry once when the platform temporarily returns a deleted version to an active audit state. Deleting the entire plugin module is not supported by the current client/web contract and is outside this command.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Heybox Workshop plugin provider."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import hashlib, json, secrets, time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import urlencode
|
|
6
|
+
from urllib.request import Request, urlopen
|
|
7
|
+
from typing import Any, Mapping
|
|
8
|
+
from .errors import FuploadError, ValidationError
|
|
9
|
+
from .blackbox_auth import API_BASE, hkey, load_session
|
|
10
|
+
|
|
11
|
+
class Blackbox:
|
|
12
|
+
def __init__(self, config: Mapping[str, Any] | None = None, transport=None):
|
|
13
|
+
self.config = dict(config or {})
|
|
14
|
+
self.profile = Path(self.config.get("client_profile") or Path.home() / "AppData/Roaming/heybox-pc-launcher")
|
|
15
|
+
self._transport = transport
|
|
16
|
+
def _request(self, method, path, body=None, query=None):
|
|
17
|
+
if self._transport: return self._transport(method, path, body or {}, query or {})
|
|
18
|
+
cookies, identity = load_session(self.profile); ts=int(time.time()); nonce=hashlib.md5((str(ts)+secrets.token_hex(16)).encode()).hexdigest().upper()
|
|
19
|
+
params={**identity,"version":identity.get("version","1.12.0"),"hkey":hkey(path,ts,nonce),"_time":ts,"_chat_time":int(time.time()*1000),"nonce":nonce,**(query or {})}
|
|
20
|
+
headers={"User-Agent":"HeyboxApp/1.12.0","x_xhh_tokenid":cookies["x_xhh_tokenid"],"Cookie":"; ".join(f"{k}={v}" for k,v in cookies.items()),"Content-Type":"application/x-www-form-urlencoded"}
|
|
21
|
+
url=API_BASE+path+"?"+urlencode(params); data=urlencode(body or {},doseq=True).encode() if method=="POST" else None
|
|
22
|
+
try:
|
|
23
|
+
with urlopen(Request(url,data=data,headers=headers,method=method),timeout=60) as r: result=json.loads(r.read().decode())
|
|
24
|
+
except Exception as exc: raise FuploadError("Workshop API request failed", endpoint=path, verification_required=method=="POST") from exc
|
|
25
|
+
if not isinstance(result,dict) or result.get("status")!="ok": raise FuploadError("Workshop API rejected request", endpoint=path, details={"status":result.get("status") if isinstance(result,dict) else None})
|
|
26
|
+
return result
|
|
27
|
+
@staticmethod
|
|
28
|
+
def _result(payload): return payload.get("result") or {}
|
|
29
|
+
def execute_read(self, resource: str, action: str, args: Any):
|
|
30
|
+
if resource == "plugin" and action in ("list", "get", "versions"):
|
|
31
|
+
if action == "list": return self.plugin_list()
|
|
32
|
+
module_id = getattr(args,"module_id",None) if not isinstance(args,Mapping) else args.get("module_id")
|
|
33
|
+
result = self.plugin_get(int(module_id))
|
|
34
|
+
return result["versions"] if action == "versions" else result
|
|
35
|
+
raise ValidationError("unsupported blackbox read operation")
|
|
36
|
+
def execute_write(self, resource: str, action: str, doc: Mapping[str,Any]):
|
|
37
|
+
if doc.get("dry_run"): return {"dry_run":True,"operation":f"{resource}.{action}","fields":sorted(k for k in doc if k not in {"dry_run","schema"})}
|
|
38
|
+
if resource == "plugin" and action == "edit": return self.module_edit(doc)
|
|
39
|
+
if resource == "plugin" and action == "update": return self.version_upsert(doc)
|
|
40
|
+
if resource=="version" and action in ("create","update","edit"): return self.version_upsert(doc)
|
|
41
|
+
if resource=="version" and action=="delete": return self.version_delete(doc)
|
|
42
|
+
raise ValidationError("unsupported blackbox write operation")
|
|
43
|
+
def plugin_list(self):
|
|
44
|
+
rows=self._result(self._request("GET","/wow/open_platform/module/list/")).get("moduleList") or []
|
|
45
|
+
return {"total_count":len(rows),"plugins":[self._redact(x) for x in rows if isinstance(x,dict)]}
|
|
46
|
+
def plugin_get(self,module_id:int):
|
|
47
|
+
detail=self._result(self._request("GET","/wow/open_platform/module/detail/",query={"moduleId":module_id}))
|
|
48
|
+
versions=self._result(self._request("GET","/wow/open_platform/module_version/list/",query={"moduleId":module_id,"offset":0,"limit":100}))
|
|
49
|
+
return {"module":self._redact(detail.get("module") or detail),"versions":[self._redact(x) for x in (versions.get("versionList") or [])]}
|
|
50
|
+
def module_edit(self,doc):
|
|
51
|
+
aliases={"module_id":"id","logo_url":"logoUrl","category_ids":"categoryIds","official_url":"officialUrl","core_folders":"coreFolders"}
|
|
52
|
+
normalized={aliases.get(k,k):v for k,v in doc.items() if k not in {"schema","dry_run"}}
|
|
53
|
+
if "id" not in normalized: raise ValidationError("id is required",path="$.id")
|
|
54
|
+
module_id = int(normalized["id"])
|
|
55
|
+
current = self.plugin_get(module_id)["module"]
|
|
56
|
+
# The web client sends a complete module object; preserve omitted fields.
|
|
57
|
+
defaults = {"name":"", "logoUrl":"", "id":module_id, "categoryIds":[],
|
|
58
|
+
"type":1, "desc":"", "official":"", "officialUrl":"", "coreFolders":""}
|
|
59
|
+
fields={key: normalized.get(key, current.get(key, defaults[key]))
|
|
60
|
+
for key in ("name","logoUrl","id","categoryIds","type","desc","official","officialUrl","coreFolders")}
|
|
61
|
+
fields["id"] = module_id
|
|
62
|
+
if isinstance(fields.get("coreFolders"),list): fields["coreFolders"]=",".join(map(str,fields["coreFolders"]))
|
|
63
|
+
response=self._request("POST","/wow/open_platform/module/update/",body=fields)
|
|
64
|
+
actual=self.plugin_get(module_id)["module"]
|
|
65
|
+
mismatches=[]
|
|
66
|
+
for key,wanted in ((aliases.get(key,key), value) for key,value in normalized.items() if key in aliases or key in fields):
|
|
67
|
+
observed=actual.get(key)
|
|
68
|
+
if key=="coreFolders" and isinstance(observed,list): wanted=[x for x in str(wanted).split(",") if x]
|
|
69
|
+
if observed != wanted: mismatches.append(key)
|
|
70
|
+
if mismatches: raise FuploadError("module update readback mismatch",kind="verification_required",verification_required=True,details={"fields":mismatches})
|
|
71
|
+
return {"accepted":True,"verified":True,"module_id":module_id,"response":self._redact(response)}
|
|
72
|
+
def version_upsert(self,doc):
|
|
73
|
+
aliases={"module_id":"moduleId","version_id":"versionId","game_versions":"gameVersions","file_url":"fileUrl"}
|
|
74
|
+
normalized={aliases.get(k,k):v for k,v in doc.items() if k not in {"schema","dry_run"}}
|
|
75
|
+
if "file" in normalized and "fileUrl" not in normalized:
|
|
76
|
+
normalized["fileUrl"]=self.upload_zip(int(normalized["moduleId"]),str(normalized["file"]))["url"]
|
|
77
|
+
if "fileUrl" not in normalized and "versionId" in normalized:
|
|
78
|
+
existing = self._find_version(int(normalized["moduleId"]), int(normalized["versionId"]))
|
|
79
|
+
if existing and existing.get("fileUrlHeybox"):
|
|
80
|
+
normalized["fileUrl"] = existing["fileUrlHeybox"]
|
|
81
|
+
required=("moduleId","name","type","gameVersions","fileUrl")
|
|
82
|
+
missing=[k for k in required if k not in normalized]
|
|
83
|
+
if missing: raise ValidationError("missing field(s): %s"%", ".join(missing))
|
|
84
|
+
games=normalized["gameVersions"] if isinstance(normalized["gameVersions"],list) else [x for x in str(normalized["gameVersions"]).split(",") if x]
|
|
85
|
+
body={"moduleId":int(normalized["moduleId"]),"name":normalized["name"],"type":int(normalized["type"]),"gameVersions":",".join(map(str,games)),"fileUrl":normalized["fileUrl"]}
|
|
86
|
+
if "versionId" in normalized: body["versionId"]=int(normalized["versionId"])
|
|
87
|
+
response=self._request("POST","/wow/open_platform/module_version/upsert/",body=body)
|
|
88
|
+
found=self._wait_version(body["moduleId"],body.get("versionId"),body["name"],
|
|
89
|
+
expected={"name":body["name"],"type":body["type"],"gameVersions":list(map(str,games))})
|
|
90
|
+
mismatches=[key for key,wanted in {"name":body["name"],"type":body["type"],"gameVersions":list(map(str,games))}.items() if found.get(key)!=wanted]
|
|
91
|
+
if not found.get("fileUrlHeybox"):
|
|
92
|
+
mismatches.append("fileUrl")
|
|
93
|
+
if mismatches: raise FuploadError("version upsert readback mismatch",kind="verification_required",verification_required=True,details={"fields":mismatches})
|
|
94
|
+
return {"accepted":True,"verified":True,"module_id":body["moduleId"],"version_id":found.get("id"),"readback":self._redact(found),"response":self._redact(response)}
|
|
95
|
+
def version_delete(self,doc):
|
|
96
|
+
version_id=doc.get("versionId",doc.get("version_id")); module_id=doc.get("moduleId",doc.get("module_id"))
|
|
97
|
+
if version_id is None or module_id is None: raise ValidationError("versionId and moduleId are required")
|
|
98
|
+
response=self._request("POST","/wow/open_platform/module_version/delete/",body={"versionId":int(version_id),"moduleId":int(module_id)})
|
|
99
|
+
found=self._wait_version(int(module_id),int(version_id),None,deleted=True)
|
|
100
|
+
retry = False
|
|
101
|
+
settle = float(self.config.get("delete_settle_seconds", 10))
|
|
102
|
+
if settle:
|
|
103
|
+
time.sleep(settle)
|
|
104
|
+
current = self._find_version(int(module_id), int(version_id))
|
|
105
|
+
if current is not None and current.get("auditState") != 4:
|
|
106
|
+
retry = True
|
|
107
|
+
response = self._request("POST","/wow/open_platform/module_version/delete/",body={"versionId":int(version_id),"moduleId":int(module_id)})
|
|
108
|
+
found = self._wait_version(int(module_id),int(version_id),None,deleted=True)
|
|
109
|
+
return {"accepted":True,"verified":True,"module_id":int(module_id),"version_id":int(version_id),"audit_state":found.get("auditState") if found else None,"retry":retry,"response":self._redact(response)}
|
|
110
|
+
def _version_rows(self,module_id):
|
|
111
|
+
return self._result(self._request("GET","/wow/open_platform/module_version/list/",query={"moduleId":module_id,"offset":0,"limit":100})).get("versionList") or []
|
|
112
|
+
|
|
113
|
+
def _find_version(self, module_id, version_id):
|
|
114
|
+
return next((x for x in self._version_rows(module_id) if x.get("id") == version_id), None)
|
|
115
|
+
def _wait_version(self,module_id,version_id,name,deleted=False,expected=None):
|
|
116
|
+
for attempt in range(int(self.config.get("verify_attempts",20))):
|
|
117
|
+
rows=self._version_rows(module_id)
|
|
118
|
+
found=next((x for x in rows if (version_id is not None and x.get("id")==version_id) or (version_id is None and x.get("name")==name)),None)
|
|
119
|
+
matches_expected = found and all(
|
|
120
|
+
(list(map(str, found.get(key) or [])) if key == "gameVersions" else found.get(key)) == wanted
|
|
121
|
+
for key, wanted in (expected or {}).items()
|
|
122
|
+
)
|
|
123
|
+
if (deleted and (found is None or found.get("auditState")==4)) or (not deleted and found and matches_expected): return found or {}
|
|
124
|
+
if attempt+1<int(self.config.get("verify_attempts",20)): time.sleep(float(self.config.get("verify_interval",2)))
|
|
125
|
+
raise FuploadError("version write was not confirmed by readback",kind="verification_required",verification_required=True)
|
|
126
|
+
@staticmethod
|
|
127
|
+
def _redact(value):
|
|
128
|
+
if isinstance(value,dict): return {k:("<redacted>" if any(s in k.lower() for s in ("token","secret","cookie","nonce","hkey")) else Blackbox._redact(v)) for k,v in value.items()}
|
|
129
|
+
if isinstance(value,list): return [Blackbox._redact(v) for v in value]
|
|
130
|
+
return value
|
|
131
|
+
|
|
132
|
+
def upload_zip(self, module_id: int, file_path: str, *, dry_run=False):
|
|
133
|
+
path=Path(file_path)
|
|
134
|
+
if not path.is_file(): raise ValidationError("file does not exist",path="$.file")
|
|
135
|
+
if dry_run: return {"dry_run":True,"bytes":path.stat().st_size,"sha256":hashlib.sha256(path.read_bytes()).hexdigest()}
|
|
136
|
+
size_mb=path.stat().st_size/1024/1024
|
|
137
|
+
token=self._result(self._request("POST","/wow/cos/upload/token/",body={"jsonData":json.dumps({"upload_infos":[{"filename":path.name,"file_size":size_mb,"type":"module"}]},separators=(",",":"))}))
|
|
138
|
+
info=token["info"]; f=token["files"][0]; creds=info["Credentials"].get("Credentials",info["Credentials"])
|
|
139
|
+
try:
|
|
140
|
+
from qcloud_cos import CosConfig, CosS3Client
|
|
141
|
+
except ModuleNotFoundError as exc:
|
|
142
|
+
raise FuploadError(
|
|
143
|
+
"The managed Fuploader Python runtime is missing the Heybox COS SDK",
|
|
144
|
+
kind="environment_error",
|
|
145
|
+
details={"repair_command":"fupload update"},
|
|
146
|
+
) from exc
|
|
147
|
+
try:
|
|
148
|
+
cos=CosS3Client(CosConfig(Region=info.get("region") or "ap-shanghai",SecretId=creds["TmpSecretID"],SecretKey=creds["TmpSecretKey"],Token=creds["Token"],Scheme="https"))
|
|
149
|
+
with path.open("rb") as stream: cos.put_object(Bucket=info["bucket"],Key=f["key"],Body=stream)
|
|
150
|
+
except Exception as exc: raise FuploadError("COS upload failed",verification_required=True) from exc
|
|
151
|
+
return {"url":"https://%s/%s"%(info["host"],f["key"]),"bytes":path.stat().st_size,"sha256":hashlib.sha256(path.read_bytes()).hexdigest()}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Authentication helpers for the Heybox desktop Workshop client."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import hashlib, json, secrets, sqlite3, time
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from urllib.parse import parse_qs, urlparse
|
|
6
|
+
from .errors import FuploadError
|
|
7
|
+
|
|
8
|
+
API_BASE = "https://workshopapi.xiaoheihe.cn"
|
|
9
|
+
_ALPHABET = "AB45STUVWZEFGJ6CH01D237IXYPQRKLMN89"
|
|
10
|
+
|
|
11
|
+
def _n8(value: str, limit: int) -> str:
|
|
12
|
+
table = _ALPHABET[:limit]
|
|
13
|
+
return "".join(table[ord(ch) % len(table)] for ch in value)
|
|
14
|
+
def _i8(value: str) -> str:
|
|
15
|
+
return "".join(_ALPHABET[ord(ch) % len(_ALPHABET)] for ch in value)
|
|
16
|
+
def _interleave(parts):
|
|
17
|
+
return "".join(part[i] for i in range(max(map(len, parts))) for part in parts if i < len(part))
|
|
18
|
+
def _mix(values):
|
|
19
|
+
def p(x): return ((x << 1) ^ 27) & 255 if x & 128 else (x << 1) & 255
|
|
20
|
+
def hm(x): return p(x) ^ x
|
|
21
|
+
def qg(x): return hm(p(x))
|
|
22
|
+
def dx(x): return qg(hm(p(x)))
|
|
23
|
+
def mw(x): return dx(x) ^ qg(x) ^ hm(x)
|
|
24
|
+
a,b,c,d,*rest=values
|
|
25
|
+
return [mw(a)^dx(b)^qg(c)^hm(d), hm(a)^mw(b)^dx(c)^qg(d), qg(a)^hm(b)^mw(c)^dx(d), dx(a)^qg(b)^hm(c)^mw(d), *rest]
|
|
26
|
+
def hkey(path: str, timestamp: int, nonce: str) -> str:
|
|
27
|
+
normalized = "/" + "/".join(x for x in path.split("/") if x) + "/"
|
|
28
|
+
digest = hashlib.md5(_interleave([_n8(str(timestamp), -2), _i8(normalized), _i8(nonce)]).encode()).hexdigest()
|
|
29
|
+
return _n8(digest[:5], -4) + "%02d" % (sum(_mix([ord(x) for x in digest[-6:]])) % 100)
|
|
30
|
+
|
|
31
|
+
def load_session(profile: Path | None = None):
|
|
32
|
+
profile = profile or Path.home() / "AppData/Roaming/heybox-pc-launcher"
|
|
33
|
+
db = profile / "Network/Cookies"
|
|
34
|
+
try:
|
|
35
|
+
with sqlite3.connect("file:%s?mode=ro" % db, uri=True) as con:
|
|
36
|
+
rows = con.execute("select name,value from cookies where host_key like '%xiaoheihe.cn'").fetchall()
|
|
37
|
+
except (OSError, sqlite3.Error) as exc:
|
|
38
|
+
raise FuploadError("Heybox desktop Cookie DB is missing", kind="authentication_error") from exc
|
|
39
|
+
cookies = {str(k): str(v) for k,v in rows if k in {"user_heybox_id","user_pkey","x_xhh_tokenid"} and v}
|
|
40
|
+
if not {"user_heybox_id","user_pkey","x_xhh_tokenid"} <= set(cookies):
|
|
41
|
+
raise FuploadError("Heybox desktop login state is incomplete", kind="authentication_error")
|
|
42
|
+
identity = {}
|
|
43
|
+
scope = profile / "sentry/scope_v3.json"
|
|
44
|
+
try:
|
|
45
|
+
crumbs = json.loads(scope.read_text(encoding="utf-8")).get("scope",{}).get("breadcrumbs",[])
|
|
46
|
+
for crumb in reversed(crumbs):
|
|
47
|
+
raw = crumb.get("data",{}).get("url","")
|
|
48
|
+
if "x_app=heybox_pc" in raw:
|
|
49
|
+
query = parse_qs(urlparse(raw).query)
|
|
50
|
+
allowed = {"x_client_type","x_os_type","x_app","version","exe_version","os_version","device_id","channel","heybox_id"}
|
|
51
|
+
identity = {k:v[-1] for k,v in query.items() if k in allowed and v}; break
|
|
52
|
+
except (OSError, ValueError, TypeError):
|
|
53
|
+
pass
|
|
54
|
+
return cookies, identity
|
|
@@ -11,6 +11,7 @@ from typing import Any, Callable, Dict, Optional, Sequence, Tuple
|
|
|
11
11
|
from . import __version__
|
|
12
12
|
from .dd import DD
|
|
13
13
|
from .curseforge import CurseForge
|
|
14
|
+
from .blackbox import Blackbox
|
|
14
15
|
from .errors import FuploadError, ValidationError
|
|
15
16
|
from .io import read_json, write_error, write_output
|
|
16
17
|
from .newbee import NewBee
|
|
@@ -58,9 +59,10 @@ def _list_flags(parser: argparse.ArgumentParser, *, offset: bool = False, game_t
|
|
|
58
59
|
parser.add_argument("--game-type", type=_positive, required=True, help="DD game type selected from `dd options game-types`.")
|
|
59
60
|
|
|
60
61
|
|
|
61
|
-
def _write_leaf(parent: argparse._SubParsersAction, platform: str, resource: str, action: str, summary: str) -> None:
|
|
62
|
+
def _write_leaf(parent: argparse._SubParsersAction, platform: str, resource: str, action: str, summary: str, *, command: Optional[str] = None) -> None:
|
|
63
|
+
command = command or action
|
|
62
64
|
leaf = parent.add_parser(
|
|
63
|
-
|
|
65
|
+
command, help=summary, description=summary + "\n\n" + WRITE_HELP,
|
|
64
66
|
epilog=schema_help(platform, resource, action),
|
|
65
67
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
66
68
|
)
|
|
@@ -200,6 +202,25 @@ def _curseforge_tree(platforms: argparse._SubParsersAction) -> None:
|
|
|
200
202
|
_write_leaf(plugin, "curseforge", "plugin", "upload", "Upload one plugin archive to an existing CurseForge project.")
|
|
201
203
|
|
|
202
204
|
|
|
205
|
+
def _blackbox_tree(platforms: argparse._SubParsersAction) -> None:
|
|
206
|
+
root = platforms.add_parser(
|
|
207
|
+
"blackbox",
|
|
208
|
+
help="Heybox Workshop plugin management",
|
|
209
|
+
description="Reuse the signed-in Heybox desktop client login state; no credential input is accepted.",
|
|
210
|
+
)
|
|
211
|
+
groups = root.add_subparsers(dest="resource_command", required=True)
|
|
212
|
+
plugin = groups.add_parser("plugin", help="Heybox Workshop plugin metadata and versions").add_subparsers(dest="action_command", required=True)
|
|
213
|
+
_read_leaf(plugin, "list", "List plugins managed by the current Heybox Workshop account.", platform="blackbox", resource="plugin", action="list")
|
|
214
|
+
leaf = _read_leaf(plugin, "get", "Read one Heybox Workshop plugin and its versions.", platform="blackbox", resource="plugin", action="get")
|
|
215
|
+
leaf.add_argument("--module-id", type=_positive, required=True)
|
|
216
|
+
leaf = _read_leaf(plugin, "versions", "List versions for one Heybox Workshop plugin.", platform="blackbox", resource="plugin", action="versions")
|
|
217
|
+
leaf.add_argument("--module-id", type=_positive, required=True)
|
|
218
|
+
_write_leaf(plugin, "blackbox", "plugin", "edit", "Edit Heybox Workshop plugin metadata and verify the module readback.")
|
|
219
|
+
_write_leaf(plugin, "blackbox", "plugin", "update", "Upload a ZIP and create a new Heybox Workshop plugin version.")
|
|
220
|
+
_write_leaf(plugin, "blackbox", "version", "edit", "Edit an existing Heybox Workshop plugin version and verify its readback.", command="version-edit")
|
|
221
|
+
_write_leaf(plugin, "blackbox", "version", "delete", "Soft-delete one Heybox Workshop plugin version and verify its deleted state.", command="version-delete")
|
|
222
|
+
|
|
223
|
+
|
|
203
224
|
def build_parser() -> argparse.ArgumentParser:
|
|
204
225
|
parser = _parser(
|
|
205
226
|
prog="fupload",
|
|
@@ -211,6 +232,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
211
232
|
_newbee_tree(platforms)
|
|
212
233
|
_dd_tree(platforms)
|
|
213
234
|
_curseforge_tree(platforms)
|
|
235
|
+
_blackbox_tree(platforms)
|
|
214
236
|
return parser
|
|
215
237
|
|
|
216
238
|
|
|
@@ -258,14 +280,14 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
258
280
|
if args.dry_run:
|
|
259
281
|
write_output(platform, operation, _dry_run_data(doc, schema.name), dry_run=True)
|
|
260
282
|
return 0
|
|
261
|
-
provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else CurseForge())
|
|
283
|
+
provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else CurseForge()))
|
|
262
284
|
if platform == "dd":
|
|
263
285
|
data = provider.execute_write(resource, action, doc, getattr(args, "session", None))
|
|
264
286
|
else:
|
|
265
287
|
data = provider.execute_write(resource, action, doc)
|
|
266
288
|
write_output(platform, operation, data)
|
|
267
289
|
return 0
|
|
268
|
-
provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else CurseForge())
|
|
290
|
+
provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else (Blackbox() if platform == "blackbox" else CurseForge()))
|
|
269
291
|
if platform == "dd":
|
|
270
292
|
data = provider.execute_read(resource, action, args, getattr(args, "session", None))
|
|
271
293
|
else:
|
|
@@ -552,6 +552,24 @@ register("curseforge", "plugin", "upload", required({
|
|
|
552
552
|
"is_marked_for_manual_release": f("boolean"),
|
|
553
553
|
}, ("project_id", "file", "changelog", "release_type")))
|
|
554
554
|
|
|
555
|
+
register("blackbox", "plugin", "edit", required({
|
|
556
|
+
"id": f("integer"), "name": f("string"), "logo_url": f("string"), "category_ids": f("array"),
|
|
557
|
+
"type": f("integer", choices=(1, 9)), "desc": f("string"), "official": f("string"),
|
|
558
|
+
"official_url": f("string"), "core_folders": f("array"),
|
|
559
|
+
}, ("id",)))
|
|
560
|
+
register("blackbox", "plugin", "update", required({
|
|
561
|
+
"module_id": f("integer"), "name": f("string", nonempty=True), "type": f("integer", choices=(1, 2, 3)),
|
|
562
|
+
"game_versions": f("array", nonempty=True), "file": f("string", local_file=True), "file_url": f("string"),
|
|
563
|
+
}, ("module_id", "name", "type", "game_versions", "file")))
|
|
564
|
+
register("blackbox", "version", "edit", required({
|
|
565
|
+
"version_id": f("integer"), "module_id": f("integer"), "name": f("string", nonempty=True),
|
|
566
|
+
"type": f("integer", choices=(1, 2, 3)), "game_versions": f("array", nonempty=True),
|
|
567
|
+
"file": f("string", local_file=True), "file_url": f("string"),
|
|
568
|
+
}, ("version_id", "module_id", "name", "type", "game_versions")))
|
|
569
|
+
register("blackbox", "version", "delete", required({
|
|
570
|
+
"version_id": f("integer"), "module_id": f("integer"),
|
|
571
|
+
}, ("version_id", "module_id")))
|
|
572
|
+
|
|
555
573
|
|
|
556
574
|
def get_schema(platform: str, resource: str, action: str) -> Schema:
|
|
557
575
|
try:
|
package/npm/bin/fupload.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { ensureCurseForgeEnv } from "../lib/curseforge-config.mjs";
|
|
7
7
|
import { recordManagedSkill } from "../lib/managed-install.mjs";
|
|
8
8
|
import { parseLauncherOptions, resolveSkillDirectory } from "../lib/options.mjs";
|
|
9
|
-
import {
|
|
9
|
+
import { ensurePythonRuntime, runPython } from "../lib/python.mjs";
|
|
10
10
|
import { ensureSkill } from "../lib/skill-installer.mjs";
|
|
11
11
|
import { uninstallSelf } from "../lib/uninstall.mjs";
|
|
12
12
|
import { updateSelf } from "../lib/update.mjs";
|
|
@@ -65,16 +65,18 @@ async function main() {
|
|
|
65
65
|
return 0;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
let runtime;
|
|
69
|
+
try {
|
|
70
|
+
runtime = ensurePythonRuntime({ packageRoot });
|
|
71
|
+
} catch (error) {
|
|
70
72
|
emitError(
|
|
71
|
-
"
|
|
72
|
-
|
|
73
|
+
"PYTHON_RUNTIME_SETUP_FAILED",
|
|
74
|
+
error.message,
|
|
73
75
|
);
|
|
74
76
|
return 1;
|
|
75
77
|
}
|
|
76
78
|
const script = path.join(target, "scripts", "fupload.py");
|
|
77
|
-
const result = runPython(python, script, options.forwarded);
|
|
79
|
+
const result = runPython(runtime.python, script, options.forwarded);
|
|
78
80
|
if (result.error) {
|
|
79
81
|
emitError("PYTHON_LAUNCH_FAILED", result.error.message);
|
|
80
82
|
return 1;
|
|
@@ -6,11 +6,15 @@ export const MANAGED_INSTALL_SCHEMA = "fupload.npm-managed-install.v1";
|
|
|
6
6
|
export const MANAGED_INSTALL_FILE = "managed-install.json";
|
|
7
7
|
const PACKAGE_NAME = "@follenfang/fupload";
|
|
8
8
|
|
|
9
|
-
function
|
|
9
|
+
export function productStateRoot({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
|
|
10
10
|
if (platform === "win32") {
|
|
11
|
-
return path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Fupload"
|
|
11
|
+
return path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Fupload");
|
|
12
12
|
}
|
|
13
|
-
return path.join(env.XDG_STATE_HOME || path.join(home, ".local", "state"), "fupload"
|
|
13
|
+
return path.join(env.XDG_STATE_HOME || path.join(home, ".local", "state"), "fupload");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function stateRoot(options = {}) {
|
|
17
|
+
return path.join(productStateRoot(options), "npm");
|
|
14
18
|
}
|
|
15
19
|
|
|
16
20
|
function atomicWriteJson(filename, value) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
cos-python-sdk-v5==1.9.44
|
package/npm/lib/python.mjs
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import { productStateRoot } from "./managed-install.mjs";
|
|
8
|
+
|
|
9
|
+
export const PYTHON_RUNTIME_SCHEMA = "fupload.python-runtime.v1";
|
|
10
|
+
const RUNTIME_DIRECTORY = "python";
|
|
11
|
+
const RUNTIME_MARKER = "runtime.json";
|
|
12
|
+
const RUNTIME_LOCK_WAIT_MS = 5 * 60 * 1000;
|
|
13
|
+
const RUNTIME_LOCK_STALE_MS = 15 * 60 * 1000;
|
|
2
14
|
|
|
3
15
|
function probe(command, args, minimum) {
|
|
4
16
|
const result = spawnSync(command, [...args, "-c", "import sys; print('.'.join(map(str, sys.version_info[:3])))"], {
|
|
@@ -43,3 +55,218 @@ export function runPython(python, script, args, options = {}) {
|
|
|
43
55
|
windowsHide: true,
|
|
44
56
|
});
|
|
45
57
|
}
|
|
58
|
+
|
|
59
|
+
export function pythonRequirementsFile(packageRoot) {
|
|
60
|
+
return path.join(packageRoot, "npm", "lib", "python-requirements.txt");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function pythonRuntimeRoot(options = {}) {
|
|
64
|
+
return path.join(productStateRoot(options), RUNTIME_DIRECTORY);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function pythonRuntimeExecutable(root, platform = process.platform) {
|
|
68
|
+
return platform === "win32"
|
|
69
|
+
? path.join(root, "Scripts", "python.exe")
|
|
70
|
+
: path.join(root, "bin", "python");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sha256(buffer) {
|
|
74
|
+
return crypto.createHash("sha256").update(buffer).digest("hex");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function readMarker(root) {
|
|
78
|
+
try {
|
|
79
|
+
return JSON.parse(fs.readFileSync(path.join(root, RUNTIME_MARKER), "utf8"));
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function probeRuntime(executable, run = spawnSync) {
|
|
86
|
+
if (!fs.existsSync(executable)) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const result = run(executable, [
|
|
90
|
+
"-c",
|
|
91
|
+
"import importlib.metadata,sys; import qcloud_cos; print('.'.join(map(str,sys.version_info[:3]))); print(importlib.metadata.version('cos-python-sdk-v5'))",
|
|
92
|
+
], { encoding: "utf8", shell: false, windowsHide: true });
|
|
93
|
+
if (result.status !== 0) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
const [pythonVersion, dependencyVersion] = result.stdout.trim().split(/\r?\n/);
|
|
97
|
+
const match = pythonVersion?.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
|
98
|
+
if (!match || !dependencyVersion) {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
return { version: match.slice(1).map(Number), dependencyVersion };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function inspectRuntime({ root, platform, requirementsHash, run }) {
|
|
105
|
+
const marker = readMarker(root);
|
|
106
|
+
if (marker?.schema !== PYTHON_RUNTIME_SCHEMA || marker.requirements_sha256 !== requirementsHash) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const command = pythonRuntimeExecutable(root, platform);
|
|
110
|
+
const probe = probeRuntime(command, run);
|
|
111
|
+
if (!probe || probe.version[0] !== 3 || probe.version[1] < 9) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
return { command, args: [], version: probe.version, dependencyVersion: probe.dependencyVersion };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function bounded(value) {
|
|
118
|
+
return String(value || "").trim().slice(0, 4000);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function runChecked(run, command, args, message) {
|
|
122
|
+
const result = run(command, args, {
|
|
123
|
+
encoding: "utf8",
|
|
124
|
+
shell: false,
|
|
125
|
+
windowsHide: true,
|
|
126
|
+
});
|
|
127
|
+
if (result.error || result.status !== 0) {
|
|
128
|
+
const detail = bounded(result.stderr || result.stdout || result.error?.message);
|
|
129
|
+
throw new Error(`${message}${detail ? `: ${detail}` : ""}`);
|
|
130
|
+
}
|
|
131
|
+
return result;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function writeMarker(root, value) {
|
|
135
|
+
fs.writeFileSync(path.join(root, RUNTIME_MARKER), `${JSON.stringify(value, null, 2)}\n`, {
|
|
136
|
+
encoding: "utf8",
|
|
137
|
+
mode: 0o600,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function sleepSync(milliseconds) {
|
|
142
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function acquireRuntimeLock(root) {
|
|
146
|
+
const lock = `${root}.lock`;
|
|
147
|
+
fs.mkdirSync(path.dirname(root), { recursive: true });
|
|
148
|
+
const deadline = Date.now() + RUNTIME_LOCK_WAIT_MS;
|
|
149
|
+
while (Date.now() < deadline) {
|
|
150
|
+
try {
|
|
151
|
+
const descriptor = fs.openSync(lock, "wx", 0o600);
|
|
152
|
+
fs.writeFileSync(descriptor, `${JSON.stringify({ pid: process.pid })}\n`);
|
|
153
|
+
return {
|
|
154
|
+
release() {
|
|
155
|
+
fs.closeSync(descriptor);
|
|
156
|
+
fs.rmSync(lock, { force: true });
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
} catch (error) {
|
|
160
|
+
if (error.code !== "EEXIST") {
|
|
161
|
+
throw error;
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
if (Date.now() - fs.statSync(lock).mtimeMs > RUNTIME_LOCK_STALE_MS) {
|
|
165
|
+
fs.rmSync(lock, { force: true });
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
} catch (statError) {
|
|
169
|
+
if (statError.code !== "ENOENT") {
|
|
170
|
+
throw statError;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
sleepSync(50);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
throw new Error(`Timed out waiting for the Fuploader Python runtime lock: ${lock}`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function ensurePythonRuntime({
|
|
180
|
+
packageRoot,
|
|
181
|
+
platform = process.platform,
|
|
182
|
+
env = process.env,
|
|
183
|
+
home = os.homedir(),
|
|
184
|
+
run = spawnSync,
|
|
185
|
+
discover = discoverPython,
|
|
186
|
+
} = {}) {
|
|
187
|
+
const requirements = pythonRequirementsFile(packageRoot);
|
|
188
|
+
const content = fs.readFileSync(requirements);
|
|
189
|
+
const requirementsHash = sha256(content);
|
|
190
|
+
const root = pythonRuntimeRoot({ platform, env, home });
|
|
191
|
+
const parent = path.dirname(root);
|
|
192
|
+
const lock = acquireRuntimeLock(root);
|
|
193
|
+
try {
|
|
194
|
+
const current = inspectRuntime({ root, platform, requirementsHash, run });
|
|
195
|
+
if (current) {
|
|
196
|
+
return { status: "current", root, requirements, python: current };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const base = discover({ platform });
|
|
200
|
+
if (!base) {
|
|
201
|
+
throw new Error("Fuploader requires Python >=3.9,<4.0 to create its managed runtime.");
|
|
202
|
+
}
|
|
203
|
+
const nonce = `${process.pid}-${crypto.randomBytes(8).toString("hex")}`;
|
|
204
|
+
const staging = path.join(parent, `.python-stage-${nonce}`);
|
|
205
|
+
const backup = path.join(parent, `.python-backup-${nonce}`);
|
|
206
|
+
fs.mkdirSync(parent, { recursive: true });
|
|
207
|
+
let movedOld = false;
|
|
208
|
+
try {
|
|
209
|
+
runChecked(run, base.command, [...base.args, "-m", "venv", staging], "Could not create the Fuploader Python runtime");
|
|
210
|
+
const stagingPython = pythonRuntimeExecutable(staging, platform);
|
|
211
|
+
runChecked(run, stagingPython, [
|
|
212
|
+
"-m", "pip", "install", "--disable-pip-version-check", "--no-input", "--requirement", requirements,
|
|
213
|
+
], "Could not install Fuploader Python dependencies");
|
|
214
|
+
const installed = probeRuntime(stagingPython, run);
|
|
215
|
+
if (!installed) {
|
|
216
|
+
throw new Error("The Fuploader Python runtime did not pass its dependency probe.");
|
|
217
|
+
}
|
|
218
|
+
writeMarker(staging, {
|
|
219
|
+
schema: PYTHON_RUNTIME_SCHEMA,
|
|
220
|
+
requirements_sha256: requirementsHash,
|
|
221
|
+
python_version: installed.version.join("."),
|
|
222
|
+
dependency_version: installed.dependencyVersion,
|
|
223
|
+
});
|
|
224
|
+
if (fs.existsSync(root)) {
|
|
225
|
+
fs.renameSync(root, backup);
|
|
226
|
+
movedOld = true;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
fs.renameSync(staging, root);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (movedOld && !fs.existsSync(root)) {
|
|
232
|
+
fs.renameSync(backup, root);
|
|
233
|
+
movedOld = false;
|
|
234
|
+
}
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
if (movedOld) {
|
|
238
|
+
fs.rmSync(backup, { recursive: true, force: true });
|
|
239
|
+
}
|
|
240
|
+
} finally {
|
|
241
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
242
|
+
}
|
|
243
|
+
const created = inspectRuntime({ root, platform, requirementsHash, run });
|
|
244
|
+
if (!created) {
|
|
245
|
+
throw new Error("The installed Fuploader Python runtime failed final validation.");
|
|
246
|
+
}
|
|
247
|
+
return { status: "installed", root, requirements, python: created };
|
|
248
|
+
} finally {
|
|
249
|
+
lock.release();
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function removePythonRuntime({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
|
|
254
|
+
const productRoot = productStateRoot({ platform, env, home });
|
|
255
|
+
const root = pythonRuntimeRoot({ platform, env, home });
|
|
256
|
+
const lock = acquireRuntimeLock(root);
|
|
257
|
+
try {
|
|
258
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
259
|
+
} finally {
|
|
260
|
+
lock.release();
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
if (fs.readdirSync(productRoot).length === 0) {
|
|
264
|
+
fs.rmdirSync(productRoot);
|
|
265
|
+
}
|
|
266
|
+
} catch (error) {
|
|
267
|
+
if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY") {
|
|
268
|
+
throw error;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return { root, status: "removed" };
|
|
272
|
+
}
|
package/npm/lib/uninstall.mjs
CHANGED
|
@@ -5,6 +5,7 @@ import path from "node:path";
|
|
|
5
5
|
|
|
6
6
|
import { clearManagedInstall, readManagedInstall } from "./managed-install.mjs";
|
|
7
7
|
import { resolveSkillDirectory } from "./options.mjs";
|
|
8
|
+
import { removePythonRuntime } from "./python.mjs";
|
|
8
9
|
import { INSTALL_STAMP, INSTALL_STAMP_SCHEMA, readInstallStamp } from "./skill-installer.mjs";
|
|
9
10
|
|
|
10
11
|
export const PACKAGE_NAME = "@follenfang/fupload";
|
|
@@ -173,6 +174,7 @@ export async function uninstallSelf({
|
|
|
173
174
|
env = process.env,
|
|
174
175
|
home = os.homedir(),
|
|
175
176
|
runNpm = runNpmCli,
|
|
177
|
+
removeRuntime = removePythonRuntime,
|
|
176
178
|
} = {}) {
|
|
177
179
|
const installation = resolveGlobalInstall(packageRoot, platform);
|
|
178
180
|
const cleanup = await cleanupManagedSkills({
|
|
@@ -181,6 +183,15 @@ export async function uninstallSelf({
|
|
|
181
183
|
home,
|
|
182
184
|
extraTargets: target ? [target] : [],
|
|
183
185
|
});
|
|
186
|
+
let runtime;
|
|
187
|
+
try {
|
|
188
|
+
runtime = removeRuntime({ platform, env, home });
|
|
189
|
+
} catch (error) {
|
|
190
|
+
throw new ManagedUninstallError("The managed Fuploader Python runtime could not be removed.", {
|
|
191
|
+
cleanup,
|
|
192
|
+
reason: error.message,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
184
195
|
const args = ["uninstall", "-g", "--ignore-scripts", "--prefix", installation.prefix, PACKAGE_NAME];
|
|
185
196
|
const npmResult = runNpm(args, { platform, env });
|
|
186
197
|
if (npmResult.error || npmResult.status !== 0) {
|
|
@@ -206,6 +217,7 @@ export async function uninstallSelf({
|
|
|
206
217
|
package: PACKAGE_NAME,
|
|
207
218
|
prefix: installation.prefix,
|
|
208
219
|
cleanup,
|
|
220
|
+
python_runtime: runtime,
|
|
209
221
|
npm_exit_status: npmResult.status,
|
|
210
222
|
};
|
|
211
223
|
}
|
package/npm/lib/update.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import path from "node:path";
|
|
|
4
4
|
import { ensureCurseForgeEnv } from "./curseforge-config.mjs";
|
|
5
5
|
import { readManagedInstall, recordManagedSkill } from "./managed-install.mjs";
|
|
6
6
|
import { resolveSkillDirectory } from "./options.mjs";
|
|
7
|
+
import { ensurePythonRuntime } from "./python.mjs";
|
|
7
8
|
import { ensureSkill, loadDistribution } from "./skill-installer.mjs";
|
|
8
9
|
import { PACKAGE_NAME, inspectManagedSkill, resolveGlobalInstall, runNpmCli } from "./uninstall.mjs";
|
|
9
10
|
|
|
@@ -27,6 +28,7 @@ export async function updateSelf({
|
|
|
27
28
|
env = process.env,
|
|
28
29
|
home = os.homedir(),
|
|
29
30
|
runNpm = runNpmCli,
|
|
31
|
+
ensureRuntime = ensurePythonRuntime,
|
|
30
32
|
} = {}) {
|
|
31
33
|
const curseforgeConfig = ensureCurseForgeEnv({ home, platform });
|
|
32
34
|
const installation = resolveGlobalInstall(packageRoot, platform);
|
|
@@ -57,6 +59,16 @@ export async function updateSelf({
|
|
|
57
59
|
reason: error.message,
|
|
58
60
|
});
|
|
59
61
|
}
|
|
62
|
+
let runtime;
|
|
63
|
+
try {
|
|
64
|
+
runtime = ensureRuntime({ packageRoot, platform, env, home });
|
|
65
|
+
} catch (error) {
|
|
66
|
+
throw new ManagedUpdateError("The npm CLI updated but its Python runtime could not be synchronized.", {
|
|
67
|
+
from_version: current.packageRecord.version,
|
|
68
|
+
to_version: updated.packageRecord.version,
|
|
69
|
+
reason: error.message,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
60
72
|
const registry = readManagedInstall({ platform, env, home });
|
|
61
73
|
const targets = [
|
|
62
74
|
...new Set([
|
|
@@ -97,6 +109,7 @@ export async function updateSelf({
|
|
|
97
109
|
to_version: updated.packageRecord.version,
|
|
98
110
|
npm_exit_status: npmResult.status,
|
|
99
111
|
curseforge_config: curseforgeConfig,
|
|
112
|
+
python_runtime: { status: runtime.status, root: runtime.root },
|
|
100
113
|
skills,
|
|
101
114
|
};
|
|
102
115
|
}
|
package/npm/postinstall.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import { ensureCurseForgeEnv } from "./lib/curseforge-config.mjs";
|
|
5
5
|
import { recordManagedSkill } from "./lib/managed-install.mjs";
|
|
6
6
|
import { resolveSkillDirectory } from "./lib/options.mjs";
|
|
7
|
+
import { ensurePythonRuntime } from "./lib/python.mjs";
|
|
7
8
|
import { ensureSkill } from "./lib/skill-installer.mjs";
|
|
8
9
|
|
|
9
10
|
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
@@ -11,11 +12,13 @@ const target = resolveSkillDirectory();
|
|
|
11
12
|
|
|
12
13
|
try {
|
|
13
14
|
const config = ensureCurseForgeEnv();
|
|
15
|
+
const runtime = ensurePythonRuntime({ packageRoot });
|
|
14
16
|
const result = await ensureSkill({ packageRoot, target });
|
|
15
17
|
recordManagedSkill(target);
|
|
16
18
|
process.stdout.write(`Fuploader Skill ${result.status}: ${target}\n`);
|
|
17
19
|
process.stdout.write(`CurseForge configuration ${config.status}: ${config.path}\n`);
|
|
20
|
+
process.stdout.write(`Python runtime ${runtime.status}: ${runtime.root}\n`);
|
|
18
21
|
} catch (error) {
|
|
19
|
-
process.stderr.write(`Fuploader
|
|
22
|
+
process.stderr.write(`Fuploader installation failed: ${error.message}\n`);
|
|
20
23
|
process.exitCode = 1;
|
|
21
24
|
}
|
package/npm/skill-manifest.json
CHANGED
|
@@ -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.
|
|
5
|
-
"skill_version": "0.0.
|
|
6
|
-
"tree_sha256": "
|
|
4
|
+
"package_version": "0.0.5",
|
|
5
|
+
"skill_version": "0.0.5",
|
|
6
|
+
"tree_sha256": "0a6d0f91197fbaaff3266c7b78f6f894eb8eac38bdf899581909344393d79afd",
|
|
7
7
|
"files": [
|
|
8
8
|
{
|
|
9
9
|
"path": "agents/openai.yaml",
|
|
@@ -75,6 +75,11 @@
|
|
|
75
75
|
"bytes": 168,
|
|
76
76
|
"sha256": "40491e9aeddc71a4e9578264e5c42b40613e0ea5f0321da29e170b84ff9f1905"
|
|
77
77
|
},
|
|
78
|
+
{
|
|
79
|
+
"path": "references/blackbox.md",
|
|
80
|
+
"bytes": 2357,
|
|
81
|
+
"sha256": "022563a7611a5877761df71074ed347ef51fd27f735da7926cbb96d3b781dcc6"
|
|
82
|
+
},
|
|
78
83
|
{
|
|
79
84
|
"path": "references/curseforge.md",
|
|
80
85
|
"bytes": 11917,
|
|
@@ -103,12 +108,22 @@
|
|
|
103
108
|
{
|
|
104
109
|
"path": "scripts/fupload_cli/__init__.py",
|
|
105
110
|
"bytes": 49,
|
|
106
|
-
"sha256": "
|
|
111
|
+
"sha256": "219d361d9b7f13dc9ff7f9eab399b0696fd3de9adcd6ecae755f5724262f12e6"
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"path": "scripts/fupload_cli/blackbox_auth.py",
|
|
115
|
+
"bytes": 2874,
|
|
116
|
+
"sha256": "e018a33dce4babd1dc739af68c58499412474c7e1f1e3c0640dc53dbe46fce29"
|
|
117
|
+
},
|
|
118
|
+
{
|
|
119
|
+
"path": "scripts/fupload_cli/blackbox.py",
|
|
120
|
+
"bytes": 12499,
|
|
121
|
+
"sha256": "5b46c38f8c5f8a352f261f2c3751b51f16827f62410d2124e0147073e8308350"
|
|
107
122
|
},
|
|
108
123
|
{
|
|
109
124
|
"path": "scripts/fupload_cli/cli.py",
|
|
110
|
-
"bytes":
|
|
111
|
-
"sha256": "
|
|
125
|
+
"bytes": 25664,
|
|
126
|
+
"sha256": "d1327783aa0e4e27fcc1f71a80cd090f661feaf2e226688b2efa6a36b9645073"
|
|
112
127
|
},
|
|
113
128
|
{
|
|
114
129
|
"path": "scripts/fupload_cli/curseforge.py",
|
|
@@ -152,8 +167,8 @@
|
|
|
152
167
|
},
|
|
153
168
|
{
|
|
154
169
|
"path": "scripts/fupload_cli/schema.py",
|
|
155
|
-
"bytes":
|
|
156
|
-
"sha256": "
|
|
170
|
+
"bytes": 36268,
|
|
171
|
+
"sha256": "db788d2422c0f6b50ce2795a3df75d5bf1e23934ba4211a64198de06931492a5"
|
|
157
172
|
},
|
|
158
173
|
{
|
|
159
174
|
"path": "scripts/fupload_cli/transport.py",
|
|
@@ -172,8 +187,8 @@
|
|
|
172
187
|
},
|
|
173
188
|
{
|
|
174
189
|
"path": "SKILL.md",
|
|
175
|
-
"bytes":
|
|
176
|
-
"sha256": "
|
|
190
|
+
"bytes": 22890,
|
|
191
|
+
"sha256": "937bbee45d18e8ef257a8071f63951aede681628bb951e40793de81d763f85f3"
|
|
177
192
|
}
|
|
178
193
|
]
|
|
179
194
|
}
|