dsh-git-badge 0.10.0 → 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,42 @@ 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
+
33
+ ## [0.10.1] - 2026-09-14
34
+
35
+ ### Fixed
36
+
37
+ - **Revert backups now live in a stable, user-level store.** They defaulted to
38
+ the patcher's own directory — which for `npx dsh-git-badge` is a cache entry
39
+ keyed by package version, so a backup written by one `apply` was invisible to
40
+ the next `revert`, which then had to refuse ("patched but no backup exists")
41
+ even though the bytes were on disk somewhere. The default is now
42
+ `$DSH_HOME/git-badge-seam` (else `~/.dsh/git-badge-seam`), shared by npx
43
+ runs, the profile bin, the repo wrapper and the postinstall hook;
44
+ `SEAM_DATA_DIR` still overrides. Backups written by older versions to their
45
+ install directories are not migrated — the no-backup refusal now says so and
46
+ names both remaining routes (the repo wrapper's backup, or reinstalling the
47
+ DSH package).
48
+
13
49
  ## [0.10.0] - 2026-09-14
14
50
 
15
51
  ### Added
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.0",
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
@@ -33,8 +33,10 @@
33
33
  *
34
34
  * Environment:
35
35
  * DSH_INSTALL the DSH package root. Default: `<global npm root>/@deepseek-ai/dsh`.
36
- * SEAM_DATA_DIR where revert backups are written. Default: this script's
37
- * directory (inside the installed package).
36
+ * SEAM_DATA_DIR where revert backups are written. Default:
37
+ * `$DSH_HOME/git-badge-seam` (else `~/.dsh/git-badge-seam`) —
38
+ * a stable, user-level store shared by every way this tool
39
+ * can run. See the note at the definition below.
38
40
  *
39
41
  * Usage: apply.js apply | apply.js revert | apply.js status
40
42
  */
@@ -53,8 +55,16 @@ import {
53
55
  seamPresent,
54
56
  } from "./anchors.js";
55
57
 
58
+ import { dataDir, writeRestartMarker } from "./store.js";
59
+
56
60
  const HERE = dirname(fileURLToPath(import.meta.url));
57
- const DATA_DIR = process.env.SEAM_DATA_DIR || HERE;
61
+ /**
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.
66
+ */
67
+ const DATA_DIR = dataDir();
58
68
 
59
69
  // sha256 of the upstream build the anchors were last verified against. ADVISORY:
60
70
  // status prints it for orientation; apply does NOT require it.
@@ -144,6 +154,9 @@ function doPatch() {
144
154
  writeText(CLIENT, patched);
145
155
  copyFileSync(STUB_INDEX, INDEX);
146
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");
147
160
  }
148
161
 
149
162
  function writeText(path, text) {
@@ -279,6 +292,8 @@ if (verb === "apply") {
279
292
  writeText(CLIENT, readText(BACKUP_CLIENT));
280
293
  copyFileSync(BACKUP_INDEX, INDEX);
281
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");
282
297
  } else {
283
298
  // Installed file is not ours => upstream replaced it since patching.
284
299
  console.error("REFUSING to revert: the installed client.js is not this repo's");
@@ -291,9 +306,13 @@ if (verb === "apply") {
291
306
  } else {
292
307
  const r = inspect();
293
308
  if (r.state === "ours-current" || r.state === "ours-stale" || r.state === "ours-corrupt") {
294
- console.error("REFUSING: the installed file is patched but no backup exists, so the");
295
- console.error("original bytes are unknown. Reinstall the package to clear the patch:");
296
- console.error(" npm rebuild -g @deepseek-ai/dsh # or your package manager's equivalent");
309
+ console.error("REFUSING: the installed file is patched but no backup exists in");
310
+ console.error(`${DATA_DIR}, so the original bytes are unknown here. Options:`);
311
+ console.error(" 1. If you applied from a clone of the plugin's repository, run its");
312
+ console.error(" wrapper instead — it keeps its own backup:");
313
+ console.error(" <clone>/seam/apply.sh revert");
314
+ console.error(" 2. Otherwise reinstall the package — pristine files, guaranteed:");
315
+ console.error(" npm i -g @deepseek-ai/dsh@latest");
297
316
  process.exit(1);
298
317
  }
299
318
  console.log("no backup present and nothing of ours installed; client.js left untouched.");
@@ -26,6 +26,7 @@ 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.";
@@ -68,12 +69,16 @@ function main() {
68
69
  return;
69
70
  }
70
71
  // The one state we act on: pristine upstream, anchors resolve.
71
- mkdirSync(HERE, { recursive: true });
72
- copyFileSync(client, join(HERE, "backup-client.js"));
73
- copyFileSync(index, join(HERE, "backup-index.js"));
72
+ mkdirSync(dataDir(), { recursive: true });
73
+ copyFileSync(client, join(dataDir(), "backup-client.js"));
74
+ copyFileSync(index, join(dataDir(), "backup-index.js"));
74
75
  writeFileSync(client, applyPatch(text));
75
76
  copyFileSync(join(HERE, "stub-index.js"), index);
76
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");
77
82
  }
78
83
 
79
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
+ }