claudeup 4.40.0 → 4.40.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.40.0",
3
+ "version": "4.40.1",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -64,8 +64,8 @@
64
64
  "typescript": "^5.6.3"
65
65
  },
66
66
  "optionalDependencies": {
67
- "claudeup-darwin-arm64": "4.40.0",
68
- "claudeup-darwin-x64": "4.40.0",
69
- "claudeup-linux-x64": "4.40.0"
67
+ "claudeup-darwin-arm64": "4.40.1",
68
+ "claudeup-darwin-x64": "4.40.1",
69
+ "claudeup-linux-x64": "4.40.1"
70
70
  }
71
71
  }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * REGRESSION: every installed plugin rendered "stale — reinstall" at once.
3
+ *
4
+ * `updateInstalledPluginsRegistry` re-copies a plugin's files out of the
5
+ * marketplace clone on every in-place update, but used to carry the pre-update
6
+ * `gitCommitSha` forward verbatim. The recorded commit therefore named whichever
7
+ * release the plugin was FIRST installed from — measured 2026-08-22 on one
8
+ * machine: dev claimed 4.3.0 against a sha holding 3.3.0, browser-use 1.4.1
9
+ * against 1.1.3 — and the subtree diff across those releases is always
10
+ * non-empty. Fixed in /dev:fix session dev-fix-20260822-125510-03db093b.
11
+ *
12
+ * Hermetic: exercises the provenance rule directly, with a real git repo. The
13
+ * registry write itself resolves through os.homedir() and is not sandboxable
14
+ * without new test infrastructure, so the decision is tested where it lives.
15
+ */
16
+
17
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
18
+ import { spawnSync } from "node:child_process";
19
+ import { promises as fs } from "node:fs";
20
+ import os from "node:os";
21
+ import path from "node:path";
22
+ import { resolveCacheProvenance } from "../services/claude-settings.js";
23
+
24
+ let repo: string;
25
+ let plain: string;
26
+
27
+ const git = (args: string[]) =>
28
+ spawnSync("git", ["-c", "commit.gpgsign=false", ...args], {
29
+ cwd: repo,
30
+ encoding: "utf-8",
31
+ });
32
+
33
+ beforeEach(async () => {
34
+ repo = await fs.mkdtemp(path.join(os.tmpdir(), "provenance-repo-"));
35
+ plain = await fs.mkdtemp(path.join(os.tmpdir(), "provenance-plain-"));
36
+ await fs.mkdir(path.join(repo, "plugins", "alpha"), { recursive: true });
37
+ await fs.writeFile(path.join(repo, "plugins", "alpha", "plugin.json"), "{}");
38
+ git(["init", "-q", "-b", "main"]);
39
+ git(["config", "user.email", "probe@example.invalid"]);
40
+ git(["config", "user.name", "probe"]);
41
+ git(["add", "-A"]);
42
+ git(["commit", "-q", "-m", "release"]);
43
+ });
44
+
45
+ afterEach(async () => {
46
+ await fs.rm(repo, { recursive: true, force: true });
47
+ await fs.rm(plain, { recursive: true, force: true });
48
+ });
49
+
50
+ describe("resolveCacheProvenance", () => {
51
+ test("stamps the source repo's HEAD when files were copied", async () => {
52
+ const head = git(["rev-parse", "HEAD"]).stdout.trim();
53
+ const stale = "0".repeat(40);
54
+
55
+ expect(
56
+ await resolveCacheProvenance(path.join(repo, "plugins", "alpha"), stale),
57
+ ).toBe(head);
58
+ });
59
+
60
+ test("does NOT carry a stale sha forward after a successful copy", async () => {
61
+ // The bug in one line: the old code returned `stale` here, so the
62
+ // recorded commit fell further behind with every release.
63
+ const stale = "0".repeat(40);
64
+ expect(
65
+ await resolveCacheProvenance(path.join(repo, "plugins", "alpha"), stale),
66
+ ).not.toBe(stale);
67
+ });
68
+
69
+ test("keeps the previous sha when nothing was copied", async () => {
70
+ // A failed copy leaves the cache untouched, so the old sha still
71
+ // describes it. Advancing it would claim the cache holds newer files
72
+ // than it does, which hides real drift.
73
+ const previous = "a".repeat(40);
74
+ expect(await resolveCacheProvenance(null, previous)).toBe(previous);
75
+ expect(await resolveCacheProvenance(null, undefined)).toBeUndefined();
76
+ });
77
+
78
+ test("records no sha for a source outside any git repo", async () => {
79
+ // Directory-type marketplaces have no commit to name. Inventing one
80
+ // would assert a provenance that is false; content-drift reads an
81
+ // absent sha as "cannot answer" and stays silent.
82
+ expect(await resolveCacheProvenance(plain, "b".repeat(40))).toBeUndefined();
83
+ });
84
+ });
@@ -14,6 +14,7 @@ import path from "node:path";
14
14
  import {
15
15
  clearContentDriftCache,
16
16
  hasContentDrift,
17
+ repoHeadSha,
17
18
  } from "../services/content-drift.js";
