prod-files 0.1.2 → 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 +192 -55
  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
  },
@@ -278,12 +335,14 @@ export function printDiff({ prunedSize, startTime, itemCount, originalSize }) {
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,6 +356,7 @@ 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
362
 
@@ -326,14 +386,6 @@ export async function validateNodeModulesPath(nodeModulesPath) {
326
386
  }
327
387
  }
328
388
 
329
- /**
330
- * Removes a directory or a file
331
- * @param {string} file - the file or dir to remove
332
- */
333
- async function rimraf(file) {
334
- await fs.rm(file, { recursive: true, force: true })
335
- }
336
-
337
389
  /**
338
390
  * `file.matchesGlob()` does not match dotfiles, this util replaces leading dots
339
391
  * with an underscore
@@ -643,6 +695,69 @@ export function compactPaths(paths) {
643
695
  return compact
644
696
  }
645
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
+
646
761
  /**
647
762
  * @typedef {Args & { path: string }} ArgsWithRequiredPath
648
763
  */
@@ -655,36 +770,56 @@ export async function prune(opts) {
655
770
  const startTime = Date.now()
656
771
  log.info('Pruning:', opts.path)
657
772
 
658
- const originalSize = opts.noSize ? undefined : getSize(opts.path)
773
+ // Don't wait
774
+ const originalSize = getSize(opts.path)
659
775
  const excludedGlobs = new Set(opts.exclude)
660
776
  const activeGlobs = [...defaultGlobs, ...opts.include].filter(
661
777
  glob => !excludedGlobs.has(glob)
662
778
  )
663
779
  const compiledGlobs = compileGlobs(activeGlobs)
664
780
 
665
- // This could be slightly faster with optimized walker
781
+ // TODO: this could be slightly faster with an optimized walker
666
782
  const allFiles = await fs.readdir(opts.path, {
667
783
  recursive: true,
668
784
  withFileTypes: true,
669
785
  })
670
786
 
671
- const junkFiles = findJunkFiles(allFiles, compiledGlobs)
672
- const results = compactPaths(junkFiles)
787
+ const junk = compactPaths(findJunkFiles(allFiles, compiledGlobs))
673
788
 
674
789
  try {
675
- 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)
676
811
  } catch (err) {
677
812
  throw bail(undefined, err)
678
813
  }
679
814
 
680
- printDiff({
681
- itemCount: results.length,
815
+ void printDiff({
816
+ itemCount: junk.length,
682
817
  prunedSize: opts.noSize ? undefined : getSize(opts.path),
683
- originalSize,
818
+ originalSize: opts.noSize ? undefined : originalSize,
684
819
  startTime,
685
820
  })
686
821
 
687
- return results
822
+ return junk
688
823
  }
689
824
 
690
825
  const entry = process.argv[1]
@@ -694,13 +829,15 @@ const runAsScript =
694
829
  if (runAsScript) {
695
830
  const args = handleArgs()
696
831
 
832
+ quiet = args.quiet
833
+
697
834
  if (args.help) {
698
835
  usage()
699
836
  process.exit(0)
700
837
  }
701
838
 
702
839
  if (args.globs) {
703
- console.log(JSON.stringify(defaultGlobs, null, 2))
840
+ log.log(JSON.stringify(defaultGlobs, null, 2))
704
841
  process.exit(0)
705
842
  }
706
843
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prod-files",
3
- "version": "0.1.2",
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
  }