maactl 0.1.0 → 0.1.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/README.md +62 -62
- package/bin/maactl.js +19 -19
- package/lib/binary.js +221 -221
- package/lib/cli.js +136 -136
- package/lib/download.js +188 -188
- package/lib/env.js +17 -17
- package/lib/errors.js +17 -17
- package/lib/messages.js +80 -80
- package/package.json +52 -52
- package/scripts/install.js +48 -48
- package/scripts/vendor-binary.js +67 -67
- package/vendor/maactl.exe +0 -0
package/README.md
CHANGED
|
@@ -1,62 +1,62 @@
|
|
|
1
|
-
# maactl (npm)
|
|
2
|
-
|
|
3
|
-
通过 `npx` 运行 MaaCtl 的命令行客户端。这个包只是一个很薄的转发器:它负责找到
|
|
4
|
-
`maactl.exe`(自带、缓存或按需下载),把命令行参数原样传给它,并把退出码回传给 shell。
|
|
5
|
-
|
|
6
|
-
```powershell
|
|
7
|
-
# 直接运行,无需全局安装
|
|
8
|
-
npx maactl --version
|
|
9
|
-
npx maactl adb devices
|
|
10
|
-
npx maactl interface --tasks -f D:\01_Projects\github\MaaMio
|
|
11
|
-
|
|
12
|
-
# 若某些 npx 版本吞掉了参数,用 -- 显式分隔
|
|
13
|
-
npx maactl -- run task "自动挂机卖蛋" -f D:\01_Projects\github\MaaMio --stop-after 10s
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
`npx maactl ...` 之后的所有参数都会原样传给 `maactl.exe`,包括 `-f/--interface`、
|
|
17
|
-
`--adb-address`、`--json` 等;工作目录也不会被改变,因此默认的 `./interface.json`
|
|
18
|
-
仍然相对于你执行命令的目录解析。
|
|
19
|
-
|
|
20
|
-
## 平台
|
|
21
|
-
|
|
22
|
-
只提供 Windows amd64 的 `maactl.exe`(自带 MaaFramework 运行库)。包内声明了
|
|
23
|
-
`"os": ["win32"]`,在其他系统上 npm 会以 `EBADPLATFORM` 拒绝安装。
|
|
24
|
-
|
|
25
|
-
## maactl.exe 的来源
|
|
26
|
-
|
|
27
|
-
按以下顺序查找,第一个命中的会被使用:
|
|
28
|
-
|
|
29
|
-
1. 环境变量 `MAACTL_BINARY` 指向的文件;
|
|
30
|
-
2. 包内的 `vendor/maactl.exe`(正式发布的 tarball 会携带);
|
|
31
|
-
3. 缓存 `%LOCALAPPDATA%\maactl\npm\<version>\maactl.exe`;
|
|
32
|
-
4. 都找不到时,从对应版本的 GitHub Release 下载并校验后缓存(下次直接复用)。
|
|
33
|
-
|
|
34
|
-
## 环境变量
|
|
35
|
-
|
|
36
|
-
| 变量 | 作用 |
|
|
37
|
-
| --- | --- |
|
|
38
|
-
| `MAACTL_BINARY` | 指定要执行的 `maactl.exe`,跳过查找与下载。 |
|
|
39
|
-
| `MAACTL_HOME` | 覆盖缓存根目录,默认 `%LOCALAPPDATA%\maactl`。 |
|
|
40
|
-
| `MAACTL_VERSION` | 覆盖下载时使用的版本,默认取包的 `version`。 |
|
|
41
|
-
| `MAACTL_BINARY_URL` | 直接指定下载地址,完全跳过 GitHub 拼接。 |
|
|
42
|
-
| `MAACTL_MIRROR` | 镜像前缀,最终地址为 `<mirror>/https://github.com/<repo>/releases/download/<tag>/maactl.exe`。 |
|
|
43
|
-
| `MAACTL_REPO` / `MAACTL_ASSET` | 覆盖仓库(默认 `TanyaShue/MaaCtl`)与资产名(默认 `maactl.exe`)。 |
|
|
44
|
-
| `MAACTL_SKIP_DOWNLOAD=1` | 安装阶段不预下载,改为首次运行时下载。 |
|
|
45
|
-
| `MAACTL_STRICT_INSTALL=1` | 预下载失败时让 `npm install` 直接失败。 |
|
|
46
|
-
| `MAACTL_QUIET=1` | 静默模式,不输出下载进度。 |
|
|
47
|
-
| `MAACTL_VERBOSE=1` | 输出实际使用的 `maactl.exe` 路径。 |
|
|
48
|
-
| `MAACTL_LANG` | 包装器的提示语言(`zh_CN`/`en`),与 CLI 自身一致。 |
|
|
49
|
-
|
|
50
|
-
## 从源码使用
|
|
51
|
-
|
|
52
|
-
```powershell
|
|
53
|
-
# 直接用 shim,无需安装
|
|
54
|
-
node npm/bin/maactl.js --help
|
|
55
|
-
|
|
56
|
-
# 或在本地安装为全局命令
|
|
57
|
-
cd npm
|
|
58
|
-
npm install -g .
|
|
59
|
-
maactl --version
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
完整的命令说明见 [MaaCtl 主仓库](https://github.com/TanyaShue/MaaCtl#readme)。
|
|
1
|
+
# maactl (npm)
|
|
2
|
+
|
|
3
|
+
通过 `npx` 运行 MaaCtl 的命令行客户端。这个包只是一个很薄的转发器:它负责找到
|
|
4
|
+
`maactl.exe`(自带、缓存或按需下载),把命令行参数原样传给它,并把退出码回传给 shell。
|
|
5
|
+
|
|
6
|
+
```powershell
|
|
7
|
+
# 直接运行,无需全局安装
|
|
8
|
+
npx maactl --version
|
|
9
|
+
npx maactl adb devices
|
|
10
|
+
npx maactl interface --tasks -f D:\01_Projects\github\MaaMio
|
|
11
|
+
|
|
12
|
+
# 若某些 npx 版本吞掉了参数,用 -- 显式分隔
|
|
13
|
+
npx maactl -- run task "自动挂机卖蛋" -f D:\01_Projects\github\MaaMio --stop-after 10s
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`npx maactl ...` 之后的所有参数都会原样传给 `maactl.exe`,包括 `-f/--interface`、
|
|
17
|
+
`--adb-address`、`--json` 等;工作目录也不会被改变,因此默认的 `./interface.json`
|
|
18
|
+
仍然相对于你执行命令的目录解析。
|
|
19
|
+
|
|
20
|
+
## 平台
|
|
21
|
+
|
|
22
|
+
只提供 Windows amd64 的 `maactl.exe`(自带 MaaFramework 运行库)。包内声明了
|
|
23
|
+
`"os": ["win32"]`,在其他系统上 npm 会以 `EBADPLATFORM` 拒绝安装。
|
|
24
|
+
|
|
25
|
+
## maactl.exe 的来源
|
|
26
|
+
|
|
27
|
+
按以下顺序查找,第一个命中的会被使用:
|
|
28
|
+
|
|
29
|
+
1. 环境变量 `MAACTL_BINARY` 指向的文件;
|
|
30
|
+
2. 包内的 `vendor/maactl.exe`(正式发布的 tarball 会携带);
|
|
31
|
+
3. 缓存 `%LOCALAPPDATA%\maactl\npm\<version>\maactl.exe`;
|
|
32
|
+
4. 都找不到时,从对应版本的 GitHub Release 下载并校验后缓存(下次直接复用)。
|
|
33
|
+
|
|
34
|
+
## 环境变量
|
|
35
|
+
|
|
36
|
+
| 变量 | 作用 |
|
|
37
|
+
| --- | --- |
|
|
38
|
+
| `MAACTL_BINARY` | 指定要执行的 `maactl.exe`,跳过查找与下载。 |
|
|
39
|
+
| `MAACTL_HOME` | 覆盖缓存根目录,默认 `%LOCALAPPDATA%\maactl`。 |
|
|
40
|
+
| `MAACTL_VERSION` | 覆盖下载时使用的版本,默认取包的 `version`。 |
|
|
41
|
+
| `MAACTL_BINARY_URL` | 直接指定下载地址,完全跳过 GitHub 拼接。 |
|
|
42
|
+
| `MAACTL_MIRROR` | 镜像前缀,最终地址为 `<mirror>/https://github.com/<repo>/releases/download/<tag>/maactl.exe`。 |
|
|
43
|
+
| `MAACTL_REPO` / `MAACTL_ASSET` | 覆盖仓库(默认 `TanyaShue/MaaCtl`)与资产名(默认 `maactl.exe`)。 |
|
|
44
|
+
| `MAACTL_SKIP_DOWNLOAD=1` | 安装阶段不预下载,改为首次运行时下载。 |
|
|
45
|
+
| `MAACTL_STRICT_INSTALL=1` | 预下载失败时让 `npm install` 直接失败。 |
|
|
46
|
+
| `MAACTL_QUIET=1` | 静默模式,不输出下载进度。 |
|
|
47
|
+
| `MAACTL_VERBOSE=1` | 输出实际使用的 `maactl.exe` 路径。 |
|
|
48
|
+
| `MAACTL_LANG` | 包装器的提示语言(`zh_CN`/`en`),与 CLI 自身一致。 |
|
|
49
|
+
|
|
50
|
+
## 从源码使用
|
|
51
|
+
|
|
52
|
+
```powershell
|
|
53
|
+
# 直接用 shim,无需安装
|
|
54
|
+
node npm/bin/maactl.js --help
|
|
55
|
+
|
|
56
|
+
# 或在本地安装为全局命令
|
|
57
|
+
cd npm
|
|
58
|
+
npm install -g .
|
|
59
|
+
maactl --version
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
完整的命令说明见 [MaaCtl 主仓库](https://github.com/TanyaShue/MaaCtl#readme)。
|
package/bin/maactl.js
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
// npm installs this file as the `maactl` command on Windows
|
|
5
|
-
// (maactl.cmd / maactl.ps1 wrappers), so `npx maactl <args>` ends up here and
|
|
6
|
-
// the arguments are forwarded to maactl.exe verbatim.
|
|
7
|
-
|
|
8
|
-
const { main } = require('../lib/cli');
|
|
9
|
-
const { MaactlError } = require('../lib/errors');
|
|
10
|
-
|
|
11
|
-
main()
|
|
12
|
-
.then((code) => {
|
|
13
|
-
process.exitCode = code;
|
|
14
|
-
})
|
|
15
|
-
.catch((error) => {
|
|
16
|
-
const message = error instanceof MaactlError ? error.message : (error && error.stack) || String(error);
|
|
17
|
-
process.stderr.write(`${message}\n`);
|
|
18
|
-
process.exitCode = error instanceof MaactlError ? error.exitCode : 1;
|
|
19
|
-
});
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// npm installs this file as the `maactl` command on Windows
|
|
5
|
+
// (maactl.cmd / maactl.ps1 wrappers), so `npx maactl <args>` ends up here and
|
|
6
|
+
// the arguments are forwarded to maactl.exe verbatim.
|
|
7
|
+
|
|
8
|
+
const { main } = require('../lib/cli');
|
|
9
|
+
const { MaactlError } = require('../lib/errors');
|
|
10
|
+
|
|
11
|
+
main()
|
|
12
|
+
.then((code) => {
|
|
13
|
+
process.exitCode = code;
|
|
14
|
+
})
|
|
15
|
+
.catch((error) => {
|
|
16
|
+
const message = error instanceof MaactlError ? error.message : (error && error.stack) || String(error);
|
|
17
|
+
process.stderr.write(`${message}\n`);
|
|
18
|
+
process.exitCode = error instanceof MaactlError ? error.exitCode : 1;
|
|
19
|
+
});
|
package/lib/binary.js
CHANGED
|
@@ -1,221 +1,221 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
// Locating (and if necessary fetching) the maactl.exe the wrapper hands over to.
|
|
4
|
-
//
|
|
5
|
-
// Resolution order:
|
|
6
|
-
// 1. MAACTL_BINARY – an explicit path supplied by the user;
|
|
7
|
-
// 2. <package>/vendor/maactl.exe – the executable published inside the tarball;
|
|
8
|
-
// 3. <cache>/npm/<version>/maactl.exe – a previous download, reused across runs.
|
|
9
|
-
//
|
|
10
|
-
// When none of them exists the exe is downloaded from the GitHub release that
|
|
11
|
-
// matches the package version, verified, and cached for the next run.
|
|
12
|
-
|
|
13
|
-
const fs = require('node:fs');
|
|
14
|
-
const os = require('node:os');
|
|
15
|
-
const path = require('node:path');
|
|
16
|
-
const { spawnSync } = require('node:child_process');
|
|
17
|
-
|
|
18
|
-
const { downloadWithRetry, formatBytes, progressReporter } = require('./download');
|
|
19
|
-
const env = require('./env');
|
|
20
|
-
const { MaactlError } = require('./errors');
|
|
21
|
-
const messages = require('./messages');
|
|
22
|
-
|
|
23
|
-
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
24
|
-
const PACKAGE = require(path.join(PACKAGE_ROOT, 'package.json'));
|
|
25
|
-
|
|
26
|
-
// Every asset is a Windows PE binary; anything smaller than this floor is a
|
|
27
|
-
// truncated download, an HTML error page or a git-lfs pointer.
|
|
28
|
-
const MIN_BINARY_BYTES = 1 << 20;
|
|
29
|
-
|
|
30
|
-
const EXE_NAME = 'maactl.exe';
|
|
31
|
-
const DEFAULT_REPO = 'TanyaShue/MaaCtl';
|
|
32
|
-
const DEFAULT_ASSET = 'maactl.exe';
|
|
33
|
-
|
|
34
|
-
function version() {
|
|
35
|
-
return (env.value('MAACTL_VERSION') || PACKAGE.version).replace(/^v/, '');
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function tag() {
|
|
39
|
-
return `v${version()}`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
function repo() {
|
|
43
|
-
return env.value('MAACTL_REPO') || DEFAULT_REPO;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function assetName() {
|
|
47
|
-
return env.value('MAACTL_ASSET') || DEFAULT_ASSET;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
/** Cache root mirroring the CLI's own layout: %LOCALAPPDATA%\maactl on Windows. */
|
|
51
|
-
function home() {
|
|
52
|
-
const explicit = env.value('MAACTL_HOME');
|
|
53
|
-
if (explicit) {
|
|
54
|
-
return path.resolve(explicit);
|
|
55
|
-
}
|
|
56
|
-
if (process.platform === 'win32') {
|
|
57
|
-
const localAppData = env.value('LOCALAPPDATA') || path.join(os.homedir(), 'AppData', 'Local');
|
|
58
|
-
return path.join(localAppData, 'maactl');
|
|
59
|
-
}
|
|
60
|
-
const cacheHome = env.value('XDG_CACHE_HOME') || path.join(os.homedir(), '.cache');
|
|
61
|
-
return path.join(cacheHome, 'maactl');
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function cacheExePath(versionOverride) {
|
|
65
|
-
return path.join(home(), 'npm', versionOverride || version(), EXE_NAME);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function bundledExePath() {
|
|
69
|
-
return path.join(PACKAGE_ROOT, 'vendor', EXE_NAME);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
function binaryUrl() {
|
|
73
|
-
const direct = env.value('MAACTL_BINARY_URL');
|
|
74
|
-
if (direct) {
|
|
75
|
-
return direct;
|
|
76
|
-
}
|
|
77
|
-
const github = `https://github.com/${repo()}/releases/download/${tag()}/${assetName()}`;
|
|
78
|
-
const mirror = env.value('MAACTL_MIRROR').replace(/\/+$/, '');
|
|
79
|
-
return mirror ? `${mirror}/${github}` : github;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
function isUsable(file) {
|
|
83
|
-
try {
|
|
84
|
-
return fs.statSync(file).size > 0;
|
|
85
|
-
} catch {
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Return the path of an existing maactl.exe without touching the network, or
|
|
92
|
-
* `null` when nothing is available locally.
|
|
93
|
-
*/
|
|
94
|
-
function resolveBinary() {
|
|
95
|
-
const explicit = env.value('MAACTL_BINARY');
|
|
96
|
-
if (explicit) {
|
|
97
|
-
const resolved = path.resolve(explicit);
|
|
98
|
-
if (!fs.existsSync(resolved)) {
|
|
99
|
-
throw new MaactlError(messages.text('explicitMissing', resolved));
|
|
100
|
-
}
|
|
101
|
-
return resolved;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
return [bundledExePath(), cacheExePath()].find(isUsable) || null;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Sanity-check a freshly downloaded executable by asking it for its version. */
|
|
108
|
-
function verifyBinary(file, { spawn = spawnSync } = {}) {
|
|
109
|
-
const header = Buffer.alloc(2);
|
|
110
|
-
const fd = fs.openSync(file, 'r');
|
|
111
|
-
try {
|
|
112
|
-
fs.readSync(fd, header, 0, 2, 0);
|
|
113
|
-
} finally {
|
|
114
|
-
fs.closeSync(fd);
|
|
115
|
-
}
|
|
116
|
-
if (header.toString('latin1') !== 'MZ') {
|
|
117
|
-
throw new MaactlError(messages.text('verifyFailed', file, 'not a Windows PE executable'));
|
|
118
|
-
}
|
|
119
|
-
if (fs.statSync(file).size < MIN_BINARY_BYTES) {
|
|
120
|
-
throw new MaactlError(messages.text('verifyFailed', file, 'file is truncated'));
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
const result = spawn(file, ['--version'], {
|
|
124
|
-
encoding: 'utf8',
|
|
125
|
-
timeout: 60_000,
|
|
126
|
-
windowsHide: true,
|
|
127
|
-
});
|
|
128
|
-
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
|
129
|
-
if (result.error) {
|
|
130
|
-
throw new MaactlError(messages.text('verifyFailed', file, result.error.message), { cause: result.error });
|
|
131
|
-
}
|
|
132
|
-
if (result.status !== 0 || !/maactl/i.test(output)) {
|
|
133
|
-
throw new MaactlError(
|
|
134
|
-
messages.text('verifyFailed', file, `unexpected output: ${output || '(empty)'}`),
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
return output;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Download maactl.exe into the cache unless a local copy already exists.
|
|
142
|
-
* `write(text)` receives human readable progress lines; pass `quiet` to
|
|
143
|
-
* suppress them.
|
|
144
|
-
*/
|
|
145
|
-
async function ensureBinary({ write = () => {}, quiet = false, verify = verifyBinary } = {}) {
|
|
146
|
-
const existing = resolveBinary();
|
|
147
|
-
if (existing) {
|
|
148
|
-
return existing;
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const dest = cacheExePath();
|
|
152
|
-
try {
|
|
153
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
154
|
-
} catch (error) {
|
|
155
|
-
throw new MaactlError(`${messages.text('downloadFailed', binaryUrl(), error.message)}\n cache: ${dest}`, {
|
|
156
|
-
cause: error,
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const url = binaryUrl();
|
|
161
|
-
const showProgress = !quiet && Boolean(process.stderr.isTTY);
|
|
162
|
-
if (!quiet) {
|
|
163
|
-
write(messages.text('downloading', url));
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const onProgress = showProgress ? progressReporter() : undefined;
|
|
167
|
-
try {
|
|
168
|
-
await downloadWithRetry(url, dest, {
|
|
169
|
-
onProgress,
|
|
170
|
-
onRetry: (attempt, attempts, error) => {
|
|
171
|
-
if (!quiet) {
|
|
172
|
-
write(`maactl: retrying download (${attempt}/${attempts - 1}) after: ${error.message}`);
|
|
173
|
-
}
|
|
174
|
-
},
|
|
175
|
-
});
|
|
176
|
-
if (onProgress) {
|
|
177
|
-
process.stderr.write('\n');
|
|
178
|
-
}
|
|
179
|
-
try {
|
|
180
|
-
verify(dest);
|
|
181
|
-
} catch (error) {
|
|
182
|
-
fs.rmSync(dest, { force: true });
|
|
183
|
-
throw error;
|
|
184
|
-
}
|
|
185
|
-
} catch (error) {
|
|
186
|
-
if (error instanceof MaactlError) {
|
|
187
|
-
throw error;
|
|
188
|
-
}
|
|
189
|
-
throw new MaactlError(messages.text('downloadFailed', url, error.message), { cause: error });
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
const size = formatBytes(fs.statSync(dest).size);
|
|
193
|
-
if (!quiet) {
|
|
194
|
-
write(messages.text('downloaded', size, dest));
|
|
195
|
-
}
|
|
196
|
-
return dest;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
/** Print the exe the wrapper will hand over to, when MAACTL_VERBOSE is set. */
|
|
200
|
-
function verbose() {
|
|
201
|
-
return env.flag('MAACTL_VERBOSE');
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
module.exports = {
|
|
205
|
-
PACKAGE,
|
|
206
|
-
PACKAGE_ROOT,
|
|
207
|
-
MIN_BINARY_BYTES,
|
|
208
|
-
EXE_NAME,
|
|
209
|
-
version,
|
|
210
|
-
tag,
|
|
211
|
-
repo,
|
|
212
|
-
assetName,
|
|
213
|
-
home,
|
|
214
|
-
cacheExePath,
|
|
215
|
-
bundledExePath,
|
|
216
|
-
binaryUrl,
|
|
217
|
-
resolveBinary,
|
|
218
|
-
ensureBinary,
|
|
219
|
-
verifyBinary,
|
|
220
|
-
verbose,
|
|
221
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Locating (and if necessary fetching) the maactl.exe the wrapper hands over to.
|
|
4
|
+
//
|
|
5
|
+
// Resolution order:
|
|
6
|
+
// 1. MAACTL_BINARY – an explicit path supplied by the user;
|
|
7
|
+
// 2. <package>/vendor/maactl.exe – the executable published inside the tarball;
|
|
8
|
+
// 3. <cache>/npm/<version>/maactl.exe – a previous download, reused across runs.
|
|
9
|
+
//
|
|
10
|
+
// When none of them exists the exe is downloaded from the GitHub release that
|
|
11
|
+
// matches the package version, verified, and cached for the next run.
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const os = require('node:os');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
const { spawnSync } = require('node:child_process');
|
|
17
|
+
|
|
18
|
+
const { downloadWithRetry, formatBytes, progressReporter } = require('./download');
|
|
19
|
+
const env = require('./env');
|
|
20
|
+
const { MaactlError } = require('./errors');
|
|
21
|
+
const messages = require('./messages');
|
|
22
|
+
|
|
23
|
+
const PACKAGE_ROOT = path.resolve(__dirname, '..');
|
|
24
|
+
const PACKAGE = require(path.join(PACKAGE_ROOT, 'package.json'));
|
|
25
|
+
|
|
26
|
+
// Every asset is a Windows PE binary; anything smaller than this floor is a
|
|
27
|
+
// truncated download, an HTML error page or a git-lfs pointer.
|
|
28
|
+
const MIN_BINARY_BYTES = 1 << 20;
|
|
29
|
+
|
|
30
|
+
const EXE_NAME = 'maactl.exe';
|
|
31
|
+
const DEFAULT_REPO = 'TanyaShue/MaaCtl';
|
|
32
|
+
const DEFAULT_ASSET = 'maactl.exe';
|
|
33
|
+
|
|
34
|
+
function version() {
|
|
35
|
+
return (env.value('MAACTL_VERSION') || PACKAGE.version).replace(/^v/, '');
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function tag() {
|
|
39
|
+
return `v${version()}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function repo() {
|
|
43
|
+
return env.value('MAACTL_REPO') || DEFAULT_REPO;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function assetName() {
|
|
47
|
+
return env.value('MAACTL_ASSET') || DEFAULT_ASSET;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Cache root mirroring the CLI's own layout: %LOCALAPPDATA%\maactl on Windows. */
|
|
51
|
+
function home() {
|
|
52
|
+
const explicit = env.value('MAACTL_HOME');
|
|
53
|
+
if (explicit) {
|
|
54
|
+
return path.resolve(explicit);
|
|
55
|
+
}
|
|
56
|
+
if (process.platform === 'win32') {
|
|
57
|
+
const localAppData = env.value('LOCALAPPDATA') || path.join(os.homedir(), 'AppData', 'Local');
|
|
58
|
+
return path.join(localAppData, 'maactl');
|
|
59
|
+
}
|
|
60
|
+
const cacheHome = env.value('XDG_CACHE_HOME') || path.join(os.homedir(), '.cache');
|
|
61
|
+
return path.join(cacheHome, 'maactl');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function cacheExePath(versionOverride) {
|
|
65
|
+
return path.join(home(), 'npm', versionOverride || version(), EXE_NAME);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function bundledExePath() {
|
|
69
|
+
return path.join(PACKAGE_ROOT, 'vendor', EXE_NAME);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function binaryUrl() {
|
|
73
|
+
const direct = env.value('MAACTL_BINARY_URL');
|
|
74
|
+
if (direct) {
|
|
75
|
+
return direct;
|
|
76
|
+
}
|
|
77
|
+
const github = `https://github.com/${repo()}/releases/download/${tag()}/${assetName()}`;
|
|
78
|
+
const mirror = env.value('MAACTL_MIRROR').replace(/\/+$/, '');
|
|
79
|
+
return mirror ? `${mirror}/${github}` : github;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isUsable(file) {
|
|
83
|
+
try {
|
|
84
|
+
return fs.statSync(file).size > 0;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Return the path of an existing maactl.exe without touching the network, or
|
|
92
|
+
* `null` when nothing is available locally.
|
|
93
|
+
*/
|
|
94
|
+
function resolveBinary() {
|
|
95
|
+
const explicit = env.value('MAACTL_BINARY');
|
|
96
|
+
if (explicit) {
|
|
97
|
+
const resolved = path.resolve(explicit);
|
|
98
|
+
if (!fs.existsSync(resolved)) {
|
|
99
|
+
throw new MaactlError(messages.text('explicitMissing', resolved));
|
|
100
|
+
}
|
|
101
|
+
return resolved;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return [bundledExePath(), cacheExePath()].find(isUsable) || null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Sanity-check a freshly downloaded executable by asking it for its version. */
|
|
108
|
+
function verifyBinary(file, { spawn = spawnSync } = {}) {
|
|
109
|
+
const header = Buffer.alloc(2);
|
|
110
|
+
const fd = fs.openSync(file, 'r');
|
|
111
|
+
try {
|
|
112
|
+
fs.readSync(fd, header, 0, 2, 0);
|
|
113
|
+
} finally {
|
|
114
|
+
fs.closeSync(fd);
|
|
115
|
+
}
|
|
116
|
+
if (header.toString('latin1') !== 'MZ') {
|
|
117
|
+
throw new MaactlError(messages.text('verifyFailed', file, 'not a Windows PE executable'));
|
|
118
|
+
}
|
|
119
|
+
if (fs.statSync(file).size < MIN_BINARY_BYTES) {
|
|
120
|
+
throw new MaactlError(messages.text('verifyFailed', file, 'file is truncated'));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const result = spawn(file, ['--version'], {
|
|
124
|
+
encoding: 'utf8',
|
|
125
|
+
timeout: 60_000,
|
|
126
|
+
windowsHide: true,
|
|
127
|
+
});
|
|
128
|
+
const output = `${result.stdout || ''}${result.stderr || ''}`.trim();
|
|
129
|
+
if (result.error) {
|
|
130
|
+
throw new MaactlError(messages.text('verifyFailed', file, result.error.message), { cause: result.error });
|
|
131
|
+
}
|
|
132
|
+
if (result.status !== 0 || !/maactl/i.test(output)) {
|
|
133
|
+
throw new MaactlError(
|
|
134
|
+
messages.text('verifyFailed', file, `unexpected output: ${output || '(empty)'}`),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
return output;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Download maactl.exe into the cache unless a local copy already exists.
|
|
142
|
+
* `write(text)` receives human readable progress lines; pass `quiet` to
|
|
143
|
+
* suppress them.
|
|
144
|
+
*/
|
|
145
|
+
async function ensureBinary({ write = () => {}, quiet = false, verify = verifyBinary } = {}) {
|
|
146
|
+
const existing = resolveBinary();
|
|
147
|
+
if (existing) {
|
|
148
|
+
return existing;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const dest = cacheExePath();
|
|
152
|
+
try {
|
|
153
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
154
|
+
} catch (error) {
|
|
155
|
+
throw new MaactlError(`${messages.text('downloadFailed', binaryUrl(), error.message)}\n cache: ${dest}`, {
|
|
156
|
+
cause: error,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const url = binaryUrl();
|
|
161
|
+
const showProgress = !quiet && Boolean(process.stderr.isTTY);
|
|
162
|
+
if (!quiet) {
|
|
163
|
+
write(messages.text('downloading', url));
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const onProgress = showProgress ? progressReporter() : undefined;
|
|
167
|
+
try {
|
|
168
|
+
await downloadWithRetry(url, dest, {
|
|
169
|
+
onProgress,
|
|
170
|
+
onRetry: (attempt, attempts, error) => {
|
|
171
|
+
if (!quiet) {
|
|
172
|
+
write(`maactl: retrying download (${attempt}/${attempts - 1}) after: ${error.message}`);
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
if (onProgress) {
|
|
177
|
+
process.stderr.write('\n');
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
verify(dest);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
fs.rmSync(dest, { force: true });
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error instanceof MaactlError) {
|
|
187
|
+
throw error;
|
|
188
|
+
}
|
|
189
|
+
throw new MaactlError(messages.text('downloadFailed', url, error.message), { cause: error });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const size = formatBytes(fs.statSync(dest).size);
|
|
193
|
+
if (!quiet) {
|
|
194
|
+
write(messages.text('downloaded', size, dest));
|
|
195
|
+
}
|
|
196
|
+
return dest;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Print the exe the wrapper will hand over to, when MAACTL_VERBOSE is set. */
|
|
200
|
+
function verbose() {
|
|
201
|
+
return env.flag('MAACTL_VERBOSE');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
module.exports = {
|
|
205
|
+
PACKAGE,
|
|
206
|
+
PACKAGE_ROOT,
|
|
207
|
+
MIN_BINARY_BYTES,
|
|
208
|
+
EXE_NAME,
|
|
209
|
+
version,
|
|
210
|
+
tag,
|
|
211
|
+
repo,
|
|
212
|
+
assetName,
|
|
213
|
+
home,
|
|
214
|
+
cacheExePath,
|
|
215
|
+
bundledExePath,
|
|
216
|
+
binaryUrl,
|
|
217
|
+
resolveBinary,
|
|
218
|
+
ensureBinary,
|
|
219
|
+
verifyBinary,
|
|
220
|
+
verbose,
|
|
221
|
+
};
|