18
19
 
19
20
  let configDir: string;
@@ -34,6 +35,13 @@ const git = (args: string[]) =>
34
35
 
35
36
  const head = () => git(["rev-parse", "HEAD"]).stdout.trim();
36
37
 
38
+ /** Rewrite a plugin's manifest version, mimicking a release. */
39
+ const setVersion = (plugin: string, version: string) =>
40
+ fs.writeFile(
41
+ path.join(repo, "plugins", plugin, "plugin.json"),
42
+ JSON.stringify({ name: plugin, version }, null, 2),
43
+ );
44
+
37
45
  beforeEach(async () => {
38
46
  prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
39
47
  configDir = await fs.mkdtemp(path.join(os.tmpdir(), "drift-"));
@@ -47,6 +55,8 @@ beforeEach(async () => {
47
55
  await fs.mkdir(path.join(repo, "plugins", "beta"), { recursive: true });
48
56
  await fs.writeFile(path.join(repo, "plugins", "alpha", "skills", "s.md"), "v1");
49
57
  await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v1");
58
+ await setVersion("alpha", "1.0.0");
59
+ await setVersion("beta", "1.0.0");
50
60
 
51
61
  git(["init", "-q", "-b", "main"]);
52
62
  git(["config", "user.email", "probe@example.invalid"]);
@@ -69,6 +79,7 @@ describe("hasContentDrift", () => {
69
79
  marketplace: "probemp",
70
80
  pluginName: "alpha",
71
81
  installedSha: sha,
82
+ installedVersion: "1.0.0",
72
83
  }),
73
84
  ).toBe(false);
74
85
  });
@@ -88,6 +99,7 @@ describe("hasContentDrift", () => {
88
99
  marketplace: "probemp",
89
100
  pluginName: "alpha",
90
101
  installedSha: sha,
102
+ installedVersion: "1.0.0",
91
103
  }),
92
104
  ).toBe(true);
93
105
  });
@@ -105,6 +117,7 @@ describe("hasContentDrift", () => {
105
117
  marketplace: "probemp",
106
118
  pluginName: "alpha",
107
119
  installedSha: sha,
120
+ installedVersion: "1.0.0",
108
121
  }),
109
122
  ).toBe(false);
110
123
  expect(
@@ -112,6 +125,7 @@ describe("hasContentDrift", () => {
112
125
  marketplace: "probemp",
113
126
  pluginName: "beta",
114
127
  installedSha: sha,
128
+ installedVersion: "1.0.0",
115
129
  }),
116
130
  ).toBe(true);
117
131
  });
@@ -129,6 +143,7 @@ describe("hasContentDrift", () => {
129
143
  marketplace: "probemp",
130
144
  pluginName: "alpha",
131
145
  installedSha: sha,
146
+ installedVersion: "1.0.0",
132
147
  }),
133
148
  ).toBe(true);
134
149
  });
@@ -141,6 +156,7 @@ describe("hasContentDrift", () => {
141
156
  marketplace: "probemp",
142
157
  pluginName: "alpha",
143
158
  installedSha: undefined,
159
+ installedVersion: "1.0.0",
144
160
  }),
145
161
  ).toBe(false);
