cloudhouse-admin-cli 0.3.3

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.
Files changed (96) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/LICENSE +21 -0
  3. package/README.md +63 -0
  4. package/SECURITY.md +11 -0
  5. package/THIRD_PARTY_NOTICES.md +7 -0
  6. package/bin/ch.mjs +11 -0
  7. package/docs/00-overview.md +71 -0
  8. package/docs/01-command-reference.md +1206 -0
  9. package/docs/02-agent-contract.md +118 -0
  10. package/docs/03-agent-setup.md +69 -0
  11. package/docs/04-audit.md +3 -0
  12. package/docs/05-release.md +5 -0
  13. package/docs/06-coverage.md +3 -0
  14. package/docs/07-data-analysis.md +105 -0
  15. package/docs/08-installation.md +36 -0
  16. package/docs/security-review.md +26 -0
  17. package/integrations/codex/.agents/plugins/marketplace.json +20 -0
  18. package/integrations/codex/.codex-plugin/plugin.json +10 -0
  19. package/integrations/codex/LICENSE +21 -0
  20. package/integrations/codex/README.md +5 -0
  21. package/integrations/codex/package.json +7 -0
  22. package/integrations/codex/plugin.json +11 -0
  23. package/integrations/codex/skills/cloudhouse-admin/SKILL.md +32 -0
  24. package/integrations/codex/skills/cloudhouse-admin/references/01-command-reference.md +1206 -0
  25. package/integrations/codex/skills/cloudhouse-admin/references/02-agent-contract.md +118 -0
  26. package/integrations/dsh/LICENSE +21 -0
  27. package/integrations/dsh/README.md +14 -0
  28. package/integrations/dsh/index.mjs +7 -0
  29. package/integrations/dsh/package.json +12 -0
  30. package/integrations/dsh/skills/cloudhouse-admin/SKILL.md +32 -0
  31. package/integrations/dsh/skills/cloudhouse-admin/references/01-command-reference.md +1206 -0
  32. package/integrations/dsh/skills/cloudhouse-admin/references/02-agent-contract.md +118 -0
  33. package/integrations/openclaw/LICENSE +21 -0
  34. package/integrations/openclaw/README.md +5 -0
  35. package/integrations/openclaw/package.json +7 -0
  36. package/integrations/openclaw/skills/cloudhouse-admin/SKILL.md +32 -0
  37. package/integrations/openclaw/skills/cloudhouse-admin/references/01-command-reference.md +1206 -0
  38. package/integrations/openclaw/skills/cloudhouse-admin/references/02-agent-contract.md +118 -0
  39. package/integrations/opencode/LICENSE +21 -0
  40. package/integrations/opencode/README.md +5 -0
  41. package/integrations/opencode/package.json +7 -0
  42. package/integrations/opencode/skills/cloudhouse-admin/SKILL.md +32 -0
  43. package/integrations/opencode/skills/cloudhouse-admin/references/01-command-reference.md +1206 -0
  44. package/integrations/opencode/skills/cloudhouse-admin/references/02-agent-contract.md +118 -0
  45. package/integrations/pi/LICENSE +21 -0
  46. package/integrations/pi/README.md +5 -0
  47. package/integrations/pi/package.json +15 -0
  48. package/integrations/pi/skills/cloudhouse-admin/SKILL.md +32 -0
  49. package/integrations/pi/skills/cloudhouse-admin/references/01-command-reference.md +1206 -0
  50. package/integrations/pi/skills/cloudhouse-admin/references/02-agent-contract.md +118 -0
  51. package/package.json +44 -0
  52. package/skills/cloudhouse-admin/SKILL.md +32 -0
  53. package/skills/cloudhouse-admin/references/01-command-reference.md +1206 -0
  54. package/skills/cloudhouse-admin/references/02-agent-contract.md +118 -0
  55. package/src/agent-install.mjs +50 -0
  56. package/src/analysis-resources.json +1207 -0
  57. package/src/argv.mjs +88 -0
  58. package/src/capabilities.mjs +18 -0
  59. package/src/cli.mjs +180 -0
  60. package/src/client.mjs +169 -0
  61. package/src/commands/accounts.mjs +115 -0
  62. package/src/commands/agent.mjs +13 -0
  63. package/src/commands/ai.mjs +420 -0
  64. package/src/commands/analysis.mjs +124 -0
  65. package/src/commands/api.mjs +63 -0
  66. package/src/commands/applies.mjs +89 -0
  67. package/src/commands/assistant.mjs +53 -0
  68. package/src/commands/auth.mjs +109 -0
  69. package/src/commands/candidates.mjs +252 -0
  70. package/src/commands/checkin.mjs +41 -0
  71. package/src/commands/dashboard.mjs +11 -0
  72. package/src/commands/evaluations.mjs +27 -0
  73. package/src/commands/extended.mjs +96 -0
  74. package/src/commands/feishu.mjs +31 -0
  75. package/src/commands/groups.mjs +66 -0
  76. package/src/commands/index.mjs +101 -0
  77. package/src/commands/interviews.mjs +151 -0
  78. package/src/commands/load.mjs +27 -0
  79. package/src/commands/notifications.mjs +131 -0
  80. package/src/commands/plans.mjs +90 -0
  81. package/src/commands/profile.mjs +43 -0
  82. package/src/commands/slots.mjs +95 -0
  83. package/src/commands/uploads.mjs +169 -0
  84. package/src/config.mjs +68 -0
  85. package/src/errors.mjs +156 -0
  86. package/src/flags.mjs +114 -0
  87. package/src/idempotent.mjs +75 -0
  88. package/src/output.mjs +138 -0
  89. package/src/path.mjs +40 -0
  90. package/src/payload.mjs +137 -0
  91. package/src/prompt.mjs +47 -0
  92. package/src/query.mjs +44 -0
  93. package/src/sensitive.mjs +50 -0
  94. package/src/session.mjs +94 -0
  95. package/src/time.mjs +87 -0
  96. package/src/version.mjs +1 -0
