@shellraining/dsh-plugin-updates 0.1.2 → 0.2.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/README.md CHANGED
@@ -27,17 +27,27 @@ dsh plugin --profile web add @shellraining/dsh-plugin-updates
27
27
 
28
28
  发布不足 24 小时的版本会标为「新发布」,pnpm 开启 `minimumReleaseAge` 时,这类版本可能装不上。如果出现 `ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED`,说明 pnpm 的策略处理发生内部错误,不代表供应链校验通过就能继续安装;插件会停止操作并保留日志,不会关闭安全策略。
29
29
 
30
- 本插件不能在页面里更新自己,因为卸载自己时界面也会一起卸载。有新版本时,它的详情页会给出对应的 `dsh plugin` 命令,退出 DSH 后在终端运行即可。
31
-
32
30
  官方列表没有给卡片开放扩展点,列表上的按钮靠 `data-plugin-group="bundles"`、`data-plugin-package` 和开关的 `role="switch"` 这几个语义属性定位,见 `src/client/anchors.ts`。DSH 改了这部分结构时,列表上的按钮会消失,详情页不受影响。
33
31
 
32
+ ## 更新本插件自己
33
+
34
+ 在页面里卸载本插件时,负责后续安装的浏览器代码也会被卸掉,所以本插件的更新交给它的宿主半侧执行。详情页有新版本时会出现「更新到 x.y.z」,确认后浏览器只发一个请求,宿主在后台依次完成:
35
+
36
+ 1. 卸载当前版本,再按精确版本安装新版本,保持启用。
37
+ 2. 新版本装不上时,装回原来的版本,并保留新版本的错误和日志路径。
38
+ 3. 卸载这一步失败时,把被取消选中的插件重新启用。
39
+
40
+ 宿主保存的是插件管理器服务本身,本插件的上下文在卸载时释放,不影响后续步骤。进度写在 profile 的 `.dsh-plugin-updates/self-update.json`,新版本加载后读取这个文件,提示一次结果。更新期间插件页上本插件的按钮会暂时消失,装好后自动回来。
41
+
42
+ 接口挂在宿主 Connection 的 `/api/dsh-plugin-updates.self-update` 上,沿用宿主的登录 cookie 和同源检查,只接受精确版本号,包名在代码里固定为本插件。确认对话框里也列出了 `dsh plugin` 命令,原版本也装不回时,可以退出 DSH 后在终端手动安装。
43
+
34
44
  ## 开发
35
45
 
36
46
  ```sh
37
47
  pnpm --filter @shellraining/dsh-plugin-updates build
38
48
  ```
39
49
 
40
- 只有浏览器半侧,源码在 `src/client/`。semver 打包进 `lib/client.js`,React、React DOM 和 `@deepseek-ai/dsh-client-ui-primitives` 由宿主提供。
50
+ 宿主半侧在 `src/host/`,只负责自更新;浏览器半侧在 `src/client/`,两边共用的接口约定在 `src/shared/`。semver 打包进 `lib/client.js`,React、React DOM 和 `@deepseek-ai/dsh-client-ui-primitives` 由宿主提供。
41
51
 
42
52
  ## 许可
43
53
 
package/cordis.patch.yml CHANGED
@@ -1,4 +1,4 @@
1
- # 插件页的「更新」标签与插件详情页的版本区块,只有浏览器半侧。
1
+ # 宿主半侧提供自更新接口;插件页的更新按钮与详情页的版本区块在浏览器半侧。
2
2
  - insert:
3
3
  - id: plugin-updates
4
4
  name: '@shellraining/dsh-plugin-updates'
