starforge-cli 0.1.6__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 (55) hide show
  1. starforge_cli/__init__.py +3 -0
  2. starforge_cli/api_client.py +589 -0
  3. starforge_cli/auth.py +349 -0
  4. starforge_cli/catalog.py +124 -0
  5. starforge_cli/cli.py +74 -0
  6. starforge_cli/cli_ui.py +469 -0
  7. starforge_cli/client_device.py +104 -0
  8. starforge_cli/commands/__init__.py +1 -0
  9. starforge_cli/commands/admin.py +140 -0
  10. starforge_cli/commands/bench.py +94 -0
  11. starforge_cli/commands/common.py +178 -0
  12. starforge_cli/commands/dataset.py +150 -0
  13. starforge_cli/commands/exp.py +213 -0
  14. starforge_cli/commands/init.py +52 -0
  15. starforge_cli/commands/jobs.py +223 -0
  16. starforge_cli/commands/login.py +54 -0
  17. starforge_cli/commands/plugin.py +243 -0
  18. starforge_cli/commands/recipe.py +163 -0
  19. starforge_cli/commands/serve.py +79 -0
  20. starforge_cli/commands/submit.py +467 -0
  21. starforge_cli/commands/sweep.py +154 -0
  22. starforge_cli/config_resolve.py +17 -0
  23. starforge_cli/data_prep.py +60 -0
  24. starforge_cli/new_experiment.py +195 -0
  25. starforge_cli/packing.py +179 -0
  26. starforge_cli/plugins_lock.py +73 -0
  27. starforge_cli/project.py +130 -0
  28. starforge_cli/recipe_lock.py +453 -0
  29. starforge_cli/scaffold/agent-run.py.tmpl +146 -0
  30. starforge_cli/scaffold/custom-framework/train.sh +56 -0
  31. starforge_cli/scaffold/experiment-template/.gitkeep +0 -0
  32. starforge_cli/scaffold/experiment-template/README.md +36 -0
  33. starforge_cli/scaffold/experiment-template/config.yaml +44 -0
  34. starforge_cli/scaffold/project/common/README.md +12 -0
  35. starforge_cli/scaffold/project/common/__init__.py +0 -0
  36. starforge_cli/scaffold/project/configs/README.md +103 -0
  37. starforge_cli/scaffold/project/configs/base/README.md +24 -0
  38. starforge_cli/scaffold/project/configs/base/distillation_math.yaml +284 -0
  39. starforge_cli/scaffold/project/configs/base/grpo_lora.yaml +30 -0
  40. starforge_cli/scaffold/project/configs/base/grpo_math_1B.yaml +470 -0
  41. starforge_cli/scaffold/project/configs/base/grpo_megatron.yaml +43 -0
  42. starforge_cli/scaffold/project/configs/base/grpo_noncolocated.yaml +18 -0
  43. starforge_cli/scaffold/project/configs/base/grpo_sliding_puzzle.yaml +81 -0
  44. starforge_cli/scaffold/project/configs/base/ppo_math_1B.yaml +454 -0
  45. starforge_cli/scaffold/project/configs/base/rm.yaml +224 -0
  46. starforge_cli/scaffold/project/configs/base/sft.yaml +294 -0
  47. starforge_cli/scaffold/project/configs/models/README.md +16 -0
  48. starforge_cli/scaffold/project/configs/models/qwen3.5-4b.yaml +12 -0
  49. starforge_cli/scaffold/project/configs/models/qwen3.5-9b.yaml +10 -0
  50. starforge_cli/scaffold/project/gitignore +11 -0
  51. starforge_cli/spec_builder.py +372 -0
  52. starforge_cli-0.1.6.dist-info/METADATA +40 -0
  53. starforge_cli-0.1.6.dist-info/RECORD +55 -0
  54. starforge_cli-0.1.6.dist-info/WHEEL +4 -0
  55. starforge_cli-0.1.6.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,469 @@