@@ -0,0 +1,90 @@
1
+ import { register } from './index.mjs'
2
+ import { usageError } from '../errors.mjs'
3
+ import { collectQuery, parseNumber } from '../query.mjs'
4
+ import { flagEnum, flagString, flagNumber, payloadFrom, camelCase } from '../payload.mjs'
5
+ import { normalizeLocalTime } from '../time.mjs'
6
+ import { idParam } from '../path.mjs'
7
+
8
+ /** buildPlanPayload 读取的 flag 清单(词表守护测试的扫描面,见文件内注释) */
9
+ const TEXT_FLAGS = ['assessment-image-url', 'assessment-file-url', 'assessment-file-name', 'pass-text-content', 'pass-image-url', 'fail-text-content', 'fail-image-url']
10
+ const BOOKING_TIME_FLAGS = ['booking-start-time', 'booking-end-time', 'publish-time']
11
+
12
+ register('plans list', {
13
+ summary: '考核方案列表(一面/二轮/三面方案)',
14
+ usage: 'ch plans list [--round 1|2|3] [--scope-group-id N] [--status 1|2] [--pretty]',
15
+ endpoints: ['GET /admin/interview-plans'],
16
+ run: async (ctx) => {
17
+ const query = collectQuery(ctx, {
18
+ round: { parse: parseNumber },
19
+ 'scope-group-id': { key: 'scopeGroupId', parse: parseNumber },
20
+ status: { parse: parseNumber },
21
+ })
22
+ const { data } = await ctx.client.request({ method: 'GET', path: '/admin/interview-plans', query })
23
+ return data
24
+ },
25
+ })
26
+
27
+ /** 构造方案载荷:语义 flag 为基础,-d 覆盖;时间统一本地无偏移格式 */
28
+ function buildPlanPayload(ctx, { includeScope }) {
29
+ const base = {}
30
+ if (includeScope) {
31
+ const round = flagEnum(ctx, 'round', ['1', '2', '3'], { required: true, label: 'ch plans create --round 1|2|3' })
32
+ base.round = Number(round)
33
+ base.scopeGroupId = flagNumber(ctx, 'scope-group-id', { required: true, label: 'ch plans create --scope-group-id N' })
34
+ }
35
+ const assessmentContent = flagString(ctx, 'assessment-content', { required: includeScope, label: '--assessment-content 考核内容' })
36
+ if (assessmentContent !== undefined) base.assessmentContent = assessmentContent
37
+ // 显式常量而非内联字面量:src/flags.mjs 的词表守护测试只认顶层读取与 *_FLAGS 常量,
38
+ // 藏在 for-of 字面量里的 flag 名扫不到,就会退化成「拼错也不报」的静默丢失。
39
+ for (const flag of TEXT_FLAGS) {
40
+ const value = flagString(ctx, flag)
41
+ if (value !== undefined) base[flag.replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = value
42
+ }
43
+ for (const flag of BOOKING_TIME_FLAGS) {
44
+ const value = flagString(ctx, flag)
45
+ if (value !== undefined) base[camelCase(flag)] = normalizeLocalTime(value)
46
+ }
47
+ const publishMode = flagEnum(ctx, 'publish-mode', ['REALTIME', 'SCHEDULED'])
48
+ if (publishMode !== undefined) base.publishMode = publishMode
49
+ const status = flagEnum(ctx, 'status', ['1', '2'])
50
+ if (status !== undefined) base.status = Number(status)
51
+ const payload = payloadFrom(ctx, base)
52
+ if (payload.publishMode === 'SCHEDULED' && !payload.publishTime) {
53
+ throw usageError('publishMode=SCHEDULED 时必须提供 --publish-time(或载荷中带 publishTime)')
54
+ }
55
+ return payload
56
+ }
57
+
58
+ register('plans create', {
59
+ summary: '创建考核方案',
60
+ usage: 'ch plans create --round 1|2|3 --scope-group-id N --assessment-content 内容 [--assessment-image-url U] [--assessment-file-url U] [--assessment-file-name 名] [--pass-text-content T] [--pass-image-url U] [--fail-text-content T] [--fail-image-url U] [--booking-start-time "2026-09-28 14:00"] [--booking-end-time "..."] [--publish-mode REALTIME|SCHEDULED] [--publish-time "..."] [-d json]',
61
+ endpoints: ['POST /admin/interview-plans'],
62
+ run: async (ctx) => {
63
+ const payload = buildPlanPayload(ctx, { includeScope: true })
64
+ const { data } = await ctx.client.request({ method: 'POST', path: '/admin/interview-plans', body: payload })
65
+ return data
66
+ },
67
+ })
68
+
69
+ register('plans update', {
70
+ summary: '更新考核方案(未停用且无场次时可改)',
71
+ usage: 'ch plans update <planId> [--assessment-content 内容] [--status 1|2] [--publish-mode ...] [--publish-time ...] [-d json]',
72
+ endpoints: ['PUT /admin/interview-plans/{planId}'],
73
+ run: async (ctx) => {
74
+ const planId = idParam(ctx.positionals[0], { label: 'planId' })
75
+ const payload = buildPlanPayload(ctx, { includeScope: false })
76
+ const { data } = await ctx.client.request({ method: 'PUT', path: `/admin/interview-plans/${planId}`, body: payload })
77
+ return data
78
+ },
79
+ })
80
+
81
+ register('plans delete', {
82
+ summary: '删除已停用且无场次的考核方案',
83
+ usage: 'ch plans delete <planId>',
84
+ endpoints: ['DELETE /admin/interview-plans/{planId}'],
85
+ run: async (ctx) => {
86
+ const planId = idParam(ctx.positionals[0], { label: 'planId' })
87
+ const { data } = await ctx.client.request({ method: 'DELETE', path: `/admin/interview-plans/${planId}` })
88
+ return data === null ? { deleted: true, planId: Number(planId) } : data
89
+ },
90
+ })
@@ -0,0 +1,43 @@
1
+ import { register } from './index.mjs'
2
+ import { usageError } from '../errors.mjs'
3
+ import { flagString, flagNumber, payloadFrom } from '../payload.mjs'
4
+
5
+ register('profile get', {
6
+ summary: '当前管理员个人资料',
7
+ usage: 'ch profile get',
8
+ endpoints: ['GET /admin/profile'],
9
+ run: async (ctx) => {
10
+ const { data } = await ctx.client.request({ method: 'GET', path: '/admin/profile' })
11
+ return data
12
+ },
13
+ })
14
+
15
+ register('profile update', {
16
+ summary: '修改个人资料',
17
+ usage: 'ch profile update --real-name 姓名 [--group-id N] [--phone 手机号]',
18
+ endpoints: ['PUT /admin/profile'],
19
+ run: async (ctx) => {
20
+ const realName = flagString(ctx, 'real-name', { required: true, label: 'ch profile update --real-name 姓名' })
21
+ const base = { realName }
22
+ const groupId = flagNumber(ctx, 'group-id')
23
+ if (groupId !== undefined) base.groupId = groupId
24
+ const phone = flagString(ctx, 'phone')
25
+ if (phone !== undefined) base.phone = phone
26
+ const payload = payloadFrom(ctx, base)
27
+ const { data } = await ctx.client.request({ method: 'PUT', path: '/admin/profile', body: payload })
28
+ return data === null ? { updated: true } : data
29
+ },
30
+ })
31
+
32
+ register('profile change-password', {
33
+ summary: '修改当前账号登录密码',
34
+ usage: 'ch profile change-password --password 新密码',
35
+ endpoints: ['PUT /admin/profile/password'],
36
+ run: async (ctx) => {
37
+ const password = flagString(ctx, 'password', { required: true, label: 'ch profile change-password --password ******' })
38
+ if (password.length < 6) throw usageError('密码长度不能少于 6 位')
39
+ const payload = payloadFrom(ctx, { password })
40
+ const { data } = await ctx.client.request({ method: 'PUT', path: '/admin/profile/password', body: payload })
41
+ return data === null ? { updated: true } : data
42
+ },
43
+ })
@@ -0,0 +1,95 @@
1
+ import { register } from './index.mjs'
2
+ import { usageError } from '../errors.mjs'
3
+ import { collectQuery, parseNumber } from '../query.mjs'
4
+ import { flagEnum, flagString, flagNumber, payloadFrom } from '../payload.mjs'
5
+ import { normalizeLocalTime } from '../time.mjs'
6
+ import { idParam } from '../path.mjs'
7
+
8
+ register('slots list', {
9
+ summary: '面试场次列表',
10
+ usage: 'ch slots list [--round 1|2|3] [--scope-group-id N] [--plan-id N] [--status 1|2] [--pretty]',
11
+ endpoints: ['GET /admin/interview-slots'],
12
+ run: async (ctx) => {
13
+ const query = collectQuery(ctx, {
14
+ round: { parse: parseNumber },
15
+ 'scope-group-id': { key: 'scopeGroupId', parse: parseNumber },
16
+ 'plan-id': { key: 'planId', parse: parseNumber },
17
+ status: { parse: parseNumber },
18
+ })
19
+ const { data } = await ctx.client.request({ method: 'GET', path: '/admin/interview-slots', query })
20
+ return data
21
+ },
22
+ })
23
+
24
+ register('slots qr', {
25
+ summary: '场次签到二维码/口令',
26
+ usage: 'ch slots qr <slotId>',
27
+ endpoints: ['GET /admin/interview-slots/{slotId}/checkin-qr'],
28
+ run: async (ctx) => {
29
+ const slotId = idParam(ctx.positionals[0], { label: 'slotId' })
30
+ const { data } = await ctx.client.request({ method: 'GET', path: `/admin/interview-slots/${slotId}/checkin-qr` })
31
+ return data
32
+ },
33
+ })
34
+
35
+ register('slots create', {
36
+ summary: '创建面试场次',
37
+ usage: 'ch slots create --plan-id N --slot-time "2026-09-28 14:00" --location 教学楼A301 --capacity 5',
38
+ endpoints: ['POST /admin/interview-slots'],
39
+ run: async (ctx) => {
40
+ const planId = flagNumber(ctx, 'plan-id', { required: true, label: 'ch slots create --plan-id N' })
41
+ const slotTime = flagString(ctx, 'slot-time', { required: true, label: 'ch slots create --slot-time "2026-09-28 14:00"' })
42
+ const location = flagString(ctx, 'location', { required: true, label: 'ch slots create --location 地点' })
43
+ const capacity = flagNumber(ctx, 'capacity', { required: true, label: 'ch slots create --capacity N' })
44
+ if (!Number.isInteger(capacity) || capacity <= 0) throw usageError('--capacity 必须是正整数')
45
+ const payload = payloadFrom(ctx, { planId, slotTime: normalizeLocalTime(slotTime), location, capacity })
46
+ const { data } = await ctx.client.request({ method: 'POST', path: '/admin/interview-slots', body: payload })
47
+ return data
48
+ },
49
+ })
50
+
51
+ register('slots update', {
52
+ summary: '更新面试场次',
53
+ usage: 'ch slots update <slotId> [--slot-time "..."] [--location 地点] [--capacity N] [--status 1|2]',
54
+ endpoints: ['PUT /admin/interview-slots/{slotId}'],
55
+ run: async (ctx) => {
56
+ const slotId = idParam(ctx.positionals[0], { label: 'slotId' })
57
+ const base = {}
58
+ const slotTime = flagString(ctx, 'slot-time')
59
+ if (slotTime !== undefined) base.slotTime = normalizeLocalTime(slotTime)
60
+ const location = flagString(ctx, 'location')
61
+ if (location !== undefined) base.location = location
62
+ const capacity = flagNumber(ctx, 'capacity')
63
+ if (capacity !== undefined) {
64
+ if (!Number.isInteger(capacity) || capacity <= 0) throw usageError('--capacity 必须是正整数')
65
+ base.capacity = capacity
66
+ }
67
+ const status = flagEnum(ctx, 'status', ['1', '2'])
68
+ if (status !== undefined) base.status = Number(status)
69
+ const payload = payloadFrom(ctx, base)
70
+ const { data } = await ctx.client.request({ method: 'PUT', path: `/admin/interview-slots/${slotId}`, body: payload })
71
+ return data
72
+ },
73
+ })
74
+
75
+ register('slots close', {
76
+ summary: '关闭场次(停止预约)',
77
+ usage: 'ch slots close <slotId>',
78
+ endpoints: ['PUT /admin/interview-slots/{slotId}/close'],
79
+ run: async (ctx) => {
80
+ const slotId = idParam(ctx.positionals[0], { label: 'slotId' })
81
+ const { data } = await ctx.client.request({ method: 'PUT', path: `/admin/interview-slots/${slotId}/close` })
82
+ return data === null ? { closed: true, slotId: Number(slotId) } : data
83
+ },
84
+ })
85
+
86
+ register('slots delete', {
87
+ summary: '删除场次',
88
+ usage: 'ch slots delete <slotId>',
89
+ endpoints: ['DELETE /admin/interview-slots/{slotId}'],
90
+ run: async (ctx) => {
91
+ const slotId = idParam(ctx.positionals[0], { label: 'slotId' })
92
+ const { data } = await ctx.client.request({ method: 'DELETE', path: `/admin/interview-slots/${slotId}` })
93
+ return data === null ? { deleted: true, slotId: Number(slotId) } : data
94
+ },
95
+ })
@@ -0,0 +1,169 @@
1
+ import path from 'node:path'
2
+ import fs from 'node:fs'
3
+ import crypto from 'node:crypto'
4
+
5
+ import { register } from './index.mjs'
6
+ import { usageError } from '../errors.mjs'
7
+ import { textParam } from '../path.mjs'
8
+ import { flagString, flagOn } from '../payload.mjs'
9
+
10
+ const MAX_IMAGE_SIZE = 2 * 1024 * 1024
11
+ const MAX_ASSESSMENT_SIZE = 10 * 1024 * 1024
12
+
13
+ /**
14
+ * 决定附件落盘位置。
15
+ * 之前是 `path.resolve(flags.get('out') || name)` 直接写:--name 是调用方(服务端按名
16
+ * 返回内容)给的,`--name ../../x.txt` 实测把远端内容写到了 cwd 之外;同名文件还会被
17
+ * 静默覆盖。现在默认名取 basename、必须落在 cwd 内,显式 --out 才允许落在别处,
18
+ * 覆盖必须 --force。
19
+ */
20
+ function resolveDownloadTarget({ name, out, force }) {
21
+ if (out !== undefined && out !== false && out !== true && String(out).trim() !== '') {
22
+ const explicit = path.resolve(String(out))
23
+ if (!force && fs.existsSync(explicit)) {
24
+ throw usageError(`目标文件已存在:${explicit}`, '确认要覆盖请加 --force')
25
+ }
26
+ return explicit
27
+ }
28
+ const base = path.basename(String(name).trim())
29
+ if (base === '' || base === '.' || base === '..') {
30
+ throw usageError(`无法从 --name 推出安全文件名:${name}`, '请用 --out 指定完整输出路径')
31
+ }
32
+ const target = path.resolve(process.cwd(), base)
33
+ if (!force && fs.existsSync(target)) {
34
+ throw usageError(`目标文件已存在:${target}`, '确认要覆盖请加 --force,或用 --out 换个路径')
35
+ }
36
+ return target
37
+ }
38
+
39
+ const IMAGE_MIME_BY_EXTENSION = {
40
+ '.jpg': 'image/jpeg',
41
+ '.jpeg': 'image/jpeg',
42
+ '.png': 'image/png',
43
+ '.webp': 'image/webp',
44
+ }
45
+
46
+ /** 与 admin-web lib/assessmentFile.ts 的允许清单一致 */
47
+ const ASSESSMENT_MIME_BY_EXTENSION = {
48
+ '.pdf': 'application/pdf',
49
+ '.doc': 'application/msword',
50
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
51
+ '.xls': 'application/vnd.ms-excel',
52
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
53
+ '.csv': 'text/csv',
54
+ '.md': 'text/markdown',
55
+ '.zip': 'application/zip',
56
+ '.rar': 'application/vnd.rar',
57
+ '.7z': 'application/x-7z-compressed',
58
+ '.tar': 'application/x-tar',
59
+ }
60
+
61
+ /** 读取待上传文件并做本地预检(格式/大小),失败按用法错误处理 */
62
+ function readUploadFile(ctx, { mimeByExtension, maxSize, label }) {
63
+ const filePath = ctx.positionals[0] || flagString(ctx, 'file')
64
+ if (!filePath) throw usageError('缺少待上传文件路径', `用法:${label}`)
65
+ let stat
66
+ try {
67
+ stat = fs.statSync(filePath)
68
+ } catch {
69
+ throw usageError(`文件不存在:${filePath}`)
70
+ }
71
+ if (!stat.isFile()) throw usageError(`不是普通文件:${filePath}`)
72
+ if (stat.size === 0) throw usageError('文件不能为空')
73
+ if (stat.size > maxSize) {
74
+ throw usageError(
75
+ `文件大小 ${(stat.size / 1024 / 1024).toFixed(2)}MB 超过上限 ${maxSize / 1024 / 1024}MB`,
76
+ '管理台上传同样受限;请先压缩或裁剪',
77
+ )
78
+ }
79
+ const extension = path.extname(filePath).toLowerCase()
80
+ const mime = mimeByExtension[extension]
81
+ if (!mime) {
82
+ throw usageError(`不支持的文件格式 ${extension || '(无扩展名)'}`, `允许:${Object.keys(mimeByExtension).join(' ')}`)
83
+ }
84
+ const buffer = fs.readFileSync(filePath)
85
+ const fileName = path.basename(filePath)
86
+ const form = new FormData()
87
+ form.append('file', new Blob([buffer], { type: mime }), fileName)
88
+ return { form, fileName, size: stat.size }
89
+ }
90
+
91
+ /** 相对资源地址(/uploads/**)拼成绝对地址,便于跨域部署下直接使用 */
92
+ function absoluteUrl(baseUrl, url) {
93
+ if (typeof url !== 'string' || !url.startsWith('/')) return url
94
+ try {
95
+ return new URL(url, baseUrl.replace(/\/api\/?$/, '/')).toString()
96
+ } catch {
97
+ return url
98
+ }
99
+ }
100
+
101
+ register('uploads image', {
102
+ summary: '上传图片(JPG/PNG/WebP,≤2MB)',
103
+ usage: 'ch uploads image <文件路径>',
104
+ endpoints: ['POST /admin/uploads'],
105
+ run: async (ctx) => {
106
+ const { form, fileName, size } = readUploadFile(ctx, {
107
+ mimeByExtension: IMAGE_MIME_BY_EXTENSION,
108
+ maxSize: MAX_IMAGE_SIZE,
109
+ label: 'ch uploads image <文件路径>',
110
+ })
111
+ const { data } = await ctx.client.request({ method: 'POST', path: '/admin/uploads', formData: form })
112
+ return { ...data, fileName, size, absoluteUrl: absoluteUrl(ctx.config.baseUrl, data && data.url) }
113
+ },
114
+ })
115
+
116
+ register('uploads assessment', {
117
+ summary: '上传考核附件(PDF/DOC/DOCX/XLS/XLSX/CSV/ZIP/RAR/7Z/TAR/MD,≤10MB)',
118
+ usage: 'ch uploads assessment <文件路径>',
119
+ endpoints: ['POST /admin/uploads/assessment-files'],
120
+ run: async (ctx) => {
121
+ const { form, fileName, size } = readUploadFile(ctx, {
122
+ mimeByExtension: ASSESSMENT_MIME_BY_EXTENSION,
123
+ maxSize: MAX_ASSESSMENT_SIZE,
124
+ label: 'ch uploads assessment <文件路径>',
125
+ })
126
+ const { data } = await ctx.client.request({ method: 'POST', path: '/admin/uploads/assessment-files', formData: form })
127
+ return { ...data, size, absoluteUrl: absoluteUrl(ctx.config.baseUrl, data && data.url) }
128
+ },
129
+ })
130
+
131
+ register('uploads ai-attachment', {
132
+ summary: '上传 AI Coding 题包附件(≤10MB)',
133
+ usage: 'ch uploads ai-attachment <文件路径>',
134
+ endpoints: ['POST /admin/ai-coding/paper-attachments'],
135
+ run: async (ctx) => {
136
+ const { form, fileName, size } = readUploadFile(ctx, {
137
+ mimeByExtension: ASSESSMENT_MIME_BY_EXTENSION,
138
+ maxSize: MAX_ASSESSMENT_SIZE,
139
+ label: 'ch uploads ai-attachment <文件路径>',
140
+ })
141
+ const { data } = await ctx.client.request({ method: 'POST', path: '/admin/ai-coding/paper-attachments', formData: form })
142
+ return data
143
+ },
144
+ })
145
+
146
+ register('uploads attachment-download', {
147
+ summary: '下载 AI Coding 题包附件到本地文件',
148
+ usage: 'ch uploads attachment-download <attachmentId> --name 文件名 [--out 输出路径]',
149
+ endpoints: ['GET /admin/ai-coding/paper-attachments/{attachmentId}'],
150
+ run: async (ctx) => {
151
+ const [attachmentId] = ctx.positionals
152
+ if (!attachmentId) throw usageError('缺少 attachmentId', '用法:ch uploads attachment-download <attachmentId> --name 文件名')
153
+ const name = flagString(ctx, 'name', { required: true, label: '--name 附件名(服务端按名返回)' })
154
+ const { buffer, contentType } = await ctx.client.request({
155
+ method: 'GET',
156
+ path: `/admin/ai-coding/paper-attachments/${textParam(attachmentId, { label: 'attachmentId' })}`,
157
+ query: [['name', name]],
158
+ raw: true,
159
+ })
160
+ const outPath = resolveDownloadTarget({ name, out: ctx.flags.get('out'), force: flagOn(ctx, 'force') })
161
+ const temp = `${outPath}.partial-${crypto.randomUUID()}`
162
+ try {
163
+ fs.writeFileSync(temp, buffer, { flag: 'wx', mode: 0o600 })
164
+ if (flagOn(ctx, 'force')) fs.renameSync(temp, outPath)
165
+ else { fs.linkSync(temp, outPath); fs.unlinkSync(temp) }
166
+ } finally { try { fs.unlinkSync(temp) } catch {} }
167
+ return { savedTo: outPath, bytes: buffer.length, contentType }
168
+ },
169
+ })
package/src/config.mjs ADDED
@@ -0,0 +1,68 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+
5
+ import { usageError } from './errors.mjs'
6
+
7
+ /** 内置 profile:优先级 flag > env > 配置文件 > 内置默认值 */
8
+ export const BUILTIN_PROFILES = Object.freeze({
9
+ local: 'http://127.0.0.1:8080/api',
10
+ prod: 'https://api.dayunwu.cn/api',
11
+ })
12
+ export const DEFAULT_PROFILE = 'local'
13
+
14
+ export function configDir(env = process.env) {
15
+ return env.CH_CONFIG_DIR || path.join(os.homedir(), '.config', 'cloudhouse-cli')
16
+ }
17
+
18
+ export function configFilePath(env = process.env) {
19
+ return path.join(configDir(env), 'config.json')
20
+ }
21
+
22
+ /** 读取用户配置文件(不存在或损坏时返回空对象,不阻断命令) */
23
+ export function loadConfigFile(env = process.env) {
24
+ try {
25
+ const raw = fs.readFileSync(configFilePath(env), 'utf8')
26
+ const parsed = JSON.parse(raw)
27
+ return parsed && typeof parsed === 'object' ? parsed : {}
28
+ } catch {
29
+ return {}
30
+ }
31
+ }
32
+
33
+ /**
34
+ * 解析运行配置。优先级:--flag > 环境变量 > 配置文件 > 内置默认。
35
+ *
36
+ * profile 拼错绝不能静默回落到 local:那会让一条以为在操作生产的写请求打到本地
37
+ * (或反过来),而调用方看到的仍然是 exit 0。只有在 baseUrl 已由 flag/env 显式
38
+ * 给出时,profile 才退化成一个标签;否则未知 profile 必须是用法错误。
39
+ *
40
+ * @returns {{profile:string, baseUrl:string, baseUrlSource:string}}
41
+ */
42
+ export function resolveConfig({ flags = {}, env = process.env } = {}) {
43
+ const file = loadConfigFile(env)
44
+ const profile = String(flags.profile ?? env.CH_PROFILE ?? file.defaultProfile ?? DEFAULT_PROFILE)
45
+
46
+ const flagBase = flags.baseUrl
47
+ const envBase = env.CH_BASE_URL || env.CH_API_BASE
48
+ const fileProfiles = file.profiles && typeof file.profiles === 'object' ? file.profiles : null
49
+ const fileBase = fileProfiles ? fileProfiles[profile]?.baseUrl : undefined
50
+ const builtinBase = BUILTIN_PROFILES[profile]
51
+ const explicitBase = flagBase ?? envBase
52
+
53
+ if (!builtinBase && !fileBase && !explicitBase) {
54
+ const known = [...new Set([...Object.keys(BUILTIN_PROFILES), ...Object.keys(fileProfiles || {})])]
55
+ throw usageError(`未知 profile:${profile}`, `已定义的环境:${known.join(' / ')};也可用 --base-url 或 CH_BASE_URL 直接指定 API 根地址`)
56
+ }
57
+
58
+ const baseUrl = String(explicitBase ?? fileBase ?? builtinBase ?? BUILTIN_PROFILES[DEFAULT_PROFILE])
59
+ .trim()
60
+ .replace(/\/+$/, '')
61
+
62
+ let baseUrlSource = 'builtin'
63
+ if (flagBase) baseUrlSource = 'flag'
64
+ else if (envBase) baseUrlSource = 'env'
65
+ else if (fileBase) baseUrlSource = 'config-file'
66
+
67
+ return { profile, baseUrl, baseUrlSource }
68
+ }
package/src/errors.mjs ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * 统一错误类型与 exit code 契约(Agent 按 exit code 分支,不需要解析中文文案)。
3
+ *
4
+ * 退出码:
5
+ * 0 成功
6
+ * 1 普通业务错误(后端 code≠200,未细分)
7
+ * 2 用法错误(本地校验失败:缺参数、缺 reason 等)
8
+ * 3 鉴权失效(HTTP 401,或 HTTP 200 + 业务码 401):需重新 login
9
+ * 4 权限不足(HTTP 403,或 HTTP 200 + 业务码 403,或 ADMIN_PERMISSION_REQUIRED)
10
+ * 5 限流(429):后端已限流,稍后重试
11
+ * 6 版本冲突且重试耗尽(VERSION_CONFLICT / GRADE_VERSION_CONFLICT)
12
+ * 7 网络不可达(fetch 抛错)或请求超时(NETWORK_TIMEOUT)
13
+ * 8 功能开关未开(FEATURE_DISABLED)
14
+ * 9 端点未实现(HTTP/业务 code 404 或 405)
15
+ */
16
+ export const EXIT = Object.freeze({
17
+ OK: 0,
18
+ BUSINESS: 1,
19
+ USAGE: 2,
20
+ AUTH: 3,
21
+ PERMISSION: 4,
22
+ RATE_LIMIT: 5,
23
+ VERSION_CONFLICT: 6,
24
+ NETWORK: 7,
25
+ FEATURE_DISABLED: 8,
26
+ NOT_IMPLEMENTED: 9,
27
+ })
28
+
29
+ export class CliError extends Error {
30
+ constructor({ code, message, exitCode = EXIT.BUSINESS, reasonCode = null, httpStatus = null, payload = null, hint = null }) {
31
+ super(message)
32
+ this.name = 'CliError'
33
+ this.code = code
34
+ this.exitCode = exitCode
35
+ this.reasonCode = reasonCode
36
+ this.httpStatus = httpStatus
37
+ this.payload = payload
38
+ this.hint = hint
39
+ }
40
+
41
+ toJSON() {
42
+ const error = { code: this.code, message: this.message }
43
+ if (this.reasonCode) error.reasonCode = this.reasonCode
44
+ if (this.httpStatus !== null) error.httpStatus = this.httpStatus
45
+ if (this.hint) error.hint = this.hint
46
+ return error
47
+ }
48
+ }
49
+
50
+ /** 从后端错误响应体 data 中读取机器可读原因码(与 admin-web readReasonCode 同契约) */
51
+ export function readReasonCode(payload) {
52
+ if (!payload || typeof payload !== 'object') return null
53
+ if (typeof payload.reasonCode === 'string') return payload.reasonCode
54
+ // 鉴权错误的历史契约字段是 data.reason(与 reasonCode 同值)
55
+ if (typeof payload.reason === 'string') return payload.reason
56
+ return null
57
+ }
58
+
59
+ export function usageError(message, hint = null) {
60
+ return new CliError({ code: 'USAGE_ERROR', message, exitCode: EXIT.USAGE, hint })
61
+ }
62
+
63
+ const VERSION_CONFLICT_CODES = new Set(['VERSION_CONFLICT', 'GRADE_VERSION_CONFLICT'])
64
+ export function isVersionConflictCode(reasonCode) {
65
+ return typeof reasonCode === 'string' && VERSION_CONFLICT_CODES.has(reasonCode)
66
+ }
67
+
68
+ /** 登录端点:只有它的 401 才是「凭据不对」,且带 IP/账号两级限流 */
69
+ const LOGIN_PATH = '/auth/login'
70
+ /** 明确表示会话而非密码的原因码(JwtInterceptor 与业务端点共用这套词表) */
71
+ const SESSION_REASON_CODES = new Set([
72
+ 'TOKEN_EXPIRED',
73
+ 'SESSION_EXPIRED',
74
+ 'SESSION_ABSOLUTE_EXPIRED',
75
+ 'AUTH_REQUIRED',
76
+ ])
77
+
78
+ /**
79
+ * 把「非成功响应」分类成带 exit code 的 CliError。
80
+ * @param {{httpStatus:number, code:number, message:string, payload:unknown, retryAfter?:string|null, requestPath?:string|null}} input
81
+ */
82
+ export function classifyFailure({ httpStatus, code, message, payload, retryAfter = null, requestPath = null }) {
83
+ const reasonCode = readReasonCode(payload)
84
+ const base = { reasonCode, httpStatus, payload, message }
85
+
86
+ if (httpStatus === 401) {
87
+ return new CliError({
88
+ ...base,
89
+ code: 'AUTH_EXPIRED',
90
+ exitCode: EXIT.AUTH,
91
+ hint: '会话已失效,请重新执行 ch login',
92
+ })
93
+ }
94
+ if (code === 401) {
95
+ // HTTP 200 + 业务码 401 在本项目有两种含义,必须分开:
96
+ // /auth/login → 凭据不对(且有 IP 与账号两级限流,必须劝阻连续重试)
97
+ // 其它端点 → 会话没被认出来(如 InterviewAttendanceServiceImpl 的 Result.error(401,"未登录")),
98
+ // 此时说「账号或密码错误」会把 Agent 引向完全错误的补救方向。
99
+ const sessionReason = SESSION_REASON_CODES.has(reasonCode)
100
+ if (requestPath === LOGIN_PATH && !sessionReason) {
101
+ return new CliError({
102
+ ...base,
103
+ code: 'AUTH_REJECTED',
104
+ exitCode: EXIT.AUTH,
105
+ hint: '账号或密码错误;登录接口有限流(IP 60/300s、账号 10/300s),请勿连续重试',
106
+ })
107
+ }
108
+ return new CliError({
109
+ ...base,
110
+ code: 'AUTH_EXPIRED',
111
+ exitCode: EXIT.AUTH,
112
+ hint: '服务端未接受当前会话(原因可能是会话失效、账号停用或请求未带 Token):先用 ch whoami 确认身份,再重新 ch login;这不是密码错误,无需改用其它凭据重试',
113
+ })
114
+ }
115
+ // 后端权限不足的既有契约是 HTTP 200 + body.code=403(Result.error(403, …) 是纯 POJO,
116
+ // 没有 @ResponseStatus,也没有 ResponseBodyAdvice 把业务码映射回 HTTP 状态;只有
117
+ // JwtInterceptor 与 AiCodingControllerAdvice 会真的设置 HTTP 状态)。只判 httpStatus
118
+ // 会让 exit 4 对绝大多数权限拒绝不可达,Agent 按文档「4 → 停止」的分支就永远走不到。
119
+ if (httpStatus === 403 || code === 403 || reasonCode === 'ADMIN_PERMISSION_REQUIRED') {
120
+ return new CliError({
121
+ ...base,
122
+ code: 'PERMISSION_DENIED',
123
+ exitCode: EXIT.PERMISSION,
124
+ hint: '当前账号权限不足;ch whoami 可查看 adminLevel 与授权组别',
125
+ })
126
+ }
127
+ if (httpStatus === 429 || code === 429) {
128
+ const hint = retryAfter ? `后端限流,请 ${retryAfter} 秒后重试` : '后端限流,请稍后重试'
129
+ return new CliError({ ...base, code: 'RATE_LIMITED', exitCode: EXIT.RATE_LIMIT, hint })
130
+ }
131
+ if (httpStatus === 404 || httpStatus === 405 || httpStatus === 501 || code === 404 || code === 405 || code === 501) {
132
+ return new CliError({
133
+ ...base,
134
+ code: 'NOT_IMPLEMENTED',
135
+ exitCode: EXIT.NOT_IMPLEMENTED,
136
+ hint: '后端未实现该接口;可检查后端版本或用 ch api 直接调用确认',
137
+ })
138
+ }
139
+ if (reasonCode === 'FEATURE_DISABLED') {
140
+ return new CliError({
141
+ ...base,
142
+ code: 'FEATURE_DISABLED',
143
+ exitCode: EXIT.FEATURE_DISABLED,
144
+ hint: '对应功能开关未开启(如 AI Coding / 三面选组 / 飞书签到)',
145
+ })
146
+ }
147
+ if (isVersionConflictCode(reasonCode)) {
148
+ return new CliError({
149
+ ...base,
150
+ code: 'VERSION_CONFLICT',
151
+ exitCode: EXIT.VERSION_CONFLICT,
152
+ hint: '数据版本已变化,请重读后使用最新 version 重试',
153
+ })
154
+ }
155
+ return new CliError({ ...base, code: 'BUSINESS_ERROR', exitCode: EXIT.BUSINESS })
156
+ }