From 3a157d1fecf3f420b6865328dd103d75d6e8f777 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Fri, 27 Mar 2026 20:38:35 +0800 Subject: [PATCH 1/3] feat(extension): add npm registry support for extension installation - Add new npm extension installation channel via scoped packages (@scope/name) - Implement npm.ts module with registry resolution, authentication, and download logic - Support version pinning, dist-tags (latest, beta), and custom registries - Handle private registry auth via NPM_TOKEN env var and .npmrc _authToken entries - Update CLI install command with --registry flag for npm extensions - Add comprehensive tests for npm package parsing and registry operations - Update documentation for releasing and installing from npm registries - Integrate npm updates into extension manager and update checking flow This enables teams using npm for package distribution to publish Qwen Code extensions through their existing infrastructure, with full support for private registries and access control. Co-authored-by: Qwen-Coder --- docs/users/extension/extension-releasing.md | 87 +++- docs/users/extension/introduction.md | 40 +- .../cli/src/commands/extensions/install.ts | 27 +- packages/core/src/config/config.ts | 5 +- .../core/src/extension/extensionManager.ts | 6 + packages/core/src/extension/github.ts | 4 + packages/core/src/extension/index.ts | 1 + .../core/src/extension/marketplace.test.ts | 44 ++ packages/core/src/extension/marketplace.ts | 8 + packages/core/src/extension/npm.test.ts | 282 ++++++++++++ packages/core/src/extension/npm.ts | 415 ++++++++++++++++++ 11 files changed, 907 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/extension/npm.test.ts create mode 100644 packages/core/src/extension/npm.ts diff --git a/docs/users/extension/extension-releasing.md b/docs/users/extension/extension-releasing.md index 0ec25fa71..28aafe491 100644 --- a/docs/users/extension/extension-releasing.md +++ b/docs/users/extension/extension-releasing.md @@ -1,11 +1,12 @@ # Extension Releasing -There are two primary ways of releasing extensions to users: +There are three primary ways of releasing extensions to users: - [Git repository](#releasing-through-a-git-repository) - [Github Releases](#releasing-through-github-releases) +- [npm Registry](#releasing-through-npm-registry) -Git repository releases tend to be the simplest and most flexible approach, while GitHub releases can be more efficient on initial install as they are shipped as single archives instead of requiring a git clone which downloads each file individually. Github releases may also contain platform specific archives if you need to ship platform specific binary files. +Git repository releases tend to be the simplest and most flexible approach, while GitHub releases can be more efficient on initial install as they are shipped as single archives instead of requiring a git clone which downloads each file individually. Github releases may also contain platform specific archives if you need to ship platform specific binary files. npm registry releases are ideal for teams that already use npm for package distribution, especially with private registries. ## Releasing through a git repository @@ -119,3 +120,85 @@ jobs: release/linux.arm64.my-tool.tar.gz release/win32.arm64.my-tool.zip ``` + +## Releasing through npm registry + +You can publish Qwen Code extensions as scoped npm packages (e.g. `@your-org/my-extension`). This is a good fit when: + +- Your team already uses npm for package distribution +- You need private registry support with existing auth infrastructure +- You want version resolution and access control handled by npm + +### Package requirements + +Your npm package must include a `qwen-extension.json` file at the package root. This is the same config file used by all Qwen Code extensions — the npm tarball is simply another delivery mechanism. + +A minimal package structure looks like: + +``` +my-extension/ +├── package.json +├── qwen-extension.json +├── QWEN.md # optional context file +├── commands/ # optional custom commands +├── skills/ # optional custom skills +└── agents/ # optional custom subagents +``` + +Make sure `qwen-extension.json` is included in your published package (i.e. not excluded by `.npmignore` or the `files` field in `package.json`). + +### Publishing + +Use standard npm publishing tools: + +```bash +# Publish to the default registry +npm publish + +# Publish to a private/custom registry +npm publish --registry https://your-registry.com +``` + +### Installation + +Users install your extension using the scoped package name: + +```bash +# Install latest version +qwen extensions install @your-org/my-extension + +# Install a specific version +qwen extensions install @your-org/my-extension@1.2.0 + +# Install from a custom registry +qwen extensions install @your-org/my-extension --registry https://your-registry.com +``` + +### Update behavior + +- Extensions installed without a version pin (e.g. `@scope/pkg`) track the `latest` dist-tag. +- Extensions installed with a dist-tag (e.g. `@scope/pkg@beta`) track that specific tag. +- Extensions pinned to an exact version (e.g. `@scope/pkg@1.2.0`) are always considered up-to-date and will not prompt for updates. + +### Authentication for private registries + +Qwen Code reads npm auth credentials automatically: + +1. **`NPM_TOKEN` environment variable** — highest priority +2. **`.npmrc` file** — supports both host-level and path-scoped `_authToken` entries (e.g. `//your-registry.com/:_authToken=TOKEN` or `//pkgs.dev.azure.com/org/_packaging/feed/npm/registry/:_authToken=TOKEN`) + +`.npmrc` files are read from the current directory and the user's home directory. + +### Managing release channels + +You can use npm dist-tags to manage release channels: + +```bash +# Publish a beta release +npm publish --tag beta + +# Users install beta channel +qwen extensions install @your-org/my-extension@beta +``` + +This works similarly to git branch-based release channels but uses npm's native dist-tag mechanism. diff --git a/docs/users/extension/introduction.md b/docs/users/extension/introduction.md index 0efb25b7c..6cf5113dd 100644 --- a/docs/users/extension/introduction.md +++ b/docs/users/extension/introduction.md @@ -12,11 +12,11 @@ We offer a suite of extension management tools using both `qwen extensions` CLI You can manage extensions at runtime within the interactive CLI using `/extensions` slash commands. These commands support hot-reloading, meaning changes take effect immediately without restarting the application. -| Command | Description | -| ------------------------------------- | ----------------------------------------------------------------- | -| `/extensions` or `/extensions manage` | Manage all installed extensions | -| `/extensions install ` | Install an extension from a git URL, local path, or marketplace | -| `/extensions explore [source]` | Open extensions source page(Gemini or ClaudeCode) in your browser | +| Command | Description | +| ------------------------------------- | ---------------------------------------------------------------------------- | +| `/extensions` or `/extensions manage` | Manage all installed extensions | +| `/extensions install ` | Install an extension from a git URL, local path, npm package, or marketplace | +| `/extensions explore [source]` | Open extensions source page(Gemini or ClaudeCode) in your browser | ### CLI Extension Management @@ -89,6 +89,34 @@ Gemini extensions are automatically converted to Qwen Code format during install - TOML command files are automatically migrated to Markdown format - MCP servers, context files, and settings are preserved +#### From npm Registry + +Qwen Code supports installing extensions from npm registries using scoped package names. This is ideal for teams with private registries that already have auth, versioning, and publishing infrastructure in place. + +```bash +# Install the latest version +qwen extensions install @scope/my-extension + +# Install a specific version +qwen extensions install @scope/my-extension@1.2.0 + +# Install from a custom registry +qwen extensions install @scope/my-extension --registry https://your-registry.com +``` + +Only scoped packages (`@scope/package-name`) are supported to avoid ambiguity with the `owner/repo` GitHub shorthand format. + +**Registry resolution** follows this priority: + +1. `--registry` CLI flag (explicit override) +2. Scoped registry from `.npmrc` (e.g. `@scope:registry=https://...`) +3. Default registry from `.npmrc` +4. Fallback: `https://registry.npmjs.org/` + +**Authentication** is handled automatically via the `NPM_TOKEN` environment variable or registry-specific `_authToken` entries in your `.npmrc` file. + +> **Note:** npm extensions must include a `qwen-extension.json` file at the package root, following the same format as any other Qwen Code extension. See [Extension Releasing](./extension-releasing.md#releasing-through-npm-registry) for packaging details. + #### From Git Repository ```bash @@ -127,7 +155,7 @@ This is useful if you have an extension disabled at the top-level and only enabl ### Updating an extension -For extensions installed from a local path or a git repository, you can explicitly update to the latest version (as reflected in the `qwen-extension.json` `version` field) with `qwen extensions update extension-name`. +For extensions installed from a local path, a git repository, or an npm registry, you can explicitly update to the latest version with `qwen extensions update extension-name`. For npm extensions installed without a version pin (e.g. `@scope/pkg`), updates check the `latest` dist-tag. For those installed with a specific dist-tag (e.g. `@scope/pkg@beta`), updates track that tag. Extensions pinned to an exact version (e.g. `@scope/pkg@1.2.0`) are always considered up-to-date. You can update all extensions with: diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index 000184535..cc63f6370 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -27,6 +27,7 @@ interface InstallArgs { autoUpdate?: boolean; allowPreRelease?: boolean; consent?: boolean; + registry?: string; } export async function handleInstall(args: InstallArgs) { @@ -35,7 +36,8 @@ export async function handleInstall(args: InstallArgs) { if ( installMetadata.type !== 'git' && - installMetadata.type !== 'github-release' + installMetadata.type !== 'github-release' && + installMetadata.type !== 'npm' ) { if (args.ref || args.autoUpdate) { throw new Error( @@ -46,6 +48,22 @@ export async function handleInstall(args: InstallArgs) { } } + if (installMetadata.type === 'npm' && args.ref) { + throw new Error( + t( + '--ref is not applicable for npm extensions. Use @version suffix instead (e.g. @scope/package@1.2.0).', + ), + ); + } + + if (installMetadata.type !== 'npm' && args.registry) { + throw new Error(t('--registry is only applicable for npm extensions.')); + } + + if (installMetadata.type === 'npm' && args.registry) { + installMetadata.registryUrl = args.registry; + } + const requestConsent = args.consent ? () => Promise.resolve() : requestConsentOrFail.bind(null, requestConsentNonInteractive); @@ -83,7 +101,7 @@ export async function handleInstall(args: InstallArgs) { export const installCommand: CommandModule = { command: 'install ', describe: t( - 'Installs an extension from a git repository URL, local path, or claude marketplace (marketplace-url:plugin-name).', + 'Installs an extension from a git repository URL, local path, scoped npm package (@scope/name), or claude marketplace (marketplace-url:plugin-name).', ), builder: (yargs) => yargs @@ -106,6 +124,10 @@ export const installCommand: CommandModule = { describe: t('Enable pre-release versions for this extension.'), type: 'boolean', }) + .option('registry', { + describe: t('Custom npm registry URL (only for npm extensions).'), + type: 'string', + }) .option('consent', { describe: t( 'Acknowledge the security risks of installing an extension and skip the confirmation prompt.', @@ -126,6 +148,7 @@ export const installCommand: CommandModule = { autoUpdate: argv['auto-update'] as boolean | undefined, allowPreRelease: argv['pre-release'] as boolean | undefined, consent: argv['consent'] as boolean | undefined, + registry: argv['registry'] as string | undefined, }); }, }; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index dc743d9b9..9167a7199 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -227,9 +227,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; diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index e64527ced..0346a3c51 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -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' diff --git a/packages/core/src/extension/github.ts b/packages/core/src/extension/github.ts index e0f448b90..9cf463cc0 100644 --- a/packages/core/src/extension/github.ts +++ b/packages/core/src/extension/github.ts @@ -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' || diff --git a/packages/core/src/extension/index.ts b/packages/core/src/extension/index.ts index 10b53da2c..d2f0c25c7 100644 --- a/packages/core/src/extension/index.ts +++ b/packages/core/src/extension/index.ts @@ -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'; diff --git a/packages/core/src/extension/marketplace.test.ts b/packages/core/src/extension/marketplace.test.ts index d55af8446..80fad9de6 100644 --- a/packages/core/src/extension/marketplace.test.ts +++ b/packages/core/src/extension/marketplace.test.ts @@ -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) diff --git a/packages/core/src/extension/marketplace.ts b/packages/core/src/extension/marketplace.ts index 5165e3f81..4ec7b8298 100644 --- a/packages/core/src/extension/marketplace.ts +++ b/packages/core/src/extension/marketplace.ts @@ -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); diff --git a/packages/core/src/extension/npm.test.ts b/packages/core/src/extension/npm.test.ts new file mode 100644 index 000000000..1ab2ddffd --- /dev/null +++ b/packages/core/src/extension/npm.test.ts @@ -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); + }); +}); diff --git a/packages/core/src/extension/npm.ts b/packages/core/src/extension/npm.ts new file mode 100644 index 000000000..9ad92eac1 --- /dev/null +++ b/packages/core/src/extension/npm.ts @@ -0,0 +1,415 @@ +/** + * 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; + 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(url: string, authToken?: string): Promise { + const headers: Record = { + 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) { + fetchNpmJson(res.headers.location, authToken) + .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 { + const headers: Record = {}; + 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) { + downloadNpmFile(res.headers.location, dest, authToken) + .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 { + 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.replace('/', '%2f'); + const metadataUrl = `${registryUrl}/${encodedName}`; + debugLogger.debug(`Fetching npm package metadata from ${metadataUrl}`); + + const metadata = await fetchNpmJson( + 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}`, + ); + + // Download tarball + const tarballPath = path.join(destination, 'package.tgz'); + await downloadNpmFile(tarballUrl, tarballPath, authToken); + + // 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 { + 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.replace('/', '%2f'); + const metadataUrl = `${registryUrl}/${encodedName}`; + const metadata = await fetchNpmJson( + 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; + } +} From ec677383b94a4149aa1a56a6791b67bd1db8841d Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Mon, 30 Mar 2026 19:48:54 +0800 Subject: [PATCH 2/3] fix(npm): prevent auth token leakage on cross-host redirects - Strip auth token when following redirects to a different host - Strip auth token when downloading tarballs from a different host - Fix package name encoding to replace all slashes, not just the first This prevents credentials from being sent to unintended hosts when private registries redirect to CDNs or other external domains. Co-authored-by: Qwen-Coder --- packages/core/src/extension/npm.ts | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/core/src/extension/npm.ts b/packages/core/src/extension/npm.ts index 9ad92eac1..94017878f 100644 --- a/packages/core/src/extension/npm.ts +++ b/packages/core/src/extension/npm.ts @@ -194,7 +194,12 @@ function fetchNpmJson(url: string, authToken?: string): Promise { .get(url, { headers }, (res) => { if (res.statusCode === 301 || res.statusCode === 302) { if (res.headers.location) { - fetchNpmJson(res.headers.location, authToken) + // 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(res.headers.location, redirectToken) .then(resolve) .catch(reject); return; @@ -241,7 +246,12 @@ function downloadNpmFile( .get(url, { headers }, (res) => { if (res.statusCode === 301 || res.statusCode === 302) { if (res.headers.location) { - downloadNpmFile(res.headers.location, dest, authToken) + // 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; @@ -282,7 +292,7 @@ export async function downloadFromNpmRegistry( const authToken = getNpmAuthToken(registryUrl); // Fetch package metadata - const encodedName = name.replace('/', '%2f'); + const encodedName = name.replaceAll('/', '%2f'); const metadataUrl = `${registryUrl}/${encodedName}`; debugLogger.debug(`Fetching npm package metadata from ${metadataUrl}`); @@ -322,9 +332,16 @@ export async function downloadFromNpmRegistry( `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, authToken); + await downloadNpmFile(tarballUrl, tarballPath, tarballAuthToken); // Extract tarball await tar.x({ @@ -371,7 +388,7 @@ export async function checkNpmUpdate( installMetadata.registryUrl || resolveNpmRegistry(scope, undefined); const authToken = getNpmAuthToken(registryUrl); - const encodedName = name.replace('/', '%2f'); + const encodedName = name.replaceAll('/', '%2f'); const metadataUrl = `${registryUrl}/${encodedName}`; const metadata = await fetchNpmJson( metadataUrl,