@patchstack/connect 0.3.29 → 0.3.31

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.
Files changed (40) hide show
  1. package/AGENT-INSTALL.md +42 -23
  2. package/README.md +40 -11
  3. package/dist/{chunk-MJOTFUDE.js → chunk-LLKP5EJS.js} +1 -1
  4. package/dist/chunk-LLKP5EJS.js.map +1 -0
  5. package/dist/cli.js +1265 -287
  6. package/dist/cli.js.map +1 -1
  7. package/dist/index.cjs +456 -111
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +76 -10
  10. package/dist/index.d.ts +76 -10
  11. package/dist/index.js +449 -104
  12. package/dist/index.js.map +1 -1
  13. package/dist/protect/templates/astro-middleware.ts +33 -13
  14. package/dist/protect/templates/demo-rules.json +2 -3
  15. package/dist/protect/templates/express-guard.cjs +26 -15
  16. package/dist/protect/templates/express-guard.js +26 -15
  17. package/dist/protect/templates/express-guard.ts +33 -16
  18. package/dist/protect/templates/fastify-plugin.cjs +33 -11
  19. package/dist/protect/templates/fastify-plugin.js +33 -11
  20. package/dist/protect/templates/fastify-plugin.ts +40 -12
  21. package/dist/protect/templates/generic-guard.cjs +28 -12
  22. package/dist/protect/templates/generic-guard.js +28 -13
  23. package/dist/protect/templates/generic-guard.ts +41 -20
  24. package/dist/protect/templates/guard.ts +47 -27
  25. package/dist/protect/templates/next-middleware.ts +29 -12
  26. package/dist/protect/templates/nuxt-middleware.ts +29 -12
  27. package/dist/protect/templates/rules.json +2 -2
  28. package/dist/protect/templates/sveltekit-hooks.ts +33 -13
  29. package/dist/protect.cjs +1204 -252
  30. package/dist/protect.cjs.map +1 -1
  31. package/dist/protect.d.ts +39 -11
  32. package/dist/protect.edge.js +792 -143
  33. package/dist/protect.edge.js.map +4 -4
  34. package/dist/protect.js +794 -145
  35. package/dist/protect.js.map +1 -1
  36. package/dist/{refresh-manifest-VRBE6RH6.js → refresh-manifest-KNZQBC4V.js} +387 -100
  37. package/dist/refresh-manifest-KNZQBC4V.js.map +1 -0
  38. package/package.json +5 -2
  39. package/dist/chunk-MJOTFUDE.js.map +0 -1
  40. package/dist/refresh-manifest-VRBE6RH6.js.map +0 -1
package/dist/index.js CHANGED
@@ -34,7 +34,7 @@ async function readDeclaredDependencyNames(cwd) {
34
34
  return [];
35
35
  }
36
36
  const names = /* @__PURE__ */ new Set();
