maactl 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.
- package/README.md +62 -0
- package/bin/maactl.js +19 -0
- package/lib/binary.js +221 -0
- package/lib/cli.js +136 -0
- package/lib/download.js +188 -0
- package/lib/env.js +17 -0
- package/lib/errors.js +17 -0
- package/lib/messages.js +80 -0
- package/package.json +52 -0
- package/scripts/install.js +48 -0
- package/scripts/vendor-binary.js +67 -0
- package/vendor/maactl.exe +0 -0
package/README.md
ADDED
|
@@ -0,0 +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)。
|
package/bin/maactl.js
ADDED
|
@@ -0,0 +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
ADDED
|
@@ -0,0 +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
|
+
};
|
package/lib/cli.js
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Thin launcher: hand every argument over to maactl.exe untouched, inherit the
|
|
4
|
+
// stdio of the calling shell, and relay the exit status back to npm/npx.
|
|
5
|
+
|
|
6
|
+
const { spawn } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
const { resolveBinary, ensureBinary, verbose } = require('./binary');
|
|
9
|
+
const env = require('./env');
|
|
10
|
+
const { MaactlError } = require('./errors');
|
|
11
|
+
const messages = require('./messages');
|
|
12
|
+
|
|
13
|
+
// Signals worth relaying on POSIX hosts. On Windows the console broadcasts
|
|
14
|
+
// Ctrl+C to the whole process group, so the Go process receives it directly.
|
|
15
|
+
const POSIX_SIGNALS = ['SIGINT', 'SIGTERM', 'SIGQUIT', 'SIGHUP'];
|
|
16
|
+
|
|
17
|
+
function describeSpawnError(file, error) {
|
|
18
|
+
const reason = error && error.message ? error.message : String(error);
|
|
19
|
+
return new MaactlError(messages.text('spawnFailed', file, reason), { cause: error });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Run `exe` with `argv`, inheriting stdio.
|
|
24
|
+
*
|
|
25
|
+
* Resolves with the numeric exit code of the child. When the child is killed
|
|
26
|
+
* by a signal on POSIX the shim re-raises that signal so the caller sees the
|
|
27
|
+
* same failure it would have seen without the wrapper.
|
|
28
|
+
*/
|
|
29
|
+
function runBinary(exe, argv, options = {}) {
|
|
30
|
+
const spawnImpl = options.spawn || spawn;
|
|
31
|
+
const proc = options.process || process;
|
|
32
|
+
const platform = options.platform || process.platform;
|
|
33
|
+
const child = spawnImpl(exe, argv, {
|
|
34
|
+
stdio: options.stdio || 'inherit',
|
|
35
|
+
cwd: options.cwd || process.cwd(),
|
|
36
|
+
env: options.env || process.env,
|
|
37
|
+
windowsHide: false,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return new Promise((resolve, reject) => {
|
|
41
|
+
const listeners = [];
|
|
42
|
+
const cleanup = () => {
|
|
43
|
+
for (const [signal, handler] of listeners) {
|
|
44
|
+
proc.removeListener(signal, handler);
|
|
45
|
+
}
|
|
46
|
+
listeners.length = 0;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
child.once('error', (error) => {
|
|
50
|
+
cleanup();
|
|
51
|
+
reject(describeSpawnError(exe, error));
|
|
52
|
+
});
|
|
53
|
+
child.once('exit', (code, signal) => {
|
|
54
|
+
cleanup();
|
|
55
|
+
if (signal) {
|
|
56
|
+
try {
|
|
57
|
+
proc.kill(proc.pid, signal);
|
|
58
|
+
} catch {
|
|
59
|
+
// Re-raising is a courtesy; fall through to a generic failure code.
|
|
60
|
+
}
|
|
61
|
+
resolve(1);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
resolve(code === null || code === undefined ? 1 : code);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const forward = (signal) => {
|
|
68
|
+
if (child.exitCode !== null || child.signalCode !== null) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
child.kill(signal);
|
|
73
|
+
} catch {
|
|
74
|
+
// The child already exited; its exit handler reports the status.
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
if (platform === 'win32') {
|
|
79
|
+
// Windows has no POSIX signals: the console broadcasts Ctrl+C/Ctrl+Break
|
|
80
|
+
// to the whole process group, so maactl.exe already received it. Swallowing
|
|
81
|
+
// the first signal keeps the shim alive long enough to report the child's
|
|
82
|
+
// exit code; a second Ctrl+C falls back to the default behaviour and kills
|
|
83
|
+
// the shim, which is the escape hatch when the child will not stop.
|
|
84
|
+
const swallowOnce = () => cleanup();
|
|
85
|
+
for (const signal of ['SIGINT', 'SIGBREAK']) {
|
|
86
|
+
try {
|
|
87
|
+
proc.on(signal, swallowOnce);
|
|
88
|
+
listeners.push([signal, swallowOnce]);
|
|
89
|
+
} catch {
|
|
90
|
+
// Signal not supported on this platform.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} else {
|
|
94
|
+
for (const signal of POSIX_SIGNALS) {
|
|
95
|
+
const handler = () => forward(signal);
|
|
96
|
+
try {
|
|
97
|
+
proc.on(signal, handler);
|
|
98
|
+
listeners.push([signal, handler]);
|
|
99
|
+
} catch {
|
|
100
|
+
// Signal not supported on this platform.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Resolve (downloading once if needed) and run maactl.exe with `argv`.
|
|
109
|
+
*
|
|
110
|
+
* `argv` is forwarded verbatim, so `npx maactl run task "自动挂机卖蛋" -f D:\proj`
|
|
111
|
+
* reaches the executable exactly as typed.
|
|
112
|
+
*/
|
|
113
|
+
async function main(argv = process.argv.slice(2), options = {}) {
|
|
114
|
+
const write = options.write || ((line) => process.stderr.write(`${line}\n`));
|
|
115
|
+
const resolve = options.resolveBinary || resolveBinary;
|
|
116
|
+
const ensure = options.ensureBinary || ensureBinary;
|
|
117
|
+
const quiet = options.quiet ?? env.flag('MAACTL_QUIET');
|
|
118
|
+
|
|
119
|
+
let exe = options.binary || (await resolve());
|
|
120
|
+
if (!exe) {
|
|
121
|
+
try {
|
|
122
|
+
exe = await ensure({ write, quiet });
|
|
123
|
+
} catch (error) {
|
|
124
|
+
const detail = error instanceof MaactlError ? error.message : String(error && error.message);
|
|
125
|
+
throw new MaactlError(`${detail}\n${messages.text('resolveHint')}`, { cause: error });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (verbose() && !options.binary) {
|
|
130
|
+
write(messages.text('usingBinary', exe));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return runBinary(exe, argv, options);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { main, runBinary };
|
package/lib/download.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Minimal HTTPS downloader for the maactl.exe release asset.
|
|
4
|
+
//
|
|
5
|
+
// Node built-ins only: the npm package ships no runtime dependencies. GitHub
|
|
6
|
+
// release downloads answer with a 302 to objects.githubusercontent.com, so
|
|
7
|
+
// redirects are followed manually and the Authorization header (when a
|
|
8
|
+
// GH_TOKEN is provided to dodge rate limits) is dropped on cross-host hops.
|
|
9
|
+
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const http = require('node:http');
|
|
12
|
+
const https = require('node:https');
|
|
13
|
+
|
|
14
|
+
const MAX_REDIRECTS = 5;
|
|
15
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
16
|
+
// A slow-but-alive transfer is fine; a silent socket is not. If no bytes arrive
|
|
17
|
+
// for this long the download is aborted with a readable error.
|
|
18
|
+
const STALL_TIMEOUT_MS = 120_000;
|
|
19
|
+
|
|
20
|
+
const REDIRECT_CODES = new Set([301, 302, 303, 307, 308]);
|
|
21
|
+
|
|
22
|
+
function userAgent() {
|
|
23
|
+
const pkg = require('../package.json');
|
|
24
|
+
return `maactl-npm/${pkg.version} (node/${process.versions.node}; ${process.platform})`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function githubToken() {
|
|
28
|
+
return (process.env.GH_TOKEN || process.env.GITHUB_TOKEN || '').trim();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function baseHeaders(url) {
|
|
32
|
+
const headers = {
|
|
33
|
+
'user-agent': userAgent(),
|
|
34
|
+
accept: 'application/octet-stream',
|
|
35
|
+
};
|
|
36
|
+
const token = githubToken();
|
|
37
|
+
if (token && /(^|\.)github\.com$/i.test(new URL(url).hostname)) {
|
|
38
|
+
headers.authorization = `Bearer ${token}`;
|
|
39
|
+
}
|
|
40
|
+
return headers;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function requestOnce(url, headers) {
|
|
44
|
+
return new Promise((resolve, reject) => {
|
|
45
|
+
const target = new URL(url);
|
|
46
|
+
const transport = target.protocol === 'http:' ? http : https;
|
|
47
|
+
const request = transport.get(target, { headers, timeout: REQUEST_TIMEOUT_MS }, (response) => {
|
|
48
|
+
response.setTimeout(STALL_TIMEOUT_MS);
|
|
49
|
+
response.on('timeout', () => {
|
|
50
|
+
response.destroy(new Error(`no data received for ${STALL_TIMEOUT_MS} ms`));
|
|
51
|
+
});
|
|
52
|
+
resolve(response);
|
|
53
|
+
});
|
|
54
|
+
request.on('timeout', () => request.destroy(new Error(`request timed out after ${REQUEST_TIMEOUT_MS} ms`)));
|
|
55
|
+
request.on('error', reject);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function writeBody(response, dest, onProgress) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const total = Number(response.headers['content-length'] || 0);
|
|
62
|
+
let received = 0;
|
|
63
|
+
let finished = false;
|
|
64
|
+
const settle = (fn, value) => {
|
|
65
|
+
if (!finished) {
|
|
66
|
+
finished = true;
|
|
67
|
+
fn(value);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const output = fs.createWriteStream(dest);
|
|
71
|
+
|
|
72
|
+
response.on('data', (chunk) => {
|
|
73
|
+
received += chunk.length;
|
|
74
|
+
if (onProgress) {
|
|
75
|
+
onProgress(received, total);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
response.on('error', (error) => settle(reject, error));
|
|
79
|
+
output.on('error', (error) => settle(reject, error));
|
|
80
|
+
output.on('finish', () => {
|
|
81
|
+
if (total > 0 && received !== total) {
|
|
82
|
+
settle(reject, new Error(`truncated download: received ${received} of ${total} bytes`));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
settle(resolve, received);
|
|
86
|
+
});
|
|
87
|
+
response.pipe(output);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Download `url` into `dest` (written to `dest.part` first, renamed on
|
|
93
|
+
* success). `onProgress(received, total)` is called for every chunk.
|
|
94
|
+
*/
|
|
95
|
+
async function download(url, dest, { onProgress } = {}) {
|
|
96
|
+
const partial = `${dest}.part`;
|
|
97
|
+
const headers = baseHeaders(url);
|
|
98
|
+
let current = url;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) {
|
|
102
|
+
const response = await requestOnce(current, headers);
|
|
103
|
+
const status = response.statusCode || 0;
|
|
104
|
+
|
|
105
|
+
if (REDIRECT_CODES.has(status)) {
|
|
106
|
+
const location = response.headers.location;
|
|
107
|
+
response.resume();
|
|
108
|
+
if (!location) {
|
|
109
|
+
throw new Error(`HTTP ${status} without a Location header`);
|
|
110
|
+
}
|
|
111
|
+
const next = new URL(location, current);
|
|
112
|
+
if (next.hostname !== new URL(current).hostname) {
|
|
113
|
+
delete headers.authorization;
|
|
114
|
+
}
|
|
115
|
+
current = next.href;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (status !== 200) {
|
|
120
|
+
response.resume();
|
|
121
|
+
throw new Error(`HTTP ${status} ${response.statusMessage || ''}`.trim());
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
await writeBody(response, partial, onProgress);
|
|
125
|
+
fs.renameSync(partial, dest);
|
|
126
|
+
return dest;
|
|
127
|
+
}
|
|
128
|
+
throw new Error(`too many redirects (limit ${MAX_REDIRECTS})`);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
try {
|
|
131
|
+
fs.rmSync(partial, { force: true });
|
|
132
|
+
} catch {
|
|
133
|
+
// Best effort: a stale .part file only costs disk space.
|
|
134
|
+
}
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Try `download` up to `attempts` times, backing off between attempts. */
|
|
140
|
+
async function downloadWithRetry(url, dest, options = {}) {
|
|
141
|
+
const attempts = options.attempts || 3;
|
|
142
|
+
const delay = options.retryDelayMs ?? 1_000;
|
|
143
|
+
let lastError;
|
|
144
|
+
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
145
|
+
try {
|
|
146
|
+
return await download(url, dest, options);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
lastError = error;
|
|
149
|
+
if (attempt < attempts && options.onRetry) {
|
|
150
|
+
options.onRetry(attempt, attempts, error);
|
|
151
|
+
}
|
|
152
|
+
if (attempt < attempts) {
|
|
153
|
+
await new Promise((resolve) => setTimeout(resolve, delay * attempt));
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
throw lastError;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Human readable byte count, e.g. 34.2 MiB. */
|
|
161
|
+
function formatBytes(bytes) {
|
|
162
|
+
if (!Number.isFinite(bytes) || bytes <= 0) {
|
|
163
|
+
return '0 B';
|
|
164
|
+
}
|
|
165
|
+
const units = ['B', 'KiB', 'MiB', 'GiB'];
|
|
166
|
+
let value = bytes;
|
|
167
|
+
let unit = 0;
|
|
168
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
169
|
+
value /= 1024;
|
|
170
|
+
unit += 1;
|
|
171
|
+
}
|
|
172
|
+
return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Render a single-line progress indicator to stderr. */
|
|
176
|
+
function progressReporter(stream = process.stderr) {
|
|
177
|
+
let lastLength = 0;
|
|
178
|
+
return (received, total) => {
|
|
179
|
+
const line = total > 0
|
|
180
|
+
? ` ${formatBytes(received)} / ${formatBytes(total)} (${Math.floor((received / total) * 100)}%)`
|
|
181
|
+
: ` ${formatBytes(received)}`;
|
|
182
|
+
const padding = ' '.repeat(Math.max(0, lastLength - line.length));
|
|
183
|
+
lastLength = line.length;
|
|
184
|
+
stream.write(`\r${line}${padding}`);
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
module.exports = { download, downloadWithRetry, formatBytes, progressReporter, userAgent };
|
package/lib/env.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Small helpers so every module reads the environment the same way.
|
|
4
|
+
|
|
5
|
+
const TRUTHY = new Set(['1', 'true', 'yes', 'on']);
|
|
6
|
+
|
|
7
|
+
/** Trimmed value of an environment variable ('' when unset). */
|
|
8
|
+
function value(name) {
|
|
9
|
+
return (process.env[name] || '').trim();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** True when the variable is set to 1/true/yes/on (regardless of case). */
|
|
13
|
+
function flag(name) {
|
|
14
|
+
return TRUTHY.has(value(name).toLowerCase());
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { value, flag };
|
package/lib/errors.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error type used for every failure raised by the npm wrapper itself.
|
|
5
|
+
*
|
|
6
|
+
* The wrapper never relays these to maactl.exe: they describe problems with
|
|
7
|
+
* locating, downloading or starting the executable.
|
|
8
|
+
*/
|
|
9
|
+
class MaactlError extends Error {
|
|
10
|
+
constructor(message, { exitCode = 1, cause } = {}) {
|
|
11
|
+
super(message, cause ? { cause } : undefined);
|
|
12
|
+
this.name = 'MaactlError';
|
|
13
|
+
this.exitCode = exitCode;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
module.exports = { MaactlError };
|
package/lib/messages.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The wrapper only prints text for error and progress paths, so the message
|
|
4
|
+
// catalogue stays small. Language follows MAACTL_LANG, the same variable the
|
|
5
|
+
// CLI itself honours; anything not starting with "zh" falls back to English.
|
|
6
|
+
|
|
7
|
+
const MESSAGES = {
|
|
8
|
+
zh: {
|
|
9
|
+
downloading: (url) => `maactl: 首次使用,正在下载 maactl.exe\n ${url}`,
|
|
10
|
+
downloaded: (size, dest) => `maactl: 已下载 ${size} 到 ${dest}`,
|
|
11
|
+
downloadFailed: (url, reason) =>
|
|
12
|
+
`maactl: 下载 maactl.exe 失败\n ${url}\n 原因: ${reason}`,
|
|
13
|
+
verifyFailed: (file, reason) => `maactl: 下载的 maactl.exe 校验失败 (${file})\n 原因: ${reason}`,
|
|
14
|
+
explicitMissing: (file) => `maactl: MAACTL_BINARY 指向的文件不存在: ${file}`,
|
|
15
|
+
spawnFailed: (file, reason) => `maactl: 无法启动 maactl.exe (${file})\n 原因: ${reason}`,
|
|
16
|
+
resolveHint: () =>
|
|
17
|
+
[
|
|
18
|
+
'maactl: 找不到 maactl.exe。可用的解决办法:',
|
|
19
|
+
' 1. 设置 MAACTL_BINARY 指向本地已有的 maactl.exe;',
|
|
20
|
+
' 2. 检查网络后重试,或设置 MAACTL_MIRROR 使用镜像下载;',
|
|
21
|
+
' 3. 手动下载 release 里的 maactl.exe,放到 %LOCALAPPDATA%\\maactl\\npm\\<version>\\ 下。',
|
|
22
|
+
].join('\n'),
|
|
23
|
+
skippingDownload: () => 'maactl: 已跳过 maactl.exe 下载 (MAACTL_SKIP_DOWNLOAD)',
|
|
24
|
+
bundledMissing: (file) => `maactl: 未找到内置的 ${file},改为下载 maactl.exe`,
|
|
25
|
+
preDownloadFailed: (reason) => `maactl: 预下载 maactl.exe 失败 (${reason})`,
|
|
26
|
+
preDownloadHint: () =>
|
|
27
|
+
'maactl: 改为首次运行时下载;设置 MAACTL_STRICT_INSTALL=1 可让安装在此处直接失败。',
|
|
28
|
+
usingBinary: (file) => `maactl: 使用 ${file}`,
|
|
29
|
+
},
|
|
30
|
+
en: {
|
|
31
|
+
downloading: (url) => `maactl: downloading maactl.exe for first use\n ${url}`,
|
|
32
|
+
downloaded: (size, dest) => `maactl: downloaded ${size} to ${dest}`,
|
|
33
|
+
downloadFailed: (url, reason) => `maactl: could not download maactl.exe\n ${url}\n reason: ${reason}`,
|
|
34
|
+
verifyFailed: (file, reason) => `maactl: the downloaded maactl.exe failed verification (${file})\n reason: ${reason}`,
|
|
35
|
+
explicitMissing: (file) => `maactl: MAACTL_BINARY points at a missing file: ${file}`,
|
|
36
|
+
spawnFailed: (file, reason) => `maactl: could not start maactl.exe (${file})\n reason: ${reason}`,
|
|
37
|
+
resolveHint: () =>
|
|
38
|
+
[
|
|
39
|
+
'maactl: maactl.exe could not be located. Things to try:',
|
|
40
|
+
' 1. set MAACTL_BINARY to an existing maactl.exe;',
|
|
41
|
+
' 2. check your network and retry, or set MAACTL_MIRROR to use a mirror;',
|
|
42
|
+
' 3. download maactl.exe from the release page and place it in %LOCALAPPDATA%\\maactl\\npm\\<version>\\.',
|
|
43
|
+
].join('\n'),
|
|
44
|
+
skippingDownload: () => 'maactl: skipped downloading maactl.exe (MAACTL_SKIP_DOWNLOAD)',
|
|
45
|
+
bundledMissing: (file) => `maactl: no bundled ${file}; fetching maactl.exe instead`,
|
|
46
|
+
preDownloadFailed: (reason) => `maactl: could not pre-download maactl.exe (${reason})`,
|
|
47
|
+
preDownloadHint: () =>
|
|
48
|
+
'maactl: it will be fetched on first run instead; set MAACTL_STRICT_INSTALL=1 to fail the install here.',
|
|
49
|
+
usingBinary: (file) => `maactl: using ${file}`,
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function language() {
|
|
54
|
+
const explicit = (process.env.MAACTL_LANG || '').trim().toLowerCase();
|
|
55
|
+
if (explicit) {
|
|
56
|
+
return explicit.startsWith('zh') ? 'zh' : 'en';
|
|
57
|
+
}
|
|
58
|
+
if (process.platform !== 'win32') {
|
|
59
|
+
const posix = `${process.env.LC_ALL || ''}${process.env.LC_MESSAGES || ''}${process.env.LANG || ''}`.toLowerCase();
|
|
60
|
+
if (posix) {
|
|
61
|
+
return posix.includes('zh') ? 'zh' : 'en';
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
let locale = '';
|
|
65
|
+
try {
|
|
66
|
+
locale = Intl.DateTimeFormat().resolvedOptions().locale || '';
|
|
67
|
+
} catch {
|
|
68
|
+
locale = '';
|
|
69
|
+
}
|
|
70
|
+
return locale.toLowerCase().startsWith('zh') ? 'zh' : 'en';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Return the localized message for `key`, formatted with `args`. */
|
|
74
|
+
function text(key, ...args) {
|
|
75
|
+
const catalogue = MESSAGES[language()] || MESSAGES.en;
|
|
76
|
+
const entry = catalogue[key] || MESSAGES.en[key];
|
|
77
|
+
return typeof entry === 'function' ? entry(...args) : String(entry ?? '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
module.exports = { text, language };
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "maactl",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MaaFramework / ProjectInterface v2 command-line client, runnable with npx on Windows",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"maactl",
|
|
7
|
+
"maafw",
|
|
8
|
+
"maaframework",
|
|
9
|
+
"projectinterface",
|
|
10
|
+
"automation",
|
|
11
|
+
"adb",
|
|
12
|
+
"cli"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"author": "TanyaShue",
|
|
16
|
+
"homepage": "https://github.com/TanyaShue/MaaCtl#readme",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/TanyaShue/MaaCtl.git",
|
|
20
|
+
"directory": "npm"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/TanyaShue/MaaCtl/issues"
|
|
24
|
+
},
|
|
25
|
+
"bin": {
|
|
26
|
+
"maactl": "bin/maactl.js"
|
|
27
|
+
},
|
|
28
|
+
"files": [
|
|
29
|
+
"bin/",
|
|
30
|
+
"lib/",
|
|
31
|
+
"scripts/",
|
|
32
|
+
"vendor/",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"postinstall": "node scripts/install.js",
|
|
37
|
+
"vendor:binary": "node scripts/vendor-binary.js",
|
|
38
|
+
"check:vendor": "node scripts/vendor-binary.js --check",
|
|
39
|
+
"test": "node --test",
|
|
40
|
+
"prepublishOnly": "npm test && npm run check:vendor"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18"
|
|
44
|
+
},
|
|
45
|
+
"os": [
|
|
46
|
+
"win32"
|
|
47
|
+
],
|
|
48
|
+
"publishConfig": {
|
|
49
|
+
"access": "public",
|
|
50
|
+
"registry": "https://registry.npmjs.org/"
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// postinstall: pre-fetch maactl.exe into the user cache.
|
|
5
|
+
//
|
|
6
|
+
// The published tarball already carries vendor/maactl.exe, so this only runs
|
|
7
|
+
// for installs without the bundled executable (git checkouts, `npm pack` from
|
|
8
|
+
// source, custom MAACTL_ASSET builds). A download failure must never break
|
|
9
|
+
// `npm install`: the shim retries lazily on first use. Set
|
|
10
|
+
// MAACTL_STRICT_INSTALL=1 to turn the warning into an error.
|
|
11
|
+
|
|
12
|
+
const { ensureBinary, resolveBinary, bundledExePath } = require('../lib/binary');
|
|
13
|
+
const env = require('../lib/env');
|
|
14
|
+
const messages = require('../lib/messages');
|
|
15
|
+
|
|
16
|
+
function write(line) {
|
|
17
|
+
process.stderr.write(`${line}\n`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function install() {
|
|
21
|
+
if (env.flag('MAACTL_SKIP_DOWNLOAD')) {
|
|
22
|
+
write(messages.text('skippingDownload'));
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// resolveBinary() may throw when MAACTL_BINARY is set to a missing path, and
|
|
27
|
+
// that is a configuration error the user should see right away.
|
|
28
|
+
if (resolveBinary()) {
|
|
29
|
+
return; // bundled or cached executable is already in place
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
write(messages.text('bundledMissing', bundledExePath()));
|
|
34
|
+
await ensureBinary({ write, quiet: false });
|
|
35
|
+
} catch (error) {
|
|
36
|
+
const strict = env.flag('MAACTL_STRICT_INSTALL');
|
|
37
|
+
if (strict) {
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
write(messages.text('preDownloadFailed', error.message));
|
|
41
|
+
write(messages.text('preDownloadHint'));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
install().catch((error) => {
|
|
46
|
+
write(`maactl: install step failed: ${error.message}`);
|
|
47
|
+
process.exitCode = 1;
|
|
48
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Copy a locally built maactl.exe into vendor/ so `npm pack` / `npm publish`
|
|
5
|
+
// produce a tarball that needs no download at install time.
|
|
6
|
+
//
|
|
7
|
+
// The release workflow calls this with the exe it just built; developers can
|
|
8
|
+
// call it with the exe from `go build` to test the packaging flow offline.
|
|
9
|
+
//
|
|
10
|
+
// node scripts/vendor-binary.js [path-to-exe] # default: ../maactl.exe
|
|
11
|
+
// node scripts/vendor-binary.js --list # show the current state
|
|
12
|
+
|
|
13
|
+
const fs = require('node:fs');
|
|
14
|
+
const path = require('node:path');
|
|
15
|
+
|
|
16
|
+
const { bundledExePath, verifyBinary, EXE_NAME } = require('../lib/binary');
|
|
17
|
+
const { formatBytes } = require('../lib/download');
|
|
18
|
+
|
|
19
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
20
|
+
|
|
21
|
+
function usage() {
|
|
22
|
+
process.stderr.write(
|
|
23
|
+
[
|
|
24
|
+
`usage: node scripts/vendor-binary.js [<maactl.exe>] [--check]`,
|
|
25
|
+
'',
|
|
26
|
+
`Copies <maactl.exe> (default ${path.join(ROOT, '..', EXE_NAME)}) to ${bundledExePath()}.`,
|
|
27
|
+
'Use --check to verify the vendored executable without copying anything.',
|
|
28
|
+
'',
|
|
29
|
+
].join('\n'),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function main(argv) {
|
|
34
|
+
const args = argv.filter((arg) => !arg.startsWith('--'));
|
|
35
|
+
const check = argv.includes('--check');
|
|
36
|
+
const source = path.resolve(args[0] || path.join(ROOT, '..', EXE_NAME));
|
|
37
|
+
const dest = bundledExePath();
|
|
38
|
+
|
|
39
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
40
|
+
usage();
|
|
41
|
+
return 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (!check) {
|
|
45
|
+
if (!fs.existsSync(source)) {
|
|
46
|
+
throw new Error(`source executable not found: ${source}\nbuild it first: go build -tags bundled -o maactl.exe ./cmd/maactl`);
|
|
47
|
+
}
|
|
48
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
49
|
+
fs.copyFileSync(source, dest);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!fs.existsSync(dest)) {
|
|
53
|
+
throw new Error(`no vendored executable at ${dest}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const version = verifyBinary(dest);
|
|
57
|
+
const size = formatBytes(fs.statSync(dest).size);
|
|
58
|
+
process.stdout.write(`vendored ${dest}\n ${size}\n ${version}\n`);
|
|
59
|
+
return 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
process.exitCode = main(process.argv.slice(2));
|
|
64
|
+
} catch (error) {
|
|
65
|
+
process.stderr.write(`vendor-binary: ${error.message}\n`);
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
}
|
|
Binary file
|