@puntoycoma/bulki 3.0.1 → 3.0.3

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.
@@ -0,0 +1,566 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ DEFAULT_OUTPUT_FOLDER,
4
+ HookError,
5
+ UNSUPPORTED,
6
+ assignOutputs,
7
+ cleanPathInput,
8
+ defaultConcurrency,
9
+ displayPath,
10
+ formatBytes,
11
+ installHook,
12
+ isInside,
13
+ parsePathInput,
14
+ runBatch,
15
+ samePath,
16
+ scanInputs,
17
+ startWatch,
18
+ uninstallHook
19
+ } from "./chunk-YUAKNSBH.js";
20
+
21
+ // src/interactive/index.ts
22
+ import { spawn } from "child_process";
23
+ import fs from "fs";
24
+ import path from "path";
25
+ import readline from "readline";
26
+ import * as p from "@clack/prompts";
27
+ var BACK = /* @__PURE__ */ Symbol("back");
28
+ var ctrlCPressed = false;
29
+ function quit(message = "Bye!") {
30
+ p.outro(message);
31
+ process.exit(0);
32
+ }
33
+ async function ask(prompt) {
34
+ ctrlCPressed = false;
35
+ const value = await prompt;
36
+ if (p.isCancel(value)) {
37
+ if (ctrlCPressed) quit();
38
+ return BACK;
39
+ }
40
+ return value;
41
+ }
42
+ var PRESETS = {
43
+ web: {
44
+ label: "Web optimized",
45
+ hint: "WebP, quality 80, max 1920px wide",
46
+ settings: { format: "webp", quality: 80, resize: 1920 }
47
+ },
48
+ social: {
49
+ label: "Social media",
50
+ hint: "JPEG, quality 85, max 1080px wide",
51
+ settings: { format: "jpeg", quality: 85, resize: 1080 }
52
+ },
53
+ keep: {
54
+ label: "Just make them smaller",
55
+ hint: "same format and size, quality 80",
56
+ settings: { quality: 80 }
57
+ }
58
+ };
59
+ function describeSettings(s) {
60
+ const fmt = s.format ? s.format.toUpperCase() : "same format";
61
+ const size = s.resize ? `max ${s.resize}px wide` : "original size";
62
+ return `${fmt}, quality ${s.quality}, ${size}`;
63
+ }
64
+ async function chooseSettings(current) {
65
+ let highlighted = current?.preset ?? "web";
66
+ const customDraft = current?.preset === "custom" ? current.settings : void 0;
67
+ while (true) {
68
+ const preset = await ask(
69
+ p.select({
70
+ message: "How do you want to optimize them?",
71
+ initialValue: highlighted,
72
+ options: [
73
+ ...Object.entries(PRESETS).map(([value, preset2]) => ({
74
+ value,
75
+ label: preset2.label,
76
+ hint: preset2.hint
77
+ })),
78
+ { value: "custom", label: "Custom settings", hint: "choose format, quality and size" }
79
+ ]
80
+ })
81
+ );
82
+ if (preset === BACK) return BACK;
83
+ if (preset !== "custom") return { preset, settings: { ...PRESETS[preset].settings } };
84
+ const custom = await chooseCustom(customDraft);
85
+ if (custom !== BACK) return { preset, settings: custom };
86
+ highlighted = "custom";
87
+ }
88
+ }
89
+ async function chooseCustom(previous) {
90
+ const s = { quality: 80, ...previous };
91
+ let step = 0;
92
+ while (step < 3) {
93
+ if (step === 0) {
94
+ const format = await ask(
95
+ p.select({
96
+ message: "Output format",
97
+ initialValue: s.format ?? "original",
98
+ options: [
99
+ { value: "original", label: "Keep original format" },
100
+ { value: "webp", label: "WebP", hint: "best size/quality for the web" },
101
+ { value: "avif", label: "AVIF", hint: "smallest files, slower" },
102
+ { value: "jpeg", label: "JPEG", hint: "works everywhere" },
103
+ { value: "png", label: "PNG", hint: "keeps transparency" }
104
+ ]
105
+ })
106
+ );
107
+ if (format === BACK) return BACK;
108
+ s.format = format === "original" ? void 0 : format;
109
+ step++;
110
+ } else if (step === 1) {
111
+ const quality = await ask(
112
+ p.text({
113
+ message: "Quality (1-100)",
114
+ placeholder: `${s.quality} \u2014 lower = smaller files`,
115
+ defaultValue: String(s.quality),
116
+ validate(value) {
117
+ if (!value?.trim()) return;
118
+ const n = Number(value.trim());
119
+ if (!Number.isInteger(n) || n < 1 || n > 100)
120
+ return "Enter a whole number between 1 and 100";
121
+ }
122
+ })
123
+ );
124
+ if (quality === BACK) {
125
+ step--;
126
+ continue;
127
+ }
128
+ s.quality = Number(quality);
129
+ step++;
130
+ } else {
131
+ const presets = ["none", "3840", "2560", "1920", "1080", "800"];
132
+ const current = s.resize === void 0 ? "none" : String(s.resize);
133
+ const resize = await ask(
134
+ p.select({
135
+ message: "Resize? (images are only made smaller, never bigger)",
136
+ initialValue: presets.includes(current) ? current : "custom",
137
+ options: [
138
+ { value: "none", label: "Keep original size" },
139
+ { value: "3840", label: "3840px wide", hint: "4K" },
140
+ { value: "2560", label: "2560px wide", hint: "retina screens" },
141
+ { value: "1920", label: "1920px wide", hint: "full HD" },
142
+ { value: "1080", label: "1080px wide", hint: "social media" },
143
+ { value: "800", label: "800px wide", hint: "thumbnails" },
144
+ { value: "custom", label: "Other width\u2026" }
145
+ ]
146
+ })
147
+ );
148
+ if (resize === BACK) {
149
+ step--;
150
+ continue;
151
+ }
152
+ if (resize === "custom") {
153
+ const width = await ask(
154
+ p.text({
155
+ message: "Max width in pixels",
156
+ placeholder: "1200",
157
+ initialValue: s.resize && !presets.includes(String(s.resize)) ? String(s.resize) : "",
158
+ validate(value) {
159
+ const n = Number(value?.trim());
160
+ if (!Number.isInteger(n) || n <= 0) return "Enter a whole number greater than 0";
161
+ }
162
+ })
163
+ );
164
+ if (width === BACK) continue;
165
+ s.resize = Number(width);
166
+ } else {
167
+ s.resize = resize === "none" ? void 0 : Number(resize);
168
+ }
169
+ step++;
170
+ }
171
+ }
172
+ return s;
173
+ }
174
+ var DROP_HINT = "type a path, or drag a folder or images into this window";
175
+ async function chooseSource(initial) {
176
+ const raw = await ask(
177
+ p.text({
178
+ message: `Which images? (${DROP_HINT})`,
179
+ placeholder: "./photos",
180
+ initialValue: initial,
181
+ validate(value) {
182
+ const parsed2 = parsePathInput(value);
183
+ if (!parsed2.ok) return parsed2.error;
184
+ }
185
+ })
186
+ );
187
+ if (raw === BACK) return BACK;
188
+ const parsed = parsePathInput(raw);
189
+ return parsed.ok ? parsed.paths : BACK;
190
+ }
191
+ async function scanWithSpinner(inputs) {
192
+ const spin = p.spinner();
193
+ spin.start("Looking for images");
194
+ try {
195
+ const scan = await scanInputs(inputs);
196
+ const supported = scan.images.filter(
197
+ (i) => i.detectedFormat !== UNSUPPORTED || process.platform === "darwin"
198
+ );
199
+ if (supported.length === 0) {
200
+ spin.error(`No images found in ${inputs.map(displayPath).join(", ")}`);
201
+ p.log.warn(
202
+ "Supported formats: JPEG, PNG, WebP, GIF, AVIF (and HEIC on macOS). Pick another folder."
203
+ );
204
+ return null;
205
+ }
206
+ spin.stop(`Found ${supported.length} ${supported.length === 1 ? "image" : "images"}`);
207
+ return { ...scan, images: supported };
208
+ } catch (err) {
209
+ spin.error("Could not read that location");
210
+ p.log.error(err.message);
211
+ return null;
212
+ }
213
+ }
214
+ function validateOutput(output, scan) {
215
+ if (samePath(output, scan.sourceRoot) || scan.inputs.some((i) => samePath(i, output))) {
216
+ return "That is the source folder \u2014 pick a different one so your originals are not overwritten";
217
+ }
218
+ if (fs.existsSync(output) && !fs.statSync(output).isDirectory()) {
219
+ return "That path is a file, not a folder";
220
+ }
221
+ if (scan.inputs.some((i) => fs.statSync(i).isFile() && samePath(i, output))) {
222
+ return "That is one of the source images";
223
+ }
224
+ return checkWritable(output);
225
+ }
226
+ function checkWritable(dir) {
227
+ let existing = dir;
228
+ while (!fs.existsSync(existing)) {
229
+ const parent = path.dirname(existing);
230
+ if (parent === existing) break;
231
+ existing = parent;
232
+ }
233
+ try {
234
+ if (!fs.statSync(existing).isDirectory())
235
+ return `${displayPath(existing)} is a file, not a folder`;
236
+ fs.accessSync(existing, fs.constants.W_OK);
237
+ } catch {
238
+ return `You don't have permission to write in ${displayPath(existing)} \u2014 choose another folder`;
239
+ }
240
+ }
241
+ async function chooseOutput(scan, current) {
242
+ const fallback = current ?? path.join(scan.sourceRoot, DEFAULT_OUTPUT_FOLDER);
243
+ const raw = await ask(
244
+ p.text({
245
+ message: `Where should the optimized copies go? (Enter = ${displayPath(fallback)})`,
246
+ placeholder: displayPath(fallback),
247
+ defaultValue: fallback,
248
+ validate(value) {
249
+ if (!value?.trim()) return;
250
+ return validateOutput(path.resolve(cleanPathInput(value)), scan);
251
+ }
252
+ })
253
+ );
254
+ if (raw === BACK) return BACK;
255
+ return path.resolve(cleanPathInput(raw));
256
+ }
257
+ async function runCompress(prefill) {
258
+ const state = {};
259
+ let step = "source";
260
+ if (prefill) {
261
+ state.inputs = prefill;
262
+ const scan = await scanWithSpinner(prefill);
263
+ if (scan) {
264
+ state.scan = scan;
265
+ step = "settings";
266
+ }
267
+ }
268
+ while (true) {
269
+ if (step === "source") {
270
+ const inputs = await chooseSource(state.inputs?.join(" "));
271
+ if (inputs === BACK) return "menu";
272
+ const scan = await scanWithSpinner(inputs);
273
+ if (!scan) continue;
274
+ state.inputs = inputs;
275
+ state.scan = scan;
276
+ if (state.output && validateOutput(state.output, scan)) state.output = void 0;
277
+ step = state.settings ? "review" : "settings";
278
+ } else if (step === "settings") {
279
+ const chosen = await chooseSettings(
280
+ state.preset && state.settings ? { preset: state.preset, settings: state.settings } : void 0
281
+ );
282
+ if (chosen === BACK) {
283
+ step = state.output ? "review" : "source";
284
+ continue;
285
+ }
286
+ state.preset = chosen.preset;
287
+ state.settings = chosen.settings;
288
+ step = state.output ? "review" : "output";
289
+ } else if (step === "output") {
290
+ const output = await chooseOutput(state.scan, state.output);
291
+ if (output === BACK) {
292
+ step = "settings";
293
+ continue;
294
+ }
295
+ state.output = output;
296
+ step = "review";
297
+ } else if (step === "review") {
298
+ const scan = state.scan;
299
+ const settings = state.settings;
300
+ const output = state.output;
301
+ let plan;
302
+ try {
303
+ plan = assignOutputs(scan, output, settings.format);
304
+ } catch (err) {
305
+ p.log.error(err.message);
306
+ step = "output";
307
+ continue;
308
+ }
309
+ const existing = plan.jobs.filter((j) => fs.existsSync(j.output)).length;
310
+ p.note(
311
+ [
312
+ `From: ${scan.inputs.map(displayPath).join(", ")}`,
313
+ `Images: ${plan.jobs.length}`,
314
+ `Settings: ${describeSettings(settings)}`,
315
+ `Save to: ${displayPath(output)}${fs.existsSync(output) ? "" : " (will be created)"}`,
316
+ "",
317
+ "Your original images are not modified.",
318
+ ...existing > 0 ? [`${existing} file(s) already in the output folder will be replaced.`] : []
319
+ ].join("\n"),
320
+ "Ready"
321
+ );
322
+ const choice = await ask(
323
+ p.select({
324
+ message: "Start?",
325
+ options: [
326
+ { value: "run", label: "Start optimizing" },
327
+ { value: "source", label: "Change images" },
328
+ { value: "settings", label: "Change settings" },
329
+ { value: "output", label: "Change output folder" },
330
+ { value: "menu", label: "Cancel \u2014 back to main menu" }
331
+ ]
332
+ })
333
+ );
334
+ if (choice === BACK) step = "output";
335
+ else if (choice === "menu") return "menu";
336
+ else step = choice;
337
+ } else {
338
+ const plan = assignOutputs(state.scan, state.output, state.settings?.format);
339
+ await executePlan(plan.jobs, state.settings, state.output);
340
+ const next = await ask(
341
+ p.select({
342
+ message: "What next?",
343
+ options: [
344
+ { value: "open", label: "Open the output folder" },
345
+ { value: "again", label: "Optimize more images" },
346
+ { value: "retry", label: "Run again with different settings" },
347
+ { value: "menu", label: "Main menu" },
348
+ { value: "quit", label: "Exit" }
349
+ ]
350
+ })
351
+ );
352
+ if (next === "open") {
353
+ openFolder(state.output);
354
+ return "menu";
355
+ }
356
+ if (next === "retry") {
357
+ step = "settings";
358
+ continue;
359
+ }
360
+ if (next === "quit") quit();
361
+ return next === "again" ? "again" : "menu";
362
+ }
363
+ }
364
+ }
365
+ async function executePlan(jobs, settings, output) {
366
+ const controller = new AbortController();
367
+ const onSigint = () => controller.abort();
368
+ process.on("SIGINT", onSigint);
369
+ const bar = p.progress({ max: jobs.length, size: 30 });
370
+ bar.start(`Optimizing ${jobs.length} images (Ctrl+C to stop)`);
371
+ let done = 0;
372
+ const batch = await runBatch(
373
+ jobs,
374
+ settings,
375
+ defaultConcurrency(),
376
+ (job) => {
377
+ done++;
378
+ bar.advance(1, `${done}/${jobs.length} ${path.basename(job.input)}`);
379
+ },
380
+ controller.signal
381
+ );
382
+ process.off("SIGINT", onSigint);
383
+ const { results, errors } = batch;
384
+ const stopped = controller.signal.aborted;
385
+ if (stopped) bar.stop(`Stopped \u2014 ${results.length} of ${jobs.length} images done`);
386
+ else if (results.length === 0) bar.error("No image could be optimized");
387
+ else bar.stop(`Done in ${(batch.duration / 1e3).toFixed(1)}s`);
388
+ const before = results.reduce((s, r) => s + r.originalSize, 0);
389
+ const after = results.reduce((s, r) => s + r.newSize, 0);
390
+ const pct = before > 0 ? Math.round((1 - after / before) * 100) : 0;
391
+ const kept = results.filter((r) => r.keptOriginal).length;
392
+ const lines = [
393
+ `${results.length} ${results.length === 1 ? "image" : "images"} optimized`,
394
+ `${formatBytes(before)} \u2192 ${formatBytes(after)} (${pct}% smaller, ${formatBytes(before - after)} saved)`,
395
+ `Saved to: ${displayPath(output)}`
396
+ ];
397
+ if (kept > 0) lines.push(`${kept} were already optimal and were copied unchanged.`);
398
+ p.note(lines.join("\n"), errors.length > 0 ? "Finished with errors" : "Finished");
399
+ if (errors.length > 0) {
400
+ const shown = errors.slice(0, 8);
401
+ p.log.error(
402
+ [
403
+ `${errors.length} ${errors.length === 1 ? "image" : "images"} could not be optimized:`,
404
+ ...shown.map((e) => ` ${path.basename(e.file)} \u2014 ${e.message}`),
405
+ ...errors.length > shown.length ? [` \u2026and ${errors.length - shown.length} more`] : []
406
+ ].join("\n")
407
+ );
408
+ }
409
+ }
410
+ function openFolder(dir) {
411
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer" : "xdg-open";
412
+ try {
413
+ const child = spawn(cmd, [dir], { detached: true, stdio: "ignore" });
414
+ child.on("error", () => p.log.warn(`Could not open the folder. It is here: ${dir}`));
415
+ child.unref();
416
+ } catch {
417
+ p.log.warn(`Could not open the folder. It is here: ${dir}`);
418
+ }
419
+ }
420
+ async function runWatch() {
421
+ let dir;
422
+ let chosen;
423
+ let output;
424
+ let step = 0;
425
+ while (step < 3) {
426
+ if (step === 0) {
427
+ const raw = await ask(
428
+ p.text({
429
+ message: `Which folder should be watched? (${DROP_HINT})`,
430
+ placeholder: "./uploads",
431
+ initialValue: dir,
432
+ validate(value) {
433
+ const parsed2 = parsePathInput(value);
434
+ if (!parsed2.ok) return parsed2.error;
435
+ if (parsed2.paths.length > 1 || !fs.statSync(parsed2.paths[0]).isDirectory())
436
+ return "Choose one folder (not files)";
437
+ }
438
+ })
439
+ );
440
+ if (raw === BACK) return;
441
+ const parsed = parsePathInput(raw);
442
+ if (!parsed.ok) continue;
443
+ dir = parsed.paths[0];
444
+ step++;
445
+ } else if (step === 1) {
446
+ const s = await chooseSettings(chosen);
447
+ if (s === BACK) {
448
+ step--;
449
+ continue;
450
+ }
451
+ chosen = s;
452
+ step++;
453
+ } else {
454
+ const fakeScan = { inputs: [dir], sourceRoot: dir, images: [] };
455
+ const out2 = await chooseOutput(fakeScan, output);
456
+ if (out2 === BACK) {
457
+ step--;
458
+ continue;
459
+ }
460
+ output = out2;
461
+ step++;
462
+ }
463
+ }
464
+ const root = dir;
465
+ const out = output;
466
+ let handle;
467
+ try {
468
+ handle = await startWatch(root, {
469
+ ...chosen?.settings,
470
+ output: out,
471
+ log: (line) => p.log.success(line),
472
+ logError: (line) => p.log.error(line)
473
+ });
474
+ } catch (err) {
475
+ p.log.error(err.message);
476
+ return;
477
+ }
478
+ p.note(
479
+ [
480
+ `Watching: ${displayPath(root)}`,
481
+ `Settings: ${describeSettings(chosen?.settings)}`,
482
+ `Save to: ${displayPath(out)}`,
483
+ "",
484
+ "New or changed images are optimized automatically.",
485
+ isInside(out, root) ? `(${path.basename(out)} itself is ignored)` : ""
486
+ ].filter(Boolean).join("\n"),
487
+ "Watching \u2014 press Ctrl+C to stop"
488
+ );
489
+ await new Promise((resolve) => process.once("SIGINT", () => resolve()));
490
+ handle.close();
491
+ p.log.info("Stopped watching.");
492
+ }
493
+ async function runHook() {
494
+ const action = await ask(
495
+ p.select({
496
+ message: `Git hook for ${displayPath(process.cwd())}`,
497
+ options: [
498
+ { value: "install", label: "Install", hint: "optimize staged images on every commit" },
499
+ { value: "uninstall", label: "Uninstall", hint: "remove the hook" }
500
+ ]
501
+ })
502
+ );
503
+ if (action === BACK) return;
504
+ try {
505
+ if (action === "install") await installHook();
506
+ else await uninstallHook();
507
+ } catch (err) {
508
+ if (!(err instanceof HookError)) throw err;
509
+ p.log.error(`${err.message} (current folder: ${displayPath(process.cwd())})`);
510
+ }
511
+ }
512
+ async function runInteractive(prefill) {
513
+ if (!process.stdin.isTTY) {
514
+ console.error("Interactive mode needs a terminal. Run `bulki --help` to see the commands.");
515
+ process.exit(1);
516
+ }
517
+ readline.emitKeypressEvents(process.stdin);
518
+ process.stdin.on("keypress", (_str, key) => {
519
+ if (key?.ctrl && key.name === "c") ctrlCPressed = true;
520
+ });
521
+ p.intro("Punto y Coma \u2014 bulki");
522
+ p.log.message("Esc = go back \xB7 Ctrl+C = quit", { symbol: " " });
523
+ let next = "menu";
524
+ let pending = prefill;
525
+ while (true) {
526
+ let action = "compress";
527
+ if (next === "menu" && !pending) {
528
+ action = await ask(
529
+ p.select({
530
+ message: "What do you want to do?",
531
+ options: [
532
+ { value: "compress", label: "Optimize images", hint: "files or folders" },
533
+ { value: "watch", label: "Watch a folder", hint: "auto-optimize new images" },
534
+ { value: "hook", label: "Git hook", hint: "auto-optimize on commit" },
535
+ { value: "quit", label: "Exit" }
536
+ ]
537
+ })
538
+ );
539
+ if (action === "quit") quit();
540
+ if (action === BACK) {
541
+ p.log.info('To leave bulki choose "Exit" or press Ctrl+C.');
542
+ continue;
543
+ }
544
+ }
545
+ try {
546
+ if (action === "compress") {
547
+ next = await runCompress(pending);
548
+ pending = void 0;
549
+ } else if (action === "watch") {
550
+ await runWatch();
551
+ next = "menu";
552
+ } else {
553
+ await runHook();
554
+ next = "menu";
555
+ }
556
+ } catch (err) {
557
+ p.log.error(`Something went wrong: ${err.message}`);
558
+ pending = void 0;
559
+ next = "menu";
560
+ }
561
+ }
562
+ }
563
+ export {
564
+ runInteractive
565
+ };
566
+ //# sourceMappingURL=interactive-MTL4CMVQ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/interactive/index.ts"],"sourcesContent":["import { spawn } from 'node:child_process'\nimport fs from 'node:fs'\nimport path from 'node:path'\nimport readline from 'node:readline'\nimport * as p from '@clack/prompts'\nimport { HookError, installHook, uninstallHook } from '../commands/hook.js'\nimport { startWatch } from '../commands/watch.js'\nimport { formatBytes } from '../output/format-bytes.js'\nimport { UNSUPPORTED } from '../pipeline/filter.js'\nimport { runBatch } from '../pipeline/optimize.js'\nimport {\n\tcleanPathInput,\n\tdisplayPath,\n\tisInside,\n\tparsePathInput,\n\tsamePath,\n} from '../pipeline/paths.js'\nimport { assignOutputs, type PlanError, type Scan, scanInputs } from '../pipeline/plan.js'\nimport type { EncodeSettings, OutputFormat } from '../types/index.js'\nimport { DEFAULT_OUTPUT_FOLDER, defaultConcurrency } from '../validation/options.js'\n\n/*\n * Interactive mode.\n *\n * Every screen is a step in a small state machine:\n * Esc → go back one step (never loses what you already chose)\n * Ctrl+C → quit bulki\n * Wrong input (a path that doesn't exist, a bad number) is rejected in place,\n * so you can fix it without starting over.\n */\n\n// ─── prompt helpers ─────────────────────────────────────────────────────────\n\nconst BACK = Symbol('back')\ntype Back = typeof BACK\n\nlet ctrlCPressed = false\n\nfunction quit(message = 'Bye!'): never {\n\tp.outro(message)\n\tprocess.exit(0)\n}\n\n/** Await a clack prompt. Esc → BACK, Ctrl+C → quit. */\nasync function ask<T>(prompt: Promise<T | symbol>): Promise<T | Back> {\n\tctrlCPressed = false\n\tconst value = await prompt\n\tif (p.isCancel(value)) {\n\t\tif (ctrlCPressed) quit()\n\t\treturn BACK\n\t}\n\treturn value as T\n}\n\n// ─── settings ───────────────────────────────────────────────────────────────\n\nconst PRESETS = {\n\tweb: {\n\t\tlabel: 'Web optimized',\n\t\thint: 'WebP, quality 80, max 1920px wide',\n\t\tsettings: { format: 'webp', quality: 80, resize: 1920 } as EncodeSettings,\n\t},\n\tsocial: {\n\t\tlabel: 'Social media',\n\t\thint: 'JPEG, quality 85, max 1080px wide',\n\t\tsettings: { format: 'jpeg', quality: 85, resize: 1080 } as EncodeSettings,\n\t},\n\tkeep: {\n\t\tlabel: 'Just make them smaller',\n\t\thint: 'same format and size, quality 80',\n\t\tsettings: { quality: 80 } as EncodeSettings,\n\t},\n} as const\n\ntype PresetKey = keyof typeof PRESETS | 'custom'\n\nfunction describeSettings(s: EncodeSettings): string {\n\tconst fmt = s.format ? s.format.toUpperCase() : 'same format'\n\tconst size = s.resize ? `max ${s.resize}px wide` : 'original size'\n\treturn `${fmt}, quality ${s.quality}, ${size}`\n}\n\n/** Preset menu, plus the custom sub-flow. Esc inside custom goes back one question. */\nasync function chooseSettings(current?: { preset: PresetKey; settings: EncodeSettings }) {\n\tlet highlighted: PresetKey = current?.preset ?? 'web'\n\tconst customDraft = current?.preset === 'custom' ? current.settings : undefined\n\twhile (true) {\n\t\tconst preset = await ask(\n\t\t\tp.select<PresetKey>({\n\t\t\t\tmessage: 'How do you want to optimize them?',\n\t\t\t\tinitialValue: highlighted,\n\t\t\t\toptions: [\n\t\t\t\t\t...Object.entries(PRESETS).map(([value, preset]) => ({\n\t\t\t\t\t\tvalue: value as PresetKey,\n\t\t\t\t\t\tlabel: preset.label,\n\t\t\t\t\t\thint: preset.hint,\n\t\t\t\t\t})),\n\t\t\t\t\t{ value: 'custom', label: 'Custom settings', hint: 'choose format, quality and size' },\n\t\t\t\t],\n\t\t\t}),\n\t\t)\n\t\tif (preset === BACK) return BACK\n\t\tif (preset !== 'custom') return { preset, settings: { ...PRESETS[preset].settings } }\n\n\t\tconst custom = await chooseCustom(customDraft)\n\t\tif (custom !== BACK) return { preset, settings: custom }\n\t\t// Esc on the first custom question → back to the preset list, still on \"Custom\"\n\t\thighlighted = 'custom'\n\t}\n}\n\nasync function chooseCustom(previous?: EncodeSettings): Promise<EncodeSettings | Back> {\n\tconst s: EncodeSettings = { quality: 80, ...previous }\n\tlet step = 0\n\twhile (step < 3) {\n\t\tif (step === 0) {\n\t\t\tconst format = await ask(\n\t\t\t\tp.select<OutputFormat | 'original'>({\n\t\t\t\t\tmessage: 'Output format',\n\t\t\t\t\tinitialValue: s.format ?? 'original',\n\t\t\t\t\toptions: [\n\t\t\t\t\t\t{ value: 'original', label: 'Keep original format' },\n\t\t\t\t\t\t{ value: 'webp', label: 'WebP', hint: 'best size/quality for the web' },\n\t\t\t\t\t\t{ value: 'avif', label: 'AVIF', hint: 'smallest files, slower' },\n\t\t\t\t\t\t{ value: 'jpeg', label: 'JPEG', hint: 'works everywhere' },\n\t\t\t\t\t\t{ value: 'png', label: 'PNG', hint: 'keeps transparency' },\n\t\t\t\t\t],\n\t\t\t\t}),\n\t\t\t)\n\t\t\tif (format === BACK) return BACK\n\t\t\ts.format = format === 'original' ? undefined : format\n\t\t\tstep++\n\t\t} else if (step === 1) {\n\t\t\tconst quality = await ask(\n\t\t\t\tp.text({\n\t\t\t\t\tmessage: 'Quality (1-100)',\n\t\t\t\t\tplaceholder: `${s.quality} — lower = smaller files`,\n\t\t\t\t\tdefaultValue: String(s.quality),\n\t\t\t\t\tvalidate(value) {\n\t\t\t\t\t\tif (!value?.trim()) return\n\t\t\t\t\t\tconst n = Number(value.trim())\n\t\t\t\t\t\tif (!Number.isInteger(n) || n < 1 || n > 100)\n\t\t\t\t\t\t\treturn 'Enter a whole number between 1 and 100'\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t)\n\t\t\tif (quality === BACK) {\n\t\t\t\tstep--\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\ts.quality = Number(quality)\n\t\t\tstep++\n\t\t} else {\n\t\t\tconst presets = ['none', '3840', '2560', '1920', '1080', '800']\n\t\t\tconst current = s.resize === undefined ? 'none' : String(s.resize)\n\t\t\tconst resize = await ask(\n\t\t\t\tp.select<string>({\n\t\t\t\t\tmessage: 'Resize? (images are only made smaller, never bigger)',\n\t\t\t\t\tinitialValue: presets.includes(current) ? current : 'custom',\n\t\t\t\t\toptions: [\n\t\t\t\t\t\t{ value: 'none', label: 'Keep original size' },\n\t\t\t\t\t\t{ value: '3840', label: '3840px wide', hint: '4K' },\n\t\t\t\t\t\t{ value: '2560', label: '2560px wide', hint: 'retina screens' },\n\t\t\t\t\t\t{ value: '1920', label: '1920px wide', hint: 'full HD' },\n\t\t\t\t\t\t{ value: '1080', label: '1080px wide', hint: 'social media' },\n\t\t\t\t\t\t{ value: '800', label: '800px wide', hint: 'thumbnails' },\n\t\t\t\t\t\t{ value: 'custom', label: 'Other width…' },\n\t\t\t\t\t],\n\t\t\t\t}),\n\t\t\t)\n\t\t\tif (resize === BACK) {\n\t\t\t\tstep--\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif (resize === 'custom') {\n\t\t\t\tconst width = await ask(\n\t\t\t\t\tp.text({\n\t\t\t\t\t\tmessage: 'Max width in pixels',\n\t\t\t\t\t\tplaceholder: '1200',\n\t\t\t\t\t\tinitialValue: s.resize && !presets.includes(String(s.resize)) ? String(s.resize) : '',\n\t\t\t\t\t\tvalidate(value) {\n\t\t\t\t\t\t\tconst n = Number(value?.trim())\n\t\t\t\t\t\t\tif (!Number.isInteger(n) || n <= 0) return 'Enter a whole number greater than 0'\n\t\t\t\t\t\t},\n\t\t\t\t\t}),\n\t\t\t\t)\n\t\t\t\tif (width === BACK) continue // back to the resize list\n\t\t\t\ts.resize = Number(width)\n\t\t\t} else {\n\t\t\t\ts.resize = resize === 'none' ? undefined : Number(resize)\n\t\t\t}\n\t\t\tstep++\n\t\t}\n\t}\n\treturn s\n}\n\n// ─── paths ──────────────────────────────────────────────────────────────────\n\nconst DROP_HINT = 'type a path, or drag a folder or images into this window'\n\nasync function chooseSource(initial?: string): Promise<string[] | Back> {\n\tconst raw = await ask(\n\t\tp.text({\n\t\t\tmessage: `Which images? (${DROP_HINT})`,\n\t\t\tplaceholder: './photos',\n\t\t\tinitialValue: initial,\n\t\t\tvalidate(value) {\n\t\t\t\tconst parsed = parsePathInput(value)\n\t\t\t\tif (!parsed.ok) return parsed.error\n\t\t\t},\n\t\t}),\n\t)\n\tif (raw === BACK) return BACK\n\tconst parsed = parsePathInput(raw)\n\treturn parsed.ok ? parsed.paths : BACK\n}\n\nasync function scanWithSpinner(inputs: string[]): Promise<Scan | null> {\n\tconst spin = p.spinner()\n\tspin.start('Looking for images')\n\ttry {\n\t\tconst scan = await scanInputs(inputs)\n\t\tconst supported = scan.images.filter(\n\t\t\t(i) => i.detectedFormat !== UNSUPPORTED || process.platform === 'darwin',\n\t\t)\n\t\tif (supported.length === 0) {\n\t\t\tspin.error(`No images found in ${inputs.map(displayPath).join(', ')}`)\n\t\t\tp.log.warn(\n\t\t\t\t'Supported formats: JPEG, PNG, WebP, GIF, AVIF (and HEIC on macOS). Pick another folder.',\n\t\t\t)\n\t\t\treturn null\n\t\t}\n\t\tspin.stop(`Found ${supported.length} ${supported.length === 1 ? 'image' : 'images'}`)\n\t\treturn { ...scan, images: supported }\n\t} catch (err) {\n\t\tspin.error('Could not read that location')\n\t\tp.log.error((err as Error).message)\n\t\treturn null\n\t}\n}\n\nfunction validateOutput(output: string, scan: Scan): string | undefined {\n\tif (samePath(output, scan.sourceRoot) || scan.inputs.some((i) => samePath(i, output))) {\n\t\treturn 'That is the source folder — pick a different one so your originals are not overwritten'\n\t}\n\tif (fs.existsSync(output) && !fs.statSync(output).isDirectory()) {\n\t\treturn 'That path is a file, not a folder'\n\t}\n\tif (scan.inputs.some((i) => fs.statSync(i).isFile() && samePath(i, output))) {\n\t\treturn 'That is one of the source images'\n\t}\n\treturn checkWritable(output)\n}\n\n/** The folder (or the closest existing parent, since it will be created) must be writable. */\nfunction checkWritable(dir: string): string | undefined {\n\tlet existing = dir\n\twhile (!fs.existsSync(existing)) {\n\t\tconst parent = path.dirname(existing)\n\t\tif (parent === existing) break\n\t\texisting = parent\n\t}\n\ttry {\n\t\tif (!fs.statSync(existing).isDirectory())\n\t\t\treturn `${displayPath(existing)} is a file, not a folder`\n\t\tfs.accessSync(existing, fs.constants.W_OK)\n\t} catch {\n\t\treturn `You don't have permission to write in ${displayPath(existing)} — choose another folder`\n\t}\n}\n\nasync function chooseOutput(scan: Scan, current?: string): Promise<string | Back> {\n\tconst fallback = current ?? path.join(scan.sourceRoot, DEFAULT_OUTPUT_FOLDER)\n\tconst raw = await ask(\n\t\tp.text({\n\t\t\tmessage: `Where should the optimized copies go? (Enter = ${displayPath(fallback)})`,\n\t\t\tplaceholder: displayPath(fallback),\n\t\t\tdefaultValue: fallback,\n\t\t\tvalidate(value) {\n\t\t\t\tif (!value?.trim()) return\n\t\t\t\treturn validateOutput(path.resolve(cleanPathInput(value)), scan)\n\t\t\t},\n\t\t}),\n\t)\n\tif (raw === BACK) return BACK\n\treturn path.resolve(cleanPathInput(raw))\n}\n\n// ─── compress ───────────────────────────────────────────────────────────────\n\ninterface CompressState {\n\tinputs?: string[]\n\tscan?: Scan\n\tpreset?: PresetKey\n\tsettings?: EncodeSettings\n\toutput?: string\n}\n\ntype Step = 'source' | 'settings' | 'output' | 'review' | 'run'\n\nasync function runCompress(prefill?: string[]): Promise<'menu' | 'again'> {\n\tconst state: CompressState = {}\n\tlet step: Step = 'source'\n\n\tif (prefill) {\n\t\tstate.inputs = prefill\n\t\tconst scan = await scanWithSpinner(prefill)\n\t\tif (scan) {\n\t\t\tstate.scan = scan\n\t\t\tstep = 'settings'\n\t\t}\n\t}\n\n\twhile (true) {\n\t\tif (step === 'source') {\n\t\t\tconst inputs = await chooseSource(state.inputs?.join(' '))\n\t\t\tif (inputs === BACK) return 'menu'\n\t\t\tconst scan = await scanWithSpinner(inputs)\n\t\t\tif (!scan) continue // stay on this step\n\t\t\tstate.inputs = inputs\n\t\t\tstate.scan = scan\n\t\t\t// A previous output that's now invalid (e.g. same as new source) is dropped\n\t\t\tif (state.output && validateOutput(state.output, scan)) state.output = undefined\n\t\t\tstep = state.settings ? 'review' : 'settings'\n\t\t} else if (step === 'settings') {\n\t\t\tconst chosen = await chooseSettings(\n\t\t\t\tstate.preset && state.settings\n\t\t\t\t\t? { preset: state.preset, settings: state.settings }\n\t\t\t\t\t: undefined,\n\t\t\t)\n\t\t\tif (chosen === BACK) {\n\t\t\t\tstep = state.output ? 'review' : 'source'\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstate.preset = chosen.preset\n\t\t\tstate.settings = chosen.settings\n\t\t\tstep = state.output ? 'review' : 'output'\n\t\t} else if (step === 'output') {\n\t\t\tconst output = await chooseOutput(state.scan as Scan, state.output)\n\t\t\tif (output === BACK) {\n\t\t\t\tstep = 'settings'\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tstate.output = output\n\t\t\tstep = 'review'\n\t\t} else if (step === 'review') {\n\t\t\tconst scan = state.scan as Scan\n\t\t\tconst settings = state.settings as EncodeSettings\n\t\t\tconst output = state.output as string\n\t\t\tlet plan: ReturnType<typeof assignOutputs>\n\t\t\ttry {\n\t\t\t\tplan = assignOutputs(scan, output, settings.format)\n\t\t\t} catch (err) {\n\t\t\t\tp.log.error((err as PlanError).message)\n\t\t\t\tstep = 'output'\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tconst existing = plan.jobs.filter((j) => fs.existsSync(j.output)).length\n\t\t\tp.note(\n\t\t\t\t[\n\t\t\t\t\t`From: ${scan.inputs.map(displayPath).join(', ')}`,\n\t\t\t\t\t`Images: ${plan.jobs.length}`,\n\t\t\t\t\t`Settings: ${describeSettings(settings)}`,\n\t\t\t\t\t`Save to: ${displayPath(output)}${fs.existsSync(output) ? '' : ' (will be created)'}`,\n\t\t\t\t\t'',\n\t\t\t\t\t'Your original images are not modified.',\n\t\t\t\t\t...(existing > 0\n\t\t\t\t\t\t? [`${existing} file(s) already in the output folder will be replaced.`]\n\t\t\t\t\t\t: []),\n\t\t\t\t].join('\\n'),\n\t\t\t\t'Ready',\n\t\t\t)\n\t\t\tconst choice = await ask(\n\t\t\t\tp.select<string>({\n\t\t\t\t\tmessage: 'Start?',\n\t\t\t\t\toptions: [\n\t\t\t\t\t\t{ value: 'run', label: 'Start optimizing' },\n\t\t\t\t\t\t{ value: 'source', label: 'Change images' },\n\t\t\t\t\t\t{ value: 'settings', label: 'Change settings' },\n\t\t\t\t\t\t{ value: 'output', label: 'Change output folder' },\n\t\t\t\t\t\t{ value: 'menu', label: 'Cancel — back to main menu' },\n\t\t\t\t\t],\n\t\t\t\t}),\n\t\t\t)\n\t\t\tif (choice === BACK) step = 'output'\n\t\t\telse if (choice === 'menu') return 'menu'\n\t\t\telse step = choice as Step\n\t\t} else {\n\t\t\tconst plan = assignOutputs(state.scan as Scan, state.output as string, state.settings?.format)\n\t\t\tawait executePlan(plan.jobs, state.settings as EncodeSettings, state.output as string)\n\t\t\tconst next = await ask(\n\t\t\t\tp.select<string>({\n\t\t\t\t\tmessage: 'What next?',\n\t\t\t\t\toptions: [\n\t\t\t\t\t\t{ value: 'open', label: 'Open the output folder' },\n\t\t\t\t\t\t{ value: 'again', label: 'Optimize more images' },\n\t\t\t\t\t\t{ value: 'retry', label: 'Run again with different settings' },\n\t\t\t\t\t\t{ value: 'menu', label: 'Main menu' },\n\t\t\t\t\t\t{ value: 'quit', label: 'Exit' },\n\t\t\t\t\t],\n\t\t\t\t}),\n\t\t\t)\n\t\t\tif (next === 'open') {\n\t\t\t\topenFolder(state.output as string)\n\t\t\t\treturn 'menu'\n\t\t\t}\n\t\t\tif (next === 'retry') {\n\t\t\t\tstep = 'settings'\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif (next === 'quit') quit()\n\t\t\treturn next === 'again' ? 'again' : 'menu'\n\t\t}\n\t}\n}\n\nasync function executePlan(\n\tjobs: ReturnType<typeof assignOutputs>['jobs'],\n\tsettings: EncodeSettings,\n\toutput: string,\n) {\n\tconst controller = new AbortController()\n\tconst onSigint = () => controller.abort()\n\tprocess.on('SIGINT', onSigint)\n\n\tconst bar = p.progress({ max: jobs.length, size: 30 })\n\tbar.start(`Optimizing ${jobs.length} images (Ctrl+C to stop)`)\n\tlet done = 0\n\tconst batch = await runBatch(\n\t\tjobs,\n\t\tsettings,\n\t\tdefaultConcurrency(),\n\t\t(job) => {\n\t\t\tdone++\n\t\t\tbar.advance(1, `${done}/${jobs.length} ${path.basename(job.input)}`)\n\t\t},\n\t\tcontroller.signal,\n\t)\n\tprocess.off('SIGINT', onSigint)\n\n\tconst { results, errors } = batch\n\tconst stopped = controller.signal.aborted\n\tif (stopped) bar.stop(`Stopped — ${results.length} of ${jobs.length} images done`)\n\telse if (results.length === 0) bar.error('No image could be optimized')\n\telse bar.stop(`Done in ${(batch.duration / 1000).toFixed(1)}s`)\n\n\tconst before = results.reduce((s, r) => s + r.originalSize, 0)\n\tconst after = results.reduce((s, r) => s + r.newSize, 0)\n\tconst pct = before > 0 ? Math.round((1 - after / before) * 100) : 0\n\tconst kept = results.filter((r) => r.keptOriginal).length\n\n\tconst lines = [\n\t\t`${results.length} ${results.length === 1 ? 'image' : 'images'} optimized`,\n\t\t`${formatBytes(before)} → ${formatBytes(after)} (${pct}% smaller, ${formatBytes(before - after)} saved)`,\n\t\t`Saved to: ${displayPath(output)}`,\n\t]\n\tif (kept > 0) lines.push(`${kept} were already optimal and were copied unchanged.`)\n\tp.note(lines.join('\\n'), errors.length > 0 ? 'Finished with errors' : 'Finished')\n\n\tif (errors.length > 0) {\n\t\tconst shown = errors.slice(0, 8)\n\t\tp.log.error(\n\t\t\t[\n\t\t\t\t`${errors.length} ${errors.length === 1 ? 'image' : 'images'} could not be optimized:`,\n\t\t\t\t...shown.map((e) => ` ${path.basename(e.file)} — ${e.message}`),\n\t\t\t\t...(errors.length > shown.length ? [` …and ${errors.length - shown.length} more`] : []),\n\t\t\t].join('\\n'),\n\t\t)\n\t}\n}\n\nfunction openFolder(dir: string) {\n\tconst cmd =\n\t\tprocess.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open'\n\ttry {\n\t\tconst child = spawn(cmd, [dir], { detached: true, stdio: 'ignore' })\n\t\tchild.on('error', () => p.log.warn(`Could not open the folder. It is here: ${dir}`))\n\t\tchild.unref()\n\t} catch {\n\t\tp.log.warn(`Could not open the folder. It is here: ${dir}`)\n\t}\n}\n\n// ─── watch ──────────────────────────────────────────────────────────────────\n\nasync function runWatch(): Promise<void> {\n\tlet dir: string | undefined\n\tlet chosen: { preset: PresetKey; settings: EncodeSettings } | undefined\n\tlet output: string | undefined\n\tlet step = 0\n\n\twhile (step < 3) {\n\t\tif (step === 0) {\n\t\t\tconst raw = await ask(\n\t\t\t\tp.text({\n\t\t\t\t\tmessage: `Which folder should be watched? (${DROP_HINT})`,\n\t\t\t\t\tplaceholder: './uploads',\n\t\t\t\t\tinitialValue: dir,\n\t\t\t\t\tvalidate(value) {\n\t\t\t\t\t\tconst parsed = parsePathInput(value)\n\t\t\t\t\t\tif (!parsed.ok) return parsed.error\n\t\t\t\t\t\tif (parsed.paths.length > 1 || !fs.statSync(parsed.paths[0]).isDirectory())\n\t\t\t\t\t\t\treturn 'Choose one folder (not files)'\n\t\t\t\t\t},\n\t\t\t\t}),\n\t\t\t)\n\t\t\tif (raw === BACK) return\n\t\t\tconst parsed = parsePathInput(raw)\n\t\t\tif (!parsed.ok) continue\n\t\t\tdir = parsed.paths[0]\n\t\t\tstep++\n\t\t} else if (step === 1) {\n\t\t\tconst s = await chooseSettings(chosen)\n\t\t\tif (s === BACK) {\n\t\t\t\tstep--\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tchosen = s\n\t\t\tstep++\n\t\t} else {\n\t\t\tconst fakeScan: Scan = { inputs: [dir as string], sourceRoot: dir as string, images: [] }\n\t\t\tconst out = await chooseOutput(fakeScan, output)\n\t\t\tif (out === BACK) {\n\t\t\t\tstep--\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\toutput = out\n\t\t\tstep++\n\t\t}\n\t}\n\n\tconst root = dir as string\n\tconst out = output as string\n\tlet handle: Awaited<ReturnType<typeof startWatch>>\n\ttry {\n\t\thandle = await startWatch(root, {\n\t\t\t...(chosen?.settings as EncodeSettings),\n\t\t\toutput: out,\n\t\t\tlog: (line) => p.log.success(line),\n\t\t\tlogError: (line) => p.log.error(line),\n\t\t})\n\t} catch (err) {\n\t\tp.log.error((err as Error).message)\n\t\treturn\n\t}\n\n\tp.note(\n\t\t[\n\t\t\t`Watching: ${displayPath(root)}`,\n\t\t\t`Settings: ${describeSettings(chosen?.settings as EncodeSettings)}`,\n\t\t\t`Save to: ${displayPath(out)}`,\n\t\t\t'',\n\t\t\t'New or changed images are optimized automatically.',\n\t\t\tisInside(out, root) ? `(${path.basename(out)} itself is ignored)` : '',\n\t\t]\n\t\t\t.filter(Boolean)\n\t\t\t.join('\\n'),\n\t\t'Watching — press Ctrl+C to stop',\n\t)\n\n\tawait new Promise<void>((resolve) => process.once('SIGINT', () => resolve()))\n\thandle.close()\n\tp.log.info('Stopped watching.')\n}\n\n// ─── hook ───────────────────────────────────────────────────────────────────\n\nasync function runHook(): Promise<void> {\n\tconst action = await ask(\n\t\tp.select<string>({\n\t\t\tmessage: `Git hook for ${displayPath(process.cwd())}`,\n\t\t\toptions: [\n\t\t\t\t{ value: 'install', label: 'Install', hint: 'optimize staged images on every commit' },\n\t\t\t\t{ value: 'uninstall', label: 'Uninstall', hint: 'remove the hook' },\n\t\t\t],\n\t\t}),\n\t)\n\tif (action === BACK) return\n\ttry {\n\t\tif (action === 'install') await installHook()\n\t\telse await uninstallHook()\n\t} catch (err) {\n\t\tif (!(err instanceof HookError)) throw err\n\t\tp.log.error(`${err.message} (current folder: ${displayPath(process.cwd())})`)\n\t}\n}\n\n// ─── entry ──────────────────────────────────────────────────────────────────\n\nexport async function runInteractive(prefill?: string[]): Promise<void> {\n\tif (!process.stdin.isTTY) {\n\t\tconsole.error('Interactive mode needs a terminal. Run `bulki --help` to see the commands.')\n\t\tprocess.exit(1)\n\t}\n\n\treadline.emitKeypressEvents(process.stdin)\n\tprocess.stdin.on('keypress', (_str, key) => {\n\t\tif (key?.ctrl && key.name === 'c') ctrlCPressed = true\n\t})\n\n\tp.intro('Punto y Coma — bulki')\n\tp.log.message('Esc = go back · Ctrl+C = quit', { symbol: ' ' })\n\n\tlet next: 'menu' | 'again' = 'menu'\n\tlet pending = prefill\n\twhile (true) {\n\t\tlet action: string | Back = 'compress'\n\t\tif (next === 'menu' && !pending) {\n\t\t\taction = await ask(\n\t\t\t\tp.select<string>({\n\t\t\t\t\tmessage: 'What do you want to do?',\n\t\t\t\t\toptions: [\n\t\t\t\t\t\t{ value: 'compress', label: 'Optimize images', hint: 'files or folders' },\n\t\t\t\t\t\t{ value: 'watch', label: 'Watch a folder', hint: 'auto-optimize new images' },\n\t\t\t\t\t\t{ value: 'hook', label: 'Git hook', hint: 'auto-optimize on commit' },\n\t\t\t\t\t\t{ value: 'quit', label: 'Exit' },\n\t\t\t\t\t],\n\t\t\t\t}),\n\t\t\t)\n\t\t\tif (action === 'quit') quit()\n\t\t\tif (action === BACK) {\n\t\t\t\t// Esc on the main menu must not close bulki — leaving is always explicit\n\t\t\t\tp.log.info('To leave bulki choose \"Exit\" or press Ctrl+C.')\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\ttry {\n\t\t\tif (action === 'compress') {\n\t\t\t\tnext = await runCompress(pending)\n\t\t\t\tpending = undefined\n\t\t\t} else if (action === 'watch') {\n\t\t\t\tawait runWatch()\n\t\t\t\tnext = 'menu'\n\t\t\t} else {\n\t\t\t\tawait runHook()\n\t\t\t\tnext = 'menu'\n\t\t\t}\n\t\t} catch (err) {\n\t\t\t// Last line of defence: report and return to the menu instead of crashing\n\t\t\tp.log.error(`Something went wrong: ${(err as Error).message}`)\n\t\t\tpending = undefined\n\t\t\tnext = 'menu'\n\t\t}\n\t}\n}\n"],"mappings":"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","names":["preset","parsed","out"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@puntoycoma/bulki",
3
- "version": "3.0.1",
3
+ "version": "3.0.3",
4
4
  "description": "Bulk image optimizer CLI — compress, resize, convert JPEG/PNG/WebP/AVIF. Interactive mode, MCP server for AI editors, watch mode, git hooks. Free.",
5
5
  "keywords": [
6
6
  "image",
@@ -78,6 +78,7 @@
78
78
  },
79
79
  "devDependencies": {
80
80
  "@biomejs/biome": "^2.0.0",
81
+ "@types/node": "^20.19.43",
81
82
  "execa": "^9.0.0",
82
83
  "tsup": "^8.0.0",
83
84
  "typescript": "^5.0.0",