@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.
- package/README.md +2 -0
- package/bin/qcplay.js +177 -27
- 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
|
-
|
|
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
|
|
665
|
-
const response = await requestJson("
|
|
666
|
-
|
|
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
|
|
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
|
|
690
|
-
|
|
691
|
-
|
|
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
|
|
898
|
-
|
|
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
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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
|
-
|
|
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
|
-
|
|
935
|
-
console.log(chalk.green(
|
|
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
|
|
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
|
|
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) {
|