Skip to content

Commit 4a4d1b4

Browse files
feat(CLI): cleanup commands (#475)
* Add cli cache clear and uninstall commands Co-authored-by: me <me@kentcdodds.com> * Format cli cache commands Co-authored-by: me <me@kentcdodds.com> * fix(cli): handle USER_QUIT in uninstall command with exit code 0 When a user presses Ctrl+C during the uninstall confirmation prompt, the command should exit with code 0 (graceful cancellation) instead of code 1 (error), consistent with other commands like update and warm. Changes: - Re-throw USER_QUIT error from uninstall function instead of converting to success: false - Add try-catch in CLI handler to catch USER_QUIT and exit with code 0 Fixes: bd9b0305-7d31-40f3-85b7-71448869ad77 Co-authored-by: me <me@kentcdodds.com> * Replace cache cleanup with cleanup command Co-authored-by: me <me@kentcdodds.com> * Format cleanup command docs Co-authored-by: me <me@kentcdodds.com> * feat(cli): enhance cleanup command with size display and offline-videos support Adds comprehensive enhancements to the cleanup command: Features: - Calculate and display data sizes for all cleanup targets before deletion - Add offline-videos as a cleanup target option - Interactive workshop selection with size display - Show total data to be freed with size breakdowns - Support selective workshop deletion Changes: - Add filesystem utils for directory size calculation and formatting - Enhance cleanup command with size calculations for all targets - Add offline-videos target to cleanup options - Display sizes next to each target in interactive selection - Allow users to select specific workshops to delete (with sizes shown) - Fix USER_QUIT handling in cleanup command (exit code 0) This addresses user requirements: - Add an offline-videos option - Display how much data would be freed up with each option - Give an option to select specific workshops to cleanup Co-authored-by: me <me@kentcdodds.com> * test(cli): update cleanup tests to follow testing guidelines - Remove manual console mocking (use setup file's consoleError) - Use await expect(promise).resolves pattern - Improve test names to describe 'aha' behavior - Add test for offline-videos cleanup target - Follow testing best practices from vitest-setup.ts Co-authored-by: me <me@kentcdodds.com> * Expand cleanup options and workshop targeting Co-authored-by: me <me@kentcdodds.com> * Format cleanup updates Co-authored-by: me <me@kentcdodds.com> * Fix cleanup typecheck Co-authored-by: me <me@kentcdodds.com> * Fix offline video index typing Co-authored-by: me <me@kentcdodds.com> * Format cleanup typing fix Co-authored-by: me <me@kentcdodds.com> * Clarify cleanup offline videos Co-authored-by: me <me@kentcdodds.com> * Clarify cleanup workshop flags Co-authored-by: me <me@kentcdodds.com> * Format cleanup prompts Co-authored-by: me <me@kentcdodds.com> * fix: remove redundant console mocks from cleanup tests - Removed beforeEach/afterEach hooks with redundant console mocking - The global test setup already handles console.error and console.warn mocking - All tests use silent: true, so console output is already suppressed - Follows testing guidelines and patterns from warm.test.ts and update.test.ts Co-authored-by: me <me@kentcdodds.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 3d0e23a commit 4a4d1b4

7 files changed

Lines changed: 1541 additions & 0 deletions

File tree

docs/cli.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,49 @@ epicshop warm --silent
367367
- Pre-caches diff files for faster loading
368368
- Reports the number of apps loaded and diffs generated
369369

370+
### `cleanup`
371+
372+
Clean up local epicshop data using a multi-select prompt. Choose what to delete
373+
from workshops, caches, offline videos, preferences, and auth data.
374+
375+
```bash
376+
epicshop cleanup [options]
377+
```
378+
379+
#### Options
380+
381+
- `--targets, -t <name>` - Cleanup targets (repeatable): `caches`,
382+
`offline-videos`, `preferences`, `auth`
383+
- `--workshops <name>` - Workshops to clean (repeatable, by repo name or path)
384+
- `--workshop-actions <name>` - Workshop cleanup actions (repeatable): `files`,
385+
`caches`, `offline-videos`
386+
- `--force, -f` - Skip the confirmation prompt (default: false)
387+
- `--silent, -s` - Run without output logs (default: false)
388+
389+
#### Examples
390+
391+
```bash
392+
# Pick cleanup targets interactively (multi-select)
393+
epicshop cleanup
394+
395+
# Clean selected targets without prompting
396+
epicshop cleanup --targets caches --targets preferences --force
397+
398+
# Clean offline videos for selected workshops without prompting
399+
epicshop cleanup \
400+
--workshops full-stack-foundations \
401+
--workshop-actions offline-videos \
402+
--force
403+
```
404+
405+
#### Notes
406+
407+
- Warns about unpushed workshop changes before deletion
408+
- Removes cache and legacy cache directories when selected
409+
- Preferences/auth cleanup updates local data files in-place
410+
- Workshop cleanup prompts for specific workshops, then what to clean for them
411+
- Workshop actions are scoped to selected workshops, not all workshops
412+
370413
### `migrate`
371414

372415
Run any necessary migrations for workshop data.

packages/workshop-cli/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ epicshop start <workshop>
4747
- `epicshop open`: open a workshop in your editor
4848
- `epicshop update`: pull the latest workshop changes
4949
- `epicshop warm`: warm caches for faster workshop startup
50+
- `epicshop cleanup`: select what to delete (workshops, caches, offline videos,
51+
prefs, auth)
5052
- `epicshop exercises`: list exercises with progress (context-aware)
5153
- `epicshop playground`: view or set the current playground (context-aware)
5254
- `epicshop progress`: view or update your progress (context-aware)
@@ -66,6 +68,7 @@ This package also exports ESM entrypoints:
6668
import { start } from 'epicshop/start'
6769
import { update } from 'epicshop/update'
6870
import { warm } from 'epicshop/warm'
71+
import { cleanup } from 'epicshop/cleanup'
6972
import { show, set } from 'epicshop/playground'
7073
import {
7174
show as showProgress,

packages/workshop-cli/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"cjs": false,
1111
"exports": {
1212
"./package.json": "./package.json",
13+
"./cleanup": "./src/commands/cleanup.ts",
1314
"./warm": "./src/commands/warm.ts",
1415
"./start": "./src/commands/start.ts",
1516
"./update": "./src/commands/update.ts",
@@ -24,6 +25,10 @@
2425
},
2526
"exports": {
2627
"./package.json": "./package.json",
28+
"./cleanup": {
29+
"types": "./dist/commands/cleanup.d.ts",
30+
"import": "./dist/commands/cleanup.js"
31+
},
2732
"./warm": {
2833
"types": "./dist/commands/warm.d.ts",
2934
"import": "./dist/commands/warm.js"

packages/workshop-cli/src/cli.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -672,6 +672,78 @@ const cli = yargs(args)
672672
}
673673
},
674674
)
675+
.command(
676+
'cleanup',
677+
'Clean up local epicshop data',
678+
(yargs: Argv) => {
679+
return yargs
680+
.option('targets', {
681+
alias: 't',
682+
type: 'array',
683+
choices: ['caches', 'offline-videos', 'preferences', 'auth'],
684+
description:
685+
'Cleanup targets (repeatable): caches, offline-videos, preferences, auth',
686+
})
687+
.option('workshops', {
688+
type: 'array',
689+
description: 'Workshops to clean (repeatable, by repo name or path)',
690+
})
691+
.option('workshop-actions', {
692+
type: 'array',
693+
choices: ['files', 'caches', 'offline-videos'],
694+
description: 'Cleanup actions for selected workshops (repeatable)',
695+
})
696+
.option('silent', {
697+
alias: 's',
698+
type: 'boolean',
699+
description: 'Run without output logs',
700+
default: false,
701+
})
702+
.option('force', {
703+
alias: 'f',
704+
type: 'boolean',
705+
description: 'Skip the confirmation prompt',
706+
default: false,
707+
})
708+
.example(
709+
'$0 cleanup',
710+
'Pick cleanup targets interactively (multi-select)',
711+
)
712+
.example(
713+
'$0 cleanup --targets caches --targets preferences --force',
714+
'Clean selected targets without prompting',
715+
)
716+
.example(
717+
'$0 cleanup --workshops full-stack-foundations --workshop-actions caches --force',
718+
'Clean caches for a specific workshop',
719+
)
720+
},
721+
async (
722+
argv: ArgumentsCamelCase<{
723+
silent?: boolean
724+
force?: boolean
725+
targets?: Array<string>
726+
workshops?: Array<string>
727+
workshopActions?: Array<string>
728+
}>,
729+
) => {
730+
const { cleanup } = await import('./commands/cleanup.js')
731+
const result = await cleanup({
732+
silent: argv.silent,
733+
force: argv.force,
734+
targets: argv.targets as Array<
735+
'caches' | 'offline-videos' | 'preferences' | 'auth'
736+
>,
737+
workshops: argv.workshops,
738+
workshopTargets: argv.workshopActions as Array<
739+
'files' | 'caches' | 'offline-videos'
740+
>,
741+
})
742+
if (!result.success) {
743+
process.exit(1)
744+
}
745+
},
746+
)
675747
.command(
676748
'migrate',
677749
'Run any necessary migrations for workshop data',
@@ -1359,6 +1431,11 @@ try {
13591431
? `Warm the cache for ${workshopTitle}`
13601432
: 'Select a workshop to warm the cache for',
13611433
},
1434+
{
1435+
name: `${chalk.green('cleanup')} - Cleanup data`,
1436+
value: 'cleanup' as const,
1437+
description: 'Select what to delete (workshops, caches, prefs, auth)',
1438+
},
13621439
{
13631440
name: `${chalk.green('config')} - View/update configuration`,
13641441
value: 'config' as const,
@@ -1578,6 +1655,19 @@ try {
15781655
}
15791656
break
15801657
}
1658+
case 'cleanup': {
1659+
try {
1660+
const { cleanup } = await import('./commands/cleanup.js')
1661+
const result = await cleanup({})
1662+
if (!result.success) process.exit(1)
1663+
} catch (error) {
1664+
if ((error as Error).message === 'USER_QUIT') {
1665+
process.exit(0)
1666+
}
1667+
throw error
1668+
}
1669+
break
1670+
}
15811671
case 'config': {
15821672
const { config } = await import('./commands/workshops.js')
15831673
const result = await config({})
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import { mkdtemp, mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises'
2+
import os from 'node:os'
3+
import path from 'node:path'
4+
import { expect, test } from 'vitest'
5+
import { cleanup } from './cleanup.ts'
6+
7+
test('cleanup removes caches and deletes data file when empty', async () => {
8+
const root = await mkdtemp(path.join(os.tmpdir(), 'epicshop-cleanup-'))
9+
const cacheDir = path.join(root, 'cache')
10+
const legacyCacheDir = path.join(root, 'legacy-cache')
11+
const dataPath = path.join(root, 'data.json')
12+
const reposDir = path.join(root, 'repos')
13+
14+
try {
15+
await mkdir(reposDir, { recursive: true })
16+
await mkdir(cacheDir, { recursive: true })
17+
await mkdir(legacyCacheDir, { recursive: true })
18+
await writeFile(path.join(cacheDir, 'cache.json'), '{}')
19+
await writeFile(path.join(legacyCacheDir, 'legacy.json'), '{}')
20+
await writeFile(
21+
dataPath,
22+
JSON.stringify(
23+
{
24+
preferences: { player: { muted: true } },
25+
authInfos: { 'www.epicweb.dev': { id: 'user-1' } },
26+
mutedNotifications: ['notice-1'],
27+
},
28+
null,
29+
2,
30+
),
31+
)
32+
33+
const result = await cleanup({
34+
silent: true,
35+
force: true,
36+
targets: ['caches', 'preferences', 'auth'],
37+
paths: {
38+
cacheDir,
39+
legacyCacheDir,
40+
dataPaths: [dataPath],
41+
reposDir,
42+
},
43+
})
44+
45+
expect(result.success).toBe(true)
46+
await expect(stat(cacheDir)).rejects.toThrow()
47+
await expect(stat(legacyCacheDir)).rejects.toThrow()
48+
await expect(stat(dataPath)).rejects.toThrow()
49+
} finally {
50+
await rm(root, { recursive: true, force: true })
51+
}
52+
})
53+
54+
test('cleanup removes workshops but keeps non-workshop entries', async () => {
55+
const root = await mkdtemp(path.join(os.tmpdir(), 'epicshop-cleanup-'))
56+
const reposDir = path.join(root, 'repos')
57+
const workshopDir = path.join(reposDir, 'sample-workshop')
58+
const keepDir = path.join(reposDir, 'notes')
59+
60+
try {
61+
await mkdir(workshopDir, { recursive: true })
62+
await writeFile(
63+
path.join(workshopDir, 'package.json'),
64+
JSON.stringify(
65+
{
66+
name: 'sample-workshop',
67+
epicshop: { title: 'Sample Workshop' },
68+
},
69+
null,
70+
2,
71+
),
72+
)
73+
await mkdir(keepDir, { recursive: true })
74+
await writeFile(path.join(keepDir, 'notes.txt'), 'keep')
75+
76+
const result = await cleanup({
77+
silent: true,
78+
force: true,
79+
targets: ['workshops'],
80+
workshops: ['sample-workshop'],
81+
workshopTargets: ['files'],
82+
paths: { reposDir },
83+
})
84+
85+
expect(result.success).toBe(true)
86+
await expect(stat(workshopDir)).rejects.toThrow()
87+
88+
const remaining = await readdir(reposDir)
89+
expect(remaining).toContain('notes')
90+
} finally {
91+
await rm(root, { recursive: true, force: true })
92+
}
93+
})
94+
95+
test('cleanup removes offline videos directory', async () => {
96+
const root = await mkdtemp(path.join(os.tmpdir(), 'epicshop-cleanup-'))
97+
const offlineVideosDir = path.join(root, 'offline-videos')
98+
const indexPath = path.join(offlineVideosDir, 'index.json')
99+
const videoPath = path.join(offlineVideosDir, 'video.mp4')
100+
const reposDir = path.join(root, 'repos')
101+
102+
try {
103+
await mkdir(reposDir, { recursive: true })
104+
await mkdir(offlineVideosDir, { recursive: true })
105+
await writeFile(videoPath, 'video-data')
106+
await writeFile(
107+
indexPath,
108+
JSON.stringify(
109+
{
110+
video123: {
111+
playbackId: 'video123',
112+
fileName: 'video.mp4',
113+
size: 10,
114+
workshops: [{ id: 'workshop-1', title: 'Workshop' }],
115+
},
116+
},
117+
null,
118+
2,
119+
),
120+
)
121+
122+
const result = await cleanup({
123+
silent: true,
124+
force: true,
125+
targets: ['offline-videos'],
126+
paths: { offlineVideosDir, reposDir },
127+
})
128+
129+
expect(result.success).toBe(true)
130+
await expect(stat(offlineVideosDir)).rejects.toThrow()
131+
} finally {
132+
await rm(root, { recursive: true, force: true })
133+
}
134+
})

0 commit comments

Comments
 (0)