@follenfang/fupload 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,12 +1,13 @@
1
1
  # Fuploader
2
2
 
3
- Fuploader 是一个面向 Agent 的《魔兽世界》作者发布 Skill 和 CLI。npm 安装后统一使用 `fupload` 命令;命令内部运行随 Skill 分发的纯 Python 实现。新手盒子(NewBeeBox)默认优先使用官方 `ncc` CLI,用户显式要求第三方管理工具时才使用 Fuploader 通道;网易 DD 使用 Fuploader 调用官方无头客户端。
3
+ Fuploader 是一个面向 Agent 的《魔兽世界》作者发布 Skill 和 CLI。npm 安装后统一使用 `fupload` 命令;命令内部运行随 Skill 分发的纯 Python 实现。新手盒子(NewBeeBox)默认优先使用官方 `ncc` CLI,用户显式要求第三方管理工具时才使用 Fuploader 通道;网易 DD 使用 Fuploader 调用官方无头客户端;CurseForge 使用 Core API 查询作者公开项目,并使用 Authors Upload API 上传插件 ZIP。
4
4
 
5
5
  项目强调显式调用、完整字段收集、写入前确认和写入后读回验证。CLI 只负责单次原子读写,业务选择、执行计划和异常恢复由 Agent 在对话中完成。
6
6
 
7
7
  ## 功能
8
8
 
9
- - 支持新手盒子官方 `ncc`、第三方 Python 管理通道和网易 DD。
9
+ - 支持新手盒子官方 `ncc`、第三方 Python 管理通道、网易 DD 和 CurseForge
10
+ - CurseForge 支持按作者 ID 查询 WoW 公开项目、读取上传用游戏版本,以及向已有项目上传插件 ZIP;不创建 Project,也不枚举私有、草稿或待审 Project。
10
11
  - 支持插件、配置分享、WA/字符串的创建、内容更新与元数据编辑。
11
12
  - 官方通道严格采用已安装 `ncc` 暴露的能力;第三方 Python 通道覆盖版本、游戏分支、分类、媒体、可见性、审核、商业设置、频道、关联内容和配置备份选择等页面字段。
12
13
  - 第三方 Python 以 Creator Center 网页的请求和表单状态为基准,编辑和更新采用 `GET -> 动态选项查询 -> presence-aware patch -> 写入 -> 读回`;关联作者/内容在主记录读回后替换并再次读回。官方通道采用 `ncc` 文档规定的只读查询、写入和读回命令。
@@ -54,6 +55,16 @@ fupload --help
54
55
 
55
56
  全局安装会同时创建 `fupload` 命令,并把版本匹配的 Skill 原子安装到 `~/.agents/skills/fupload/`。可用 `FUPLOAD_AGENT_HOME` 把默认位置改为 `<home>/skills/fupload`,或在单次命令中使用 `--skill-dir <完整路径>`;所有成功管理过的 Skill 路径都会登记,供完整卸载使用。
56
57
 
58
+ 安装和 `fupload update` 还会幂等创建缺失的 `~/.fupload/curseforge.env`;已有文件逐字节保留,不覆盖或补写其中的值:
59
+
60
+ ```dotenv
61
+ CURSEFORGE_AUTHOR_ID=
62
+ CURSEFORGE_API_KEY=
63
+ CURSEFORGE_UPLOAD_TOKEN=
64
+ ```
65
+
66
+ `CURSEFORGE_AUTHOR_ID` 是可在对话中提供的非秘密数字 ID;Core API Key 与 Upload Token 是两套不同秘密。请只在本机 env 文件或进程环境中填写秘密,不要粘贴到 Agent 对话、命令参数、项目文件或 Git。
67
+
57
68
  ## 作为 Skill 使用
58
69
 
59
70
  安装后显式调用:
@@ -62,7 +73,7 @@ fupload --help
62
73
  $fupload
63
74
  ```
64
75
 
65
- 该 Skill 不会因普通提及“发布”“新手盒子”或“DD”而自动触发。Agent 会先询问平台、资源和动作。新手盒子会自动检测 `ncc`:已安装即默认使用官方通道;未安装时先询问是否安装;只有用户显式选择第三方 Python 管理工具才进入项目内 CLI。Agent 在被发布项目中创建 `publish/<时间>-<平台>-<资源>-<动作>/`,同一次发布的脱敏 JSON 按原子步骤保存为 `01-<动作>.json`、`02-<动作>.json`。展示完整写入计划并得到确认后才会真实写入。
76
+ 该 Skill 不会因普通提及“发布”“新手盒子”“DD”或“CurseForge”而自动触发。Agent 会先询问平台、资源和动作。新手盒子会自动检测 `ncc`:已安装即默认使用官方通道;未安装时先询问是否安装;只有用户显式选择第三方 Python 管理工具才进入项目内 CLI。CurseForge 上传缺少作者 ID 时,Agent 会主动询问;缺少 API Key 或 Upload Token 时,只会引导用户在本机填写 `~/.fupload/curseforge.env`,不会要求在对话中提供秘密。Agent 在被发布项目中创建 `publish/<时间>-<平台>-<资源>-<动作>/`,同一次发布的脱敏 JSON 按原子步骤保存为 `01-<动作>.json`、`02-<动作>.json`。展示完整写入计划并得到确认后才会真实写入。
66
77
 
67
78
  ## CLI 使用
68
79
 
@@ -77,7 +88,7 @@ ncc whoami -o json
77
88
 
78
89
  创建令牌和本机登录说明见[官方 CLI 文档](https://creator.newbeebox.com/cli-docs)。不要把令牌粘贴到 Agent 对话;在自己的终端完成 `ncc login`。
79
90
 
80
- 以下为 Fuploader 第三方 NewBeeBox 与 DD 执行层。
91
+ 以下为 Fuploader 第三方 NewBeeBox、DDCurseForge 执行层。
81
92
 
82
93
  查看总帮助:
83
94
 
@@ -85,6 +96,22 @@ ncc whoami -o json
85
96
  fupload --help
86
97
  ```
87
98
 
99
+ 检查 CurseForge 配置、查询作者公开项目和读取游戏版本:
100
+
101
+ ```powershell
102
+ fupload curseforge session doctor
103
+ fupload curseforge project list
104
+ fupload curseforge project list --author-id 138844367
105
+ fupload curseforge plugin game-versions
106
+ ```
107
+
108
+ 上传前先执行本地 dry-run;确认完整计划后再移除 `--dry-run`:
109
+
110
+ ```powershell
111
+ fupload curseforge plugin upload --input fupload\examples\curseforge-plugin-upload.json --dry-run
112
+ fupload curseforge plugin upload --input publish\20260807-120000-curseforge-plugin-upload\01-upload.json
113
+ ```
114
+
88
115
  查看具体操作的可执行字段契约:
89
116
 
90
117
  ```powershell
@@ -134,7 +161,7 @@ fupload uninstall
134
161
 
135
162
  `fupload update` 固定安装 `@follenfang/fupload@latest`,随后把默认位置和所有登记有效的自定义 Skill 同步到同一版本。未知目录不会被覆盖。
136
163
 
137
- `fupload uninstall` 先删除所有仍有有效 npm 管理标记的 Fuploader Skill,再调用 npm 删除 `@follenfang/fupload` 和 `fupload` 命令。Skill 清理失败时 npm 包保持可用,处理占用或权限问题后可重试。项目中的 `publish/`、新手盒子/DD 登录数据和 DD 日志始终保留。
164
+ `fupload uninstall` 先删除所有仍有有效 npm 管理标记的 Fuploader Skill,再调用 npm 删除 `@follenfang/fupload` 和 `fupload` 命令。Skill 清理失败时 npm 包保持可用,处理占用或权限问题后可重试。项目中的 `publish/`、新手盒子/DD 登录数据、DD 日志和 `~/.fupload/curseforge.env` 始终保留。
138
165
 
139
166
  npm 7 及以上不执行卸载 lifecycle,因此直接运行 `npm uninstall -g @follenfang/fupload` 只删除 npm 包和 CLI,可能留下 Skill;它不属于完整卸载流程。
140
167
 
@@ -154,6 +181,7 @@ npm 7 及以上不执行卸载 lifecycle,因此直接运行 `npm uninstall -g
154
181
  - [新手盒子官方 CLI 完整参考](fupload/references/newbee-official-cli.md)
