@xpufx/paseo-plugin-updates 0.1.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.
Files changed (99) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +108 -0
  3. package/client/orphans.ts +37 -0
  4. package/client/updates.tsx +510 -0
  5. package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
  6. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
  7. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
  8. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
  9. package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
  10. package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
  11. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
  12. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
  13. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
  14. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
  15. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
  16. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
  17. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
  18. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
  19. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
  20. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
  21. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
  22. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
  23. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
  24. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
  25. package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
  26. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
  27. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
  28. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
  29. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
  30. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
  31. package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
  32. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
  33. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
  34. package/client/vendor/paseo-plugin-helper/host.ts +277 -0
  35. package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
  36. package/client/vendor/paseo-plugin-helper/index.ts +28 -0
  37. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
  38. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
  39. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
  40. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
  41. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
  42. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
  43. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
  44. package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
  45. package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
  46. package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
  47. package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
  48. package/client/vendor/paseo-plugin-helper/query.ts +66 -0
  49. package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
  50. package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
  51. package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
  52. package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
  53. package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
  54. package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
  55. package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
  56. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
  57. package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
  58. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
  59. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
  60. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
  61. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
  62. package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
  63. package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
  64. package/package.json +37 -0
  65. package/paseo-plugin.json +4 -0
  66. package/server/__node-test-shim.ts +14 -0
  67. package/server/updates.ts +1332 -0
  68. package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
  69. package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
  70. package/server/vendor/paseo-plugin-helper/index.ts +18 -0
  71. package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
  72. package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
  73. package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
  74. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
  75. package/server/vendor/paseo-plugin-helper/network.ts +91 -0
  76. package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
  77. package/server/vendor/paseo-plugin-helper/process.ts +186 -0
  78. package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
  79. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
  80. package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
  81. package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
  82. package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
  83. package/server/vendor/paseo-plugin-helper/system.ts +128 -0
  84. package/server/vendor/paseo-plugin-helper/task.ts +116 -0
  85. package/server/vendor/paseo-plugin-helper/version.ts +153 -0
  86. package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
  87. package/shared/updates.ts +239 -0
  88. package/shared/vendor/paseo-plugin-helper/README.md +11 -0
  89. package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
  90. package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
  91. package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
  92. package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
  93. package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
  94. package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
  95. package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
  96. package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
  97. package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
  98. package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
  99. package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
