@linxin666/dsh-remote-web-ui 0.1.9 → 0.1.11

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 (52) hide show
  1. package/README.md +17 -6
  2. package/lib/client.js +442 -17
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +406 -17
  5. package/lib/types/client/PluginSettingsCard.d.ts +1 -1
  6. package/lib/types/client/PluginSettingsCard.d.ts.map +1 -1
  7. package/lib/types/client/PluginSettingsCard.js +10 -1
  8. package/lib/types/client/RemoteEntry.d.ts.map +1 -1
  9. package/lib/types/client/RemoteEntry.js +9 -7
  10. package/lib/types/client/UpdateEntry.d.ts +14 -0
  11. package/lib/types/client/UpdateEntry.d.ts.map +1 -0
  12. package/lib/types/client/UpdateEntry.js +68 -0
  13. package/lib/types/client/UpdatePanel.d.ts +40 -0
  14. package/lib/types/client/UpdatePanel.d.ts.map +1 -0
  15. package/lib/types/client/UpdatePanel.js +82 -0
  16. package/lib/types/client/index.d.ts +2 -0
  17. package/lib/types/client/index.d.ts.map +1 -1
  18. package/lib/types/client/locales.d.ts +58 -0
  19. package/lib/types/client/locales.d.ts.map +1 -1
  20. package/lib/types/client/locales.js +60 -0
  21. package/lib/types/client/settings-form.d.ts +9 -1
  22. package/lib/types/client/settings-form.d.ts.map +1 -1
  23. package/lib/types/client/settings-form.js +2 -1
  24. package/lib/types/client/update-api.d.ts +19 -0
  25. package/lib/types/client/update-api.d.ts.map +1 -0
  26. package/lib/types/client/update-api.js +27 -0
  27. package/lib/types/index.d.ts.map +1 -1
  28. package/lib/types/index.js +45 -1
  29. package/lib/types/routes.d.ts +15 -0
  30. package/lib/types/routes.d.ts.map +1 -1
  31. package/lib/types/routes.js +1 -1
  32. package/lib/types/update-routes.d.ts +30 -0
  33. package/lib/types/update-routes.d.ts.map +1 -0
  34. package/lib/types/update-routes.js +52 -0
  35. package/lib/types/update.d.ts +191 -0
  36. package/lib/types/update.d.ts.map +1 -0
  37. package/lib/types/update.js +333 -0
  38. package/package.json +1 -1
  39. package/src/client/PluginSettingsCard.tsx +31 -1
  40. package/src/client/RemoteEntry.tsx +12 -7
  41. package/src/client/UpdateEntry.tsx +96 -0
  42. package/src/client/UpdatePanel.tsx +203 -0
  43. package/src/client/index.ts +2 -0
  44. package/src/client/locales.ts +60 -0
  45. package/src/client/remote.module.css +109 -0
  46. package/src/client/settings-card.module.css +7 -0
  47. package/src/client/settings-form.ts +11 -2
  48. package/src/client/update-api.ts +29 -0
  49. package/src/index.ts +51 -1
  50. package/src/routes.ts +1 -1
  51. package/src/update-routes.ts +71 -0
  52. package/src/update.ts +435 -0
package/lib/index.js CHANGED
@@ -1,13 +1,16 @@
1
+ import { createRequire } from "node:module";
1
2
  import { setInterval as setInterval$1 } from "node:timers";
2
3
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
3
4
  import z from "schemastery";
4
5
  import { randomBytes } from "node:crypto";
5
6
  import { readFile } from "node:fs/promises";
6
- import { existsSync } from "node:fs";
7
+ import { existsSync, readFileSync } from "node:fs";
7
8
  import { fileURLToPath } from "node:url";
8
9
  import { RpcId } from "@deepseek-ai/dsh-host-apiproxy/api/rpc";
9
10
  import { networkInterfaces } from "node:os";
10
11
  import { Tunnel, bin, install } from "cloudflared";
12
+ import { dirname, join } from "node:path";
13
+ import { spawn } from "node:child_process";
11
14
  //#region src/pairing.ts
12
15
  /**
13
16
  * Pairing state machine: one active one-time token, a device-session table,
@@ -439,7 +442,7 @@ const PAIR_PATHS = {
439
442
  events: "/api/pair/events"
440
443
  };
441
444
  /** One JSON response. */
