@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,376 @@
|
|
|
1
|
+
// Public command guidance. Business execution and permission checks stay in their existing modules.
|
|
2
|
+
const definitions = {
|
|
3
|
+
'auth connect': {
|
|
4
|
+
purpose: '供 WorkBuddy 连接器首次连接校园号,并等待网页授权完成。', example: '--scope school-library:read',
|
|
5
|
+
result: '退出码 0 且 data.authenticated=true 表示已连接;完整授权 URL 实时单独写入 stderr,登录保存后才输出成功 JSON。',
|
|
6
|
+
next: '连接后按实际业务调用命令;有效登录即复用,缺少业务权限时通过 auth login 按需追加授权。',
|
|
7
|
+
boundary: '每 3 秒检查同一会话,最多等待 5 分钟并受会话有效期限制。只有明确待授权才继续;拒绝、取消、网络或存储错误停止。对话授权使用 auth login 的两步流程。', errors: 'auth',
|
|
8
|
+
},
|
|
9
|
+
'auth status': {
|
|
10
|
+
purpose: '查看 CLI 当前用户、组织及登录有效性。', example: '--verify',
|
|
11
|
+
result: 'ok=true 表示查询成功。data.authenticated 表示是否登录,data.verified 表示是否经服务端校验;token_status 为 missing、expired、unverified、invalid 或服务端返回的状态。user、tenant、granted_scopes、expires_at 是当前摘要;last_known_profile 仅为历史信息。',
|
|
12
|
+
next: '回答当前身份;未登录时如需办理业务,先执行该业务,按其返回的 required_scopes 授权。',
|
|
13
|
+
boundary: '不带 --verify 只查本地;本地缺少或已过期的登录态不会发起服务端校验。远程检查最多等待 8 秒,检查失败保留登录态。此命令不会登录或切换账号。', errors: 'auth',
|
|
14
|
+
},
|
|
15
|
+
'auth logout': {
|
|
16
|
+
purpose: '清理本机 CLI 的当前登录态。', example: '', result: 'ok=true、data.status=logged_out 表示本机退出完成。',
|
|
17
|
+
next: '报告本机退出结果;只有任务需要重新登录时,使用原业务所需权限创建授权会话。',
|
|
18
|
+
boundary: '不撤销服务端授权,不退出浏览器。', errors: 'auth',
|
|
19
|
+
},
|
|
20
|
+
'config set': {
|
|
21
|
+
purpose: '设置 CLI 请求使用的 Gateway 地址。', example: 'gateway-url https://gateway.example.com',
|
|
22
|
+
result: 'ok=true,data.gateway_url 为保存后的地址。', next: '报告保存的地址;不自动登录或调用业务。',
|
|
23
|
+
boundary: '支持 HTTP(S) 和部署路径前缀。已有本机登录态时,切换地址需先退出。', errors: 'config',
|
|
24
|
+
},
|
|
25
|
+
version: {
|
|
26
|
+
purpose: '查看当前 CLI 发行版本。', example: '', result: 'ok=true,data.version 为当前发布版本。',
|
|
27
|
+
next: '向用户报告版本。', boundary: '不联网、不安装或更新 CLI,不需要在业务调用前执行。', errors: 'local',
|
|
28
|
+
},
|
|
29
|
+
ls: {
|
|
30
|
+
purpose: '浏览当前空间指定目录的直接子项。', example: '/',
|
|
31
|
+
result: 'ok=true,data.entries 为本页文件和目录;空数组表示本次目录列表为空。条目含 name、path 及 file_id 或 folder_id;next_cursor 用于翻页。',
|
|
32
|
+
next: '按用户需求展示条目。需要更多时保留查询参数,原样回传 next_cursor;明确需要理解或下载资源时再 fetch 或 download。',
|
|
33
|
+
boundary: '只列直接子项,不递归。空列表不能证明正文索引状态。', errors: 'read',
|
|
34
|
+
},
|
|
35
|
+
glob: {
|
|
36
|
+
purpose: '在指定目录后代中按名称或业务路径查找文件、目录。', example: '/ --pattern "**/*.docx" --type file',
|
|
37
|
+
result: 'ok=true,data.matches 为匹配条目,next_cursor 用于翻页;空数组表示本次没有匹配。',
|
|
38
|
+
next: '展示匹配项;目标不唯一时请用户选择。需要下一页时保持条件不变并原样传回 next_cursor。',
|
|
39
|
+
boundary: '支持 *、**、?、[abc]、[!abc];不含通配符时按名称包含匹配。', errors: 'read',
|
|
40
|
+
},
|
|
41
|
+
search: {
|
|
42
|
+
purpose: '根据内容线索搜索当前空间可读资源。', example: '/ --query "分数教学"',
|
|
43
|
+
result: 'ok=true,data.items 含 file_id、name、path、score 和 snippets 等证据;next_cursor 用于翻页。空数组只表示本次没有匹配。',
|
|
44
|
+
next: '使用匹配证据回答或展示候选。需要完整解析正文时 fetch;目标不明确时询问用户。',
|
|
45
|
+
boundary: '不按名称替代内容检索;结果不证明资源是否已完成索引。组织、数据权限和搜索阈值由服务端决定。', errors: 'read',
|
|
46
|
+
},
|
|
47
|
+
grep: {
|
|
48
|
+
purpose: '在资源解析正文中查找指定的普通文字。', example: '/ --pattern "教学目标" --glob "**/*.docx"',
|
|
49
|
+
result: 'ok=true,data.matches 含 file_id、line、text、text_truncated 等;next_cursor 用于继续匹配行分页。',
|
|
50
|
+
next: '按匹配文字和行号报告;需要上下文时读取同一资源的解析正文。',
|
|
51
|
+
boundary: '不是正则搜索;多个 --pattern 为 OR。默认区分大小写,行号对应解析 Markdown。', errors: 'read',
|
|
52
|
+
},
|
|
53
|
+
fetch: {
|
|
54
|
+
purpose: '读取单个资源的解析正文,用于理解、总结和回答内容问题。', example: '--file <file_id> --start-line 1 --limit 200',
|
|
55
|
+
result: 'ok=true,data.content 是本段正文,start_line 是起始行,next_start_line 是续读位置;同时返回资源身份和元数据。',
|
|
56
|
+
next: '根据正文回答;有续读位置且任务需要时,以 next_start_line 继续。需要原文件则使用 download。',
|
|
57
|
+
boundary: '解析正文可能缺少原排版、图片、公式和批注;不等同于下载。待审批资源不能通过 RAG fetch 读取。', errors: 'read',
|
|
58
|
+
},
|
|
59
|
+
download: {
|
|
60
|
+
purpose: '下载资源原文件并保存到本地。', example: '--file <file_id> --output ./material.docx',
|
|
61
|
+
result: 'ok=true,data.local_path 是已保存文件的绝对路径;还可返回 file_id、name、size_bytes 等。',
|
|
62
|
+
next: '向用户提供实际 local_path。',
|
|
63
|
+
boundary: '--output 是文件路径,不是目录;省略时保存到当前目录并使用业务文件名。仅用户明确要求覆盖才传 --overwrite。', errors: 'download',
|
|
64
|
+
},
|
|
65
|
+
'learning-tool ls': {
|
|
66
|
+
purpose: '浏览当前老师个人备课空间中的学具。', example: '/', scope: 'school-library:read',
|
|
67
|
+
result: 'ok=true,data.entries 为目录和直接学具;学具返回 origin、run_url、cover_url 以及 can_update、can_move、can_remove。next_cursor 用于翻页。',
|
|
68
|
+
next: '按名称、完整路径、来源和实际能力展示结果;需要更多时保持路径与 limit,原样传回 next_cursor。',
|
|
69
|
+
boundary: '只展示 HTML/HTM 学具,不混入普通教案、课件或 PDF。能力以返回字段为准,不根据来源自行推断。', errors: 'read',
|
|
70
|
+
},
|
|
71
|
+
'learning-tool upload-options': {
|
|
72
|
+
purpose: '查询学具目标备课目录及其真实可用分类、标签。', example: '--folder-id <folder_id>', scope: 'school-library:read',
|
|
73
|
+
result: 'ok=true,data.target 为已解析目录;category_options、label_options 是可用于该目录的真实选项。',
|
|
74
|
+
next: '仅在老师已要求上传且需要元数据推荐时,使用返回 ID 完成已请求的上传。',
|
|
75
|
+
boundary: '只读,不上传学具。目录改变后重新核对选项;空选项列表有效。', errors: 'maintenance',
|
|
76
|
+
},
|
|
77
|
+
'learning-tool upload': {
|
|
78
|
+
purpose: '将老师指定的既有本地 HTML 学具成品托管并登记到个人备课空间。',
|
|
79
|
+
example: '--source ./分数比较 --folder-id <folder_id> --title "分数比较"', scope: 'school-library:write',
|
|
80
|
+
result: 'ok=true 表示页面托管和个人备课登记均完成;data 返回 file_id、title、path、folder_id、origin、run_url、cover_url、size_bytes、sha256 和 message。',
|
|
81
|
+
next: '分别报告本地成品和上传结果,保留 file_id、个人备课路径与公开 run_url。',
|
|
82
|
+
boundary: '只有老师明确要求上传才执行。--source 接受根含 index.html 的目录、根或唯一顶层目录含 index.html 的 ZIP、单个自包含 HTML;CLI 在临时目录打包且不修改源。ZIP 与文件总量分别不超过 500 MiB,单文件不超过 100 MiB,最多 2,000 个文件;只接收 Help 列出的浏览器静态文件,拒绝嵌套压缩包、可执行文件、系统脚本、符号链接和不安全路径。外部依赖仅允许 https://cdn.jsdelivr.net 和 https://unpkg.com,服务端会在托管前拒绝其他外部源。登记结果不确定时先查看备课空间,不要直接重新上传。', errors: 'upload',
|
|
83
|
+
},
|
|
84
|
+
'learning-tool update': {
|
|
85
|
+
purpose: '修改导入学具的标题、分类或标签。', example: '--file-id <file_id> --title "新标题"', scope: 'school-library:write',
|
|
86
|
+
result: 'ok=true,data 返回更新后的学具与来源、运行地址和当前能力。', next: '报告实际修改结果。',
|
|
87
|
+
boundary: '不替换 HTML、附件或运行地址。只在 can_update=true 时执行;内容修改后按老师明确要求重新 upload 为新学具。', errors: 'maintenance',
|
|
88
|
+
},
|
|
89
|
+
'learning-tool move': {
|
|
90
|
+
purpose: '把可移动的导入学具放入另一个备课目录。', example: '--file-id <file_id> --folder-id <folder_id>', scope: 'school-library:write',
|
|
91
|
+
result: 'ok=true,data.path 是移动后的完整路径。', next: '报告实际目标路径。',
|
|
92
|
+
boundary: '只在 can_move=true 时执行。先读取目标目录的 upload-options,再分别通过 --category-id/--clear-category 和 --label-id/--clear-labels 明确保留、替换或清空决定。', errors: 'maintenance',
|
|
93
|
+
},
|
|
94
|
+
'learning-tool remove': {
|
|
95
|
+
purpose: '从个人备课空间移除指定学具。', example: '--file-id <file_id> --yes', scope: 'school-library:write',
|
|
96
|
+
result: 'ok=true 且 data.removed=true 表示完整移除;导入学具同时清理独占静态页面,引用只移除引用。', next: '按 mode 和 message 报告结果,不重复删除。',
|
|
97
|
+
boundary: '先确认唯一学具、完整路径和影响,再传 --yes。created 学具不由本命令删除;是否可删以 can_remove 为准。', errors: 'maintenance',
|
|
98
|
+
},
|
|
99
|
+
'upload-options': {
|
|
100
|
+
purpose: '查询目标目录及其可选分类、标签。', example: '--folder-id <folder_id>',
|
|
101
|
+
result: 'ok=true,data.target 含目录身份、名称和路径;category_options、label_options 为可用选项。',
|
|
102
|
+
next: '按用户选择使用返回的 category_id、label_id;随后执行用户已请求的上传或元数据更新。',
|
|
103
|
+
boundary: '只读查询,不上传文件或修改全局分类;不自行构造选项 ID。仅需要选择且缺少有效选项时查询,不是上传的固定前置步骤。空选项数组正常;必需字段缺失时报错,不过滤异常条目。', errors: 'maintenance',
|
|
104
|
+
},
|
|
105
|
+
upload: {
|
|
106
|
+
purpose: '将一个本地普通文件上传并创建当前空间的资源。', example: '--file ./material.docx --folder-id <folder_id>',
|
|
107
|
+
result: 'ok=true,data.file_id 为新资源 ID;返回 name、path、file_ext、file_size、indexing 等。业务创建成功不代表内容已可搜索。',
|
|
108
|
+
next: '报告新资源及路径,保留 file_id。正文索引异步完成,不重复上传来等待索引。',
|
|
109
|
+
boundary: '一次一个非空普通文件,须小于 500 MiB。格式:pdf/doc/docx/ppt/pptx/xls/xlsx/jpg/jpeg/png/gif/mp4/avi/mov/mp3/wav/txt/m3u8/flv/mpd。文件传输后由 Gateway 创建资源;不自动重试或续传。', errors: 'upload',
|
|
110
|
+
},
|
|
111
|
+
update: {
|
|
112
|
+
purpose: '修改资源标题、分类或标签,保留未指定字段。', example: '--file-id <file_id> --title "新教案"',
|
|
113
|
+
result: 'ok=true,data 返回更新后的 file_id、name、path、category_id、label_ids 等资源信息。',
|
|
114
|
+
next: '报告实际修改结果;不重复更新或扩大修改范围。',
|
|
115
|
+
boundary: '至少指定一项修改;标题不含扩展名,保留原格式。同一字段不能既设置又清空。分类、标签 ID 来自目标目录 upload-options。', errors: 'maintenance',
|
|
116
|
+
},
|
|
117
|
+
move: {
|
|
118
|
+
purpose: '将已确定的资源移动到同一空间的指定目录。', example: '--file-id <file_id> --folder-id <folder_id>',
|
|
119
|
+
result: 'ok=true,data 返回资源身份及移动后的 path 等信息。', next: '报告实际目标路径。',
|
|
120
|
+
boundary: '先确定唯一资源和完整目标路径;服务端校验移动资格、目录和名称冲突,不自动改名。', errors: 'maintenance',
|
|
121
|
+
},
|
|
122
|
+
remove: {
|
|
123
|
+
purpose: '移除指定资源,具体处理方式由服务端决定。', example: '--file-id <file_id> --yes',
|
|
124
|
+
result: 'ok=true 且 data.removed=true 表示已移除;file_id 标识目标,mode 是服务端实际执行的方式。',
|
|
125
|
+
next: '按返回的 mode 报告结果;已移除或业务已完成时不要重复删除。',
|
|
126
|
+
boundary: '执行前确认唯一资源、完整路径及不可逆影响,再传 --yes。不要自行指定或猜测删除模式。', errors: 'maintenance',
|
|
127
|
+
},
|
|
128
|
+
'folder create': {
|
|
129
|
+
purpose: '在可维护的父目录下创建自定义目录。', example: '--parent-folder-id <folder_id> --name "教学资料"',
|
|
130
|
+
result: 'ok=true,data 返回 event、folder_id、name、path。', next: '报告新目录,后续使用返回的 folder_id。',
|
|
131
|
+
boundary: '同名和层级限制由服务端检查;不创建知识库,不修改权威教材章节。', errors: 'folder',
|
|
132
|
+
},
|
|
133
|
+
'folder update': {
|
|
134
|
+
purpose: '重命名可维护的自定义目录。', example: '--folder-id <folder_id> --name "新目录名"',
|
|
135
|
+
result: 'ok=true,data 返回目录身份和更新后的 name、path。', next: '报告目录新名称和完整路径。',
|
|
136
|
+
boundary: '不移动目录,不修改标签和图标,不编辑权威教材章节。', errors: 'folder',
|
|
137
|
+
},
|
|
138
|
+
'folder delete': {
|
|
139
|
+
purpose: '删除可维护的目录及允许删除的后代目录。', example: '--folder-id <folder_id> --yes',
|
|
140
|
+
result: 'ok=true,data 返回 event、folder_id,以及可用的 deleted_folder_count。', next: '报告服务端实际删除结果,不重复删除。',
|
|
141
|
+
boundary: '执行前确认路径与后代范围,再传 --yes。有后代资源时拒绝整个删除;空间或知识库根节点不能删除。', errors: 'folder',
|
|
142
|
+
},
|
|
143
|
+
'textbook available': {
|
|
144
|
+
purpose: '查找当前空间可以添加的教材。', example: '--query "数学"',
|
|
145
|
+
result: 'ok=true,data.items 含 textbook_id、name、school_stage、subject、grade、version、volume;next_cursor 用于翻页。',
|
|
146
|
+
next: '展示可用教材;仅当用户需要添加教材时,确定候选后使用 textbook_id 执行 textbook add。', boundary: '只查询候选,不添加教材。已添加的教材通过 ls 查看。', errors: 'textbook',
|
|
147
|
+
},
|
|
148
|
+
'textbook add': {
|
|
149
|
+
purpose: '将选定教材及其目录结构添加到当前空间。', example: '--textbook-id <textbook_id>',
|
|
150
|
+
result: 'ok=true,data 返回 event、textbook_id、教材根 folder_id、name 和 path。',
|
|
151
|
+
next: '报告教材根目录,后续使用返回的 folder_id。', boundary: '候选 ID 来自 textbook available;已添加时报告现有目录,不重复添加。', errors: 'textbook',
|
|
152
|
+
},
|
|
153
|
+
'textbook remove': {
|
|
154
|
+
purpose: '移除当前空间已添加的教材目录。', example: '--folder-id <textbook_root_id> --yes',
|
|
155
|
+
result: 'ok=true,data 返回移除事件和教材目录信息。', next: '报告移除结果。',
|
|
156
|
+
boundary: '选择教材根而非章节,确认路径和影响后传 --yes。目录须无资源;不删除全局教材,之后仍可重新添加。', errors: 'textbook',
|
|
157
|
+
},
|
|
158
|
+
'reference search': {
|
|
159
|
+
purpose: '搜索当前用户可引用的学校、关联区域和省级资源。', example: '--query "分数教学"',
|
|
160
|
+
result: 'ok=true,data.items 含源 file_id、space、provider、name、web_url、snippets 等;next_cursor 用于翻页。',
|
|
161
|
+
next: '展示候选;仅当用户需要引用时,确定来源、资源和目标备课目录,再 reference add。', boundary: '不接受业务路径、组织或来源过滤参数,不改变源资源。', errors: 'reference',
|
|
162
|
+
},
|
|
163
|
+
'reference add': {
|
|
164
|
+
purpose: '将选定源资源引用到当前教师的备课目录。', example: '--source-file-id <source_file_id> --folder-id <folder_id>',
|
|
165
|
+
result: 'ok=true,data.file_id 是新备课引用身份;source_file_id、folder_id、kind、name、path、message 描述创建结果。',
|
|
166
|
+
next: '备课引用已创建,报告结果。仅当用户目标需要后续操作时使用新 file_id;不要把源 ID 当成备课引用 ID。',
|
|
167
|
+
boundary: '先确认源资源和目标目录。不需要 --yes;引用不改变源所有权,正文索引异步完成。', errors: 'reference',
|
|
168
|
+
},
|
|
169
|
+
'report submit': {
|
|
170
|
+
purpose: '将已有资源上报到下一层资源空间,等待审批。', example: '--file-id <file_id>',
|
|
171
|
+
result: 'ok=true,data.status=pending 表示已提交;返回 event、report_id、source_name 等。此时尚未创建目标正式资源。',
|
|
172
|
+
next: '报告待审批状态并保留 report_id;需要进度时 report show,不重复提交。',
|
|
173
|
+
boundary: '先确认源资源和接收空间;无需 --yes。待审批或已通过的上报阻止重复提交。', errors: 'report',
|
|
174
|
+
},
|
|
175
|
+
'report list': {
|
|
176
|
+
purpose: '查看当前用户在本层资源空间提交的上报记录。', example: '--status pending',
|
|
177
|
+
result: 'ok=true,data.reports 为本页上报;记录含 report_id、status、目标和时间,可有 file_id;next_cursor 用于翻页。',
|
|
178
|
+
next: '展示记录;需要详情或历次上报时使用 report_id 调用 show 或 history。', boundary: '这是提交侧记录,不是当前组织收到的审批列表。', errors: 'report',
|
|
179
|
+
},
|
|
180
|
+
'report show': {
|
|
181
|
+
purpose: '查看指定上报的源资源、目标、当前状态及处理轨迹。', example: '--report-id <report_id>',
|
|
182
|
+
result: 'ok=true,data 含 report_id、status、source、target、remark、timeline、actual 等;历史记录可能没有可用的源 file_id。',
|
|
183
|
+
next: '按实际 status 和 timeline 报告进度,不把查询成功描述为审批通过。', boundary: '只读查询;上报记录可在源资源删除后继续存在。', errors: 'report',
|
|
184
|
+
},
|
|
185
|
+
'report history': {
|
|
186
|
+
purpose: '通过某次上报查看同一源资源的历次提交。', example: '--report-id <report_id>',
|
|
187
|
+
result: 'ok=true,data.source 为源资源摘要或 null;attempts 为各次 report_id、status、时间和原因。',
|
|
188
|
+
next: '按历次记录回答;再次提交需根据用户意图和当前状态决定。', boundary: '不撤销、重新提交或改变任何上报。', errors: 'report',
|
|
189
|
+
},
|
|
190
|
+
'report cancel': {
|
|
191
|
+
purpose: '撤销当前用户的一次待审批上报,保留源资源。', example: '--report-id <report_id>',
|
|
192
|
+
result: 'ok=true,data.status=cancelled 表示已撤销;返回 event、report_id 等。', next: '报告撤销结果;不自动重新提交。',
|
|
193
|
+
boundary: '先确认资源及撤销影响;无需 --yes。非 pending 状态不能撤销。', errors: 'report',
|
|
194
|
+
},
|
|
195
|
+
'review list': {
|
|
196
|
+
purpose: '查看当前组织有权处理的传入审批记录。', example: '--status pending',
|
|
197
|
+
result: 'ok=true,data.reviews 含 report_id、status、来源、提交人及目标等;next_cursor 用于翻页。',
|
|
198
|
+
next: '展示审批记录;仅在用户需要详情或处理审批时,使用已有 report_id 查看 review show;不直接批量通过或拒绝。', boundary: '与 report list 的提交侧视角不同;--query 匹配名称、提交人等元数据,不搜索正文。', errors: 'review',
|
|
199
|
+
},
|
|
200
|
+
'review show': {
|
|
201
|
+
purpose: '查看审批详情,并预览审批通过后的最终目录和元数据。', example: '--report-id <report_id> --folder-id <folder_id>',
|
|
202
|
+
result: 'ok=true,data.status 是当前状态;source 为来源,approval_preview 为最终分类、标签、folder_id、library_name、directory_name、path,或 null。',
|
|
203
|
+
next: '仅当记录可审批且预览完整时,展示最终目标及元数据并取得确认,再用相同 folder_id 审批。',
|
|
204
|
+
boundary: '预览不锁定状态。approval_preview=null 不一定是缺少目录;历史记录或失效源资源也可能无预览。不清楚原因时停止。', errors: 'review',
|
|
205
|
+
},
|
|
206
|
+
'review approve': {
|
|
207
|
+
purpose: '通过上报,在接收空间创建新的正式资源。', example: '--report-id <report_id> --folder-id <folder_id> --yes',
|
|
208
|
+
result: 'ok=true、data.status=approved 表示已通过;file_id、name、path 是新正式资源,report_id 是审批记录。',
|
|
209
|
+
next: '报告新资源和路径;正文索引异步进行,不重复审批。',
|
|
210
|
+
boundary: '先 review show,确认最终预览后传 --yes;folder_id 与预览一致,默认目标时两次均省略。保留源资源,不支持撤回审批。', errors: 'review',
|
|
211
|
+
},
|
|
212
|
+
'review reject': {
|
|
213
|
+
purpose: '拒绝指定上报,并记录完整拒绝原因。', example: '--report-id <report_id> --reason "请补充完整教案" --yes',
|
|
214
|
+
result: 'ok=true、data.status=rejected 表示已拒绝;返回 event、report_id、source_name、reason 等。',
|
|
215
|
+
next: '报告拒绝结果及原因,不重复拒绝。', boundary: '先确认资源、提交人及完整原因,再传 --yes;只影响本次上报,保留源资源。', errors: 'review',
|
|
216
|
+
},
|
|
217
|
+
web: {
|
|
218
|
+
purpose: '获取课堂授课、全局分类管理、数据看板等网页办理入口。', example: '',
|
|
219
|
+
result: 'ok=true,data.pages 含 name 和完整 web_url。没有条目表示当前组织没有返回可用入口。',
|
|
220
|
+
next: '按用户任务提供匹配的完整 URL;没有匹配入口时如实说明。',
|
|
221
|
+
boundary: '只取入口,不创建课堂、修改分类或读取统计。网页使用独立浏览器登录态,不因此重新授权 CLI。', errors: 'web',
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
function definition(command) {
|
|
226
|
+
const [space, ...words] = command.split(' ');
|
|
227
|
+
const key = definitions[command] ? command : words.join(' ');
|
|
228
|
+
const item = definitions[key];
|
|
229
|
+
if (!item) return;
|
|
230
|
+
const result = { ...item };
|
|
231
|
+
if (space === 'lesson-prep' || space === 'school-library') {
|
|
232
|
+
result.purpose = (space === 'lesson-prep' ? '备课空间:' : '校本库:') + result.purpose;
|
|
233
|
+
const read = ['ls', 'glob', 'search', 'grep', 'fetch', 'download', 'upload-options', 'textbook available', 'reference search', 'report list', 'report show', 'report history', 'review list', 'review show', 'web'].includes(key);
|
|
234
|
+
result.scope = read ? 'school-library:read' : key.startsWith('report ') || space === 'lesson-prep' ? 'school-library:write' : 'school-library:manage';
|
|
235
|
+
if (['update', 'move'].includes(key) && space === 'lesson-prep') result.boundary += ' 仅允许当前教师的个人上传资源(kind=upload),不支持外部引用。';
|
|
236
|
+
if (key === 'remove') result.boundary += space === 'lesson-prep' ? ' 移除引用只移除该引用及其检索副本,不删除源资源。' : ' 移除正式资源会使已有备课引用失效;删除或下架以 mode 为准。';
|
|
237
|
+
if (key.startsWith('folder ')) result.boundary += space === 'lesson-prep' ? ' 仅维护其他授课的自定义目录,最多四层。' : ' 仅维护自定义知识库目录;学科目录使用教材命令。';
|
|
238
|
+
if (key === 'upload' && space === 'school-library') result.boundary += ' 目标须为正式库目录,不能是知识库根。';
|
|
239
|
+
if (key === 'report submit') result.boundary += space === 'lesson-prep' ? ' 仅个人上传资源可上报学校;学科授课由服务端匹配教材目录,其他授课需选择学校自定义目录。' : ' 学校资源上报关联区域,目标由服务端确定,不接受目标目录或组织参数。';
|
|
240
|
+
}
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function parameters(command, usage) {
|
|
245
|
+
const key = command.split(' ').slice(1).join(' ');
|
|
246
|
+
const maximum = { ls: 200, glob: 200, search: 20, grep: 100, fetch: 500, 'reference search': 20 }[key] || 100;
|
|
247
|
+
const defaults = { ls: 100, glob: 100, search: 10, grep: 50, fetch: 200, 'reference search': 10 };
|
|
248
|
+
const descriptions = {
|
|
249
|
+
'--scope': '必填、可重复;连接器首次传 school-library:read,每个权限分别传入。',
|
|
250
|
+
'--verify': '可选;向服务端核验当前有效本地登录态,默认只查本地。',
|
|
251
|
+
'--limit': `可选;每页/段数量,默认 ${defaults[key] || 20},范围 1..${maximum}。`,
|
|
252
|
+
'--cursor': '可选;使用上一页 next_cursor 原值;翻页保持其他条件不变,默认从首页开始。',
|
|
253
|
+
'--pattern': key === 'glob' ? '可选;名称或 glob 模式,默认 **/*。' : '必填;普通文字,可重复最多 10 次,每次最多 200 字符,多个条件为 OR。',
|
|
254
|
+
'--query': ['textbook available', 'review list'].includes(key) ? '可选;教材或审批元数据筛选文字,默认不筛选。' : '必填;非空搜索文字。',
|
|
255
|
+
'--type': '可选;all、folder、file,默认 all。', '--glob': '可选;限制匹配文件的 glob,默认不限制;与 glob 命令使用相同模式规则。',
|
|
256
|
+
'--ignore-case': '可选;忽略文字大小写,默认区分大小写。',
|
|
257
|
+
'--file': key === 'upload' ? '必填;用户指定的本地普通文件路径。' : '必填;当前空间返回的 file_id,或已知完整绝对业务路径。',
|
|
258
|
+
'--source': '必填;已制作完成的本地学具目录、ZIP 或单个自包含 HTML 路径。',
|
|
259
|
+
'--start-line': '可选;解析正文起始行,默认 1,从第 1 行开始计数。',
|
|
260
|
+
'--output': '可选;本地目标文件路径;默认当前目录下使用业务文件名。', '--overwrite': '可选;覆盖现有文件,仅用户明确要求时传入,默认拒绝覆盖。',
|
|
261
|
+
'--file-id': '必填;当前空间已返回的资源 file_id,不使用文件名代替。',
|
|
262
|
+
'--source-file-id': '必填;reference search 返回的源资源 file_id。',
|
|
263
|
+
'--folder-id': key.startsWith('review ') ? '可选;当前组织 ls/glob 返回的正式库目录;省略时服务端尝试使用上报目标或匹配教材。' : '必填;当前空间已返回的目录 folder_id。' + (key === 'textbook remove' ? '须使用教材根 ID。' : ''),
|
|
264
|
+
'--parent-folder-id': '必填;当前空间 ls/glob 返回的可维护父目录 folder_id。',
|
|
265
|
+
'--target-folder-id': '可选;学校 ls/glob 返回的目标自定义目录 ID;其他授课上报需要,学科授课省略。',
|
|
266
|
+
'--title': command === 'learning-tool upload' ? '必填;上传后在个人备课空间显示的学具标题。'
|
|
267
|
+
: key === 'upload' ? '可选;不含扩展名的标题,默认本地文件名去掉最后一个扩展名。' : '可选;不含扩展名的新标题,省略则保持不变。',
|
|
268
|
+
'--category-id': '可选;目标目录 upload-options 返回的分类 ID。' + (key === 'update' ? '不能与 --clear-category 同用。' : ''),
|
|
269
|
+
'--label-id': '可选、可重复;目标目录 upload-options 返回的标签 ID。' + (key === 'update' ? '不能与 --clear-labels 同用。' : ''),
|
|
270
|
+
'--clear-category': '可选;清空分类,不可同时设置分类。', '--clear-labels': '可选;清空标签,不可同时设置标签。',
|
|
271
|
+
'--yes': '必填;在用户确认目标和影响后传入,不替代业务权限校验。',
|
|
272
|
+
'--name': '必填;目录名,1..100 个字符。',
|
|
273
|
+
'--textbook-id': '必填;textbook available 返回的教材 ID。',
|
|
274
|
+
'--report-id': '必填;当前任务返回的 report_id,不是资源 file_id。',
|
|
275
|
+
'--remark': '可选;用户的上报备注,去除首尾空白后最多 500 字符。',
|
|
276
|
+
'--reason': '必填;完整拒绝原因,去除首尾空白后 1..500 字符。',
|
|
277
|
+
'--status': key === 'review list' ? '可选;all、pending、approved、rejected、delisted,默认 pending。' : '可选;pending、approved、rejected、delisted、cancelled,省略表示全部状态。',
|
|
278
|
+
};
|
|
279
|
+
const lines = [];
|
|
280
|
+
if (usage.startsWith('[path]')) lines.push('path 可选;当前空间绝对业务路径,默认 /,使用已返回的路径。');
|
|
281
|
+
if (command === 'config set') lines.push('gateway-url <http(s)-url> 必填;指定要保存的完整 Gateway 地址。');
|
|
282
|
+
for (const flag of new Set(usage.match(/--[a-z-]+/g) || [])) lines.push(flag + ' ' + descriptions[flag]);
|
|
283
|
+
return lines.length ? lines : ['无业务参数。'];
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function render(command, usage) {
|
|
287
|
+
const item = definition(command);
|
|
288
|
+
return `${item.purpose}\n\n用法:yunzhi-cli ${command}${usage ? ' ' + usage : ''}\n参数:\n ${parameters(command, usage).join('\n ')}\n 除明确标注可重复的参数外,同一参数不能重复传入。\n示例:\n yunzhi-cli ${command}${item.example ? ' ' + item.example : ''}\n返回:\n ${item.result}\n下一步:\n ${item.next}\n注意:\n ${item.boundary}${item.scope ? '\n CLI 请求权限:' + item.scope + ';实际所需权限和业务资格以服务端返回为准。' : ''}\n默认输出 JSON。失败时读取 error.message、error.hint 和返回字段,按业务或认证指南处理。\n`;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function resultProblem(command, data) {
|
|
292
|
+
const key = command.split(' ').slice(1).join(' ');
|
|
293
|
+
const text = name => typeof data[name] === 'string' && Boolean(data[name].trim());
|
|
294
|
+
const missing = name => `响应字段 data.${name} 缺失或无效。`;
|
|
295
|
+
const expect = (name, value) => data[name] === value ? '' : `响应字段 data.${name} 应为 ${value},实际为 ${JSON.stringify(data[name]) ?? '未提供'}。`;
|
|
296
|
+
const reportStatuses = ['pending', 'approved', 'rejected', 'delisted', 'cancelled'];
|
|
297
|
+
const collectionStatuses = { 'report list': ['reports', reportStatuses], 'review list': ['reviews', reportStatuses.slice(0, 4)],
|
|
298
|
+
'report history': ['attempts', reportStatuses] };
|
|
299
|
+
if (collectionStatuses[key]) {
|
|
300
|
+
const [name, statuses] = collectionStatuses[key];
|
|
301
|
+
if (!Array.isArray(data[name])) return missing(name);
|
|
302
|
+
const index = data[name].findIndex(item => item?.status !== undefined && !statuses.includes(item.status));
|
|
303
|
+
return index < 0 ? '' : `响应字段 data.${name}[${index}].status 无效,应为 ${statuses.join('、')}。`;
|
|
304
|
+
}
|
|
305
|
+
const lists = { ls: 'entries', glob: 'matches', search: 'items', grep: 'matches',
|
|
306
|
+
'textbook available': 'items', 'reference search': 'items', web: 'pages' };
|
|
307
|
+
if (lists[key]) return Array.isArray(data[lists[key]]) ? '' : missing(lists[key]);
|
|
308
|
+
if (['fetch', 'upload', 'update', 'move', 'remove', 'reference add', 'review approve'].includes(key) && !text('file_id')) return missing('file_id');
|
|
309
|
+
if (key === 'fetch') return typeof data.content === 'string' ? '' : missing('content');
|
|
310
|
+
if (key === 'remove') return expect('removed', true) || (text('mode') ? '' : missing('mode'));
|
|
311
|
+
const events = { 'folder create': 'folder_created', 'folder update': 'folder_updated', 'folder delete': 'folder_deleted',
|
|
312
|
+
'textbook add': 'textbook_added', 'textbook remove': 'textbook_removed' };
|
|
313
|
+
if (events[key]) return !text('folder_id') ? missing('folder_id') : expect('event', events[key]);
|
|
314
|
+
if (key === 'reference add') return !text('source_file_id') ? missing('source_file_id')
|
|
315
|
+
: !text('folder_id') ? missing('folder_id') : expect('kind', 'reference');
|
|
316
|
+
const statuses = { 'report submit': 'pending', 'report cancel': 'cancelled', 'review approve': 'approved', 'review reject': 'rejected' };
|
|
317
|
+
if (statuses[key]) return !text('report_id') ? missing('report_id') : expect('status', statuses[key]);
|
|
318
|
+
if (['report show', 'review show'].includes(key)) return !text('report_id') ? missing('report_id')
|
|
319
|
+
: reportStatuses.includes(data.status) ? '' : `响应字段 data.status 无效,应为 ${reportStatuses.join('、')}。`;
|
|
320
|
+
return '';
|
|
321
|
+
}
|
|
322
|
+
function validResult(command, data) { return !resultProblem(command, data); }
|
|
323
|
+
|
|
324
|
+
function message(command, data) {
|
|
325
|
+
if (command === 'auth login') return data.event === 'authorization_complete'
|
|
326
|
+
? '登录授权已完成。请使用当前用户和组织恢复原业务命令。' : '请核对授权响应的 event;只有 authorization_complete 才可恢复原业务。';
|
|
327
|
+
if (command === 'auth status') return !data.authenticated ? '当前没有有效的 CLI 登录态;last_known_profile 仅为历史信息。'
|
|
328
|
+
: data.verified ? '当前登录已通过服务端核验,请根据 user 和 tenant 回答身份。' : '当前存在本地登录态,尚未经服务端核验;需要确认当前身份时使用 auth status --verify。';
|
|
329
|
+
const item = definition(command);
|
|
330
|
+
if (!item) return;
|
|
331
|
+
const key = command.split(' ').slice(1).join(' ');
|
|
332
|
+
if (['ls', 'glob', 'search', 'grep', 'textbook available', 'reference search', 'report list', 'review list'].includes(key)) {
|
|
333
|
+
const list = data.entries || data.matches || data.items || data.reports || data.reviews;
|
|
334
|
+
return Array.isArray(list) && !list.length
|
|
335
|
+
? '本次查询范围内没有匹配结果。' + (data.next_cursor ? '当前仍有后续页,仅在用户目标需要时继续查询。' : '')
|
|
336
|
+
: '本次查询已完成。' + item.next;
|
|
337
|
+
}
|
|
338
|
+
if (key === 'review show' && data.approval_preview == null) return '审批详情已返回,但没有审批预览。请结合当前状态和源资源信息判断;不能仅据此认定缺少目录,不明确时停止。';
|
|
339
|
+
return item.next;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function errorHint(command, error, exchanging) {
|
|
343
|
+
if (error.partial_file === true) return '本地文件已直接写入,可能为空或不完整;覆盖写入时原内容可能已损坏。报告 error.local_path 和原始错误后停止,不自动重试或清理文件。';
|
|
344
|
+
if (error.operation_completed === true) return '根据 data 报告已完成结果和清理错误,然后停止,不重复业务操作。';
|
|
345
|
+
if (error.cleanup_failed === true) return '报告原始错误及本地清理失败,然后停止;cleanup_failed 本身不表示业务完成。';
|
|
346
|
+
if (command.endsWith(' download') && error.type === 'local' && error.message === '无法打开本地目标文件。') return '请检查目标文件的父目录是否存在且可写;CLI 不自动创建缺失的父目录。报告实际错误并停止,不自动重试。';
|
|
347
|
+
if (['invalid_local_file', 'unsupported_file_type', 'file_too_large', 'invalid_local_source',
|
|
348
|
+
'unsafe_learning_tool_source', 'invalid_learning_tool_archive', 'unsafe_learning_tool_archive',
|
|
349
|
+
'unsupported_learning_tool_file', 'learning_tool_index_missing', 'learning_tool_file_too_large',
|
|
350
|
+
'learning_tool_package_too_large', 'learning_tool_too_many_files', 'standalone_html_has_local_assets'].includes(error.subtype))
|
|
351
|
+
return '根据实际原因检查本地学具路径、目录结构、静态文件类型、读取权限或大小;上传尚未开始,不自动重试。';
|
|
352
|
+
|
|
353
|
+
if (error.subtype === 'learning_tool_registration_result_unknown') return '先查看个人备课空间确认结果,不要直接重新上传。';
|
|
354
|
+
if (error.subtype === 'upload_page_cleanup_failed') return '报告页面清理失败并联系管理员,不要自动重新上传。';
|
|
355
|
+
if (error.subtype === 'page_publish_result_unknown') return '静态页面发布结果无法确认。报告实际错误后停止;不要自动重试、重新上传或删除。';
|
|
356
|
+
if (error.subtype === 'page_cleanup_failed') return '个人备课记录已移除,但静态页面清理失败。报告返回的 file_id、delete_stage 和实际错误后停止,不重复移除。';
|
|
357
|
+
if (error.file_transferred) return '报告文件已传输及资源创建阶段的实际结果;结果不明时停止,不自动重试、重新上传或查询核实。';
|
|
358
|
+
if (error.subtype === 'invalid_pattern') return '请查看返回的 syntax/examples;glob 模式最多 200 个 UTF-16 代码单元,不接受反斜杠和控制字符,** 必须独占路径段。报告错误并停止,不要自动改写模式。';
|
|
359
|
+
if (command === 'config set' && error.type === 'validation' && error.message === 'Gateway URL 必须是有效的 HTTP(S) 地址。') return '请检查 HTTP(S) 地址:不接受用户信息、查询参数、Fragment、重复斜杠或点路径段。';
|
|
360
|
+
if (['scope_too_large', 'content_unavailable', 'file_not_downloadable'].includes(error.subtype)) return '报告实际限制并停止;不要自动改写模式、拆分查询、推测索引状态或替换资源形式。';
|
|
361
|
+
if (['report_not_pending', 'report_already_exists', 'file_report_pending'].includes(error.subtype)) return '根据返回的状态和 details 报告现有记录;不要自动撤销、重新上报或改用其他写操作。';
|
|
362
|
+
if (error.subtype === 'file_not_reportable') return '说明该本地导入学具当前只服务个人备课空间,不能上报校本库;停止本次上报,不改用其他命令规避。';
|
|
363
|
+
if (command === 'auth login' && error.subtype === 'authorization_pending') return '若这是首次兑换,请用户再次明确确认网页授权完成,并结束本轮等待回复;确认后使用同一 device_code 再兑换一次。若已重试或重试仍失败,报告错误并停止,不再请求确认;不轮询或自动创建新会话。';
|
|
364
|
+
if (command === 'auth login' && ['authorization_expired', 'invalid_device_code', 'access_denied'].includes(error.subtype)) return '报告实际授权状态并停止本次兑换;不轮询或自动创建新会话。';
|
|
365
|
+
if (exchanging) return '本次兑换未完成,请报告实际错误并停止;不要重复兑换或自动重新登录。';
|
|
366
|
+
if (error.subtype === 'target_folder_required') return '优先复用用户已明确且适用于当前组织的有效目录;缺失或歧义时再查询并选择。使用 review show --folder-id 取得审批预览,确认后沿用同一目录审批。';
|
|
367
|
+
if (command === 'auth connect' && error.type !== 'validation') return '报告实际连接错误并停止;不自动重新连接或重复兑换。';
|
|
368
|
+
if (error.type === 'validation') return `请根据具体参数错误核对 ${command} --help;ID 和权限使用当前任务已返回的值,不自行构造。`;
|
|
369
|
+
if (error.subtype === 'confirmation_required') return '请先向用户说明目标与影响并取得确认,再传入 --yes。';
|
|
370
|
+
if (['auth_required', 'missing_scope'].includes(error.subtype)) return '使用 error.required_scopes,按登录与授权指南处理,完成后恢复原业务。';
|
|
371
|
+
if (error.subtype === 'stale_session_requires_logout') return command === 'auth login' && error.required_scopes
|
|
372
|
+
? '退出成功后使用 required_scopes 重试创建会话一次;退出或重试失败即停止。' : '本次兑换未完成,请报告错误并停止;不要重复兑换或自动重新登录。';
|
|
373
|
+
return '报告实际错误并按对应业务指南停止;不要自动重试、改名、覆盖或改用其他写操作。';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
module.exports = { definition, parameters, render, validResult, resultProblem, message, errorHint };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Optional business guidance, relative to the product Skill root.
|
|
2
|
+
function reference(command) {
|
|
3
|
+
const [space, action, operation] = command.split(' ');
|
|
4
|
+
if (space === 'auth') return 'references/auth/SKILL.md#' +
|
|
5
|
+
(action === 'connect' ? 'connector-connection' : action === 'login' ? 'authorization-flow' : 'identity-and-organization');
|
|
6
|
+
if (space === 'config') return 'SKILL.md#configuration';
|
|
7
|
+
if (space === 'learning-tool') {
|
|
8
|
+
const sections = { 'upload-options': 'when-the-teacher-explicitly-requests-upload',
|
|
9
|
+
upload: 'when-the-teacher-explicitly-requests-upload', update: 'maintain-uploaded-learning-tools',
|
|
10
|
+
move: 'maintain-uploaded-learning-tools', remove: 'maintain-uploaded-learning-tools' };
|
|
11
|
+
return sections[action] ? 'references/learning-tools/SKILL.md#' + sections[action] : null;
|
|
12
|
+
}
|
|
13
|
+
if (space !== 'lesson-prep' && space !== 'school-library') return null;
|
|
14
|
+
const root = 'references/school-library/';
|
|
15
|
+
if (action === 'report') {
|
|
16
|
+
if (operation === 'history') return null;
|
|
17
|
+
if (operation === 'submit' || operation === 'cancel')
|
|
18
|
+
return root + 'references/reports.md#' + (operation === 'submit' ? 'submit' : 'cancel-or-resubmit');
|
|
19
|
+
return root + 'SKILL.md#reports-and-reviews';
|
|
20
|
+
}
|
|
21
|
+
if (action === 'review') return root + (operation === 'list'
|
|
22
|
+
? 'SKILL.md#reports-and-reviews'
|
|
23
|
+
: 'references/reviews.md#' + (operation === 'reject' ? 'reject' : 'approve'));
|
|
24
|
+
const sections = {
|
|
25
|
+
fetch: 'reading-resources', folder: 'directories-and-textbooks', textbook: 'directories-and-textbooks',
|
|
26
|
+
upload: 'uploads-and-metadata', 'upload-options': 'uploads-and-metadata',
|
|
27
|
+
reference: 'external-references', update: 'resource-changes', move: 'resource-changes',
|
|
28
|
+
remove: 'resource-changes', web: 'continue-on-the-web',
|
|
29
|
+
};
|
|
30
|
+
return sections[action] ? root + 'SKILL.md#' + sections[action] : null;
|
|
31
|
+
}
|
|
32
|
+
module.exports = { reference };
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const { cleanup } = require('./diagnostics.cjs');
|
|
2
|
+
const https = require('node:https');
|
|
3
|
+
const { createHash, createHmac } = require('node:crypto');
|
|
4
|
+
const { crc64 } = require('./crc64.cjs');
|
|
5
|
+
|
|
6
|
+
const encode = value => encodeURIComponent(value).replace(/[!'()*]/g, char => '%' + char.charCodeAt(0).toString(16).toUpperCase());
|
|
7
|
+
const hmac = (key, value) => createHmac('sha1', key).update(value).digest('hex');
|
|
8
|
+
const partSize = 16 * 1024 * 1024;
|
|
9
|
+
const escapeXML = value => value.replace(/[&<>"']/g, char => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[char]);
|
|
10
|
+
// COS v5 signs the decoded path, percent-encoded sorted query and headers.
|
|
11
|
+
function authorize(method, url, headers, grant) {
|
|
12
|
+
const time = Math.floor(Date.now() / 1000), keyTime = `${time};${time + 3600}`;
|
|
13
|
+
const parameters = Object.fromEntries([...url.searchParams].map(([key, value]) => [key.toLowerCase(), value]));
|
|
14
|
+
const headerNames = Object.keys(headers).sort(), parameterNames = Object.keys(parameters).sort();
|
|
15
|
+
const canonical = (names, values) => names.map(name => encode(name) + '=' + encode(String(values[name]))).join('&');
|
|
16
|
+
const formatted = `${method.toLowerCase()}\n${decodeURIComponent(url.pathname)}\n${canonical(parameterNames, parameters)}\n${canonical(headerNames, headers)}\n`;
|
|
17
|
+
const digest = createHash('sha1').update(formatted).digest('hex');
|
|
18
|
+
const signature = hmac(hmac(grant.secret_key, keyTime), `sha1\n${keyTime}\n${digest}\n`);
|
|
19
|
+
return `q-sign-algorithm=sha1&q-ak=${grant.secret_id}&q-sign-time=${keyTime}&q-key-time=${keyTime}&q-header-list=${headerNames.join(';')}&q-url-param-list=${parameterNames.join(';')}&q-signature=${signature}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function send(grant, method, query, body, headers, signal) {
|
|
23
|
+
const url = new URL(`https://${grant.bucket}.cos.${grant.region}.myqcloud.com/` + encode(grant.object_key));
|
|
24
|
+
for (const [name, value] of Object.entries(query)) url.searchParams.set(name, String(value));
|
|
25
|
+
headers = { host: url.host, 'x-cos-security-token': grant.session_token, 'content-length': String(body.length), ...headers };
|
|
26
|
+
headers.authorization = authorize(method, url, headers, grant);
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
const req = https.request(url, { method, headers, signal }, async res => {
|
|
29
|
+
try {
|
|
30
|
+
const chunks = []; for await (const chunk of res) chunks.push(chunk);
|
|
31
|
+
if (res.statusCode < 200 || res.statusCode >= 300) throw new Error('COS request failed: HTTP ' + res.statusCode + ' ' + res.statusMessage);
|
|
32
|
+
if (method === 'PUT' && (res.headers['x-cos-hash-crc64ecma'] || '0') !== crc64(body)) throw new Error('COS checksum mismatch');
|
|
33
|
+
resolve({ headers: res.headers, text: Buffer.concat(chunks).toString('utf8') });
|
|
34
|
+
} catch (error) { reject(error); }
|
|
35
|
+
});
|
|
36
|
+
req.on('error', reject);
|
|
37
|
+
req.end(body);
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function put(file, size, grant) {
|
|
42
|
+
const controller = new AbortController();
|
|
43
|
+
const interrupt = () => controller.abort();
|
|
44
|
+
process.once('SIGINT', interrupt);
|
|
45
|
+
const timer = setTimeout(interrupt, 25 * 60 * 1000);
|
|
46
|
+
try {
|
|
47
|
+
if (size <= partSize) await send(grant, 'PUT', {}, await readPart(file, 0, size), { 'x-cos-forbid-overwrite': 'true' }, controller.signal);
|
|
48
|
+
else await multipart(file, size, grant, controller.signal);
|
|
49
|
+
} finally { clearTimeout(timer); process.removeListener('SIGINT', interrupt); }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function readPart(file, start, size) {
|
|
53
|
+
const bytes = Buffer.alloc(size);
|
|
54
|
+
let offset = 0;
|
|
55
|
+
while (offset < size) {
|
|
56
|
+
const { bytesRead } = await file.read(bytes, offset, size - offset, start + offset);
|
|
57
|
+
if (!bytesRead) throw new Error('File changed during upload');
|
|
58
|
+
offset += bytesRead;
|
|
59
|
+
}
|
|
60
|
+
return bytes;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function multipart(file, size, grant, signal) {
|
|
64
|
+
const initialized = await send(grant, 'POST', { uploads: '' }, Buffer.alloc(0), { 'x-cos-forbid-overwrite': 'true' }, signal);
|
|
65
|
+
requireXMLResult(initialized.text, 'InitiateMultipartUploadResult');
|
|
66
|
+
const matched = initialized.text.match(/<UploadId>([^<]*)<\/UploadId>/);
|
|
67
|
+
if (!matched || !matched[1]) throw new Error('Missing upload ID');
|
|
68
|
+
const uploadId = matched[1].replace(/&(#x[\da-f]+|#\d+|amp|lt|gt|quot|apos);/gi, (_, entity) => {
|
|
69
|
+
if (entity.startsWith('#')) return String.fromCodePoint(entity[1].toLowerCase() === 'x' ? parseInt(entity.slice(2), 16) : +entity.slice(1));
|
|
70
|
+
return { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }[entity];
|
|
71
|
+
});
|
|
72
|
+
try {
|
|
73
|
+
const parts = [];
|
|
74
|
+
for (let start = 0, partNumber = 1; start < size; start += partSize, partNumber++) {
|
|
75
|
+
const response = await send(grant, 'PUT', { partNumber, uploadId }, await readPart(file, start, Math.min(partSize, size - start)),
|
|
76
|
+
{ 'x-cos-psize-max': String(partSize) }, signal);
|
|
77
|
+
if (!response.headers.etag) throw new Error('Missing part ETag');
|
|
78
|
+
parts.push(`<Part><PartNumber>${partNumber}</PartNumber><ETag>${escapeXML(response.headers.etag)}</ETag></Part>`);
|
|
79
|
+
}
|
|
80
|
+
const completed = await send(grant, 'POST', { uploadId }, Buffer.from('<CompleteMultipartUpload>' + parts.join('') + '</CompleteMultipartUpload>'),
|
|
81
|
+
{ 'content-type': 'application/xml', 'x-cos-forbid-overwrite': 'true', 'x-cos-pnum-max': String(parts.length) }, signal);
|
|
82
|
+
requireXMLResult(completed.text, 'CompleteMultipartUploadResult');
|
|
83
|
+
} catch (error) {
|
|
84
|
+
// Abort has its own deadline so cancellation does not strand a multipart upload.
|
|
85
|
+
try { await send(grant, 'DELETE', { uploadId }, Buffer.alloc(0), {}, AbortSignal.timeout(10000)); } catch (cleanupError) { cleanup(error, cleanupError); }
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function requireXMLResult(text, root) {
|
|
90
|
+
const body = text.trim().replace(/^<\?xml[^?]*\?>\s*/, '');
|
|
91
|
+
if (!new RegExp('^<' + root + '(?:\\s[^>]*)?(?:\\s*/>|>[\\s\\S]*</' + root + '>)$').test(body)) throw new Error('Invalid COS response');
|
|
92
|
+
}
|
|
93
|
+
module.exports = { put };
|
package/lib/crc64.cjs
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Reflected CRC64/ECMA with complemented initial/final state, matching Go hash/crc64.
|
|
2
|
+
// Two 32-bit words keep the per-byte loop fast without losing 64-bit precision.
|
|
3
|
+
const highTable = new Uint32Array(256), lowTable = new Uint32Array(256);
|
|
4
|
+
for (let i = 0; i < 256; i++) {
|
|
5
|
+
let value = BigInt(i);
|
|
6
|
+
for (let bit = 0; bit < 8; bit++) value = (value >> 1n) ^ (value & 1n ? 0xc96c5795d7870f42n : 0n);
|
|
7
|
+
highTable[i] = Number(value >> 32n);
|
|
8
|
+
lowTable[i] = Number(value & 0xffffffffn);
|
|
9
|
+
}
|
|
10
|
+
function crc64(bytes) {
|
|
11
|
+
let high = 0xffffffff, low = 0xffffffff;
|
|
12
|
+
for (const byte of bytes) {
|
|
13
|
+
const index = (low ^ byte) & 255;
|
|
14
|
+
low = ((low >>> 8) | (high << 24)) ^ lowTable[index];
|
|
15
|
+
high = (high >>> 8) ^ highTable[index];
|
|
16
|
+
}
|
|
17
|
+
return ((BigInt(~high >>> 0) << 32n) | BigInt(~low >>> 0)).toString();
|
|
18
|
+
}
|
|
19
|
+
module.exports = { crc64 };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Keep the original error messages and cause chain; do not serialize whole error objects.
|
|
2
|
+
function detail(error) {
|
|
3
|
+
const seen = new Set();
|
|
4
|
+
const messages = [];
|
|
5
|
+
function collect(value) {
|
|
6
|
+
if (value == null || seen.has(value)) return;
|
|
7
|
+
seen.add(value);
|
|
8
|
+
const structured = value.result?.value?.error;
|
|
9
|
+
const message = structured?.detail || value.detail || value.message || String(value);
|
|
10
|
+
if (message) messages.push(message);
|
|
11
|
+
if (structured?.detail || value.detail) return;
|
|
12
|
+
if (value.cause) collect(value.cause);
|
|
13
|
+
if (Array.isArray(value.errors)) value.errors.forEach(collect);
|
|
14
|
+
}
|
|
15
|
+
collect(error);
|
|
16
|
+
return messages.join('\nCaused by: ');
|
|
17
|
+
}
|
|
18
|
+
function attach(failure, cause) {
|
|
19
|
+
failure.result.value.error.detail = detail(cause);
|
|
20
|
+
if (cause?.cleanup_failed) failure.result.value.error.cleanup_failed = true;
|
|
21
|
+
return failure;
|
|
22
|
+
}
|
|
23
|
+
function cleanup(primary, cause) {
|
|
24
|
+
const target = primary.result?.value?.error || primary;
|
|
25
|
+
const original = target.detail || (!primary.result ? detail(primary) : '');
|
|
26
|
+
target.cleanup_failed = true;
|
|
27
|
+
target.detail = [original, 'Cleanup failed: ' + detail(cause)].filter(Boolean).join('\n');
|
|
28
|
+
}
|
|
29
|
+
module.exports = { detail, attach, cleanup };
|