dsh-cloudq 0.2.0 → 0.3.0
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/lib/client.js +123 -0
- package/lib/index.js +401 -173
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -408,6 +408,46 @@ linear-gradient(-30deg, transparent 49.5%, #2c2c31 49.5%, #2c2c31 50.5%, transpa
|
|
|
408
408
|
overflow: hidden;
|
|
409
409
|
text-overflow: ellipsis;
|
|
410
410
|
}
|
|
411
|
+
/* Outdated-plugin badge appended after the sidebar button label. */
|
|
412
|
+
.dsh-cloudq-version-badge {
|
|
413
|
+
position: relative;
|
|
414
|
+
flex: none;
|
|
415
|
+
display: inline-flex;
|
|
416
|
+
align-items: center;
|
|
417
|
+
justify-content: center;
|
|
418
|
+
width: 16px;
|
|
419
|
+
height: 16px;
|
|
420
|
+
margin-left: 6px;
|
|
421
|
+
border-radius: 50%;
|
|
422
|
+
background: var(--dsw-alias-state-warning, #d18400);
|
|
423
|
+
color: #fff;
|
|
424
|
+
font-size: 11px;
|
|
425
|
+
font-weight: 700;
|
|
426
|
+
line-height: 16px;
|
|
427
|
+
cursor: pointer;
|
|
428
|
+
}
|
|
429
|
+
.dsh-cloudq-version-badge.is-busy {
|
|
430
|
+
opacity: .7;
|
|
431
|
+
cursor: default;
|
|
432
|
+
}
|
|
433
|
+
.dsh-cloudq-version-badge__tip {
|
|
434
|
+
display: none;
|
|
435
|
+
position: fixed;
|
|
436
|
+
z-index: 1000;
|
|
437
|
+
padding: 6px 10px;
|
|
438
|
+
border-radius: 6px;
|
|
439
|
+
background: rgba(20, 24, 31, .92);
|
|
440
|
+
color: #fff;
|
|
441
|
+
font-size: 12px;
|
|
442
|
+
font-weight: 400;
|
|
443
|
+
line-height: 18px;
|
|
444
|
+
white-space: nowrap;
|
|
445
|
+
box-shadow: 0 4px 12px rgba(0, 0, 0, .18);
|
|
446
|
+
}
|
|
447
|
+
.dsh-cloudq-version-badge:hover .dsh-cloudq-version-badge__tip,
|
|
448
|
+
.dsh-cloudq-version-badge:focus .dsh-cloudq-version-badge__tip {
|
|
449
|
+
display: block;
|
|
450
|
+
}
|
|
411
451
|
/* Persisted CloudQ marker shown on rows whose session id is in the registry. */
|
|
412
452
|
.dsh-cloudq-session-badge {
|
|
413
453
|
flex: none;
|
|
@@ -773,6 +813,75 @@ linear-gradient(-30deg, transparent 49.5%, #2c2c31 49.5%, #2c2c31 50.5%, transpa
|
|
|
773
813
|
if (button) button.remove();
|
|
774
814
|
};
|
|
775
815
|
}
|
|
816
|
+
const API_VERSION = "/api/dsh-cloudq/version";
|
|
817
|
+
const API_UPDATE = "/api/dsh-cloudq/update";
|
|
818
|
+
const API_RESTART = "/api/dsh-cloudq/restart";
|
|
819
|
+
let cloudqOutdatedInfo = null;
|
|
820
|
+
let cloudqSelfUpdateRunning = false;
|
|
821
|
+
function positionVersionTip(badge, tip) {
|
|
822
|
+
const rect = badge.getBoundingClientRect();
|
|
823
|
+
tip.style.left = `${Math.max(8, rect.left)}px`;
|
|
824
|
+
tip.style.top = `${rect.bottom + 6}px`;
|
|
825
|
+
tip.style.bottom = "auto";
|
|
826
|
+
}
|
|
827
|
+
/** Append the "!" badge after the sidebar button label (retries while the
|
|
828
|
+
* button is not mounted yet). The button node is reused across sidebar
|
|
829
|
+
* re-mounts, so a child badge survives them. */
|
|
830
|
+
function attachVersionBadge(attempt = 0) {
|
|
831
|
+
if (!cloudqOutdatedInfo) return;
|
|
832
|
+
const button = document.getElementById("dsh-cloudq-sidebar-entry");
|
|
833
|
+
if (!button) {
|
|
834
|
+
if (attempt < 20) window.setTimeout(() => attachVersionBadge(attempt + 1), 500);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
if (button.querySelector(".dsh-cloudq-version-badge")) return;
|
|
838
|
+
const badge = document.createElement("span");
|
|
839
|
+
badge.className = "dsh-cloudq-version-badge";
|
|
840
|
+
badge.dataset.testid = "cloudq-version-badge";
|
|
841
|
+
badge.textContent = "!";
|
|
842
|
+
badge.setAttribute("role", "button");
|
|
843
|
+
badge.setAttribute("aria-label", `CloudQ 插件有新版本 ${cloudqOutdatedInfo.latest},点击更新`);
|
|
844
|
+
const tip = document.createElement("span");
|
|
845
|
+
tip.className = "dsh-cloudq-version-badge__tip";
|
|
846
|
+
tip.textContent = `当前版本 ${cloudqOutdatedInfo.current} 不是最新版本 ${cloudqOutdatedInfo.latest},点击自动更新`;
|
|
847
|
+
badge.appendChild(tip);
|
|
848
|
+
badge.addEventListener("mouseenter", () => positionVersionTip(badge, tip));
|
|
849
|
+
badge.addEventListener("click", (event) => {
|
|
850
|
+
event.preventDefault();
|
|
851
|
+
event.stopPropagation();
|
|
852
|
+
runCloudqSelfUpdate(badge, tip);
|
|
853
|
+
});
|
|
854
|
+
button.appendChild(badge);
|
|
855
|
+
}
|
|
856
|
+
/** Badge click flow: update → restart host → wait for it → reload page. */
|
|
857
|
+
async function runCloudqSelfUpdate(badge, tip) {
|
|
858
|
+
if (cloudqSelfUpdateRunning) return;
|
|
859
|
+
cloudqSelfUpdateRunning = true;
|
|
860
|
+
badge.classList.add("is-busy");
|
|
861
|
+
tip.textContent = "正在更新到最新版本…";
|
|
862
|
+
try {
|
|
863
|
+
await cloudqRequest(API_UPDATE, { method: "POST" }, 15e4);
|
|
864
|
+
} catch (error) {
|
|
865
|
+
tip.textContent = `更新失败:${error.message}`;
|
|
866
|
+
badge.classList.remove("is-busy");
|
|
867
|
+
cloudqSelfUpdateRunning = false;
|
|
868
|
+
return;
|
|
869
|
+
}
|
|
870
|
+
tip.textContent = "更新完成,正在重启 DSH 服务…";
|
|
871
|
+
try {
|
|
872
|
+
await cloudqRequest(API_RESTART, { method: "POST" }, 5e3);
|
|
873
|
+
} catch {}
|
|
874
|
+
const sleep = (ms) => new Promise((resolvePromise) => window.setTimeout(resolvePromise, ms));
|
|
875
|
+
const deadline = Date.now() + 6e4;
|
|
876
|
+
while (Date.now() < deadline) try {
|
|
877
|
+
await cloudqRequest(API_VERSION, void 0, 3e3);
|
|
878
|
+
window.location.reload();
|
|
879
|
+
return;
|
|
880
|
+
} catch {
|
|
881
|
+
await sleep(1e3);
|
|
882
|
+
}
|
|
883
|
+
tip.textContent = "服务重启超时,请手动重启 DSH 后刷新页面。";
|
|
884
|
+
}
|
|
776
885
|
/** Build the exact visible row order from Host workspace membership. */
|
|
777
886
|
function orderedVisibleSessions(snapshot, workspaces, sortByUpdated) {
|
|
778
887
|
const archived = new Set(workspaces?.archivedSessionIds ?? []);
|
|
@@ -3696,6 +3805,20 @@ display: none;
|
|
|
3696
3805
|
}
|
|
3697
3806
|
});
|
|
3698
3807
|
}, "dsh-cloudq: refresh artifacts on turn completion");
|
|
3808
|
+
ctx.effect(() => {
|
|
3809
|
+
let disposed = false;
|
|
3810
|
+
cloudqRequest(API_VERSION).then((data) => {
|
|
3811
|
+
if (disposed || data?.outdated !== true || typeof data?.latest !== "string") return;
|
|
3812
|
+
cloudqOutdatedInfo = {
|
|
3813
|
+
current: String(data.current ?? ""),
|
|
3814
|
+
latest: data.latest
|
|
3815
|
+
};
|
|
3816
|
+
attachVersionBadge();
|
|
3817
|
+
}).catch(() => {});
|
|
3818
|
+
return () => {
|
|
3819
|
+
disposed = true;
|
|
3820
|
+
};
|
|
3821
|
+
}, "dsh-cloudq: version check");
|
|
3699
3822
|
ctx.effect(() => {
|
|
3700
3823
|
const markIfCloudqClaim = () => {
|
|
3701
3824
|
const textarea = document.querySelector("textarea[class*=input]");
|
package/lib/index.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
|
-
import {
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { get, request } from "node:https";
|
|
3
5
|
import { dirname, join, resolve } from "node:path";
|
|
4
6
|
import { fileURLToPath } from "node:url";
|
|
5
7
|
import Schema from "@deepseek-ai/schemastery";
|
|
6
|
-
import { Buffer
|
|
8
|
+
import { Buffer } from "node:buffer";
|
|
7
9
|
import yaml from "js-yaml";
|
|
8
|
-
import {
|
|
10
|
+
import { createHash, createHmac } from "node:crypto";
|
|
11
|
+
import { homedir } from "node:os";
|
|
9
12
|
//#region src/http.js
|
|
10
13
|
/** Maximum accepted JSON request body size. */
|
|
11
14
|
const MAX_JSON_BODY_BYTES = 65536;
|
|
@@ -114,7 +117,7 @@ function readJsonBody(request, { maxBytes = MAX_JSON_BODY_BYTES } = {}) {
|
|
|
114
117
|
};
|
|
115
118
|
request.on("data", (chunk) => {
|
|
116
119
|
if (settled) return;
|
|
117
|
-
const buffer = Buffer
|
|
120
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
118
121
|
bytes += buffer.byteLength;
|
|
119
122
|
if (bytes > maxBytes) {
|
|
120
123
|
rejectOnce(new HttpError(413, "payload-too-large", "The JSON request body is too large."));
|
|
@@ -130,7 +133,7 @@ function readJsonBody(request, { maxBytes = MAX_JSON_BODY_BYTES } = {}) {
|
|
|
130
133
|
return;
|
|
131
134
|
}
|
|
132
135
|
try {
|
|
133
|
-
resolveBody(JSON.parse(Buffer
|
|
136
|
+
resolveBody(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
134
137
|
} catch {
|
|
135
138
|
rejectBody(new HttpError(400, "invalid-json", "The request body is not valid JSON."));
|
|
136
139
|
}
|
|
@@ -147,13 +150,13 @@ function readJsonBody(request, { maxBytes = MAX_JSON_BODY_BYTES } = {}) {
|
|
|
147
150
|
//#region src/plugin-manager.js
|
|
148
151
|
/** Host-side management of optional profile bundle entries. */
|
|
149
152
|
const PROTECTED_BUNDLES = /* @__PURE__ */ new Set(["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]);
|
|
150
|
-
function profileDirectory(baseUrl) {
|
|
153
|
+
function profileDirectory$1(baseUrl) {
|
|
151
154
|
const url = new URL(".", baseUrl);
|
|
152
155
|
if (url.protocol !== "file:") throw new Error("The active DSH profile URL must use the file protocol.");
|
|
153
156
|
return fileURLToPath(url);
|
|
154
157
|
}
|
|
155
158
|
function patchPath(baseUrl) {
|
|
156
|
-
return resolve(profileDirectory(baseUrl), "cordis.patch.yml");
|
|
159
|
+
return resolve(profileDirectory$1(baseUrl), "cordis.patch.yml");
|
|
157
160
|
}
|
|
158
161
|
function parsePatchList(content) {
|
|
159
162
|
if (!content.trim()) return [];
|
|
@@ -226,7 +229,7 @@ function writePatchAtomically(path, content, originalSnapshot) {
|
|
|
226
229
|
* @returns {Array<{id: string, name: string, bundle: string, disabled: boolean, self: boolean}>}
|
|
227
230
|
*/
|
|
228
231
|
function listPlugins(baseUrl) {
|
|
229
|
-
const profileDir = profileDirectory(baseUrl);
|
|
232
|
+
const profileDir = profileDirectory$1(baseUrl);
|
|
230
233
|
const userPatchPath = patchPath(baseUrl);
|
|
231
234
|
const overrides = (existsSync(userPatchPath) ? parsePatchList(readFileSync(userPatchPath, "utf8")) : []).filter((entry) => entry && typeof entry === "object" && typeof entry.id === "string");
|
|
232
235
|
const plugins = [];
|
|
@@ -287,99 +290,157 @@ function setPluginDisabled(baseUrl, id, disabled) {
|
|
|
287
290
|
};
|
|
288
291
|
}
|
|
289
292
|
//#endregion
|
|
290
|
-
//#region src/
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
293
|
+
//#region src/tcloud.js
|
|
294
|
+
/**
|
|
295
|
+
* Node-native Tencent Cloud API client (TC3-HMAC-SHA256) and credential
|
|
296
|
+
* store for the dsh-cloudq host.
|
|
297
|
+
*
|
|
298
|
+
* The DSH host itself runs on Node, so — same as dsh-cos — every panel API
|
|
299
|
+
* call happens in-process with zero external runtime dependency. Python only
|
|
300
|
+
* remains for the agent-driven skill conversation flow.
|
|
301
|
+
*
|
|
302
|
+
* @module dsh-cloudq/tcloud
|
|
303
|
+
*/
|
|
304
|
+
const CREDENTIAL_FILE = join(homedir(), ".tencent-cloudq", "credential.json");
|
|
305
|
+
const sha256 = (value) => createHash("sha256").update(value, "utf8").digest("hex");
|
|
306
|
+
const hmacSha256 = (key, value) => createHmac("sha256", key).update(value, "utf8").digest();
|
|
307
|
+
const hmacSha256Hex = (key, value) => createHmac("sha256", key).update(value, "utf8").digest("hex");
|
|
308
|
+
/** Mask a SecretId exactly like the Python helpers: keep only the last 4. */
|
|
309
|
+
function maskSecretId(value) {
|
|
310
|
+
const text = String(value ?? "");
|
|
311
|
+
return text.length <= 4 ? "*".repeat(text.length) : "*".repeat(text.length - 4) + text.slice(-4);
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Read the stored credential shared with the CloudQ skill.
|
|
315
|
+
* Only the AK/SK entries (`type: "ak"`, optional STS token) can TC3-sign;
|
|
316
|
+
* OAuth/Connector entries carry no SecretKey and surface as NeedAuth.
|
|
317
|
+
*/
|
|
318
|
+
function readCredential() {
|
|
319
|
+
let data;
|
|
320
|
+
try {
|
|
321
|
+
data = JSON.parse(readFileSync(CREDENTIAL_FILE, "utf8"));
|
|
322
|
+
} catch {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
const secretId = typeof data?.secretId === "string" ? data.secretId : "";
|
|
326
|
+
const secretKey = typeof data?.secretKey === "string" ? data.secretKey : "";
|
|
327
|
+
if (!secretId || !secretKey) return null;
|
|
328
|
+
return {
|
|
329
|
+
secretId,
|
|
330
|
+
secretKey,
|
|
331
|
+
token: typeof data?.token === "string" ? data.token : "",
|
|
332
|
+
authType: typeof data?.type === "string" ? data.type : "ak"
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/** Credential state for the settings UI (never exposes the SecretKey). */
|
|
336
|
+
function credentialStatus$1() {
|
|
337
|
+
const credential = readCredential();
|
|
338
|
+
if (!credential) return { logged_in: false };
|
|
339
|
+
return {
|
|
340
|
+
logged_in: true,
|
|
341
|
+
secret_id_masked: maskSecretId(credential.secretId),
|
|
342
|
+
auth_type: credential.authType
|
|
343
|
+
};
|
|
294
344
|
}
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
345
|
+
/** Persist an AK/SK pair atomically, mirroring credential_manager (dir 700, file 600). */
|
|
346
|
+
function saveCredential(secretId, secretKey) {
|
|
347
|
+
const data = {
|
|
348
|
+
type: "ak",
|
|
349
|
+
secretId,
|
|
350
|
+
secretKey,
|
|
351
|
+
token: "",
|
|
352
|
+
expiresAt: 0,
|
|
353
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
354
|
+
};
|
|
355
|
+
const directory = dirname(CREDENTIAL_FILE);
|
|
356
|
+
mkdirSync(directory, { recursive: true });
|
|
357
|
+
const temporary = `${CREDENTIAL_FILE}.tmp-${process.pid}`;
|
|
358
|
+
writeFileSync(temporary, JSON.stringify(data, null, 2), {
|
|
359
|
+
encoding: "utf8",
|
|
360
|
+
mode: 384
|
|
361
|
+
});
|
|
362
|
+
renameSync(temporary, CREDENTIAL_FILE);
|
|
363
|
+
if (process.platform !== "win32") try {
|
|
364
|
+
chmodSync(directory, 448);
|
|
365
|
+
chmodSync(CREDENTIAL_FILE, 384);
|
|
366
|
+
} catch {}
|
|
367
|
+
}
|
|
368
|
+
/** Remove the stored credential (退出登录). */
|
|
369
|
+
function deleteCredential() {
|
|
370
|
+
try {
|
|
371
|
+
unlinkSync(CREDENTIAL_FILE);
|
|
372
|
+
} catch {}
|
|
299
373
|
}
|
|
300
374
|
/**
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
* @param {object} options Execution options.
|
|
306
|
-
* @returns {Promise<Record<string, unknown>>} Parsed helper response.
|
|
375
|
+
* Call a Tencent Cloud API with TC3-HMAC-SHA256 signing.
|
|
376
|
+
* Returns the unwrapped `Response` payload; API errors become HttpError with
|
|
377
|
+
* the upstream code (AuthFailure.* etc.) so callers can map them like the
|
|
378
|
+
* Python helpers did.
|
|
307
379
|
*/
|
|
308
|
-
function
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
})
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
380
|
+
async function callTcloudApi({ service, host, action, version, payload = {} }, credential = readCredential()) {
|
|
381
|
+
if (!credential) throw new HttpError(401, "NeedAuth", "尚未配置 AK/SK,请前往「设置 → 插件 → CloudQ」完成配置。");
|
|
382
|
+
const body = JSON.stringify(payload);
|
|
383
|
+
const timestamp = Math.floor(Date.now() / 1e3);
|
|
384
|
+
const date = (/* @__PURE__ */ new Date(timestamp * 1e3)).toISOString().slice(0, 10);
|
|
385
|
+
const canonicalRequest = [
|
|
386
|
+
"POST",
|
|
387
|
+
"/",
|
|
388
|
+
"",
|
|
389
|
+
`content-type:application/json\nhost:${host}\nx-tc-action:${action.toLowerCase()}`,
|
|
390
|
+
"",
|
|
391
|
+
"content-type;host;x-tc-action",
|
|
392
|
+
sha256(body)
|
|
393
|
+
].join("\n");
|
|
394
|
+
const stringToSign = [
|
|
395
|
+
"TC3-HMAC-SHA256",
|
|
396
|
+
String(timestamp),
|
|
397
|
+
`${date}/${service}/tc3_request`,
|
|
398
|
+
sha256(canonicalRequest)
|
|
399
|
+
].join("\n");
|
|
400
|
+
const keyDate = hmacSha256(`TC3${credential.secretKey}`, date);
|
|
401
|
+
const keyService = hmacSha256(keyDate, service);
|
|
402
|
+
const keySigning = hmacSha256(keyService, "tc3_request");
|
|
403
|
+
const signature = hmacSha256Hex(keySigning, stringToSign);
|
|
404
|
+
const authorization = `TC3-HMAC-SHA256 Credential=${credential.secretId}/${date}/${service}/tc3_request, SignedHeaders=content-type;host;x-tc-action, Signature=${signature}`;
|
|
405
|
+
const headers = {
|
|
406
|
+
"Content-Type": "application/json",
|
|
407
|
+
"X-TC-Action": action,
|
|
408
|
+
"X-TC-Version": version,
|
|
409
|
+
"X-TC-Timestamp": String(timestamp),
|
|
410
|
+
Authorization: authorization
|
|
411
|
+
};
|
|
412
|
+
if (credential.token) headers["X-TC-Token"] = credential.token;
|
|
413
|
+
const text = await new Promise((resolveRequest, rejectRequest) => {
|
|
414
|
+
const request$1 = request({
|
|
415
|
+
host,
|
|
416
|
+
path: "/",
|
|
417
|
+
method: "POST",
|
|
418
|
+
headers,
|
|
419
|
+
timeout: 3e4
|
|
420
|
+
}, (response) => {
|
|
421
|
+
let bodyText = "";
|
|
422
|
+
response.on("data", (chunk) => {
|
|
423
|
+
bodyText += chunk;
|
|
424
|
+
if (bodyText.length > 1048576) request$1.destroy();
|
|
425
|
+
});
|
|
426
|
+
response.on("end", () => resolveRequest(bodyText));
|
|
350
427
|
});
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (code !== 0) rejectOnce(new HttpError(502, "script-failed", "The CloudQ helper failed."));
|
|
356
|
-
else resolveOnce({
|
|
357
|
-
success: true,
|
|
358
|
-
message: redact(trimmed, sensitiveValues)
|
|
359
|
-
});
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
let parsed;
|
|
363
|
-
try {
|
|
364
|
-
parsed = trimmed ? JSON.parse(trimmed) : {};
|
|
365
|
-
} catch {
|
|
366
|
-
rejectOnce(new HttpError(502, "script-invalid-output", "The CloudQ helper returned an invalid response."));
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
if (code !== 0 || parsed?.ok === false || parsed?.success === false) {
|
|
370
|
-
const codeValue = safeCode(parsed?.error?.code ?? parsed?.code);
|
|
371
|
-
rejectOnce(new HttpError(502, codeValue, "The CloudQ helper failed."));
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
resolveOnce(parsed);
|
|
428
|
+
request$1.on("error", () => rejectRequest(new HttpError(502, "network-error", "无法连接腾讯云服务,请检查网络后重试。")));
|
|
429
|
+
request$1.on("timeout", () => {
|
|
430
|
+
request$1.destroy();
|
|
431
|
+
rejectRequest(new HttpError(504, "network-timeout", "腾讯云服务响应超时,请稍后重试。"));
|
|
375
432
|
});
|
|
376
|
-
|
|
377
|
-
child.stdin.on("error", () => {
|
|
378
|
-
rejectOnce(new HttpError(502, "script-input-failed", "The CloudQ helper could not receive its input."));
|
|
379
|
-
});
|
|
380
|
-
child.stdin.end(stdin);
|
|
381
|
-
}
|
|
433
|
+
request$1.end(body);
|
|
382
434
|
});
|
|
435
|
+
let parsed;
|
|
436
|
+
try {
|
|
437
|
+
parsed = JSON.parse(text);
|
|
438
|
+
} catch {
|
|
439
|
+
throw new HttpError(502, "invalid-response", "腾讯云服务返回了无法识别的响应。");
|
|
440
|
+
}
|
|
441
|
+
const envelope = parsed?.Response ?? {};
|
|
442
|
+
if (envelope.Error) throw new HttpError(502, String(envelope.Error.Code ?? "api-error"), String(envelope.Error.Message ?? "腾讯云接口调用失败。"));
|
|
443
|
+
return envelope;
|
|
383
444
|
}
|
|
384
445
|
//#endregion
|
|
385
446
|
//#region src/index.js
|
|
@@ -405,6 +466,121 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
405
466
|
function skillDirectory() {
|
|
406
467
|
return resolve(__dirname, "../skills/cloudq");
|
|
407
468
|
}
|
|
469
|
+
/** This plugin's own version, read from the installed package manifest. */
|
|
470
|
+
const PACKAGE_VERSION = (() => {
|
|
471
|
+
try {
|
|
472
|
+
return JSON.parse(readFileSync(resolve(__dirname, "../package.json"), "utf8"))?.version ?? "0.0.0";
|
|
473
|
+
} catch {
|
|
474
|
+
return "0.0.0";
|
|
475
|
+
}
|
|
476
|
+
})();
|
|
477
|
+
/**
|
|
478
|
+
* Profile directory holding this plugin (`<profile>/node_modules/dsh-cloudq`).
|
|
479
|
+
* Both src/ (dev) and lib/ (packed) sit one level under the package root.
|
|
480
|
+
*/
|
|
481
|
+
function profileDirectory() {
|
|
482
|
+
return resolve(__dirname, "../../..");
|
|
483
|
+
}
|
|
484
|
+
/** Fetch the latest published version from the npm registry (best effort). */
|
|
485
|
+
function fetchLatestPackageVersion() {
|
|
486
|
+
return new Promise((resolvePromise) => {
|
|
487
|
+
const request = get("https://registry.npmjs.org/dsh-cloudq/latest", { timeout: 8e3 }, (res) => {
|
|
488
|
+
if (res.statusCode !== 200) {
|
|
489
|
+
res.resume();
|
|
490
|
+
resolvePromise(null);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
let body = "";
|
|
494
|
+
res.on("data", (chunk) => {
|
|
495
|
+
body += chunk;
|
|
496
|
+
if (body.length > 65536) request.destroy();
|
|
497
|
+
});
|
|
498
|
+
res.on("end", () => {
|
|
499
|
+
try {
|
|
500
|
+
resolvePromise(JSON.parse(body)?.version ?? null);
|
|
501
|
+
} catch {
|
|
502
|
+
resolvePromise(null);
|
|
503
|
+
}
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
request.on("error", () => resolvePromise(null));
|
|
507
|
+
request.on("timeout", () => {
|
|
508
|
+
request.destroy();
|
|
509
|
+
resolvePromise(null);
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
let latestVersionCache = {
|
|
514
|
+
at: 0,
|
|
515
|
+
version: null
|
|
516
|
+
};
|
|
517
|
+
async function latestPackageVersion() {
|
|
518
|
+
if (latestVersionCache.version && Date.now() - latestVersionCache.at < 6e5) return latestVersionCache.version;
|
|
519
|
+
const version = await fetchLatestPackageVersion();
|
|
520
|
+
if (version) latestVersionCache = {
|
|
521
|
+
at: Date.now(),
|
|
522
|
+
version
|
|
523
|
+
};
|
|
524
|
+
return version;
|
|
525
|
+
}
|
|
526
|
+
/** Semver-ish compare: is `latest` strictly newer than `current`? */
|
|
527
|
+
function isNewerVersion(latest, current) {
|
|
528
|
+
const parse = (value) => String(value).split(".").map((part) => parseInt(part, 10) || 0);
|
|
529
|
+
const next = parse(latest);
|
|
530
|
+
const now = parse(current);
|
|
531
|
+
for (let index = 0; index < 3; index += 1) if (next[index] !== now[index]) return next[index] > now[index];
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
const UPDATE_TIMEOUT_MS = 12e4;
|
|
535
|
+
/** Install the latest published plugin version into the active profile. */
|
|
536
|
+
function runProfileUpdate() {
|
|
537
|
+
return new Promise((resolveRun, rejectRun) => {
|
|
538
|
+
const child = spawn("pnpm", [
|
|
539
|
+
"add",
|
|
540
|
+
"dsh-cloudq@latest",
|
|
541
|
+
"--registry=https://registry.npmjs.org/"
|
|
542
|
+
], { cwd: profileDirectory() });
|
|
543
|
+
let output = "";
|
|
544
|
+
const collect = (chunk) => {
|
|
545
|
+
output += chunk;
|
|
546
|
+
if (output.length > 65536) child.kill();
|
|
547
|
+
};
|
|
548
|
+
child.stdout.on("data", collect);
|
|
549
|
+
child.stderr.on("data", collect);
|
|
550
|
+
const timer = setTimeout(() => {
|
|
551
|
+
child.kill();
|
|
552
|
+
rejectRun(new HttpError(504, "update-timeout", "更新超时,请检查网络后重试。"));
|
|
553
|
+
}, UPDATE_TIMEOUT_MS);
|
|
554
|
+
child.on("error", () => {
|
|
555
|
+
clearTimeout(timer);
|
|
556
|
+
rejectRun(new HttpError(502, "update-failed", "无法启动 pnpm,请在终端手动执行:dsh plugin --profile web add dsh-cloudq"));
|
|
557
|
+
});
|
|
558
|
+
child.on("close", (code) => {
|
|
559
|
+
clearTimeout(timer);
|
|
560
|
+
if (code === 0) resolveRun();
|
|
561
|
+
else rejectRun(new HttpError(502, "update-failed", `更新失败:${output.trim().slice(-200) || "pnpm 执行异常"}`));
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
/**
|
|
566
|
+
* Restart the DSH host after an update. A detached watcher respawns the same
|
|
567
|
+
* command line the moment this process exits; there is no supervisor, so the
|
|
568
|
+
* plugin exits itself once the watcher is armed.
|
|
569
|
+
*/
|
|
570
|
+
function scheduleSelfRestart() {
|
|
571
|
+
if (process.platform === "win32") throw new HttpError(501, "restart-unsupported", "当前系统不支持自动重启,请手动重启 DSH 服务。");
|
|
572
|
+
const quote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
573
|
+
const command = [
|
|
574
|
+
`while kill -0 ${process.pid} 2>/dev/null; do sleep 0.5; done`,
|
|
575
|
+
"sleep 1",
|
|
576
|
+
`cd ${quote(process.cwd())} && nohup ${quote(process.argv[0])} ${process.argv.slice(1).map(quote).join(" ")} >> /tmp/dsh-cloudq-restart.log 2>&1 &`
|
|
577
|
+
].join("; ");
|
|
578
|
+
spawn("/bin/sh", ["-c", command], {
|
|
579
|
+
detached: true,
|
|
580
|
+
stdio: "ignore"
|
|
581
|
+
}).unref();
|
|
582
|
+
setTimeout(() => process.exit(0), 300).unref();
|
|
583
|
+
}
|
|
408
584
|
/** Raw SKILL.md body. */
|
|
409
585
|
function rawSkillContent() {
|
|
410
586
|
return readFileSync(resolve(skillDirectory(), "SKILL.md"), "utf8");
|
|
@@ -419,119 +595,124 @@ function renderedSkillContent() {
|
|
|
419
595
|
const baseDir = skillDirectory();
|
|
420
596
|
return rawSkillContent().replace(/\{baseDir\}/g, baseDir);
|
|
421
597
|
}
|
|
422
|
-
/**
|
|
423
|
-
|
|
598
|
+
/** Map upstream TC error codes to stable, actionable client-facing errors. */
|
|
599
|
+
function normalizeApiError(error) {
|
|
600
|
+
if (error instanceof HttpError) {
|
|
601
|
+
if (error.code === "NeedAuth") throw error;
|
|
602
|
+
if (typeof error.code === "string" && error.code.startsWith("AuthFailure")) throw new HttpError(401, "AuthFailure", "AK/SK 无效或权限不足,请前往「设置 → 插件 → CloudQ」检查配置。");
|
|
603
|
+
}
|
|
604
|
+
throw error;
|
|
605
|
+
}
|
|
606
|
+
/** Wrap one API call with the credential-error mapping. */
|
|
607
|
+
async function callCloudq(spec) {
|
|
424
608
|
try {
|
|
425
|
-
return await
|
|
609
|
+
return { data: await callTcloudApi(spec) };
|
|
426
610
|
} catch (error) {
|
|
427
|
-
|
|
428
|
-
if (error.code === "NeedAuth") throw new HttpError(401, "NeedAuth", "尚未配置 AK/SK,请前往「设置 → 插件 → CloudQ」完成配置。");
|
|
429
|
-
if (error.code === "CredentialExpired") throw new HttpError(401, "CredentialExpired", "凭证已过期,请前往「设置 → 插件 → CloudQ」重新配置。");
|
|
430
|
-
if (typeof error.code === "string" && error.code.startsWith("AuthFailure")) throw new HttpError(401, "AuthFailure", "AK/SK 无效或权限不足,请前往「设置 → 插件 → CloudQ」检查配置。");
|
|
431
|
-
}
|
|
611
|
+
normalizeApiError(error);
|
|
432
612
|
throw error;
|
|
433
613
|
}
|
|
434
614
|
}
|
|
435
|
-
/**
|
|
436
|
-
function
|
|
437
|
-
|
|
438
|
-
const safeStatus = { ...status };
|
|
439
|
-
delete safeStatus.credential_file;
|
|
440
|
-
return safeStatus;
|
|
441
|
-
}
|
|
442
|
-
/** Wrap runScript so failures keep a stable envelope for the client. */
|
|
443
|
-
async function credentialStatus() {
|
|
444
|
-
return publicCredentialStatus(await runScript("login.py", ["--status"]));
|
|
615
|
+
/** Credential state for the settings UI (never exposes the SecretKey). */
|
|
616
|
+
function credentialStatus() {
|
|
617
|
+
return credentialStatus$1();
|
|
445
618
|
}
|
|
446
619
|
function logout() {
|
|
447
|
-
|
|
620
|
+
deleteCredential();
|
|
621
|
+
return { message: "已退出登录。" };
|
|
448
622
|
}
|
|
449
623
|
/**
|
|
450
624
|
* Validate a long-lived Tencent Cloud AK/SK pair without persisting it.
|
|
451
|
-
*
|
|
625
|
+
* DescribeCloudQUsageOverview is read-only, parameterless, and CloudQ's own
|
|
626
|
+
* API — a successful call proves both the key and the CloudQ enrollment.
|
|
452
627
|
*/
|
|
453
|
-
function testAccessKey(secretId, secretKey) {
|
|
454
|
-
|
|
455
|
-
|
|
628
|
+
async function testAccessKey(secretId, secretKey) {
|
|
629
|
+
try {
|
|
630
|
+
await callTcloudApi({
|
|
631
|
+
service: "advisor",
|
|
632
|
+
host: "advisor.tencentcloudapi.com",
|
|
633
|
+
action: "DescribeCloudQUsageOverview",
|
|
634
|
+
version: "2020-07-21"
|
|
635
|
+
}, {
|
|
456
636
|
secretId,
|
|
457
|
-
secretKey
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
637
|
+
secretKey,
|
|
638
|
+
token: ""
|
|
639
|
+
});
|
|
640
|
+
return {
|
|
641
|
+
valid: true,
|
|
642
|
+
secret_id_masked: maskSecretId(secretId)
|
|
643
|
+
};
|
|
644
|
+
} catch (error) {
|
|
645
|
+
normalizeApiError(error);
|
|
646
|
+
throw error;
|
|
647
|
+
}
|
|
461
648
|
}
|
|
462
649
|
/** Validate then persist a long-lived AK/SK pair as `type:"ak"`. */
|
|
463
650
|
async function saveAccessKey(secretId, secretKey) {
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
secretKey
|
|
468
|
-
}),
|
|
469
|
-
sensitiveValues: [secretId, secretKey]
|
|
470
|
-
}));
|
|
651
|
+
await testAccessKey(secretId, secretKey);
|
|
652
|
+
saveCredential(secretId, secretKey);
|
|
653
|
+
return credentialStatus$1();
|
|
471
654
|
}
|
|
472
655
|
function cloudqUsageOverview() {
|
|
473
|
-
return
|
|
474
|
-
"advisor",
|
|
475
|
-
"advisor.tencentcloudapi.com",
|
|
476
|
-
"DescribeCloudQUsageOverview",
|
|
477
|
-
"2020-07-21"
|
|
478
|
-
|
|
479
|
-
]);
|
|
656
|
+
return callCloudq({
|
|
657
|
+
service: "advisor",
|
|
658
|
+
host: "advisor.tencentcloudapi.com",
|
|
659
|
+
action: "DescribeCloudQUsageOverview",
|
|
660
|
+
version: "2020-07-21"
|
|
661
|
+
});
|
|
480
662
|
}
|
|
481
663
|
function cloudqUsageDetail({ startTime, endTime, limit = 20, offset = 0 }) {
|
|
482
|
-
return
|
|
483
|
-
"advisor",
|
|
484
|
-
"advisor.tencentcloudapi.com",
|
|
485
|
-
"DescribeCloudQUsageDetail",
|
|
486
|
-
"2020-07-21",
|
|
487
|
-
|
|
664
|
+
return callCloudq({
|
|
665
|
+
service: "advisor",
|
|
666
|
+
host: "advisor.tencentcloudapi.com",
|
|
667
|
+
action: "DescribeCloudQUsageDetail",
|
|
668
|
+
version: "2020-07-21",
|
|
669
|
+
payload: {
|
|
488
670
|
StartTime: startTime,
|
|
489
671
|
EndTime: endTime,
|
|
490
672
|
Limit: limit,
|
|
491
673
|
Offset: offset
|
|
492
|
-
}
|
|
493
|
-
|
|
674
|
+
}
|
|
675
|
+
});
|
|
494
676
|
}
|
|
495
677
|
function cloudqInspirationList() {
|
|
496
|
-
return
|
|
497
|
-
"advisor",
|
|
498
|
-
"advisor.tencentcloudapi.com",
|
|
499
|
-
"DescribeCloudQInspirationList",
|
|
500
|
-
"2020-07-21",
|
|
501
|
-
|
|
502
|
-
|
|
678
|
+
return callCloudq({
|
|
679
|
+
service: "advisor",
|
|
680
|
+
host: "advisor.tencentcloudapi.com",
|
|
681
|
+
action: "DescribeCloudQInspirationList",
|
|
682
|
+
version: "2020-07-21",
|
|
683
|
+
payload: { Category: 0 }
|
|
684
|
+
});
|
|
503
685
|
}
|
|
504
686
|
/** Fetch all CloudQ artifact sessions and their archived files. */
|
|
505
687
|
function cloudqArtifactLibrary() {
|
|
506
|
-
return
|
|
507
|
-
"advisor",
|
|
508
|
-
"advisor.tencentcloudapi.com",
|
|
509
|
-
"DescribeCloudQArtifactLibrary",
|
|
510
|
-
"2020-07-21"
|
|
511
|
-
|
|
512
|
-
]);
|
|
688
|
+
return callCloudq({
|
|
689
|
+
service: "advisor",
|
|
690
|
+
host: "advisor.tencentcloudapi.com",
|
|
691
|
+
action: "DescribeCloudQArtifactLibrary",
|
|
692
|
+
version: "2020-07-21"
|
|
693
|
+
});
|
|
513
694
|
}
|
|
514
695
|
/** Fetch the architecture directory tree available to the current account. */
|
|
515
696
|
function cloudqArchitectureDirectories() {
|
|
516
|
-
return
|
|
517
|
-
"advisor",
|
|
518
|
-
"advisor.tencentcloudapi.com",
|
|
519
|
-
"ListDirectoryV2",
|
|
520
|
-
"2020-07-21",
|
|
521
|
-
|
|
697
|
+
return callCloudq({
|
|
698
|
+
service: "advisor",
|
|
699
|
+
host: "advisor.tencentcloudapi.com",
|
|
700
|
+
action: "ListDirectoryV2",
|
|
701
|
+
version: "2020-07-21",
|
|
702
|
+
payload: {
|
|
522
703
|
Tags: [],
|
|
523
704
|
TagKeys: []
|
|
524
|
-
}
|
|
525
|
-
|
|
705
|
+
}
|
|
706
|
+
});
|
|
526
707
|
}
|
|
527
708
|
/** Fetch one page of diagrams in a CloudQ architecture directory. */
|
|
528
709
|
function cloudqArchitectureList({ folderId, pageNumber = 1, pageSize = 30 }) {
|
|
529
|
-
return
|
|
530
|
-
"advisor",
|
|
531
|
-
"advisor.tencentcloudapi.com",
|
|
532
|
-
"DescribeArchList",
|
|
533
|
-
"2020-07-21",
|
|
534
|
-
|
|
710
|
+
return callCloudq({
|
|
711
|
+
service: "advisor",
|
|
712
|
+
host: "advisor.tencentcloudapi.com",
|
|
713
|
+
action: "DescribeArchList",
|
|
714
|
+
version: "2020-07-21",
|
|
715
|
+
payload: {
|
|
535
716
|
PageNumber: pageNumber,
|
|
536
717
|
PageSize: pageSize,
|
|
537
718
|
SearchKey: "",
|
|
@@ -539,8 +720,8 @@ function cloudqArchitectureList({ folderId, pageNumber = 1, pageSize = 30 }) {
|
|
|
539
720
|
WithSvgURL: true,
|
|
540
721
|
Tags: [],
|
|
541
722
|
TagKeys: []
|
|
542
|
-
}
|
|
543
|
-
|
|
723
|
+
}
|
|
724
|
+
});
|
|
544
725
|
}
|
|
545
726
|
/**
|
|
546
727
|
* Extract and validate an AK/SK pair from a request body.
|
|
@@ -676,6 +857,53 @@ function apply(ctx) {
|
|
|
676
857
|
}
|
|
677
858
|
}
|
|
678
859
|
}));
|
|
860
|
+
disposers.push(ctx.webServer.register({
|
|
861
|
+
kind: "exact",
|
|
862
|
+
path: "/api/dsh-cloudq/version",
|
|
863
|
+
handler: async (request, response) => {
|
|
864
|
+
try {
|
|
865
|
+
assertSafeRequest(request, "GET");
|
|
866
|
+
const latest = await latestPackageVersion();
|
|
867
|
+
sendJson(response, 200, {
|
|
868
|
+
ok: true,
|
|
869
|
+
current: PACKAGE_VERSION,
|
|
870
|
+
latest,
|
|
871
|
+
outdated: latest !== null && isNewerVersion(latest, PACKAGE_VERSION)
|
|
872
|
+
});
|
|
873
|
+
} catch (error) {
|
|
874
|
+
sendError(response, error);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}));
|
|
878
|
+
disposers.push(ctx.webServer.register({
|
|
879
|
+
kind: "exact",
|
|
880
|
+
path: "/api/dsh-cloudq/update",
|
|
881
|
+
handler: async (request, response) => {
|
|
882
|
+
try {
|
|
883
|
+
assertSafeRequest(request, "POST");
|
|
884
|
+
await runProfileUpdate();
|
|
885
|
+
sendJson(response, 200, { ok: true });
|
|
886
|
+
} catch (error) {
|
|
887
|
+
sendError(response, error);
|
|
888
|
+
}
|
|
889
|
+
}
|
|
890
|
+
}));
|
|
891
|
+
disposers.push(ctx.webServer.register({
|
|
892
|
+
kind: "exact",
|
|
893
|
+
path: "/api/dsh-cloudq/restart",
|
|
894
|
+
handler: async (request, response) => {
|
|
895
|
+
try {
|
|
896
|
+
assertSafeRequest(request, "POST");
|
|
897
|
+
sendJson(response, 200, {
|
|
898
|
+
ok: true,
|
|
899
|
+
restarting: true
|
|
900
|
+
});
|
|
901
|
+
scheduleSelfRestart();
|
|
902
|
+
} catch (error) {
|
|
903
|
+
sendError(response, error);
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
}));
|
|
679
907
|
disposers.push(ctx.webServer.register({
|
|
680
908
|
kind: "exact",
|
|
681
909
|
path: "/api/dsh-cloudq/credential/test",
|