@peers-app/peers-ui 0.21.0 → 0.21.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.
@@ -29,5 +29,26 @@ export declare function isGroupAdmin(): Promise<boolean>;
29
29
  * @param pv - The package version to activate.
30
30
  */
31
31
  export declare function activatePackageVersion(packageId: string, pv: IPackageVersion): Promise<void>;
32
+ /**
33
+ * After promoting a version that is currently effective-active on this device,
34
+ * activate it when the new tag matches the group's auto-activate / follow rules.
35
+ *
36
+ * This closes the gotcha where a device is already running a `dev` build, promotes
37
+ * it to `beta`/`stable`, and expects the group active version to advance without a
38
+ * separate Activate click — but only when the group would accept that channel
39
+ * (e.g. default follow is `stable`; `beta` only when the group follows beta).
40
+ *
41
+ * @param packageId - Package that was promoted
42
+ * @param promotedPv - PV after the tag change (must include the new `versionTag`)
43
+ * @param opts.wasEffectiveActive - True when this PV was this device's effective active before promote
44
+ * @param opts.groupFollowTags - Group `IPackage.followVersionTags` (not device override)
45
+ * @param opts.groupPinned - True when group `versionFollowRange` is `pinned`
46
+ * @returns Whether activation was attempted
47
+ */
48
+ export declare function maybeActivateAfterPromote(packageId: string, promotedPv: IPackageVersion, opts: {
49
+ wasEffectiveActive: boolean;
50
+ groupFollowTags: string | undefined;
51
+ groupPinned: boolean;
52
+ }): Promise<boolean>;
32
53
  /** Numeric rank for sorting packages by update urgency. */
33
54
  export declare const updateLevelRank: Record<string, number>;
@@ -4,6 +4,7 @@ exports.updateLevelRank = void 0;
4
4
  exports.checkVersionStatus = checkVersionStatus;
5
5
  exports.isGroupAdmin = isGroupAdmin;
6
6
  exports.activatePackageVersion = activatePackageVersion;
7
+ exports.maybeActivateAfterPromote = maybeActivateAfterPromote;
7
8
  const peers_sdk_1 = require("@peers-app/peers-sdk");
8
9
  /**
9
10
  * Check a package's version status: what's currently active, whether a newer
@@ -106,5 +107,36 @@ async function activatePackageVersion(packageId, pv) {
106
107
  await (0, peers_sdk_1.updatePackagePrefs)(packageId, { activePackageVersionId: pv.packageVersionId });
107
108
  }
108
109
  }
110
+ /**
111
+ * After promoting a version that is currently effective-active on this device,
112
+ * activate it when the new tag matches the group's auto-activate / follow rules.
113
+ *
114
+ * This closes the gotcha where a device is already running a `dev` build, promotes
115
+ * it to `beta`/`stable`, and expects the group active version to advance without a
116
+ * separate Activate click — but only when the group would accept that channel
117
+ * (e.g. default follow is `stable`; `beta` only when the group follows beta).
118
+ *
119
+ * @param packageId - Package that was promoted
120
+ * @param promotedPv - PV after the tag change (must include the new `versionTag`)
121
+ * @param opts.wasEffectiveActive - True when this PV was this device's effective active before promote
122
+ * @param opts.groupFollowTags - Group `IPackage.followVersionTags` (not device override)
123
+ * @param opts.groupPinned - True when group `versionFollowRange` is `pinned`
124
+ * @returns Whether activation was attempted
125
+ */
126
+ async function maybeActivateAfterPromote(packageId, promotedPv, opts) {
127
+ if (!opts.wasEffectiveActive)
128
+ return false;
129
+ if (opts.groupPinned)
130
+ return false;
131
+ if (!promotedPv.versionTag || promotedPv.versionTag === "dev")
132
+ return false;
133
+ // Docs default: groups follow `stable` when followVersionTags is unset.
134
+ const followTags = opts.groupFollowTags ?? "stable";
135
+ if (!(0, peers_sdk_1.doesTagMatch)(undefined, promotedPv.versionTag, followTags, undefined)) {
136
+ return false;
137
+ }
138
+ await activatePackageVersion(packageId, promotedPv);
139
+ return true;
140
+ }
109
141
  /** Numeric rank for sorting packages by update urgency. */
110
142
  exports.updateLevelRank = { major: 3, minor: 2, patch: 1 };
@@ -1,4 +1,5 @@
1
1
  import { type IDoc, type IPackage } from "@peers-app/peers-sdk";
2
+ /** Lists package versions with activate, promote, pin, edit-semver, and delete actions. */
2
3
  export declare const PackageVersionsList: (props: {
3
4
  pkg: IDoc<IPackage>;
4
5
  }) => import("react/jsx-runtime").JSX.Element;
@@ -39,6 +39,7 @@ function tagBadgeClass(tag) {
39
39
  return "text-bg-success";
40
40
  return "text-bg-secondary";
41
41
  }
