@jayrdeaton/scripts 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,6 +10,36 @@ npm install -g @jayrdeaton/scripts
10
10
 
11
11
  ## Commands
12
12
 
13
+ ### `jrd base64`
14
+
15
+ Encode or decode base64 strings and files.
16
+
17
+ ```
18
+ jrd base64 encode <value> [options]
19
+ jrd base64 decode <value> [options]
20
+
21
+ Options:
22
+ -f, --file Treat value as a file path
23
+ -c, --copy Copy result to clipboard
24
+ ```
25
+
26
+ ---
27
+
28
+ ### `jrd binary`
29
+
30
+ Encode a file to a binary string, or restore a binary string back to its original file.
31
+
32
+ ```
33
+ jrd binary encode <file> [options]
34
+ jrd binary decode <file> <destination>
35
+
36
+ Options (encode):
37
+ -c, --copy Copy result to clipboard
38
+ -o, --output <dest> Write result to a file
39
+ ```
40
+
41
+ ---
42
+
13
43
  ### `jrd bump-ota`
14
44
 
15
45
  Bumps `otaVersion` in `src/constants/release.ts` and auto-commits the change. Requires a clean working directory.
@@ -23,6 +53,57 @@ Options:
23
53
 
24
54
  ---
25
55
 
56
+ ### `jrd check-domains`
57
+
58
+ Check domain availability via RDAP using a wildcard pattern where `?` matches any letter. Supports `.com`, `.net`, `.org`, and `.io`.
59
+
60
+ ```
61
+ jrd check-domains <pattern> [options]
62
+
63
+ Options:
64
+ -c, --concurrency <n> Concurrent requests (default: 5)
65
+ -o, --output <file> Write available domains to a file
66
+ ```
67
+
68
+ Example: `jrd check-domains ??fu.com`
69
+
70
+ ---
71
+
72
+ ### `jrd clean-builds`
73
+
74
+ Delete build artifacts (`build`, `dist`, `ios`, `android`) across one or more repos. Dry run by default.
75
+
76
+ ```
77
+ jrd clean-builds [dir...] [options]
78
+
79
+ Options:
80
+ -m, --modules Also delete node_modules
81
+ -D, --delete Actually delete (default is dry run)
82
+ ```
83
+
84
+ ---
85
+
86
+ ### `jrd clean-junk`
87
+
88
+ Delete files and directories matching given criteria.
89
+
90
+ ```
91
+ jrd clean-junk [dir] [options]
92
+
93
+ Options:
94
+ -i, --includes <str> Delete items whose name includes this string
95
+ -e, --excludes <str> Delete items whose name excludes this string
96
+ --extension <str> Delete files with this extension
97
+ -s, --size <mb> Delete items under this size in MB
98
+ -r, --recursive Scan directories recursively
99
+ -f, --force Skip confirmation
100
+ -v, --verbose Show matched items before deleting
101
+ ```
102
+
103
+ Example: `jrd clean-junk --includes .DS_Store -r`
104
+
105
+ ---
106
+
26
107
  ### `jrd code-count`
27
108
 
28
109
  Count lines of code by file extension across one or more paths.
@@ -37,6 +118,18 @@ Options:
37
118
 
38
119
  ---
39
120
 
121
+ ### `jrd focus`
122
+
123
+ Bring an application to the front using AppleScript.
124
+
125
+ ```
126
+ jrd focus [app]
127
+ ```
128
+
129
+ Default app is `Terminal`.
130
+
131
+ ---
132
+
40
133
  ### `jrd folder-sizes`
41
134
 
42
135
  List all subdirectories sorted by size, largest first.
@@ -47,6 +140,34 @@ jrd folder-sizes [dir]
47
140
 
48
141
  ---
49
142
 
143
+ ### `jrd npm-downloads`
144
+
145
+ List all your npm packages sorted by total downloads.
146
+
147
+ ```
148
+ jrd npm-downloads [options]
149
+
150
+ Options:
151
+ -u, --user <name> npm username (defaults to npm whoami)
152
+ -p, --period <period> last-day | last-week | last-month | last-year (default: last-month)
153
+ -m, --mtd Use month-to-date instead of rolling 30 days
154
+ ```
155
+
156
+ ---
157
+
158
+ ### `jrd npm-namer`
159
+
160
+ Check npm package name availability. Automatically checks nospace, hyphenated, and underscored variants.
161
+
162
+ ```
163
+ jrd npm-namer <name> [options]
164
+
165
+ Options:
166
+ -s, --synonyms Also check synonyms of the name
167
+ ```
168
+
169
+ ---
170
+
50
171
  ### `jrd rename-season`
