museav-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.
Files changed (70) hide show
  1. package/.github/workflows/ci.yml +19 -0
  2. package/.github/workflows/publish.yml +79 -0
  3. package/AGENTS.md +90 -0
  4. package/CHANGELOG.md +49 -0
  5. package/LICENSE +21 -0
  6. package/README.md +490 -0
  7. package/SECURITY.md +11 -0
  8. package/dist/client.d.ts +387 -0
  9. package/dist/client.js +372 -0
  10. package/dist/commands/assets.d.ts +14 -0
  11. package/dist/commands/assets.js +30 -0
  12. package/dist/commands/balance.d.ts +3 -0
  13. package/dist/commands/balance.js +11 -0
  14. package/dist/commands/bind-feishu.d.ts +3 -0
  15. package/dist/commands/bind-feishu.js +63 -0
  16. package/dist/commands/gen.d.ts +16 -0
  17. package/dist/commands/gen.js +88 -0
  18. package/dist/commands/image-to-template.d.ts +24 -0
  19. package/dist/commands/image-to-template.js +111 -0
  20. package/dist/commands/jobs.d.ts +11 -0
  21. package/dist/commands/jobs.js +27 -0
  22. package/dist/commands/login.d.ts +3 -0
  23. package/dist/commands/login.js +67 -0
  24. package/dist/commands/models.d.ts +3 -0
  25. package/dist/commands/models.js +9 -0
  26. package/dist/commands/products.d.ts +9 -0
  27. package/dist/commands/products.js +16 -0
  28. package/dist/commands/reverse.d.ts +3 -0
  29. package/dist/commands/reverse.js +16 -0
  30. package/dist/commands/skills.d.ts +5 -0
  31. package/dist/commands/skills.js +22 -0
  32. package/dist/commands/templates.d.ts +28 -0
  33. package/dist/commands/templates.js +79 -0
  34. package/dist/commands/upload.d.ts +9 -0
  35. package/dist/commands/upload.js +8 -0
  36. package/dist/commands/video-templates.d.ts +21 -0
  37. package/dist/commands/video-templates.js +71 -0
  38. package/dist/commands/welcome.d.ts +6 -0
  39. package/dist/commands/welcome.js +93 -0
  40. package/dist/commands/whoami.d.ts +3 -0
  41. package/dist/commands/whoami.js +14 -0
  42. package/dist/config.d.ts +31 -0
  43. package/dist/config.js +93 -0
  44. package/dist/index.d.ts +2 -0
  45. package/dist/index.js +292 -0
  46. package/dist/tenant-client.d.ts +31 -0
  47. package/dist/tenant-client.js +84 -0
  48. package/package.json +55 -0
  49. package/src/client.ts +636 -0
  50. package/src/commands/assets.ts +47 -0
  51. package/src/commands/balance.ts +12 -0
  52. package/src/commands/bind-feishu.ts +77 -0
  53. package/src/commands/gen.ts +111 -0
  54. package/src/commands/image-to-template.ts +150 -0
  55. package/src/commands/jobs.ts +37 -0
  56. package/src/commands/login.ts +80 -0
  57. package/src/commands/models.ts +12 -0
  58. package/src/commands/products.ts +38 -0
  59. package/src/commands/reverse.ts +21 -0
  60. package/src/commands/skills.ts +29 -0
  61. package/src/commands/templates.ts +98 -0
  62. package/src/commands/upload.ts +18 -0
  63. package/src/commands/video-templates.ts +89 -0
  64. package/src/commands/welcome.ts +108 -0
  65. package/src/commands/whoami.ts +19 -0
  66. package/src/config.ts +114 -0
  67. package/src/index.ts +318 -0
  68. package/src/tenant-client.ts +90 -0
  69. package/src/types/update-notifier.d.ts +31 -0
  70. package/tsconfig.json +19 -0
