From 7a3b22a77c41c1b3a8367dc4901c06285ef9fac8 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 19 Dec 2025 10:56:41 -0500 Subject: [PATCH 1/3] Add programmatic API and update to v1.0.0 - Add runPatch() function for programmatic usage from socket-cli - Add ./run export in package.json for subpath import - Update postinstall detection to use socket CLI subcommand format - Change generated postinstall from npx @socketsecurity/socket-patch to socket patch apply - Add debug logging support via SOCKET_PATCH_DEBUG env var - Bump version to 1.0.0 for first stable release --- package.json | 7 ++- src/index.ts | 4 ++ src/package-json/detect.test.ts | 85 ++++++++++++++++++++++++++++----- src/package-json/detect.ts | 37 +++++++++----- src/run.ts | 84 ++++++++++++++++++++++++++++++++ src/utils/api-client.ts | 46 ++++++++++++++++-- 6 files changed, 235 insertions(+), 28 deletions(-) create mode 100644 src/run.ts diff --git a/package.json b/package.json index 581e76a..58076ae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@socketsecurity/socket-patch", - "version": "0.3.0", + "version": "1.0.0", "packageManager": "pnpm@10.16.1", "description": "CLI tool for applying security patches to dependencies", "main": "dist/index.js", @@ -48,6 +48,11 @@ "types": "./dist/package-json/index.d.ts", "require": "./dist/package-json/index.js", "import": "./dist/package-json/index.js" + }, + "./run": { + "types": "./dist/run.d.ts", + "require": "./dist/run.js", + "import": "./dist/run.js" } }, "scripts": { diff --git a/src/index.ts b/src/index.ts index 3d25080..2354ab3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,3 +15,7 @@ export * from './manifest/recovery.js' // Re-export constants export * from './constants.js' + +// Re-export programmatic API +export { runPatch } from './run.js' +export type { PatchOptions } from './run.js' diff --git a/src/package-json/detect.test.ts b/src/package-json/detect.test.ts index c6c5931..806300a 100644 --- a/src/package-json/detect.test.ts +++ b/src/package-json/detect.test.ts @@ -313,6 +313,40 @@ describe('isPostinstallConfigured', () => { assert.equal(result.configured, true) assert.equal(result.needsUpdate, false) }) + + it('should detect socket patch apply (Socket CLI subcommand) as configured', () => { + const packageJson = { + name: 'test', + version: '1.0.0', + scripts: { + postinstall: 'socket patch apply', + }, + } + + const result = isPostinstallConfigured(packageJson) + + assert.equal( + result.configured, + true, + 'socket patch apply (CLI subcommand) should be recognized', + ) + assert.equal(result.needsUpdate, false) + }) + + it('should detect socket patch apply with --silent flag as configured', () => { + const packageJson = { + name: 'test', + version: '1.0.0', + scripts: { + postinstall: 'socket patch apply --silent', + }, + } + + const result = isPostinstallConfigured(packageJson) + + assert.equal(result.configured, true) + assert.equal(result.needsUpdate, false) + }) }) describe('Edge Case 5: Invalid or malformed data', () => { @@ -377,19 +411,19 @@ describe('isPostinstallConfigured', () => { describe('generateUpdatedPostinstall', () => { it('should create command for empty string', () => { const result = generateUpdatedPostinstall('') - assert.equal(result, 'npx @socketsecurity/socket-patch apply') + assert.equal(result, 'socket patch apply --silent') }) it('should create command for whitespace-only string', () => { const result = generateUpdatedPostinstall(' \n\t ') - assert.equal(result, 'npx @socketsecurity/socket-patch apply') + assert.equal(result, 'socket patch apply --silent') }) it('should prepend to existing script', () => { const result = generateUpdatedPostinstall('echo "Hello"') assert.equal( result, - 'npx @socketsecurity/socket-patch apply && echo "Hello"', + 'socket patch apply --silent && echo "Hello"', ) }) @@ -405,13 +439,25 @@ describe('generateUpdatedPostinstall', () => { assert.equal(result, existing) }) - it('should prepend to script with socket apply (main CLI)', () => { + it('should preserve socket patch apply (CLI subcommand)', () => { + const existing = 'socket patch apply' + const result = generateUpdatedPostinstall(existing) + assert.equal(result, existing) + }) + + it('should preserve socket patch apply --silent', () => { + const existing = 'socket patch apply --silent' + const result = generateUpdatedPostinstall(existing) + assert.equal(result, existing) + }) + + it('should prepend to script with socket apply (non-patch command)', () => { const existing = 'socket apply' const result = generateUpdatedPostinstall(existing) assert.equal( result, - 'npx @socketsecurity/socket-patch apply && socket apply', - 'Should add socket-patch even if socket apply is present', + 'socket patch apply --silent && socket apply', + 'Should add socket patch apply even if socket apply is present', ) }) }) @@ -430,7 +476,7 @@ describe('updatePackageJsonContent', () => { assert.ok(updated.scripts) assert.equal( updated.scripts.postinstall, - 'npx @socketsecurity/socket-patch apply', + 'socket patch apply --silent', ) }) @@ -450,7 +496,7 @@ describe('updatePackageJsonContent', () => { const updated = JSON.parse(result.content) assert.equal( updated.scripts.postinstall, - 'npx @socketsecurity/socket-patch apply', + 'socket patch apply --silent', ) assert.equal(updated.scripts.test, 'jest', 'Should preserve other scripts') assert.equal(updated.scripts.build, 'tsc', 'Should preserve other scripts') @@ -471,11 +517,11 @@ describe('updatePackageJsonContent', () => { assert.equal(result.oldScript, 'echo "Setup complete"') assert.equal( result.newScript, - 'npx @socketsecurity/socket-patch apply && echo "Setup complete"', + 'socket patch apply --silent && echo "Setup complete"', ) }) - it('should not modify when already configured', () => { + it('should not modify when already configured with legacy format', () => { const content = JSON.stringify({ name: 'test', version: '1.0.0', @@ -490,6 +536,21 @@ describe('updatePackageJsonContent', () => { assert.equal(result.content, content) }) + it('should not modify when already configured with socket patch apply', () => { + const content = JSON.stringify({ + name: 'test', + version: '1.0.0', + scripts: { + postinstall: 'socket patch apply --silent', + }, + }) + + const result = updatePackageJsonContent(content) + + assert.equal(result.modified, false) + assert.equal(result.content, content) + }) + it('should throw error for invalid JSON', () => { const content = '{ invalid json }' @@ -514,7 +575,7 @@ describe('updatePackageJsonContent', () => { const updated = JSON.parse(result.content) assert.equal( updated.scripts.postinstall, - 'npx @socketsecurity/socket-patch apply', + 'socket patch apply --silent', ) }) @@ -533,7 +594,7 @@ describe('updatePackageJsonContent', () => { const updated = JSON.parse(result.content) assert.equal( updated.scripts.postinstall, - 'npx @socketsecurity/socket-patch apply', + 'socket patch apply --silent', ) }) diff --git a/src/package-json/detect.ts b/src/package-json/detect.ts index 8549ca3..24f6cf6 100644 --- a/src/package-json/detect.ts +++ b/src/package-json/detect.ts @@ -1,9 +1,17 @@ /** - * Shared logic for detecting and generating postinstall scripts - * Used by both CLI and GitHub bot + * Shared logic for detecting and generating postinstall scripts. + * Used by both CLI and GitHub bot. */ -const SOCKET_PATCH_COMMAND = 'npx @socketsecurity/socket-patch apply' +// The command to run for applying patches via socket CLI. +const SOCKET_PATCH_COMMAND = 'socket patch apply --silent' + +// Legacy command patterns to detect existing configurations. +const LEGACY_PATCH_PATTERNS = [ + 'socket-patch apply', + 'npx @socketsecurity/socket-patch apply', + 'socket patch apply', +] export interface PostinstallStatus { configured: boolean @@ -34,11 +42,13 @@ export function isPostinstallConfigured( } const rawPostinstall = packageJson.scripts?.postinstall - // Handle non-string values (null, object, array) by treating as empty string + // Handle non-string values (null, object, array) by treating as empty string. const currentScript = typeof rawPostinstall === 'string' ? rawPostinstall : '' - // Check if socket-patch apply is already present - const configured = currentScript.includes('socket-patch apply') + // Check if any socket-patch apply variant is already present. + const configured = LEGACY_PATCH_PATTERNS.some(pattern => + currentScript.includes(pattern), + ) return { configured, @@ -48,25 +58,28 @@ export function isPostinstallConfigured( } /** - * Generate an updated postinstall script that includes socket-patch + * Generate an updated postinstall script that includes socket-patch. */ export function generateUpdatedPostinstall( currentPostinstall: string, ): string { const trimmed = currentPostinstall.trim() - // If empty, just add the socket-patch command + // If empty, just add the socket-patch command. if (!trimmed) { return SOCKET_PATCH_COMMAND } - // If socket-patch is already present, return unchanged - if (trimmed.includes('socket-patch apply')) { + // If any socket-patch variant is already present, return unchanged. + const alreadyConfigured = LEGACY_PATCH_PATTERNS.some(pattern => + trimmed.includes(pattern), + ) + if (alreadyConfigured) { return trimmed } - // Prepend socket-patch command so it runs first, then existing script - // Using && ensures existing script only runs if patching succeeds + // Prepend socket-patch command so it runs first, then existing script. + // Using && ensures existing script only runs if patching succeeds. return `${SOCKET_PATCH_COMMAND} && ${trimmed}` } diff --git a/src/run.ts b/src/run.ts new file mode 100644 index 0000000..e28a4a8 --- /dev/null +++ b/src/run.ts @@ -0,0 +1,84 @@ +import yargs from 'yargs' +import { applyCommand } from './commands/apply.js' +import { getCommand } from './commands/get.js' +import { listCommand } from './commands/list.js' +import { removeCommand } from './commands/remove.js' +import { rollbackCommand } from './commands/rollback.js' +import { repairCommand } from './commands/repair.js' +import { setupCommand } from './commands/setup.js' + +/** + * Configuration options for running socket-patch programmatically. + */ +export interface PatchOptions { + /** Socket API URL (e.g., https://api.socket.dev). */ + apiUrl?: string + /** Socket API token for authentication. */ + apiToken?: string + /** Organization slug. */ + orgSlug?: string + /** Public patch API proxy URL. */ + patchProxyUrl?: string + /** HTTP proxy URL for all requests. */ + httpProxy?: string + /** Enable debug logging. */ + debug?: boolean +} + +/** + * Run socket-patch programmatically with provided arguments and options. + * Maps options to environment variables before executing yargs commands. + * + * @param args - Command line arguments to pass to yargs (e.g., ['get', 'CVE-2021-44228']). + * @param options - Configuration options that override environment variables. + * @returns Exit code (0 for success, non-zero for failure). + */ +export async function runPatch( + args: string[], + options?: PatchOptions +): Promise { + // Map options to environment variables. + if (options?.apiUrl) { + process.env.SOCKET_API_URL = options.apiUrl + } + if (options?.apiToken) { + process.env.SOCKET_API_TOKEN = options.apiToken + } + if (options?.orgSlug) { + process.env.SOCKET_ORG_SLUG = options.orgSlug + } + if (options?.patchProxyUrl) { + process.env.SOCKET_PATCH_PROXY_URL = options.patchProxyUrl + } + if (options?.httpProxy) { + process.env.SOCKET_PATCH_HTTP_PROXY = options.httpProxy + } + if (options?.debug) { + process.env.SOCKET_PATCH_DEBUG = '1' + } + + try { + await yargs(args) + .scriptName('socket patch') + .usage('$0 [options]') + .command(getCommand) + .command(applyCommand) + .command(rollbackCommand) + .command(removeCommand) + .command(listCommand) + .command(setupCommand) + .command(repairCommand) + .demandCommand(1, 'You must specify a command') + .help() + .alias('h', 'help') + .strict() + .parse() + + return 0 + } catch (error) { + if (process.env.SOCKET_PATCH_DEBUG) { + console.error('socket-patch error:', error) + } + return 1 + } +} diff --git a/src/utils/api-client.ts b/src/utils/api-client.ts index 4f84d2e..2d58c89 100644 --- a/src/utils/api-client.ts +++ b/src/utils/api-client.ts @@ -1,9 +1,42 @@ import * as https from 'node:https' import * as http from 'node:http' -// Default public patch API URL for free patches (no auth required) +// Default public patch API URL for free patches (no auth required). const DEFAULT_PATCH_API_PROXY_URL = 'https://patches-api.socket.dev' +/** + * Check if debug mode is enabled. + */ +function isDebugEnabled(): boolean { + return process.env.SOCKET_PATCH_DEBUG === '1' || process.env.SOCKET_PATCH_DEBUG === 'true' +} + +/** + * Log debug messages when debug mode is enabled. + */ +function debugLog(message: string, ...args: unknown[]): void { + if (isDebugEnabled()) { + console.error(`[socket-patch debug] ${message}`, ...args) + } +} + +/** + * Get the HTTP proxy URL from environment variables. + * Returns undefined if no proxy is configured. + * + * Note: Full HTTP proxy support requires manual configuration. + * Node.js native http/https modules don't support proxies natively. + * For proxy support, set NODE_EXTRA_CA_CERTS and configure your + * system/corporate proxy settings. + */ +function getHttpProxyUrl(): string | undefined { + return process.env.SOCKET_PATCH_HTTP_PROXY || + process.env.HTTPS_PROXY || + process.env.https_proxy || + process.env.HTTP_PROXY || + process.env.http_proxy +} + // Full patch response with blob content (from view endpoint) export interface PatchResponse { uuid: string @@ -86,10 +119,17 @@ export class APIClient { } /** - * Make a GET request to the API + * Make a GET request to the API. */ private async get(path: string): Promise { const url = `${this.apiUrl}${path}` + debugLog(`GET ${url}`) + + // Log proxy warning if configured but not natively supported. + const proxyUrl = getHttpProxyUrl() + if (proxyUrl) { + debugLog(`HTTP proxy detected: ${proxyUrl} (Note: native http/https modules don't support proxies directly)`) + } return new Promise((resolve, reject) => { const urlObj = new URL(url) @@ -101,7 +141,7 @@ export class APIClient { 'User-Agent': 'SocketPatchCLI/1.0', } - // Only add auth header if we have a token (not using public proxy) + // Only add auth header if we have a token (not using public proxy). if (this.apiToken) { headers['Authorization'] = `Bearer ${this.apiToken}` } From c8e9db51eef0c517b7dfccf81afd9b2b659b2a7f Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 19 Dec 2025 11:11:28 -0500 Subject: [PATCH 2/3] Exit successfully when no .socket folder exists The apply command now exits with code 0 when no manifest is found, treating it as a successful no-op. This allows postinstall scripts to run without failing when no patches are configured. --- src/commands/apply.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/commands/apply.ts b/src/commands/apply.ts index c886c4f..e183863 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -228,14 +228,15 @@ export const applyCommand: CommandModule<{}, ApplyArgs> = { ? argv['manifest-path'] : path.join(argv.cwd, argv['manifest-path']) - // Check if manifest exists + // Check if manifest exists - exit successfully if no .socket folder is set up try { await fs.access(manifestPath) } catch { + // No manifest means no patches to apply - this is a successful no-op if (!argv.silent) { - console.error(`Manifest not found at ${manifestPath}`) + console.log('No .socket folder found, skipping patch application.') } - process.exit(1) + process.exit(0) } const { success, results } = await applyPatches( From 5dbd2bd4fa48c54b20e0647dce3fc59e8338a40a Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Fri, 19 Dec 2025 19:22:12 -0500 Subject: [PATCH 3/3] Add CLI telemetry for patch lifecycle events MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track patch apply, remove, and rollback operations via telemetry. Events are sent to: - Authenticated: /v0/orgs/{org}/telemetry when API token is available - Public proxy: patches-api.socket.dev/patch/telemetry for free tier Telemetry can be disabled via SOCKET_PATCH_TELEMETRY_DISABLED=1 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/commands/apply.ts | 25 +++ src/commands/remove.ts | 25 +++ src/commands/rollback.ts | 36 ++++ src/utils/telemetry.ts | 410 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 496 insertions(+) create mode 100644 src/utils/telemetry.ts diff --git a/src/commands/apply.ts b/src/commands/apply.ts index e183863..b8278ed 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -21,6 +21,10 @@ import { formatFetchResult, } from '../utils/blob-fetcher.js' import { getGlobalPrefix } from '../utils/global-packages.js' +import { + trackPatchApplied, + trackPatchApplyFailed, +} from '../utils/telemetry.js' interface ApplyArgs { cwd: string @@ -223,6 +227,10 @@ export const applyCommand: CommandModule<{}, ApplyArgs> = { .example('$0 apply --dry-run', 'Preview patches without applying') }, handler: async argv => { + // Get API credentials for authenticated telemetry (optional). + const apiToken = process.env['SOCKET_API_TOKEN'] + const orgSlug = process.env['SOCKET_ORG_SLUG'] + try { const manifestPath = path.isAbsolute(argv['manifest-path']) ? argv['manifest-path'] @@ -276,8 +284,25 @@ export const applyCommand: CommandModule<{}, ApplyArgs> = { } } + // Track telemetry event. + const patchedCount = results.filter(r => r.success && r.filesPatched.length > 0).length + if (success) { + await trackPatchApplied(patchedCount, argv['dry-run'], apiToken, orgSlug) + } else { + await trackPatchApplyFailed( + new Error('One or more patches failed to apply'), + argv['dry-run'], + apiToken, + orgSlug, + ) + } + process.exit(success ? 0 : 1) } catch (err) { + // Track telemetry for unexpected errors. + const error = err instanceof Error ? err : new Error(String(err)) + await trackPatchApplyFailed(error, argv['dry-run'], apiToken, orgSlug) + if (!argv.silent) { const errorMessage = err instanceof Error ? err.message : String(err) console.error(`Error: ${errorMessage}`) diff --git a/src/commands/remove.ts b/src/commands/remove.ts index 6bc8d73..defac49 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -11,6 +11,10 @@ import { formatCleanupResult, } from '../utils/cleanup-blobs.js' import { rollbackPatches } from './rollback.js' +import { + trackPatchRemoved, + trackPatchRemoveFailed, +} from '../utils/telemetry.js' interface RemoveArgs { identifier: string @@ -118,6 +122,10 @@ export const removeCommand: CommandModule<{}, RemoveArgs> = { ) }, handler: async argv => { + // Get API credentials for authenticated telemetry (optional). + const apiToken = process.env['SOCKET_API_TOKEN'] + const orgSlug = process.env['SOCKET_ORG_SLUG'] + try { const manifestPath = path.isAbsolute(argv['manifest-path']) ? argv['manifest-path'] @@ -147,6 +155,11 @@ export const removeCommand: CommandModule<{}, RemoveArgs> = { ) if (!rollbackSuccess) { + await trackPatchRemoveFailed( + new Error('Rollback failed during patch removal'), + apiToken, + orgSlug, + ) console.error( '\nRollback failed. Use --skip-rollback to remove from manifest without restoring files.', ) @@ -184,6 +197,11 @@ export const removeCommand: CommandModule<{}, RemoveArgs> = { ) if (notFound) { + await trackPatchRemoveFailed( + new Error(`No patch found matching identifier: ${argv.identifier}`), + apiToken, + orgSlug, + ) console.error(`No patch found matching identifier: ${argv.identifier}`) process.exit(1) } @@ -203,8 +221,15 @@ export const removeCommand: CommandModule<{}, RemoveArgs> = { console.log(`\n${formatCleanupResult(cleanupResult, false)}`) } + // Track successful removal. + await trackPatchRemoved(removed.length, apiToken, orgSlug) + process.exit(0) } catch (err) { + // Track telemetry for unexpected errors. + const error = err instanceof Error ? err : new Error(String(err)) + await trackPatchRemoveFailed(error, apiToken, orgSlug) + const errorMessage = err instanceof Error ? err.message : String(err) console.error(`Error: ${errorMessage}`) process.exit(1) diff --git a/src/commands/rollback.ts b/src/commands/rollback.ts index b62cf2b..dc2f72b 100644 --- a/src/commands/rollback.ts +++ b/src/commands/rollback.ts @@ -20,6 +20,10 @@ import { } from '../utils/blob-fetcher.js' import { getGlobalPrefix } from '../utils/global-packages.js' import { getAPIClientFromEnv } from '../utils/api-client.js' +import { + trackPatchRolledBack, + trackPatchRollbackFailed, +} from '../utils/telemetry.js' interface RollbackArgs { identifier?: string @@ -363,6 +367,10 @@ export const rollbackCommand: CommandModule<{}, RollbackArgs> = { }) }, handler: async argv => { + // Get API credentials for authenticated telemetry (optional). + const apiToken = argv['api-token'] || process.env['SOCKET_API_TOKEN'] + const orgSlug = argv.org || process.env['SOCKET_ORG_SLUG'] + try { // Handle one-off mode (no manifest required) if (argv['one-off']) { @@ -377,6 +385,18 @@ export const rollbackCommand: CommandModule<{}, RollbackArgs> = { argv['api-url'], argv['api-token'], ) + + // Track telemetry for one-off rollback. + if (success) { + await trackPatchRolledBack(1, apiToken, orgSlug) + } else { + await trackPatchRollbackFailed( + new Error('One-off rollback failed'), + apiToken, + orgSlug, + ) + } + process.exit(success ? 0 : 1) } @@ -444,8 +464,24 @@ export const rollbackCommand: CommandModule<{}, RollbackArgs> = { } } + // Track telemetry event. + const rolledBackCount = results.filter(r => r.success && r.filesRolledBack.length > 0).length + if (success) { + await trackPatchRolledBack(rolledBackCount, apiToken, orgSlug) + } else { + await trackPatchRollbackFailed( + new Error('One or more rollbacks failed'), + apiToken, + orgSlug, + ) + } + process.exit(success ? 0 : 1) } catch (err) { + // Track telemetry for unexpected errors. + const error = err instanceof Error ? err : new Error(String(err)) + await trackPatchRollbackFailed(error, apiToken, orgSlug) + if (!argv.silent) { const errorMessage = err instanceof Error ? err.message : String(err) console.error(`Error: ${errorMessage}`) diff --git a/src/utils/telemetry.ts b/src/utils/telemetry.ts new file mode 100644 index 0000000..1d978b9 --- /dev/null +++ b/src/utils/telemetry.ts @@ -0,0 +1,410 @@ +/** + * Telemetry module for socket-patch CLI. + * Collects anonymous usage data for patch lifecycle events. + * + * Telemetry can be disabled via: + * - Environment variable: SOCKET_PATCH_TELEMETRY_DISABLED=1 + * - Running in test environment: VITEST=true + * + * Events are sent to: + * - Authenticated: https://api.socket.dev/v0/orgs/{org}/telemetry + * - Public proxy: https://patches-api.socket.dev/patch/telemetry + */ + +import * as https from 'node:https' +import * as http from 'node:http' +import * as os from 'node:os' +import * as crypto from 'node:crypto' + +// Default public patch API URL for free tier telemetry. +const DEFAULT_PATCH_API_PROXY_URL = 'https://patches-api.socket.dev' + +// Package version - updated during build. +const PACKAGE_VERSION = '1.0.0' + +/** + * Check if telemetry is disabled via environment variables. + */ +function isTelemetryDisabled(): boolean { + return ( + process.env['SOCKET_PATCH_TELEMETRY_DISABLED'] === '1' || + process.env['SOCKET_PATCH_TELEMETRY_DISABLED'] === 'true' || + process.env['VITEST'] === 'true' + ) +} + +/** + * Check if debug mode is enabled. + */ +function isDebugEnabled(): boolean { + return ( + process.env['SOCKET_PATCH_DEBUG'] === '1' || + process.env['SOCKET_PATCH_DEBUG'] === 'true' + ) +} + +/** + * Log debug messages when debug mode is enabled. + */ +function debugLog(message: string, ...args: unknown[]): void { + if (isDebugEnabled()) { + console.error(`[socket-patch telemetry] ${message}`, ...args) + } +} + +/** + * Generate a unique session ID for the current CLI invocation. + * This is shared across all telemetry events in a single CLI run. + */ +const SESSION_ID = crypto.randomUUID() + +/** + * Telemetry context describing the execution environment. + */ +export interface PatchTelemetryContext { + version: string + platform: string + node_version: string + arch: string + command: string +} + +/** + * Error details for telemetry events. + */ +export interface PatchTelemetryError { + type: string + message: string | undefined +} + +/** + * Telemetry event types for patch lifecycle. + */ +export type PatchTelemetryEventType = + | 'patch_applied' + | 'patch_apply_failed' + | 'patch_removed' + | 'patch_remove_failed' + | 'patch_rolled_back' + | 'patch_rollback_failed' + +/** + * Telemetry event structure for patch operations. + */ +export interface PatchTelemetryEvent { + event_sender_created_at: string + event_type: PatchTelemetryEventType + context: PatchTelemetryContext + session_id: string + metadata?: Record + error?: PatchTelemetryError +} + +/** + * Options for tracking a patch event. + */ +export interface TrackPatchEventOptions { + /** The type of event being tracked. */ + eventType: PatchTelemetryEventType + /** The CLI command being executed (e.g., 'apply', 'remove', 'rollback'). */ + command: string + /** Optional metadata to include with the event. */ + metadata?: Record + /** Optional error information if the operation failed. */ + error?: Error + /** Optional API token for authenticated telemetry endpoint. */ + apiToken?: string + /** Optional organization slug for authenticated telemetry endpoint. */ + orgSlug?: string +} + +/** + * Build the telemetry context for the current environment. + */ +function buildTelemetryContext(command: string): PatchTelemetryContext { + return { + version: PACKAGE_VERSION, + platform: process.platform, + node_version: process.version, + arch: os.arch(), + command, + } +} + +/** + * Sanitize error for telemetry. + * Removes sensitive paths and information. + */ +function sanitizeError(error: Error): PatchTelemetryError { + const homeDir = os.homedir() + let message = error.message + if (homeDir) { + message = message.replace(new RegExp(homeDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), '~') + } + return { + type: error.constructor.name, + message, + } +} + +/** + * Build a telemetry event from the given options. + */ +function buildTelemetryEvent(options: TrackPatchEventOptions): PatchTelemetryEvent { + const event: PatchTelemetryEvent = { + event_sender_created_at: new Date().toISOString(), + event_type: options.eventType, + context: buildTelemetryContext(options.command), + session_id: SESSION_ID, + } + + if (options.metadata && Object.keys(options.metadata).length > 0) { + event.metadata = options.metadata + } + + if (options.error) { + event.error = sanitizeError(options.error) + } + + return event +} + +/** + * Send telemetry event to the API. + * Returns a promise that resolves when the request completes. + * Errors are logged but never thrown - telemetry should never block CLI operations. + */ +async function sendTelemetryEvent( + event: PatchTelemetryEvent, + apiToken?: string, + orgSlug?: string, +): Promise { + // Determine the telemetry endpoint based on authentication. + let url: string + let useAuth = false + + if (apiToken && orgSlug) { + // Authenticated endpoint. + const apiUrl = process.env['SOCKET_API_URL'] || 'https://api.socket.dev' + url = `${apiUrl}/v0/orgs/${orgSlug}/telemetry` + useAuth = true + } else { + // Public proxy endpoint. + const proxyUrl = process.env['SOCKET_PATCH_PROXY_URL'] || DEFAULT_PATCH_API_PROXY_URL + url = `${proxyUrl}/patch/telemetry` + } + + debugLog(`Sending telemetry to ${url}`, event) + + return new Promise(resolve => { + const body = JSON.stringify(event) + const urlObj = new URL(url) + const isHttps = urlObj.protocol === 'https:' + const httpModule = isHttps ? https : http + + const headers: Record = { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body).toString(), + 'User-Agent': 'SocketPatchCLI/1.0', + } + + if (useAuth && apiToken) { + headers['Authorization'] = `Bearer ${apiToken}` + } + + const requestOptions: https.RequestOptions = { + method: 'POST', + headers, + timeout: 5000, // 5 second timeout. + } + + const req = httpModule.request(urlObj, requestOptions, res => { + // Consume response body to free resources. + res.on('data', () => {}) + res.on('end', () => { + if (res.statusCode === 200) { + debugLog('Telemetry sent successfully') + } else { + debugLog(`Telemetry request returned status ${res.statusCode}`) + } + resolve() + }) + }) + + req.on('error', err => { + debugLog(`Telemetry request failed: ${err.message}`) + resolve() + }) + + req.on('timeout', () => { + debugLog('Telemetry request timed out') + req.destroy() + resolve() + }) + + req.write(body) + req.end() + }) +} + +/** + * Track a patch lifecycle event. + * + * This function is non-blocking and will never throw errors. + * Telemetry failures are logged in debug mode but don't affect CLI operation. + * + * @param options - Event tracking options. + * @returns Promise that resolves when the event is sent (or immediately if telemetry is disabled). + * + * @example + * ```typescript + * // Track successful patch application. + * await trackPatchEvent({ + * eventType: 'patch_applied', + * command: 'apply', + * metadata: { + * patches_count: 5, + * dry_run: false, + * }, + * }) + * + * // Track failed patch application. + * await trackPatchEvent({ + * eventType: 'patch_apply_failed', + * command: 'apply', + * error: new Error('Failed to apply patch'), + * metadata: { + * patches_count: 0, + * dry_run: false, + * }, + * }) + * ``` + */ +export async function trackPatchEvent(options: TrackPatchEventOptions): Promise { + if (isTelemetryDisabled()) { + debugLog('Telemetry is disabled, skipping event') + return + } + + try { + const event = buildTelemetryEvent(options) + await sendTelemetryEvent(event, options.apiToken, options.orgSlug) + } catch (err) { + // Telemetry should never block CLI operations. + debugLog(`Failed to track event: ${err instanceof Error ? err.message : String(err)}`) + } +} + +/** + * Convenience function to track a successful patch application. + */ +export async function trackPatchApplied( + patchesCount: number, + dryRun: boolean, + apiToken?: string, + orgSlug?: string, +): Promise { + await trackPatchEvent({ + eventType: 'patch_applied', + command: 'apply', + metadata: { + patches_count: patchesCount, + dry_run: dryRun, + }, + apiToken, + orgSlug, + }) +} + +/** + * Convenience function to track a failed patch application. + */ +export async function trackPatchApplyFailed( + error: Error, + dryRun: boolean, + apiToken?: string, + orgSlug?: string, +): Promise { + await trackPatchEvent({ + eventType: 'patch_apply_failed', + command: 'apply', + error, + metadata: { + dry_run: dryRun, + }, + apiToken, + orgSlug, + }) +} + +/** + * Convenience function to track a successful patch removal. + */ +export async function trackPatchRemoved( + removedCount: number, + apiToken?: string, + orgSlug?: string, +): Promise { + await trackPatchEvent({ + eventType: 'patch_removed', + command: 'remove', + metadata: { + removed_count: removedCount, + }, + apiToken, + orgSlug, + }) +} + +/** + * Convenience function to track a failed patch removal. + */ +export async function trackPatchRemoveFailed( + error: Error, + apiToken?: string, + orgSlug?: string, +): Promise { + await trackPatchEvent({ + eventType: 'patch_remove_failed', + command: 'remove', + error, + apiToken, + orgSlug, + }) +} + +/** + * Convenience function to track a successful patch rollback. + */ +export async function trackPatchRolledBack( + rolledBackCount: number, + apiToken?: string, + orgSlug?: string, +): Promise { + await trackPatchEvent({ + eventType: 'patch_rolled_back', + command: 'rollback', + metadata: { + rolled_back_count: rolledBackCount, + }, + apiToken, + orgSlug, + }) +} + +/** + * Convenience function to track a failed patch rollback. + */ +export async function trackPatchRollbackFailed( + error: Error, + apiToken?: string, + orgSlug?: string, +): Promise { + await trackPatchEvent({ + eventType: 'patch_rollback_failed', + command: 'rollback', + error, + apiToken, + orgSlug, + }) +}