@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,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
+