@youzan-cloud/zan-saas-cli 0.1.0-beta.4 → 0.1.0-beta.7
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/bin/zan-saas-cli.js +8 -11
- package/internal/registry/manifest.json +9 -8
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/skills/zan-saas/SKILL.md +3 -3
- package/skills/zan-saas/references/host-entry.md +8 -0
- package/skills/zan-saas-org/SKILL.md +4 -0
- package/skills/zan-saas-shared/SKILL.md +16 -5
- package/skills/zan-saas-shared/references/authentication.md +9 -4
- package/skills/zan-saas-shared/references/shop-context.md +37 -0
- package/src/auth/credential-source.js +26 -0
- package/src/auth/credential-store.js +61 -5
- package/src/auth/session.js +80 -27
- package/src/cli/command-paths.js +3 -2
- package/src/cli/program.js +20 -4
- package/src/commands/native/access/commands.index.json +1 -1
- package/src/commands/native/customer/commands.index.json +1 -1
- package/src/commands/native/customer/customer-tag/list/command.js +2 -2
- package/src/commands/native/member/commands.index.json +1 -1
- package/src/commands/native/org/commands.index.json +4 -4
- package/src/commands/system/api.js +1 -1
- package/src/commands/system/auth.js +13 -2
- package/src/commands/system/contracts.js +27 -19
- package/src/commands/system/shop-contracts.js +66 -0
- package/src/commands/system/shop.js +128 -27
- package/src/identity/context-store.js +14 -10
- package/src/identity/file-store.js +147 -0
- package/src/identity/session-store.js +261 -0
- package/src/identity/shop-location.js +153 -0
- package/src/transport/api.js +78 -11
- package/src/transport/shop-app.js +8 -1
package/bin/zan-saas-cli.js
CHANGED
|
@@ -2,22 +2,19 @@
|
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
4
|
const { createProgram } = require('../src/cli/program');
|
|
5
|
-
const { runWithApiSession } = require('../src/transport/api');
|
|
6
5
|
const { finalizeError } = require('../src/execution/error-details');
|
|
7
6
|
const { visibleCommands } = require('../src/commands/system/schema');
|
|
8
7
|
const { commandSchema } = require('../src/execution/command-schema');
|
|
9
8
|
|
|
10
9
|
let program;
|
|
11
|
-
Promise.resolve().then(() =>
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
})
|
|
20
|
-
).catch(error => {
|
|
10
|
+
Promise.resolve().then(() => {
|
|
11
|
+
program = createProgram();
|
|
12
|
+
if (process.argv.length === 2) {
|
|
13
|
+
program.outputHelp();
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
return program.parseAsync(process.argv);
|
|
17
|
+
}).catch(error => {
|
|
21
18
|
if (['commander.helpDisplayed', 'commander.version'].includes(error.code)) return;
|
|
22
19
|
if (error.code === 'commander.help' && error.exitCode === 0) return;
|
|
23
20
|
const registered = program && visibleCommands(program)
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"indexFile": "access/commands.index.json",
|
|
11
11
|
"commandCount": 2,
|
|
12
12
|
"indexSize": 652,
|
|
13
|
-
"indexSha256": "
|
|
13
|
+
"indexSha256": "0b87b02e700ae06f5835f54599259a0d7c1739a7cb9f83f7db370010a6872bce"
|
|
14
14
|
},
|
|
15
15
|
{
|
|
16
16
|
"name": "aftersale",
|
|
@@ -50,7 +50,7 @@
|
|
|
50
50
|
"indexFile": "customer/commands.index.json",
|
|
51
51
|
"commandCount": 10,
|
|
52
52
|
"indexSize": 3042,
|
|
53
|
-
"indexSha256": "
|
|
53
|
+
"indexSha256": "3aa80cf1681c76e7d720f7f70c411850b53d8d312b5468124caa7cfa1a987652"
|
|
54
54
|
},
|
|
55
55
|
{
|
|
56
56
|
"name": "fulfillment",
|
|
@@ -90,7 +90,7 @@
|
|
|
90
90
|
"indexFile": "member/commands.index.json",
|
|
91
91
|
"commandCount": 6,
|
|
92
92
|
"indexSize": 1811,
|
|
93
|
-
"indexSha256": "
|
|
93
|
+
"indexSha256": "3f122369a09e91ac0d1ab8981bffef4d7a05ce99b8f4b0ae6cbd5b7588a9679c"
|
|
94
94
|
},
|
|
95
95
|
{
|
|
96
96
|
"name": "order",
|
|
@@ -106,11 +106,11 @@
|
|
|
106
106
|
"name": "org",
|
|
107
107
|
"description": "组织:组织资料、员工与角色关联",
|
|
108
108
|
"skill": "zan-saas-org",
|
|
109
|
-
"fingerprint": "
|
|
109
|
+
"fingerprint": "a37293793a4944a5afa4ea2cf85168852f93b876e8e57d7f7b8d7b2a49e75ce0",
|
|
110
110
|
"indexFile": "org/commands.index.json",
|
|
111
111
|
"commandCount": 4,
|
|
112
112
|
"indexSize": 1185,
|
|
113
|
-
"indexSha256": "
|
|
113
|
+
"indexSha256": "88abc0f5b6605a408ef88dd969f6ed2bb4473b7fa36001b2036c40d68e8a5173"
|
|
114
114
|
},
|
|
115
115
|
{
|
|
116
116
|
"name": "points",
|
|
@@ -165,7 +165,7 @@
|
|
|
165
165
|
"SKILL.md",
|
|
166
166
|
"references/host-entry.md"
|
|
167
167
|
],
|
|
168
|
-
"fingerprint": "
|
|
168
|
+
"fingerprint": "5b40c6bec46af43e31ea6406475c59afae705e9cc247640deafc4626b6c599b1"
|
|
169
169
|
},
|
|
170
170
|
{
|
|
171
171
|
"name": "zan-saas-shared",
|
|
@@ -175,9 +175,10 @@
|
|
|
175
175
|
"files": [
|
|
176
176
|
"SKILL.md",
|
|
177
177
|
"references/authentication.md",
|
|
178
|
+
"references/shop-context.md",
|
|
178
179
|
"references/value-processing.md"
|
|
179
180
|
],
|
|
180
|
-
"fingerprint": "
|
|
181
|
+
"fingerprint": "05045a2660b7801297f0883650aabc240fda7b6115b7cbe6a0edbdf43b1e2676"
|
|
181
182
|
},
|
|
182
183
|
{
|
|
183
184
|
"name": "zan-saas-access",
|
|
@@ -359,7 +360,7 @@
|
|
|
359
360
|
"references/unit-get.md",
|
|
360
361
|
"references/unit-list.md"
|
|
361
362
|
],
|
|
362
|
-
"fingerprint": "
|
|
363
|
+
"fingerprint": "2732c7e06c009a15f5b00fb028e72ceb518e1f5b1618c58523310962e14e1d4e"
|
|
363
364
|
},
|
|
364
365
|
{
|
|
365
366
|
"name": "zan-saas-points",
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@youzan-cloud/zan-saas-cli",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.7",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@youzan-cloud/zan-saas-cli",
|
|
9
|
-
"version": "0.1.0-beta.
|
|
9
|
+
"version": "0.1.0-beta.7",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"ajv": "8.20.0",
|
package/package.json
CHANGED
package/skills/zan-saas/SKILL.md
CHANGED
|
@@ -11,13 +11,13 @@ Agent 负责运行命令、定位对象和解释结果。用户只需完成浏
|
|
|
11
11
|
|
|
12
12
|
需要登录时,Agent 运行 `zan-saas auth login` 并把输出的授权地址交给用户;用户在浏览器确认授权,Agent 等待命令成功,再运行 `zan-saas auth status`。浏览器显示授权完成但命令失败时,按错误核查并停止,不将已有凭证状态当作本次登录成功。用户取消或授权超时后,需要继续时重新运行登录。
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
先读取 `zan-saas skills read zan-saas-shared`,按公共规则确定会话位置及本次任务范围。商家所说的“店铺”可能指账号下的单店或连锁,也可能指连锁内部的店铺;明确目标后区分持久切换和本次临时查询,再选择命令。
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
宿主提供稳定会话标识时,新会话首次业务使用前初始化,恢复时使用原标识及恢复模式。需要初始化、恢复、临时查询或处理覆盖时,读取 `zan-saas skills read zan-saas-shared references/shop-context.md`。已就绪且用户没有要求切换时沿用,不重复询问。
|
|
17
17
|
|
|
18
18
|
店铺列表的 `total` 是员工停用过滤前的匹配总数,未知时为 `null`。`nextPageNo` 非空时,保留名称、业务线、容量及 `--all` 等选项,用 `--page-no` 继续;`hasMore=null` 表示是否还有后续未知。不要因本页候选少或为空就认定查完;`windowLimitReached=true` 时停止翻页,缩小筛选,不宣称已查全。
|
|
19
19
|
|
|
20
|
-
选店后用 `zan-saas shop current`
|
|
20
|
+
选店后用 `zan-saas shop current` 核对实际位置和来源。宿主未提供稳定会话标识时使用本机共享选择,不能声称会话隔离。切店无需重新登录,浏览器中的店铺选择不改变 CLI 上下文。账号路由或 OAuth 地址错误按共享指引的[认证排查](../zan-saas-shared/references/authentication.md)处理。
|
|
21
21
|
|
|
22
22
|
## 选择当前能力
|
|
23
23
|
|
|
@@ -23,3 +23,11 @@ description: 有赞 SaaS 产品的命令行工具入口。用户需要查询有
|
|
|
23
23
|
宿主侧只保存这个轻量入口,不复制整份领域知识。CLI 升级后重新读取同版指引;宿主不支持按需读取时,需要显式安装匹配版本的静态副本。
|
|
24
24
|
|
|
25
25
|
重复安装只更新本项目管理且未被手工修改的入口;同名其他 Skill 保留并报错。安装不执行登录、选店或业务请求。完成后在宿主技能列表确认 `zan-saas` 已启用,再打开新会话。
|
|
26
|
+
|
|
27
|
+
## 会话选店接入
|
|
28
|
+
|
|
29
|
+
安装成功只表示 CLI 和入口文件已就绪。会话隔离还要求宿主从可信元数据取得稳定标识及新建/恢复状态,每次调用注入 `ZAN_SAAS_SESSION_ID`。恢复复用标识,新建生成新标识;已接入的启动器漏传标识时应拒绝业务调用。
|
|
30
|
+
|
|
31
|
+
新会话先初始化,恢复使用 `shop session init --resume`。业务任务固定 current 或 resolve 返回的四字段 context,并在每次调用显式注入;任务结束丢弃。位置管理命令使用会话环境,不携带旧任务覆盖。具体步骤读取 `zan-saas skills read zan-saas-shared references/shop-context.md`。
|
|
32
|
+
|
|
33
|
+
宿主未具备或尚未验证这些能力时,说明使用本机共享模式,不把入口 Skill 的安装或模型记住店铺当作会话隔离。
|
|
@@ -17,6 +17,10 @@ description: 查询当前组织资料、总部下级组织、微商城单店员
|
|
|
17
17
|
|
|
18
18
|
组织角色描述组织在体系中的身份,员工角色关联描述授角事实,角色定义目录描述有哪些角色,员工权限点描述本次可读权限。四者分别判断,角色名称不能推算权限或可授予性;业务线也不证明店铺类型。
|
|
19
19
|
|
|
20
|
+
商家在连锁内说的“店铺”通常指组织单元,先核对所属连锁和组织角色。账号层店铺入口由公共 `shop list` 发现,本域读取组织事实;当前位置由公共选店机制管理。需要内部切换、临时查询其他组织,或在分店浏览同连锁下级候选时,读取 `zan-saas skills read zan-saas-shared references/shop-context.md`。
|
|
21
|
+
|
|
22
|
+
组织详情仍固定当前任务上下文;不能把下级 ID 直接代入原上下文的详情参数。准备其他组织的任务上下文后,所选叶子的产品、位置和权限限制仍适用。能够切到分店不扩大微商城单店员工入口的支持范围。
|
|
23
|
+
|
|
20
24
|
## 按目标选择命令
|
|
21
25
|
|
|
22
26
|
| 用户目标 | 命令入口与读取条件 |
|
|
@@ -15,15 +15,26 @@ description: zan-saas 跨领域公共使用规则。首次调用或 CLI 版本
|
|
|
15
15
|
|
|
16
16
|
需要登录时使用 `zan-saas auth login`,本地查看已存凭证状态使用 `zan-saas auth status`;用户要求退出时使用 `zan-saas auth logout`。先读取相应 help/schema,登录需要用户在浏览器中完成授权,退出尝试撤销当前授权服务下的个人会话并清理本机凭证。
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
认证统一由公共入口与传输层处理,领域命令不选择凭证或决定登录、续期策略。普通使用通过 `zan-saas auth login` 保存个人登录,后续默认优先使用该个人凭证并按需续期;环境中存在 `ZAN_SAAS_USER_ACCESS_TOKEN` 不会覆盖已保存的个人登录。没有个人登录记录时才使用环境 token;受控调用明确要求环境身份时配置 `ZAN_SAAS_AUTH_SOURCE=env`。个人登录失效或续期被拒绝时重新登录,不自动切换环境身份。同一次命令从首个实际请求开始固定调用环境、凭证及来源;参数拒绝、发现及预览不会触发认证。
|
|
19
19
|
|
|
20
20
|
缺少凭证时,交互终端可进入公共登录流程,非交互环境返回登录指引。不索取聊天中的 token,也不读取旧 `youzan-cli` 配置。`auth status` 只检查本地保存状态,不证明环境覆盖凭证有效、业务权限已获准或目标店铺匹配。登录和续期不能作为业务请求失败后的自动重放理由。
|
|
21
21
|
|
|
22
22
|
需要配置、自定义授权环境、判断续期、退出结果、请求计数,或登录与选店收到 OAuth HTTP 404、账号网关 `4003` 时,读取 [公共认证与会话](references/authentication.md)。业务命令声明的请求预算计算业务 API,不包含公共认证前置;上下文与服务端业务授权仍须分别满足。
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
商家说的“店铺”可能指账号下的单店或连锁,也可能指连锁内部的分店、网店。按本次明确限定、已确认任务范围、会话位置和真实候选依次核实;历史只作线索。账号层入口由 `shop list` 发现,连锁下级候选按组织领域指引发现。
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
| 用户意图 | 操作 |
|
|
27
|
+
| --- | --- |
|
|
28
|
+
| 切到另一家单店或连锁,之后默认使用它 | `shop use`,进入根组织 |
|
|
29
|
+
| 切到当前连锁内部的店铺,之后默认使用它 | `shop unit use`,保留当前连锁根 |
|
|
30
|
+
| 本次查询另一家店铺 | `shop resolve` 后固定本次任务上下文 |
|
|
31
|
+
| 继续当前范围且未要求切换 | 沿用已确认的位置 |
|
|
32
|
+
|
|
33
|
+
新会话首次初始化继承一次最近位置,已有会话独立保持。明确切换默认更新本会话及供未来会话继承的位置;只影响本会话时按 `--session-only` 契约处理。查询和恢复不更新最近位置。需要初始化、恢复、临时执行、组织消歧或覆盖处理时,读取 `zan-saas skills read zan-saas-shared references/shop-context.md`。
|
|
34
|
+
|
|
35
|
+
多次 CLI 调用属于同一任务时,开始时固定四字段上下文,逐次显式传入。对象引用保留环境、所属租户及领域需要的组织范围;改变范围后按领域契约核实。当前范围查无结果或请求失败时,结论只覆盖实际查询范围;历史同名对象不能替换本次对象或触发自动跨店补查。首次继承、切换、临时查询或旧请求返回可能造成混淆时,使用“某某连锁 / 杭州店”说明实际范围。
|
|
36
|
+
|
|
37
|
+
业务调用的用户上下文由入口注入,或通过 `ZAN_SAAS_CONTEXT` 提供仅含 `userId`、`hq`、`shopId`、`orgType` 的 JSON;否则取已初始化会话位置,无会话标识时取本机共享选择。`shop current` 查看实际来源。`hq` 表示租户根,单店为自身;`shopId` 表示当前组织;`orgType` 只取 `hq/shop`,后者为受支持的非总部位置,不能据此认定单店或分店角色。凭证不放进该 JSON,组织关系与权限仍由服务端核验。
|
|
27
38
|
|
|
28
39
|
业务命令的 `at` 声明为 `hq`、`shop` 或 `all`。有有效上下文时,根与领域帮助按位置展示适用命令;无有效上下文时全量展示并提示未筛选。单条帮助始终可查,schema 始终保留完整契约。命令未展示时不自动切店或改用 Raw API;真实执行仍检查位置。
|
|
29
40
|
|
|
@@ -76,9 +87,9 @@ description: zan-saas 跨领域公共使用规则。首次调用或 CLI 版本
|
|
|
76
87
|
|
|
77
88
|
`auth login` 会输出浏览器授权地址并等待回调。Agent 把地址交给用户,用户确认后等待命令成功,再执行 `zan-saas auth status`。浏览器授权完成只代表取得授权码;命令失败时按认证错误核查并停止,已有凭证状态不能证明本次登录完成。用户取消或五分钟超时后,需要继续时重新执行登录。
|
|
78
89
|
|
|
79
|
-
|
|
90
|
+
登录成功后按宿主生命周期初始化或恢复,再用 `shop current` 展示当前位置。无位置且用户没有指定目标时,请用户按店名或本次候选序号选择,只有一个候选也简短确认;明确名字唯一且范围无歧义时继续。ID 由 Agent 映射,不默认第一家,不把一页唯一候选视为全账号唯一。选定对象及动作后按上表执行,成功后读取实际位置。
|
|
80
91
|
|
|
81
|
-
切店无需重新登录,浏览器内切店不会改变 CLI
|
|
92
|
+
切店无需重新登录,浏览器内切店不会改变 CLI 上下文。会话模式的显式覆盖冲突会拒绝切换;共享模式 `overriddenByEnv=true` 仍表示只保存、未实际生效。说明实际来源,按受控配置处理后核对,不声称切换成功。
|
|
82
93
|
|
|
83
94
|
列表的 `returned` 只表示本页条数,不能当作匹配总数;`pagination.total` 表示匹配查询条件的总记录数,仅在有可靠来源时提供;省略表示未知,不能用本页条数补值。提供 `nextPageNo` 和 `windowLimitReached` 的命令中,下一页码非 null 才有已确认且合法的下一页;达到窗口上限不等于数据已读完。保留原筛选和页容量,不能从其他命令类推分页上限。
|
|
84
95
|
|
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
|
|
5
5
|
## 配置与优先级
|
|
6
6
|
|
|
7
|
-
普通使用先执行 `zan-saas auth login
|
|
7
|
+
普通使用先执行 `zan-saas auth login`。默认使用已保存的个人登录并按需续期,环境 token 不会覆盖它。仅没有个人登录记录时才使用 `ZAN_SAAS_USER_ACCESS_TOKEN`。个人登录已失效或续期被拒绝时返回重新登录指引,保留需重新登录状态,后续调用也不会悄悄换成环境身份。
|
|
8
|
+
|
|
9
|
+
`ZAN_SAAS_AUTH_SOURCE` 可取 `auto`、`personal` 或 `env`。默认 auto 采用上述优先级;personal 只使用个人登录;env 是受控调用的明确选择,只使用环境 token,缺少该 token 时失败。非法配置失败,不回退其他身份。请求发出后不会因401、权限或业务错误更换凭证重放。
|
|
10
|
+
|
|
11
|
+
默认生产 API 网关为 `https://open.youzanyun.com/api`,`ZAN_SAAS_API_BASE_URL` 可以覆盖。没有显式网关配置不等于缺少认证。
|
|
8
12
|
|
|
9
13
|
`ZAN_SAAS_CONFIG_DIR` 可以指定凭证目录;默认是 `~/.zan-saas`。`ZAN_SAAS_OAUTH_BASE_URL` 可以指定授权服务,个人凭证按授权服务 origin 分开保存。配置目录和授权服务必须与登录时一致。
|
|
10
14
|
|
|
11
|
-
业务命令的目标店铺可由 `--kdt-id` 显式指定;省略时取本次有效上下文的 shopId
|
|
15
|
+
业务命令的目标店铺可由 `--kdt-id` 显式指定;省略时取本次有效上下文的 shopId,两者都没有则失败。显式值非法时不回退。目标解析不证明权限,原有同店、总部及服务端授权校验仍适用。会话位置、组织切换和临时目标执行按 [会话选店](shop-context.md) 处理。
|
|
12
16
|
|
|
13
17
|
业务请求的店铺头优先取 `ZAN_SAAS_KDT_ID`,否则取环境或 `shop use` 保存的有效上下文 shopId;公共同店查询会绑定本次明确目标。店铺头、本地上下文与实际服务端授权不是同一件事,查询仍须通过各领域的目标范围检查。
|
|
14
18
|
|
|
@@ -16,7 +20,7 @@
|
|
|
16
20
|
|
|
17
21
|
运行 `zan-saas auth login` 完成浏览器授权。`--no-browser` 只跳过自动打开浏览器,仍打印地址并等待手动授权回调,再交换与保存凭证;默认超时五分钟。
|
|
18
22
|
|
|
19
|
-
|
|
23
|
+
本命令成功退出后才接续会话初始化或恢复,再用 `shop current` 核对实际位置;缺目标时按公共选店规则补齐。切店复用当前登录凭证,浏览器切店不会改变 CLI 上下文;显式覆盖以实际来源为准。
|
|
20
24
|
|
|
21
25
|
首次实际 API 请求才解析认证,并为本次命令固定调用环境与已取得凭证。help、schema、参数校验拒绝和 `--dry-run` 不读取凭证文件、不触发登录或续期、不发送业务请求。
|
|
22
26
|
|
|
@@ -38,7 +42,7 @@
|
|
|
38
42
|
|
|
39
43
|
## 状态与退出
|
|
40
44
|
|
|
41
|
-
`zan-saas auth status`
|
|
45
|
+
`zan-saas auth status` 离线查看当前授权服务下的个人登录和凭证选择。loggedIn 表示本地个人记录未要求重新登录且已知刷新有效期未过期;credentialSource 分别以 login/env/missing 表示按配置选择个人、环境或没有凭证。environmentTokenIgnored 为 true 表示个人登录优先,忽略了同时存在的环境 token。状态不联网,不证明实际凭证或服务端权限有效。
|
|
42
46
|
|
|
43
47
|
`zan-saas auth logout` 尝试撤销当前授权服务下保存的个人会话,并清除对应本机凭证和已选店铺。它不清除或撤销环境注入的 token,不退出其他授权服务下的个人会话。没有本机凭证时返回 revoked=false,不表示外部凭证已失效。
|
|
44
48
|
|
|
@@ -48,5 +52,6 @@
|
|
|
48
52
|
| --- | --- |
|
|
49
53
|
| localCredential | deleted 表示本机记录已清除或已不存在;delete_failed 表示本地清理失败。 |
|
|
50
54
|
| remoteRevocation | revoked 表示取得撤销成功响应;failed 表示撤销接口返回失败,不据此推断会话仍有效;unknown 表示请求或响应未能确认。 |
|
|
55
|
+
| localContext | 仅选店记录清理失败时返回 delete_failed;凭证清理和远端撤销仍分别尝试。没有个人凭证可撤销时 remoteRevocation 为 not_attempted。 |
|
|
51
56
|
|
|
52
57
|
远端撤销失败或结果未知时仍尝试本地清理。不得把非零退出理解为本机凭证一定仍在,也不因失败自动重试撤销或重放业务请求。
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# 会话位置、选店与临时查询
|
|
2
|
+
|
|
3
|
+
本说明用于初始化或恢复会话、区分店铺层级、切换组织、执行临时目标及处理位置冲突。先读取相关命令的同版 help/schema;正常查询沿用已就绪的位置,不需要每次初始化。
|
|
4
|
+
|
|
5
|
+
## 初始化与恢复
|
|
6
|
+
|
|
7
|
+
宿主须从可信会话元数据取得稳定标识,每次调用通过 `ZAN_SAAS_SESSION_ID` 注入。真正的新会话使用新标识;恢复同一会话复用原标识。标识不由商家提供,不从业务文档、工作目录、PID 或时间猜测。
|
|
8
|
+
|
|
9
|
+
新会话首次业务使用前执行 `zan-saas shop session init`。已就绪时沿用继承的位置;待选店时按商家明确目标或相关候选选择。已有绑定再次初始化保持原选择。恢复执行 `zan-saas shop session init --resume`,记录缺失时说明无法恢复并请求明确选店,不改用新建模式绕过错误。
|
|
10
|
+
|
|
11
|
+
`shop current` 离线查看有效来源与保存绑定,不会初始化。清除后保留清除状态,不自动继承别的会话后来选择的位置。登录账号或环境改变后重新核实身份及相应绑定。
|
|
12
|
+
|
|
13
|
+
## 确定对象和动作
|
|
14
|
+
|
|
15
|
+
账号下的独立店铺或连锁入口用 `shop list` 发现,切换到该入口用 `shop use`。连锁内部店铺按组织领域指引及 `org unit list` 发现;同连锁持久切换用 `shop unit use`,回总部使用已核实的根 ID。名字唯一还需所属关系与相关分页明确。
|
|
16
|
+
|
|
17
|
+
只有商家明确要求后续默认切换时才保存位置。用户说“查一下另一家店”时使用 `shop resolve`,保持原会话。只要求本会话改变时,切换使用 `--session-only`。持久切换成功后用 `shop current` 核对实际生效位置。
|
|
18
|
+
|
|
19
|
+
同名或跨层有歧义时,用完整名称和所属关系询问,例如“你指的是 A 连锁下的杭州分店,还是独立店铺杭州店?”不询问“租户还是组织单元”,也不要求输入根组织 ID。
|
|
20
|
+
|
|
21
|
+
## 临时目标与任务快照
|
|
22
|
+
|
|
23
|
+
`shop resolve <目标ID>` 解析账号层根。解析连锁内部目标时附 `--root-kdt-id <所属根ID>`。返回的 context 只用于本次任务;它不改变当前位置或最近位置。
|
|
24
|
+
|
|
25
|
+
宿主把返回的四字段 context 作为每次业务调用的 `ZAN_SAAS_CONTEXT` 或程序注入值,并持续传原会话 ID。多命令任务每一步使用同一快照,不重新读取最近位置。任务结束后丢弃,无需切回原店。快照不包含 token,实际权限仍由服务端核验。
|
|
26
|
+
|
|
27
|
+
当前在分店而要浏览同连锁候选时,先 resolve 已核实的根,使用临时总部上下文执行 `org unit list`,按页读取;当前会话仍为原分店。每条业务命令仍须满足自己的位置、产品和同店限制,不能直接用目标参数绕过。
|
|
28
|
+
|
|
29
|
+
位置管理命令使用会话调用环境,不携带上次任务专用 context。用户受控配置的覆盖不能为使切换成功而擅自删除。
|
|
30
|
+
|
|
31
|
+
切换期间已经发出的请求按原目标解释。迟到结果标明原店铺;新范围的后续步骤重新核验对象。查无结果、权限拒绝或未知结果不能用历史其他店铺的数据补答案。
|
|
32
|
+
|
|
33
|
+
## 覆盖、冲突及共享模式
|
|
34
|
+
|
|
35
|
+
实际来源以 `shop current` 为准。显式上下文无效不回退;会话模式显式上下文或请求头与切换目标冲突时拒绝保存。并发版本冲突先读取当前位置,再根据仍有效的明确意图处理,不自动重放业务操作。
|
|
36
|
+
|
|
37
|
+
没有宿主标识时使用本机共享选店,其他会话可能影响下一次调用,不能声称会话隔离。临时查询仍可 resolve 后显式传入任务上下文。内部持久切换需要会话模式;安装入口文件不会自动提供稳定会话标识和逐次注入能力。
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createHash } = require('node:crypto');
|
|
4
|
+
const { AuthConfigurationError } = require('./config');
|
|
5
|
+
|
|
6
|
+
function authSource(env) {
|
|
7
|
+
const source = env.ZAN_SAAS_AUTH_SOURCE ?? 'auto';
|
|
8
|
+
if (!['auto', 'personal', 'env'].includes(source)) {
|
|
9
|
+
throw new AuthConfigurationError('ZAN_SAAS_AUTH_SOURCE 只接受 auto、personal 或 env。');
|
|
10
|
+
}
|
|
11
|
+
return source;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function credentialChoice(env, credential) {
|
|
15
|
+
const mode = authSource(env);
|
|
16
|
+
if (mode !== 'env' && credential) {
|
|
17
|
+
return { source: 'login', credentialRef: `login:${credential.generation || credential.loginAt || 'legacy'}` };
|
|
18
|
+
}
|
|
19
|
+
if (mode !== 'personal' && env.ZAN_SAAS_USER_ACCESS_TOKEN) {
|
|
20
|
+
const hash = createHash('sha256').update(env.ZAN_SAAS_USER_ACCESS_TOKEN).digest('hex');
|
|
21
|
+
return { source: 'env', credentialRef: `env:${hash}` };
|
|
22
|
+
}
|
|
23
|
+
return { source: 'missing', credentialRef: null };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { authSource, credentialChoice };
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
const fs = require('node:fs');
|
|
4
4
|
const path = require('node:path');
|
|
5
5
|
const crypto = require('node:crypto');
|
|
6
|
-
const { configDir } = require('./config');
|
|
6
|
+
const { configDir, AuthConfigurationError } = require('./config');
|
|
7
|
+
const { credentialChoice } = require('./credential-source');
|
|
7
8
|
|
|
8
9
|
const fileName = 'credentials.json';
|
|
9
10
|
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -53,16 +54,70 @@ const readAll = env => readStore(fileName, env);
|
|
|
53
54
|
const writeAll = (data, env) => writeStore(fileName, data, env);
|
|
54
55
|
|
|
55
56
|
// Credentials are keyed by oauth origin so logins to different environments do not overwrite each other.
|
|
57
|
+
function credentialDocument(env) {
|
|
58
|
+
let data;
|
|
59
|
+
let descriptor;
|
|
60
|
+
try {
|
|
61
|
+
descriptor = fs.openSync(credentialPath(env), 'r');
|
|
62
|
+
const stat = fs.fstatSync(descriptor);
|
|
63
|
+
data = JSON.parse(fs.readFileSync(descriptor, 'utf8'));
|
|
64
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error();
|
|
65
|
+
return { data, metadata: { mtimeMs: stat.mtimeMs, size: stat.size, ino: stat.ino, dev: stat.dev } };
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (error.code === 'ENOENT') return { data: {}, metadata: null };
|
|
68
|
+
throw new AuthConfigurationError('个人凭证文件损坏或不可读,请重新登录;未改用环境身份。');
|
|
69
|
+
} finally {
|
|
70
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
56
74
|
function readCredential(server, env = process.env) {
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
75
|
+
const { data } = credentialDocument(env);
|
|
76
|
+
if (!Object.hasOwn(data, server)) return null;
|
|
77
|
+
const entry = data[server];
|
|
78
|
+
if (!entry || typeof entry.accessToken !== 'string' || !entry.accessToken ||
|
|
79
|
+
typeof entry.refreshToken !== 'string' || !entry.refreshToken) {
|
|
80
|
+
throw new AuthConfigurationError('个人凭证记录不完整,请重新登录;未改用环境身份。');
|
|
81
|
+
}
|
|
82
|
+
return entry;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function syncCredentialPresence(env = process.env) {
|
|
86
|
+
const filename = 'credential-state.json';
|
|
87
|
+
const { data, metadata } = credentialDocument(env);
|
|
88
|
+
const records = Object.fromEntries(Object.entries(data).map(([server, credential]) => [server, {
|
|
89
|
+
present: true,
|
|
90
|
+
credentialRef: credential && typeof credential.accessToken === 'string' &&
|
|
91
|
+
typeof credential.refreshToken === 'string'
|
|
92
|
+
? credentialChoice({ ZAN_SAAS_AUTH_SOURCE: 'personal' }, credential).credentialRef : 'login:pending'
|
|
93
|
+
}]));
|
|
94
|
+
const next = { schemaVersion: 1, metadata, records };
|
|
95
|
+
if (JSON.stringify(readStore(filename, env)) === JSON.stringify(next)) return;
|
|
96
|
+
writeStore(filename, next, env);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Offline context selection reads only this non-secret index and file metadata.
|
|
100
|
+
function credentialPresence(server, env = process.env) {
|
|
101
|
+
const stat = fs.existsSync(credentialPath(env)) ? fs.statSync(credentialPath(env)) : null;
|
|
102
|
+
if (!stat) return { present: false, credentialRef: null };
|
|
103
|
+
const index = readStore('credential-state.json', env);
|
|
104
|
+
const metadata = index.metadata;
|
|
105
|
+
if (index.schemaVersion === 1 && metadata && metadata.mtimeMs === stat.mtimeMs &&
|
|
106
|
+
metadata.size === stat.size && metadata.ino === stat.ino && metadata.dev === stat.dev &&
|
|
107
|
+
index.records && typeof index.records === 'object' && !Array.isArray(index.records)) {
|
|
108
|
+
if (!Object.hasOwn(index.records, server)) return { present: false, credentialRef: null };
|
|
109
|
+
const entry = index.records[server];
|
|
110
|
+
if (entry?.present === true && typeof entry.credentialRef === 'string' &&
|
|
111
|
+
entry.credentialRef.startsWith('login:')) return entry;
|
|
112
|
+
}
|
|
113
|
+
return { present: true, credentialRef: 'login:pending' };
|
|
60
114
|
}
|
|
61
115
|
|
|
62
116
|
function saveCredential(server, credential, env = process.env) {
|
|
63
117
|
const data = readAll(env);
|
|
64
118
|
data[server] = credential;
|
|
65
119
|
writeAll(data, env);
|
|
120
|
+
syncCredentialPresence(env);
|
|
66
121
|
}
|
|
67
122
|
|
|
68
123
|
function deleteCredential(server, env = process.env) {
|
|
@@ -70,6 +125,7 @@ function deleteCredential(server, env = process.env) {
|
|
|
70
125
|
if (!Object.hasOwn(data, server)) return false;
|
|
71
126
|
delete data[server];
|
|
72
127
|
writeAll(data, env);
|
|
128
|
+
syncCredentialPresence(env);
|
|
73
129
|
return true;
|
|
74
130
|
}
|
|
75
131
|
|
|
@@ -105,5 +161,5 @@ async function withFileLock(env, task, { staleMs = 30000, waitMs = 15000 } = {})
|
|
|
105
161
|
|
|
106
162
|
module.exports = {
|
|
107
163
|
credentialPath, deleteCredential, readCredential, readStore, saveCredential, storePath, withFileLock,
|
|
108
|
-
writeStore
|
|
164
|
+
writeStore, credentialPresence, syncCredentialPresence
|
|
109
165
|
};
|
package/src/auth/session.js
CHANGED
|
@@ -8,6 +8,8 @@ const { openBrowser } = require('./browser');
|
|
|
8
8
|
const { OAuthError, exchangeCode, refreshTokens, revokeToken } = require('./oauth-client');
|
|
9
9
|
const store = require('./credential-store');
|
|
10
10
|
const { deleteContext } = require('../identity/context-store');
|
|
11
|
+
const { randomUUID } = require('node:crypto');
|
|
12
|
+
const { authSource, credentialChoice } = require('./credential-source');
|
|
11
13
|
|
|
12
14
|
const dayMs = 24 * 60 * 60 * 1000;
|
|
13
15
|
|
|
@@ -29,7 +31,8 @@ function summary(server, credential, now = Date.now()) {
|
|
|
29
31
|
if (!credential) return { loggedIn: false, server };
|
|
30
32
|
const iso = value => (Number.isFinite(value) ? new Date(value).toISOString() : null);
|
|
31
33
|
return {
|
|
32
|
-
loggedIn:
|
|
34
|
+
loggedIn: !credential.reauthRequired &&
|
|
35
|
+
(Number.isFinite(credential.refreshExpiresAt) ? credential.refreshExpiresAt > now : true),
|
|
33
36
|
server,
|
|
34
37
|
clientId: credential.clientId,
|
|
35
38
|
tool: credential.tool,
|
|
@@ -43,6 +46,7 @@ async function login({
|
|
|
43
46
|
env = process.env, browser = true, port = defaultPort, log = message => process.stderr.write(`${message}\n`),
|
|
44
47
|
open = openBrowser, timeoutMs
|
|
45
48
|
} = {}) {
|
|
49
|
+
authSource(env);
|
|
46
50
|
const config = oauthConfig(env);
|
|
47
51
|
const pkce = createPkce();
|
|
48
52
|
const tool = detectTool(env);
|
|
@@ -72,38 +76,66 @@ async function login({
|
|
|
72
76
|
throw error;
|
|
73
77
|
}
|
|
74
78
|
const credential = {
|
|
75
|
-
...tokens, clientId: config.clientId, oauthBaseUrl: config.oauthBaseUrl, tool, loginAt: Date.now()
|
|
79
|
+
...tokens, clientId: config.clientId, oauthBaseUrl: config.oauthBaseUrl, tool, loginAt: Date.now(),
|
|
80
|
+
generation: randomUUID()
|
|
76
81
|
};
|
|
77
|
-
store.saveCredential(config.server, credential, env);
|
|
78
|
-
|
|
82
|
+
await store.withFileLock(env, async () => store.saveCredential(config.server, credential, env));
|
|
83
|
+
require('../identity/session-store').resetIdentity(env, {
|
|
84
|
+
pendingCredentialRef: credentialChoice({ ...env, ZAN_SAAS_AUTH_SOURCE: 'personal' }, credential).credentialRef
|
|
85
|
+
});
|
|
86
|
+
return status({ env });
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
async function logout({ env = process.env } = {}) {
|
|
82
90
|
const config = oauthConfig(env);
|
|
83
91
|
const credential = store.readCredential(config.server, env);
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
let localContext = 'deleted';
|
|
93
|
+
for (const clear of [
|
|
94
|
+
() => deleteContext(env),
|
|
95
|
+
() => require('../identity/session-store').resetIdentity(env, { logout: true })
|
|
96
|
+
]) {
|
|
97
|
+
try { clear(); } catch { localContext = 'delete_failed'; }
|
|
98
|
+
}
|
|
99
|
+
if (!credential && localContext === 'deleted') return { loggedOut: true, revoked: false, server: config.server };
|
|
100
|
+
let remoteRevocation = credential ? 'revoked' : 'not_attempted';
|
|
88
101
|
let httpStatus;
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
102
|
+
if (credential) {
|
|
103
|
+
try {
|
|
104
|
+
await revokeToken(config, credential.refreshToken);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
httpStatus = error instanceof OAuthError ? error.httpStatus : undefined;
|
|
107
|
+
remoteRevocation = httpStatus === undefined ? 'unknown' : 'failed';
|
|
108
|
+
}
|
|
94
109
|
}
|
|
95
110
|
let localCredential = 'deleted';
|
|
111
|
+
let loginChanged = false;
|
|
96
112
|
try {
|
|
97
|
-
store.
|
|
113
|
+
await store.withFileLock(env, async () => {
|
|
114
|
+
const current = store.readCredential(config.server, env);
|
|
115
|
+
const sameLogin = !current || (credential?.generation
|
|
116
|
+
? current.generation === credential.generation
|
|
117
|
+
: current.loginAt === credential?.loginAt && current.refreshToken === credential?.refreshToken);
|
|
118
|
+
if (!sameLogin) {
|
|
119
|
+
loginChanged = true;
|
|
120
|
+
throw new Error('The current login changed during logout');
|
|
121
|
+
}
|
|
122
|
+
store.deleteCredential(config.server, env);
|
|
123
|
+
});
|
|
98
124
|
} catch {
|
|
99
125
|
localCredential = 'delete_failed';
|
|
100
126
|
}
|
|
101
|
-
if (
|
|
102
|
-
|
|
127
|
+
if (!['revoked', 'not_attempted'].includes(remoteRevocation) ||
|
|
128
|
+
localCredential !== 'deleted' || localContext !== 'deleted') {
|
|
129
|
+
const error = new Error(loginChanged
|
|
130
|
+
? '退出期间已产生新登录,已保留新凭证;旧凭证的服务端撤销结果请按退出信息核对。'
|
|
131
|
+
: localContext !== 'deleted'
|
|
132
|
+
? '选店记录清理未完成;本机凭证清理与服务端撤销已分别尝试,请按退出结果核对。'
|
|
133
|
+
: localCredential === 'deleted'
|
|
103
134
|
? '本机个人凭证已清除,服务端会话撤销未确认;请核对授权服务状态。'
|
|
104
135
|
: '本机个人凭证清理失败;请按退出结果核对本地凭证与服务端会话。');
|
|
105
136
|
error.cliDetails = {
|
|
106
137
|
command: 'auth.logout', server: config.server, localCredential, remoteRevocation,
|
|
138
|
+
...(localContext === 'deleted' ? {} : { localContext }),
|
|
107
139
|
...(httpStatus === undefined ? {} : { httpStatus })
|
|
108
140
|
};
|
|
109
141
|
throw error;
|
|
@@ -113,7 +145,10 @@ async function logout({ env = process.env } = {}) {
|
|
|
113
145
|
|
|
114
146
|
function status({ env = process.env } = {}) {
|
|
115
147
|
const { server } = oauthConfig(env);
|
|
116
|
-
|
|
148
|
+
const credential = store.readCredential(server, env);
|
|
149
|
+
const choice = credentialChoice(env, credential);
|
|
150
|
+
return { ...summary(server, credential), credentialSource: choice.source,
|
|
151
|
+
environmentTokenIgnored: choice.source === 'login' && Boolean(env.ZAN_SAAS_USER_ACCESS_TOKEN) };
|
|
117
152
|
}
|
|
118
153
|
|
|
119
154
|
function interactive(env) {
|
|
@@ -127,17 +162,24 @@ async function refreshIfNeeded(config, env, now) {
|
|
|
127
162
|
// Another process may have refreshed while this one waited for the lock.
|
|
128
163
|
const credential = store.readCredential(key, env);
|
|
129
164
|
if (!credential) return null;
|
|
130
|
-
if (
|
|
131
|
-
if (credential.refreshExpiresAt <= now) {
|
|
132
|
-
store.deleteCredential(key, env);
|
|
165
|
+
if (credential.reauthRequired || credential.refreshExpiresAt <= now) {
|
|
133
166
|
return null;
|
|
134
167
|
}
|
|
168
|
+
if (!(credential.accessExpiresAt - now < dayMs)) return credential;
|
|
169
|
+
const assertUnchanged = () => {
|
|
170
|
+
const current = store.readCredential(key, env);
|
|
171
|
+
if (!current || current.generation !== credential.generation ||
|
|
172
|
+
current.refreshToken !== credential.refreshToken) {
|
|
173
|
+
throw new AuthRequiredError('个人登录在续期期间改变,请核对当前身份后重试;未改用环境身份。');
|
|
174
|
+
}
|
|
175
|
+
};
|
|
135
176
|
let tokens;
|
|
136
177
|
try {
|
|
137
178
|
tokens = await refreshTokens(config, credential.refreshToken);
|
|
138
179
|
} catch (error) {
|
|
139
180
|
if (error instanceof OAuthError && error.oauthError === 'invalid_grant') {
|
|
140
|
-
|
|
181
|
+
assertUnchanged();
|
|
182
|
+
store.saveCredential(key, { ...credential, reauthRequired: true }, env);
|
|
141
183
|
return null;
|
|
142
184
|
}
|
|
143
185
|
// Keep using an access token that has not expired yet when refresh is unreachable.
|
|
@@ -145,20 +187,29 @@ async function refreshIfNeeded(config, env, now) {
|
|
|
145
187
|
throw error;
|
|
146
188
|
}
|
|
147
189
|
const next = { ...credential, ...tokens };
|
|
190
|
+
assertUnchanged();
|
|
148
191
|
store.saveCredential(key, next, env);
|
|
149
192
|
return next;
|
|
150
193
|
});
|
|
151
194
|
}
|
|
152
195
|
|
|
153
|
-
//
|
|
154
|
-
// existing controlled environments behave as before.
|
|
196
|
+
// Select once before a request; a failed personal login never changes to another identity.
|
|
155
197
|
async function resolveAccessToken(env = process.env, { now = Date.now(), loginImpl = login } = {}) {
|
|
156
|
-
|
|
157
|
-
|
|
198
|
+
const mode = authSource(env);
|
|
199
|
+
if (mode === 'env') {
|
|
200
|
+
if (!env.ZAN_SAAS_USER_ACCESS_TOKEN) throw new AuthRequiredError('环境凭证模式缺少 ZAN_SAAS_USER_ACCESS_TOKEN。');
|
|
201
|
+
return Object.freeze({ token: env.ZAN_SAAS_USER_ACCESS_TOKEN, ...credentialChoice(env) });
|
|
158
202
|
}
|
|
159
203
|
const config = oauthConfig(env);
|
|
160
204
|
let credential = store.readCredential(config.server, env);
|
|
161
|
-
|
|
205
|
+
const choice = credentialChoice(env, credential);
|
|
206
|
+
if (choice.source === 'env') {
|
|
207
|
+
store.syncCredentialPresence(env);
|
|
208
|
+
return Object.freeze({ token: env.ZAN_SAAS_USER_ACCESS_TOKEN, ...choice });
|
|
209
|
+
}
|
|
210
|
+
if (credential?.reauthRequired || credential?.refreshExpiresAt <= now) {
|
|
211
|
+
credential = null;
|
|
212
|
+
} else if (credential && Number.isFinite(credential.accessExpiresAt) &&
|
|
162
213
|
credential.accessExpiresAt - now < dayMs) {
|
|
163
214
|
credential = await refreshIfNeeded(config, env, now);
|
|
164
215
|
}
|
|
@@ -170,7 +221,9 @@ async function resolveAccessToken(env = process.env, { now = Date.now(), loginIm
|
|
|
170
221
|
credential = store.readCredential(config.server, env);
|
|
171
222
|
if (!credential) throw new AuthRequiredError('Login did not produce a credential');
|
|
172
223
|
}
|
|
173
|
-
|
|
224
|
+
store.syncCredentialPresence(env);
|
|
225
|
+
return Object.freeze({ token: credential.accessToken, ...credentialChoice(
|
|
226
|
+
{ ...env, ZAN_SAAS_AUTH_SOURCE: 'personal' }, credential) });
|
|
174
227
|
}
|
|
175
228
|
|
|
176
229
|
module.exports = {
|