new-erp-mcp 0.2.2 → 0.3.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 CHANGED
@@ -12,7 +12,7 @@
12
12
 
13
13
  ### 1. 安装命令行工具
14
14
 
15
- 需要 Node.js 20+。npm 包名为 `new-erp-mcp`,安装后使用 `new-erp` 命令。也支持 `npm install -g ./new-erp-mcp-0.2.2.tgz` 安装下载的 npm 包。
15
+ 需要 Node.js 20+。npm 包名为 `new-erp-mcp`,安装后使用 `new-erp` 命令。也支持 `npm install -g ./new-erp-mcp-0.3.0.tgz` 安装下载的 npm 包。
16
16
 
17
17
  ```sh
18
18
  node --version
@@ -26,18 +26,20 @@ new-erp game-product-list --help
26
26
  npx --package=new-erp-mcp new-erp game-product-list --help
27
27
  ```
28
28
 
29
- 需要固定版本时使用 `new-erp-mcp@0.2.2`。
29
+ 需要固定版本时使用 `new-erp-mcp@0.3.0`。
30
30
 
31
31
  ### 2. 配置 ERP
32
32
 
33
- 推荐用自己的 ERP 账号登录,密码在终端中隐藏输入:
33
+ 推荐打开本机临时网页登录,一次填写 ERP 网址、账号和密码:
34
34
 
35
35
  ```sh
36
- new-erp login --baseUrl https://your-erp.example.com
36
+ new-erp login --browser
37
37
  ```
38
38
 
39
39
  登录只保存 token,不保存密码,后续 CLI 与本地 MCP 均可使用。运行 `new-erp logout` 删除本机登录会话。
40
40
 
41
+ 网页登录成功后自动关闭临时服务。MCP 未登录时才返回详细引导,让 Agent 打开登录页并在完成后重试查询;初始化只加载两个入口,业务参数按需发现。也支持 `new-erp login --baseUrl https://your-erp.example.com` 在终端隐藏输入密码,或使用客户端私密输入卡。
42
+
41
43
  已有 token 时,也可在运行命令的目录创建不提交到版本库的 `config.json`:
42
44
 
43
45
  ```json
@@ -117,13 +119,15 @@ new-erp setup --client codex
117
119
 
118
120
  该命令调用本机 `codex mcp add`,将当前 Node.js 和 MCP 入口的绝对路径注册到 Codex,无需手动编辑配置。需要先安装 Codex CLI;会替换同名的 `new-erp` MCP 配置,可用 `--name` 改名或用 `--dryRun` 预览。重启 Codex 后即可在对话中查询。
119
121
 
120
- `new-erp mcp` 提供本地 stdio MCP,工具为 `game_product_list`、`product_detail`、`inventory_list` 和 `erp_options`。所有工具只使用 `new-erp login` 保存的会话;缺失或失效时要求重新登录,不读取项目或环境中的共享 token。
122
+ `new-erp mcp` 只注册 `erp_discover` 和 `erp_execute` 两个入口,按需发现并调用现有全部 24 项业务能力。包括商品、ASIN 图片、销量、订单、库存、装箱、配货、物流和导出;`auth.login` 可直接打开本机配置页,不需要 Agent 执行终端命令。业务只使用保存的个人会话,不读取共享 token。
123
+
124
+ 导出返回任务编号及本地文件路径,长任务不阻塞客户端;参数与完整能力目录通过 `erp_discover` 获取。MCP 用户无需安装 CLI 技能。0.3 起,原四个直接工具改为 `erp_execute` 的业务能力名称。
121
125
 
122
126
  安装、登录保存位置、手动配置和更新方法见 [本地 MCP 指南](./docs/mcp.md)。
123
127
 
124
- ## 供 AI Agent 使用
128
+ ## 可选 CLI 技能
125
129
 
126
- 安装技能,让 Agent 按需选命令、处理分页并核对数据口径:
130
+ MCP 用户无需安装仓库技能。需要让 Agent 直接使用 CLI、处理批量导出或分页时,可选安装:
127
131
 
128
132
  ```sh
129
133
  npx skills add PIZZAxBOY/new-erp-scripts --skill new-erp-scripts --agent codex
package/docs/api.md CHANGED
@@ -26,7 +26,7 @@ for (const product of products) {
26
26
  }