package/lib/client.js CHANGED
@@ -4,7 +4,6 @@ window.__ModuleLoader__.load({
4
4
  factory: (require) => {
5
5
  var module = { exports: {} };
6
6
  var exports = module.exports;
7
- "use strict";
8
7
  var __create = Object.create;
9
8
  var __defProp = Object.defineProperty;
10
9
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -1285,13 +1284,17 @@ var ZH = {
1285
1284
  "tag.fresh": "\u65B0\u53D1\u5E03",
1286
1285
  "hint.fresh": "\u53D1\u5E03\u4E0D\u8DB3 24 \u5C0F\u65F6\uFF1Apnpm \u9ED8\u8BA4\u4E0D\u5B89\u88C5\u8FD9\u4E48\u65B0\u7684\u7248\u672C\uFF0C\u5B89\u88C5\u53EF\u80FD\u5931\u8D25",
1287
1286
  "hint.incompatible": "\u4E0E\u5F53\u524D DSH \u7248\u672C\u4E0D\u517C\u5BB9\uFF0C\u5B89\u88C5\u4F1A\u88AB\u62D2\u7EDD",
1288
- "self.title": "\u672C\u63D2\u4EF6\u4E0D\u80FD\u5728\u9875\u9762\u91CC\u66F4\u65B0\u81EA\u5DF1",
1289
- "self.body": "\u5728\u9875\u9762\u91CC\u5378\u8F7D\u81EA\u5DF1\u4F1A\u8BA9\u8FD9\u4E2A\u754C\u9762\u4E00\u8D77\u6D88\u5931\u3002\u8BF7\u5148\u5B8C\u5168\u9000\u51FA DSH\uFF0C\u5728\u7EC8\u7AEF\u8FD0\u884C\u4E0B\u9762\u7684\u547D\u4EE4\uFF0C\u518D\u91CD\u65B0\u6253\u5F00\uFF1A",
1287
+ "self.body": "\u53EF\u4EE5\u66F4\u65B0\u5230 {version}\u3002\u66F4\u65B0\u5728\u5BBF\u4E3B\u540E\u53F0\u8FDB\u884C\uFF0C\u671F\u95F4\u672C\u63D2\u4EF6\u7684\u754C\u9762\u4F1A\u6682\u65F6\u6D88\u5931",
1288
+ "self.confirmTitle": "\u628A\u672C\u63D2\u4EF6\u66F4\u65B0\u5230 {version}\uFF1F",
1289
+ "self.confirmBody": "\u5BBF\u4E3B\u4F1A\u5148\u5378\u8F7D\u5F53\u524D\u7248\u672C\uFF0C\u518D\u5B89\u88C5\u65B0\u7248\u672C\u3002\u671F\u95F4\u63D2\u4EF6\u9875\u4E0A\u7684\u66F4\u65B0\u6309\u94AE\u4F1A\u6682\u65F6\u6D88\u5931\uFF0C\u88C5\u597D\u540E\u81EA\u52A8\u56DE\u6765\uFF1B\u65B0\u7248\u672C\u88C5\u4E0D\u4E0A\u65F6\u4F1A\u88C5\u56DE\u5F53\u524D\u7248\u672C\u3002\u5982\u679C\u8FDE\u5F53\u524D\u7248\u672C\u4E5F\u88C5\u4E0D\u56DE\uFF0C\u53EF\u4EE5\u9000\u51FA DSH \u540E\u5728\u7EC8\u7AEF\u8FD0\u884C\u4E0B\u9762\u7684\u547D\u4EE4\u624B\u52A8\u5B89\u88C5\uFF1A",
1290
+ "self.cancel": "\u53D6\u6D88",
1290
1291
  "self.copy": "\u590D\u5236\u547D\u4EE4",
1291
1292
  "self.copied": "\u5DF2\u590D\u5236",
1292
1293
  "error.policy": "pnpm \u5904\u7406 minimumReleaseAge \u65F6\u53D1\u751F\u5185\u90E8\u9519\u8BEF\u3002\u8BF7\u5347\u7EA7\u5BBF\u4E3B\u9644\u5E26\u7684 pnpm\uFF0C\u6216\u7B49\u53D1\u5E03\u7B49\u5F85\u671F\u7ED3\u675F\u540E\u91CD\u8BD5\uFF1B\u5B8C\u6574\u9519\u8BEF\u89C1\u4E0B\u65B9\u65E5\u5FD7",
1293
1294
  "error.inconsistent": "\u5B89\u88C5\u58F0\u660E\u4E0E\u5B9E\u9645\u7248\u672C\u4E0D\u4E00\u81F4\uFF0C\u8BF7\u5148\u7528\u5BBF\u4E3B\u63D2\u4EF6\u7BA1\u7406\u5668\u6216 CLI \u4FEE\u590D\u5B89\u88C5\uFF0C\u518D\u91CD\u8BD5\u66F4\u65B0",
1294
1295
  "error.stateChanged": "\u63D2\u4EF6\u5B89\u88C5\u72B6\u6001\u5DF2\u53D8\u5316\uFF0C\u8BF7\u5237\u65B0\u63D2\u4EF6\u5217\u8868\u540E\u91CD\u8BD5",
1296
+ "error.interrupted": "\u5BBF\u4E3B\u5728\u66F4\u65B0\u5B8C\u6210\u524D\u9000\u51FA\u4E86\uFF0C\u8BF7\u68C0\u67E5\u63D2\u4EF6\u5217\u8868\u91CC\u7684\u7248\u672C",
1297
+ "error.selfBusy": "\u5DF2\u6709\u4E00\u4E2A\u81EA\u66F4\u65B0\u5728\u8FDB\u884C",
1295
1298
  "op.checking": "\u6B63\u5728\u6838\u5BF9 {name} \u7684\u5B89\u88C5\u72B6\u6001\u2026",
1296
1299
  "op.checkFailed": "\u65E0\u6CD5\u786E\u8BA4 {name} \u7684\u5B89\u88C5\u72B6\u6001\uFF0C\u5DF2\u505C\u6B62\u64CD\u4F5C\uFF1A{reason}",
1297
1300
  "op.current": "{name} \u5DF2\u662F {to}\uFF0C\u65E0\u9700\u91CD\u590D\u5B89\u88C5",
@@ -1301,6 +1304,8 @@ var ZH = {
1301
1304
  "op.done": "{name} \u5DF2\u5207\u6362\u5230 {to}",
1302
1305
  "op.restart": "{name} \u5DF2\u5207\u6362\u5230 {to}\uFF0C\u91CD\u542F DSH \u540E\u751F\u6548",
1303
1306
  "op.failed": "{name} \u5B89\u88C5 {to} \u5931\u8D25\uFF1A{reason}",
1307
+ "op.restored": "{name} \u5B89\u88C5 {to} \u5931\u8D25\uFF0C\u5DF2\u88C5\u56DE {from}\uFF1A{reason}",
1308
+ "op.selfFailed": "{name} \u66F4\u65B0\u5230 {to} \u5931\u8D25\uFF1A{reason}",
1304
1309
  "op.removeFailed": "{name} \u5378\u8F7D\u672A\u5B8C\u6210\uFF0C\u5DF2\u505C\u6B62\u540E\u7EED\u5B89\u88C5\uFF1A{reason}",
1305
1310
  "op.restore": "\u6062\u590D {from}",
1306
1311
  "op.dismiss": "\u5173\u95ED",
@@ -1327,13 +1332,17 @@ var EN = {
1327
1332
  "tag.fresh": "New",
1328
1333
  "hint.fresh": "Published less than 24 hours ago: pnpm does not install versions this new by default, so installing may fail",
1329
1334
  "hint.incompatible": "Not compatible with this DSH version; the installation would be refused",
1330
- "self.title": "This plugin cannot update itself from the page",
1331
- "self.body": "Uninstalling it from the page would unload this page too. Quit DSH completely, run this command in a terminal, then start DSH again:",
1335
+ "self.body": "Version {version} is available. The host updates this plugin in the background, and its controls disappear meanwhile",
1336
+ "self.confirmTitle": "Update this plugin to {version}?",
1337
+ "self.confirmBody": "The host uninstalls the current version, then installs the new one. The update controls on the Plugins page disappear meanwhile and come back once it is installed; if the new version cannot be installed, the current one is put back. If even that fails, quit DSH and run this command in a terminal to install it by hand:",
1338
+ "self.cancel": "Cancel",
1332
1339
  "self.copy": "Copy command",
1333
1340
  "self.copied": "Copied",
1334
1341
  "error.policy": "pnpm hit an internal error while handling minimumReleaseAge. Upgrade the pnpm bundled with the host, or retry after the release-age waiting period; see the full error below",
1335
1342
  "error.inconsistent": "The declared and installed versions differ. Repair the installation with the host plugin manager or CLI before retrying",
1336
1343
  "error.stateChanged": "The installed state has changed. Refresh the plugin list and retry",
1344
+ "error.interrupted": "The host stopped before the update finished; check the version in the plugin list",
1345
+ "error.selfBusy": "A self-update is already running",
1337
1346
  "op.checking": "Checking the installed version of {name}\u2026",
1338
1347
  "op.checkFailed": "Could not confirm the installed state of {name}; stopped: {reason}",
1339
1348
  "op.current": "{name} is already at {to}; no reinstall needed",
@@ -1343,6 +1352,8 @@ var EN = {
1343
1352
  "op.done": "{name} is now at {to}",
1344
1353
  "op.restart": "{name} is now at {to}; restart DSH to apply it",
1345
1354
  "op.failed": "Could not install {name} {to}: {reason}",
1355
+ "op.restored": "Could not install {name} {to}; {from} was put back: {reason}",
1356
+ "op.selfFailed": "Could not update {name} to {to}: {reason}",
1346
1357
  "op.removeFailed": "Uninstalling {name} did not finish; installation was not started: {reason}",
1347
1358
  "op.restore": "Restore {from}",
1348
1359
  "op.dismiss": "Dismiss",
@@ -1356,10 +1367,23 @@ var import_rcompare = __toESM(require_rcompare(), 1);
1356
1367
  var import_satisfies = __toESM(require_satisfies(), 1);
1357
1368
  var import_valid = __toESM(require_valid(), 1);
1358
1369
  var import_valid2 = __toESM(require_valid2(), 1);
1370
+
1371
+ // src/shared/protocol.ts
1359
1372
  var SELF = "@shellraining/dsh-plugin-updates";
1373
+ var SELF_UPDATE_PATH = "/api/dsh-plugin-updates.self-update";
1374
+ function isRunning(status) {
1375
+ return status?.phase === "removing" || status?.phase === "installing" || status?.phase === "restoring";
1376
+ }
1377
+ function errorLine(text) {
1378
+ return text.split("\n").map((line) => line.trim()).filter(Boolean).find((line) => /\bERR[_!\s]|\berror\b|\bfatal\b/i.test(line));
1379
+ }
1380
+
1381
+ // src/client/model.ts
1360
1382
  var DEFAULT_REGISTRY = "https://registry.npmjs.org/";
1361
1383
  var CACHE_MS = 10 * 6e4;
1362
1384
  var FRESH_MS = 24 * 60 * 6e4;
1385
+ var POLL_MS = 1e3;
1386
+ var SEEN_KEY = `${SELF}:self-update-seen`;
1363
1387
  function updateTarget(candidate, lookup) {
1364
1388
  if (lookup?.status !== "ready") return void 0;
1365
1389
  const best = lookup.versions.find((info) => info.compatible && !info.prerelease && !info.deprecated);
@@ -1378,9 +1402,48 @@ function failureReason(result) {
1378
1402
  if (!result.ok) return result.error.message;
1379
1403
  const { error, packageResult, application } = result.value;
1380
1404
  const diagnostic = error?.diagnostic?.trim();
1381
- const lines = (diagnostic || packageResult?.output || "").split("\n").map((line) => line.trim()).filter(Boolean);
1382
- const failure = lines.find((line) => /\bERR[_!\s]|\berror\b|\bfatal\b/i.test(line));
1383
- return failure ?? error?.code ?? diagnostic ?? application;
1405
+ const lines = diagnostic || packageResult?.output || "";
1406
+ return errorLine(lines) ?? error?.code ?? diagnostic ?? application;
1407
+ }
1408
+ async function selfRequest(init) {
1409
+ const response = await fetch(SELF_UPDATE_PATH.slice(1), { credentials: "same-origin", cache: "no-store", ...init });
1410
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
1411
+ const body = record(await response.json());
1412
+ if (body["ok"] === true) {
1413
+ const status = record(body["status"]);
1414
+ const valid2 = typeof status["id"] === "string" && typeof status["from"] === "string" && typeof status["to"] === "string" && typeof status["phase"] === "string";
1415
+ return { ok: true, status: valid2 ? status : null };
1416
+ }
1417
+ const code = body["code"];
1418
+ return { ok: false, code: code === "invalid-version" || code === "busy" || code === "not-npm" || code === "current" ? code : "internal", ...typeof body["message"] === "string" ? { message: body["message"] } : {} };
1419
+ }
1420
+ function selfOperation(status, toast) {
1421
+ const phase = status.phase === "restoring" ? "installing" : status.phase;
1422
+ return {
1423
+ name: SELF,
1424
+ from: status.from,
1425
+ to: status.phase === "restoring" ? status.from : status.to,
1426
+ enabled: true,
1427
+ phase,
1428
+ restoring: status.phase === "restoring",
1429
+ ...status.reason === void 0 ? {} : { reason: status.reason },
1430
+ ...status.output === void 0 ? {} : { output: status.output },
1431
+ ...status.logPath === void 0 ? {} : { logPath: status.logPath },
1432
+ ...toast ? { toast: Date.now() } : {}
1433
+ };
1434
+ }
1435
+ function seen(id) {
1436
+ try {
1437
+ return localStorage.getItem(SEEN_KEY) === id;
1438
+ } catch {
1439
+ return false;
1440
+ }
1441
+ }
1442
+ function markSeen(id) {
1443
+ try {
1444
+ localStorage.setItem(SEEN_KEY, id);
1445
+ } catch {
1446
+ }
1384
1447
  }
1385
1448
  function npmInstalled(bundle) {
1386
1449
  const prefix = `${bundle.name}@`;
@@ -1412,6 +1475,7 @@ var UpdatesStore = class {
1412
1475
  disposed = false;
1413
1476
  registries;
1414
1477
  exemptions = {};
1478
+ pollTimer;
1415
1479
  getSnapshot = () => this.state;
1416
1480
  subscribe = (listener) => {
1417
1481
  this.listeners.add(listener);
@@ -1421,8 +1485,59 @@ var UpdatesStore = class {
1421
1485
  };
1422
1486
  dispose() {
1423
1487
  this.disposed = true;
1488
+ if (this.pollTimer !== void 0) clearTimeout(this.pollTimer);
1424
1489
  this.listeners.clear();
1425
1490
  }
1491
+ /** Read the last self-update once per load: report a result this page has not shown, follow one still running. */
1492
+ watchSelf = async () => {
1493
+ let response;
1494
+ try {
1495
+ response = await selfRequest();
1496
+ } catch {
1497
+ if (!this.disposed && this.state.operation?.name === SELF && isBusy(this.state.operation)) {
1498
+ this.pollTimer = setTimeout(() => {
1499
+ void this.watchSelf();
1500
+ }, POLL_MS);
1501
+ }
1502
+ return;
1503
+ }
1504
+ if (this.disposed || !response.ok || response.status === null) return;
1505
+ const status = response.status;
1506
+ if (isRunning(status)) {
1507
+ this.patch({ operation: selfOperation(status, false) });
1508
+ this.pollTimer = setTimeout(() => {
1509
+ void this.watchSelf();
1510
+ }, POLL_MS);
1511
+ return;
1512
+ }
1513
+ if (seen(status.id)) return;
1514
+ markSeen(status.id);
1515
+ this.patch({ operation: selfOperation(status, true) });
1516
+ };
1517
+ /** Ask the host half to update this plugin; the page loses this plugin while it runs. */
1518
+ updateSelf = async (version) => {
1519
+ if (this.disposed || isBusy(this.state.operation) || (0, import_valid.default)(version) === null) return false;
1520
+ const from = this.state.candidates.find((item) => item.name === SELF)?.version ?? "";
1521
+ const base = { name: SELF, from, to: version, enabled: true, restoring: false };
1522
+ this.patch({ operation: { ...base, phase: "checking" } });
1523
+ let response;
1524
+ try {
1525
+ response = await selfRequest({ method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ version }) });
1526
+ } catch (error) {
1527
+ response = { ok: false, code: "internal", message: errorText(error) };
1528
+ }
1529
+ if (this.disposed) return false;
1530
+ if (!response.ok) {
1531
+ const phase = response.code === "current" ? "current" : "check-failed";
1532
+ this.patch({ operation: { ...base, phase, reason: response.message ?? `self-${response.code}`, toast: Date.now() } });
1533
+ return false;
1534
+ }
1535
+ if (response.status !== null) this.patch({ operation: selfOperation(response.status, false) });
1536
+ this.pollTimer = setTimeout(() => {
1537
+ void this.watchSelf();
1538
+ }, POLL_MS);
1539
+ return true;
1540
+ };
1426
1541
  patch(next) {
1427
1542
  if (this.disposed) return;
1428
1543
  this.state = { ...this.state, ...next };
@@ -1729,23 +1844,48 @@ function selfCommand(version) {
1729
1844
  function dateText(stamp) {
1730
1845
  return stamp === void 0 ? "" : new Date(stamp).toLocaleDateString();
1731
1846
  }
1732
- function SelfNotice({ t, version }) {
1847
+ function SelfNotice({ t, version, busy, onUpdate }) {
1848
+ const [open, setOpen] = (0, import_react.useState)(false);
1733
1849
  const [copied, setCopied] = (0, import_react.useState)(false);
1734
1850
  const command = selfCommand(version);
1851
+ const close = () => {
1852
+ setOpen(false);
1853
+ };
1735
1854
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dpu-banner", children: [
1736
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("strong", { children: t("self.title") }),
1737
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("self.body") }),
1738
1855
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dpu-banner-row", children: [
1739
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("pre", { className: "dpu-pre dpu-command", children: command }),
1740
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { size: "sm", variant: "outline", onClick: () => {
1741
- void (0, import_dsh_client_ui_primitives.writeClipboard)(command).then(setCopied);
1742
- }, children: copied ? t("self.copied") : t("self.copy") })
1743
- ] })
1856
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { children: t("self.body", { version }) }),
1857
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { size: "sm", variant: "primary", disabled: busy, onClick: () => {
1858
+ setOpen(true);
1859
+ }, children: t("updateTo", { version }) })
1860
+ ] }),
1861
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1862
+ import_dsh_client_ui_primitives.Modal,
1863
+ {
1864
+ open,
1865
+ onClose: close,
1866
+ title: t("self.confirmTitle", { version }),
1867
+ description: t("self.confirmBody"),
1868
+ closeLabel: t("op.dismiss"),
1869
+ footer: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
1870
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { size: "sm", variant: "ghost", onClick: close, children: t("self.cancel") }),
1871
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { size: "sm", variant: "primary", "data-modal-autofocus": "", onClick: () => {
1872
+ close();
1873
+ onUpdate();
1874
+ }, children: t("updateTo", { version }) })
1875
+ ] }),
1876
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dpu-banner-row", children: [
1877
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("pre", { className: "dpu-pre dpu-command", children: command }),
1878
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_dsh_client_ui_primitives.Button, { size: "sm", variant: "outline", onClick: () => {
1879
+ void (0, import_dsh_client_ui_primitives.writeClipboard)(command).then(setCopied);
1880
+ }, children: copied ? t("self.copied") : t("self.copy") })
1881
+ ] })
1882
+ }
1883
+ )
1744
1884
  ] });
