@jayrdeaton/scripts 1.0.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 ADDED
@@ -0,0 +1,85 @@
1
+ # @jayrdeaton/scripts
2
+
3
+ Personal dev CLI. Invoked as `jrd`.
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm install -g @jayrdeaton/scripts
9
+ ```
10
+
11
+ ## Commands
12
+
13
+ ### `jrd bump-ota`
14
+
15
+ Bumps `otaVersion` in `src/constants/release.ts` and auto-commits the change. Requires a clean working directory.
16
+
17
+ ```
18
+ jrd bump-ota [options]
19
+
20
+ Options:
21
+ -f, --file <file> Path to release file (default: src/constants/release.ts)
22
+ ```
23
+
24
+ ---
25
+
26
+ ### `jrd code-count`
27
+
28
+ Count lines of code by file extension across one or more paths.
29
+
30
+ ```
31
+ jrd code-count [paths...] [options]
32
+
33
+ Options:
34
+ -i, --ignore <types...> Ignore files or file types (e.g. .json)
35
+ -r, --recursive Scan folders recursively
36
+ ```
37
+
38
+ ---
39
+
40
+ ### `jrd folder-sizes`
41
+
42
+ List all subdirectories sorted by size, largest first.
43
+
44
+ ```
45
+ jrd folder-sizes [dir]
46
+ ```
47
+
48
+ ---
49
+
50
+ ### `jrd rename-season`
51
+
52
+ Rename files in a directory to `SxEE` format for TV library pickup (e.g. `1x01.mkv`, `1x02.mkv`).
53
+
54
+ ```
55
+ jrd rename-season <season> [dir]
56
+ ```
57
+
58
+ ---
59
+
60
+ ### `jrd repo-status`
61
+
62
+ Scan a directory of git repos and report which ones have dirty files, untracked files, or unpushed commits.
63
+
64
+ ```
65
+ jrd repo-status [dir]
66
+ ```
67
+
68
+ ---
69
+
70
+ ### `jrd update-deps`
71
+
72
+ Update all npm dependencies to `@latest`. Automatically runs `npx expo install --fix` if the project uses Expo.
73
+
74
+ ```
75
+ jrd update-deps [options]
76
+
77
+ Options:
78
+ -d, --dev Only update devDependencies
79
+ -p, --prod Only update dependencies
80
+ -l, --legacy Pass --legacy-peer-deps to npm install
81
+ ```
82
+
83
+ ## Requirements
84
+
85
+ Node >= 20
package/bin/cli.mjs ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ import { readdirSync } from 'node:fs'
3
+ import { dirname, resolve } from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+
6
+ import { command, parse } from 'termkit'
7
+
8
+ const __dir = dirname(fileURLToPath(import.meta.url))
9
+ const commandsDir = resolve(__dir, '../src/commands')
10
+
11
+ // Must be created first so termkit registers this as the root command
12
+ const program = command('jrd').description('Personal dev scripts')
13
+
14
+ const files = readdirSync(commandsDir).filter((f) => f.endsWith('.mjs'))
15
+ const mods = await Promise.all(files.map((f) => import(`../src/commands/${f}`)))
16
+
17
+ program.commands(mods.map((m) => m.command))
18
+
19
+ try {
20
+ await parse(process.argv)
21
+ } catch (err) {
22
+ console.error(err.message)
23
+ process.exit(1)
24
+ }
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@jayrdeaton/scripts",
3
+ "version": "1.0.0",
4
+ "private": false,
5
+ "description": "Personal dev scripts",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "bin": {
9
+ "jrd": "./bin/cli.mjs"
10
+ },
11
+ "files": [
12
+ "bin",
13
+ "src"
14
+ ],
15
+ "scripts": {
16
+ "fix": "eslint --fix",
17
+ "lint": "eslint"
18
+ },
19
+ "dependencies": {
20
+ "cosmetic": "latest",
21
+ "termkit": "latest"
22
+ },
23
+ "devDependencies": {
24
+ "eslint": "^10.4.1",
25
+ "eslint-config-prettier": "^10.1.8",
26
+ "eslint-plugin-package-json": "^1.3.0",
27
+ "eslint-plugin-prettier": "^5.5.6",
28
+ "eslint-plugin-simple-import-sort": "^13.0.0",
29
+ "prettier": "^3.8.3"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "engines": {
35
+ "node": ">=20"
36
+ }
37
+ }
@@ -0,0 +1,55 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { readFileSync, writeFileSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+
5
+ import cosmetic from 'cosmetic'
6
+ import { command as createCommand } from 'termkit'
7
+
8
+ export const command = createCommand('bump-ota')
9
+ .description('Bump otaVersion in src/constants/release.ts and commit')
10
+ .option('f', 'file', '[file]', 'Path to release file (default: src/constants/release.ts)')
11
+ .action(async (options) => {
12
+ const filePath = resolve(process.cwd(), options.file ?? 'src/constants/release.ts')
13
+
14
+ try {
15
+ const status = execSync('git status --porcelain').toString().trim()
16
+ if (status) {
17
+ console.error(cosmetic.red('Working directory is not clean. Commit or stash changes first.'))
18
+ process.exit(1)
19
+ }
20
+ } catch (err) {
21
+ console.error(cosmetic.red(`Failed to check git status: ${err.message}`))
22
+ process.exit(1)
23
+ }
24
+
25
+ let source
26
+ try {
27
+ source = readFileSync(filePath, 'utf8')
28
+ } catch {
29
+ console.error(cosmetic.red(`Could not read file: ${filePath}`))
30
+ process.exit(1)
31
+ }
32
+
33
+ const pattern = /(otaVersion:\s*)(\d+)/
34
+ const match = source.match(pattern)
35
+
36
+ if (!match) {
37
+ console.error(cosmetic.red(`Could not find otaVersion in ${filePath}`))
38
+ process.exit(1)
39
+ }
40
+
41
+ const current = parseInt(match[2], 10)
42
+ const next = current + 1
43
+
44
+ writeFileSync(filePath, source.replace(pattern, `$1${next}`))
45
+
46
+ try {
47
+ execSync(`git add ${filePath}`)
48
+ execSync(`git commit -m "otaVersion ${current} -> ${next}"`)
49
+ } catch (err) {
50
+ console.error(cosmetic.red(`Auto-commit failed: ${err.message}`))
51
+ process.exit(1)
52
+ }
53
+
54
+ console.log(cosmetic.bold.green(`otaVersion bumped: ${current} -> ${next}`))
55
+ })
@@ -0,0 +1,87 @@
1
+ import { createReadStream, existsSync, lstatSync, readdirSync } from 'node:fs'
2
+ import { extname, join, resolve } from 'node:path'
3
+
4
+ import cosmetic from 'cosmetic'
5
+ import { command as createCommand } from 'termkit'
6
+
7
+ const WHITELIST = new Set([
8
+ '.cjs', '.css', '.csv', '.ejs', '.env', '.gitignore', '.haml', '.html', '.java',
9
+ '.js', '.json', '.mjs', '.paw', '.plist', '.py', '.rake', '.scss', '.sh', '.sql',
10
+ '.stl', '.swift', '.ts', '.tsx', '.txt', '.xib', '.xml', '.yaml', '.yml',
11
+ ])
12
+
13
+ const SKIP = new Set([
14
+ '.DS_Store', '.git', 'Carthage', 'Dockerfile', 'LICENSE', 'Test',
15
+ 'node_modules', 'package-lock.json',
16
+ ])
17
+
18
+ function getFiles(base, { ignore = [], recursive = false } = {}) {
19
+ try {
20
+ base = resolve(base)
21
+ if (!lstatSync(base).isDirectory()) {
22
+ return existsSync(base) ? [base] : []
23
+ }
24
+ const paths = []
25
+ for (const item of readdirSync(base)) {
26
+ const ext = extname(item)
27
+ if (SKIP.has(item) || ignore.includes(item) || ignore.includes(`*${ext}`)) continue
28
+ const full = join(base, item)
29
+ const isDir = lstatSync(full).isDirectory()
30
+ if (recursive && isDir) {
31
+ paths.push(...getFiles(full, { ignore, recursive }))
32
+ } else if (!isDir && WHITELIST.has(ext)) {
33
+ paths.push(full)
34
+ }
35
+ }
36
+ return paths
37
+ } catch (err) {
38
+ console.log(`error reading ${base}: ${err.message}`)
39
+ return []
40
+ }
41
+ }
42
+
43
+ function countLines(path) {
44
+ return new Promise((res) => {
45
+ let count = 0
46
+ createReadStream(path)
47
+ .on('data', (chunk) => {
48
+ for (let i = 0; i < chunk.length; i++) if (chunk[i] === 10) count++
49
+ })
50
+ .on('error', () => res(0))
51
+ .on('end', () => res(count))
52
+ })
53
+ }
54
+
55
+ function commaString(n) {
56
+ return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')
57
+ }
58
+
59
+ export const command = createCommand('code-count')
60
+ .description('Count lines of code by file type')
61
+ .variable('[paths...]')
62
+ .option('i', 'ignore', '[types...]', 'ignore files or file types')
63
+ .option('r', 'recursive', null, 'scan folders recursively')
64
+ .action(async (args) => {
65
+ let { ignore, paths, recursive } = args
66
+ if (!paths || paths.length === 0) paths = ['.']
67
+ if (!ignore) ignore = []
68
+ ignore = ignore.reduce((a, i) => (a.includes(`*${extname(i)}`) ? a : [...a, `*${extname(i)}`]), [])
69
+
70
+ const allPaths = paths.flatMap((p) => getFiles(p, { ignore, recursive }))
71
+
72
+ const totals = {}
73
+ for (let i = 0; i < allPaths.length; i++) {
74
+ const path = allPaths[i]
75
+ const ext = extname(path)
76
+ if (!ext) continue
77
+ process.stdout.write(`checking files ${i + 1} / ${allPaths.length}\r`)
78
+ totals[ext] = (totals[ext] ?? 0) + (await countLines(path))
79
+ }
80
+ if (process.stdout.clearLine) process.stdout.clearLine(0)
81
+
82
+ for (const key of Object.keys(totals).sort()) {
83
+ console.log(`${key}: ${cosmetic.cyan(commaString(totals[key]))}`)
84
+ }
85
+ const total = Object.values(totals).reduce((a, n) => a + n, 0)
86
+ console.log(`total ${cosmetic.cyan(commaString(total))} in ${cosmetic.cyan(String(allPaths.length))} files`)
87
+ })
@@ -0,0 +1,67 @@
1
+ import { readdirSync, statSync } from 'node:fs'
2
+ import { join, resolve } from 'node:path'
3
+
4
+ import cosmetic from 'cosmetic'
5
+ import { command as createCommand } from 'termkit'
6
+
7
+ function getDirSize(dirPath) {
8
+ let total = 0
9
+ try {
10
+ for (const entry of readdirSync(dirPath, { withFileTypes: true })) {
11
+ const full = join(dirPath, entry.name)
12
+ if (entry.isSymbolicLink()) continue
13
+ if (entry.isDirectory()) {
14
+ total += getDirSize(full)
15
+ } else {
16
+ try {
17
+ total += statSync(full).size
18
+ } catch {}
19
+ }
20
+ }
21
+ } catch {}
22
+ return total
23
+ }
24
+
25
+ function formatSize(bytes) {
26
+ if (bytes >= 1_073_741_824) return `${(bytes / 1_073_741_824).toFixed(1)} GB`
27
+ if (bytes >= 1_048_576) return `${(bytes / 1_048_576).toFixed(1)} MB`
28
+ if (bytes >= 1_024) return `${(bytes / 1_024).toFixed(1)} KB`
29
+ return `${bytes} B`
30
+ }
31
+
32
+ export const command = createCommand('folder-sizes')
33
+ .description('List folders sorted by size, largest first')
34
+ .variable('[dir]')
35
+ .action(async (args) => {
36
+ const root = resolve(args.dir ?? '.')
37
+
38
+ let entries
39
+ try {
40
+ entries = readdirSync(root, { withFileTypes: true })
41
+ } catch {
42
+ console.error(cosmetic.red(`Could not read directory: ${root}`))
43
+ process.exit(1)
44
+ }
45
+
46
+ const folders = entries
47
+ .filter((e) => e.isDirectory())
48
+ .map((e) => {
49
+ const size = getDirSize(join(root, e.name))
50
+ return { name: e.name, size }
51
+ })
52
+ .sort((a, b) => b.size - a.size)
53
+
54
+ if (folders.length === 0) {
55
+ console.log(cosmetic.faint('No folders found.'))
56
+ return
57
+ }
58
+
59
+ const maxName = Math.max(...folders.map((f) => f.name.length))
60
+ const maxSize = Math.max(...folders.map((f) => formatSize(f.size).length))
61
+
62
+ for (const folder of folders) {
63
+ const name = folder.name.padEnd(maxName)
64
+ const size = formatSize(folder.size).padStart(maxSize)
65
+ console.log(` ${cosmetic.cyan(name)} ${cosmetic.bold(size)}`)
66
+ }
67
+ })
@@ -0,0 +1,45 @@
1
+ import { readdirSync, renameSync, statSync } from 'node:fs'
2
+ import { extname, join } from 'node:path'
3
+
4
+ import cosmetic from 'cosmetic'
5
+ import { command as createCommand } from 'termkit'
6
+
7
+ export const command = createCommand('rename-season')
8
+ .description('Rename files in a directory to SxEE format for TV library pickup')
9
+ .variable('<season> [dir]')
10
+ .action(async (args) => {
11
+ const { season, dir = '.' } = args
12
+
13
+ let files
14
+ try {
15
+ files = readdirSync(dir)
16
+ } catch {
17
+ console.error(cosmetic.red(`Could not read directory: ${dir}`))
18
+ process.exit(1)
19
+ }
20
+
21
+ let counter = 1
22
+ let renamed = 0
23
+
24
+ for (const file of files) {
25
+ if (file.startsWith('.')) continue
26
+
27
+ const filePath = join(dir, file)
28
+ if (!statSync(filePath).isFile()) continue
29
+
30
+ const ext = extname(file)
31
+ const episode = counter.toString().padStart(2, '0')
32
+ const newName = `${season}x${episode}${ext}`
33
+ const newPath = join(dir, newName)
34
+
35
+ if (filePath !== newPath) {
36
+ console.log(`${cosmetic.faint(file)} -> ${cosmetic.cyan(newName)}`)
37
+ renameSync(filePath, newPath)
38
+ renamed++
39
+ }
40
+
41
+ counter++
42
+ }
43
+
44
+ console.log(cosmetic.bold.green(`\nDone. Renamed ${renamed} files.`))
45
+ })
@@ -0,0 +1,94 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { readdirSync, statSync } from 'node:fs'
3
+ import { join, resolve } from 'node:path'
4
+
5
+ import cosmetic from 'cosmetic'
6
+ import { command as createCommand } from 'termkit'
7
+
8
+ function getGitStatus(dir) {
9
+ try {
10
+ const out = execSync('git status --porcelain', {
11
+ cwd: dir,
12
+ stdio: ['ignore', 'pipe', 'ignore'],
13
+ encoding: 'utf8',
14
+ })
15
+ const lines = out.split('\n').filter(Boolean)
16
+ const dirty = lines.filter((l) => !l.startsWith('??'))
17
+ const untracked = lines.filter((l) => l.startsWith('??'))
18
+
19
+ let unpushed = 0
20
+ try {
21
+ const ahead = execSync('git rev-list --count @{u}..HEAD', {
22
+ cwd: dir,
23
+ stdio: ['ignore', 'pipe', 'ignore'],
24
+ encoding: 'utf8',
25
+ }).trim()
26
+ unpushed = parseInt(ahead, 10) || 0
27
+ } catch {
28
+ // no upstream configured
29
+ }
30
+
31
+ return { isRepo: true, dirty, untracked, unpushed }
32
+ } catch {
33
+ return { isRepo: false }
34
+ }
35
+ }
36
+
37
+ export const command = createCommand('repo-status')
38
+ .description('Report dirty and untracked files across repos in a directory')
39
+ .variable('[dir]')
40
+ .action(async (args) => {
41
+ const root = resolve(args.dir ?? '.')
42
+
43
+ let entries
44
+ try {
45
+ entries = readdirSync(root)
46
+ } catch {
47
+ console.error(cosmetic.red(`Could not read directory: ${root}`))
48
+ process.exit(1)
49
+ }
50
+
51
+ const repos = entries
52
+ .filter((name) => {
53
+ try {
54
+ return statSync(join(root, name)).isDirectory()
55
+ } catch {
56
+ return false
57
+ }
58
+ })
59
+ .map((name) => ({ name, ...getGitStatus(join(root, name)) }))
60
+ .filter((r) => r.isRepo)
61
+
62
+ const dirty = repos.filter((r) => r.dirty.length > 0)
63
+ const untracked = repos.filter((r) => r.untracked.length > 0)
64
+ const unpushed = repos.filter((r) => r.unpushed > 0)
65
+ const clean = repos.filter((r) => r.dirty.length === 0 && r.untracked.length === 0 && r.unpushed === 0)
66
+
67
+ if (dirty.length === 0 && untracked.length === 0 && unpushed.length === 0) {
68
+ console.log(cosmetic.green(`All ${clean.length} repos are clean.`))
69
+ return
70
+ }
71
+
72
+ if (dirty.length > 0) {
73
+ console.log(cosmetic.bold.red(`\nDirty (${dirty.length})`))
74
+ for (const repo of dirty) {
75
+ console.log(` ${cosmetic.red(repo.name)} ${cosmetic.faint(`${repo.dirty.length} change${repo.dirty.length !== 1 ? 's' : ''}`)}`)
76
+ }
77
+ }
78
+
79
+ if (untracked.length > 0) {
80
+ console.log(cosmetic.bold.yellow(`\nUntracked (${untracked.length})`))
81
+ for (const repo of untracked) {
82
+ console.log(` ${cosmetic.yellow(repo.name)} ${cosmetic.faint(`${repo.untracked.length} file${repo.untracked.length !== 1 ? 's' : ''}`)}`)
83
+ }
84
+ }
85
+
86
+ if (unpushed.length > 0) {
87
+ console.log(cosmetic.bold.cyan(`\nUnpushed (${unpushed.length})`))
88
+ for (const repo of unpushed) {
89
+ console.log(` ${cosmetic.cyan(repo.name)} ${cosmetic.faint(`${repo.unpushed} commit${repo.unpushed !== 1 ? 's' : ''} ahead`)}`)
90
+ }
91
+ }
92
+
93
+ console.log(cosmetic.faint(`\n${clean.length} of ${repos.length} repos clean`))
94
+ })
@@ -0,0 +1,55 @@
1
+ import { execSync } from 'node:child_process'
2
+ import { readFileSync } from 'node:fs'
3
+ import { resolve } from 'node:path'
4
+
5
+ import cosmetic from 'cosmetic'
6
+ import { command as createCommand } from 'termkit'
7
+
8
+ function exec(cmd) {
9
+ console.log(cosmetic.faint(`$ ${cmd}`))
10
+ execSync(cmd, { stdio: 'inherit' })
11
+ }
12
+
13
+ function latestPackages(deps = {}) {
14
+ return Object.keys(deps).map((name) => `${name}@latest`)
15
+ }
16
+
17
+ export const command = createCommand('update-deps')
18
+ .description('Update all npm deps to @latest, then run expo install --fix if applicable')
19
+ .option('d', 'dev', null, 'Only update devDependencies')
20
+ .option('p', 'prod', null, 'Only update dependencies')
21
+ .option('l', 'legacy', null, 'Pass --legacy-peer-deps to npm install')
22
+ .action(async (options) => {
23
+ const pkgPath = resolve(process.cwd(), 'package.json')
24
+ let pkg
25
+
26
+ try {
27
+ pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
28
+ } catch {
29
+ console.error(cosmetic.red('No package.json found in current directory.'))
30
+ process.exit(1)
31
+ }
32
+
33
+ const prodDeps = latestPackages(pkg.dependencies)
34
+ const devDeps = latestPackages(pkg.devDependencies)
35
+ const legacyFlag = options.legacy ? ' --legacy-peer-deps' : ''
36
+
37
+ if (!options.dev && prodDeps.length) {
38
+ console.log(cosmetic.bold.cyan('\nUpdating dependencies...'))
39
+ exec(`npm install${legacyFlag} ${prodDeps.join(' ')}`)
40
+ }
41
+
42
+ if (!options.prod && devDeps.length) {
43
+ console.log(cosmetic.bold.cyan('\nUpdating devDependencies...'))
44
+ exec(`npm install --save-dev${legacyFlag} ${devDeps.join(' ')}`)
45
+ }
46
+
47
+ const hasExpo = pkg.dependencies?.expo !== undefined || pkg.devDependencies?.expo !== undefined
48
+
49
+ if (hasExpo) {
50
+ console.log(cosmetic.bold.cyan('\nFixing Expo managed versions...'))
51
+ exec(`npx expo install --fix${options.legacy ? ' -- --legacy-peer-deps' : ''}`)
52
+ }
53
+
54
+ console.log(cosmetic.bold.green('\nDone.'))
55
+ })