npmdata 0.9.1 → 0.10.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/dist/consumer.js CHANGED
@@ -3,6 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.compressGitignoreEntries = compressGitignoreEntries;
7
+ exports.findNearestMarkerPath = findNearestMarkerPath;
6
8
  exports.extract = extract;
7
9
  exports.check = check;
8
10
  exports.purge = purge;
@@ -12,6 +14,7 @@ exports.list = list;
12
14
  /* eslint-disable no-continue */
13
15
  /* eslint-disable functional/immutable-data */
14
16
  /* eslint-disable no-restricted-syntax */
17
+ /* eslint-disable max-depth */
15
18
  const node_fs_1 = __importDefault(require("node:fs"));
16
19
  const node_path_1 = __importDefault(require("node:path"));
17
20
  const node_child_process_1 = require("node:child_process");
@@ -22,6 +25,24 @@ const MARKER_FILE = '.npmdata';
22
25
  const GITIGNORE_FILE = '.gitignore';
23
26
  const GITIGNORE_START = '# npmdata:start';
24
27
  const GITIGNORE_END = '# npmdata:end';
28
+ /**
29
+ * Read the .gitignore at dir and return parsed patterns, excluding the npmdata-managed
30
+ * section. These are the "external" patterns (e.g. node_modules, dist) that were written
31
+ * by the project author rather than by npmdata itself.
32
+ */
33
+ function readExternalGitignorePatterns(dir) {
34
+ const gitignorePath = node_path_1.default.join(dir, GITIGNORE_FILE);
35
+ if (!node_fs_1.default.existsSync(gitignorePath))
36
+ return [];
37
+ let content = node_fs_1.default.readFileSync(gitignorePath, 'utf8');
38
+ // Strip out the npmdata-managed block so we only act on external entries.
39
+ const startIdx = content.indexOf(GITIGNORE_START);
40
+ const endIdx = content.indexOf(GITIGNORE_END);
41
+ if (startIdx !== -1 && endIdx !== -1 && startIdx < endIdx) {
42
+ content = content.slice(0, startIdx) + content.slice(endIdx + GITIGNORE_END.length);
43
+ }
44
+ return (0, utils_1.parseGitignorePatterns)(content);
45
+ }
25
46
  /**
26
47
  * Update (or create) a .gitignore in the given directory so that the managed
27
48
  * files and the .npmdata marker file are ignored by git.
@@ -67,40 +88,161 @@ function updateGitignoreForDir(dir, managedFilenames, addEntries = true) {
67
88
  node_fs_1.default.writeFileSync(gitignorePath, updatedContent, 'utf8');
68
89
  }
69
90
  /**
70
- * Walk outputDir and update .gitignore files for every directory that has a
71
- * .npmdata marker (to reflect its current managed files) and also clean up
72
- * any npmdata sections in directories where the marker was removed.
91
+ * Optimise the list of managed file paths for use in .gitignore.
92
+ * When every file inside a directory (recursively, excluding MARKER_FILE, GITIGNORE_FILE, and
93
+ * symlinks) is present in managedPaths, the whole directory is represented as "dir/" rather than
94
+ * listing each file individually. Root-level files (no slash) are always emitted as-is.
95
+ *
96
+ * @param managedPaths - Paths relative to outputDir (e.g. ["docs/guide.md", "README.md"])
97
+ * @param outputDir - Absolute path to the root used to inspect actual disk contents
98
+ */
99
+ function compressGitignoreEntries(managedPaths, outputDir) {
100
+ const managedSet = new Set(managedPaths);
101
+ const gitignorePatterns = readExternalGitignorePatterns(outputDir);
102
+ // Returns true when every non-special, non-symlink file inside absDir (recursively)
103
+ // appears in managedSet under its full outputDir-relative path (relDir prefix included).
104
+ const isDirFullyManaged = (absDir, relDir) => {
105
+ if (!node_fs_1.default.existsSync(absDir))
106
+ return false;
107
+ for (const entry of node_fs_1.default.readdirSync(absDir)) {
108
+ if (entry === MARKER_FILE || entry === GITIGNORE_FILE)
109
+ continue;
110
+ const absEntry = node_path_1.default.join(absDir, entry);
111
+ const relEntry = `${relDir}/${entry}`;
112
+ const lstat = node_fs_1.default.lstatSync(absEntry);
113
+ if (lstat.isSymbolicLink())
114
+ continue;
115
+ if (lstat.isDirectory()) {
116
+ // Skip gitignored subdirs that have no managed files — they are not our concern
117
+ // and traversing them (e.g. node_modules) causes serious performance problems.
118
+ if ((0, utils_1.isGitignored)(entry, gitignorePatterns) && !(0, utils_1.hasManagedFilesUnder)(relEntry, managedSet)) {
119
+ continue;
120
+ }
121
+ if (!isDirFullyManaged(absEntry, relEntry))
122
+ return false;
123
+ }
124
+ else if (!managedSet.has(relEntry))
125
+ return false;
126
+ }
127
+ return true;
128
+ };
129
+ // paths: managed paths relative to the current directory scope
130
+ // absRoot: absolute path of the current directory scope
131
+ // relRoot: path of the current scope relative to outputDir (empty string at top level)
132
+ const compress = (paths, absRoot, relRoot) => {
133
+ const result = [];
134
+ const subdirNames = new Set();
135
+ for (const p of paths) {
136
+ const slashIdx = p.indexOf('/');
137
+ if (slashIdx === -1) {
138
+ // File lives directly in this scope — emit its full outputDir-relative path
139
+ result.push(relRoot ? `${relRoot}/${p}` : p);
140
+ }
141
+ else {
142
+ subdirNames.add(p.slice(0, slashIdx));
143
+ }
144
+ }
145
+ for (const dirName of subdirNames) {
146
+ const absDir = node_path_1.default.join(absRoot, dirName);
147
+ const relDir = relRoot ? `${relRoot}/${dirName}` : dirName;
148
+ const prefix = `${dirName}/`;
149
+ const subPaths = paths.filter((p) => p.startsWith(prefix)).map((p) => p.slice(prefix.length));
150
+ if (isDirFullyManaged(absDir, relDir)) {
151
+ result.push(`${relDir}/`);
152
+ }
153
+ else {
154
+ result.push(...compress(subPaths, absDir, relDir));
155
+ }
156
+ }
157
+ return result;
158
+ };
159
+ return compress(managedPaths, outputDir, '');
160
+ }
161
+ /**
162
+ * Find the nearest .npmdata marker file by walking up from fromDir to outputDir (inclusive).
163
+ * Returns the path to the marker file, or null if none found within the outputDir boundary.
164
+ */
165
+ function findNearestMarkerPath(fromDir, outputDir) {
166
+ let dir = fromDir;
167
+ const resolvedOutput = node_path_1.default.resolve(outputDir);
168
+ // eslint-disable-next-line no-constant-condition
169
+ while (true) {
170
+ const markerPath = node_path_1.default.join(dir, MARKER_FILE);
171
+ if (node_fs_1.default.existsSync(markerPath))
172
+ return markerPath;
173
+ if (node_path_1.default.resolve(dir) === resolvedOutput)
174
+ break;
175
+ const parent = node_path_1.default.dirname(dir);
176
+ if (parent === dir)
177
+ break; // reached filesystem root
178
+ dir = parent;
179
+ }
180
+ // eslint-disable-next-line unicorn/no-null
181
+ return null;
182
+ }
183
+ /**
184
+ * Write one .gitignore at outputDir containing all managed file paths (relative to outputDir),
185
+ * and remove any npmdata sections from .gitignore files in subdirectories.
73
186
  * When addEntries is false, existing sections are updated/removed but no new
74
187
  * sections are created — use this to clean up without opting into gitignore management.
75
188
  */
