pi-extmgr 0.1.26 → 0.1.27
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 +3 -2
- package/package.json +3 -3
- package/src/packages/catalog.ts +162 -0
- package/src/packages/discovery.ts +59 -247
- package/src/packages/install.ts +30 -26
- package/src/packages/management.ts +103 -83
- package/src/ui/async-task.ts +158 -0
- package/src/ui/package-config.ts +27 -2
- package/src/ui/remote.ts +69 -17
- package/src/ui/unified.ts +45 -12
- package/src/utils/auto-update.ts +8 -54
- package/src/utils/network.ts +10 -2
- package/src/utils/npm-exec.ts +2 -0
- package/src/utils/package-source.ts +51 -0
- package/src/utils/status.ts +5 -3
- package/src/utils/ui-helpers.ts +1 -1
package/README.md
CHANGED
|
@@ -38,10 +38,11 @@ Requires Node.js `>=22.5.0`.
|
|
|
38
38
|
- npm search/browse with pagination
|
|
39
39
|
- Install by source (`npm:`, `git:`, `https://`, `ssh://`, `git@...`, local path)
|
|
40
40
|
- Supports direct GitHub `.ts` installs and standalone local install for self-contained packages
|
|
41
|
+
- Long-running discovery/detail screens now show dedicated loading UI, and cancellable reads can be aborted with `Esc`
|
|
41
42
|
- **Auto-update**
|
|
42
43
|
- Interactive wizard (`t` in manager, or `/extensions auto-update`)
|
|
43
44
|
- Persistent schedule restored on startup and session switch
|
|
44
|
-
- Background checks + status bar updates for installed npm packages
|
|
45
|
+
- Background checks + status bar updates for installed npm + git packages
|
|
45
46
|
- **Operational visibility**
|
|
46
47
|
- Session history (`/extensions history`)
|
|
47
48
|
- Cache controls (`/extensions clear-cache` clears persistent + runtime extmgr caches)
|
|
@@ -159,7 +160,7 @@ Examples:
|
|
|
159
160
|
- **Managed** (npm): Auto-updates with `pi update`, stored in pi's package cache, supports Pi package manifest/convention loading
|
|
160
161
|
- **Local** (standalone): Copies to `~/.pi/agent/extensions/{package}/`, so it only accepts runnable standalone layouts (manifest-declared/root entrypoints), requires `tar` on `PATH`, and rejects packages whose runtime `dependencies` are not already bundled with the package contents
|
|
161
162
|
- **Auto-update schedule is persistent**: `/extensions auto-update 1d` stays active across future Pi sessions and is restored when switching sessions.
|
|
162
|
-
- **Auto-update
|
|
163
|
+
- **Auto-update/update badges cover npm + git packages**: extmgr now uses pi's package manager APIs for structured update detection instead of parsing `pi list` output.
|
|
163
164
|
- **Settings/cache writes are hardened**: extmgr serializes writes and uses safe file replacement to reduce JSON corruption issues.
|
|
164
165
|
- **Invalid JSON is handled safely**: malformed `auto-update.json` / metadata cache files are backed up and reset; invalid `.pi/settings.json` is not overwritten during package-extension toggles.
|
|
165
166
|
- **Reload is built-in**: When extmgr asks to reload, it calls `ctx.reload()` directly.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-extmgr",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
4
4
|
"description": "Enhanced UX for managing local Pi extensions and community packages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -42,8 +42,8 @@
|
|
|
42
42
|
"@mariozechner/pi-tui": "*"
|
|
43
43
|
},
|
|
44
44
|
"devDependencies": {
|
|
45
|
-
"@mariozechner/pi-coding-agent": "^0.
|
|
46
|
-
"@mariozechner/pi-tui": "^0.
|
|
45
|
+
"@mariozechner/pi-coding-agent": "^0.62.0",
|
|
46
|
+
"@mariozechner/pi-tui": "^0.62.0",
|
|
47
47
|
"@types/node": "^22.13.10",
|
|
48
48
|
"@typescript-eslint/eslint-plugin": "^8.42.0",
|
|
49
49
|
"@typescript-eslint/parser": "^8.42.0",
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DefaultPackageManager,
|
|
3
|
+
getAgentDir,
|
|
4
|
+
SettingsManager,
|
|
5
|
+
type PackageSource,
|
|
6
|
+
type ProgressEvent,
|
|
7
|
+
} from "@mariozechner/pi-coding-agent";
|
|
8
|
+
import type { InstalledPackage, Scope } from "../types/index.js";
|
|
9
|
+
import { normalizePackageIdentity, parsePackageNameAndVersion } from "../utils/package-source.js";
|
|
10
|
+
|
|
11
|
+
type PiScope = "user" | "project";
|
|
12
|
+
type PiPackageUpdate = Awaited<
|
|
13
|
+
ReturnType<DefaultPackageManager["checkForAvailableUpdates"]>
|
|
14
|
+
>[number];
|
|
15
|
+
|
|
16
|
+
export interface AvailablePackageUpdate {
|
|
17
|
+
source: string;
|
|
18
|
+
displayName: string;
|
|
19
|
+
type: "npm" | "git";
|
|
20
|
+
scope: Scope;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PackageCatalog {
|
|
24
|
+
listInstalledPackages(options?: { dedupe?: boolean }): Promise<InstalledPackage[]>;
|
|
25
|
+
checkForAvailableUpdates(): Promise<AvailablePackageUpdate[]>;
|
|
26
|
+
install(source: string, scope: Scope, onProgress?: (event: ProgressEvent) => void): Promise<void>;
|
|
27
|
+
remove(source: string, scope: Scope, onProgress?: (event: ProgressEvent) => void): Promise<void>;
|
|
28
|
+
update(source?: string, onProgress?: (event: ProgressEvent) => void): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
type PackageCatalogFactory = (cwd: string) => PackageCatalog;
|
|
32
|
+
|
|
33
|
+
let packageCatalogFactory: PackageCatalogFactory = createDefaultPackageCatalog;
|
|
34
|
+
|
|
35
|
+
function toScope(scope: PiScope): Scope {
|
|
36
|
+
return scope === "project" ? "project" : "global";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getPackageSource(pkg: PackageSource): string {
|
|
40
|
+
return typeof pkg === "string" ? pkg : pkg.source;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createPackageRecord(
|
|
44
|
+
source: string,
|
|
45
|
+
scope: PiScope,
|
|
46
|
+
packageManager: DefaultPackageManager
|
|
47
|
+
): InstalledPackage {
|
|
48
|
+
const resolvedPath = packageManager.getInstalledPath(source, scope);
|
|
49
|
+
const { name, version } = parsePackageNameAndVersion(source);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
source,
|
|
53
|
+
name,
|
|
54
|
+
scope: toScope(scope),
|
|
55
|
+
...(version ? { version } : {}),
|
|
56
|
+
...(resolvedPath ? { resolvedPath } : {}),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function dedupeInstalledPackages(packages: InstalledPackage[]): InstalledPackage[] {
|
|
61
|
+
const byIdentity = new Map<string, InstalledPackage>();
|
|
62
|
+
|
|
63
|
+
for (const pkg of packages) {
|
|
64
|
+
const identity = normalizePackageIdentity(
|
|
65
|
+
pkg.source,
|
|
66
|
+
pkg.resolvedPath ? { resolvedPath: pkg.resolvedPath } : undefined
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
if (!byIdentity.has(identity)) {
|
|
70
|
+
byIdentity.set(identity, pkg);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return [...byIdentity.values()];
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function setProgressCallback(
|
|
78
|
+
packageManager: DefaultPackageManager,
|
|
79
|
+
onProgress?: (event: ProgressEvent) => void
|
|
80
|
+
): void {
|
|
81
|
+
packageManager.setProgressCallback(onProgress);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function createDefaultPackageCatalog(cwd: string): PackageCatalog {
|
|
85
|
+
const agentDir = getAgentDir();
|
|
86
|
+
const settingsManager = SettingsManager.create(cwd, agentDir);
|
|
87
|
+
const packageManager = new DefaultPackageManager({ cwd, agentDir, settingsManager });
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
listInstalledPackages(options) {
|
|
91
|
+
const projectPackages = (settingsManager.getProjectSettings().packages ?? []).map((pkg) =>
|
|
92
|
+
createPackageRecord(getPackageSource(pkg), "project", packageManager)
|
|
93
|
+
);
|
|
94
|
+
const globalPackages = (settingsManager.getGlobalSettings().packages ?? []).map((pkg) =>
|
|
95
|
+
createPackageRecord(getPackageSource(pkg), "user", packageManager)
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const installed = [...projectPackages, ...globalPackages];
|
|
99
|
+
return Promise.resolve(
|
|
100
|
+
options?.dedupe === false ? installed : dedupeInstalledPackages(installed)
|
|
101
|
+
);
|
|
102
|
+
},
|
|
103
|
+
|
|
104
|
+
async checkForAvailableUpdates() {
|
|
105
|
+
const updates = await packageManager.checkForAvailableUpdates();
|
|
106
|
+
return updates.map((update: PiPackageUpdate) => ({
|
|
107
|
+
source: update.source,
|
|
108
|
+
displayName: update.displayName,
|
|
109
|
+
type: update.type,
|
|
110
|
+
scope: toScope(update.scope),
|
|
111
|
+
}));
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
async install(source, scope, onProgress) {
|
|
115
|
+
setProgressCallback(packageManager, onProgress);
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
await packageManager.install(source, { local: scope === "project" });
|
|
119
|
+
packageManager.addSourceToSettings(source, { local: scope === "project" });
|
|
120
|
+
await settingsManager.flush();
|
|
121
|
+
} finally {
|
|
122
|
+
setProgressCallback(packageManager, undefined);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
|
|
126
|
+
async remove(source, scope, onProgress) {
|
|
127
|
+
setProgressCallback(packageManager, onProgress);
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await packageManager.remove(source, { local: scope === "project" });
|
|
131
|
+
const removed = packageManager.removeSourceFromSettings(source, {
|
|
132
|
+
local: scope === "project",
|
|
133
|
+
});
|
|
134
|
+
await settingsManager.flush();
|
|
135
|
+
|
|
136
|
+
if (!removed) {
|
|
137
|
+
throw new Error(`No matching package found for ${source}`);
|
|
138
|
+
}
|
|
139
|
+
} finally {
|
|
140
|
+
setProgressCallback(packageManager, undefined);
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
async update(source, onProgress) {
|
|
145
|
+
setProgressCallback(packageManager, onProgress);
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
await packageManager.update(source);
|
|
149
|
+
} finally {
|
|
150
|
+
setProgressCallback(packageManager, undefined);
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function getPackageCatalog(cwd: string): PackageCatalog {
|
|
157
|
+
return packageCatalogFactory(cwd);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function setPackageCatalogFactory(factory?: PackageCatalogFactory): void {
|
|
161
|
+
packageCatalogFactory = factory ?? createDefaultPackageCatalog;
|
|
162
|
+
}
|
|
@@ -12,12 +12,8 @@ import type { InstalledPackage, NpmPackage, SearchCache } from "../types/index.j
|
|
|
12
12
|
import { CACHE_TTL, TIMEOUTS } from "../constants.js";
|
|
13
13
|
import { readSummary } from "../utils/fs.js";
|
|
14
14
|
import { parseNpmSource } from "../utils/format.js";
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
normalizePackageIdentity,
|
|
18
|
-
splitGitRepoAndRef,
|
|
19
|
-
stripGitSourcePrefix,
|
|
20
|
-
} from "../utils/package-source.js";
|
|
15
|
+
import { normalizePackageIdentity } from "../utils/package-source.js";
|
|
16
|
+
import { getPackageCatalog } from "./catalog.js";
|
|
21
17
|
import { execNpm } from "../utils/npm-exec.js";
|
|
22
18
|
import { fetchWithTimeout } from "../utils/network.js";
|
|
23
19
|
|
|
@@ -41,6 +37,18 @@ interface NpmSearchResponse {
|
|
|
41
37
|
|
|
42
38
|
let searchCache: SearchCache | null = null;
|
|
43
39
|
|
|
40
|
+
function createAbortError(): Error {
|
|
41
|
+
const error = new Error("Operation cancelled");
|
|
42
|
+
error.name = "AbortError";
|
|
43
|
+
return error;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function throwIfAborted(signal?: AbortSignal): void {
|
|
47
|
+
if (signal?.aborted) {
|
|
48
|
+
throw createAbortError();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
export function getSearchCache(): SearchCache | null {
|
|
45
53
|
return searchCache;
|
|
46
54
|
}
|
|
@@ -88,7 +96,8 @@ function toNpmPackage(entry: NpmSearchResultObject): NpmPackage | undefined {
|
|
|
88
96
|
|
|
89
97
|
async function fetchNpmSearchPage(
|
|
90
98
|
query: string,
|
|
91
|
-
from: number
|
|
99
|
+
from: number,
|
|
100
|
+
signal?: AbortSignal
|
|
92
101
|
): Promise<{
|
|
93
102
|
total: number;
|
|
94
103
|
resultCount: number;
|
|
@@ -101,7 +110,8 @@ async function fetchNpmSearchPage(
|
|
|
101
110
|
});
|
|
102
111
|
const response = await fetchWithTimeout(
|
|
103
112
|
`${NPM_SEARCH_API}?${params.toString()}`,
|
|
104
|
-
TIMEOUTS.npmSearch
|
|
113
|
+
TIMEOUTS.npmSearch,
|
|
114
|
+
signal
|
|
105
115
|
);
|
|
106
116
|
|
|
107
117
|
if (!response.ok) {
|
|
@@ -120,13 +130,16 @@ async function fetchNpmSearchPage(
|
|
|
120
130
|
};
|
|
121
131
|
}
|
|
122
132
|
|
|
123
|
-
export async function fetchNpmRegistrySearchResults(
|
|
133
|
+
export async function fetchNpmRegistrySearchResults(
|
|
134
|
+
query: string,
|
|
135
|
+
signal?: AbortSignal
|
|
136
|
+
): Promise<NpmPackage[]> {
|
|
124
137
|
const packagesByName = new Map<string, NpmPackage>();
|
|
125
138
|
let from = 0;
|
|
126
139
|
let total = Infinity;
|
|
127
140
|
|
|
128
141
|
while (from < total) {
|
|
129
|
-
const page = await fetchNpmSearchPage(query, from);
|
|
142
|
+
const page = await fetchNpmSearchPage(query, from, signal);
|
|
130
143
|
total = page.total;
|
|
131
144
|
|
|
132
145
|
if (page.resultCount === 0) {
|
|
@@ -148,11 +161,10 @@ export async function fetchNpmRegistrySearchResults(query: string): Promise<NpmP
|
|
|
148
161
|
export async function searchNpmPackages(
|
|
149
162
|
query: string,
|
|
150
163
|
ctx: ExtensionCommandContext,
|
|
151
|
-
|
|
164
|
+
options?: { signal?: AbortSignal }
|
|
152
165
|
): Promise<NpmPackage[]> {
|
|
153
|
-
// Check persistent cache first
|
|
154
166
|
const cached = await getCachedSearch(query);
|
|
155
|
-
if (cached
|
|
167
|
+
if (cached) {
|
|
156
168
|
if (ctx.hasUI) {
|
|
157
169
|
ctx.ui.notify(`Using ${cached.length} cached results`, "info");
|
|
158
170
|
}
|
|
@@ -163,7 +175,7 @@ export async function searchNpmPackages(
|
|
|
163
175
|
ctx.ui.notify(`Searching npm for "${query}"...`, "info");
|
|
164
176
|
}
|
|
165
177
|
|
|
166
|
-
const packages = await fetchNpmRegistrySearchResults(query);
|
|
178
|
+
const packages = await fetchNpmRegistrySearchResults(query, options?.signal);
|
|
167
179
|
|
|
168
180
|
// Cache the results
|
|
169
181
|
await setCachedSearch(query, packages);
|
|
@@ -174,31 +186,21 @@ export async function searchNpmPackages(
|
|
|
174
186
|
export async function getInstalledPackages(
|
|
175
187
|
ctx: ExtensionCommandContext | ExtensionContext,
|
|
176
188
|
pi: ExtensionAPI,
|
|
177
|
-
onProgress?: (current: number, total: number) => void
|
|
189
|
+
onProgress?: (current: number, total: number) => void,
|
|
190
|
+
signal?: AbortSignal
|
|
178
191
|
): Promise<InstalledPackage[]> {
|
|
179
|
-
|
|
180
|
-
if (res.code !== 0) return [];
|
|
192
|
+
throwIfAborted(signal);
|
|
181
193
|
|
|
182
|
-
const
|
|
183
|
-
if (
|
|
194
|
+
const packages = await getPackageCatalog(ctx.cwd).listInstalledPackages();
|
|
195
|
+
if (packages.length === 0) {
|
|
184
196
|
return [];
|
|
185
197
|
}
|
|
186
198
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
// Fetch metadata (descriptions and sizes) for packages in parallel
|
|
190
|
-
await addPackageMetadata(packages, ctx, pi, onProgress);
|
|
191
|
-
|
|
199
|
+
await addPackageMetadata(packages, ctx, pi, onProgress, signal);
|
|
200
|
+
throwIfAborted(signal);
|
|
192
201
|
return packages;
|
|
193
202
|
}
|
|
194
203
|
|
|
195
|
-
function sanitizeListSourceSuffix(source: string): string {
|
|
196
|
-
return source
|
|
197
|
-
.trim()
|
|
198
|
-
.replace(/\s+\((filtered|pinned)\)$/i, "")
|
|
199
|
-
.trim();
|
|
200
|
-
}
|
|
201
|
-
|
|
202
204
|
function getInstalledPackageIdentity(pkg: InstalledPackage): string {
|
|
203
205
|
return normalizePackageIdentity(
|
|
204
206
|
pkg.source,
|
|
@@ -206,224 +208,26 @@ function getInstalledPackageIdentity(pkg: InstalledPackage): string {
|
|
|
206
208
|
);
|
|
207
209
|
}
|
|
208
210
|
|
|
209
|
-
function isScopeHeader(lowerTrimmed: string, scope: "global" | "project"): boolean {
|
|
210
|
-
if (scope === "global") {
|
|
211
|
-
return (
|
|
212
|
-
lowerTrimmed === "global" ||
|
|
213
|
-
lowerTrimmed === "user" ||
|
|
214
|
-
lowerTrimmed.startsWith("global packages") ||
|
|
215
|
-
lowerTrimmed.startsWith("global:") ||
|
|
216
|
-
lowerTrimmed.startsWith("user packages") ||
|
|
217
|
-
lowerTrimmed.startsWith("user:")
|
|
218
|
-
);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
return (
|
|
222
|
-
lowerTrimmed === "project" ||
|
|
223
|
-
lowerTrimmed === "local" ||
|
|
224
|
-
lowerTrimmed.startsWith("project packages") ||
|
|
225
|
-
lowerTrimmed.startsWith("project:") ||
|
|
226
|
-
lowerTrimmed.startsWith("local packages") ||
|
|
227
|
-
lowerTrimmed.startsWith("local:")
|
|
228
|
-
);
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function looksLikePackageSource(source: string): boolean {
|
|
232
|
-
return getPackageSourceKind(source) !== "unknown";
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function parseResolvedPathLine(line: string): string | undefined {
|
|
236
|
-
const resolvedMatch = line.match(/^resolved\s*:\s*(.+)$/i);
|
|
237
|
-
if (resolvedMatch?.[1]) {
|
|
238
|
-
return resolvedMatch[1].trim();
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
if (
|
|
242
|
-
line.startsWith("/") ||
|
|
243
|
-
line.startsWith("./") ||
|
|
244
|
-
line.startsWith("../") ||
|
|
245
|
-
line.startsWith(".\\") ||
|
|
246
|
-
line.startsWith("..\\") ||
|
|
247
|
-
line.startsWith("~/") ||
|
|
248
|
-
line.startsWith("file://") ||
|
|
249
|
-
/^[a-zA-Z]:[\\/]/.test(line) ||
|
|
250
|
-
line.startsWith("\\\\")
|
|
251
|
-
) {
|
|
252
|
-
return line;
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
return undefined;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
function parseInstalledPackagesOutputInternal(text: string): InstalledPackage[] {
|
|
259
|
-
const packages: InstalledPackage[] = [];
|
|
260
|
-
|
|
261
|
-
const lines = text.split("\n");
|
|
262
|
-
let currentScope: "global" | "project" = "global";
|
|
263
|
-
let currentPackage: InstalledPackage | undefined;
|
|
264
|
-
|
|
265
|
-
for (const rawLine of lines) {
|
|
266
|
-
if (!rawLine.trim()) continue;
|
|
267
|
-
|
|
268
|
-
const isIndented = /^(?:\t+|\s{4,})/.test(rawLine);
|
|
269
|
-
const trimmed = rawLine.trim();
|
|
270
|
-
|
|
271
|
-
if (isIndented && currentPackage) {
|
|
272
|
-
const resolved = parseResolvedPathLine(trimmed);
|
|
273
|
-
if (resolved) {
|
|
274
|
-
currentPackage.resolvedPath = resolved;
|
|
275
|
-
}
|
|
276
|
-
continue;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const lowerTrimmed = trimmed.toLowerCase();
|
|
280
|
-
if (isScopeHeader(lowerTrimmed, "global")) {
|
|
281
|
-
currentScope = "global";
|
|
282
|
-
currentPackage = undefined;
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
if (isScopeHeader(lowerTrimmed, "project")) {
|
|
286
|
-
currentScope = "project";
|
|
287
|
-
currentPackage = undefined;
|
|
288
|
-
continue;
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
const candidate = trimmed.replace(/^[-•]?\s*/, "").trim();
|
|
292
|
-
if (!looksLikePackageSource(candidate)) continue;
|
|
293
|
-
|
|
294
|
-
const source = sanitizeListSourceSuffix(candidate);
|
|
295
|
-
const { name, version } = parsePackageNameAndVersion(source);
|
|
296
|
-
|
|
297
|
-
const pkg: InstalledPackage = { source, name, scope: currentScope };
|
|
298
|
-
if (version !== undefined) {
|
|
299
|
-
pkg.version = version;
|
|
300
|
-
}
|
|
301
|
-
packages.push(pkg);
|
|
302
|
-
currentPackage = pkg;
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
return packages;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
function shouldReplaceInstalledPackage(
|
|
309
|
-
current: InstalledPackage | undefined,
|
|
310
|
-
candidate: InstalledPackage
|
|
311
|
-
): boolean {
|
|
312
|
-
if (!current) {
|
|
313
|
-
return true;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
if (current.scope !== candidate.scope) {
|
|
317
|
-
return candidate.scope === "project";
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
return false;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
export function parseInstalledPackagesOutput(text: string): InstalledPackage[] {
|
|
324
|
-
const parsed = parseInstalledPackagesOutputInternal(text);
|
|
325
|
-
const deduped = new Map<string, InstalledPackage>();
|
|
326
|
-
|
|
327
|
-
for (const pkg of parsed) {
|
|
328
|
-
const identity = getInstalledPackageIdentity(pkg);
|
|
329
|
-
const current = deduped.get(identity);
|
|
330
|
-
if (shouldReplaceInstalledPackage(current, pkg)) {
|
|
331
|
-
deduped.set(identity, pkg);
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
return Array.from(deduped.values());
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
/**
|
|
339
|
-
* Check whether a specific package source is installed.
|
|
340
|
-
* Matches on normalized package source and optional scope.
|
|
341
|
-
*/
|
|
342
211
|
export async function isSourceInstalled(
|
|
343
212
|
source: string,
|
|
344
213
|
ctx: ExtensionCommandContext | ExtensionContext,
|
|
345
|
-
pi: ExtensionAPI,
|
|
346
214
|
options?: { scope?: "global" | "project" }
|
|
347
215
|
): Promise<boolean> {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
if (res.code !== 0) return false;
|
|
351
|
-
|
|
352
|
-
const installed = parseInstalledPackagesOutputAllScopes(res.stdout || "");
|
|
353
|
-
const expected = normalizePackageIdentity(source);
|
|
354
|
-
|
|
355
|
-
return installed.some((pkg) => {
|
|
356
|
-
if (getInstalledPackageIdentity(pkg) !== expected) {
|
|
357
|
-
return false;
|
|
358
|
-
}
|
|
359
|
-
return options?.scope ? pkg.scope === options.scope : true;
|
|
360
|
-
});
|
|
361
|
-
} catch {
|
|
362
|
-
return false;
|
|
363
|
-
}
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
/**
|
|
367
|
-
* parseInstalledPackagesOutputAllScopes returns the raw parsed entries from
|
|
368
|
-
* parseInstalledPackagesOutputInternal without deduplication or scope merging.
|
|
369
|
-
* Prefer parseInstalledPackagesOutput for user-facing lists, since it applies
|
|
370
|
-
* deduplication and normalized scope selection.
|
|
371
|
-
*/
|
|
372
|
-
export function parseInstalledPackagesOutputAllScopes(text: string): InstalledPackage[] {
|
|
373
|
-
return parseInstalledPackagesOutputInternal(text);
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function extractGitPackageName(repoSpec: string): string {
|
|
377
|
-
// git@github.com:user/repo(.git)
|
|
378
|
-
if (repoSpec.startsWith("git@")) {
|
|
379
|
-
const afterColon = repoSpec.split(":").slice(1).join(":");
|
|
380
|
-
if (afterColon) {
|
|
381
|
-
const last = afterColon.split("/").pop() || afterColon;
|
|
382
|
-
return last.replace(/\.git$/i, "") || repoSpec;
|
|
383
|
-
}
|
|
384
|
-
}
|
|
216
|
+
const installed = await getPackageCatalog(ctx.cwd).listInstalledPackages({ dedupe: false });
|
|
217
|
+
const expected = normalizePackageIdentity(source);
|
|
385
218
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
const last = url.pathname.split("/").filter(Boolean).pop();
|
|
390
|
-
if (last) {
|
|
391
|
-
return last.replace(/\.git$/i, "") || repoSpec;
|
|
219
|
+
return installed.some((pkg) => {
|
|
220
|
+
if (getInstalledPackageIdentity(pkg) !== expected) {
|
|
221
|
+
return false;
|
|
392
222
|
}
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
const last = repoSpec.split(/[/:]/).filter(Boolean).pop();
|
|
398
|
-
return (last ? last.replace(/\.git$/i, "") : repoSpec) || repoSpec;
|
|
223
|
+
return options?.scope ? pkg.scope === options.scope : true;
|
|
224
|
+
});
|
|
399
225
|
}
|
|
400
226
|
|
|
401
|
-
function
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
const parsedNpm = parseNpmSource(fullSource);
|
|
406
|
-
if (parsedNpm) {
|
|
407
|
-
return parsedNpm;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
const sourceKind = getPackageSourceKind(fullSource);
|
|
411
|
-
if (sourceKind === "git") {
|
|
412
|
-
const gitSpec = stripGitSourcePrefix(fullSource);
|
|
413
|
-
const { repo } = splitGitRepoAndRef(gitSpec);
|
|
414
|
-
return { name: extractGitPackageName(repo) };
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
if (fullSource.includes("node_modules/")) {
|
|
418
|
-
const nmMatch = fullSource.match(/node_modules\/(.+)$/);
|
|
419
|
-
if (nmMatch?.[1]) {
|
|
420
|
-
return { name: nmMatch[1] };
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
const pathParts = fullSource.split(/[\\/]/);
|
|
425
|
-
const fileName = pathParts[pathParts.length - 1];
|
|
426
|
-
return { name: fileName || fullSource };
|
|
227
|
+
export async function getInstalledPackagesAllScopes(
|
|
228
|
+
ctx: ExtensionCommandContext | ExtensionContext
|
|
229
|
+
): Promise<InstalledPackage[]> {
|
|
230
|
+
return getPackageCatalog(ctx.cwd).listInstalledPackages({ dedupe: false });
|
|
427
231
|
}
|
|
428
232
|
|
|
429
233
|
async function hydratePackageFromResolvedPath(pkg: InstalledPackage): Promise<void> {
|
|
@@ -471,7 +275,8 @@ async function hydratePackageFromResolvedPath(pkg: InstalledPackage): Promise<vo
|
|
|
471
275
|
async function fetchPackageSize(
|
|
472
276
|
pkgName: string,
|
|
473
277
|
ctx: ExtensionCommandContext | ExtensionContext,
|
|
474
|
-
pi: ExtensionAPI
|
|
278
|
+
pi: ExtensionAPI,
|
|
279
|
+
signal?: AbortSignal
|
|
475
280
|
): Promise<number | undefined> {
|
|
476
281
|
// Check cache first
|
|
477
282
|
const cachedSize = await getCachedPackageSize(pkgName);
|
|
@@ -481,6 +286,7 @@ async function fetchPackageSize(
|
|
|
481
286
|
// Try to get unpacked size from npm view
|
|
482
287
|
const res = await execNpm(pi, ["view", pkgName, "dist.unpackedSize", "--json"], ctx, {
|
|
483
288
|
timeout: TIMEOUTS.npmView,
|
|
289
|
+
...(signal ? { signal } : {}),
|
|
484
290
|
});
|
|
485
291
|
if (res.code === 0) {
|
|
486
292
|
try {
|
|
@@ -503,25 +309,29 @@ async function addPackageMetadata(
|
|
|
503
309
|
packages: InstalledPackage[],
|
|
504
310
|
ctx: ExtensionCommandContext | ExtensionContext,
|
|
505
311
|
pi: ExtensionAPI,
|
|
506
|
-
onProgress?: (current: number, total: number) => void
|
|
312
|
+
onProgress?: (current: number, total: number) => void,
|
|
313
|
+
signal?: AbortSignal
|
|
507
314
|
): Promise<void> {
|
|
508
|
-
|
|
315
|
+
throwIfAborted(signal);
|
|
316
|
+
|
|
509
317
|
const cachedDescriptions = await getPackageDescriptions(packages);
|
|
510
318
|
for (const [source, description] of cachedDescriptions) {
|
|
511
319
|
const pkg = packages.find((p) => p.source === source);
|
|
512
320
|
if (pkg) pkg.description = description;
|
|
513
321
|
}
|
|
514
322
|
|
|
515
|
-
// Process remaining packages in batches
|
|
516
323
|
const batchSize = 5;
|
|
517
324
|
for (let i = 0; i < packages.length; i += batchSize) {
|
|
325
|
+
throwIfAborted(signal);
|
|
326
|
+
|
|
518
327
|
const batch = packages.slice(i, i + batchSize);
|
|
519
328
|
|
|
520
|
-
// Report progress
|
|
521
329
|
onProgress?.(i, packages.length);
|
|
522
330
|
|
|
523
331
|
await Promise.all(
|
|
524
332
|
batch.map(async (pkg) => {
|
|
333
|
+
throwIfAborted(signal);
|
|
334
|
+
|
|
525
335
|
await hydratePackageFromResolvedPath(pkg);
|
|
526
336
|
|
|
527
337
|
const needsDescription = !pkg.description;
|
|
@@ -546,6 +356,7 @@ async function addPackageMetadata(
|
|
|
546
356
|
} else {
|
|
547
357
|
const res = await execNpm(pi, ["view", pkgName, "description", "--json"], ctx, {
|
|
548
358
|
timeout: TIMEOUTS.npmView,
|
|
359
|
+
...(signal ? { signal } : {}),
|
|
549
360
|
});
|
|
550
361
|
if (res.code === 0) {
|
|
551
362
|
try {
|
|
@@ -565,7 +376,7 @@ async function addPackageMetadata(
|
|
|
565
376
|
}
|
|
566
377
|
|
|
567
378
|
if (needsSize) {
|
|
568
|
-
pkg.size = await fetchPackageSize(pkgName, ctx, pi);
|
|
379
|
+
pkg.size = await fetchPackageSize(pkgName, ctx, pi, signal);
|
|
569
380
|
}
|
|
570
381
|
}
|
|
571
382
|
} else if (pkg.source.startsWith("git:")) {
|
|
@@ -578,8 +389,9 @@ async function addPackageMetadata(
|
|
|
578
389
|
}
|
|
579
390
|
})
|
|
580
391
|
);
|
|
392
|
+
|
|
393
|
+
throwIfAborted(signal);
|
|
581
394
|
}
|
|
582
395
|
|
|
583
|
-
// Final progress update
|
|
584
396
|
onProgress?.(packages.length, packages.length);
|
|
585
397
|
}
|