51
172
 
52
173
  Rename files in a directory to `SxEE` format for TV library pickup (e.g. `1x01.mkv`, `1x02.mkv`).
package/package.json CHANGED
@@ -1,8 +1,12 @@
1
1
  {
2
2
  "name": "@jayrdeaton/scripts",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "private": false,
5
5
  "description": "Personal dev scripts",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/jayrdeaton/Scripts"
9
+ },
6
10
  "license": "MIT",
7
11
  "type": "module",
8
12
  "bin": {
@@ -13,11 +17,15 @@
13
17
  "src"
14
18
  ],
15
19
  "scripts": {
16
- "fix": "eslint --fix",
17
- "lint": "eslint"
20
+ "fix": "npm run lint:fix",
21
+ "lint": "eslint",
22
+ "lint:fix": "eslint --fix",
23
+ "release:major": "npm version major && git push --follow-tags",
24
+ "release:minor": "npm version minor && git push --follow-tags",
25
+ "release:patch": "npm version patch && git push --follow-tags",
26
+ "preversion": "npm ci && npm run lint"
18
27
  },
19
28
  "dependencies": {
20
- "cosmetic": "latest",
21
29
  "termkit": "latest"
22
30
  },
23
31
  "devDependencies": {
@@ -28,10 +36,10 @@
28
36
  "eslint-plugin-simple-import-sort": "^13.0.0",
29
37
  "prettier": "^3.8.3"
30
38
  },
31
- "publishConfig": {
32
- "access": "public"
33
- },
34
39
  "engines": {
35
40
  "node": ">=20"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public"
36
44
  }
37
45
  }
@@ -0,0 +1,45 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { execSync } from 'node:child_process'
3
+
4
+ import { Color, command as createCommand } from 'termkit'
5
+
6
+ function resolveInput(value, file) {
7
+ if (file) {
8
+ if (!existsSync(value)) throw new Error(`${value} not found`)
9
+ return readFileSync(value)
10
+ }
11
+ return value
12
+ }
13
+
14
+ function output(result, copy) {
15
+ if (copy) {
16
+ execSync('pbcopy', { input: result })
17
+ console.log(`${Color.green('Success:')} Copied to clipboard`)
18
+ } else {
19
+ console.log(result)
20
+ }
21
+ }
22
+
23
+ export const command = createCommand('base64')
24
+ .description('Encode or decode base64')
25
+ .commands([
26
+ createCommand('encode')
27
+ .description('Encode a string or file to base64')
28
+ .variable('<value>')
29
+ .option('f', 'file', null, 'treat value as a file path')
30
+ .option('c', 'copy', null, 'copy result to clipboard')
31
+ .action(({ value, file, copy }) => {
32
+ const input = resolveInput(value, file)
33
+ const result = Buffer.from(input).toString('base64')
34
+ output(result, copy)
35
+ }),
36
+
37
+ createCommand('decode')
38
+ .description('Decode a base64 string')
39
+ .variable('<value>')
40
+ .option('c', 'copy', null, 'copy result to clipboard')
41
+ .action(({ value, copy }) => {
42
+ const result = Buffer.from(value, 'base64').toString('utf8')
43
+ output(result, copy)
44
+ }),
45
+ ])
@@ -0,0 +1,38 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { execSync } from 'node:child_process'
3
+
4
+ import { Color, command as createCommand } from 'termkit'
5
+
6
+ export const command = createCommand('binary')
7
+ .description('Encode or decode binary strings')
8
+ .commands([
9
+ createCommand('encode')
10
+ .description('Encode a file to a binary string')
11
+ .variable('<file>')
12
+ .option('c', 'copy', null, 'copy result to clipboard')
13
+ .option('o', 'output', '<dest>', 'write result to a file')
14
+ .action(({ file, copy, output }) => {
15
+ if (!existsSync(file)) throw new Error(`${file} not found`)
16
+ const result = JSON.stringify(readFileSync(file, 'binary'))
17
+ if (copy) {
18
+ execSync('pbcopy', { input: result })
19
+ console.log(`${Color.green('Success:')} Copied binary string to clipboard`)
20
+ } else if (output) {
21
+ writeFileSync(output, result)
22
+ console.log(`${Color.green('Success:')} Wrote binary string to ${output}`)
23
+ } else {
24
+ console.log(result)
25
+ }
26
+ }),
27
+
28
+ createCommand('decode')
29
+ .description('Restore a binary string file back to its original format')
30
+ .variable('<file>')
31
+ .variable('<destination>')
32
+ .action(({ file, destination }) => {
33
+ if (!existsSync(file)) throw new Error(`${file} not found`)
34
+ const buf = Buffer.from(JSON.parse(readFileSync(file, 'utf8')), 'binary')
35
+ writeFileSync(destination, buf)
36
+ console.log(`${Color.green('Success:')} Restored ${file} to ${destination}`)
37
+ }),
38
+ ])
@@ -2,23 +2,24 @@ import { execSync } from 'node:child_process'
2
2
  import { readFileSync, writeFileSync } from 'node:fs'
