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.
@@ -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")