prod-files 0.1.1 → 0.1.3

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.
Files changed (3) hide show
  1. package/README.md +19 -18
  2. package/index.mjs +210 -60
  3. package/package.json +4 -6
package/README.md CHANGED
@@ -63,6 +63,8 @@ Flags:
63
63
  -g, --globs Prints out the default globs.
64
64
 
65
65
  -n, --noSize Skips the size calc at the end, saves about 200-1000ms.
66
+
67
+ -q, --quiet Quiet output, suppresses stdout.
66
68
  ```
67
69
 
68
70
  With a package manager:
@@ -122,42 +124,41 @@ RUN node pf.js my-app/foo/node_modules/.pnpm
122
124
 
123
125
  ## Development
124
126
 
125
- Unit tests are written with node's test utils.
126
-
127
127
  ```sh
128
- pnpm test
128
+ pnpm i
129
129
  ```
130
130
 
131
- There's also a `test-project` directory with dummy `package.json` with some
132
- random deps. You can run the script against it to see how it fairs in real usage
133
- and get some timing data.
131
+ ### Unit tests
134
132
 
135
- Set it up:
133
+ Unit tests are written with node's test utils.
136
134
 
137
135
  ```sh
138
- pnpm test:setup
136
+ pnpm test
139
137
  ```
140
138
 
141
- Run `prod-files` on it:
139
+ ### End to end tests
140
+
141
+ In `test-project` directory has Sentry's `package.json`. You can run the script
142
+ against it to see how it fairs in real-world use and get some timing data.
142
143
 
143
144
  ```sh
144
- pnpm test:prune
145
+ # Re-installs the packages and runs the script on it
146
+ pnpm test:e2e
147
+ # Disable size reportings since it's pretty slow
148
+ pnpm test:e2e --noSize
145
149
  ```
146
150
 
147
- It uses the real file system, so you need to reset it with `pnpm test:setup`
148
- before running another test.
149
-
150
- Or chain it for ease of use (with timing):
151
+ The nuke command removes `node_modules` and prunes the store:
151
152
 
152
153
  ```sh
153
- pnpm test:setup && time pnpm test:prune
154
+ pnpm test:e2e:nuke
154
155
  ```
155
156
 
156
- There's also a simple script to print the size of `test-project/node_modules/`
157
- using `du`:
157
+ There's also a simple script to print the weight of `test-project/node_modules/`
158
+ using `du`. You can run it before and after to see more detailed results:
158
159
 
159
160
  ```sh
