@qcplay/cli 1.0.6 → 1.0.7

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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/bin/qcplay.js +177 -27
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -7,6 +7,8 @@
7
7
  - 认证默认请求线上接口:`https://cli.qcg.ink`
8
8
  - 发布默认请求线上接口:`https://cli.qcg.ink`
9
9
  - 登录默认打开线上页面:`https://cli.qcg.ink/auth.html`
10
+ - 登录凭证保存在当前用户本机的 `~/.qcplay/auth.json`,不同客户端之间不共享账号
11
+ - 状态、权限和发布请求通过 Bearer Token 识别当前账号
10
12
  - `qcplay-cli update` 会更新 npm 全局包,并同步 `~/.agents/skills`
11
13
  - 本地调试时可直接使用 `--local`
12
14
  - `--local` 默认指向 `http://127.0.0.1:8787`
package/bin/qcplay.js CHANGED
@@ -91,6 +91,7 @@ const BOOLEAN_TEXT_MAP = {
91
91
  const QCPLAY_DIR = path.join(os.homedir(), ".qcplay");
92
92
  const AGENTS_DIR = path.join(os.homedir(), ".agents");
93
93
  const CONFIG_FILE = path.join(QCPLAY_DIR, "config.json");
94
+ const AUTH_FILE = path.join(QCPLAY_DIR, "auth.json");
94
95
  const SKILLS_DIR = path.join(AGENTS_DIR, "skills");
95
96
 
96
97
  function readPackageMetadata() {
@@ -234,6 +235,7 @@ async function writeJson(filePath, value) {
234
235
  encoding: "utf8",
235
236
  mode: 0o600
236
237
  });
238
+ await fs.promises.chmod(filePath, 0o600);
237
239
  }
238
240
 
239
241
  async function readJson(filePath) {
@@ -491,7 +493,7 @@ function parsePublishOptions(args) {
491
493
  };
492
494
  }
493
495
 
