@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,186 @@
|
|
|
1
|
+
// 配置:`config --list|--show|--set|--use`(契约 §2、§4)。
|
|
2
|
+
//
|
|
3
|
+
// 一条硬规矩:**永不打印令牌**。`--show` 里的凭据一律掩码
|
|
4
|
+
// (`abcd…(共 42 字符)`),只够确认"是哪一枚",不够拿来用。
|
|
5
|
+
|
|
6
|
+
import { CliError } from '../core/error.js';
|
|
7
|
+
import { DEFAULT_PROFILE, ProxyMode, parseProxyMode } from '../core/config.js';
|
|
8
|
+
import { Auth, USER_AGENT, hostOf, normalizeBaseUrl, requestSpec } from '../core/http.js';
|
|
9
|
+
import { maskPhone, maskToken } from '../core/util.js';
|
|
10
|
+
|
|
11
|
+
/** `config --list` / `--show` / `--set` / `--use` 的分发。 */
|
|
12
|
+
export async function handleConfig({ args, runtime }) {
|
|
13
|
+
if (args['--list'] === true) return list(runtime);
|
|
14
|
+
if (args['--show'] === true) return show(runtime);
|
|
15
|
+
if (args['--set'] !== undefined) return set(runtime, String(args['--set']));
|
|
16
|
+
if (args['--use'] !== undefined) return useProfile(runtime, String(args['--use']));
|
|
17
|
+
throw CliError.usage('`config` 必须给一个动作').withHint(
|
|
18
|
+
'四选一:--list | --show | --set 键=值 | --use <名字>',
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function list(runtime) {
|
|
23
|
+
const current = runtime.config.currentName();
|
|
24
|
+
const profiles = runtime.config.names().map((name) => {
|
|
25
|
+
const profile = runtime.config.profile(name);
|
|
26
|
+
const credential = runtime.credentials.get(name);
|
|
27
|
+
const entry = {
|
|
28
|
+
name,
|
|
29
|
+
current: name === current,
|
|
30
|
+
base_url: profile.base_url ?? null,
|
|
31
|
+
proxy: profile.proxy ?? ProxyMode.System,
|
|
32
|
+
phone_masked: profile.phone ? maskPhone(profile.phone) : null,
|
|
33
|
+
has_credentials: credential
|
|
34
|
+
? Boolean(credential.tokens?.access_token || credential.tokens?.refresh_token)
|
|
35
|
+
: false,
|
|
36
|
+
device_id: credential?.device_id ?? null,
|
|
37
|
+
tokens_valid_until: credential?.tokens?.refresh_expires_at ?? null,
|
|
38
|
+
};
|
|
39
|
+
return entry;
|
|
40
|
+
});
|
|
41
|
+
runtime.finish({
|
|
42
|
+
current,
|
|
43
|
+
config_file: runtime.paths.configFile,
|
|
44
|
+
credentials_file: runtime.paths.credentialsFile,
|
|
45
|
+
profiles,
|
|
46
|
+
note: '令牌**永不打印**:这里只有掩码与有效期。',
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function show(runtime) {
|
|
51
|
+
const profile = runtime.config.profile(runtime.profile);
|
|
52
|
+
const credential = runtime.credentials.get(runtime.profile);
|
|
53
|
+
const data = {
|
|
54
|
+
profile: runtime.profile,
|
|
55
|
+
base_url: profile.base_url ?? '<未配置>',
|
|
56
|
+
effective_base_url: runtime.baseUrl ?? null,
|
|
57
|
+
proxy: profile.proxy ?? ProxyMode.System,
|
|
58
|
+
proxy_effective: runtime.proxyDescription(),
|
|
59
|
+
phone_masked: profile.phone ? maskPhone(profile.phone) : null,
|
|
60
|
+
device: credential
|
|
61
|
+
? {
|
|
62
|
+
device_id: credential.device_id,
|
|
63
|
+
user_agent: USER_AGENT,
|
|
64
|
+
note: '服务端把 refresh 绑在 HMAC(User-Agent + X-Device-Id) 上;换设备会被 auth.device_mismatch 拒',
|
|
65
|
+
}
|
|
66
|
+
: null,
|
|
67
|
+
credentials: credential
|
|
68
|
+
? {
|
|
69
|
+
access_token: maskToken(credential.tokens?.access_token ?? ''),
|
|
70
|
+
refresh_token: maskToken(credential.tokens?.refresh_token ?? ''),
|
|
71
|
+
access_expires_at: credential.tokens?.access_expires_at ?? null,
|
|
72
|
+
refresh_expires_at: credential.tokens?.refresh_expires_at ?? null,
|
|
73
|
+
user: credential.user ?? null,
|
|
74
|
+
updated_at: credential.updated_at ?? null,
|
|
75
|
+
file_mode: process.platform === 'win32' ? null : '0600',
|
|
76
|
+
}
|
|
77
|
+
: null,
|
|
78
|
+
paths: {
|
|
79
|
+
config: runtime.paths.configFile,
|
|
80
|
+
credentials: runtime.paths.credentialsFile,
|
|
81
|
+
config_dir: runtime.paths.dir,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
runtime.finish(data);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function set(runtime, pair) {
|
|
88
|
+
const index = pair.indexOf('=');
|
|
89
|
+
if (index < 0) {
|
|
90
|
+
throw CliError.usage('--set 的写法是 `键=值`').withHint(
|
|
91
|
+
'支持:base_url=<地址> | proxy=system|none | profile=<名字>;例:xinhe-oa-cli config --set base_url=http://127.0.0.1:8102',
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const key = pair.slice(0, index).trim().toLowerCase();
|
|
95
|
+
const value = pair.slice(index + 1).trim();
|
|
96
|
+
const updated = {};
|
|
97
|
+
let nextStep;
|
|
98
|
+
if (key === 'base_url' || key === 'base-url' || key === 'url') {
|
|
99
|
+
if (value.length === 0) {
|
|
100
|
+
throw CliError.usage('base_url 不能为空').withHint('例:--set base_url=http://127.0.0.1:8102');
|
|
101
|
+
}
|
|
102
|
+
const normalized = normalizeBaseUrl(value);
|
|
103
|
+
// 纯 IP + 端口也合法(容器里的 LLM 常走 http://172.17.0.1:8100)。
|
|
104
|
+
if (hostOf(normalized) === null) {
|
|
105
|
+
throw CliError.usage(`看不出来这是个地址:${JSON.stringify(value)}`).withHint(
|
|
106
|
+
'例:--set base_url=http://127.0.0.1:8102',
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
runtime.config.setCurrentBaseUrl(normalized);
|
|
110
|
+
runtime.baseUrl = normalized;
|
|
111
|
+
updated.base_url = normalized;
|
|
112
|
+
nextStep = '接着登录:xinhe-oa-cli login --phone <手机号>';
|
|
113
|
+
} else if (key === 'proxy') {
|
|
114
|
+
const mode = parseProxyMode(value);
|
|
115
|
+
runtime.config.setCurrentProxy(mode);
|
|
116
|
+
runtime.proxy = mode;
|
|
117
|
+
updated.proxy = mode;
|
|
118
|
+
nextStep = '验证连通性:xinhe-oa-cli doctor';
|
|
119
|
+
} else if (key === 'profile') {
|
|
120
|
+
if (value.length === 0) throw CliError.usage('profile 名字不能为空');
|
|
121
|
+
runtime.config.setCurrent(value);
|
|
122
|
+
runtime.profile = value;
|
|
123
|
+
updated.profile = value;
|
|
124
|
+
nextStep = '查看配置:xinhe-oa-cli config --list';
|
|
125
|
+
} else {
|
|
126
|
+
throw CliError.usage(`不认识的配置项 ${JSON.stringify(key)}`).withHint(
|
|
127
|
+
'支持:base_url=<地址> | proxy=system|none | profile=<名字>',
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
runtime.config.save();
|
|
131
|
+
runtime.finish({
|
|
132
|
+
updated,
|
|
133
|
+
profile: runtime.profile,
|
|
134
|
+
config_file: runtime.paths.configFile,
|
|
135
|
+
proxy_effective: runtime.proxyDescription(),
|
|
136
|
+
next_step: nextStep,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function useProfile(runtime, name) {
|
|
141
|
+
if (name.trim().length === 0) {
|
|
142
|
+
throw CliError.usage('--use 需要 profile 名字').withHint(
|
|
143
|
+
`例:xinhe-oa-cli config --use ${DEFAULT_PROFILE}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
runtime.config.setCurrent(name);
|
|
147
|
+
runtime.config.save();
|
|
148
|
+
const profile = runtime.config.profile(name);
|
|
149
|
+
const credential = runtime.credentials.get(name);
|
|
150
|
+
runtime.finish({
|
|
151
|
+
current: name,
|
|
152
|
+
base_url: profile.base_url ?? null,
|
|
153
|
+
phone_masked: profile.phone ? maskPhone(profile.phone) : null,
|
|
154
|
+
has_credentials: credential
|
|
155
|
+
? Boolean(credential.tokens?.access_token || credential.tokens?.refresh_token)
|
|
156
|
+
: false,
|
|
157
|
+
next_step:
|
|
158
|
+
credential !== null
|
|
159
|
+
? '确认还能用:xinhe-oa-cli me --json'
|
|
160
|
+
: '这个 profile 还没有凭据:xinhe-oa-cli login --phone <手机号>',
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** 探一次 `/health`(`doctor` 用)。 */
|
|
165
|
+
export async function probeHealth(runtime) {
|
|
166
|
+
const http = runtime.anonymousHttp();
|
|
167
|
+
const response = await http.send('GET', '/health', Auth.none(), requestSpec(), 'doctor');
|
|
168
|
+
return response.body;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 问一次 `/cli/latest`(#71 的版本协商面)。
|
|
173
|
+
*
|
|
174
|
+
* 三点刻意如此:
|
|
175
|
+
* - **匿名**(`capability="public"`):首次安装、以及"手上这枚令牌已经失效"的旧客户端
|
|
176
|
+
* 都要能问出该装哪个版本 —— 把它关在登录之后就等于让登不上来的人连自救都做不了;
|
|
177
|
+
* - **带上 `X-CLI-Version`**:不发这个头服务端不做门禁(它只拦"确实太旧"),
|
|
178
|
+
* 而我们要的就是被明确告知"你太旧了,去升级"(`cli.too_old` → 退出码 8);
|
|
179
|
+
* - **响应体不套 `data` 信封**,所以这里回整个响应体,由调用方按字段名取。
|
|
180
|
+
*/
|
|
181
|
+
export async function fetchLatestRelease(runtime) {
|
|
182
|
+
const http = runtime.anonymousHttp();
|
|
183
|
+
const response = await http.send('GET', '/cli/latest', Auth.none(), requestSpec(), 'doctor');
|
|
184
|
+
return response.body;
|
|
185
|
+
}
|
|
186
|
+
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// 看板:`dashboard --list|--show|--data|--create`(契约 §2)。
|
|
2
|
+
//
|
|
3
|
+
// 看板是"库(base)之上的只读视图集合":block(指标/表格/图表/文本)各自绑一个
|
|
4
|
+
// 数据源,`--data` 就是把它们**按你的权限现算一遍**(docs/26 §1.2)。
|
|
5
|
+
//
|
|
6
|
+
// 两条与直觉相反、但必须保持的口径:
|
|
7
|
+
//
|
|
8
|
+
// 1. `--data` 是 POST,却**不是写**:它没有 `--yes`、也不要幂等键 —— `--show` 给的是
|
|
9
|
+
// 看板的**定义**(有哪些 block、怎么配的),`--data` 给的是**这一刻的数**。
|
|
10
|
+
// 之所以用 POST,是因为 block 的取数参数(聚合/筛选)可以放在请求体里。
|
|
11
|
+
// 2. `--create` 是 `write` 而不是 `high-risk-write`:它只往一个已有的库里加一块看板,
|
|
12
|
+
// 删掉就回到原样,改不了任何既有对象。所以只要幂等键,不要 `--yes`。
|
|
13
|
+
//
|
|
14
|
+
// 与 Rust 版逐条对齐的细节:
|
|
15
|
+
// * `--list` 的 `--base` 空值**不进 query**(`query_pair` 会滤掉 trim 后为空的);
|
|
16
|
+
// * `--create` 的 `key` 缺省由显示名推导(`deriveKey`),中文/数字开头推不出来时
|
|
17
|
+
// 退化成 `d<8 位随机 hex>` —— 而不是静默造一个客户第二天认不出的机器名;
|
|
18
|
+
// * `--data` 的响应里 `blocks` 与 `clipped_blocks` 各自独立取键:被裁掉的 block 是
|
|
19
|
+
// "你看不见它",不是"它不存在",这两个字段不能互相兜底。
|
|
20
|
+
|
|
21
|
+
import { CliError, exactlyOne } from '../core/error.js';
|
|
22
|
+
import { flagOn, flagValue } from '../core/args.js';
|
|
23
|
+
import { dataOf, idempotencyKey, requestSpec } from '../core/runtime.js';
|
|
24
|
+
import { randomHex } from '../core/util.js';
|
|
25
|
+
import { queryPairs } from '../core/values.js';
|
|
26
|
+
|
|
27
|
+
/** `dashboard --list|--show|--data|--create` 的分发。 */
|
|
28
|
+
export async function handleDashboard({ args, runtime }) {
|
|
29
|
+
const action = exactlyOne(
|
|
30
|
+
[
|
|
31
|
+
['--list', flagOn(args, '--list')],
|
|
32
|
+
['--show', flagValue(args, '--show') !== null],
|
|
33
|
+
['--data', flagValue(args, '--data') !== null],
|
|
34
|
+
['--create', flagOn(args, '--create')],
|
|
35
|
+
],
|
|
36
|
+
'dashboard',
|
|
37
|
+
'例:xinhe-oa-cli dashboard --list --json',
|
|
38
|
+
);
|
|
39
|
+
if (action === '--list') return list(runtime, args);
|
|
40
|
+
if (action === '--show') return show(runtime, flagValue(args, '--show'));
|
|
41
|
+
if (action === '--data') return data(runtime, flagValue(args, '--data'));
|
|
42
|
+
return create(runtime, args);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function list(runtime, args) {
|
|
46
|
+
// `--base` 只做过滤:空串/纯空白等于没给(`queryPairs` 与 Rust 的 `query_pair` 同义),
|
|
47
|
+
// 但**非空值原样发**(不 trim)—— 服务端按原值匹配。
|
|
48
|
+
const query = queryPairs([['base_id', flagValue(args, '--base')]]);
|
|
49
|
+
const response = await runtime.sendAuthed('GET', '/dashboards', requestSpec({ query }));
|
|
50
|
+
const data = dataOf(response);
|
|
51
|
+
runtime.finishFrom(response, {
|
|
52
|
+
dashboards: hasKey(data, 'dashboards') ? data.dashboards : [],
|
|
53
|
+
total: hasKey(data, 'total') ? data.total : null,
|
|
54
|
+
next_step: '取数:xinhe-oa-cli dashboard --data <DASHBOARD_ID> --json',
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function show(runtime, dashboardId) {
|
|
59
|
+
const response = await runtime.sendAuthed('GET', `/dashboards/${dashboardId}`, requestSpec());
|
|
60
|
+
// 定义(blocks / filters / layout)原样透传。
|
|
61
|
+
runtime.finishFrom(response, dataOf(response));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function data(runtime, dashboardId) {
|
|
65
|
+
const path = `/dashboards/${dashboardId}/data`;
|
|
66
|
+
// 空对象是**有意义的请求体**(不是"没有体"):服务端把 `{}` 读成"按看板自己的
|
|
67
|
+
// 配置取数",而缺体在 FastAPI 里会走另一条分支。
|
|
68
|
+
const spec = requestSpec({ body: {} });
|
|
69
|
+
if (runtime.dryRun) return runtime.dryRunReport('POST', path, spec);
|
|
70
|
+
|
|
71
|
+
const response = await runtime.sendAuthed('POST', path, spec);
|
|
72
|
+
const body = dataOf(response);
|
|
73
|
+
runtime.finishFrom(response, {
|
|
74
|
+
// 没有 `blocks` 键时用整个 data 兜底(老版本直出数组);有键就**如实**取它,
|
|
75
|
+
// 哪怕是 `null` —— 那表示"一个 block 都没算出来"。
|
|
76
|
+
blocks: hasKey(body, 'blocks') ? body.blocks : body,
|
|
77
|
+
clipped_blocks: hasKey(body, 'clipped_blocks') ? body.clipped_blocks : null,
|
|
78
|
+
note: '取数不是「看板授权」:每个 block 都按**你的**权限现算,看不见的被裁剪并说明原因',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function create(runtime, args) {
|
|
83
|
+
const baseId = flagValue(args, '--base');
|
|
84
|
+
if (baseId === null) {
|
|
85
|
+
throw CliError.usage('`dashboard --create` 需要 --base <BASE_ID>').withHint(
|
|
86
|
+
'看板属于某个库:xinhe-oa-cli bitable base --list --json',
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
const name = flagValue(args, '--name');
|
|
90
|
+
if (name === null) {
|
|
91
|
+
throw CliError.usage('`dashboard --create` 需要 --name <看板名>');
|
|
92
|
+
}
|
|
93
|
+
// 机器名创建后不可变,所以:显式 `--key` 优先;否则从显示名推导;推导不出来
|
|
94
|
+
// (中文/数字开头、空名)退化成随机名 —— 与 Rust 的 `d{random_hex[..8]}` 同形。
|
|
95
|
+
const key = flagValue(args, '--key') ?? deriveKey(name) ?? `d${randomHex().slice(0, 8)}`;
|
|
96
|
+
const body = { base_id: baseId, key, name };
|
|
97
|
+
const spec = requestSpec({
|
|
98
|
+
body,
|
|
99
|
+
idempotencyKey: idempotencyKey('dashboard --create', flagValue(args, '--idempotency-key')),
|
|
100
|
+
});
|
|
101
|
+
if (runtime.dryRun) return runtime.dryRunReport('POST', '/dashboards', spec);
|
|
102
|
+
|
|
103
|
+
const response = await runtime.sendAuthed('POST', '/dashboards', spec);
|
|
104
|
+
runtime.finishFrom(response, { dashboard: dataOf(response) });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 由显示名推导机器名 —— 与 Rust `bitable::derive_key` 逐条同义。
|
|
109
|
+
*
|
|
110
|
+
* 规则:只保留 ASCII 字母/数字(转小写),其余 ASCII 字符折成 `_`,
|
|
111
|
+
* 连续 `_` 合并、首尾去掉,截到 48 字符;**必须以小写字母开头**,否则算推导失败
|
|
112
|
+
* (返回 null)。中文名一个字符都不保留 —— 推不出来时由调用方决定怎么办,
|
|
113
|
+
* 这个函数绝不"尽力而为"地编一个名字出来。
|
|
114
|
+
*/
|
|
115
|
+
function deriveKey(name) {
|
|
116
|
+
let key = '';
|
|
117
|
+
for (const ch of String(name ?? '')) {
|
|
118
|
+
if (/^[A-Za-z0-9]$/.test(ch)) key += ch.toLowerCase();
|
|
119
|
+
else if (ch.codePointAt(0) < 128) key += '_';
|
|
120
|
+
}
|
|
121
|
+
key = key.replace(/_{2,}/g, '_').replace(/^_+|_+$/g, '');
|
|
122
|
+
if (key.length > 48) key = key.slice(0, 48);
|
|
123
|
+
if (key.length === 0 || !/^[a-z]/.test(key)) return null;
|
|
124
|
+
return key;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 键存在就取(`null` 也算存在)—— 对齐 Rust `Value::get`。 */
|
|
128
|
+
function hasKey(value, key) {
|
|
129
|
+
return (
|
|
130
|
+
value !== null && typeof value === 'object' && Object.prototype.hasOwnProperty.call(value, key)
|
|
131
|
+
);
|
|
132
|
+
}
|