@xiaoyuanhao/yunzhi-cli 2.0.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/lib/arguments.cjs +37 -0
- package/lib/auth.cjs +193 -0
- package/lib/business-schemas.cjs +24 -0
- package/lib/business.cjs +57 -0
- package/lib/cli.cjs +184 -0
- package/lib/command-guidance.cjs +376 -0
- package/lib/command-reference.cjs +32 -0
- package/lib/cos-upload.cjs +93 -0
- package/lib/crc64.cjs +19 -0
- package/lib/diagnostics.cjs +29 -0
- package/lib/download.cjs +80 -0
- package/lib/gateway.cjs +124 -0
- package/lib/learning-tool-archive.cjs +275 -0
- package/lib/learning-tool.cjs +82 -0
- package/lib/lesson-prep.cjs +73 -0
- package/lib/report-schemas.cjs +22 -0
- package/lib/reports.cjs +43 -0
- package/lib/result.cjs +73 -0
- package/lib/session.cjs +24 -0
- package/lib/storage.cjs +122 -0
- package/lib/suggestions.cjs +46 -0
- package/lib/upload.cjs +76 -0
- package/lib/version.cjs +3 -0
- package/package.json +18 -0
- package/yunzhi-cli.cjs +13 -0
package/lib/download.cjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
const { attach, cleanup } = require('./diagnostics.cjs');
|
|
2
|
+
const fs = require('node:fs/promises');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { Failure, optionalFields } = require('./result.cjs');
|
|
5
|
+
const { options, required } = require('./arguments.cjs');
|
|
6
|
+
const { session } = require('./session.cjs');
|
|
7
|
+
const { version } = require('./version.cjs');
|
|
8
|
+
const { request, publicResult } = require('./gateway.cjs');
|
|
9
|
+
const local = (message, extra) => new Failure(5, 'local', 'storage_error', message, extra);
|
|
10
|
+
const network = error => {
|
|
11
|
+
const failure = new Failure(4, 'network', 'download_failed', '原文件下载失败。');
|
|
12
|
+
return error ? attach(failure, error) : failure;
|
|
13
|
+
};
|
|
14
|
+
const exists = target => new Failure(5, 'local', 'output_already_exists', '本地目标文件已经存在,本次未覆盖。', { local_path: target, hint: '报告实际 local_path,由用户决定另存或覆盖;不要自动改名或覆盖。' });
|
|
15
|
+
|
|
16
|
+
async function download(namespace, args) {
|
|
17
|
+
const parsed = options(args, ['file', 'output'], ['overwrite'], ['file', 'output']);
|
|
18
|
+
required(parsed, 'file');
|
|
19
|
+
const { config, token } = await session('school-library:read');
|
|
20
|
+
const envelope = await request(config, namespace + '/download', token, { file: parsed.file });
|
|
21
|
+
const data = envelope.data;
|
|
22
|
+
try {
|
|
23
|
+
const url = new URL(data.download_url);
|
|
24
|
+
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password || url.hash ||
|
|
25
|
+
typeof data.name !== 'string' || !data.name || ['.', '..'].includes(data.name) || /[/\\\0]/.test(data.name) ||
|
|
26
|
+
!Number.isSafeInteger(data.size_bytes) || data.size_bytes < 0) throw new Error();
|
|
27
|
+
} catch (error) { throw attach(new Failure(1, 'api', 'invalid_response', 'Agent Gateway 返回了无效下载响应。'), error); }
|
|
28
|
+
let target;
|
|
29
|
+
try { target = path.resolve(parsed.output || data.name); } catch (error) { throw attach(local('无法解析本地目标路径。'), error); }
|
|
30
|
+
// Validate the public response before creating or replacing any user file.
|
|
31
|
+
const result = publicResult({ ...envelope, data: { ...data, local_path: target } }, {
|
|
32
|
+
...optionalFields({ file_id: 'string', kind: 'string', space: 'string', name: 'string', path: 'string', file_ext: 'string', size_bytes: 'integer' }), local_path: 'string',
|
|
33
|
+
});
|
|
34
|
+
try {
|
|
35
|
+
const info = await fs.lstat(target);
|
|
36
|
+
if (!parsed.overwrite) throw exists(target);
|
|
37
|
+
if (!info.isFile() || info.isSymbolicLink()) throw local('覆盖目标必须是普通文件。');
|
|
38
|
+
} catch (error) { if (error instanceof Failure) throw error; if (error.code !== 'ENOENT') throw attach(local('无法检查本地目标文件。'), error); }
|
|
39
|
+
let handle, opened = false, primaryError;
|
|
40
|
+
try {
|
|
41
|
+
try { handle = await fs.open(target, parsed.overwrite ? 'w' : 'wx'); opened = true; }
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (!parsed.overwrite && error.code === 'EEXIST') throw attach(exists(target), error);
|
|
44
|
+
throw attach(local('无法打开本地目标文件。'), error);
|
|
45
|
+
}
|
|
46
|
+
let response;
|
|
47
|
+
try { response = await fetch(data.download_url, { headers: { 'User-Agent': 'yunzhi-cli/' + version }, signal: AbortSignal.timeout(25 * 60 * 1000) }); }
|
|
48
|
+
catch (error) { throw network(error); }
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
const failure = network(new Error('HTTP ' + response.status + ' ' + response.statusText));
|
|
51
|
+
try { await response.body?.cancel(); } catch (error) { cleanup(failure, error); }
|
|
52
|
+
throw failure;
|
|
53
|
+
}
|
|
54
|
+
let size = 0;
|
|
55
|
+
try {
|
|
56
|
+
for await (const chunk of response.body) {
|
|
57
|
+
size += chunk.length;
|
|
58
|
+
if (size > data.size_bytes) throw network(new Error('Download exceeds expected size: ' + size + ' > ' + data.size_bytes));
|
|
59
|
+
try { await handle.writeFile(chunk); } catch (error) { throw attach(local('无法写入本地文件。'), error); }
|
|
60
|
+
}
|
|
61
|
+
} catch (error) { if (error instanceof Failure) throw error; throw network(error); }
|
|
62
|
+
if (size !== data.size_bytes) throw network(new Error('Download size mismatch: expected ' + data.size_bytes + ', received ' + size));
|
|
63
|
+
try { await handle.sync(); await handle.close(); handle = undefined; }
|
|
64
|
+
catch (error) { throw attach(local('无法写入本地文件。'), error); }
|
|
65
|
+
return result;
|
|
66
|
+
} catch (error) {
|
|
67
|
+
primaryError = error;
|
|
68
|
+
if (opened && error instanceof Failure) Object.assign(error.result.value.error, { local_path: target, partial_file: true });
|
|
69
|
+
throw error;
|
|
70
|
+
} finally {
|
|
71
|
+
if (handle) {
|
|
72
|
+
try { await handle.close(); }
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (primaryError) cleanup(primaryError, error);
|
|
75
|
+
else throw attach(local('无法关闭本地文件。', { local_path: target, partial_file: true }), error);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
module.exports = { download };
|
package/lib/gateway.cjs
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
const { detail, attach } = require('./diagnostics.cjs');
|
|
2
|
+
const { Failure, internal, fields, optional } = require('./result.cjs');
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const { randomBytes } = require('node:crypto');
|
|
5
|
+
const { Readable } = require('node:stream');
|
|
6
|
+
|
|
7
|
+
const learningToolUploadTimeoutMs = 25 * 60 * 1000;
|
|
8
|
+
|
|
9
|
+
function gatewayURL(config) {
|
|
10
|
+
const raw = (config.gateway_url?.trim() || process.env.YUNZHI_GATEWAY_URL?.trim() ||
|
|
11
|
+
'https://opc.api.xiaoyuanhao.com');
|
|
12
|
+
try {
|
|
13
|
+
// Check before URL normalization removes dot segments.
|
|
14
|
+
const pathname = raw.replace(/^https?:\/\/[^/]+/, '');
|
|
15
|
+
if (/[?#]/.test(raw) || /\/\//.test(pathname) || /(?:^|\/)\.{1,2}(?:\/|$)/.test(decodeURIComponent(pathname))) throw new Error();
|
|
16
|
+
const url = new URL(raw);
|
|
17
|
+
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password) throw new Error();
|
|
18
|
+
return url.href.replace(/\/$/, '');
|
|
19
|
+
} catch (error) { throw internal('validate_gateway_url', '本地 Gateway URL 无效。', error); }
|
|
20
|
+
}
|
|
21
|
+
async function execute(scope, endpoint, payload, schema) {
|
|
22
|
+
const { config, token } = await require('./session.cjs').session(scope);
|
|
23
|
+
return publicResult(await request(config, endpoint, token, payload), schema);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function request(config, endpoint, token, payload, signal = AbortSignal.timeout(80000)) {
|
|
27
|
+
const address = gatewayURL(config) + '/api/v1/cli/' + endpoint;
|
|
28
|
+
const response = await send(address, {
|
|
29
|
+
method: payload === undefined ? 'GET' : 'POST',
|
|
30
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': 'yunzhi-cli/' + require('./version.cjs').version,
|
|
31
|
+
...(token ? { Authorization: 'Bearer ' + token } : {}) },
|
|
32
|
+
body: payload === undefined ? undefined : JSON.stringify(payload),
|
|
33
|
+
redirect: 'error', signal,
|
|
34
|
+
});
|
|
35
|
+
return responseEnvelope(response);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function multipartRequest(config, endpoint, token, values, filePath) {
|
|
39
|
+
const boundary = 'yunzhi-' + randomBytes(18).toString('hex');
|
|
40
|
+
async function* parts() {
|
|
41
|
+
for (const name of Object.keys(values).sort()) for (const value of values[name]) {
|
|
42
|
+
yield Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${name}"\r\n\r\n${value}\r\n`);
|
|
43
|
+
}
|
|
44
|
+
yield Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="learning-tool.zip"\r\nContent-Type: application/zip\r\n\r\n`);
|
|
45
|
+
for await (const chunk of fs.createReadStream(filePath)) yield chunk;
|
|
46
|
+
yield Buffer.from(`\r\n--${boundary}--\r\n`);
|
|
47
|
+
}
|
|
48
|
+
const address = gatewayURL(config) + '/api/v1/cli/' + endpoint;
|
|
49
|
+
const response = await send(address, {
|
|
50
|
+
method: 'POST', body: Readable.from(parts()), duplex: 'half', redirect: 'error',
|
|
51
|
+
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
52
|
+
'User-Agent': 'yunzhi-cli/' + require('./version.cjs').version,
|
|
53
|
+
Authorization: 'Bearer ' + token }, signal: AbortSignal.timeout(learningToolUploadTimeoutMs),
|
|
54
|
+
});
|
|
55
|
+
return responseEnvelope(response);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function send(address, options) {
|
|
59
|
+
try { return await fetch(address, options); }
|
|
60
|
+
catch (error) {
|
|
61
|
+
const cause = error.cause || error;
|
|
62
|
+
const codes = cause.errors?.map(item => item.code) || [cause.code];
|
|
63
|
+
const notConnected = codes.length && codes.every(code => ['ENOTFOUND', 'EAI_AGAIN', 'ECONNREFUSED', 'ENETUNREACH', 'EHOSTUNREACH'].includes(code));
|
|
64
|
+
const timeout = error.name === 'TimeoutError' || ['ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_HEADERS_TIMEOUT'].includes(cause.code);
|
|
65
|
+
const interrupted = ['ECONNRESET', 'EPIPE', 'UND_ERR_SOCKET'].includes(cause.code);
|
|
66
|
+
const subtype = notConnected ? 'connection_failed' : timeout ? 'request_timeout' : interrupted ? 'connection_interrupted' : 'network_error';
|
|
67
|
+
const message = notConnected ? '无法连接业务服务,本次请求未能建立连接。'
|
|
68
|
+
: timeout ? '等待业务服务响应超时,暂时无法确认本次操作结果。'
|
|
69
|
+
: interrupted ? '与业务服务的连接中断,暂时无法确认本次操作结果。'
|
|
70
|
+
: '请求业务服务时发生网络错误,暂时无法确认本次操作结果。';
|
|
71
|
+
throw new Failure(4, 'network', subtype, message, { step: 'send_gateway_request', detail: detail(error) });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function responseEnvelope(response) {
|
|
76
|
+
let text;
|
|
77
|
+
try { text = await response.text(); }
|
|
78
|
+
catch (error) { throw new Failure(4, 'network', 'response_read_failed', '未能完整接收业务服务的返回,暂时无法确认本次操作结果。', { step: 'read_gateway_response', detail: detail(error) }); }
|
|
79
|
+
let envelope;
|
|
80
|
+
try { envelope = JSON.parse(text); }
|
|
81
|
+
catch (error) { throw new Failure(1, 'api', 'invalid_response', '已收到业务服务响应,但无法解析 JSON,暂时无法确认本次操作结果。', { step: 'decode_gateway_response', detail: detail(error) }); }
|
|
82
|
+
if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) throw new Failure(1, 'api', 'invalid_response', '已收到业务服务响应,但响应格式无效,暂时无法确认本次操作结果。');
|
|
83
|
+
if (!response.ok || envelope.ok !== true) {
|
|
84
|
+
const invalidError = cause => {
|
|
85
|
+
const message = cause?.message?.startsWith('响应字段 ') ? cause.message
|
|
86
|
+
: '业务服务的错误响应缺少有效的 error.type、error.subtype 或 error.message。';
|
|
87
|
+
const failure = new Failure(1, 'api', 'invalid_response', message + '暂时无法确认本次操作结果。');
|
|
88
|
+
if (typeof envelope.request_id === 'string' && envelope.request_id) failure.result.value.request_id = envelope.request_id;
|
|
89
|
+
return cause ? attach(failure, cause) : failure;
|
|
90
|
+
};
|
|
91
|
+
let error;
|
|
92
|
+
try {
|
|
93
|
+
error = fields(envelope.error, { type: 'string', subtype: 'string', message: 'string', hint: optional('string'), detail: optional('string'), required_scopes: optional(['string']), current_status: optional('string') }, 'error');
|
|
94
|
+
let details = require('./business-schemas.cjs').errors[error.subtype];
|
|
95
|
+
if (!details && ['page_publish_result_unknown'].includes(error.subtype)
|
|
96
|
+
&& envelope.error.details?.upload_stage != null)
|
|
97
|
+
details = { upload_stage: 'string' };
|
|
98
|
+
if (details && envelope.error.details != null) error.details = fields(envelope.error.details, details, 'error.details');
|
|
99
|
+
}
|
|
100
|
+
catch (error) { throw invalidError(error); }
|
|
101
|
+
if (!error?.type || !error.subtype || !error.message) throw invalidError();
|
|
102
|
+
const codes = { authentication: 3, authorization: 3, configuration: 3, validation: 2, network: 4, security: 6, policy: 6 };
|
|
103
|
+
const failure = new Failure(codes[error.type] || 1, error.type, error.subtype, error.message, error);
|
|
104
|
+
if (typeof envelope.request_id === 'string' && envelope.request_id) failure.result.value.request_id = envelope.request_id;
|
|
105
|
+
throw failure;
|
|
106
|
+
}
|
|
107
|
+
return envelope;
|
|
108
|
+
}
|
|
109
|
+
function publicResult(envelope, schema) {
|
|
110
|
+
let data;
|
|
111
|
+
try {
|
|
112
|
+
if (!envelope.data || typeof envelope.data !== 'object' || Array.isArray(envelope.data)) throw new Error('invalid response');
|
|
113
|
+
data = fields(envelope.data, schema);
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
const failure = attach(new Failure(1, 'api', 'invalid_response', error.message.startsWith('响应字段 ') ? error.message : '业务服务返回的数据不是有效对象。'), error);
|
|
117
|
+
if (typeof envelope.request_id === 'string' && envelope.request_id) failure.result.value.request_id = envelope.request_id;
|
|
118
|
+
throw failure;
|
|
119
|
+
}
|
|
120
|
+
const value = { ok: true, data };
|
|
121
|
+
if (typeof envelope.request_id === 'string' && envelope.request_id) value.request_id = envelope.request_id;
|
|
122
|
+
return { code: 0, value };
|
|
123
|
+
}
|
|
124
|
+
module.exports = { gatewayURL, execute, request, multipartRequest, publicResult };
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const fsp = require('node:fs/promises');
|
|
3
|
+
const os = require('node:os');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { createHash } = require('node:crypto');
|
|
6
|
+
const { createDeflateRaw, createInflateRaw } = require('node:zlib');
|
|
7
|
+
const { Readable, Transform } = require('node:stream');
|
|
8
|
+
const { Failure } = require('./result.cjs');
|
|
9
|
+
|
|
10
|
+
const MAX_PACKAGE = 500 * 1024 * 1024;
|
|
11
|
+
const MAX_FILE = 100 * 1024 * 1024;
|
|
12
|
+
const MAX_FILES = 2000;
|
|
13
|
+
const MAX_NAME = 1024;
|
|
14
|
+
const allowed = new Set('.html .htm .css .js .mjs .json .txt .csv .pdf .wasm .png .jpg .jpeg .webp .gif .svg .mp3 .wav .ogg .mp4 .webm .woff .woff2 .ttf .otf'.split(' '));
|
|
15
|
+
const attribute = /\b(?:src|href)\s*=\s*["']([^"']+)["']/gis;
|
|
16
|
+
const cssURL = /\burl\s*\(\s*["']?([^"')]+)/gis;
|
|
17
|
+
|
|
18
|
+
function invalid(subtype, message) { return new Failure(2, 'validation', subtype, message); }
|
|
19
|
+
function safeName(name) {
|
|
20
|
+
if (!name || Buffer.byteLength(name) > MAX_NAME || name.includes('\\') || name.startsWith('/') ||
|
|
21
|
+
/^[a-zA-Z]:/.test(name) || name.includes('\0') || /[\x00-\x1f\x7f]/.test(name)) return false;
|
|
22
|
+
const clean = path.posix.normalize(name.replace(/\/$/, ''));
|
|
23
|
+
return clean !== '.' && clean !== '..' && !clean.startsWith('../') && clean === name.replace(/\/$/, '');
|
|
24
|
+
}
|
|
25
|
+
function supported(name) { return allowed.has(path.posix.extname(name).toLowerCase()); }
|
|
26
|
+
|
|
27
|
+
async function normalize(source) {
|
|
28
|
+
let sourceInfo;
|
|
29
|
+
try { sourceInfo = await fsp.lstat(source); }
|
|
30
|
+
catch (error) { throw invalid('invalid_local_source', '本地学具 source 不存在或不可读取。'); }
|
|
31
|
+
if (sourceInfo.isSymbolicLink()) throw invalid('unsafe_learning_tool_source', '本地学具 source 不能是符号链接。');
|
|
32
|
+
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), 'yunzhi-learning-tool-'));
|
|
33
|
+
const archivePath = path.join(temporary, 'learning-tool.zip');
|
|
34
|
+
const cleanup = async () => fsp.rm(temporary, { recursive: true, force: true });
|
|
35
|
+
try {
|
|
36
|
+
let entries;
|
|
37
|
+
const extension = path.extname(source).toLowerCase();
|
|
38
|
+
if (sourceInfo.isDirectory()) entries = await directoryEntries(source);
|
|
39
|
+
else if (!sourceInfo.isFile()) throw invalid('invalid_learning_tool_source', 'source 只接受目录、ZIP 或单个自包含 HTML。');
|
|
40
|
+
else if (extension === '.zip') {
|
|
41
|
+
if (sourceInfo.size > MAX_PACKAGE) throw invalid('learning_tool_package_too_large', 'ZIP 不能超过 500 MiB。');
|
|
42
|
+
entries = await zipEntries(source);
|
|
43
|
+
} else if (extension === '.html' || extension === '.htm') {
|
|
44
|
+
await validateStandalone(source, sourceInfo.size);
|
|
45
|
+
entries = [{ name: 'index.html', size: sourceInfo.size, open: () => fs.createReadStream(source) }];
|
|
46
|
+
} else throw invalid('invalid_learning_tool_source', 'source 只接受目录、ZIP 或单个自包含 HTML。');
|
|
47
|
+
validateEntries(entries);
|
|
48
|
+
await write(archivePath, entries);
|
|
49
|
+
const result = await fsp.stat(archivePath);
|
|
50
|
+
if (result.size > MAX_PACKAGE) throw invalid('learning_tool_package_too_large', '规范化后的 ZIP 不能超过 500 MiB。');
|
|
51
|
+
const digest = createHash('sha256');
|
|
52
|
+
for await (const chunk of fs.createReadStream(archivePath)) digest.update(chunk);
|
|
53
|
+
const names = new Set(entries.map(entry => entry.name));
|
|
54
|
+
const coverFile = ['cover.webp', 'cover.png', 'cover.jpg'].find(name => names.has(name)) || null;
|
|
55
|
+
return { archive_path: archivePath, sha256: digest.digest('hex'), cover_file: coverFile, cleanup };
|
|
56
|
+
} catch (error) { await cleanup(); throw error; }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function directoryEntries(root) {
|
|
60
|
+
const entries = [];
|
|
61
|
+
async function visit(directory, prefix = '') {
|
|
62
|
+
let children;
|
|
63
|
+
try { children = await fsp.readdir(directory, { withFileTypes: true }); }
|
|
64
|
+
catch (error) { throw invalid('invalid_local_source', '本地学具目录不可读取。'); }
|
|
65
|
+
children.sort((left, right) => left.name.localeCompare(right.name, 'en'));
|
|
66
|
+
for (const child of children) {
|
|
67
|
+
const location = path.join(directory, child.name);
|
|
68
|
+
const info = await fsp.lstat(location);
|
|
69
|
+
const name = prefix ? prefix + '/' + child.name : child.name;
|
|
70
|
+
if (info.isSymbolicLink()) throw invalid('unsafe_learning_tool_source', '学具目录不能包含符号链接。');
|
|
71
|
+
if (info.isDirectory()) await visit(location, name);
|
|
72
|
+
else if (!info.isFile()) throw invalid('unsafe_learning_tool_source', '学具目录只能包含普通静态文件。');
|
|
73
|
+
else entries.push({ name, size: info.size, open: () => fs.createReadStream(location) });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
await visit(root);
|
|
77
|
+
return entries;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function validateEntries(entries) {
|
|
81
|
+
if (entries.length > MAX_FILES) throw invalid('learning_tool_too_many_files', '学具文件数不能超过 2,000。');
|
|
82
|
+
const names = new Set(); let total = 0;
|
|
83
|
+
for (const entry of entries) {
|
|
84
|
+
if (!safeName(entry.name)) throw invalid('unsafe_learning_tool_source', '学具包含不安全路径。');
|
|
85
|
+
if (names.has(entry.name)) throw invalid('unsafe_learning_tool_source', '学具包含重复路径。');
|
|
86
|
+
names.add(entry.name);
|
|
87
|
+
if (!supported(entry.name)) throw invalid('unsupported_learning_tool_file', '学具包含不支持的静态文件:' + entry.name);
|
|
88
|
+
if (!Number.isSafeInteger(entry.size) || entry.size < 0 || entry.size > MAX_FILE)
|
|
89
|
+
throw invalid('learning_tool_file_too_large', '学具中的单个文件不能超过 100 MiB。');
|
|
90
|
+
total += entry.size;
|
|
91
|
+
if (total > MAX_PACKAGE) throw invalid('learning_tool_extracted_too_large', '学具文件合计不能超过 500 MiB。');
|
|
92
|
+
}
|
|
93
|
+
if (!names.has('index.html')) throw invalid('learning_tool_index_missing', '学具根目录必须包含 index.html。');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function validateStandalone(location, size) {
|
|
97
|
+
if (size > MAX_FILE) throw invalid('learning_tool_file_too_large', '单个 HTML 不能超过 100 MiB。');
|
|
98
|
+
let content;
|
|
99
|
+
try { content = await fsp.readFile(location, 'utf8'); }
|
|
100
|
+
catch (error) { throw invalid('invalid_local_source', '本地 HTML 不可读取。'); }
|
|
101
|
+
for (const expression of [attribute, cssURL]) {
|
|
102
|
+
expression.lastIndex = 0;
|
|
103
|
+
for (const match of content.matchAll(expression)) {
|
|
104
|
+
const value = match[1].trim().toLowerCase();
|
|
105
|
+
if (!value || value.startsWith('#') || value.startsWith('data:') || value.startsWith('blob:') || value.startsWith('https://')) continue;
|
|
106
|
+
throw invalid('standalone_html_has_local_assets', '单个 HTML 引用了旁边文件;请改用包含附件的目录或 ZIP。');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function zipEntries(location) {
|
|
112
|
+
const parsed = await inspect(location, true);
|
|
113
|
+
const files = parsed.filter(entry => !entry.directory);
|
|
114
|
+
const names = new Set(files.map(entry => entry.name));
|
|
115
|
+
let prefix = '';
|
|
116
|
+
if (!names.has('index.html')) {
|
|
117
|
+
const top = new Set(files.map(entry => entry.name.split('/')[0]));
|
|
118
|
+
if (top.size !== 1) throw invalid('learning_tool_index_missing', 'ZIP 根目录或唯一顶层目录必须包含 index.html。');
|
|
119
|
+
prefix = [...top][0] + '/';
|
|
120
|
+
if (!names.has(prefix + 'index.html')) throw invalid('learning_tool_index_missing', 'ZIP 根目录或唯一顶层目录必须包含 index.html。');
|
|
121
|
+
}
|
|
122
|
+
return files.map(entry => ({
|
|
123
|
+
name: prefix ? entry.name.slice(prefix.length) : entry.name,
|
|
124
|
+
size: entry.size,
|
|
125
|
+
open: () => openZipEntry(location, entry),
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function inspect(location, includeDirectories = false) {
|
|
130
|
+
const handle = await fsp.open(location, 'r');
|
|
131
|
+
try {
|
|
132
|
+
const stat = await handle.stat();
|
|
133
|
+
const tailSize = Math.min(stat.size, 65557);
|
|
134
|
+
const tail = Buffer.alloc(tailSize);
|
|
135
|
+
await handle.read(tail, 0, tailSize, stat.size - tailSize);
|
|
136
|
+
let eocd = -1;
|
|
137
|
+
for (let index = tail.length - 22; index >= 0; index--) {
|
|
138
|
+
if (tail.readUInt32LE(index) === 0x06054b50) { eocd = index; break; }
|
|
139
|
+
}
|
|
140
|
+
if (eocd < 0 || tail.readUInt16LE(eocd + 4) !== 0 || tail.readUInt16LE(eocd + 6) !== 0)
|
|
141
|
+
throw invalid('invalid_learning_tool_archive', 'ZIP 无法读取。');
|
|
142
|
+
const count = tail.readUInt16LE(eocd + 10);
|
|
143
|
+
const centralSize = tail.readUInt32LE(eocd + 12);
|
|
144
|
+
const centralOffset = tail.readUInt32LE(eocd + 16);
|
|
145
|
+
if (count > MAX_FILES || centralSize > 16 * 1024 * 1024 || centralOffset + centralSize > stat.size)
|
|
146
|
+
throw invalid('invalid_learning_tool_archive', 'ZIP 目录无效或文件数超限。');
|
|
147
|
+
const central = Buffer.alloc(centralSize);
|
|
148
|
+
await handle.read(central, 0, centralSize, centralOffset);
|
|
149
|
+
const entries = []; let offset = 0;
|
|
150
|
+
for (let index = 0; index < count; index++) {
|
|
151
|
+
if (offset + 46 > central.length || central.readUInt32LE(offset) !== 0x02014b50)
|
|
152
|
+
throw invalid('invalid_learning_tool_archive', 'ZIP 目录无效。');
|
|
153
|
+
const flags = central.readUInt16LE(offset + 8);
|
|
154
|
+
const method = central.readUInt16LE(offset + 10);
|
|
155
|
+
const crc = central.readUInt32LE(offset + 16);
|
|
156
|
+
const compressedSize = central.readUInt32LE(offset + 20);
|
|
157
|
+
const size = central.readUInt32LE(offset + 24);
|
|
158
|
+
const nameLength = central.readUInt16LE(offset + 28);
|
|
159
|
+
const extraLength = central.readUInt16LE(offset + 30);
|
|
160
|
+
const commentLength = central.readUInt16LE(offset + 32);
|
|
161
|
+
const external = central.readUInt32LE(offset + 38);
|
|
162
|
+
const localOffset = central.readUInt32LE(offset + 42);
|
|
163
|
+
if (flags & 1 || ![0, 8].includes(method) || nameLength < 1 || nameLength > MAX_NAME ||
|
|
164
|
+
[compressedSize, size, localOffset].includes(0xffffffff))
|
|
165
|
+
throw invalid('invalid_learning_tool_archive', 'ZIP 使用了不支持的加密、压缩或 ZIP64 格式。');
|
|
166
|
+
const end = offset + 46 + nameLength + extraLength + commentLength;
|
|
167
|
+
if (end > central.length) throw invalid('invalid_learning_tool_archive', 'ZIP 目录无效。');
|
|
168
|
+
const nameBuffer = central.subarray(offset + 46, offset + 46 + nameLength);
|
|
169
|
+
const name = new TextDecoder('utf-8', { fatal: true }).decode(nameBuffer);
|
|
170
|
+
const mode = external >>> 16;
|
|
171
|
+
const directory = name.endsWith('/');
|
|
172
|
+
if (!safeName(name) || (mode & 0o170000) === 0o120000 || (!directory && mode && (mode & 0o170000) !== 0o100000))
|
|
173
|
+
throw invalid('unsafe_learning_tool_archive', 'ZIP 包含符号链接、特殊文件或不安全路径。');
|
|
174
|
+
if (!directory) entries.push({ name, size, compressedSize, method, crc, localOffset });
|
|
175
|
+
else if (includeDirectories) entries.push({ name, directory: true });
|
|
176
|
+
offset = end;
|
|
177
|
+
}
|
|
178
|
+
if (offset !== central.length) throw invalid('invalid_learning_tool_archive', 'ZIP 目录包含多余数据。');
|
|
179
|
+
return entries;
|
|
180
|
+
} catch (error) {
|
|
181
|
+
if (error instanceof Failure) throw error;
|
|
182
|
+
throw invalid('invalid_learning_tool_archive', 'ZIP 无法读取。');
|
|
183
|
+
} finally { await handle.close(); }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function openZipEntry(location, entry) {
|
|
187
|
+
const handle = await fsp.open(location, 'r');
|
|
188
|
+
const header = Buffer.alloc(30);
|
|
189
|
+
try {
|
|
190
|
+
await handle.read(header, 0, 30, entry.localOffset);
|
|
191
|
+
if (header.readUInt32LE(0) !== 0x04034b50) throw new Error('local header');
|
|
192
|
+
const start = entry.localOffset + 30 + header.readUInt16LE(26) + header.readUInt16LE(28);
|
|
193
|
+
await handle.close();
|
|
194
|
+
const compressed = entry.compressedSize === 0 ? Readable.from([])
|
|
195
|
+
: fs.createReadStream(location, { start, end: start + entry.compressedSize - 1 });
|
|
196
|
+
const stream = entry.method === 8 ? compressed.pipe(createInflateRaw()) : compressed;
|
|
197
|
+
return checkedStream(stream, entry);
|
|
198
|
+
} catch (error) { await handle.close().catch(() => {}); throw invalid('invalid_learning_tool_archive', 'ZIP 文件无法解压。'); }
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function checkedStream(stream, expected) {
|
|
202
|
+
async function* check() {
|
|
203
|
+
let crc = 0xffffffff, size = 0;
|
|
204
|
+
for await (const chunk of stream) {
|
|
205
|
+
size += chunk.length;
|
|
206
|
+
if (size > MAX_FILE) throw invalid('learning_tool_file_too_large', '学具中的单个文件不能超过 100 MiB。');
|
|
207
|
+
crc = crc32Update(crc, chunk);
|
|
208
|
+
yield chunk;
|
|
209
|
+
}
|
|
210
|
+
crc = (crc ^ 0xffffffff) >>> 0;
|
|
211
|
+
if (size !== expected.size || crc !== expected.crc)
|
|
212
|
+
throw invalid('invalid_learning_tool_archive', 'ZIP 文件大小或校验值无效。');
|
|
213
|
+
}
|
|
214
|
+
return Readable.from(check());
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function write(destination, entries) {
|
|
218
|
+
const output = fs.createWriteStream(destination, { flags: 'wx' });
|
|
219
|
+
let offset = 0;
|
|
220
|
+
const central = [];
|
|
221
|
+
const writeChunk = chunk => new Promise((resolve, reject) => {
|
|
222
|
+
output.write(chunk, error => error ? reject(error) : resolve());
|
|
223
|
+
});
|
|
224
|
+
try {
|
|
225
|
+
for (const entry of [...entries].sort((left, right) => left.name.localeCompare(right.name, 'en'))) {
|
|
226
|
+
const name = Buffer.from(entry.name);
|
|
227
|
+
const localOffset = offset;
|
|
228
|
+
const local = Buffer.alloc(30);
|
|
229
|
+
local.writeUInt32LE(0x04034b50, 0); local.writeUInt16LE(20, 4); local.writeUInt16LE(0x0808, 6);
|
|
230
|
+
local.writeUInt16LE(8, 8); local.writeUInt16LE(name.length, 26);
|
|
231
|
+
await writeChunk(local); await writeChunk(name); offset += local.length + name.length;
|
|
232
|
+
const source = await entry.open();
|
|
233
|
+
let crc = 0xffffffff, size = 0, compressedSize = 0;
|
|
234
|
+
const tracker = new Transform({ transform(chunk, encoding, callback) {
|
|
235
|
+
crc = crc32Update(crc, chunk); size += chunk.length; callback(null, chunk);
|
|
236
|
+
} });
|
|
237
|
+
const compressed = source.pipe(tracker).pipe(createDeflateRaw());
|
|
238
|
+
for await (const chunk of compressed) { await writeChunk(chunk); compressedSize += chunk.length; offset += chunk.length; }
|
|
239
|
+
crc = (crc ^ 0xffffffff) >>> 0;
|
|
240
|
+
if (size !== entry.size) throw invalid('invalid_local_source', '学具文件在打包期间发生变化。');
|
|
241
|
+
const descriptor = Buffer.alloc(16);
|
|
242
|
+
descriptor.writeUInt32LE(0x08074b50, 0); descriptor.writeUInt32LE(crc, 4);
|
|
243
|
+
descriptor.writeUInt32LE(compressedSize, 8); descriptor.writeUInt32LE(size, 12);
|
|
244
|
+
await writeChunk(descriptor); offset += descriptor.length;
|
|
245
|
+
central.push({ name, crc, size, compressedSize, localOffset });
|
|
246
|
+
}
|
|
247
|
+
const centralOffset = offset;
|
|
248
|
+
for (const entry of central) {
|
|
249
|
+
const header = Buffer.alloc(46);
|
|
250
|
+
header.writeUInt32LE(0x02014b50, 0); header.writeUInt16LE(0x0314, 4); header.writeUInt16LE(20, 6);
|
|
251
|
+
header.writeUInt16LE(0x0808, 8); header.writeUInt16LE(8, 10); header.writeUInt32LE(entry.crc, 16);
|
|
252
|
+
header.writeUInt32LE(entry.compressedSize, 20); header.writeUInt32LE(entry.size, 24);
|
|
253
|
+
header.writeUInt16LE(entry.name.length, 28); header.writeUInt32LE((0o100644 << 16) >>> 0, 38);
|
|
254
|
+
header.writeUInt32LE(entry.localOffset, 42);
|
|
255
|
+
await writeChunk(header); await writeChunk(entry.name); offset += header.length + entry.name.length;
|
|
256
|
+
}
|
|
257
|
+
const eocd = Buffer.alloc(22); const centralSize = offset - centralOffset;
|
|
258
|
+
eocd.writeUInt32LE(0x06054b50, 0); eocd.writeUInt16LE(central.length, 8); eocd.writeUInt16LE(central.length, 10);
|
|
259
|
+
eocd.writeUInt32LE(centralSize, 12); eocd.writeUInt32LE(centralOffset, 16);
|
|
260
|
+
await writeChunk(eocd);
|
|
261
|
+
await new Promise((resolve, reject) => output.end(error => error ? reject(error) : resolve()));
|
|
262
|
+
} catch (error) { output.destroy(); throw error; }
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const crcTable = Array.from({ length: 256 }, (_, value) => {
|
|
266
|
+
let crc = value;
|
|
267
|
+
for (let bit = 0; bit < 8; bit++) crc = (crc & 1) ? (0xedb88320 ^ (crc >>> 1)) : (crc >>> 1);
|
|
268
|
+
return crc >>> 0;
|
|
269
|
+
});
|
|
270
|
+
function crc32Update(crc, buffer) {
|
|
271
|
+
for (const value of buffer) crc = crcTable[(crc ^ value) & 0xff] ^ (crc >>> 8);
|
|
272
|
+
return crc >>> 0;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
module.exports = { normalize, inspect, write };
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
const { options, required, confirm, limit } = require('./arguments.cjs');
|
|
2
|
+
const { invalid, optionalFields, nullable, required: requiredField } = require('./result.cjs');
|
|
3
|
+
const { session } = require('./session.cjs');
|
|
4
|
+
const gateway = require('./gateway.cjs');
|
|
5
|
+
const archive = require('./learning-tool-archive.cjs');
|
|
6
|
+
|
|
7
|
+
const entry = optionalFields({ type: 'string', folder_id: 'string', file_id: 'string', kind: 'string',
|
|
8
|
+
name: 'string', path: 'string', file_ext: 'string', file_size: 'integer', category_id: 'string',
|
|
9
|
+
label_ids: ['string'], updated_at: 'string', origin: 'string', run_url: 'string', cover_url: 'string',
|
|
10
|
+
can_update: 'boolean', can_move: 'boolean', can_remove: 'boolean' });
|
|
11
|
+
const schemas = {
|
|
12
|
+
ls: { entries: requiredField([entry]), next_cursor: nullable('string') },
|
|
13
|
+
'upload-options': require('./business-schemas.cjs')['upload-options'],
|
|
14
|
+
upload: { file_id: requiredField('string'), title: requiredField('string'), path: requiredField('string'),
|
|
15
|
+
folder_id: requiredField('string'), origin: requiredField('string'), run_url: requiredField('string'),
|
|
16
|
+
cover_url: nullable('string'), size_bytes: 'integer', sha256: requiredField('string'), message: requiredField('string') },
|
|
17
|
+
update: entry, move: entry,
|
|
18
|
+
remove: { removed: 'boolean', file_id: requiredField('string'), mode: requiredField('string'), message: 'string' },
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
async function execute(command, args) {
|
|
22
|
+
if (command === 'upload') return upload(args);
|
|
23
|
+
let payload, scope = 'school-library:write';
|
|
24
|
+
if (command === 'ls') {
|
|
25
|
+
payload = listArguments(args); scope = 'school-library:read';
|
|
26
|
+
} else if (command === 'upload-options') {
|
|
27
|
+
payload = options(args, ['folder_id'], [], ['folder_id']); required(payload, 'folder_id');
|
|
28
|
+
scope = 'school-library:read';
|
|
29
|
+
} else if (command === 'update') {
|
|
30
|
+
payload = options(args, ['file_id', 'title', 'category_id', 'label_id'], ['clear_category', 'clear_labels']);
|
|
31
|
+
required(payload, 'file_id');
|
|
32
|
+
if (payload.category_id && payload.clear_category) throw invalid('--category-id 与 --clear-category 不能同时使用。');
|
|
33
|
+
if (payload.label_ids && payload.clear_labels) throw invalid('--label-id 与 --clear-labels 不能同时使用。');
|
|
34
|
+
if (Object.keys(payload).length === 1) throw invalid('update 至少需要一个修改项。');
|
|
35
|
+
} else if (command === 'move') {
|
|
36
|
+
payload = options(args, ['file_id', 'folder_id', 'category_id', 'label_id'], ['clear_category', 'clear_labels']);
|
|
37
|
+
required(payload, 'file_id', 'folder_id');
|
|
38
|
+
if (payload.category_id && payload.clear_category) throw invalid('--category-id 与 --clear-category 不能同时使用。');
|
|
39
|
+
if (payload.label_ids && payload.clear_labels) throw invalid('--label-id 与 --clear-labels 不能同时使用。');
|
|
40
|
+
if (!payload.category_id && !payload.clear_category) throw invalid('移动学具必须传入 --category-id 或 --clear-category。');
|
|
41
|
+
if (!payload.label_ids && !payload.clear_labels) throw invalid('移动学具必须传入 --label-id 或 --clear-labels。');
|
|
42
|
+
} else if (command === 'remove') {
|
|
43
|
+
payload = options(args, ['file_id'], ['yes']); required(payload, 'file_id'); confirm(payload);
|
|
44
|
+
} else throw invalid('未知命令。');
|
|
45
|
+
return gateway.execute(scope, 'learning-tool/' + command, payload, schemas[command]);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function listArguments(args) {
|
|
49
|
+
const payload = { path: '/', limit: 100, cursor: null };
|
|
50
|
+
let pathSet = false; const flags = [];
|
|
51
|
+
for (let index = 0; index < args.length; index++) {
|
|
52
|
+
const value = args[index];
|
|
53
|
+
if (value === '--limit' || value === '--cursor') {
|
|
54
|
+
if (flags.includes(value)) throw invalid(value + ' 不能重复传入。');
|
|
55
|
+
flags.push(value);
|
|
56
|
+
if (++index >= args.length || !args[index].trim() || args[index].startsWith('--')) throw invalid(value + ' 需要非空参数。');
|
|
57
|
+
if (value === '--limit') payload.limit = args[index]; else payload.cursor = args[index];
|
|
58
|
+
} else if (value.startsWith('-') || pathSet) throw invalid('learning-tool ls 包含未知参数。');
|
|
59
|
+
else { payload.path = value; pathSet = true; }
|
|
60
|
+
}
|
|
61
|
+
limit(payload, 200);
|
|
62
|
+
return payload;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function upload(args) {
|
|
66
|
+
const payload = options(args, ['source', 'folder_id', 'title', 'category_id', 'label_id'], [], ['source']);
|
|
67
|
+
|
|
68
|
+
required(payload, 'source', 'folder_id', 'title');
|
|
69
|
+
const normalized = await archive.normalize(payload.source);
|
|
70
|
+
try {
|
|
71
|
+
const { config, token } = await session('school-library:write');
|
|
72
|
+
const values = { folder_id: [payload.folder_id], title: [payload.title], sha256: [normalized.sha256] };
|
|
73
|
+
if (normalized.cover_file) values.cover_file = [normalized.cover_file];
|
|
74
|
+
if (payload.category_id) values.category_id = [payload.category_id];
|
|
75
|
+
if (payload.label_ids) values.label_id = payload.label_ids;
|
|
76
|
+
const envelope = await gateway.multipartRequest(
|
|
77
|
+
config, 'learning-tool/upload', token, values, normalized.archive_path);
|
|
78
|
+
return gateway.publicResult(envelope, schemas.upload);
|
|
79
|
+
} finally { await normalized.cleanup(); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { execute, schemas };
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const { invalid, optionalFields, nullableFields, nullable } = require('./result.cjs');
|
|
2
|
+
const { unknownOption } = require('./suggestions.cjs');
|
|
3
|
+
const gateway = require('./gateway.cjs');
|
|
4
|
+
|
|
5
|
+
const entry = optionalFields({ type: 'string', folder_id: 'string', file_id: 'string', kind: 'string', space: 'string',
|
|
6
|
+
name: 'string', path: 'string', file_ext: 'string', file_size: 'integer', category_id: 'string',
|
|
7
|
+
label_ids: ['string'], updated_at: 'string', web_url: 'string', source_file_deleted: 'boolean' });
|
|
8
|
+
|
|
9
|
+
const locator = optionalFields({ source_type: 'string', segment_index: 'integer', begin_time: 'number', end_time: 'number',
|
|
10
|
+
page: 'integer', section: 'string', kind: 'string', slide: 'integer' });
|
|
11
|
+
const snippet = nullableFields({ text: 'string', score: 'number', section: 'string', locator, start_line: 'integer', end_line: 'integer' });
|
|
12
|
+
const searchItem = optionalFields({ type: 'string', file_id: 'string', kind: 'string', space: 'string', name: 'string',
|
|
13
|
+
path: 'string', file_ext: 'string', updated_at: 'string', score: 'number', snippets: [snippet], source_file_deleted: 'boolean' });
|
|
14
|
+
const grepMatch = optionalFields({ type: 'string', file_id: 'string', kind: 'string', space: 'string', name: 'string',
|
|
15
|
+
path: 'string', line: 'integer', text: 'string', text_truncated: 'boolean', updated_at: 'string', web_url: 'string', source_file_deleted: 'boolean' });
|
|
16
|
+
const fetchResult = optionalFields({ file_id: 'string', kind: 'string', space: 'string', name: 'string', path: 'string', file_ext: 'string',
|
|
17
|
+
updated_at: 'string', web_url: 'string', start_line: 'integer', content: 'string', next_start_line: 'integer', source_file_deleted: 'boolean' });
|
|
18
|
+
const schemas = { ls: { entries: [entry], next_cursor: nullable('string') }, glob: { matches: [entry], next_cursor: nullable('string') },
|
|
19
|
+
search: { items: [searchItem], next_cursor: nullable('string') }, grep: { matches: [grepMatch], next_cursor: nullable('string') }, fetch: fetchResult };
|
|
20
|
+
|
|
21
|
+
async function read(command, args, namespace = 'lesson-prep') {
|
|
22
|
+
const payload = command === 'fetch' ? { file: '', start_line: 1, limit: 200 } : { path: '/', limit: 100, cursor: null };
|
|
23
|
+
if (command === 'glob') Object.assign(payload, { pattern: '**/*', type: 'all' });
|
|
24
|
+
if (command === 'search') Object.assign(payload, { query: '', limit: 10 });
|
|
25
|
+
if (command === 'grep') Object.assign(payload, { patterns: [], glob: null, ignore_case: false, limit: 50 });
|
|
26
|
+
const maximum = { ls: 200, glob: 200, search: 20, grep: 100, fetch: 500 }[command];
|
|
27
|
+
const extraOptions = { ls: [], glob: ['--pattern', '--type'], search: ['--query'],
|
|
28
|
+
grep: ['--pattern', '--glob'], fetch: ['--file', '--start-line'] }[command];
|
|
29
|
+
let pathSet = false;
|
|
30
|
+
const seen = new Set();
|
|
31
|
+
for (let i = 0; i < args.length; i++) {
|
|
32
|
+
if (['--limit', ...(command !== 'fetch' ? ['--cursor'] : []), ...extraOptions, ...(command === 'grep' ? ['--ignore-case'] : [])].includes(args[i])
|
|
33
|
+
&& !(command === 'grep' && args[i] === '--pattern')) {
|
|
34
|
+
if (seen.has(args[i])) throw invalid(args[i] + ' 不能重复传入。');
|
|
35
|
+
seen.add(args[i]);
|
|
36
|
+
}
|
|
37
|
+
if (command === 'grep' && args[i] === '--ignore-case') { payload.ignore_case = true; continue; }
|
|
38
|
+
if (args[i] === '--limit' || (args[i] === '--cursor' && command !== 'fetch') || extraOptions.includes(args[i])) {
|
|
39
|
+
const option = args[i];
|
|
40
|
+
if (++i >= args.length || ['--limit', '--cursor', '--ignore-case', '--help', '-h', ...extraOptions].includes(args[i])) throw invalid(option + ' 需要参数。');
|
|
41
|
+
if (option === '--cursor') payload.cursor = args[i];
|
|
42
|
+
else if (option === '--limit') {
|
|
43
|
+
if (!/^[+]?\d+$/.test(args[i]) || +args[i] < 1 || +args[i] > maximum) throw invalid(`--limit 必须在 1 到 ${maximum} 之间。`);
|
|
44
|
+
payload.limit = +args[i];
|
|
45
|
+
} else if (option === '--start-line') {
|
|
46
|
+
if (!/^[+]?\d+$/.test(args[i]) || +args[i] < 1 || +args[i] > 2147483647) throw invalid('--start-line 必须是 1 到 2147483647 之间的整数。');
|
|
47
|
+
payload.start_line = +args[i];
|
|
48
|
+
} else if (option === '--type') {
|
|
49
|
+
if (!['all', 'folder', 'file'].includes(args[i])) throw invalid('--type 必须是 all、folder 或 file。');
|
|
50
|
+
payload.type = args[i];
|
|
51
|
+
} else {
|
|
52
|
+
if (!args[i].trim()) throw invalid(option + ' 需要非空参数。');
|
|
53
|
+
if (command === 'grep' && option === '--pattern') {
|
|
54
|
+
if ([...args[i]].length > 200 || payload.patterns.length >= 10) throw invalid('--pattern 最多 10 次,每个最多 200 个字符。');
|
|
55
|
+
payload.patterns.push(args[i]);
|
|
56
|
+
} else payload[option.slice(2)] = option === '--query' ? args[i].trim() : args[i];
|
|
57
|
+
}
|
|
58
|
+
} else {
|
|
59
|
+
if (command === 'fetch' || pathSet || args[i].startsWith('-')) throw unknownOption(args[i], ['--limit', ...(command !== 'fetch' ? ['--cursor'] : []), ...extraOptions, ...(command === 'grep' ? ['--ignore-case'] : [])]);
|
|
60
|
+
payload.path = args[i]; pathSet = true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (command === 'fetch' && !payload.file) throw invalid('--file 不能为空。');
|
|
64
|
+
if (command === 'search' && !payload.query) throw invalid('--query 不能为空。');
|
|
65
|
+
if (command === 'grep' && !payload.patterns.length) throw invalid('至少需要一个 --pattern。');
|
|
66
|
+
const schema = namespace === 'school-library' && command === 'search' ? {
|
|
67
|
+
items: [{ ...nullableFields({ type: 'string', file_id: 'string', kind: 'string', space: 'string', name: 'string',
|
|
68
|
+
path: 'string', file_ext: 'string', updated_at: 'string', web_url: 'string', score: 'number' }), snippets: [snippet] }],
|
|
69
|
+
next_cursor: nullable('string'),
|
|
70
|
+
} : schemas[command];
|
|
71
|
+
return gateway.execute('school-library:read', namespace + '/' + command, payload, schema);
|
|
72
|
+
}
|
|
73
|
+
module.exports = { read, snippet };
|