Skip to content

Commit 1ec2ba0

Browse files
mikolalysenkoclaude
andcommitted
Add download command and blob cleanup utilities
- Implement download command to fetch patches from Socket API - Add API client for HTTP requests with Bearer authentication - Create blob cleanup utility to remove unused blobs - Integrate cleanup into both apply and download commands - Support environment variables SOCKET_API_URL and SOCKET_API_TOKEN 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 4ff7063 commit 1ec2ba0

File tree

5 files changed

+430
-0
lines changed

5 files changed

+430
-0
lines changed

src/cli.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
import yargs from 'yargs'
44
import { hideBin } from 'yargs/helpers'
55
import { applyCommand } from './commands/apply.js'
6+
import { downloadCommand } from './commands/download.js'
67

78
async function main(): Promise<void> {
89
await yargs(hideBin(process.argv))
910
.scriptName('socket-patch')
1011
.usage('$0 <command> [options]')
1112
.command(applyCommand)
13+
.command(downloadCommand)
1214
.demandCommand(1, 'You must specify a command')
1315
.help()
1416
.alias('h', 'help')

src/commands/apply.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ import {
1111
applyPackagePatch,
1212
} from '../patch/apply.js'
1313
import type { ApplyResult } from '../patch/apply.js'
14+
import {
15+
cleanupUnusedBlobs,
16+
formatCleanupResult,
17+
} from '../utils/cleanup-blobs.js'
1418

1519
interface ApplyArgs {
1620
cwd: string
@@ -95,6 +99,14 @@ async function applyPatches(
9599
}
96100
}
97101

102+
// Clean up unused blobs after applying patches
103+
if (!silent) {
104+
const cleanupResult = await cleanupUnusedBlobs(manifest, blobsPath, dryRun)
105+
if (cleanupResult.blobsRemoved > 0) {
106+
console.log(`\n${formatCleanupResult(cleanupResult, dryRun)}`)
107+
}
108+
}
109+
98110
return { success: !hasErrors, results }
99111
}
100112

src/commands/download.ts

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import * as fs from 'fs/promises'
2+
import * as path from 'path'
3+
import type { CommandModule } from 'yargs'
4+
import { PatchManifestSchema } from '../schema/manifest-schema.js'
5+
import { getAPIClientFromEnv } from '../utils/api-client.js'
6+
import {
7+
cleanupUnusedBlobs,
8+
formatCleanupResult,
9+
} from '../utils/cleanup-blobs.js'
10+
11+
interface DownloadArgs {
12+
uuid: string
13+
org: string
14+
cwd: string
15+
'api-url'?: string
16+
'api-token'?: string
17+
}
18+
19+
async function downloadPatch(
20+
uuid: string,
21+
orgSlug: string,
22+
cwd: string,
23+
apiUrl?: string,
24+
apiToken?: string,
25+
): Promise<boolean> {
26+
// Override environment variables if CLI options are provided
27+
if (apiUrl) {
28+
process.env.SOCKET_API_URL = apiUrl
29+
}
30+
if (apiToken) {
31+
process.env.SOCKET_API_TOKEN = apiToken
32+
}
33+
34+
// Get API client (will use env vars if not overridden)
35+
const apiClient = getAPIClientFromEnv()
36+
37+
console.log(`Fetching patch ${uuid} from ${orgSlug}...`)
38+
39+
// Fetch patch from API
40+
const patch = await apiClient.fetchPatch(orgSlug, uuid)
41+
42+
if (!patch) {
43+
throw new Error(`Patch with UUID ${uuid} not found`)
44+
}
45+
46+
console.log(`Downloaded patch for ${patch.purl}`)
47+
48+
// Prepare .socket directory
49+
const socketDir = path.join(cwd, '.socket')
50+
const blobsDir = path.join(socketDir, 'blobs')
51+
const manifestPath = path.join(socketDir, 'manifest.json')
52+
53+
// Create directories
54+
await fs.mkdir(socketDir, { recursive: true })
55+
await fs.mkdir(blobsDir, { recursive: true })
56+
57+
// Read existing manifest or create new one
58+
let manifest: any
59+
try {
60+
const manifestContent = await fs.readFile(manifestPath, 'utf-8')
61+
manifest = PatchManifestSchema.parse(JSON.parse(manifestContent))
62+
} catch {
63+
// Create new manifest
64+
manifest = { patches: {} }
65+
}
66+
67+
// Save blob contents
68+
const files: Record<string, { beforeHash?: string; afterHash?: string }> = {}
69+
for (const [filePath, fileInfo] of Object.entries(patch.files)) {
70+
if (fileInfo.afterHash) {
71+
files[filePath] = {
72+
beforeHash: fileInfo.beforeHash,
73+
afterHash: fileInfo.afterHash,
74+
}
75+
}
76+
77+
// Save blob content if provided
78+
if (fileInfo.blobContent && fileInfo.afterHash) {
79+
const blobPath = path.join(blobsDir, fileInfo.afterHash)
80+
const blobBuffer = Buffer.from(fileInfo.blobContent, 'base64')
81+
await fs.writeFile(blobPath, blobBuffer)
82+
console.log(` Saved blob: ${fileInfo.afterHash}`)
83+
}
84+
}
85+
86+
// Add/update patch in manifest
87+
manifest.patches[patch.purl] = {
88+
uuid: patch.uuid,
89+
exportedAt: patch.publishedAt,
90+
files,
91+
vulnerabilities: patch.vulnerabilities,
92+
description: patch.description,
93+
license: patch.license,
94+
tier: patch.tier,
95+
}
96+
97+
// Write updated manifest
98+
await fs.writeFile(
99+
manifestPath,
100+
JSON.stringify(manifest, null, 2) + '\n',
101+
'utf-8',
102+
)
103+
104+
console.log(`\nPatch saved to ${manifestPath}`)
105+
console.log(` PURL: ${patch.purl}`)
106+
console.log(` UUID: ${patch.uuid}`)
107+
console.log(` Files: ${Object.keys(files).length}`)
108+
console.log(` Vulnerabilities: ${Object.keys(patch.vulnerabilities).length}`)
109+
110+
// Clean up unused blobs
111+
const cleanupResult = await cleanupUnusedBlobs(manifest, blobsDir, false)
112+
if (cleanupResult.blobsRemoved > 0) {
113+
console.log(`\n${formatCleanupResult(cleanupResult, false)}`)
114+
}
115+
116+
return true
117+
}
118+
119+
export const downloadCommand: CommandModule<{}, DownloadArgs> = {
120+
command: 'download',
121+
describe: 'Download a security patch from Socket API',
122+
builder: yargs => {
123+
return yargs
124+
.option('uuid', {
125+
describe: 'Patch UUID to download',
126+
type: 'string',
127+
demandOption: true,
128+
})
129+
.option('org', {
130+
describe: 'Organization slug',
131+
type: 'string',
132+
demandOption: true,
133+
})
134+
.option('cwd', {
135+
describe: 'Working directory',
136+
type: 'string',
137+
default: process.cwd(),
138+
})
139+
.option('api-url', {
140+
describe: 'Socket API URL (overrides SOCKET_API_URL env var)',
141+
type: 'string',
142+
})
143+
.option('api-token', {
144+
describe: 'Socket API token (overrides SOCKET_API_TOKEN env var)',
145+
type: 'string',
146+
})
147+
},
148+
handler: async argv => {
149+
try {
150+
await downloadPatch(
151+
argv.uuid,
152+
argv.org,
153+
argv.cwd,
154+
argv['api-url'],
155+
argv['api-token'],
156+
)
157+
158+
process.exit(0)
159+
} catch (err) {
160+
const errorMessage = err instanceof Error ? err.message : String(err)
161+
console.error(`Error: ${errorMessage}`)
162+
process.exit(1)
163+
}
164+
},
165+
}

