@oh-my-pi/pi-utils 17.2.7 → 17.2.9

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/CHANGELOG.md CHANGED
@@ -2,6 +2,17 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.9] - 2026-08-05
6
+
7
+ ### Added
8
+
9
+ - Added a public `compareVersions` utility (`@oh-my-pi/pi-utils`) that compares two version strings with SemVer-2.0 prerelease ordering, build-metadata stripping, and numeric segment comparison without float overflow; never throws.
10
+
11
+ ### Fixed
12
+
13
+ - Honor the current process `PATH` when caching executable lookups, preventing stale tool paths after environment reloads.
14
+ - Parsed account-cap reset windows such as “Your limit will reset in 13 minutes” so credential backoff honors the provider's full reset duration.
15
+
5
16
  ## [17.2.6] - 2026-08-03
6
17
 
7
18
  ### Added
@@ -34,5 +34,6 @@ export * from "./tab-spacing.js";
34
34
  export * from "./temp.js";
35
35
  export * from "./tls-fetch.js";
36
36
  export * from "./type-guards.js";
37
+ export * from "./version.js";
37
38
  export * from "./which.js";
38
39
  export declare function structuredCloneJSON<T>(value: T): T;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Compare two version strings.
3
+ *
4
+ * Canonical comparator that supersedes the historical in-repo copies
5
+ * (update-cli, hackage scraper, release scripts):
6
+ * - inputs are trimmed and at most one leading `v`/`V` is stripped
7
+ * - dot-separated segments are compared numerically, missing trailing
8
+ * segments count as 0, so `1.2` === `1.2.0` and any segment count works
9
+ * - a SemVer-2.0 prerelease suffix sorts before the plain release
10
+ * (`1.0.0-beta` < `1.0.0`); prerelease identifiers follow SemVer order
11
+ * (numeric < alphanumeric, numeric compared by value, alphanumeric
12
+ * compared lexically, longer sets of equal fields win)
13
+ * - SemVer build metadata begins at the first `+` and does not participate
14
+ * in precedence; it is stripped before core/prerelease parsing
15
+ * - malformed numeric segments compare as 0 (`1.2.x` === `1.2.0`)
16
+ * - never throws; returns only -1 | 0 | 1
17
+ */
18
+ export declare function compareVersions(a: string, b: string): number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-utils",
4
- "version": "17.2.7",
4
+ "version": "17.2.9",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@oh-my-pi/pi-natives": "17.2.7",
34
+ "@oh-my-pi/pi-natives": "17.2.9",
35
35
  "handlebars": "^4.7.9",
36
36
  "winston": "^3.19.0",
37
37
  "winston-daily-rotate-file": "5.0.0"
@@ -10,6 +10,8 @@ const RETRY_DELAY_FIELD_PATTERN = /"retryDelay":\s*"([0-9.]+)(ms|s)"/i;
10
10
  // "try again in 5 min" / "try again in ~158 min." / "try again in 2h" /
11
11
  // "try again in 90 minutes" / "try again in 1 hour"
12
12
  const TRY_AGAIN_PATTERN = /try again in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h)\b/i;
13
+ // "Your limit will reset in 13 minutes" / "reset in 13 minutes" / "will reset in 2h"
14
+ const WILL_RESET_IN_PATTERN = /(?:will\s+)?reset in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h)\b/i;
13
15
 
