pi-extmgr 0.1.24 → 0.1.25

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extmgr",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
4
4
  "description": "Enhanced UX for managing local Pi extensions and community packages",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -19,6 +19,25 @@ import {
19
19
  stripGitSourcePrefix,
20
20
  } from "../utils/package-source.js";
21
21
  import { execNpm } from "../utils/npm-exec.js";
22
+ import { fetchWithTimeout } from "../utils/network.js";
23
+
24
+ const NPM_SEARCH_API = "https://registry.npmjs.org/-/v1/search";
25
+ const NPM_SEARCH_PAGE_SIZE = 250;
26
+
27
+ interface NpmSearchResultObject {
28
+ package?: {
29
+ name?: string;
30
+ version?: string;
31
+ description?: string;
32
+ keywords?: string[];
33
+ date?: string;
34
+ };
35
+ }
36
+
37
+ interface NpmSearchResponse {
38
+ total?: number;
39
+ objects?: NpmSearchResultObject[];
40
+ }
22
41
 
23
42
  let searchCache: SearchCache | null = null;
24
43
 
@@ -51,15 +70,86 @@ import {
51
70
  setCachedPackageSize,
52
71
  } from "../utils/cache.js";
53
72
 
73
+ function toNpmPackage(entry: NpmSearchResultObject): NpmPackage | undefined {
74
+ const pkg = entry.package;
75
+ if (!pkg) return undefined;
76
+
77
+ const name = pkg.name?.trim();
78
+ if (!name) return undefined;
79
+
80
+ return {
81
+ name,
82
+ version: pkg.version,
83
+ description: pkg.description,
84
+ keywords: Array.isArray(pkg.keywords) ? pkg.keywords : undefined,
85
+ date: pkg.date,
86
+ };
87
+ }
88
+
89
+ async function fetchNpmSearchPage(
90
+ query: string,
91
+ from: number
92
+ ): Promise<{
93
+ total: number;
94
+ resultCount: number;
95
+ packages: NpmPackage[];
96
+ }> {
97
+ const params = new URLSearchParams({
98
+ text: query,
99
+ size: String(NPM_SEARCH_PAGE_SIZE),
100
+ from: String(from),
101
+ });
102
+ const response = await fetchWithTimeout(
103
+ `${NPM_SEARCH_API}?${params.toString()}`,
104
+ TIMEOUTS.npmSearch
105
+ );
106
+
107
+ if (!response.ok) {
108
+ throw new Error(`npm registry search failed: HTTP ${response.status}`);
109
+ }
110
+
111
+ const data = (await response.json()) as NpmSearchResponse;
112
+ const objects = data.objects ?? [];
113
+ const packages = objects.map(toNpmPackage).filter((pkg): pkg is NpmPackage => !!pkg);
114
+
115
+ return {
116
+ total:
117
+ typeof data.total === "number" && Number.isFinite(data.total) ? data.total : packages.length,
118
+ resultCount: objects.length,
119
+ packages,
120
+ };
121
+ }
122
+
123
+ export async function fetchNpmRegistrySearchResults(query: string): Promise<NpmPackage[]> {
124
+ const packagesByName = new Map<string, NpmPackage>();
125
+ let from = 0;
126
+ let total = Infinity;
127
+
128
+ while (from < total) {
129
+ const page = await fetchNpmSearchPage(query, from);
130
+ total = page.total;
131
+
132
+ if (page.resultCount === 0) {
133
+ break;
134
+ }
135
+
136
+ for (const pkg of page.packages) {
137
+ if (!packagesByName.has(pkg.name)) {
138
+ packagesByName.set(pkg.name, pkg);
139
+ }
140
+ }
141
+
142
+ from += page.resultCount;
143
+ }
144
+
145
+ return [...packagesByName.values()];
146
+ }
147
+
54
148
  export async function searchNpmPackages(
55
149
  query: string,
56
150
  ctx: ExtensionCommandContext,
57
- pi: ExtensionAPI
151
+ _pi: ExtensionAPI
58
152
  ): Promise<NpmPackage[]> {
59
- // Pull more results so browse mode has meaningful pagination.
60
- // npm search can still cap server-side, but this improves coverage.
61
- const searchLimit = 250;
62
-
63
153
  // Check persistent cache first
64
154
  const cached = await getCachedSearch(query);
65
155
  if (cached && cached.length > 0) {
@@ -73,25 +163,12 @@ export async function searchNpmPackages(
73
163
  ctx.ui.notify(`Searching npm for "${query}"...`, "info");
74
164
  }
75
165
 
76
- const res = await execNpm(pi, ["search", "--json", `--searchlimit=${searchLimit}`, query], ctx, {
77
- timeout: TIMEOUTS.npmSearch,
78
- });
166
+ const packages = await fetchNpmRegistrySearchResults(query);
79
167
 
80
- if (res.code !== 0) {
81
- throw new Error(`npm search failed: ${res.stderr || res.stdout || `exit ${res.code}`}`);
82
- }
83
-
84
- try {
85
- const parsed = JSON.parse(res.stdout || "[]") as NpmPackage[];
86
- const filtered = parsed.filter((p) => !!p?.name);
168
+ // Cache the results
169
+ await setCachedSearch(query, packages);
87
170
 
88
- // Cache the results
89
- await setCachedSearch(query, filtered);
90
-
91
- return filtered;
92
- } catch {
93
- throw new Error("Failed to parse npm search output");
94
- }
171
+ return packages;
95
172
  }
96
173
 
97
174
  export async function getInstalledPackages(
@@ -18,6 +18,7 @@ import { tryOperation } from "../utils/mode.js";
18
18
  import { updateExtmgrStatus } from "../utils/status.js";
19
19
  import { execNpm } from "../utils/npm-exec.js";
20
20
  import { normalizePackageIdentity } from "../utils/package-source.js";
21
+ import { fetchWithTimeout } from "../utils/network.js";
21
22
  import { TIMEOUTS } from "../constants.js";
22
23
 
23
24
  export type InstallScope = "global" | "project";
@@ -73,22 +74,6 @@ function safeExtractGithubMatch(match: RegExpMatchArray | null): GithubUrlInfo |
73
74
  return { owner, repo, branch, filePath };
74
75
  }
75
76
 
76
- async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
77
- const controller = new AbortController();
78
- const timer = setTimeout(() => controller.abort(), timeoutMs);
79
-
80
- try {
81
- return await fetch(url, { signal: controller.signal });
82
- } catch (error) {
83
- if (error instanceof Error && error.name === "AbortError") {
84
- throw new Error(`Download timed out after ${Math.ceil(timeoutMs / 1000)}s`);
85
- }
86
- throw error;
87
- } finally {
88
- clearTimeout(timer);
89
- }
90
- }
91
-
92
77
  async function ensureTarAvailable(
93
78
  pi: ExtensionAPI,
94
79
  ctx: ExtensionCommandContext
@@ -12,6 +12,7 @@ const CACHE_DIR = process.env.PI_EXTMGR_CACHE_DIR
12
12
  ? process.env.PI_EXTMGR_CACHE_DIR
13
13
  : join(homedir(), ".pi", "agent", ".extmgr-cache");
14
14
  const CACHE_FILE = join(CACHE_DIR, "metadata.json");
15
+ const CURRENT_SEARCH_CACHE_STRATEGY = "npm-registry-v1-paginated";
15
16
 
16
17
  interface CachedPackageData {
17
18
  name: string;
@@ -29,6 +30,7 @@ interface CacheData {
29
30
  query: string;
30
31
  results: string[];
31
32
  timestamp: number;
33
+ strategy: string;
32
34
  }
33
35
  | undefined;
34
36
  }
@@ -92,12 +94,15 @@ function normalizeCacheFromDisk(input: unknown): CacheData {
92
94
  const query = input.lastSearch.query;
93
95
  const timestamp = input.lastSearch.timestamp;
94
96
  const results = input.lastSearch.results;
97
+ const strategy = input.lastSearch.strategy;
95
98
 
96
99
  if (
97
100
  typeof query === "string" &&
98
101
  typeof timestamp === "number" &&
99
102
  Number.isFinite(timestamp) &&
100
- Array.isArray(results)
103
+ Array.isArray(results) &&
104
+ typeof strategy === "string" &&
105
+ strategy.trim()
101
106
  ) {
102
107
  const normalizedResults = results.filter(
103
108
  (value): value is string => typeof value === "string"
@@ -106,6 +111,7 @@ function normalizeCacheFromDisk(input: unknown): CacheData {
106
111
  query,
107
112
  timestamp,
108
113
  results: normalizedResults,
114
+ strategy: strategy.trim(),
109
115
  };
110
116
  }
111
117
  }
@@ -194,7 +200,9 @@ async function saveCache(): Promise<void> {
194
200
  const data: {
195
201
  version: number;
196
202
  packages: Record<string, CachedPackageData>;
197
- lastSearch?: { query: string; results: string[]; timestamp: number } | undefined;
203
+ lastSearch?:
204
+ | { query: string; results: string[]; timestamp: number; strategy: string }
205
+ | undefined;
198
206
  } = {
199
207
  version: memoryCache.version,
200
208
  packages: Object.fromEntries(memoryCache.packages),
@@ -276,6 +284,10 @@ export async function getCachedSearch(query: string): Promise<NpmPackage[] | nul
276
284
  return null;
277
285
  }
278
286
 
287
+ if (cache.lastSearch.strategy !== CURRENT_SEARCH_CACHE_STRATEGY) {
288
+ return null;
289
+ }
290
+
279
291
  // Reconstruct packages from cached names
280
292
  const packages: NpmPackage[] = [];
281
293
  for (const name of cache.lastSearch.results) {
@@ -313,6 +325,7 @@ export async function setCachedSearch(query: string, packages: NpmPackage[]): Pr
313
325
  query,
314
326
  results: packages.map((p) => p.name),
315
327
  timestamp: Date.now(),
328
+ strategy: CURRENT_SEARCH_CACHE_STRATEGY,
316
329
  };
317
330
 
318
331
  await enqueueCacheSave();
@@ -0,0 +1,15 @@
1
+ export async function fetchWithTimeout(url: string, timeoutMs: number): Promise<Response> {
2
+ const controller = new AbortController();
3
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
4
+
5
+ try {
6
+ return await fetch(url, { signal: controller.signal });
7
+ } catch (error) {
8
+ if (error instanceof Error && error.name === "AbortError") {
9
+ throw new Error(`Request timed out after ${Math.ceil(timeoutMs / 1000)}s`);
10
+ }
11
+ throw error;
12
+ } finally {
13
+ clearTimeout(timer);
14
+ }
15
+ }