@dependably/npm-check 1.7.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/src/backup.js ADDED
@@ -0,0 +1,235 @@
1
+ // src/backup.js
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import crypto from 'crypto';
5
+
6
+ export class BackupError extends Error {
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = 'BackupError';
10
+ }
11
+ }
12
+
13
+ /**
14
+ * Derive the backup directory for a given source file.
15
+ * Backups live in a `.backups/` sibling directory next to the source file,
16
+ * so they follow the file regardless of the caller's cwd.
17
+ *
18
+ * @param {string} absFilePath - Absolute path to the source file
19
+ * @returns {string} Absolute path to the backup directory
20
+ */
21
+ function backupDirFor(absFilePath) {
22
+ return path.join(path.dirname(absFilePath), '.backups');
23
+ }
24
+
25
+ /**
26
+ * A short, stable hex hash of the absolute source path. Used as part of the
27
+ * backup filename so that two files with the same basename in different
28
+ * directories (e.g. `web/package-lock.json` and `api/package-lock.json`) get
29
+ * distinct backup names and never overwrite each other's history.
30
+ *
31
+ * @param {string} absPath - Absolute path to hash
32
+ * @returns {string} 8-character lowercase hex string
33
+ */
34
+ function pathHash(absPath) {
35
+ // Not security-sensitive — just an 8-char discriminator so same-basename files
36
+ // in different directories get distinct backup names. sha256 (over sha1) keeps
37
+ // static analysis happy about weak hashes at zero cost.
38
+ return crypto.createHash('sha256').update(absPath).digest('hex').slice(0, 8);
39
+ }
40
+
41
+ /**
42
+ * Create a backup directory if it doesn't exist.
43
+ *
44
+ * @param {string} dir - Absolute path to the backup directory
45
+ * @throws {BackupError} If directory creation fails
46
+ */
47
+ function ensureBackupsDir(dir) {
48
+ try {
49
+ if (!fs.existsSync(dir)) {
50
+ fs.mkdirSync(dir, { recursive: true });
51
+ }
52
+ } catch (e) {
53
+ throw new BackupError(`Failed to create backups directory: ${e.message}`);
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Create a timestamped backup of a file.
59
+ *
60
+ * The backup is placed in a `.backups/` directory next to the source file so
61
+ * that backups are always adjacent to what they protect and are not sensitive
62
+ * to the caller's cwd.
63
+ *
64
+ * Filename format: `<basename>.<pathHash8>.<isoTimestampMs>.bak`
65
+ *
66
+ * - `pathHash8` — 8-char hash of the absolute source path, preventing
67
+ * cross-file collisions when two files share the same basename.
68
+ * - Millisecond-precision ISO timestamp — prevents same-second overwrites.
69
+ * - `wx` open flag — refuses to overwrite an existing file; a monotonically
70
+ * increasing counter suffix (`.1`, `.2`, …) is appended on the rare
71
+ * EEXIST collision (e.g. two calls within the same millisecond in tests).
72
+ *
73
+ * @param {string} filePath - Path to the file to backup (absolute or relative)
74
+ * @returns {string} Absolute path to the created backup file
75
+ * @throws {BackupError} If the source file is missing or backup creation fails
76
+ */
77
+ export function createBackup(filePath) {
78
+ const absPath = path.resolve(filePath);
79
+ if (!fs.existsSync(absPath)) {
80
+ throw new BackupError(`File not found: ${filePath}`);
81
+ }
82
+
83
+ const backupDir = backupDirFor(absPath);
84
+ try {
85
+ ensureBackupsDir(backupDir);
86
+
87
+ const basename = path.basename(absPath);
88
+ const hash = pathHash(absPath);
89
+ // ms-precision: YYYY-MM-DDTHH-mm-ss-mmmZ (all colons/dots replaced)
90
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
91
+ const baseBackupName = `${basename}.${hash}.${timestamp}.bak`;
92
+ const content = fs.readFileSync(absPath, 'utf8');
93
+
94
+ // Open with 'wx' — errors on EEXIST rather than overwriting. On the rare
95
+ // same-millisecond collision append a counter and retry.
96
+ let backupPath = path.join(backupDir, baseBackupName);
97
+ let counter = 0;
98
+ for (;;) {
99
+ try {
100
+ fs.writeFileSync(backupPath, content, { encoding: 'utf8', flag: 'wx' });
101
+ break;
102
+ } catch (e) {
103
+ if (e.code === 'EEXIST') {
104
+ counter++;
105
+ backupPath = path.join(backupDir, `${baseBackupName}.${counter}`);
106
+ } else {
107
+ throw e;
108
+ }
109
+ }
110
+ }
111
+
112
+ return backupPath;
113
+ } catch (e) {
114
+ if (e instanceof BackupError) throw e;
115
+ throw new BackupError(`Failed to create backup: ${e.message}`);
116
+ }
117
+ }
118
+
119
+ /**
120
+ * List all backups for a given source file, ordered newest-first.
121
+ *
122
+ * Backups are scoped by both the basename and the path hash, so only backups
123
+ * for this exact file are returned — not backups for same-named files in other
124
+ * directories.
125
+ *
126
+ * @param {string} filePath - Path to the source file (not a bare basename)
127
+ * @returns {Array<{name: string, path: string, timestamp: string, created: Date}>}
128
+ * @throws {BackupError} If backup listing fails
129
+ */
130
+ export function listBackups(filePath) {
131
+ const absPath = path.resolve(filePath);
132
+ const backupDir = backupDirFor(absPath);
133
+ try {
134
+ ensureBackupsDir(backupDir);
135
+
136
+ if (!fs.existsSync(backupDir)) {
137
+ return [];
138
+ }
139
+
140
+ const basename = path.basename(absPath);
141
+ const hash = pathHash(absPath);
142
+ // All backups for this exact source file share this prefix.
143
+ const prefix = `${basename}.${hash}.`;
144
+
145
+ const files = fs.readdirSync(backupDir);
146
+ const backups = files
147
+ .filter(f => f.startsWith(prefix) && f.includes('.bak'))
148
+ .map(f => {
149
+ const backupPath = path.join(backupDir, f);
150
+ const stats = fs.statSync(backupPath);
151
+ // Timestamp is everything between the prefix and ".bak"
152
+ let timestamp = 'unknown';
153
+ const withoutPrefix = f.slice(prefix.length);
154
+ const bakIdx = withoutPrefix.indexOf('.bak');
155
+ if (bakIdx !== -1) timestamp = withoutPrefix.slice(0, bakIdx);
156
+ return {
157
+ name: f,
158
+ path: backupPath,
159
+ timestamp,
160
+ created: stats.mtime
161
+ };
162
+ })
163
+ .sort((a, b) => b.created - a.created);
164
+
165
+ return backups;
166
+ } catch (e) {
167
+ if (e instanceof BackupError) throw e;
168
+ throw new BackupError(`Failed to list backups: ${e.message}`);
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Restore a file from its most recent backup.
174
+ *
175
+ * @param {string} filePath - Path to the file to restore
176
+ * @returns {boolean} True if restoration was successful
177
+ * @throws {BackupError} If restoration fails or no backups exist
178
+ */
179
+ export function restoreFromLatestBackup(filePath) {
180
+ try {
181
+ const absPath = path.resolve(filePath);
182
+ const backups = listBackups(absPath);
183
+
184
+ if (backups.length === 0) {
185
+ throw new BackupError(`No backups found for ${filePath}`);
186
+ }
187
+
188
+ const latestBackup = backups[0];
189
+ const backupContent = fs.readFileSync(latestBackup.path, 'utf8');
190
+ fs.writeFileSync(absPath, backupContent, 'utf8');
191
+
192
+ console.log(`Restored ${path.basename(absPath)} from backup: ${latestBackup.name}`);
193
+ return true;
194
+ } catch (e) {
195
+ if (e instanceof BackupError) throw e;
196
+ throw new BackupError(`Failed to restore backup: ${e.message}`);
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Clean old backups for a source file, keeping only the most recent N.
202
+ *
203
+ * Cleanup is scoped to the specific source file (by absolute path) so that
204
+ * pruning one file's history never deletes backups for a same-named file in a
205
+ * different directory.
206
+ *
207
+ * @param {string} filePath - Path to the source file
208
+ * @param {number} keepCount - Number of backups to keep (default: 5)
209
+ * @returns {number} Number of backups deleted
210
+ * @throws {BackupError} If cleanup fails
211
+ */
212
+ export function cleanOldBackups(filePath, keepCount = 5) {
213
+ try {
214
+ const absPath = path.resolve(filePath);
215
+ const backups = listBackups(absPath);
216
+ if (backups.length <= keepCount) {
217
+ return 0;
218
+ }
219
+
220
+ const toDelete = backups.slice(keepCount);
221
+ let deleted = 0;
222
+
223
+ for (const backup of toDelete) {
224
+ fs.unlinkSync(backup.path);
225
+ deleted++;
226
+ }
227
+
228
+ return deleted;
229
+ } catch (e) {
230
+ if (e instanceof BackupError) throw e;
231
+ throw new BackupError(`Failed to clean old backups: ${e.message}`);
232
+ }
233
+ }
234
+
235
+ export default { createBackup, listBackups, restoreFromLatestBackup, cleanOldBackups, BackupError };