@ngockhoale/ukit 1.6.4 → 1.6.6
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/CHANGELOG.md +28 -0
- package/manifests/platform.full.yaml +59 -0
- package/package.json +1 -1
- package/src/core/applyPlan.js +6 -1
- package/src/core/buildPlan.js +16 -2
- package/src/core/ensureGitignore.js +136 -60
- package/src/core/runInstallPipeline.js +19 -11
- package/src/core/runtimeConfig.js +37 -1
- package/src/core/uninstall.js +10 -5
- package/templates/.claude/agents/ukit-vision-analyst.md +101 -0
- package/templates/.claude/hooks/handoff-model-guard.sh +13 -0
- package/templates/.claude/hooks/vision-gate.sh +230 -0
- package/templates/.claude/hooks/vision-router.sh +155 -0
- package/templates/.claude/settings.json +10 -0
- package/templates/.claude/ukit/index/extract-image.mjs +434 -0
- package/templates/.claude/ukit/index/route-task.mjs +290 -7
- package/templates/.claude/ukit/index/unic-gateway.mjs +271 -0
- package/templates/CLAUDE.md +8 -0
- package/templates/ukit/storage/config.json +33 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* extract-image.mjs — materialise transcript images so a subagent can Read them.
|
|
4
|
+
*
|
|
5
|
+
* A spawned subagent receives TEXT ONLY and does not inherit the parent's image
|
|
6
|
+
* blocks, so pasted images are invisible unless this script first decodes them
|
|
7
|
+
* to disk. This is the single home of the sha256 key: `pending-<sha>` markers
|
|
8
|
+
* (written by --mark-pending) and `<sha>.<ext>` image files (written by a normal
|
|
9
|
+
* extract) MUST agree exactly, or the TASK-008 gate deadlocks forever. Both
|
|
10
|
+
* paths call the same hashPayload() function on the exact base64 string as it
|
|
11
|
+
* appears in the transcript — no trimming, no normalising, no decoding first.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* node extract-image.mjs [options]
|
|
15
|
+
* --session <path> explicit transcript .jsonl (default: auto-discover)
|
|
16
|
+
* --detect count only; decode nothing, write nothing
|
|
17
|
+
* --mark-pending write pending-<sha>.json markers; NO decode, NO image write
|
|
18
|
+
* --json machine-readable stdout
|
|
19
|
+
* --limit <n> max images, default 3 (most recent first)
|
|
20
|
+
* --out <dir> default <projectRoot>/.ukit/storage/cache/vision
|
|
21
|
+
* --session-id <id> marker subdirectory (default: from transcript filename, else "default")
|
|
22
|
+
* --ref <path|url> repeatable; arm a marker for an image named in the prompt text
|
|
23
|
+
* rather than pasted. Pasted images hash their base64 payload;
|
|
24
|
+
* a local file hashes its bytes and a URL hashes the URL string.
|
|
25
|
+
* Different domains, but the sha only has to be stable and unique.
|
|
26
|
+
*
|
|
27
|
+
* Builtins only (fs, path, crypto, os). No dependencies, no network calls.
|
|
28
|
+
* Never throws: always exits 0, including "no images found" and missing session file.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import fs from 'node:fs';
|
|
32
|
+
import os from 'node:os';
|
|
33
|
+
import path from 'node:path';
|
|
34
|
+
import crypto from 'node:crypto';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
|
|
37
|
+
const MEDIA_EXT = {
|
|
38
|
+
'image/png': 'png',
|
|
39
|
+
'image/jpeg': 'jpg',
|
|
40
|
+
'image/gif': 'gif',
|
|
41
|
+
'image/webp': 'webp',
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const DEFAULT_LIMIT = 3;
|
|
45
|
+
const MAX_DECODED_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
46
|
+
const RETENTION_MAX_IMAGE_FILES = 20;
|
|
47
|
+
const MARKER_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Single shared hash function for both the image filename sha and the
|
|
51
|
+
* pending-<sha> marker sha. Hashes the base64 payload string EXACTLY as it
|
|
52
|
+
* appears in the transcript (no trim/normalise/decode) so the two paths can
|
|
53
|
+
* never diverge.
|
|
54
|
+
*/
|
|
55
|
+
export function hashPayload(base64Data) {
|
|
56
|
+
return crypto.createHash('sha256').update(base64Data, 'utf8').digest('hex');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const REF_EXT_RE = /\.(png|jpe?g|gif|webp|bmp)(?:[?#].*)?$/i;
|
|
60
|
+
const EXT_MEDIA = {
|
|
61
|
+
png: 'image/png',
|
|
62
|
+
jpg: 'image/jpeg',
|
|
63
|
+
jpeg: 'image/jpeg',
|
|
64
|
+
gif: 'image/gif',
|
|
65
|
+
webp: 'image/webp',
|
|
66
|
+
bmp: 'image/bmp',
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Turns --ref values (local paths and URLs named in the prompt) into markers.
|
|
71
|
+
*
|
|
72
|
+
* Pasted images hash their base64 payload; these have no payload to hash, so a local
|
|
73
|
+
* file hashes its bytes and a URL hashes the URL string. The domains differ, but the
|
|
74
|
+
* sha only has to be stable and unique — the gate matches pending-<sha> to
|
|
75
|
+
* analyzed-<sha> by filename and never recomputes it. Still routed through
|
|
76
|
+
* crypto.createHash here so this file remains the single owner of the hash.
|
|
77
|
+
*
|
|
78
|
+
* Silently drops anything that is not an image ref or cannot be read: this feeds a
|
|
79
|
+
* hook that must never wedge a prompt.
|
|
80
|
+
*/
|
|
81
|
+
export function resolveRefs(values) {
|
|
82
|
+
const out = [];
|
|
83
|
+
const seen = new Set();
|
|
84
|
+
for (const value of values ?? []) {
|
|
85
|
+
const match = REF_EXT_RE.exec(value);
|
|
86
|
+
if (!match) continue;
|
|
87
|
+
const mediaType = EXT_MEDIA[match[1].toLowerCase()] ?? 'application/octet-stream';
|
|
88
|
+
const isUrl = /^https?:\/\//i.test(value);
|
|
89
|
+
let sha;
|
|
90
|
+
if (isUrl) {
|
|
91
|
+
sha = crypto.createHash('sha256').update(value, 'utf8').digest('hex');
|
|
92
|
+
} else {
|
|
93
|
+
let bytes;
|
|
94
|
+
try {
|
|
95
|
+
bytes = fs.readFileSync(path.resolve(value));
|
|
96
|
+
} catch {
|
|
97
|
+
continue; // path named in the prompt but not present on disk: nothing to analyse
|
|
98
|
+
}
|
|
99
|
+
if (bytes.length > MAX_DECODED_BYTES) continue;
|
|
100
|
+
sha = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
101
|
+
}
|
|
102
|
+
if (seen.has(sha)) continue;
|
|
103
|
+
seen.add(sha);
|
|
104
|
+
out.push({ sha, mediaType, source: isUrl ? 'url' : 'path', value });
|
|
105
|
+
}
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function parseArgs(argv) {
|
|
110
|
+
const opts = {
|
|
111
|
+
session: null,
|
|
112
|
+
detect: false,
|
|
113
|
+
markPending: false,
|
|
114
|
+
json: false,
|
|
115
|
+
limit: DEFAULT_LIMIT,
|
|
116
|
+
out: null,
|
|
117
|
+
sessionId: null,
|
|
118
|
+
refs: [],
|
|
119
|
+
};
|
|
120
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
121
|
+
const arg = argv[i];
|
|
122
|
+
if (arg === '--session') {
|
|
123
|
+
opts.session = argv[i + 1];
|
|
124
|
+
i += 1;
|
|
125
|
+
} else if (arg === '--detect') {
|
|
126
|
+
opts.detect = true;
|
|
127
|
+
} else if (arg === '--mark-pending') {
|
|
128
|
+
opts.markPending = true;
|
|
129
|
+
} else if (arg === '--json') {
|
|
130
|
+
opts.json = true;
|
|
131
|
+
} else if (arg === '--limit') {
|
|
132
|
+
const n = Number(argv[i + 1]);
|
|
133
|
+
if (Number.isFinite(n) && n > 0) opts.limit = Math.floor(n);
|
|
134
|
+
i += 1;
|
|
135
|
+
} else if (arg === '--out') {
|
|
136
|
+
opts.out = argv[i + 1];
|
|
137
|
+
i += 1;
|
|
138
|
+
} else if (arg === '--session-id') {
|
|
139
|
+
opts.sessionId = argv[i + 1];
|
|
140
|
+
i += 1;
|
|
141
|
+
} else if (arg === '--ref') {
|
|
142
|
+
const value = argv[i + 1];
|
|
143
|
+
if (typeof value === 'string' && value.trim()) opts.refs.push(value.trim());
|
|
144
|
+
i += 1;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return opts;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function slugifyCwd(cwd) {
|
|
151
|
+
return cwd.replace(/[^a-zA-Z0-9]/g, '-');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function discoverSessionFile(cwd) {
|
|
155
|
+
const slug = slugifyCwd(cwd);
|
|
156
|
+
const dir = path.join(os.homedir(), '.claude', 'projects', slug);
|
|
157
|
+
let entries;
|
|
158
|
+
try {
|
|
159
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
160
|
+
} catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let newestPath = null;
|
|
165
|
+
let newestMtimeMs = -Infinity;
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
|
168
|
+
const full = path.join(dir, entry.name);
|
|
169
|
+
let stat;
|
|
170
|
+
try {
|
|
171
|
+
stat = fs.statSync(full);
|
|
172
|
+
} catch {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (stat.mtimeMs > newestMtimeMs) {
|
|
176
|
+
newestMtimeMs = stat.mtimeMs;
|
|
177
|
+
newestPath = full;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return newestPath;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function readLines(filePath) {
|
|
184
|
+
let raw;
|
|
185
|
+
try {
|
|
186
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
187
|
+
} catch {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
return raw.split('\n').filter((line) => line.trim().length > 0);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Parses each JSONL line independently. A malformed line is skipped via its
|
|
195
|
+
* own try/catch and never throws out to the caller.
|
|
196
|
+
*/
|
|
197
|
+
function extractImageBlocks(lines) {
|
|
198
|
+
const blocks = [];
|
|
199
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
200
|
+
try {
|
|
201
|
+
const obj = JSON.parse(lines[i]);
|
|
202
|
+
const content = obj?.message?.content;
|
|
203
|
+
if (!Array.isArray(content)) continue;
|
|
204
|
+
for (const block of content) {
|
|
205
|
+
if (block?.type !== 'image') continue;
|
|
206
|
+
const source = block.source;
|
|
207
|
+
if (!source || source.type !== 'base64') continue;
|
|
208
|
+
const mediaType = source.media_type;
|
|
209
|
+
const data = source.data;
|
|
210
|
+
if (typeof mediaType !== 'string' || typeof data !== 'string' || !data) continue;
|
|
211
|
+
blocks.push({ mediaType, data, lineIndex: i });
|
|
212
|
+
}
|
|
213
|
+
} catch {
|
|
214
|
+
// malformed line: skip and continue
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return blocks;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Filters to the allowlisted media types, dedupes by sha (content-addressed —
|
|
222
|
+
* same payload always produces the same key), and orders most-recent-first.
|
|
223
|
+
*/
|
|
224
|
+
function selectImages(blocks, limit) {
|
|
225
|
+
const allowed = blocks.filter((b) => MEDIA_EXT[b.mediaType]);
|
|
226
|
+
const bySha = new Map();
|
|
227
|
+
for (const block of allowed) {
|
|
228
|
+
const sha = hashPayload(block.data);
|
|
229
|
+
bySha.set(sha, { sha, mediaType: block.mediaType, data: block.data, lineIndex: block.lineIndex });
|
|
230
|
+
}
|
|
231
|
+
const deduped = Array.from(bySha.values());
|
|
232
|
+
deduped.sort((a, b) => b.lineIndex - a.lineIndex);
|
|
233
|
+
return deduped.slice(0, limit);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function pruneImageFiles(outDir) {
|
|
237
|
+
let entries;
|
|
238
|
+
try {
|
|
239
|
+
entries = fs.readdirSync(outDir, { withFileTypes: true });
|
|
240
|
+
} catch {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const imageFiles = entries
|
|
244
|
+
.filter((e) => e.isFile() && /^[0-9a-f]{64}\.(png|jpg|gif|webp)$/.test(e.name))
|
|
245
|
+
.map((e) => {
|
|
246
|
+
const full = path.join(outDir, e.name);
|
|
247
|
+
let mtimeMs = 0;
|
|
248
|
+
try {
|
|
249
|
+
mtimeMs = fs.statSync(full).mtimeMs;
|
|
250
|
+
} catch {
|
|
251
|
+
// ignore
|
|
252
|
+
}
|
|
253
|
+
return { full, mtimeMs };
|
|
254
|
+
})
|
|
255
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
256
|
+
|
|
257
|
+
for (const f of imageFiles.slice(RETENTION_MAX_IMAGE_FILES)) {
|
|
258
|
+
try {
|
|
259
|
+
fs.unlinkSync(f.full);
|
|
260
|
+
} catch {
|
|
261
|
+
// ignore
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function pruneMarkerDirs(outDir) {
|
|
267
|
+
let entries;
|
|
268
|
+
try {
|
|
269
|
+
entries = fs.readdirSync(outDir, { withFileTypes: true });
|
|
270
|
+
} catch {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const now = Date.now();
|
|
274
|
+
for (const entry of entries) {
|
|
275
|
+
if (!entry.isDirectory()) continue;
|
|
276
|
+
const full = path.join(outDir, entry.name);
|
|
277
|
+
let mtimeMs;
|
|
278
|
+
try {
|
|
279
|
+
mtimeMs = fs.statSync(full).mtimeMs;
|
|
280
|
+
} catch {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (now - mtimeMs > MARKER_MAX_AGE_MS) {
|
|
284
|
+
try {
|
|
285
|
+
fs.rmSync(full, { recursive: true, force: true });
|
|
286
|
+
} catch {
|
|
287
|
+
// ignore
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function printResult(opts, result) {
|
|
294
|
+
if (opts.json) {
|
|
295
|
+
console.log(JSON.stringify(result));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
console.log(`imageCount: ${result.imageCount}`);
|
|
299
|
+
for (const img of result.images) {
|
|
300
|
+
if (img.path) console.log(img.path);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function main() {
|
|
305
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
306
|
+
const cwd = process.cwd();
|
|
307
|
+
|
|
308
|
+
const sessionPath = opts.session ? path.resolve(opts.session) : discoverSessionFile(cwd);
|
|
309
|
+
const outDir = opts.out
|
|
310
|
+
? path.resolve(opts.out)
|
|
311
|
+
: path.join(cwd, '.ukit', 'storage', 'cache', 'vision');
|
|
312
|
+
|
|
313
|
+
const sessionId = opts.sessionId
|
|
314
|
+
|| (sessionPath ? path.basename(sessionPath, path.extname(sessionPath)) : 'default');
|
|
315
|
+
|
|
316
|
+
const lines = sessionPath ? readLines(sessionPath) : [];
|
|
317
|
+
const blocks = extractImageBlocks(lines);
|
|
318
|
+
const selected = selectImages(blocks, opts.limit);
|
|
319
|
+
const refs = resolveRefs(opts.refs);
|
|
320
|
+
|
|
321
|
+
// --detect is report-only, so it must not short-circuit --mark-pending when both are
|
|
322
|
+
// passed together. vision-router.sh combines them; short-circuiting here would arm
|
|
323
|
+
// nothing and leave the TASK-008 write gate permanently open.
|
|
324
|
+
if (opts.detect && !opts.markPending) {
|
|
325
|
+
printResult(opts, { imageCount: selected.length + refs.length, sessionId, images: [] });
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (opts.markPending) {
|
|
330
|
+
const markerDir = path.join(outDir, sessionId);
|
|
331
|
+
if (selected.length > 0 || refs.length > 0) {
|
|
332
|
+
try {
|
|
333
|
+
fs.mkdirSync(markerDir, { recursive: true });
|
|
334
|
+
} catch (err) {
|
|
335
|
+
process.stderr.write(`extract-image: failed to create marker dir ${markerDir}: ${err.message}\n`);
|
|
336
|
+
printResult(opts, { imageCount: 0, sessionId, images: [] });
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const written = [];
|
|
342
|
+
for (const img of selected) {
|
|
343
|
+
const markerPath = path.join(markerDir, `pending-${img.sha}.json`);
|
|
344
|
+
const marker = { sha: img.sha, mediaType: img.mediaType, ts: Date.now(), sessionId };
|
|
345
|
+
try {
|
|
346
|
+
fs.writeFileSync(markerPath, JSON.stringify(marker));
|
|
347
|
+
written.push({ sha: img.sha, path: markerPath, mediaType: img.mediaType, bytes: 0 });
|
|
348
|
+
} catch (err) {
|
|
349
|
+
process.stderr.write(`extract-image: failed to write marker ${markerPath}: ${err.message}\n`);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
for (const ref of refs) {
|
|
353
|
+
const markerPath = path.join(markerDir, `pending-${ref.sha}.json`);
|
|
354
|
+
const marker = {
|
|
355
|
+
sha: ref.sha,
|
|
356
|
+
mediaType: ref.mediaType,
|
|
357
|
+
ts: Date.now(),
|
|
358
|
+
sessionId,
|
|
359
|
+
source: ref.source,
|
|
360
|
+
ref: ref.value,
|
|
361
|
+
};
|
|
362
|
+
try {
|
|
363
|
+
fs.writeFileSync(markerPath, JSON.stringify(marker));
|
|
364
|
+
written.push({ sha: ref.sha, path: markerPath, mediaType: ref.mediaType, bytes: 0, source: ref.source, ref: ref.value });
|
|
365
|
+
} catch (err) {
|
|
366
|
+
process.stderr.write(`extract-image: failed to write marker ${markerPath}: ${err.message}\n`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
pruneMarkerDirs(outDir);
|
|
370
|
+
printResult(opts, { imageCount: written.length, sessionId, images: written });
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// default: decode + write image files
|
|
375
|
+
if (selected.length > 0) {
|
|
376
|
+
try {
|
|
377
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
378
|
+
} catch (err) {
|
|
379
|
+
process.stderr.write(`extract-image: failed to create out dir ${outDir}: ${err.message}\n`);
|
|
380
|
+
printResult(opts, { imageCount: 0, sessionId, images: [] });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const images = [];
|
|
386
|
+
for (const img of selected) {
|
|
387
|
+
let decoded;
|
|
388
|
+
try {
|
|
389
|
+
decoded = Buffer.from(img.data, 'base64');
|
|
390
|
+
} catch (err) {
|
|
391
|
+
process.stderr.write(`extract-image: failed to decode image (sha=${img.sha}): ${err.message}\n`);
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
if (decoded.length > MAX_DECODED_BYTES) {
|
|
395
|
+
process.stderr.write(
|
|
396
|
+
`extract-image: skipping oversized image (sha=${img.sha}, bytes=${decoded.length} > ${MAX_DECODED_BYTES})\n`
|
|
397
|
+
);
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
const ext = MEDIA_EXT[img.mediaType];
|
|
401
|
+
const filePath = path.join(outDir, `${img.sha}.${ext}`);
|
|
402
|
+
try {
|
|
403
|
+
fs.writeFileSync(filePath, decoded);
|
|
404
|
+
} catch (err) {
|
|
405
|
+
process.stderr.write(`extract-image: failed to write ${filePath}: ${err.message}\n`);
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
images.push({ sha: img.sha, path: filePath, mediaType: img.mediaType, bytes: decoded.length });
|
|
409
|
+
}
|
|
410
|
+
pruneImageFiles(outDir);
|
|
411
|
+
printResult(opts, { imageCount: images.length, sessionId, images });
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Only run as a CLI. Importing this module (tests import resolveRefs/hashPayload) must
|
|
415
|
+
// not decode images, write markers, or prune the real cache as a side effect.
|
|
416
|
+
const invokedDirectly = (() => {
|
|
417
|
+
const entry = process.argv[1];
|
|
418
|
+
if (!entry) return false;
|
|
419
|
+
try {
|
|
420
|
+
return fs.realpathSync(entry) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
421
|
+
} catch {
|
|
422
|
+
return false;
|
|
423
|
+
}
|
|
424
|
+
})();
|
|
425
|
+
|
|
426
|
+
if (invokedDirectly) {
|
|
427
|
+
try {
|
|
428
|
+
main();
|
|
429
|
+
process.exitCode = 0;
|
|
430
|
+
} catch (err) {
|
|
431
|
+
process.stderr.write(`extract-image: unexpected error: ${err?.message ?? err}\n`);
|
|
432
|
+
process.exitCode = 0;
|
|
433
|
+
}
|
|
434
|
+
}
|