scriptnow-cli 0.3.94__py3-none-any.whl
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.
- cli_anything/__init__.py +0 -0
- cli_anything/scriptnow/README.md +345 -0
- cli_anything/scriptnow/__init__.py +3 -0
- cli_anything/scriptnow/__main__.py +6 -0
- cli_anything/scriptnow/scriptnow_cli.py +10354 -0
- cli_anything/scriptnow/skills/SKILL.md +305 -0
- cli_anything/scriptnow/ui.py +124 -0
- cli_anything/scriptnow/utils/__init__.py +1 -0
- cli_anything/scriptnow/utils/diag.py +163 -0
- cli_anything/scriptnow/utils/session.py +548 -0
- cli_anything/scriptnow/utils/upgrade.py +331 -0
- scriptnow_cli-0.3.94.dist-info/METADATA +88 -0
- scriptnow_cli-0.3.94.dist-info/RECORD +17 -0
- scriptnow_cli-0.3.94.dist-info/WHEEL +5 -0
- scriptnow_cli-0.3.94.dist-info/entry_points.txt +2 -0
- scriptnow_cli-0.3.94.dist-info/licenses/LICENSE +21 -0
- scriptnow_cli-0.3.94.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
"""Authenticated HTTP session for the ScriptNow platform.
|
|
2
|
+
|
|
3
|
+
The platform authenticates via cookie + CSRF (same-origin web model):
|
|
4
|
+
- POST /api/auth/login with email/password sets sf_access / sf_refresh / sf_csrf cookies.
|
|
5
|
+
- Mutating requests must send the X-CSRF-Token header matching the sf_csrf cookie.
|
|
6
|
+
- The session is persisted locally (base_url, cookies, csrf) so a CLI run does
|
|
7
|
+
not re-login on every invocation; credentials are never stored.
|
|
8
|
+
|
|
9
|
+
Endpoints are reached under ``<base_url>/api/...`` for platform APIs and
|
|
10
|
+
``<base_url>/api/novel/...`` / ``<base_url>/api/script/...`` for domain APIs.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
import re
|
|
20
|
+
import errno
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
try: # POSIX inter-process refresh lock.
|
|
26
|
+
import fcntl as _fcntl
|
|
27
|
+
except ImportError: # pragma: no cover - exercised on Windows.
|
|
28
|
+
_fcntl = None
|
|
29
|
+
|
|
30
|
+
try: # Windows inter-process refresh lock.
|
|
31
|
+
import msvcrt as _msvcrt
|
|
32
|
+
except ImportError: # pragma: no cover - exercised on POSIX.
|
|
33
|
+
_msvcrt = None
|
|
34
|
+
|
|
35
|
+
import requests
|
|
36
|
+
|
|
37
|
+
from cli_anything.scriptnow import __version__ as _CLIENT_VERSION
|
|
38
|
+
|
|
39
|
+
# Per-process invocation id so the server can correlate retries and audit
|
|
40
|
+
# a logical call across multiple HTTP requests.
|
|
41
|
+
import uuid as _uuid
|
|
42
|
+
|
|
43
|
+
_INVOCATION_ID = str(_uuid.uuid4())
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _version_tuple(value: str) -> tuple[int, int, int] | None:
|
|
47
|
+
match = re.fullmatch(r"(\d+)\.(\d+)\.(\d+)", value.strip())
|
|
48
|
+
return tuple(int(part) for part in match.groups()) if match else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ScriptNowError(RuntimeError):
|
|
52
|
+
"""Raised when the platform returns an error or the session is unusable."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _SessionFileError(RuntimeError):
|
|
56
|
+
"""The local session file cannot safely participate in a refresh."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _SessionFilePermissionError(RuntimeError):
|
|
60
|
+
"""The session lock/config area is not writable (EPERM/EACCES).
|
|
61
|
+
|
|
62
|
+
Distinct from file corruption: a sandbox or directory-permission denial
|
|
63
|
+
must produce an actionable fix (relocate the session via
|
|
64
|
+
``SCRIPTNOW_CLI_CONFIG``), never a misleading "re-login" hint.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _SessionLockTimeout(RuntimeError):
|
|
69
|
+
"""Another CLI process held the session-refresh lock for too long."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _SessionFileLock:
|
|
73
|
+
"""A small cross-platform inter-process lock beside a session file.
|
|
74
|
+
|
|
75
|
+
A normal CLI invocation is a new process, so an in-memory lock cannot
|
|
76
|
+
coordinate refresh-token rotation. POSIX uses ``flock`` and Windows uses
|
|
77
|
+
``msvcrt.locking`` on the first byte. The file contains no credentials.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(self, path: Path, *, timeout: float | None = None) -> None:
|
|
81
|
+
self.path = path.with_name(f"{path.name}.refresh.lock")
|
|
82
|
+
self.timeout = _refresh_lock_timeout() if timeout is None else timeout
|
|
83
|
+
self._fd: int | None = None
|
|
84
|
+
|
|
85
|
+
def __enter__(self) -> "_SessionFileLock":
|
|
86
|
+
try:
|
|
87
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
88
|
+
self._fd = os.open(self.path, os.O_RDWR | os.O_CREAT, 0o600)
|
|
89
|
+
try:
|
|
90
|
+
os.chmod(self.path, 0o600)
|
|
91
|
+
except OSError:
|
|
92
|
+
pass
|
|
93
|
+
except OSError as error:
|
|
94
|
+
if error.errno in (errno.EPERM, errno.EACCES):
|
|
95
|
+
raise _SessionFilePermissionError("无法创建本地登录续期锁(目录写入被拒绝)") from error
|
|
96
|
+
raise _SessionFileError("无法创建本地登录续期锁") from error
|
|
97
|
+
|
|
98
|
+
deadline = time.monotonic() + self.timeout
|
|
99
|
+
while True:
|
|
100
|
+
try:
|
|
101
|
+
self._lock()
|
|
102
|
+
return self
|
|
103
|
+
except OSError as error:
|
|
104
|
+
if error.errno not in (errno.EACCES, errno.EAGAIN):
|
|
105
|
+
self._close()
|
|
106
|
+
raise _SessionFileError("无法获取本地登录续期锁") from error
|
|
107
|
+
if time.monotonic() >= deadline:
|
|
108
|
+
self._close()
|
|
109
|
+
raise _SessionLockTimeout()
|
|
110
|
+
time.sleep(0.05)
|
|
111
|
+
|
|
112
|
+
def __exit__(self, exc_type: object, exc: object, traceback: object) -> None:
|
|
113
|
+
del exc_type, exc, traceback
|
|
114
|
+
if self._fd is not None:
|
|
115
|
+
try:
|
|
116
|
+
self._unlock()
|
|
117
|
+
finally:
|
|
118
|
+
self._close()
|
|
119
|
+
|
|
120
|
+
def _lock(self) -> None:
|
|
121
|
+
if self._fd is None:
|
|
122
|
+
raise _SessionFileError("本地登录续期锁未初始化")
|
|
123
|
+
if _fcntl is not None:
|
|
124
|
+
_fcntl.flock(self._fd, _fcntl.LOCK_EX | _fcntl.LOCK_NB)
|
|
125
|
+
return
|
|
126
|
+
if _msvcrt is not None:
|
|
127
|
+
if os.fstat(self._fd).st_size == 0:
|
|
128
|
+
os.write(self._fd, b"\0")
|
|
129
|
+
os.fsync(self._fd)
|
|
130
|
+
os.lseek(self._fd, 0, os.SEEK_SET)
|
|
131
|
+
_msvcrt.locking(self._fd, _msvcrt.LK_NBLCK, 1)
|
|
132
|
+
return
|
|
133
|
+
raise _SessionFileError("当前系统不支持本地登录续期锁")
|
|
134
|
+
|
|
135
|
+
def _unlock(self) -> None:
|
|
136
|
+
if self._fd is None:
|
|
137
|
+
return
|
|
138
|
+
if _fcntl is not None:
|
|
139
|
+
_fcntl.flock(self._fd, _fcntl.LOCK_UN)
|
|
140
|
+
return
|
|
141
|
+
if _msvcrt is not None:
|
|
142
|
+
os.lseek(self._fd, 0, os.SEEK_SET)
|
|
143
|
+
_msvcrt.locking(self._fd, _msvcrt.LK_UNLCK, 1)
|
|
144
|
+
|
|
145
|
+
def _close(self) -> None:
|
|
146
|
+
if self._fd is not None:
|
|
147
|
+
try:
|
|
148
|
+
os.close(self._fd)
|
|
149
|
+
finally:
|
|
150
|
+
self._fd = None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _refresh_lock_timeout() -> float:
|
|
154
|
+
"""Return a bounded, configurable wait for another CLI refresh process."""
|
|
155
|
+
raw = os.environ.get("SCRIPTNOW_CLI_REFRESH_LOCK_TIMEOUT_SECONDS", "15")
|
|
156
|
+
try:
|
|
157
|
+
return max(0.1, min(float(raw), 120.0))
|
|
158
|
+
except ValueError:
|
|
159
|
+
return 15.0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _state_marker(base_url: str, cookies: dict[str, str], csrf: str) -> str:
|
|
163
|
+
"""Credential-state comparison used only in memory; never logged."""
|
|
164
|
+
return json.dumps(
|
|
165
|
+
{"base_url": base_url.rstrip("/"), "cookies": cookies, "csrf": csrf},
|
|
166
|
+
ensure_ascii=False,
|
|
167
|
+
sort_keys=True,
|
|
168
|
+
separators=(",", ":"),
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _read_session_payload(path: Path) -> dict[str, Any]:
|
|
173
|
+
"""Read and minimally validate a saved session without exposing secrets."""
|
|
174
|
+
try:
|
|
175
|
+
value = json.loads(path.read_text())
|
|
176
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error:
|
|
177
|
+
raise _SessionFileError("本地登录会话文件损坏或无法读取") from error
|
|
178
|
+
if not isinstance(value, dict):
|
|
179
|
+
raise _SessionFileError("本地登录会话文件格式无效")
|
|
180
|
+
base_url = value.get("base_url")
|
|
181
|
+
cookies = value.get("cookies")
|
|
182
|
+
csrf = value.get("csrf")
|
|
183
|
+
if not isinstance(base_url, str) or not isinstance(cookies, dict) or not isinstance(csrf, str):
|
|
184
|
+
raise _SessionFileError("本地登录会话文件格式无效")
|
|
185
|
+
if not all(isinstance(key, str) and isinstance(item, str) for key, item in cookies.items()):
|
|
186
|
+
raise _SessionFileError("本地登录会话文件格式无效")
|
|
187
|
+
return value
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass
|
|
191
|
+
class Session:
|
|
192
|
+
base_url: str
|
|
193
|
+
cookies: dict[str, str] = field(default_factory=dict)
|
|
194
|
+
csrf: str = ""
|
|
195
|
+
_http: requests.Session = field(default_factory=requests.Session, repr=False)
|
|
196
|
+
_persisted_marker: str | None = field(default=None, repr=False)
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def api_root(self) -> str:
|
|
200
|
+
return f"{self.base_url}/api"
|
|
201
|
+
|
|
202
|
+
def request(
|
|
203
|
+
self,
|
|
204
|
+
method: str,
|
|
205
|
+
path: str,
|
|
206
|
+
*,
|
|
207
|
+
json_body: dict[str, Any] | None = None,
|
|
208
|
+
form_data: dict[str, Any] | None = None,
|
|
209
|
+
params: dict[str, Any] | None = None,
|
|
210
|
+
files: dict[str, Any] | None = None,
|
|
211
|
+
write: bool = False,
|
|
212
|
+
timeout: int = 120,
|
|
213
|
+
command: str | None = None,
|
|
214
|
+
headers: dict[str, str] | None = None,
|
|
215
|
+
raw: bool = False,
|
|
216
|
+
) -> Any:
|
|
217
|
+
file_positions: list[tuple[Any, int]] = []
|
|
218
|
+
for value in (files or {}).values():
|
|
219
|
+
candidate = value
|
|
220
|
+
if isinstance(value, (tuple, list)) and len(value) > 1:
|
|
221
|
+
candidate = value[1]
|
|
222
|
+
if hasattr(candidate, "tell") and hasattr(candidate, "seek"):
|
|
223
|
+
try:
|
|
224
|
+
file_positions.append((candidate, int(candidate.tell())))
|
|
225
|
+
except (OSError, ValueError):
|
|
226
|
+
pass
|
|
227
|
+
|
|
228
|
+
def _perform() -> requests.Response:
|
|
229
|
+
for handle, position in file_positions:
|
|
230
|
+
try:
|
|
231
|
+
handle.seek(position)
|
|
232
|
+
except (OSError, ValueError):
|
|
233
|
+
pass
|
|
234
|
+
request_headers: dict[str, str] = {
|
|
235
|
+
# 请求元数据:让服务端能够区分 CLI 与网页/自写脚本,并审计到
|
|
236
|
+
# 具体命令与调用(client 类型 + 版本 + 命令 + 调用标识)。
|
|
237
|
+
"X-ScriptNow-Client": "scriptnow-cli",
|
|
238
|
+
"X-ScriptNow-Client-Version": _CLIENT_VERSION,
|
|
239
|
+
"X-ScriptNow-Command": command or "",
|
|
240
|
+
"X-ScriptNow-Invocation": _INVOCATION_ID,
|
|
241
|
+
}
|
|
242
|
+
if headers:
|
|
243
|
+
request_headers.update(headers)
|
|
244
|
+
if write:
|
|
245
|
+
if not self.csrf:
|
|
246
|
+
raise ScriptNowError(
|
|
247
|
+
"session is missing CSRF token; run 'scriptnow login'"
|
|
248
|
+
)
|
|
249
|
+
request_headers["X-CSRF-Token"] = self.csrf
|
|
250
|
+
response = self._http.request(
|
|
251
|
+
method,
|
|
252
|
+
f"{self.api_root}{path}",
|
|
253
|
+
headers=request_headers,
|
|
254
|
+
json=json_body,
|
|
255
|
+
data=form_data,
|
|
256
|
+
params=params,
|
|
257
|
+
files=files,
|
|
258
|
+
cookies=self.cookies or None,
|
|
259
|
+
timeout=timeout,
|
|
260
|
+
)
|
|
261
|
+
# Absorb cookies set by the response (login / refresh).
|
|
262
|
+
for cookie in response.cookies:
|
|
263
|
+
self.cookies[cookie.name] = cookie.value
|
|
264
|
+
if cookie.name == "sf_csrf":
|
|
265
|
+
self.csrf = cookie.value
|
|
266
|
+
return response
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
response = _perform()
|
|
270
|
+
except requests.RequestException as error:
|
|
271
|
+
err = ScriptNowError(f"network error: {error}")
|
|
272
|
+
_record(err, command)
|
|
273
|
+
raise err from error
|
|
274
|
+
minimum_cli = response.headers.get("X-ScriptNow-Minimum-CLI-Version", "")
|
|
275
|
+
api_contract = response.headers.get("X-ScriptNow-API-Contract", "")
|
|
276
|
+
required = _version_tuple(minimum_cli)
|
|
277
|
+
current = _version_tuple(_CLIENT_VERSION)
|
|
278
|
+
# Only enforce the contract when this is a genuine ScriptNow API response
|
|
279
|
+
# (both contract headers present). Non-API endpoints / gateway error
|
|
280
|
+
# pages may inject unrelated headers and would otherwise spuriously fail
|
|
281
|
+
# the check (e.g. a placeholder minimum of "9.0.0").
|
|
282
|
+
if api_contract and minimum_cli and required is not None and current is not None and current < required:
|
|
283
|
+
err = ScriptNowError(
|
|
284
|
+
f"CLI {_CLIENT_VERSION} 与平台合同 {api_contract or 'unknown'} 不兼容;"
|
|
285
|
+
f"最低需要 {minimum_cli},请运行 scriptnow self-upgrade"
|
|
286
|
+
)
|
|
287
|
+
_record(err, command)
|
|
288
|
+
raise err
|
|
289
|
+
# Access tokens are short-lived (platform default: 60 minutes) while
|
|
290
|
+
# refresh tokens last for days. A long-running agent session would
|
|
291
|
+
# otherwise hit 401 mid-work and stall. On 401, rotate the persisted
|
|
292
|
+
# refresh token once and retry the original request before giving up.
|
|
293
|
+
if response.status_code == 401:
|
|
294
|
+
try:
|
|
295
|
+
refreshed = self._refresh()
|
|
296
|
+
except ScriptNowError as error:
|
|
297
|
+
_record(error, command)
|
|
298
|
+
raise
|
|
299
|
+
if refreshed:
|
|
300
|
+
try:
|
|
301
|
+
response = _perform()
|
|
302
|
+
except requests.RequestException as error:
|
|
303
|
+
err = ScriptNowError(f"network error: {error}")
|
|
304
|
+
_record(err, command)
|
|
305
|
+
raise err from error
|
|
306
|
+
if response.status_code == 401:
|
|
307
|
+
err = ScriptNowError("登录状态已失效,请重新运行 scriptnow login")
|
|
308
|
+
_record(err, command)
|
|
309
|
+
raise err
|
|
310
|
+
if response.status_code >= 400:
|
|
311
|
+
detail = _extract_detail(response)
|
|
312
|
+
error = ScriptNowError(f"HTTP {response.status_code}: {detail}")
|
|
313
|
+
_record(error, command)
|
|
314
|
+
raise error
|
|
315
|
+
if raw:
|
|
316
|
+
return response
|
|
317
|
+
if response.status_code == 204:
|
|
318
|
+
return None
|
|
319
|
+
try:
|
|
320
|
+
return response.json()
|
|
321
|
+
except ValueError:
|
|
322
|
+
return response.text
|
|
323
|
+
|
|
324
|
+
def _refresh(self) -> bool:
|
|
325
|
+
"""Rotate access/refresh/CSRF cookies via POST /api/auth/refresh.
|
|
326
|
+
|
|
327
|
+
Returns True when a fresh session is available. The persisted session
|
|
328
|
+
file is updated so the next CLI invocation also benefits from the
|
|
329
|
+
rotation. Never raises; a failed rotation simply reports False so the
|
|
330
|
+
caller can surface the usual "session expired" error.
|
|
331
|
+
"""
|
|
332
|
+
path = _config_path()
|
|
333
|
+
try:
|
|
334
|
+
with _SessionFileLock(path):
|
|
335
|
+
# Another process may have already rotated a one-time refresh
|
|
336
|
+
# token while this invocation was waiting. Always reload after
|
|
337
|
+
# acquiring the lock; never let a stale response overwrite it.
|
|
338
|
+
payload = _read_session_payload(path)
|
|
339
|
+
latest_base_url = str(payload["base_url"]).rstrip("/")
|
|
340
|
+
latest_cookies = dict(payload["cookies"])
|
|
341
|
+
latest_csrf = str(payload["csrf"])
|
|
342
|
+
latest_marker = _state_marker(latest_base_url, latest_cookies, latest_csrf)
|
|
343
|
+
baseline = self._persisted_marker or _state_marker(
|
|
344
|
+
self.base_url, self.cookies, self.csrf
|
|
345
|
+
)
|
|
346
|
+
if latest_marker != baseline:
|
|
347
|
+
self.base_url = latest_base_url
|
|
348
|
+
self.cookies = latest_cookies
|
|
349
|
+
self.csrf = latest_csrf
|
|
350
|
+
self._persisted_marker = latest_marker
|
|
351
|
+
return bool(self.cookies.get("sf_refresh") and self.csrf)
|
|
352
|
+
# Refresh with exactly the durable state that was protected by
|
|
353
|
+
# this lock. A 401 response must not leave an incidental
|
|
354
|
+
# Set-Cookie mutation in memory as the input to token rotation.
|
|
355
|
+
self.base_url = latest_base_url
|
|
356
|
+
self.cookies = latest_cookies
|
|
357
|
+
self.csrf = latest_csrf
|
|
358
|
+
self._persisted_marker = latest_marker
|
|
359
|
+
if not self.cookies.get("sf_refresh") or not self.csrf:
|
|
360
|
+
return False
|
|
361
|
+
try:
|
|
362
|
+
response = self._http.post(
|
|
363
|
+
f"{self.api_root}/auth/refresh",
|
|
364
|
+
headers={"X-CSRF-Token": self.csrf},
|
|
365
|
+
cookies=self.cookies or None,
|
|
366
|
+
timeout=60,
|
|
367
|
+
)
|
|
368
|
+
except requests.RequestException:
|
|
369
|
+
return False
|
|
370
|
+
if response.status_code != 200:
|
|
371
|
+
return False
|
|
372
|
+
rotated = False
|
|
373
|
+
for cookie in response.cookies:
|
|
374
|
+
self.cookies[cookie.name] = cookie.value
|
|
375
|
+
if cookie.name == "sf_csrf":
|
|
376
|
+
self.csrf = cookie.value
|
|
377
|
+
rotated = True
|
|
378
|
+
if not rotated:
|
|
379
|
+
return False
|
|
380
|
+
# Atomic replacement makes a complete new cookie set visible as
|
|
381
|
+
# one unit to other CLI processes. A failed save must not make
|
|
382
|
+
# this request fail: the freshly rotated in-memory session can
|
|
383
|
+
# still retry its original request.
|
|
384
|
+
try:
|
|
385
|
+
self.save(path)
|
|
386
|
+
except OSError:
|
|
387
|
+
pass
|
|
388
|
+
return True
|
|
389
|
+
except _SessionLockTimeout as error:
|
|
390
|
+
raise ScriptNowError(
|
|
391
|
+
"等待另一条 ScriptNow CLI 命令完成登录续期超时;请等待该命令结束后重试"
|
|
392
|
+
) from error
|
|
393
|
+
except _SessionFilePermissionError as error:
|
|
394
|
+
raise ScriptNowError(
|
|
395
|
+
"无法写入本地登录会话锁文件(目录权限受限或沙箱拦截),"
|
|
396
|
+
"不是会话损坏:请将 SCRIPTNOW_CLI_CONFIG 指向可写路径"
|
|
397
|
+
"(如 <工作区>/.cli-session/session.json,把现有 session 复制过去并 chmod 600)后重试"
|
|
398
|
+
) from error
|
|
399
|
+
except _SessionFileError as error:
|
|
400
|
+
raise ScriptNowError(
|
|
401
|
+
"本地登录会话文件损坏或不可读取,未覆盖原文件;请重新登录后重试"
|
|
402
|
+
) from error
|
|
403
|
+
|
|
404
|
+
def save(self, path: Path) -> None:
|
|
405
|
+
payload = {
|
|
406
|
+
"base_url": self.base_url,
|
|
407
|
+
"cookies": self.cookies,
|
|
408
|
+
"csrf": self.csrf,
|
|
409
|
+
"saved_at": int(time.time()),
|
|
410
|
+
}
|
|
411
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
412
|
+
# The session holds live auth cookies: restrict the file (and its
|
|
413
|
+
# directory) to the owner so other local users cannot read them.
|
|
414
|
+
try:
|
|
415
|
+
path.parent.chmod(0o700)
|
|
416
|
+
except OSError:
|
|
417
|
+
pass # best-effort on platforms without POSIX chmod
|
|
418
|
+
encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
419
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.{_uuid.uuid4().hex}.tmp")
|
|
420
|
+
descriptor: int | None = None
|
|
421
|
+
try:
|
|
422
|
+
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
423
|
+
with os.fdopen(descriptor, "wb") as handle:
|
|
424
|
+
descriptor = None
|
|
425
|
+
handle.write(encoded)
|
|
426
|
+
handle.flush()
|
|
427
|
+
os.fsync(handle.fileno())
|
|
428
|
+
os.replace(temporary, path)
|
|
429
|
+
# Best effort durability for the rename on POSIX filesystems.
|
|
430
|
+
try:
|
|
431
|
+
directory_fd = os.open(path.parent, os.O_RDONLY)
|
|
432
|
+
try:
|
|
433
|
+
os.fsync(directory_fd)
|
|
434
|
+
finally:
|
|
435
|
+
os.close(directory_fd)
|
|
436
|
+
except OSError:
|
|
437
|
+
pass
|
|
438
|
+
except Exception:
|
|
439
|
+
if descriptor is not None:
|
|
440
|
+
os.close(descriptor)
|
|
441
|
+
try:
|
|
442
|
+
temporary.unlink()
|
|
443
|
+
except OSError:
|
|
444
|
+
pass
|
|
445
|
+
raise
|
|
446
|
+
try:
|
|
447
|
+
path.chmod(0o600)
|
|
448
|
+
except OSError:
|
|
449
|
+
pass # best-effort on platforms without POSIX chmod
|
|
450
|
+
self._persisted_marker = _state_marker(self.base_url, self.cookies, self.csrf)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _record(error: Exception, command: str | None) -> None:
|
|
454
|
+
"""记录 CLI 错误到诊断日志(失败不影响主流程)。"""
|
|
455
|
+
try:
|
|
456
|
+
from cli_anything.scriptnow.utils.diag import record_error
|
|
457
|
+
|
|
458
|
+
record_error(command=command or "", args=tuple(), detail=str(error))
|
|
459
|
+
except Exception:
|
|
460
|
+
pass
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _extract_detail(response: requests.Response) -> str:
|
|
464
|
+
try:
|
|
465
|
+
body = response.json()
|
|
466
|
+
except ValueError:
|
|
467
|
+
return response.text[:300]
|
|
468
|
+
if isinstance(body, dict) and body.get("agent_detail"):
|
|
469
|
+
detail = body["agent_detail"]
|
|
470
|
+
if isinstance(detail, str):
|
|
471
|
+
return detail[:300]
|
|
472
|
+
if isinstance(body, dict) and body.get("detail"):
|
|
473
|
+
detail = body["detail"]
|
|
474
|
+
if isinstance(detail, str):
|
|
475
|
+
return detail[:300]
|
|
476
|
+
if isinstance(detail, list) and detail:
|
|
477
|
+
return json.dumps(detail[0], ensure_ascii=False)[:300]
|
|
478
|
+
if isinstance(detail, dict):
|
|
479
|
+
# Platform structured errors ({code, message, guide, ...}) —
|
|
480
|
+
# surface the human message, same source the frontend uses.
|
|
481
|
+
message = detail.get("message") or detail.get("msg")
|
|
482
|
+
if isinstance(message, str):
|
|
483
|
+
return message[:300]
|
|
484
|
+
return json.dumps(detail, ensure_ascii=False)[:300]
|
|
485
|
+
return response.text[:300]
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _config_path() -> Path:
|
|
489
|
+
override = os.environ.get("SCRIPTNOW_CLI_CONFIG")
|
|
490
|
+
if override:
|
|
491
|
+
return Path(override)
|
|
492
|
+
return (
|
|
493
|
+
Path(os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config")))
|
|
494
|
+
/ "scriptnow-cli"
|
|
495
|
+
/ "session.json"
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def login(base_url: str, email: str, password: str) -> Session:
|
|
500
|
+
session = Session(base_url=base_url.rstrip("/"))
|
|
501
|
+
payload = {"email": email, "password": password}
|
|
502
|
+
response = session._http.post(
|
|
503
|
+
f"{session.api_root}/auth/login",
|
|
504
|
+
json=payload,
|
|
505
|
+
timeout=60,
|
|
506
|
+
)
|
|
507
|
+
if response.status_code != 200:
|
|
508
|
+
raise ScriptNowError(
|
|
509
|
+
f"login failed (HTTP {response.status_code}): {_extract_detail(response)}"
|
|
510
|
+
)
|
|
511
|
+
for cookie in response.cookies:
|
|
512
|
+
session.cookies[cookie.name] = cookie.value
|
|
513
|
+
if cookie.name == "sf_csrf":
|
|
514
|
+
session.csrf = cookie.value
|
|
515
|
+
if not session.csrf:
|
|
516
|
+
raise ScriptNowError("login response did not set CSRF cookie")
|
|
517
|
+
session.save(_config_path())
|
|
518
|
+
return session
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def load() -> Session:
|
|
522
|
+
path = _config_path()
|
|
523
|
+
if not path.exists():
|
|
524
|
+
raise ScriptNowError(
|
|
525
|
+
"没有已保存的会话。请先运行: scriptnow login --host <平台地址> --email <账号> --password <密码>\n"
|
|
526
|
+
"例如: scriptnow login --host https://sn.igeewa.com --email you@example.com --password '...'"
|
|
527
|
+
)
|
|
528
|
+
try:
|
|
529
|
+
payload = _read_session_payload(path)
|
|
530
|
+
except _SessionFileError as error:
|
|
531
|
+
raise ScriptNowError(
|
|
532
|
+
"本地登录会话文件损坏或不可读取,未覆盖原文件;请重新运行 scriptnow login"
|
|
533
|
+
) from error
|
|
534
|
+
base_url = str(payload["base_url"]).rstrip("/")
|
|
535
|
+
cookies = dict(payload.get("cookies") or {})
|
|
536
|
+
csrf = str(payload.get("csrf") or "")
|
|
537
|
+
session = Session(
|
|
538
|
+
base_url=base_url,
|
|
539
|
+
cookies=cookies,
|
|
540
|
+
csrf=csrf,
|
|
541
|
+
_persisted_marker=_state_marker(base_url, cookies, csrf),
|
|
542
|
+
)
|
|
543
|
+
return session
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def write_json(value: Any) -> None:
|
|
547
|
+
json.dump(value, sys.stdout, ensure_ascii=False, indent=2)
|
|
548
|
+
sys.stdout.write("\n")
|