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