@xpufx/paseo-plugin-updates 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # plugin-updates
2
2
 
3
- Git-source update monitor and updater for [Paseo](https://github.com/getpaseo/paseo) plugins (v0.8+).
3
+ Read-only Git-source update monitor for [Paseo](https://github.com/getpaseo/paseo) plugins (v0.8+).
4
4
 
5
5
  <table align="center">
6
6
  <tr>
@@ -9,13 +9,13 @@ Git-source update monitor and updater for [Paseo](https://github.com/getpaseo/pa
9
9
  </tr>
10
10
  </table>
11
11
 
12
- Adds a header button to every workspace that opens a popover listing the installed plugins. Each row reports whether the plugin's installed git source is current, behind, pinned, missing, or has no upstream, and offers a one-click update when a pullable change exists.
12
+ Adds a header button to every workspace that opens a popover listing the installed plugins. Each row reports whether the plugin's installed git source is current, behind, pinned, missing, or has no upstream.
13
13
 
14
14
  Built on [paseo-plugin-helper](https://github.com/xpufx/paseo/tree/main/packages/paseo-plugin-helper), the shared Paseo plugin runtime.
15
15
 
16
16
  > [!NOTE]
17
17
  > **Prerequisites & Platform Support**:
18
- > - The system `git` CLI must be available, because update checks and pulls shell out to `git`.
18
+ > - The system `git` CLI must be available, because update checks shell out to `git`.
19
19
  > - Developed and tested primarily on **Linux**.
20
20
 
21
21
  ## Core Capabilities
@@ -45,12 +45,11 @@ Each row resolves the plugin to its git repository root, ref, and subdirectory,
45
45
  - GitHub, GitLab, and unknown forges get an appropriate glyph, and links deep-link into the subdirectory for GitHub (`/tree/<ref>/<subdir>`) and Forgejo/Gitea-style (`/src/branch/<ref>/<subdir>`) hosts.
46
46
  - A `dirty` badge appears on a directory install when the plugin scope has uncommitted changes.
47
47
 
48
- ### 5. Update Action
49
- - Available updates show an **Update** button in the row. After a failed pull that needs an override, the button becomes **Force update**.
50
- - **git-managed installs** delegate to `paseo plugin update <id>`.
51
- - **directory (path-linked) installs** resolve the repository root and run a fast-forward `git pull`. The pull is refused when the working tree is dirty unless forced, and refused when the local branch has diverged from its upstream.
52
- - After a successful pull, every plugin that shares the same repository root is reloaded.
53
- - When more than one plugin has an update, an **Update all** button appears. If any failure requires an override, **Force update all** is offered instead.
48
+ ### 5. Native lifecycle blocker
49
+
50
+ The 0.9.0-beta.2 daemon client has native plugin preview/proposal/apply/reload methods, but they are not exposed through the plugin-facing `PaseoApi` returned by `getPaseoClient(serverId)`. The server lifecycle API is limited to agent/workspace events. Consequently this plugin deliberately has no update action for managed Git or npm sources, local directories, or self-update: it never spawns `paseo plugin update`, pulls, or reloads another plugin.
51
+
52
+ `useHosts()` is not an update lifecycle API. Choosing a configured host could operate on a different daemon than the installation being displayed, so it is never used as a targeting substitute. Use the host's own plugin management UI/CLI to review and apply updates. The Git diagnostics remain available because native source metadata does not expose their per-subdirectory tree/ref comparison.
54
53
 
55
54
  ### 6. Orphaned Directory Flagging
56
55
  - Directories left behind in Paseo's managed plugin location by an uninstalled or failed install are listed in a separate **Orphaned directories** section with a name and path.
@@ -81,9 +80,8 @@ paseo plugin add ./plugins/plugin-updates
81
80
  ## Usage & Configuration
82
81
 
83
82
  - No settings are required. The header button registers automatically in each workspace that has an agent session.
84
- - Open the button to see installed plugins, their status, and available updates, then use the row **Update** button or **Update all** to pull and reload.
83
+ - Open the button to see installed plugins, their status, and detected remote changes. Review and apply updates in the owning Paseo host.
85
84
  - Checks run against the plugin's install remote only, so a fork or mirror installed as the source is what gets compared.
86
- - Pulls run with `--ff-only`. A dirty working tree or a diverged branch must be reconciled manually before an update can apply.
87
85
 
88
86
  ## Limitations
89
87
 
@@ -13,19 +13,17 @@ import {
13
13
  KeyValue,
14
14
  KeyValueGroup,
15
15
  PluginThemeProvider,
16
- ProgressBar,
17
16
  Row,
18
17
  SectionHeader,
19
18
  StatusDot,
20
19
  usePluginTheme,
21
- } from "paseo-plugin-helper/client";
22
- import { useQuery, useQueryClient } from "@tanstack/react-query";
23
- import { useEffect, useState } from "react";
20
+ } from "./vendor/paseo-plugin-helper/index";
21
+ import { useQuery } from "@tanstack/react-query";
22
+ import { useEffect } from "react";
24
23
  import { ActivityIndicator, Linking, Text, View } from "react-native";
24
+ import { NATIVE_PLUGIN_UPDATE_BLOCKER } from "../shared/native-lifecycle";
25
25
  import {
26
26
  pluginUpdatesCheckRpc,
27
- pluginUpdatesUpdateAllRpc,
28
- pluginUpdatesUpdateRpc,
29
27
  resolveSourceRef,
30
28
  shortHash,
31
29
  type PluginUpdate,
@@ -102,14 +100,8 @@ function hashValue(value: string | null): string | null {
102
100
 
103
101
  function PluginRow({
104
102
  plugin,
105
- updating,
106
- forceNeeded,
107
- onUpdate,
108
103
  }: {
109
104
  plugin: PluginUpdate;
110
- updating: boolean;
111
- forceNeeded: boolean;
112
- onUpdate: (pluginId: string, force: boolean) => void;
113
105
  }) {
114
106
  const { colors, typography, padding } = usePluginTheme();
115
107
  const detail = fallbackDetail(plugin);
@@ -138,16 +130,6 @@ function PluginRow({
138
130
  />
139
131
  ) : null}
140
132
  {plugin.dirty === true && !isOrphan ? <Badge label="dirty" variant="warning" dot /> : null}
141
- {plugin.updateAvailable && !isOrphan ? (
142
- <Button
143
- label={forceNeeded ? "Force update" : "Update"}
144
- variant={forceNeeded ? "primary" : "secondary"}
145
- size="sm"
146
- loading={updating}
147
- disabled={updating}
148
- onPress={() => onUpdate(plugin.id, forceNeeded)}
149
- />
150
- ) : null}
151
133
  </Row>
152
134
  <Text selectable numberOfLines={detailLines} style={{ color: colors.foregroundMuted, ...typography.caption }}>
153
135
  {detail}
@@ -252,103 +234,23 @@ export function PluginUpdatesIcon(props: PluginButtonIconProps) {
252
234
  );
253
235
  }
254
236
 
255
- interface Failure {
256
- pluginId: string;
257
- error: string;
258
- requiresForce: boolean;
259
- }
260
-
261
237
  function PluginUpdatesPopoverInner(props: PluginButtonContentProps) {
262
238
  const { colors, typography, padding } = usePluginTheme();
263
239
  const toast = useToast();
264
- const queryClient = useQueryClient();
265
240
  const query = usePluginUpdates(props.workspaceId);
266
- const update = useRpc(pluginUpdatesUpdateRpc);
267
- const updateAll = useRpc(pluginUpdatesUpdateAllRpc);
268
- const [activeUpdate, setActiveUpdate] = useState<string | "all" | null>(null);
269
- const [progress, setProgress] = useState(0);
270
- const [failures, setFailures] = useState<Failure[]>([]);
271
241
 
272
242
  const plugins = query.data?.plugins ?? [];
273
243
  const rows = partitionedRows(plugins);
274
244
  const orphanSection = buildOrphanSection(plugins);
275
245
  const staleCount = plugins.filter((plugin) => plugin.updateAvailable).length;
276
- const forceCount = failures.filter((failure) => failure.requiresForce).length;
277
-
278
246
  const refresh = async () => {
279
247
  try {
280
- setFailures([]);
281
248
  await query.refetch();
282
249
  } catch (error) {
283
250
  toast.show(error instanceof Error ? error.message : String(error), { variant: "error" });
284
251
  }
285
252
  };
286
253
 
287
- const recordFailure = (failure: Failure) => {
288
- setFailures((current) => [
289
- ...current.filter((item) => item.pluginId !== failure.pluginId),
290
- failure,
291
- ]);
292
- };
293
-
294
- const clearFailure = (pluginId: string) => {
295
- setFailures((current) => current.filter((failure) => failure.pluginId !== pluginId));
296
- };
297
-
298
- const runUpdate = async (pluginId: string, force = false) => {
299
- setActiveUpdate(pluginId);
300
- setProgress(0.25);
301
- try {
302
- const result = await update({ workspaceId: props.workspaceId, pluginId, force });
303
- if (result.status === "error") {
304
- recordFailure({
305
- pluginId,
306
- error: result.error || "Update failed",
307
- requiresForce: result.requiresForce === true,
308
- });
309
- toast.show(result.error || `Failed to update ${pluginId}`, { variant: "error" });
310
- } else {
311
- clearFailure(pluginId);
312
- toast.show(`${pluginId} updated`, { variant: "success" });
313
- }
314
- setProgress(1);
315
- await queryClient.invalidateQueries({ queryKey: [...QUERY_KEY, props.workspaceId] });
316
- } catch (error) {
317
- toast.show(error instanceof Error ? error.message : String(error), { variant: "error" });
318
- } finally {
319
- setActiveUpdate(null);
320
- }
321
- };
322
-
323
- const runUpdateAll = async (force = false) => {
324
- setActiveUpdate("all");
325
- setProgress(0.1);
326
- try {
327
- const result = await updateAll({ workspaceId: props.workspaceId, force });
328
- const failed = result.results.filter((item) => item.status === "error");
329
- setFailures(
330
- failed.map((item) => ({
331
- pluginId: item.pluginId,
332
- error: item.error || "Update failed",
333
- requiresForce: item.requiresForce === true,
334
- })),
335
- );
336
- setProgress(1);
337
- if (failed.length > 0) {
338
- toast.show(`${failed.length} plugin update${failed.length === 1 ? "" : "s"} failed`, {
339
- variant: "error",
340
- });
341
- } else {
342
- toast.show("All plugins updated", { variant: "success" });
343
- }
344
- await queryClient.invalidateQueries({ queryKey: [...QUERY_KEY, props.workspaceId] });
345
- } catch (error) {
346
- toast.show(error instanceof Error ? error.message : String(error), { variant: "error" });
347
- } finally {
348
- setActiveUpdate(null);
349
- }
350
- };
351
-
352
254
  useEffect(() => {
353
255
  if (query.isError) {
354
256
  toast.show("Unable to check plugin updates", { variant: "error" });
@@ -368,7 +270,10 @@ function PluginUpdatesPopoverInner(props: PluginButtonContentProps) {
368
270
  <View style={{ flex: 1 }}>
369
271
  <Text style={{ color: colors.foreground, ...typography.heading }}>Plugin updates</Text>
370
272
  <Text style={{ color: colors.foregroundMuted, ...typography.caption }}>
371
- {query.isFetching ? "Checking installed plugins…" : `${staleCount} update${staleCount === 1 ? "" : "s"} available`}
273
+ {query.isFetching ? "Checking installed plugins…" : `${staleCount} remote change${staleCount === 1 ? "" : "s"} detected`}
274
+ </Text>
275
+ <Text style={{ color: colors.foregroundMuted, ...typography.caption }}>
276
+ Diagnostics only — {NATIVE_PLUGIN_UPDATE_BLOCKER}
372
277
  </Text>
373
278
  </View>
374
279
  <Button
@@ -376,12 +281,11 @@ function PluginUpdatesPopoverInner(props: PluginButtonContentProps) {
376
281
  variant="ghost"
377
282
  size="sm"
378
283
  loading={query.isFetching}
379
- disabled={query.isFetching || activeUpdate !== null}
284
+ disabled={query.isFetching}
380
285
  accessibilityLabel="Refresh plugin update check"
381
286
  onPress={refresh}
382
287
  />
383
288
  </Row>
384
- {activeUpdate ? <ProgressBar value={progress} autoStatusColor label="Updating plugins" showValueText /> : null}
385
289
  {query.isLoading && !query.data ? (
386
290
  <View style={{ alignItems: "center", paddingVertical: padding.vertical * 2 }}>
387
291
  <ActivityIndicator color={colors.foregroundMuted} />
@@ -394,58 +298,11 @@ function PluginUpdatesPopoverInner(props: PluginButtonContentProps) {
394
298
  <PluginRow
395
299
  key={plugin.id}
396
300
  plugin={plugin}
397
- updating={activeUpdate === plugin.id}
398
- forceNeeded={failures.some((failure) => failure.pluginId === plugin.id && failure.requiresForce)}
399
- onUpdate={runUpdate}
400
301
  />
401
302
  ))}
402
303
  {orphanSection ? <OrphanSection section={orphanSection} /> : null}
403
304
  </>
404
305
  )}
405
- {failures.map((failure) => (
406
- <Card key={`failure-${failure.pluginId}`} variant="elevated">
407
- <View style={{ gap: padding.gap }}>
408
- <Row align="center">
409
- <StatusDot variant="danger" />
410
- <Text style={{ color: colors.statusDanger, ...typography.bodyStrong, flex: 1 }}>
411
- {failure.pluginId} update failed
412
- </Text>
413
- {failure.requiresForce ? (
414
- <Button
415
- label="Force"
416
- variant="secondary"
417
- size="sm"
418
- loading={activeUpdate === failure.pluginId}
419
- disabled={activeUpdate !== null}
420
- onPress={() => runUpdate(failure.pluginId, true)}
421
- />
422
- ) : null}
423
- </Row>
424
- <Text selectable style={{ color: colors.foregroundMuted, ...typography.caption }}>
425
- {failure.error}
426
- </Text>
427
- </View>
428
- </Card>
429
- ))}
430
- {forceCount > 0 ? (
431
- <Button
432
- label="Force update all"
433
- variant="secondary"
434
- icon="AlertTriangle"
435
- loading={activeUpdate === "all"}
436
- disabled={activeUpdate !== null}
437
- onPress={() => runUpdateAll(true)}
438
- />
439
- ) : staleCount > 1 ? (
440
- <Button
441
- label={`Update all (${staleCount})`}
442
- variant="primary"
443
- icon="DownloadCloud"
444
- loading={activeUpdate === "all"}
445
- disabled={activeUpdate !== null}
446
- onPress={() => runUpdateAll(false)}
447
- />
448
- ) : null}
449
306
  </ScrollView>
450
307
  </PluginThemeProvider>
451
308
  );
@@ -24,16 +24,23 @@ export interface ClipboardEnvironment {
24
24
  * `react-native-web` `Clipboard.setString` reports success even when its
25
25
  * `document.execCommand("copy")` fails, which leaves the previous clipboard
26
26
  * item in place while the UI claims success (xpufx-org/paseo#278); it is only
27
- * usable off-DOM (native), where it is the real platform clipboard. In a DOM
28
- * the checked `execCommand` fallback is preferred to that unverifiable path.
27
+ * usable off-DOM (native), where it is the real platform clipboard. The same
28
+ * applies to any `setStringAsync` whose DOM fallback is that unverified
29
+ * `execCommand`. In a DOM the checked `execCommand` fallback is preferred to
30
+ * those unverifiable paths.
29
31
  */
30
32
  export function clipboardTierOrder(env: ClipboardEnvironment): ClipboardTier[] {
31
33
  const tiers: ClipboardTier[] = [];
32
34
  if (env.hasNavigatorClipboard) tiers.push("navigator");
33
35
  if (env.hasHostCopyText) tiers.push("host");
34
- if (env.hasRnSetStringAsync) {
36
+ // On a DOM both RN-web paths are unverifiable: `setString` reports success
37
+ // even when its `execCommand` no-ops, and a `setStringAsync` that falls back
38
+ // to it does the same. Off-DOM they are the real native clipboard, so they
39
+ // lead there; on a DOM only the checked `execCommand` fallback is honest.
40
+ const rnDomOk = !env.isDom;
41
+ if (env.hasRnSetStringAsync && rnDomOk) {
35
42
  tiers.push("rnAsync");
36
- } else if (env.hasRnClipboard && !env.isDom) {
43
+ } else if (env.hasRnClipboard && rnDomOk) {
37
44
  tiers.push("rnSync");
38
45
  }
39
46
  tiers.push("execCommand");
@@ -0,0 +1,10 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { useRpc } from "@getpaseo/plugin/client";
3
+ import { Icon, Modal, ScrollView, useToast } from "@getpaseo/plugin/client/react-native";
4
+ import { initClientHelpers } from "./client/vendor/paseo-plugin-helper/index";
5
+ import { registerPluginUpdateHeaders } from "./client/updates";
6
+
7
+ export default function contribute(client: PluginClientContext) {
8
+ initClientHelpers({ Icon, Modal, useRpc, ScrollView, useToast });
9
+ return registerPluginUpdateHeaders(client);
10
+ }
@@ -0,0 +1,8 @@
1
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
+ import { pluginUpdatesCheckRpc } from "./shared/updates";
3
+ import { checkInstalledPlugins } from "./server/updates";
4
+
5
+ export default function contribute(server: PluginServerContext) {
6
+ server.handle(pluginUpdatesCheckRpc, ({ workspaceId }) => checkInstalledPlugins(workspaceId));
7
+ return () => {};
8
+ }
package/package.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "name": "@xpufx/paseo-plugin-updates",
3
3
  "private": false,
4
4
  "license": "MIT",
5
- "version": "0.1.0",
6
- "description": "Monitor installed Paseo plugins for git-source updates, with per-subdirectory status, source links, and one-click pulls.",
5
+ "version": "0.1.2",
6
+ "description": "Read-only monitor for installed Paseo plugin source status, links, and per-subdirectory Git diagnostics.",
7
7
  "scripts": {
8
8
  "typecheck": "tsc --noEmit",
9
- "test": "npm run typecheck && npx tsx --test shared/updates.test.ts server/updates.test.ts client/orphans.test.ts",
9
+ "test": "npm run typecheck && npx tsx --test shared/updates.test.ts shared/native-lifecycle.test.ts server/updates.test.ts client/orphans.test.ts",
10
10
  "test:vitest": "npm run typecheck && vitest run --config vitest.config.ts"
11
11
  },
12
12
  "devDependencies": {
@@ -22,6 +22,8 @@
22
22
  "paseo-plugin.json",
23
23
  "README.md",
24
24
  "LICENSE",
25
+ "index.client.tsx",
26
+ "index.server.ts",
25
27
  "client",
26
28
  "server",
27
29
  "shared",
package/server/updates.ts CHANGED
@@ -2,31 +2,16 @@ import { createHash } from "node:crypto";
2
2
  import fs from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import path from "node:path";
5
- import { listPlugins, safeSpawn, type PaseoPluginInfo, type SafeSpawnResult } from "paseo-plugin-helper/server";
5
+ import { listPlugins, safeSpawn, type PaseoPluginInfo, type SafeSpawnResult } from "./vendor/paseo-plugin-helper/index";
6
6
  import { deriveSourceUrl, shortHash } from "../shared/updates";
7
7
  import type {
8
8
  PluginUpdate,
9
- PluginUpdateActionResult,
10
9
  PluginUpdateChange,
11
10
  RefKind,
12
11
  } from "../shared/updates";
13
12
 
14
13
  const PROBE_TIMEOUT_MS = 10_000;
15
14
  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
15
  // Report-only verdict for an install whose tracked ref or plugin subdirectory is gone.
31
16
  const MISSING_SOURCE_DETAIL = "Plugin does not exist at the source it was installed from.";
32
17
 
@@ -1008,325 +993,16 @@ async function scanOrphanedDirs(
1008
993
  return rows;
1009
994
  }
1010
995
 
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
996
  export const testing = {
1315
997
  probePlugin,
1316
998
  probeInstalled,
1317
- pullRoot,
1318
999
  scanOrphanedDirs,
1319
1000
  checkInstalledPlugins,
1320
- updatePlugin,
1321
- updateAllPlugins,
1322
- planReloads,
1323
1001
  loadManagedRecords,
1324
1002
  resolveIdentity,
1325
1003
  resolveRef,
1326
1004
  classifyBranch,
1327
1005
  classifyTag,
1328
- SELF_PLUGIN_ID,
1329
1006
  PROBE_TIMEOUT_MS,
1330
1007
  FETCH_TIMEOUT_MS,
1331
- UPDATE_TIMEOUT_MS,
1332
1008
  };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * SDK 0.9.0-beta.2 exposes plugin update methods on DaemonClient, but plugins
3
+ * receive only PaseoApi through getPaseoClient(serverId). PaseoApi deliberately
4
+ * omits those methods, so a plugin cannot safely choose a host or mutate an
5
+ * installation on the daemon's behalf.
6
+ */
7
+ export const NATIVE_PLUGIN_UPDATE_BLOCKER =
8
+ "Paseo SDK 0.9.0-beta.2 does not expose plugin update preview/proposal/apply/reload on plugin-facing PaseoApi.";
9
+
10
+ export type ManagedPluginSource = "git" | "npm" | "directory" | "unknown";
11
+
12
+ export interface NativePluginUpdateCapability {
13
+ supported: false;
14
+ source: ManagedPluginSource;
15
+ /** No serverId is accepted: selecting another configured host would be unsafe. */
16
+ hostTarget: null;
17
+ blocker: typeof NATIVE_PLUGIN_UPDATE_BLOCKER;
18
+ }
19
+
20
+ /**
21
+ * Formal, source-independent fallback for every installation kind. Do not turn
22
+ * useHosts() or getPaseoClient(serverId) into a lifecycle substitute: neither
23
+ * grants the absent plugin-management API, and a selected server could be the
24
+ * wrong daemon for this plugin installation.
25
+ */
26
+ export function nativePluginUpdateCapability(source: string | null | undefined): NativePluginUpdateCapability {
27
+ const normalized: ManagedPluginSource =
28
+ source === "git" || source === "npm" || source === "directory" ? source : "unknown";
29
+ return { supported: false, source: normalized, hostTarget: null, blocker: NATIVE_PLUGIN_UPDATE_BLOCKER };
30
+ }
package/shared/updates.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { defineContract } from "paseo-plugin-helper/shared";
2
+ import { defineContract } from "./vendor/paseo-plugin-helper/index";
3
3
 
4
4
  export const PluginUpdateStatusSchema = z.enum([
5
5
  "checking",
@@ -205,35 +205,3 @@ export const pluginUpdatesCheckRpc = defineContract({
205
205
  plugins: z.array(PluginUpdateSchema),
206
206
  }),
207
207
  });
208
-
209
- export const PluginUpdateActionSchema = z.object({
210
- pluginId: z.string(),
211
- status: z.enum(["updated", "error"]),
212
- output: z.string().nullable(),
213
- error: z.string().nullable(),
214
- requiresForce: z.boolean().optional(),
215
- });
216
- export type PluginUpdateActionResult = z.infer<typeof PluginUpdateActionSchema>;
217
-
218
- export const pluginUpdatesUpdateRpc = defineContract({
219
- name: "plugin-updates.update",
220
- description: "Pulls and reloads one installed plugin in place",
221
- input: z.object({
222
- workspaceId: z.string().optional(),
223
- pluginId: z.string(),
224
- force: z.boolean().optional(),
225
- }),
226
- output: PluginUpdateActionSchema,
227
- });
228
-
229
- export const pluginUpdatesUpdateAllRpc = defineContract({
230
- name: "plugin-updates.update-all",
231
- description: "Pulls and reloads every installed plugin with a remote update",
232
- input: z.object({
233
- workspaceId: z.string().optional(),
234
- force: z.boolean().optional(),
235
- }),
236
- output: z.object({
237
- results: z.array(PluginUpdateActionSchema),
238
- }),
239
- });