dsh-cloudq 0.2.0 → 0.2.1

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 +124 -1
  2. package/lib/index.js +188 -6
  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 ?? []);
@@ -3276,7 +3385,7 @@ display: none;
3276
3385
  });
3277
3386
  } catch (error) {
3278
3387
  setValidated(false);
3279
- const invalid = error instanceof CloudQApiError && error.code !== "network-error" && error.code !== "invalid-response";
3388
+ const invalid = error instanceof CloudQApiError && error.code !== "network-error" && error.code !== "invalid-response" && error.code !== "script-launch-failed";
3280
3389
  setFeedback({
3281
3390
  kind: "error",
3282
3391
  text: invalid ? "AKSK 无效,请检查后重新配置。" : error.message
@@ -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,12 @@
1
1
  import { createRequire } from "node:module";
2
+ import { spawn, spawnSync } from "node:child_process";
2
3
  import { existsSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
4
+ import { get } 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
8
  import { Buffer as Buffer$1 } from "node:buffer";
7
9
  import yaml from "js-yaml";
8
- import { spawn } from "node:child_process";
9
10
  //#region src/http.js
10
11
  /** Maximum accepted JSON request body size. */
11
12
  const MAX_JSON_BODY_BYTES = 65536;
@@ -147,13 +148,13 @@ function readJsonBody(request, { maxBytes = MAX_JSON_BODY_BYTES } = {}) {
147
148
  //#region src/plugin-manager.js
148
149
  /** Host-side management of optional profile bundle entries. */
149
150
  const PROTECTED_BUNDLES = /* @__PURE__ */ new Set(["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app"]);
150
- function profileDirectory(baseUrl) {
151
+ function profileDirectory$1(baseUrl) {
151
152
  const url = new URL(".", baseUrl);
152
153
  if (url.protocol !== "file:") throw new Error("The active DSH profile URL must use the file protocol.");
153
154
  return fileURLToPath(url);
154
155
  }
155
156
  function patchPath(baseUrl) {
156
- return resolve(profileDirectory(baseUrl), "cordis.patch.yml");
157
+ return resolve(profileDirectory$1(baseUrl), "cordis.patch.yml");
157
158
  }
158
159
  function parsePatchList(content) {
159
160
  if (!content.trim()) return [];
@@ -226,7 +227,7 @@ function writePatchAtomically(path, content, originalSnapshot) {
226
227
  * @returns {Array<{id: string, name: string, bundle: string, disabled: boolean, self: boolean}>}
227
228
  */
228
229
  function listPlugins(baseUrl) {
229
- const profileDir = profileDirectory(baseUrl);
230
+ const profileDir = profileDirectory$1(baseUrl);
230
231
  const userPatchPath = patchPath(baseUrl);
231
232
  const overrides = (existsSync(userPatchPath) ? parsePatchList(readFileSync(userPatchPath, "utf8")) : []).filter((entry) => entry && typeof entry === "object" && typeof entry.id === "string");
232
233
  const plugins = [];
@@ -289,6 +290,24 @@ function setPluginDisabled(baseUrl, id, disabled) {
289
290
  //#endregion
290
291
  //#region src/script-runner.js
291
292
  const MAX_SCRIPT_OUTPUT_BYTES = 1048576;
293
+ let resolvedPythonCommand = null;
294
+ function pythonCommand() {
295
+ if (resolvedPythonCommand) return resolvedPythonCommand;
296
+ for (const candidate of [
297
+ "python3",
298
+ "python",
299
+ "py"
300
+ ]) try {
301
+ if (spawnSync(candidate, ["--version"], {
302
+ stdio: "ignore",
303
+ timeout: 5e3
304
+ }).status === 0) {
305
+ resolvedPythonCommand = candidate;
306
+ return candidate;
307
+ }
308
+ } catch {}
309
+ return "python3";
310
+ }
292
311
  function safeCode(value) {
293
312
  return typeof value === "string" && /^[a-zA-Z0-9._-]{1,80}$/.test(value) ? value : "script-failed";
294
313
  }
@@ -307,7 +326,8 @@ function redact(value, sensitiveValues) {
307
326
  */
308
327
  function runScript$1(scriptsDirectory, scriptName, args, { timeoutMs = 3e4, jsonOnly = true, stdin, sensitiveValues = [], spawnProcess = spawn } = {}) {
309
328
  return new Promise((resolveRun, rejectRun) => {
310
- const child = spawnProcess("python3", [resolve(scriptsDirectory, scriptName), ...args], {
329
+ const script = resolve(scriptsDirectory, scriptName);
330
+ const child = spawnProcess(pythonCommand(), [script, ...args], {
311
331
  stdio: [
312
332
  stdin === void 0 ? "ignore" : "pipe",
313
333
  "pipe",
@@ -346,7 +366,7 @@ function runScript$1(scriptsDirectory, scriptName, args, { timeoutMs = 3e4, json
346
366
  stderr = collect(stderr, chunk);
347
367
  });
348
368
  child.on("error", () => {
349
- rejectOnce(new HttpError(502, "script-launch-failed", "The CloudQ helper could not be started."));
369
+ rejectOnce(new HttpError(502, "script-launch-failed", "无法启动 Python 运行环境,请先安装 Python 3 后重试。"));
350
370
  });
351
371
  child.on("close", (code) => {
352
372
  if (settled) return;
@@ -405,6 +425,121 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
405
425
  function skillDirectory() {
406
426
  return resolve(__dirname, "../skills/cloudq");
407
427
  }
428
+ /** This plugin's own version, read from the installed package manifest. */
429
+ const PACKAGE_VERSION = (() => {
430
+ try {
431
+ return JSON.parse(readFileSync(resolve(__dirname, "../package.json"), "utf8"))?.version ?? "0.0.0";
432
+ } catch {
433
+ return "0.0.0";
434
+ }
435
+ })();
436
+ /**
437
+ * Profile directory holding this plugin (`<profile>/node_modules/dsh-cloudq`).
438
+ * Both src/ (dev) and lib/ (packed) sit one level under the package root.
439
+ */
440
+ function profileDirectory() {
441
+ return resolve(__dirname, "../../..");
442
+ }
443
+ /** Fetch the latest published version from the npm registry (best effort). */
444
+ function fetchLatestPackageVersion() {
445
+ return new Promise((resolvePromise) => {
446
+ const request = get("https://registry.npmjs.org/dsh-cloudq/latest", { timeout: 8e3 }, (res) => {
447
+ if (res.statusCode !== 200) {
448
+ res.resume();
449
+ resolvePromise(null);
450
+ return;
451
+ }
452
+ let body = "";
453
+ res.on("data", (chunk) => {
454
+ body += chunk;
455
+ if (body.length > 65536) request.destroy();
456
+ });
457
+ res.on("end", () => {
458
+ try {
459
+ resolvePromise(JSON.parse(body)?.version ?? null);
460
+ } catch {
461
+ resolvePromise(null);
462
+ }
463
+ });
464
+ });
465
+ request.on("error", () => resolvePromise(null));
466
+ request.on("timeout", () => {
467
+ request.destroy();
468
+ resolvePromise(null);
469
+ });
470
+ });
471
+ }
472
+ let latestVersionCache = {
473
+ at: 0,
474
+ version: null
475
+ };
476
+ async function latestPackageVersion() {
477
+ if (latestVersionCache.version && Date.now() - latestVersionCache.at < 6e5) return latestVersionCache.version;
478
+ const version = await fetchLatestPackageVersion();
479
+ if (version) latestVersionCache = {
480
+ at: Date.now(),
481
+ version
482
+ };
483
+ return version;
484
+ }
485
+ /** Semver-ish compare: is `latest` strictly newer than `current`? */
486
+ function isNewerVersion(latest, current) {
487
+ const parse = (value) => String(value).split(".").map((part) => parseInt(part, 10) || 0);
488
+ const next = parse(latest);
489
+ const now = parse(current);
490
+ for (let index = 0; index < 3; index += 1) if (next[index] !== now[index]) return next[index] > now[index];
491
+ return false;
492
+ }
493
+ const UPDATE_TIMEOUT_MS = 12e4;
494
+ /** Install the latest published plugin version into the active profile. */
495
+ function runProfileUpdate() {
496
+ return new Promise((resolveRun, rejectRun) => {
497
+ const child = spawn("pnpm", [
498
+ "add",
499
+ "dsh-cloudq@latest",
500
+ "--registry=https://registry.npmjs.org/"
501
+ ], { cwd: profileDirectory() });
502
+ let output = "";
503
+ const collect = (chunk) => {
504
+ output += chunk;
505
+ if (output.length > 65536) child.kill();
506
+ };
507
+ child.stdout.on("data", collect);
508
+ child.stderr.on("data", collect);
509
+ const timer = setTimeout(() => {
510
+ child.kill();
511
+ rejectRun(new HttpError(504, "update-timeout", "更新超时,请检查网络后重试。"));
512
+ }, UPDATE_TIMEOUT_MS);
513
+ child.on("error", () => {
514
+ clearTimeout(timer);
515
+ rejectRun(new HttpError(502, "update-failed", "无法启动 pnpm,请在终端手动执行:dsh plugin --profile web add dsh-cloudq"));
516
+ });
517
+ child.on("close", (code) => {
518
+ clearTimeout(timer);
519
+ if (code === 0) resolveRun();
520
+ else rejectRun(new HttpError(502, "update-failed", `更新失败:${output.trim().slice(-200) || "pnpm 执行异常"}`));
521
+ });
522
+ });
523
+ }
524
+ /**
525
+ * Restart the DSH host after an update. A detached watcher respawns the same
526
+ * command line the moment this process exits; there is no supervisor, so the
527
+ * plugin exits itself once the watcher is armed.
528
+ */
529
+ function scheduleSelfRestart() {
530
+ if (process.platform === "win32") throw new HttpError(501, "restart-unsupported", "当前系统不支持自动重启,请手动重启 DSH 服务。");
531
+ const quote = (value) => `'${String(value).replaceAll("'", "'\\''")}'`;
532
+ const command = [
533
+ `while kill -0 ${process.pid} 2>/dev/null; do sleep 0.5; done`,
534
+ "sleep 1",
535
+ `cd ${quote(process.cwd())} && nohup ${quote(process.argv[0])} ${process.argv.slice(1).map(quote).join(" ")} >> /tmp/dsh-cloudq-restart.log 2>&1 &`
536
+ ].join("; ");
537
+ spawn("/bin/sh", ["-c", command], {
538
+ detached: true,
539
+ stdio: "ignore"
540
+ }).unref();
541
+ setTimeout(() => process.exit(0), 300).unref();
542
+ }
408
543
  /** Raw SKILL.md body. */
409
544
  function rawSkillContent() {
410
545
  return readFileSync(resolve(skillDirectory(), "SKILL.md"), "utf8");
@@ -676,6 +811,53 @@ function apply(ctx) {
676
811
  }
677
812
  }
678
813
  }));
814
+ disposers.push(ctx.webServer.register({
815
+ kind: "exact",
816
+ path: "/api/dsh-cloudq/version",
817
+ handler: async (request, response) => {
818
+ try {
819
+ assertSafeRequest(request, "GET");
820
+ const latest = await latestPackageVersion();
821
+ sendJson(response, 200, {
822
+ ok: true,
823
+ current: PACKAGE_VERSION,
824
+ latest,
825
+ outdated: latest !== null && isNewerVersion(latest, PACKAGE_VERSION)
826
+ });
827
+ } catch (error) {
828
+ sendError(response, error);
829
+ }
830
+ }
831
+ }));
832
+ disposers.push(ctx.webServer.register({
833
+ kind: "exact",
834
+ path: "/api/dsh-cloudq/update",
835
+ handler: async (request, response) => {
836
+ try {
837
+ assertSafeRequest(request, "POST");
838
+ await runProfileUpdate();
839
+ sendJson(response, 200, { ok: true });
840
+ } catch (error) {
841
+ sendError(response, error);
842
+ }
843
+ }
844
+ }));
845
+ disposers.push(ctx.webServer.register({
846
+ kind: "exact",
847
+ path: "/api/dsh-cloudq/restart",
848
+ handler: async (request, response) => {
849
+ try {
850
+ assertSafeRequest(request, "POST");
851
+ sendJson(response, 200, {
852
+ ok: true,
853
+ restarting: true
854
+ });
855
+ scheduleSelfRestart();
856
+ } catch (error) {
857
+ sendError(response, error);
858
+ }
859
+ }
860
+ }));
679
861
  disposers.push(ctx.webServer.register({
680
862
  kind: "exact",
681
863
  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.2.1",
4
4
  "description": "CloudQ integration for DeepSeek Harness with secure credential, workspace, and plugin-management surfaces",
5
5
  "license": "MIT",
6
6
  "type": "module",