@qcplay/cli 1.0.6 → 1.0.8

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 +3 -0
  2. package/bin/qcplay.js +179 -30
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -7,6 +7,9 @@
7
7
  - 认证默认请求线上接口:`https://cli.qcg.ink`
8
8
  - 发布默认请求线上接口:`https://cli.qcg.ink`
9
9
  - 登录默认打开线上页面:`https://cli.qcg.ink/auth.html`
10
+ - `qcplay-cli install` 初始化完成后会自动打开登录页并等待登录完成
11
+ - 登录凭证保存在当前用户本机的 `~/.qcplay/auth.json`,不同客户端之间不共享账号
12
+ - 状态、权限和发布请求通过 Bearer Token 识别当前账号
10
13
  - `qcplay-cli update` 会更新 npm 全局包,并同步 `~/.agents/skills`
11
14
  - 本地调试时可直接使用 `--local`
12
15
  - `--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
  }
@@ -829,9 +969,8 @@ async function installCommand(options) {
829
969
  console.log(chalk.gray(`发布后端: ${publishBackendUrl}`));
830
970
  console.log(chalk.gray(`登录页地址: ${authPageUrl || "本地内置页面"}`));
831
971
  console.log("");
832
- console.log("安装完成后可直接执行:");
833
- console.log(" qcplay-cli auth");
834
- console.log("");
972
+ console.log(chalk.cyan("正在启动登录..."));
973
+ await authCommand([]);
835
974
  }
836
975
 
837
976
  function printSkillsSyncResult(result) {
@@ -894,8 +1033,8 @@ async function authCommand(rawArgs = []) {
894
1033
  const authPageUrl = resolveAuthPageUrl(options, config);
895
1034
 
896
1035
  if (!action || action === "login") {
897
- const loginPageUrl = buildAuthPageUrl(backendUrl, authPageUrl);
898
- let previousSequence = 0;
1036
+ const loginRequest = await createLoginRequest(backendUrl);
1037
+ const loginPageUrl = buildAuthPageUrl(backendUrl, authPageUrl, loginRequest.requestId);
899
1038
 
900
1039
  console.log(chalk.cyan("正在登录中"));
901
1040
  try {
@@ -905,40 +1044,50 @@ async function authCommand(rawArgs = []) {
905
1044
  console.log(chalk.gray(loginPageUrl));
906
1045
  }
907
1046
 
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)}`));
1047
+ const result = await waitForLoginResult(backendUrl, loginRequest);
1048
+ await saveAuthState(result, backendUrl);
1049
+ const statusResponse = await authenticatedRequest("GET", backendUrl, "/api/auth/status");
1050
+ if (!statusResponse.data?.logged_in) {
1051
+ await removeAuthState();
1052
+ throw new Error("登录结果校验失败,请重新登录");
917
1053
  }
1054
+ console.log(chalk.green("登录成功"));
1055
+ console.log(chalk.gray(`当前账号: ${formatAccountSummary(statusResponse.data)}`));
918
1056
  return;
919
1057
  }
920
1058
 
921
1059
  if (action === "status") {
922
- const response = await requestJson("GET", backendUrl, "/api/auth/status");
1060
+ let response;
1061
+ try {
1062
+ response = await authenticatedRequest("GET", backendUrl, "/api/auth/status");
1063
+ } catch (error) {
1064
+ if (error.code === "AUTH_REQUIRED" || error.statusCode === 401 || !(await pathExists(AUTH_FILE))) {
1065
+ await removeAuthState();
1066
+ console.log(chalk.yellow("未登录"));
1067
+ return;
1068
+ }
1069
+ throw error;
1070
+ }
923
1071
  if (response.data?.logged_in) {
924
1072
  console.log(chalk.green("已登录"));
925
1073
  console.log(chalk.gray(`当前账号: ${formatAccountSummary(response.data)}`));
926
1074
  return;
927
1075
  }
928
1076
 
1077
+ await removeAuthState();
929
1078
  console.log(chalk.yellow("未登录"));
930
1079
  return;
931
1080
  }
932
1081
 
933
1082
  if (action === "logout") {
934
- const response = await requestJson("POST", backendUrl, "/api/auth/logout", {});
935
- console.log(chalk.green(response.message || "已退出登录"));
1083
+ await removeAuthState();
1084
+ console.log(chalk.green("已退出登录"));
936
1085
  return;
937
1086
  }
938
1087
 
939
1088
  if (action === "permissions") {
940
1089
  const flags = parsePermissionsOptions(args.slice(1));
941
- const response = await requestJson("GET", backendUrl, "/api/permissions");
1090
+ const response = await authenticatedRequest("GET", backendUrl, "/api/permissions");
942
1091
  let permissions = response.data || [];
943
1092
 
944
1093
  if (flags.key) {
@@ -1331,7 +1480,7 @@ async function publishArticleCommand(file, options) {
1331
1480
  console.log(chalk.gray(`文章文件: ${articleFile}`));
1332
1481
  console.log("");
1333
1482
 
1334
- const response = await requestJson("POST", backendUrl, "/api/articles/publish", payload);
1483
+ const response = await authenticatedRequest("POST", backendUrl, "/api/articles/publish", payload);
1335
1484
  console.log(chalk.green(response.message || "发布成功"));
1336
1485
  const articleId = response.data?.id ?? response.data?.article_id ?? response.data?.articleId;
1337
1486
  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.8",
4
4
  "description": "QCPlay CLI",
5
5
  "type": "module",
6
6
  "bin": {