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 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("qdmp login 需要在支持交互输入的终端中执行,或使用 QDMP_TOKEN");
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
@@ -53,6 +53,7 @@ program
53
53
  .command("login")
54
54
  .description("登录")
55
55
  .option("-e, --env <env>", "登录环境:prod 或 dev", "prod")
56
+ .option("--agent", "输出登录链接和 PNG 图片的 JSON 事件,并等待登录(无需终端输入)")
56
57
  .action(loginAction);
57
58
  program
58
59
  .command("getMe")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qdmp-cli",
3
- "version": "0.1.27",
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
- "registry": "https://registry.npmjs.org/",
51
- "access": "public"
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![千岛 App 扫码登录](<${qrImagePath}>)`,
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("当前命令需要登录,请在支持交互输入的终端中重新执行,或设置 QDMP_TOKEN");
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);
@@ -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
  }