@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
package/src/core/xlsx.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
// 最小 XLSX 读取器:只用 `node:zlib` 的 `inflateRawSync` 解 zip,自己解 XML。
|
|
2
|
+
//
|
|
3
|
+
// 为什么自己写而不是装 `xlsx` / `exceljs`:这个 CLI 要**零运行时依赖**地装进客户机器
|
|
4
|
+
// (`cli-node/package.json` 的 dependencies 是空的)。一个 xlsx 库带进来的是几十个包、
|
|
5
|
+
// 几十兆体积与一条供应链,而我们真正需要的只有三样东西:
|
|
6
|
+
//
|
|
7
|
+
// 1. `xl/sharedStrings.xml` —— 字符串单元格的真实取值(`t="s"` 的单元格只存下标);
|
|
8
|
+
// 2. `xl/workbook.xml` + `xl/_rels/workbook.xml.rels` —— 工作表名与它的 XML 路径
|
|
9
|
+
// (**默认读第一个工作表**,`--sheet` 按名精确匹配);
|
|
10
|
+
// 3. `xl/styles.xml` 的 `cellXfs` —— 每个样式用的 `numFmtId`,用来判断
|
|
11
|
+
// "这个数字单元格是不是日期"(Excel 的日期就是序列号 + 日期格式)。
|
|
12
|
+
//
|
|
13
|
+
// 日期按 Rust 版 `excel_serial_to_date` 的口径转(序列号 1 = 1900-01-01、
|
|
14
|
+
// 跳过 Excel 的 1900 假闰日 60、`>=60` 减 1),见 `core/import.js` —— 转换放在那边,
|
|
15
|
+
// 这里只负责把"这个单元格是日期格式"这个事实带出去。
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import zlib from 'node:zlib';
|
|
19
|
+
|
|
20
|
+
import { CliError } from './error.js';
|
|
21
|
+
import { rustIoErrorText } from './util.js';
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// zip(只读:中央目录 → 本地头 → inflateRaw)
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
const EOCD_SIG = 0x06054b50;
|
|
27
|
+
const CENTRAL_SIG = 0x02014b50;
|
|
28
|
+
const LOCAL_SIG = 0x04034b50;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 读 zip 里的若干条目。
|
|
32
|
+
*
|
|
33
|
+
* @returns {Map<string, Buffer>} 文件名 → 解压后的字节
|
|
34
|
+
*/
|
|
35
|
+
function readZipEntries(bytes, wanted) {
|
|
36
|
+
const eocd = findEocd(bytes);
|
|
37
|
+
if (eocd < 0) throw CliError.usage('这不是一个 zip(xlsx 就是 zip):找不到中央目录');
|
|
38
|
+
const total = bytes.readUInt16LE(eocd + 10);
|
|
39
|
+
let offset = bytes.readUInt32LE(eocd + 16);
|
|
40
|
+
const out = new Map();
|
|
41
|
+
for (let index = 0; index < total; index += 1) {
|
|
42
|
+
if (offset + 46 > bytes.length || bytes.readUInt32LE(offset) !== CENTRAL_SIG) break;
|
|
43
|
+
const method = bytes.readUInt16LE(offset + 10);
|
|
44
|
+
const compressedSize = bytes.readUInt32LE(offset + 20);
|
|
45
|
+
const nameLength = bytes.readUInt16LE(offset + 28);
|
|
46
|
+
const extraLength = bytes.readUInt16LE(offset + 30);
|
|
47
|
+
const commentLength = bytes.readUInt16LE(offset + 32);
|
|
48
|
+
const localOffset = bytes.readUInt32LE(offset + 42);
|
|
49
|
+
const name = bytes.toString('utf8', offset + 46, offset + 46 + nameLength);
|
|
50
|
+
offset += 46 + nameLength + extraLength + commentLength;
|
|
51
|
+
if (wanted !== null && !wanted.has(name)) continue;
|
|
52
|
+
if (compressedSize === 0xffffffff || localOffset === 0xffffffff) {
|
|
53
|
+
throw CliError.usage('这个 xlsx 用了 ZIP64(超大文件),本期读不了');
|
|
54
|
+
}
|
|
55
|
+
if (bytes.readUInt32LE(localOffset) !== LOCAL_SIG) {
|
|
56
|
+
throw CliError.usage('xlsx 的 zip 结构坏了:本地文件头签名不对');
|
|
57
|
+
}
|
|
58
|
+
const localNameLength = bytes.readUInt16LE(localOffset + 26);
|
|
59
|
+
const localExtraLength = bytes.readUInt16LE(localOffset + 28);
|
|
60
|
+
const start = localOffset + 30 + localNameLength + localExtraLength;
|
|
61
|
+
const raw = bytes.subarray(start, start + compressedSize);
|
|
62
|
+
if (method === 0) out.set(name, Buffer.from(raw));
|
|
63
|
+
else if (method === 8) {
|
|
64
|
+
try {
|
|
65
|
+
out.set(name, zlib.inflateRawSync(raw));
|
|
66
|
+
} catch (err) {
|
|
67
|
+
throw CliError.usage(`解不开 xlsx 里的 ${name}:${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
throw CliError.usage(`xlsx 用了不支持的压缩方式 ${method}(${name})`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 从尾部往回找 EOCD(注释最长 64KB,所以只搜最后 64KB+22 字节)。 */
|
|
77
|
+
function findEocd(bytes) {
|
|
78
|
+
const from = Math.max(0, bytes.length - (0xffff + 22));
|
|
79
|
+
for (let index = bytes.length - 22; index >= from; index -= 1) {
|
|
80
|
+
if (bytes.readUInt32LE(index) === EOCD_SIG) return index;
|
|
81
|
+
}
|
|
82
|
+
return -1;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// XML(够用就好的抽取:这些文件的结构由 Excel 生成,形状稳定)
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
function decodeXmlText(text) {
|
|
89
|
+
return String(text)
|
|
90
|
+
.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
|
|
91
|
+
.replace(/&#(\d+);/g, (_, digits) => String.fromCodePoint(Number.parseInt(digits, 10)))
|
|
92
|
+
.replace(/</g, '<')
|
|
93
|
+
.replace(/>/g, '>')
|
|
94
|
+
.replace(/"/g, '"')
|
|
95
|
+
.replace(/'/g, "'")
|
|
96
|
+
.replace(/&/g, '&');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** 取一个标签的属性(没有就是 null)。 */
|
|
100
|
+
function attr(tagText, name) {
|
|
101
|
+
const match = new RegExp(`\\s${name}\\s*=\\s*"([^"]*)"`).exec(tagText);
|
|
102
|
+
return match === null ? null : decodeXmlText(match[1]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** 取 `<tag ...>...</tag>` 的正文(非贪婪;这些文件里嵌套同名标签不存在)。 */
|
|
106
|
+
function bodyOf(xml, tag) {
|
|
107
|
+
const match = new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)</${tag}>`).exec(xml);
|
|
108
|
+
return match === null ? null : match[1];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// 三个部件
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
/** 共享字符串表:`<si>` 里可能有多段 `<t>`(富文本),拼起来才是完整取值。 */
|
|
115
|
+
function parseSharedStrings(xml) {
|
|
116
|
+
const out = [];
|
|
117
|
+
const items = xml.match(/<si(?:\s[^>]*)?>[\s\S]*?<\/si>|<si\s*\/>/g) ?? [];
|
|
118
|
+
for (const item of items) {
|
|
119
|
+
const texts = item.match(/<t(?:\s[^>]*)?>[\s\S]*?<\/t>|<t\s*\/>/g) ?? [];
|
|
120
|
+
out.push(texts.map((piece) => decodeXmlText(piece.replace(/^<t(?:\s[^>]*)?>|<\/t>$/g, ''))).join(''));
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 样式:`cellXfs` 里第 i 个 `<xf>` 的 `numFmtId`(单元格的 `s="i"` 指向它)。 */
|
|
126
|
+
function parseStyles(xml) {
|
|
127
|
+
const custom = new Map();
|
|
128
|
+
const numFmts = bodyOf(xml, 'numFmts');
|
|
129
|
+
if (numFmts !== null) {
|
|
130
|
+
for (const tag of numFmts.match(/<numFmt\s[^>]*\/?>/g) ?? []) {
|
|
131
|
+
const id = attr(tag, 'numFmtId');
|
|
132
|
+
const code = attr(tag, 'formatCode');
|
|
133
|
+
if (id !== null && code !== null) custom.set(Number(id), code);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const xfsBody = bodyOf(xml, 'cellXfs');
|
|
137
|
+
const numFmtIds = [];
|
|
138
|
+
if (xfsBody !== null) {
|
|
139
|
+
for (const tag of xfsBody.match(/<xf\s[^>]*?\/?>|<xf\s[^>]*?>/g) ?? []) {
|
|
140
|
+
const id = attr(tag, 'numFmtId');
|
|
141
|
+
numFmtIds.push(id === null ? 0 : Number(id));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return { numFmtIds, custom };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 内置日期格式 id(ECMA-376 §18.8.30)。
|
|
149
|
+
*
|
|
150
|
+
* 14–22 是标准里的日期/时间格式;27–36 与 50–58 是 CJK 区域的日期格式
|
|
151
|
+
* (Excel 中文版把 `yyyy/m/d` 之类落在这些 id 上 —— 只认 14–22 会把
|
|
152
|
+
* 中文环境导出的日期列全部当数字)。45–47 是时间/时长。
|
|
153
|
+
*/
|
|
154
|
+
const BUILTIN_DATE_FORMATS = new Set([
|
|
155
|
+
14, 15, 16, 17, 18, 19, 20, 21, 22, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 45, 46, 47, 50, 51,
|
|
156
|
+
52, 53, 54, 55, 56, 57, 58,
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
/** 自定义格式里只要出现未被引号包住的 y/m/d/h/s,就当日期(`0.00` 这类不是)。 */
|
|
160
|
+
function looksLikeDateFormat(code) {
|
|
161
|
+
const stripped = String(code ?? '')
|
|
162
|
+
.replace(/"[^"]*"/g, '')
|
|
163
|
+
.replace(/\[[^\]]*\]/g, '')
|
|
164
|
+
.replace(/\\./g, '');
|
|
165
|
+
return /[ymdhs]/i.test(stripped);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function isDateStyle(styles, styleIndex) {
|
|
169
|
+
if (styleIndex === null || styleIndex === undefined) return false;
|
|
170
|
+
const numFmtId = styles.numFmtIds[styleIndex];
|
|
171
|
+
if (numFmtId === undefined) return false;
|
|
172
|
+
if (BUILTIN_DATE_FORMATS.has(numFmtId)) return true;
|
|
173
|
+
const code = styles.custom.get(numFmtId);
|
|
174
|
+
return code === undefined ? false : looksLikeDateFormat(code);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** `xl/workbook.xml` + rels:按声明顺序给出 `[{name, target}]`。 */
|
|
178
|
+
function parseSheets(workbookXml, relsXml, entryNames) {
|
|
179
|
+
const body = bodyOf(workbookXml, 'sheets') ?? workbookXml;
|
|
180
|
+
const sheets = [];
|
|
181
|
+
for (const tag of body.match(/<sheet\s[^>]*\/?>/g) ?? []) {
|
|
182
|
+
const name = attr(tag, 'name');
|
|
183
|
+
const relId = attr(tag, 'r:id') ?? attr(tag, 'id');
|
|
184
|
+
let target = null;
|
|
185
|
+
if (relsXml !== null && relId !== null) {
|
|
186
|
+
for (const rel of relsXml.match(/<Relationship\s[^>]*\/?>/g) ?? []) {
|
|
187
|
+
if (attr(rel, 'Id') === relId) {
|
|
188
|
+
target = attr(rel, 'Target');
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (target === null) {
|
|
194
|
+
// 关系文件缺失/对不上时的兜底:按 sheet 的出现顺序猜 `sheetN.xml`。
|
|
195
|
+
const guess = `xl/worksheets/sheet${sheets.length + 1}.xml`;
|
|
196
|
+
target = entryNames.has(guess) ? guess : null;
|
|
197
|
+
} else {
|
|
198
|
+
target = target.replace(/^\//, '').replace(/^xl\//, 'xl/');
|
|
199
|
+
if (!target.startsWith('xl/')) target = `xl/${target}`;
|
|
200
|
+
if (!entryNames.has(target)) target = null;
|
|
201
|
+
}
|
|
202
|
+
sheets.push({ name: name ?? `Sheet${sheets.length + 1}`, target });
|
|
203
|
+
}
|
|
204
|
+
return sheets;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 列引用 `BC12` → 列下标(0 起)。 */
|
|
208
|
+
function columnOf(reference) {
|
|
209
|
+
const letters = /^([A-Z]+)/.exec(String(reference ?? ''))?.[1] ?? '';
|
|
210
|
+
let index = 0;
|
|
211
|
+
for (const ch of letters) index = index * 26 + (ch.charCodeAt(0) - 64);
|
|
212
|
+
return index - 1;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** 单元格取值 → 字符串(**与 Rust 的 `cell_to_string` 同一口径**)。 */
|
|
216
|
+
function cellToString(cell, sharedStrings, styles, formatFloat, serialToDate) {
|
|
217
|
+
const type = attr(cell, 't');
|
|
218
|
+
const styleIndexRaw = attr(cell, 's');
|
|
219
|
+
const styleIndex = styleIndexRaw === null ? null : Number(styleIndexRaw);
|
|
220
|
+
const valueBody = bodyOf(cell, 'v');
|
|
221
|
+
const value = valueBody === null ? null : decodeXmlText(valueBody);
|
|
222
|
+
|
|
223
|
+
if (type === 'inlineStr') {
|
|
224
|
+
const inline = bodyOf(cell, 'is');
|
|
225
|
+
const texts = inline === null ? [] : inline.match(/<t(?:\s[^>]*)?>[\s\S]*?<\/t>|<t\s*\/>/g) ?? [];
|
|
226
|
+
return texts.map((piece) => decodeXmlText(piece.replace(/^<t(?:\s[^>]*)?>|<\/t>$/g, ''))).join('').trim();
|
|
227
|
+
}
|
|
228
|
+
if (type === 's') {
|
|
229
|
+
if (value === null) return '';
|
|
230
|
+
const text = sharedStrings[Number(value)];
|
|
231
|
+
return text === undefined ? '' : text.trim();
|
|
232
|
+
}
|
|
233
|
+
if (type === 'str') return value === null ? '' : value.trim();
|
|
234
|
+
if (type === 'b') return value === '1' ? 'true' : 'false';
|
|
235
|
+
if (type === 'e') return `#错误(${value ?? ''})`;
|
|
236
|
+
if (type === 'd') return value === null ? '' : value; // ISO 日期(少见的写法)
|
|
237
|
+
if (value === null || value === '') return '';
|
|
238
|
+
const number = Number(value);
|
|
239
|
+
if (!Number.isFinite(number)) return value;
|
|
240
|
+
if (isDateStyle(styles, styleIndex)) {
|
|
241
|
+
const date = serialToDate(number);
|
|
242
|
+
if (date !== null) return date;
|
|
243
|
+
}
|
|
244
|
+
return formatFloat(number);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/** 读一个工作表 → 行 × 列的字符串网格(缺的格子是空串)。 */
|
|
248
|
+
function parseWorksheet(xml, sharedStrings, styles, helpers) {
|
|
249
|
+
const rows = [];
|
|
250
|
+
const sheetData = bodyOf(xml, 'sheetData');
|
|
251
|
+
if (sheetData === null) return rows;
|
|
252
|
+
for (const rowTag of sheetData.match(/<row(?:\s[^>]*)?>[\s\S]*?<\/row>|<row\s[^>]*\/>/g) ?? []) {
|
|
253
|
+
const cells = [];
|
|
254
|
+
for (const cellTag of rowTag.match(/<c(?:\s[^>]*)?>[\s\S]*?<\/c>|<c\s[^>]*\/>/g) ?? []) {
|
|
255
|
+
const at = columnOf(attr(cellTag, 'r'));
|
|
256
|
+
const text = cellToString(cellTag, sharedStrings, styles, helpers.formatFloat, helpers.serialToDate);
|
|
257
|
+
if (at < 0) cells.push(text);
|
|
258
|
+
else cells[at] = text;
|
|
259
|
+
}
|
|
260
|
+
for (let index = 0; index < cells.length; index += 1) {
|
|
261
|
+
if (cells[index] === undefined) cells[index] = '';
|
|
262
|
+
}
|
|
263
|
+
rows.push(cells);
|
|
264
|
+
}
|
|
265
|
+
return rows;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// 对外入口
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
/**
|
|
272
|
+
* 读 xlsx:默认第一个工作表,`sheet` 按名精确匹配。
|
|
273
|
+
*
|
|
274
|
+
* @param {string} file
|
|
275
|
+
* @param {string|null} sheet
|
|
276
|
+
* @param {{formatFloat: (n:number)=>string, serialToDate: (n:number)=>string|null}} helpers
|
|
277
|
+
* 数值/日期的渲染由 `core/import.js` 提供 —— 那里才是有 Rust 口径注释的地方。
|
|
278
|
+
* @returns {{sheetName: string, grid: string[][]}}
|
|
279
|
+
*/
|
|
280
|
+
export function readXlsx(file, sheet, helpers) {
|
|
281
|
+
let bytes;
|
|
282
|
+
try {
|
|
283
|
+
bytes = fs.readFileSync(file);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
throw CliError.usage(`打不开 xlsx ${file}:${rustIoErrorText(err)}`);
|
|
286
|
+
}
|
|
287
|
+
// 一次把要用的部件都解出来(内部条目名固定)。
|
|
288
|
+
const entries = readZipEntries(bytes, null);
|
|
289
|
+
const names = new Set(entries.keys());
|
|
290
|
+
const workbookXml = entries.get('xl/workbook.xml')?.toString('utf8') ?? null;
|
|
291
|
+
if (workbookXml === null) {
|
|
292
|
+
throw CliError.usage(`打不开 xlsx ${file}:压缩包里没有 xl/workbook.xml`);
|
|
293
|
+
}
|
|
294
|
+
const relsXml = entries.get('xl/_rels/workbook.xml.rels')?.toString('utf8') ?? null;
|
|
295
|
+
const sheets = parseSheets(workbookXml, relsXml, names);
|
|
296
|
+
if (sheets.length === 0) throw CliError.usage(`xlsx ${file} 里一个工作表都没有`);
|
|
297
|
+
let chosen;
|
|
298
|
+
if (sheet === null || sheet === undefined) {
|
|
299
|
+
[chosen] = sheets;
|
|
300
|
+
} else {
|
|
301
|
+
chosen = sheets.find((item) => item.name === sheet) ?? null;
|
|
302
|
+
if (chosen === null) {
|
|
303
|
+
throw CliError.usage(`xlsx 里没有工作表 ${JSON.stringify(sheet)}`).withDetails({
|
|
304
|
+
sheets: sheets.map((item) => item.name),
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (chosen.target === null) {
|
|
309
|
+
throw CliError.usage(`读不了工作表 ${JSON.stringify(chosen.name)}:找不到它的 XML`);
|
|
310
|
+
}
|
|
311
|
+
const sheetXml = entries.get(chosen.target)?.toString('utf8') ?? null;
|
|
312
|
+
if (sheetXml === null) {
|
|
313
|
+
throw CliError.usage(`读不了工作表 ${JSON.stringify(chosen.name)}:${chosen.target} 不在压缩包里`);
|
|
314
|
+
}
|
|
315
|
+
const sharedStrings = parseSharedStrings(
|
|
316
|
+
entries.get('xl/sharedStrings.xml')?.toString('utf8') ?? '',
|
|
317
|
+
);
|
|
318
|
+
const styles = parseStyles(entries.get('xl/styles.xml')?.toString('utf8') ?? '');
|
|
319
|
+
const grid = parseWorksheet(sheetXml, sharedStrings, styles, helpers);
|
|
320
|
+
return { sheetName: chosen.name, grid };
|
|
321
|
+
}
|
package/src/main.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// CLI 入口:解析 → 建运行时 → 分发 → **退出码统一收口**(契约 §3.2)。
|
|
2
|
+
//
|
|
3
|
+
// 退出码是判卷与脚本读的第一样东西,所以它只在这里算一次:
|
|
4
|
+
// 0 成功 · 2 用法 · 3 未登录 · 4 权限 · 5 冲突 · 6 服务端 · 7 网络 · 8 版本过旧。
|
|
5
|
+
|
|
6
|
+
import { CliError, EXIT_OK, EXIT_SERVER, Kind } from './core/error.js';
|
|
7
|
+
import { parseArgv, commandSurface } from './core/args.js';
|
|
8
|
+
import { Runtime } from './core/runtime.js';
|
|
9
|
+
import { Format, Meta, parseFormat, renderFailure } from './core/output.js';
|
|
10
|
+
import { commandLabel, handlerFor } from './commands/index.js';
|
|
11
|
+
import { VERSION } from './version.js';
|
|
12
|
+
|
|
13
|
+
const ABOUT = '心河 OA 客户端:多维表格 / 应用 / 权限的唯一命令行入口';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 跑一次 CLI。
|
|
17
|
+
*
|
|
18
|
+
* @param {string[]} argv 不含 `node` 与脚本名的参数
|
|
19
|
+
* @returns {Promise<number>} 退出码
|
|
20
|
+
*/
|
|
21
|
+
export async function runCli(argv) {
|
|
22
|
+
let parsed = null;
|
|
23
|
+
let format = Format.Table;
|
|
24
|
+
try {
|
|
25
|
+
parsed = parseArgv(argv);
|
|
26
|
+
format = parsed.globals.format
|
|
27
|
+
? parseFormat(parsed.globals.format)
|
|
28
|
+
: parsed.globals.json
|
|
29
|
+
? Format.Json
|
|
30
|
+
: Format.Table;
|
|
31
|
+
} catch (err) {
|
|
32
|
+
// 连 `--json` 都没解析出来时的兜底:`--format json` 还认得出就按 JSON 报错。
|
|
33
|
+
const guess = guessFormat(argv);
|
|
34
|
+
return reportFailure(err, guess ?? format, labelFromArgv(argv));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const { globals } = parsed;
|
|
38
|
+
|
|
39
|
+
if (globals.version && parsed.command === null) {
|
|
40
|
+
process.stdout.write(`xinhe-oa-cli ${VERSION}\n`);
|
|
41
|
+
return EXIT_OK;
|
|
42
|
+
}
|
|
43
|
+
if (globals.help || parsed.command === null) {
|
|
44
|
+
process.stdout.write(helpText(parsed.command?.name ?? null));
|
|
45
|
+
return EXIT_OK;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// `meta.command` 在**成功**路径上比失败路径更细:Rust 是把
|
|
49
|
+
// `Runtime::new(global, "<细标签>")` 里那个字符串写进 meta 的
|
|
50
|
+
// (`config --list` / `login --phone --code` / `report rule`),
|
|
51
|
+
// 而失败路径用的是 `main.rs::command_label()` 的域标签(`config` / `login`)。
|
|
52
|
+
const label = commandLabel(parsed.command.name);
|
|
53
|
+
// 失败路径的 `meta.command`:Rust 的 `main.rs::command_label()` 对 `Command::Login`
|
|
54
|
+
// 用的是 `format!("login {}", args.kind())`(**从参数推**,见 `LoginArgs::kind()`),
|
|
55
|
+
// 其余域就是域名。与成功路径的细标签在这里恰好一致。
|
|
56
|
+
const failureLabel = label === 'login' ? runtimeLabel(parsed.command.name) : label;
|
|
57
|
+
let runtime;
|
|
58
|
+
try {
|
|
59
|
+
runtime = new Runtime(globals, runtimeLabel(parsed.command.name));
|
|
60
|
+
} catch (err) {
|
|
61
|
+
return reportFailure(err, format, failureLabel);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const handler = handlerFor(parsed.command.name);
|
|
65
|
+
if (handler === null) {
|
|
66
|
+
return reportFailure(
|
|
67
|
+
CliError.usage(`这条命令在 Node 版上还没实现:${parsed.command.name}`).withHint(
|
|
68
|
+
'先用 Rust 版:https://github.com/xinhe-oa/cli;或跑 `xinhe-oa-cli schema` 看已实现的面',
|
|
69
|
+
),
|
|
70
|
+
format,
|
|
71
|
+
failureLabel,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
await handler({ args: parsed.args, positional: parsed.positional, runtime, command: parsed.command });
|
|
77
|
+
return EXIT_OK;
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return reportFailure(err, format, failureLabel);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 失败出口:按格式渲染,返回退出码。 */
|
|
84
|
+
function reportFailure(err, format, label) {
|
|
85
|
+
const error =
|
|
86
|
+
err instanceof CliError
|
|
87
|
+
? err
|
|
88
|
+
: new CliError(Kind.Server, 'cli.internal', `内部错误:${err?.message ?? String(err)}`);
|
|
89
|
+
if (!(err instanceof CliError) && err?.stack) {
|
|
90
|
+
// 非预期异常(代码缺陷)与"用法错误"必须一眼分得开:把栈打到 stderr,
|
|
91
|
+
// 但**不进** `--json` 的 error 段(那是对外契约,形状不能变)。
|
|
92
|
+
process.stderr.write(`${err.stack}\n`);
|
|
93
|
+
}
|
|
94
|
+
// 失败路径的 `meta` 也要如实:`request_id` 取服务端回显的那个(判卷靠它与服务端
|
|
95
|
+
// 审计对齐),`profile` 取真实 profile 而不是写死 default。
|
|
96
|
+
const meta = new Meta({
|
|
97
|
+
command: label,
|
|
98
|
+
cliVersion: VERSION,
|
|
99
|
+
requestId: error.requestId ?? null,
|
|
100
|
+
profile: error.profile ?? process.env.XINHE_OA_PROFILE ?? 'default',
|
|
101
|
+
});
|
|
102
|
+
try {
|
|
103
|
+
renderFailure(error, meta, format);
|
|
104
|
+
} catch {
|
|
105
|
+
process.stderr.write(`✗ ${error.code}: ${error.message}\n`);
|
|
106
|
+
}
|
|
107
|
+
// 退出码表里没有 1:任何没归类的意外都算服务端/内部错误(6)。
|
|
108
|
+
return error.exitCode ?? EXIT_SERVER;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 参数都没解析出来时,尽量猜出"要不要 JSON"(否则报错格式会跟用户要的相反)。 */
|
|
112
|
+
function guessFormat(argv) {
|
|
113
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
114
|
+
const token = argv[index];
|
|
115
|
+
if (token === '--json') return Format.Json;
|
|
116
|
+
if (token === '--format' || token.startsWith('--format=')) {
|
|
117
|
+
const raw = token.includes('=') ? token.split('=').slice(1).join('=') : argv[index + 1];
|
|
118
|
+
if (raw) {
|
|
119
|
+
try {
|
|
120
|
+
return parseFormat(raw);
|
|
121
|
+
} catch {
|
|
122
|
+
return Format.Table;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* 成功路径的 `meta.command`(对齐 Rust 的 `Runtime::new(global, …)` 实参)。
|
|
132
|
+
*
|
|
133
|
+
* 只有三处与域标签不同,其余就是域名本身:
|
|
134
|
+
* * `config --list|--show|--set|--use` → 各自的名字;
|
|
135
|
+
* * `login …` → `login --phone` / `login --phone --code` / `login --password` /
|
|
136
|
+
* `login --adopt-session`(由参数推,见 Rust `LoginArgs::kind()`);
|
|
137
|
+
* * `bitable base|table|…` 与 `report rule` 本来就是域名形式。
|
|
138
|
+
*/
|
|
139
|
+
function runtimeLabel(specName) {
|
|
140
|
+
const domain = commandLabel(specName);
|
|
141
|
+
// `config` 与 `login` 是仅有的两处"细标签";其余就是域名本身
|
|
142
|
+
// (`bitable base` / `report rule` 已经带子资源)。
|
|
143
|
+
if (specName.startsWith('config ')) return specName;
|
|
144
|
+
if (domain === 'login') return specName;
|
|
145
|
+
return domain;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** 从 argv 里取出域名,好在解析失败时也能给出 `meta.command`。 */
|
|
149
|
+
function labelFromArgv(argv) {
|
|
150
|
+
for (const token of argv) {
|
|
151
|
+
if (token.startsWith('-')) continue;
|
|
152
|
+
const surface = commandSurface();
|
|
153
|
+
const hit = surface.commands.find(
|
|
154
|
+
(entry) => entry.name === token || entry.name.startsWith(`${token} `),
|
|
155
|
+
);
|
|
156
|
+
if (hit) return commandLabel(hit.name);
|
|
157
|
+
return token;
|
|
158
|
+
}
|
|
159
|
+
return '';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 顶层帮助(`--help` / 无参数)。 */
|
|
163
|
+
function helpText(commandName) {
|
|
164
|
+
const surface = commandSurface();
|
|
165
|
+
const lines = [];
|
|
166
|
+
if (commandName === null) {
|
|
167
|
+
lines.push(`xinhe-oa-cli ${VERSION} —— ${ABOUT}`);
|
|
168
|
+
lines.push('');
|
|
169
|
+
lines.push('用法:xinhe-oa-cli <域> [<资源>] --<动作> [旗标]');
|
|
170
|
+
lines.push('');
|
|
171
|
+
lines.push(`全部命令(${surface.commands.length} 条,用 \`xinhe-oa-cli schema --json\` 取机器可读版):`);
|
|
172
|
+
const seen = new Set();
|
|
173
|
+
for (const entry of surface.commands) {
|
|
174
|
+
const label = commandLabel(entry.name);
|
|
175
|
+
if (seen.has(label)) continue;
|
|
176
|
+
seen.add(label);
|
|
177
|
+
lines.push(` ${label}`);
|
|
178
|
+
}
|
|
179
|
+
lines.push('');
|
|
180
|
+
lines.push('全局旗标:--base-url --profile --json --dry-run --yes --timeout -v/--verbose --quiet --no-color --format table|md|json');
|
|
181
|
+
lines.push('');
|
|
182
|
+
lines.push('最短上手:');
|
|
183
|
+
lines.push(' xinhe-oa-cli config --set base_url=<后端地址>');
|
|
184
|
+
lines.push(' xinhe-oa-cli login --phone <号码>');
|
|
185
|
+
lines.push(' xinhe-oa-cli me --json');
|
|
186
|
+
return `${lines.join('\n')}\n`;
|
|
187
|
+
}
|
|
188
|
+
const matched = surface.commands.filter(
|
|
189
|
+
(entry) => entry.name === commandName || entry.name.startsWith(`${commandName} `),
|
|
190
|
+
);
|
|
191
|
+
lines.push(`${commandName} —— ${matched.length} 条命令`);
|
|
192
|
+
lines.push('');
|
|
193
|
+
for (const entry of matched) {
|
|
194
|
+
lines.push(` ${entry.name} [${entry.risk}]`);
|
|
195
|
+
if (entry.params.length > 0) {
|
|
196
|
+
for (const param of entry.params) {
|
|
197
|
+
const value = param.value ? ` <${param.value}>` : '';
|
|
198
|
+
const optional = param.optional ? `[${param.flag}${value}]` : `${param.flag}${value}`;
|
|
199
|
+
lines.push(` ${optional}${param.variadic ? '...' : ''}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
if (entry.examples.length > 0) lines.push(` 例:${entry.examples[0]}`);
|
|
203
|
+
}
|
|
204
|
+
return `${lines.join('\n')}\n`;
|
|
205
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# xinhe-oa-cli 自举说明
|
|
2
|
+
|
|
3
|
+
你(LLM)在客户机器上干活只有一条路:`xinhe-oa-cli`。
|
|
4
|
+
令牌由 CLI 自己维护(0600 的凭据文件),**你看不到令牌,也不需要**。
|
|
5
|
+
|
|
6
|
+
## 最短路径
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
xinhe-oa-cli config --set base_url=<后端地址>
|
|
10
|
+
xinhe-oa-cli login --phone <号码> # 发码
|
|
11
|
+
xinhe-oa-cli login --phone <号码> --code <码> # 换凭据(CLI 落盘)
|
|
12
|
+
xinhe-oa-cli me --json # 我是谁、能做什么、缺什么
|
|
13
|
+
xinhe-oa-cli bitable base --list --json # 看有什么
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## 纪律
|
|
17
|
+
|
|
18
|
+
- 一切通过**命令面**:没有任意路径、没有 SQL、没有绕过口。
|
|
19
|
+
- 写操作自动带幂等键;`--dry-run` 只打印将发送的请求。
|
|
20
|
+
- 高风险命令(`record --delete`、`application --copy|--install`、`workflow --run`、`event --replay`)**必须** `--yes`。
|
|
21
|
+
- 退出码即结论:3=要重新登录、4=缺权限(看 `error.details.missing_scopes`)、5=冲突(重读再写)。
|
|
22
|
+
- 版本过旧(退出码 8 / `cli.too_old`)→ `xinhe-oa-cli doctor --upgrade`:用已有登录凭据下载 + sha256 校验 + 原地替换;**退出码 0 不代表升成功**(看 `data.upgrade.replaced` 或 `--version`),升完重开终端再重试原命令。先 `doctor --upgrade --dry-run` 预演(只问一次只读的 `/cli/latest`,**不下载、不替换**);只想把产物写到别处用 `doctor --upgrade --out <路径>`(**不动当前这个可执行文件**,且与本机是不是最新无关)。
|
|
23
|
+
- 每个错误都带 `error.hint`:那就是下一步该敲的命令。
|
|
24
|
+
- 长任务(导入 / 全表翻页)默认往 **stderr** 打进度;脚本里用全局 `--quiet` 关掉(告警不受影响)。导入可 `--workers N` 并发发批(默认 3),**断了直接重跑同一条命令**:checkpoint 只补剩余批次(`--fresh` 才从头来);读表 `--all --workers N` 并发取页。
|
|
25
|
+
- 用 `--json` 解析,别去刮表格文本。
|
|
26
|
+
|
|
27
|
+
## 企业里的常见活
|
|
28
|
+
|
|
29
|
+
业务员要「上传一份单据」= 建表 + 灌数据,一条命令:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
xinhe-oa-cli bitable table --import --file 公账流水.csv \
|
|
33
|
+
--base-name 开叔企业 --name 公账流水 --keep-source
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
客户端负责解析(CSV/XLSX → 推断字段类型 → 建表 → 建字段 → 分批写记录),
|
|
37
|
+
平台只存结果 —— 业务计算全在客户端。
|
|
38
|
+
|
|
39
|
+
## 造技能 / 分享 / 审批(技能即应用)
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
xinhe-oa-cli skills --create --name 财务月结 --readme ./SKILL.md --json # 默认 private
|
|
43
|
+
xinhe-oa-cli skills --share 财务月结 --to-user usr_x --json # 点对点,直接生效
|
|
44
|
+
xinhe-oa-cli skills --conflicts --name 财务月结 --json # 先扫冲突
|
|
45
|
+
xinhe-oa-cli skills --publish 财务月结 --visibility tenant \
|
|
46
|
+
--on-conflict rename --new-key finance_monthly_v2 --json # 提交企业审批
|
|
47
|
+
xinhe-oa-cli approval --list --to-review --json # 企业负责人看这个
|
|
48
|
+
xinhe-oa-cli approval --approve apr_x --yes --json # 高风险:必须 --yes
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
- 冲突**绝不自动选**:`--conflicts` / `--publish` 撞上冲突就退出码 5,并列出冲突项的
|
|
52
|
+
key / name / 创建人 / 更新时间 / 撞的是 key 还是 name;你必须显式选
|
|
53
|
+
`--on-conflict rename --new-key <k>` 或 `--on-conflict update`。
|
|
54
|
+
- 内置名(`xo-core` / `xo-admin`)不可覆盖:直接报 `skill.name_reserved`,换个名字。
|
|
55
|
+
- `approval --list --to-review` 在非企业负责人身份下返回**空列表是正常的**,不是错误。
|