new-erp-mcp 0.2.1 → 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 +11 -7
- package/docs/api.md +2 -2
- package/docs/erp-asin-contract.md +2 -2
- package/docs/mcp.md +78 -43
- package/package.json +1 -1
- package/scripts/catalog-list.js +1 -1
- package/scripts/category-summary.js +1 -1
- package/scripts/erp-options.js +1 -1
- package/scripts/game-product-batch.js +1 -1
- package/scripts/game-product-list.js +1 -1
- package/scripts/inventory-list.js +1 -1
- package/scripts/login-browser.js +185 -0
- package/scripts/login.js +28 -3
- package/scripts/logistics-channel-list.js +1 -1
- package/scripts/market-order-list.js +1 -1
- package/scripts/mcp-artifacts.js +106 -0
- package/scripts/mcp-auth.js +86 -0
- package/scripts/mcp-catalog.js +124 -0
- package/scripts/mcp-job-store.js +65 -0
- package/scripts/mcp-jobs.js +277 -0
- package/scripts/mcp.js +89 -57
- package/scripts/new-erp.js +28 -27
- package/scripts/outer-package-list.js +1 -1
- package/scripts/packing-batch-detail.js +1 -1
- package/scripts/packing-batch-list.js +1 -1
- package/scripts/packing-detail-export.js +1 -1
- package/scripts/packing-detail.js +1 -1
- package/scripts/packing-order-pages.js +1 -0
- package/scripts/plan-delivery-order-list.js +1 -1
- package/scripts/product-asin.js +7 -2
- package/scripts/product-declaration-list.js +1 -1
- package/scripts/product-detail.js +1 -1
- package/scripts/sales-volume-export.js +1 -1
- package/scripts/sales-volume-ranking.js +1 -0
- package/scripts/shared.js +16 -3
- package/scripts/stocking-assembly-inbound.js +1 -1
- package/scripts/supply-evidence.js +1 -1
- package/skills/new-erp-scripts/SKILL.md +8 -4
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.
|
|
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.
|
|
29
|
+
需要固定版本时使用 `new-erp-mcp@0.3.0`。
|
|
30
30
|
|
|
31
31
|
### 2. 配置 ERP
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
推荐打开本机临时网页登录,一次填写 ERP 网址、账号和密码:
|
|
34
34
|
|
|
35
35
|
```sh
|
|
36
|
-
new-erp login --
|
|
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`
|
|
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
|
-
##
|
|
128
|
+
## 可选 CLI 技能
|
|
125
129
|
|
|
126
|
-
|
|
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,13 +26,13 @@ for (const product of products) {
|
|
|
26
26
|
}
|
|
27
27
|
```
|
|
28
28
|
|
|
29
|
-
示例 ASIN 需替换为实际值。CLI 为 `new-erp product-detail --asin B012345678`,MCP 为 `
|
|
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
|
|
|
33
33
|
1. `GET /sales/ling-xing/jy-amazon-selected-option` 获取 `other_select_name1_arr`,选择唯一标为 `ASIN` 的字段值,不硬编码字段编号。
|
|
34
34
|
2. `GET /sales/ling-xing/jy-amazon-list-data` 使用 `other_select_name1` 和 `other_select_value1` 搜索,完整分页后按返回的 `asin` 精确核对。
|
|
35
|
-
3. 按每个匹配的 ERP `sku` 查询 `/products/game-product-list/index`,完整分页后精确匹配 SKU
|
|
35
|
+
3. 按每个匹配的 ERP `sku` 查询 `/products/game-product-list/index`,完整分页后精确匹配 SKU;没有精确匹配时再查询 `/products/game-product-list/game-disuse-product-list`,取得停用商品的产品 ID。
|
|
36
36
|
4. `/products/product-basic/detail` 查询详情,核对 ID 和 SKU 后返回,不将 Listing 行 ID 当作产品基础 ID。
|
|
37
37
|
|
|
38
38
|
这些接口由 [ERP 前端模块](./erp-asin-contract.md) 确认。ASIN 查询覆盖该 ERP 的 `jyAmazon` Listing 数据范围;未自动混入其他 Listing 接口或外部 ERP 数据。接口权限由登录账号决定。
|
|
@@ -16,10 +16,10 @@ ERP 前端的 `jyAmazon` API 模块声明:
|
|
|
16
16
|
|
|
17
17
|
## 商品详情关联
|
|
18
18
|
|
|
19
|
-
`gameProductList` API 模块声明 `GET /products/game-product-list/index`。ASIN 搜索返回 ERP SKU
|
|
19
|
+
`gameProductList` API 模块声明 `GET /products/game-product-list/index` 和 `GET /products/game-product-list/game-disuse-product-list`。ASIN 搜索返回 ERP SKU 后,先查询在售商品;没有精确匹配时再查询停用商品。两者都使用 SKU 查询参数、完整分页和精确匹配,取得产品基础 ID。
|
|
20
20
|
|
|
21
21
|
详情继续使用本项目既有 `/products/product-basic/detail?id=...`。Listing 行 ID、ERP SKU 和产品基础 ID 分别保留,不互相代替。
|
|
22
22
|
|
|
23
23
|
## 验证边界
|
|
24
24
|
|
|
25
|
-
已核对真实前端的路由、字段映射及使用方式,并以本地接口响应验证 CLI/MCP
|
|
25
|
+
已核对真实前端的路由、字段映射及使用方式,并以本地接口响应验证 CLI/MCP 的完整调用链、分页、账号隔离、模糊匹配过滤和异常处理。已使用登录账号通过 MCP 实测 ASIN 查询,验证停用商品详情及图片获取;公开文档不记录业务标识和结果。实际账号未开放该菜单或搜索枚举时直接报错,不转接领星外部接口或公开亚马逊商品页。
|
package/docs/mcp.md
CHANGED
|
@@ -1,79 +1,116 @@
|
|
|
1
|
-
#
|
|
1
|
+
# 安装与使用本地 MCP
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
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
|
-
|
|
24
|
+
该调用立即返回本机登录链接,并自动尝试打开浏览器。用户在页面一次填写 ERP 网址、账号和密码;不能自动打开时手动打开返回的链接。Agent 不查看或填写真实密码字段。密码由网页直接交给本地程序登录 ERP,不进入对话、工具参数、命令参数或结果,也不会保存。
|
|
25
|
+
|
|
26
|
+
页面五分钟后失效,成功、取消或过期后关闭临时服务。登录失败可在原页面重试,不会覆盖原会话。Agent 可查询 `auth.status`,登录成功后重试原业务操作,无需重启 MCP。
|
|
27
|
+
|
|
28
|
+
也可独立运行 CLI:
|
|
20
29
|
|
|
21
30
|
```sh
|
|
22
|
-
new-erp login --
|
|
23
|
-
new-erp
|
|
31
|
+
new-erp login --browser
|
|
32
|
+
new-erp login --browser --open false
|
|
24
33
|
```
|
|
25
34
|
|
|
26
|
-
|
|
35
|
+
第二条只输出登录链接。网页不可用时,使用客户端私密输入能力或在自己的终端运行 `new-erp login --baseUrl https://your-erp.example.com`,隐藏输入密码。不要在聊天正文或命令参数中提交密码。
|
|
27
36
|
|
|
28
|
-
|
|
37
|
+
## 按需发现能力和参数
|
|
29
38
|
|
|
30
|
-
|
|
39
|
+
初始化只加载两个工具,完整业务参数在需要时返回:
|
|
31
40
|
|
|
32
|
-
|
|
41
|
+
| 工具 | 用法 |
|
|
42
|
+
| --- | --- |
|
|
43
|
+
| `erp_discover` | `query` 按关键词找能力;`operation` 返回所选能力的参数;都省略时列出目录。 |
|
|
44
|
+
| `erp_execute` | 用 `operation`、`params` 执行;导出还需稳定的 `requestId`。 |
|
|
33
45
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
54
|
+
ASIN 是示例值。其他业务参数以对应的 `erp_discover({ operation })` 结果为准;未知参数、错误类型和凭据参数会被拒绝。查询只使用本机保存的个人会话,不回退到环境或项目中的共享 token。
|
|
40
55
|
|
|
41
|
-
|
|
56
|
+
0.3 起,原四个直接 MCP 工具改为业务能力名称。例如原 `product_detail({ asin })` 改为上面的 `erp_execute` 调用。CLI 和 JavaScript API 保留原有入口。
|
|
42
57
|
|
|
43
|
-
##
|
|
58
|
+
## 读取任务结果和导出文件
|
|
44
59
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 } })
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`succeeded` 才表示执行完成;`failed`、`interrupted` 和 `cancelled` 不能当作成功或空结果。结果以单份 JSON 文本返回,不在 `structuredContent` 中重复整份数据。
|
|
68
|
+
|
|
69
|
+
`jobs.list` 仅返回任务摘要和文件信息,不重复每个任务的数据;完整结果通过单项 `jobs.status` 获取。
|
|
70
|
+
|
|
71
|
+
导出使用独立的请求编号。同一次重试复用编号,新的导出使用新编号;相同编号和参数返回原任务,不重复提交。编号与不同参数冲突时会报错:
|
|
51
72
|
|
|
52
|
-
|
|
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,分页失败会标记不完整并返回失败,不静默截断。
|
|
83
|
+
|
|
84
|
+
默认导出目录为 `~/Downloads/new-erp`,可用 `NEW_ERP_EXPORT_DIR` 指定专用目录。macOS/Linux 的私有任务目录和文件使用 `0700`、`0600` 权限;Windows 使用当前用户的文件权限。MCP 管理输出路径,不接受 CLI 的 `output` 参数。任务记录不保存 token 或密码;换账号后不能通过 MCP 读取其他账号的任务。
|
|
53
85
|
|
|
54
|
-
|
|
86
|
+
## 恢复装箱导出与取消任务
|
|
55
87
|
|
|
56
|
-
|
|
88
|
+
装箱导出会在继续轮询前保存 ERP `taskId`。任务中断后先检查原任务;有已保存的装箱检查点时,可使用新的请求编号按原范围恢复:
|
|
57
89
|
|
|
58
|
-
|
|
90
|
+
```js
|
|
91
|
+
erp_execute({
|
|
92
|
+
operation: 'jobs.resume',
|
|
93
|
+
requestId: 'packing-resume-request-002',
|
|
94
|
+
params: { jobId: '原装箱任务编号' },
|
|
95
|
+
})
|
|
96
|
+
```
|
|
59
97
|
|
|
60
|
-
|
|
61
|
-
- Windows:`%LOCALAPPDATA%\new-erp\session.json`,缺少该变量时使用用户目录下的 `AppData\Local`。
|
|
62
|
-
- 设置 `NEW_ERP_SESSION_FILE` 可指定其他位置,适合隔离不同本地环境。
|
|
98
|
+
恢复会校验原查询和 `taskId`,不创建替代导出。没有检查点或其他导出类型不自动恢复、重提;ERP 端任务可能仍继续运行,先查看下载中心。客户端断线不会自动取消已接收的任务;停止执行进程可能中断本地处理。
|
|
63
99
|
|
|
64
|
-
|
|
100
|
+
`jobs.cancel` 可取消本实例中尚在排队的任务。现有业务 API 不支持可靠地取消运行中的 ERP 操作,工具会明确返回不支持。不同 MCP 进程可以共用任务目录;其他存活进程的未完成任务返回最后记录和不确定状态,不自动接管或重试。
|
|
65
101
|
|
|
66
|
-
|
|
102
|
+
## 保留登录会话并更新
|
|
67
103
|
|
|
68
|
-
|
|
104
|
+
会话只保存 ERP 地址、账号、token、用户 ID 和过期时间,不保存密码。默认位置为 macOS/Linux 的 `${XDG_CONFIG_HOME:-~/.config}/new-erp/session.json`,或 Windows 的 `%LOCALAPPDATA%\new-erp\session.json`;也可用 `NEW_ERP_SESSION_FILE` 指定位置。
|
|
69
105
|
|
|
70
106
|
```sh
|
|
71
|
-
new-erp
|
|
107
|
+
npm install -g new-erp-mcp@latest
|
|
108
|
+
new-erp setup --client codex
|
|
72
109
|
```
|
|
73
110
|
|
|
74
|
-
|
|
111
|
+
更新后重启客户端以加载新代码。会话文件在包目录之外,有效会话会保留,通常无需重新登录;失效后重新打开登录页。`auth.logout` 或 `new-erp logout` 删除本机会话,不撤销 ERP 上游 token;已开始的任务继续使用原账号快照,切换账号后不能读取原账号的任务。
|
|
75
112
|
|
|
76
|
-
##
|
|
113
|
+
## 打包验证
|
|
77
114
|
|
|
78
115
|
```sh
|
|
79
116
|
npm ci
|
|
@@ -82,6 +119,4 @@ npm run check:package
|
|
|
82
119
|
npm pack
|
|
83
120
|
```
|
|
84
121
|
|
|
85
|
-
`
|
|
86
|
-
|
|
87
|
-
`npm run check:package` 检查实际发布文件清单,阻止内部服务地址、个人路径、凭据和业务导出文件进入包。检查已生成的包可用 `node test/check-package-privacy.js ./new-erp-mcp-0.2.1.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
package/scripts/catalog-list.js
CHANGED
package/scripts/erp-options.js
CHANGED
|
@@ -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
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
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
|
-
|
|
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: '
|
|
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
|
}
|