27
27
  ```
28
28
 
29
- 示例 ASIN 需替换为实际值。CLI 为 `new-erp product-detail --asin B012345678`,MCP 为 `product_detail({ asin: 'B012345678' })`。
29
+ 示例 ASIN 需替换为实际值。CLI 为 `new-erp product-detail --asin B012345678`,MCP 为 `erp_execute({ operation: 'product_detail', params: { asin: 'B012345678' } })`,返回任务回执。MCP 覆盖本文件全部业务能力,参数按需发现,详见 [MCP 指南](./mcp.md)。
30
30
 
31
31
  查询仅访问当前配置的 ERP:
32
32
 
package/docs/mcp.md CHANGED
@@ -1,88 +1,116 @@
1
- # 通过 npm 安装本地 MCP
1
+ # 安装与使用本地 MCP
2
2
 
3
- 本包提供本地 stdio MCP,不需要部署在线服务。每位用户在自己的电脑上安装并登录 ERP,电脑需能访问 ERP。需要 Node.js 20+;自动接入 Codex 还需要 Codex CLI。
3
+ 需要 Node.js 20+。MCP 在用户自己的电脑运行,使用各自的 ERP 登录和权限,无需部署服务器。MCP 用户无需安装仓库的可选 CLI 技能。
4
4
 
5
- ## 安装与登录
6
-
7
- 从 npm 安装:
5
+ ## 安装并接入客户端
8
6
 
9
7
  ```sh
10
8
  npm install -g new-erp-mcp
9
+ new-erp setup --client codex
11
10
  ```
12
11
 
13
- 也可以下载打包生成的 `.tgz`,通过 npm 安装:
12
+ 重启 Codex 或 T3 Code 以加载 MCP。`setup` 需要已安装的 Codex CLI,将当前 Node.js 和 MCP 脚本的绝对路径写入客户端配置,不写入账号或凭据。用 `codex mcp get new-erp` 检查路径;`new-erp setup --dryRun` 预览注册命令。不要用临时 `npx` 安装目录运行 `setup`。
14
13
 
15
- ```sh
16
- npm install -g ./new-erp-mcp-0.2.2.tgz
14
+ 其他支持本地 stdio 的客户端可将 `new-erp mcp` 配置为启动命令。标准输入和输出仅传输 MCP 协议。
15
+
16
+ ## 首次在网页完成配置
17
+
18
+ 向 Agent 提出业务需求即可,例如“按 ASIN 查询商品图片”。未登录时,工具返回配置引导,Agent 调用:
19
+
20
+ ```js
21
+ erp_execute({ operation: 'auth.login', params: {} })
17
22
  ```
18
23
 
19
- 在自己的终端完成登录,ERP 地址替换为实际地址:
24
+ 该调用立即返回本机登录链接,并自动尝试打开浏览器。用户在页面一次填写 ERP 网址、账号和密码;不能自动打开时手动打开返回的链接。Agent 不查看或填写真实密码字段。密码由网页直接交给本地程序登录 ERP,不进入对话、工具参数、命令参数或结果,也不会保存。
25
+
26
+ 页面五分钟后失效,成功、取消或过期后关闭临时服务。登录失败可在原页面重试,不会覆盖原会话。Agent 可查询 `auth.status`,登录成功后重试原业务操作,无需重启 MCP。
27
+
28
+ 也可独立运行 CLI:
20
29
 
21
30
  ```sh
22
- new-erp login --baseUrl https://your-erp.example.com
23
- new-erp setup --client codex
31
+ new-erp login --browser
32
+ new-erp login --browser --open false
24
33
  ```
25
34
 
26
- `login` 提示输入账号和密码,密码不回显。也可用 `--username` 提供账号。非交互环境使用 `NEW_ERP_USERNAME` 和 `NEW_ERP_PASSWORD`;不要把密码放进命令参数或聊天。登录失败不会覆盖原会话。
35
+ 第二条只输出登录链接。网页不可用时,使用客户端私密输入能力或在自己的终端运行 `new-erp login --baseUrl https://your-erp.example.com`,隐藏输入密码。不要在聊天正文或命令参数中提交密码。
27
36
 
28
- 重启 Codex 后即可使用,例如“查询某个 SKU 的产品图片”。`codex mcp list` 可查看注册配置;Codex 终端界面中的 `/mcp` 可查看连接状态。所有工具都要求有效的本机 ERP 会话。
37
+ ## 按需发现能力和参数
29
38
 
30
- ## Codex 配置
39
+ 初始化只加载两个工具,完整业务参数在需要时返回:
31
40
 
32
- `setup` 调用 `codex mcp add`,注册当前安装的 Node.js 和 `scripts/mcp.js` 的绝对路径,不将账号、密码或 token 写入 Codex 配置。它会替换同名配置,其他名称的 MCP 不受影响。
41
+ | 工具 | 用法 |
42
+ | --- | --- |
43
+ | `erp_discover` | `query` 按关键词找能力;`operation` 返回所选能力的参数;都省略时列出目录。 |
44
+ | `erp_execute` | 用 `operation`、`params` 执行;导出还需稳定的 `requestId`。 |
33
45
 
