diff --git a/CHANGELOG.md b/CHANGELOG.md index b59a61c31..137e7c9a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] ### Changed +- Updated to @socketsecurity/socket-patch@1.2.0. - Updated Coana CLI to v14.12.148. ### Fixed diff --git a/packages/build-infra/lib/github-releases.mjs b/packages/build-infra/lib/github-releases.mjs index ff6c71c2b..5c4676020 100644 --- a/packages/build-infra/lib/github-releases.mjs +++ b/packages/build-infra/lib/github-releases.mjs @@ -2,6 +2,7 @@ * Shared utilities for fetching GitHub releases. */ +import { createTtlCache } from '@socketsecurity/lib/cache-with-ttl' import { safeMkdir } from '@socketsecurity/lib/fs' import { httpDownload, httpRequest } from '@socketsecurity/lib/http-request' import { getDefaultLogger } from '@socketsecurity/lib/logger' @@ -12,6 +13,13 @@ const logger = getDefaultLogger() const OWNER = 'SocketDev' const REPO = 'socket-btm' +// Cache GitHub API responses for 1 hour to avoid rate limiting. +const cache = createTtlCache({ + memoize: true, + prefix: 'github-releases', + ttl: 60 * 60 * 1000, // 1 hour. +}) + /** * Get GitHub authentication headers if token is available. * @@ -38,52 +46,56 @@ function getAuthHeaders() { * @returns {Promise} - Latest release tag or null if not found. */ export async function getLatestRelease(tool, { quiet = false } = {}) { - return await pRetry( - async () => { - const response = await httpRequest( - `https://api.github.com/repos/${OWNER}/${REPO}/releases?per_page=100`, - { - headers: getAuthHeaders(), - }, - ) - - if (!response.ok) { - throw new Error(`Failed to fetch releases: ${response.status}`) - } + const cacheKey = `latest-release:${tool}` + + return await cache.getOrFetch(cacheKey, async () => { + return await pRetry( + async () => { + const response = await httpRequest( + `https://api.github.com/repos/${OWNER}/${REPO}/releases?per_page=100`, + { + headers: getAuthHeaders(), + }, + ) + + if (!response.ok) { + throw new Error(`Failed to fetch releases: ${response.status}`) + } - const releases = JSON.parse(response.body) + const releases = JSON.parse(response.body) - // Find the first release matching the tool prefix. - for (const release of releases) { - const { tag_name: tag } = release - if (tag.startsWith(`${tool}-`)) { - if (!quiet) { - logger.info(` Found release: ${tag}`) + // Find the first release matching the tool prefix. + for (const release of releases) { + const { tag_name: tag } = release + if (tag.startsWith(`${tool}-`)) { + if (!quiet) { + logger.info(` Found release: ${tag}`) + } + return tag } - return tag } - } - - // No matching release found in the list. - if (!quiet) { - logger.info(` No ${tool} release found in latest 100 releases`) - } - return null - }, - { - backoffFactor: 1, - baseDelayMs: 5000, - onRetry: (attempt, error) => { + + // No matching release found in the list. if (!quiet) { - logger.info( - ` Retry attempt ${attempt + 1}/3 for ${tool} release list...`, - ) - logger.warn(` Attempt ${attempt + 1}/3 failed: ${error.message}`) + logger.info(` No ${tool} release found in latest 100 releases`) } + return null }, - retries: 2, - }, - ) + { + backoffFactor: 1, + baseDelayMs: 5000, + onRetry: (attempt, error) => { + if (!quiet) { + logger.info( + ` Retry attempt ${attempt + 1}/3 for ${tool} release list...`, + ) + logger.warn(` Attempt ${attempt + 1}/3 failed: ${error.message}`) + } + }, + retries: 2, + }, + ) + }) } /** @@ -103,46 +115,50 @@ export async function getReleaseAssetUrl( assetName, { quiet = false } = {}, ) { - return await pRetry( - async () => { - const response = await httpRequest( - `https://api.github.com/repos/${OWNER}/${REPO}/releases/tags/${tag}`, - { - headers: getAuthHeaders(), - }, - ) - - if (!response.ok) { - throw new Error(`Failed to fetch release ${tag}: ${response.status}`) - } - - const release = JSON.parse(response.body) + const cacheKey = `asset-url:${tag}:${assetName}` + + return await cache.getOrFetch(cacheKey, async () => { + return await pRetry( + async () => { + const response = await httpRequest( + `https://api.github.com/repos/${OWNER}/${REPO}/releases/tags/${tag}`, + { + headers: getAuthHeaders(), + }, + ) + + if (!response.ok) { + throw new Error(`Failed to fetch release ${tag}: ${response.status}`) + } - // Find the matching asset. - const asset = release.assets.find(a => a.name === assetName) + const release = JSON.parse(response.body) - if (!asset) { - throw new Error(`Asset ${assetName} not found in release ${tag}`) - } + // Find the matching asset. + const asset = release.assets.find(a => a.name === assetName) - if (!quiet) { - logger.info(` Found asset: ${assetName}`) - } + if (!asset) { + throw new Error(`Asset ${assetName} not found in release ${tag}`) + } - return asset.browser_download_url - }, - { - backoffFactor: 1, - baseDelayMs: 5000, - onRetry: (attempt, error) => { if (!quiet) { - logger.info(` Retry attempt ${attempt + 1}/3 for asset URL...`) - logger.warn(` Attempt ${attempt + 1}/3 failed: ${error.message}`) + logger.info(` Found asset: ${assetName}`) } + + return asset.browser_download_url }, - retries: 2, - }, - ) + { + backoffFactor: 1, + baseDelayMs: 5000, + onRetry: (attempt, error) => { + if (!quiet) { + logger.info(` Retry attempt ${attempt + 1}/3 for asset URL...`) + logger.warn(` Attempt ${attempt + 1}/3 failed: ${error.message}`) + } + }, + retries: 2, + }, + ) + }) } /** diff --git a/packages/cli/.config/esbuild.index.config.mjs b/packages/cli/.config/esbuild.index.config.mjs index 7d470370d..307b19cb2 100644 --- a/packages/cli/.config/esbuild.index.config.mjs +++ b/packages/cli/.config/esbuild.index.config.mjs @@ -3,6 +3,7 @@ * Builds the index loader that executes the CLI. */ +import { writeFileSync } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' @@ -20,10 +21,19 @@ const config = createIndexConfig({ // Run build if invoked directly. if (fileURLToPath(import.meta.url) === process.argv[1]) { - build(config).catch(error => { - console.error('Index loader build failed:', error) - process.exitCode = 1 - }) + build(config) + .then(result => { + // Write the transformed output (build had write: false). + if (result.outputFiles && result.outputFiles.length > 0) { + for (const output of result.outputFiles) { + writeFileSync(output.path, output.contents) + } + } + }) + .catch(error => { + console.error('Index loader build failed:', error) + process.exitCode = 1 + }) } export default config diff --git a/packages/cli/.env.test b/packages/cli/.env.test index f92171611..44938893b 100644 --- a/packages/cli/.env.test +++ b/packages/cli/.env.test @@ -6,3 +6,8 @@ SOCKET_CLI_BIN_PATH="./build/cli.js" SOCKET_CLI_JS_PATH="./dist/cli.js" # RUN_E2E_TESTS=1 # SOCKET_CLI_BIN_PATH="./dist/sea/socket-macos-arm64" + +# External tool versions (from external-tools.json) +INLINED_SOCKET_CLI_COANA_VERSION="14.12.148" +INLINED_SOCKET_CLI_SFW_VERSION="2.0.4" +INLINED_SOCKET_CLI_SOCKET_PATCH_VERSION="1.2.0" diff --git a/packages/cli/external-tools.json b/packages/cli/external-tools.json index cbf0c9d0e..a9c29c5ff 100644 --- a/packages/cli/external-tools.json +++ b/packages/cli/external-tools.json @@ -24,6 +24,12 @@ "package": "socketsecurity", "version": "^2.2.15" }, + "socket-patch": { + "description": "Socket Patch CLI for applying security patches", + "type": "npm", + "package": "@socketsecurity/socket-patch", + "version": "1.2.0" + }, "sfw": { "description": "Socket Firewall (sfw)", "type": "standalone", diff --git a/packages/cli/scripts/build-js.mjs b/packages/cli/scripts/build-js.mjs index 92ce0dc24..1c2748c49 100644 --- a/packages/cli/scripts/build-js.mjs +++ b/packages/cli/scripts/build-js.mjs @@ -3,6 +3,8 @@ * Orchestrates extraction, building, and validation. */ +import { copyFileSync } from 'node:fs' + import { getDefaultLogger } from '@socketsecurity/lib/logger' import { spawn } from '@socketsecurity/lib/spawn' @@ -34,7 +36,10 @@ async function main() { return } - // Step 3: Validate bundle. + // Step 3: Copy bundle to dist/. + copyFileSync('build/cli.js', 'dist/cli.js') + + // Step 4: Validate bundle. logger.step('Validating bundle') const validateResult = await spawn( 'node', diff --git a/packages/cli/scripts/build.mjs b/packages/cli/scripts/build.mjs index 17f6cb7d2..77f0e5219 100644 --- a/packages/cli/scripts/build.mjs +++ b/packages/cli/scripts/build.mjs @@ -3,8 +3,7 @@ * Options: --quiet, --verbose, --force, --watch */ -import { copyFileSync } from 'node:fs' -import { promises as fs } from 'node:fs' +import { copyFileSync, promises as fs } from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' diff --git a/packages/cli/scripts/esbuild-shared.mjs b/packages/cli/scripts/esbuild-shared.mjs index fdc270cd7..31dd4408e 100644 --- a/packages/cli/scripts/esbuild-shared.mjs +++ b/packages/cli/scripts/esbuild-shared.mjs @@ -21,6 +21,9 @@ const rootPath = path.join(__dirname, '..') * @returns {Object} esbuild configuration object */ export function createIndexConfig({ entryPoint, minify = false, outfile }) { + // Get inlined environment variables for build-time constant replacement. + const inlinedEnvVars = getInlinedEnvVars() + const config = { banner: { js: '#!/usr/bin/env node', @@ -33,6 +36,15 @@ export function createIndexConfig({ entryPoint, minify = false, outfile }) { platform: 'node', target: 'node18', treeShaking: true, + // Define environment variables for inlining. + define: { + 'process.env.NODE_ENV': '"production"', + ...createDefineEntries(inlinedEnvVars), + }, + // Add plugin for post-bundle env var replacement. + plugins: [envVarReplacementPlugin(inlinedEnvVars)], + // Plugin needs to transform output. + write: false, } if (minify) { @@ -137,12 +149,30 @@ export function getInlinedEnvVars() { const externalTools = JSON.parse( readFileSync(path.join(rootPath, 'external-tools.json'), 'utf-8'), ) - const cdxgenVersion = externalTools['@cyclonedx/cdxgen']?.version || '' - const coanaVersion = externalTools['@coana-tech/cli']?.version || '' - const pyCliVersion = externalTools['socketsecurity']?.version || '' - const pythonBuildTag = externalTools['python']?.buildTag || '' - const pythonVersion = externalTools['python']?.version || '' - const sfwVersion = externalTools['sfw']?.version || '' + + function getExternalToolVersion(key, field = 'version') { + const tool = externalTools[key] + if (!tool) { + throw new Error( + `External tool "${key}" not found in external-tools.json. Please add it to the configuration.`, + ) + } + const value = tool[field] + if (!value) { + throw new Error( + `External tool "${key}" is missing required field "${field}" in external-tools.json.`, + ) + } + return value + } + + const cdxgenVersion = getExternalToolVersion('@cyclonedx/cdxgen') + const coanaVersion = getExternalToolVersion('@coana-tech/cli') + const pyCliVersion = getExternalToolVersion('socketsecurity') + const pythonBuildTag = getExternalToolVersion('python', 'buildTag') + const pythonVersion = getExternalToolVersion('python') + const sfwVersion = getExternalToolVersion('sfw') + const socketPatchVersion = getExternalToolVersion('socket-patch') // Build-time constants that can be overridden by environment variables. const publishedBuild = @@ -166,6 +196,7 @@ export function getInlinedEnvVars() { INLINED_SOCKET_CLI_CYCLONEDX_CDXGEN_VERSION: JSON.stringify(cdxgenVersion), INLINED_SOCKET_CLI_PYCLI_VERSION: JSON.stringify(pyCliVersion), INLINED_SOCKET_CLI_SFW_VERSION: JSON.stringify(sfwVersion), + INLINED_SOCKET_CLI_SOCKET_PATCH_VERSION: JSON.stringify(socketPatchVersion), INLINED_SOCKET_CLI_SYNP_VERSION: JSON.stringify(synpVersion), INLINED_SOCKET_CLI_PUBLISHED_BUILD: JSON.stringify( publishedBuild ? '1' : '', diff --git a/packages/cli/src/commands/patch/PatchSelectorApp.tsx b/packages/cli/src/commands/patch/PatchSelectorApp.tsx deleted file mode 100644 index 1dee37482..000000000 --- a/packages/cli/src/commands/patch/PatchSelectorApp.tsx +++ /dev/null @@ -1,244 +0,0 @@ -// @ts-nocheck -/** @fileoverview Patch selector Ink React component with multi-select checkboxes. */ - -import { Box, Text, useApp, useInput } from 'ink' -import type React from 'react' -import { createElement, useEffect, useState } from 'react' - -import type { DiscoveredPatch } from './handle-patch-discover.mts' - -export type PatchSelectorAppProps = { - patches: DiscoveredPatch[] - onSelect: (selectedPatches: DiscoveredPatch[]) => void -} - -/** - * Shimmering text component with purple gradient effect. - */ -function ShimmerText({ children }: { children: string }): React.ReactElement { - const [frame, setFrame] = useState(0) - - useEffect(() => { - const interval = setInterval(() => { - setFrame(f => (f + 1) % 20) - }, 100) - return () => clearInterval(interval) - }, []) - - const colors = ['#8B5CF6', '#A78BFA', '#C4B5FD', '#DDD6FE'] - const colorIndex = Math.floor(frame / 5) % colors.length - - return createElement( - Text, - { color: colors[colorIndex], bold: true }, - children, - ) -} - -/** - * Patch selector app with multi-select checkboxes. - */ -export function PatchSelectorApp({ - patches, - onSelect, -}: PatchSelectorAppProps): React.ReactElement { - const { exit } = useApp() - const [selectedIndex, setSelectedIndex] = useState(0) - const [selectedPatches, setSelectedPatches] = useState>(new Set()) - - useInput((input, key) => { - if (input === 'q' || key.escape) { - exit() - onSelect([]) - } else if (key.ctrl && input === 'c') { - exit() - process.exit(0) - } else if (key.upArrow || input === 'k') { - setSelectedIndex(prev => Math.max(0, prev - 1)) - } else if (key.downArrow || input === 'j') { - setSelectedIndex(prev => Math.min(patches.length - 1, prev + 1)) - } else if (input === ' ') { - // Toggle selection. - setSelectedPatches(prev => { - const next = new Set(prev) - if (next.has(selectedIndex)) { - next.delete(selectedIndex) - } else { - next.add(selectedIndex) - } - return next - }) - } else if (input === 'a') { - // Select all. - setSelectedPatches(new Set(patches.map((_, i) => i))) - } else if (input === 'n') { - // Select none. - setSelectedPatches(new Set()) - } else if (key.return) { - // Apply selected patches. - const selected = patches.filter((_, i) => selectedPatches.has(i)) - exit() - onSelect(selected) - } - }) - - return createElement( - Box, - { flexDirection: 'column', paddingX: 2, paddingY: 1 }, - // Header - createElement( - Box, - { - borderStyle: 'double', - borderColor: 'magenta', - paddingX: 2, - paddingY: 1, - marginBottom: 1, - }, - createElement( - Box, - { flexDirection: 'column', width: '100%' }, - createElement( - Box, - { justifyContent: 'center', marginBottom: 1 }, - createElement(ShimmerText, {}, '🛡️ Socket Security Patches'), - ), - createElement( - Box, - { justifyContent: 'center' }, - createElement( - Text, - { dimColor: true }, - 'Select patches to apply to your project', - ), - ), - ), - ), - // Patch List - createElement( - Box, - { - borderStyle: 'single', - borderColor: 'cyan', - flexDirection: 'column', - paddingX: 1, - marginBottom: 1, - }, - patches.map((patch, index) => { - const isSelected = selectedPatches.has(index) - const isCursor = index === selectedIndex - const checkbox = isSelected ? '[✓]' : '[ ]' - const cursor = isCursor ? '▶ ' : ' ' - - const freeCveCount = patch.freeCves.length - const paidCveCount = patch.paidCves.length - const totalCveCount = freeCveCount + paidCveCount - - let vulnText = '' - if (totalCveCount > 0) { - if (paidCveCount > 0) { - vulnText = ` (${freeCveCount} free + ${paidCveCount} enterprise CVEs)` - } else { - vulnText = ` (${freeCveCount} free CVE${freeCveCount !== 1 ? 's' : ''})` - } - } - - return createElement( - Box, - { key: index, flexDirection: 'column' }, - createElement( - Text, - { - color: isCursor ? 'magenta' : undefined, - bold: isCursor, - backgroundColor: isCursor ? 'gray' : undefined, - }, - cursor, - createElement( - Text, - { color: isSelected ? 'green' : 'white' }, - checkbox, - ), - ' ', - createElement( - Text, - { color: 'cyan' }, - patch.purl || 'Unknown package', - ), - createElement(Text, { color: 'yellow' }, vulnText), - ), - // Show features if available. - patch.freeFeatures.length > 0 || patch.paidFeatures.length > 0 - ? createElement( - Box, - { marginLeft: 6, flexDirection: 'column' }, - patch.freeFeatures.map((feature, i) => - createElement( - Text, - { key: `free-${i}`, color: 'green', dimColor: !isCursor }, - ` ✓ ${feature}`, - ), - ), - patch.paidFeatures.map((feature, i) => - createElement( - Text, - { key: `paid-${i}`, color: 'magenta', dimColor: !isCursor }, - ` ⭐ ${feature}`, - ), - ), - ) - : null, - ) - }), - ), - // Summary - createElement( - Box, - { - borderStyle: 'single', - borderColor: 'yellow', - paddingX: 2, - marginBottom: 1, - }, - createElement( - Text, - { color: 'yellow' }, - `Selected: ${selectedPatches.size} / ${patches.length} patches`, - ), - ), - // Controls - createElement( - Box, - { - borderStyle: 'single', - borderColor: 'magenta', - paddingX: 2, - backgroundColor: 'black', - }, - createElement( - Box, - { flexDirection: 'column', width: '100%' }, - createElement( - Text, - { color: 'magenta' }, - createElement(Text, { bold: true }, 'Space:'), - ' Toggle ', - createElement(Text, { bold: true }, 'a:'), - ' All ', - createElement(Text, { bold: true }, 'n:'), - ' None', - ), - createElement( - Text, - { color: 'magenta' }, - createElement(Text, { bold: true }, '↑/↓:'), - ' Navigate ', - createElement(Text, { bold: true }, 'Enter:'), - ' Apply ', - createElement(Text, { bold: true }, 'q/ESC:'), - ' Cancel', - ), - ), - ), - ) -} diff --git a/packages/cli/src/commands/patch/cmd-patch-apply.mts b/packages/cli/src/commands/patch/cmd-patch-apply.mts deleted file mode 100644 index 7ec907523..000000000 --- a/packages/cli/src/commands/patch/cmd-patch-apply.mts +++ /dev/null @@ -1,138 +0,0 @@ -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { arrayUnique } from '@socketsecurity/lib/arrays' -import { DOT_SOCKET_DIR } from '@socketsecurity/lib/paths/dirnames' -import { MANIFEST_JSON } from '@socketsecurity/lib/paths/filenames' -import { getDefaultSpinner } from '@socketsecurity/lib/spinner' - -import { handlePatchApply } from './handle-patch-apply.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../utils/cli/with-subcommands.mjs' -import { InputError } from '../../utils/error/errors.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../utils/output/formatting.mts' -import { getOutputKind } from '../../utils/output/mode.mjs' -import { cmdFlagValueToArray } from '../../utils/process/cmd.mts' -import { getPurlObject } from '../../utils/purl/parse.mjs' -import { checkCommandInput } from '../../utils/validation/check-input.mts' - -import type { - CliCommandConfig, - CliCommandContext, - CliSubcommand, -} from '../../utils/cli/with-subcommands.mjs' -import type { PurlObject } from '../../utils/purl/parse.mjs' -import type { PackageURL } from '@socketregistry/packageurl-js' - -export const CMD_NAME = 'apply' - -const description = 'Apply CVE patches to dependencies' - -export const cmdPatchApply: CliSubcommand = { - description, - hidden: false, - run, -} - -async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise { - const config: CliCommandConfig = { - commandName: CMD_NAME, - description, - hidden: false, - flags: { - ...commonFlags, - ...outputFlags, - purl: { - type: 'string', - default: [], - description: - 'Specify purls to patch, as either a comma separated value or as multiple flags', - isMultiple: true, - shortFlag: 'p', - }, - }, - help: (command, config) => ` - Usage - $ ${command} [options] [CWD=.] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} - $ ${command} --purl pkg:npm/lodash@4.17.21 - $ ${command} ./path/to/project --purl pkg:npm/lodash@4.17.21,pkg:npm/react@18.0.0 - `, - } - - const cli = meowOrExit( - { - argv, - config, - parentName, - importMeta, - }, - { allowUnknownFlags: false }, - ) - - const { dryRun, json, markdown } = cli.flags as unknown as { - dryRun: boolean - json: boolean - markdown: boolean - } - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput(outputKind, { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }) - if (!wasValidInput) { - return - } - - let [cwd = '.'] = cli.input - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - const dotSocketDirPath = path.join(cwd, DOT_SOCKET_DIR) - if (!existsSync(dotSocketDirPath)) { - throw new InputError( - `No ${DOT_SOCKET_DIR} directory found in current directory`, - ) - } - - const manifestPath = path.join(dotSocketDirPath, MANIFEST_JSON) - if (!existsSync(manifestPath)) { - throw new InputError( - `No ${MANIFEST_JSON} found in ${DOT_SOCKET_DIR} directory`, - ) - } - - const spinner = getDefaultSpinner() - - const purlObjs = arrayUnique(cmdFlagValueToArray(cli.flags['purl'])) - .map(p => getPurlObject(p, { throws: false })) - .filter(Boolean) as Array> - - await handlePatchApply({ - cwd, - dryRun, - outputKind, - purlObjs, - spinner, - }) -} diff --git a/packages/cli/src/commands/patch/cmd-patch-cleanup.mts b/packages/cli/src/commands/patch/cmd-patch-cleanup.mts deleted file mode 100644 index 6c1ed05aa..000000000 --- a/packages/cli/src/commands/patch/cmd-patch-cleanup.mts +++ /dev/null @@ -1,142 +0,0 @@ -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { DOT_SOCKET_DIR } from '@socketsecurity/lib/paths/dirnames' -import { MANIFEST_JSON } from '@socketsecurity/lib/paths/filenames' -import { getDefaultSpinner } from '@socketsecurity/lib/spinner' - -import { handlePatchCleanup } from './handle-patch-cleanup.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../utils/cli/with-subcommands.mjs' -import { InputError } from '../../utils/error/errors.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../utils/output/formatting.mts' -import { getOutputKind } from '../../utils/output/mode.mjs' -import { checkCommandInput } from '../../utils/validation/check-input.mts' - -import type { - CliCommandConfig, - CliCommandContext, - CliSubcommand, -} from '../../utils/cli/with-subcommands.mjs' - -export const CMD_NAME = 'cleanup' - -const description = 'Clean up orphaned patch backups' - -export const cmdPatchCleanup: CliSubcommand = { - description, - hidden: false, - run, -} - -async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise { - const config: CliCommandConfig = { - commandName: CMD_NAME, - description, - hidden: false, - flags: { - ...commonFlags, - ...outputFlags, - all: { - type: 'boolean', - default: false, - description: 'Clean up all backups, including those in manifest', - }, - }, - help: (command, config) => ` - Usage - $ ${command} [UUID] [CWD=.] [options] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} - $ ${command} --all - $ ${command} abc123-def456-789 - $ ${command} ./path/to/project - `, - } - - const cli = meowOrExit( - { - argv, - config, - parentName, - importMeta, - }, - { allowUnknownFlags: false }, - ) - - const { all, json, markdown } = cli.flags as unknown as { - all: boolean - json: boolean - markdown: boolean - } - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput(outputKind, { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }) - if (!wasValidInput) { - return - } - - const [uuidOrCwd, cwdArg] = cli.input - let uuid: string | undefined - let cwd: string - - // Determine if first argument is UUID or CWD. - if (uuidOrCwd && existsSync(uuidOrCwd)) { - // First argument is a directory path. - cwd = uuidOrCwd - } else if (uuidOrCwd) { - // First argument is a UUID. - uuid = uuidOrCwd - cwd = cwdArg || '.' - } else { - cwd = '.' - } - - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - const dotSocketDirPath = path.join(cwd, DOT_SOCKET_DIR) - if (!existsSync(dotSocketDirPath)) { - throw new InputError( - `No ${DOT_SOCKET_DIR} directory found in current directory`, - ) - } - - const manifestPath = path.join(dotSocketDirPath, MANIFEST_JSON) - if (!existsSync(manifestPath)) { - throw new InputError( - `No ${MANIFEST_JSON} found in ${DOT_SOCKET_DIR} directory`, - ) - } - - const spinner = getDefaultSpinner() - - await handlePatchCleanup({ - all, - cwd, - outputKind, - spinner, - uuid, - }) -} diff --git a/packages/cli/src/commands/patch/cmd-patch-discover.mts b/packages/cli/src/commands/patch/cmd-patch-discover.mts deleted file mode 100644 index fa3079552..000000000 --- a/packages/cli/src/commands/patch/cmd-patch-discover.mts +++ /dev/null @@ -1,130 +0,0 @@ -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { getDefaultSpinner } from '@socketsecurity/lib/spinner' - -import { handlePatchDiscover } from './handle-patch-discover.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../utils/cli/with-subcommands.mjs' -import { InputError } from '../../utils/error/errors.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../utils/output/formatting.mts' -import { getOutputKind } from '../../utils/output/mode.mjs' -import { checkCommandInput } from '../../utils/validation/check-input.mts' - -import type { - CliCommandConfig, - CliCommandContext, - CliSubcommand, -} from '../../utils/cli/with-subcommands.mjs' - -export const CMD_NAME = 'discover' - -const description = 'Discover available patches for installed dependencies' - -export const cmdPatchDiscover: CliSubcommand = { - description, - hidden: false, - run, -} - -async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise { - const config: CliCommandConfig = { - commandName: CMD_NAME, - description, - flags: { - ...commonFlags, - ...outputFlags, - interactive: { - type: 'boolean', - default: false, - shortFlag: 'i', - description: 'Interactively download discovered patches', - }, - scan: { - type: 'string', - shortFlag: 's', - description: 'Discover patches from existing scan', - }, - }, - hidden: false, - help: (command, config) => ` - Usage - $ ${command} [CWD=.] - $ ${command} -s - $ ${command} -i - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} - $ ${command} ./path/to/project - $ ${command} -s scan-abc123 - $ ${command} -i - $ ${command} -s scan-abc123 -i - $ ${command} --json - `, - } - - const cli = meowOrExit( - { - argv, - config, - parentName, - importMeta, - }, - { allowUnknownFlags: false }, - ) - - const { interactive, json, markdown, scan } = cli.flags as unknown as { - interactive: boolean - json: boolean - markdown: boolean - scan?: string - } - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput(outputKind, { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }) - if (!wasValidInput) { - return - } - - let [cwd = '.'] = cli.input - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - // Check if node_modules exists (only if not using --scan). - if (!scan) { - const nodeModulesPath = path.join(cwd, 'node_modules') - if (!existsSync(nodeModulesPath)) { - throw new InputError( - 'No node_modules directory found. Run npm/yarn/pnpm install first', - ) - } - } - - await handlePatchDiscover({ - cwd, - interactive, - outputKind, - ...(scan ? { scanId: scan } : {}), - spinner: getDefaultSpinner() ?? undefined, - }) -} diff --git a/packages/cli/src/commands/patch/cmd-patch-download.mts b/packages/cli/src/commands/patch/cmd-patch-download.mts deleted file mode 100644 index ce5895221..000000000 --- a/packages/cli/src/commands/patch/cmd-patch-download.mts +++ /dev/null @@ -1,143 +0,0 @@ -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { DOT_SOCKET_DIR } from '@socketsecurity/lib/paths/dirnames' -import { getDefaultSpinner } from '@socketsecurity/lib/spinner' - -import { handlePatchDownload } from './handle-patch-download.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../utils/cli/with-subcommands.mjs' -import { InputError } from '../../utils/error/errors.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../utils/output/formatting.mts' -import { getOutputKind } from '../../utils/output/mode.mjs' -import { checkCommandInput } from '../../utils/validation/check-input.mts' - -import type { - CliCommandConfig, - CliCommandContext, - CliSubcommand, -} from '../../utils/cli/with-subcommands.mjs' - -export const CMD_NAME = 'download' - -const description = 'Download patches from Socket API' - -export const cmdPatchDownload: CliSubcommand = { - description, - hidden: false, - run, -} - -async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise { - const config: CliCommandConfig = { - commandName: CMD_NAME, - description, - hidden: false, - flags: { - ...commonFlags, - ...outputFlags, - scan: { - type: 'string', - description: 'Download patches from scan results', - }, - }, - help: (command, config) => ` - Usage - $ ${command} - $ ${command} --scan - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} 550e8400-e29b-41d4-a716-446655440000 - $ ${command} uuid1 uuid2 uuid3 - $ ${command} --scan scan-abc123 - $ ${command} --scan scan-abc123 --json - `, - } - - const cli = meowOrExit( - { - argv, - config, - parentName, - importMeta, - }, - { allowUnknownFlags: false }, - ) - - const { json, markdown, scan } = cli.flags as unknown as { - json: boolean - markdown: boolean - scan?: string - } - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput(outputKind, { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }) - if (!wasValidInput) { - return - } - - let uuids: string[] = [] - let cwd: string - - if (scan) { - // When using --scan, we can use current directory - cwd = process.cwd() - } else { - if (cli.input.length === 0) { - throw new InputError('Must provide patch UUIDs or use --scan flag') - } - - // First arg might be cwd if it's a directory - const firstArg = cli.input[0] - if ( - firstArg && - !firstArg.match(/^[0-9a-f-]{36}$/i) && - existsSync(firstArg) - ) { - cwd = firstArg - uuids = cli.input.slice(1) as string[] - } else { - cwd = process.cwd() - uuids = cli.input.slice() as string[] - } - } - - cwd = path.resolve(cwd) - - // Create .socket directory if it doesn't exist. - const dotSocketDirPath = path.join(cwd, DOT_SOCKET_DIR) - if (!existsSync(dotSocketDirPath)) { - throw new InputError( - `No ${DOT_SOCKET_DIR} directory found. Run 'socket scan create' first.`, - ) - } - - const spinner = getDefaultSpinner() - - await handlePatchDownload({ - cwd, - outputKind, - ...(scan ? { scanId: scan } : {}), - spinner, - uuids, - }) -} diff --git a/packages/cli/src/commands/patch/cmd-patch-get.mts b/packages/cli/src/commands/patch/cmd-patch-get.mts deleted file mode 100644 index d6709bb3e..000000000 --- a/packages/cli/src/commands/patch/cmd-patch-get.mts +++ /dev/null @@ -1,131 +0,0 @@ -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { DOT_SOCKET_DIR } from '@socketsecurity/lib/paths/dirnames' -import { MANIFEST_JSON } from '@socketsecurity/lib/paths/filenames' -import { getDefaultSpinner } from '@socketsecurity/lib/spinner' - -import { handlePatchGet } from './handle-patch-get.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../utils/cli/with-subcommands.mjs' -import { InputError } from '../../utils/error/errors.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../utils/output/formatting.mts' -import { getOutputKind } from '../../utils/output/mode.mjs' -import { checkCommandInput } from '../../utils/validation/check-input.mts' - -import type { - CliCommandConfig, - CliCommandContext, - CliSubcommand, -} from '../../utils/cli/with-subcommands.mjs' - -export const CMD_NAME = 'get' - -const description = 'Download patch files to local directory' - -export const cmdPatchGet: CliSubcommand = { - description, - hidden: false, - run, -} - -async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise { - const config: CliCommandConfig = { - commandName: CMD_NAME, - description, - hidden: false, - flags: { - ...commonFlags, - ...outputFlags, - output: { - type: 'string', - description: 'Output directory for patch files', - shortFlag: 'o', - }, - }, - help: (command, config) => ` - Usage - $ ${command} [CWD=.] [options] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} pkg:npm/on-headers@1.0.2 - $ ${command} pkg:npm/lodash@4.17.21 ./path/to/project - $ ${command} pkg:npm/react@18.0.0 --output ./patches - `, - } - - const cli = meowOrExit( - { - argv, - config, - parentName, - importMeta, - }, - { allowUnknownFlags: false }, - ) - - const { json, markdown, output } = cli.flags as unknown as { - json: boolean - markdown: boolean - output: string | undefined - } - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput(outputKind, { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }) - if (!wasValidInput) { - return - } - - const [purl, cwdArg] = cli.input - if (!purl) { - throw new InputError('PURL is required') - } - - let cwd = cwdArg || '.' - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - const dotSocketDirPath = path.join(cwd, DOT_SOCKET_DIR) - if (!existsSync(dotSocketDirPath)) { - throw new InputError( - `No ${DOT_SOCKET_DIR} directory found in current directory`, - ) - } - - const manifestPath = path.join(dotSocketDirPath, MANIFEST_JSON) - if (!existsSync(manifestPath)) { - throw new InputError( - `No ${MANIFEST_JSON} found in ${DOT_SOCKET_DIR} directory`, - ) - } - - const spinner = getDefaultSpinner() - - await handlePatchGet({ - cwd, - outputDir: output, - outputKind, - purl, - spinner, - }) -} diff --git a/packages/cli/src/commands/patch/cmd-patch-info.mts b/packages/cli/src/commands/patch/cmd-patch-info.mts deleted file mode 100644 index 68093dbe6..000000000 --- a/packages/cli/src/commands/patch/cmd-patch-info.mts +++ /dev/null @@ -1,124 +0,0 @@ -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { DOT_SOCKET_DIR } from '@socketsecurity/lib/paths/dirnames' -import { MANIFEST_JSON } from '@socketsecurity/lib/paths/filenames' -import { getDefaultSpinner } from '@socketsecurity/lib/spinner' - -import { handlePatchInfo } from './handle-patch-info.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../utils/cli/with-subcommands.mjs' -import { InputError } from '../../utils/error/errors.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../utils/output/formatting.mts' -import { getOutputKind } from '../../utils/output/mode.mjs' -import { checkCommandInput } from '../../utils/validation/check-input.mts' - -import type { - CliCommandConfig, - CliCommandContext, - CliSubcommand, -} from '../../utils/cli/with-subcommands.mjs' - -export const CMD_NAME = 'info' - -const description = 'Show detailed information about a specific patch' - -export const cmdPatchInfo: CliSubcommand = { - description, - hidden: false, - run, -} - -async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise { - const config: CliCommandConfig = { - commandName: CMD_NAME, - description, - hidden: false, - flags: { - ...commonFlags, - ...outputFlags, - }, - help: (command, config) => ` - Usage - $ ${command} [CWD=.] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} pkg:npm/on-headers@1.0.2 - $ ${command} pkg:npm/lodash@4.17.21 ./path/to/project - $ ${command} pkg:npm/react@18.0.0 --json - `, - } - - const cli = meowOrExit( - { - argv, - config, - parentName, - importMeta, - }, - { allowUnknownFlags: false }, - ) - - const { json, markdown } = cli.flags as unknown as { - json: boolean - markdown: boolean - } - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput(outputKind, { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }) - if (!wasValidInput) { - return - } - - const [purl, cwdArg] = cli.input - if (!purl) { - throw new InputError('PURL is required') - } - - let cwd = cwdArg || '.' - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - const dotSocketDirPath = path.join(cwd, DOT_SOCKET_DIR) - if (!existsSync(dotSocketDirPath)) { - throw new InputError( - `No ${DOT_SOCKET_DIR} directory found in current directory`, - ) - } - - const manifestPath = path.join(dotSocketDirPath, MANIFEST_JSON) - if (!existsSync(manifestPath)) { - throw new InputError( - `No ${MANIFEST_JSON} found in ${DOT_SOCKET_DIR} directory`, - ) - } - - const spinner = getDefaultSpinner() - - await handlePatchInfo({ - cwd, - outputKind, - purl, - spinner, - }) -} diff --git a/packages/cli/src/commands/patch/cmd-patch-list.mts b/packages/cli/src/commands/patch/cmd-patch-list.mts deleted file mode 100644 index 122f13195..000000000 --- a/packages/cli/src/commands/patch/cmd-patch-list.mts +++ /dev/null @@ -1,132 +0,0 @@ -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { DOT_SOCKET_DIR } from '@socketsecurity/lib/paths/dirnames' -import { MANIFEST_JSON } from '@socketsecurity/lib/paths/filenames' -import { getDefaultSpinner } from '@socketsecurity/lib/spinner' - -import { handlePatchList } from './handle-patch-list.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../utils/cli/with-subcommands.mjs' -import { InputError } from '../../utils/error/errors.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../utils/output/formatting.mts' -import { getOutputKind } from '../../utils/output/mode.mjs' -import { checkCommandInput } from '../../utils/validation/check-input.mts' - -import type { - CliCommandConfig, - CliCommandContext, - CliSubcommand, -} from '../../utils/cli/with-subcommands.mjs' - -export const CMD_NAME = 'list' - -const description = 'List all applied patches' - -export const cmdPatchList: CliSubcommand = { - description, - hidden: false, - run, -} - -async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise { - const config: CliCommandConfig = { - commandName: CMD_NAME, - description, - hidden: false, - flags: { - ...commonFlags, - ...outputFlags, - interactive: { - type: 'boolean', - default: false, - description: 'Interactively select patches to apply', - }, - }, - help: (command, config) => ` - Usage - $ ${command} [CWD=.] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} - $ ${command} ./path/to/project - $ ${command} --json - $ ${command} --interactive - `, - } - - const cli = meowOrExit( - { - argv, - config, - parentName, - importMeta, - }, - { allowUnknownFlags: false }, - ) - - const { interactive, json, markdown } = cli.flags as unknown as { - interactive: boolean - json: boolean - markdown: boolean - } - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput(outputKind, { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }) - if (!wasValidInput) { - return - } - - if (interactive && (json || markdown)) { - throw new InputError( - 'Cannot use --interactive with --json or --markdown flags', - ) - } - - let [cwd = '.'] = cli.input - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - const dotSocketDirPath = path.join(cwd, DOT_SOCKET_DIR) - if (!existsSync(dotSocketDirPath)) { - throw new InputError( - `No ${DOT_SOCKET_DIR} directory found in current directory`, - ) - } - - const manifestPath = path.join(dotSocketDirPath, MANIFEST_JSON) - if (!existsSync(manifestPath)) { - throw new InputError( - `No ${MANIFEST_JSON} found in ${DOT_SOCKET_DIR} directory`, - ) - } - - const spinner = getDefaultSpinner() - - await handlePatchList({ - cwd, - interactive, - outputKind, - spinner, - }) -} diff --git a/packages/cli/src/commands/patch/cmd-patch-rm.mts b/packages/cli/src/commands/patch/cmd-patch-rm.mts deleted file mode 100644 index 6c07d5406..000000000 --- a/packages/cli/src/commands/patch/cmd-patch-rm.mts +++ /dev/null @@ -1,131 +0,0 @@ -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { DOT_SOCKET_DIR } from '@socketsecurity/lib/paths/dirnames' -import { MANIFEST_JSON } from '@socketsecurity/lib/paths/filenames' -import { getDefaultSpinner } from '@socketsecurity/lib/spinner' - -import { handlePatchRm } from './handle-patch-rm.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../utils/cli/with-subcommands.mjs' -import { InputError } from '../../utils/error/errors.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../utils/output/formatting.mts' -import { getOutputKind } from '../../utils/output/mode.mjs' -import { checkCommandInput } from '../../utils/validation/check-input.mts' - -import type { - CliCommandConfig, - CliCommandContext, - CliSubcommand, -} from '../../utils/cli/with-subcommands.mjs' - -export const CMD_NAME = 'rm' - -const description = 'Remove applied patch and restore original files' - -export const cmdPatchRm: CliSubcommand = { - description, - hidden: false, - run, -} - -async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise { - const config: CliCommandConfig = { - commandName: CMD_NAME, - description, - hidden: false, - flags: { - ...commonFlags, - ...outputFlags, - 'keep-backups': { - type: 'boolean', - default: false, - description: 'Keep backup files after removing patch', - }, - }, - help: (command, config) => ` - Usage - $ ${command} [CWD=.] [options] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} pkg:npm/on-headers@1.0.2 - $ ${command} pkg:npm/lodash@4.17.21 ./path/to/project - $ ${command} pkg:npm/react@18.0.0 --keep-backups - `, - } - - const cli = meowOrExit( - { - argv, - config, - parentName, - importMeta, - }, - { allowUnknownFlags: false }, - ) - - const { json, keepBackups, markdown } = cli.flags as unknown as { - json: boolean - keepBackups: boolean - markdown: boolean - } - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput(outputKind, { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }) - if (!wasValidInput) { - return - } - - const [purl, cwdArg] = cli.input - if (!purl) { - throw new InputError('PURL is required') - } - - let cwd = cwdArg || '.' - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - const dotSocketDirPath = path.join(cwd, DOT_SOCKET_DIR) - if (!existsSync(dotSocketDirPath)) { - throw new InputError( - `No ${DOT_SOCKET_DIR} directory found in current directory`, - ) - } - - const manifestPath = path.join(dotSocketDirPath, MANIFEST_JSON) - if (!existsSync(manifestPath)) { - throw new InputError( - `No ${MANIFEST_JSON} found in ${DOT_SOCKET_DIR} directory`, - ) - } - - const spinner = getDefaultSpinner() - - await handlePatchRm({ - cwd, - keepBackups, - outputKind, - purl, - spinner, - }) -} diff --git a/packages/cli/src/commands/patch/cmd-patch-status.mts b/packages/cli/src/commands/patch/cmd-patch-status.mts deleted file mode 100644 index 4023387ca..000000000 --- a/packages/cli/src/commands/patch/cmd-patch-status.mts +++ /dev/null @@ -1,143 +0,0 @@ -import { existsSync } from 'node:fs' -import path from 'node:path' - -import { DOT_SOCKET_DIR } from '@socketsecurity/lib/paths/dirnames' -import { MANIFEST_JSON } from '@socketsecurity/lib/paths/filenames' -import { getDefaultSpinner } from '@socketsecurity/lib/spinner' - -import { handlePatchStatus } from './handle-patch-status.mts' -import { commonFlags, outputFlags } from '../../flags.mts' -import { meowOrExit } from '../../utils/cli/with-subcommands.mjs' -import { InputError } from '../../utils/error/errors.mjs' -import { - getFlagApiRequirementsOutput, - getFlagListOutput, -} from '../../utils/output/formatting.mts' -import { getOutputKind } from '../../utils/output/mode.mjs' -import { checkCommandInput } from '../../utils/validation/check-input.mts' - -import type { - CliCommandConfig, - CliCommandContext, - CliSubcommand, -} from '../../utils/cli/with-subcommands.mjs' - -export const CMD_NAME = 'status' - -const description = 'Show patch application status' - -export const cmdPatchStatus: CliSubcommand = { - description, - hidden: false, - run, -} - -async function run( - argv: string[] | readonly string[], - importMeta: ImportMeta, - { parentName }: CliCommandContext, -): Promise { - const config: CliCommandConfig = { - commandName: CMD_NAME, - description, - hidden: false, - flags: { - ...commonFlags, - ...outputFlags, - applied: { - type: 'boolean', - default: false, - description: 'Show only applied patches', - }, - downloaded: { - type: 'boolean', - default: false, - description: 'Show only downloaded patches', - }, - failed: { - type: 'boolean', - default: false, - description: 'Show only failed patches', - }, - }, - help: (command, config) => ` - Usage - $ ${command} [CWD=.] - - API Token Requirements - ${getFlagApiRequirementsOutput(`${parentName}:${CMD_NAME}`)} - - Options - ${getFlagListOutput(config.flags)} - - Examples - $ ${command} - $ ${command} ./path/to/project - $ ${command} --applied - $ ${command} --json - `, - } - - const cli = meowOrExit( - { - argv, - config, - parentName, - importMeta, - }, - { allowUnknownFlags: false }, - ) - - const { applied, downloaded, failed, json, markdown } = - cli.flags as unknown as { - applied: boolean - downloaded: boolean - failed: boolean - json: boolean - markdown: boolean - } - - const outputKind = getOutputKind(json, markdown) - - const wasValidInput = checkCommandInput(outputKind, { - nook: true, - test: !json || !markdown, - message: 'The json and markdown flags cannot be both set, pick one', - fail: 'omit one', - }) - if (!wasValidInput) { - return - } - - let [cwd = '.'] = cli.input - // Note: path.resolve vs .join: - // If given path is absolute then cwd should not affect it. - cwd = path.resolve(process.cwd(), cwd) - - const dotSocketDirPath = path.join(cwd, DOT_SOCKET_DIR) - if (!existsSync(dotSocketDirPath)) { - throw new InputError( - `No ${DOT_SOCKET_DIR} directory found in current directory`, - ) - } - - const manifestPath = path.join(dotSocketDirPath, MANIFEST_JSON) - if (!existsSync(manifestPath)) { - throw new InputError( - `No ${MANIFEST_JSON} found in ${DOT_SOCKET_DIR} directory`, - ) - } - - const spinner = getDefaultSpinner() - - await handlePatchStatus({ - cwd, - filters: { - applied, - downloaded, - failed, - }, - outputKind, - spinner, - }) -} diff --git a/packages/cli/src/commands/patch/cmd-patch.mts b/packages/cli/src/commands/patch/cmd-patch.mts index 5e5ace8c5..729eb28cd 100644 --- a/packages/cli/src/commands/patch/cmd-patch.mts +++ b/packages/cli/src/commands/patch/cmd-patch.mts @@ -1,15 +1,13 @@ -import { cmdPatchApply } from './cmd-patch-apply.mts' -import { cmdPatchCleanup } from './cmd-patch-cleanup.mts' -import { cmdPatchDiscover } from './cmd-patch-discover.mts' -import { cmdPatchDownload } from './cmd-patch-download.mts' -import { cmdPatchGet } from './cmd-patch-get.mts' -import { cmdPatchInfo } from './cmd-patch-info.mts' -import { cmdPatchList } from './cmd-patch-list.mts' -import { cmdPatchRm } from './cmd-patch-rm.mts' -import { cmdPatchStatus } from './cmd-patch-status.mts' -import { meowWithSubcommands } from '../../utils/cli/with-subcommands.mjs' - -import type { CliSubcommand } from '../../utils/cli/with-subcommands.mjs' +import { spawnSocketPatchDlx } from '../../utils/dlx/spawn.mjs' +import { meowOrExit } from '../../utils/cli/with-subcommands.mjs' + +import type { + CliCommandConfig, + CliCommandContext, + CliSubcommand, +} from '../../utils/cli/with-subcommands.mjs' + +export const CMD_NAME = 'patch' const description = 'Manage CVE patches for dependencies' @@ -18,28 +16,62 @@ const hidden = false export const cmdPatch: CliSubcommand = { description, hidden, - async run(argv, importMeta, { parentName }) { - await meowWithSubcommands( - { - argv, - name: `${parentName} patch`, - importMeta, - subcommands: { - apply: cmdPatchApply, - cleanup: cmdPatchCleanup, - discover: cmdPatchDiscover, - download: cmdPatchDownload, - get: cmdPatchGet, - info: cmdPatchInfo, - list: cmdPatchList, - rm: cmdPatchRm, - status: cmdPatchStatus, - }, - }, - { - defaultSub: 'discover', - description, - }, - ) - }, + run, +} + +async function run( + argv: string[] | readonly string[], + importMeta: ImportMeta, + context: CliCommandContext, +): Promise { + const { parentName } = { __proto__: null, ...context } as CliCommandContext + + // Check if there are any non-flag arguments (subcommands). + const hasSubcommand = argv.some(arg => !arg.startsWith('-')) + + // Only show Socket CLI help if no subcommand is provided. + // If a subcommand is present (like 'list', 'info'), forward to socket-patch. + if (!hasSubcommand) { + const config: CliCommandConfig = { + commandName: CMD_NAME, + description, + hidden, + flags: {}, + help: command => ` + Usage + $ ${command} ... + + Note: All arguments are forwarded to socket-patch. + + Examples + $ ${command} list + $ ${command} get + $ ${command} apply + `, + } + + // Parse arguments to handle --help for patch-level help. + meowOrExit({ + argv, + config, + importMeta, + parentName, + }) + } + + process.exitCode = 1 + + // Forward all arguments to socket-patch via DLX. + const { spawnPromise } = await spawnSocketPatchDlx([...argv], { + stdio: 'inherit', + }) + + // Wait for the spawn to complete and set exit code. + const result = await spawnPromise + + if (result.code !== null && result.code !== 0) { + process.exitCode = result.code + } else if (result.code === 0) { + process.exitCode = 0 + } } diff --git a/packages/cli/src/commands/patch/handle-patch-apply.mts b/packages/cli/src/commands/patch/handle-patch-apply.mts deleted file mode 100644 index 0ad938262..000000000 --- a/packages/cli/src/commands/patch/handle-patch-apply.mts +++ /dev/null @@ -1,530 +0,0 @@ -import crypto from 'node:crypto' -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' - -import fastGlob from 'fast-glob' - -import { joinAnd } from '@socketsecurity/lib/arrays' -import { NPM } from '@socketsecurity/lib/constants/agents' -import { UTF8 } from '@socketsecurity/lib/constants/encoding' -import { debugDirNs } from '@socketsecurity/lib/debug' -import { readDirNames } from '@socketsecurity/lib/fs' -import { getDefaultLogger } from '@socketsecurity/lib/logger' -import { readPackageJson } from '@socketsecurity/lib/packages' -import { - DOT_SOCKET_DIR, - NODE_MODULES, -} from '@socketsecurity/lib/paths/dirnames' -import { MANIFEST_JSON } from '@socketsecurity/lib/paths/filenames' -import { normalizePath } from '@socketsecurity/lib/paths/normalize' -import { isNonEmptyString } from '@socketsecurity/lib/strings' -import { pluralize } from '@socketsecurity/lib/words' - -import { PatchManifestSchema } from './manifest-schema.mts' -import { outputPatchResult } from './output-patch-result.mts' -import { getErrorCause } from '../../utils/error/errors.mjs' -import { findUp } from '../../utils/fs/find-up.mjs' -import { createBackup } from '../../utils/manifest/patch-backup.mts' -import { updatePatchStatus } from '../../utils/manifest/patches.mts' -import { getPurlObject, normalizePurl } from '../../utils/purl/parse.mjs' - -import type { PatchRecord } from './manifest-schema.mts' -import type { CResult, OutputKind } from '../../types.mts' -import type { PackageURL } from '@socketregistry/packageurl-js' -import type { Spinner } from '@socketsecurity/lib/spinner' -const logger = getDefaultLogger() - -type PatchEntry = { - key: string - patch: PatchRecord - purl: string - purlObj: PackageURL -} - -type PatchFileInfo = { - beforeHash: string - afterHash: string -} - -type ApplyNpmPatchesOptions = { - cwd?: string | undefined - dryRun?: boolean | undefined - purlObjs?: PackageURL[] | undefined - spinner?: Spinner | undefined -} - -type ApplyNpmPatchesResult = { - passed: string[] - failed: string[] - locations: Map -} - -async function applyNpmPatches( - socketDir: string, - patches: PatchEntry[], - options?: ApplyNpmPatchesOptions | undefined, -): Promise { - const { - cwd = process.cwd(), - dryRun = false, - purlObjs, - spinner, - } = { __proto__: null, ...options } as ApplyNpmPatchesOptions - - const wasSpinning = !!spinner?.isSpinning - - spinner?.start() - - const patchLookup = new Map() - for (const patchInfo of patches) { - patchLookup.set(patchInfo.purl, patchInfo) - } - - const nmPaths = await findNodeModulesPaths(cwd) - - spinner?.stop() - - logger.log( - `Found ${nmPaths.length} ${NODE_MODULES} ${pluralize('folder', { count: nmPaths.length })}`, - ) - - logger.group('') - - spinner?.start() - - const result: ApplyNpmPatchesResult = { - passed: [], - failed: [], - locations: new Map(), - } - - for (const nmPath of nmPaths) { - // eslint-disable-next-line no-await-in-loop - const dirNames = await readDirNames(nmPath) - for (const dirName of dirNames) { - const isScoped = dirName.startsWith('@') - const pkgPath = normalizePath(path.join(nmPath, dirName)) - const pkgSubNames = isScoped - ? // eslint-disable-next-line no-await-in-loop - await readDirNames(pkgPath) - : [dirName] - - for (const pkgSubName of pkgSubNames) { - const dirFullName = isScoped ? `${dirName}/${pkgSubName}` : pkgSubName - const pkgPath = normalizePath(path.join(nmPath, dirFullName)) - // eslint-disable-next-line no-await-in-loop - const pkgJson = await readPackageJson(pkgPath, { throws: false }) - if ( - !isNonEmptyString(pkgJson?.name) || - !isNonEmptyString(pkgJson?.version) - ) { - continue - } - - const purl = `pkg:npm/${pkgJson.name}@${pkgJson.version}` - const purlObj = getPurlObject(purl, { throws: false }) - if (!purlObj) { - continue - } - - // Skip if specific packages requested and this isn't one of them - if ( - purlObjs?.length && - purlObjs.findIndex( - p => - p.type === NPM && - p.namespace === purlObj.namespace && - p.name === purlObj.name, - ) === -1 - ) { - continue - } - - const patchInfo = patchLookup.get(purl) - if (!patchInfo) { - continue - } - - spinner?.stop() - - logger.log( - `Found match: ${pkgJson.name}@${pkgJson.version} at ${pkgPath}`, - ) - logger.log(`Patch key: ${patchInfo.key}`) - logger.group('Processing files:') - - spinner?.start() - - let passed = true - - for (const { 0: fileName, 1: fileInfo } of Object.entries( - patchInfo.patch.files, - )) { - // eslint-disable-next-line no-await-in-loop - const filePatchPassed = await processFilePatch( - pkgPath, - fileName, - fileInfo, - socketDir, - { - dryRun, - patchUuid: patchInfo.patch.uuid, - spinner, - }, - ) - if (!filePatchPassed) { - passed = false - } - } - - logger.groupEnd() - - if (passed) { - result.passed.push(purl) - // Track the location where this patch was applied. - const locations = result.locations.get(purl) || [] - locations.push(pkgPath) - result.locations.set(purl, locations) - } else { - result.failed.push(purl) - } - } - } - } - - spinner?.stop() - - logger.groupEnd() - - if (wasSpinning) { - spinner?.start() - } - return result -} - -/** - * Compute SHA256 hash of file contents. - */ -async function computeSHA256(filepath: string): Promise> { - try { - const content = await fs.readFile(filepath) - const hash = crypto.createHash('sha256') - hash.update(content) - return { - ok: true, - data: hash.digest('hex'), - } - } catch (e) { - return { - ok: false, - message: 'Failed to compute file hash', - cause: `Unable to read file ${filepath}: ${getErrorCause(e)}`, - } - } -} - -async function findNodeModulesPaths(cwd: string): Promise { - const rootNmPath = await findUp(NODE_MODULES, { cwd, onlyDirectories: true }) - if (!rootNmPath) { - return [] - } - return await fastGlob.glob([`**/${NODE_MODULES}`], { - absolute: true, - cwd: path.dirname(rootNmPath), - dot: true, - followSymbolicLinks: false, - onlyDirectories: true, - }) -} - -type ProcessFilePatchOptions = { - dryRun?: boolean | undefined - patchUuid?: string | undefined - spinner?: Spinner | undefined -} - -async function processFilePatch( - pkgPath: string, - fileName: string, - fileInfo: PatchFileInfo, - socketDir: string, - options?: ProcessFilePatchOptions | undefined, -): Promise { - const { dryRun, patchUuid, spinner } = { - __proto__: null, - ...options, - } as ProcessFilePatchOptions - - const wasSpinning = !!spinner?.isSpinning - - spinner?.stop() - - const filepath = normalizePath(path.join(pkgPath, fileName)) - if (!existsSync(filepath)) { - logger.log(`File not found: ${fileName}`) - if (wasSpinning) { - spinner?.start() - } - return false - } - - const currentHashResult = await computeSHA256(filepath) - if (!currentHashResult.ok) { - logger.log( - `Failed to compute hash for: ${fileName}: ${currentHashResult.cause || currentHashResult.message}`, - ) - if (wasSpinning) { - spinner?.start() - } - return false - } - - if (currentHashResult.data === fileInfo.afterHash) { - logger.success(`File already patched: ${fileName}`) - logger.group() - logger.log(`Current hash: ${currentHashResult.data}`) - logger.groupEnd() - if (wasSpinning) { - spinner?.start() - } - return true - } - - if (currentHashResult.data !== fileInfo.beforeHash) { - logger.fail(`File hash mismatch: ${fileName}`) - logger.group() - logger.log(`Expected: ${fileInfo.beforeHash}`) - logger.log(`Current: ${currentHashResult.data}`) - logger.log(`Target: ${fileInfo.afterHash}`) - logger.groupEnd() - if (wasSpinning) { - spinner?.start() - } - return false - } - - logger.success(`File matches expected hash: ${fileName}`) - logger.group() - logger.log(`Current hash: ${currentHashResult.data}`) - logger.log(`Ready to patch to: ${fileInfo.afterHash}`) - logger.group() - - if (dryRun) { - logger.log('(dry run - no changes made)') - logger.groupEnd() - logger.groupEnd() - if (wasSpinning) { - spinner?.start() - } - return false - } - - const blobPath = normalizePath( - path.join(socketDir, 'blobs', fileInfo.afterHash), - ) - if (!existsSync(blobPath)) { - logger.fail(`Error: Patch file not found at ${blobPath}`) - logger.groupEnd() - logger.groupEnd() - if (wasSpinning) { - spinner?.start() - } - return false - } - - spinner?.start() - - let result = true - try { - // Create backup before applying patch if UUID is provided. - if (patchUuid) { - try { - await createBackup(patchUuid, filepath) - logger.log(`Created backup for ${fileName}`) - } catch (e) { - logger.warn( - `Failed to create backup for ${fileName}: ${getErrorCause(e)}`, - ) - // Continue with patching even if backup fails. - } - } - - await fs.copyFile(blobPath, filepath) - - // Verify the hash after copying to ensure file integrity. - const verifyHashResult = await computeSHA256(filepath) - if (!verifyHashResult.ok) { - logger.error( - `Failed to verify hash after patch: ${verifyHashResult.cause || verifyHashResult.message}`, - ) - result = false - } else if (verifyHashResult.data !== fileInfo.afterHash) { - logger.error('Hash verification failed after patch') - logger.group() - logger.log(`Expected: ${fileInfo.afterHash}`) - logger.log(`Got: ${verifyHashResult.data}`) - logger.groupEnd() - result = false - } else { - logger.success('Patch applied successfully') - } - } catch (e) { - logger.error('Error applying patch') - debugDirNs('error', e) - result = false - } - logger.groupEnd() - logger.groupEnd() - - spinner?.stop() - - if (wasSpinning) { - spinner?.start() - } - return result -} - -export interface HandlePatchApplyConfig { - cwd: string - dryRun: boolean - outputKind: OutputKind - purlObjs: PackageURL[] - spinner: Spinner | null -} - -export async function handlePatchApply({ - cwd, - dryRun, - outputKind, - purlObjs, - spinner, -}: HandlePatchApplyConfig): Promise { - try { - const dotSocketDirPath = normalizePath(path.join(cwd, DOT_SOCKET_DIR)) - const manifestPath = normalizePath( - path.join(dotSocketDirPath, MANIFEST_JSON), - ) - const manifestContent = await fs.readFile(manifestPath, UTF8) - const manifestData = JSON.parse(manifestContent) - const purls = purlObjs.map(String) - const validated = PatchManifestSchema.parse(manifestData) - - // Parse PURLs and group by ecosystem. - const patchesByEcosystem = new Map() - for (const { 0: key, 1: patch } of Object.entries(validated.patches)) { - const purl = normalizePurl(key) - if (purls.length && !purls.includes(purl)) { - continue - } - const purlObj = getPurlObject(purl, { throws: false }) - if (!purlObj) { - continue - } - let patches = patchesByEcosystem.get(purlObj.type) - if (!Array.isArray(patches)) { - patches = [] - patchesByEcosystem.set(purlObj.type, patches) - } - patches.push({ - key, - patch, - purl, - purlObj, - }) - } - - if (purls.length) { - const displayPurls = - purls.length > 3 - ? `${purls.slice(0, 3).join(', ')} … and ${purls.length - 3} more` - : joinAnd(purls) - spinner?.start(`Checking patches for: ${displayPurls}`) - } else { - spinner?.start('Scanning all dependencies for available patches') - } - - const patched = [] - - const npmPatches = patchesByEcosystem.get(NPM) - if (npmPatches) { - const patchingResults = await applyNpmPatches( - dotSocketDirPath, - npmPatches, - { - cwd, - dryRun, - purlObjs, - spinner: spinner ?? undefined, - }, - ) - patched.push(...patchingResults.passed) - - // Update manifest with application status for successful patches. - if (!dryRun) { - for (const purl of patchingResults.passed) { - const locations = patchingResults.locations.get(purl) || [] - try { - // eslint-disable-next-line no-await-in-loop - await updatePatchStatus(purl, 'applied', { - appliedAt: new Date().toISOString(), - appliedTo: locations, - }) - } catch (e) { - // Log error but don't fail the whole operation. - logger.warn( - `Failed to update status for ${purl}: ${getErrorCause(e)}`, - ) - } - } - - // Update status to 'failed' for patches that didn't apply. - for (const purl of patchingResults.failed) { - try { - // eslint-disable-next-line no-await-in-loop - await updatePatchStatus(purl, 'failed', {}) - } catch (e) { - // Log error but don't fail the whole operation. - logger.warn( - `Failed to update status for ${purl}: ${getErrorCause(e)}`, - ) - } - } - } - } - - spinner?.stop() - - await outputPatchResult( - { - ok: true, - data: { - patched, - }, - }, - outputKind, - ) - } catch (e) { - spinner?.stop() - - let message = 'Failed to apply patches' - let cause = getErrorCause(e) - - if (e instanceof SyntaxError) { - message = `Invalid JSON in ${MANIFEST_JSON}` - cause = e.message - } else if (e instanceof Error && 'issues' in e) { - message = 'Schema validation failed' - cause = String(e) - } - - await outputPatchResult( - { - ok: false, - code: 1, - message, - cause, - }, - outputKind, - ) - } -} diff --git a/packages/cli/src/commands/patch/handle-patch-cleanup.mts b/packages/cli/src/commands/patch/handle-patch-cleanup.mts deleted file mode 100644 index e31500e4e..000000000 --- a/packages/cli/src/commands/patch/handle-patch-cleanup.mts +++ /dev/null @@ -1,166 +0,0 @@ -import { promises as fs } from 'node:fs' -import path from 'node:path' - -import { UTF8 } from '@socketsecurity/lib/constants/encoding' -import { getDefaultLogger } from '@socketsecurity/lib/logger' -import { DOT_SOCKET_DIR } from '@socketsecurity/lib/paths/dirnames' -import { MANIFEST_JSON } from '@socketsecurity/lib/paths/filenames' -import { normalizePath } from '@socketsecurity/lib/paths/normalize' -import { pluralize } from '@socketsecurity/lib/words' - -import { PatchManifestSchema } from './manifest-schema.mts' -import { outputPatchCleanupResult } from './output-patch-cleanup-result.mts' -import { getErrorCause } from '../../utils/error/errors.mjs' -import { - cleanupBackups, - listAllPatches, -} from '../../utils/manifest/patch-backup.mts' - -import type { OutputKind } from '../../types.mts' -import type { Spinner } from '@socketsecurity/lib/spinner' -const logger = getDefaultLogger() - -export interface PatchCleanupData { - cleaned: string[] -} - -export interface HandlePatchCleanupConfig { - all: boolean - cwd: string - outputKind: OutputKind - spinner: Spinner | null - uuid: string | undefined -} - -export async function handlePatchCleanup({ - all, - cwd, - outputKind, - spinner, - uuid, -}: HandlePatchCleanupConfig): Promise { - try { - const cleaned: string[] = [] - - if (uuid) { - // Clean up specific UUID. - spinner?.start(`Cleaning up backups for ${uuid}`) - await cleanupBackups(uuid) - cleaned.push(uuid) - spinner?.stop() - if (outputKind === 'text') { - logger.log(`Cleaned up backups for ${uuid}`) - } - } else if (all) { - // Clean up all backups. - spinner?.start('Finding all patch backups') - const allPatchUuids = await listAllPatches() - - if (allPatchUuids.length === 0) { - spinner?.stop() - if (outputKind === 'text') { - logger.log('No patch backups found') - } - } else { - spinner?.text( - `Cleaning up ${allPatchUuids.length} ${pluralize('patch', { count: allPatchUuids.length })}`, - ) - - for (const patchUuid of allPatchUuids) { - // eslint-disable-next-line no-await-in-loop - await cleanupBackups(patchUuid) - cleaned.push(patchUuid) - } - - spinner?.stop() - if (outputKind === 'text') { - logger.log( - `Cleaned up backups for ${cleaned.length} ${pluralize('patch', { count: cleaned.length })}`, - ) - } - } - } else { - // Clean up orphaned backups (not in manifest). - spinner?.start('Reading patch manifest') - - const dotSocketDirPath = normalizePath(path.join(cwd, DOT_SOCKET_DIR)) - const manifestPath = normalizePath( - path.join(dotSocketDirPath, MANIFEST_JSON), - ) - const manifestContent = await fs.readFile(manifestPath, UTF8) - const manifestData = JSON.parse(manifestContent) - const validated = PatchManifestSchema.parse(manifestData) - - // Get UUIDs from manifest. - const manifestUuids = new Set() - for (const patch of Object.values(validated.patches)) { - if (patch.uuid) { - manifestUuids.add(patch.uuid) - } - } - - spinner?.text('Finding all patch backups') - const allPatchUuids = await listAllPatches() - - // Find orphaned UUIDs (in backups but not in manifest). - const orphanedUuids = allPatchUuids.filter( - patchUuid => !manifestUuids.has(patchUuid), - ) - - if (orphanedUuids.length === 0) { - spinner?.stop() - if (outputKind === 'text') { - logger.log('No orphaned patch backups found') - } - } else { - spinner?.text( - `Cleaning up ${orphanedUuids.length} orphaned ${pluralize('backup', { count: orphanedUuids.length })}`, - ) - - for (const patchUuid of orphanedUuids) { - // eslint-disable-next-line no-await-in-loop - await cleanupBackups(patchUuid) - cleaned.push(patchUuid) - } - - spinner?.stop() - if (outputKind === 'text') { - logger.log( - `Cleaned up ${cleaned.length} orphaned ${pluralize('backup', { count: cleaned.length })}`, - ) - } - } - } - - await outputPatchCleanupResult( - { - ok: true, - data: { cleaned }, - }, - outputKind, - ) - } catch (e) { - spinner?.stop() - - let message = 'Failed to clean up patch backups' - let cause = getErrorCause(e) - - if (e instanceof SyntaxError) { - message = `Invalid JSON in ${MANIFEST_JSON}` - cause = e.message - } else if (e instanceof Error && 'issues' in e) { - message = 'Schema validation failed' - cause = String(e) - } - - await outputPatchCleanupResult( - { - ok: false, - code: 1, - message, - cause, - }, - outputKind, - ) - } -} diff --git a/packages/cli/src/commands/patch/handle-patch-discover.mts b/packages/cli/src/commands/patch/handle-patch-discover.mts deleted file mode 100644 index 518cb672d..000000000 --- a/packages/cli/src/commands/patch/handle-patch-discover.mts +++ /dev/null @@ -1,764 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib/logger' -import { pluralize } from '@socketsecurity/lib/words' - -import { handlePatchDownload } from './handle-patch-download.mts' -import { outputPatchDiscoverResult } from './output-patch-discover-result.mts' -import ENV from '../../constants/env.mts' -import { getErrorCause } from '../../utils/error/errors.mjs' -import { getPackageFilesForScan } from '../../utils/fs/path-resolve.mjs' -import { setupSdk } from '../../utils/socket/sdk.mts' -import { fetchCreateOrgFullScan } from '../scan/fetch-create-org-full-scan.mts' -import { fetchSupportedScanFileNames } from '../scan/fetch-supported-scan-file-names.mts' - -import type { OutputKind } from '../../types.mts' -import type { Spinner } from '@socketsecurity/lib/spinner' -import type { SocketSdk } from '@socketsecurity/sdk' -const logger = getDefaultLogger() - -export type PatchVulnerability = { - cve?: string - severity?: string -} - -export interface DiscoveredPatch { - description: string | undefined - freeCves: PatchVulnerability[] - freeFeatures: string[] - license: string | undefined - paidCves: PatchVulnerability[] - paidFeatures: string[] - purl: string - tier: string | undefined - uuid: string | undefined -} - -export interface HandlePatchDiscoverConfig { - cwd: string - interactive?: boolean - outputKind: OutputKind - scanId?: string - spinner: Spinner | undefined -} - -type CveRecord = { - cveId: string | null - ghsaId: string | null - severity: string - summary: string -} - -type PurlObject = { - artifactId?: string - name: string - namespace?: string - subpath?: string - type: string - version?: string -} - -type PatchData = { - description: string - freeCves: CveRecord[] - freeFeatures: string[] - license: string - paidCves: CveRecord[] - paidFeatures: string[] - publishedAt: string - tier: 'free' | 'enterprise' - uuid: string -} - -type ArtifactPatchRecord = { - artifactId: string - patch: PatchData | null - purl: PurlObject - purlString: string -} - -export async function handlePatchDiscover({ - cwd, - interactive = false, - outputKind, - scanId, - spinner, -}: HandlePatchDiscoverConfig): Promise { - try { - // Setup SDK. - spinner?.start('Initializing Socket SDK...') - const sdkResult = await setupSdk() - if (!sdkResult.ok) { - spinner?.failAndStop('Failed to initialize Socket SDK') - await outputPatchDiscoverResult(sdkResult, outputKind) - return - } - - const sdk = sdkResult.data - - // Get organization slug. - spinner?.text('Fetching organization information...') - const orgSlug = await getOrgSlug(sdk) - if (!orgSlug) { - spinner?.failAndStop('Could not determine organization') - - // Check if SOCKET_CLI_API_TOKEN is set to provide helpful guidance. - const hasEnvToken = !!ENV.SOCKET_CLI_API_TOKEN - const errorMessage = hasEnvToken - ? 'Could not determine organization from API token. Try running `socket login` or set SOCKET_CLI_ORG_SLUG environment variable.' - : 'Could not determine organization from API token. Run `socket login` first.' - - await outputPatchDiscoverResult( - { - ok: false, - cause: errorMessage, - message: 'Organization Error', - }, - outputKind, - ) - return - } - - let activeScanId = scanId - - // Create scan if no scan ID provided. - if (!scanId) { - spinner?.text('Preparing to scan dependencies...') - - // Get supported files for scanning. - const supportedFilesResult = await fetchSupportedScanFileNames({ - spinner: spinner ?? undefined, - }) - if (!supportedFilesResult.ok) { - spinner?.failAndStop('Failed to fetch supported file types') - await outputPatchDiscoverResult(supportedFilesResult, outputKind) - return - } - - spinner?.text('Searching for package files...') - - // Get package files for scanning. - const packagePaths = await getPackageFilesForScan( - ['.'], - supportedFilesResult.data, - { cwd }, - ) - - if (packagePaths.length === 0) { - spinner?.successAndStop('No package files found to scan') - logger.log('No package files found to scan') - await outputPatchDiscoverResult( - { - ok: true, - data: { patches: [] }, - }, - outputKind, - ) - return - } - - spinner?.text( - `Creating scan with ${packagePaths.length} package ${pluralize('file', { count: packagePaths.length })}...`, - ) - - logger.log(`[DEBUG] Package files to scan: ${packagePaths.join(', ')}`) - - // Create scan (silent by default, background operation). - const scanResult = await fetchCreateOrgFullScan( - packagePaths, - orgSlug, - { - branchName: '', - commitHash: '', - commitMessage: '', - committers: '', - pullRequest: 0, - repoName: 'patch-discover', - scanType: undefined, - }, - { - cwd, - tmp: true, - }, - ) - - if (!scanResult.ok) { - spinner?.failAndStop('Failed to create scan') - await outputPatchDiscoverResult(scanResult, outputKind) - return - } - - activeScanId = scanResult.data?.id - - logger.log(`[DEBUG] Scan created with ID: ${activeScanId}`) - - if (!activeScanId) { - spinner?.failAndStop('Scan creation did not return scan ID') - await outputPatchDiscoverResult( - { - ok: false, - cause: 'Scan creation did not return scan ID', - message: 'Scan Error', - }, - outputKind, - ) - return - } - - spinner?.successAndStop('Scan created successfully') - } - - // Validate scan ID before streaming. - if (!activeScanId) { - spinner?.failAndStop('No scan ID available') - await outputPatchDiscoverResult( - { - ok: false, - cause: 'No scan ID available', - message: 'Scan Error', - }, - outputKind, - ) - return - } - - // Stream patches from scan. - spinner?.start('Discovering available patches...') - const patches = await streamPatchesFromScan(sdk, orgSlug, activeScanId) - - if (patches.length === 0) { - spinner?.successAndStop('No patches available for scanned dependencies') - await outputPatchDiscoverResult( - { - ok: true, - data: { patches: [] }, - }, - outputKind, - ) - return - } - - // Fetch scan data to enrich patches with package names. - spinner?.start('Fetching package details...') - const enrichedPatches = await enrichPatchesWithPackageNames( - sdk, - orgSlug, - activeScanId, - patches, - ) - - spinner?.successAndStop( - `Found ${enrichedPatches.length} available ${pluralize('patch', { count: enrichedPatches.length })}`, - ) - - // If interactive mode, show patch selector UI. - if (interactive) { - if (enrichedPatches.length === 0) { - logger.log('No patches available to select') - await outputPatchDiscoverResult( - { - ok: true, - data: { patches: enrichedPatches }, - }, - outputKind, - ) - return - } - - // Show interactive patch selector. - const selectedPatches = await showPatchSelector(enrichedPatches) - - if (selectedPatches.length === 0) { - logger.log('No patches selected') - return - } - - logger.log('') - logger.log( - `Downloading ${selectedPatches.length} ${pluralize('patch', { count: selectedPatches.length })}...`, - ) - - const scanIds = selectedPatches - .map(p => p.uuid) - .filter((uuid): uuid is string => !!uuid) - - // Call download handler. - if (spinner) { - await handlePatchDownload({ - cwd, - outputKind, - ...(scanId ? { scanId } : {}), - spinner, - uuids: scanIds, - }) - } else { - logger.error('Spinner is required for patch download') - } - } else { - await outputPatchDiscoverResult( - { - ok: true, - data: { patches: enrichedPatches }, - }, - outputKind, - ) - } - } catch (e) { - spinner?.stop() - - const message = 'Failed to discover patches' - const cause = getErrorCause(e) - - await outputPatchDiscoverResult( - { - ok: false, - code: 1, - message, - cause, - }, - outputKind, - ) - } -} - -/** - * Get organization slug from SDK. - */ -async function getOrgSlug(sdk: SocketSdk): Promise { - // Check if SOCKET_CLI_ORG_SLUG is explicitly set in environment. - if (ENV.SOCKET_CLI_ORG_SLUG) { - return ENV.SOCKET_CLI_ORG_SLUG - } - - // Check if defaultOrg is set in config (from socket login). - const { getConfigValueOrUndef } = await import('../../utils/config.mts') - const defaultOrg = getConfigValueOrUndef('defaultOrg') - if (defaultOrg) { - return defaultOrg - } - - // Otherwise, try to fetch from API. - try { - const orgs = await sdk.listOrganizations() - if (Array.isArray(orgs) && orgs.length > 0) { - return orgs[0]?.slug - } - return undefined - } catch (_e) { - return undefined - } -} - -/** - * Shimmering text component with purple gradient effect. - */ -function ShimmerText({ - Text, - children, - createElement, - useEffect, - useState, -}: { - children: string - createElement: any - Text: any - useEffect: any - useState: any -}): any { - const [frame, setFrame] = useState(0) - - useEffect(() => { - const interval = setInterval(() => { - setFrame((f: number) => (f + 1) % 20) - }, 100) - return () => clearInterval(interval) - }, []) - - const colors = ['#8B5CF6', '#A78BFA', '#C4B5FD', '#DDD6FE'] - const colorIndex = Math.floor(frame / 5) % colors.length - - return createElement( - Text, - { bold: true, color: colors[colorIndex] }, - children, - ) -} - -/** - * Patch selector app component with multi-select checkboxes. - */ -function createPatchSelectorApp({ - Box, - Text, - createElement, - useApp, - useEffect, - useInput, - useState, -}: any) { - return function PatchSelectorApp({ - onSelect, - patches, - }: { - onSelect: (selectedPatches: DiscoveredPatch[]) => void - patches: DiscoveredPatch[] - }): any { - const { exit } = useApp() - const [selectedIndex, setSelectedIndex] = useState(0) - const [selectedPatches, setSelectedPatches] = useState(new Set()) - - useInput((input: string, key: any) => { - if (input === 'q' || key.escape) { - exit() - onSelect([]) - } else if (key.ctrl && input === 'c') { - exit() - // eslint-disable-next-line n/no-process-exit - process.exit(0) - } else if (key.upArrow || input === 'k') { - setSelectedIndex((prev: number) => Math.max(0, prev - 1)) - } else if (key.downArrow || input === 'j') { - setSelectedIndex((prev: number) => - Math.min(patches.length - 1, prev + 1), - ) - } else if (input === ' ') { - // Toggle selection. - setSelectedPatches((prev: Set) => { - const next = new Set(prev) - if (next.has(selectedIndex)) { - next.delete(selectedIndex) - } else { - next.add(selectedIndex) - } - return next - }) - } else if (input === 'a') { - // Select all. - setSelectedPatches(new Set(patches.map((_: any, i: number) => i))) - } else if (input === 'n') { - // Select none. - setSelectedPatches(new Set()) - } else if (key.return) { - // Apply selected patches. - const selected = patches.filter((_: any, i: number) => - selectedPatches.has(i), - ) - exit() - onSelect(selected) - } - }) - - return createElement( - Box, - { flexDirection: 'column', paddingX: 2, paddingY: 1 }, - // Header. - createElement( - Box, - { - borderColor: 'magenta', - borderStyle: 'double', - marginBottom: 1, - paddingX: 2, - paddingY: 1, - }, - createElement( - Box, - { flexDirection: 'column', width: '100%' }, - createElement( - Box, - { justifyContent: 'center', marginBottom: 1 }, - createElement( - ShimmerText, - { createElement, Text, useEffect, useState }, - '🛡️ Socket Security Patches', - ), - ), - createElement( - Box, - { justifyContent: 'center' }, - createElement( - Text, - { dimColor: true }, - 'Select patches to apply to your project', - ), - ), - ), - ), - // Patch List. - createElement( - Box, - { - borderColor: 'cyan', - borderStyle: 'single', - flexDirection: 'column', - marginBottom: 1, - paddingX: 1, - }, - patches.map((patch: DiscoveredPatch, index: number) => { - const isSelected = selectedPatches.has(index) - const isCursor = index === selectedIndex - const checkbox = isSelected ? '[✓]' : '[ ]' - const cursor = isCursor ? '▶ ' : ' ' - - const vulnCount = patch.freeCves.length + patch.paidCves.length - const vulnText = - vulnCount > 0 - ? ` (${vulnCount} vuln${vulnCount > 1 ? 's' : ''})` - : '' - - return createElement( - Box, - { key: index }, - createElement( - Text, - { - backgroundColor: isCursor ? 'gray' : undefined, - bold: isCursor, - color: isCursor ? 'magenta' : undefined, - }, - cursor, - createElement( - Text, - { color: isSelected ? 'green' : 'white' }, - checkbox, - ), - ' ', - createElement( - Text, - { color: 'cyan' }, - patch.purl || 'Unknown package', - ), - createElement(Text, { color: 'yellow' }, vulnText), - ), - ) - }), - ), - // Summary. - createElement( - Box, - { - borderColor: 'yellow', - borderStyle: 'single', - marginBottom: 1, - paddingX: 2, - }, - createElement( - Text, - { color: 'yellow' }, - `Selected: ${selectedPatches.size} / ${patches.length} patches`, - ), - ), - // Controls. - createElement( - Box, - { - backgroundColor: 'black', - borderColor: 'magenta', - borderStyle: 'single', - paddingX: 2, - }, - createElement( - Box, - { flexDirection: 'column', width: '100%' }, - createElement( - Text, - { color: 'magenta' }, - createElement(Text, { bold: true }, 'Space:'), - ' Toggle ', - createElement(Text, { bold: true }, 'a:'), - ' All ', - createElement(Text, { bold: true }, 'n:'), - ' None', - ), - createElement( - Text, - { color: 'magenta' }, - createElement(Text, { bold: true }, '↑/↓:'), - ' Navigate ', - createElement(Text, { bold: true }, 'Enter:'), - ' Apply ', - createElement(Text, { bold: true }, 'q/ESC:'), - ' Cancel', - ), - ), - ), - ) - } -} - -/** - * Show interactive patch selector UI. - */ -async function showPatchSelector( - patches: DiscoveredPatch[], -): Promise { - const React = await import('react') - const { Box, Text, render, useApp, useInput } = await import('ink') - - const PatchSelectorApp = createPatchSelectorApp({ - Box, - Text, - createElement: React.createElement, - useApp, - useEffect: React.useEffect, - useInput, - useState: React.useState, - }) - - return new Promise(resolve => { - try { - const app = render( - React.createElement(PatchSelectorApp, { - onSelect: (selectedPatches: DiscoveredPatch[]) => { - app.unmount() - resolve(selectedPatches) - }, - patches, - }), - ) - } catch (err) { - logger.error('[DEBUG] Error rendering PatchSelectorApp:', err) - logger.error('[DEBUG] Error stack:', (err as Error).stack) - throw err - } - }) -} - -/** - * Enrich patches with package names by fetching scan artifact data. - */ -async function enrichPatchesWithPackageNames( - sdk: SocketSdk, - orgSlug: string, - scanId: string, - patches: DiscoveredPatch[], -): Promise { - try { - // Fetch full scan data to get artifact details. - const scanResult = await sdk.getFullScan(orgSlug, scanId) - - if (!scanResult.success || !scanResult.data) { - logger.error('[DEBUG] Failed to fetch scan data for enrichment') - return patches - } - - // Build artifact ID to package info map. - const artifactMap = new Map() - - // The scan data contains artifacts array with id and package info. - const artifacts = (scanResult.data as any).artifacts || [] - - for (const artifact of artifacts) { - if (artifact.id && (artifact.name || artifact.purl)) { - artifactMap.set(artifact.id, { - name: artifact.name || artifact.purl || 'unknown', - purl: - artifact.purl || - `pkg:${artifact.type}/${artifact.name}@${artifact.version}`, - }) - } - } - - logger.log(`[DEBUG] Built artifact map with ${artifactMap.size} entries`) - - // Enrich patches with package names. - return patches.map(patch => { - const artifactInfo = artifactMap.get(patch.purl) - if (artifactInfo) { - logger.log( - `[DEBUG] Enriching patch: ${patch.purl} -> ${artifactInfo.purl}`, - ) - return { - ...patch, - purl: artifactInfo.purl, - } - } - return patch - }) - } catch (e: any) { - logger.error(`[DEBUG] Failed to enrich patches: ${e.message}`) - return patches - } -} - -/** - * Stream patches from a scan and collect all results. - */ -async function streamPatchesFromScan( - sdk: SocketSdk, - orgSlug: string, - scanId: string, -): Promise { - const discoveredPatches: DiscoveredPatch[] = [] - - try { - const stream = await sdk.streamPatchesFromScan(orgSlug, scanId) - const reader = stream.getReader() - - let chunkCount = 0 - - while (true) { - // eslint-disable-next-line no-await-in-loop - const { done, value } = await reader.read() - if (done) { - break - } - - chunkCount++ - const record = value as unknown as ArtifactPatchRecord - - logger.log( - `[DEBUG] Received chunk ${chunkCount}: artifactId=${record.artifactId}, purl=${record.purlString}, patch=${record.patch ? 'available' : 'null'}`, - ) - - // Skip records with no patch. - if (!record.patch) { - continue - } - - const patch = record.patch - - // Convert freeCves to vulnerabilities. - const freeCves: PatchVulnerability[] = patch.freeCves.map(cve => { - const vuln = { __proto__: null } as PatchVulnerability - if (cve.cveId) { - vuln.cve = cve.cveId - } - if (cve.severity) { - vuln.severity = cve.severity - } - return vuln - }) - - // Convert paidCves to vulnerabilities. - const paidCves: PatchVulnerability[] = patch.paidCves.map(cve => { - const vuln = { __proto__: null } as PatchVulnerability - if (cve.cveId) { - vuln.cve = cve.cveId - } - if (cve.severity) { - vuln.severity = cve.severity - } - return vuln - }) - - discoveredPatches.push({ - description: patch.description, - freeCves, - freeFeatures: patch.freeFeatures, - license: patch.license, - paidCves, - paidFeatures: patch.paidFeatures, - purl: record.purlString, - tier: patch.tier, - uuid: patch.uuid, - }) - } - - logger.log( - `[DEBUG] Stream complete: received ${chunkCount} chunks, collected ${discoveredPatches.length} patches`, - ) - } catch (e: any) { - logger.error(`Failed to stream patches from scan: ${e.message}`) - } - - return discoveredPatches -} diff --git a/packages/cli/src/commands/patch/handle-patch-download.mts b/packages/cli/src/commands/patch/handle-patch-download.mts deleted file mode 100644 index d012ceab0..000000000 --- a/packages/cli/src/commands/patch/handle-patch-download.mts +++ /dev/null @@ -1,396 +0,0 @@ -/** - * Patch Download Handler - * - * Handles downloading patches from the Socket API and storing them locally. - * Supports downloading by UUID or from scan results. - * - * Features: - * - Download patches by UUID - * - Download patches from scan results - * - Save blobs to _cacache with integrity verification - * - Update manifest with downloaded patch metadata - * - Track download status and timestamps - * - * API Integration: - * - Uses streamPatchesFromScan() for scan-based discovery - * - Uses viewPatch() to get patch details and blob hashes - * - Uses downloadPatch() to fetch blob content - * - * Storage: - * - Uses cacache for content storage: ~/.socket/_cacache - * - Keys follow pattern: socket:patch:blob:: - * - Integrity verified using SSRI format - * - * Data Flow: - * 1. Get patch UUIDs (from args or scan) - * 2. For each UUID, viewPatch() to get metadata - * 3. For each file in patch, downloadPatch() blob content - * 4. Save blobs to cacache with integrity verification - * 5. Update manifest with status='downloaded' - */ - -import crypto from 'node:crypto' - -// @ts-expect-error - No type declarations available. -import ssri from 'ssri' - -import * as cacache from '@socketsecurity/lib/cacache' -import { getDefaultLogger } from '@socketsecurity/lib/logger' -import { pluralize } from '@socketsecurity/lib/words' - -import { outputPatchDownloadResult } from './output-patch-download-result.mts' -import ENV from '../../constants/env.mts' -import { addPatch } from '../../utils/manifest/patches.mts' -import { setupSdk } from '../../utils/socket/sdk.mts' - -import type { OutputKind } from '../../types.mts' -import type { PatchRecord } from '../../utils/manifest/patches.mts' -import type { Spinner } from '@socketsecurity/lib/spinner' -import type { SocketSdk } from '@socketsecurity/sdk' -const logger = getDefaultLogger() - -export type HandlePatchDownloadConfig = { - cwd: string - outputKind: OutputKind - scanId?: string - spinner: Spinner | null - uuids: string[] -} - -export type PatchDownloadResult = { - downloaded: Array<{ - purl: string - uuid: string - }> - failed: Array<{ - error: string - uuid: string - }> -} - -type PatchViewResponse = { - description?: string - files: Record< - string, - { - afterHash: string | null - beforeHash: string | null - socketBlob: string - } - > - license?: string - publishedAt: string - purl: string - tier: 'free' | 'paid' - uuid: string - vulnerabilities: Record< - string, - { - cves: string[] - description: string - severity: string - summary: string - } - > -} - -type ArtifactPatches = { - artifactId: string - patches: Array<{ - description?: string - license?: string - publishedAt: string - securityAlerts: Array<{ - cveId?: string - description: string - ghsaId: string - severity: string - summary: string - }> - tier: 'free' | 'paid' - uuid: string - }> -} - -/** - * Download patches from Socket API. - */ -export async function handlePatchDownload({ - cwd, - outputKind, - scanId, - spinner, - uuids, -}: HandlePatchDownloadConfig): Promise { - // Setup SDK. - const sdkResult = await setupSdk() - if (!sdkResult.ok) { - spinner?.failAndStop('Failed to initialize Socket SDK') - await outputPatchDownloadResult( - { - ok: false, - cause: sdkResult.cause, - message: sdkResult.message, - }, - { outputKind }, - ) - return - } - - const sdk = sdkResult.data - - // Get organization slug. - const orgSlug = await getOrgSlug(sdk) - if (!orgSlug) { - spinner?.failAndStop('Failed to determine organization') - - // Check if SOCKET_CLI_API_TOKEN is set to provide helpful guidance. - const hasEnvToken = !!ENV.SOCKET_CLI_API_TOKEN - const errorMessage = hasEnvToken - ? 'Could not determine organization from API token. Try running `socket login` or set SOCKET_CLI_ORG_SLUG environment variable.' - : 'Could not determine organization from API token. Run `socket login` first.' - - await outputPatchDownloadResult( - { - ok: false, - cause: errorMessage, - message: 'Organization Error', - }, - { outputKind }, - ) - return - } - - // Collect UUIDs to download. - let patchUuids = uuids - - if (scanId) { - spinner?.start('Fetching patches from scan...') - const scanUuids = await collectPatchesFromScan(sdk, orgSlug, scanId) - if (!scanUuids.length) { - spinner?.failAndStop('No patches found in scan') - await outputPatchDownloadResult( - { - ok: false, - cause: `Scan ${scanId} has no patches available`, - message: 'No Patches Found', - }, - { outputKind }, - ) - return - } - patchUuids = scanUuids - spinner?.successAndStop( - `Found ${patchUuids.length} ${pluralize('patch', { count: patchUuids.length })} in scan`, - ) - } - - if (!patchUuids.length) { - spinner?.failAndStop('No patches to download') - await outputPatchDownloadResult( - { - ok: false, - cause: 'Must provide patch UUIDs or --scan flag', - message: 'No UUIDs Provided', - }, - { outputKind }, - ) - return - } - - // Download each patch. - const results: PatchDownloadResult = { - downloaded: [], - failed: [], - } - - for (const uuid of patchUuids) { - try { - spinner?.start(`Downloading patch ${uuid}...`) - // eslint-disable-next-line no-await-in-loop - await downloadPatch(sdk, orgSlug, uuid, cwd) - // eslint-disable-next-line no-await-in-loop - const patchDetails = await sdk.viewPatch(orgSlug, uuid) - results.downloaded.push({ - purl: patchDetails.purl, - uuid, - }) - spinner?.successAndStop(`Downloaded patch ${uuid}`) - } catch (e: any) { - spinner?.failAndStop(`Failed to download patch ${uuid}`) - results.failed.push({ - error: e.message, - uuid, - }) - logger.error(`Failed to download patch ${uuid}: ${e.message}`) - } - } - - // Output results. - await outputPatchDownloadResult( - { - ok: true, - data: results, - }, - { outputKind }, - ) -} - -/** - * Get organization slug from SDK. - */ -async function getOrgSlug(sdk: SocketSdk): Promise { - // Check if SOCKET_CLI_ORG_SLUG is explicitly set in environment. - if (ENV.SOCKET_CLI_ORG_SLUG) { - return ENV.SOCKET_CLI_ORG_SLUG - } - - // Check if defaultOrg is set in config (from socket login). - const { getConfigValueOrUndef } = await import('../../utils/config.mts') - const defaultOrg = getConfigValueOrUndef('defaultOrg') - if (defaultOrg) { - return defaultOrg - } - - // Otherwise, try to fetch from API. - try { - const orgs = await sdk.listOrganizations() - if (Array.isArray(orgs) && orgs.length > 0) { - return orgs[0]?.slug - } - return undefined - } catch (_e) { - return undefined - } -} - -/** - * Collect patch UUIDs from a scan. - */ -async function collectPatchesFromScan( - sdk: SocketSdk, - orgSlug: string, - scanId: string, -): Promise { - const uuids: string[] = [] - - try { - const stream = await sdk.streamPatchesFromScan(orgSlug, scanId) - const reader = stream.getReader() - - while (true) { - // eslint-disable-next-line no-await-in-loop - const { done, value } = await reader.read() - if (done) { - break - } - - const artifactPatches = value as ArtifactPatches - for (const patch of artifactPatches.patches) { - uuids.push(patch.uuid) - } - } - } catch (e: any) { - logger.error(`Failed to stream patches from scan: ${e.message}`) - } - - return uuids -} - -/** - * Hash a file path to create a unique, filesystem-safe identifier. - */ -function hashFilePath(filePath: string): string { - return crypto.createHash('sha256').update(filePath).digest('hex').slice(0, 16) -} - -/** - * Build cacache key for a patch blob. - */ -function buildBlobKey(uuid: string, filePath: string): string { - return `socket:patch:blob:${uuid}:${hashFilePath(filePath)}` -} - -/** - * Download a single patch and save to cacache. - */ -async function downloadPatch( - sdk: SocketSdk, - orgSlug: string, - uuid: string, - cwd: string, -): Promise { - // Get patch details. - const patchDetails: PatchViewResponse = (await (sdk as any).viewPatch( - orgSlug, - uuid, - )) as PatchViewResponse - - // Download each file blob. - for (const [filePath, fileInfo] of Object.entries(patchDetails.files)) { - if (!fileInfo.socketBlob) { - continue - } - - // Download blob content. - // eslint-disable-next-line no-await-in-loop - const content = await (sdk as any).downloadPatch(fileInfo.socketBlob) - - // Compute integrity using ssri. - const integrity = ssri.fromData(Buffer.from(content, 'utf-8'), { - algorithms: ['sha256'], - }) - const integrityString = integrity.toString() - - // Store in cacache with integrity verification. - const cacheKey = buildBlobKey(uuid, filePath) - // eslint-disable-next-line no-await-in-loop - await cacache.put(cacheKey, content, { - integrity: integrityString, - metadata: { - uuid, - filePath, - purl: patchDetails.purl, - downloadedAt: new Date().toISOString(), - afterHash: fileInfo.afterHash, - beforeHash: fileInfo.beforeHash, - }, - }) - } - - // Build patch record for manifest. - const patchRecord: PatchRecord = { - uuid: patchDetails.uuid, - exportedAt: patchDetails.publishedAt, - files: Object.fromEntries( - Object.entries(patchDetails.files).map(([filePath, fileInfo]) => [ - filePath, - { - beforeHash: fileInfo.beforeHash || '', - afterHash: fileInfo.afterHash || '', - }, - ]), - ), - description: patchDetails.description, - license: patchDetails.license, - tier: patchDetails.tier, - vulnerabilities: patchDetails.vulnerabilities - ? Object.fromEntries( - Object.entries(patchDetails.vulnerabilities).map(([ghsaId, vuln]) => [ - ghsaId, - { - cves: vuln.cves, - summary: vuln.summary, - severity: vuln.severity, - description: vuln.description, - }, - ]), - ) - : undefined, - status: 'downloaded', - downloadedAt: new Date().toISOString(), - } - - // Add to manifest. - await addPatch(patchDetails.purl, patchRecord, cwd) -} diff --git a/packages/cli/src/commands/patch/handle-patch-get.mts b/packages/cli/src/commands/patch/handle-patch-get.mts deleted file mode 100644 index 888e2e2e8..000000000 --- a/packages/cli/src/commands/patch/handle-patch-get.mts +++ /dev/null @@ -1,136 +0,0 @@ -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' - -import { UTF8 } from '@socketsecurity/lib/constants/encoding' -import { safeMkdir } from '@socketsecurity/lib/fs' -import { DOT_SOCKET_DIR } from '@socketsecurity/lib/paths/dirnames' -import { MANIFEST_JSON } from '@socketsecurity/lib/paths/filenames' -import { normalizePath } from '@socketsecurity/lib/paths/normalize' - -import { PatchManifestSchema } from './manifest-schema.mts' -import { outputPatchGetResult } from './output-patch-get-result.mts' -import { getErrorCause, InputError } from '../../utils/error/errors.mjs' -import { normalizePurl } from '../../utils/purl/parse.mjs' - -import type { OutputKind } from '../../types.mts' -import type { Spinner } from '@socketsecurity/lib/spinner' - -export interface PatchGetData { - files: string[] - outputDir: string - purl: string -} - -export interface HandlePatchGetConfig { - cwd: string - outputDir: string | undefined - outputKind: OutputKind - purl: string - spinner: Spinner | null -} - -export async function handlePatchGet({ - cwd, - outputDir, - outputKind, - purl, - spinner, -}: HandlePatchGetConfig): Promise { - try { - spinner?.start('Reading patch manifest') - - const dotSocketDirPath = normalizePath(path.join(cwd, DOT_SOCKET_DIR)) - const manifestPath = normalizePath( - path.join(dotSocketDirPath, MANIFEST_JSON), - ) - const manifestContent = await fs.readFile(manifestPath, UTF8) - const manifestData = JSON.parse(manifestContent) - const validated = PatchManifestSchema.parse(manifestData) - - const normalizedPurl = normalizePurl(purl) - const patch = validated.patches[normalizedPurl] - - if (!patch) { - spinner?.stop() - throw new InputError(`Patch not found for PURL: ${purl}`) - } - - const targetDir = outputDir - ? path.resolve(process.cwd(), outputDir) - : path.join(cwd, 'patches', normalizedPurl.replace(/[/:@]/g, '_')) - - // Create output directory if it doesn't exist. - if (!existsSync(targetDir)) { - await safeMkdir(targetDir, { recursive: true }) - } - - spinner?.text('Copying patch files') - - const copiedFiles: string[] = [] - const blobsDir = normalizePath(path.join(dotSocketDirPath, 'blobs')) - - for (const { 0: fileName, 1: fileInfo } of Object.entries(patch.files)) { - const blobPath = normalizePath(path.join(blobsDir, fileInfo.afterHash)) - if (!existsSync(blobPath)) { - spinner?.stop() - throw new InputError( - `Patch file not found: ${fileInfo.afterHash} for ${fileName}`, - ) - } - - const targetFilePath = normalizePath(path.join(targetDir, fileName)) - const targetFileDir = path.dirname(targetFilePath) - - // Create subdirectories if needed. - if (!existsSync(targetFileDir)) { - // eslint-disable-next-line no-await-in-loop - await safeMkdir(targetFileDir, { recursive: true }) - } - - // eslint-disable-next-line no-await-in-loop - await fs.copyFile(blobPath, targetFilePath) - copiedFiles.push(fileName) - } - - spinner?.stop() - - await outputPatchGetResult( - { - ok: true, - data: { - files: copiedFiles, - outputDir: targetDir, - purl: normalizedPurl, - }, - }, - outputKind, - ) - } catch (e) { - spinner?.stop() - - if (e instanceof InputError) { - throw e - } - - let message = 'Failed to get patch files' - let cause = getErrorCause(e) - - if (e instanceof SyntaxError) { - message = `Invalid JSON in ${MANIFEST_JSON}` - cause = e.message - } else if (e instanceof Error && 'issues' in e) { - message = 'Schema validation failed' - cause = String(e) - } - - await outputPatchGetResult( - { - ok: false, - code: 1, - message, - cause, - }, - outputKind, - ) - } -} diff --git a/packages/cli/src/commands/patch/handle-patch-info.mts b/packages/cli/src/commands/patch/handle-patch-info.mts deleted file mode 100644 index 0f1e4e262..000000000 --- a/packages/cli/src/commands/patch/handle-patch-info.mts +++ /dev/null @@ -1,115 +0,0 @@ -import { promises as fs } from 'node:fs' -import path from 'node:path' - -import { UTF8 } from '@socketsecurity/lib/constants/encoding' -import { getDefaultLogger } from '@socketsecurity/lib/logger' -import { DOT_SOCKET_DIR } from '@socketsecurity/lib/paths/dirnames' -import { MANIFEST_JSON } from '@socketsecurity/lib/paths/filenames' -import { normalizePath } from '@socketsecurity/lib/paths/normalize' - -import { PatchManifestSchema } from './manifest-schema.mts' -import { outputPatchInfoResult } from './output-patch-info-result.mts' -import { getErrorCause, InputError } from '../../utils/error/errors.mjs' -import { normalizePurl } from '../../utils/purl/parse.mjs' - -import type { PatchRecord } from './manifest-schema.mts' -import type { OutputKind } from '../../types.mts' -import type { Spinner } from '@socketsecurity/lib/spinner' - -export interface PatchInfoData { - description: string | undefined - exportedAt: string - files: Record - license: string | undefined - purl: string - tier: string | undefined - uuid: string | undefined - vulnerabilities: PatchRecord['vulnerabilities'] -} - -export interface HandlePatchInfoConfig { - cwd: string - outputKind: OutputKind - purl: string - spinner: Spinner | null -} - -export async function handlePatchInfo({ - cwd, - outputKind, - purl, - spinner, -}: HandlePatchInfoConfig): Promise { - try { - spinner?.start('Reading patch manifest') - - const dotSocketDirPath = normalizePath(path.join(cwd, DOT_SOCKET_DIR)) - const manifestPath = normalizePath( - path.join(dotSocketDirPath, MANIFEST_JSON), - ) - const manifestContent = await fs.readFile(manifestPath, UTF8) - const manifestData = JSON.parse(manifestContent) - const validated = PatchManifestSchema.parse(manifestData) - - const normalizedPurl = normalizePurl(purl) - const patch = validated.patches[normalizedPurl] - - if (!patch) { - spinner?.stop() - throw new InputError(`Patch not found for PURL: ${purl}`) - } - - spinner?.stop() - - if (outputKind === 'text') { - const logger = getDefaultLogger() - logger.log(`Patch information for: ${normalizedPurl}`) - } - - const patchInfo: PatchInfoData = { - description: patch.description, - exportedAt: patch.exportedAt, - files: patch.files, - license: patch.license, - purl: normalizedPurl, - tier: patch.tier, - uuid: patch.uuid, - vulnerabilities: patch.vulnerabilities, - } - - await outputPatchInfoResult( - { - ok: true, - data: patchInfo, - }, - outputKind, - ) - } catch (e) { - spinner?.stop() - - if (e instanceof InputError) { - throw e - } - - let message = 'Failed to get patch info' - let cause = getErrorCause(e) - - if (e instanceof SyntaxError) { - message = `Invalid JSON in ${MANIFEST_JSON}` - cause = e.message - } else if (e instanceof Error && 'issues' in e) { - message = 'Schema validation failed' - cause = String(e) - } - - await outputPatchInfoResult( - { - ok: false, - code: 1, - message, - cause, - }, - outputKind, - ) - } -} diff --git a/packages/cli/src/commands/patch/handle-patch-list.mts b/packages/cli/src/commands/patch/handle-patch-list.mts deleted file mode 100644 index 6fc72168e..000000000 --- a/packages/cli/src/commands/patch/handle-patch-list.mts +++ /dev/null @@ -1,218 +0,0 @@ -import { promises as fs } from 'node:fs' -import path from 'node:path' - -import { UTF8 } from '@socketsecurity/lib/constants/encoding' -import { getDefaultLogger } from '@socketsecurity/lib/logger' -import { DOT_SOCKET_DIR } from '@socketsecurity/lib/paths/dirnames' -import { MANIFEST_JSON } from '@socketsecurity/lib/paths/filenames' -import { normalizePath } from '@socketsecurity/lib/paths/normalize' -import { select } from '@socketsecurity/lib/stdio/prompts' -import { pluralize } from '@socketsecurity/lib/words' - -import { handlePatchApply } from './handle-patch-apply.mts' -import { PatchManifestSchema } from './manifest-schema.mts' -import { outputPatchListResult } from './output-patch-list-result.mts' -import { getErrorCause } from '../../utils/error/errors.mjs' -import { getPurlObject } from '../../utils/purl/parse.mjs' - -import type { OutputKind } from '../../types.mts' -import type { Spinner } from '@socketsecurity/lib/spinner' -const logger = getDefaultLogger() - -export interface PatchListEntry { - appliedAt: string | undefined - description: string | undefined - exportedAt: string - fileCount: number - license: string | undefined - purl: string - status: 'downloaded' | 'applied' | 'failed' | undefined - tier: string | undefined - uuid: string | undefined - vulnerabilityCount: number -} - -export interface HandlePatchListConfig { - cwd: string - interactive: boolean - outputKind: OutputKind - spinner: Spinner | null -} - -export async function handlePatchList({ - cwd, - interactive, - outputKind, - spinner, -}: HandlePatchListConfig): Promise { - try { - spinner?.start('Reading patch manifest') - - const dotSocketDirPath = normalizePath(path.join(cwd, DOT_SOCKET_DIR)) - const manifestPath = normalizePath( - path.join(dotSocketDirPath, MANIFEST_JSON), - ) - const manifestContent = await fs.readFile(manifestPath, UTF8) - const manifestData = JSON.parse(manifestContent) - const validated = PatchManifestSchema.parse(manifestData) - - const patches: PatchListEntry[] = [] - - for (const { 0: purl, 1: patch } of Object.entries(validated.patches)) { - const fileCount = Object.keys(patch.files).length - const vulnerabilityCount = Object.keys(patch.vulnerabilities || {}).length - - patches.push({ - appliedAt: patch.appliedAt, - description: patch.description, - exportedAt: patch.exportedAt, - fileCount, - license: patch.license, - purl, - status: patch.status, - tier: patch.tier, - uuid: patch.uuid, - vulnerabilityCount, - }) - } - - spinner?.stop() - - if (patches.length === 0) { - if (outputKind === 'text') { - logger.log('No patches found in manifest') - } - return - } - - if (outputKind === 'text') { - logger.log( - `Found ${patches.length} ${pluralize('patch', { count: patches.length })} in manifest`, - ) - } - - // Interactive mode: Let user select patches to apply. - if (interactive) { - if (patches.length === 0) { - logger.log('No patches available to select') - return - } - - // Show list first. - await outputPatchListResult( - { - ok: true, - data: { patches }, - }, - outputKind, - ) - - logger.log('') - logger.log('Select patches to apply (use arrow keys and Enter):') - logger.log('') - - // Create choices for selection. - const choices = [ - { - name: '✓ Apply All Patches', - value: '__ALL__', - }, - ...patches.map(patch => { - const statusIndicator = - patch.status === 'applied' - ? '[✓]' - : patch.status === 'failed' - ? '[✗]' - : '[○]' - const vulnText = - patch.vulnerabilityCount > 0 - ? ` - ${patch.vulnerabilityCount} ${pluralize('vuln', { count: patch.vulnerabilityCount })}` - : '' - - return { - name: `${statusIndicator} ${patch.purl}${vulnText}`, - value: patch.purl, - description: patch.description || 'No description', - } - }), - { - name: '✗ Cancel', - value: '__CANCEL__', - }, - ] - - const selectedValue = await select({ - message: 'Select a patch to apply:', - choices, - }) - - if (selectedValue === '__CANCEL__') { - logger.log('Cancelled') - return - } - - // Determine which patches to apply. - const purlsToApply: string[] = [] - - if (selectedValue === '__ALL__') { - purlsToApply.push(...patches.map(p => p.purl)) - } else { - purlsToApply.push(selectedValue) - } - - logger.log('') - logger.log( - `Applying ${purlsToApply.length} ${pluralize('patch', { count: purlsToApply.length })}...`, - ) - logger.log('') - - // Convert PURLs to PackageURL objects. - const purlObjs = purlsToApply - .map(purl => getPurlObject(purl, { throws: false })) - .filter((p): p is NonNullable => p !== null) - - // Apply the selected patches. - await handlePatchApply({ - cwd, - dryRun: false, - outputKind, - purlObjs, - spinner, - }) - - return - } - - // Non-interactive mode: Just show the list. - await outputPatchListResult( - { - ok: true, - data: { patches }, - }, - outputKind, - ) - } catch (e) { - spinner?.stop() - - let message = 'Failed to list patches' - let cause = getErrorCause(e) - - if (e instanceof SyntaxError) { - message = `Invalid JSON in ${MANIFEST_JSON}` - cause = e.message - } else if (e instanceof Error && 'issues' in e) { - message = 'Schema validation failed' - cause = String(e) - } - - await outputPatchListResult( - { - ok: false, - code: 1, - message, - cause, - }, - outputKind, - ) - } -} diff --git a/packages/cli/src/commands/patch/handle-patch-rm.mts b/packages/cli/src/commands/patch/handle-patch-rm.mts deleted file mode 100644 index 0fc02fb56..000000000 --- a/packages/cli/src/commands/patch/handle-patch-rm.mts +++ /dev/null @@ -1,167 +0,0 @@ -import { promises as fs } from 'node:fs' -import path from 'node:path' - -import { UTF8 } from '@socketsecurity/lib/constants/encoding' -import { getDefaultLogger } from '@socketsecurity/lib/logger' -import { DOT_SOCKET_DIR } from '@socketsecurity/lib/paths/dirnames' -import { MANIFEST_JSON } from '@socketsecurity/lib/paths/filenames' -import { normalizePath } from '@socketsecurity/lib/paths/normalize' -import { pluralize } from '@socketsecurity/lib/words' - -import { PatchManifestSchema } from './manifest-schema.mts' -import { outputPatchRmResult } from './output-patch-rm-result.mts' -import { getErrorCause, InputError } from '../../utils/error/errors.mjs' -import { - cleanupBackups, - getPatchMetadata, - restoreAllBackups, -} from '../../utils/manifest/patch-backup.mts' -import { removePatch } from '../../utils/manifest/patches.mts' -import { normalizePurl } from '../../utils/purl/parse.mjs' - -import type { OutputKind } from '../../types.mts' -import type { Spinner } from '@socketsecurity/lib/spinner' -const logger = getDefaultLogger() - -export interface PatchRmData { - filesRestored: number - purl: string -} - -export interface HandlePatchRmConfig { - cwd: string - keepBackups: boolean - outputKind: OutputKind - purl: string - spinner: Spinner | null -} - -export async function handlePatchRm({ - cwd, - keepBackups, - outputKind, - purl, - spinner, -}: HandlePatchRmConfig): Promise { - try { - spinner?.start('Reading patch manifest') - - const dotSocketDirPath = normalizePath(path.join(cwd, DOT_SOCKET_DIR)) - const manifestPath = normalizePath( - path.join(dotSocketDirPath, MANIFEST_JSON), - ) - const manifestContent = await fs.readFile(manifestPath, UTF8) - const manifestData = JSON.parse(manifestContent) - const validated = PatchManifestSchema.parse(manifestData) - - const normalizedPurl = normalizePurl(purl) - const patch = validated.patches[normalizedPurl] - - if (!patch) { - spinner?.stop() - throw new InputError(`Patch not found for PURL: ${purl}`) - } - - // Check if patch has backups. - const uuid = patch.uuid - if (!uuid) { - spinner?.stop() - throw new InputError('Patch does not have a UUID for backup restoration') - } - - spinner?.text('Checking for backups') - - const metadata = await getPatchMetadata(uuid) - if (!metadata) { - spinner?.stop() - if (outputKind === 'text') { - logger.warn( - 'No backups found for this patch. Original files cannot be restored.', - ) - logger.log('Removing patch from manifest only.') - } - } - - let filesRestored = 0 - - if (metadata) { - spinner?.text('Restoring original files from backups') - - // Restore all backed up files. - const restoreResults = await restoreAllBackups(uuid) - - filesRestored = restoreResults.restored.length - - if (restoreResults.failed.length > 0) { - spinner?.stop() - if (outputKind === 'text') { - logger.warn( - `Failed to restore ${restoreResults.failed.length} ${pluralize('file', { count: restoreResults.failed.length })}:`, - ) - for (const filePath of restoreResults.failed) { - logger.log(` - ${filePath}`) - } - } - } - - if (!keepBackups) { - spinner?.text('Cleaning up backups') - await cleanupBackups(uuid) - } - } - - spinner?.text('Removing patch from manifest') - - // Remove patch from manifest. - await removePatch(normalizedPurl, cwd) - - spinner?.stop() - - if (outputKind === 'text') { - logger.log(`Removed patch for ${normalizedPurl}`) - if (filesRestored > 0) { - logger.log( - `Restored ${filesRestored} ${pluralize('file', { count: filesRestored })} from backups`, - ) - } - } - - await outputPatchRmResult( - { - ok: true, - data: { - filesRestored, - purl: normalizedPurl, - }, - }, - outputKind, - ) - } catch (e) { - spinner?.stop() - - if (e instanceof InputError) { - throw e - } - - let message = 'Failed to remove patch' - let cause = getErrorCause(e) - - if (e instanceof SyntaxError) { - message = `Invalid JSON in ${MANIFEST_JSON}` - cause = e.message - } else if (e instanceof Error && 'issues' in e) { - message = 'Schema validation failed' - cause = String(e) - } - - await outputPatchRmResult( - { - ok: false, - code: 1, - message, - cause, - }, - outputKind, - ) - } -} diff --git a/packages/cli/src/commands/patch/handle-patch-status.mts b/packages/cli/src/commands/patch/handle-patch-status.mts deleted file mode 100644 index d87f4b755..000000000 --- a/packages/cli/src/commands/patch/handle-patch-status.mts +++ /dev/null @@ -1,284 +0,0 @@ -import crypto from 'node:crypto' -import { existsSync, promises as fs } from 'node:fs' -import path from 'node:path' - -import { UTF8 } from '@socketsecurity/lib/constants/encoding' -import { getDefaultLogger } from '@socketsecurity/lib/logger' -import { - DOT_SOCKET_DIR, - NODE_MODULES, -} from '@socketsecurity/lib/paths/dirnames' -import { MANIFEST_JSON } from '@socketsecurity/lib/paths/filenames' -import { normalizePath } from '@socketsecurity/lib/paths/normalize' -import { pluralize } from '@socketsecurity/lib/words' - -import { PatchManifestSchema } from './manifest-schema.mts' -import { outputPatchStatusResult } from './output-patch-status-result.mts' -import { getErrorCause } from '../../utils/error/errors.mjs' -import { findUp } from '../../utils/fs/find-up.mjs' -import { hasBackupForPatch } from '../../utils/manifest/patch-backup.mts' - -import type { PatchRecord } from './manifest-schema.mts' -import type { OutputKind } from '../../types.mts' -import type { Spinner } from '@socketsecurity/lib/spinner' -const logger = getDefaultLogger() - -export interface PatchStatus { - appliedAt: string | undefined - appliedLocations: string[] - backupAvailable: boolean - description: string | undefined - downloadedAt: string | undefined - fileCount: number - purl: string - status: 'downloaded' | 'applied' | 'failed' | 'unknown' - uuid: string | undefined - vulnerabilityCount: number -} - -export interface HandlePatchStatusConfig { - cwd: string - filters: { - applied: boolean - downloaded: boolean - failed: boolean - } - outputKind: OutputKind - spinner: Spinner | null -} - -/** - * Compute SHA256 hash of file contents. - */ -async function computeSHA256(filepath: string): Promise { - try { - const content = await fs.readFile(filepath) - const hash = crypto.createHash('sha256') - hash.update(content) - return hash.digest('hex') - } catch (_e) { - return null - } -} - -/** - * Find all locations where a package exists in node_modules. - */ -async function findPackageLocations( - cwd: string, - packageName: string, -): Promise { - const locations: string[] = [] - - const rootNmPath = await findUp(NODE_MODULES, { cwd, onlyDirectories: true }) - if (!rootNmPath) { - return locations - } - - // Check root node_modules. - const rootPkgPath = normalizePath(path.join(rootNmPath, packageName)) - if (existsSync(rootPkgPath)) { - locations.push(rootPkgPath) - } - - // Note: Currently only checks root-level node_modules. - // Nested node_modules scanning could be added if needed for complex dependency trees. - - return locations -} - -/** - * Verify if a patch is actually applied by checking file hashes. - */ -async function verifyPatchApplied( - pkgPath: string, - patch: PatchRecord, -): Promise { - let allMatch = true - - for (const { 0: fileName, 1: fileInfo } of Object.entries(patch.files)) { - const filePath = normalizePath(path.join(pkgPath, fileName)) - - if (!existsSync(filePath)) { - return false - } - - // eslint-disable-next-line no-await-in-loop - const currentHash = await computeSHA256(filePath) - - if (currentHash !== fileInfo.afterHash) { - allMatch = false - break - } - } - - return allMatch -} - -/** - * Determine the actual status of a patch by checking the filesystem. - */ -async function determinePatchStatus( - cwd: string, - purl: string, - patch: PatchRecord, -): Promise<{ - appliedLocations: string[] - backupAvailable: boolean - status: 'downloaded' | 'applied' | 'failed' | 'unknown' -}> { - // Extract package name from PURL. - // Format: pkg:npm/package-name@version. - const match = purl.match(/pkg:npm\/([^@]+)/) - if (!match) { - return { - appliedLocations: [], - backupAvailable: false, - status: 'unknown', - } - } - - const packageName = match[1]! - const locations = await findPackageLocations(cwd, packageName) - - if (locations.length === 0) { - // Package not found in node_modules. - return { - appliedLocations: [], - backupAvailable: patch.uuid ? await hasBackupForPatch(patch.uuid) : false, - status: patch.status || 'downloaded', - } - } - - // Check if patch is applied in any location. - const appliedLocations: string[] = [] - - for (const location of locations) { - // eslint-disable-next-line no-await-in-loop - const isApplied = await verifyPatchApplied(location, patch) - if (isApplied) { - appliedLocations.push(location) - } - } - - let backupAvailable = false - if (patch.uuid) { - backupAvailable = await hasBackupForPatch(patch.uuid) - } - - if (appliedLocations.length > 0) { - return { - appliedLocations, - backupAvailable, - status: 'applied', - } - } - - // Package exists but patch not applied. - return { - appliedLocations: [], - backupAvailable, - status: patch.status || 'downloaded', - } -} - -export async function handlePatchStatus({ - cwd, - filters, - outputKind, - spinner, -}: HandlePatchStatusConfig): Promise { - try { - spinner?.start('Reading patch manifest') - - const dotSocketDirPath = normalizePath(path.join(cwd, DOT_SOCKET_DIR)) - const manifestPath = normalizePath( - path.join(dotSocketDirPath, MANIFEST_JSON), - ) - const manifestContent = await fs.readFile(manifestPath, UTF8) - const manifestData = JSON.parse(manifestContent) - const validated = PatchManifestSchema.parse(manifestData) - - spinner?.start('Checking patch status') - - const statuses: PatchStatus[] = [] - - for (const { 0: purl, 1: patch } of Object.entries(validated.patches)) { - const { appliedLocations, backupAvailable, status } = - // eslint-disable-next-line no-await-in-loop - await determinePatchStatus(cwd, purl, patch) - - const fileCount = Object.keys(patch.files).length - const vulnerabilityCount = Object.keys(patch.vulnerabilities || {}).length - - statuses.push({ - appliedAt: patch.appliedAt, - appliedLocations, - backupAvailable, - description: patch.description, - downloadedAt: patch.downloadedAt, - fileCount, - purl, - status, - uuid: patch.uuid, - vulnerabilityCount, - }) - } - - spinner?.stop() - - // Apply filters. - let filteredStatuses = statuses - - if (filters.applied) { - filteredStatuses = filteredStatuses.filter(s => s.status === 'applied') - } else if (filters.downloaded) { - filteredStatuses = filteredStatuses.filter(s => s.status === 'downloaded') - } else if (filters.failed) { - filteredStatuses = filteredStatuses.filter(s => s.status === 'failed') - } - - if (outputKind === 'text') { - if (statuses.length === 0) { - logger.log('No patches found in manifest') - } else if (filteredStatuses.length === 0) { - logger.log('No patches match the filter criteria') - } else { - logger.log( - `Found ${filteredStatuses.length} ${pluralize('patch', { count: filteredStatuses.length })}`, - ) - } - } - - await outputPatchStatusResult( - { - ok: true, - data: { statuses: filteredStatuses }, - }, - outputKind, - ) - } catch (e) { - spinner?.stop() - - let message = 'Failed to get patch status' - let cause = getErrorCause(e) - - if (e instanceof SyntaxError) { - message = `Invalid JSON in ${MANIFEST_JSON}` - cause = e.message - } else if (e instanceof Error && 'issues' in e) { - message = 'Schema validation failed' - cause = String(e) - } - - await outputPatchStatusResult( - { - ok: false, - code: 1, - message, - cause, - }, - outputKind, - ) - } -} diff --git a/packages/cli/src/commands/patch/manifest-schema.mts b/packages/cli/src/commands/patch/manifest-schema.mts deleted file mode 100644 index 1fb3fda49..000000000 --- a/packages/cli/src/commands/patch/manifest-schema.mts +++ /dev/null @@ -1,45 +0,0 @@ -import { z } from 'zod' - -export type PatchManifest = z.infer - -export type PatchRecord = z.infer - -export const PatchRecordSchema = z.object({ - description: z.string().optional(), - exportedAt: z.string(), - files: z.record( - z.string(), // File path. - z.object({ - beforeHash: z.string(), - afterHash: z.string(), - }), - ), - license: z.string().optional(), - tier: z.string().optional(), - uuid: z.string().optional(), - vulnerabilities: z - .record( - z.string(), // Vulnerability ID like "GHSA-jrhj-2j3q-xf3v". - z.object({ - cves: z.array(z.string()), - summary: z.string(), - severity: z.string(), - description: z.string(), - patchExplanation: z.string(), - }), - ) - .optional(), - // Status tracking fields. - status: z.enum(['downloaded', 'applied', 'failed']).optional(), - downloadedAt: z.string().optional(), - appliedAt: z.string().optional(), - appliedTo: z.array(z.string()).optional(), -}) - -export const PatchManifestSchema = z.object({ - patches: z.record( - // Package identifier like "npm:simplehttpserver@0.0.6". - z.string(), - PatchRecordSchema, - ), -}) diff --git a/packages/cli/src/commands/patch/output-patch-cleanup-result.mts b/packages/cli/src/commands/patch/output-patch-cleanup-result.mts deleted file mode 100644 index de73fe771..000000000 --- a/packages/cli/src/commands/patch/output-patch-cleanup-result.mts +++ /dev/null @@ -1,57 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib/logger' - -import { failMsgWithBadge } from '../../utils/error/fail-msg-with-badge.mts' -import { serializeResultJson } from '../../utils/output/result-json.mjs' - -import type { PatchCleanupData } from './handle-patch-cleanup.mts' -import type { CResult, OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -export async function outputPatchCleanupResult( - result: CResult, - outputKind: OutputKind, -): Promise { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - const { cleaned } = result.data - - if (outputKind === 'markdown') { - logger.log('## Patch Backups Cleaned\n') - logger.log(`**Count**: ${cleaned.length}\n`) - if (cleaned.length > 0) { - logger.log('**UUIDs**:\n') - for (const uuid of cleaned) { - logger.log(`- ${uuid}`) - } - } - return - } - - // Default output. - if (cleaned.length === 0) { - return - } - - logger.group('') - logger.log(`Cleaned backups: ${cleaned.length}`) - if (cleaned.length > 0) { - logger.group() - for (const uuid of cleaned) { - logger.log(`- ${uuid}`) - } - logger.groupEnd() - } - logger.groupEnd() -} diff --git a/packages/cli/src/commands/patch/output-patch-discover-result.mts b/packages/cli/src/commands/patch/output-patch-discover-result.mts deleted file mode 100644 index 6b750e706..000000000 --- a/packages/cli/src/commands/patch/output-patch-discover-result.mts +++ /dev/null @@ -1,163 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib/logger' - -import { OUTPUT_JSON } from '../../constants/cli.mjs' -import { failMsgWithBadge } from '../../utils/error/fail-msg-with-badge.mts' -import { serializeResultJson } from '../../utils/output/result-json.mjs' - -import type { DiscoveredPatch } from './handle-patch-discover.mts' -import type { CResult, OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -type PatchDiscoverResultData = { - patches: DiscoveredPatch[] -} - -export async function outputPatchDiscoverResult( - result: CResult, - outputKind: OutputKind, -): Promise { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === OUTPUT_JSON) { - logger.log(serializeResultJson(result)) - return - } - - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - const { patches } = result.data - - if (outputKind === 'markdown') { - if (patches.length === 0) { - logger.log('## Discovered Patches\n\nNo patches discovered.') - return - } - - logger.log('## Discovered Patches\n') - for (const patch of patches) { - logger.log(`### ${patch.purl}\n`) - if (patch.uuid) { - logger.log(`**UUID**: ${patch.uuid}\n`) - } - if (patch.description) { - logger.log(`**Description**: ${patch.description}\n`) - } - if (patch.tier) { - logger.log(`**Tier**: ${patch.tier}`) - } - if (patch.license) { - logger.log(`**License**: ${patch.license}`) - } - - // Free CVEs. - const freeCveCount = patch.freeCves.length - if (freeCveCount > 0) { - logger.log(`\n**Free CVEs**: ${freeCveCount}`) - for (const vuln of patch.freeCves) { - const cveStr = vuln.cve || 'Unknown' - const severityStr = vuln.severity ? ` (${vuln.severity})` : '' - logger.log(` - ${cveStr}${severityStr}`) - } - } - - // Enterprise CVEs. - const paidCveCount = patch.paidCves.length - if (paidCveCount > 0) { - logger.log(`\n**Enterprise CVEs**: ${paidCveCount}`) - for (const vuln of patch.paidCves) { - const cveStr = vuln.cve || 'Unknown' - const severityStr = vuln.severity ? ` (${vuln.severity})` : '' - logger.log(` - ${cveStr}${severityStr}`) - } - } - - // Free Features. - if (patch.freeFeatures?.length) { - logger.log('\n**Free Features**:') - for (const feature of patch.freeFeatures) { - logger.log(` - ${feature}`) - } - } - - // Enterprise Features. - if (patch.paidFeatures?.length) { - logger.log('\n**Enterprise Features**:') - for (const feature of patch.paidFeatures) { - logger.log(` - ${feature}`) - } - } - - logger.log('') - } - return - } - - // Default output. - if (patches.length === 0) { - return - } - - logger.group('') - for (const patch of patches) { - logger.log(`- ${patch.purl}`) - logger.group() - if (patch.uuid) { - logger.log(`UUID: ${patch.uuid}`) - } - if (patch.description) { - logger.log(`Description: ${patch.description}`) - } - if (patch.tier) { - logger.log(`Tier: ${patch.tier}`) - } - if (patch.license) { - logger.log(`License: ${patch.license}`) - } - - // Free CVEs. - const freeCveCount = patch.freeCves.length - if (freeCveCount > 0) { - logger.log(`Free CVEs: ${freeCveCount}`) - for (const vuln of patch.freeCves) { - const cveStr = vuln.cve || 'Unknown' - const severityStr = vuln.severity ? ` (${vuln.severity})` : '' - logger.log(` - ${cveStr}${severityStr}`) - } - } - - // Enterprise CVEs. - const paidCveCount = patch.paidCves.length - if (paidCveCount > 0) { - logger.log(`Enterprise CVEs: ${paidCveCount}`) - for (const vuln of patch.paidCves) { - const cveStr = vuln.cve || 'Unknown' - const severityStr = vuln.severity ? ` (${vuln.severity})` : '' - logger.log(` - ${cveStr}${severityStr}`) - } - } - - // Free Features. - if (patch.freeFeatures?.length) { - logger.log('Free Features:') - for (const feature of patch.freeFeatures) { - logger.log(` - ${feature}`) - } - } - - // Enterprise Features. - if (patch.paidFeatures?.length) { - logger.log('Enterprise Features:') - for (const feature of patch.paidFeatures) { - logger.log(` - ${feature}`) - } - } - - logger.groupEnd() - } - logger.groupEnd() -} diff --git a/packages/cli/src/commands/patch/output-patch-download-result.mts b/packages/cli/src/commands/patch/output-patch-download-result.mts deleted file mode 100644 index 024405681..000000000 --- a/packages/cli/src/commands/patch/output-patch-download-result.mts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Output formatter for patch download results. - * - * Displays download status for patches retrieved from Socket API. - * Supports JSON and text output formats. - * - * Features: - * - Summary of downloaded patches - * - Summary of failed patches - * - Error details for failures - * - JSON output for automation - */ - -import { getDefaultLogger } from '@socketsecurity/lib/logger' -import { pluralize } from '@socketsecurity/lib/words' - -import { OUTPUT_JSON } from '../../constants/cli.mts' -import { failMsgWithBadge } from '../../utils/error/fail-msg-with-badge.mts' -import { serializeResultJson } from '../../utils/output/result-json.mjs' - -import type { PatchDownloadResult } from './handle-patch-download.mts' -import type { CResult, OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -type OutputOptions = { - outputKind: OutputKind -} - -/** - * Output patch download results. - */ -export async function outputPatchDownloadResult( - result: CResult, - { outputKind }: OutputOptions, -): Promise { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === OUTPUT_JSON) { - logger.log(serializeResultJson(result)) - return - } - - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - const { downloaded, failed } = result.data - - logger.log('') - - // Show downloaded patches. - if (downloaded.length) { - logger.group( - `Successfully downloaded ${downloaded.length} ${pluralize('patch', { count: downloaded.length })}:`, - ) - for (const patch of downloaded) { - logger.success(`${patch.purl} (${patch.uuid})`) - } - logger.groupEnd() - } - - // Show failed patches. - if (failed.length) { - logger.log('') - logger.group( - `Failed to download ${failed.length} ${pluralize('patch', { count: failed.length })}:`, - ) - for (const failure of failed) { - logger.error(`${failure.uuid}: ${failure.error}`) - } - logger.groupEnd() - } - - // Summary. - logger.log('') - if (failed.length) { - logger.warn( - `Patch download completed with ${failed.length} ${pluralize('failure', { count: failed.length })}`, - ) - } else { - logger.success('All patches downloaded successfully!') - } -} diff --git a/packages/cli/src/commands/patch/output-patch-get-result.mts b/packages/cli/src/commands/patch/output-patch-get-result.mts deleted file mode 100644 index 2736e8912..000000000 --- a/packages/cli/src/commands/patch/output-patch-get-result.mts +++ /dev/null @@ -1,54 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib/logger' - -import { failMsgWithBadge } from '../../utils/error/fail-msg-with-badge.mts' -import { serializeResultJson } from '../../utils/output/result-json.mjs' - -import type { PatchGetData } from './handle-patch-get.mts' -import type { CResult, OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -export async function outputPatchGetResult( - result: CResult, - outputKind: OutputKind, -): Promise { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - const { files, outputDir, purl } = result.data - - if (outputKind === 'markdown') { - logger.log('## Patch Files Retrieved\n') - logger.log(`**PURL**: ${purl}`) - logger.log(`**Output Directory**: ${outputDir}`) - logger.log(`**Files**: ${files.length}\n`) - for (const file of files) { - logger.log(`- ${file}`) - } - return - } - - // Default output. - logger.group('') - logger.log(`PURL: ${purl}`) - logger.log(`Output directory: ${outputDir}`) - logger.log(`Files copied: ${files.length}`) - if (files.length > 0) { - logger.group() - for (const file of files) { - logger.log(`- ${file}`) - } - logger.groupEnd() - } - logger.groupEnd() -} diff --git a/packages/cli/src/commands/patch/output-patch-info-result.mts b/packages/cli/src/commands/patch/output-patch-info-result.mts deleted file mode 100644 index 52fa55423..000000000 --- a/packages/cli/src/commands/patch/output-patch-info-result.mts +++ /dev/null @@ -1,148 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib/logger' -import { pluralize } from '@socketsecurity/lib/words' - -import { failMsgWithBadge } from '../../utils/error/fail-msg-with-badge.mts' -import { mdHeader, mdKeyValue } from '../../utils/output/markdown.mts' -import { serializeResultJson } from '../../utils/output/result-json.mjs' - -import type { PatchInfoData } from './handle-patch-info.mts' -import type { CResult, OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -export async function outputPatchInfoResult( - result: CResult, - outputKind: OutputKind, -): Promise { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - const patch = result.data - - if (outputKind === 'markdown') { - logger.log(`${mdHeader('Patch Information', 2)}\n`) - logger.log(`${mdKeyValue('PURL', patch.purl)}\n`) - if (patch.uuid) { - logger.log(`${mdKeyValue('UUID', patch.uuid)}\n`) - } - logger.log( - `${mdKeyValue('Description', patch.description || 'No description provided')}\n`, - ) - logger.log(`${mdKeyValue('Exported', patch.exportedAt)}\n`) - if (patch.tier) { - logger.log(`${mdKeyValue('Tier', patch.tier)}\n`) - } - if (patch.license) { - logger.log(`${mdKeyValue('License', patch.license)}\n`) - } - - const fileCount = Object.keys(patch.files).length - logger.log(`${mdHeader(`Files (${fileCount})`, 3)}\n`) - for (const { 0: fileName, 1: fileInfo } of Object.entries(patch.files)) { - logger.log(`**${fileName}**`) - logger.log(`- Before: \`${fileInfo.beforeHash}\``) - logger.log(`- After: \`${fileInfo.afterHash}\``) - logger.log('') - } - - if (patch.vulnerabilities) { - const vulnCount = Object.keys(patch.vulnerabilities).length - logger.log(`${mdHeader(`Vulnerabilities (${vulnCount})`, 3)}\n`) - for (const { 0: ghsaId, 1: vuln } of Object.entries( - patch.vulnerabilities, - )) { - logger.log(`**${ghsaId}**`) - if (vuln.cves && vuln.cves.length > 0) { - logger.log(`- CVEs: ${vuln.cves.join(', ')}`) - } - if (vuln.severity) { - logger.log(`- Severity: ${vuln.severity}`) - } - if (vuln.summary) { - logger.log(`- Summary: ${vuln.summary}`) - } - if (vuln.description) { - logger.log(`\n${vuln.description}`) - } - if (vuln.patchExplanation) { - logger.log( - `\n${mdKeyValue('Patch Explanation', vuln.patchExplanation)}`, - ) - } - logger.log('') - } - } - return - } - - // Default output. - logger.group('') - if (patch.uuid) { - logger.log(`UUID: ${patch.uuid}`) - } - logger.log(`Description: ${patch.description || 'No description provided'}`) - logger.log(`Exported: ${patch.exportedAt}`) - if (patch.tier) { - logger.log(`Tier: ${patch.tier}`) - } - if (patch.license) { - logger.log(`License: ${patch.license}`) - } - - const fileCount = Object.keys(patch.files).length - logger.log( - `\nFiles (${fileCount} ${pluralize('file', { count: fileCount })}):`, - ) - logger.group() - for (const { 0: fileName, 1: fileInfo } of Object.entries(patch.files)) { - logger.log(`- ${fileName}`) - logger.group() - logger.log(`Before: ${fileInfo.beforeHash}`) - logger.log(`After: ${fileInfo.afterHash}`) - logger.groupEnd() - } - logger.groupEnd() - - if (patch.vulnerabilities) { - const vulnCount = Object.keys(patch.vulnerabilities).length - const vulnWord = vulnCount === 1 ? 'vulnerability' : 'vulnerabilities' - logger.log(`\nVulnerabilities (${vulnCount} ${vulnWord}):`) - logger.group() - for (const { 0: ghsaId, 1: vuln } of Object.entries( - patch.vulnerabilities, - )) { - logger.log(`- ${ghsaId}`) - logger.group() - if (vuln.cves && vuln.cves.length > 0) { - logger.log(`CVEs: ${vuln.cves.join(', ')}`) - } - if (vuln.severity) { - logger.log(`Severity: ${vuln.severity}`) - } - if (vuln.summary) { - logger.log(`Summary: ${vuln.summary}`) - } - if (vuln.description) { - logger.log('\nDescription:') - logger.log(vuln.description) - } - if (vuln.patchExplanation) { - logger.log('\nPatch Explanation:') - logger.log(vuln.patchExplanation) - } - logger.groupEnd() - } - logger.groupEnd() - } - logger.groupEnd() -} diff --git a/packages/cli/src/commands/patch/output-patch-list-result.mts b/packages/cli/src/commands/patch/output-patch-list-result.mts deleted file mode 100644 index 18842e5c9..000000000 --- a/packages/cli/src/commands/patch/output-patch-list-result.mts +++ /dev/null @@ -1,178 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib/logger' - -import { failMsgWithBadge } from '../../utils/error/fail-msg-with-badge.mts' -import { mdHeader, mdKeyValue } from '../../utils/output/markdown.mts' -import { serializeResultJson } from '../../utils/output/result-json.mjs' - -import type { PatchListEntry } from './handle-patch-list.mts' -import type { CResult, OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -type CleanedPatchEntry = { - appliedAt?: string - description?: string - exportedAt: string - fileCount: number - license?: string - purl: string - status?: string - tier?: string - uuid?: string - vulnerabilityCount: number -} - -type PatchListResultData = { - patches: PatchListEntry[] -} - -const STATUS_INDICATORS = { - __proto__: null, - applied: '[✓]', - downloaded: '[○]', - failed: '[✗]', -} - -function getStatusIndicator(status: string | undefined): string { - if (!status) { - return '[○]' // Default to downloaded if no status - } - return STATUS_INDICATORS[status as keyof typeof STATUS_INDICATORS] || '[?]' -} - -export async function outputPatchListResult( - result: CResult, - outputKind: OutputKind, -): Promise { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - if (result.ok) { - // Remove undefined fields from patches for clean JSON output. - const cleanedPatches = result.data.patches.map(patch => { - const cleaned: CleanedPatchEntry = { - exportedAt: patch.exportedAt, - fileCount: patch.fileCount, - purl: patch.purl, - vulnerabilityCount: patch.vulnerabilityCount, - } - if (patch.uuid !== undefined) { - cleaned.uuid = patch.uuid - } - if (patch.description !== undefined) { - cleaned.description = patch.description - } - if (patch.tier !== undefined) { - cleaned.tier = patch.tier - } - if (patch.license !== undefined) { - cleaned.license = patch.license - } - if (patch.status !== undefined) { - cleaned.status = patch.status - } - if (patch.appliedAt !== undefined) { - cleaned.appliedAt = patch.appliedAt - } - return cleaned - }) - logger.log( - serializeResultJson({ - ok: true, - data: { patches: cleanedPatches }, - }), - ) - } else { - logger.log(serializeResultJson(result)) - } - return - } - - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - const { patches } = result.data - - if (outputKind === 'markdown') { - if (patches.length === 0) { - logger.log(`${mdHeader('Patches', 2)}\n\nNo patches found.`) - return - } - - logger.log(`${mdHeader('Patches', 2)}\n`) - for (const patch of patches) { - const indicator = getStatusIndicator(patch.status) - logger.log(`${mdHeader(`${indicator} ${patch.purl}`, 3)}\n`) - if (patch.status) { - const statusName = - patch.status.charAt(0).toUpperCase() + patch.status.slice(1) - logger.log(`${mdKeyValue('Status', statusName)}\n`) - } - if (patch.uuid) { - logger.log(`${mdKeyValue('UUID', patch.uuid)}\n`) - } - logger.log( - `${mdKeyValue('Description', patch.description || 'No description provided')}\n`, - ) - logger.log(mdKeyValue('Exported', patch.exportedAt)) - if (patch.appliedAt) { - logger.log(mdKeyValue('Applied', patch.appliedAt)) - } - logger.log(mdKeyValue('Files', patch.fileCount)) - logger.log(mdKeyValue('Vulnerabilities', patch.vulnerabilityCount)) - if (patch.tier) { - logger.log(mdKeyValue('Tier', patch.tier)) - } - if (patch.license) { - logger.log(mdKeyValue('License', patch.license)) - } - logger.log('') - } - logger.log( - `${mdKeyValue('Legend', '[✓] Applied | [○] Downloaded | [✗] Failed')}`, - ) - return - } - - // Default output. - if (patches.length === 0) { - return - } - - logger.group('') - for (const patch of patches) { - const indicator = getStatusIndicator(patch.status) - logger.log(`${indicator} ${patch.purl}`) - logger.group() - if (patch.status) { - const statusName = - patch.status.charAt(0).toUpperCase() + patch.status.slice(1) - logger.log(`Status: ${statusName}`) - } - if (patch.uuid) { - logger.log(`UUID: ${patch.uuid}`) - } - logger.log(`Description: ${patch.description || 'No description provided'}`) - logger.log(`Exported: ${patch.exportedAt}`) - if (patch.appliedAt) { - logger.log(`Applied: ${patch.appliedAt}`) - } - logger.log(`Files: ${patch.fileCount}`) - logger.log(`Vulnerabilities: ${patch.vulnerabilityCount}`) - if (patch.tier) { - logger.log(`Tier: ${patch.tier}`) - } - if (patch.license) { - logger.log(`License: ${patch.license}`) - } - logger.groupEnd() - } - logger.groupEnd() - - // Legend. - logger.log('') - logger.log('Legend: [✓] Applied | [○] Downloaded | [✗] Failed') -} diff --git a/packages/cli/src/commands/patch/output-patch-result.mts b/packages/cli/src/commands/patch/output-patch-result.mts deleted file mode 100644 index 92afa7681..000000000 --- a/packages/cli/src/commands/patch/output-patch-result.mts +++ /dev/null @@ -1,47 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib/logger' -import { pluralize } from '@socketsecurity/lib/words' - -import { OUTPUT_JSON } from '../../constants/cli.mts' -import { failMsgWithBadge } from '../../utils/error/fail-msg-with-badge.mts' -import { serializeResultJson } from '../../utils/output/result-json.mjs' - -import type { CResult, OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -export async function outputPatchResult( - result: CResult<{ patched: string[] }>, - outputKind: OutputKind, -) { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === OUTPUT_JSON) { - logger.log(serializeResultJson(result)) - return - } - - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - const { patched } = result.data - - logger.log('') - - if (patched.length) { - logger.group( - `Successfully processed patches for ${patched.length} ${pluralize('package', { count: patched.length })}:`, - ) - for (const pkg of patched) { - logger.success(pkg) - } - logger.groupEnd() - } else { - logger.warn('No packages found requiring patches.') - } - - logger.log('') - logger.success('Patch command completed!') -} diff --git a/packages/cli/src/commands/patch/output-patch-rm-result.mts b/packages/cli/src/commands/patch/output-patch-rm-result.mts deleted file mode 100644 index 6ee67e194..000000000 --- a/packages/cli/src/commands/patch/output-patch-rm-result.mts +++ /dev/null @@ -1,42 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib/logger' - -import { failMsgWithBadge } from '../../utils/error/fail-msg-with-badge.mts' -import { serializeResultJson } from '../../utils/output/result-json.mjs' - -import type { PatchRmData } from './handle-patch-rm.mts' -import type { CResult, OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -export async function outputPatchRmResult( - result: CResult, - outputKind: OutputKind, -): Promise { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - const { filesRestored, purl } = result.data - - if (outputKind === 'markdown') { - logger.log('## Patch Removed\n') - logger.log(`**PURL**: ${purl}`) - logger.log(`**Files Restored**: ${filesRestored}`) - return - } - - // Default output. - logger.group('') - logger.log(`PURL: ${purl}`) - logger.log(`Files restored: ${filesRestored}`) - logger.groupEnd() -} diff --git a/packages/cli/src/commands/patch/output-patch-status-result.mts b/packages/cli/src/commands/patch/output-patch-status-result.mts deleted file mode 100644 index ed6e753aa..000000000 --- a/packages/cli/src/commands/patch/output-patch-status-result.mts +++ /dev/null @@ -1,166 +0,0 @@ -import { getDefaultLogger } from '@socketsecurity/lib/logger' - -import { failMsgWithBadge } from '../../utils/error/fail-msg-with-badge.mts' -import { mdHeader, mdKeyValue } from '../../utils/output/markdown.mts' -import { serializeResultJson } from '../../utils/output/result-json.mjs' - -import type { PatchStatus } from './handle-patch-status.mts' -import type { CResult, OutputKind } from '../../types.mts' -const logger = getDefaultLogger() - -type PatchStatusResultData = { - statuses: PatchStatus[] -} - -const STATUS_INDICATORS = { - __proto__: null, - applied: '[✓]', - downloaded: '[○]', - failed: '[✗]', - unknown: '[?]', -} - -const STATUS_NAMES = { - __proto__: null, - applied: 'Applied', - downloaded: 'Downloaded', - failed: 'Failed', - unknown: 'Unknown', -} - -export async function outputPatchStatusResult( - result: CResult, - outputKind: OutputKind, -): Promise { - if (!result.ok) { - process.exitCode = result.code ?? 1 - } - - if (outputKind === 'json') { - logger.log(serializeResultJson(result)) - return - } - - if (!result.ok) { - logger.fail(failMsgWithBadge(result.message, result.cause)) - return - } - - const { statuses } = result.data - - if (outputKind === 'markdown') { - if (statuses.length === 0) { - logger.log(`${mdHeader('Patch Status', 2)}\n\nNo patches found.`) - return - } - - logger.log(`${mdHeader('Patch Status', 2)}\n`) - - for (const status of statuses) { - const indicator = STATUS_INDICATORS[status.status] - const statusName = STATUS_NAMES[status.status] - - logger.log(`${mdHeader(`${indicator} ${status.purl}`, 3)}\n`) - logger.log(`${mdKeyValue('Status', statusName)}\n`) - - if (status.uuid) { - logger.log(`${mdKeyValue('UUID', status.uuid)}\n`) - } - - if (status.description) { - logger.log(`${mdKeyValue('Description', status.description)}\n`) - } - - if (status.downloadedAt) { - logger.log(mdKeyValue('Downloaded', status.downloadedAt)) - } - - if (status.appliedAt) { - logger.log(mdKeyValue('Applied', status.appliedAt)) - } - - logger.log(mdKeyValue('Files', status.fileCount)) - logger.log(mdKeyValue('Vulnerabilities', status.vulnerabilityCount)) - - if (status.appliedLocations.length > 0) { - logger.log('**Locations**:') - for (const location of status.appliedLocations) { - logger.log(`- ${location}`) - } - } - - logger.log( - mdKeyValue('Backup Available', status.backupAvailable ? 'Yes' : 'No'), - ) - logger.log('') - } - - // Legend. - logger.log( - mdKeyValue( - 'Legend', - '[✓] Applied | [○] Downloaded | [✗] Failed | [?] Unknown', - ), - ) - - return - } - - // Default output. - if (statuses.length === 0) { - return - } - - logger.group('') - - for (const status of statuses) { - const indicator = STATUS_INDICATORS[status.status] - const statusName = STATUS_NAMES[status.status] - - logger.log(`${indicator} ${status.purl}`) - logger.group() - - logger.log(`Status: ${statusName}`) - - if (status.uuid) { - logger.log(`UUID: ${status.uuid}`) - } - - if (status.description) { - logger.log(`Description: ${status.description}`) - } - - if (status.downloadedAt) { - logger.log(`Downloaded: ${status.downloadedAt}`) - } - - if (status.appliedAt) { - logger.log(`Applied: ${status.appliedAt}`) - } - - logger.log(`Files: ${status.fileCount}`) - logger.log(`Vulnerabilities: ${status.vulnerabilityCount}`) - - if (status.appliedLocations.length > 0) { - logger.log('Locations:') - logger.group() - for (const location of status.appliedLocations) { - logger.log(`- ${location}`) - } - logger.groupEnd() - } - - logger.log( - `Backup: ${status.backupAvailable ? 'Available' : 'Not available'}`, - ) - - logger.groupEnd() - } - - logger.groupEnd() - - // Legend. - logger.log('') - logger.log('Legend:') - logger.log(' [✓] Applied [○] Downloaded [✗] Failed [?] Unknown') -} diff --git a/packages/cli/src/constants/env.mts b/packages/cli/src/constants/env.mts index 57139a5c9..1ae2313b9 100644 --- a/packages/cli/src/constants/env.mts +++ b/packages/cli/src/constants/env.mts @@ -63,7 +63,9 @@ import { SOCKET_CLI_PYCLI_LOCAL_PATH } from '../env/socket-cli-pycli-local-path. import { SOCKET_CLI_SEA_NODE_VERSION } from '../env/socket-cli-sea-node-version.mts' import { SOCKET_CLI_SFW_LOCAL_PATH } from '../env/socket-cli-sfw-local-path.mts' import { SOCKET_CLI_SKIP_UPDATE_CHECK } from '../env/socket-cli-skip-update-check.mts' +import { SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH } from '../env/socket-cli-socket-patch-local-path.mts' import { SOCKET_CLI_VIEW_ALL_RISKS } from '../env/socket-cli-view-all-risks.mts' +import { getSocketPatchVersion } from '../env/socket-patch-version.mts' import { getSynpVersion } from '../env/synp-version.mts' import { TEMP } from '../env/temp.mts' import { TERM } from '../env/term.mts' @@ -122,6 +124,7 @@ export { SOCKET_CLI_SEA_NODE_VERSION, SOCKET_CLI_SFW_LOCAL_PATH, SOCKET_CLI_SKIP_UPDATE_CHECK, + SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH, SOCKET_CLI_VIEW_ALL_RISKS, TEMP, TERM, @@ -143,6 +146,7 @@ export { getPyCliVersion, getPythonBuildTag, getPythonVersion, + getSocketPatchVersion, getSynpVersion, isPublishedBuild, isSentryBuild, @@ -202,6 +206,7 @@ const envSnapshot = { SOCKET_CLI_SEA_NODE_VERSION, SOCKET_CLI_SFW_LOCAL_PATH, SOCKET_CLI_SKIP_UPDATE_CHECK, + SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH, SOCKET_CLI_VIEW_ALL_RISKS, TEMP, TERM, @@ -222,6 +227,7 @@ const envSnapshot = { INLINED_SOCKET_CLI_PYCLI_VERSION: getPyCliVersion(), INLINED_SOCKET_CLI_SENTRY_BUILD: isSentryBuild(), INLINED_SOCKET_CLI_SFW_VERSION: getSwfVersion(), + INLINED_SOCKET_CLI_SOCKET_PATCH_VERSION: getSocketPatchVersion(), INLINED_SOCKET_CLI_SYNP_VERSION: getSynpVersion(), INLINED_SOCKET_CLI_VERSION: getCliVersion(), INLINED_SOCKET_CLI_VERSION_HASH: getCliVersionHash(), diff --git a/packages/cli/src/env/coana-version.mts b/packages/cli/src/env/coana-version.mts index a30735a4a..02b4acc64 100644 --- a/packages/cli/src/env/coana-version.mts +++ b/packages/cli/src/env/coana-version.mts @@ -9,5 +9,11 @@ import process from 'node:process' export function getCoanaVersion(): string { - return process.env['INLINED_SOCKET_CLI_COANA_VERSION'] ?? '' + const version = process.env['INLINED_SOCKET_CLI_COANA_VERSION'] + if (!version) { + throw new Error( + 'INLINED_SOCKET_CLI_COANA_VERSION not found. Please ensure @coana-tech/cli is properly configured in external-tools.json.', + ) + } + return version } diff --git a/packages/cli/src/env/sfw-version.mts b/packages/cli/src/env/sfw-version.mts index fd23071c1..81e50150a 100644 --- a/packages/cli/src/env/sfw-version.mts +++ b/packages/cli/src/env/sfw-version.mts @@ -9,5 +9,11 @@ import process from 'node:process' export function getSwfVersion(): string { - return process.env['INLINED_SOCKET_CLI_SFW_VERSION'] ?? '' + const version = process.env['INLINED_SOCKET_CLI_SFW_VERSION'] + if (!version) { + throw new Error( + 'INLINED_SOCKET_CLI_SFW_VERSION not found. Please ensure sfw is properly configured in external-tools.json.', + ) + } + return version } diff --git a/packages/cli/src/env/socket-cli-socket-patch-local-path.mts b/packages/cli/src/env/socket-cli-socket-patch-local-path.mts new file mode 100644 index 000000000..5e6e24858 --- /dev/null +++ b/packages/cli/src/env/socket-cli-socket-patch-local-path.mts @@ -0,0 +1,9 @@ +/** + * Local path override for socket-patch binary. + * Useful for local development and testing with custom socket-patch builds. + */ + +import { env } from 'node:process' + +export const SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH = + env['SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH'] diff --git a/packages/cli/src/env/socket-patch-version.mts b/packages/cli/src/env/socket-patch-version.mts new file mode 100644 index 000000000..e71879bf9 --- /dev/null +++ b/packages/cli/src/env/socket-patch-version.mts @@ -0,0 +1,19 @@ +/** + * Socket Patch version getter function. + * Uses direct process.env access so esbuild define can inline values. + * IMPORTANT: esbuild's define plugin can only replace direct process.env['KEY'] references. + * If we imported from env modules, esbuild couldn't inline the values at build time. + * This is critical for embedding version info into the binary. + */ + +import process from 'node:process' + +export function getSocketPatchVersion(): string { + const version = process.env['INLINED_SOCKET_CLI_SOCKET_PATCH_VERSION'] + if (!version) { + throw new Error( + 'INLINED_SOCKET_CLI_SOCKET_PATCH_VERSION not found. Please ensure socket-patch is properly configured in external-tools.json.', + ) + } + return version +} diff --git a/packages/cli/src/utils/dlx/binary.mts b/packages/cli/src/utils/dlx/binary.mts deleted file mode 100644 index 70d012ae2..000000000 --- a/packages/cli/src/utils/dlx/binary.mts +++ /dev/null @@ -1,514 +0,0 @@ -/** - * DLX binary execution utilities for Socket CLI. - * Downloads and executes arbitrary binaries from URLs with caching. - * - * Key Functions: - * - cleanDlxCache: Clean expired binary cache entries - * - dlxBinary: Download and execute binary from URL with caching - * - getDlxCachePath: Get the cache directory path for binaries - * - getSocketHomePath: Get the base .socket directory path - * - listDlxCache: Get information about cached binaries - * - * Cache Management: - * - Stores binaries in ~/.socket/_dlx (POSIX) - * - Stores binaries in %USERPROFILE%\.socket\_dlx (Windows) - * - Uses npm/npx approach: first 16 chars of SHA-512 (shorter Windows paths) - * - Cache key input: URL + binary name for uniqueness - * - Supports TTL-based cache expiration - * - Verifies checksums for security - * - * Platform Support: - * - Handles Windows, macOS, and Linux - * - Manages executable permissions automatically - * - Supports architecture-specific binary selection - */ - -import { createHash } from 'node:crypto' -import { existsSync, promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { generateCacheKey } from '@socketsecurity/lib/dlx/cache' -import { readJson, safeDelete, safeMkdir } from '@socketsecurity/lib/fs' -import { normalizePath } from '@socketsecurity/lib/paths/normalize' -import { getSocketDlxDir } from '@socketsecurity/lib/paths/socket' -import { spawn } from '@socketsecurity/lib/spawn' - -import { DLX_BINARY_CACHE_TTL } from '../../constants/cache.mjs' -import { InputError } from '../error/errors.mts' - -import type { SpawnExtra, SpawnOptions } from '@socketsecurity/lib/spawn' - -/** - * Metadata structure for cached binaries (.dlx-metadata.json). - * Unified schema shared across TypeScript (dlxBinary) and C++ (socket_macho_decompress). - * Canonical documentation: @socketsecurity/lib/src/dlx-binary.ts (DlxMetadata interface) - * - * Core Fields (present in all implementations): - * - version: Schema version (currently "1.0.0") - * - cache_key: First 16 chars of SHA-512 hash (matches directory name) - * - timestamp: Unix timestamp in milliseconds - * - checksum: Full hash of cached binary (SHA-512 for C++, SHA-256 for TypeScript) - * - checksum_algorithm: "sha512" or "sha256" - * - platform: "darwin" | "linux" | "win32" - * - arch: "x64" | "arm64" - * - size: Size of cached binary in bytes - * - source: Origin information - * - type: "download" (from URL) or "decompression" (from embedded binary) - * - url: Download URL (if type is "download") - * - path: Source binary path (if type is "decompression") - * - * Extra Fields (implementation-specific): - * - For C++ decompression: - * - compressed_size: Size of compressed data in bytes - * - compression_algorithm: Brotli level (numeric) - * - compression_ratio: original_size / compressed_size - * - * Example (TypeScript download): - * { - * "version": "1.0.0", - * "cache_key": "a1b2c3d4e5f67890", - * "timestamp": 1730332800000, - * "checksum": "sha256-abc123...", - * "checksum_algorithm": "sha256", - * "platform": "darwin", - * "arch": "arm64", - * "size": 15000000, - * "source": { - * "type": "download", - * "url": "https://example.com/binary" - * } - * } - * - * Example (C++ decompression): - * { - * "version": "1.0.0", - * "cache_key": "0123456789abcdef", - * "timestamp": 1730332800000, - * "checksum": "sha512-def456...", - * "checksum_algorithm": "sha512", - * "platform": "darwin", - * "arch": "arm64", - * "size": 13000000, - * "source": { - * "type": "decompression", - * "path": "/usr/local/bin/socket" - * }, - * "extra": { - * "compressed_size": 1700000, - * "compression_algorithm": 3, - * "compression_ratio": 7.647 - * } - * } - */ -interface DlxMetadata { - version: string - cache_key: string - timestamp: number - checksum: string - checksum_algorithm: string - platform: string - arch: string - size: number - source?: { - type: 'download' | 'decompression' - url?: string - path?: string - } - extra?: Record -} - -export interface DlxBinaryOptions { - /** URL to download the binary from. */ - url: string - /** Optional name for the cached binary (defaults to URL hash). */ - name?: string - /** Expected checksum (sha512) for verification. */ - checksum?: string - /** Cache TTL in milliseconds (default: 7 days). */ - cacheTtl?: number - /** Force re-download even if cached. */ - force?: boolean - /** Platform override (defaults to current platform). */ - platform?: NodeJS.Platform - /** Architecture override (defaults to current arch). */ - arch?: string - /** Additional spawn options. */ - spawnOptions?: SpawnOptions -} - -export interface DlxBinaryResult { - /** Path to the cached binary. */ - binaryPath: string - /** Whether the binary was newly downloaded. */ - downloaded: boolean - /** The spawn promise for the running process. */ - spawnPromise: ReturnType -} - -/** - * Get metadata file path for a cached binary. - */ -function getMetadataPath(cacheEntryPath: string): string { - return path.join(cacheEntryPath, '.dlx-metadata.json') -} - -/** - * Check if a cached binary is still valid. - */ -async function isCacheValid( - cacheEntryPath: string, - cacheTtl: number, -): Promise { - try { - const metaPath = getMetadataPath(cacheEntryPath) - if (!existsSync(metaPath)) { - return false - } - - const metadata = (await readJson(metaPath, { - throws: false, - })) as DlxMetadata | null - if (!metadata || !metadata.timestamp) { - return false - } - const now = Date.now() - const age = now - metadata.timestamp - - return age < cacheTtl - } catch { - return false - } -} - -/** - * Download a file from a URL with integrity checking. - */ -async function downloadBinary( - url: string, - destPath: string, - checksum?: string, -): Promise { - const response = await fetch(url) - if (!response.ok) { - throw new InputError( - `Failed to download binary: ${response.status} ${response.statusText}`, - ) - } - - // Create a temporary file first. - const tempPath = `${destPath}.download` - const hasher = createHash('sha512') - - try { - // Ensure directory exists. - await safeMkdir(path.dirname(destPath), { recursive: true }) - - // Get the response as a buffer and compute hash. - const arrayBuffer = await response.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) - - // Compute hash. - hasher.update(buffer) - const actualChecksum = hasher.digest('hex') - - // Verify checksum if provided. - if (checksum && actualChecksum !== checksum) { - throw new InputError( - `Checksum mismatch: expected ${checksum}, got ${actualChecksum}`, - ) - } - - // Write to temp file. - await fs.writeFile(tempPath, buffer) - - // Make executable on POSIX systems. - if (os.platform() !== 'win32') { - await fs.chmod(tempPath, 0o755) - } - - // Move temp file to final location. - await fs.rename(tempPath, destPath) - - return actualChecksum - } catch (error) { - // Clean up temp file on error. - try { - await fs.unlink(tempPath) - } catch { - // Ignore cleanup errors. - } - throw error - } -} - -/** - * Write metadata for a cached binary. - */ -async function writeMetadata( - cacheEntryPath: string, - cacheKey: string, - url: string, - checksum: string, - size: number, -): Promise { - const metaPath = getMetadataPath(cacheEntryPath) - const metadata: DlxMetadata = { - version: '1.0.0', - cache_key: cacheKey, - timestamp: Date.now(), - checksum, - checksum_algorithm: 'sha512', - platform: os.platform(), - arch: os.arch(), - size, - source: { - type: 'download', - url, - }, - } - await fs.writeFile(metaPath, JSON.stringify(metadata, null, 2)) -} - -/** - * Clean expired entries from the DLX cache. - */ -export async function cleanDlxCache( - maxAge: number = DLX_BINARY_CACHE_TTL, -): Promise { - const cacheDir = getDlxCachePath() - - if (!existsSync(cacheDir)) { - return 0 - } - - let cleaned = 0 - const now = Date.now() - const entries = await fs.readdir(cacheDir) - - for (const entry of entries) { - const entryPath = path.join(cacheDir, entry) - const metaPath = getMetadataPath(entryPath) - - try { - // eslint-disable-next-line no-await-in-loop - const stats = await fs.stat(entryPath) - if (!stats.isDirectory()) { - continue - } - - // eslint-disable-next-line no-await-in-loop - const metadata = (await readJson(metaPath, { - throws: false, - })) as DlxMetadata | null - if (!metadata) { - continue - } - const age = now - metadata.timestamp - - if (age > maxAge) { - // Remove entire cache entry directory. - // eslint-disable-next-line no-await-in-loop - await safeDelete(entryPath) - cleaned += 1 - } - } catch { - // If we can't read metadata, check if directory is empty or corrupted. - try { - // eslint-disable-next-line no-await-in-loop - const contents = await fs.readdir(entryPath) - if (!contents.length) { - // Remove empty directory. - // eslint-disable-next-line no-await-in-loop - await safeDelete(entryPath) - cleaned += 1 - } - } catch {} - } - } - - return cleaned -} - -/** - * Download and execute a binary from a URL with caching. - */ -export async function dlxBinary( - args: string[] | readonly string[], - options?: DlxBinaryOptions | undefined, - spawnExtra?: SpawnExtra | undefined, -): Promise { - const { - arch = os.arch(), - cacheTtl = DLX_BINARY_CACHE_TTL, - checksum, - force = false, - name, - platform = os.platform(), - spawnOptions, - url, - } = { __proto__: null, ...options } as DlxBinaryOptions - - // Generate cache paths similar to pnpm/npx structure. - const cacheDir = getDlxCachePath() - const platformKey = `${platform}-${arch}` - const binaryName = - name || `binary-${platformKey}${platform === 'win32' ? '.exe' : ''}` - // Use shared generateCacheKey from @socketsecurity/lib/dlx. - // Spec format for binaries: `${url}:${binaryName}`. - const cacheKey = generateCacheKey(`${url}:${binaryName}`) - const cacheEntryDir = path.join(cacheDir, cacheKey) - const binaryPath = path.join(cacheEntryDir, binaryName) - - let downloaded = false - let computedChecksum = checksum - - // Check if we need to download. - if ( - !force && - existsSync(cacheEntryDir) && - (await isCacheValid(cacheEntryDir, cacheTtl)) - ) { - // Binary is cached and valid, read the checksum from metadata. - try { - const metaPath = getMetadataPath(cacheEntryDir) - const metadata = (await readJson(metaPath, { - throws: false, - })) as DlxMetadata | null - if (metadata && typeof metadata.checksum === 'string') { - computedChecksum = metadata.checksum - } else { - // If metadata is invalid, re-download. - downloaded = true - } - } catch { - // If we can't read metadata, re-download. - downloaded = true - } - } else { - downloaded = true - } - - if (downloaded) { - // Ensure cache directory exists. - await safeMkdir(cacheEntryDir, { recursive: true }) - - // Download the binary. - computedChecksum = await downloadBinary(url, binaryPath, checksum) - - // Get file size for metadata. - const stats = await fs.stat(binaryPath) - await writeMetadata( - cacheEntryDir, - cacheKey, - url, - computedChecksum || '', - stats.size, - ) - } - - // Execute the binary. - const spawnPromise = spawn(binaryPath, args, spawnOptions, spawnExtra) - - return { - binaryPath, - downloaded, - spawnPromise, - } -} - -/** - * Get the DLX binary cache directory path. - * Returns normalized path for cross-platform compatibility. - * Uses getSocketDlxDir from socket-lib for correct path: ~/.socket/_dlx - */ -export function getDlxCachePath(): string { - return getSocketDlxDir() -} - -/** - * Get the base .socket directory path. - * Uses %USERPROFILE% on Windows, $HOME on POSIX systems. - * Returns normalized path for cross-platform compatibility. - */ -export function getSocketHomePath(): string { - const homedir = os.homedir() - if (!homedir) { - throw new InputError('Unable to determine home directory') - } - return normalizePath(path.join(homedir, '.socket')) -} - -/** - * Get information about cached binaries. - */ -export async function listDlxCache(): Promise< - Array<{ - name: string - url: string - size: number - age: number - platform: string - arch: string - checksum: string - }> -> { - const cacheDir = getDlxCachePath() - - if (!existsSync(cacheDir)) { - return [] - } - - const results = [] - const now = Date.now() - const entries = await fs.readdir(cacheDir) - - for (const entry of entries) { - const entryPath = path.join(cacheDir, entry) - try { - // eslint-disable-next-line no-await-in-loop - const stats = await fs.stat(entryPath) - if (!stats.isDirectory()) { - continue - } - - const metaPath = getMetadataPath(entryPath) - // eslint-disable-next-line no-await-in-loop - const metadata = (await readJson(metaPath, { - throws: false, - })) as DlxMetadata | null - if (!metadata || !metadata.timestamp) { - continue - } - - // Get URL from source field for unified schema. - const url = metadata.source?.url || '' - if (!url) { - continue - } - - // Find the binary file in the directory. - // eslint-disable-next-line no-await-in-loop - const files = await fs.readdir(entryPath) - const binaryFile = files.find(f => !f.startsWith('.')) - - if (binaryFile) { - const binaryPath = path.join(entryPath, binaryFile) - // eslint-disable-next-line no-await-in-loop - const binaryStats = await fs.stat(binaryPath) - - results.push({ - name: binaryFile, - url, - size: binaryStats.size, - age: now - metadata.timestamp, - platform: metadata.platform || 'unknown', - arch: metadata.arch || 'unknown', - checksum: metadata.checksum || '', - }) - } - } catch {} - } - - return results -} diff --git a/packages/cli/src/utils/dlx/detection.mts b/packages/cli/src/utils/dlx/detection.mts deleted file mode 100644 index 7efa3c273..000000000 --- a/packages/cli/src/utils/dlx/detection.mts +++ /dev/null @@ -1,145 +0,0 @@ -/** - * Temporary package executor detection utilities for Socket CLI. - * Identifies and handles temporary execution contexts. - * - * Key Functions: - * - isRunningInTemporaryExecutor: Detects if running in npx/dlx/exec context - * - shouldSkipShadow: Determines if shadow installation should be skipped - * - * Temporary Execution Contexts: - * - npm exec/npx: Runs packages in temporary npm cache - * - pnpm dlx: Executes packages in temporary pnpm store - * - yarn dlx: Runs packages in temporary yarn environment - * - * Detection Methods: - * - Environment variable analysis (npm_config_user_agent) - * - Path pattern matching for temporary directories - * - Cache directory identification - * - * Usage: - * - Prevents shadow installation in temporary contexts - * - Avoids PATH pollution in ephemeral environments - * - Ensures package manager commands work correctly - */ - -import { normalizePath } from '@socketsecurity/lib/paths/normalize' - -import ENV from '../../constants/env.mts' - -/** - * Detects if the current process is running in a temporary package execution context - * such as npm exec, npx, pnpm dlx, or yarn dlx. - * - * When package managers run commands via exec/npx/dlx, they execute in temporary directories - * that are cleaned up after execution. Creating persistent shadows or modifying PATH - * in these contexts can break subsequent package manager commands. - * - * @returns true if running in an exec/npx/dlx context, false otherwise - */ -export function isRunningInTemporaryExecutor(): boolean { - // Check environment variable for exec/npx/dlx indicators. - const userAgent = ENV.npm_config_user_agent - if ( - userAgent?.includes('exec') || - userAgent?.includes('npx') || - userAgent?.includes('dlx') - ) { - return true - } - - // Normalize the __dirname path for consistent checking across platforms. - const normalizedDirname = normalizePath(__dirname) - - // Check if running from npm's npx cache. - const npmCache = ENV.npm_config_cache - if (npmCache && normalizedDirname.includes(normalizePath(npmCache))) { - return true - } - - // Check common temporary execution path patterns. - // Note: Always use forward slashes since we normalize paths to forward slashes. - const tempPatterns = [ - '_npx', // npm's npx cache directory - '.pnpm-store', // pnpm dlx temporary store - 'dlx-', // Common dlx directory prefix - '.yarn/$$', // Yarn Berry PnP virtual packages - 'AppData/Local/Temp/xfs-', // Yarn on Windows - ] - - return tempPatterns.some(pattern => normalizedDirname.includes(pattern)) -} - -export type ShadowInstallationOptions = { - cwd?: string | undefined - win32?: boolean | undefined -} - -/** - * Determines if shadow binaries should be installed. - * Shadows should NOT be installed when: - * - Running in a temporary execution context (exec/npx/dlx) - * - On Windows with an existing binary path (required for Windows to function) - * - * @param binPath - Path to the binary being shadowed - * @param options - Configuration options - * @param options.cwd - Current working directory path to check - * @param options.win32 - Whether running on Windows - * @returns true if shadow installation should be skipped - */ -export function shouldSkipShadow( - binPath: string, - options: ShadowInstallationOptions, -): boolean { - const { cwd = process.cwd(), win32 = false } = { - __proto__: null, - ...options, - } as ShadowInstallationOptions - - // Windows compatibility: Skip shadow installation if binary is already found. - // - // This check is required because Windows handles executables differently than Unix: - // 1. File locking - Windows locks running executables, so cmd-shim creation would - // fail with EBUSY/EACCES errors when trying to create wrapper files. - // 2. PATH conflicts - Attempting to shadow an already-resolved binary can create - // circular references or ambiguous command resolution. - // 3. Registry integration - Windows package managers often use system-level - // integrations beyond just PATH that our shadowing would interfere with. - // - // Without this check, users would see "Access Denied" or file locking errors - // that are difficult to debug. This is not a performance optimization - the - // shadow installation will fail without it. - if (win32 && binPath) { - return true - } - - // Check environment variable for exec/npx/dlx indicators. - const userAgent = ENV.npm_config_user_agent - if ( - userAgent?.includes('exec') || - userAgent?.includes('npx') || - userAgent?.includes('dlx') - ) { - return true - } - - // Normalize the cwd path for consistent checking across platforms. - const normalizedCwd = normalizePath(cwd) - - // Check if running from npm's npx cache. - const npmCache = ENV.npm_config_cache - if (npmCache && normalizedCwd.includes(normalizePath(npmCache))) { - return true - } - - // Check common temporary execution path patterns. - // Note: Always use forward slashes since we normalize paths to forward slashes. - const tempPatterns = [ - '_npx', // npm's npx cache directory - '.pnpm-store', // pnpm dlx temporary store - 'dlx-', // Common dlx directory prefix - '.yarn/$$', // Yarn Berry PnP virtual packages - 'AppData/Local/Temp/xfs-', // Yarn on Windows - ] - - return tempPatterns.some(pattern => normalizedCwd.includes(pattern)) -} diff --git a/packages/cli/src/utils/dlx/resolve-binary.mts b/packages/cli/src/utils/dlx/resolve-binary.mts index 2161397ac..849c80bac 100644 --- a/packages/cli/src/utils/dlx/resolve-binary.mts +++ b/packages/cli/src/utils/dlx/resolve-binary.mts @@ -4,6 +4,7 @@ */ import ENV from '../../constants/env.mts' +import { getSocketPatchVersion } from '../../env/socket-patch-version.mts' import { getSwfVersion } from '../../env/sfw-version.mts' import type { DlxPackageSpec } from './spawn.mjs' @@ -90,6 +91,26 @@ export function resolveSfw(): BinaryResolution { } } +/** + * Resolve path for Socket Patch binary. + * Checks SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH environment variable first. + */ +export function resolveSocketPatch(): BinaryResolution { + const localPath = ENV.SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH + if (localPath) { + return { type: 'local', path: localPath } + } + + return { + type: 'dlx', + details: { + name: '@socketsecurity/socket-patch', + version: getSocketPatchVersion(), + binaryName: 'socket-patch', + }, + } +} + /** * Resolve path for synp binary. * No local path override currently supported. diff --git a/packages/cli/src/utils/dlx/spawn.mts b/packages/cli/src/utils/dlx/spawn.mts index 893b85c9e..51e5079b0 100644 --- a/packages/cli/src/utils/dlx/spawn.mts +++ b/packages/cli/src/utils/dlx/spawn.mts @@ -6,6 +6,8 @@ * - spawnCdxgenDlx: Execute CycloneDX generator via dlx * - spawnCoanaDlx: Execute Coana CLI tool via dlx * - spawnDlx: Execute packages using Socket's dlx + * - spawnSfwDlx: Execute Socket Firewall via dlx + * - spawnSocketPatchDlx: Execute Socket Patch via dlx * - spawnSynpDlx: Execute Synp converter via dlx * * Implementation: @@ -15,13 +17,19 @@ */ import { dlxPackage } from '@socketsecurity/lib/dlx/package' - -import { resolveCdxgen, resolveCoana, resolveSfw } from './resolve-binary.mjs' +import { detectExecutableType } from '@socketsecurity/lib/dlx/detect' +import { spawn } from '@socketsecurity/lib/spawn' + +import { + resolveCdxgen, + resolveCoana, + resolveSocketPatch, + resolveSfw, +} from './resolve-binary.mjs' import { getDefaultOrgSlug } from '../../commands/ci/fetch-default-org-slug.mjs' import ENV from '../../constants/env.mts' import { getErrorCause, InputError } from '../error/errors.mts' import { getDefaultApiToken, getDefaultProxyUrl } from '../socket/sdk.mjs' -import { spawnNode } from '../spawn/spawn-node.mjs' import type { ShadowBinOptions, @@ -153,8 +161,7 @@ export async function spawnCoanaDlx( // Use local Coana CLI if available. if (resolution.type === 'local') { - const isBinary = - !resolution.path.endsWith('.js') && !resolution.path.endsWith('.mjs') + const detection = detectExecutableType(resolution.path) const finalEnv = { ...process.env, @@ -162,10 +169,11 @@ export async function spawnCoanaDlx( ...spawnEnv, } - const spawnArgs = isBinary ? args : [resolution.path, ...args] - const spawnCommand = isBinary ? resolution.path : 'node' + const spawnArgs = + detection.type === 'binary' ? args : [resolution.path, ...args] + const spawnCommand = + detection.type === 'binary' ? resolution.path : 'node' - const { spawn } = await import('@socketsecurity/lib/spawn') const spawnPromise = spawn(spawnCommand, spawnArgs, { ...dlxOptions, env: finalEnv, @@ -229,12 +237,17 @@ export async function spawnCdxgenDlx( // Use local cdxgen if available. if (resolution.type === 'local') { + const detection = detectExecutableType(resolution.path) const { env: spawnEnv, ...dlxOptions } = { __proto__: null, ...options, } as DlxOptions - const spawnPromise = spawnNode([resolution.path, ...args], { + const spawnArgs = + detection.type === 'binary' ? args : [resolution.path, ...args] + const spawnCommand = detection.type === 'binary' ? resolution.path : 'node' + + const spawnPromise = spawn(spawnCommand, spawnArgs, { ...dlxOptions, env: { ...process.env, @@ -290,12 +303,64 @@ export async function spawnSfwDlx( // Use local sfw if available. if (resolution.type === 'local') { + const detection = detectExecutableType(resolution.path) const { env: spawnEnv, ...dlxOptions } = { __proto__: null, ...options, } as DlxOptions - const spawnPromise = spawnNode([resolution.path, ...args], { + const spawnArgs = + detection.type === 'binary' ? args : [resolution.path, ...args] + const spawnCommand = detection.type === 'binary' ? resolution.path : 'node' + + const spawnPromise = spawn(spawnCommand, spawnArgs, { + ...dlxOptions, + env: { + ...process.env, + ...spawnEnv, + }, + stdio: spawnExtra?.['stdio'] || 'inherit', + }) + + return { + spawnPromise, + } + } + + // Use dlx version. + return await spawnDlx( + resolution.details, + args, + { force: false, ...options }, + spawnExtra, + ) +} + +/** + * Helper to spawn Socket Patch with dlx. + * If SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH environment variable is set, uses the local + * socket-patch binary at that path instead of downloading from npm. + */ +export async function spawnSocketPatchDlx( + args: string[] | readonly string[], + options?: DlxOptions | undefined, + spawnExtra?: SpawnExtra | undefined, +): Promise { + const resolution = resolveSocketPatch() + + // Use local socket-patch if available. + if (resolution.type === 'local') { + const detection = detectExecutableType(resolution.path) + const { env: spawnEnv, ...dlxOptions } = { + __proto__: null, + ...options, + } as DlxOptions + + const spawnArgs = + detection.type === 'binary' ? args : [resolution.path, ...args] + const spawnCommand = detection.type === 'binary' ? resolution.path : 'node' + + const spawnPromise = spawn(spawnCommand, spawnArgs, { ...dlxOptions, env: { ...process.env, diff --git a/packages/cli/src/utils/shadow/links.mts b/packages/cli/src/utils/shadow/links.mts index 904eb7553..3663416f1 100644 --- a/packages/cli/src/utils/shadow/links.mts +++ b/packages/cli/src/utils/shadow/links.mts @@ -26,9 +26,9 @@ import { fileURLToPath } from 'node:url' import cmdShim from 'cmd-shim' import { WIN32 } from '@socketsecurity/lib/constants/platform' +import { shouldSkipShadow } from '@socketsecurity/lib/shadow' import { getDistPath } from '../../constants/paths.mts' -import { shouldSkipShadow } from '../dlx/detection.mts' import { getNpmBinPath, getNpxBinPath, diff --git a/packages/cli/test/integration/binary/js.test.mts b/packages/cli/test/integration/binary/js.test.mts index d6695d803..7559d2c03 100644 --- a/packages/cli/test/integration/binary/js.test.mts +++ b/packages/cli/test/integration/binary/js.test.mts @@ -958,51 +958,48 @@ if (BINARY.enabled) { ) it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch info help', + 'should display patch scan help', async () => { if (!binaryExists) { return } - const result = await executeCliCommand(['patch', 'info', '--help'], { + const result = await executeCliCommand(['patch', 'scan', '--help'], { binPath: BINARY.path, isolateConfig: false, }) expect(result.code).toBe(0) - expect(result.stdout).toContain('info') + expect(result.stdout).toContain('scan') }, ) it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch discover help', + 'should display patch get help', async () => { if (!binaryExists) { return } - const result = await executeCliCommand( - ['patch', 'discover', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) + const result = await executeCliCommand(['patch', 'get', '--help'], { + binPath: BINARY.path, + isolateConfig: false, + }) expect(result.code).toBe(0) - expect(result.stdout).toContain('discover') + expect(result.stdout).toContain('get') }, ) it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch download help', + 'should display patch repair help', async () => { if (!binaryExists) { return } const result = await executeCliCommand( - ['patch', 'download', '--help'], + ['patch', 'repair', '--help'], { binPath: BINARY.path, isolateConfig: false, @@ -1010,19 +1007,19 @@ if (BINARY.enabled) { ) expect(result.code).toBe(0) - expect(result.stdout).toContain('download') + expect(result.stdout).toContain('repair') }, ) it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch cleanup help', + 'should display patch remove help', async () => { if (!binaryExists) { return } const result = await executeCliCommand( - ['patch', 'cleanup', '--help'], + ['patch', 'remove', '--help'], { binPath: BINARY.path, isolateConfig: false, @@ -1030,24 +1027,7 @@ if (BINARY.enabled) { ) expect(result.code).toBe(0) - expect(result.stdout).toContain('cleanup') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch rm help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['patch', 'rm', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('rm') + expect(result.stdout).toContain('remove') }, ) }) diff --git a/packages/cli/test/integration/binary/sea.test.mts b/packages/cli/test/integration/binary/sea.test.mts index 4a853047f..609bb35ec 100644 --- a/packages/cli/test/integration/binary/sea.test.mts +++ b/packages/cli/test/integration/binary/sea.test.mts @@ -952,51 +952,48 @@ if (BINARY.enabled) { ) it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch info help', + 'should display patch scan help', async () => { if (!binaryExists) { return } - const result = await executeCliCommand(['patch', 'info', '--help'], { + const result = await executeCliCommand(['patch', 'scan', '--help'], { binPath: BINARY.path, isolateConfig: false, }) expect(result.code).toBe(0) - expect(result.stdout).toContain('info') + expect(result.stdout).toContain('scan') }, ) it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch discover help', + 'should display patch get help', async () => { if (!binaryExists) { return } - const result = await executeCliCommand( - ['patch', 'discover', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) + const result = await executeCliCommand(['patch', 'get', '--help'], { + binPath: BINARY.path, + isolateConfig: false, + }) expect(result.code).toBe(0) - expect(result.stdout).toContain('discover') + expect(result.stdout).toContain('get') }, ) it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch download help', + 'should display patch repair help', async () => { if (!binaryExists) { return } const result = await executeCliCommand( - ['patch', 'download', '--help'], + ['patch', 'repair', '--help'], { binPath: BINARY.path, isolateConfig: false, @@ -1004,19 +1001,19 @@ if (BINARY.enabled) { ) expect(result.code).toBe(0) - expect(result.stdout).toContain('download') + expect(result.stdout).toContain('repair') }, ) it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch cleanup help', + 'should display patch remove help', async () => { if (!binaryExists) { return } const result = await executeCliCommand( - ['patch', 'cleanup', '--help'], + ['patch', 'remove', '--help'], { binPath: BINARY.path, isolateConfig: false, @@ -1024,24 +1021,7 @@ if (BINARY.enabled) { ) expect(result.code).toBe(0) - expect(result.stdout).toContain('cleanup') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch rm help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['patch', 'rm', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('rm') + expect(result.stdout).toContain('remove') }, ) }) diff --git a/packages/cli/test/integration/binary/smol.test.mts b/packages/cli/test/integration/binary/smol.test.mts index 370d786cf..20b86ff91 100644 --- a/packages/cli/test/integration/binary/smol.test.mts +++ b/packages/cli/test/integration/binary/smol.test.mts @@ -958,51 +958,48 @@ if (BINARY.enabled) { ) it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch info help', + 'should display patch scan help', async () => { if (!binaryExists) { return } - const result = await executeCliCommand(['patch', 'info', '--help'], { + const result = await executeCliCommand(['patch', 'scan', '--help'], { binPath: BINARY.path, isolateConfig: false, }) expect(result.code).toBe(0) - expect(result.stdout).toContain('info') + expect(result.stdout).toContain('scan') }, ) it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch discover help', + 'should display patch get help', async () => { if (!binaryExists) { return } - const result = await executeCliCommand( - ['patch', 'discover', '--help'], - { - binPath: BINARY.path, - isolateConfig: false, - }, - ) + const result = await executeCliCommand(['patch', 'get', '--help'], { + binPath: BINARY.path, + isolateConfig: false, + }) expect(result.code).toBe(0) - expect(result.stdout).toContain('discover') + expect(result.stdout).toContain('get') }, ) it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch download help', + 'should display patch repair help', async () => { if (!binaryExists) { return } const result = await executeCliCommand( - ['patch', 'download', '--help'], + ['patch', 'repair', '--help'], { binPath: BINARY.path, isolateConfig: false, @@ -1010,19 +1007,19 @@ if (BINARY.enabled) { ) expect(result.code).toBe(0) - expect(result.stdout).toContain('download') + expect(result.stdout).toContain('repair') }, ) it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch cleanup help', + 'should display patch remove help', async () => { if (!binaryExists) { return } const result = await executeCliCommand( - ['patch', 'cleanup', '--help'], + ['patch', 'remove', '--help'], { binPath: BINARY.path, isolateConfig: false, @@ -1030,24 +1027,7 @@ if (BINARY.enabled) { ) expect(result.code).toBe(0) - expect(result.stdout).toContain('cleanup') - }, - ) - - it.skipIf(!ENV.RUN_INTEGRATION_TESTS)( - 'should display patch rm help', - async () => { - if (!binaryExists) { - return - } - - const result = await executeCliCommand(['patch', 'rm', '--help'], { - binPath: BINARY.path, - isolateConfig: false, - }) - - expect(result.code).toBe(0) - expect(result.stdout).toContain('rm') + expect(result.stdout).toContain('remove') }, ) }) diff --git a/packages/cli/test/integration/cli/cmd-patch-cleanup.test.mts b/packages/cli/test/integration/cli/cmd-patch-cleanup.test.mts deleted file mode 100644 index 7f5fd3c54..000000000 --- a/packages/cli/test/integration/cli/cmd-patch-cleanup.test.mts +++ /dev/null @@ -1,176 +0,0 @@ -/** - * Integration tests for `socket patch cleanup` command. - * - * Tests removing unused or obsolete patches from the project. - * - * Test Coverage: - * - Help text display and usage examples - * - Dry-run behavior validation - * - Unused patch detection - * - Patch removal - * - * Related Files: - * - src/commands/patch/cmd-patch-cleanup.mts - Command definition - * - src/commands/patch/handle-patch-cleanup.mts - Cleanup logic - */ - -import { promises as fs } from 'node:fs' -import path from 'node:path' - -import { afterEach, describe, expect } from 'vitest' - -import { FLAG_CONFIG, FLAG_HELP } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli, testPath } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -const fixtureBaseDir = path.join(testPath, 'fixtures/commands/patch') -const pnpmFixtureDir = path.join(fixtureBaseDir, 'pnpm') - -async function cleanupNodeModules() { - // Clean up node_modules from all package manager directories. - Promise.allSettled([ - fs.rm(path.join(pnpmFixtureDir, 'node_modules'), { - force: true, - recursive: true, - }), - fs.rm(path.join(fixtureBaseDir, 'npm/node_modules'), { - force: true, - recursive: true, - }), - fs.rm(path.join(fixtureBaseDir, 'yarn/node_modules'), { - force: true, - recursive: true, - }), - ]) -} - -describe('socket patch cleanup', async () => { - afterEach(async () => { - await cleanupNodeModules() - }) - - cmdit( - ['patch', 'cleanup', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('Clean up orphaned patch backups') - expect(stderr).toContain('`socket patch cleanup`') - expect(code, 'explicit help should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'cleanup', - path.join(fixtureBaseDir, 'nonexistent'), - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show error when no .socket directory found', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('No .socket directory found') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'cleanup', - pnpmFixtureDir, - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle cleanup with no backups', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect( - stdout.includes('No orphaned patch backups found') || - stdout.includes('Cleaned'), - ).toBe(true) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'cleanup', - pnpmFixtureDir, - '--all', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should handle cleanup --all with no backups', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect( - stdout.includes('No patch backups found') || stdout.includes('Cleaned'), - ).toBe(true) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'cleanup', - pnpmFixtureDir, - '--json', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should output cleanup result in JSON format', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - const json = JSON.parse(stdout) - expect(json.ok).toBe(true) - expect(json.data?.cleaned).toBeDefined() - expect(Array.isArray(json.data.cleaned)).toBe(true) - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'cleanup', - pnpmFixtureDir, - '--markdown', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should output cleanup result in markdown format', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('## Patch Backups Cleaned') - expect(stdout).toContain('**Count**') - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'cleanup', - pnpmFixtureDir, - '--json', - '--markdown', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should fail when both json and markdown flags are used', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('json and markdown flags cannot be both set') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-patch-discover.test.mts b/packages/cli/test/integration/cli/cmd-patch-discover.test.mts deleted file mode 100644 index 8060a3054..000000000 --- a/packages/cli/test/integration/cli/cmd-patch-discover.test.mts +++ /dev/null @@ -1,337 +0,0 @@ -/** - * Integration tests for `socket patch discover` command. - * - * Tests discovering available patches for vulnerable dependencies. - * - * Test Coverage: - * - Help text display and usage examples - * - Dry-run behavior validation - * - Patch discovery from Socket registry - * - Available patch listing - * - * Related Files: - * - src/commands/patch/cmd-patch-discover.mts - Command definition - * - src/commands/patch/handle-patch-discover.mts - Discovery logic - */ - -import { promises as fs } from 'node:fs' -import os from 'node:os' -import path from 'node:path' - -import { afterEach, describe, expect } from 'vitest' - -import { FLAG_CONFIG, FLAG_HELP } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli, testPath } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -const fixtureBaseDir = path.join(testPath, 'fixtures/commands/patch') -const pnpmFixtureDir = path.join(fixtureBaseDir, 'pnpm') - -async function cleanupNodeModules() { - // Clean up node_modules from all package manager directories. - Promise.allSettled([ - fs.rm(path.join(pnpmFixtureDir, 'node_modules'), { - force: true, - recursive: true, - }), - fs.rm(path.join(fixtureBaseDir, 'npm/node_modules'), { - force: true, - recursive: true, - }), - fs.rm(path.join(fixtureBaseDir, 'yarn/node_modules'), { - force: true, - recursive: true, - }), - ]) -} - -describe('socket patch discover', async () => { - afterEach(async () => { - await cleanupNodeModules() - }) - - cmdit( - ['patch', 'discover', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('Discover available patches') - expect(stderr).toContain('`socket patch discover`') - expect(code, 'explicit help should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['patch', 'discover', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should show error when no node_modules directory found', - async cmd => { - // Create a temporary directory without node_modules. - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-test-')) - try { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: tmpDir, - }) - const output = stdout + stderr - expect(output).toContain('No node_modules directory found') - expect(code, 'should exit with non-zero code').not.toBe(0) - } finally { - // Clean up temporary directory. - await fs.rm(tmpDir, { force: true, recursive: true }) - } - }, - ) - - cmdit( - [ - 'patch', - 'discover', - '--scan', - 'nonexistent-scan-id', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should accept --scan flag with scan ID', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - // Should not complain about node_modules when using --scan. - expect(output).not.toContain('No node_modules directory found') - // Will fail with API error since scan doesn't exist, but that's expected. - expect(code, 'should exit with non-zero code for invalid scan').not.toBe( - 0, - ) - }, - ) - - cmdit( - [ - 'patch', - 'discover', - '-s', - 'test-scan-id', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should support -s short flag for --scan', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - // Should accept -s as alias for --scan. - expect(output).not.toContain('Unknown flag') - expect(code, 'should accept -s flag').toBeDefined() - }, - ) - - cmdit( - [ - 'patch', - 'discover', - '--interactive', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should accept --interactive flag', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - // Should not complain about unknown flag. - expect(output).not.toContain('Unknown flag') - expect(code, 'should accept --interactive flag').toBeDefined() - }, - ) - - cmdit( - ['patch', 'discover', '-i', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should support -i short flag for --interactive', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - // Should accept -i as alias for --interactive. - expect(output).not.toContain('Unknown flag') - expect(code, 'should accept -i flag').toBeDefined() - }, - ) - - cmdit( - [ - 'patch', - 'discover', - '--json', - '--markdown', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should reject both --json and --markdown flags', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - expect(output).toContain('json and markdown flags cannot be both set') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - ['patch', 'discover', '--json', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should support --json output format', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - // Should accept --json flag without error. - const output = stdout + stderr - expect(output).not.toContain('Unknown flag') - expect(code, 'should accept --json flag').toBeDefined() - }, - ) - - cmdit( - [ - 'patch', - 'discover', - '--markdown', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should support --markdown output format', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - // Should accept --markdown flag without error. - const output = stdout + stderr - expect(output).not.toContain('Unknown flag') - expect(code, 'should accept --markdown flag').toBeDefined() - }, - ) - - cmdit( - [ - 'patch', - 'discover', - './some/path', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should accept custom directory path', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - // Should accept path argument (will fail if path doesn't exist, but syntax is valid). - expect(output).not.toContain('Unexpected argument') - expect(code, 'should accept path argument').toBeDefined() - }, - ) - - cmdit( - ['patch', 'discover', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should not show double checkmark in output', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - - // Should not have double checkmark (✔ ✓ or ✓ ✔). - expect(output).not.toMatch(/✔\s*✓/) - expect(output).not.toMatch(/✓\s*✔/) - - // Should still have checkmarks for success messages. - if (code === 0 || output.includes('Found')) { - expect(output).toMatch(/✔/) - } - }, - ) - - cmdit( - ['patch', 'discover', '--json', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should output valid JSON with patches array', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - - // If command succeeds, should have valid JSON. - if (code === 0) { - expect(() => JSON.parse(stdout)).not.toThrow() - const json = JSON.parse(stdout) - expect(json).toHaveProperty('patches') - expect(Array.isArray(json.patches)).toBe(true) - } - }, - ) - - cmdit( - ['patch', 'discover', '--json', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should include vulnerability information in patches', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - - // If patches are found, they should have vulnerability info. - if (code === 0) { - const json = JSON.parse(stdout) - if (json.patches && json.patches.length > 0) { - const patch = json.patches[0] - expect(patch).toHaveProperty('purl') - expect(patch).toHaveProperty('uuid') - expect(patch).toHaveProperty('tier') - expect(patch).toHaveProperty('vulnerabilities') - expect(Array.isArray(patch.vulnerabilities)).toBe(true) - - // Each vulnerability should have cve and severity. - if (patch.vulnerabilities.length > 0) { - const vuln = patch.vulnerabilities[0] - // cve and severity are optional but should be present if vulnerability exists. - expect(vuln).toBeDefined() - } - } - } - }, - ) - - cmdit( - ['patch', 'discover', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should use singular form for 1 patch', - async cmd => { - const { stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - - // If exactly 1 patch found, should say "patch" not "patches". - if (stdout.includes('Found 1')) { - expect(stdout).toContain('1 available patch') - expect(stdout).not.toContain('1 available patches') - } - }, - ) - - cmdit( - ['patch', 'discover', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should use plural form for multiple patches', - async cmd => { - const { stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - - // If more than 1 patch found, should say "patches" not "patch". - const multiMatch = stdout.match(/Found (\d+)/) - if (multiMatch && Number.parseInt(multiMatch[1], 10) > 1) { - expect(stdout).toContain('available patches') - expect(stdout).not.toMatch(/\d+ available patch[^e]/) - } - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-patch-download.test.mts b/packages/cli/test/integration/cli/cmd-patch-download.test.mts deleted file mode 100644 index eee3a11d5..000000000 --- a/packages/cli/test/integration/cli/cmd-patch-download.test.mts +++ /dev/null @@ -1,259 +0,0 @@ -/** - * Integration tests for `socket patch download` command. - * - * Tests downloading patch files from the Socket patch registry. - * - * Test Coverage: - * - Help text display and usage examples - * - Dry-run behavior validation - * - Patch file download - * - Patch verification - * - * Related Files: - * - src/commands/patch/cmd-patch-download.mts - Command definition - * - src/commands/patch/handle-patch-download.mts - Download logic - */ - -import { promises as fs } from 'node:fs' -import path from 'node:path' - -import { afterEach, describe, expect } from 'vitest' - -import { FLAG_CONFIG, FLAG_HELP } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli, testPath } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -const fixtureBaseDir = path.join(testPath, 'fixtures/commands/patch') -const pnpmFixtureDir = path.join(fixtureBaseDir, 'pnpm') - -async function cleanupNodeModules() { - // Clean up node_modules from all package manager directories. - Promise.allSettled([ - fs.rm(path.join(pnpmFixtureDir, 'node_modules'), { - force: true, - recursive: true, - }), - fs.rm(path.join(fixtureBaseDir, 'npm/node_modules'), { - force: true, - recursive: true, - }), - fs.rm(path.join(fixtureBaseDir, 'yarn/node_modules'), { - force: true, - recursive: true, - }), - ]) -} - -describe('socket patch download', async () => { - afterEach(async () => { - await cleanupNodeModules() - }) - - cmdit( - ['patch', 'download', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('Download patches') - expect(stderr).toContain('`socket patch download`') - expect(code, 'explicit help should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['patch', 'download', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should show error when no UUIDs or --scan provided', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - expect(output).toContain('Must provide patch UUIDs or use --scan flag') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'download', - 'abc123-def456-789', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should accept UUID argument', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - // Should not complain about missing UUIDs. - expect(output).not.toContain('Must provide patch UUIDs') - expect(code, 'should accept UUID argument').toBeDefined() - }, - ) - - cmdit( - [ - 'patch', - 'download', - 'abc123-def456-789', - 'xyz789-abc123-456', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should accept multiple UUID arguments', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - // Should accept multiple UUIDs. - expect(output).not.toContain('Must provide patch UUIDs') - expect(code, 'should accept multiple UUIDs').toBeDefined() - }, - ) - - cmdit( - [ - 'patch', - 'download', - '--scan', - 'test-scan-id', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should accept --scan flag', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - // Should not complain about missing UUIDs when using --scan. - expect(output).not.toContain('Must provide patch UUIDs') - expect(code, 'should accept --scan flag').toBeDefined() - }, - ) - - cmdit( - [ - 'patch', - 'download', - '-s', - 'test-scan-id', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should support -s short flag for --scan', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - // Should accept -s as alias for --scan. - expect(output).not.toContain('Unknown flag') - expect(code, 'should accept -s flag').toBeDefined() - }, - ) - - cmdit( - [ - 'patch', - 'download', - path.join(fixtureBaseDir, 'nonexistent'), - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show error when no .socket directory found', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('No .socket directory found') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'download', - 'abc123-def456-789', - '--json', - '--markdown', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should reject both --json and --markdown flags', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - expect(output).toContain('json and markdown flags cannot be both set') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'download', - 'abc123-def456-789', - '--json', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should support --json output format', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - // Should accept --json flag without error. - expect(output).not.toContain('Unknown flag') - expect(code, 'should accept --json flag').toBeDefined() - }, - ) - - cmdit( - [ - 'patch', - 'download', - 'abc123-def456-789', - '--markdown', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should support --markdown output format', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - // Should accept --markdown flag without error. - expect(output).not.toContain('Unknown flag') - expect(code, 'should accept --markdown flag').toBeDefined() - }, - ) - - cmdit( - [ - 'patch', - 'download', - pnpmFixtureDir, - 'abc123-def456-789', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should accept custom directory path as first argument', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - // Should accept path as first argument followed by UUIDs. - expect(output).not.toContain('Unexpected argument') - expect(code, 'should accept path and UUID arguments').toBeDefined() - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-patch-info.test.mts b/packages/cli/test/integration/cli/cmd-patch-info.test.mts deleted file mode 100644 index 09089ed37..000000000 --- a/packages/cli/test/integration/cli/cmd-patch-info.test.mts +++ /dev/null @@ -1,199 +0,0 @@ -/** - * Integration tests for `socket patch info` command. - * - * Tests displaying detailed patch information. - * - * Test Coverage: - * - Help text display and usage examples - * - Dry-run behavior validation - * - Patch details display - * - CVE information - * - * Related Files: - * - src/commands/patch/cmd-patch-info.mts - Command definition - * - src/commands/patch/handle-patch-info.mts - Info display logic - */ - -import { promises as fs } from 'node:fs' -import path from 'node:path' - -import { afterEach, describe, expect } from 'vitest' - -import { FLAG_CONFIG, FLAG_HELP } from '../../../src/constants/cli.mts' -import { getBinCliPath } from '../../../src/constants/paths.mts' -import { cmdit, spawnSocketCli, testPath } from '../../utils.mts' - -const binCliPath = getBinCliPath() - -const fixtureBaseDir = path.join(testPath, 'fixtures/commands/patch') -const pnpmFixtureDir = path.join(fixtureBaseDir, 'pnpm') - -async function cleanupNodeModules() { - // Clean up node_modules from all package manager directories. - Promise.allSettled([ - fs.rm(path.join(pnpmFixtureDir, 'node_modules'), { - force: true, - recursive: true, - }), - fs.rm(path.join(fixtureBaseDir, 'npm/node_modules'), { - force: true, - recursive: true, - }), - fs.rm(path.join(fixtureBaseDir, 'yarn/node_modules'), { - force: true, - recursive: true, - }), - ]) -} - -describe('socket patch info', async () => { - afterEach(async () => { - await cleanupNodeModules() - }) - - cmdit( - ['patch', 'info', FLAG_HELP, FLAG_CONFIG, '{}'], - `should support ${FLAG_HELP}`, - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain( - 'Show detailed information about a specific patch', - ) - expect(stderr).toContain('`socket patch info`') - expect(code, 'explicit help should exit with code 0').toBe(0) - }, - ) - - cmdit( - ['patch', 'info', FLAG_CONFIG, '{"apiToken":"fake-token"}'], - 'should show error when PURL is not provided', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd, { - cwd: pnpmFixtureDir, - }) - const output = stdout + stderr - expect(output).toContain('PURL is required') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'info', - 'pkg:npm/on-headers@1.0.2', - path.join(fixtureBaseDir, 'nonexistent'), - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show error when no .socket directory found', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('No .socket directory found') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'info', - 'pkg:npm/nonexistent@1.0.0', - pnpmFixtureDir, - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show error when patch not found', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('Patch not found') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'info', - 'pkg:npm/on-headers@1.0.2', - pnpmFixtureDir, - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should show patch information', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('pkg:npm/on-headers@1.0.2') - expect(stdout).toContain('index.js') - expect(stdout).toContain('GHSA-76c9-3jph-rj3q') - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'info', - 'pkg:npm/on-headers@1.0.2', - pnpmFixtureDir, - '--json', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should output patch info in JSON format', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - const json = JSON.parse(stdout) - expect(json.ok).toBe(true) - expect(json.data?.purl).toBe('pkg:npm/on-headers@1.0.2') - expect(json.data?.files).toBeDefined() - expect(json.data.files['index.js']).toBeDefined() - expect(json.data?.vulnerabilities).toBeDefined() - expect(json.data.vulnerabilities['GHSA-76c9-3jph-rj3q']).toBeDefined() - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'info', - 'pkg:npm/on-headers@1.0.2', - pnpmFixtureDir, - '--markdown', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should output patch info in markdown format', - async cmd => { - const { code, stdout } = await spawnSocketCli(binCliPath, cmd) - expect(stdout).toContain('## Patch Information') - expect(stdout).toContain('**PURL**: pkg:npm/on-headers@1.0.2') - expect(stdout).toContain('### Files') - expect(stdout).toContain('### Vulnerabilities') - expect(code, 'should exit with code 0').toBe(0) - }, - ) - - cmdit( - [ - 'patch', - 'info', - 'pkg:npm/on-headers@1.0.2', - pnpmFixtureDir, - '--json', - '--markdown', - FLAG_CONFIG, - '{"apiToken":"fake-token"}', - ], - 'should fail when both json and markdown flags are used', - async cmd => { - const { code, stderr, stdout } = await spawnSocketCli(binCliPath, cmd) - const output = stdout + stderr - expect(output).toContain('json and markdown flags cannot be both set') - expect(code, 'should exit with non-zero code').not.toBe(0) - }, - ) -}) diff --git a/packages/cli/test/integration/cli/cmd-patch-rm.test.mts b/packages/cli/test/integration/cli/cmd-patch-remove.test.mts similarity index 100% rename from packages/cli/test/integration/cli/cmd-patch-rm.test.mts rename to packages/cli/test/integration/cli/cmd-patch-remove.test.mts diff --git a/packages/cli/test/unit/commands/patch/handle-patch-apply.test.mts b/packages/cli/test/unit/commands/patch/handle-patch-apply.test.mts deleted file mode 100644 index 5f7195c07..000000000 --- a/packages/cli/test/unit/commands/patch/handle-patch-apply.test.mts +++ /dev/null @@ -1,393 +0,0 @@ -/** - * Unit Tests: Socket Registry Patch Application Handler - * - * Purpose: - * Tests the command handler that applies Socket Registry patches to installed packages in - * node_modules. Validates package resolution via PURL, patch file discovery and copying, - * dry-run mode, and error handling for various failure scenarios. - * - * Test Coverage: - * - Successful patch application with file copying - * - Package resolution from PURL specifications - * - Patch file discovery via glob patterns - * - Dry-run mode (validation without file modifications) - * - Package not found error handling - * - Missing patch file scenarios - * - File system operation error handling - * - Multiple package patching in single invocation - * - * Testing Approach: - * Mocks fs operations, fast-glob, package.json reading, PURL parsing, and output formatting - * to test patch application logic without actual file system modifications. Tests verify - * proper error handling and result reporting. - * - * Related Files: - * - src/commands/patch/handle-patch-apply.mts - Patch application handler - * - src/commands/patch/output-patch-result.mts - Result output formatter - * - src/utils/purl/parse.mts - PURL parsing utilities - */ - -import { describe, expect, it, vi } from 'vitest' - -import { handlePatchApply } from '../../../../src/commands/patch/handle-patch-apply.mts' - -import type { PackageURL } from '@socketregistry/packageurl-js' - -// Mock the dependencies. -vi.mock('node:fs', () => ({ - existsSync: vi.fn(), - promises: { - copyFile: vi.fn(), - readFile: vi.fn(), - }, -})) - -vi.mock('fast-glob', () => ({ - default: { - glob: vi.fn(), - }, -})) - -vi.mock('@socketsecurity/lib/fs', () => ({ - readDirNames: vi.fn(), -})) - -const mockLogger = vi.hoisted(() => ({ - fail: vi.fn(), - log: vi.fn(), - info: vi.fn(), - success: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - group: vi.fn(), - groupEnd: vi.fn(), -})) - -vi.mock('@socketsecurity/lib/logger', () => ({ - getDefaultLogger: () => mockLogger, - logger: mockLogger, -})) - -vi.mock('@socketsecurity/lib/packages', () => ({ - readPackageJson: vi.fn(), -})) - -vi.mock('../../../../src/commands/patch/output-patch-result.mts', () => ({ - outputPatchResult: vi.fn(), -})) - -vi.mock('../../../../src/utils/fs/find-up.mts', () => ({ - findUp: vi.fn(), -})) - -vi.mock('../../../../src/utils/purl/parse.mts', () => ({ - getPurlObject: vi.fn(), - normalizePurl: vi.fn(purl => - purl.startsWith('pkg:') ? purl : `pkg:${purl}`, - ), -})) - -describe('handlePatch', () => { - it('handles successful patch application', async () => { - const { existsSync, promises: fs } = await import('node:fs') - const fastGlob = await import('fast-glob') - const { readDirNames } = await import('@socketsecurity/lib/fs') - const { outputPatchResult } = await import( - '../../../../src/commands/patch/output-patch-result.mts' - ) - const { findUp } = await import('../../../../src/utils/fs/find-up.mts') - const mockExistsSync = vi.mocked(existsSync) - const mockReadFile = vi.mocked(fs.readFile) - const mockOutput = vi.mocked(outputPatchResult) - const mockFindUp = vi.mocked(findUp) - const mockGlob = vi.mocked(fastGlob.default.glob) - const mockReadDirNames = vi.mocked(readDirNames) - - mockExistsSync.mockReturnValue(true) - mockFindUp.mockResolvedValue('/project/node_modules') - mockGlob.mockResolvedValue(['/project/node_modules']) - mockReadDirNames.mockResolvedValue([]) - mockReadFile.mockResolvedValue( - JSON.stringify({ - patches: { - 'npm/lodash@4.17.21': { - exportedAt: '2025-01-01T00:00:00.000Z', - files: { - 'index.js': { - beforeHash: 'abc123', - afterHash: 'def456', - }, - }, - vulnerabilities: { - 'GHSA-xxxx-yyyy-zzzz': { - cves: ['CVE-2025-0001'], - summary: 'Test vulnerability', - severity: 'high', - description: 'Test description', - patchExplanation: 'Test patch explanation', - }, - }, - }, - }, - }), - ) - - const mockSpinner = { - isSpinning: false, - start: vi.fn(), - stop: vi.fn(), - } - - await handlePatchApply({ - cwd: '/project', - dryRun: false, - outputKind: 'json', - purlObjs: [], - spinner: mockSpinner as any, - }) - - expect(mockReadFile).toHaveBeenCalledWith( - '/project/.socket/manifest.json', - 'utf8', - ) - expect(mockOutput).toHaveBeenCalledWith( - expect.objectContaining({ ok: true }), - 'json', - ) - }) - - it('handles dry run mode', async () => { - const { promises: fs } = await import('node:fs') - const { outputPatchResult } = await import( - '../../../../src/commands/patch/output-patch-result.mts' - ) - const mockReadFile = vi.mocked(fs.readFile) - const mockOutput = vi.mocked(outputPatchResult) - - mockReadFile.mockResolvedValue( - JSON.stringify({ - patches: {}, - }), - ) - - const mockSpinner = { - isSpinning: false, - start: vi.fn(), - stop: vi.fn(), - } - - await handlePatchApply({ - cwd: '/project', - dryRun: true, - outputKind: 'text', - purlObjs: [], - spinner: mockSpinner as any, - }) - - expect(mockOutput).toHaveBeenCalledWith( - expect.objectContaining({ - ok: true, - data: { patched: [] }, - }), - 'text', - ) - }) - - it('handles invalid JSON in manifest', async () => { - const { promises: fs } = await import('node:fs') - const { outputPatchResult } = await import( - '../../../../src/commands/patch/output-patch-result.mts' - ) - const mockReadFile = vi.mocked(fs.readFile) - const mockOutput = vi.mocked(outputPatchResult) - - mockReadFile.mockResolvedValue('invalid json') - - const mockSpinner = { - isSpinning: false, - start: vi.fn(), - stop: vi.fn(), - } - - await handlePatchApply({ - cwd: '/project', - dryRun: false, - outputKind: 'json', - purlObjs: [], - spinner: mockSpinner as any, - }) - - expect(mockOutput).toHaveBeenCalledWith( - expect.objectContaining({ - ok: false, - message: 'Invalid JSON in manifest.json', - }), - 'json', - ) - }) - - it('filters patches by specified PURLs', async () => { - const { promises: fs } = await import('node:fs') - const { getPurlObject } = await import( - '../../../../src/utils/purl/parse.mts' - ) - const mockReadFile = vi.mocked(fs.readFile) - const mockGetPurlObject = vi.mocked(getPurlObject) - - mockReadFile.mockResolvedValue( - JSON.stringify({ - patches: { - 'npm/lodash@4.17.21': { - exportedAt: '2025-01-01T00:00:00.000Z', - files: {}, - vulnerabilities: {}, - }, - 'npm/express@4.18.2': { - exportedAt: '2025-01-01T00:00:00.000Z', - files: {}, - vulnerabilities: {}, - }, - }, - }), - ) - - mockGetPurlObject.mockReturnValue({ - type: 'npm', - name: 'lodash', - version: '4.17.21', - } as PackageURL) - - const mockSpinner = { - isSpinning: false, - start: vi.fn(), - stop: vi.fn(), - } - - const purlObjs = [ - { - type: 'npm', - name: 'lodash', - version: '4.17.21', - toString: () => 'pkg:npm/lodash@4.17.21', - } as PackageURL, - ] - - await handlePatchApply({ - cwd: '/project', - dryRun: false, - outputKind: 'json', - purlObjs, - spinner: mockSpinner as any, - }) - - expect(mockSpinner.start).toHaveBeenCalledWith( - expect.stringContaining('lodash'), - ) - }) - - it('handles schema validation errors', async () => { - const { promises: fs } = await import('node:fs') - const { outputPatchResult } = await import( - '../../../../src/commands/patch/output-patch-result.mts' - ) - const mockReadFile = vi.mocked(fs.readFile) - const mockOutput = vi.mocked(outputPatchResult) - - mockReadFile.mockResolvedValue( - JSON.stringify({ - patches: { - 'invalid-purl': { - // Missing required fields. - }, - }, - }), - ) - - const mockSpinner = { - isSpinning: false, - start: vi.fn(), - stop: vi.fn(), - } - - await handlePatchApply({ - cwd: '/project', - dryRun: false, - outputKind: 'json', - purlObjs: [], - spinner: mockSpinner as any, - }) - - expect(mockOutput).toHaveBeenCalledWith( - expect.objectContaining({ - ok: false, - message: 'Schema validation failed', - }), - 'json', - ) - }) - - it('handles markdown output format', async () => { - const { promises: fs } = await import('node:fs') - const { outputPatchResult } = await import( - '../../../../src/commands/patch/output-patch-result.mts' - ) - const mockReadFile = vi.mocked(fs.readFile) - const mockOutput = vi.mocked(outputPatchResult) - - mockReadFile.mockResolvedValue( - JSON.stringify({ - patches: {}, - }), - ) - - const mockSpinner = { - isSpinning: false, - start: vi.fn(), - stop: vi.fn(), - } - - await handlePatchApply({ - cwd: '/project', - dryRun: false, - outputKind: 'markdown', - purlObjs: [], - spinner: mockSpinner as any, - }) - - expect(mockOutput).toHaveBeenCalledWith(expect.any(Object), 'markdown') - }) - - it('handles file read errors', async () => { - const { promises: fs } = await import('node:fs') - const { outputPatchResult } = await import( - '../../../../src/commands/patch/output-patch-result.mts' - ) - const mockReadFile = vi.mocked(fs.readFile) - const mockOutput = vi.mocked(outputPatchResult) - - mockReadFile.mockRejectedValue(new Error('ENOENT')) - - const mockSpinner = { - isSpinning: false, - start: vi.fn(), - stop: vi.fn(), - } - - await handlePatchApply({ - cwd: '/project', - dryRun: false, - outputKind: 'json', - purlObjs: [], - spinner: mockSpinner as any, - }) - - expect(mockSpinner.stop).toHaveBeenCalled() - expect(mockOutput).toHaveBeenCalledWith( - expect.objectContaining({ - ok: false, - message: 'Failed to apply patches', - }), - 'json', - ) - }) -}) diff --git a/packages/cli/test/unit/utils/dlx/binary.test.mts b/packages/cli/test/unit/utils/dlx/binary.test.mts index 4a5e20d65..a2b96c98d 100644 --- a/packages/cli/test/unit/utils/dlx/binary.test.mts +++ b/packages/cli/test/unit/utils/dlx/binary.test.mts @@ -14,17 +14,11 @@ import { existsSync, promises as fs } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, it } from 'vitest' +import { cleanDlxCache, getDlxCachePath, listDlxCache } from '@socketsecurity/lib/dlx/binary' import { normalizePath } from '@socketsecurity/lib/paths/normalize' - -import { - cleanDlxCache, - getDlxCachePath, - getSocketHomePath, - listDlxCache, -} from '../../../../src/utils/dlx/binary.mts' -import { InputError } from '../../../../src/utils/error/errors.mts' +import { getSocketHomePath } from '@socketsecurity/lib/paths/socket' describe('binary', () => { describe('getSocketHomePath', () => { @@ -33,17 +27,6 @@ describe('binary', () => { const expected = normalizePath(path.join(os.homedir(), '.socket')) expect(result).toBe(expected) }) - - it('should throw error when home directory cannot be determined', () => { - const originalHomedir = os.homedir - os.homedir = vi.fn(() => '') - - expect(() => getSocketHomePath()).toThrow( - new InputError('Unable to determine home directory'), - ) - - os.homedir = originalHomedir - }) }) describe('getDlxCachePath', () => { diff --git a/packages/cli/test/unit/utils/dlx/detection.test.mts b/packages/cli/test/unit/utils/dlx/detection.test.mts deleted file mode 100644 index 2e78ff461..000000000 --- a/packages/cli/test/unit/utils/dlx/detection.test.mts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * Unit tests for dlx package detection. - * - * Purpose: - * Tests package detection logic for dlx commands. Validates package name extraction and resolution. - * - * Test Coverage: - * - Package name extraction from URLs - * - Version detection - * - Scope handling (@org/package) - * - Package registry detection - * - Invalid input handling - * - * Testing Approach: - * Tests parsing and detection logic for dlx workflows. - * - * Related Files: - * - utils/dlx/detection.mts (implementation) - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { - isRunningInTemporaryExecutor, - shouldSkipShadow, -} from '../../../../src/utils/dlx/detection.mts' - -import type { default as ENV } from '../../../../src/constants/env.mts' - -// Mock the dependencies. -vi.mock('@socketsecurity/lib/paths/normalize', () => ({ - normalizePath: vi.fn((p: string) => p.replace(/\\/g, '/')), -})) - -// Mock the ENV module. -vi.mock('../../../../src/constants/env.mts', () => ({ - default: { - npm_config_user_agent: undefined, - npm_config_cache: undefined, - }, -})) - -describe('detection', () => { - let mockEnv: typeof ENV - - beforeEach(async () => { - vi.clearAllMocks() - mockEnv = (await import('../../../../src/constants/env.mts')).default - // Reset the mock values. - mockEnv.npm_config_user_agent = undefined - mockEnv.npm_config_cache = undefined - }) - - afterEach(() => { - vi.resetModules() - }) - - describe('isRunningInTemporaryExecutor', () => { - it('returns false when not in temporary executor', async () => { - const result = isRunningInTemporaryExecutor() - expect(result).toBe(false) - }) - - it('detects npm exec in user agent', () => { - mockEnv.npm_config_user_agent = 'npm/8.0.0 node/v16.0.0 darwin exec' - - const result = isRunningInTemporaryExecutor() - expect(result).toBe(true) - }) - - it('detects npx in user agent', () => { - mockEnv.npm_config_user_agent = 'npm/8.0.0 node/v16.0.0 darwin npx' - - const result = isRunningInTemporaryExecutor() - expect(result).toBe(true) - }) - - it('detects pnpm dlx in user agent', () => { - mockEnv.npm_config_user_agent = 'pnpm/7.0.0 node/v16.0.0 darwin dlx' - - const result = isRunningInTemporaryExecutor() - expect(result).toBe(true) - }) - - // Note: Tests that depend on __dirname cannot be easily tested without complex mocking. - // The function uses __dirname which is a module-level global. - // We test the path pattern logic through shouldSkipShadow which accepts a cwd parameter. - - it('returns false for non-temporary executor environments', () => { - const result = isRunningInTemporaryExecutor() - // Default environment should not be detected as temporary. - expect(result).toBe(false) - }) - }) - - describe('shouldSkipShadow', () => { - it('skips on Windows when binary path exists', () => { - const result = shouldSkipShadow('C:\\npm\\npm.cmd', { win32: true }) - expect(result).toBe(true) - }) - - it('does not skip on Windows when no binary path', () => { - const result = shouldSkipShadow('', { win32: true }) - expect(result).toBe(false) - }) - - it('does not skip on Unix even with binary path', () => { - const result = shouldSkipShadow('/usr/local/bin/npm', { win32: false }) - expect(result).toBe(false) - }) - - it('skips when npm exec in user agent', () => { - mockEnv.npm_config_user_agent = 'npm/8.0.0 node/v16.0.0 darwin exec' - - const result = shouldSkipShadow('/usr/local/bin/npm', {}) - expect(result).toBe(true) - }) - - it('skips when npx in user agent', () => { - mockEnv.npm_config_user_agent = 'npm/8.0.0 node/v16.0.0 darwin npx' - - const result = shouldSkipShadow('/usr/local/bin/npm', {}) - expect(result).toBe(true) - }) - - it('skips when dlx in user agent', () => { - mockEnv.npm_config_user_agent = 'pnpm/7.0.0 node/v16.0.0 darwin dlx' - - const result = shouldSkipShadow('/usr/local/bin/pnpm', {}) - expect(result).toBe(true) - }) - - it('skips when cwd is in npm cache', () => { - mockEnv.npm_config_cache = '/Users/test/.npm' - - const result = shouldSkipShadow('/usr/local/bin/npm', { - cwd: '/Users/test/.npm/_npx/12345/node_modules/.bin', - }) - expect(result).toBe(true) - }) - - it('skips when cwd contains _npx', () => { - const result = shouldSkipShadow('/usr/local/bin/npm', { - cwd: '/var/folders/abc/_npx/12345/node_modules/.bin', - }) - expect(result).toBe(true) - }) - - it('skips when cwd contains .pnpm-store', () => { - const result = shouldSkipShadow('/usr/local/bin/pnpm', { - cwd: '/home/user/.pnpm-store/v3/tmp/dlx-12345', - }) - expect(result).toBe(true) - }) - - it('skips when cwd contains dlx- prefix', () => { - const result = shouldSkipShadow('/usr/local/bin/pnpm', { - cwd: '/tmp/dlx-socket-cli-12345/node_modules/.bin', - }) - expect(result).toBe(true) - }) - - it('skips when cwd contains Yarn virtual packages', () => { - const result = shouldSkipShadow('/usr/local/bin/yarn', { - cwd: '/project/.yarn/$$virtual/package-name', - }) - expect(result).toBe(true) - }) - - it('skips when cwd contains Yarn Windows temp', () => { - // Test both Unix and Windows style paths. - const resultUnixStyle = shouldSkipShadow('/usr/local/bin/yarn', { - cwd: 'C:/Users/test/AppData/Local/Temp/xfs-12345', - }) - expect(resultUnixStyle).toBe(true) - - // Windows style path. - const resultWinStyle = shouldSkipShadow('/usr/local/bin/yarn', { - cwd: 'C:\\Users\\test\\AppData\\Local\\Temp\\xfs-12345', - }) - expect(resultWinStyle).toBe(true) - }) - - it('does not skip for regular project paths', () => { - const result = shouldSkipShadow('/usr/local/bin/npm', { - cwd: '/home/user/projects/my-app/node_modules/.bin', - }) - expect(result).toBe(false) - }) - - it('uses process.cwd() when cwd not provided', () => { - vi.spyOn(process, 'cwd').mockReturnValue('/home/user/projects/my-app') - const result = shouldSkipShadow('/usr/local/bin/npm', {}) - expect(result).toBe(false) - }) - - it('uses default win32 value when not provided', () => { - const result = shouldSkipShadow('/usr/local/bin/npm', { - cwd: '/home/user/projects', - }) - expect(result).toBe(false) - }) - - it('handles undefined options gracefully', () => { - vi.spyOn(process, 'cwd').mockReturnValue('/home/user/projects') - const result = shouldSkipShadow('/usr/local/bin/npm', undefined as any) - expect(result).toBe(false) - }) - }) -}) diff --git a/packages/cli/test/unit/utils/shadow/links.test.mts b/packages/cli/test/unit/utils/shadow/links.test.mts index c285852d8..29a692602 100644 --- a/packages/cli/test/unit/utils/shadow/links.test.mts +++ b/packages/cli/test/unit/utils/shadow/links.test.mts @@ -46,7 +46,7 @@ vi.mock('@socketsecurity/lib/constants/platform', async () => { vi.mock('../../../../src/constants/paths.mts', () => ({ getDistPath: mockGetDistPath, })) -vi.mock('../../../../src/utils/dlx/detection.mts', () => ({ +vi.mock('@socketsecurity/lib/shadow', () => ({ shouldSkipShadow: mockShouldSkipShadow, })) vi.mock('../../../../src/utils/npm/paths.mts', () => ({