@@ -2,21 +2,108 @@ import { ICommand, ICommandParameter } from "../common/definitions/commands";
22import { injector } from "../common/yok" ;
33import * as constants from "../constants" ;
44import {
5+ IProjectCleanupResult ,
56 IProjectCleanupService ,
67 IProjectConfigService ,
8+ IProjectService ,
79} from "../definitions/project" ;
810
11+ import type { PromptObject } from "prompts" ;
12+ import { IOptions } from "../declarations" ;
13+ import {
14+ ITerminalSpinner ,
15+ ITerminalSpinnerService ,
16+ } from "../definitions/terminal-spinner-service" ;
17+ import { IChildProcess } from "../common/declarations" ;
18+ import * as os from "os" ;
19+
20+ import { resolve } from "path" ;
21+ import { readdir } from "fs/promises" ;
22+ import { isInteractive } from "../common/helpers" ;
23+
24+ const CLIPath = resolve ( __dirname , ".." , ".." , "bin" , "nativescript.js" ) ;
25+
26+ function bytesToHumanReadable ( bytes : number ) : string {
27+ const units = [ "B" , "KB" , "MB" , "GB" , "TB" ] ;
28+ let unit = 0 ;
29+ while ( bytes >= 1024 ) {
30+ bytes /= 1024 ;
31+ unit ++ ;
32+ }
33+ return `${ bytes . toFixed ( 2 ) } ${ units [ unit ] } ` ;
34+ }
35+
36+ /**
37+ * A helper function to map an array of values to promises with a concurrency limit.
38+ * The mapper function should return a promise. It will be called for each value in the values array.
39+ * The concurrency limit is the number of promises that can be running at the same time.
40+ *
41+ * This function will return a promise that resolves when all values have been mapped.
42+ *
43+ * @param values A static array of values to map to promises
44+ * @param mapper A function that maps a value to a promise
45+ * @param concurrency The number of promises that can be running at the same time
46+ * @returns Promise<void>
47+ */
48+ function promiseMap < T > (
49+ values : T [ ] ,
50+ mapper : ( value : T ) => Promise < void > ,
51+ concurrency = 10
52+ ) {
53+ let index = 0 ;
54+ let pending = 0 ;
55+ let done = false ;
56+
57+ return new Promise < void > ( ( resolve , reject ) => {
58+ const next = ( ) => {
59+ done = index === values . length ;
60+
61+ if ( done && pending === 0 ) {
62+ return resolve ( ) ;
63+ }
64+
65+ while ( pending < concurrency && index < values . length ) {
66+ const value = values [ index ++ ] ;
67+ pending ++ ;
68+ mapper ( value )
69+ . then ( ( ) => {
70+ pending -- ;
71+ next ( ) ;
72+ } )
73+ . catch ( ) ;
74+ }
75+ } ;
76+
77+ next ( ) ;
78+ } ) ;
79+ }
80+
981export class CleanCommand implements ICommand {
1082 public allowedParameters : ICommandParameter [ ] = [ ] ;
1183
1284 constructor (
1385 private $projectCleanupService : IProjectCleanupService ,
1486 private $projectConfigService : IProjectConfigService ,
15- private $terminalSpinnerService : ITerminalSpinnerService
87+ private $terminalSpinnerService : ITerminalSpinnerService ,
88+ private $projectService : IProjectService ,
89+ private $prompter : IPrompter ,
90+ private $logger : ILogger ,
91+ private $options : IOptions ,
92+ private $childProcess : IChildProcess
1693 ) { }
1794
1895 public async execute ( args : string [ ] ) : Promise < void > {
19- const spinner = this . $terminalSpinnerService . createSpinner ( ) ;
96+ const isDryRun = this . $options . dryRun ?? false ;
97+ const isJSON = this . $options . json ?? false ;
98+
99+ const spinner = this . $terminalSpinnerService . createSpinner ( {
100+ isSilent : isJSON ,
101+ } ) ;
102+
103+ if ( ! this . $projectService . isValidNativeScriptProject ( ) ) {
104+ return this . cleanMultipleProjects ( spinner ) ;
105+ }
106+
20107 spinner . start ( "Cleaning project...\n" ) ;
21108
22109 let pathsToClean = [
@@ -46,14 +133,248 @@ export class CleanCommand implements ICommand {
46133 // ignore
47134 }
48135
49- const success = await this . $projectCleanupService . clean ( pathsToClean ) ;
136+ const res = await this . $projectCleanupService . clean ( pathsToClean , {
137+ dryRun : isDryRun ,
138+ silent : isJSON ,
139+ stats : isJSON ,
140+ } ) ;
141+
142+ if ( res . stats && isJSON ) {
143+ console . log (
144+ JSON . stringify (
145+ {
146+ ok : res . ok ,
147+ dryRun : isDryRun ,
148+ stats : Object . fromEntries ( res . stats . entries ( ) ) ,
149+ } ,
150+ null ,
151+ 2
152+ )
153+ ) ;
154+
155+ return ;
156+ }
50157
51- if ( success ) {
158+ if ( res . ok ) {
52159 spinner . succeed ( "Project successfully cleaned." ) ;
53160 } else {
54161 spinner . fail ( `${ "Project unsuccessfully cleaned." . red } ` ) ;
55162 }
56163 }
164+
165+ private async cleanMultipleProjects ( spinner : ITerminalSpinner ) {
166+ if ( ! isInteractive ( ) || this . $options . json ) {
167+ // interactive terminal is required, and we can't output json in an interactive command.
168+ this . $logger . warn ( "No project found in the current directory." ) ;
169+ return ;
170+ }
171+
172+ const shouldScan = await this . $prompter . confirm (
173+ "No project found in the current directory. Would you like to scan for all projects in sub-directories instead?"
174+ ) ;
175+
176+ if ( ! shouldScan ) {
177+ return ;
178+ }
179+
180+ spinner . start ( "Scanning for projects... Please wait." ) ;
181+ const paths = await this . getNSProjectPathsInDirectory ( ) ;
182+ spinner . succeed ( `Found ${ paths . length } projects.` ) ;
183+
184+ let computed = 0 ;
185+ const updateProgress = ( ) => {
186+ const current = `${ computed } /${ paths . length } ` . grey ;
187+ spinner . start (
188+ `Gathering cleanable sizes. This may take a while... ${ current } `
189+ ) ;
190+ } ;
191+
192+ // update the progress initially
193+ updateProgress ( ) ;
194+
195+ const projects = new Map < string , number > ( ) ;
196+
197+ await promiseMap (
198+ paths ,
199+ ( p ) => {
200+ return this . $childProcess
201+ . exec ( `node ${ CLIPath } clean --dry-run --json --disable-analytics` , {
202+ cwd : p ,
203+ } )
204+ . then ( ( res ) => {
205+ const paths : Record < string , number > = JSON . parse ( res ) . stats ;
206+ return Object . values ( paths ) . reduce ( ( a , b ) => a + b , 0 ) ;
207+ } )
208+ . catch ( ( err ) => {
209+ this . $logger . trace (
210+ "Failed to get project size for %s, Error is:" ,
211+ p ,
212+ err
213+ ) ;
214+ return - 1 ;
215+ } )
216+ . then ( ( size ) => {
217+ if ( size > 0 || size === - 1 ) {
218+ // only store size if it's larger than 0 or -1 (error while getting size)
219+ projects . set ( p , size ) ;
220+ }
221+ // update the progress after each processed project
222+ computed ++ ;
223+ updateProgress ( ) ;
224+ } ) ;
225+ } ,
226+ os . cpus ( ) . length
227+ ) ;
228+
229+ spinner . clear ( ) ;
230+ spinner . stop ( ) ;
231+
232+ this . $logger . clearScreen ( ) ;
233+
234+ const totalSize = Array . from ( projects . values ( ) )
235+ . filter ( ( s ) => s > 0 )
236+ . reduce ( ( a , b ) => a + b , 0 ) ;
237+
238+ const pathsToClean = await this . $prompter . promptForChoice (
239+ `Found ${ projects . size } cleanable project(s) with a total size of: ${
240+ bytesToHumanReadable ( totalSize ) . green
241+ } . Select projects to clean`,
242+ Array . from ( projects . keys ( ) ) . map ( ( p ) => {
243+ const size = projects . get ( p ) ;
244+ let description ;
245+ if ( size === - 1 ) {
246+ description = " - could not get size" ;
247+ } else {
248+ description = ` - ${ bytesToHumanReadable ( size ) } ` ;
249+ }
250+
251+ return {
252+ title : `${ p } ${ description . grey } ` ,
253+ value : p ,
254+ } ;
255+ } ) ,
256+ true ,
257+ {
258+ optionsPerPage : process . stdout . rows - 6 , // 6 lines are taken up by the instructions
259+ } as Partial < PromptObject >
260+ ) ;
261+ this . $logger . clearScreen ( ) ;
262+
263+ spinner . warn (
264+ `This will run "${ `ns clean` . yellow } " in all the selected projects and ${
265+ "delete files from your system" . red . bold
266+ } !`
267+ ) ;
268+ spinner . warn ( `This action cannot be undone!` ) ;
269+
270+ let confirmed = await this . $prompter . confirm (
271+ "Are you sure you want to clean the selected projects?"
272+ ) ;
273+ if ( ! confirmed ) {
274+ return ;
275+ }
276+
277+ spinner . info ( "Cleaning... This might take a while..." ) ;
278+
279+ let totalSizeCleaned = 0 ;
280+ for ( let i = 0 ; i < pathsToClean . length ; i ++ ) {
281+ const currentPath = pathsToClean [ i ] ;
282+
283+ spinner . start (
284+ `Cleaning ${ currentPath . cyan } ... ${ i + 1 } /${ pathsToClean . length } `
285+ ) ;
286+
287+ const ok = await this . $childProcess
288+ . exec (
289+ `node ${ CLIPath } clean ${
290+ this . $options . dryRun ? "--dry-run" : ""
291+ } --json --disable-analytics`,
292+ {
293+ cwd : currentPath ,
294+ }
295+ )
296+ . then ( ( res ) => {
297+ const cleanupRes = JSON . parse ( res ) as IProjectCleanupResult ;
298+ return cleanupRes . ok ;
299+ } )
300+ . catch ( ( err ) => {
301+ this . $logger . trace ( 'Failed to clean project "%s"' , currentPath , err ) ;
302+ return false ;
303+ } ) ;
304+
305+ if ( ok ) {
306+ const cleanedSize = projects . get ( currentPath ) ;
307+ const cleanedSizeStr = `- ${ bytesToHumanReadable ( cleanedSize ) } ` . grey ;
308+ spinner . succeed ( `Cleaned ${ currentPath . cyan } ${ cleanedSizeStr } ` ) ;
309+ totalSizeCleaned += cleanedSize ;
310+ } else {
311+ spinner . fail ( `Failed to clean ${ currentPath . cyan } - skipped` ) ;
312+ }
313+ }
314+ spinner . clear ( ) ;
315+ spinner . stop ( ) ;
316+ spinner . succeed (
317+ `Done! We've just freed up ${
318+ bytesToHumanReadable ( totalSizeCleaned ) . green
319+ } ! Woohoo! 🎉`
320+ ) ;
321+
322+ if ( this . $options . dryRun ) {
323+ spinner . info (
324+ 'Note: the "--dry-run" flag was used, so no files were actually deleted.'
325+ ) ;
326+ }
327+ }
328+
329+ private async getNSProjectPathsInDirectory (
330+ dir = process . cwd ( )
331+ ) : Promise < string [ ] > {
332+ let nsDirs : string [ ] = [ ] ;
333+
334+ const getFiles = async ( dir : string ) => {
335+ if ( dir . includes ( "node_modules" ) ) {
336+ // skip traversing node_modules
337+ return ;
338+ }
339+
340+ const dirents = await readdir ( dir , { withFileTypes : true } ) . catch (
341+ ( err ) => {
342+ this . $logger . trace (
343+ 'Failed to read directory "%s". Error is:' ,
344+ dir ,
345+ err
346+ ) ;
347+ return [ ] ;
348+ }
349+ ) ;
350+
351+ const hasNSConfig = dirents . some (
352+ ( ent ) =>
353+ ent . name . includes ( "nativescript.config.ts" ) ||
354+ ent . name . includes ( "nativescript.config.js" )
355+ ) ;
356+
357+ if ( hasNSConfig ) {
358+ nsDirs . push ( dir ) ;
359+ // found a NativeScript project, stop traversing
360+ return ;
361+ }
362+
363+ await Promise . all (
364+ dirents . map ( ( dirent : any ) => {
365+ const res = resolve ( dir , dirent . name ) ;
366+
367+ if ( dirent . isDirectory ( ) ) {
368+ return getFiles ( res ) ;
369+ }
370+ } )
371+ ) ;
372+ } ;
373+
374+ await getFiles ( dir ) ;
375+
376+ return nsDirs ;
377+ }
57378}
58379
59380injector . registerCommand ( "clean" , CleanCommand ) ;
0 commit comments