42
+ /** Lists package versions with activate, promote, pin, edit-semver, and delete actions. */
42
43
  const PackageVersionsList = (props) => {
43
44
  const { pkg } = props;
44
45
  const refreshKey = (0, hooks_1.useObservableState)(0);
@@ -115,24 +116,41 @@ const PackageVersionsList = (props) => {
115
116
  signature: "",
116
117
  });
117
118
  const pvHash = (0, peers_sdk_1.computePackageVersionHash)(pv.version, newTag, pv.packageBundleFileHash, pv.routesBundleFileHash, pv.uiBundleFileHash);
119
+ // Auto-sign with package author key if available (server/PWA host — key never
120
+ // exposed to UI). Soft-fail when the key is missing so group admins can still
121
+ // promote, but never keep a signature that was computed for the old tag.
122
+ const authorSig = await peers_sdk_1.rpcServerCalls.signPackageAuthorVersion({
123
+ packageId: pv.packageId,
124
+ packageVersionId: pv.packageVersionId,
125
+ version: pv.version,
126
+ versionTag: newTag,
127
+ packageBundleFileHash: pv.packageBundleFileHash,
128
+ routesBundleFileHash: pv.routesBundleFileHash,
129
+ uiBundleFileHash: pv.uiBundleFileHash,
130
+ appNavs: pv.appNavs,
131
+ createdBy: pv.createdBy,
132
+ createdAt: pv.createdAt,
133
+ });
118
134
  const updated = {
119
135
  ...pv,
120
136
  versionTag: newTag,
121
137
  packageVersionHash: pvHash,
122
138
  history,
139
+ packageAuthorSignature: authorSig,
123
140
  };
124
- // Auto-sign with package author key if available (server-side, key never exposed)
125
- const authorSig = await peers_sdk_1.rpcServerCalls.signPackageAuthorVersion({
126
- packageId: updated.packageId,
127
- packageVersionId: updated.packageVersionId,
128
- version: updated.version,
129
- versionTag: newTag,
130
- packageBundleFileHash: updated.packageBundleFileHash,
131
- routesBundleFileHash: updated.routesBundleFileHash,
132
- uiBundleFileHash: updated.uiBundleFileHash,
133
- });
134
- updated.packageAuthorSignature = authorSig;
135
141
  await (0, peers_sdk_1.PackageVersions)().signAndSave(updated, { saveAsSnapshot: true });
142
+ // Promoting the version this device is already running should advance the
143
+ // group active version when the new tag matches group follow policy
144
+ // (e.g. promote active → stable with default follow settings).
145
+ const pkgJs = pkg.toJS();
146
+ const activated = await (0, package_helpers_1.maybeActivateAfterPromote)(pkg.packageId, updated, {
147
+ wasEffectiveActive: pv.packageVersionId === effectiveActiveVersionId,
148
+ groupFollowTags: pkgJs.followVersionTags,
149
+ groupPinned: pkgJs.versionFollowRange === "pinned",
150
+ });
151
+ if (activated) {
152
+ await pkg.load();
153
+ }
136
154
  refreshKey(refreshKey() + 1);
137
155
  }
