@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
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const { unknownOption } = require('./suggestions.cjs');
|
|
2
|
+
const { Failure, invalid } = require('./result.cjs');
|
|
3
|
+
|
|
4
|
+
// The flags remain command-specific; no unrecognized option reaches the Gateway.
|
|
5
|
+
function options(args, values, booleans = [], raw = [], strict = false) {
|
|
6
|
+
const payload = {};
|
|
7
|
+
const seen = new Set();
|
|
8
|
+
for (let i = 0; i < args.length; i++) {
|
|
9
|
+
const flag = args[i];
|
|
10
|
+
const name = flag.slice(2).replaceAll('-', '_');
|
|
11
|
+
if (flag.startsWith('--') && (values.includes(name) || booleans.includes(name)) && name !== 'label_id') {
|
|
12
|
+
if (seen.has(name)) throw invalid(flag + ' 不能重复传入。');
|
|
13
|
+
seen.add(name);
|
|
14
|
+
}
|
|
15
|
+
if (booleans.includes(name) && flag.startsWith('--')) { payload[name] = true; continue; }
|
|
16
|
+
if (!flag.startsWith('--') || !values.includes(name)) throw unknownOption(flag, values.concat(booleans).map(name => '--' + name.replaceAll('_', '-')));
|
|
17
|
+
const value = args[++i];
|
|
18
|
+
if (!value?.trim() || ['--help', '-h'].includes(value)
|
|
19
|
+
|| (value.startsWith('--') && values.concat(booleans).includes(value.slice(2).replaceAll('-', '_')))
|
|
20
|
+
|| (strict && value.startsWith('--'))) throw invalid(flag + ' 需要非空参数。');
|
|
21
|
+
if (name === 'label_id') (payload.label_ids ||= []).push(raw.includes(name) ? value : value.trim());
|
|
22
|
+
else payload[name] = raw.includes(name) ? value : value.trim();
|
|
23
|
+
}
|
|
24
|
+
return payload;
|
|
25
|
+
}
|
|
26
|
+
function required(payload, ...names) {
|
|
27
|
+
for (const name of names) if (!payload[name]) throw invalid('--' + name.replaceAll('_', '-') + ' 为必填参数。');
|
|
28
|
+
}
|
|
29
|
+
function confirm(payload) {
|
|
30
|
+
if (!payload.yes) throw new Failure(10, 'confirmation', 'confirmation_required', '执行前必须明确传入 --yes。');
|
|
31
|
+
delete payload.yes;
|
|
32
|
+
}
|
|
33
|
+
function limit(payload, maximum) {
|
|
34
|
+
if (!/^[+]?\d+$/.test(String(payload.limit)) || +payload.limit < 1 || +payload.limit > maximum) throw invalid(`--limit 必须在 1 到 ${maximum} 之间。`);
|
|
35
|
+
payload.limit = +payload.limit;
|
|
36
|
+
}
|
|
37
|
+
module.exports = { options, required, confirm, limit };
|
package/lib/auth.cjs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
const { setTimeout: wait } = require('node:timers/promises');
|
|
2
|
+
const { unknownOption } = require('./suggestions.cjs');
|
|
3
|
+
const { cleanup, attach } = require('./diagnostics.cjs');
|
|
4
|
+
const { Failure, invalid, success, optionalFields, cleanupFailure } = require('./result.cjs');
|
|
5
|
+
const { loadConfig, expired, putCredential, removeCredential, saveConfig } = require('./storage.cjs');
|
|
6
|
+
const { tokenFor } = require('./session.cjs');
|
|
7
|
+
const { version } = require('./version.cjs');
|
|
8
|
+
const { request, publicResult, gatewayURL } = require('./gateway.cjs');
|
|
9
|
+
|
|
10
|
+
const user = { user_id: 'string', name: 'string' };
|
|
11
|
+
const tenant = { tenant_id: 'string', name: 'string' };
|
|
12
|
+
const authorization = { event: 'string', user, tenant, requested_scopes: ['string'], newly_granted_scopes: ['string'],
|
|
13
|
+
already_granted_scopes: ['string'], granted_scopes: ['string'], expires_at: 'string' };
|
|
14
|
+
const pending = { event: 'string', authorization_url: 'string', device_code: 'string', expires_at: 'string', requested_scopes: ['string'] };
|
|
15
|
+
function invalidAuthorization(message, envelope) {
|
|
16
|
+
const error = new Failure(1, 'api', 'invalid_response', message);
|
|
17
|
+
if (typeof envelope.request_id === 'string' && envelope.request_id) error.result.value.request_id = envelope.request_id;
|
|
18
|
+
return error;
|
|
19
|
+
}
|
|
20
|
+
function loginOptions(args, allowDeviceCode = true) {
|
|
21
|
+
const options = allowDeviceCode ? ['--scope', '--device-code'] : ['--scope'];
|
|
22
|
+
const scopes = [];
|
|
23
|
+
let deviceCode = '';
|
|
24
|
+
for (let i = 0; i < args.length; i++) {
|
|
25
|
+
const option = args[i];
|
|
26
|
+
if (!options.includes(option)) throw unknownOption(option, options);
|
|
27
|
+
if (option === '--device-code' && deviceCode) throw invalid(option + ' 不能重复传入。');
|
|
28
|
+
if (++i >= args.length || !args[i].trim() || ['--scope', '--device-code', '--help', '-h'].includes(args[i])
|
|
29
|
+
|| (!allowDeviceCode && args[i].startsWith('-'))) throw invalid(option + ' 需要非空参数。');
|
|
30
|
+
if (option === '--scope') scopes.push(args[i]); else deviceCode = args[i];
|
|
31
|
+
}
|
|
32
|
+
if (deviceCode) {
|
|
33
|
+
if (scopes.length) throw invalid('--device-code 不能与 --scope 同时使用。');
|
|
34
|
+
}
|
|
35
|
+
if (!deviceCode && !scopes.length) throw invalid('缺少 --scope。请使用原业务错误返回的 error.required_scopes,每个权限分别传入一次 --scope。');
|
|
36
|
+
return { scopes, deviceCode };
|
|
37
|
+
}
|
|
38
|
+
async function login(args, signal) {
|
|
39
|
+
const { scopes, deviceCode } = loginOptions(args);
|
|
40
|
+
if (deviceCode) return complete(deviceCode, signal);
|
|
41
|
+
const config = await loadConfig();
|
|
42
|
+
const token = expired(config.current_profile) ? '' : await tokenFor(config.current_profile);
|
|
43
|
+
try {
|
|
44
|
+
const result = await request(config, 'auth/sessions', token, { requested_scopes: scopes,
|
|
45
|
+
client_id: 'yunzhi-cli', cli_version: version, source: 'workbuddy' }, signal);
|
|
46
|
+
const output = publicResult(result, pending);
|
|
47
|
+
const data = output.value.data;
|
|
48
|
+
let validURL = false, urlError;
|
|
49
|
+
try { const url = new URL(data.authorization_url); validURL = ['http:', 'https:'].includes(url.protocol) && !url.username && !url.password; } catch (error) { urlError = error; }
|
|
50
|
+
if (data.event !== 'authorization_pending' || !validURL || !data.device_code.trim()
|
|
51
|
+
|| !Array.isArray(data.requested_scopes) || !data.requested_scopes.length
|
|
52
|
+
|| !Number.isFinite(Date.parse(data.expires_at))) {
|
|
53
|
+
const failure = invalidAuthorization('Agent Gateway 返回了无效授权会话。', result);
|
|
54
|
+
throw urlError ? attach(failure, urlError) : failure;
|
|
55
|
+
}
|
|
56
|
+
output.value.data.message = '授权会话已创建。请向用户展示 authorization_url,等待用户明确确认网页授权完成后,使用 device_code 兑换登录态。';
|
|
57
|
+
return output;
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (token && error.result?.value.error.type === 'authentication' && error.result.value.error.subtype === 'auth_required') {
|
|
60
|
+
const failure = new Failure(3, 'authentication', 'stale_session_requires_logout',
|
|
61
|
+
'当前 CLI 登录已失效。请执行 auth logout,成功后使用本次所需权限重新创建授权会话。', { required_scopes: scopes });
|
|
62
|
+
if (error.result.value.request_id) failure.result.value.request_id = error.result.value.request_id;
|
|
63
|
+
throw attach(failure, error);
|
|
64
|
+
}
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
async function complete(deviceCode, signal) {
|
|
69
|
+
const config = await loadConfig();
|
|
70
|
+
const resolvedURL = gatewayURL(config);
|
|
71
|
+
const envelope = await request(config, 'auth/sessions/token', '', { device_code: deviceCode, client_id: 'yunzhi-cli' }, signal);
|
|
72
|
+
signal?.throwIfAborted();
|
|
73
|
+
const result = publicResult(envelope, authorization);
|
|
74
|
+
const data = result.value.data;
|
|
75
|
+
if (data.event !== 'authorization_complete' || !data?.user?.user_id.trim() || !data.tenant?.tenant_id.trim()
|
|
76
|
+
|| !Array.isArray(data.granted_scopes) || !Number.isFinite(Date.parse(data.expires_at))) {
|
|
77
|
+
throw invalidAuthorization('Agent Gateway 返回了无效授权响应。', envelope);
|
|
78
|
+
}
|
|
79
|
+
const oldProfile = config.current_profile;
|
|
80
|
+
const reuse = envelope.data.reuse_existing_credential === true;
|
|
81
|
+
let reference;
|
|
82
|
+
if (reuse) {
|
|
83
|
+
if (!oldProfile || expired(oldProfile) || !await tokenFor(oldProfile)) {
|
|
84
|
+
throw new Failure(3, 'authentication', 'stale_session_requires_logout', '当前 CLI 登录已失效,无法复用本地登录态。请报告错误并停止本次兑换。');
|
|
85
|
+
}
|
|
86
|
+
if (oldProfile.user?.user_id !== data.user.user_id || oldProfile.tenant?.tenant_id !== data.tenant.tenant_id) {
|
|
87
|
+
throw invalidAuthorization('授权响应的用户或组织与待复用的本地登录态不一致。', envelope);
|
|
88
|
+
}
|
|
89
|
+
reference = oldProfile.credential_ref;
|
|
90
|
+
} else {
|
|
91
|
+
if (typeof envelope.data.access_token !== 'string' || !envelope.data.access_token) {
|
|
92
|
+
throw invalidAuthorization('授权响应缺少 Access Token。', envelope);
|
|
93
|
+
}
|
|
94
|
+
reference = await putCredential(envelope.data.access_token);
|
|
95
|
+
}
|
|
96
|
+
config.gateway_url = resolvedURL;
|
|
97
|
+
config.current_profile = { credential_ref: reference, user: data.user, tenant: data.tenant,
|
|
98
|
+
granted_scopes: data.granted_scopes, expires_at: data.expires_at };
|
|
99
|
+
try { await saveConfig(config); }
|
|
100
|
+
catch (error) {
|
|
101
|
+
if (!reuse) {
|
|
102
|
+
try { await removeCredential(reference); }
|
|
103
|
+
catch (cleanupError) { cleanup(error, cleanupError); }
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
if (!reuse && oldProfile) {
|
|
108
|
+
try { await removeCredential(oldProfile.credential_ref); }
|
|
109
|
+
catch (error) { throw cleanupFailure(error, result); }
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
async function localStatus(args, signal) {
|
|
114
|
+
if (args.filter(arg => arg === '--verify').length > 1) throw invalid('--verify 不能重复传入。');
|
|
115
|
+
if (args.some(arg => arg !== '--verify')) throw unknownOption(args.find(arg => arg !== '--verify'), ['--verify']);
|
|
116
|
+
const config = await loadConfig();
|
|
117
|
+
const profile = config.current_profile;
|
|
118
|
+
if (!profile) return success({ authenticated: false, verified: false, token_status: 'missing' });
|
|
119
|
+
const last = { user: profile.user, tenant: profile.tenant };
|
|
120
|
+
const token = await tokenFor(profile);
|
|
121
|
+
if (!token) return success({ authenticated: false, verified: false, token_status: 'missing', last_known_profile: last });
|
|
122
|
+
if (expired(profile)) return success({ authenticated: false, verified: false, token_status: 'expired', expires_at: profile.expires_at, last_known_profile: last });
|
|
123
|
+
if (!args.includes('--verify')) return success({ authenticated: true, verified: false, user: profile.user, tenant: profile.tenant,
|
|
124
|
+
granted_scopes: profile.granted_scopes, expires_at: profile.expires_at, token_status: 'unverified' });
|
|
125
|
+
try {
|
|
126
|
+
const timeout = AbortSignal.timeout(8000);
|
|
127
|
+
const result = await request(config, 'auth/me', token, undefined, signal ? AbortSignal.any([signal, timeout]) : timeout);
|
|
128
|
+
if (typeof result.data?.user?.user_id !== 'string' || !result.data.user.user_id.trim()
|
|
129
|
+
|| typeof result.data?.tenant?.tenant_id !== 'string' || !result.data.tenant.tenant_id.trim()) {
|
|
130
|
+
throw invalidAuthorization('Agent Gateway 返回了无效身份响应。', result);
|
|
131
|
+
}
|
|
132
|
+
return publicResult({ ...result, data: { ...result.data, authenticated: true, verified: true } }, {
|
|
133
|
+
authenticated: 'boolean', verified: 'boolean', user, tenant, granted_scopes: ['string'], token_status: 'string', expires_at: 'string',
|
|
134
|
+
});
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error.result?.value.error.type === 'authentication' && error.result.value.error.subtype === 'auth_required') {
|
|
137
|
+
return success({ authenticated: false, verified: true, token_status: 'invalid', last_known_profile: last });
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
async function status(args, signal) {
|
|
143
|
+
const result = await localStatus(args, signal);
|
|
144
|
+
return publicResult(result.value, { authenticated: 'boolean', verified: 'boolean', token_status: 'string',
|
|
145
|
+
...optionalFields({ user, tenant, granted_scopes: ['string'], expires_at: 'string', last_known_profile: { user, tenant } }) });
|
|
146
|
+
}
|
|
147
|
+
async function logout(args) {
|
|
148
|
+
if (args.length) throw unknownOption(args[0], []);
|
|
149
|
+
const config = await loadConfig();
|
|
150
|
+
if (config.current_profile) await removeCredential(config.current_profile.credential_ref);
|
|
151
|
+
delete config.current_profile;
|
|
152
|
+
await saveConfig(config);
|
|
153
|
+
return success({ status: 'logged_out' });
|
|
154
|
+
}
|
|
155
|
+
async function connect(args) {
|
|
156
|
+
loginOptions(args, false);
|
|
157
|
+
const limit = AbortSignal.timeout(300000);
|
|
158
|
+
let expiry;
|
|
159
|
+
try {
|
|
160
|
+
const current = await status(['--verify'], limit);
|
|
161
|
+
if (current.value.data.authenticated) return current;
|
|
162
|
+
// Only a confirmed missing, expired or invalid login reaches this point.
|
|
163
|
+
await logout([]);
|
|
164
|
+
limit.throwIfAborted();
|
|
165
|
+
const session = (await login(args, limit)).value.data;
|
|
166
|
+
const remaining = Date.parse(session.expires_at) - Date.now();
|
|
167
|
+
if (remaining <= 0) throw new Failure(3, 'authentication', 'authorization_expired', '授权会话已过期,请重新连接。');
|
|
168
|
+
expiry = remaining < 300000 ? AbortSignal.timeout(remaining) : undefined;
|
|
169
|
+
const signal = expiry ? AbortSignal.any([limit, expiry]) : limit;
|
|
170
|
+
process.stderr.write(session.authorization_url + '\n');
|
|
171
|
+
while (true) {
|
|
172
|
+
await wait(3000, undefined, { signal });
|
|
173
|
+
try {
|
|
174
|
+
const completed = await complete(session.device_code, signal);
|
|
175
|
+
completed.value.data.authenticated = true;
|
|
176
|
+
completed.value.data.message = '校园号已连接,登录状态已保存。';
|
|
177
|
+
return completed;
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error.result?.value.error.type !== 'authentication'
|
|
180
|
+
|| error.result.value.error.subtype !== 'authorization_pending') throw error;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
const failure = error.result?.value.error;
|
|
185
|
+
if (failure?.operation_completed || failure?.cleanup_failed || failure?.partial_file) throw error;
|
|
186
|
+
if (limit.aborted || expiry?.aborted) throw attach(new Failure(3, 'authentication',
|
|
187
|
+
limit.aborted ? 'authorization_timeout' : 'authorization_expired',
|
|
188
|
+
limit.aborted ? '等待授权超过 5 分钟,请重新连接。' : '授权会话已过期,请重新连接。'), error);
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
module.exports = { login, connect, status, logout };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const { optionalFields, nullableFields, nullable, required } = require('./result.cjs');
|
|
2
|
+
const { snippet } = require('./lesson-prep.cjs');
|
|
3
|
+
const resource = { ...optionalFields({ type: 'string', file_id: 'string', kind: 'string', space: 'string', name: 'string',
|
|
4
|
+
path: 'string', file_ext: 'string', file_size: 'integer', label_ids: ['string'], updated_at: 'string', web_url: 'string' }), category_id: nullable('string') };
|
|
5
|
+
const folder = optionalFields({ event: 'string', folder_id: 'string', name: 'string', path: 'string', deleted_folder_count: 'integer' });
|
|
6
|
+
const textbook = optionalFields({ event: 'string', textbook_id: 'string', folder_id: 'string', name: 'string', path: 'string' });
|
|
7
|
+
module.exports = {
|
|
8
|
+
errors: {
|
|
9
|
+
...require('./report-schemas.cjs').errors,
|
|
10
|
+
invalid_pattern: { syntax: ['string'], examples: ['string'] },
|
|
11
|
+
file_report_pending: { report_id: 'string', report_status: 'string' },
|
|
12
|
+
textbook_already_added: { textbook_id: 'string', folder_id: 'string', name: 'string', path: 'string' },
|
|
13
|
+
page_cleanup_failed: { file_id: 'string', delete_stage: 'string', lesson_registration_removed: 'boolean' },
|
|
14
|
+
|
|
15
|
+
},
|
|
16
|
+
update: resource, move: resource, remove: { removed: 'boolean', file_id: 'string', mode: 'string' },
|
|
17
|
+
'folder/create': folder, 'folder/update': folder, 'folder/delete': folder,
|
|
18
|
+
'textbook/add': textbook, 'textbook/remove': textbook,
|
|
19
|
+
'textbook/available': { items: [nullableFields({ textbook_id: 'string', name: 'string', school_stage: 'string', subject: 'string', grade: 'string', version: 'string', volume: 'string' })], next_cursor: nullable('string') },
|
|
20
|
+
'reference/search': { items: [{ ...nullableFields({ file_id: 'string', space: 'string', name: 'string', provider: 'string', file_ext: 'string', updated_at: 'string', web_url: 'string', score: 'number' }), snippets: [snippet] }], next_cursor: nullable('string') },
|
|
21
|
+
'reference/add': { file_id: 'string', source_file_id: 'string', folder_id: 'string', kind: 'string', name: 'string', path: 'string', message: 'string' },
|
|
22
|
+
'upload-options': { target: required({ folder_id: required('string'), name: required('string'), path: required('string') }), category_options: required([{ category_id: required('string'), name: required('string') }]), label_options: required([{ label_id: required('string'), name: required('string') }]) },
|
|
23
|
+
web: { pages: [{ name: 'string', web_url: 'string' }] },
|
|
24
|
+
};
|
package/lib/business.cjs
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
const { invalid } = require('./result.cjs');
|
|
2
|
+
const { writeScope } = require('./session.cjs');
|
|
3
|
+
const { options, required, confirm, limit } = require('./arguments.cjs');
|
|
4
|
+
const gateway = require('./gateway.cjs');
|
|
5
|
+
|
|
6
|
+
function parse(namespace, command, args) {
|
|
7
|
+
let payload, scope = writeScope(namespace);
|
|
8
|
+
switch (command) {
|
|
9
|
+
case 'update':
|
|
10
|
+
payload = options(args, ['file_id', 'title', 'category_id', 'label_id'], ['clear_category', 'clear_labels']);
|
|
11
|
+
required(payload, 'file_id');
|
|
12
|
+
if (payload.category_id && payload.clear_category) throw invalid('--category-id 与 --clear-category 不能同时使用。');
|
|
13
|
+
if (payload.label_ids && payload.clear_labels) throw invalid('--label-id 与 --clear-labels 不能同时使用。');
|
|
14
|
+
if (Object.keys(payload).length === 1) throw invalid('update 至少需要一个修改项。');
|
|
15
|
+
break;
|
|
16
|
+
case 'move':
|
|
17
|
+
case 'reference/add':
|
|
18
|
+
payload = options(args, [command === 'move' ? 'file_id' : 'source_file_id', 'folder_id']);
|
|
19
|
+
required(payload, command === 'move' ? 'file_id' : 'source_file_id', 'folder_id');
|
|
20
|
+
break;
|
|
21
|
+
case 'remove':
|
|
22
|
+
case 'textbook/remove':
|
|
23
|
+
case 'folder/delete': {
|
|
24
|
+
const id = command === 'remove' ? 'file_id' : 'folder_id';
|
|
25
|
+
payload = options(args, [id], ['yes']); required(payload, id); confirm(payload); break;
|
|
26
|
+
}
|
|
27
|
+
case 'folder/create':
|
|
28
|
+
case 'folder/update': {
|
|
29
|
+
const id = command === 'folder/create' ? 'parent_folder_id' : 'folder_id';
|
|
30
|
+
payload = options(args, [id, 'name']); required(payload, id, 'name');
|
|
31
|
+
if ([...payload.name].length > 100 || payload.name.includes('/') || ['.', '..'].includes(payload.name)) throw invalid('--name 必须为 1 到 100 个字符,不能包含 /,也不能为 . 或 ..。');
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
case 'textbook/add':
|
|
35
|
+
payload = options(args, ['textbook_id']); required(payload, 'textbook_id'); break;
|
|
36
|
+
case 'textbook/available':
|
|
37
|
+
case 'reference/search':
|
|
38
|
+
payload = { query: null, limit: command === 'reference/search' ? 10 : 20, cursor: null,
|
|
39
|
+
...options(args, ['query', 'limit', 'cursor'], [], ['cursor', 'limit']) };
|
|
40
|
+
limit(payload, command === 'reference/search' ? 20 : 100);
|
|
41
|
+
if (command === 'reference/search') required(payload, 'query');
|
|
42
|
+
scope = 'school-library:read'; break;
|
|
43
|
+
case 'upload-options':
|
|
44
|
+
payload = options(args, ['folder_id'], [], ['folder_id']); required(payload, 'folder_id'); scope = 'school-library:read'; break;
|
|
45
|
+
case 'web':
|
|
46
|
+
payload = options(args, []); scope = 'school-library:read'; break;
|
|
47
|
+
default: throw invalid('未知命令。');
|
|
48
|
+
}
|
|
49
|
+
return { payload, scope };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function execute(namespace, command, args) {
|
|
53
|
+
const { payload, scope } = parse(namespace, command, args);
|
|
54
|
+
const schemas = require('./business-schemas.cjs');
|
|
55
|
+
return gateway.execute(scope, namespace + '/' + command, payload, schemas[command]);
|
|
56
|
+
}
|
|
57
|
+
module.exports = { execute };
|
package/lib/cli.cjs
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
const { attach } = require('./diagnostics.cjs');
|
|
2
|
+
const { version } = require('./version.cjs');
|
|
3
|
+
const { Failure, invalid, success } = require('./result.cjs');
|
|
4
|
+
const guidance = require('./command-guidance.cjs');
|
|
5
|
+
const { unknownCommand, unknownOption } = require('./suggestions.cjs');
|
|
6
|
+
const usage = {
|
|
7
|
+
'auth login': '--scope <scope> [--scope <scope>...]\n 或 auth login --device-code <device_code>',
|
|
8
|
+
'auth connect': '--scope <scope> [--scope <scope>...]',
|
|
9
|
+
'auth status': '[--verify]', 'auth logout': '',
|
|
10
|
+
'lesson-prep ls': '[path] [--limit 1..200] [--cursor <cursor>]',
|
|
11
|
+
'lesson-prep glob': '[path] [--pattern <pattern>] [--type all|folder|file] [--limit 1..200] [--cursor <cursor>]',
|
|
12
|
+
'lesson-prep search': '[path] --query <query> [--limit 1..20] [--cursor <cursor>]',
|
|
13
|
+
'lesson-prep grep': '[path] --pattern <text> [--pattern <text>...] [--glob <glob>] [--ignore-case] [--limit 1..100] [--cursor <cursor>]',
|
|
14
|
+
'lesson-prep fetch': '--file <file_id|absolute_business_path> [--start-line <line>] [--limit 1..500]',
|
|
15
|
+
'learning-tool ls': '[path] [--limit 1..200] [--cursor <cursor>]',
|
|
16
|
+
'learning-tool upload-options': '--folder-id <folder_id>',
|
|
17
|
+
'learning-tool upload': '--source <path> --folder-id <folder_id> --title <title> [--category-id <category_id>] [--label-id <label_id>...]',
|
|
18
|
+
'learning-tool update': '--file-id <file_id> [--title <title>] [--category-id <category_id> | --clear-category] [--label-id <label_id>... | --clear-labels]',
|
|
19
|
+
'learning-tool move': '--file-id <file_id> --folder-id <folder_id> [--category-id <category_id> | --clear-category] [--label-id <label_id>... | --clear-labels]',
|
|
20
|
+
'learning-tool remove': '--file-id <file_id> --yes',
|
|
21
|
+
'config set': 'gateway-url <http(s)-url>', version: '',
|
|
22
|
+
};
|
|
23
|
+
for (const namespace of ['lesson-prep', 'school-library']) {
|
|
24
|
+
for (const command of ['ls', 'glob', 'search', 'grep', 'fetch']) usage[namespace + ' ' + command] = usage['lesson-prep ' + command];
|
|
25
|
+
for (const [command, flags] of Object.entries({
|
|
26
|
+
download: '--file <file_id|absolute_business_path> [--output <local_file>] [--overwrite]',
|
|
27
|
+
'upload-options': '--folder-id <folder_id>',
|
|
28
|
+
upload: '--file <local_file> --folder-id <folder_id> [--title <title>] [--category-id <category_id>] [--label-id <label_id>...]',
|
|
29
|
+
update: '--file-id <file_id> [--title <title>] [--category-id <category_id> | --clear-category] [--label-id <label_id>... | --clear-labels]',
|
|
30
|
+
move: '--file-id <file_id> --folder-id <folder_id>', remove: '--file-id <file_id> --yes',
|
|
31
|
+
'folder create': '--parent-folder-id <folder_id> --name <name>', 'folder update': '--folder-id <folder_id> --name <name>', 'folder delete': '--folder-id <folder_id> --yes',
|
|
32
|
+
'textbook available': '[--query <text>] [--limit 1..100] [--cursor <cursor>]', 'textbook add': '--textbook-id <textbook_id>', 'textbook remove': '--folder-id <folder_id> --yes',
|
|
33
|
+
'report submit': '--file-id <file_id>' + (namespace === 'lesson-prep' ? ' [--target-folder-id <folder_id>]' : '') + ' [--remark <remark>]',
|
|
34
|
+
'report list': '[--status pending|approved|rejected|delisted|cancelled] [--limit 1..100] [--cursor <cursor>]',
|
|
35
|
+
'report show': '--report-id <report_id>', 'report history': '--report-id <report_id>', 'report cancel': '--report-id <report_id>',
|
|
36
|
+
})) usage[namespace + ' ' + command] = flags;
|
|
37
|
+
}
|
|
38
|
+
Object.assign(usage, {
|
|
39
|
+
'lesson-prep reference search': '--query <text> [--limit 1..20] [--cursor <cursor>]',
|
|
40
|
+
'lesson-prep reference add': '--source-file-id <file_id> --folder-id <folder_id>',
|
|
41
|
+
'school-library web': '',
|
|
42
|
+
'school-library review list': '[--status all|pending|approved|rejected|delisted] [--query <text>] [--limit 1..100] [--cursor <cursor>]',
|
|
43
|
+
'school-library review show': '--report-id <report_id> [--folder-id <folder_id>]',
|
|
44
|
+
'school-library review approve': '--report-id <report_id> [--folder-id <folder_id>] --yes',
|
|
45
|
+
'school-library review reject': '--report-id <report_id> --reason <reason> --yes',
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const summaries = {
|
|
49
|
+
auth: '登录与授权', config: '本地配置', 'lesson-prep': '备课空间', 'school-library': '校本库',
|
|
50
|
+
'learning-tool': '本地 HTML 学具制作成品与个人备课学具',
|
|
51
|
+
folder: '目录管理', textbook: '教材管理', report: '资源上报', review: '上报审批', reference: '资源引用',
|
|
52
|
+
'auth connect': '连接校园号并等待授权完成',
|
|
53
|
+
'auth login': '创建授权会话或兑换登录态', 'auth status': '查看当前登录身份和组织',
|
|
54
|
+
'auth logout': '退出本机 CLI 登录', 'config set': '设置 Gateway 地址', version: '显示当前 CLI 发行版本',
|
|
55
|
+
ls: '列出目录的直接子项', glob: '按名称或路径查找文件和目录', search: '搜索资源正文',
|
|
56
|
+
grep: '在解析正文中查找指定文字', fetch: '读取资源的解析正文', download: '将资源原文件保存到本地',
|
|
57
|
+
'upload-options': '查看目标目录可选的分类和标签', upload: '上传本地文件并创建资源',
|
|
58
|
+
update: '修改资源标题、分类或标签', move: '移动资源到指定目录', remove: '移除资源',
|
|
59
|
+
'folder create': '创建目录', 'folder update': '修改目录名称', 'folder delete': '删除目录',
|
|
60
|
+
'textbook available': '查找可添加的教材', 'textbook add': '添加教材目录', 'textbook remove': '移除教材目录',
|
|
61
|
+
'report submit': '提交资源上报,等待审批', 'report list': '查看自己的上报记录',
|
|
62
|
+
'report show': '查看上报详情', 'report history': '查看资源的历次上报', 'report cancel': '撤销待审批上报',
|
|
63
|
+
'reference search': '搜索可引用的源资源', 'reference add': '将源资源引用到备课目录',
|
|
64
|
+
web: '获取校本库业务页面地址', 'review list': '查看审批列表', 'review show': '查看审批详情及目标预览',
|
|
65
|
+
'review approve': '通过上报并创建目标资源', 'review reject': '拒绝上报并记录原因',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const loginHelp = `登录与授权:创建授权会话,或在网页授权完成后兑换登录态。
|
|
69
|
+
|
|
70
|
+
1. 创建授权会话(首次登录或追加权限)
|
|
71
|
+
用法:yunzhi-cli auth login --scope <scope> [--scope <scope>...]
|
|
72
|
+
参数:
|
|
73
|
+
--scope 必填,可重复,无默认值。使用原业务错误返回的 error.required_scopes,每个权限分别传入一次。
|
|
74
|
+
仅要求登录或切换账号、组织且尚未明确业务时,先询问要办理的业务,不自行选择默认权限。
|
|
75
|
+
示例:
|
|
76
|
+
yunzhi-cli auth login --scope school-library:read
|
|
77
|
+
返回:
|
|
78
|
+
ok=true、data.event=authorization_pending 表示会话创建成功,尚未完成登录。
|
|
79
|
+
data.authorization_url 是完整授权地址;data.device_code 用于后续兑换。
|
|
80
|
+
data.expires_at 是会话过期时间;data.requested_scopes 是本次权限;data.message 提示下一步。
|
|
81
|
+
下一步:
|
|
82
|
+
向用户展示完整授权地址,等待用户明确确认网页授权完成后再兑换,不轮询。
|
|
83
|
+
|
|
84
|
+
2. 兑换登录态
|
|
85
|
+
用法:yunzhi-cli auth login --device-code <device_code>
|
|
86
|
+
参数:
|
|
87
|
+
--device-code 必填,使用创建会话时返回的 data.device_code,不能与 --scope 同时使用。
|
|
88
|
+
示例:
|
|
89
|
+
yunzhi-cli auth login --device-code <device_code>
|
|
90
|
+
返回:
|
|
91
|
+
ok=true、data.event=authorization_complete 表示登录授权完成,可以恢复原业务命令。
|
|
92
|
+
data.user、data.tenant 是当前用户和组织;data.granted_scopes 是已授权权限;data.expires_at 是登录过期时间。
|
|
93
|
+
下一步:
|
|
94
|
+
成功后重试原业务命令。
|
|
95
|
+
|
|
96
|
+
除 --scope 外,参数不能重复传入。
|
|
97
|
+
默认输出 JSON。失败时阅读 error.message 及返回的引导字段,并按认证指南处理。
|
|
98
|
+
`;
|
|
99
|
+
|
|
100
|
+
async function execute(args) {
|
|
101
|
+
if (args[0] === 'help' || ['--help', '-h'].includes(args.at(-1))) {
|
|
102
|
+
const command = (args[0] === 'help' ? args.slice(1) : args.slice(0, -1)).join(' ');
|
|
103
|
+
const commands = Object.keys(usage).filter(item => !command || item === command || item.startsWith(command + ' '));
|
|
104
|
+
if (usage[command] !== undefined) {
|
|
105
|
+
const text = command === 'auth login' ? loginHelp
|
|
106
|
+
: guidance.render(command, usage[command]);
|
|
107
|
+
const reference = require('./command-reference.cjs').reference(command);
|
|
108
|
+
return { code: 0, text: text + (reference ? '\n命令参考(相对产品 Skill 根目录):\n' + reference + '\n' : '') };
|
|
109
|
+
}
|
|
110
|
+
if (commands.length) {
|
|
111
|
+
const depth = command ? command.split(' ').length + 1 : 1;
|
|
112
|
+
const children = [...new Set(commands.map(item => item.split(' ').slice(0, depth).join(' ')))];
|
|
113
|
+
const title = command || '校园号 CLI';
|
|
114
|
+
return { code: 0, text: `${title}\n\n可用命令:\n` + children.map(item =>
|
|
115
|
+
` ${item} ${summaries[item] || summaries[item.split(' ').slice(1).join(' ')]}\n`).join('')
|
|
116
|
+
+ '\n使用 yunzhi-cli <命令> --help 查看下一级或命令详情。\n' };
|
|
117
|
+
}
|
|
118
|
+
throw unknownCommand(command.split(' ').filter(Boolean), Object.keys(usage));
|
|
119
|
+
}
|
|
120
|
+
if (!Object.keys(usage).some(name => name.split(' ').every((word, index) => args[index] === word))) throw unknownCommand(args, Object.keys(usage));
|
|
121
|
+
if (args[0] === 'version' && args.length > 1) throw unknownOption(args[1], []);
|
|
122
|
+
if (args[0] === 'version' && args.length === 1) {
|
|
123
|
+
return { code: 0, value: { ok: true, data: { version } } };
|
|
124
|
+
}
|
|
125
|
+
if (args[0] === 'learning-tool' && ['ls', 'upload-options', 'upload', 'update', 'move', 'remove'].includes(args[1]))
|
|
126
|
+
return require('./learning-tool.cjs').execute(args[1], args.slice(2));
|
|
127
|
+
if (['lesson-prep', 'school-library'].includes(args[0]) && ['ls', 'glob', 'search', 'grep', 'fetch'].includes(args[1])) return require('./lesson-prep.cjs').read(args[1], args.slice(2), args[0]);
|
|
128
|
+
if (['lesson-prep', 'school-library'].includes(args[0])) {
|
|
129
|
+
if (args[1] === 'download') return require('./download.cjs').download(args[0], args.slice(2));
|
|
130
|
+
if (args[1] === 'upload') return require('./upload.cjs').upload(args[0], args.slice(2));
|
|
131
|
+
if (args[1] === 'report' || (args[1] === 'review' && args[0] === 'school-library')) return require('./reports.cjs').execute(args[0], args[1] + '/' + args[2], args.slice(3));
|
|
132
|
+
const grouped = ['folder', 'textbook', 'reference'].includes(args[1]);
|
|
133
|
+
const command = grouped ? args[1] + '/' + args[2] : args[1];
|
|
134
|
+
if ((args[1] === 'reference' && args[0] !== 'lesson-prep') || (command === 'web' && args[0] !== 'school-library')) throw invalid('未知命令。');
|
|
135
|
+
return require('./business.cjs').execute(args[0], command, args.slice(grouped ? 3 : 2));
|
|
136
|
+
}
|
|
137
|
+
if (args[0] === 'auth' && ['login', 'connect', 'status', 'logout'].includes(args[1])) return require('./auth.cjs')[args[1]](args.slice(2));
|
|
138
|
+
if (args[0] === 'config' && args[1] === 'set') {
|
|
139
|
+
const filtered = args.slice(2);
|
|
140
|
+
if (filtered.some(arg => arg.startsWith('--'))) throw unknownOption(filtered.find(arg => arg.startsWith('--')), []);
|
|
141
|
+
if (filtered.length !== 2 || filtered[0] !== 'gateway-url') throw invalid('用法:config set gateway-url <http(s)-url>。');
|
|
142
|
+
const { gatewayURL } = require('./gateway.cjs');
|
|
143
|
+
const { loadConfig, saveConfig } = require('./storage.cjs');
|
|
144
|
+
let next;
|
|
145
|
+
try { next = gatewayURL({ gateway_url: filtered[1] }); } catch (error) { throw attach(invalid('Gateway URL 必须是有效的 HTTP(S) 地址。'), error); }
|
|
146
|
+
if (!filtered[1].trim()) throw invalid('Gateway URL 必须是有效的 HTTP(S) 地址。');
|
|
147
|
+
const config = await loadConfig();
|
|
148
|
+
if (config.current_profile && gatewayURL(config) !== next) throw new Failure(6, 'security', 'gateway_change_requires_logout', '切换 Gateway 前必须先退出当前 CLI 登录。');
|
|
149
|
+
config.gateway_url = next;
|
|
150
|
+
await saveConfig(config);
|
|
151
|
+
return success({ gateway_url: next });
|
|
152
|
+
}
|
|
153
|
+
return { code: 2, value: { ok: false, error: {
|
|
154
|
+
type: 'validation', subtype: 'invalid_arguments', message: '未知命令。',
|
|
155
|
+
} } };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function run(args) {
|
|
159
|
+
const command = Object.keys(usage).filter(item => item.split(' ').every((word, index) => args[index] === word))
|
|
160
|
+
.sort((left, right) => right.length - left.length)[0];
|
|
161
|
+
let result;
|
|
162
|
+
try { result = await execute(args); }
|
|
163
|
+
catch (error) { if (error instanceof Failure) result = error.result; else throw error; }
|
|
164
|
+
if (command && result.value?.ok && !guidance.validResult(command, result.value.data)) {
|
|
165
|
+
const failure = new Failure(1, 'api', 'invalid_response', guidance.resultProblem(command, result.value.data));
|
|
166
|
+
if (result.value.request_id) failure.result.value.request_id = result.value.request_id;
|
|
167
|
+
result = failure.result;
|
|
168
|
+
}
|
|
169
|
+
if (command && result.value) {
|
|
170
|
+
if (result.value.ok) {
|
|
171
|
+
const next = guidance.message(command, result.value.data);
|
|
172
|
+
if (next && !result.value.data.message) result.value.data.message = next;
|
|
173
|
+
} else {
|
|
174
|
+
const error = result.value.error;
|
|
175
|
+
if (error.candidates) error.hint = `请核对候选;使用 ${command} --help 查看参数,不自动执行候选。`;
|
|
176
|
+
const exchanging = command === 'auth login' && args.includes('--device-code');
|
|
177
|
+
if (!error.hint || ['请根据错误信息处理后重试。', '请修正参数后重试。'].includes(error.hint) || error.operation_completed || error.cleanup_failed
|
|
178
|
+
|| exchanging) error.hint = guidance.errorHint(command, error, exchanging);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return result;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
module.exports = { run };
|