epicshop 6.59.0 → 6.60.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
@@ -47,6 +47,8 @@ epicshop start <workshop>
47
47
  - `epicshop open`: open a workshop in your editor
48
48
  - `epicshop update`: pull the latest workshop changes
49
49
  - `epicshop warm`: warm caches for faster workshop startup
50
+ - `epicshop cleanup`: select what to delete (workshops, caches, offline videos,
51
+ prefs, auth)
50
52
  - `epicshop exercises`: list exercises with progress (context-aware)
51
53
  - `epicshop playground`: view or set the current playground (context-aware)
52
54
  - `epicshop progress`: view or update your progress (context-aware)
@@ -66,6 +68,7 @@ This package also exports ESM entrypoints:
66
68
  import { start } from 'epicshop/start'
67
69
  import { update } from 'epicshop/update'
68
70
  import { warm } from 'epicshop/warm'
71
+ import { cleanup } from 'epicshop/cleanup'
69
72
  import { show, set } from 'epicshop/playground'
70
73
  import {
71
74
  show as showProgress,
package/dist/cli.js CHANGED
@@ -505,6 +505,51 @@ const cli = yargs(args)
505
505
  throw error;
506
506
  }
507
507
  }
508
+ })
509
+ .command('cleanup', 'Clean up local epicshop data', (yargs) => {
510
+ return yargs
511
+ .option('targets', {
512
+ alias: 't',
513
+ type: 'array',
514
+ choices: ['caches', 'offline-videos', 'preferences', 'auth'],
515
+ description: 'Cleanup targets (repeatable): caches, offline-videos, preferences, auth',
516
+ })
517
+ .option('workshops', {
518
+ type: 'array',
519
+ description: 'Workshops to clean (repeatable, by repo name or path)',
520
+ })
521
+ .option('workshop-actions', {
522
+ type: 'array',
523
+ choices: ['files', 'caches', 'offline-videos'],
524
+ description: 'Cleanup actions for selected workshops (repeatable)',
525
+ })
526
+ .option('silent', {
527
+ alias: 's',
528
+ type: 'boolean',
529
+ description: 'Run without output logs',
530
+ default: false,
531
+ })
532
+ .option('force', {
533
+ alias: 'f',
534
+ type: 'boolean',
535
+ description: 'Skip the confirmation prompt',
536
+ default: false,
537
+ })
538
+ .example('$0 cleanup', 'Pick cleanup targets interactively (multi-select)')
539
+ .example('$0 cleanup --targets caches --targets preferences --force', 'Clean selected targets without prompting')
540
+ .example('$0 cleanup --workshops full-stack-foundations --workshop-actions caches --force', 'Clean caches for a specific workshop');
541
+ }, async (argv) => {
542
+ const { cleanup } = await import("./commands/cleanup.js");
543
+ const result = await cleanup({
544
+ silent: argv.silent,
545
+ force: argv.force,
546
+ targets: argv.targets,
547
+ workshops: argv.workshops,
548
+ workshopTargets: argv.workshopActions,
549
+ });
550
+ if (!result.success) {
551
+ process.exit(1);
552
+ }
508
553
  })
