diff --git a/src/cli.ts b/src/cli.ts index 8de1aa417..9537a9dcd 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -7,28 +7,28 @@ import { messageWithCauses, stackWithCauses } from 'pony-cause' import updateNotifier from 'tiny-updater' import colors from 'yoctocolors-cjs' -import { cmdAction } from './commands/action/cmd-action.ts' -import { cmdAnalytics } from './commands/analytics/cmd-analytics.ts' -import { cmdAuditLog } from './commands/audit-log/cmd-audit-log.ts' -import { cmdCdxgen } from './commands/cdxgen/cmd-cdxgen.ts' -import { cmdScanCreate } from './commands/dependencies/cmd-dependencies.ts' -import { cmdDiffScan } from './commands/diff-scan/cmd-diff-scan.ts' -import { cmdFix } from './commands/fix/cmd-fix.ts' -import { cmdInfo } from './commands/info/cmd-info.ts' -import { loginCommand } from './commands/login' -import { logoutCommand } from './commands/logout' -import { cmdManifest } from './commands/manifest/cmd-manifest.ts' -import { npmCommand } from './commands/npm' -import { npxCommand } from './commands/npx' -import { optimizeCommand } from './commands/optimize' -import { organizationCommand } from './commands/organization' -import { rawNpmCommand } from './commands/raw-npm' -import { rawNpxCommand } from './commands/raw-npx' -import { cmdReport } from './commands/report/cmd-report.ts' -import { cmdRepos } from './commands/repos/cmd-repos.ts' -import { cmdScan } from './commands/scan/cmd-scan.ts' -import { threatFeedCommand } from './commands/threat-feed' -import { wrapperCommand } from './commands/wrapper' +import { cmdAction } from './commands/action/cmd-action' +import { cmdAnalytics } from './commands/analytics/cmd-analytics' +import { cmdAuditLog } from './commands/audit-log/cmd-audit-log' +import { cmdCdxgen } from './commands/cdxgen/cmd-cdxgen' +import { cmdScanCreate } from './commands/dependencies/cmd-dependencies' +import { cmdDiffScan } from './commands/diff-scan/cmd-diff-scan' +import { cmdFix } from './commands/fix/cmd-fix' +import { cmdInfo } from './commands/info/cmd-info' +import { cmdLogin } from './commands/login/cmd-login' +import { cmdLogout } from './commands/logout/cmd-logout' +import { cmdManifest } from './commands/manifest/cmd-manifest' +import { cmdNpm } from './commands/npm/cmd-npm' +import { cmdNpx } from './commands/npx/cmd-npx' +import { cmdOptimize } from './commands/optimize/cmd-optimize' +import { cmdOrganizations } from './commands/organizations/cmd-organizations' +import { cmdRawNpm } from './commands/raw-npm/cmd-raw-npm' +import { cmdRawNpx } from './commands/raw-npx/cmd-raw-npx' +import { cmdReport } from './commands/report/cmd-report' +import { cmdRepos } from './commands/repos/cmd-repos' +import { cmdScan } from './commands/scan/cmd-scan' +import { cmdThreatFeed } from './commands/threat-feed/cmd-threat-feed' +import { cmdWrapper } from './commands/wrapper/cmd-wrapper' import constants from './constants' import { AuthError, InputError } from './utils/errors' import { logSymbols } from './utils/logging' @@ -51,23 +51,23 @@ void (async () => { cdxgen: cmdCdxgen, fix: cmdFix, info: cmdInfo, - login: loginCommand, - logout: logoutCommand, - npm: npmCommand, - npx: npxCommand, - optimize: optimizeCommand, - organization: organizationCommand, - 'raw-npm': rawNpmCommand, - 'raw-npx': rawNpxCommand, + login: cmdLogin, + logout: cmdLogout, + npm: cmdNpm, + npx: cmdNpx, + optimize: cmdOptimize, + organization: cmdOrganizations, + 'raw-npm': cmdRawNpm, + 'raw-npx': cmdRawNpx, report: cmdReport, - wrapper: wrapperCommand, + wrapper: cmdWrapper, scan: cmdScan, 'audit-log': cmdAuditLog, repos: cmdRepos, dependencies: cmdScanCreate, analytics: cmdAnalytics, 'diff-scan': cmdDiffScan, - 'threat-feed': threatFeedCommand, + 'threat-feed': cmdThreatFeed, manifest: cmdManifest }, { diff --git a/src/commands/fix/cmd-fix.ts b/src/commands/fix/cmd-fix.ts index ee70cb111..0c07a9800 100644 --- a/src/commands/fix/cmd-fix.ts +++ b/src/commands/fix/cmd-fix.ts @@ -1,15 +1,10 @@ import meowOrExit from 'meow' -import { Spinner } from '@socketsecurity/registry/lib/spinner' - -import constants from '../../constants' -import { shadowNpmInstall } from '../../utils/npm' +import { runFix } from './run-fix.ts' import { getFlagListOutput } from '../../utils/output-formatting.ts' import type { CliCommandConfig } from '../../utils/meow-with-subcommands.ts' -const { SOCKET_CLI_IN_FIX_CMD, SOCKET_IPC_HANDSHAKE } = constants - const config: CliCommandConfig = { commandName: 'fix', description: 'Fix "fixable" Socket alerts', @@ -42,46 +37,5 @@ async function run( flags: config.flags }) - // const prev = new Set(alerts.map(a => a.key)) - // let ret: SafeNode | undefined - // /* eslint-disable no-await-in-loop */ - // while (alerts.length > 0) { - // await updateAdvisoryNodes(this, alerts) - // ret = await this[kRiskyReify](...args) - // await this.loadActual() - // await this.buildIdealTree() - // needInfoOn = getPackagesToQueryFromDiff(this.diff, { - // includeUnchanged: true - // }) - // alerts = ( - // await getPackagesAlerts(needInfoOn, { - // includeExisting: true, - // includeUnfixable: true - // }) - // ).filter(({ key }) => { - // const unseen = !prev.has(key) - // if (unseen) { - // prev.add(key) - // } - // return unseen - // }) - // } - // /* eslint-enable no-await-in-loop */ - // return ret! - - const spinner = new Spinner().start() - try { - await shadowNpmInstall({ - ipc: { - [SOCKET_IPC_HANDSHAKE]: { - [SOCKET_CLI_IN_FIX_CMD]: true - } - } - }) - } catch (e: any) { - console.error(e) - spinner.error() - } finally { - spinner.stop() - } + await runFix() } diff --git a/src/commands/fix/run-fix.ts b/src/commands/fix/run-fix.ts new file mode 100644 index 000000000..6dbd62944 --- /dev/null +++ b/src/commands/fix/run-fix.ts @@ -0,0 +1,51 @@ +import { Spinner } from '@socketsecurity/registry/lib/spinner' + +import constants from '../../constants.ts' +import { shadowNpmInstall } from '../../utils/npm.ts' + +const { SOCKET_CLI_IN_FIX_CMD, SOCKET_IPC_HANDSHAKE } = constants + +export async function runFix() { + // const prev = new Set(alerts.map(a => a.key)) + // let ret: SafeNode | undefined + // /* eslint-disable no-await-in-loop */ + // while (alerts.length > 0) { + // await updateAdvisoryNodes(this, alerts) + // ret = await this[kRiskyReify](...args) + // await this.loadActual() + // await this.buildIdealTree() + // needInfoOn = getPackagesToQueryFromDiff(this.diff, { + // includeUnchanged: true + // }) + // alerts = ( + // await getPackagesAlerts(needInfoOn, { + // includeExisting: true, + // includeUnfixable: true + // }) + // ).filter(({ key }) => { + // const unseen = !prev.has(key) + // if (unseen) { + // prev.add(key) + // } + // return unseen + // }) + // } + // /* eslint-enable no-await-in-loop */ + // return ret! + + const spinner = new Spinner().start() + try { + await shadowNpmInstall({ + ipc: { + [SOCKET_IPC_HANDSHAKE]: { + [SOCKET_CLI_IN_FIX_CMD]: true + } + } + }) + } catch (e: any) { + console.error(e) + spinner.error() + } finally { + spinner.stop() + } +} diff --git a/src/commands/login.ts b/src/commands/login.ts deleted file mode 100644 index 81eb904db..000000000 --- a/src/commands/login.ts +++ /dev/null @@ -1,165 +0,0 @@ -import meow from 'meow' -import terminalLink from 'terminal-link' - -import isInteractive from '@socketregistry/is-interactive/index.cjs' -import { confirm, password, select } from '@socketsecurity/registry/lib/prompts' -import { Spinner } from '@socketsecurity/registry/lib/spinner' - -import constants from '../constants' -import { AuthError, InputError } from '../utils/errors' -import { getFlagListOutput } from '../utils/output-formatting' -import { setupSdk } from '../utils/sdk' -import { getSetting, updateSetting } from '../utils/settings' - -import type { CliSubcommand } from '../utils/meow-with-subcommands' -import type { Separator } from '@socketsecurity/registry/lib/prompts' -import type { SocketSdkReturnType } from '@socketsecurity/sdk' - -type Choice = { - value: Value - name?: string - description?: string - disabled?: boolean | string - type?: never -} - -type OrgChoice = Choice - -type OrgChoices = (Separator | OrgChoice)[] - -const { SOCKET_PUBLIC_API_TOKEN } = constants - -const description = 'Socket API login' - -const flags: { [key: string]: any } = { - apiBaseUrl: { - type: 'string', - description: 'API server to connect to for login' - }, - apiProxy: { - type: 'string', - description: 'Proxy to use when making connection to API server' - } -} - -function nonNullish(value: T | null | undefined): value is T { - return value !== null && value !== undefined -} - -export const loginCommand: CliSubcommand = { - description, - async run(argv, importMeta, { parentName }) { - const name = `${parentName} login` - const cli = meow( - ` - Usage - $ ${name} - - Logs into the Socket API by prompting for an API key - - Options - ${getFlagListOutput( - { - 'api-base-url': flags['apiBaseUrl'].description, - 'api-proxy': flags['apiProxy'].description - }, - 8 - )} - - Examples - $ ${name} - `, - { - argv, - description, - importMeta, - flags - } - ) - let showHelp = cli.flags['help'] - if (cli.input.length) { - showHelp = true - } - if (showHelp) { - cli.showHelp() - return - } - if (!isInteractive()) { - throw new InputError( - 'Cannot prompt for credentials in a non-interactive shell' - ) - } - const apiToken = - (await password({ - message: `Enter your ${terminalLink( - 'Socket.dev API key', - 'https://docs.socket.dev/docs/api-keys' - )} (leave blank for a public key)` - })) || SOCKET_PUBLIC_API_TOKEN - - let apiBaseUrl = cli.flags['apiBaseUrl'] as string | null | undefined - apiBaseUrl ??= getSetting('apiBaseUrl') ?? undefined - - let apiProxy = cli.flags['apiProxy'] as string | null | undefined - apiProxy ??= getSetting('apiProxy') ?? undefined - - const spinner = new Spinner({ text: 'Verifying API key...' }).start() - - let orgs: SocketSdkReturnType<'getOrganizations'>['data'] - try { - const sdk = await setupSdk(apiToken, apiBaseUrl, apiProxy) - const result = await sdk.getOrganizations() - if (!result.success) { - throw new AuthError() - } - orgs = result.data - spinner.success('API key verified') - } catch { - spinner.error('Invalid API key') - return - } - - const enforcedChoices: OrgChoices = Object.values(orgs.organizations) - .filter(nonNullish) - .filter(org => org.plan === 'enterprise') - .map(org => ({ - name: org.name, - value: org.id - })) - - let enforcedOrgs: string[] = [] - - if (enforcedChoices.length > 1) { - const id = await select({ - message: - "Which organization's policies should Socket enforce system-wide?", - choices: enforcedChoices.concat({ - name: 'None', - value: '', - description: 'Pick "None" if this is a personal device' - }) - }) - if (id) { - enforcedOrgs = [id] - } - } else if (enforcedChoices.length) { - const confirmOrg = await confirm({ - message: `Should Socket enforce ${(enforcedChoices[0] as OrgChoice)?.name}'s security policies system-wide?`, - default: true - }) - if (confirmOrg) { - const existing = enforcedChoices[0] - if (existing) { - enforcedOrgs = [existing.value] - } - } - } - - updateSetting('enforcedOrgs', enforcedOrgs) - const oldToken = getSetting('apiToken') - updateSetting('apiToken', apiToken) - updateSetting('apiBaseUrl', apiBaseUrl) - updateSetting('apiProxy', apiProxy) - spinner.success(`API credentials ${oldToken ? 'updated' : 'set'}`) - } -} diff --git a/src/commands/login/apply-login.ts b/src/commands/login/apply-login.ts new file mode 100644 index 000000000..81054da60 --- /dev/null +++ b/src/commands/login/apply-login.ts @@ -0,0 +1,13 @@ +import { updateSetting } from '../../utils/settings.ts' + +export function applyLogin( + apiToken: string, + enforcedOrgs: Array, + apiBaseUrl: string | undefined, + apiProxy: string | undefined +) { + updateSetting('enforcedOrgs', enforcedOrgs) + updateSetting('apiToken', apiToken) + updateSetting('apiBaseUrl', apiBaseUrl) + updateSetting('apiProxy', apiProxy) +} diff --git a/src/commands/login/attempt-login.ts b/src/commands/login/attempt-login.ts new file mode 100644 index 000000000..30c95ba3f --- /dev/null +++ b/src/commands/login/attempt-login.ts @@ -0,0 +1,104 @@ +import terminalLink from 'terminal-link' + +import { + type Separator, + confirm, + password, + select +} from '@socketsecurity/registry/lib/prompts' +import { Spinner } from '@socketsecurity/registry/lib/spinner' + +import { applyLogin } from './apply-login.ts' +import constants from '../../constants.ts' +import { AuthError } from '../../utils/errors.ts' +import { setupSdk } from '../../utils/sdk.ts' +import { getSetting } from '../../utils/settings.ts' + +import type { SocketSdkReturnType } from '@socketsecurity/sdk' + +// TODO: this type should come from a general Socket REST API type doc +type Choice = { + value: Value + name?: string + description?: string + disabled?: boolean | string + type?: never +} +type OrgChoice = Choice +type OrgChoices = Array +const { SOCKET_PUBLIC_API_TOKEN } = constants + +export async function attemptLogin( + apiBaseUrl: string | undefined, + apiProxy: string | undefined +) { + const apiToken = + (await password({ + message: `Enter your ${terminalLink( + 'Socket.dev API key', + 'https://docs.socket.dev/docs/api-keys' + )} (leave blank for a public key)` + })) || SOCKET_PUBLIC_API_TOKEN + + apiBaseUrl ??= getSetting('apiBaseUrl') ?? undefined + apiProxy ??= getSetting('apiProxy') ?? undefined + + const spinner = new Spinner({ text: 'Verifying API key...' }).start() + + let orgs: SocketSdkReturnType<'getOrganizations'>['data'] + try { + const sdk = await setupSdk(apiToken, apiBaseUrl, apiProxy) + const result = await sdk.getOrganizations() + if (!result.success) { + throw new AuthError() + } + orgs = result.data + spinner.success('API key verified') + } catch { + spinner.error('Invalid API key') + return + } + + const enforcedChoices: OrgChoices = Object.values(orgs.organizations) + .filter(org => org?.plan === 'enterprise') + .map(org => ({ + name: org.name, + value: org.id + })) + + let enforcedOrgs: Array = [] + + if (enforcedChoices.length > 1) { + const id = await select({ + message: + "Which organization's policies should Socket enforce system-wide?", + choices: enforcedChoices.concat({ + name: 'None', + value: '', + description: 'Pick "None" if this is a personal device' + }) + }) + if (id) { + enforcedOrgs = [id] + } + } else if (enforcedChoices.length) { + const confirmOrg = await confirm({ + message: `Should Socket enforce ${(enforcedChoices[0] as OrgChoice)?.name}'s security policies system-wide?`, + default: true + }) + if (confirmOrg) { + const existing = enforcedChoices[0] + if (existing) { + enforcedOrgs = [existing.value] + } + } + } + + const oldToken = getSetting('apiToken') + try { + applyLogin(apiToken, enforcedOrgs, apiBaseUrl, apiProxy) + spinner.success(`API credentials ${oldToken ? 'updated' : 'set'}`) + } catch { + spinner.error(`API login failed`) + } +} diff --git a/src/commands/login/cmd-login.ts b/src/commands/login/cmd-login.ts new file mode 100644 index 000000000..8d0927929 --- /dev/null +++ b/src/commands/login/cmd-login.ts @@ -0,0 +1,67 @@ +import meowOrExit from 'meow' + +import isInteractive from '@socketregistry/is-interactive/index.cjs' + +import { attemptLogin } from './attempt-login.ts' +import { InputError } from '../../utils/errors' +import { getFlagListOutput } from '../../utils/output-formatting' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.ts' + +const config: CliCommandConfig = { + commandName: 'login', + description: 'Socket API login', + hidden: false, + flags: { + apiBaseUrl: { + type: 'string', + description: 'API server to connect to for login' + }, + apiProxy: { + type: 'string', + description: 'Proxy to use when making connection to API server' + } + }, + help: (parentName, config) => ` + Usage + $ ${parentName} ${config.commandName} + + Logs into the Socket API by prompting for an API key + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${parentName} ${config.commandName} + ` +} + +export const cmdLogin = { + description: config.description, + hidden: config.hidden, + run +} + +async function run( + argv: readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string } +): Promise { + const cli = meowOrExit(config.help(parentName, config), { + argv, + description: config.description, + importMeta, + flags: config.flags + }) + + if (!isInteractive()) { + throw new InputError( + 'Cannot prompt for credentials in a non-interactive shell' + ) + } + + let apiBaseUrl = cli.flags['apiBaseUrl'] as string | undefined + let apiProxy = cli.flags['apiProxy'] as string | undefined + + await attemptLogin(apiBaseUrl, apiProxy) +} diff --git a/src/commands/logout.ts b/src/commands/logout.ts deleted file mode 100644 index 69590ed27..000000000 --- a/src/commands/logout.ts +++ /dev/null @@ -1,45 +0,0 @@ -import meow from 'meow' - -import { Spinner } from '@socketsecurity/registry/lib/spinner' - -import { updateSetting } from '../utils/settings' - -import type { CliSubcommand } from '../utils/meow-with-subcommands' - -const description = 'Socket API logout' - -export const logoutCommand: CliSubcommand = { - description, - async run(argv, importMeta, { parentName }) { - const name = `${parentName} logout` - const cli = meow( - ` - Usage - $ ${name} - - Logs out of the Socket API and clears all Socket credentials from disk - - Examples - $ ${name} - `, - { - argv, - description, - importMeta - } - ) - let showHelp = cli.flags['help'] - if (cli.input.length) { - showHelp = true - } - if (showHelp) { - cli.showHelp() - return - } - updateSetting('apiToken', null) - updateSetting('apiBaseUrl', null) - updateSetting('apiProxy', null) - updateSetting('enforcedOrgs', null) - new Spinner().success('Successfully logged out') - } -} diff --git a/src/commands/logout/apply-logout.ts b/src/commands/logout/apply-logout.ts new file mode 100644 index 000000000..a35866a4f --- /dev/null +++ b/src/commands/logout/apply-logout.ts @@ -0,0 +1,8 @@ +import { updateSetting } from '../../utils/settings' + +export function applyLogout() { + updateSetting('apiToken', null) + updateSetting('apiBaseUrl', null) + updateSetting('apiProxy', null) + updateSetting('enforcedOrgs', null) +} diff --git a/src/commands/logout/attempt-logout.ts b/src/commands/logout/attempt-logout.ts new file mode 100644 index 000000000..f6e8d62fa --- /dev/null +++ b/src/commands/logout/attempt-logout.ts @@ -0,0 +1,12 @@ +import { Spinner } from '@socketsecurity/registry/lib/spinner' + +import { applyLogout } from './apply-logout.ts' + +export function attemptLogout() { + try { + applyLogout() + new Spinner().success('Successfully logged out') + } catch { + new Spinner().success('Failed to complete logout steps') + } +} diff --git a/src/commands/logout/cmd-logout.ts b/src/commands/logout/cmd-logout.ts new file mode 100644 index 000000000..baffd13b2 --- /dev/null +++ b/src/commands/logout/cmd-logout.ts @@ -0,0 +1,42 @@ +import meowOrExit from 'meow' + +import { attemptLogout } from './attempt-logout.ts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.ts' + +const config: CliCommandConfig = { + commandName: 'logout', + description: 'Socket API logout', + hidden: false, + flags: {}, + help: (parentName, config) => ` + Usage + $ ${parentName} ${config.commandName} + + Logs out of the Socket API and clears all Socket credentials from disk + + Examples + $ ${parentName} ${config.commandName} + ` +} + +export const cmdLogout = { + description: config.description, + hidden: config.hidden, + run +} + +async function run( + argv: readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string } +): Promise { + meowOrExit(config.help(parentName, config), { + argv, + description: config.description, + importMeta, + flags: config.flags + }) + + attemptLogout() +} diff --git a/src/commands/manifest/cmd-auto.ts b/src/commands/manifest/cmd-manifest-auto.ts similarity index 96% rename from src/commands/manifest/cmd-auto.ts rename to src/commands/manifest/cmd-manifest-auto.ts index e8662732e..e9b592910 100644 --- a/src/commands/manifest/cmd-auto.ts +++ b/src/commands/manifest/cmd-manifest-auto.ts @@ -3,8 +3,8 @@ import path from 'node:path' import meow from 'meow' -import { cmdManifestGradle } from './cmd-gradle.ts' -import { cmdManifestScala } from './cmd-scala.ts' +import { cmdManifestGradle } from './cmd-manifest-gradle.ts' +import { cmdManifestScala } from './cmd-manifest-scala.ts' import { commonFlags } from '../../flags.ts' import { getFlagListOutput } from '../../utils/output-formatting.ts' diff --git a/src/commands/manifest/cmd-gradle.ts b/src/commands/manifest/cmd-manifest-gradle.ts similarity index 100% rename from src/commands/manifest/cmd-gradle.ts rename to src/commands/manifest/cmd-manifest-gradle.ts diff --git a/src/commands/manifest/cmd-kotlin.ts b/src/commands/manifest/cmd-manifest-kotlin.ts similarity index 100% rename from src/commands/manifest/cmd-kotlin.ts rename to src/commands/manifest/cmd-manifest-kotlin.ts diff --git a/src/commands/manifest/cmd-scala.ts b/src/commands/manifest/cmd-manifest-scala.ts similarity index 100% rename from src/commands/manifest/cmd-scala.ts rename to src/commands/manifest/cmd-manifest-scala.ts diff --git a/src/commands/manifest/cmd-manifest.ts b/src/commands/manifest/cmd-manifest.ts index a2eeb18e0..8ddd85c4a 100644 --- a/src/commands/manifest/cmd-manifest.ts +++ b/src/commands/manifest/cmd-manifest.ts @@ -1,7 +1,7 @@ -import { cmdManifestAuto } from './cmd-auto.ts' -import { cmdManifestGradle } from './cmd-gradle.ts' -import { cmdManifestKotlin } from './cmd-kotlin.ts' -import { cmdManifestScala } from './cmd-scala.ts' +import { cmdManifestAuto } from './cmd-manifest-auto.ts' +import { cmdManifestGradle } from './cmd-manifest-gradle.ts' +import { cmdManifestKotlin } from './cmd-manifest-kotlin.ts' +import { cmdManifestScala } from './cmd-manifest-scala.ts' import { commonFlags } from '../../flags.ts' import { type CliCommandConfig, diff --git a/src/commands/npm.ts b/src/commands/npm.ts deleted file mode 100644 index 2251d6eb0..000000000 --- a/src/commands/npm.ts +++ /dev/null @@ -1,14 +0,0 @@ -import constants from '../constants' - -import type { CliSubcommand } from '../utils/meow-with-subcommands' - -const { NPM } = constants - -export const npmCommand: CliSubcommand = { - description: `${NPM} wrapper functionality`, - async run(argv) { - // Lazily access constants.distPath. - const shadowBin = require(`${constants.distPath}/shadow-bin.js`) - await shadowBin(NPM, argv) - } -} diff --git a/src/commands/npm/cmd-npm.ts b/src/commands/npm/cmd-npm.ts new file mode 100644 index 000000000..b934aa09e --- /dev/null +++ b/src/commands/npm/cmd-npm.ts @@ -0,0 +1,40 @@ +import meowOrExit from 'meow' + +import { wrapNpm } from './wrap-npm.ts' +import constants from '../../constants' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands' + +const { NPM } = constants + +const config: CliCommandConfig = { + commandName: 'npm', + description: `${NPM} wrapper functionality`, + hidden: false, + flags: {}, + help: (parentName, config) => ` + Usage + $ ${parentName} ${config.commandName} + ` +} + +export const cmdNpm = { + description: config.description, + hidden: config.hidden, + run +} + +async function run( + argv: readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string } +): Promise { + meowOrExit(config.help(parentName, config), { + argv, + description: config.description, + importMeta, + flags: config.flags + }) + + await wrapNpm(argv) +} diff --git a/src/commands/npm/wrap-npm.ts b/src/commands/npm/wrap-npm.ts new file mode 100644 index 000000000..b1fa4e3c2 --- /dev/null +++ b/src/commands/npm/wrap-npm.ts @@ -0,0 +1,9 @@ +import constants from '../../constants.ts' + +const { NPM } = constants + +export async function wrapNpm(argv: readonly string[]) { + // Lazily access constants.distPath. + const shadowBin = require(`${constants.distPath}/shadow-bin.js`) + await shadowBin(NPM, argv) +} diff --git a/src/commands/npx.ts b/src/commands/npx.ts deleted file mode 100644 index c2da405af..000000000 --- a/src/commands/npx.ts +++ /dev/null @@ -1,14 +0,0 @@ -import constants from '../constants' - -import type { CliSubcommand } from '../utils/meow-with-subcommands' - -const { NPX } = constants - -export const npxCommand: CliSubcommand = { - description: `${NPX} wrapper functionality`, - async run(argv) { - // Lazily access constants.distPath. - const shadowBin = require(`${constants.distPath}/shadow-bin.js`) - await shadowBin(NPX, argv) - } -} diff --git a/src/commands/npx/cmd-npx.ts b/src/commands/npx/cmd-npx.ts new file mode 100644 index 000000000..acf29648e --- /dev/null +++ b/src/commands/npx/cmd-npx.ts @@ -0,0 +1,40 @@ +import meowOrExit from 'meow' + +import { wrapNpx } from './wrap-npx.ts' +import constants from '../../constants' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.ts' + +const { NPX } = constants + +const config: CliCommandConfig = { + commandName: 'npx', + description: `${NPX} wrapper functionality`, + hidden: false, + flags: {}, + help: (parentName, config) => ` + Usage + $ ${parentName} ${config.commandName} + ` +} + +export const cmdNpx = { + description: config.description, + hidden: config.hidden, + run +} + +async function run( + argv: readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string } +): Promise { + meowOrExit(config.help(parentName, config), { + argv, + description: config.description, + importMeta, + flags: config.flags + }) + + await wrapNpx(argv) +} diff --git a/src/commands/npx/wrap-npx.ts b/src/commands/npx/wrap-npx.ts new file mode 100644 index 000000000..1584670e9 --- /dev/null +++ b/src/commands/npx/wrap-npx.ts @@ -0,0 +1,9 @@ +import constants from '../../constants.ts' + +const { NPX } = constants + +export async function wrapNpx(argv: readonly string[]) { + // Lazily access constants.distPath. + const shadowBin = require(`${constants.distPath}/shadow-bin.js`) + await shadowBin(NPX, argv) +} diff --git a/src/commands/optimize.ts b/src/commands/optimize.ts deleted file mode 100644 index fcbbc274f..000000000 --- a/src/commands/optimize.ts +++ /dev/null @@ -1,1025 +0,0 @@ -import path from 'node:path' -import process from 'node:process' - -import spawn from '@npmcli/promise-spawn' -import meow from 'meow' -import npa from 'npm-package-arg' -import semver from 'semver' -import { glob as tinyGlob } from 'tinyglobby' -import { parse as yamlParse } from 'yaml' - -import { getManifestData } from '@socketsecurity/registry' -import { - hasKeys, - hasOwn, - isObject, - toSortedObject -} from '@socketsecurity/registry/lib/objects' -import { - fetchPackageManifest, - readPackageJson -} from '@socketsecurity/registry/lib/packages' -import { pEach } from '@socketsecurity/registry/lib/promises' -import { escapeRegExp } from '@socketsecurity/registry/lib/regexps' -import { Spinner } from '@socketsecurity/registry/lib/spinner' -import { isNonEmptyString } from '@socketsecurity/registry/lib/strings' -import { pluralize } from '@socketsecurity/registry/lib/words' - -import constants from '../constants' -import { commonFlags } from '../flags' -import { safeReadFile } from '../utils/fs' -import { shadowNpmInstall } from '../utils/npm' -import { getFlagListOutput } from '../utils/output-formatting' -import { detect } from '../utils/package-manager-detector' - -import type { CliSubcommand } from '../utils/meow-with-subcommands' -import type { - Agent, - StringKeyValueObject -} from '../utils/package-manager-detector' -import type { ManifestEntry } from '@socketsecurity/registry' -import type { EditablePackageJson } from '@socketsecurity/registry/lib/packages' - -type PackageJson = Awaited> - -const { - BUN, - LOCK_EXT, - NPM, - OVERRIDES, - PNPM, - RESOLUTIONS, - SOCKET_CLI_IN_OPTIMIZE_CMD, - SOCKET_IPC_HANDSHAKE, - VLT, - YARN_BERRY, - YARN_CLASSIC, - abortSignal -} = constants - -const COMMAND_TITLE = 'Socket Optimize' -const NPM_OVERRIDE_PR_URL = 'https://github.com/npm/cli/pull/7025' -const PNPM_FIELD_NAME = PNPM -const PNPM_WORKSPACE = `${PNPM}-workspace` - -const manifestNpmOverrides = getManifestData(NPM) - -const description = 'Optimize dependencies with @socketregistry overrides' - -type NpmOverrides = { [key: string]: string | StringKeyValueObject } -type PnpmOrYarnOverrides = { [key: string]: string } -type Overrides = NpmOverrides | PnpmOrYarnOverrides -type GetOverrides = (pkgJson: PackageJson) => GetOverridesResult -type GetOverridesResult = { - type: Agent - overrides: Overrides -} - -const getOverridesDataByAgent = >{ - __proto__: null, - [BUN](pkgJson: PackageJson) { - const overrides = (pkgJson as any)?.[RESOLUTIONS] ?? {} - return { type: YARN_BERRY, overrides } - }, - // npm overrides documentation: - // https://docs.npmjs.com/cli/v10/configuring-npm/package-json#overrides - [NPM](pkgJson: PackageJson) { - const overrides = (pkgJson as any)?.[OVERRIDES] ?? {} - return { type: NPM, overrides } - }, - // pnpm overrides documentation: - // https://pnpm.io/package_json#pnpmoverrides - [PNPM](pkgJson: PackageJson) { - const overrides = (pkgJson as any)?.pnpm?.[OVERRIDES] ?? {} - return { type: PNPM, overrides } - }, - [VLT](pkgJson: PackageJson) { - const overrides = (pkgJson as any)?.[OVERRIDES] ?? {} - return { type: VLT, overrides } - }, - // Yarn resolutions documentation: - // https://yarnpkg.com/configuration/manifest#resolutions - [YARN_BERRY](pkgJson: PackageJson) { - const overrides = (pkgJson as any)?.[RESOLUTIONS] ?? {} - return { type: YARN_BERRY, overrides } - }, - // Yarn resolutions documentation: - // https://classic.yarnpkg.com/en/docs/selective-version-resolutions - [YARN_CLASSIC](pkgJson: PackageJson) { - const overrides = (pkgJson as any)?.[RESOLUTIONS] ?? {} - return { type: YARN_CLASSIC, overrides } - } -} - -type AgentLockIncludesFn = ( - lockSrc: string, - name: string, - ext?: string -) => boolean - -const lockIncludesByAgent: Record = (() => { - function npmLockIncludes(lockSrc: string, name: string) { - // Detects the package name in the following cases: - // "name": - return lockSrc.includes(`"${name}":`) - } - - function yarnLockIncludes(lockSrc: string, name: string) { - const escapedName = escapeRegExp(name) - return new RegExp( - // Detects the package name in the following cases: - // "name@ - // , "name@ - // name@ - // , name@ - `(?<=(?:^\\s*|,\\s*)"?)${escapedName}(?=@)`, - 'm' - ).test(lockSrc) - } - - return { - __proto__: null, - [BUN](lockSrc: string, name: string, lockBasename?: string) { - // This is a bit counterintuitive. When lockBasename ends with a .lockb - // we treat it as a yarn.lock. When lockBasename ends with a .lock we - // treat it as a package-lock.json. The bun.lock format is not identical - // package-lock.json, however it close enough for npmLockIncludes to work. - const lockScanner = lockBasename?.endsWith(LOCK_EXT) - ? npmLockIncludes - : yarnLockIncludes - return lockScanner(lockSrc, name) - }, - [NPM]: npmLockIncludes, - [PNPM](lockSrc: string, name: string) { - const escapedName = escapeRegExp(name) - return new RegExp( - // Detects the package name in the following cases: - // /name/ - // 'name' - // name: - // name@ - `(?<=^\\s*)(?:(['/])${escapedName}\\1|${escapedName}(?=[:@]))`, - 'm' - ).test(lockSrc) - }, - [VLT](lockSrc: string, name: string) { - // Detects the package name in the following cases: - // "name" - return lockSrc.includes(`"${name}"`) - }, - [YARN_BERRY]: yarnLockIncludes, - [YARN_CLASSIC]: yarnLockIncludes - } -})() - -type AgentModifyManifestFn = ( - pkgJson: EditablePackageJson, - overrides: Overrides -) => void - -const updateManifestByAgent: Record = (() => { - const depFields = [ - 'dependencies', - 'devDependencies', - 'peerDependencies', - 'peerDependenciesMeta', - 'optionalDependencies', - 'bundleDependencies' - ] - - function getEntryIndexes( - entries: [string | symbol, any][], - keys: (string | symbol)[] - ): number[] { - return keys - .map(n => entries.findIndex(p => p[0] === n)) - .filter(n => n !== -1) - .sort((a, b) => a - b) - } - - function getLowestEntryIndex( - entries: [string | symbol, any][], - keys: (string | symbol)[] - ) { - return getEntryIndexes(entries, keys)?.[0] ?? -1 - } - - function getHighestEntryIndex( - entries: [string | symbol, any][], - keys: (string | symbol)[] - ) { - return getEntryIndexes(entries, keys).at(-1) ?? -1 - } - - function updatePkgJson( - editablePkgJson: EditablePackageJson, - field: string, - value: any - ) { - const pkgJson = editablePkgJson.content - const oldValue = pkgJson[field] - if (oldValue) { - // The field already exists so we simply update the field value. - if (field === PNPM_FIELD_NAME) { - if (hasKeys(value)) { - editablePkgJson.update({ - [field]: { - ...(isObject(oldValue) ? oldValue : {}), - overrides: value - } - }) - } else { - // Properties with undefined values are omitted when saved as JSON. - editablePkgJson.update((hasKeys(pkgJson[field]) - ? { - [field]: { - ...(isObject(oldValue) ? oldValue : {}), - overrides: undefined - } - } - : { [field]: undefined })) - } - } else if (field === OVERRIDES || field === RESOLUTIONS) { - // Properties with undefined values are omitted when saved as JSON. - editablePkgJson.update({ - [field]: hasKeys(value) ? value : undefined - }) - } else { - editablePkgJson.update({ [field]: value }) - } - return - } - if ( - (field === OVERRIDES || - field === PNPM_FIELD_NAME || - field === RESOLUTIONS) && - !hasKeys(value) - ) { - return - } - // Since the field doesn't exist we want to insert it into the package.json - // in a place that makes sense, e.g. close to the "dependencies" field. If - // we can't find a place to insert the field we'll add it to the bottom. - const entries = Object.entries(pkgJson) - let insertIndex = -1 - let isPlacingHigher = false - if (field === OVERRIDES) { - insertIndex = getLowestEntryIndex(entries, [RESOLUTIONS]) - if (insertIndex === -1) { - isPlacingHigher = true - insertIndex = getHighestEntryIndex(entries, [...depFields, PNPM]) - } - } else if (field === RESOLUTIONS) { - isPlacingHigher = true - insertIndex = getHighestEntryIndex(entries, [ - ...depFields, - OVERRIDES, - PNPM - ]) - } else if (field === PNPM_FIELD_NAME) { - insertIndex = getLowestEntryIndex(entries, [OVERRIDES, RESOLUTIONS]) - if (insertIndex === -1) { - isPlacingHigher = true - insertIndex = getHighestEntryIndex(entries, depFields) - } - } - if (insertIndex === -1) { - insertIndex = getLowestEntryIndex(entries, ['engines', 'files']) - } - if (insertIndex === -1) { - isPlacingHigher = true - insertIndex = getHighestEntryIndex(entries, [ - 'exports', - 'imports', - 'main' - ]) - } - if (insertIndex === -1) { - insertIndex = entries.length - } else if (isPlacingHigher) { - insertIndex += 1 - } - entries.splice(insertIndex, 0, [field, value]) - editablePkgJson.fromJSON( - `${JSON.stringify(Object.fromEntries(entries), null, 2)}\n` - ) - } - - function updateOverrides( - editablePkgJson: EditablePackageJson, - overrides: Overrides - ) { - updatePkgJson(editablePkgJson, OVERRIDES, overrides) - } - - function updateResolutions( - editablePkgJson: EditablePackageJson, - overrides: Overrides - ) { - updatePkgJson(editablePkgJson, RESOLUTIONS, overrides) - } - - return { - __proto__: null, - [BUN]: updateResolutions, - [NPM]: updateOverrides, - [PNPM](editablePkgJson: EditablePackageJson, overrides: Overrides) { - updatePkgJson(editablePkgJson, PNPM_FIELD_NAME, overrides) - }, - [VLT]: updateOverrides, - [YARN_BERRY]: updateResolutions, - [YARN_CLASSIC]: updateResolutions - } -})() - -type AgentListDepsOptions = { - npmExecPath?: string -} -type AgentListDepsFn = ( - agentExecPath: string, - cwd: string, - options?: AgentListDepsOptions -) => Promise - -const lsByAgent = (() => { - function cleanupQueryStdout(stdout: string): string { - if (stdout === '') { - return '' - } - let pkgs - try { - pkgs = JSON.parse(stdout) - } catch {} - if (!Array.isArray(pkgs)) { - return '' - } - const names = new Set() - for (const { _id, name, pkgid } of pkgs) { - // `npm query` results may not have a "name" property, in which case we - // fallback to "_id" and then "pkgid". - // `vlt ls --view json` results always have a "name" property. - const fallback = _id ?? pkgid ?? '' - const resolvedName = name ?? fallback.slice(0, fallback.indexOf('@', 1)) - // Add package names, except for those under the `@types` scope as those - // are known to only be dev dependencies. - if (resolvedName && !resolvedName.startsWith('@types/')) { - names.add(resolvedName) - } - } - return JSON.stringify([...names], null, 2) - } - - function parseableToQueryStdout(stdout: string) { - if (stdout === '') { - return '' - } - // Convert the parseable stdout into a json array of unique names. - // The matchAll regexp looks for a forward (posix) or backward (win32) slash - // and matches one or more non-slashes until the newline. - const names = new Set(stdout.matchAll(/(?<=[/\\])[^/\\]+(?=\n)/g)) - return JSON.stringify([...names], null, 2) - } - - async function npmQuery(npmExecPath: string, cwd: string): Promise { - let stdout = '' - try { - stdout = (await spawn(npmExecPath, ['query', ':not(.dev)'], { cwd })) - .stdout - } catch {} - return cleanupQueryStdout(stdout) - } - - return >{ - __proto__: null, - async [BUN](agentExecPath: string, cwd: string) { - try { - // Bun does not support filtering by production packages yet. - // https://github.com/oven-sh/bun/issues/8283 - return (await spawn(agentExecPath!, ['pm', 'ls', '--all'], { cwd })) - .stdout - } catch {} - return '' - }, - async [NPM](agentExecPath: string, cwd: string) { - return await npmQuery(agentExecPath, cwd) - }, - async [PNPM]( - agentExecPath: string, - cwd: string, - options: AgentListDepsOptions - ) { - const { npmExecPath } = { - __proto__: null, - ...options - } - if (npmExecPath && npmExecPath !== NPM) { - const result = await npmQuery(npmExecPath, cwd) - if (result) { - return result - } - } - let stdout = '' - try { - stdout = ( - await spawn( - agentExecPath, - ['ls', '--parseable', '--prod', '--depth', 'Infinity'], - { cwd } - ) - ).stdout - } catch {} - return parseableToQueryStdout(stdout) - }, - async [VLT](agentExecPath: string, cwd: string) { - let stdout = '' - try { - stdout = ( - await spawn(agentExecPath, ['ls', '--view', 'human', ':not(.dev)'], { - cwd - }) - ).stdout - } catch {} - return cleanupQueryStdout(stdout) - }, - async [YARN_BERRY](agentExecPath: string, cwd: string) { - try { - return ( - // Yarn Berry does not support filtering by production packages yet. - // https://github.com/yarnpkg/berry/issues/5117 - ( - await spawn(agentExecPath, ['info', '--recursive', '--name-only'], { - cwd - }) - ).stdout.trim() - ) - } catch {} - return '' - }, - async [YARN_CLASSIC](agentExecPath: string, cwd: string) { - try { - // However, Yarn Classic does support it. - // https://github.com/yarnpkg/yarn/releases/tag/v1.0.0 - // > Fix: Excludes dev dependencies from the yarn list output when the - // environment is production - return ( - await spawn(agentExecPath, ['list', '--prod'], { cwd }) - ).stdout.trim() - } catch {} - return '' - } - } -})() - -type AgentDepsIncludesFn = (stdout: string, name: string) => boolean - -const depsIncludesByAgent: Record = (() => { - function matchHumanStdout(stdout: string, name: string) { - return stdout.includes(` ${name}@`) - } - - function matchQueryStdout(stdout: string, name: string) { - return stdout.includes(`"${name}"`) - } - - return { - __proto__: null, - [BUN]: matchHumanStdout, - [NPM]: matchQueryStdout, - [PNPM]: matchQueryStdout, - [VLT]: matchQueryStdout, - [YARN_BERRY]: matchHumanStdout, - [YARN_CLASSIC]: matchHumanStdout - } -})() - -function createActionMessage( - verb: string, - overrideCount: number, - workspaceCount: number -) { - return `${verb} ${overrideCount} Socket.dev optimized ${pluralize('override', overrideCount)}${workspaceCount ? ` in ${workspaceCount} ${pluralize('workspace', workspaceCount)}` : ''}` -} - -function getDependencyEntries(pkgJson: PackageJson) { - const { - dependencies, - devDependencies, - optionalDependencies, - peerDependencies - } = pkgJson - return <[string, NonNullable][]>[ - [ - 'dependencies', - dependencies ? { __proto__: null, ...dependencies } : undefined - ], - [ - 'devDependencies', - devDependencies ? { __proto__: null, ...devDependencies } : undefined - ], - [ - 'peerDependencies', - peerDependencies ? { __proto__: null, ...peerDependencies } : undefined - ], - [ - 'optionalDependencies', - optionalDependencies - ? { __proto__: null, ...optionalDependencies } - : undefined - ] - ].filter(({ 1: o }) => o) -} - -async function getWorkspaceGlobs( - agent: Agent, - pkgPath: string, - pkgJson: PackageJson -): Promise { - let workspacePatterns - if (agent === PNPM) { - for (const workspacePath of [ - path.join(pkgPath!, `${PNPM_WORKSPACE}.yaml`), - path.join(pkgPath!, `${PNPM_WORKSPACE}.yml`) - ]) { - // eslint-disable-next-line no-await-in-loop - const yml = await safeReadFile(workspacePath, 'utf8') - if (yml) { - try { - workspacePatterns = yamlParse(yml)?.packages - } catch {} - if (workspacePatterns) { - break - } - } - } - } else { - workspacePatterns = pkgJson['workspaces'] - } - return Array.isArray(workspacePatterns) - ? workspacePatterns - .filter(isNonEmptyString) - .map(workspacePatternToGlobPattern) - : undefined -} - -function workspacePatternToGlobPattern(workspace: string): string { - const { length } = workspace - if (!length) { - return '' - } - // If the workspace ends with "/" - if (workspace.charCodeAt(length - 1) === 47 /*'/'*/) { - return `${workspace}/*/package.json` - } - // If the workspace ends with "/**" - if ( - workspace.charCodeAt(length - 1) === 42 /*'*'*/ && - workspace.charCodeAt(length - 2) === 42 /*'*'*/ && - workspace.charCodeAt(length - 3) === 47 /*'/'*/ - ) { - return `${workspace}/*/**/package.json` - } - // Things like "packages/a" or "packages/*" - return `${workspace}/package.json` -} - -type AddOverridesConfig = { - agent: Agent - agentExecPath: string - lockBasename: string - lockSrc: string - manifestEntries: ManifestEntry[] - npmExecPath: string - pkgJson?: EditablePackageJson | undefined - pkgPath: string - pin?: boolean | undefined - prod?: boolean | undefined - rootPath: string -} - -type AddOverridesState = { - added: Set - addedInWorkspaces: Set - spinner?: Spinner | undefined - updated: Set - updatedInWorkspaces: Set - warnedPnpmWorkspaceRequiresNpm: boolean -} - -function createAddOverridesState(initials?: any): AddOverridesState { - return { - added: new Set(), - addedInWorkspaces: new Set(), - spinner: undefined, - updated: new Set(), - updatedInWorkspaces: new Set(), - warnedPnpmWorkspaceRequiresNpm: false, - ...initials - } -} - -async function addOverrides( - { - agent, - agentExecPath, - lockBasename, - lockSrc, - manifestEntries, - npmExecPath, - pin, - pkgJson: editablePkgJson, - pkgPath, - prod, - rootPath - }: AddOverridesConfig, - state = createAddOverridesState() -): Promise { - if (editablePkgJson === undefined) { - editablePkgJson = await readPackageJson(pkgPath, { editable: true }) - } - const { spinner } = state - const { content: pkgJson } = editablePkgJson - const isRoot = pkgPath === rootPath - const isLockScanned = isRoot && !prod - const workspaceName = path.relative(rootPath, pkgPath) - const workspaceGlobs = await getWorkspaceGlobs(agent, pkgPath, pkgJson) - const isWorkspace = !!workspaceGlobs - if ( - isWorkspace && - agent === PNPM && - npmExecPath === NPM && - !state.warnedPnpmWorkspaceRequiresNpm - ) { - state.warnedPnpmWorkspaceRequiresNpm = true - console.warn( - `⚠️ ${COMMAND_TITLE}: pnpm workspace support requires \`npm ls\`, falling back to \`pnpm list\`` - ) - } - const thingToScan = isLockScanned - ? lockSrc - : await lsByAgent[agent](agentExecPath, pkgPath, { npmExecPath }) - // The AgentDepsIncludesFn and AgentLockIncludesFn types overlap in their - // first two parameters. AgentLockIncludesFn accepts an optional third - // parameter which AgentDepsIncludesFn will ignore so we cast thingScanner - // as an AgentLockIncludesFn type. - const thingScanner = ( - (isLockScanned ? lockIncludesByAgent[agent] : depsIncludesByAgent[agent]) - ) - const depEntries = getDependencyEntries(pkgJson) - - const overridesDataObjects = [] - if (pkgJson['private'] || isWorkspace) { - overridesDataObjects.push(getOverridesDataByAgent[agent](pkgJson)) - } else { - overridesDataObjects.push( - getOverridesDataByAgent[NPM](pkgJson), - getOverridesDataByAgent[YARN_CLASSIC](pkgJson) - ) - } - if (spinner) { - spinner.text = `Adding overrides${workspaceName ? ` to ${workspaceName}` : ''}...` - } - const depAliasMap = new Map() - // Chunk package names to process them in parallel 3 at a time. - await pEach(manifestEntries, 3, async ({ 1: data }) => { - const { name: sockRegPkgName, package: origPkgName, version } = data - const major = semver.major(version) - const sockOverridePrefix = `${NPM}:${sockRegPkgName}@` - const sockOverrideSpec = `${sockOverridePrefix}${pin ? version : `^${major}`}` - for (const { 1: depObj } of depEntries) { - const sockSpec = hasOwn(depObj, sockRegPkgName) - ? depObj[sockRegPkgName] - : undefined - if (sockSpec) { - depAliasMap.set(sockRegPkgName, sockSpec) - } - const origSpec = hasOwn(depObj, origPkgName) - ? depObj[origPkgName] - : undefined - if (origSpec) { - let thisSpec = origSpec - // Add package aliases for direct dependencies to avoid npm EOVERRIDE errors. - // https://docs.npmjs.com/cli/v8/using-npm/package-spec#aliases - if ( - !( - thisSpec.startsWith(sockOverridePrefix) && - semver.coerce(npa(thisSpec).rawSpec)?.version - ) - ) { - thisSpec = sockOverrideSpec - depObj[origPkgName] = thisSpec - state.added.add(sockRegPkgName) - if (workspaceName) { - state.addedInWorkspaces.add(workspaceName) - } - } - depAliasMap.set(origPkgName, thisSpec) - } - } - if (isRoot) { - // Chunk package names to process them in parallel 3 at a time. - await pEach(overridesDataObjects, 3, async ({ overrides, type }) => { - const overrideExists = hasOwn(overrides, origPkgName) - if ( - overrideExists || - thingScanner(thingToScan, origPkgName, lockBasename) - ) { - const oldSpec = overrideExists ? overrides[origPkgName] : undefined - const origDepAlias = depAliasMap.get(origPkgName) - const sockRegDepAlias = depAliasMap.get(sockRegPkgName) - const depAlias = sockRegDepAlias ?? origDepAlias - let newSpec = sockOverrideSpec - if (type === NPM && depAlias) { - // With npm one may not set an override for a package that one directly - // depends on unless both the dependency and the override itself share - // the exact same spec. To make this limitation easier to deal with, - // overrides may also be defined as a reference to a spec for a direct - // dependency by prefixing the name of the package to match the version - // of with a $. - // https://docs.npmjs.com/cli/v8/configuring-npm/package-json#overrides - newSpec = `$${sockRegDepAlias ? sockRegPkgName : origPkgName}` - } else if (overrideExists) { - const thisSpec = oldSpec.startsWith('$') - ? depAlias || newSpec - : oldSpec || newSpec - if (thisSpec.startsWith(sockOverridePrefix)) { - if ( - pin && - semver.major( - semver.coerce(npa(thisSpec).rawSpec)?.version ?? version - ) !== major - ) { - const otherVersion = (await fetchPackageManifest(thisSpec)) - ?.version - if (otherVersion && otherVersion !== version) { - newSpec = `${sockOverridePrefix}${pin ? otherVersion : `^${semver.major(otherVersion)}`}` - } - } - } else { - newSpec = oldSpec - } - } - if (newSpec !== oldSpec) { - overrides[origPkgName] = newSpec - const addedOrUpdated = overrideExists ? 'updated' : 'added' - state[addedOrUpdated].add(sockRegPkgName) - } - } - }) - } - }) - if (workspaceGlobs) { - const workspacePkgJsonPaths = await tinyGlob(workspaceGlobs, { - absolute: true, - cwd: pkgPath!, - ignore: ['**/node_modules/**', '**/bower_components/**'] - }) - // Chunk package names to process them in parallel 3 at a time. - await pEach(workspacePkgJsonPaths, 3, async workspacePkgJsonPath => { - const otherState = await addOverrides( - { - agent, - agentExecPath, - lockBasename, - lockSrc, - manifestEntries, - npmExecPath, - pin, - pkgPath: path.dirname(workspacePkgJsonPath), - prod, - rootPath - }, - createAddOverridesState({ spinner }) - ) - for (const key of [ - 'added', - 'addedInWorkspaces', - 'updated', - 'updatedInWorkspaces' - ]) { - for (const value of (otherState as any)[key]) { - ;(state as any)[key].add(value) - } - } - }) - } - if (state.added.size > 0 || state.updated.size > 0) { - editablePkgJson.update(Object.fromEntries(depEntries)) - for (const { overrides, type } of overridesDataObjects) { - updateManifestByAgent[type](editablePkgJson, toSortedObject(overrides)) - } - await editablePkgJson.save() - } - return state -} - -export const optimizeCommand: CliSubcommand = { - description, - async run(argv, importMeta, { parentName }) { - const commandContext = setupCommand( - `${parentName} optimize`, - description, - argv, - importMeta - ) - if (!commandContext) { - return - } - const { pin, prod } = commandContext - const cwd = process.cwd() - const { - agent, - agentExecPath, - agentVersion, - lockBasename, - lockPath, - lockSrc, - minimumNodeVersion, - npmExecPath, - pkgJson, - pkgPath, - supported - } = await detect({ - cwd, - onUnknown(pkgManager: string | undefined) { - console.warn( - `⚠️ ${COMMAND_TITLE}: Unknown package manager${pkgManager ? ` ${pkgManager}` : ''}, defaulting to npm` - ) - } - }) - if (!supported) { - console.error( - `✖️ ${COMMAND_TITLE}: No supported Node or browser range detected` - ) - return - } - if (agent === VLT) { - console.error( - `✖️ ${COMMAND_TITLE}: ${agent} does not support overrides. Soon, though ⚡` - ) - return - } - const lockName = lockPath ? lockBasename : 'lock file' - if (lockBasename === undefined || lockSrc === undefined) { - console.error(`✖️ ${COMMAND_TITLE}: No ${lockName} found`) - return - } - if (lockSrc.trim() === '') { - console.error(`✖️ ${COMMAND_TITLE}: ${lockName} is empty`) - return - } - if (pkgPath === undefined) { - console.error(`✖️ ${COMMAND_TITLE}: No package.json found`) - return - } - if (prod && (agent === BUN || agent === YARN_BERRY)) { - console.error( - `✖️ ${COMMAND_TITLE}: --prod not supported for ${agent}${agentVersion ? `@${agentVersion.toString()}` : ''}` - ) - return - } - if (lockPath && path.relative(cwd, lockPath).startsWith('.')) { - console.warn( - `⚠️ ${COMMAND_TITLE}: Package ${lockName} found at ${lockPath}` - ) - } - const spinner = new Spinner({ text: 'Socket optimizing...' }) - const state = createAddOverridesState({ spinner }) - spinner.start() - const nodeRange = `>=${minimumNodeVersion}` - const manifestEntries = manifestNpmOverrides.filter(({ 1: data }) => - semver.satisfies(semver.coerce(data.engines.node)!, nodeRange) - ) - await addOverrides( - { - agent, - agentExecPath, - lockBasename, - lockSrc, - manifestEntries, - npmExecPath, - pin, - pkgJson, - pkgPath, - prod, - rootPath: pkgPath - }, - state - ) - spinner.stop() - const addedCount = state.added.size - const updatedCount = state.updated.size - const pkgJsonChanged = addedCount > 0 || updatedCount > 0 - if (pkgJsonChanged) { - if (updatedCount > 0) { - console.log( - `${createActionMessage('Updated', updatedCount, state.updatedInWorkspaces.size)}${addedCount ? '.' : '🚀'}` - ) - } - if (addedCount > 0) { - console.log( - `${createActionMessage('Added', addedCount, state.addedInWorkspaces.size)} 🚀` - ) - } - } else { - console.log('Congratulations! Already Socket.dev optimized 🎉') - } - const isNpm = agent === NPM - if (isNpm || pkgJsonChanged) { - // Always update package-lock.json until the npm overrides PR lands: - // https://github.com/npm/cli/pull/7025 - spinner.start(`Updating ${lockName}...`) - try { - if (isNpm) { - const ipc = { - [SOCKET_IPC_HANDSHAKE]: { - [SOCKET_CLI_IN_OPTIMIZE_CMD]: true - } - } - await shadowNpmInstall({ - flags: ['--ignore-scripts'], - ipc - }) - // TODO: This is a temporary workaround for a `npm ci` bug where it - // will error out after Socket Optimize generates a lock file. More - // investigation is needed. - await shadowNpmInstall({ - flags: ['--ignore-scripts', '--package-lock-only'], - ipc - }) - } else { - // All package managers support the "install" command. - await spawn(agentExecPath, ['install'], { - signal: abortSignal, - stdio: 'ignore' - }) - } - spinner.stop() - if (isNpm) { - console.log( - `💡 Re-run ${COMMAND_TITLE} whenever ${lockName} changes.\n This can be skipped once npm ships ${NPM_OVERRIDE_PR_URL}.` - ) - } - } catch (e: any) { - spinner.error( - `${COMMAND_TITLE}: ${agent} install failed to update ${lockName}` - ) - console.error(e) - } - } - } -} - -// Internal functions - -type CommandContext = { - pin: boolean - prod: boolean -} - -function setupCommand( - name: string, - description: string, - argv: readonly string[], - importMeta: ImportMeta -): CommandContext | undefined { - const flags: { [key: string]: any } = { - ...commonFlags, - pin: { - type: 'boolean', - default: false, - description: 'Pin overrides to their latest version' - }, - prod: { - type: 'boolean', - default: false, - description: 'Only add overrides for production dependencies' - } - } - const cli = meow( - ` - Usage - $ ${name} - - Options - ${getFlagListOutput(flags, 6)} - - Examples - $ ${name} - `, - { - argv, - description, - importMeta, - flags - } - ) - const { help, pin, prod } = cli.flags - if (help) { - cli.showHelp() - return - } - return { - pin, - prod - } -} diff --git a/src/commands/optimize/apply-optimization.ts b/src/commands/optimize/apply-optimization.ts new file mode 100644 index 000000000..1e7e1d7de --- /dev/null +++ b/src/commands/optimize/apply-optimization.ts @@ -0,0 +1,359 @@ +import path from 'node:path' + +import npa from 'npm-package-arg' +import semver from 'semver' +import { glob as tinyGlob } from 'tinyglobby' + +import { type ManifestEntry } from '@socketsecurity/registry' +import { hasOwn, toSortedObject } from '@socketsecurity/registry/lib/objects' +import { + fetchPackageManifest, + readPackageJson +} from '@socketsecurity/registry/lib/packages' +import { type EditablePackageJson } from '@socketsecurity/registry/lib/packages' +import { pEach } from '@socketsecurity/registry/lib/promises' +import { Spinner } from '@socketsecurity/registry/lib/spinner' +import { pluralize } from '@socketsecurity/registry/lib/words' + +import { depsIncludesByAgent } from './deps-includes-by-agent.ts' +import { detectAndValidatePackageManager } from './detect-and-validate-package-manager.ts' +import { getDependencyEntries } from './get-dependency-entries.ts' +import { getOverridesDataByAgent } from './get-overrides-by-agent.ts' +import { getWorkspaceGlobs } from './get-workspace-globs.ts' +import { + AgentLockIncludesFn, + lockIncludesByAgent +} from './lock-includes-by-agent.ts' +import { lsByAgent } from './ls-by-agent.ts' +import { updateManifestByAgent } from './update-manifest-by-agent.ts' +import { updatePackageLockJson } from './update-package-lock-json.ts' +import constants from '../../constants.ts' + +import type { + Agent, + StringKeyValueObject +} from '../../utils/package-manager-detector.ts' + +type PackageJson = Awaited> + +type AddOverridesConfig = { + agent: Agent + agentExecPath: string + lockBasename: string + lockSrc: string + manifestEntries: ManifestEntry[] + npmExecPath: string + pkgJson?: EditablePackageJson | undefined + pkgPath: string + pin?: boolean | undefined + prod?: boolean | undefined + rootPath: string +} + +type AddOverridesState = { + added: Set + addedInWorkspaces: Set + spinner: Spinner + updated: Set + updatedInWorkspaces: Set + warnedPnpmWorkspaceRequiresNpm: boolean +} + +type NpmOverrides = { [key: string]: string | StringKeyValueObject } +type PnpmOrYarnOverrides = { [key: string]: string } +type Overrides = NpmOverrides | PnpmOrYarnOverrides +type GetOverridesResult = { type: Agent; overrides: Overrides } + +const { NPM, PNPM, YARN_CLASSIC } = constants + +const COMMAND_TITLE = 'Socket Optimize' + +export async function applyOptimization( + cwd: string, + pin: boolean, + prod: boolean +) { + const pkgMgrData = await detectAndValidatePackageManager(cwd, prod) + if (!pkgMgrData) return + + const { + agent, + agentExecPath, + lockBasename, + lockName, + lockSrc, + manifestEntries, + npmExecPath, + pkgJson, + pkgPath + } = pkgMgrData + + const spinner = new Spinner({ text: 'Socket optimizing...' }) + spinner.start() + + const state = await addOverrides( + { + agent, + agentExecPath, + lockBasename, + lockSrc, + manifestEntries, + npmExecPath, + pin, + pkgJson, + pkgPath, + prod, + rootPath: pkgPath + }, + createAddOverridesState(spinner) + ) + + spinner.stop() + + const addedCount = state.added.size + const updatedCount = state.updated.size + const pkgJsonChanged = addedCount > 0 || updatedCount > 0 + if (pkgJsonChanged) { + if (updatedCount > 0) { + console.log( + `${createActionMessage('Updated', updatedCount, state.updatedInWorkspaces.size)}${addedCount ? '.' : '🚀'}` + ) + } + if (addedCount > 0) { + console.log( + `${createActionMessage('Added', addedCount, state.addedInWorkspaces.size)} 🚀` + ) + } + } else { + console.log('Congratulations! Already Socket.dev optimized 🎉') + } + + if (agent === NPM || pkgJsonChanged) { + // Always update package-lock.json until the npm overrides PR lands: + // https://github.com/npm/cli/pull/7025 + await updatePackageLockJson(lockName, agentExecPath, agent, spinner) + } +} + +function createActionMessage( + verb: string, + overrideCount: number, + workspaceCount: number +): string { + return `${verb} ${overrideCount} Socket.dev optimized ${pluralize('override', overrideCount)}${workspaceCount ? ` in ${workspaceCount} ${pluralize('workspace', workspaceCount)}` : ''}` +} + +function createAddOverridesState(spinner: Spinner): AddOverridesState { + return { + added: new Set(), + addedInWorkspaces: new Set(), + spinner, + updated: new Set(), + updatedInWorkspaces: new Set(), + warnedPnpmWorkspaceRequiresNpm: false + } +} + +async function addOverrides( + { + agent, + agentExecPath, + lockBasename, + lockSrc, + manifestEntries, + npmExecPath, + pin, + pkgJson: editablePkgJson, + pkgPath, + prod, + rootPath + }: AddOverridesConfig, + state: AddOverridesState +): Promise { + if (editablePkgJson === undefined) { + editablePkgJson = await readPackageJson(pkgPath, { editable: true }) + } + const { spinner } = state + const { content: pkgJson } = editablePkgJson + const isRoot = pkgPath === rootPath + const isLockScanned = isRoot && !prod + const workspaceName = path.relative(rootPath, pkgPath) + const workspaceGlobs = await getWorkspaceGlobs(agent, pkgPath, pkgJson) + const isWorkspace = !!workspaceGlobs + if ( + isWorkspace && + agent === PNPM && + npmExecPath === NPM && + !state.warnedPnpmWorkspaceRequiresNpm + ) { + state.warnedPnpmWorkspaceRequiresNpm = true + console.warn( + `⚠️ ${COMMAND_TITLE}: pnpm workspace support requires \`npm ls\`, falling back to \`pnpm list\`` + ) + } + const thingToScan = isLockScanned + ? lockSrc + : await lsByAgent[agent](agentExecPath, pkgPath, { npmExecPath }) + // The AgentDepsIncludesFn and AgentLockIncludesFn types overlap in their + // first two parameters. AgentLockIncludesFn accepts an optional third + // parameter which AgentDepsIncludesFn will ignore so we cast thingScanner + // as an AgentLockIncludesFn type. + const thingScanner = ( + (isLockScanned ? lockIncludesByAgent[agent] : depsIncludesByAgent[agent]) + ) + const depEntries = getDependencyEntries(pkgJson) + + const overridesDataObjects = [] + if (pkgJson['private'] || isWorkspace) { + overridesDataObjects.push(getOverridesDataByAgent[agent](pkgJson)) + } else { + overridesDataObjects.push( + getOverridesDataByAgent[NPM](pkgJson), + getOverridesDataByAgent[YARN_CLASSIC](pkgJson) + ) + } + if (spinner) { + spinner.text = `Adding overrides${workspaceName ? ` to ${workspaceName}` : ''}...` + } + const depAliasMap = new Map() + // Chunk package names to process them in parallel 3 at a time. + await pEach(manifestEntries, 3, async ({ 1: data }) => { + const { name: sockRegPkgName, package: origPkgName, version } = data + const major = semver.major(version) + const sockOverridePrefix = `${NPM}:${sockRegPkgName}@` + const sockOverrideSpec = `${sockOverridePrefix}${pin ? version : `^${major}`}` + for (const { 1: depObj } of depEntries) { + const sockSpec = hasOwn(depObj, sockRegPkgName) + ? depObj[sockRegPkgName] + : undefined + if (sockSpec) { + depAliasMap.set(sockRegPkgName, sockSpec) + } + const origSpec = hasOwn(depObj, origPkgName) + ? depObj[origPkgName] + : undefined + if (origSpec) { + let thisSpec = origSpec + // Add package aliases for direct dependencies to avoid npm EOVERRIDE errors. + // https://docs.npmjs.com/cli/v8/using-npm/package-spec#aliases + if ( + !( + thisSpec.startsWith(sockOverridePrefix) && + semver.coerce(npa(thisSpec).rawSpec)?.version + ) + ) { + thisSpec = sockOverrideSpec + depObj[origPkgName] = thisSpec + state.added.add(sockRegPkgName) + if (workspaceName) { + state.addedInWorkspaces.add(workspaceName) + } + } + depAliasMap.set(origPkgName, thisSpec) + } + } + if (isRoot) { + // Chunk package names to process them in parallel 3 at a time. + await pEach(overridesDataObjects, 3, async ({ overrides, type }) => { + const overrideExists = hasOwn(overrides, origPkgName) + if ( + overrideExists || + thingScanner(thingToScan, origPkgName, lockBasename) + ) { + const oldSpec = overrideExists ? overrides[origPkgName] : undefined + const origDepAlias = depAliasMap.get(origPkgName) + const sockRegDepAlias = depAliasMap.get(sockRegPkgName) + const depAlias = sockRegDepAlias ?? origDepAlias + let newSpec = sockOverrideSpec + if (type === NPM && depAlias) { + // With npm one may not set an override for a package that one directly + // depends on unless both the dependency and the override itself share + // the exact same spec. To make this limitation easier to deal with, + // overrides may also be defined as a reference to a spec for a direct + // dependency by prefixing the name of the package to match the version + // of with a $. + // https://docs.npmjs.com/cli/v8/configuring-npm/package-json#overrides + newSpec = `$${sockRegDepAlias ? sockRegPkgName : origPkgName}` + } else if (overrideExists) { + const thisSpec = oldSpec.startsWith('$') + ? depAlias || newSpec + : oldSpec || newSpec + if (thisSpec.startsWith(sockOverridePrefix)) { + if ( + pin && + semver.major( + semver.coerce(npa(thisSpec).rawSpec)?.version ?? version + ) !== major + ) { + const otherVersion = (await fetchPackageManifest(thisSpec)) + ?.version + if (otherVersion && otherVersion !== version) { + newSpec = `${sockOverridePrefix}${pin ? otherVersion : `^${semver.major(otherVersion)}`}` + } + } + } else { + newSpec = oldSpec + } + } + if (newSpec !== oldSpec) { + overrides[origPkgName] = newSpec + const addedOrUpdated = overrideExists ? 'updated' : 'added' + state[addedOrUpdated].add(sockRegPkgName) + } + } + }) + } + }) + if (workspaceGlobs) { + const workspacePkgJsonPaths = await tinyGlob(workspaceGlobs, { + absolute: true, + cwd: pkgPath!, + ignore: ['**/node_modules/**', '**/bower_components/**'] + }) + // Chunk package names to process them in parallel 3 at a time. + await pEach(workspacePkgJsonPaths, 3, async workspacePkgJsonPath => { + const otherState = await addOverrides( + { + agent, + agentExecPath, + lockBasename, + lockSrc, + manifestEntries, + npmExecPath, + pin, + pkgPath: path.dirname(workspacePkgJsonPath), + prod, + rootPath + }, + createAddOverridesState(spinner) + ) + for (const key of [ + 'added', + 'addedInWorkspaces', + 'updated', + 'updatedInWorkspaces' + ] satisfies + // Here we're just telling TS that we're looping over key names + // of the type and that they're all Set props. This allows + // us to do the SetA.add(setB.get) pump type-safe without casts. + Array< + keyof Pick< + AddOverridesState, + 'added' | 'addedInWorkspaces' | 'updated' | 'updatedInWorkspaces' + > + >) { + for (const value of otherState[key]) { + state[key].add(value) + } + } + }) + } + if (state.added.size > 0 || state.updated.size > 0) { + editablePkgJson.update(Object.fromEntries(depEntries)) + for (const { overrides, type } of overridesDataObjects) { + updateManifestByAgent[type](editablePkgJson, toSortedObject(overrides)) + } + await editablePkgJson.save() + } + return state +} diff --git a/src/commands/optimize/cmd-optimize.ts b/src/commands/optimize/cmd-optimize.ts new file mode 100644 index 000000000..467226f66 --- /dev/null +++ b/src/commands/optimize/cmd-optimize.ts @@ -0,0 +1,65 @@ +import process from 'node:process' + +import meowOrExit from 'meow' + +import { applyOptimization } from './apply-optimization.ts' +import { commonFlags } from '../../flags' +import { getFlagListOutput } from '../../utils/output-formatting' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.ts' + +const config: CliCommandConfig = { + commandName: 'create', + description: 'Optimize dependencies with @socketregistry overrides', + hidden: false, + flags: { + ...commonFlags, + pin: { + type: 'boolean', + default: false, + description: 'Pin overrides to their latest version' + }, + prod: { + type: 'boolean', + default: false, + description: 'Only add overrides for production dependencies' + } + }, + help: (parentName, config) => ` + Usage + $ ${parentName} ${config.commandName} + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${parentName} ${config.commandName} + ` +} + +export const cmdOptimize = { + description: config.description, + hidden: config.hidden, + run +} + +async function run( + argv: readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string } +): Promise { + const cli = meowOrExit(config.help(parentName, config), { + argv, + description: config.description, + importMeta, + flags: config.flags + }) + + const cwd = process.cwd() + + await applyOptimization( + cwd, + Boolean(cli.flags['pin']), + Boolean(cli.flags['prod']) + ) +} diff --git a/src/commands/optimize/deps-includes-by-agent.ts b/src/commands/optimize/deps-includes-by-agent.ts new file mode 100644 index 000000000..1e86fef6e --- /dev/null +++ b/src/commands/optimize/deps-includes-by-agent.ts @@ -0,0 +1,27 @@ +import constants from '../../constants.ts' + +import type { Agent } from '../../utils/package-manager-detector.ts' + +type AgentDepsIncludesFn = (stdout: string, name: string) => boolean + +const { BUN, NPM, PNPM, VLT, YARN_BERRY, YARN_CLASSIC } = constants + +function matchHumanStdout(stdout: string, name: string) { + return stdout.includes(` ${name}@`) +} + +function matchQueryStdout(stdout: string, name: string) { + return stdout.includes(`"${name}"`) +} + +export const depsIncludesByAgent: Record = { + // @ts-ignore + __proto__: null, + + [BUN]: matchHumanStdout, + [NPM]: matchQueryStdout, + [PNPM]: matchQueryStdout, + [VLT]: matchQueryStdout, + [YARN_BERRY]: matchHumanStdout, + [YARN_CLASSIC]: matchHumanStdout +} diff --git a/src/commands/optimize/detect-and-validate-package-manager.ts b/src/commands/optimize/detect-and-validate-package-manager.ts new file mode 100644 index 000000000..3c559fd7f --- /dev/null +++ b/src/commands/optimize/detect-and-validate-package-manager.ts @@ -0,0 +1,109 @@ +import path from 'node:path' + +import semver from 'semver' + +import { ManifestEntry, getManifestData } from '@socketsecurity/registry' + +import constants from '../../constants.ts' +import { detect } from '../../utils/package-manager-detector.ts' + +const { BUN, NPM, VLT, YARN_BERRY } = constants + +const COMMAND_TITLE = 'Socket Optimize' + +const manifestNpmOverrides = getManifestData(NPM) + +export async function detectAndValidatePackageManager( + cwd: string, + prod: boolean +): Promise< + | void + | (Pick< + Awaited>, + | 'agent' + | 'agentExecPath' + | 'lockBasename' + | 'lockSrc' + | 'npmExecPath' + | 'pkgJson' + | 'pkgPath' + > & { + lockName: string + manifestEntries: Array + lockBasename: string + lockSrc: string + pkgPath: string + }) +> { + const { + agent, + agentExecPath, + agentVersion, + lockBasename, + lockPath, + lockSrc, + minimumNodeVersion, + npmExecPath, + pkgJson, + pkgPath, + supported + } = await detect({ + cwd, + onUnknown(pkgManager: string | undefined) { + console.warn( + `⚠️ ${COMMAND_TITLE}: Unknown package manager${pkgManager ? ` ${pkgManager}` : ''}, defaulting to npm` + ) + } + }) + if (!supported) { + console.error( + `✖️ ${COMMAND_TITLE}: No supported Node or browser range detected` + ) + return + } + if (agent === VLT) { + console.error( + `✖️ ${COMMAND_TITLE}: ${agent} does not support overrides. Soon, though ⚡` + ) + return + } + const lockName = lockPath && lockBasename ? lockBasename : 'lock file' + if (lockBasename === undefined || lockSrc === undefined) { + console.error(`✖️ ${COMMAND_TITLE}: No ${lockName} found`) + return + } + if (lockSrc.trim() === '') { + console.error(`✖️ ${COMMAND_TITLE}: ${lockName} is empty`) + return + } + if (pkgPath === undefined) { + console.error(`✖️ ${COMMAND_TITLE}: No package.json found`) + return + } + if (prod && (agent === BUN || agent === YARN_BERRY)) { + console.error( + `✖️ ${COMMAND_TITLE}: --prod not supported for ${agent}${agentVersion ? `@${agentVersion.toString()}` : ''}` + ) + return + } + if (lockPath && path.relative(cwd, lockPath).startsWith('.')) { + console.warn(`⚠️ ${COMMAND_TITLE}: Package ${lockName} found at ${lockPath}`) + } + + const nodeRange = `>=${minimumNodeVersion}` + const manifestEntries = manifestNpmOverrides.filter(({ 1: data }) => + semver.satisfies(semver.coerce(data.engines.node)!, nodeRange) + ) + + return { + agent, + agentExecPath, + lockBasename, + lockName, + lockSrc, + manifestEntries, + npmExecPath, + pkgJson, + pkgPath + } +} diff --git a/src/commands/optimize/get-dependency-entries.ts b/src/commands/optimize/get-dependency-entries.ts new file mode 100644 index 000000000..833bbe515 --- /dev/null +++ b/src/commands/optimize/get-dependency-entries.ts @@ -0,0 +1,32 @@ +import { readPackageJson } from '@socketsecurity/registry/lib/packages' + +type PackageJson = Awaited> + +export function getDependencyEntries(pkgJson: PackageJson) { + const { + dependencies, + devDependencies, + optionalDependencies, + peerDependencies + } = pkgJson + return <[string, NonNullable][]>[ + [ + 'dependencies', + dependencies ? { __proto__: null, ...dependencies } : undefined + ], + [ + 'devDependencies', + devDependencies ? { __proto__: null, ...devDependencies } : undefined + ], + [ + 'peerDependencies', + peerDependencies ? { __proto__: null, ...peerDependencies } : undefined + ], + [ + 'optionalDependencies', + optionalDependencies + ? { __proto__: null, ...optionalDependencies } + : undefined + ] + ].filter(({ 1: o }) => o) +} diff --git a/src/commands/optimize/get-overrides-by-agent.ts b/src/commands/optimize/get-overrides-by-agent.ts new file mode 100644 index 000000000..38d7dc759 --- /dev/null +++ b/src/commands/optimize/get-overrides-by-agent.ts @@ -0,0 +1,76 @@ +import { readPackageJson } from '@socketsecurity/registry/lib/packages' + +import constants from '../../constants.ts' + +import type { + Agent, + StringKeyValueObject +} from '../../utils/package-manager-detector.ts' + +type PackageJson = Awaited> +type NpmOverrides = { [key: string]: string | StringKeyValueObject } +type PnpmOrYarnOverrides = { [key: string]: string } +type Overrides = NpmOverrides | PnpmOrYarnOverrides +type GetOverrides = (pkgJson: PackageJson) => GetOverridesResult +type GetOverridesResult = { type: Agent; overrides: Overrides } + +const { + BUN, + NPM, + OVERRIDES, + PNPM, + RESOLUTIONS, + VLT, + YARN_BERRY, + YARN_CLASSIC +} = constants + +function getOverridesDataBun(pkgJson: PackageJson) { + const overrides = (pkgJson as any)?.[RESOLUTIONS] ?? {} + return { type: YARN_BERRY, overrides } +} + +// npm overrides documentation: +// https://docs.npmjs.com/cli/v10/configuring-npm/package-json#overrides +function getOverridesDataNpm(pkgJson: PackageJson) { + const overrides = (pkgJson as any)?.[OVERRIDES] ?? {} + return { type: NPM, overrides } +} + +// pnpm overrides documentation: +// https://pnpm.io/package_json#pnpmoverrides +function getOverridesDataPnpm(pkgJson: PackageJson) { + const overrides = (pkgJson as any)?.pnpm?.[OVERRIDES] ?? {} + return { type: PNPM, overrides } +} + +function getOverridesDataVlt(pkgJson: PackageJson) { + const overrides = (pkgJson as any)?.[OVERRIDES] ?? {} + return { type: VLT, overrides } +} + +// Yarn resolutions documentation: +// https://yarnpkg.com/configuration/manifest#resolutions +function getOverridesDataYarn(pkgJson: PackageJson) { + const overrides = (pkgJson as any)?.[RESOLUTIONS] ?? {} + return { type: YARN_BERRY, overrides } +} + +// Yarn resolutions documentation: +// https://classic.yarnpkg.com/en/docs/selective-version-resolutions +function getOverridesDataClassic(pkgJson: PackageJson) { + const overrides = (pkgJson as any)?.[RESOLUTIONS] ?? {} + return { type: YARN_CLASSIC, overrides } +} + +export const getOverridesDataByAgent: Record = { + // @ts-ignore + __proto__: null, + + [BUN]: getOverridesDataBun, + [NPM]: getOverridesDataNpm, + [PNPM]: getOverridesDataPnpm, + [VLT]: getOverridesDataVlt, + [YARN_BERRY]: getOverridesDataYarn, + [YARN_CLASSIC]: getOverridesDataClassic +} diff --git a/src/commands/optimize/get-workspace-globs.ts b/src/commands/optimize/get-workspace-globs.ts new file mode 100644 index 000000000..ea0625845 --- /dev/null +++ b/src/commands/optimize/get-workspace-globs.ts @@ -0,0 +1,70 @@ +import path from 'node:path' + +import { parse as yamlParse } from 'yaml' + +import { readPackageJson } from '@socketsecurity/registry/lib/packages' +import { isNonEmptyString } from '@socketsecurity/registry/lib/strings' + +import constants from '../../constants.ts' +import { safeReadFile } from '../../utils/fs.ts' + +import type { Agent } from '../../utils/package-manager-detector.ts' + +type PackageJson = Awaited> + +const { PNPM } = constants + +const PNPM_WORKSPACE = `${PNPM}-workspace` + +export async function getWorkspaceGlobs( + agent: Agent, + pkgPath: string, + pkgJson: PackageJson +): Promise { + let workspacePatterns + if (agent === PNPM) { + for (const workspacePath of [ + path.join(pkgPath!, `${PNPM_WORKSPACE}.yaml`), + path.join(pkgPath!, `${PNPM_WORKSPACE}.yml`) + ]) { + // eslint-disable-next-line no-await-in-loop + const yml = await safeReadFile(workspacePath, 'utf8') + if (yml) { + try { + workspacePatterns = yamlParse(yml)?.packages + } catch {} + if (workspacePatterns) { + break + } + } + } + } else { + workspacePatterns = pkgJson['workspaces'] + } + return Array.isArray(workspacePatterns) + ? workspacePatterns + .filter(isNonEmptyString) + .map(workspacePatternToGlobPattern) + : undefined +} + +function workspacePatternToGlobPattern(workspace: string): string { + const { length } = workspace + if (!length) { + return '' + } + // If the workspace ends with "/" + if (workspace.charCodeAt(length - 1) === 47 /*'/'*/) { + return `${workspace}/*/package.json` + } + // If the workspace ends with "/**" + if ( + workspace.charCodeAt(length - 1) === 42 /*'*'*/ && + workspace.charCodeAt(length - 2) === 42 /*'*'*/ && + workspace.charCodeAt(length - 3) === 47 /*'/'*/ + ) { + return `${workspace}/*/**/package.json` + } + // Things like "packages/a" or "packages/*" + return `${workspace}/package.json` +} diff --git a/src/commands/optimize/lock-includes-by-agent.ts b/src/commands/optimize/lock-includes-by-agent.ts new file mode 100644 index 000000000..97d6a90b6 --- /dev/null +++ b/src/commands/optimize/lock-includes-by-agent.ts @@ -0,0 +1,74 @@ +import { escapeRegExp } from '@socketsecurity/registry/lib/regexps' + +import constants from '../../constants.ts' + +import type { Agent } from '../../utils/package-manager-detector.ts' + +export type AgentLockIncludesFn = ( + lockSrc: string, + name: string, + ext?: string +) => boolean + +const { BUN, LOCK_EXT, NPM, PNPM, VLT, YARN_BERRY, YARN_CLASSIC } = constants + +function lockIncludesNpm(lockSrc: string, name: string) { + // Detects the package name in the following cases: + // "name": + return lockSrc.includes(`"${name}":`) +} + +function lockIncludesBun(lockSrc: string, name: string, lockBasename?: string) { + // This is a bit counterintuitive. When lockBasename ends with a .lockb + // we treat it as a yarn.lock. When lockBasename ends with a .lock we + // treat it as a package-lock.json. The bun.lock format is not identical + // package-lock.json, however it close enough for npmLockIncludes to work. + const lockScanner = lockBasename?.endsWith(LOCK_EXT) + ? lockIncludesNpm + : lockIncludesYarn + return lockScanner(lockSrc, name) +} + +function lockIncludesPnpm(lockSrc: string, name: string) { + const escapedName = escapeRegExp(name) + return new RegExp( + // Detects the package name in the following cases: + // /name/ + // 'name' + // name: + // name@ + `(?<=^\\s*)(?:(['/])${escapedName}\\1|${escapedName}(?=[:@]))`, + 'm' + ).test(lockSrc) +} + +function lockIncludesVlt(lockSrc: string, name: string) { + // Detects the package name in the following cases: + // "name" + return lockSrc.includes(`"${name}"`) +} + +function lockIncludesYarn(lockSrc: string, name: string) { + const escapedName = escapeRegExp(name) + return new RegExp( + // Detects the package name in the following cases: + // "name@ + // , "name@ + // name@ + // , name@ + `(?<=(?:^\\s*|,\\s*)"?)${escapedName}(?=@)`, + 'm' + ).test(lockSrc) +} + +export const lockIncludesByAgent: Record = { + // @ts-ignore + __proto__: null, + + [BUN]: lockIncludesBun, + [NPM]: lockIncludesNpm, + [PNPM]: lockIncludesPnpm, + [VLT]: lockIncludesVlt, + [YARN_BERRY]: lockIncludesYarn, + [YARN_CLASSIC]: lockIncludesYarn +} diff --git a/src/commands/optimize/ls-by-agent.ts b/src/commands/optimize/ls-by-agent.ts new file mode 100644 index 000000000..d566dd700 --- /dev/null +++ b/src/commands/optimize/ls-by-agent.ts @@ -0,0 +1,157 @@ +import spawn from '@npmcli/promise-spawn' + +import constants from '../../constants.ts' + +import type { Agent } from '../../utils/package-manager-detector.ts' + +type AgentListDepsOptions = { npmExecPath?: string } + +type AgentListDepsFn = ( + agentExecPath: string, + cwd: string, + options?: AgentListDepsOptions +) => Promise + +const { BUN, NPM, PNPM, VLT, YARN_BERRY, YARN_CLASSIC } = constants + +function cleanupQueryStdout(stdout: string): string { + if (stdout === '') { + return '' + } + let pkgs + try { + pkgs = JSON.parse(stdout) + } catch {} + if (!Array.isArray(pkgs)) { + return '' + } + const names = new Set() + for (const { _id, name, pkgid } of pkgs) { + // `npm query` results may not have a "name" property, in which case we + // fallback to "_id" and then "pkgid". + // `vlt ls --view json` results always have a "name" property. + const fallback = _id ?? pkgid ?? '' + const resolvedName = name ?? fallback.slice(0, fallback.indexOf('@', 1)) + // Add package names, except for those under the `@types` scope as those + // are known to only be dev dependencies. + if (resolvedName && !resolvedName.startsWith('@types/')) { + names.add(resolvedName) + } + } + return JSON.stringify([...names], null, 2) +} + +function parseableToQueryStdout(stdout: string) { + if (stdout === '') { + return '' + } + // Convert the parseable stdout into a json array of unique names. + // The matchAll regexp looks for a forward (posix) or backward (win32) slash + // and matches one or more non-slashes until the newline. + const names = new Set(stdout.matchAll(/(?<=[/\\])[^/\\]+(?=\n)/g)) + return JSON.stringify([...names], null, 2) +} + +async function npmQuery(npmExecPath: string, cwd: string): Promise { + let stdout = '' + try { + stdout = (await spawn(npmExecPath, ['query', ':not(.dev)'], { cwd })).stdout + } catch {} + return cleanupQueryStdout(stdout) +} + +async function lsBun(agentExecPath: string, cwd: string): Promise { + try { + // Bun does not support filtering by production packages yet. + // https://github.com/oven-sh/bun/issues/8283 + return (await spawn(agentExecPath!, ['pm', 'ls', '--all'], { cwd })).stdout + } catch {} + return '' +} + +async function lsNpm(agentExecPath: string, cwd: string): Promise { + return await npmQuery(agentExecPath, cwd) +} + +async function lsPnpm( + agentExecPath: string, + cwd: string, + options?: AgentListDepsOptions +): Promise { + const npmExecPath = options?.npmExecPath + if (npmExecPath && npmExecPath !== NPM) { + const result = await npmQuery(npmExecPath, cwd) + if (result) { + return result + } + } + let stdout = '' + try { + stdout = ( + await spawn( + agentExecPath, + ['ls', '--parseable', '--prod', '--depth', 'Infinity'], + { cwd } + ) + ).stdout + } catch {} + return parseableToQueryStdout(stdout) +} + +async function lsVlt(agentExecPath: string, cwd: string): Promise { + let stdout = '' + try { + stdout = ( + await spawn(agentExecPath, ['ls', '--view', 'human', ':not(.dev)'], { + cwd + }) + ).stdout + } catch {} + return cleanupQueryStdout(stdout) +} + +async function lsYarnBerry( + agentExecPath: string, + cwd: string +): Promise { + try { + return ( + // Yarn Berry does not support filtering by production packages yet. + // https://github.com/yarnpkg/berry/issues/5117 + ( + await spawn(agentExecPath, ['info', '--recursive', '--name-only'], { + cwd + }) + ).stdout.trim() + ) + } catch {} + return '' +} + +async function lsYarnClassic( + agentExecPath: string, + cwd: string +): Promise { + try { + // However, Yarn Classic does support it. + // https://github.com/yarnpkg/yarn/releases/tag/v1.0.0 + // > Fix: Excludes dev dependencies from the yarn list output when the + // environment is production + return ( + await spawn(agentExecPath, ['list', '--prod'], { cwd }) + ).stdout.trim() + } catch {} + return '' +} + +export const lsByAgent: Record = { + // @ts-ignore + __proto__: null, + + [BUN]: lsBun, + [NPM]: lsNpm, + [PNPM]: lsPnpm, + [VLT]: lsVlt, + [YARN_BERRY]: lsYarnBerry, + [YARN_CLASSIC]: lsYarnClassic +} diff --git a/src/commands/optimize/update-manifest-by-agent.ts b/src/commands/optimize/update-manifest-by-agent.ts new file mode 100644 index 000000000..fc94f00e5 --- /dev/null +++ b/src/commands/optimize/update-manifest-by-agent.ts @@ -0,0 +1,182 @@ +import { hasKeys, isObject } from '@socketsecurity/registry/lib/objects' + +import constants from '../../constants.ts' + +import type { + Agent, + StringKeyValueObject +} from '../../utils/package-manager-detector.ts' +import type { EditablePackageJson } from '@socketsecurity/registry/lib/packages' + +type NpmOverrides = { [key: string]: string | StringKeyValueObject } +type PnpmOrYarnOverrides = { [key: string]: string } +type Overrides = NpmOverrides | PnpmOrYarnOverrides +type AgentModifyManifestFn = ( + pkgJson: EditablePackageJson, + overrides: Overrides +) => void + +const { + BUN, + NPM, + OVERRIDES, + PNPM, + RESOLUTIONS, + VLT, + YARN_BERRY, + YARN_CLASSIC +} = constants + +const PNPM_FIELD_NAME = PNPM + +const depFields = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'peerDependenciesMeta', + 'optionalDependencies', + 'bundleDependencies' +] + +function getEntryIndexes( + entries: [string | symbol, any][], + keys: (string | symbol)[] +): number[] { + return keys + .map(n => entries.findIndex(p => p[0] === n)) + .filter(n => n !== -1) + .sort((a, b) => a - b) +} + +function getLowestEntryIndex( + entries: [string | symbol, any][], + keys: (string | symbol)[] +) { + return getEntryIndexes(entries, keys)?.[0] ?? -1 +} + +function getHighestEntryIndex( + entries: [string | symbol, any][], + keys: (string | symbol)[] +) { + return getEntryIndexes(entries, keys).at(-1) ?? -1 +} + +function updatePkgJson( + editablePkgJson: EditablePackageJson, + field: string, + value: any +) { + const pkgJson = editablePkgJson.content + const oldValue = pkgJson[field] + if (oldValue) { + // The field already exists so we simply update the field value. + if (field === PNPM_FIELD_NAME) { + if (hasKeys(value)) { + editablePkgJson.update({ + [field]: { + ...(isObject(oldValue) ? oldValue : {}), + overrides: value + } + }) + } else { + // Properties with undefined values are omitted when saved as JSON. + editablePkgJson.update((hasKeys(pkgJson[field]) + ? { + [field]: { + ...(isObject(oldValue) ? oldValue : {}), + overrides: undefined + } + } + : { [field]: undefined })) + } + } else if (field === OVERRIDES || field === RESOLUTIONS) { + // Properties with undefined values are omitted when saved as JSON. + editablePkgJson.update({ + [field]: hasKeys(value) ? value : undefined + }) + } else { + editablePkgJson.update({ [field]: value }) + } + return + } + if ( + (field === OVERRIDES || + field === PNPM_FIELD_NAME || + field === RESOLUTIONS) && + !hasKeys(value) + ) { + return + } + // Since the field doesn't exist we want to insert it into the package.json + // in a place that makes sense, e.g. close to the "dependencies" field. If + // we can't find a place to insert the field we'll add it to the bottom. + const entries = Object.entries(pkgJson) + let insertIndex = -1 + let isPlacingHigher = false + if (field === OVERRIDES) { + insertIndex = getLowestEntryIndex(entries, [RESOLUTIONS]) + if (insertIndex === -1) { + isPlacingHigher = true + insertIndex = getHighestEntryIndex(entries, [...depFields, PNPM]) + } + } else if (field === RESOLUTIONS) { + isPlacingHigher = true + insertIndex = getHighestEntryIndex(entries, [...depFields, OVERRIDES, PNPM]) + } else if (field === PNPM_FIELD_NAME) { + insertIndex = getLowestEntryIndex(entries, [OVERRIDES, RESOLUTIONS]) + if (insertIndex === -1) { + isPlacingHigher = true + insertIndex = getHighestEntryIndex(entries, depFields) + } + } + if (insertIndex === -1) { + insertIndex = getLowestEntryIndex(entries, ['engines', 'files']) + } + if (insertIndex === -1) { + isPlacingHigher = true + insertIndex = getHighestEntryIndex(entries, ['exports', 'imports', 'main']) + } + if (insertIndex === -1) { + insertIndex = entries.length + } else if (isPlacingHigher) { + insertIndex += 1 + } + entries.splice(insertIndex, 0, [field, value]) + editablePkgJson.fromJSON( + `${JSON.stringify(Object.fromEntries(entries), null, 2)}\n` + ) +} + +function updateOverrides( + editablePkgJson: EditablePackageJson, + overrides: Overrides +) { + updatePkgJson(editablePkgJson, OVERRIDES, overrides) +} + +function updateResolutions( + editablePkgJson: EditablePackageJson, + overrides: Overrides +) { + updatePkgJson(editablePkgJson, RESOLUTIONS, overrides) +} + +function pnpmUpdatePkgJson( + editablePkgJson: EditablePackageJson, + overrides: Overrides +) { + updatePkgJson(editablePkgJson, PNPM_FIELD_NAME, overrides) +} + +export const updateManifestByAgent: Record = { + // @ts-ignore + __proto__: null, + + [BUN]: updateResolutions, + [NPM]: updateOverrides, + [PNPM]: pnpmUpdatePkgJson, + [VLT]: updateOverrides, + [YARN_BERRY]: updateResolutions, + [YARN_CLASSIC]: updateResolutions +} diff --git a/src/commands/optimize/update-package-lock-json.ts b/src/commands/optimize/update-package-lock-json.ts new file mode 100644 index 000000000..af8b7eefb --- /dev/null +++ b/src/commands/optimize/update-package-lock-json.ts @@ -0,0 +1,60 @@ +import spawn from '@npmcli/promise-spawn' + +import { Spinner } from '@socketsecurity/registry/lib/spinner' + +import constants from '../../constants.ts' +import { shadowNpmInstall } from '../../utils/npm.ts' + +import type { Agent } from '../../utils/package-manager-detector.ts' + +const { NPM, SOCKET_CLI_IN_OPTIMIZE_CMD, SOCKET_IPC_HANDSHAKE, abortSignal } = + constants + +const COMMAND_TITLE = 'Socket Optimize' +const NPM_OVERRIDE_PR_URL = 'https://github.com/npm/cli/pull/7025' + +export async function updatePackageLockJson( + lockName: string, + agentExecPath: string, + agent: Agent, + spinner: Spinner +) { + spinner.start(`Updating ${lockName}...`) + try { + if (agent === NPM) { + const ipc = { + [SOCKET_IPC_HANDSHAKE]: { + [SOCKET_CLI_IN_OPTIMIZE_CMD]: true + } + } + await shadowNpmInstall({ + flags: ['--ignore-scripts'], + ipc + }) + // TODO: This is a temporary workaround for a `npm ci` bug where it + // will error out after Socket Optimize generates a lock file. + // More investigation is needed. + await shadowNpmInstall({ + flags: ['--ignore-scripts', '--package-lock-only'], + ipc + }) + } else { + // All package managers support the "install" command. + await spawn(agentExecPath, ['install'], { + signal: abortSignal, + stdio: 'ignore' + }) + } + spinner.stop() + if (agent === NPM) { + console.log( + `💡 Re-run ${COMMAND_TITLE} whenever ${lockName} changes.\n This can be skipped once npm ships ${NPM_OVERRIDE_PR_URL}.` + ) + } + } catch (e: any) { + spinner.error( + `${COMMAND_TITLE}: ${agent} install failed to update ${lockName}` + ) + console.error(e) + } +} diff --git a/src/commands/organizations/cmd-organizations.ts b/src/commands/organizations/cmd-organizations.ts new file mode 100644 index 000000000..e13653cb8 --- /dev/null +++ b/src/commands/organizations/cmd-organizations.ts @@ -0,0 +1,37 @@ +import meowOrExit from 'meow' + +import { getOrganizations } from './get-organizations.ts' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.ts' + +const config: CliCommandConfig = { + commandName: 'organizations', + description: 'List organizations associated with the API key used', + hidden: false, + flags: {}, + help: (parentName, config) => ` + Usage + $ ${parentName} ${config.commandName} + ` +} + +export const cmdOrganizations = { + description: config.description, + hidden: config.hidden, + run +} + +async function run( + argv: readonly string[], + importMeta: ImportMeta, + { parentName }: { parentName: string } +): Promise { + meowOrExit(config.help(parentName, config), { + argv, + description: config.description, + importMeta, + flags: config.flags + }) + + await getOrganizations() +} diff --git a/src/commands/organization.ts b/src/commands/organizations/get-organizations.ts similarity index 55% rename from src/commands/organization.ts rename to src/commands/organizations/get-organizations.ts index 9ca9de5ab..494c4bc52 100644 --- a/src/commands/organization.ts +++ b/src/commands/organizations/get-organizations.ts @@ -1,46 +1,12 @@ -import meow from 'meow' import colors from 'yoctocolors-cjs' import { Spinner } from '@socketsecurity/registry/lib/spinner' -import { handleApiCall, handleUnsuccessfulApiResponse } from '../utils/api' -import { AuthError } from '../utils/errors' -import { getDefaultToken, setupSdk } from '../utils/sdk' +import { handleApiCall, handleUnsuccessfulApiResponse } from '../../utils/api' +import { AuthError } from '../../utils/errors' +import { getDefaultToken, setupSdk } from '../../utils/sdk' -import type { CliSubcommand } from '../utils/meow-with-subcommands' - -const description = 'List organizations associated with the API key used' - -export const organizationCommand: CliSubcommand = { - description, - async run(argv, importMeta, { parentName }) { - setupCommand(`${parentName} organizations`, description, argv, importMeta) - await fetchOrganizations() - } -} - -// Internal functions - -function setupCommand( - name: string, - description: string, - argv: readonly string[], - importMeta: ImportMeta -) { - meow( - ` - Usage - $ ${name} - `, - { - argv, - description, - importMeta - } - ) -} - -async function fetchOrganizations(): Promise { +export async function getOrganizations(): Promise { const apiToken = getDefaultToken() if (!apiToken) { throw new AuthError( diff --git a/src/commands/raw-npm.ts b/src/commands/raw-npm.ts deleted file mode 100644 index 216b9873a..000000000 --- a/src/commands/raw-npm.ts +++ /dev/null @@ -1,83 +0,0 @@ -import process from 'node:process' - -import spawn from '@npmcli/promise-spawn' -import meow from 'meow' - -import constants from '../constants' -import { commonFlags, validationFlags } from '../flags' -import { getNpmBinPath } from '../shadow/npm-paths' -import { getFlagListOutput } from '../utils/output-formatting' - -import type { CliSubcommand } from '../utils/meow-with-subcommands' - -const { NPM, abortSignal } = constants - -const binName = NPM - -const description = `Temporarily disable the Socket ${binName} wrapper` - -export const rawNpmCommand: CliSubcommand = { - description, - async run(argv, importMeta, { parentName }) { - await setupCommand( - `${parentName} raw-${binName}`, - description, - argv, - importMeta - ) - } -} - -async function setupCommand( - name: string, - description: string, - argv: readonly string[], - importMeta: ImportMeta -): Promise { - const flags: { [key: string]: any } = { - ...commonFlags, - ...validationFlags - } - const cli = meow( - ` - Usage - $ ${name} <${binName} command> - - Options - ${getFlagListOutput(flags, 6)} - - Examples - $ ${name} install - `, - { - argv, - description, - importMeta, - flags - } - ) - let showHelp = cli.flags['help'] - if (!argv[0]) { - showHelp = true - } - if (showHelp) { - cli.showHelp() - return - } - const spawnPromise = spawn(getNpmBinPath(), argv, { - signal: abortSignal, - stdio: 'inherit' - }) - // See https://nodejs.org/api/all.html#all_child_process_event-exit. - spawnPromise.process.on('exit', (code, signalName) => { - if (abortSignal.aborted) { - return - } - if (signalName) { - process.kill(process.pid, signalName) - } else if (code !== null) { - process.exit(code) - } - }) - await spawnPromise -} diff --git a/src/commands/raw-npm/cmd-raw-npm.ts b/src/commands/raw-npm/cmd-raw-npm.ts new file mode 100644 index 000000000..cfbd93af9 --- /dev/null +++ b/src/commands/raw-npm/cmd-raw-npm.ts @@ -0,0 +1,47 @@ +import meowOrExit from 'meow' + +import { runRawNpm } from './run-raw-npm.ts' +import constants from '../../constants' +import { getFlagListOutput } from '../../utils/output-formatting' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.ts' + +const { NPM } = constants + +const config: CliCommandConfig = { + commandName: 'raw-npm', + description: `Temporarily disable the Socket ${NPM} wrapper`, + hidden: false, + flags: {}, + help: (parentName, config) => ` + Usage + $ ${parentName} ${config.commandName} + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${parentName} ${config.commandName} install + ` +} + +export const cmdRawNpm = { + description: config.description, + hidden: config.hidden, + run +} + +async function run( + argv: ReadonlyArray, + importMeta: ImportMeta, + { parentName }: { parentName: string } +): Promise { + meowOrExit(config.help(parentName, config), { + argv, + description: config.description, + importMeta, + flags: config.flags + }) + + await runRawNpm(argv) +} diff --git a/src/commands/raw-npm/run-raw-npm.ts b/src/commands/raw-npm/run-raw-npm.ts new file mode 100644 index 000000000..1d2e1f9f8 --- /dev/null +++ b/src/commands/raw-npm/run-raw-npm.ts @@ -0,0 +1,27 @@ +import process from 'node:process' + +import spawn from '@npmcli/promise-spawn' + +import constants from '../../constants' +import { getNpmBinPath } from '../../shadow/npm-paths' + +const { abortSignal } = constants + +export async function runRawNpm(argv: ReadonlyArray): Promise { + const spawnPromise = spawn(getNpmBinPath(), argv.slice(0), { + signal: abortSignal, + stdio: 'inherit' + }) + // See https://nodejs.org/api/all.html#all_child_process_event-exit. + spawnPromise.process.on('exit', (code, signalName) => { + if (abortSignal.aborted) { + return + } + if (signalName) { + process.kill(process.pid, signalName) + } else if (code !== null) { + process.exit(code) + } + }) + await spawnPromise +} diff --git a/src/commands/raw-npx.ts b/src/commands/raw-npx.ts deleted file mode 100644 index affdd534c..000000000 --- a/src/commands/raw-npx.ts +++ /dev/null @@ -1,83 +0,0 @@ -import process from 'node:process' - -import spawn from '@npmcli/promise-spawn' -import meow from 'meow' - -import constants from '../constants' -import { commonFlags, validationFlags } from '../flags' -import { getNpxBinPath } from '../shadow/npm-paths' -import { getFlagListOutput } from '../utils/output-formatting' - -import type { CliSubcommand } from '../utils/meow-with-subcommands' - -const { NPX, abortSignal } = constants - -const binName = NPX - -const description = `Temporarily disable the Socket ${binName} wrapper` - -export const rawNpxCommand: CliSubcommand = { - description, - async run(argv, importMeta, { parentName }) { - await setupCommand( - `${parentName} raw-${binName}`, - description, - argv, - importMeta - ) - } -} - -async function setupCommand( - name: string, - description: string, - argv: readonly string[], - importMeta: ImportMeta -): Promise { - const flags: { [key: string]: any } = { - ...commonFlags, - ...validationFlags - } - const cli = meow( - ` - Usage - $ ${name} <${binName} command> - - Options - ${getFlagListOutput(flags, 6)} - - Examples - $ ${name} install - `, - { - argv, - description, - importMeta, - flags - } - ) - let showHelp = cli.flags['help'] - if (!argv[0]) { - showHelp = true - } - if (showHelp) { - cli.showHelp() - return - } - const spawnPromise = spawn(getNpxBinPath(), argv, { - signal: abortSignal, - stdio: 'inherit' - }) - // See https://nodejs.org/api/all.html#all_child_process_event-exit. - spawnPromise.process.on('exit', (code, signalName) => { - if (abortSignal.aborted) { - return - } - if (signalName) { - process.kill(process.pid, signalName) - } else if (code !== null) { - process.exit(code) - } - }) - await spawnPromise -} diff --git a/src/commands/raw-npx/cmd-raw-npx.ts b/src/commands/raw-npx/cmd-raw-npx.ts new file mode 100644 index 000000000..035486710 --- /dev/null +++ b/src/commands/raw-npx/cmd-raw-npx.ts @@ -0,0 +1,47 @@ +import meowOrExit from 'meow' + +import { runRawNpx } from './run-raw-npx.ts' +import constants from '../../constants' +import { getFlagListOutput } from '../../utils/output-formatting' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands.ts' + +const { NPX } = constants + +const config: CliCommandConfig = { + commandName: 'raw-npx', + description: `Temporarily disable the Socket ${NPX} wrapper`, + hidden: false, + flags: {}, + help: (parentName, config) => ` + Usage + $ ${parentName} ${config.commandName} + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${parentName} ${config.commandName} install + ` +} + +export const cmdRawNpx = { + description: config.description, + hidden: config.hidden, + run +} + +async function run( + argv: ReadonlyArray, + importMeta: ImportMeta, + { parentName }: { parentName: string } +): Promise { + meowOrExit(config.help(parentName, config), { + argv, + description: config.description, + importMeta, + flags: config.flags + }) + + await runRawNpx(argv) +} diff --git a/src/commands/raw-npx/run-raw-npx.ts b/src/commands/raw-npx/run-raw-npx.ts new file mode 100644 index 000000000..0d7060320 --- /dev/null +++ b/src/commands/raw-npx/run-raw-npx.ts @@ -0,0 +1,27 @@ +import process from 'node:process' + +import spawn from '@npmcli/promise-spawn' + +import constants from '../../constants' +import { getNpxBinPath } from '../../shadow/npm-paths' + +const { abortSignal } = constants + +export async function runRawNpx(argv: ReadonlyArray): Promise { + const spawnPromise = spawn(getNpxBinPath(), argv, { + signal: abortSignal, + stdio: 'inherit' + }) + // See https://nodejs.org/api/all.html#all_child_process_event-exit. + spawnPromise.process.on('exit', (code, signalName) => { + if (abortSignal.aborted) { + return + } + if (signalName) { + process.kill(process.pid, signalName) + } else if (code !== null) { + process.exit(code) + } + }) + await spawnPromise +} diff --git a/src/commands/scan/cmd-scan-list.ts b/src/commands/scan/cmd-scan-list.ts index 22e0c59e3..99ec5010c 100644 --- a/src/commands/scan/cmd-scan-list.ts +++ b/src/commands/scan/cmd-scan-list.ts @@ -1,8 +1,6 @@ import meow from 'meow' import colors from 'yoctocolors-cjs' -import { Spinner } from '@socketsecurity/registry/lib/spinner' - import { listFullScans } from './list-full-scans.ts' import { commonFlags, outputFlags } from '../../flags' import { AuthError } from '../../utils/errors' @@ -104,8 +102,7 @@ async function run( 'User must be authenticated to run this command. To log in, run the command `socket login` and enter your API key.' ) } - const spinnerText = 'Listing scans... \n' - const spinner = new Spinner({ text: spinnerText }).start() + await listFullScans( orgSlug, // TODO: refine this object to what we need @@ -130,7 +127,6 @@ async function run( from_time: string until_time: string }, - spinner, apiToken ) } diff --git a/src/commands/scan/cmd-scan-metadata.ts b/src/commands/scan/cmd-scan-metadata.ts index 0ec281702..cb4345ee4 100644 --- a/src/commands/scan/cmd-scan-metadata.ts +++ b/src/commands/scan/cmd-scan-metadata.ts @@ -1,8 +1,6 @@ import meow from 'meow' import colors from 'yoctocolors-cjs' -import { Spinner } from '@socketsecurity/registry/lib/spinner' - import { getOrgScanMetadata } from './get-full-scan-metadata.ts' import { commonFlags, outputFlags } from '../../flags' import { AuthError } from '../../utils/errors' @@ -71,7 +69,6 @@ async function run( 'User must be authenticated to run this command. To log in, run the command `socket login` and enter your API key.' ) } - const spinnerText = "Getting scan's metadata... \n" - const spinner = new Spinner({ text: spinnerText }).start() - await getOrgScanMetadata(orgSlug, fullScanId, spinner, apiToken) + + await getOrgScanMetadata(orgSlug, fullScanId, apiToken) } diff --git a/src/commands/scan/get-full-scan-metadata.ts b/src/commands/scan/get-full-scan-metadata.ts index aecd6ea0f..9f0a9e08a 100644 --- a/src/commands/scan/get-full-scan-metadata.ts +++ b/src/commands/scan/get-full-scan-metadata.ts @@ -9,9 +9,11 @@ import { setupSdk } from '../../utils/sdk.ts' export async function getOrgScanMetadata( orgSlug: string, scanId: string, - spinner: Spinner, apiToken: string ): Promise { + const spinnerText = "Getting scan's metadata... \n" + const spinner = new Spinner({ text: spinnerText }).start() + const socketSdk = await setupSdk(apiToken) const result = await handleApiCall( socketSdk.getOrgFullScanMetadata(orgSlug, scanId), diff --git a/src/commands/scan/list-full-scans.ts b/src/commands/scan/list-full-scans.ts index 13efae309..67a9d37da 100644 --- a/src/commands/scan/list-full-scans.ts +++ b/src/commands/scan/list-full-scans.ts @@ -24,9 +24,11 @@ export async function listFullScans( from_time: string until_time: string }, - spinner: Spinner, apiToken: string ): Promise { + const spinnerText = 'Listing scans... \n' + const spinner = new Spinner({ text: spinnerText }).start() + const socketSdk = await setupSdk(apiToken) const result = await handleApiCall( socketSdk.getOrgFullScanList(orgSlug, input), diff --git a/src/commands/threat-feed.ts b/src/commands/threat-feed.ts deleted file mode 100644 index 6e2b2deeb..000000000 --- a/src/commands/threat-feed.ts +++ /dev/null @@ -1,244 +0,0 @@ -import process from 'node:process' - -// @ts-ignore -import ScreenWidget from 'blessed/lib/widgets/screen' -// @ts-ignore -import TableWidget from 'blessed-contrib/lib/widget/table' -import meow from 'meow' - -import { Spinner } from '@socketsecurity/registry/lib/spinner' - -import { commonFlags, outputFlags } from '../flags' -import { queryAPI } from '../utils/api' -import { AuthError } from '../utils/errors' -import { getFlagListOutput } from '../utils/output-formatting' -import { getDefaultToken } from '../utils/sdk' - -import type { CliSubcommand } from '../utils/meow-with-subcommands' - -const description = 'Look up the threat feed' - -export const threatFeedCommand: CliSubcommand = { - description, - async run(argv, importMeta, { parentName }) { - const name = `${parentName} threat-feed` - - const input = setupCommand(name, description, argv, importMeta) - if (input) { - const apiToken = getDefaultToken() - if (!apiToken) { - throw new AuthError( - 'User must be authenticated to run this command. To log in, run the command `socket login` and enter your API key.' - ) - } - const spinner = new Spinner({ - text: 'Looking up the threat feed' - }).start() - await fetchThreatFeed(input, spinner, apiToken) - } - } -} - -const threatFeedFlags = { - perPage: { - type: 'number', - shortFlag: 'pp', - default: 30, - description: 'Number of items per page' - }, - page: { - type: 'string', - shortFlag: 'p', - default: '1', - description: 'Page token' - }, - direction: { - type: 'string', - shortFlag: 'd', - default: 'desc', - description: 'Order asc or desc by the createdAt attribute.' - }, - filter: { - type: 'string', - shortFlag: 'f', - default: 'mal', - description: 'Filter what type of threats to return' - } -} - -// Internal functions - -type CommandContext = { - outputJson: boolean - outputMarkdown: boolean - per_page: number - page: string - direction: string - filter: string -} - -function setupCommand( - name: string, - description: string, - argv: readonly string[], - importMeta: ImportMeta -): CommandContext | undefined { - const flags: { [key: string]: any } = { - ...commonFlags, - ...outputFlags, - ...threatFeedFlags - } - - const cli = meow( - ` - Usage - $ ${name} - - Options - ${getFlagListOutput(flags, 6)} - - Examples - $ ${name} - $ ${name} --perPage=5 --page=2 --direction=asc --filter=joke - `, - { - argv, - description, - importMeta, - flags - } - ) - - const { - direction, - filter, - json: outputJson, - markdown: outputMarkdown, - page, - perPage: per_page - } = cli.flags - - return { - outputJson, - outputMarkdown, - per_page, - page, - direction, - filter - } -} - -type ThreatResult = { - createdAt: string - description: string - id: number - locationHtmlUrl: string - packageHtmlUrl: string - purl: string - removedAt: string - threatType: string -} - -async function fetchThreatFeed( - { direction, filter, outputJson, page, per_page }: CommandContext, - spinner: Spinner, - apiToken: string -): Promise { - const formattedQueryParams = formatQueryParams({ - per_page, - page, - direction, - filter - }).join('&') - const response = await queryAPI( - `threat-feed?${formattedQueryParams}`, - apiToken - ) - const data = <{ results: ThreatResult[]; nextPage: string }>( - await response.json() - ) - - spinner.stop() - - if (outputJson) { - console.log(data) - return - } - - const screen = new ScreenWidget() - - const table = new TableWidget({ - keys: 'true', - fg: 'white', - selectedFg: 'white', - selectedBg: 'magenta', - interactive: 'true', - label: 'Threat feed', - width: '100%', - height: '100%', - border: { - type: 'line', - fg: 'cyan' - }, - columnSpacing: 3, //in chars - columnWidth: [9, 30, 10, 17, 13, 100] /*in chars*/ - }) - - // allow control the table with the keyboard - table.focus() - - screen.append(table) - - const formattedOutput = formatResults(data.results) - - table.setData({ - headers: [ - 'Ecosystem', - 'Name', - 'Version', - 'Threat type', - 'Detected at', - 'Details' - ], - data: formattedOutput - }) - - screen.render() - - screen.key(['escape', 'q', 'C-c'], () => process.exit(0)) -} - -const formatResults = (data: ThreatResult[]) => { - return data.map(d => { - const ecosystem = d.purl.split('pkg:')[1]!.split('/')[0]! - const name = d.purl.split('/')[1]!.split('@')[0]! - const version = d.purl.split('@')[1]! - - const timeStart = new Date(d.createdAt).getMilliseconds() - const timeEnd = Date.now() - - const diff = getHourDiff(timeStart, timeEnd) - const hourDiff = - diff > 0 - ? `${diff} hours ago` - : `${getMinDiff(timeStart, timeEnd)} minutes ago` - - return [ - ecosystem, - decodeURIComponent(name), - version, - d.threatType, - hourDiff, - d.locationHtmlUrl - ] - }) -} - -const formatQueryParams = (params: object) => - Object.entries(params).map(entry => `${entry[0]}=${entry[1]}`) - -const getHourDiff = (start: number, end: number) => - Math.floor((end - start) / 3600000) - -const getMinDiff = (start: number, end: number) => - Math.floor((end - start) / 60000) diff --git a/src/commands/threat-feed/cmd-threat-feed.ts b/src/commands/threat-feed/cmd-threat-feed.ts new file mode 100644 index 000000000..03d7b0342 --- /dev/null +++ b/src/commands/threat-feed/cmd-threat-feed.ts @@ -0,0 +1,89 @@ +import meowOrExit from 'meow' + +import { getThreatFeed } from './get-threat-feed.ts' +import { commonFlags, outputFlags } from '../../flags' +import { AuthError } from '../../utils/errors' +import { getFlagListOutput } from '../../utils/output-formatting' +import { getDefaultToken } from '../../utils/sdk' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands' + +const config: CliCommandConfig = { + commandName: 'threat-feed', + description: 'Look up the threat feed', + hidden: false, + flags: { + ...commonFlags, + ...outputFlags, + perPage: { + type: 'number', + shortFlag: 'pp', + default: 30, + description: 'Number of items per page' + }, + page: { + type: 'string', + shortFlag: 'p', + default: '1', + description: 'Page token' + }, + direction: { + type: 'string', + shortFlag: 'd', + default: 'desc', + description: 'Order asc or desc by the createdAt attribute.' + }, + filter: { + type: 'string', + shortFlag: 'f', + default: 'mal', + description: 'Filter what type of threats to return' + } + }, + help: (parentName, config) => ` + Usage + $ ${parentName} ${config.commandName} + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${parentName} ${config.commandName} + $ ${parentName} ${config.commandName} --perPage=5 --page=2 --direction=asc --filter=joke + ` +} + +export const cmdThreatFeed = { + description: config.description, + hidden: config.hidden, + run +} + +async function run( + argv: ReadonlyArray, + importMeta: ImportMeta, + { parentName }: { parentName: string } +): Promise { + const cli = meowOrExit(config.help(parentName, config), { + argv, + description: config.description, + importMeta, + flags: config.flags + }) + + const apiToken = getDefaultToken() + if (!apiToken) { + throw new AuthError( + 'User must be authenticated to run this command. To log in, run the command `socket login` and enter your API key.' + ) + } + + await getThreatFeed({ + apiToken, + direction: String(cli.flags['direction'] || 'desc'), + filter: String(cli.flags['filter'] || 'mal'), + outputJson: Boolean(cli.flags['json']), + page: String(cli.flags['filter'] || '1'), + perPage: Number(cli.flags['per_page'] || 0) + }) +} diff --git a/src/commands/threat-feed/get-threat-feed.ts b/src/commands/threat-feed/get-threat-feed.ts new file mode 100644 index 000000000..c75543eca --- /dev/null +++ b/src/commands/threat-feed/get-threat-feed.ts @@ -0,0 +1,142 @@ +import process from 'node:process' + +// @ts-ignore +import ScreenWidget from 'blessed/lib/widgets/screen' +// @ts-ignore +import TableWidget from 'blessed-contrib/lib/widget/table' + +import { Spinner } from '@socketsecurity/registry/lib/spinner' + +import { queryAPI } from '../../utils/api.ts' + +type ThreatResult = { + createdAt: string + description: string + id: number + locationHtmlUrl: string + packageHtmlUrl: string + purl: string + removedAt: string + threatType: string +} + +export async function getThreatFeed({ + apiToken, + direction, + filter, + outputJson, + page, + perPage +}: { + apiToken: string + outputJson: boolean + perPage: number + page: string + direction: string + filter: string +}): Promise { + const spinner = new Spinner({ + text: 'Looking up the threat feed' + }).start() + + const formattedQueryParams = formatQueryParams({ + per_page: perPage, + page, + direction, + filter + }).join('&') + const response = await queryAPI( + `threat-feed?${formattedQueryParams}`, + apiToken + ) + const data = <{ results: ThreatResult[]; nextPage: string }>( + await response.json() + ) + + spinner.stop() + + if (outputJson) { + console.log(data) + return + } + + const screen = new ScreenWidget() + + const table = new TableWidget({ + keys: 'true', + fg: 'white', + selectedFg: 'white', + selectedBg: 'magenta', + interactive: 'true', + label: 'Threat feed', + width: '100%', + height: '100%', + border: { + type: 'line', + fg: 'cyan' + }, + columnSpacing: 3, //in chars + columnWidth: [9, 30, 10, 17, 13, 100] /*in chars*/ + }) + + // allow control the table with the keyboard + table.focus() + + screen.append(table) + + const formattedOutput = formatResults(data.results) + + table.setData({ + headers: [ + 'Ecosystem', + 'Name', + 'Version', + 'Threat type', + 'Detected at', + 'Details' + ], + data: formattedOutput + }) + + screen.render() + + screen.key(['escape', 'q', 'C-c'], () => process.exit(0)) +} + +function formatResults(data: ThreatResult[]) { + return data.map(d => { + const ecosystem = d.purl.split('pkg:')[1]!.split('/')[0]! + const name = d.purl.split('/')[1]!.split('@')[0]! + const version = d.purl.split('@')[1]! + + const timeStart = new Date(d.createdAt).getMilliseconds() + const timeEnd = Date.now() + + const diff = getHourDiff(timeStart, timeEnd) + const hourDiff = + diff > 0 + ? `${diff} hours ago` + : `${getMinDiff(timeStart, timeEnd)} minutes ago` + + return [ + ecosystem, + decodeURIComponent(name), + version, + d.threatType, + hourDiff, + d.locationHtmlUrl + ] + }) +} + +function formatQueryParams(params: object) { + return Object.entries(params).map(entry => `${entry[0]}=${entry[1]}`) +} + +function getHourDiff(start: number, end: number) { + return Math.floor((end - start) / 3600000) +} + +function getMinDiff(start: number, end: number) { + return Math.floor((end - start) / 60000) +} diff --git a/src/commands/wrapper.ts b/src/commands/wrapper.ts deleted file mode 100644 index 75abd3da6..000000000 --- a/src/commands/wrapper.ts +++ /dev/null @@ -1,203 +0,0 @@ -import fs from 'node:fs' -import os from 'node:os' -import process from 'node:process' -import readline from 'node:readline' - -import meow from 'meow' - -import { commandFlags } from '../flags' -import { getFlagListOutput } from '../utils/output-formatting' - -import type { CliSubcommand } from '../utils/meow-with-subcommands' - -const HOME_DIR = os.homedir() -const BASH_FILE = `${HOME_DIR}/.bashrc` -const ZSH_BASH_FILE = `${HOME_DIR}/.zshrc` - -const description = 'Enable or disable the Socket npm/npx wrapper' - -export const wrapperCommand: CliSubcommand = { - description, - async run( - argv: readonly string[], - importMeta: ImportMeta, - { parentName }: { parentName: string } - ) { - setupCommand(`${parentName} wrapper`, description, argv, importMeta) - } -} - -function setupCommand( - name: string, - description: string, - argv: readonly string[], - importMeta: ImportMeta -): void { - const flags: { [key: string]: any } = commandFlags - const cli = meow( - ` - Usage - $ ${name} - - Options - ${getFlagListOutput(flags, 6)} - - Examples - $ ${name} --enable - $ ${name} --disable - `, - { - argv, - description, - importMeta, - flags - } - ) - if (argv[0] === '--postinstall') { - const socketWrapperEnabled = - (fs.existsSync(BASH_FILE) && checkSocketWrapperAlreadySetup(BASH_FILE)) || - (fs.existsSync(ZSH_BASH_FILE) && - checkSocketWrapperAlreadySetup(ZSH_BASH_FILE)) - - if (!socketWrapperEnabled) { - installSafeNpm(`The Socket CLI is now successfully installed! 🎉 - - To better protect yourself against supply-chain attacks, our "safe npm" wrapper can warn you about malicious packages whenever you run 'npm install'. - - Do you want to install "safe npm" (this will create an alias to the socket-npm command)? (y/n)`) - } - return - } - const { disable, enable } = cli.flags - let showHelp = cli.flags['help'] - if (!enable && !disable) { - showHelp = true - } - if (showHelp) { - cli.showHelp() - return - } - if (enable) { - if (fs.existsSync(BASH_FILE)) { - const socketWrapperEnabled = checkSocketWrapperAlreadySetup(BASH_FILE) - !socketWrapperEnabled && addAlias(BASH_FILE) - } - if (fs.existsSync(ZSH_BASH_FILE)) { - const socketWrapperEnabled = checkSocketWrapperAlreadySetup(ZSH_BASH_FILE) - !socketWrapperEnabled && addAlias(ZSH_BASH_FILE) - } - } else if (disable) { - if (fs.existsSync(BASH_FILE)) { - removeAlias(BASH_FILE) - } - if (fs.existsSync(ZSH_BASH_FILE)) { - removeAlias(ZSH_BASH_FILE) - } - } - if (!fs.existsSync(BASH_FILE) && !fs.existsSync(ZSH_BASH_FILE)) { - console.error( - 'There was an issue setting up the alias in your bash profile' - ) - } -} - -function addAlias(file: string): void { - return fs.appendFile( - file, - 'alias npm="socket npm"\nalias npx="socket npx"\n', - err => { - if (err) { - return new Error(`There was an error setting up the alias: ${err}`) - } - console.log(` -The alias was added to ${file}. Running 'npm install' will now be wrapped in Socket's "safe npm" 🎉 -If you want to disable it at any time, run \`socket wrapper --disable\` -`) - } - ) -} - -function askQuestion(rl: readline.Interface, query: string): void { - rl.question(query, (ans: string) => { - if (ans.toLowerCase() === 'y') { - try { - if (fs.existsSync(BASH_FILE)) { - addAlias(BASH_FILE) - } - if (fs.existsSync(ZSH_BASH_FILE)) { - addAlias(ZSH_BASH_FILE) - } - } catch (e) { - throw new Error(`There was an issue setting up the alias: ${e}`) - } - rl.close() - } else if (ans.toLowerCase() !== 'n') { - askQuestion( - rl, - 'Incorrect input: please enter either y (yes) or n (no): ' - ) - } else { - rl.close() - } - }) -} - -function checkSocketWrapperAlreadySetup(file: string): boolean { - const fileContent = fs.readFileSync(file, 'utf8') - const linesWithSocketAlias = fileContent - .split('\n') - .filter( - l => l === 'alias npm="socket npm"' || l === 'alias npx="socket npx"' - ) - - if (linesWithSocketAlias.length) { - console.log( - `The Socket npm/npx wrapper is set up in your bash profile (${file}).` - ) - return true - } - return false -} - -function installSafeNpm(query: string): void { - console.log(` - _____ _ _ -| __|___ ___| |_ ___| |_ -|__ | . | _| '_| -_| _| -|_____|___|___|_,_|___|_| - -`) - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout - }) - return askQuestion(rl, query) -} - -function removeAlias(file: string): void { - return fs.readFile(file, 'utf8', function (err, data) { - if (err) { - console.error(`There was an error removing the alias: ${err}`) - return - } - const linesWithoutSocketAlias = data - .split('\n') - .filter( - l => l !== 'alias npm="socket npm"' && l !== 'alias npx="socket npx"' - ) - - const updatedFileContent = linesWithoutSocketAlias.join('\n') - - fs.writeFile(file, updatedFileContent, function (err) { - if (err) { - console.log(err) - return - } else { - console.log( - `\nThe alias was removed from ${file}. Running 'npm install' will now run the standard npm command.\n` - ) - } - }) - }) -} diff --git a/src/commands/wrapper/add-socket-wrapper.ts b/src/commands/wrapper/add-socket-wrapper.ts new file mode 100644 index 000000000..8f37b6d8a --- /dev/null +++ b/src/commands/wrapper/add-socket-wrapper.ts @@ -0,0 +1,19 @@ +import fs from 'node:fs' + +export function addSocketWrapper(file: string): void { + return fs.appendFile( + file, + 'alias npm="socket npm"\nalias npx="socket npx"\n', + err => { + if (err) { + return new Error(`There was an error setting up the alias: ${err}`) + } + // TODO: pretty sure you need to source the file or restart + // any terminal session before changes are reflected. + console.log(` +The alias was added to ${file}. Running 'npm install' will now be wrapped in Socket's "safe npm" 🎉 +If you want to disable it at any time, run \`socket wrapper --disable\` +`) + } + ) +} diff --git a/src/commands/wrapper/check-socket-wrapper-setup.ts b/src/commands/wrapper/check-socket-wrapper-setup.ts new file mode 100644 index 000000000..34dc89d4d --- /dev/null +++ b/src/commands/wrapper/check-socket-wrapper-setup.ts @@ -0,0 +1,18 @@ +import fs from 'node:fs' + +export function checkSocketWrapperSetup(file: string): boolean { + const fileContent = fs.readFileSync(file, 'utf8') + const linesWithSocketAlias = fileContent + .split('\n') + .filter( + l => l === 'alias npm="socket npm"' || l === 'alias npx="socket npx"' + ) + + if (linesWithSocketAlias.length) { + console.log( + `The Socket npm/npx wrapper is set up in your bash profile (${file}).` + ) + return true + } + return false +} diff --git a/src/commands/wrapper/cmd-wrapper.ts b/src/commands/wrapper/cmd-wrapper.ts new file mode 100644 index 000000000..f025c9f38 --- /dev/null +++ b/src/commands/wrapper/cmd-wrapper.ts @@ -0,0 +1,99 @@ +import fs from 'node:fs' +import os from 'node:os' + +import meowOrExit from 'meow' + +import { addSocketWrapper } from './add-socket-wrapper.ts' +import { checkSocketWrapperSetup } from './check-socket-wrapper-setup.ts' +import { postinstallWrapper } from './postinstall-wrapper.ts' +import { removeSocketWrapper } from './remove-socket-wrapper.ts' +import { getFlagListOutput } from '../../utils/output-formatting' + +import type { CliCommandConfig } from '../../utils/meow-with-subcommands' + +const HOME_DIR = os.homedir() +const BASH_FILE = `${HOME_DIR}/.bashrc` +const ZSH_BASH_FILE = `${HOME_DIR}/.zshrc` + +const config: CliCommandConfig = { + commandName: 'wrapper', + description: 'Enable or disable the Socket npm/npx wrapper', + hidden: false, + flags: { + enable: { + type: 'boolean', + default: false, + description: 'Enables the Socket npm/npx wrapper' + }, + disable: { + type: 'boolean', + default: false, + description: 'Disables the Socket npm/npx wrapper' + } + }, + help: (parentName, config) => ` + Usage + $ ${parentName} ${config.commandName} + + Options + ${getFlagListOutput(config.flags, 6)} + + Examples + $ ${parentName} ${config.commandName} --enable + $ ${parentName} ${config.commandName} --disable + ` +} + +export const cmdWrapper = { + description: config.description, + hidden: config.hidden, + run +} + +async function run( + argv: ReadonlyArray, + importMeta: ImportMeta, + { parentName }: { parentName: string } +): Promise { + // I don't think meow would mess with this but ... + if (argv[0] === '--postinstall') { + postinstallWrapper() + return + } + + const cli = meowOrExit(config.help(parentName, config), { + argv, + description: config.description, + importMeta, + flags: config.flags + }) + + const { disable, enable } = cli.flags + if (!enable && !disable) { + cli.showHelp() + return + } + + if (enable) { + if (fs.existsSync(BASH_FILE)) { + const socketWrapperEnabled = checkSocketWrapperSetup(BASH_FILE) + !socketWrapperEnabled && addSocketWrapper(BASH_FILE) + } + if (fs.existsSync(ZSH_BASH_FILE)) { + const socketWrapperEnabled = checkSocketWrapperSetup(ZSH_BASH_FILE) + !socketWrapperEnabled && addSocketWrapper(ZSH_BASH_FILE) + } + } else { + if (fs.existsSync(BASH_FILE)) { + removeSocketWrapper(BASH_FILE) + } + if (fs.existsSync(ZSH_BASH_FILE)) { + removeSocketWrapper(ZSH_BASH_FILE) + } + } + if (!fs.existsSync(BASH_FILE) && !fs.existsSync(ZSH_BASH_FILE)) { + console.error( + 'There was an issue setting up the alias in your bash profile' + ) + } +} diff --git a/src/commands/wrapper/postinstall-wrapper.ts b/src/commands/wrapper/postinstall-wrapper.ts new file mode 100644 index 000000000..357588755 --- /dev/null +++ b/src/commands/wrapper/postinstall-wrapper.ts @@ -0,0 +1,66 @@ +import fs from 'node:fs' +import os from 'node:os' +import process from 'node:process' +import readline from 'node:readline' + +import { addSocketWrapper } from './add-socket-wrapper.ts' +import { checkSocketWrapperSetup } from './check-socket-wrapper-setup.ts' + +const HOME_DIR = os.homedir() +const BASH_FILE = `${HOME_DIR}/.bashrc` +const ZSH_BASH_FILE = `${HOME_DIR}/.zshrc` + +export function postinstallWrapper() { + const socketWrapperEnabled = + (fs.existsSync(BASH_FILE) && checkSocketWrapperSetup(BASH_FILE)) || + (fs.existsSync(ZSH_BASH_FILE) && checkSocketWrapperSetup(ZSH_BASH_FILE)) + + if (!socketWrapperEnabled) { + installSafeNpm(`The Socket CLI is now successfully installed! 🎉 + + To better protect yourself against supply-chain attacks, our "safe npm" wrapper can warn you about malicious packages whenever you run 'npm install'. + + Do you want to install "safe npm" (this will create an alias to the socket-npm command)? (y/n)`) + } +} + +function installSafeNpm(query: string): void { + console.log(` + _____ _ _ +| __|___ ___| |_ ___| |_ +|__ | . | _| '_| -_| _| +|_____|___|___|_,_|___|_| + +`) + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }) + return askQuestion(rl, query) +} + +function askQuestion(rl: readline.Interface, query: string): void { + rl.question(query, (ans: string) => { + if (ans.toLowerCase() === 'y') { + try { + if (fs.existsSync(BASH_FILE)) { + addSocketWrapper(BASH_FILE) + } + if (fs.existsSync(ZSH_BASH_FILE)) { + addSocketWrapper(ZSH_BASH_FILE) + } + } catch (e) { + throw new Error(`There was an issue setting up the alias: ${e}`) + } + rl.close() + } else if (ans.toLowerCase() !== 'n') { + askQuestion( + rl, + 'Incorrect input: please enter either y (yes) or n (no): ' + ) + } else { + rl.close() + } + }) +} diff --git a/src/commands/wrapper/remove-socket-wrapper.ts b/src/commands/wrapper/remove-socket-wrapper.ts new file mode 100644 index 000000000..b846e9f71 --- /dev/null +++ b/src/commands/wrapper/remove-socket-wrapper.ts @@ -0,0 +1,30 @@ +import fs from 'node:fs' + +export function removeSocketWrapper(file: string): void { + return fs.readFile(file, 'utf8', function (err, data) { + if (err) { + console.error(`There was an error removing the alias: ${err}`) + return + } + const linesWithoutSocketAlias = data + .split('\n') + .filter( + l => l !== 'alias npm="socket npm"' && l !== 'alias npx="socket npx"' + ) + + const updatedFileContent = linesWithoutSocketAlias.join('\n') + + fs.writeFile(file, updatedFileContent, function (err) { + if (err) { + console.log(err) + return + } else { + // TODO: pretty sure you need to source the file or restart + // any terminal session before changes are reflected. + console.log( + `\nThe alias was removed from ${file}. Running 'npm install' will now run the standard npm command.\n` + ) + } + }) + }) +} diff --git a/src/flags.ts b/src/flags.ts index 82cfd7346..e07874548 100644 --- a/src/flags.ts +++ b/src/flags.ts @@ -18,19 +18,6 @@ export const commonFlags: MeowFlags = { } } -export const commandFlags: MeowFlags = { - enable: { - type: 'boolean', - default: false, - description: 'Enables the Socket npm/npx wrapper' - }, - disable: { - type: 'boolean', - default: false, - description: 'Disables the Socket npm/npx wrapper' - } -} - export const outputFlags: MeowFlags = { json: { type: 'boolean',