@gengjiawen/os-init 1.17.0 → 1.18.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/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.18.0](https://github.com/gengjiawen/os-init/compare/v1.17.0...v1.18.0) (2026-03-13)
4
+
5
+
6
+ ### Features
7
+
8
+ * add clash config generator ([b9f46b4](https://github.com/gengjiawen/os-init/commit/b9f46b4366955a0cfb8c6337a9956d3461d673f6))
9
+ * add optional mihomo downloader ([632d502](https://github.com/gengjiawen/os-init/commit/632d50234c2a6119f989dd39feb89b3d4d542514))
10
+ * print pm2 command for clash setup ([c4a3a81](https://github.com/gengjiawen/os-init/commit/c4a3a81eaa992816d1d54fdd019e7479bccbc2fb))
11
+
12
+
13
+ ### Bug Fixes
14
+
15
+ * stabilize mihomo download test across platforms ([#28](https://github.com/gengjiawen/os-init/issues/28)) ([006b748](https://github.com/gengjiawen/os-init/commit/006b7481fe5c0e60628547bb22bfa357c8502bea))
16
+
3
17
  ## [1.17.0](https://github.com/gengjiawen/os-init/compare/v1.16.0...v1.17.0) (2026-03-07)
4
18
 
5
19
 
package/README.md CHANGED
@@ -72,6 +72,26 @@ Example:
72
72
  pnpx @gengjiawen/os-init set-fish
73
73
  ```
74
74
 
75
+ ### Generate Mihomo Config
76
+
77
+ ```bash
78
+ pnpx @gengjiawen/os-init set-clash
79
+ pnpx @gengjiawen/os-init set-clash --download
80
+ pnpx @gengjiawen/os-init set-clash --target ~/mihomo/config.yml
81
+ ```
82
+
83
+ Generates a Mihomo `config.yml` from the built-in template. This command will:
84
+
85
+ - Write `./config.yml` in the current directory by default
86
+ - Download the Mihomo binary to the config directory when `--download` is set
87
+ - Support a custom output path with `-t, --target <path>`
88
+
89
+ After generating the config, you can keep Mihomo running with pm2:
90
+
91
+ ```bash
92
+ pm2 start mihomo --name mihomo -- -f /absolute/path/to/config.yml && pm2 save
93
+ ```
94
+
75
95
  ---
76
96
 
77
97
  ### Configure Claude Code
package/bin/bin.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const { Command } = require('commander')
4
+ const path = require('path')
4
5
  const {
5
6
  writeClaudeConfig,
6
7
  installDeps,
@@ -15,11 +16,17 @@ const {
15
16
  writeRaycastConfig,
16
17
  setupDevEnvironment,
17
18
  setupAndroidEnvironment,
19
+ writeMihomoConfig,
20
+ downloadMihomoBinary,
18
21
  } = require('../build')
19
22
  const { appendFishImportScript } = require('../build/fish-shell-utils')
20
23
 
21
24
  const program = new Command()
22
25
 
26
+ function shellEscape(value) {
27
+ return `'${String(value).replace(/'/g, `'\\''`)}'`
28
+ }
29
+
23
30
  program
24
31
  .command('set-cc')
25
32
  .description('setup Claude Code')
@@ -255,4 +262,35 @@ program
255
262
  }
256
263
  })
257
264
 
265
+ program
266
+ .command('set-clash')
267
+ .description('generate clash/mihomo config.yml in current directory')
268
+ .option('-t, --target <path>', 'Target path for mihomo config.yml')
269
+ .option('--download', 'Download Mihomo binary to the config directory')
270
+ .action(async (options) => {
271
+ try {
272
+ const { configPath } = writeMihomoConfig(options.target)
273
+ const resolvedConfigPath = path.resolve(configPath)
274
+ let mihomoCommand = 'mihomo'
275
+
276
+ if (options.download) {
277
+ const { binaryPath, downloadUrl, version } = await downloadMihomoBinary(
278
+ path.dirname(resolvedConfigPath)
279
+ )
280
+ mihomoCommand = path.resolve(binaryPath)
281
+ console.log(`Mihomo download URL: ${downloadUrl}`)
282
+ console.log(`Mihomo version: ${version}`)
283
+ console.log(`Mihomo binary downloaded to: ${binaryPath}`)
284
+ }
285
+
286
+ const pm2Command = `pm2 start ${shellEscape(mihomoCommand)} --name mihomo -- -f ${shellEscape(resolvedConfigPath)} && pm2 save`
287
+ console.log(`Clash config written to: ${configPath}`)
288
+ console.log('Run Mihomo with pm2:')
289
+ console.log(` ${pm2Command}`)
290
+ } catch (err) {
291
+ console.error('Failed to generate Clash config:', err.message)
292
+ process.exit(1)
293
+ }
294
+ })
295
+
258
296
  program.parse(process.argv)
package/build/index.d.ts CHANGED
@@ -8,3 +8,4 @@ export { writeOpencodeConfig, installOpencodeDeps } from './opencode';
8
8
  export { writeAllAgentsConfig, installAllAgentsDeps } from './all-agents';
9
9
  export { setupDevEnvironment } from './dev-setup';
10
10
  export { setupAndroidEnvironment } from './android-setup';
11
+ export { writeMihomoConfig, downloadMihomoBinary } from './mihomo';
package/build/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.setupAndroidEnvironment = exports.setupDevEnvironment = exports.installAllAgentsDeps = exports.writeAllAgentsConfig = exports.installOpencodeDeps = exports.writeOpencodeConfig = exports.installGeminiDeps = exports.writeGeminiConfig = exports.installCodexDeps = exports.writeCodexConfig = exports.installDeps = exports.writeClaudeConfig = void 0;
3
+ exports.downloadMihomoBinary = exports.writeMihomoConfig = exports.setupAndroidEnvironment = exports.setupDevEnvironment = exports.installAllAgentsDeps = exports.writeAllAgentsConfig = exports.installOpencodeDeps = exports.writeOpencodeConfig = exports.installGeminiDeps = exports.writeGeminiConfig = exports.installCodexDeps = exports.writeCodexConfig = exports.installDeps = exports.writeClaudeConfig = void 0;
4
4
  exports.writeRaycastConfig = writeRaycastConfig;
5
5
  const fs = require("fs");
6
6
  const path = require("path");
@@ -57,3 +57,6 @@ var dev_setup_1 = require("./dev-setup");
57
57
  Object.defineProperty(exports, "setupDevEnvironment", { enumerable: true, get: function () { return dev_setup_1.setupDevEnvironment; } });
58
58
  var android_setup_1 = require("./android-setup");
59
59
  Object.defineProperty(exports, "setupAndroidEnvironment", { enumerable: true, get: function () { return android_setup_1.setupAndroidEnvironment; } });
60
+ var mihomo_1 = require("./mihomo");
61
+ Object.defineProperty(exports, "writeMihomoConfig", { enumerable: true, get: function () { return mihomo_1.writeMihomoConfig; } });
62
+ Object.defineProperty(exports, "downloadMihomoBinary", { enumerable: true, get: function () { return mihomo_1.downloadMihomoBinary; } });
@@ -0,0 +1,8 @@
1
+ export declare function downloadMihomoBinary(targetDir: string): Promise<{
2
+ binaryPath: string;
3
+ downloadUrl: string;
4
+ version: string;
5
+ }>;
6
+ export declare function writeMihomoConfig(targetPath?: string): {
7
+ configPath: string;
8
+ };
@@ -0,0 +1,207 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.downloadMihomoBinary = downloadMihomoBinary;
4
+ exports.writeMihomoConfig = writeMihomoConfig;
5
+ const fs = require("fs");
6
+ const os = require("os");
7
+ const path = require("path");
8
+ const zlib_1 = require("zlib");
9
+ const unzip_url_1 = require("@gengjiawen/unzip-url");
10
+ const utils_1 = require("./utils");
11
+ const MIHOMO_CONFIG_TEMPLATE = `mixed-port: 7890
12
+ mode: rule
13
+ log-level: info
14
+ dns:
15
+ enable: true
16
+ ipv6: false
17
+ default-nameserver:
18
+ - 1.1.1.1
19
+ - 223.5.5.5
20
+ - 119.29.29.29
21
+ sniffer:
22
+ enable: true
23
+ sniffing:
24
+ - tls
25
+ - http
26
+ tun:
27
+ enable: true
28
+ stack: system
29
+ auto-route: true
30
+ auto-redirect: true
31
+ auto-detect-interface: true
32
+ proxies:
33
+ - name: socks5-proxy
34
+ type: socks5
35
+ server: your.ip
36
+ port: 6153
37
+ proxy-groups:
38
+ - name: PROXY
39
+ type: select
40
+ proxies:
41
+ - socks5-proxy
42
+ rules:
43
+ - IP-CIDR,127.0.0.0/8,DIRECT
44
+ - IP-CIDR,10.0.0.0/8,DIRECT
45
+ - IP-CIDR,172.16.0.0/12,DIRECT
46
+ - IP-CIDR,192.168.0.0/16,DIRECT
47
+ - IP-CIDR,169.254.0.0/16,DIRECT
48
+ - IP-CIDR,100.64.0.0/10,DIRECT
49
+ - IP-CIDR6,fc00::/7,DIRECT
50
+ - IP-CIDR6,fe80::/10,DIRECT
51
+ - DOMAIN-SUFFIX,local,DIRECT
52
+ - DOMAIN-SUFFIX,cn,DIRECT
53
+ - GEOIP,CN,DIRECT
54
+ - MATCH,PROXY
55
+ `;
56
+ const MIHOMO_LATEST_RELEASE_API_URL = 'https://api.github.com/repos/MetaCubeX/mihomo/releases/latest';
57
+ function getMihomoBinaryFilename(platform = process.platform) {
58
+ return platform === 'win32' ? 'mihomo.exe' : 'mihomo';
59
+ }
60
+ function getMihomoPlatformToken(platform = process.platform) {
61
+ switch (platform) {
62
+ case 'linux':
63
+ return 'linux';
64
+ case 'darwin':
65
+ return 'darwin';
66
+ case 'win32':
67
+ return 'windows';
68
+ default:
69
+ throw new Error(`Unsupported platform for Mihomo download: ${platform}`);
70
+ }
71
+ }
72
+ function getMihomoArchTokens(arch = process.arch) {
73
+ switch (arch) {
74
+ case 'x64':
75
+ return ['amd64'];
76
+ case 'arm64':
77
+ return ['arm64-v8', 'arm64'];
78
+ case 'arm':
79
+ return ['armv7', 'arm'];
80
+ case 'ia32':
81
+ return ['386'];
82
+ default:
83
+ throw new Error(`Unsupported architecture for Mihomo download: ${arch}`);
84
+ }
85
+ }
86
+ function getMihomoCpuLevelTokens(platform = process.platform, arch = process.arch) {
87
+ if (platform !== 'linux' && platform !== 'darwin') {
88
+ return [];
89
+ }
90
+ if (arch !== 'x64') {
91
+ return [];
92
+ }
93
+ return ['v3', 'v2', 'v1'];
94
+ }
95
+ function findMihomoAsset(assets, platform = process.platform, arch = process.arch) {
96
+ const platformToken = getMihomoPlatformToken(platform);
97
+ const archTokens = getMihomoArchTokens(arch);
98
+ const cpuLevelTokens = getMihomoCpuLevelTokens(platform, arch);
99
+ const extension = platform === 'win32' ? '.zip' : '.gz';
100
+ const candidates = assets.filter((asset) => asset.name.startsWith(`mihomo-${platformToken}-`) &&
101
+ asset.name.endsWith(extension));
102
+ const sortCandidates = (left, right) => {
103
+ const leftHasGoTag = left.name.includes('-go');
104
+ const rightHasGoTag = right.name.includes('-go');
105
+ if (leftHasGoTag !== rightHasGoTag) {
106
+ return leftHasGoTag ? 1 : -1;
107
+ }
108
+ const leftCompatible = left.name.includes('compatible');
109
+ const rightCompatible = right.name.includes('compatible');
110
+ if (leftCompatible !== rightCompatible) {
111
+ return leftCompatible ? 1 : -1;
112
+ }
113
+ return left.name.localeCompare(right.name);
114
+ };
115
+ for (const archToken of archTokens) {
116
+ const archMatches = candidates.filter((asset) => asset.name.includes(`-${archToken}-`));
117
+ if (archMatches.length === 0) {
118
+ continue;
119
+ }
120
+ for (const cpuLevelToken of cpuLevelTokens) {
121
+ const cpuMatches = archMatches
122
+ .filter((asset) => asset.name.includes(`-${cpuLevelToken}-`))
123
+ .sort(sortCandidates);
124
+ if (cpuMatches.length > 0) {
125
+ return cpuMatches[0];
126
+ }
127
+ }
128
+ const fallbackMatches = archMatches.sort(sortCandidates);
129
+ if (fallbackMatches.length > 0) {
130
+ return fallbackMatches[0];
131
+ }
132
+ }
133
+ throw new Error(`No Mihomo binary found for platform ${platform} and architecture ${arch}.`);
134
+ }
135
+ function findFileRecursively(rootDir, matcher) {
136
+ const entries = fs.readdirSync(rootDir, { withFileTypes: true });
137
+ for (const entry of entries) {
138
+ const fullPath = path.join(rootDir, entry.name);
139
+ if (entry.isDirectory()) {
140
+ const nested = findFileRecursively(fullPath, matcher);
141
+ if (nested)
142
+ return nested;
143
+ continue;
144
+ }
145
+ if (matcher.test(entry.name)) {
146
+ return fullPath;
147
+ }
148
+ }
149
+ return null;
150
+ }
151
+ async function fetchMihomoLatestRelease() {
152
+ const response = await fetch(MIHOMO_LATEST_RELEASE_API_URL, {
153
+ headers: {
154
+ Accept: 'application/vnd.github+json',
155
+ 'User-Agent': '@gengjiawen/os-init',
156
+ },
157
+ });
158
+ if (!response.ok) {
159
+ throw new Error(`Failed to fetch Mihomo release metadata: ${response.status} ${response.statusText}`);
160
+ }
161
+ return (await response.json());
162
+ }
163
+ async function downloadMihomoBinary(targetDir) {
164
+ (0, utils_1.ensureDir)(targetDir);
165
+ const release = await fetchMihomoLatestRelease();
166
+ const asset = findMihomoAsset(release.assets);
167
+ const binaryPath = path.join(targetDir, getMihomoBinaryFilename());
168
+ if (process.platform === 'win32') {
169
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'os-init-mihomo-'));
170
+ try {
171
+ await (0, unzip_url_1.unzip)(asset.browser_download_url, tempDir);
172
+ const extractedBinary = findFileRecursively(tempDir, /^mihomo.*\.exe$/i);
173
+ if (!extractedBinary) {
174
+ throw new Error(`Mihomo executable was not found in asset ${asset.name}.`);
175
+ }
176
+ fs.copyFileSync(extractedBinary, binaryPath);
177
+ }
178
+ finally {
179
+ fs.rmSync(tempDir, { recursive: true, force: true });
180
+ }
181
+ }
182
+ else {
183
+ const response = await fetch(asset.browser_download_url, {
184
+ headers: {
185
+ Accept: 'application/octet-stream',
186
+ 'User-Agent': '@gengjiawen/os-init',
187
+ },
188
+ });
189
+ if (!response.ok) {
190
+ throw new Error(`Failed to download Mihomo binary: ${response.status} ${response.statusText}`);
191
+ }
192
+ const compressedBinary = Buffer.from(await response.arrayBuffer());
193
+ fs.writeFileSync(binaryPath, (0, zlib_1.gunzipSync)(compressedBinary));
194
+ fs.chmodSync(binaryPath, 0o755);
195
+ }
196
+ return {
197
+ binaryPath,
198
+ downloadUrl: asset.browser_download_url,
199
+ version: release.tag_name,
200
+ };
201
+ }
202
+ function writeMihomoConfig(targetPath) {
203
+ const configPath = targetPath || path.join(process.cwd(), 'config.yml');
204
+ (0, utils_1.ensureDir)(path.dirname(configPath));
205
+ fs.writeFileSync(configPath, MIHOMO_CONFIG_TEMPLATE);
206
+ return { configPath };
207
+ }
package/libs/index.ts CHANGED
@@ -66,3 +66,6 @@ export { setupDevEnvironment } from './dev-setup'
66
66
 
67
67
  // Re-export android-setup functionality
68
68
  export { setupAndroidEnvironment } from './android-setup'
69
+
70
+ // Re-export mihomo functionality
71
+ export { writeMihomoConfig, downloadMihomoBinary } from './mihomo'
@@ -0,0 +1,293 @@
1
+ import * as fs from 'fs'
2
+ import * as os from 'os'
3
+ import * as path from 'path'
4
+ import { gzipSync } from 'zlib'
5
+
6
+ jest.mock('execa', () => ({
7
+ execa: jest.fn(),
8
+ }))
9
+
10
+ jest.mock('@gengjiawen/unzip-url', () => ({
11
+ unzip: jest.fn(),
12
+ }))
13
+
14
+ import { unzip } from '@gengjiawen/unzip-url'
15
+ import { downloadMihomoBinary, writeMihomoConfig } from './mihomo'
16
+
17
+ describe('writeMihomoConfig', () => {
18
+ let tempHome: string
19
+ let tempCwd: string
20
+ let homedirSpy: jest.SpiedFunction<typeof os.homedir>
21
+ let cwdSpy: jest.SpiedFunction<typeof process.cwd>
22
+ let originalPlatformDescriptor: PropertyDescriptor | undefined
23
+ let originalArchDescriptor: PropertyDescriptor | undefined
24
+ let originalFetch: typeof global.fetch | undefined
25
+ let originalAppData: string | undefined
26
+ const unzipMock = unzip as jest.MockedFunction<typeof unzip>
27
+
28
+ function setProcessRuntime(
29
+ platform: NodeJS.Platform,
30
+ arch: NodeJS.Architecture
31
+ ): void {
32
+ Object.defineProperty(process, 'platform', {
33
+ configurable: true,
34
+ value: platform,
35
+ })
36
+ Object.defineProperty(process, 'arch', {
37
+ configurable: true,
38
+ value: arch,
39
+ })
40
+ }
41
+
42
+ beforeEach(() => {
43
+ tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'os-init-mihomo-'))
44
+ tempCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'os-init-mihomo-cwd-'))
45
+ homedirSpy = jest.spyOn(os, 'homedir').mockReturnValue(tempHome)
46
+ cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(tempCwd)
47
+ originalPlatformDescriptor = Object.getOwnPropertyDescriptor(
48
+ process,
49
+ 'platform'
50
+ )
51
+ originalArchDescriptor = Object.getOwnPropertyDescriptor(process, 'arch')
52
+ originalFetch = global.fetch
53
+ originalAppData = process.env.APPDATA
54
+ process.env.APPDATA = path.join(tempHome, 'AppData', 'Roaming')
55
+ unzipMock.mockReset()
56
+ })
57
+
58
+ afterEach(() => {
59
+ homedirSpy.mockRestore()
60
+ cwdSpy.mockRestore()
61
+ if (originalAppData === undefined) {
62
+ delete process.env.APPDATA
63
+ } else {
64
+ process.env.APPDATA = originalAppData
65
+ }
66
+ if (originalPlatformDescriptor) {
67
+ Object.defineProperty(process, 'platform', originalPlatformDescriptor)
68
+ }
69
+ if (originalArchDescriptor) {
70
+ Object.defineProperty(process, 'arch', originalArchDescriptor)
71
+ }
72
+ if (originalFetch === undefined) {
73
+ global.fetch = undefined as unknown as typeof global.fetch
74
+ } else {
75
+ global.fetch = originalFetch
76
+ }
77
+ fs.rmSync(tempHome, { recursive: true, force: true })
78
+ fs.rmSync(tempCwd, { recursive: true, force: true })
79
+ })
80
+
81
+ test('writes default mihomo config.yml to current directory', () => {
82
+ const result = writeMihomoConfig()
83
+
84
+ expect(result.configPath).toBe(path.join(tempCwd, 'config.yml'))
85
+
86
+ const content = fs.readFileSync(result.configPath, 'utf8')
87
+ expect(content).toContain('mixed-port: 7890')
88
+ expect(content).toContain('mode: rule')
89
+ expect(content).toContain('type: socks5')
90
+ expect(content).toContain('name: socks5-proxy')
91
+ expect(content).toContain('server: your.ip')
92
+ expect(content).toContain('port: 6153')
93
+ expect(content).toContain('proxy-groups:')
94
+ expect(content).toContain('- name: PROXY')
95
+ expect(content).toContain('- socks5-proxy')
96
+ expect(content).toContain('auto-detect-interface: true')
97
+ expect(content).toContain('- IP-CIDR,127.0.0.0/8,DIRECT')
98
+ expect(content).toContain('- IP-CIDR,10.0.0.0/8,DIRECT')
99
+ expect(content).toContain('- IP-CIDR,172.16.0.0/12,DIRECT')
100
+ expect(content).toContain('- IP-CIDR,192.168.0.0/16,DIRECT')
101
+ expect(content).toContain('- IP-CIDR,169.254.0.0/16,DIRECT')
102
+ expect(content).toContain('- IP-CIDR,100.64.0.0/10,DIRECT')
103
+ expect(content).toContain('- IP-CIDR6,fc00::/7,DIRECT')
104
+ expect(content).toContain('- IP-CIDR6,fe80::/10,DIRECT')
105
+ expect(content).toContain('- DOMAIN-SUFFIX,local,DIRECT')
106
+ expect(content).toContain('- DOMAIN-SUFFIX,cn,DIRECT')
107
+ expect(content).toContain('- GEOIP,CN,DIRECT')
108
+ expect(content).toContain('- MATCH,PROXY')
109
+ })
110
+
111
+ test('writes mihomo config to custom target path', () => {
112
+ const customPath = path.join(tempHome, 'custom', 'mihomo.yml')
113
+ const result = writeMihomoConfig(customPath)
114
+
115
+ expect(result.configPath).toBe(customPath)
116
+ expect(fs.existsSync(customPath)).toBe(true)
117
+ })
118
+
119
+ test('downloads linux mihomo binary and prefers v3 without go tag', async () => {
120
+ const targetDir = path.join(tempCwd, 'mihomo-bin')
121
+ const fetchMock = jest.fn()
122
+ const binaryContent = Buffer.from('#!/bin/sh\necho mihomo\n', 'utf8')
123
+
124
+ setProcessRuntime('linux', 'x64')
125
+
126
+ fetchMock
127
+ .mockResolvedValueOnce({
128
+ ok: true,
129
+ json: async () => ({
130
+ tag_name: 'v1.19.13',
131
+ assets: [
132
+ {
133
+ name: 'mihomo-linux-amd64-v1-v1.19.13.gz',
134
+ browser_download_url:
135
+ 'https://example.com/mihomo-linux-amd64-v1-v1.19.13.gz',
136
+ },
137
+ {
138
+ name: 'mihomo-linux-amd64-v2-v1.19.13.gz',
139
+ browser_download_url:
140
+ 'https://example.com/mihomo-linux-amd64-v2-v1.19.13.gz',
141
+ },
142
+ {
143
+ name: 'mihomo-linux-amd64-v3-v1.19.13.gz',
144
+ browser_download_url:
145
+ 'https://example.com/mihomo-linux-amd64-v3-v1.19.13.gz',
146
+ },
147
+ {
148
+ name: 'mihomo-linux-amd64-v3-go120-v1.19.13.gz',
149
+ browser_download_url:
150
+ 'https://example.com/mihomo-linux-amd64-v3-go120-v1.19.13.gz',
151
+ },
152
+ {
153
+ name: 'mihomo-linux-amd64-v3-go123-v1.19.13.gz',
154
+ browser_download_url:
155
+ 'https://example.com/mihomo-linux-amd64-v3-go123-v1.19.13.gz',
156
+ },
157
+ {
158
+ name: 'mihomo-linux-amd64-v1.19.13.gz',
159
+ browser_download_url:
160
+ 'https://example.com/mihomo-linux-amd64-v1.19.13.gz',
161
+ },
162
+ ],
163
+ }),
164
+ })
165
+ .mockResolvedValueOnce({
166
+ ok: true,
167
+ arrayBuffer: async () => gzipSync(binaryContent),
168
+ })
169
+
170
+ global.fetch = fetchMock as unknown as typeof global.fetch
171
+
172
+ const result = await downloadMihomoBinary(targetDir)
173
+
174
+ expect(result.version).toBe('v1.19.13')
175
+ expect(result.binaryPath).toBe(path.join(targetDir, 'mihomo'))
176
+ expect(result.downloadUrl).toBe(
177
+ 'https://example.com/mihomo-linux-amd64-v3-v1.19.13.gz'
178
+ )
179
+ expect(fs.existsSync(result.binaryPath)).toBe(true)
180
+ expect(fs.readFileSync(result.binaryPath)).toEqual(binaryContent)
181
+ expect(fetchMock).toHaveBeenCalledTimes(2)
182
+ })
183
+
184
+ test('downloads darwin mihomo binary and selects darwin asset', async () => {
185
+ const targetDir = path.join(tempCwd, 'mihomo-darwin-bin')
186
+ const fetchMock = jest.fn()
187
+ const binaryContent = Buffer.from('#!/bin/sh\necho mihomo-darwin\n', 'utf8')
188
+
189
+ setProcessRuntime('darwin', 'x64')
190
+
191
+ fetchMock
192
+ .mockResolvedValueOnce({
193
+ ok: true,
194
+ json: async () => ({
195
+ tag_name: 'v1.19.13',
196
+ assets: [
197
+ {
198
+ name: 'mihomo-linux-amd64-v3-v1.19.13.gz',
199
+ browser_download_url:
200
+ 'https://example.com/mihomo-linux-amd64-v3-v1.19.13.gz',
201
+ },
202
+ {
203
+ name: 'mihomo-darwin-amd64-v3-go123-v1.19.13.gz',
204
+ browser_download_url:
205
+ 'https://example.com/mihomo-darwin-amd64-v3-go123-v1.19.13.gz',
206
+ },
207
+ {
208
+ name: 'mihomo-darwin-amd64-v2-v1.19.13.gz',
209
+ browser_download_url:
210
+ 'https://example.com/mihomo-darwin-amd64-v2-v1.19.13.gz',
211
+ },
212
+ {
213
+ name: 'mihomo-darwin-amd64-v3-v1.19.13.gz',
214
+ browser_download_url:
215
+ 'https://example.com/mihomo-darwin-amd64-v3-v1.19.13.gz',
216
+ },
217
+ ],
218
+ }),
219
+ })
220
+ .mockResolvedValueOnce({
221
+ ok: true,
222
+ arrayBuffer: async () => gzipSync(binaryContent),
223
+ })
224
+
225
+ global.fetch = fetchMock as unknown as typeof global.fetch
226
+
227
+ const result = await downloadMihomoBinary(targetDir)
228
+
229
+ expect(result.version).toBe('v1.19.13')
230
+ expect(result.binaryPath).toBe(path.join(targetDir, 'mihomo'))
231
+ expect(result.downloadUrl).toBe(
232
+ 'https://example.com/mihomo-darwin-amd64-v3-v1.19.13.gz'
233
+ )
234
+ expect(fs.existsSync(result.binaryPath)).toBe(true)
235
+ expect(fs.readFileSync(result.binaryPath)).toEqual(binaryContent)
236
+ expect(fetchMock).toHaveBeenCalledTimes(2)
237
+ })
238
+
239
+ test('downloads windows mihomo binary and extracts zip asset', async () => {
240
+ const targetDir = path.join(tempCwd, 'mihomo-windows-bin')
241
+ const fetchMock = jest.fn()
242
+ const binaryContent = Buffer.from('windows-mihomo', 'utf8')
243
+
244
+ setProcessRuntime('win32', 'x64')
245
+
246
+ fetchMock.mockResolvedValueOnce({
247
+ ok: true,
248
+ json: async () => ({
249
+ tag_name: 'v1.19.13',
250
+ assets: [
251
+ {
252
+ name: 'mihomo-linux-amd64-v3-v1.19.13.gz',
253
+ browser_download_url:
254
+ 'https://example.com/mihomo-linux-amd64-v3-v1.19.13.gz',
255
+ },
256
+ {
257
+ name: 'mihomo-windows-amd64-go123-v1.19.13.zip',
258
+ browser_download_url:
259
+ 'https://example.com/mihomo-windows-amd64-go123-v1.19.13.zip',
260
+ },
261
+ {
262
+ name: 'mihomo-windows-amd64-v1.19.13.zip',
263
+ browser_download_url:
264
+ 'https://example.com/mihomo-windows-amd64-v1.19.13.zip',
265
+ },
266
+ ],
267
+ }),
268
+ })
269
+
270
+ unzipMock.mockImplementation(async (_url, destination) => {
271
+ const nestedDir = path.join(destination, 'nested')
272
+ fs.mkdirSync(nestedDir, { recursive: true })
273
+ fs.writeFileSync(path.join(nestedDir, 'mihomo.exe'), binaryContent)
274
+ })
275
+
276
+ global.fetch = fetchMock as unknown as typeof global.fetch
277
+
278
+ const result = await downloadMihomoBinary(targetDir)
279
+
280
+ expect(result.version).toBe('v1.19.13')
281
+ expect(result.binaryPath).toBe(path.join(targetDir, 'mihomo.exe'))
282
+ expect(result.downloadUrl).toBe(
283
+ 'https://example.com/mihomo-windows-amd64-v1.19.13.zip'
284
+ )
285
+ expect(fs.existsSync(result.binaryPath)).toBe(true)
286
+ expect(fs.readFileSync(result.binaryPath)).toEqual(binaryContent)
287
+ expect(unzipMock).toHaveBeenCalledWith(
288
+ 'https://example.com/mihomo-windows-amd64-v1.19.13.zip',
289
+ expect.any(String)
290
+ )
291
+ expect(fetchMock).toHaveBeenCalledTimes(1)
292
+ })
293
+ })
package/libs/mihomo.ts ADDED
@@ -0,0 +1,280 @@
1
+ import * as fs from 'fs'
2
+ import * as os from 'os'
3
+ import * as path from 'path'
4
+ import { gunzipSync } from 'zlib'
5
+ import { unzip } from '@gengjiawen/unzip-url'
6
+ import { ensureDir } from './utils'
7
+
8
+ const MIHOMO_CONFIG_TEMPLATE = `mixed-port: 7890
9
+ mode: rule
10
+ log-level: info
11
+ dns:
12
+ enable: true
13
+ ipv6: false
14
+ default-nameserver:
15
+ - 1.1.1.1
16
+ - 223.5.5.5
17
+ - 119.29.29.29
18
+ sniffer:
19
+ enable: true
20
+ sniffing:
21
+ - tls
22
+ - http
23
+ tun:
24
+ enable: true
25
+ stack: system
26
+ auto-route: true
27
+ auto-redirect: true
28
+ auto-detect-interface: true
29
+ proxies:
30
+ - name: socks5-proxy
31
+ type: socks5
32
+ server: your.ip
33
+ port: 6153
34
+ proxy-groups:
35
+ - name: PROXY
36
+ type: select
37
+ proxies:
38
+ - socks5-proxy
39
+ rules:
40
+ - IP-CIDR,127.0.0.0/8,DIRECT
41
+ - IP-CIDR,10.0.0.0/8,DIRECT
42
+ - IP-CIDR,172.16.0.0/12,DIRECT
43
+ - IP-CIDR,192.168.0.0/16,DIRECT
44
+ - IP-CIDR,169.254.0.0/16,DIRECT
45
+ - IP-CIDR,100.64.0.0/10,DIRECT
46
+ - IP-CIDR6,fc00::/7,DIRECT
47
+ - IP-CIDR6,fe80::/10,DIRECT
48
+ - DOMAIN-SUFFIX,local,DIRECT
49
+ - DOMAIN-SUFFIX,cn,DIRECT
50
+ - GEOIP,CN,DIRECT
51
+ - MATCH,PROXY
52
+ `
53
+
54
+ const MIHOMO_LATEST_RELEASE_API_URL =
55
+ 'https://api.github.com/repos/MetaCubeX/mihomo/releases/latest'
56
+
57
+ interface MihomoReleaseAsset {
58
+ name: string
59
+ browser_download_url: string
60
+ }
61
+
62
+ interface MihomoRelease {
63
+ tag_name: string
64
+ assets: MihomoReleaseAsset[]
65
+ }
66
+
67
+ function getMihomoBinaryFilename(platform = process.platform): string {
68
+ return platform === 'win32' ? 'mihomo.exe' : 'mihomo'
69
+ }
70
+
71
+ function getMihomoPlatformToken(platform = process.platform): string {
72
+ switch (platform) {
73
+ case 'linux':
74
+ return 'linux'
75
+ case 'darwin':
76
+ return 'darwin'
77
+ case 'win32':
78
+ return 'windows'
79
+ default:
80
+ throw new Error(`Unsupported platform for Mihomo download: ${platform}`)
81
+ }
82
+ }
83
+
84
+ function getMihomoArchTokens(arch = process.arch): string[] {
85
+ switch (arch) {
86
+ case 'x64':
87
+ return ['amd64']
88
+ case 'arm64':
89
+ return ['arm64-v8', 'arm64']
90
+ case 'arm':
91
+ return ['armv7', 'arm']
92
+ case 'ia32':
93
+ return ['386']
94
+ default:
95
+ throw new Error(`Unsupported architecture for Mihomo download: ${arch}`)
96
+ }
97
+ }
98
+
99
+ function getMihomoCpuLevelTokens(
100
+ platform = process.platform,
101
+ arch = process.arch
102
+ ): string[] {
103
+ if (platform !== 'linux' && platform !== 'darwin') {
104
+ return []
105
+ }
106
+
107
+ if (arch !== 'x64') {
108
+ return []
109
+ }
110
+
111
+ return ['v3', 'v2', 'v1']
112
+ }
113
+
114
+ function findMihomoAsset(
115
+ assets: MihomoReleaseAsset[],
116
+ platform = process.platform,
117
+ arch = process.arch
118
+ ): MihomoReleaseAsset {
119
+ const platformToken = getMihomoPlatformToken(platform)
120
+ const archTokens = getMihomoArchTokens(arch)
121
+ const cpuLevelTokens = getMihomoCpuLevelTokens(platform, arch)
122
+ const extension = platform === 'win32' ? '.zip' : '.gz'
123
+
124
+ const candidates = assets.filter(
125
+ (asset) =>
126
+ asset.name.startsWith(`mihomo-${platformToken}-`) &&
127
+ asset.name.endsWith(extension)
128
+ )
129
+
130
+ const sortCandidates = (
131
+ left: MihomoReleaseAsset,
132
+ right: MihomoReleaseAsset
133
+ ): number => {
134
+ const leftHasGoTag = left.name.includes('-go')
135
+ const rightHasGoTag = right.name.includes('-go')
136
+
137
+ if (leftHasGoTag !== rightHasGoTag) {
138
+ return leftHasGoTag ? 1 : -1
139
+ }
140
+
141
+ const leftCompatible = left.name.includes('compatible')
142
+ const rightCompatible = right.name.includes('compatible')
143
+
144
+ if (leftCompatible !== rightCompatible) {
145
+ return leftCompatible ? 1 : -1
146
+ }
147
+
148
+ return left.name.localeCompare(right.name)
149
+ }
150
+
151
+ for (const archToken of archTokens) {
152
+ const archMatches = candidates.filter((asset) =>
153
+ asset.name.includes(`-${archToken}-`)
154
+ )
155
+
156
+ if (archMatches.length === 0) {
157
+ continue
158
+ }
159
+
160
+ for (const cpuLevelToken of cpuLevelTokens) {
161
+ const cpuMatches = archMatches
162
+ .filter((asset) => asset.name.includes(`-${cpuLevelToken}-`))
163
+ .sort(sortCandidates)
164
+
165
+ if (cpuMatches.length > 0) {
166
+ return cpuMatches[0]
167
+ }
168
+ }
169
+
170
+ const fallbackMatches = archMatches.sort(sortCandidates)
171
+
172
+ if (fallbackMatches.length > 0) {
173
+ return fallbackMatches[0]
174
+ }
175
+ }
176
+
177
+ throw new Error(
178
+ `No Mihomo binary found for platform ${platform} and architecture ${arch}.`
179
+ )
180
+ }
181
+
182
+ function findFileRecursively(rootDir: string, matcher: RegExp): string | null {
183
+ const entries = fs.readdirSync(rootDir, { withFileTypes: true })
184
+
185
+ for (const entry of entries) {
186
+ const fullPath = path.join(rootDir, entry.name)
187
+
188
+ if (entry.isDirectory()) {
189
+ const nested = findFileRecursively(fullPath, matcher)
190
+ if (nested) return nested
191
+ continue
192
+ }
193
+
194
+ if (matcher.test(entry.name)) {
195
+ return fullPath
196
+ }
197
+ }
198
+
199
+ return null
200
+ }
201
+
202
+ async function fetchMihomoLatestRelease(): Promise<MihomoRelease> {
203
+ const response = await fetch(MIHOMO_LATEST_RELEASE_API_URL, {
204
+ headers: {
205
+ Accept: 'application/vnd.github+json',
206
+ 'User-Agent': '@gengjiawen/os-init',
207
+ },
208
+ })
209
+
210
+ if (!response.ok) {
211
+ throw new Error(
212
+ `Failed to fetch Mihomo release metadata: ${response.status} ${response.statusText}`
213
+ )
214
+ }
215
+
216
+ return (await response.json()) as MihomoRelease
217
+ }
218
+
219
+ export async function downloadMihomoBinary(targetDir: string): Promise<{
220
+ binaryPath: string
221
+ downloadUrl: string
222
+ version: string
223
+ }> {
224
+ ensureDir(targetDir)
225
+
226
+ const release = await fetchMihomoLatestRelease()
227
+ const asset = findMihomoAsset(release.assets)
228
+ const binaryPath = path.join(targetDir, getMihomoBinaryFilename())
229
+
230
+ if (process.platform === 'win32') {
231
+ const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'os-init-mihomo-'))
232
+
233
+ try {
234
+ await unzip(asset.browser_download_url, tempDir)
235
+ const extractedBinary = findFileRecursively(tempDir, /^mihomo.*\.exe$/i)
236
+
237
+ if (!extractedBinary) {
238
+ throw new Error(
239
+ `Mihomo executable was not found in asset ${asset.name}.`
240
+ )
241
+ }
242
+
243
+ fs.copyFileSync(extractedBinary, binaryPath)
244
+ } finally {
245
+ fs.rmSync(tempDir, { recursive: true, force: true })
246
+ }
247
+ } else {
248
+ const response = await fetch(asset.browser_download_url, {
249
+ headers: {
250
+ Accept: 'application/octet-stream',
251
+ 'User-Agent': '@gengjiawen/os-init',
252
+ },
253
+ })
254
+
255
+ if (!response.ok) {
256
+ throw new Error(
257
+ `Failed to download Mihomo binary: ${response.status} ${response.statusText}`
258
+ )
259
+ }
260
+
261
+ const compressedBinary = Buffer.from(await response.arrayBuffer())
262
+ fs.writeFileSync(binaryPath, gunzipSync(compressedBinary))
263
+ fs.chmodSync(binaryPath, 0o755)
264
+ }
265
+
266
+ return {
267
+ binaryPath,
268
+ downloadUrl: asset.browser_download_url,
269
+ version: release.tag_name,
270
+ }
271
+ }
272
+
273
+ /** Write Mihomo config.yml */
274
+ export function writeMihomoConfig(targetPath?: string): { configPath: string } {
275
+ const configPath = targetPath || path.join(process.cwd(), 'config.yml')
276
+ ensureDir(path.dirname(configPath))
277
+ fs.writeFileSync(configPath, MIHOMO_CONFIG_TEMPLATE)
278
+
279
+ return { configPath }
280
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gengjiawen/os-init",
3
3
  "private": false,
4
- "version": "1.17.0",
4
+ "version": "1.18.0",
5
5
  "description": "",
6
6
  "main": "index.js",
7
7
  "bin": {