160
- pnpm test:size
161
+ pnpm test:e2e:weight
161
162
  ```
162
163
 
163
164
  ## Prior art
package/index.mjs CHANGED
@@ -1,14 +1,17 @@
1
- import childProcess from 'node:child_process'
1
+ // oxlint-disable prefer-spread
2
+ import cp from 'node:child_process'
2
3
  import fs from 'node:fs/promises'
3
- import { matchesGlob, join, isAbsolute, resolve } from 'node:path'
4
- import { parseArgs, styleText } from 'node:util'
4
+ import { matchesGlob, join, isAbsolute, resolve, dirname } from 'node:path'
5
+ import { parseArgs, promisify, styleText } from 'node:util'
6
+
7
+ const exec = promisify(cp.exec)
5
8
 
6
9
  /**
7
10
  * A list of glob patterns for files/dirs to be deleted. The globs are matched
8
11
  * with node's `matchesGlob()`. With one special rule: globs which end in `/`
9
12
  * are marked as directories.
10
13
  *
11
- * Ordered by popularity (educated guess).
14
+ * Roughly ordered by popularity (educated guess).
12
15
  *
13
16
  * Partially based on
14
17
  * @see {@link https://github.com/duniul/clean-modules/blob/main/.cleanmodules-default}
@@ -21,16 +24,21 @@ const defaultGlobs = [
21
24
  '**/*.tsx',
22
25
  '**/doc{,s}/',
23
26
 
24
- // TypeScript
27
+ // Types
25
28
  '**/*tsconfig*.json',
26
29
  '**/*.tsbuildinfo',
30
+ '**/flow-typed/',
31
+
32
+ // Sensitive
33
+ '**/.env*',
27
34
 
28
35
  // Package mangers
29
36
  '**/.npm*',
30
- '**/pnpm-*.y{,a}ml',
37
+ '**/pnpm-{lock,workspace}.yaml',
31
38
  '**/.yarn*',
32
39
  '**/yarn.lock',
33
40
  '**/bun.lock',
41
+ '**/bunfig.toml',
34
42
 
35
43
  // IDE
36
44
  '**/.idea/',
@@ -50,6 +58,13 @@ const defaultGlobs = [
50
58
  // CI/CD
51
59
  '**/.github/',
52
60
  '**/.circleci/',
61
+ '**/.vercel',
62
+ '**/now.json',
63
+ '**/.travis.yml',
64
+
65
+ // Docker
66
+ '**/Dockerfile*',
67
+ '**/.dockerignore',
53
68
 
54
69
  // Tests
55
70
  '**/test{,s}/',
@@ -60,31 +75,42 @@ const defaultGlobs = [
60
75
  '**/karma.conf.{js,ts}',
61
76
  '**/wallaby.conf.{js,ts}',
62
77
  '**/wallaby.{js,ts}',
63
-
64
- // Build tools
65
- '**/gemfile',
78
+ '**/playwright.config.{js,ts}',
79
+ '**/.mocharc*',
80
+
81
+ // Build/bundle config
82
+ '**/{rollup,rolldown,vite}.config.{js,ts,mjs}',
83
+ '**/webpack.config.{js,mjs,cjs,ts}',
84
+ '**/babel.config.{js,mjs,cjs,json}',
85
+ '**/parcel.config.{js,ts,json}',
86
+ '**/rspack.config.{js,mjs,cjs,ts}',
87
+ '**/.babelrc*',
88
+ '**/turbo.json',
89
+ '**/.browserslist*',
90
+ '**/metro.config.{js,json}',
66
91
  '**/{G,g}runtfile.{js,ts}',
67
92
  '**/{G,g}ulpfile.{js,ts}',
68
93
  '**/{M,m}akefile',
94
+ '**/gemfile',
69
95
 
70
96
  // Images
71
97
  '**/*.jp{,e}g',
72
- '**/*.png',
73
- '**/*.gif',
74
98
  '**/*.svg',
99
+ '**/*.gif',
100
+ '**/*.png',
75
101
 
76
102
  // Linters and formatters
77
- '**/.jshintrc',
78
- '**/.lint',
79
- '**/.prettier*',
80
- '**/prettier.config*',
81
- '**/biome.json{,c}',
82
- '**/tslint.json',
83
- '**/.eslintrc',
84
103
  '**/eslint*.{json,jsonc,ts}',
104
+ '**/.eslintrc',
105
+ '**/prettier.config*',
106
+ '**/.prettier*',
85
107
  '**/.ox{lint,fmt}rc.json{,c}',
86
108
  '**/ox{lint,fmt}*.{json,jsonc,ts}',
109
+ '**/biome.json{,c}',
87
110
  '**/.dprint.json{,c}',
111
+ '**/.jshintrc',
112
+ '**/.lint',
113
+ '**/tslint.json',
88
114
 
89
115
  // Git
90
116
  '**/.git/',
@@ -93,7 +119,7 @@ const defaultGlobs = [
93
119
 
94
120
  // Code coverage
95
121
  '**/.nyc_output/',
96
- '**/.nycrc',
122
+ '**/.nycrc*',
97
123
  '**/.codecov.y{,a}ml',
98
124
  '**/coverage/',
99
125
 
@@ -123,6 +149,7 @@ const defaultGlobs = [
123
149
  '**/*.coffee',
124
150
 
125
151
  // Misc
152
+ '**/.jscpd',
126
153
  '**/*.jst',
127
154
  '**/*.log',
128
155
  '**/*.mkd',
@@ -189,9 +216,11 @@ function usage() {
189
216
  -g, --globs Prints out the default globs.
190
217
 
191
218
  -n, --noSize Skips the size calc at the end, saves about 200-1000ms.
219
+
220
+ -q, --quiet Quiet output, suppresses stdout.
192
221
  `
193
222
 
194
- console.log(usageText)
223
+ log.log(usageText)
195
224
  }
196
225
 
197
226
  /**
@@ -213,29 +242,48 @@ function bail(message, error, withUsage = false) {
213
242
 
214
243
  /**
215
244
  * @typedef {Object} Logger
216
- * @property {( ...args: any[] ) => void} info - Logs information messages in blue
217
- * @property {( ...args: any[] ) => void} error - Logs error messages in red
218
- * @property {( ...args: any[] ) => void} success - Logs success messages in green
245
+ * @property {typeof console.error} error - Logs error messages in red
246
+ * @property {typeof console.info} info - Logs information messages in blue
247
+ * @property {typeof console.info} log - Logs with no color
248
+ * @property {typeof console.log} success - Logs success messages in green
249
+ * @property {typeof console.table} table - Logs as table
219
250
  */
