diff --git a/CHANGES.md b/CHANGES.md index 6f8e0d573..a72ca2356 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -21,6 +21,11 @@ To be released. ### @fedify/cli + - Added `--reverse` option to `fedify lookup` to reverse presentation order + of emitted results. It now works across default multi-input lookup, + `--traverse` collection traversal output, and `--recurse` object chains, + while preserving existing fetch/error semantics. [[#607], [#609]] + - Fixed `fedify lookup` printing separators with extra quotes between adjacent objects/items in some output paths (e.g., recurse/traverse flows). Separators are now printed as plain text consistently. @@ -42,7 +47,9 @@ To be released. [[#608]] [#606]: https://github.com/fedify-dev/fedify/issues/606 +[#607]: https://github.com/fedify-dev/fedify/issues/607 [#608]: https://github.com/fedify-dev/fedify/pull/608 +[#609]: https://github.com/fedify-dev/fedify/pull/609 ### @fedify/vocab diff --git a/docs/cli.md b/docs/cli.md index 68c775134..8195b3000 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -176,6 +176,7 @@ firstKnock = "draft-cavage-http-signatures-12" # or "rfc9421" allowPrivateAddress = false traverse = false suppressErrors = false +reverse = false defaultFormat = "default" # "default", "raw", "compact", or "expand" separator = "----" timeout = 30 # seconds @@ -1021,6 +1022,22 @@ It does not affect the output when looking up a single object. > The separator is also used when looking up a collection object with the > [`-t`/`--traverse`](#t-traverse-traverse-the-collection) option. +### `--reverse`: Reverse output order + +*This option is available since Fedify 2.1.0.* + +The `--reverse` option reverses the output order of fetched results. +It affects output order only, and does not change lookup semantics. + +~~~~ sh +fedify lookup @fedify@hollo.social @hongminhee@fosstodon.org --reverse +fedify lookup --traverse https://fosstodon.org/users/hongminhee/outbox --reverse +fedify lookup --recurse=replyTarget https://hollo.social/@fedify/019c8522-b247-79d3-b0e7-c6a2293bb1cf --reverse +~~~~ + +When using `--reverse`, `fedify lookup` buffers results before printing. +This may increase memory usage for large traversals or long recursion chains. + ### `-o`/`--output`: Output file path *This option is available since Fedify 1.8.0.* diff --git a/packages/cli/src/config.ts b/packages/cli/src/config.ts index 450197c59..8411b6def 100644 --- a/packages/cli/src/config.ts +++ b/packages/cli/src/config.ts @@ -50,6 +50,7 @@ const lookupSchema = pipe( ), recurseDepth: optional(pipe(number(), integer(), minValue(1))), suppressErrors: optional(boolean()), + reverse: optional(boolean()), defaultFormat: optional(picklist(["default", "raw", "compact", "expand"])), separator: optional(string()), timeout: optional(number()), diff --git a/packages/cli/src/lookup.test.ts b/packages/cli/src/lookup.test.ts index 06ecca7c1..859d32cd6 100644 --- a/packages/cli/src/lookup.test.ts +++ b/packages/cli/src/lookup.test.ts @@ -1,4 +1,4 @@ -import { Activity, Note } from "@fedify/vocab"; +import { Activity, Collection, Note } from "@fedify/vocab"; import { clearActiveConfig, setActiveConfig } from "@optique/config"; import { runWithConfig } from "@optique/config/run"; import { parse } from "@optique/core/parser"; @@ -15,15 +15,18 @@ import { getContextLoader } from "./docloader.ts"; import { authorizedFetchOption, clearTimeoutSignal, + collectAsyncItems, collectRecursiveObjects, createTimeoutSignal, getLookupFailureHint, getRecursiveTargetId, lookupCommand, RecursiveLookupError, + runLookup, shouldPrintLookupFailureHint, shouldSuggestSuppressErrorsForLookupFailure, TimeoutError, + toPresentationOrder, writeObjectToStream, writeSeparator, } from "./lookup.ts"; @@ -445,6 +448,28 @@ test("lookupCommand - reads allowPrivateAddress from config", async () => { assert.strictEqual(result.allowPrivateAddress, true); }); +test("lookupCommand - parses --reverse", () => { + setActiveConfig(configContext.id, {}); + const result = parse(lookupCommand, [ + "lookup", + "--reverse", + "https://example.com/notes/1", + ]); + clearActiveConfig(configContext.id); + assert.ok(result.success); + if (result.success) { + assert.strictEqual(result.value.reverse, true); + } +}); + +test("lookupCommand - reads reverse from config", async () => { + const result = await runWithConfig(lookupCommand, configContext, { + load: () => ({ lookup: { reverse: true } }), + args: ["lookup", "https://example.com/notes/1"], + }); + assert.strictEqual(result.reverse, true); +}); + test("lookupCommand - parses recurse option", () => { setActiveConfig(configContext.id, {}); const result = parse(lookupCommand, [ @@ -821,3 +846,496 @@ test( assert.equal(visited.has("https://example.com/notes/1"), false); }, ); + +test( + "toPresentationOrder - keeps order when reverse is false (default mode)", + () => { + assert.deepEqual( + toPresentationOrder( + ["https://example.com/1", "https://example.com/2"], + false, + ), + ["https://example.com/1", "https://example.com/2"], + ); + }, +); + +test("toPresentationOrder - reverses order when reverse is true (default mode)", () => { + assert.deepEqual( + toPresentationOrder( + ["https://example.com/1", "https://example.com/2"], + true, + ), + ["https://example.com/2", "https://example.com/1"], + ); +}); + +test("toPresentationOrder - reverses recursive chain order when reverse is true", () => { + assert.deepEqual( + toPresentationOrder(["self", "parent", "root"], true), + ["root", "parent", "self"], + ); +}); + +test("toPresentationOrder - reverses traversed item order when reverse is true", () => { + assert.deepEqual( + toPresentationOrder(["item-1", "item-2", "item-3"], true), + ["item-3", "item-2", "item-1"], + ); +}); + +test("collectAsyncItems - collects items without error", async () => { + async function* source() { + yield 1; + yield 2; + } + const result = await collectAsyncItems(source()); + assert.deepEqual(result.items, [1, 2]); + assert.equal(result.error, undefined); +}); + +test("collectAsyncItems - keeps partial items when iteration fails", async () => { + async function* source() { + yield "first"; + throw new Error("boom"); + } + const result = await collectAsyncItems(source()); + assert.deepEqual(result.items, ["first"]); + assert.ok(result.error instanceof Error); + assert.equal((result.error as Error).message, "boom"); +}); + +class ExitSignal extends Error { + code: number; + constructor(code: number) { + super(`Exited with code ${code}`); + this.code = code; + } +} + +function createLookupRunCommand( + overrides: Partial[0]>, +): Parameters[0] { + const baseCommand = { + command: "lookup", + urls: [], + traverse: false, + recurse: undefined, + recurseDepth: undefined, + suppressErrors: false, + authorizedFetch: false, + firstKnock: undefined, + tunnelService: undefined, + userAgent: "FedifyTest/1.0", + allowPrivateAddress: true, + timeout: undefined, + reverse: false, + format: "raw", + separator: "----", + output: undefined, + debug: false, + ignoreConfig: false, + configPath: undefined, + } satisfies Parameters[0]; + return { ...baseCommand, ...overrides } as Parameters[0]; +} + +async function runLookupAndCaptureExitCode( + command: Parameters[0], + deps?: Parameters[1], +): Promise { + try { + await runLookup(command, { + ...deps, + exit: (code: number) => { + throw new ExitSignal(code); + }, + }); + return null; + } catch (error) { + if (error instanceof ExitSignal) return error.code; + throw error; + } +} + +function extractIdsFromRawOutput(content: string): string[] { + return [...content.matchAll(/"id"\s*:\s*"([^"]+)"/g)].map((match) => + match[1] + ); +} + +test("runLookup - reverses output order in default multi-input mode", async () => { + const testDir = "./test_output_runlookup_default_reverse"; + const testFile = `${testDir}/out.jsonl`; + await mkdir(testDir, { recursive: true }); + try { + const objects = new Map([ + [ + "u1", + new Note({ + id: new URL("https://example.com/notes/1"), + content: "one", + }), + ], + [ + "u2", + new Note({ + id: new URL("https://example.com/notes/2"), + content: "two", + }), + ], + [ + "u3", + new Note({ + id: new URL("https://example.com/notes/3"), + content: "three", + }), + ], + ]); + const exitCode = await runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: ["u1", "u2", "u3"], + reverse: true, + output: testFile, + }), + { + lookupObject: (url) => + Promise.resolve( + objects.get(typeof url === "string" ? url : url.href) ?? null, + ), + traverseCollection: () => { + throw new Error("not used"); + }, + }, + ); + assert.equal(exitCode, null); + const content = await readFile(testFile, "utf8"); + assert.deepEqual(extractIdsFromRawOutput(content), [ + "https://example.com/notes/3", + "https://example.com/notes/2", + "https://example.com/notes/1", + ]); + } finally { + await rm(testDir, { recursive: true }); + } +}); + +test("runLookup - reverses output order in recurse mode", async () => { + const testDir = "./test_output_runlookup_recurse_reverse"; + const testFile = `${testDir}/out.jsonl`; + await mkdir(testDir, { recursive: true }); + try { + const u1 = "https://lookup.test/u1"; + const u2 = "https://lookup.test/u2"; + const u3 = "https://lookup.test/u3"; + const objects = new Map([ + [ + u1, + new Note({ + id: new URL("https://example.com/notes/1"), + content: "one", + }), + ], + [ + u2, + new Note({ + id: new URL("https://example.com/notes/2"), + replyTarget: new URL(u1), + content: "two", + }), + ], + [ + u3, + new Note({ + id: new URL("https://example.com/notes/3"), + replyTarget: new URL(u2), + content: "three", + }), + ], + ]); + const exitCode = await runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: [u3], + recurse: "replyTarget", + recurseDepth: 20, + reverse: true, + output: testFile, + }), + { + lookupObject: (url) => + Promise.resolve( + objects.get(typeof url === "string" ? url : url.href) ?? null, + ), + traverseCollection: () => { + throw new Error("not used"); + }, + }, + ); + assert.equal(exitCode, 0); + const content = await readFile(testFile, "utf8"); + assert.deepEqual(extractIdsFromRawOutput(content), [ + "https://example.com/notes/1", + "https://example.com/notes/2", + "https://example.com/notes/3", + ]); + } finally { + await rm(testDir, { recursive: true }); + } +}); + +test("runLookup - reverses output order in traverse mode", async () => { + const testDir = "./test_output_runlookup_traverse_reverse"; + const testFile = `${testDir}/out.jsonl`; + await mkdir(testDir, { recursive: true }); + try { + const collection = new Collection({ + id: new URL("https://example.com/collection"), + }); + const items = [ + new Note({ id: new URL("https://example.com/items/1"), content: "one" }), + new Note({ id: new URL("https://example.com/items/2"), content: "two" }), + new Note({ + id: new URL("https://example.com/items/3"), + content: "three", + }), + ]; + const exitCode = await runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: ["collection-url"], + traverse: true, + reverse: true, + output: testFile, + }), + { + lookupObject: (url) => + Promise.resolve(url === "collection-url" ? collection : null), + async *traverseCollection() { + for (const item of items) yield item; + }, + }, + ); + assert.equal(exitCode, 0); + const content = await readFile(testFile, "utf8"); + assert.deepEqual(extractIdsFromRawOutput(content), [ + "https://example.com/items/3", + "https://example.com/items/2", + "https://example.com/items/1", + ]); + } finally { + await rm(testDir, { recursive: true }); + } +}); + +test("runLookup - emits reversed partial items on traverse reverse failure", async () => { + const testDir = "./test_output_runlookup_traverse_reverse_partial_failure"; + const testFile = `${testDir}/out.jsonl`; + await mkdir(testDir, { recursive: true }); + try { + const collection = new Collection({ + id: new URL("https://example.com/collection"), + }); + const item1 = new Note({ + id: new URL("https://example.com/items/1"), + content: "one", + }); + const item2 = new Note({ + id: new URL("https://example.com/items/2"), + content: "two", + }); + const exitCode = await runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: ["collection-url"], + traverse: true, + reverse: true, + output: testFile, + }), + { + lookupObject: (url) => + Promise.resolve( + (typeof url === "string" ? url : url.href) === "collection-url" + ? collection + : null, + ), + async *traverseCollection() { + yield item1; + yield item2; + throw new Error("traversal failed"); + }, + }, + ); + assert.equal(exitCode, 1); + const content = await readFile(testFile, "utf8"); + assert.deepEqual(extractIdsFromRawOutput(content), [ + "https://example.com/items/2", + "https://example.com/items/1", + ]); + } finally { + await rm(testDir, { recursive: true }); + } +}); + +test("runLookup - writes separators between adjacent traversed items", async () => { + const testDir = "./test_output_runlookup_traverse_separator"; + const testFile = `${testDir}/out.jsonl`; + const separator = ""; + await mkdir(testDir, { recursive: true }); + try { + const collectionA = new Collection({ + id: new URL("https://example.com/collections/a"), + }); + const collectionB = new Collection({ + id: new URL("https://example.com/collections/b"), + }); + const a1 = new Note({ + id: new URL("https://example.com/items/a1"), + content: "a1", + }); + const a2 = new Note({ + id: new URL("https://example.com/items/a2"), + content: "a2", + }); + const b1 = new Note({ + id: new URL("https://example.com/items/b1"), + content: "b1", + }); + const exitCode = await runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: ["collection-a", "collection-b"], + traverse: true, + separator, + output: testFile, + }), + { + lookupObject: (url) => { + const key = typeof url === "string" ? url : url.href; + if (key === "collection-a") return Promise.resolve(collectionA); + if (key === "collection-b") return Promise.resolve(collectionB); + return Promise.resolve(null); + }, + async *traverseCollection(collection) { + if (collection === collectionA) { + yield a1; + yield a2; + } else if (collection === collectionB) { + yield b1; + } + }, + }, + ); + assert.equal(exitCode, 0); + const content = await readFile(testFile, "utf8"); + assert.deepEqual(extractIdsFromRawOutput(content), [ + "https://example.com/items/a1", + "https://example.com/items/a2", + "https://example.com/items/b1", + ]); + assert.equal(content.split(`${separator}\n`).length - 1, 2); + } finally { + await rm(testDir, { recursive: true }); + } +}); + +test( + "runLookup - writes separators between adjacent traversed items in reverse mode", + async () => { + const testDir = "./test_output_runlookup_traverse_separator_reverse"; + const testFile = `${testDir}/out.jsonl`; + const separator = ""; + await mkdir(testDir, { recursive: true }); + try { + const collectionA = new Collection({ + id: new URL("https://example.com/collections/a"), + }); + const collectionB = new Collection({ + id: new URL("https://example.com/collections/b"), + }); + const a1 = new Note({ + id: new URL("https://example.com/items/a1"), + content: "a1", + }); + const a2 = new Note({ + id: new URL("https://example.com/items/a2"), + content: "a2", + }); + const b1 = new Note({ + id: new URL("https://example.com/items/b1"), + content: "b1", + }); + const exitCode = await runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: ["collection-a", "collection-b"], + traverse: true, + reverse: true, + separator, + output: testFile, + }), + { + lookupObject: (url) => { + const key = typeof url === "string" ? url : url.href; + if (key === "collection-a") return Promise.resolve(collectionA); + if (key === "collection-b") return Promise.resolve(collectionB); + return Promise.resolve(null); + }, + async *traverseCollection(collection) { + if (collection === collectionA) { + yield a1; + yield a2; + } else if (collection === collectionB) { + yield b1; + } + }, + }, + ); + assert.equal(exitCode, 0); + const content = await readFile(testFile, "utf8"); + assert.deepEqual(extractIdsFromRawOutput(content), [ + "https://example.com/items/a2", + "https://example.com/items/a1", + "https://example.com/items/b1", + ]); + assert.equal(content.split(`${separator}\n`).length - 1, 2); + } finally { + await rm(testDir, { recursive: true }); + } + }, +); + +test("runLookup - emits root object on recurse reverse failure", async () => { + const testDir = "./test_output_runlookup_recurse_reverse_partial_failure"; + const testFile = `${testDir}/out.jsonl`; + await mkdir(testDir, { recursive: true }); + try { + const u3 = "https://lookup.test/u3"; + const root = new Note({ + id: new URL("https://example.com/notes/3"), + replyTarget: new URL("https://lookup.test/u2"), + content: "three", + }); + const exitCode = await runLookupAndCaptureExitCode( + createLookupRunCommand({ + urls: [u3], + recurse: "replyTarget", + recurseDepth: 20, + reverse: true, + output: testFile, + }), + { + lookupObject: (url) => { + const key = typeof url === "string" ? url : url.href; + if (key === u3) return Promise.resolve(root); + throw new Error("recursive lookup failed"); + }, + traverseCollection: () => { + throw new Error("not used"); + }, + }, + ); + assert.equal(exitCode, 1); + const content = await readFile(testFile, "utf8"); + assert.deepEqual(extractIdsFromRawOutput(content), [ + "https://example.com/notes/3", + ]); + } finally { + await rm(testDir, { recursive: true }); + } +}); diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index 78fadb878..6389bd322 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -234,6 +234,17 @@ export const lookupCommand = command( ), }), object("Output options", { + reverse: bindConfig( + flag("--reverse", { + description: + message`Reverse the output order of fetched objects or items.`, + }), + { + context: configContext, + key: (config) => config.lookup?.reverse ?? false, + default: false, + }, + ), format: bindConfig( optional( or( @@ -438,6 +449,28 @@ export async function writeSeparator( await writeToStream(stream ?? process.stdout, `${separator}\n`); } +export function toPresentationOrder( + items: readonly T[], + reverse: boolean, +): readonly T[] { + if (reverse) return [...items].reverse(); + return items; +} + +export async function collectAsyncItems( + iterable: AsyncIterable, +): Promise<{ items: T[]; error?: unknown }> { + const items: T[] = []; + try { + for await (const item of iterable) { + items.push(item); + } + return { items }; + } catch (error) { + return { items, error }; + } +} + const signalTimers = new WeakMap(); export function createTimeoutSignal( @@ -642,10 +675,26 @@ export async function collectRecursiveObjects( export async function runLookup( command: InferValue & GlobalOptions, + deps: Partial<{ + lookupObject: typeof lookupObject; + traverseCollection: typeof traverseCollection; + exit: (code: number) => never; + }> = {}, ) { + const effectiveDeps: { + lookupObject: typeof lookupObject; + traverseCollection: typeof traverseCollection; + exit: (code: number) => never; + } = { + lookupObject, + traverseCollection, + exit: (code: number) => process.exit(code), + ...deps, + }; + if (command.urls.length < 1) { printError(message`At least one URL or actor handle must be provided.`); - process.exit(1); + effectiveDeps.exit(1); } // Enable Debug mode if requested @@ -724,7 +773,7 @@ export async function runLookup( }, ); } - process.exit(cleanupFailed && code === 0 ? 1 : code); + effectiveDeps.exit(cleanupFailed && code === 0 ? 1 : code); }; if (command.authorizedFetch) { @@ -853,7 +902,7 @@ export async function runLookup( } let current: APObject | null = null; try { - current = await lookupObject(url, { + current = await effectiveDeps.lookupObject(url, { documentLoader: initialLookupDocumentLoader, contextLoader, userAgent: command.userAgent, @@ -879,29 +928,32 @@ export async function runLookup( return; } - try { - if (totalObjects > 0) { - await writeSeparator(command.separator, getOutputStream()); - } - await writeObjectToStream( - current, - command.output, - command.format, - contextLoader, - getOutputStream(), - ); - } catch (error) { - logger.error("Failed to write lookup output: {error}", { error }); - spinner.fail("Failed to write output."); - await finalizeAndExit(1); - return; - } - totalObjects++; visited.add(url); if (current.id != null) { visited.add(current.id.href); } + if (!command.reverse) { + try { + if (totalObjects > 0) { + await writeSeparator(command.separator, getOutputStream()); + } + await writeObjectToStream( + current, + command.output, + command.format, + contextLoader, + getOutputStream(), + ); + totalObjects++; + } catch (error) { + logger.error("Failed to write lookup output: {error}", { error }); + spinner.fail("Failed to write output."); + await finalizeAndExit(1); + return; + } + } + let chain: APObject[] = []; try { chain = await collectRecursiveObjects( @@ -909,7 +961,7 @@ export async function runLookup( command.recurse, recurseDepth, (target) => - lookupObject(target, { + effectiveDeps.lookupObject(target, { documentLoader: recursiveLookupDocumentLoader, contextLoader: recursiveContextLoader, userAgent: command.userAgent, @@ -917,6 +969,28 @@ export async function runLookup( { suppressErrors: command.suppressErrors, visited }, ); } catch (error) { + if (command.reverse) { + try { + if (totalObjects > 0) { + await writeSeparator(command.separator, getOutputStream()); + } + await writeObjectToStream( + current, + command.output, + command.format, + contextLoader, + getOutputStream(), + ); + totalObjects++; + } catch (writeError) { + logger.error("Failed to write lookup output: {error}", { + error: writeError, + }); + spinner.fail("Failed to write output."); + await finalizeAndExit(1); + return; + } + } logger.error( "Failed to recursively fetch an object in chain: {error}", { @@ -949,22 +1023,68 @@ export async function runLookup( return; } - for (const next of chain) { - try { - await writeSeparator(command.separator, getOutputStream()); - await writeObjectToStream( - next, - command.output, - command.format, - recursiveContextLoader, - getOutputStream(), - ); - totalObjects++; - } catch (error) { - logger.error("Failed to write lookup output: {error}", { error }); - spinner.fail("Failed to write output."); - await finalizeAndExit(1); - return; + if (command.reverse) { + const chainEntries = [ + { object: current, objectContextLoader: contextLoader }, + ...chain.map((next) => ({ + object: next, + objectContextLoader: recursiveContextLoader, + })), + ]; + for ( + let chainIndex = chainEntries.length - 1; + chainIndex >= 0; + chainIndex-- + ) { + const entry = chainEntries[chainIndex]; + try { + if (totalObjects > 0 || chainIndex < chainEntries.length - 1) { + await writeSeparator(command.separator, getOutputStream()); + } + await writeObjectToStream( + entry.object, + command.output, + command.format, + entry.objectContextLoader, + getOutputStream(), + ); + totalObjects++; + } catch (error) { + logger.error("Failed to write lookup output: {error}", { error }); + spinner.fail("Failed to write output."); + await finalizeAndExit(1); + return; + } + } + } else { + const chainEntries = chain.map((next) => ({ + object: next, + objectContextLoader: recursiveContextLoader, + })); + for ( + let chainIndex = 0; + chainIndex < chainEntries.length; + chainIndex++ + ) { + const entry = chainEntries[chainIndex]; + try { + if (totalObjects > 0 || chainIndex > 0) { + await writeSeparator(command.separator, getOutputStream()); + } + await writeObjectToStream( + entry.object, + command.output, + command.format, + entry.objectContextLoader, + getOutputStream(), + ); + totalObjects++; + } catch (error) { + logger.error("Failed to write lookup output: {error}", { error }); + spinner.fail("Failed to write output."); + await finalizeAndExit(1); + return; + } } } } @@ -988,7 +1108,7 @@ export async function runLookup( let collection: APObject | null = null; try { - collection = await lookupObject(url, { + collection = await effectiveDeps.lookupObject(url, { documentLoader: authLoader ?? documentLoader, contextLoader, userAgent: command.userAgent, @@ -1024,36 +1144,74 @@ export async function runLookup( spinner.succeed(`Fetched collection: ${colors.green(url)}.`); try { - let collectionItems = 0; - for await ( - const item of traverseCollection(collection, { - documentLoader: authLoader ?? documentLoader, - contextLoader, - suppressError: command.suppressErrors, - }) - ) { - try { - if (totalItems > 0 || collectionItems > 0) { - await writeSeparator(command.separator, getOutputStream()); + if (command.reverse) { + const { + items: traversedItems, + error: traversalError, + } = await collectAsyncItems( + effectiveDeps.traverseCollection(collection, { + documentLoader: authLoader ?? documentLoader, + contextLoader, + suppressError: command.suppressErrors, + }), + ); + for (let index = traversedItems.length - 1; index >= 0; index--) { + const item = traversedItems[index]; + try { + if (totalItems > 0) { + await writeSeparator(command.separator, getOutputStream()); + } + await writeObjectToStream( + item, + command.output, + command.format, + contextLoader, + getOutputStream(), + ); + } catch (error) { + logger.error("Failed to write output for {url}: {error}", { + url, + error, + }); + spinner.fail(`Failed to write output for: ${colors.red(url)}.`); + await finalizeAndExit(1); + return; } - await writeObjectToStream( - item, - command.output, - command.format, + totalItems++; + } + if (traversalError != null) { + throw traversalError; + } + } else { + for await ( + const item of effectiveDeps.traverseCollection(collection, { + documentLoader: authLoader ?? documentLoader, contextLoader, - getOutputStream(), - ); - } catch (error) { - logger.error("Failed to write output for {url}: {error}", { - url, - error, - }); - spinner.fail(`Failed to write output for: ${colors.red(url)}.`); - await finalizeAndExit(1); - return; + suppressError: command.suppressErrors, + }) + ) { + try { + if (totalItems > 0) { + await writeSeparator(command.separator, getOutputStream()); + } + await writeObjectToStream( + item, + command.output, + command.format, + contextLoader, + getOutputStream(), + ); + } catch (error) { + logger.error("Failed to write output for {url}: {error}", { + url, + error, + }); + spinner.fail(`Failed to write output for: ${colors.red(url)}.`); + await finalizeAndExit(1); + return; + } + totalItems++; } - collectionItems++; - totalItems++; } } catch (error) { logger.error("Failed to complete the traversal for {url}: {error}", { @@ -1089,7 +1247,7 @@ export async function runLookup( for (const url of command.urls) { promises.push( - lookupObject(url, { + effectiveDeps.lookupObject(url, { documentLoader: authLoader ?? documentLoader, contextLoader, userAgent: command.userAgent, @@ -1113,6 +1271,7 @@ export async function runLookup( spinner.stop(); let success = true; let printedCount = 0; + const successfulObjects: APObject[] = []; for (const [i, obj] of objects.entries()) { const url = command.urls[i]; if (obj == null) { @@ -1125,25 +1284,28 @@ export async function runLookup( success = false; } else { spinner.succeed(`Fetched object: ${colors.green(url)}`); - try { - if (printedCount > 0) { - await writeSeparator(command.separator, getOutputStream()); - } - await writeObjectToStream( - obj, - command.output, - command.format, - contextLoader, - getOutputStream(), - ); - } catch (error) { - logger.error("Failed to write lookup output: {error}", { error }); - spinner.fail("Failed to write output."); - await finalizeAndExit(1); - return; + successfulObjects.push(obj); + } + } + for (const obj of toPresentationOrder(successfulObjects, command.reverse)) { + try { + if (printedCount > 0) { + await writeSeparator(command.separator, getOutputStream()); } - printedCount++; + await writeObjectToStream( + obj, + command.output, + command.format, + contextLoader, + getOutputStream(), + ); + } catch (error) { + logger.error("Failed to write lookup output: {error}", { error }); + spinner.fail("Failed to write output."); + await finalizeAndExit(1); + return; } + printedCount++; } if (success) { spinner.succeed(