146
162
  expect(
@@ -148,6 +164,7 @@ describe("hasContentDrift", () => {
148
164
  marketplace: "probemp",
149
165
  pluginName: "alpha",
150
166
  installedSha: "0".repeat(40),
167
+ installedVersion: "1.0.0",
151
168
  }),
152
169
  ).toBe(false);
153
170
  expect(
@@ -155,7 +172,118 @@ describe("hasContentDrift", () => {
155
172
  marketplace: "does-not-exist",
156
173
  pluginName: "alpha",
157
174
  installedSha: head(),
175
+ installedVersion: "1.0.0",
176
+ }),
177
+ ).toBe(false);
178
+ });
179
+
180
+ // REGRESSION: every installed plugin rendered "stale — reinstall" because
181
+ // updateInstalledPluginsRegistry bumped `version` while carrying the
182
+ // pre-update `gitCommitSha` forward. The sha then named a commit several
183
+ // releases old, so the subtree diff was non-empty for essentially every
184
+ // plugin. Fixed in /dev:fix session dev-fix-20260822-125510-03db093b.
185
+ test("a sha recorded against a DIFFERENT version cannot answer the question", async () => {
186
+ const oldSha = head();
187
+
188
+ // Ship 2.0.0 — the plugin genuinely changed, as a release does.
189
+ await fs.writeFile(
190
+ path.join(repo, "plugins", "alpha", "skills", "s.md"),
191
+ "v2 content",
192
+ );
193
+ await setVersion("alpha", "2.0.0");
194
+ git(["add", "-A"]);
195
+ git(["commit", "-q", "-m", "release 2.0.0"]);
196
+
197
+ // 2.0.0 is installed, but the registry still carries the 1.0.0 sha.
198
+ // The subtree diff between them is non-empty, and says nothing about
199
+ // whether the installed 2.0.0 files match the marketplace's 2.0.0.
200
+ expect(
201
+ await hasContentDrift({
202
+ marketplace: "probemp",
203
+ pluginName: "alpha",
204
+ installedSha: oldSha,
205
+ installedVersion: "2.0.0",
158
206
  }),
159
207
  ).toBe(false);
160
208
  });
209
+
210
+ test("a manifest that cannot be read at that sha reports no drift", async () => {
211
+ // Provenance unverifiable — the module's policy is silence, not a nag.
212
+ await fs.rm(path.join(repo, "plugins", "beta", "plugin.json"));
213
+ git(["add", "-A"]);
214
+ git(["commit", "-q", "-m", "beta loses its manifest"]);
215
+ const sha = head();
216
+
217
+ await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v2");
218
+ git(["add", "-A"]);
219
+ git(["commit", "-q", "-m", "beta content changes"]);
220
+
221
+ expect(
222
+ await hasContentDrift({
223
+ marketplace: "probemp",
224
+ pluginName: "beta",
225
+ installedSha: sha,
226
+ installedVersion: "1.0.0",
227
+ }),
228
+ ).toBe(false);
229
+ });
230
+
231
+ test("reads a manifest at .claude-plugin/plugin.json too", async () => {
232
+ // magus lays plugins out as plugins/<name>/plugin.json, but Claude Code's
233
+ // documented location is .claude-plugin/plugin.json. Both must resolve or
234
+ // the guard silences drift detection for half the ecosystem.
235
+ await fs.mkdir(path.join(repo, "plugins", "beta", ".claude-plugin"), {
236
+ recursive: true,
237
+ });
238
+ await fs.rm(path.join(repo, "plugins", "beta", "plugin.json"));
239
+ await fs.writeFile(
240
+ path.join(repo, "plugins", "beta", ".claude-plugin", "plugin.json"),
241
+ JSON.stringify({ name: "beta", version: "1.0.0" }),
242
+ );
243
+ git(["add", "-A"]);
244
+ git(["commit", "-q", "-m", "beta uses .claude-plugin"]);
245
+ const sha = head();
246
+
247
+ await fs.writeFile(path.join(repo, "plugins", "beta", "readme.md"), "v2");
248
+ git(["add", "-A"]);
249
+ git(["commit", "-q", "-m", "republish 1.0.0"]);
250
+
251
+ expect(
252
+ await hasContentDrift({
253
+ marketplace: "probemp",
254
+ pluginName: "beta",
255
+ installedSha: sha,
256
+ installedVersion: "1.0.0",
257
+ }),
258
+ ).toBe(true);
259
+ });
260
+ });
261
+
262
+ describe("repoHeadSha", () => {
263
+ // The other half of the same bug: nothing in claudeup ever recorded a fresh
264
+ // sha, so the registry could only ever go stale. This is what
265
+ // updateInstalledPluginsRegistry now calls to stamp real provenance onto the
266
+ // files it just copied out of the marketplace clone.
267
+ test("resolves HEAD from a path inside the repo", async () => {
268
+ const sha = head();
269
+ expect(await repoHeadSha(path.join(repo, "plugins", "alpha"))).toBe(sha);
270
+ expect(await repoHeadSha(repo)).toBe(sha);
271
+ });
272
+
273
+ test("returns undefined for a directory that is not in a git repo", async () => {
274
+ // Directory-type marketplaces have no commit to name. Recording a sha
275
+ // from some unrelated clone would assert a provenance that is false.
276
+ const plain = await fs.mkdtemp(path.join(os.tmpdir(), "nogit-"));
277
+ try {
278
+ expect(await repoHeadSha(plain)).toBeUndefined();
279
+ } finally {
280
+ await fs.rm(plain, { recursive: true, force: true });
281
+ }
282
+ });
283
+
284
+ test("returns undefined for a path that does not exist", async () => {
285
+ expect(
286
+ await repoHeadSha(path.join(configDir, "no", "such", "dir")),
287
+ ).toBeUndefined();
288
+ });
161
289
  });
