@follenfang/fupload 0.0.6 → 0.0.8

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.
@@ -46,9 +46,13 @@ export function discoverPython({ platform = process.platform, minimumMinor = 9 }
46
46
  }
47
47
 
48
48
  export function runPython(python, script, args, options = {}) {
49
- return spawnSync(python.command, [...python.args, script, ...args], {
49
+ const run = options.run || spawnSync;
50
+ const env = options.runtimeRoot
51
+ ? runtimeEnvironment(options.runtimeRoot, options.env || process.env)
52
+ : options.env || process.env;
53
+ return run(python.command, [...python.args, script, ...args], {
50
54
  cwd: options.cwd || process.cwd(),
51
- env: options.env || process.env,
55
+ env,
52
56
  stdio: options.stdio || "inherit",
53
57
  encoding: options.encoding,
54
58
  shell: false,
@@ -82,45 +86,68 @@ function readMarker(root) {
82
86
  }
83
87
  }
84
88
 
85
- function probeRuntime(executable, run = spawnSync) {
89
+ function runtimeEnvironment(root, env) {
90
+ return {
91
+ ...env,
92
+ PLAYWRIGHT_BROWSERS_PATH: path.join(root, "browsers"),
93
+ };
94
+ }
95
+
96
+ function probeRuntime(executable, root, env, run = spawnSync) {
86
97
  if (!fs.existsSync(executable)) {
87
98
  return null;
88
99
  }
89
100
  const result = run(executable, [
90
101
  "-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 });
102
+ "import importlib.metadata,json,pathlib,sys; import qcloud_cos; from playwright.sync_api import sync_playwright; p=sync_playwright().start(); executable=p.chromium.executable_path; p.stop(); print(json.dumps({'python_version': '.'.join(map(str,sys.version_info[:3])), 'cos_version': importlib.metadata.version('cos-python-sdk-v5'), 'playwright_version': importlib.metadata.version('playwright'), 'chromium_executable': executable})); sys.exit(0 if pathlib.Path(executable).is_file() else 1)",
103
+ ], {
104
+ encoding: "utf8",
105
+ env: runtimeEnvironment(root, env),
106
+ shell: false,
107
+ windowsHide: true,
108
+ });
93
109
  if (result.status !== 0) {
94
110
  return null;
95
111
  }
96
- const [pythonVersion, dependencyVersion] = result.stdout.trim().split(/\r?\n/);
97
- const match = pythonVersion?.match(/^(\d+)\.(\d+)\.(\d+)$/);
98
- if (!match || !dependencyVersion) {
112
+ let details;
113
+ try {
114
+ details = JSON.parse(result.stdout.trim());
115
+ } catch {
99
116
  return null;
100
117
  }
101
- return { version: match.slice(1).map(Number), dependencyVersion };
118
+ const match = details.python_version?.match(/^(\d+)\.(\d+)\.(\d+)$/);
119
+ if (!match || !details.cos_version || !details.playwright_version || !details.chromium_executable) {
120
+ return null;
121
+ }
122
+ return {
123
+ version: match.slice(1).map(Number),
124
+ dependencyVersion: details.cos_version,
125
+ playwrightVersion: details.playwright_version,
126
+ chromiumExecutable: details.chromium_executable,
127
+ };
102
128
  }
103
129
 
104
- function inspectRuntime({ root, platform, requirementsHash, run }) {
130
+ function inspectRuntime({ root, platform, requirementsHash, env, run }) {
105
131
  const marker = readMarker(root);
106
132
  if (marker?.schema !== PYTHON_RUNTIME_SCHEMA || marker.requirements_sha256 !== requirementsHash) {
107
133
  return null;
108
134
  }
109
135
  const command = pythonRuntimeExecutable(root, platform);
110
- const probe = probeRuntime(command, run);
136
+ const probe = probeRuntime(command, root, env, run);
111
137
  if (!probe || probe.version[0] !== 3 || probe.version[1] < 9) {
112
138
  return null;
113
139
  }
114
- return { command, args: [], version: probe.version, dependencyVersion: probe.dependencyVersion };
140
+ return { command, args: [], ...probe };
115
141
  }
116
142
 
117
143
  function bounded(value) {
118
144
  return String(value || "").trim().slice(0, 4000);
119
145
  }
120
146
 
121
- function runChecked(run, command, args, message) {
147
+ function runChecked(run, command, args, message, options = {}) {
122
148
  const result = run(command, args, {
123
149
  encoding: "utf8",
150
+ ...options,
124
151
  shell: false,
125
152
  windowsHide: true,
126
153
  });
@@ -191,7 +218,7 @@ export function ensurePythonRuntime({
191
218
  const parent = path.dirname(root);
192
219
  const lock = acquireRuntimeLock(root);
193
220
  try {
194
- const current = inspectRuntime({ root, platform, requirementsHash, run });
221
+ const current = inspectRuntime({ root, platform, requirementsHash, env, run });
195
222
  if (current) {
196
223
  return { status: "current", root, requirements, python: current };
197
224
  }
@@ -205,13 +232,19 @@ export function ensurePythonRuntime({
205
232
  const backup = path.join(parent, `.python-backup-${nonce}`);
206
233
  fs.mkdirSync(parent, { recursive: true });
207
234
  let movedOld = false;
235
+ let created;
208
236
  try {
209
237
  runChecked(run, base.command, [...base.args, "-m", "venv", staging], "Could not create the Fuploader Python runtime");
210
238
  const stagingPython = pythonRuntimeExecutable(staging, platform);
211
239
  runChecked(run, stagingPython, [
212
240
  "-m", "pip", "install", "--disable-pip-version-check", "--no-input", "--requirement", requirements,
213
241
  ], "Could not install Fuploader Python dependencies");
214
- const installed = probeRuntime(stagingPython, run);
242
+ runChecked(run, stagingPython, [
243
+ "-m", "playwright", "install", "chromium",
244
+ ], "Could not install Fuploader Chromium", {
245
+ env: runtimeEnvironment(staging, env),
246
+ });
247
+ const installed = probeRuntime(stagingPython, staging, env, run);
215
248
  if (!installed) {
216
249
  throw new Error("The Fuploader Python runtime did not pass its dependency probe.");
217
250
  }
@@ -220,6 +253,8 @@ export function ensurePythonRuntime({
220
253
  requirements_sha256: requirementsHash,
221
254
  python_version: installed.version.join("."),
222
255
  dependency_version: installed.dependencyVersion,
256
+ playwright_version: installed.playwrightVersion,
257
+ chromium_executable: path.relative(staging, installed.chromiumExecutable),
223
258
  });
224
259
  if (fs.existsSync(root)) {
225
260
  fs.renameSync(root, backup);
@@ -234,16 +269,21 @@ export function ensurePythonRuntime({
234
269
  }
235
270
  throw error;
236
271
  }
272
+ created = inspectRuntime({ root, platform, requirementsHash, env, run });
273
+ if (!created) {
274
+ fs.rmSync(root, { recursive: true, force: true });
275
+ if (movedOld) {
276
+ fs.renameSync(backup, root);
277
+ movedOld = false;
278
+ }
279
+ throw new Error("The installed Fuploader Python runtime failed final validation.");
280
+ }
237
281
  if (movedOld) {
238
282
  fs.rmSync(backup, { recursive: true, force: true });
239
283
  }
240
284
  } finally {
241
285
  fs.rmSync(staging, { recursive: true, force: true });
242
286
  }
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
287
  return { status: "installed", root, requirements, python: created };
248
288
  } finally {
249
289
  lock.release();
@@ -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.6",
5
- "skill_version": "0.0.6",
6
- "tree_sha256": "89170e9d93a44d9aa72a1c041f93d941609eb9d762375358090c5a96129405d9",
4
+ "package_version": "0.0.8",
5
+ "skill_version": "0.0.8",
6
+ "tree_sha256": "19b487eaba532263a849d4bfd48f4f40166edbf156f5317e5dbcc040fdc9819e",
7
7
  "files": [
8
8
  {
9
9
  "path": "agents/openai.yaml",
@@ -77,8 +77,8 @@
77
77
  },
78
78
  {
79
79
  "path": "references/blackbox.md",
80
- "bytes": 2357,
81
- "sha256": "022563a7611a5877761df71074ed347ef51fd27f735da7926cbb96d3b781dcc6"
80
+ "bytes": 2966,
81
+ "sha256": "ea95e8826af8e54c82343c404f9c97a611ab8ec3fb0282b561907c50169e4307"
82
82
  },
83
83
  {
84
84
  "path": "references/curseforge.md",
@@ -108,22 +108,22 @@
108
108
  {
109
109
  "path": "scripts/fupload_cli/__init__.py",
110
110
  "bytes": 49,
111
- "sha256": "acf9b050750b58742981c5b9ae836149b89bb86daad01d18dd1948e71817d2c2"
111
+ "sha256": "7f13892a82918a297a8af731adf5b20a540dd3a873d089bf133225a0a642a840"
112
112
  },
113
113
  {
114
- "path": "scripts/fupload_cli/blackbox_auth.py",
115
- "bytes": 7211,
116
- "sha256": "797ca228c57d2b845a7105f132891b71d3e494adcdb8f44027ce835125206177"
114
+ "path": "scripts/fupload_cli/blackbox_web.py",
115
+ "bytes": 19392,
116
+ "sha256": "4a849fb297f045b81d261f4456542c165a3c0d9a77f963dbfcbc1c9dad8fec65"
117
117
  },
118
118
  {
119
119
  "path": "scripts/fupload_cli/blackbox.py",
120
- "bytes": 16666,
121
- "sha256": "c6a793cacc7d9ceb8995cac62a076c1944345eaa113bed21068c75a05b1b0ee6"
120
+ "bytes": 17089,
121
+ "sha256": "4487141898ba6a080d2120c1df9ad83c290528e9b462acb22d6c0f89dbbd117c"
122
122
  },
123
123
  {
124
124
  "path": "scripts/fupload_cli/cli.py",
125
- "bytes": 25664,
126
- "sha256": "d1327783aa0e4e27fcc1f71a80cd090f661feaf2e226688b2efa6a36b9645073"
125
+ "bytes": 25982,
126
+ "sha256": "79c5de939e3a7e7d0ed49d79fcce6c22e78c1a5abcf8eae5422dbd78f7c7bf2d"
127
127
  },
128
128
  {
129
129
  "path": "scripts/fupload_cli/curseforge.py",
@@ -167,8 +167,8 @@
167
167
  },
168
168
  {
169
169
  "path": "scripts/fupload_cli/schema.py",
170
- "bytes": 36268,
171
- "sha256": "db788d2422c0f6b50ce2795a3df75d5bf1e23934ba4211a64198de06931492a5"
170
+ "bytes": 37399,
171
+ "sha256": "5538eaf099bf9e7cae5f8142faecf827d41da698ae574b5597d17756da23e331"
172
172
  },
173
173
  {
174
174
  "path": "scripts/fupload_cli/transport.py",
@@ -187,8 +187,8 @@
187
187
  },
188
188
  {
189
189
  "path": "SKILL.md",
190
- "bytes": 22890,
191
- "sha256": "af700f4e6eb924d955c8e5ae1205934c0bc08b425c85ce5f279eb0c630a0f77a"
190
+ "bytes": 23242,
191
+ "sha256": "ad3813733c9fd7aac62683f704bf1adc25982b6d45a14e4d749b6734a2bee3c3"
192
192
  }
193
193
  ]
194
194
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@follenfang/fupload",
3
- "version": "0.0.6",
3
+ "version": "0.0.8",
4
4
  "description": "Install and run the Fuploader Agent Skill and Python CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,159 +0,0 @@
1
- """Authentication helpers for the Heybox desktop Workshop client."""
2
- from __future__ import annotations
3
- import hashlib, json, platform, 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
- CLIENT_VERSION = "1.14.1"
10
- _CONFIG_AES_KEY = bytes.fromhex("5f1d7f11e6e90dbb5c2f0c1e614a6a8c4b9e16b50fa724e4c54d6f25b1208b93")
11
- _ALPHABET = "AB45STUVWZEFGJ6CH01D237IXYPQRKLMN89"
12
-
13
- def _n8(value: str, limit: int) -> str:
14
- table = _ALPHABET[:limit]
15
- return "".join(table[ord(ch) % len(table)] for ch in value)
16
- def _i8(value: str) -> str:
17
- return "".join(_ALPHABET[ord(ch) % len(_ALPHABET)] for ch in value)
18
- def _interleave(parts):
19
- return "".join(part[i] for i in range(max(map(len, parts))) for part in parts if i < len(part))
20
- def _mix(values):
21
- def p(x): return ((x << 1) ^ 27) & 255 if x & 128 else (x << 1) & 255
22
- def hm(x): return p(x) ^ x
23
- def qg(x): return hm(p(x))
24
- def dx(x): return qg(hm(p(x)))
25
- def mw(x): return dx(x) ^ qg(x) ^ hm(x)
26
- a,b,c,d,*rest=values
27
- 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]
28
- def hkey(path: str, timestamp: int, nonce: str) -> str:
29
- normalized = "/" + "/".join(x for x in path.split("/") if x) + "/"
30
- digest = hashlib.md5(_interleave([_n8(str(timestamp), -2), _i8(normalized), _i8(nonce)]).encode()).hexdigest()
31
- return _n8(digest[:5], -4) + "%02d" % (sum(_mix([ord(x) for x in digest[-6:]])) % 100)
32
-
33
- def _decrypt_user_pkey(value: str) -> str:
34
- """Decrypt the desktop config's ``iv:ciphertext`` AES-256-CBC value.
35
-
36
- Older Chromium cookie databases contain the already decrypted pkey, so this
37
- helper deliberately returns ordinary values unchanged and only attempts
38
- crypto for the new config format.
39
- """
40
- if not isinstance(value, str) or ":" not in value:
41
- return value
42
- iv_hex, ciphertext_hex = value.split(":", 1)
43
- try:
44
- iv = bytes.fromhex(iv_hex)
45
- ciphertext = bytes.fromhex(ciphertext_hex)
46
- if len(iv) != 16 or not ciphertext or len(ciphertext) % 16:
47
- return value
48
- except ValueError:
49
- return value
50
- try:
51
- from Crypto.Cipher import AES
52
- plaintext = AES.new(_CONFIG_AES_KEY, AES.MODE_CBC, iv).decrypt(ciphertext)
53
- except ImportError:
54
- try:
55
- from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
56
- decryptor = Cipher(algorithms.AES(_CONFIG_AES_KEY), modes.CBC(iv)).decryptor()
57
- plaintext = decryptor.update(ciphertext) + decryptor.finalize()
58
- except ImportError as exc:
59
- raise FuploadError(
60
- "AES support is required to read the Heybox desktop session",
61
- kind="environment_error",
62
- details={"install": "Crypto or cryptography"},
63
- ) from exc
64
- if not plaintext:
65
- return value
66
- pad = plaintext[-1]
67
- if not 1 <= pad <= 16 or plaintext[-pad:] != bytes([pad]) * pad:
68
- raise FuploadError("Heybox desktop pkey could not be decrypted", kind="authentication_error")
69
- try:
70
- return plaintext[:-pad].decode("utf-8")
71
- except UnicodeDecodeError as exc:
72
- raise FuploadError("Heybox desktop pkey is invalid", kind="authentication_error") from exc
73
-
74
-
75
- def _read_json(path: Path) -> dict:
76
- try:
77
- data = json.loads(path.read_text(encoding="utf-8"))
78
- except (OSError, UnicodeError, ValueError):
79
- return {}
80
- return data if isinstance(data, dict) else {}
81
-
82
-
83
- def _load_cookie_db(profile: Path) -> dict[str, str]:
84
- db = profile / "Network/Cookies"
85
- try:
86
- with sqlite3.connect("file:%s?mode=ro" % db, uri=True) as con:
87
- rows = con.execute("select name,value from cookies where host_key like '%xiaoheihe.cn'").fetchall()
88
- except (OSError, sqlite3.Error):
89
- return {}
90
- return {str(k): str(v) for k, v in rows if k in {"user_heybox_id", "user_pkey", "x_xhh_tokenid"} and v}
91
-
92
-
93
- def _read_sentry_identity(profile: Path) -> dict[str, str]:
94
- identity: dict[str, str] = {}
95
- scope = profile / "sentry/scope_v3.json"
96
- try:
97
- crumbs = _read_json(scope).get("scope", {}).get("breadcrumbs", [])
98
- for crumb in reversed(crumbs):
99
- raw = crumb.get("data",{}).get("url","")
100
- if "x_app=heybox_pc" in raw:
101
- query = parse_qs(urlparse(raw).query)
102
- allowed = {
103
- "app", "client_type", "device_id", "exe_version", "heybox_id",
104
- "os_type", "os_version", "version", "web_version", "x_app",
105
- "x_client_type", "x_client_version", "x_os_type",
106
- }
107
- identity = {k: v[-1] for k, v in query.items() if k in allowed and v}
108
- break
109
- except (OSError, ValueError, TypeError):
110
- pass
111
- return identity
112
-
113
-
114
- def load_session(profile: Path | None = None):
115
- """Load the current desktop session without exposing credential material.
116
-
117
- Heybox 1.14 stores its authoritative session in ``config.json``. The
118
- Chromium database is retained as a compatibility fallback because older
119
- installations and upgrade paths may not have written the new config yet.
120
- """
121
- profile = profile or Path.home() / "AppData/Roaming/heybox-pc-launcher"
122
- config = _read_json(profile / "config.json")
123
- db_cookies = _load_cookie_db(profile)
124
- cookies: dict[str, str] = dict(db_cookies)
125
- config_cookies = config.get("cookies")
126
- if isinstance(config_cookies, list):
127
- for item in config_cookies:
128
- if not isinstance(item, dict):
129
- continue
130
- name, value = item.get("name"), item.get("value")
131
- if isinstance(name, str) and isinstance(value, str) and value:
132
- if name in {"user_heybox_id", "user_pkey", "x_xhh_tokenid"}:
133
- cookies[name] = value
134
- acc_config = config.get("acc_config")
135
- if isinstance(acc_config, dict) and acc_config.get("xhh_token_id"):
136
- cookies["x_xhh_tokenid"] = str(acc_config["xhh_token_id"])
137
- if cookies.get("user_pkey"):
138
- cookies["user_pkey"] = _decrypt_user_pkey(cookies["user_pkey"])
139
- account = config.get("account") if isinstance(config.get("account"), dict) else {}
140
- if not cookies.get("user_heybox_id") and account.get("heybox_id"):
141
- cookies["user_heybox_id"] = str(account["heybox_id"])
142
- if not {"user_heybox_id", "user_pkey", "x_xhh_tokenid"} <= set(cookies):
143
- raise FuploadError("Heybox desktop login state is incomplete", kind="authentication_error")
144
-
145
- identity = _read_sentry_identity(profile)
146
- version = identity.get("version") or CLIENT_VERSION
147
- identity = {
148
- "x_client_type": "pc",
149
- "x_os_type": "Windows",
150
- "x_app": "heybox_pc",
151
- "version": version,
152
- "exe_version": identity.get("exe_version") or version,
153
- "os_version": identity.get("os_version") or platform.platform(aliased=True),
154
- **identity,
155
- }
156
- identity["version"] = version
157
- identity["exe_version"] = identity.get("exe_version") or version
158
- identity["heybox_id"] = cookies["user_heybox_id"]
159
- return cookies, identity