dsh-git-badge 0.8.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The seam patch as anchor pairs — the single definition of the patch.
3
+ *
4
+ * Every entry is [name, old, new]: `old` must occur EXACTLY ONCE in the target
5
+ * client.js, and is replaced by `new`. apply.js runs these against the INSTALLED
6
+ * file in place; the repo's seam/make-patch.sh runs them against the pinned
7
+ * pristine snapshot to regenerate patched-client.js for the upstream-PR diff
8
+ * only.
9
+ *
10
+ * This replaces the old whole-file hash guard: a DSH update that changes
11
+ * anything outside these blocks no longer invalidates the patch. Only a change
12
+ * that moves one of the anchors does — and the failure names the anchor that
13
+ * moved instead of reporting an opaque hash mismatch.
14
+ *
15
+ * Tabs matter: the target is a bundled, tab-indented file. The strings below
16
+ * use \t escapes so a reader cannot mistake them for spaces. When upstream
17
+ * moves an anchor, edit that entry's `old` (and `new`, if the insertion point
18
+ * changed) to the new upstream text — the drift report names which one.
19
+ */
20
+
21
+ export const MARKER = "dsh-git-badge:seam-patch";
22
+ // Bumped whenever the anchor set changes shape. An installed file carrying the
23
+ // MARKER without this revision is OUR older artifact: apply restores its backup
24
+ // and re-patches rather than refusing.
25
+ export const PATCH_REV = "2";
26
+ export const REV_COMMENT = `${MARKER} rev ${PATCH_REV}`;
27
+
28
+ // The seam strings upstream would declare if the patch were merged. Present in
29
+ // a file that is NOT ours => upstream landed; the patch is a no-op.
30
+ export const SEAM_STRINGS = [
31
+ '"sidebar.workspaces.sessionRow"',
32
+ '"sidebar.workspaces.sessionRow.detail"',
33
+ '"sidebar.workspaces.row"', // the pre-session-row seam, also upstream's if landed
34
+ ];
35
+
36
+ export const PATCHES = [
37
+ // --- 1. Session-row seam helper, inserted with the row component's props ---
38
+ [
39
+ "SessionNodeItem header + seam helpers",
40
+ "\t\tfunction SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, onReveal, drag, flat = false, t }) {",
41
+ "\t\t/* dsh-git-badge:seam-patch rev 2 — seam/apply.sh recognises its own artifact by this\n\t\t * marker, which upstream would never carry. */\n\t\t/**\n\t\t* Seam entry boundary. The host wraps each registered ENTRY in its own error\n\t\t* boundary, but the outlet a row renders is NOT covered by it, so a throw\n\t\t* anywhere in an occupant's render path would propagate into the workspace\n\t\t* browser itself — and the shell ABDICATES that browser entry, blanking the\n\t\t* whole sidebar. A seam must not be able to do that: this keeps the blast\n\t\t* radius at \"no badge\", and logs what happened instead of swallowing it.\n\t\t*/\n\t\tclass SeamBoundary extends react.Component {\n\t\t\tconstructor(props) {\n\t\t\t\tsuper(props);\n\t\t\t\tthis.state = { failed: false };\n\t\t\t}\n\t\t\tstatic getDerivedStateFromError() {\n\t\t\t\treturn { failed: true };\n\t\t\t}\n\t\t\tcomponentDidCatch(error) {\n\t\t\t\tconsole.error(\"[dsh-git-badge] seam entry failed; badge omitted:\", error);\n\t\t\t}\n\t\t\trender() {\n\t\t\t\treturn this.state.failed ? null : this.props.children;\n\t\t\t}\n\t\t}\n\t\t/** Seam entry bodies, so the boundary above ENCLOSES the renderSlot call. */\n\t\tfunction SessionRowSeam({ renderSlot, sessionId, workspaceId, label }) {\n\t\t\treturn renderSlot(\"sidebar.workspaces.sessionRow\", { sessionId, workspaceId, label });\n\t\t}\n\t\tfunction SessionRowDetailSeam({ renderSlot, sessionId, workspaceId, label }) {\n\t\t\treturn renderSlot(\"sidebar.workspaces.sessionRow.detail\", { sessionId, workspaceId, label });\n\t\t}\n\t\t/**\n\t\t* Session-row seam (sidebar.workspaces.sessionRow): renders the additive\n\t\t* list-slot entries for one session row with the row owner share\n\t\t* ({ sessionId, workspaceId, label }).\n\t\t*\n\t\t* Returns null — not a fallback element — when no plugin occupies the seam,\n\t\t* so a pristine install renders exactly as upstream. `workspaceId` is only\n\t\t* ever passed from the TREE call site: the flat \"all sessions\" list and the\n\t\t* search-result list render this component without it, which is what keeps\n\t\t* badges off those lists without either of them needing a guard.\n\t\t*/\n\t\tfunction renderSessionRowSeam(renderSlot, sessionId, workspaceId, label) {\n\t\t\tif (renderSlot === void 0 || workspaceId === void 0) return null;\n\t\t\treturn (0, react_jsx_runtime.jsx)(SeamBoundary, {\n\t\t\t\tchildren: (0, react_jsx_runtime.jsx)(SessionRowSeam, { renderSlot, sessionId, workspaceId, label })\n\t\t\t});\n\t\t}\n\t\tfunction SessionNodeItem({ node, currentId, now, onOpen, onRename, onFork, onArchive, onReveal, drag, flat = false, t, renderSlot, workspaceId }) {",
42
+ ],
43
+ // --- 2. The badge sits with the title, before the schedule/time cluster ---
44
+ [
45
+ "title span + badge render",
46
+ '\t\t\t\t\t\t(0, react_jsx_runtime.jsx)("span", {\n\t\t\t\t\t\t\tclassName: Rows_module_css_default.title,\n\t\t\t\t\t\t\tchildren: title\n\t\t\t\t\t\t}),',
47
+ '\t\t\t\t\t\t(0, react_jsx_runtime.jsx)("span", {\n\t\t\t\t\t\t\tclassName: Rows_module_css_default.title,\n\t\t\t\t\t\t\tchildren: title\n\t\t\t\t\t\t}),\n\t\t\t\t\t\trenderSessionRowSeam(renderSlot, node.id, workspaceId, title),',
48
+ ],
49
+ // --- 3. The hover card gains the detail seam ---
50
+ [
51
+ "SessionHoverContent call-site props",
52
+ '\t\t\t\tcontent: (0, react_jsx_runtime.jsx)(SessionHoverContent, {\n\t\t\t\t\tnode,\n\t\t\t\t\tnow,\n\t\t\t\t\tt\n\t\t\t\t}),',
53
+ '\t\t\t\tcontent: (0, react_jsx_runtime.jsx)(SessionHoverContent, {\n\t\t\t\t\tnode,\n\t\t\t\t\tnow,\n\t\t\t\t\tt,\n\t\t\t\t\trenderSlot,\n\t\t\t\t\tworkspaceId\n\t\t\t\t}),',
54
+ ],
55
+ [
56
+ "SessionHoverContent header",
57
+ "\t\tfunction SessionHoverContent({ node, now, t }) {",
58
+ "\t\tfunction SessionHoverContent({ node, now, t, renderSlot, workspaceId }) {",
59
+ ],
60
+ [
61
+ "hover card detail row",
62
+ "\t\t\t\t\t}, status.label))\n\t\t\t\t]\n\t\t\t});\n\t\t}",
63
+ '\t\t\t\t\t}, status.label)),\n\t\t\t\t\trenderSlot !== void 0 && workspaceId !== void 0 ? (0, react_jsx_runtime.jsx)("div", {\n\t\t\t\t\t\tclassName: Rows_module_css_default.hoverStatus,\n\t\t\t\t\t\tchildren: (0, react_jsx_runtime.jsx)(SeamBoundary, {\n\t\t\t\t\t\t\tchildren: (0, react_jsx_runtime.jsx)(SessionRowDetailSeam, { renderSlot, sessionId: node.id, workspaceId, label: displayTitle(node, t) })\n\t\t\t\t\t\t})\n\t\t\t\t\t}) : null\n\t\t\t\t]\n\t\t\t});\n\t\t}',
64
+ ],
65
+ // --- 4. Thread renderSlot: WorkspaceBrowser -> SessionTree ---
66
+ // A bare `renderSlot` at the SessionNodeItem call site resolves in THIS scope.
67
+ // The first cut of this patch threaded it into SessionNodeItem but not into
68
+ // SessionTree, so the row render threw `ReferenceError: renderSlot is not
69
+ // defined` the moment sessions appeared — and the shell abdicated the whole
70
+ // sidebar with it. Any prop a patched call site reads by shorthand must arrive
71
+ // through every intermediate scope.
72
+ [
73
+ "SessionTree header",
74
+ "\t\tfunction SessionTree({ useSessions, useSessionPendingInteraction, startSession, open, forkSession, workspaces, archivedSessionIds, workspaceReady, usePanelInfo, onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertWorkspaceBefore, insertSessionBefore, orderBy, groupExpansion, setGroupExpanded, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, home, t, revealSessionId, onSessionRevealed }) {",
75
+ "\t\tfunction SessionTree({ useSessions, useSessionPendingInteraction, startSession, open, forkSession, workspaces, archivedSessionIds, workspaceReady, usePanelInfo, onRenameRequest, onDeleteRequest, onSessionRename, onSessionArchive, insertWorkspaceBefore, insertSessionBefore, orderBy, groupExpansion, setGroupExpanded, sessionOrderByAccount, sessionUpdatedAtByAccount, syncSessionOrderAccount, setSessionOrder, home, t, renderSlot, revealSessionId, onSessionRevealed }) {",
76
+ ],
77
+ [
78
+ "SessionTree call site",
79
+ "(0, react_jsx_runtime.jsx)(SessionTree, {\n\t\t\t\t\t\t\tusePanelInfo,",
80
+ "(0, react_jsx_runtime.jsx)(SessionTree, {\n\t\t\t\t\t\t\trenderSlot,\n\t\t\t\t\t\t\tusePanelInfo,",
81
+ ],
82
+ // --- 5. Thread renderSlot + the owning workspace id from the TREE call site only ---
83
+ [
84
+ "SessionNodeItem call site",
85
+ "\t\t\t\t\t\t\t\t\t\treturn (0, react_jsx_runtime.jsx)(SessionNodeItem, {\n\t\t\t\t\t\t\t\t\t\t\tnode,\n\t\t\t\t\t\t\t\t\t\t\tcurrentId: current,",
86
+ "\t\t\t\t\t\t\t\t\t\treturn (0, react_jsx_runtime.jsx)(SessionNodeItem, {\n\t\t\t\t\t\t\t\t\t\t\tnode,\n\t\t\t\t\t\t\t\t\t\t\tcurrentId: current,\n\t\t\t\t\t\t\t\t\t\t\trenderSlot,\n\t\t\t\t\t\t\t\t\t\t\t// only the tree knows which workspace a row belongs to; the\n\t\t\t\t\t\t\t\t\t\t\t// flat and search lists pass neither prop, so they stay bare\n\t\t\t\t\t\t\t\t\t\t\tworkspaceId: group.workspaceId,",
87
+ ],
88
+ // --- 6. Declare the two seam children on the sidebar.workspaces registration ---
89
+ [
90
+ "seam children registration",
91
+ '\t\t\t\tchildren: { "sidebar.workspaces.directoryFlow": {\n\t\t\t\t\tkind: "single",\n\t\t\t\t\tscope: "root"\n\t\t\t\t} },',
92
+ '\t\t\t\tchildren: { "sidebar.workspaces.directoryFlow": {\n\t\t\t\t\tkind: "single",\n\t\t\t\t\tscope: "root"\n\t\t\t\t}, "sidebar.workspaces.sessionRow": {\n\t\t\t\t\tkind: "list",\n\t\t\t\t\tscope: "root"\n\t\t\t\t}, "sidebar.workspaces.sessionRow.detail": {\n\t\t\t\t\tkind: "list",\n\t\t\t\t\tscope: "root"\n\t\t\t\t} },',
93
+ ],
94
+ ];
95
+
96
+ export function anchorCounts(text) {
97
+ return Object.fromEntries(PATCHES.map(([name, old]) => [name, text.split(old).length - 1]));
98
+ }
99
+
100
+ export function firstFailure(text) {
101
+ for (const [name, old] of PATCHES) {
102
+ const n = text.split(old).length - 1;
103
+ if (n !== 1) return { name, count: n };
104
+ }
105
+ return null;
106
+ }
107
+
108
+ export function applyPatch(text) {
109
+ for (const [name, old] of PATCHES) {
110
+ const n = text.split(old).length - 1;
111
+ if (n !== 1) {
112
+ throw new Error(
113
+ `anchor ${JSON.stringify(name)} found ${n} times (need exactly 1) — upstream drift; nothing was patched`
114
+ );
115
+ }
116
+ }
117
+ for (const [, old, replacement] of PATCHES) text = text.replace(old, replacement);
118
+ return text;
119
+ }
120
+
121
+ export function isOurs(text) {
122
+ return text.includes(MARKER);
123
+ }
124
+
125
+ export function isCurrentRev(text) {
126
+ return text.includes(REV_COMMENT);
127
+ }
128
+
129
+ export function seamPresent(text) {
130
+ return SEAM_STRINGS.some((s) => text.includes(s));
131
+ }
132
+
133
+ /** A patched file's applied signature (the `old` anchor forms are gone — that is the point). */
134
+ export function appliedSignature(text) {
135
+ return (
136
+ seamPresent(text) && text.includes("function renderSessionRowSeam") && text.includes("class SeamBoundary")
137
+ );
138
+ }
package/seam/apply.js ADDED
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * dsh-git-badge-seam — apply / revert / inspect the sidebar session-row seam
4
+ * patch on an installed DSH package.
5
+ *
6
+ * WHY THIS EXISTS: the sidebar session-row badges need a slot the DSH workspace
7
+ * browser does not declare upstream yet (proposed upstream — see PR.md in the
8
+ * plugin's repository). The patch is SEAM-ONLY; all badge logic lives in the
9
+ * dsh-git-badge plugin itself. This tool is shipped IN the package, so a market
10
+ * install needs no clone:
11
+ *
12
+ * dsh plugin --profile web add dsh-git-badge
13
+ * npx dsh-git-badge apply # then restart the dsh web process
14
+ *
15
+ * HOW PATCHING WORKS — anchors, not hashes. The patch is defined once in
16
+ * anchors.js as [name, old, new] triples. `apply` locates each `old` block in
17
+ * the INSTALLED client.js (each must occur exactly once) and replaces it in
18
+ * place. A DSH update that changes anything else in the file no longer matters;
19
+ * only a change that moves one of the anchors does, and the failure names the
20
+ * anchor that moved instead of reporting an opaque hash mismatch.
21
+ *
22
+ * Safety:
23
+ * - anchor assertion: every anchor must match exactly once; anything else is
24
+ * drift and NOTHING is written
25
+ * - own-artifact detection: every artifact carries the dsh-git-badge:seam-patch
26
+ * marker (with a rev number), so our stale patch can be upgraded in place
27
+ * while a genuine upstream landing (seam declared, no marker) is left alone
28
+ * - backup of the bytes as found: revert restores exactly what was installed
29
+ * before WE patched, guarded against downgrading a newer upstream build
30
+ * - the pinned KNOWN_GOOD_HASH is ADVISORY only: status reports whether the
31
+ * installed build is the one the anchors were verified against, but apply
32
+ * proceeds on any build whose anchors resolve
33
+ *
34
+ * Environment:
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).
38
+ *
39
+ * Usage: apply.js apply | apply.js revert | apply.js status
40
+ */
41
+ import { execSync } from "node:child_process";
42
+ import { createHash } from "node:crypto";
43
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
44
+ import { dirname, join } from "node:path";
45
+ import { fileURLToPath } from "node:url";
46
+ import {
47
+ MARKER,
48
+ applyPatch,
49
+ appliedSignature,
50
+ firstFailure,
51
+ isCurrentRev,
52
+ isOurs,
53
+ seamPresent,
54
+ } from "./anchors.js";
55
+
56
+ const HERE = dirname(fileURLToPath(import.meta.url));
57
+ const DATA_DIR = process.env.SEAM_DATA_DIR || HERE;
58
+
59
+ // sha256 of the upstream build the anchors were last verified against. ADVISORY:
60
+ // status prints it for orientation; apply does NOT require it.
61
+ const KNOWN_GOOD_HASH = "383b9ef779366c13d818500b6488896328b189f156addbaa480c835e902edd5f";
62
+
63
+ /** Locate the installed DSH package root; null when it cannot be found. */
64
+ function dshRoot() {
65
+ if (process.env.DSH_INSTALL) return process.env.DSH_INSTALL;
66
+ try {
67
+ return join(execSync("npm root -g", { encoding: "utf8" }).trim(), "@deepseek-ai", "dsh");
68
+ } catch {
69
+ return null;
70
+ }
71
+ }
72
+
73
+ const DSH = dshRoot();
74
+ if (!DSH) {
75
+ console.error("ERROR: cannot locate the DSH package. Set DSH_INSTALL to its root");
76
+ console.error("(e.g. the directory containing node_modules/@deepseek-ai/dsh).");
77
+ process.exit(1);
78
+ }
79
+ const PKG = join(DSH, "node_modules", "@deepseek-ai", "dsh-client-ui-workspace");
80
+ const CLIENT = join(PKG, "lib", "client.js");
81
+ const INDEX = join(PKG, "lib", "index.js");
82
+ const STUB_INDEX = join(HERE, "stub-index.js"); // no-op host half; copied over index.js on apply
83
+ const BACKUP_CLIENT = join(DATA_DIR, "backup-client.js");
84
+ const BACKUP_INDEX = join(DATA_DIR, "backup-index.js");
85
+
86
+ const sha256 = (text) => createHash("sha256").update(text, "utf8").digest("hex");
87
+
88
+ /** Inspect the installed client.js. Returns the same key:value report apply.sh printed. */
89
+ function inspect() {
90
+ const report = {};
91
+ let text;
92
+ try {
93
+ text = readClient();
94
+ } catch (error) {
95
+ if (error.code === "ENOENT") {
96
+ report.state = "missing";
97
+ return report;
98
+ }
99
+ report.state = "unreadable";
100
+ report.error = String(error.message ?? error);
101
+ return report;
102
+ }
103
+ report.hash = sha256(text);
104
+ report.known_good_match = report.hash === KNOWN_GOOD_HASH ? "yes" : "no";
105
+ report.backup = existsSync(BACKUP_CLIENT) ? "yes" : "no";
106
+ report.marker = isOurs(text) ? "yes" : "no";
107
+ report.rev_current = isCurrentRev(text) ? "yes" : "no";
108
+ report.seam_strings = seamPresent(text) ? "yes" : "no";
109
+
110
+ const fail = firstFailure(text);
111
+ if (fail === null) report.anchors = "all-resolve";
112
+ else {
113
+ report.anchors = "drift";
114
+ report.anchor_fail = `${fail.name}:${fail.count}`;
115
+ }
116
+
117
+ if (isOurs(text)) {
118
+ // A PATCHED file no longer contains the `old` anchor forms — that is the
119
+ // point — so it is verified by its applied signature instead.
120
+ report.applied_signature = appliedSignature(text) ? "yes" : "no";
121
+ if (!isCurrentRev(text)) report.state = "ours-stale";
122
+ else if (appliedSignature(text)) report.state = "ours-current";
123
+ else report.state = "ours-corrupt";
124
+ } else if (seamPresent(text)) report.state = "upstream-landed";
125
+ else if (fail === null) report.state = "patchable";
126
+ else report.state = "drift";
127
+ return report;
128
+ }
129
+
130
+ function readClient() {
131
+ return readText(CLIENT);
132
+ }
133
+
134
+ function readText(path) {
135
+ return readFileSync(path, "utf8");
136
+ }
137
+
138
+ /** Backup the bytes AS FOUND, patch client.js in place, install the no-op host half. */
139
+ function doPatch() {
140
+ mkdirSync(DATA_DIR, { recursive: true });
141
+ copyFileSync(CLIENT, BACKUP_CLIENT);
142
+ copyFileSync(INDEX, BACKUP_INDEX);
143
+ const patched = applyPatch(readClient());
144
+ writeText(CLIENT, patched);
145
+ copyFileSync(STUB_INDEX, INDEX);
146
+ console.log("patched lib/client.js in place (anchors applied); host half stubbed.");
147
+ }
148
+
149
+ function writeText(path, text) {
150
+ writeFileSync(path, text, "utf8");
151
+ }
152
+
153
+ const VERDICT_EXIT_1 = new Set(["drift", "unreadable", "missing", "ours-corrupt"]);
154
+
155
+ const verb = process.argv[2];
156
+ if (!verb || !["apply", "revert", "status"].includes(verb)) {
157
+ console.error("usage: apply.js apply|revert|status");
158
+ process.exit(1);
159
+ }
160
+
161
+ if (verb === "status") {
162
+ const r = inspect();
163
+ console.log(`installed: ${CLIENT}`);
164
+ console.log(`hash: ${r.hash ?? r.error ?? "-"}`);
165
+ if (r.known_good_match === "yes") console.log("pinned: KNOWN-GOOD MATCH — the build the anchors were verified against");
166
+ else console.log(`pinned: ${KNOWN_GOOD_HASH} — differs (advisory only; anchors are the guard)`);
167
+ console.log(`anchors: ${r.anchors ?? "-"}`);
168
+ if (r.anchors === "drift") console.log(` ${r.anchor_fail}`);
169
+ console.log(`marker: ${r.marker ?? "-"} (rev current: ${r.rev_current ?? "-"}, applied signature: ${r.applied_signature ?? "n/a"})`);
170
+ console.log(`backup: ${r.backup ?? "-"}`);
171
+ console.log(`state: ${r.state}`);
172
+ switch (r.state) {
173
+ case "ours-current":
174
+ console.log("verdict: PATCHED — sidebar session-row badges come from this repo's patch.");
175
+ break;
176
+ case "ours-stale":
177
+ console.log("verdict: PATCHED, OUT OF DATE — our older artifact; 'apply' upgrades in place.");
178
+ break;
179
+ case "ours-corrupt":
180
+ console.log("verdict: BROKEN ARTIFACT — marker present but anchors/seam inconsistent. Inspect by hand.");
181
+ break;
182
+ case "upstream-landed":
183
+ console.log("verdict: UPSTREAM LANDED — 'apply' no-ops; the patch can be retired.");
184
+ break;
185
+ case "patchable":
186
+ console.log("verdict: PATCHABLE — 'apply' will patch it in place.");
187
+ break;
188
+ case "drift":
189
+ console.log("verdict: DRIFT — an anchor moved; update seam/anchors.js for the new upstream.");
190
+ break;
191
+ case "missing":
192
+ console.log("verdict: INSTALLED PACKAGE NOT FOUND — check DSH_INSTALL.");
193
+ break;
194
+ default:
195
+ console.log(`verdict: ${r.state.toUpperCase()} — see error above.`);
196
+ }
197
+ process.exit(VERDICT_EXIT_1.has(r.state) ? 1 : 0);
198
+ }
199
+
200
+ if (verb === "apply") {
201
+ const r = inspect();
202
+ for (const [k, v] of Object.entries(r)) {
203
+ if (k !== "state") console.log(` ${k}=${v}`);
204
+ }
205
+ console.log(`installed: ${CLIENT}`);
206
+ switch (r.state) {
207
+ case "ours-current":
208
+ console.log("verdict: PATCHED (rev current) — nothing to do.");
209
+ break;
210
+ case "patchable":
211
+ doPatch();
212
+ console.log("verdict: PATCHED.");
213
+ break;
214
+ case "ours-stale": {
215
+ // Our older artifact. The re-patch target is the BACKUP (the bytes as we
216
+ // found them pre-patch). Validate THAT before touching the installed
217
+ // file: restoring a backup whose anchors no longer resolve would
218
+ // overwrite the installed build with an unpatchable older one.
219
+ if (!existsSync(BACKUP_CLIENT)) {
220
+ console.error("REFUSING: installed file is an older artifact of ours but no backup");
221
+ console.error("exists, so the original bytes are unknown. Reinstall the package or");
222
+ console.error("restore the backup by hand, then re-run apply.");
223
+ process.exit(1);
224
+ }
225
+ const backupText = readText(BACKUP_CLIENT);
226
+ const bstate = isOurs(backupText)
227
+ ? "ours"
228
+ : seamPresent(backupText)
229
+ ? "upstream-landed"
230
+ : firstFailure(backupText) === null
231
+ ? "patchable"
232
+ : "drift";
233
+ if (bstate !== "patchable") {
234
+ console.error(`REFUSING: the backup (bytes as found before patching) is '${bstate}',`);
235
+ console.error("not patchable — the anchors no longer match the backed-up upstream.");
236
+ console.error("Nothing was changed. Reinstall the DSH package, then re-run apply.");
237
+ process.exit(1);
238
+ }
239
+ writeText(CLIENT, backupText);
240
+ copyFileSync(BACKUP_INDEX, INDEX);
241
+ doPatch();
242
+ console.log("verdict: UPGRADED — older artifact replaced with the current patch.");
243
+ break;
244
+ }
245
+ case "upstream-landed":
246
+ console.error("verdict: UPSTREAM LANDED — the installed client.js declares the seam");
247
+ console.error("and is NOT this repo's artifact. Patch skipped (plugin only); the patch");
248
+ console.error("can be retired.");
249
+ break;
250
+ case "drift": {
251
+ const [name, count] = r.anchor_fail.split(":");
252
+ console.error("REFUSING: anchor drift — nothing was written.");
253
+ console.error(` anchor: ${name} (found ${count} times, need exactly 1)`);
254
+ console.error("Upstream changed code this patch anchors on. Update the matching entry");
255
+ console.error("in anchors.js, then re-run: dsh-git-badge-seam status && dsh-git-badge-seam apply");
256
+ process.exit(1);
257
+ }
258
+ case "ours-corrupt":
259
+ console.error("REFUSING: the installed file carries this repo's marker but is neither");
260
+ console.error("the current revision nor a restorable state. Inspect by hand:");
261
+ console.error(` grep -n '${MARKER}' "${CLIENT}"`);
262
+ process.exit(1);
263
+ break;
264
+ default:
265
+ console.error(`REFUSING: installed client.js is '${r.state}'.`);
266
+ process.exit(1);
267
+ }
268
+ console.log(`now install the plugin: dsh plugin --profile web add dsh-git-badge`);
269
+ } else {
270
+ // revert
271
+ if (existsSync(BACKUP_CLIENT)) {
272
+ const r = inspect();
273
+ const installedHash = r.hash;
274
+ const backupHash = sha256(readText(BACKUP_CLIENT));
275
+ if (installedHash === backupHash) {
276
+ // Idempotent re-run: the installed bytes already ARE the backup.
277
+ console.log("already reverted — installed client.js matches the backup; nothing to do.");
278
+ } else if (r.state === "ours-current" || r.state === "ours-stale" || r.state === "ours-corrupt") {
279
+ writeText(CLIENT, readText(BACKUP_CLIENT));
280
+ copyFileSync(BACKUP_INDEX, INDEX);
281
+ console.log("reverted client.js/index.js to the bytes as found before patching.");
282
+ } else {
283
+ // Installed file is not ours => upstream replaced it since patching.
284
+ console.error("REFUSING to revert: the installed client.js is not this repo's");
285
+ console.error(`artifact (state: ${r.state}), so upstream replaced it since the patch was`);
286
+ console.error("applied. Restoring the backup would DOWNGRADE the package.");
287
+ console.error("Nothing was changed. If the downgrade is really what you want, restore");
288
+ console.error(`${BACKUP_CLIENT} by hand.`);
289
+ process.exit(1);
290
+ }
291
+ } else {
292
+ const r = inspect();
293
+ 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");
297
+ process.exit(1);
298
+ }
299
+ console.log("no backup present and nothing of ours installed; client.js left untouched.");
300
+ }
301
+ // restore the no-op host half if no backup of the original exists
302
+ if (!existsSync(BACKUP_INDEX)) copyFileSync(STUB_INDEX, INDEX);
303
+ console.log("remember to remove the plugin: dsh plugin --profile web remove dsh-git-badge");
304
+ }
@@ -0,0 +1,11 @@
1
+ //#region lib/types/index.js
2
+ /**
3
+ * Workspace picker plugin, node half. Pure UI plugin: the empty apply exists
4
+ * so the plugin appears in the host cordis.yml / Loader (load and lifecycle
5
+ * follow the host; the browser half ships via exports["./client"], discovered
6
+ * through the package.json dsh.client declaration).
7
+ */
8
+ /** Host plugin body — no host-side behavior for the workspace picker plugin. */
9
+ function apply() {}
10
+ //#endregion
11
+ export { apply };