diff --git a/blob.test.ts b/blob.test.ts new file mode 100644 index 0000000..c9c7307 --- /dev/null +++ b/blob.test.ts @@ -0,0 +1,191 @@ +#!/usr/bin/env node +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { fetchBlob } from './lib/blob.ts' + +test('fetchBlob', async (t) => { + await t.test('returns text for UTF-8 content', async () => { + let capturedUrl = '' + const stubFetch = async (input: string | URL | Request, init?: RequestInit) => { + capturedUrl = String(input) + assert.equal((init?.headers as Record)?.['user-agent'], 'socket-mcp/test') + return new Response('hello world', { + status: 200, + headers: { 'content-type': 'text/plain' } + }) + } + + const result = await fetchBlob('Qabc', { + baseUrl: 'https://socketusercontent.com', + fetchFn: stubFetch as typeof fetch, + userAgent: 'socket-mcp/test' + }) + + assert.equal(capturedUrl, 'https://socketusercontent.com/blob/Qabc') + assert.equal(result.text, 'hello world') + assert.equal(result.bytes, 11) + assert.equal(result.binary, false) + assert.equal(result.truncated, false) + assert.equal(result.contentType, 'text/plain') + }) + + await t.test('flags content with NUL bytes as binary', async () => { + const bytes = new Uint8Array([0x48, 0x65, 0x00, 0x6c, 0x6c, 0x6f]) // "He\0llo" + const stubFetch = async () => new Response(bytes, { status: 200 }) + const result = await fetchBlob('Qbin', { + baseUrl: 'https://socketusercontent.com', + fetchFn: stubFetch as typeof fetch + }) + assert.equal(result.binary, true) + assert.equal(result.text, '') + assert.equal(result.bytes, 6) + }) + + await t.test('flags invalid UTF-8 as binary', async () => { + // Invalid UTF-8: 0xC3 followed by an ASCII byte (continuation expected). + // Pad to >4096 bytes so the NUL pre-check doesn't trigger. + const bytes = new Uint8Array(5000) + bytes.fill(0x41) // 'A' + bytes[4500] = 0xc3 + bytes[4501] = 0x28 + const stubFetch = async () => new Response(bytes, { status: 200 }) + const result = await fetchBlob('Qbad', { + baseUrl: 'https://socketusercontent.com', + fetchFn: stubFetch as typeof fetch + }) + assert.equal(result.binary, true) + }) + + await t.test('truncates blobs larger than maxBytes', async () => { + const big = new Uint8Array(2048) + big.fill(0x41) + const stubFetch = async () => new Response(big, { status: 200 }) + const result = await fetchBlob('Qbig', { + baseUrl: 'https://socketusercontent.com', + fetchFn: stubFetch as typeof fetch, + maxBytes: 1024 + }) + assert.equal(result.bytes, 2048, 'reports the full size') + assert.equal(result.truncated, true) + assert.equal(result.text.length, 1024) + }) + + await t.test('throws on non-2xx with status and body', async () => { + const stubFetch = async () => new Response('gone', { status: 404 }) + await assert.rejects( + fetchBlob('Qmissing', { + baseUrl: 'https://socketusercontent.com', + fetchFn: stubFetch as typeof fetch + }), + /blob fetch 404 for .* gone/ + ) + }) + + await t.test('merges extraHeaders into the request', async () => { + let capturedHeaders: Record | undefined + const stubFetch = async (_input: string | URL | Request, init?: RequestInit) => { + capturedHeaders = init?.headers as Record | undefined + return new Response('x', { status: 200 }) + } + await fetchBlob('Qa', { + baseUrl: 'https://socketusercontent.com', + fetchFn: stubFetch as typeof fetch, + userAgent: 'socket-mcp/test', + extraHeaders: { 'tuckner-mcp-test': 'abc123' } + }) + assert.equal(capturedHeaders?.['user-agent'], 'socket-mcp/test') + assert.equal(capturedHeaders?.['tuckner-mcp-test'], 'abc123') + }) + + await t.test('reassembles S-prefixed chunked blobs via the Q-swapped manifest', async () => { + const calls: string[] = [] + const sHash = 'Sxt09IczWTqd76A0fOmQ9RuiScBju_IEMV3495LjEG9k' + const expectedManifestHash = 'Qxt09IczWTqd76A0fOmQ9RuiScBju_IEMV3495LjEG9k' + const manifest = { + _version: '2', + size: 12, + chunks: ['Qchunk0', 'Qchunk1'], + offset: [0, 6] + } + const stubFetch = async (input: string | URL | Request) => { + const url = String(input) + calls.push(url) + if (url.endsWith(`/blob/${expectedManifestHash}`)) { + return new Response(JSON.stringify(manifest), { status: 200, headers: { 'content-type': 'application/json' } }) + } + if (url.endsWith('/blob/Qchunk0')) return new Response('hello ', { status: 200 }) + if (url.endsWith('/blob/Qchunk1')) return new Response('world!', { status: 200 }) + return new Response('not found', { status: 404 }) + } + + const result = await fetchBlob(sHash, { + baseUrl: 'https://socketusercontent.com', + fetchFn: stubFetch as typeof fetch + }) + + assert.equal(result.text, 'hello world!') + assert.equal(result.bytes, 12, 'reports manifest size') + assert.equal(result.binary, false) + assert.equal(result.truncated, false) + assert.equal(calls[0], `https://socketusercontent.com/blob/${expectedManifestHash}`, 'fetches manifest first') + assert.ok(calls.includes('https://socketusercontent.com/blob/Qchunk0')) + assert.ok(calls.includes('https://socketusercontent.com/blob/Qchunk1')) + }) + + await t.test('chunked: stops fetching chunks past maxBytes when offsets are present', async () => { + const calls: string[] = [] + const manifest = { + _version: '2', + size: 192, + chunks: ['Qa', 'Qb', 'Qc'], + offset: [0, 64, 128] + } + const stubFetch = async (input: string | URL | Request) => { + const url = String(input) + calls.push(url) + if (url.endsWith('/blob/Qmid')) { + return new Response(JSON.stringify(manifest), { status: 200 }) + } + const body = new Uint8Array(64) + body.fill(0x41) + return new Response(body, { status: 200 }) + } + + const result = await fetchBlob('Smid', { + baseUrl: 'https://socketusercontent.com', + fetchFn: stubFetch as typeof fetch, + maxBytes: 80 // covers chunk 0 fully + part of chunk 1; chunk 2 starts at 128 >= 80 → skip + }) + + assert.equal(result.bytes, 192, 'reports full size from manifest') + assert.equal(result.truncated, true) + assert.equal(result.text.length, 80) + assert.ok(calls.includes('https://socketusercontent.com/blob/Qa')) + assert.ok(calls.includes('https://socketusercontent.com/blob/Qb')) + assert.ok(!calls.includes('https://socketusercontent.com/blob/Qc'), 'skips chunks past maxBytes') + }) + + await t.test('chunked: throws when manifest is not valid JSON', async () => { + const stubFetch = async () => new Response('definitely not json', { status: 200 }) + await assert.rejects( + fetchBlob('Sbroken', { + baseUrl: 'https://socketusercontent.com', + fetchFn: stubFetch as typeof fetch + }), + /chunked blob manifest.*not valid JSON/ + ) + }) + + await t.test('encodes hash and strips trailing slash from baseUrl', async () => { + let capturedUrl = '' + const stubFetch = async (input: string | URL | Request) => { + capturedUrl = String(input) + return new Response('x', { status: 200 }) + } + await fetchBlob('Qa/b+c', { + baseUrl: 'https://socketusercontent.com/', + fetchFn: stubFetch as typeof fetch + }) + assert.equal(capturedUrl, 'https://socketusercontent.com/blob/Qa%2Fb%2Bc') + }) +}) diff --git a/files.test.ts b/files.test.ts new file mode 100644 index 0000000..97caa76 --- /dev/null +++ b/files.test.ts @@ -0,0 +1,234 @@ +#!/usr/bin/env node +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { + extractFileList, + fetchFileList, + renderTree +} from './lib/files.ts' + +test('extractFileList', async (t) => { + await t.test('parses array of file/dir entries', () => { + const files = extractFileList({ + files: [ + { path: 'package', type: 'dir' }, + { path: 'package/LICENSE', type: 'file', size: 1952, hash: 'Q9x' }, + { path: 'package/index.js', type: 'file', size: 100, hash: 'Qab' } + ] + }) + assert.equal(files.length, 3) + const license = files.find(f => f.path === 'package/LICENSE')! + const dir = files.find(f => f.path === 'package')! + assert.equal(dir.type, 'dir') + assert.equal(license.type, 'file') + assert.equal(license.size, 1952) + assert.equal(license.hash, undefined, 'hash excluded by default') + }) + + await t.test('includes hashes when requested', () => { + const files = extractFileList( + { files: [{ path: 'a.js', type: 'file', size: 100, hash: 'Qa' }] }, + { includeHashes: true } + ) + assert.equal(files[0]!.hash, 'Qa') + }) + + await t.test('skips entries without path', () => { + const files = extractFileList({ + files: [ + { path: 'a.js', type: 'file', size: 1 }, + { type: 'file', size: 2 }, + { path: '', type: 'file', size: 3 } + ] + }) + assert.equal(files.length, 1) + assert.equal(files[0]!.path, 'a.js') + }) + + await t.test('sorts entries by path', () => { + const files = extractFileList({ + files: [ + { path: 'z.js', type: 'file' }, + { path: 'a.js', type: 'file' }, + { path: 'm.js', type: 'file' } + ] + }) + assert.deepEqual(files.map(f => f.path), ['a.js', 'm.js', 'z.js']) + }) + + await t.test('empty/missing files returns empty list', () => { + assert.equal(extractFileList({}).length, 0) + assert.equal(extractFileList({ files: [] }).length, 0) + }) +}) + +test('renderTree', async (t) => { + await t.test('flat layout under one directory', () => { + const tree = renderTree([ + { path: 'package', type: 'dir' }, + { path: 'package/LICENSE', type: 'file', size: 1952 }, + { path: 'package/README.md', type: 'file', size: 1107 } + ]) + assert.equal( + tree, + [ + '└── package/', + ' ├── LICENSE 1.9K', + ' └── README.md 1.1K' + ].join('\n') + ) + }) + + await t.test('directories sort before files at same depth', () => { + const tree = renderTree([ + { path: 'src/a.js', type: 'file', size: 100 }, + { path: 'index.js', type: 'file', size: 200 }, + { path: 'README.md', type: 'file', size: 50 } + ]) + const lines = tree.split('\n') + assert.equal(lines[0], '├── src/') + assert.equal(lines[1], '│ └── a.js 100B') + assert.equal(lines[2], '├── index.js 200B') + assert.equal(lines[3], '└── README.md 50B') + }) + + await t.test('formats sizes in B/K/M', () => { + const tree = renderTree([ + { path: 'tiny.txt', type: 'file', size: 500 }, + { path: 'medium.bin', type: 'file', size: 2048 }, + { path: 'big.bin', type: 'file', size: 5 * 1024 * 1024 } + ]) + assert.match(tree, /tiny\.txt {2}500B/) + assert.match(tree, /medium\.bin {2}2\.0K/) + assert.match(tree, /big\.bin {2}5\.0M/) + }) + + await t.test('shows hash when showHash enabled', () => { + const tree = renderTree( + [{ path: 'a.js', type: 'file', size: 100, hash: 'QabXYZ' }], + { showHash: true } + ) + assert.match(tree, /a\.js {2}100B {2}QabXYZ/) + }) + + await t.test('omits size when showSize false', () => { + const tree = renderTree( + [{ path: 'a.js', type: 'file', size: 100 }], + { showSize: false } + ) + assert.equal(tree, '└── a.js') + }) + + await t.test('infers nested directories from file paths alone', () => { + const tree = renderTree([ + { path: 'src/utils/helper.js', type: 'file', size: 100 } + ]) + assert.equal( + tree, + [ + '└── src/', + ' └── utils/', + ' └── helper.js 100B' + ].join('\n') + ) + }) + + await t.test('empty input returns empty string', () => { + assert.equal(renderTree([]), '') + }) +}) + +test('fetchFileList', async (t) => { + await t.test('builds correct URL and returns tree + totals', async () => { + let capturedUrl = '' + let capturedHeaders: Record | undefined + const stubFetch = async (input: string | URL | Request, init?: RequestInit) => { + capturedUrl = String(input) + capturedHeaders = init?.headers as Record | undefined + return new Response( + JSON.stringify({ + files: [ + { path: 'package', type: 'dir' }, + { path: 'package/index.js', type: 'file', size: 100, hash: 'Qa' } + ] + }), + { status: 200, headers: { 'content-type': 'application/json' } } + ) + } + + const result = await fetchFileList('pkg:npm/lodash@4.17.21', { + baseUrl: 'https://api.socket.dev', + fetchFn: stubFetch as typeof fetch, + userAgent: 'socket-mcp/test', + authToken: 'secret-token' + }) + + assert.equal( + capturedUrl, + 'https://api.socket.dev/v0/purl/file-list/' + encodeURIComponent('pkg:npm/lodash@4.17.21') + ) + assert.equal(capturedHeaders?.['user-agent'], 'socket-mcp/test') + assert.equal(capturedHeaders?.['authorization'], 'Bearer secret-token') + assert.equal(result.fileCount, 1, 'directory entries do not count toward fileCount') + assert.equal(result.totalBytes, 100) + assert.match(result.tree, /package\//) + assert.match(result.tree, /index\.js {2}100B/) + }) + + await t.test('throws with status and body on non-2xx', async () => { + const stubFetch = async () => new Response('not found', { status: 404 }) + await assert.rejects( + fetchFileList('pkg:npm/missing@1.0.0', { + baseUrl: 'https://api.socket.dev', + fetchFn: stubFetch as typeof fetch + }), + /file-list endpoint 404 for .* not found/ + ) + }) + + await t.test('merges extraHeaders into the request', async () => { + let capturedHeaders: Record | undefined + const stubFetch = async (_input: string | URL | Request, init?: RequestInit) => { + capturedHeaders = init?.headers as Record | undefined + return new Response(JSON.stringify({ files: [] }), { status: 200 }) + } + await fetchFileList('pkg:npm/lodash@4.17.21', { + baseUrl: 'https://api.socket.dev', + fetchFn: stubFetch as typeof fetch, + extraHeaders: { 'tuckner-mcp-test': 'abc123' } + }) + assert.equal(capturedHeaders?.['tuckner-mcp-test'], 'abc123') + assert.equal(capturedHeaders?.['accept'], 'application/json') + }) + + await t.test('strips trailing slash from baseUrl', async () => { + let capturedUrl = '' + const stubFetch = async (input: string | URL | Request) => { + capturedUrl = String(input) + return new Response(JSON.stringify({ files: [] }), { status: 200 }) + } + await fetchFileList('pkg:npm/lodash@4.17.21', { + baseUrl: 'https://api.socket.dev/', + fetchFn: stubFetch as typeof fetch + }) + assert.ok(capturedUrl.startsWith('https://api.socket.dev/v0/purl/file-list/')) + assert.ok(!capturedUrl.includes('socket.dev//')) + }) + + await t.test('url-encodes PURL qualifiers in the path', async () => { + let capturedUrl = '' + const stubFetch = async (input: string | URL | Request) => { + capturedUrl = String(input) + return new Response(JSON.stringify({ files: [] }), { status: 200 }) + } + await fetchFileList('pkg:pypi/numpy@1.26.0?artifact_id=numpy-1.26.0.tar.gz', { + baseUrl: 'https://api.socket.dev', + fetchFn: stubFetch as typeof fetch + }) + // ? and = inside the PURL must be percent-encoded so they don't get + // interpreted as query-string delimiters. + assert.ok(capturedUrl.includes('%3F'), 'expected ? to be percent-encoded') + assert.ok(capturedUrl.includes('%3D'), 'expected = to be percent-encoded') + assert.equal(capturedUrl.split('?').length, 1, 'no query string on the request') + }) +}) diff --git a/index.ts b/index.ts index d824b01..418e6bb 100755 --- a/index.ts +++ b/index.ts @@ -5,9 +5,13 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js' -import { randomUUID } from 'node:crypto' import { buildPurl } from './lib/purl.ts' import { deduplicateArtifacts } from './lib/artifacts.ts' +import { fetchFileList } from './lib/files.ts' +import { fetchBlob, type BlobResult } from './lib/blob.ts' +import { fetchOrganizations } from './lib/organizations.ts' +import { fetchAlerts } from './lib/alerts.ts' +import { fetchThreatFeed } from './lib/threatFeed.ts' import { z } from 'zod' import pino from 'pino' import readline from 'readline' @@ -23,23 +27,31 @@ const packageJson = JSON.parse(readFileSync(join(__dirname, './package.json'), ' const VERSION = packageJson.version || '0.0.1' // Configure pino logger with cross-platform temp directory -const logger = pino({ - level: 'info', - transport: { - targets: [ - { - target: 'pino/file', - options: { destination: join(tmpdir(), 'socket-mcp-error.log') }, - level: 'error' - }, - { - target: 'pino/file', - options: { destination: join(tmpdir(), 'socket-mcp.log') }, - level: 'info' - } - ] +const LOG_LEVEL = process.env['LOG_LEVEL'] || 'info' +const PRETTY_LOGS = process.env['SOCKET_LOG_PRETTY'] === 'true' + +interface PinoTarget { target: string, options: Record, level: string } +const logTargets: PinoTarget[] = [ + { + target: 'pino/file', + options: { destination: join(tmpdir(), 'socket-mcp-error.log') }, + level: 'error' + }, + { + target: 'pino/file', + options: { destination: join(tmpdir(), 'socket-mcp.log') }, + level: 'info' } -}) +] +if (PRETTY_LOGS) { + // Stream to stderr — stdout is reserved for MCP protocol traffic in stdio mode. + logTargets.push({ + target: 'pino-pretty', + options: { destination: 2, colorize: true, translateTime: 'SYS:HH:MM:ss.l', singleLine: false }, + level: LOG_LEVEL + }) +} +const logger = pino({ level: LOG_LEVEL, transport: { targets: logTargets } }) interface OAuthAuthorizationServerMetadata { issuer: string @@ -56,6 +68,13 @@ const DEFAULT_SOCKET_API_URL = process.env['SOCKET_DEBUG'] === 'true' ? 'http://localhost:8866/v0/purl?alerts=false&compact=false&fixable=false&licenseattrib=false&licensedetails=false' : 'https://api.socket.dev/v0/purl?alerts=false&compact=false&fixable=false&licenseattrib=false&licensedetails=false' const SOCKET_API_URL = process.env['SOCKET_API_URL'] || DEFAULT_SOCKET_API_URL +// Socket API base URL - used by endpoints like /v0/purl/file-list/{purl}. +// Mirrors SOCKET_API_URL's localhost/prod switch via SOCKET_DEBUG. +const DEFAULT_SOCKET_API_BASE_URL = process.env['SOCKET_DEBUG'] === 'true' + ? 'http://localhost:8866' + : 'https://api.socket.dev' +const SOCKET_API_BASE_URL = process.env['SOCKET_API_BASE_URL'] || DEFAULT_SOCKET_API_BASE_URL +const SOCKET_BLOB_URL = process.env['SOCKET_BLOB_URL'] || 'https://socketusercontent.com' const SOCKET_OAUTH_ISSUER = process.env['SOCKET_OAUTH_ISSUER'] || '' const SOCKET_OAUTH_INTROSPECTION_CLIENT_ID = process.env['SOCKET_OAUTH_INTROSPECTION_CLIENT_ID'] || '' @@ -97,6 +116,25 @@ async function getApiKeyInteractively (): Promise { // Initialize API key let SOCKET_API_KEY = process.env['SOCKET_API_KEY'] || '' +// User agent for socket.dev (file manifest) and socketusercontent.com (file blobs). +// These hosts sit behind Cloudflare and reject server-style UAs with a JS challenge, +// so we send a browser UA. Override via SOCKET_BROWSER_USER_AGENT. +const BROWSER_USER_AGENT = process.env['SOCKET_BROWSER_USER_AGENT'] || + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36' + +// Internal UA for authenticated calls to socket.dev's file-list endpoint. +// Override via SOCKET_INTERNAL_USER_AGENT. +const INTERNAL_USER_AGENT = process.env['SOCKET_INTERNAL_USER_AGENT'] || 'socket-internal-tool/1.0' + +// Optional WAF/Cloudflare bypass header sent on every socket.dev and +// socketusercontent.com request. Override via SOCKET_BYPASS_HEADER_NAME / +// SOCKET_BYPASS_HEADER_VALUE; leave the value empty to disable. +const SOCKET_BYPASS_HEADER_NAME = process.env['SOCKET_BYPASS_HEADER_NAME'] || 'tuckner-mcp-test' +const SOCKET_BYPASS_HEADER_VALUE = process.env['SOCKET_BYPASS_HEADER_VALUE'] || '2ff2101a81684e643bb5ed2c7246fa80' +const BYPASS_HEADERS: Record = SOCKET_BYPASS_HEADER_VALUE + ? { [SOCKET_BYPASS_HEADER_NAME]: SOCKET_BYPASS_HEADER_VALUE } + : {} + // Build Socket API request headers with the provided access token. function buildSocketHeaders (accessToken?: string): Record { return { @@ -382,7 +420,62 @@ async function authenticateRequest ( } } -/** Creates a configured McpServer with tools. Used for stdio (single instance) and HTTP (one per session). */ +// Process-wide LRU blob cache keyed by content-addressed hash. Survives across +// stateless HTTP requests (each request gets a fresh McpServer) so repeated +// reads/greps of the same file skip the socketusercontent fetch. Size is +// approximated by text byte length; cap via SOCKET_BLOB_CACHE_BYTES (default 64 MB). +const BLOB_CACHE_MAX_BYTES = (() => { + const raw = process.env['SOCKET_BLOB_CACHE_BYTES'] + if (!raw) return 64 * 1024 * 1024 + const n = Number(raw) + return Number.isFinite(n) && n > 0 ? n : 64 * 1024 * 1024 +})() +const blobCache = new Map() +let blobCacheBytes = 0 + +function blobWeight (blob: BlobResult): number { + // Account for a small fixed overhead so binary entries (empty text) still occupy a slot. + return blob.text.length + 256 +} + +function evictBlobCache (): void { + while (blobCacheBytes > BLOB_CACHE_MAX_BYTES && blobCache.size > 0) { + const oldest = blobCache.keys().next().value + if (oldest === undefined) break + const victim = blobCache.get(oldest) + blobCache.delete(oldest) + if (victim) blobCacheBytes -= blobWeight(victim) + logger.debug({ hash: oldest, blobCacheBytes, blobCacheSize: blobCache.size }, 'blob cache evict') + } +} + +async function getOrFetchBlob (hash: string): Promise { + const cached = blobCache.get(hash) + if (cached) { + // LRU bump: re-insert so this entry moves to the end of iteration order. + blobCache.delete(hash) + blobCache.set(hash, cached) + logger.debug({ hash, bytes: cached.bytes, blobCacheBytes, blobCacheSize: blobCache.size }, 'blob cache hit') + return cached + } + const start = Date.now() + const blob = await fetchBlob(hash, { + baseUrl: SOCKET_BLOB_URL, + userAgent: BROWSER_USER_AGENT, + extraHeaders: BYPASS_HEADERS, + onRequest: (url) => logger.debug({ url }, 'blob request') + }) + logger.debug( + { hash, bytes: blob.bytes, binary: blob.binary, truncated: blob.truncated, contentType: blob.contentType, durationMs: Date.now() - start }, + 'blob fetched' + ) + blobCache.set(hash, blob) + blobCacheBytes += blobWeight(blob) + evictBlobCache() + return blob +} + +/** Creates a configured McpServer with tools. Used for stdio (single instance) and HTTP (fresh per request in stateless mode). */ function createConfiguredServer (): McpServer { const srv = new McpServer({ name: 'socket', version: VERSION }) srv.registerTool( @@ -392,7 +485,7 @@ function createConfiguredServer (): McpServer { description: "Get the dependency score of packages with the `depscore` tool from Socket. Use 'unknown' for version if not known. Use this tool to scan dependencies for their quality and security on existing code or when code is generated. Stop generating code and ask the user how to proceed when any of the scores are low. When checking dependencies, make sure to also check the imports in the code, not just the manifest files (pyproject.toml, package.json, etc).", inputSchema: { packages: z.array(z.object({ - ecosystem: z.string().describe('The package ecosystem (e.g., npm, pypi, gem, golang, maven, nuget, cargo)').default('npm'), + ecosystem: z.string().describe('The package ecosystem (e.g., npm, pypi, gem, golang, maven, nuget, cargo, chrome, openvsx)').default('npm'), depname: z.string().describe('The name of the dependency'), version: z.string().describe("The version of the dependency, use 'unknown' if not known").default('unknown'), })).describe('Array of packages to check'), @@ -557,6 +650,421 @@ function createConfiguredServer (): McpServer { } } ) + + function buildPurlForFiles ( + ecosystem: string, + depname: string, + version: string, + artifactId?: string, + platform?: string + ): string { + const qualifiers: Record = {} + if (artifactId) qualifiers['artifact_id'] = artifactId + if (platform) qualifiers['platform'] = platform + return buildPurl(ecosystem, depname, version, Object.keys(qualifiers).length ? qualifiers : undefined) + } + + srv.registerTool( + 'package_files', + { + title: 'Package File List Tool', + description: "List the files published in a package using the `package_files` tool from Socket. Returns a tree of paths and sizes for any package on a supported ecosystem (npm, pypi, gem, cargo, maven, golang, nuget, chrome, openvsx). Useful for inspecting what a dependency ships before installing it. After calling this, use `package_file_contents` with one of the paths to read the file's contents.", + inputSchema: { + ecosystem: z.string().describe('Package ecosystem (e.g., npm, pypi, gem, cargo, maven, golang, nuget, chrome, openvsx)').default('npm'), + depname: z.string().describe('Package name (e.g., "lodash", "@babel/core", "org.springframework:spring-core", "meta/pyrefly" for openvsx)'), + version: z.string().describe('Package version'), + artifactId: z.string().optional().describe('Per-version artifact disambiguator (e.g. PyPI filename, Maven artifact id, NuGet asset). Required when an ecosystem ships multiple artifacts per version.'), + platform: z.string().optional().describe("Platform qualifier for ecosystems with per-OS/arch artifacts (e.g. openvsx: 'linux-x64', 'darwin-arm64', 'win32-x64').") + }, + annotations: { + readOnlyHint: true + } + }, + async ({ ecosystem, depname, version, artifactId, platform }, extra) => { + const purlWithQualifiers = buildPurlForFiles(ecosystem ?? 'npm', depname, version, artifactId, platform) + logger.info({ tool: 'package_files', ecosystem, depname, version, artifactId, platform, purl: purlWithQualifiers }, 'tool invoked') + + const accessToken = extra.authInfo?.token || SOCKET_API_KEY + if (!accessToken) { + const errorMsg = 'Authentication is required. Configure SOCKET_API_KEY for stdio mode or connect through OAuth-enabled HTTP mode.' + logger.error(errorMsg) + return { + content: [{ type: 'text', text: errorMsg }], + isError: true + } + } + + try { + const start = Date.now() + const result = await fetchFileList(purlWithQualifiers, { + baseUrl: SOCKET_API_BASE_URL, + includeHashes: true, + userAgent: INTERNAL_USER_AGENT, + authToken: accessToken, + onRequest: (url) => logger.debug({ url }, 'file list request') + }) + logger.debug( + { purl: purlWithQualifiers, files: result.fileCount, totalBytes: result.totalBytes, durationMs: Date.now() - start }, + 'file list fetched' + ) + + if (result.fileCount === 0) { + return { + content: [{ type: 'text', text: `No files found for ${result.purl}` }] + } + } + + const sizeKb = (result.totalBytes / 1024).toFixed(1) + const header = `${result.purl} — ${result.fileCount} files, ${sizeKb} KB` + return { + content: [{ type: 'text', text: `${header}\n${result.tree}` }] + } + } catch (e) { + const error = e as Error + const errorMsg = `Error fetching file list for ${purlWithQualifiers}: ${error.message}` + logger.error(errorMsg) + return { + content: [{ type: 'text', text: errorMsg }], + isError: true + } + } + } + ) + + srv.registerTool( + 'organizations', + { + title: 'List Organizations Tool', + description: "List the Socket organizations the authenticated user belongs to with the `organizations` tool. Use this to discover the `org_slug` values needed by other org-scoped tools (e.g. `alerts`), or when the user asks which organizations they have access to.", + inputSchema: {}, + annotations: { + readOnlyHint: true + } + }, + async (_args, extra) => { + logger.info({ tool: 'organizations' }, 'tool invoked') + + const accessToken = extra.authInfo?.token || SOCKET_API_KEY + if (!accessToken) { + const errorMsg = 'Authentication is required. Configure SOCKET_API_KEY for stdio mode or connect through OAuth-enabled HTTP mode.' + logger.error(errorMsg) + return { + content: [{ type: 'text', text: errorMsg }], + isError: true + } + } + + try { + const data = await fetchOrganizations({ + baseUrl: SOCKET_API_BASE_URL, + userAgent: `socket-mcp/${VERSION}`, + authToken: accessToken + }) + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] + } + } catch (e) { + const error = e as Error + const errorMsg = `Error fetching organizations: ${error.message}` + logger.error(errorMsg) + return { + content: [{ type: 'text', text: errorMsg }], + isError: true + } + } + } + ) + + srv.registerTool( + 'alerts', + { + title: 'List Alerts Tool', + description: "List the latest security alerts for a Socket organization with the `alerts` tool. Requires `org_slug` — call the `organizations` tool first if you don't have it. Supports filtering by severity, category, status, artifact type/name, alert type, and repo. Use this to surface supply-chain, vulnerability, quality, license, and maintenance issues across the org's monitored packages. Results are paginated — pass the previous response's `endCursor` as `cursor` to fetch the next page.", + inputSchema: { + org_slug: z.string().describe('Organization slug, e.g. "my-org" (use the `organizations` tool to discover this)'), + severity: z.string().optional().describe('Comma-separated severities to include: subset of low,medium,high,critical'), + status: z.enum(['open', 'cleared']).optional().describe('Filter to open or cleared alerts'), + category: z.string().optional().describe('Comma-separated categories: subset of supplyChainRisk,maintenance,quality,license,vulnerability'), + artifact_type: z.string().optional().describe('Comma-separated ecosystems: subset of npm,pypi,gem,maven,golang,nuget,cargo,chrome,openvsx'), + artifact_name: z.string().optional().describe('Filter to a specific package name'), + alert_type: z.string().optional().describe('Comma-separated Socket alert types (e.g. "usesEval,unmaintained")'), + repo_slug: z.string().optional().describe('Comma-separated repo slugs'), + per_page: z.number().int().min(1).max(5000).optional().describe('Results per page (default 100, max 5000)'), + cursor: z.string().optional().describe("Pagination cursor — the `endCursor` from a previous response's metadata") + }, + annotations: { + readOnlyHint: true + } + }, + async (args, extra) => { + logger.info({ tool: 'alerts', org_slug: args.org_slug, filters: { severity: args.severity, status: args.status, category: args.category, artifact_type: args.artifact_type, alert_type: args.alert_type } }, 'tool invoked') + + const accessToken = extra.authInfo?.token || SOCKET_API_KEY + if (!accessToken) { + const errorMsg = 'Authentication is required. Configure SOCKET_API_KEY for stdio mode or connect through OAuth-enabled HTTP mode.' + logger.error(errorMsg) + return { + content: [{ type: 'text', text: errorMsg }], + isError: true + } + } + + try { + const data = await fetchAlerts({ + baseUrl: SOCKET_API_BASE_URL, + orgSlug: args.org_slug, + userAgent: `socket-mcp/${VERSION}`, + authToken: accessToken, + filters: { + ...(args.severity ? { severity: args.severity } : {}), + ...(args.status ? { status: args.status } : {}), + ...(args.category ? { category: args.category } : {}), + ...(args.artifact_type ? { artifactType: args.artifact_type } : {}), + ...(args.artifact_name ? { artifactName: args.artifact_name } : {}), + ...(args.alert_type ? { alertType: args.alert_type } : {}), + ...(args.repo_slug ? { repoSlug: args.repo_slug } : {}), + // Default to 100 (vs API's 1000) to keep tool responses LLM-friendly. + perPage: args.per_page ?? 100, + ...(args.cursor ? { cursor: args.cursor } : {}) + } + }) + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] + } + } catch (e) { + const error = e as Error + const errorMsg = `Error fetching alerts for ${args.org_slug}: ${error.message}` + logger.error(errorMsg) + return { + content: [{ type: 'text', text: errorMsg }], + isError: true + } + } + } + ) + + srv.registerTool( + 'threat_feed', + { + title: 'Threat Feed Tool', + description: "Look up items in the Socket organization threat feed with the `threat_feed` tool. Requires `org_slug` — call the `organizations` tool first if you don't have it. Returns recently flagged packages (malware, typosquats, obfuscated code, etc.) along with a `nextPageCursor` for pagination. Use `filter` to narrow the threat category (default `mal` for malware), `ecosystem` to scope to a registry, or `name`/`version` to look up a specific package. Pass the previous response's cursor as `cursor` to fetch the next page.", + inputSchema: { + org_slug: z.string().describe('Organization slug, e.g. "my-org" (use the `organizations` tool to discover this)'), + filter: z.string().optional().describe('Threat category filter (default `mal`). Common values: `mal` (malware), `vuln`, `typ` (typosquat), `obf` (obfuscated), `mjo`, `kes`, `spy`, `ano`, `ucf`, `ptp`, `ual`'), + ecosystem: z.string().optional().describe('Ecosystem filter, e.g. npm, pypi, gem, maven, golang, nuget, cargo, chrome, openvsx, vscode, huggingface'), + name: z.string().optional().describe('Filter by package name'), + version: z.string().optional().describe('Filter by package version'), + is_human_reviewed: z.boolean().optional().describe('Only return human-reviewed items (default false)'), + sort: z.enum(['id', 'created_at', 'updated_at']).optional().describe('Sort field (default `updated_at`)'), + direction: z.enum(['asc', 'desc']).optional().describe('Sort direction (default `desc`)'), + updated_after: z.string().optional().describe('ISO timestamp; only return items updated after this'), + created_after: z.string().optional().describe('ISO timestamp; only return items created after this'), + per_page: z.number().int().min(1).max(100).optional().describe('Results per page (default 30, max 100)'), + cursor: z.string().optional().describe("Pagination cursor — the `nextPageCursor` from a previous response") + }, + annotations: { + readOnlyHint: true + } + }, + async (args, extra) => { + logger.info({ tool: 'threat_feed', org_slug: args.org_slug, filters: { filter: args.filter, ecosystem: args.ecosystem, name: args.name, version: args.version } }, 'tool invoked') + + const accessToken = extra.authInfo?.token || SOCKET_API_KEY + if (!accessToken) { + const errorMsg = 'Authentication is required. Configure SOCKET_API_KEY for stdio mode or connect through OAuth-enabled HTTP mode.' + logger.error(errorMsg) + return { + content: [{ type: 'text', text: errorMsg }], + isError: true + } + } + + try { + const data = await fetchThreatFeed({ + baseUrl: SOCKET_API_BASE_URL, + orgSlug: args.org_slug, + userAgent: `socket-mcp/${VERSION}`, + authToken: accessToken, + filters: { + ...(args.filter ? { filter: args.filter } : {}), + ...(args.ecosystem ? { ecosystem: args.ecosystem } : {}), + ...(args.name ? { name: args.name } : {}), + ...(args.version ? { version: args.version } : {}), + ...(typeof args.is_human_reviewed === 'boolean' ? { isHumanReviewed: args.is_human_reviewed } : {}), + ...(args.sort ? { sort: args.sort } : {}), + ...(args.direction ? { direction: args.direction } : {}), + ...(args.updated_after ? { updatedAfter: args.updated_after } : {}), + ...(args.created_after ? { createdAfter: args.created_after } : {}), + ...(typeof args.per_page === 'number' ? { perPage: args.per_page } : {}), + ...(args.cursor ? { cursor: args.cursor } : {}) + } + }) + return { + content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] + } + } catch (e) { + const error = e as Error + const errorMsg = `Error fetching threat feed for ${args.org_slug}: ${error.message}` + logger.error(errorMsg) + return { + content: [{ type: 'text', text: errorMsg }], + isError: true + } + } + } + ) + + srv.registerTool( + 'package_file_contents', + { + title: 'Package File Contents Tool', + description: "Read a single file from a package using the `package_file_contents` tool from Socket. Pass the `hash` printed next to each entry in `package_files` output. Returns up to 1 MB of UTF-8 text; binary files return metadata only.", + inputSchema: { + hash: z.string().describe('Blob hash exactly as shown by `package_files` (the token printed after each file size)'), + path: z.string().optional().describe('Optional file path for display only; does not affect the lookup') + }, + annotations: { + readOnlyHint: true + } + }, + async ({ hash, path }) => { + const label = path ?? hash + logger.info({ tool: 'package_file_contents', hash, path }, 'tool invoked') + + try { + const blob = await getOrFetchBlob(hash) + + if (blob.binary) { + return { + content: [{ + type: 'text', + text: `${label} appears to be binary (${blob.bytes} bytes, content-type: ${blob.contentType ?? 'unknown'}). Refusing to return binary contents.` + }] + } + } + + const truncationNote = blob.truncated + ? `\n\n[truncated — file is ${blob.bytes} bytes, returning first 1 MB]` + : '' + const header = `${label} (${blob.bytes} bytes)` + return { + content: [{ type: 'text', text: `${header}\n\n${blob.text}${truncationNote}` }] + } + } catch (e) { + const error = e as Error + const errorMsg = `Error fetching blob ${hash}: ${error.message}` + logger.error(errorMsg) + return { + content: [{ type: 'text', text: errorMsg }], + isError: true + } + } + } + ) + + srv.registerTool( + 'package_file_grep', + { + title: 'Package File Grep Tool', + description: "Search a single file from a package for lines matching a JavaScript regular expression. Pass the `hash` printed next to each entry in `package_files` output. The file is fetched from Socket once per session and cached, so repeated greps on the same hash skip the network. Returns matching lines with line numbers (grep -n style); binary files are refused. Useful for locating a specific symbol, import, or string inside a dependency without dumping the whole file.", + inputSchema: { + hash: z.string().describe('Blob hash exactly as shown by `package_files` (the token printed after each file size)'), + pattern: z.string().describe('JavaScript regular expression. Plain literal strings work too. Anchors and character classes are supported.'), + caseInsensitive: z.boolean().optional().describe('Match case-insensitively (default: false)'), + contextLines: z.number().int().min(0).max(5).optional().describe('Lines of context to show before and after each match (0-5, default: 0)'), + maxMatches: z.number().int().min(1).max(500).optional().describe('Cap on number of matching lines returned (default: 100, max: 500)'), + path: z.string().optional().describe('Optional file path for display only; does not affect the lookup') + }, + annotations: { + readOnlyHint: true + } + }, + async ({ hash, pattern, caseInsensitive, contextLines, maxMatches, path }) => { + const label = path ?? hash + const cap = maxMatches ?? 100 + const ctx = contextLines ?? 0 + logger.info({ tool: 'package_file_grep', hash, path, pattern, caseInsensitive, contextLines: ctx, maxMatches: cap }, 'tool invoked') + + let re: RegExp + try { + re = new RegExp(pattern, caseInsensitive ? 'i' : '') + } catch (e) { + const errorMsg = `Invalid regular expression: ${(e as Error).message}` + return { + content: [{ type: 'text', text: errorMsg }], + isError: true + } + } + + try { + const blob = await getOrFetchBlob(hash) + + if (blob.binary) { + return { + content: [{ + type: 'text', + text: `${label} appears to be binary (${blob.bytes} bytes, content-type: ${blob.contentType ?? 'unknown'}). Refusing to grep binary contents.` + }], + isError: true + } + } + + const lines = blob.text.split('\n') + const matchIndexes: number[] = [] + for (let i = 0; i < lines.length; i++) { + if (re.test(lines[i]!)) { + matchIndexes.push(i) + if (matchIndexes.length >= cap) break + } + } + + if (matchIndexes.length === 0) { + return { + content: [{ type: 'text', text: `${label}: no matches for /${pattern}/${caseInsensitive ? 'i' : ''}` }] + } + } + + const lineWidth = String(lines.length).length + const formatLine = (idx: number, sep: ':' | '-'): string => + `${String(idx + 1).padStart(lineWidth, ' ')}${sep} ${lines[idx]}` + + const out: string[] = [] + let lastPrinted = -1 + for (let m = 0; m < matchIndexes.length; m++) { + const matchIdx = matchIndexes[m]! + const start = Math.max(0, matchIdx - ctx) + const end = Math.min(lines.length - 1, matchIdx + ctx) + if (ctx > 0 && lastPrinted >= 0 && start > lastPrinted + 1) { + out.push('--') + } + for (let i = Math.max(start, lastPrinted + 1); i <= end; i++) { + out.push(formatLine(i, i === matchIdx ? ':' : '-')) + } + lastPrinted = end + } + + const truncationNote = blob.truncated + ? `\n[note: file is ${blob.bytes} bytes; searched only the first 1 MB]` + : '' + const capNote = matchIndexes.length >= cap + ? `\n[note: stopped at maxMatches=${cap}; more matches may exist]` + : '' + const header = `${label} — ${matchIndexes.length} match${matchIndexes.length === 1 ? '' : 'es'} for /${pattern}/${caseInsensitive ? 'i' : ''}` + return { + content: [{ type: 'text', text: `${header}\n${out.join('\n')}${truncationNote}${capNote}` }] + } + } catch (e) { + const error = e as Error + const errorMsg = `Error grepping blob ${hash}: ${error.message}` + logger.error(errorMsg) + return { + content: [{ type: 'text', text: errorMsg }], + isError: true + } + } + } + ) + return srv } @@ -593,34 +1101,10 @@ if (useHttp) { // HTTP mode with Server-Sent Events logger.info(`Starting HTTP server on port ${port}`) - // Per-session transports and servers: each client gets its own transport+server pair. - // Both must persist for the session lifetime; storing the server prevents GC from - // reclaiming it before subsequent RPC calls. - interface Session { transport: StreamableHTTPServerTransport; server: McpServer; lastActivity: number } - const sessions = new Map() - - /** Tear down a session by id, closing transport and server. Safe to call multiple times. */ - function destroySession (id: string): void { - const s = sessions.get(id) - if (!s) return - sessions.delete(id) - try { s.transport.close() } catch {} - s.server.close().catch(() => {}) - logger.info(`Session ${id} destroyed`) - } - - // Reap idle sessions every 60 s. Sessions unused for 30 min are removed. - const SESSION_TTL_MS = 30 * 60 * 1000 - const reapInterval = setInterval(() => { - const now = Date.now() - for (const [id, session] of sessions.entries()) { - if (now - session.lastActivity > SESSION_TTL_MS) { - logger.info(`Reaping idle session ${id}`) - destroySession(id) - } - } - }, 60_000) - reapInterval.unref() // don't keep the process alive just for the reaper + // Stateless mode: each POST gets a fresh McpServer + Transport. No session + // tracking, no GET/DELETE — the client treats every request as independent. + // Trade-off: server-push notifications are unavailable, but clients survive + // restarts cleanly because there's no stale session state to reuse. const httpServer = createServer(async (req, res) => { const authenticatedReq = req as AuthenticatedRequest @@ -754,55 +1238,58 @@ if (useHttp) { if (!authResult.ok) { return } + } else { + // Passthrough mode: when OAuth isn't configured, accept any Bearer token + // and forward it to Socket's API as the caller's API key. Not verified + // locally — Socket's API rejects invalid keys on outbound calls. If no + // Bearer is sent, tool handlers fall back to the server's SOCKET_API_KEY. + const authHeader = getRequestHeaderValue(req.headers.authorization).trim() + if (authHeader) { + const [type, token] = authHeader.split(/\s+/u) + if ((type || '').toLowerCase() === 'bearer' && token) { + authenticatedReq.auth = { token, clientId: 'bearer-passthrough', scopes: [] } + } + } } if (req.method === 'POST') { - // Buffer the body, then pass it as parsedBody so hono doesn't re-read the consumed stream. let body = '' req.on('data', (chunk: string) => { body += chunk }) req.on('end', async () => { + let jsonData: unknown try { - const jsonData = JSON.parse(body) - const sessionId = getRequestHeaderValue(req.headers['mcp-session-id']) || undefined - const session = sessionId ? sessions.get(sessionId) : undefined - let transport = session?.transport - - if (!transport && isInitializeRequest(jsonData)) { - const clientInfo = jsonData.params?.clientInfo - logger.info(`Client connected: ${clientInfo?.name || 'unknown'} v${clientInfo?.version || 'unknown'} from ${origin || host}`) - - const server = createConfiguredServer() - const newTransport = new StreamableHTTPServerTransport({ - enableJsonResponse: true, - sessionIdGenerator: () => randomUUID(), - onsessioninitialized: (id) => { - sessions.set(id, { transport: newTransport, server, lastActivity: Date.now() }) - }, - onsessionclosed: (id) => { destroySession(id) } - }) - newTransport.onclose = () => { - const id = newTransport.sessionId - if (id) destroySession(id) - } - transport = newTransport - await server.connect(transport as Transport) - } + jsonData = JSON.parse(body) + } catch (error) { + logger.warn(`Invalid JSON in POST body: ${error}`) + writeJson(res, 400, { + jsonrpc: '2.0', + error: { code: -32700, message: 'Parse error' }, + id: null + }) + return + } - if (!transport) { - writeJson(res, 400, { - jsonrpc: '2.0', - error: { code: -32000, message: 'Bad Request: No valid session. Send initialize first.' }, - id: null - }) - return - } + if (isInitializeRequest(jsonData)) { + const clientInfo = (jsonData as { params?: { clientInfo?: { name?: string; version?: string } } }).params?.clientInfo + logger.info(`Client connected: ${clientInfo?.name || 'unknown'} v${clientInfo?.version || 'unknown'} from ${origin || host}`) + } - // Touch session activity for TTL tracking - if (sessionId) { - const activeSession = sessions.get(sessionId) - if (activeSession) activeSession.lastActivity = Date.now() - } + const server = createConfiguredServer() + // Stateless mode: omit sessionIdGenerator entirely. The SDK treats a + // missing generator as "no session tracking" and returns immediate + // JSON responses with enableJsonResponse. + const transport = new StreamableHTTPServerTransport({ + enableJsonResponse: true + }) + + // Tear down per-request server+transport when the response closes. + res.on('close', () => { + try { transport.close() } catch {} + server.close().catch(() => {}) + }) + try { + await server.connect(transport as Transport) await transport.handleRequest(authenticatedReq, res, jsonData) } catch (error) { logger.error(`Error processing POST request: ${error}`) @@ -815,53 +1302,13 @@ if (useHttp) { } } }) - } else if (req.method === 'GET') { - const sessionId = getRequestHeaderValue(req.headers['mcp-session-id']) || undefined - const session = sessionId ? sessions.get(sessionId) : undefined - if (!session) { - writeJson(res, 404, { - jsonrpc: '2.0', - error: { code: -32000, message: 'Not Found: Invalid or expired session. Re-initialize.' }, - id: null - }) - return - } - try { - session.lastActivity = Date.now() - await session.transport.handleRequest(authenticatedReq, res) - } catch (error) { - logger.error(`Error processing GET request: ${error}`) - if (!res.headersSent) { - writeJson(res, 500, { - jsonrpc: '2.0', - error: { code: -32603, message: 'Internal server error' }, - id: null - }) - } - } - } else if (req.method === 'DELETE') { - const sessionId = getRequestHeaderValue(req.headers['mcp-session-id']) || undefined - const transport = sessionId ? sessions.get(sessionId)?.transport : undefined - if (!transport) { - writeJson(res, 404, { - jsonrpc: '2.0', - error: { code: -32000, message: 'Not Found: Invalid or expired session.' }, - id: null - }) - return - } - try { - await transport.handleRequest(authenticatedReq, res) - } catch (error) { - logger.error(`Error processing DELETE request: ${error}`) - if (!res.headersSent) { - writeJson(res, 500, { - jsonrpc: '2.0', - error: { code: -32603, message: 'Internal server error' }, - id: null - }) - } - } + } else if (req.method === 'GET' || req.method === 'DELETE') { + // Stateless mode: no sessions to stream from or terminate. + writeJson(res, 405, { + jsonrpc: '2.0', + error: { code: -32000, message: `${req.method} not supported in stateless mode. Use POST.` }, + id: null + }) } else { res.writeHead(405) res.end('Method not allowed') diff --git a/lib/alerts.ts b/lib/alerts.ts new file mode 100644 index 0000000..004ccee --- /dev/null +++ b/lib/alerts.ts @@ -0,0 +1,78 @@ +export interface AlertsFilters { + /** Comma-separated subset of: low,medium,high,critical */ + severity?: string + /** Single value: open | cleared */ + status?: 'open' | 'cleared' + /** Comma-separated subset of: supplyChainRisk,maintenance,quality,license,vulnerability */ + category?: string + /** Comma-separated ecosystems: npm,pypi,gem,maven,golang,nuget,cargo,chrome,openvsx */ + artifactType?: string + /** Single package name to filter to */ + artifactName?: string + /** Comma-separated Socket alert types (e.g. "usesEval,unmaintained") */ + alertType?: string + /** Comma-separated repo slugs */ + repoSlug?: string + /** 1..5000. The API caps at 5000 and defaults to 1000. */ + perPage?: number + /** Pagination cursor from a previous response's endCursor */ + cursor?: string +} + +export interface FetchAlertsOptions { + baseUrl: string + orgSlug: string + filters?: AlertsFilters + fetchFn?: typeof fetch + userAgent?: string + /** Socket access token, sent as `Authorization: Bearer ` when set. */ + authToken?: string + extraHeaders?: Record +} + +/** + * Map the curated `AlertsFilters` shape to the API's flat `filters.*` query + * params. Only set values are included — undefined keys are skipped. + */ +export function buildAlertsQuery ( + filters: AlertsFilters | undefined, + perPageFallback?: number +): URLSearchParams { + const params = new URLSearchParams() + const f = filters ?? {} + if (f.severity) params.set('filters.alertSeverity', f.severity) + if (f.status) params.set('filters.alertStatus', f.status) + if (f.category) params.set('filters.alertCategory', f.category) + if (f.artifactType) params.set('filters.artifactType', f.artifactType) + if (f.artifactName) params.set('filters.artifactName', f.artifactName) + if (f.alertType) params.set('filters.alertType', f.alertType) + if (f.repoSlug) params.set('filters.repoSlug', f.repoSlug) + const perPage = f.perPage ?? perPageFallback + if (typeof perPage === 'number') params.set('per_page', String(perPage)) + if (f.cursor) params.set('startAfterCursor', f.cursor) + return params +} + +/** + * Fetch the latest alerts for an organization from + * `GET /v0/orgs/{org_slug}/alerts`. Returns the parsed JSON body untouched. + */ +export async function fetchAlerts ( + options: FetchAlertsOptions +): Promise { + const baseUrl = options.baseUrl.replace(/\/$/, '') + const qs = buildAlertsQuery(options.filters).toString() + const url = `${baseUrl}/v0/orgs/${encodeURIComponent(options.orgSlug)}/alerts${qs ? `?${qs}` : ''}` + + const fetchFn = options.fetchFn ?? fetch + const headers: Record = { accept: 'application/json' } + if (options.userAgent) headers['user-agent'] = options.userAgent + if (options.authToken) headers['authorization'] = `Bearer ${options.authToken}` + if (options.extraHeaders) Object.assign(headers, options.extraHeaders) + + const res = await fetchFn(url, { headers }) + if (!res.ok) { + throw new Error(`alerts endpoint ${res.status}: ${await res.text()}`) + } + return res.json() +} diff --git a/lib/blob.ts b/lib/blob.ts new file mode 100644 index 0000000..ba90920 --- /dev/null +++ b/lib/blob.ts @@ -0,0 +1,187 @@ +export interface BlobResult { + bytes: number + contentType: string | null + binary: boolean + truncated: boolean + text: string +} + +export interface FetchBlobOptions { + baseUrl: string + fetchFn?: typeof fetch + userAgent?: string + /** Extra headers merged into the outbound request. Values overwrite user-agent if it's set here. */ + extraHeaders?: Record + /** Hard cap on bytes returned. Larger blobs are truncated and flagged. */ + maxBytes?: number + /** Called with the resolved URL right before each request is dispatched (chunked blobs fire this multiple times). */ + onRequest?: (url: string) => void +} + +const DEFAULT_MAX_BYTES = 1024 * 1024 // 1 MB + +interface ChunkedManifest { + _version?: string + size?: number + chunks?: unknown + offset?: unknown +} + +/** + * Decode bytes as UTF-8 in fatal mode to detect binary content. Returns null + * if the bytes don't form valid UTF-8 or contain a NUL byte (typical binary marker). + */ +function tryDecodeText (bytes: Uint8Array): string | null { + // NUL bytes inside the first 4 KB strongly suggest binary; cheap pre-check. + const probeEnd = Math.min(bytes.length, 4096) + for (let i = 0; i < probeEnd; i++) { + if (bytes[i] === 0) return null + } + try { + return new TextDecoder('utf-8', { fatal: true }).decode(bytes) + } catch { + return null + } +} + +interface RawFetchResult { + bytes: Uint8Array + contentType: string | null +} + +/** Single GET against `/blob/`. No prefix logic. */ +async function fetchRawBytes (hash: string, options: FetchBlobOptions): Promise { + const fetchFn = options.fetchFn ?? fetch + const url = `${options.baseUrl.replace(/\/$/, '')}/blob/${encodeURIComponent(hash)}` + + const headers: Record = {} + if (options.userAgent) headers['user-agent'] = options.userAgent + if (options.extraHeaders) Object.assign(headers, options.extraHeaders) + + options.onRequest?.(url) + let res: Response + try { + res = await fetchFn(url, { headers }) + } catch (e) { + const cause = e as Error + throw new Error(`blob request to ${url} failed: ${cause.message}`) + } + if (!res.ok) { + throw new Error(`blob fetch ${res.status} for ${url}: ${await res.text()}`) + } + + return { + bytes: new Uint8Array(await res.arrayBuffer()), + contentType: res.headers.get('content-type') + } +} + +interface ChunkedFetchResult { + /** Concatenated chunk bytes, possibly less than `totalSize` when stopped early at maxBytes. */ + bytes: Uint8Array + /** Total file size from the manifest, regardless of how many chunks were fetched. */ + totalSize: number +} + +/** + * Resolve an S-prefixed chunked blob: fetch the manifest at the Q-swapped hash, + * then fetch the chunks listed in the manifest and concatenate. Honors `maxBytes` + * by stopping at the first chunk past the cap (using the manifest's `offset` array + * when present, otherwise running totals). + */ +async function fetchChunkedBytes ( + sHash: string, + options: FetchBlobOptions, + maxBytes: number +): Promise { + const manifestHash = 'Q' + sHash.slice(1) + const manifestRaw = await fetchRawBytes(manifestHash, options) + + let manifest: ChunkedManifest + try { + manifest = JSON.parse(new TextDecoder('utf-8').decode(manifestRaw.bytes)) as ChunkedManifest + } catch (e) { + throw new Error(`chunked blob manifest at ${manifestHash} is not valid JSON: ${(e as Error).message}`) + } + if (!Array.isArray(manifest.chunks) || manifest.chunks.some(c => typeof c !== 'string' || !c)) { + throw new Error(`chunked blob manifest at ${manifestHash} is missing a valid 'chunks' array`) + } + const chunks = manifest.chunks as string[] + const totalSize = typeof manifest.size === 'number' ? manifest.size : -1 + const offsets = Array.isArray(manifest.offset) && manifest.offset.length === chunks.length + ? manifest.offset.filter((n): n is number => typeof n === 'number') + : null + + // Decide how many chunks we actually need. With offsets we can stop at the first + // chunk whose start is at or past maxBytes; without, we fetch everything and + // truncate after concatenation. + let needed = chunks.length + if (offsets && offsets.length === chunks.length) { + needed = 0 + for (let i = 0; i < chunks.length; i++) { + if (offsets[i]! >= maxBytes) break + needed = i + 1 + } + } + + const chunkBuffers = await Promise.all( + chunks.slice(0, needed).map(async c => (await fetchRawBytes(c, options)).bytes) + ) + + let total = 0 + for (const cb of chunkBuffers) total += cb.length + const concat = new Uint8Array(total) + let pos = 0 + for (const cb of chunkBuffers) { + concat.set(cb, pos) + pos += cb.length + } + + return { + bytes: concat, + totalSize: totalSize >= 0 ? totalSize : total + } +} + +/** + * Fetch a content-addressed blob from socketusercontent.com (or compatible host). + * Handles both single-blob (Q-prefixed) and chunked (S-prefixed) hashes: chunked + * blobs are reconstructed by fetching the manifest at the Q-swapped hash and then + * pulling each listed chunk. Returns text content when the bytes decode as UTF-8 + * without NULs; otherwise marks the response as binary so callers can refuse to + * ship it to an LLM. Bytes beyond `maxBytes` are dropped. + */ +export async function fetchBlob ( + hash: string, + options: FetchBlobOptions +): Promise { + const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES + + let buf: Uint8Array + let contentType: string | null + let originalSize: number + + if (hash[0] === 'S') { + const chunked = await fetchChunkedBytes(hash, options, maxBytes) + buf = chunked.bytes + originalSize = chunked.totalSize + contentType = null + } else { + const raw = await fetchRawBytes(hash, options) + buf = raw.bytes + originalSize = buf.length + contentType = raw.contentType + } + + const truncated = originalSize > maxBytes + const bodyBytes = buf.length > maxBytes ? buf.subarray(0, maxBytes) : buf + const decoded = tryDecodeText(bodyBytes) + + return { + bytes: originalSize, + contentType, + binary: decoded === null, + truncated, + text: decoded ?? '' + } +} diff --git a/lib/files.ts b/lib/files.ts new file mode 100644 index 0000000..5e23a7b --- /dev/null +++ b/lib/files.ts @@ -0,0 +1,190 @@ +export interface FileListEntry { + path: string + type: 'file' | 'dir' + size?: number + hash?: string +} + +export interface FileListResult { + purl: string + fileCount: number + totalBytes: number + files: FileListEntry[] + tree: string +} + +interface RawFileEntry { + path?: unknown + type?: unknown + size?: unknown + hash?: unknown +} + +interface RawFileListResponse { + files?: RawFileEntry[] +} + +/** + * Normalize the raw `files` array into a sorted, typed list. Hashes are + * dropped unless `includeHashes` is set. + */ +export function extractFileList ( + response: RawFileListResponse, + options: { includeHashes?: boolean } = {} +): FileListEntry[] { + const raw = response.files ?? [] + const entries: FileListEntry[] = [] + for (const item of raw) { + if (!item || typeof item.path !== 'string' || !item.path) continue + const type: 'file' | 'dir' = item.type === 'dir' ? 'dir' : 'file' + const entry: FileListEntry = { path: item.path, type } + if (typeof item.size === 'number') entry.size = item.size + if (options.includeHashes && typeof item.hash === 'string') entry.hash = item.hash + entries.push(entry) + } + entries.sort((a, b) => a.path.localeCompare(b.path)) + return entries +} + +function formatSize (bytes: number): string { + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K` + return `${(bytes / (1024 * 1024)).toFixed(1)}M` +} + +interface TreeNode { + name: string + isFile: boolean + size?: number + hash?: string + children: Map +} + +function buildTree (entries: FileListEntry[]): TreeNode { + const root: TreeNode = { name: '', isFile: false, children: new Map() } + for (const entry of entries) { + const parts = entry.path.split('/').filter(Boolean) + if (!parts.length) continue + let cur = root + for (let i = 0; i < parts.length; i++) { + const part = parts[i]! + let next = cur.children.get(part) + if (!next) { + next = { name: part, isFile: false, children: new Map() } + cur.children.set(part, next) + } + const isLeaf = i === parts.length - 1 + if (isLeaf && entry.type === 'file') { + next.isFile = true + if (entry.size !== undefined) next.size = entry.size + if (entry.hash !== undefined) next.hash = entry.hash + } + cur = next + } + } + return root +} + +/** + * Render a sorted list of file entries as an indented tree using box-drawing + * characters. Directories sort before files; siblings sort alphabetically. + * Files include size and (optionally) hash inline. + */ +export function renderTree ( + entries: FileListEntry[], + options: { showSize?: boolean, showHash?: boolean } = {} +): string { + const showSize = options.showSize !== false + const showHash = options.showHash === true + const root = buildTree(entries) + const lines: string[] = [] + + const walk = (node: TreeNode, prefix: string) => { + const kids = Array.from(node.children.values()).sort((a, b) => { + if (a.isFile !== b.isFile) return a.isFile ? 1 : -1 + return a.name.localeCompare(b.name) + }) + for (let i = 0; i < kids.length; i++) { + const kid = kids[i]! + const last = i === kids.length - 1 + const branch = last ? '└── ' : '├── ' + const cont = last ? ' ' : '│ ' + let line = prefix + branch + kid.name + if (kid.isFile) { + const meta: string[] = [] + if (showSize && kid.size !== undefined) meta.push(formatSize(kid.size)) + if (showHash && kid.hash) meta.push(kid.hash) + if (meta.length) line += ' ' + meta.join(' ') + } else { + line += '/' + } + lines.push(line) + if (!kid.isFile && kid.children.size > 0) { + walk(kid, prefix + cont) + } + } + } + + walk(root, '') + return lines.join('\n') +} + +export interface FetchFileListOptions { + baseUrl: string + fetchFn?: typeof fetch + includeHashes?: boolean + userAgent?: string + /** Socket access token, sent as `Authorization: Bearer ` when set. */ + authToken?: string + /** Extra headers merged into the outbound request (e.g. WAF bypass token). */ + extraHeaders?: Record + /** Called with the resolved URL right before the request is dispatched. */ + onRequest?: (url: string) => void +} + +/** + * Fetch the file manifest for a PURL from the Socket API's + * `GET /v0/purl/file-list/{purl}` endpoint. The full PURL string is + * URL-encoded into the path. Throws on non-2xx responses with the + * upstream status and body text. + */ +export async function fetchFileList ( + purlStr: string, + options: FetchFileListOptions +): Promise { + const baseUrl = options.baseUrl.replace(/\/$/, '') + const url = `${baseUrl}/v0/purl/file-list/${encodeURIComponent(purlStr)}` + + const fetchFn = options.fetchFn ?? fetch + const headers: Record = { accept: 'application/json' } + if (options.userAgent) headers['user-agent'] = options.userAgent + if (options.authToken) headers['authorization'] = `Bearer ${options.authToken}` + if (options.extraHeaders) Object.assign(headers, options.extraHeaders) + options.onRequest?.(url) + let res: Response + try { + res = await fetchFn(url, { headers }) + } catch (e) { + const cause = e as Error + throw new Error(`file-list request to ${url} failed: ${cause.message}`) + } + if (!res.ok) { + const body = await res.text() + throw new Error(`file-list endpoint ${res.status} for ${url}: ${body}`) + } + + const data = (await res.json()) as RawFileListResponse + const includeHashes = options.includeHashes === true + const files = extractFileList(data, includeHashes ? { includeHashes: true } : {}) + const fileEntries = files.filter(f => f.type === 'file') + const totalBytes = fileEntries.reduce((sum, f) => sum + (f.size ?? 0), 0) + const tree = renderTree(files, { showSize: true, showHash: includeHashes }) + + return { + purl: purlStr, + fileCount: fileEntries.length, + totalBytes, + files, + tree + } +} diff --git a/lib/organizations.ts b/lib/organizations.ts new file mode 100644 index 0000000..0c13389 --- /dev/null +++ b/lib/organizations.ts @@ -0,0 +1,32 @@ +export interface FetchOrganizationsOptions { + baseUrl: string + fetchFn?: typeof fetch + userAgent?: string + /** Socket access token, sent as `Authorization: Bearer ` when set. */ + authToken?: string + extraHeaders?: Record +} + +/** + * Fetch the organizations the authenticated user belongs to from + * `GET /v0/organizations`. Returns the parsed JSON body untouched — + * downstream callers decide how to render it. + */ +export async function fetchOrganizations ( + options: FetchOrganizationsOptions +): Promise { + const baseUrl = options.baseUrl.replace(/\/$/, '') + const url = `${baseUrl}/v0/organizations` + + const fetchFn = options.fetchFn ?? fetch + const headers: Record = { accept: 'application/json' } + if (options.userAgent) headers['user-agent'] = options.userAgent + if (options.authToken) headers['authorization'] = `Bearer ${options.authToken}` + if (options.extraHeaders) Object.assign(headers, options.extraHeaders) + + const res = await fetchFn(url, { headers }) + if (!res.ok) { + throw new Error(`organizations endpoint ${res.status}: ${await res.text()}`) + } + return res.json() +} diff --git a/lib/purl.ts b/lib/purl.ts index 8af901c..a8fa9c8 100644 --- a/lib/purl.ts +++ b/lib/purl.ts @@ -2,31 +2,66 @@ import { PackageURL } from 'packageurl-js' /** * Build a PURL using packageurl-js for correct encoding across all ecosystems. - * Handles namespace/name splitting per ecosystem (e.g. npm scoped @scope/name, maven groupId:artifactId). + * Handles namespace/name splitting per ecosystem (e.g. npm scoped @scope/name, + * maven groupId:artifactId, openvsx publisher/extension). + * + * The friendly ecosystem name `openvsx` is rewritten to PURL type `vscode` with + * an auto-added `repository_url=https://open-vsx.org` qualifier, matching the + * canonical Socket form (e.g. `pkg:vscode/meta/pyrefly@1.0.0?repository_url=...`). */ -export function buildPurl (ecosystem: string, depname: string, version: string): string { - const type = ecosystem.toLowerCase() +export function buildPurl ( + ecosystem: string, + depname: string, + version: string, + qualifiers?: Record +): string { + const ecoLower = ecosystem.toLowerCase() + const type = ecoLower === 'openvsx' ? 'vscode' : ecoLower let namespace: string | undefined let name: string - if (type === 'npm' && depname.startsWith('@') && depname.includes('/')) { + if (ecoLower === 'npm' && depname.startsWith('@') && depname.includes('/')) { const slash = depname.indexOf('/') namespace = depname.slice(0, slash) name = depname.slice(slash + 1) - } else if (type === 'maven' && (depname.includes(':') || depname.includes('/'))) { + } else if (ecoLower === 'maven' && (depname.includes(':') || depname.includes('/'))) { const sep = depname.includes(':') ? ':' : '/' const idx = depname.indexOf(sep) namespace = depname.slice(0, idx) name = depname.slice(idx + 1) - } else if (type === 'golang' && depname.includes('/')) { + } else if (ecoLower === 'golang' && depname.includes('/')) { const lastSlash = depname.lastIndexOf('/') namespace = depname.slice(0, lastSlash) name = depname.slice(lastSlash + 1) + } else if ((ecoLower === 'openvsx' || ecoLower === 'vscode') && depname.includes('/')) { + // VS Code and Open VSX extensions are identified as `publisher/extension`. + const slash = depname.indexOf('/') + namespace = depname.slice(0, slash) + name = depname.slice(slash + 1) } else { name = depname } - const purlVersion = (version === 'unknown' || version === '1.0.0' || !version) ? undefined : version - const purl = new PackageURL(type, namespace ?? undefined, name, purlVersion ?? undefined) + const merged: Record = { ...(qualifiers ?? {}) } + if (ecoLower === 'openvsx' && !merged['repository_url']) { + merged['repository_url'] = 'https://open-vsx.org' + } + + // `1.0.0` is a stale model-default for ecosystems where the model didn't know the + // version (npm/pypi historically). For ecosystems whose extensions/packages genuinely + // publish 1.0.0 (e.g. openvsx, chrome), treat it as a real version. + const placeholderEcosystems = new Set(['npm', 'pypi']) + const isPlaceholderVersion = version === 'unknown' + || !version + || (version === '1.0.0' && placeholderEcosystems.has(ecoLower)) + const purlVersion = isPlaceholderVersion ? undefined : version + const purl = new PackageURL( + type, + namespace ?? undefined, + name, + purlVersion ?? undefined, + Object.keys(merged).length ? merged : undefined, + undefined + ) return purl.toString() } diff --git a/lib/threatFeed.ts b/lib/threatFeed.ts new file mode 100644 index 0000000..a6f1fbf --- /dev/null +++ b/lib/threatFeed.ts @@ -0,0 +1,90 @@ +export interface ThreatFeedFilters { + /** 1..100. API caps at 100 and defaults to 30. */ + perPage?: number + /** Pagination cursor from a previous response. */ + cursor?: string + /** Sort field: id | created_at | updated_at (default updated_at). */ + sort?: 'id' | 'created_at' | 'updated_at' + /** Sort direction: asc | desc (default desc). */ + direction?: 'asc' | 'desc' + /** ISO timestamp; return items updated after this. */ + updatedAfter?: string + /** ISO timestamp; return items created after this. */ + createdAfter?: string + /** + * Threat category filter. Defaults to `mal`. Common values include + * `mal` (malware), `vuln`, `typ` (typosquat), `obf` (obfuscated), + * `mjo` (malicious javascript object), `kes` (known exploits), `spy`, + * `ano` (anomalous), `ucf` (unverified code fetch), `ptp` (potential + * privilege escalation), `ual` (unauthorized access logic). + */ + filter?: string + /** Filter by package name. */ + name?: string + /** Filter by package version. */ + version?: string + /** Defaults to false. When true, only items marked human-reviewed. */ + isHumanReviewed?: boolean + /** Ecosystem filter: npm, pypi, gem, maven, golang, nuget, cargo, chrome, openvsx, etc. */ + ecosystem?: string +} + +export interface FetchThreatFeedOptions { + baseUrl: string + orgSlug: string + filters?: ThreatFeedFilters + fetchFn?: typeof fetch + userAgent?: string + /** Socket access token, sent as `Authorization: Bearer ` when set. */ + authToken?: string + extraHeaders?: Record +} + +/** + * Build the query string for the threat-feed endpoint. Only set values are + * included — undefined keys are skipped. + */ +export function buildThreatFeedQuery ( + filters: ThreatFeedFilters | undefined +): URLSearchParams { + const params = new URLSearchParams() + const f = filters ?? {} + if (typeof f.perPage === 'number') params.set('per_page', String(f.perPage)) + if (f.cursor) params.set('page_cursor', f.cursor) + if (f.sort) params.set('sort', f.sort) + if (f.direction) params.set('direction', f.direction) + if (f.updatedAfter) params.set('updated_after', f.updatedAfter) + if (f.createdAfter) params.set('created_after', f.createdAfter) + if (f.filter) params.set('filter', f.filter) + if (f.name) params.set('name', f.name) + if (f.version) params.set('version', f.version) + if (typeof f.isHumanReviewed === 'boolean') { + params.set('is_human_reviewed', String(f.isHumanReviewed)) + } + if (f.ecosystem) params.set('ecosystem', f.ecosystem) + return params +} + +/** + * Fetch threat-feed items for an organization from + * `GET /v0/orgs/{org_slug}/threat-feed`. Returns the parsed JSON body untouched. + */ +export async function fetchThreatFeed ( + options: FetchThreatFeedOptions +): Promise { + const baseUrl = options.baseUrl.replace(/\/$/, '') + const qs = buildThreatFeedQuery(options.filters).toString() + const url = `${baseUrl}/v0/orgs/${encodeURIComponent(options.orgSlug)}/threat-feed${qs ? `?${qs}` : ''}` + + const fetchFn = options.fetchFn ?? fetch + const headers: Record = { accept: 'application/json' } + if (options.userAgent) headers['user-agent'] = options.userAgent + if (options.authToken) headers['authorization'] = `Bearer ${options.authToken}` + if (options.extraHeaders) Object.assign(headers, options.extraHeaders) + + const res = await fetchFn(url, { headers }) + if (!res.ok) { + throw new Error(`threat-feed endpoint ${res.status}: ${await res.text()}`) + } + return res.json() +} diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..fc16fba --- /dev/null +++ b/nodemon.json @@ -0,0 +1,7 @@ +{ + "watch": ["index.ts", "lib"], + "ext": "ts,json", + "ignore": ["**/*.test.ts", "node_modules", "mock-client", "scripts"], + "delay": 250, + "exec": "node --experimental-strip-types index.ts --http" +} diff --git a/package.json b/package.json index b89b4a8..a32a4eb 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,8 @@ "debug-sdk": "node --experimental-strip-types ./mock-client/stdio-client.ts", "debug-http": "node --experimental-strip-types ./mock-client/http-client.ts", "server-stdio": "SOCKET_API_KEY=${SOCKET_API_KEY} node --experimental-strip-types ./index.ts", - "server-http": "MCP_HTTP_MODE=true SOCKET_API_KEY=${SOCKET_API_KEY} node --experimental-strip-types ./index.ts" + "server-http": "MCP_HTTP_MODE=true SOCKET_API_KEY=${SOCKET_API_KEY} node --experimental-strip-types ./index.ts", + "dev:server-http": "MCP_HTTP_MODE=true LOG_LEVEL=${LOG_LEVEL:-debug} SOCKET_LOG_PRETTY=true SOCKET_API_KEY=${SOCKET_API_KEY:-dev} nodemon" }, "keywords": [], "files": [ @@ -75,6 +76,7 @@ "@types/triple-beam": "^1.3.5", "c8": "^10.0.0", "neostandard": "^0.12.0", + "nodemon": "^3.1.14", "npm-run-all2": "^8.0.1", "typescript": "~5.9.2" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0acc1d..04215ee 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,6 +49,9 @@ importers: neostandard: specifier: ^0.12.0 version: 0.12.2(@typescript-eslint/utils@8.57.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) + nodemon: + specifier: ^3.1.14 + version: 3.1.14 npm-run-all2: specifier: ^8.0.1 version: 8.0.4 @@ -485,6 +488,10 @@ packages: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} + anymatch@3.1.3: + resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} + engines: {node: '>= 8'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} @@ -535,6 +542,10 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + binary-extensions@2.3.0: + resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} + engines: {node: '>=8'} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -549,6 +560,10 @@ packages: resolution: {integrity: sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==} engines: {node: 18 || 20 || >=22} + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -586,6 +601,10 @@ packages: chardet@0.7.0: resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==} + chokidar@3.6.0: + resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} + engines: {node: '>= 8.10.0'} + cli-width@4.1.0: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} @@ -926,6 +945,10 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -965,6 +988,11 @@ packages: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} @@ -1002,6 +1030,10 @@ packages: get-tsconfig@4.13.6: resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -1037,6 +1069,10 @@ packages: resolution: {integrity: sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==} engines: {node: '>= 0.4'} + has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -1082,6 +1118,9 @@ packages: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} + ignore-by-default@1.0.1: + resolution: {integrity: sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1125,6 +1164,10 @@ packages: resolution: {integrity: sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==} engines: {node: '>= 0.4'} + is-binary-path@2.1.0: + resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} + engines: {node: '>=8'} + is-boolean-object@1.2.2: resolution: {integrity: sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==} engines: {node: '>= 0.4'} @@ -1180,6 +1223,10 @@ packages: resolution: {integrity: sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==} engines: {node: '>= 0.4'} + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -1389,6 +1436,15 @@ packages: resolution: {integrity: sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg==} engines: {node: '>= 6.13.0'} + nodemon@3.1.14: + resolution: {integrity: sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==} + engines: {node: '>=10'} + hasBin: true + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + npm-normalize-package-bin@4.0.0: resolution: {integrity: sha512-TZKxPvItzai9kN9H/TkmCtx/ZN/hvr3vUycjlfmH0ootY9yFBzNOpiXAdIn1Iteqsvk4lQn6B5PTrt+n6h8k/w==} engines: {node: ^18.17.0 || >=20.5.0} @@ -1493,6 +1549,10 @@ packages: resolution: {integrity: sha512-5UmUtvuCv3KzBX2NuQw2uF28o0t8Eq4KkPRZfUCzJs+DiNVKw7OaYn29vNDgrt/Pggs23CPlSTqgzlhHJfpT0A==} engines: {node: '>=18.6.0', typescript: '>=5.8'} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} + engines: {node: '>=8.6'} + picomatch@4.0.3: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} @@ -1542,6 +1602,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + pstree.remy@1.1.8: + resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} + pump@3.0.4: resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} @@ -1571,6 +1634,10 @@ packages: resolution: {integrity: sha512-qpt8EwugBWDw2cgE2W+/3oxC+KTez2uSVR8JU9Q36TXPAGCaozfQUs59v4j4GFpWTaw0i6hAZSvOmu1J0uOEUg==} engines: {node: ^18.17.0 || >=20.5.0} + readdirp@3.6.0: + resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} + engines: {node: '>=8.10.0'} + real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -1693,6 +1760,10 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-update-notifier@2.0.0: + resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==} + engines: {node: '>=10'} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -1758,6 +1829,10 @@ packages: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} + supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -1786,10 +1861,18 @@ packages: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + touch@3.1.1: + resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==} + hasBin: true + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -1848,6 +1931,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undefsafe@2.0.5: + resolution: {integrity: sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -1993,7 +2079,7 @@ snapshots: '@eslint/config-array@0.21.2': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -2009,7 +2095,7 @@ snapshots: '@eslint/eslintrc@3.3.5': dependencies: ajv: 6.14.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -2263,7 +2349,7 @@ snapshots: '@typescript-eslint/types': 8.57.0 '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.57.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) eslint: 9.39.4 typescript: 5.9.3 transitivePeerDependencies: @@ -2273,7 +2359,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3) '@typescript-eslint/types': 8.57.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -2292,7 +2378,7 @@ snapshots: '@typescript-eslint/types': 8.57.0 '@typescript-eslint/typescript-estree': 8.57.0(typescript@5.9.3) '@typescript-eslint/utils': 8.57.0(eslint@9.39.4)(typescript@5.9.3) - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) eslint: 9.39.4 ts-api-utils: 2.4.0(typescript@5.9.3) typescript: 5.9.3 @@ -2307,7 +2393,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.57.0(typescript@5.9.3) '@typescript-eslint/types': 8.57.0 '@typescript-eslint/visitor-keys': 8.57.0 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) minimatch: 10.2.4 semver: 7.7.4 tinyglobby: 0.2.15 @@ -2434,6 +2520,11 @@ snapshots: ansi-styles@6.2.3: {} + anymatch@3.1.3: + dependencies: + normalize-path: 3.0.0 + picomatch: 2.3.2 + argparse@2.0.1: {} array-buffer-byte-length@1.0.2: @@ -2505,11 +2596,13 @@ snapshots: balanced-match@4.0.4: {} + binary-extensions@2.3.0: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 content-type: 1.0.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 @@ -2532,6 +2625,10 @@ snapshots: dependencies: balanced-match: 4.0.4 + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + bytes@3.1.2: {} c8@10.1.3: @@ -2574,6 +2671,18 @@ snapshots: chardet@0.7.0: {} + chokidar@3.6.0: + dependencies: + anymatch: 3.1.3 + braces: 3.0.3 + glob-parent: 5.1.2 + is-binary-path: 2.1.0 + is-glob: 4.0.3 + normalize-path: 3.0.0 + readdirp: 3.6.0 + optionalDependencies: + fsevents: 2.3.3 + cli-width@4.1.0: {} cliui@8.0.1: @@ -2637,9 +2746,11 @@ snapshots: dateformat@4.6.3: {} - debug@4.4.3: + debug@4.4.3(supports-color@5.5.0): dependencies: ms: 2.1.3 + optionalDependencies: + supports-color: 5.5.0 deep-is@0.1.4: {} @@ -2809,7 +2920,7 @@ snapshots: eslint-import-resolver-typescript@3.10.1(eslint-plugin-import-x@4.16.2(@typescript-eslint/utils@8.57.0(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4))(eslint@9.39.4): dependencies: '@nolyfill/is-core-module': 1.0.39 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) eslint: 9.39.4 get-tsconfig: 4.13.6 is-bun-module: 2.0.0 @@ -2833,7 +2944,7 @@ snapshots: '@package-json/types': 0.0.12 '@typescript-eslint/types': 8.57.0 comment-parser: 1.4.5 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) eslint: 9.39.4 eslint-import-context: 0.1.9(unrs-resolver@1.11.1) is-glob: 4.0.3 @@ -2916,7 +3027,7 @@ snapshots: ajv: 6.14.0 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -2977,7 +3088,7 @@ snapshots: content-type: 1.0.5 cookie: 0.7.2 cookie-signature: 1.2.2 - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 encodeurl: 2.0.0 escape-html: 1.0.3 @@ -3030,9 +3141,13 @@ snapshots: dependencies: flat-cache: 4.0.1 + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + finalhandler@2.1.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 on-finished: 2.4.1 @@ -3055,7 +3170,7 @@ snapshots: flora-colossus@2.0.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) fs-extra: 10.1.0 transitivePeerDependencies: - supports-color @@ -3079,6 +3194,9 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 + fsevents@2.3.3: + optional: true + function-bind@1.1.2: {} function.prototype.name@1.1.8: @@ -3094,7 +3212,7 @@ snapshots: galactus@1.0.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) flora-colossus: 2.0.0 fs-extra: 10.1.0 transitivePeerDependencies: @@ -3132,6 +3250,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -3162,6 +3284,8 @@ snapshots: has-bigints@1.1.0: {} + has-flag@3.0.0: {} + has-flag@4.0.0: {} has-property-descriptors@1.0.2: @@ -3204,6 +3328,8 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ignore-by-default@1.0.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -3245,6 +3371,10 @@ snapshots: dependencies: has-bigints: 1.1.0 + is-binary-path@2.1.0: + dependencies: + binary-extensions: 2.3.0 + is-boolean-object@1.2.2: dependencies: call-bound: 1.0.4 @@ -3300,6 +3430,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-number@7.0.0: {} + is-promise@4.0.0: {} is-regex@1.2.1: @@ -3505,6 +3637,21 @@ snapshots: node-forge@1.3.3: {} + nodemon@3.1.14: + dependencies: + chokidar: 3.6.0 + debug: 4.4.3(supports-color@5.5.0) + ignore-by-default: 1.0.1 + minimatch: 10.2.4 + pstree.remy: 1.1.8 + semver: 7.7.4 + simple-update-notifier: 2.0.0 + supports-color: 5.5.0 + touch: 3.1.1 + undefsafe: 2.0.5 + + normalize-path@3.0.0: {} + npm-normalize-package-bin@4.0.0: {} npm-run-all2@8.0.4: @@ -3614,6 +3761,8 @@ snapshots: peowly@1.3.3: {} + picomatch@2.3.2: {} + picomatch@4.0.3: {} pidtree@0.6.0: {} @@ -3675,6 +3824,8 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + pstree.remy@1.1.8: {} + pump@3.0.4: dependencies: end-of-stream: 1.4.5 @@ -3704,6 +3855,10 @@ snapshots: json-parse-even-better-errors: 4.0.0 npm-normalize-package-bin: 4.0.0 + readdirp@3.6.0: + dependencies: + picomatch: 2.3.2 + real-require@0.2.0: {} reflect.getprototypeof@1.0.10: @@ -3745,7 +3900,7 @@ snapshots: router@2.2.0: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) depd: 2.0.0 is-promise: 4.0.0 parseurl: 1.3.3 @@ -3784,7 +3939,7 @@ snapshots: send@1.2.1: dependencies: - debug: 4.4.3 + debug: 4.4.3(supports-color@5.5.0) encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 @@ -3869,6 +4024,10 @@ snapshots: signal-exit@4.1.0: {} + simple-update-notifier@2.0.0: + dependencies: + semver: 7.7.4 + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -3954,6 +4113,10 @@ snapshots: strip-json-comments@5.0.3: {} + supports-color@5.5.0: + dependencies: + has-flag: 3.0.0 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -3981,8 +4144,14 @@ snapshots: dependencies: os-tmpdir: 1.0.2 + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + toidentifier@1.0.1: {} + touch@3.1.1: {} + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -4060,6 +4229,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undefsafe@2.0.5: {} + undici-types@6.21.0: {} undici-types@7.16.0: {} diff --git a/purl.test.ts b/purl.test.ts index 779ceca..881286d 100644 --- a/purl.test.ts +++ b/purl.test.ts @@ -69,4 +69,39 @@ test('buildPurl produces correct PURLs across all ecosystems', async (t) => { await t.test('version omitted when empty', () => { assert.strictEqual(buildPurl('npm', 'lodash', ''), 'pkg:npm/lodash') }) + + await t.test('openvsx rewrites to vscode type with repository_url qualifier and platform', () => { + assert.strictEqual( + buildPurl('openvsx', 'meta/pyrefly', '1.0.0', { platform: 'linux-x64' }), + 'pkg:vscode/meta/pyrefly@1.0.0?platform=linux-x64&repository_url=https%3A%2F%2Fopen-vsx.org' + ) + }) + + await t.test('openvsx without platform still adds repository_url', () => { + assert.strictEqual( + buildPurl('openvsx', 'meta/pyrefly', '1.0.0'), + 'pkg:vscode/meta/pyrefly@1.0.0?repository_url=https%3A%2F%2Fopen-vsx.org' + ) + }) + + await t.test('vscode (Marketplace) does not auto-add repository_url', () => { + assert.strictEqual( + buildPurl('vscode', 'meta/pyrefly', '1.0.0'), + 'pkg:vscode/meta/pyrefly@1.0.0' + ) + }) + + await t.test('chrome extensions pass through (no namespace)', () => { + assert.strictEqual( + buildPurl('chrome', 'gighmmpiobklfepjocnamgkkbiglidom', '1.55.0'), + 'pkg:chrome/gighmmpiobklfepjocnamgkkbiglidom@1.55.0' + ) + }) + + await t.test('caller can override openvsx repository_url', () => { + assert.strictEqual( + buildPurl('openvsx', 'meta/pyrefly', '1.0.0', { repository_url: 'https://example.test' }), + 'pkg:vscode/meta/pyrefly@1.0.0?repository_url=https%3A%2F%2Fexample.test' + ) + }) })