494
- function requestJson(method, baseUrl, pathname, payload) {
496
+ function requestJson(method, baseUrl, pathname, payload, headers = {}) {
495
497
  return new Promise((resolve, reject) => {
496
498
  const url = new URL(pathname, baseUrl);
497
499
  const client = url.protocol === "https:" ? https : http;
@@ -505,6 +507,7 @@ function requestJson(method, baseUrl, pathname, payload) {
505
507
  Accept: "application/json",
506
508
  "Content-Type": "application/json",
507
509
  "User-Agent": `qcplay-cli/${PACKAGE_VERSION}`,
510
+ ...headers,
508
511
  ...(body ? { "Content-Length": Buffer.byteLength(body) } : {})
509
512
  }
510
513
  },
@@ -523,7 +526,9 @@ function requestJson(method, baseUrl, pathname, payload) {
523
526
  }
524
527
 
525
528
  if ((res.statusCode || 0) < 200 || (res.statusCode || 0) >= 300) {
526
- reject(new Error(json.message || `接口请求失败 (${res.statusCode})`));
529
+ const error = new Error(json.message || `接口请求失败 (${res.statusCode})`);
530
+ error.statusCode = res.statusCode || 0;
531
+ reject(error);
527
532
  return;
528
533
  }
529
534
 
@@ -661,9 +666,18 @@ function resolvePublishBackendUrl(options, config = {}) {
661
666
  return ensureAbsoluteUrl(backendUrl, "--backend");
662
667
  }
663
668
 
664
- async function fetchLoginSuccessSequence(baseUrl) {
665
- const response = await requestJson("GET", baseUrl, "/api/auth/login-success");
666
- return Number(response.data?.sequence || 0);
669
+ async function createLoginRequest(baseUrl) {
670
+ const response = await requestJson("POST", baseUrl, "/api/auth/login-request", {});
671
+ const requestId = normalizeText(response.data?.request_id);
672
+ const pollToken = normalizeText(response.data?.poll_token);
673
+ if (!requestId || !pollToken) {
674
+ throw new Error("认证后端没有返回有效的登录请求凭证");
675
+ }
676
+ return {
677
+ requestId,
678
+ pollToken,
679
+ expiresIn: Number(response.data?.expires_in || 600)
680
+ };
667
681
  }
668
682
 
669
683
  function isNonJsonResponseError(error) {
@@ -673,22 +687,30 @@ function isNonJsonResponseError(error) {
673
687
  function buildAuthBackendError(baseUrl, error) {
674
688
  if (isNonJsonResponseError(error)) {
675
689
  return new Error(
676
- `认证接口 ${baseUrl} 返回了 HTML 页面,不是 JSON。请确认 ${baseUrl}/api/auth/login、${baseUrl}/api/auth/login-success、${baseUrl}/api/auth/status 已正确部署,而不是被站点首页或登录页接管。`
690
+ `认证接口 ${baseUrl} 返回了 HTML 页面,不是 JSON。请确认 ${baseUrl}/api/auth/login-request、${baseUrl}/api/auth/login、${baseUrl}/api/auth/login-success、${baseUrl}/api/auth/status 已正确部署,而不是被站点首页或登录页接管。`
677
691
  );
678
692
  }
679
693
 
680
694
  return error;
681
695
  }
682
696
 
683
- async function waitForLoginSuccessSequence(baseUrl, previousSequence, intervalMs = 1000, timeoutMs = 60000) {
697
+ async function waitForLoginResult(baseUrl, loginRequest, intervalMs = 1000) {
684
698
  const startedAt = Date.now();
699
+ const timeoutMs = Math.max(1, loginRequest.expiresIn) * 1000;
685
700
  let lastError;
701
+ const query = new URLSearchParams({ request_id: loginRequest.requestId });
686
702
 
687
703
  while (true) {
688
704
  try {
689
- const nextSequence = await fetchLoginSuccessSequence(baseUrl);
690
- if (nextSequence > previousSequence) {
691
- return nextSequence;
705
+ const response = await requestJson("GET", baseUrl, `/api/auth/login-success?${query}`, undefined, {
706
+ "X-QCPlay-Poll-Token": loginRequest.pollToken
707
+ });
708
+ if (response.data?.complete) {
709
+ const result = response.data?.result;
710
+ if (!result?.access_token || !result?.refresh_token) {
711
+ throw new Error("认证后端返回的登录结果不完整");
712
+ }
713
+ return result;
692
714
  }
693
715
  } catch (error) {
694
716
  lastError = error;
@@ -706,13 +728,15 @@ async function waitForLoginSuccessSequence(baseUrl, previousSequence, intervalMs
706
728
  }
707
729
  }
708
730
 
709
- function buildAuthPageUrl(backendUrl, authPageUrl = "") {
731
+ function buildAuthPageUrl(backendUrl, authPageUrl = "", requestId = "") {
710
732
  const targetUrl = authPageUrl ? new URL(authPageUrl) : pathToFileURL(AUTH_PAGE);
711
733
 
712
734
  if (shouldAttachBackendQuery(targetUrl, backendUrl)) {
713
735
  targetUrl.searchParams.set("backend", backendUrl);
714
736
  }
715
737
 
738
+ targetUrl.searchParams.set("request_id", requestId);
739
+
716
740
  return targetUrl.toString();
717
741
  }
718
742
 
@@ -729,6 +753,117 @@ function shouldAttachBackendQuery(targetUrl, backendUrl) {
729
753
  return normalizedBackendUrl.origin !== targetUrl.origin;
730
754
  }
731
755
 
756
+ function normalizeTimestamp(value) {
757
+ const timestamp = Number(value || 0);
758
+ return timestamp > 0 && timestamp < 1_000_000_000_000 ? timestamp * 1000 : timestamp;
759
+ }
760
+
761
+ function tokenIsValid(token, expiresAt) {
762
+ return Boolean(normalizeText(token)) && Date.now() < normalizeTimestamp(expiresAt);
763
+ }
764
+
765
+ function authRequiredError(message) {
766
+ const error = new Error(message);
767
+ error.code = "AUTH_REQUIRED";
768
+ return error;
769
+ }
770
+
771
+ async function loadAuthState() {
772
+ const state = await readJson(AUTH_FILE);
773
+ return normalizeText(state.access_token) ? state : {};
774
+ }
775
+
776
+ async function saveAuthState(result, backendUrl) {
777
+ if (!normalizeText(result?.access_token) || !normalizeText(result?.refresh_token)) {
778
+ throw new Error("认证后端返回的登录结果不完整");
779
+ }
780
+
781
+ await writeJson(AUTH_FILE, {
782
+ backend_url: backendUrl,
783
+ api_base: result.api_base || "",
784
+ access_token: result.access_token,
785
+ refresh_token: result.refresh_token,
786
+ token_type: result.token_type || "Bearer",
787
+ expires_at: Number(result.expires_at || 0),
788
+ refresh_expires_at: Number(result.refresh_expires_at || 0),
789
+ created_at: Number(result.created_at || Date.now()),
790
+ id: Number(result.id || 0),
791
+ account: result.account || "",
792
+ name: result.name || "",
793
+ status: result.status || "",
794
+ type: result.type || ""
795
+ });
796
+ }
797
+
798
+ async function removeAuthState() {
799
+ try {
800
+ await fs.promises.unlink(AUTH_FILE);
801
+ } catch (error) {
802
+ if (error.code !== "ENOENT") {
803
+ throw error;
804
+ }
805
+ }
806
+ }
807
+
808
+ async function refreshAuthState(state, fallbackBackendUrl) {
809
+ if (!tokenIsValid(state.refresh_token, state.refresh_expires_at)) {
810
+ throw authRequiredError("登录状态已过期,请重新登录");
811
+ }
812
+
813
+ const authBackendUrl = normalizeOptionalUrl(state.backend_url) || fallbackBackendUrl;
814
+ const response = await requestJson("POST", authBackendUrl, "/api/auth/login", {
815
+ refresh_token: state.refresh_token
816
+ });
817
+ const result = response.data?.result;
818
+ await saveAuthState(result, authBackendUrl);
819
+ return loadAuthState();
820
+ }
821
+
822
+ async function requireAuthState(backendUrl) {
823
+ let state = await loadAuthState();
824
+ if (!normalizeText(state.access_token)) {
825
+ throw authRequiredError("未检测到登录状态,请先执行 qcplay-cli auth");
826
+ }
827
+
828
+ if (!tokenIsValid(state.access_token, state.expires_at)) {
829
+ try {
830
+ state = await refreshAuthState(state, backendUrl);
831
+ } catch (error) {
832
+ if (error.code === "AUTH_REQUIRED" || error.statusCode === 401) {
833
+ await removeAuthState();
834
+ }
835
+ throw error;
836
+ }
837
+ }
838
+ return state;
839
+ }
840
+
841
+ async function authenticatedRequest(method, baseUrl, pathname, payload) {
842
+ let state = await requireAuthState(baseUrl);
843
+ try {
844
+ return await requestJson(method, baseUrl, pathname, payload, {
845
+ Authorization: `Bearer ${state.access_token}`
846
+ });
847
+ } catch (error) {
848
+ if (error.statusCode !== 401) {
849
+ throw error;
850
+ }
851
+ }
852
+
853
+ try {
854
+ state = await refreshAuthState(state, baseUrl);
855
+ } catch (error) {
856
+ if (error.code === "AUTH_REQUIRED" || error.statusCode === 401) {
857
+ await removeAuthState();
858
+ }
859
+ throw error;
860
+ }
861
+
862
+ return requestJson(method, baseUrl, pathname, payload, {
863
+ Authorization: `Bearer ${state.access_token}`
864
+ });
865
+ }
866
+
732
867
  function flattenPermissionTree(items, bucket = []) {
733
868
  for (const item of items) {
734
869
  bucket.push(item);
@@ -782,10 +917,15 @@ function printPermissionTree(items) {
782
917
  function formatAccountSummary(data = {}) {
783
918
  const id = Number(data.id || 0);
784
919
  const name = normalizeText(data.name) || "未知账号";
920
+ const account = normalizeText(data.account);
785
921
  const type = normalizeText(data.type);
786
922
  const status = normalizeText(data.status);
787
923
  const parts = [name];
788
924
 
925
+ if (account && account !== name) {
926
+ parts.push(`account=${account}`);
927
+ }
928
+
789
929
  if (id > 0) {
790
930
  parts.push(`id=${id}`);
791
931
  }
@@ -894,8 +1034,8 @@ async function authCommand(rawArgs = []) {
894
1034
  const authPageUrl = resolveAuthPageUrl(options, config);
895
1035
 
896
1036
  if (!action || action === "login") {
897
- const loginPageUrl = buildAuthPageUrl(backendUrl, authPageUrl);
898
- let previousSequence = 0;
1037
+ const loginRequest = await createLoginRequest(backendUrl);
1038
+ const loginPageUrl = buildAuthPageUrl(backendUrl, authPageUrl, loginRequest.requestId);
899
1039
 
900
1040
  console.log(chalk.cyan("正在登录中"));
901
1041
  try {
@@ -905,40 +1045,50 @@ async function authCommand(rawArgs = []) {
905
1045
  console.log(chalk.gray(loginPageUrl));
906
1046
  }
907
1047
 
908
- try {
909
- previousSequence = await fetchLoginSuccessSequence(backendUrl);
910
- } catch {}
911
-
912
- await waitForLoginSuccessSequence(backendUrl, previousSequence);
913
- const statusResponse = await requestJson("GET", backendUrl, "/api/auth/status");
914
- console.log(chalk.green("登录成功"));
915
- if (statusResponse.data?.logged_in) {
916
- console.log(chalk.gray(`当前账号: ${formatAccountSummary(statusResponse.data)}`));
1048
+ const result = await waitForLoginResult(backendUrl, loginRequest);
1049
+ await saveAuthState(result, backendUrl);
1050
+ const statusResponse = await authenticatedRequest("GET", backendUrl, "/api/auth/status");
1051
+ if (!statusResponse.data?.logged_in) {
1052
+ await removeAuthState();
1053
+ throw new Error("登录结果校验失败,请重新登录");
917
1054
  }
1055
+ console.log(chalk.green("登录成功"));
1056
+ console.log(chalk.gray(`当前账号: ${formatAccountSummary(statusResponse.data)}`));
918
1057
  return;
919
1058
  }
920
1059
 
921
1060
  if (action === "status") {
922
- const response = await requestJson("GET", backendUrl, "/api/auth/status");
1061
+ let response;
1062
+ try {
1063
+ response = await authenticatedRequest("GET", backendUrl, "/api/auth/status");
1064
+ } catch (error) {
1065
+ if (error.code === "AUTH_REQUIRED" || error.statusCode === 401 || !(await pathExists(AUTH_FILE))) {
1066
+ await removeAuthState();
1067
+ console.log(chalk.yellow("未登录"));
1068
+ return;
1069
+ }
1070
+ throw error;
1071
+ }
923
1072
  if (response.data?.logged_in) {
924
1073
  console.log(chalk.green("已登录"));
925
1074
  console.log(chalk.gray(`当前账号: ${formatAccountSummary(response.data)}`));
926
1075
  return;
927
1076
  }
928
1077
 
1078
+ await removeAuthState();
929
1079
  console.log(chalk.yellow("未登录"));
930
1080
  return;
931
1081
  }
932
1082
 
933
1083
  if (action === "logout") {
934
- const response = await requestJson("POST", backendUrl, "/api/auth/logout", {});
935
- console.log(chalk.green(response.message || "已退出登录"));
1084
+ await removeAuthState();
1085
+ console.log(chalk.green("已退出登录"));
936
1086
  return;
937
1087
  }
938
1088
 
939
1089
  if (action === "permissions") {
940
1090
  const flags = parsePermissionsOptions(args.slice(1));
941
- const response = await requestJson("GET", backendUrl, "/api/permissions");
1091
+ const response = await authenticatedRequest("GET", backendUrl, "/api/permissions");
942
1092
  let permissions = response.data || [];
943
1093
 
944
1094
  if (flags.key) {
@@ -1331,7 +1481,7 @@ async function publishArticleCommand(file, options) {
1331
1481
  console.log(chalk.gray(`文章文件: ${articleFile}`));
1332
1482
  console.log("");
1333
1483
 
1334
- const response = await requestJson("POST", backendUrl, "/api/articles/publish", payload);
1484
+ const response = await authenticatedRequest("POST", backendUrl, "/api/articles/publish", payload);
1335
1485
  console.log(chalk.green(response.message || "发布成功"));
1336
1486
  const articleId = response.data?.id ?? response.data?.article_id ?? response.data?.articleId;
1337
1487
  if (articleId !== undefined) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qcplay/cli",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "QCPlay CLI",
5
5
  "type": "module",
6
6
  "bin": {