442
- function writeJson(res, status, body) {
445
+ function writeJson$1(res, status, body) {
443
446
  const payload = JSON.stringify(body);
444
447
  res.writeHead(status, {
445
448
  "content-type": "application/json; charset=utf-8",
@@ -555,7 +558,7 @@ function makeRoutes(deps) {
555
558
  const handleIssue = async (req, res) => {
556
559
  if (!requireMethod(req, res, "POST")) return;
557
560
  if (!loopbackFence(req)) {
558
- writeJson(res, 403, {
561
+ writeJson$1(res, 403, {
559
562
  ok: false,
560
563
  code: "forbidden"
561
564
  });
@@ -568,7 +571,7 @@ function makeRoutes(deps) {
568
571
  const { token, expiresAt } = service.issue(workspaceId, address);
569
572
  const base = address === void 0 ? service.publicBaseUrl ?? service.lanBaseUrl : service.lanBaseUrlFor(address);
570
573
  if (base === void 0) throw new Error("remote-web-ui: base unavailable");
571
- writeJson(res, 200, {
574
+ writeJson$1(res, 200, {
572
575
  ok: true,
573
576
  url: `${base}/?pair=${token}${workspaceId === void 0 ? "" : `&workspace=${encodeURIComponent(workspaceId)}`}`,
574
577
  token,
@@ -578,7 +581,7 @@ function makeRoutes(deps) {
578
581
  });
579
582
  } catch (error) {
580
583
  const unknownAddress = error instanceof UnknownLanAddressError;
581
- writeJson(res, unknownAddress ? 400 : 409, {
584
+ writeJson$1(res, unknownAddress ? 400 : 409, {
582
585
  ok: false,
583
586
  code: unknownAddress ? "unknown-address" : "lan-required"
584
587
  });
@@ -587,14 +590,14 @@ function makeRoutes(deps) {
587
590
  const handleAccept = async (req, res) => {
588
591
  if (!requireMethod(req, res, "POST")) return;
589
592
  if (!lanFence(req)) {
590
- writeJson(res, 403, {
593
+ writeJson$1(res, 403, {
591
594
  ok: false,
592
595
  code: "forbidden"
593
596
  });
594
597
  return;
595
598
  }
596
599
  if (rateLimitAccept(req)) {
597
- writeJson(res, 429, {
600
+ writeJson$1(res, 429, {
598
601
  ok: false,
599
602
  code: "rate-limited"
600
603
  });
@@ -604,7 +607,7 @@ function makeRoutes(deps) {
604
607
  const token = typeof body?.token === "string" ? body.token : "";
605
608
  const result = service.accept(token);
606
609
  if (!result.ok) {
607
- writeJson(res, result.code === "used" ? 409 : 404, {
610
+ writeJson$1(res, result.code === "used" ? 409 : 404, {
608
611
  ok: false,
609
612
  code: result.code
610
613
  });
@@ -622,7 +625,7 @@ function makeRoutes(deps) {
622
625
  const handleStop = async (req, res) => {
623
626
  if (!requireMethod(req, res, "POST")) return;
624
627
  if (!loopbackFence(req)) {
625
- writeJson(res, 403, {
628
+ writeJson$1(res, 403, {
626
629
  ok: false,
627
630
  code: "forbidden"
628
631
  });
@@ -630,12 +633,12 @@ function makeRoutes(deps) {
630
633
  }
631
634
  await readJsonBody$1(req);
632
635
  service.stop();
633
- writeJson(res, 200, { ok: true });
636
+ writeJson$1(res, 200, { ok: true });
634
637
  };
635
638
  const handleHeartbeat = async (req, res) => {
636
639
  if (!requireMethod(req, res, "POST")) return;
637
640
  if (!lanFence(req)) {
638
- writeJson(res, 403, {
641
+ writeJson$1(res, 403, {
639
642
  ok: false,
640
643
  code: "forbidden"
641
644
  });
@@ -644,25 +647,25 @@ function makeRoutes(deps) {
644
647
  await readJsonBody$1(req);
645
648
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
646
649
  if (deviceId === void 0 || !service.heartbeat(deviceId)) {
647
- writeJson(res, 401, {
650
+ writeJson$1(res, 401, {
648
651
  ok: false,
649
652
  code: "unpaired"
650
653
  });
651
654
  return;
652
655
  }
653
- writeJson(res, 200, { ok: true });
656
+ writeJson$1(res, 200, { ok: true });
654
657
  };
655
658
  const handleStatus = async (req, res) => {
656
659
  if (!requireMethod(req, res, "GET")) return;
657
660
  if (!lanFence(req)) {
658
- writeJson(res, 403, {
661
+ writeJson$1(res, 403, {
659
662
  ok: false,
660
663
  code: "forbidden"
661
664
  });
662
665
  return;
663
666
  }
664
667
  const deviceId = readCookie(req.headers.cookie, service.config.cookieName);
665
- writeJson(res, 200, {
668
+ writeJson$1(res, 200, {
666
669
  ok: true,
667
670
  paired: deviceId !== void 0 && service.hasDevice(deviceId),
668
671
  ...service.snapshot()
@@ -671,7 +674,7 @@ function makeRoutes(deps) {
671
674
  const handleEvents = (req, res) => {
672
675
  if (!requireMethod(req, res, "GET")) return;
673
676
  if (!loopbackFence(req)) {
674
- writeJson(res, 403, {
677
+ writeJson$1(res, 403, {
675
678
  ok: false,
676
679
  code: "forbidden"
677
680
  });
@@ -1261,6 +1264,358 @@ var TunnelManager = class {
1261
1264
  } catch {}
1262
1265
  }
1263
1266
  };
1267
+ /** The aggregate package that is the canonical update entry point. */
1268
+ const AGGREGATE_PACKAGE = "@linxin666/dsh-web-ui-all";
1269
+ /** Fallback anchor: this plugin's own package when the aggregate is absent. */
1270
+ const SELF_PACKAGE = "@linxin666/dsh-remote-web-ui";
1271
+ /** A profile manifest `name` prefix (e.g. `dsh-profile-web`). */
1272
+ const PROFILE_NAME_PREFIX = "dsh-profile-";
1273
+ /** How many ancestor directories a profile search walks before giving up. */
1274
+ const PROFILE_WALK_DEPTH = 12;
1275
+ /**
1276
+ * Parse a semantic version string (leading `v` tolerated, build metadata
1277
+ * ignored). Returns undefined for unparseable input.
1278
+ * @param value - the version string, e.g. `0.1.10` or `0.1.11-rc.1`.
1279
+ * @returns the parsed parts, or undefined.
1280
+ */
1281
+ function parseSemver(value) {
1282
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(value.trim());
1283
+ if (match === null) return void 0;
1284
+ return {
1285
+ major: Number(match[1]),
1286
+ minor: Number(match[2]),
1287
+ patch: Number(match[3]),
1288
+ prerelease: match[4] === void 0 ? [] : match[4].split(".")
1289
+ };
1290
+ }
1291
+ /**
1292
+ * Compare two semantic versions per the semver precedence rules (a release
1293
+ * outranks any of its prereleases; numeric prerelease identifiers compare
1294
+ * numerically and sort below alphanumeric ones). An unparseable version sorts
1295
+ * below every parseable one; two unparseable versions compare equal.
1296
+ * @param a - first version.
1297
+ * @param b - second version.
1298
+ * @returns negative when a < b, 0 when equal, positive when a > b.
1299
+ */
1300
+ function compareVersions(a, b) {
1301
+ const pa = parseSemver(a);
1302
+ const pb = parseSemver(b);
1303
+ if (pa === void 0 && pb === void 0) return 0;
1304
+ if (pa === void 0) return -1;
1305
+ if (pb === void 0) return 1;
1306
+ for (const key of [
1307
+ "major",
1308
+ "minor",
1309
+ "patch"
1310
+ ]) if (pa[key] !== pb[key]) return pa[key] < pb[key] ? -1 : 1;
1311
+ if (pa.prerelease.length === 0 && pb.prerelease.length === 0) return 0;
1312
+ if (pa.prerelease.length === 0) return 1;
1313
+ if (pb.prerelease.length === 0) return -1;
1314
+ for (let index = 0; index < Math.max(pa.prerelease.length, pb.prerelease.length); index++) {
1315
+ const ra = pa.prerelease[index];
1316
+ const rb = pb.prerelease[index];
1317
+ if (ra === void 0) return -1;
1318
+ if (rb === void 0) return 1;
1319
+ if (ra === rb) continue;
1320
+ const numericA = /^\d+$/.test(ra);
1321
+ const numericB = /^\d+$/.test(rb);
1322
+ if (numericA && numericB) return Number(ra) < Number(rb) ? -1 : 1;
1323
+ if (numericA) return -1;
1324
+ if (numericB) return 1;
1325
+ return ra < rb ? -1 : 1;
1326
+ }
1327
+ return 0;
1328
+ }
1329
+ /** Read a package.json at a path, tolerating any parse/IO failure. */
1330
+ function readManifest(path) {
1331
+ try {
1332
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
1333
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
1334
+ } catch {
1335
+ return;
1336
+ }
1337
+ }
1338
+ /**
1339
+ * Locate the owning dsh profile by walking up from an installed package's
1340
+ * manifest until a manifest named `dsh-profile-*` appears (the profile
1341
+ * directory is the first ancestor whose package.json carries that name).
1342
+ * @param anchorManifestPath - absolute path of the anchor package.json.
1343
+ * @returns the profile name/dir, or undefined when not profile-installed.
1344
+ */
1345
+ function findProfile(anchorManifestPath) {
1346
+ let dir = dirname(anchorManifestPath);
1347
+ for (let depth = 0; depth < PROFILE_WALK_DEPTH; depth++) {
1348
+ const name = readManifest(join(dir, "package.json"))?.name;
1349
+ if (typeof name === "string" && name.startsWith(PROFILE_NAME_PREFIX)) return {
1350
+ name: name.slice(12),
1351
+ dir
1352
+ };
1353
+ const parent = dirname(dir);
1354
+ if (parent === dir) return void 0;
1355
+ dir = parent;
1356
+ }
1357
+ }
1358
+ /** Whether a dependency spec is a local link/file/dev-mode install. */
1359
+ function isLinkedSpec(spec) {
1360
+ if (typeof spec !== "string") return false;
1361
+ return /^(?:link|file):|^\.{1,2}(?:[/\\]|$)/.test(spec);
1362
+ }
1363
+ /**
1364
+ * Resolve the anchor package's manifest path. The aggregate package is the
1365
+ * canonical entry point; this plugin's own package is the fallback.
1366
+ * @param resolve - a Node resolve implementation scoped to the host process.
1367
+ * @returns the absolute manifest path, or undefined when neither is installed.
1368
+ */
1369
+ function resolveAnchorManifest(resolve) {
1370
+ for (const name of [AGGREGATE_PACKAGE, SELF_PACKAGE]) try {
1371
+ return resolve(name + "/package.json");
1372
+ } catch {}
1373
+ }
1374
+ /**
1375
+ * Resolve what an update would touch: the owning profile directory and the
1376
+ * family package list. Fails with an error code when the anchor is missing
1377
+ * ('not-found') or is a local dev install ('link').
1378
+ * @param deps - the anchor manifest path (resolveAnchorManifest output).
1379
+ * @returns the target, or the failure code.
1380
+ */
1381
+ function resolveUpdateTarget(deps) {
1382
+ const manifestPath = deps.anchorManifestPath;
1383
+ if (manifestPath === void 0) return { error: "not-found" };
1384
+ const manifest = readManifest(manifestPath);
1385
+ if (manifest === void 0) return { error: "not-found" };
1386
+ const anchor = typeof manifest.name === "string" ? manifest.name : void 0;
1387
+ if (anchor === void 0) return { error: "not-found" };
1388
+ const profile = findProfile(manifestPath);
1389
+ if (profile === void 0) return { error: "link" };
1390
+ const spec = (readManifest(join(profile.dir, "package.json"))?.dependencies)?.[anchor];
1391
+ if (isLinkedSpec(spec)) return { error: "link" };
1392
+ return {
1393
+ profileName: profile.name,
1394
+ profileDir: profile.dir,
1395
+ packages: [anchor, ...familyChildren(manifest)]
1396
+ };
1397
+ }
1398
+ /** Family children of the anchor: its dependencies under the family scope. */
1399
+ function familyChildren(anchorManifest) {
1400
+ const dependencies = anchorManifest.dependencies;
1401
+ if (typeof dependencies !== "object" || dependencies === null) return [];
1402
+ const names = [];
1403
+ for (const [name, spec] of Object.entries(dependencies)) if (name.startsWith("@linxin666/") && typeof spec === "string") names.push(name);
1404
+ return names;
1405
+ }
1406
+ /**
1407
+ * Probe the npm registry for one package's latest release.
1408
+ * @param name - the package name (scope slash URL-encoded).
1409
+ * @param fetchImpl - the fetch implementation (global fetch in the host).
1410
+ * @param timeoutMs - probe timeout.
1411
+ * @returns the latest version string, or undefined on any failure.
1412
+ */
1413
+ async function fetchLatestVersion(name, fetchImpl, timeoutMs = 1e4) {
1414
+ try {
1415
+ const controller = new AbortController();
1416
+ const timer = setTimeout(() => {
1417
+ controller.abort();
1418
+ }, timeoutMs);
1419
+ try {
1420
+ const response = await fetchImpl("https://registry.npmjs.org/" + name.replace("/", "%2F") + "/latest");
1421
+ if (!response.ok) return void 0;
1422
+ const body = await response.json();
1423
+ if (typeof body !== "object" || body === null) return void 0;
1424
+ const version = body.version;
1425
+ return typeof version === "string" ? version : void 0;
1426
+ } finally {
1427
+ clearTimeout(timer);
1428
+ }
1429
+ } catch {
1430
+ return;
1431
+ }
1432
+ }
1433
+ /** The resolved current version of one family package (probe failure tolerated). */
1434
+ function readInstalledVersion(resolve, name) {
1435
+ try {
1436
+ const path = resolve(name + "/package.json");
1437
+ const version = path === void 0 ? void 0 : readManifest(path)?.version;
1438
+ return typeof version === "string" ? version : "0.0.0";
1439
+ } catch {
1440
+ return "0.0.0";
1441
+ }
1442
+ }
1443
+ /**
1444
+ * Build the update status: locate the anchor, detect the install mode, and
1445
+ * compare every family package against the npm registry.
1446
+ * @param deps - manifest resolution + registry probe seams.
1447
+ * @returns the status snapshot.
1448
+ */
1449
+ async function checkUpdates(deps) {
1450
+ const manifestPath = deps.anchorManifestPath;
1451
+ if (manifestPath === void 0) return {
1452
+ mode: "missing",
1453
+ packages: [],
1454
+ outdated: false
1455
+ };
1456
+ const manifest = readManifest(manifestPath);
1457
+ if (manifest === void 0) return {
1458
+ mode: "missing",
1459
+ packages: [],
1460
+ outdated: false
1461
+ };
1462
+ const anchor = typeof manifest.name === "string" ? manifest.name : void 0;
1463
+ if (anchor === void 0) return {
1464
+ mode: "missing",
1465
+ packages: [],
1466
+ outdated: false
1467
+ };
1468
+ const profile = findProfile(manifestPath);
1469
+ const profileManifest = profile === void 0 ? void 0 : readManifest(join(profile.dir, "package.json"));
1470
+ const linked = profile === void 0 || isLinkedSpec((profileManifest?.dependencies)?.[anchor]);
1471
+ if (profile === void 0) return {
1472
+ mode: "link",
1473
+ packages: [],
1474
+ outdated: false
1475
+ };
1476
+ const names = [anchor, ...familyChildren(manifest)];
1477
+ const packages = [];
1478
+ let probeFailures = 0;
1479
+ for (const name of names) {
1480
+ const latest = await deps.fetchLatest(name);
1481
+ if (latest === void 0) probeFailures++;
1482
+ const current = readInstalledVersion(deps.resolve, name);
1483
+ packages.push({
1484
+ name,
1485
+ current,
1486
+ latest,
1487
+ outdated: latest !== void 0 && latest !== current && compareVersions(latest, current) > 0
1488
+ });
1489
+ }
1490
+ const error = probeFailures === names.length && names.length > 0 ? "registry-unreachable" : void 0;
1491
+ return {
1492
+ mode: linked ? "link" : "npm",
1493
+ profileName: profile.name,
1494
+ anchor,
1495
+ packages,
1496
+ outdated: packages.some((packageStatus) => packageStatus.outdated),
1497
+ ...error !== void 0 ? { error } : {}
1498
+ };
1499
+ }
1500
+ /** Cap on captured pnpm output (keeps error payloads bounded). */
1501
+ const OUTPUT_CAP = 16 * 1024;
1502
+ /**
1503
+ * Run the update: `pnpm update <packages>` inside the profile directory.
1504
+ * @param deps - profile dir, package list, and spawn/timeout seams.
1505
+ * @returns the outcome with captured output.
1506
+ */
1507
+ function runUpdate(deps) {
1508
+ return new Promise((resolve) => {
1509
+ const child = (deps.spawnImpl ?? spawn)("pnpm", ["update", ...deps.packages], {
1510
+ cwd: deps.profileDir,
1511
+ stdio: [
1512
+ "ignore",
1513
+ "pipe",
1514
+ "pipe"
1515
+ ]
1516
+ });
1517
+ let output = "";
1518
+ const append = (chunk) => {
1519
+ output += chunk.toString("utf8");
1520
+ if (output.length > OUTPUT_CAP) output = output.slice(output.length - OUTPUT_CAP);
1521
+ };
1522
+ child.stdout?.on("data", append);
1523
+ child.stderr?.on("data", append);
1524
+ const timer = setTimeout(() => {
1525
+ child.kill("SIGTERM");
1526
+ resolve({
1527
+ ok: false,
1528
+ exitCode: null,
1529
+ output,
1530
+ error: "update timed out; install process killed",
1531
+ errorCode: "timeout"
1532
+ });
1533
+ }, deps.timeoutMs ?? 10 * 6e4);
1534
+ child.on("error", (error) => {
1535
+ clearTimeout(timer);
1536
+ const missing = error.code === "ENOENT";
1537
+ resolve({
1538
+ ok: false,
1539
+ exitCode: null,
1540
+ output,
1541
+ error: missing ? "pnpm not found on PATH" : error.message,
1542
+ errorCode: missing ? "pnpm-missing" : void 0
1543
+ });
1544
+ });
1545
+ child.on("close", (code) => {
1546
+ clearTimeout(timer);
1547
+ resolve({
1548
+ ok: code === 0,
1549
+ exitCode: code,
1550
+ output,
1551
+ error: code === 0 ? void 0 : "pnpm exited with code " + String(code),
1552
+ ...code === 0 ? {} : { errorCode: "pnpm-failed" }
1553
+ });
1554
+ });
1555
+ });
1556
+ }
1557
+ //#endregion
1558
+ //#region src/update-routes.ts
1559
+ /** Route paths (exact matches under /api). */
1560
+ const UPDATE_PATHS = {
1561
+ status: "/api/update/status",
1562
+ run: "/api/update/run"
1563
+ };
1564
+ /** One JSON response. */
1565
+ function writeJson(res, status, body) {
1566
+ const payload = JSON.stringify(body);
1567
+ res.writeHead(status, {
1568
+ "content-type": "application/json; charset=utf-8",
1569
+ "referrer-policy": "no-referrer"
1570
+ });
1571
+ res.end(payload);
1572
+ }
1573
+ /**
1574
+ * Build the /api/update route family.
1575
+ * @param deps - fence + check/run seams.
1576
+ * @returns the exact routes to register on webServer.
1577
+ */
1578
+ function makeUpdateRoutes(deps) {
1579
+ const handleStatus = async (req, res) => {
1580
+ if (req.method !== "GET") {
1581
+ res.writeHead(405, { "content-type": "text/plain; charset=utf-8" });
1582
+ res.end("method not allowed");
1583
+ return;
1584
+ }
1585
+ if (!deps.fence(req)) {
1586
+ writeJson(res, 403, {
1587
+ ok: false,
1588
+ code: "forbidden"
1589
+ });
1590
+ return;
1591
+ }
1592
+ writeJson(res, 200, await deps.check());
1593
+ };
1594
+ const handleRun = async (req, res) => {
1595
+ if (req.method !== "POST") {
1596
+ res.writeHead(405, { "content-type": "text/plain; charset=utf-8" });
1597
+ res.end("method not allowed");
1598
+ return;
1599
+ }
1600
+ if (!deps.fence(req)) {
1601
+ writeJson(res, 403, {
1602
+ ok: false,
1603
+ code: "forbidden"
1604
+ });
1605
+ return;
1606
+ }
1607
+ writeJson(res, 200, await deps.run());
1608
+ };
1609
+ return [{
1610
+ kind: "exact",
1611
+ path: UPDATE_PATHS.status,
1612
+ handler: handleStatus
1613
+ }, {
1614
+ kind: "exact",
1615
+ path: UPDATE_PATHS.run,
1616
+ handler: handleRun
1617
+ }];
1618
+ }
1264
1619
  //#endregion
1265
1620
  //#region src/index.ts
1266
1621
  /**
@@ -1375,6 +1730,39 @@ function apply(ctx, config) {
1375
1730
  let disposeSweep;
1376
1731
  const apiProxy = ctx.get("apiProxy");
1377
1732
  if (apiProxy === void 0) console.warn("remote-web-ui: apiProxy service unavailable — the mobile data channel is disabled");
1733
+ const requireFromHost = createRequire(import.meta.url);
1734
+ const anchorManifestPath = resolveAnchorManifest((specifier) => requireFromHost.resolve(specifier));
1735
+ const updateRoutes = makeUpdateRoutes({
1736
+ fence: (request) => isTrustedApiRequest(request, []),
1737
+ check: () => checkUpdates({
1738
+ anchorManifestPath,
1739
+ resolve: (specifier) => {
1740
+ try {
1741
+ return requireFromHost.resolve(specifier);
1742
+ } catch {
1743
+ return;
1744
+ }
1745
+ },
1746
+ fetchLatest: (name) => fetchLatestVersion(name, fetch)
1747
+ }),
1748
+ run: async () => {
1749
+ const target = resolveUpdateTarget({ anchorManifestPath });
1750
+ if ("error" in target) {
1751
+ const code = target.error;
1752
+ return {
1753
+ ok: false,
1754
+ exitCode: null,
1755
+ output: "",
1756
+ error: code === "not-found" ? "dsh-web-ui aggregate not installed" : "local link install — update unavailable",
1757
+ errorCode: code
1758
+ };
1759
+ }
1760
+ return runUpdate({
1761
+ profileDir: target.profileDir,
1762
+ packages: target.packages
1763
+ });
1764
+ }
1765
+ });
1378
1766
  const routes = [
1379
1767
  ...makeRoutes({
1380
1768
  service,
@@ -1384,7 +1772,8 @@ function apply(ctx, config) {
1384
1772
  ...apiProxy !== void 0 ? makeMobileApiRoutes({
1385
1773
  service,
1386
1774
  apiProxy
1387
- }) : []
1775
+ }) : [],
1776
+ ...updateRoutes
1388
1777
  ];
1389
1778
  const gate = makeGateListener(service, () => resolve().requirePairingForLan, () => resolve().enabled);
1390
1779
  ctx.effect(() => ctx.on("api/gate", gate), "remote-web-ui: api gate");
@@ -29,7 +29,7 @@ export interface PluginSettingsCardProps {
29
29
  /**
30
30
  * Render one plugin settings card.
31
31
  * @param props - the plugin's copy keys, its form state, and its controls.
32
- * @returns the card, or nothing when the namespace is unavailable.
32
+ * @returns the card, or nothing while the namespace is still loading.
33
33
  */
34
34
  export declare function PluginSettingsCard(props: PluginSettingsCardProps): import("react").JSX.Element | null;
35
35
  /** Props every field control needs regardless of its value type. */
@@ -1 +1 @@
1
- {"version":3,"file":"PluginSettingsCard.d.ts","sourceRoot":"","sources":["../../../src/client/PluginSettingsCard.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAY,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAChD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AACnD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAGnD,wDAAwD;AACxD,MAAM,WAAW,uBAAuB;IACtC,0CAA0C;IAC1C,CAAC,EAAE,CAAC,GAAG,EAAE,eAAe,KAAK,MAAM,CAAA;IACnC,uCAAuC;IACvC,QAAQ,EAAE,eAAe,CAAA;IACzB,4EAA4E;IAC5E,cAAc,EAAE,eAAe,CAAA;IAC/B,kFAAkF;IAClF,KAAK,EAAE,SAAS,CAAA;IAChB,+BAA+B;IAC/B,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,8BAA8B;IAC9B,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,6BAA6B;IAC7B,QAAQ,EAAE,SAAS,CAAA;CACpB;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,uBAAuB,sCAmDhE;AAED,oEAAoE;AACpE,MAAM,WAAW,UAAU;IACzB,wDAAwD;IACxD,EAAE,EAAE,MAAM,CAAA;IACV,qBAAqB;IACrB,KAAK,EAAE,MAAM,CAAA;IACb,uDAAuD;IACvD,IAAI,EAAE,MAAM,CAAA;IACZ,uCAAuC;IACvC,IAAI,EAAE,MAAM,CAAA;IACZ,sEAAsE;IACtE,UAAU,EAAE,OAAO,CAAA;IACnB,6DAA6D;IAC7D,OAAO,EAAE,OAAO,CAAA;IAChB,qCAAqC;IACrC,eAAe,EAAE,MAAM,CAAA;IACvB,kCAAkC;IAClC,UAAU,EAAE,MAAM,CAAA;IAClB,kEAAkE;IAClE,YAAY,EAAE,MAAM,CAAA;IACpB,gFAAgF;IAChF,QAAQ,EAAE,OAAO,CAAA;IACjB,wBAAwB;IACxB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAC9B,oEAAoE;IACpE,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB;AAED,kHAAkH;AAClH,wBAAgB,UAAU,CAAC,KAAK,EAAE,UAAU,GAAG;IAC7C,yEAAyE;IACzE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,kDAAkD;IAClD,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,+BAqCA;AAED,0CAA0C;AAC1C,wBAAgB,YAAY,CAAC,KAAK,EAAE,UAAU,GAAG;IAC/C,mCAAmC;IACnC,YAAY,EAAE,MAAM,CAAA;IACpB,8BAA8B;IAC9B,OAAO,EAAE,MAAM,CAAA;IACf,+BAA+B;IAC/B,QAAQ,EAAE,MAAM,CAAA;CACjB,+BAmCA"}
1
+ {"version":3,"file":"PluginSettingsCard.d.ts","sourceRoot":"","sources":["../../../src/client/PluginSettingsCard.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,EAAY,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAChD,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,oBAAoB,CAAA;AACnD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAA;AAGnD,wDAAwD;AACxD,MAAM,WAAW,uBAAuB;IACtC,0CAA0C;IAC1C,CAAC,EAAE,CAAC,GAAG,EAAE,eAAe,KAAK,MAAM,CAAA;IACnC,uCAAuC;IACvC,QAAQ,EAAE,eAAe,CAAA;IACzB,4EAA4E;IAC5E,cAAc,EAAE,eAAe,CAAA;IAC/B,kFAAkF;IAClF,KAAK,EAAE,SAAS,CAAA;IAChB,+BAA+B;IAC/B,MAAM,EAAE,MAAM,IAAI,CAAA;IAClB,8BAA8B;IAC9B,SAAS,EAAE,MAAM,IAAI,CAAA;IACrB,6BAA6B;IAC7B,QAAQ,EAAE,SAAS,CAAA;CACpB;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,uBAAuB,sCAiFhE;AAED,oEAAoE;AACpE,MAAM,WAAW,UAAU;IACzB,wDAAwD;IACxD,EAAE,EAAE,MAAM,CAAA;IACV,qBAAqB;IACrB,KAAK,EAAE,MAAM,CAAA;IACb,uDAAuD;IACvD,IAAI,EAAE,MAAM,CAAA;IACZ,uCAAuC;IACvC,IAAI,EAAE,MAAM,CAAA;IACZ,sEAAsE;IACtE,UAAU,EAAE,OAAO,CAAA;IACnB,6DAA6D;IAC7D,OAAO,EAAE,OAAO,CAAA;IAChB,qCAAqC;IACrC,eAAe,EAAE,MAAM,CAAA;IACvB,kCAAkC;IAClC,UAAU,EAAE,MAAM,CAAA;IAClB,kEAAkE;IAClE,YAAY,EAAE,MAAM,CAAA;IACpB,gFAAgF;IAChF,QAAQ,EAAE,OAAO,CAAA;IACjB,wBAAwB;IACxB,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAA;IAC9B,oEAAoE;IACpE,OAAO,EAAE,MAAM,IAAI,CAAA;CACpB;AAED,kHAAkH;AAClH,wBAAgB,UAAU,CAAC,KAAK,EAAE,UAAU,GAAG;IAC7C,yEAAyE;IACzE,OAAO,CAAC,EAAE,OAAO,CAAA;IACjB,kDAAkD;IAClD,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB,+BAqCA;AAED,0CAA0C;AAC1C,wBAAgB,YAAY,CAAC,KAAK,EAAE,UAAU,GAAG;IAC/C,mCAAmC;IACnC,YAAY,EAAE,MAAM,CAAA;IACpB,8BAA8B;IAC9B,OAAO,EAAE,MAAM,CAAA;IACf,+BAA+B;IAC/B,QAAQ,EAAE,MAAM,CAAA;CACjB,+BAmCA"}
@@ -12,7 +12,7 @@ import css from './settings-card.module.css';
12
12
  /**
13
13
  * Render one plugin settings card.
14
14
  * @param props - the plugin's copy keys, its form state, and its controls.
15
- * @returns the card, or nothing when the namespace is unavailable.
15
+ * @returns the card, or nothing while the namespace is still loading.
16
16
  */
17
17
  export function PluginSettingsCard(props) {
18
18
  const [open, setOpen] = useState(false);
@@ -21,6 +21,15 @@ export function PluginSettingsCard(props) {
21
21
  return null;
22
22
  const title = props.t(props.titleKey);
23
23
  const blocked = !state.dirty || state.invalid || state.saving;
24
+ // The namespace exists but the Host does not serve it to this client (the
25
+ // official settings allowlist omits third-party namespaces): show a card
26
+ // that explains the gap instead of vanishing, so a missing card never
27
+ // reads as a missing plugin.
28
+ if (!state.exposed) {
29
+ return (_jsxs("li", { className: css.card, children: [_jsxs("button", { type: "button", className: css.header, "aria-expanded": open, "aria-label": `${props.t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`, onClick: () => { setOpen(!open); }, children: [_jsxs("span", { className: css.headText, children: [_jsx("span", { className: css.name, children: title }), _jsx("span", { className: css.description, children: props.t(props.descriptionKey) })] }), _jsx("span", { className: open ? css.chevronOpen : css.chevron, children: "\u25BE" })] }), open
30
+ ? (_jsx("div", { className: css.body, children: _jsx("p", { className: css.notExposed, role: "status", children: props.t('settings.notExposed') }) }))
31
+ : null] }));
32
+ }
24
33
  return (_jsxs("li", { className: css.card, children: [_jsxs("button", { type: "button", className: css.header, "aria-expanded": open, "aria-label": `${props.t(open ? 'settings.collapse' : 'settings.expand')}: ${title}`, onClick: () => { setOpen(!open); }, children: [_jsxs("span", { className: css.headText, children: [_jsx("span", { className: css.name, children: title }), _jsx("span", { className: css.description, children: props.t(props.descriptionKey) })] }), state.dirty ? _jsx("span", { className: css.pending, children: props.t('settings.unsaved') }) : null, _jsx("span", { className: open ? css.chevronOpen : css.chevron, children: "\u25BE" })] }), open
25
34
  ? (_jsxs("div", { className: css.body, children: [!state.writable ? _jsx("p", { className: css.readOnly, role: "status", children: props.t('settings.readOnly') }) : null, props.children, _jsxs("div", { className: css.footer, children: [state.failed ? _jsx("p", { className: css.failed, role: "status", children: props.t('settings.saveFailed') }) : null, _jsx("button", { type: "button", className: css.discard, disabled: !state.dirty || state.saving, onClick: props.onDiscard, children: props.t('settings.discard') }), _jsx("button", { type: "button", className: css.save, disabled: blocked, onClick: props.onSave, children: props.t(!state.saving ? 'settings.save' : 'settings.saving') })] })] }))
26
35
  : null] }));
@@ -1 +1 @@
1
- {"version":3,"file":"RemoteEntry.d.ts","sourceRoot":"","sources":["../../../src/client/RemoteEntry.tsx"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAA;AAOjF,wEAAwE;AACxE,MAAM,MAAM,gBAAgB,GAAG,YAAY,CAAC,gBAAgB,CAAC,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAA;AAcrF;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,aAAa,EAAE,CAAC,EAAE,EAAE,gBAAgB,+BAoJvE"}
1
+ {"version":3,"file":"RemoteEntry.d.ts","sourceRoot":"","sources":["../../../src/client/RemoteEntry.tsx"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,kCAAkC,CAAA;AAQjF,wEAAwE;AACxE,MAAM,MAAM,gBAAgB,GAAG,YAAY,CAAC,gBAAgB,CAAC,GAAG,WAAW,CAAC,QAAQ,CAAC,CAAA;AAcrF;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,aAAa,EAAE,CAAC,EAAE,EAAE,gBAAgB,+BAuJvE"}
@@ -1,17 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
- * The sidebar remote-control seat: the phone-icon trigger beside the
4
- * settings button plus the pairing panel modal. Owns the panel behavior —
5
- * token minting on open, the status SSE subscription, stop/refresh/copy —
6
- * and renders the pure {@link RemotePanel} body. Component-local state per
7
- * the client stack rules: nothing here survives remounts or crosses
8
- * entries.
3
+ * The sidebar remote-control seat: the update trigger plus the phone-icon
4
+ * trigger beside the settings button, and the pairing panel modal. Owns the
5
+ * panel behavior — token minting on open, the status SSE subscription,
6
+ * stop/refresh/copy — and renders the pure {@link RemotePanel} body. The
7
+ * update seat (the dsh-web-ui self-update flow) rides the same footer row,
8
+ * rendered by {@link UpdateEntry}. Component-local state per the client
9
+ * stack rules: nothing here survives remounts or crosses entries.
9
10
  */
10
11
  import { useCallback, useEffect, useRef, useState } from 'react';
11
12
  import { createPortal } from 'react-dom';
12
13
  import { RemotePanel } from "./RemotePanel.js";
13
14
  import { copyText, issuePair, stopPair } from "./pair-api.js";
14
15
  import { PhoneIcon } from "./PhoneIcon.js";
16
+ import { UpdateEntry } from "./UpdateEntry.js";
15
17
  import css from './remote.module.css';
16
18
  /** Apply one status frame onto the current ready state. */
17
19
  function mergeFrame(state, frame) {
@@ -154,7 +156,7 @@ export function RemoteEntry({ wide, useWorkspaces, t }) {
154
156
  window.setTimeout(() => { setCopied(false); }, 1500);
155
157
  });
156
158
  }, [state]);
157
- return (_jsxs(_Fragment, { children: [_jsx(TooltipAnchor, { wide: wide, label: t('entry.label'), onClick: openPanel }), open && createPortal((_jsxs("div", { className: css.overlay, role: "presentation", children: [_jsx("div", { className: css.mask, "aria-hidden": "true", onClick: closePanel }), _jsx(RemotePanel, { t: t, state: state, copied: copied, onClose: closePanel, onStop: handleStop, onRefresh: handleRefresh, onCopy: handleCopy, onPickAddress: handlePickAddress, onPickPublic: handlePickPublic })] })), document.body)] }));
159
+ return (_jsxs(_Fragment, { children: [_jsxs("div", { className: css.entryRow, "data-rail": wide ? undefined : 'rail', children: [_jsx(UpdateEntry, { wide: wide, t: t }), _jsx(TooltipAnchor, { wide: wide, label: t('entry.label'), onClick: openPanel })] }), open && createPortal((_jsxs("div", { className: css.overlay, role: "presentation", children: [_jsx("div", { className: css.mask, "aria-hidden": "true", onClick: closePanel }), _jsx(RemotePanel, { t: t, state: state, copied: copied, onClose: closePanel, onStop: handleStop, onRefresh: handleRefresh, onCopy: handleCopy, onPickAddress: handlePickAddress, onPickPublic: handlePickPublic })] })), document.body)] }));
158
160
  }
159
161
  /** The trigger: an icon button matching the settings rail/row geometry. */
160
162
  function TooltipAnchor({ wide, label, onClick }) {
@@ -0,0 +1,14 @@
1
+ import type { TranslateNS } from '@deepseek-ai/dsh-client-ui-slots';
2
+ /** Entry props: the sidebar column state + the standard locale seat. */
3
+ export interface UpdateEntryProps {
4
+ /** Whether the sidebar renders wide content (false = 56px rail). */
5
+ wide: boolean;
6
+ t: TranslateNS<'remote'>;
7
+ }
8
+ /**
9
+ * Render the update trigger and panel.
10
+ * @param props - column state and locale seat.
11
+ * @returns the entry element tree.
12
+ */
13
+ export declare function UpdateEntry({ wide, t }: UpdateEntryProps): import("react").JSX.Element;
14
+ //# sourceMappingURL=UpdateEntry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"UpdateEntry.d.ts","sourceRoot":"","sources":["../../../src/client/UpdateEntry.tsx"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kCAAkC,CAAA;AAOnE,wEAAwE;AACxE,MAAM,WAAW,gBAAgB;IAC/B,oEAAoE;IACpE,IAAI,EAAE,OAAO,CAAA;IACb,CAAC,EAAE,WAAW,CAAC,QAAQ,CAAC,CAAA;CACzB;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CAAC,EAAE,IAAI,EAAE,CAAC,EAAE,EAAE,gBAAgB,+BAmExD"}