maactl 0.1.0 → 0.1.2

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/lib/cli.js CHANGED
@@ -1,136 +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 };
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 CHANGED
@@ -1,188 +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 };
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 };