@@ -13,6 +13,7 @@ import type {
13
13
  } from "../types/index.js";
14
14
  import { parsePluginId } from "../utils/string-utils.js";
15
15
  import { inheritablePaths } from "./git-worktree.js";
16
+ import { repoHeadSha } from "./content-drift.js";
16
17
 
17
18
  const CLAUDE_DIR = ".claude";
18
19
  const SETTINGS_FILE = "settings.json";
@@ -1509,19 +1510,23 @@ async function getPluginSourcePath(
1509
1510
  /**
1510
1511
  * Copy plugin files from source to cache
1511
1512
  * This ensures the cache is populated with the latest plugin version
1513
+ *
1514
+ * Returns the directory the files were copied FROM, or null if nothing was
1515
+ * copied. The caller needs the source path, not just a success flag: that path
1516
+ * is what identifies the commit the cached files came from.
1512
1517
  */
1513
1518
  async function copyPluginToCache(
1514
1519
  pluginId: string,
1515
1520
  version: string,
1516
1521
  marketplace: string,
1517
- ): Promise<boolean> {
1522
+ ): Promise<string | null> {
1518
1523
  const { pluginName } = parsePluginId(pluginId) || {
1519
1524
  pluginName: pluginId.split("@")[0],
1520
1525
  };
1521
1526
 
1522
1527
  const sourcePath = await getPluginSourcePath(pluginName, marketplace);
1523
1528
  if (!sourcePath) {
1524
- return false;
1529
+ return null;
1525
1530
  }
1526
1531
 
1527
1532
  const cachePath = getPluginCachePath(pluginId, version, marketplace);
@@ -1538,16 +1543,42 @@ async function copyPluginToCache(
1538
1543
  errorOnExist: false,
1539
1544
  });
1540
1545
 
1541
- return true;
1546
+ return sourcePath;
1542
1547
  } catch (error) {
1543
1548
  console.warn(
1544
1549
  `Failed to copy plugin ${pluginId} to cache:`,
1545
1550
  error instanceof Error ? error.message : "Unknown error",
1546
1551
  );
1547
- return false;
1552
+ return null;
1548
1553
  }
1549
1554
  }
1550
1555
 
