WIP: A simple cli for daily tangled use cases and AI integration. This is for my personal use right now, but happy if others get mileage from it! :)

Add --json output with field filtering to issue commands

Adds GitHub CLI-style --json [fields] option to issue list, view,
create, and edit commands. When --json is passed, outputs machine-
readable JSON instead of human-readable text. An optional comma-
separated field list filters the output to only the requested fields.

Adds outputJson() utility to src/utils/formatting.ts for reuse
across future commands.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

markbennett.ca 1b804d29 ba347070

verified
+464 -66
+1 -1
TODO.md
··· 39 39 40 40 - [x] Implement `tangled issue create "<title>" [--body "<body>" | --body-file <file> | -F -]` command. 41 41 - [x] Implement `tangled issue list [--json "id,title"]` command. 42 - - [ ] Support `--json` output with field filtering. 42 + - [x] Support `--json` output with field filtering. 43 43 - [ ] Migrate this TODO list into Tangled issues once issue creation is implemented. (note defects and address blocking features as needed). 44 44 - [ ] Create phases in this todo list, and then use `- [ ]` tasks in the issue descriptions. 45 45 - [ ] Remove TODO.md once all tasks are migrated to issues.
+148 -64
src/commands/issue.ts
··· 16 16 import { buildRepoAtUri } from '../utils/at-uri.js'; 17 17 import { requireAuth } from '../utils/auth-helpers.js'; 18 18 import { readBodyInput } from '../utils/body-input.js'; 19 - import { formatDate, formatIssueState } from '../utils/formatting.js'; 19 + import { formatDate, formatIssueState, outputJson } from '../utils/formatting.js'; 20 20 import { validateIssueBody, validateIssueTitle } from '../utils/validation.js'; 21 21 22 22 /** ··· 93 93 return new Command('view') 94 94 .description('View details of a specific issue') 95 95 .argument('<issue-id>', 'Issue number (e.g., 1, #2) or rkey') 96 - .action(async (issueId: string) => { 96 + .option( 97 + '--json [fields]', 98 + 'Output JSON; optionally specify comma-separated fields (title, body, state, author, createdAt, uri, cid)' 99 + ) 100 + .action(async (issueId: string, options: { json?: string | true }) => { 97 101 try { 98 102 // 1. Validate auth 99 103 const client = createApiClient(); ··· 123 127 // 6. Fetch issue state 124 128 const state = await getIssueState({ client, issueUri: issue.uri }); 125 129 126 - // 7. Display issue details 130 + // 7. Output result 131 + if (options.json !== undefined) { 132 + const issueData = { 133 + title: issue.title, 134 + body: issue.body, 135 + state, 136 + author: issue.author, 137 + createdAt: issue.createdAt, 138 + uri: issue.uri, 139 + cid: issue.cid, 140 + }; 141 + outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 142 + return; 143 + } 144 + 127 145 console.log(`\nIssue ${displayId} ${formatIssueState(state)}`); 128 146 console.log(`Title: ${issue.title}`); 129 147 console.log(`Author: ${issue.author}`); ··· 156 174 .option('-t, --title <string>', 'New issue title') 157 175 .option('-b, --body <string>', 'New issue body text') 158 176 .option('-F, --body-file <path>', 'Read body from file (- for stdin)') 177 + .option( 178 + '--json [fields]', 179 + 'Output JSON of the updated issue; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)' 180 + ) 159 181 .action( 160 - async (issueId: string, options: { title?: string; body?: string; bodyFile?: string }) => { 182 + async ( 183 + issueId: string, 184 + options: { title?: string; body?: string; bodyFile?: string; json?: string | true } 185 + ) => { 161 186 try { 162 187 // 1. Validate at least one option provided 163 188 if (!options.title && !options.body && !options.bodyFile) { ··· 195 220 const validBody = body !== undefined ? validateIssueBody(body) : undefined; 196 221 197 222 // 8. Update issue 198 - await updateIssue({ 223 + const updatedIssue = await updateIssue({ 199 224 client, 200 225 issueUri, 201 226 title: validTitle, 202 227 body: validBody, 203 228 }); 204 229 205 - // 9. Display success 230 + // 9. Output result 231 + if (options.json !== undefined) { 232 + const issueData = { 233 + title: updatedIssue.title, 234 + body: updatedIssue.body, 235 + author: updatedIssue.author, 236 + createdAt: updatedIssue.createdAt, 237 + uri: updatedIssue.uri, 238 + cid: updatedIssue.cid, 239 + }; 240 + outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 241 + return; 242 + } 243 + 206 244 const updated: string[] = []; 207 245 if (validTitle !== undefined) updated.push('title'); 208 246 if (validBody !== undefined) updated.push('body'); ··· 400 438 .argument('<title>', 'Issue title') 401 439 .option('-b, --body <string>', 'Issue body text') 402 440 .option('-F, --body-file <path>', 'Read body from file (- for stdin)') 403 - .action(async (title: string, options: { body?: string; bodyFile?: string }) => { 404 - try { 405 - // 1. Validate auth 406 - const client = createApiClient(); 407 - if (!(await client.resumeSession())) { 408 - console.error('✗ Not authenticated. Run "tangled auth login" first.'); 409 - process.exit(1); 410 - } 441 + .option( 442 + '--json [fields]', 443 + 'Output JSON; optionally specify comma-separated fields (title, body, author, createdAt, uri, cid)' 444 + ) 445 + .action( 446 + async ( 447 + title: string, 448 + options: { body?: string; bodyFile?: string; json?: string | true } 449 + ) => { 450 + try { 451 + // 1. Validate auth 452 + const client = createApiClient(); 453 + if (!(await client.resumeSession())) { 454 + console.error('✗ Not authenticated. Run "tangled auth login" first.'); 455 + process.exit(1); 456 + } 411 457 412 - // 2. Get repo context 413 - const context = await getCurrentRepoContext(); 414 - if (!context) { 415 - console.error('✗ Not in a Tangled repository'); 416 - console.error('\nTo use this repository with Tangled, add a remote:'); 417 - console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 418 - process.exit(1); 419 - } 458 + // 2. Get repo context 459 + const context = await getCurrentRepoContext(); 460 + if (!context) { 461 + console.error('✗ Not in a Tangled repository'); 462 + console.error('\nTo use this repository with Tangled, add a remote:'); 463 + console.error(' git remote add origin git@tangled.org:<did>/<repo>.git'); 464 + process.exit(1); 465 + } 420 466 421 - // 3. Validate title 422 - const validTitle = validateIssueTitle(title); 467 + // 3. Validate title 468 + const validTitle = validateIssueTitle(title); 423 469 424 - // 4. Handle body input 425 - const body = await readBodyInput(options.body, options.bodyFile); 426 - if (body !== undefined) { 427 - validateIssueBody(body); 428 - } 470 + // 4. Handle body input 471 + const body = await readBodyInput(options.body, options.bodyFile); 472 + if (body !== undefined) { 473 + validateIssueBody(body); 474 + } 429 475 430 - // 5. Build repo AT-URI 431 - const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 476 + // 5. Build repo AT-URI 477 + const repoAtUri = await buildRepoAtUri(context.owner, context.name, client); 432 478 433 - // 6. Create issue 434 - console.log('Creating issue...'); 435 - const issue = await createIssue({ 436 - client, 437 - repoAtUri, 438 - title: validTitle, 439 - body, 440 - }); 479 + // 6. Create issue (suppress progress message in JSON mode) 480 + if (options.json === undefined) { 481 + console.log('Creating issue...'); 482 + } 483 + const issue = await createIssue({ 484 + client, 485 + repoAtUri, 486 + title: validTitle, 487 + body, 488 + }); 489 + 490 + // 7. Output result 491 + if (options.json !== undefined) { 492 + const issueData = { 493 + title: issue.title, 494 + body: issue.body, 495 + author: issue.author, 496 + createdAt: issue.createdAt, 497 + uri: issue.uri, 498 + cid: issue.cid, 499 + }; 500 + outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 501 + return; 502 + } 441 503 442 - // 7. Display success 443 - const rkey = extractRkey(issue.uri); 444 - console.log(`\n✓ Issue created: #${rkey}`); 445 - console.log(` Title: ${issue.title}`); 446 - console.log(` URI: ${issue.uri}`); 447 - } catch (error) { 448 - console.error( 449 - `✗ Failed to create issue: ${error instanceof Error ? error.message : 'Unknown error'}` 450 - ); 451 - process.exit(1); 504 + const rkey = extractRkey(issue.uri); 505 + console.log(`\n✓ Issue created: #${rkey}`); 506 + console.log(` Title: ${issue.title}`); 507 + console.log(` URI: ${issue.uri}`); 508 + } catch (error) { 509 + console.error( 510 + `✗ Failed to create issue: ${error instanceof Error ? error.message : 'Unknown error'}` 511 + ); 512 + process.exit(1); 513 + } 452 514 } 453 - }); 515 + ); 454 516 } 455 517 456 518 /** ··· 460 522 return new Command('list') 461 523 .description('List issues for the current repository') 462 524 .option('-l, --limit <number>', 'Maximum number of issues to fetch', '50') 463 - .action(async (options: { limit: string }) => { 525 + .option( 526 + '--json [fields]', 527 + 'Output JSON; optionally specify comma-separated fields (number, title, body, state, author, createdAt, uri, cid)' 528 + ) 529 + .action(async (options: { limit: string; json?: string | true }) => { 464 530 try { 465 531 // 1. Validate auth 466 532 const client = createApiClient(); ··· 494 560 limit, 495 561 }); 496 562 497 - // 5. Display results 563 + // 5. Handle empty results 498 564 if (issues.length === 0) { 499 - console.log('No issues found for this repository.'); 565 + if (options.json !== undefined) { 566 + console.log('[]'); 567 + } else { 568 + console.log('No issues found for this repository.'); 569 + } 500 570 return; 501 571 } 502 572 ··· 505 575 (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() 506 576 ); 507 577 508 - console.log( 509 - `\nFound ${sortedIssues.length} issue${sortedIssues.length === 1 ? '' : 's'}:\n` 578 + // Build issue data with states (in parallel for performance) 579 + const issueData = await Promise.all( 580 + sortedIssues.map(async (issue, i) => { 581 + const state = await getIssueState({ client, issueUri: issue.uri }); 582 + return { 583 + number: i + 1, 584 + title: issue.title, 585 + body: issue.body, 586 + state, 587 + author: issue.author, 588 + createdAt: issue.createdAt, 589 + uri: issue.uri, 590 + cid: issue.cid, 591 + }; 592 + }) 510 593 ); 511 594 512 - // Fetch and display each issue with number and state 513 - for (let i = 0; i < sortedIssues.length; i++) { 514 - const issue = sortedIssues[i]; 515 - const num = i + 1; 516 - const date = formatDate(issue.createdAt); 595 + // 6. Output results 596 + if (options.json !== undefined) { 597 + outputJson(issueData, typeof options.json === 'string' ? options.json : undefined); 598 + return; 599 + } 517 600 518 - // Get issue state 519 - const state = await getIssueState({ client, issueUri: issue.uri }); 520 - const stateBadge = formatIssueState(state); 601 + console.log(`\nFound ${issueData.length} issue${issueData.length === 1 ? '' : 's'}:\n`); 521 602 522 - console.log(` #${num} ${stateBadge} ${issue.title}`); 603 + for (const item of issueData) { 604 + const stateBadge = formatIssueState(item.state); 605 + const date = formatDate(item.createdAt); 606 + console.log(` #${item.number} ${stateBadge} ${item.title}`); 523 607 console.log(` Created ${date}`); 524 608 console.log(); 525 609 }
+44
src/utils/formatting.ts
··· 25 25 export function formatIssueState(state: 'open' | 'closed'): string { 26 26 return state === 'open' ? '[OPEN]' : '[CLOSED]'; 27 27 } 28 + 29 + /** 30 + * Pick specific fields from an object, omitting fields not present in the object 31 + */ 32 + function pickFields(obj: Record<string, unknown>, fields: string[]): Record<string, unknown> { 33 + const result: Record<string, unknown> = {}; 34 + for (const field of fields) { 35 + if (field in obj) { 36 + result[field] = obj[field]; 37 + } 38 + } 39 + return result; 40 + } 41 + 42 + /** 43 + * Output data as JSON to stdout, following GitHub CLI conventions. 44 + * 45 + * @param data - The data to output (object or array of objects) 46 + * @param fields - Comma-separated field names to include; omit for all fields 47 + */ 48 + export function outputJson( 49 + data: Record<string, unknown> | Record<string, unknown>[], 50 + fields?: string 51 + ): void { 52 + if (fields) { 53 + const fieldList = fields 54 + .split(',') 55 + .map((f) => f.trim()) 56 + .filter(Boolean); 57 + if (Array.isArray(data)) { 58 + console.log( 59 + JSON.stringify( 60 + data.map((item) => pickFields(item, fieldList)), 61 + null, 62 + 2 63 + ) 64 + ); 65 + } else { 66 + console.log(JSON.stringify(pickFields(data, fieldList), null, 2)); 67 + } 68 + } else { 69 + console.log(JSON.stringify(data, null, 2)); 70 + } 71 + }
+207
tests/commands/issue.test.ts
··· 248 248 expect(processExitSpy).toHaveBeenCalledWith(1); 249 249 }); 250 250 }); 251 + 252 + describe('JSON output', () => { 253 + const mockIssue: IssueWithMetadata = { 254 + $type: 'sh.tangled.repo.issue', 255 + repo: 'at://did:plc:abc123/sh.tangled.repo/test-repo', 256 + title: 'Test Issue', 257 + body: 'Test body', 258 + createdAt: '2024-01-01T00:00:00.000Z', 259 + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', 260 + cid: 'bafyreiabc123', 261 + author: 'did:plc:abc123', 262 + }; 263 + 264 + it('should output JSON of created issue when --json is passed', async () => { 265 + vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 266 + 267 + const command = createIssueCommand(); 268 + await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json']); 269 + 270 + // Should NOT print human-readable messages 271 + expect(consoleLogSpy).not.toHaveBeenCalledWith('Creating issue...'); 272 + 273 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 274 + expect(jsonOutput).toMatchObject({ 275 + title: 'Test Issue', 276 + body: 'Test body', 277 + author: 'did:plc:abc123', 278 + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', 279 + cid: 'bafyreiabc123', 280 + }); 281 + }); 282 + 283 + it('should output filtered JSON when --json with fields is passed', async () => { 284 + vi.mocked(issuesApi.createIssue).mockResolvedValue(mockIssue); 285 + 286 + const command = createIssueCommand(); 287 + await command.parseAsync(['node', 'test', 'create', 'Test Issue', '--json', 'title,uri']); 288 + 289 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 290 + expect(jsonOutput).toEqual({ 291 + title: 'Test Issue', 292 + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/abc123', 293 + }); 294 + expect(jsonOutput).not.toHaveProperty('body'); 295 + expect(jsonOutput).not.toHaveProperty('author'); 296 + }); 297 + }); 251 298 }); 252 299 253 300 describe('issue list command', () => { ··· 435 482 expect(processExitSpy).toHaveBeenCalledWith(1); 436 483 }); 437 484 }); 485 + 486 + describe('JSON output', () => { 487 + const mockIssues: IssueWithMetadata[] = [ 488 + { 489 + $type: 'sh.tangled.repo.issue', 490 + repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 491 + title: 'First Issue', 492 + body: 'First body', 493 + createdAt: new Date('2024-01-01').toISOString(), 494 + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue1', 495 + cid: 'bafyrei1', 496 + author: 'did:plc:abc123', 497 + }, 498 + { 499 + $type: 'sh.tangled.repo.issue', 500 + repo: 'at://did:plc:abc123/sh.tangled.repo/xyz789', 501 + title: 'Second Issue', 502 + createdAt: new Date('2024-01-02').toISOString(), 503 + uri: 'at://did:plc:abc123/sh.tangled.repo.issue/issue2', 504 + cid: 'bafyrei2', 505 + author: 'did:plc:abc123', 506 + }, 507 + ]; 508 + 509 + beforeEach(() => { 510 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ 511 + issues: mockIssues, 512 + cursor: undefined, 513 + }); 514 + vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 515 + }); 516 + 517 + it('should output JSON array when --json is passed', async () => { 518 + const command = createIssueCommand(); 519 + await command.parseAsync(['node', 'test', 'list', '--json']); 520 + 521 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 522 + expect(Array.isArray(jsonOutput)).toBe(true); 523 + expect(jsonOutput).toHaveLength(2); 524 + expect(jsonOutput[0]).toMatchObject({ 525 + number: 1, 526 + title: 'First Issue', 527 + state: 'open', 528 + author: 'did:plc:abc123', 529 + }); 530 + expect(jsonOutput[1]).toMatchObject({ number: 2, title: 'Second Issue' }); 531 + }); 532 + 533 + it('should output filtered JSON when --json with fields is passed', async () => { 534 + const command = createIssueCommand(); 535 + await command.parseAsync(['node', 'test', 'list', '--json', 'number,title,state']); 536 + 537 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 538 + expect(jsonOutput[0]).toEqual({ number: 1, title: 'First Issue', state: 'open' }); 539 + expect(jsonOutput[0]).not.toHaveProperty('author'); 540 + expect(jsonOutput[0]).not.toHaveProperty('uri'); 541 + }); 542 + 543 + it('should output empty JSON array when no issues exist', async () => { 544 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ issues: [], cursor: undefined }); 545 + 546 + const command = createIssueCommand(); 547 + await command.parseAsync(['node', 'test', 'list', '--json']); 548 + 549 + expect(consoleLogSpy).toHaveBeenCalledWith('[]'); 550 + }); 551 + }); 438 552 }); 439 553 440 554 describe('issue view command', () => { ··· 578 692 579 693 expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Issue #99 not found')); 580 694 }); 695 + 696 + describe('JSON output', () => { 697 + it('should output JSON when --json is passed', async () => { 698 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ 699 + issues: [mockIssue], 700 + cursor: undefined, 701 + }); 702 + vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 703 + vi.mocked(issuesApi.getIssueState).mockResolvedValue('open'); 704 + 705 + const command = createIssueCommand(); 706 + await command.parseAsync(['node', 'test', 'view', '1', '--json']); 707 + 708 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 709 + expect(jsonOutput).toMatchObject({ 710 + title: 'Test Issue', 711 + body: 'Issue body', 712 + state: 'open', 713 + author: 'did:plc:abc123', 714 + uri: mockIssue.uri, 715 + cid: mockIssue.cid, 716 + }); 717 + }); 718 + 719 + it('should output filtered JSON when --json with fields is passed', async () => { 720 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ 721 + issues: [mockIssue], 722 + cursor: undefined, 723 + }); 724 + vi.mocked(issuesApi.getIssue).mockResolvedValue(mockIssue); 725 + vi.mocked(issuesApi.getIssueState).mockResolvedValue('closed'); 726 + 727 + const command = createIssueCommand(); 728 + await command.parseAsync(['node', 'test', 'view', '1', '--json', 'title,state']); 729 + 730 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 731 + expect(jsonOutput).toEqual({ title: 'Test Issue', state: 'closed' }); 732 + expect(jsonOutput).not.toHaveProperty('body'); 733 + expect(jsonOutput).not.toHaveProperty('author'); 734 + }); 735 + }); 581 736 }); 582 737 583 738 describe('issue edit command', () => { ··· 691 846 expect(consoleErrorSpy).toHaveBeenCalledWith( 692 847 '✗ Not authenticated. Run "tangled auth login" first.' 693 848 ); 849 + }); 850 + 851 + describe('JSON output', () => { 852 + it('should output JSON of updated issue when --json is passed', async () => { 853 + const updatedIssue = { ...mockIssue, title: 'New Title' }; 854 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ 855 + issues: [mockIssue], 856 + cursor: undefined, 857 + }); 858 + vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue); 859 + 860 + const command = createIssueCommand(); 861 + await command.parseAsync(['node', 'test', 'edit', '1', '--title', 'New Title', '--json']); 862 + 863 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 864 + expect(jsonOutput).toMatchObject({ 865 + title: 'New Title', 866 + author: 'did:plc:abc123', 867 + uri: mockIssue.uri, 868 + cid: mockIssue.cid, 869 + }); 870 + // Human-readable messages should NOT appear 871 + expect(consoleLogSpy).not.toHaveBeenCalledWith('✓ Issue #1 updated'); 872 + }); 873 + 874 + it('should output filtered JSON when --json with fields is passed', async () => { 875 + const updatedIssue = { ...mockIssue, title: 'New Title' }; 876 + vi.mocked(issuesApi.listIssues).mockResolvedValue({ 877 + issues: [mockIssue], 878 + cursor: undefined, 879 + }); 880 + vi.mocked(issuesApi.updateIssue).mockResolvedValue(updatedIssue); 881 + 882 + const command = createIssueCommand(); 883 + await command.parseAsync([ 884 + 'node', 885 + 'test', 886 + 'edit', 887 + '1', 888 + '--title', 889 + 'New Title', 890 + '--json', 891 + 'title,uri', 892 + ]); 893 + 894 + const jsonOutput = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 895 + expect(jsonOutput).toEqual({ 896 + title: 'New Title', 897 + uri: mockIssue.uri, 898 + }); 899 + expect(jsonOutput).not.toHaveProperty('author'); 900 + }); 694 901 }); 695 902 }); 696 903
+64 -1
tests/utils/formatting.test.ts
··· 1 1 import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 2 - import { formatDate } from '../../src/utils/formatting.js'; 2 + import { formatDate, outputJson } from '../../src/utils/formatting.js'; 3 3 4 4 describe('formatDate', () => { 5 5 beforeEach(() => { ··· 81 81 expect(formatted).toMatch(/\d{1,2}\/\d{1,2}\/\d{4}/); 82 82 }); 83 83 }); 84 + 85 + describe('outputJson', () => { 86 + let consoleLogSpy: ReturnType<typeof vi.spyOn>; 87 + 88 + beforeEach(() => { 89 + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); 90 + }); 91 + 92 + afterEach(() => { 93 + vi.restoreAllMocks(); 94 + }); 95 + 96 + it('should output all fields of an object when no fields specified', () => { 97 + const data = { title: 'Test', state: 'open', author: 'did:plc:abc' }; 98 + outputJson(data); 99 + expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2)); 100 + }); 101 + 102 + it('should output only specified fields of an object', () => { 103 + const data = { title: 'Test', state: 'open', author: 'did:plc:abc', cid: 'bafyrei1' }; 104 + outputJson(data, 'title,state'); 105 + const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 106 + expect(output).toEqual({ title: 'Test', state: 'open' }); 107 + expect(output).not.toHaveProperty('author'); 108 + expect(output).not.toHaveProperty('cid'); 109 + }); 110 + 111 + it('should output all fields of an array when no fields specified', () => { 112 + const data = [ 113 + { title: 'First', state: 'open' }, 114 + { title: 'Second', state: 'closed' }, 115 + ]; 116 + outputJson(data); 117 + expect(consoleLogSpy).toHaveBeenCalledWith(JSON.stringify(data, null, 2)); 118 + }); 119 + 120 + it('should output only specified fields of an array', () => { 121 + const data = [ 122 + { title: 'First', state: 'open', author: 'did:plc:abc' }, 123 + { title: 'Second', state: 'closed', author: 'did:plc:xyz' }, 124 + ]; 125 + outputJson(data, 'title,state'); 126 + const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 127 + expect(output).toEqual([ 128 + { title: 'First', state: 'open' }, 129 + { title: 'Second', state: 'closed' }, 130 + ]); 131 + }); 132 + 133 + it('should silently omit fields not present in the object', () => { 134 + const data = { title: 'Test', state: 'open' }; 135 + outputJson(data, 'title,nonexistent'); 136 + const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 137 + expect(output).toEqual({ title: 'Test' }); 138 + }); 139 + 140 + it('should trim whitespace from field names', () => { 141 + const data = { title: 'Test', state: 'open' }; 142 + outputJson(data, ' title , state '); 143 + const output = JSON.parse(consoleLogSpy.mock.calls[0][0] as string); 144 + expect(output).toEqual({ title: 'Test', state: 'open' }); 145 + }); 146 + });