@nemu.pm/tachiyomi-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,550 @@
1
+ import { buildCommand, buildRouteMap } from "@stricli/core";
2
+ import pc from "picocolors";
3
+ import { loadOutputConfig } from "../config";
4
+ import { loadExtension, unwrapResult, type MangasPage, type TachiyomiExports } from "../lib/extension-loader";
5
+ import { printHeader, printJson, printListItem } from "../lib/output";
6
+
7
+ function getExtensionAndSource(extensionId: string) {
8
+ const config = loadOutputConfig();
9
+ const { exports, sources } = loadExtension(config.output, extensionId);
10
+
11
+ if (sources.length === 0) {
12
+ throw new Error("No sources found in extension");
13
+ }
14
+
15
+ const source = sources[0];
16
+ console.log(`Loaded: ${source.name} (${source.lang})\n`);
17
+
18
+ return { exports, source, sourceId: source.id };
19
+ }
20
+
21
+ export const popular = buildCommand({
22
+ docs: {
23
+ brief: "Get popular manga",
24
+ },
25
+ parameters: {
26
+ positional: {
27
+ kind: "tuple",
28
+ parameters: [
29
+ { brief: "Extension path (e.g., en/mangapill)", parse: String, placeholder: "extension" },
30
+ ],
31
+ },
32
+ flags: {
33
+ page: {
34
+ kind: "parsed",
35
+ brief: "Page number",
36
+ parse: (s: string) => parseInt(s, 10),
37
+ optional: true,
38
+ },
39
+ json: {
40
+ kind: "boolean",
41
+ brief: "Output as JSON",
42
+ optional: true,
43
+ },
44
+ },
45
+ },
46
+ func: async (flags: { page?: number; json?: boolean }, extensionId: string) => {
47
+ const { exports, sourceId } = getExtensionAndSource(extensionId);
48
+ const page = flags.page ?? 1;
49
+
50
+ console.log(`Fetching popular manga (page ${page})...`);
51
+ const result = unwrapResult<MangasPage>(exports.getPopularManga(sourceId, page));
52
+
53
+ if (flags.json) {
54
+ printJson(result);
55
+ return;
56
+ }
57
+
58
+ printHeader(`Popular Manga (${result.mangas?.length ?? 0} items, hasNextPage: ${result.hasNextPage})`);
59
+ for (const m of result.mangas?.slice(0, 10) ?? []) {
60
+ printListItem(m.title);
61
+ console.log(` URL: ${m.url}`);
62
+ if (m.thumbnailUrl) console.log(` Thumb: ${m.thumbnailUrl}`);
63
+ }
64
+ if ((result.mangas?.length ?? 0) > 10) {
65
+ console.log(`\n... and ${result.mangas.length - 10} more`);
66
+ }
67
+ },
68
+ });
69
+
70
+ export const latest = buildCommand({
71
+ docs: {
72
+ brief: "Get latest manga updates",
73
+ },
74
+ parameters: {
75
+ positional: {
76
+ kind: "tuple",
77
+ parameters: [
78
+ { brief: "Extension path (e.g., en/mangapill)", parse: String, placeholder: "extension" },
79
+ ],
80
+ },
81
+ flags: {
82
+ page: {
83
+ kind: "parsed",
84
+ brief: "Page number",
85
+ parse: (s: string) => parseInt(s, 10),
86
+ optional: true,
87
+ },
88
+ json: {
89
+ kind: "boolean",
90
+ brief: "Output as JSON",
91
+ optional: true,
92
+ },
93
+ },
94
+ },
95
+ func: async (flags: { page?: number; json?: boolean }, extensionId: string) => {
96
+ const { exports, sourceId } = getExtensionAndSource(extensionId);
97
+ const page = flags.page ?? 1;
98
+
99
+ console.log(`Fetching latest updates (page ${page})...`);
100
+ const result = unwrapResult<MangasPage>(exports.getLatestUpdates(sourceId, page));
101
+
102
+ if (flags.json) {
103
+ printJson(result);
104
+ return;
105
+ }
106
+
107
+ printHeader(`Latest Updates (${result.mangas?.length ?? 0} items, hasNextPage: ${result.hasNextPage})`);
108
+ for (const m of result.mangas?.slice(0, 10) ?? []) {
109
+ printListItem(m.title);
110
+ console.log(` URL: ${m.url}`);
111
+ }
112
+ },
113
+ });
114
+
115
+ export const search = buildCommand({
116
+ docs: {
117
+ brief: "Search for manga",
118
+ },
119
+ parameters: {
120
+ positional: {
121
+ kind: "tuple",
122
+ parameters: [
123
+ { brief: "Extension path (e.g., en/mangapill)", parse: String, placeholder: "extension" },
124
+ { brief: "Search query", parse: String, placeholder: "query" },
125
+ ],
126
+ },
127
+ flags: {
128
+ page: {
129
+ kind: "parsed",
130
+ brief: "Page number",
131
+ parse: (s: string) => parseInt(s, 10),
132
+ optional: true,
133
+ },
134
+ json: {
135
+ kind: "boolean",
136
+ brief: "Output as JSON",
137
+ optional: true,
138
+ },
139
+ },
140
+ },
141
+ func: async (flags: { page?: number; json?: boolean }, extensionId: string, query: string) => {
142
+ const { exports, sourceId } = getExtensionAndSource(extensionId);
143
+ const page = flags.page ?? 1;
144
+
145
+ console.log(`Searching for "${query}"...`);
146
+ const result = unwrapResult<MangasPage>(exports.searchManga(sourceId, page, query));
147
+
148
+ if (flags.json) {
149
+ printJson(result);
150
+ return;
151
+ }
152
+
153
+ printHeader(`Search Results (${result.mangas?.length ?? 0} items)`);
154
+ for (const m of result.mangas?.slice(0, 10) ?? []) {
155
+ printListItem(m.title);
156
+ console.log(` URL: ${m.url}`);
157
+ }
158
+ },
159
+ });
160
+
161
+ export const details = buildCommand({
162
+ docs: {
163
+ brief: "Get manga details",
164
+ },
165
+ parameters: {
166
+ positional: {
167
+ kind: "tuple",
168
+ parameters: [
169
+ { brief: "Extension path (e.g., en/mangapill)", parse: String, placeholder: "extension" },
170
+ { brief: "Manga URL (relative)", parse: String, placeholder: "url" },
171
+ ],
172
+ },
173
+ flags: {
174
+ json: {
175
+ kind: "boolean",
176
+ brief: "Output as JSON",
177
+ optional: true,
178
+ },
179
+ },
180
+ },
181
+ func: async (flags: { json?: boolean }, extensionId: string, url: string) => {
182
+ const { exports, sourceId } = getExtensionAndSource(extensionId);
183
+
184
+ console.log(`Fetching manga details...`);
185
+ const result = unwrapResult<unknown>(exports.getMangaDetails(sourceId, url));
186
+
187
+ if (flags.json) {
188
+ printJson(result);
189
+ return;
190
+ }
191
+
192
+ printHeader("Manga Details");
193
+ console.log(JSON.stringify(result, null, 2));
194
+ },
195
+ });
196
+
197
+ export const chapters = buildCommand({
198
+ docs: {
199
+ brief: "Get chapter list",
200
+ },
201
+ parameters: {
202
+ positional: {
203
+ kind: "tuple",
204
+ parameters: [
205
+ { brief: "Extension path (e.g., en/mangapill)", parse: String, placeholder: "extension" },
206
+ { brief: "Manga URL (relative)", parse: String, placeholder: "url" },
207
+ ],
208
+ },
209
+ flags: {
210
+ json: {
211
+ kind: "boolean",
212
+ brief: "Output as JSON",
213
+ optional: true,
214
+ },
215
+ },
216
+ },
217
+ func: async (flags: { json?: boolean }, extensionId: string, url: string) => {
218
+ const { exports, sourceId } = getExtensionAndSource(extensionId);
219
+
220
+ console.log(`Fetching chapter list...`);
221
+ const result = unwrapResult<Array<{ name: string; url: string }>>(
222
+ exports.getChapterList(sourceId, url)
223
+ );
224
+
225
+ if (flags.json) {
226
+ printJson(result);
227
+ return;
228
+ }
229
+
230
+ printHeader(`Chapters (${result?.length ?? 0})`);
231
+ for (const c of result?.slice(0, 10) ?? []) {
232
+ printListItem(c.name);
233
+ console.log(` URL: ${c.url}`);
234
+ }
235
+ if ((result?.length ?? 0) > 10) {
236
+ console.log(`\n... and ${result.length - 10} more`);
237
+ }
238
+ },
239
+ });
240
+
241
+ export const pages = buildCommand({
242
+ docs: {
243
+ brief: "Get page list for a chapter",
244
+ },
245
+ parameters: {
246
+ positional: {
247
+ kind: "tuple",
248
+ parameters: [
249
+ { brief: "Extension path (e.g., en/mangapill)", parse: String, placeholder: "extension" },
250
+ { brief: "Chapter URL (relative)", parse: String, placeholder: "url" },
251
+ ],
252
+ },
253
+ flags: {
254
+ json: {
255
+ kind: "boolean",
256
+ brief: "Output as JSON",
257
+ optional: true,
258
+ },
259
+ },
260
+ },
261
+ func: async (flags: { json?: boolean }, extensionId: string, url: string) => {
262
+ const { exports, sourceId } = getExtensionAndSource(extensionId);
263
+
264
+ console.log(`Fetching page list...`);
265
+ const result = unwrapResult<Array<{ index: number; imageUrl?: string; url?: string }>>(
266
+ exports.getPageList(sourceId, url)
267
+ );
268
+
269
+ if (flags.json) {
270
+ printJson(result);
271
+ return;
272
+ }
273
+
274
+ printHeader(`Pages (${result?.length ?? 0})`);
275
+ for (const p of result?.slice(0, 5) ?? []) {
276
+ console.log(`Page ${p.index + 1}: ${p.imageUrl || p.url}`);
277
+ }
278
+ if ((result?.length ?? 0) > 5) {
279
+ console.log(`\n... and ${result.length - 5} more`);
280
+ }
281
+ },
282
+ });
283
+
284
+ // Image magic bytes for validation
285
+ const IMAGE_SIGNATURES: Array<{ name: string; bytes: number[] }> = [
286
+ { name: "PNG", bytes: [0x89, 0x50, 0x4e, 0x47] },
287
+ { name: "JPEG", bytes: [0xff, 0xd8, 0xff] },
288
+ { name: "GIF", bytes: [0x47, 0x49, 0x46, 0x38] },
289
+ { name: "WebP", bytes: [0x52, 0x49, 0x46, 0x46] }, // RIFF header
290
+ ];
291
+
292
+ function detectImageFormat(buffer: Buffer): string | null {
293
+ for (const sig of IMAGE_SIGNATURES) {
294
+ if (sig.bytes.every((b, i) => buffer[i] === b)) {
295
+ return sig.name;
296
+ }
297
+ }
298
+ return null;
299
+ }
300
+
301
+ interface TestResult {
302
+ test: string;
303
+ passed: boolean;
304
+ error?: string;
305
+ data?: unknown;
306
+ }
307
+
308
+ export const all = buildCommand({
309
+ docs: {
310
+ brief: "Run all API tests on an extension",
311
+ fullDescription: "Comprehensive test that validates popular, latest, search, details, chapters, pages, and image download. Useful for CI/CD.",
312
+ },
313
+ parameters: {
314
+ positional: {
315
+ kind: "tuple",
316
+ parameters: [
317
+ { brief: "Extension path (e.g., en/mangapill)", parse: String, placeholder: "extension" },
318
+ ],
319
+ },
320
+ flags: {
321
+ json: {
322
+ kind: "boolean",
323
+ brief: "Output results as JSON",
324
+ optional: true,
325
+ },
326
+ },
327
+ },
328
+ func: async (flags: { json?: boolean }, extensionId: string) => {
329
+ const config = loadOutputConfig();
330
+ const { exports, sources } = loadExtension(config.output, extensionId);
331
+
332
+ if (sources.length === 0) {
333
+ throw new Error("No sources found in extension");
334
+ }
335
+
336
+ const source = sources[0];
337
+ const sourceId = source.id;
338
+ const results: TestResult[] = [];
339
+ const log = flags.json ? () => {} : console.log;
340
+
341
+ log(pc.cyan(`\n🧪 Testing: ${source.name} (${source.lang})\n`));
342
+
343
+ // Collect manga samples from popular and latest
344
+ const mangaSamples: Array<{ title: string; url: string }> = [];
345
+
346
+ // Test 1: Popular
347
+ try {
348
+ log(pc.dim("Testing popular..."));
349
+ const popular = unwrapResult<MangasPage>(exports.getPopularManga(sourceId, 1));
350
+ const count = popular.mangas?.length ?? 0;
351
+ if (count === 0) throw new Error("No manga returned");
352
+ // Collect up to 5 samples
353
+ mangaSamples.push(...popular.mangas.slice(0, 5));
354
+ results.push({ test: "popular", passed: true, data: { count, hasNextPage: popular.hasNextPage } });
355
+ log(pc.green(` ✓ popular: ${count} manga`));
356
+ } catch (e) {
357
+ results.push({ test: "popular", passed: false, error: String(e) });
358
+ log(pc.red(` ✗ popular: ${e}`));
359
+ }
360
+
361
+ // Test 2: Latest
362
+ try {
363
+ log(pc.dim("Testing latest..."));
364
+ const latest = unwrapResult<MangasPage>(exports.getLatestUpdates(sourceId, 1));
365
+ const count = latest.mangas?.length ?? 0;
366
+ if (count === 0) throw new Error("No manga returned");
367
+ // Add more samples if needed
368
+ if (mangaSamples.length < 5) {
369
+ mangaSamples.push(...latest.mangas.slice(0, 5 - mangaSamples.length));
370
+ }
371
+ results.push({ test: "latest", passed: true, data: { count, hasNextPage: latest.hasNextPage } });
372
+ log(pc.green(` ✓ latest: ${count} manga`));
373
+ } catch (e) {
374
+ results.push({ test: "latest", passed: false, error: String(e) });
375
+ log(pc.red(` ✗ latest: ${e}`));
376
+ }
377
+
378
+ // Test 3: Search (using exact title from samples)
379
+ if (mangaSamples.length > 0) {
380
+ try {
381
+ // Use a known manga title for more reliable search
382
+ const searchManga = mangaSamples[0];
383
+ const query = searchManga.title;
384
+ log(pc.dim(`Testing search ("${query.slice(0, 30)}${query.length > 30 ? "..." : ""}")...`));
385
+ const search = unwrapResult<MangasPage>(exports.searchManga(sourceId, 1, query));
386
+ const count = search.mangas?.length ?? 0;
387
+ // Check if we found the manga we searched for
388
+ const found = search.mangas?.some(m =>
389
+ m.title.toLowerCase().includes(searchManga.title.toLowerCase().slice(0, 10)) ||
390
+ m.url === searchManga.url
391
+ );
392
+ results.push({ test: "search", passed: true, data: { query, count, foundTarget: found } });
393
+ log(pc.green(` ✓ search: ${count} results${found ? " (target found)" : ""}`));
394
+ } catch (e) {
395
+ results.push({ test: "search", passed: false, error: String(e) });
396
+ log(pc.red(` ✗ search: ${e}`));
397
+ }
398
+ }
399
+
400
+ // Test 4: Manga Details (try up to 3 manga)
401
+ let detailsManga: { title: string; url: string } | null = null;
402
+ for (const manga of mangaSamples.slice(0, 3)) {
403
+ try {
404
+ log(pc.dim(`Testing details (${manga.title.slice(0, 30)})...`));
405
+ const details = unwrapResult<{ title?: string; url?: string }>(
406
+ exports.getMangaDetails(sourceId, manga.url)
407
+ );
408
+ detailsManga = manga;
409
+ results.push({ test: "details", passed: true, data: { title: details.title || manga.title } });
410
+ log(pc.green(` ✓ details: ${details.title || manga.title}`));
411
+ break;
412
+ } catch (e) {
413
+ log(pc.dim(` (${manga.title.slice(0, 20)} failed, trying next...)`));
414
+ }
415
+ }
416
+ if (!detailsManga) {
417
+ results.push({ test: "details", passed: false, error: "All manga samples failed" });
418
+ log(pc.red(` ✗ details: All ${Math.min(3, mangaSamples.length)} samples failed`));
419
+ }
420
+
421
+ // Test 5: Chapters (try up to 3 manga)
422
+ let chaptersData: { manga: { title: string; url: string }; chapters: Array<{ name: string; url: string }> } | null = null;
423
+ for (const manga of mangaSamples.slice(0, 3)) {
424
+ try {
425
+ log(pc.dim(`Testing chapters (${manga.title.slice(0, 30)})...`));
426
+ const chapters = unwrapResult<Array<{ name: string; url: string }>>(
427
+ exports.getChapterList(sourceId, manga.url)
428
+ );
429
+ if (chapters?.length > 0) {
430
+ chaptersData = { manga, chapters };
431
+ results.push({ test: "chapters", passed: true, data: { manga: manga.title, count: chapters.length } });
432
+ log(pc.green(` ✓ chapters: ${chapters.length} chapters`));
433
+ break;
434
+ }
435
+ log(pc.dim(` (${manga.title.slice(0, 20)} has no chapters, trying next...)`));
436
+ } catch (e) {
437
+ log(pc.dim(` (${manga.title.slice(0, 20)} failed, trying next...)`));
438
+ }
439
+ }
440
+ if (!chaptersData) {
441
+ results.push({ test: "chapters", passed: false, error: "No manga with chapters found" });
442
+ log(pc.red(` ✗ chapters: No manga with chapters found in ${Math.min(3, mangaSamples.length)} samples`));
443
+ }
444
+
445
+ // Test 6: Pages (try up to 3 chapters)
446
+ let samplePages: Array<{ index: number; imageUrl?: string; url?: string }> = [];
447
+ if (chaptersData) {
448
+ for (const chapter of chaptersData.chapters.slice(0, 3)) {
449
+ try {
450
+ log(pc.dim(`Testing pages (${chapter.name.slice(0, 30)})...`));
451
+ const pages = unwrapResult<Array<{ index: number; imageUrl?: string; url?: string }>>(
452
+ exports.getPageList(sourceId, chapter.url)
453
+ );
454
+ if (pages?.length > 0) {
455
+ samplePages = pages.slice(0, 3);
456
+ results.push({ test: "pages", passed: true, data: { chapter: chapter.name, count: pages.length } });
457
+ log(pc.green(` ✓ pages: ${pages.length} pages`));
458
+ break;
459
+ }
460
+ log(pc.dim(` (${chapter.name.slice(0, 20)} has no pages, trying next...)`));
461
+ } catch (e) {
462
+ log(pc.dim(` (${chapter.name.slice(0, 20)} failed, trying next...)`));
463
+ }
464
+ }
465
+ if (samplePages.length === 0) {
466
+ results.push({ test: "pages", passed: false, error: "No chapter with pages found" });
467
+ log(pc.red(` ✗ pages: No chapter with pages found in 3 samples`));
468
+ }
469
+ }
470
+
471
+ // Test 7: Image Download (up to 3 pages)
472
+ if (samplePages.length > 0) {
473
+ log(pc.dim("Testing image download..."));
474
+ let imagesPassed = 0;
475
+ const imageResults: Array<{ page: number; format: string | null; size: number }> = [];
476
+
477
+ for (const page of samplePages) {
478
+ const imageUrl = page.imageUrl || "";
479
+ if (!imageUrl) continue;
480
+
481
+ try {
482
+ const base64 = unwrapResult<string>(
483
+ exports.fetchImage(sourceId, page.url || "", imageUrl)
484
+ );
485
+ const buffer = Buffer.from(base64, "base64");
486
+ const format = detectImageFormat(buffer);
487
+
488
+ if (format) {
489
+ imagesPassed++;
490
+ imageResults.push({ page: page.index + 1, format, size: buffer.length });
491
+ log(pc.green(` ✓ page ${page.index + 1}: ${format} (${(buffer.length / 1024).toFixed(1)}KB)`));
492
+ } else {
493
+ imageResults.push({ page: page.index + 1, format: null, size: buffer.length });
494
+ log(pc.yellow(` ⚠ page ${page.index + 1}: unknown format (${(buffer.length / 1024).toFixed(1)}KB)`));
495
+ }
496
+ } catch (e) {
497
+ imageResults.push({ page: page.index + 1, format: null, size: 0 });
498
+ log(pc.red(` ✗ page ${page.index + 1}: ${e}`));
499
+ }
500
+ }
501
+
502
+ results.push({
503
+ test: "images",
504
+ passed: imagesPassed > 0,
505
+ data: { tested: samplePages.length, valid: imagesPassed, results: imageResults },
506
+ });
507
+ }
508
+
509
+ // Summary
510
+ const passed = results.filter((r) => r.passed).length;
511
+ const failed = results.filter((r) => !r.passed).length;
512
+
513
+ if (flags.json) {
514
+ printJson({
515
+ extension: extensionId,
516
+ source: { id: sourceId, name: source.name, lang: source.lang },
517
+ summary: { passed, failed, total: results.length },
518
+ results,
519
+ });
520
+ } else {
521
+ log("");
522
+ if (failed === 0) {
523
+ log(pc.green(`✓ All ${passed} tests passed`));
524
+ } else {
525
+ log(pc.yellow(`⚠ ${passed}/${passed + failed} tests passed`));
526
+ }
527
+ }
528
+
529
+ // Exit with error code if tests failed
530
+ if (failed > 0) {
531
+ process.exit(1);
532
+ }
533
+ },
534
+ });
535
+
536
+ // Route map for test subcommands
537
+ export const testRoutes = buildRouteMap({
538
+ routes: {
539
+ all,
540
+ popular,
541
+ latest,
542
+ search,
543
+ details,
544
+ chapters,
545
+ pages,
546
+ },
547
+ docs: {
548
+ brief: "Test extension API endpoints",
549
+ },
550
+ });
package/config.ts ADDED
@@ -0,0 +1,87 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+
4
+ export interface Config {
5
+ /** Path to extensions source repo (Kotlin sources) */
6
+ source: string;
7
+ /** Path to built extensions output */
8
+ output: string;
9
+ }
10
+
11
+ const CONFIG_FILE_NAMES = ["tachiyomi.config.json", ".tachiyomirc.json"];
12
+
13
+ function findConfigFile(): string | null {
14
+ for (const name of CONFIG_FILE_NAMES) {
15
+ const p = path.join(process.cwd(), name);
16
+ if (fs.existsSync(p)) return p;
17
+ }
18
+ return null;
19
+ }
20
+
21
+ function loadConfigFile(): Partial<Config> {
22
+ const file = findConfigFile();
23
+ if (!file) return {};
24
+
25
+ try {
26
+ const content = fs.readFileSync(file, "utf-8");
27
+ return JSON.parse(content);
28
+ } catch {
29
+ return {};
30
+ }
31
+ }
32
+
33
+ export function loadConfig(): Config {
34
+ const fileConfig = loadConfigFile();
35
+
36
+ const source = process.env.TACHIYOMI_SOURCE ?? fileConfig.source;
37
+ const output = process.env.TACHIYOMI_OUTPUT ?? fileConfig.output;
38
+
39
+ const missing: string[] = [];
40
+ if (!source) missing.push("source");
41
+ if (!output) missing.push("output");
42
+
43
+ if (missing.length > 0) {
44
+ console.error(`Missing config: ${missing.join(", ")}
45
+
46
+ Set via environment variables:
47
+ TACHIYOMI_SOURCE - path to extensions source repo
48
+ TACHIYOMI_OUTPUT - path for built extensions
49
+
50
+ Or create tachiyomi.config.json:
51
+ {
52
+ "source": "/path/to/extensions-source",
53
+ "output": "./dist/extensions"
54
+ }
55
+ `);
56
+ process.exit(1);
57
+ }
58
+
59
+ // Resolve to absolute paths
60
+ return {
61
+ source: path.resolve(source!),
62
+ output: path.resolve(output!),
63
+ };
64
+ }
65
+
66
+ /** Load config, but only require output (for test-only commands) */
67
+ export function loadOutputConfig(): Pick<Config, "output"> {
68
+ const fileConfig = loadConfigFile();
69
+ const output = process.env.TACHIYOMI_OUTPUT ?? fileConfig.output;
70
+
71
+ if (!output) {
72
+ console.error(`Missing config: output
73
+
74
+ Set via environment variable:
75
+ TACHIYOMI_OUTPUT - path to built extensions
76
+
77
+ Or create tachiyomi.config.json:
78
+ {
79
+ "output": "./dist/extensions"
80
+ }
81
+ `);
82
+ process.exit(1);
83
+ }
84
+
85
+ return { output: path.resolve(output) };
86
+ }
87
+