getwebp 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.
Files changed (3) hide show
  1. package/README.md +318 -0
  2. package/dist/index.js +927 -0
  3. package/package.json +41 -0
package/README.md ADDED
@@ -0,0 +1,318 @@
1
+ # GetWebP CLI
2
+
3
+ 一个命令行图片转换工具,将 JPG、PNG、BMP、WebP 批量转为 WebP 格式,支持 Free 和 Pro 两种模式。
4
+
5
+ 官网:[https://getwebp.com](https://getwebp.com) · 定价:[https://getwebp.com/pricing](https://getwebp.com/pricing)
6
+
7
+ ---
8
+
9
+ ## 安装
10
+
11
+ ### 下载二进制文件
12
+
13
+ | 平台 | 下载地址 |
14
+ |------|----------|
15
+ | macOS Apple Silicon (M1/M2/M3) | `getwebp-macos-arm64` |
16
+ | macOS Intel | `getwebp-macos-x64` |
17
+ | Linux x64 | `getwebp-linux-x64` |
18
+ | Windows x64 | `getwebp-windows-x64.exe` |
19
+
20
+ 从 [Releases 页面](https://getwebp.com/download) 下载对应平台的二进制文件。
21
+
22
+ ### macOS / Linux 安装
23
+
24
+ ```bash
25
+ # 重命名并赋予执行权限
26
+ mv getwebp-macos-arm64 getwebp # Apple Silicon
27
+ # mv getwebp-macos-x64 getwebp # Intel Mac
28
+ # mv getwebp-linux-x64 getwebp # Linux
29
+
30
+ chmod +x getwebp
31
+
32
+ # 移动到 PATH 目录(需要 sudo)
33
+ sudo mv getwebp /usr/local/bin/
34
+
35
+ # 验证安装
36
+ getwebp --version
37
+ ```
38
+
39
+ ### Windows 安装
40
+
41
+ 将 `getwebp-windows-x64.exe` 重命名为 `getwebp.exe`,然后放到任意已加入 `PATH` 的目录(如 `C:\Windows\System32`),或手动将其所在目录添加到系统环境变量 `PATH`。
42
+
43
+ ### macOS Gatekeeper 处理
44
+
45
+ macOS 可能会阻止运行未签名的二进制文件,执行以下命令移除隔离属性:
46
+
47
+ ```bash
48
+ xattr -cr getwebp
49
+ ```
50
+
51
+ ---
52
+
53
+ ## 安全验证
54
+
55
+ 每个 Release 都附带 SHA256 校验和文件(`*.sha256`),建议安装前验证:
56
+
57
+ ### macOS / Linux
58
+
59
+ ```bash
60
+ # 下载二进制和校验和文件
61
+ curl -LO https://github.com/getwebp/cli/releases/download/v2.1.0/getwebp-macos-arm64
62
+ curl -LO https://github.com/getwebp/cli/releases/download/v2.1.0/getwebp-macos-arm64.sha256
63
+
64
+ # 验证
65
+ shasum -a 256 -c getwebp-macos-arm64.sha256
66
+ # 输出:getwebp-macos-arm64: OK
67
+ ```
68
+
69
+ ### Windows (PowerShell)
70
+
71
+ ```powershell
72
+ # 下载
73
+ Invoke-WebRequest -Uri "https://github.com/getwebp/cli/releases/download/v2.1.0/getwebp-win-x64.exe" -OutFile getwebp.exe
74
+ Invoke-WebRequest -Uri "https://github.com/getwebp/cli/releases/download/v2.1.0/getwebp-win-x64.exe.sha256" -OutFile getwebp.exe.sha256
75
+
76
+ # 验证
77
+ $expected = (Get-Content getwebp.exe.sha256).Split(' ')[0]
78
+ $actual = (Get-FileHash getwebp.exe -Algorithm SHA256).Hash.ToLower()
79
+ if ($expected -eq $actual) { Write-Host "✓ 验证通过" } else { Write-Host "✗ 验证失败" }
80
+ ```
81
+
82
+ ---
83
+
84
+ ## 命令用法
85
+
86
+ ### `getwebp convert` — 转换图片
87
+
88
+ ```
89
+ getwebp convert [path] [options]
90
+ ```
91
+
92
+ **选项:**
93
+
94
+ | 选项 | 说明 | 默认值 |
95
+ |------|------|--------|
96
+ | `-i, --input <path>` | 输入目录或文件 | — |
97
+ | `-o, --output <path>` | 输出目录 | 原地覆盖 |
98
+ | `-q, --quality <number>` | 质量 1-100 | 80 |
99
+ | `--concurrency <number>` | 并发处理数(Pro only) | 4 |
100
+ | `-r, --recursive` | 递归处理子目录中的图片(Starter/Pro only) | — |
101
+ | `--dry-run` | 预览模式,不实际转换 | — |
102
+ | `--skip-existing` | 跳过已存在的 .webp 文件 | — |
103
+ | `--json` | JSON 格式输出(适合 AI / CI) | — |
104
+
105
+ **示例:**
106
+
107
+ ```bash
108
+ # 转换单张图片
109
+ getwebp convert photo.jpg
110
+
111
+ # 转换整个目录(递归)
112
+ getwebp convert ./images
113
+
114
+ # 指定输入目录和输出目录
115
+ getwebp convert -i ./src/images -o ./dist/images
116
+
117
+ # 设置质量为 90(Pro)
118
+ getwebp convert ./images -q 90
119
+
120
+ # 预览将会转换的文件(不实际执行)
121
+ getwebp convert ./images --dry-run
122
+
123
+ # 跳过已存在的 WebP 文件
124
+ getwebp convert ./images --skip-existing
125
+
126
+ # 递归处理子目录中的图片(Starter/Pro)
127
+ getwebp convert ./images --recursive
128
+
129
+ # 并发 8 线程处理(Pro)
130
+ getwebp convert ./images --concurrency 8
131
+
132
+ # JSON 输出,适合 CI/CD 流水线
133
+ getwebp convert ./images --json
134
+
135
+ # 组合选项:高质量 + 跳过已有文件 + JSON 输出
136
+ getwebp convert -i ./images -o ./output -q 90 --skip-existing --json
137
+ ```
138
+
139
+ ---
140
+
141
+ ### `getwebp auth` — 激活 Pro 授权
142
+
143
+ ```bash
144
+ getwebp auth <license-key>
145
+ ```
146
+
147
+ **示例:**
148
+
149
+ ```bash
150
+ # 使用许可证 Key 激活 Pro
151
+ getwebp auth XXXX-XXXX-XXXX-XXXX
152
+
153
+ # 激活成功后会显示账号信息和到期时间
154
+ ```
155
+
156
+ 购买 Pro 许可证请访问:[https://getwebp.com/pricing](https://getwebp.com/pricing)
157
+
158
+ ---
159
+
160
+ ### `getwebp status` — 查看授权状态
161
+
162
+ ```bash
163
+ # 查看当前激活状态、许可证信息和到期时间
164
+ getwebp status
165
+
166
+ # 以 JSON 格式输出(适合脚本解析)
167
+ getwebp status --json
168
+ ```
169
+
170
+ ---
171
+
172
+ ### `getwebp logout` — 退出登录
173
+
174
+ ```bash
175
+ # 清除本地存储的许可证信息
176
+ getwebp logout
177
+ ```
178
+
179
+ ---
180
+
181
+ ## JSON 输出格式参考
182
+
183
+ 所有命令均支持 `--json` 参数,适用于 AI Agent 和 CI/CD 脚本。
184
+
185
+ **成功响应**(`success: true`):
186
+ ```json
187
+ { "success": true, "data": { ... } }
188
+ ```
189
+
190
+ **错误响应**(`success: false`):
191
+ ```json
192
+ { "success": false, "status": "error", "error": "<error-code>", "message": "<human-readable>" }
193
+ ```
194
+
195
+ **常见错误码:**
196
+
197
+ | error 字段 | 含义 | 是否可重试 |
198
+ |------------|------|-----------|
199
+ | `missing_input` | 未指定输入路径 | — |
200
+ | `network_unreachable` | 无法连接服务器 | ✅ |
201
+ | `not_activated` | 未激活 Pro 许可证 | — |
202
+ | `device_not_found` | 设备未找到(可能已解绑) | — |
203
+ | `invalid_token` | Token 无效或已过期 | — |
204
+ | `unknown_error` | 未知错误 | ✅ |
205
+
206
+ **convert 成功示例:**
207
+ ```json
208
+ { "status": "success", "total": 3, "success": 3, "failed": 0, "results": [...] }
209
+ ```
210
+
211
+ **convert 错误示例(缺少路径):**
212
+ ```json
213
+ { "success": false, "status": "error", "error": "missing_input", "message": "Please specify an input path" }
214
+ ```
215
+
216
+ > 注意:`getwebp logout --json` 在脚本中使用时请同时加 `--force`,否则会等待交互式确认。
217
+
218
+ ---
219
+
220
+ ## 退出码
221
+
222
+ | 退出码 | 含义 |
223
+ |--------|------|
224
+ | `0` | 成功 |
225
+ | `1` | 失败(详见 stderr 或 JSON 的 `error` 字段) |
226
+
227
+ 使用 `--json` 模式时,通过 `error` 字段区分可重试错误(如 `network_unreachable`)和永久性错误(如 `missing_input`、`not_activated`)。
228
+
229
+ ---
230
+
231
+ ## Free vs Pro
232
+
233
+ | 功能 | Free | Pro |
234
+ |------|:----:|:---:|
235
+ | 每次最多处理张数 | 10 张 | 无限制 |
236
+ | 处理速度 | 每张等待 3 秒 | 无限制 |
237
+ | 并发处理 | ❌ | ✅(最多 4 并发) |
238
+ | 递归目录(`-r`) | ❌ | ✅ |
239
+ | 自定义质量(`-q`) | ✅ | ✅ |
240
+ | JSON 输出(`--json`) | ✅ | ✅ |
241
+
242
+ 升级 Pro:[https://getwebp.com/pricing](https://getwebp.com/pricing)
243
+
244
+ ---
245
+
246
+ ## FAQ
247
+
248
+ **Q:支持哪些图片格式?**
249
+
250
+ A:支持 JPG、PNG、BMP、WebP 作为输入格式,统一输出为 WebP。
251
+
252
+ ---
253
+
254
+ **Q:macOS 提示"无法打开,因为无法验证开发者"怎么办?**
255
+
256
+ A:在终端执行以下命令移除隔离属性,然后重试:
257
+
258
+ ```bash
259
+ xattr -cr getwebp
260
+ ```
261
+
262
+ ---
263
+
264
+ **Q:如何激活 Pro?**
265
+
266
+ A:在 [getwebp.com/pricing](https://getwebp.com/pricing) 购买许可证后,运行:
267
+
268
+ ```bash
269
+ getwebp auth <你的许可证 Key>
270
+ ```
271
+
272
+ 激活成功后即可解锁所有 Pro 功能。
273
+
274
+ ---
275
+
276
+ **Q:Free 模式有什么限制?**
277
+
278
+ A:Free 模式每次最多转换 10 张图片,且每张图片处理前会等待 3 秒。不支持并发处理和递归子目录。升级 Pro 可解锁全部功能。
279
+
280
+ ---
281
+
282
+ **Q:如何在 CI/CD 流水线中使用?**
283
+
284
+ A:使用 `--json` 参数可以获得结构化的 JSON 输出,方便脚本解析:
285
+
286
+ ```bash
287
+ getwebp convert ./images --json
288
+ ```
289
+
290
+ 配合 Pro 许可证,可以在 CI 环境中批量处理大量图片,无数量和速度限制。
291
+
292
+ ---
293
+
294
+ **Q:支持退款吗?**
295
+
296
+ A:购买后如有问题,请联系 [getwebp.com](https://getwebp.com/contact) 客服,我们提供合理的退款支持。
297
+
298
+ ---
299
+
300
+ **Q:许可证可以在多台机器上使用吗?**
301
+
302
+ A:许可证与设备绑定,具体设备数量限制请参考购买页面的套餐说明:[https://getwebp.com/pricing](https://getwebp.com/pricing)
303
+
304
+ ---
305
+
306
+ **Q:如何查看当前版本?**
307
+
308
+ ```bash
309
+ getwebp --version
310
+ ```
311
+
312
+ ---
313
+
314
+ ## 版本
315
+
316
+ 当前版本:**2.1.0**
317
+
318
+ 查看更新日志:[https://getwebp.com/changelog](https://getwebp.com/changelog)
package/dist/index.js ADDED
@@ -0,0 +1,927 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/index.ts
4
+ import { cac } from "cac";
5
+ import chalk2 from "chalk";
6
+
7
+ // src/core/license.ts
8
+ import jwt from "jsonwebtoken";
9
+
10
+ // src/core/config.ts
11
+ import Conf from "conf";
12
+ import { machineIdSync } from "node-machine-id";
13
+ import crypto from "node:crypto";
14
+ import os from "node:os";
15
+ function getMachineKey() {
16
+ try {
17
+ const id = machineIdSync();
18
+ return crypto.createHash("sha256").update(id).digest("hex");
19
+ } catch {
20
+ process.stderr.write(
21
+ "warn: Could not read machine ID, falling back to hostname.\n Token may become invalid if hostname changes.\n"
22
+ );
23
+ return crypto.createHash("sha256").update(`${os.hostname()}:${os.userInfo().username}`).digest("hex");
24
+ }
25
+ }
26
+ var store = new Conf({
27
+ projectName: "getwebp",
28
+ encryptionKey: getMachineKey(),
29
+ ...process.env.GETWEBP_CONFIG_DIR ? { cwd: process.env.GETWEBP_CONFIG_DIR } : {}
30
+ });
31
+ function getConfig() {
32
+ return store.store;
33
+ }
34
+ function saveConfig(data) {
35
+ for (const [key, value] of Object.entries(data)) {
36
+ if (value === void 0) {
37
+ store.delete(key);
38
+ } else {
39
+ store.set(key, value);
40
+ }
41
+ }
42
+ }
43
+
44
+ // src/core/device.ts
45
+ import { machineId } from "node-machine-id";
46
+ import crypto2 from "node:crypto";
47
+ async function getDeviceId() {
48
+ const id = await machineId();
49
+ return crypto2.createHash("sha256").update(id).digest("hex");
50
+ }
51
+
52
+ // src/core/constants.ts
53
+ import os2 from "node:os";
54
+ var FREE_TIER = {
55
+ FILE_LIMIT: 10,
56
+ DELAY_MS: 3e3
57
+ };
58
+ var DEFAULTS = {
59
+ QUALITY: 80,
60
+ CONCURRENCY: Math.max(1, os2.cpus().length - 1)
61
+ };
62
+ var NETWORK = {
63
+ HEARTBEAT_TIMEOUT_MS: 3e3,
64
+ API_TIMEOUT_MS: 5e3,
65
+ DRAIN_TIMEOUT_MS: 3e3
66
+ };
67
+
68
+ // src/core/heartbeat.ts
69
+ var _promise = null;
70
+ var API_BASE_URL = process.env.API_BASE_URL ?? "https://api.getwebp.com";
71
+ function pingHeartbeat(token) {
72
+ const p = fetch(`${API_BASE_URL}/v1/ping`, {
73
+ method: "POST",
74
+ headers: { Authorization: `Bearer ${token}` },
75
+ signal: AbortSignal.timeout(NETWORK.HEARTBEAT_TIMEOUT_MS)
76
+ }).then(async (res) => {
77
+ if (res.status === 401 || res.status === 403) {
78
+ try {
79
+ saveConfig({ token: void 0 });
80
+ } catch {
81
+ }
82
+ }
83
+ }).catch(() => {
84
+ }).finally(() => {
85
+ if (_promise === p) _promise = null;
86
+ });
87
+ _promise = p;
88
+ }
89
+ function drainHeartbeat() {
90
+ if (!_promise) return Promise.resolve();
91
+ return new Promise((resolve) => {
92
+ const timer = setTimeout(resolve, NETWORK.DRAIN_TIMEOUT_MS);
93
+ _promise.then(() => {
94
+ clearTimeout(timer);
95
+ resolve();
96
+ });
97
+ });
98
+ }
99
+
100
+ // src/core/license.ts
101
+ var API_BASE_URL2 = process.env.API_BASE_URL ?? "https://api.getwebp.com";
102
+ function getPublicKey() {
103
+ const key = "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAz41vwWfBT+qVpqXGz7sL\nzev2A4XL6Kp62fYM1vgzUUvZMfmKY9tMa2FYgasmh+l/J+5wa310ZZV9kp4cxhKc\nGC+lgTTL/sfauyzEjtY3ZDsYON0LOA6TPGYwIppsnmgYT5PS8lIsDL1gTaaskfoy\nTz3blsLS73DX6duqJxXLZxUlXrq9OdjQj9C8OYovNXgXDCc6miexDo70TCi0oHQq\n0tpKTngmAoGsGodK+7d2Etan5n8JjCQ6LnF+g8la1k1gwcyLLaTrK3cy3fGP4gAL\nDdmkyd8ELpUrebBTRtpE9Fyk979Qs4gwghx6ecpRgd3WT354/414s6K4OsElyTyT\npwIDAQAB\n-----END PUBLIC KEY-----\n";
104
+ if (!key) throw new Error("JWT_PUBLIC_KEY was not injected at build time");
105
+ return key;
106
+ }
107
+ async function activate(licenseKey) {
108
+ try {
109
+ const deviceId = await getDeviceId();
110
+ const res = await fetch(`${API_BASE_URL2}/v1/activate`, {
111
+ method: "POST",
112
+ headers: { "Content-Type": "application/json" },
113
+ body: JSON.stringify({ licenseKey, deviceId })
114
+ });
115
+ if (!res.ok) {
116
+ const body = await res.json().catch(() => ({}));
117
+ return { success: false, error: body.error ?? body.message ?? `HTTP ${res.status}` };
118
+ }
119
+ const data = await res.json().catch(() => null);
120
+ const token = data?.token;
121
+ if (!token) return { success: false, error: "Unexpected response format from server" };
122
+ verifyToken(token);
123
+ saveConfig({ token });
124
+ return { success: true };
125
+ } catch (err) {
126
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
127
+ }
128
+ }
129
+ async function checkLicense() {
130
+ const config = getConfig();
131
+ if (!config?.token) return { valid: false, plan: "free" };
132
+ try {
133
+ const payload = verifyToken(config.token);
134
+ pingHeartbeat(config.token);
135
+ const exp = typeof payload === "object" && payload !== null && "exp" in payload ? payload.exp : void 0;
136
+ const rawPlan = typeof payload === "object" && payload !== null && "plan" in payload ? String(payload.plan) : "pro";
137
+ const plan = rawPlan === "starter" ? "starter" : "pro";
138
+ return {
139
+ valid: true,
140
+ plan,
141
+ expiresAt: exp ? new Date(exp * 1e3) : void 0
142
+ };
143
+ } catch (err) {
144
+ if (err instanceof Error && err.name === "TokenExpiredError") {
145
+ return { valid: false, plan: "free", expired: true };
146
+ }
147
+ if (err instanceof Error && err.message.includes("JWT_PUBLIC_KEY")) {
148
+ process.stderr.write("warn: JWT public key not found. License validation skipped.\n");
149
+ }
150
+ return { valid: false, plan: "free" };
151
+ }
152
+ }
153
+ async function fetchStatus() {
154
+ const config = getConfig();
155
+ if (!config?.token) return null;
156
+ try {
157
+ const res = await fetch(`${API_BASE_URL2}/v1/status`, {
158
+ headers: { Authorization: `Bearer ${config.token}` },
159
+ signal: AbortSignal.timeout(NETWORK.API_TIMEOUT_MS)
160
+ });
161
+ if (res.status === 401) {
162
+ try {
163
+ saveConfig({ token: void 0, statusCache: void 0 });
164
+ } catch {
165
+ }
166
+ return null;
167
+ }
168
+ if (!res.ok) return null;
169
+ const data = await res.json();
170
+ saveConfig({ statusCache: data });
171
+ return data;
172
+ } catch {
173
+ const cached = config.statusCache;
174
+ if (cached) return { ...cached, cached: true };
175
+ return null;
176
+ }
177
+ }
178
+ async function logout() {
179
+ const config = getConfig();
180
+ if (!config?.token) return { success: false, error: "not_activated" };
181
+ try {
182
+ const res = await fetch(`${API_BASE_URL2}/v1/unbind`, {
183
+ method: "POST",
184
+ headers: { Authorization: `Bearer ${config.token}` },
185
+ signal: AbortSignal.timeout(NETWORK.API_TIMEOUT_MS)
186
+ });
187
+ if (res.status === 404) return { success: false, error: "device_not_found" };
188
+ if (res.status === 401) return { success: false, error: "invalid_token" };
189
+ if (!res.ok) return { success: false, error: `HTTP ${res.status}` };
190
+ saveConfig({ token: void 0, statusCache: void 0 });
191
+ return { success: true };
192
+ } catch {
193
+ return { success: false, error: "network_unreachable" };
194
+ }
195
+ }
196
+ function verifyToken(token) {
197
+ return jwt.verify(token, getPublicKey(), { algorithms: ["RS256"] });
198
+ }
199
+
200
+ // src/core/exit-codes.ts
201
+ var ExitCode = {
202
+ Success: 0,
203
+ GenericError: 1,
204
+ PartialFailure: 2,
205
+ AuthError: 3,
206
+ NetworkError: 4
207
+ };
208
+ function resolveExitCode(results) {
209
+ if (results.length === 0) return ExitCode.Success;
210
+ const hasError = results.some((r) => r.status === "error");
211
+ return hasError ? ExitCode.PartialFailure : ExitCode.Success;
212
+ }
213
+
214
+ // src/core/processor.ts
215
+ import { encode as encodeWebp } from "@jsquash/webp";
216
+ import { unlinkSync, constants as fsConstants } from "node:fs";
217
+ import fs3 from "node:fs/promises";
218
+ import path3 from "node:path";
219
+ import pLimit from "p-limit";
220
+
221
+ // src/utils/debug.ts
222
+ var _verbose = false;
223
+ var _debug = false;
224
+ function setVerbose(v) {
225
+ _verbose = v;
226
+ }
227
+ function setDebug(v) {
228
+ _debug = v;
229
+ if (v) _verbose = true;
230
+ }
231
+ function debugLog(...args) {
232
+ if (_debug) process.stderr.write("[debug] " + args.join(" ") + "\n");
233
+ }
234
+
235
+ // src/core/codecs.ts
236
+ import { decode as decodeJpeg } from "@jsquash/jpeg";
237
+ import { decode as decodePng } from "@jsquash/png";
238
+ import { decode as decodeWebp } from "@jsquash/webp";
239
+ import Bmp from "bmp-js";
240
+ import fs from "node:fs/promises";
241
+ import path from "node:path";
242
+ var decoders = {
243
+ ".jpg": decodeJpeg,
244
+ ".jpeg": decodeJpeg,
245
+ ".png": decodePng,
246
+ ".webp": decodeWebp
247
+ };
248
+ var MAGIC_VALIDATORS = {
249
+ ".jpg": (b) => b[0] === 255 && b[1] === 216 && b[2] === 255,
250
+ ".jpeg": (b) => b[0] === 255 && b[1] === 216 && b[2] === 255,
251
+ ".png": (b) => b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71,
252
+ ".webp": (b) => b.length > 11 && b[8] === 87 && b[9] === 69 && b[10] === 66 && b[11] === 80,
253
+ ".bmp": (b) => b[0] === 66 && b[1] === 77
254
+ };
255
+ var BMP_MAX_DIMENSION = 65535;
256
+ async function decodeBmp(buffer) {
257
+ if (buffer.byteLength < 54) {
258
+ throw new Error("BMP file too small to contain a valid header");
259
+ }
260
+ const bmpWidth = buffer.readUInt32LE(18);
261
+ const bmpHeight = Math.abs(buffer.readInt32LE(22));
262
+ if (bmpWidth === 0 || bmpHeight === 0 || bmpWidth > BMP_MAX_DIMENSION || bmpHeight > BMP_MAX_DIMENSION) {
263
+ throw new Error(`BMP dimensions out of range: ${bmpWidth}x${bmpHeight}`);
264
+ }
265
+ const bmp = Bmp.decode(buffer);
266
+ const data = bmp.data;
267
+ for (let i = 0; i < data.length; i += 4) {
268
+ const tmp = data[i];
269
+ data[i] = data[i + 2];
270
+ data[i + 2] = tmp;
271
+ }
272
+ return new ImageData(new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength), bmp.width, bmp.height);
273
+ }
274
+ async function decodeImage(filePath) {
275
+ const ext = path.extname(filePath).toLowerCase();
276
+ const buffer = await fs.readFile(filePath);
277
+ if (buffer.byteLength === 0) {
278
+ throw new Error(`File is empty (0 bytes): ${filePath}`);
279
+ }
280
+ const validate = MAGIC_VALIDATORS[ext];
281
+ if (validate && !validate(buffer)) {
282
+ throw new Error(`File header does not match expected ${ext} format: ${filePath}`);
283
+ }
284
+ debugLog("decode", ext, filePath);
285
+ if (ext === ".bmp") {
286
+ try {
287
+ return await decodeBmp(buffer);
288
+ } catch (err) {
289
+ throw new Error(`Failed to decode ${filePath} as BMP: ${err instanceof Error ? err.message : String(err)}`);
290
+ }
291
+ }
292
+ const decoder = decoders[ext];
293
+ if (!decoder) {
294
+ throw new Error(`Unsupported format: ${ext}`);
295
+ }
296
+ const arrayBuffer = buffer.byteOffset === 0 && buffer.byteLength === buffer.buffer.byteLength ? buffer.buffer : buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
297
+ try {
298
+ return await decoder(arrayBuffer);
299
+ } catch (err) {
300
+ throw new Error(`Failed to decode ${filePath} as ${ext}: ${err instanceof Error ? err.message : String(err)}`);
301
+ }
302
+ }
303
+
304
+ // src/core/file-scanner.ts
305
+ import fs2 from "node:fs/promises";
306
+ import path2 from "node:path";
307
+ var IMAGE_EXTS = /* @__PURE__ */ new Set([".jpg", ".jpeg", ".png", ".bmp", ".webp"]);
308
+ async function collectImageFiles(input, recursive) {
309
+ const lstat = await fs2.lstat(input);
310
+ if (lstat.isSymbolicLink()) {
311
+ process.stderr.write(`Warning: ${input} is a symlink, skipping
312
+ `);
313
+ return [];
314
+ }
315
+ if (lstat.isFile()) return [input];
316
+ let entries;
317
+ try {
318
+ entries = await fs2.readdir(input, { withFileTypes: true });
319
+ } catch {
320
+ process.stderr.write(`Warning: cannot read directory ${input}, skipping
321
+ `);
322
+ return [];
323
+ }
324
+ entries.sort((a, b) => a.name.localeCompare(b.name));
325
+ const results = [];
326
+ const subdirPromises = [];
327
+ for (const entry of entries) {
328
+ const fullPath = path2.join(input, entry.name);
329
+ if (entry.isDirectory() && recursive && !entry.isSymbolicLink()) {
330
+ subdirPromises.push(collectImageFiles(fullPath, recursive));
331
+ } else if (entry.isFile() && !entry.isSymbolicLink() && IMAGE_EXTS.has(path2.extname(entry.name).toLowerCase())) {
332
+ results.push(fullPath);
333
+ }
334
+ }
335
+ const subResults = await Promise.all(subdirPromises);
336
+ results.push(...subResults.flat());
337
+ return results.sort();
338
+ }
339
+
340
+ // src/core/wasm-init.ts
341
+ import { readFileSync } from "node:fs";
342
+ import { createRequire } from "node:module";
343
+ import { init as initPngCodec } from "@jsquash/png/decode";
344
+ import { init as initJpegDec } from "@jsquash/jpeg/decode";
345
+ import { init as initWebpDec } from "@jsquash/webp/decode";
346
+ import { init as initWebpEnc } from "@jsquash/webp/encode";
347
+ var _require = createRequire(import.meta.url);
348
+ var initialized = false;
349
+ async function initWasm() {
350
+ if (initialized) return;
351
+ initialized = true;
352
+ const pngWasm = new WebAssembly.Module(readFileSync(_require.resolve("@jsquash/png/codec/pkg/squoosh_png_bg.wasm")));
353
+ await initPngCodec(pngWasm);
354
+ const jpegWasm = new WebAssembly.Module(readFileSync(_require.resolve("@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm")));
355
+ await initJpegDec(jpegWasm);
356
+ const webpDecWasm = new WebAssembly.Module(readFileSync(_require.resolve("@jsquash/webp/codec/dec/webp_dec.wasm")));
357
+ await initWebpDec(webpDecWasm);
358
+ const webpEncWasm = new WebAssembly.Module(readFileSync(_require.resolve("@jsquash/webp/codec/enc/webp_enc.wasm")));
359
+ await initWebpEnc(webpEncWasm);
360
+ }
361
+
362
+ // src/core/processor.ts
363
+ async function processImages(options) {
364
+ const { input, output, quality, plan, out } = options;
365
+ const isFree = plan === "free";
366
+ const dryRun = options.dryRun ?? false;
367
+ const skipExisting = options.skipExisting ?? false;
368
+ debugLog("processImages", input, {
369
+ plan,
370
+ concurrency: options.concurrency,
371
+ recursive: options.recursive,
372
+ dryRun,
373
+ skipExisting
374
+ });
375
+ try {
376
+ await fs3.access(input);
377
+ } catch {
378
+ throw new Error(`Path not found: ${input}`);
379
+ }
380
+ await initWasm();
381
+ const recursive = options.recursive ?? false;
382
+ const files = await collectImageFiles(input, recursive);
383
+ debugLog("collected", files.length, "files");
384
+ if (isFree && files.length > 0) {
385
+ out.warn("Free plan: max 10 files, 3s delay between each.");
386
+ out.warn("Upgrade at getwebp.com/pricing, then run: getwebp auth <your-key>");
387
+ }
388
+ const targets = isFree ? files.slice(0, FREE_TIER.FILE_LIMIT) : files;
389
+ const skipped = isFree ? Math.max(0, files.length - FREE_TIER.FILE_LIMIT) : 0;
390
+ const outputPaths = /* @__PURE__ */ new Map();
391
+ for (const file of targets) {
392
+ const dir = output ?? path3.dirname(file);
393
+ const outName = path3.basename(file, path3.extname(file)) + ".webp";
394
+ const outPath = path3.resolve(dir, outName);
395
+ const existing = outputPaths.get(outPath) ?? [];
396
+ existing.push(file);
397
+ outputPaths.set(outPath, existing);
398
+ }
399
+ for (const [outPath, sources] of outputPaths) {
400
+ if (sources.length > 1) {
401
+ out.warn(`Conflict: ${sources.join(", ")} all map to ${path3.basename(outPath)} \u2014 only the last processed will survive`);
402
+ }
403
+ }
404
+ if (!output && !dryRun) {
405
+ out.warn("No --output specified: converted files will be written next to source files.");
406
+ }
407
+ if (dryRun) {
408
+ const webpCount = targets.filter((f) => path3.extname(f).toLowerCase() === ".webp").length;
409
+ const nonWebpCount = targets.length - webpCount;
410
+ out.warn("Dry run \u2014 no files will be converted:");
411
+ for (const file of targets) {
412
+ out.warn(` would convert: ${file}`);
413
+ }
414
+ out.warn(`Would process ${nonWebpCount} file(s) (${webpCount} already .webp, skipped)`);
415
+ return [];
416
+ }
417
+ if (output) {
418
+ await fs3.mkdir(output, { recursive: true });
419
+ await fs3.access(output, fsConstants.W_OK);
420
+ }
421
+ const results = [];
422
+ const writingFiles = /* @__PURE__ */ new Set();
423
+ const sigintHandler = () => {
424
+ for (const f of writingFiles) {
425
+ try {
426
+ unlinkSync(f);
427
+ } catch {
428
+ }
429
+ }
430
+ process.stderr.write(`
431
+ Interrupted. ${results.length} file(s) completed, ${writingFiles.size} in-progress cleaned up.
432
+ `);
433
+ process.exit(130);
434
+ };
435
+ process.once("SIGINT", sigintHandler);
436
+ try {
437
+ if (isFree) {
438
+ for (let i = 0; i < targets.length; i++) {
439
+ const file = targets[i];
440
+ if (i === 1) {
441
+ out.warn("\u23F1 3s cooldown (Starter/Pro: instant) \u2014 getwebp.com/pricing");
442
+ await sleep(FREE_TIER.DELAY_MS);
443
+ } else if (i > 1) {
444
+ out.warn("\u23F1 3s...");
445
+ await sleep(FREE_TIER.DELAY_MS);
446
+ }
447
+ debugLog("converting", file);
448
+ const result = await convertFile(file, output ?? path3.dirname(file), quality, out, skipExisting, writingFiles);
449
+ debugLog("done", file, result.status);
450
+ results.push(result);
451
+ }
452
+ } else {
453
+ const MAX_CONCURRENCY = 32;
454
+ const rawConcurrency = Number(options.concurrency ?? DEFAULTS.CONCURRENCY);
455
+ const concurrency = Number.isNaN(rawConcurrency) || rawConcurrency < 1 ? DEFAULTS.CONCURRENCY : Math.min(Math.floor(rawConcurrency), MAX_CONCURRENCY);
456
+ const limit = pLimit(concurrency);
457
+ const uniqueDirs = new Set(targets.map((file) => output ?? path3.dirname(file)));
458
+ await Promise.all([...uniqueDirs].map((dir) => fs3.mkdir(dir, { recursive: true })));
459
+ const tasks = targets.map((file) => {
460
+ const outputDir = output ?? path3.dirname(file);
461
+ return limit(async () => {
462
+ debugLog("converting", file);
463
+ const result = await convertFile(file, outputDir, quality, out, skipExisting, writingFiles);
464
+ debugLog("done", file, result.status);
465
+ return result;
466
+ });
467
+ });
468
+ results.push(...await Promise.all(tasks));
469
+ }
470
+ } finally {
471
+ process.removeListener("SIGINT", sigintHandler);
472
+ }
473
+ out.summary(results, { skipped, totalFound: files.length, plan });
474
+ return results;
475
+ }
476
+ async function convertFile(filePath, outputDir, quality, out, skipExisting, writingFiles) {
477
+ const outName = path3.basename(filePath, path3.extname(filePath)) + ".webp";
478
+ const outPath = path3.join(outputDir, outName);
479
+ if (path3.extname(filePath).toLowerCase() === ".webp" && path3.normalize(outPath) === path3.normalize(filePath)) {
480
+ return { file: filePath, status: "skipped", reason: "existing" };
481
+ }
482
+ if (skipExisting) {
483
+ try {
484
+ await fs3.access(outPath);
485
+ return { file: filePath, status: "skipped", reason: "existing" };
486
+ } catch {
487
+ }
488
+ }
489
+ const spinner = out.startFile(filePath);
490
+ try {
491
+ const inputStat = await fs3.stat(filePath);
492
+ let imageData;
493
+ try {
494
+ imageData = await decodeImage(filePath);
495
+ } catch (err) {
496
+ spinner.fail();
497
+ const msg = err instanceof Error ? err.message : String(err);
498
+ return { file: filePath, status: "error", error: `Decode failed: ${msg}` };
499
+ }
500
+ let webpBuffer;
501
+ try {
502
+ webpBuffer = await encodeWebp(imageData, { quality });
503
+ } catch (err) {
504
+ spinner.fail();
505
+ const msg = err instanceof Error ? err.message : String(err);
506
+ return { file: filePath, status: "error", error: `Encode failed: ${msg}` };
507
+ }
508
+ const tmpPath = outPath + ".tmp";
509
+ if (process.platform === "win32" && tmpPath.length >= 260) {
510
+ spinner.fail();
511
+ return { file: filePath, status: "error", error: `Output path too long for Windows (${tmpPath.length} chars, max 260). Use a shorter output directory or enable LongPathsEnabled in Windows registry.` };
512
+ }
513
+ writingFiles.add(tmpPath);
514
+ await fs3.writeFile(tmpPath, new Uint8Array(webpBuffer));
515
+ await fs3.rename(tmpPath, outPath);
516
+ writingFiles.delete(tmpPath);
517
+ const savedRatio = 1 - webpBuffer.byteLength / inputStat.size;
518
+ spinner.succeed();
519
+ return {
520
+ file: filePath,
521
+ originalSize: inputStat.size,
522
+ newSize: webpBuffer.byteLength,
523
+ savedRatio,
524
+ status: "success"
525
+ };
526
+ } catch (err) {
527
+ writingFiles.delete(outPath + ".tmp");
528
+ spinner.fail();
529
+ const msg = err instanceof Error ? err.message : String(err);
530
+ return { file: filePath, status: "error", error: `Write failed: ${msg}` };
531
+ }
532
+ }
533
+ function sleep(ms) {
534
+ return new Promise((resolve) => setTimeout(resolve, ms));
535
+ }
536
+
537
+ // src/output/human.ts
538
+ import ora from "ora";
539
+ import chalk from "chalk";
540
+ var HumanOutput = class {
541
+ startFile(file) {
542
+ const spinner = ora(`Processing ${chalk.cyan(file)}`).start();
543
+ return {
544
+ succeed: () => spinner.succeed(chalk.green(`\u2713 ${file}`)),
545
+ fail: () => spinner.fail(chalk.red(`\u2717 ${file}`))
546
+ };
547
+ }
548
+ warn(msg) {
549
+ process.stderr.write(chalk.yellow(`\u26A0 ${msg}`) + "\n");
550
+ }
551
+ summary(results, meta) {
552
+ const ok = results.filter((r) => r.status === "success");
553
+ const fail = results.filter((r) => r.status === "error");
554
+ const skipped = meta?.skipped ?? 0;
555
+ const totalFound = meta?.totalFound ?? results.length;
556
+ process.stdout.write(`
557
+ Done: ${chalk.green(ok.length)} succeeded, ${chalk.red(fail.length)} failed
558
+ `);
559
+ if (ok.length > 0) {
560
+ const avgSaved = ok.reduce((acc, r) => acc + r.savedRatio, 0) / ok.length;
561
+ const label = avgSaved >= 0 ? chalk.bold((avgSaved * 100).toFixed(1) + "%") : chalk.yellow(`-${(Math.abs(avgSaved) * 100).toFixed(1)}% (some files are larger as WebP)`);
562
+ process.stdout.write(`Avg saved: ${label}
563
+ `);
564
+ }
565
+ const isFree = meta?.plan === "free";
566
+ if (skipped > 0) {
567
+ process.stdout.write(chalk.green(`
568
+ \u2705 Processed ${results.length}/${totalFound} images (Free plan limit reached).`) + "\n");
569
+ process.stdout.write(chalk.cyan(`\u{1F680} ${skipped} images remaining. Free: 10 files, 3s delay | Starter: unlimited, instant ($19/mo)`) + "\n");
570
+ process.stdout.write(chalk.cyan(` Upgrade at getwebp.com/pricing, then run: getwebp auth <your-key>`) + "\n");
571
+ } else if (isFree && results.length > 0) {
572
+ process.stdout.write(chalk.dim(`
573
+ Tip: Free: 10 files, 3s delay | Starter: unlimited, instant ($19/mo) \u2014 getwebp.com/pricing`) + "\n");
574
+ }
575
+ }
576
+ };
577
+
578
+ // src/output/json.ts
579
+ var JsonOutput = class {
580
+ startFile(_file) {
581
+ return { succeed: () => {
582
+ }, fail: () => {
583
+ } };
584
+ }
585
+ warn(msg) {
586
+ process.stderr.write(`warn: ${msg}
587
+ `);
588
+ }
589
+ summary(results, meta) {
590
+ const skipped = meta?.skipped ?? 0;
591
+ const isFree = meta?.plan === "free";
592
+ if (skipped > 0) {
593
+ process.stdout.write(JSON.stringify({
594
+ success: true,
595
+ status: "partial",
596
+ message: `Processed ${results.length} images. Free plan limit reached (${skipped} remaining). Upgrade at getwebp.com/pricing, then run: getwebp auth <your-key>`,
597
+ data: {
598
+ processed: results.length,
599
+ skipped
600
+ },
601
+ upgrade: { url: "https://getwebp.com/pricing", activateCommand: "getwebp auth <your-key>" }
602
+ }) + "\n");
603
+ return;
604
+ }
605
+ const successCount = results.filter((r) => r.status === "success").length;
606
+ const allSuccess = results.every((r) => r.status === "success");
607
+ const payload = {
608
+ success: allSuccess,
609
+ status: allSuccess ? "success" : "error",
610
+ total: results.length,
611
+ successCount,
612
+ failedCount: results.length - successCount,
613
+ results: results.map(
614
+ (r) => r.status === "success" ? { ...r, saved: `${(r.savedRatio * 100).toFixed(1)}%` } : r
615
+ )
616
+ };
617
+ if (isFree && results.length > 0) {
618
+ payload.upgrade = { url: "https://getwebp.com/pricing", activateCommand: "getwebp auth <your-key>" };
619
+ }
620
+ process.stdout.write(JSON.stringify(payload) + "\n");
621
+ }
622
+ };
623
+
624
+ // src/output/index.ts
625
+ function createOutput(jsonMode) {
626
+ return jsonMode ? new JsonOutput() : new HumanOutput();
627
+ }
628
+
629
+ // src/utils/exit.ts
630
+ async function safeExit(code) {
631
+ await drainHeartbeat();
632
+ process.exit(code);
633
+ }
634
+
635
+ // src/utils/action.ts
636
+ var _resolveAction;
637
+ var _rejectAction;
638
+ var actionDone = new Promise((resolve, reject) => {
639
+ _resolveAction = resolve;
640
+ _rejectAction = reject;
641
+ });
642
+ var _hasAction = false;
643
+ function hasAction() {
644
+ return _hasAction;
645
+ }
646
+ function runAction(fn) {
647
+ _hasAction = true;
648
+ fn().then(_resolveAction, _rejectAction);
649
+ }
650
+
651
+ // src/utils/early-flags.ts
652
+ function hasFlag(flag) {
653
+ const args = process.argv.slice(2);
654
+ for (const arg of args) {
655
+ if (arg === "--") break;
656
+ if (arg === flag) return true;
657
+ }
658
+ return false;
659
+ }
660
+ var earlyFlags = {
661
+ json: hasFlag("--json"),
662
+ verbose: hasFlag("--verbose"),
663
+ debug: hasFlag("--debug")
664
+ };
665
+
666
+ // src/commands/convert.ts
667
+ function convertCommand(cli2) {
668
+ cli2.command("[path]", "Convert images (JPEG/PNG/BMP/WebP) to WebP format").option("-i, --input <path>", "Input file or directory path (e.g. ./images)").option("-o, --output <path>", "Output directory for converted files (default: overwrite source files in place)").option("-q, --quality <number>", "WebP quality 1\u2013100 (default: 80)", { default: void 0 }).option("--json", "Output results as JSON \u2014 useful for CI pipelines and AI agents").option("--concurrency <number>", "Number of parallel workers (default: auto, paid plans only)", { default: void 0 }).option("-r, --recursive", "Recursively process images in subdirectories").option("--dry-run", "Preview which files would be converted without actually converting them").option("--skip-existing", "Skip conversion if a .webp file already exists at the output path").action((argPath, options) => {
669
+ runAction(async () => {
670
+ const targetPath = argPath || options.input;
671
+ if (!targetPath) {
672
+ if (earlyFlags.json) {
673
+ process.stdout.write(JSON.stringify({ success: false, status: "error", error: "missing_input", message: "Please specify an input path" }) + "\n");
674
+ } else {
675
+ process.stderr.write("Error: Please specify an input path\n");
676
+ process.stderr.write("Usage: getwebp convert <path> or getwebp convert -i <path>\n");
677
+ process.stderr.write("Run 'getwebp --help' for more information.\n");
678
+ }
679
+ await safeExit(1);
680
+ }
681
+ const out = createOutput(options.json ?? false);
682
+ const licenseStatus = await checkLicense();
683
+ const rawQuality = Number(options.quality ?? 80);
684
+ const quality = Number.isNaN(rawQuality) ? 80 : Math.max(1, Math.min(100, rawQuality));
685
+ const results = await processImages({
686
+ input: targetPath,
687
+ output: options.output,
688
+ quality,
689
+ plan: licenseStatus.plan,
690
+ out,
691
+ concurrency: options.concurrency,
692
+ recursive: options.recursive,
693
+ dryRun: options.dryRun ?? false,
694
+ skipExisting: options.skipExisting ?? false
695
+ });
696
+ const exitCode = resolveExitCode(results);
697
+ if (exitCode !== 0) await safeExit(exitCode);
698
+ });
699
+ });
700
+ }
701
+
702
+ // src/commands/auth.ts
703
+ function authCommand(cli2) {
704
+ cli2.command("auth <license-key>", "Activate your license key").option("--json", "Output results as JSON (for AI agents / CI)").action((licenseKey, options) => {
705
+ runAction(async () => {
706
+ const jsonMode = options.json ?? false;
707
+ if (!jsonMode) {
708
+ process.stderr.write("Verifying license...\n");
709
+ }
710
+ const result = await activate(licenseKey);
711
+ let planLabel = "Pro";
712
+ if (result.success) {
713
+ const status = await checkLicense();
714
+ const p = status.plan;
715
+ planLabel = p.charAt(0).toUpperCase() + p.slice(1);
716
+ }
717
+ if (jsonMode) {
718
+ process.stdout.write(JSON.stringify(
719
+ result.success ? { success: true, data: { message: `Activated \u2014 ${planLabel} plan unlocked` } } : { success: false, status: "error", error: "unknown_error", message: result.error }
720
+ ) + "\n");
721
+ await safeExit(result.success ? 0 : 1);
722
+ } else {
723
+ if (result.success) {
724
+ process.stderr.write(`\u2713 Activated! ${planLabel} plan unlocked.
725
+ `);
726
+ } else {
727
+ process.stderr.write(`\u2717 Activation failed: ${result.error}
728
+ `);
729
+ await safeExit(1);
730
+ }
731
+ }
732
+ });
733
+ });
734
+ }
735
+
736
+ // src/version.ts
737
+ var VERSION = true ? "1.0.0" : await getDevVersion();
738
+
739
+ // src/commands/status.ts
740
+ function statusCommand(cli2) {
741
+ cli2.command("status", "Show current license and CLI status").option("--json", "Output results as JSON (for AI agents / CI)").action((options) => {
742
+ runAction(async () => {
743
+ const jsonMode = options.json ?? false;
744
+ const licenseCheck = await checkLicense();
745
+ if (!licenseCheck.valid && !licenseCheck.expired) {
746
+ if (jsonMode) {
747
+ process.stdout.write(JSON.stringify({
748
+ success: true,
749
+ data: { version: VERSION, mode: "free" }
750
+ }) + "\n");
751
+ } else {
752
+ process.stdout.write(`Version : ${VERSION}
753
+ Mode : Free
754
+ `);
755
+ }
756
+ return;
757
+ }
758
+ const status = await fetchStatus();
759
+ if (!status) {
760
+ if (jsonMode) {
761
+ process.stdout.write(JSON.stringify({
762
+ success: true,
763
+ data: {
764
+ version: VERSION,
765
+ mode: "pro",
766
+ cached: true,
767
+ expiresAt: licenseCheck.expiresAt?.toISOString()
768
+ }
769
+ }) + "\n");
770
+ } else {
771
+ process.stdout.write(
772
+ `Version : ${VERSION}
773
+ Mode : Pro${licenseCheck.expired ? " (expired)" : ""}
774
+ ` + (licenseCheck.expiresAt ? `Expires : ${licenseCheck.expiresAt.toISOString().slice(0, 10)}
775
+ ` : "")
776
+ );
777
+ }
778
+ return;
779
+ }
780
+ const plan = status.plan ?? "pro";
781
+ const planLabel = plan.charAt(0).toUpperCase() + plan.slice(1);
782
+ const modeLabel = status.cached ? `${planLabel} \xB7 cached` : planLabel;
783
+ const expiresLabel = status.expiresAt ? status.expiresAt.slice(0, 10) : void 0;
784
+ if (jsonMode) {
785
+ const data = {
786
+ version: VERSION,
787
+ mode: plan,
788
+ licenseKeySuffix: status.licenseKeySuffix,
789
+ expiresAt: status.expiresAt
790
+ };
791
+ if (status.cached) {
792
+ data.cached = true;
793
+ } else {
794
+ data.devicesUsed = status.devicesUsed;
795
+ data.devicesLimit = status.devicesLimit;
796
+ }
797
+ process.stdout.write(JSON.stringify({ success: true, data }) + "\n");
798
+ } else {
799
+ let out = `Version : ${VERSION}
800
+ `;
801
+ out += `Mode : ${modeLabel}
802
+ `;
803
+ out += `License : xxxx-xxxx-xxxx-${status.licenseKeySuffix}
804
+ `;
805
+ if (expiresLabel) out += `Expires : ${expiresLabel}
806
+ `;
807
+ if (!status.cached) out += `Devices : ${status.devicesUsed} / ${status.devicesLimit} used
808
+ `;
809
+ process.stdout.write(out);
810
+ }
811
+ });
812
+ });
813
+ }
814
+
815
+ // src/commands/logout.ts
816
+ import * as readline from "node:readline";
817
+ function logoutCommand(cli2) {
818
+ cli2.command("logout", "Unbind license from this device").option("-f, --force", "Skip confirmation prompt").option("--json", "Output results as JSON (for AI agents / CI)").action((options) => {
819
+ runAction(async () => {
820
+ const jsonMode = options.json ?? false;
821
+ const force = options.force ?? false;
822
+ if (!force && !jsonMode) {
823
+ const confirmed = await confirm(
824
+ "? Confirm unbind license from this device? This cannot be undone. (y/N) "
825
+ );
826
+ if (!confirmed) {
827
+ process.stderr.write("Cancelled.\n");
828
+ return;
829
+ }
830
+ }
831
+ const result = await logout();
832
+ if (jsonMode) {
833
+ if (result.success) {
834
+ process.stdout.write(JSON.stringify({
835
+ success: true,
836
+ data: { message: "Device unbound. Switched to Free plan." }
837
+ }) + "\n");
838
+ } else {
839
+ process.stdout.write(JSON.stringify({
840
+ success: false,
841
+ status: "error",
842
+ error: result.error ?? "unknown_error",
843
+ message: errorMessage(result.error)
844
+ }) + "\n");
845
+ await safeExit(1);
846
+ }
847
+ } else {
848
+ if (result.success) {
849
+ process.stderr.write("\u2713 Successfully unbound. This device is now on the Free plan.\n");
850
+ } else if (result.error === "network_unreachable") {
851
+ process.stderr.write(
852
+ "\u2717 Cannot reach server. Local credentials were not cleared.\n Retry later or manually unbind via https://getwebp.com/dashboard\n"
853
+ );
854
+ await safeExit(1);
855
+ } else if (result.error === "not_activated") {
856
+ process.stderr.write("No active license found on this device.\n");
857
+ await safeExit(1);
858
+ } else {
859
+ process.stderr.write(`\u2717 Logout failed: ${result.error}
860
+ `);
861
+ await safeExit(1);
862
+ }
863
+ }
864
+ });
865
+ });
866
+ }
867
+ function confirm(prompt) {
868
+ return new Promise((resolve) => {
869
+ const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
870
+ rl.question(prompt, (answer) => {
871
+ rl.close();
872
+ resolve(answer.trim().toLowerCase() === "y");
873
+ });
874
+ });
875
+ }
876
+ function errorMessage(error) {
877
+ switch (error) {
878
+ case "network_unreachable":
879
+ return "Cannot reach server. Retry later or unbind via dashboard.";
880
+ case "not_activated":
881
+ return "No active license on this device.";
882
+ case "device_not_found":
883
+ return "Device not found. It may have already been unbound.";
884
+ case "invalid_token":
885
+ return "Token is invalid or expired.";
886
+ default:
887
+ return error ?? "Unknown error";
888
+ }
889
+ }
890
+
891
+ // src/index.ts
892
+ function fatalExit(err) {
893
+ const message = err instanceof Error ? err.message : String(err);
894
+ if (earlyFlags.json) {
895
+ process.stdout.write(JSON.stringify({ status: "error", success: false, error: "unknown_error", message }) + "\n");
896
+ } else {
897
+ process.stderr.write(chalk2.red(`
898
+ \u2717 ${message}
899
+ `));
900
+ }
901
+ process.exit(1);
902
+ }
903
+ process.on("uncaughtException", fatalExit);
904
+ process.on("unhandledRejection", fatalExit);
905
+ process.on("beforeExit", async () => {
906
+ await drainHeartbeat();
907
+ });
908
+ var cli = cac("getwebp");
909
+ authCommand(cli);
910
+ statusCommand(cli);
911
+ logoutCommand(cli);
912
+ convertCommand(cli);
913
+ cli.option("--verbose", "Enable verbose output");
914
+ cli.option("--debug", "Enable debug output (implies --verbose)");
915
+ if (process.argv.includes("--version") || process.argv.includes("-v")) {
916
+ process.stdout.write(`getwebp/${VERSION}
917
+ `);
918
+ process.exit(0);
919
+ }
920
+ if (process.argv[2] === "help") {
921
+ process.argv[2] = "--help";
922
+ }
923
+ cli.help();
924
+ cli.parse();
925
+ setVerbose(earlyFlags.verbose);
926
+ setDebug(earlyFlags.debug);
927
+ if (hasAction()) await actionDone;
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "getwebp",
3
+ "version": "1.0.0",
4
+ "description": "Convert images to WebP/AVIF from the command line",
5
+ "type": "module",
6
+ "bin": {
7
+ "getwebp": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "scripts": {
16
+ "dev": "bun run src/index.ts",
17
+ "build": "CLI_VERSION=$(node -p \"require('./package.json').version\") bun build src/index.ts --compile --minify --define \"CLI_VERSION='$CLI_VERSION'\" --outfile release/getwebp",
18
+ "typecheck": "tsc --noEmit",
19
+ "test": "bun test --timeout 60000 tests/isolated/ && bun test --timeout 60000 tests/",
20
+ "test:watch": "bun test --timeout 60000 --watch"
21
+ },
22
+ "dependencies": {
23
+ "@jsquash/jpeg": "^1.5.0",
24
+ "@jsquash/png": "^3.1.0",
25
+ "@jsquash/webp": "^1.4.0",
26
+ "bmp-js": "^0.1.0",
27
+ "cac": "^6.7.14",
28
+ "chalk": "^5.3.0",
29
+ "conf": "^13.0.0",
30
+ "jsonwebtoken": "^9.0.2",
31
+ "node-machine-id": "^1.1.12",
32
+ "ora": "^8.1.0",
33
+ "p-limit": "^6.2.0"
34
+ },
35
+ "devDependencies": {
36
+ "@types/jsonwebtoken": "^9.0.7",
37
+ "@types/node": "^22.0.0",
38
+ "esbuild": "^0.25.0",
39
+ "typescript": "^5.7.0"
40
+ }
41
+ }