mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 15:31:27 +00:00
Merge pull request #2719 from QwenLM/feat/npm-extension-installation
feat(extension): Add npm registry support for extension installation
This commit is contained in:
commit
2eb2f4e319
11 changed files with 924 additions and 12 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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' ||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
282
packages/core/src/extension/npm.test.ts
Normal file
282
packages/core/src/extension/npm.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
432
packages/core/src/extension/npm.ts
Normal file
432
packages/core/src/extension/npm.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue