@macrulez/devtoolz 0.3.3 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +79 -3
  2. package/dist/cli.js +3 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/image-batch/backup.d.ts +22 -0
  5. package/dist/commands/image-batch/backup.js +55 -0
  6. package/dist/commands/image-batch/backup.js.map +1 -0
  7. package/dist/commands/image-batch/cache.d.ts +15 -0
  8. package/dist/commands/image-batch/cache.js +38 -0
  9. package/dist/commands/image-batch/cache.js.map +1 -0
  10. package/dist/commands/image-batch/cli.d.ts +15 -0
  11. package/dist/commands/image-batch/cli.js +307 -0
  12. package/dist/commands/image-batch/cli.js.map +1 -0
  13. package/dist/commands/image-batch/codecs.d.ts +38 -0
  14. package/dist/commands/image-batch/codecs.js +366 -0
  15. package/dist/commands/image-batch/codecs.js.map +1 -0
  16. package/dist/commands/image-batch/config.d.ts +120 -0
  17. package/dist/commands/image-batch/config.js +679 -0
  18. package/dist/commands/image-batch/config.js.map +1 -0
  19. package/dist/commands/image-batch/errors.d.ts +5 -0
  20. package/dist/commands/image-batch/errors.js +8 -0
  21. package/dist/commands/image-batch/errors.js.map +1 -0
  22. package/dist/commands/image-batch/geometry.d.ts +26 -0
  23. package/dist/commands/image-batch/geometry.js +90 -0
  24. package/dist/commands/image-batch/geometry.js.map +1 -0
  25. package/dist/commands/image-batch/inputs.d.ts +22 -0
  26. package/dist/commands/image-batch/inputs.js +87 -0
  27. package/dist/commands/image-batch/inputs.js.map +1 -0
  28. package/dist/commands/image-batch/manage-cli.d.ts +2 -0
  29. package/dist/commands/image-batch/manage-cli.js +276 -0
  30. package/dist/commands/image-batch/manage-cli.js.map +1 -0
  31. package/dist/commands/image-batch/manage.d.ts +51 -0
  32. package/dist/commands/image-batch/manage.js +1041 -0
  33. package/dist/commands/image-batch/manage.js.map +1 -0
  34. package/dist/commands/image-batch/overwrite.d.ts +14 -0
  35. package/dist/commands/image-batch/overwrite.js +31 -0
  36. package/dist/commands/image-batch/overwrite.js.map +1 -0
  37. package/dist/commands/image-batch/plan.d.ts +86 -0
  38. package/dist/commands/image-batch/plan.js +396 -0
  39. package/dist/commands/image-batch/plan.js.map +1 -0
  40. package/dist/commands/image-batch/process.d.ts +8 -0
  41. package/dist/commands/image-batch/process.js +97 -0
  42. package/dist/commands/image-batch/process.js.map +1 -0
  43. package/dist/commands/image-batch/report.d.ts +6 -0
  44. package/dist/commands/image-batch/report.js +251 -0
  45. package/dist/commands/image-batch/report.js.map +1 -0
  46. package/dist/commands/image-batch/restore.d.ts +29 -0
  47. package/dist/commands/image-batch/restore.js +125 -0
  48. package/dist/commands/image-batch/restore.js.map +1 -0
  49. package/dist/commands/image-batch/run.d.ts +123 -0
  50. package/dist/commands/image-batch/run.js +649 -0
  51. package/dist/commands/image-batch/run.js.map +1 -0
  52. package/dist/commands/image-batch/sharpen-ask.d.ts +6 -0
  53. package/dist/commands/image-batch/sharpen-ask.js +53 -0
  54. package/dist/commands/image-batch/sharpen-ask.js.map +1 -0
  55. package/dist/commands/image-batch/sharpen-manage.d.ts +26 -0
  56. package/dist/commands/image-batch/sharpen-manage.js +238 -0
  57. package/dist/commands/image-batch/sharpen-manage.js.map +1 -0
  58. package/dist/commands/image-batch/sharpen-presets.d.ts +24 -0
  59. package/dist/commands/image-batch/sharpen-presets.js +145 -0
  60. package/dist/commands/image-batch/sharpen-presets.js.map +1 -0
  61. package/dist/commands/image-batch/sharpen.d.ts +46 -0
  62. package/dist/commands/image-batch/sharpen.js +159 -0
  63. package/dist/commands/image-batch/sharpen.js.map +1 -0
  64. package/dist/commands/image-batch/template.d.ts +45 -0
  65. package/dist/commands/image-batch/template.js +142 -0
  66. package/dist/commands/image-batch/template.js.map +1 -0
  67. package/dist/commands/image-hash/sharp-loader.d.ts +2 -0
  68. package/dist/commands/image-hash/sharp-loader.js +7 -5
  69. package/dist/commands/image-hash/sharp-loader.js.map +1 -1
  70. package/dist/format/bytes.d.ts +2 -0
  71. package/dist/format/bytes.js +17 -0
  72. package/dist/format/bytes.js.map +1 -0
  73. package/dist/index.d.ts +5 -0
  74. package/dist/index.js +3 -0
  75. package/dist/index.js.map +1 -1
  76. package/dist/utils/closest.d.ts +2 -0
  77. package/dist/utils/closest.js +35 -0
  78. package/dist/utils/closest.js.map +1 -0
  79. package/dist/utils/glob.d.ts +4 -0
  80. package/dist/utils/glob.js +86 -0
  81. package/dist/utils/glob.js.map +1 -0
  82. package/dist/utils/tty.d.ts +60 -0
  83. package/dist/utils/tty.js +390 -0
  84. package/dist/utils/tty.js.map +1 -0
  85. package/package.json +1 -1
@@ -0,0 +1,1041 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync, } from 'node:fs';
2
+ import { basename, dirname, extname, join } from 'node:path';
3
+ import { createStyle } from '../../format/style.js';
4
+ import { renderTable } from '../../format/table.js';
5
+ import { askText, isInteractive, multiSelect, promptChoice, singleSelect, } from '../../utils/tty.js';
6
+ import { parseCodecArgs, parseQualityArg } from './codecs.js';
7
+ import { CONFIG_DIR_SEGMENTS, METADATA_MODES, POSITIONS, discoverConfigs, globalConfigDir, parseConfig, parseRecipeLayer, readConfigSource, resolveRecipe, } from './config.js';
8
+ import { ImageBatchUsageError } from './errors.js';
9
+ import { FIT_MODES } from './geometry.js';
10
+ import { renderImageBatchReport } from './report.js';
11
+ import { askSharpenFields } from './sharpen-ask.js';
12
+ import { attachSharpenPresets, discoverSharpenPresets } from './sharpen-presets.js';
13
+ import { SHARPEN_AMOUNTS, describeSharpen, } from './sharpen.js';
14
+ import { defaultTemplate } from './template.js';
15
+ import { runImageBatch } from './run.js';
16
+ export function configDir(scope, env) {
17
+ return scope === 'global'
18
+ ? (env.globalDir ?? globalConfigDir())
19
+ : join(env.cwd, ...CONFIG_DIR_SEGMENTS);
20
+ }
21
+ function detectIndent(text) {
22
+ const match = /\n([ \t]+)"/.exec(text);
23
+ if (!match)
24
+ return 2;
25
+ const indent = match[1];
26
+ return indent.includes('\t') ? '\t' : indent.length;
27
+ }
28
+ export function writeJson(path, value, indent = 2) {
29
+ mkdirSync(dirname(path), { recursive: true });
30
+ writeFileSync(path, `${JSON.stringify(value, null, indent)}\n`);
31
+ }
32
+ function readRawJson(location) {
33
+ const text = readFileSync(location.path, 'utf8');
34
+ let parsed;
35
+ try {
36
+ parsed = JSON.parse(text);
37
+ }
38
+ catch (error) {
39
+ throw new ImageBatchUsageError(`${location.path}: not valid JSON — ${error instanceof Error ? error.message : String(error)}`);
40
+ }
41
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
42
+ throw new ImageBatchUsageError(`${location.path}: expected a JSON object`);
43
+ }
44
+ return { raw: parsed, indent: detectIndent(text) };
45
+ }
46
+ export function validName(name) {
47
+ const trimmed = name.trim();
48
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed)) {
49
+ throw new ImageBatchUsageError(`"${name}" is not a usable config name — use letters, digits, dot, dash and underscore`);
50
+ }
51
+ return trimmed;
52
+ }
53
+ export function buildStarterConfig(options, title) {
54
+ const sizeFields = options.sizeFields ?? { widths: options.widths ?? [400, 800, 1200] };
55
+ const formats = options.formats ?? ['avif', 'webp', 'jpg'];
56
+ const outputs = [
57
+ {
58
+ id: 'responsive',
59
+ ...sizeFields,
60
+ formats,
61
+ ...(options.quality ? { quality: parseQualityArg(options.quality) } : { quality: 'high' }),
62
+ ...(options.fileName ? { name: options.fileName } : {}),
63
+ ...(options.sharpen
64
+ ? {
65
+ sharpen: Object.keys(options.sharpen).length === 1 && options.sharpen.preset !== undefined
66
+ ? options.sharpen.preset
67
+ : options.sharpen,
68
+ }
69
+ : {}),
70
+ },
71
+ ];
72
+ if (options.thumbnail) {
73
+ outputs.push({
74
+ id: 'thumb',
75
+ size: '200x200',
76
+ fit: 'cover',
77
+ formats: ['webp'],
78
+ name: '{dir}/{name}-thumb.{format}',
79
+ });
80
+ }
81
+ const config = {
82
+ name: title,
83
+ description: 'Created by devtoolz image-batch init',
84
+ outputs,
85
+ };
86
+ if (options.hazehash)
87
+ config.placeholders = { hazehash: { budget: 28 } };
88
+ parseConfig(config, title);
89
+ return config;
90
+ }
91
+ const FORMAT_CHOICES = [
92
+ { label: 'avif', hint: 'smallest files, slower to make', value: 'avif', selected: true },
93
+ { label: 'webp', hint: 'small, supported everywhere', value: 'webp', selected: true },
94
+ { label: 'jpg', hint: 'photos, works with everything', value: 'jpg', selected: true },
95
+ { label: 'png', hint: 'lossless, keeps transparency', value: 'png' },
96
+ { label: 'gif', hint: 'animation, up to 256 colors', value: 'gif' },
97
+ { label: 'tiff', hint: 'print and archive', value: 'tiff' },
98
+ { label: 'heif', hint: 'HEIF container, av1', value: 'heif' },
99
+ { label: 'original', hint: "keep each file's own format", value: 'original' },
100
+ ];
101
+ async function pickFormats(env, style) {
102
+ for (;;) {
103
+ const picked = await multiSelect(env.io, FORMAT_CHOICES.map((item) => ({ ...item })), {
104
+ title: 'Which formats should the config produce?',
105
+ help: 'space tick · a all · n none · i invert · enter confirm · q cancel',
106
+ }, style);
107
+ if (picked === null)
108
+ throw new ImageBatchUsageError('cancelled');
109
+ if (picked.length > 0)
110
+ return picked;
111
+ env.log(style.warn('Pick at least one format.'));
112
+ }
113
+ }
114
+ const SIZE_METHODS = [
115
+ {
116
+ value: 'widths',
117
+ label: 'Width',
118
+ hint: 'one side is given, the other follows the proportions',
119
+ sample: '400,800,1200',
120
+ question: 'Widths in pixels (comma-separated):',
121
+ },
122
+ {
123
+ value: 'heights',
124
+ label: 'Height',
125
+ hint: 'the same, by height',
126
+ sample: '300,600',
127
+ question: 'Heights in pixels (comma-separated):',
128
+ },
129
+ {
130
+ value: 'size',
131
+ label: 'Box (width × height)',
132
+ hint: 'fit inside it, or fill it exactly',
133
+ sample: '1920x1080',
134
+ question: 'Box, width x height:',
135
+ },
136
+ {
137
+ value: 'longEdge',
138
+ label: 'Long side',
139
+ hint: 'the same for landscape and portrait images',
140
+ sample: '1600',
141
+ question: 'Long side in pixels (comma-separated):',
142
+ },
143
+ {
144
+ value: 'shortEdge',
145
+ label: 'Short side',
146
+ hint: 'the same for landscape and portrait images',
147
+ sample: '1080',
148
+ question: 'Short side in pixels (comma-separated):',
149
+ },
150
+ {
151
+ value: 'megapixels',
152
+ label: 'Megapixels',
153
+ hint: 'by area, whatever the proportions',
154
+ sample: '2,0.5',
155
+ question: 'Megapixels (comma-separated):',
156
+ },
157
+ {
158
+ value: 'percent',
159
+ label: 'Percentage',
160
+ hint: 'of the source size',
161
+ sample: '50,25',
162
+ question: 'Percentages (comma-separated):',
163
+ },
164
+ {
165
+ value: 'original',
166
+ label: 'Original size',
167
+ hint: 'only convert or recompress',
168
+ sample: '',
169
+ question: '',
170
+ },
171
+ ];
172
+ const SIZE_FIELDS = [
173
+ 'widths',
174
+ 'heights',
175
+ 'size',
176
+ 'longEdge',
177
+ 'shortEdge',
178
+ 'megapixels',
179
+ 'percent',
180
+ 'matchOrientation',
181
+ ];
182
+ const FIT_CHOICES = [
183
+ { label: 'inside', hint: 'the whole image, proportions kept', value: 'inside' },
184
+ { label: 'cover', hint: 'fill the box, crop what does not fit', value: 'cover' },
185
+ { label: 'contain', hint: 'the whole image, padded to the box', value: 'contain' },
186
+ { label: 'outside', hint: 'cover the box without cropping', value: 'outside' },
187
+ { label: 'fill', hint: 'stretch to the box', value: 'fill' },
188
+ ];
189
+ export async function chooseSizeMethod(env, style, current) {
190
+ const method = await singleSelect(env.io, SIZE_METHODS.map((entry) => ({ label: entry.label, hint: entry.hint, value: entry.value })), { title: 'How should the size be chosen?', help: 'enter pick · q cancel' }, style);
191
+ if (method === null)
192
+ return null;
193
+ if (method === 'original')
194
+ return {};
195
+ const entry = SIZE_METHODS.find((candidate) => candidate.value === method);
196
+ const existing = current?.[method];
197
+ const initial = existing === undefined
198
+ ? entry.sample
199
+ : Array.isArray(existing)
200
+ ? existing.join(',')
201
+ : String(existing);
202
+ const answer = await askText(env.io, entry.question, style, initial);
203
+ if (answer === null)
204
+ return null;
205
+ const text = answer.trim();
206
+ if (method === 'size') {
207
+ const fit = await singleSelect(env.io, FIT_CHOICES.map((item) => ({ ...item })), { title: 'How should the image fill the box?', help: 'enter pick · q keep "inside"' }, style);
208
+ const turn = await promptChoice(env.io, 'Turn the box around for images of the other orientation?', [
209
+ { key: 'y', label: 'yes' },
210
+ { key: 'n', label: 'no' },
211
+ ], style, 'n');
212
+ return {
213
+ size: text,
214
+ ...(fit && fit !== 'inside' ? { fit } : {}),
215
+ ...(turn === 'y' ? { matchOrientation: true } : {}),
216
+ };
217
+ }
218
+ const numbers = text
219
+ .split(/[,\s]+/)
220
+ .filter(Boolean)
221
+ .map(Number);
222
+ if (numbers.length === 0 || numbers.some((value) => !Number.isFinite(value))) {
223
+ throw new ImageBatchUsageError(`${entry.label}: expected numbers separated by commas`);
224
+ }
225
+ return { [method]: numbers };
226
+ }
227
+ export async function runInit(env, options) {
228
+ const s = createStyle(env.style);
229
+ const interactive = isInteractive(env.io);
230
+ const settings = { ...options };
231
+ if (settings.name === undefined) {
232
+ if (!interactive) {
233
+ throw new ImageBatchUsageError('init needs --name <name> when it is not run in a terminal');
234
+ }
235
+ const answer = await askText(env.io, 'Name of the config:', s, 'web');
236
+ if (answer === null)
237
+ throw new ImageBatchUsageError('cancelled');
238
+ settings.name = answer;
239
+ }
240
+ const name = validName(settings.name);
241
+ if (interactive && options.global === undefined) {
242
+ const scope = await singleSelect(env.io, [
243
+ { label: 'This project', hint: configDir('project', env), value: 'project' },
244
+ {
245
+ label: 'Global (all projects)',
246
+ hint: configDir('global', env),
247
+ value: 'global',
248
+ },
249
+ ], { title: 'Where should the config be saved?', help: 'enter pick · q cancel' }, s);
250
+ if (scope === null)
251
+ throw new ImageBatchUsageError('cancelled');
252
+ settings.global = scope === 'global';
253
+ }
254
+ if (interactive && options.widths === undefined && options.sizeFields === undefined) {
255
+ const fields = await chooseSizeMethod(env, s);
256
+ if (fields === null)
257
+ throw new ImageBatchUsageError('cancelled');
258
+ settings.sizeFields = fields;
259
+ }
260
+ if (interactive && options.formats === undefined) {
261
+ settings.formats = await pickFormats(env, s);
262
+ }
263
+ if (interactive && options.quality === undefined) {
264
+ const level = await singleSelect(env.io, ['low', 'medium', 'high', 'best'].map((value) => ({
265
+ label: value,
266
+ hint: value === 'high' ? 'recommended' : '',
267
+ value,
268
+ })), { title: 'Quality level', help: 'enter pick · q keep "high"' }, s);
269
+ settings.quality = level ?? 'high';
270
+ }
271
+ if (interactive && options.fileName === undefined) {
272
+ const fallback = defaultTemplate({ widths: true });
273
+ env.log(s.muted('Variables: {name} {ext} {orig} {dir} {format} {width} {height} {size} {long} {short} {mp} {percent} {scale} {index:3} {hash} {date}'));
274
+ const answer = await askText(env.io, 'File name template (Enter keeps the default):', s, fallback);
275
+ if (answer === null)
276
+ throw new ImageBatchUsageError('cancelled');
277
+ const chosen = answer.trim();
278
+ if (chosen !== '' && chosen !== fallback)
279
+ settings.fileName = chosen;
280
+ }
281
+ if (interactive && options.sharpen === undefined) {
282
+ const presets = [
283
+ ...new Set(discoverSharpenPresets({
284
+ cwd: env.cwd,
285
+ ...(env.globalDir !== undefined ? { globalDir: env.globalDir } : {}),
286
+ }).map((location) => location.name)),
287
+ ];
288
+ const target = await singleSelect(env.io, [
289
+ { label: '(none)', hint: 'no sharpening', value: '' },
290
+ { label: 'screen', hint: 'light, for web and displays', value: 'screen' },
291
+ { label: 'matte', hint: 'medium, for matte paper', value: 'matte' },
292
+ { label: 'glossy', hint: 'strong, for glossy paper', value: 'glossy' },
293
+ ...presets.map((name) => ({ label: name, hint: 'saved preset', value: `preset:${name}` })),
294
+ ], { title: 'Sharpen the results after resizing?', help: 'enter pick · q none' }, s);
295
+ if (target?.startsWith('preset:')) {
296
+ settings.sharpen = { preset: target.slice('preset:'.length) };
297
+ }
298
+ else if (target) {
299
+ const amount = await singleSelect(env.io, SHARPEN_AMOUNTS.map((value) => ({
300
+ label: value,
301
+ hint: value === 'standard' ? 'recommended' : '',
302
+ value,
303
+ })), { title: 'Sharpening amount', help: 'enter pick · q keep "standard"' }, s);
304
+ settings.sharpen = { for: target, amount: amount ?? 'standard' };
305
+ }
306
+ }
307
+ if (interactive && options.thumbnail === undefined) {
308
+ const answer = await promptChoice(env.io, 'Add a 200×200 cropped thumbnail recipe?', [
309
+ { key: 'y', label: 'yes' },
310
+ { key: 'n', label: 'no' },
311
+ ], s, 'n');
312
+ settings.thumbnail = answer === 'y';
313
+ }
314
+ if (interactive && options.hazehash === undefined) {
315
+ const answer = await promptChoice(env.io, 'Compute a hazehash preview for the --emit manifest?', [
316
+ { key: 'y', label: 'yes' },
317
+ { key: 'n', label: 'no' },
318
+ ], s, 'n');
319
+ settings.hazehash = answer === 'y';
320
+ }
321
+ const scope = settings.global ? 'global' : 'project';
322
+ const dir = configDir(scope, env);
323
+ const path = join(dir, `${name}.json`);
324
+ if (existsSync(path) && !settings.force) {
325
+ throw new ImageBatchUsageError(`${path} already exists — pick another name or pass --force`);
326
+ }
327
+ writeJson(path, buildStarterConfig(settings, name));
328
+ env.log(`${s.success('Created')} ${s.path(path)}`);
329
+ env.log(s.hint(`(use it with: devtoolz image-batch <folder> -o <out> -c ${name} — edit it with: devtoolz image-batch config)`));
330
+ return path;
331
+ }
332
+ export async function describeLocation(location) {
333
+ try {
334
+ const raw = await readConfigSource(location);
335
+ const config = parseConfig(raw, location.name);
336
+ return {
337
+ location,
338
+ ...(config.title !== undefined ? { title: config.title } : {}),
339
+ ...(config.description !== undefined ? { description: config.description } : {}),
340
+ recipes: config.outputs.length,
341
+ rules: config.match.length,
342
+ };
343
+ }
344
+ catch (error) {
345
+ return {
346
+ location,
347
+ recipes: 0,
348
+ rules: 0,
349
+ error: error instanceof Error ? error.message : String(error),
350
+ };
351
+ }
352
+ }
353
+ export async function listConfigRows(env) {
354
+ const discovered = discoverConfigs({
355
+ cwd: env.cwd,
356
+ ...(env.globalDir !== undefined ? { globalDir: env.globalDir } : {}),
357
+ });
358
+ return Promise.all(discovered.map(describeLocation));
359
+ }
360
+ export function renderConfigTable(rows, style) {
361
+ const s = createStyle(style);
362
+ const columns = [
363
+ { header: 'Name', style: s.name },
364
+ { header: 'Scope', style: s.accent },
365
+ { header: 'Recipes', align: 'right', style: s.value },
366
+ { header: 'Rules', align: 'right', style: s.value },
367
+ { header: 'Path', shrink: true, style: s.path },
368
+ ];
369
+ return renderTable(columns, rows.map((row) => [
370
+ row.location.name,
371
+ row.location.scope,
372
+ row.error ? 'error' : String(row.recipes),
373
+ row.error ? '' : String(row.rules),
374
+ row.location.path,
375
+ ]), style);
376
+ }
377
+ function defaultNames(resolved) {
378
+ const names = [];
379
+ const add = (shape) => {
380
+ const text = defaultTemplate(shape);
381
+ if (!names.includes(text))
382
+ names.push(text);
383
+ };
384
+ if (resolved.widths.length)
385
+ add({ widths: true });
386
+ if (resolved.heights.length)
387
+ add({ heights: true });
388
+ if (resolved.size)
389
+ add({ size: true });
390
+ if (resolved.longEdge.length)
391
+ add({ longEdge: true });
392
+ if (resolved.shortEdge.length)
393
+ add({ shortEdge: true });
394
+ if (resolved.megapixels.length)
395
+ add({ megapixels: true });
396
+ if (resolved.percent.length)
397
+ add({ percent: true });
398
+ if (names.length === 0)
399
+ add({ scale: resolved.scales.some((x) => x !== 1) });
400
+ return names.join(' ');
401
+ }
402
+ function recipeSummary(layer) {
403
+ const parts = [];
404
+ const list = (value) => (Array.isArray(value) ? value.join(',') : String(value));
405
+ if (layer.preset)
406
+ parts.push(`preset ${String(layer.preset)}`);
407
+ if (layer.widths)
408
+ parts.push(`w ${list(layer.widths)}`);
409
+ if (layer.heights)
410
+ parts.push(`h ${list(layer.heights)}`);
411
+ if (layer.size)
412
+ parts.push(`size ${String(layer.size)}`);
413
+ if (layer.longEdge)
414
+ parts.push(`long ${list(layer.longEdge)}`);
415
+ if (layer.shortEdge)
416
+ parts.push(`short ${list(layer.shortEdge)}`);
417
+ if (layer.megapixels)
418
+ parts.push(`${list(layer.megapixels)} MP`);
419
+ if (layer.percent)
420
+ parts.push(`${list(layer.percent)}%`);
421
+ if (layer.scale)
422
+ parts.push(`×${list(layer.scale)}`);
423
+ if (layer.formats) {
424
+ parts.push(Array.isArray(layer.formats)
425
+ ? layer.formats.join('/')
426
+ : typeof layer.formats === 'object' && layer.formats !== null
427
+ ? Object.keys(layer.formats).join('/')
428
+ : String(layer.formats));
429
+ }
430
+ if (layer.quality !== undefined) {
431
+ parts.push(`q ${typeof layer.quality === 'object' ? JSON.stringify(layer.quality) : String(layer.quality)}`);
432
+ }
433
+ if (layer.fit)
434
+ parts.push(`fit ${String(layer.fit)}`);
435
+ if (typeof layer.sharpen === 'string')
436
+ parts.push(`sharpen ${layer.sharpen}`);
437
+ else if (layer.sharpen && typeof layer.sharpen === 'object') {
438
+ parts.push(`sharpen ${describeSharpen(layer.sharpen)}`);
439
+ }
440
+ return parts.join(' · ');
441
+ }
442
+ export function describeConfigLines(config, style) {
443
+ const s = createStyle(style);
444
+ const lines = [];
445
+ if (config.title)
446
+ lines.push(`${s.heading(config.title)}${config.description ? s.muted(` — ${config.description}`) : ''}`);
447
+ const printRecipe = (label, layer) => {
448
+ const resolved = resolveRecipe(config, layer);
449
+ const dims = [
450
+ resolved.widths.length ? `widths ${resolved.widths.join(', ')}` : '',
451
+ resolved.heights.length ? `heights ${resolved.heights.join(', ')}` : '',
452
+ resolved.size
453
+ ? `box ${resolved.size.width}×${resolved.size.height}${resolved.matchOrientation ? ' (matches orientation)' : ''}`
454
+ : '',
455
+ resolved.longEdge.length ? `long side ${resolved.longEdge.join(', ')}` : '',
456
+ resolved.shortEdge.length ? `short side ${resolved.shortEdge.join(', ')}` : '',
457
+ resolved.megapixels.length ? `${resolved.megapixels.join(', ')} MP` : '',
458
+ resolved.percent.length ? `${resolved.percent.join(', ')}%` : '',
459
+ resolved.scales.some((x) => x !== 1) ? `scale ${resolved.scales.join(', ')}` : '',
460
+ ].filter(Boolean);
461
+ lines.push(` ${s.name(label)} ${s.value(dims.join(' · ') || 'original size')} ${s.muted('→')} ${s.accent(resolved.formats.join(', '))} ${s.muted(`fit ${resolved.fit}`)}`);
462
+ lines.push(` ${s.muted('name')} ${s.path(resolved.explicitTemplate ? resolved.template.text : defaultNames(resolved))}${resolved.maxBytes ? ` ${s.muted(`limit ${Math.round(resolved.maxBytes / 1024)} KB`)}` : ''}`);
463
+ };
464
+ if (config.outputs.length > 0) {
465
+ lines.push(s.heading('Recipes'));
466
+ config.outputs.forEach((layer, i) => printRecipe(layer.id ?? `#${i + 1}`, layer));
467
+ }
468
+ if (config.match.length > 0) {
469
+ lines.push(s.heading('Rules'));
470
+ config.match.forEach((rule) => {
471
+ lines.push(` ${s.accent(rule.glob)}`);
472
+ rule.outputs.forEach((layer, i) => printRecipe(` ${layer.id ?? `#${i + 1}`}`, layer));
473
+ });
474
+ }
475
+ if (config.placeholders) {
476
+ lines.push(s.heading('Placeholders'), ` ${s.value(config.placeholders.types.join(', '))}${config.placeholders.budget ? s.muted(` (hazehash ${config.placeholders.budget} bytes)`) : ''}`);
477
+ }
478
+ return lines;
479
+ }
480
+ export async function showConfig(env, location) {
481
+ const raw = await readConfigSource(location);
482
+ const config = attachSharpenPresets(parseConfig(raw, location.name), {
483
+ cwd: env.cwd,
484
+ ...(env.globalDir !== undefined ? { globalDir: env.globalDir } : {}),
485
+ });
486
+ env.log(describeConfigLines(config, env.style).join('\n'));
487
+ }
488
+ export function removeConfigFile(location, force) {
489
+ if (force) {
490
+ rmSync(location.path, { force: true });
491
+ return location.path;
492
+ }
493
+ const backup = `${location.path}.bak`;
494
+ if (existsSync(backup))
495
+ rmSync(backup, { force: true });
496
+ renameSync(location.path, backup);
497
+ return backup;
498
+ }
499
+ function uniquePath(dir, name, kind) {
500
+ return join(dir, `${name}.${kind}`);
501
+ }
502
+ const FIELD_KEYS = [
503
+ 'id',
504
+ 'preset',
505
+ 'widths',
506
+ 'heights',
507
+ 'size',
508
+ 'longEdge',
509
+ 'shortEdge',
510
+ 'megapixels',
511
+ 'percent',
512
+ 'scale',
513
+ 'matchOrientation',
514
+ 'maxBytes',
515
+ 'formats',
516
+ 'quality',
517
+ 'codecs',
518
+ 'fit',
519
+ 'position',
520
+ 'background',
521
+ 'withoutEnlargement',
522
+ 'name',
523
+ 'autoOrient',
524
+ 'metadata',
525
+ 'rotate',
526
+ 'flip',
527
+ 'flop',
528
+ 'grayscale',
529
+ 'sharpen',
530
+ 'blur',
531
+ 'flatten',
532
+ ];
533
+ function currentValue(layer, key) {
534
+ const value = layer[key];
535
+ if (value === undefined)
536
+ return '';
537
+ return typeof value === 'object' ? JSON.stringify(value) : String(value);
538
+ }
539
+ async function pickFrom(env, s, title, options, allowUnset) {
540
+ const items = options.map((value) => ({ label: value, value }));
541
+ if (allowUnset)
542
+ items.unshift({ label: '(unset)', hint: 'remove the setting', value: '' });
543
+ return singleSelect(env.io, items, { title, help: 'enter pick · q cancel' }, s);
544
+ }
545
+ function csvNumbers(text) {
546
+ return text
547
+ .split(/[,\s]+/)
548
+ .filter(Boolean)
549
+ .map((part) => Number(part));
550
+ }
551
+ async function editField(env, s, layer, key, presets) {
552
+ const current = currentValue(layer, key);
553
+ const set = (value) => {
554
+ if (value === undefined || value === '')
555
+ delete layer[key];
556
+ else
557
+ layer[key] = value;
558
+ };
559
+ const askRaw = async (question) => {
560
+ const answer = await askText(env.io, question, s, current);
561
+ return answer === null ? undefined : answer.trim();
562
+ };
563
+ switch (key) {
564
+ case 'fit':
565
+ case 'position':
566
+ case 'metadata': {
567
+ const choices = key === 'fit' ? FIT_MODES : key === 'position' ? POSITIONS : METADATA_MODES;
568
+ const picked = await pickFrom(env, s, `${key}`, choices, true);
569
+ if (picked === null)
570
+ return false;
571
+ set(picked);
572
+ return true;
573
+ }
574
+ case 'preset': {
575
+ const picked = await pickFrom(env, s, 'preset', presets, true);
576
+ if (picked === null)
577
+ return false;
578
+ set(picked);
579
+ return true;
580
+ }
581
+ case 'rotate': {
582
+ const picked = await pickFrom(env, s, 'rotate', ['90', '180', '270'], true);
583
+ if (picked === null)
584
+ return false;
585
+ set(picked === '' ? undefined : Number(picked));
586
+ return true;
587
+ }
588
+ case 'withoutEnlargement':
589
+ case 'matchOrientation':
590
+ case 'autoOrient':
591
+ case 'flip':
592
+ case 'flop':
593
+ case 'grayscale': {
594
+ const picked = await pickFrom(env, s, key, ['true', 'false'], true);
595
+ if (picked === null)
596
+ return false;
597
+ set(picked === '' ? undefined : picked === 'true');
598
+ return true;
599
+ }
600
+ case 'widths':
601
+ case 'heights':
602
+ case 'longEdge':
603
+ case 'shortEdge':
604
+ case 'megapixels':
605
+ case 'percent':
606
+ case 'scale': {
607
+ const answer = await askRaw(`${key} (comma-separated, empty = remove):`);
608
+ if (answer === undefined)
609
+ return false;
610
+ set(answer === '' ? undefined : csvNumbers(answer));
611
+ return true;
612
+ }
613
+ case 'formats': {
614
+ const answer = await askRaw('formats (comma-separated, empty = remove):');
615
+ if (answer === undefined)
616
+ return false;
617
+ set(answer === '' ? undefined : answer.split(/[,\s]+/).filter(Boolean));
618
+ return true;
619
+ }
620
+ case 'quality': {
621
+ const answer = await askRaw('quality (82, high, or jpg=82,webp=high; empty = remove):');
622
+ if (answer === undefined)
623
+ return false;
624
+ set(answer === '' ? undefined : parseQualityArg(answer));
625
+ return true;
626
+ }
627
+ case 'codecs': {
628
+ const answer = await askRaw('codec options, e.g. jpg.mozjpeg=true png.colors=64 (empty = remove all):');
629
+ if (answer === undefined)
630
+ return false;
631
+ if (answer === '') {
632
+ set(undefined);
633
+ return true;
634
+ }
635
+ const map = parseCodecArgs(answer.split(/\s+/).filter(Boolean));
636
+ const merged = {
637
+ ...(layer.codecs ?? {}),
638
+ };
639
+ for (const [format, options] of Object.entries(map)) {
640
+ merged[format] = { ...merged[format], ...options };
641
+ }
642
+ layer.codecs = merged;
643
+ return true;
644
+ }
645
+ case 'sharpen': {
646
+ const how = await pickFrom(env, s, 'sharpen', ['preset', 'custom'], true);
647
+ if (how === null)
648
+ return false;
649
+ if (how === '') {
650
+ set(undefined);
651
+ return true;
652
+ }
653
+ if (how === 'preset') {
654
+ const names = [
655
+ ...new Set(discoverSharpenPresets({
656
+ cwd: env.cwd,
657
+ ...(env.globalDir !== undefined ? { globalDir: env.globalDir } : {}),
658
+ }).map((location) => location.name)),
659
+ ];
660
+ if (names.length === 0) {
661
+ env.log(s.warn('No sharpen presets yet — create one with `devtoolz image-batch sharpen new`.'));
662
+ return false;
663
+ }
664
+ const name = await pickFrom(env, s, 'Sharpen preset', names, false);
665
+ if (name === null || name === '')
666
+ return false;
667
+ set(name);
668
+ return true;
669
+ }
670
+ const fields = await askSharpenFields(env, s, typeof layer.sharpen === 'object' && layer.sharpen !== null
671
+ ? layer.sharpen
672
+ : {});
673
+ if (fields === null)
674
+ return false;
675
+ set(fields);
676
+ return true;
677
+ }
678
+ case 'blur': {
679
+ const answer = await askRaw('blur sigma (0.3–1000; empty = remove):');
680
+ if (answer === undefined)
681
+ return false;
682
+ set(answer === '' ? undefined : Number(answer));
683
+ return true;
684
+ }
685
+ case 'flatten': {
686
+ const answer = await askRaw('flatten (true, or a background color like #ffffff; empty = remove):');
687
+ if (answer === undefined)
688
+ return false;
689
+ set(answer === '' ? undefined : answer === 'true' ? true : answer);
690
+ return true;
691
+ }
692
+ default: {
693
+ const answer = await askRaw(`${key} (empty = remove):`);
694
+ if (answer === undefined)
695
+ return false;
696
+ set(answer === '' ? undefined : answer);
697
+ return true;
698
+ }
699
+ }
700
+ }
701
+ async function editRecipe(env, s, raw, layer, title, save) {
702
+ const presets = Object.keys(raw.presets ?? {});
703
+ for (;;) {
704
+ const sizePart = {};
705
+ for (const key of SIZE_FIELDS)
706
+ if (layer[key] !== undefined)
707
+ sizePart[key] = layer[key];
708
+ const items = [
709
+ {
710
+ label: '→ size method…',
711
+ hint: recipeSummary(sizePart) || 'original size',
712
+ value: '__size',
713
+ },
714
+ ...FIELD_KEYS.map((key) => ({ label: key, hint: currentValue(layer, key), value: key })),
715
+ { label: '— remove this recipe —', value: '__remove' },
716
+ { label: '— back —', value: '__back' },
717
+ ];
718
+ const picked = await singleSelect(env.io, items, { title: `Edit ${title}`, help: 'enter edit a field · q back', filterable: true }, s);
719
+ if (picked === null || picked === '__back')
720
+ return 'back';
721
+ if (picked === '__size') {
722
+ const before = JSON.stringify(layer);
723
+ try {
724
+ const fields = await chooseSizeMethod(env, s, layer);
725
+ if (fields === null)
726
+ continue;
727
+ for (const key of SIZE_FIELDS)
728
+ delete layer[key];
729
+ Object.assign(layer, fields);
730
+ parseRecipeLayer(layer, title, [...FIELD_KEYS, 'extends']);
731
+ save();
732
+ }
733
+ catch (error) {
734
+ for (const key of Object.keys(layer))
735
+ delete layer[key];
736
+ Object.assign(layer, JSON.parse(before));
737
+ env.log(s.error(error instanceof Error ? error.message : String(error)));
738
+ }
739
+ continue;
740
+ }
741
+ if (picked === '__remove') {
742
+ const sure = await promptChoice(env.io, `${s.warn('Remove')} ${s.name(title)}?`, [
743
+ { key: 'y', label: 'yes, remove' },
744
+ { key: 'n', label: 'no' },
745
+ ], s, 'n');
746
+ if (sure === 'y')
747
+ return 'removed';
748
+ continue;
749
+ }
750
+ const before = JSON.stringify(layer);
751
+ try {
752
+ const changed = await editField(env, s, layer, picked, presets);
753
+ if (!changed)
754
+ continue;
755
+ parseRecipeLayer(layer, title, [...FIELD_KEYS, 'extends']);
756
+ save();
757
+ }
758
+ catch (error) {
759
+ for (const key of Object.keys(layer))
760
+ delete layer[key];
761
+ Object.assign(layer, JSON.parse(before));
762
+ env.log(s.error(error instanceof Error ? error.message : String(error)));
763
+ }
764
+ }
765
+ }
766
+ async function editConfig(env, s, location) {
767
+ if (!location.editable) {
768
+ env.log(s.warn(`${location.name} is a .${location.kind} file — only .json configs can be edited here.`));
769
+ return;
770
+ }
771
+ const { raw, indent } = readRawJson(location);
772
+ const save = () => {
773
+ parseConfig(raw, location.name);
774
+ writeJson(location.path, raw, indent);
775
+ };
776
+ for (;;) {
777
+ const outputs = raw.outputs ?? [];
778
+ const rules = raw.match ?? [];
779
+ const items = [
780
+ ...outputs.map((layer, i) => ({
781
+ label: `recipe ${String(layer.id ?? `#${i + 1}`)}`,
782
+ hint: recipeSummary(layer),
783
+ value: `o:${i}`,
784
+ })),
785
+ ...rules.flatMap((rule, ri) => rule.outputs.map((layer, i) => ({
786
+ label: `rule ${rule.glob} → ${String(layer.id ?? `#${i + 1}`)}`,
787
+ hint: recipeSummary(layer),
788
+ value: `m:${ri}:${i}`,
789
+ }))),
790
+ { label: '+ add a recipe', value: '__add' },
791
+ { label: 'title and description', hint: String(raw.name ?? ''), value: '__title' },
792
+ {
793
+ label: 'defaults',
794
+ hint: recipeSummary(raw.defaults ?? {}),
795
+ value: '__defaults',
796
+ },
797
+ {
798
+ label: 'placeholders',
799
+ hint: JSON.stringify(raw.placeholders ?? {}),
800
+ value: '__placeholders',
801
+ },
802
+ { label: '— done —', value: '__done' },
803
+ ];
804
+ const picked = await singleSelect(env.io, items, { title: `Edit ${location.name}`, help: 'enter open · q done', filterable: true }, s);
805
+ if (picked === null || picked === '__done')
806
+ return;
807
+ try {
808
+ if (picked === '__add') {
809
+ const fields = await chooseSizeMethod(env, s);
810
+ if (fields === null)
811
+ continue;
812
+ const list = (raw.outputs ??= []);
813
+ list.push({ ...fields, formats: ['webp'] });
814
+ save();
815
+ await editRecipe(env, s, raw, list[list.length - 1], `recipe #${list.length}`, save);
816
+ continue;
817
+ }
818
+ if (picked === '__title') {
819
+ const title = await askText(env.io, 'Title:', s, String(raw.name ?? ''));
820
+ if (title !== null) {
821
+ if (title.trim() === '')
822
+ delete raw.name;
823
+ else
824
+ raw.name = title.trim();
825
+ }
826
+ const description = await askText(env.io, 'Description:', s, String(raw.description ?? ''));
827
+ if (description !== null) {
828
+ if (description.trim() === '')
829
+ delete raw.description;
830
+ else
831
+ raw.description = description.trim();
832
+ }
833
+ save();
834
+ continue;
835
+ }
836
+ if (picked === '__defaults') {
837
+ const defaults = (raw.defaults ??= {});
838
+ await editRecipe(env, s, raw, defaults, 'defaults', save);
839
+ if (Object.keys(defaults).length === 0)
840
+ delete raw.defaults;
841
+ save();
842
+ continue;
843
+ }
844
+ if (picked === '__placeholders') {
845
+ const answer = await askText(env.io, 'Placeholders (hazehash, blurhash, thumbhash, color; empty = none):', s, Object.keys(raw.placeholders ?? {}).join(', '));
846
+ if (answer !== null) {
847
+ const names = answer.split(/[,\s]+/).filter(Boolean);
848
+ if (names.length === 0)
849
+ delete raw.placeholders;
850
+ else {
851
+ const previous = raw.placeholders ?? {};
852
+ raw.placeholders = Object.fromEntries(names.map((n) => [n, previous[n] ?? true]));
853
+ }
854
+ save();
855
+ }
856
+ continue;
857
+ }
858
+ if (picked.startsWith('o:')) {
859
+ const index = Number(picked.slice(2));
860
+ const layer = outputs[index];
861
+ const result = await editRecipe(env, s, raw, layer, `recipe ${String(layer.id ?? `#${index + 1}`)}`, save);
862
+ if (result === 'removed') {
863
+ outputs.splice(index, 1);
864
+ save();
865
+ }
866
+ continue;
867
+ }
868
+ const [, ri, i] = picked.split(':');
869
+ const rule = rules[Number(ri)];
870
+ const layer = rule.outputs[Number(i)];
871
+ const result = await editRecipe(env, s, raw, layer, `rule ${rule.glob}`, save);
872
+ if (result === 'removed' && rule.outputs.length > 1) {
873
+ rule.outputs.splice(Number(i), 1);
874
+ save();
875
+ }
876
+ }
877
+ catch (error) {
878
+ env.log(s.error(error instanceof Error ? error.message : String(error)));
879
+ }
880
+ }
881
+ }
882
+ function copyOrWrite(source, targetPath) {
883
+ mkdirSync(dirname(targetPath), { recursive: true });
884
+ copyFileSync(source.path, targetPath);
885
+ }
886
+ export async function manageConfigs(env) {
887
+ const s = createStyle(env.style);
888
+ if (!isInteractive(env.io)) {
889
+ const rows = await listConfigRows(env);
890
+ env.log(rows.length === 0
891
+ ? s.warn('No configs found — create one with `devtoolz image-batch init`.')
892
+ : renderConfigTable(rows, env.style).join('\n'));
893
+ return;
894
+ }
895
+ for (;;) {
896
+ const rows = await listConfigRows(env);
897
+ const items = [
898
+ ...rows.map((row) => ({
899
+ label: row.location.name,
900
+ hint: row.error
901
+ ? `unreadable — ${row.error.slice(0, 50)}`
902
+ : `${row.location.scope} · ${row.recipes} recipe${row.recipes === 1 ? '' : 's'}${row.rules ? ` · ${row.rules} rule${row.rules === 1 ? '' : 's'}` : ''}${row.title ? ` · ${row.title}` : ''}`,
903
+ value: row,
904
+ })),
905
+ { label: '+ new config', hint: 'wizard', value: 'new' },
906
+ ];
907
+ const picked = await singleSelect(env.io, items, { title: 'Configs', help: 'enter open · / filter · q quit', filterable: true }, s);
908
+ if (picked === null)
909
+ return;
910
+ if (picked === 'new') {
911
+ try {
912
+ await runInit(env, {});
913
+ }
914
+ catch (error) {
915
+ env.log(s.error(error instanceof Error ? error.message : String(error)));
916
+ }
917
+ continue;
918
+ }
919
+ const location = picked.location;
920
+ const action = await singleSelect(env.io, [
921
+ {
922
+ label: 'Apply to a folder',
923
+ hint: 'asks for the input and output folders',
924
+ value: 'apply',
925
+ },
926
+ { label: 'Show', hint: 'what it produces', value: 'show' },
927
+ {
928
+ label: 'Edit',
929
+ hint: location.editable ? 'recipes and settings' : 'read-only (.json only)',
930
+ value: 'edit',
931
+ },
932
+ { label: 'Duplicate', value: 'duplicate' },
933
+ { label: 'Rename', value: 'rename' },
934
+ {
935
+ label: location.scope === 'project' ? 'Copy to global' : 'Copy to this project',
936
+ value: 'copy',
937
+ },
938
+ { label: 'Delete', value: 'delete' },
939
+ { label: '— back —', value: 'back' },
940
+ ], { title: `${location.name} ${location.scope}`, help: 'enter pick · q back' }, s);
941
+ try {
942
+ switch (action) {
943
+ case 'apply': {
944
+ const input = await askText(env.io, 'Input folder or files (space-separated):', s, '.');
945
+ if (input === null)
946
+ break;
947
+ const out = await askText(env.io, 'Output folder:', s);
948
+ if (out === null || out.trim() === '')
949
+ break;
950
+ const recursive = await promptChoice(env.io, 'Include subfolders?', [
951
+ { key: 'y', label: 'yes' },
952
+ { key: 'n', label: 'no' },
953
+ ], s, 'y');
954
+ const raw = await readConfigSource(location);
955
+ const config = parseConfig(raw, location.name);
956
+ const run = env.runBatch ?? runImageBatch;
957
+ const report = await run({
958
+ paths: input.split(/\s+/).filter(Boolean),
959
+ cwd: env.cwd,
960
+ out: out.trim(),
961
+ config,
962
+ configLabel: location.name,
963
+ recursive: recursive === 'y',
964
+ io: env.io,
965
+ interactive: true,
966
+ style: env.style,
967
+ });
968
+ env.log(renderImageBatchReport(report, env.style));
969
+ break;
970
+ }
971
+ case 'show':
972
+ await showConfig(env, location);
973
+ break;
974
+ case 'edit':
975
+ await editConfig(env, s, location);
976
+ break;
977
+ case 'duplicate':
978
+ case 'copy': {
979
+ const defaultName = action === 'duplicate' ? `${location.name}-copy` : location.name;
980
+ const name = await askText(env.io, 'Name of the copy:', s, defaultName);
981
+ if (name === null)
982
+ break;
983
+ const scope = action === 'copy'
984
+ ? location.scope === 'project'
985
+ ? 'global'
986
+ : 'project'
987
+ : location.scope;
988
+ const target = uniquePath(configDir(scope, env), validName(name), location.kind);
989
+ if (existsSync(target)) {
990
+ env.log(s.error(`${target} already exists`));
991
+ break;
992
+ }
993
+ copyOrWrite(location, target);
994
+ env.log(`${s.success('Created')} ${s.path(target)}`);
995
+ break;
996
+ }
997
+ case 'rename': {
998
+ const name = await askText(env.io, 'New name:', s, location.name);
999
+ if (name === null)
1000
+ break;
1001
+ const target = join(dirname(location.path), `${validName(name)}${extname(location.path)}`);
1002
+ if (existsSync(target)) {
1003
+ env.log(s.error(`${target} already exists`));
1004
+ break;
1005
+ }
1006
+ renameSync(location.path, target);
1007
+ env.log(`${s.success('Renamed to')} ${s.path(basename(target))}`);
1008
+ break;
1009
+ }
1010
+ case 'delete': {
1011
+ const answer = await promptChoice(env.io, `${s.warn('Delete')} ${s.name(location.name)} ${s.muted(`(${location.path})`)}?`, [
1012
+ { key: 'y', label: 'delete (keeps a .bak copy)' },
1013
+ { key: 'n', label: 'no' },
1014
+ ], s, 'n');
1015
+ if (answer === 'y') {
1016
+ const kept = removeConfigFile(location, false);
1017
+ env.log(`${s.success('Deleted.')} ${s.muted(`Backup: ${kept}`)}`);
1018
+ }
1019
+ break;
1020
+ }
1021
+ default:
1022
+ break;
1023
+ }
1024
+ }
1025
+ catch (error) {
1026
+ env.log(s.error(error instanceof Error ? error.message : String(error)));
1027
+ }
1028
+ }
1029
+ }
1030
+ export function configPathFor(env, ref) {
1031
+ const discovered = discoverConfigs({
1032
+ cwd: env.cwd,
1033
+ ...(env.globalDir !== undefined ? { globalDir: env.globalDir } : {}),
1034
+ });
1035
+ const location = discovered.find((candidate) => candidate.name === ref);
1036
+ if (!location) {
1037
+ throw new ImageBatchUsageError(`no config named "${ref}" — see \`devtoolz image-batch config list\``);
1038
+ }
1039
+ return location;
1040
+ }
1041
+ //# sourceMappingURL=manage.js.map