1745
1885
  }
1746
1886
  function operationText(t, operation) {
1747
1887
  const raw = operation.reason ?? "";
1748
- const reason = raw.includes("ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED") ? t("error.policy") : raw === "installation-inconsistent" ? t("error.inconsistent") : raw === "plugin-state-changed" ? t("error.stateChanged") : raw;
1888
+ const reason = raw.includes("ERR_PNPM_RESOLUTION_POLICY_VIOLATIONS_UNHANDLED") ? t("error.policy") : raw === "installation-inconsistent" ? t("error.inconsistent") : raw === "plugin-state-changed" ? t("error.stateChanged") : raw === "interrupted" ? t("error.interrupted") : raw === "self-busy" ? t("error.selfBusy") : raw === "self-not-npm" ? t("error.stateChanged") : raw;
1749
1889
  const params = { name: operation.name, from: operation.from, to: operation.to, reason };
1750
1890
  switch (operation.phase) {
1751
1891
  case "checking":
@@ -1762,16 +1902,21 @@ function operationText(t, operation) {
1762
1902
  return t("op.done", params);
1763
1903
  case "restart":
1764
1904
  return t("op.restart", params);
1905
+ case "restored":
1906
+ return t("op.restored", params);
1765
1907
  case "failed":
1766
- return t("op.failed", params);
1908
+ return t(operation.name === SELF ? "op.selfFailed" : "op.failed", params);
1767
1909
  case "remove-failed":
1768
1910
  return t("op.removeFailed", params);
1769
1911
  }
1770
1912
  }
1913
+ function isFailure(operation) {
1914
+ return operation.phase === "failed" || operation.phase === "remove-failed" || operation.phase === "check-failed" || operation.phase === "restored";
1915
+ }
1771
1916
  function OperationToast({ t, useUpdates, actions }) {
1772
1917
  const operation = useUpdates((state) => state.operation);
1773
1918
  if (operation?.toast === void 0) return null;
1774
- const failed = operation.phase === "failed" || operation.phase === "remove-failed" || operation.phase === "check-failed";
1919
+ const failed = isFailure(operation);
1775
1920
  const restorable = operation.phase === "failed" && operation.recoverable;
1776
1921
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1777
1922
  import_dsh_client_ui_primitives.Toast,
@@ -1790,7 +1935,7 @@ function OperationToast({ t, useUpdates, actions }) {
1790
1935
  function OperationBanner({ t, operation, actions }) {
1791
1936
  const params = { from: operation.from };
1792
1937
  const busy = isBusy(operation);
1793
- const failed = operation.phase === "failed" || operation.phase === "remove-failed" || operation.phase === "check-failed";
1938
+ const failed = isFailure(operation);
1794
1939
  const text = operationText(t, operation);
1795
1940
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dpu-banner", role: failed ? "alert" : "status", ...failed ? { "data-tone": "error" } : {}, children: [
1796
1941
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { className: "dpu-banner-row", children: [
@@ -1882,7 +2027,9 @@ function DetailSection(props) {
1882
2027
  ] })
1883
2028
  ] }),
1884
2029
  operation?.name === candidate.name ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(OperationBanner, { t, operation, actions }) : null,
1885
- self && target !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SelfNotice, { t, version: target }) : null,
2030
+ self && target !== void 0 ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SelfNotice, { t, version: target, busy: isBusy(operation), onUpdate: () => {
2031
+ void actions.updateSelf(target);
2032
+ } }) : null,
1886
2033
  body
