aisc-cli 0.1.0__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.
Files changed (92) hide show
  1. aisc/VERSION +1 -0
  2. aisc/__init__.py +49 -0
  3. aisc/__main__.py +6 -0
  4. aisc/adapters/__init__.py +1 -0
  5. aisc/adapters/config_reader.py +215 -0
  6. aisc/adapters/container_registry.py +718 -0
  7. aisc/adapters/data_root_store.py +296 -0
  8. aisc/adapters/docker_.py +1183 -0
  9. aisc/adapters/docker_gateway.py +1244 -0
  10. aisc/adapters/docker_sdk_backed.py +274 -0
  11. aisc/adapters/maintenance_lock.py +97 -0
  12. aisc/adapters/state_file.py +184 -0
  13. aisc/adapters/system.py +101 -0
  14. aisc/adapters/windows_config_reader.py +249 -0
  15. aisc/adapters/workspace_lease_store.py +189 -0
  16. aisc/application/__init__.py +1 -0
  17. aisc/application/artifact.py +265 -0
  18. aisc/application/bundle_fetch.py +836 -0
  19. aisc/application/cc_switch_provider.py +245 -0
  20. aisc/application/cc_switch_resolver.py +363 -0
  21. aisc/application/config_service.py +452 -0
  22. aisc/application/conversation.py +516 -0
  23. aisc/application/data_migration.py +440 -0
  24. aisc/application/data_root.py +275 -0
  25. aisc/application/docker_lifecycle.py +659 -0
  26. aisc/application/doctor.py +821 -0
  27. aisc/application/legacy_scan.py +252 -0
  28. aisc/application/network_subscription.py +642 -0
  29. aisc/application/profile_service.py +105 -0
  30. aisc/application/provider.py +92 -0
  31. aisc/application/repair.py +387 -0
  32. aisc/application/resources.py +270 -0
  33. aisc/application/runtime.py +1826 -0
  34. aisc/application/session.py +320 -0
  35. aisc/application/tool_versions.py +210 -0
  36. aisc/application/toolchain.py +91 -0
  37. aisc/application/update.py +286 -0
  38. aisc/application/usage.py +305 -0
  39. aisc/application/version.py +133 -0
  40. aisc/application/web_gateway.py +459 -0
  41. aisc/application/workspace_reconcile.py +318 -0
  42. aisc/applog.py +103 -0
  43. aisc/cli/__init__.py +1 -0
  44. aisc/cli/build_progress_parser.py +217 -0
  45. aisc/cli/commands/__init__.py +1 -0
  46. aisc/cli/commands/agents.py +117 -0
  47. aisc/cli/commands/artifact.py +210 -0
  48. aisc/cli/commands/build.py +566 -0
  49. aisc/cli/commands/bundle.py +107 -0
  50. aisc/cli/commands/cc_switch.py +158 -0
  51. aisc/cli/commands/config.py +104 -0
  52. aisc/cli/commands/container.py +787 -0
  53. aisc/cli/commands/conversation.py +72 -0
  54. aisc/cli/commands/data_root.py +84 -0
  55. aisc/cli/commands/logs.py +89 -0
  56. aisc/cli/commands/network.py +132 -0
  57. aisc/cli/commands/profile.py +52 -0
  58. aisc/cli/commands/provider.py +49 -0
  59. aisc/cli/commands/run.py +494 -0
  60. aisc/cli/commands/runs.py +125 -0
  61. aisc/cli/commands/runtime.py +450 -0
  62. aisc/cli/commands/serve.py +550 -0
  63. aisc/cli/commands/serve_fs.py +369 -0
  64. aisc/cli/commands/session.py +167 -0
  65. aisc/cli/commands/update.py +145 -0
  66. aisc/cli/commands/usage.py +74 -0
  67. aisc/cli/commands/wizard.py +130 -0
  68. aisc/cli/commands/workspaces.py +162 -0
  69. aisc/cli/help_i18n.py +385 -0
  70. aisc/cli/main.py +2926 -0
  71. aisc/cli/output.py +239 -0
  72. aisc/cli/tutorial.py +109 -0
  73. aisc/domain/__init__.py +1 -0
  74. aisc/domain/artifacts.py +279 -0
  75. aisc/domain/cc_switch_release.py +303 -0
  76. aisc/domain/config.py +64 -0
  77. aisc/domain/data_migration.py +259 -0
  78. aisc/domain/data_root.py +124 -0
  79. aisc/domain/docker_ownership.py +148 -0
  80. aisc/domain/gateway.py +168 -0
  81. aisc/domain/models.py +674 -0
  82. aisc/domain/toolchain.py +72 -0
  83. aisc/domain/web_services.py +355 -0
  84. aisc/domain/workspace_lease.py +139 -0
  85. aisc/schemas/__init__.py +1 -0
  86. aisc/schemas/config_schema.py +145 -0
  87. aisc_cli-0.1.0.dist-info/METADATA +420 -0
  88. aisc_cli-0.1.0.dist-info/RECORD +92 -0
  89. aisc_cli-0.1.0.dist-info/WHEEL +5 -0
  90. aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
  91. aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. aisc_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,642 @@
