flatlock 1.1.0 → 1.3.0

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/README.md CHANGED
@@ -11,7 +11,7 @@ Most lockfile parsers (like `@npmcli/arborist` or `snyk-nodejs-lockfile-parser`)
11
11
  **flatlock** takes a different approach: it extracts a flat stream of packages from any lockfile format. No trees, no graphs, no edges - just packages.
12
12
 
13
13
  ```javascript
14
- import * as `flatlock`from 'flatlock';
14
+ import * as flatlock from 'flatlock';
15
15
 
16
16
  // Stream packages from any lockfile
17
17
  for await (const pkg of flatlock.fromPath('./package-lock.json')) {
@@ -38,6 +38,29 @@ for await (const pkg of flatlock.fromPath('./package-lock.json')) {
38
38
  - **yarn classic**: yarn.lock v1
39
39
  - **yarn berry**: yarn.lock v2+
40
40
 
41
+ ## CLI Tools
42
+
43
+ Three command-line tools are included for common workflows:
44
+
45
+ ```bash
46
+ # Extract dependencies from any lockfile
47
+ npx flatlock package-lock.json --specs --json
48
+
49
+ # Verify parser accuracy against official tools
50
+ npx flatlock-cmp --dir ./fixtures --glob "**/*lock*"
51
+
52
+ # Check registry availability of all dependencies
53
+ npx flatcover package-lock.json --summary
54
+ ```
55
+
56
+ | Command | Purpose |
57
+ |---------|---------|
58
+ | `flatlock` | Extract dependencies as plain text, JSON, or NDJSON |
59
+ | `flatlock-cmp` | Compare output against @npmcli/arborist, @yarnpkg/lockfile, @pnpm/lockfile.fs |
60
+ | `flatcover` | Verify packages exist on registry (useful for private registry migrations) |
61
+
62
+ Run any command with `--help` for full options.
63
+
41
64
  ## API
42
65
 
43
66
  ```javascript
@@ -89,6 +112,77 @@ Each yielded package has:
89
112
  }
