@patchstack/connect 0.3.28 → 0.3.30

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 +37 -21
  2. package/README.md +24 -10
  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 +1267 -224
  6. package/dist/cli.js.map +1 -1
  7. package/dist/index.cjs +419 -104
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +28 -9
  10. package/dist/index.d.ts +28 -9
  11. package/dist/index.js +412 -97
  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 +1039 -235
  30. package/dist/protect.cjs.map +1 -1
  31. package/dist/protect.d.ts +39 -11
  32. package/dist/protect.edge.js +663 -132
  33. package/dist/protect.edge.js.map +4 -4
  34. package/dist/protect.js +665 -134
  35. package/dist/protect.js.map +1 -1
  36. package/dist/{refresh-manifest-VRBE6RH6.js → refresh-manifest-ZSP76JWQ.js} +351 -94
  37. package/dist/refresh-manifest-ZSP76JWQ.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,122 @@ 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));
388
+ }
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);
227
396
  }
228
- async function walk(dir, acc, depth) {
397
+ var CONCURRENCY = 32;
398
+ async function walk(dir, depth, seen) {
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);
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);
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);
428
+ });
429
+ return perEntry.flat();
430
+ }
431
+ async function packageAndNested(pkgDir, resolved, depth, seen) {
432
+ const [own, nested] = await Promise.all([readPackage(pkgDir, depth), walkNested(pkgDir, depth, seen)]);
433
+ return own === null ? nested : [{ entry: own, realpath: resolved, depth }, ...nested];
266
434
  }
267
- async function readPackage(pkgDir, depth, acc) {
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;
446
+ }
447
+ async function readPackage(pkgDir, depth) {
268
448
  let raw;
269
449
  try {
270
- raw = await (0, import_promises3.readFile)(import_node_path3.default.join(pkgDir, "package.json"), "utf8");
450
+ raw = await (0, import_promises4.readFile)(import_node_path3.default.join(pkgDir, "package.json"), "utf8");
271
451
  } catch {
272
- return;
452
+ return null;
273
453
  }
274
454
  let parsed;
275
455
  try {
276
456
  parsed = JSON.parse(raw);
277
457
  } catch {
278
- return;
458
+ return null;
279
459
  }
280
460
  if (typeof parsed.name !== "string" || parsed.name.length === 0) {
281
- return;
461
+ return null;
282
462
  }
283
463
  if (typeof parsed.version !== "string" || parsed.version.length === 0) {
284
- return;
464
+ return null;
285
465
  }
286
- acc.push({
287
- name: parsed.name,
288
- version: parsed.version,
289
- direct: depth === 0
290
- });
466
+ return { name: parsed.name, version: parsed.version, direct: depth === 0 };
291
467
  }
