@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.
- package/app.ts +27 -0
- package/bin.ts +8 -0
- package/bun.lock +92 -0
- package/commands/build.ts +81 -0
- package/commands/explore.ts +495 -0
- package/commands/info.ts +59 -0
- package/commands/list.ts +39 -0
- package/commands/test.ts +550 -0
- package/config.ts +87 -0
- package/lib/extension-loader.ts +163 -0
- package/lib/http.ts +122 -0
- package/lib/output.ts +37 -0
- package/package.json +39 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { installHttpBridge } from "./http";
|
|
4
|
+
|
|
5
|
+
export interface TachiyomiExports {
|
|
6
|
+
getManifest(): string;
|
|
7
|
+
getPopularManga(sourceId: string, page: number): string;
|
|
8
|
+
getLatestUpdates(sourceId: string, page: number): string;
|
|
9
|
+
searchManga(sourceId: string, page: number, query: string): string;
|
|
10
|
+
getMangaDetails(sourceId: string, mangaUrl: string): string;
|
|
11
|
+
getChapterList(sourceId: string, mangaUrl: string): string;
|
|
12
|
+
getPageList(sourceId: string, chapterUrl: string): string;
|
|
13
|
+
getFilterList(sourceId: string): string;
|
|
14
|
+
fetchImage(sourceId: string, pageUrl: string, pageImageUrl: string): string;
|
|
15
|
+
getHeaders(sourceId: string): string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface SourceInfo {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
lang: string;
|
|
22
|
+
baseUrl: string;
|
|
23
|
+
supportsLatest: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ExtensionManifest {
|
|
27
|
+
name: string;
|
|
28
|
+
pkg: string;
|
|
29
|
+
version: string;
|
|
30
|
+
nsfw: boolean;
|
|
31
|
+
authors?: Array<{
|
|
32
|
+
name?: string;
|
|
33
|
+
github?: string;
|
|
34
|
+
commits: number;
|
|
35
|
+
firstCommit: string;
|
|
36
|
+
}>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface LoadedExtension {
|
|
40
|
+
manifest: ExtensionManifest;
|
|
41
|
+
exports: TachiyomiExports;
|
|
42
|
+
sources: SourceInfo[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MangasPage {
|
|
46
|
+
mangas: Array<{
|
|
47
|
+
url: string;
|
|
48
|
+
title: string;
|
|
49
|
+
thumbnailUrl?: string;
|
|
50
|
+
author?: string;
|
|
51
|
+
artist?: string;
|
|
52
|
+
description?: string;
|
|
53
|
+
status?: number;
|
|
54
|
+
genre?: string[];
|
|
55
|
+
}>;
|
|
56
|
+
hasNextPage: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Unwrap Kotlin/JS result format */
|
|
60
|
+
export function unwrapResult<T>(json: string): T {
|
|
61
|
+
const result = JSON.parse(json) as { ok: boolean; data?: T; error?: any };
|
|
62
|
+
if (!result.ok) {
|
|
63
|
+
const errMsg = typeof result.error === "string" ? result.error : JSON.stringify(result.error, null, 2);
|
|
64
|
+
throw new Error(errMsg || "Unknown error");
|
|
65
|
+
}
|
|
66
|
+
return result.data as T;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ExtensionListItem {
|
|
70
|
+
id: string; // lang/name format
|
|
71
|
+
name: string;
|
|
72
|
+
lang: string;
|
|
73
|
+
isNsfw: boolean;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** List available built extensions (returns lang/name format) */
|
|
77
|
+
export function listExtensions(outputDir: string): string[] {
|
|
78
|
+
return listExtensionsWithInfo(outputDir).map((e) => e.id);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** List available built extensions with manifest info */
|
|
82
|
+
export function listExtensionsWithInfo(outputDir: string): ExtensionListItem[] {
|
|
83
|
+
if (!fs.existsSync(outputDir)) {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const extensions: ExtensionListItem[] = [];
|
|
88
|
+
|
|
89
|
+
// Iterate over lang directories
|
|
90
|
+
for (const lang of fs.readdirSync(outputDir)) {
|
|
91
|
+
const langDir = path.join(outputDir, lang);
|
|
92
|
+
if (!fs.statSync(langDir).isDirectory()) continue;
|
|
93
|
+
|
|
94
|
+
// Iterate over extension directories within each lang
|
|
95
|
+
for (const name of fs.readdirSync(langDir)) {
|
|
96
|
+
const extDir = path.join(langDir, name);
|
|
97
|
+
const manifestPath = path.join(extDir, "manifest.json");
|
|
98
|
+
if (fs.statSync(extDir).isDirectory() && fs.existsSync(manifestPath)) {
|
|
99
|
+
try {
|
|
100
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as ExtensionManifest;
|
|
101
|
+
extensions.push({
|
|
102
|
+
id: `${lang}/${name}`,
|
|
103
|
+
name: manifest.name,
|
|
104
|
+
lang,
|
|
105
|
+
isNsfw: manifest.nsfw ?? false,
|
|
106
|
+
});
|
|
107
|
+
} catch {
|
|
108
|
+
extensions.push({
|
|
109
|
+
id: `${lang}/${name}`,
|
|
110
|
+
name,
|
|
111
|
+
lang,
|
|
112
|
+
isNsfw: false,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return extensions;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Load and execute an extension */
|
|
123
|
+
export function loadExtension(outputDir: string, extensionId: string): LoadedExtension {
|
|
124
|
+
// Install HTTP bridge before loading extension
|
|
125
|
+
installHttpBridge();
|
|
126
|
+
|
|
127
|
+
const extDir = path.join(outputDir, extensionId);
|
|
128
|
+
const manifestPath = path.join(extDir, "manifest.json");
|
|
129
|
+
const jsPath = path.join(extDir, "extension.js");
|
|
130
|
+
|
|
131
|
+
if (!fs.existsSync(manifestPath)) {
|
|
132
|
+
throw new Error(`Extension not found: ${extensionId}\nLooked in: ${extDir}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
|
|
136
|
+
const code = fs.readFileSync(jsPath, "utf-8");
|
|
137
|
+
|
|
138
|
+
// Execute the extension code
|
|
139
|
+
const fn = new Function(code);
|
|
140
|
+
fn();
|
|
141
|
+
|
|
142
|
+
// Find the exports in globalThis
|
|
143
|
+
const g = globalThis as any;
|
|
144
|
+
let exports: TachiyomiExports | null = null;
|
|
145
|
+
|
|
146
|
+
for (const key of Object.keys(g)) {
|
|
147
|
+
if (g[key]?.tachiyomi?.generated) {
|
|
148
|
+
exports = g[key].tachiyomi.generated;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (!exports || typeof exports.getManifest !== "function") {
|
|
154
|
+
throw new Error("Could not find tachiyomi.generated exports");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Get sources from extension
|
|
158
|
+
const sourcesJson = exports.getManifest();
|
|
159
|
+
const sources = unwrapResult<SourceInfo[]>(sourcesJson);
|
|
160
|
+
|
|
161
|
+
return { manifest, exports, sources };
|
|
162
|
+
}
|
|
163
|
+
|
package/lib/http.ts
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync HTTP bridge for Tachiyomi extensions.
|
|
3
|
+
* Extensions expect synchronous HTTP - we use curl under the hood.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function syncHttpRequest(
|
|
7
|
+
url: string,
|
|
8
|
+
method: string,
|
|
9
|
+
headers: Record<string, string>,
|
|
10
|
+
body: string | null,
|
|
11
|
+
wantBytes: boolean
|
|
12
|
+
): { status: number; body: string; headers: Record<string, string>; error: string | null } {
|
|
13
|
+
const args = ["-s", "-S", "-X", method, "-w", "\n%{http_code}", "-D", "-", "-L"];
|
|
14
|
+
|
|
15
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
16
|
+
args.push("-H", `${key}: ${value}`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (body) {
|
|
20
|
+
args.push("-d", body);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
args.push(url);
|
|
24
|
+
|
|
25
|
+
const result = Bun.spawnSync(["curl", ...args]);
|
|
26
|
+
|
|
27
|
+
if (result.exitCode !== 0) {
|
|
28
|
+
const stderr = result.stderr.toString().trim();
|
|
29
|
+
return {
|
|
30
|
+
status: 0,
|
|
31
|
+
body: "",
|
|
32
|
+
headers: {},
|
|
33
|
+
error: stderr || `curl failed with exit code ${result.exitCode}`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const stdout = result.stdout;
|
|
38
|
+
|
|
39
|
+
// Parse headers (until empty line)
|
|
40
|
+
let headerEndIdx = 0;
|
|
41
|
+
for (let i = 0; i < stdout.length - 1; i++) {
|
|
42
|
+
if (stdout[i] === 13 && stdout[i + 1] === 10) {
|
|
43
|
+
// \r\n
|
|
44
|
+
if (i + 3 < stdout.length && stdout[i + 2] === 13 && stdout[i + 3] === 10) {
|
|
45
|
+
headerEndIdx = i + 4;
|
|
46
|
+
break;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const headerSection = stdout.slice(0, headerEndIdx).toString();
|
|
52
|
+
const bodySection = stdout.slice(headerEndIdx);
|
|
53
|
+
|
|
54
|
+
// Parse response headers
|
|
55
|
+
const responseHeaders: Record<string, string> = {};
|
|
56
|
+
const headerLines = headerSection.split("\r\n");
|
|
57
|
+
for (const line of headerLines.slice(1)) {
|
|
58
|
+
// Skip status line
|
|
59
|
+
const idx = line.indexOf(": ");
|
|
60
|
+
if (idx > 0) {
|
|
61
|
+
responseHeaders[line.slice(0, idx).toLowerCase()] = line.slice(idx + 2);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Find status code from last line (curl -w appends it)
|
|
66
|
+
// Search backwards for the last newline in the buffer
|
|
67
|
+
let lastNewlineIdx = bodySection.length - 1;
|
|
68
|
+
while (lastNewlineIdx >= 0 && bodySection[lastNewlineIdx] !== 10) {
|
|
69
|
+
lastNewlineIdx--;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const statusCodeStr = bodySection.slice(lastNewlineIdx + 1).toString();
|
|
73
|
+
const statusCode = parseInt(statusCodeStr) || 200;
|
|
74
|
+
const bodyBytes = bodySection.slice(0, lastNewlineIdx);
|
|
75
|
+
|
|
76
|
+
// If wantBytes, keep as binary and base64 encode
|
|
77
|
+
let finalBody: string;
|
|
78
|
+
if (wantBytes) {
|
|
79
|
+
finalBody = bodyBytes.toString("base64");
|
|
80
|
+
} else {
|
|
81
|
+
finalBody = bodyBytes.toString("utf-8");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check for HTTP errors
|
|
85
|
+
let error: string | null = null;
|
|
86
|
+
if (statusCode >= 400) {
|
|
87
|
+
error = `HTTP ${statusCode}`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
status: statusCode,
|
|
92
|
+
body: finalBody,
|
|
93
|
+
headers: responseHeaders,
|
|
94
|
+
error,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Install the HTTP bridge into globalThis for Kotlin/JS extensions */
|
|
99
|
+
export function installHttpBridge(): void {
|
|
100
|
+
(globalThis as any).tachiyomiHttpRequest = (
|
|
101
|
+
url: string,
|
|
102
|
+
method: string,
|
|
103
|
+
headersJson: string,
|
|
104
|
+
body: string | null,
|
|
105
|
+
wantBytes: boolean
|
|
106
|
+
): { status: number; statusText: string; headersJson: string; body: string; error: string | null } => {
|
|
107
|
+
if (process.env.DEBUG_HTTP) {
|
|
108
|
+
console.log(`[HTTP] ${method} ${url}`);
|
|
109
|
+
}
|
|
110
|
+
const headers = JSON.parse(headersJson || "{}");
|
|
111
|
+
const result = syncHttpRequest(url, method, headers, body, wantBytes);
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
status: result.status,
|
|
115
|
+
statusText: result.status >= 200 && result.status < 300 ? "OK" : "Error",
|
|
116
|
+
headersJson: JSON.stringify(result.headers),
|
|
117
|
+
body: result.body,
|
|
118
|
+
error: result.error,
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
package/lib/output.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
|
|
3
|
+
export interface OutputOptions {
|
|
4
|
+
json?: boolean;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function printJson(data: unknown): void {
|
|
8
|
+
console.log(JSON.stringify(data, null, 2));
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function printHeader(text: string): void {
|
|
12
|
+
console.log(pc.bold(pc.cyan(`\n=== ${text} ===\n`)));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function printField(label: string, value: string | number | boolean | undefined): void {
|
|
16
|
+
if (value !== undefined) {
|
|
17
|
+
console.log(`${pc.dim(label + ":")} ${value}`);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function printListItem(text: string, indent = 0): void {
|
|
22
|
+
const prefix = " ".repeat(indent);
|
|
23
|
+
console.log(`${prefix}${pc.yellow("•")} ${text}`);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function printError(message: string): void {
|
|
27
|
+
console.error(pc.red(`Error: ${message}`));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function printSuccess(message: string): void {
|
|
31
|
+
console.log(pc.green(`✓ ${message}`));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function printWarning(message: string): void {
|
|
35
|
+
console.log(pc.yellow(`⚠ ${message}`));
|
|
36
|
+
}
|
|
37
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nemu.pm/tachiyomi-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for testing and exploring Tachiyomi extensions (requires bun)",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/nemu-pm/tachiyomi-js.git",
|
|
8
|
+
"directory": "cli"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/nemu-pm/tachiyomi-js#readme",
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"tachiyomi": "./bin.ts"
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"typecheck": "tsc --noEmit"
|
|
17
|
+
},
|
|
18
|
+
"engines": {
|
|
19
|
+
"bun": ">=1.0.0"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@inquirer/prompts": "^8.1.0",
|
|
23
|
+
"@stricli/core": "^1.2.4",
|
|
24
|
+
"picocolors": "^1.1.1"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/bun": "latest",
|
|
28
|
+
"@types/node": "^22.19.3",
|
|
29
|
+
"typescript": "^5.0.0"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"tachiyomi",
|
|
33
|
+
"manga",
|
|
34
|
+
"extension",
|
|
35
|
+
"cli",
|
|
36
|
+
"bun"
|
|
37
|
+
],
|
|
38
|
+
"license": "MIT"
|
|
39
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ESNext",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"skipLibCheck": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"resolveJsonModule": true,
|
|
11
|
+
"types": ["bun-types", "node"]
|
|
12
|
+
},
|
|
13
|
+
"include": ["**/*.ts"],
|
|
14
|
+
"exclude": ["node_modules"]
|
|
15
|
+
}
|
|
16
|
+
|