1
+ """CLI 用户可见的错误 / 提示格式化(stderr,简洁可读)。
2
+
3
+ 避免把 API / 业务错误当作 typer.BadParameter 抛出——那会显示误导性的
4
+ 「Invalid value:」前缀。风格参考现代 CLI:短标题 + 要点 + 可执行提示。
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import re
10
+ import sys
11
+ import time
12
+ import urllib.error
13
+ from contextlib import contextmanager
14
+ from dataclasses import dataclass
15
+ from typing import Optional
16
+
17
+ import typer
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ParsedMessage:
22
+ title: str
23
+ items: tuple[str, ...] = ()
24
+ body: str = ""
25
+ hint: str = ""
26
+
27
+
28
+ # 常见服务端文案 → 更短的标题与固定提示
29
+ _KNOWN_TITLES: tuple[tuple[str, str], ...] = (
30
+ ("提交前 HuggingFace 资源预检未通过", "HuggingFace 资源预检未通过"),
31
+ ("HuggingFace 资源预检未通过", "HuggingFace 资源预检未通过"),
32
+ )
33
+
34
+ _HINT_RULES: tuple[tuple[tuple[str, ...], str], ...] = (
35
+ (("未绑定 HF", "绑定后重试"), "在 Web 控制台绑定 HuggingFace:集成 → HuggingFace"),
36
+ (("gated", "访问条款"), "到 HuggingFace 接受该资源的访问条款后再试"),
37
+ (("请先运行 sf login", "登录"), "运行 sf login 登录"),
38
+ (("登录令牌无效", "登录失败"), "运行 sf login 重新登录"),
39
+ )
40
+
41
+ # 含这些片段时不附加 CLI 侧「→ 提示」(服务端文案已足够或会误导)
42
+ _HINT_SUPPRESS = ("不是有效的 HuggingFace repo id", "继承了未 override", "org/name")
43
+
44
+
45
+ def http_error_detail(e: urllib.error.HTTPError, *, fallback: str) -> str:
46
+ """从 HTTP 响应提取可读错误信息(不暴露状态码等实现细节)。"""
47
+ raw = e.read().decode(errors="ignore")
48
+ try:
49
+ payload = json.loads(raw)
50
+ detail = payload.get("detail", payload)
51
+ if isinstance(detail, str) and detail.strip():
52
+ return detail.strip()
53
+ if isinstance(detail, list) and detail:
54
+ first = detail[0]
55
+ if isinstance(first, dict) and first.get("msg"):
56
+ return str(first["msg"])
57
+ return str(first)
58
+ except json.JSONDecodeError:
59
+ pass
60
+ text = raw.strip()
61
+ return text[:240] if text else fallback
62
+
63
+
64
+ def parse_message(text: str) -> ParsedMessage:
65
+ """把服务端长文本拆成标题 / 要点 / 提示。"""
66
+ raw = (text or "").strip()
67
+ if not raw:
68
+ return ParsedMessage(title="操作失败")
69
+
70
+ title = raw
71
+ items: list[str] = []
72
+ body = ""
73
+
74
+ if "\n" in raw:
75
+ first, rest = raw.split("\n", 1)
76
+ title = first.rstrip(":").strip()
77
+ for line in rest.splitlines():
78
+ line = line.strip()
79
+ if line.startswith("- "):
80
+ items.append(line[2:].strip())
81
+ elif line:
82
+ body = f"{body}\n{line}".strip() if body else line
83
+ elif raw.startswith("- "):
84
+ items.append(raw[2:].strip())
85
+ title = "操作失败"
86
+
87
+ for prefix, short in _KNOWN_TITLES:
88
+ if title.startswith(prefix):
89
+ title = short
90
+ break
91
+ if title.endswith(":"):
92
+ title = title[:-1]
93
+
94
+ hint = _guess_hint(" ".join(items) or raw)
95
+ if not items and not body:
96
+ body = raw if title != raw else ""
97
+
98
+ return ParsedMessage(title=title, items=tuple(items), body=body, hint=hint)
99
+
100
+
101
+ def _guess_hint(text: str) -> str:
102
+ if any(s in text for s in _HINT_SUPPRESS):
103
+ return ""
104
+ for keys, hint in _HINT_RULES:
105
+ if any(k in text for k in keys):
106
+ return hint
107
+ return ""
108
+
109
+
110
+ def _shorten_bullet(item: str) -> tuple[str, str]:
111
+ """把「主句;补充说明」拆成两行,便于扫读。"""
112
+ extra = ""
113
+ core = item.strip()
114
+ if ";" in core:
115
+ core, extra = core.split(";", 1)
116
+ core, extra = core.strip(), extra.strip()
117
+ # 常见 HF 预检:「无权访问 dataset X(私有/未授权,401)」
118
+ m = re.match(r"^(无权访问|无法访问|找不到)\s+(model|dataset)\s+(\S+)", core)
119
+ if m:
120
+ verb, kind, name = m.groups()
121
+ name = name.split("(", 1)[0].split("(", 1)[0]
122
+ kind_label = "模型" if kind == "model" else "数据集"
123
+ rest = core[m.end():].strip()
124
+ tail_parts = [p.strip("()() ") for p in (rest, verb, extra) if p and p.strip("()() ")]
125
+ return f"{kind_label} {name}", " · ".join(tail_parts)
126
+ if extra:
127
+ return core, extra
128
+ return core, ""
129
+
130
+
131
+ def emit_error(
132
+ title: str,
133
+ *,
134
+ items: Optional[list[str]] = None,
135
+ body: str = "",
136
+ hint: str = "",
137
+ ) -> None:
138
+ """向 stderr 输出一块结构化错误(不退出)。"""
139
+ stop_active_progress()
140
+ typer.echo("", err=True)
141
+ typer.secho(f" ✗ {title}", fg=typer.colors.RED, bold=True, err=True)
142
+ if items:
143
+ for item in items:
144
+ head, tail = _shorten_bullet(item)
145
+ typer.secho(f" • {head}", fg=typer.colors.RED, err=True)
146
+ if tail:
147
+ typer.secho(f" {tail}", err=True)
148
+ elif body:
149
+ for line in body.splitlines():
150
+ typer.secho(f" {line}", fg=typer.colors.RED, err=True)
151
+ if hint:
152
+ typer.echo("", err=True)
153
+ typer.secho(f" → {hint}", fg=typer.colors.YELLOW, err=True)
154
+ typer.echo("", err=True)
155
+
156
+
157
+ def emit_warning(title: str, *, body: str = "", hint: str = "") -> None:
158
+ stop_active_progress()
159
+ typer.secho(f" ! {title}", fg=typer.colors.YELLOW, bold=True, err=True)
160
+ if body:
161
+ for line in body.splitlines():
162
+ typer.secho(f" {line}", err=True)
163
+ if hint:
164
+ typer.secho(f" → {hint}", fg=typer.colors.YELLOW, err=True)
165
+
166
+
167
+ def fail(
168
+ message: str,
169
+ *,
170
+ title: str = "",
171
+ items: Optional[list[str]] = None,
172
+ hint: str = "",
173
+ code: int = 1,
174
+ ) -> None:
175
+ """打印错误并退出(替代 typer.BadParameter 用于非参数校验场景)。"""
176
+ if title or items or hint:
177
+ emit_error(title or "操作失败", items=items, body="" if items else message, hint=hint)
178
+ else:
179
+ parsed = parse_message(message)
180
+ emit_error(
181
+ parsed.title,
182
+ items=list(parsed.items) or None,
183
+ body=parsed.body if not parsed.items else "",
184
+ hint=parsed.hint or hint,
185
+ )
186
+ raise typer.Exit(code)
187
+
188
+
189
+ def fail_http(e: urllib.error.HTTPError, *, fallback: str, title: str = "") -> None:
190
+ """HTTP 4xx/5xx:解析 detail 后友好展示并退出。"""
191
+ detail = http_error_detail(e, fallback=fallback)
192
+ parsed = parse_message(detail)
193
+ emit_error(
194
+ title or parsed.title,
195
+ items=list(parsed.items) or None,
196
+ body=parsed.body if not parsed.items else detail,
197
+ hint=parsed.hint,
198
+ )
199
+ raise typer.Exit(1) from e
200
+
201
+
202
+ # ----------------------------- 提交进度条(打包 → 上传 → 受理)-----------------------------
203
+ # 当前活跃的进度显示。rich Live 每秒重绘十余次,重绘会把光标移回步骤条顶部并擦掉下方内容——
204
+ # 若在 Live 运行期间直接往 stderr 打错误(提交被服务端拒绝就是这条路径),文案会被下一帧覆盖,
205
+ # 用户只剩一个退出码。因此所有 emit_* 都先停掉进度显示,再输出。
206
+ _active_progress: Optional[object] = None
207
+
208
+
209
+ def stop_active_progress() -> None:
210
+ """停掉当前进度显示(若有),让后续 stderr 输出不会被重绘覆盖。"""
211
+ global _active_progress
212
+ reporter, _active_progress = _active_progress, None
213
+ if reporter is not None:
214
+ reporter.stop() # type: ignore[attr-defined]
215
+
216
+
217
+ def human_bytes(n: float) -> str:
218
+ """字节数 → 人类可读(1023 B / 5.8 MB)。"""
219
+ for unit in ("B", "KB", "MB", "GB"):
220
+ if n < 1024 or unit == "GB":
221
+ return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
222
+ n /= 1024
223
+ return f"{n:.1f} GB"
224
+
225
+
226
+ def format_elapsed(seconds: float) -> str:
227
+ """阶段耗时 → 紧凑可读(3.2s / 1m 05s)。"""
228
+ seconds = max(0.0, seconds)
229
+ if seconds < 60:
230
+ return f"{seconds:.1f}s"
231
+ minutes, secs = divmod(int(seconds), 60)
232
+ if minutes < 60:
233
+ return f"{minutes}m {secs:02d}s"
234
+ hours, minutes = divmod(minutes, 60)
235
+ return f"{hours}h {minutes:02d}m"
236
+
237
+
238
+ def _stage_elapsed(stage: "_StageState") -> float:
239
+ if stage.started is None:
240
+ return 0.0
241
+ end = stage.finished if stage.finished is not None else time.monotonic()
242
+ return max(0.0, end - stage.started)
243
+
244
+
245
+ @dataclass
246
+ class _StageState:
247
+ key: str
248
+ label: str
249
+ status: str = "pending" # pending | active | done
250
+ detail: str = ""
251
+ started: float | None = None
252
+ finished: float | None = None
253
+
254
+
255
+ class _PlainReporter:
256
+ """无 rich / 非 TTY 时的降级上报:分阶段输出 + 完成耗时。"""
257
+
258
+ def __init__(self) -> None:
259
+ self._phase_started = time.monotonic()
260
+
261
+ def _mark_phase_done(self, label: str) -> None:
262
+ elapsed = format_elapsed(time.monotonic() - self._phase_started)
263
+ typer.secho(f" ✓ {label} {elapsed}", fg=typer.colors.GREEN, dim=True, err=True)
264
+ self._phase_started = time.monotonic()
265
+
266
+ def start_pack(self, total: int) -> None:
267
+ self._phase_started = time.monotonic()
268
+ typer.echo(f" · 打包工作目录({total} 个文件)…", err=True)
269
+
270
+ def pack_tick(self, n: int = 1) -> None: # noqa: D401
271
+ pass
272
+
273
+ def start_upload(self, total_bytes: int) -> None:
274
+ self._mark_phase_done("打包完成")
275
+ typer.echo(f" · 上传到 Lab({human_bytes(total_bytes)})…", err=True)
276
+
277
+ def upload_tick(self, n: int) -> None:
278
+ pass
279
+
280
+ def awaiting_server(self) -> None:
281
+ self._mark_phase_done("上传完成")
282
+ typer.echo(" · 服务端受理(预检 / 配额 / Ray 提交)…", err=True)
283
+
284
+ def finish(self) -> None:
285
+ self._mark_phase_done("已受理")
286
+
287
+ def stop(self) -> None:
288
+ pass
289
+
290
+ def __enter__(self):
291
+ global _active_progress
292
+ _active_progress = self
293
+ return self
294
+
295
+ def __exit__(self, *exc):
296
+ global _active_progress
297
+ if _active_progress is self:
298
+ _active_progress = None
299
+ self.stop()
300
+ return False
301
+
302
+
303
+ class _PipelineReporter:
304
+ """Claude Code 风格垂直步骤条:已完成 ✓ + 当前 spinner + 右对齐耗时。"""
305
+
306
+ _ORDER = ("pack", "upload", "server")
307
+
308
+ def __init__(self, console) -> None:
309
+ self._console = console
310
+ self._live = None
311
+ self._pack_total = 0
312
+ self._pack_done = 0
313
+ self._upload_total = 0
314
+ self._upload_done = 0
315
+ self._stages = {
316
+ "pack": _StageState("pack", "打包工作目录"),
317
+ "upload": _StageState("upload", "上传到 Lab"),
318
+ "server": _StageState("server", "服务端受理"),
319
+ }
320
+
321
+ def _ensure_live(self) -> None:
322
+ if self._live is not None:
323
+ return
324
+ from rich.live import Live
325
+
326
+ self._live = Live(
327
+ self._render(),
328
+ console=self._console,
329
+ refresh_per_second=12,
330
+ transient=True,
331
+ )
332
+ self._live.__enter__()
333
+
334
+ def _activate(self, key: str, *, detail: str = "") -> None:
335
+ stage = self._stages[key]
336
+ stage.status = "active"
337
+ stage.started = time.monotonic()
338
+ stage.finished = None
339
+ stage.detail = detail
340
+ self._refresh()
341
+
342
+ def _complete(self, key: str, *, detail: str = "") -> None:
343
+ stage = self._stages[key]
344
+ stage.status = "done"
345
+ if stage.started is None:
346
+ stage.started = time.monotonic()
347
+ stage.finished = time.monotonic()
348
+ if detail:
349
+ stage.detail = detail
350
+ self._refresh()
351
+
352
+ def _refresh(self) -> None:
353
+ if self._live is not None:
354
+ self._live.update(self._render())
355
+
356
+ def _render(self):
357
+ from rich.spinner import Spinner
358
+ from rich.table import Table
359
+ from rich.text import Text
360
+
361
+ table = Table(show_header=False, box=None, padding=(0, 1), expand=False, pad_edge=False)
362
+ table.add_column(width=2, no_wrap=True)
363
+ table.add_column(min_width=14, no_wrap=True)
364
+ table.add_column(min_width=28)
365
+ table.add_column(justify="right", min_width=8, no_wrap=True)
366
+
367
+ table.add_row("", Text("sf submit", style="bold"), "", "")
368
+
369
+ for key in self._ORDER:
370
+ stage = self._stages[key]
371
+ if stage.status == "pending":
372
+ continue
373
+ elapsed = format_elapsed(_stage_elapsed(stage))
374
+ if stage.status == "done":
375
+ table.add_row(
376
+ Text("✓", style="green"),
377
+ Text(stage.label, style="dim"),
378
+ Text(stage.detail, style="dim"),
379
+ Text(elapsed, style="dim"),
380
+ )
381
+ else:
382
+ table.add_row(
383
+ Spinner("dots", style="cyan", speed=0.85),
384
+ Text(stage.label, style="bold cyan"),
385
+ Text(stage.detail),
386
+ Text(elapsed, style="bold cyan"),
387
+ )
388
+ return table
389
+
390
+ def start_pack(self, total: int) -> None:
391
+ self._ensure_live()
392
+ self._pack_total = total
393
+ self._pack_done = 0
394
+ self._activate("pack", detail=f"0/{total} 文件")
395
+
396
+ def pack_tick(self, n: int = 1) -> None:
397
+ if self._stages["pack"].status != "active":
398
+ return
399
+ self._pack_done += n
400
+ self._stages["pack"].detail = f"{self._pack_done}/{self._pack_total} 文件"
401
+ self._refresh()
402
+
403
+ def start_upload(self, total_bytes: int) -> None:
404
+ self._complete("pack", detail=f"{self._pack_total}/{self._pack_total} 文件")
405
+ self._upload_total = total_bytes
406
+ self._upload_done = 0
407
+ self._activate("upload", detail=f"0 B / {human_bytes(total_bytes)}")
408
+
409
+ def upload_tick(self, n: int) -> None:
410
+ if self._stages["upload"].status != "active":
411
+ return
412
+ self._upload_done += n
413
+ elapsed = _stage_elapsed(self._stages["upload"])
414
+ detail = f"{human_bytes(self._upload_done)} / {human_bytes(self._upload_total)}"
415
+ if elapsed >= 0.2 and self._upload_done > 0:
416
+ detail += f" · {human_bytes(self._upload_done / elapsed)}/s"
417
+ self._stages["upload"].detail = detail
418
+ self._refresh()
419
+
420
+ def awaiting_server(self) -> None:
421
+ upload = self._stages["upload"]
422
+ upload_detail = human_bytes(self._upload_total)
423
+ elapsed = _stage_elapsed(upload)
424
+ if elapsed >= 0.2 and self._upload_total > 0:
425
+ upload_detail += f" · {human_bytes(self._upload_total / elapsed)}/s"
426
+ self._complete("upload", detail=upload_detail)
427
+ self._activate("server", detail="预检 · 配额 · 提交")
428
+
429
+ def finish(self) -> None:
430
+ self._complete("server", detail="完成")
431
+
432
+ def stop(self) -> None:
433
+ live, self._live = self._live, None
434
+ if live is not None:
435
+ live.__exit__(None, None, None)
436
+
437
+ def __enter__(self):
438
+ global _active_progress
439
+ _active_progress = self
440
+ return self
441
+
442
+ def __exit__(self, *exc):
443
+ global _active_progress
444
+ if _active_progress is self:
445
+ _active_progress = None
446
+ self.stop()
447
+ return False
448
+
449
+
450
+ @contextmanager
451
+ def submit_progress():
452
+ """提交进度条上下文:`with submit_progress() as reporter: submit_via_server(..., reporter=reporter)`。
453
+
454
+ 有 rich 且输出是 TTY 时用垂直步骤条;否则(CI / 管道 / 无 rich)降级为分阶段状态行。
455
+ """
456
+ reporter = _make_reporter()
457
+ with reporter as r:
458
+ yield r
459
+
460
+
461
+ def _make_reporter():
462
+ if not sys.stderr.isatty():
463
+ return _PlainReporter()
464
+ try:
465
+ from rich.console import Console
466
+ except Exception: # noqa: BLE001
467
+ return _PlainReporter()
468
+ console = Console(stderr=True, highlight=False, soft_wrap=False)
469
+ return _PipelineReporter(console)
@@ -0,0 +1,104 @@
1
+ """CLI 登录设备信息:终端采集 + 稳定设备 ID(哈希,不上传原始 UUID)。
2
+
3
+ 业界 CLI/OAuth 场景常见做法(GitHub CLI、gcloud、AWS SSO 等):
4
+ - 客户端自报 hostname / OS / 用户(可读标签)
5
+ - 读取 OS 级稳定标识(machine-id / Platform UUID / MachineGuid)后 **只上传 SHA256 短哈希**
6
+ - 不采集浏览器式 canvas/WebGL 指纹;不做跨站追踪
7
+
8
+ 与服务端 server/core/client_device.py 的字段契约保持一致。
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ import hashlib
14
+ import json
15
+ import os
16
+ import platform
17
+ import socket
18
+ import subprocess
19
+ from pathlib import Path
20
+ from typing import Any, Optional
21
+
22
+
23
+ def collect_cli_device() -> dict[str, str]:
24
+ """从当前终端环境采集设备信息(sf login 打开浏览器前调用)。"""
25
+ hostname = (socket.gethostname() or platform.node() or "").strip()[:64]
26
+ shell_path = (os.environ.get("SHELL") or "").strip()
27
+ shell = Path(shell_path).name[:32] if shell_path else ""
28
+
29
+ info: dict[str, str] = {
30
+ "hostname": hostname or "unknown",
31
+ "os": (platform.system() or "")[:32],
32
+ "os_release": (platform.release() or "")[:32],
33
+ "machine": (platform.machine() or "")[:32],
34
+ "platform": (platform.platform(terse=True) or "")[:120],
35
+ "user": (os.environ.get("USER") or os.environ.get("USERNAME") or "")[:64],
36
+ "terminal": (os.environ.get("TERM") or "")[:32],
37
+ "shell": shell,
38
+ "lab_version": _lab_version()[:32],
39
+ "source": "lab-cli",
40
+ }
41
+ device_id = _device_id_hash(_stable_machine_id(), fallback=hostname)
42
+ if device_id:
43
+ info["device_id"] = device_id
44
+ return info
45
+
46
+
47
+ def encode_device_param(info: dict[str, Any]) -> str:
48
+ raw = json.dumps(info, ensure_ascii=False, separators=(",", ":")).encode()
49
+ return base64.urlsafe_b64encode(raw).decode().rstrip("=")
50
+
51
+
52
+ def _lab_version() -> str:
53
+ try:
54
+ from importlib.metadata import version
55
+
56
+ return version("starforge")
57
+ except Exception: # noqa: BLE001
58
+ return "unknown"
59
+
60
+
61
+ def _stable_machine_id() -> Optional[str]:
62
+ """读取 OS 原生稳定标识(原始值仅本地使用,不上传)。"""
63
+ system = platform.system()
64
+ try:
65
+ if system == "Darwin":
66
+ proc = subprocess.run(
67
+ ["ioreg", "-rd1", "-c", "IOPlatformExpertDevice"],
68
+ capture_output=True,
69
+ text=True,
70
+ timeout=2,
71
+ check=False,
72
+ )
73
+ for line in proc.stdout.splitlines():
74
+ if "IOPlatformUUID" in line:
75
+ parts = line.split('"')
76
+ if len(parts) >= 2:
77
+ val = parts[-2].strip()
78
+ return val or None
79
+ elif system == "Linux":
80
+ for path in (Path("/etc/machine-id"), Path("/var/lib/dbus/machine-id")):
81
+ if path.is_file():
82
+ val = path.read_text(encoding="utf-8", errors="ignore").strip()
83
+ if val:
84
+ return val
85
+ elif system == "Windows":
86
+ import winreg
87
+
88
+ with winreg.OpenKey(
89
+ winreg.HKEY_LOCAL_MACHINE,
90
+ r"SOFTWARE\Microsoft\Cryptography",
91
+ ) as key:
92
+ val, _ = winreg.QueryValueEx(key, "MachineGuid")
93
+ if val:
94
+ return str(val).strip()
95
+ except Exception: # noqa: BLE001
96
+ return None
97
+ return None
98
+
99
+
100
+ def _device_id_hash(stable_id: Optional[str], *, fallback: str) -> Optional[str]:
101
+ raw = (stable_id or "").strip() or (fallback or "").strip()
102
+ if not raw:
103
+ return None
104
+ return hashlib.sha256(raw.encode()).hexdigest()[:16]
@@ -0,0 +1 @@
1
+ """lab CLI 命令实现,按领域分模块;app 组装见 starforge_cli.cli。"""