155
182
  - [新手盒子第三方 Python 字段参考](fupload/references/newbee.md)
156
183
  - [网易 DD 字段参考](fupload/references/dd.md)
184
+ - [CurseForge API、字段与上传参考](fupload/references/curseforge.md)
157
185
 
158
186
  ## 测试
159
187
 
@@ -166,7 +194,7 @@ python -m unittest discover -s fupload\scripts\tests
166
194
  python -m compileall -q fupload\scripts
167
195
  ```
168
196
 
169
- 当前回归测试覆盖 CLI 路由、严格 JSON、字段 Schema、双平台 builder、动态选项校验、字段保留与清空语义,以及所有内置示例的 dry-run。
197
+ 当前回归测试覆盖 CLI 路由、严格 JSON、字段 Schema、平台 builder、动态选项校验、字段保留与清空语义,以及所有内置示例的 dry-run。
170
198
 
171
199
  DD 逐字段 wire 矩阵可单独运行并生成本地审计报告:
172
200
 
@@ -184,7 +212,7 @@ python fupload\scripts\tests\generate_dd_wire_matrix_report.py > analyze\dd-fiel
184
212
  - `fupload/scripts/fupload_cli/__init__.py` 的 `__version__`
185
213
  - `npm/skill-manifest.json` 的包版本与 Skill 版本
186
214
 
187
- 当前初版为 `0.0.1`,对应 tag `v0.0.1`。`.github/workflows/publish-npm.yml` 在 pull request、`main` push 和 `v*` tag 上运行 Windows/Linux CI;只有 tag CI 全部通过时才通过 npm Trusted Publishing/OIDC 发布,不使用长期 `NPM_TOKEN`。
215
+ 当前版本为 `0.0.2`,对应 tag `v0.0.2`。`.github/workflows/publish-npm.yml` 在 pull request、`main` push 和 `v*` tag 上运行 Windows/Linux CI;只有 tag CI 全部通过时才通过 npm Trusted Publishing/OIDC 发布,不使用长期 `NPM_TOKEN`。
188
216
 
189
217
  发布前在 npm 包设置中绑定 Trusted Publisher:
190
218
 
@@ -197,12 +225,12 @@ python fupload\scripts\tests\generate_dd_wire_matrix_report.py > analyze\dd-fiel
197
225
 
198
226
  ```powershell
199
227
  npm run check:versions