1556
+ /**
1557
+ * The commit a cache copy should be stamped with.
1558
+ *
1559
+ * `gitCommitSha` must describe the files sitting in the cache RIGHT NOW, which
1560
+ * is the single thing content-drift detection reads it for. Two rules:
1561
+ *
1562
+ * - Files were copied → the source repo's HEAD is their provenance. Undefined
1563
+ * is a legitimate answer here: a directory-type marketplace has no commit,
1564
+ * and content-drift stays silent rather than guessing.
1565
+ * - Nothing was copied → the cache is untouched, so whatever sha already
1566
+ * described it still does. Advancing it would claim the cache holds newer
1567
+ * files than it does, hiding real drift.
1568
+ *
1569
+ * The bug this replaces did neither: it preserved the pre-update sha even when
1570
+ * fresh files HAD been copied, so the recorded commit drifted further behind
1571
+ * with every release and drift detection reported a false positive on every
1572
+ * plugin at once.
1573
+ */
1574
+ export async function resolveCacheProvenance(
1575
+ copiedFrom: string | null,
1576
+ previousSha: string | undefined,
1577
+ ): Promise<string | undefined> {
1578
+ if (!copiedFrom) return previousSha;
1579
+ return await repoHeadSha(copiedFrom);
1580
+ }
1581
+
1551
1582
  /**
1552
1583
  * Read installed_plugins.json registry
1553
1584
  */
@@ -1632,7 +1663,7 @@ export async function updateInstalledPluginsRegistry(
1632
1663
 
1633
1664
  // Copy plugin files from source to cache
1634
1665
  // This ensures the cache has the latest plugin version
1635
- await copyPluginToCache(pluginId, version, marketplace);
1666
+ const copiedFrom = await copyPluginToCache(pluginId, version, marketplace);
1636
1667
 
1637
1668
  const installPath = getPluginCachePath(pluginId, version, marketplace);
1638
1669
  const now = new Date().toISOString();
@@ -1659,10 +1690,12 @@ export async function updateInstalledPluginsRegistry(
1659
1690
  ? registry.plugins[pluginId][existingIndex].installedAt
1660
1691
  : now,
1661
1692
  lastUpdated: now,
1662
- gitCommitSha:
1693
+ gitCommitSha: await resolveCacheProvenance(
1694
+ copiedFrom,
1663
1695
  existingIndex >= 0
1664
1696
  ? registry.plugins[pluginId][existingIndex].gitCommitSha
1665
1697
  : undefined,
1698
+ ),
1666
1699
  };
1667
1700
 
1668
1701
  if (existingIndex >= 0) {
@@ -67,12 +67,33 @@ function git(cwd: string, args: string[]): Promise<{ code: number; out: string }
67
67
  });
68
68
  }
69
69
 
