@follenfang/fupload 0.0.0-bootstrap.0 → 0.0.1
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 +208 -3
- package/fupload/SKILL.md +129 -0
- package/fupload/agents/openai.yaml +4 -0
- package/fupload/examples/dd-config-delete.json +5 -0
- package/fupload/examples/dd-config-update.json +25 -0
- package/fupload/examples/dd-plugin-delete.json +5 -0
- package/fupload/examples/dd-plugin-update.json +9 -0
- package/fupload/examples/dd-wa-delete.json +5 -0
- package/fupload/examples/dd-wa-edit.json +9 -0
- package/fupload/examples/newbee-config-delete.json +5 -0
- package/fupload/examples/newbee-config-update.json +10 -0
- package/fupload/examples/newbee-plugin-create.json +14 -0
- package/fupload/examples/newbee-plugin-delete.json +5 -0
- package/fupload/examples/newbee-wa-delete.json +5 -0
- package/fupload/examples/newbee-wa-update.json +8 -0
- package/fupload/references/dd.md +105 -0
- package/fupload/references/newbee-official-cli.md +288 -0
- package/fupload/references/newbee.md +80 -0
- package/fupload/references/workflow.md +67 -0
- package/fupload/scripts/fupload.py +17 -0
- package/fupload/scripts/fupload_cli/__init__.py +3 -0
- package/fupload/scripts/fupload_cli/cli.py +266 -0
- package/fupload/scripts/fupload_cli/dd.py +2406 -0
- package/fupload/scripts/fupload_cli/dd_broker.py +634 -0
- package/fupload/scripts/fupload_cli/dd_sidecar.py +860 -0
- package/fupload/scripts/fupload_cli/errors.py +94 -0
- package/fupload/scripts/fupload_cli/io.py +125 -0
- package/fupload/scripts/fupload_cli/newbee.py +1412 -0
- package/fupload/scripts/fupload_cli/newbee_auth.py +135 -0
- package/fupload/scripts/fupload_cli/schema.py +539 -0
- package/fupload/scripts/fupload_cli/transport.py +125 -0
- package/fupload/scripts/fupload_cli/trust.py +207 -0
- package/npm/bin/fupload.mjs +90 -0
- package/npm/lib/managed-install.mjs +86 -0
- package/npm/lib/options.mjs +38 -0
- package/npm/lib/python.mjs +45 -0
- package/npm/lib/skill-installer.mjs +228 -0
- package/npm/lib/uninstall.mjs +211 -0
- package/npm/lib/update.mjs +99 -0
- package/npm/lib/versions.mjs +63 -0
- package/npm/postinstall.mjs +18 -0
- package/npm/skill-manifest.json +164 -0
- package/package.json +50 -6
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Small standard-library JSON and multipart HTTP client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import mimetypes
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from typing import Any, Dict, Mapping, Optional
|
|
12
|
+
|
|
13
|
+
from .errors import FuploadError
|
|
14
|
+
from .trust import official_opener, require_official_url
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _http_error_details(raw: bytes, fallback: str) -> tuple[str, Any]:
|
|
18
|
+
"""Extract a server error only from a conforming UTF-8 JSON object."""
|
|
19
|
+
try:
|
|
20
|
+
parsed = json.loads(raw.decode("utf-8"))
|
|
21
|
+
except (UnicodeDecodeError, ValueError):
|
|
22
|
+
return fallback, None
|
|
23
|
+
if not isinstance(parsed, dict):
|
|
24
|
+
return fallback, None
|
|
25
|
+
message = parsed.get("message") or parsed.get("msg") or fallback
|
|
26
|
+
return str(message), parsed.get("code")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def json_request(
|
|
30
|
+
url: str,
|
|
31
|
+
*,
|
|
32
|
+
method: str = "GET",
|
|
33
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
34
|
+
body: Any = None,
|
|
35
|
+
timeout: int = 60,
|
|
36
|
+
trusted_service: Optional[str] = None,
|
|
37
|
+
) -> Any:
|
|
38
|
+
data = None
|
|
39
|
+
request_headers = dict(headers or {})
|
|
40
|
+
if body is not None:
|
|
41
|
+
data = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|
42
|
+
request_headers.setdefault("Content-Type", "application/json")
|
|
43
|
+
request = urllib.request.Request(url, data=data, method=method, headers=request_headers)
|
|
44
|
+
try:
|
|
45
|
+
response_context = (
|
|
46
|
+
official_opener(require_official_url(url, trusted_service)).open(request, timeout=timeout)
|
|
47
|
+
if trusted_service else urllib.request.urlopen(request, timeout=timeout)
|
|
48
|
+
)
|
|
49
|
+
with response_context as response:
|
|
50
|
+
raw = response.read()
|
|
51
|
+
status = response.status
|
|
52
|
+
except urllib.error.HTTPError as exc:
|
|
53
|
+
raw = exc.read()
|
|
54
|
+
message, code = _http_error_details(raw, "HTTP %d" % exc.code)
|
|
55
|
+
raise FuploadError(message, endpoint=url, http_status=exc.code, business_code=code) from exc
|
|
56
|
+
except (OSError, urllib.error.URLError) as exc:
|
|
57
|
+
raise FuploadError(
|
|
58
|
+
"network result is uncertain: %s" % exc,
|
|
59
|
+
endpoint=url,
|
|
60
|
+
verification_required=method not in ("GET", "HEAD"),
|
|
61
|
+
) from exc
|
|
62
|
+
if status < 200 or status >= 300:
|
|
63
|
+
raise FuploadError("HTTP %d" % status, endpoint=url, http_status=status)
|
|
64
|
+
if not raw:
|
|
65
|
+
return {}
|
|
66
|
+
try:
|
|
67
|
+
return json.loads(raw.decode("utf-8"))
|
|
68
|
+
except ValueError as exc:
|
|
69
|
+
raise FuploadError("response was not valid JSON", endpoint=url, http_status=status) from exc
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def multipart_request(
|
|
73
|
+
url: str,
|
|
74
|
+
file_path: str,
|
|
75
|
+
*,
|
|
76
|
+
file_field: str = "file",
|
|
77
|
+
fields: Optional[Mapping[str, str]] = None,
|
|
78
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
79
|
+
timeout: int = 600,
|
|
80
|
+
trusted_service: Optional[str] = None,
|
|
81
|
+
) -> Any:
|
|
82
|
+
boundary = "----fupload-%s" % secrets.token_hex(16)
|
|
83
|
+
chunks = []
|
|
84
|
+
for key, value in (fields or {}).items():
|
|
85
|
+
chunks.extend([
|
|
86
|
+
("--%s\r\n" % boundary).encode(),
|
|
87
|
+
('Content-Disposition: form-data; name="%s"\r\n\r\n' % key).encode(),
|
|
88
|
+
str(value).encode("utf-8"), b"\r\n",
|
|
89
|
+
])
|
|
90
|
+
filename = os.path.basename(file_path)
|
|
91
|
+
content_type = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
92
|
+
chunks.extend([
|
|
93
|
+
("--%s\r\n" % boundary).encode(),
|
|
94
|
+
('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (file_field, filename)).encode("utf-8"),
|
|
95
|
+
("Content-Type: %s\r\n\r\n" % content_type).encode(),
|
|
96
|
+
])
|
|
97
|
+
with open(file_path, "rb") as handle:
|
|
98
|
+
chunks.append(handle.read())
|
|
99
|
+
chunks.extend([b"\r\n", ("--%s--\r\n" % boundary).encode()])
|
|
100
|
+
body = b"".join(chunks)
|
|
101
|
+
request_headers = dict(headers or {})
|
|
102
|
+
request_headers["Content-Type"] = "multipart/form-data; boundary=%s" % boundary
|
|
103
|
+
request = urllib.request.Request(url, data=body, method="POST", headers=request_headers)
|
|
104
|
+
try:
|
|
105
|
+
response_context = (
|
|
106
|
+
official_opener(require_official_url(url, trusted_service)).open(request, timeout=timeout)
|
|
107
|
+
if trusted_service else urllib.request.urlopen(request, timeout=timeout)
|
|
108
|
+
)
|
|
109
|
+
with response_context as response:
|
|
110
|
+
raw = response.read()
|
|
111
|
+
status = response.status
|
|
112
|
+
except urllib.error.HTTPError as exc:
|
|
113
|
+
raw = exc.read()
|
|
114
|
+
message, code = _http_error_details(raw, "HTTP %d" % exc.code)
|
|
115
|
+
raise FuploadError(message, endpoint=url, http_status=exc.code, business_code=code) from exc
|
|
116
|
+
except (OSError, urllib.error.URLError) as exc:
|
|
117
|
+
raise FuploadError(
|
|
118
|
+
"upload result is uncertain: %s" % exc,
|
|
119
|
+
endpoint=url,
|
|
120
|
+
verification_required=True,
|
|
121
|
+
) from exc
|
|
122
|
+
try:
|
|
123
|
+
return json.loads(raw.decode("utf-8")) if raw else {}
|
|
124
|
+
except ValueError as exc:
|
|
125
|
+
raise FuploadError("upload response was not valid JSON", endpoint=url, http_status=status) from exc
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Platform trust primitives for credential and executable boundaries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ctypes
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import urllib.parse
|
|
11
|
+
import urllib.request
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Dict, Mapping, Optional, Tuple
|
|
14
|
+
|
|
15
|
+
from .errors import FuploadError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
NEWBEE_ORIGINS = {
|
|
19
|
+
"creator": "https://api.newbeebox.com",
|
|
20
|
+
"auth": "https://api.next.newbeebox.com",
|
|
21
|
+
"next": "https://api.next.newbeebox.com",
|
|
22
|
+
"metadata": "https://cdn2.newbeebox.com",
|
|
23
|
+
"upload": "https://api.next.newbeebox.com",
|
|
24
|
+
}
|
|
25
|
+
ALLOWED_DD_PUBLISHERS = (
|
|
26
|
+
"netease (hangzhou) network co., ltd",
|
|
27
|
+
)
|
|
28
|
+
_SUBJECT_ORGANIZATION = re.compile(r"(?:^|,\s*)O=(?:\"([^\"]+)\"|([^,]+))", re.IGNORECASE)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _origin(value: str) -> Tuple[str, str, int]:
|
|
32
|
+
parsed = urllib.parse.urlsplit(value)
|
|
33
|
+
if parsed.scheme.casefold() != "https" or not parsed.hostname:
|
|
34
|
+
raise FuploadError("untrusted HTTPS origin", kind="trust_boundary")
|
|
35
|
+
host = parsed.hostname.casefold().rstrip(".")
|
|
36
|
+
try:
|
|
37
|
+
port = parsed.port or 443
|
|
38
|
+
except ValueError as exc:
|
|
39
|
+
raise FuploadError("origin contains an invalid port", kind="trust_boundary") from exc
|
|
40
|
+
if parsed.username or parsed.password or parsed.fragment:
|
|
41
|
+
raise FuploadError("origin contains unsupported URL components", kind="trust_boundary")
|
|
42
|
+
return parsed.scheme.casefold(), host, port
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def require_official_url(value: str, service: str, *, path_prefix: str = "") -> str:
|
|
46
|
+
expected = _origin(NEWBEE_ORIGINS[service])
|
|
47
|
+
actual = _origin(value)
|
|
48
|
+
if actual != expected:
|
|
49
|
+
raise FuploadError(
|
|
50
|
+
"untrusted %s origin" % service,
|
|
51
|
+
kind="trust_boundary",
|
|
52
|
+
endpoint="%s://%s:%d" % actual,
|
|
53
|
+
)
|
|
54
|
+
parsed = urllib.parse.urlsplit(value)
|
|
55
|
+
if path_prefix and not parsed.path.startswith(path_prefix):
|
|
56
|
+
raise FuploadError("untrusted %s path" % service, kind="trust_boundary")
|
|
57
|
+
return value.rstrip("/")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def same_origin_redirect(original: str, redirected: str) -> bool:
|
|
61
|
+
try:
|
|
62
|
+
return _origin(original) == _origin(redirected)
|
|
63
|
+
except (FuploadError, ValueError):
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class SameOriginRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
68
|
+
def __init__(self, allowed_origin: str) -> None:
|
|
69
|
+
super().__init__()
|
|
70
|
+
self.allowed_origin = allowed_origin
|
|
71
|
+
|
|
72
|
+
def redirect_request(self, req: urllib.request.Request, fp: Any, code: int, msg: str, headers: Mapping[str, str], newurl: str) -> Optional[urllib.request.Request]:
|
|
73
|
+
if not same_origin_redirect(req.full_url, newurl) or not same_origin_redirect(self.allowed_origin, newurl):
|
|
74
|
+
raise FuploadError("cross-origin redirect rejected", kind="trust_boundary")
|
|
75
|
+
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def official_opener(origin: str) -> urllib.request.OpenerDirector:
|
|
79
|
+
return urllib.request.build_opener(SameOriginRedirectHandler(origin))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _known_folder_roaming() -> Path:
|
|
83
|
+
if os.name != "nt":
|
|
84
|
+
raise FuploadError("Windows Roaming AppData is unavailable", kind="authentication_error")
|
|
85
|
+
try:
|
|
86
|
+
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
|
|
87
|
+
ole32 = ctypes.WinDLL("ole32", use_last_error=True)
|
|
88
|
+
folder_id = ctypes.c_ubyte * 16
|
|
89
|
+
# FOLDERID_RoamingAppData: 3EB685DB-65F9-4CF6-A03A-E3EF65729F3D.
|
|
90
|
+
guid = folder_id(0xDB, 0x85, 0xB6, 0x3E, 0xF9, 0x65, 0xF6, 0x4C,
|
|
91
|
+
0xA0, 0x3A, 0xE3, 0xEF, 0x65, 0x72, 0x9F, 0x3D)
|
|
92
|
+
shell32.SHGetKnownFolderPath.argtypes = [ctypes.POINTER(folder_id), ctypes.c_uint32, ctypes.c_void_p, ctypes.POINTER(ctypes.c_wchar_p)]
|
|
93
|
+
shell32.SHGetKnownFolderPath.restype = ctypes.c_long
|
|
94
|
+
value = ctypes.c_wchar_p()
|
|
95
|
+
result = shell32.SHGetKnownFolderPath(ctypes.byref(guid), 0, None, ctypes.byref(value))
|
|
96
|
+
if result != 0:
|
|
97
|
+
raise OSError(result, "SHGetKnownFolderPath failed")
|
|
98
|
+
path = Path(value.value)
|
|
99
|
+
ole32.CoTaskMemFree(value)
|
|
100
|
+
return path
|
|
101
|
+
except (AttributeError, OSError, TypeError, ValueError) as exc:
|
|
102
|
+
raise FuploadError("Windows Roaming AppData could not be resolved", kind="authentication_error") from exc
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _known_folder_local() -> Path:
|
|
106
|
+
if os.name != "nt":
|
|
107
|
+
raise FuploadError("Windows Local AppData is unavailable", kind="trust_boundary")
|
|
108
|
+
try:
|
|
109
|
+
shell32 = ctypes.WinDLL("shell32", use_last_error=True)
|
|
110
|
+
ole32 = ctypes.WinDLL("ole32", use_last_error=True)
|
|
111
|
+
folder_id = ctypes.c_ubyte * 16
|
|
112
|
+
# FOLDERID_LocalAppData: F1B32785-6FBA-4FCF-9D55-7B8E7F157091.
|
|
113
|
+
guid = folder_id(0x85, 0x27, 0xB3, 0xF1, 0xBA, 0x6F, 0xCF, 0x4F,
|
|
114
|
+
0x9D, 0x55, 0x7B, 0x8E, 0x7F, 0x15, 0x70, 0x91)
|
|
115
|
+
shell32.SHGetKnownFolderPath.argtypes = [ctypes.POINTER(folder_id), ctypes.c_uint32, ctypes.c_void_p, ctypes.POINTER(ctypes.c_wchar_p)]
|
|
116
|
+
shell32.SHGetKnownFolderPath.restype = ctypes.c_long
|
|
117
|
+
value = ctypes.c_wchar_p()
|
|
118
|
+
result = shell32.SHGetKnownFolderPath(ctypes.byref(guid), 0, None, ctypes.byref(value))
|
|
119
|
+
if result != 0:
|
|
120
|
+
raise OSError(result, "SHGetKnownFolderPath failed")
|
|
121
|
+
path = Path(value.value)
|
|
122
|
+
ole32.CoTaskMemFree(value)
|
|
123
|
+
return path
|
|
124
|
+
except (AttributeError, OSError, TypeError, ValueError) as exc:
|
|
125
|
+
raise FuploadError("Windows Local AppData could not be resolved", kind="trust_boundary") from exc
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def trusted_roaming_dir(*parts: str) -> Path:
|
|
129
|
+
root = _known_folder_roaming().resolve()
|
|
130
|
+
current = root
|
|
131
|
+
for part in parts:
|
|
132
|
+
current = current / part
|
|
133
|
+
if current.exists() and _is_reparse_point(current):
|
|
134
|
+
raise FuploadError("trusted application path contains a reparse point", kind="trust_boundary")
|
|
135
|
+
resolved = current.resolve(strict=False)
|
|
136
|
+
if resolved != root and root not in resolved.parents:
|
|
137
|
+
raise FuploadError("trusted application path escaped Known Folder", kind="trust_boundary")
|
|
138
|
+
return resolved
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def trusted_local_dir(*parts: str) -> Path:
|
|
142
|
+
root = _known_folder_local().resolve()
|
|
143
|
+
current = root
|
|
144
|
+
for part in parts:
|
|
145
|
+
current = current / part
|
|
146
|
+
if current.exists() and _is_reparse_point(current):
|
|
147
|
+
raise FuploadError("trusted application path contains a reparse point", kind="trust_boundary")
|
|
148
|
+
resolved = current.resolve(strict=False)
|
|
149
|
+
if resolved != root and root not in resolved.parents:
|
|
150
|
+
raise FuploadError("trusted application path escaped Known Folder", kind="trust_boundary")
|
|
151
|
+
return resolved
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _is_reparse_point(path: Path) -> bool:
|
|
155
|
+
if path.is_symlink():
|
|
156
|
+
return True
|
|
157
|
+
try:
|
|
158
|
+
attributes = os.stat(str(path), follow_symlinks=False).st_file_attributes
|
|
159
|
+
except (AttributeError, OSError):
|
|
160
|
+
return False
|
|
161
|
+
return bool(attributes & 0x400)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _powershell_path() -> str:
|
|
165
|
+
if os.name != "nt":
|
|
166
|
+
raise FuploadError("DD executable signature validation requires Windows", kind="trust_boundary")
|
|
167
|
+
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
168
|
+
kernel32.GetWindowsDirectoryW.argtypes = [ctypes.c_wchar_p, ctypes.c_uint32]
|
|
169
|
+
kernel32.GetWindowsDirectoryW.restype = ctypes.c_uint32
|
|
170
|
+
buffer = ctypes.create_unicode_buffer(32768)
|
|
171
|
+
length = kernel32.GetWindowsDirectoryW(buffer, len(buffer))
|
|
172
|
+
if not length:
|
|
173
|
+
raise FuploadError("Windows system directory could not be resolved", kind="trust_boundary")
|
|
174
|
+
return str(Path(buffer.value) / "System32" / "WindowsPowerShell" / "v1.0" / "powershell.exe")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def verify_dd_executable(executable: Path) -> Dict[str, str]:
|
|
178
|
+
powershell = _powershell_path()
|
|
179
|
+
script = (
|
|
180
|
+
"Import-Module Microsoft.PowerShell.Security -ErrorAction Stop; "
|
|
181
|
+
"$OutputEncoding = [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); "
|
|
182
|
+
"$s=Get-AuthenticodeSignature -LiteralPath '%s'; "
|
|
183
|
+
"[pscustomobject]@{Status=[string]$s.Status;Subject=[string]$s.SignerCertificate.Subject} "
|
|
184
|
+
"| ConvertTo-Json -Compress"
|
|
185
|
+
) % str(executable).replace("'", "''")
|
|
186
|
+
try:
|
|
187
|
+
environment = os.environ.copy()
|
|
188
|
+
windows_root = str(Path(powershell).parents[3])
|
|
189
|
+
environment["PSModulePath"] = windows_root + "\\System32\\WindowsPowerShell\\v1.0\\Modules"
|
|
190
|
+
completed = subprocess.run(
|
|
191
|
+
[powershell, "-NoProfile", "-NonInteractive", "-Command", script],
|
|
192
|
+
capture_output=True, text=True, encoding="utf-8", errors="strict",
|
|
193
|
+
timeout=20, check=False, env=environment,
|
|
194
|
+
)
|
|
195
|
+
if completed.returncode != 0:
|
|
196
|
+
raise FuploadError("DD Authenticode verification process failed", kind="trust_boundary")
|
|
197
|
+
payload = json.loads((completed.stdout or "").strip() or "{}")
|
|
198
|
+
except (OSError, subprocess.SubprocessError, ValueError) as exc:
|
|
199
|
+
raise FuploadError("DD Authenticode verification failed", kind="trust_boundary") from exc
|
|
200
|
+
status = str(payload.get("Status") or "")
|
|
201
|
+
subject = str(payload.get("Subject") or "")
|
|
202
|
+
match = _SUBJECT_ORGANIZATION.search(subject)
|
|
203
|
+
publisher = (match.group(1) or match.group(2)).strip() if match else ""
|
|
204
|
+
normalized_publisher = publisher.casefold()
|
|
205
|
+
if status.casefold() != "valid" or normalized_publisher not in ALLOWED_DD_PUBLISHERS:
|
|
206
|
+
raise FuploadError("DD executable has no trusted official signature", kind="trust_boundary")
|
|
207
|
+
return {"status": status, "publisher": publisher}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { recordManagedSkill } from "../lib/managed-install.mjs";
|
|
7
|
+
import { parseLauncherOptions, resolveSkillDirectory } from "../lib/options.mjs";
|
|
8
|
+
import { discoverPython, runPython } from "../lib/python.mjs";
|
|
9
|
+
import { ensureSkill } from "../lib/skill-installer.mjs";
|
|
10
|
+
import { uninstallSelf } from "../lib/uninstall.mjs";
|
|
11
|
+
import { updateSelf } from "../lib/update.mjs";
|
|
12
|
+
|
|
13
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
14
|
+
|
|
15
|
+
function emitError(code, message, details = undefined) {
|
|
16
|
+
const record = { event: "error", code, message };
|
|
17
|
+
if (details) {
|
|
18
|
+
record.details = details;
|
|
19
|
+
}
|
|
20
|
+
process.stderr.write(`${JSON.stringify(record)}\n`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function main() {
|
|
24
|
+
let options;
|
|
25
|
+
try {
|
|
26
|
+
options = parseLauncherOptions(process.argv.slice(2));
|
|
27
|
+
} catch (error) {
|
|
28
|
+
emitError("LAUNCHER_ARGUMENT_INVALID", error.message);
|
|
29
|
+
return 2;
|
|
30
|
+
}
|
|
31
|
+
const target = resolveSkillDirectory({ explicit: options.skillDirectory });
|
|
32
|
+
if (options.forwarded.length === 1 && options.forwarded[0] === "update") {
|
|
33
|
+
try {
|
|
34
|
+
const result = await updateSelf({ packageRoot, target });
|
|
35
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
36
|
+
return 0;
|
|
37
|
+
} catch (error) {
|
|
38
|
+
emitError(error.code || "FUPLOAD_UPDATE_FAILED", error.message, error.details);
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (options.forwarded.length === 1 && options.forwarded[0] === "uninstall") {
|
|
43
|
+
try {
|
|
44
|
+
const result = await uninstallSelf({ packageRoot, target });
|
|
45
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
46
|
+
return 0;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
emitError(error.code || "FUPLOAD_UNINSTALL_FAILED", error.message, error.details);
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let ensured;
|
|
54
|
+
try {
|
|
55
|
+
ensured = await ensureSkill({ packageRoot, target });
|
|
56
|
+
recordManagedSkill(target);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
emitError("SKILL_INSTALL_FAILED", error.message, { target });
|
|
59
|
+
return 1;
|
|
60
|
+
}
|
|
61
|
+
if (options.forwarded.length === 1 && options.forwarded[0] === "--version") {
|
|
62
|
+
process.stdout.write(`${ensured.distribution.packageRecord.version}\n`);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const python = discoverPython();
|
|
67
|
+
if (!python) {
|
|
68
|
+
emitError(
|
|
69
|
+
"PYTHON_VERSION_UNSUPPORTED",
|
|
70
|
+
"Fuploader requires Python >=3.9,<4.0. Install Python and run the command again.",
|
|
71
|
+
);
|
|
72
|
+
return 1;
|
|
73
|
+
}
|
|
74
|
+
const script = path.join(target, "scripts", "fupload.py");
|
|
75
|
+
const result = runPython(python, script, options.forwarded);
|
|
76
|
+
if (result.error) {
|
|
77
|
+
emitError("PYTHON_LAUNCH_FAILED", result.error.message);
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
if (
|
|
81
|
+
result.status === 0 &&
|
|
82
|
+
options.forwarded.length === 1 &&
|
|
83
|
+
["--help", "-h"].includes(options.forwarded[0])
|
|
84
|
+
) {
|
|
85
|
+
process.stdout.write("\nnpm management:\n fupload update Update this CLI and all managed Skills.\n fupload uninstall Remove managed Skills, this CLI, and its npm package.\n");
|
|
86
|
+
}
|
|
87
|
+
return result.status ?? 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
process.exitCode = await main();
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const MANAGED_INSTALL_SCHEMA = "fupload.npm-managed-install.v1";
|
|
6
|
+
export const MANAGED_INSTALL_FILE = "managed-install.json";
|
|
7
|
+
const PACKAGE_NAME = "@follenfang/fupload";
|
|
8
|
+
|
|
9
|
+
function stateRoot({ platform = process.platform, env = process.env, home = os.homedir() } = {}) {
|
|
10
|
+
if (platform === "win32") {
|
|
11
|
+
return path.join(env.LOCALAPPDATA || path.join(home, "AppData", "Local"), "Fupload", "npm");
|
|
12
|
+
}
|
|
13
|
+
return path.join(env.XDG_STATE_HOME || path.join(home, ".local", "state"), "fupload", "npm");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function atomicWriteJson(filename, value) {
|
|
17
|
+
fs.mkdirSync(path.dirname(filename), { recursive: true });
|
|
18
|
+
const temporary = `${filename}.${process.pid}.${Date.now()}.tmp`;
|
|
19
|
+
fs.writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, {
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
mode: 0o600,
|
|
22
|
+
});
|
|
23
|
+
fs.renameSync(temporary, filename);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function managedInstallFile(options = {}) {
|
|
27
|
+
return path.join(stateRoot(options), MANAGED_INSTALL_FILE);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function readManagedInstall(options = {}) {
|
|
31
|
+
const filename = options.filename || managedInstallFile(options);
|
|
32
|
+
try {
|
|
33
|
+
const value = JSON.parse(fs.readFileSync(filename, "utf8"));
|
|
34
|
+
if (
|
|
35
|
+
value?.schema !== MANAGED_INSTALL_SCHEMA ||
|
|
36
|
+
value?.package_name !== PACKAGE_NAME ||
|
|
37
|
+
!Array.isArray(value.targets)
|
|
38
|
+
) {
|
|
39
|
+
throw new Error("unsupported schema");
|
|
40
|
+
}
|
|
41
|
+
return value;
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code === "ENOENT") {
|
|
44
|
+
return { schema: MANAGED_INSTALL_SCHEMA, package_name: PACKAGE_NAME, targets: [] };
|
|
45
|
+
}
|
|
46
|
+
throw new Error(`Managed Skill registry is invalid: ${filename}`, { cause: error });
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function recordManagedSkill(target, options = {}) {
|
|
51
|
+
const filename = options.filename || managedInstallFile(options);
|
|
52
|
+
const resolved = path.resolve(target);
|
|
53
|
+
if (resolved === path.parse(resolved).root) {
|
|
54
|
+
throw new Error("A filesystem root cannot be registered as a managed Skill.");
|
|
55
|
+
}
|
|
56
|
+
const state = readManagedInstall({ ...options, filename });
|
|
57
|
+
const targets = state.targets
|
|
58
|
+
.filter((entry) => typeof entry?.path === "string" && path.resolve(entry.path) !== resolved)
|
|
59
|
+
.map((entry) => ({ path: path.resolve(entry.path), registered_at: entry.registered_at }));
|
|
60
|
+
targets.push({ path: resolved, registered_at: new Date().toISOString() });
|
|
61
|
+
targets.sort((left, right) => left.path.localeCompare(right.path));
|
|
62
|
+
atomicWriteJson(filename, {
|
|
63
|
+
schema: MANAGED_INSTALL_SCHEMA,
|
|
64
|
+
package_name: PACKAGE_NAME,
|
|
65
|
+
targets,
|
|
66
|
+
});
|
|
67
|
+
return { filename, target: resolved, count: targets.length };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function clearManagedInstall(options = {}) {
|
|
71
|
+
const filename = options.filename || managedInstallFile(options);
|
|
72
|
+
fs.rmSync(filename, { force: true });
|
|
73
|
+
const npmState = path.dirname(filename);
|
|
74
|
+
const productState = path.dirname(npmState);
|
|
75
|
+
for (const directory of [npmState, productState]) {
|
|
76
|
+
try {
|
|
77
|
+
if (fs.readdirSync(directory).length === 0) {
|
|
78
|
+
fs.rmdirSync(directory);
|
|
79
|
+
}
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error.code !== "ENOENT" && error.code !== "ENOTEMPTY") {
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function resolveSkillDirectory({ explicit, env = process.env, home } = {}) {
|
|
5
|
+
if (explicit) {
|
|
6
|
+
return path.resolve(explicit);
|
|
7
|
+
}
|
|
8
|
+
if (env.FUPLOAD_AGENT_HOME) {
|
|
9
|
+
return path.resolve(env.FUPLOAD_AGENT_HOME, "skills", "fupload");
|
|
10
|
+
}
|
|
11
|
+
return path.join(home || os.homedir(), ".agents", "skills", "fupload");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseLauncherOptions(argv) {
|
|
15
|
+
const forwarded = [];
|
|
16
|
+
let skillDirectory;
|
|
17
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
18
|
+
const value = argv[index];
|
|
19
|
+
if (value === "--skill-dir") {
|
|
20
|
+
const candidate = argv[index + 1];
|
|
21
|
+
if (!candidate || candidate.startsWith("--")) {
|
|
22
|
+
throw new Error("--skill-dir requires a directory path.");
|
|
23
|
+
}
|
|
24
|
+
skillDirectory = candidate;
|
|
25
|
+
index += 1;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (value.startsWith("--skill-dir=")) {
|
|
29
|
+
skillDirectory = value.slice("--skill-dir=".length);
|
|
30
|
+
if (!skillDirectory) {
|
|
31
|
+
throw new Error("--skill-dir requires a directory path.");
|
|
32
|
+
}
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
forwarded.push(value);
|
|
36
|
+
}
|
|
37
|
+
return { forwarded, skillDirectory };
|
|
38
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
function probe(command, args, minimum) {
|
|
4
|
+
const result = spawnSync(command, [...args, "-c", "import sys; print('.'.join(map(str, sys.version_info[:3])))"], {
|
|
5
|
+
encoding: "utf8",
|
|
6
|
+
shell: false,
|
|
7
|
+
windowsHide: true,
|
|
8
|
+
});
|
|
9
|
+
if (result.status !== 0) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
const match = result.stdout.trim().match(/^(\d+)\.(\d+)\.(\d+)$/);
|
|
13
|
+
if (!match) {
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
const version = match.slice(1).map(Number);
|
|
17
|
+
if (version[0] !== 3 || version[1] < minimum) {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
return { command, args, version };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function discoverPython({ platform = process.platform, minimumMinor = 9 } = {}) {
|
|
24
|
+
const candidates = platform === "win32"
|
|
25
|
+
? [["python", []], ["py", ["-3"]], ["python3", []]]
|
|
26
|
+
: [["python3", []], ["python", []]];
|
|
27
|
+
for (const [command, args] of candidates) {
|
|
28
|
+
const result = probe(command, args, minimumMinor);
|
|
29
|
+
if (result) {
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function runPython(python, script, args, options = {}) {
|
|
37
|
+
return spawnSync(python.command, [...python.args, script, ...args], {
|
|
38
|
+
cwd: options.cwd || process.cwd(),
|
|
39
|
+
env: options.env || process.env,
|
|
40
|
+
stdio: options.stdio || "inherit",
|
|
41
|
+
encoding: options.encoding,
|
|
42
|
+
shell: false,
|
|
43
|
+
windowsHide: true,
|
|
44
|
+
});
|
|
45
|
+
}
|