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.
- package/.github/workflows/ci.yml +19 -0
- package/.github/workflows/publish.yml +79 -0
- package/AGENTS.md +90 -0
- package/CHANGELOG.md +49 -0
- package/LICENSE +21 -0
- package/README.md +490 -0
- package/SECURITY.md +11 -0
- package/dist/client.d.ts +387 -0
- package/dist/client.js +372 -0
- package/dist/commands/assets.d.ts +14 -0
- package/dist/commands/assets.js +30 -0
- package/dist/commands/balance.d.ts +3 -0
- package/dist/commands/balance.js +11 -0
- package/dist/commands/bind-feishu.d.ts +3 -0
- package/dist/commands/bind-feishu.js +63 -0
- package/dist/commands/gen.d.ts +16 -0
- package/dist/commands/gen.js +88 -0
- package/dist/commands/image-to-template.d.ts +24 -0
- package/dist/commands/image-to-template.js +111 -0
- package/dist/commands/jobs.d.ts +11 -0
- package/dist/commands/jobs.js +27 -0
- package/dist/commands/login.d.ts +3 -0
- package/dist/commands/login.js +67 -0
- package/dist/commands/models.d.ts +3 -0
- package/dist/commands/models.js +9 -0
- package/dist/commands/products.d.ts +9 -0
- package/dist/commands/products.js +16 -0
- package/dist/commands/reverse.d.ts +3 -0
- package/dist/commands/reverse.js +16 -0
- package/dist/commands/skills.d.ts +5 -0
- package/dist/commands/skills.js +22 -0
- package/dist/commands/templates.d.ts +28 -0
- package/dist/commands/templates.js +79 -0
- package/dist/commands/upload.d.ts +9 -0
- package/dist/commands/upload.js +8 -0
- package/dist/commands/video-templates.d.ts +21 -0
- package/dist/commands/video-templates.js +71 -0
- package/dist/commands/welcome.d.ts +6 -0
- package/dist/commands/welcome.js +93 -0
- package/dist/commands/whoami.d.ts +3 -0
- package/dist/commands/whoami.js +14 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.js +93 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +292 -0
- package/dist/tenant-client.d.ts +31 -0
- package/dist/tenant-client.js +84 -0
- package/package.json +55 -0
- package/src/client.ts +636 -0
- package/src/commands/assets.ts +47 -0
- package/src/commands/balance.ts +12 -0
- package/src/commands/bind-feishu.ts +77 -0
- package/src/commands/gen.ts +111 -0
- package/src/commands/image-to-template.ts +150 -0
- package/src/commands/jobs.ts +37 -0
- package/src/commands/login.ts +80 -0
- package/src/commands/models.ts +12 -0
- package/src/commands/products.ts +38 -0
- package/src/commands/reverse.ts +21 -0
- package/src/commands/skills.ts +29 -0
- package/src/commands/templates.ts +98 -0
- package/src/commands/upload.ts +18 -0
- package/src/commands/video-templates.ts +89 -0
- package/src/commands/welcome.ts +108 -0
- package/src/commands/whoami.ts +19 -0
- package/src/config.ts +114 -0
- package/src/index.ts +318 -0
- package/src/tenant-client.ts +90 -0
- package/src/types/update-notifier.d.ts +31 -0
- package/tsconfig.json +19 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* museav —— MUSE AV 出图中台 CLI
|
|
4
|
+
*
|
|
5
|
+
* 用法见 README。注册中台:museav config --baseUrl ... --apiKey ...
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, writeFileSync, chmodSync } from 'node:fs'
|
|
8
|
+
import { Command } from 'commander'
|
|
9
|
+
import updateNotifier from 'update-notifier'
|
|
10
|
+
import { StudioClient } from './client.js'
|
|
11
|
+
import { TenantClient } from './tenant-client.js'
|
|
12
|
+
import { loadConfig, saveConfig, clearToken } from './config.js'
|
|
13
|
+
import { login } from './commands/login.js'
|
|
14
|
+
import { bindFeishu } from './commands/bind-feishu.js'
|
|
15
|
+
import { printWelcome } from './commands/welcome.js'
|
|
16
|
+
import { gen } from './commands/gen.js'
|
|
17
|
+
import { reverse } from './commands/reverse.js'
|
|
18
|
+
import { imageToTemplate } from './commands/image-to-template.js'
|
|
19
|
+
import { upload } from './commands/upload.js'
|
|
20
|
+
import { models } from './commands/models.js'
|
|
21
|
+
import { skills } from './commands/skills.js'
|
|
22
|
+
import { templates, createTemplate } from './commands/templates.js'
|
|
23
|
+
import { videoTemplates, createVideoTemplate } from './commands/video-templates.js'
|
|
24
|
+
import { balance } from './commands/balance.js'
|
|
25
|
+
import { jobs } from './commands/jobs.js'
|
|
26
|
+
import { whoami } from './commands/whoami.js'
|
|
27
|
+
import { products } from './commands/products.js'
|
|
28
|
+
import { assets } from './commands/assets.js'
|
|
29
|
+
|
|
30
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')) as { name: string; version: string }
|
|
31
|
+
// 每 12 小时最多查一次 npm registry,过期才提示,不拖慢日常调用
|
|
32
|
+
updateNotifier({ pkg, updateCheckInterval: 1000 * 60 * 60 * 12 }).notify({ defer: false })
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 首次运行欢迎语:只在第一次执行时提示一次(用环境变量 XHS_ONCE 防止在同一会话里重复输出),
|
|
36
|
+
* 之后靠 ~/.museav.json 里的 welcome_seen 标记跳过。不打断正常输出(写 stderr)。
|
|
37
|
+
*/
|
|
38
|
+
function maybeWelcome(): void {
|
|
39
|
+
try {
|
|
40
|
+
if (process.env.MUSEAV_NO_WELCOME === '1' || process.env.STUDIO_NO_WELCOME === '1') return
|
|
41
|
+
const file = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8')) as { version: string }
|
|
42
|
+
const CONFIG_PATH = process.env.HOME + '/.museav.json'
|
|
43
|
+
let cfg: Record<string, unknown> = {}
|
|
44
|
+
try { cfg = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')) } catch { /* 首次运行还没有配置 */ }
|
|
45
|
+
if (cfg.welcome_seen) return
|
|
46
|
+
process.stderr.write(
|
|
47
|
+
`\n🎨 感谢使用 museav v${file.version}(MUSE AV 出图中台)\n` +
|
|
48
|
+
` 有需求 / 反馈 / 合作?小红书关注「山鬼映画」找我:东方电影美学 · 把不存在的武侠电影做成江湖\n` +
|
|
49
|
+
` → https://www.xiaohongshu.com/user/profile/5c3c1581000000000501835d\n\n`,
|
|
50
|
+
)
|
|
51
|
+
// 标记已看过(写入失败也无所谓,最多下次再提示一次)
|
|
52
|
+
try {
|
|
53
|
+
cfg.welcome_seen = true
|
|
54
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 })
|
|
55
|
+
chmodSync(CONFIG_PATH, 0o600)
|
|
56
|
+
} catch { /* ignore */ }
|
|
57
|
+
} catch { /* 欢迎语失败绝不阻塞任何命令 */ }
|
|
58
|
+
}
|
|
59
|
+
maybeWelcome()
|
|
60
|
+
|
|
61
|
+
const program = new Command()
|
|
62
|
+
|
|
63
|
+
program
|
|
64
|
+
.name('museav')
|
|
65
|
+
.description('MUSE AV 出图中台 CLI —— login 登录后即可命令行出图、出视频、读图逆向、图生模板')
|
|
66
|
+
.version(pkg.version)
|
|
67
|
+
// templates 有子命令 create,且父子都用了 --category(一个是列表过滤,一个是新建模板的分类)。
|
|
68
|
+
// commander 默认不区分参数出现在父命令还是子命令段,会把 --category 的值吞给父命令、
|
|
69
|
+
// 子命令读到 undefined(已用 templates create 实测复现)。开这个选项后,参数按位置归属
|
|
70
|
+
// 所在的命令段解析,同名 flag 在父子命令里就不会互相打架。
|
|
71
|
+
.enablePositionalOptions()
|
|
72
|
+
|
|
73
|
+
// 工厂:加载配置 + 构造 client,把 commander 透传的参数转给命令
|
|
74
|
+
function withClient<T extends (...args: any[]) => Promise<any>>(fn: T) {
|
|
75
|
+
return async (...args: any[]) => {
|
|
76
|
+
try {
|
|
77
|
+
const cfg = loadConfig()
|
|
78
|
+
const client = new StudioClient(cfg)
|
|
79
|
+
// commander action 参数固定形如 (...positionalArgs, options, command)。
|
|
80
|
+
// 之前直接 args.slice(0, -2) 想留下 positionalArgs、丢掉 options+command,
|
|
81
|
+
// 但纯 --flag、没有 <positional> 的命令(如 gen)总共只有 2 个参数
|
|
82
|
+
// (options, command),slice(0, -2) 会把 options 也一起丢掉——gen(client, opts)
|
|
83
|
+
// 里的 opts 变成 undefined,读 opts.prompt 直接崩。这里显式取出 options,
|
|
84
|
+
// 拼在 positional 之后转发,对没声明 options 的命令(reverse/upload/models
|
|
85
|
+
// 等)只是多传一个不会被用到的参数,无影响。
|
|
86
|
+
const opts = args[args.length - 2]
|
|
87
|
+
const positional = args.slice(0, -2)
|
|
88
|
+
await fn(client, ...positional, opts)
|
|
89
|
+
} catch (e) {
|
|
90
|
+
process.stderr.write(`❌ ${(e as Error).message}\n`)
|
|
91
|
+
process.exit(1)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// products / assets 查的是租户自己后台的数据,不是 Studio 中台的,走独立的 TenantClient
|
|
97
|
+
// (见 tenant-client.ts 顶部注释),只支持租户 apiKey 身份,不支持个人 login token。
|
|
98
|
+
function withTenantClient<T extends (...args: any[]) => Promise<any>>(fn: T) {
|
|
99
|
+
return async (...args: any[]) => {
|
|
100
|
+
try {
|
|
101
|
+
const cfg = loadConfig()
|
|
102
|
+
if (!cfg.apiKey) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
'products / assets 只支持租户 API Key 身份:museav config --apiKey sk-studio-xxx\n' +
|
|
105
|
+
'(业务中台服务 key,统一形态 sk-studio-<24位>;个人 login 拿到的是个人 token,查不了组织级的产品/素材数据)',
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
const client = new TenantClient({ apiKey: cfg.apiKey, tenantBaseUrl: cfg.tenantBaseUrl })
|
|
109
|
+
const opts = args[args.length - 2]
|
|
110
|
+
const positional = args.slice(0, -2)
|
|
111
|
+
await fn(client, ...positional, opts)
|
|
112
|
+
} catch (e) {
|
|
113
|
+
process.stderr.write(`❌ ${(e as Error).message}\n`)
|
|
114
|
+
process.exit(1)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
program
|
|
120
|
+
.command('gen')
|
|
121
|
+
.description('出图 / 出视频(提交 + 自动轮询,成功输出 URL)')
|
|
122
|
+
.option('-p, --prompt <text>', '完整出图提示词(与 --skill / --template 三选一)')
|
|
123
|
+
.option('-s, --skill <slug>', '用中台技能出图,提示词在服务端展开(与 --prompt / --template 三选一);清单见 museav skills')
|
|
124
|
+
.option('-i, --input <text>', '配合 --skill 的一句业务描述,如「米白色针织衫」;不给则按技能规范自由发挥')
|
|
125
|
+
.option('-t, --template <id>', '用图片模板出图,提示词在服务端展开(与 --prompt / --skill 三选一);清单见 museav templates')
|
|
126
|
+
.option('--fields <json>', '配合 --template 的占位符取值,JSON 对象,如 \'{"artist":"王嘉尔"}\';模板没有占位符则不用传')
|
|
127
|
+
// 不设默认值:--skill / --template 场景下要让技能/模板自己的比例生效,
|
|
128
|
+
// CLI 强填默认值会把它们覆盖掉(服务端在纯 --prompt 场景已有 3:4 兜底,这里不用重复兜底)
|
|
129
|
+
.option('-r, --ratio <ratio>', '宽高比: 3:4 / 9:16 / 1:1 / 4:3 / 16:9(不指定则用技能/模板自己的比例,纯 prompt 模式兜底 3:4)')
|
|
130
|
+
.option('-m, --model <name>', '指定模型,如 gpt-image-2 / seedance-2-fast / artsdance-2-0-pro-260801')
|
|
131
|
+
.option('-q, --quality <level>', '质量: low / medium / high(仅 gpt-image)')
|
|
132
|
+
.option('--ref <file>', '垫图文件路径(图片图生图,自动上传)')
|
|
133
|
+
.option('--video', '生成视频(走 /api/videos 链路,模型如 seedance-2-fast / artsdance-2-0-pro)')
|
|
134
|
+
.option('--duration <sec>', '视频时长(秒,仅 --video;由模型与上游支持范围决定)', (v) => Number(v))
|
|
135
|
+
.option('--image <file>', '图生视频首帧图(仅 --video,自动上传)')
|
|
136
|
+
.action(withClient((client: StudioClient, opts: any) => gen(client, opts)))
|
|
137
|
+
|
|
138
|
+
program
|
|
139
|
+
.command('reverse <input>')
|
|
140
|
+
.description('读图:上传图或图片 URL,反推 SCULPT prompt,stdout 输出英文 prompt(只读图;要做成模板用 image-to-template)')
|
|
141
|
+
.action(withClient((client: StudioClient, input: string) => reverse(client, input)))
|
|
142
|
+
|
|
143
|
+
program
|
|
144
|
+
.command('image-to-template <input>')
|
|
145
|
+
.description('图生模板:上传图或图片 URL → 读图 + 文字层逆向 + 变量化 → 建成可复用的图片模板(原图自动焊成参考图)')
|
|
146
|
+
.option('--no-create', '只看模板草稿,不真的建模板(同步返回,不消耗模板库)')
|
|
147
|
+
.option('--name <zh_name>', '模板中文名,不给则由中台生成')
|
|
148
|
+
.option('--slug <slug>', '模板 slug(全局唯一,撞了直接报错不覆盖),不给则由中台生成')
|
|
149
|
+
.option('--category <name>', '模板分类,不给则按图片内容自动归类')
|
|
150
|
+
.option('--variables <keys>', '收窄变量白名单,逗号分隔,如 title,subject,location(不传则中台按全集自行判断)')
|
|
151
|
+
.option('--labels <json>', '变量 → 你的业务叫法,JSON 对象,如 \'{"subject":"艺人","location":"城市"}\';只影响表单显示名')
|
|
152
|
+
.option('--async', '强制异步(建模板本来就是异步,这个只对 --no-create 有意义)')
|
|
153
|
+
.action(withClient((client: StudioClient, input: string, opts: any) => imageToTemplate(client, input, opts)))
|
|
154
|
+
|
|
155
|
+
program
|
|
156
|
+
.command('upload <file>')
|
|
157
|
+
.description('上传素材(图片/音频/视频,按字节内容判类型;图片 8MB / 音频 20MB / 视频 50MB),stdout 输出公网直链')
|
|
158
|
+
.action(withClient((client: StudioClient, file: string) => upload(client, file)))
|
|
159
|
+
|
|
160
|
+
program
|
|
161
|
+
.command('models')
|
|
162
|
+
.description('查可用模型列表')
|
|
163
|
+
.action(withClient((client: StudioClient) => models(client)))
|
|
164
|
+
|
|
165
|
+
program
|
|
166
|
+
.command('skills')
|
|
167
|
+
.description('查可用技能:自己的私有技能 + 所属租户专属模板 + 公共技能库')
|
|
168
|
+
.option('--genre <name>', '按分类过滤,如 电商 / 人像写真')
|
|
169
|
+
.action(withClient((client: StudioClient, opts: any) => skills(client, opts)))
|
|
170
|
+
|
|
171
|
+
const templatesCmd = program
|
|
172
|
+
.command('templates')
|
|
173
|
+
.description('查可用图片/文字模板:自己租户建的 + 平台共享的(跟技能是两套不同的机制,见 gen --template)')
|
|
174
|
+
.option('--category <name>', '按分类过滤,如 电商白底图 / 演唱会')
|
|
175
|
+
.option('--type <type>', '按类型过滤:image(图片) / article(文字),不传则两类都列并标注')
|
|
176
|
+
.action(withClient((client: StudioClient, opts: any) => templates(client, opts)))
|
|
177
|
+
|
|
178
|
+
const videoTemplatesCmd = program
|
|
179
|
+
.command('video-templates')
|
|
180
|
+
.description('查可用视频模板:配合 gen --video --template 使用(视频模板与图片模板是两套表)')
|
|
181
|
+
.option('--category <name>', '按分类过滤,如 电商 / 换装视频')
|
|
182
|
+
.action(withClient((client: StudioClient, opts: any) => videoTemplates(client, opts)))
|
|
183
|
+
|
|
184
|
+
videoTemplatesCmd
|
|
185
|
+
.command('create')
|
|
186
|
+
.description(
|
|
187
|
+
'新建视频模板——归属由账号身份自动决定:租户 apiKey 建的自动归该租户,平台管理员建的是平台共享模板,个人账号不能建',
|
|
188
|
+
)
|
|
189
|
+
.requiredOption('--name <zh_name>', '模板中文名')
|
|
190
|
+
.requiredOption('--prompt <template>', '提示词模板,占位符用 {key} 形式,如 "{product} 在 {scene} 中展示"')
|
|
191
|
+
.option('--category <name>', '分类,默认「其他」')
|
|
192
|
+
.option('--description <text>', '模板说明')
|
|
193
|
+
.option('--model <name>', '视频模型,默认 seedance-2(可选 seedance-2-fast / seedance-2-mini / artsdance-2-0-pro-260801)')
|
|
194
|
+
.option('--duration <sec>', '视频时长(秒,4-15,可选)')
|
|
195
|
+
.option('--ratio <ratio>', '画面比例: 9:16 / 16:9 / 1:1 / 3:4(可选)')
|
|
196
|
+
.option('--sample-video <url>', '参考视频 URL(可选,展示给用户的示例片)')
|
|
197
|
+
.option('--sample-cover <url>', '封面图 URL(可选)')
|
|
198
|
+
.action(withClient((client: StudioClient, opts: any) => createVideoTemplate(client, opts)))
|
|
199
|
+
|
|
200
|
+
templatesCmd
|
|
201
|
+
.command('create')
|
|
202
|
+
.description(
|
|
203
|
+
'新建图片/文字模板——归属由账号身份自动决定:租户 apiKey 建的自动归该租户(其他租户看不到),' +
|
|
204
|
+
'平台管理员账号建的是平台共享模板(所有租户可见),个人账号不能建',
|
|
205
|
+
)
|
|
206
|
+
.requiredOption('--name <zh_name>', '模板中文名')
|
|
207
|
+
.requiredOption('--prompt <template>', '提示词模板,占位符用 {key} 形式,如 "{artist} 在 {city} 的演唱会海报"')
|
|
208
|
+
.option('--category <name>', '分类,默认「其他」')
|
|
209
|
+
.option('--ratio <ratio>', '宽高比,默认 3:4')
|
|
210
|
+
.option('--description <text>', '模板说明')
|
|
211
|
+
.option('--model <name>', '生成模型,默认 gpt-image-2')
|
|
212
|
+
.option('--quality <level>', '质量: low / medium / high')
|
|
213
|
+
.option('--fields <json>', '占位符字段说明,JSON 数组,如 \'[{"key":"artist","label":"艺人名"}]\';不传则自动从 --prompt 里的 {key} 提取')
|
|
214
|
+
.option('--type <type>', '模板类型:image(图片,默认) / article(文字)')
|
|
215
|
+
.action(withClient((client: StudioClient, opts: any) => createTemplate(client, opts)))
|
|
216
|
+
|
|
217
|
+
program
|
|
218
|
+
.command('products')
|
|
219
|
+
.description('查所属租户自己的产品目录(数据在租户自己的后台,不在 Studio 中台;只支持已开通该接口的租户,仅租户 apiKey 身份可用)')
|
|
220
|
+
.action(withTenantClient((client: TenantClient) => products(client)))
|
|
221
|
+
|
|
222
|
+
program
|
|
223
|
+
.command('assets')
|
|
224
|
+
.description('查所属租户自己的素材/资产库(数据在租户自己的后台,不在 Studio 中台;只支持已开通该接口的租户,仅租户 apiKey 身份可用)')
|
|
225
|
+
.action(withTenantClient((client: TenantClient) => assets(client)))
|
|
226
|
+
|
|
227
|
+
program
|
|
228
|
+
.command('balance')
|
|
229
|
+
.description('查上游余额')
|
|
230
|
+
.action(withClient((client: StudioClient) => balance(client)))
|
|
231
|
+
|
|
232
|
+
program
|
|
233
|
+
.command('jobs')
|
|
234
|
+
.description('查自己名下的出图工作流(个人 login 看自己的;租户 apiKey 看业务下全部)——服务端固定返回最近 50 条,limit/status 是本地过滤')
|
|
235
|
+
.option('--limit <n>', '最多显示几条(在最近 50 条以内截取),默认 20', '20')
|
|
236
|
+
.option('--status <status>', '按状态过滤: pending / processing / done / failed(本地过滤,不是服务端查询)')
|
|
237
|
+
.action(withClient((client: StudioClient, opts: any) => jobs(client, opts)))
|
|
238
|
+
|
|
239
|
+
program
|
|
240
|
+
.command('whoami')
|
|
241
|
+
.description('查当前登录账户 + 租户归属(仅个人 login 可用,apiKey 调用会报错)')
|
|
242
|
+
.action(withClient((client: StudioClient) => whoami(client)))
|
|
243
|
+
|
|
244
|
+
program
|
|
245
|
+
.command('config')
|
|
246
|
+
.description('配置中台地址和 apiKey(存到 ~/.museav.json)')
|
|
247
|
+
.option('--baseUrl <url>', '中台地址,默认 https://manager.museav.top')
|
|
248
|
+
.option('--apiKey <key>', 'apiKey(sk-studio-xxx)')
|
|
249
|
+
.option('--tenantBaseUrl <url>', '租户自己后台的域名,供 products/assets 命令用;已知租户(hym/mzmeso)不配也能跑')
|
|
250
|
+
.action(async (opts) => {
|
|
251
|
+
if (!opts.baseUrl && !opts.apiKey && !opts.tenantBaseUrl) {
|
|
252
|
+
try {
|
|
253
|
+
const cfg = loadConfig()
|
|
254
|
+
process.stderr.write(
|
|
255
|
+
`当前配置 (~/.museav.json):\n` +
|
|
256
|
+
` baseUrl: ${cfg.baseUrl}\n` +
|
|
257
|
+
` apiKey: ${cfg.apiKey ? cfg.apiKey.slice(0, 16) + '...' : '(未设置)'}\n` +
|
|
258
|
+
` tenantBaseUrl: ${cfg.tenantBaseUrl || '(未设置,products/assets 对已知租户用内置默认值)'}\n`,
|
|
259
|
+
|
|
260
|
+
)
|
|
261
|
+
} catch (e) {
|
|
262
|
+
process.stderr.write(`${(e as Error).message}\n`)
|
|
263
|
+
}
|
|
264
|
+
process.stderr.write(`\n修改: museav config --apiKey sk-studio-xxx\n`)
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
const cfg = saveConfig(opts)
|
|
268
|
+
process.stderr.write(
|
|
269
|
+
`✅ 已保存到 ~/.museav.json\n` +
|
|
270
|
+
` baseUrl: ${cfg.baseUrl}\n` +
|
|
271
|
+
` apiKey: ${cfg.apiKey ? cfg.apiKey.slice(0, 16) + '...' : '(未设置)'}\n` +
|
|
272
|
+
` tenantBaseUrl: ${cfg.tenantBaseUrl || '(未设置)'}\n`,
|
|
273
|
+
)
|
|
274
|
+
// 配置了 apiKey 就识别身份打招呼(租户欢迎;个人 token 场景欢迎在 login 后打)
|
|
275
|
+
if (opts.apiKey) {
|
|
276
|
+
try {
|
|
277
|
+
await printWelcome(cfg.baseUrl, { apiKey: opts.apiKey })
|
|
278
|
+
} catch (e) {
|
|
279
|
+
process.stderr.write(`⚠ 无法识别该 apiKey 对应的账户:${(e as Error).message}\n`)
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
program
|
|
285
|
+
.command('login')
|
|
286
|
+
.description('登录(设备授权:终端显示验证码,浏览器打开授权后 CLI 自动完成登录)')
|
|
287
|
+
.option('--baseUrl <url>', '中台地址,默认 https://manager.museav.top')
|
|
288
|
+
.action(async (opts) => {
|
|
289
|
+
try {
|
|
290
|
+
await login(opts)
|
|
291
|
+
} catch (e) {
|
|
292
|
+
process.stderr.write(`❌ ${(e as Error).message}\n`)
|
|
293
|
+
process.exit(1)
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
program
|
|
298
|
+
.command('bind-feishu')
|
|
299
|
+
.description('绑定飞书(设备授权:终端显示验证码,浏览器授权后完成绑定;一个飞书可绑多个平台账户)')
|
|
300
|
+
.option('--baseUrl <url>', '中台地址,默认 https://manager.museav.top')
|
|
301
|
+
.action(async (opts) => {
|
|
302
|
+
try {
|
|
303
|
+
await bindFeishu(opts)
|
|
304
|
+
} catch (e) {
|
|
305
|
+
process.stderr.write(`❌ ${(e as Error).message}\n`)
|
|
306
|
+
process.exit(1)
|
|
307
|
+
}
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
program
|
|
311
|
+
.command('logout')
|
|
312
|
+
.description('退出登录(清除本地 token)')
|
|
313
|
+
.action(() => {
|
|
314
|
+
clearToken()
|
|
315
|
+
process.stderr.write('✅ 已退出登录\n')
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
program.parse()
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TenantClient —— 调「租户自己后台」的只读产品/素材接口,注意不是 Studio 中台。
|
|
3
|
+
*
|
|
4
|
+
* 背景(2026-08-10 架构决策):租户自己的"产品""素材/资产"数据物理上存在各租户自己
|
|
5
|
+
* 的数据库里(好易美是 hym-admin 的 Supabase,mzmeso 是 manager 的 Supabase),
|
|
6
|
+
* Studio 中台不代理这部分数据——跨库统一存取需要 Studio 保管每个租户数据库的连接
|
|
7
|
+
* 信息,风险和复杂度远超"选产品参考图 + 模板 组合出图"这一个使用场景本身的收益,
|
|
8
|
+
* 这次没有做。维持现状:数据在哪个租户后台,只读接口就长在那个后台上,
|
|
9
|
+
* museav 直接调租户自己的域名,不经过 Studio。
|
|
10
|
+
*
|
|
11
|
+
* 鉴权复用同一把 apiKey:租户后台反过来调用 Studio(出图/查模板)时,本来就要在自己
|
|
12
|
+
* 环境变量里存这把 sk-studio-xxx(业务中台服务 key),所以它可以原样再拿来当"调用自己只读接口"
|
|
13
|
+
* 的凭证用,不用为这一件事再签发一套新 key。这把 key 已经能触发出图消费(更高价值的
|
|
14
|
+
* 操作),只读产品/素材不构成新的越权面。
|
|
15
|
+
*
|
|
16
|
+
* 已知局限:只支持"租户 apiKey"身份,不支持个人登录(login token)—— 产品/素材
|
|
17
|
+
* 是组织级数据,跟个人账号无关。
|
|
18
|
+
*
|
|
19
|
+
* 后台域名怎么定(2026-08-14 统一形态后):key 不再携带租户名段(统一 sk-studio-<24hex>),
|
|
20
|
+
* 新格式 key 无法从 key 解析租户名 → 必须显式配置 --tenantBaseUrl;旧格式 key
|
|
21
|
+
* (sk-studio-<租户名>-<24hex>)仍能解析租户名,走内置 KNOWN_TENANT_BACKENDS 映射。
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** 已知租户名 → 该租户自己后台的域名。仅 hym / mzmeso 两个已接入的租户,其他租户
|
|
25
|
+
* 需要用 `museav config --tenantBaseUrl <后台域名>` 显式配置。
|
|
26
|
+
* 这份映射维护在 CLI 本地是权宜之计——见任务报告里的"开放问题":
|
|
27
|
+
* 长期是否应该让 Studio 的 api_tenants 表补一个 portal_base_url 字段,
|
|
28
|
+
* 由服务端下发而不是 CLI 硬编码,需要人确认后再决定。 */
|
|
29
|
+
const KNOWN_TENANT_BACKENDS: Record<string, string> = {
|
|
30
|
+
hym: 'https://manager.hympro.cn',
|
|
31
|
+
mzmeso: 'https://mzmeso.webkubor.online',
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 从旧格式 key(sk-studio-<租户名>-<24位十六进制>)里解析出租户名;新统一形态 sk-studio-<24hex> 解析不出(返回 null,需显式 tenantBaseUrl) */
|
|
35
|
+
function parseTenantName(apiKey: string): string | null {
|
|
36
|
+
const m = apiKey.match(/^sk-studio-(.+)-[0-9a-f]{24}$/i)
|
|
37
|
+
return m ? m[1] : null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class TenantClient {
|
|
41
|
+
private baseUrl: string
|
|
42
|
+
private apiKey: string
|
|
43
|
+
|
|
44
|
+
constructor(opts: { apiKey: string; tenantBaseUrl?: string }) {
|
|
45
|
+
if (!opts.apiKey.startsWith('sk-studio-')) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
'products / assets 需要配置「租户 API Key」(业务中台服务 key,sk-studio-<24位>)。\n' +
|
|
48
|
+
'个人登录(login)是个人 token,查不了组织级的产品/素材数据。',
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
this.apiKey = opts.apiKey
|
|
52
|
+
const tenant = parseTenantName(opts.apiKey)
|
|
53
|
+
const known = tenant ? KNOWN_TENANT_BACKENDS[tenant] : undefined
|
|
54
|
+
const resolved = opts.tenantBaseUrl || known
|
|
55
|
+
if (!resolved) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`无法确定「${tenant || '该'}」租户自己后台的域名。\n` +
|
|
58
|
+
`(统一形态 key sk-studio-<24位> 不再携带租户名,需显式配置后台域名)\n` +
|
|
59
|
+
`请先用 museav config --tenantBaseUrl <你的租户后台域名> 显式配置一次\n` +
|
|
60
|
+
`(目前内置已知租户:${Object.keys(KNOWN_TENANT_BACKENDS).join(', ')})。`,
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
this.baseUrl = resolved.replace(/\/+$/, '')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async get<T = unknown>(path: string): Promise<T> {
|
|
67
|
+
const resp = await fetch(`${this.baseUrl}/api/${path}`, {
|
|
68
|
+
headers: {
|
|
69
|
+
'X-API-Key': this.apiKey,
|
|
70
|
+
// 自报身份:新头是长期形态,旧头 X-Studio-Client 过渡期一起发(见 client.ts 里
|
|
71
|
+
// CLIENT_HEADERS 的说明)。这条打的是租户自己的后台,不是 MUSE AV 中台——
|
|
72
|
+
// 目前已知租户后台都没读这个头,两个头一起发只是口径统一,不产生耦合。
|
|
73
|
+
'X-Museav-Client': 'museav-cli-tenant',
|
|
74
|
+
'X-Studio-Client': 'museav-cli-tenant',
|
|
75
|
+
'User-Agent': 'museav-cli-tenant',
|
|
76
|
+
},
|
|
77
|
+
})
|
|
78
|
+
const text = await resp.text()
|
|
79
|
+
let body: any
|
|
80
|
+
try { body = JSON.parse(text) } catch { body = { raw: text } }
|
|
81
|
+
if (!resp.ok) {
|
|
82
|
+
const msg = body?.error || body?.raw || `HTTP ${resp.status}`
|
|
83
|
+
throw new Error(
|
|
84
|
+
`租户后台 API /api/${path} 失败: ${msg}\n` +
|
|
85
|
+
`(该租户后台可能尚未开通这个只读端点,或域名配置不对:${this.baseUrl})`,
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
return body as T
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
declare module 'update-notifier' {
|
|
2
|
+
interface Package {
|
|
3
|
+
name: string
|
|
4
|
+
version: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface NotifyOptions {
|
|
8
|
+
defer?: boolean
|
|
9
|
+
isGlobal?: boolean
|
|
10
|
+
message?: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface UpdateInfo {
|
|
14
|
+
current: string
|
|
15
|
+
latest: string
|
|
16
|
+
type: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface UpdateNotifier {
|
|
20
|
+
update?: UpdateInfo
|
|
21
|
+
notify(options?: NotifyOptions): void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Options {
|
|
25
|
+
pkg: Package
|
|
26
|
+
updateCheckInterval?: number
|
|
27
|
+
shouldNotifyInNpmScript?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default function updateNotifier(options: Options): UpdateNotifier
|
|
31
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ES2022",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"rootDir": "src",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"skipLibCheck": true,
|
|
11
|
+
"forceConsistentCasingInFileNames": true,
|
|
12
|
+
"declaration": true,
|
|
13
|
+
"resolveJsonModule": true,
|
|
14
|
+
"lib": ["ES2022", "DOM"],
|
|
15
|
+
"types": ["node"]
|
|
16
|
+
},
|
|
17
|
+
"include": ["src"],
|
|
18
|
+
"exclude": ["node_modules", "dist"]
|
|
19
|
+
}
|