509
554
  .command('migrate', 'Run any necessary migrations for workshop data', (yargs) => {
510
555
  return yargs
@@ -1040,6 +1085,10 @@ try {
1040
1085
  description: workshopTitle
1041
1086
  ? `Warm the cache for ${workshopTitle}`
1042
1087
  : 'Select a workshop to warm the cache for',
1088
+ }, {
1089
+ name: `${chalk.green('cleanup')} - Cleanup data`,
1090
+ value: 'cleanup',
1091
+ description: 'Select what to delete (workshops, caches, prefs, auth)',
1043
1092
  }, {
1044
1093
  name: `${chalk.green('config')} - View/update configuration`,
1045
1094
  value: 'config',
@@ -1251,6 +1300,21 @@ try {
1251
1300
  }
1252
1301
  break;
1253
1302
  }
1303
+ case 'cleanup': {
1304
+ try {
1305
+ const { cleanup } = await import("./commands/cleanup.js");
1306
+ const result = await cleanup({});
1307
+ if (!result.success)
1308
+ process.exit(1);
1309
+ }
1310
+ catch (error) {
1311
+ if (error.message === 'USER_QUIT') {
1312
+ process.exit(0);
1313
+ }
1314
+ throw error;
1315
+ }
1316
+ break;
1317
+ }
1254
1318
  case 'config': {
1255
1319
  const { config } = await import("./commands/workshops.js");
1256
1320
  const result = await config({});
@@ -0,0 +1,32 @@
1
+ import '@epic-web/workshop-utils/init-env';
2
+ export type CleanupTarget = 'workshops' | 'caches' | 'offline-videos' | 'preferences' | 'auth';
3
+ export type WorkshopCleanupTarget = 'files' | 'caches' | 'offline-videos';
4
+ export type CleanupResult = {
5
+ success: boolean;
6
+ message?: string;
7
+ error?: Error;
8
+ removedPaths?: string[];
9
+ updatedPaths?: string[];
10
+ skippedPaths?: string[];
11
+ selectedTargets?: CleanupTarget[];
12
+ };
13
+ type CleanupPaths = {
14
+ reposDir: string;
15
+ cacheDir: string;
16
+ legacyCacheDir: string;
17
+ dataPaths: string[];
18
+ offlineVideosDir: string;
19
+ };
20
+ export type CleanupOptions = {
21
+ silent?: boolean;
22
+ force?: boolean;
23
+ targets?: CleanupTarget[];
24
+ workshops?: string[];
25
+ workshopTargets?: WorkshopCleanupTarget[];
26
+ paths?: Partial<CleanupPaths>;
27
+ };
28
+ /**
29
+ * Clean up local epicshop data.
30
+ */
31
+ export declare function cleanup({ silent, force, targets, workshops, workshopTargets, paths, }?: CleanupOptions): Promise<CleanupResult>;
32
+ export {};
@@ -0,0 +1,829 @@
1
+ import '@epic-web/workshop-utils/init-env';
2
+ import { createHash } from 'node:crypto';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { resolveCacheDir, resolveFallbackPath, resolvePrimaryDir, resolvePrimaryPath, } from '@epic-web/workshop-utils/data-storage.server';
7
+ import { deleteWorkshop, getReposDirectory, getUnpushedChanges, } from '@epic-web/workshop-utils/workshops.server';
8
+ import chalk from 'chalk';
9
+ import { assertCanPrompt } from "../utils/cli-runtime.js";
10
+ const CLEANUP_TARGETS = [
11
+ {
12
+ value: 'workshops',
13
+ name: 'Workshops',
14
+ description: 'Delete locally installed workshop directories',
15
+ },
16
+ {
17
+ value: 'caches',
18
+ name: 'Caches',
19
+ description: 'Remove local cache directories (apps, diffs, GitHub)',
20
+ },
21
+ {
22
+ value: 'offline-videos',
23
+ name: 'Offline videos',
24
+ description: 'Delete downloaded offline videos',
25
+ },
26
+ {
27
+ value: 'preferences',
28
+ name: 'Preferences',
29
+ description: 'Clear stored preferences and local settings',
30
+ },
31
+ {
32
+ value: 'auth',
33
+ name: 'Auth data',
34
+ description: 'Remove stored login tokens',
35
+ },
36
+ ];
37
+ const WORKSHOP_CLEANUP_TARGETS = [
38
+ {
39
+ value: 'files',
40
+ name: 'Workshop files',
41
+ description: 'Delete the workshop directory',
42
+ },
43
+ {
44
+ value: 'caches',
45
+ name: 'Workshop caches',
46
+ description: 'Remove caches for selected workshops',
47
+ },
48
+ {
49
+ value: 'offline-videos',
50
+ name: 'Offline videos',
51
+ description: 'Delete offline videos for selected workshops',
52
+ },
53
+ ];
54
+ function resolveCleanupTargets(targets) {
55
+ if (!targets || targets.length === 0)
56
+ return [];
57
+ const allowed = new Set(CLEANUP_TARGETS.map((target) => target.value));
58
+ return Array.from(new Set(targets.filter((target) => allowed.has(target))));
59
+ }
60
+ function resolveWorkshopCleanupTargets(targets) {
61
+ if (!targets || targets.length === 0)
62
+ return [];
63
+ const allowed = new Set(WORKSHOP_CLEANUP_TARGETS.map((target) => target.value));
64
+ return Array.from(new Set(targets.filter((target) => allowed.has(target))));
65
+ }
66
+ async function resolveCleanupPaths(paths = {}) {
67
+ const reposDir = paths.reposDir ?? (await getReposDirectory());
68
+ const cacheDir = paths.cacheDir ?? resolveCacheDir();
69
+ const legacyCacheDir = paths.legacyCacheDir ?? path.join(os.homedir(), '.epicshop', 'cache');
70
+ const dataPaths = paths.dataPaths ?? [
71
+ resolvePrimaryPath(),
72
+ resolveFallbackPath(),
73
+ ];
74
+ const offlineVideosDir = paths.offlineVideosDir ?? path.join(resolvePrimaryDir(), 'offline-videos');
75
+ return { reposDir, cacheDir, legacyCacheDir, dataPaths, offlineVideosDir };
76
+ }
77
+ async function pathExists(targetPath) {
78
+ try {
79
+ await fs.access(targetPath);
80
+ return true;
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ async function isDirectoryEmpty(targetPath) {
87
+ try {
88
+ const entries = await fs.readdir(targetPath);
89
+ return entries.length === 0;
90
+ }
91
+ catch {
92
+ return false;
93
+ }
94
+ }
95
+ async function listWorkshopsInDirectory(reposDir) {
96
+ try {
97
+ const entries = await fs.readdir(reposDir, { withFileTypes: true });
98
+ const workshops = [];
99
+ for (const entry of entries) {
100
+ if (!entry.isDirectory())
101
+ continue;
102
+ const workshopPath = path.join(reposDir, entry.name);
103
+ const packageJsonPath = path.join(workshopPath, 'package.json');
104
+ try {
105
+ const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
106
+ if (packageJson.epicshop) {
107
+ workshops.push({
108
+ title: packageJson.epicshop.title || packageJson.name || entry.name,
109
+ repoName: entry.name,
110
+ path: workshopPath,
111
+ });
112
+ }
113
+ }
114
+ catch {
115
+ // Not a valid workshop directory, skip.
116
+ }
117
+ }
118
+ return workshops;
119
+ }
120
+ catch {
121
+ return [];
122
+ }
123
+ }
124
+ async function removePath(targetPath, removedPaths, skippedPaths, failures) {
125
+ const exists = await pathExists(targetPath);
126
+ if (!exists) {
127
+ skippedPaths.push(targetPath);
128
+ return;
129
+ }
130
+ try {
131
+ await fs.rm(targetPath, { recursive: true, force: true });
132
+ removedPaths.push(targetPath);
133
+ }
134
+ catch (error) {
135
+ failures.push({
136
+ path: targetPath,
137
+ error: error instanceof Error ? error : new Error(String(error)),
138
+ });
139
+ }
140
+ }
141
+ function formatBytes(bytes) {
142
+ if (!Number.isFinite(bytes) || bytes <= 0)
143
+ return '0 B';
144
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
145
+ let size = bytes;
146
+ let unitIndex = 0;
147
+ while (size >= 1024 && unitIndex < units.length - 1) {
148
+ size /= 1024;
149
+ unitIndex += 1;
150
+ }
151
+ const formatted = size >= 10 || unitIndex === 0 ? Math.round(size) : size.toFixed(1);
152
+ return `${formatted} ${units[unitIndex]}`;
153
+ }
154
+ async function getPathSize(targetPath) {
155
+ try {
156
+ const stats = await fs.lstat(targetPath);
157
+ if (stats.isSymbolicLink())
158
+ return 0;
159
+ if (stats.isFile())
160
+ return stats.size;
161
+ if (!stats.isDirectory())
162
+ return 0;
163
+ const entries = await fs.readdir(targetPath, { withFileTypes: true });
164
+ let total = 0;
165
+ for (const entry of entries) {
166
+ const entryPath = path.join(targetPath, entry.name);
167
+ if (entry.isSymbolicLink())
168
+ continue;
169
+ if (entry.isFile()) {
170
+ try {
171
+ const entryStats = await fs.lstat(entryPath);
172
+ total += entryStats.size;
173
+ }
174
+ catch {
175
+ // ignore unreadable files
176
+ }
177
+ }
178
+ else if (entry.isDirectory()) {
179
+ total += await getPathSize(entryPath);
180
+ }
181
+ }
182
+ return total;
183
+ }
184
+ catch {
185
+ return 0;
186
+ }
187
+ }
188
+ function getWorkshopInstanceId(workshopPath) {
189
+ return createHash('md5').update(path.resolve(workshopPath)).digest('hex');
190
+ }
191
+ function getOfflineVideoIndexPath(offlineVideosDir) {
192
+ return path.join(offlineVideosDir, 'index.json');
193
+ }
194
+ function getOfflineVideoFilePath(offlineVideosDir, playbackId, fileName) {
195
+ if (fileName)
196
+ return path.join(offlineVideosDir, fileName);
197
+ const hash = createHash('sha256').update(playbackId).digest('hex');
198
+ return path.join(offlineVideosDir, `${hash}.mp4`);
199
+ }
200
+ async function readOfflineVideoIndex(offlineVideosDir) {
201
+ try {
202
+ const raw = await fs.readFile(getOfflineVideoIndexPath(offlineVideosDir), 'utf8');
203
+ const parsed = JSON.parse(raw);
204
+ return (typeof parsed === 'object' && parsed ? parsed : {});
205
+ }
206
+ catch {
207
+ return {};
208
+ }
209
+ }
210
+ async function writeOfflineVideoIndex(offlineVideosDir, index) {
211
+ await fs.mkdir(offlineVideosDir, { recursive: true, mode: 0o700 });
212
+ await fs.writeFile(getOfflineVideoIndexPath(offlineVideosDir), JSON.stringify(index, null, 2), { mode: 0o600 });
213
+ }
214
+ function getEntryWorkshops(entry) {
215
+ return Array.isArray(entry.workshops)
216
+ ? entry.workshops.filter((workshop) => typeof workshop?.id === 'string')
217
+ : [];
218
+ }
219
+ async function getOfflineVideoEntrySize(offlineVideosDir, entry) {
220
+ if (typeof entry.size === 'number')
221
+ return entry.size;
222
+ const filePath = getOfflineVideoFilePath(offlineVideosDir, entry.playbackId, entry.fileName);
223
+ try {
224
+ const stats = await fs.stat(filePath);
225
+ return stats.size;
226
+ }
227
+ catch {
228
+ return 0;
229
+ }
230
+ }
231
+ async function estimateOfflineVideoBytesForWorkshops(offlineVideosDir, index, workshopIds) {
232
+ let total = 0;
233
+ for (const entry of Object.values(index)) {
234
+ const entryWorkshops = getEntryWorkshops(entry).map((workshop) => workshop.id);
235
+ if (entryWorkshops.length === 0)
236
+ continue;
237
+ const hasSelected = entryWorkshops.some((id) => workshopIds.has(id));
238
+ if (!hasSelected)
239
+ continue;
240
+ const remaining = entryWorkshops.filter((id) => !workshopIds.has(id));
241
+ if (remaining.length > 0)
242
+ continue;
243
+ total += await getOfflineVideoEntrySize(offlineVideosDir, entry);
244
+ }
245
+ return total;
246
+ }
247
+ async function deleteOfflineVideosForWorkshopIds({ offlineVideosDir, index, workshopIds, removedPaths, skippedPaths, failures, }) {
248
+ let updated = false;
249
+ for (const [playbackId, entry] of Object.entries(index)) {
250
+ const entryWorkshops = getEntryWorkshops(entry);
251
+ if (entryWorkshops.length === 0)
252
+ continue;
253
+ const hasSelected = entryWorkshops.some((workshop) => workshopIds.has(workshop.id));
254
+ if (!hasSelected)
255
+ continue;
256
+ const remaining = entryWorkshops.filter((workshop) => !workshopIds.has(workshop.id));
257
+ if (remaining.length > 0) {
258
+ index[playbackId] = { ...entry, workshops: remaining };
259
+ updated = true;
260
+ continue;
261
+ }
262
+ const filePath = getOfflineVideoFilePath(offlineVideosDir, entry.playbackId, entry.fileName);
263
+ delete index[playbackId];
264
+ updated = true;
265
+ try {
266
+ await fs.rm(filePath, { force: true });
267
+ removedPaths.push(filePath);
268
+ }
269
+ catch (error) {
270
+ failures.push({
271
+ path: filePath,
272
+ error: error instanceof Error ? error : new Error(String(error)),
273
+ });
274
+ }
275
+ }
276
+ if (updated) {
277
+ try {
278
+ await writeOfflineVideoIndex(offlineVideosDir, index);
279
+ }
280
+ catch (error) {
281
+ failures.push({
282
+ path: getOfflineVideoIndexPath(offlineVideosDir),
283
+ error: error instanceof Error ? error : new Error(String(error)),
284
+ });
285
+ }
286
+ }
287
+ else {
288
+ skippedPaths.push(getOfflineVideoIndexPath(offlineVideosDir));
289
+ }
290
+ }
291
+ async function writeJsonFile(filePath, data) {
292
+ const dir = path.dirname(filePath);
293
+ await fs.mkdir(dir, { recursive: true, mode: 0o700 });
294
+ await fs.writeFile(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
295
+ }
296
+ async function cleanupDataFiles({ dataPaths, removePreferences, removeAuth, removedPaths, updatedPaths, skippedPaths, failures, }) {
297
+ for (const dataPath of dataPaths) {
298
+ const exists = await pathExists(dataPath);
299
+ const backupPath = `${dataPath}.bkp`;
300
+ if (!exists) {
301
+ skippedPaths.push(dataPath);
302
+ await removePath(backupPath, removedPaths, skippedPaths, failures);
303
+ continue;
304
+ }
305
+ try {
306
+ const raw = await fs.readFile(dataPath, 'utf8');
307
+ const data = JSON.parse(raw);
308
+ const next = { ...data };
309
+ let changed = false;
310
+ if (removePreferences) {
311
+ if ('preferences' in next) {
312
+ delete next.preferences;
313
+ changed = true;
314
+ }
315
+ if ('mutedNotifications' in next) {
316
+ delete next.mutedNotifications;
317
+ changed = true;
318
+ }
319
+ }
320
+ if (removeAuth) {
321
+ if ('authInfo' in next) {
322
+ delete next.authInfo;
323
+ changed = true;
324
+ }
325
+ if ('authInfos' in next) {
326
+ delete next.authInfos;
327
+ changed = true;
328
+ }
329
+ }
330
+ if (!changed) {
331
+ skippedPaths.push(dataPath);
332
+ await removePath(backupPath, removedPaths, skippedPaths, failures);
333
+ continue;
334
+ }
335
+ if (Object.keys(next).length === 0) {
336
+ await fs.rm(dataPath, { force: true });
337
+ removedPaths.push(dataPath);
338
+ await removePath(backupPath, removedPaths, skippedPaths, failures);
339
+ continue;
340
+ }
341
+ await writeJsonFile(dataPath, next);
342
+ updatedPaths.push(dataPath);
343
+ await removePath(backupPath, removedPaths, skippedPaths, failures);
344
+ }
345
+ catch (error) {
346
+ failures.push({
347
+ path: dataPath,
348
+ error: error instanceof Error ? error : new Error(String(error)),
349
+ });
350
+ }
351
+ }
352
+ }
353
+ async function getDataCleanupSizeSummary(dataPaths) {
354
+ let preferencesBytes = 0;
355
+ let authBytes = 0;
356
+ for (const dataPath of dataPaths) {
357
+ try {
358
+ const raw = await fs.readFile(dataPath, 'utf8');
359
+ const originalBytes = Buffer.byteLength(raw);
360
+ const data = JSON.parse(raw);
361
+ const prefs = { ...data };
362
+ let prefsChanged = false;
363
+ if ('preferences' in prefs) {
364
+ delete prefs.preferences;
365
+ prefsChanged = true;
366
+ }
367
+ if ('mutedNotifications' in prefs) {
368
+ delete prefs.mutedNotifications;
369
+ prefsChanged = true;
370
+ }
371
+ if (prefsChanged) {
372
+ if (Object.keys(prefs).length === 0) {
373
+ preferencesBytes += originalBytes;
374
+ }
375
+ else {
376
+ const nextBytes = Buffer.byteLength(JSON.stringify(prefs, null, 2));
377
+ preferencesBytes += Math.max(0, originalBytes - nextBytes);
378
+ }
379
+ }
380
+ const authData = { ...data };
381
+ let authChanged = false;
382
+ if ('authInfo' in authData) {
383
+ delete authData.authInfo;
384
+ authChanged = true;
385
+ }
386
+ if ('authInfos' in authData) {
387
+ delete authData.authInfos;
388
+ authChanged = true;
389
+ }
390
+ if (authChanged) {
391
+ if (Object.keys(authData).length === 0) {
392
+ authBytes += originalBytes;
393
+ }
394
+ else {
395
+ const nextBytes = Buffer.byteLength(JSON.stringify(authData, null, 2));
396
+ authBytes += Math.max(0, originalBytes - nextBytes);
397
+ }
398
+ }
399
+ }
400
+ catch {
401
+ // ignore unreadable data files
402
+ }
403
+ }
404
+ return { preferencesBytes, authBytes };
405
+ }
406
+ async function getWorkshopSummaries({ workshops, cacheDir, }) {
407
+ const summaries = [];
408
+ for (const workshop of workshops) {
409
+ const id = getWorkshopInstanceId(workshop.path);
410
+ const sizeBytes = await getPathSize(workshop.path);
411
+ const cacheBytes = await getPathSize(path.join(cacheDir, id));
412
+ summaries.push({
413
+ ...workshop,
414
+ id,
415
+ sizeBytes,
416
+ cacheBytes,
417
+ });
418
+ }
419
+ return summaries;
420
+ }
421
+ async function selectWorkshops(workshops) {
422
+ assertCanPrompt({
423
+ reason: 'select workshops to clean up',
424
+ hints: ['Provide workshops via: npx epicshop cleanup --workshops <name>'],
425
+ });
426
+ const { checkbox } = await import('@inquirer/prompts');
427
+ console.log(chalk.gray('\n Use space to select, enter to confirm your selection.\n'));
428
+ return checkbox({
429
+ message: 'Select workshops to clean up:',
430
+ choices: workshops.map((workshop) => ({
431
+ name: `${workshop.title} (${workshop.repoName})`,
432
+ value: workshop.id,
433
+ description: `${workshop.path} • ${formatBytes(workshop.sizeBytes)}`,
434
+ })),
435
+ });
436
+ }
437
+ function matchesWorkshopInput(workshop, input) {
438
+ const normalized = input.trim().toLowerCase();
439
+ if (!normalized)
440
+ return false;
441
+ const candidates = [
442
+ workshop.repoName,
443
+ workshop.title,
444
+ workshop.path,
445
+ path.basename(workshop.path),
446
+ ].map((value) => value.toLowerCase());
447
+ if (candidates.includes(normalized))
448
+ return true;
449
+ const resolvedInput = expandTilde(normalized);
450
+ if (resolvedInput.includes(path.sep)) {
451
+ return (path.resolve(workshop.path).toLowerCase() ===
452
+ path.resolve(resolvedInput).toLowerCase());
453
+ }
454
+ return false;
455
+ }
456
+ function expandTilde(inputPath) {
457
+ if (inputPath === '~')
458
+ return os.homedir();
459
+ if (inputPath.startsWith('~/') || inputPath.startsWith('~\\')) {
460
+ return path.join(os.homedir(), inputPath.slice(2));
461
+ }
462
+ return inputPath;
463
+ }
464
+ function resolveWorkshopSelection(workshops, requested) {
465
+ const selected = new Map();
466
+ const missing = [];
467
+ for (const entry of requested) {
468
+ const match = workshops.find((workshop) => matchesWorkshopInput(workshop, entry));
469
+ if (match) {
470
+ selected.set(match.id, match);
471
+ }
472
+ else {
473
+ missing.push(entry);
474
+ }
475
+ }
476
+ return { selected: Array.from(selected.values()), missing };
477
+ }
478
+ async function selectWorkshopTargets(choices) {
479
+ assertCanPrompt({
480
+ reason: 'select what to clean for the selected workshops',
481
+ hints: [
482
+ 'Provide selections via: npx epicshop cleanup --workshop-actions <name>',
483
+ ],
484
+ });
485
+ const { checkbox } = await import('@inquirer/prompts');
486
+ console.log(chalk.gray('\n Use space to select, enter to confirm your selection.\n'));
487
+ return checkbox({
488
+ message: 'Select what to clean for the selected workshops:',
489
+ choices,
490
+ });
491
+ }
492
+ async function selectCleanupTargets(availableTargets) {
493
+ assertCanPrompt({
494
+ reason: 'select cleanup targets',
495
+ hints: [
496
+ 'Provide targets via: npx epicshop cleanup --targets <name>',
497
+ 'Example: npx epicshop cleanup --targets caches --targets offline-videos --force',
498
+ ],
499
+ });
500
+ const { checkbox } = await import('@inquirer/prompts');
501
+ console.log(chalk.gray('\n Use space to select, enter to confirm your selection.\n'));
502
+ return checkbox({
503
+ message: 'Select what to clean up:',
504
+ choices: availableTargets.map((target) => ({
505
+ name: target.name,
506
+ value: target.value,
507
+ description: target.description,
508
+ })),
509
+ });
510
+ }
511
+ /**
512
+ * Clean up local epicshop data.
513
+ */
514
+ export async function cleanup({ silent = false, force = false, targets, workshops, workshopTargets, paths, } = {}) {
515
+ try {
516
+ let selectedTargets = resolveCleanupTargets(targets);
517
+ let selectedWorkshopTargets = resolveWorkshopCleanupTargets(workshopTargets);
518
+ if ((workshops?.length ?? 0) > 0 || selectedWorkshopTargets.length > 0) {
519
+ if (!selectedTargets.includes('workshops')) {
520
+ selectedTargets.push('workshops');
521
+ }
522
+ }
523
+ const { reposDir, cacheDir, legacyCacheDir, dataPaths, offlineVideosDir } = await resolveCleanupPaths(paths);
524
+ const allWorkshops = await listWorkshopsInDirectory(reposDir);
525
+ const workshopSummaries = await getWorkshopSummaries({
526
+ workshops: allWorkshops,
527
+ cacheDir,
528
+ });
529
+ const workshopBytes = workshopSummaries.reduce((total, workshop) => total + workshop.sizeBytes, 0);
530
+ const legacyCacheBytes = await getPathSize(legacyCacheDir);
531
+ const cacheBytes = (await getPathSize(cacheDir)) + legacyCacheBytes;
532
+ const offlineVideosBytes = await getPathSize(offlineVideosDir);
533
+ const { preferencesBytes, authBytes } = await getDataCleanupSizeSummary(dataPaths);
534
+ const cleanupChoices = CLEANUP_TARGETS.map((target) => {
535
+ const sizeByTarget = {
536
+ workshops: workshopBytes,
537
+ caches: cacheBytes,
538
+ 'offline-videos': offlineVideosBytes,
539
+ preferences: preferencesBytes,
540
+ auth: authBytes,
541
+ };
542
+ return {
543
+ ...target,
544
+ description: `${target.description} (${formatBytes(sizeByTarget[target.value])})`,
545
+ };
546
+ });
547
+ if (selectedTargets.length === 0) {
548
+ selectedTargets = await selectCleanupTargets(cleanupChoices);
549
+ }
550
+ if (selectedTargets.length === 0) {
551
+ const message = 'No cleanup targets selected';
552
+ if (!silent)
553
+ console.log(chalk.gray(message));
554
+ return { success: true, message, selectedTargets };
555
+ }
556
+ let selectedWorkshops = [];
557
+ if (selectedTargets.includes('workshops')) {
558
+ if (workshopSummaries.length === 0) {
559
+ if (!silent) {
560
+ console.log(chalk.yellow('No workshops found to clean up.'));
561
+ }
562
+ }
563
+ else if (workshops && workshops.length > 0) {
564
+ const resolved = resolveWorkshopSelection(workshopSummaries, workshops);
565
+ if (resolved.missing.length > 0) {
566
+ return {
567
+ success: false,
568
+ message: `Workshops not found: ${resolved.missing.join(', ')}`,
569
+ selectedTargets,
570
+ };
571
+ }
572
+ selectedWorkshops = resolved.selected;
573
+ }
574
+ else {
575
+ const selectedIds = await selectWorkshops(workshopSummaries);
576
+ selectedWorkshops = workshopSummaries.filter((workshop) => selectedIds.includes(workshop.id));
577
+ }
578
+ if (selectedWorkshops.length > 0 &&
579
+ selectedWorkshopTargets.length === 0) {
580
+ const selectedWorkshopIds = new Set(selectedWorkshops.map((workshop) => workshop.id));
581
+ const workshopFileBytes = selectedWorkshops.reduce((total, workshop) => total + workshop.sizeBytes, 0);
582
+ const workshopCacheBytes = selectedWorkshops.reduce((total, workshop) => total + workshop.cacheBytes, 0);
583
+ const offlineVideoIndex = await readOfflineVideoIndex(offlineVideosDir);
584
+ const workshopOfflineBytes = await estimateOfflineVideoBytesForWorkshops(offlineVideosDir, offlineVideoIndex, selectedWorkshopIds);
585
+ const workshopChoices = WORKSHOP_CLEANUP_TARGETS.map((target) => {
586
+ const sizeByTarget = {
587
+ files: workshopFileBytes,
588
+ caches: workshopCacheBytes,
589
+ 'offline-videos': workshopOfflineBytes,
590
+ };
591
+ return {
592
+ ...target,
593
+ description: `${target.description} (${formatBytes(sizeByTarget[target.value])})`,
594
+ };
595
+ });
596
+ selectedWorkshopTargets = await selectWorkshopTargets(workshopChoices);
597
+ }
598
+ if (selectedWorkshops.length === 0) {
599
+ selectedWorkshopTargets = [];
600
+ }
601
+ }
602
+ const selectedWorkshopIds = new Set(selectedWorkshops.map((workshop) => workshop.id));
603
+ const workshopFileBytes = selectedWorkshops.reduce((total, workshop) => total + workshop.sizeBytes, 0);
604
+ const workshopCacheBytes = selectedWorkshops.reduce((total, workshop) => total + workshop.cacheBytes, 0);
605
+ const emptyOfflineVideoIndex = {};
606
+ const offlineVideoIndex = selectedWorkshopTargets.includes('offline-videos') ||
607
+ selectedTargets.includes('offline-videos')
608
+ ? await readOfflineVideoIndex(offlineVideosDir)
609
+ : emptyOfflineVideoIndex;
610
+ const workshopOfflineBytes = selectedWorkshopTargets.includes('offline-videos')
611
+ ? await estimateOfflineVideoBytesForWorkshops(offlineVideosDir, offlineVideoIndex, selectedWorkshopIds)
612
+ : 0;
613
+ const hasWorkshopActions = selectedWorkshopTargets.length > 0;
614
+ const hasOtherTargets = selectedTargets.some((target) => target !== 'workshops');
615
+ if (!hasWorkshopActions && !hasOtherTargets) {
616
+ const message = 'No cleanup actions selected';
617
+ if (!silent)
618
+ console.log(chalk.gray(message));
619
+ return { success: true, message, selectedTargets };
620
+ }
621
+ const unpushedSummaries = !silent &&
622
+ selectedWorkshopTargets.includes('files') &&
623
+ selectedWorkshops.length > 0
624
+ ? await Promise.all(selectedWorkshops.map(async (workshop) => ({
625
+ workshop,
626
+ unpushedChanges: await getUnpushedChanges(workshop.path),
627
+ })))
628
+ : [];
629
+ if (!silent) {
630
+ console.log(chalk.yellow('This will clean up the following:'));
631
+ if (selectedWorkshopTargets.includes('files')) {
632
+ console.log(chalk.yellow(`- Workshop files (${selectedWorkshops.length} selected): ${formatBytes(workshopFileBytes)}`));
633
+ }
634
+ if (selectedWorkshopTargets.includes('caches')) {
635
+ console.log(chalk.yellow(`- Workshop caches: ${formatBytes(workshopCacheBytes)}`));
636
+ }
637
+ if (selectedWorkshopTargets.includes('offline-videos')) {
638
+ console.log(chalk.yellow(`- Workshop offline videos: ${formatBytes(workshopOfflineBytes)}`));
639
+ }
640
+ if (selectedTargets.includes('caches')) {
641
+ console.log(chalk.yellow(`- Caches: ${formatBytes(cacheBytes)} (${cacheDir})`));
642
+ console.log(chalk.yellow(`- Legacy cache: ${formatBytes(legacyCacheBytes)} (${legacyCacheDir})`));
643
+ }
644
+ if (selectedTargets.includes('offline-videos')) {
645
+ console.log(chalk.yellow(`- Offline videos: ${formatBytes(offlineVideosBytes)} (${offlineVideosDir})`));
646
+ }
647
+ if (selectedTargets.includes('preferences')) {
648
+ console.log(chalk.yellow(`- Preferences: ${formatBytes(preferencesBytes)} (${dataPaths.join(', ')})`));
649
+ }
650
+ if (selectedTargets.includes('auth')) {
651
+ console.log(chalk.yellow(`- Auth data: ${formatBytes(authBytes)} (${dataPaths.join(', ')})`));
652
+ }
653
+ const unpushed = unpushedSummaries.filter((item) => item.unpushedChanges.hasUnpushed);
654
+ if (unpushed.length > 0) {
655
+ console.log();
656
+ console.log(chalk.yellow('Warning: unpushed workshop changes detected. Review before deleting:'));
657
+ for (const report of unpushed) {
658
+ console.log(chalk.yellow(`- ${report.workshop.title} (${report.workshop.path})`));
659
+ for (const line of report.unpushedChanges.summary) {
660
+ console.log(chalk.yellow(` - ${line}`));
661
+ }
662
+ }
663
+ }
664
+ }
665
+ if (!force) {
666
+ const confirmationItems = [
667
+ ...selectedWorkshopTargets.map((target) => {
668
+ switch (target) {
669
+ case 'files':
670
+ return 'Workshop files';
671
+ case 'caches':
672
+ return 'Workshop caches';
673
+ case 'offline-videos':
674
+ return 'Workshop offline videos';
675
+ default:
676
+ return target;
677
+ }
678
+ }),
679
+ ...selectedTargets
680
+ .filter((target) => target !== 'workshops')
681
+ .map((target) => {
682
+ switch (target) {
683
+ case 'offline-videos':
684
+ return 'Offline videos';
685
+ default:
686
+ return target.charAt(0).toUpperCase() + target.slice(1);
687
+ }
688
+ }),
689
+ ];
690
+ assertCanPrompt({
691
+ reason: 'confirm cleanup',
692
+ hints: [
693
+ `Run non-interactively with: npx epicshop cleanup --targets ${selectedTargets.join(' --targets ')} --force`,
694
+ ],
695
+ });
696
+ const { confirm } = await import('@inquirer/prompts');
697
+ const shouldProceed = await confirm({
698
+ message: `Proceed with cleanup of: ${confirmationItems.join(', ')}?`,
699
+ default: false,
700
+ });
701
+ if (!shouldProceed) {
702
+ const message = 'Cleanup cancelled';
703
+ if (!silent)
704
+ console.log(chalk.gray(message));
705
+ return { success: false, message, selectedTargets };
706
+ }
707
+ }
708
+ const removedPaths = [];
709
+ const updatedPaths = [];
710
+ const skippedPaths = [];
711
+ const failures = [];
712
+ if (selectedWorkshopTargets.includes('files')) {
713
+ for (const workshop of selectedWorkshops) {
714
+ try {
715
+ await deleteWorkshop(workshop.path);
716
+ removedPaths.push(workshop.path);
717
+ }
718
+ catch (error) {
719
+ failures.push({
720
+ path: workshop.path,
721
+ error: error instanceof Error ? error : new Error(String(error)),
722
+ });
723
+ }
724
+ }
725
+ if (await pathExists(reposDir)) {
726
+ const isEmpty = await isDirectoryEmpty(reposDir);
727
+ if (isEmpty) {
728
+ await removePath(reposDir, removedPaths, skippedPaths, failures);
729
+ }
730
+ else if (selectedWorkshops.length > 0) {
731
+ skippedPaths.push(reposDir);
732
+ }
733
+ }
734
+ else {
735
+ skippedPaths.push(reposDir);
736
+ }
737
+ }
738
+ if (selectedWorkshopTargets.includes('caches')) {
739
+ for (const workshop of selectedWorkshops) {
740
+ await removePath(path.join(cacheDir, workshop.id), removedPaths, skippedPaths, failures);
741
+ }
742
+ }
743
+ if (selectedWorkshopTargets.includes('offline-videos')) {
744
+ const hasOfflineVideosDir = await pathExists(offlineVideosDir);
745
+ if (!hasOfflineVideosDir) {
746
+ skippedPaths.push(offlineVideosDir);
747
+ }
748
+ else {
749
+ await deleteOfflineVideosForWorkshopIds({
750
+ offlineVideosDir,
751
+ index: offlineVideoIndex,
752
+ workshopIds: selectedWorkshopIds,
753
+ removedPaths,
754
+ skippedPaths,
755
+ failures,
756
+ });
757
+ }
758
+ }
759
+ if (selectedTargets.includes('caches')) {
760
+ await removePath(cacheDir, removedPaths, skippedPaths, failures);
761
+ await removePath(legacyCacheDir, removedPaths, skippedPaths, failures);
762
+ }
763
+ if (selectedTargets.includes('offline-videos')) {
764
+ await removePath(offlineVideosDir, removedPaths, skippedPaths, failures);
765
+ }
766
+ if (selectedTargets.includes('preferences') ||
767
+ selectedTargets.includes('auth')) {
768
+ await cleanupDataFiles({
769
+ dataPaths,
770
+ removePreferences: selectedTargets.includes('preferences'),
771
+ removeAuth: selectedTargets.includes('auth'),
772
+ removedPaths,
773
+ updatedPaths,
774
+ skippedPaths,
775
+ failures,
776
+ });
777
+ }
778
+ if (failures.length > 0) {
779
+ const message = `Failed to clean up ${failures.length} path(s).`;
780
+ if (!silent) {
781
+ console.error(chalk.red(message));
782
+ for (const failure of failures) {
783
+ console.error(chalk.red(`- ${failure.path}: ${failure.error.message}`));
784
+ }
785
+ }
786
+ return {
787
+ success: false,
788
+ message,
789
+ error: new Error(failures.map((failure) => failure.error.message).join('; ')),
790
+ removedPaths,
791
+ updatedPaths,
792
+ skippedPaths,
793
+ selectedTargets,
794
+ };
795
+ }
796
+ const message = `Cleanup complete. Removed ${removedPaths.length} path(s).`;
797
+ if (!silent) {
798
+ console.log(chalk.green(message));
799
+ if (updatedPaths.length > 0) {
800
+ console.log(chalk.gray(`Updated ${updatedPaths.length} data file(s) with selected cleanup changes.`));
801
+ }
802
+ if (skippedPaths.length > 0) {
803
+ console.log(chalk.gray(`Skipped ${skippedPaths.length} path(s) that did not exist or required no changes.`));
804
+ }
805
+ }
806
+ return {
807
+ success: true,
808
+ message,
809
+ removedPaths,
810
+ updatedPaths,
811
+ skippedPaths,
812
+ selectedTargets,
813
+ };
814
+ }
815
+ catch (error) {
816
+ if (error.message === 'USER_QUIT') {
817
+ return { success: false, message: 'User quit' };
818
+ }
819
+ const message = error instanceof Error ? error.message : String(error);
820
+ if (!silent) {
821
+ console.error(chalk.red(message));
822
+ }
823
+ return {
824
+ success: false,
825
+ message,
826
+ error: error instanceof Error ? error : new Error(message),
827
+ };
828
+ }
829
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "epicshop",
3
- "version": "6.59.0",
3
+ "version": "6.60.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -10,6 +10,7 @@
10
10
  "cjs": false,
11
11
  "exports": {
12
12
  "./package.json": "./package.json",
13
+ "./cleanup": "./src/commands/cleanup.ts",
13
14
  "./warm": "./src/commands/warm.ts",
14
15
  "./start": "./src/commands/start.ts",
15
16
  "./update": "./src/commands/update.ts",
@@ -24,6 +25,10 @@
24
25
  },
25
26
  "exports": {
26
27
  "./package.json": "./package.json",
28
+ "./cleanup": {
29
+ "types": "./dist/commands/cleanup.d.ts",
30
+ "import": "./dist/commands/cleanup.js"
31
+ },
27
32
  "./warm": {
28
33
  "types": "./dist/commands/warm.d.ts",
29
34
  "import": "./dist/commands/warm.js"
@@ -75,7 +80,7 @@
75
80
  "build:watch": "nx watch --projects=epicshop -- nx run \\$NX_PROJECT_NAME:build"
76
81
  },
77
82
  "dependencies": {
78
- "@epic-web/workshop-utils": "6.59.0",
83
+ "@epic-web/workshop-utils": "6.60.0",
79
84
  "@inquirer/prompts": "^7.5.1",
80
85
  "chalk": "^5.6.2",
81
86
  "close-with-grace": "^2.3.0",