@genduo/desk 0.4.0 → 0.5.1

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 CHANGED
@@ -1,12 +1,12 @@
1
1
  # 店铺采集 Agent(CLI)
2
2
 
3
- **状态** 门店 CLI 登录授权已落地;**采集循环已落地**(`run.py` 0.4.0)| **下一店** 5RT2 | **中心** `https://dataanalyst.jp` | **POC** 7RT2 已绿
3
+ **状态** 门店 CLI 登录授权已落地;**采集循环已落地**(`run.py` 0.5.0)| **凭据** 与销量计算系统一致| **下一店** 5RT2 | **中心** `https://dataanalyst.jp` | **POC** 7RT2 已绿
4
4
 
5
5
  ## 目标 UX
6
6
 
7
- 1. **超管先备齐**:每店 Agent token + R-Login 两组密码。
8
- 2. **门店只拿到本 CLI**:运行 `login` 完成登录授权(写入 Keychain、心跳、可选装 LaunchAgent)。
9
- 3. 不再以「每店手写 plist / 手工 security 命令」作为主路径。
7
+ 1. **超管先备齐**:中心签发每店 Agent token;店机维护 `JDY_MCP`(与销量系统相同);RMS 用 env-file。
8
+ 2. **门店只拿到本 CLI**:运行 `login`(token 进环境 / bootstrap)、心跳、可选装 LaunchAgent。
9
+ 3. 不再以「手工 `security` 写 dataanalyst/*」作为主路径。
10
10
 
11
11
  ### 安装(门店主路径:npm)
12
12
 
@@ -19,8 +19,8 @@ genduo-desk login --shop 5RT2 --verify-rms --install-service
19
19
  genduo-desk status
20
20
  ```
21
21
 
22
- 包名 `@genduo/desk`(全局命令 `genduo-desk`);版本见 `package.json` / `local_config.VERSION`(0.4.0)。
23
- **Keychain service 名未改**(仍为 `dataanalyst-agent-token` / `dataanalyst/rms/...`),换包名不影响已写入的凭据。
22
+ 包名 `@genduo/desk`(全局命令 `genduo-desk`);版本见 `package.json` / `local_config.VERSION`(0.5.0)。
23
+ **0.5.0**:凭据获取与销量计算系统一致(`JDY_MCP` → MCP → 密码表 API Key);旧 `dataanalyst-agent-token` / `dataanalyst/rms/...` 已废弃。
24
24
 
25
25
  ### 开发 / monorepo 路径(次要)
26
26
 
@@ -37,15 +37,16 @@ genduo-desk status
37
37
 
38
38
  | 命令 | 谁用 | 作用 |
39
39
  |---|---|---|
40
- | `login` | 门店 | 粘贴 token + 输入 R-Login 两组 → Keychain;可选心跳 / RMS 校验 / LaunchAgent |
41
- | `status` | 门店 | Keychain 是否齐全、LaunchAgent、打一次心跳 |
40
+ | `login` | 门店 | Agent token → 环境;可选心跳 / RMS 校验 / LaunchAgent |
41
+ | `status` | 门店 | JDY_MCP / token 环境 / LaunchAgent / 心跳 |
42
42
  | `heartbeat` | 门店 / 调试 | `POST /api/agent/heartbeat` + 拉 jobs |
43
43
  | `install-service` | 门店 | 装 Agent + **caffeinate** LaunchAgent(默认) |
44
44
  | `prevent-sleep` | 门店 / 超管 | 只装 `jp.dataanalyst.caffeinate`(`caffeinate -dims`),并打印 `pmset` 说明 |
45
45
  | `uninstall-service` | 门店 | 卸 Agent **与** caffeinate LaunchAgent |
46
- | `admin checklist` | 超管 | 打印某店开店清单(含签发 token 命令) |
47
- | `admin set-rms` | 超管 | 交互写入本机 `dataanalyst/rms/<SHOP>-rlogin` / `-ai` |
48
- | `admin write-bootstrap` | 超管 | 把 token 写成 600 一次性 JSON,供门店 `login --bootstrap` |
46
+ | `admin checklist` | 超管 | 开店清单(与销量系统凭据说明) |
47
+ | `admin verify-secrets` | 超管 | 探测 `JDY_MCP` → API Key(不打印) |
48
+ | `admin set-rms` | 超管 | **已废弃**(勿写 dataanalyst/rms/*) |
49
+ | `admin write-bootstrap` | 超管 | 600 一次性 JSON 给门店 `login --bootstrap` |
49
50
 
50
51
  常驻进程入口是 `run.py`(LaunchAgent 调用):进程内 sleep 循环,心跳 + 日次访问 + 月度 RPP → ingest。
51
52
 
@@ -106,15 +107,20 @@ genduo-desk install-service --skip-caffeinate
106
107
  合盖笔记本另议;常开台式 + 登录用户是推荐形态。
107
108
 
108
109
 
109
- ## Keychain 约定(勿改名)
110
+ ## 凭据(与销量计算系统一致)
110
111
 
111
- | 用途 | service | account |
112
+ 本机 Keychain **只**用于 MCP URL(店机维护;本开发机可以没有):
113
+
114
+ | service | account | 覆盖 |
112
115
  |---|---|---|
113
- | Agent token | `dataanalyst-agent-token` | `$USER` |
114
- | R-Login 第一组 | `dataanalyst/rms/<SHOP>-rlogin` | R-Login ID |
115
- | Rakuten SSO 第二组 | `dataanalyst/rms/<SHOP>-ai` | 邮箱 |
116
+ | `JDY_MCP` | `本机JDY服务器URL`(兼容 `服务器URL`) | 环境变量 `JDY_MCP_URL` 优先 |
117
+
118
+ JDY API Key:经 MCP `member_data_list` 查密码表,Title 精确匹配 **`api key 精细化运营`**(同销量系统 `config.JDY_API_KEY_TITLE`)。不硬编码、不打印。
119
+
120
+ Agent token(中心 `da_…`):环境变量 `DATAANALYST_AGENT_TOKEN`,或 `login --bootstrap`。**不写** `dataanalyst-agent-token` Keychain。
121
+
122
+ RMS 两组:`RMS_<SHOP>_LOGIN_ID` / `PASSWORD` / `SHOP_USER` / `SHOP_PASSWORD`,或 `rms_browser_login --env-file`。**不写** `dataanalyst/rms/*` Keychain。
116
123
 
117
- 与包内 vendored `rms_browser_login.py --from-keychain`(源真相仍是 monorepo `tools/rms_browser_login.py`)、7RT2 POC 一致。
118
124
  本机非机密配置:`~/.config/dataanalyst-agent/config.json`(仅 `shop` / `base`)。
119
125
 
120
126
  ## 中心契约
@@ -136,33 +142,27 @@ genduo-desk install-service --skip-caffeinate
136
142
  # 1) 看清单
137
143
  genduo-desk admin checklist --shop 5RT2
138
144
 
139
- # 2) 中心签发 token(Web 或 VPS python -m app.tokens …);明文不要进 git/聊天
140
- # 3) 可选:在店里 Mac 上预写 RMS
141
- genduo-desk admin set-rms --shop 5RT2
145
+ # 2) 中心签发 token(明文勿进 git/聊天)→ 门店 DATAANALYST_AGENT_TOKEN 或 bootstrap
146
+ # 3) 店机维护 JDY_MCP(与销量系统相同;开发机可无)
147
+ genduo-desk admin verify-secrets --shop 5RT2
142
148
 
143
- # 4) 可选:做一次性 bootstrap 交给门店
149
+ # 4) 可选 bootstrap
144
150
  genduo-desk admin write-bootstrap --shop 5RT2 --out /tmp/5RT2.boot.json
145
151
  ```
146
152
 
147
153
  ## 门店流程(后)
148
154
 
149
155
  ```bash
150
- # 交互粘贴 token + 输入两组密码
151
156
  genduo-desk login --shop 5RT2 --verify-rms --install-service
152
157
 
153
- # 或用超管 bootstrap(读完可删)
154
- genduo-desk login --shop 5RT2 \
155
- --bootstrap /tmp/5RT2.boot.json --consume-bootstrap \
156
- --keep-rms --verify-rms --install-service
157
-
158
158
  genduo-desk status
159
159
  ```
160
160
 
161
- `--keep-token` / `--keep-rms`:Keychain 已有则跳过对应提示(7RT2 POC 复用时有用)。
161
+ `--keep-token` / `--keep-rms`:沿用已有环境变量,不重复交互。
162
162
 
163
163
  ## 5RT2 下一店清单
164
164
 
165
- 1. 超管:签发 `agent-5RT2`(scopes/shops 如上);备齐 R-Login 两组。
165
+ 1. 超管:签发 `agent-5RT2`;门店 Mac 维护 `JDY_MCP`(与销量系统一致);备齐 RMS env-file。
166
166
  2. 门店 Mac:装 Playwright Chromium(若要 `--verify-rms`)。
167
167
  3. 门店:`login --shop 5RT2 --verify-rms --install-service`。
168
168
  4. 先只推最近 7 天 `rms_access`;连续 7 天正常后再接 RPP。
@@ -173,7 +173,8 @@ genduo-desk status
173
173
 
174
174
  ## 安全
175
175
 
176
- - 不打印 token / 密码;bootstrap 文件 mode 600,`--consume-bootstrap` 用后删除。
176
+ - 不打印 token / 密码;JDY 侧与销量计算系统一致(Keychain 仅 MCP URL → 密码表 API Key)。
177
+ - Agent token / RMS 用环境变量或 bootstrap / env-file,不写 dataanalyst/* Keychain。
177
178
  - 中心**不保存** R-Login 密码;只收出站 HTTPS ingest / 心跳。
178
179
  - RMS 登录阶段禁止 accessibility snapshot / 把密码写入日志;需要人工 2FA 时本机接管。
179
180
  - 本 CLI **不会**自行向生产签发新 token。
package/cli.py CHANGED
@@ -4,13 +4,13 @@
4
4
 
5
5
  主 UX(门店):
6
6
  genduo-desk login --shop 5RT2
7
- 交互写入 Agent token + R-Login 两组凭据到 Keychain,
7
+ 本机 Keychain 只需已有 JDY_MCP / 本机JDY服务器URL;
8
+ Agent token 与 R-Login 两组从简道云密码表经 MCP 读取,
8
9
  可选验证 RMS 登录、注册心跳、安装 LaunchAgent + caffeinate 防休眠。
9
10
 
10
- 超管侧(先备齐凭据 / 签发 token):
11
+ 超管侧(先备齐简道云 Title + 签发 token 写入密码表):
11
12
  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 文件
13
+ genduo-desk admin verify-secrets --shop 5RT2
14
14
 
15
15
  不打印 token / 密码;不向生产库签发新 token(签发走中心 Web 或
16
16
  `python -m app.tokens`,见 admin checklist)。
@@ -82,13 +82,13 @@ def _resolve_base(explicit: str | None) -> str:
82
82
  # ---------------------------------------------------------------------------
83
83
 
84
84
  def cmd_login(a: argparse.Namespace) -> None:
85
- """门店首次授权:token + R-Login → Keychain,可选心跳 / RMS 校验 / LaunchAgent。"""
85
+ """门店授权:Agent token → 环境(及可选写入 LaunchAgent);不写 dataanalyst Keychain。"""
86
86
  shop = _resolve_shop(a.shop)
87
87
  base = _resolve_base(a.base)
88
88
  print(f"店铺 {shop} · 中心 {base}")
89
- print("凭据只写入本机 Keychain,不会打印、不会上传到中心。")
89
+ print("与销量计算系统一致:Keychain 仅 JDY_MCP(本机可无);"
90
+ "不写 dataanalyst-agent-token / dataanalyst/rms/*。")
90
91
 
91
- # --- Agent token ---
92
92
  token: str | None = None
93
93
  if a.bootstrap:
94
94
  boot = Path(a.bootstrap).expanduser()
@@ -107,43 +107,48 @@ def cmd_login(a: argparse.Namespace) -> None:
107
107
  print("已删除 bootstrap 文件(一次性)")
108
108
  except OSError as exc:
109
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:
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
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:
114
+ if token:
115
+ print("使用环境变量 DATAANALYST_AGENT_TOKEN")
116
+ if token is None and not a.keep_token:
117
117
  print("请粘贴超管签发的 Agent token(da_…,输入时不回显)")
118
118
  token = _prompt_secret("Agent token")
119
119
 
120
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
- # 尽快丢掉明文引用
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)")
127
125
  token = None
128
126
 
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)})")
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)
134
137
  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)}")
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}*")
147
152
 
148
153
  path = cfg.save(shop, base)
149
154
  print(f"✓ 本机配置 → {path}(无密钥)")
@@ -164,8 +169,8 @@ def cmd_login(a: argparse.Namespace) -> None:
164
169
  print("login 完成。常用后续:")
165
170
  print(f" {sys.argv[0]} status")
166
171
  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 另做")
172
+ print(f" {sys.argv[0]} install-service")
173
+
169
174
 
170
175
 
171
176
  def cmd_status(a: argparse.Namespace) -> None:
@@ -176,16 +181,20 @@ def cmd_status(a: argparse.Namespace) -> None:
176
181
  print(f"shop {shop}")
177
182
  print(f"base {base}")
178
183
  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)})")
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)}*)")
182
190
  print(f"launchagent {'已装' if PLIST_PATH.is_file() else '未装'} ({PLIST_PATH})")
183
191
  print(f"caffeinate {'已装' if CAFFEINATE_PATH.is_file() else '未装'} ({CAFFEINATE_PATH})")
184
192
  caf_alive = _caffeinate_running()
185
- print(f"caffeinate_ps {'运行中' if caf_alive else '未运行'} ({CAFFEINATE_BIN} {' '.join(CAFFEINATE_ARGS)})")
193
+ print(f"caffeinate_ps {'运行中' if caf_alive else '未运行'} "
194
+ f"({CAFFEINATE_BIN} {' '.join(CAFFEINATE_ARGS)})")
186
195
  if present["agent_token"] and not a.offline:
187
196
  try:
188
- tok = kc.get_agent_token()
197
+ tok = kc.get_agent_token(shop)
189
198
  hb = api_client.heartbeat(base, tok, shop, cfg.VERSION)
190
199
  agent = (hb or {}).get("agent") or {}
191
200
  print(f"heartbeat ok last_seen={agent.get('last_seen_at')}")
@@ -193,6 +202,7 @@ def cmd_status(a: argparse.Namespace) -> None:
193
202
  print(f"heartbeat 失败: {exc}")
194
203
 
195
204
 
205
+
196
206
  def cmd_heartbeat(a: argparse.Namespace) -> None:
197
207
  conf = cfg.load()
198
208
  shop = _resolve_shop(a.shop or conf.get("shop"))
@@ -239,77 +249,60 @@ def cmd_admin_checklist(a: argparse.Namespace) -> None:
239
249
  print(textwrap.dedent(f"""
240
250
  ======== 超管 · {shop} 开店清单 ========
241
251
 
242
- 目标 UX:超管先备齐本店全部凭据;门店只拿到本 CLI,运行
243
- genduo-desk login --shop {shop}
244
- 完成登录授权(Keychain + 心跳),不必手写 LaunchAgent。
252
+ 凭据逻辑与「销量计算系统」一致:
253
+ · 店机 Keychain:仅 JDY_MCP / 本机JDY服务器URL(开发机可无)
254
+ · 或环境变量 JDY_MCP_URL
255
+ · JDY API Key:MCP 密码表 Title={kc.JDY_API_KEY_TITLE}
256
+ · 不再使用 dataanalyst-agent-token / dataanalyst/rms/* Keychain
245
257
 
246
258
  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
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=本机JDY服务器URL
270
+ · 本开发机没有该条目是正常的
272
271
 
273
272
  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. 验收
273
+ genduo-desk login --shop {shop} --verify-rms --install-service
274
+
275
+ E. 验收
289
276
  genduo-desk status
290
- # 应见 caffeinate=已装 且 caffeinate_ps=运行中
291
- # 中心 /sources 或 GET /api/agent/status 可见 {shop} online
292
277
  """).strip())
293
278
 
294
279
 
295
- def cmd_admin_set_rms(a: argparse.Namespace) -> None:
296
- """超管在本机(或店里 Mac)写入 RMS Keychain,不涉及 Agent token。"""
280
+ def cmd_admin_verify_secrets(a: argparse.Namespace) -> None:
281
+ """探测 JDY_MCP → get_api_key(与销量系统相同);不打印 Key。"""
297
282
  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)}")
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))
307
293
  if a.verify_rms:
308
294
  _do_verify_rms(shop, headed=a.headed)
309
295
 
310
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
+
311
304
  def cmd_admin_write_bootstrap(a: argparse.Namespace) -> None:
312
- """把超管刚签发的 token 写成 600 的一次性文件;不进仓库。"""
305
+ """把 token 写成 600 一次性文件;不进仓库、不写 Keychain。"""
313
306
  shop = _resolve_shop(a.shop)
314
307
  out = Path(a.out).expanduser() if a.out else Path(f"/tmp/dataanalyst-{shop}-bootstrap.json")
315
308
  print("粘贴 Agent token(不回显);将写入仅含 shop+token 的 600 文件")
@@ -331,13 +324,14 @@ def cmd_admin_write_bootstrap(a: argparse.Namespace) -> None:
331
324
  f"--bootstrap {out} --consume-bootstrap")
332
325
 
333
326
 
327
+
334
328
  # ---------------------------------------------------------------------------
335
329
  # helpers
336
330
  # ---------------------------------------------------------------------------
337
331
 
338
332
  def _do_heartbeat(shop: str, base: str, *, show_jobs: bool = True) -> None:
339
333
  try:
340
- tok = kc.get_agent_token()
334
+ tok = kc.get_agent_token(shop)
341
335
  except RuntimeError as exc:
342
336
  _die(str(exc))
343
337
  try:
@@ -367,12 +361,36 @@ def _do_verify_rms(shop: str, *, headed: bool = False) -> None:
367
361
  _die("找不到 rms_browser_login.py(包内 vendored 或 monorepo tools/)")
368
362
  out = HERE / "state" / f"{shop}.json"
369
363
  out.parent.mkdir(parents=True, exist_ok=True)
370
- cmd = [sys.executable, str(script), "--shop", shop, "--from-keychain",
371
- "--out", str(out)]
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)]
372
384
  if headed:
373
385
  cmd.append("--headed")
374
386
  print("验证 RMS 登录(Playwright;登录阶段不截密码)…")
375
- r = subprocess.run(cmd)
387
+ try:
388
+ r = subprocess.run(cmd)
389
+ finally:
390
+ try:
391
+ env_path.unlink()
392
+ except OSError:
393
+ pass
376
394
  if r.returncode != 0:
377
395
  _die("RMS 登录验证失败", r.returncode)
378
396
  try:
@@ -486,6 +504,14 @@ def _do_install_service(shop: str, base: str, *, force: bool = False,
486
504
  **不是**短任务轮询——切勿把 run.py 改回「跑完即退」否则会狂刷心跳。
487
505
  """
488
506
  cfg.save(shop, base)
507
+ def _plist_esc(s: str) -> str:
508
+ return (s.replace("&", "&amp;").replace("<", "&lt;")
509
+ .replace(">", "&gt;").replace('"', "&quot;"))
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
+ )
489
515
  python = _pick_agent_python()
490
516
  run_py = HERE / "run.py"
491
517
  logs = HERE / "logs"
@@ -511,6 +537,7 @@ def _do_install_service(shop: str, base: str, *, force: bool = False,
511
537
  " <dict>\n"
512
538
  f" <key>DATAANALYST_SHOP</key><string>{shop}</string>\n"
513
539
  f" <key>DATAANALYST_BASE</key><string>{base}</string>\n"
540
+ f"{tok_xml}"
514
541
  " </dict>\n"
515
542
  " <key>RunAtLoad</key><true/>\n"
516
543
  " <key>KeepAlive</key><true/>\n"
@@ -554,16 +581,18 @@ def build_parser() -> argparse.ArgumentParser:
554
581
  sub = ap.add_subparsers(dest="cmd", required=True)
555
582
 
556
583
  p = sub.add_parser("login", parents=[common],
557
- help="门店登录授权:token + R-Login → Keychain")
558
- p.add_argument("--bootstrap", help="超管生成的一次性 JSON(含 agent_token)")
584
+ help="门店登录授权:Agent token 环境 + 可选 RMS 校验")
585
+ p.add_argument("--bootstrap", help="紧急:一次性 JSON(含 agent_token)→ 仅本进程环境")
559
586
  p.add_argument("--consume-bootstrap", action="store_true",
560
587
  help="login 成功后删除 bootstrap 文件")
561
588
  p.add_argument("--token-env", action="store_true",
562
- help="从 DATAANALYST_AGENT_TOKEN 读 token")
589
+ help="要求已设置 DATAANALYST_AGENT_TOKEN(覆盖密码表)")
563
590
  p.add_argument("--keep-token", action="store_true",
564
- help="Keychain 已有 token 则不覆盖")
591
+ help="沿用已有 DATAANALYST_AGENT_TOKEN,不交互粘贴")
565
592
  p.add_argument("--keep-rms", action="store_true",
566
- help="Keychain 已有 RMS 凭据则不覆盖")
593
+ help="沿用已有 RMS_<SHOP>_* 环境变量")
594
+ p.add_argument("--skip-secret-check", action="store_true",
595
+ help="跳过密码表拉取校验(不推荐)")
567
596
  p.add_argument("--skip-heartbeat", action="store_true")
568
597
  p.add_argument("--verify-rms", action="store_true",
569
598
  help="用 Playwright 验证 R-Login 并写 state/<shop>.json")
@@ -575,7 +604,7 @@ def build_parser() -> argparse.ArgumentParser:
575
604
  p.set_defaults(func=cmd_login)
576
605
 
577
606
  p = sub.add_parser("status", parents=[common],
578
- help="查看 Keychain / LaunchAgent / caffeinate / 心跳")
607
+ help="查看 JDY_MCP / token 环境 / LaunchAgent / 心跳")
579
608
  p.add_argument("--offline", action="store_true", help="不打中心心跳")
580
609
  p.set_defaults(func=cmd_status)
581
610
 
@@ -600,21 +629,27 @@ def build_parser() -> argparse.ArgumentParser:
600
629
  help="移除 Agent 与 caffeinate LaunchAgent")
601
630
  p.set_defaults(func=cmd_uninstall_service)
602
631
 
603
- adm = sub.add_parser("admin", help="超管:备齐凭据 / 清单 / bootstrap")
632
+ adm = sub.add_parser("admin", help="超管:清单 / 校验密码表 / 紧急 bootstrap")
604
633
  adm_sub = adm.add_subparsers(dest="admin_cmd", required=True)
605
634
 
606
635
  p = adm_sub.add_parser("checklist", parents=[common],
607
- help="打印某店开店清单(含签发 token 命令)")
636
+ help="打印某店开店清单(与销量计算系统凭据说明)")
608
637
  p.set_defaults(func=cmd_admin_checklist)
609
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
+
610
645
  p = adm_sub.add_parser("set-rms", parents=[common],
611
- help="交互写入本店 RMS Keychain")
646
+ help="已废弃:等同 verify-secrets")
612
647
  p.add_argument("--verify-rms", action="store_true")
613
648
  p.add_argument("--headed", action="store_true")
614
649
  p.set_defaults(func=cmd_admin_set_rms)
615
650
 
616
651
  p = adm_sub.add_parser("write-bootstrap", parents=[common],
617
- help="把 token 写成 600 一次性文件给门店 login 用")
652
+ help="紧急:把 token 写成 600 文件(正式请写简道云)")
618
653
  p.add_argument("--out", help="输出路径(默认 /tmp/dataanalyst-<SHOP>-bootstrap.json)")
619
654
  p.set_defaults(func=cmd_admin_write_bootstrap)
620
655
 
package/jdy_secrets.py ADDED
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """简道云密码 / MCP —— 与「销量计算系统」jdy_api.py + config.py 一致。
4
+
5
+ 本机钥匙串只存一件(店机由用户维护;本开发机可以没有):
6
+ service = JDY_MCP
7
+ account = 本机JDY服务器URL(兼容旧账户名 服务器URL)
8
+
9
+ 环境变量优先:JDY_MCP_URL
10
+
11
+ API Key:MCP member_data_list 查【密码PASSWORDパスワード】,
12
+ Title 精确匹配 JDY_API_KEY_TITLE(= config 中「api key 精细化运营」)。
13
+ 不硬编码 Key;不打印 secret。
14
+
15
+ 实现说明:逻辑与 zip 内 jdy_api 相同。若未安装 mcp/keyring,
16
+ 则用等价的 urllib JSON-RPC / security CLI 完成同一调用(filter / fields / Title)。
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import json
22
+ import logging
23
+ import os
24
+ import re
25
+ import subprocess
26
+ import time
27
+ from typing import Any, Optional
28
+ from urllib.error import URLError
29
+ from urllib.request import Request, urlopen
30
+
31
+ log = logging.getLogger("jdy")
32
+
33
+ # ── 与销量计算系统 config.py 同名常量 ─────────────────────
34
+ JDY_PWD_APP_ID = "5a6f241ae24fd5382436f985"
35
+ JDY_PWD_ENTRY_ID = "67188a2824e6cc898a1d7d7f"
36
+ JDY_PWD_F_TITLE = "_widget_1729661480436" # Title (text)
37
+ JDY_PWD_F_PASSWORD = "_widget_1729661480439" # Password (text)
38
+ JDY_API_KEY_TITLE = "api key 精细化运营"
39
+ JDY_MCP_KEYRING_SERVICE = "JDY_MCP"
40
+ JDY_MCP_KEYRING_ACCOUNT = "本机JDY服务器URL"
41
+ # 兼容旧条目(早期文档写过 account=服务器URL)
42
+ JDY_MCP_KEYRING_ACCOUNT_ALIASES = ("本机JDY服务器URL", "服务器URL")
43
+
44
+ # 别名(旧调用方)
45
+ JDY_MCP_SERVICE = JDY_MCP_KEYRING_SERVICE
46
+ JDY_MCP_ACCOUNT = JDY_MCP_KEYRING_ACCOUNT
47
+
48
+ _API_KEY: Optional[str] = None
49
+
50
+
51
+ def _get_mcp_url() -> str:
52
+ """MCP URL 优先级:环境变量 > 钥匙串(账户优先本机JDY服务器URL,兼容服务器URL)"""
53
+ env = os.environ.get("JDY_MCP_URL")
54
+ if env:
55
+ return env
56
+ try:
57
+ import keyring
58
+ for acct in JDY_MCP_KEYRING_ACCOUNT_ALIASES:
59
+ v = keyring.get_password(JDY_MCP_KEYRING_SERVICE, acct)
60
+ if v:
61
+ return v
62
+ except Exception:
63
+ pass
64
+ # keyring 未装或读失败时:同条目的 security CLI
65
+ for acct in JDY_MCP_KEYRING_ACCOUNT_ALIASES:
66
+ try:
67
+ r = subprocess.run(
68
+ ["security", "find-generic-password",
69
+ "-s", JDY_MCP_KEYRING_SERVICE,
70
+ "-a", acct, "-w"],
71
+ capture_output=True, text=True,
72
+ )
73
+ if r.returncode == 0 and (r.stdout or "").strip():
74
+ return (r.stdout or "").rstrip("\n")
75
+ except Exception:
76
+ pass
77
+ raise RuntimeError(
78
+ "未找到 JDY MCP URL:请设置环境变量 JDY_MCP_URL "
79
+ "或钥匙串条目 JDY_MCP/本机JDY服务器URL"
80
+ )
81
+
82
+
83
+ def get_mcp_url() -> str:
84
+ return _get_mcp_url()
85
+
86
+
87
+ def mcp_url_present() -> bool:
88
+ try:
89
+ _get_mcp_url()
90
+ return True
91
+ except RuntimeError:
92
+ return False
93
+
94
+
95
+ async def _fetch_api_key_async() -> str:
96
+ """MCP member_data_list 查询密码表单,按 Title 精确匹配取 Password 字段。"""
97
+ # 优先与销量系统相同的 mcp SDK
98
+ try:
99
+ from mcp import ClientSession
100
+ from mcp.client.streamable_http import streamable_http_client
101
+ except ImportError:
102
+ return _fetch_api_key_urllib()
103
+
104
+ async with streamable_http_client(_get_mcp_url()) as (read, write):
105
+ async with ClientSession(read, write) as session:
106
+ await session.initialize()
107
+ await asyncio.sleep(1) # JDY MCP 限流 3 次/秒
108
+ res = await session.call_tool("member_data_list", {
109
+ "app_id": JDY_PWD_APP_ID,
110
+ "entry_id": JDY_PWD_ENTRY_ID,
111
+ "limit": 20,
112
+ "fields": [JDY_PWD_F_TITLE, JDY_PWD_F_PASSWORD],
113
+ "filter": {"rel": "and", "cond": [
114
+ {"field": JDY_PWD_F_TITLE, "type": "text",
115
+ "method": "like", "value": ["api key"]},
116
+ ]},
117
+ })
118
+ if res.is_error:
119
+ raise RuntimeError(f"MCP member_data_list 失败: {res.content}")
120
+ for block in res.content:
121
+ if getattr(block, "type", None) != "text":
122
+ continue
123
+ rows = json.loads(block.text).get("data", [])
124
+ for row in rows:
125
+ if row.get(JDY_PWD_F_TITLE) == JDY_API_KEY_TITLE:
126
+ key = row.get(JDY_PWD_F_PASSWORD)
127
+ if key:
128
+ log.info("JDY API Key 已从密码表单读取: %s",
129
+ JDY_API_KEY_TITLE)
130
+ return key
131
+ raise RuntimeError(
132
+ f"密码表单中未找到 Title={JDY_API_KEY_TITLE!r} 的 API Key"
133
+ )
134
+
135
+
136
+ def _rpc(url: str, method: str, params: dict) -> dict:
137
+ body = json.dumps(
138
+ {"jsonrpc": "2.0", "id": 1, "method": method, "params": params}
139
+ ).encode()
140
+ req = Request(
141
+ url, data=body, method="POST",
142
+ headers={"Content-Type": "application/json",
143
+ "Accept": "application/json, text/event-stream"},
144
+ )
145
+ last_err: Exception | None = None
146
+ for attempt in range(3):
147
+ try:
148
+ with urlopen(req, timeout=40) as resp:
149
+ raw = resp.read().decode("utf-8", "replace")
150
+ break
151
+ except (URLError, TimeoutError, ConnectionError) as exc:
152
+ last_err = exc
153
+ time.sleep(1.5 * (attempt + 1))
154
+ else:
155
+ raise RuntimeError(f"JDY MCP 网络失败: {type(last_err).__name__}")
156
+ m = re.search(r"data:\s*(\{.*\})", raw, re.S)
157
+ data = json.loads(m.group(1) if m else raw)
158
+ if "error" in data:
159
+ err = data["error"]
160
+ msg = err.get("message", err) if isinstance(err, dict) else err
161
+ raise RuntimeError(f"JDY MCP {method} 失败: {msg}")
162
+ return data.get("result", {})
163
+
164
+
165
+ def _fetch_api_key_urllib() -> str:
166
+ """无 mcp 包时:同一 member_data_list 参数,经 JSON-RPC 调用。"""
167
+ url = _get_mcp_url()
168
+ time.sleep(1)
169
+ res = _rpc(url, "tools/call", {
170
+ "name": "member_data_list",
171
+ "arguments": {
172
+ "app_id": JDY_PWD_APP_ID,
173
+ "entry_id": JDY_PWD_ENTRY_ID,
174
+ "limit": 20,
175
+ "fields": [JDY_PWD_F_TITLE, JDY_PWD_F_PASSWORD],
176
+ "filter": {"rel": "and", "cond": [
177
+ {"field": JDY_PWD_F_TITLE, "type": "text",
178
+ "method": "like", "value": ["api key"]},
179
+ ]},
180
+ },
181
+ })
182
+ text = ""
183
+ for c in res.get("content", []) or []:
184
+ if c.get("type") == "text":
185
+ text = c.get("text") or ""
186
+ break
187
+ if not text:
188
+ raise RuntimeError("MCP member_data_list 无 text 内容")
189
+ if text.strip().startswith("API error"):
190
+ raise RuntimeError(f"MCP member_data_list 失败: {text.strip()[:200]}")
191
+ body: Any = json.loads(text)
192
+ rows = body.get("data") if isinstance(body, dict) else body
193
+ for row in rows or []:
194
+ if not isinstance(row, dict):
195
+ continue
196
+ if row.get(JDY_PWD_F_TITLE) == JDY_API_KEY_TITLE:
197
+ key = row.get(JDY_PWD_F_PASSWORD)
198
+ if key:
199
+ log.info("JDY API Key 已从密码表单读取: %s", JDY_API_KEY_TITLE)
200
+ return key
201
+ raise RuntimeError(
202
+ f"密码表单中未找到 Title={JDY_API_KEY_TITLE!r} 的 API Key"
203
+ )
204
+
205
+
206
+ def get_api_key() -> str:
207
+ """同步接口:动态读取 JDY API Key(不落盘、不硬编码、不打印)。"""
208
+ global _API_KEY
209
+ if _API_KEY:
210
+ return _API_KEY
211
+ try:
212
+ # mcp SDK 路径
213
+ import mcp # noqa: F401
214
+ key = asyncio.run(_fetch_api_key_async())
215
+ except ImportError:
216
+ key = _fetch_api_key_urllib()
217
+ except Exception:
218
+ # SDK 可用但失败时,再试 urllib 等价路径
219
+ try:
220
+ key = _fetch_api_key_urllib()
221
+ except Exception as exc:
222
+ raise RuntimeError(
223
+ f"读取 JDY API Key 失败: {type(exc).__name__}"
224
+ ) from exc
225
+ _API_KEY = key
226
+ return key
227
+
228
+
229
+ def clear_cache() -> None:
230
+ global _API_KEY
231
+ _API_KEY = None
package/keychain_util.py CHANGED
@@ -1,115 +1,108 @@
1
1
  #!/usr/bin/env python3
2
2
  # -*- coding: utf-8 -*-
3
- """macOS Keychain 读写(不打印 secret)。
3
+ """凭据入口(兼容旧 import 名 keychain_util)。
4
4
 
5
- 与现有约定对齐:
6
- · Agent token service = dataanalyst-agent-token account = $USER
7
- · R-Login 第一组 service = dataanalyst/rms/<SHOP>-rlogin account = R-Login ID
8
- · Rakuten SSO 第二组 service = dataanalyst/rms/<SHOP>-ai account = 邮箱
5
+ 与「销量计算系统」一致:
6
+ · 本机 Keychain 只用于 JDY_MCP / 本机JDY服务器URL(店机维护;本开发机可无)
7
+ · JDY API Key 经 MCP 密码表读取(Title = api key 精细化运营)
8
+
9
+ 已废弃(不再读写):
10
+ · dataanalyst-agent-token
11
+ · dataanalyst/rms/<SHOP>-rlogin / -ai
12
+
13
+ Agent token:环境变量 DATAANALYST_AGENT_TOKEN(或 login --bootstrap 注入本进程)。
14
+ RMS 登录:env-file / 环境变量 RMS_<SHOP>_*,见 rms_browser_login。
9
15
  """
10
16
  from __future__ import annotations
11
17
 
12
18
  import os
13
- import re
14
- import subprocess
15
19
  from typing import Optional
16
20
 
17
- AGENT_TOKEN_SERVICE = "dataanalyst-agent-token"
21
+ import jdy_secrets as jdy
18
22
 
23
+ AGENT_TOKEN_SERVICE = "DEPRECATED:dataanalyst-agent-token"
24
+ JDY_MCP_SERVICE = jdy.JDY_MCP_KEYRING_SERVICE
25
+ JDY_MCP_ACCOUNT = jdy.JDY_MCP_KEYRING_ACCOUNT
26
+ JDY_API_KEY_TITLE = jdy.JDY_API_KEY_TITLE
19
27
 
20
- def rms_rlogin_service(shop: str) -> str:
21
- return f"dataanalyst/rms/{shop.upper()}-rlogin"
22
28
 
29
+ def get_mcp_url() -> str:
30
+ return jdy.get_mcp_url()
23
31
 
24
- def rms_ai_service(shop: str) -> str:
25
- return f"dataanalyst/rms/{shop.upper()}-ai"
26
32
 
33
+ def get_api_key() -> str:
34
+ """与销量计算系统 jdy_api.get_api_key 相同。勿打印返回值。"""
35
+ return jdy.get_api_key()
27
36
 
28
- def _run(cmd: list[str], *, check: bool = True) -> subprocess.CompletedProcess:
29
- return subprocess.run(cmd, capture_output=True, text=True, check=check)
30
37
 
31
-
32
- def has_generic(service: str, account: Optional[str] = None) -> bool:
33
- cmd = ["security", "find-generic-password", "-s", service]
34
- if account:
35
- cmd += ["-a", account]
36
- r = _run(cmd, check=False)
37
- return r.returncode == 0
38
-
39
-
40
- def get_password(service: str, account: Optional[str] = None) -> str:
41
- """读取密码;失败抛 RuntimeError。调用方不得打印返回值。"""
42
- cmd = ["security", "find-generic-password", "-s", service, "-w"]
43
- if account:
44
- cmd += ["-a", account]
45
- r = _run(cmd, check=False)
46
- if r.returncode != 0:
47
- raise RuntimeError(f"Keychain 缺少条目: {service}")
48
- return (r.stdout or "").rstrip("\n")
49
-
50
-
51
- def get_account(service: str) -> str:
52
- r = _run(["security", "find-generic-password", "-s", service], check=False)
53
- if r.returncode != 0:
54
- raise RuntimeError(f"Keychain 缺少条目: {service}")
55
- m = re.search(r'"acct"<blob>="([^"]*)"', r.stdout or "")
56
- if not m:
57
- raise RuntimeError(f"Keychain 缺 account: {service}")
58
- return m.group(1)
59
-
60
-
61
- def set_generic(service: str, account: str, secret: str, *,
62
- label: str = "") -> None:
63
- """写入/覆盖 generic password;secret 不进日志。"""
64
- if not secret:
65
- raise ValueError(f"拒绝写入空 secret: {service}")
66
- cmd = ["security", "add-generic-password", "-U",
67
- "-s", service, "-a", account, "-w", secret]
68
- if label:
69
- cmd += ["-l", label]
70
- r = _run(cmd, check=False)
71
- if r.returncode != 0:
72
- err = (r.stderr or r.stdout or "").strip() or f"exit {r.returncode}"
73
- # 不回显可能含参数的完整命令
74
- raise RuntimeError(f"Keychain 写入失败 ({service}): {err[:200]}")
75
-
76
-
77
- def set_agent_token(token: str, *, account: Optional[str] = None) -> None:
78
- acct = account or os.environ.get("USER", "")
79
- if not acct:
80
- raise RuntimeError("无法确定 Keychain account($USER 为空)")
81
- if not token.startswith("da_"):
82
- raise ValueError("Agent token 应以 da_ 开头")
83
- set_generic(AGENT_TOKEN_SERVICE, acct, token,
84
- label="dataanalyst agent token")
85
-
86
-
87
- def get_agent_token() -> str:
88
- env = os.environ.get("DATAANALYST_AGENT_TOKEN", "").strip()
38
+ def get_agent_token(shop: Optional[str] = None) -> str:
39
+ """中心 Agent token:只从环境变量读(不再走 dataanalyst Keychain / 自创 JDY Title)。"""
40
+ env = (os.environ.get("DATAANALYST_AGENT_TOKEN") or "").strip()
89
41
  if env:
90
42
  return env
91
- acct = os.environ.get("USER", "")
92
- try:
93
- return get_password(AGENT_TOKEN_SERVICE, acct or None)
94
- except RuntimeError:
95
- # 兼容早期未指定 -a 的写法
96
- return get_password(AGENT_TOKEN_SERVICE)
43
+ raise RuntimeError(
44
+ "未设置 DATAANALYST_AGENT_TOKEN。"
45
+ "请 export、或 genduo-desk login --bootstrap / --token-env"
46
+ )
97
47
 
98
48
 
99
- def set_rms_creds(shop: str, *, rlogin_id: str, rlogin_password: str,
100
- sso_user: str, sso_password: str) -> None:
101
- shop_u = shop.upper()
102
- set_generic(rms_rlogin_service(shop_u), rlogin_id, rlogin_password,
103
- label=f"{shop_u} R-Login")
104
- set_generic(rms_ai_service(shop_u), sso_user, sso_password,
105
- label=f"{shop_u} Rakuten SSO")
49
+ def rms_env_prefix(shop: str) -> str:
50
+ return f"RMS_{shop.upper()}_"
51
+
52
+
53
+ def get_rms_creds(shop: str) -> dict[str, str]:
54
+ """从环境变量读 RMS 两组。勿打印。"""
55
+ p = rms_env_prefix(shop)
56
+ out = {
57
+ "rlogin_id": (os.environ.get(p + "LOGIN_ID") or "").strip(),
58
+ "rlogin_password": (os.environ.get(p + "PASSWORD") or "").strip(),
59
+ "sso_user": (os.environ.get(p + "SHOP_USER") or "").strip(),
60
+ "sso_password": (os.environ.get(p + "SHOP_PASSWORD") or "").strip(),
61
+ }
62
+ missing = [k for k, v in out.items() if not v]
63
+ if missing:
64
+ raise RuntimeError(
65
+ f"{shop.upper()} 缺少环境变量 {p}*(缺: {', '.join(missing)});"
66
+ "见 rms_browser_login --env-file"
67
+ )
68
+ return out
106
69
 
107
70
 
108
71
  def rms_creds_present(shop: str) -> dict:
109
- """只返回是否存在,不读出密码。"""
110
- shop_u = shop.upper()
72
+ """布尔:mcp_url / api_key 不在此探测网络;agent_token / rlogin / ai 看环境变量。"""
73
+ p = rms_env_prefix(shop)
111
74
  return {
112
- "rlogin": has_generic(rms_rlogin_service(shop_u)),
113
- "ai": has_generic(rms_ai_service(shop_u)),
114
- "agent_token": has_generic(AGENT_TOKEN_SERVICE),
75
+ "mcp_url": jdy.mcp_url_present(),
76
+ "agent_token": bool((os.environ.get("DATAANALYST_AGENT_TOKEN") or "").strip()),
77
+ "rlogin": bool((os.environ.get(p + "LOGIN_ID") or "").strip()
78
+ and (os.environ.get(p + "PASSWORD") or "").strip()),
79
+ "ai": bool((os.environ.get(p + "SHOP_USER") or "").strip()
80
+ and (os.environ.get(p + "SHOP_PASSWORD") or "").strip()),
115
81
  }
82
+
83
+
84
+ def has_generic(service: str, account: Optional[str] = None) -> bool:
85
+ if service == JDY_MCP_SERVICE:
86
+ return jdy.mcp_url_present()
87
+ return False
88
+
89
+
90
+ def set_generic(*_a, **_k) -> None:
91
+ raise RuntimeError(
92
+ "已废弃:不再写入 dataanalyst/* Keychain。"
93
+ "与销量计算系统一致,本机只维护 JDY_MCP / 本机JDY服务器URL。"
94
+ )
95
+
96
+
97
+ def set_agent_token(*_a, **_k) -> None:
98
+ raise RuntimeError(
99
+ "已废弃:不再写入 dataanalyst-agent-token。"
100
+ "请使用环境变量 DATAANALYST_AGENT_TOKEN 或 login --bootstrap。"
101
+ )
102
+
103
+
104
+ def set_rms_creds(*_a, **_k) -> None:
105
+ raise RuntimeError(
106
+ "已废弃:不再写入 dataanalyst/rms/* Keychain。"
107
+ "请使用 RMS_<SHOP>_* 环境变量或 rms_browser_login --env-file。"
108
+ )
package/local_config.py CHANGED
@@ -9,7 +9,7 @@ from pathlib import Path
9
9
 
10
10
  SHOPS = ["0RT1", "3RT1", "5RT1", "3RT2", "5RT2", "7RT2"]
11
11
  DEFAULT_BASE = "https://dataanalyst.jp"
12
- VERSION = "0.4.0"
12
+ VERSION = "0.5.0"
13
13
  HERE = Path(__file__).resolve().parent
14
14
  # 优先用户级配置,其次包内 state(POC 兼容)
15
15
  USER_CFG = Path.home() / ".config" / "dataanalyst-agent" / "config.json"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genduo/desk",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Local Mac desk agent CLI for genduo",
5
5
  "bin": {
6
6
  "genduo-desk": "bin/genduo-desk.js"
@@ -11,6 +11,7 @@
11
11
  "cli.py",
12
12
  "api_client.py",
13
13
  "keychain_util.py",
14
+ "jdy_secrets.py",
14
15
  "local_config.py",
15
16
  "run.py",
16
17
  "rms_browser_login.py",
@@ -5,14 +5,16 @@
5
5
  NOTE (npm / shop CLI):
6
6
  源真相仍是 monorepo `tools/rms_browser_login.py`(VPS 路径也用那份)。
7
7
  本文件是 collector-agent 的 vendored 副本,供 `npm i -g dataanalyst-agent`
8
- 后门店机无 monorepo 也能跑 `--verify-rms`。打包前若源文件有变,请同步拷贝。
8
+ 后门店机无 monorepo 也能跑 `--verify-rms`。打包前若源文件有变,请同步拷贝;
9
+ 勿再读 dataanalyst/rms/* Keychain;JDY_MCP 见 jdy_secrets.py(与销量系统一致)。
9
10
 
10
11
  Rakuten 已把 R-Login 切到 Rakuten 会员 SSO:
11
12
  1. glogin 填 R-Login ID / 密码(第一组);
12
13
  2. Rakuten 会员填邮箱 / 密码(第二组);
13
14
  3. R-Login 提示页「次へ」→ RMS 主菜单「RMSを利用します」。
14
15
 
15
- 凭据从 env 文件或 macOS Keychain 读,不打印。会话保存为 600 的 storage_state。
16
+ 凭据从 env 文件读,不打印。会话保存为 600 的 storage_state。
17
+ 简道云 JDY_MCP / API Key 逻辑见 collector-agent/jdy_secrets.py(与销量计算系统一致)。
16
18
 
17
19
  用法:
18
20
  # VPS / env 文件
@@ -21,16 +23,14 @@ Rakuten 已把 R-Login 切到 Rakuten 会员 SSO:
21
23
  --env-file /etc/dataanalyst/rms.env \\
22
24
  --out /etc/dataanalyst/rms_session/7RT2.json
23
25
 
24
- # 本机 Keychain(service: dataanalyst/rms/<shop>-rlogin 与 -ai)
25
- python tools/rms_browser_login.py --shop 7RT2 --from-keychain \\
26
+ # 本机:Keychain 仅 JDY_MCP;RMS 凭据从简道云密码表取
27
+ python tools/rms_browser_login.py --shop 7RT2 --from-jdy \\
26
28
  --out tools/collector-agent/state/7RT2.json
27
29
  """
28
30
  from __future__ import annotations
29
31
 
30
32
  import argparse
31
33
  import os
32
- import re
33
- import subprocess
34
34
  import sys
35
35
  from pathlib import Path
36
36
 
@@ -53,36 +53,16 @@ def load_env(path: str) -> dict:
53
53
  return out
54
54
 
55
55
 
56
- def keychain_get(service: str) -> tuple[str, str]:
57
- """Return (account, password) from macOS Keychain. Never prints password."""
58
- meta = subprocess.check_output(
59
- ["security", "find-generic-password", "-s", service],
60
- stderr=subprocess.DEVNULL,
61
- ).decode("utf-8", errors="replace")
62
- m = re.search(r'"acct"<blob>="([^"]*)"', meta)
63
- if not m:
64
- raise SystemExit(f"Keychain 缺 account: {service}")
65
- account = m.group(1)
66
- password = subprocess.check_output(
67
- ["security", "find-generic-password", "-s", service, "-w"],
68
- stderr=subprocess.DEVNULL,
69
- ).decode("utf-8", errors="replace").rstrip("\n")
70
- if not password:
71
- raise SystemExit(f"Keychain 缺 password: {service}")
72
- return account, password
73
56
 
74
57
 
75
58
  def load_from_keychain(shop: str) -> dict:
76
- shop_u = shop.upper()
77
- login_id, password = keychain_get(f"dataanalyst/rms/{shop_u}-rlogin")
78
- sso_user, sso_pass = keychain_get(f"dataanalyst/rms/{shop_u}-ai")
79
- prefix = f"RMS_{shop_u}_"
80
- return {
81
- prefix + "LOGIN_ID": login_id,
82
- prefix + "PASSWORD": password,
83
- prefix + "SHOP_USER": sso_user,
84
- prefix + "SHOP_PASSWORD": sso_pass,
85
- }
59
+ """已废弃:不再读 dataanalyst/rms/* Keychain。请用 --env-file 或环境变量。"""
60
+ raise SystemExit(
61
+ "已废弃 --from-keychain(dataanalyst/rms/*)。"
62
+ "请使用 --env-file 或环境变量 RMS_<SHOP>_LOGIN_ID/PASSWORD/SHOP_USER/SHOP_PASSWORD。"
63
+ )
64
+
65
+
86
66
 
87
67
 
88
68
  def _try_click(page, role: str, name: str, timeout: int = 4000) -> bool:
@@ -185,7 +165,7 @@ def main() -> int:
185
165
  ap.add_argument("--shop", required=True)
186
166
  ap.add_argument("--env-file", default="/etc/dataanalyst/rms.env")
187
167
  ap.add_argument("--from-keychain", action="store_true",
188
- help="从 macOS Keychain 读 dataanalyst/rms/<SHOP>-rlogin 与 -ai")
168
+ help="已废弃(原 dataanalyst/rms/* Keychain)")
189
169
  ap.add_argument("--out", help="默认 tools/collector-agent/state/<shop>.json(本机)")
190
170
  ap.add_argument("--screenshot", help="可选截图路径(不含密钥)")
191
171
  ap.add_argument("--headed", action="store_true")
@@ -193,7 +173,6 @@ def main() -> int:
193
173
  out = a.out or f"tools/collector-agent/state/{a.shop.upper()}.json"
194
174
  try:
195
175
  if a.from_keychain:
196
- print("cred_source", "keychain")
197
176
  env = load_from_keychain(a.shop)
198
177
  # Confirm accounts only (no passwords)
199
178
  prefix = f"RMS_{a.shop.upper()}_"
package/run.py CHANGED
@@ -244,7 +244,7 @@ def main(argv: list[str] | None = None) -> int:
244
244
  return 2
245
245
  base = conf.get("base") or cfg.DEFAULT_BASE
246
246
  try:
247
- token = kc.get_agent_token()
247
+ token = kc.get_agent_token(shop)
248
248
  except RuntimeError as exc:
249
249
  log.error("%s", exc)
250
250
  return 2