Merge pull request #2719 from QwenLM/feat/npm-extension-installation

feat(extension): Add npm registry support for extension installation
This commit is contained in:
tanzhenxin 2026-04-01 16:18:17 +08:00 committed by GitHub
commit 2eb2f4e319
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 924 additions and 12 deletions

View file

@ -228,9 +228,10 @@ export type ExtensionOriginSource = 'QwenCode' | 'Claude' | 'Gemini';
export interface ExtensionInstallMetadata {
source: string;
type: 'git' | 'local' | 'link' | 'github-release';
type: 'git' | 'local' | 'link' | 'github-release' | 'npm';
originSource?: ExtensionOriginSource;
releaseTag?: string; // Only present for github-release installs.
releaseTag?: string; // Only present for github-release and npm installs.
registryUrl?: string; // Only present for npm installs.
ref?: string;
autoUpdate?: boolean;
allowPreRelease?: boolean;

View file

@ -39,6 +39,7 @@ import {
downloadFromGitHubRelease,
parseGitHubRepoForReleases,
} from './github.js';
import { downloadFromNpmRegistry } from './npm.js';
import type { LoadExtensionContext } from './variableSchema.js';
import { Override, type AllExtensionsEnablementConfig } from './override.js';
import {
@ -873,6 +874,11 @@ export class ExtensionManager {
}
}
localSourcePath = tempDir;
} else if (installMetadata.type === 'npm') {
tempDir = await ExtensionStorage.createTmpDir();
const result = await downloadFromNpmRegistry(installMetadata, tempDir);
installMetadata.releaseTag = result.version;
localSourcePath = tempDir;
} else if (
installMetadata.type === 'local' ||
installMetadata.type === 'link'

View file

@ -21,6 +21,7 @@ import {
type ExtensionManager,
} from './extensionManager.js';
import type { ExtensionInstallMetadata } from '../config/config.js';
import { checkNpmUpdate } from './npm.js';
const debugLogger = createDebugLogger('EXT_GITHUB');
@ -173,6 +174,9 @@ export async function checkForExtensionUpdate(
}
return ExtensionUpdateState.UP_TO_DATE;
}
if (installMetadata?.type === 'npm') {
return checkNpmUpdate(installMetadata);
}
if (
!installMetadata ||
installMetadata.originSource === 'Claude' ||

View file

@ -3,4 +3,5 @@ export * from './variables.js';
export * from './github.js';
export * from './extensionSettings.js';
export * from './marketplace.js';
export * from './npm.js';
export * from './claude-converter.js';

View file

@ -232,6 +232,50 @@ describe('parseInstallSource', () => {
});
});
describe('scoped npm package parsing', () => {
it('should parse scoped npm package without version', async () => {
vi.mocked(fs.stat).mockRejectedValueOnce(new Error('ENOENT'));
const result = await parseInstallSource('@ali/openclaw-tmcp-dingtalk');
expect(result.source).toBe('@ali/openclaw-tmcp-dingtalk');
expect(result.type).toBe('npm');
expect(result.pluginName).toBeUndefined();
});
it('should parse scoped npm package with version', async () => {
vi.mocked(fs.stat).mockRejectedValueOnce(new Error('ENOENT'));
const result = await parseInstallSource(
'@ali/openclaw-tmcp-dingtalk@1.2.0',
);
expect(result.source).toBe('@ali/openclaw-tmcp-dingtalk@1.2.0');
expect(result.type).toBe('npm');
});
it('should parse scoped npm package with latest tag', async () => {
vi.mocked(fs.stat).mockRejectedValueOnce(new Error('ENOENT'));
const result = await parseInstallSource('@scope/my-extension@latest');
expect(result.source).toBe('@scope/my-extension@latest');
expect(result.type).toBe('npm');
});
it('should parse scoped npm package with plugin name', async () => {
vi.mocked(fs.stat).mockRejectedValueOnce(new Error('ENOENT'));
const result = await parseInstallSource(
'@ali/openclaw-tmcp-dingtalk:my-plugin',
);
expect(result.source).toBe('@ali/openclaw-tmcp-dingtalk');
expect(result.type).toBe('npm');
expect(result.pluginName).toBe('my-plugin');
});
});
describe('marketplace config detection', () => {
it('should detect marketplace type when config exists', async () => {
// Mock stat to fail (not a local path)

View file

@ -12,6 +12,7 @@ import * as path from 'node:path';
import * as https from 'node:https';
import { stat } from 'node:fs/promises';
import { parseGitHubRepoForReleases } from './github.js';
import { isScopedNpmPackage } from './npm.js';
export interface MarketplaceInstallOptions {
marketplaceUrl: string;
@ -248,6 +249,13 @@ export async function parseInstallSource(
} catch {
// Not a valid GitHub URL or failed to fetch, continue without marketplace config
}
} else if (isScopedNpmPackage(repo)) {
// Priority 3: Scoped npm package (@scope/name, optionally @version)
installMetadata = {
source: repo,
type: 'npm',
pluginName,
};
} else if (isOwnerRepoFormat(repo)) {
// Priority 3: owner/repo format - convert to GitHub URL
repoSource = convertOwnerRepoToGitHubUrl(repo);

View file

@ -0,0 +1,282 @@
/**
* Tests for npm registry extension support.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
parseNpmPackageSource,
isScopedNpmPackage,
resolveNpmRegistry,
checkNpmUpdate,
} from './npm.js';
import type { ExtensionInstallMetadata } from '../config/config.js';
import { ExtensionUpdateState } from './extensionManager.js';
import * as fs from 'node:fs';
vi.mock('node:fs', () => ({
readFileSync: vi.fn(),
existsSync: vi.fn(),
createWriteStream: vi.fn(),
promises: {
readdir: vi.fn(),
rename: vi.fn(),
rmdir: vi.fn(),
unlink: vi.fn(),
mkdir: vi.fn(),
},
}));
describe('parseNpmPackageSource', () => {
it('should parse scoped package without version', () => {
const result = parseNpmPackageSource('@ali/openclaw-tmcp-dingtalk');
expect(result.name).toBe('@ali/openclaw-tmcp-dingtalk');
expect(result.version).toBeUndefined();
});
it('should parse scoped package with version', () => {
const result = parseNpmPackageSource('@ali/openclaw-tmcp-dingtalk@1.2.0');
expect(result.name).toBe('@ali/openclaw-tmcp-dingtalk');
expect(result.version).toBe('1.2.0');
});
it('should parse scoped package with latest tag', () => {
const result = parseNpmPackageSource('@scope/pkg@latest');
expect(result.name).toBe('@scope/pkg');
expect(result.version).toBe('latest');
});
it('should parse scoped package with semver range', () => {
const result = parseNpmPackageSource('@scope/pkg@^1.0.0');
expect(result.name).toBe('@scope/pkg');
expect(result.version).toBe('^1.0.0');
});
it('should throw for invalid source', () => {
expect(() => parseNpmPackageSource('not-scoped')).toThrow(
'Invalid scoped npm package source',
);
});
it('should throw for unscoped package', () => {
expect(() => parseNpmPackageSource('some-package')).toThrow(
'Invalid scoped npm package source',
);
});
});
describe('isScopedNpmPackage', () => {
it('should return true for scoped package', () => {
expect(isScopedNpmPackage('@ali/openclaw-tmcp-dingtalk')).toBe(true);
});
it('should return true for scoped package with version', () => {
expect(isScopedNpmPackage('@ali/openclaw-tmcp-dingtalk@1.2.0')).toBe(true);
});
it('should return true for scoped package with dots', () => {
expect(isScopedNpmPackage('@my.org/my.pkg')).toBe(true);
});
it('should return false for owner/repo format', () => {
expect(isScopedNpmPackage('owner/repo')).toBe(false);
});
it('should return false for unscoped package', () => {
expect(isScopedNpmPackage('some-package')).toBe(false);
});
it('should return false for git URL', () => {
expect(isScopedNpmPackage('https://github.com/owner/repo')).toBe(false);
});
it('should return false for local path', () => {
expect(isScopedNpmPackage('/path/to/extension')).toBe(false);
});
});
describe('resolveNpmRegistry', () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should return CLI override when provided', () => {
const result = resolveNpmRegistry(
'@ali',
'https://registry.npmmirror.com/',
);
expect(result).toBe('https://registry.npmmirror.com');
});
it('should return scoped registry from .npmrc', () => {
vi.mocked(fs.readFileSync).mockReturnValueOnce(
'@ali:registry=https://registry.npmmirror.com/\nregistry=https://custom.registry.com/',
);
const result = resolveNpmRegistry('@ali');
expect(result).toBe('https://registry.npmmirror.com');
});
it('should return default registry from .npmrc when no scoped match', () => {
vi.mocked(fs.readFileSync).mockReturnValueOnce(
'registry=https://custom.registry.com/',
);
const result = resolveNpmRegistry('@other');
expect(result).toBe('https://custom.registry.com');
});
it('should return npmjs.org as fallback', () => {
vi.mocked(fs.readFileSync).mockImplementation(() => {
throw new Error('ENOENT');
});
const result = resolveNpmRegistry('@ali');
expect(result).toBe('https://registry.npmjs.org');
});
});
// Mock https/http for checkNpmUpdate tests
vi.mock('node:https', () => ({
get: vi.fn(),
}));
vi.mock('node:http', () => ({
get: vi.fn(),
}));
// We need to import https after mocking
const https = await import('node:https');
function mockNpmRegistryResponse(data: object) {
vi.mocked(https.get).mockImplementation(
(_url: unknown, _options: unknown, callback: unknown) => {
const mockRes = {
statusCode: 200,
on: vi.fn((event: string, handler: (data?: Buffer) => void) => {
if (event === 'data') {
handler(Buffer.from(JSON.stringify(data)));
}
if (event === 'end') {
handler();
}
}),
};
if (typeof callback === 'function') {
callback(mockRes as never);
}
return { on: vi.fn() } as never;
},
);
}
describe('checkNpmUpdate', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(fs.readFileSync).mockImplementation(() => {
throw new Error('ENOENT');
});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should report UPDATE_AVAILABLE when latest is newer', async () => {
mockNpmRegistryResponse({
'dist-tags': { latest: '2.0.0' },
versions: { '2.0.0': { dist: { tarball: '' } } },
});
const metadata: ExtensionInstallMetadata = {
source: '@scope/pkg',
type: 'npm',
releaseTag: '1.0.0',
registryUrl: 'https://registry.npmjs.org',
};
const result = await checkNpmUpdate(metadata);
expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE);
});
it('should report UP_TO_DATE when latest matches', async () => {
mockNpmRegistryResponse({
'dist-tags': { latest: '1.0.0' },
versions: { '1.0.0': { dist: { tarball: '' } } },
});
const metadata: ExtensionInstallMetadata = {
source: '@scope/pkg',
type: 'npm',
releaseTag: '1.0.0',
registryUrl: 'https://registry.npmjs.org',
};
const result = await checkNpmUpdate(metadata);
expect(result).toBe(ExtensionUpdateState.UP_TO_DATE);
});
it('should report UP_TO_DATE for pinned exact version', async () => {
mockNpmRegistryResponse({
'dist-tags': { latest: '2.0.0' },
versions: {
'1.0.0': { dist: { tarball: '' } },
'2.0.0': { dist: { tarball: '' } },
},
});
const metadata: ExtensionInstallMetadata = {
source: '@scope/pkg@1.0.0',
type: 'npm',
releaseTag: '1.0.0',
registryUrl: 'https://registry.npmjs.org',
};
const result = await checkNpmUpdate(metadata);
expect(result).toBe(ExtensionUpdateState.UP_TO_DATE);
});
it('should check correct dist-tag for non-latest tag installs', async () => {
mockNpmRegistryResponse({
'dist-tags': { latest: '1.0.0', beta: '2.0.0-beta.2' },
versions: {
'1.0.0': { dist: { tarball: '' } },
'2.0.0-beta.1': { dist: { tarball: '' } },
'2.0.0-beta.2': { dist: { tarball: '' } },
},
});
const metadata: ExtensionInstallMetadata = {
source: '@scope/pkg@beta',
type: 'npm',
releaseTag: '2.0.0-beta.1',
registryUrl: 'https://registry.npmjs.org',
};
const result = await checkNpmUpdate(metadata);
expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE);
});
it('should report UP_TO_DATE for beta tag when on latest beta', async () => {
mockNpmRegistryResponse({
'dist-tags': { latest: '1.0.0', beta: '2.0.0-beta.2' },
versions: {
'1.0.0': { dist: { tarball: '' } },
'2.0.0-beta.2': { dist: { tarball: '' } },
},
});
const metadata: ExtensionInstallMetadata = {
source: '@scope/pkg@beta',
type: 'npm',
releaseTag: '2.0.0-beta.2',
registryUrl: 'https://registry.npmjs.org',
};
const result = await checkNpmUpdate(metadata);
expect(result).toBe(ExtensionUpdateState.UP_TO_DATE);
});
});

View file

@ -0,0 +1,432 @@
/**
* npm registry support for extension installation and updates.
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import * as https from 'node:https';
import * as http from 'node:http';
import * as tar from 'tar';
import type { ExtensionInstallMetadata } from '../config/config.js';
import { ExtensionUpdateState } from './extensionManager.js';
import { createDebugLogger } from '../utils/debugLogger.js';
const debugLogger = createDebugLogger('EXT_NPM');
export interface NpmDownloadResult {
version: string;
type: 'npm';
}
interface NpmPackageMetadata {
'dist-tags': Record<string, string>;
versions: Record<
string,
{
dist: {
tarball: string;
shasum?: string;
};
}
>;
}
/**
* Parse a scoped npm package source string into name and optional version.
* Examples:
* "@ali/openclaw-tmcp-dingtalk" { name: "@ali/openclaw-tmcp-dingtalk" }
* "@ali/openclaw-tmcp-dingtalk@1.2.0" { name: "@ali/openclaw-tmcp-dingtalk", version: "1.2.0" }
* "@ali/openclaw-tmcp-dingtalk@latest" { name: "@ali/openclaw-tmcp-dingtalk", version: "latest" }
*/
export function parseNpmPackageSource(source: string): {
name: string;
version?: string;
} {
// Scoped package: @scope/name[@version]
// First @ is the scope prefix, last @ (after scope/) is the version delimiter
const match = source.match(/^(@[^/]+\/[^@]+)(?:@(.+))?$/);
if (!match) {
throw new Error(`Invalid scoped npm package source: ${source}`);
}
return {
name: match[1],
version: match[2],
};
}
/**
* Check if a string looks like a scoped npm package.
*/
export function isScopedNpmPackage(source: string): boolean {
return /^@[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(@.+)?$/.test(source);
}
/**
* Resolve the npm registry URL for a scoped package.
*
* Priority:
* 1. Explicit CLI override (registryOverride parameter)
* 2. Scoped registry from .npmrc (e.g. @ali:registry=https://...)
* 3. Default registry from .npmrc
* 4. Fallback: https://registry.npmjs.org/
*/
export function resolveNpmRegistry(
scope: string,
registryOverride?: string,
): string {
if (registryOverride) {
return registryOverride.replace(/\/$/, '');
}
const npmrcPaths = [
path.join(process.cwd(), '.npmrc'),
path.join(os.homedir(), '.npmrc'),
];
let scopedRegistry: string | undefined;
let defaultRegistry: string | undefined;
for (const npmrcPath of npmrcPaths) {
try {
const content = fs.readFileSync(npmrcPath, 'utf-8');
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
// Scoped registry: @scope:registry=https://...
const scopeMatch = trimmed.match(
new RegExp(
`^${scope.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}:registry\\s*=\\s*(.+)`,
),
);
if (scopeMatch && !scopedRegistry) {
scopedRegistry = scopeMatch[1].trim().replace(/\/$/, '');
}
// Default registry: registry=https://...
const defaultMatch = trimmed.match(/^registry\s*=\s*(.+)/);
if (defaultMatch && !defaultRegistry) {
defaultRegistry = defaultMatch[1].trim().replace(/\/$/, '');
}
}
} catch {
// .npmrc doesn't exist at this path, continue
}
}
return scopedRegistry || defaultRegistry || 'https://registry.npmjs.org';
}
/**
* Get npm auth token for a registry.
*
* Priority:
* 1. NPM_TOKEN environment variable
* 2. Registry-specific _authToken from .npmrc
*/
function getNpmAuthToken(registryUrl: string): string | undefined {
const envToken = process.env['NPM_TOKEN'];
if (envToken) {
return envToken;
}
const npmrcPaths = [
path.join(process.cwd(), '.npmrc'),
path.join(os.homedir(), '.npmrc'),
];
// Build candidate prefixes from the registry URL to match against .npmrc
// entries. For "https://host/path/to/registry/", we try:
// //host/path/to/registry/
// //host/path/to/
// //host/path/
// //host/
// This handles both host-only entries (//registry.npmjs.org/:_authToken=...)
// and path-scoped entries (//pkgs.dev.azure.com/org/_packaging/feed/npm/registry/:_authToken=...)
const parsed = new URL(registryUrl);
const registryPrefixes: string[] = [];
const pathSegments = parsed.pathname
.replace(/\/$/, '')
.split('/')
.filter(Boolean);
for (let i = pathSegments.length; i >= 0; i--) {
const prefix = pathSegments.slice(0, i).join('/');
registryPrefixes.push(`${parsed.host}${prefix ? `/${prefix}` : ''}`);
}
for (const npmrcPath of npmrcPaths) {
try {
const content = fs.readFileSync(npmrcPath, 'utf-8');
const lines = content.split('\n');
for (const line of lines) {
const trimmed = line.trim();
// Format: //host[/path]/:_authToken=TOKEN
const match = trimmed.match(/^\/\/(.+?)\/:_authToken\s*=\s*(.+)/);
if (match) {
const entryPrefix = match[1].replace(/\/$/, '');
if (registryPrefixes.includes(entryPrefix)) {
return match[2].trim();
}
}
}
} catch {
// .npmrc doesn't exist at this path, continue
}
}
return undefined;
}
/**
* Fetch JSON from a URL, handling both https and http.
*/
function fetchNpmJson<T>(url: string, authToken?: string): Promise<T> {
const headers: Record<string, string> = {
Accept: 'application/json',
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const client = url.startsWith('https://') ? https : http;
return new Promise((resolve, reject) => {
client
.get(url, { headers }, (res) => {
if (res.statusCode === 301 || res.statusCode === 302) {
if (res.headers.location) {
// Strip auth token when redirected to a different host
const originalHost = new URL(url).host;
const redirectHost = new URL(res.headers.location).host;
const redirectToken =
redirectHost === originalHost ? authToken : undefined;
fetchNpmJson<T>(res.headers.location, redirectToken)
.then(resolve)
.catch(reject);
return;
}
}
if (res.statusCode !== 200) {
return reject(
new Error(
`npm registry request failed with status ${res.statusCode}: ${url}`,
),
);
}
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(chunk));
res.on('end', () => {
try {
resolve(JSON.parse(Buffer.concat(chunks).toString()) as T);
} catch (e) {
reject(new Error(`Failed to parse npm registry response: ${e}`));
}
});
})
.on('error', reject);
});
}
/**
* Download a file from a URL, following redirects.
*/
function downloadNpmFile(
url: string,
dest: string,
authToken?: string,
): Promise<void> {
const headers: Record<string, string> = {};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const client = url.startsWith('https://') ? https : http;
return new Promise((resolve, reject) => {
client
.get(url, { headers }, (res) => {
if (res.statusCode === 301 || res.statusCode === 302) {
if (res.headers.location) {
// Strip auth token when redirected to a different host
const originalHost = new URL(url).host;
const redirectHost = new URL(res.headers.location).host;
const redirectToken =
redirectHost === originalHost ? authToken : undefined;
downloadNpmFile(res.headers.location, dest, redirectToken)
.then(resolve)
.catch(reject);
return;
}
}
if (res.statusCode !== 200) {
return reject(
new Error(
`Failed to download npm tarball: status ${res.statusCode}`,
),
);
}
const file = fs.createWriteStream(dest);
res.pipe(file);
file.on('finish', () => file.close(resolve as () => void));
})
.on('error', reject);
});
}
/**
* Download and extract an extension from an npm registry.
*/
export async function downloadFromNpmRegistry(
installMetadata: ExtensionInstallMetadata,
destination: string,
): Promise<NpmDownloadResult> {
const { name, version: requestedVersion } = parseNpmPackageSource(
installMetadata.source,
);
const scope = name.split('/')[0];
const registryUrl =
installMetadata.registryUrl || resolveNpmRegistry(scope, undefined);
// Store resolved registry for future update checks
installMetadata.registryUrl = registryUrl;
const authToken = getNpmAuthToken(registryUrl);
// Fetch package metadata
const encodedName = name.replaceAll('/', '%2f');
const metadataUrl = `${registryUrl}/${encodedName}`;
debugLogger.debug(`Fetching npm package metadata from ${metadataUrl}`);
const metadata = await fetchNpmJson<NpmPackageMetadata>(
metadataUrl,
authToken,
);
// Resolve version
let resolvedVersion: string;
if (requestedVersion && requestedVersion !== 'latest') {
if (metadata.versions[requestedVersion]) {
resolvedVersion = requestedVersion;
} else if (metadata['dist-tags'][requestedVersion]) {
resolvedVersion = metadata['dist-tags'][requestedVersion];
} else {
throw new Error(
`Version "${requestedVersion}" not found for package ${name}`,
);
}
} else {
resolvedVersion = metadata['dist-tags']['latest'];
if (!resolvedVersion) {
throw new Error(`No "latest" dist-tag found for package ${name}`);
}
}
const versionData = metadata.versions[resolvedVersion];
if (!versionData) {
throw new Error(
`Version data for "${resolvedVersion}" not found for package ${name}`,
);
}
const tarballUrl = versionData.dist.tarball;
debugLogger.debug(
`Downloading ${name}@${resolvedVersion} from ${tarballUrl}`,
);
// Only send auth token if the tarball is hosted on the same registry host.
// Private registries often point dist.tarball at a CDN on a different domain;
// forwarding the registry token there would leak credentials.
const registryHost = new URL(registryUrl).host;
const tarballHost = new URL(tarballUrl).host;
const tarballAuthToken = tarballHost === registryHost ? authToken : undefined;
// Download tarball
const tarballPath = path.join(destination, 'package.tgz');
await downloadNpmFile(tarballUrl, tarballPath, tarballAuthToken);
// Extract tarball
await tar.x({
file: tarballPath,
cwd: destination,
});
// npm tarballs contain a `package/` wrapper directory — flatten it
const packageDir = path.join(destination, 'package');
if (fs.existsSync(packageDir)) {
const entries = await fs.promises.readdir(packageDir);
for (const entry of entries) {
await fs.promises.rename(
path.join(packageDir, entry),
path.join(destination, entry),
);
}
await fs.promises.rmdir(packageDir);
}
// Clean up tarball
await fs.promises.unlink(tarballPath);
debugLogger.debug(
`Successfully extracted ${name}@${resolvedVersion} to ${destination}`,
);
return {
version: resolvedVersion,
type: 'npm',
};
}
/**
* Check if an npm-installed extension has an update available.
*/
export async function checkNpmUpdate(
installMetadata: ExtensionInstallMetadata,
): Promise<ExtensionUpdateState> {
try {
const { name } = parseNpmPackageSource(installMetadata.source);
const scope = name.split('/')[0];
const registryUrl =
installMetadata.registryUrl || resolveNpmRegistry(scope, undefined);
const authToken = getNpmAuthToken(registryUrl);
const encodedName = name.replaceAll('/', '%2f');
const metadataUrl = `${registryUrl}/${encodedName}`;
const metadata = await fetchNpmJson<NpmPackageMetadata>(
metadataUrl,
authToken,
);
const { version: requestedVersion } = parseNpmPackageSource(
installMetadata.source,
);
// If pinned to an exact version, it's always up-to-date
if (
requestedVersion &&
requestedVersion !== 'latest' &&
!metadata['dist-tags'][requestedVersion]
) {
return ExtensionUpdateState.UP_TO_DATE;
}
// Resolve the target dist-tag (default: "latest")
const targetTag =
requestedVersion && metadata['dist-tags'][requestedVersion]
? requestedVersion
: 'latest';
const targetVersion = metadata['dist-tags'][targetTag];
if (!targetVersion) {
debugLogger.error(`No "${targetTag}" dist-tag found for package ${name}`);
return ExtensionUpdateState.ERROR;
}
if (targetVersion !== installMetadata.releaseTag) {
return ExtensionUpdateState.UPDATE_AVAILABLE;
}
return ExtensionUpdateState.UP_TO_DATE;
} catch (error) {
debugLogger.error(
`Failed to check npm update for "${installMetadata.source}": ${error}`,
);
return ExtensionUpdateState.ERROR;
}
}