90
113
  ```
91
114
 
115
+ ## FlatlockSet
116
+
117
+ For more advanced use cases, `FlatlockSet` provides Set-like operations on lockfile dependencies:
118
+
119
+ ```javascript
120
+ import { FlatlockSet } from 'flatlock';
121
+
122
+ // Create from lockfile
123
+ const set = await FlatlockSet.fromPath('./package-lock.json');
124
+ console.log(set.size); // 1234
125
+ console.log(set.has('lodash@4.17.21')); // true
126
+
127
+ // Set operations (immutable - return new sets)
128
+ const other = await FlatlockSet.fromPath('./other-lock.json');
129
+ const common = set.intersection(other); // packages in both
130
+ const added = other.difference(set); // packages only in other
131
+ const all = set.union(other); // packages in either
132
+
133
+ // Predicates
134
+ set.isSubsetOf(other); // true if all packages in set are in other
135
+ set.isSupersetOf(other); // true if set contains all packages in other
136
+ set.isDisjointFrom(other); // true if no packages in common
137
+
138
+ // Iterate like a Set
139
+ for (const dep of set) {
140
+ console.log(dep.name, dep.version);
141
+ }
142
+ ```
143
+
144
+ ### Workspace-Specific SBOMs
145
+
146
+ For monorepos, use `dependenciesOf()` to get only the dependencies of a specific workspace:
147
+
148
+ ```javascript
149
+ import { readFile } from 'node:fs/promises';
150
+ import { FlatlockSet } from 'flatlock';
151
+
152
+ const lockfile = await FlatlockSet.fromPath('./package-lock.json');
153
+ const pkg = JSON.parse(await readFile('./packages/api/package.json', 'utf8'));
154
+
155
+ // Get only dependencies reachable from this workspace
156
+ const subset = await lockfile.dependenciesOf(pkg, {
157
+ workspacePath: 'packages/api', // for correct resolution in monorepos
158
+ repoDir: '.', // reads workspace package.json files for accurate traversal
159
+ dev: false, // exclude devDependencies
160
+ optional: true, // include optionalDependencies
161
+ peer: false // exclude peerDependencies
162
+ });
163
+
164
+ console.log(`${pkg.name} has ${subset.size} production dependencies`);
165
+ ```
166
+
167
+ **Note:** Sets created via `union()`, `intersection()`, or `difference()` cannot use `dependenciesOf()` because they lack the raw lockfile data needed for traversal. Check `set.canTraverse` before calling.
168
+
169
+ ## Compare API
170
+
171
+ Verify flatlock output against official package manager parsers:
172
+
173
+ ```javascript
174
+ // Both import styles work - use whichever you prefer
175
+ import { compare } from 'flatlock';
176
+ import { compare } from 'flatlock/compare';
177
+
178
+ const result = await compare('./package-lock.json');
179
+ console.log(result.equinumerous); // true if counts match
180
+ console.log(result.flatlockCount); // packages found by flatlock
181
+ console.log(result.comparisonCount); // packages found by official parser
182
+ ```
183
+
184
+ The dedicated `flatlock/compare` entry point exists for tools that want explicit imports, but the main export works identically.
185
+
92
186
  ## License
93
187
 
94
188
  Apache-2.0
@@ -0,0 +1,398 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * flatcover - Check lockfile package coverage against a registry
4
+ *
5
+ * Checks if packages from a lockfile exist in a npm registry.
6
+ * Outputs CSV by default: package,version,present
7
+ *
8
+ * Usage:
9
+ * flatcover <lockfile> --cover # check against npmjs.org
10
+ * flatcover <lockfile> --cover --registry <url> # custom registry
11
+ * flatcover <lockfile> --cover --registry <url> --auth u:p # with basic auth
12
+ * flatcover <lockfile> --cover --ndjson # streaming output
13
+ */
14
+
15
+ import { parseArgs } from 'node:util';
16
+ import { readFileSync } from 'node:fs';
17
+ import { dirname, join } from 'node:path';
18
+ import { Pool, RetryAgent } from 'undici';
19
+ import { FlatlockSet } from '../src/set.js';
20
+
21
+ const { values, positionals } = parseArgs({
22
+ options: {
23
+ workspace: { type: 'string', short: 'w' },
24
+ dev: { type: 'boolean', default: false },
25
+ peer: { type: 'boolean', default: true },
26
+ specs: { type: 'boolean', short: 's', default: false },
27
+ json: { type: 'boolean', default: false },
28
+ ndjson: { type: 'boolean', default: false },
29
+ full: { type: 'boolean', default: false },
30
+ cover: { type: 'boolean', default: false },
31
+ registry: { type: 'string', default: 'https://registry.npmjs.org' },
32
+ auth: { type: 'string' },
33
+ token: { type: 'string' },
34
+ concurrency: { type: 'string', default: '20' },
35
+ progress: { type: 'boolean', default: false },
36
+ summary: { type: 'boolean', default: false },
37
+ help: { type: 'boolean', short: 'h' }
38
+ },
39
+ allowPositionals: true
40
+ });
41
+
42
+ if (values.help || positionals.length === 0) {
43
+ console.log(`flatcover - Check lockfile package coverage against a registry
44
+
45
+ Usage:
46
+ flatcover <lockfile> --cover
47
+ flatcover <lockfile> --cover --registry <url>
48
+ flatcover <lockfile> --cover --registry <url> --auth user:pass
49
+
50
+ Options:
51
+ -w, --workspace <path> Workspace path within monorepo
52
+ -s, --specs Include version (name@version or {name,version})
53
+ --json Output as JSON array
54
+ --ndjson Output as newline-delimited JSON (streaming)
55
+ --full Include all metadata (integrity, resolved)
56
+ --dev Include dev dependencies (default: false)
57
+ --peer Include peer dependencies (default: true)
58
+ -h, --help Show this help
59
+
60
+ Coverage options:
61
+ --cover Enable registry coverage checking
62
+ --registry <url> Registry URL (default: https://registry.npmjs.org)
63
+ --auth <user:pass> Basic authentication credentials
64
+ --token <token> Bearer token for authentication
65
+ --concurrency <n> Concurrent requests (default: 20)
66
+ --progress Show progress on stderr
67
+ --summary Show coverage summary on stderr
68
+
69
+ Output formats (with --cover):
70
+ (default) CSV: package,version,present
71
+ --json [{"name":"...","version":"...","present":true}, ...]
72
+ --ndjson {"name":"...","version":"...","present":true} per line
73
+
74
+ Examples:
75
+ flatcover package-lock.json --cover
76
+ 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`);
79
+ process.exit(values.help ? 0 : 1);
80
+ }
81
+
82
+ if (values.json && values.ndjson) {
83
+ console.error('Error: --json and --ndjson are mutually exclusive');
84
+ process.exit(1);
85
+ }
86
+
87
+ if (values.auth && values.token) {
88
+ console.error('Error: --auth and --token are mutually exclusive');
89
+ process.exit(1);
90
+ }
91
+
92
+ // --full implies --specs
93
+ if (values.full) {
94
+ values.specs = true;
95
+ }
96
+
97
+ // --cover implies --specs (need versions to check)
98
+ if (values.cover) {
99
+ values.specs = true;
100
+ }
101
+
102
+ const lockfilePath = positionals[0];
103
+ const concurrency = Math.max(1, Math.min(50, Number.parseInt(values.concurrency, 10) || 20));
104
+
105
+ /**
106
+ * Encode package name for URL (handle scoped packages)
107
+ * @param {string} name - Package name like @babel/core
108
+ * @returns {string} URL-safe name like @babel%2fcore
109
+ */
110
+ function encodePackageName(name) {
111
+ // Scoped packages: @scope/name -> @scope%2fname
112
+ return name.replace('/', '%2f');
113
+ }
114
+
115
+ /**
116
+ * Create undici client with retry support
117
+ * @param {string} registryUrl
118
+ * @param {{ auth?: string, token?: string }} options
119
+ * @returns {{ client: RetryAgent, headers: Record<string, string>, baseUrl: URL }}
120
+ */
121
+ function createClient(registryUrl, { auth, token }) {
122
+ const baseUrl = new URL(registryUrl);
123
+
124
+ const pool = new Pool(baseUrl.origin, {
125
+ connections: Math.min(concurrency, 50),
126
+ pipelining: 1, // Conservative - most proxies don't support HTTP pipelining
127
+ keepAliveTimeout: 30000,
128
+ keepAliveMaxTimeout: 60000
129
+ });
130
+
131
+ const client = new RetryAgent(pool, {
132
+ maxRetries: 3,
133
+ minTimeout: 1000,
134
+ maxTimeout: 10000,
135
+ timeoutFactor: 2,
136
+ retryAfter: true, // Respect Retry-After header
137
+ statusCodes: [429, 500, 502, 503, 504],
138
+ errorCodes: [
139
+ 'ECONNRESET',
140
+ 'ECONNREFUSED',
141
+ 'ENOTFOUND',
142
+ 'ENETUNREACH',
143
+ 'ETIMEDOUT',
144
+ 'UND_ERR_SOCKET'
145
+ ]
146
+ });
147
+
148
+ const headers = {
149
+ Accept: 'application/json',
150
+ 'User-Agent': 'flatcover/1.0.0'
151
+ };
152
+
153
+ if (auth) {
154
+ headers.Authorization = `Basic ${Buffer.from(auth).toString('base64')}`;
155
+ } else if (token) {
156
+ headers.Authorization = `Bearer ${token}`;
157
+ }
158
+
159
+ return { client, headers, baseUrl };
160
+ }
161
+
162
+ /**
163
+ * Check coverage for all dependencies
164
+ * @param {Array<{ name: string, version: string }>} deps
165
+ * @param {{ registry: string, auth?: string, token?: string, progress: boolean }} options
166
+ * @returns {AsyncGenerator<{ name: string, version: string, present: boolean, error?: string }>}
167
+ */
168
+ async function* checkCoverage(deps, { registry, auth, token, progress }) {
169
+ const { client, headers, baseUrl } = createClient(registry, { auth, token });
170
+
171
+ // Group by package name to avoid duplicate requests
172
+ /** @type {Map<string, Set<string>>} */
173
+ const byPackage = new Map();
174
+ for (const dep of deps) {
175
+ if (!byPackage.has(dep.name)) {
176
+ byPackage.set(dep.name, new Set());
177
+ }
178
+ byPackage.get(dep.name).add(dep.version);
179
+ }
180
+
181
+ const packages = [...byPackage.entries()];
182
+ let completed = 0;
183
+ const total = packages.length;
184
+
185
+ // Process in batches for bounded concurrency
186
+ for (let i = 0; i < packages.length; i += concurrency) {
187
+ const batch = packages.slice(i, i + concurrency);
188
+
189
+ const results = await Promise.all(
190
+ batch.map(async ([name, versions]) => {
191
+ const encodedName = encodePackageName(name);
192
+ const basePath = baseUrl.pathname.replace(/\/$/, '');
193
+ const path = `${basePath}/${encodedName}`;
194
+
195
+ try {
196
+ const response = await client.request({
197
+ method: 'GET',
198
+ path,
199
+ headers
200
+ });
201
+
202
+ const chunks = [];
203
+ for await (const chunk of response.body) {
204
+ chunks.push(chunk);
205
+ }
206
+
207
+ if (response.statusCode === 401 || response.statusCode === 403) {
208
+ console.error(`Error: Authentication failed for ${name} (${response.statusCode})`);
209
+ process.exit(1);
210
+ }
211
+
212
+ let packumentVersions = null;
213
+ if (response.statusCode === 200) {
214
+ const body = Buffer.concat(chunks).toString('utf8');
215
+ const packument = JSON.parse(body);
216
+ packumentVersions = packument.versions || {};
217
+ }
218
+
219
+ // Check each version
220
+ const versionResults = [];
221
+ for (const version of versions) {
222
+ const present = packumentVersions ? !!packumentVersions[version] : false;
223
+ versionResults.push({ name, version, present });
224
+ }
225
+ return versionResults;
226
+ } catch (err) {
227
+ // 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
+ }));
234
+ }
235
+ })
236
+ );
237
+
238
+ // Flatten and yield results
239
+ for (const packageResults of results) {
240
+ for (const result of packageResults) {
241
+ yield result;
242
+ }
243
+ completed++;
244
+ if (progress) {
245
+ process.stderr.write(`\r Checking: ${completed}/${total} packages`);
246
+ }
247
+ }
248
+ }
249
+
250
+ if (progress) {
251
+ process.stderr.write('\n');
252
+ }
253
+ }
254
+
255
+ /**
256
+ * Format a single dependency based on output options
257
+ * @param {{ name: string, version: string, integrity?: string, resolved?: string }} dep
258
+ * @param {{ specs: boolean, full: boolean }} options
259
+ * @returns {string | object}
260
+ */
261
+ function formatDep(dep, { specs, full }) {
262
+ if (full) {
263
+ const obj = { name: dep.name, version: dep.version };
264
+ if (dep.integrity) obj.integrity = dep.integrity;
265
+ if (dep.resolved) obj.resolved = dep.resolved;
266
+ return obj;
267
+ }
268
+ if (specs) {
269
+ return { name: dep.name, version: dep.version };
270
+ }
271
+ return dep.name;
272
+ }
273
+
274
+ /**
275
+ * Output dependencies in the requested format (non-cover mode)
276
+ * @param {Iterable<{ name: string, version: string, integrity?: string, resolved?: string }>} deps
277
+ * @param {{ specs: boolean, json: boolean, ndjson: boolean, full: boolean }} options
278
+ */
279
+ function outputDeps(deps, { specs, json, ndjson, full }) {
280
+ const sorted = [...deps].sort((a, b) => a.name.localeCompare(b.name));
281
+
282
+ if (json) {
283
+ const data = sorted.map(d => formatDep(d, { specs, full }));
284
+ console.log(JSON.stringify(data, null, 2));
285
+ return;
286
+ }
287
+
288
+ if (ndjson) {
289
+ for (const d of sorted) {
290
+ console.log(JSON.stringify(formatDep(d, { specs, full })));
291
+ }
292
+ return;
293
+ }
294
+
295
+ // Plain text
296
+ for (const d of sorted) {
297
+ console.log(specs ? `${d.name}@${d.version}` : d.name);
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Output coverage results
303
+ * @param {AsyncGenerator<{ name: string, version: string, present: boolean, error?: string }>} results
304
+ * @param {{ json: boolean, ndjson: boolean, summary: boolean }} options
305
+ */
306
+ async function outputCoverage(results, { json, ndjson, summary }) {
307
+ const all = [];
308
+ let presentCount = 0;
309
+ let missingCount = 0;
310
+
311
+ for await (const result of results) {
312
+ if (result.present) {
313
+ presentCount++;
314
+ } else {
315
+ missingCount++;
316
+ }
317
+
318
+ if (ndjson) {
319
+ // Stream immediately
320
+ console.log(JSON.stringify({ name: result.name, version: result.version, present: result.present }));
321
+ } else {
322
+ all.push(result);
323
+ }
324
+ }
325
+
326
+ if (!ndjson) {
327
+ // Sort by name, then version
328
+ all.sort((a, b) => a.name.localeCompare(b.name) || a.version.localeCompare(b.version));
329
+
330
+ if (json) {
331
+ const data = all.map(r => ({ name: r.name, version: r.version, present: r.present }));
332
+ console.log(JSON.stringify(data, null, 2));
333
+ } else {
334
+ // CSV output
335
+ console.log('package,version,present');
336
+ for (const r of all) {
337
+ console.log(`${r.name},${r.version},${r.present}`);
338
+ }
339
+ }
340
+ }
341
+
342
+ if (summary) {
343
+ const total = presentCount + missingCount;
344
+ const percentage = total > 0 ? ((presentCount / total) * 100).toFixed(1) : 0;
345
+ process.stderr.write(`\nCoverage: ${presentCount}/${total} (${percentage}%) packages present\n`);
346
+ if (missingCount > 0) {
347
+ process.stderr.write(`Missing: ${missingCount} packages\n`);
348
+ }
349
+ }
350
+ }
351
+
352
+ try {
353
+ const lockfile = await FlatlockSet.fromPath(lockfilePath);
354
+ let deps;
355
+
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
+ });
367
+ } else {
368
+ deps = lockfile;
369
+ }
370
+
371
+ if (values.cover) {
372
+ // Coverage mode
373
+ const sorted = [...deps].sort((a, b) => a.name.localeCompare(b.name));
374
+ const results = checkCoverage(sorted, {
375
+ registry: values.registry,
376
+ auth: values.auth,
377
+ token: values.token,
378
+ progress: values.progress
379
+ });
380
+
381
+ await outputCoverage(results, {
382
+ json: values.json,
383
+ ndjson: values.ndjson,
384
+ summary: values.summary
385
+ });
386
+ } else {
387
+ // Standard flatlock mode
388
+ outputDeps(deps, {
389
+ specs: values.specs,
390
+ json: values.json,
391
+ ndjson: values.ndjson,
392
+ full: values.full
393
+ });
394
+ }
395
+ } catch (err) {
396
+ console.error(`Error: ${err.message}`);
397
+ process.exit(1);
398
+ }