292
- async function walkNested(pkgDir, acc, depth) {
468
+ async function walkNested(pkgDir, depth, seen) {
293
469
  const nested = import_node_path3.default.join(pkgDir, "node_modules");
294
- if (!await isPlainDirectory(nested)) {
295
- return;
470
+ if (await claim(nested, seen, depth + 1) === null) {
471
+ return [];
296
472
  }
297
- await walk(nested, acc, depth + 1);
473
+ return walk(nested, depth + 1, seen);
298
474
  }
299
- async function isPlainDirectory(dir) {
475
+ async function claim(dir, seen, depth) {
476
+ let resolved;
300
477
  try {
301
- const info = await (0, import_promises3.lstat)(dir);
302
- return info.isDirectory() && !info.isSymbolicLink();
478
+ const info = await (0, import_promises4.lstat)(dir);
479
+ if (!info.isDirectory() && !info.isSymbolicLink()) return null;
480
+ resolved = await (0, import_promises4.realpath)(dir);
481
+ const target = await (0, import_promises4.stat)(resolved);
482
+ if (!target.isDirectory()) return null;
303
483
  } catch {
304
- return false;
484
+ return null;
305
485
  }
486
+ const claimed = seen.get(resolved);
487
+ if (claimed !== void 0 && claimed <= depth) return null;
488
+ seen.set(resolved, depth);
489
+ return resolved;
306
490
  }
307
491
 
308
492
  // src/parsers/pnpm.ts
309
- var import_promises4 = require("fs/promises");
310
- async function parsePnpmLockfile(lockfilePath) {
493
+ var import_promises5 = require("fs/promises");
494
+ async function parsePnpmLockfile(lockfilePath, report) {
311
495
  let raw;
312
496
  try {
313
- raw = await (0, import_promises4.readFile)(lockfilePath, "utf8");
497
+ raw = await (0, import_promises5.readFile)(lockfilePath, "utf8");
314
498
  } catch (cause) {
315
499
  throw new PatchstackError(
316
500
  `Could not read lockfile at ${lockfilePath}`,
@@ -331,6 +515,7 @@ async function parsePnpmLockfile(lockfilePath) {
331
515
  for (const key of packageKeys) {
332
516
  const parsed = parsePackageKey(key);
333
517
  if (parsed === null) {
518
+ recordUnreadable(report, key);
334
519
  continue;
335
520
  }
336
521
  entries.push({
@@ -558,12 +743,12 @@ function extractLeafName(trimmed) {
558
743
  }
559
744
 
560
745
  // src/parsers/yarn.ts
561
- var import_promises5 = require("fs/promises");
746
+ var import_promises6 = require("fs/promises");
562
747
  var import_node_path4 = __toESM(require("path"), 1);
563
- async function parseYarnLockfile(lockfilePath) {
748
+ async function parseYarnLockfile(lockfilePath, report) {
564
749
  let raw;
565
750
  try {
566
- raw = await (0, import_promises5.readFile)(lockfilePath, "utf8");
751
+ raw = await (0, import_promises6.readFile)(lockfilePath, "utf8");
567
752
  } catch (cause) {
568
753
  throw new PatchstackError(
569
754
  `Could not read lockfile at ${lockfilePath}`,
@@ -571,7 +756,7 @@ async function parseYarnLockfile(lockfilePath) {
571
756
  cause
572
757
  );
573
758
  }
574
- const blocks = parseBlocks(raw);
759
+ const blocks = parseBlocks(raw, report);
575
760
  if (blocks.length === 0) {
576
761
  throw new PatchstackError(
577
762
  `Lockfile at ${lockfilePath} contains no package entries`,
@@ -600,13 +785,19 @@ async function parseYarnLockfile(lockfilePath) {
600
785
  }
601
786
  return entries;
602
787
  }
603
- function parseBlocks(raw) {
788
+ var NON_PACKAGE_KEYS = /* @__PURE__ */ new Set(["__metadata"]);
789
+ function parseBlocks(raw, report) {
604
790
  const lines = raw.split(/\r?\n/);
605
791
  const blocks = [];
606
792
  let current = null;
607
793
  const finalize = () => {
608
- if (current !== null && current.version.length > 0 && current.names.size > 0) {
794
+ if (current === null) {
795
+ return;
796
+ }
797
+ if (current.version.length > 0 && current.names.size > 0) {
609
798
  blocks.push(current);
799
+ } else if (current.unreadable) {
800
+ recordUnreadable(report, current.key);
610
801
  }
611
802
  current = null;
612
803
  };
@@ -623,13 +814,13 @@ function parseBlocks(raw) {
623
814
  }
624
815
  const keyLine = trimmed.slice(0, -1);
625
816
  const names = /* @__PURE__ */ new Set();
817
+ let unreadable = false;
626
818
  for (const spec of splitDescriptors(keyLine)) {
627
- const name = extractName(spec);
628
- if (name !== null) {
629
- names.add(name);
630
- }
819
+ const classified = classifyDescriptor(spec);
820
+ if (classified.name !== void 0) names.add(classified.name);
821
+ else if (!classified.excluded) unreadable = true;
631
822
  }
632
- current = { names, version: "" };
823
+ current = { names, version: "", key: keyLine, unreadable: unreadable && names.size === 0 };
633
824
  continue;
634
825
  }
635
826
  if (current === null) {
@@ -684,20 +875,37 @@ function splitDescriptors(keyLine) {
684
875
  }
685
876
  return parts;
686
877
  }
687
- function extractName(rawSpec) {
878
+ var NON_REGISTRY_PROTOCOLS = /^(?:workspace|patch|virtual|portal|file|link|exec|git|github|https?|ssh):/i;
879
+ function classifyDescriptor(rawSpec) {
688
880
  let s = rawSpec.trim();
689
881
  if (s.length === 0) {
690
- return null;
882
+ return { excluded: true };
691
883
  }
692
884
  if (s.startsWith('"') && s.endsWith('"') || s.startsWith("'") && s.endsWith("'")) {
693
885
  s = s.slice(1, -1);
694
886
  }
695
- const atIdx = s.lastIndexOf("@");
887
+ if (NON_PACKAGE_KEYS.has(s)) {
888
+ return { excluded: true };
889
+ }
890
+ const atIdx = s.indexOf("@", 1);
696
891
  if (atIdx <= 0) {
697
- return null;
892
+ return {};
698
893
  }
699
894
  const name = s.slice(0, atIdx);
700
- return name.length > 0 ? name : null;
895
+ const range = s.slice(atIdx + 1);
896
+ if (name.length === 0) {
897
+ return {};
898
+ }
899
+ if (NON_REGISTRY_PROTOCOLS.test(range)) {
900
+ return { excluded: true };
901
+ }
902
+ const alias = /^npm:(.+)$/i.exec(range);
903
+ if (alias !== null) {
904
+ const target = alias[1] ?? "";
905
+ const targetAt = target.indexOf("@", 1);
906
+ return { name: targetAt > 0 ? target.slice(0, targetAt) : name };
907
+ }
908
+ return { name };
701
909
  }
702
910
  function parseVersionField(content) {
703
911
  if (!content.startsWith("version")) {
@@ -722,7 +930,7 @@ async function readDirectDepNames(cwd) {
722
930
  const names = /* @__PURE__ */ new Set();
723
931
  let raw;
724
932
  try {
725
- raw = await (0, import_promises5.readFile)(import_node_path4.default.join(cwd, "package.json"), "utf8");
933
+ raw = await (0, import_promises6.readFile)(import_node_path4.default.join(cwd, "package.json"), "utf8");
726
934
  } catch {
727
935
  return names;
728
936
  }
@@ -751,7 +959,10 @@ async function readDirectDepNames(cwd) {
751
959
  // src/parsers/index.ts
752
960
  var LOCKFILE_CANDIDATES = [
753
961
  { filename: "package-lock.json", strategy: "npm-lockfile" },
754
- { filename: "bun.lock", strategy: "node-modules-walk" },
962
+ // Read directly, not walked. Bun's isolated layout puts packages under a dot-directory and makes the
963
+ // top level symlinks into it, so a `node_modules` walk finds nothing there — and isolated is the default
964
+ // for new workspaces.
965
+ { filename: "bun.lock", strategy: "bun-lockfile" },
755
966
  { filename: "bun.lockb", strategy: "node-modules-walk" },
756
967
  { filename: "pnpm-lock.yaml", strategy: "pnpm-lockfile" },
757
968
  { filename: "yarn.lock", strategy: "yarn-lockfile" }
@@ -786,34 +997,58 @@ async function scanLockfile(cwd) {
786
997
  const declared = await readDeclaredDependencyNames(cwd);
787
998
  const warnings = [];
788
999
  let firstParsed = null;
789
- let walkTried = false;
1000
+ let walked = null;
1001
+ let walkWasCandidate = false;
1002
+ const parsedSources = [];
790
1003
  for (const candidate of candidates) {
791
1004
  let packages;
1005
+ const report = newReport();
792
1006
  try {
793
- packages = await runStrategy(candidate, cwd);
1007
+ packages = await runStrategy(candidate, cwd, report);
794
1008
  } catch {
795
1009
  continue;
796
1010
  }
797
- walkTried ||= candidate.strategy === "node-modules-walk";
1011
+ const unreadable = unreadableWarning(candidate.filename, report);
1012
+ if (unreadable !== null) warnings.push(unreadable);
1013
+ if (candidate.strategy === "node-modules-walk") {
1014
+ walked = packages;
1015
+ walkWasCandidate = true;
1016
+ }
798
1017
  firstParsed ??= { packages, filename: candidate.filename };
799
- const missing = missingDependencies(declared, packages);
800
- if (missing.length === 0) {
801
- return manifestWith(packages, warnings);
1018
+ parsedSources.push({ packages, filename: candidate.filename });
1019
+ }
1020
+ for (const source of parsedSources) {
1021
+ const missing = missingDependencies(declared, source.packages);
1022
+ if (missing.length > 0) {
1023
+ warnings.push(staleWarning(source.filename, missing));
1024
+ continue;
1025
+ }
1026
+ const conflicts = otherCompleteSources(parsedSources, source, declared).flatMap(
1027
+ (other) => disagreements(source.packages, other.packages).map((conflict) => ({ other: other.filename, conflict }))
1028
+ );
1029
+ if (conflicts.length === 0) {
1030
+ return manifestWith(source.packages, warnings);
802
1031
  }
803
- warnings.push(staleWarning(candidate.filename, missing));
1032
+ warnings.push(conflictWarning(source.filename, conflicts));
1033
+ break;
804
1034
  }
805
- if (!walkTried) {
1035
+ if (walked === null) {
806
1036
  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);
1037
+ walked = await walkNodeModules(cwd);
814
1038
  } catch {
1039
+ walked = null;
815
1040
  }
816
1041
  }
1042
+ if (walked !== null) {
1043
+ const missing = missingDependencies(declared, walked);
1044
+ if (missing.length > 0) {
1045
+ warnings.push(staleWarning("node_modules/", missing));
1046
+ }
1047
+ warnings.push(
1048
+ walkWasCandidate ? "Reporting node_modules/, which is what the build loads." : "Scanned node_modules/ instead. Delete the stale lockfile to silence this warning."
1049
+ );
1050
+ return manifestWith(walked, warnings);
1051
+ }
817
1052
  if (firstParsed === null) {
818
1053
  const expected = LOCKFILE_CANDIDATES.map((candidate) => candidate.filename).join(", ");
819
1054
  throw new PatchstackError(
@@ -829,6 +1064,15 @@ async function scanLockfile(cwd) {
829
1064
  function manifestWith(packages, warnings) {
830
1065
  return warnings.length > 0 ? { ecosystem: "npm", packages, warnings } : { ecosystem: "npm", packages };
831
1066
  }
1067
+ function otherCompleteSources(all, current, declared) {
1068
+ return all.filter(
1069
+ (source) => source.filename !== current.filename && missingDependencies(declared, source.packages).length === 0
1070
+ );
1071
+ }
1072
+ function conflictWarning(filename, conflicts) {
1073
+ const shown = conflicts.slice(0, 3).map(({ other, conflict }) => `${conflict.name} ${conflict.version} vs ${conflict.otherVersion} in ${other}`).join("; ");
1074
+ 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.`;
1075
+ }
832
1076
  function staleWarning(source, missing) {
833
1077
  const sample = missing.slice(0, 3).join(", ");
834
1078
  const suffix = missing.length > 3 ? `, +${missing.length - 3} more` : "";
@@ -843,21 +1087,25 @@ async function presentLockfiles(cwd) {
843
1087
  );
844
1088
  return probed.filter((candidate) => candidate.present).map(({ filename, strategy, filePath }) => ({ ecosystem: "npm", filename, strategy, filePath }));
845
1089
  }
846
- async function runStrategy(detected, cwd) {
1090
+ async function runStrategy(detected, cwd, report) {
847
1091
  switch (detected.strategy) {
848
1092
  case "npm-lockfile":
849
1093
  return parseNpmLockfile(detected.filePath);
1094
+ case "bun-lockfile":
1095
+ return parseBunLockfile(detected.filePath, report);
1096
+ // The hand-written scanners. Each meets entries it was not written for, and each is asked to say how
1097
+ // many it dropped, because a manifest short a package reads exactly like a project without it.
850
1098
  case "pnpm-lockfile":
851
- return parsePnpmLockfile(detected.filePath);
1099
+ return parsePnpmLockfile(detected.filePath, report);
852
1100
  case "yarn-lockfile":
853
- return parseYarnLockfile(detected.filePath);
1101
+ return parseYarnLockfile(detected.filePath, report);
854
1102
  case "node-modules-walk":
855
1103
  return walkNodeModules(cwd);
856
1104
  }
857
1105
  }
858
1106
  async function exists(filePath) {
859
1107
  try {
860
- await (0, import_promises6.access)(filePath);
1108
+ await (0, import_promises7.access)(filePath);
861
1109
  return true;
862
1110
  } catch {
863
1111
  return false;
@@ -1143,12 +1391,14 @@ function isTimeoutError(cause) {
1143
1391
  }
1144
1392
 
1145
1393
  // src/config.ts
1146
- var import_promises7 = require("fs/promises");
1394
+ var import_promises8 = require("fs/promises");
1147
1395
  var import_node_path6 = __toESM(require("path"), 1);
1148
1396
  var CONFIG_FILENAME = ".patchstackrc.json";
1397
+ var SECRET_FILENAME = ".patchstackrc.local.json";
1149
1398
  var DEFAULT_ENVIRONMENT = "production";
1150
1399
  async function resolveConfig(options) {
1151
1400
  const fromFile = await readConfigFile(options.cwd);
1401
+ const fromSecretFile = await readSecretFile(options.cwd);
1152
1402
  const fromEnv = readEnv();
1153
1403
  const siteUuid = options.cliSiteUuid ?? fromEnv.siteUuid ?? fromFile.siteUuid ?? null;
1154
1404
  const endpoint = options.cliEndpoint ?? fromEnv.endpoint ?? fromFile.endpoint ?? DEFAULT_ENDPOINT;
@@ -1173,8 +1423,8 @@ async function resolveConfig(options) {
1173
1423
  "CONFIG_MISSING"
1174
1424
  );
1175
1425
  }
1176
- const apiKeyRaw = fromEnv.apiKey ?? fromFile.apiKey ?? null;
1177
- const pulseAuthRaw = fromEnv.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;
1426
+ const apiKeyRaw = fromEnv.apiKey ?? fromSecretFile.apiKey ?? fromFile.apiKey ?? null;
1427
+ const pulseAuthRaw = fromEnv.pulseAuth ?? fromSecretFile.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;
1178
1428
  return {
1179
1429
  siteUuid: siteUuid === null || siteUuid.length === 0 ? null : siteUuid,
1180
1430
  apiKey: apiKeyRaw === null || apiKeyRaw.length === 0 ? null : apiKeyRaw,
@@ -1188,26 +1438,91 @@ async function resolveConfig(options) {
1188
1438
  async function writeConfigFile(cwd, config) {
1189
1439
  const target = import_node_path6.default.join(cwd, CONFIG_FILENAME);
1190
1440
  const content = JSON.stringify(config, null, 2) + "\n";
1191
- await (0, import_promises7.writeFile)(target, content, "utf8");
1441
+ await (0, import_promises8.writeFile)(target, content, "utf8");
1192
1442
  return target;
1193
1443
  }
1444
+ async function writeSecretFile(cwd, secrets) {
1445
+ const target = import_node_path6.default.join(cwd, SECRET_FILENAME);
1446
+ await (0, import_promises8.writeFile)(target, JSON.stringify(secrets, null, 2) + "\n", { encoding: "utf8", mode: 384 });
1447
+ try {
1448
+ await (0, import_promises8.chmod)(target, 384);
1449
+ } catch {
1450
+ }
1451
+ const ignore = await ensureIgnored(cwd, SECRET_FILENAME);
1452
+ return { path: target, ...ignore };
1453
+ }
1454
+ async function ensureIgnored(cwd, entry) {
1455
+ const target = import_node_path6.default.join(cwd, ".gitignore");
1456
+ let existing = "";
1457
+ try {
1458
+ existing = await (0, import_promises8.readFile)(target, "utf8");
1459
+ } catch (err) {
1460
+ const code = err.code;
1461
+ if (code !== "ENOENT") {
1462
+ return { ignored: false, reason: `.gitignore could not be read (${code ?? "unknown error"})` };
1463
+ }
1464
+ }
1465
+ if (ignoresEntry(existing, entry)) return { ignored: true };
1466
+ const separator = existing === "" || existing.endsWith("\n") ? "" : "\n";
1467
+ const block = `${separator}
1468
+ # Patchstack credential \u2014 never commit this
1469
+ ${entry}
1470
+ `;
1471
+ try {
1472
+ await (0, import_promises8.writeFile)(target, existing + block, "utf8");
1473
+ } catch (err) {
1474
+ const code = err.code;
1475
+ return { ignored: false, reason: `.gitignore could not be written (${code ?? "unknown error"})` };
1476
+ }
1477
+ try {
1478
+ const written = await (0, import_promises8.readFile)(target, "utf8");
1479
+ if (ignoresEntry(written, entry)) return { ignored: true };
1480
+ return { ignored: false, reason: ".gitignore does not cover the file after writing it" };
1481
+ } catch (err) {
1482
+ const code = err.code;
1483
+ return { ignored: false, reason: `.gitignore could not be read back (${code ?? "unknown error"})` };
1484
+ }
1485
+ }
1486
+ function ignoresEntry(contents, entry) {
1487
+ let ignored = false;
1488
+ for (const raw of contents.split("\n")) {
1489
+ const line = raw.trim();
1490
+ if (line === "" || line.startsWith("#")) continue;
1491
+ const negated = line.startsWith("!");
1492
+ const pattern = negated ? line.slice(1).trim() : line;
1493
+ if (pattern === entry || pattern === `/${entry}`) ignored = !negated;
1494
+ }
1495
+ return ignored;
1496
+ }
1194
1497
  async function persistSiteUuid(cwd, siteUuid) {
1195
1498
  const existing = await readConfigFile(cwd);
1196
1499
  return writeConfigFile(cwd, { ...existing, siteUuid });
1197
1500
  }
1198
1501
  async function persistApiKey(cwd, apiKey) {
1199
- const { pulseAuth: _dropped, ...existing } = await readConfigFile(cwd);
1200
- return writeConfigFile(cwd, { ...existing, apiKey });
1502
+ const { apiKey: _movedKey, pulseAuth: _movedPulse, ...publicConfig } = await readConfigFile(cwd);
1503
+ const existingSecrets = await readSecretFile(cwd);
1504
+ const target = await writeSecretFile(cwd, { ...existingSecrets, apiKey, pulseAuth: void 0 });
1505
+ if (_movedKey !== void 0 || _movedPulse !== void 0) {
1506
+ await writeConfigFile(cwd, publicConfig);
1507
+ }
1508
+ return target;
1201
1509
  }
1202
1510
  async function persistPulseAuth(cwd, pulseAuth) {
1203
- const existing = await readConfigFile(cwd);
1204
- return writeConfigFile(cwd, { ...existing, pulseAuth });
1511
+ const existing = await readSecretFile(cwd);
1512
+ return writeSecretFile(cwd, { ...existing, pulseAuth });
1513
+ }
1514
+ async function readSecretFile(cwd) {
1515
+ const file = await readJsonFile(cwd, SECRET_FILENAME);
1516
+ return { apiKey: file.apiKey, pulseAuth: file.pulseAuth };
1205
1517
  }
1206
1518
  async function readConfigFile(cwd) {
1207
- const target = import_node_path6.default.join(cwd, CONFIG_FILENAME);
1519
+ return readJsonFile(cwd, CONFIG_FILENAME);
1520
+ }
1521
+ async function readJsonFile(cwd, filename) {
1522
+ const target = import_node_path6.default.join(cwd, filename);
1208
1523
  let raw;
1209
1524
  try {
1210
- raw = await (0, import_promises7.readFile)(target, "utf8");
1525
+ raw = await (0, import_promises8.readFile)(target, "utf8");
1211
1526
  } catch (err) {
1212
1527
  if (err.code === "ENOENT") {
1213
1528
  return {};