dsh-git-badge 0.10.1 → 0.11.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/CHANGELOG.md CHANGED
@@ -10,6 +10,26 @@ npm. 0.6.0 is the first release recorded here.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.11.0] - 2026-09-14
14
+
15
+ ### Added
16
+
17
+ - **A one-click restart for the change the marketplace cannot see.** The
18
+ postinstall/apply patches DSH's own files, which is invisible to dshmarket's
19
+ restart bookkeeping — a market install could complete "restart-free" while
20
+ the running bundles no longer matched the disk. Now: the installer writes a
21
+ `restart-pending` marker (`seam/store.js`); the plugin's status response
22
+ carries it; and while it is pending the input chip shows a one-line
23
+ explanation — *the installer patched DSH, client bundles are composed at
24
+ boot* — plus a **Restart** button calling dshmarket's PUBLIC, documented v1
25
+ restart endpoint (`POST /dsh-market/api/v1/restart`, feature-detected via
26
+ `capabilities`; hosts that delegate restart — desktop/supervised — get the
27
+ manual instruction instead, never a hand-rolled process-control path). The
28
+ button polls until the host is serving again, then reloads into the
29
+ recomposed boot. The marker is cleared by the node half at the next boot, so
30
+ it self-expires exactly when the restart it asks for has happened. Applies
31
+ to `revert` too — removing the patch equally needs a restart to take effect.
32
+
13
33
  ## [0.10.1] - 2026-09-14
14
34
 
15
35
  ### Fixed