37
- for (const group of ["dependencies", "devDependencies"]) {
37
+ for (const group of ["dependencies", "devDependencies", "optionalDependencies"]) {
38
38
  const deps = parsed[group];
39
39
  if (typeof deps !== "object" || deps === null) {
40
40
  continue;
@@ -52,14 +52,171 @@ function missingDependencies(declared, packages) {
52
52
  const present = new Set(packages.map((entry) => entry.name));
53
53
  return declared.filter((name) => !present.has(name));
54
54
  }
55
+ function disagreements(packages, other) {
56
+ const otherVersions = /* @__PURE__ */ new Map();
57
+ for (const entry of other) {
58
+ if (!otherVersions.has(entry.name)) otherVersions.set(entry.name, entry.version);
59
+ }
60
+ const seen = /* @__PURE__ */ new Set();
61
+ const conflicts = [];
62
+ for (const entry of packages) {
63
+ if (seen.has(entry.name)) continue;
64
+ seen.add(entry.name);
65
+ const otherVersion = otherVersions.get(entry.name);
66
+ if (otherVersion !== void 0 && otherVersion !== entry.version) {
67
+ conflicts.push({ name: entry.name, version: entry.version, otherVersion });
68
+ }
69
+ }
70
+ return conflicts;
71
+ }
55
72
 
56
- // src/parsers/npm.ts
73
+ // src/parsers/bun.ts
57
74
  import { readFile as readFile2 } from "fs/promises";
75
+
76
+ // src/parsers/report.ts
77
+ var MAX_SAMPLES = 3;
78
+ function newReport() {
79
+ return { unreadable: 0, samples: [] };
80
+ }
81
+ function recordUnreadable(report, sample) {
82
+ if (!report) return;
83
+ report.unreadable++;
84
+ if (report.samples.length < MAX_SAMPLES) report.samples.push(sample);
85
+ }
86
+ function unreadableWarning(filename, report) {
87
+ if (report.unreadable === 0) return null;
88
+ const shown = report.samples.join(", ");
89
+ const one = report.unreadable === 1;
90
+ return `${filename}: ${report.unreadable} entr${one ? "y was" : "ies were"} not in a form this scanner reads (${shown}${report.unreadable > report.samples.length ? ", \u2026" : ""}), so ${one ? "it is" : "they are"} absent from this manifest. Anything installed only through ${one ? "that entry" : "those entries"} is not being checked.`;
91
+ }
92
+
93
+ // src/parsers/bun.ts
94
+ async function parseBunLockfile(lockfilePath, report) {
95
+ let raw;
96
+ try {
97
+ raw = await readFile2(lockfilePath, "utf8");
98
+ } catch (cause) {
99
+ throw new PatchstackError(`Could not read lockfile at ${lockfilePath}`, "LOCKFILE_NOT_FOUND", cause);
100
+ }
101
+ let parsed;
102
+ try {
103
+ parsed = JSON.parse(stripTrailingCommas(raw));
104
+ } catch (cause) {
105
+ throw new PatchstackError(`Lockfile at ${lockfilePath} is not valid JSON`, "LOCKFILE_PARSE_ERROR", cause);
106
+ }
107
+ const packages = parsed?.packages;
108
+ if (typeof packages !== "object" || packages === null) {
109
+ throw new PatchstackError(`Lockfile at ${lockfilePath} has no "packages" entries`, "LOCKFILE_PARSE_ERROR");
110
+ }
111
+ const direct = directDependencyNames(parsed);
112
+ const entries = [];
113
+ let skipped = 0;
114
+ for (const [key, value] of Object.entries(packages)) {
115
+ const descriptor = Array.isArray(value) ? value[0] : value;
116
+ if (typeof descriptor !== "string") {
117
+ skipped++;
118
+ recordUnreadable(report, key);
119
+ continue;
120
+ }
121
+ const split = splitDescriptor(descriptor);
122
+ if (split === null) {
123
+ skipped++;
124
+ if (!isDeliberatelyExcluded(descriptor)) recordUnreadable(report, key);
125
+ continue;
126
+ }
127
+ entries.push({
128
+ name: split.name,
129
+ version: split.version,
130
+ ...direct.has(split.name) ? { direct: true } : {},
131
+ ...key === split.name ? {} : { path: key }
132
+ });
133
+ }
134
+ if (entries.length === 0) {
135
+ throw new PatchstackError(
136
+ `Lockfile at ${lockfilePath} lists no registry packages${skipped > 0 ? ` (${skipped} non-registry entr${skipped === 1 ? "y" : "ies"} skipped)` : ""}`,
137
+ "LOCKFILE_PARSE_ERROR"
138
+ );
139
+ }
140
+ return entries;
141
+ }
142
+ function splitDescriptor(descriptor) {
143
+ const at = descriptor.lastIndexOf("@");
144
+ if (at <= 0) return null;
145
+ const name = descriptor.slice(0, at);
146
+ const version = descriptor.slice(at + 1);
147
+ if (name === "" || version === "") return null;
148
+ if (NON_REGISTRY_VERSION.test(version)) return null;
149
+ return { name, version };
150
+ }
151
+ var NON_REGISTRY_VERSION = /^[a-z][a-z0-9+.-]*:|\//i;
152
+ function isDeliberatelyExcluded(descriptor) {
153
+ const at = descriptor.lastIndexOf("@");
154
+ if (at <= 0) return false;
155
+ return NON_REGISTRY_VERSION.test(descriptor.slice(at + 1));
156
+ }
157
+ function directDependencyNames(parsed) {
158
+ const names = /* @__PURE__ */ new Set();
159
+ const workspaces = parsed?.workspaces;
160
+ if (typeof workspaces !== "object" || workspaces === null) return names;
161
+ for (const workspace of Object.values(workspaces)) {
162
+ if (typeof workspace !== "object" || workspace === null) continue;
163
+ for (const group of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
164
+ const deps = workspace[group];
165
+ if (typeof deps !== "object" || deps === null) continue;
166
+ for (const name of Object.keys(deps)) names.add(name);
167
+ }
168
+ }
169
+ return names;
170
+ }
171
+ function stripTrailingCommas(source) {
172
+ const out = [];
173
+ let inString = false;
174
+ let pendingComma = -1;
175
+ for (let i = 0; i < source.length; i++) {
176
+ const ch = source[i];
177
+ if (inString) {
178
+ out.push(ch);
179
+ if (ch === "\\") {
180
+ const next = source[i + 1];
181
+ if (next !== void 0) {
182
+ out.push(next);
183
+ i++;
184
+ }
185
+ } else if (ch === '"') {
186
+ inString = false;
187
+ }
188
+ continue;
189
+ }
190
+ if (ch === '"') {
191
+ inString = true;
192
+ pendingComma = -1;
193
+ out.push(ch);
194
+ continue;
195
+ }
196
+ if (ch === ",") {
197
+ pendingComma = out.length;
198
+ out.push(ch);
199
+ continue;
200
+ }
201
+ if (ch === "}" || ch === "]") {
202
+ if (pendingComma !== -1) out[pendingComma] = " ";
203
+ pendingComma = -1;
204
+ out.push(ch);
205
+ continue;
206
+ }
207
+ if (!/\s/.test(ch)) pendingComma = -1;
208
+ out.push(ch);
209
+ }
210
+ return out.join("");
211
+ }
212
+
213
+ // src/parsers/npm.ts
214
+ import { readFile as readFile3 } from "fs/promises";
58
215
  import path2 from "path";
59
216
  async function parseNpmLockfile(lockfilePath) {
60
217
  let raw;
61
218
  try {
62
- raw = await readFile2(lockfilePath, "utf8");
219
+ raw = await readFile3(lockfilePath, "utf8");
63
220
  } catch (cause) {
64
221
  throw new PatchstackError(`Could not read lockfile at ${lockfilePath}`, "LOCKFILE_NOT_FOUND", cause);
65
222
  }
@@ -140,8 +297,9 @@ function isDirectV2(pkgPath) {
140
297
  }
141
298
 
142
299
  // src/parsers/node_modules.ts
143
- import { lstat, readFile as readFile3, readdir, stat } from "fs/promises";
300
+ import { lstat, readFile as readFile4, readdir, realpath, stat } from "fs/promises";
144
301
  import path3 from "path";
302
+ var PACKAGE_STORE_DIRS = /* @__PURE__ */ new Set([".bun", ".pnpm", ".store"]);
145
303
  async function walkNodeModules(cwd) {
146
304
  const root = path3.join(cwd, "node_modules");
147
305
  try {
@@ -162,96 +320,125 @@ async function walkNodeModules(cwd) {
162
320
  cause
163
321
  );
164
322
  }
165
- const entries = [];
166
- await walk(root, entries, 0);
167
- return entries;
323
+ const seen = /* @__PURE__ */ new Map();
324
+ try {
325
+ seen.set(await realpath(cwd), -1);
326
+ } catch {
327
+ }
328
+ return dedupeByShallowest(await walk(root, 0, seen, cwd));
329
+ }
330
+ function dedupeByShallowest(found) {
331
+ const best = /* @__PURE__ */ new Map();
332
+ for (const item of found) {
333
+ const current = best.get(item.realpath);
334
+ if (current === void 0 || item.depth < current.depth) best.set(item.realpath, item);
335
+ }
336
+ return [...best.values()].map(({ entry }) => entry);
168
337
  }
169
- async function walk(dir, acc, depth) {
338
+ var CONCURRENCY = 32;
339
+ async function walk(dir, depth, seen, root) {
170
340
  let names;
171
341
  try {
172
342
  names = await readdir(dir);
173
343
  } catch {
174
- return;
344
+ return [];
175
345
  }
176
- for (const name of names) {
177
- if (name.startsWith(".")) {
178
- continue;
179
- }
346
+ const perEntry = await mapLimit(names, async (name) => {
347
+ if (name.startsWith(".") && !PACKAGE_STORE_DIRS.has(name)) return [];
180
348
  const fullPath = path3.join(dir, name);
181
- if (!await isPlainDirectory(fullPath)) {
182
- continue;
183
- }
349
+ const resolved = await claim(fullPath, seen, depth);
350
+ if (resolved === null) return [];
351
+ if (PACKAGE_STORE_DIRS.has(name)) return walk(fullPath, depth, seen, root);
184
352
  if (name.startsWith("@")) {
185
353
  let subNames;
186
354
  try {
187
355
  subNames = await readdir(fullPath);
188
356
  } catch {
189
- continue;
357
+ return [];
190
358
  }
191
- for (const sub of subNames) {
192
- if (sub.startsWith(".")) {
193
- continue;
194
- }
359
+ const scoped = await mapLimit(subNames, async (sub) => {
360
+ if (sub.startsWith(".")) return [];
195
361
  const scopedDir = path3.join(fullPath, sub);
196
- if (!await isPlainDirectory(scopedDir)) {
197
- continue;
198
- }
199
- await readPackage(scopedDir, depth, acc);
200
- await walkNested(scopedDir, acc, depth);
201
- }
202
- continue;
362
+ const scopedReal = await claim(scopedDir, seen, depth);
363
+ if (scopedReal === null) return [];
364
+ return packageAndNested(scopedDir, scopedReal, depth, seen, root);
365
+ });
366
+ return scoped.flat();
203
367
  }
204
- await readPackage(fullPath, depth, acc);
205
- await walkNested(fullPath, acc, depth);
206
- }
368
+ return packageAndNested(fullPath, resolved, depth, seen, root);
369
+ });
370
+ return perEntry.flat();
371
+ }
372
+ async function packageAndNested(pkgDir, resolved, depth, seen, root) {
373
+ const [own, nested] = await Promise.all([readPackage(pkgDir, depth, root), walkNested(pkgDir, depth, seen, root)]);
374
+ return own === null ? nested : [{ entry: own, realpath: resolved, depth }, ...nested];
375
+ }
376
+ async function mapLimit(items, fn) {
377
+ const results = new Array(items.length);
378
+ let next = 0;
379
+ const workers = Array.from({ length: Math.min(CONCURRENCY, items.length) }, async () => {
380
+ while (next < items.length) {
381
+ const index = next++;
382
+ results[index] = await fn(items[index]);
383
+ }
384
+ });
385
+ await Promise.all(workers);
386
+ return results;
387
+ }
388
+ function installLocation(root, pkgDir, impl = path3) {
389
+ return impl.relative(root, pkgDir).split(impl.sep).join("/");
207
390
  }
208
- async function readPackage(pkgDir, depth, acc) {
391
+ async function readPackage(pkgDir, depth, root) {
209
392
  let raw;
210
393
  try {
211
- raw = await readFile3(path3.join(pkgDir, "package.json"), "utf8");
394
+ raw = await readFile4(path3.join(pkgDir, "package.json"), "utf8");
212
395
  } catch {
213
- return;
396
+ return null;
214
397
  }
215
398
  let parsed;
216
399
  try {
217
400
  parsed = JSON.parse(raw);
218
401
  } catch {
219
- return;
402
+ return null;
220
403
  }
221
404
  if (typeof parsed.name !== "string" || parsed.name.length === 0) {
222
- return;
405
+ return null;
223
406
  }
224
407
  if (typeof parsed.version !== "string" || parsed.version.length === 0) {
225
- return;
408
+ return null;
226
409
  }
227
- acc.push({
228
- name: parsed.name,
229
- version: parsed.version,
230
- direct: depth === 0
231
- });
410
+ return { name: parsed.name, version: parsed.version, path: installLocation(root, pkgDir), direct: depth === 0 };
232
411
  }
233
- async function walkNested(pkgDir, acc, depth) {
412
+ async function walkNested(pkgDir, depth, seen, root) {
234
413
  const nested = path3.join(pkgDir, "node_modules");
235
- if (!await isPlainDirectory(nested)) {
236
- return;
414
+ if (await claim(nested, seen, depth + 1) === null) {
415
+ return [];
237
416
  }
238
- await walk(nested, acc, depth + 1);
417
+ return walk(nested, depth + 1, seen, root);
239
418
  }
240
- async function isPlainDirectory(dir) {
419
+ async function claim(dir, seen, depth) {
420
+ let resolved;
241
421
  try {
242
422
  const info = await lstat(dir);
243
- return info.isDirectory() && !info.isSymbolicLink();
423
+ if (!info.isDirectory() && !info.isSymbolicLink()) return null;
424
+ resolved = await realpath(dir);
425
+ const target = await stat(resolved);
426
+ if (!target.isDirectory()) return null;
244
427
  } catch {
245
- return false;
428
+ return null;
246
429
  }
430
+ const claimed = seen.get(resolved);
431
+ if (claimed !== void 0 && claimed <= depth) return null;
432
+ seen.set(resolved, depth);
433
+ return resolved;
247
434
  }
248
435
 
249
436
  // src/parsers/pnpm.ts
250
- import { readFile as readFile4 } from "fs/promises";
251
- async function parsePnpmLockfile(lockfilePath) {
437
+ import { readFile as readFile5 } from "fs/promises";
438
+ async function parsePnpmLockfile(lockfilePath, report) {
252
439
  let raw;
253
440
  try {
254
- raw = await readFile4(lockfilePath, "utf8");
441
+ raw = await readFile5(lockfilePath, "utf8");
255
442
  } catch (cause) {
256
443
  throw new PatchstackError(
257
444
  `Could not read lockfile at ${lockfilePath}`,
@@ -272,6 +459,7 @@ async function parsePnpmLockfile(lockfilePath) {
272
459
  for (const key of packageKeys) {
273
460
  const parsed = parsePackageKey(key);
274
461
  if (parsed === null) {
462
+ recordUnreadable(report, key);
275
463
  continue;
276
464
  }
277
465
  entries.push({
@@ -499,12 +687,12 @@ function extractLeafName(trimmed) {
499
687
  }
500
688
 
501
689
  // src/parsers/yarn.ts
502
- import { readFile as readFile5 } from "fs/promises";
690
+ import { readFile as readFile6 } from "fs/promises";
503
691
  import path4 from "path";
504
- async function parseYarnLockfile(lockfilePath) {
692
+ async function parseYarnLockfile(lockfilePath, report) {
505
693
  let raw;
506
694
  try {
507
- raw = await readFile5(lockfilePath, "utf8");
695
+ raw = await readFile6(lockfilePath, "utf8");
508
696
  } catch (cause) {
509
697
  throw new PatchstackError(
510
698
  `Could not read lockfile at ${lockfilePath}`,
@@ -512,7 +700,7 @@ async function parseYarnLockfile(lockfilePath) {
512
700
  cause
513
701
  );
514
702
  }
515
- const blocks = parseBlocks(raw);
703
+ const blocks = parseBlocks(raw, report);
516
704
  if (blocks.length === 0) {
517
705
  throw new PatchstackError(
518
706
  `Lockfile at ${lockfilePath} contains no package entries`,
@@ -541,13 +729,19 @@ async function parseYarnLockfile(lockfilePath) {
541
729
  }
542
730
  return entries;
543
731
  }
544
- function parseBlocks(raw) {
732
+ var NON_PACKAGE_KEYS = /* @__PURE__ */ new Set(["__metadata"]);
733
+ function parseBlocks(raw, report) {
545
734
  const lines = raw.split(/\r?\n/);
546
735
  const blocks = [];
547
736
  let current = null;
548
737
  const finalize = () => {
549
- if (current !== null && current.version.length > 0 && current.names.size > 0) {
738
+ if (current === null) {
739
+ return;
740
+ }
741
+ if (current.version.length > 0 && current.names.size > 0) {
550
742
  blocks.push(current);
743
+ } else if (current.unreadable) {
744
+ recordUnreadable(report, current.key);
551
745
  }
552
746
  current = null;
553
747
  };
@@ -564,13 +758,13 @@ function parseBlocks(raw) {
564
758
  }
565
759
  const keyLine = trimmed.slice(0, -1);
566
760
  const names = /* @__PURE__ */ new Set();
761
+ let unreadable = false;
567
762
  for (const spec of splitDescriptors(keyLine)) {
568
- const name = extractName(spec);
569
- if (name !== null) {
570
- names.add(name);
571
- }
763
+ const classified = classifyDescriptor(spec);
764
+ if (classified.name !== void 0) names.add(classified.name);
765
+ else if (!classified.excluded) unreadable = true;
572
766
  }
573
- current = { names, version: "" };
767
+ current = { names, version: "", key: keyLine, unreadable: unreadable && names.size === 0 };
574
768
  continue;
575
769
  }
576
770
  if (current === null) {
@@ -625,20 +819,37 @@ function splitDescriptors(keyLine) {
625
819
  }
626
820
  return parts;
627
821
  }
628
- function extractName(rawSpec) {
822
+ var NON_REGISTRY_PROTOCOLS = /^(?:workspace|patch|virtual|portal|file|link|exec|git|github|https?|ssh):/i;
823
+ function classifyDescriptor(rawSpec) {
629
824
  let s = rawSpec.trim();
630
825
  if (s.length === 0) {
631
- return null;
826
+ return { excluded: true };
632
827
  }
633
828
  if (s.startsWith('"') && s.endsWith('"') || s.startsWith("'") && s.endsWith("'")) {
634
829
  s = s.slice(1, -1);
635
830
  }
636
- const atIdx = s.lastIndexOf("@");
831
+ if (NON_PACKAGE_KEYS.has(s)) {
832
+ return { excluded: true };
833
+ }
834
+ const atIdx = s.indexOf("@", 1);
637
835
  if (atIdx <= 0) {
638
- return null;
836
+ return {};
639
837
  }
640
838
  const name = s.slice(0, atIdx);
641
- return name.length > 0 ? name : null;
839
+ const range = s.slice(atIdx + 1);
840
+ if (name.length === 0) {
841
+ return {};
842
+ }
843
+ if (NON_REGISTRY_PROTOCOLS.test(range)) {
844
+ return { excluded: true };
845
+ }
846
+ const alias = /^npm:(.+)$/i.exec(range);
847
+ if (alias !== null) {
848
+ const target = alias[1] ?? "";
849
+ const targetAt = target.indexOf("@", 1);
850
+ return { name: targetAt > 0 ? target.slice(0, targetAt) : name };
851
+ }
852
+ return { name };
642
853
  }
643
854
  function parseVersionField(content) {
644
855
  if (!content.startsWith("version")) {
@@ -663,7 +874,7 @@ async function readDirectDepNames(cwd) {
663
874
  const names = /* @__PURE__ */ new Set();
664
875
  let raw;
665
876
  try {
666
- raw = await readFile5(path4.join(cwd, "package.json"), "utf8");
877
+ raw = await readFile6(path4.join(cwd, "package.json"), "utf8");
667
878
  } catch {
668
879
  return names;
669
880
  }
@@ -692,7 +903,10 @@ async function readDirectDepNames(cwd) {
692
903
  // src/parsers/index.ts
693
904
  var LOCKFILE_CANDIDATES = [
694
905
  { filename: "package-lock.json", strategy: "npm-lockfile" },
695
- { filename: "bun.lock", strategy: "node-modules-walk" },
906
+ // Read directly, not walked. Bun's isolated layout puts packages under a dot-directory and makes the
907
+ // top level symlinks into it, so a `node_modules` walk finds nothing there — and isolated is the default
908
+ // for new workspaces.
909
+ { filename: "bun.lock", strategy: "bun-lockfile" },
696
910
  { filename: "bun.lockb", strategy: "node-modules-walk" },
697
911
  { filename: "pnpm-lock.yaml", strategy: "pnpm-lockfile" },
698
912
  { filename: "yarn.lock", strategy: "yarn-lockfile" }
@@ -727,34 +941,58 @@ async function scanLockfile(cwd) {
727
941
  const declared = await readDeclaredDependencyNames(cwd);
728
942
  const warnings = [];
729
943
  let firstParsed = null;
730
- let walkTried = false;
944
+ let walked = null;
945
+ let walkWasCandidate = false;
946
+ const parsedSources = [];
731
947
  for (const candidate of candidates) {
732
948
  let packages;
949
+ const report = newReport();
733
950
  try {
734
- packages = await runStrategy(candidate, cwd);
951
+ packages = await runStrategy(candidate, cwd, report);
735
952
  } catch {
736
953
  continue;
737
954
  }
738
- walkTried ||= candidate.strategy === "node-modules-walk";
955
+ const unreadable = unreadableWarning(candidate.filename, report);
956
+ if (unreadable !== null) warnings.push(unreadable);
957
+ if (candidate.strategy === "node-modules-walk") {
958
+ walked = packages;
959
+ walkWasCandidate = true;
960
+ }
739
961
  firstParsed ??= { packages, filename: candidate.filename };
740
- const missing = missingDependencies(declared, packages);
741
- if (missing.length === 0) {
742
- return manifestWith(packages, warnings);
962
+ parsedSources.push({ packages, filename: candidate.filename });
963
+ }
964
+ for (const source of parsedSources) {
965
+ const missing = missingDependencies(declared, source.packages);
966
+ if (missing.length > 0) {
967
+ warnings.push(staleWarning(source.filename, missing));
968
+ continue;
743
969
  }
744
- warnings.push(staleWarning(candidate.filename, missing));
970
+ const conflicts = otherCompleteSources(parsedSources, source, declared).flatMap(
971
+ (other) => disagreements(source.packages, other.packages).map((conflict) => ({ other: other.filename, conflict }))
972
+ );
973
+ if (conflicts.length === 0) {
974
+ return manifestWith(source.packages, warnings);
975
+ }
976
+ warnings.push(conflictWarning(source.filename, conflicts));
977
+ break;
745
978
  }
746
- if (!walkTried) {
979
+ if (walked === null) {
747
980
  try {
748
- const packages = await walkNodeModules(cwd);
749
- const missing = missingDependencies(declared, packages);
750
- if (missing.length > 0) {
751
- warnings.push(staleWarning("node_modules/", missing));
752
- }
753
- warnings.push("Scanned node_modules/ instead. Delete the stale lockfile to silence this warning.");
754
- return manifestWith(packages, warnings);
981
+ walked = await walkNodeModules(cwd);
755
982
  } catch {
983
+ walked = null;
756
984
  }
757
985
  }
986
+ if (walked !== null) {
987
+ const missing = missingDependencies(declared, walked);
988
+ if (missing.length > 0) {
989
+ warnings.push(staleWarning("node_modules/", missing));
990
+ }
991
+ warnings.push(
992
+ walkWasCandidate ? "Reporting node_modules/, which is what the build loads." : "Scanned node_modules/ instead. Delete the stale lockfile to silence this warning."
993
+ );
994
+ return manifestWith(walked, warnings);
995
+ }
758
996
  if (firstParsed === null) {
759
997
  const expected = LOCKFILE_CANDIDATES.map((candidate) => candidate.filename).join(", ");
760
998
  throw new PatchstackError(
@@ -770,6 +1008,15 @@ async function scanLockfile(cwd) {
770
1008
  function manifestWith(packages, warnings) {
771
1009
  return warnings.length > 0 ? { ecosystem: "npm", packages, warnings } : { ecosystem: "npm", packages };
772
1010
  }
1011
+ function otherCompleteSources(all, current, declared) {
1012
+ return all.filter(
1013
+ (source) => source.filename !== current.filename && missingDependencies(declared, source.packages).length === 0
1014
+ );
1015
+ }
1016
+ function conflictWarning(filename, conflicts) {
1017
+ const shown = conflicts.slice(0, 3).map(({ other, conflict }) => `${conflict.name} ${conflict.version} vs ${conflict.otherVersion} in ${other}`).join("; ");
1018
+ return `${filename} and another lockfile disagree about installed versions (${shown}${conflicts.length > 3 ? `, +${conflicts.length - 3} more` : ""}). Scanned node_modules/ instead. Remove whichever lockfile your package manager does not maintain.`;
1019
+ }
773
1020
  function staleWarning(source, missing) {
774
1021
  const sample = missing.slice(0, 3).join(", ");
775
1022
  const suffix = missing.length > 3 ? `, +${missing.length - 3} more` : "";
@@ -784,14 +1031,18 @@ async function presentLockfiles(cwd) {
784
1031
  );
785
1032
  return probed.filter((candidate) => candidate.present).map(({ filename, strategy, filePath }) => ({ ecosystem: "npm", filename, strategy, filePath }));
786
1033
  }
787
- async function runStrategy(detected, cwd) {
1034
+ async function runStrategy(detected, cwd, report) {
788
1035
  switch (detected.strategy) {
789
1036
  case "npm-lockfile":
790
1037
  return parseNpmLockfile(detected.filePath);
1038
+ case "bun-lockfile":
1039
+ return parseBunLockfile(detected.filePath, report);
1040
+ // The hand-written scanners. Each meets entries it was not written for, and each is asked to say how
1041
+ // many it dropped, because a manifest short a package reads exactly like a project without it.
791
1042
  case "pnpm-lockfile":
792
- return parsePnpmLockfile(detected.filePath);
1043
+ return parsePnpmLockfile(detected.filePath, report);
793
1044
  case "yarn-lockfile":
794
- return parseYarnLockfile(detected.filePath);
1045
+ return parseYarnLockfile(detected.filePath, report);
795
1046
  case "node-modules-walk":
796
1047
  return walkNodeModules(cwd);
797
1048
  }
@@ -806,21 +1057,33 @@ async function exists(filePath) {
806
1057
  }
807
1058
 
808
1059
  // src/normalize.ts
809
- function buildWirePayload(manifest) {
1060
+ function buildWirePayload(manifest, options = {}) {
1061
+ const withPaths = options.installPaths === true;
810
1062
  const seen = /* @__PURE__ */ new Map();
811
1063
  const wirePackages = [];
1064
+ const byIdentity = /* @__PURE__ */ new Map();
1065
+ let entriesWithoutPath = 0;
812
1066
  for (const entry of manifest.packages) {
1067
+ const identity = `${entry.name}@${entry.version}`;
1068
+ const location = withPaths && entry.path !== void 0 && entry.path !== "" ? entry.path : void 0;
1069
+ if (location === void 0) entriesWithoutPath++;
1070
+ const existing = byIdentity.get(identity);
1071
+ if (existing) {
1072
+ if (location !== void 0) addPath(existing, location);
1073
+ continue;
1074
+ }
813
1075
  const versions = seen.get(entry.name);
814
1076
  if (versions) {
815
- if (versions.has(entry.version)) {
816
- continue;
817
- }
818
1077
  versions.add(entry.version);
819
1078
  } else {
820
1079
  seen.set(entry.name, /* @__PURE__ */ new Set([entry.version]));
821
1080
  }
822
- wirePackages.push({ name: entry.name, version: entry.version });
1081
+ const wire = { name: entry.name, version: entry.version };
1082
+ if (location !== void 0) addPath(wire, location);
1083
+ byIdentity.set(identity, wire);
1084
+ wirePackages.push(wire);
823
1085
  }
1086
+ for (const wire of wirePackages) wire.paths?.sort();
824
1087
  wirePackages.sort((a, b) => {
825
1088
  if (a.name === b.name) {
826
1089
  return compareVersions(a.version, b.version);
@@ -834,7 +1097,18 @@ function buildWirePayload(manifest) {
834
1097
  }
835
1098
  }
836
1099
  return {
837
- payload: { ecosystem: manifest.ecosystem, packages: wirePackages },
1100
+ payload: {
1101
+ ecosystem: manifest.ecosystem,
1102
+ packages: wirePackages,
1103
+ // Every entry had a location, so a missing path anywhere means the package is not installed there.
1104
+ // One entry without one forfeits that for the whole payload: a consumer reading a short `paths` has
1105
+ // no way to tell which entry was the incomplete one.
1106
+ //
1107
+ // No separate check for "locations were not requested": with them off every entry counts as without
1108
+ // one, so this is already false — which is the default, and the safe direction. An explicit
1109
+ // `withPaths &&` here was a guard no test could distinguish from its absence.
1110
+ installPathsComplete: entriesWithoutPath === 0 && manifest.packages.length > 0
1111
+ },
838
1112
  stats: {
839
1113
  uniqueNames: seen.size,
840
1114
  duplicateNames,
@@ -842,6 +1116,10 @@ function buildWirePayload(manifest) {
842
1116
  }
843
1117
  };
844
1118
  }
1119
+ function addPath(wire, installPath) {
1120
+ if (wire.paths === void 0) wire.paths = [installPath];
1121
+ else if (!wire.paths.includes(installPath)) wire.paths.push(installPath);
1122
+ }
845
1123
  function compareVersions(a, b) {
846
1124
  if (a === b) {
847
1125
  return 0;
@@ -1084,12 +1362,14 @@ function isTimeoutError(cause) {
1084
1362
  }
1085
1363
 
1086
1364
  // src/config.ts
1087
- import { readFile as readFile6, writeFile } from "fs/promises";
1365
+ import { readFile as readFile7, writeFile, chmod } from "fs/promises";
1088
1366
  import path6 from "path";
1089
1367
  var CONFIG_FILENAME = ".patchstackrc.json";
1368
+ var SECRET_FILENAME = ".patchstackrc.local.json";
1090
1369
  var DEFAULT_ENVIRONMENT = "production";
1091
1370
  async function resolveConfig(options) {
1092
1371
  const fromFile = await readConfigFile(options.cwd);
1372
+ const fromSecretFile = await readSecretFile(options.cwd);
1093
1373
  const fromEnv = readEnv();
1094
1374
  const siteUuid = options.cliSiteUuid ?? fromEnv.siteUuid ?? fromFile.siteUuid ?? null;
1095
1375
  const endpoint = options.cliEndpoint ?? fromEnv.endpoint ?? fromFile.endpoint ?? DEFAULT_ENDPOINT;
@@ -1114,8 +1394,8 @@ async function resolveConfig(options) {
1114
1394
  "CONFIG_MISSING"
1115
1395
  );
1116
1396
  }
1117
- const apiKeyRaw = fromEnv.apiKey ?? fromFile.apiKey ?? null;
1118
- const pulseAuthRaw = fromEnv.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;
1397
+ const apiKeyRaw = fromEnv.apiKey ?? fromSecretFile.apiKey ?? fromFile.apiKey ?? null;
1398
+ const pulseAuthRaw = fromEnv.pulseAuth ?? fromSecretFile.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;
1119
1399
  return {
1120
1400
  siteUuid: siteUuid === null || siteUuid.length === 0 ? null : siteUuid,
1121
1401
  apiKey: apiKeyRaw === null || apiKeyRaw.length === 0 ? null : apiKeyRaw,
@@ -1132,23 +1412,88 @@ async function writeConfigFile(cwd, config) {
1132
1412
  await writeFile(target, content, "utf8");
1133
1413
  return target;
1134
1414
  }
1415
+ async function writeSecretFile(cwd, secrets) {
1416
+ const target = path6.join(cwd, SECRET_FILENAME);
1417
+ await writeFile(target, JSON.stringify(secrets, null, 2) + "\n", { encoding: "utf8", mode: 384 });
1418
+ try {
1419
+ await chmod(target, 384);
1420
+ } catch {
1421
+ }
1422
+ const ignore = await ensureIgnored(cwd, SECRET_FILENAME);
1423
+ return { path: target, ...ignore };
1424
+ }
1425
+ async function ensureIgnored(cwd, entry) {
1426
+ const target = path6.join(cwd, ".gitignore");
1427
+ let existing = "";
1428
+ try {
1429
+ existing = await readFile7(target, "utf8");
1430
+ } catch (err) {
1431
+ const code = err.code;
1432
+ if (code !== "ENOENT") {
1433
+ return { ignored: false, reason: `.gitignore could not be read (${code ?? "unknown error"})` };
1434
+ }
1435
+ }
1436
+ if (ignoresEntry(existing, entry)) return { ignored: true };
1437
+ const separator = existing === "" || existing.endsWith("\n") ? "" : "\n";
1438
+ const block = `${separator}
1439
+ # Patchstack credential \u2014 never commit this
1440
+ ${entry}
1441
+ `;
1442
+ try {
1443
+ await writeFile(target, existing + block, "utf8");
1444
+ } catch (err) {
1445
+ const code = err.code;
1446
+ return { ignored: false, reason: `.gitignore could not be written (${code ?? "unknown error"})` };
1447
+ }
1448
+ try {
1449
+ const written = await readFile7(target, "utf8");
1450
+ if (ignoresEntry(written, entry)) return { ignored: true };
1451
+ return { ignored: false, reason: ".gitignore does not cover the file after writing it" };
1452
+ } catch (err) {
1453
+ const code = err.code;
1454
+ return { ignored: false, reason: `.gitignore could not be read back (${code ?? "unknown error"})` };
1455
+ }
1456
+ }
1457
+ function ignoresEntry(contents, entry) {
1458
+ let ignored = false;
1459
+ for (const raw of contents.split("\n")) {
1460
+ const line = raw.trim();
1461
+ if (line === "" || line.startsWith("#")) continue;
1462
+ const negated = line.startsWith("!");
1463
+ const pattern = negated ? line.slice(1).trim() : line;
1464
+ if (pattern === entry || pattern === `/${entry}`) ignored = !negated;
1465
+ }
1466
+ return ignored;
1467
+ }
1135
1468
  async function persistSiteUuid(cwd, siteUuid) {
1136
1469
  const existing = await readConfigFile(cwd);
1137
1470
  return writeConfigFile(cwd, { ...existing, siteUuid });
1138
1471
  }
1139
1472
  async function persistApiKey(cwd, apiKey) {
1140
- const { pulseAuth: _dropped, ...existing } = await readConfigFile(cwd);
1141
- return writeConfigFile(cwd, { ...existing, apiKey });
1473
+ const { apiKey: _movedKey, pulseAuth: _movedPulse, ...publicConfig } = await readConfigFile(cwd);
1474
+ const existingSecrets = await readSecretFile(cwd);
1475
+ const target = await writeSecretFile(cwd, { ...existingSecrets, apiKey, pulseAuth: void 0 });
1476
+ if (_movedKey !== void 0 || _movedPulse !== void 0) {
1477
+ await writeConfigFile(cwd, publicConfig);
1478
+ }
1479
+ return target;
1142
1480
  }
1143
1481
  async function persistPulseAuth(cwd, pulseAuth) {
1144
- const existing = await readConfigFile(cwd);
1145
- return writeConfigFile(cwd, { ...existing, pulseAuth });
1482
+ const existing = await readSecretFile(cwd);
1483
+ return writeSecretFile(cwd, { ...existing, pulseAuth });
1484
+ }
1485
+ async function readSecretFile(cwd) {
1486
+ const file = await readJsonFile(cwd, SECRET_FILENAME);
1487
+ return { apiKey: file.apiKey, pulseAuth: file.pulseAuth };
1146
1488
  }
1147
1489
  async function readConfigFile(cwd) {
1148
- const target = path6.join(cwd, CONFIG_FILENAME);
1490
+ return readJsonFile(cwd, CONFIG_FILENAME);
1491
+ }
1492
+ async function readJsonFile(cwd, filename) {
1493
+ const target = path6.join(cwd, filename);
1149
1494
  let raw;
1150
1495
  try {
1151
- raw = await readFile6(target, "utf8");
1496
+ raw = await readFile7(target, "utf8");
1152
1497
  } catch (err) {
1153
1498
  if (err.code === "ENOENT") {
1154
1499
  return {};
@@ -1350,7 +1695,7 @@ async function scanAndReport(options = {}) {
1350
1695
  const cwd = options.cwd ?? process.cwd();
1351
1696
  const config = options.config ?? await resolveConfig({ cwd });
1352
1697
  const manifest = await scanLockfile(cwd);
1353
- const { payload, stats } = buildWirePayload(manifest);
1698
+ const { payload, stats } = buildWirePayload(manifest, { installPaths: options.installPaths === true });
1354
1699
  const response = await postManifest(config, payload);
1355
1700
  if (config.siteUuid === null && response.uuid !== void 0 && response.uuid.length > 0) {
1356
1701
  await persistSiteUuid(cwd, response.uuid);