lubanpng 0.1.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/dist/api.js ADDED
@@ -0,0 +1,146 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { basename } from "node:path";
3
+ import { ApiError } from "./errors.js";
4
+ import { VERSION } from "./version.js";
5
+ export const DEFAULT_API_BASE = "https://lubanpng.wizthink.cn";
6
+ export const API_BASE_ENV = "LUBANPNG_API_BASE";
7
+ export const USER_AGENT = `lubanpng-cli/${VERSION}`;
8
+ export const ErrorCode = {
9
+ network: -1,
10
+ invalidParams: 1001,
11
+ fileTooLarge: 1004,
12
+ internal: 2001,
13
+ compressionFailed: 2002,
14
+ serviceUnconfigured: 2003,
15
+ notFound: 3003,
16
+ unauthorized: 4001,
17
+ missingCsrfHeader: 4002,
18
+ quotaExhausted: 4003,
19
+ };
20
+ const isEnvelope = (value) => typeof value === "object" &&
21
+ value !== null &&
22
+ typeof value.code === "number" &&
23
+ typeof value.msg === "string";
24
+ const readNumberHeader = (read, name) => {
25
+ const raw = read(name);
26
+ if (raw === null || raw.trim() === "")
27
+ return null;
28
+ const parsed = Number(raw);
29
+ return Number.isFinite(parsed) ? parsed : null;
30
+ };
31
+ export const readQuotaHeaders = (read) => ({
32
+ limit: readNumberHeader(read, "X-Quota-Limit"),
33
+ remaining: readNumberHeader(read, "X-Quota-Remaining"),
34
+ resetsAt: read("X-Quota-Reset"),
35
+ });
36
+ export class ApiClient {
37
+ baseUrl;
38
+ apiKey;
39
+ fetchImpl;
40
+ constructor(options) {
41
+ this.baseUrl = options.baseUrl.replace(/\/+$/, "");
42
+ this.apiKey = options.apiKey && options.apiKey !== "" ? options.apiKey : null;
43
+ this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
44
+ }
45
+ url(path) {
46
+ if (/^https?:\/\//i.test(path))
47
+ return path;
48
+ return `${this.baseUrl}${path.startsWith("/") ? path : `/${path}`}`;
49
+ }
50
+ headers(json) {
51
+ const headers = new Headers({ Accept: "application/json", "User-Agent": USER_AGENT });
52
+ if (this.apiKey)
53
+ headers.set("Authorization", `Bearer ${this.apiKey}`);
54
+ if (json)
55
+ headers.set("Content-Type", "application/json");
56
+ return headers;
57
+ }
58
+ async request(method, path, options = {}) {
59
+ let response;
60
+ try {
61
+ response = await this.fetchImpl(this.url(path), {
62
+ method,
63
+ headers: this.headers(options.json !== undefined),
64
+ body: options.formData ?? (options.json === undefined ? null : JSON.stringify(options.json)),
65
+ signal: options.signal ?? null,
66
+ });
67
+ }
68
+ catch (error) {
69
+ if (error instanceof DOMException && error.name === "AbortError")
70
+ throw error;
71
+ throw new ApiError(0, ErrorCode.network, "网络错误,无法连接 LubanPNG 服务");
72
+ }
73
+ const text = await response.text();
74
+ const quota = readQuotaHeaders((name) => response.headers.get(name));
75
+ let parsed;
76
+ try {
77
+ parsed = text === "" ? null : JSON.parse(text);
78
+ }
79
+ catch {
80
+ throw new ApiError(response.status, ErrorCode.network, "服务返回了无法解析的响应");
81
+ }
82
+ if (!isEnvelope(parsed)) {
83
+ throw new ApiError(response.status, ErrorCode.network, "服务返回了无法解析的响应");
84
+ }
85
+ if (response.ok && parsed.code === 0)
86
+ return { data: parsed.data, quota };
87
+ throw new ApiError(response.status, parsed.code, parsed.msg);
88
+ }
89
+ me(signal) {
90
+ return this.request("GET", "/v1/me", { signal });
91
+ }
92
+ async uploadImage(filePath, signal) {
93
+ const data = await readFile(filePath);
94
+ const form = new FormData();
95
+ form.append("file", new Blob([new Uint8Array(data)]), basename(filePath));
96
+ return this.request("POST", "/v1/images/compress", { formData: form, signal });
97
+ }
98
+ taskStatus(taskId, waitSeconds, signal) {
99
+ return this.request("GET", `/v1/images/compress/${encodeURIComponent(taskId)}?wait=${waitSeconds}`, { signal });
100
+ }
101
+ async download(pathOrUrl, signal) {
102
+ let response;
103
+ try {
104
+ response = await this.fetchImpl(this.url(pathOrUrl), {
105
+ method: "GET",
106
+ headers: this.headers(false),
107
+ signal: signal ?? null,
108
+ });
109
+ }
110
+ catch (error) {
111
+ if (error instanceof DOMException && error.name === "AbortError")
112
+ throw error;
113
+ throw new ApiError(0, ErrorCode.network, "下载产物失败,网络错误");
114
+ }
115
+ if (!response.ok) {
116
+ const text = await response.text();
117
+ try {
118
+ const parsed = text === "" ? null : JSON.parse(text);
119
+ if (isEnvelope(parsed))
120
+ throw new ApiError(response.status, parsed.code, parsed.msg);
121
+ }
122
+ catch (error) {
123
+ if (error instanceof ApiError)
124
+ throw error;
125
+ }
126
+ throw new ApiError(response.status, ErrorCode.network, "下载产物失败");
127
+ }
128
+ return new Uint8Array(await response.arrayBuffer());
129
+ }
130
+ }
131
+ export const describeError = (error) => {
132
+ if (error instanceof ApiError) {
133
+ if (error.code === ErrorCode.unauthorized)
134
+ return "API Key 无效或已吊销,请重新运行 lubanpng login";
135
+ if (error.code === ErrorCode.quotaExhausted)
136
+ return `本期额度已用尽${error.message ? `:${error.message}` : ""}`;
137
+ if (error.code === ErrorCode.notFound)
138
+ return "任务或产物不存在、已过期";
139
+ if (error.code === ErrorCode.network)
140
+ return error.message;
141
+ return error.message !== "" ? error.message : "请求失败";
142
+ }
143
+ if (error instanceof Error && error.message !== "")
144
+ return error.message;
145
+ return "请求失败";
146
+ };
package/dist/args.js ADDED
@@ -0,0 +1,93 @@
1
+ import { UsageError } from "./errors.js";
2
+ export const DEFAULT_CONCURRENCY = 4;
3
+ export const MAX_CONCURRENCY = 16;
4
+ const STRING_OPTIONS = new Set(["--api-base", "--out", "--concurrency"]);
5
+ const BOOLEAN_OPTIONS = new Set(["--in-place", "--recursive", "--help", "-h", "--version", "-v"]);
6
+ const COMMANDS = new Set(["login", "logout", "compress", "usage"]);
7
+ const readConcurrency = (raw) => {
8
+ if (!/^[1-9]\d*$/.test(raw))
9
+ throw new UsageError(`--concurrency 必须是正整数,收到:${raw}`);
10
+ const value = Number(raw);
11
+ if (value > MAX_CONCURRENCY) {
12
+ throw new UsageError(`--concurrency 最大 ${MAX_CONCURRENCY},收到:${raw}`);
13
+ }
14
+ return value;
15
+ };
16
+ export const parseArgv = (argv) => {
17
+ const options = new Map();
18
+ const positionals = [];
19
+ let index = 0;
20
+ while (index < argv.length) {
21
+ const token = argv[index];
22
+ if (token === "--") {
23
+ positionals.push(...argv.slice(index + 1));
24
+ break;
25
+ }
26
+ if (token.startsWith("-") && token !== "-") {
27
+ const equals = token.indexOf("=");
28
+ const name = equals >= 0 ? token.slice(0, equals) : token;
29
+ const inlineValue = equals >= 0 ? token.slice(equals + 1) : undefined;
30
+ if (BOOLEAN_OPTIONS.has(name)) {
31
+ if (inlineValue !== undefined)
32
+ throw new UsageError(`选项 ${name} 不接受值`);
33
+ options.set(name, true);
34
+ index += 1;
35
+ continue;
36
+ }
37
+ if (STRING_OPTIONS.has(name)) {
38
+ const value = inlineValue ?? argv[index + 1];
39
+ if (value === undefined)
40
+ throw new UsageError(`选项 ${name} 缺少值`);
41
+ options.set(name, value);
42
+ index += inlineValue !== undefined ? 1 : 2;
43
+ continue;
44
+ }
45
+ throw new UsageError(`未知选项:${name}`);
46
+ }
47
+ positionals.push(token);
48
+ index += 1;
49
+ }
50
+ if (options.has("--help") || options.has("-h"))
51
+ return { command: "help" };
52
+ if (options.has("--version") || options.has("-v"))
53
+ return { command: "version" };
54
+ const apiBase = options.get("--api-base");
55
+ const apiBaseValue = typeof apiBase === "string" ? apiBase : undefined;
56
+ const name = positionals[0];
57
+ if (name === undefined) {
58
+ throw new UsageError("缺少子命令。可用命令:login、logout、compress、usage");
59
+ }
60
+ if (!COMMANDS.has(name)) {
61
+ throw new UsageError(`未知命令:${name}。可用命令:login、logout、compress、usage`);
62
+ }
63
+ if (name === "compress") {
64
+ const out = options.get("--out");
65
+ const outValue = typeof out === "string" ? out : undefined;
66
+ const inPlace = options.get("--in-place") === true;
67
+ const recursive = options.get("--recursive") === true;
68
+ const concurrency = options.get("--concurrency");
69
+ if (inPlace && outValue !== undefined) {
70
+ throw new UsageError("--in-place 与 --out 不能同时使用");
71
+ }
72
+ const paths = positionals.slice(1);
73
+ if (paths.length === 0)
74
+ throw new UsageError("compress 至少需要一个文件或目录路径");
75
+ return {
76
+ command: "compress",
77
+ apiBase: apiBaseValue,
78
+ paths,
79
+ out: outValue,
80
+ inPlace,
81
+ recursive,
82
+ concurrency: typeof concurrency === "string" ? readConcurrency(concurrency) : DEFAULT_CONCURRENCY,
83
+ };
84
+ }
85
+ if (positionals.length > 1) {
86
+ throw new UsageError(`${name} 不接受多余参数:${positionals.slice(1).join(" ")}`);
87
+ }
88
+ if (name === "login")
89
+ return { command: "login", apiBase: apiBaseValue };
90
+ if (name === "logout")
91
+ return { command: "logout", apiBase: apiBaseValue };
92
+ return { command: "usage", apiBase: apiBaseValue };
93
+ };
@@ -0,0 +1,196 @@
1
+ import { mkdir, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, extname, join, relative as relativePath, resolve } from "node:path";
3
+ import { ApiClient, describeError, } from "../api.js";
4
+ import { API_KEY_ENV, resolveApiKey } from "../config.js";
5
+ import { UsageError } from "../errors.js";
6
+ import { formatBytes, formatSavings, formatSizePair, periodNoun, savingsPercent } from "../format.js";
7
+ const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif"]);
8
+ const WAIT_SECONDS = 30;
9
+ const MAX_POLL_ROUNDS = 40;
10
+ const SIZE_COLUMN = 9;
11
+ const walk = async (root, current, push) => {
12
+ const entries = await readdir(current, { withFileTypes: true });
13
+ for (const entry of entries) {
14
+ const full = join(current, entry.name);
15
+ if (entry.isDirectory()) {
16
+ await walk(root, full, push);
17
+ }
18
+ else if (entry.isFile() && IMAGE_EXTENSIONS.has(extname(entry.name).toLowerCase())) {
19
+ push({ path: full, name: entry.name, relative: relativePath(root, full) });
20
+ }
21
+ }
22
+ };
23
+ export const collectFiles = async (inputs, recursive) => {
24
+ const collected = [];
25
+ const seen = new Set();
26
+ const push = (item) => {
27
+ const key = resolve(item.path);
28
+ if (seen.has(key))
29
+ return;
30
+ seen.add(key);
31
+ collected.push(item);
32
+ };
33
+ for (const input of inputs) {
34
+ let info;
35
+ try {
36
+ info = await stat(input);
37
+ }
38
+ catch {
39
+ throw new UsageError(`路径不存在:${input}`);
40
+ }
41
+ if (info.isDirectory()) {
42
+ if (!recursive)
43
+ throw new UsageError(`目录需要加 --recursive:${input}`);
44
+ await walk(input, input, push);
45
+ }
46
+ else if (info.isFile()) {
47
+ push({ path: input, name: basename(input), relative: basename(input) });
48
+ }
49
+ else {
50
+ throw new UsageError(`不支持的路径类型:${input}`);
51
+ }
52
+ }
53
+ return collected;
54
+ };
55
+ const outputPathFor = (file, options) => {
56
+ if (options.inPlace)
57
+ return file.path;
58
+ if (options.out !== undefined)
59
+ return join(options.out, file.relative);
60
+ const ext = extname(file.path);
61
+ return `${file.path.slice(0, file.path.length - ext.length)}.min${ext}`;
62
+ };
63
+ const writeFileAtomic = async (target, bytes) => {
64
+ const temp = `${target}.lubanpng-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
65
+ try {
66
+ await writeFile(temp, bytes);
67
+ await rename(temp, target);
68
+ }
69
+ catch (error) {
70
+ await rm(temp, { force: true }).catch(() => undefined);
71
+ throw error;
72
+ }
73
+ };
74
+ const assertNoTargetConflicts = (files, options) => {
75
+ const byTarget = new Map();
76
+ for (const file of files) {
77
+ const target = resolve(outputPathFor(file, options));
78
+ const sources = byTarget.get(target);
79
+ if (sources === undefined) {
80
+ byTarget.set(target, [file.path]);
81
+ }
82
+ else {
83
+ sources.push(file.path);
84
+ }
85
+ }
86
+ const conflicts = [...byTarget.entries()].filter(([, sources]) => sources.length > 1);
87
+ if (conflicts.length === 0)
88
+ return;
89
+ const detail = conflicts.map(([target, sources]) => `${target}(${sources.join("、")})`).join(";");
90
+ throw new UsageError(`输出路径冲突:${detail}。请调整输入路径或分批压缩`);
91
+ };
92
+ const runPool = async (items, limit, worker) => {
93
+ const queue = [...items];
94
+ const workers = Array.from({ length: Math.min(limit, queue.length) }, async () => {
95
+ let next = queue.shift();
96
+ while (next !== undefined) {
97
+ await worker(next);
98
+ next = queue.shift();
99
+ }
100
+ });
101
+ await Promise.all(workers);
102
+ };
103
+ const waitForCompletion = async (client, taskId, onQuota) => {
104
+ let result = await client.taskStatus(taskId, WAIT_SECONDS);
105
+ onQuota(result.quota);
106
+ let rounds = 0;
107
+ while ((result.data.status === "pending" || result.data.status === "processing") &&
108
+ rounds < MAX_POLL_ROUNDS) {
109
+ rounds += 1;
110
+ result = await client.taskStatus(taskId, WAIT_SECONDS);
111
+ onQuota(result.quota);
112
+ }
113
+ return result.data;
114
+ };
115
+ export const compressCommand = async (context, options) => {
116
+ const key = await resolveApiKey(context.env);
117
+ if (!key)
118
+ throw new UsageError(`未登录,请先运行 lubanpng login 或设置 ${API_KEY_ENV}`);
119
+ const client = new ApiClient({ baseUrl: context.apiBase, apiKey: key });
120
+ const me = await client.me();
121
+ const period = me.data.plan.period;
122
+ let remaining = me.quota.remaining ?? me.data.quota.remaining;
123
+ const onQuota = (quota) => {
124
+ if (quota.remaining !== null)
125
+ remaining = quota.remaining;
126
+ };
127
+ const files = await collectFiles(options.paths, options.recursive);
128
+ if (files.length === 0) {
129
+ throw new UsageError("没有找到可压缩的图片(支持 PNG / JPEG / GIF)");
130
+ }
131
+ assertNoTargetConflicts(files, options);
132
+ const nameWidth = Math.max(...files.map((file) => file.name.length));
133
+ const printOutcome = (outcome) => {
134
+ const name = outcome.file.name.padEnd(nameWidth);
135
+ const pair = formatSizePair(outcome.originalSize, outcome.compressedSize);
136
+ const original = pair.original.padStart(SIZE_COLUMN);
137
+ if (!outcome.ok) {
138
+ context.io.write(` ${name} ${original} → 失败:${outcome.error ?? "压缩失败"}\n`);
139
+ return;
140
+ }
141
+ const compressed = pair.compressed.padStart(SIZE_COLUMN);
142
+ if (outcome.retained) {
143
+ context.io.write(` ${name} ${original} → ${compressed} 无收益,保留原图\n`);
144
+ return;
145
+ }
146
+ const percent = savingsPercent(outcome.originalSize, outcome.compressedSize);
147
+ context.io.write(` ${name} ${original} → ${compressed} ${formatSavings(percent)}\n`);
148
+ };
149
+ const processOne = async (file) => {
150
+ const originalSize = (await stat(file.path)).size;
151
+ try {
152
+ const upload = await client.uploadImage(file.path);
153
+ onQuota(upload.quota);
154
+ const view = await waitForCompletion(client, upload.data.task_id, onQuota);
155
+ if (view.status !== "completed" || view.compressed_url === null) {
156
+ return { file, ok: false, retained: false, originalSize, compressedSize: 0, error: view.error_msg ?? "压缩失败" };
157
+ }
158
+ if (options.inPlace && view.compressed_size !== null && view.compressed_size >= originalSize) {
159
+ return { file, ok: true, retained: true, originalSize, compressedSize: view.compressed_size, error: null };
160
+ }
161
+ const bytes = await client.download(view.compressed_url);
162
+ const compressedSize = view.compressed_size ?? bytes.byteLength;
163
+ if (options.inPlace && compressedSize >= originalSize) {
164
+ return { file, ok: true, retained: true, originalSize, compressedSize, error: null };
165
+ }
166
+ const target = outputPathFor(file, options);
167
+ await mkdir(dirname(target), { recursive: true });
168
+ await writeFileAtomic(target, bytes);
169
+ return { file, ok: true, retained: false, originalSize, compressedSize, error: null };
170
+ }
171
+ catch (error) {
172
+ return { file, ok: false, retained: false, originalSize, compressedSize: 0, error: describeError(error) };
173
+ }
174
+ };
175
+ const outcomes = [];
176
+ await runPool(files, options.concurrency, async (file) => {
177
+ const outcome = await processOne(file);
178
+ outcomes.push(outcome);
179
+ printOutcome(outcome);
180
+ });
181
+ const succeeded = outcomes.filter((outcome) => outcome.ok);
182
+ const failed = outcomes.filter((outcome) => !outcome.ok);
183
+ const retained = outcomes.filter((outcome) => outcome.retained);
184
+ const saved = succeeded.reduce((total, outcome) => outcome.retained ? total : total + Math.max(0, outcome.originalSize - outcome.compressedSize), 0);
185
+ const parts = [
186
+ `本次 ${succeeded.length} 张`,
187
+ `节省 ${formatBytes(saved)}`,
188
+ `${periodNoun(period)}剩余 ${remaining} 次`,
189
+ ];
190
+ if (retained.length > 0)
191
+ parts.push(`${retained.length} 张无收益保留原图`);
192
+ if (failed.length > 0)
193
+ parts.push(`${failed.length} 张失败`);
194
+ context.io.write(` ${parts.join(",")}\n`);
195
+ return failed.length > 0 || succeeded.length === 0 ? 1 : 0;
196
+ };
@@ -0,0 +1,21 @@
1
+ import { clientFor } from "../context.js";
2
+ import { API_KEY_ENV, envApiKey, writeStoredKey } from "../config.js";
3
+ import { UsageError } from "../errors.js";
4
+ import { periodNoun } from "../format.js";
5
+ export const loginCommand = async (context) => {
6
+ const fromEnv = envApiKey(context.env);
7
+ const key = fromEnv ?? (await context.io.promptSecret(" 粘贴你的 API Key: ")).trim();
8
+ if (key === "")
9
+ throw new UsageError("API Key 不能为空");
10
+ const { data } = await clientFor(context, key).me();
11
+ const who = data.email ?? "当前账号";
12
+ const label = periodNoun(data.plan.period);
13
+ if (fromEnv) {
14
+ context.io.write(` 环境变量 ${API_KEY_ENV} 校验通过 · ${who} · ${label}剩余 ${data.quota.remaining} 次(未写入本机配置)\n`);
15
+ }
16
+ else {
17
+ await writeStoredKey(key, context.env);
18
+ context.io.write(` 已登录 ${who} · ${label}剩余 ${data.quota.remaining} 次\n`);
19
+ }
20
+ return 0;
21
+ };
@@ -0,0 +1,6 @@
1
+ import { clearStoredKey } from "../config.js";
2
+ export const logoutCommand = async (context) => {
3
+ const cleared = await clearStoredKey(context.env);
4
+ context.io.write(cleared ? " 已退出登录,本机 API Key 已清除\n" : " 本机没有保存 API Key\n");
5
+ return 0;
6
+ };
@@ -0,0 +1,15 @@
1
+ import { API_KEY_ENV, resolveApiKey } from "../config.js";
2
+ import { clientFor } from "../context.js";
3
+ import { UsageError } from "../errors.js";
4
+ import { formatResetDate, periodNoun } from "../format.js";
5
+ export const usageCommand = async (context) => {
6
+ const key = await resolveApiKey(context.env);
7
+ if (!key)
8
+ throw new UsageError(`未登录,请先运行 lubanpng login 或设置 ${API_KEY_ENV}`);
9
+ const { data } = await clientFor(context, key).me();
10
+ const label = periodNoun(data.plan.period);
11
+ const reset = formatResetDate(data.quota.resets_at);
12
+ const tail = reset === "" ? "" : ` · ${reset}重置`;
13
+ context.io.write(` ${data.plan.name}套餐 · ${label}已用 ${data.quota.used} / ${data.quota.limit}${tail}\n`);
14
+ return 0;
15
+ };
package/dist/config.js ADDED
@@ -0,0 +1,46 @@
1
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ export const API_KEY_ENV = "LUBANPNG_API_KEY";
5
+ export const configDir = (env = process.env) => {
6
+ if (process.platform === "win32") {
7
+ const appData = env.APPDATA?.trim();
8
+ return join(appData && appData !== "" ? appData : join(homedir(), "AppData", "Roaming"), "lubanpng");
9
+ }
10
+ const base = env.XDG_CONFIG_HOME?.trim();
11
+ return join(base && base !== "" ? base : join(homedir(), ".config"), "lubanpng");
12
+ };
13
+ export const configPath = (env = process.env) => join(configDir(env), "config.json");
14
+ export const readStoredKey = async (env = process.env) => {
15
+ try {
16
+ const raw = await readFile(configPath(env), "utf8");
17
+ const parsed = JSON.parse(raw);
18
+ const key = typeof parsed.api_key === "string" ? parsed.api_key.trim() : "";
19
+ return key === "" ? null : key;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ };
25
+ export const writeStoredKey = async (key, env = process.env) => {
26
+ const dir = configDir(env);
27
+ await mkdir(dir, { recursive: true, mode: 0o700 });
28
+ await writeFile(configPath(env), `${JSON.stringify({ api_key: key }, null, 2)}\n`, {
29
+ encoding: "utf8",
30
+ mode: 0o600,
31
+ });
32
+ };
33
+ export const clearStoredKey = async (env = process.env) => {
34
+ try {
35
+ await rm(configPath(env));
36
+ return true;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ };
42
+ export const envApiKey = (env = process.env) => {
43
+ const value = env[API_KEY_ENV]?.trim();
44
+ return value && value !== "" ? value : null;
45
+ };
46
+ export const resolveApiKey = async (env = process.env) => envApiKey(env) ?? (await readStoredKey(env));
@@ -0,0 +1,2 @@
1
+ import { ApiClient as Client } from "./api.js";
2
+ export const clientFor = (context, apiKey) => new Client({ baseUrl: context.apiBase, apiKey });
package/dist/errors.js ADDED
@@ -0,0 +1,23 @@
1
+ export class UsageError extends Error {
2
+ exitCode = 2;
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = "UsageError";
6
+ }
7
+ }
8
+ export class ApiError extends Error {
9
+ status;
10
+ code;
11
+ constructor(status, code, message) {
12
+ super(message);
13
+ this.name = "ApiError";
14
+ this.status = status;
15
+ this.code = code;
16
+ }
17
+ }
18
+ export class CancelledError extends Error {
19
+ constructor() {
20
+ super("已取消");
21
+ this.name = "CancelledError";
22
+ }
23
+ }
package/dist/format.js ADDED
@@ -0,0 +1,52 @@
1
+ const KIB = 1024;
2
+ const MIB = KIB * 1024;
3
+ const GIB = MIB * 1024;
4
+ export const unitFor = (bytes) => {
5
+ const safe = Math.max(0, bytes);
6
+ if (safe >= GIB)
7
+ return "GB";
8
+ if (safe >= MIB)
9
+ return "MB";
10
+ if (safe >= KIB)
11
+ return "KB";
12
+ return "B";
13
+ };
14
+ export const formatBytesIn = (bytes, unit) => {
15
+ const safe = Math.max(0, bytes);
16
+ if (unit === "B")
17
+ return `${Math.round(safe)} B`;
18
+ const divisor = unit === "GB" ? GIB : unit === "MB" ? MIB : KIB;
19
+ const value = safe / divisor;
20
+ return `${value.toFixed(value < 10 ? 2 : 0)} ${unit}`;
21
+ };
22
+ export const formatBytes = (bytes) => formatBytesIn(bytes, unitFor(bytes));
23
+ export const formatSizePair = (original, compressed) => {
24
+ const unit = unitFor(original);
25
+ return {
26
+ original: formatBytesIn(original, unit),
27
+ compressed: formatBytesIn(compressed, unit),
28
+ };
29
+ };
30
+ export const savingsPercent = (original, compressed) => {
31
+ if (original <= 0)
32
+ return 0;
33
+ return Math.max(0, Math.round((1 - compressed / original) * 100));
34
+ };
35
+ export const formatSavings = (percent) => `-${percent}%`;
36
+ const SHANGHAI = "Asia/Shanghai";
37
+ export const formatResetDate = (iso) => {
38
+ const date = new Date(iso);
39
+ if (Number.isNaN(date.getTime()))
40
+ return "";
41
+ const parts = new Intl.DateTimeFormat("en-US", {
42
+ timeZone: SHANGHAI,
43
+ month: "numeric",
44
+ day: "numeric",
45
+ }).formatToParts(date);
46
+ const month = parts.find((part) => part.type === "month")?.value ?? "";
47
+ const day = parts.find((part) => part.type === "day")?.value ?? "";
48
+ if (month === "" || day === "")
49
+ return "";
50
+ return `${Number(month)} 月 ${Number(day)} 日`;
51
+ };
52
+ export const periodNoun = (period) => (period === "day" ? "今日" : "本月");
package/dist/help.js ADDED
@@ -0,0 +1,33 @@
1
+ import { DEFAULT_API_BASE, API_BASE_ENV, USER_AGENT } from "./api.js";
2
+ import { API_KEY_ENV } from "./config.js";
3
+ import { VERSION } from "./version.js";
4
+ export const HELP = [
5
+ `lubanpng ${VERSION} — LubanPNG 命令行图片压缩`,
6
+ "",
7
+ "用法:",
8
+ " lubanpng <命令> [选项]",
9
+ "",
10
+ "命令:",
11
+ " login 粘贴 API Key,校验后保存到本机",
12
+ " logout 清除本机保存的 API Key",
13
+ " compress <路径...> 压缩文件或目录",
14
+ " usage 查看套餐、本期用量与重置时间",
15
+ "",
16
+ "全局选项:",
17
+ ` --api-base <url> API 地址(默认 ${DEFAULT_API_BASE})`,
18
+ " -h, --help 显示帮助",
19
+ " -v, --version 显示版本",
20
+ "",
21
+ "compress 选项:",
22
+ " --out <dir> 输出目录,保持输入目录结构",
23
+ " --in-place 覆盖原文件",
24
+ " --recursive 递归处理目录",
25
+ " --concurrency <n> 并发数(默认 4,最大 16)",
26
+ "",
27
+ "环境变量:",
28
+ ` ${API_KEY_ENV} API Key(优先于本机配置)`,
29
+ ` ${API_BASE_ENV} API 地址覆盖`,
30
+ "",
31
+ `User-Agent: ${USER_AGENT}`,
32
+ "",
33
+ ].join("\n");
package/dist/io.js ADDED
@@ -0,0 +1,58 @@
1
+ import { createInterface } from "node:readline";
2
+ import { CancelledError } from "./errors.js";
3
+ const readVisibleLine = (label) => new Promise((resolve) => {
4
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
5
+ rl.question(label, (answer) => {
6
+ rl.close();
7
+ resolve(answer);
8
+ });
9
+ });
10
+ const readHiddenLine = (label) => new Promise((resolve, reject) => {
11
+ const stdin = process.stdin;
12
+ const stdout = process.stdout;
13
+ stdout.write(label);
14
+ stdin.setRawMode(true);
15
+ stdin.resume();
16
+ stdin.setEncoding("utf8");
17
+ let value = "";
18
+ const cleanup = () => {
19
+ stdin.setRawMode(false);
20
+ stdin.pause();
21
+ stdin.off("data", onData);
22
+ };
23
+ const onData = (chunk) => {
24
+ for (const char of chunk) {
25
+ if (char === "\r" || char === "\n") {
26
+ cleanup();
27
+ stdout.write("\n");
28
+ resolve(value);
29
+ return;
30
+ }
31
+ if (char === "\u0003") {
32
+ cleanup();
33
+ stdout.write("\n");
34
+ reject(new CancelledError());
35
+ return;
36
+ }
37
+ if (char === "\u007f" || char === "\b") {
38
+ if (value !== "") {
39
+ value = value.slice(0, -1);
40
+ stdout.write("\b \b");
41
+ }
42
+ continue;
43
+ }
44
+ value += char;
45
+ stdout.write("*");
46
+ }
47
+ };
48
+ stdin.on("data", onData);
49
+ });
50
+ export const defaultIo = {
51
+ write: (text) => {
52
+ process.stdout.write(text);
53
+ },
54
+ writeError: (text) => {
55
+ process.stderr.write(text);
56
+ },
57
+ promptSecret: (label) => process.stdin.isTTY ? readHiddenLine(label) : readVisibleLine(label),
58
+ };
package/dist/main.js ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "./run.js";
3
+ run(process.argv.slice(2)).then((code) => {
4
+ process.exitCode = code;
5
+ });
package/dist/run.js ADDED
@@ -0,0 +1,61 @@
1
+ import { API_BASE_ENV, DEFAULT_API_BASE, describeError } from "./api.js";
2
+ import { parseArgv } from "./args.js";
3
+ import { compressCommand } from "./commands/compress.js";
4
+ import { loginCommand } from "./commands/login.js";
5
+ import { logoutCommand } from "./commands/logout.js";
6
+ import { usageCommand } from "./commands/usage.js";
7
+ import { CancelledError, UsageError } from "./errors.js";
8
+ import { HELP } from "./help.js";
9
+ import { defaultIo } from "./io.js";
10
+ import { VERSION } from "./version.js";
11
+ export const run = async (argv, options = {}) => {
12
+ const env = options.env ?? process.env;
13
+ const io = options.io ?? defaultIo;
14
+ try {
15
+ const parsed = parseArgv(argv);
16
+ if (parsed.command === "help") {
17
+ io.write(HELP);
18
+ return 0;
19
+ }
20
+ if (parsed.command === "version") {
21
+ io.write(`${VERSION}\n`);
22
+ return 0;
23
+ }
24
+ const configured = parsed.apiBase ?? env[API_BASE_ENV]?.trim();
25
+ const context = {
26
+ apiBase: configured && configured !== "" ? configured : DEFAULT_API_BASE,
27
+ env,
28
+ io,
29
+ };
30
+ switch (parsed.command) {
31
+ case "login":
32
+ return await loginCommand(context);
33
+ case "logout":
34
+ return await logoutCommand(context);
35
+ case "usage":
36
+ return await usageCommand(context);
37
+ case "compress":
38
+ return await compressCommand(context, {
39
+ paths: parsed.paths,
40
+ out: parsed.out,
41
+ inPlace: parsed.inPlace,
42
+ recursive: parsed.recursive,
43
+ concurrency: parsed.concurrency,
44
+ });
45
+ }
46
+ return 1;
47
+ }
48
+ catch (error) {
49
+ if (error instanceof UsageError) {
50
+ io.writeError(`${error.message}\n`);
51
+ io.writeError("运行 lubanpng --help 查看用法\n");
52
+ return error.exitCode;
53
+ }
54
+ if (error instanceof CancelledError) {
55
+ io.writeError("已取消\n");
56
+ return 130;
57
+ }
58
+ io.writeError(`${describeError(error)}\n`);
59
+ return 1;
60
+ }
61
+ };
@@ -0,0 +1,4 @@
1
+ import { createRequire } from "node:module";
2
+ const require = createRequire(import.meta.url);
3
+ const pkg = require("../package.json");
4
+ export const VERSION = pkg.version;
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "lubanpng",
3
+ "version": "0.1.0",
4
+ "description": "LubanPNG command line image compressor",
5
+ "type": "module",
6
+ "bin": {
7
+ "lubanpng": "dist/main.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=24"
14
+ },
15
+ "scripts": {
16
+ "build": "tsc -p tsconfig.json && node -e \"require('node:fs').chmodSync('dist/main.js', 0o755)\"",
17
+ "test": "vitest run",
18
+ "lint": "tsc --noEmit -p tsconfig.json",
19
+ "prepack": "npm run build"
20
+ },
21
+ "keywords": [
22
+ "lubanpng",
23
+ "image",
24
+ "compress",
25
+ "tinypng",
26
+ "cli"
27
+ ],
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/Curzibn/LubanPNG.git",
32
+ "directory": "packages/cli"
33
+ },
34
+ "homepage": "https://lubanpng.wizthink.cn",
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "22.20.2",
40
+ "typescript": "7.0.2",
41
+ "vitest": "5.0.0"
42
+ }
43
+ }