138
156
  catch (err) {
@@ -142,7 +160,25 @@ const PackageVersionsList = (props) => {
142
160
  async function updateVersion(pv, newSemver) {
143
161
  try {
144
162
  const pvHash = (0, peers_sdk_1.computePackageVersionHash)(newSemver, pv.versionTag || "dev", pv.packageBundleFileHash, pv.routesBundleFileHash, pv.uiBundleFileHash);
145
- const updated = { ...pv, version: newSemver, packageVersionHash: pvHash };
163
+ // Semver is part of the author-signed payload — re-sign or clear the old signature.
164
+ const authorSig = await peers_sdk_1.rpcServerCalls.signPackageAuthorVersion({
165
+ packageId: pv.packageId,
166
+ packageVersionId: pv.packageVersionId,
167
+ version: newSemver,
168
+ versionTag: pv.versionTag,
169
+ packageBundleFileHash: pv.packageBundleFileHash,
170
+ routesBundleFileHash: pv.routesBundleFileHash,
171
+ uiBundleFileHash: pv.uiBundleFileHash,
172
+ appNavs: pv.appNavs,
173
+ createdBy: pv.createdBy,
174
+ createdAt: pv.createdAt,
175
+ });
176
+ const updated = {
177
+ ...pv,
178
+ version: newSemver,
179
+ packageVersionHash: pvHash,
180
+ packageAuthorSignature: authorSig,
181
+ };
146
182
  await (0, peers_sdk_1.PackageVersions)().signAndSave(updated, { saveAsSnapshot: true });
147
183
  refreshKey(refreshKey() + 1);
148
184
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peers-app/peers-ui",
3
- "version": "0.21.0",
3
+ "version": "0.21.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/peers-app/peers-ui.git"
@@ -28,7 +28,7 @@
28
28
  "lint:fix": "biome check --write ."
29
29
  },
30
30
  "peerDependencies": {
31
- "@peers-app/peers-sdk": "^0.21.0",
31
+ "@peers-app/peers-sdk": "^0.21.2",
32
32
  "bootstrap": "^5.3.3",
33
33
  "react": "^18.0.0",
34
34
  "react-dom": "^18.0.0"
@@ -39,7 +39,7 @@
39
39
  "@babel/preset-react": "^7.24.1",
40
40
  "@babel/preset-typescript": "^7.27.1",
41
41
  "@biomejs/biome": "2.3.11",
42
- "@peers-app/peers-sdk": "0.21.0",
42
+ "@peers-app/peers-sdk": "0.21.2",
43
43
  "@testing-library/dom": "^10.4.0",
44
44
  "@testing-library/jest-dom": "^6.6.3",
45
45
  "@testing-library/react": "^16.3.0",
@@ -136,5 +136,44 @@ export async function activatePackageVersion(
136
136
  }
137
137
  }
138
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;
176
+ }
177
+
139
178
  /** Numeric rank for sorting packages by update urgency. */
140
179
  export const updateLevelRank: Record<string, number> = { major: 3, minor: 2, patch: 1 };
@@ -15,7 +15,7 @@ import {
15
15
  } from "@peers-app/peers-sdk";
16
16
  import { useState } from "react";
17
17
  import { useObservable, useObservableState, usePromise } from "../../hooks";
18
- import { activatePackageVersion, isGroupAdmin } from "./package-helpers";
18
+ import { activatePackageVersion, isGroupAdmin, maybeActivateAfterPromote } from "./package-helpers";
19
19
 
20
20
  function formatDate(iso: string): string {
21
21
  try {
@@ -43,6 +43,7 @@ function tagBadgeClass(tag?: string): string {
43
43
  return "text-bg-secondary";
44
44
  }
45
45
 
46
+ /** Lists package versions with activate, promote, pin, edit-semver, and delete actions. */
46
47
  export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
47
48
  const { pkg } = props;
48
49
  const refreshKey = useObservableState(0);
@@ -127,26 +128,46 @@ export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
127
128
  pv.routesBundleFileHash,
128
129
  pv.uiBundleFileHash,
129
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,
137
+ packageVersionId: pv.packageVersionId,
138
+ version: pv.version,
139
+ 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
+ });
147
+
130
148
  const updated: IPackageVersion = {
131
149
  ...pv,
132
150
  versionTag: newTag,
133
151
  packageVersionHash: pvHash,
134
152
  history,
153
+ packageAuthorSignature: authorSig,
135
154
  };
136
155
 
137
- // Auto-sign with package author key if available (server-side, key never exposed)
138
- const authorSig = await rpcServerCalls.signPackageAuthorVersion({
139
- packageId: updated.packageId,
140
- packageVersionId: updated.packageVersionId,
141
- version: updated.version,
142
- versionTag: newTag,
143
- packageBundleFileHash: updated.packageBundleFileHash,
144
- routesBundleFileHash: updated.routesBundleFileHash,
145
- uiBundleFileHash: updated.uiBundleFileHash,
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",
146
166
  });
147
- updated.packageAuthorSignature = authorSig;
167
+ if (activated) {
168
+ await pkg.load();
169
+ }
148
170
 
149
- await PackageVersions().signAndSave(updated, { saveAsSnapshot: true });
150
171
  refreshKey(refreshKey() + 1);
151
172
  } catch (err: unknown) {
152
173
  alert(`Failed to promote version: ${err instanceof Error ? err.message : String(err)}`);
@@ -162,7 +183,25 @@ export const PackageVersionsList = (props: { pkg: IDoc<IPackage> }) => {
162
183
  pv.routesBundleFileHash,
163
184
  pv.uiBundleFileHash,
164
185
  );
165
- const updated = { ...pv, version: newSemver, packageVersionHash: pvHash };
186
+ // Semver is part of the author-signed payload — re-sign or clear the old signature.
187
+ const authorSig = await rpcServerCalls.signPackageAuthorVersion({
188
+ packageId: pv.packageId,
189
+ packageVersionId: pv.packageVersionId,
190
+ version: newSemver,
191
+ versionTag: pv.versionTag,
192
+ packageBundleFileHash: pv.packageBundleFileHash,
193
+ routesBundleFileHash: pv.routesBundleFileHash,
194
+ uiBundleFileHash: pv.uiBundleFileHash,
195
+ appNavs: pv.appNavs,
196
+ createdBy: pv.createdBy,
197
+ createdAt: pv.createdAt,
198
+ });
199
+ const updated: IPackageVersion = {
200
+ ...pv,
201
+ version: newSemver,
202
+ packageVersionHash: pvHash,
203
+ packageAuthorSignature: authorSig,
204
+ };
166
205
  await PackageVersions().signAndSave(updated, { saveAsSnapshot: true });
167
206
  refreshKey(refreshKey() + 1);
168
207
  } catch (err: unknown) {