Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 191 additions & 42 deletions bin/flatcover.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@

import { parseArgs } from 'node:util';
import { readFileSync } from 'node:fs';
import { createReadStream } from 'node:fs';
import { createInterface } from 'node:readline';
import { dirname, join } from 'node:path';
import { Pool, RetryAgent } from 'undici';
import { FlatlockSet } from '../src/set.js';

const { values, positionals } = parseArgs({
options: {
workspace: { type: 'string', short: 'w' },
list: { type: 'string', short: 'l' },
dev: { type: 'boolean', default: false },
peer: { type: 'boolean', default: true },
specs: { type: 'boolean', short: 's', default: false },
Expand All @@ -39,16 +42,28 @@ const { values, positionals } = parseArgs({
allowPositionals: true
});

if (values.help || positionals.length === 0) {
// Check if stdin input is requested via '-' positional argument (Unix convention)
const useStdin = positionals[0] === '-';

// Determine if we have a valid input source
const hasInputSource = positionals.length > 0 || values.list;

if (values.help || !hasInputSource) {
console.log(`flatcover - Check lockfile package coverage against a registry

Usage:
flatcover <lockfile> --cover
flatcover <lockfile> --cover --registry <url>
flatcover --list packages.json --cover
cat packages.ndjson | flatcover - --cover
flatcover <lockfile> --cover --registry <url> --auth user:pass

Input sources (mutually exclusive):
<lockfile> Parse lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock)
-l, --list <file> Read JSON array of {name, version} objects from file
- Read NDJSON {name, version} objects from stdin (one per line)

Options:
-w, --workspace <path> Workspace path within monorepo
-w, --workspace <path> Workspace path within monorepo (lockfile mode only)
-s, --specs Include version (name@version or {name,version})
--json Output as JSON array
--ndjson Output as newline-delimited JSON (streaming)
Expand All @@ -68,14 +83,27 @@ Coverage options:

Output formats (with --cover):
(default) CSV: package,version,present
--full CSV: package,version,present,integrity,resolved
--json [{"name":"...","version":"...","present":true}, ...]
--full --json Adds "integrity" and "resolved" fields to JSON
--ndjson {"name":"...","version":"...","present":true} per line

Examples:
# From lockfile
flatcover package-lock.json --cover
flatcover package-lock.json --cover --full --json

# From JSON list file
flatcover --list packages.json --cover --summary
echo '[{"name":"lodash","version":"4.17.21"}]' > pkgs.json && flatcover -l pkgs.json --cover

# From stdin (NDJSON) - use '-' to read from stdin
echo '{"name":"lodash","version":"4.17.21"}' | flatcover - --cover
cat packages.ndjson | flatcover - --cover --json

# With custom registry
flatcover package-lock.json --cover --registry https://npm.pkg.github.com --token ghp_xxx
flatcover pnpm-lock.yaml --cover --auth admin:secret --ndjson
flatcover pnpm-lock.yaml -w packages/core --cover --summary`);
flatcover pnpm-lock.yaml --cover --auth admin:secret --ndjson`);
process.exit(values.help ? 0 : 1);
}

Expand All @@ -89,6 +117,19 @@ if (values.auth && values.token) {
process.exit(1);
}

// Validate mutually exclusive input sources
// Note: useStdin means positionals[0] === '-', so it's already counted in positionals.length
if (positionals.length > 0 && values.list) {
console.error('Error: Cannot use both lockfile/stdin and --list');
process.exit(1);
}

// --workspace only works with lockfile input (not stdin or --list)
if (values.workspace && (useStdin || values.list || !positionals.length)) {
console.error('Error: --workspace can only be used with lockfile input');
process.exit(1);
}

// --full implies --specs
if (values.full) {
values.specs = true;
Expand All @@ -102,6 +143,70 @@ if (values.cover) {
const lockfilePath = positionals[0];
const concurrency = Math.max(1, Math.min(50, Number.parseInt(values.concurrency, 10) || 20));

/**
* Read packages from a JSON list file
* @param {string} filePath - Path to JSON file containing [{name, version}, ...]
* @returns {Array<{ name: string, version: string }>}
*/
function readJsonList(filePath) {
const content = readFileSync(filePath, 'utf8');
const data = JSON.parse(content);

if (!Array.isArray(data)) {
throw new Error('--list file must contain a JSON array');
}

const packages = [];
for (const item of data) {
if (!item.name || !item.version) {
throw new Error('Each item in --list must have "name" and "version" fields');
}
packages.push({
name: item.name,
version: item.version,
integrity: item.integrity,
resolved: item.resolved
});
}

return packages;
}

/**
* Read packages from stdin as NDJSON
* @returns {Promise<Array<{ name: string, version: string }>>}
*/
async function readStdinNdjson() {
const packages = [];

const rl = createInterface({
input: process.stdin,
crlfDelay: Infinity
});

for await (const line of rl) {
const trimmed = line.trim();
if (!trimmed) continue;

try {
const item = JSON.parse(trimmed);
if (!item.name || !item.version) {
throw new Error('Each line must have "name" and "version" fields');
}
packages.push({
name: item.name,
version: item.version,
integrity: item.integrity,
resolved: item.resolved
});
} catch (err) {
throw new Error(`Invalid JSON on stdin: ${err.message}`);
}
}

return packages;
}

/**
* Encode package name for URL (handle scoped packages)
* @param {string} name - Package name like @babel/core
Expand Down Expand Up @@ -161,21 +266,22 @@ function createClient(registryUrl, { auth, token }) {

/**
* Check coverage for all dependencies
* @param {Array<{ name: string, version: string }>} deps
* @param {Array<{ name: string, version: string, integrity?: string, resolved?: string }>} deps
* @param {{ registry: string, auth?: string, token?: string, progress: boolean }} options
* @returns {AsyncGenerator<{ name: string, version: string, present: boolean, error?: string }>}
* @returns {AsyncGenerator<{ name: string, version: string, present: boolean, integrity?: string, resolved?: string, error?: string }>}
*/
async function* checkCoverage(deps, { registry, auth, token, progress }) {
const { client, headers, baseUrl } = createClient(registry, { auth, token });

// Group by package name to avoid duplicate requests
/** @type {Map<string, Set<string>>} */
// Store full dep info (including integrity/resolved) keyed by version
/** @type {Map<string, Map<string, { name: string, version: string, integrity?: string, resolved?: string }>>} */
const byPackage = new Map();
for (const dep of deps) {
if (!byPackage.has(dep.name)) {
byPackage.set(dep.name, new Set());
byPackage.set(dep.name, new Map());
}
byPackage.get(dep.name).add(dep.version);
byPackage.get(dep.name).set(dep.version, dep);
}

const packages = [...byPackage.entries()];
Expand All @@ -187,7 +293,7 @@ async function* checkCoverage(deps, { registry, auth, token, progress }) {
const batch = packages.slice(i, i + concurrency);

const results = await Promise.all(
batch.map(async ([name, versions]) => {
batch.map(async ([name, versionMap]) => {
const encodedName = encodePackageName(name);
const basePath = baseUrl.pathname.replace(/\/$/, '');
const path = `${basePath}/${encodedName}`;
Expand Down Expand Up @@ -216,21 +322,29 @@ async function* checkCoverage(deps, { registry, auth, token, progress }) {
packumentVersions = packument.versions || {};
}

// Check each version
// Check each version, preserving integrity/resolved from original dep
const versionResults = [];
for (const version of versions) {
for (const [version, dep] of versionMap) {
const present = packumentVersions ? !!packumentVersions[version] : false;
versionResults.push({ name, version, present });
const result = { name, version, present };
if (dep.integrity) result.integrity = dep.integrity;
if (dep.resolved) result.resolved = dep.resolved;
versionResults.push(result);
}
return versionResults;
} catch (err) {
// Return error for all versions of this package
return [...versions].map(version => ({
name,
version,
present: false,
error: err.message
}));
return [...versionMap.values()].map(dep => {
const result = {
name: dep.name,
version: dep.version,
present: false,
error: err.message
};
if (dep.integrity) result.integrity = dep.integrity;
if (dep.resolved) result.resolved = dep.resolved;
return result;
});
}
})
);
Expand Down Expand Up @@ -300,10 +414,10 @@ function outputDeps(deps, { specs, json, ndjson, full }) {

/**
* Output coverage results
* @param {AsyncGenerator<{ name: string, version: string, present: boolean, error?: string }>} results
* @param {{ json: boolean, ndjson: boolean, summary: boolean }} options
* @param {AsyncGenerator<{ name: string, version: string, present: boolean, integrity?: string, resolved?: string, error?: string }>} results
* @param {{ json: boolean, ndjson: boolean, summary: boolean, full: boolean }} options
*/
async function outputCoverage(results, { json, ndjson, summary }) {
async function outputCoverage(results, { json, ndjson, summary, full }) {
const all = [];
let presentCount = 0;
let missingCount = 0;
Expand All @@ -317,7 +431,10 @@ async function outputCoverage(results, { json, ndjson, summary }) {

if (ndjson) {
// Stream immediately
console.log(JSON.stringify({ name: result.name, version: result.version, present: result.present }));
const obj = { name: result.name, version: result.version, present: result.present };
if (full && result.integrity) obj.integrity = result.integrity;
if (full && result.resolved) obj.resolved = result.resolved;
console.log(JSON.stringify(obj));
} else {
all.push(result);
}
Expand All @@ -328,13 +445,25 @@ async function outputCoverage(results, { json, ndjson, summary }) {
all.sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version));

if (json) {
const data = all.map(r => ({ name: r.name, version: r.version, present: r.present }));
const data = all.map(r => {
const obj = { name: r.name, version: r.version, present: r.present };
if (full && r.integrity) obj.integrity = r.integrity;
if (full && r.resolved) obj.resolved = r.resolved;
return obj;
});
console.log(JSON.stringify(data, null, 2));
} else {
// CSV output
console.log('package,version,present');
for (const r of all) {
console.log(`${r.name},${r.version},${r.present}`);
if (full) {
console.log('package,version,present,integrity,resolved');
for (const r of all) {
console.log(`${r.name},${r.version},${r.present},${r.integrity || ''},${r.resolved || ''}`);
}
} else {
console.log('package,version,present');
for (const r of all) {
console.log(`${r.name},${r.version},${r.present}`);
}
}
}
}
Expand All @@ -350,22 +479,41 @@ async function outputCoverage(results, { json, ndjson, summary }) {
}

try {
const lockfile = await FlatlockSet.fromPath(lockfilePath);
let deps;

if (values.workspace) {
const repoDir = dirname(lockfilePath);
const workspacePkgPath = join(repoDir, values.workspace, 'package.json');
const workspacePkg = JSON.parse(readFileSync(workspacePkgPath, 'utf8'));

deps = await lockfile.dependenciesOf(workspacePkg, {
workspacePath: values.workspace,
repoDir,
dev: values.dev,
peer: values.peer
});
// Determine input source and load dependencies
if (useStdin) {
// Read from stdin (NDJSON)
deps = await readStdinNdjson();
if (deps.length === 0) {
console.error('Error: No packages read from stdin');
process.exit(1);
}
} else if (values.list) {
// Read from JSON list file
deps = readJsonList(values.list);
if (deps.length === 0) {
console.error('Error: No packages found in --list file');
process.exit(1);
}
} else {
deps = lockfile;
// Read from lockfile (existing behavior)
const lockfile = await FlatlockSet.fromPath(lockfilePath);

if (values.workspace) {
const repoDir = dirname(lockfilePath);
const workspacePkgPath = join(repoDir, values.workspace, 'package.json');
const workspacePkg = JSON.parse(readFileSync(workspacePkgPath, 'utf8'));

deps = await lockfile.dependenciesOf(workspacePkg, {
workspacePath: values.workspace,
repoDir,
dev: values.dev,
peer: values.peer
});
} else {
deps = lockfile;
}
}

if (values.cover) {
Expand All @@ -381,7 +529,8 @@ try {
await outputCoverage(results, {
json: values.json,
ndjson: values.ndjson,
summary: values.summary
summary: values.summary,
full: values.full
});
} else {
// Standard flatlock mode
Expand Down
Loading