trawly 0.0.1 → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -203,8 +203,8 @@ function truncate(s, max) {
203
203
  }
204
204
 
205
205
  // src/scanner.ts
206
- import { existsSync, statSync } from "fs";
207
- import { resolve as resolve2, join } from "path";
206
+ import { existsSync as existsSync2, statSync } from "fs";
207
+ import { basename, resolve as resolve4, join as join2 } from "path";
208
208
 
209
209
  // src/extractors/npm-package-lock.ts
210
210
  import { readFileSync } from "fs";
@@ -290,6 +290,262 @@ function isTopLevelInstance(path) {
290
290
  return path.indexOf("node_modules/", first + 1) === -1;
291
291
  }
292
292
 
293
+ // src/extractors/pnpm-lock.ts
294
+ import { readFileSync as readFileSync2 } from "fs";
295
+ import { resolve as resolve2 } from "path";
296
+ import yaml from "js-yaml";
297
+ var SUPPORTED_MAJOR_VERSIONS = /* @__PURE__ */ new Set([6, 9]);
298
+ function parsePnpmLock(filePath) {
299
+ const absolute = resolve2(filePath);
300
+ const raw = readFileSync2(absolute, "utf8");
301
+ let parsed;
302
+ try {
303
+ parsed = yaml.load(raw) ?? {};
304
+ } catch (err) {
305
+ throw new Error(
306
+ `Failed to parse ${absolute}: ${err.message}`
307
+ );
308
+ }
309
+ const versionRaw = parsed.lockfileVersion;
310
+ const major = parseLockfileMajor(versionRaw);
311
+ if (major === null || !SUPPORTED_MAJOR_VERSIONS.has(major)) {
312
+ throw new Error(
313
+ `Unsupported pnpm lockfileVersion ${String(versionRaw)} in ${absolute}. Supported: 6.x, 9.x.`
314
+ );
315
+ }
316
+ const packages = parsed.packages;
317
+ if (!packages || typeof packages !== "object") {
318
+ throw new Error(
319
+ `Lockfile ${absolute} has no "packages" map; cannot extract installed versions.`
320
+ );
321
+ }
322
+ const importers = parsed.importers ?? {
323
+ ".": {
324
+ dependencies: parsed.dependencies,
325
+ devDependencies: parsed.devDependencies,
326
+ optionalDependencies: parsed.optionalDependencies
327
+ }
328
+ };
329
+ const direct = collectDirectFromImporters(importers);
330
+ const instances = [];
331
+ for (const [key, entry] of Object.entries(packages)) {
332
+ const parsed2 = parsePnpmPackageKey(key);
333
+ if (!parsed2) continue;
334
+ const name = entry.name ?? parsed2.name;
335
+ const version = entry.version ?? parsed2.version;
336
+ if (!name || !version) continue;
337
+ const isDirect = direct.prod.has(name) || direct.dev.has(name) || direct.optional.has(name);
338
+ const onlyDev = direct.dev.has(name) && !direct.prod.has(name);
339
+ const onlyOptional = direct.optional.has(name) && !direct.prod.has(name) && !direct.dev.has(name);
340
+ instances.push({
341
+ name,
342
+ version,
343
+ ecosystem: "npm",
344
+ path: key,
345
+ direct: isDirect,
346
+ dev: Boolean(entry.dev) || onlyDev,
347
+ optional: Boolean(entry.optional) || onlyOptional,
348
+ resolved: entry.resolution?.tarball,
349
+ integrity: entry.resolution?.integrity
350
+ });
351
+ }
352
+ return instances;
353
+ }
354
+ function parseLockfileMajor(value) {
355
+ if (typeof value === "number") return Math.trunc(value);
356
+ if (typeof value === "string") {
357
+ const num = parseInt(value, 10);
358
+ return Number.isNaN(num) ? null : num;
359
+ }
360
+ return null;
361
+ }
362
+ function collectDirectFromImporters(importers) {
363
+ const prod = /* @__PURE__ */ new Set();
364
+ const dev = /* @__PURE__ */ new Set();
365
+ const optional = /* @__PURE__ */ new Set();
366
+ for (const importer of Object.values(importers)) {
367
+ if (!importer) continue;
368
+ addDepNames(importer.dependencies, prod);
369
+ addDepNames(importer.devDependencies, dev);
370
+ addDepNames(importer.optionalDependencies, optional);
371
+ }
372
+ return { prod, dev, optional };
373
+ }
374
+ function addDepNames(block, into) {
375
+ if (!block) return;
376
+ for (const name of Object.keys(block)) into.add(name);
377
+ }
378
+ function parsePnpmPackageKey(key) {
379
+ let working = key.startsWith("/") ? key.slice(1) : key;
380
+ const parenIdx = working.indexOf("(");
381
+ if (parenIdx !== -1) working = working.slice(0, parenIdx);
382
+ const startSearch = working.startsWith("@") ? 1 : 0;
383
+ const atIdx = working.indexOf("@", startSearch);
384
+ if (atIdx <= 0) return null;
385
+ const name = working.slice(0, atIdx);
386
+ const version = working.slice(atIdx + 1);
387
+ if (!name || !version) return null;
388
+ return { name, version };
389
+ }
390
+
391
+ // src/extractors/yarn-lock.ts
392
+ import { existsSync, readFileSync as readFileSync3 } from "fs";
393
+ import { dirname, join, resolve as resolve3 } from "path";
394
+ import yaml2 from "js-yaml";
395
+ function parseYarnLock(filePath) {
396
+ const absolute = resolve3(filePath);
397
+ const content = readFileSync3(absolute, "utf8");
398
+ const projectDir = dirname(absolute);
399
+ const directs = readDirectDepsFromPackageJson(projectDir);
400
+ const isBerry = /^__metadata:/m.test(content);
401
+ return isBerry ? parseBerry(absolute, content, directs) : parseClassic(absolute, content, directs);
402
+ }
403
+ function readDirectDepsFromPackageJson(projectDir) {
404
+ const result = {
405
+ prod: /* @__PURE__ */ new Set(),
406
+ dev: /* @__PURE__ */ new Set(),
407
+ optional: /* @__PURE__ */ new Set(),
408
+ any: /* @__PURE__ */ new Set()
409
+ };
410
+ const pkgPath = join(projectDir, "package.json");
411
+ if (!existsSync(pkgPath)) return result;
412
+ try {
413
+ const pkg = JSON.parse(readFileSync3(pkgPath, "utf8"));
414
+ for (const name of Object.keys(pkg.dependencies ?? {})) result.prod.add(name);
415
+ for (const name of Object.keys(pkg.devDependencies ?? {})) result.dev.add(name);
416
+ for (const name of Object.keys(pkg.optionalDependencies ?? {}))
417
+ result.optional.add(name);
418
+ for (const set of [result.prod, result.dev, result.optional]) {
419
+ for (const n of set) result.any.add(n);
420
+ }
421
+ for (const name of Object.keys(pkg.peerDependencies ?? {})) result.any.add(name);
422
+ } catch {
423
+ }
424
+ return result;
425
+ }
426
+ function parseClassic(absolute, content, directs) {
427
+ const entries = parseClassicEntries(content);
428
+ const instances = [];
429
+ for (const entry of entries) {
430
+ const version = entry.fields.version;
431
+ if (!version) continue;
432
+ const names = uniq(entry.specs.map((s) => parseYarnSpec(s).name).filter(Boolean));
433
+ const name = names[0];
434
+ if (!name) continue;
435
+ const isDirect = names.some((n) => directs.any.has(n));
436
+ const inProd = names.some((n) => directs.prod.has(n));
437
+ const inDev = names.some((n) => directs.dev.has(n));
438
+ const inOpt = names.some((n) => directs.optional.has(n));
439
+ instances.push({
440
+ name,
441
+ version,
442
+ ecosystem: "npm",
443
+ path: `${name}@${version}`,
444
+ direct: isDirect,
445
+ dev: inDev && !inProd,
446
+ optional: inOpt && !inProd && !inDev,
447
+ resolved: entry.fields.resolved,
448
+ integrity: entry.fields.integrity
449
+ });
450
+ }
451
+ void absolute;
452
+ return instances;
453
+ }
454
+ function parseClassicEntries(content) {
455
+ const lines = content.split(/\r?\n/);
456
+ const entries = [];
457
+ let current = null;
458
+ for (const rawLine of lines) {
459
+ if (rawLine === "" || rawLine.trimStart().startsWith("#")) continue;
460
+ if (!/^\s/.test(rawLine)) {
461
+ if (current) entries.push(current);
462
+ const header = rawLine.replace(/:\s*$/, "");
463
+ current = { specs: splitClassicSpecs(header), fields: {} };
464
+ continue;
465
+ }
466
+ if (!current) continue;
467
+ const indent = rawLine.match(/^ +/)?.[0].length ?? 0;
468
+ if (indent !== 2) continue;
469
+ const trimmed = rawLine.trim();
470
+ const m = trimmed.match(/^([^\s"]+)\s+"((?:[^"\\]|\\.)*)"$/) ?? trimmed.match(/^([^\s"]+)\s+(\S+)$/);
471
+ if (m && m[1] !== void 0 && m[2] !== void 0) {
472
+ current.fields[m[1]] = m[2];
473
+ }
474
+ }
475
+ if (current) entries.push(current);
476
+ return entries;
477
+ }
478
+ function splitClassicSpecs(header) {
479
+ const out = [];
480
+ let cur = "";
481
+ let inQuote = false;
482
+ for (const ch of header) {
483
+ if (ch === '"') {
484
+ inQuote = !inQuote;
485
+ continue;
486
+ }
487
+ if (ch === "," && !inQuote) {
488
+ const spec = cur.trim();
489
+ if (spec) out.push(spec);
490
+ cur = "";
491
+ continue;
492
+ }
493
+ cur += ch;
494
+ }
495
+ const last = cur.trim();
496
+ if (last) out.push(last);
497
+ return out;
498
+ }
499
+ function parseBerry(absolute, content, directs) {
500
+ let parsed;
501
+ try {
502
+ parsed = yaml2.load(content) ?? {};
503
+ } catch (err) {
504
+ throw new Error(
505
+ `Failed to parse ${absolute}: ${err.message}`
506
+ );
507
+ }
508
+ const instances = [];
509
+ for (const [key, value] of Object.entries(parsed)) {
510
+ if (key === "__metadata") continue;
511
+ if (!value || typeof value !== "object") continue;
512
+ const entry = value;
513
+ if (!entry.version) continue;
514
+ const specs = splitClassicSpecs(key);
515
+ const names = uniq(specs.map((s) => parseYarnSpec(s).name).filter(Boolean));
516
+ const name = names[0];
517
+ if (!name) continue;
518
+ const isDirect = names.some((n) => directs.any.has(n));
519
+ const inProd = names.some((n) => directs.prod.has(n));
520
+ const inDev = names.some((n) => directs.dev.has(n));
521
+ const inOpt = names.some((n) => directs.optional.has(n));
522
+ instances.push({
523
+ name,
524
+ version: entry.version,
525
+ ecosystem: "npm",
526
+ path: `${name}@${entry.version}`,
527
+ direct: isDirect,
528
+ dev: inDev && !inProd,
529
+ optional: inOpt && !inProd && !inDev,
530
+ resolved: entry.resolution,
531
+ integrity: entry.checksum
532
+ });
533
+ }
534
+ return instances;
535
+ }
536
+ function parseYarnSpec(spec) {
537
+ const startSearch = spec.startsWith("@") ? 1 : 0;
538
+ const atIdx = spec.indexOf("@", startSearch);
539
+ if (atIdx <= 0) return { name: spec, selector: "" };
540
+ const name = spec.slice(0, atIdx);
541
+ let selector = spec.slice(atIdx + 1);
542
+ if (selector.startsWith("npm:")) selector = selector.slice(4);
543
+ return { name, selector };
544
+ }
545
+ function uniq(values) {
546
+ return Array.from(new Set(values));
547
+ }
548
+
293
549
  // src/types.ts
294
550
  var SEVERITY_RANK = {
295
551
  critical: 4,
@@ -301,11 +557,11 @@ var SEVERITY_RANK = {
301
557
 
302
558
  // src/scanner.ts
303
559
  async function scanProject(options = {}) {
304
- const cwd = resolve2(options.cwd ?? process.cwd());
305
- const lockfilePath = options.lockfile ? resolve2(cwd, options.lockfile) : detectLockfile(cwd);
560
+ const cwd = resolve4(options.cwd ?? process.cwd());
561
+ const lockfilePath = options.lockfile ? resolve4(cwd, options.lockfile) : detectLockfile(cwd);
306
562
  if (!lockfilePath) {
307
563
  throw new ScanInputError(
308
- `No npm lockfile found in ${cwd}. Pass --lockfile or run in a directory with package-lock.json.`
564
+ `No supported lockfile found in ${cwd}. Pass --lockfile or run in a directory with package-lock.json, pnpm-lock.yaml, or yarn.lock.`
309
565
  );
310
566
  }
311
567
  return scanLockfile({
@@ -317,14 +573,14 @@ async function scanProject(options = {}) {
317
573
  }
318
574
  async function scanLockfile(options) {
319
575
  const { lockfilePath } = options;
320
- if (!existsSync(lockfilePath)) {
576
+ if (!existsSync2(lockfilePath)) {
321
577
  throw new ScanInputError(`Lockfile does not exist: ${lockfilePath}`);
322
578
  }
323
579
  const stat = statSync(lockfilePath);
324
580
  if (!stat.isFile()) {
325
581
  throw new ScanInputError(`Lockfile path is not a file: ${lockfilePath}`);
326
582
  }
327
- const allInstances = parseNpmPackageLock(lockfilePath);
583
+ const allInstances = parseLockfile(lockfilePath);
328
584
  const instances = filterInstances(allInstances, options);
329
585
  const errors = [];
330
586
  let findings = [];
@@ -351,9 +607,29 @@ var ScanInputError = class extends Error {
351
607
  this.name = "ScanInputError";
352
608
  }
353
609
  };
610
+ var LOCKFILE_CANDIDATES = [
611
+ "pnpm-lock.yaml",
612
+ "yarn.lock",
613
+ "package-lock.json",
614
+ "npm-shrinkwrap.json"
615
+ ];
354
616
  function detectLockfile(cwd) {
355
- const candidate = join(cwd, "package-lock.json");
356
- return existsSync(candidate) ? candidate : void 0;
617
+ for (const file of LOCKFILE_CANDIDATES) {
618
+ const candidate = join2(cwd, file);
619
+ if (existsSync2(candidate)) return candidate;
620
+ }
621
+ return void 0;
622
+ }
623
+ function parseLockfile(lockfilePath) {
624
+ const name = basename(lockfilePath);
625
+ if (name === "pnpm-lock.yaml") return parsePnpmLock(lockfilePath);
626
+ if (name === "yarn.lock") return parseYarnLock(lockfilePath);
627
+ if (name === "package-lock.json" || name === "npm-shrinkwrap.json") {
628
+ return parseNpmPackageLock(lockfilePath);
629
+ }
630
+ throw new ScanInputError(
631
+ `Unsupported lockfile name: ${name}. Supported: package-lock.json, npm-shrinkwrap.json, pnpm-lock.yaml, yarn.lock.`
632
+ );
357
633
  }
358
634
  function filterInstances(instances, options) {
359
635
  const includeDev = options.prodOnly ? false : options.includeDev !== false;
@@ -389,8 +665,8 @@ function meetsThreshold(findings, threshold) {
389
665
  }
390
666
 
391
667
  // src/installer/pm-detect.ts
392
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
393
- import { join as join2 } from "path";
668
+ import { existsSync as existsSync3, readFileSync as readFileSync4 } from "fs";
669
+ import { join as join3 } from "path";
394
670
  var LOCKFILES = [
395
671
  { file: "pnpm-lock.yaml", pm: "pnpm" },
396
672
  { file: "yarn.lock", pm: "yarn" },
@@ -405,15 +681,15 @@ function detectPackageManager(opts = {}) {
405
681
  const fromField = readPackageManagerField(cwd);
406
682
  if (fromField) return fromField;
407
683
  for (const { file, pm } of LOCKFILES) {
408
- if (existsSync2(join2(cwd, file))) return pm;
684
+ if (existsSync3(join3(cwd, file))) return pm;
409
685
  }
410
686
  return "npm";
411
687
  }
412
688
  function readPackageManagerField(cwd) {
413
- const pkgPath = join2(cwd, "package.json");
414
- if (!existsSync2(pkgPath)) return void 0;
689
+ const pkgPath = join3(cwd, "package.json");
690
+ if (!existsSync3(pkgPath)) return void 0;
415
691
  try {
416
- const pkg = JSON.parse(readFileSync2(pkgPath, "utf8"));
692
+ const pkg = JSON.parse(readFileSync4(pkgPath, "utf8"));
417
693
  if (typeof pkg.packageManager !== "string") return void 0;
418
694
  const name = pkg.packageManager.split("@")[0];
419
695
  if (name === "npm" || name === "pnpm" || name === "yarn" || name === "bun") {
@@ -454,14 +730,14 @@ function buildRemoveCommand(pm, packages, flags) {
454
730
  // src/installer/runner.ts
455
731
  import { spawn } from "child_process";
456
732
  function runPackageManager(cmd, opts = {}) {
457
- return new Promise((resolve3, reject) => {
733
+ return new Promise((resolve5, reject) => {
458
734
  const child = spawn(cmd.bin, cmd.args, {
459
735
  cwd: opts.cwd,
460
736
  stdio: "inherit",
461
737
  shell: process.platform === "win32"
462
738
  });
463
739
  child.on("error", reject);
464
- child.on("close", (code) => resolve3(code ?? 0));
740
+ child.on("close", (code) => resolve5(code ?? 0));
465
741
  });
466
742
  }
467
743
 
@@ -1109,7 +1385,10 @@ program.name("trawly").description(
1109
1385
  });
1110
1386
  program.command("scan", { isDefault: true }).description(
1111
1387
  "Scan a project and gate on findings. Exits non-zero when --fail-on is met. Use `inspect` for a log-only run."
1112
- ).argument("[path]", "Project directory to scan", ".").option("--lockfile <path>", "Explicit path to package-lock.json").option(
1388
+ ).argument("[path]", "Project directory to scan", ".").option(
1389
+ "--lockfile <path>",
1390
+ "Explicit path to a lockfile (package-lock.json, pnpm-lock.yaml, or yarn.lock)"
1391
+ ).option(
1113
1392
  "--format <format>",
1114
1393
  "Output format: table | json",
1115
1394
  parseFormat,
@@ -1130,7 +1409,10 @@ program.command("scan", { isDefault: true }).description(
1130
1409
  });
1131
1410
  program.command("inspect").description(
1132
1411
  "Scan a project and print findings without gating. Always exits 0 unless an operational error occurs. Use `scan` for CI gating."
1133
- ).argument("[path]", "Project directory to scan", ".").option("--lockfile <path>", "Explicit path to package-lock.json").option(
1412
+ ).argument("[path]", "Project directory to scan", ".").option(
1413
+ "--lockfile <path>",
1414
+ "Explicit path to a lockfile (package-lock.json, pnpm-lock.yaml, or yarn.lock)"
1415
+ ).option(
1134
1416
  "--format <format>",
1135
1417
  "Output format: table | json",
1136
1418
  parseFormat,