1
+ """mihomo subscription management — application layer (IDEA-2, D1-D4).
2
+
3
+ Storage (Stage 7 data root; the file below is what gets ro-mounted at
4
+ ``/etc/mihomo/config.yaml`` — the container's ``mihomo-build-config.js`` is
5
+ the sole conversion authority, so the raw subscription is stored verbatim)::
6
+
7
+ <data-root>/config/mihomo/subscription.yaml raw subscription file
8
+ <data-root>/config/network-subscription.json snapshot (full URL for refresh)
9
+
10
+ Policies (02-data-contracts.md §1-§4, §6):
11
+ - the full URL only ever lives in the snapshot file under the data root;
12
+ envelopes, logs and diagnostics only ever see ``mask_url`` output;
13
+ - ``subscription-userinfo`` response header is parsed tolerantly
14
+ (``total=0`` = unlimited, ``expire`` may be absent, garbage pairs skipped);
15
+ - downloads use an injectable stdlib-urllib transport with a clash-family
16
+ User-Agent (providers gate payload format by UA); TLS-handshake kills are
17
+ classified as ``..._TLS_REJECTED`` so the UI can guide manual content
18
+ import (D4 — fingerprint-filtering providers drop script-stack handshakes);
19
+ - legacy ``<aisc_root>/.claude/mihomo/config.yaml`` is adopted once (copied,
20
+ never moved) when the data-root target is absent.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import hashlib
26
+ import json
27
+ import os
28
+ import re
29
+ import ssl
30
+ import urllib.error
31
+ import urllib.request
32
+ from datetime import datetime
33
+ from pathlib import Path
34
+ from typing import Any, Callable, Dict, Mapping, Optional, Tuple
35
+ from urllib.parse import urlsplit
36
+
37
+ from aisc.domain.models import CliError
38
+
39
+
40
+ SUBSCRIPTION_SCHEMA = "aisc.network-subscription/v1"
41
+
42
+ #: Clash-family UA: subscription panels gate payload format on it (browser
43
+ #: UAs get the HTML panel; clash clients get the config payload).
44
+ USER_AGENT = "clash-verge/v2.2.0 (aisc)"
45
+
46
+ DEFAULT_TIMEOUT_S = 30.0
47
+ MAX_URL_LENGTH = 2048
48
+
49
+ ERROR_FETCH = "AISC_ERR_NETWORK_SUBSCRIPTION_FETCH"
50
+ ERROR_HTTP = "AISC_ERR_NETWORK_SUBSCRIPTION_HTTP"
51
+ ERROR_INVALID_URL = "AISC_ERR_NETWORK_SUBSCRIPTION_INVALID_URL"
52
+ ERROR_NOT_CONFIGURED = "AISC_ERR_NETWORK_SUBSCRIPTION_NOT_CONFIGURED"
53
+ ERROR_EMPTY = "AISC_ERR_NETWORK_SUBSCRIPTION_EMPTY"
54
+ ERROR_TLS_REJECTED = "AISC_ERR_NETWORK_SUBSCRIPTION_TLS_REJECTED"
55
+
56
+ #: Injectable transport: (url, headers, timeout) -> (status, headers, body).
57
+ #: Headers keys are lowercased; body is the raw response bytes. ``HTTPError``
58
+ #: is returned as a normal 4xx/5xx result; transport-level failures raise.
59
+ Transport = Callable[[str, Dict[str, str], float], Tuple[int, Dict[str, str], bytes]]
60
+
61
+ _USERINFO_KEYS = ("upload", "download", "total", "expire")
62
+
63
+
64
+ def default_transport(url: str, headers: Dict[str, str], timeout: float) -> Tuple[int, Dict[str, str], bytes]:
65
+ """stdlib urllib transport (mirrors ``cc_switch_resolver.default_transport``
66
+ but returns raw bytes — the subscription payload is not JSON).
67
+
68
+ ``HTTPError`` surfaces as a normal 4xx/5xx result; transport-level
69
+ failures (``URLError``/``OSError``) propagate raw — ``_fetch`` owns the
70
+ classification so custom transports behave identically.
71
+ """
72
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, **headers})
73
+ try:
74
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
75
+ return int(resp.status), {k.lower(): v for k, v in resp.headers.items()}, resp.read()
76
+ except urllib.error.HTTPError as exc:
77
+ raw = exc.read() if exc.fp else b""
78
+ return int(exc.code), {k.lower(): v for k, v in exc.headers.items()}, raw or b""
79
+
80
+
81
+ def _classify_transport_error(exc: urllib.error.URLError) -> CliError:
82
+ """Map a transport-level failure onto a stable code.
83
+
84
+ Handshake-phase kills (server closes right after ClientHello — the
85
+ fingerprint-filtering behaviour probed in 2a §6.1) surface as
86
+ ``ssl.SSLError``-family reasons or hard connection resets; those get
87
+ ``TLS_REJECTED`` so the UI can suggest content import (D4). Everything
88
+ else (unreachable, timeouts) is a plain fetch failure.
89
+ """
90
+ reason = exc.reason
91
+ if isinstance(reason, ssl.SSLError):
92
+ return CliError(
93
+ message=(
94
+ "subscription source rejected the connection during TLS "
95
+ "handshake (client fingerprint filtering). Import the config "
96
+ "content manually instead ('aisc network subscription "
97
+ "import-file', stdin = full subscription content)."
98
+ ),
99
+ exit_code=1,
100
+ error_code=ERROR_TLS_REJECTED,
101
+ )
102
+ if isinstance(reason, ConnectionResetError):
103
+ return CliError(
104
+ message=(
105
+ "subscription source reset the connection during TLS "
106
+ "handshake. If it persists, import the config content "
107
+ "manually ('aisc network subscription import-file')."
108
+ ),
109
+ exit_code=1,
110
+ error_code=ERROR_TLS_REJECTED,
111
+ )
112
+ return CliError(
113
+ message=(
114
+ f"subscription source unreachable: {reason}. Check network/proxy "
115
+ "availability, or import the config content manually "
116
+ "('aisc network subscription import-file')."
117
+ ),
118
+ exit_code=1,
119
+ error_code=ERROR_FETCH,
120
+ )
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # Paths
125
+ # ---------------------------------------------------------------------------
126
+
127
+ def subscription_config_path(env: Optional[Mapping[str, str]] = None) -> Path:
128
+ """``<data-root>/config/mihomo/subscription.yaml`` (does not create it)."""
129
+ from aisc.application.data_root import shared_root
130
+
131
+ return shared_root(env) / "config" / "mihomo" / "subscription.yaml"
132
+
133
+
134
+ def _snapshot_path(env: Optional[Mapping[str, str]] = None) -> Path:
135
+ from aisc.application.data_root import shared_root
136
+
137
+ return shared_root(env) / "config" / "network-subscription.json"
138
+
139
+
140
+ def _legacy_config_path(legacy_root: Optional[Path]) -> Optional[Path]:
141
+ if legacy_root is not None:
142
+ return Path(legacy_root) / ".claude" / "mihomo" / "config.yaml"
143
+ from aisc.application.resources import locate_aisc_root, _RootSourceError
144
+
145
+ try:
146
+ root = locate_aisc_root()
147
+ except _RootSourceError:
148
+ return None
149
+ if root is None:
150
+ return None
151
+ return Path(root) / ".claude" / "mihomo" / "config.yaml"
152
+
153
+
154
+ def resolve_subscription_config_path(
155
+ explicit: Optional[str] = None,
156
+ *,
157
+ env: Optional[Mapping[str, str]] = None,
158
+ legacy_root: Optional[Path] = None,
159
+ adopt_legacy: bool = True,
160
+ ) -> Optional[str]:
161
+ """Resolve which host file a ``network=proxy`` run should mount.
162
+
163
+ Order: explicit path (returned verbatim — callers validate) → data-root
164
+ subscription → legacy ``<aisc_root>/.claude/mihomo/config.yaml``. With
165
+ ``adopt_legacy=True`` the legacy file is *copied* into the data root once
166
+ (idempotent, source untouched); with ``adopt_legacy=False`` the legacy
167
+ path itself is returned (read-only callers must stay side-effect free).
168
+ Returns ``None`` when nothing exists (TUN without config stays valid).
169
+ """
170
+ if explicit:
171
+ return str(explicit)
172
+ target = subscription_config_path(env)
173
+ if target.is_file():
174
+ return str(target)
175
+ legacy = _legacy_config_path(legacy_root)
176
+ if legacy is not None and legacy.is_file():
177
+ if not adopt_legacy:
178
+ return str(legacy)
179
+ target.parent.mkdir(parents=True, exist_ok=True)
180
+ tmp = target.with_name(f".{target.name}.adopt.{os.getpid()}")
181
+ tmp.write_bytes(legacy.read_bytes())
182
+ os.replace(tmp, target)
183
+ return str(target)
184
+ return None
185
+
186
+
187
+ # ---------------------------------------------------------------------------
188
+ # Parsing / masking
189
+ # ---------------------------------------------------------------------------
190
+
191
+ def parse_userinfo(value: str) -> Optional[Dict[str, int]]:
192
+ """Parse a ``subscription-userinfo`` header value.
193
+
194
+ ``upload=..; download=..; total=..; expire=..`` — semicolon-separated
195
+ ``k=v`` pairs, whitespace tolerated, unknown keys and malformed pairs
196
+ skipped. Returns ``None`` when nothing usable is present. Values are raw
197
+ non-negative integers (bytes / unix seconds); presentation converts.
198
+ """
199
+ if not value:
200
+ return None
201
+ out: Dict[str, int] = {}
202
+ for pair in value.split(";"):
203
+ pair = pair.strip()
204
+ if not pair or "=" not in pair:
205
+ continue
206
+ key, _, raw = pair.partition("=")
207
+ key = key.strip().lower()
208
+ raw = raw.strip()
209
+ if key not in _USERINFO_KEYS or not raw.isdigit():
210
+ continue
211
+ out[key] = int(raw)
212
+ return out or None
213
+
214
+
215
+ # ---------------------------------------------------------------------------
216
+ # Node-name usage fallback (挂账②): many airports send NO userinfo header but
217
+ # embed the plan facts as FAKE PROXY NODES ("已用流量:4.03 GB", "剩余流量:
218
+ # 9999995.97 GB", "套餐总量:10000000 GB", "套餐到期:永久有效"). When the
219
+ # header is absent, the raw subscription text is scanned for these patterns
220
+ # and the result is mapped onto the header's userinfo shape (upload+download
221
+ # = used ⇒ everything lands in `download`; derivations fill the gaps).
222
+ # ---------------------------------------------------------------------------
223
+
224
+ _UNIT_MULT = {
225
+ "B": 1, "KB": 1_000, "MB": 1_000_000, "GB": 1_000_000_000, "TB": 10**12,
226
+ "KIB": 1024, "MIB": 1024**2, "GIB": 1024**3, "TIB": 1024**4,
227
+ }
228
+ _TRAFFIC_VALUE = r"([0-9][0-9.,]*)\s*(B|KB|MB|GB|TB|KiB|MiB|GiB|TiB)"
229
+ _NODE_USAGE_PATTERNS = {
230
+ "used": re.compile(r"(?:已用|已使用|使用量)[^::\n]{0,6}[::]\s*" + _TRAFFIC_VALUE, re.I),
231
+ "remaining": re.compile(r"(?:剩余|可用|余量)[^::\n]{0,6}[::]\s*" + _TRAFFIC_VALUE, re.I),
232
+ "total": re.compile(r"(?:总流量|套餐总量|流量总量|总量)[^::\n]{0,6}[::]\s*" + _TRAFFIC_VALUE, re.I),
233
+ }
234
+ _EXPIRE_LINE_RE = re.compile(r"(?:到期|过期|有效期|expire)[^::\n]{0,6}[::]\s*([^||,,\n\r]{1,40})", re.I)
235
+ _PERMANENT_RE = re.compile(r"永久|不过期|长期|never", re.I)
236
+ _EXPIRE_DATE_FORMATS = ("%Y-%m-%d %H:%M:%S", "%Y/%m/%d %H:%M:%S",
237
+ "%Y-%m-%d %H:%M", "%Y/%m/%d %H:%M",
238
+ "%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d")
239
+
240
+
241
+ def _traffic_to_bytes(value: str, unit: str) -> Optional[int]:
242
+ try:
243
+ return int(float(value.replace(",", "")) * _UNIT_MULT[unit.upper()])
244
+ except (ValueError, KeyError, OverflowError):
245
+ return None
246
+
247
+
248
+ def parse_node_name_userinfo(text: str) -> Optional[Dict[str, int]]:
249
+ """Extract plan usage from fake-node names in the subscription text.
250
+
251
+ Returns the header-shaped dict (``download`` carries the used bytes) or
252
+ ``None`` when nothing recognizable is present. Derivations: any one of
253
+ used/total missing is filled from the other two when possible; a
254
+ permanent-plan keyword omits ``expire``; a parseable date becomes an
255
+ epoch."""
256
+ found: Dict[str, int] = {}
257
+ for name, pattern in _NODE_USAGE_PATTERNS.items():
258
+ m = pattern.search(text)
259
+ if m:
260
+ amount = _traffic_to_bytes(m.group(1), m.group(2))
261
+ if amount is not None:
262
+ found[name] = amount
263
+ used = found.get("used")
264
+ remaining = found.get("remaining")
265
+ total = found.get("total")
266
+ if total is None and used is not None and remaining is not None:
267
+ total = used + remaining
268
+ elif used is None and total is not None and remaining is not None:
269
+ used = total - remaining
270
+ if used is None or (total is None and remaining is None):
271
+ return None
272
+
273
+ out: Dict[str, int] = {"upload": 0, "download": used}
274
+ if total is not None:
275
+ out["total"] = total
276
+ m = _EXPIRE_LINE_RE.search(text)
277
+ if m:
278
+ raw = m.group(1).strip()
279
+ if not _PERMANENT_RE.search(raw):
280
+ for fmt in _EXPIRE_DATE_FORMATS:
281
+ try:
282
+ out["expire"] = int(datetime.strptime(raw, fmt).timestamp())
283
+ break
284
+ except ValueError:
285
+ continue
286
+ return out
287
+
288
+
289
+ def mask_url(url: str) -> Optional[str]:
290
+ """Deterministic display form: scheme + host survive, path truncated,
291
+ query replaced by ``****``, fragment dropped. ``None`` for garbage."""
292
+ try:
293
+ parts = urlsplit(url.strip())
294
+ except ValueError:
295
+ return None
296
+ if not parts.scheme or not parts.netloc:
297
+ return None
298
+ path = parts.path or "/"
299
+ if len(path) > 8:
300
+ path = path[:8] + "…"
301
+ masked = f"{parts.scheme}://{parts.netloc}{path}"
302
+ if parts.query:
303
+ masked += "?****"
304
+ return masked
305
+
306
+
307
+ def _validate_url(url: str) -> str:
308
+ url = url.strip()
309
+ if not url or len(url) > MAX_URL_LENGTH:
310
+ raise CliError(
311
+ message="subscription URL is empty or longer than "
312
+ f"{MAX_URL_LENGTH} characters",
313
+ exit_code=2,
314
+ error_code=ERROR_INVALID_URL,
315
+ )
316
+ parts = urlsplit(url)
317
+ if parts.scheme not in ("http", "https") or not parts.netloc:
318
+ raise CliError(
319
+ message="subscription URL must be an absolute http(s) URL",
320
+ exit_code=2,
321
+ error_code=ERROR_INVALID_URL,
322
+ )
323
+ return url
324
+
325
+
326
+ # ---------------------------------------------------------------------------
327
+ # Storage
328
+ # ---------------------------------------------------------------------------
329
+
330
+ def _atomic_write(path: Path, payload: bytes) -> None:
331
+ path.parent.mkdir(parents=True, exist_ok=True)
332
+ tmp = path.with_name(f".{path.name}.tmp.{os.getpid()}")
333
+ tmp.write_bytes(payload)
334
+ os.replace(tmp, path)
335
+
336
+
337
+ def _sha256_hex(data: bytes) -> str:
338
+ return f"sha256:{hashlib.sha256(data).hexdigest()}"
339
+
340
+
341
+ def _load_snapshot(env: Optional[Mapping[str, str]] = None) -> Optional[Dict[str, Any]]:
342
+ try:
343
+ raw = _snapshot_path(env).read_text(encoding="utf-8")
344
+ except OSError:
345
+ return None
346
+ try:
347
+ data = json.loads(raw)
348
+ except json.JSONDecodeError:
349
+ return None
350
+ return data if isinstance(data, dict) else None
351
+
352
+
353
+ def _envelope_data(
354
+ *,
355
+ configured: bool,
356
+ source: Optional[str],
357
+ url_masked: Optional[str],
358
+ fetched_at: Optional[str],
359
+ config_sha256: Optional[str],
360
+ has_config_file: bool,
361
+ userinfo: Optional[Dict[str, int]],
362
+ userinfo_source: Optional[str] = None,
363
+ config_path: Optional[str] = None,
364
+ ) -> Dict[str, Any]:
365
+ data: Dict[str, Any] = {
366
+ "configured": configured,
367
+ "source": source,
368
+ "url_masked": url_masked,
369
+ "fetched_at": fetched_at,
370
+ "config_sha256": config_sha256,
371
+ "has_config_file": has_config_file,
372
+ "userinfo": userinfo,
373
+ }
374
+ if userinfo_source is not None:
375
+ data["userinfo_source"] = userinfo_source
376
+ if config_path is not None:
377
+ data["config_path"] = config_path
378
+ return data
379
+
380
+
381
+ # ---------------------------------------------------------------------------
382
+ # Operations (envelope data per 02-data-contracts.md §4)
383
+ # ---------------------------------------------------------------------------
384
+
385
+ def _fetch(
386
+ url: str,
387
+ *,
388
+ transport: Transport,
389
+ timeout: float,
390
+ ) -> Tuple[bytes, Dict[str, str]]:
391
+ """Fetch with one retry on transient failures (4xx never retries)."""
392
+ attempts = 2
393
+ last_error: Optional[CliError] = None
394
+ for attempt in range(attempts):
395
+ err: Optional[CliError] = None
396
+ try:
397
+ status, headers, body = transport(url, {"User-Agent": USER_AGENT}, timeout)
398
+ except urllib.error.URLError as exc:
399
+ err = _classify_transport_error(exc)
400
+ except CliError as exc:
401
+ err = exc
402
+ except (TimeoutError, OSError) as exc:
403
+ err = CliError(
404
+ message=f"subscription download failed: {exc}",
405
+ exit_code=1,
406
+ error_code=ERROR_FETCH,
407
+ )
408
+ if err is not None:
409
+ # TLS_REJECTED is deterministic — retrying only burns the budget.
410
+ if err.error_code == ERROR_TLS_REJECTED or attempt == attempts - 1:
411
+ raise err
412
+ last_error = err
413
+ continue
414
+ if 200 <= status < 300:
415
+ if not body:
416
+ raise CliError(
417
+ message="subscription source returned an empty body",
418
+ exit_code=1,
419
+ error_code=ERROR_EMPTY,
420
+ )
421
+ return body, headers
422
+ if 400 <= status < 500:
423
+ raise CliError(
424
+ message=f"subscription source returned HTTP {status}",
425
+ exit_code=1,
426
+ error_code=ERROR_HTTP,
427
+ data={"http_status": status},
428
+ )
429
+ # 5xx (or unexpected): retry once, then fail as FETCH.
430
+ last_error = CliError(
431
+ message=f"subscription source returned HTTP {status}",
432
+ exit_code=1,
433
+ error_code=ERROR_FETCH,
434
+ data={"http_status": status},
435
+ )
436
+ assert last_error is not None
437
+ raise last_error
438
+
439
+
440
+ def import_subscription(
441
+ url: str,
442
+ *,
443
+ transport: Optional[Transport] = None,
444
+ timeout: float = DEFAULT_TIMEOUT_S,
445
+ env: Optional[Mapping[str, str]] = None,
446
+ ) -> Dict[str, Any]:
447
+ """Download a subscription, store it, return the envelope data.
448
+
449
+ The URL is a credential — callers must pass it via stdin, never argv.
450
+ ``transport`` resolves lazily so tests can patch
451
+ ``network_subscription.default_transport`` at module level.
452
+ """
453
+ transport = transport or default_transport
454
+ url = _validate_url(url)
455
+ body, headers = _fetch(url, transport=transport, timeout=timeout)
456
+ userinfo = parse_userinfo(headers.get("subscription-userinfo", ""))
457
+ userinfo_source = "header" if userinfo else None
458
+ if userinfo is None:
459
+ # 挂账②: airports without the header embed the facts as fake nodes.
460
+ userinfo = parse_node_name_userinfo(body.decode("utf-8", "replace"))
461
+ if userinfo is not None:
462
+ userinfo_source = "node-names"
463
+ return _persist(
464
+ url=url, body=body, userinfo=userinfo, userinfo_source=userinfo_source,
465
+ source="download", env=env,
466
+ fetched_at=datetime.now().astimezone().isoformat(timespec="seconds"),
467
+ )
468
+
469
+
470
+ def import_subscription_content(
471
+ content: bytes,
472
+ *,
473
+ env: Optional[Mapping[str, str]] = None,
474
+ ) -> Dict[str, Any]:
475
+ """Store manually supplied subscription content (D4 fallback for
476
+ fingerprint-filtering sources). No URL — and no header either, so the
477
+ node-name fallback is the only usage source."""
478
+ if not content or not content.strip():
479
+ raise CliError(
480
+ message="subscription content is empty",
481
+ exit_code=2,
482
+ error_code=ERROR_EMPTY,
483
+ )
484
+ userinfo = parse_node_name_userinfo(content.decode("utf-8", "replace"))
485
+ return _persist(
486
+ url=None, body=content, userinfo=userinfo,
487
+ userinfo_source="node-names" if userinfo else None,
488
+ source="manual", env=env,
489
+ fetched_at=datetime.now().astimezone().isoformat(timespec="seconds"),
490
+ )
491
+
492
+
493
+ def _persist(
494
+ *,
495
+ url: Optional[str],
496
+ body: bytes,
497
+ userinfo: Optional[Dict[str, int]],
498
+ userinfo_source: Optional[str],
499
+ source: str,
500
+ env: Optional[Mapping[str, str]],
501
+ fetched_at: str,
502
+ ) -> Dict[str, Any]:
503
+ config_path = subscription_config_path(env)
504
+ _atomic_write(config_path, body)
505
+ snapshot: Dict[str, Any] = {
506
+ "schema": SUBSCRIPTION_SCHEMA,
507
+ "url": url,
508
+ "source": source,
509
+ "fetched_at": fetched_at,
510
+ "config_sha256": _sha256_hex(body),
511
+ "userinfo": userinfo,
512
+ "userinfo_source": userinfo_source,
513
+ }
514
+ _atomic_write(
515
+ _snapshot_path(env),
516
+ json.dumps(snapshot, ensure_ascii=False, indent=2).encode("utf-8"),
517
+ )
518
+ return _envelope_data(
519
+ configured=True,
520
+ source=source,
521
+ url_masked=mask_url(url) if url else None,
522
+ fetched_at=fetched_at,
523
+ config_sha256=snapshot["config_sha256"],
524
+ has_config_file=True,
525
+ userinfo=userinfo,
526
+ userinfo_source=userinfo_source,
527
+ config_path=str(config_path),
528
+ )
529
+
530
+
531
+ def store_downloaded(
532
+ url: Optional[str],
533
+ content: bytes,
534
+ userinfo_header: str = "",
535
+ *,
536
+ env: Optional[Mapping[str, str]] = None,
537
+ ) -> Dict[str, Any]:
538
+ """Persist a Rust-side download (挂账① transport split).
539
+
540
+ The Workbench's reqwest downloader (the clash-verge stack — the only
541
+ non-browser transport passing airport TLS-fingerprint walls) hands the
542
+ raw body and the captured ``subscription-userinfo`` header here; every
543
+ persistence/parsing rule stays in this module (snapshot, masking,
544
+ node-name fallback)."""
545
+ if not content or not content.strip():
546
+ raise CliError(
547
+ message="subscription content is empty",
548
+ exit_code=2,
549
+ error_code=ERROR_EMPTY,
550
+ )
551
+ userinfo = parse_userinfo(userinfo_header)
552
+ userinfo_source = "header" if userinfo else None
553
+ if userinfo is None:
554
+ userinfo = parse_node_name_userinfo(content.decode("utf-8", "replace"))
555
+ if userinfo is not None:
556
+ userinfo_source = "node-names"
557
+ return _persist(
558
+ url=url, body=content, userinfo=userinfo, userinfo_source=userinfo_source,
559
+ source="download", env=env,
560
+ fetched_at=datetime.now().astimezone().isoformat(timespec="seconds"),
561
+ )
562
+
563
+
564
+ def refresh_subscription(
565
+ *,
566
+ transport: Optional[Transport] = None,
567
+ timeout: float = DEFAULT_TIMEOUT_S,
568
+ env: Optional[Mapping[str, str]] = None,
569
+ ) -> Dict[str, Any]:
570
+ """Re-fetch the stored subscription URL."""
571
+ transport = transport or default_transport
572
+ snapshot = _load_snapshot(env)
573
+ url = (snapshot or {}).get("url")
574
+ if not url or not isinstance(url, str):
575
+ raise CliError(
576
+ message="no subscription URL stored (import one first, or use "
577
+ "import-file for manually supplied content)",
578
+ exit_code=1,
579
+ error_code=ERROR_NOT_CONFIGURED,
580
+ )
581
+ return import_subscription(url, transport=transport, timeout=timeout, env=env)
582
+
583
+
584
+ def show_subscription(
585
+ *,
586
+ env: Optional[Mapping[str, str]] = None,
587
+ legacy_root: Optional[Path] = None,
588
+ ) -> Dict[str, Any]:
589
+ """Secret-free snapshot of the current subscription state (no fetch)."""
590
+ # One-time legacy adoption so an existing CLI-wizard config keeps working
591
+ # from the data root without a manual copy.
592
+ resolve_subscription_config_path(env=env, legacy_root=legacy_root, adopt_legacy=True)
593
+
594
+ config_path = subscription_config_path(env)
595
+ snapshot = _load_snapshot(env) or {}
596
+ if not config_path.is_file():
597
+ return _envelope_data(
598
+ configured=False, source=None, url_masked=None, fetched_at=None,
599
+ config_sha256=None, has_config_file=False, userinfo=None,
600
+ )
601
+ try:
602
+ sha = _sha256_hex(config_path.read_bytes())
603
+ except OSError:
604
+ sha = None
605
+ userinfo = snapshot.get("userinfo")
606
+ if not isinstance(userinfo, dict):
607
+ userinfo = None
608
+ userinfo_source = snapshot.get("userinfo_source")
609
+ if userinfo_source not in ("header", "node-names"):
610
+ userinfo_source = None
611
+ url = snapshot.get("url")
612
+ return _envelope_data(
613
+ configured=True,
614
+ source=snapshot.get("source") or "manual",
615
+ url_masked=mask_url(url) if isinstance(url, str) else None,
616
+ fetched_at=snapshot.get("fetched_at"),
617
+ config_sha256=sha,
618
+ has_config_file=True,
619
+ userinfo=userinfo,
620
+ userinfo_source=userinfo_source,
621
+ config_path=str(config_path),
622
+ )
623
+
624
+
625
+ def clear_subscription(
626
+ *,
627
+ env: Optional[Mapping[str, str]] = None,
628
+ ) -> Dict[str, Any]:
629
+ """Remove the stored subscription (config file + snapshot). The legacy
630
+ repo-root file, if any, is deliberately left untouched."""
631
+ for path in (subscription_config_path(env), _snapshot_path(env)):
632
+ try:
633
+ path.unlink()
634
+ except FileNotFoundError:
635
+ pass
636
+ except OSError as exc:
637
+ raise CliError(
638
+ message=f"failed to remove {path}: {exc}",
639
+ exit_code=1,
640
+ error_code="AISC_ERR_GENERAL",
641
+ ) from exc
642
+ return {"configured": False}