3
3
  import { resolve } from 'node:path'
4
4
 
5
- import cosmetic from 'cosmetic'
6
- import { command as createCommand } from 'termkit'
5
+ import { command as createCommand, Spinner } from 'termkit'
7
6
 
8
7
  export const command = createCommand('bump-ota')
9
8
  .description('Bump otaVersion in src/constants/release.ts and commit')
10
9
  .option('f', 'file', '[file]', 'Path to release file (default: src/constants/release.ts)')
11
10
  .action(async (options) => {
12
11
  const filePath = resolve(process.cwd(), options.file ?? 'src/constants/release.ts')
12
+ const spinner = new Spinner({ text: 'Checking git status...' })
13
+ spinner.start()
13
14
 
14
15
  try {
15
16
  const status = execSync('git status --porcelain').toString().trim()
16
17
  if (status) {
17
- console.error(cosmetic.red('Working directory is not clean. Commit or stash changes first.'))
18
+ spinner.fail('Working directory is not clean. Commit or stash changes first.')
18
19
  process.exit(1)
19
20
  }
20
21
  } catch (err) {
21
- console.error(cosmetic.red(`Failed to check git status: ${err.message}`))
22
+ spinner.fail(`Failed to check git status: ${err.message}`)
22
23
  process.exit(1)
23
24
  }
24
25
 
@@ -26,7 +27,7 @@ export const command = createCommand('bump-ota')
26
27
  try {
27
28
  source = readFileSync(filePath, 'utf8')
28
29
  } catch {
29
- console.error(cosmetic.red(`Could not read file: ${filePath}`))
30
+ spinner.fail(`Could not read file: ${filePath}`)
30
31
  process.exit(1)
31
32
  }
32
33
 
@@ -34,7 +35,7 @@ export const command = createCommand('bump-ota')
34
35
  const match = source.match(pattern)
35
36
 
36
37
  if (!match) {
37
- console.error(cosmetic.red(`Could not find otaVersion in ${filePath}`))
38
+ spinner.fail(`Could not find otaVersion in ${filePath}`)
38
39
  process.exit(1)
39
40
  }
40
41
 
@@ -43,13 +44,14 @@ export const command = createCommand('bump-ota')
43
44
 
44
45
  writeFileSync(filePath, source.replace(pattern, `$1${next}`))
45
46
 
47
+ spinner.message('Committing...')
46
48
  try {
47
49
  execSync(`git add ${filePath}`)
48
50
  execSync(`git commit -m "otaVersion ${current} -> ${next}"`)
49
51
  } catch (err) {
50
- console.error(cosmetic.red(`Auto-commit failed: ${err.message}`))
52
+ spinner.fail(`Auto-commit failed: ${err.message}`)
51
53
  process.exit(1)
52
54
  }
53
55
 
54
- console.log(cosmetic.bold.green(`otaVersion bumped: ${current} -> ${next}`))
56
+ spinner.succeed(`otaVersion bumped: ${current} -> ${next}`)
55
57
  })