14
16
  /**
15
17
  * Server-suggested retry delay extraction. Merges the patterns historically used
@@ -83,7 +85,11 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
83
85
  if (totalMs > 0) return totalMs;
84
86
  }
85
87
  }
86
- for (const pattern of [PLEASE_RETRY_PATTERN, RETRY_DELAY_FIELD_PATTERN, TRY_AGAIN_PATTERN]) {
88
+ // Account-reset hints ("will reset in …") take precedence over short
89
+ // retry hints ("please retry in 5s"): a body carrying both must honour the
90
+ // longer account window, not the shorter generic one. QUOTA_RESET_PATTERN
91
+ // ("reset after …") above already runs first and stays first.
92
+ for (const pattern of [WILL_RESET_IN_PATTERN, PLEASE_RETRY_PATTERN, RETRY_DELAY_FIELD_PATTERN, TRY_AGAIN_PATTERN]) {
87
93
  const match = pattern.exec(body);
88
94
  if (match?.[1]) {
89
95
  const value = Number.parseFloat(match[1]);
@@ -71,7 +71,7 @@ export class FrontmatterError extends Error {
71
71
  this.name = "FrontmatterError";
72
72
  }
73
73
 
74
- toString(): string {
74
+ override toString(): string {
75
75
  // Format the error with stack and detail, including the error message, stack, and source if present
76
76
  const details: string[] = [this.message];
77
77
  if (this.source !== undefined) {
package/src/index.ts CHANGED
@@ -34,6 +34,7 @@ export * from "./tab-spacing";
34
34
  export * from "./temp";
35
35
  export * from "./tls-fetch";
36
36
  export * from "./type-guards";
37
+ export * from "./version";
37
38
  export * from "./which";
38
39
 
39
40
  function isPlainObject(val: object): val is Record<string, unknown> {
package/src/version.ts ADDED
@@ -0,0 +1,99 @@
1
+ const DIGITS = /^\d+$/;
2
+
3
+ /**
4
+ * Compare two version strings.
5
+ *
6
+ * Canonical comparator that supersedes the historical in-repo copies
7
+ * (update-cli, hackage scraper, release scripts):
8
+ * - inputs are trimmed and at most one leading `v`/`V` is stripped
9
+ * - dot-separated segments are compared numerically, missing trailing
10
+ * segments count as 0, so `1.2` === `1.2.0` and any segment count works
11
+ * - a SemVer-2.0 prerelease suffix sorts before the plain release
12
+ * (`1.0.0-beta` < `1.0.0`); prerelease identifiers follow SemVer order
13
+ * (numeric < alphanumeric, numeric compared by value, alphanumeric
14
+ * compared lexically, longer sets of equal fields win)
15
+ * - SemVer build metadata begins at the first `+` and does not participate
16
+ * in precedence; it is stripped before core/prerelease parsing
17
+ * - malformed numeric segments compare as 0 (`1.2.x` === `1.2.0`)
18
+ * - never throws; returns only -1 | 0 | 1
19
+ */
20
+ export function compareVersions(a: string, b: string): number {
21
+ const pa = parseVersion(a);
22
+ const pb = parseVersion(b);
23
+
24
+ const core = compareNumericParts(pa.core, pb.core);
25
+ if (core !== 0) return core;
26
+
27
+ return comparePrerelease(pa.prerelease, pb.prerelease);
28
+ }
29
+
30
+ interface ParsedVersion {
31
+ core: string[];
32
+ prerelease: string[] | null;
33
+ }
34
+
35
+ function parseVersion(version: string): ParsedVersion {
36
+ const trimmed = version.trim();
37
+ const stripped = trimmed.startsWith("v") || trimmed.startsWith("V") ? trimmed.slice(1) : trimmed;
38
+ const plusIndex = stripped.indexOf("+");
39
+ const withoutBuild = plusIndex === -1 ? stripped : stripped.slice(0, plusIndex);
40
+ const dashIndex = withoutBuild.indexOf("-");
41
+ if (dashIndex === -1) {
42
+ return { core: withoutBuild.split("."), prerelease: null };
43
+ }
44
+ return {
45
+ core: withoutBuild.slice(0, dashIndex).split("."),
46
+ prerelease: withoutBuild.slice(dashIndex + 1).split("."),
47
+ };
48
+ }
49
+
50
+ /** Compare dot-separated numeric segments; missing/malformed segments count as 0. */
51
+ function compareNumericParts(a: string[], b: string[]): number {
52
+ const length = Math.max(a.length, b.length);
53
+ for (let i = 0; i < length; i++) {
54
+ // Missing or malformed segments compare as 0.
55
+ const sa = a[i];
56
+ const sb = b[i];
57
+ const result = compareDigits(
58
+ sa !== undefined && DIGITS.test(sa) ? sa : "0",
59
+ sb !== undefined && DIGITS.test(sb) ? sb : "0",
60
+ );
61
+ if (result !== 0) return result;
62
+ }
63
+ return 0;
64
+ }
65
+
66
+ /** Exact integer comparison of digit strings, avoiding float overflow. */
67
+ function compareDigits(a: string, b: string): number {
68
+ const na = a.replace(/^0+/, "") || "0";
69
+ const nb = b.replace(/^0+/, "") || "0";
70
+ if (na.length !== nb.length) return na.length < nb.length ? -1 : 1;
71
+ if (na < nb) return -1;
72
+ if (na > nb) return 1;
73
+ return 0;
74
+ }
75
+
76
+ /** SemVer-2.0 prerelease ordering; null means a plain release, which wins. */
77
+ function comparePrerelease(a: string[] | null, b: string[] | null): number {
78
+ if (a === null || b === null) {
79
+ return a === b ? 0 : a === null ? 1 : -1;
80
+ }
81
+ const length = Math.max(a.length, b.length);
82
+ for (let i = 0; i < length; i++) {
83
+ const ia = a[i];
84
+ const ib = b[i];
85
+ if (ia === undefined) return -1;
86
+ if (ib === undefined) return 1;
87
+ const aNumeric = DIGITS.test(ia);
88
+ const bNumeric = DIGITS.test(ib);
89
+ if (aNumeric && bNumeric) {
90
+ const result = compareDigits(ia, ib);
91
+ if (result !== 0) return result;
92
+ } else if (aNumeric !== bNumeric) {
93
+ return aNumeric ? -1 : 1;
94
+ } else if (ia !== ib) {
95
+ return ia < ib ? -1 : 1;
96
+ }
97
+ }
98
+ return 0;
99
+ }
package/src/which.ts CHANGED
@@ -183,8 +183,8 @@ export interface WhichOptions extends Bun.WhichOptions {
183
183
 
184
184
  // Darwin-specific "which" shim: consult Xcode/CLT toolchain directories after $PATH.
185
185
  // Uses cached directory listings instead of per-command existsSync or xcrun subprocesses.
186
- function darwinWhich(command: string, _options?: Bun.WhichOptions): string | null {
187
- const regular = Bun.which(command);
186
+ function darwinWhich(command: string, options?: Bun.WhichOptions): string | null {
187
+ const regular = Bun.which(command, options);
188
188
  if (regular) return regular;
189
189
  if (isXcodeBin(command)) {
190
190
  return getMacosToolPaths().get(command) ?? null;
@@ -214,17 +214,19 @@ function cacheKey(command: string, options?: Bun.WhichOptions): CacheKey {
214
214
  */
215
215
  export function $which(command: string, options?: WhichOptions): string | null {
216
216
  const cachePolicy = options?.cache ?? WhichCachePolicy.Cached;
217
+ const lookupOptions =
218
+ options?.PATH !== undefined || process.env.PATH === undefined ? options : { ...options, PATH: process.env.PATH };
217
219
  let key: CacheKey | undefined;
218
220
 
219
221
  if (cachePolicy !== WhichCachePolicy.Bypass) {
220
- key = cacheKey(command, options);
222
+ key = cacheKey(command, lookupOptions);
221
223
  if (cachePolicy !== WhichCachePolicy.Fresh) {
222
224
  const cached = toolCache.get(key);
223
225
  if (cached !== undefined) return cached;
224
226
  }
225
227
  }
226
228
 
227
- const result = whichFresh(command, options);
229
+ const result = whichFresh(command, lookupOptions);
228
230
  if (key != null && cachePolicy !== WhichCachePolicy.ReadOnly) {
229
231
  toolCache.set(key, result);
230
232
  }