@@ -2,21 +2,108 @@ import { ICommand, ICommandParameter } from "../common/definitions/commands";
2
2
import { injector } from "../common/yok" ;
3
3
import * as constants from "../constants" ;
4
4
import {
5
+ IProjectCleanupResult ,
5
6
IProjectCleanupService ,
6
7
IProjectConfigService ,
8
+ IProjectService ,
7
9
} from "../definitions/project" ;
8
10
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
+
9
81
export class CleanCommand implements ICommand {
10
82
public allowedParameters : ICommandParameter [ ] = [ ] ;
11
83
12
84
constructor (
13
85
private $projectCleanupService : IProjectCleanupService ,
14
86
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
16
93
) { }
17
94
18
95
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
+
20
107
spinner . start ( "Cleaning project...\n" ) ;
21
108
22
109
let pathsToClean = [
@@ -46,14 +133,248 @@ export class CleanCommand implements ICommand {
46
133
// ignore
47
134
}
48
135
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
+ }
50
157
51
- if ( success ) {
158
+ if ( res . ok ) {
52
159
spinner . succeed ( "Project successfully cleaned." ) ;
53
160
} else {
54
161
spinner . fail ( `${ "Project unsuccessfully cleaned." . red } ` ) ;
55
162
}
56
163
}
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
+ }
57
378
}
58
379
59
380
injector . registerCommand ( "clean" , CleanCommand ) ;
0 commit comments