@@ -0,0 +1,102 @@
1
+ import { writeFile } from 'node:fs/promises'
2
+
3
+ import { Color, command as createCommand, Spinner } from 'termkit'
4
+
5
+ const RDAP = {
6
+ com: 'https://rdap.verisign.com/com/v1',
7
+ net: 'https://rdap.verisign.com/net/v1',
8
+ org: 'https://rdap.publicinterestregistry.org/rdap/org/v1',
9
+ io: 'https://rdap.nic.io/v1'
10
+ }
11
+
12
+ const ALPHA = 'abcdefghijklmnopqrstuvwxyz'.split('')
13
+
14
+ function expandPattern(pattern) {
15
+ const dot = pattern.lastIndexOf('.')
16
+ if (dot === -1) throw new Error(`Pattern must include a TLD, e.g. ??fu.com`)
17
+ const namePart = pattern.slice(0, dot).toLowerCase()
18
+ const tld = pattern.slice(dot + 1).toLowerCase()
19
+ if (!RDAP[tld]) throw new Error(`.${tld} is not supported — supported TLDs: ${Object.keys(RDAP).join(', ')}`)
20
+
21
+ function expand(s) {
22
+ const i = s.indexOf('?')
23
+ if (i === -1) return [s]
24
+ return ALPHA.flatMap((c) => expand(s.slice(0, i) + c + s.slice(i + 1)))
25
+ }
26
+
27
+ return expand(namePart).map((n) => `${n}.${tld}`)
28
+ }
29
+
30
+ async function isAvailable(domain) {
31
+ const tld = domain.slice(domain.lastIndexOf('.') + 1)
32
+ const res = await fetch(`${RDAP[tld]}/domain/${domain}`, { signal: AbortSignal.timeout(10_000) })
33
+ if (res.status === 404) return true
34
+ if (res.ok) return false
35
+ throw new Error(`HTTP ${res.status}`)
36
+ }
37
+
38
+ async function withConcurrency(items, limit, fn) {
39
+ let i = 0
40
+ async function worker() {
41
+ while (i < items.length) {
42
+ const item = items[i++]
43
+ await fn(item)
44
+ }
45
+ }
46
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
47
+ }
48
+
49
+ export const command = createCommand('check-domains')
50
+ .description('Check domain availability via RDAP for a wildcard pattern (? = any letter)')
51
+ .variable('[pattern]')
52
+ .option('c', 'concurrency', '<n>', 'Concurrent requests (default: 5)')
53
+ .option('o', 'output', '<file>', 'Write available domains to a file')
54
+ .action(async (args) => {
55
+ const pattern = args.pattern
56
+ if (!pattern) {
57
+ console.error(Color.red('Usage: jrd check-domains <pattern> e.g. ??fu.com or ???.io'))
58
+ process.exit(1)
59
+ }
60
+
61
+ const concurrency = Math.max(1, parseInt(args.concurrency ?? '5', 10))
62
+
63
+ let domains
64
+ try {
65
+ domains = expandPattern(pattern)
66
+ } catch (err) {
67
+ console.error(Color.red(err.message))
68
+ process.exit(1)
69
+ }
70
+
71
+ const available = []
72
+ let checked = 0
73
+ let errors = 0
74
+
75
+ const spinner = new Spinner({ text: `0/${domains.length}` })
76
+ spinner.start()
77
+
78
+ await withConcurrency(domains, concurrency, async (domain) => {
79
+ try {
80
+ const ok = await isAvailable(domain)
81
+ checked++
82
+ spinner.message(`${checked}/${domains.length} ${Color.faint(domain)}`)
83
+ if (ok) {
84
+ available.push(domain)
85
+ spinner.stop()
86
+ console.log(` ${Color.green(domain)}`)
87
+ spinner.start()
88
+ spinner.message(`${checked}/${domains.length} ${Color.faint(domain)}`)
89
+ }
90
+ } catch {
91
+ errors++
92
+ checked++
93
+ }
94
+ })
95
+
96
+ if (args.output && available.length > 0) {
97
+ await writeFile(args.output, available.join('\n') + '\n')
98
+ spinner.succeed(`${available.length} available · saved to ${args.output}` + (errors > 0 ? Color.faint(` (${errors} errors)`) : ''))
99
+ } else {
100
+ spinner.succeed(`${available.length} available of ${domains.length}` + (errors > 0 ? Color.faint(` (${errors} errors)`) : ''))
101
+ }
102
+ })
@@ -0,0 +1,129 @@
1
+ import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+
4
+ import { Color, command as createCommand, Spinner } from 'termkit'
5
+
6
+ const BUILD_TARGETS = ['build', 'dist', 'ios', 'android']
7
+
8
+ function getDirSize(dirPath) {
9
+ let total = 0
10
+ try {
11
+ for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
12
+ const full = join(dirPath, entry.name)
13
+ if (entry.isSymbolicLink()) continue
14
+ if (entry.isDirectory()) {
15
+ total += getDirSize(full)
16
+ } else {
17
+ try {
18
+ total += statSync(full).size
19
+ } catch {}
20
+ }
21
+ }
22
+ } catch {}
23
+ return total
24
+ }
25
+
26
+ function formatSize(bytes) {
27
+ if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`
28
+ if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`
29
+ if (bytes >= 1_024) return `${(bytes / 1_024).toFixed(1)} KB`
30
+ return `${bytes} B`
31
+ }
32
+
33
+ function findTargets(projectDir, targets) {
34
+ return targets
35
+ .map((t) => join(projectDir, t))
36
+ .filter((p) => {
37
+ try {
38
+ return statSync(p).isDirectory()
39
+ } catch {
40
+ return false
41
+ }
42
+ })
43
+ }
44
+
45
+ function resolveProjects(dirs) {
46
+ const projects = []
47
+ for (const dir of dirs) {
48
+ const root = resolve(dir)
49
+ if (existsSync(join(root, 'package.json'))) {
50
+ projects.push({ root, path: root })
51
+ } else {
52
+ let entries
53
+ try {
54
+ entries = readdirSync(root, { withFileTypes: true })
55
+ } catch {
56
+ console.error(Color.red(`Could not read directory: ${root}`))
57
+ process.exit(1)
58
+ }
59
+ for (const e of entries) {
60
+ if (e.isDirectory()) projects.push({ root, path: join(root, e.name) })
61
+ }
62
+ }
63
+ }
64
+ return projects
65
+ }
66
+
67
+ export const command = createCommand('clean-builds')
68
+ .description('Delete build artifacts across repos — dry run by default')
69
+ .variable('[dir...]')
70
+ .option('m', 'modules', null, 'Also delete node_modules')
71
+ .option('D', 'delete', null, 'Actually delete (default is dry run)')
72
+ .action(async (options) => {
73
+ const dirs = options.dir?.length ? options.dir : ['.']
74
+ const targets = [...BUILD_TARGETS]
75
+ if (options.modules) targets.push('node_modules')
76
+
77
+ const projects = resolveProjects(dirs)
78
+
79
+ const spinner = new Spinner({ text: 'Scanning...' })
80
+ spinner.start()
81
+
82
+ const toDelete = []
83
+ for (const { root, path: projectDir } of projects) {
84
+ spinner.message(projectDir.split('/').at(-1))
85
+ for (const targetPath of findTargets(projectDir, targets)) {
86
+ const size = getDirSize(targetPath)
87
+ toDelete.push({ root, path: targetPath, size })
88
+ }
89
+ }
90
+
91
+ spinner.stop()
92
+
93
+ if (toDelete.length === 0) {
94
+ console.log(Color.green('Nothing to clean.'))
95
+ return
96
+ }
97
+
98
+ const totalSize = toDelete.reduce((sum, f) => sum + f.size, 0)
99
+
100
+ if (!options.delete) {
101
+ console.log(Color.bold.yellow('\nDry run — pass --delete to remove:\n'))
102
+ for (const { root, path, size } of toDelete) {
103
+ const rel = path.replace(root + '/', '')
104
+ console.log(` ${Color.red(rel)} ${Color.faint(formatSize(size))}`)
105
+ }
106
+ console.log(Color.faint(`\n${toDelete.length} folder${toDelete.length !== 1 ? 's' : ''} ${formatSize(totalSize)} total`))
107
+ return
108
+ }
109
+
110
+ const deleteSpinner = new Spinner({ text: 'Deleting...' })
111
+ deleteSpinner.start()
112
+
113
+ let deleted = 0
114
+ let freed = 0
115
+ for (const { path, size } of toDelete) {
116
+ deleteSpinner.message(path.split('/').slice(-2).join('/'))
117
+ try {
118
+ rmSync(path, { recursive: true, force: true })
119
+ deleted++
120
+ freed += size
121
+ } catch (err) {
122
+ deleteSpinner.stop()
123
+ console.error(Color.red(` Failed: ${path} — ${err.message}`))
124
+ deleteSpinner.start()
125
+ }
126
+ }
127
+
128
+ deleteSpinner.succeed(`Deleted ${deleted} folder${deleted !== 1 ? 's' : ''}, ${formatSize(freed)} freed`)
129
+ })
@@ -0,0 +1,123 @@
1
+ import { extname, basename } from 'node:path'
2
+ import { readdirSync, rmSync, statSync } from 'node:fs'
3
+ import { createInterface } from 'node:readline'
4
+ import { join, resolve } from 'node:path'
5
+
6
+ import { Color, command as createCommand, Spinner } from 'termkit'
7
+
8
+ function prompt(question) {
9
+ return new Promise((res) => {
10
+ const rl = createInterface({ input: process.stdin, output: process.stdout })
11
+ rl.question(question, (answer) => {
12
+ rl.close()
13
+ res(answer)
14
+ })
15
+ })
16
+ }
17
+
18
+ function getItems(dir, recursive) {
19
+ const items = []
20
+ try {
21
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
22
+ const full = join(dir, entry.name)
23
+ items.push(full)
24
+ if (recursive && entry.isDirectory()) items.push(...getItems(full, recursive))
25
+ }
26
+ } catch {}
27
+ return items
28
+ }
29
+
30
+ function getItemSize(itemPath) {
31
+ let total = 0
32
+ try {
33
+ const stat = statSync(itemPath)
34
+ if (!stat.isDirectory()) return stat.size
35
+ for (const entry of readdirSync(itemPath, { withFileTypes: true })) {
36
+ const full = join(itemPath, entry.name)
37
+ if (entry.isSymbolicLink()) continue
38
+ total += entry.isDirectory() ? getItemSize(full) : statSync(full).size
39
+ }
40
+ } catch {}
41
+ return total
42
+ }
43
+
44
+ export const command = createCommand('clean-junk')
45
+ .description('Delete files and directories matching given criteria')
46
+ .variable('[dir]')
47
+ .option('i', 'includes', '<str>', 'Delete items whose name includes this string')
48
+ .option('e', 'excludes', '<str>', 'Delete items whose name excludes this string')
49
+ .option(null, 'extension', '<str>', 'Delete files with this extension')
50
+ .option('s', 'size', '<mb>', 'Delete items under this size in MB')
51
+ .option('r', 'recursive', null, 'Scan directories recursively')
52
+ .option('f', 'force', null, 'Skip confirmation')
53
+ .option('v', 'verbose', null, 'Show matched items before deleting')
54
+ .action(async (options) => {
55
+ let { extension, force, verbose } = options
56
+ const dir = resolve(options.dir ?? '.')
57
+ const { includes, excludes, recursive, size } = options
58
+
59
+ if (extension && !extension.startsWith('.')) extension = `.${extension}`
60
+
61
+ if (!includes && !excludes && !extension && !size) {
62
+ console.error(Color.red('Requires at least one filter: --includes, --excludes, --extension, or --size'))
63
+ process.exit(1)
64
+ }
65
+
66
+ const spinner = new Spinner({ text: 'Scanning...' })
67
+ spinner.start()
68
+ const items = getItems(dir, recursive)
69
+ spinner.stop()
70
+
71
+ const sizeBytes = size ? size * 1_048_576 : null
72
+ const targets = []
73
+
74
+ const filterSpinner = new Spinner({ text: 'Filtering...' })
75
+ filterSpinner.start()
76
+
77
+ for (const item of items) {
78
+ const name = basename(item)
79
+ let match = true
80
+ if (includes && !name.includes(includes)) match = false
81
+ if (excludes && name.includes(excludes)) match = false
82
+ if (extension && extname(item) !== extension) match = false
83
+ if (sizeBytes !== null && getItemSize(item) > sizeBytes) match = false
84
+ if (match) targets.push(item)
85
+ }
86
+
87
+ filterSpinner.stop()
88
+
89
+ if (targets.length === 0) {
90
+ console.log(Color.green('No junk items found.'))
91
+ return
92
+ }
93
+
94
+ if (verbose) {
95
+ for (const item of targets) console.log(` ${item}`)
96
+ }
97
+
98
+ if (!force) {
99
+ const answer = await prompt(`Delete ${targets.length} item${targets.length !== 1 ? 's' : ''}? (Y/n) `)
100
+ if (answer.toLowerCase() !== 'y') {
101
+ console.log('Aborted.')
102
+ return
103
+ }
104
+ }
105
+
106
+ const deleteSpinner = new Spinner({ text: 'Deleting...' })
107
+ deleteSpinner.start()
108
+
109
+ let deleted = 0
110
+ for (const item of targets) {
111
+ deleteSpinner.message(basename(item))
112
+ try {
113
+ rmSync(item, { recursive: true, force: true })
114
+ deleted++
115
+ } catch (err) {
116
+ deleteSpinner.stop()
117
+ console.error(Color.red(` Failed: ${item} — ${err.message}`))
118
+ deleteSpinner.start()
119
+ }
120
+ }
121
+
122
+ deleteSpinner.succeed(`Deleted ${deleted} item${deleted !== 1 ? 's' : ''}`)
123
+ })