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.
- package/README.md +318 -0
- package/dist/index.js +927 -0
- 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
|
+
}
|