200
- npm run check:release -- v0.0.1
201
- git tag -a v0.0.1 -m "Fuploader 0.0.1"
202
- git push origin v0.0.1
228
+ npm run check:release -- v0.0.2
229
+ git tag -a v0.0.2 -m "Fuploader 0.0.2"
230
+ git push origin v0.0.2
203
231
  ```
204
232
 
205
- npm 对首次创建的包可能要求先由账号所有者完成一次 bootstrap 发布;该步骤只用于建立包身份,正式 `latest` 仍由 `v0.0.1` workflow 发布。需要人工处理时,不要把 npm token 写入仓库或 GitHub Secret。
233
+ npm 对首次创建的包可能要求先由账号所有者完成一次 bootstrap 发布;该步骤只用于建立包身份,正式 `latest` 由版本 tag workflow 发布。需要人工处理时,不要把 npm token 写入仓库或 GitHub Secret。
206
234
 
207
235
  ## 说明
208
236
 
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 and NetEase DD. 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, plugins, configurations, or WA.
3
+ description: Explicit author-publishing workflow for World of Warcraft plugins, configuration shares, and WA/strings on NewBeeBox, NetEase DD, and CurseForge, including CurseForge author project lookup and plugin ZIP upload. 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, plugins, configurations, or WA.
4
4
  metadata:
5
- version: "0.0.1"
5
+ version: "0.0.2"
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, and for 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, 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,8 +15,8 @@ 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 or DD.
19
- 2. Resource and action: plugin/configuration/WA, then create/content update/metadata edit/delete.
18
+ 1. Platform: NewBeeBox, DD, or CurseForge.
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:
22
22
 
@@ -27,10 +27,14 @@ Map natural language consistently:
27
27
 
28
28
  Never offer bulk delete, version-file delete, drafts, guides, messages, or GUI control automation. The DD task-session command may close verified official GUI processes only after the consent flow below.
29
29
 
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
+
30
32
  ## Select the execution channel
31
33
 
32
34
  For DD, use the bundled Python CLI.
33
35
 
36
+ For CurseForge, use the bundled Python CLI. Do not substitute direct HTTP calls or the CurseForge for Studios API for uploads: public project lookup uses the Core API, while game-version lookup and upload use the separate Upload API and credential.
37
+
34
38
  For NewBeeBox, apply this order exactly:
35
39
 
36
40
  1. If the user explicitly requests the third-party Python management tool, use the bundled Python CLI even when `ncc` is installed.
@@ -44,9 +48,11 @@ Never switch channels silently. Before changing channels, re-read remote state a
44
48
 
45
49
  ## Protect credentials
46
50
 
47
- Prefer an existing official `ncc login`, then a caller-provided `NCC_TOKEN`, then user-performed local login. Never ask the user to paste a token into the conversation. Never place a real token in `--token`, shell history, command output, JSON, `.env`, `publish/`, `analyze/`, tests, Skill files, references, or Git. Do not inspect or copy the official CLI credential store.
51
+ For NewBeeBox, prefer an existing official `ncc login`, then a caller-provided `NCC_TOKEN`, then user-performed local login. Never ask the user to paste a token into the conversation. Never place a real NewBeeBox token in `--token`, shell history, command output, JSON, `.env`, `publish/`, `analyze/`, tests, Skill files, references, or Git. Do not inspect or copy the official CLI credential store.
52
+
53
+ For CurseForge, use `~/.fupload/curseforge.env`, created idempotently by npm install/update, with `CURSEFORGE_AUTHOR_ID`, `CURSEFORGE_API_KEY`, and `CURSEFORGE_UPLOAD_TOKEN`. The author ID is non-secret: if absent, proactively ask for its numeric value or offer `--author-id`. The API key and upload token are secrets: never ask the user to paste either into the conversation; direct them to fill the local env file in their own editor or terminal, then run `curseforge session doctor`. Never print file contents or secret values. Process environment values may override the file without being shown.
48
54
 
49
- If a token is pasted into the conversation, do not repeat or use it. Tell the user to revoke it from the official CLI token page and create a replacement through local login or pre-launch environment injection. Check only whether `NCC_TOKEN` is present, never its length, prefix, hash, or value.
55
+ If a token is pasted into the conversation, do not repeat or use it. Tell the user to revoke or regenerate it at its provider and configure the replacement locally. Check only whether the expected field is present, never its length, prefix, hash, or value.
50
56
 
51
57
  ## Locate the Fuploader CLI
52
58
 
@@ -62,6 +68,7 @@ For npm package maintenance, use `fupload update` to update the CLI and every re
62
68
  - For official NewBeeBox, read [references/newbee-official-cli.md](references/newbee-official-cli.md), then run the installed `ncc docs` and exact leaf `--help`. The bundled reference is a complete official snapshot, but runtime docs win when versions differ.
63
69
  - Read [references/newbee.md](references/newbee.md) only when the user explicitly selected the third-party Python NewBeeBox channel.
64
70
  - Read [references/dd.md](references/dd.md) only for DD.
71
+ - Read [references/curseforge.md](references/curseforge.md) for every CurseForge lookup or upload.
65
72
  - For Python, run the exact leaf command with `--help` before creating its input. Treat help as the executable schema contract.
66
73
 
67
74
  ## Investigate
@@ -70,6 +77,8 @@ Inspect the user-provided project or current workspace for `.toc`, README, chang
70
77
 
71
78
  Use read-only commands from the selected channel to discover the current account's records, target detail, versions, backups, categories, game branches/builds, installation paths, life types, VIP levels, channels, and association candidates. Never ask the user to copy an ID that the selected CLI can query.
72
79
 
80
+ For CurseForge, run `curseforge session doctor`, then `curseforge project list` using `CURSEFORGE_AUTHOR_ID` or the user-provided non-secret `--author-id`. Select a public project by name plus `project_id`. Before a version-tagged upload, run `curseforge plugin game-versions` and select returned IDs or names; do not guess IDs from display names. A parent-file upload instead uses `parent_file_id` and omits both game-version fields. Inspect the ZIP and local release notes. An empty public list does not prove the account has no private, draft, or pending-review projects.
81
+
73
82
  For official NewBeeBox, use the documented `ncc wow addons list|info|categories|versions`, `wa list|info|categories`, `uipack list|info`, `cloudbackup list|info`, and related commands needed by the action. Use the exact options returned by the installed help. `addons push` compatibility uses explicit build strings or documented `auto`; never substitute a parent branch ID. An empty required option list blocks the write.
74
83
 
75
84
  For third-party Python NewBeeBox, use Creator Center webpage requests and form behavior as the business baseline. Always read `newbee options content-origins`, `subscribe-plans`, and `time-ranges` when the corresponding field is present. Also read plugin categories/builds, WA categories, attachment paths, and each resource's `co-author`/`reference` candidates for those fields. Empty option output blocks the write. Plugin compatibility uses build strings from `game-versions.items[].versions`, never the parent branch `id`. Preserve omitted edit fields; an explicit empty `co_authors` or `references` array replaces and clears that complete relation only after the main record readback succeeds.
@@ -96,13 +105,13 @@ For a configuration, require a cloud backup already uploaded by the matching des
96
105
 
97
106
  Expose every business field writable through the selected channel. Do not silently accept a webpage preselection or invent a business default. This includes applicable game type/build, categories, origin, format, visibility, review submission, payment, lifetime, price, room/channel, synchronization, membership, associations, backup content, WTF roles, incremental selections, retail UI data, WA material mode, and install path. When official `ncc` does not expose a webpage field or action, state that capability boundary; do not guess a hidden flag or silently switch to Python.
98
107
 
99
- Use existing remote values only for omitted fields in `edit` or `update`, where omission means preserve. Show candidates by human name plus ID/SN and relevant status. Ask only choices that cannot be determined from the user's explicit request, local artifacts, or remote reads. For DD, do not draft the executable JSON incrementally: close the full dependency graph first, then generate one final JSON containing parent fields and stable child IDs/selectors. Python repeats the live GETs before upload or mutation and rejects missing or cross-parent selections; detail is authoritative when DD list and detail timestamps differ. When an existing plugin or WA has `assign_user_sn`, only public scope is selectable and the final rebuilt form must remain public.
108
+ Use existing remote values only for omitted fields in `edit` or `update`, where omission means preserve. Show candidates by human name plus ID/SN and relevant status. Ask only choices that cannot be determined from the user's explicit request, local artifacts, or remote reads. For CurseForge upload, collect project ID, ZIP path, changelog, release type, version IDs/names or parent file choice, and each requested optional metadata field; do not infer visibility or approval from upload acceptance. For DD, do not draft the executable JSON incrementally: close the full dependency graph first, then generate one final JSON containing parent fields and stable child IDs/selectors. Python repeats the live GETs before upload or mutation and rejects missing or cross-parent selections; detail is authoritative when DD list and detail timestamps differ. When an existing plugin or WA has `assign_user_sn`, only public scope is selectable and the final rebuilt form must remain public.
100
109
 
101
110
  ## Prepare and confirm
102
111
 
103
112
  Create a durable release directory under the target project's root, never under or beside the installed Skill. Use `publish/<YYYYMMDD-HHmmss>-<platform>-<resource>-<action>/`; if that name already exists, append `-2`, `-3`, and so on instead of reusing it. Put every atomic step in a versioned JSON file ordered as `01-<action>.json`, `02-<action>.json`, and so on. A retry or readback for the same plan reuses its directory; a new independent publishing plan gets a new directory. Use JSON, not YAML. Keep the directory after execution as the target project's publishing record, and do not change its ignore rules unless the user asks.
104
113
 
105
- For Python, these are executable `--input` documents using the leaf schema. For official `ncc`, these are redacted plan records containing the channel, working directory, argument vector, non-secret business inputs, local file references, and expected readback; `ncc` does not consume them. Never store a token, raw WA string, raw configuration content, or signed URL in a plan record. Use `@file` or local path references for content. Run `--dry-run` where the selected leaf documents it, especially `ncc wow addons push --dry-run`; do not invent dry-run support for other official commands.
114
+ For Python, these are executable `--input` documents using the leaf schema. For official `ncc`, these are redacted plan records containing the channel, working directory, argument vector, non-secret business inputs, local file references, and expected readback; `ncc` does not consume them. Never store a token, API key, raw WA string, raw configuration content, or signed URL in a plan record. Use `@file` or local path references for content. Run `--dry-run` where the selected leaf documents it, especially `ncc wow addons push --dry-run` and every CurseForge upload; do not invent dry-run support for other official commands.
106
115
 
107
116
  Before the first write, present one complete human-readable plan containing:
108
117
 
@@ -116,12 +125,16 @@ Before the first write, present one complete human-readable plan containing:
116
125
 
117
126
  Obtain one explicit confirmation for that exact plan. If the plan changes materially, confirm the changed plan once.
118
127
 
128
+ For CurseForge, confirmation must name the public project and numeric ID, ZIP path, selected game-version IDs/names or parent file ID, changelog, release type, optional metadata and relations, and whether manual release is requested. After confirmation, execute exactly one upload attempt. A dry-run performs local validation only and is not remote permission or ID validation.
129
+
119
130
  For a Python delete, first run the resource `get` command, show the exact name and ID/SN, and obtain confirmation for that single record. NewBeeBox delete input contains only the schema, `id`, and `confirm: "DELETE"`; DD delete input contains only the schema, `sn`, and `confirm_delete: true`. Do not reuse confirmation for another record and do not retry an uncertain delete. Official `ncc` currently documents plugin, WA, and configuration main-record deletion as web-only; state that boundary and wait for an explicit third-party selection before offering the Python delete command.
120
131
 
121
132
  ## Execute and verify
122
133
 
123
134
  Run writes serially. For official `ncc`, always request `-o json`, parse stdout as JSON, and treat stderr only as progress diagnostics. For Python, parse its stable JSON output. Never scrape human text. After each successful step, immediately run the corresponding info/get/list/versions/history command and compare the intended fields in the same DD session when applicable. DD performs a bounded GET-only readback poll and never resends a mutation during verification. For DD plugin update, use a nonempty `/addon/addon_versions` result only as a pre-upload duplicate guard; the matching author-list item's `latest_version` is the primary success confirmation and `detail_v2` is supplementary. Plugin edit also uses the same-SN author projection when detail remains stale. An empty history is diagnostic-only. Treat “submitted for review” and “under review” as distinct from “approved” or “publicly visible.”
124
135
 
136
+ For CurseForge, treat the Upload API's returned file ID as upload acceptance only. Record the ID and command result, then report that moderation, processing, manual release, and public visibility are separate states. On an interrupted or ambiguous upload, do not resend automatically because that can create a duplicate file; inspect the Authors project page or public file list before deciding on a new attempt. Follow the HTTP/error handling table in the CurseForge reference.
137
+
125
138
  In the third-party Python NewBeeBox channel, public plugin publication is three atomic writes: create privately, upload and verify the first version, then edit to public with explicit review intent. Never send a public `share_state` during Python create. In the official channel, follow the installed `ncc docs` sequence for create, init, push, and visibility instead of applying Python wire rules.
126
139
 
127
140
  Stop on the first failure. Report completed steps, retained IDs/SNs or media references, the redacted failure stage, whether verification is required, and the smallest safe retry. Explicit DD HTTP/business rejection has `verification_required=false`; interrupted PUT/mutation or failed readback after an accepted write has `verification_required=true`. Native DD failures may include a bounded message, HTTP status, native business code, field validation hints, and `details.log_path`. When `log_path` is present, read only the referenced Fuploader JSONL record under the DD version directory; do not inspect DD's other logs or any credential files. The Fuploader record contains sanitized request and response JSON/body plus byte counts and truncation flags, with signed URLs, cookies, JWTs, credentials, client identifiers, signatures, and tokens recursively redacted. Read back after an uncertain write before resending it. Official NewBeeBox exit codes are `0` success, `1` business error, `2` authentication failure, and `3` network error. Never loop on `quota_exceeded`.
@@ -1,4 +1,4 @@
1
1
  interface:
2
2
  display_name: "Fupload"
3
- short_description: "整理并发布新手盒子与网易DD的插件、配置分享及WA字符串"
4
- default_prompt: "使用 $fupload 检查当前项目和远端状态;新手盒子优先检测并使用官方 ncc,显式要求第三方时使用 fupload CLI;整理全部业务选项并在确认后完成发布或更新。"
3
+ short_description: "发布新手盒子、网易DD与CurseForge的魔兽世界作者内容"
4
+ default_prompt: "使用 $fupload 检查当前项目和远端状态;新手盒子优先使用官方 ncc,DD 与 CurseForge 使用 fupload CLI;整理全部业务选项并在确认后完成发布或更新。"
@@ -0,0 +1,21 @@
1
+ {
2
+ "schema": "fupload.v1.curseforge.plugin.upload",
3
+ "project_id": 123456,
4
+ "file": "./addon.zip",
5
+ "changelog": "Release notes for 1.0.0",
6
+ "changelog_type": "markdown",
7
+ "display_name": "Example AddOn 1.0.0",
8
+ "game_versions": [12345],
9
+ "game_version_names": ["Retail"],
10
+ "release_type": "release",
11
+ "relations": {
12
+ "projects": [
13
+ {
14
+ "slug": "example-library",
15
+ "project_id": 74924,
16
+ "type": "requiredDependency"
17
+ }
18
+ ]
19
+ },
20
+ "is_marked_for_manual_release": false
21
+ }
@@ -0,0 +1,233 @@
1
+ # CurseForge reference
2
+
3
+ Use this reference only for CurseForge public author-project lookup and WoW plugin ZIP upload. Fuploader uses two independent official APIs and credentials.
4
+
5
+ ## Contents
6
+
7
+ - [Capability boundary](#capability-boundary)
8
+ - [Local configuration](#local-configuration)
9
+ - [Public project lookup](#public-project-lookup)
10
+ - [Game versions](#game-versions)
11
+ - [Plugin upload](#plugin-upload)
12
+ - [Metadata contract](#metadata-contract)
13
+ - [Relations](#relations)
14
+ - [Plan, dry-run, and confirmation](#plan-dry-run-and-confirmation)
15
+ - [Success, visibility, and verification](#success-visibility-and-verification)
16
+ - [Errors and retry rules](#errors-and-retry-rules)
17
+ - [Official documentation](#official-documentation)
18
+
19
+ ## Capability boundary
20
+
21
+ Fuploader supports:
22
+
23
+ - querying public World of Warcraft projects for one numeric author ID through the CurseForge Core API;
24
+ - listing Upload API game-version choices;
25
+ - uploading one ZIP to an existing World of Warcraft project.
26
+
27
+ It does not create projects, list an authenticated account's private/draft/pending-review projects, update an existing file, or delete a project/file. Create and inspect non-public projects in the [CurseForge Authors dashboard](https://authors.curseforge.com/). An empty Core API result means only that no matching public project was returned.
28
+
29
+ ## Local configuration
30
+
31
+ Use `~/.fupload/curseforge.env`:
32
+
33
+ ```dotenv
34
+ CURSEFORGE_AUTHOR_ID=
35
+ CURSEFORGE_API_KEY=
36
+ CURSEFORGE_UPLOAD_TOKEN=
37
+ ```
38
+
39
+ - `CURSEFORGE_AUTHOR_ID`: non-secret positive integer used by public project lookup. It is an author membership/owner identifier, not an account username or project ID.
40
+ - `CURSEFORGE_API_KEY`: secret CurseForge for Studios/Core API key sent as `x-api-key`.
41
+ - `CURSEFORGE_UPLOAD_TOKEN`: secret Authors Upload API token sent as `X-Api-Token`.
42
+
43
+ The two secrets are not interchangeable. npm install/update creates the directory and template file when missing; an existing file is preserved byte for byte and is never backfilled or overwritten. Process environment values override file values for the current process. Never read, echo, log, copy into `publish/`, or request either secret in chat.
44
+
45
+ Check presence without exposing values:
46
+
47
+ ```powershell
48
+ fupload curseforge session doctor
49
+ ```
50
+
51
+ If the author ID is absent, ask for the numeric ID because it is non-secret, or use `--author-id` for that lookup. If a secret is absent, direct the user to fill the local file and rerun doctor.
52
+
53
+ ## Public project lookup
54
+
55
+ Official request:
56
+
57
+ ```http
58
+ GET https://api.curseforge.com/v1/mods/search?gameId=1&authorId=AUTHOR_ID&index=0&pageSize=50
59
+ Accept: application/json
60
+ x-api-key: CURSEFORGE_API_KEY
61
+ ```
62
+
63
+ `gameId=1` selects World of Warcraft. The Core API defines `authorId` as filtering mods for which that author is a member; `primaryAuthorId` is the separate owner-only filter. `index` is zero-based, `pageSize` defaults to and is capped at 50, and `index + pageSize` cannot exceed 10,000.
64
+
65
+ Fuploader commands:
66
+
67
+ ```powershell
68
+ fupload curseforge project list
69
+ fupload curseforge project list --author-id 138844367
70
+ ```
71
+
72
+ Use `pagination.totalCount` as the count of matching public projects. Select the upload target by human-readable project name and returned numeric project ID. Do not interpret this response as a private account inventory.
73
+
74
+ ## Game versions
75
+
76
+ Official request against the WoW Authors host:
77
+
78
+ ```http
79
+ GET https://wow.curseforge.com/api/game/versions
80
+ X-Api-Token: CURSEFORGE_UPLOAD_TOKEN
81
+ ```
82
+
83
+ The response entries contain `id`, `gameVersionTypeID`, `name`, and `slug`. Use the returned IDs or official names in upload metadata.
84
+
85
+ Fuploader command:
86
+
87
+ ```powershell
88
+ fupload curseforge plugin game-versions
89
+ ```
90
+
91
+ Fetch the current list immediately before planning an upload. Present selected names and IDs together; never derive IDs from version strings.
92
+
93
+ ## Plugin upload
94
+
95
+ Official request:
96
+
97
+ ```http
98
+ POST https://wow.curseforge.com/api/projects/{projectId}/upload-file
99
+ X-Api-Token: CURSEFORGE_UPLOAD_TOKEN
100
+ Content-Type: multipart/form-data; boundary=...
101
+
102
+ metadata=<JSON object>
103
+ file=<ZIP bytes>
104
+ ```
105
+
106
+ The multipart request has the text field `metadata` and binary field `file`. The project ID comes from the existing project's overview URL/API record. On acceptance the official API returns JSON containing the new file `id`.
107
+
108
+ Fuploader accepts the strict schema `fupload.v1.curseforge.plugin.upload`:
109
+
110
+ | Field | Required | Contract |
111
+ | --- | --- | --- |
112
+ | `schema` | yes | Exactly `fupload.v1.curseforge.plugin.upload`. |
113
+ | `project_id` | yes | Positive integer for an existing project. |
114
+ | `file` | yes | Existing local ZIP path. |
115
+ | `changelog` | yes | Release notes string. |
116
+ | `changelog_type` | no | `text`, `html`, or `markdown`; official default is `text`. |
117
+ | `display_name` | no | Non-empty friendly file name. |
118
+ | `game_versions` | no | Non-empty array of numeric IDs from `plugin game-versions`; not supported with `parent_file_id`. |
119
+ | `game_version_names` | no | Array of non-empty official game-version names. |
120
+ | `release_type` | yes | `alpha`, `beta`, or `release`. |
121
+ | `parent_file_id` | no | Positive parent file ID; mutually exclusive with `game_versions` and `game_version_names`. |
122
+ | `relations` | no | Object described below. |
123
+ | `is_marked_for_manual_release` | no | Boolean; when true, approval does not immediately release the file. |
124
+
125
+ Compatibility fields are optional in the official API and executable schema. When supplied, use current values returned by `plugin game-versions`; do not combine `parent_file_id` with either version field. Example: [curseforge-plugin-upload.json](../examples/curseforge-plugin-upload.json).
126
+
127
+ ```powershell
128
+ fupload curseforge plugin upload --input publish\20260807-120000-curseforge-plugin-upload\01-upload.json --dry-run
129
+ fupload curseforge plugin upload --input publish\20260807-120000-curseforge-plugin-upload\01-upload.json
130
+ ```
131
+
132
+ The executable Fuploader JSON uses snake_case. The provider maps it to the official camelCase Upload API metadata keys.
133
+
134
+ ## Metadata contract
135
+
136
+ The official Upload API documents these metadata fields:
137
+
138
+ | Official field | Fuploader field | Meaning and constraints |
139
+ | --- | --- | --- |
140
+ | `changelog` | `changelog` | Change description; HTML or Markdown requires matching `changelogType`. |
141
+ | `changelogType` | `changelog_type` | `text`, `html`, or `markdown`; optional, defaults to `text`. |
142
+ | `displayName` | `display_name` | Optional friendly display name. |
143
+ | `parentFileID` | `parent_file_id` | Optional positive parent file ID. Fuploader rejects it together with `gameVersions` or `gameVersionNames`. |
144
+ | `gameVersions` | `game_versions` | Optional array of numeric game-version IDs; not supported with `parentFileID`. |
145
+ | `gameVersionNames` | `game_version_names` | Optional array of game-version names. |
146
+ | `releaseType` | `release_type` | Required: `alpha`, `beta`, or `release`. |
147
+ | `isMarkedForManualRelease` | `is_marked_for_manual_release` | Optional manual-release flag. |
148
+ | `relations` | `relations` | Optional project dependency relations. |
149
+
150
+ Do not put official camelCase keys directly into Fuploader input; unknown fields are rejected. `project_id` and `file` select the endpoint/body file and are not members of the metadata JSON.
151
+
152
+ ## Relations
153
+
154
+ Fuploader input mirrors the official relation shape while using snake_case for `projectID`:
155
+
156
+ ```json
157
+ {
158
+ "relations": {
159
+ "projects": [
160
+ {
161
+ "slug": "related-project-slug",
162
+ "project_id": 74924,
163
+ "type": "requiredDependency"
164
+ }
165
+ ]
166
+ }
167
+ }
168
+ ```
169
+
170
+ Each item requires non-empty `slug` and `type`. `project_id` is optional and, when supplied, must be a positive integer for an exact project match. Unknown relation keys are rejected. Official relation types are:
171
+
172
+ - `embeddedLibrary`
173
+ - `incompatible`
174
+ - `optionalDependency`
175
+ - `requiredDependency`
176
+ - `tool`
177
+
178
+ The wire mapping is `slug`, optional `projectID`, and `type` inside `relations.projects`. Resolve and show each intended related project during planning; do not guess a slug or ID.
179
+
180
+ ## Plan, dry-run, and confirmation
181
+
182
+ Create `publish/<YYYYMMDD-HHmmss>-curseforge-plugin-upload/01-upload.json`. Store only non-secret business fields and local file paths. Never store the API key or upload token.
183
+
184
+ Run `--dry-run` before confirmation. It validates strict JSON, field values, relation structure, and local file existence without authenticating, querying remote permissions, or uploading.
185
+
186
+ Present one complete plan containing:
187
+
188
+ - author ID and public project name plus project ID;
189
+ - ZIP path and inspected filename;
190
+ - selected game-version names plus IDs, or parent file ID;
191
+ - release type, changelog/type, display name, relations, and manual-release value;
192
+ - the exact `fupload curseforge plugin upload --input ...` command;
193
+ - the effects boundary: acceptance is not approval, release, or public visibility.
194
+
195
+ Obtain one explicit confirmation for that exact plan. If any project, file, version, parent, relation, release type, or manual-release choice changes, present and confirm the changed plan. After confirmation, issue only one upload request.
196
+
197
+ ## Success, visibility, and verification
198
+
199
+ The Upload API success response is a JSON object containing the new file ID. Record that literal ID and exit status. This verifies that CurseForge accepted the upload request, not that moderation completed or the file is publicly visible.
200
+
201
+ Visibility boundaries:
202
+
203
+ - Core project search lists public projects only and has no authenticated current-account inventory.
204
+ - Upload acceptance can precede processing, moderation, approval, manual release, and public visibility.
205
+ - `is_marked_for_manual_release=true` intentionally adds a later author release decision after approval.
206
+ - The public search/list response cannot verify private, draft, or pending-review state.
207
+
208
+ Fuploader does not expose an authenticated uploaded-file status/readback endpoint. Use the returned file ID as the acceptance record and inspect the Authors dashboard when a later state must be confirmed. Do not describe acceptance as publication.
209
+
210
+ ## Errors and retry rules
211
+
212
+ Interpret responses conservatively:
213
+
214
+ | Result | Meaning and action |
215
+ | --- | --- |
216
+ | Local schema/file error | No request was sent. Correct the input and rerun dry-run. |
217
+ | `400`/`422` | Metadata, version, relation, or multipart validation was rejected. Do not retry unchanged input. |
218
+ | `401` | Credential missing, invalid, or expired. Regenerate/configure locally; never request it in chat. |
219
+ | `403` | Token lacks permission for the project/action. Recheck project ownership and token scope locally. |
220
+ | `404` | Host, project, or endpoint was not found. Confirm the WoW host and project ID. |
221
+ | `409` | Treat as a conflicting/duplicate state; inspect the project before another write. |
222
+ | `429` | Rate limited. Honor `Retry-After` when supplied; do not loop. |
223
+ | `5xx` after request transmission | Outcome may be uncertain. Inspect the Authors dashboard before retrying. |
224
+ | Network interruption/timeout during upload | Outcome is uncertain. Never automatically resend; check the project/file list first to prevent duplicates. |
225
+
226
+ Expose only sanitized status, error kind, stage, and provider message. Never include request headers, secret values, or raw multipart bodies in output or logs.
227
+
228
+ ## Official documentation
229
+
230
+ - [CurseForge for Studios introduction](https://docs.curseforge.com/docs/curseforge-for-studios/intro)
231
+ - [CurseForge Core REST API](https://docs.curseforge.com/rest-api/): base URL, `x-api-key`, pagination, and `GET /v1/mods/search` including `authorId`/`primaryAuthorId`.
232
+ - [CurseForge Upload API](https://support.curseforge.com/en/support/solutions/articles/9000197321-curseforge-upload-api): token generation, `X-Api-Token`, `GET /api/game/versions`, multipart `POST /api/projects/{projectId}/upload-file`, metadata, relations, and success file ID.
233
+ - [CurseForge Authors dashboard](https://authors.curseforge.com/): project creation and non-public author state.
@@ -1,3 +1,3 @@
1
1
  """Fupload Python CLI."""
2
2
 
3
- __version__ = "0.0.1"
3
+ __version__ = "0.0.2"
@@ -10,6 +10,7 @@ from typing import Any, Callable, Dict, Optional, Sequence, Tuple
10
10
 
11
11
  from . import __version__
12
12
  from .dd import DD
13
+ from .curseforge import CurseForge
13
14
  from .errors import FuploadError, ValidationError
14
15
  from .io import read_json, write_error, write_output
15
16
  from .newbee import NewBee
@@ -186,16 +187,30 @@ def _dd_tree(platforms: argparse._SubParsersAction) -> None:
186
187
  leaf = _read_leaf(wa, "categories", "List DD WA category choices for a game type.", platform="dd", resource="wa", action="categories"); leaf.add_argument("--game-type", type=_positive, required=True)
187
188
 
188
189
 
190
+ def _curseforge_tree(platforms: argparse._SubParsersAction) -> None:
191
+ root = platforms.add_parser("curseforge", help="CurseForge public project lookup and author uploads")
192
+ groups = root.add_subparsers(dest="resource_command", required=True)
193
+ session = groups.add_parser("session", help="Configuration diagnostics").add_subparsers(dest="action_command", required=True)
194
+ _read_leaf(session, "doctor", "Check whether the fixed CurseForge configuration fields exist without revealing their values.", platform="curseforge", resource="session", action="doctor")
195
+ project = groups.add_parser("project", help="Public project lookup").add_subparsers(dest="action_command", required=True)
196
+ leaf = _read_leaf(project, "list", "List public WoW projects for one CurseForge author ID.", platform="curseforge", resource="project", action="list")
197
+ leaf.add_argument("--author-id", type=_positive, help="Override CURSEFORGE_AUTHOR_ID for this lookup.")
198
+ plugin = groups.add_parser("plugin", help="WoW plugin versions and uploads").add_subparsers(dest="action_command", required=True)
199
+ _read_leaf(plugin, "game-versions", "List CurseForge Upload API game-version choices.", platform="curseforge", resource="plugin", action="game-versions")
200
+ _write_leaf(plugin, "curseforge", "plugin", "upload", "Upload one plugin archive to an existing CurseForge project.")
201
+
202
+
189
203
  def build_parser() -> argparse.ArgumentParser:
190
204
  parser = _parser(
191
205
  prog="fupload",
192
- description="Atomic World of Warcraft author publishing CLI for NewBeeBox and NetEase DD.",
206
+ description="Atomic World of Warcraft author publishing CLI for NewBeeBox, NetEase DD, and CurseForge.",
193
207
  epilog="All output is JSON. Write commands require versioned JSON through --input and never prompt.",
194
208
  )
195
209
  parser.add_argument("--version", action="version", version="%(prog)s " + __version__)
196
210
  platforms = parser.add_subparsers(dest="platform_command", required=True)
197
211
  _newbee_tree(platforms)
198
212
  _dd_tree(platforms)
213
+ _curseforge_tree(platforms)
199
214
  return parser
200
215
 
201
216
 
@@ -243,14 +258,14 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
243
258
  if args.dry_run:
244
259
  write_output(platform, operation, _dry_run_data(doc, schema.name), dry_run=True)
245
260
  return 0
246
- provider = NewBee() if platform == "newbee" else DD()
261
+ provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else CurseForge())
247
262
  if platform == "dd":
248
263
  data = provider.execute_write(resource, action, doc, getattr(args, "session", None))
249
264
  else:
250
265
  data = provider.execute_write(resource, action, doc)
251
266
  write_output(platform, operation, data)
252
267
  return 0
253
- provider = NewBee() if platform == "newbee" else DD()
268
+ provider = NewBee() if platform == "newbee" else (DD() if platform == "dd" else CurseForge())
254
269
  if platform == "dd":
255
270
  data = provider.execute_read(resource, action, args, getattr(args, "session", None))
256
271
  else:
@@ -0,0 +1,186 @@
1
+ """CurseForge public project lookup and author upload provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import urllib.parse
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Mapping, Optional
10
+
11
+ from .errors import FuploadError, ValidationError
12
+ from .transport import json_request, multipart_request
13
+
14
+
15
+ CORE_BASE = "https://api.curseforge.com"
16
+ UPLOAD_BASE = "https://wow.curseforge.com"
17
+ CONFIG_KEYS = (
18
+ "CURSEFORGE_AUTHOR_ID",
19
+ "CURSEFORGE_API_KEY",
20
+ "CURSEFORGE_UPLOAD_TOKEN",
21
+ )
22
+
23
+
24
+ def config_path() -> Path:
25
+ return Path.home() / ".fupload" / "curseforge.env"
26
+
27
+
28
+ def load_config(path: Optional[Path] = None) -> Dict[str, str]:
29
+ """Load only the fixed CurseForge fields, with process env taking precedence."""
30
+ source = path or config_path()
31
+ values: Dict[str, str] = {}
32
+ if source.is_file():
33
+ try:
34
+ lines = source.read_text(encoding="utf-8-sig").splitlines()
35
+ except OSError as exc:
36
+ raise FuploadError("cannot read CurseForge configuration: %s" % exc, stage="dependency_get") from exc
37
+ for number, raw in enumerate(lines, 1):
38
+ line = raw.strip()
39
+ if not line or line.startswith("#"):
40
+ continue
41
+ if "=" not in line:
42
+ raise ValidationError("expected NAME=VALUE", path="%s:%d" % (source, number))
43
+ name, value = line.split("=", 1)
44
+ name, value = name.strip(), value.strip()
45
+ if name not in CONFIG_KEYS:
46
+ raise ValidationError("unknown CurseForge configuration field", path="%s:%d" % (source, number))
47
+ if name in values:
48
+ raise ValidationError("duplicate CurseForge configuration field", path="%s:%d" % (source, number))
49
+ values[name] = value
50
+ for name in CONFIG_KEYS:
51
+ environment_value = os.environ.get(name, "").strip()
52
+ if environment_value:
53
+ values[name] = environment_value
54
+ return values
55
+
56
+
57
+ def _required(config: Mapping[str, str], *names: str) -> None:
58
+ missing = [name for name in names if not config.get(name)]
59
+ if missing:
60
+ raise FuploadError(
61
+ "missing CurseForge configuration field(s): %s" % ", ".join(missing),
62
+ kind="authentication_error", stage="dependency_get",
63
+ details={"config_path": str(config_path()), "missing": missing},
64
+ )
65
+
66
+
67
+ def _author_id(value: Any) -> int:
68
+ try:
69
+ result = int(value)
70
+ except (TypeError, ValueError) as exc:
71
+ raise ValidationError("author ID must be a positive integer", path="--author-id") from exc
72
+ if result <= 0:
73
+ raise ValidationError("author ID must be a positive integer", path="--author-id")
74
+ return result
75
+
76
+
77
+ class CurseForge:
78
+ def __init__(self, config: Optional[Mapping[str, str]] = None) -> None:
79
+ self.config = dict(config) if config is not None else load_config()
80
+
81
+ def execute_read(self, resource: str, action: str, args: Any) -> Any:
82
+ if resource == "session" and action == "doctor":
83
+ return self.doctor()
84
+ if resource == "project" and action == "list":
85
+ return self.project_list(getattr(args, "author_id", None))
86
+ if resource == "plugin" and action == "game-versions":
87
+ return self.game_versions()
88
+ raise ValidationError("unsupported CurseForge read operation")
89
+
90
+ def execute_write(self, resource: str, action: str, doc: Mapping[str, Any]) -> Any:
91
+ if resource == "plugin" and action == "upload":
92
+ return self.upload(doc)
93
+ raise ValidationError("unsupported CurseForge write operation")
94
+
95
+ def doctor(self) -> Dict[str, Any]:
96
+ fields = [{"name": name, "present": bool(self.config.get(name))} for name in CONFIG_KEYS]
97
+ return {
98
+ "config_path": str(config_path()),
99
+ "fields": fields,
100
+ "ready": all(field["present"] for field in fields),
101
+ }
102
+
103
+ def project_list(self, author_id: Optional[int]) -> Dict[str, Any]:
104
+ _required(self.config, "CURSEFORGE_API_KEY")
105
+ selected = _author_id(author_id if author_id is not None else self.config.get("CURSEFORGE_AUTHOR_ID"))
106
+ query = urllib.parse.urlencode({"gameId": 1, "authorId": selected, "index": 0, "pageSize": 50})
107
+ url = CORE_BASE + "/v1/mods/search?" + query
108
+ payload = json_request(url, headers={"x-api-key": self.config["CURSEFORGE_API_KEY"]})
109
+ if not isinstance(payload, dict) or not isinstance(payload.get("data"), list):
110
+ raise FuploadError("CurseForge project response did not contain a data array", kind="platform_data_error", endpoint=url)
111
+ pagination = payload.get("pagination") if isinstance(payload.get("pagination"), dict) else {}
112
+ projects = []
113
+ for item in payload["data"]:
114
+ if not isinstance(item, dict):
115
+ raise FuploadError("CurseForge project response contained a non-object item", kind="platform_data_error", endpoint=url)
116
+ projects.append({
117
+ "id": item.get("id"),
118
+ "name": item.get("name"),
119
+ "slug": item.get("slug"),
120
+ "status": item.get("status"),
121
+ "dateCreated": item.get("dateCreated"),
122
+ "dateModified": item.get("dateModified"),
123
+ })
124
+ total_count = pagination.get("totalCount")
125
+ if isinstance(total_count, bool) or not isinstance(total_count, int) or total_count < 0:
126
+ total_count = len(projects)
127
+ return {
128
+ "author_id": selected,
129
+ "game_id": 1,
130
+ "total_count": total_count,
131
+ "projects": projects,
132
+ "pagination": pagination,
133
+ }
134
+
135
+ def game_versions(self) -> Any:
136
+ _required(self.config, "CURSEFORGE_UPLOAD_TOKEN")
137
+ return json_request(
138
+ UPLOAD_BASE + "/api/game/versions",
139
+ headers={"X-Api-Token": self.config["CURSEFORGE_UPLOAD_TOKEN"]},
140
+ )
141
+
142
+ def upload(self, doc: Mapping[str, Any]) -> Dict[str, Any]:
143
+ _required(self.config, "CURSEFORGE_UPLOAD_TOKEN")
144
+ project_id = int(doc["project_id"])
145
+ file_path = str(doc["file"])
146
+ field_names = {
147
+ "changelog": "changelog",
148
+ "changelog_type": "changelogType",
149
+ "display_name": "displayName",
150
+ "game_versions": "gameVersions",
151
+ "game_version_names": "gameVersionNames",
152
+ "release_type": "releaseType",
153
+ "parent_file_id": "parentFileID",
154
+ "is_marked_for_manual_release": "isMarkedForManualRelease",
155
+ }
156
+ metadata = {wire: doc[name] for name, wire in field_names.items() if name in doc}
157
+ if "relations" in doc:
158
+ projects = []
159
+ for item in doc["relations"]["projects"]:
160
+ relation = {"slug": item["slug"], "type": item["type"]}
161
+ if "project_id" in item:
162
+ relation["projectID"] = item["project_id"]
163
+ projects.append(relation)
164
+ metadata["relations"] = {"projects": projects}
165
+ url = UPLOAD_BASE + "/api/projects/%d/upload-file" % project_id
166
+ response = multipart_request(
167
+ url, file_path, file_field="file",
168
+ fields={"metadata": json.dumps(metadata, ensure_ascii=False, separators=(",", ":"))},
169
+ headers={"X-Api-Token": self.config["CURSEFORGE_UPLOAD_TOKEN"]},
170
+ )
171
+ if (
172
+ not isinstance(response, dict)
173
+ or isinstance(response.get("id"), bool)
174
+ or not isinstance(response.get("id"), int)
175
+ or response["id"] <= 0
176
+ ):
177
+ raise FuploadError(
178
+ "CurseForge upload response did not contain a positive integer id",
179
+ kind="platform_data_error", endpoint=url,
180
+ )
181
+ return {
182
+ "file_id": response["id"],
183
+ "project_id": project_id,
184
+ "archive": Path(file_path).name,
185
+ "status": "uploaded",
186
+ }
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import os
6
+ import zipfile
6
7
  from dataclasses import dataclass, field
7
8
  from typing import Any, Dict, Iterable, Mapping, Optional, Sequence, Tuple
8
9
 
@@ -306,6 +307,39 @@ class Schema:
306
307
  raise ValidationError("expected nonempty string or null", path="$.retail_ui_config.default_edit_mode_selector")
307
308
  if "enable_dd_setup_wizard" in retail and not isinstance(retail["enable_dd_setup_wizard"], bool):
308
309
  raise ValidationError("expected boolean", path="$.retail_ui_config.enable_dd_setup_wizard")
310
+ if self.name == "fupload.v1.curseforge.plugin.upload":
311
+ if not zipfile.is_zipfile(value["file"]):
312
+ raise ValidationError("file must be a valid ZIP archive", path="$.file")
313
+ if "game_versions" in value and any(isinstance(item, bool) or not isinstance(item, int) or item <= 0 for item in value["game_versions"]):
314
+ raise ValidationError("array must contain positive integer IDs", path="$.game_versions")
315
+ if "game_version_names" in value and any(not isinstance(item, str) or not item.strip() for item in value["game_version_names"]):
316
+ raise ValidationError("array must contain nonempty strings", path="$.game_version_names")
317
+ relations = value.get("relations")
318
+ if relations is not None:
319
+ if set(relations) != {"projects"}:
320
+ unknown = sorted(set(relations) - {"projects"})
321
+ message = "unknown field(s): %s" % ", ".join(unknown) if unknown else "projects is required"
322
+ raise ValidationError(message, path="$.relations")
323
+ if not isinstance(relations["projects"], list):
324
+ raise ValidationError("expected array", path="$.relations.projects")
325
+ for index, relation in enumerate((relations or {}).get("projects") or []):
326
+ if not isinstance(relation, dict):
327
+ raise ValidationError("expected object", path="$.relations.projects[%d]" % index)
328
+ unknown = sorted(set(relation) - {"slug", "type", "project_id"})
329
+ if unknown:
330
+ raise ValidationError("unknown field(s): %s" % ", ".join(unknown), path="$.relations.projects[%d].%s" % (index, unknown[0]))
331
+ if not {"slug", "type"}.issubset(relation):
332
+ raise ValidationError("slug and type are required", path="$.relations.projects[%d]" % index)
333
+ if not isinstance(relation["slug"], str) or not relation["slug"].strip():
334
+ raise ValidationError("expected nonempty string", path="$.relations.projects[%d].slug" % index)
335
+ if relation["type"] not in ("embeddedLibrary", "incompatible", "optionalDependency", "requiredDependency", "tool"):
336
+ raise ValidationError("unsupported relation type", path="$.relations.projects[%d].type" % index)
337
+ if "project_id" in relation and (isinstance(relation["project_id"], bool) or not isinstance(relation["project_id"], int) or relation["project_id"] <= 0):
338
+ raise ValidationError("must be a positive integer", path="$.relations.projects[%d].project_id" % index)
339
+ if "parent_file_id" in value:
340
+ for field_name in ("game_versions", "game_version_names"):
341
+ if field_name in value:
342
+ raise ValidationError("must be omitted when parent_file_id is set", path="$.%s" % field_name)
309
343
 
310
344
 
311
345
  def f(type_name: str, **kwargs: Any) -> Field:
@@ -504,6 +538,20 @@ for _resource in ("plugin", "config", "wa"):
504
538
  "confirm_delete": f("boolean", choices=(True,)),
505
539
  }, ("sn", "confirm_delete")))
506
540
 
541
+ register("curseforge", "plugin", "upload", required({
542
+ "project_id": f("integer", minimum=1),
543
+ "file": f("string", local_file=True),
544
+ "changelog": f("string"),
545
+ "changelog_type": f("string", choices=("text", "html", "markdown")),
546
+ "display_name": f("string", nonempty=True),
547
+ "game_versions": f("array", nonempty=True),
548
+ "game_version_names": f("array"),
549
+ "release_type": f("string", choices=("alpha", "beta", "release")),
550
+ "parent_file_id": f("integer", minimum=1),
551
+ "relations": f("object"),
552
+ "is_marked_for_manual_release": f("boolean"),
553
+ }, ("project_id", "file", "changelog", "release_type")))
554
+
507
555
 
508
556
  def get_schema(platform: str, resource: str, action: str) -> Schema:
509
557
  try:
@@ -3,6 +3,7 @@
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
 
6
+ import { ensureCurseForgeEnv } from "../lib/curseforge-config.mjs";
6
7
  import { recordManagedSkill } from "../lib/managed-install.mjs";
7
8
  import { parseLauncherOptions, resolveSkillDirectory } from "../lib/options.mjs";
8
9
  import { discoverPython, runPython } from "../lib/python.mjs";
@@ -52,6 +53,7 @@ async function main() {
52
53
 
53
54
  let ensured;
54
55
  try {
56
+ ensureCurseForgeEnv();
55
57
  ensured = await ensureSkill({ packageRoot, target });
56
58
  recordManagedSkill(target);
57
59
  } catch (error) {
@@ -0,0 +1,36 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export const CURSEFORGE_ENV_TEMPLATE = [
6
+ "CURSEFORGE_AUTHOR_ID=",
7
+ "CURSEFORGE_API_KEY=",
8
+ "CURSEFORGE_UPLOAD_TOKEN=",
9
+ "",
10
+ ].join("\n");
11
+
12
+ export function curseForgeEnvPath({ home = os.homedir() } = {}) {
13
+ return path.join(home, ".fupload", "curseforge.env");
14
+ }
15
+
16
+ export function ensureCurseForgeEnv({ home = os.homedir(), platform = process.platform } = {}) {
17
+ const filename = curseForgeEnvPath({ home });
18
+ fs.mkdirSync(path.dirname(filename), { recursive: true, mode: 0o700 });
19
+
20
+ try {
21
+ fs.writeFileSync(filename, CURSEFORGE_ENV_TEMPLATE, {
22
+ encoding: "utf8",
23
+ flag: "wx",
24
+ mode: 0o600,
25
+ });
26
+ if (platform !== "win32") {
27
+ fs.chmodSync(filename, 0o600);
28
+ }
29
+ return { path: filename, status: "created" };
30
+ } catch (error) {
31
+ if (error?.code === "EEXIST") {
32
+ return { path: filename, status: "preserved" };
33
+ }
34
+ throw error;
35
+ }
36
+ }
@@ -1,6 +1,7 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
3
 
4
+ import { ensureCurseForgeEnv } from "./curseforge-config.mjs";
4
5
  import { readManagedInstall, recordManagedSkill } from "./managed-install.mjs";
5
6
  import { resolveSkillDirectory } from "./options.mjs";
6
7
  import { ensureSkill, loadDistribution } from "./skill-installer.mjs";
@@ -27,6 +28,7 @@ export async function updateSelf({
27
28
  home = os.homedir(),
28
29
  runNpm = runNpmCli,
29
30
  } = {}) {
31
+ const curseforgeConfig = ensureCurseForgeEnv({ home, platform });
30
32
  const installation = resolveGlobalInstall(packageRoot, platform);
31
33
  const current = loadDistribution(packageRoot);
32
34
  const primary = target || resolveSkillDirectory({ env, home });
@@ -94,6 +96,7 @@ export async function updateSelf({
94
96
  from_version: current.packageRecord.version,
95
97
  to_version: updated.packageRecord.version,
96
98
  npm_exit_status: npmResult.status,
99
+ curseforge_config: curseforgeConfig,
97
100
  skills,
98
101
  };
99
102
  }
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
 
4
+ import { ensureCurseForgeEnv } from "./lib/curseforge-config.mjs";
4
5
  import { recordManagedSkill } from "./lib/managed-install.mjs";
5
6
  import { resolveSkillDirectory } from "./lib/options.mjs";
6
7
  import { ensureSkill } from "./lib/skill-installer.mjs";
@@ -9,9 +10,11 @@ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "
9
10
  const target = resolveSkillDirectory();
10
11
 
11
12
  try {
13
+ const config = ensureCurseForgeEnv();
12
14
  const result = await ensureSkill({ packageRoot, target });
13
15
  recordManagedSkill(target);
14
16
  process.stdout.write(`Fuploader Skill ${result.status}: ${target}\n`);
17
+ process.stdout.write(`CurseForge configuration ${config.status}: ${config.path}\n`);
15
18
  } catch (error) {
16
19
  process.stderr.write(`Fuploader Skill installation failed: ${error.message}\n`);
17
20
  process.exitCode = 1;
@@ -1,14 +1,19 @@
1
1
  {
2
2
  "schema": "fupload.npm-skill-manifest.v1",
3
3
  "package_name": "@follenfang/fupload",
4
- "package_version": "0.0.1",
5
- "skill_version": "0.0.1",
6
- "tree_sha256": "310b805667d90a590f5e1ac9f8833224e5b6a6050e4d2e3c412f712993e362a7",
4
+ "package_version": "0.0.2",
5
+ "skill_version": "0.0.2",
6
+ "tree_sha256": "4ea99f3fb2dee23792450b0d890dcd4520aa8ed827edddf6c6353a8cd753d32e",
7
7
  "files": [
8
8
  {
9
9
  "path": "agents/openai.yaml",
10
- "bytes": 364,
11
- "sha256": "8316fb299d1e77fbbca1cfc6ec80b2078372dff96940d154af425e692ea9bd58"
10
+ "bytes": 342,
11
+ "sha256": "473872fbc6fcecb2030fd2ac66589afd72f813ad47b2e6e968ee2bca4b7e6ab3"
12
+ },
13
+ {
14
+ "path": "examples/curseforge-plugin-upload.json",
15
+ "bytes": 515,
16
+ "sha256": "624cc03b0d6debd842270d129817904aadff67b6d462f8d1481fc95e999c053e"
12
17
  },
13
18
  {
14
19
  "path": "examples/dd-config-delete.json",
@@ -70,6 +75,11 @@
70
75
  "bytes": 168,
71
76
  "sha256": "40491e9aeddc71a4e9578264e5c42b40613e0ea5f0321da29e170b84ff9f1905"
72
77
  },
78
+ {
79
+ "path": "references/curseforge.md",
80
+ "bytes": 11917,
81
+ "sha256": "2dd0a2429a37af30c4f85a884c2e8ea6fcdb1691c34ccc0c34925890e4e60d92"
82
+ },
73
83
  {
74
84
  "path": "references/dd.md",
75
85
  "bytes": 16543,
@@ -93,12 +103,17 @@
93
103
  {
94
104
  "path": "scripts/fupload_cli/__init__.py",
95
105
  "bytes": 49,
96
- "sha256": "c324e2309cb62d40e43669d5cf8c09f9ffe354ddb4cee317fc9d68caa0740b50"
106
+ "sha256": "72609f840f9864ae70d3ee5226e29dc260e132e06ea4c89b0174b3ba9313d95e"
97
107
  },
98
108
  {
99
109
  "path": "scripts/fupload_cli/cli.py",
100
- "bytes": 22260,
101
- "sha256": "4ab353e8c322dfaea76bce52d39fb777bcf0296a515d4065e52afe62f38ed92c"
110
+ "bytes": 23794,
111
+ "sha256": "3c01585b50e1db308444d094e60751491667ea05a98e5a51f284f098b7a8f376"
112
+ },
113
+ {
114
+ "path": "scripts/fupload_cli/curseforge.py",
115
+ "bytes": 7959,
116
+ "sha256": "c607c6caba5c551a7e3970bd152de94ab1e96350ee73969048f5aea5bf42c75d"
102
117
  },
103
118
  {
104
119
  "path": "scripts/fupload_cli/dd_broker.py",
@@ -137,8 +152,8 @@
137
152
  },
138
153
  {
139
154
  "path": "scripts/fupload_cli/schema.py",
140
- "bytes": 31522,
141
- "sha256": "8256229fd2b82db42628bc7c61ba7881035b37640483f3398bb771aa327fea21"
155
+ "bytes": 35113,
156
+ "sha256": "388c89131bf032428d2ed6c047b52c54adf2fa9d1db91833caefe7a1296acf58"
142
157
  },
143
158
  {
144
159
  "path": "scripts/fupload_cli/transport.py",
@@ -157,8 +172,8 @@
157
172
  },
158
173
  {
159
174
  "path": "SKILL.md",
160
- "bytes": 18699,
161
- "sha256": "ff3b6e220ffbe94361f59e9cbcc925c9ddb3568c655ce908ad4117b75f0a89fc"
175
+ "bytes": 21764,
176
+ "sha256": "ea2019f79aca3a6420aff657fa29b75fd731fc0b3fa4941068e0cfce0921afba"
162
177
  }
163
178
  ]
164
179
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@follenfang/fupload",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Install and run the Fuploader Agent Skill and Python CLI.",
5
5
  "type": "module",
6
6
  "bin": {