1414
1515import { parseArgs } from 'node:util' ;
1616import { readFileSync } from 'node:fs' ;
17+ import { createReadStream } from 'node:fs' ;
18+ import { createInterface } from 'node:readline' ;
1719import { dirname , join } from 'node:path' ;
1820import { Pool , RetryAgent } from 'undici' ;
1921import { FlatlockSet } from '../src/set.js' ;
2022
2123const { values, positionals } = parseArgs ( {
2224 options : {
2325 workspace : { type : 'string' , short : 'w' } ,
26+ list : { type : 'string' , short : 'l' } ,
2427 dev : { type : 'boolean' , default : false } ,
2528 peer : { type : 'boolean' , default : true } ,
2629 specs : { type : 'boolean' , short : 's' , default : false } ,
@@ -39,16 +42,28 @@ const { values, positionals } = parseArgs({
3942 allowPositionals : true
4043} ) ;
4144
42- if ( values . help || positionals . length === 0 ) {
45+ // Check if stdin input is requested via '-' positional argument (Unix convention)
46+ const useStdin = positionals [ 0 ] === '-' ;
47+
48+ // Determine if we have a valid input source
49+ const hasInputSource = positionals . length > 0 || values . list ;
50+
51+ if ( values . help || ! hasInputSource ) {
4352 console . log ( `flatcover - Check lockfile package coverage against a registry
4453
4554Usage:
4655 flatcover <lockfile> --cover
47- flatcover <lockfile> --cover --registry <url>
56+ flatcover --list packages.json --cover
57+ cat packages.ndjson | flatcover - --cover
4858 flatcover <lockfile> --cover --registry <url> --auth user:pass
4959
60+ Input sources (mutually exclusive):
61+ <lockfile> Parse lockfile (package-lock.json, pnpm-lock.yaml, yarn.lock)
62+ -l, --list <file> Read JSON array of {name, version} objects from file
63+ - Read NDJSON {name, version} objects from stdin (one per line)
64+
5065Options:
51- -w, --workspace <path> Workspace path within monorepo
66+ -w, --workspace <path> Workspace path within monorepo (lockfile mode only)
5267 -s, --specs Include version (name@version or {name,version})
5368 --json Output as JSON array
5469 --ndjson Output as newline-delimited JSON (streaming)
@@ -68,14 +83,27 @@ Coverage options:
6883
6984Output formats (with --cover):
7085 (default) CSV: package,version,present
86+ --full CSV: package,version,present,integrity,resolved
7187 --json [{"name":"...","version":"...","present":true}, ...]
88+ --full --json Adds "integrity" and "resolved" fields to JSON
7289 --ndjson {"name":"...","version":"...","present":true} per line
7390
7491Examples:
92+ # From lockfile
7593 flatcover package-lock.json --cover
94+ flatcover package-lock.json --cover --full --json
95+
96+ # From JSON list file
97+ flatcover --list packages.json --cover --summary
98+ echo '[{"name":"lodash","version":"4.17.21"}]' > pkgs.json && flatcover -l pkgs.json --cover
99+
100+ # From stdin (NDJSON) - use '-' to read from stdin
101+ echo '{"name":"lodash","version":"4.17.21"}' | flatcover - --cover
102+ cat packages.ndjson | flatcover - --cover --json
103+
104+ # With custom registry
76105 flatcover package-lock.json --cover --registry https://npm.pkg.github.com --token ghp_xxx
77- flatcover pnpm-lock.yaml --cover --auth admin:secret --ndjson
78- flatcover pnpm-lock.yaml -w packages/core --cover --summary` ) ;
106+ flatcover pnpm-lock.yaml --cover --auth admin:secret --ndjson` ) ;
79107 process . exit ( values . help ? 0 : 1 ) ;
80108}
81109
@@ -89,6 +117,19 @@ if (values.auth && values.token) {
89117 process . exit ( 1 ) ;
90118}
91119
120+ // Validate mutually exclusive input sources
121+ // Note: useStdin means positionals[0] === '-', so it's already counted in positionals.length
122+ if ( positionals . length > 0 && values . list ) {
123+ console . error ( 'Error: Cannot use both lockfile/stdin and --list' ) ;
124+ process . exit ( 1 ) ;
125+ }
126+
127+ // --workspace only works with lockfile input (not stdin or --list)
128+ if ( values . workspace && ( useStdin || values . list || ! positionals . length ) ) {
129+ console . error ( 'Error: --workspace can only be used with lockfile input' ) ;
130+ process . exit ( 1 ) ;
131+ }
132+
92133// --full implies --specs
93134if ( values . full ) {
94135 values . specs = true ;
@@ -102,6 +143,70 @@ if (values.cover) {
102143const lockfilePath = positionals [ 0 ] ;
103144const concurrency = Math . max ( 1 , Math . min ( 50 , Number . parseInt ( values . concurrency , 10 ) || 20 ) ) ;
104145
146+ /**
147+ * Read packages from a JSON list file
148+ * @param {string } filePath - Path to JSON file containing [{name, version}, ...]
149+ * @returns {Array<{ name: string, version: string }> }
150+ */
151+ function readJsonList ( filePath ) {
152+ const content = readFileSync ( filePath , 'utf8' ) ;
153+ const data = JSON . parse ( content ) ;
154+
155+ if ( ! Array . isArray ( data ) ) {
156+ throw new Error ( '--list file must contain a JSON array' ) ;
157+ }
158+
159+ const packages = [ ] ;
160+ for ( const item of data ) {
161+ if ( ! item . name || ! item . version ) {
162+ throw new Error ( 'Each item in --list must have "name" and "version" fields' ) ;
163+ }
164+ packages . push ( {
165+ name : item . name ,
166+ version : item . version ,
167+ integrity : item . integrity ,
168+ resolved : item . resolved
169+ } ) ;
170+ }
171+
172+ return packages ;
173+ }
174+
175+ /**
176+ * Read packages from stdin as NDJSON
177+ * @returns {Promise<Array<{ name: string, version: string }>> }
178+ */
179+ async function readStdinNdjson ( ) {
180+ const packages = [ ] ;
181+
182+ const rl = createInterface ( {
183+ input : process . stdin ,
184+ crlfDelay : Infinity
185+ } ) ;
186+
187+ for await ( const line of rl ) {
188+ const trimmed = line . trim ( ) ;
189+ if ( ! trimmed ) continue ;
190+
191+ try {
192+ const item = JSON . parse ( trimmed ) ;
193+ if ( ! item . name || ! item . version ) {
194+ throw new Error ( 'Each line must have "name" and "version" fields' ) ;
195+ }
196+ packages . push ( {
197+ name : item . name ,
198+ version : item . version ,
199+ integrity : item . integrity ,
200+ resolved : item . resolved
201+ } ) ;
202+ } catch ( err ) {
203+ throw new Error ( `Invalid JSON on stdin: ${ err . message } ` ) ;
204+ }
205+ }
206+
207+ return packages ;
208+ }
209+
105210/**
106211 * Encode package name for URL (handle scoped packages)
107212 * @param {string } name - Package name like @babel/core
@@ -161,21 +266,22 @@ function createClient(registryUrl, { auth, token }) {
161266
162267/**
163268 * Check coverage for all dependencies
164- * @param {Array<{ name: string, version: string }> } deps
269+ * @param {Array<{ name: string, version: string, integrity?: string, resolved?: string }> } deps
165270 * @param {{ registry: string, auth?: string, token?: string, progress: boolean } } options
166- * @returns {AsyncGenerator<{ name: string, version: string, present: boolean, error?: string }> }
271+ * @returns {AsyncGenerator<{ name: string, version: string, present: boolean, integrity?: string, resolved?: string, error?: string }> }
167272 */
168273async function * checkCoverage ( deps , { registry, auth, token, progress } ) {
169274 const { client, headers, baseUrl } = createClient ( registry , { auth, token } ) ;
170275
171276 // Group by package name to avoid duplicate requests
172- /** @type {Map<string, Set<string>> } */
277+ // Store full dep info (including integrity/resolved) keyed by version
278+ /** @type {Map<string, Map<string, { name: string, version: string, integrity?: string, resolved?: string }>> } */
173279 const byPackage = new Map ( ) ;
174280 for ( const dep of deps ) {
175281 if ( ! byPackage . has ( dep . name ) ) {
176- byPackage . set ( dep . name , new Set ( ) ) ;
282+ byPackage . set ( dep . name , new Map ( ) ) ;
177283 }
178- byPackage . get ( dep . name ) . add ( dep . version ) ;
284+ byPackage . get ( dep . name ) . set ( dep . version , dep ) ;
179285 }
180286
181287 const packages = [ ...byPackage . entries ( ) ] ;
@@ -187,7 +293,7 @@ async function* checkCoverage(deps, { registry, auth, token, progress }) {
187293 const batch = packages . slice ( i , i + concurrency ) ;
188294
189295 const results = await Promise . all (
190- batch . map ( async ( [ name , versions ] ) => {
296+ batch . map ( async ( [ name , versionMap ] ) => {
191297 const encodedName = encodePackageName ( name ) ;
192298 const basePath = baseUrl . pathname . replace ( / \/ $ / , '' ) ;
193299 const path = `${ basePath } /${ encodedName } ` ;
@@ -216,21 +322,29 @@ async function* checkCoverage(deps, { registry, auth, token, progress }) {
216322 packumentVersions = packument . versions || { } ;
217323 }
218324
219- // Check each version
325+ // Check each version, preserving integrity/resolved from original dep
220326 const versionResults = [ ] ;
221- for ( const version of versions ) {
327+ for ( const [ version , dep ] of versionMap ) {
222328 const present = packumentVersions ? ! ! packumentVersions [ version ] : false ;
223- versionResults . push ( { name, version, present } ) ;
329+ const result = { name, version, present } ;
330+ if ( dep . integrity ) result . integrity = dep . integrity ;
331+ if ( dep . resolved ) result . resolved = dep . resolved ;
332+ versionResults . push ( result ) ;
224333 }
225334 return versionResults ;
226335 } catch ( err ) {
227336 // Return error for all versions of this package
228- return [ ...versions ] . map ( version => ( {
229- name,
230- version,
231- present : false ,
232- error : err . message
233- } ) ) ;
337+ return [ ...versionMap . values ( ) ] . map ( dep => {
338+ const result = {
339+ name : dep . name ,
340+ version : dep . version ,
341+ present : false ,
342+ error : err . message
343+ } ;
344+ if ( dep . integrity ) result . integrity = dep . integrity ;
345+ if ( dep . resolved ) result . resolved = dep . resolved ;
346+ return result ;
347+ } ) ;
234348 }
235349 } )
236350 ) ;
@@ -300,10 +414,10 @@ function outputDeps(deps, { specs, json, ndjson, full }) {
300414
301415/**
302416 * Output coverage results
303- * @param {AsyncGenerator<{ name: string, version: string, present: boolean, error?: string }> } results
304- * @param {{ json: boolean, ndjson: boolean, summary: boolean } } options
417+ * @param {AsyncGenerator<{ name: string, version: string, present: boolean, integrity?: string, resolved?: string, error?: string }> } results
418+ * @param {{ json: boolean, ndjson: boolean, summary: boolean, full: boolean } } options
305419 */
306- async function outputCoverage ( results , { json, ndjson, summary } ) {
420+ async function outputCoverage ( results , { json, ndjson, summary, full } ) {
307421 const all = [ ] ;
308422 let presentCount = 0 ;
309423 let missingCount = 0 ;
@@ -317,7 +431,10 @@ async function outputCoverage(results, { json, ndjson, summary }) {
317431
318432 if ( ndjson ) {
319433 // Stream immediately
320- console . log ( JSON . stringify ( { name : result . name , version : result . version , present : result . present } ) ) ;
434+ const obj = { name : result . name , version : result . version , present : result . present } ;
435+ if ( full && result . integrity ) obj . integrity = result . integrity ;
436+ if ( full && result . resolved ) obj . resolved = result . resolved ;
437+ console . log ( JSON . stringify ( obj ) ) ;
321438 } else {
322439 all . push ( result ) ;
323440 }
@@ -328,13 +445,25 @@ async function outputCoverage(results, { json, ndjson, summary }) {
328445 all . sort ( ( a , b ) => a . name . localeCompare ( b . name ) || a . version . localeCompare ( b . version ) ) ;
329446
330447 if ( json ) {
331- const data = all . map ( r => ( { name : r . name , version : r . version , present : r . present } ) ) ;
448+ const data = all . map ( r => {
449+ const obj = { name : r . name , version : r . version , present : r . present } ;
450+ if ( full && r . integrity ) obj . integrity = r . integrity ;
451+ if ( full && r . resolved ) obj . resolved = r . resolved ;
452+ return obj ;
453+ } ) ;
332454 console . log ( JSON . stringify ( data , null , 2 ) ) ;
333455 } else {
334456 // CSV output
335- console . log ( 'package,version,present' ) ;
336- for ( const r of all ) {
337- console . log ( `${ r . name } ,${ r . version } ,${ r . present } ` ) ;
457+ if ( full ) {
458+ console . log ( 'package,version,present,integrity,resolved' ) ;
459+ for ( const r of all ) {
460+ console . log ( `${ r . name } ,${ r . version } ,${ r . present } ,${ r . integrity || '' } ,${ r . resolved || '' } ` ) ;
461+ }
462+ } else {
463+ console . log ( 'package,version,present' ) ;
464+ for ( const r of all ) {
465+ console . log ( `${ r . name } ,${ r . version } ,${ r . present } ` ) ;
466+ }
338467 }
339468 }
340469 }
@@ -350,22 +479,41 @@ async function outputCoverage(results, { json, ndjson, summary }) {
350479}
351480
352481try {
353- const lockfile = await FlatlockSet . fromPath ( lockfilePath ) ;
354482 let deps ;
355483
356- if ( values . workspace ) {
357- const repoDir = dirname ( lockfilePath ) ;
358- const workspacePkgPath = join ( repoDir , values . workspace , 'package.json' ) ;
359- const workspacePkg = JSON . parse ( readFileSync ( workspacePkgPath , 'utf8' ) ) ;
360-
361- deps = await lockfile . dependenciesOf ( workspacePkg , {
362- workspacePath : values . workspace ,
363- repoDir,
364- dev : values . dev ,
365- peer : values . peer
366- } ) ;
484+ // Determine input source and load dependencies
485+ if ( useStdin ) {
486+ // Read from stdin (NDJSON)
487+ deps = await readStdinNdjson ( ) ;
488+ if ( deps . length === 0 ) {
489+ console . error ( 'Error: No packages read from stdin' ) ;
490+ process . exit ( 1 ) ;
491+ }
492+ } else if ( values . list ) {
493+ // Read from JSON list file
494+ deps = readJsonList ( values . list ) ;
495+ if ( deps . length === 0 ) {
496+ console . error ( 'Error: No packages found in --list file' ) ;
497+ process . exit ( 1 ) ;
498+ }
367499 } else {
368- deps = lockfile ;
500+ // Read from lockfile (existing behavior)
501+ const lockfile = await FlatlockSet . fromPath ( lockfilePath ) ;
502+
503+ if ( values . workspace ) {
504+ const repoDir = dirname ( lockfilePath ) ;
505+ const workspacePkgPath = join ( repoDir , values . workspace , 'package.json' ) ;
506+ const workspacePkg = JSON . parse ( readFileSync ( workspacePkgPath , 'utf8' ) ) ;
507+
508+ deps = await lockfile . dependenciesOf ( workspacePkg , {
509+ workspacePath : values . workspace ,
510+ repoDir,
511+ dev : values . dev ,
512+ peer : values . peer
513+ } ) ;
514+ } else {
515+ deps = lockfile ;
516+ }
369517 }
370518
371519 if ( values . cover ) {
@@ -381,7 +529,8 @@ try {
381529 await outputCoverage ( results , {
382530 json : values . json ,
383531 ndjson : values . ndjson ,
384- summary : values . summary
532+ summary : values . summary ,
533+ full : values . full
385534 } ) ;
386535 } else {
387536 // Standard flatlock mode
0 commit comments