@genduo/desk 0.0.0-stage → 0.5.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 +187 -2
- package/api_client.py +65 -0
- package/bin/genduo-desk.js +54 -0
- package/cli.py +667 -0
- package/collect_access.py +162 -0
- package/collect_ads.py +255 -0
- package/ingest_util.py +136 -0
- package/jdy_secrets.py +228 -0
- package/keychain_util.py +108 -0
- package/local_config.py +53 -0
- package/package.json +43 -4
- package/rms_ads_parse.py +99 -0
- package/rms_browser_login.py +192 -0
- package/run.py +303 -0
- package/scripts/postinstall.js +23 -0
package/cli.py
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
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
|
+
本机 Keychain 只需已有 JDY_MCP / 服务器URL;
|
|
8
|
+
Agent token 与 R-Login 两组从简道云密码表经 MCP 读取,
|
|
9
|
+
可选验证 RMS 登录、注册心跳、安装 LaunchAgent + caffeinate 防休眠。
|
|
10
|
+
|
|
11
|
+
超管侧(先备齐简道云 Title + 签发 token 写入密码表):
|
|
12
|
+
genduo-desk admin checklist --shop 5RT2
|
|
13
|
+
genduo-desk admin verify-secrets --shop 5RT2
|
|
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
|
+
"""门店授权:Agent token → 环境(及可选写入 LaunchAgent);不写 dataanalyst Keychain。"""
|
|
86
|
+
shop = _resolve_shop(a.shop)
|
|
87
|
+
base = _resolve_base(a.base)
|
|
88
|
+
print(f"店铺 {shop} · 中心 {base}")
|
|
89
|
+
print("与销量计算系统一致:Keychain 仅 JDY_MCP(本机可无);"
|
|
90
|
+
"不写 dataanalyst-agent-token / dataanalyst/rms/*。")
|
|
91
|
+
|
|
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 or a.keep_token:
|
|
111
|
+
token = os.environ.get("DATAANALYST_AGENT_TOKEN", "").strip() or None
|
|
112
|
+
if a.token_env and not token:
|
|
113
|
+
_die("DATAANALYST_AGENT_TOKEN 为空")
|
|
114
|
+
if token:
|
|
115
|
+
print("使用环境变量 DATAANALYST_AGENT_TOKEN")
|
|
116
|
+
if token is None and not a.keep_token:
|
|
117
|
+
print("请粘贴超管签发的 Agent token(da_…,输入时不回显)")
|
|
118
|
+
token = _prompt_secret("Agent token")
|
|
119
|
+
|
|
120
|
+
if token:
|
|
121
|
+
if not token.startswith("da_"):
|
|
122
|
+
_die("Agent token 应以 da_ 开头")
|
|
123
|
+
os.environ["DATAANALYST_AGENT_TOKEN"] = token
|
|
124
|
+
print("✓ Agent token → 本进程环境 DATAANALYST_AGENT_TOKEN(不写 Keychain)")
|
|
125
|
+
token = None
|
|
126
|
+
|
|
127
|
+
# JDY_MCP:与销量系统相同;本机没有属正常,仅提示
|
|
128
|
+
if kc.has_generic(kc.JDY_MCP_SERVICE):
|
|
129
|
+
print(f"✓ JDY_MCP 钥匙串可见(或已设 JDY_MCP_URL)")
|
|
130
|
+
if not a.skip_secret_check:
|
|
131
|
+
try:
|
|
132
|
+
_ = kc.get_api_key()
|
|
133
|
+
_ = None
|
|
134
|
+
print(f"✓ JDY API Key ← 密码表 Title={kc.JDY_API_KEY_TITLE}")
|
|
135
|
+
except RuntimeError as exc:
|
|
136
|
+
print(f"警告: 未能经 MCP 读取 API Key: {exc}", file=sys.stderr)
|
|
137
|
+
else:
|
|
138
|
+
print("(本机无 JDY_MCP / JDY_MCP_URL — 开发机可如此;店机请维护钥匙串)")
|
|
139
|
+
|
|
140
|
+
# RMS:可选交互写入环境变量供本进程 verify-rms(不写 Keychain)
|
|
141
|
+
if a.verify_rms and not a.keep_rms:
|
|
142
|
+
present = kc.rms_creds_present(shop)
|
|
143
|
+
if not (present["rlogin"] and present["ai"]):
|
|
144
|
+
print("R-Login 第一组(写入本进程环境,不写 Keychain)")
|
|
145
|
+
p = kc.rms_env_prefix(shop)
|
|
146
|
+
os.environ[p + "LOGIN_ID"] = _prompt("R-Login ID")
|
|
147
|
+
os.environ[p + "PASSWORD"] = _prompt_secret("R-Login 密码")
|
|
148
|
+
print("Rakuten 会员第二组")
|
|
149
|
+
os.environ[p + "SHOP_USER"] = _prompt("SSO 邮箱 / 用户名")
|
|
150
|
+
os.environ[p + "SHOP_PASSWORD"] = _prompt_secret("SSO 密码")
|
|
151
|
+
print(f"✓ RMS 凭据 → 环境变量 {p}*")
|
|
152
|
+
|
|
153
|
+
path = cfg.save(shop, base)
|
|
154
|
+
print(f"✓ 本机配置 → {path}(无密钥)")
|
|
155
|
+
|
|
156
|
+
if not a.skip_heartbeat:
|
|
157
|
+
_do_heartbeat(shop, base)
|
|
158
|
+
|
|
159
|
+
if a.verify_rms:
|
|
160
|
+
_do_verify_rms(shop, headed=a.headed)
|
|
161
|
+
|
|
162
|
+
if a.install_service:
|
|
163
|
+
_do_install_service(
|
|
164
|
+
shop, base,
|
|
165
|
+
skip_caffeinate=getattr(a, "skip_caffeinate", False),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
print()
|
|
169
|
+
print("login 完成。常用后续:")
|
|
170
|
+
print(f" {sys.argv[0]} status")
|
|
171
|
+
print(f" {sys.argv[0]} heartbeat")
|
|
172
|
+
print(f" {sys.argv[0]} install-service")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def cmd_status(a: argparse.Namespace) -> None:
|
|
177
|
+
conf = cfg.load()
|
|
178
|
+
shop = _resolve_shop(a.shop or conf.get("shop"))
|
|
179
|
+
base = _resolve_base(a.base or conf.get("base"))
|
|
180
|
+
present = kc.rms_creds_present(shop)
|
|
181
|
+
print(f"shop {shop}")
|
|
182
|
+
print(f"base {base}")
|
|
183
|
+
print(f"version {cfg.VERSION}")
|
|
184
|
+
print(f"jdy_mcp {'有' if present.get('mcp_url') else '无(可预期)'} "
|
|
185
|
+
f"({kc.JDY_MCP_SERVICE}/{kc.JDY_MCP_ACCOUNT} 或 JDY_MCP_URL)")
|
|
186
|
+
print(f"agent_token {'有' if present['agent_token'] else '缺'} "
|
|
187
|
+
f"(env DATAANALYST_AGENT_TOKEN)")
|
|
188
|
+
print(f"rms_env rlogin={'有' if present['rlogin'] else '缺'} "
|
|
189
|
+
f"ai={'有' if present['ai'] else '缺'} ({kc.rms_env_prefix(shop)}*)")
|
|
190
|
+
print(f"launchagent {'已装' if PLIST_PATH.is_file() else '未装'} ({PLIST_PATH})")
|
|
191
|
+
print(f"caffeinate {'已装' if CAFFEINATE_PATH.is_file() else '未装'} ({CAFFEINATE_PATH})")
|
|
192
|
+
caf_alive = _caffeinate_running()
|
|
193
|
+
print(f"caffeinate_ps {'运行中' if caf_alive else '未运行'} "
|
|
194
|
+
f"({CAFFEINATE_BIN} {' '.join(CAFFEINATE_ARGS)})")
|
|
195
|
+
if present["agent_token"] and not a.offline:
|
|
196
|
+
try:
|
|
197
|
+
tok = kc.get_agent_token(shop)
|
|
198
|
+
hb = api_client.heartbeat(base, tok, shop, cfg.VERSION)
|
|
199
|
+
agent = (hb or {}).get("agent") or {}
|
|
200
|
+
print(f"heartbeat ok last_seen={agent.get('last_seen_at')}")
|
|
201
|
+
except Exception as exc: # noqa: BLE001
|
|
202
|
+
print(f"heartbeat 失败: {exc}")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def cmd_heartbeat(a: argparse.Namespace) -> None:
|
|
207
|
+
conf = cfg.load()
|
|
208
|
+
shop = _resolve_shop(a.shop or conf.get("shop"))
|
|
209
|
+
base = _resolve_base(a.base or conf.get("base"))
|
|
210
|
+
_do_heartbeat(shop, base, show_jobs=not a.no_jobs)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def cmd_install_service(a: argparse.Namespace) -> None:
|
|
214
|
+
conf = cfg.load()
|
|
215
|
+
shop = _resolve_shop(a.shop or conf.get("shop"))
|
|
216
|
+
base = _resolve_base(a.base or conf.get("base"))
|
|
217
|
+
_do_install_service(shop, base, force=a.force,
|
|
218
|
+
skip_caffeinate=getattr(a, "skip_caffeinate", False))
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def cmd_prevent_sleep(a: argparse.Namespace) -> None:
|
|
222
|
+
"""只装 caffeinate LaunchAgent,并打印需 sudo 的 pmset 说明。"""
|
|
223
|
+
_do_install_caffeinate(force=a.force)
|
|
224
|
+
_print_pmset_hint()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def cmd_uninstall_service(_a: argparse.Namespace) -> None:
|
|
228
|
+
_launchctl_unload(PLIST_LABEL, PLIST_PATH)
|
|
229
|
+
if PLIST_PATH.is_file():
|
|
230
|
+
PLIST_PATH.unlink()
|
|
231
|
+
print(f"已移除 {PLIST_PATH}")
|
|
232
|
+
else:
|
|
233
|
+
print("Agent LaunchAgent 未安装")
|
|
234
|
+
_launchctl_unload(CAFFEINATE_LABEL, CAFFEINATE_PATH)
|
|
235
|
+
if CAFFEINATE_PATH.is_file():
|
|
236
|
+
CAFFEINATE_PATH.unlink()
|
|
237
|
+
print(f"已移除 {CAFFEINATE_PATH}")
|
|
238
|
+
else:
|
|
239
|
+
print("caffeinate LaunchAgent 未安装")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# ---------------------------------------------------------------------------
|
|
243
|
+
# admin commands
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
|
|
246
|
+
def cmd_admin_checklist(a: argparse.Namespace) -> None:
|
|
247
|
+
shop = _resolve_shop(a.shop)
|
|
248
|
+
name = f"agent-{shop}"
|
|
249
|
+
print(textwrap.dedent(f"""
|
|
250
|
+
======== 超管 · {shop} 开店清单 ========
|
|
251
|
+
|
|
252
|
+
凭据逻辑与「销量计算系统」一致:
|
|
253
|
+
· 店机 Keychain:仅 JDY_MCP / 服务器URL(开发机可无)
|
|
254
|
+
· 或环境变量 JDY_MCP_URL
|
|
255
|
+
· JDY API Key:MCP 密码表 Title={kc.JDY_API_KEY_TITLE}
|
|
256
|
+
· 不再使用 dataanalyst-agent-token / dataanalyst/rms/* Keychain
|
|
257
|
+
|
|
258
|
+
A. 中心签发 Agent token(只显示一次明文)
|
|
259
|
+
· Web / VPS python -m app.tokens …
|
|
260
|
+
name={name} scopes=collect:view,collect:edit shops={shop}
|
|
261
|
+
· 门店用环境变量 DATAANALYST_AGENT_TOKEN,或 login --bootstrap
|
|
262
|
+
|
|
263
|
+
B. RMS 两组
|
|
264
|
+
· 环境变量 {kc.rms_env_prefix(shop)}LOGIN_ID / PASSWORD / SHOP_USER / SHOP_PASSWORD
|
|
265
|
+
· 或 rms_browser_login --env-file
|
|
266
|
+
· login --verify-rms 时可交互写入「本进程环境」(不写 Keychain)
|
|
267
|
+
|
|
268
|
+
C. 店机 Keychain(用户维护;与销量系统相同)
|
|
269
|
+
· service=JDY_MCP account=服务器URL
|
|
270
|
+
· 本开发机没有该条目是正常的
|
|
271
|
+
|
|
272
|
+
D. 门店执行
|
|
273
|
+
genduo-desk login --shop {shop} --verify-rms --install-service
|
|
274
|
+
|
|
275
|
+
E. 验收
|
|
276
|
+
genduo-desk status
|
|
277
|
+
""").strip())
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def cmd_admin_verify_secrets(a: argparse.Namespace) -> None:
|
|
281
|
+
"""探测 JDY_MCP → get_api_key(与销量系统相同);不打印 Key。"""
|
|
282
|
+
shop = _resolve_shop(a.shop)
|
|
283
|
+
print(f"校验 JDY MCP / API Key(shop={shop} 仅作上下文;不打印 secret)")
|
|
284
|
+
if not kc.has_generic(kc.JDY_MCP_SERVICE):
|
|
285
|
+
print("本机无 JDY_MCP / JDY_MCP_URL(开发机可预期;店机请维护)")
|
|
286
|
+
raise SystemExit(0)
|
|
287
|
+
try:
|
|
288
|
+
_ = kc.get_api_key()
|
|
289
|
+
_ = None
|
|
290
|
+
print(f"✓ API Key 可读 Title={kc.JDY_API_KEY_TITLE}")
|
|
291
|
+
except RuntimeError as exc:
|
|
292
|
+
_die(str(exc))
|
|
293
|
+
if a.verify_rms:
|
|
294
|
+
_do_verify_rms(shop, headed=a.headed)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def cmd_admin_set_rms(a: argparse.Namespace) -> None:
|
|
298
|
+
print("admin set-rms 已废弃:不再写入 dataanalyst/rms/* Keychain。")
|
|
299
|
+
print(f"请设置环境变量 {kc.rms_env_prefix(_resolve_shop(a.shop))}* "
|
|
300
|
+
"或使用 --env-file。")
|
|
301
|
+
raise SystemExit(2)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def cmd_admin_write_bootstrap(a: argparse.Namespace) -> None:
|
|
305
|
+
"""把 token 写成 600 一次性文件;不进仓库、不写 Keychain。"""
|
|
306
|
+
shop = _resolve_shop(a.shop)
|
|
307
|
+
out = Path(a.out).expanduser() if a.out else Path(f"/tmp/dataanalyst-{shop}-bootstrap.json")
|
|
308
|
+
print("粘贴 Agent token(不回显);将写入仅含 shop+token 的 600 文件")
|
|
309
|
+
token = _prompt_secret("Agent token")
|
|
310
|
+
if not token.startswith("da_"):
|
|
311
|
+
_die("Agent token 应以 da_ 开头")
|
|
312
|
+
payload = {
|
|
313
|
+
"shop": shop,
|
|
314
|
+
"agent_token": token,
|
|
315
|
+
"base": _resolve_base(a.base),
|
|
316
|
+
"note": "一次性;login --consume-bootstrap 后应删除。勿提交 git。",
|
|
317
|
+
}
|
|
318
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
319
|
+
out.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n",
|
|
320
|
+
encoding="utf-8")
|
|
321
|
+
os.chmod(out, 0o600)
|
|
322
|
+
print(f"✓ bootstrap → {out} (mode 600)")
|
|
323
|
+
print(f"门店执行: genduo-desk login --shop {shop} "
|
|
324
|
+
f"--bootstrap {out} --consume-bootstrap")
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
# helpers
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
def _do_heartbeat(shop: str, base: str, *, show_jobs: bool = True) -> None:
|
|
333
|
+
try:
|
|
334
|
+
tok = kc.get_agent_token(shop)
|
|
335
|
+
except RuntimeError as exc:
|
|
336
|
+
_die(str(exc))
|
|
337
|
+
try:
|
|
338
|
+
hb = api_client.heartbeat(base, tok, shop, cfg.VERSION)
|
|
339
|
+
except api_client.ApiError as exc:
|
|
340
|
+
_die(f"heartbeat 失败: {exc}")
|
|
341
|
+
agent = (hb or {}).get("agent") or {}
|
|
342
|
+
print(f"✓ heartbeat ok shop={shop} last_seen={agent.get('last_seen_at')}")
|
|
343
|
+
if show_jobs:
|
|
344
|
+
try:
|
|
345
|
+
j = api_client.jobs(base, tok, shop, limit=5)
|
|
346
|
+
n = len((j or {}).get("jobs") or j or []) if isinstance(j, dict) else 0
|
|
347
|
+
print(f" jobs queued/leased: {n}")
|
|
348
|
+
except api_client.ApiError as exc:
|
|
349
|
+
print(f" jobs: {exc}")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _do_verify_rms(shop: str, *, headed: bool = False) -> None:
|
|
353
|
+
# 优先包内 vendored 副本(npm 安装后无 monorepo);其次 monorepo tools/。
|
|
354
|
+
candidates = [
|
|
355
|
+
HERE / "rms_browser_login.py",
|
|
356
|
+
HERE / "vendor" / "rms_browser_login.py",
|
|
357
|
+
ROOT / "tools" / "rms_browser_login.py",
|
|
358
|
+
]
|
|
359
|
+
script = next((p for p in candidates if p.is_file()), None)
|
|
360
|
+
if script is None:
|
|
361
|
+
_die("找不到 rms_browser_login.py(包内 vendored 或 monorepo tools/)")
|
|
362
|
+
out = HERE / "state" / f"{shop}.json"
|
|
363
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
364
|
+
# 把本进程 RMS_* 环境写成 600 临时 env-file(不进 Keychain、用后删)
|
|
365
|
+
try:
|
|
366
|
+
creds = kc.get_rms_creds(shop)
|
|
367
|
+
except RuntimeError as exc:
|
|
368
|
+
_die(str(exc))
|
|
369
|
+
pfx = kc.rms_env_prefix(shop)
|
|
370
|
+
env_path = HERE / "state" / f".{shop}.rms.env"
|
|
371
|
+
lines = [
|
|
372
|
+
f"{pfx}LOGIN_ID={creds['rlogin_id']}",
|
|
373
|
+
f"{pfx}PASSWORD={creds['rlogin_password']}",
|
|
374
|
+
f"{pfx}SHOP_USER={creds['sso_user']}",
|
|
375
|
+
f"{pfx}SHOP_PASSWORD={creds['sso_password']}",
|
|
376
|
+
"",
|
|
377
|
+
]
|
|
378
|
+
env_path.write_text("\n".join(lines), encoding="utf-8")
|
|
379
|
+
os.chmod(env_path, 0o600)
|
|
380
|
+
# 清内存引用
|
|
381
|
+
creds = None # noqa: F841
|
|
382
|
+
cmd = [sys.executable, str(script), "--shop", shop,
|
|
383
|
+
"--env-file", str(env_path), "--out", str(out)]
|
|
384
|
+
if headed:
|
|
385
|
+
cmd.append("--headed")
|
|
386
|
+
print("验证 RMS 登录(Playwright;登录阶段不截密码)…")
|
|
387
|
+
try:
|
|
388
|
+
r = subprocess.run(cmd)
|
|
389
|
+
finally:
|
|
390
|
+
try:
|
|
391
|
+
env_path.unlink()
|
|
392
|
+
except OSError:
|
|
393
|
+
pass
|
|
394
|
+
if r.returncode != 0:
|
|
395
|
+
_die("RMS 登录验证失败", r.returncode)
|
|
396
|
+
try:
|
|
397
|
+
os.chmod(out, 0o600)
|
|
398
|
+
except OSError:
|
|
399
|
+
pass
|
|
400
|
+
print(f"✓ RMS 会话 → {out}")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _caffeinate_running() -> bool:
|
|
404
|
+
r = subprocess.run(["pgrep", "-x", "caffeinate"], capture_output=True)
|
|
405
|
+
return r.returncode == 0
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _launchctl_unload(label: str, plist_path: Path) -> None:
|
|
409
|
+
uid = os.getuid()
|
|
410
|
+
subprocess.run(["launchctl", "bootout", f"gui/{uid}/{label}"],
|
|
411
|
+
capture_output=True)
|
|
412
|
+
subprocess.run(["launchctl", "unload", str(plist_path)],
|
|
413
|
+
capture_output=True)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _launchctl_load(label: str, plist_path: Path) -> None:
|
|
417
|
+
uid = os.getuid()
|
|
418
|
+
_launchctl_unload(label, plist_path)
|
|
419
|
+
r = subprocess.run(["launchctl", "bootstrap", f"gui/{uid}", str(plist_path)],
|
|
420
|
+
capture_output=True, text=True)
|
|
421
|
+
if r.returncode != 0:
|
|
422
|
+
r2 = subprocess.run(["launchctl", "load", str(plist_path)],
|
|
423
|
+
capture_output=True, text=True)
|
|
424
|
+
if r2.returncode != 0:
|
|
425
|
+
_die(f"launchctl 失败 ({label}): {(r.stderr or r2.stderr or '')[:300]}")
|
|
426
|
+
subprocess.run(["launchctl", "kickstart", "-k", f"gui/{uid}/{label}"],
|
|
427
|
+
capture_output=True)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _print_pmset_hint() -> None:
|
|
431
|
+
"""系统级防休眠需 sudo;CLI 只提示,不静默执行。"""
|
|
432
|
+
print()
|
|
433
|
+
print("防休眠分层:")
|
|
434
|
+
print(f" · 用户级(已由 CLI 安装):LaunchAgent {CAFFEINATE_LABEL}")
|
|
435
|
+
print(f" → {CAFFEINATE_BIN} {' '.join(CAFFEINATE_ARGS)}(登录会话内 KeepAlive)")
|
|
436
|
+
print(" · 系统级(需管理员,CLI 不会自动 sudo):")
|
|
437
|
+
print(" sudo pmset -a sleep 0 disksleep 0 displaysleep 0")
|
|
438
|
+
print(" sudo systemsetup -setrestartpowerfailure on # 断电恢复,可选")
|
|
439
|
+
print(" 执行前请确认这是店铺常开机;笔记本合盖策略另议。")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _do_install_caffeinate(*, force: bool = False) -> None:
|
|
443
|
+
if not Path(CAFFEINATE_BIN).is_file():
|
|
444
|
+
_die(f"找不到 {CAFFEINATE_BIN}(非 macOS?)")
|
|
445
|
+
CAFFEINATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
446
|
+
logs = HERE / "logs"
|
|
447
|
+
logs.mkdir(parents=True, exist_ok=True)
|
|
448
|
+
if CAFFEINATE_PATH.is_file() and not force:
|
|
449
|
+
print(f"caffeinate LaunchAgent 已存在: {CAFFEINATE_PATH}(加 --force 覆盖)")
|
|
450
|
+
_launchctl_load(CAFFEINATE_LABEL, CAFFEINATE_PATH)
|
|
451
|
+
if _caffeinate_running():
|
|
452
|
+
print("✓ caffeinate 进程已在运行")
|
|
453
|
+
else:
|
|
454
|
+
print("警告: caffeinate LaunchAgent 已装但进程未见到,请查 Console",
|
|
455
|
+
file=sys.stderr)
|
|
456
|
+
return
|
|
457
|
+
|
|
458
|
+
arg_lines = "\n".join(
|
|
459
|
+
f" <string>{a}</string>" for a in CAFFEINATE_ARGS
|
|
460
|
+
)
|
|
461
|
+
plist = (
|
|
462
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
463
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"\n'
|
|
464
|
+
' "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
|
|
465
|
+
'<plist version="1.0">\n'
|
|
466
|
+
"<dict>\n"
|
|
467
|
+
f" <key>Label</key><string>{CAFFEINATE_LABEL}</string>\n"
|
|
468
|
+
" <key>ProgramArguments</key>\n"
|
|
469
|
+
" <array>\n"
|
|
470
|
+
f" <string>{CAFFEINATE_BIN}</string>\n"
|
|
471
|
+
f"{arg_lines}\n"
|
|
472
|
+
" </array>\n"
|
|
473
|
+
" <key>RunAtLoad</key><true/>\n"
|
|
474
|
+
" <key>KeepAlive</key><true/>\n"
|
|
475
|
+
f" <key>StandardOutPath</key><string>{logs}/caffeinate.stdout.log</string>\n"
|
|
476
|
+
f" <key>StandardErrorPath</key><string>{logs}/caffeinate.stderr.log</string>\n"
|
|
477
|
+
"</dict>\n"
|
|
478
|
+
"</plist>\n"
|
|
479
|
+
)
|
|
480
|
+
CAFFEINATE_PATH.write_text(plist, encoding="utf-8")
|
|
481
|
+
_launchctl_load(CAFFEINATE_LABEL, CAFFEINATE_PATH)
|
|
482
|
+
print(f"✓ caffeinate LaunchAgent → {CAFFEINATE_PATH}")
|
|
483
|
+
print(f" program: {CAFFEINATE_BIN} {' '.join(CAFFEINATE_ARGS)}")
|
|
484
|
+
if _caffeinate_running():
|
|
485
|
+
print("✓ caffeinate 进程运行中(防 idle/磁盘/显示/系统休眠)")
|
|
486
|
+
else:
|
|
487
|
+
print("警告: LaunchAgent 已 load,但尚未见到 caffeinate 进程",
|
|
488
|
+
file=sys.stderr)
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def _pick_agent_python() -> str:
|
|
492
|
+
"""优先 monorepo .venv(有 playwright),否则当前解释器。"""
|
|
493
|
+
venv_py = HERE.parents[1] / ".venv" / "bin" / "python"
|
|
494
|
+
if venv_py.is_file() and os.access(venv_py, os.X_OK):
|
|
495
|
+
return str(venv_py)
|
|
496
|
+
return sys.executable
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _do_install_service(shop: str, base: str, *, force: bool = False,
|
|
500
|
+
skip_caffeinate: bool = False) -> None:
|
|
501
|
+
"""安装 Agent LaunchAgent。
|
|
502
|
+
|
|
503
|
+
依赖长驻 `run.py`(进程内 sleep 循环)。KeepAlive=崩溃重启,
|
|
504
|
+
**不是**短任务轮询——切勿把 run.py 改回「跑完即退」否则会狂刷心跳。
|
|
505
|
+
"""
|
|
506
|
+
cfg.save(shop, base)
|
|
507
|
+
def _plist_esc(s: str) -> str:
|
|
508
|
+
return (s.replace("&", "&").replace("<", "<")
|
|
509
|
+
.replace(">", ">").replace('"', """))
|
|
510
|
+
tok_env = (os.environ.get("DATAANALYST_AGENT_TOKEN") or "").strip()
|
|
511
|
+
tok_xml = (
|
|
512
|
+
f" <key>DATAANALYST_AGENT_TOKEN</key><string>{_plist_esc(tok_env)}</string>\n"
|
|
513
|
+
if tok_env else ""
|
|
514
|
+
)
|
|
515
|
+
python = _pick_agent_python()
|
|
516
|
+
run_py = HERE / "run.py"
|
|
517
|
+
logs = HERE / "logs"
|
|
518
|
+
logs.mkdir(parents=True, exist_ok=True)
|
|
519
|
+
PLIST_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
520
|
+
if PLIST_PATH.is_file() and not force:
|
|
521
|
+
print(f"Agent LaunchAgent 已存在: {PLIST_PATH}(加 --force 覆盖)")
|
|
522
|
+
else:
|
|
523
|
+
# KeepAlive + 长驻 run.py;ThrottleInterval 仅限制崩溃重启频率
|
|
524
|
+
plist = (
|
|
525
|
+
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
526
|
+
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"\n'
|
|
527
|
+
' "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n'
|
|
528
|
+
'<plist version="1.0">\n'
|
|
529
|
+
"<dict>\n"
|
|
530
|
+
f" <key>Label</key><string>{PLIST_LABEL}</string>\n"
|
|
531
|
+
" <key>ProgramArguments</key>\n"
|
|
532
|
+
" <array>\n"
|
|
533
|
+
f" <string>{python}</string>\n"
|
|
534
|
+
f" <string>{run_py}</string>\n"
|
|
535
|
+
" </array>\n"
|
|
536
|
+
" <key>EnvironmentVariables</key>\n"
|
|
537
|
+
" <dict>\n"
|
|
538
|
+
f" <key>DATAANALYST_SHOP</key><string>{shop}</string>\n"
|
|
539
|
+
f" <key>DATAANALYST_BASE</key><string>{base}</string>\n"
|
|
540
|
+
f"{tok_xml}"
|
|
541
|
+
" </dict>\n"
|
|
542
|
+
" <key>RunAtLoad</key><true/>\n"
|
|
543
|
+
" <key>KeepAlive</key><true/>\n"
|
|
544
|
+
" <key>ThrottleInterval</key><integer>30</integer>\n"
|
|
545
|
+
f" <key>WorkingDirectory</key><string>{HERE}</string>\n"
|
|
546
|
+
f" <key>StandardOutPath</key><string>{logs}/stdout.log</string>\n"
|
|
547
|
+
f" <key>StandardErrorPath</key><string>{logs}/stderr.log</string>\n"
|
|
548
|
+
"</dict>\n"
|
|
549
|
+
"</plist>\n"
|
|
550
|
+
)
|
|
551
|
+
PLIST_PATH.write_text(plist, encoding="utf-8")
|
|
552
|
+
_launchctl_load(PLIST_LABEL, PLIST_PATH)
|
|
553
|
+
print(f"✓ Agent LaunchAgent → {PLIST_PATH}")
|
|
554
|
+
print(f" program: {python} {run_py}")
|
|
555
|
+
print(" 说明: run.py 进程内 sleep 常驻;KeepAlive 仅崩溃重启(ThrottleInterval=30s)")
|
|
556
|
+
|
|
557
|
+
if skip_caffeinate:
|
|
558
|
+
print("已跳过 caffeinate(--skip-caffeinate)")
|
|
559
|
+
else:
|
|
560
|
+
_do_install_caffeinate(force=force)
|
|
561
|
+
_print_pmset_hint()
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def _link_hint() -> None:
|
|
565
|
+
"""可选:提示如何把 cli 挂到 PATH。"""
|
|
566
|
+
pass
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# ---------------------------------------------------------------------------
|
|
570
|
+
# argparse
|
|
571
|
+
# ---------------------------------------------------------------------------
|
|
572
|
+
|
|
573
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
574
|
+
ap = argparse.ArgumentParser(
|
|
575
|
+
prog="genduo-desk",
|
|
576
|
+
description="店铺 Mac 采集 Agent CLI(login 授权 / 心跳 / LaunchAgent)",
|
|
577
|
+
)
|
|
578
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
579
|
+
common.add_argument("--shop", help="店铺代码,如 5RT2")
|
|
580
|
+
common.add_argument("--base", help=f"中心 URL(默认 {cfg.DEFAULT_BASE})")
|
|
581
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
582
|
+
|
|
583
|
+
p = sub.add_parser("login", parents=[common],
|
|
584
|
+
help="门店登录授权:Agent token 环境 + 可选 RMS 校验")
|
|
585
|
+
p.add_argument("--bootstrap", help="紧急:一次性 JSON(含 agent_token)→ 仅本进程环境")
|
|
586
|
+
p.add_argument("--consume-bootstrap", action="store_true",
|
|
587
|
+
help="login 成功后删除 bootstrap 文件")
|
|
588
|
+
p.add_argument("--token-env", action="store_true",
|
|
589
|
+
help="要求已设置 DATAANALYST_AGENT_TOKEN(覆盖密码表)")
|
|
590
|
+
p.add_argument("--keep-token", action="store_true",
|
|
591
|
+
help="沿用已有 DATAANALYST_AGENT_TOKEN,不交互粘贴")
|
|
592
|
+
p.add_argument("--keep-rms", action="store_true",
|
|
593
|
+
help="沿用已有 RMS_<SHOP>_* 环境变量")
|
|
594
|
+
p.add_argument("--skip-secret-check", action="store_true",
|
|
595
|
+
help="跳过密码表拉取校验(不推荐)")
|
|
596
|
+
p.add_argument("--skip-heartbeat", action="store_true")
|
|
597
|
+
p.add_argument("--verify-rms", action="store_true",
|
|
598
|
+
help="用 Playwright 验证 R-Login 并写 state/<shop>.json")
|
|
599
|
+
p.add_argument("--headed", action="store_true", help="RMS 验证时显示浏览器")
|
|
600
|
+
p.add_argument("--install-service", action="store_true",
|
|
601
|
+
help="login 末尾安装 Agent + caffeinate LaunchAgent")
|
|
602
|
+
p.add_argument("--skip-caffeinate", action="store_true",
|
|
603
|
+
help="与 --install-service 合用时不装 caffeinate")
|
|
604
|
+
p.set_defaults(func=cmd_login)
|
|
605
|
+
|
|
606
|
+
p = sub.add_parser("status", parents=[common],
|
|
607
|
+
help="查看 JDY_MCP / token 环境 / LaunchAgent / 心跳")
|
|
608
|
+
p.add_argument("--offline", action="store_true", help="不打中心心跳")
|
|
609
|
+
p.set_defaults(func=cmd_status)
|
|
610
|
+
|
|
611
|
+
p = sub.add_parser("heartbeat", parents=[common],
|
|
612
|
+
help="向中心打一次心跳并拉任务")
|
|
613
|
+
p.add_argument("--no-jobs", action="store_true")
|
|
614
|
+
p.set_defaults(func=cmd_heartbeat)
|
|
615
|
+
|
|
616
|
+
p = sub.add_parser("install-service", parents=[common],
|
|
617
|
+
help="安装 / 更新 Agent + caffeinate LaunchAgent")
|
|
618
|
+
p.add_argument("--force", action="store_true")
|
|
619
|
+
p.add_argument("--skip-caffeinate", action="store_true",
|
|
620
|
+
help="只装 Agent,不装 caffeinate")
|
|
621
|
+
p.set_defaults(func=cmd_install_service)
|
|
622
|
+
|
|
623
|
+
p = sub.add_parser("prevent-sleep", parents=[common],
|
|
624
|
+
help="安装 caffeinate LaunchAgent,并提示 pmset(需 sudo)")
|
|
625
|
+
p.add_argument("--force", action="store_true")
|
|
626
|
+
p.set_defaults(func=cmd_prevent_sleep)
|
|
627
|
+
|
|
628
|
+
p = sub.add_parser("uninstall-service", parents=[common],
|
|
629
|
+
help="移除 Agent 与 caffeinate LaunchAgent")
|
|
630
|
+
p.set_defaults(func=cmd_uninstall_service)
|
|
631
|
+
|
|
632
|
+
adm = sub.add_parser("admin", help="超管:清单 / 校验密码表 / 紧急 bootstrap")
|
|
633
|
+
adm_sub = adm.add_subparsers(dest="admin_cmd", required=True)
|
|
634
|
+
|
|
635
|
+
p = adm_sub.add_parser("checklist", parents=[common],
|
|
636
|
+
help="打印某店开店清单(与销量计算系统凭据说明)")
|
|
637
|
+
p.set_defaults(func=cmd_admin_checklist)
|
|
638
|
+
|
|
639
|
+
p = adm_sub.add_parser("verify-secrets", parents=[common],
|
|
640
|
+
help="探测 JDY_MCP → API Key(与销量系统相同;不打印)")
|
|
641
|
+
p.add_argument("--verify-rms", action="store_true")
|
|
642
|
+
p.add_argument("--headed", action="store_true")
|
|
643
|
+
p.set_defaults(func=cmd_admin_verify_secrets)
|
|
644
|
+
|
|
645
|
+
p = adm_sub.add_parser("set-rms", parents=[common],
|
|
646
|
+
help="已废弃:等同 verify-secrets")
|
|
647
|
+
p.add_argument("--verify-rms", action="store_true")
|
|
648
|
+
p.add_argument("--headed", action="store_true")
|
|
649
|
+
p.set_defaults(func=cmd_admin_set_rms)
|
|
650
|
+
|
|
651
|
+
p = adm_sub.add_parser("write-bootstrap", parents=[common],
|
|
652
|
+
help="紧急:把 token 写成 600 文件(正式请写简道云)")
|
|
653
|
+
p.add_argument("--out", help="输出路径(默认 /tmp/dataanalyst-<SHOP>-bootstrap.json)")
|
|
654
|
+
p.set_defaults(func=cmd_admin_write_bootstrap)
|
|
655
|
+
|
|
656
|
+
return ap
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def main(argv: list[str] | None = None) -> None:
|
|
660
|
+
ap = build_parser()
|
|
661
|
+
args = ap.parse_args(argv)
|
|
662
|
+
# 顶层 --shop/--base 渗入子命令 namespace
|
|
663
|
+
args.func(args)
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
if __name__ == "__main__":
|
|
667
|
+
main()
|