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
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.run = run;
|
|
4
|
+
const commander_1 = require("commander");
|
|
5
|
+
const node_fs_1 = require("node:fs");
|
|
6
|
+
const node_path_1 = require("node:path");
|
|
7
|
+
const auth_1 = require("./commands/auth");
|
|
8
|
+
const image_1 = require("./commands/image");
|
|
9
|
+
const errors_1 = require("./core/errors");
|
|
10
|
+
async function run(argv = process.argv) {
|
|
11
|
+
const program = new commander_1.Command();
|
|
12
|
+
program
|
|
13
|
+
.name("svmc")
|
|
14
|
+
.description("SVMC CLI for image workflows")
|
|
15
|
+
.helpOption("-h, --help", "显示帮助")
|
|
16
|
+
.version(getCliVersion(), "-v, --version", "显示版本号")
|
|
17
|
+
.showHelpAfterError();
|
|
18
|
+
const auth = program
|
|
19
|
+
.command("auth")
|
|
20
|
+
.description("鉴权相关命令(默认执行 token/cookie 写入)")
|
|
21
|
+
.option("--config-path <path>", "指定配置文件路径")
|
|
22
|
+
.option("--token <token>", "直接传入 token(可选)")
|
|
23
|
+
.option("--cookie <cookie>", "保存会话 Cookie,可粘贴 k1=v1; k2=v2")
|
|
24
|
+
.action(async (options) => {
|
|
25
|
+
await (0, auth_1.runAuthCommand)(options);
|
|
26
|
+
});
|
|
27
|
+
auth
|
|
28
|
+
.command("status")
|
|
29
|
+
.description("验证当前认证信息是否可用")
|
|
30
|
+
.option("--config-path <path>", "指定配置文件路径")
|
|
31
|
+
.option("--cookie <cookie>", "会话 Cookie,可覆盖配置")
|
|
32
|
+
.action(async (options) => {
|
|
33
|
+
await (0, auth_1.runAuthStatusCommand)(options);
|
|
34
|
+
});
|
|
35
|
+
const image = program.command("image").description("图像相关命令");
|
|
36
|
+
image
|
|
37
|
+
.command("specs")
|
|
38
|
+
.description("获取 img2img 参数规格")
|
|
39
|
+
.option("--provider <provider>", "指定供应商,仅输出该供应商规格")
|
|
40
|
+
.option("--base-url <url>", "服务地址")
|
|
41
|
+
.option("--cookie <cookie>", "会话 Cookie,可覆盖配置")
|
|
42
|
+
.option("--config-path <path>", "指定配置文件路径")
|
|
43
|
+
.action(async (options) => (0, image_1.runImageSpecsCommand)(options));
|
|
44
|
+
image
|
|
45
|
+
.command("generate")
|
|
46
|
+
.description("提交 img2img 任务并轮询下载结果")
|
|
47
|
+
.allowUnknownOption(true)
|
|
48
|
+
.option("--config-path <path>", "指定配置文件路径")
|
|
49
|
+
.action(async (options) => {
|
|
50
|
+
const rawArgs = getRawArgsAfterToken(argv, "generate");
|
|
51
|
+
await (0, image_1.runImageGenerateCommand)({
|
|
52
|
+
rawArgs,
|
|
53
|
+
configPath: options.configPath,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
try {
|
|
57
|
+
await program.parseAsync(argv);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error instanceof errors_1.CliError) {
|
|
61
|
+
process.stderr.write(`${error.message}\n`);
|
|
62
|
+
process.exit(error.exitCode);
|
|
63
|
+
}
|
|
64
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
65
|
+
process.stderr.write(`${message}\n`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function getRawArgsAfterToken(argv, token) {
|
|
70
|
+
const index = argv.findIndex((item) => item === token);
|
|
71
|
+
if (index === -1) {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
return argv.slice(index + 1);
|
|
75
|
+
}
|
|
76
|
+
if (require.main === module) {
|
|
77
|
+
run(process.argv).catch((error) => {
|
|
78
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
79
|
+
process.stderr.write(`${message}\n`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
function getCliVersion() {
|
|
84
|
+
try {
|
|
85
|
+
const candidates = [(0, node_path_1.join)(__dirname, "../package.json"), (0, node_path_1.join)(__dirname, "../../package.json")];
|
|
86
|
+
const target = candidates.find((path) => (0, node_fs_1.existsSync)(path));
|
|
87
|
+
if (!target) {
|
|
88
|
+
return "0.0.0";
|
|
89
|
+
}
|
|
90
|
+
const content = (0, node_fs_1.readFileSync)(target, "utf8");
|
|
91
|
+
const pkg = JSON.parse(content);
|
|
92
|
+
return pkg.version ?? "0.0.0";
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return "0.0.0";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const apiClient_1 = require("../src/core/apiClient");
|
|
5
|
+
const errors_1 = require("../src/core/errors");
|
|
6
|
+
(0, vitest_1.describe)("ApiClient", () => {
|
|
7
|
+
(0, vitest_1.afterEach)(() => {
|
|
8
|
+
vitest_1.vi.unstubAllGlobals();
|
|
9
|
+
});
|
|
10
|
+
(0, vitest_1.it)("returns parsed json when request succeeds", async () => {
|
|
11
|
+
vitest_1.vi.stubGlobal("fetch", vitest_1.vi.fn(async () => new Response(JSON.stringify({ code: 200, message: "ok", data: { ok: true } }), {
|
|
12
|
+
status: 200,
|
|
13
|
+
headers: { "Content-Type": "application/json" },
|
|
14
|
+
})));
|
|
15
|
+
const client = new apiClient_1.ApiClient("https://example.com", "token");
|
|
16
|
+
const res = await client.get("/ping");
|
|
17
|
+
(0, vitest_1.expect)(res.ok).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
(0, vitest_1.it)("throws CliError on 401", async () => {
|
|
20
|
+
vitest_1.vi.stubGlobal("fetch", vitest_1.vi.fn(async () => new Response(JSON.stringify({ message: "unauthorized" }), {
|
|
21
|
+
status: 401,
|
|
22
|
+
headers: { "Content-Type": "application/json" },
|
|
23
|
+
})));
|
|
24
|
+
const client = new apiClient_1.ApiClient("https://example.com", "token");
|
|
25
|
+
await (0, vitest_1.expect)(client.get("/ping")).rejects.toBeInstanceOf(errors_1.CliError);
|
|
26
|
+
});
|
|
27
|
+
(0, vitest_1.it)("throws CliError when business code is 401", async () => {
|
|
28
|
+
vitest_1.vi.stubGlobal("fetch", vitest_1.vi.fn(async () => new Response(JSON.stringify({ code: 401, message: "未登录或登录已过期", data: null }), {
|
|
29
|
+
status: 200,
|
|
30
|
+
headers: { "Content-Type": "application/json" },
|
|
31
|
+
})));
|
|
32
|
+
const client = new apiClient_1.ApiClient("https://example.com", "token");
|
|
33
|
+
await (0, vitest_1.expect)(client.get("/ping")).rejects.toBeInstanceOf(errors_1.CliError);
|
|
34
|
+
});
|
|
35
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const promises_1 = require("node:fs/promises");
|
|
4
|
+
const node_os_1 = require("node:os");
|
|
5
|
+
const node_path_1 = require("node:path");
|
|
6
|
+
const vitest_1 = require("vitest");
|
|
7
|
+
const config_1 = require("../src/core/config");
|
|
8
|
+
let tempDir = "";
|
|
9
|
+
(0, vitest_1.afterEach)(async () => {
|
|
10
|
+
if (tempDir) {
|
|
11
|
+
await (0, promises_1.rm)(tempDir, { recursive: true, force: true });
|
|
12
|
+
}
|
|
13
|
+
tempDir = "";
|
|
14
|
+
});
|
|
15
|
+
(0, vitest_1.describe)("config", () => {
|
|
16
|
+
(0, vitest_1.it)("saves and loads token", async () => {
|
|
17
|
+
tempDir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "svmc-cli-"));
|
|
18
|
+
const configPath = (0, node_path_1.join)(tempDir, "config.toml");
|
|
19
|
+
await (0, config_1.saveConfig)({
|
|
20
|
+
auth: { access_token: "Bearer abc", cookie: "session=1" },
|
|
21
|
+
server: { base_url: "https://example.com" },
|
|
22
|
+
}, configPath);
|
|
23
|
+
const config = await (0, config_1.loadConfig)(configPath);
|
|
24
|
+
(0, vitest_1.expect)(config.auth.access_token).toBe("Bearer abc");
|
|
25
|
+
(0, vitest_1.expect)(config.auth.cookie).toBe("session=1");
|
|
26
|
+
(0, vitest_1.expect)(config.server.base_url).toBe("https://example.com");
|
|
27
|
+
await (0, vitest_1.expect)((0, config_1.getAccessToken)(configPath)).resolves.toBe("abc");
|
|
28
|
+
await (0, vitest_1.expect)((0, config_1.getSessionCookie)(undefined, configPath)).resolves.toBe("session=1");
|
|
29
|
+
});
|
|
30
|
+
(0, vitest_1.it)("uses production base url by default", async () => {
|
|
31
|
+
tempDir = await (0, promises_1.mkdtemp)((0, node_path_1.join)((0, node_os_1.tmpdir)(), "svmc-cli-"));
|
|
32
|
+
const configPath = (0, node_path_1.join)(tempDir, "empty.toml");
|
|
33
|
+
const baseUrl = await (0, config_1.getBaseUrl)(undefined, configPath);
|
|
34
|
+
(0, vitest_1.expect)(baseUrl).toBe(config_1.DEFAULT_BASE_URL);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const parser_1 = require("../src/core/parser");
|
|
5
|
+
(0, vitest_1.describe)("parseGenerateCliArgs", () => {
|
|
6
|
+
(0, vitest_1.it)("parses standard and dynamic options", () => {
|
|
7
|
+
const args = (0, parser_1.parseGenerateCliArgs)([
|
|
8
|
+
"--provider",
|
|
9
|
+
"nano-banana",
|
|
10
|
+
"--input",
|
|
11
|
+
"a.jpg",
|
|
12
|
+
"--input",
|
|
13
|
+
"b.png",
|
|
14
|
+
"--prompt",
|
|
15
|
+
"cyberpunk",
|
|
16
|
+
"--ratio",
|
|
17
|
+
"16:9",
|
|
18
|
+
"--steps",
|
|
19
|
+
"20",
|
|
20
|
+
"--negative_prompt",
|
|
21
|
+
"low quality",
|
|
22
|
+
]);
|
|
23
|
+
(0, vitest_1.expect)(args.provider).toBe("nano-banana");
|
|
24
|
+
(0, vitest_1.expect)(args.input).toEqual(["a.jpg", "b.png"]);
|
|
25
|
+
(0, vitest_1.expect)(args.prompt).toBe("cyberpunk");
|
|
26
|
+
(0, vitest_1.expect)(args.ratio).toBe("16:9");
|
|
27
|
+
(0, vitest_1.expect)(args.dynamicParams.steps).toBe(20);
|
|
28
|
+
(0, vitest_1.expect)(args.dynamicParams.negative_prompt).toBe("low quality");
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const vitest_1 = require("vitest");
|
|
4
|
+
const poller_1 = require("../src/core/poller");
|
|
5
|
+
(0, vitest_1.describe)("pollTaskUntilDone", () => {
|
|
6
|
+
(0, vitest_1.it)("returns completed task", async () => {
|
|
7
|
+
const statuses = ["pending", "processing", "completed"];
|
|
8
|
+
const task = await (0, poller_1.pollTaskUntilDone)(async () => ({
|
|
9
|
+
task_uuid: "t-1",
|
|
10
|
+
status: statuses.shift(),
|
|
11
|
+
}), { intervalMs: 1, timeoutMs: 1000 });
|
|
12
|
+
(0, vitest_1.expect)(task.status).toBe("completed");
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const config_1 = require("vitest/config");
|
|
4
|
+
exports.default = (0, config_1.defineConfig)({
|
|
5
|
+
test: {
|
|
6
|
+
include: ["tests/**/*.test.ts"],
|
|
7
|
+
exclude: ["dist/**", "node_modules/**"],
|
|
8
|
+
},
|
|
9
|
+
});
|
package/eslint.config.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const tsParser = require("@typescript-eslint/parser");
|
|
2
|
+
const tsPlugin = require("@typescript-eslint/eslint-plugin");
|
|
3
|
+
|
|
4
|
+
/** @type {import('eslint').Linter.FlatConfig[]} */
|
|
5
|
+
module.exports = [
|
|
6
|
+
{
|
|
7
|
+
files: ["**/*.ts"],
|
|
8
|
+
languageOptions: {
|
|
9
|
+
parser: tsParser,
|
|
10
|
+
parserOptions: {
|
|
11
|
+
project: "./tsconfig.json",
|
|
12
|
+
sourceType: "module",
|
|
13
|
+
ecmaVersion: "latest",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
plugins: {
|
|
17
|
+
"@typescript-eslint": tsPlugin,
|
|
18
|
+
},
|
|
19
|
+
rules: {
|
|
20
|
+
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
|
21
|
+
"@typescript-eslint/no-explicit-any": "off",
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
ignores: ["dist/**", "node_modules/**"],
|
|
26
|
+
},
|
|
27
|
+
];
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "svmc-cli",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "SVMC CLI for media-center image workflows",
|
|
5
|
+
"main": "dist/src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"svmc": "./bin/svmc.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc -p tsconfig.json",
|
|
11
|
+
"dev": "tsx src/index.ts",
|
|
12
|
+
"lint:eslint": "eslint .",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"type-check": "tsc --noEmit -p tsconfig.json"
|
|
15
|
+
},
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "git@gitlab.valsun.cn:visual-ai/svmc-cli.git"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [],
|
|
21
|
+
"author": "",
|
|
22
|
+
"license": "ISC",
|
|
23
|
+
"type": "commonjs",
|
|
24
|
+
"engines": {
|
|
25
|
+
"node": ">=20"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@iarna/toml": "^2.2.5",
|
|
29
|
+
"@inquirer/prompts": "^8.3.2",
|
|
30
|
+
"commander": "^14.0.3",
|
|
31
|
+
"yargs-parser": "^22.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^25.5.0",
|
|
35
|
+
"@types/yargs-parser": "^21.0.3",
|
|
36
|
+
"@typescript-eslint/eslint-plugin": "^8.57.0",
|
|
37
|
+
"@typescript-eslint/parser": "^8.57.0",
|
|
38
|
+
"eslint": "^10.0.3",
|
|
39
|
+
"tsx": "^4.21.0",
|
|
40
|
+
"typescript": "^5.9.3",
|
|
41
|
+
"vitest": "^4.1.0"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { confirm, input, password } from "@inquirer/prompts";
|
|
2
|
+
import { ApiClient } from "../core/apiClient";
|
|
3
|
+
import {
|
|
4
|
+
getAccessToken,
|
|
5
|
+
getBaseUrl,
|
|
6
|
+
getConfigPath,
|
|
7
|
+
getSessionCookie,
|
|
8
|
+
loadConfig,
|
|
9
|
+
saveConfig,
|
|
10
|
+
} from "../core/config";
|
|
11
|
+
import { CliError, EXIT_CODES } from "../core/errors";
|
|
12
|
+
|
|
13
|
+
export interface AuthCommandOptions {
|
|
14
|
+
configPath?: string;
|
|
15
|
+
token?: string;
|
|
16
|
+
cookie?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface AuthStatusCommandOptions {
|
|
20
|
+
configPath?: string;
|
|
21
|
+
cookie?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function runAuthCommand(
|
|
25
|
+
options: AuthCommandOptions,
|
|
26
|
+
): Promise<void> {
|
|
27
|
+
const resolvedConfigPath = getConfigPath(options.configPath);
|
|
28
|
+
const currentConfig = await loadConfig(resolvedConfigPath);
|
|
29
|
+
|
|
30
|
+
const hasExisting = Boolean(currentConfig.auth.access_token?.trim());
|
|
31
|
+
if (hasExisting) {
|
|
32
|
+
const shouldOverwrite = await confirm({
|
|
33
|
+
message: `检测到已有 token,是否覆盖?(${resolvedConfigPath})`,
|
|
34
|
+
default: false,
|
|
35
|
+
});
|
|
36
|
+
if (!shouldOverwrite) {
|
|
37
|
+
process.stdout.write("已取消覆盖,保留现有配置。\n");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const accessToken =
|
|
43
|
+
options.token?.trim() ||
|
|
44
|
+
(await password({
|
|
45
|
+
message: "请输入 SVMC_ACCESS_TOKEN",
|
|
46
|
+
mask: "*",
|
|
47
|
+
validate: (value) => (value.trim() ? true : "Token 不能为空"),
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
const cookie =
|
|
51
|
+
options.cookie?.trim() ||
|
|
52
|
+
(await input({
|
|
53
|
+
message: "请输入可选 Cookie",
|
|
54
|
+
default: currentConfig.auth.cookie ?? "",
|
|
55
|
+
}));
|
|
56
|
+
|
|
57
|
+
const nextConfig = {
|
|
58
|
+
auth: {
|
|
59
|
+
access_token: accessToken.trim(),
|
|
60
|
+
cookie: cookie.trim() || undefined,
|
|
61
|
+
},
|
|
62
|
+
server: { base_url: currentConfig.server.base_url },
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
await saveConfig(nextConfig, resolvedConfigPath);
|
|
66
|
+
const verifyConfig = await loadConfig(resolvedConfigPath);
|
|
67
|
+
if (verifyConfig.auth.access_token !== nextConfig.auth.access_token) {
|
|
68
|
+
throw new CliError("配置写入校验失败,请重试。", EXIT_CODES.IO);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
process.stdout.write(`AUTH_OK: ${resolvedConfigPath}\n`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function runAuthStatusCommand(
|
|
75
|
+
options: AuthStatusCommandOptions,
|
|
76
|
+
): Promise<void> {
|
|
77
|
+
try {
|
|
78
|
+
const token = await getAccessToken(options.configPath);
|
|
79
|
+
const baseUrl = await getBaseUrl(undefined, options.configPath);
|
|
80
|
+
const cookie = await getSessionCookie(options.cookie, options.configPath);
|
|
81
|
+
const client = new ApiClient(baseUrl, token, cookie);
|
|
82
|
+
await client.get("/image_generation/params");
|
|
83
|
+
process.stdout.write(`AUTH_STATUS: OK (${baseUrl})\n`);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
process.stdout.write("AUTH_STATUS: FAIL\n");
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { ApiClient } from "../core/apiClient";
|
|
3
|
+
import { getAccessToken, getBaseUrl, getSessionCookie } from "../core/config";
|
|
4
|
+
import { downloadToLocal } from "../core/downloader";
|
|
5
|
+
import { CliError, EXIT_CODES } from "../core/errors";
|
|
6
|
+
import { ensureInputsReadable, parseGenerateCliArgs, readImagesAsDataUrls } from "../core/parser";
|
|
7
|
+
import { pollTaskUntilDone } from "../core/poller";
|
|
8
|
+
import type { ImageGenerationTask, ImageParamsResponse } from "../types";
|
|
9
|
+
|
|
10
|
+
export interface SpecsCommandOptions {
|
|
11
|
+
provider?: string;
|
|
12
|
+
configPath?: string;
|
|
13
|
+
baseUrl?: string;
|
|
14
|
+
cookie?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface GenerateCommandOptions {
|
|
18
|
+
rawArgs: string[];
|
|
19
|
+
configPath?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function runImageSpecsCommand(options: SpecsCommandOptions): Promise<void> {
|
|
23
|
+
const token = await getAccessToken(options.configPath);
|
|
24
|
+
const baseUrl = await getBaseUrl(options.baseUrl, options.configPath);
|
|
25
|
+
const cookie = await getSessionCookie(options.cookie, options.configPath);
|
|
26
|
+
const client = new ApiClient(baseUrl, token, cookie);
|
|
27
|
+
|
|
28
|
+
const params = await client.get<ImageParamsResponse>("/image_generation/params");
|
|
29
|
+
const suppliers = params.img2img?.for_supplier ?? {};
|
|
30
|
+
if (!options.provider) {
|
|
31
|
+
process.stdout.write(`${JSON.stringify({ providers: Object.keys(suppliers) }, null, 2)}\n`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const providerSpec = suppliers[options.provider];
|
|
36
|
+
if (!providerSpec) {
|
|
37
|
+
throw new CliError(`未找到 provider: ${options.provider}`, EXIT_CODES.USAGE);
|
|
38
|
+
}
|
|
39
|
+
process.stdout.write(
|
|
40
|
+
`${JSON.stringify({ provider: options.provider, spec: providerSpec }, null, 2)}\n`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function runImageGenerateCommand(options: GenerateCommandOptions): Promise<void> {
|
|
45
|
+
const parsed = parseGenerateCliArgs(options.rawArgs);
|
|
46
|
+
await ensureInputsReadable(parsed.input);
|
|
47
|
+
const token = await getAccessToken(options.configPath);
|
|
48
|
+
const baseUrl = await getBaseUrl(parsed.baseUrl, options.configPath);
|
|
49
|
+
const cookie = await getSessionCookie(parsed.cookie, options.configPath);
|
|
50
|
+
const client = new ApiClient(baseUrl, token, cookie);
|
|
51
|
+
const imageData = await readImagesAsDataUrls(parsed.input);
|
|
52
|
+
|
|
53
|
+
const submitPayload: Record<string, unknown> = {
|
|
54
|
+
supplier: parsed.provider,
|
|
55
|
+
image: imageData,
|
|
56
|
+
...parsed.dynamicParams,
|
|
57
|
+
};
|
|
58
|
+
if (parsed.prompt) {
|
|
59
|
+
submitPayload.prompt = parsed.prompt;
|
|
60
|
+
}
|
|
61
|
+
if (parsed.ratio) {
|
|
62
|
+
submitPayload.ratio = parsed.ratio;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (parsed.verbose) {
|
|
66
|
+
process.stdout.write(`[generate] provider=${parsed.provider} images=${imageData.length}\n`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const submittedTask = await client.post<ImageGenerationTask>(
|
|
70
|
+
"/image_generation/img2img",
|
|
71
|
+
submitPayload,
|
|
72
|
+
);
|
|
73
|
+
const taskUuid = submittedTask.task_uuid;
|
|
74
|
+
if (!taskUuid) {
|
|
75
|
+
throw new CliError("提交成功但未返回 task_uuid。", EXIT_CODES.API);
|
|
76
|
+
}
|
|
77
|
+
process.stdout.write(`TASK_UUID: ${taskUuid}\n`);
|
|
78
|
+
|
|
79
|
+
const completedTask = await pollTaskUntilDone(
|
|
80
|
+
() => client.get<ImageGenerationTask>(`/image_generation/get_task?task_uuid=${taskUuid}`),
|
|
81
|
+
{
|
|
82
|
+
intervalMs: 5000,
|
|
83
|
+
timeoutMs: parsed.timeoutSec * 1000,
|
|
84
|
+
verbose: parsed.verbose,
|
|
85
|
+
},
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const results = completedTask.results ?? [];
|
|
89
|
+
if (results.length === 0) {
|
|
90
|
+
throw new CliError("任务已完成,但未返回任何图片结果。", EXIT_CODES.TASK_FAILED);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const outputDir = parsed.outputDir ? join(process.cwd(), parsed.outputDir) : process.cwd();
|
|
94
|
+
for (let index = 0; index < results.length; index += 1) {
|
|
95
|
+
const item = results[index];
|
|
96
|
+
if (!item.result_url) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const name = `img2img_${taskUuid}_${Date.now()}_${index + 1}.${item.format || "jpg"}`;
|
|
100
|
+
const localPath = await downloadToLocal(item.result_url, outputDir, name);
|
|
101
|
+
process.stdout.write(`IMAGE_SAVED: ${localPath}\n`);
|
|
102
|
+
process.stdout.write(`MEDIA:${localPath}\n`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { CliError, EXIT_CODES } from "./errors";
|
|
2
|
+
import type { ApiEnvelope } from "../types";
|
|
3
|
+
|
|
4
|
+
interface RequestOptions {
|
|
5
|
+
method?: "GET" | "POST";
|
|
6
|
+
body?: unknown;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class ApiClient {
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly baseUrl: string,
|
|
13
|
+
private readonly accessToken: string,
|
|
14
|
+
private readonly sessionCookie?: string,
|
|
15
|
+
private readonly defaultTimeoutMs = 30000,
|
|
16
|
+
) {}
|
|
17
|
+
|
|
18
|
+
async get<T>(path: string): Promise<T> {
|
|
19
|
+
return this.request<T>(path, { method: "GET" });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async post<T>(path: string, body: unknown): Promise<T> {
|
|
23
|
+
return this.request<T>(path, { method: "POST", body });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private async request<T>(path: string, options: RequestOptions): Promise<T> {
|
|
27
|
+
const controller = new AbortController();
|
|
28
|
+
const timeoutMs = options.timeoutMs ?? this.defaultTimeoutMs;
|
|
29
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
30
|
+
const url = `${this.baseUrl}${path}`;
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const response = await fetch(url, {
|
|
34
|
+
method: options.method ?? "GET",
|
|
35
|
+
signal: controller.signal,
|
|
36
|
+
headers: buildHeaders(this.accessToken, this.sessionCookie),
|
|
37
|
+
body: options.body ? JSON.stringify(options.body) : undefined,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const text = await response.text();
|
|
41
|
+
const payload = text ? safeJsonParse(text) : null;
|
|
42
|
+
|
|
43
|
+
if (!response.ok) {
|
|
44
|
+
const errorMessage = extractErrorMessage(payload) ?? (text || response.statusText);
|
|
45
|
+
if (response.status === 401) {
|
|
46
|
+
throw new CliError(`鉴权失败(401): ${errorMessage}`, EXIT_CODES.AUTH);
|
|
47
|
+
}
|
|
48
|
+
throw new CliError(
|
|
49
|
+
`请求失败(${response.status}) ${path}: ${errorMessage}`,
|
|
50
|
+
EXIT_CODES.API,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return unwrapApiEnvelope<T>(payload);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error instanceof CliError) {
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
if ((error as Error).name === "AbortError") {
|
|
60
|
+
throw new CliError(`请求超时: ${path}`, EXIT_CODES.API);
|
|
61
|
+
}
|
|
62
|
+
throw new CliError(`网络请求异常: ${(error as Error).message}`, EXIT_CODES.API);
|
|
63
|
+
} finally {
|
|
64
|
+
clearTimeout(timeout);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function safeJsonParse(text: string): unknown {
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(text);
|
|
72
|
+
} catch {
|
|
73
|
+
return { message: text };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function extractErrorMessage(payload: unknown): string | null {
|
|
78
|
+
if (!payload || typeof payload !== "object") {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const maybeMessage = (payload as Record<string, unknown>).message;
|
|
82
|
+
if (typeof maybeMessage === "string") {
|
|
83
|
+
return maybeMessage;
|
|
84
|
+
}
|
|
85
|
+
const maybeDetail = (payload as Record<string, unknown>).detail;
|
|
86
|
+
if (typeof maybeDetail === "string") {
|
|
87
|
+
return maybeDetail;
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function buildHeaders(token: string, cookie?: string): HeadersInit {
|
|
93
|
+
const headers: Record<string, string> = {
|
|
94
|
+
"Content-Type": "application/json",
|
|
95
|
+
Authorization: `Bearer ${token}`,
|
|
96
|
+
};
|
|
97
|
+
if (cookie?.trim()) {
|
|
98
|
+
headers.Cookie = cookie.trim();
|
|
99
|
+
}
|
|
100
|
+
return headers;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function unwrapApiEnvelope<T>(payload: unknown): T {
|
|
104
|
+
if (!payload || typeof payload !== "object") {
|
|
105
|
+
return payload as T;
|
|
106
|
+
}
|
|
107
|
+
const record = payload as Partial<ApiEnvelope<T>>;
|
|
108
|
+
if (typeof record.code !== "number") {
|
|
109
|
+
return payload as T;
|
|
110
|
+
}
|
|
111
|
+
if (record.code !== 200) {
|
|
112
|
+
const message = typeof record.message === "string" ? record.message : "接口返回错误";
|
|
113
|
+
if (record.code === 401) {
|
|
114
|
+
throw new CliError(`鉴权失败(401): ${message}`, EXIT_CODES.AUTH);
|
|
115
|
+
}
|
|
116
|
+
throw new CliError(`请求失败(${record.code}): ${message}`, EXIT_CODES.API);
|
|
117
|
+
}
|
|
118
|
+
return record.data as T;
|
|
119
|
+
}
|