76
189
  function updateGitignores(outputDir, addEntries = true) {
77
190
  if (!node_fs_1.default.existsSync(outputDir))
78
191
  return;
79
- const walkDir = (dir) => {
80
- const markerPath = node_path_1.default.join(dir, MARKER_FILE);
81
- const gitignorePath = node_path_1.default.join(dir, GITIGNORE_FILE);
82
- if (node_fs_1.default.existsSync(markerPath)) {
83
- try {
84
- const managedFiles = (0, utils_1.readCsvMarker)(markerPath);
85
- updateGitignoreForDir(dir, managedFiles.map((m) => m.path), addEntries);
86
- }
87
- catch {
88
- // Ignore unreadable marker files
192
+ // Read managed paths up-front so we can skip gitignored dirs that have no managed files.
193
+ const managedPaths = new Set();
194
+ const rootMarkerPathForRead = node_path_1.default.join(outputDir, MARKER_FILE);
195
+ if (node_fs_1.default.existsSync(rootMarkerPathForRead)) {
196
+ // eslint-disable-next-line functional/no-try-statements
197
+ try {
198
+ for (const m of (0, utils_1.readCsvMarker)(rootMarkerPathForRead)) {
199
+ managedPaths.add(m.path);
89
200
  }
90
201
  }
91
- else if (node_fs_1.default.existsSync(gitignorePath)) {
92
- // Clean up any leftover npmdata section
93
- updateGitignoreForDir(dir, [], addEntries);
202
+ catch {
203
+ // ignore unreadable marker
94
204
  }
205
+ }
206
+ // Read external gitignore patterns once for the whole walk.
207
+ const gitignorePatterns = readExternalGitignorePatterns(outputDir);
208
+ // Remove npmdata sections from all subdirectory .gitignore files (migration / cleanup of old format)
209
+ const cleanupSubDirGitignores = (dir) => {
95
210
  for (const item of node_fs_1.default.readdirSync(dir)) {
96
211
  const fullPath = node_path_1.default.join(dir, item);
97
212
  const lstat = node_fs_1.default.lstatSync(fullPath);
98
213
  if (!lstat.isSymbolicLink() && lstat.isDirectory()) {
99
- walkDir(fullPath);
214
+ const relPath = node_path_1.default.relative(outputDir, fullPath);
215
+ // Skip gitignored directories that have no managed files under them —
216
+ // traversing them (e.g. node_modules) causes serious performance problems.
217
+ if ((0, utils_1.isGitignored)(item, gitignorePatterns) && !(0, utils_1.hasManagedFilesUnder)(relPath, managedPaths)) {
218
+ continue;
219
+ }
220
+ const subGitignore = node_path_1.default.join(fullPath, GITIGNORE_FILE);
221
+ if (node_fs_1.default.existsSync(subGitignore)) {
222
+ updateGitignoreForDir(fullPath, [], false);
223
+ }
224
+ cleanupSubDirGitignores(fullPath);
100
225
  }
101
226
  }
102
227
  };
103
- walkDir(outputDir);
228
+ cleanupSubDirGitignores(outputDir);
229
+ // Update (or remove) the single .gitignore at outputDir
230
+ const rootMarkerPath = node_path_1.default.join(outputDir, MARKER_FILE);
231
+ if (node_fs_1.default.existsSync(rootMarkerPath)) {
232
+ try {
233
+ const managedFiles = (0, utils_1.readCsvMarker)(rootMarkerPath);
234
+ const rawPaths = managedFiles.map((m) => m.path);
235
+ const optimisedPaths = compressGitignoreEntries(rawPaths, outputDir);
236
+ updateGitignoreForDir(outputDir, optimisedPaths, addEntries);
237
+ }
238
+ catch {
239
+ // Ignore unreadable marker files
240
+ }
241
+ }
242
+ else {
243
+ // Clean up any leftover npmdata section at root
244
+ updateGitignoreForDir(outputDir, [], false);
245
+ }
104
246
  }
105
247
  async function getPackageFiles(packageName, cwd) {
106
248
  const pkgPath = require.resolve(`${packageName}/package.json`, {
@@ -177,41 +319,23 @@ async function ensurePackageInstalled(packageName, version, packageManager, cwd,
177
319
  return installedVersion;
178
320
  }
179
321
  /**
180
- * Load all managed files from marker files under outputDir as a flat list.
181
- * Paths are relative to outputDir.
322
+ * Load all managed files from the root marker file at outputDir.
323
+ * Paths stored in the marker are already relative to outputDir.
324
+ * Uses findNearestMarkerPath starting from outputDir itself.
182
325
  */
183
326
  function loadAllManagedFiles(outputDir) {
184
- const files = [];
185
- const walkDir = (dir) => {
186
- if (!node_fs_1.default.existsSync(dir))
187
- return;
188
- for (const item of node_fs_1.default.readdirSync(dir)) {
189
- const fullPath = node_path_1.default.join(dir, item);
190
- const stat = node_fs_1.default.lstatSync(fullPath);
191
- if (stat.isSymbolicLink())
192
- continue;
193
- if (item === MARKER_FILE) {
194
- try {
195
- const managedFiles = (0, utils_1.readCsvMarker)(fullPath);
196
- const markerDir = node_path_1.default.dirname(fullPath);
197
- const relMarkerDir = node_path_1.default.relative(outputDir, markerDir);
198
- for (const managed of managedFiles) {
199
- const relPath = relMarkerDir === '.' ? managed.path : node_path_1.default.join(relMarkerDir, managed.path);
200
- files.push({ ...managed, path: relPath });
201
- }
202
- }
203
- catch {
204
- console.warn(`Warning: Failed to read marker file at ${fullPath}. Skipping.`); // eslint-disable-line no-console
205
- // Ignore unreadable marker files
206
- }
207
- }
208
- else if (stat.isDirectory()) {
209
- walkDir(fullPath);
210
- }
211
- }
212
- };
213
- walkDir(outputDir);
214
- return files;
327
+ if (!node_fs_1.default.existsSync(outputDir))
328
+ return [];
329
+ const markerPath = findNearestMarkerPath(outputDir, outputDir);
330
+ if (!markerPath)
331
+ return [];
332
+ try {
333
+ return (0, utils_1.readCsvMarker)(markerPath);
334
+ }
335
+ catch {
336
+ console.warn(`Warning: Failed to read marker file at ${markerPath}. Skipping.`); // eslint-disable-line no-console
337
+ return [];
338
+ }
215
339
  }
216
340
  /**
217
341
  * Load managed files from all marker files under outputDir, keyed by relative path.
@@ -221,34 +345,25 @@ function loadManagedFilesMap(outputDir) {
221
345
  return new Map(loadAllManagedFiles(outputDir).map((m) => [m.path, m]));
222
346
  }
223
347
  function cleanupEmptyMarkers(outputDir) {
224
- const walkDir = (dir) => {
225
- if (!node_fs_1.default.existsSync(dir))
226
- return;
227
- for (const file of node_fs_1.default.readdirSync(dir)) {
228
- const fullPath = node_path_1.default.join(dir, file);
229
- if (file === MARKER_FILE) {
230
- try {
231
- const managedFiles = (0, utils_1.readCsvMarker)(fullPath);
232
- if (managedFiles.length === 0) {
233
- node_fs_1.default.chmodSync(fullPath, 0o644);
234
- node_fs_1.default.unlinkSync(fullPath);
235
- }
236
- }
237
- catch {
238
- // Ignore unreadable marker files
239
- }
240
- }
241
- else {
242
- const lstat = node_fs_1.default.lstatSync(fullPath);
243
- if (!lstat.isSymbolicLink() && lstat.isDirectory()) {
244
- walkDir(fullPath);
245
- }
246
- }
348
+ if (!node_fs_1.default.existsSync(outputDir))
349
+ return;
350
+ const markerPath = node_path_1.default.join(outputDir, MARKER_FILE);
351
+ if (!node_fs_1.default.existsSync(markerPath))
352
+ return;
353
+ try {
354
+ const managedFiles = (0, utils_1.readCsvMarker)(markerPath);
355
+ if (managedFiles.length === 0) {
356
+ node_fs_1.default.chmodSync(markerPath, 0o644);
357
+ node_fs_1.default.unlinkSync(markerPath);
247
358
  }
248
- };
249
- walkDir(outputDir);
359
+ }
360
+ catch {
361
+ // Ignore unreadable marker files
362
+ }
250
363
  }
251
364
  function cleanupEmptyDirs(outputDir) {
365
+ const gitignorePatterns = readExternalGitignorePatterns(outputDir);
366
+ const managedPaths = new Set(loadAllManagedFiles(outputDir).map((m) => m.path));
252
367
  const walkDir = (dir) => {
253
368
  if (!node_fs_1.default.existsSync(dir))
254
369
  return true;
@@ -257,6 +372,13 @@ function cleanupEmptyDirs(outputDir) {
257
372
  const fullPath = node_path_1.default.join(dir, item);
258
373
  const lstat = node_fs_1.default.lstatSync(fullPath);
259
374
  if (!lstat.isSymbolicLink() && lstat.isDirectory()) {
375
+ const relPath = node_path_1.default.relative(outputDir, fullPath);
376
+ // Skip gitignored directories that have no managed files — they are not our concern
377
+ // and traversing them (e.g. node_modules) causes serious performance problems.
378
+ if ((0, utils_1.isGitignored)(item, gitignorePatterns) && !(0, utils_1.hasManagedFilesUnder)(relPath, managedPaths)) {
379
+ isEmpty = false; // treat as non-empty so we preserve the parent directory
380
+ continue;
381
+ }
260
382
  const childEmpty = walkDir(fullPath);
261
383
  if (!childEmpty)
262
384
  isEmpty = false;
@@ -289,160 +411,145 @@ async function extractFiles(config, packageName) {
289
411
  }
290
412
  emit?.({ type: 'package-start', packageName, packageVersion: installedVersion });
291
413
  const packageFiles = await getPackageFiles(packageName, config.cwd);
292
- const addedByDir = new Map();
414
+ const extractedFiles = [];
293
415
  const existingManagedMap = loadManagedFilesMap(config.outputDir);
294
- const deletedOnlyDirs = new Set();
295
- // Tracks basenames (per directory) force-claimed from a different package so the
416
+ // Tracks full relPaths force-claimed from a different package so the
296
417
  // marker-file merge can evict the previous owner's entry.
297
- const forceClaimedByDir = new Map();
418
+ const forceClaimedPaths = new Set();
298
419
  // eslint-disable-next-line functional/no-let
299
420
  let wasForced = false;
300
- for (const packageFile of packageFiles) {
301
- if (!(0, utils_1.matchesFilenamePattern)(packageFile.relPath, config.filenamePatterns ?? types_1.DEFAULT_FILENAME_PATTERNS) ||
302
- !(0, utils_1.matchesContentRegex)(packageFile.fullPath, config.contentRegexes)) {
303
- continue;
304
- }
305
- const destPath = node_path_1.default.join(config.outputDir, packageFile.relPath);
306
- if (!dryRun)
307
- (0, utils_1.ensureDir)(node_path_1.default.dirname(destPath));
308
- const existingOwner = existingManagedMap.get(packageFile.relPath);
309
- // In unmanaged mode, skip files that already exist on disk.
310
- if (config.unmanaged && node_fs_1.default.existsSync(destPath)) {
311
- changes.skipped.push(packageFile.relPath);
312
- emit?.({ type: 'file-skipped', packageName, file: packageFile.relPath });
313
- continue;
314
- }
315
- // In keep-existing mode, skip files that already exist on disk but create missing ones normally.
316
- if (config.keepExisting && node_fs_1.default.existsSync(destPath)) {
317
- changes.skipped.push(packageFile.relPath);
318
- emit?.({ type: 'file-skipped', packageName, file: packageFile.relPath });
319
- continue;
320
- }
321
- if (node_fs_1.default.existsSync(destPath)) {
322
- if (existingOwner?.packageName === packageName) {
323
- if ((0, utils_1.calculateFileHash)(packageFile.fullPath) === (0, utils_1.calculateFileHash)(destPath)) {
324
- changes.skipped.push(packageFile.relPath);
325
- emit?.({ type: 'file-skipped', packageName, file: packageFile.relPath });
421
+ try {
422
+ for (const packageFile of packageFiles) {
423
+ if (!(0, utils_1.matchesFilenamePattern)(packageFile.relPath, config.filenamePatterns ?? types_1.DEFAULT_FILENAME_PATTERNS) ||
424
+ !(0, utils_1.matchesContentRegex)(packageFile.fullPath, config.contentRegexes)) {
425
+ continue;
426
+ }
427
+ const destPath = node_path_1.default.join(config.outputDir, packageFile.relPath);
428
+ if (!dryRun)
429
+ (0, utils_1.ensureDir)(node_path_1.default.dirname(destPath));
430
+ const existingOwner = existingManagedMap.get(packageFile.relPath);
431
+ // In unmanaged mode, skip files that already exist on disk.
432
+ if (config.unmanaged && node_fs_1.default.existsSync(destPath)) {
433
+ changes.skipped.push(packageFile.relPath);
434
+ emit?.({ type: 'file-skipped', packageName, file: packageFile.relPath });
435
+ continue;
436
+ }
437
+ // In keep-existing mode, skip files that already exist on disk but create missing ones normally.
438
+ if (config.keepExisting && node_fs_1.default.existsSync(destPath)) {
439
+ changes.skipped.push(packageFile.relPath);
440
+ emit?.({ type: 'file-skipped', packageName, file: packageFile.relPath });
441
+ continue;
442
+ }
443
+ if (node_fs_1.default.existsSync(destPath)) {
444
+ if (existingOwner?.packageName === packageName) {
445
+ if ((0, utils_1.calculateFileHash)(packageFile.fullPath) === (0, utils_1.calculateFileHash)(destPath)) {
446
+ changes.skipped.push(packageFile.relPath);
447
+ emit?.({ type: 'file-skipped', packageName, file: packageFile.relPath });
448
+ }
449
+ else {
450
+ if (!dryRun)
451
+ (0, utils_1.copyFile)(packageFile.fullPath, destPath);
452
+ changes.modified.push(packageFile.relPath);
453
+ emit?.({ type: 'file-modified', packageName, file: packageFile.relPath });
454
+ }
455
+ wasForced = false;
326
456
  }
327
457
  else {
458
+ // File exists but is owned by a different package (clash) or is unmanaged (conflict).
459
+ // Behaviour is identical in both cases: throw when force is false, overwrite when true.
460
+ if (!config.force) {
461
+ if (existingOwner) {
462
+ throw new Error(`Package clash: ${packageFile.relPath} already managed by ${existingOwner.packageName}@${existingOwner.packageVersion}. Cannot extract from ${packageName}. Use force: true to override.`);
463
+ }
464
+ throw new Error(`File conflict: ${packageFile.relPath} already exists and is not managed by npmdata. Use force: true to override.`);
465
+ }
466
+ // force=true: overwrite the existing file and take ownership.
328
467
  if (!dryRun)
329
468
  (0, utils_1.copyFile)(packageFile.fullPath, destPath);
330
469
  changes.modified.push(packageFile.relPath);
331
470
  emit?.({ type: 'file-modified', packageName, file: packageFile.relPath });
332
- }
333
- wasForced = false;
334
- }
335
- else {
336
- // File exists but is owned by a different package (clash) or is unmanaged (conflict).
337
- // Behaviour is identical in both cases: throw when force is false, overwrite when true.
338
- if (!config.force) {
471
+ wasForced = true;
339
472
  if (existingOwner) {
340
- throw new Error(`Package clash: ${packageFile.relPath} already managed by ${existingOwner.packageName}@${existingOwner.packageVersion}. Cannot extract from ${packageName}. Use force: true to override.`);
473
+ // Evict the previous owner's entry from the root marker file.
474
+ forceClaimedPaths.add(packageFile.relPath);
341
475
  }
342
- throw new Error(`File conflict: ${packageFile.relPath} already exists and is not managed by npmdata. Use force: true to override.`);
343
476
  }
344
- // force=true: overwrite the existing file and take ownership.
477
+ }
478
+ else {
345
479
  if (!dryRun)
346
480
  (0, utils_1.copyFile)(packageFile.fullPath, destPath);
347
- changes.modified.push(packageFile.relPath);
348
- emit?.({ type: 'file-modified', packageName, file: packageFile.relPath });
349
- wasForced = true;
350
- if (existingOwner) {
351
- // Evict the previous owner's entry from the marker file.
352
- const claimDir = node_path_1.default.dirname(packageFile.relPath) || '.';
353
- if (!forceClaimedByDir.has(claimDir))
354
- forceClaimedByDir.set(claimDir, new Set());
355
- forceClaimedByDir.get(claimDir).add(node_path_1.default.basename(packageFile.relPath));
356
- }
481
+ changes.added.push(packageFile.relPath);
482
+ emit?.({ type: 'file-added', packageName, file: packageFile.relPath });
483
+ wasForced = false;
357
484
  }
358
- }
359
- else {
360
- if (!dryRun)
361
- (0, utils_1.copyFile)(packageFile.fullPath, destPath);
362
- changes.added.push(packageFile.relPath);
363
- emit?.({ type: 'file-added', packageName, file: packageFile.relPath });
364
- wasForced = false;
365
- }
366
- if (!dryRun && !config.unmanaged && node_fs_1.default.existsSync(destPath))
367
- node_fs_1.default.chmodSync(destPath, 0o444);
368
- if (!config.unmanaged) {
369
- const dir = node_path_1.default.dirname(packageFile.relPath) || '.';
370
- if (!addedByDir.has(dir)) {
371
- addedByDir.set(dir, []);
485
+ if (!dryRun && !config.unmanaged && node_fs_1.default.existsSync(destPath))
486
+ node_fs_1.default.chmodSync(destPath, 0o444);
487
+ if (!config.unmanaged) {
488
+ // eslint-disable-next-line functional/immutable-data
489
+ extractedFiles.push({
490
+ path: packageFile.relPath,
491
+ packageName,
492
+ packageVersion: installedVersion,
493
+ force: wasForced,
494
+ });
372
495
  }
373
- addedByDir.get(dir).push({
374
- path: node_path_1.default.basename(packageFile.relPath),
375
- packageName,
376
- packageVersion: installedVersion,
377
- force: wasForced,
378
- });
379
496
  }
380
- }
381
- // Delete files that were managed by this package but are no longer in the package
382
- for (const [relPath, owner] of existingManagedMap) {
383
- if (owner.packageName !== packageName)
384
- continue;
385
- const fileDir = node_path_1.default.dirname(relPath) === '.' ? '.' : node_path_1.default.dirname(relPath);
386
- const dirFiles = addedByDir.get(fileDir) ?? [];
387
- const stillPresent = dirFiles.some((m) => m.path === node_path_1.default.basename(relPath));
388
- if (!stillPresent) {
389
- const fullPath = node_path_1.default.join(config.outputDir, relPath);
390
- if (node_fs_1.default.existsSync(fullPath)) {
391
- if (!dryRun)
392
- (0, utils_1.removeFile)(fullPath);
393
- changes.deleted.push(relPath);
394
- emit?.({ type: 'file-deleted', packageName, file: relPath });
395
- }
396
- const dir = node_path_1.default.dirname(relPath) === '.' ? '.' : node_path_1.default.dirname(relPath);
397
- if (!addedByDir.has(dir)) {
398
- deletedOnlyDirs.add(dir);
497
+ // Delete files that were managed by this package but are no longer in the package
498
+ for (const [relPath, owner] of existingManagedMap) {
499
+ if (owner.packageName !== packageName)
500
+ continue;
501
+ const stillPresent = extractedFiles.some((m) => m.path === relPath);
502
+ if (!stillPresent) {
503
+ const fullPath = node_path_1.default.join(config.outputDir, relPath);
504
+ if (node_fs_1.default.existsSync(fullPath)) {
505
+ if (!dryRun)
506
+ (0, utils_1.removeFile)(fullPath);
507
+ changes.deleted.push(relPath);
508
+ emit?.({ type: 'file-deleted', packageName, file: relPath });
509
+ }
399
510
  }
400
511
  }
401
- }
402
- if (!dryRun && !config.unmanaged) {
403
- // Write updated marker files
404
- // eslint-disable-next-line unicorn/no-keyword-prefix
405
- for (const [dir, newFiles] of addedByDir) {
406
- const markerDir = dir === '.' ? config.outputDir : node_path_1.default.join(config.outputDir, dir);
407
- (0, utils_1.ensureDir)(markerDir);
408
- const markerPath = node_path_1.default.join(markerDir, MARKER_FILE);
409
- // eslint-disable-next-line unicorn/no-null
512
+ if (!dryRun && !config.unmanaged) {
513
+ // Write a single root marker at outputDir with all managed file paths (relative to outputDir)
514
+ const rootMarkerPath = node_path_1.default.join(config.outputDir, MARKER_FILE);
410
515
  let existingFiles = [];
411
- if (node_fs_1.default.existsSync(markerPath)) {
412
- existingFiles = (0, utils_1.readCsvMarker)(markerPath);
516
+ if (node_fs_1.default.existsSync(rootMarkerPath)) {
517
+ existingFiles = (0, utils_1.readCsvMarker)(rootMarkerPath);
413
518
  }
414
- // Keep entries from other packages, replace entries from this package.
415
- // Also evict entries from other packages for any file force-claimed in this pass.
416
- const claimedInDir = forceClaimedByDir.get(dir);
519
+ // Keep entries from other packages, evict entries from force-claimed paths.
417
520
  const mergedFiles = [
418
- ...existingFiles.filter((m) => m.packageName !== packageName && !claimedInDir?.has(m.path)),
419
- // eslint-disable-next-line unicorn/no-keyword-prefix
420
- ...newFiles,
521
+ ...existingFiles.filter((m) => m.packageName !== packageName && !forceClaimedPaths.has(m.path)),
522
+ ...extractedFiles,
421
523
  ];
422
- (0, utils_1.writeCsvMarker)(markerPath, mergedFiles);
423
- }
424
- // Update marker files for directories where all managed files were removed (no new files added)
425
- for (const dir of deletedOnlyDirs) {
426
- const markerDir = dir === '.' ? config.outputDir : node_path_1.default.join(config.outputDir, dir);
427
- const markerPath = node_path_1.default.join(markerDir, MARKER_FILE);
428
- if (!node_fs_1.default.existsSync(markerPath))
429
- continue;
430
- try {
431
- const existingFiles = (0, utils_1.readCsvMarker)(markerPath);
432
- const mergedFiles = existingFiles.filter((m) => m.packageName !== packageName);
433
- if (mergedFiles.length === 0) {
434
- node_fs_1.default.chmodSync(markerPath, 0o644);
435
- node_fs_1.default.unlinkSync(markerPath);
436
- }
437
- else {
438
- (0, utils_1.writeCsvMarker)(markerPath, mergedFiles);
524
+ if (mergedFiles.length === 0) {
525
+ if (node_fs_1.default.existsSync(rootMarkerPath)) {
526
+ node_fs_1.default.chmodSync(rootMarkerPath, 0o644);
527
+ node_fs_1.default.unlinkSync(rootMarkerPath);
439
528
  }
440
529
  }
441
- catch {
442
- // Ignore unreadable marker files
530
+ else {
531
+ (0, utils_1.writeCsvMarker)(rootMarkerPath, mergedFiles);
532
+ }
533
+ cleanupEmptyMarkers(config.outputDir);
534
+ }
535
+ }
536
+ catch (error) {
537
+ // On error, delete all files that were created during this extraction run
538
+ if (!dryRun) {
539
+ for (const relPath of changes.added) {
540
+ const fullPath = node_path_1.default.join(config.outputDir, relPath);
541
+ if (node_fs_1.default.existsSync(fullPath)) {
542
+ try {
543
+ (0, utils_1.removeFile)(fullPath);
544
+ }
545
+ catch {
546
+ // ignore cleanup errors
547
+ }
548
+ }
443
549
  }
550
+ cleanupEmptyDirs(config.outputDir);
444
551
  }
445
- cleanupEmptyMarkers(config.outputDir);
552
+ throw error;
446
553
  }
447
554
  emit?.({ type: 'package-end', packageName, packageVersion: installedVersion });
448
555
  return changes;
@@ -631,7 +738,6 @@ async function purge(config) {
631
738
  for (const spec of config.packages) {
632
739
  const { name: packageName } = (0, utils_1.parsePackageSpec)(spec);
633
740
  const deleted = [];
634
- const deletedOnlyDirs = new Set();
635
741
  emit?.({ type: 'package-start', packageName, packageVersion: 'unknown' });
636
742
  const allManaged = loadManagedFilesMap(config.outputDir);
637
743
  for (const [relPath, owner] of allManaged) {
@@ -644,26 +750,21 @@ async function purge(config) {
644
750
  deleted.push(relPath);
645
751
  emit?.({ type: 'file-deleted', packageName, file: relPath });
646
752
  }
647
- const dir = node_path_1.default.dirname(relPath) === '.' ? '.' : node_path_1.default.dirname(relPath);
648
- deletedOnlyDirs.add(dir);
649
753
  }
650
754
  if (!dryRun) {
651
- // Update marker files: remove entries owned by this package.
652
- for (const dir of deletedOnlyDirs) {
653
- const markerDir = dir === '.' ? config.outputDir : node_path_1.default.join(config.outputDir, dir);
654
- const markerPath = node_path_1.default.join(markerDir, MARKER_FILE);
655
- if (!node_fs_1.default.existsSync(markerPath))
656
- continue;
755
+ // Update root marker: remove entries owned by this package.
756
+ const rootMarkerPath = node_path_1.default.join(config.outputDir, MARKER_FILE);
757
+ if (node_fs_1.default.existsSync(rootMarkerPath)) {
657
758
  // eslint-disable-next-line functional/no-try-statements
658
759
  try {
659
- const existingFiles = (0, utils_1.readCsvMarker)(markerPath);
760
+ const existingFiles = (0, utils_1.readCsvMarker)(rootMarkerPath);
660
761
  const mergedFiles = existingFiles.filter((m) => m.packageName !== packageName);
661
762
  if (mergedFiles.length === 0) {
662
- node_fs_1.default.chmodSync(markerPath, 0o644);
663
- node_fs_1.default.unlinkSync(markerPath);
763
+ node_fs_1.default.chmodSync(rootMarkerPath, 0o644);
764
+ node_fs_1.default.unlinkSync(rootMarkerPath);
664
765
  }
665
766
  else {
666
- (0, utils_1.writeCsvMarker)(markerPath, mergedFiles);
767
+ (0, utils_1.writeCsvMarker)(rootMarkerPath, mergedFiles);
667
768
  }
668
769
  }
669
770
  catch {