@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.
- package/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +111 -0
- package/README.md +96 -0
- package/bin/mihomo +0 -0
- package/bun.lock +146 -0
- package/index.ts +141 -0
- package/package.json +36 -0
- package/src/commands/core.tsx +181 -0
- package/src/commands/proxy.ts +203 -0
- package/src/commands/subscribe.ts +43 -0
- package/src/commands/system.ts +152 -0
- package/src/commands/ui.tsx +322 -0
- package/src/utils/api.ts +107 -0
- package/src/utils/core.ts +153 -0
- package/src/utils/parser.ts +193 -0
- package/src/utils/paths.ts +10 -0
- package/src/utils/subscription.ts +169 -0
- package/tsconfig.json +29 -0
|
@@ -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
|
+
}
|