34
- ```sh
35
- new-erp setup --client codex --dryRun
36
- new-erp setup --client codex --name my-erp
46
+ 目录覆盖现有 24 项业务能力:商品与分类、ASIN 图片、销量与汇总、市场订单、库存与凭证、装箱与配货、外包装、物流与申报,以及全部导出。`auth.*` 和 `jobs.*` 也通过同一个入口按需调用。
47
+
48
+ ```js
49
+ erp_discover({ query: '商品' })
50
+ erp_discover({ operation: 'product_detail' })
51
+ erp_execute({ operation: 'product_detail', params: { asin: 'B012345678' } })
37
52
  ```
38
53
 
39
- 其他支持本地 stdio 的客户端可以使用 `new-erp mcp` 作为启动命令,也可使用 Node.js 和 MCP 脚本的绝对路径。先独立完成登录,MCP 进程的标准输入与输出只传输协议,不能用于交互输入密码。
54
+ ASIN 是示例值。其他业务参数以对应的 `erp_discover({ operation })` 结果为准;未知参数、错误类型和凭据参数会被拒绝。查询只使用本机保存的个人会话,不回退到环境或项目中的共享 token。
40
55
 
41
- 不要通过临时的 `npx` 安装目录运行 `setup`;全局安装能提供持久的脚本路径。移动包目录、切换 Node.js 安装位置或更新包后重新运行 `setup`。Codex 的接入方式见 [OpenAI 官方 MCP 文档](https://learn.chatgpt.com/docs/extend/mcp)。
56
+ 0.3 起,原四个直接 MCP 工具改为业务能力名称。例如原 `product_detail({ asin })` 改为上面的 `erp_execute` 调用。CLI 和 JavaScript API 保留原有入口。
42
57
 
43
- ## 更新版本
58
+ ## 读取任务结果和导出文件
44
59
 
45
- ```sh
46
- npm install -g new-erp-mcp@latest
47
- new-erp setup --client codex
60
+ 业务调用返回任务回执,包括 `jobId` 和 `state`。短查询会等待片刻,通常直接返回 `succeeded` 及 `result.data`;较慢的操作返回 `queued` 或 `running`,按 `pollAfterMs` 提示查询:
61
+
62
+ ```js
63
+ erp_execute({ operation: 'jobs.status', params: { jobId: '返回的任务编号' } })
64
+ erp_execute({ operation: 'jobs.list', params: { limit: 10 } })
48
65
  ```
49
66
 
50
- 更新后重启 MCP 客户端以加载新代码。本机登录会话保存在包目录之外,更新包不会删除会话;同一系统用户、ERP 地址和会话路径下,有效 token 可继续使用,无需重新登录。token 失效、主动退出或更换上述配置后,重新运行 `new-erp login`。
67
+ `succeeded` 才表示执行完成;`failed`、`interrupted` 和 `cancelled` 不能当作成功或空结果。结果以单份 JSON 文本返回,不在 `structuredContent` 中重复整份数据。
51
68
 
52
- ## 可用工具
69
+ `jobs.list` 仅返回任务摘要和文件信息,不重复每个任务的数据;完整结果通过单项 `jobs.status` 获取。
53
70
 
54
- | MCP 工具 | 用途 |
55
- | --- | --- |
56
- | `game_product_list` | 按 SKU、品名、分类和类型查询 ERP 商品及图片,每次返回一页。 |
57
- | `product_detail` | 按 ERP 产品基础 ID 或 ASIN 查询详情、ERP 图片和全部 SKU/BOM 版本;`id/asin` 二选一。ASIN 先查 ERP Listing,再按匹配的 SKU 获取详情。 |
58
- | `inventory_list` | 按精确 SKU、仓库代码查询库存批次或仓库汇总,每次返回一页。 |
59
- | `erp_options` | 查询库存、装箱或配货仓库的实时枚举。 |
71
+ 导出使用独立的请求编号。同一次重试复用编号,新的导出使用新编号;相同编号和参数返回原任务,不重复提交。编号与不同参数冲突时会报错:
72
+
73
+ ```js
74
+ erp_discover({ operation: 'export_game_products' })
75
+ erp_execute({
76
+ operation: 'export_game_products',
77
+ requestId: 'product-export-request-001',
78
+ params: { fields: ['sku', 'name_cn', 'price'], disused: false },
79
+ })
80
+ ```
81
+
82
+ 文件保存在本机,结果返回 `artifact.path`、类型、字节数等信息;不会把 XLSX、CSV、Base64 或整份大型结果塞进上下文。大型 JSON 完整保存到文件并提供有限预览;逐页装箱读取保存为 NDJSON,分页失败会标记不完整并返回失败,不静默截断。
60
83
 
61
- 工具直接调用已有 JavaScript 函数。数组结果封装为 `{ list }`,对象结果保持原结构;同时返回 MCP 文本和 `structuredContent`。工具参数不接受账号、密码、token 或自定义 ERP 地址。
84
+ 默认导出目录为 `~/Downloads/new-erp`,可用 `NEW_ERP_EXPORT_DIR` 指定专用目录。macOS/Linux 的私有任务目录和文件使用 `0700`、`0600` 权限;Windows 使用当前用户的文件权限。MCP 管理输出路径,不接受 CLI 的 `output` 参数。任务记录不保存 token 或密码;换账号后不能通过 MCP 读取其他账号的任务。
62
85
 