package/lib/client.js CHANGED
@@ -703,6 +703,48 @@ window.__ModuleLoader__.load({
703
703
  */
704
704
  let seamHint = null;
705
705
 
706
+ /**
707
+ * The restart affordance for a seam change made while the host was
708
+ * running. dshmarket cannot see host-file mutations (its restart banner
709
+ * tracks only its own package bookkeeping), so the flag this reads is
710
+ * written by our own postinstall/apply — see seam/store.js. The Restart
711
+ * button calls dshmarket's PUBLIC, documented v1 restart endpoint
712
+ * (UPDATE-API-V1.md): feature-detected via capabilities, same-origin, and
713
+ * rendered as text-only on hosts that delegate restart (desktop /
714
+ * supervised), never a hand-rolled process-control path.
715
+ */
716
+ async function marketRestartSupported() {
717
+ try {
718
+ const cap = await fetch("/dsh-market/api/v1/capabilities", { headers: { accept: "application/json" } }).then((r) => r.json());
719
+ return cap !== null && typeof cap === "object" && cap.restart !== void 0 && cap.restart.supported === true;
720
+ } catch {
721
+ return false;
722
+ }
723
+ }
724
+
725
+ async function marketRestart() {
726
+ await fetch("/dsh-market/api/v1/restart", {
727
+ method: "POST",
728
+ headers: { accept: "application/json", "content-type": "application/json" },
729
+ body: "{}",
730
+ }).catch(() => void 0); // the host drops the socket mid-restart — expected
731
+ // Poll until the origin is serving again, then reload into the
732
+ // recomposed boot. Bounded so a failed relaunch cannot spin forever.
733
+ for (let waited = 0; waited < 30000; waited += 500) {
734
+ await new Promise((r) => setTimeout(r, 500));
735
+ try {
736
+ const cap = await fetch("/dsh-market/api/v1/capabilities", { headers: { accept: "application/json" } });
737
+ if (cap.ok) {
738
+ location.reload();
739
+ return;
740
+ }
741
+ } catch {
742
+ // host down mid-restart — expected; keep polling
743
+ }
744
+ }
745
+ location.reload();
746
+ }
747
+
706
748
  function ComposerGitChip({ sessionId }) {
707
749
  const target = sessionId === void 0 ? void 0 : { kind: "session", id: sessionId };
708
750
  // the PR/CI token is always on the chip, so its fetch is not gated
@@ -715,7 +757,29 @@ window.__ModuleLoader__.load({
715
757
  // the PR token's own hover/focus state: it underlines on hover (and on
716
758
  // focus) rather than at rest, and that underline is applied inline below
717
759
  const [linkHover, setLinkHover] = react.useState(false);
760
+ // The restart affordance: idle → checking (capabilities) → restarting.
761
+ // One status refresh carries seamRestartPending at a time; when the
762
+ // restart lands, the marker is cleared at the new boot and this reverts.
763
+ const [restarting, setRestarting] = react.useState(false);
764
+ const [canRestart, setCanRestart] = react.useState(null); // null = not yet checked
718
765
  const detail = useGitStatus(target, { pr: true, detail: true, enabled: hovered });
766
+ const restartPending = info !== void 0 && info.git === true && info.seamRestartPending !== void 0;
767
+ react.useEffect(() => {
768
+ if (!restartPending || canRestart !== null) return;
769
+ let cancelled = false;
770
+ marketRestartSupported().then((v) => {
771
+ if (!cancelled) setCanRestart(v);
772
+ });
773
+ return () => {
774
+ cancelled = true;
775
+ };
776
+ }, [restartPending, canRestart]);
777
+ async function restartNow() {
778
+ setRestarting(true);
779
+ if (await marketRestartSupported()) await marketRestart();
780
+ // Unsupported host: the text stays and says restart dsh web manually.
781
+ else setRestarting(false);
782
+ }
719
783
  if (info === void 0 || info.git !== true) return null;
720
784
  // the mark is an element now rather than a leading glyph in the string, so
721
785
  // the SAME StatusMark the sidebar row draws carries the status here too;
@@ -723,6 +787,63 @@ window.__ModuleLoader__.load({
723
787
  const text = info.branch + formatOperationToken(info) + formatGitSuffix(info);
724
788
  const prToken = formatPrToken(info);
725
789
  const prUrl = prLinkUrl(info);
790
+ // One-line explanation + one-click restart, ONLY while a restart is
791
+ // actually pending. The button exists because dshmarket's own banner
792
+ // cannot see the host-file change our installer made; the endpoint is
793
+ // its documented public API. On hosts that delegate restart (desktop,
794
+ // supervisors) canRestart is false and the text names the manual step.
795
+ const restartNotice =
796
+ !restartPending || restarting
797
+ ? null
798
+ : react_jsx_runtime.jsxs(
799
+ "span",
800
+ {
801
+ style: {
802
+ display: "inline-flex",
803
+ alignItems: "center",
804
+ gap: "6px",
805
+ paddingLeft: "6px",
806
+ borderLeft: "1px solid var(--dsw-alias-border-secondary, #d0d7de)",
807
+ whiteSpace: "normal",
808
+ maxWidth: "320px"
809
+ },
810
+ children: [
811
+ react_jsx_runtime.jsx(
812
+ "span",
813
+ {
814
+ title: "The installer applied dsh-git-badge's sidebar patch to DSH's own files; client bundles are composed at boot, so a restart is needed for it to take effect. Revert anytime with `npx dsh-git-badge revert`.",
815
+ children:
816
+ canRestart === false
817
+ ? "restart dsh web to activate the sidebar badges (one-click restart is unavailable on this host)"
818
+ : "installer patched DSH — restart to activate the sidebar badges"
819
+ },
820
+ "explain"
821
+ ),
822
+ canRestart === true
823
+ ? react_jsx_runtime.jsx(
824
+ "button",
825
+ {
826
+ onClick: () => void restartNow(),
827
+ style: {
828
+ cursor: "pointer",
829
+ color: "inherit",
830
+ background: "none",
831
+ border: "1px solid var(--dsw-alias-border-secondary, #d0d7de)",
832
+ borderRadius: "4px",
833
+ fontSize: "11px",
834
+ lineHeight: "18px",
835
+ padding: "0 6px",
836
+ flex: "none"
837
+ },
838
+ children: "Restart"
839
+ },
840
+ "btn"
841
+ )
842
+ : null
843
+ ]
844
+ },
845
+ "restart"
846
+ );
726
847
  const chip = react_jsx_runtime.jsxs("span", {
727
848
  style: {
728
849
  display: "inline-flex",
@@ -785,7 +906,8 @@ window.__ModuleLoader__.load({
785
906
  onFocus: () => setLinkHover(true),
786
907
  onBlur: () => setLinkHover(false),
787
908
  children: prToken
788
- })
909
+ }),
910
+ restartNotice
789
911
  ]
790
912
  });
791
913
  // No Tooltip primitive (a shell that does not seed it): render the chip
package/lib/index.js CHANGED
@@ -69,6 +69,7 @@
69
69
  import { execFile } from "node:child_process";
70
70
  import { watch, existsSync, readFileSync } from "node:fs";
71
71
  import { basename, isAbsolute, join, resolve } from "node:path";
72
+ import { clearRestartMarker, readRestartMarker } from "../seam/store.js";
72
73
 
73
74
  const inject = ["webServer", "workspaceRegistry"];
74
75
  const name = "dsh-git-badge";
@@ -1218,6 +1219,11 @@ async function effectiveTarget(target, bySession, wantPr, notify) {
1218
1219
 
1219
1220
  /** Host plugin body — register the status route and the SSE change feed. */
1220
1221
  function apply(ctx) {
1222
+ // A fresh boot composed the files on disk into the new bundles — any
1223
+ // restart-pending marker was written by an install/apply against the PREVIOUS
1224
+ // boot and its change is now live. Clearing here is what makes the marker
1225
+ // self-expiring: it survives exactly until the restart it asks for happens.
1226
+ clearRestartMarker();
1221
1227
  ctx.effect(() => ctx.webServer.register({
1222
1228
  kind: "exact",
1223
1229
  path: "/api/git-badge",
@@ -1230,6 +1236,7 @@ function apply(ctx) {
1230
1236
  res.end(JSON.stringify({ git: false, error: target.error }));
1231
1237
  return;
1232
1238
  }
1239
+ const pending = readRestartMarker();
1233
1240
  const detail = url.searchParams.get("detail") === "1";
1234
1241
  const pr = url.searchParams.get("pr") === "1";
1235
1242
  // WHO asked decides whether a linked worktree may stand in for the
@@ -1268,6 +1275,12 @@ function apply(ctx) {
1268
1275
  // tell whether an event belongs to it — without this the input chip
1269
1276
  // only ever refreshed on remount or the 60s poll.
1270
1277
  res.writeHead(200, { "content-type": "application/json" });
1278
+ if (pending !== null) {
1279
+ // dshmarket cannot see postinstall/apply host-file changes; this
1280
+ // flag is how the running UI learns a restart is pending and
1281
+ // offers its own Restart button (dshmarket v1 restart endpoint).
1282
+ body.seamRestartPending = { by: pending.by, at: pending.at };
1283
+ }
1271
1284
  res.end(JSON.stringify(body));
1272
1285
  } catch (error) {
1273
1286
  res.writeHead(400, { "content-type": "application/json" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-git-badge",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "description": "Git status badges for DeepSeek Harness: an input-row chip on every install, plus sidebar session-row badges (git status, PR/CI action tokens) after one manual step: `npx dsh-git-badge apply`, then restart dsh web. Event-driven freshness over SSE.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -52,6 +52,7 @@
52
52
  "seam/apply.js",
53
53
  "seam/anchors.js",
54
54
  "seam/postinstall.js",
55
+ "seam/store.js",
55
56
  "seam/stub-index.js",
56
57
  "cordis.patch.yml",
57
58
  "package.json",
package/seam/apply.js CHANGED
@@ -55,18 +55,16 @@ import {
55
55
  seamPresent,
56
56
  } from "./anchors.js";
57
57
 
58
+ import { dataDir, writeRestartMarker } from "./store.js";
59
+
58
60
  const HERE = dirname(fileURLToPath(import.meta.url));
59
61
  /**
60
- * Where revert backups live. A STABLE, user-level directory by default — NOT
61
- * this script's own directory. For an npx run that directory is a cache entry
62
- * keyed by package version: a backup written by one `npx dsh-git-badge apply`
63
- * was invisible to the next `npx dsh-git-badge revert`, which then had to
64
- * refuse ("patched but no backup exists") even though the bytes were on disk
65
- * somewhere. Sharing one store across npx runs, the profile bin and the repo
66
- * wrapper makes the apply→revert round trip reliable for everyone. Override
67
- * with SEAM_DATA_DIR (the test suite does, to stay hermetic).
62
+ * The stable backup store — shared with the postinstall hook and any future
63
+ * invocation path, so an apply→revert round trip works across npx cache
64
+ * entries. SEAM_DATA_DIR overrides (the test suite does, to stay hermetic).
65
+ * See seam/store.js for the full rationale and the restart marker.
68
66
  */
69
- const DATA_DIR = process.env.SEAM_DATA_DIR || join(process.env.DSH_HOME || join(process.env.HOME || "", ".dsh"), "git-badge-seam");
67
+ const DATA_DIR = dataDir();
70
68
 
71
69
  // sha256 of the upstream build the anchors were last verified against. ADVISORY:
72
70
  // status prints it for orientation; apply does NOT require it.
@@ -156,6 +154,9 @@ function doPatch() {
156
154
  writeText(CLIENT, patched);
157
155
  copyFileSync(STUB_INDEX, INDEX);
158
156
  console.log("patched lib/client.js in place (anchors applied); host half stubbed.");
157
+ // dshmarket cannot see this host-file change; the plugin surfaces the
158
+ // restart itself (marker → status response → chip button).
159
+ writeRestartMarker("apply");
159
160
  }
160
161
 
161
162
  function writeText(path, text) {
@@ -291,6 +292,8 @@ if (verb === "apply") {
291
292
  writeText(CLIENT, readText(BACKUP_CLIENT));
292
293
  copyFileSync(BACKUP_INDEX, INDEX);
293
294
  console.log("reverted client.js/index.js to the bytes as found before patching.");
295
+ // A revert also changes bytes the running bundles were composed from.
296
+ writeRestartMarker("revert");
294
297
  } else {
295
298
  // Installed file is not ours => upstream replaced it since patching.
296
299
  console.error("REFUSING to revert: the installed client.js is not this repo's");
@@ -26,15 +26,10 @@ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from
26
26
  import { dirname, join } from "node:path";
27
27
  import { fileURLToPath } from "node:url";
28
28
  import { applyPatch, firstFailure, isOurs, seamPresent } from "./anchors.js";
29
+ import { dataDir, writeRestartMarker } from "./store.js";
29
30
 
30
31
  const HERE = dirname(fileURLToPath(import.meta.url));
31
32
  const HINT = "Sidebar session-row badges need one manual step: `npx dsh-git-badge apply`, then restart dsh web.";
32
- /**
33
- * Same stable backup store apply.js defaults to — the backup this hook takes
34
- * must be findable by a LATER `npx dsh-git-badge revert` running from a
35
- * different cache entry. SEAM_DATA_DIR overrides (test suite).
36
- */
37
- const DATA_DIR = process.env.SEAM_DATA_DIR || join(process.env.DSH_HOME || join(process.env.HOME || "", ".dsh"), "git-badge-seam");
38
33
 
39
34
  function dshClientPath() {
40
35
  try {
@@ -74,12 +69,16 @@ function main() {
74
69
  return;
75
70
  }
76
71
  // The one state we act on: pristine upstream, anchors resolve.
77
- mkdirSync(DATA_DIR, { recursive: true });
78
- copyFileSync(client, join(DATA_DIR, "backup-client.js"));
79
- copyFileSync(index, join(DATA_DIR, "backup-index.js"));
72
+ mkdirSync(dataDir(), { recursive: true });
73
+ copyFileSync(client, join(dataDir(), "backup-client.js"));
74
+ copyFileSync(index, join(dataDir(), "backup-index.js"));
80
75
  writeFileSync(client, applyPatch(text));
81
76
  copyFileSync(join(HERE, "stub-index.js"), index);
82
77
  console.log("[dsh-git-badge] seam applied — RESTART dsh web to get sidebar session-row badges.");
78
+ // Tell the running UI: dshmarket cannot see this host-file change, so the
79
+ // plugin offers the Restart button itself (marker → status response → chip).
80
+ // Read/cleared by the plugin halves — see seam/store.js.
81
+ writeRestartMarker("postinstall");
83
82
  }
84
83
 
85
84
  try {
package/seam/store.js ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The restart-pending marker — the bridge from install-time (node side) to the
3
+ * running UI.
4
+ *
5
+ * WHY IT EXISTS: dshmarket's pending-restart banner tracks its own package
6
+ * bookkeeping. A postinstall that patches the DSH HOST package
7
+ * (dsh-client-ui-workspace) mutates files outside that bookkeeping, so the
8
+ * market can complete "restart-free" while the host's composed client bundles
9
+ * no longer match the files on disk. The marker is the out-of-band signal:
10
+ *
11
+ * postinstall / apply / revert → write the marker (seam changed on disk)
12
+ * plugin node half, at boot → clear the marker (fresh boot composed the
13
+ * new bytes; the change is live)
14
+ * plugin client half → while the marker is served on the status
15
+ * response, offer a Restart button calling
16
+ * dshmarket's public v1 restart endpoint
17
+ *
18
+ * The hot-mount path is what makes this work on a FIRST market install: the
19
+ * freshly installed plugin's client half mounts into the running shell, reads
20
+ * the marker through the status response, and can restart the host — even
21
+ * though the process that installed the patch is gone.
22
+ */
23
+
24
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
25
+ import { join } from "node:path";
26
+
27
+ /** The stable backup store — same default apply.js uses; SEAM_DATA_DIR overrides. */
28
+ export function dataDir() {
29
+ if (process.env.SEAM_DATA_DIR) return process.env.SEAM_DATA_DIR;
30
+ return join(process.env.DSH_HOME || join(process.env.HOME || "", ".dsh"), "git-badge-seam");
31
+ }
32
+
33
+ function markerPath() {
34
+ return join(dataDir(), "restart-pending.json");
35
+ }
36
+
37
+ /**
38
+ * Record that DSH files were changed on disk and a restart is needed to
39
+ * compose them. `reason` is a one-word actor (postinstall | apply | revert).
40
+ */
41
+ export function writeRestartMarker(reason) {
42
+ try {
43
+ mkdirSync(dataDir(), { recursive: true });
44
+ writeFileSync(
45
+ markerPath(),
46
+ JSON.stringify({ schema: "dsh-git-badge/restart-pending/v1", by: reason, at: new Date().toISOString() }),
47
+ "utf8",
48
+ );
49
+ } catch {
50
+ // The marker is a UX nicety; never fail the operation that earned it.
51
+ }
52
+ }
53
+
54
+ /** The pending marker, or null. Never throws. */
55
+ export function readRestartMarker() {
56
+ try {
57
+ if (!existsSync(markerPath())) return null;
58
+ return JSON.parse(readFileSync(markerPath(), "utf8"));
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ /** The change is live — a new boot composed it. Clear the pending state. */
65
+ export function clearRestartMarker() {
66
+ try {
67
+ rmSync(markerPath(), { force: true });
68
+ } catch {
69
+ // Absent is the goal; any error leaves the marker to be cleared later.
70
+ }
71
+ }