Skip to content
7 changes: 6 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,17 @@ export default function extensionsManager(pi: ExtensionAPI) {
// Restore persisted auto-update config into session entries so sync lookups are valid.
await hydrateAutoUpdateConfig(pi, ctx);

if (!ctx.hasUI) return;
if (!ctx.hasUI) {
stopAutoUpdateTimer();
return;
}

const config = getAutoUpdateConfig(ctx);
if (config.enabled && config.intervalMs > 0) {
const getCtx: ContextProvider = () => ctx;
startAutoUpdateTimer(pi, getCtx, createAutoUpdateNotificationHandler(ctx));
} else {
stopAutoUpdateTimer();
}

setImmediate(() => {
Expand Down
25 changes: 17 additions & 8 deletions src/packages/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ import type { InstalledPackage, NpmPackage, SearchCache } from "../types/index.j
import { CACHE_TTL, TIMEOUTS } from "../constants.js";
import { readSummary } from "../utils/fs.js";
import { parseNpmSource } from "../utils/format.js";
import { getPackageSourceKind, splitGitRepoAndRef } from "../utils/package-source.js";
import {
getPackageSourceKind,
normalizeLocalSourceIdentity,
splitGitRepoAndRef,
} from "../utils/package-source.js";
import { execNpm } from "../utils/npm-exec.js";

let searchCache: SearchCache | null = null;

Expand Down Expand Up @@ -67,9 +72,8 @@ export async function searchNpmPackages(
ctx.ui.notify(`Searching npm for "${query}"...`, "info");
}

const res = await pi.exec("npm", ["search", "--json", `--searchlimit=${searchLimit}`, query], {
const res = await execNpm(pi, ["search", "--json", `--searchlimit=${searchLimit}`, query], ctx, {
timeout: TIMEOUTS.npmSearch,
cwd: ctx.cwd,
});

if (res.code !== 0) {
Expand Down Expand Up @@ -118,7 +122,14 @@ function sanitizeListSourceSuffix(source: string): string {
}

function normalizeSourceIdentity(source: string): string {
return sanitizeListSourceSuffix(source).replace(/\\/g, "/").toLowerCase();
const sanitized = sanitizeListSourceSuffix(source);
const kind = getPackageSourceKind(sanitized);

if (kind === "local") {
return normalizeLocalSourceIdentity(sanitized);
}

return sanitized.replace(/\\/g, "/").toLowerCase();
}

function isScopeHeader(lowerTrimmed: string, scope: "global" | "project"): boolean {
Expand Down Expand Up @@ -375,9 +386,8 @@ async function fetchPackageSize(

try {
// Try to get unpacked size from npm view
const res = await pi.exec("npm", ["view", pkgName, "dist.unpackedSize", "--json"], {
const res = await execNpm(pi, ["view", pkgName, "dist.unpackedSize", "--json"], ctx, {
timeout: TIMEOUTS.npmView,
cwd: ctx.cwd,
});
if (res.code === 0) {
try {
Expand Down Expand Up @@ -441,9 +451,8 @@ async function addPackageMetadata(
if (cached?.description) {
pkg.description = cached.description;
} else {
const res = await pi.exec("npm", ["view", pkgName, "description", "--json"], {
const res = await execNpm(pi, ["view", pkgName, "description", "--json"], ctx, {
timeout: TIMEOUTS.npmView,
cwd: ctx.cwd,
});
if (res.code === 0) {
try {
Expand Down
4 changes: 2 additions & 2 deletions src/packages/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { notify, error as notifyError, success } from "../utils/notify.js";
import { confirmAction, confirmReload, showProgress } from "../utils/ui-helpers.js";
import { tryOperation } from "../utils/mode.js";
import { updateExtmgrStatus } from "../utils/status.js";
import { execNpm } from "../utils/npm-exec.js";
import { TIMEOUTS } from "../constants.js";

export type InstallScope = "global" | "project";
Expand Down Expand Up @@ -291,9 +292,8 @@ export async function installPackageLocally(
await mkdir(extensionDir, { recursive: true });
showProgress(ctx, "Fetching", packageName);

const viewRes = await pi.exec("npm", ["view", packageName, "--json"], {
const viewRes = await execNpm(pi, ["view", packageName, "--json"], ctx, {
timeout: TIMEOUTS.fetchPackageInfo,
cwd: ctx.cwd,
});

if (viewRes.code !== 0) {
Expand Down
72 changes: 59 additions & 13 deletions src/packages/management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,11 @@ import {
} from "./discovery.js";
import { waitForCondition } from "../utils/retry.js";
import { formatInstalledPackageLabel, parseNpmSource } from "../utils/format.js";
import { getPackageSourceKind, splitGitRepoAndRef } from "../utils/package-source.js";
import {
getPackageSourceKind,
normalizeLocalSourceIdentity,
splitGitRepoAndRef,
} from "../utils/package-source.js";
import { logPackageUpdate, logPackageRemove } from "../utils/history.js";
import { clearUpdatesAvailable } from "../utils/settings.js";
import { notify, error as notifyError, success } from "../utils/notify.js";
Expand All @@ -33,12 +37,19 @@ const NO_PACKAGE_MUTATION_OUTCOME: PackageMutationOutcome = {
reloaded: false,
};

const BULK_UPDATE_LABEL = "all packages";

function packageMutationOutcome(
overrides: Partial<PackageMutationOutcome>
): PackageMutationOutcome {
return { ...NO_PACKAGE_MUTATION_OUTCOME, ...overrides };
}

function isUpToDateOutput(stdout: string): boolean {
const pinnedAsStatus = /^\s*pinned\b(?!\s+dependency\b)(?:\s*$|\s*[:(-])/im.test(stdout);
return /already\s+up\s+to\s+date/i.test(stdout) || pinnedAsStatus;
}

async function updatePackageInternal(
source: string,
ctx: ExtensionCommandContext,
Expand All @@ -60,9 +71,10 @@ async function updatePackageInternal(
}

const stdout = res.stdout || "";
if (stdout.includes("already up to date") || stdout.includes("pinned")) {
if (isUpToDateOutput(stdout)) {
notify(ctx, `${source} is already up to date (or pinned).`, "info");
logPackageUpdate(pi, source, source, undefined, true);
clearUpdatesAvailable(pi, ctx);
void updateExtmgrStatus(ctx, pi);
return NO_PACKAGE_MUTATION_OUTCOME;
}
Expand All @@ -87,18 +99,23 @@ async function updatePackagesInternal(
const res = await pi.exec("pi", ["update"], { timeout: TIMEOUTS.packageUpdateAll, cwd: ctx.cwd });

if (res.code !== 0) {
notifyError(ctx, `Update failed: ${res.stderr || res.stdout || `exit ${res.code}`}`);
const errorMsg = `Update failed: ${res.stderr || res.stdout || `exit ${res.code}`}`;
logPackageUpdate(pi, BULK_UPDATE_LABEL, BULK_UPDATE_LABEL, undefined, false, errorMsg);
notifyError(ctx, errorMsg);
void updateExtmgrStatus(ctx, pi);
return NO_PACKAGE_MUTATION_OUTCOME;
}

const stdout = res.stdout || "";
if (stdout.includes("already up to date") || stdout.trim() === "") {
if (isUpToDateOutput(stdout) || stdout.trim() === "") {
notify(ctx, "All packages are already up to date.", "info");
logPackageUpdate(pi, BULK_UPDATE_LABEL, BULK_UPDATE_LABEL, undefined, true);
clearUpdatesAvailable(pi, ctx);
void updateExtmgrStatus(ctx, pi);
return NO_PACKAGE_MUTATION_OUTCOME;
}

logPackageUpdate(pi, BULK_UPDATE_LABEL, BULK_UPDATE_LABEL, undefined, true);
success(ctx, "Packages updated");
clearUpdatesAvailable(pi, ctx);

Expand Down Expand Up @@ -145,12 +162,18 @@ function packageIdentity(source: string, fallbackName?: string): string {
return `npm:${npm.name}`;
}

if (getPackageSourceKind(source) === "git") {
const sourceKind = getPackageSourceKind(source);

if (sourceKind === "git") {
const gitSpec = source.startsWith("git:") ? source.slice(4) : source;
const { repo } = splitGitRepoAndRef(gitSpec);
return `git:${repo}`;
}

if (sourceKind === "local") {
return `src:${normalizeLocalSourceIdentity(source)}`;
}

if (fallbackName) {
return `name:${fallbackName}`;
}
Expand Down Expand Up @@ -238,12 +261,18 @@ function formatRemovalTargets(targets: RemovalTarget[]): string {
return targets.map((t) => `${t.scope}: ${t.source}`).join("\n");
}

interface RemovalExecutionResult {
target: RemovalTarget;
success: boolean;
error?: string;
}

async function executeRemovalTargets(
targets: RemovalTarget[],
ctx: ExtensionCommandContext,
pi: ExtensionAPI
): Promise<string[]> {
const failures: string[] = [];
): Promise<RemovalExecutionResult[]> {
const results: RemovalExecutionResult[] = [];

for (const target of targets) {
showProgress(ctx, "Removing", `${target.source} (${target.scope})`);
Expand All @@ -254,14 +283,15 @@ async function executeRemovalTargets(
if (res.code !== 0) {
const errorMsg = `Remove failed (${target.scope}): ${res.stderr || res.stdout || `exit ${res.code}`}`;
logPackageRemove(pi, target.source, target.name, false, errorMsg);
failures.push(errorMsg);
results.push({ target, success: false, error: errorMsg });
continue;
}

logPackageRemove(pi, target.source, target.name, true);
results.push({ target, success: true });
}

return failures;
return results;
}

function notifyRemovalSummary(
Expand Down Expand Up @@ -326,9 +356,18 @@ async function removePackageInternal(
return NO_PACKAGE_MUTATION_OUTCOME;
}

const failures = await executeRemovalTargets(targets, ctx, pi);
const results = await executeRemovalTargets(targets, ctx, pi);
clearSearchCache();

const failures = results
.filter((result): result is RemovalExecutionResult & { success: false; error: string } =>
Boolean(!result.success && result.error)
)
.map((result) => result.error);
const successfulTargets = results
.filter((result) => result.success)
.map((result) => result.target);

const remaining = (await getInstalledPackagesAllScopes(ctx, pi)).filter(
(p) => packageIdentity(p.source, p.name) === identity
);
Expand All @@ -338,13 +377,15 @@ async function removePackageInternal(
clearUpdatesAvailable(pi, ctx);
}

// Wait for selected targets to disappear from their target scopes before reloading.
if (failures.length === 0 && targets.length > 0) {
const successfulRemovalCount = successfulTargets.length;

// Wait for successfully removed targets to disappear from their target scopes before reloading.
if (successfulTargets.length > 0) {
notify(ctx, "Waiting for removal to complete...", "info");
const isRemoved = await waitForCondition(
async () => {
const installedChecks = await Promise.all(
targets.map((target) =>
successfulTargets.map((target) =>
isSourceInstalled(target.source, ctx, pi, {
scope: target.scope,
})
Expand All @@ -360,6 +401,11 @@ async function removePackageInternal(
}
}

if (successfulRemovalCount === 0) {
void updateExtmgrStatus(ctx, pi);
return NO_PACKAGE_MUTATION_OUTCOME;
}

const reloaded = await confirmReload(ctx, "Removal complete.");
if (!reloaded) {
void updateExtmgrStatus(ctx, pi);
Expand Down
4 changes: 2 additions & 2 deletions src/ui/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
isCacheValid,
} from "../packages/discovery.js";
import { installPackage, installPackageLocally } from "../packages/install.js";
import { execNpm } from "../utils/npm-exec.js";
import { notify } from "../utils/notify.js";

interface PackageInfoCacheEntry {
Expand Down Expand Up @@ -143,9 +144,8 @@ async function buildPackageInfoText(
}

const [infoRes, weeklyDownloads] = await Promise.all([
pi.exec("npm", ["view", packageName, "--json"], {
execNpm(pi, ["view", packageName, "--json"], ctx, {
timeout: TIMEOUTS.npmView,
cwd: ctx.cwd,
}),
fetchWeeklyDownloads(packageName),
]);
Expand Down
6 changes: 5 additions & 1 deletion src/ui/unified.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,7 +263,11 @@ async function showInteractiveOnce(
}

function normalizePathForDuplicateCheck(value: string): string {
return value.replace(/\\/g, "/").toLowerCase();
const normalized = value.replace(/\\/g, "/");
const looksWindowsPath =
/^[a-zA-Z]:\//.test(normalized) || normalized.startsWith("//") || value.includes("\\");

return looksWindowsPath ? normalized.toLowerCase() : normalized;
}

export function buildUnifiedItems(
Expand Down
4 changes: 2 additions & 2 deletions src/utils/auto-update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
type AutoUpdateConfig,
} from "./settings.js";
import { parseNpmSource } from "./format.js";
import { execNpm } from "./npm-exec.js";
import { TIMEOUTS } from "../constants.js";

import { startTimer, stopTimer, isTimerRunning } from "./timer.js";
Expand Down Expand Up @@ -134,9 +135,8 @@ async function checkPackageUpdate(
if (!pkgName) return false;

try {
const res = await pi.exec("npm", ["view", pkgName, "version", "--json"], {
const res = await execNpm(pi, ["view", pkgName, "version", "--json"], ctx, {
timeout: TIMEOUTS.npmView,
cwd: ctx.cwd,
});

if (res.code !== 0) return false;
Expand Down
Loading