qdmp-cli 0.1.27 → 0.1.28
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/actions.js +11 -1
- package/api.js +9 -8
- package/index.js +1 -0
- package/package.json +5 -4
- package/utils/agentLogin.js +106 -0
- package/utils/authFlow.js +1 -1
- package/utils/authorized.js +2 -2
package/actions.js
CHANGED
|
@@ -7,6 +7,7 @@ import chalk from "chalk";
|
|
|
7
7
|
import table from "table";
|
|
8
8
|
import { inquirerVersionDescription } from "./utils/interactive.js";
|
|
9
9
|
import { ensureInteractiveAuth, interactiveLogin } from "./utils/authFlow.js";
|
|
10
|
+
import { agentLogin } from "./utils/agentLogin.js";
|
|
10
11
|
import { getDeveloperId, getUserInfo } from "./api.js";
|
|
11
12
|
import fs from "fs";
|
|
12
13
|
import path from "path";
|
|
@@ -79,12 +80,21 @@ export const listAction = () => {
|
|
|
79
80
|
|
|
80
81
|
export const loginAction = async (option) => {
|
|
81
82
|
try {
|
|
83
|
+
if (option.agent) {
|
|
84
|
+
await agentLogin(option.env);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
82
87
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
83
|
-
throw new Error(
|
|
88
|
+
throw new Error(`Agent 对话请执行 qdmp login --agent --env ${option.env} 获取登录链接和图片;CI 请设置 QDMP_TOKEN`);
|
|
84
89
|
}
|
|
85
90
|
await interactiveLogin(option.env);
|
|
86
91
|
success("登录成功");
|
|
87
92
|
} catch (e) {
|
|
93
|
+
if (option.agent) {
|
|
94
|
+
console.log(JSON.stringify({ type: "error", env: option.env, message: e.message }));
|
|
95
|
+
process.exitCode = 1;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
88
98
|
error(e.message);
|
|
89
99
|
} finally {
|
|
90
100
|
// Inquirer may leave stdin in a flowing state after the localhost callback
|
package/api.js
CHANGED
|
@@ -211,10 +211,10 @@ const unwrapData = (result) => result?.data || result;
|
|
|
211
211
|
const qrcodeLoginURL = (qrcodeId) =>
|
|
212
212
|
`https://qiandao.com/login/scanning/confirm?qrcodeId=${encodeURIComponent(qrcodeId)}`;
|
|
213
213
|
|
|
214
|
-
export const generateLoginQrcode = async (env = "prod") => {
|
|
214
|
+
export const generateLoginQrcode = async (env = "prod", { signal } = {}) => {
|
|
215
215
|
const result = await request(
|
|
216
216
|
"/signin/login/scan/qrcode/generate-id",
|
|
217
|
-
{ method: "POST", body: JSON.stringify({}) },
|
|
217
|
+
{ method: "POST", body: JSON.stringify({}), signal },
|
|
218
218
|
env,
|
|
219
219
|
false,
|
|
220
220
|
);
|
|
@@ -223,10 +223,10 @@ export const generateLoginQrcode = async (env = "prod") => {
|
|
|
223
223
|
return { qrcodeId: String(data.qrcodeId), url: qrcodeLoginURL(data.qrcodeId) };
|
|
224
224
|
};
|
|
225
225
|
|
|
226
|
-
export const getLoginQrcodeStatus = async (qrcodeId, env = "prod") => {
|
|
226
|
+
export const getLoginQrcodeStatus = async (qrcodeId, env = "prod", { signal } = {}) => {
|
|
227
227
|
const result = await request(
|
|
228
228
|
"/signin/login/scan/qrcode/status",
|
|
229
|
-
{ method: "POST", body: JSON.stringify({ qrcodeId }) },
|
|
229
|
+
{ method: "POST", body: JSON.stringify({ qrcodeId }), signal },
|
|
230
230
|
env,
|
|
231
231
|
false,
|
|
232
232
|
);
|
|
@@ -259,17 +259,17 @@ export const loginByPlatformURL = async (env = "prod", onUpdate = () => {}) => {
|
|
|
259
259
|
}
|
|
260
260
|
};
|
|
261
261
|
|
|
262
|
-
export const getQiandaoUser = async (token, env = "prod") => {
|
|
262
|
+
export const getQiandaoUser = async (token, env = "prod", { signal } = {}) => {
|
|
263
263
|
const result = await request(
|
|
264
264
|
"/user/users/me",
|
|
265
|
-
{ headers: { authorization: `Bearer ${token}` } },
|
|
265
|
+
{ headers: { authorization: `Bearer ${token}` }, signal },
|
|
266
266
|
env,
|
|
267
267
|
false,
|
|
268
268
|
);
|
|
269
269
|
return unwrapData(result);
|
|
270
270
|
};
|
|
271
271
|
|
|
272
|
-
export const exchangeQiandaoLogin = async (token, user, env = "prod") => {
|
|
272
|
+
export const exchangeQiandaoLogin = async (token, user, env = "prod", { signal, persist = true } = {}) => {
|
|
273
273
|
const userInfo = unwrapData(user) || {};
|
|
274
274
|
const thirdPartyUserId = userInfo.id ?? userInfo.userId ?? userInfo.openId;
|
|
275
275
|
if (thirdPartyUserId == null || String(thirdPartyUserId).trim() === "") {
|
|
@@ -279,6 +279,7 @@ export const exchangeQiandaoLogin = async (token, user, env = "prod") => {
|
|
|
279
279
|
"/mp/v1/user/login/mobile",
|
|
280
280
|
{
|
|
281
281
|
method: "POST",
|
|
282
|
+
signal,
|
|
282
283
|
body: JSON.stringify({
|
|
283
284
|
thirdPartyUserId: String(thirdPartyUserId),
|
|
284
285
|
nickname: userInfo.nickName || userInfo.nickname || userInfo.name,
|
|
@@ -290,7 +291,7 @@ export const exchangeQiandaoLogin = async (token, user, env = "prod") => {
|
|
|
290
291
|
);
|
|
291
292
|
const platformToken = unwrapData(result)?.token;
|
|
292
293
|
if (!platformToken) throw new Error("扫码登录未获取到开放平台 Token");
|
|
293
|
-
saveToken(platformToken, env);
|
|
294
|
+
if (persist) saveToken(platformToken, env);
|
|
294
295
|
return platformToken;
|
|
295
296
|
};
|
|
296
297
|
|
package/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qdmp-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.28",
|
|
4
4
|
"description": "qdmp-cli",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -37,6 +37,7 @@
|
|
|
37
37
|
"is-unicode-supported": "^2.1.0",
|
|
38
38
|
"js-base64": "3.7.8",
|
|
39
39
|
"ora": "^8.2.0",
|
|
40
|
+
"qrcode": "^1.5.4",
|
|
40
41
|
"qrcode-terminal": "^0.12.0",
|
|
41
42
|
"query-string": "7.1.3",
|
|
42
43
|
"shelljs": "^0.10.0",
|
|
@@ -47,7 +48,7 @@
|
|
|
47
48
|
"node": "^20.19.0 || >=22.12.0"
|
|
48
49
|
},
|
|
49
50
|
"publishConfig": {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
"registry": "https://registry.npmjs.org/",
|
|
52
|
+
"access": "public"
|
|
53
|
+
}
|
|
53
54
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { mkdtemp, chmod, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import QRCode from "qrcode";
|
|
6
|
+
import { generateLoginQrcode, getLoginQrcodeStatus, getQiandaoUser, exchangeQiandaoLogin, request } from "../api.js";
|
|
7
|
+
import { saveToken } from "./authorized.js";
|
|
8
|
+
import { normalizeLoginEnv } from "./loginEnvironment.js";
|
|
9
|
+
import { startLocalhostLogin } from "./localhostLogin.js";
|
|
10
|
+
|
|
11
|
+
// NDJSON keeps stdout machine-readable. The caller renders ready.markdown while
|
|
12
|
+
// this process keeps the loopback callback and App status polling alive.
|
|
13
|
+
export const agentLogin = async (env = "prod", {
|
|
14
|
+
emit = event => console.log(JSON.stringify(event)),
|
|
15
|
+
timeoutMs = 120000,
|
|
16
|
+
pollIntervalMs = 1500,
|
|
17
|
+
} = {}) => {
|
|
18
|
+
normalizeLoginEnv(env);
|
|
19
|
+
const controller = new AbortController();
|
|
20
|
+
const { signal } = controller;
|
|
21
|
+
const expiresAt = new Date(Date.now() + timeoutMs).toISOString();
|
|
22
|
+
const timer = setTimeout(() => controller.abort(new Error("登录超时,请重新执行登录命令")), timeoutMs);
|
|
23
|
+
const interrupt = () => controller.abort(new Error("已取消登录"));
|
|
24
|
+
process.once("SIGINT", interrupt);
|
|
25
|
+
process.once("SIGTERM", interrupt);
|
|
26
|
+
let browserSession;
|
|
27
|
+
let imageDirectory;
|
|
28
|
+
let committed = false;
|
|
29
|
+
const closeBrowser = () => browserSession?.close();
|
|
30
|
+
signal.addEventListener("abort", closeBrowser, { once: true });
|
|
31
|
+
const commit = token => {
|
|
32
|
+
signal.throwIfAborted();
|
|
33
|
+
if (committed) throw new Error("登录已完成");
|
|
34
|
+
saveToken(token, env, { quiet: true });
|
|
35
|
+
committed = true;
|
|
36
|
+
};
|
|
37
|
+
const requestSignal = () => AbortSignal.any([signal, AbortSignal.timeout(10000)]);
|
|
38
|
+
try {
|
|
39
|
+
browserSession = await startLocalhostLogin({
|
|
40
|
+
env, timeoutMs,
|
|
41
|
+
acceptToken: async (token, isActive) => {
|
|
42
|
+
const result = await request("/mp/v1/user/me", {
|
|
43
|
+
headers: { authorization: `Bearer ${token}` }, signal: requestSignal(),
|
|
44
|
+
}, env, false);
|
|
45
|
+
if (Number(result?.code || 0) !== 0) throw new Error("验证网页登录态失败");
|
|
46
|
+
if (!isActive()) throw new Error("网页登录会话已结束");
|
|
47
|
+
commit(token);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
signal.throwIfAborted();
|
|
51
|
+
const qr = await generateLoginQrcode(env, { signal: requestSignal() });
|
|
52
|
+
imageDirectory = await mkdtemp(join(tmpdir(), "qdmp-login-"));
|
|
53
|
+
const qrImagePath = join(imageDirectory, "qrcode.png");
|
|
54
|
+
await QRCode.toFile(qrImagePath, qr.url, { type: "png", width: 320, margin: 4 });
|
|
55
|
+
await chmod(qrImagePath, 0o600);
|
|
56
|
+
signal.throwIfAborted();
|
|
57
|
+
const loginUrl = browserSession.url;
|
|
58
|
+
await emit({
|
|
59
|
+
type: "ready", env, loginUrl, qrImagePath, expiresAt,
|
|
60
|
+
message: "点击链接前往浏览器登录,或用千岛 App 扫码登录。",
|
|
61
|
+
markdown: `[点击链接前往浏览器登录](${loginUrl}),或用千岛 App 扫码登录。\n\n`,
|
|
62
|
+
});
|
|
63
|
+
const poll = async () => {
|
|
64
|
+
let previousStatus;
|
|
65
|
+
while (!signal.aborted && !committed) {
|
|
66
|
+
const result = await getLoginQrcodeStatus(qr.qrcodeId, env, { signal: requestSignal() });
|
|
67
|
+
signal.throwIfAborted();
|
|
68
|
+
if (result.status === "success") {
|
|
69
|
+
if (!result.token) throw new Error("扫码登录未返回登录态");
|
|
70
|
+
const user = await getQiandaoUser(result.token, env, { signal: requestSignal() });
|
|
71
|
+
const token = await exchangeQiandaoLogin(result.token, user, env, { signal: requestSignal(), persist: false });
|
|
72
|
+
commit(token);
|
|
73
|
+
return "qrcode";
|
|
74
|
+
}
|
|
75
|
+
if (["expired", "canceled", "cancelled", "error"].includes(result.status)) {
|
|
76
|
+
throw new Error("二维码已过期或登录已取消,可继续使用浏览器链接");
|
|
77
|
+
}
|
|
78
|
+
if (previousStatus !== result.status) {
|
|
79
|
+
previousStatus = result.status;
|
|
80
|
+
await emit({ type: "status", env, method: "qrcode", status: result.status });
|
|
81
|
+
}
|
|
82
|
+
await delay(pollIntervalMs, undefined, { signal });
|
|
83
|
+
}
|
|
84
|
+
throw new Error("扫码登录会话已结束");
|
|
85
|
+
};
|
|
86
|
+
const lane = (promise, method) => promise.catch(async () => {
|
|
87
|
+
if (!signal.aborted && !committed) await emit({ type: "unavailable", env, method });
|
|
88
|
+
throw new Error(`${method} 登录未完成`);
|
|
89
|
+
});
|
|
90
|
+
const method = await Promise.any([
|
|
91
|
+
lane(browserSession.result.then(() => "browser"), "browser"),
|
|
92
|
+
lane(poll(), "qrcode"),
|
|
93
|
+
]).catch(() => {
|
|
94
|
+
throw signal.reason || new Error("登录未完成,请重新执行登录命令");
|
|
95
|
+
});
|
|
96
|
+
await emit({ type: "success", env, method });
|
|
97
|
+
} finally {
|
|
98
|
+
controller.abort();
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
closeBrowser();
|
|
101
|
+
process.removeListener("SIGINT", interrupt);
|
|
102
|
+
process.removeListener("SIGTERM", interrupt);
|
|
103
|
+
signal.removeEventListener("abort", closeBrowser);
|
|
104
|
+
if (imageDirectory) await rm(imageDirectory, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
};
|
package/utils/authFlow.js
CHANGED
|
@@ -87,7 +87,7 @@ export const ensureInteractiveAuth = async (env = "prod") => {
|
|
|
87
87
|
} catch (cause) {
|
|
88
88
|
if (!isAuthRequiredError(cause)) throw cause;
|
|
89
89
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
90
|
-
throw new Error(
|
|
90
|
+
throw new Error(`当前命令需要登录。Agent 对话请执行 qdmp login --agent --env ${normalizeLoginEnv(env)} 获取登录链接和图片,登录后重试原命令;CI 请设置 QDMP_TOKEN`);
|
|
91
91
|
}
|
|
92
92
|
await interactiveLogin(env);
|
|
93
93
|
return ensureAuth(env);
|
package/utils/authorized.js
CHANGED
|
@@ -20,7 +20,7 @@ const readConfig = () => {
|
|
|
20
20
|
}
|
|
21
21
|
};
|
|
22
22
|
|
|
23
|
-
export const saveToken = (token, env = "prod") => {
|
|
23
|
+
export const saveToken = (token, env = "prod", { quiet = false } = {}) => {
|
|
24
24
|
normalizeLoginEnv(env);
|
|
25
25
|
const configPath = getConfigPath();
|
|
26
26
|
const config = readConfig();
|
|
@@ -32,7 +32,7 @@ export const saveToken = (token, env = "prod") => {
|
|
|
32
32
|
try {
|
|
33
33
|
fs.writeFileSync(configPath, JSON.stringify(nextConfig, null, 2), { mode: 0o600 });
|
|
34
34
|
fs.chmodSync(configPath, 0o600);
|
|
35
|
-
info(`🔑 ${env} 环境认证令牌已安全存储`);
|
|
35
|
+
if (!quiet) info(`🔑 ${env} 环境认证令牌已安全存储`);
|
|
36
36
|
} catch (err) {
|
|
37
37
|
throw new Error(`保存 ${env} 环境认证令牌失败:${err.message}`);
|
|
38
38
|
}
|