63
- 商品列表和库存支持 `page`、`pageSize`,默认第1页20条,每页最多100条。`product_detail` 的 ASIN 解析会完整读取相关 Listing 和 SKU 搜索,每页100条,`maxPages` 默认100、上限1000,保留全部精确匹配及店铺信息。批量导出和其他全量查询可继续使用 CLI/API。库存预测量不是已签收库存,保留批次与 BOM 差异。
86
+ ## 恢复装箱导出与取消任务
64
87
 
65
- ## 本机登录会话
88
+ 装箱导出会在继续轮询前保存 ERP `taskId`。任务中断后先检查原任务;有已保存的装箱检查点时,可使用新的请求编号按原范围恢复:
66
89
 
67
- 会话仅包含 ERP 地址、账号、token、用户 ID 和上游返回的过期时间,不保存密码,也不会上传到额外的服务。位置为:
90
+ ```js
91
+ erp_execute({
92
+ operation: 'jobs.resume',
93
+ requestId: 'packing-resume-request-002',
94
+ params: { jobId: '原装箱任务编号' },
95
+ })
96
+ ```
68
97
 
69
- - macOS/Linux:`${XDG_CONFIG_HOME:-~/.config}/new-erp/session.json`。
70
- - Windows:`%LOCALAPPDATA%\new-erp\session.json`,缺少该变量时使用用户目录下的 `AppData\Local`。
71
- - 设置 `NEW_ERP_SESSION_FILE` 可指定其他位置,适合隔离不同本地环境。
98
+ 恢复会校验原查询和 `taskId`,不创建替代导出。没有检查点或其他导出类型不自动恢复、重提;ERP 端任务可能仍继续运行,先查看下载中心。客户端断线不会自动取消已接收的任务;停止执行进程可能中断本地处理。
72
99
 
73
- token 以明文保存在本机文件,macOS/Linux 文件权限为 `0600`,应用新建的目录为 `0700`;Windows 使用当前用户目录的文件权限。不要分享或提交会话文件。登录写入采用临时文件后原子替换,读到的会话对应一次完整登录。
100
+ `jobs.cancel` 可取消本实例中尚在排队的任务。现有业务 API 不支持可靠地取消运行中的 ERP 操作,工具会明确返回不支持。不同 MCP 进程可以共用任务目录;其他存活进程的未完成任务返回最后记录和不确定状态,不自动接管或重试。
74
101
 
75
- MCP 每次 ERP 调用读取当前会话,并把 ERP 地址及 token 绑定到该次调用;不回退到当前目录的 `config.json` 或 `NEW_ERP_TOKEN`。切换账号或退出后,后续调用使用新的状态,已开始的调用继续使用开始时的会话。
102
+ ## 保留登录会话并更新
76
103
 
77
- 可识别的过期时间到期,或 ERP 返回登录失效时,需要重新运行 `new-erp login`。目前没有自动刷新 ERP token 的实现。权限不足保留为权限错误,不换用其他账号。
104
+ 会话只保存 ERP 地址、账号、token、用户 ID 和过期时间,不保存密码。默认位置为 macOS/Linux 的 `${XDG_CONFIG_HOME:-~/.config}/new-erp/session.json`,或 Windows 的 `%LOCALAPPDATA%\new-erp\session.json`;也可用 `NEW_ERP_SESSION_FILE` 指定位置。
78
105
 
79
106
  ```sh
80
- new-erp logout
107
+ npm install -g new-erp-mcp@latest
108
+ new-erp setup --client codex
81
109
  ```
82
110
 
83
- `logout` 删除本机保存的会话,不撤销上游 token,也不修改项目配置或环境变量。旧 CLI/API 显式配置的认证仍按原方式生效;MCP 的 ERP 工具要求重新登录。
111
+ 更新后重启客户端以加载新代码。会话文件在包目录之外,有效会话会保留,通常无需重新登录;失效后重新打开登录页。`auth.logout` 或 `new-erp logout` 删除本机会话,不撤销 ERP 上游 token;已开始的任务继续使用原账号快照,切换账号后不能读取原账号的任务。
84
112
 
85
- ## 打包与验证
113
+ ## 打包验证
86
114
 
87
115
  ```sh
88
116
  npm ci
@@ -91,6 +119,4 @@ npm run check:package
91
119
  npm pack
92
120
  ```
93
121
 
94
- `npm pack` 生成可供 npm 安装的 `.tgz`。可作为 Release 附件分发;上传 Release 或发布 npm registry 是单独的发布操作。包中不包含本机会话、项目配置或测试数据。
95
-
96
- `npm run check:package` 检查实际发布文件清单,阻止内部服务地址、个人路径、凭据和业务导出文件进入包。检查已生成的包可用 `node test/check-package-privacy.js ./new-erp-mcp-0.2.2.tgz`;检查结果只输出文件、行号和类别,不输出匹配到的敏感值。
122
+ `.tgz` 可通过 `npm install -g ./new-erp-mcp-0.3.0.tgz` 安装。发布文件不包含本机会话、项目配置、业务导出或测试数据。使用 `node test/check-package-privacy.js ./new-erp-mcp-0.3.0.tgz` 检查实际归档;检查只报告文件、行号和类别,不输出敏感值。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "new-erp-mcp",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "CLI and local MCP tools for querying new-erp products, images and inventory",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -118,7 +118,7 @@ async function main(argsValue) {
118
118
  return filtered.map((row) => outputRow(row, Boolean(argsValue.raw)));
119
119
  }
