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 +301 -19
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +12 -1
- package/dist/index.js +287 -9
- package/dist/index.js.map +1 -1
- package/package.json +3 -1
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
|
|
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 =
|
|
305
|
-
const lockfilePath = options.lockfile ?
|
|
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
|
|
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 (!
|
|
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 =
|
|
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
|
|
356
|
-
|
|
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
|
|
393
|
-
import { join as
|
|
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 (
|
|
684
|
+
if (existsSync3(join3(cwd, file))) return pm;
|
|
409
685
|
}
|
|
410
686
|
return "npm";
|
|
411
687
|
}
|
|
412
688
|
function readPackageManagerField(cwd) {
|
|
413
|
-
const pkgPath =
|
|
414
|
-
if (!
|
|
689
|
+
const pkgPath = join3(cwd, "package.json");
|
|
690
|
+
if (!existsSync3(pkgPath)) return void 0;
|
|
415
691
|
try {
|
|
416
|
-
const pkg = JSON.parse(
|
|
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((
|
|
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) =>
|
|
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(
|
|
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(
|
|
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,
|