svmc-cli 1.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/README.md +89 -0
- package/bin/svmc.js +9 -0
- package/dist/src/commands/auth.js +61 -0
- package/dist/src/commands/image.js +77 -0
- package/dist/src/core/apiClient.js +108 -0
- package/dist/src/core/config.js +98 -0
- package/dist/src/core/downloader.js +36 -0
- package/dist/src/core/errors.js +21 -0
- package/dist/src/core/parser.js +126 -0
- package/dist/src/core/poller.js +34 -0
- package/dist/src/index.js +97 -0
- package/dist/src/types/index.js +2 -0
- package/dist/tests/apiClient.test.js +35 -0
- package/dist/tests/config.test.js +36 -0
- package/dist/tests/parser.test.js +30 -0
- package/dist/tests/poller.test.js +14 -0
- package/dist/vitest.config.js +9 -0
- package/eslint.config.js +27 -0
- package/package.json +43 -0
- package/src/commands/auth.ts +88 -0
- package/src/commands/image.ts +104 -0
- package/src/core/apiClient.ts +119 -0
- package/src/core/config.ts +101 -0
- package/src/core/downloader.ts +42 -0
- package/src/core/errors.ts +19 -0
- package/src/core/parser.ts +147 -0
- package/src/core/poller.ts +46 -0
- package/src/index.ts +108 -0
- package/src/types/index.ts +47 -0
- package/tests/apiClient.test.ts +53 -0
- package/tests/config.test.ts +50 -0
- package/tests/parser.test.ts +30 -0
- package/tests/poller.test.ts +16 -0
- package/tsconfig.json +15 -0
- package/vitest.config.ts +8 -0
package/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# svmc-cli
|
|
2
|
+
|
|
3
|
+
将 `media-center-frontend` 的图生图能力封装为可供 Agent 调用的命令行工具。
|
|
4
|
+
|
|
5
|
+
## 安装与构建
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run build
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
本地调试:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm run dev -- --help
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 鉴权
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
svmc auth
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
该命令会引导输入 `SVMC_ACCESS_TOKEN` 并写入 `~/.svmc/config.toml`。
|
|
25
|
+
服务地址默认固定使用生产环境 `https://media.sailvan.com/api/v1`(`auth` 不再询问 base_url)。
|
|
26
|
+
如果接口需要会话态,可在 `auth` 时一并保存 Cookie(也可后续通过 `--cookie` 或 `SVMC_COOKIE` 传入)。
|
|
27
|
+
|
|
28
|
+
验证认证状态:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
svmc auth status
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 获取参数规格
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
svmc image specs --provider nano-banana
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
需要显式传 Cookie 时:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
svmc image specs --provider nano-banana --cookie "session=...; sl-session=..."
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
仅列出 provider 列表:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
svmc image specs
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 发起图生图任务
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
svmc image generate \
|
|
56
|
+
--provider nano-banana \
|
|
57
|
+
--input "img1.jpg" \
|
|
58
|
+
--input "img2.png" \
|
|
59
|
+
--prompt "cyberpunk style" \
|
|
60
|
+
--ratio "16:9"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
支持传递动态参数(会原样透传给后端):
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
svmc image generate ... --steps 20 --guidance_scale 7 --cookie "session=...; sl-session=..."
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
任务轮询间隔固定 5 秒,完成后输出:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
IMAGE_SAVED: /abs/path/to/file.jpg
|
|
73
|
+
MEDIA:/abs/path/to/file.jpg
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## 端到端验收脚本
|
|
77
|
+
|
|
78
|
+
1. `svmc auth`,写入 token。
|
|
79
|
+
2. `svmc image specs --provider <provider>`,确认返回规格。
|
|
80
|
+
3. `svmc image generate --provider <provider> --input <img> --prompt "..."`。
|
|
81
|
+
4. 观察输出包含 `IMAGE_SAVED` 和 `MEDIA`,并确认本地文件存在。
|
|
82
|
+
|
|
83
|
+
## 质量命令
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
npm run lint:eslint
|
|
87
|
+
npm run type-check
|
|
88
|
+
npm test
|
|
89
|
+
```
|
package/bin/svmc.js
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runAuthCommand = runAuthCommand;
|
|
4
|
+
exports.runAuthStatusCommand = runAuthStatusCommand;
|
|
5
|
+
const prompts_1 = require("@inquirer/prompts");
|
|
6
|
+
const apiClient_1 = require("../core/apiClient");
|
|
7
|
+
const config_1 = require("../core/config");
|
|
8
|
+
const errors_1 = require("../core/errors");
|
|
9
|
+
async function runAuthCommand(options) {
|
|
10
|
+
const resolvedConfigPath = (0, config_1.getConfigPath)(options.configPath);
|
|
11
|
+
const currentConfig = await (0, config_1.loadConfig)(resolvedConfigPath);
|
|
12
|
+
const hasExisting = Boolean(currentConfig.auth.access_token?.trim());
|
|
13
|
+
if (hasExisting) {
|
|
14
|
+
const shouldOverwrite = await (0, prompts_1.confirm)({
|
|
15
|
+
message: `检测到已有 token,是否覆盖?(${resolvedConfigPath})`,
|
|
16
|
+
default: false,
|
|
17
|
+
});
|
|
18
|
+
if (!shouldOverwrite) {
|
|
19
|
+
process.stdout.write("已取消覆盖,保留现有配置。\n");
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
const accessToken = options.token?.trim() ||
|
|
24
|
+
(await (0, prompts_1.password)({
|
|
25
|
+
message: "请输入 SVMC_ACCESS_TOKEN",
|
|
26
|
+
mask: "*",
|
|
27
|
+
validate: (value) => (value.trim() ? true : "Token 不能为空"),
|
|
28
|
+
}));
|
|
29
|
+
const cookie = options.cookie?.trim() ||
|
|
30
|
+
(await (0, prompts_1.input)({
|
|
31
|
+
message: "请输入可选 Cookie",
|
|
32
|
+
default: currentConfig.auth.cookie ?? "",
|
|
33
|
+
}));
|
|
34
|
+
const nextConfig = {
|
|
35
|
+
auth: {
|
|
36
|
+
access_token: accessToken.trim(),
|
|
37
|
+
cookie: cookie.trim() || undefined,
|
|
38
|
+
},
|
|
39
|
+
server: { base_url: currentConfig.server.base_url },
|
|
40
|
+
};
|
|
41
|
+
await (0, config_1.saveConfig)(nextConfig, resolvedConfigPath);
|
|
42
|
+
const verifyConfig = await (0, config_1.loadConfig)(resolvedConfigPath);
|
|
43
|
+
if (verifyConfig.auth.access_token !== nextConfig.auth.access_token) {
|
|
44
|
+
throw new errors_1.CliError("配置写入校验失败,请重试。", errors_1.EXIT_CODES.IO);
|
|
45
|
+
}
|
|
46
|
+
process.stdout.write(`AUTH_OK: ${resolvedConfigPath}\n`);
|
|
47
|
+
}
|
|
48
|
+
async function runAuthStatusCommand(options) {
|
|
49
|
+
try {
|
|
50
|
+
const token = await (0, config_1.getAccessToken)(options.configPath);
|
|
51
|
+
const baseUrl = await (0, config_1.getBaseUrl)(undefined, options.configPath);
|
|
52
|
+
const cookie = await (0, config_1.getSessionCookie)(options.cookie, options.configPath);
|
|
53
|
+
const client = new apiClient_1.ApiClient(baseUrl, token, cookie);
|
|
54
|
+
await client.get("/image_generation/params");
|
|
55
|
+
process.stdout.write(`AUTH_STATUS: OK (${baseUrl})\n`);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
process.stdout.write("AUTH_STATUS: FAIL\n");
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.runImageSpecsCommand = runImageSpecsCommand;
|
|
4
|
+
exports.runImageGenerateCommand = runImageGenerateCommand;
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const apiClient_1 = require("../core/apiClient");
|
|
7
|
+
const config_1 = require("../core/config");
|
|
8
|
+
const downloader_1 = require("../core/downloader");
|
|
9
|
+
const errors_1 = require("../core/errors");
|
|
10
|
+
const parser_1 = require("../core/parser");
|
|
11
|
+
const poller_1 = require("../core/poller");
|
|
12
|
+
async function runImageSpecsCommand(options) {
|
|
13
|
+
const token = await (0, config_1.getAccessToken)(options.configPath);
|
|
14
|
+
const baseUrl = await (0, config_1.getBaseUrl)(options.baseUrl, options.configPath);
|
|
15
|
+
const cookie = await (0, config_1.getSessionCookie)(options.cookie, options.configPath);
|
|
16
|
+
const client = new apiClient_1.ApiClient(baseUrl, token, cookie);
|
|
17
|
+
const params = await client.get("/image_generation/params");
|
|
18
|
+
const suppliers = params.img2img?.for_supplier ?? {};
|
|
19
|
+
if (!options.provider) {
|
|
20
|
+
process.stdout.write(`${JSON.stringify({ providers: Object.keys(suppliers) }, null, 2)}\n`);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const providerSpec = suppliers[options.provider];
|
|
24
|
+
if (!providerSpec) {
|
|
25
|
+
throw new errors_1.CliError(`未找到 provider: ${options.provider}`, errors_1.EXIT_CODES.USAGE);
|
|
26
|
+
}
|
|
27
|
+
process.stdout.write(`${JSON.stringify({ provider: options.provider, spec: providerSpec }, null, 2)}\n`);
|
|
28
|
+
}
|
|
29
|
+
async function runImageGenerateCommand(options) {
|
|
30
|
+
const parsed = (0, parser_1.parseGenerateCliArgs)(options.rawArgs);
|
|
31
|
+
await (0, parser_1.ensureInputsReadable)(parsed.input);
|
|
32
|
+
const token = await (0, config_1.getAccessToken)(options.configPath);
|
|
33
|
+
const baseUrl = await (0, config_1.getBaseUrl)(parsed.baseUrl, options.configPath);
|
|
34
|
+
const cookie = await (0, config_1.getSessionCookie)(parsed.cookie, options.configPath);
|
|
35
|
+
const client = new apiClient_1.ApiClient(baseUrl, token, cookie);
|
|
36
|
+
const imageData = await (0, parser_1.readImagesAsDataUrls)(parsed.input);
|
|
37
|
+
const submitPayload = {
|
|
38
|
+
supplier: parsed.provider,
|
|
39
|
+
image: imageData,
|
|
40
|
+
...parsed.dynamicParams,
|
|
41
|
+
};
|
|
42
|
+
if (parsed.prompt) {
|
|
43
|
+
submitPayload.prompt = parsed.prompt;
|
|
44
|
+
}
|
|
45
|
+
if (parsed.ratio) {
|
|
46
|
+
submitPayload.ratio = parsed.ratio;
|
|
47
|
+
}
|
|
48
|
+
if (parsed.verbose) {
|
|
49
|
+
process.stdout.write(`[generate] provider=${parsed.provider} images=${imageData.length}\n`);
|
|
50
|
+
}
|
|
51
|
+
const submittedTask = await client.post("/image_generation/img2img", submitPayload);
|
|
52
|
+
const taskUuid = submittedTask.task_uuid;
|
|
53
|
+
if (!taskUuid) {
|
|
54
|
+
throw new errors_1.CliError("提交成功但未返回 task_uuid。", errors_1.EXIT_CODES.API);
|
|
55
|
+
}
|
|
56
|
+
process.stdout.write(`TASK_UUID: ${taskUuid}\n`);
|
|
57
|
+
const completedTask = await (0, poller_1.pollTaskUntilDone)(() => client.get(`/image_generation/get_task?task_uuid=${taskUuid}`), {
|
|
58
|
+
intervalMs: 5000,
|
|
59
|
+
timeoutMs: parsed.timeoutSec * 1000,
|
|
60
|
+
verbose: parsed.verbose,
|
|
61
|
+
});
|
|
62
|
+
const results = completedTask.results ?? [];
|
|
63
|
+
if (results.length === 0) {
|
|
64
|
+
throw new errors_1.CliError("任务已完成,但未返回任何图片结果。", errors_1.EXIT_CODES.TASK_FAILED);
|
|
65
|
+
}
|
|
66
|
+
const outputDir = parsed.outputDir ? (0, node_path_1.join)(process.cwd(), parsed.outputDir) : process.cwd();
|
|
67
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
68
|
+
const item = results[index];
|
|
69
|
+
if (!item.result_url) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const name = `img2img_${taskUuid}_${Date.now()}_${index + 1}.${item.format || "jpg"}`;
|
|
73
|
+
const localPath = await (0, downloader_1.downloadToLocal)(item.result_url, outputDir, name);
|
|
74
|
+
process.stdout.write(`IMAGE_SAVED: ${localPath}\n`);
|
|
75
|
+
process.stdout.write(`MEDIA:${localPath}\n`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ApiClient = void 0;
|
|
4
|
+
const errors_1 = require("./errors");
|
|
5
|
+
class ApiClient {
|
|
6
|
+
baseUrl;
|
|
7
|
+
accessToken;
|
|
8
|
+
sessionCookie;
|
|
9
|
+
defaultTimeoutMs;
|
|
10
|
+
constructor(baseUrl, accessToken, sessionCookie, defaultTimeoutMs = 30000) {
|
|
11
|
+
this.baseUrl = baseUrl;
|
|
12
|
+
this.accessToken = accessToken;
|
|
13
|
+
this.sessionCookie = sessionCookie;
|
|
14
|
+
this.defaultTimeoutMs = defaultTimeoutMs;
|
|
15
|
+
}
|
|
16
|
+
async get(path) {
|
|
17
|
+
return this.request(path, { method: "GET" });
|
|
18
|
+
}
|
|
19
|
+
async post(path, body) {
|
|
20
|
+
return this.request(path, { method: "POST", body });
|
|
21
|
+
}
|
|
22
|
+
async request(path, options) {
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
const timeoutMs = options.timeoutMs ?? this.defaultTimeoutMs;
|
|
25
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
26
|
+
const url = `${this.baseUrl}${path}`;
|
|
27
|
+
try {
|
|
28
|
+
const response = await fetch(url, {
|
|
29
|
+
method: options.method ?? "GET",
|
|
30
|
+
signal: controller.signal,
|
|
31
|
+
headers: buildHeaders(this.accessToken, this.sessionCookie),
|
|
32
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
33
|
+
});
|
|
34
|
+
const text = await response.text();
|
|
35
|
+
const payload = text ? safeJsonParse(text) : null;
|
|
36
|
+
if (!response.ok) {
|
|
37
|
+
const errorMessage = extractErrorMessage(payload) ?? (text || response.statusText);
|
|
38
|
+
if (response.status === 401) {
|
|
39
|
+
throw new errors_1.CliError(`鉴权失败(401): ${errorMessage}`, errors_1.EXIT_CODES.AUTH);
|
|
40
|
+
}
|
|
41
|
+
throw new errors_1.CliError(`请求失败(${response.status}) ${path}: ${errorMessage}`, errors_1.EXIT_CODES.API);
|
|
42
|
+
}
|
|
43
|
+
return unwrapApiEnvelope(payload);
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error instanceof errors_1.CliError) {
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
if (error.name === "AbortError") {
|
|
50
|
+
throw new errors_1.CliError(`请求超时: ${path}`, errors_1.EXIT_CODES.API);
|
|
51
|
+
}
|
|
52
|
+
throw new errors_1.CliError(`网络请求异常: ${error.message}`, errors_1.EXIT_CODES.API);
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
clearTimeout(timeout);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
exports.ApiClient = ApiClient;
|
|
60
|
+
function safeJsonParse(text) {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(text);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return { message: text };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
function extractErrorMessage(payload) {
|
|
69
|
+
if (!payload || typeof payload !== "object") {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
const maybeMessage = payload.message;
|
|
73
|
+
if (typeof maybeMessage === "string") {
|
|
74
|
+
return maybeMessage;
|
|
75
|
+
}
|
|
76
|
+
const maybeDetail = payload.detail;
|
|
77
|
+
if (typeof maybeDetail === "string") {
|
|
78
|
+
return maybeDetail;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
function buildHeaders(token, cookie) {
|
|
83
|
+
const headers = {
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
Authorization: `Bearer ${token}`,
|
|
86
|
+
};
|
|
87
|
+
if (cookie?.trim()) {
|
|
88
|
+
headers.Cookie = cookie.trim();
|
|
89
|
+
}
|
|
90
|
+
return headers;
|
|
91
|
+
}
|
|
92
|
+
function unwrapApiEnvelope(payload) {
|
|
93
|
+
if (!payload || typeof payload !== "object") {
|
|
94
|
+
return payload;
|
|
95
|
+
}
|
|
96
|
+
const record = payload;
|
|
97
|
+
if (typeof record.code !== "number") {
|
|
98
|
+
return payload;
|
|
99
|
+
}
|
|
100
|
+
if (record.code !== 200) {
|
|
101
|
+
const message = typeof record.message === "string" ? record.message : "接口返回错误";
|
|
102
|
+
if (record.code === 401) {
|
|
103
|
+
throw new errors_1.CliError(`鉴权失败(401): ${message}`, errors_1.EXIT_CODES.AUTH);
|
|
104
|
+
}
|
|
105
|
+
throw new errors_1.CliError(`请求失败(${record.code}): ${message}`, errors_1.EXIT_CODES.API);
|
|
106
|
+
}
|
|
107
|
+
return record.data;
|
|
108
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.DEFAULT_BASE_URL = void 0;
|
|
4
|
+
exports.getConfigPath = getConfigPath;
|
|
5
|
+
exports.loadConfig = loadConfig;
|
|
6
|
+
exports.saveConfig = saveConfig;
|
|
7
|
+
exports.getAccessToken = getAccessToken;
|
|
8
|
+
exports.getSessionCookie = getSessionCookie;
|
|
9
|
+
exports.getBaseUrl = getBaseUrl;
|
|
10
|
+
exports.normalizeBaseUrl = normalizeBaseUrl;
|
|
11
|
+
const promises_1 = require("node:fs/promises");
|
|
12
|
+
const node_path_1 = require("node:path");
|
|
13
|
+
const node_os_1 = require("node:os");
|
|
14
|
+
const toml_1 = require("@iarna/toml");
|
|
15
|
+
const errors_1 = require("./errors");
|
|
16
|
+
const DEFAULT_CONFIG_PATH = (0, node_path_1.join)((0, node_os_1.homedir)(), ".svmc", "config.toml");
|
|
17
|
+
exports.DEFAULT_BASE_URL = "https://media.sailvan.com/api/v1";
|
|
18
|
+
const EMPTY_CONFIG = {
|
|
19
|
+
auth: { access_token: "" },
|
|
20
|
+
server: {},
|
|
21
|
+
};
|
|
22
|
+
function getConfigPath(overridePath) {
|
|
23
|
+
return overridePath ?? process.env.SVMC_CONFIG_PATH ?? DEFAULT_CONFIG_PATH;
|
|
24
|
+
}
|
|
25
|
+
async function loadConfig(configPath) {
|
|
26
|
+
const resolved = getConfigPath(configPath);
|
|
27
|
+
try {
|
|
28
|
+
const content = await (0, promises_1.readFile)(resolved, "utf8");
|
|
29
|
+
const raw = (0, toml_1.parse)(content);
|
|
30
|
+
return {
|
|
31
|
+
auth: {
|
|
32
|
+
access_token: raw.auth?.access_token ?? "",
|
|
33
|
+
cookie: raw.auth?.cookie ?? undefined,
|
|
34
|
+
},
|
|
35
|
+
server: { base_url: raw.server?.base_url ?? undefined },
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error.code === "ENOENT") {
|
|
40
|
+
return { ...EMPTY_CONFIG };
|
|
41
|
+
}
|
|
42
|
+
throw new errors_1.CliError(`读取配置失败: ${error.message}`, errors_1.EXIT_CODES.IO);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function saveConfig(config, configPath) {
|
|
46
|
+
const resolved = getConfigPath(configPath);
|
|
47
|
+
await (0, promises_1.mkdir)((0, node_path_1.dirname)(resolved), { recursive: true });
|
|
48
|
+
const content = (0, toml_1.stringify)(config);
|
|
49
|
+
await (0, promises_1.writeFile)(resolved, content, "utf8");
|
|
50
|
+
return resolved;
|
|
51
|
+
}
|
|
52
|
+
async function getAccessToken(configPath) {
|
|
53
|
+
const cfg = await loadConfig(configPath);
|
|
54
|
+
const token = cfg.auth.access_token?.trim();
|
|
55
|
+
if (!token) {
|
|
56
|
+
throw new errors_1.CliError("未找到 SVMC_ACCESS_TOKEN,请先执行 `svmc auth` 完成鉴权。", errors_1.EXIT_CODES.AUTH);
|
|
57
|
+
}
|
|
58
|
+
return stripBearerPrefix(token);
|
|
59
|
+
}
|
|
60
|
+
async function getSessionCookie(cookieFlag, configPath) {
|
|
61
|
+
const fromFlag = cookieFlag?.trim();
|
|
62
|
+
if (fromFlag) {
|
|
63
|
+
return fromFlag;
|
|
64
|
+
}
|
|
65
|
+
const fromEnv = process.env.SVMC_COOKIE?.trim();
|
|
66
|
+
if (fromEnv) {
|
|
67
|
+
return fromEnv;
|
|
68
|
+
}
|
|
69
|
+
const cfg = await loadConfig(configPath);
|
|
70
|
+
const fromConfig = cfg.auth.cookie?.trim();
|
|
71
|
+
return fromConfig || undefined;
|
|
72
|
+
}
|
|
73
|
+
async function getBaseUrl(baseUrlFlag, configPath) {
|
|
74
|
+
const fromFlag = baseUrlFlag?.trim();
|
|
75
|
+
if (fromFlag) {
|
|
76
|
+
return normalizeBaseUrl(fromFlag);
|
|
77
|
+
}
|
|
78
|
+
const fromEnv = process.env.SVMC_BASE_URL?.trim();
|
|
79
|
+
if (fromEnv) {
|
|
80
|
+
return normalizeBaseUrl(fromEnv);
|
|
81
|
+
}
|
|
82
|
+
const config = await loadConfig(configPath);
|
|
83
|
+
const fromConfig = config.server.base_url?.trim();
|
|
84
|
+
if (fromConfig) {
|
|
85
|
+
return normalizeBaseUrl(fromConfig);
|
|
86
|
+
}
|
|
87
|
+
return exports.DEFAULT_BASE_URL;
|
|
88
|
+
}
|
|
89
|
+
function normalizeBaseUrl(url) {
|
|
90
|
+
return url.replace(/\/+$/, "");
|
|
91
|
+
}
|
|
92
|
+
function stripBearerPrefix(token) {
|
|
93
|
+
const normalized = token.trim();
|
|
94
|
+
if (normalized.toLowerCase().startsWith("bearer ")) {
|
|
95
|
+
return normalized.slice(7).trim();
|
|
96
|
+
}
|
|
97
|
+
return normalized;
|
|
98
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.downloadToLocal = downloadToLocal;
|
|
4
|
+
const promises_1 = require("node:fs/promises");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const errors_1 = require("./errors");
|
|
7
|
+
async function downloadToLocal(url, outputDir, preferredName) {
|
|
8
|
+
await (0, promises_1.mkdir)(outputDir, { recursive: true });
|
|
9
|
+
const response = await fetch(url);
|
|
10
|
+
if (!response.ok) {
|
|
11
|
+
throw new errors_1.CliError(`下载失败(${response.status}): ${url}`, errors_1.EXIT_CODES.DOWNLOAD_FAILED);
|
|
12
|
+
}
|
|
13
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
14
|
+
const fileName = sanitizeFileName(preferredName ?? ((0, node_path_1.basename)(new URL(url).pathname) || "image.jpg"));
|
|
15
|
+
const fullPath = (0, node_path_1.resolve)((0, node_path_1.join)(outputDir, ensureExt(fileName, response.headers.get("content-type"))));
|
|
16
|
+
await (0, promises_1.writeFile)(fullPath, bytes);
|
|
17
|
+
return fullPath;
|
|
18
|
+
}
|
|
19
|
+
function ensureExt(fileName, contentType) {
|
|
20
|
+
if ((0, node_path_1.extname)(fileName)) {
|
|
21
|
+
return fileName;
|
|
22
|
+
}
|
|
23
|
+
if (!contentType) {
|
|
24
|
+
return `${fileName}.jpg`;
|
|
25
|
+
}
|
|
26
|
+
if (contentType.includes("png")) {
|
|
27
|
+
return `${fileName}.png`;
|
|
28
|
+
}
|
|
29
|
+
if (contentType.includes("webp")) {
|
|
30
|
+
return `${fileName}.webp`;
|
|
31
|
+
}
|
|
32
|
+
return `${fileName}.jpg`;
|
|
33
|
+
}
|
|
34
|
+
function sanitizeFileName(input) {
|
|
35
|
+
return input.replace(/[^\w.-]+/g, "_");
|
|
36
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.EXIT_CODES = exports.CliError = void 0;
|
|
4
|
+
class CliError extends Error {
|
|
5
|
+
exitCode;
|
|
6
|
+
constructor(message, exitCode = 1) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "CliError";
|
|
9
|
+
this.exitCode = exitCode;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
exports.CliError = CliError;
|
|
13
|
+
exports.EXIT_CODES = {
|
|
14
|
+
OK: 0,
|
|
15
|
+
USAGE: 2,
|
|
16
|
+
AUTH: 3,
|
|
17
|
+
API: 4,
|
|
18
|
+
TASK_FAILED: 5,
|
|
19
|
+
DOWNLOAD_FAILED: 6,
|
|
20
|
+
IO: 7,
|
|
21
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.parseGenerateCliArgs = parseGenerateCliArgs;
|
|
7
|
+
exports.ensureInputsReadable = ensureInputsReadable;
|
|
8
|
+
exports.readImagesAsDataUrls = readImagesAsDataUrls;
|
|
9
|
+
const promises_1 = require("node:fs/promises");
|
|
10
|
+
const yargs_parser_1 = __importDefault(require("yargs-parser"));
|
|
11
|
+
const errors_1 = require("./errors");
|
|
12
|
+
function parseGenerateCliArgs(rawArgs) {
|
|
13
|
+
const parsed = (0, yargs_parser_1.default)(rawArgs, {
|
|
14
|
+
array: ["input"],
|
|
15
|
+
string: ["provider", "prompt", "ratio", "output-dir", "base-url", "cookie", "config-path"],
|
|
16
|
+
boolean: ["verbose"],
|
|
17
|
+
number: ["timeout-sec"],
|
|
18
|
+
configuration: {
|
|
19
|
+
"camel-case-expansion": false,
|
|
20
|
+
"dot-notation": false,
|
|
21
|
+
"unknown-options-as-args": false,
|
|
22
|
+
"strip-aliased": true,
|
|
23
|
+
"strip-dashed": false,
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
const provider = asString(parsed.provider);
|
|
27
|
+
if (!provider) {
|
|
28
|
+
throw new errors_1.CliError("缺少 `--provider` 参数。", errors_1.EXIT_CODES.USAGE);
|
|
29
|
+
}
|
|
30
|
+
const input = toStringArray(parsed.input);
|
|
31
|
+
if (input.length === 0) {
|
|
32
|
+
throw new errors_1.CliError("至少传入一个 `--input` 图片路径。", errors_1.EXIT_CODES.USAGE);
|
|
33
|
+
}
|
|
34
|
+
const timeoutSec = typeof parsed["timeout-sec"] === "number" ? parsed["timeout-sec"] : 600;
|
|
35
|
+
if (!Number.isFinite(timeoutSec) || timeoutSec <= 0) {
|
|
36
|
+
throw new errors_1.CliError("`--timeout-sec` 必须为正整数。", errors_1.EXIT_CODES.USAGE);
|
|
37
|
+
}
|
|
38
|
+
const dynamicParams = collectDynamicParams(parsed, [
|
|
39
|
+
"_",
|
|
40
|
+
"$0",
|
|
41
|
+
"provider",
|
|
42
|
+
"input",
|
|
43
|
+
"prompt",
|
|
44
|
+
"ratio",
|
|
45
|
+
"output-dir",
|
|
46
|
+
"timeout-sec",
|
|
47
|
+
"verbose",
|
|
48
|
+
"base-url",
|
|
49
|
+
"cookie",
|
|
50
|
+
"config-path",
|
|
51
|
+
]);
|
|
52
|
+
return {
|
|
53
|
+
provider,
|
|
54
|
+
input,
|
|
55
|
+
prompt: asString(parsed.prompt),
|
|
56
|
+
ratio: asString(parsed.ratio),
|
|
57
|
+
outputDir: asString(parsed["output-dir"]),
|
|
58
|
+
timeoutSec,
|
|
59
|
+
verbose: Boolean(parsed.verbose),
|
|
60
|
+
baseUrl: asString(parsed["base-url"]),
|
|
61
|
+
cookie: asString(parsed.cookie),
|
|
62
|
+
dynamicParams,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
async function ensureInputsReadable(paths) {
|
|
66
|
+
for (const path of paths) {
|
|
67
|
+
try {
|
|
68
|
+
await (0, promises_1.access)(path);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
throw new errors_1.CliError(`输入文件不存在或不可读: ${path}`, errors_1.EXIT_CODES.USAGE);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function readImagesAsDataUrls(paths) {
|
|
76
|
+
const urls = [];
|
|
77
|
+
for (const path of paths) {
|
|
78
|
+
const content = await (0, promises_1.readFile)(path);
|
|
79
|
+
const ext = path.split(".").pop()?.toLowerCase() ?? "jpg";
|
|
80
|
+
const mime = toMime(ext);
|
|
81
|
+
urls.push(`data:${mime};base64,${content.toString("base64")}`);
|
|
82
|
+
}
|
|
83
|
+
return urls;
|
|
84
|
+
}
|
|
85
|
+
function toMime(ext) {
|
|
86
|
+
switch (ext) {
|
|
87
|
+
case "png":
|
|
88
|
+
return "image/png";
|
|
89
|
+
case "webp":
|
|
90
|
+
return "image/webp";
|
|
91
|
+
case "gif":
|
|
92
|
+
return "image/gif";
|
|
93
|
+
case "jpeg":
|
|
94
|
+
case "jpg":
|
|
95
|
+
default:
|
|
96
|
+
return "image/jpeg";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
function collectDynamicParams(parsed, excludedKeys) {
|
|
100
|
+
const excluded = new Set(excludedKeys);
|
|
101
|
+
const result = {};
|
|
102
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
103
|
+
if (excluded.has(key)) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (typeof value === "string" ||
|
|
107
|
+
typeof value === "number" ||
|
|
108
|
+
typeof value === "boolean" ||
|
|
109
|
+
(Array.isArray(value) && value.every((item) => typeof item === "string"))) {
|
|
110
|
+
result[key] = value;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
function asString(value) {
|
|
116
|
+
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
117
|
+
}
|
|
118
|
+
function toStringArray(value) {
|
|
119
|
+
if (!value) {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
if (Array.isArray(value)) {
|
|
123
|
+
return value.filter((item) => typeof item === "string");
|
|
124
|
+
}
|
|
125
|
+
return typeof value === "string" ? [value] : [];
|
|
126
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.pollTaskUntilDone = pollTaskUntilDone;
|
|
4
|
+
const errors_1 = require("./errors");
|
|
5
|
+
const RUNNING_STATUSES = new Set(["pending", "processing", "running"]);
|
|
6
|
+
async function pollTaskUntilDone(fetchTask, options = {}) {
|
|
7
|
+
const intervalMs = options.intervalMs ?? 5000;
|
|
8
|
+
const timeoutMs = options.timeoutMs ?? 600_000;
|
|
9
|
+
const start = Date.now();
|
|
10
|
+
while (true) {
|
|
11
|
+
const task = await fetchTask();
|
|
12
|
+
const status = task.status;
|
|
13
|
+
if (options.verbose) {
|
|
14
|
+
process.stdout.write(`[poll] status=${status} task_uuid=${task.task_uuid}\n`);
|
|
15
|
+
}
|
|
16
|
+
if (status === "completed") {
|
|
17
|
+
return task;
|
|
18
|
+
}
|
|
19
|
+
if (status === "failed") {
|
|
20
|
+
const reason = task.error_message ?? "任务失败(无错误详情)";
|
|
21
|
+
throw new errors_1.CliError(reason, errors_1.EXIT_CODES.TASK_FAILED);
|
|
22
|
+
}
|
|
23
|
+
if (!RUNNING_STATUSES.has(status)) {
|
|
24
|
+
throw new errors_1.CliError(`未知任务状态: ${status}`, errors_1.EXIT_CODES.API);
|
|
25
|
+
}
|
|
26
|
+
if (Date.now() - start > timeoutMs) {
|
|
27
|
+
throw new errors_1.CliError("轮询超时,请稍后重试。", errors_1.EXIT_CODES.API);
|
|
28
|
+
}
|
|
29
|
+
await sleep(intervalMs);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function sleep(ms) {
|
|
33
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
34
|
+
}
|