@patchstack/connect 0.3.29 → 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 +1062 -189
  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.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,122 @@ 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));
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) {
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);
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);
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);
369
+ });
370
+ return perEntry.flat();
371
+ }
372
+ async function packageAndNested(pkgDir, resolved, depth, seen) {
373
+ const [own, nested] = await Promise.all([readPackage(pkgDir, depth), walkNested(pkgDir, depth, seen)]);
374
+ return own === null ? nested : [{ entry: own, realpath: resolved, depth }, ...nested];
207
375
  }
208
- async function readPackage(pkgDir, depth, acc) {
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
+ async function readPackage(pkgDir, depth) {
209
389
  let raw;
210
390
  try {
211
- raw = await readFile3(path3.join(pkgDir, "package.json"), "utf8");
391
+ raw = await readFile4(path3.join(pkgDir, "package.json"), "utf8");
212
392
  } catch {
213
- return;
393
+ return null;
214
394
  }
215
395
  let parsed;
216
396
  try {
217
397
  parsed = JSON.parse(raw);
218
398
  } catch {
219
- return;
399
+ return null;
220
400
  }
221
401
  if (typeof parsed.name !== "string" || parsed.name.length === 0) {
222
- return;
402
+ return null;
223
403
  }
224
404
  if (typeof parsed.version !== "string" || parsed.version.length === 0) {
225
- return;
405
+ return null;
226
406
  }
227
- acc.push({
228
- name: parsed.name,
229
- version: parsed.version,
230
- direct: depth === 0
231
- });
407
+ return { name: parsed.name, version: parsed.version, direct: depth === 0 };
232
408
  }
233
- async function walkNested(pkgDir, acc, depth) {
409
+ async function walkNested(pkgDir, depth, seen) {
234
410
  const nested = path3.join(pkgDir, "node_modules");
235
- if (!await isPlainDirectory(nested)) {
236
- return;
411
+ if (await claim(nested, seen, depth + 1) === null) {
412
+ return [];
237
413
  }
238
- await walk(nested, acc, depth + 1);
414
+ return walk(nested, depth + 1, seen);
239
415
  }
240
- async function isPlainDirectory(dir) {
416
+ async function claim(dir, seen, depth) {
417
+ let resolved;
241
418
  try {
242
419
  const info = await lstat(dir);
243
- return info.isDirectory() && !info.isSymbolicLink();
420
+ if (!info.isDirectory() && !info.isSymbolicLink()) return null;
421
+ resolved = await realpath(dir);
422
+ const target = await stat(resolved);
423
+ if (!target.isDirectory()) return null;
244
424
  } catch {
245
- return false;
425
+ return null;
246
426
  }
427
+ const claimed = seen.get(resolved);
428
+ if (claimed !== void 0 && claimed <= depth) return null;
429
+ seen.set(resolved, depth);
430
+ return resolved;
247
431
  }
248
432
 
249
433
  // src/parsers/pnpm.ts
250
- import { readFile as readFile4 } from "fs/promises";
251
- async function parsePnpmLockfile(lockfilePath) {
434
+ import { readFile as readFile5 } from "fs/promises";
435
+ async function parsePnpmLockfile(lockfilePath, report) {
252
436
  let raw;
253
437
  try {
254
- raw = await readFile4(lockfilePath, "utf8");
438
+ raw = await readFile5(lockfilePath, "utf8");
255
439
  } catch (cause) {
256
440
  throw new PatchstackError(
257
441
  `Could not read lockfile at ${lockfilePath}`,
@@ -272,6 +456,7 @@ async function parsePnpmLockfile(lockfilePath) {
272
456
  for (const key of packageKeys) {
273
457
  const parsed = parsePackageKey(key);
274
458
  if (parsed === null) {
459
+ recordUnreadable(report, key);
275
460
  continue;
276
461
  }
277
462
  entries.push({
@@ -499,12 +684,12 @@ function extractLeafName(trimmed) {
499
684
  }
500
685
 
501
686
  // src/parsers/yarn.ts
502
- import { readFile as readFile5 } from "fs/promises";
687
+ import { readFile as readFile6 } from "fs/promises";
503
688
  import path4 from "path";
504
- async function parseYarnLockfile(lockfilePath) {
689
+ async function parseYarnLockfile(lockfilePath, report) {
505
690
  let raw;
506
691
  try {
507
- raw = await readFile5(lockfilePath, "utf8");
692
+ raw = await readFile6(lockfilePath, "utf8");
508
693
  } catch (cause) {
509
694
  throw new PatchstackError(
510
695
  `Could not read lockfile at ${lockfilePath}`,
@@ -512,7 +697,7 @@ async function parseYarnLockfile(lockfilePath) {
512
697
  cause
513
698
  );
514
699
  }
515
- const blocks = parseBlocks(raw);
700
+ const blocks = parseBlocks(raw, report);
516
701
  if (blocks.length === 0) {
517
702
  throw new PatchstackError(
518
703
  `Lockfile at ${lockfilePath} contains no package entries`,
@@ -541,13 +726,19 @@ async function parseYarnLockfile(lockfilePath) {
541
726
  }
542
727
  return entries;
543
728
  }
544
- function parseBlocks(raw) {
729
+ var NON_PACKAGE_KEYS = /* @__PURE__ */ new Set(["__metadata"]);
730
+ function parseBlocks(raw, report) {
545
731
  const lines = raw.split(/\r?\n/);
546
732
  const blocks = [];
547
733
  let current = null;
548
734
  const finalize = () => {
549
- if (current !== null && current.version.length > 0 && current.names.size > 0) {
735
+ if (current === null) {
736
+ return;
737
+ }
738
+ if (current.version.length > 0 && current.names.size > 0) {
550
739
  blocks.push(current);
740
+ } else if (current.unreadable) {
741
+ recordUnreadable(report, current.key);
551
742
  }
552
743
  current = null;
553
744
  };
@@ -564,13 +755,13 @@ function parseBlocks(raw) {
564
755
  }
565
756
  const keyLine = trimmed.slice(0, -1);
566
757
  const names = /* @__PURE__ */ new Set();
758
+ let unreadable = false;
567
759
  for (const spec of splitDescriptors(keyLine)) {
568
- const name = extractName(spec);
569
- if (name !== null) {
570
- names.add(name);
571
- }
760
+ const classified = classifyDescriptor(spec);
761
+ if (classified.name !== void 0) names.add(classified.name);
762
+ else if (!classified.excluded) unreadable = true;
572
763
  }
573
- current = { names, version: "" };
764
+ current = { names, version: "", key: keyLine, unreadable: unreadable && names.size === 0 };
574
765
  continue;
575
766
  }
576
767
  if (current === null) {
@@ -625,20 +816,37 @@ function splitDescriptors(keyLine) {
625
816
  }
626
817
  return parts;
627
818
  }
628
- function extractName(rawSpec) {
819
+ var NON_REGISTRY_PROTOCOLS = /^(?:workspace|patch|virtual|portal|file|link|exec|git|github|https?|ssh):/i;
820
+ function classifyDescriptor(rawSpec) {
629
821
  let s = rawSpec.trim();
630
822
  if (s.length === 0) {
631
- return null;
823
+ return { excluded: true };
632
824
  }
633
825
  if (s.startsWith('"') && s.endsWith('"') || s.startsWith("'") && s.endsWith("'")) {
634
826
  s = s.slice(1, -1);
635
827
  }
636
- const atIdx = s.lastIndexOf("@");
828
+ if (NON_PACKAGE_KEYS.has(s)) {
829
+ return { excluded: true };
830
+ }
831
+ const atIdx = s.indexOf("@", 1);
637
832
  if (atIdx <= 0) {
638
- return null;
833
+ return {};
639
834
  }
640
835
  const name = s.slice(0, atIdx);
641
- return name.length > 0 ? name : null;
836
+ const range = s.slice(atIdx + 1);
837
+ if (name.length === 0) {
838
+ return {};
839
+ }
840
+ if (NON_REGISTRY_PROTOCOLS.test(range)) {
841
+ return { excluded: true };
842
+ }
843
+ const alias = /^npm:(.+)$/i.exec(range);
844
+ if (alias !== null) {
845
+ const target = alias[1] ?? "";
846
+ const targetAt = target.indexOf("@", 1);
847
+ return { name: targetAt > 0 ? target.slice(0, targetAt) : name };
848
+ }
849
+ return { name };
642
850
  }
643
851
  function parseVersionField(content) {
644
852
  if (!content.startsWith("version")) {
@@ -663,7 +871,7 @@ async function readDirectDepNames(cwd) {
663
871
  const names = /* @__PURE__ */ new Set();
664
872
  let raw;
665
873
  try {
666
- raw = await readFile5(path4.join(cwd, "package.json"), "utf8");
874
+ raw = await readFile6(path4.join(cwd, "package.json"), "utf8");
667
875
  } catch {
668
876
  return names;
669
877
  }
@@ -692,7 +900,10 @@ async function readDirectDepNames(cwd) {
692
900
  // src/parsers/index.ts
693
901
  var LOCKFILE_CANDIDATES = [
694
902
  { filename: "package-lock.json", strategy: "npm-lockfile" },
695
- { filename: "bun.lock", strategy: "node-modules-walk" },
903
+ // Read directly, not walked. Bun's isolated layout puts packages under a dot-directory and makes the
904
+ // top level symlinks into it, so a `node_modules` walk finds nothing there — and isolated is the default
905
+ // for new workspaces.
906
+ { filename: "bun.lock", strategy: "bun-lockfile" },
696
907
  { filename: "bun.lockb", strategy: "node-modules-walk" },
697
908
  { filename: "pnpm-lock.yaml", strategy: "pnpm-lockfile" },
698
909
  { filename: "yarn.lock", strategy: "yarn-lockfile" }
@@ -727,33 +938,57 @@ async function scanLockfile(cwd) {
727
938
  const declared = await readDeclaredDependencyNames(cwd);
728
939
  const warnings = [];
729
940
  let firstParsed = null;
730
- let walkTried = false;
941
+ let walked = null;
942
+ let walkWasCandidate = false;
943
+ const parsedSources = [];
731
944
  for (const candidate of candidates) {
732
945
  let packages;
946
+ const report = newReport();
733
947
  try {
734
- packages = await runStrategy(candidate, cwd);
948
+ packages = await runStrategy(candidate, cwd, report);
735
949
  } catch {
736
950
  continue;
737
951
  }
738
- walkTried ||= candidate.strategy === "node-modules-walk";
952
+ const unreadable = unreadableWarning(candidate.filename, report);
953
+ if (unreadable !== null) warnings.push(unreadable);
954
+ if (candidate.strategy === "node-modules-walk") {
955
+ walked = packages;
956
+ walkWasCandidate = true;
957
+ }
739
958
  firstParsed ??= { packages, filename: candidate.filename };
740
- const missing = missingDependencies(declared, packages);
741
- if (missing.length === 0) {
742
- return manifestWith(packages, warnings);
959
+ parsedSources.push({ packages, filename: candidate.filename });
960
+ }
961
+ for (const source of parsedSources) {
962
+ const missing = missingDependencies(declared, source.packages);
963
+ if (missing.length > 0) {
964
+ warnings.push(staleWarning(source.filename, missing));
965
+ continue;
966
+ }
967
+ const conflicts = otherCompleteSources(parsedSources, source, declared).flatMap(
968
+ (other) => disagreements(source.packages, other.packages).map((conflict) => ({ other: other.filename, conflict }))
969
+ );
970
+ if (conflicts.length === 0) {
971
+ return manifestWith(source.packages, warnings);
743
972
  }
744
- warnings.push(staleWarning(candidate.filename, missing));
973
+ warnings.push(conflictWarning(source.filename, conflicts));
974
+ break;
745
975
  }
746
- if (!walkTried) {
976
+ if (walked === null) {
747
977
  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);
978
+ walked = await walkNodeModules(cwd);
755
979
  } catch {
980
+ walked = null;
981
+ }
982
+ }
983
+ if (walked !== null) {
984
+ const missing = missingDependencies(declared, walked);
985
+ if (missing.length > 0) {
986
+ warnings.push(staleWarning("node_modules/", missing));
756
987
  }
988
+ warnings.push(
989
+ walkWasCandidate ? "Reporting node_modules/, which is what the build loads." : "Scanned node_modules/ instead. Delete the stale lockfile to silence this warning."
990
+ );
991
+ return manifestWith(walked, warnings);
757
992
  }
758
993
  if (firstParsed === null) {
759
994
  const expected = LOCKFILE_CANDIDATES.map((candidate) => candidate.filename).join(", ");
@@ -770,6 +1005,15 @@ async function scanLockfile(cwd) {
770
1005
  function manifestWith(packages, warnings) {
771
1006
  return warnings.length > 0 ? { ecosystem: "npm", packages, warnings } : { ecosystem: "npm", packages };
772
1007
  }
1008
+ function otherCompleteSources(all, current, declared) {
1009
+ return all.filter(
1010
+ (source) => source.filename !== current.filename && missingDependencies(declared, source.packages).length === 0
1011
+ );
1012
+ }
1013
+ function conflictWarning(filename, conflicts) {
1014
+ const shown = conflicts.slice(0, 3).map(({ other, conflict }) => `${conflict.name} ${conflict.version} vs ${conflict.otherVersion} in ${other}`).join("; ");
1015
+ 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.`;
1016
+ }
773
1017
  function staleWarning(source, missing) {
774
1018
  const sample = missing.slice(0, 3).join(", ");
775
1019
  const suffix = missing.length > 3 ? `, +${missing.length - 3} more` : "";
@@ -784,14 +1028,18 @@ async function presentLockfiles(cwd) {
784
1028
  );
785
1029
  return probed.filter((candidate) => candidate.present).map(({ filename, strategy, filePath }) => ({ ecosystem: "npm", filename, strategy, filePath }));
786
1030
  }
787
- async function runStrategy(detected, cwd) {
1031
+ async function runStrategy(detected, cwd, report) {
788
1032
  switch (detected.strategy) {
789
1033
  case "npm-lockfile":
790
1034
  return parseNpmLockfile(detected.filePath);
1035
+ case "bun-lockfile":
1036
+ return parseBunLockfile(detected.filePath, report);
1037
+ // The hand-written scanners. Each meets entries it was not written for, and each is asked to say how
1038
+ // many it dropped, because a manifest short a package reads exactly like a project without it.
791
1039
  case "pnpm-lockfile":
792
- return parsePnpmLockfile(detected.filePath);
1040
+ return parsePnpmLockfile(detected.filePath, report);
793
1041
  case "yarn-lockfile":
794
- return parseYarnLockfile(detected.filePath);
1042
+ return parseYarnLockfile(detected.filePath, report);
795
1043
  case "node-modules-walk":
796
1044
  return walkNodeModules(cwd);
797
1045
  }
@@ -1084,12 +1332,14 @@ function isTimeoutError(cause) {
1084
1332
  }
1085
1333
 
1086
1334
  // src/config.ts
1087
- import { readFile as readFile6, writeFile } from "fs/promises";
1335
+ import { readFile as readFile7, writeFile, chmod } from "fs/promises";
1088
1336
  import path6 from "path";
1089
1337
  var CONFIG_FILENAME = ".patchstackrc.json";
1338
+ var SECRET_FILENAME = ".patchstackrc.local.json";
1090
1339
  var DEFAULT_ENVIRONMENT = "production";
1091
1340
  async function resolveConfig(options) {
1092
1341
  const fromFile = await readConfigFile(options.cwd);
1342
+ const fromSecretFile = await readSecretFile(options.cwd);
1093
1343
  const fromEnv = readEnv();
1094
1344
  const siteUuid = options.cliSiteUuid ?? fromEnv.siteUuid ?? fromFile.siteUuid ?? null;
1095
1345
  const endpoint = options.cliEndpoint ?? fromEnv.endpoint ?? fromFile.endpoint ?? DEFAULT_ENDPOINT;
@@ -1114,8 +1364,8 @@ async function resolveConfig(options) {
1114
1364
  "CONFIG_MISSING"
1115
1365
  );
1116
1366
  }
1117
- const apiKeyRaw = fromEnv.apiKey ?? fromFile.apiKey ?? null;
1118
- const pulseAuthRaw = fromEnv.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;
1367
+ const apiKeyRaw = fromEnv.apiKey ?? fromSecretFile.apiKey ?? fromFile.apiKey ?? null;
1368
+ const pulseAuthRaw = fromEnv.pulseAuth ?? fromSecretFile.pulseAuth ?? fromFile.pulseAuth ?? apiKeyRaw;
1119
1369
  return {
1120
1370
  siteUuid: siteUuid === null || siteUuid.length === 0 ? null : siteUuid,
1121
1371
  apiKey: apiKeyRaw === null || apiKeyRaw.length === 0 ? null : apiKeyRaw,
@@ -1132,23 +1382,88 @@ async function writeConfigFile(cwd, config) {
1132
1382
  await writeFile(target, content, "utf8");
1133
1383
  return target;
1134
1384
  }
1385
+ async function writeSecretFile(cwd, secrets) {
1386
+ const target = path6.join(cwd, SECRET_FILENAME);
1387
+ await writeFile(target, JSON.stringify(secrets, null, 2) + "\n", { encoding: "utf8", mode: 384 });
1388
+ try {
1389
+ await chmod(target, 384);
1390
+ } catch {
1391
+ }
1392
+ const ignore = await ensureIgnored(cwd, SECRET_FILENAME);
1393
+ return { path: target, ...ignore };
1394
+ }
1395
+ async function ensureIgnored(cwd, entry) {
1396
+ const target = path6.join(cwd, ".gitignore");
1397
+ let existing = "";
1398
+ try {
1399
+ existing = await readFile7(target, "utf8");
1400
+ } catch (err) {
1401
+ const code = err.code;
1402
+ if (code !== "ENOENT") {
1403
+ return { ignored: false, reason: `.gitignore could not be read (${code ?? "unknown error"})` };
1404
+ }
1405
+ }
1406
+ if (ignoresEntry(existing, entry)) return { ignored: true };
1407
+ const separator = existing === "" || existing.endsWith("\n") ? "" : "\n";
1408
+ const block = `${separator}
1409
+ # Patchstack credential \u2014 never commit this
1410
+ ${entry}
1411
+ `;
1412
+ try {
1413
+ await writeFile(target, existing + block, "utf8");
1414
+ } catch (err) {
1415
+ const code = err.code;
1416
+ return { ignored: false, reason: `.gitignore could not be written (${code ?? "unknown error"})` };
1417
+ }
1418
+ try {
1419
+ const written = await readFile7(target, "utf8");
1420
+ if (ignoresEntry(written, entry)) return { ignored: true };
1421
+ return { ignored: false, reason: ".gitignore does not cover the file after writing it" };
1422
+ } catch (err) {
1423
+ const code = err.code;
1424
+ return { ignored: false, reason: `.gitignore could not be read back (${code ?? "unknown error"})` };
1425
+ }
1426
+ }
1427
+ function ignoresEntry(contents, entry) {
1428
+ let ignored = false;
1429
+ for (const raw of contents.split("\n")) {
1430
+ const line = raw.trim();
1431
+ if (line === "" || line.startsWith("#")) continue;
1432
+ const negated = line.startsWith("!");
1433
+ const pattern = negated ? line.slice(1).trim() : line;
1434
+ if (pattern === entry || pattern === `/${entry}`) ignored = !negated;
1435
+ }
1436
+ return ignored;
1437
+ }
1135
1438
  async function persistSiteUuid(cwd, siteUuid) {
1136
1439
  const existing = await readConfigFile(cwd);
1137
1440
  return writeConfigFile(cwd, { ...existing, siteUuid });
1138
1441
  }
1139
1442
  async function persistApiKey(cwd, apiKey) {
1140
- const { pulseAuth: _dropped, ...existing } = await readConfigFile(cwd);
1141
- return writeConfigFile(cwd, { ...existing, apiKey });
1443
+ const { apiKey: _movedKey, pulseAuth: _movedPulse, ...publicConfig } = await readConfigFile(cwd);
1444
+ const existingSecrets = await readSecretFile(cwd);
1445
+ const target = await writeSecretFile(cwd, { ...existingSecrets, apiKey, pulseAuth: void 0 });
1446
+ if (_movedKey !== void 0 || _movedPulse !== void 0) {
1447
+ await writeConfigFile(cwd, publicConfig);
1448
+ }
1449
+ return target;
1142
1450
  }
1143
1451
  async function persistPulseAuth(cwd, pulseAuth) {
1144
- const existing = await readConfigFile(cwd);
1145
- return writeConfigFile(cwd, { ...existing, pulseAuth });
1452
+ const existing = await readSecretFile(cwd);
1453
+ return writeSecretFile(cwd, { ...existing, pulseAuth });
1454
+ }
1455
+ async function readSecretFile(cwd) {
1456
+ const file = await readJsonFile(cwd, SECRET_FILENAME);
1457
+ return { apiKey: file.apiKey, pulseAuth: file.pulseAuth };
1146
1458
  }
1147
1459
  async function readConfigFile(cwd) {
1148
- const target = path6.join(cwd, CONFIG_FILENAME);
1460
+ return readJsonFile(cwd, CONFIG_FILENAME);
1461
+ }
1462
+ async function readJsonFile(cwd, filename) {
1463
+ const target = path6.join(cwd, filename);
1149
1464
  let raw;
1150
1465
  try {
1151
- raw = await readFile6(target, "utf8");
1466
+ raw = await readFile7(target, "utf8");
1152
1467
  } catch (err) {
1153
1468
  if (err.code === "ENOENT") {
1154
1469
  return {};