120
120
 
121
- module.exports = { main };
121
+ module.exports = { main, args };
122
122
 
123
123
  if (require.main === module) {
124
124
  runCli({
@@ -153,7 +153,7 @@ async function main(argsValue) {
153
153
  }));
154
154
  }
155
155
 
156
- module.exports = { main };
156
+ module.exports = { main, args };
157
157
 
158
158
  if (require.main === module) {
159
159
  runCli({
@@ -19,5 +19,5 @@ async function main(options = {}) {
19
19
  return { data, source };
20
20
  }
21
21
 
22
- module.exports = { main };
22
+ module.exports = { main, args };
23
23
  if (require.main === module) runCli({ name: 'options', description: '读取ERP实时仓库、状态等枚举,不推断业务状态含义。', args, func: main });
@@ -123,7 +123,7 @@ async function main(argsValue) {
123
123
  return outputRows;
124
124
  }
125
125
 
126
- module.exports = { main };
126
+ module.exports = { main, args };
127
127
 
128
128
  if (require.main === module) {
129
129
  runCli({
@@ -106,7 +106,7 @@ async function main(argsValue) {
106
106
  return rows.map((row) => productRow(row, meta, Boolean(argsValue.raw)));
107
107
  }
108
108
 
109
- module.exports = { main };
109
+ module.exports = { main, args };
110
110
 
111
111
  if (require.main === module) {
112
112
  runCli({
@@ -23,5 +23,5 @@ async function main(options = {}) {
23
23
  return result;
24
24
  }
25
25
 
26
- module.exports = { main };
26
+ module.exports = { main, args };
27
27
  if (require.main === module) runCli({ name: 'inventory-list', description: '读取国内仓即时、占用、在途和预计可用库存;预计可用不等于已签收可装箱。', args, func: main });
@@ -0,0 +1,185 @@
1
+ const http = require('node:http');
2
+ const { randomBytes } = require('node:crypto');
3
+ const { spawn } = require('node:child_process');
4
+ const { CommandExecutionError, loginToken, normalizeBaseUrl, requiredString, withErpContext } = require('./shared.js');
5
+ const { saveSession } = require('./local-session.js');
6
+
7
+ const BODY_LIMIT = 16 * 1024;
8
+
9
+ function escapeHtml(value) {
10
+ return String(value ?? '').replace(/[&<>"']/g, (character) => ({
11
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
12
+ })[character]);
13
+ }
14
+
15
+ function page({ route, csrf, defaults, error, complete = false }) {
16
+ const hidden = `<input type="hidden" name="csrf" value="${csrf}">`;
17
+ return `<!doctype html>
18
+ <html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
19
+ <title>${complete ? '登录成功' : 'ERP 登录'}</title>
20
+ <style>
21
+ *{box-sizing:border-box}body{margin:0;color:#152c29;background:#f4f7f5;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif;min-height:100vh;display:grid;place-items:center;padding:40px 20px;background-image:radial-gradient(ellipse at 15% 0%,#e5f0e9,transparent 50%)}
22
+ main{width:100%;max-width:480px}.brand{display:flex;align-items:center;gap:10px;font-size:15px;font-weight:700;margin:0 0 26px 4px}.mark{background:#164e41;color:white;border-radius:11px;padding:10px;font-size:13px;letter-spacing:1px}
23
+ .card{background:#fff;border:1px solid #e0e8e3;border-radius:22px;padding:36px;box-shadow:0 18px 55px #193d2710}h1{font-size:28px;letter-spacing:-.7px;margin:0 0 26px;font-weight:650}.intro{font-size:14px;line-height:1.8;color:#718078;margin:0}
24
+ label{display:block;font-size:13px;font-weight:600;margin:22px 0 9px}input{display:block;width:100%;border:1px solid #d9e2dc;border-radius:10px;padding:13px 14px;font-size:15px;color:#203a30;background:#fbfcfb;outline:none;transition:border-color .15s,box-shadow .15s}input:focus{border-color:#3c8068;box-shadow:0 0 0 3px #3c806815}input::placeholder{color:#a4b0a9}
25
+ button{font:inherit;cursor:pointer}.primary{margin-top:28px;width:100%;border:0;background:#205e4c;color:white;font-size:14px;font-weight:600;border-radius:10px;padding:15px;transition:background .15s}.primary:hover{background:#164a3b}.privacy{margin:22px 0 0;padding:15px 0 0;border-top:1px solid #edf0ed;font-size:12px;color:#7b8981;line-height:1.8}.cancel{text-align:center;margin-top:15px}.cancel button{border:0;background:none;color:#87968b;font-size:12px;padding:8px}.error{color:#a93a32;background:#fff3f1;border:1px solid #f3ded9;padding:12px;border-radius:9px;font-size:13px;line-height:1.6}.success{display:grid;place-items:center;width:48px;height:48px;background:#e8f3ec;color:#2c7051;border-radius:50%;font-size:25px;margin-bottom:22px}.foot{text-align:center;color:#99a59e;font-size:11px;margin:24px 0 0;letter-spacing:.4px}@media(max-width:520px){body{padding:24px 16px}.card{padding:28px 24px;border-radius:18px}h1{font-size:26px}}
26
+ </style></head><body><main><div class="brand"><span class="mark">ERP</span>New ERP</div>
27
+ <section class="card">${complete ? '<div class="success">✓</div><h1>登录成功</h1><p class="intro">配置已保存,可以关闭此页面,返回对话继续查询。</p>' : `
28
+ <h1>ERP 登录</h1>
29
+ ${error ? `<p class="error" role="alert">${escapeHtml(error)}</p>` : ''}
30
+ <form method="post" action="${route}" autocomplete="on">${hidden}
31
+ <label for="baseUrl">ERP 网址</label><input id="baseUrl" name="baseUrl" type="url" value="${escapeHtml(defaults.baseUrl)}" placeholder="https://your-erp.example.com" required autocomplete="url" spellcheck="false">
32
+ <label for="username">账号</label><input id="username" name="username" value="${escapeHtml(defaults.username)}" placeholder="输入 ERP 账号" required autocomplete="username">
33
+ <label for="password">密码</label><input id="password" name="password" type="password" placeholder="输入 ERP 密码" required autocomplete="current-password">
34
+ <button class="primary" type="submit">登录</button></form>
35
+ <p class="privacy">密码不进入 AI 对话;仅保存本机会话,不保存密码。</p>`}</section>
36
+ ${complete ? '' : `<form class="cancel" method="post" action="${route}/cancel">${hidden}<button type="submit">取消登录</button></form>`}
37
+ ${complete ? '' : '<p class="foot">本次登录页面将在 5 分钟后失效</p>'}</main></body></html>`;
38
+ }
39
+
40
+ function openLocalBrowser(url) {
41
+ const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32.exe' : 'xdg-open';
42
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
43
+ return new Promise((resolve, reject) => {
44
+ const child = spawn(command, args, { stdio: 'ignore', detached: true });
45
+ child.once('error', reject);
46
+ child.once('spawn', () => { child.unref(); resolve(); });
47
+ });
48
+ }
49
+
50
+ function readBody(request) {
51
+ return new Promise((resolve, reject) => {
52
+ let length = 0;
53
+ const chunks = [];
54
+ request.on('data', (chunk) => {
55
+ length += chunk.length;
56
+ if (length > BODY_LIMIT) {
57
+ chunks.length = 0;
58
+ reject(new Error('body-too-large'));
59
+ } else chunks.push(chunk);
60
+ });
61
+ request.once('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
62
+ request.once('error', reject);
63
+ request.once('aborted', () => reject(new Error('request-aborted')));
64
+ });
65
+ }
66
+
67
+ async function runBrowserLogin({ defaults = {}, openBrowser = openLocalBrowser,
68
+ onReady = (url) => process.stderr.write(`请在浏览器中完成 ERP 登录:${url}\n`),
69
+ signal, timeoutMs = 300_000 } = {}) {
70
+ if (signal?.aborted) throw new CommandExecutionError('网页登录已取消');
71
+ const route = `/login/${randomBytes(24).toString('hex')}`;
72
+ const csrf = randomBytes(24).toString('hex');
73
+ const shutdown = new AbortController();
74
+ const deadline = Date.now() + timeoutMs;
75
+ let phase = 'ready';
76
+ let origin;
77
+ let timer;
78
+ let drain;
79
+ let committed;
80
+
81
+ return new Promise((resolve, reject) => {
82
+ function finish(error) {
83
+ if (phase === 'settled') return;
84
+ if (committed) error = undefined;
85
+ phase = 'settled';
86
+ clearTimeout(timer);
87
+ clearTimeout(drain);
88
+ signal?.removeEventListener('abort', cancel);
89
+ process.removeListener('SIGINT', cancel);
90
+ process.removeListener('SIGTERM', cancel);
91
+ shutdown.abort();
92
+ server.close(() => error ? reject(error) : resolve(committed));
93
+ server.closeAllConnections();
94
+ }
95
+ function cancel() { finish(new CommandExecutionError('网页登录已取消')); }
96
+ function respond(response, status, html) {
97
+ response.writeHead(status, {
98
+ 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store',
99
+ 'Referrer-Policy': 'no-referrer', 'X-Content-Type-Options': 'nosniff',
100
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",
101
+ Connection: 'close',
102
+ });
103
+ response.end(html);
104
+ }
105
+ function endRun(response, status, html, error) {
106
+ phase = committed ? 'committed' : 'closing';
107
+ clearTimeout(timer);
108
+ drain = setTimeout(() => finish(error), 250);
109
+ response.once('finish', () => finish(error));
110
+ respond(response, status, html);
111
+ }
112
+ const render = (values, error, complete) => page({ route, csrf, defaults: values, error, complete });
113
+ async function handle(request, response) {
114
+ const rejectRequest = (status) => respond(response, status, '请求无效,请重新打开登录链接。');
115
+ if (request.headers.host !== new URL(origin).host) return rejectRequest(403);
116
+ if (request.url !== route && request.url !== `${route}/cancel`) return rejectRequest(404);
117
+ if (request.method === 'GET' && request.url === route) return respond(response, 200, render(defaults));
118
+ if (request.method !== 'POST') return rejectRequest(405);
119
+ if (request.headers.origin !== origin || request.headers['sec-fetch-site'] === 'cross-site') return rejectRequest(403);
120
+ if (request.headers['content-type']?.split(';')[0].trim() !== 'application/x-www-form-urlencoded') return rejectRequest(415);
121
+ const canceling = request.url === `${route}/cancel`;
122
+ if (phase !== 'ready' && !(canceling && phase === 'authenticating')) return rejectRequest(409);
123
+ let body;
124
+ try { body = await readBody(request); } catch { return rejectRequest(413); }
125
+ if (phase !== 'ready' && !(canceling && phase === 'authenticating')) return rejectRequest(409);
126
+ if (/%(?![a-f0-9]{2})/i.test(body)) return rejectRequest(400);
127
+ const fields = new URLSearchParams(body);
128
+ if ([...fields.keys()].some((key) => !['csrf', 'baseUrl', 'username', 'password'].includes(key) || fields.getAll(key).length !== 1)) return rejectRequest(400);
129
+ if (fields.get('csrf') !== csrf) return rejectRequest(403);
130
+ if (canceling) {
131
+ shutdown.abort();
132
+ return endRun(response, 200, '登录已取消,可以关闭此页面。', new CommandExecutionError('网页登录已取消'));
133
+ }
134
+ let baseUrl, username, password;
135
+ try {
136
+ baseUrl = normalizeBaseUrl(fields.get('baseUrl'));
137
+ username = requiredString(fields.get('username'), 'username');
138
+ password = requiredString(fields.get('password'), 'password');
139
+ } catch { return respond(response, 400, render(defaults, '请填写有效的 ERP 网址、账号和密码。')); }
140
+ phase = 'authenticating';
141
+ let session;
142
+ try {
143
+ session = await withErpContext({ baseUrl }, () => loginToken({ username, password, signal: shutdown.signal }));
144
+ } catch {
145
+ if (phase !== 'authenticating') return;
146
+ phase = 'ready';
147
+ return respond(response, 401, render({ baseUrl, username }, '登录失败,请检查 ERP 地址、账号和密码后重试。'));
148
+ }
149
+ if (phase !== 'authenticating' || shutdown.signal.aborted || Date.now() >= deadline) return;
150
+ let sessionFile;
151
+ try { sessionFile = saveSession({ baseUrl, ...session }); } catch {
152
+ return endRun(response, 500, render({ baseUrl, username }, '无法保存本机登录会话,请重新启动登录。'),
153
+ new CommandExecutionError('无法保存本机登录会话'));
154
+ }
155
+ committed = { baseUrl, username: session.username, userId: session.userId,
156
+ expireTime: session.expireTime, sessionFile, loggedIn: true };
157
+ endRun(response, 200, render({}, undefined, true));
158
+ }
159
+ const server = http.createServer((request, response) => {
160
+ void handle(request, response).catch(() => {
161
+ if (!response.headersSent) respond(response, 500, '登录服务暂不可用,请重新启动登录。');
162
+ finish(new CommandExecutionError('网页登录服务异常,请重新运行登录命令'));
163
+ });
164
+ });
165
+ server.requestTimeout = 15_000;
166
+ server.headersTimeout = 10_000;
167
+ server.maxConnections = 8;
168
+ server.once('error', () => finish(new CommandExecutionError('无法启动本机登录页面')));
169
+ signal?.addEventListener('abort', cancel, { once: true });
170
+ process.once('SIGINT', cancel);
171
+ process.once('SIGTERM', cancel);
172
+ timer = setTimeout(() => finish(new CommandExecutionError('登录页面已过期,请重新运行 new-erp login --browser')), timeoutMs);
173
+ server.listen(0, '127.0.0.1', () => {
174
+ if (phase === 'settled') return;
175
+ origin = `http://127.0.0.1:${server.address().port}`;
176
+ const url = `${origin}${route}`;
177
+ try { onReady(url); } catch { return finish(new CommandExecutionError('无法显示登录链接')); }
178
+ if (openBrowser && phase !== 'settled') Promise.resolve().then(() => openBrowser(url)).catch(() => {
179
+ if (phase !== 'settled') process.stderr.write('无法自动打开浏览器,请手动打开上面的本机登录链接。\n');
180
+ });
181
+ });
182
+ });
183
+ }
184
+
185
+ module.exports = { runBrowserLogin };
package/scripts/login.js CHANGED
@@ -3,7 +3,7 @@ const readline = require('node:readline/promises');
3
3
  const { Writable } = require('node:stream');
4
4
  const { ArgumentError, getBaseUrl, loginToken, normalizeBaseUrl, requiredString, runCli,
5
5
  withErpContext } = require('./shared.js');
6
- const { saveSession } = require('./local-session.js');
6
+ const { saveSession, readSession } = require('./local-session.js');
7
7
 
8
8
  async function promptCredentials(username) {
9
9
  if (!process.stdin.isTTY || !process.stderr.isTTY) {
@@ -29,7 +29,30 @@ async function promptCredentials(username) {
29
29
  }
30
30
 
31
31
  async function main(options = {}) {
32
- const baseUrl = options.baseUrl ? normalizeBaseUrl(options.baseUrl) : getBaseUrl();
32
+ if (options.browser) {
33
+ let baseUrl = '';
34
+ try { baseUrl = normalizeBaseUrl(options.baseUrl || getBaseUrl()); } catch {}
35
+ let username = options.username || process.env.NEW_ERP_USERNAME || '';
36
+ if (!username) try { username = readSession()?.username || ''; } catch {}
37
+ const { runBrowserLogin } = require('./login-browser.js');
38
+ return runBrowserLogin({ defaults: { baseUrl, username },
39
+ ...(options.open === false ? { openBrowser: false } : {}) });
40
+ }
41
+ let baseUrl;
42
+ try {
43
+ baseUrl = options.baseUrl ? normalizeBaseUrl(options.baseUrl) : getBaseUrl();
44
+ } catch (error) {
45
+ if (!(error instanceof ArgumentError) || error.message !== 'NEW_ERP_BASE_URL is required') throw error;
46
+ if (!process.stdin.isTTY || !process.stderr.isTTY) {
47
+ throw new ArgumentError('首次登录需要 ERP 地址。请运行 new-erp login --baseUrl <ERP_URL>,或配置 NEW_ERP_BASE_URL;密码使用客户端私密输入或在自己的终端隐藏输入。');
48
+ }
49
+ const terminal = readline.createInterface({ input: process.stdin, output: process.stderr });
50
+ try {
51
+ baseUrl = normalizeBaseUrl(await terminal.question('ERP 地址: '));
52
+ } finally {
53
+ terminal.close();
54
+ }
55
+ }
33
56
  let username = String(options.username || process.env.NEW_ERP_USERNAME || '').trim();
34
57
  let password = process.env.NEW_ERP_PASSWORD;
35
58
  if (!password) ({ username, password } = await promptCredentials(username));
@@ -44,6 +67,8 @@ async function main(options = {}) {
44
67
  module.exports = { main };
45
68
  if (require.main === module) runCli({ name: 'login', description: '登录自己的 ERP 账号,仅在本机保存 token,不保存密码。',
46
69
  args: [
47
- { name: 'baseUrl', type: 'string', help: 'ERP 地址;首次登录必填,也可配置 NEW_ERP_BASE_URL' },
70
+ { name: 'browser', type: 'bool', default: false, help: '打开本机临时网页,一次填写 ERP 网址、账号和密码' },
71
+ { name: 'open', type: 'bool', default: true, help: '浏览器模式自动打开页面;设为 false 时只显示登录链接' },
72
+ { name: 'baseUrl', type: 'string', help: 'ERP 地址;首次登录未配置时在终端提示输入,也可配置 NEW_ERP_BASE_URL' },
48
73
  { name: 'username', type: 'string', help: 'ERP 账号;留空时交互输入,也可用 NEW_ERP_USERNAME' },
49
74
  ], func: main });
@@ -6,5 +6,5 @@ async function main(options = {}) {
6
6
  return queryList('/logistics/initial-channel/index', {}, options);
7
7
  }
8
8
 
9
- module.exports = { main };
9
+ module.exports = { main, args: listArgs };
10
10
  if (require.main === module) runCli({ name: 'logistics-channel-list', description: '查询头程物流渠道、运输方式和材积除数原始配置。', args: listArgs, func: main });
@@ -31,7 +31,7 @@ async function main(options = {}) {
31
31
  });
32
32
  }
33
33
 
34
- module.exports = { main };
34
+ module.exports = { main, args };
35
35
  if (require.main === module) {
36
36
  runCli({ name: 'market-order-list', description: '只读市场订单列表,保留订单、SKU、物流及实时枚举原字段;支持分页与 fetchAll。', args, func: main });
37
37
  }