@peers-app/peers-ui 0.26.4 → 0.26.5

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 (59) hide show
  1. package/dist/components/group-switcher.d.ts +6 -0
  2. package/dist/components/group-switcher.js +8 -1
  3. package/dist/components/markdown-with-mentions.d.ts +4 -0
  4. package/dist/components/markdown-with-mentions.js +6 -1
  5. package/dist/components/save-button.d.ts +15 -0
  6. package/dist/components/save-button.js +14 -3
  7. package/dist/globals.js +34 -8
  8. package/dist/host-shell/host-shell-consumer.d.ts +21 -0
  9. package/dist/host-shell/host-shell-consumer.js +50 -0
  10. package/dist/host-shell/host-shell-consumer.test.d.ts +1 -0
  11. package/dist/host-shell/host-shell-consumer.test.js +28 -0
  12. package/dist/mention-configs.js +2 -1
  13. package/dist/screens/groups/group-list.js +4 -1
  14. package/dist/screens/packages/package-details.d.ts +1 -0
  15. package/dist/screens/packages/package-details.js +27 -8
  16. package/dist/screens/packages/package-helpers.d.ts +3 -23
  17. package/dist/screens/packages/package-helpers.js +12 -46
  18. package/dist/screens/packages/package-helpers.test.d.ts +1 -0
  19. package/dist/screens/packages/package-helpers.test.js +53 -0
  20. package/dist/screens/packages/package-info.js +3 -2
  21. package/dist/screens/packages/package-list.d.ts +4 -2
  22. package/dist/screens/packages/package-list.js +50 -10
  23. package/dist/screens/packages/package-new-local.js +7 -4
  24. package/dist/screens/packages/package-runtime-status.d.ts +12 -0
  25. package/dist/screens/packages/package-runtime-status.js +33 -0
  26. package/dist/screens/packages/package-runtime-status.test.d.ts +1 -0
  27. package/dist/screens/packages/package-runtime-status.test.js +50 -0
  28. package/dist/screens/packages/package-versions.js +8 -55
  29. package/dist/screens/packages/packages-ipc.d.ts +41 -0
  30. package/dist/screens/packages/packages-ipc.js +102 -0
  31. package/dist/screens/packages/packages-ipc.test.d.ts +1 -0
  32. package/dist/screens/packages/packages-ipc.test.js +43 -0
  33. package/dist/screens/tools/tool-catalog-ipc.test.js +6 -0
  34. package/dist/screens/variables/variable-details.d.ts +1 -0
  35. package/dist/screens/variables/variable-details.js +11 -5
  36. package/dist/ui-router/ui-loader.js +10 -37
  37. package/package.json +3 -3
  38. package/src/components/group-switcher.tsx +12 -4
  39. package/src/components/markdown-with-mentions.tsx +7 -2
  40. package/src/components/save-button.tsx +24 -3
  41. package/src/globals.tsx +32 -11
  42. package/src/host-shell/host-shell-consumer.test.ts +32 -0
  43. package/src/host-shell/host-shell-consumer.ts +53 -0
  44. package/src/mention-configs.ts +2 -2
  45. package/src/screens/groups/group-list.tsx +6 -4
  46. package/src/screens/packages/package-details.tsx +31 -8
  47. package/src/screens/packages/package-helpers.test.ts +57 -0
  48. package/src/screens/packages/package-helpers.ts +13 -55
  49. package/src/screens/packages/package-info.tsx +3 -3
  50. package/src/screens/packages/package-list.tsx +51 -16
  51. package/src/screens/packages/package-new-local.tsx +8 -13
  52. package/src/screens/packages/package-runtime-status.test.tsx +58 -0
  53. package/src/screens/packages/package-runtime-status.tsx +68 -0
  54. package/src/screens/packages/package-versions.tsx +9 -72
  55. package/src/screens/packages/packages-ipc.test.ts +47 -0
  56. package/src/screens/packages/packages-ipc.ts +125 -0
  57. package/src/screens/tools/tool-catalog-ipc.test.ts +7 -1
  58. package/src/screens/variables/variable-details.tsx +11 -6
  59. package/src/ui-router/ui-loader.tsx +12 -51
