diff --git a/cli/cli/src/cmd/packument/show.js b/cli/cli/src/cmd/packument/show.js new file mode 100644 index 0000000..728f499 --- /dev/null +++ b/cli/cli/src/cmd/packument/show.js @@ -0,0 +1,155 @@ +import { Cache, createStorageDriver, createPackumentKey, encodeOrigin } from '@_all_docs/cache'; +import { compileSelector } from '@_all_docs/view'; + +export const usage = `Usage: _all_docs packument show [options] + +Display a packument from cache or registry. + +Options: + --select Project specific fields using selector syntax + --registry Registry URL (default: npm) + --origin Origin name (alternative to --registry) + --raw Output raw JSON without formatting + +Selector Syntax: + field Simple field access + field.nested Nested field access + field["key"] Bracket notation (for special chars or variables) + field|transform Apply transform (keys, values, length, etc.) + expr as alias Rename output field + +Examples: + _all_docs packument show lodash + _all_docs packument show lodash --select 'versions|keys' + _all_docs packument show lodash --select 'time["4.17.21"]' + _all_docs packument show lodash@4.17.21 --select 'dist.integrity' + _all_docs packument show lodash --select 'name, versions|keys|length as versionCount' +`; + +export const command = async (cli) => { + if (cli.values.help) { + console.log(usage); + return; + } + + const spec = cli._[0]; + if (!spec) { + console.error('Error: Package name required'); + console.error('Usage: _all_docs packument show '); + process.exit(1); + } + + // Parse spec (handles scoped packages: @scope/name@version) + const { name, version } = parseSpec(spec); + + // Determine origin + const registry = cli.values.registry || 'https://registry.npmjs.org'; + const origin = cli.values.origin || encodeOrigin(registry); + + // Setup cache + const driver = await createStorageDriver({ CACHE_DIR: cli.dir('packuments') }); + const cache = new Cache({ path: cli.dir('packuments'), driver }); + + // Create cache key and fetch + const cacheKey = createPackumentKey(name, registry); + + let packument; + try { + const entry = await cache.fetch(cacheKey); + packument = entry?.body || entry; + } catch (err) { + console.error(`Error: Could not find cached packument for ${name}`); + console.error(`Hint: Run '_all_docs packument fetch ${name}' first`); + process.exit(1); + } + + if (!packument) { + console.error(`Error: Package not found in cache: ${name}`); + console.error(`Hint: Run '_all_docs packument fetch ${name}' first`); + process.exit(1); + } + + // Determine what to output + let data = packument; + + // If version specified, narrow to version-specific data + if (version) { + if (!packument.versions?.[version]) { + console.error(`Error: Version not found: ${name}@${version}`); + console.error(`Available versions: ${Object.keys(packument.versions || {}).slice(0, 10).join(', ')}...`); + process.exit(1); + } + // Include version data + time field for date lookups + data = { + ...packument.versions[version], + name: packument.name, + time: packument.time + }; + } + + // Apply projection if --select specified + if (cli.values.select) { + try { + const project = compileSelector(cli.values.select); + data = project(data); + + // If single field selected, unwrap the result + const keys = Object.keys(data); + if (keys.length === 1) { + data = data[keys[0]]; + } + } catch (err) { + console.error(`Error: Invalid select expression: ${err.message}`); + process.exit(1); + } + } + + // Output + if (cli.values.raw) { + console.log(JSON.stringify(data)); + } else { + console.log(JSON.stringify(data, null, 2)); + } +}; + +/** + * Parse a package spec into name and version + * Handles: lodash, lodash@4.17.21, @scope/name, @scope/name@version + * @param {string} spec - Package spec + * @returns {{ name: string, version: string|null }} + */ +function parseSpec(spec) { + // Handle scoped packages + if (spec.startsWith('@')) { + // @scope/name or @scope/name@version + const slashIndex = spec.indexOf('/'); + if (slashIndex === -1) { + return { name: spec, version: null }; + } + + const afterSlash = spec.slice(slashIndex + 1); + const atIndex = afterSlash.indexOf('@'); + + if (atIndex === -1) { + // @scope/name (no version) + return { name: spec, version: null }; + } else { + // @scope/name@version + return { + name: spec.slice(0, slashIndex + 1 + atIndex), + version: afterSlash.slice(atIndex + 1) + }; + } + } + + // Unscoped package: name or name@version + const atIndex = spec.indexOf('@'); + if (atIndex === -1) { + return { name: spec, version: null }; + } + + return { + name: spec.slice(0, atIndex), + version: spec.slice(atIndex + 1) + }; +} diff --git a/cli/cli/src/cmd/packument/show.test.js b/cli/cli/src/cmd/packument/show.test.js new file mode 100644 index 0000000..7df1a2c --- /dev/null +++ b/cli/cli/src/cmd/packument/show.test.js @@ -0,0 +1,149 @@ +import { describe, it } from 'node:test'; +import { strict as assert } from 'node:assert'; + +/** + * Test the parseSpec function logic for packument show command + */ + +/** + * Parse a package spec into name and version + * @param {string} spec - Package spec + * @returns {{ name: string, version: string|null }} + */ +function parseSpec(spec) { + // Handle scoped packages + if (spec.startsWith('@')) { + // @scope/name or @scope/name@version + const slashIndex = spec.indexOf('/'); + if (slashIndex === -1) { + return { name: spec, version: null }; + } + + const afterSlash = spec.slice(slashIndex + 1); + const atIndex = afterSlash.indexOf('@'); + + if (atIndex === -1) { + // @scope/name (no version) + return { name: spec, version: null }; + } else { + // @scope/name@version + return { + name: spec.slice(0, slashIndex + 1 + atIndex), + version: afterSlash.slice(atIndex + 1) + }; + } + } + + // Unscoped package: name or name@version + const atIndex = spec.indexOf('@'); + if (atIndex === -1) { + return { name: spec, version: null }; + } + + return { + name: spec.slice(0, atIndex), + version: spec.slice(atIndex + 1) + }; +} + +describe('parseSpec', () => { + describe('unscoped packages', () => { + it('parses simple name', () => { + const result = parseSpec('lodash'); + assert.deepEqual(result, { name: 'lodash', version: null }); + }); + + it('parses name@version', () => { + const result = parseSpec('lodash@4.17.21'); + assert.deepEqual(result, { name: 'lodash', version: '4.17.21' }); + }); + + it('parses name with dots', () => { + const result = parseSpec('left-pad'); + assert.deepEqual(result, { name: 'left-pad', version: null }); + }); + + it('parses name with dots and version', () => { + const result = parseSpec('left-pad@1.3.0'); + assert.deepEqual(result, { name: 'left-pad', version: '1.3.0' }); + }); + }); + + describe('scoped packages', () => { + it('parses @scope/name', () => { + const result = parseSpec('@babel/core'); + assert.deepEqual(result, { name: '@babel/core', version: null }); + }); + + it('parses @scope/name@version', () => { + const result = parseSpec('@babel/core@7.23.0'); + assert.deepEqual(result, { name: '@babel/core', version: '7.23.0' }); + }); + + it('parses complex scoped package', () => { + const result = parseSpec('@types/node@18.0.0'); + assert.deepEqual(result, { name: '@types/node', version: '18.0.0' }); + }); + + it('parses scoped package with no slash', () => { + const result = parseSpec('@scope'); + assert.deepEqual(result, { name: '@scope', version: null }); + }); + }); + + describe('edge cases', () => { + it('parses version with pre-release tag', () => { + const result = parseSpec('react@18.0.0-alpha.1'); + assert.deepEqual(result, { name: 'react', version: '18.0.0-alpha.1' }); + }); + + it('parses scoped package with pre-release tag', () => { + const result = parseSpec('@angular/core@16.0.0-rc.1'); + assert.deepEqual(result, { name: '@angular/core', version: '16.0.0-rc.1' }); + }); + }); +}); + +describe('packument show --select', () => { + describe('single field selection', () => { + it('unwraps single field result', () => { + // When selecting a single field, the show command unwraps it + const data = { name: 'lodash' }; + const keys = Object.keys(data); + const result = keys.length === 1 ? data[keys[0]] : data; + + assert.equal(result, 'lodash'); + }); + + it('keeps multi-field result as object', () => { + const data = { name: 'lodash', version: '4.17.21' }; + const keys = Object.keys(data); + const result = keys.length === 1 ? data[keys[0]] : data; + + assert.deepEqual(result, { name: 'lodash', version: '4.17.21' }); + }); + }); + + describe('version narrowing', () => { + it('merges version data with time field', () => { + const packument = { + name: 'lodash', + time: { '4.17.21': '2021-02-20T15:42:16.891Z' }, + versions: { + '4.17.21': { dist: { integrity: 'sha512-abc' } } + } + }; + + const version = '4.17.21'; + const data = { + ...packument.versions[version], + name: packument.name, + time: packument.time + }; + + assert.equal(data.name, 'lodash'); + assert.deepEqual(data.dist, { integrity: 'sha512-abc' }); + assert.equal(data.time['4.17.21'], '2021-02-20T15:42:16.891Z'); + }); + }); +}); diff --git a/doc/cli-reference.md b/doc/cli-reference.md index ebb0767..35f945d 100644 --- a/doc/cli-reference.md +++ b/doc/cli-reference.md @@ -155,6 +155,64 @@ npx _all_docs packument fetch @babel/core - Cache file: `cache/packuments/v1:packument:npm:{hex(name)}.json` - Console: Package name, version count, cache status +### packument show + +Display a cached packument with optional field selection. + +```bash +npx _all_docs packument show [options] +``` + +**Arguments:** +- `` - Package name, optionally with version + +**Options:** +- `--select ` - Project specific fields using selector syntax +- `--registry ` - Registry URL (default: npm) +- `--raw` - Output raw JSON without formatting + +**Selector Syntax:** +- `field` - Simple field access +- `field.nested` - Nested field access +- `field["key"]` - Bracket notation (for keys with special chars) +- `field|transform` - Apply transform (keys, values, length, etc.) +- `expr as alias` - Rename output field + +**Examples:** + +```bash +# Show full packument +npx _all_docs packument show lodash + +# Get version list +npx _all_docs packument show lodash --select 'versions|keys' + +# Get publish date for specific version +npx _all_docs packument show lodash --select 'time["4.17.21"]' + +# Get integrity hash for versioned packument +npx _all_docs packument show lodash@4.17.21 --select 'dist.integrity' + +# Get multiple fields +npx _all_docs packument show lodash --select 'name, versions|keys|length as count' +``` + +**Use Cases:** + +```bash +# Build verification - check integrity hash +npx _all_docs packument show express@4.18.2 --select 'dist.integrity' + +# Audit - check publish date +npx _all_docs packument show left-pad --select 'time["1.1.1"]' + +# Quick version count +npx _all_docs packument show lodash --select 'versions|keys|length' + +# Get tarball URL for download +npx _all_docs packument show react@18.2.0 --select 'dist.tarball' +``` + ### packument fetch-list Fetch multiple packuments from a list. diff --git a/src/view/projection.js b/src/view/projection.js index 8e89f34..1b2a1d1 100644 --- a/src/view/projection.js +++ b/src/view/projection.js @@ -23,24 +23,75 @@ const TRANSFORMS = { }; /** - * Get a nested value from an object using dot notation + * Get a nested value from an object using dot and bracket notation * @param {Object} obj - Source object - * @param {string} path - Dot-separated path (e.g., "time.modified") + * @param {string} path - Path with dot or bracket notation (e.g., "time.modified", "time["4.17.21"]") */ function getPath(obj, path) { if (!path || path === '.') return obj; - const parts = path.split('.'); + // Parse path into segments, handling both dot notation and bracket notation + const segments = parsePath(path); let value = obj; - for (const part of parts) { + for (const segment of segments) { if (value == null) return undefined; - value = value[part]; + value = value[segment]; } return value; } +/** + * Parse a path string into segments + * Handles: field.nested, field["key"], field[0], field["key with spaces"] + * @param {string} path - Path string to parse + * @returns {string[]} Array of path segments + */ +function parsePath(path) { + const segments = []; + let current = ''; + let inBracket = false; + let bracketContent = ''; + + for (let i = 0; i < path.length; i++) { + const char = path[i]; + + if (char === '[' && !inBracket) { + if (current) { + segments.push(current); + current = ''; + } + inBracket = true; + bracketContent = ''; + } else if (char === ']' && inBracket) { + // Remove quotes from bracket content if present + let key = bracketContent; + if ((key.startsWith('"') && key.endsWith('"')) || + (key.startsWith("'") && key.endsWith("'"))) { + key = key.slice(1, -1); + } + segments.push(key); + inBracket = false; + } else if (char === '.' && !inBracket) { + if (current) { + segments.push(current); + current = ''; + } + } else if (inBracket) { + bracketContent += char; + } else { + current += char; + } + } + + if (current) { + segments.push(current); + } + + return segments; +} + /** * Apply a transform function to a value */ @@ -72,9 +123,9 @@ function parseFieldExpr(expr) { const path = parts[0]; const transforms = parts.slice(1); - // Default alias is the path (or last path segment) + // Default alias is the last segment of the path if (!alias) { - alias = path.includes('.') ? path.split('.').pop() : path; + alias = extractLastSegment(path); // If there are transforms, append them to alias if (transforms.length > 0) { alias = `${alias}_${transforms[transforms.length - 1]}`; @@ -84,6 +135,21 @@ function parseFieldExpr(expr) { return { path, transforms, alias }; } +/** + * Extract the last segment from a path for use as default alias + * Handles both dot notation and bracket notation + * @param {string} path - Path string + * @returns {string} Last segment suitable for use as alias + */ +function extractLastSegment(path) { + // Parse the path to get segments + const segments = parsePath(path); + if (segments.length === 0) return path; + + // Return the last segment + return segments[segments.length - 1]; +} + /** * Compile a simple selector expression into a projection function * @param {string} selectExpr - e.g., "name, versions|keys as versions, time.modified" diff --git a/src/view/projection.test.js b/src/view/projection.test.js index b35fab3..9d60f4f 100644 --- a/src/view/projection.test.js +++ b/src/view/projection.test.js @@ -199,3 +199,61 @@ describe('createFilter', () => { assert.strictEqual(fn({ count: 0 }), false); }); }); + +describe('bracket notation', () => { + test('selects with bracket notation double quotes', () => { + const fn = compileSelector('time["4.17.21"]'); + const result = fn({ + time: { '4.17.21': '2021-02-20T15:42:16.891Z' } + }); + + assert.deepStrictEqual(result, { '4.17.21': '2021-02-20T15:42:16.891Z' }); + }); + + test('selects with bracket notation single quotes', () => { + const fn = compileSelector("time['4.17.21']"); + const result = fn({ + time: { '4.17.21': '2021-02-20T15:42:16.891Z' } + }); + + assert.deepStrictEqual(result, { '4.17.21': '2021-02-20T15:42:16.891Z' }); + }); + + test('selects with mixed notation', () => { + const fn = compileSelector('versions["1.0.0"].dist.integrity'); + const result = fn({ + versions: { + '1.0.0': { dist: { integrity: 'sha512-abc123' } } + } + }); + + assert.deepStrictEqual(result, { integrity: 'sha512-abc123' }); + }); + + test('selects with bracket notation and transform', () => { + const fn = compileSelector('time["4.17.21"] as publishedAt'); + const result = fn({ + time: { '4.17.21': '2021-02-20T15:42:16.891Z' } + }); + + assert.deepStrictEqual(result, { publishedAt: '2021-02-20T15:42:16.891Z' }); + }); + + test('handles missing bracket key gracefully', () => { + const fn = compileSelector('time["missing"]'); + const result = fn({ + time: { '4.17.21': '2021-02-20T15:42:16.891Z' } + }); + + assert.deepStrictEqual(result, { missing: undefined }); + }); + + test('selects with numeric bracket notation', () => { + const fn = compileSelector('items[0]'); + const result = fn({ + items: ['first', 'second', 'third'] + }); + + assert.deepStrictEqual(result, { '0': 'first' }); + }); +});