Skip to content

Commit 99a492e

Browse files
mikolalysenkoclaude
andcommitted
Add list, remove, and gc commands to socket-patch CLI
- Add list command to display patches from local manifest with detailed information - Add remove command to remove patches by PURL or UUID - Add gc (garbage collection) command to clean up unused blob files - Support --json output for list command - Support --dry-run for gc command 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 53ac4d1 commit 99a492e

File tree

4 files changed

+372
-0
lines changed

4 files changed

+372
-0
lines changed

src/cli.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,19 @@ import yargs from 'yargs'
44
import { hideBin } from 'yargs/helpers'
55
import { applyCommand } from './commands/apply.js'
66
import { downloadCommand } from './commands/download.js'
7+
import { listCommand } from './commands/list.js'
8+
import { removeCommand } from './commands/remove.js'
9+
import { gcCommand } from './commands/gc.js'
710

811
async function main(): Promise<void> {
912
await yargs(hideBin(process.argv))
1013
.scriptName('socket-patch')
1114
.usage('$0 <command> [options]')
1215
.command(applyCommand)
1316
.command(downloadCommand)
17+
.command(listCommand)
18+
.command(removeCommand)
19+
.command(gcCommand)
1420
.demandCommand(1, 'You must specify a command')
1521
.help()
1622
.alias('h', 'help')

src/commands/gc.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import * as fs from 'fs/promises'
2+
import * as path from 'path'
3+
import type { CommandModule } from 'yargs'
4+
import {
5+
PatchManifestSchema,
6+
DEFAULT_PATCH_MANIFEST_PATH,
7+
} from '../schema/manifest-schema.js'
8+
import {
9+
cleanupUnusedBlobs,
10+
formatCleanupResult,
11+
} from '../utils/cleanup-blobs.js'
12+
13+
interface GCArgs {
14+
cwd: string
15+
'manifest-path': string
16+
'dry-run': boolean
17+
}
18+
19+
async function garbageCollect(
20+
manifestPath: string,
21+
dryRun: boolean,
22+
): Promise<void> {
23+
// Read and parse manifest
24+
const manifestContent = await fs.readFile(manifestPath, 'utf-8')
25+
const manifestData = JSON.parse(manifestContent)
26+
const manifest = PatchManifestSchema.parse(manifestData)
27+
28+
// Find .socket directory (contains blobs)
29+
const socketDir = path.dirname(manifestPath)
30+
const blobsPath = path.join(socketDir, 'blobs')
31+
32+
// Run cleanup
33+
const cleanupResult = await cleanupUnusedBlobs(manifest, blobsPath, dryRun)
34+
35+
// Display results
36+
if (cleanupResult.blobsChecked === 0) {
37+
console.log('No blobs directory found, nothing to clean up.')
38+
} else if (cleanupResult.blobsRemoved === 0) {
39+
console.log(
40+
`Checked ${cleanupResult.blobsChecked} blob(s), all are in use.`,
41+
)
42+
} else {
43+
console.log(formatCleanupResult(cleanupResult, dryRun))
44+
45+
if (!dryRun) {
46+
console.log('\nGarbage collection complete.')
47+
}
48+
}
49+
}
50+
51+
export const gcCommand: CommandModule<{}, GCArgs> = {
52+
command: 'gc',
53+
describe: 'Clean up unused blob files from .socket/blobs directory',
54+
builder: yargs => {
55+
return yargs
56+
.option('cwd', {
57+
describe: 'Working directory',
58+
type: 'string',
59+
default: process.cwd(),
60+
})
61+
.option('manifest-path', {
62+
alias: 'm',
63+
describe: 'Path to patch manifest file',
64+
type: 'string',
65+
default: DEFAULT_PATCH_MANIFEST_PATH,
66+
})
67+
.option('dry-run', {
68+
alias: 'd',
69+
describe: 'Show what would be removed without actually removing',
70+
type: 'boolean',
71+
default: false,
72+
})
73+
},
74+
handler: async argv => {
75+
try {
76+
const manifestPath = path.isAbsolute(argv['manifest-path'])
77+
? argv['manifest-path']
78+
: path.join(argv.cwd, argv['manifest-path'])
79+
80+
// Check if manifest exists
81+
try {
82+
await fs.access(manifestPath)
83+
} catch {
84+
console.error(`Manifest not found at ${manifestPath}`)
85+
process.exit(1)
86+
}
87+
88+
await garbageCollect(manifestPath, argv['dry-run'])
89+
process.exit(0)
90+
} catch (err) {
91+
const errorMessage = err instanceof Error ? err.message : String(err)
92+
console.error(`Error: ${errorMessage}`)
93+
process.exit(1)
94+
}
95+
},
96+
}

src/commands/list.ts

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import * as fs from 'fs/promises'
2+
import * as path from 'path'
3+
import type { CommandModule } from 'yargs'
4+
import {
5+
PatchManifestSchema,
6+
DEFAULT_PATCH_MANIFEST_PATH,
7+
} from '../schema/manifest-schema.js'
8+
9+
interface ListArgs {
10+
cwd: string
11+
'manifest-path': string
12+
json: boolean
13+
}
14+
15+
async function listPatches(
16+
manifestPath: string,
17+
outputJson: boolean,
18+
): Promise<void> {
19+
// Read and parse manifest
20+
const manifestContent = await fs.readFile(manifestPath, 'utf-8')
21+
const manifestData = JSON.parse(manifestContent)
22+
const manifest = PatchManifestSchema.parse(manifestData)
23+
24+
const patchEntries = Object.entries(manifest.patches)
25+
26+
if (patchEntries.length === 0) {
27+
if (outputJson) {
28+
console.log(JSON.stringify({ patches: [] }, null, 2))
29+
} else {
30+
console.log('No patches found in manifest.')
31+
}
32+
return
33+
}
34+
35+
if (outputJson) {
36+
// Output as JSON for machine consumption
37+
const jsonOutput = {
38+
patches: patchEntries.map(([purl, patch]) => ({
39+
purl,
40+
uuid: patch.uuid,
41+
exportedAt: patch.exportedAt,
42+
tier: patch.tier,
43+
license: patch.license,
44+
description: patch.description,
45+
files: Object.keys(patch.files),
46+
vulnerabilities: Object.entries(patch.vulnerabilities).map(
47+
([id, vuln]) => ({
48+
id,
49+
cves: vuln.cves,
50+
summary: vuln.summary,
51+
severity: vuln.severity,
52+
description: vuln.description,
53+
}),
54+
),
55+
})),
56+
}
57+
console.log(JSON.stringify(jsonOutput, null, 2))
58+
} else {
59+
// Human-readable output
60+
console.log(`Found ${patchEntries.length} patch(es):\n`)
61+
62+
for (const [purl, patch] of patchEntries) {
63+
console.log(`Package: ${purl}`)
64+
console.log(` UUID: ${patch.uuid}`)
65+
console.log(` Tier: ${patch.tier}`)
66+
console.log(` License: ${patch.license}`)
67+
console.log(` Exported: ${patch.exportedAt}`)
68+
69+
if (patch.description) {
70+
console.log(` Description: ${patch.description}`)
71+
}
72+
73+
// List vulnerabilities
74+
const vulnEntries = Object.entries(patch.vulnerabilities)
75+
if (vulnEntries.length > 0) {
76+
console.log(` Vulnerabilities (${vulnEntries.length}):`)
77+
for (const [id, vuln] of vulnEntries) {
78+
const cveList = vuln.cves.length > 0 ? ` (${vuln.cves.join(', ')})` : ''
79+
console.log(` - ${id}${cveList}`)
80+
console.log(` Severity: ${vuln.severity}`)
81+
console.log(` Summary: ${vuln.summary}`)
82+
}
83+
}
84+
85+
// List files being patched
86+
const fileList = Object.keys(patch.files)
87+
if (fileList.length > 0) {
88+
console.log(` Files patched (${fileList.length}):`)
89+
for (const filePath of fileList) {
90+
console.log(` - ${filePath}`)
91+
}
92+
}
93+
94+
console.log('') // Empty line between patches
95+
}
96+
}
97+
}
98+
99+
export const listCommand: CommandModule<{}, ListArgs> = {
100+
command: 'list',
101+
describe: 'List all patches in the local manifest',
102+
builder: yargs => {
103+
return yargs
104+
.option('cwd', {
105+
describe: 'Working directory',
106+
type: 'string',
107+
default: process.cwd(),
108+
})
109+
.option('manifest-path', {
110+
alias: 'm',
111+
describe: 'Path to patch manifest file',
112+
type: 'string',
113+
default: DEFAULT_PATCH_MANIFEST_PATH,
114+
})
115+
.option('json', {
116+
describe: 'Output as JSON',
117+
type: 'boolean',
118+
default: false,
119+
})
120+
},
121+
handler: async argv => {
122+
try {
123+
const manifestPath = path.isAbsolute(argv['manifest-path'])
124+
? argv['manifest-path']
125+
: path.join(argv.cwd, argv['manifest-path'])
126+
127+
// Check if manifest exists
128+
try {
129+
await fs.access(manifestPath)
130+
} catch {
131+
if (argv.json) {
132+
console.log(JSON.stringify({ error: 'Manifest not found', path: manifestPath }, null, 2))
133+
} else {
134+
console.error(`Manifest not found at ${manifestPath}`)
135+
}
136+
process.exit(1)
137+
}
138+
139+
await listPatches(manifestPath, argv.json)
140+
process.exit(0)
141+
} catch (err) {
142+
const errorMessage = err instanceof Error ? err.message : String(err)
143+
if (argv.json) {
144+
console.log(JSON.stringify({ error: errorMessage }, null, 2))
145+
} else {
146+
console.error(`Error: ${errorMessage}`)
147+
}
148+
process.exit(1)
149+
}
150+
},
151+
}

src/commands/remove.ts

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import * as fs from 'fs/promises'
2+
import * as path from 'path'
3+
import type { CommandModule } from 'yargs'
4+
import {
5+
PatchManifestSchema,
6+
DEFAULT_PATCH_MANIFEST_PATH,
7+
} from '../schema/manifest-schema.js'
8+
9+
interface RemoveArgs {
10+
identifier: string
11+
cwd: string
12+
'manifest-path': string
13+
}
14+
15+
async function removePatch(
16+
identifier: string,
17+
manifestPath: string,
18+
): Promise<{ removed: string[]; notFound: boolean }> {
19+
// Read and parse manifest
20+
const manifestContent = await fs.readFile(manifestPath, 'utf-8')
21+
const manifestData = JSON.parse(manifestContent)
22+
const manifest = PatchManifestSchema.parse(manifestData)
23+
24+
const removed: string[] = []
25+
let foundMatch = false
26+
27+
// Check if identifier is a PURL (contains "pkg:")
28+
if (identifier.startsWith('pkg:')) {
29+
// Remove by PURL
30+
if (manifest.patches[identifier]) {
31+
removed.push(identifier)
32+
delete manifest.patches[identifier]
33+
foundMatch = true
34+
}
35+
} else {
36+
// Remove by UUID - search through all patches
37+
for (const [purl, patch] of Object.entries(manifest.patches)) {
38+
if (patch.uuid === identifier) {
39+
removed.push(purl)
40+
delete manifest.patches[purl]
41+
foundMatch = true
42+
}
43+
}
44+
}
45+
46+
if (foundMatch) {
47+
// Write updated manifest
48+
await fs.writeFile(
49+
manifestPath,
50+
JSON.stringify(manifest, null, 2) + '\n',
51+
'utf-8',
52+
)
53+
}
54+
55+
return { removed, notFound: !foundMatch }
56+
}
57+
58+
export const removeCommand: CommandModule<{}, RemoveArgs> = {
59+
command: 'remove <identifier>',
60+
describe: 'Remove a patch from the manifest by PURL or UUID',
61+
builder: yargs => {
62+
return yargs
63+
.positional('identifier', {
64+
describe: 'Package PURL (e.g., pkg:npm/package@version) or patch UUID',
65+
type: 'string',
66+
demandOption: true,
67+
})
68+
.option('cwd', {
69+
describe: 'Working directory',
70+
type: 'string',
71+
default: process.cwd(),
72+
})
73+
.option('manifest-path', {
74+
alias: 'm',
75+
describe: 'Path to patch manifest file',
76+
type: 'string',
77+
default: DEFAULT_PATCH_MANIFEST_PATH,
78+
})
79+
},
80+
handler: async argv => {
81+
try {
82+
const manifestPath = path.isAbsolute(argv['manifest-path'])
83+
? argv['manifest-path']
84+
: path.join(argv.cwd, argv['manifest-path'])
85+
86+
// Check if manifest exists
87+
try {
88+
await fs.access(manifestPath)
89+
} catch {
90+
console.error(`Manifest not found at ${manifestPath}`)
91+
process.exit(1)
92+
}
93+
94+
const { removed, notFound } = await removePatch(
95+
argv.identifier,
96+
manifestPath,
97+
)
98+
99+
if (notFound) {
100+
console.error(`No patch found matching identifier: ${argv.identifier}`)
101+
process.exit(1)
102+
}
103+
104+
console.log(`Removed ${removed.length} patch(es):`)
105+
for (const purl of removed) {
106+
console.log(` - ${purl}`)
107+
}
108+
109+
console.log(`\nManifest updated at ${manifestPath}`)
110+
console.log('Tip: Run "socket-patch gc" to clean up unused blob files.')
111+
112+
process.exit(0)
113+
} catch (err) {
114+
const errorMessage = err instanceof Error ? err.message : String(err)
115+
console.error(`Error: ${errorMessage}`)
116+
process.exit(1)
117+
}
118+
},
119+
}

0 commit comments

Comments
 (0)