From 97377e89031a4ca39f4b13810a07ec89dce09cd8 Mon Sep 17 00:00:00 2001 From: Mikola Lysenko Date: Thu, 18 Dec 2025 14:52:17 -0500 Subject: [PATCH] Improve CLI UX with auto-apply, global packages, and one-off mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename 'download' command to 'get' (with 'download' as hidden alias) - Auto-apply patches after download by default, with --no-apply to opt out - Add --global/-g flag to apply/rollback/remove patches on global npm packages - Add --global-prefix option for custom global node_modules path (supports yarn/pnpm, enables safe testing) - Add --one-off mode for ephemeral patching without saving to .socket folder - Add global-packages.ts utility for detecting npm/yarn/pnpm global paths 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/cli.ts | 8 +- src/commands/apply.ts | 42 ++- src/commands/{download.ts => get.ts} | 452 +++++++++++++++++++++++++-- src/commands/remove.test.ts | 8 + src/commands/remove.ts | 18 ++ src/commands/rollback.test.ts | 12 + src/commands/rollback.ts | 282 ++++++++++++++++- src/utils/global-packages.ts | 92 ++++++ 8 files changed, 879 insertions(+), 35 deletions(-) rename src/commands/{download.ts => get.ts} (65%) create mode 100644 src/utils/global-packages.ts diff --git a/src/cli.ts b/src/cli.ts index af94df9d1..4544b1f80 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3,7 +3,7 @@ import yargs from 'yargs' import { hideBin } from 'yargs/helpers' import { applyCommand } from './commands/apply.js' -import { downloadCommand } from './commands/download.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' @@ -14,12 +14,12 @@ async function main(): Promise { await yargs(hideBin(process.argv)) .scriptName('socket-patch') .usage('$0 [options]') + .command(getCommand) .command(applyCommand) .command(rollbackCommand) - .command(setupCommand) - .command(downloadCommand) - .command(listCommand) .command(removeCommand) + .command(listCommand) + .command(setupCommand) .command(repairCommand) .demandCommand(1, 'You must specify a command') .help() diff --git a/src/commands/apply.ts b/src/commands/apply.ts index 508ba87d8..c886c4fbe 100644 --- a/src/commands/apply.ts +++ b/src/commands/apply.ts @@ -20,6 +20,7 @@ import { fetchMissingBlobs, formatFetchResult, } from '../utils/blob-fetcher.js' +import { getGlobalPrefix } from '../utils/global-packages.js' interface ApplyArgs { cwd: string @@ -27,6 +28,8 @@ interface ApplyArgs { silent: boolean 'manifest-path': string offline: boolean + global: boolean + 'global-prefix'?: string } async function applyPatches( @@ -35,6 +38,8 @@ async function applyPatches( dryRun: boolean, silent: boolean, offline: boolean, + useGlobal: boolean, + globalPrefix?: string, ): Promise<{ success: boolean; results: ApplyResult[] }> { // Read and parse manifest const manifestContent = await fs.readFile(manifestPath, 'utf-8') @@ -89,12 +94,27 @@ async function applyPatches( } } - // Find all node_modules directories - const nodeModulesPaths = await findNodeModules(cwd) + // Find node_modules directories + let nodeModulesPaths: string[] + if (useGlobal || globalPrefix) { + try { + nodeModulesPaths = [getGlobalPrefix(globalPrefix)] + if (!silent) { + console.log(`Using global npm packages at: ${nodeModulesPaths[0]}`) + } + } catch (error) { + if (!silent) { + console.error('Failed to find global npm packages:', error instanceof Error ? error.message : String(error)) + } + return { success: false, results: [] } + } + } else { + nodeModulesPaths = await findNodeModules(cwd) + } if (nodeModulesPaths.length === 0) { if (!silent) { - console.error('No node_modules directories found') + console.error(useGlobal || globalPrefix ? 'No global npm packages found' : 'No node_modules directories found') } return { success: false, results: [] } } @@ -187,6 +207,20 @@ export const applyCommand: CommandModule<{}, ApplyArgs> = { type: 'boolean', default: false, }) + .option('global', { + alias: 'g', + describe: 'Apply patches to globally installed npm packages', + type: 'boolean', + default: false, + }) + .option('global-prefix', { + describe: 'Custom path to global node_modules (overrides auto-detection, useful for yarn/pnpm)', + type: 'string', + }) + .example('$0 apply', 'Apply all patches to local packages') + .example('$0 apply --global', 'Apply patches to global npm packages') + .example('$0 apply --global-prefix /custom/path', 'Apply patches to custom global location') + .example('$0 apply --dry-run', 'Preview patches without applying') }, handler: async argv => { try { @@ -210,6 +244,8 @@ export const applyCommand: CommandModule<{}, ApplyArgs> = { argv['dry-run'], argv.silent, argv.offline, + argv.global, + argv['global-prefix'], ) // Print results if not silent diff --git a/src/commands/download.ts b/src/commands/get.ts similarity index 65% rename from src/commands/download.ts rename to src/commands/get.ts index b4eb6141e..16789aac4 100644 --- a/src/commands/download.ts +++ b/src/commands/get.ts @@ -1,6 +1,7 @@ import * as fs from 'fs/promises' import * as path from 'path' import * as readline from 'readline' +import * as os from 'os' import type { CommandModule } from 'yargs' import { PatchManifestSchema } from '../schema/manifest-schema.js' import { @@ -19,6 +20,18 @@ import { type EnumeratedPackage, } from '../utils/enumerate-packages.js' import { fuzzyMatchPackages, isPurl } from '../utils/fuzzy-match.js' +import { getGlobalPrefix } from '../utils/global-packages.js' +import { + findNodeModules, + findPackagesForPatches, + applyPackagePatch, +} from '../patch/apply.js' +import { rollbackPackagePatch } from '../patch/rollback.js' +import { + getMissingBlobs, + fetchMissingBlobs, + formatFetchResult, +} from '../utils/blob-fetcher.js' /** * Represents a package that has available patches with CVE information @@ -62,10 +75,9 @@ function parsePurl(purl: string): { packageDir: string; version: string } | null * NOT O(m) where m = total packages in node_modules. */ async function findInstalledPurls( - cwd: string, + nodeModulesPath: string, purls: string[], ): Promise> { - const nodeModulesPath = path.join(cwd, 'node_modules') const installedPurls = new Set() // Group PURLs by package directory to handle multiple versions of same package @@ -187,7 +199,7 @@ async function findPackagesWithPatches( return packagesWithPatches } -interface DownloadArgs { +interface GetArgs { identifier: string org?: string cwd: string @@ -198,6 +210,10 @@ interface DownloadArgs { yes?: boolean 'api-url'?: string 'api-token'?: string + 'no-apply'?: boolean + global?: boolean + 'global-prefix'?: string + 'one-off'?: boolean } /** @@ -384,7 +400,286 @@ async function savePatch( return true } -async function downloadPatches(args: DownloadArgs): Promise { +/** + * Apply patches after downloading + */ +async function applyDownloadedPatches( + cwd: string, + manifestPath: string, + silent: boolean, + useGlobal: boolean, + globalPrefix?: string, +): Promise { + // Read and parse manifest + const manifestContent = await fs.readFile(manifestPath, 'utf-8') + const manifestData = JSON.parse(manifestContent) + const manifest = PatchManifestSchema.parse(manifestData) + + // Find .socket directory (contains blobs) + const socketDir = path.dirname(manifestPath) + const blobsPath = path.join(socketDir, 'blobs') + + // Ensure blobs directory exists + await fs.mkdir(blobsPath, { recursive: true }) + + // Check for and download missing blobs + const missingBlobs = await getMissingBlobs(manifest, blobsPath) + if (missingBlobs.size > 0) { + if (!silent) { + console.log(`Downloading ${missingBlobs.size} missing blob(s)...`) + } + + const fetchResult = await fetchMissingBlobs(manifest, blobsPath, undefined, { + onProgress: silent + ? undefined + : (hash, index, total) => { + process.stdout.write( + `\r Downloading ${index}/${total}: ${hash.slice(0, 12)}...`.padEnd(60), + ) + }, + }) + + if (!silent) { + // Clear progress line + process.stdout.write('\r' + ' '.repeat(60) + '\r') + console.log(formatFetchResult(fetchResult)) + } + + if (fetchResult.failed > 0) { + if (!silent) { + console.error('Some blobs could not be downloaded. Cannot apply patches.') + } + return false + } + } + + // Find node_modules directories + let nodeModulesPaths: string[] + if (useGlobal || globalPrefix) { + try { + nodeModulesPaths = [getGlobalPrefix(globalPrefix)] + } catch (error) { + if (!silent) { + console.error('Failed to find global npm packages:', error instanceof Error ? error.message : String(error)) + } + return false + } + } else { + nodeModulesPaths = await findNodeModules(cwd) + } + + if (nodeModulesPaths.length === 0) { + if (!silent) { + console.error(useGlobal || globalPrefix ? 'No global npm packages found' : 'No node_modules directories found') + } + return false + } + + // Find all packages that need patching + const allPackages = new Map() + for (const nmPath of nodeModulesPaths) { + const packages = await findPackagesForPatches(nmPath, manifest) + for (const [purl, location] of packages) { + if (!allPackages.has(purl)) { + allPackages.set(purl, location.path) + } + } + } + + if (allPackages.size === 0) { + if (!silent) { + console.log('No packages found that match available patches') + } + return true + } + + // Apply patches to each package + let hasErrors = false + const patchedPackages: string[] = [] + const alreadyPatched: string[] = [] + + for (const [purl, pkgPath] of allPackages) { + const patch = manifest.patches[purl] + if (!patch) continue + + const result = await applyPackagePatch( + purl, + pkgPath, + patch.files, + blobsPath, + false, + ) + + if (!result.success) { + hasErrors = true + if (!silent) { + console.error(`Failed to patch ${purl}: ${result.error}`) + } + } else if (result.filesPatched.length > 0) { + patchedPackages.push(purl) + } else if (result.filesVerified.every(f => f.status === 'already-patched')) { + alreadyPatched.push(purl) + } + } + + // Print results + if (!silent) { + if (patchedPackages.length > 0) { + console.log(`\nPatched packages:`) + for (const pkg of patchedPackages) { + console.log(` ${pkg}`) + } + } + if (alreadyPatched.length > 0) { + console.log(`\nAlready patched:`) + for (const pkg of alreadyPatched) { + console.log(` ${pkg}`) + } + } + } + + return !hasErrors +} + +/** + * Handle one-off patch application (no manifest storage) + */ +async function applyOneOffPatch( + patch: PatchResponse, + useGlobal: boolean, + cwd: string, + silent: boolean, + globalPrefix?: string, +): Promise<{ success: boolean; rollback?: () => Promise }> { + // Find the package location + let nodeModulesPath: string + if (useGlobal || globalPrefix) { + try { + nodeModulesPath = getGlobalPrefix(globalPrefix) + } catch (error) { + if (!silent) { + console.error('Failed to find global npm packages:', error instanceof Error ? error.message : String(error)) + } + return { success: false } + } + } else { + nodeModulesPath = path.join(cwd, 'node_modules') + } + + // Parse PURL to get package directory + const parsed = parsePurl(patch.purl) + if (!parsed) { + if (!silent) { + console.error(`Invalid PURL format: ${patch.purl}`) + } + return { success: false } + } + + const pkgPath = path.join(nodeModulesPath, parsed.packageDir) + + // Verify package exists + try { + const pkgJsonPath = path.join(pkgPath, 'package.json') + const pkgJsonContent = await fs.readFile(pkgJsonPath, 'utf-8') + const pkgJson = JSON.parse(pkgJsonContent) + if (pkgJson.version !== parsed.version) { + if (!silent) { + console.error(`Version mismatch: installed ${pkgJson.version}, patch is for ${parsed.version}`) + } + return { success: false } + } + } catch { + if (!silent) { + console.error(`Package not found: ${parsed.packageDir}`) + } + return { success: false } + } + + // Create temporary directory for blobs + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-patch-')) + const tempBlobsDir = path.join(tempDir, 'blobs') + await fs.mkdir(tempBlobsDir, { recursive: true }) + + // Store beforeHash blobs in temp directory for rollback + const beforeBlobs = new Map() + for (const [, fileInfo] of Object.entries(patch.files)) { + if (fileInfo.beforeBlobContent && fileInfo.beforeHash) { + const blobBuffer = Buffer.from(fileInfo.beforeBlobContent, 'base64') + beforeBlobs.set(fileInfo.beforeHash, blobBuffer) + await fs.writeFile(path.join(tempBlobsDir, fileInfo.beforeHash), blobBuffer) + } + if (fileInfo.blobContent && fileInfo.afterHash) { + const blobBuffer = Buffer.from(fileInfo.blobContent, 'base64') + await fs.writeFile(path.join(tempBlobsDir, fileInfo.afterHash), blobBuffer) + } + } + + // Build files record for applyPackagePatch + const files: Record = {} + for (const [filePath, fileInfo] of Object.entries(patch.files)) { + if (fileInfo.beforeHash && fileInfo.afterHash) { + files[filePath] = { + beforeHash: fileInfo.beforeHash, + afterHash: fileInfo.afterHash, + } + } + } + + // Apply the patch + const result = await applyPackagePatch( + patch.purl, + pkgPath, + files, + tempBlobsDir, + false, + ) + + if (!result.success) { + if (!silent) { + console.error(`Failed to patch ${patch.purl}: ${result.error}`) + } + // Clean up temp directory + await fs.rm(tempDir, { recursive: true, force: true }) + return { success: false } + } + + if (!silent) { + if (result.filesPatched.length > 0) { + console.log(`\nPatched ${patch.purl}`) + } else if (result.filesVerified.every(f => f.status === 'already-patched')) { + console.log(`\n${patch.purl} is already patched`) + } + } + + // Return rollback function + const rollback = async () => { + if (!silent) { + console.log(`Rolling back ${patch.purl}...`) + } + const rollbackResult = await rollbackPackagePatch( + patch.purl, + pkgPath, + files, + tempBlobsDir, + false, + ) + if (rollbackResult.success) { + if (!silent) { + console.log(`Rolled back ${patch.purl}`) + } + } else { + if (!silent) { + console.error(`Failed to rollback: ${rollbackResult.error}`) + } + } + // Clean up temp directory + await fs.rm(tempDir, { recursive: true, force: true }) + } + + return { success: true, rollback } +} + +async function getPatches(args: GetArgs): Promise { const { identifier, org: orgSlug, @@ -396,6 +691,10 @@ async function downloadPatches(args: DownloadArgs): Promise { yes: skipConfirmation, 'api-url': apiUrl, 'api-token': apiToken, + 'no-apply': noApply, + global: useGlobal, + 'global-prefix': globalPrefix, + 'one-off': oneOff, } = args // Override environment variables if CLI options are provided @@ -419,6 +718,21 @@ async function downloadPatches(args: DownloadArgs): Promise { // The org slug to use (null when using public proxy) const effectiveOrgSlug = usePublicProxy ? null : orgSlug ?? null + // Determine node_modules path for package lookups + let nodeModulesPath: string + if (useGlobal || globalPrefix) { + try { + nodeModulesPath = getGlobalPrefix(globalPrefix) + console.log(`Using global npm packages at: ${nodeModulesPath}`) + } catch (error) { + throw new Error( + `Failed to find global npm packages: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } else { + nodeModulesPath = path.join(cwd, 'node_modules') + } + // Determine identifier type let idType: IdentifierType if (forceId) { @@ -451,6 +765,16 @@ async function downloadPatches(args: DownloadArgs): Promise { return true } + // Handle one-off mode + if (oneOff) { + const { success, rollback } = await applyOneOffPatch(patch, useGlobal ?? false, cwd, false, globalPrefix) + if (success && rollback) { + console.log('\nPatch applied (one-off mode). The patch will persist until you reinstall the package.') + console.log('To rollback, use: socket-patch rollback --one-off ' + identifier + (useGlobal ? ' --global' : '')) + } + return success + } + // Prepare .socket directory const socketDir = path.join(cwd, '.socket') const blobsDir = path.join(socketDir, 'blobs') @@ -482,6 +806,15 @@ async function downloadPatches(args: DownloadArgs): Promise { console.log(` Skipped: 1 (already exists)`) } + // Auto-apply unless --no-apply is specified + if (!noApply) { + console.log('\nApplying patches...') + const applySuccess = await applyDownloadedPatches(cwd, manifestPath, false, useGlobal ?? false, globalPrefix) + if (!applySuccess) { + console.error('\nSome patches could not be applied.') + } + } + return true } @@ -506,21 +839,26 @@ async function downloadPatches(args: DownloadArgs): Promise { } case 'package': { // Enumerate packages from node_modules and fuzzy match - console.log(`Enumerating packages in ${cwd}...`) - const packages = await enumerateNodeModules(cwd) + const enumPath = useGlobal ? nodeModulesPath : cwd + console.log(`Enumerating packages in ${enumPath}...`) + const packages = useGlobal + ? await enumerateNodeModules(path.dirname(nodeModulesPath)) + : await enumerateNodeModules(cwd) if (packages.length === 0) { - console.log('No packages found in node_modules. Run npm/yarn/pnpm install first.') + console.log(useGlobal + ? 'No global packages found.' + : 'No packages found in node_modules. Run npm/yarn/pnpm install first.') return true } - console.log(`Found ${packages.length} packages in node_modules`) + console.log(`Found ${packages.length} packages`) // Fuzzy match against the identifier let matches = fuzzyMatchPackages(identifier, packages) if (matches.length === 0) { - console.log(`No packages matching "${identifier}" found in node_modules.`) + console.log(`No packages matching "${identifier}" found.`) return true } @@ -632,7 +970,7 @@ async function downloadPatches(args: DownloadArgs): Promise { if (idType === 'cve' || idType === 'ghsa') { console.log(`Checking which packages are installed...`) const searchPurls = searchResults.map(patch => patch.purl) - const installedPurls = await findInstalledPurls(cwd, searchPurls) + const installedPurls = await findInstalledPurls(nodeModulesPath, searchPurls) filteredResults = searchResults.filter(patch => installedPurls.has(patch.purl)) notInstalledCount = searchResults.length - filteredResults.length @@ -683,6 +1021,26 @@ async function downloadPatches(args: DownloadArgs): Promise { } } + // Handle one-off mode for search results + if (oneOff) { + // For one-off mode with multiple patches, apply the first one + const patchToApply = accessiblePatches[0] + console.log(`\nFetching and applying patch for ${patchToApply.purl}...`) + + const fullPatch = await apiClient.fetchPatch(effectiveOrgSlug, patchToApply.uuid) + if (!fullPatch) { + console.error(`Could not fetch patch details for ${patchToApply.uuid}`) + return false + } + + const { success, rollback } = await applyOneOffPatch(fullPatch, useGlobal ?? false, cwd, false, globalPrefix) + if (success && rollback) { + console.log('\nPatch applied (one-off mode). The patch will persist until you reinstall the package.') + console.log('To rollback, use: socket-patch rollback --one-off ' + patchToApply.uuid + (useGlobal ? ' --global' : '')) + } + return success + } + // Prepare .socket directory const socketDir = path.join(cwd, '.socket') const blobsDir = path.join(socketDir, 'blobs') @@ -745,12 +1103,22 @@ async function downloadPatches(args: DownloadArgs): Promise { console.log(`\n${formatCleanupResult(cleanupResult, false)}`) } + // Auto-apply unless --no-apply is specified + if (!noApply && patchesAdded > 0) { + console.log('\nApplying patches...') + const applySuccess = await applyDownloadedPatches(cwd, manifestPath, false, useGlobal ?? false, globalPrefix) + if (!applySuccess) { + console.error('\nSome patches could not be applied.') + } + } + return true } -export const downloadCommand: CommandModule<{}, DownloadArgs> = { - command: 'download ', - describe: 'Download security patches from Socket API', +export const getCommand: CommandModule<{}, GetArgs> = { + command: 'get ', + aliases: ['download'], + describe: 'Get security patches from Socket API and apply them', builder: yargs => { return yargs .positional('identifier', { @@ -804,29 +1172,57 @@ export const downloadCommand: CommandModule<{}, DownloadArgs> = { describe: 'Socket API token (overrides SOCKET_API_TOKEN env var)', type: 'string', }) + .option('no-apply', { + describe: 'Download patch without applying it', + type: 'boolean', + default: false, + }) + .option('global', { + alias: 'g', + describe: 'Apply patch to globally installed npm packages', + type: 'boolean', + default: false, + }) + .option('global-prefix', { + describe: 'Custom path to global node_modules (overrides auto-detection, useful for yarn/pnpm)', + type: 'string', + }) + .option('one-off', { + describe: 'Apply patch immediately without saving to .socket folder (ephemeral)', + type: 'boolean', + default: false, + }) .example( - '$0 download CVE-2021-44228', - 'Download free patches for a CVE (no auth required)', + '$0 get CVE-2021-44228', + 'Get and apply free patches for a CVE', ) .example( - '$0 download GHSA-jfhm-5ghh-2f97', - 'Download free patches for a GHSA (no auth required)', + '$0 get GHSA-jfhm-5ghh-2f97', + 'Get and apply free patches for a GHSA', ) .example( - '$0 download pkg:npm/lodash@4.17.21', - 'Download patches for a specific package version by PURL', + '$0 get pkg:npm/lodash@4.17.21', + 'Get and apply patches for a specific package version by PURL', ) .example( - '$0 download lodash --package', + '$0 get lodash --package', 'Search for patches by package name (fuzzy matches node_modules)', ) .example( - '$0 download 12345678-1234-1234-1234-123456789abc --org myorg', - 'Download a patch by UUID (requires SOCKET_API_TOKEN)', + '$0 get CVE-2021-44228 --no-apply', + 'Download patches without applying them', + ) + .example( + '$0 get lodash --global', + 'Get and apply patches to globally installed package', + ) + .example( + '$0 get CVE-2021-44228 --one-off', + 'Apply patch immediately without saving to .socket folder', ) .example( - '$0 download CVE-2021-44228 --org myorg --yes', - 'Download all matching patches without confirmation (with auth)', + '$0 get lodash --global --one-off', + 'Apply patch to global package without saving', ) .check(argv => { // Ensure only one type flag is set @@ -838,12 +1234,18 @@ export const downloadCommand: CommandModule<{}, DownloadArgs> = { 'Only one of --id, --cve, --ghsa, or --package can be specified', ) } + // --one-off implies apply, so --no-apply doesn't make sense + if (argv['one-off'] && argv['no-apply']) { + throw new Error( + '--one-off and --no-apply cannot be used together', + ) + } return true }) }, handler: async argv => { try { - const success = await downloadPatches(argv) + const success = await getPatches(argv) process.exit(success ? 0 : 1) } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err) diff --git a/src/commands/remove.test.ts b/src/commands/remove.test.ts index 8d2f35641..c5fc29cc3 100644 --- a/src/commands/remove.test.ts +++ b/src/commands/remove.test.ts @@ -65,6 +65,8 @@ describe('remove command with rollback', () => { false, true, false, // not offline + false, // not global + undefined, // no global prefix ) assert.equal(rollbackSuccess, true) @@ -153,6 +155,8 @@ describe('remove command with rollback', () => { false, true, false, // not offline + false, // not global + undefined, // no global prefix ) // Rollback should fail due to hash mismatch @@ -195,6 +199,8 @@ describe('remove command with rollback', () => { false, true, false, // not offline + false, // not global + undefined, // no global prefix ) assert.equal(success, true) @@ -244,6 +250,8 @@ describe('remove command with rollback', () => { false, true, false, // not offline + false, // not global + undefined, // no global prefix ) assert.equal(success, true) diff --git a/src/commands/remove.ts b/src/commands/remove.ts index f1e32bf5a..6bc8d73a6 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -17,6 +17,8 @@ interface RemoveArgs { cwd: string 'manifest-path': string 'skip-rollback': boolean + global: boolean + 'global-prefix'?: string } async function removePatch( @@ -88,6 +90,16 @@ export const removeCommand: CommandModule<{}, RemoveArgs> = { type: 'boolean', default: false, }) + .option('global', { + alias: 'g', + describe: 'Remove patches from globally installed npm packages', + type: 'boolean', + default: false, + }) + .option('global-prefix', { + describe: 'Custom path to global node_modules (overrides auto-detection, useful for yarn/pnpm)', + type: 'string', + }) .example( '$0 remove pkg:npm/lodash@4.17.21', 'Rollback and remove a patch by PURL', @@ -100,6 +112,10 @@ export const removeCommand: CommandModule<{}, RemoveArgs> = { '$0 remove pkg:npm/lodash@4.17.21 --skip-rollback', 'Remove from manifest without rolling back files', ) + .example( + '$0 remove pkg:npm/lodash@4.17.21 --global', + 'Remove and rollback from global npm packages', + ) }, handler: async argv => { try { @@ -126,6 +142,8 @@ export const removeCommand: CommandModule<{}, RemoveArgs> = { false, // not dry run false, // not silent false, // not offline + argv.global, + argv['global-prefix'], ) if (!rollbackSuccess) { diff --git a/src/commands/rollback.test.ts b/src/commands/rollback.test.ts index 3f810b1ba..db131276c 100644 --- a/src/commands/rollback.test.ts +++ b/src/commands/rollback.test.ts @@ -325,6 +325,8 @@ describe('rollback command', () => { false, true, false, // not offline + false, // not global + undefined, // no global prefix ) assert.equal(success, true) @@ -373,6 +375,8 @@ describe('rollback command', () => { false, true, false, // not offline + false, // not global + undefined, // no global prefix ) assert.equal(success, true) @@ -422,6 +426,8 @@ describe('rollback command', () => { false, true, false, // not offline + false, // not global + undefined, // no global prefix ) assert.equal(success, true) @@ -463,6 +469,8 @@ describe('rollback command', () => { false, true, false, // not offline + false, // not global + undefined, // no global prefix ) }, /No patch found matching identifier/, @@ -496,6 +504,8 @@ describe('rollback command', () => { false, true, false, // not offline + false, // not global + undefined, // no global prefix ) }, /No patch found matching identifier/, @@ -527,6 +537,8 @@ describe('rollback command', () => { false, true, false, // not offline + false, // not global + undefined, // no global prefix ) assert.equal(success, true) diff --git a/src/commands/rollback.ts b/src/commands/rollback.ts index 6ded537ea..b62cf2b35 100644 --- a/src/commands/rollback.ts +++ b/src/commands/rollback.ts @@ -1,5 +1,6 @@ import * as fs from 'fs/promises' import * as path from 'path' +import * as os from 'os' import type { CommandModule } from 'yargs' import { PatchManifestSchema, @@ -17,6 +18,8 @@ import { fetchBlobsByHash, formatFetchResult, } from '../utils/blob-fetcher.js' +import { getGlobalPrefix } from '../utils/global-packages.js' +import { getAPIClientFromEnv } from '../utils/api-client.js' interface RollbackArgs { identifier?: string @@ -25,6 +28,12 @@ interface RollbackArgs { silent: boolean 'manifest-path': string offline: boolean + global: boolean + 'global-prefix'?: string + 'one-off': boolean + org?: string + 'api-url'?: string + 'api-token'?: string } interface PatchToRollback { @@ -114,6 +123,8 @@ async function rollbackPatches( dryRun: boolean, silent: boolean, offline: boolean, + useGlobal: boolean, + globalPrefix?: string, ): Promise<{ success: boolean; results: RollbackResult[] }> { // Read and parse manifest const manifestContent = await fs.readFile(manifestPath, 'utf-8') @@ -192,12 +203,27 @@ async function rollbackPatches( } } - // Find all node_modules directories - const nodeModulesPaths = await findNodeModules(cwd) + // Find node_modules directories + let nodeModulesPaths: string[] + if (useGlobal || globalPrefix) { + try { + nodeModulesPaths = [getGlobalPrefix(globalPrefix)] + if (!silent) { + console.log(`Using global npm packages at: ${nodeModulesPaths[0]}`) + } + } catch (error) { + if (!silent) { + console.error('Failed to find global npm packages:', error instanceof Error ? error.message : String(error)) + } + return { success: false, results: [] } + } + } else { + nodeModulesPaths = await findNodeModules(cwd) + } if (nodeModulesPaths.length === 0) { if (!silent) { - console.error('No node_modules directories found') + console.error(useGlobal || globalPrefix ? 'No global npm packages found' : 'No node_modules directories found') } return { success: false, results: [] } } @@ -287,6 +313,33 @@ export const rollbackCommand: CommandModule<{}, RollbackArgs> = { type: 'boolean', default: false, }) + .option('global', { + alias: 'g', + describe: 'Rollback patches from globally installed npm packages', + type: 'boolean', + default: false, + }) + .option('global-prefix', { + describe: 'Custom path to global node_modules (overrides auto-detection, useful for yarn/pnpm)', + type: 'string', + }) + .option('one-off', { + describe: 'Rollback a patch by fetching beforeHash blobs from API (no manifest required)', + type: 'boolean', + default: false, + }) + .option('org', { + describe: 'Organization slug (required for --one-off when using SOCKET_API_TOKEN)', + type: 'string', + }) + .option('api-url', { + describe: 'Socket API URL (overrides SOCKET_API_URL env var)', + type: 'string', + }) + .option('api-token', { + describe: 'Socket API token (overrides SOCKET_API_TOKEN env var)', + type: 'string', + }) .example('$0 rollback', 'Rollback all patches') .example( '$0 rollback pkg:npm/lodash@4.17.21', @@ -297,9 +350,36 @@ export const rollbackCommand: CommandModule<{}, RollbackArgs> = { 'Rollback a patch by UUID', ) .example('$0 rollback --dry-run', 'Preview what would be rolled back') + .example('$0 rollback --global', 'Rollback patches from global npm packages') + .example( + '$0 rollback pkg:npm/lodash@4.17.21 --one-off --global', + 'Rollback global package by fetching blobs from API', + ) + .check(argv => { + if (argv['one-off'] && !argv.identifier) { + throw new Error('--one-off requires an identifier (UUID or PURL)') + } + return true + }) }, handler: async argv => { try { + // Handle one-off mode (no manifest required) + if (argv['one-off']) { + const success = await rollbackOneOff( + argv.identifier!, + argv.cwd, + argv.global, + argv['global-prefix'], + argv['dry-run'], + argv.silent, + argv.org, + argv['api-url'], + argv['api-token'], + ) + process.exit(success ? 0 : 1) + } + const manifestPath = path.isAbsolute(argv['manifest-path']) ? argv['manifest-path'] : path.join(argv.cwd, argv['manifest-path']) @@ -321,6 +401,8 @@ export const rollbackCommand: CommandModule<{}, RollbackArgs> = { argv['dry-run'], argv.silent, argv.offline, + argv.global, + argv['global-prefix'], ) // Print results if not silent @@ -373,5 +455,199 @@ export const rollbackCommand: CommandModule<{}, RollbackArgs> = { }, } +/** + * Parse a PURL to extract the package directory path and version + */ +function parsePurl(purl: string): { packageDir: string; version: string } | null { + const match = purl.match(/^pkg:npm\/(.+)@([^@]+)$/) + if (!match) return null + return { packageDir: match[1], version: match[2] } +} + +/** + * Rollback a patch without using the manifest (one-off mode) + * Downloads beforeHash blobs from API on demand + */ +async function rollbackOneOff( + identifier: string, + cwd: string, + useGlobal: boolean, + globalPrefix: string | undefined, + dryRun: boolean, + silent: boolean, + orgSlug: string | undefined, + apiUrl: string | undefined, + apiToken: string | undefined, +): Promise { + // Override environment variables if CLI options are provided + if (apiUrl) { + process.env.SOCKET_API_URL = apiUrl + } + if (apiToken) { + process.env.SOCKET_API_TOKEN = apiToken + } + + // Get API client + const { client: apiClient, usePublicProxy } = getAPIClientFromEnv() + + // Validate that org is provided when using authenticated API + if (!usePublicProxy && !orgSlug) { + throw new Error( + '--org is required when using SOCKET_API_TOKEN. Provide an organization slug.', + ) + } + + const effectiveOrgSlug = usePublicProxy ? null : orgSlug ?? null + + if (!silent) { + console.log(`Fetching patch data for: ${identifier}`) + } + + // Fetch the patch (can be UUID or PURL) + let patch + if (identifier.startsWith('pkg:')) { + // Search by PURL + const searchResponse = await apiClient.searchPatchesByPackage(effectiveOrgSlug, identifier) + if (searchResponse.patches.length === 0) { + throw new Error(`No patch found for PURL: ${identifier}`) + } + patch = await apiClient.fetchPatch(effectiveOrgSlug, searchResponse.patches[0].uuid) + } else { + // Assume UUID + patch = await apiClient.fetchPatch(effectiveOrgSlug, identifier) + } + + if (!patch) { + throw new Error(`Could not fetch patch: ${identifier}`) + } + + // Determine node_modules path + let nodeModulesPath: string + if (useGlobal || globalPrefix) { + try { + nodeModulesPath = getGlobalPrefix(globalPrefix) + if (!silent) { + console.log(`Using global npm packages at: ${nodeModulesPath}`) + } + } catch (error) { + throw new Error( + `Failed to find global npm packages: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } else { + nodeModulesPath = path.join(cwd, 'node_modules') + } + + // Parse PURL to get package directory + const parsed = parsePurl(patch.purl) + if (!parsed) { + throw new Error(`Invalid PURL format: ${patch.purl}`) + } + + const pkgPath = path.join(nodeModulesPath, parsed.packageDir) + + // Verify package exists + try { + const pkgJsonPath = path.join(pkgPath, 'package.json') + const pkgJsonContent = await fs.readFile(pkgJsonPath, 'utf-8') + const pkgJson = JSON.parse(pkgJsonContent) + if (pkgJson.version !== parsed.version) { + if (!silent) { + console.log(`Note: Installed version ${pkgJson.version} differs from patch version ${parsed.version}`) + } + } + } catch { + throw new Error(`Package not found: ${parsed.packageDir}`) + } + + // Create temporary directory for blobs + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-patch-')) + const tempBlobsDir = path.join(tempDir, 'blobs') + await fs.mkdir(tempBlobsDir, { recursive: true }) + + try { + // Download beforeHash blobs + const beforeHashes = new Set() + for (const fileInfo of Object.values(patch.files)) { + if (fileInfo.beforeHash) { + beforeHashes.add(fileInfo.beforeHash) + } + // Also save beforeBlobContent if available + if (fileInfo.beforeBlobContent && fileInfo.beforeHash) { + const blobBuffer = Buffer.from(fileInfo.beforeBlobContent, 'base64') + await fs.writeFile(path.join(tempBlobsDir, fileInfo.beforeHash), blobBuffer) + beforeHashes.delete(fileInfo.beforeHash) + } + } + + // Fetch any missing beforeHash blobs + if (beforeHashes.size > 0) { + if (!silent) { + console.log(`Downloading ${beforeHashes.size} blob(s) for rollback...`) + } + const fetchResult = await fetchBlobsByHash(beforeHashes, tempBlobsDir, undefined, { + onProgress: silent + ? undefined + : (hash, index, total) => { + process.stdout.write( + `\r Downloading ${index}/${total}: ${hash.slice(0, 12)}...`.padEnd(60), + ) + }, + }) + if (!silent) { + process.stdout.write('\r' + ' '.repeat(60) + '\r') + console.log(formatFetchResult(fetchResult)) + } + if (fetchResult.failed > 0) { + throw new Error('Some blobs could not be downloaded. Cannot rollback.') + } + } + + // Build files record + const files: Record = {} + for (const [filePath, fileInfo] of Object.entries(patch.files)) { + if (fileInfo.beforeHash && fileInfo.afterHash) { + files[filePath] = { + beforeHash: fileInfo.beforeHash, + afterHash: fileInfo.afterHash, + } + } + } + + if (dryRun) { + if (!silent) { + console.log(`\nDry run: Would rollback ${patch.purl}`) + console.log(` Files: ${Object.keys(files).length}`) + } + return true + } + + // Perform rollback + const result = await rollbackPackagePatch( + patch.purl, + pkgPath, + files, + tempBlobsDir, + false, + ) + + if (result.success) { + if (!silent) { + if (result.filesRolledBack.length > 0) { + console.log(`\nRolled back ${patch.purl}`) + } else if (result.filesVerified.every(f => f.status === 'already-original')) { + console.log(`\n${patch.purl} is already in original state`) + } + } + return true + } else { + throw new Error(result.error || 'Unknown rollback error') + } + } finally { + // Clean up temp directory + await fs.rm(tempDir, { recursive: true, force: true }) + } +} + // Export the rollback function for use by other commands (e.g., remove) export { rollbackPatches, findPatchesToRollback } diff --git a/src/utils/global-packages.ts b/src/utils/global-packages.ts new file mode 100644 index 000000000..5f1444116 --- /dev/null +++ b/src/utils/global-packages.ts @@ -0,0 +1,92 @@ +import { execSync } from 'child_process' +import * as path from 'path' + +/** + * Get the npm global node_modules path using 'npm root -g' + * @returns The path to the global node_modules directory + */ +export function getNpmGlobalPrefix(): string { + try { + const result = execSync('npm root -g', { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }) + return result.trim() + } catch (error) { + throw new Error( + 'Failed to determine npm global prefix. Ensure npm is installed and in PATH.', + ) + } +} + +/** + * Get the yarn global node_modules path + * @returns The path to yarn's global node_modules directory, or null if not available + */ +export function getYarnGlobalPrefix(): string | null { + try { + const result = execSync('yarn global dir', { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }) + return path.join(result.trim(), 'node_modules') + } catch { + return null + } +} + +/** + * Get the pnpm global node_modules path + * @returns The path to pnpm's global node_modules directory, or null if not available + */ +export function getPnpmGlobalPrefix(): string | null { + try { + const result = execSync('pnpm root -g', { + encoding: 'utf-8', + stdio: ['pipe', 'pipe', 'pipe'], + }) + return result.trim() + } catch { + return null + } +} + +/** + * Get the global node_modules path, with support for custom override + * @param customPrefix - Optional custom path to use instead of auto-detection + * @returns The path to the global node_modules directory + */ +export function getGlobalPrefix(customPrefix?: string): string { + if (customPrefix) { + return customPrefix + } + return getNpmGlobalPrefix() +} + +/** + * Get all global node_modules paths for package lookup + * Currently returns npm global path, but could be extended for yarn global, etc. + * @param customPrefix - Optional custom path to use instead of auto-detection + * @returns Array of global node_modules paths + */ +export function getGlobalNodeModulesPaths(customPrefix?: string): string[] { + if (customPrefix) { + return [customPrefix] + } + return [getNpmGlobalPrefix()] +} + +/** + * Check if a path is within a global node_modules directory + */ +export function isGlobalPath(pkgPath: string): boolean { + try { + const globalPaths = getGlobalNodeModulesPaths() + const normalizedPkgPath = path.normalize(pkgPath) + return globalPaths.some(globalPath => + normalizedPkgPath.startsWith(path.normalize(globalPath)), + ) + } catch { + return false + } +}