70
+ /**
71
+ * HEAD of the git repo containing `dir`, or undefined when there is none.
72
+ *
73
+ * This is what stamps provenance onto a cache copy: `copyPluginToCache` reads
74
+ * the plugin's files out of the marketplace clone, so that clone's HEAD is the
75
+ * commit those files came from. Undefined is a real answer — a directory-type
76
+ * marketplace has no commit to name, and inventing one would assert a
77
+ * provenance that is false.
78
+ */
79
+ export async function repoHeadSha(dir: string): Promise<string | undefined> {
80
+ const r = await git(dir, ["rev-parse", "HEAD"]);
81
+ if (r.code !== 0) return undefined;
82
+ return /^[0-9a-f]{40}$/.test(r.out) ? r.out : undefined;
83
+ }
84
+
70
85
  export interface DriftQuery {
71
86
  marketplace: string;
72
87
  /** Plugin name without the @marketplace suffix. */
73
88
  pluginName: string;
74
89
  /** `gitCommitSha` from the installed_plugins.json entry. */
75
90
  installedSha: string | undefined;
91
+ /**
92
+ * Version currently installed. Required, not optional: the sha alone cannot
93
+ * be trusted, and a caller that omitted this would silently get the old
94
+ * always-drifted behaviour back.
95
+ */
96
+ installedVersion: string | undefined;
76
97
  /**
77
98
  * Path of the plugin inside the marketplace repo, from the catalog `source`
78
99
  * field (e.g. "./plugins/dev"). Defaults to `plugins/<name>`.
@@ -80,19 +101,52 @@ export interface DriftQuery {
80
101
  sourcePath?: string;
81
102
  }
82
103
 
104
+ /** Strip a leading "v" so "v1.2.3" and "1.2.3" compare equal. */
105
+ const normalize = (v: string) => v.trim().replace(/^v/, "");
106
+
107
+ /**
108
+ * The version a plugin's manifest declared at a given commit.
109
+ *
110
+ * Both layouts are tried because both are real: magus lays plugins out as
111
+ * `plugins/<name>/plugin.json`, while Claude Code documents
112
+ * `.claude-plugin/plugin.json`. Reading only one would silence drift detection
113
+ * for every plugin using the other.
114
+ */
115
+ async function versionAtSha(
116
+ repo: string,
117
+ sha: string,
118
+ rel: string,
119
+ ): Promise<string | undefined> {
120
+ for (const manifest of [
121
+ `${rel}/plugin.json`,
122
+ `${rel}/.claude-plugin/plugin.json`,
123
+ ]) {
124
+ const r = await git(repo, ["show", `${sha}:${manifest}`]);
125
+ if (r.code !== 0 || !r.out) continue;
126
+ try {
127
+ const v = JSON.parse(r.out)?.version;
128
+ if (typeof v === "string" && v) return v;
129
+ } catch {
130
+ // Unparseable manifest is the same as no manifest: unverifiable.
131
+ }
132
+ }
133
+ return undefined;
134
+ }
135
+
83
136
  /**
84
137
  * True when the plugin's files in the marketplace differ from the commit it was
85
138
  * installed from — i.e. a reinstall would deliver different content.
86
139
  *
87
140
  * Returns false whenever the question cannot be answered honestly: no recorded
88
- * sha, no clone, git unavailable, or the recorded commit is absent locally
89
- * (force-push, shallow clone). A false negative leaves today's behaviour; a
90
- * false positive would nag the user to reinstall for no reason.
141
+ * sha, no clone, git unavailable, the recorded commit is absent locally
142
+ * (force-push, shallow clone), or that commit declares a DIFFERENT version than
143
+ * the one installed. A false negative leaves today's behaviour; a false
144
+ * positive would nag the user to reinstall for no reason.
91
145
  */
92
146
  export async function hasContentDrift(q: DriftQuery): Promise<boolean> {
93
147
  if (!q.installedSha) return false;
94
148
 
95
- const key = `${q.marketplace}\0${q.pluginName}\0${q.installedSha}`;
149
+ const key = `${q.marketplace}\0${q.pluginName}\0${q.installedSha}\0${q.installedVersion}`;
96
150
  const hit = cache.get(key);
97
151
  if (hit !== undefined) return hit;
98
152
 
@@ -106,6 +160,29 @@ export async function hasContentDrift(q: DriftQuery): Promise<boolean> {
106
160
  return false;
107
161
  }
108
162
 
163
+ // The recorded sha must describe the version actually installed, or the diff
164
+ // answers a different question than the one asked.
165
+ //
166
+ // This is the whole reason the badge fired on every plugin at once:
167
+ // `updateInstalledPluginsRegistry` bumps `version` on an in-place update but
168
+ // carries the pre-update `gitCommitSha` forward, so the sha names whichever
169
+ // commit the plugin was FIRST installed from. Measured 2026-08-22 on one
170
+ // machine: dev recorded 4.3.0 against a sha holding 3.3.0, browser-use 1.4.1
171
+ // against 1.1.3. Diffing across those releases is trivially non-empty, so
172
+ // every plugin rendered "stale — reinstall" while its cached files were in
173
+ // fact byte-identical to the marketplace.
174
+ const shaVersion = q.installedVersion
175
+ ? await versionAtSha(repo, q.installedSha, rel)
176
+ : undefined;
177
+ if (
178
+ !q.installedVersion ||
179
+ !shaVersion ||
180
+ normalize(shaVersion) !== normalize(q.installedVersion)
181
+ ) {
182
+ cache.set(key, false);
183
+ return false;
184
+ }
185
+
109
186
  // Exit 0 = no difference, 1 = differs, anything else = could not tell.
110
187
  const diff = await git(repo, [
111
188
  "diff",
@@ -722,6 +722,7 @@ async function annotateContentDrift(
722
722
  marketplace: plugin.marketplace,
723
723
  pluginName: plugin.name,
724
724
  installedSha: entry.gitCommitSha,
725
+ installedVersion: plugin.installedVersion,
725
726
  });
726
727
  } catch {
727
728
  /* leave unflagged */