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.
Files changed (3) hide show
  1. package/lib/client.js +123 -0
  2. package/lib/index.js +401 -173
  3. 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 { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
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 as Buffer$1 } from "node:buffer";
8
+ import { Buffer } from "node:buffer";
7
9
  import yaml from "js-yaml";
8
- import { spawn } from "node:child_process";
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$1.isBuffer(chunk) ? chunk : Buffer$1.from(chunk);
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$1.concat(chunks).toString("utf8")));
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/script-runner.js
291
- const MAX_SCRIPT_OUTPUT_BYTES = 1048576;
292
- function safeCode(value) {
293
- return typeof value === "string" && /^[a-zA-Z0-9._-]{1,80}$/.test(value) ? value : "script-failed";
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
- function redact(value, sensitiveValues) {
296
- let text = String(value ?? "");
297
- for (const sensitive of sensitiveValues) if (sensitive) text = text.replaceAll(sensitive, "[redacted]");
298
- return text.replace(/[\r\n\t]+/g, " ").slice(0, 300);
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
- * Run one bundled Python helper with bounded output and optional stdin.
302
- * @param {string} scriptsDirectory Absolute scripts directory.
303
- * @param {string} scriptName Bundled script filename.
304
- * @param {string[]} args Non-sensitive command-line arguments.
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 runScript$1(scriptsDirectory, scriptName, args, { timeoutMs = 3e4, jsonOnly = true, stdin, sensitiveValues = [], spawnProcess = spawn } = {}) {
309
- return new Promise((resolveRun, rejectRun) => {
310
- const child = spawnProcess("python3", [resolve(scriptsDirectory, scriptName), ...args], {
311
- stdio: [
312
- stdin === void 0 ? "ignore" : "pipe",
313
- "pipe",
314
- "pipe"
315
- ],
316
- timeout: timeoutMs
317
- });
318
- let stdout = "";
319
- let stderr = "";
320
- let outputBytes = 0;
321
- let settled = false;
322
- const rejectOnce = (error) => {
323
- if (settled) return;
324
- settled = true;
325
- rejectRun(error);
326
- };
327
- const resolveOnce = (value) => {
328
- if (settled) return;
329
- settled = true;
330
- resolveRun(value);
331
- };
332
- const collect = (current, chunk) => {
333
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
334
- outputBytes += buffer.byteLength;
335
- if (outputBytes > MAX_SCRIPT_OUTPUT_BYTES) {
336
- child.kill();
337
- rejectOnce(new HttpError(502, "script-output-too-large", "The CloudQ helper returned too much data."));
338
- return current;
339
- }
340
- return current + buffer.toString("utf8");
341
- };
342
- child.stdout.on("data", (chunk) => {
343
- stdout = collect(stdout, chunk);
344
- });
345
- child.stderr.on("data", (chunk) => {
346
- stderr = collect(stderr, chunk);
347
- });
348
- child.on("error", () => {
349
- rejectOnce(new HttpError(502, "script-launch-failed", "The CloudQ helper could not be started."));
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
- child.on("close", (code) => {
352
- if (settled) return;
353
- const trimmed = stdout.trim();
354
- if (!jsonOnly) {
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
- if (stdin !== void 0) {
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
- /** Run one helper from the bundled CloudQ skill. */
423
- async function runScript(scriptName, args, options) {
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 runScript$1(resolve(skillDirectory(), "scripts"), scriptName, args, options);
609
+ return { data: await callTcloudApi(spec) };
426
610
  } catch (error) {
427
- if (error instanceof HttpError) {
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
- /** Remove host filesystem details before returning credential state to the browser. */
436
- function publicCredentialStatus(status) {
437
- if (!status || typeof status !== "object" || Array.isArray(status)) return status;
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
- return runScript("logout.py", [], { jsonOnly: false });
620
+ deleteCredential();
621
+ return { message: "已退出登录。" };
448
622
  }
449
623
  /**
450
624
  * Validate a long-lived Tencent Cloud AK/SK pair without persisting it.
451
- * The script performs one read-only CloudQ call to prove the key works.
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
- return runScript("save_ak.py", ["--test", "--stdin"], {
455
- stdin: JSON.stringify({
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
- sensitiveValues: [secretId, secretKey]
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
- return publicCredentialStatus(await runScript("save_ak.py", ["--save", "--stdin"], {
465
- stdin: JSON.stringify({
466
- secretId,
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 runScript("tcloud_api.py", [
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 runScript("tcloud_api.py", [
483
- "advisor",
484
- "advisor.tencentcloudapi.com",
485
- "DescribeCloudQUsageDetail",
486
- "2020-07-21",
487
- JSON.stringify({
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 runScript("tcloud_api.py", [
497
- "advisor",
498
- "advisor.tencentcloudapi.com",
499
- "DescribeCloudQInspirationList",
500
- "2020-07-21",
501
- "{\"Category\":0}"
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 runScript("tcloud_api.py", [
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 runScript("tcloud_api.py", [
517
- "advisor",
518
- "advisor.tencentcloudapi.com",
519
- "ListDirectoryV2",
520
- "2020-07-21",
521
- JSON.stringify({
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 runScript("tcloud_api.py", [
530
- "advisor",
531
- "advisor.tencentcloudapi.com",
532
- "DescribeArchList",
533
- "2020-07-21",
534
- JSON.stringify({
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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-cloudq",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "CloudQ integration for DeepSeek Harness with secure credential, workspace, and plugin-management surfaces",
5
5
  "license": "MIT",
6
6
  "type": "module",