1887
2034
  ] });
1888
2035
  }
@@ -1948,12 +2095,14 @@ function apply(ctx) {
1948
2095
  for (const off of offs) off();
1949
2096
  };
1950
2097
  }, `${name}: host invalidations`);
2098
+ void store.watchSelf();
1951
2099
  const t = ctx.locale.bind(LOCALE_NS);
1952
2100
  const face = {
1953
2101
  hooks: { updates: store },
1954
2102
  actions: {
1955
2103
  ensure: store.ensure,
1956
2104
  install: store.install,
2105
+ updateSelf: store.updateSelf,
1957
2106
  updateAll: store.updateAll,
1958
2107
  restore: store.restore,
1959
2108
  dismiss: store.dismiss,
package/lib/index.js CHANGED
@@ -1,10 +1,161 @@
1
1
  // Generated by `pnpm run build`; edit src/, not this file.
2
2
 
3
- // src/index.ts
4
- var name = "@shellraining/dsh-plugin-updates";
5
- function apply() {
3
+ // src/shared/protocol.ts
4
+ var SELF = "@shellraining/dsh-plugin-updates";
5
+ var SELF_UPDATE_PATH = "/api/dsh-plugin-updates.self-update";
6
+ function isRunning(status) {
7
+ return status?.phase === "removing" || status?.phase === "installing" || status?.phase === "restoring";
8
+ }
9
+ function errorLine(text2) {
10
+ return text2.split("\n").map((line) => line.trim()).filter(Boolean).find((line) => /\bERR[_!\s]|\berror\b|\bfatal\b/i.test(line));
11
+ }
12
+
13
+ // src/host/self-update.ts
14
+ import { randomUUID } from "node:crypto";
15
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
16
+ import { join } from "node:path";
17
+ var STALE_MS = 15 * 6e4;
18
+ var VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
19
+ function record(value) {
20
+ return typeof value === "object" && value !== null ? value : {};
21
+ }
22
+ function text(error) {
23
+ return error instanceof Error ? error.message : String(error);
24
+ }
25
+ function failed(result) {
26
+ return result.application === "failed" || result.application === "cancelled";
27
+ }
28
+ function reasonOf(result) {
29
+ const output = result.error?.diagnostic ?? result.packageResult?.output ?? "";
30
+ return errorLine(output) ?? result.error?.code ?? (output.trim() || result.application);
31
+ }
32
+ function details(result) {
33
+ const output = (result.packageResult?.output.trim() || result.error?.diagnostic)?.slice(-8e3);
34
+ const logPath = result.packageResult?.logPath;
35
+ return { ...output ? { output } : {}, ...logPath ? { logPath } : {} };
36
+ }
37
+ function npmVersion(bundle) {
38
+ if (bundle === void 0 || !bundle.installed || !bundle.removable) return void 0;
39
+ if (bundle.source?.startsWith(`${SELF}@`) !== true || bundle.version === void 0) return void 0;
40
+ return VERSION.test(bundle.version) ? bundle.version : void 0;
41
+ }
42
+ var SelfUpdater = class {
43
+ constructor(profileDir, manager, logger) {
44
+ this.manager = manager;
45
+ this.logger = logger;
46
+ this.file = join(profileDir, ".dsh-plugin-updates", "self-update.json");
47
+ }
48
+ manager;
49
+ logger;
50
+ file;
51
+ running = false;
52
+ async status() {
53
+ let stored;
54
+ try {
55
+ stored = record(JSON.parse(await readFile(this.file, "utf8")));
56
+ } catch {
57
+ return null;
58
+ }
59
+ const { id, from, to, phase, startedAt } = stored;
60
+ if (typeof id !== "string" || typeof from !== "string" || typeof to !== "string" || typeof phase !== "string") return null;
61
+ const status = stored;
62
+ if (isRunning(status) && !this.running && typeof startedAt === "number" && Date.now() - startedAt > STALE_MS) {
63
+ return { ...status, phase: "failed", reason: "interrupted" };
64
+ }
65
+ return status;
66
+ }
67
+ async start(version) {
68
+ if (typeof version !== "string" || !VERSION.test(version)) return { ok: false, code: "invalid-version" };
69
+ if (this.running || isRunning(await this.status())) return { ok: false, code: "busy" };
70
+ const from = npmVersion((await this.manager.listBundles()).find((bundle) => bundle.name === SELF));
71
+ if (from === void 0) return { ok: false, code: "not-npm" };
72
+ if (from === version) return { ok: false, code: "current" };
73
+ const id = randomUUID();
74
+ const startedAt = Date.now();
75
+ const base = { id, from, to: version, startedAt };
76
+ this.running = true;
77
+ await this.save({ ...base, phase: "removing" });
78
+ void this.run(base).finally(() => {
79
+ this.running = false;
80
+ });
81
+ return { ok: true, status: { id, from, to: version, phase: "removing" } };
82
+ }
83
+ async run(base) {
84
+ const step = (phase, extra = {}) => this.save({ ...base, ...extra, phase, ...isRunning({ ...base, phase }) ? {} : { finishedAt: Date.now() } });
85
+ try {
86
+ const removed = await this.manager.removeBundle(SELF);
87
+ if (failed(removed)) {
88
+ await this.reselect();
89
+ await step("failed", { reason: reasonOf(removed), ...details(removed) });
90
+ return;
91
+ }
92
+ await step("installing");
93
+ const installed = await this.manager.installBundle(`${SELF}@${base.to}`, { enabled: true, saveExact: true });
94
+ if (!failed(installed)) {
95
+ await step(installed.application === "restart-required" ? "restart" : "done", { to: installed.version ?? base.to });
96
+ return;
97
+ }
98
+ const failure = { reason: reasonOf(installed), ...details(installed) };
99
+ await step("restoring", failure);
100
+ const restored = await this.manager.installBundle(`${SELF}@${base.from}`, { enabled: true, saveExact: true });
101
+ if (failed(restored)) {
102
+ this.logger.warn("[plugin-updates] could not restore %s@%s: %s", SELF, base.from, reasonOf(restored));
103
+ await step("failed", { ...failure, reason: `${failure.reason}
104
+ ${reasonOf(restored)}` });
105
+ return;
106
+ }
107
+ await step("restored", failure);
108
+ } catch (error) {
109
+ this.logger.warn("[plugin-updates] self-update stopped: %s", text(error));
110
+ await step("failed", { reason: text(error) }).catch(() => void 0);
111
+ }
112
+ }
113
+ async reselect() {
114
+ const bundle = (await this.manager.listBundles()).find((item) => item.name === SELF);
115
+ if (bundle?.installed === true && !bundle.enabled) await this.manager.setBundleEnabled(SELF, true);
116
+ }
117
+ async save(status) {
118
+ const dir = join(this.file, "..");
119
+ await mkdir(dir, { recursive: true, mode: 448 });
120
+ const temp = `${this.file}.${process.pid}.tmp`;
121
+ await writeFile(temp, `${JSON.stringify(status, null, 2)}
122
+ `, { mode: 384 });
123
+ await rename(temp, this.file);
124
+ }
125
+ };
126
+
127
+ // src/host/index.ts
128
+ var name = SELF;
129
+ var inject = ["connection", "pluginManager", "profileContext"];
130
+ var ORIGINAL = /* @__PURE__ */ Symbol.for("cordis.original");
131
+ function unwrap(service) {
132
+ const original = Reflect.get(service, ORIGINAL);
133
+ return typeof original === "object" && original !== null ? original : service;
134
+ }
135
+ function json(body) {
136
+ return new Response(JSON.stringify(body), { headers: { "content-type": "application/json", "cache-control": "no-store" } });
137
+ }
138
+ function apply(ctx) {
139
+ const updater = new SelfUpdater(ctx.profileContext.dir, unwrap(ctx.pluginManager), ctx.logger);
140
+ ctx.connection.fetch.register({
141
+ path: SELF_UPDATE_PATH,
142
+ methods: ["GET", "POST"],
143
+ requestBody: "buffered",
144
+ fetch: async (request) => {
145
+ try {
146
+ if (request.method === "GET") return json({ ok: true, status: await updater.status() });
147
+ const body = await request.json().catch(() => null);
148
+ const version = typeof body === "object" && body !== null ? Reflect.get(body, "version") : void 0;
149
+ return json(await updater.start(version));
150
+ } catch (error) {
151
+ ctx.logger.warn("[plugin-updates] self-update route failed: %s", error instanceof Error ? error.message : String(error));
152
+ return json({ ok: false, code: "internal", message: error instanceof Error ? error.message : String(error) });
153
+ }
154
+ }
155
+ });
6
156
  }
7
157
  export {
8
158
  apply,
159
+ inject,
9
160
  name
10
161
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shellraining/dsh-plugin-updates",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "在插件页检查 npm 来源插件的新版本,一键更新或安装指定版本",
6
6
  "main": "./lib/index.js",
@@ -19,6 +19,7 @@
19
19
  },
20
20
  "devDependencies": {
21
21
  "@deepseek-ai/dsh-client-ui-primitives": "0.2.1-rc.3",
22
+ "@types/node": "24.19.2",
22
23
  "@types/react": "18.3.31",
23
24
  "@types/react-dom": "18.3.7",
24
25
  "@types/semver": "7.8.0",
@@ -57,7 +58,7 @@
57
58
  "access": "public"
58
59
  },
59
60
  "scripts": {
60
- "typecheck": "tsc -p tsconfig.json",
61
+ "typecheck": "tsc -p tsconfig.host.json && tsc -p tsconfig.client.json",
61
62
  "build": "pnpm run typecheck && node build.mjs"
62
63
  }
63
64
  }