@@ -0,0 +1,98 @@
1
+ /** museav templates —— 查可用图片/文字模板(自己租户建的 + 平台共享的)
2
+ * --type image|article 可过滤(中台 templates 表同时装两种,不传则都列并标注类型) */
3
+ import type { StudioClient } from '../client.js'
4
+
5
+ export async function templates(client: StudioClient, opts: { category?: string; type?: string } = {}): Promise<void> {
6
+ let list = await client.templates((opts.type === 'image' || opts.type === 'article') ? opts.type : undefined)
7
+ if (opts.category) {
8
+ const kw = opts.category.toLowerCase()
9
+ list = list.filter((t) => (t.category || '').toLowerCase().includes(kw))
10
+ }
11
+ if (!list.length) {
12
+ process.stderr.write(opts.category ? `没有匹配「${opts.category}」的模板\n` : '没有可用模板\n')
13
+ return
14
+ }
15
+
16
+ const tag = (t: (typeof list)[number]) => (t.tenant_id ? '' : '[平台]')
17
+ const typeTag = (t: (typeof list)[number]) => (t.template_type === 'article' ? '[文字]' : t.template_type === 'image' ? '[图片]' : '')
18
+
19
+ process.stderr.write(`可用模板(${list.length} 个):\n`)
20
+ for (const t of list) {
21
+ const cfg = t.generation_configs?.find((c) => c.is_default) || t.generation_configs?.[0]
22
+ const fields = cfg?.params_json?.fields || []
23
+ const fieldHint = fields.length ? `字段:${fields.map((f) => f.key).join(',')}` : ''
24
+ process.stderr.write(
25
+ ` ${t.id.padEnd(38)} ${(t.zh_name || '').padEnd(16)} ${(t.category || '').padEnd(10)} ${(t.ratio || '').padEnd(6)} ${typeTag(t).padEnd(8)} ${fieldHint.padEnd(20)} ${tag(t)}\n`,
26
+ )
27
+ }
28
+ process.stderr.write(`\n出图: museav gen --template <模板id> [--fields '{"key":"值"}']\n`)
29
+ process.stderr.write(`按类型过滤: museav templates --type image|article\n`)
30
+ // stdout 只出 id,便于脚本与 agent 解析
31
+ console.log(list.map((t) => t.id).join('\n'))
32
+ }
33
+
34
+ interface CreateTemplateOpts {
35
+ name: string
36
+ prompt: string
37
+ category?: string
38
+ ratio?: string
39
+ description?: string
40
+ model?: string
41
+ quality?: string
42
+ fields?: string
43
+ type?: string
44
+ }
45
+
46
+ /**
47
+ * museav templates create —— 新建图片模板。
48
+ *
49
+ * 归属不用自己传:服务端根据鉴权身份自动决定——租户 apiKey 建的自动归该租户
50
+ * (其他租户看不到),平台管理员 JWT 建的是 tenant_id=null 的平台共享模板,
51
+ * 个人账号(无租户、非管理员)会被服务端拒绝。CLI 这里不做额外判断,直接把
52
+ * 服务端返回的结果(含真实归属)打印出来。
53
+ */
54
+ export async function createTemplate(client: StudioClient, opts: CreateTemplateOpts): Promise<void> {
55
+ if (!opts.name?.trim()) throw new Error('--name 必填')
56
+ if (!opts.prompt?.trim()) throw new Error('--prompt 必填,占位符用 {key} 形式,如 "{artist} 在 {city} 的演唱会海报"')
57
+
58
+ let fields: Array<{ key: string; label: string }>
59
+ if (opts.fields) {
60
+ try {
61
+ fields = JSON.parse(opts.fields)
62
+ } catch {
63
+ throw new Error('--fields 必须是合法 JSON 数组,如 \'[{"key":"artist","label":"艺人名"}]\'')
64
+ }
65
+ } else {
66
+ // 不传 --fields 就自动从 --prompt 里的 {key} 占位符提取,label 先等于 key,
67
+ // 想要更友好的中文标签可以自己传 --fields 覆盖
68
+ const keys = Array.from(new Set(Array.from(opts.prompt.matchAll(/\{(\w+)\}/g), (m) => m[1])))
69
+ fields = keys.map((key) => ({ key, label: key }))
70
+ }
71
+
72
+ const type = opts.type === 'article' ? 'article' : 'image'
73
+ const row = await client.createTemplate({
74
+ zh_name: opts.name,
75
+ category: opts.category,
76
+ ratio: opts.ratio,
77
+ description: opts.description,
78
+ template_type: type,
79
+ generation_configs: [
80
+ {
81
+ model: opts.model || 'gpt-image-2',
82
+ prompt_template: opts.prompt,
83
+ quality: opts.quality,
84
+ // 契约要求 fields 在 config 顶层(服务端 validateConfig 读 cfg.fields)
85
+ fields: fields.length ? fields : undefined,
86
+ is_default: true,
87
+ },
88
+ ],
89
+ })
90
+
91
+ process.stderr.write(`✅ ${type === 'article' ? '文字' : '图片'}模板已建:${row.id}\n`)
92
+ process.stderr.write(`归属:${row.tenant_id ? '当前租户(其他租户看不到)' : '平台共享(所有租户可见)'}\n`)
93
+ if (fields.length) process.stderr.write(`占位符字段: ${fields.map((f) => f.key).join(', ')}\n`)
94
+ const fieldExample = fields.length ? ` --fields '{"${fields[0].key}":"..."}'` : ''
95
+ process.stderr.write(`\n出图: museav gen --template ${row.id}${fieldExample}\n`)
96
+ // stdout 只出新建的模板 id,便于脚本链式使用
97
+ console.log(row.id)
98
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * museav upload —— 上传素材到中台图库,stdout 输出公网直链。
3
+ *
4
+ * 走 POST /api/upload-ref:图片 / 音频 / 视频都收,类型按**字节魔数**判定(中台不信
5
+ * 客户端声明的 MIME),分类型限大小——图片 8MB / 音频 20MB / 视频 50MB。
6
+ * 拿到的 URL 可以直接喂给 gen --ref / gen --video --image,也能给 reverse 当图片 URL。
7
+ */
8
+ import type { StudioClient } from '../client.js'
9
+
10
+ const KIND_LABEL: Record<string, string> = { image: '图片', audio: '音频', video: '视频' }
11
+
12
+ export async function upload(client: StudioClient, filePath: string): Promise<void> {
13
+ process.stderr.write(`上传 ${filePath} ...\n`)
14
+ const { url, media_type, mime } = await client.uploadRef(filePath)
15
+ const kind = media_type ? `${KIND_LABEL[media_type] || media_type}${mime ? ` · ${mime}` : ''}` : ''
16
+ process.stderr.write(`✅ 上传成功${kind ? `(${kind})` : ''}\n`)
17
+ console.log(url)
18
+ }
@@ -0,0 +1,89 @@
1
+ /** museav video-templates —— 查可用视频模板(配合 gen --video --template) */
2
+ import type { StudioClient, CreateVideoTemplateInput } from '../client.js'
3
+
4
+ export async function videoTemplates(client: StudioClient, opts: { category?: string } = {}): Promise<void> {
5
+ let list = await client.videoTemplates()
6
+ if (opts.category) {
7
+ const kw = opts.category.toLowerCase()
8
+ list = list.filter((t) => (t.category || '').toLowerCase().includes(kw))
9
+ }
10
+ if (!list.length) {
11
+ process.stderr.write(opts.category ? `没有匹配「${opts.category}」的视频模板\n` : '没有可用视频模板\n')
12
+ return
13
+ }
14
+
15
+ const tag = (t: (typeof list)[number]) => (t.tenant_id ? '' : '[平台]')
16
+
17
+ process.stderr.write(`可用视频模板(${list.length} 个):\n`)
18
+ for (const t of list) {
19
+ const cfg = t.generation_configs?.find((c) => c.is_default) || t.generation_configs?.[0]
20
+ const modelHint = cfg?.model ? `模型:${cfg.model}` : ''
21
+ const ratioHint = t.ratio || ''
22
+ const fieldCount = cfg?.params_json?.fields?.length || 0
23
+ const fieldHint = fieldCount ? `字段:${cfg!.params_json!.fields!.map((f) => f.key).join(',')}` : ''
24
+ const sampleHint = t.sample_video_url ? '有参考视频' : ''
25
+ process.stderr.write(
26
+ ` ${t.id.padEnd(38)} ${(t.zh_name || '').padEnd(20)} ${(t.category || '').padEnd(10)} ${ratioHint.padEnd(6)} ${modelHint.padEnd(30)} ${fieldHint.padEnd(24)} ${sampleHint.padEnd(10)} ${tag(t)}\n`,
27
+ )
28
+ if (t.sample_video_url) {
29
+ process.stderr.write(` 参考视频: ${t.sample_video_url}\n`)
30
+ }
31
+ }
32
+ process.stderr.write(`\n出视频: museav gen --video --template <模板id> [--fields '{"key":"值"}']\n`)
33
+ // stdout 只出 id 和参考视频 URL(tab 分隔),便于脚本与 agent 解析
34
+ console.log(list.map((t) => t.sample_video_url ? `${t.id}\t${t.sample_video_url}` : t.id).join('\n'))
35
+ }
36
+
37
+ interface CreateVideoTemplateOpts {
38
+ name: string
39
+ prompt: string
40
+ category?: string
41
+ description?: string
42
+ model?: string
43
+ duration?: string
44
+ ratio?: string
45
+ sampleVideo?: string
46
+ sampleCover?: string
47
+ }
48
+
49
+ /** museav video-templates create —— 新建视频模板。
50
+ * 视频模板字段跟图片不同:模型/时长/比例在 generation_configs 每项里(服务端契约)。
51
+ * 归属跟图片模板一样由服务端根据鉴权身份自动决定。 */
52
+ export async function createVideoTemplate(client: StudioClient, opts: CreateVideoTemplateOpts): Promise<void> {
53
+ if (!opts.name?.trim()) throw new Error('--name 必填')
54
+ if (!opts.prompt?.trim()) throw new Error('--prompt 必填,占位符用 {key} 形式,如 "{product} 在 {scene} 中展示"')
55
+
56
+ // 占位符必须声明 fields(中台 validateConfig 硬校验:prompt 里有 {key} 但没 fields 会被拒)
57
+ const keys = Array.from(new Set(Array.from(opts.prompt.matchAll(/\{(\w+)\}/g), (m) => m[1])))
58
+ const fields = keys.map((key) => ({ key, label: key }))
59
+
60
+ const cfg: Record<string, unknown> = {
61
+ model: opts.model || 'seedance-2',
62
+ prompt_template: opts.prompt,
63
+ is_default: true,
64
+ }
65
+ if (fields.length) cfg.fields = fields
66
+ if (opts.duration) {
67
+ const d = Number(opts.duration)
68
+ if (!Number.isFinite(d) || d < 4 || d > 15) throw new Error('--duration 必须是 4-15 之间的数字(秒)')
69
+ cfg.duration = d
70
+ }
71
+ if (opts.ratio) cfg.aspect_ratio = opts.ratio
72
+
73
+ const row = await client.createVideoTemplate({
74
+ zh_name: opts.name,
75
+ category: opts.category,
76
+ description: opts.description,
77
+ sample_video_url: opts.sampleVideo || null,
78
+ sample_cover_image: opts.sampleCover || null,
79
+ generation_configs: [cfg as CreateVideoTemplateInput['generation_configs'][number]],
80
+ })
81
+
82
+ process.stderr.write(`✅ 视频模板已建:${row.id}\n`)
83
+ process.stderr.write(`归属:${row.tenant_id ? '当前租户(其他租户看不到)' : '平台共享(所有租户可见)'}\n`)
84
+ process.stderr.write(`模型: ${cfg.model} 时长: ${cfg.duration || '模板默认'} 比例: ${cfg.aspect_ratio || '模板默认'}\n`)
85
+ if (fields.length) process.stderr.write(`占位符字段: ${fields.map((f) => f.key).join(', ')}\n`)
86
+ process.stderr.write(`\n出视频: museav gen --video --template ${row.id}\n`)
87
+ // stdout 只出新建的模板 id,便于脚本链式使用
88
+ console.log(row.id)
89
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * CLI 身份欢迎(2026-08-15)——login(个人 token)和 config(租户 apiKey)共用。
3
+ *
4
+ * 原则:不管哪种凭证,配置完成后都识别「我是谁」并打招呼:
5
+ * - 个人 token → 账户身份(superadmin 山鬼映画 / 租户成员 / 平台用户)
6
+ * - 租户 apiKey → 租户身份(XX 租户)
7
+ * 然后给使用引导 + 联系我 + 绑定说明。
8
+ */
9
+ import type { MeInfo } from '../client.js'
10
+ import { StudioClient } from '../client.js'
11
+
12
+ export interface WelcomeCred {
13
+ token?: string
14
+ apiKey?: string
15
+ }
16
+
17
+ /** 终端显示宽度:ASCII=1,中文等宽字符=2(终端等宽字体下中文占 2 格) */
18
+ function displayWidth(s: string): number {
19
+ return [...s].reduce((w, ch) => w + (ch.charCodeAt(0) > 255 ? 2 : 1), 0)
20
+ }
21
+
22
+ function box(lines: string[]): string {
23
+ const width = Math.max(...lines.map(displayWidth)) + 4
24
+ const border = '┌' + '─'.repeat(width) + '┐'
25
+ const bottom = '└' + '─'.repeat(width) + '┘'
26
+ return [
27
+ border,
28
+ ...lines.map((l) => `│ ${l}${' '.repeat(width - displayWidth(l))} │`),
29
+ bottom,
30
+ ].join('\n')
31
+ }
32
+
33
+ /** 打印身份欢迎。识别失败时给兜底问候,不影响 CLI 继续使用。 */
34
+ export async function printWelcome(baseUrl: string, cred: WelcomeCred): Promise<void> {
35
+ let me: MeInfo | null = null
36
+ try {
37
+ me = await new StudioClient({ baseUrl, ...cred }).me()
38
+ } catch {
39
+ me = null
40
+ }
41
+
42
+ if (me?.identity === 'tenant') {
43
+ // ── 系统接入方(apiKey 模式)──
44
+ // 「租户」是内部技术称谓,对客户不暴露:客户视角是「我们的系统接入了
45
+ // MUSE AV AI 创作平台」,我们是服务提供方,不是把对方当租客。
46
+ const t = me.tenant
47
+ const tname = t?.nickname || t?.name || '贵方系统'
48
+ process.stderr.write(`👋 欢迎,${tname}!MUSE AV AI 创作平台已为你的系统接入创作能力\n`)
49
+ process.stderr.write(` 接入方:${tname}(系统级 API Key,为你的业务后台提供图片/视频创作)\n`)
50
+ } else if (me) {
51
+ // ── 个人身份 ──
52
+ const name = me.nickname || me.email
53
+ let identity = '平台用户'
54
+ let greeting = '欢迎使用 MUSE AV 创作中台'
55
+ if (me.role === 'superadmin') {
56
+ identity = '平台超级管理员'
57
+ greeting = '欢迎回来,山鬼映画!平台归你管,出了事找你本人 😄'
58
+ } else if (me.role === 'admin') {
59
+ identity = '平台管理员'
60
+ greeting = `欢迎回来,${name}!`
61
+ } else if (me.brand) {
62
+ // 接入方业务系统的成员(如好易美员工账户):
63
+ // 身份表达 = 我是「XX」的人,MUSE AV 为「XX」提供创作能力。
64
+ // 不用「租户/租户成员」这类内部词,客户不该有被出租的感觉。
65
+ const biz = me.brand.name
66
+ identity = `${biz}成员`
67
+ greeting = `欢迎,${name}!MUSE AV 为「${biz}」提供 AI 创作能力,你的创作工作台已就绪`
68
+ } else {
69
+ greeting = `欢迎,${name}!`
70
+ }
71
+ const quota = me.generation_remaining != null ? `${me.generation_remaining} 次` : '不限'
72
+ process.stderr.write(`👋 ${greeting}\n`)
73
+ process.stderr.write(` 账户:${me.email}(${identity})\n`)
74
+ if (me.gen_done != null) process.stderr.write(` 已出图 ${me.gen_done} 张 · 剩余额度 ${quota}\n`)
75
+ // 已绑定飞书:让客户知道缪斯 agent 在飞书里能认出他(未绑定不提示,绑定说明区有引导)
76
+ if (me.feishu_open_id) process.stderr.write(' 飞书:已绑定 ✓(缪斯 agent 在飞书群里能认出你)\n')
77
+ } else {
78
+ process.stderr.write('👋 欢迎使用 MUSE AV 创作中台\n')
79
+ }
80
+
81
+ process.stderr.write('\n' + box([
82
+ '📖 怎么用(常用命令)',
83
+ ' museav gen --prompt "英文提示词" 自由出图',
84
+ ' museav gen --skill <技能> --input "描述" 技能出图',
85
+ ' museav gen --template <id> --fields \'{..}\' 模板出图',
86
+ ' museav image-to-template <图> 一张图做成可复用模板',
87
+ ' museav skills / templates / reverse 查技能/模板/读图',
88
+ ' museav jobs / whoami 记录 / 身份',
89
+ ' museav bind-feishu 绑定飞书',
90
+ ]) + '\n')
91
+
92
+ process.stderr.write('\n🔗 找我 / 支持我:\n')
93
+ process.stderr.write(' · GitHub 给项目点个 ⭐ → https://github.com/webkubor/museav-cli\n')
94
+ process.stderr.write(' · 小红书「山鬼映画」(东方电影美学)→ https://www.xiaohongshu.com/user/profile/5c3c1581000000000501835d\n')
95
+
96
+ process.stderr.write('\n🎁 我的其他作品(GitHub 上给它们点个 ⭐ 就是最大的支持):\n')
97
+ process.stderr.write(' · typora-Bloom-theme(Typora 写作主题,★89)→ github.com/webkubor/typora-Bloom-theme\n')
98
+ process.stderr.write(' · voice-editor(本地中文 TTS 工作台)→ github.com/webkubor/voice-editor\n')
99
+ process.stderr.write(' · kyvault(本地加密密钥管理 CLI)→ github.com/webkubor/kyvault\n')
100
+ process.stderr.write(' · wechat-chat-gen(高仿真微信聊天截图生成器)→ github.com/webkubor/wechat-chat-gen\n')
101
+ process.stderr.write(' · knowledge-pdf-kit(Markdown → PDF/长图)→ github.com/webkubor/knowledge-pdf-kit\n')
102
+
103
+ process.stderr.write('\nℹ️ 绑定说明:\n')
104
+ process.stderr.write(' · 出图 / 技能 / 模板 / 逆向等全部创作功能【不需要】绑定飞书\n')
105
+ process.stderr.write(' · 绑定飞书(bind-feishu)只影响:让 agent 在飞书里认出你的身份\n')
106
+
107
+ process.stderr.write('\n现在就可以开始:museav gen --prompt "一只猫"\n')
108
+ }
@@ -0,0 +1,19 @@
1
+ /** museav whoami —— 查当前登录账户 + 租户归属 */
2
+ import type { StudioClient } from '../client.js'
3
+ import { loadConfig } from '../config.js'
4
+
5
+ export async function whoami(client: StudioClient): Promise<void> {
6
+ // apiKey 是系统接入方/服务身份,不是个人账户,/api/me 不适用——直接提示,别等服务端 401
7
+ const cfg = loadConfig()
8
+ if (cfg.apiKey && !cfg.token) {
9
+ throw new Error(
10
+ 'whoami 只支持个人 login 身份:museav login\n' +
11
+ '(apiKey 代表接入的业务系统,不是个人账户,可用 museav jobs / balance 查看业务数据)',
12
+ )
13
+ }
14
+ const me = await client.me()
15
+ process.stderr.write(`账户: ${me.nickname || me.email}(${me.email})\n`)
16
+ process.stderr.write(me.brand ? `业务系统: ${me.brand.name}\n` : '业务系统: 未接入(个人用户)\n')
17
+ process.stderr.write(`出图: 累计 ${me.gen_total} 次,成功 ${me.gen_done} 次\n`)
18
+ console.log(JSON.stringify(me))
19
+ }
package/src/config.ts ADDED
@@ -0,0 +1,114 @@
1
+ /**
2
+ * 配置管理 —— 环境变量 > 配置文件
3
+ *
4
+ * 两类凭证:
5
+ * - token:个人用户通过 `museav login` 设备授权拿到的 JWT(Bearer 鉴权)
6
+ * - apiKey:租户/B 端的 sk-studio-xxx(X-API-Key 鉴权)
7
+ * token 优先于 apiKey(个人用户场景为主)。
8
+ *
9
+ * 配置文件:~/.museav.json,存 { baseUrl, token, apiKey }
10
+ * 环境变量:MUSEAV_BASE_URL / MUSEAV_API_KEY(旧名 STUDIO_BASE_URL / STUDIO_API_KEY 仍有效,
11
+ * 中台文档与既有 CI 都在用,不能说停就停)
12
+ */
13
+ import { readFileSync, writeFileSync, existsSync, chmodSync } from 'node:fs'
14
+ import { homedir } from 'node:os'
15
+ import { join } from 'node:path'
16
+
17
+ const CONFIG_PATH = join(homedir(), '.museav.json')
18
+ /**
19
+ * 更名前遗留在用户机器上的配置文件,按新到旧排列,**只读**兼容。
20
+ *
21
+ * 这两个文件名是历史事实(用户硬盘上真实存在的路径),不是本项目还在用的叫法——
22
+ * 想读到它们就只能原样写出来。留着它们的理由不是念旧:apiKey 明文在中台只在创建那一次
23
+ * 返回,很多租户唯一的一份就躺在这些文件里,直接不读 = 逼人去找管理员重置密钥。
24
+ * 读到之后下一次 saveConfig 自然落到 ~/.museav.json,旧文件不动也不删。
25
+ */
26
+ const LEGACY_CONFIG_PATHS = [
27
+ join(homedir(), '.studio-cli.json'),
28
+ join(homedir(), '.studio-image.json'),
29
+ ]
30
+ /** 旧域名 webkubor.online 已弃用,API 统一走 manager.museav.top。 */
31
+ export const DEFAULT_BASE_URL = 'https://manager.museav.top'
32
+
33
+ export interface StudioConfig {
34
+ baseUrl: string
35
+ /** 个人用户 JWT(login 获得),优先用 */
36
+ token?: string
37
+ /** 租户 apikey(sk-studio-xxx),B 端场景 */
38
+ apiKey?: string
39
+ /**
40
+ * 租户自己后台的域名(如 https://manager.hympro.cn)。
41
+ * 只给 `products` / `assets` 两个命令用——那两个命令查的是租户自己的产品/素材数据,
42
+ * 数据物理上不在 Studio 中台,而在租户自己的数据库,所以要单独一个 base url。
43
+ * 已知租户(hym / mzmeso)不配也能跑(TenantClient 内置了默认值),
44
+ * 其他租户或本地联调时才需要显式配置。
45
+ */
46
+ tenantBaseUrl?: string
47
+ }
48
+
49
+ /**
50
+ * 读配置文件(不存在返回空对象)。
51
+ * 新路径缺失时按 LEGACY_CONFIG_PATHS 从新到旧回落,读到什么用什么——
52
+ * 下一次 saveConfig 会自然写到新路径,不主动搬文件、不删旧文件。
53
+ */
54
+ function readFileConfig(): Partial<StudioConfig> {
55
+ for (const path of [CONFIG_PATH, ...LEGACY_CONFIG_PATHS]) {
56
+ try {
57
+ if (!existsSync(path)) continue
58
+ return JSON.parse(readFileSync(path, 'utf-8'))
59
+ } catch {
60
+ // 坏掉的那份跳过,继续试下一个
61
+ }
62
+ }
63
+ return {}
64
+ }
65
+
66
+ /** 写配置文件 */
67
+ export function saveConfig(patch: Partial<StudioConfig>): StudioConfig {
68
+ const current = readFileConfig()
69
+ const next: StudioConfig = {
70
+ baseUrl: patch.baseUrl || current.baseUrl || DEFAULT_BASE_URL,
71
+ token: 'token' in patch ? patch.token : current.token,
72
+ apiKey: 'apiKey' in patch ? patch.apiKey : current.apiKey,
73
+ tenantBaseUrl: 'tenantBaseUrl' in patch ? patch.tenantBaseUrl : current.tenantBaseUrl,
74
+ }
75
+ writeFileSync(CONFIG_PATH, JSON.stringify(next, null, 2) + '\n', { mode: 0o600 })
76
+ // mode 只在文件新建时生效;已存在的旧配置文件(可能是更早版本用默认权限创建的)显式收紧一次
77
+ chmodSync(CONFIG_PATH, 0o600)
78
+ return next
79
+ }
80
+
81
+ /** 清除登录态(token),保留其他配置 */
82
+ export function clearToken(): StudioConfig {
83
+ return saveConfig({ token: '' })
84
+ }
85
+
86
+ /**
87
+ * 解析最终配置:环境变量 > 配置文件 > 默认值
88
+ * 优先级:MUSEAV_API_KEY / STUDIO_API_KEY 环境变量 > 文件里的 token > 文件里的 apiKey
89
+ * 缺任何凭证时抛错,提示 login 或 config
90
+ *
91
+ * 改名后新增 MUSEAV_* 两个环境变量,旧的 STUDIO_* 继续认:中台对外文档和已经跑起来的
92
+ * CI 里写的都是 STUDIO_API_KEY,改名不该让别人的流水线在毫无预警的情况下断掉。
93
+ * 两个都设时以 MUSEAV_* 为准(显式用了新名字就是明确意图)。
94
+ */
95
+ export function loadConfig(): StudioConfig {
96
+ const file = readFileConfig()
97
+ const baseUrl = process.env.MUSEAV_BASE_URL || process.env.STUDIO_BASE_URL || file.baseUrl || DEFAULT_BASE_URL
98
+ // tenantBaseUrl 只来自配置文件(没有对应的环境变量),跟 token/apiKey 的取舍无关,
99
+ // 统一透出去,用不用由调用方(目前只有 products/assets 两个命令)决定
100
+ const tenantBaseUrl = file.tenantBaseUrl
101
+
102
+ // 环境变量 apiKey 优先(CI/agent 场景)
103
+ const envApiKey = process.env.MUSEAV_API_KEY || process.env.STUDIO_API_KEY
104
+ if (envApiKey) return { baseUrl, apiKey: envApiKey, tenantBaseUrl }
105
+
106
+ // 文件里的 token(个人用户 login)优先于 apiKey
107
+ if (file.token) return { baseUrl, token: file.token, tenantBaseUrl }
108
+ if (file.apiKey) return { baseUrl, apiKey: file.apiKey, tenantBaseUrl }
109
+
110
+ throw new Error(
111
+ `未登录。请运行:museav login\n` +
112
+ `(或租户/B端配置:museav config --apiKey sk-studio-xxx)`
113
+ )
114
+ }