@@ -0,0 +1,1332 @@
1
+ import { createHash } from "node:crypto";
2
+ import fs from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import path from "node:path";
5
+ import { listPlugins, safeSpawn, type PaseoPluginInfo, type SafeSpawnResult } from "paseo-plugin-helper/server";
6
+ import { deriveSourceUrl, shortHash } from "../shared/updates";
7
+ import type {
8
+ PluginUpdate,
9
+ PluginUpdateActionResult,
10
+ PluginUpdateChange,
11
+ RefKind,
12
+ } from "../shared/updates";
13
+
14
+ const PROBE_TIMEOUT_MS = 10_000;
15
+ const FETCH_TIMEOUT_MS = 30_000;
16
+ const UPDATE_TIMEOUT_MS = 120_000;
17
+ const RELOAD_TIMEOUT_MS = 60_000;
18
+
19
+ // This plugin's own install id. Paseo runs plugin backend code in the plugin's
20
+ // subprocess, so `paseo plugin reload plugin-updates` terminates the very
21
+ // process executing the update loop (#210). We therefore never reload ourselves
22
+ // from inside ourselves.
23
+ const SELF_PLUGIN_ID = "plugin-updates";
24
+
25
+ // Shown when the updater updated itself: the pull succeeded, but applying it
26
+ // requires a reload that only the host can safely perform.
27
+ const SELF_UPDATE_DETAIL =
28
+ "Updated in place. Reload plugin-updates from Settings to apply (a plugin cannot safely reload itself).";
29
+
30
+ // Report-only verdict for an install whose tracked ref or plugin subdirectory is gone.
31
+ const MISSING_SOURCE_DETAIL = "Plugin does not exist at the source it was installed from.";
32
+
33
+ type CommandRunner = (
34
+ command: string,
35
+ args: string[],
36
+ options: { cwd?: string; timeoutMs: number },
37
+ ) => Promise<SafeSpawnResult>;
38
+
39
+ type ReadDir = (dir: string) => Promise<Array<{ name: string; isDirectory(): boolean }>>;
40
+ type ReadFile = (file: string) => Promise<string>;
41
+ type Mkdir = (dir: string) => Promise<void>;
42
+
43
+ const runCommand: CommandRunner = (command, args, options) =>
44
+ safeSpawn(command, args, { cwd: options.cwd, timeoutMs: options.timeoutMs });
45
+
46
+ const defaultReadDir: ReadDir = (dir) => fs.promises.readdir(dir, { withFileTypes: true });
47
+ const defaultReadFile: ReadFile = (file) => fs.promises.readFile(file, "utf8");
48
+ const defaultMkdir: Mkdir = async (dir) => {
49
+ await fs.promises.mkdir(dir, { recursive: true });
50
+ };
51
+
52
+ export interface ProbeDeps {
53
+ pluginsRoot?: string;
54
+ homeDir?: string;
55
+ cacheRoot?: string;
56
+ readDir?: ReadDir;
57
+ readFile?: ReadFile;
58
+ mkdir?: Mkdir;
59
+ scanOrphans?: boolean;
60
+ }
61
+
62
+ function defaultPluginsRoot(): string {
63
+ return path.join(homedir(), ".paseo", "plugins");
64
+ }
65
+
66
+ function outputOf(result: SafeSpawnResult): string {
67
+ return [result.stdout, result.stderr].filter(Boolean).join("\n").trim();
68
+ }
69
+
70
+ function errorOf(error: unknown): string {
71
+ return error instanceof Error ? error.message : String(error);
72
+ }
73
+
74
+ function normalizeDir(value: string): string {
75
+ return path.resolve(value.trim().replace(/\/+$/, ""));
76
+ }
77
+
78
+ function toPosix(value: string): string {
79
+ return value.split(path.sep).join("/");
80
+ }
81
+
82
+ const FULL_COMMIT_RE = /^[0-9a-f]{40,64}$/i;
83
+ // Git abbreviates commit ids down to as few as 7 hex chars; a ref matching this
84
+ // shape is a commit pin, not a movable branch/tag name.
85
+ const COMMIT_REF_RE = /^[0-9a-f]{7,64}$/i;
86
+
87
+ function isCommitSha(value: string): boolean {
88
+ return COMMIT_REF_RE.test(value.trim());
89
+ }
90
+
91
+ function parseCommit(output: string): string | null {
92
+ const value = output.trim();
93
+ return FULL_COMMIT_RE.test(value) ? value : null;
94
+ }
95
+
96
+ function treeExpr(commit: string, subdir: string): string {
97
+ return subdir ? `${commit}:${subdir}` : `${commit}^{tree}`;
98
+ }
99
+
100
+ function scopeLabel(subdir: string | null): string {
101
+ if (subdir === null) return "the plugin";
102
+ return subdir === "" ? "the repository root" : subdir;
103
+ }
104
+
105
+ async function git(
106
+ runner: CommandRunner,
107
+ args: string[],
108
+ cwd: string | undefined,
109
+ timeoutMs: number = PROBE_TIMEOUT_MS,
110
+ ): Promise<SafeSpawnResult> {
111
+ try {
112
+ return await runner("git", args, { cwd, timeoutMs });
113
+ } catch (error) {
114
+ return { stdout: "", stderr: errorOf(error), code: null, signal: null, durationMs: 0 };
115
+ }
116
+ }
117
+
118
+ interface LsRemoteLine {
119
+ sha: string;
120
+ ref: string;
121
+ }
122
+
123
+ function parseLsRemote(output: string): LsRemoteLine[] {
124
+ const lines: LsRemoteLine[] = [];
125
+ for (const raw of output.split(/\r?\n/)) {
126
+ const line = raw.trim();
127
+ if (!line) continue;
128
+ const [sha, ref] = line.split(/\s+/);
129
+ if (sha && ref && /^[0-9a-f]{40,64}$/i.test(sha)) lines.push({ sha, ref });
130
+ }
131
+ return lines;
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // Managed install metadata (~/.paseo/plugins/sources.json + config fallback)
136
+ // ---------------------------------------------------------------------------
137
+
138
+ interface ManagedRecord {
139
+ remote: string;
140
+ requestedRef: string | null;
141
+ trackingBranch: string | null;
142
+ commit: string | null;
143
+ pluginPath: string | null;
144
+ }
145
+
146
+ function asString(value: unknown): string | null {
147
+ return typeof value === "string" && value.trim() ? value.trim() : null;
148
+ }
149
+
150
+ function toManagedRecord(value: unknown): ManagedRecord | null {
151
+ if (!value || typeof value !== "object") return null;
152
+ const record = value as Record<string, unknown>;
153
+ const remote = asString(record.remote);
154
+ if (!remote) return null;
155
+ const commit = asString(record.commit);
156
+ return {
157
+ remote,
158
+ requestedRef: asString(record.requestedRef) ?? asString(record.ref),
159
+ trackingBranch: asString(record.trackingBranch),
160
+ commit: commit && /^[0-9a-f]{40,64}$/i.test(commit) ? commit : null,
161
+ pluginPath: asString(record.pluginPath),
162
+ };
163
+ }
164
+
165
+ async function readJson(
166
+ readFile: ReadFile,
167
+ file: string,
168
+ ): Promise<Record<string, unknown> | null> {
169
+ try {
170
+ const parsed: unknown = JSON.parse(await readFile(file));
171
+ return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
172
+ } catch {
173
+ return null;
174
+ }
175
+ }
176
+
177
+ interface PackageUrls {
178
+ repositoryUrl: string | null;
179
+ homepage: string | null;
180
+ }
181
+
182
+ // package.json `repository` is either a string or `{ url }`; treat both, and
183
+ // swallow any read/parse failure as "no metadata".
184
+ async function readPackageUrls(pluginPath: string | undefined, readFile: ReadFile): Promise<PackageUrls> {
185
+ if (!pluginPath) return { repositoryUrl: null, homepage: null };
186
+ const pkg = await readJson(readFile, path.join(pluginPath, "package.json"));
187
+ if (!pkg) return { repositoryUrl: null, homepage: null };
188
+ const repository = pkg.repository;
189
+ const repositoryUrl =
190
+ typeof repository === "string"
191
+ ? asString(repository)
192
+ : repository && typeof repository === "object"
193
+ ? asString((repository as Record<string, unknown>).url)
194
+ : null;
195
+ return { repositoryUrl, homepage: asString(pkg.homepage) };
196
+ }
197
+
198
+ async function resolveSourceUrl(
199
+ plugin: PaseoPluginInfo,
200
+ identity: PluginIdentity,
201
+ resolution: RefResolution,
202
+ deps: ProbeDeps,
203
+ ): Promise<string | null> {
204
+ const urls = await readPackageUrls(plugin.path, deps.readFile ?? defaultReadFile);
205
+ return deriveSourceUrl({
206
+ repositoryUrl: urls.repositoryUrl,
207
+ homepage: urls.homepage,
208
+ remoteUrl: resolution.remoteUrl,
209
+ ref: resolution.ref,
210
+ subdir: identity.subdir,
211
+ });
212
+ }
213
+
214
+ async function loadManagedRecords(deps: ProbeDeps): Promise<Map<string, ManagedRecord>> {
215
+ const home = deps.homeDir ?? homedir();
216
+ const pluginsRoot = deps.pluginsRoot ?? path.join(home, ".paseo", "plugins");
217
+ const readFile = deps.readFile ?? defaultReadFile;
218
+ const records = new Map<string, ManagedRecord>();
219
+
220
+ const sources = await readJson(readFile, path.join(pluginsRoot, "sources.json"));
221
+ if (sources) {
222
+ for (const [id, value] of Object.entries(sources)) {
223
+ const record = toManagedRecord(value);
224
+ if (record) records.set(id, record);
225
+ }
226
+ }
227
+
228
+ const config = await readJson(readFile, path.join(home, ".paseo", "config.json"));
229
+ const configPlugins = config?.plugins;
230
+ if (configPlugins && typeof configPlugins === "object") {
231
+ for (const [id, value] of Object.entries(configPlugins as Record<string, unknown>)) {
232
+ if (records.has(id)) continue;
233
+ const record = toManagedRecord(value);
234
+ if (record) records.set(id, record);
235
+ }
236
+ }
237
+ return records;
238
+ }
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Identity: (repo root, ref, subdir)
242
+ // ---------------------------------------------------------------------------
243
+
244
+ interface PluginIdentity {
245
+ plugin: PaseoPluginInfo;
246
+ repoRoot: string | null;
247
+ subdir: string | null;
248
+ managed: ManagedRecord | null;
249
+ }
250
+
251
+ async function resolveRepoRoot(pluginPath: string, runner: CommandRunner): Promise<string | null> {
252
+ const result = await git(runner, ["rev-parse", "--show-toplevel"], pluginPath);
253
+ if (result.code === 0 && result.stdout.trim()) return normalizeDir(result.stdout);
254
+ return null;
255
+ }
256
+
257
+ async function resolveIdentity(
258
+ plugin: PaseoPluginInfo,
259
+ managed: ManagedRecord | null,
260
+ runner: CommandRunner,
261
+ ): Promise<PluginIdentity> {
262
+ if (!plugin.path) return { plugin, repoRoot: null, subdir: null, managed };
263
+ const repoRoot = await resolveRepoRoot(plugin.path, runner);
264
+ if (!repoRoot) return { plugin, repoRoot: null, subdir: null, managed };
265
+ let subdir = toPosix(path.relative(repoRoot, normalizeDir(plugin.path)));
266
+ if (subdir === ".") subdir = "";
267
+ if (managed?.pluginPath !== null && managed?.pluginPath !== undefined) {
268
+ subdir = managed.pluginPath === "." ? "" : toPosix(managed.pluginPath);
269
+ }
270
+ return { plugin, repoRoot, subdir, managed };
271
+ }
272
+
273
+ // ---------------------------------------------------------------------------
274
+ // Ref resolution
275
+ // ---------------------------------------------------------------------------
276
+
277
+ type ReportOnly = "pinned" | "unpinned" | "no-upstream";
278
+
279
+ interface RefResolution {
280
+ remote: string | null;
281
+ remoteUrl: string | null;
282
+ ref: string | null;
283
+ refKind: RefKind | null;
284
+ reportOnly: ReportOnly | null;
285
+ detail: string | null;
286
+ }
287
+
288
+ function pinnedResolution(remote: string, ref: string | null): RefResolution {
289
+ return {
290
+ remote,
291
+ remoteUrl: remote,
292
+ ref,
293
+ refKind: "sha",
294
+ reportOnly: "pinned",
295
+ detail: ref ? `Pinned to immutable commit ${shortHash(ref)} — report only` : "Pinned to an immutable commit — report only",
296
+ };
297
+ }
298
+
299
+ async function resolveRef(
300
+ identity: PluginIdentity,
301
+ runner: CommandRunner,
302
+ ): Promise<RefResolution> {
303
+ const managed = identity.managed;
304
+ if (managed) {
305
+ // A tracked branch is the authoritative moving ref; only fall back to the
306
+ // requested ref when there is no branch to track.
307
+ if (managed.trackingBranch) {
308
+ return {
309
+ remote: managed.remote,
310
+ remoteUrl: managed.remote,
311
+ ref: managed.trackingBranch,
312
+ refKind: "branch",
313
+ reportOnly: null,
314
+ detail: null,
315
+ };
316
+ }
317
+ const requested = managed.requestedRef;
318
+ if (requested && isCommitSha(requested)) {
319
+ return pinnedResolution(managed.remote, requested);
320
+ }
321
+ if (!requested) {
322
+ if (managed.commit) return pinnedResolution(managed.remote, managed.commit);
323
+ return {
324
+ remote: managed.remote,
325
+ remoteUrl: managed.remote,
326
+ ref: null,
327
+ refKind: null,
328
+ reportOnly: "unpinned",
329
+ detail: "No ref recorded for this git-managed install — report only",
330
+ };
331
+ }
332
+ return {
333
+ remote: managed.remote,
334
+ remoteUrl: managed.remote,
335
+ ref: requested,
336
+ refKind: "tag",
337
+ reportOnly: null,
338
+ detail: null,
339
+ };
340
+ }
341
+
342
+ if (identity.plugin.source === "git" && identity.plugin.remote && identity.plugin.ref) {
343
+ if (isCommitSha(identity.plugin.ref)) {
344
+ return pinnedResolution(identity.plugin.remote, identity.plugin.ref);
345
+ }
346
+ return {
347
+ remote: identity.plugin.remote,
348
+ remoteUrl: identity.plugin.remote,
349
+ ref: identity.plugin.ref,
350
+ refKind: "branch",
351
+ reportOnly: null,
352
+ detail: null,
353
+ };
354
+ }
355
+
356
+ if (!identity.repoRoot) {
357
+ return { remote: null, remoteUrl: null, ref: null, refKind: null, reportOnly: "unpinned", detail: "Not a git repository" };
358
+ }
359
+
360
+ const upstream = await git(
361
+ runner,
362
+ ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
363
+ identity.repoRoot,
364
+ );
365
+ const upstreamName = upstream.code === 0 ? upstream.stdout.trim() : "";
366
+ if (upstreamName && upstreamName !== "HEAD") {
367
+ const separator = upstreamName.indexOf("/");
368
+ const remoteName = separator > 0 ? upstreamName.slice(0, separator) : upstreamName;
369
+ const ref = separator > 0 ? upstreamName.slice(separator + 1) : null;
370
+ const urlResult = await git(runner, ["remote", "get-url", remoteName], identity.repoRoot);
371
+ const remoteUrl = urlResult.code === 0 ? urlResult.stdout.trim() : null;
372
+ if (!ref) {
373
+ return {
374
+ remote: remoteName,
375
+ remoteUrl: remoteUrl,
376
+ ref: null,
377
+ refKind: null,
378
+ reportOnly: "unpinned",
379
+ detail: "Unable to resolve the upstream branch name — report only",
380
+ };
381
+ }
382
+ return {
383
+ remote: remoteName,
384
+ remoteUrl: remoteUrl ?? remoteName,
385
+ ref,
386
+ refKind: "branch",
387
+ reportOnly: null,
388
+ detail: null,
389
+ };
390
+ }
391
+
392
+ const branchResult = await git(runner, ["rev-parse", "--abbrev-ref", "HEAD"], identity.repoRoot);
393
+ const current = branchResult.code === 0 ? branchResult.stdout.trim() : "";
394
+ if (!current || current === "HEAD") {
395
+ return {
396
+ remote: null,
397
+ remoteUrl: null,
398
+ ref: null,
399
+ refKind: "detached",
400
+ reportOnly: "unpinned",
401
+ detail: "Detached HEAD — no upstream to compare; report only",
402
+ };
403
+ }
404
+ return {
405
+ remote: null,
406
+ remoteUrl: null,
407
+ ref: current,
408
+ refKind: "branch",
409
+ reportOnly: "no-upstream",
410
+ detail: `Branch '${current}' has no upstream remote; report only`,
411
+ };
412
+ }
413
+
414
+ // ---------------------------------------------------------------------------
415
+ // Remote cache + per-remote state (deduped per (remote, ref))
416
+ // ---------------------------------------------------------------------------
417
+
418
+ interface RemoteRefState {
419
+ commit: string | null;
420
+ refKind: RefKind | null;
421
+ refExists: boolean;
422
+ cacheDir: string;
423
+ shallow: boolean;
424
+ error: string | null;
425
+ }
426
+
427
+ interface RemoteCache {
428
+ states: Map<string, Promise<RemoteRefState>>;
429
+ cacheRoot: string;
430
+ }
431
+
432
+ function remoteKey(remoteUrl: string, ref: string): string {
433
+ return `${remoteUrl}\0${ref}`;
434
+ }
435
+
436
+ function cacheDirFor(cacheRoot: string, remoteUrl: string, ref: string): string {
437
+ const hash = createHash("sha256").update(remoteKey(remoteUrl, ref)).digest("hex").slice(0, 24);
438
+ return path.join(cacheRoot, hash);
439
+ }
440
+
441
+ async function fetchIntoCache(
442
+ cacheDir: string,
443
+ remoteUrl: string,
444
+ resolution: RefResolution,
445
+ filtered: boolean,
446
+ runner: CommandRunner,
447
+ deps: ProbeDeps,
448
+ ): Promise<string | null> {
449
+ const mkdir = deps.mkdir ?? defaultMkdir;
450
+ try {
451
+ await mkdir(path.dirname(cacheDir));
452
+ } catch {
453
+ // git init below will surface a real failure if the directory is unusable.
454
+ }
455
+ const init = await git(runner, ["init", "--bare", "-q", cacheDir], undefined);
456
+ if (init.code !== 0) {
457
+ return outputOf(init) || "git init of the update cache failed";
458
+ }
459
+ const refArg =
460
+ resolution.refKind === "tag" ? `refs/tags/${resolution.ref}` : (resolution.ref as string);
461
+ const args = ["-C", cacheDir, "fetch", "--depth=1", "--no-tags"];
462
+ if (filtered) args.push("--filter=tree:0");
463
+ args.push(remoteUrl, refArg);
464
+ const fetch = await git(runner, args, undefined, FETCH_TIMEOUT_MS);
465
+ if (fetch.code !== 0) {
466
+ return outputOf(fetch) || `git fetch ${remoteUrl} failed`;
467
+ }
468
+ return null;
469
+ }
470
+
471
+ async function resolveRemoteState(
472
+ resolution: RefResolution,
473
+ cache: RemoteCache,
474
+ runner: CommandRunner,
475
+ deps: ProbeDeps,
476
+ ): Promise<RemoteRefState> {
477
+ const remoteUrl = resolution.remoteUrl!;
478
+ const ref = resolution.ref!;
479
+ const cacheDir = cacheDirFor(cache.cacheRoot, remoteUrl, ref);
480
+
481
+ // A bare tag name matches only the unpeeled `refs/tags/<tag>` line, which for
482
+ // an annotated tag is the tag OBJECT, not the commit. Ask for the peeled ref
483
+ // explicitly so the compared commit is the tag's target commit.
484
+ const tagRef = `refs/tags/${ref}`;
485
+ const ls = await git(runner, ["ls-remote", remoteUrl, ref, tagRef, `${tagRef}^{}`], undefined);
486
+ if (ls.code !== 0) {
487
+ return { commit: null, refKind: resolution.refKind, refExists: false, cacheDir, shallow: false, error: outputOf(ls) || "git ls-remote failed" };
488
+ }
489
+ const lines = parseLsRemote(ls.stdout);
490
+ const heads = lines.filter((line) => line.ref.startsWith("refs/heads/"));
491
+ const tags = lines.filter((line) => line.ref.startsWith("refs/tags/"));
492
+
493
+ let commit: string | null = null;
494
+ let refKind: RefKind | null = resolution.refKind;
495
+ if (resolution.refKind !== "tag" && heads.length > 0) {
496
+ commit = (heads.find((line) => line.ref === `refs/heads/${ref}`) ?? heads[0]!).sha;
497
+ refKind = "branch";
498
+ } else if (tags.length > 0) {
499
+ // Annotated tags peel to the commit via `^{}`; lightweight tags have no
500
+ // peeled entry, so the direct ref already points at the commit.
501
+ const peeled = tags.find((line) => line.ref === `${tagRef}^{}`);
502
+ const direct = tags.find((line) => line.ref === tagRef);
503
+ commit = (peeled ?? direct ?? tags[0]!).sha;
504
+ refKind = "tag";
505
+ }
506
+ if (!commit) {
507
+ // The remote is reachable but holds no ref with this name: the tracked
508
+ // branch/tag was deleted. That is report-only, not a raw git error.
509
+ return { commit: null, refKind, refExists: false, cacheDir, shallow: false, error: null };
510
+ }
511
+
512
+ const resolved: RefResolution = { ...resolution, refKind };
513
+ const fetchError = await fetchIntoCache(cacheDir, remoteUrl, resolved, true, runner, deps);
514
+ if (fetchError) {
515
+ return { commit, refKind, refExists: true, cacheDir, shallow: false, error: fetchError };
516
+ }
517
+ const shallowResult = await git(runner, ["-C", cacheDir, "rev-parse", "--is-shallow-repository"], undefined);
518
+ const shallow = shallowResult.code === 0 && shallowResult.stdout.trim() === "true";
519
+ return { commit, refKind, refExists: true, cacheDir, shallow, error: null };
520
+ }
521
+
522
+ async function getRemoteState(
523
+ resolution: RefResolution,
524
+ cache: RemoteCache,
525
+ runner: CommandRunner,
526
+ deps: ProbeDeps,
527
+ ): Promise<RemoteRefState> {
528
+ const key = remoteKey(resolution.remoteUrl!, resolution.ref!);
529
+ let promise = cache.states.get(key);
530
+ if (!promise) {
531
+ promise = resolveRemoteState(resolution, cache, runner, deps);
532
+ cache.states.set(key, promise);
533
+ }
534
+ return promise;
535
+ }
536
+
537
+ interface RemoteTree {
538
+ tree: string | null;
539
+ absent: boolean;
540
+ error: string | null;
541
+ }
542
+
543
+ async function readRemoteTree(
544
+ state: RemoteRefState,
545
+ resolution: RefResolution,
546
+ subdir: string,
547
+ runner: CommandRunner,
548
+ deps: ProbeDeps,
549
+ ): Promise<RemoteTree> {
550
+ let result = await git(runner, ["-C", state.cacheDir, "rev-parse", treeExpr(state.commit!, subdir)], undefined);
551
+ if (result.code !== 0) {
552
+ const message = outputOf(result);
553
+ if (/not a valid object name|bad object|missing/i.test(message)) {
554
+ const refetched = await fetchIntoCache(
555
+ state.cacheDir,
556
+ resolution.remoteUrl!,
557
+ { ...resolution, refKind: state.refKind },
558
+ false,
559
+ runner,
560
+ deps,
561
+ );
562
+ if (refetched) return { tree: null, absent: false, error: refetched };
563
+ result = await git(runner, ["-C", state.cacheDir, "rev-parse", treeExpr(state.commit!, subdir)], undefined);
564
+ }
565
+ }
566
+ if (result.code === 0) return { tree: parseCommit(result.stdout), absent: false, error: null };
567
+ const message = outputOf(result);
568
+ if (/does not exist|exists on disk, but not in/i.test(message)) {
569
+ return { tree: null, absent: true, error: null };
570
+ }
571
+ return { tree: null, absent: false, error: message || "Unable to read the remote subdirectory tree" };
572
+ }
573
+
574
+ async function readLatestChange(
575
+ cacheDir: string,
576
+ commit: string,
577
+ subdir: string,
578
+ runner: CommandRunner,
579
+ shallow: boolean,
580
+ ): Promise<PluginUpdateChange> {
581
+ if (shallow) return { commit, date: null, subject: null };
582
+ const result = await git(
583
+ runner,
584
+ ["-C", cacheDir, "log", "-1", "--format=%H%x1f%cI%x1f%s", commit, "--", subdir || "."],
585
+ undefined,
586
+ );
587
+ if (result.code === 0 && result.stdout.trim()) {
588
+ const [changeCommit, date, subject] = result.stdout.trim().split("\x1f");
589
+ if (changeCommit) return { commit: changeCommit, date: date ?? null, subject: subject ?? null };
590
+ }
591
+ return { commit, date: null, subject: null };
592
+ }
593
+
594
+ // ---------------------------------------------------------------------------
595
+ // Local side
596
+ // ---------------------------------------------------------------------------
597
+
598
+ interface LocalState {
599
+ localCommit: string | null;
600
+ localTree: string | null;
601
+ dirty: boolean | null;
602
+ workingTree: string | null;
603
+ }
604
+
605
+ async function revParseTree(
606
+ runner: CommandRunner,
607
+ repoRoot: string,
608
+ commit: string,
609
+ subdir: string,
610
+ ): Promise<string | null> {
611
+ const result = await git(runner, ["rev-parse", treeExpr(commit, subdir)], repoRoot);
612
+ return result.code === 0 ? parseCommit(result.stdout) : null;
613
+ }
614
+
615
+ async function workingTreeHash(
616
+ repoRoot: string,
617
+ subdir: string,
618
+ runner: CommandRunner,
619
+ ): Promise<string | null> {
620
+ const stash = await git(runner, ["stash", "create"], repoRoot);
621
+ if (stash.code !== 0) return null;
622
+ const commit = parseCommit(stash.stdout);
623
+ if (!commit) return null;
624
+ return revParseTree(runner, repoRoot, commit, subdir);
625
+ }
626
+
627
+ async function readLocalState(
628
+ identity: PluginIdentity,
629
+ runner: CommandRunner,
630
+ ): Promise<LocalState> {
631
+ const head = await git(runner, ["rev-parse", "HEAD"], identity.repoRoot!);
632
+ const localCommit = parseCommit(head.stdout);
633
+ const localTree = localCommit
634
+ ? await revParseTree(runner, identity.repoRoot!, localCommit, identity.subdir ?? "")
635
+ : null;
636
+
637
+ let dirty: boolean | null = null;
638
+ let workingTree: string | null = null;
639
+ if (identity.plugin.source !== "git") {
640
+ const status = await git(runner, ["status", "--porcelain", "--", identity.subdir || "."], identity.repoRoot!);
641
+ if (status.code === 0) {
642
+ dirty = status.stdout.trim().length > 0;
643
+ if (dirty) workingTree = await workingTreeHash(identity.repoRoot!, identity.subdir ?? "", runner);
644
+ }
645
+ }
646
+ return { localCommit, localTree, dirty, workingTree };
647
+ }
648
+
649
+ // ---------------------------------------------------------------------------
650
+ // Verdicts
651
+ // ---------------------------------------------------------------------------
652
+
653
+ interface Verdict {
654
+ status: PluginUpdate["status"];
655
+ updateAvailable: boolean;
656
+ detail: string;
657
+ }
658
+
659
+ async function isAncestor(
660
+ runner: CommandRunner,
661
+ repoRoot: string,
662
+ ancestor: string,
663
+ descendant: string,
664
+ ): Promise<boolean | null> {
665
+ const result = await git(runner, ["merge-base", "--is-ancestor", ancestor, descendant], repoRoot);
666
+ if (result.code === 0) return true;
667
+ if (result.code === 1) return false;
668
+ return null;
669
+ }
670
+
671
+ async function mergeBaseCommit(
672
+ runner: CommandRunner,
673
+ repoRoot: string,
674
+ a: string,
675
+ b: string,
676
+ ): Promise<string | null> {
677
+ const result = await git(runner, ["merge-base", a, b], repoRoot);
678
+ return result.code === 0 ? parseCommit(result.stdout) : null;
679
+ }
680
+
681
+ async function classifyBranch(
682
+ identity: PluginIdentity,
683
+ resolution: RefResolution,
684
+ local: LocalState,
685
+ state: RemoteRefState,
686
+ remote: RemoteTree,
687
+ runner: CommandRunner,
688
+ ): Promise<Verdict> {
689
+ const scope = scopeLabel(identity.subdir);
690
+ const localCommit = local.localCommit;
691
+ const remoteCommit = state.commit;
692
+ if (localCommit && remoteCommit && localCommit === remoteCommit) {
693
+ return { status: "current", updateAvailable: false, detail: `Up to date with ${resolution.ref} (${shortHash(remoteCommit)})` };
694
+ }
695
+ if (local.localTree && remote.tree && local.localTree === remote.tree) {
696
+ return { status: "current", updateAvailable: false, detail: `Subdirectory tree matches remote ${resolution.ref}` };
697
+ }
698
+
699
+ if (localCommit && remoteCommit) {
700
+ const localBehind = await isAncestor(runner, identity.repoRoot!, localCommit, remoteCommit);
701
+ if (localBehind === true) {
702
+ return { status: "behind", updateAvailable: true, detail: `Remote ${resolution.ref} is ahead (${shortHash(remoteCommit)}); update available` };
703
+ }
704
+ const remoteBehind = await isAncestor(runner, identity.repoRoot!, remoteCommit, localCommit);
705
+ if (remoteBehind === true) {
706
+ return { status: "current", updateAvailable: false, detail: `Local is ahead of remote ${resolution.ref}; nothing to pull` };
707
+ }
708
+ const base = await mergeBaseCommit(runner, identity.repoRoot!, localCommit, remoteCommit);
709
+ if (base) {
710
+ const baseTree = await revParseTree(runner, identity.repoRoot!, base, identity.subdir ?? "");
711
+ if (baseTree !== null && baseTree === remote.tree) {
712
+ return {
713
+ status: "current",
714
+ updateAvailable: false,
715
+ detail: `Local is ahead; remote ${resolution.ref} did not change ${scope} — nothing to pull`,
716
+ };
717
+ }
718
+ if (baseTree === local.localTree) {
719
+ return { status: "behind", updateAvailable: true, detail: `Remote ${resolution.ref} changed ${scope}; update available` };
720
+ }
721
+ return { status: "behind", updateAvailable: true, detail: `Both local and remote ${resolution.ref} changed ${scope}; update may conflict` };
722
+ }
723
+ }
724
+ return { status: "behind", updateAvailable: true, detail: `Subdirectory tree differs from remote ${resolution.ref}; update available` };
725
+ }
726
+
727
+ function classifyTag(
728
+ identity: PluginIdentity,
729
+ resolution: RefResolution,
730
+ state: RemoteRefState,
731
+ ): Verdict {
732
+ // `paseo plugin update` no-ops for any install without a tracking branch, so
733
+ // a tag install is never actionable. Report-only: the tag never "moves" into
734
+ // an update we could apply.
735
+ const commit = identity.managed?.commit ?? state.commit;
736
+ return {
737
+ status: "pinned",
738
+ updateAvailable: false,
739
+ detail: `Pinned to tag ${resolution.ref} (${shortHash(commit)}) — tags are not auto-updated; report only`,
740
+ };
741
+ }
742
+
743
+ // ---------------------------------------------------------------------------
744
+ // Rows
745
+ // ---------------------------------------------------------------------------
746
+
747
+ function emptyRow(plugin: PaseoPluginInfo, identity: PluginIdentity, resolution: RefResolution): PluginUpdate {
748
+ return {
749
+ id: plugin.id,
750
+ path: plugin.path,
751
+ source: plugin.source ?? null,
752
+ sourceUrl: null,
753
+ repoRoot: identity.repoRoot,
754
+ subdir: identity.subdir,
755
+ ref: resolution.ref,
756
+ refKind: resolution.refKind,
757
+ remote: resolution.remote,
758
+ localCommit: null,
759
+ remoteCommit: null,
760
+ localTree: null,
761
+ remoteTree: null,
762
+ workingTree: null,
763
+ dirty: null,
764
+ updateAvailable: false,
765
+ status: "current",
766
+ error: null,
767
+ detail: null,
768
+ latestChange: null,
769
+ };
770
+ }
771
+
772
+ function notARepoRow(plugin: PaseoPluginInfo): PluginUpdate {
773
+ return {
774
+ id: plugin.id,
775
+ path: plugin.path,
776
+ source: plugin.source ?? null,
777
+ sourceUrl: null,
778
+ repoRoot: null,
779
+ subdir: null,
780
+ ref: null,
781
+ refKind: null,
782
+ remote: null,
783
+ localCommit: null,
784
+ remoteCommit: null,
785
+ localTree: null,
786
+ remoteTree: null,
787
+ workingTree: null,
788
+ dirty: null,
789
+ updateAvailable: false,
790
+ status: "not-a-repo",
791
+ error: null,
792
+ detail: "Not a git repository — no git update possible",
793
+ latestChange: null,
794
+ };
795
+ }
796
+
797
+ interface ProbeContext {
798
+ runner: CommandRunner;
799
+ deps: ProbeDeps;
800
+ cache: RemoteCache;
801
+ }
802
+
803
+ async function probeOne(
804
+ identity: PluginIdentity,
805
+ resolution: RefResolution,
806
+ context: ProbeContext,
807
+ ): Promise<PluginUpdate> {
808
+ const plugin = identity.plugin;
809
+ if (!identity.repoRoot) {
810
+ const notARepo = notARepoRow(plugin);
811
+ notARepo.sourceUrl = await resolveSourceUrl(plugin, identity, resolution, context.deps);
812
+ return notARepo;
813
+ }
814
+
815
+ const row = emptyRow(plugin, identity, resolution);
816
+ row.sourceUrl = await resolveSourceUrl(plugin, identity, resolution, context.deps);
817
+
818
+ const local = await readLocalState(identity, context.runner);
819
+ row.localCommit = local.localCommit;
820
+ row.localTree = local.localTree;
821
+ row.workingTree = local.workingTree;
822
+ row.dirty = local.dirty;
823
+
824
+ if (resolution.reportOnly) {
825
+ row.status = resolution.reportOnly;
826
+ row.detail = resolution.detail;
827
+ return row;
828
+ }
829
+ if (!resolution.remoteUrl || !resolution.ref) {
830
+ row.status = "error";
831
+ row.error = "Unable to resolve a remote/ref for this plugin";
832
+ return row;
833
+ }
834
+
835
+ const state = await getRemoteState(resolution, context.cache, context.runner, context.deps);
836
+ if (state.error) {
837
+ row.status = "error";
838
+ row.error = state.error;
839
+ return row;
840
+ }
841
+ row.remoteCommit = state.commit;
842
+ row.refKind = state.refKind ?? resolution.refKind;
843
+
844
+ if (!state.refExists || !state.commit) {
845
+ row.status = "no-upstream";
846
+ row.detail = MISSING_SOURCE_DETAIL;
847
+ return row;
848
+ }
849
+
850
+ const remote = await readRemoteTree(state, resolution, identity.subdir ?? "", context.runner, context.deps);
851
+ if (remote.error) {
852
+ row.status = "error";
853
+ row.error = remote.error;
854
+ return row;
855
+ }
856
+ row.remoteTree = remote.tree;
857
+ if (remote.absent) {
858
+ row.status = "missing";
859
+ row.detail = MISSING_SOURCE_DETAIL;
860
+ return row;
861
+ }
862
+ row.latestChange = await readLatestChange(state.cacheDir, state.commit, identity.subdir ?? "", context.runner, state.shallow);
863
+
864
+ const verdict =
865
+ row.refKind === "tag"
866
+ ? classifyTag(identity, resolution, state)
867
+ : await classifyBranch(identity, resolution, local, state, remote, context.runner);
868
+
869
+ row.status = verdict.status;
870
+ row.updateAvailable = verdict.updateAvailable;
871
+ row.detail = verdict.detail;
872
+ return row;
873
+ }
874
+
875
+ async function probeInstalled(
876
+ installed: PaseoPluginInfo[],
877
+ runner: CommandRunner,
878
+ deps: ProbeDeps = {},
879
+ ): Promise<PluginUpdate[]> {
880
+ const records = await loadManagedRecords(deps);
881
+ const home = deps.homeDir ?? homedir();
882
+ const cacheRoot = deps.cacheRoot ?? path.join(home, ".paseo", "plugins", ".cache", "plugin-updates");
883
+
884
+ const identities: PluginIdentity[] = [];
885
+ for (const plugin of installed) {
886
+ identities.push(await resolveIdentity(plugin, records.get(plugin.id) ?? null, runner));
887
+ }
888
+
889
+ const resolutions: RefResolution[] = [];
890
+ for (const identity of identities) {
891
+ resolutions.push(await resolveRef(identity, runner));
892
+ }
893
+
894
+ const cache: RemoteCache = { states: new Map(), cacheRoot };
895
+ const context: ProbeContext = { runner, deps, cache };
896
+
897
+ const rows: PluginUpdate[] = [];
898
+ for (let index = 0; index < identities.length; index += 1) {
899
+ rows.push(await probeOne(identities[index]!, resolutions[index]!, context));
900
+ }
901
+
902
+ if (deps.scanOrphans !== false) {
903
+ rows.push(...(await scanOrphanedDirs(installed, deps)));
904
+ }
905
+ return rows;
906
+ }
907
+
908
+ async function probePlugin(
909
+ plugin: PaseoPluginInfo,
910
+ runner: CommandRunner = runCommand,
911
+ deps: ProbeDeps = { scanOrphans: false },
912
+ ): Promise<PluginUpdate> {
913
+ const rows = await probeInstalled([plugin], runner, deps);
914
+ return rows[0]!;
915
+ }
916
+
917
+ export async function checkInstalledPlugins(
918
+ _workspaceId?: string,
919
+ runner: CommandRunner = runCommand,
920
+ installedOverride?: PaseoPluginInfo[],
921
+ deps: ProbeDeps = {},
922
+ ): Promise<{ checkedAt: string; plugins: PluginUpdate[] }> {
923
+ try {
924
+ const installed = installedOverride ?? (await listPlugins({ forceRefresh: true }));
925
+ const plugins = await probeInstalled(installed, runner, deps);
926
+ return { checkedAt: new Date().toISOString(), plugins };
927
+ } catch (error) {
928
+ return {
929
+ checkedAt: new Date().toISOString(),
930
+ plugins: [
931
+ {
932
+ id: "plugin-manager",
933
+ path: "",
934
+ source: null,
935
+ sourceUrl: null,
936
+ repoRoot: null,
937
+ subdir: null,
938
+ ref: null,
939
+ refKind: null,
940
+ remote: null,
941
+ localCommit: null,
942
+ remoteCommit: null,
943
+ localTree: null,
944
+ remoteTree: null,
945
+ workingTree: null,
946
+ dirty: null,
947
+ updateAvailable: false,
948
+ status: "error",
949
+ error: `Unable to list installed plugins: ${errorOf(error)}`,
950
+ detail: null,
951
+ latestChange: null,
952
+ },
953
+ ],
954
+ };
955
+ }
956
+ }
957
+
958
+ // ---------------------------------------------------------------------------
959
+ // Orphans
960
+ // ---------------------------------------------------------------------------
961
+
962
+ async function scanOrphanedDirs(
963
+ installed: PaseoPluginInfo[],
964
+ deps: ProbeDeps,
965
+ ): Promise<PluginUpdate[]> {
966
+ const pluginsRoot = deps.pluginsRoot ?? defaultPluginsRoot();
967
+ const readDir = deps.readDir ?? defaultReadDir;
968
+ let entries: Array<{ name: string; isDirectory(): boolean }>;
969
+ try {
970
+ entries = await readDir(pluginsRoot);
971
+ } catch {
972
+ return [];
973
+ }
974
+ const livePaths = installed
975
+ .map((plugin) => plugin.path)
976
+ .filter((value): value is string => Boolean(value))
977
+ .map(normalizeDir);
978
+ const rows: PluginUpdate[] = [];
979
+ for (const entry of entries) {
980
+ if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
981
+ const dir = normalizeDir(path.join(pluginsRoot, entry.name));
982
+ const live = livePaths.some((livePath) => livePath === dir || livePath.startsWith(`${dir}${path.sep}`));
983
+ if (live) continue;
984
+ rows.push({
985
+ id: `orphaned:${entry.name}`,
986
+ path: dir,
987
+ source: null,
988
+ sourceUrl: null,
989
+ repoRoot: null,
990
+ subdir: null,
991
+ ref: null,
992
+ refKind: null,
993
+ remote: null,
994
+ localCommit: null,
995
+ remoteCommit: null,
996
+ localTree: null,
997
+ remoteTree: null,
998
+ workingTree: null,
999
+ dirty: null,
1000
+ updateAvailable: false,
1001
+ status: "orphaned",
1002
+ error: null,
1003
+ detail: "Leftover managed directory from an uninstalled or failed install — not probed",
1004
+ latestChange: null,
1005
+ });
1006
+ }
1007
+ rows.sort((a, b) => a.id.localeCompare(b.id));
1008
+ return rows;
1009
+ }
1010
+
1011
+ // ---------------------------------------------------------------------------
1012
+ // Actions
1013
+ // ---------------------------------------------------------------------------
1014
+
1015
+ function actionResult(
1016
+ pluginId: string,
1017
+ status: "updated" | "error",
1018
+ error: string | null = null,
1019
+ output: string | null = null,
1020
+ requiresForce = false,
1021
+ ): PluginUpdateActionResult {
1022
+ return {
1023
+ pluginId,
1024
+ status,
1025
+ output: output ?? null,
1026
+ error: error ?? null,
1027
+ ...(requiresForce ? { requiresForce: true } : {}),
1028
+ };
1029
+ }
1030
+
1031
+ async function invokePaseoUpdate(
1032
+ args: string[],
1033
+ runner: CommandRunner,
1034
+ ): Promise<{ status: "updated" | "error"; output: string | null; error: string | null }> {
1035
+ try {
1036
+ const result = await runner("paseo", ["plugin", "update", ...args], {
1037
+ timeoutMs: UPDATE_TIMEOUT_MS,
1038
+ });
1039
+ const output = outputOf(result) || null;
1040
+ if (result.code === 0) return { status: "updated", output, error: null };
1041
+ return { status: "error", output, error: output || `paseo plugin update exited with code ${result.code}` };
1042
+ } catch (error) {
1043
+ return { status: "error", output: null, error: errorOf(error) };
1044
+ }
1045
+ }
1046
+
1047
+ async function reloadPlugin(
1048
+ pluginId: string,
1049
+ runner: CommandRunner,
1050
+ ): Promise<{ ok: boolean; output: string | null; error: string | null }> {
1051
+ try {
1052
+ const result = await runner("paseo", ["plugin", "reload", pluginId], {
1053
+ timeoutMs: RELOAD_TIMEOUT_MS,
1054
+ });
1055
+ const output = outputOf(result) || null;
1056
+ if (result.code === 0) return { ok: true, output, error: null };
1057
+ return { ok: false, output, error: output || `paseo plugin reload exited with code ${result.code}` };
1058
+ } catch (error) {
1059
+ return { ok: false, output: null, error: errorOf(error) };
1060
+ }
1061
+ }
1062
+
1063
+ /**
1064
+ * Splits the plugins affected by a pull into those we may reload and the ones
1065
+ * we must not.
1066
+ *
1067
+ * `plugin-updates` MUST never reload itself: its handlers run inside the plugin
1068
+ * subprocess, so `paseo plugin reload plugin-updates` kills the process that is
1069
+ * mid-update — the crash/loop in #210. Callers report the self entry as updated
1070
+ * with a "reload from Settings" note instead.
1071
+ */
1072
+ export function planReloads(
1073
+ ids: string[],
1074
+ selfId: string = SELF_PLUGIN_ID,
1075
+ ): { reload: string[]; skippedSelf: string[] } {
1076
+ const reload: string[] = [];
1077
+ const skippedSelf: string[] = [];
1078
+ const seen = new Set<string>();
1079
+ for (const id of ids) {
1080
+ if (seen.has(id)) continue;
1081
+ seen.add(id);
1082
+ if (id === selfId) skippedSelf.push(id);
1083
+ else reload.push(id);
1084
+ }
1085
+ return { reload, skippedSelf };
1086
+ }
1087
+
1088
+ /**
1089
+ * Reloads plugins strictly one at a time.
1090
+ *
1091
+ * Reloads are serialized because each `paseo plugin reload` restarts a plugin
1092
+ * subprocess; overlapping them races the daemon's plugin registry (#210).
1093
+ */
1094
+ async function reloadSerially(
1095
+ ids: string[],
1096
+ runner: CommandRunner,
1097
+ ): Promise<Array<{ id: string; ok: boolean; output: string | null; error: string | null }>> {
1098
+ const outcomes: Array<{ id: string; ok: boolean; output: string | null; error: string | null }> = [];
1099
+ for (const id of ids) {
1100
+ const reload = await reloadPlugin(id, runner);
1101
+ outcomes.push({ id, ...reload });
1102
+ }
1103
+ return outcomes;
1104
+ }
1105
+
1106
+ interface PullOutcome {
1107
+ ok: boolean;
1108
+ requiresForce: boolean;
1109
+ output: string | null;
1110
+ error: string | null;
1111
+ }
1112
+
1113
+ async function checkDirty(root: string, runner: CommandRunner): Promise<boolean | null> {
1114
+ const statusResult = await git(runner, ["status", "--porcelain"], root);
1115
+ if (statusResult.code !== 0) return null;
1116
+ return statusResult.stdout.trim().length > 0;
1117
+ }
1118
+
1119
+ async function pullRoot(root: string, force: boolean, runner: CommandRunner): Promise<PullOutcome> {
1120
+ const dirty = await checkDirty(root, runner);
1121
+ if (dirty === null) {
1122
+ return { ok: false, requiresForce: false, output: null, error: "git status failed for the plugin repository" };
1123
+ }
1124
+ if (dirty && !force) {
1125
+ return {
1126
+ ok: false,
1127
+ requiresForce: true,
1128
+ output: null,
1129
+ error: "Working tree is dirty — refusing to pull. Re-run with force to override.",
1130
+ };
1131
+ }
1132
+
1133
+ const head = parseCommit((await git(runner, ["rev-parse", "HEAD"], root)).stdout);
1134
+ const upstream = await git(runner, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"], root);
1135
+ const upstreamName = upstream.code === 0 ? upstream.stdout.trim() : "";
1136
+ if (head && upstreamName && upstreamName !== "HEAD") {
1137
+ const upstreamCommit = parseCommit((await git(runner, ["rev-parse", upstreamName], root)).stdout);
1138
+ if (upstreamCommit) {
1139
+ const remoteBehind = await isAncestor(runner, root, upstreamCommit, head);
1140
+ const localBehind = await isAncestor(runner, root, head, upstreamCommit);
1141
+ if (remoteBehind === false && localBehind === false) {
1142
+ return {
1143
+ ok: false,
1144
+ requiresForce: false,
1145
+ output: null,
1146
+ error: `Branch has diverged from ${upstreamName} — refusing to pull. Reconcile manually.`,
1147
+ };
1148
+ }
1149
+ }
1150
+ }
1151
+
1152
+ const pull = await git(runner, ["pull", "--ff-only"], root, UPDATE_TIMEOUT_MS);
1153
+ const output = outputOf(pull) || null;
1154
+ if (pull.code !== 0) {
1155
+ return { ok: false, requiresForce: false, output, error: output || `git pull --ff-only exited with code ${pull.code}` };
1156
+ }
1157
+ return { ok: true, requiresForce: false, output, error: null };
1158
+ }
1159
+
1160
+ async function idsSharingRoot(
1161
+ installed: PaseoPluginInfo[],
1162
+ records: Map<string, ManagedRecord>,
1163
+ root: string,
1164
+ runner: CommandRunner,
1165
+ ): Promise<string[]> {
1166
+ const resolved = await Promise.all(
1167
+ installed.map(async (plugin) => ({
1168
+ id: plugin.id,
1169
+ root: plugin.path ? (await resolveIdentity(plugin, records.get(plugin.id) ?? null, runner)).repoRoot : null,
1170
+ })),
1171
+ );
1172
+ return resolved.filter((entry) => entry.root === root).map((entry) => entry.id);
1173
+ }
1174
+
1175
+ export interface UpdateOptions {
1176
+ force?: boolean;
1177
+ runner?: CommandRunner;
1178
+ installedOverride?: PaseoPluginInfo[];
1179
+ deps?: ProbeDeps;
1180
+ }
1181
+
1182
+ export async function updatePlugin(
1183
+ pluginId: string,
1184
+ _workspaceId?: string,
1185
+ options: UpdateOptions = {},
1186
+ ): Promise<PluginUpdateActionResult> {
1187
+ const runner = options.runner ?? runCommand;
1188
+ const deps = options.deps ?? {};
1189
+ let installed: PaseoPluginInfo[];
1190
+ try {
1191
+ installed = options.installedOverride ?? (await listPlugins({ forceRefresh: true }));
1192
+ } catch (error) {
1193
+ return actionResult(pluginId, "error", `Unable to list installed plugins: ${errorOf(error)}`);
1194
+ }
1195
+ const info = installed.find((plugin) => plugin.id === pluginId);
1196
+ if (!info) return actionResult(pluginId, "error", `Plugin '${pluginId}' is not installed`);
1197
+ if (!info.path) return actionResult(pluginId, "error", `Plugin '${pluginId}' has no directory path`);
1198
+
1199
+ if (info.source === "git") {
1200
+ const result = await invokePaseoUpdate([pluginId], runner);
1201
+ return actionResult(pluginId, result.status, result.error, result.output);
1202
+ }
1203
+
1204
+ const records = await loadManagedRecords(deps);
1205
+ const identity = await resolveIdentity(info, records.get(pluginId) ?? null, runner);
1206
+ if (!identity.repoRoot) {
1207
+ return actionResult(pluginId, "error", `Plugin '${pluginId}' is not a git repository — no git update possible`);
1208
+ }
1209
+ const pull = await pullRoot(identity.repoRoot, options.force ?? false, runner);
1210
+ if (!pull.ok) {
1211
+ return actionResult(pluginId, "error", pull.error, pull.output, pull.requiresForce);
1212
+ }
1213
+
1214
+ const ids = await idsSharingRoot(installed, records, identity.repoRoot, runner);
1215
+ // Never reload ourselves: this handler runs inside the plugin subprocess, so
1216
+ // reloading plugin-updates would kill the process mid-update (#210).
1217
+ const { reload, skippedSelf } = planReloads(ids);
1218
+ const reloadFailures: string[] = [];
1219
+ for (const outcome of await reloadSerially(reload, runner)) {
1220
+ if (!outcome.ok) reloadFailures.push(`${outcome.id}: ${outcome.error}`);
1221
+ }
1222
+ const output = pull.output ?? `Pulled ${identity.repoRoot}`;
1223
+ if (reloadFailures.length > 0) {
1224
+ return actionResult(pluginId, "error", `Pulled but failed to reload: ${reloadFailures.join("; ")}`, output);
1225
+ }
1226
+ // Updating ourselves: the pull succeeded, but only the host can apply it.
1227
+ if (skippedSelf.length > 0) {
1228
+ return actionResult(pluginId, "updated", null, `${output}\n${SELF_UPDATE_DETAIL}`);
1229
+ }
1230
+ return actionResult(pluginId, "updated", null, output);
1231
+ }
1232
+
1233
+ export async function updateAllPlugins(
1234
+ _workspaceId?: string,
1235
+ options: UpdateOptions = {},
1236
+ ): Promise<{ results: PluginUpdateActionResult[] }> {
1237
+ const runner = options.runner ?? runCommand;
1238
+ const deps = options.deps ?? {};
1239
+ let installed: PaseoPluginInfo[];
1240
+ try {
1241
+ installed = options.installedOverride ?? (await listPlugins({ forceRefresh: true }));
1242
+ } catch (error) {
1243
+ return {
1244
+ results: [actionResult("all", "error", `Unable to list installed plugins: ${errorOf(error)}`)],
1245
+ };
1246
+ }
1247
+
1248
+ const rows = await probeInstalled(installed, runner, { ...deps, scanOrphans: false });
1249
+ const directoryGroups = new Map<string, PluginUpdate[]>();
1250
+ const gitRows: PluginUpdate[] = [];
1251
+ for (const row of rows) {
1252
+ if (!row.updateAvailable) continue;
1253
+ if (row.source === "git") {
1254
+ gitRows.push(row);
1255
+ continue;
1256
+ }
1257
+ if (!row.repoRoot) continue;
1258
+ const group = directoryGroups.get(row.repoRoot) ?? [];
1259
+ group.push(row);
1260
+ directoryGroups.set(row.repoRoot, group);
1261
+ }
1262
+
1263
+ const results: PluginUpdateActionResult[] = [];
1264
+ for (const row of gitRows) {
1265
+ const result = await invokePaseoUpdate([row.id], runner);
1266
+ results.push(actionResult(row.id, result.status, result.error, result.output));
1267
+ }
1268
+
1269
+ // Phase 1: pull every affected directory root, collecting the outcome. Each
1270
+ // root is pulled exactly once (`directoryGroups` is keyed by root); reloads
1271
+ // are deliberately NOT interleaved here — a reload restarts a plugin
1272
+ // subprocess, and doing that mid-batch is what let a self/shared-root update
1273
+ // tear down the running updater and loop (#210).
1274
+ const pulled = new Map<string, { ids: string[]; pull: PullOutcome }>();
1275
+ for (const [root, group] of directoryGroups) {
1276
+ const ids = group.map((row) => row.id);
1277
+ const pull = await pullRoot(root, options.force ?? false, runner);
1278
+ pulled.set(root, { ids, pull });
1279
+ }
1280
+
1281
+ // Phase 2: reload once, after every pull has completed, strictly serially.
1282
+ for (const [root, { ids, pull }] of pulled) {
1283
+ if (!pull.ok) {
1284
+ for (const id of ids) {
1285
+ results.push(actionResult(id, "error", pull.error, pull.output, pull.requiresForce));
1286
+ }
1287
+ continue;
1288
+ }
1289
+ const { reload, skippedSelf } = planReloads(ids);
1290
+ const outcomes = await reloadSerially(reload, runner);
1291
+ const byId = new Map(outcomes.map((outcome) => [outcome.id, outcome]));
1292
+ for (const id of ids) {
1293
+ if (skippedSelf.includes(id)) {
1294
+ results.push(
1295
+ actionResult(id, "updated", null, `${pull.output ?? `Pulled ${root}`}\n${SELF_UPDATE_DETAIL}`),
1296
+ );
1297
+ continue;
1298
+ }
1299
+ const reload = byId.get(id);
1300
+ results.push(
1301
+ reload?.ok
1302
+ ? actionResult(id, "updated", null, pull.output ?? `Pulled ${root}`)
1303
+ : actionResult(id, "error", reload?.error ?? "reload did not run", pull.output),
1304
+ );
1305
+ }
1306
+ }
1307
+
1308
+ if (results.length === 0) {
1309
+ results.push(actionResult("all", "updated", null, "No plugins with remote updates"));
1310
+ }
1311
+ return { results };
1312
+ }
1313
+
1314
+ export const testing = {
1315
+ probePlugin,
1316
+ probeInstalled,
1317
+ pullRoot,
1318
+ scanOrphanedDirs,
1319
+ checkInstalledPlugins,
1320
+ updatePlugin,
1321
+ updateAllPlugins,
1322
+ planReloads,
1323
+ loadManagedRecords,
1324
+ resolveIdentity,
1325
+ resolveRef,
1326
+ classifyBranch,
1327
+ classifyTag,
1328
+ SELF_PLUGIN_ID,
1329
+ PROBE_TIMEOUT_MS,
1330
+ FETCH_TIMEOUT_MS,
1331
+ UPDATE_TIMEOUT_MS,
1332
+ };