src/utils/api-client.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import * as https from 'node:https'
2+
import * as http from 'node:http'
3+
4+
export interface PatchResponse {
5+
uuid: string
6+
purl: string
7+
publishedAt: string
8+
files: Record<
9+
string,
10+
{
11+
beforeHash?: string
12+
afterHash?: string
13+
socketBlob?: string
14+
blobContent?: string
15+
}
16+
>
17+
vulnerabilities: Record<
18+
string,
19+
{
20+
cves: string[]
21+
summary: string
22+
severity: string
23+
description: string
24+
}
25+
>
26+
description: string
27+
license: string
28+
tier: 'free' | 'paid'
29+
}
30+
31+
export interface APIClientOptions {
32+
apiUrl: string
33+
apiToken: string
34+
}
35+
36+
export class APIClient {
37+
private readonly apiUrl: string
38+
private readonly apiToken: string
39+
40+
constructor(options: APIClientOptions) {
41+
this.apiUrl = options.apiUrl.replace(/\/$/, '') // Remove trailing slash
42+
this.apiToken = options.apiToken
43+
}
44+
45+
async fetchPatch(
46+
orgSlug: string,
47+
uuid: string,
48+
): Promise<PatchResponse | null> {
49+
const url = `${this.apiUrl}/v0/orgs/${orgSlug}/patches/view/${uuid}`
50+
51+
return new Promise((resolve, reject) => {
52+
const urlObj = new URL(url)
53+
const isHttps = urlObj.protocol === 'https:'
54+
const httpModule = isHttps ? https : http
55+
56+
const options: https.RequestOptions = {
57+
method: 'GET',
58+
headers: {
59+
Authorization: `Bearer ${this.apiToken}`,
60+
Accept: 'application/json',
61+
},
62+
}
63+
64+
const req = httpModule.request(urlObj, options, res => {
65+
let data = ''
66+
67+
res.on('data', chunk => {
68+
data += chunk
69+
})
70+
71+
res.on('end', () => {
72+
if (res.statusCode === 200) {
73+
try {
74+
const parsed = JSON.parse(data)
75+
resolve(parsed)
76+
} catch (err) {
77+
reject(new Error(`Failed to parse response: ${err}`))
78+
}
79+
} else if (res.statusCode === 404) {
80+
resolve(null)
81+
} else if (res.statusCode === 401) {
82+
reject(new Error('Unauthorized: Invalid API token'))
83+
} else if (res.statusCode === 403) {
84+
reject(
85+
new Error(
86+
'Forbidden: Access denied. This may be a paid patch or you may not have access to this organization.',
87+
),
88+
)
89+
} else if (res.statusCode === 429) {
90+
reject(new Error('Rate limit exceeded. Please try again later.'))
91+
} else {
92+
reject(
93+
new Error(`API request failed with status ${res.statusCode}: ${data}`),
94+
)
95+
}
96+
})
97+
})
98+
99+
req.on('error', err => {
100+
reject(new Error(`Network error: ${err.message}`))
101+
})
102+
103+
req.end()
104+
})
105+
}
106+
}
107+
108+
export function getAPIClientFromEnv(): APIClient {
109+
const apiUrl =
110+
process.env.SOCKET_API_URL || 'https://api.socket.dev'
111+
const apiToken = process.env.SOCKET_API_TOKEN
112+
113+
if (!apiToken) {
114+
throw new Error(
115+
'SOCKET_API_TOKEN environment variable is required. Please set it to your Socket API token.',
116+
)
117+
}
118+
119+
return new APIClient({ apiUrl, apiToken })
120+
}

0 commit comments

Comments
 (0)