@genduo/desk 0.0.0-stage → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +186 -2
- package/api_client.py +65 -0
- package/bin/genduo-desk.js +54 -0
- package/cli.py +632 -0
- package/collect_access.py +162 -0
- package/collect_ads.py +255 -0
- package/ingest_util.py +136 -0
- package/keychain_util.py +115 -0
- package/local_config.py +53 -0
- package/package.json +42 -4
- package/rms_ads_parse.py +99 -0
- package/rms_browser_login.py +213 -0
- package/run.py +303 -0
- package/scripts/postinstall.js +23 -0
package/cli.py
ADDED
|
@@ -0,0 +1,632 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""genduo-desk — 店铺 Mac 采集 CLI(npm: @genduo/desk)。
|
|
4
|
+
|
|
5
|
+
主 UX(门店):
|
|
6
|
+
genduo-desk login --shop 5RT2
|
|
7
|
+
交互写入 Agent token + R-Login 两组凭据到 Keychain,
|
|
8
|
+
可选验证 RMS 登录、注册心跳、安装 LaunchAgent + caffeinate 防休眠。
|
|
9
|
+
|
|
10
|
+
超管侧(先备齐凭据 / 签发 token):
|
|
11
|
+
genduo-desk admin checklist --shop 5RT2
|
|
12
|
+
genduo-desk admin set-rms --shop 5RT2
|
|
13
|
+
genduo-desk admin write-bootstrap --shop 5RT2 # token 从 stdin,600 文件
|
|
14
|
+
|
|
15
|
+
不打印 token / 密码;不向生产库签发新 token(签发走中心 Web 或
|
|
16
|
+
`python -m app.tokens`,见 admin checklist)。
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import getpass
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
import textwrap
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
HERE = Path(__file__).resolve().parent
|
|
30
|
+
# 包安装位置 = 含 cli.py 的目录(npm global 下是 node_modules/@genduo/desk)。
|
|
31
|
+
# monorepo 开发时 HERE 仍是 tools/collector-agent/;ROOT 仅作 vendored 缺失时的回退。
|
|
32
|
+
ROOT = HERE.parent.parent # dataanalyst/(仅 monorepo 布局下有意义)
|
|
33
|
+
sys.path.insert(0, str(HERE))
|
|
34
|
+
|
|
35
|
+
import api_client # noqa: E402
|
|
36
|
+
import keychain_util as kc # noqa: E402
|
|
37
|
+
import local_config as cfg # noqa: E402
|
|
38
|
+
|
|
39
|
+
PLIST_LABEL = "jp.dataanalyst.agent"
|
|
40
|
+
PLIST_PATH = Path.home() / "Library" / "LaunchAgents" / f"{PLIST_LABEL}.plist"
|
|
41
|
+
CAFFEINATE_LABEL = "jp.dataanalyst.caffeinate"
|
|
42
|
+
CAFFEINATE_PATH = Path.home() / "Library" / "LaunchAgents" / f"{CAFFEINATE_LABEL}.plist"
|
|
43
|
+
CAFFEINATE_BIN = "/usr/bin/caffeinate"
|
|
44
|
+
# -d display -i idle -m disk -s system(AC):用户级即可,重启后随登录会话拉起
|
|
45
|
+
CAFFEINATE_ARGS = ["-dims"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _die(msg: str, code: int = 1) -> None:
|
|
49
|
+
print(msg, file=sys.stderr)
|
|
50
|
+
raise SystemExit(code)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _prompt(msg: str, *, default: str = "") -> str:
|
|
54
|
+
suffix = f" [{default}]" if default else ""
|
|
55
|
+
val = input(f"{msg}{suffix}: ").strip()
|
|
56
|
+
return val or default
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _prompt_secret(msg: str) -> str:
|
|
60
|
+
val = getpass.getpass(f"{msg}: ")
|
|
61
|
+
if not val:
|
|
62
|
+
_die(f"已取消:{msg} 不能为空")
|
|
63
|
+
return val
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _resolve_shop(explicit: str | None) -> str:
|
|
67
|
+
if explicit:
|
|
68
|
+
shop = explicit.upper()
|
|
69
|
+
else:
|
|
70
|
+
shop = (cfg.load().get("shop") or _prompt("店铺代码", default="5RT2")).upper()
|
|
71
|
+
if shop not in cfg.SHOPS:
|
|
72
|
+
_die(f"非法店铺: {shop}(允许: {', '.join(cfg.SHOPS)})")
|
|
73
|
+
return shop
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _resolve_base(explicit: str | None) -> str:
|
|
77
|
+
return (explicit or cfg.load().get("base") or cfg.DEFAULT_BASE).rstrip("/")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ---------------------------------------------------------------------------
|
|
81
|
+
# shop commands
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
def cmd_login(a: argparse.Namespace) -> None:
|
|
85
|
+
"""门店首次授权:token + R-Login → Keychain,可选心跳 / RMS 校验 / LaunchAgent。"""
|
|
86
|
+
shop = _resolve_shop(a.shop)
|
|
87
|
+
base = _resolve_base(a.base)
|
|
88
|
+
print(f"店铺 {shop} · 中心 {base}")
|
|
89
|
+
print("凭据只写入本机 Keychain,不会打印、不会上传到中心。")
|
|
90
|
+
|
|
91
|
+
# --- Agent token ---
|
|
92
|
+
token: str | None = None
|
|
93
|
+
if a.bootstrap:
|
|
94
|
+
boot = Path(a.bootstrap).expanduser()
|
|
95
|
+
if not boot.is_file():
|
|
96
|
+
_die(f"找不到 bootstrap 文件: {boot}")
|
|
97
|
+
data = json.loads(boot.read_text(encoding="utf-8"))
|
|
98
|
+
if (data.get("shop") or "").upper() != shop:
|
|
99
|
+
_die(f"bootstrap.shop={data.get('shop')} 与 --shop {shop} 不一致")
|
|
100
|
+
token = (data.get("agent_token") or "").strip()
|
|
101
|
+
if not token:
|
|
102
|
+
_die("bootstrap 缺少 agent_token")
|
|
103
|
+
print(f"已从 bootstrap 读取 token(文件: {boot})")
|
|
104
|
+
if a.consume_bootstrap:
|
|
105
|
+
try:
|
|
106
|
+
boot.unlink()
|
|
107
|
+
print("已删除 bootstrap 文件(一次性)")
|
|
108
|
+
except OSError as exc:
|
|
109
|
+
print(f"警告: 未能删除 bootstrap: {exc}", file=sys.stderr)
|
|
110
|
+
elif a.token_env:
|
|
111
|
+
token = os.environ.get("DATAANALYST_AGENT_TOKEN", "").strip()
|
|
112
|
+
if not token:
|
|
113
|
+
_die("DATAANALYST_AGENT_TOKEN 为空")
|
|
114
|
+
elif a.keep_token and kc.has_generic(kc.AGENT_TOKEN_SERVICE):
|
|
115
|
+
print("保留现有 Keychain Agent token")
|
|
116
|
+
else:
|
|
117
|
+
print("请粘贴超管签发的 Agent token(da_…,输入时不回显)")
|
|
118
|
+
token = _prompt_secret("Agent token")
|
|
119
|
+
|
|
120
|
+
if token:
|
|
121
|
+
try:
|
|
122
|
+
kc.set_agent_token(token)
|
|
123
|
+
except ValueError as exc:
|
|
124
|
+
_die(str(exc))
|
|
125
|
+
print(f"✓ Agent token → Keychain service={kc.AGENT_TOKEN_SERVICE}")
|
|
126
|
+
# 尽快丢掉明文引用
|
|
127
|
+
token = None
|
|
128
|
+
|
|
129
|
+
# --- R-Login 两组 ---
|
|
130
|
+
present = kc.rms_creds_present(shop)
|
|
131
|
+
if a.keep_rms and present["rlogin"] and present["ai"]:
|
|
132
|
+
print(f"保留现有 Keychain RMS 凭据 "
|
|
133
|
+
f"({kc.rms_rlogin_service(shop)}, {kc.rms_ai_service(shop)})")
|
|
134
|
+
else:
|
|
135
|
+
print("R-Login 第一组(glogin ID / 密码)")
|
|
136
|
+
rlogin_id = _prompt("R-Login ID")
|
|
137
|
+
rlogin_pw = _prompt_secret("R-Login 密码")
|
|
138
|
+
print("Rakuten 会员第二组(SSO 邮箱 / 密码)")
|
|
139
|
+
sso_user = _prompt("SSO 邮箱 / 用户名")
|
|
140
|
+
sso_pw = _prompt_secret("SSO 密码")
|
|
141
|
+
kc.set_rms_creds(shop, rlogin_id=rlogin_id, rlogin_password=rlogin_pw,
|
|
142
|
+
sso_user=sso_user, sso_password=sso_pw)
|
|
143
|
+
# 清引用
|
|
144
|
+
rlogin_pw = sso_pw = "" # noqa: F841
|
|
145
|
+
print(f"✓ R-Login → {kc.rms_rlogin_service(shop)}")
|
|
146
|
+
print(f"✓ SSO → {kc.rms_ai_service(shop)}")
|
|
147
|
+
|
|
148
|
+
path = cfg.save(shop, base)
|
|
149
|
+
print(f"✓ 本机配置 → {path}(无密钥)")
|
|
150
|
+
|
|
151
|
+
if not a.skip_heartbeat:
|
|
152
|
+
_do_heartbeat(shop, base)
|
|
153
|
+
|
|
154
|
+
if a.verify_rms:
|
|
155
|
+
_do_verify_rms(shop, headed=a.headed)
|
|
156
|
+
|
|
157
|
+
if a.install_service:
|
|
158
|
+
_do_install_service(
|
|
159
|
+
shop, base,
|
|
160
|
+
skip_caffeinate=getattr(a, "skip_caffeinate", False),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
print()
|
|
164
|
+
print("login 完成。常用后续:")
|
|
165
|
+
print(f" {sys.argv[0]} status")
|
|
166
|
+
print(f" {sys.argv[0]} heartbeat")
|
|
167
|
+
print(f" {sys.argv[0]} install-service # Agent + caffeinate LaunchAgent")
|
|
168
|
+
print(f" {sys.argv[0]} prevent-sleep # 仅装 caffeinate;pmset 需 sudo 另做")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def cmd_status(a: argparse.Namespace) -> None:
|
|
172
|
+
conf = cfg.load()
|
|
173
|
+
shop = _resolve_shop(a.shop or conf.get("shop"))
|
|
174
|
+
base = _resolve_base(a.base or conf.get("base"))
|
|
175
|
+
present = kc.rms_creds_present(shop)
|
|
176
|
+
print(f"shop {shop}")
|
|
177
|
+
print(f"base {base}")
|
|
178
|
+
print(f"version {cfg.VERSION}")
|
|
179
|
+
print(f"agent_token {'有' if present['agent_token'] else '缺'} ({kc.AGENT_TOKEN_SERVICE})")
|
|
180
|
+
print(f"rms_rlogin {'有' if present['rlogin'] else '缺'} ({kc.rms_rlogin_service(shop)})")
|
|
181
|
+
print(f"rms_ai {'有' if present['ai'] else '缺'} ({kc.rms_ai_service(shop)})")
|
|
182
|
+
print(f"launchagent {'已装' if PLIST_PATH.is_file() else '未装'} ({PLIST_PATH})")
|
|
183
|
+
print(f"caffeinate {'已装' if CAFFEINATE_PATH.is_file() else '未装'} ({CAFFEINATE_PATH})")
|
|
184
|
+
caf_alive = _caffeinate_running()
|
|
185
|
+
print(f"caffeinate_ps {'运行中' if caf_alive else '未运行'} ({CAFFEINATE_BIN} {' '.join(CAFFEINATE_ARGS)})")
|
|
186
|
+
if present["agent_token"] and not a.offline:
|
|
187
|
+
try:
|
|
188
|
+
tok = kc.get_agent_token()
|
|
189
|
+
hb = api_client.heartbeat(base, tok, shop, cfg.VERSION)
|
|
190
|
+
agent = (hb or {}).get("agent") or {}
|
|
191
|
+
print(f"heartbeat ok last_seen={agent.get('last_seen_at')}")
|
|
192
|
+
except Exception as exc: # noqa: BLE001
|
|
193
|
+
print(f"heartbeat 失败: {exc}")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def cmd_heartbeat(a: argparse.Namespace) -> None:
|
|
197
|
+
conf = cfg.load()
|
|
198
|
+
shop = _resolve_shop(a.shop or conf.get("shop"))
|
|
199
|
+
base = _resolve_base(a.base or conf.get("base"))
|
|
200
|
+
_do_heartbeat(shop, base, show_jobs=not a.no_jobs)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def cmd_install_service(a: argparse.Namespace) -> None:
|
|
204
|
+
conf = cfg.load()
|
|
205
|
+
shop = _resolve_shop(a.shop or conf.get("shop"))
|
|
206
|
+
base = _resolve_base(a.base or conf.get("base"))
|
|
207
|
+
_do_install_service(shop, base, force=a.force,
|
|
208
|
+
skip_caffeinate=getattr(a, "skip_caffeinate", False))
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def cmd_prevent_sleep(a: argparse.Namespace) -> None:
|
|
212
|
+
"""只装 caffeinate LaunchAgent,并打印需 sudo 的 pmset 说明。"""
|
|
213
|
+
_do_install_caffeinate(force=a.force)
|
|
214
|
+
_print_pmset_hint()
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def cmd_uninstall_service(_a: argparse.Namespace) -> None:
|
|
218
|
+
_launchctl_unload(PLIST_LABEL, PLIST_PATH)
|
|
219
|
+
if PLIST_PATH.is_file():
|
|
220
|
+
PLIST_PATH.unlink()
|
|
221
|
+
print(f"已移除 {PLIST_PATH}")
|
|
222
|
+
else:
|
|
223
|
+
print("Agent LaunchAgent 未安装")
|
|
224
|
+
_launchctl_unload(CAFFEINATE_LABEL, CAFFEINATE_PATH)
|
|
225
|
+
if CAFFEINATE_PATH.is_file():
|
|
226
|
+
CAFFEINATE_PATH.unlink()
|
|
227
|
+
print(f"已移除 {CAFFEINATE_PATH}")
|
|
228
|
+
else:
|
|
229
|
+
print("caffeinate LaunchAgent 未安装")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# ---------------------------------------------------------------------------
|
|
233
|
+
# admin commands
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
def cmd_admin_checklist(a: argparse.Namespace) -> None:
|
|
237
|
+
shop = _resolve_shop(a.shop)
|
|
238
|
+
name = f"agent-{shop}"
|
|
239
|
+
print(textwrap.dedent(f"""
|
|
240
|
+
======== 超管 · {shop} 开店清单 ========
|
|
241
|
+
|
|
242
|
+
目标 UX:超管先备齐本店全部凭据;门店只拿到本 CLI,运行
|
|
243
|
+
genduo-desk login --shop {shop}
|
|
244
|
+
完成登录授权(Keychain + 心跳),不必手写 LaunchAgent。
|
|
245
|
+
|
|
246
|
+
A. 中心签发 Agent token(只显示一次明文)
|
|
247
|
+
· Web:超管 → API Token
|
|
248
|
+
name = {name}
|
|
249
|
+
scopes = collect:view,collect:edit
|
|
250
|
+
shops = {shop}
|
|
251
|
+
days = 365(建议)
|
|
252
|
+
· 或 VPS:
|
|
253
|
+
cd /opt/dataanalyst && sudo -u postgres env \\
|
|
254
|
+
DATAANALYST_DSN=dbname=dataanalyst DATAANALYST_SCHEMA=prod \\
|
|
255
|
+
venv/bin/python -m app.tokens --env prod create \\
|
|
256
|
+
--name {name} --scopes collect:view,collect:edit \\
|
|
257
|
+
--shops {shop} --days 365
|
|
258
|
+
· 明文立刻交给门店(或写入一次性 bootstrap),不要进 git / 聊天记录。
|
|
259
|
+
|
|
260
|
+
B. 备齐 RMS 两组密码(中心不存;只给人/Keychain)
|
|
261
|
+
· 第一组 R-Login ID/密码 → Keychain {kc.rms_rlogin_service(shop)}
|
|
262
|
+
· 第二组 Rakuten SSO → Keychain {kc.rms_ai_service(shop)}
|
|
263
|
+
· 超管可在本机预先写入:
|
|
264
|
+
genduo-desk admin set-rms --shop {shop}
|
|
265
|
+
或让门店在 login 时自己输入。
|
|
266
|
+
|
|
267
|
+
C. 交给门店的材料
|
|
268
|
+
1. 本仓库 tools/collector-agent/(或 npm 包 @genduo/desk(命令 genduo-desk))
|
|
269
|
+
2. 店号 {shop}
|
|
270
|
+
3. Agent token(口头 / 密封通道 / bootstrap 文件)
|
|
271
|
+
4.(可选)R-Login 两组,若尚未写入该 Mac Keychain
|
|
272
|
+
|
|
273
|
+
D. 门店执行
|
|
274
|
+
python3 tools/collector-agent/cli.py login --shop {shop} --verify-rms --install-service
|
|
275
|
+
或:
|
|
276
|
+
genduo-desk admin write-bootstrap --shop {shop} --out /tmp/{shop}.boot.json
|
|
277
|
+
# 门店:
|
|
278
|
+
genduo-desk login --shop {shop} --bootstrap /tmp/{shop}.boot.json \\
|
|
279
|
+
--consume-bootstrap --verify-rms --install-service
|
|
280
|
+
|
|
281
|
+
E. 防休眠(install-service 默认会装 caffeinate)
|
|
282
|
+
· 用户级:LaunchAgent jp.dataanalyst.caffeinate → caffeinate -dims
|
|
283
|
+
(login --install-service / install-service / prevent-sleep)
|
|
284
|
+
· 系统级需管理员(CLI 不静默 sudo):
|
|
285
|
+
sudo pmset -a sleep 0 disksleep 0 displaysleep 0
|
|
286
|
+
sudo systemsetup -setrestartpowerfailure on
|
|
287
|
+
|
|
288
|
+
F. 验收
|
|
289
|
+
genduo-desk status
|
|
290
|
+
# 应见 caffeinate=已装 且 caffeinate_ps=运行中
|
|
291
|
+
# 中心 /sources 或 GET /api/agent/status 可见 {shop} online
|
|
292
|
+
""").strip())
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def cmd_admin_set_rms(a: argparse.Namespace) -> None:
|
|
296
|
+
"""超管在本机(或店里 Mac)写入 RMS Keychain,不涉及 Agent token。"""
|
|
297
|
+
shop = _resolve_shop(a.shop)
|
|
298
|
+
print(f"为 {shop} 写入 RMS Keychain(不打印密码)")
|
|
299
|
+
rlogin_id = _prompt("R-Login ID")
|
|
300
|
+
rlogin_pw = _prompt_secret("R-Login 密码")
|
|
301
|
+
sso_user = _prompt("SSO 邮箱 / 用户名")
|
|
302
|
+
sso_pw = _prompt_secret("SSO 密码")
|
|
303
|
+
kc.set_rms_creds(shop, rlogin_id=rlogin_id, rlogin_password=rlogin_pw,
|
|
304
|
+
sso_user=sso_user, sso_password=sso_pw)
|
|
305
|
+
print(f"✓ {kc.rms_rlogin_service(shop)}")
|
|
306
|
+
print(f"✓ {kc.rms_ai_service(shop)}")
|
|
307
|
+
if a.verify_rms:
|
|
308
|
+
_do_verify_rms(shop, headed=a.headed)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def cmd_admin_write_bootstrap(a: argparse.Namespace) -> None:
|
|
312
|
+
"""把超管刚签发的 token 写成 600 的一次性文件;不进仓库。"""
|
|
313
|
+
shop = _resolve_shop(a.shop)
|
|
314
|
+
out = Path(a.out).expanduser() if a.out else Path(f"/tmp/dataanalyst-{shop}-bootstrap.json")
|
|
315
|
+
print("粘贴 Agent token(不回显);将写入仅含 shop+token 的 600 文件")
|
|
316
|
+
token = _prompt_secret("Agent token")
|
|
317
|
+
if not token.startswith("da_"):
|
|
318
|
+
_die("Agent token 应以 da_ 开头")
|
|
319
|
+
payload = {
|
|
320
|
+
"shop": shop,
|
|
321
|
+
"agent_token": token,
|
|
322
|
+
"base": _resolve_base(a.base),
|
|
323
|
+
"note": "一次性;login --consume-bootstrap 后应删除。勿提交 git。",
|
|
324
|
+
}
|
|
325
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
out.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
|
327
|
+
encoding="utf-8")
|
|
328
|
+
os.chmod(out, 0o600)
|
|
329
|
+
print(f"✓ bootstrap → {out} (mode 600)")
|
|
330
|
+
print(f"门店执行: genduo-desk login --shop {shop} "
|
|
331
|
+
f"--bootstrap {out} --consume-bootstrap")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
# ---------------------------------------------------------------------------
|
|
335
|
+
# helpers
|
|
336
|
+
# ---------------------------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
def _do_heartbeat(shop: str, base: str, *, show_jobs: bool = True) -> None:
|
|
339
|
+
try:
|
|
340
|
+
tok = kc.get_agent_token()
|
|
341
|
+
except RuntimeError as exc:
|
|
342
|
+
_die(str(exc))
|
|
343
|
+
try:
|
|
344
|
+
hb = api_client.heartbeat(base, tok, shop, cfg.VERSION)
|
|
345
|
+
except api_client.ApiError as exc:
|
|
346
|
+
_die(f"heartbeat 失败: {exc}")
|
|
347
|
+
agent = (hb or {}).get("agent") or {}
|
|
348
|
+
print(f"✓ heartbeat ok shop={shop} last_seen={agent.get('last_seen_at')}")
|
|
349
|
+
if show_jobs:
|
|
350
|
+
try:
|
|
351
|
+
j = api_client.jobs(base, tok, shop, limit=5)
|
|
352
|
+
n = len((j or {}).get("jobs") or j or []) if isinstance(j, dict) else 0
|
|
353
|
+
print(f" jobs queued/leased: {n}")
|
|
354
|
+
except api_client.ApiError as exc:
|
|
355
|
+
print(f" jobs: {exc}")
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _do_verify_rms(shop: str, *, headed: bool = False) -> None:
|
|
359
|
+
# 优先包内 vendored 副本(npm 安装后无 monorepo);其次 monorepo tools/。
|
|
360
|
+
candidates = [
|
|
361
|
+
HERE / "rms_browser_login.py",
|
|
362
|
+
HERE / "vendor" / "rms_browser_login.py",
|
|
363
|
+
ROOT / "tools" / "rms_browser_login.py",
|
|
364
|
+
]
|
|
365
|
+
script = next((p for p in candidates if p.is_file()), None)
|
|
366
|
+
if script is None:
|
|
367
|
+
_die("找不到 rms_browser_login.py(包内 vendored 或 monorepo tools/)")
|
|
368
|
+
out = HERE / "state" / f"{shop}.json"
|
|
369
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
370
|
+
cmd = [sys.executable, str(script), "--shop", shop, "--from-keychain",
|
|
371
|
+
"--out", str(out)]
|
|
372
|
+
if headed:
|
|
373
|
+
cmd.append("--headed")
|
|
374
|
+
print("验证 RMS 登录(Playwright;登录阶段不截密码)…")
|
|
375
|
+
r = subprocess.run(cmd)
|
|
376
|
+
if r.returncode != 0:
|
|
377
|
+
_die("RMS 登录验证失败", r.returncode)
|
|
378
|
+
try:
|
|
379
|
+
os.chmod(out, 0o600)
|
|
380
|
+
except OSError:
|
|
381
|
+
pass
|
|
382
|
+
print(f"✓ RMS 会话 → {out}")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _caffeinate_running() -> bool:
|
|
386
|
+
r = subprocess.run(["pgrep", "-x", "caffeinate"], capture_output=True)
|
|
387
|
+
return r.returncode == 0
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _launchctl_unload(label: str, plist_path: Path) -> None:
|
|
391
|
+
uid = os.getuid()
|
|
392
|
+
subprocess.run(["launchctl", "bootout", f"gui/{uid}/{label}"],
|
|
393
|
+
capture_output=True)
|
|
394
|
+
subprocess.run(["launchctl", "unload", str(plist_path)],
|
|
395
|
+
capture_output=True)
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _launchctl_load(label: str, plist_path: Path) -> None:
|
|
399
|
+
uid = os.getuid()
|
|
400
|
+
_launchctl_unload(label, plist_path)
|
|
401
|
+
r = subprocess.run(["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)],
|
|
402
|
+
capture_output=True, text=True)
|
|
403
|
+
if r.returncode != 0:
|
|
404
|
+
r2 = subprocess.run(["launchctl", "load", str(plist_path)],
|
|
405
|
+
capture_output=True, text=True)
|
|
406
|
+
if r2.returncode != 0:
|
|
407
|
+
_die(f"launchctl 失败 ({label}): {(r.stderr or r2.stderr or '')[:300]}")
|
|
408
|
+
subprocess.run(["launchctl", "kickstart", "-k", f"gui/{uid}/{label}"],
|
|
409
|
+
capture_output=True)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _print_pmset_hint() -> None:
|
|
413
|
+
"""系统级防休眠需 sudo;CLI 只提示,不静默执行。"""
|
|
414
|
+
print()
|
|
415
|
+
print("防休眠分层:")
|
|
416
|
+
print(f" · 用户级(已由 CLI 安装):LaunchAgent {CAFFEINATE_LABEL}")
|
|
417
|
+
print(f" → {CAFFEINATE_BIN} {' '.join(CAFFEINATE_ARGS)}(登录会话内 KeepAlive)")
|
|
418
|
+
print(" · 系统级(需管理员,CLI 不会自动 sudo):")
|
|
419
|
+
print(" sudo pmset -a sleep 0 disksleep 0 displaysleep 0")
|
|
420
|
+
print(" sudo systemsetup -setrestartpowerfailure on # 断电恢复,可选")
|
|
421
|
+
print(" 执行前请确认这是店铺常开机;笔记本合盖策略另议。")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _do_install_caffeinate(*, force: bool = False) -> None:
|
|
425
|
+
if not Path(CAFFEINATE_BIN).is_file():
|
|
426
|
+
_die(f"找不到 {CAFFEINATE_BIN}(非 macOS?)")
|
|
427
|
+
CAFFEINATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
428
|
+
logs = HERE / "logs"
|
|
429
|
+
logs.mkdir(parents=True, exist_ok=True)
|
|
430
|
+
if CAFFEINATE_PATH.is_file() and not force:
|
|
431
|
+
print(f"caffeinate LaunchAgent 已存在: {CAFFEINATE_PATH}(加 --force 覆盖)")
|
|
432
|
+
_launchctl_load(CAFFEINATE_LABEL, CAFFEINATE_PATH)
|
|
433
|
+
if _caffeinate_running():
|
|
434
|
+
print("✓ caffeinate 进程已在运行")
|
|
435
|
+
else:
|
|
436
|
+
print("警告: caffeinate LaunchAgent 已装但进程未见到,请查 Console",
|
|
437
|
+
file=sys.stderr)
|
|
438
|
+
return
|
|
439
|
+
|
|
440
|
+
arg_lines = "\n".join(
|
|
441
|
+
f" <string>{a}</string>" for a in CAFFEINATE_ARGS
|
|
442
|
+
)
|
|
443
|
+
plist = (
|
|
444
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
445
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"\n'
|
|
446
|
+
' "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
|
|
447
|
+
'<plist version="1.0">\n'
|
|
448
|
+
"<dict>\n"
|
|
449
|
+
f" <key>Label</key><string>{CAFFEINATE_LABEL}</string>\n"
|
|
450
|
+
" <key>ProgramArguments</key>\n"
|
|
451
|
+
" <array>\n"
|
|
452
|
+
f" <string>{CAFFEINATE_BIN}</string>\n"
|
|
453
|
+
f"{arg_lines}\n"
|
|
454
|
+
" </array>\n"
|
|
455
|
+
" <key>RunAtLoad</key><true/>\n"
|
|
456
|
+
" <key>KeepAlive</key><true/>\n"
|
|
457
|
+
f" <key>StandardOutPath</key><string>{logs}/caffeinate.stdout.log</string>\n"
|
|
458
|
+
f" <key>StandardErrorPath</key><string>{logs}/caffeinate.stderr.log</string>\n"
|
|
459
|
+
"</dict>\n"
|
|
460
|
+
"</plist>\n"
|
|
461
|
+
)
|
|
462
|
+
CAFFEINATE_PATH.write_text(plist, encoding="utf-8")
|
|
463
|
+
_launchctl_load(CAFFEINATE_LABEL, CAFFEINATE_PATH)
|
|
464
|
+
print(f"✓ caffeinate LaunchAgent → {CAFFEINATE_PATH}")
|
|
465
|
+
print(f" program: {CAFFEINATE_BIN} {' '.join(CAFFEINATE_ARGS)}")
|
|
466
|
+
if _caffeinate_running():
|
|
467
|
+
print("✓ caffeinate 进程运行中(防 idle/磁盘/显示/系统休眠)")
|
|
468
|
+
else:
|
|
469
|
+
print("警告: LaunchAgent 已 load,但尚未见到 caffeinate 进程",
|
|
470
|
+
file=sys.stderr)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _pick_agent_python() -> str:
|
|
474
|
+
"""优先 monorepo .venv(有 playwright),否则当前解释器。"""
|
|
475
|
+
venv_py = HERE.parents[1] / ".venv" / "bin" / "python"
|
|
476
|
+
if venv_py.is_file() and os.access(venv_py, os.X_OK):
|
|
477
|
+
return str(venv_py)
|
|
478
|
+
return sys.executable
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _do_install_service(shop: str, base: str, *, force: bool = False,
|
|
482
|
+
skip_caffeinate: bool = False) -> None:
|
|
483
|
+
"""安装 Agent LaunchAgent。
|
|
484
|
+
|
|
485
|
+
依赖长驻 `run.py`(进程内 sleep 循环)。KeepAlive=崩溃重启,
|
|
486
|
+
**不是**短任务轮询——切勿把 run.py 改回「跑完即退」否则会狂刷心跳。
|
|
487
|
+
"""
|
|
488
|
+
cfg.save(shop, base)
|
|
489
|
+
python = _pick_agent_python()
|
|
490
|
+
run_py = HERE / "run.py"
|
|
491
|
+
logs = HERE / "logs"
|
|
492
|
+
logs.mkdir(parents=True, exist_ok=True)
|
|
493
|
+
PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
494
|
+
if PLIST_PATH.is_file() and not force:
|
|
495
|
+
print(f"Agent LaunchAgent 已存在: {PLIST_PATH}(加 --force 覆盖)")
|
|
496
|
+
else:
|
|
497
|
+
# KeepAlive + 长驻 run.py;ThrottleInterval 仅限制崩溃重启频率
|
|
498
|
+
plist = (
|
|
499
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
500
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"\n'
|
|
501
|
+
' "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
|
|
502
|
+
'<plist version="1.0">\n'
|
|
503
|
+
"<dict>\n"
|
|
504
|
+
f" <key>Label</key><string>{PLIST_LABEL}</string>\n"
|
|
505
|
+
" <key>ProgramArguments</key>\n"
|
|
506
|
+
" <array>\n"
|
|
507
|
+
f" <string>{python}</string>\n"
|
|
508
|
+
f" <string>{run_py}</string>\n"
|
|
509
|
+
" </array>\n"
|
|
510
|
+
" <key>EnvironmentVariables</key>\n"
|
|
511
|
+
" <dict>\n"
|
|
512
|
+
f" <key>DATAANALYST_SHOP</key><string>{shop}</string>\n"
|
|
513
|
+
f" <key>DATAANALYST_BASE</key><string>{base}</string>\n"
|
|
514
|
+
" </dict>\n"
|
|
515
|
+
" <key>RunAtLoad</key><true/>\n"
|
|
516
|
+
" <key>KeepAlive</key><true/>\n"
|
|
517
|
+
" <key>ThrottleInterval</key><integer>30</integer>\n"
|
|
518
|
+
f" <key>WorkingDirectory</key><string>{HERE}</string>\n"
|
|
519
|
+
f" <key>StandardOutPath</key><string>{logs}/stdout.log</string>\n"
|
|
520
|
+
f" <key>StandardErrorPath</key><string>{logs}/stderr.log</string>\n"
|
|
521
|
+
"</dict>\n"
|
|
522
|
+
"</plist>\n"
|
|
523
|
+
)
|
|
524
|
+
PLIST_PATH.write_text(plist, encoding="utf-8")
|
|
525
|
+
_launchctl_load(PLIST_LABEL, PLIST_PATH)
|
|
526
|
+
print(f"✓ Agent LaunchAgent → {PLIST_PATH}")
|
|
527
|
+
print(f" program: {python} {run_py}")
|
|
528
|
+
print(" 说明: run.py 进程内 sleep 常驻;KeepAlive 仅崩溃重启(ThrottleInterval=30s)")
|
|
529
|
+
|
|
530
|
+
if skip_caffeinate:
|
|
531
|
+
print("已跳过 caffeinate(--skip-caffeinate)")
|
|
532
|
+
else:
|
|
533
|
+
_do_install_caffeinate(force=force)
|
|
534
|
+
_print_pmset_hint()
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _link_hint() -> None:
|
|
538
|
+
"""可选:提示如何把 cli 挂到 PATH。"""
|
|
539
|
+
pass
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
# ---------------------------------------------------------------------------
|
|
543
|
+
# argparse
|
|
544
|
+
# ---------------------------------------------------------------------------
|
|
545
|
+
|
|
546
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
547
|
+
ap = argparse.ArgumentParser(
|
|
548
|
+
prog="genduo-desk",
|
|
549
|
+
description="店铺 Mac 采集 Agent CLI(login 授权 / 心跳 / LaunchAgent)",
|
|
550
|
+
)
|
|
551
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
552
|
+
common.add_argument("--shop", help="店铺代码,如 5RT2")
|
|
553
|
+
common.add_argument("--base", help=f"中心 URL(默认 {cfg.DEFAULT_BASE})")
|
|
554
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
555
|
+
|
|
556
|
+
p = sub.add_parser("login", parents=[common],
|
|
557
|
+
help="门店登录授权:token + R-Login → Keychain")
|
|
558
|
+
p.add_argument("--bootstrap", help="超管生成的一次性 JSON(含 agent_token)")
|
|
559
|
+
p.add_argument("--consume-bootstrap", action="store_true",
|
|
560
|
+
help="login 成功后删除 bootstrap 文件")
|
|
561
|
+
p.add_argument("--token-env", action="store_true",
|
|
562
|
+
help="从 DATAANALYST_AGENT_TOKEN 读 token")
|
|
563
|
+
p.add_argument("--keep-token", action="store_true",
|
|
564
|
+
help="Keychain 已有 token 则不覆盖")
|
|
565
|
+
p.add_argument("--keep-rms", action="store_true",
|
|
566
|
+
help="Keychain 已有 RMS 凭据则不覆盖")
|
|
567
|
+
p.add_argument("--skip-heartbeat", action="store_true")
|
|
568
|
+
p.add_argument("--verify-rms", action="store_true",
|
|
569
|
+
help="用 Playwright 验证 R-Login 并写 state/<shop>.json")
|
|
570
|
+
p.add_argument("--headed", action="store_true", help="RMS 验证时显示浏览器")
|
|
571
|
+
p.add_argument("--install-service", action="store_true",
|
|
572
|
+
help="login 末尾安装 Agent + caffeinate LaunchAgent")
|
|
573
|
+
p.add_argument("--skip-caffeinate", action="store_true",
|
|
574
|
+
help="与 --install-service 合用时不装 caffeinate")
|
|
575
|
+
p.set_defaults(func=cmd_login)
|
|
576
|
+
|
|
577
|
+
p = sub.add_parser("status", parents=[common],
|
|
578
|
+
help="查看 Keychain / LaunchAgent / caffeinate / 心跳")
|
|
579
|
+
p.add_argument("--offline", action="store_true", help="不打中心心跳")
|
|
580
|
+
p.set_defaults(func=cmd_status)
|
|
581
|
+
|
|
582
|
+
p = sub.add_parser("heartbeat", parents=[common],
|
|
583
|
+
help="向中心打一次心跳并拉任务")
|
|
584
|
+
p.add_argument("--no-jobs", action="store_true")
|
|
585
|
+
p.set_defaults(func=cmd_heartbeat)
|
|
586
|
+
|
|
587
|
+
p = sub.add_parser("install-service", parents=[common],
|
|
588
|
+
help="安装 / 更新 Agent + caffeinate LaunchAgent")
|
|
589
|
+
p.add_argument("--force", action="store_true")
|
|
590
|
+
p.add_argument("--skip-caffeinate", action="store_true",
|
|
591
|
+
help="只装 Agent,不装 caffeinate")
|
|
592
|
+
p.set_defaults(func=cmd_install_service)
|
|
593
|
+
|
|
594
|
+
p = sub.add_parser("prevent-sleep", parents=[common],
|
|
595
|
+
help="安装 caffeinate LaunchAgent,并提示 pmset(需 sudo)")
|
|
596
|
+
p.add_argument("--force", action="store_true")
|
|
597
|
+
p.set_defaults(func=cmd_prevent_sleep)
|
|
598
|
+
|
|
599
|
+
p = sub.add_parser("uninstall-service", parents=[common],
|
|
600
|
+
help="移除 Agent 与 caffeinate LaunchAgent")
|
|
601
|
+
p.set_defaults(func=cmd_uninstall_service)
|
|
602
|
+
|
|
603
|
+
adm = sub.add_parser("admin", help="超管:备齐凭据 / 清单 / bootstrap")
|
|
604
|
+
adm_sub = adm.add_subparsers(dest="admin_cmd", required=True)
|
|
605
|
+
|
|
606
|
+
p = adm_sub.add_parser("checklist", parents=[common],
|
|
607
|
+
help="打印某店开店清单(含签发 token 命令)")
|
|
608
|
+
p.set_defaults(func=cmd_admin_checklist)
|
|
609
|
+
|
|
610
|
+
p = adm_sub.add_parser("set-rms", parents=[common],
|
|
611
|
+
help="交互写入本店 RMS Keychain")
|
|
612
|
+
p.add_argument("--verify-rms", action="store_true")
|
|
613
|
+
p.add_argument("--headed", action="store_true")
|
|
614
|
+
p.set_defaults(func=cmd_admin_set_rms)
|
|
615
|
+
|
|
616
|
+
p = adm_sub.add_parser("write-bootstrap", parents=[common],
|
|
617
|
+
help="把 token 写成 600 一次性文件给门店 login 用")
|
|
618
|
+
p.add_argument("--out", help="输出路径(默认 /tmp/dataanalyst-<SHOP>-bootstrap.json)")
|
|
619
|
+
p.set_defaults(func=cmd_admin_write_bootstrap)
|
|
620
|
+
|
|
621
|
+
return ap
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def main(argv: list[str] | None = None) -> None:
|
|
625
|
+
ap = build_parser()
|
|
626
|
+
args = ap.parse_args(argv)
|
|
627
|
+
# 顶层 --shop/--base 渗入子命令 namespace
|
|
628
|
+
args.func(args)
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
if __name__ == "__main__":
|
|
632
|
+
main()
|