@@ -5,11 +5,10 @@ import {
5
5
  getUserRole,
6
6
  hasDeviceFollowOverride,
7
7
  type IPackageVersion,
8
- Packages,
9
8
  PackageVersions,
10
9
  packagePrefsVar,
11
- updatePackagePrefs,
12
10
  } from "@peers-app/peers-sdk";
11
+ import { packagesTools } from "./packages-ipc";
13
12
 
14
13
  /** Severity of a version update: major > minor > patch. */
15
14
  export type UpdateLevel = "major" | "minor" | "patch" | null;
@@ -107,8 +106,9 @@ export async function isGroupAdmin(): Promise<boolean> {
107
106
  /**
108
107
  * Activate a specific package version, handling group-level vs device-local
109
108
  * activation. When the device has no follow override and the user is a group
110
- * admin, the group's `activePackageVersionId` is advanced so all devices
111
- * stay in sync. Otherwise only the device-local prefs are updated.
109
+ * admin, a release advances the group's `activePackageVersionId` so all devices
110
+ * stay in sync. Dev versions (including legacy missing or empty tags) and other
111
+ * selections only update the device-local prefs.
112
112
  *
113
113
  * @param packageId - The package to activate a version for.
114
114
  * @param pv - The package version to activate.
@@ -122,57 +122,15 @@ export async function activatePackageVersion(
122
122
  const devicePrefs = prefsVar();
123
123
 
124
124
  const activatesGroup =
125
- pv.versionTag !== "dev" && !hasDeviceFollowOverride(devicePrefs) && (await isGroupAdmin());
126
-
127
- if (activatesGroup) {
128
- const current = await Packages().get(packageId);
129
- if (current) {
130
- current.activePackageVersionId = pv.packageVersionId;
131
- await Packages().signAndSave(current);
132
- }
133
- await updatePackagePrefs(packageId, { activePackageVersionId: undefined });
134
- } else {
135
- await updatePackagePrefs(packageId, { activePackageVersionId: pv.packageVersionId });
136
- }
137
- }
138
-
139
- /**
140
- * After promoting a version that is currently effective-active on this device,
141
- * activate it when the new tag matches the group's auto-activate / follow rules.
142
- *
143
- * This closes the gotcha where a device is already running a `dev` build, promotes
144
- * it to `beta`/`stable`, and expects the group active version to advance without a
145
- * separate Activate click — but only when the group would accept that channel
146
- * (e.g. default follow is `stable`; `beta` only when the group follows beta).
147
- *
148
- * @param packageId - Package that was promoted
149
- * @param promotedPv - PV after the tag change (must include the new `versionTag`)
150
- * @param opts.wasEffectiveActive - True when this PV was this device's effective active before promote
151
- * @param opts.groupFollowTags - Group `IPackage.followVersionTags` (not device override)
152
- * @param opts.groupPinned - True when group `versionFollowRange` is `pinned`
153
- * @returns Whether activation was attempted
154
- */
155
- export async function maybeActivateAfterPromote(
156
- packageId: string,
157
- promotedPv: IPackageVersion,
158
- opts: {
159
- wasEffectiveActive: boolean;
160
- groupFollowTags: string | undefined;
161
- groupPinned: boolean;
162
- },
163
- ): Promise<boolean> {
164
- if (!opts.wasEffectiveActive) return false;
165
- if (opts.groupPinned) return false;
166
- if (!promotedPv.versionTag || promotedPv.versionTag === "dev") return false;
167
-
168
- // Docs default: groups follow `stable` when followVersionTags is unset.
169
- const followTags = opts.groupFollowTags ?? "stable";
170
- if (!doesTagMatch(undefined, promotedPv.versionTag, followTags, undefined)) {
171
- return false;
172
- }
173
-
174
- await activatePackageVersion(packageId, promotedPv);
175
- return true;
125
+ (pv.versionTag || "dev") !== "dev" &&
126
+ !hasDeviceFollowOverride(devicePrefs) &&
127
+ (await isGroupAdmin());
128
+
129
+ await (await packagesTools()).activatePackageVersion({
130
+ packageId,
131
+ packageVersionId: pv.packageVersionId,
132
+ scope: activatesGroup ? "group" : "device",
133
+ });
176
134
  }
177
135
 
178
136
  /** Numeric rank for sorting packages by update urgency. */
@@ -10,13 +10,13 @@ import {
10
10
  PackageVersions,
11
11
  packagePrefsVar,
12
12
  packagesRootDir,
13
- rpcServerCalls,
14
13
  updatePackagePrefs,
15
14
  } from "@peers-app/peers-sdk";
16
15
  import { Input } from "../../components/input";
17
16
  import { MarkdownWithMentions } from "../../components/markdown-with-mentions";
18
17
  import { Tooltip } from "../../components/tooltip";
19
18
  import { useObservable, usePromise } from "../../hooks";
19
+ import { openInEditorOnHost, openOnHost } from "../../host-shell/host-shell-consumer";
20
20
  import { PackageNetworkPanel } from "./package-network-panel";
21
21
 
22
22
  /** Info tab for the package details screen. */
@@ -115,7 +115,7 @@ export const PackageInfo = (props: { pkg: IDoc<IPackage> }) => {
115
115
  <button
116
116
  className="btn btn-sm btn-link"
117
117
  onClick={() => {
118
- rpcServerCalls.openPackage(localPath || packagesRootDir);
118
+ void openInEditorOnHost(localPath || packagesRootDir);
119
119
  }}
120
120
  >
121
121
  Open Local
@@ -142,7 +142,7 @@ export const PackageInfo = (props: { pkg: IDoc<IPackage> }) => {
142
142
  <button
143
143
  className="btn btn-sm btn-link"
144
144
  onClick={() => {
145
- rpcServerCalls.openLinkInBrowser(remoteRepoUrl);
145
+ void openOnHost(remoteRepoUrl);
146
146
  }}
147
147
  >
148
148
  Open Remote
@@ -3,14 +3,13 @@ import {
3
3
  type ICursorIterable,
4
4
  type IPackage,
5
5
  type IPackageVersion,
6
+ isid,
7
+ isPackageRepositoryUrl,
6
8
  observable,
7
9
  Packages,
8
10
  PackageVersions,
9
11
  packagePrefsVar,
10
12
  packagesRootDir,
11
- rpcServerCalls,
12
- unwrapAddOrUpdatePackageResult,
13
- updatePackagePrefs,
14
13
  } from "@peers-app/peers-sdk";
15
14
  import type React from "react";
16
15
  import { useCallback, useEffect, useState } from "react";
@@ -20,6 +19,7 @@ import { LoadingIndicator } from "../../components/loading-indicator";
20
19
  import { Tooltip } from "../../components/tooltip";
21
20
  import { isDesktop, mainContentPath } from "../../globals";
22
21
  import { useObservable, useObservableState, usePromise } from "../../hooks";
22
+ import { openOnHost } from "../../host-shell/host-shell-consumer";
23
23
  import { registerInternalPeersUI } from "../../ui-router/ui-loader";
24
24
  import { LocalFolderGrantsPanel } from "./local-folder-grants-panel";
25
25
  import {
@@ -29,6 +29,8 @@ import {
29
29
  type UpdateLevel,
30
30
  updateLevelRank,
31
31
  } from "./package-helpers";
32
+ import { PackageRuntimeBadge } from "./package-runtime-status";
33
+ import { packagesTools } from "./packages-ipc";
32
34
 
33
35
  interface IPackageRowStatus extends IVersionStatus {
34
36
  /** When on a dev version, the group's non-dev active PV (if any). */
@@ -53,6 +55,17 @@ const PackageRow = ({ pkg, onUpdated }: { pkg: IPackage; onUpdated: () => void }
53
55
  const [updating, setUpdating] = useState(false);
54
56
  const [devicePrefs] = useObservable(packagePrefsVar(pkg.packageId));
55
57
  const effective = getEffectivePackagePrefs(pkg, devicePrefs);
58
+ // A dev reload rewrites the selected version row in place (same id, new version), so the
59
+ // ids below do not change; recompute when this package's version rows change.
60
+ const [versionsRevision, setVersionsRevision] = useState(0);
61
+ useEffect(() => {
62
+ const sub = PackageVersions().dataChanged.subscribe((evt) => {
63
+ if (evt.dataObject?.packageId === pkg.packageId) setVersionsRevision((value) => value + 1);
64
+ });
65
+ return () => {
66
+ sub.unsubscribe();
67
+ };
68
+ }, [pkg.packageId]);
56
69
 
57
70
  const status = usePromise(
58
71
  async (): Promise<IPackageRowStatus | null> => {
@@ -94,6 +107,7 @@ const PackageRow = ({ pkg, onUpdated }: { pkg: IPackage; onUpdated: () => void }
94
107
  pkg.packageId,
95
108
  effective.followTags,
96
109
  pkg.activePackageVersionId,
110
+ versionsRevision,
97
111
  ],
98
112
  );
99
113
 
@@ -122,7 +136,10 @@ const PackageRow = ({ pkg, onUpdated }: { pkg: IPackage; onUpdated: () => void }
122
136
  async function handleUseRelease() {
123
137
  setUpdating(true);
124
138
  try {
125
- await updatePackagePrefs(pkg.packageId, { activePackageVersionId: undefined });
139
+ await (await packagesTools()).activatePackageVersion({
140
+ packageId: pkg.packageId,
141
+ scope: "device",
142
+ });
126
143
  onUpdated();
127
144
  } catch (err: unknown) {
128
145
  alert(`Failed to switch: ${err instanceof Error ? err.message : String(err)}`);
@@ -135,8 +152,10 @@ const PackageRow = ({ pkg, onUpdated }: { pkg: IPackage; onUpdated: () => void }
135
152
  if (!status?.latestDevPv) return;
136
153
  setUpdating(true);
137
154
  try {
138
- await updatePackagePrefs(pkg.packageId, {
139
- activePackageVersionId: status.latestDevPv.packageVersionId,
155
+ await (await packagesTools()).activatePackageVersion({
156
+ packageId: pkg.packageId,
157
+ packageVersionId: status.latestDevPv.packageVersionId,
158
+ scope: "device",
140
159
  });
141
160
  onUpdated();
142
161
  } catch (err: unknown) {
@@ -151,6 +170,9 @@ const PackageRow = ({ pkg, onUpdated }: { pkg: IPackage; onUpdated: () => void }
151
170
  <div className="flex-grow-1" style={{ minWidth: 0 }}>
152
171
  <i className="bi bi-box-fill"></i>&nbsp;&nbsp;
153
172
  <a href={`#packages/${pkg.packageId}`}>{pkg.name}</a>
173
+ <span className="ms-2">
174
+ <PackageRuntimeBadge packageId={pkg.packageId} />
175
+ </span>
154
176
  {isDeviceOverridden && (
155
177
  <span
156
178
  className="text-warning ms-1"
@@ -236,8 +258,10 @@ const PackageRow = ({ pkg, onUpdated }: { pkg: IPackage; onUpdated: () => void }
236
258
  );
237
259
  };
238
260
 
239
- /** Searchable list of installed packages, plus create/import from the search box. */
240
- /** Packages screen: search, local folder grants, and the installed package list. */
261
+ /**
262
+ * Packages screen: search, create/import/install from the search box, local folder grants,
263
+ * and the installed package list.
264
+ */
241
265
  export const PackageList = () => {
242
266
  const [searchTextObs] = useState(() => observable(""));
243
267
  const [searchText] = useObservable(searchTextObs);
@@ -323,13 +347,11 @@ export const PackageList = () => {
323
347
  const name = searchText.trim();
324
348
  if (!name) return;
325
349
 
326
- if (name.startsWith("http") || name.endsWith(".git")) {
350
+ if (isPackageRepositoryUrl(name)) {
327
351
  if (!confirm(`Add remote package: ${name}`)) return;
328
352
  try {
329
353
  addingPackage(true);
330
- const { packageId } = unwrapAddOrUpdatePackageResult(
331
- await rpcServerCalls.addOrUpdatePackage(name),
332
- );
354
+ const { packageId } = await (await packagesTools()).clonePackage({ url: name });
333
355
  const pkg = await Packages().get(packageId);
334
356
  if (pkg) {
335
357
  mainContentPath(`packages/${packageId}`);
@@ -344,13 +366,26 @@ export const PackageList = () => {
344
366
  return;
345
367
  }
346
368
 
369
+ // A package id re-enables that package, or copies it here from another of your contexts.
370
+ if (isid(name)) {
371
+ if (!confirm(`Install package ${name} into this context`)) return;
372
+ try {
373
+ addingPackage(true);
374
+ const { packageId } = await (await packagesTools()).installPackage({ packageId: name });
375
+ mainContentPath(`packages/${packageId}`);
376
+ } catch (err: unknown) {
377
+ confirm(`Error installing package: ${err instanceof Error ? err.message : String(err)}`);
378
+ } finally {
379
+ addingPackage(false);
380
+ }
381
+ return;
382
+ }
383
+
347
384
  if (!confirm(`Create or import local package: ${searchText}`)) return;
348
385
 
349
386
  try {
350
387
  addingPackage(true);
351
- const { packageId, warning } = unwrapAddOrUpdatePackageResult(
352
- await rpcServerCalls.addOrUpdatePackage(name),
353
- );
388
+ const { packageId, warning } = await (await packagesTools()).createPackage({ name });
354
389
  const pkg = await Packages().get(packageId);
355
390
  if (pkg) {
356
391
  if (warning) {
@@ -430,7 +465,7 @@ export const PackageList = () => {
430
465
  <li>
431
466
  <a
432
467
  className="dropdown-item"
433
- onClick={() => rpcServerCalls.openPath(packagesRootDir)}
468
+ onClick={() => void openOnHost(packagesRootDir)}
434
469
  style={{ cursor: "pointer" }}
435
470
  >
436
471
  Open Packages Directory
@@ -1,12 +1,8 @@
1
- import {
2
- camelCaseToHyphens,
3
- Packages,
4
- packagesRootDir,
5
- rpcServerCalls,
6
- unwrapAddOrUpdatePackageResult,
7
- } from "@peers-app/peers-sdk";
1
+ import { camelCaseToHyphens, Packages, packagesRootDir } from "@peers-app/peers-sdk";
8
2
  import React, { useState } from "react";
9
3
  import { mainContentPath } from "../../globals";
4
+ import { openInEditorOnHost } from "../../host-shell/host-shell-consumer";
5
+ import { packagesTools } from "./packages-ipc";
10
6
 
11
7
  /** Form for scaffolding a new local package from the template into `~/peers/packages`. */
12
8
  export const PackageNewLocal = () => {
@@ -35,18 +31,17 @@ export const PackageNewLocal = () => {
35
31
 
36
32
  const createLocalPackage = async () => {
37
33
  try {
38
- const { packageId, warning } = unwrapAddOrUpdatePackageResult(
39
- await rpcServerCalls.addOrUpdatePackage(packageName, {
40
- packageLocation: packageLocation || packageLocationDefault,
41
- }),
42
- );
34
+ const { packageId, warning } = await (await packagesTools()).createPackage({
35
+ name: packageName,
36
+ location: packageLocation || packageLocationDefault,
37
+ });
43
38
  const pkg = await Packages().get(packageId);
44
39
  if (pkg) {
45
40
  if (warning) {
46
41
  confirm(warning);
47
42
  }
48
43
  mainContentPath(`packages/${packageId}`);
49
- rpcServerCalls.openPackage(packageLocation || packageLocationDefault);
44
+ void openInEditorOnHost(packageLocation || packageLocationDefault);
50
45
  }
51
46
  } catch (err) {
52
47
  const message = err instanceof Error ? err.message : String(err);
@@ -0,0 +1,58 @@
1
+ import type { IPackageRuntimeState } from "@peers-app/peers-sdk";
2
+ import { render } from "@testing-library/react";
3
+ import { PackageRuntimeBadge, PackageRuntimeStatus } from "./package-runtime-status";
4
+
5
+ let mockStates: Record<string, IPackageRuntimeState> | undefined;
6
+ jest.mock("./packages-ipc", () => ({
7
+ ...jest.requireActual("./packages-ipc"),
8
+ usePackageRuntimeStates: () => mockStates,
9
+ }));
10
+
11
+ const packageId = "pkg";
12
+
13
+ describe("PackageRuntimeStatus", () => {
14
+ it("renders nothing until the host reports state", () => {
15
+ mockStates = undefined;
16
+ expect(render(<PackageRuntimeStatus packageId={packageId} />).container).toBeEmptyDOMElement();
17
+ });
18
+
19
+ it("shows the running version and a failed replacement's error", () => {
20
+ mockStates = {
21
+ [packageId]: {
22
+ packageId,
23
+ status: "failed",
24
+ running: { kind: "isolated", version: "1.0.0", versionTag: "dev" },
25
+ lastError: { phase: "load", message: "manifest has unknown key", version: "1.0.1", at: 1 },
26
+ updatedAt: 1,
27
+ },
28
+ };
29
+ const view = render(<PackageRuntimeStatus packageId={packageId} />);
30
+ expect(view.container).toHaveTextContent("Update failed");
31
+ expect(view.container).toHaveTextContent("Running v1.0.0 (dev) on this device");
32
+ expect(view.getByRole("alert")).toHaveTextContent(
33
+ "Loading v1.0.1 failed: manifest has unknown key",
34
+ );
35
+ });
36
+
37
+ it("shows a worker failure and a reason when nothing is running", () => {
38
+ mockStates = {
39
+ [packageId]: {
40
+ packageId,
41
+ status: "unavailable",
42
+ reason: "The selected version's bundle file is not on this device yet",
43
+ worker: { workerRunning: false, lastWorkerError: { message: "boom", at: 1 } },
44
+ updatedAt: 1,
45
+ },
46
+ };
47
+ const view = render(<PackageRuntimeStatus packageId={packageId} />);
48
+ expect(view.container).toHaveTextContent("not on this device yet");
49
+ expect(view.getByRole("alert")).toHaveTextContent("The package worker failed: boom");
50
+ });
51
+
52
+ it("renders a running badge", () => {
53
+ mockStates = { [packageId]: { packageId, status: "running", updatedAt: 1 } };
54
+ expect(render(<PackageRuntimeBadge packageId={packageId} />).container).toHaveTextContent(
55
+ "Running",
56
+ );
57
+ });
58
+ });
@@ -0,0 +1,68 @@
1
+ import type { IPackageRuntimeState } from "@peers-app/peers-sdk";
2
+ import { packageStatusBadge, usePackageRuntimeStates } from "./packages-ipc";
3
+
4
+ interface IPackageRuntimeProps {
5
+ /** Package to show. */
6
+ packageId: string;
7
+ }
8
+
9
+ /** Small badge with a package's runtime status on this device; renders nothing until known. */
10
+ export const PackageRuntimeBadge = ({ packageId }: IPackageRuntimeProps) => {
11
+ const states = usePackageRuntimeStates();
12
+ return <RuntimeBadge state={states?.[packageId]} />;
13
+ };
14
+
15
+ /**
16
+ * Runtime status on this device: badge, running version, and the last load or worker
17
+ * failure.
18
+ */
19
+ export const PackageRuntimeStatus = ({ packageId }: IPackageRuntimeProps) => {
20
+ const state = usePackageRuntimeStates()?.[packageId];
21
+ if (!state) return null;
22
+ const running = state.running;
23
+ const workerError = state.worker?.lastWorkerError;
24
+ return (
25
+ <div className="mb-3" data-testid="package-runtime-status">
26
+ <div className="d-flex align-items-center gap-2">
27
+ <RuntimeBadge state={state} />
28
+ {running?.version ? (
29
+ <span className="text-muted small">
30
+ Running v{running.version}
31
+ {running.versionTag ? ` (${running.versionTag})` : ""} on this device
32
+ </span>
33
+ ) : state.reason ? (
34
+ <span className="text-muted small">{state.reason}</span>
35
+ ) : null}
36
+ </div>
37
+ {state.status === "failed" && state.lastError ? (
38
+ <RuntimeError
39
+ title={
40
+ state.lastError.version
41
+ ? `Loading v${state.lastError.version} failed`
42
+ : "Loading this package failed"
43
+ }
44
+ message={state.lastError.message}
45
+ />
46
+ ) : null}
47
+ {workerError ? (
48
+ <RuntimeError title="The package worker failed" message={workerError.message} />
49
+ ) : null}
50
+ </div>
51
+ );
52
+ };
53
+
54
+ const RuntimeBadge = ({ state }: { state: IPackageRuntimeState | undefined }) => {
55
+ const badge = packageStatusBadge(state);
56
+ if (!badge) return null;
57
+ return (
58
+ <span className={`badge text-bg-${badge.color}`} title={state?.lastError?.message}>
59
+ {badge.label}
60
+ </span>
61
+ );
62
+ };
63
+
64
+ const RuntimeError = ({ title, message }: { title: string; message: string }) => (
65
+ <div className="alert alert-danger py-2 px-3 mt-2 mb-0 small" role="alert">
66
+ <strong>{title}:</strong> <span className="font-monospace">{message}</span>
67
+ </div>
68
+ );
@@ -1,7 +1,5 @@
1
1
  import {
2
- computePackageVersionHash,
3
2
  getEffectivePackagePrefs,
4
- getMe,
5
3
  hasDeviceFollowOverride,
6
4
  type IDoc,
7
5
  type IPackage,
@@ -9,13 +7,14 @@ import {
9
7
  Packages,
10
8
  PackageVersions,
11
9
  packagePrefsVar,
12
- rpcServerCalls,
10
+ packageVersionPromotionTargets,
13
11
  Users,
14
12
  updatePackagePrefs,
15
13
  } from "@peers-app/peers-sdk";
16
14
  import { useState } from "react";
17
15
  import { useObservable, useObservableState, usePromise } from "../../hooks";
18
- import { activatePackageVersion, isGroupAdmin, maybeActivateAfterPromote } from "./package-helpers";
16
+ import { activatePackageVersion, isGroupAdmin } from "./package-helpers";
17
+ import { packagesTools } from "./packages-ipc";
19
18
 
20
19
  function formatDate(iso: string): string {
21
20
  try {
@@ -113,61 +112,14 @@ export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
113
112
 
114
113
  async function promoteVersion(pv: IPackageVersion, newTag: string) {
115
114
  try {
116
- const me = await getMe();
117
- const history = [...(pv.history ?? [])];
118
- history.push({
119
- action: `promoted:${newTag}`,
120
- by: me.userId,
121
- at: new Date().toISOString(),
122
- signature: "",
123
- });
124
- const pvHash = computePackageVersionHash(
125
- pv.version,
126
- newTag,
127
- pv.packageBundleFileHash,
128
- pv.routesBundleFileHash,
129
- pv.uiBundleFileHash,
130
- );
131
-
132
- // Auto-sign with package author key if available (server/PWA host — key never
133
- // exposed to UI). Soft-fail when the key is missing so group admins can still
134
- // promote, but never keep a signature that was computed for the old tag.
135
- const authorSig = await rpcServerCalls.signPackageAuthorVersion({
136
- packageId: pv.packageId,
115
+ // The host re-tags, re-signs, records history, and activates the version when this
116
+ // device was running it and the group follows the new tag.
117
+ const result = await (await packagesTools()).promotePackageVersion({
137
118
  packageVersionId: pv.packageVersionId,
138
- version: pv.version,
139
119
  versionTag: newTag,
140
- packageBundleFileHash: pv.packageBundleFileHash,
141
- routesBundleFileHash: pv.routesBundleFileHash,
142
- uiBundleFileHash: pv.uiBundleFileHash,
143
- appNavs: pv.appNavs,
144
- createdBy: pv.createdBy,
145
- createdAt: pv.createdAt,
146
120
  });
147
-
148
- const updated: IPackageVersion = {
149
- ...pv,
150
- versionTag: newTag,
151
- packageVersionHash: pvHash,
152
- history,
153
- packageAuthorSignature: authorSig,
154
- };
155
-
156
- await PackageVersions().signAndSave(updated, { saveAsSnapshot: true });
157
-
158
- // Promoting the version this device is already running should advance the
159
- // group active version when the new tag matches group follow policy
160
- // (e.g. promote active → stable with default follow settings).
161
- const pkgJs = pkg.toJS();
162
- const activated = await maybeActivateAfterPromote(pkg.packageId, updated, {
163
- wasEffectiveActive: pv.packageVersionId === effectiveActiveVersionId,
164
- groupFollowTags: pkgJs.followVersionTags,
165
- groupPinned: pkgJs.versionFollowRange === "pinned",
166
- });
167
- if (activated) {
168
- await pkg.load();
169
- }
170
-
121
+ if (result.warning) alert(result.warning);
122
+ if (result.activated) await pkg.load();
171
123
  refreshKey(refreshKey() + 1);
172
124
  } catch (err: unknown) {
173
125
  alert(`Failed to promote version: ${err instanceof Error ? err.message : String(err)}`);
@@ -242,21 +194,6 @@ export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
242
194
  );
243
195
  };
244
196
 
245
- /**
246
- * Valid promotion targets for each version tag. Promotion only moves forward:
247
- * dev -> beta -> stable. No demotions allowed.
248
- */
249
- function getPromoteTargets(currentTag?: string): string[] {
250
- switch (currentTag) {
251
- case "dev":
252
- return ["beta", "stable"];
253
- case "beta":
254
- return ["stable"];
255
- default:
256
- return [];
257
- }
258
- }
259
-
260
197
  function VersionRow(props: {
261
198
  pv: IPackageVersion;
262
199
  isActive: boolean;
@@ -273,7 +210,7 @@ function VersionRow(props: {
273
210
  const { pv, isActive, isPinned, pinning, activating, deleting, userMap } = props;
274
211
  const creatorName = userMap.get(pv.createdBy);
275
212
  const currentTag = pv.versionTag || "dev";
276
- const promoteTargets = getPromoteTargets(currentTag);
213
+ const promoteTargets = packageVersionPromotionTargets(currentTag);
277
214
 
278
215
  return (
279
216
  <div className={`list-group-item ${isActive ? "list-group-item-success" : ""}`}>
@@ -0,0 +1,47 @@
1
+ const hostContractConsumer = jest.fn(
2
+ (_definition: unknown, options: { dataContextId: () => Promise<string> }) => ({
3
+ get: async () => ({ tools: { boundTo: await options.dataContextId() } }),
4
+ dispose: jest.fn(),
5
+ }),
6
+ );
7
+ jest.mock("@peers-app/peers-sdk", () => ({
8
+ getDefaultDataContext: async () => ({ dataContextId: "active-group" }),
9
+ getPackagesContractDefinition: () => ({ name: "System Packages" }),
10
+ hostContractConsumer: (definition: unknown, options: { dataContextId: () => Promise<string> }) =>
11
+ hostContractConsumer(definition, options),
12
+ subscribeHostContractTransport: () => () => undefined,
13
+ }));
14
+
15
+ import { packageStatusBadge, packagesContractFor, packagesTools } from "./packages-ipc";
16
+
17
+ test("caches one host consumer per data context and defaults to the active group", async () => {
18
+ expect(packagesContractFor("group-a")).toBe(packagesContractFor("group-a"));
19
+ expect(packagesContractFor("group-b")).not.toBe(packagesContractFor("group-a"));
20
+ expect(await packagesTools("group-b")).toEqual({ boundTo: "group-b" });
21
+ expect(await packagesTools()).toEqual({ boundTo: "active-group" });
22
+ expect(hostContractConsumer).toHaveBeenCalledTimes(3);
23
+ });
24
+
25
+ test.each([
26
+ [undefined, undefined],
27
+ [{ status: "running" }, { label: "Running", color: "success" }],
28
+ [{ status: "failed" }, { label: "Failed", color: "danger" }],
29
+ [
30
+ { status: "failed", running: { kind: "isolated", version: "1.0.0" } },
31
+ { label: "Update failed", color: "warning" },
32
+ ],
33
+ [
34
+ {
35
+ status: "running",
36
+ worker: { workerRunning: false, lastWorkerError: { message: "x", at: 1 } },
37
+ },
38
+ { label: "Worker failed", color: "danger" },
39
+ ],
40
+ [{ status: "unavailable" }, { label: "Waiting for files", color: "warning" }],
41
+ [{ status: "unsupported" }, { label: "Unsupported here", color: "secondary" }],
42
+ [{ status: "disabled" }, { label: "Disabled", color: "secondary" }],
43
+ [{ status: "inactive" }, undefined],
44
+ [{ status: "notLoaded" }, undefined],
45
+ ] as const)("badge for %p", (runtime, expected) => {
46
+ expect(packageStatusBadge(runtime as never)).toEqual(expected);
47
+ });