220
251
 
252
+ // Quiet mode
253
+ let quiet = /** @type {boolean} */ (false)
254
+
255
+ /**
256
+ * @param {import('node:util').InspectColor} color
257
+ * @param {any[]} args
258
+ * @returns
259
+ */
260
+ const style = (color, args) => args.map(a => styleText(color, String(a)))
261
+
221
262
  /**
222
263
  * A utility for styled console logs
223
264
  * @type {Logger}
224
265
  */
225
- const log = {
226
- info: (...x) => console.info(styleText('blue', x.join(' '))),
227
- error: (...x) => console.error(styleText('red', x.join(' '))),
228
- success: (...x) => console.log(styleText('green', x.join(' '))),
266
+ export const log = {
267
+ error: (...a) =>
268
+ quiet ? undefined : console.error.apply(console, style('red', a)),
269
+ info: (...a) =>
270
+ quiet ? undefined : console.info.apply(console, style('blue', a)),
271
+ log: (...a) => (quiet ? undefined : console.log.apply(console, a)),
272
+ success: (...a) =>
273
+ quiet ? undefined : console.log.apply(console, style('green', a)),
274
+ table: (...a) => (quiet ? undefined : console.table.apply(console, a)),
229
275
  }
230
276
 
231
277
  /**
232
278
  * Get size of node_modules
233
279
  * @param {string} dirPath - Path to node_modules
234
- * @returns {number}
280
+ * @returns {Promise<number>}
235
281
  */
236
- function getSize(dirPath) {
237
- const stdout = childProcess.execSync(`du -s ${dirPath} | awk '{print $1}'`)
238
- return Number(stdout)
282
+ async function getSize(dirPath) {
283
+ const { stdout, stderr } = await exec(`LC_ALL=C du -s ${dirPath}`)
284
+ if (stderr.length > 0) bail(stderr)
285
+ const size = stdout.split('\t')[0]
286
+ return size ? Number.parseInt(size, 10) : 0
239
287
  }
240
288
 
241
289
  /**
@@ -253,16 +301,25 @@ function calcSize(originalSize, prunedSize) {
253
301
  /**
254
302
  * Prints a nice diff table
255
303
  * @param {object} opts
256
- * @param {number | undefined} opts.prunedSize
304
+ * @param {Promise<number> | undefined} opts.prunedSize
257
305
  * @param {number} opts.startTime
258
306
  * @param {number} opts.itemCount
259
- * @param {number | undefined} opts.originalSize
307
+ * @param {Promise<number> | undefined} opts.originalSize
260
308
  */
261
- export function printDiff({ prunedSize, startTime, itemCount, originalSize }) {
262
- console.table([
309
+ export async function printDiff({
310
+ prunedSize,
311
+ startTime,
312
+ itemCount,
313
+ originalSize,
314
+ }) {
315
+ const [original, pruned] =
316
+ originalSize && prunedSize
317
+ ? await Promise.all([originalSize, prunedSize])
318
+ : [undefined, undefined]
319
+
320
+ log.table([
263
321
  {
264
- Pruned:
265
- originalSize && prunedSize ? calcSize(originalSize, prunedSize) : 'n/a',
322
+ ...(original && pruned && { Pruned: calcSize(original, pruned) }),
266
323
  Time: `${((Date.now() - startTime) / 1000).toFixed(1)}s`,
267
324
  Items: itemCount,
268
325
  },
@@ -272,18 +329,20 @@ export function printDiff({ prunedSize, startTime, itemCount, originalSize }) {
272
329
  /**
273
330
  * @typedef Args
274
331
  * @type {object}
275
- * @property {string} path - Path to node_modules
332
+ * @property {string} [path] - Path to node_modules
276
333
  * @property {string[]} include - New glob pattern
277
334
  * @property {string[]} exclude - Existing glob pattern
278
335
  * @property {boolean} help - Prints help
279
336
  * @property {boolean} noSize - Don't show size savings
280
337
  * @property {boolean} globs - Prints globs
338
+ * @property {boolean} quiet - Suppress console.log output
281
339
  */
282
340
 
283
341
  /**
284
342
  * Parse the command-line arguments into an object
285
343
  * @returns {Args}
286
344
  */
345
+
287
346
  function handleArgs() {
288
347
  try {
289
348
  const {
@@ -297,9 +356,9 @@ function handleArgs() {
297
356
  help: { type: 'boolean', short: 'h', default: false },
298
357
  globs: { type: 'boolean', short: 'g', default: false },
299
358
  noSize: { type: 'boolean', short: 'n', default: false },
359
+ quiet: { type: 'boolean', short: 'q', default: false },
300
360
  },
301
361
  })
302
- if (!path) throw bail('Path not defined', undefined, true)
303
362
 
304
363
  return { ...values, path }
305
364
  } catch (err) {
@@ -327,14 +386,6 @@ export async function validateNodeModulesPath(nodeModulesPath) {
327
386
  }
328
387
  }
329
388
 
330
- /**
331
- * Removes a directory or a file
332
- * @param {string} file - the file or dir to remove
333
- */
334
- async function rimraf(file) {
335
- await fs.rm(file, { recursive: true, force: true })
336
- }
337
-
338
389
  /**
339
390
  * `file.matchesGlob()` does not match dotfiles, this util replaces leading dots
340
391
  * with an underscore
@@ -644,44 +695,131 @@ export function compactPaths(paths) {
644
695
  return compact
645
696
  }
646
697
 
698
+ /**
699
+ * Checks the rmdir error: ENOTEMPTY means the dir was not empty and the removal
700
+ * failed, which is what we want
701
+ * @param {unknown} err
702
+ * @returns {boolean}
703
+ */
704
+ function hasContent(err) {
705
+ return (
706
+ !!err &&
707
+ typeof err === 'object' &&
708
+ 'code' in err &&
709
+ (err.code === 'ENOTEMPTY' || err.code === 'ENOENT')
710
+ )
711
+ }
712
+
713
+ /**
714
+ * Removes a dir if it's empty
715
+ * @param {string[]} dirs
716
+ * @returns {Promise<void>[]}
717
+ */
718
+ function rmEmptyDir(dirs) {
719
+ return dirs.map(dir =>
720
+ fs.rmdir(dir).catch(err => {
721
+ if (hasContent(err)) return
722
+ throw err
723
+ })
724
+ )
725
+ }
726
+
727
+ /**
728
+ * Removes a file and collects parent directories for later cleanup
729
+ * @param {string} file - the file to remove
730
+ * @param {Set<string>} visited - tracks directories we've already visited
731
+ * @param {Map<number, Set<string>>} dirDepths - cleanup dirs grouped by depth
732
+ * @param {string} rootDir - stop collecting once we reach this directory
733
+ */
734
+ async function rimraf(
735
+ file,
736
+ visited = new Set(),
737
+ dirDepths = new Map(),
738
+ rootDir = dirname(file)
739
+ ) {
740
+ // Remove the file/dir recursively
741
+ await fs.rm(file, { recursive: true, force: true })
742
+
743
+ // Walk up the tree collecting all the ancestors, we'll use them later on to
744
+ // delete directories which are left empty
745
+ let dir = dirname(file)
746
+ while (dir !== rootDir) {
747
+ if (visited.has(dir)) break
748
+ visited.add(dir)
749
+
750
+ const depth = dir.split('/').length
751
+
752
+ // Group the dirs by depth
753
+ const dirs = dirDepths.get(depth)
754
+ if (dirs) dirs.add(dir)
755
+ else dirDepths.set(depth, new Set([dir]))
756
+
757
+ dir = dirname(dir)
758
+ }
759
+ }
760
+
761
+ /**
762
+ * @typedef {Args & { path: string }} ArgsWithRequiredPath
763
+ */
764
+
647
765
  /**
648
766
  * Removes unneeded files from node_modules
649
- * @param {Args} opts
767
+ * @param {ArgsWithRequiredPath} opts
650
768
  */
651
769
  export async function prune(opts) {
652
770
  const startTime = Date.now()
653
771
  log.info('Pruning:', opts.path)
654
772
 
655
- const originalSize = opts.noSize ? undefined : getSize(opts.path)
773
+ // Don't wait
774
+ const originalSize = getSize(opts.path)
656
775
  const excludedGlobs = new Set(opts.exclude)
657
776
  const activeGlobs = [...defaultGlobs, ...opts.include].filter(
658
777
  glob => !excludedGlobs.has(glob)
659
778
  )
660
779
  const compiledGlobs = compileGlobs(activeGlobs)
661
780
 
662
- // This could be slightly faster with optimized walker
781
+ // TODO: this could be slightly faster with an optimized walker
663
782
  const allFiles = await fs.readdir(opts.path, {
664
783
  recursive: true,
665
784
  withFileTypes: true,
666
785
  })
667
786
 
668
- const junkFiles = findJunkFiles(allFiles, compiledGlobs)
669
- const results = compactPaths(junkFiles)
787
+ const junk = compactPaths(findJunkFiles(allFiles, compiledGlobs))
670
788
 
671
789
  try {
672
- await Promise.all(results.map(x => rimraf(x)))
790
+ /** @type {Set<string>} */
791
+ const visited = new Set()
792
+ /** @type {Map<number, Set<string>>} */
793
+ const dirDepths = new Map()
794
+ // Rm & populate visited & dirDepths so dirs can be removed in parallel
795
+ await Promise.all(junk.map(x => rimraf(x, visited, dirDepths, opts.path)))
796
+ const depths = [...dirDepths.keys()].sort((a, b) => b - a)
797
+
798
+ /**
799
+ * Remove one depth level at a time, but parallelize within each level
800
+ * @param {number} i
801
+ * @returns {Promise<void>}
802
+ */
803
+ async function removeDepth(i) {
804
+ if (i >= depths.length) return
805
+ const dirs = dirDepths.get(depths[i] || 0) ?? []
806
+ await Promise.all(rmEmptyDir([...dirs]))
807
+ await removeDepth(i + 1)
808
+ }
809
+
810
+ await removeDepth(0)
673
811
  } catch (err) {
674
812
  throw bail(undefined, err)
675
813
  }
676
814
 
677
- printDiff({
678
- itemCount: results.length,
815
+ void printDiff({
816
+ itemCount: junk.length,
679
817
  prunedSize: opts.noSize ? undefined : getSize(opts.path),
680
- originalSize,
818
+ originalSize: opts.noSize ? undefined : originalSize,
681
819
  startTime,
682
820
  })
683
821
 
684
- return results
822
+ return junk
685
823
  }
686
824
 
687
825
  const entry = process.argv[1]
@@ -691,16 +829,28 @@ const runAsScript =
691
829
  if (runAsScript) {
692
830
  const args = handleArgs()
693
831
 
832
+ quiet = args.quiet
833
+
694
834
  if (args.help) {
695
835
  usage()
696
836
  process.exit(0)
697
837
  }
698
838
 
699
839
  if (args.globs) {
700
- console.log(JSON.stringify(defaultGlobs, null, 2))
840
+ log.log(JSON.stringify(defaultGlobs, null, 2))
701
841
  process.exit(0)
702
842
  }
703
843
 
704
- await validateNodeModulesPath(args.path)
705
- await prune(args)
844
+ // Should have a path by now
845
+ if (!args.path) {
846
+ throw bail(
847
+ undefined,
848
+ 'Path not defined. Usage: prod-files <path-to-node-modules>'
849
+ )
850
+ }
851
+
852
+ const argsWithPath = /** @type {ArgsWithRequiredPath} */ (args)
853
+
854
+ await validateNodeModulesPath(argsWithPath.path)
855
+ await prune(argsWithPath)
706
856
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prod-files",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Keep only prod files by pruning non-prod files from node_modules before deploying",
5
5
  "keywords": [
6
6
  "clean",
@@ -40,10 +40,8 @@
40
40
  "pf": "node ./index.mjs",
41
41
  "pub": "(npm whoami || npm login) && pnpm publish --access=public --tag=latest",
42
42
  "test": "node --test",
43
- "test:nuke": "rm -rf ./test-project/node_modules ./test-project/pnpm-lock.yaml",
44
- "test:run": "cd test-project && time node ../index.mjs node_modules/.pnpm",
45
- "test:setup": "node --run test:nuke && pnpm -C=test-project i --prod",
46
- "test:size": "du -sh ./test-project/node_modules/.pnpm | sort -h",
47
- "test:watch": "node --test --watch"
43
+ "test:e2e": "cd test-project && bash prepare_test.sh && time node ../index.mjs node_modules/.pnpm",
44
+ "test:e2e:nuke": "trash ./test-project/node_modules ./test-project/pnpm-lock.yaml && pnpm store prune",
45
+ "test:e2e:weight": "du -sh ./test-project/node_modules/.pnpm | sort -h"
48
46
  }
49
47
  }