@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.cjs CHANGED
@@ -58,7 +58,7 @@ __export(src_exports, {
58
58
  module.exports = __toCommonJS(src_exports);
59
59
 
60
60
  // src/parsers/index.ts
61
- var import_promises6 = require("fs/promises");
61
+ var import_promises7 = require("fs/promises");
62
62
  var import_node_path5 = __toESM(require("path"), 1);
63
63
 
64
64
  // src/types.ts
@@ -93,7 +93,7 @@ async function readDeclaredDependencyNames(cwd) {
93
93
  return [];
94
94
  }
95
95
  const names = /* @__PURE__ */ new Set();
96
- for (const group of ["dependencies", "devDependencies"]) {
96
+ for (const group of ["dependencies", "devDependencies", "optionalDependencies"]) {
97
97
  const deps = parsed[group];
98
98
  if (typeof deps !== "object" || deps === null) {
99
99
  continue;
@@ -111,14 +111,171 @@ function missingDependencies(declared, packages) {
111
111
  const present = new Set(packages.map((entry) => entry.name));
112
112
  return declared.filter((name) => !present.has(name));
113
113
  }
114
+ function disagreements(packages, other) {
115
+ const otherVersions = /* @__PURE__ */ new Map();
116
+ for (const entry of other) {
117
+ if (!otherVersions.has(entry.name)) otherVersions.set(entry.name, entry.version);
118
+ }
119
+ const seen = /* @__PURE__ */ new Set();
120
+ const conflicts = [];
121
+ for (const entry of packages) {
122
+ if (seen.has(entry.name)) continue;
123
+ seen.add(entry.name);
124
+ const otherVersion = otherVersions.get(entry.name);
125
+ if (otherVersion !== void 0 && otherVersion !== entry.version) {
126
+ conflicts.push({ name: entry.name, version: entry.version, otherVersion });
127
+ }
128
+ }
129
+ return conflicts;
130
+ }
114
131
 
115
- // src/parsers/npm.ts
132
+ // src/parsers/bun.ts
116
133
  var import_promises2 = require("fs/promises");
134
+
135
+ // src/parsers/report.ts
136
+ var MAX_SAMPLES = 3;
137
+ function newReport() {
138
+ return { unreadable: 0, samples: [] };
139
+ }
140
+ function recordUnreadable(report, sample) {
141
+ if (!report) return;
142
+ report.unreadable++;
143
+ if (report.samples.length < MAX_SAMPLES) report.samples.push(sample);
144
+ }
145
+ function unreadableWarning(filename, report) {
146
+ if (report.unreadable === 0) return null;
147
+ const shown = report.samples.join(", ");
148
+ const one = report.unreadable === 1;
149
+ 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.`;
150
+ }
151
+
152
+ // src/parsers/bun.ts
153
+ async function parseBunLockfile(lockfilePath, report) {
154
+ let raw;
155
+ try {
156
+ raw = await (0, import_promises2.readFile)(lockfilePath, "utf8");
157
+ } catch (cause) {
158
+ throw new PatchstackError(`Could not read lockfile at ${lockfilePath}`, "LOCKFILE_NOT_FOUND", cause);
159
+ }
160
+ let parsed;
161
+ try {
162
+ parsed = JSON.parse(stripTrailingCommas(raw));
163
+ } catch (cause) {
164
+ throw new PatchstackError(`Lockfile at ${lockfilePath} is not valid JSON`, "LOCKFILE_PARSE_ERROR", cause);
165
+ }
166
+ const packages = parsed?.packages;
167
+ if (typeof packages !== "object" || packages === null) {
168
+ throw new PatchstackError(`Lockfile at ${lockfilePath} has no "packages" entries`, "LOCKFILE_PARSE_ERROR");
169
+ }
170
+ const direct = directDependencyNames(parsed);
171
+ const entries = [];
172
+ let skipped = 0;
173
+ for (const [key, value] of Object.entries(packages)) {
174
+ const descriptor = Array.isArray(value) ? value[0] : value;
175
+ if (typeof descriptor !== "string") {
176
+ skipped++;
177
+ recordUnreadable(report, key);
178
+ continue;
179
+ }
180
+ const split = splitDescriptor(descriptor);
181
+ if (split === null) {
182
+ skipped++;
183
+ if (!isDeliberatelyExcluded(descriptor)) recordUnreadable(report, key);
184
+ continue;
185
+ }
186
+ entries.push({
187
+ name: split.name,
188
+ version: split.version,
189
+ ...direct.has(split.name) ? { direct: true } : {},
190
+ ...key === split.name ? {} : { path: key }
191
+ });
192
+ }
193
+ if (entries.length === 0) {
194
+ throw new PatchstackError(
195
+ `Lockfile at ${lockfilePath} lists no registry packages${skipped > 0 ? ` (${skipped} non-registry entr${skipped === 1 ? "y" : "ies"} skipped)` : ""}`,
196
+ "LOCKFILE_PARSE_ERROR"
197
+ );
198
+ }
199
+ return entries;
200
+ }
201
+ function splitDescriptor(descriptor) {
202
+ const at = descriptor.lastIndexOf("@");
203
+ if (at <= 0) return null;
204
+ const name = descriptor.slice(0, at);
205
+ const version = descriptor.slice(at + 1);
206
+ if (name === "" || version === "") return null;
207
+ if (NON_REGISTRY_VERSION.test(version)) return null;
208
+ return { name, version };
209
+ }
210
+ var NON_REGISTRY_VERSION = /^[a-z][a-z0-9+.-]*:|\//i;
211
+ function isDeliberatelyExcluded(descriptor) {
212
+ const at = descriptor.lastIndexOf("@");
213
+ if (at <= 0) return false;
214
+ return NON_REGISTRY_VERSION.test(descriptor.slice(at + 1));
215
+ }
216
+ function directDependencyNames(parsed) {
217
+ const names = /* @__PURE__ */ new Set();
218
+ const workspaces = parsed?.workspaces;
219
+ if (typeof workspaces !== "object" || workspaces === null) return names;
220
+ for (const workspace of Object.values(workspaces)) {
221
+ if (typeof workspace !== "object" || workspace === null) continue;
222
+ for (const group of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) {
223
+ const deps = workspace[group];
224
+ if (typeof deps !== "object" || deps === null) continue;
225
+ for (const name of Object.keys(deps)) names.add(name);
226
+ }
227
+ }
228
+ return names;
229
+ }
230
+ function stripTrailingCommas(source) {
231
+ const out = [];
232
+ let inString = false;
233
+ let pendingComma = -1;
234
+ for (let i = 0; i < source.length; i++) {
235
+ const ch = source[i];
236
+ if (inString) {
237
+ out.push(ch);
238
+ if (ch === "\\") {
239
+ const next = source[i + 1];
240
+ if (next !== void 0) {
241
+ out.push(next);
242
+ i++;
243
+ }
244
+ } else if (ch === '"') {
245
+ inString = false;
246
+ }
247
+ continue;
248
+ }
249
+ if (ch === '"') {
250
+ inString = true;
251
+ pendingComma = -1;
252
+ out.push(ch);
253
+ continue;
254
+ }
255
+ if (ch === ",") {
256
+ pendingComma = out.length;
257
+ out.push(ch);
258
+ continue;
259
+ }
260
+ if (ch === "}" || ch === "]") {
261
+ if (pendingComma !== -1) out[pendingComma] = " ";
262
+ pendingComma = -1;
263
+ out.push(ch);
264
+ continue;
265
+ }
266
+ if (!/\s/.test(ch)) pendingComma = -1;
267
+ out.push(ch);
268
+ }
269
+ return out.join("");
270
+ }
271
+
272
+ // src/parsers/npm.ts
273
+ var import_promises3 = require("fs/promises");
117
274
  var import_node_path2 = __toESM(require("path"), 1);
118
275
  async function parseNpmLockfile(lockfilePath) {
119
276
  let raw;
120
277
  try {
121
- raw = await (0, import_promises2.readFile)(lockfilePath, "utf8");
278
+ raw = await (0, import_promises3.readFile)(lockfilePath, "utf8");
122
279
  } catch (cause) {
123
280
  throw new PatchstackError(`Could not read lockfile at ${lockfilePath}`, "LOCKFILE_NOT_FOUND", cause);
124
281
  }
@@ -199,12 +356,13 @@ function isDirectV2(pkgPath) {
199
356
  }
200
357
 
201
358
  // src/parsers/node_modules.ts
202
- var import_promises3 = require("fs/promises");
359
+ var import_promises4 = require("fs/promises");
203
360
  var import_node_path3 = __toESM(require("path"), 1);
361
+ var PACKAGE_STORE_DIRS = /* @__PURE__ */ new Set([".bun", ".pnpm", ".store"]);
204
362
  async function walkNodeModules(cwd) {
205
363
  const root = import_node_path3.default.join(cwd, "node_modules");
206
364
  try {
207
- const info = await (0, import_promises3.stat)(root);
365
+ const info = await (0, import_promises4.stat)(root);
208
366
  if (!info.isDirectory()) {
209
367
  throw new PatchstackError(
210
368
  `${root} exists but is not a directory.`,
@@ -221,96 +379,125 @@ async function walkNodeModules(cwd) {
221
379
  cause
222
380
  );
223
381
  }
224
- const entries = [];
225
- await walk(root, entries, 0);
226
- return entries;
382
+ const seen = /* @__PURE__ */ new Map();
383
+ try {
384
+ seen.set(await (0, import_promises4.realpath)(cwd), -1);
385
+ } catch {
386
+ }
387
+ return dedupeByShallowest(await walk(root, 0, seen, cwd));
227
388
  }
228
- async function walk(dir, acc, depth) {
389
+ function dedupeByShallowest(found) {
390
+ const best = /* @__PURE__ */ new Map();
391
+ for (const item of found) {
392
+ const current = best.get(item.realpath);
393
+ if (current === void 0 || item.depth < current.depth) best.set(item.realpath, item);
394
+ }
395
+ return [...best.values()].map(({ entry }) => entry);
396
+ }
397
+ var CONCURRENCY = 32;
398
+ async function walk(dir, depth, seen, root) {
229
399
  let names;
230
400
  try {
231
- names = await (0, import_promises3.readdir)(dir);
401
+ names = await (0, import_promises4.readdir)(dir);
232
402
  } catch {
233
- return;
403
+ return [];
234
404
  }
235
- for (const name of names) {
236
- if (name.startsWith(".")) {
237
- continue;
238
- }
405
+ const perEntry = await mapLimit(names, async (name) => {
406
+ if (name.startsWith(".") && !PACKAGE_STORE_DIRS.has(name)) return [];
239
407
  const fullPath = import_node_path3.default.join(dir, name);
240
- if (!await isPlainDirectory(fullPath)) {
241
- continue;
242
- }
408
+ const resolved = await claim(fullPath, seen, depth);
409
+ if (resolved === null) return [];
410
+ if (PACKAGE_STORE_DIRS.has(name)) return walk(fullPath, depth, seen, root);
243
411
  if (name.startsWith("@")) {
244
412
  let subNames;
245
413
  try {
246
- subNames = await (0, import_promises3.readdir)(fullPath);
414
+ subNames = await (0, import_promises4.readdir)(fullPath);
247
415
  } catch {
248
- continue;
416
+ return [];
249
417
  }
250
- for (const sub of subNames) {
251
- if (sub.startsWith(".")) {
252
- continue;
253
- }
418
+ const scoped = await mapLimit(subNames, async (sub) => {
419
+ if (sub.startsWith(".")) return [];
254
420
  const scopedDir = import_node_path3.default.join(fullPath, sub);
255
- if (!await isPlainDirectory(scopedDir)) {
256
- continue;
257
- }
258
- await readPackage(scopedDir, depth, acc);
259
- await walkNested(scopedDir, acc, depth);
260
- }
261
- continue;
421
+ const scopedReal = await claim(scopedDir, seen, depth);
422
+ if (scopedReal === null) return [];
423
+ return packageAndNested(scopedDir, scopedReal, depth, seen, root);
424
+ });
425
+ return scoped.flat();
262
426
  }
263
- await readPackage(fullPath, depth, acc);
264
- await walkNested(fullPath, acc, depth);
265
- }
427
+ return packageAndNested(fullPath, resolved, depth, seen, root);
428
+ });
429
+ return perEntry.flat();
430
+ }
431
+ async function packageAndNested(pkgDir, resolved, depth, seen, root) {
432
+ const [own, nested] = await Promise.all([readPackage(pkgDir, depth, root), walkNested(pkgDir, depth, seen, root)]);
433
+ return own === null ? nested : [{ entry: own, realpath: resolved, depth }, ...nested];
434
+ }
435
+ async function mapLimit(items, fn) {
436
+ const results = new Array(items.length);
437
+ let next = 0;
438
+ const workers = Array.from({ length: Math.min(CONCURRENCY, items.length) }, async () => {
439
+ while (next < items.length) {
440
+ const index = next++;
441
+ results[index] = await fn(items[index]);
442
+ }
443
+ });
444
+ await Promise.all(workers);
445
+ return results;
266
446
  }
267
- async function readPackage(pkgDir, depth, acc) {
447
+ function installLocation(root, pkgDir, impl = import_node_path3.default) {
448
+ return impl.relative(root, pkgDir).split(impl.sep).join("/");
449
+ }
450
+ async function readPackage(pkgDir, depth, root) {
268
451
  let raw;
269
452
  try {
270
- raw = await (0, import_promises3.readFile)(import_node_path3.default.join(pkgDir, "package.json"), "utf8");
453
+ raw = await (0, import_promises4.readFile)(import_node_path3.default.join(pkgDir, "package.json"), "utf8");
271
454
  } catch {
272
- return;
455
+ return null;
273
456
  }
274
457
  let parsed;
275
458
  try {
276
459
  parsed = JSON.parse(raw);
277
460
  } catch {
278
- return;
461
+ return null;
279
462
  }
280
463
  if (typeof parsed.name !== "string" || parsed.name.length === 0) {
281
- return;
464
+ return null;
282
465
  }
283
466
  if (typeof parsed.version !== "string" || parsed.version.length === 0) {
284
- return;
467
+ return null;
285
468
  }
286
- acc.push({
287
- name: parsed.name,
288
- version: parsed.version,
289
- direct: depth === 0
290
- });
469
+ return { name: parsed.name, version: parsed.version, path: installLocation(root, pkgDir), direct: depth === 0 };
291
470
  }
292
- async function walkNested(pkgDir, acc, depth) {
471
+ async function walkNested(pkgDir, depth, seen, root) {
293
472
  const nested = import_node_path3.default.join(pkgDir, "node_modules");
294
- if (!await isPlainDirectory(nested)) {
295
- return;
473
+ if (await claim(nested, seen, depth + 1) === null) {
474
+ return [];
296
475
  }
297
- await walk(nested, acc, depth + 1);
476
+ return walk(nested, depth + 1, seen, root);
298
477
  }
299
- async function isPlainDirectory(dir) {
478
+ async function claim(dir, seen, depth) {
479
+ let resolved;
300
480
  try {
301
- const info = await (0, import_promises3.lstat)(dir);
302
- return info.isDirectory() && !info.isSymbolicLink();
481
+ const info = await (0, import_promises4.lstat)(dir);
482
+ if (!info.isDirectory() && !info.isSymbolicLink()) return null;
483
+ resolved = await (0, import_promises4.realpath)(dir);
484
+ const target = await (0, import_promises4.stat)(resolved);
485
+ if (!target.isDirectory()) return null;
303
486
  } catch {
304
- return false;
487
+ return null;
305
488
  }
489
+ const claimed = seen.get(resolved);
490
+ if (claimed !== void 0 && claimed <= depth) return null;
491
+ seen.set(resolved, depth);
492
+ return resolved;
306
493
  }
307
494
 
308
495
  // src/parsers/pnpm.ts
309
- var import_promises4 = require("fs/promises");
310
- async function parsePnpmLockfile(lockfilePath) {
496
+ var import_promises5 = require("fs/promises");
497
+ async function parsePnpmLockfile(lockfilePath, report) {
311
498
  let raw;
312
499
  try {
313
- raw = await (0, import_promises4.readFile)(lockfilePath, "utf8");
500
+ raw = await (0, import_promises5.readFile)(lockfilePath, "utf8");
314
501
  } catch (cause) {
315
502
  throw new PatchstackError(
316
503
  `Could not read lockfile at ${lockfilePath}`,
@@ -331,6 +518,7 @@ async function parsePnpmLockfile(lockfilePath) {
331
518
  for (const key of packageKeys) {
332
519
  const parsed = parsePackageKey(key);
333
520
  if (parsed === null) {
521
+ recordUnreadable(report, key);
334
522
  continue;
335
523
  }
336
524
  entries.push({
@@ -558,12 +746,12 @@ function extractLeafName(trimmed) {
558
746
  }
559
747
 
560
748
  // src/parsers/yarn.ts
561
- var import_promises5 = require("fs/promises");
749
+ var import_promises6 = require("fs/promises");
562
750
  var import_node_path4 = __toESM(require("path"), 1);
563
- async function parseYarnLockfile(lockfilePath) {
751
+ async function parseYarnLockfile(lockfilePath, report) {
564
752
  let raw;
565
753
  try {
566
- raw = await (0, import_promises5.readFile)(lockfilePath, "utf8");
754
+ raw = await (0, import_promises6.readFile)(lockfilePath, "utf8");
567
755
  } catch (cause) {
568
756
  throw new PatchstackError(
569
757
  `Could not read lockfile at ${lockfilePath}`,
@@ -571,7 +759,7 @@ async function parseYarnLockfile(lockfilePath) {
571
759
  cause
572
760
  );
573
761
  }
574
- const blocks = parseBlocks(raw);
762
+ const blocks = parseBlocks(raw, report);
575
763
  if (blocks.length === 0) {
576
764
  throw new PatchstackError(
577
765
  `Lockfile at ${lockfilePath} contains no package entries`,
@@ -600,13 +788,19 @@ async function parseYarnLockfile(lockfilePath) {
600
788
  }
601
789
  return entries;
602
790
  }
603
- function parseBlocks(raw) {
791
+ var NON_PACKAGE_KEYS = /* @__PURE__ */ new Set(["__metadata"]);
792
+ function parseBlocks(raw, report) {
604
793
  const lines = raw.split(/\r?\n/);
605
794
  const blocks = [];
606
795
  let current = null;
607
796
  const finalize = () => {
608
- if (current !== null && current.version.length > 0 && current.names.size > 0) {
797
+ if (current === null) {
798
+ return;
799
+ }
800
+ if (current.version.length > 0 && current.names.size > 0) {
609
801
  blocks.push(current);
802
+ } else if (current.unreadable) {
803
+ recordUnreadable(report, current.key);
610
804
  }
611
805
  current = null;
612
806
  };
@@ -623,13 +817,13 @@ function parseBlocks(raw) {
623
817
  }
624
818
  const keyLine = trimmed.slice(0, -1);
625
819
  const names = /* @__PURE__ */ new Set();
820
+ let unreadable = false;
626
821
  for (const spec of splitDescriptors(keyLine)) {
627
- const name = extractName(spec);
628
- if (name !== null) {
629
- names.add(name);
630
- }
822
+ const classified = classifyDescriptor(spec);
823
+ if (classified.name !== void 0) names.add(classified.name);
824
+ else if (!classified.excluded) unreadable = true;
631
825
  }
632
- current = { names, version: "" };
826
+ current = { names, version: "", key: keyLine, unreadable: unreadable && names.size === 0 };
633
827
  continue;
634
828
  }
635
829
  if (current === null) {
@@ -684,20 +878,37 @@ function splitDescriptors(keyLine) {
684
878
  }
685
879
  return parts;
686
880
  }
687
- function extractName(rawSpec) {
881
+ var NON_REGISTRY_PROTOCOLS = /^(?:workspace|patch|virtual|portal|file|link|exec|git|github|https?|ssh):/i;
882
+ function classifyDescriptor(rawSpec) {
688
883
  let s = rawSpec.trim();
689
884
  if (s.length === 0) {
690
- return null;
885
+ return { excluded: true };
691
886
  }
692
887
  if (s.startsWith('"') && s.endsWith('"') || s.startsWith("'") && s.endsWith("'")) {
693
888
  s = s.slice(1, -1);
694
889
  }
695
- const atIdx = s.lastIndexOf("@");
890
+ if (NON_PACKAGE_KEYS.has(s)) {
891
+ return { excluded: true };
892
+ }
893
+ const atIdx = s.indexOf("@", 1);
696
894
  if (atIdx <= 0) {
697
- return null;
895
+ return {};
698
896
  }
699
897
  const name = s.slice(0, atIdx);
700
- return name.length > 0 ? name : null;
898
+ const range = s.slice(atIdx + 1);
899
+ if (name.length === 0) {
900
+ return {};
901
+ }
902
+ if (NON_REGISTRY_PROTOCOLS.test(range)) {
903
+ return { excluded: true };
904
+ }
905
+ const alias = /^npm:(.+)$/i.exec(range);
906
+ if (alias !== null) {
907
+ const target = alias[1] ?? "";
908
+ const targetAt = target.indexOf("@", 1);
909
+ return { name: targetAt > 0 ? target.slice(0, targetAt) : name };
910
+ }
911
+ return { name };
701
912
  }
702
913
  function parseVersionField(content) {
703
914
  if (!content.startsWith("version")) {
@@ -722,7 +933,7 @@ async function readDirectDepNames(cwd) {
722
933
  const names = /* @__PURE__ */ new Set();
723
934
  let raw;
724
935
  try {
725
- raw = await (0, import_promises5.readFile)(import_node_path4.default.join(cwd, "package.json"), "utf8");
936
+ raw = await (0, import_promises6.readFile)(import_node_path4.default.join(cwd, "package.json"), "utf8");
726
937
  } catch {
727
938
  return names;
728
939
  }
@@ -751,7 +962,10 @@ async function readDirectDepNames(cwd) {
751
962
  // src/parsers/index.ts
752
963
  var LOCKFILE_CANDIDATES = [
753
964
  { filename: "package-lock.json", strategy: "npm-lockfile" },
754
- { filename: "bun.lock", strategy: "node-modules-walk" },
965
+ // Read directly, not walked. Bun's isolated layout puts packages under a dot-directory and makes the
966
+ // top level symlinks into it, so a `node_modules` walk finds nothing there — and isolated is the default
967
+ // for new workspaces.
968
+ { filename: "bun.lock", strategy: "bun-lockfile" },
755
969
  { filename: "bun.lockb", strategy: "node-modules-walk" },
756
970
  { filename: "pnpm-lock.yaml", strategy: "pnpm-lockfile" },
757
971
  { filename: "yarn.lock", strategy: "yarn-lockfile" }
@@ -786,33 +1000,57 @@ async function scanLockfile(cwd) {
786
1000
  const declared = await readDeclaredDependencyNames(cwd);
787
1001
  const warnings = [];
788
1002
  let firstParsed = null;
789
- let walkTried = false;
1003
+ let walked = null;
1004
+ let walkWasCandidate = false;
1005
+ const parsedSources = [];
790
1006
  for (const candidate of candidates) {
791
1007
  let packages;
1008
+ const report = newReport();
792
1009
  try {
793
- packages = await runStrategy(candidate, cwd);
1010
+ packages = await runStrategy(candidate, cwd, report);
794
1011
  } catch {
795
1012
  continue;
796
1013
  }
797
- walkTried ||= candidate.strategy === "node-modules-walk";
1014
+ const unreadable = unreadableWarning(candidate.filename, report);
1015
+ if (unreadable !== null) warnings.push(unreadable);
1016
+ if (candidate.strategy === "node-modules-walk") {
1017
+ walked = packages;
1018
+ walkWasCandidate = true;
1019
+ }
798
1020
  firstParsed ??= { packages, filename: candidate.filename };
799
- const missing = missingDependencies(declared, packages);
800
- if (missing.length === 0) {
801
- return manifestWith(packages, warnings);
1021
+ parsedSources.push({ packages, filename: candidate.filename });
1022
+ }
1023
+ for (const source of parsedSources) {
1024
+ const missing = missingDependencies(declared, source.packages);
1025
+ if (missing.length > 0) {
1026
+ warnings.push(staleWarning(source.filename, missing));
1027
+ continue;
802
1028
  }
803
- warnings.push(staleWarning(candidate.filename, missing));
1029
+ const conflicts = otherCompleteSources(parsedSources, source, declared).flatMap(
1030
+ (other) => disagreements(source.packages, other.packages).map((conflict) => ({ other: other.filename, conflict }))
1031
+ );
1032
+ if (conflicts.length === 0) {
1033
+ return manifestWith(source.packages, warnings);
1034
+ }
1035
+ warnings.push(conflictWarning(source.filename, conflicts));
1036
+ break;
804
1037
  }
805
- if (!walkTried) {
1038
+ if (walked === null) {
806
1039
  try {
807
- const packages = await walkNodeModules(cwd);
808
- const missing = missingDependencies(declared, packages);
809
- if (missing.length > 0) {
810
- warnings.push(staleWarning("node_modules/", missing));
811
- }
812
- warnings.push("Scanned node_modules/ instead. Delete the stale lockfile to silence this warning.");
813
- return manifestWith(packages, warnings);
1040
+ walked = await walkNodeModules(cwd);
814
1041
  } catch {
1042
+ walked = null;
1043
+ }
1044
+ }
1045
+ if (walked !== null) {
1046
+ const missing = missingDependencies(declared, walked);
1047
+ if (missing.length > 0) {
1048
+ warnings.push(staleWarning("node_modules/", missing));
815
1049
  }
1050
+ warnings.push(
1051
+ walkWasCandidate ? "Reporting node_modules/, which is what the build loads." : "Scanned node_modules/ instead. Delete the stale lockfile to silence this warning."
1052
+ );
1053
+ return manifestWith(walked, warnings);
816
1054
  }
817
1055
  if (firstParsed === null) {
818
1056
  const expected = LOCKFILE_CANDIDATES.map((candidate) => candidate.filename).join(", ");
@@ -829,6 +1067,15 @@ async function scanLockfile(cwd) {
829
1067
  function manifestWith(packages, warnings) {
830
1068
  return warnings.length > 0 ? { ecosystem: "npm", packages, warnings } : { ecosystem: "npm", packages };
831
1069
  }
1070
+ function otherCompleteSources(all, current, declared) {
1071
+ return all.filter(
1072
+ (source) => source.filename !== current.filename && missingDependencies(declared, source.packages).length === 0
1073
+ );
1074
+ }
1075
+ function conflictWarning(filename, conflicts) {
1076
+ const shown = conflicts.slice(0, 3).map(({ other, conflict }) => `${conflict.name} ${conflict.version} vs ${conflict.otherVersion} in ${other}`).join("; ");
1077
+ 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.`;
1078
+ }
832
1079
  function staleWarning(source, missing) {
833
1080
  const sample = missing.slice(0, 3).join(", ");
834
1081
  const suffix = missing.length > 3 ? `, +${missing.length - 3} more` : "";
@@ -843,21 +1090,25 @@ async function presentLockfiles(cwd) {
843
1090
  );
844
1091
  return probed.filter((candidate) => candidate.present).map(({ filename, strategy, filePath }) => ({ ecosystem: "npm", filename, strategy, filePath }));
845
1092
  }
846
- async function runStrategy(detected, cwd) {
1093
+ async function runStrategy(detected, cwd, report) {
847
1094
  switch (detected.strategy) {
848
1095
  case "npm-lockfile":
849
1096
  return parseNpmLockfile(detected.filePath);
1097
+ case "bun-lockfile":
1098
+ return parseBunLockfile(detected.filePath, report);
1099
+ // The hand-written scanners. Each meets entries it was not written for, and each is asked to say how
1100
+ // many it dropped, because a manifest short a package reads exactly like a project without it.
850
1101
  case "pnpm-lockfile":
851
- return parsePnpmLockfile(detected.filePath);
1102
+ return parsePnpmLockfile(detected.filePath, report);
852
1103
  case "yarn-lockfile":
853
- return parseYarnLockfile(detected.filePath);
1104
+ return parseYarnLockfile(detected.filePath, report);
854
1105
  case "node-modules-walk":
855
1106
  return walkNodeModules(cwd);
856
1107
  }
857
1108
  }
858
1109
  async function exists(filePath) {
859
1110
  try {
860
- await (0, import_promises6.access)(filePath);
1111
+ await (0, import_promises7.access)(filePath);
861
1112
  return true;
862
1113
  } catch {
863
1114
  return false;
@@ -865,21 +1116,33 @@ async function exists(filePath) {
865
1116
  }
866
1117
 
867
1118
  // src/normalize.ts
868
- function buildWirePayload(manifest) {
1119
+ function buildWirePayload(manifest, options = {}) {
1120
+ const withPaths = options.installPaths === true;
869
1121
  const seen = /* @__PURE__ */ new Map();
870
1122
  const wirePackages = [];
1123
+ const byIdentity = /* @__PURE__ */ new Map();
1124
+ let entriesWithoutPath = 0;
871
1125
  for (const entry of manifest.packages) {
1126
+ const identity = `${entry.name}@${entry.version}`;
1127
+ const location = withPaths && entry.path !== void 0 && entry.path !== "" ? entry.path : void 0;
1128
+ if (location === void 0) entriesWithoutPath++;
1129
+ const existing = byIdentity.get(identity);
1130
+ if (existing) {
1131
+ if (location !== void 0) addPath(existing, location);
1132
+ continue;
1133
+ }
872
1134
  const versions = seen.get(entry.name);
873
1135
  if (versions) {
874
- if (versions.has(entry.version)) {
875
- continue;
876
- }
877
1136
  versions.add(entry.version);
878
1137
  } else {
879
1138
  seen.set(entry.name, /* @__PURE__ */ new Set([entry.version]));
880
1139
  }
881
- wirePackages.push({ name: entry.name, version: entry.version });
1140
+ const wire = { name: entry.name, version: entry.version };
1141
+ if (location !== void 0) addPath(wire, location);
1142
+ byIdentity.set(identity, wire);
1143
+ wirePackages.push(wire);
882
1144
  }
1145
+ for (const wire of wirePackages) wire.paths?.sort();
883
1146
  wirePackages.sort((a, b) => {
884
1147
  if (a.name === b.name) {
885
1148
  return compareVersions(a.version, b.version);
@@ -893,7 +1156,18 @@ function buildWirePayload(manifest) {
893
1156
  }
894
1157
  }
895
1158
  return {
896
- payload: { ecosystem: manifest.ecosystem, packages: wirePackages },
1159
+ payload: {
1160
+ ecosystem: manifest.ecosystem,
1161
+ packages: wirePackages,
1162
+ // Every entry had a location, so a missing path anywhere means the package is not installed there.
1163
+ // One entry without one forfeits that for the whole payload: a consumer reading a short `paths` has
1164
+ // no way to tell which entry was the incomplete one.
1165
+ //
1166
+ // No separate check for "locations were not requested": with them off every entry counts as without
1167
+ // one, so this is already false — which is the default, and the safe direction. An explicit
1168
+ // `withPaths &&` here was a guard no test could distinguish from its absence.
1169
+ installPathsComplete: entriesWithoutPath === 0 && manifest.packages.length > 0
1170
+ },
897
1171
  stats: {
898
1172
  uniqueNames: seen.size,
899
1173
  duplicateNames,
@@ -901,6 +1175,10 @@ function buildWirePayload(manifest) {
901
1175
  }
902
1176
  };
903
1177
  }
1178
+ function addPath(wire, installPath) {
1179
+ if (wire.paths === void 0) wire.paths = [installPath];
1180
+ else if (!wire.paths.includes(installPath)) wire.paths.push(installPath);
1181
+ }
904
1182
  function compareVersions(a, b) {
905
1183
  if (a === b) {
906
1184
  return 0;
@@ -1143,12 +1421,14 @@ function isTimeoutError(cause) {
1143
1421
  }
1144
1422
 
1145
1423
  // src/config.ts
1146
- var import_promises7 = require("fs/promises");
1424
+ var import_promises8 = require("fs/promises");
1147
1425
  var import_node_path6 = __toESM(require("path"), 1);
1148
1426
  var CONFIG_FILENAME = ".patchstackrc.json";
1427
+ var SECRET_FILENAME = ".patchstackrc.local.json";
1149
1428
  var DEFAULT_ENVIRONMENT = "production";
1150
1429
  async function resolveConfig(options) {
1151
1430
  const fromFile = await readConfigFile(options.cwd);
1431
+ const fromSecretFile = await readSecretFile(options.cwd);
1152
1432
  const fromEnv = readEnv();
1153
1433
  const siteUuid = options.cliSiteUuid ?? fromEnv.siteUuid ?? fromFile.siteUuid ?? null;
1154
1434
  const endpoint = options.cliEndpoint ?? fromEnv.endpoint ?? fromFile.endpoint ?? DEFAULT_ENDPOINT;
@@ -1173,8 +1453,8 @@ async function resolveConfig(options) {
1173
1453
  "CONFIG_MISSING"
1174
1454
  );
1175
1455
  }
1176
- const apiKeyRaw = fromEnv.apiKey ?? fromFile.apiKey ?? null;
1177
- const pulseAuthRaw = fromEnv.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;
1456
+ const apiKeyRaw = fromEnv.apiKey ?? fromSecretFile.apiKey ?? fromFile.apiKey ?? null;
1457
+ const pulseAuthRaw = fromEnv.pulseAuth ?? fromSecretFile.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;
1178
1458
  return {
1179
1459
  siteUuid: siteUuid === null || siteUuid.length === 0 ? null : siteUuid,
1180
1460
  apiKey: apiKeyRaw === null || apiKeyRaw.length === 0 ? null : apiKeyRaw,
@@ -1188,26 +1468,91 @@ async function resolveConfig(options) {
1188
1468
  async function writeConfigFile(cwd, config) {
1189
1469
  const target = import_node_path6.default.join(cwd, CONFIG_FILENAME);
1190
1470
  const content = JSON.stringify(config, null, 2) + "\n";
1191
- await (0, import_promises7.writeFile)(target, content, "utf8");
1471
+ await (0, import_promises8.writeFile)(target, content, "utf8");
1192
1472
  return target;
1193
1473
  }
1474
+ async function writeSecretFile(cwd, secrets) {
1475
+ const target = import_node_path6.default.join(cwd, SECRET_FILENAME);
1476
+ await (0, import_promises8.writeFile)(target, JSON.stringify(secrets, null, 2) + "\n", { encoding: "utf8", mode: 384 });
1477
+ try {
1478
+ await (0, import_promises8.chmod)(target, 384);
1479
+ } catch {
1480
+ }
1481
+ const ignore = await ensureIgnored(cwd, SECRET_FILENAME);
1482
+ return { path: target, ...ignore };
1483
+ }
1484
+ async function ensureIgnored(cwd, entry) {
1485
+ const target = import_node_path6.default.join(cwd, ".gitignore");
1486
+ let existing = "";
1487
+ try {
1488
+ existing = await (0, import_promises8.readFile)(target, "utf8");
1489
+ } catch (err) {
1490
+ const code = err.code;
1491
+ if (code !== "ENOENT") {
1492
+ return { ignored: false, reason: `.gitignore could not be read (${code ?? "unknown error"})` };
1493
+ }
1494
+ }
1495
+ if (ignoresEntry(existing, entry)) return { ignored: true };
1496
+ const separator = existing === "" || existing.endsWith("\n") ? "" : "\n";
1497
+ const block = `${separator}
1498
+ # Patchstack credential \u2014 never commit this
1499
+ ${entry}
1500
+ `;
1501
+ try {
1502
+ await (0, import_promises8.writeFile)(target, existing + block, "utf8");
1503
+ } catch (err) {
1504
+ const code = err.code;
1505
+ return { ignored: false, reason: `.gitignore could not be written (${code ?? "unknown error"})` };
1506
+ }
1507
+ try {
1508
+ const written = await (0, import_promises8.readFile)(target, "utf8");
1509
+ if (ignoresEntry(written, entry)) return { ignored: true };
1510
+ return { ignored: false, reason: ".gitignore does not cover the file after writing it" };
1511
+ } catch (err) {
1512
+ const code = err.code;
1513
+ return { ignored: false, reason: `.gitignore could not be read back (${code ?? "unknown error"})` };
1514
+ }
1515
+ }
1516
+ function ignoresEntry(contents, entry) {
1517
+ let ignored = false;
1518
+ for (const raw of contents.split("\n")) {
1519
+ const line = raw.trim();
1520
+ if (line === "" || line.startsWith("#")) continue;
1521
+ const negated = line.startsWith("!");
1522
+ const pattern = negated ? line.slice(1).trim() : line;
1523
+ if (pattern === entry || pattern === `/${entry}`) ignored = !negated;
1524
+ }
1525
+ return ignored;
1526
+ }
1194
1527
  async function persistSiteUuid(cwd, siteUuid) {
1195
1528
  const existing = await readConfigFile(cwd);
1196
1529
  return writeConfigFile(cwd, { ...existing, siteUuid });
1197
1530
  }
1198
1531
  async function persistApiKey(cwd, apiKey) {
1199
- const { pulseAuth: _dropped, ...existing } = await readConfigFile(cwd);
1200
- return writeConfigFile(cwd, { ...existing, apiKey });
1532
+ const { apiKey: _movedKey, pulseAuth: _movedPulse, ...publicConfig } = await readConfigFile(cwd);
1533
+ const existingSecrets = await readSecretFile(cwd);
1534
+ const target = await writeSecretFile(cwd, { ...existingSecrets, apiKey, pulseAuth: void 0 });
1535
+ if (_movedKey !== void 0 || _movedPulse !== void 0) {
1536
+ await writeConfigFile(cwd, publicConfig);
1537
+ }
1538
+ return target;
1201
1539
  }
1202
1540
  async function persistPulseAuth(cwd, pulseAuth) {
1203
- const existing = await readConfigFile(cwd);
1204
- return writeConfigFile(cwd, { ...existing, pulseAuth });
1541
+ const existing = await readSecretFile(cwd);
1542
+ return writeSecretFile(cwd, { ...existing, pulseAuth });
1543
+ }
1544
+ async function readSecretFile(cwd) {
1545
+ const file = await readJsonFile(cwd, SECRET_FILENAME);
1546
+ return { apiKey: file.apiKey, pulseAuth: file.pulseAuth };
1205
1547
  }
1206
1548
  async function readConfigFile(cwd) {
1207
- const target = import_node_path6.default.join(cwd, CONFIG_FILENAME);
1549
+ return readJsonFile(cwd, CONFIG_FILENAME);
1550
+ }
1551
+ async function readJsonFile(cwd, filename) {
1552
+ const target = import_node_path6.default.join(cwd, filename);
1208
1553
  let raw;
1209
1554
  try {
1210
- raw = await (0, import_promises7.readFile)(target, "utf8");
1555
+ raw = await (0, import_promises8.readFile)(target, "utf8");
1211
1556
  } catch (err) {
1212
1557
  if (err.code === "ENOENT") {
1213
1558
  return {};
@@ -1409,7 +1754,7 @@ async function scanAndReport(options = {}) {
1409
1754
  const cwd = options.cwd ?? process.cwd();
1410
1755
  const config = options.config ?? await resolveConfig({ cwd });
1411
1756
  const manifest = await scanLockfile(cwd);
1412
- const { payload, stats } = buildWirePayload(manifest);
1757
+ const { payload, stats } = buildWirePayload(manifest, { installPaths: options.installPaths === true });
1413
1758
  const response = await postManifest(config, payload);
1414
1759
  if (config.siteUuid === null && response.uuid !== void 0 && response.uuid.length > 0) {
1415
1760
  await persistSiteUuid(cwd, response.uuid);