@meanc/otter 0.0.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.
@@ -0,0 +1,193 @@
1
+ import yaml from 'js-yaml';
2
+
3
+ interface Proxy {
4
+ name: string;
5
+ type: string;
6
+ server: string;
7
+ port: number;
8
+ [key: string]: any;
9
+ }
10
+
11
+ export class ConfigParser {
12
+ static parse(content: string): string {
13
+ // 1. Check if it's already YAML
14
+ try {
15
+ const parsed = yaml.load(content) as any;
16
+ if (parsed && (parsed.proxies || parsed['proxy-groups'])) {
17
+ return content;
18
+ }
19
+ } catch (e) { }
20
+
21
+ // 2. Try Base64 decode
22
+ let decoded = content;
23
+ try {
24
+ const clean = content.trim();
25
+ // Simple check if it looks like base64 (no spaces, valid chars)
26
+ if (!clean.includes(' ') && /^[a-zA-Z0-9+/=]+$/.test(clean)) {
27
+ decoded = atob(clean);
28
+ }
29
+ } catch (e) {
30
+ // If decode fails, continue with original content
31
+ }
32
+
33
+ const lines = decoded.split(/\r?\n/).filter(l => l.trim() !== '');
34
+ const proxies: Proxy[] = [];
35
+
36
+ for (const line of lines) {
37
+ const l = line.trim();
38
+ if (l.startsWith('vmess://')) {
39
+ const p = this.parseVmess(l);
40
+ if (p) proxies.push(p);
41
+ } else if (l.startsWith('ss://')) {
42
+ const p = this.parseSS(l);
43
+ if (p) proxies.push(p);
44
+ } else if (l.startsWith('trojan://')) {
45
+ const p = this.parseTrojan(l);
46
+ if (p) proxies.push(p);
47
+ }
48
+ }
49
+
50
+ if (proxies.length === 0) {
51
+ return content;
52
+ }
53
+
54
+ // Construct Clash Config
55
+ const config = {
56
+ port: 7890,
57
+ 'socks-port': 7891,
58
+ 'mixed-port': 7890,
59
+ 'allow-lan': false,
60
+ mode: 'rule',
61
+ 'log-level': 'info',
62
+ 'external-controller': '127.0.0.1:9090',
63
+ proxies: proxies,
64
+ 'proxy-groups': [
65
+ {
66
+ name: 'Proxy',
67
+ type: 'select',
68
+ proxies: ['Auto', ...proxies.map(p => p.name)]
69
+ },
70
+ {
71
+ name: 'Auto',
72
+ type: 'url-test',
73
+ url: 'http://www.gstatic.com/generate_204',
74
+ interval: 300,
75
+ proxies: proxies.map(p => p.name)
76
+ }
77
+ ],
78
+ rules: [
79
+ 'MATCH,Proxy'
80
+ ]
81
+ };
82
+
83
+ return yaml.dump(config);
84
+ }
85
+
86
+ private static parseVmess(url: string): Proxy | null {
87
+ try {
88
+ const b64 = url.slice(8);
89
+ const jsonStr = atob(b64);
90
+ const config = JSON.parse(jsonStr);
91
+
92
+ return {
93
+ name: config.ps || 'vmess',
94
+ type: 'vmess',
95
+ server: config.add,
96
+ port: parseInt(config.port),
97
+ uuid: config.id,
98
+ alterId: parseInt(config.aid || '0'),
99
+ cipher: 'auto',
100
+ tls: config.tls === 'tls',
101
+ servername: config.host || '',
102
+ network: config.net || 'tcp',
103
+ 'ws-opts': config.net === 'ws' ? {
104
+ path: config.path || '/',
105
+ headers: {
106
+ Host: config.host || ''
107
+ }
108
+ } : undefined
109
+ };
110
+ } catch (e) {
111
+ return null;
112
+ }
113
+ }
114
+
115
+ private static parseSS(url: string): Proxy | null {
116
+ try {
117
+ let raw = url.slice(5);
118
+ let name = 'ss';
119
+ const hashIndex = raw.indexOf('#');
120
+ if (hashIndex !== -1) {
121
+ name = decodeURIComponent(raw.substring(hashIndex + 1));
122
+ raw = raw.substring(0, hashIndex);
123
+ }
124
+
125
+ let method, password, server, port;
126
+
127
+ if (raw.includes('@')) {
128
+ // SIP002: userinfo@host:port
129
+ const atIndex = raw.lastIndexOf('@');
130
+ const userInfoB64 = raw.substring(0, atIndex);
131
+ const hostPort = raw.substring(atIndex + 1);
132
+
133
+ // userInfoB64 decodes to method:password
134
+ const userInfo = atob(userInfoB64);
135
+ [method, password] = userInfo.split(':');
136
+ [server, port] = hostPort.split(':');
137
+ } else {
138
+ // Legacy: base64(method:password@host:port)
139
+ const decoded = atob(raw);
140
+ // method:password@host:port
141
+ const atIndex = decoded.lastIndexOf('@');
142
+ const userInfo = decoded.substring(0, atIndex);
143
+ const hostPort = decoded.substring(atIndex + 1);
144
+
145
+ [method, password] = userInfo.split(':');
146
+ [server, port] = hostPort.split(':');
147
+ }
148
+
149
+ if (!server || !port) return null;
150
+
151
+ return {
152
+ name,
153
+ type: 'ss',
154
+ server,
155
+ port: parseInt(port),
156
+ cipher: method,
157
+ password
158
+ };
159
+ } catch (e) {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ private static parseTrojan(url: string): Proxy | null {
165
+ try {
166
+ let raw = url.slice(9);
167
+ let name = 'trojan';
168
+ const hashIndex = raw.indexOf('#');
169
+ if (hashIndex !== -1) {
170
+ name = decodeURIComponent(raw.substring(hashIndex + 1));
171
+ raw = raw.substring(0, hashIndex);
172
+ }
173
+
174
+ const atIndex = raw.indexOf('@');
175
+ const userInfo = raw.substring(0, atIndex);
176
+ const hostPort = raw.substring(atIndex + 1);
177
+ const [server, portStr] = hostPort.split(':');
178
+
179
+ if (!server || !portStr) return null;
180
+
181
+ return {
182
+ name,
183
+ type: 'trojan',
184
+ server,
185
+ port: parseInt(portStr),
186
+ password: userInfo,
187
+ udp: true
188
+ };
189
+ } catch (e) {
190
+ return null;
191
+ }
192
+ }
193
+ }
@@ -0,0 +1,10 @@
1
+ import path from 'path';
2
+ import os from 'os';
3
+
4
+ export const HOME_DIR = path.join(os.homedir(), '.otter');
5
+ export const CONFIG_FILE = path.join(HOME_DIR, 'config.yaml');
6
+ export const PID_FILE = path.join(HOME_DIR, 'otter.pid');
7
+ export const LOG_FILE = path.join(HOME_DIR, 'otter.log');
8
+ export const BIN_PATH = path.resolve(import.meta.dir, '../../bin/mihomo');
9
+ export const SUBSCRIPTIONS_FILE = path.join(HOME_DIR, 'subscriptions.json');
10
+ export const PROFILES_DIR = path.join(HOME_DIR, 'profiles');
@@ -0,0 +1,169 @@
1
+ import fs from 'fs-extra';
2
+ import path from 'path';
3
+ import { SUBSCRIPTIONS_FILE, PROFILES_DIR, CONFIG_FILE } from './paths';
4
+ import { CoreManager } from './core';
5
+ import { ConfigParser } from './parser';
6
+ import chalk from 'chalk';
7
+
8
+ export interface Subscription {
9
+ name: string;
10
+ url: string;
11
+ updatedAt: string;
12
+ }
13
+
14
+ export interface SubscriptionData {
15
+ active: string | null;
16
+ subscriptions: Subscription[];
17
+ }
18
+
19
+ export class SubscriptionManager {
20
+ static async init() {
21
+ await fs.ensureDir(PROFILES_DIR);
22
+ if (!await fs.pathExists(SUBSCRIPTIONS_FILE)) {
23
+ const initialData: SubscriptionData = {
24
+ active: null,
25
+ subscriptions: []
26
+ };
27
+ await fs.writeJson(SUBSCRIPTIONS_FILE, initialData, { spaces: 2 });
28
+ }
29
+ }
30
+
31
+ static async getData(): Promise<SubscriptionData> {
32
+ await this.init();
33
+ return await fs.readJson(SUBSCRIPTIONS_FILE);
34
+ }
35
+
36
+ static async saveData(data: SubscriptionData) {
37
+ await fs.writeJson(SUBSCRIPTIONS_FILE, data, { spaces: 2 });
38
+ }
39
+
40
+ static async add(name: string, url: string) {
41
+ const data = await this.getData();
42
+ if (data.subscriptions.find(s => s.name === name)) {
43
+ throw new Error(`Subscription '${name}' already exists.`);
44
+ }
45
+
46
+ console.log(chalk.blue(`Fetching configuration from ${url}...`));
47
+ const response = await fetch(url);
48
+ if (!response.ok) {
49
+ throw new Error(`Failed to fetch: ${response.statusText}`);
50
+ }
51
+ let configContent = await response.text();
52
+
53
+ // Try to parse/convert if it's not standard Clash config
54
+ configContent = ConfigParser.parse(configContent);
55
+
56
+ // Basic validation
57
+ if (!configContent.includes('proxies:') && !configContent.includes('proxy-groups:')) {
58
+ console.warn(chalk.yellow('Warning: The downloaded content does not look like a standard Clash config.'));
59
+ }
60
+
61
+ const profilePath = path.join(PROFILES_DIR, `${name}.yaml`);
62
+ await fs.writeFile(profilePath, configContent);
63
+
64
+ data.subscriptions.push({
65
+ name,
66
+ url,
67
+ updatedAt: new Date().toISOString()
68
+ });
69
+
70
+ // If it's the first subscription, make it active
71
+ if (!data.active) {
72
+ data.active = name;
73
+ await fs.copy(profilePath, CONFIG_FILE);
74
+ }
75
+
76
+ await this.saveData(data);
77
+ console.log(chalk.green(`Subscription '${name}' added successfully.`));
78
+ }
79
+
80
+ static async remove(name: string) {
81
+ const data = await this.getData();
82
+ const index = data.subscriptions.findIndex(s => s.name === name);
83
+ if (index === -1) {
84
+ throw new Error(`Subscription '${name}' not found.`);
85
+ }
86
+
87
+ data.subscriptions.splice(index, 1);
88
+ const profilePath = path.join(PROFILES_DIR, `${name}.yaml`);
89
+ await fs.remove(profilePath);
90
+
91
+ if (data.active === name) {
92
+ data.active = null;
93
+ console.warn(chalk.yellow(`Active subscription '${name}' removed. Please select another subscription.`));
94
+ }
95
+
96
+ await this.saveData(data);
97
+ console.log(chalk.green(`Subscription '${name}' removed.`));
98
+ }
99
+
100
+ static async update(name: string) {
101
+ const data = await this.getData();
102
+ const sub = data.subscriptions.find(s => s.name === name);
103
+ if (!sub) {
104
+ throw new Error(`Subscription '${name}' not found.`);
105
+ }
106
+
107
+ console.log(chalk.blue(`Updating subscription '${name}' from ${sub.url}...`));
108
+ const response = await fetch(sub.url);
109
+ if (!response.ok) {
110
+ throw new Error(`Failed to fetch: ${response.statusText}`);
111
+ }
112
+ let configContent = await response.text();
113
+
114
+ // Try to parse/convert
115
+ configContent = ConfigParser.parse(configContent);
116
+ sub.updatedAt = new Date().toISOString();
117
+ await this.saveData(data);
118
+ console.log(chalk.green(`Subscription '${name}' updated.`));
119
+
120
+ if (data.active === name) {
121
+ await this.apply(name);
122
+ }
123
+ }
124
+
125
+ static async use(name: string) {
126
+ const data = await this.getData();
127
+ const sub = data.subscriptions.find(s => s.name === name);
128
+ if (!sub) {
129
+ throw new Error(`Subscription '${name}' not found.`);
130
+ }
131
+
132
+ await this.apply(name);
133
+
134
+ data.active = name;
135
+ await this.saveData(data);
136
+ console.log(chalk.green(`Switched to subscription '${name}'.`));
137
+ }
138
+
139
+ static async apply(name: string) {
140
+ const profilePath = path.join(PROFILES_DIR, `${name}.yaml`);
141
+ if (!await fs.pathExists(profilePath)) {
142
+ throw new Error(`Profile file for '${name}' not found.`);
143
+ }
144
+
145
+ await fs.copy(profilePath, CONFIG_FILE);
146
+
147
+ if (await CoreManager.isRunning()) {
148
+ console.log(chalk.blue('Restarting core to apply changes...'));
149
+ await CoreManager.stop();
150
+ await CoreManager.start();
151
+ }
152
+ }
153
+
154
+ static async list() {
155
+ const data = await this.getData();
156
+ if (data.subscriptions.length === 0) {
157
+ console.log('No subscriptions found.');
158
+ return;
159
+ }
160
+
161
+ console.log(chalk.bold('Subscriptions:'));
162
+ data.subscriptions.forEach(sub => {
163
+ const isActive = data.active === sub.name;
164
+ const prefix = isActive ? chalk.green('* ') : ' ';
165
+ const name = isActive ? chalk.green(sub.name) : sub.name;
166
+ console.log(`${prefix}${name} (${sub.url}) - Updated: ${sub.updatedAt}`);
167
+ });
168
+ }
169
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedIndexedAccess": true,
22
+ "noImplicitOverride": true,
23
+
24
+ // Some stricter flags (disabled by default)
25
+ "noUnusedLocals": false,
26
+ "noUnusedParameters": false,
27
+ "noPropertyAccessFromIndexSignature": false
28
+ }
29
+ }