@xinhe-oa/cli 0.0.0-stage → 0.2.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 +159 -2
- package/bin/xinhe-oa-cli.js +13 -0
- package/package.json +40 -4
- package/src/commands/application.js +244 -0
- package/src/commands/approval.js +275 -0
- package/src/commands/attachment.js +202 -0
- package/src/commands/bitable.js +3040 -0
- package/src/commands/config.js +186 -0
- package/src/commands/dashboard.js +132 -0
- package/src/commands/doctor.js +975 -0
- package/src/commands/event.js +151 -0
- package/src/commands/file.js +1474 -0
- package/src/commands/index.js +78 -0
- package/src/commands/investor.js +179 -0
- package/src/commands/member.js +1341 -0
- package/src/commands/org.js +399 -0
- package/src/commands/report.js +908 -0
- package/src/commands/schema.js +75 -0
- package/src/commands/session.js +516 -0
- package/src/commands/skills.js +1117 -0
- package/src/commands/tenant.js +202 -0
- package/src/commands/workflow.js +176 -0
- package/src/core/args.js +901 -0
- package/src/core/auth.js +349 -0
- package/src/core/checkpoint.js +226 -0
- package/src/core/config.js +407 -0
- package/src/core/error.js +217 -0
- package/src/core/http.js +903 -0
- package/src/core/import.js +753 -0
- package/src/core/output.js +300 -0
- package/src/core/runtime.js +302 -0
- package/src/core/util.js +195 -0
- package/src/core/values.js +278 -0
- package/src/core/xlsx.js +321 -0
- package/src/main.js +205 -0
- package/src/spec/agent_guide.md +55 -0
- package/src/spec/command_surface.json +27073 -0
- package/src/spec/embedded_skills.json +19 -0
- package/src/version.js +16 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// 自描述命令:`schema [command]` / `schema --agent`(契约 §2)。
|
|
2
|
+
//
|
|
3
|
+
// 这份描述符是**机器可读的能力面清单**:LLM 与判卷都靠它知道"这个 CLI 能干什么"。
|
|
4
|
+
// Node 版的正文来自 `src/spec/command_surface.json`(由 `scripts/gen-spec.py` 从
|
|
5
|
+
// Rust 产物的 `schema --json` 生成)—— 两条实现的命令面因此不可能各说各话。
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
|
|
9
|
+
import { CliError } from '../core/error.js';
|
|
10
|
+
import { commandSurface } from '../core/args.js';
|
|
11
|
+
import { VERSION } from '../version.js';
|
|
12
|
+
|
|
13
|
+
const AGENT_GUIDE = readFileSync(new URL('../spec/agent_guide.md', import.meta.url), 'utf8');
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 按查询串过滤命令(与 Rust `schema::lookup` 逐条同义):
|
|
17
|
+
*
|
|
18
|
+
* 1. 先 `trim()`;trim 后为空串 == 不筛(**返回全部,不是错误、不是空列表**);
|
|
19
|
+
* 2. 判据 = 精确相等 **或** 前缀匹配(`bitable record` 命中 5 条);
|
|
20
|
+
* 3. **大小写敏感**,无模糊/子串/后缀匹配;
|
|
21
|
+
* 4. 命中结果保持声明顺序。
|
|
22
|
+
*/
|
|
23
|
+
export function lookup(query) {
|
|
24
|
+
const needle = String(query ?? '').trim();
|
|
25
|
+
return commandSurface().commands.filter((entry) => entry.name.startsWith(needle));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** `schema --json` 的 `data` 段。 */
|
|
29
|
+
export function schemaJson(query) {
|
|
30
|
+
const surface = commandSurface();
|
|
31
|
+
const commands = query === null || query === undefined ? surface.commands : lookup(query);
|
|
32
|
+
return {
|
|
33
|
+
commands: commands.map((entry) => ({
|
|
34
|
+
endpoints: entry.endpoints,
|
|
35
|
+
examples: entry.examples,
|
|
36
|
+
name: entry.name,
|
|
37
|
+
output_schema: entry.output_schema,
|
|
38
|
+
// 逐字回显 Rust 那边的写法(`[--store S01:总院]...`):这是给人看的正文,
|
|
39
|
+
// 反渲染必然漂移,所以规格里存的就是原文(`params_raw`)。
|
|
40
|
+
params: entry.params_raw,
|
|
41
|
+
requires_yes: entry.requires_yes,
|
|
42
|
+
risk: entry.risk,
|
|
43
|
+
})),
|
|
44
|
+
count: commands.length,
|
|
45
|
+
field_types: surface.field_types,
|
|
46
|
+
note: surface.note,
|
|
47
|
+
output_contract: surface.output_contract,
|
|
48
|
+
roles: surface.roles,
|
|
49
|
+
version: VERSION,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
/** `schema [command]` / `schema --agent`。 */
|
|
55
|
+
export async function handleSchema({ args, positional, runtime }) {
|
|
56
|
+
if (args['--agent'] === true) {
|
|
57
|
+
// `--agent` 给的是人/LLM 读的自举说明(**不是** MCP,只是文本)。
|
|
58
|
+
// 优先级高于位置参数:`schema bitable --agent` 也回自举说明。
|
|
59
|
+
const data = {
|
|
60
|
+
agent_guide: AGENT_GUIDE,
|
|
61
|
+
commands: commandSurface().commands.length,
|
|
62
|
+
note: '这是一段说明文本。本 CLI **没有** MCP 面,也没有任意路径/任意 SQL。',
|
|
63
|
+
};
|
|
64
|
+
runtime.finish(data);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const query = positional.length > 0 ? positional[0] : null;
|
|
69
|
+
if (query !== null && lookup(query).length === 0) {
|
|
70
|
+
throw CliError.usage(`没有匹配 \`${query}\` 的命令`).withHint(
|
|
71
|
+
'跑 `xinhe-oa-cli schema`(不带参数)看全部命令;例:xinhe-oa-cli schema bitable record',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
runtime.finish(schemaJson(query));
|
|
75
|
+
}
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
// 会话:`login` / `logout` / `me`(契约 §2 会话行、§4 凭据与设备指纹、§5 首次使用)。
|
|
2
|
+
//
|
|
3
|
+
// 这一域回答三个问题:**怎么进来 / 怎么出去 / 我是谁**,也是唯一会写凭据文件的地方。
|
|
4
|
+
// 逐行对照 `cli/crates/xinhe-oa-cli/src/commands/session.rs` 移植,几处看起来"绕"的
|
|
5
|
+
// 地方就地写清了原因。
|
|
6
|
+
//
|
|
7
|
+
// ## 为什么 `device_id` 必须先落盘,再发那个请求
|
|
8
|
+
//
|
|
9
|
+
// 服务端把 refresh 令牌绑在 `HMAC(User-Agent + X-Device-Id)` 上(契约 §4)。
|
|
10
|
+
// "拿验证码换凭据"成功的那一刻,服务端记下的是**这次请求头里**那个 device_id:
|
|
11
|
+
//
|
|
12
|
+
// * 临时生成、不落盘 —— 本次登录看起来一切正常;但 CLI 下一次续期时只能再随机
|
|
13
|
+
// 一个(或读到空的)device_id,服务端立刻回 `auth.device_mismatch`,
|
|
14
|
+
// 用户被迫重新走一遍短信登录。**这个坑只在"下一次"才炸**,所以必须在第一次就落盘。
|
|
15
|
+
// * 因此 `runtime.ensureDeviceId()` 在**建 HTTP 客户端之前**调用,之后再从落盘凭据里取。
|
|
16
|
+
//
|
|
17
|
+
// 注意"发验证码"那一步(`login --phone` 只给手机号)与 Rust 一样**不落盘** device_id:
|
|
18
|
+
// 它只是一次匿名请求,服务端此时还没签发任何东西。真正把指纹钉下来的是
|
|
19
|
+
// `--code` 换凭据那一步(`--password` 同理)。
|
|
20
|
+
//
|
|
21
|
+
// ## 为什么 `logout` 允许用"已经过期的 access"
|
|
22
|
+
//
|
|
23
|
+
// 登出的语义是"把这个会话链吊销掉、把本机凭据删掉",而**令牌过期恰恰是最常见的登出时机**
|
|
24
|
+
// (放了一夜回来想退出登录)。所以 `auth.logout` 用 refresh 当凭据、access 只是"能用就带上";
|
|
25
|
+
// 服务端已经吊销 / 网络不通 / 报任何错,都不该挡住"本地把凭据删干净"这个用户按下去了的动作 ——
|
|
26
|
+
// 失败只进 `data.warning`(契约 §3.2 的退出码仍然照常返回)。
|
|
27
|
+
//
|
|
28
|
+
// ## 令牌纪律
|
|
29
|
+
//
|
|
30
|
+
// `--json` 里只出现掩码(`maskToken` / `maskPhone`);`--password` 的口令只从 stdin 读;
|
|
31
|
+
// 短信验证码除了 `dev_code` 那次开发模式回显外,不进日志、不进错误。
|
|
32
|
+
|
|
33
|
+
import { flagOn, flagValue } from '../core/args.js';
|
|
34
|
+
import {
|
|
35
|
+
adoptSession,
|
|
36
|
+
envelopeData,
|
|
37
|
+
loginWithPassword,
|
|
38
|
+
logout as logoutSession,
|
|
39
|
+
readSecretFromStdin,
|
|
40
|
+
verifyOtp,
|
|
41
|
+
} from '../core/auth.js';
|
|
42
|
+
import { CliError } from '../core/error.js';
|
|
43
|
+
import { requestSpec } from '../core/http.js';
|
|
44
|
+
import { renderTable } from '../core/output.js';
|
|
45
|
+
import { maskPhone, maskToken, nowRfc3339, sortKeysDeep } from '../core/util.js';
|
|
46
|
+
|
|
47
|
+
/** `me` 的人类可读视图里逐条列出的权限上限(Rust 侧的 `SCOPE_CAP`)。 */
|
|
48
|
+
const SCOPE_CAP = 50;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* `login` 到底走了哪条路(对齐 Rust `LoginArgs::kind` 的**分支顺序**)。
|
|
52
|
+
*
|
|
53
|
+
* 顺序不是随手写的:clap 的 `ArgGroup(multiple = false)` 保证四选一,
|
|
54
|
+
* 但"既有 code 又 adopt"这种组合在 Rust 里由 adopt 先赢 —— `meta.command` 要一致。
|
|
55
|
+
*/
|
|
56
|
+
function loginKind({ adopt, password, hasCode }) {
|
|
57
|
+
if (adopt) return '--adopt-session';
|
|
58
|
+
if (password) return '--password';
|
|
59
|
+
if (hasCode) return '--phone --code';
|
|
60
|
+
return '--phone';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `login`:发码 / 校验验证码 / 口令 / 交接会话。 */
|
|
64
|
+
export async function handleLogin({ args, runtime }) {
|
|
65
|
+
const adopt = flagOn(args, '--adopt-session');
|
|
66
|
+
const password = flagOn(args, '--password');
|
|
67
|
+
const phone = flagValue(args, '--phone');
|
|
68
|
+
const code = flagValue(args, '--code');
|
|
69
|
+
const account = flagValue(args, '--account');
|
|
70
|
+
|
|
71
|
+
// `meta.command` 是判卷字段。Rust 按"用户实际走了哪条登录路径"细分标签
|
|
72
|
+
// (`login --phone` / `login --phone --code` / `login --password` / `login --adopt-session`),
|
|
73
|
+
// 而 Node 的命令表按域名折叠成 `login`。这里在收尾(以及发请求)之前把它校正成
|
|
74
|
+
// Rust 的形态,`--json` 的 `meta.command` 才逐字一致。
|
|
75
|
+
runtime.command = `login ${loginKind({ adopt, password, hasCode: code !== null })}`;
|
|
76
|
+
|
|
77
|
+
// ① 交接会话(install.sh 用):不联网,只把它写进自己的凭据文件。
|
|
78
|
+
if (adopt) return adoptFromStdin(runtime);
|
|
79
|
+
|
|
80
|
+
// ② 口令登录(仅引导账号;口令从 stdin 读,**不进 argv**)。
|
|
81
|
+
if (password) return loginByPassword(runtime, account);
|
|
82
|
+
|
|
83
|
+
// ③ 拿验证码换凭据。
|
|
84
|
+
//
|
|
85
|
+
// 判空用 `=== null`(而不是 falsy):`--phone=` 这种显式空串在 Rust 里是
|
|
86
|
+
// `Some("")`,会被原样发给服务端,由服务端回"号码不合法" —— CLI 不替它判。
|
|
87
|
+
if (phone === null) {
|
|
88
|
+
throw CliError.usage('`login` 需要 --phone <手机号>').withHint(
|
|
89
|
+
'例:xinhe-oa-cli login --phone 13800000000',
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
if (code !== null) return loginByCode(runtime, phone, code);
|
|
93
|
+
|
|
94
|
+
// ④ 只给手机号 = 发码。
|
|
95
|
+
return requestOtp(runtime, phone);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* `login --password --account`:仅引导账号,口令从 stdin 读。
|
|
100
|
+
*
|
|
101
|
+
* 三步的顺序是刻意的(与 Rust 一致):**先落盘 device_id,再读口令,最后才碰网络**。
|
|
102
|
+
* 口令读失败(stdin 是空的)时,device_id 已经存好了 —— 用户重跑一次不会换指纹。
|
|
103
|
+
*/
|
|
104
|
+
async function loginByPassword(runtime, account) {
|
|
105
|
+
if (account === null) {
|
|
106
|
+
throw CliError.usage('`login --password` 需要 --account <账号>').withHint(
|
|
107
|
+
"例:printf '%s' \"$PW\" | xinhe-oa-cli login --password --account 18772318183",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
const deviceId = runtime.ensureDeviceId();
|
|
111
|
+
const secret = await readSecretFromStdin('口令(从 stdin 读,不回显):');
|
|
112
|
+
const http = runtime.anonymousHttp();
|
|
113
|
+
if (runtime.dryRun) {
|
|
114
|
+
// 口令**不回显**:dry-run 报告里只放占位符(连长度都不给)。
|
|
115
|
+
return runtime.dryRunReport(
|
|
116
|
+
'POST',
|
|
117
|
+
'/auth/login',
|
|
118
|
+
requestSpec({ body: { account, password: '<从 stdin 读,不打印>' } }),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
const credential = await loginWithPassword(http, account, secret, deviceId);
|
|
122
|
+
return saveAndReport(runtime, credential, 'password');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** `login --phone --code`:校验验证码并落盘凭据。 */
|
|
126
|
+
async function loginByCode(runtime, phone, code) {
|
|
127
|
+
// **先落盘再用**(见文件头):这一步之后,请求头里的 X-Device-Id
|
|
128
|
+
// 与将来续期时从凭据文件里读到的那个一定是同一个。
|
|
129
|
+
const deviceId = runtime.ensureDeviceId();
|
|
130
|
+
const http = runtime.anonymousHttp();
|
|
131
|
+
if (runtime.dryRun) {
|
|
132
|
+
return runtime.dryRunReport('POST', '/auth/otp/verify', requestSpec({ body: { code, phone } }));
|
|
133
|
+
}
|
|
134
|
+
const credential = await verifyOtp(http, phone, code, deviceId);
|
|
135
|
+
return saveAndReport(runtime, credential, 'otp');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* `login --phone`(只给手机号):发验证码。
|
|
140
|
+
*
|
|
141
|
+
* 两点与 Rust 一致、且只看契约看不出来:
|
|
142
|
+
*
|
|
143
|
+
* 1. **不落盘 device_id**(见文件头):这是匿名请求,服务端还没签发任何东西。
|
|
144
|
+
* `--dry-run` 时连生成都不生成(报告里 `X-Device-Id` 是 `<未生成>`)。
|
|
145
|
+
* 2. 号码在请求成功之后写进 profile:凭据失效时的 `hint` 要能给出**完整**的登录命令,
|
|
146
|
+
* 而号码不是机密(contract §4 的 hint 要求带号码)。
|
|
147
|
+
*/
|
|
148
|
+
async function requestOtp(runtime, phone) {
|
|
149
|
+
const spec = requestSpec({ body: { phone, purpose: 'login' } });
|
|
150
|
+
if (runtime.dryRun) {
|
|
151
|
+
return runtime.dryRunReport('POST', '/auth/otp/request', spec);
|
|
152
|
+
}
|
|
153
|
+
const response = await runtime.sendAnonymous('POST', '/auth/otp/request', spec);
|
|
154
|
+
// `data` 不是对象时(null / 标量 / 数组)Rust 的 `data["k"] = v` 会把它整个换成对象,
|
|
155
|
+
// 这里用 {} 起手是同一个意思 —— 服务端正常回的是对象,这条只是别在畸形响应上崩掉。
|
|
156
|
+
const payload = asObject(envelopeData(response));
|
|
157
|
+
runtime.config.setPhone(phone);
|
|
158
|
+
runtime.config.save();
|
|
159
|
+
decorateOtpSent(payload, phone);
|
|
160
|
+
return runtime.finishFrom(response, payload);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 补 `phone_masked` 与 `next_step`(Rust 里就是在 `data` 上原地加这两个键)。 */
|
|
164
|
+
function decorateOtpSent(payload, phone) {
|
|
165
|
+
payload.phone_masked = maskPhone(phone);
|
|
166
|
+
if (typeof payload.dev_code === 'string') {
|
|
167
|
+
// 开发模式(服务端 `OTP_DEV_ECHO`)回显验证码:把完整命令拼好,自动化能直接照抄。
|
|
168
|
+
payload.next_step =
|
|
169
|
+
`开发模式已回显验证码。下一步:xinhe-oa-cli login --phone ${phone} --code ${payload.dev_code}`;
|
|
170
|
+
} else {
|
|
171
|
+
payload.next_step = `把收到的验证码交给 CLI:xinhe-oa-cli login --phone ${phone} --code <验证码>`;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* `login --adopt-session`:从 stdin 读 install.sh 交接的会话 JSON 并落盘。
|
|
177
|
+
*
|
|
178
|
+
* 不联网,所以也没有"服务端说对不对"这一层:交接体里的 `device_id` 必须沿用
|
|
179
|
+
* (换一个 = 第一次续期就 `auth.device_mismatch`,见 `core/auth.js::adoptSession`)。
|
|
180
|
+
*/
|
|
181
|
+
async function adoptFromStdin(runtime) {
|
|
182
|
+
const raw = await readStdinText();
|
|
183
|
+
if (raw.trim().length === 0) {
|
|
184
|
+
throw CliError.usage('标准输入里没有会话交接体(JSON)').withHint(
|
|
185
|
+
'用法:echo "$SESSION_JSON" | xinhe-oa-cli login --adopt-session',
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
const credential = adoptSession(raw, runtime.baseUrl ?? '');
|
|
189
|
+
// 交接体里的 base_url 才是这次会话真正的后端:写进 profile。
|
|
190
|
+
//
|
|
191
|
+
// 这里**刻意不 `config.save()`**:Rust 的 `adopt_session` 同样只改内存
|
|
192
|
+
// (它调 `set_current_base_url` 后就 `finish` 了)。落盘的是 credentials.json,
|
|
193
|
+
// 里面那份 `base_url` 已经跟着凭据走了;config.json 的地址由 install.sh 的
|
|
194
|
+
// `config --set base_url=…` 负责。两条实现必须一样,否则"同一串操作两边的
|
|
195
|
+
// 配置文件不同"会在对拍时变成噪声。
|
|
196
|
+
runtime.config.setCurrentBaseUrl(credential.base_url);
|
|
197
|
+
runtime.baseUrl = credential.base_url;
|
|
198
|
+
runtime.credentials.put(runtime.profile, credential);
|
|
199
|
+
|
|
200
|
+
return runtime.finish(
|
|
201
|
+
{
|
|
202
|
+
adopted: true,
|
|
203
|
+
profile: runtime.profile,
|
|
204
|
+
base_url: credential.base_url,
|
|
205
|
+
device_id: credential.device_id,
|
|
206
|
+
user: credential.user ?? null,
|
|
207
|
+
access_token: maskToken(credential.tokens.access_token),
|
|
208
|
+
next_step: '已经可以直接用了:xinhe-oa-cli me --json',
|
|
209
|
+
note: '会话由 install.sh 交接;device_id 与 UA 与交接方一致,续期不会被 auth.device_mismatch 拒',
|
|
210
|
+
},
|
|
211
|
+
null,
|
|
212
|
+
null,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 登录成功后的统一落盘与汇报(`save_and_report`)。
|
|
218
|
+
*
|
|
219
|
+
* 顺序与 Rust 一致:先记号码(config.json)、再写凭据(credentials.json)。
|
|
220
|
+
* 号码写进 profile 是为了让"凭据失效"的 hint 能给出带号码的完整登录命令。
|
|
221
|
+
*/
|
|
222
|
+
function saveAndReport(runtime, credential, method) {
|
|
223
|
+
credential.updated_at = nowRfc3339();
|
|
224
|
+
if (credential.phone) {
|
|
225
|
+
runtime.config.setPhone(credential.phone);
|
|
226
|
+
runtime.config.save();
|
|
227
|
+
}
|
|
228
|
+
runtime.credentials.put(runtime.profile, credential);
|
|
229
|
+
|
|
230
|
+
const fileMode = runtime.credentials.fileMode();
|
|
231
|
+
return runtime.finish(
|
|
232
|
+
{
|
|
233
|
+
profile: runtime.profile,
|
|
234
|
+
base_url: credential.base_url ?? null,
|
|
235
|
+
user: credential.user ?? null,
|
|
236
|
+
auth_method: method,
|
|
237
|
+
device_id: credential.device_id,
|
|
238
|
+
credentials_file: runtime.paths.credentialsFile,
|
|
239
|
+
// Rust 是 `format!("{mode:o}")`(八进制字符串);Windows 上没有这个概念 → null。
|
|
240
|
+
credentials_file_mode: fileMode === null ? null : fileMode.toString(8),
|
|
241
|
+
access_token: maskToken(credential.tokens?.access_token ?? ''),
|
|
242
|
+
refresh_token: maskToken(credential.tokens?.refresh_token ?? ''),
|
|
243
|
+
access_expires_at: credential.tokens?.access_expires_at ?? null,
|
|
244
|
+
next_step: '看一下自己是谁、能做什么:xinhe-oa-cli me --json',
|
|
245
|
+
note: '令牌已落盘(0600)。CLI 之外的任何程序都读不到它,argv 与日志里也没有。',
|
|
246
|
+
},
|
|
247
|
+
null,
|
|
248
|
+
null,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** `logout`:吊销会话链 + 删掉本地凭据。 */
|
|
253
|
+
export async function handleLogout({ runtime }) {
|
|
254
|
+
const credential = runtime.credentials.get(runtime.profile);
|
|
255
|
+
if (credential === null) {
|
|
256
|
+
// 本来就没登录:**不是错误**(重复登出是幂等的),更不该为它发一次网络请求。
|
|
257
|
+
return runtime.finish(
|
|
258
|
+
{ revoked: false, local_credentials_removed: false, note: '本机本来就没有凭据' },
|
|
259
|
+
null,
|
|
260
|
+
null,
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (runtime.dryRun) {
|
|
265
|
+
// 与 Rust 一致:dry-run 只描述"将会发什么"。凭据里只有 access、没有 refresh 时
|
|
266
|
+
// 也照打 refresh 占位符 —— dry-run 报告是给人看的说明,不是把请求体重放一遍。
|
|
267
|
+
return runtime.dryRunReport(
|
|
268
|
+
'POST',
|
|
269
|
+
'/auth/logout',
|
|
270
|
+
requestSpec({ body: { refresh_token: '<从凭据文件读,不打印>' } }),
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const http = runtime.anonymousHttp();
|
|
275
|
+
let serverResponse;
|
|
276
|
+
let warning = null;
|
|
277
|
+
try {
|
|
278
|
+
serverResponse = await logoutSession(http, credential);
|
|
279
|
+
} catch (err) {
|
|
280
|
+
// 服务端已吊销 / 网络不通 / 令牌过期都不该挡住登出(见文件头):
|
|
281
|
+
// 记一句 warning,把本地那枚凭据删干净才是用户要的结果。
|
|
282
|
+
serverResponse = {};
|
|
283
|
+
warning = `服务端吊销失败(${err.code}:${err.message}),本地凭据已删除`;
|
|
284
|
+
}
|
|
285
|
+
runtime.credentials.remove(runtime.profile);
|
|
286
|
+
|
|
287
|
+
return runtime.finish(
|
|
288
|
+
{
|
|
289
|
+
revoked: true,
|
|
290
|
+
local_credentials_removed: true,
|
|
291
|
+
profile: runtime.profile,
|
|
292
|
+
server_response: serverResponse,
|
|
293
|
+
warning,
|
|
294
|
+
},
|
|
295
|
+
null,
|
|
296
|
+
null,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** `me`:我是谁、可见企业与门店、角色、能力、缺什么权限。 */
|
|
301
|
+
export async function handleMe({ runtime }) {
|
|
302
|
+
const response = await runtime.sendAuthed('GET', '/me', requestSpec());
|
|
303
|
+
const profile = asObjectOrNull(envelopeData(response));
|
|
304
|
+
|
|
305
|
+
// 能力清单(`GET /me/endpoints`):**失败不该挡住 `me`** —— 它是补充信息,
|
|
306
|
+
// 而"我是谁"已经拿到了。失败只把错误码/消息放进 `endpoints.error` 一起交出去。
|
|
307
|
+
let endpoints;
|
|
308
|
+
try {
|
|
309
|
+
const endpointsResponse = await runtime.sendAuthed('GET', '/me/endpoints', requestSpec());
|
|
310
|
+
endpoints = asObjectOrNull(envelopeData(endpointsResponse));
|
|
311
|
+
} catch (err) {
|
|
312
|
+
// 兜底给一个 code:Rust 的 `err` 必然是 CliError(一定带 code),
|
|
313
|
+
// 这里万一是别的异常,也不该吐出一个缺键的 error 形状。
|
|
314
|
+
endpoints = {
|
|
315
|
+
error: { code: err?.code ?? 'cli.internal', message: err?.message ?? String(err) },
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// `capabilities` 只在**键不存在**时兜底成 [](键在但值是 null 就原样给 null)——
|
|
320
|
+
// 与 Rust 的 `.get(...).cloned().unwrap_or(json!([]))` 同义。
|
|
321
|
+
const capabilities = endpoints?.capabilities !== undefined ? endpoints.capabilities : [];
|
|
322
|
+
const hidden = Array.isArray(endpoints?.hidden_endpoints) ? endpoints.hidden_endpoints.length : 0;
|
|
323
|
+
const catalog = endpoints?.capability_catalog;
|
|
324
|
+
const missing = Array.isArray(catalog)
|
|
325
|
+
? catalog
|
|
326
|
+
.filter((item) => item?.granted === false)
|
|
327
|
+
.map((item) => ({
|
|
328
|
+
capability: item?.capability ?? null,
|
|
329
|
+
description: item?.description ?? null,
|
|
330
|
+
how_to_fix:
|
|
331
|
+
'让企业管理员(tenant_owner)或 super_admin 赋角色:xinhe-oa-cli member --role <你的 user_id>',
|
|
332
|
+
}))
|
|
333
|
+
: [];
|
|
334
|
+
|
|
335
|
+
const data = {
|
|
336
|
+
user: profile?.user ?? null,
|
|
337
|
+
auth: profile?.auth ?? null,
|
|
338
|
+
scope: profile?.scope ?? null,
|
|
339
|
+
rate_limits: profile?.rate_limits ?? null,
|
|
340
|
+
datasets: profile?.datasets ?? null,
|
|
341
|
+
capabilities,
|
|
342
|
+
missing,
|
|
343
|
+
endpoints: {
|
|
344
|
+
visible: endpoints?.counts?.visible ?? null,
|
|
345
|
+
hidden,
|
|
346
|
+
list: endpoints?.endpoints ?? null,
|
|
347
|
+
},
|
|
348
|
+
next_steps: [
|
|
349
|
+
'bitable base --list --json 看自己能访问哪些库',
|
|
350
|
+
'member --list --json 看本企业的人(企业负责人及以上)',
|
|
351
|
+
'bitable table --import 上传一份单据(CSV/XLSX)',
|
|
352
|
+
],
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
// 人类可读面**补齐身份 / 范围 / 能力 / 缺什么**:通用表格渲染会抓第一个
|
|
356
|
+
// "对象数组"字段(datasets)当列表,于是 `me` 只剩一张 datasets 表 ——
|
|
357
|
+
// 而"我是谁、能看什么、能做什么、还缺什么"才是这个命令存在的理由。
|
|
358
|
+
// `--json` 一个字都不变(机器面稳定)。
|
|
359
|
+
return runtime.finishFromWithHuman(response, data, (markdown) => meHuman(data, markdown));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* `me` 的人类可读视图:把 `--json` 的几个关键段落摆成给人看的样子。
|
|
364
|
+
*
|
|
365
|
+
* 口径与 `--json` 完全一致(不多不少):身份 / 可见范围 / 权限范围 / 能力 /
|
|
366
|
+
* 接口可见性 / **缺什么** / 下一步。逐行照 Rust 的 `me_human` 拼,
|
|
367
|
+
* 连"markdown 模式下表格自带尾换行、table 模式下被 trim 掉"这种细节都对齐 ——
|
|
368
|
+
* 否则 `--format md` 的对拍会差在空行上。
|
|
369
|
+
*/
|
|
370
|
+
function meHuman(data, markdown) {
|
|
371
|
+
const user = data.user;
|
|
372
|
+
const auth = data.auth;
|
|
373
|
+
const scope = data.scope;
|
|
374
|
+
let out = '';
|
|
375
|
+
|
|
376
|
+
// ---- 身份 ----
|
|
377
|
+
out += '身份\n';
|
|
378
|
+
out += renderTable(
|
|
379
|
+
['字段', '值'],
|
|
380
|
+
[
|
|
381
|
+
['用户', textOf(user?.name)],
|
|
382
|
+
['user_id', textOf(user?.id)],
|
|
383
|
+
['角色', textOf(user?.roles)],
|
|
384
|
+
['bootstrap', textOf(user?.is_bootstrap)],
|
|
385
|
+
['认证方式', textOf(auth?.method)],
|
|
386
|
+
['会话', textOf(auth?.session_id)],
|
|
387
|
+
[
|
|
388
|
+
'令牌有效期',
|
|
389
|
+
`access ${textOf(auth?.access_token_ttl_seconds)}s / refresh ${textOf(auth?.refresh_token_ttl_seconds)}s`,
|
|
390
|
+
],
|
|
391
|
+
],
|
|
392
|
+
markdown,
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
// ---- 可见范围 ----
|
|
396
|
+
out += '\n可见范围\n';
|
|
397
|
+
const pending = scope?.pending_endorsements;
|
|
398
|
+
const pendingText =
|
|
399
|
+
!Array.isArray(pending) || pending.length === 0
|
|
400
|
+
? '—'
|
|
401
|
+
: `${textOf(pending)}(共 ${pending.length} 家:企业还没背书,所以看不到 —— 不是没权限,是等对方确认)`;
|
|
402
|
+
out += renderTable(
|
|
403
|
+
['字段', '值'],
|
|
404
|
+
[
|
|
405
|
+
[
|
|
406
|
+
'企业',
|
|
407
|
+
scope?.is_super_admin === true
|
|
408
|
+
? `${textOf(scope?.tenants)}(super_admin:全部)`
|
|
409
|
+
: textOf(scope?.tenants),
|
|
410
|
+
],
|
|
411
|
+
['门店', textOf(scope?.stores)],
|
|
412
|
+
['待背书企业', pendingText],
|
|
413
|
+
['权限范围', `${Array.isArray(scope?.scopes) ? scope.scopes.length : 0} 条(下面逐条列出)`],
|
|
414
|
+
],
|
|
415
|
+
markdown,
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
// ---- 权限范围(逐条)----
|
|
419
|
+
const scopes = Array.isArray(scope?.scopes) ? scope.scopes.map(textOf) : [];
|
|
420
|
+
out += `\n权限范围(${scopes.length} 条)\n`;
|
|
421
|
+
if (scopes.length === 0) {
|
|
422
|
+
out += '(一条都没有:默认拒绝,找企业管理员赋角色)\n';
|
|
423
|
+
} else {
|
|
424
|
+
out += renderTable(['scope'], scopes.slice(0, SCOPE_CAP).map((item) => [item]), markdown);
|
|
425
|
+
if (scopes.length > SCOPE_CAP) {
|
|
426
|
+
out += `\n(只列前 ${SCOPE_CAP} 条;${scopes.length} 条完整清单用 --json)\n`;
|
|
427
|
+
} else {
|
|
428
|
+
out += '\n';
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// ---- 能力 ----
|
|
433
|
+
const capabilities = Array.isArray(data.capabilities) ? data.capabilities.map(textOf) : [];
|
|
434
|
+
out += `能力(${capabilities.length} 项)\n`;
|
|
435
|
+
if (capabilities.length === 0) {
|
|
436
|
+
out += '(还没有任何接口级能力)\n';
|
|
437
|
+
} else {
|
|
438
|
+
out += renderTable(['capability'], capabilities.map((item) => [item]), markdown);
|
|
439
|
+
out += '\n';
|
|
440
|
+
}
|
|
441
|
+
out +=
|
|
442
|
+
`接口可见性:可见 ${textOf(data.endpoints?.visible)} 个 / 隐藏 ${textOf(data.endpoints?.hidden)} 个` +
|
|
443
|
+
'(完整清单见 `me --json` 的 endpoints)\n';
|
|
444
|
+
|
|
445
|
+
// ---- 缺什么 ----
|
|
446
|
+
const missing = Array.isArray(data.missing) ? data.missing : [];
|
|
447
|
+
out += `\n缺什么(${missing.length} 项)\n`;
|
|
448
|
+
if (missing.length === 0) {
|
|
449
|
+
out += '(没有缺的权限:上面列出的就是你现在的全部能力)\n';
|
|
450
|
+
} else {
|
|
451
|
+
out += renderTable(
|
|
452
|
+
['capability', '说明', '怎么拿到'],
|
|
453
|
+
missing.map((item) => [
|
|
454
|
+
textOf(item?.capability),
|
|
455
|
+
textOf(item?.description),
|
|
456
|
+
textOf(item?.how_to_fix),
|
|
457
|
+
]),
|
|
458
|
+
markdown,
|
|
459
|
+
);
|
|
460
|
+
out += '\n';
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---- 下一步 ----
|
|
464
|
+
const steps = data.next_steps;
|
|
465
|
+
if (Array.isArray(steps) && steps.length > 0) {
|
|
466
|
+
out += '\n下一步\n';
|
|
467
|
+
for (const step of steps) out += `- ${textOf(step)}\n`;
|
|
468
|
+
}
|
|
469
|
+
return out.trimEnd();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* 值的单行文本(`null` / 空串 / 空数组 → `—`,与人类可读表格的空值口径一致)。
|
|
474
|
+
*
|
|
475
|
+
* 对象取 `名字(id)`:服务端的 `scope.tenants` / `scope.stores` 是
|
|
476
|
+
* `{"id":…,"name":…,"status":…}` 这样的对象数组,把 JSON 原样摊进表格没法看。
|
|
477
|
+
*/
|
|
478
|
+
function textOf(value) {
|
|
479
|
+
if (value === null || value === undefined) return '—';
|
|
480
|
+
if (typeof value === 'string') return value.length === 0 ? '—' : value;
|
|
481
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
482
|
+
if (typeof value === 'number') return String(value);
|
|
483
|
+
if (Array.isArray(value)) {
|
|
484
|
+
return value.length === 0 ? '—' : value.map(textOf).join(', ');
|
|
485
|
+
}
|
|
486
|
+
if (typeof value === 'object') {
|
|
487
|
+
const name = typeof value.name === 'string' ? value.name : null;
|
|
488
|
+
const id = typeof value.id === 'string' ? value.id : null;
|
|
489
|
+
if (name !== null && id !== null) return `${name}(${id})`;
|
|
490
|
+
if (name !== null) return name;
|
|
491
|
+
if (id !== null) return id;
|
|
492
|
+
// 兜底:Rust 的 serde_json Map 是 BTreeMap(键有序),这里也排一遍键,
|
|
493
|
+
// 免得同一个对象在两种实现下打印出不同的键序。
|
|
494
|
+
return JSON.stringify(sortKeysDeep(value));
|
|
495
|
+
}
|
|
496
|
+
return String(value);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** 服务端的 `data` 不是对象时(null / 标量 / 数组),Rust 的 `data["k"] = v` 会把它整个换成对象。 */
|
|
500
|
+
function asObject(value) {
|
|
501
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : {};
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** 只读用:非对象一律当"没有"(后续全是可选链,`null` 比 `{}` 更能表达"服务端没给")。 */
|
|
505
|
+
function asObjectOrNull(value) {
|
|
506
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? value : null;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** 读完整个 stdin(`--adopt-session` 的 JSON 可能是多行的)。 */
|
|
510
|
+
async function readStdinText() {
|
|
511
|
+
const chunks = [];
|
|
512
|
+
for await (const chunk of process.stdin) {
|
|
513
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk);
|
|
514
|
+
}
|
|
515
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
516
|
+
}
|