Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
23574ef
Add recurse mode and fix lookup separators
dahlia Mar 7, 2026
cd5c522
Validate lookup.recurseDepth config as positive integer
dahlia Mar 8, 2026
10b30f4
Correct lookup docs for traverse/recurse multi-argument behavior
dahlia Mar 8, 2026
ee07874
Print exactly one separator between printed lookup objects
dahlia Mar 8, 2026
acec2d3
Reuse output stream across lookup multi-object writes
dahlia Mar 8, 2026
d418286
Avoid poisoning visited set on suppressed lookup failures
dahlia Mar 8, 2026
280502b
Simplify recursive target resolver for validated recurse props
dahlia Mar 8, 2026
7bb5605
Fix recurse visited scope and output separator destination
dahlia Mar 8, 2026
d9e97d2
Block recursive private-address fetches in lookup
dahlia Mar 8, 2026
8ee7f87
Use strict loader for recursive lookup network fetches
dahlia Mar 8, 2026
5f3fd7a
Write separators to the configured lookup output
dahlia Mar 8, 2026
af550c2
Block private-address image fetches in lookup rendering
dahlia Mar 8, 2026
b27d76a
Isolate docloader cache keys by network policy
dahlia Mar 8, 2026
1db0a8b
Simplify image download error handling in lookup
dahlia Mar 8, 2026
5e3be4a
Support quoteUrl recursion targets in lookup
dahlia Mar 8, 2026
77c8e7e
Let Optique show recurse choices in help output
dahlia Mar 8, 2026
4f24401
Lazily initialize recurse-only document loaders
dahlia Mar 8, 2026
9fa76c7
Apply authorized fetch to recursive follow-up lookups
dahlia Mar 8, 2026
ff23875
Await stdout backpressure in writeSeparator
dahlia Mar 8, 2026
21b087e
Handle stream error events in lookup output
dahlia Mar 8, 2026
c6851fa
Lazily create runLookup output stream
dahlia Mar 8, 2026
e09a2a4
Handle recurse output write failures safely
dahlia Mar 8, 2026
1248f71
Guard multi-object output writes in lookup
dahlia Mar 8, 2026
09d2338
Align recurse rendering with strict context policy
dahlia Mar 8, 2026
204cdc6
Harden stream error handling in lookup output
dahlia Mar 8, 2026
dfce3fb
Apply strict URL policy in recurse lookups
dahlia Mar 8, 2026
a66d613
Deduplicate suppress-errors option definition
dahlia Mar 8, 2026
005c4c5
Always exit in finalizeAndExit cleanup path
dahlia Mar 8, 2026
933784d
Remove redundant recurse URL pre-validation
dahlia Mar 8, 2026
12ebc6f
Handle sync stream throw paths in lookup output helpers
dahlia Mar 8, 2026
442f1d5
Harden lookup output destination and finalization paths
dahlia Mar 8, 2026
9400c07
Tighten lookup loader safety and document public helpers
dahlia Mar 8, 2026
ee934f5
Harden lookup private-address handling and image redirects
dahlia Mar 8, 2026
5101c9c
Fix recurse private-address handling in authorized fetch mode
dahlia Mar 8, 2026
8f1d18b
Improve suppressed recurse diagnostics and output/body error handling
dahlia Mar 8, 2026
3c7eb9b
Scope private-address default, cleanup exit status, and deterministic…
dahlia Mar 8, 2026
95423d9
Harden image temp path extension handling
dahlia Mar 8, 2026
764e68d
Make recursive target mapping explicit for quote properties
dahlia Mar 8, 2026
aab372f
Clarify private-address policy for recursive lookup docs
dahlia Mar 8, 2026
f00210c
Improve lookup hints for private-address validation failures
dahlia Mar 8, 2026
b898148
Use switch-based recursion property mapping
dahlia Mar 8, 2026
da6a6fc
Fix lookup failure hint classification and suppression logic
dahlia Mar 8, 2026
9303408
Harden CLI document loader defaults against private-address access
dahlia Mar 8, 2026
7a05536
Handle extensionless image URLs in downloadImage safely
dahlia Mar 8, 2026
e859672
Keep private-address hints visible with authorized fetch enabled
dahlia Mar 8, 2026
d3737a8
Support extensionless nested image URLs in downloadImage
dahlia Mar 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,23 @@ To be released.
[#473]: https://github.com/fedify-dev/fedify/issues/473
[#589]: https://github.com/fedify-dev/fedify/pull/589

### @fedify/cli

- 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.
[[#608]]

- Added `--recurse` and `--recurse-depth` options to `fedify lookup` for
recursively following object relationships (e.g., reply chains via
`replyTarget` / `inReplyTo`). `--traverse` and `--recurse` are now
mutually exclusive, `--recurse-depth` depends on `--recurse`, and
`--suppress-errors` now works in recurse mode as best-effort lookup.
[[#606], [#608]]

Comment thread
dahlia marked this conversation as resolved.
[#606]: https://github.com/fedify-dev/fedify/issues/606
[#608]: https://github.com/fedify-dev/fedify/pull/608

### @fedify/vocab

- Fixed `Endpoints.toJsonLd()` to no longer emit invalid
Expand Down
64 changes: 51 additions & 13 deletions docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,11 +479,6 @@ As you can see, the outputs are separated by `----` by default. You can change
the separator by using the [`-s`/`--separator`](#s-separator-output-separator)
option.

> [!NOTE]
> The `fedify lookup` command cannot take multiple argument if
> [`-t`/`--traverse`](#t-traverse-traverse-the-collection) option is turned
> on.

### `-t`/`--traverse`: Traverse the collection

*This option is available since Fedify 0.14.0.*
Expand All @@ -500,25 +495,68 @@ The difference between with and without the `-t`/`--traverse` option is that
the former will output the objects in the collection, while the latter will
output the collection object itself.

This option only works with a single argument, and it has to be a collection.
When this option is enabled, each argument has to resolve to a collection.

### `--recurse`: Recurse through object relationships

*This option is available since Fedify 2.1.0.*

The `--recurse` option is used to recursively follow an object relationship.
This is useful when you want to walk a reply chain through `replyTarget`.

~~~~ sh
fedify lookup --recurse=replyTarget https://hollo.social/@fedify/019c8522-b247-79d3-b0e7-c6a2293bb1cf
~~~~

You can also provide the fully qualified property IRI:

~~~~ sh
fedify lookup --recurse=https://www.w3.org/ns/activitystreams#inReplyTo https://hollo.social/@fedify/019c8522-b247-79d3-b0e7-c6a2293bb1cf
~~~~

For short names, only Fedify property naming is accepted. For example,
`replyTarget` is accepted, while `inReplyTo` is not accepted as a short form.

> [!NOTE]
> `--recurse` and [`-t`/`--traverse`](#t-traverse-traverse-the-collection)
> are mutually exclusive.

### `--recurse-depth`: Set recursion depth limit

*This option is available since Fedify 2.1.0.*

The `--recurse-depth` option sets the maximum recursion depth when using
[`--recurse`](#recurse-recurse-through-object-relationships). By default, it
is set to `20`.

~~~~ sh
fedify lookup --recurse=replyTarget --recurse-depth=10 https://hollo.social/@fedify/019c8522-b247-79d3-b0e7-c6a2293bb1cf
~~~~

### `-S`/`--suppress-errors`: Suppress partial errors during traversal
This option depends on the `--recurse` option.

### `-S`/`--suppress-errors`: Suppress partial errors during traversal or recursion

*This option is available since Fedify 0.14.0.*

The `-S`/`--suppress-errors` option is used to suppress partial errors during
traversal. For example, the below command looks up a collection object with
the `-t`/`--traverse` option:
traversal or recursion.

For traversal mode:

~~~~ sh
fedify lookup --traverse --suppress-errors https://fosstodon.org/users/hongminhee/outbox
~~~~

The difference between with and without the `-S`/`--suppress-errors` option is
that the former will suppress the partial errors during traversal, while the
latter will stop the traversal when an error occurs.
For recursion mode:

This option depends on the `-t`/`--traverse` option.
~~~~ sh
fedify lookup --recurse=replyTarget --suppress-errors https://hollo.social/@fedify/019c8522-b247-79d3-b0e7-c6a2293bb1cf
~~~~

The difference between with and without the `-S`/`--suppress-errors` option is
that the former will suppress the partial errors during traversal or recursion,
while the latter will stop on the first such error.

### `-c`/`--compact`: Compact JSON-LD

Expand Down
10 changes: 10 additions & 0 deletions packages/cli/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ import {
array,
boolean,
type InferOutput,
integer,
minValue,
number,
object,
optional,
picklist,
pipe,
string,
} from "valibot";

Expand All @@ -31,6 +34,13 @@ const lookupSchema = object({
picklist(["draft-cavage-http-signatures-12", "rfc9421"]),
),
traverse: optional(boolean()),
recurse: optional(
picklist([
"replyTarget",
"https://www.w3.org/ns/activitystreams#inReplyTo",
]),
),
recurseDepth: optional(pipe(number(), integer(), minValue(1))),
suppressErrors: optional(boolean()),
Comment thread
dahlia marked this conversation as resolved.
Outdated
defaultFormat: optional(picklist(["default", "raw", "compact", "expand"])),
separator: optional(string()),
Expand Down
203 changes: 203 additions & 0 deletions packages/cli/src/lookup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import { getContextLoader } from "./docloader.ts";
import {
authorizedFetchOption,
clearTimeoutSignal,
collectRecursiveObjects,
createTimeoutSignal,
getRecursiveTargetId,
lookupCommand,
RecursiveLookupError,
TimeoutError,
writeObjectToStream,
} from "./lookup.ts";
Expand Down Expand Up @@ -303,3 +307,202 @@ test("authorizedFetchOption - parses with -a and --tunnel-service", () => {
assert.strictEqual(result.value.tunnelService, "serveo.net");
}
});

test("lookupCommand - parses recurse option", () => {
setActiveConfig(configContext.id, {});
const result = parse(lookupCommand, [
"lookup",
"--recurse",
"replyTarget",
"https://example.com/notes/1",
]);
clearActiveConfig(configContext.id);
assert.ok(result.success);
if (result.success) {
assert.strictEqual(result.value.recurse, "replyTarget");
assert.strictEqual(result.value.recurseDepth, 20);
assert.strictEqual(result.value.traverse, false);
}
});

test("lookupCommand - rejects recurse-depth without recurse", () => {
setActiveConfig(configContext.id, {});
const result = parse(lookupCommand, [
"lookup",
"--recurse-depth",
"10",
"https://example.com/notes/1",
]);
clearActiveConfig(configContext.id);
assert.ok(!result.success);
});

test("lookupCommand - rejects traverse with recurse", () => {
setActiveConfig(configContext.id, {});
const result = parse(lookupCommand, [
"lookup",
"--traverse",
"--recurse",
"replyTarget",
"https://example.com/notes/1",
]);
clearActiveConfig(configContext.id);
assert.ok(!result.success);
});

test("lookupCommand - rejects short-form inReplyTo", () => {
setActiveConfig(configContext.id, {});
const result = parse(lookupCommand, [
"lookup",
"--recurse",
"inReplyTo",
"https://example.com/notes/1",
]);
clearActiveConfig(configContext.id);
assert.ok(!result.success);
});

test("lookupCommand - accepts IRI inReplyTo", () => {
setActiveConfig(configContext.id, {});
const result = parse(lookupCommand, [
"lookup",
"--recurse",
"https://www.w3.org/ns/activitystreams#inReplyTo",
"https://example.com/notes/1",
]);
clearActiveConfig(configContext.id);
assert.ok(result.success);
if (result.success) {
assert.strictEqual(
result.value.recurse,
"https://www.w3.org/ns/activitystreams#inReplyTo",
);
}
});

test("getRecursiveTargetId - returns reply target for short name", () => {
const replyTarget = new URL("https://example.com/notes/0");
const note = new Note({
id: new URL("https://example.com/notes/1"),
replyTarget,
});
assert.equal(getRecursiveTargetId(note, "replyTarget"), replyTarget);
});

test("getRecursiveTargetId - returns reply target for IRI", () => {
const replyTarget = new URL("https://example.com/notes/0");
const note = new Note({
id: new URL("https://example.com/notes/1"),
replyTarget,
});
assert.equal(
getRecursiveTargetId(
note,
"https://www.w3.org/ns/activitystreams#inReplyTo",
),
replyTarget,
);
});

test("collectRecursiveObjects - follows chain up to depth limit", async () => {
const note1 = new Note({
id: new URL("https://example.com/notes/1"),
replyTarget: new URL("https://example.com/notes/0"),
});
const note0 = new Note({
id: new URL("https://example.com/notes/0"),
});
const objects = new Map<string, Note>([
["https://example.com/notes/0", note0],
]);
const result = await collectRecursiveObjects(
note1,
"replyTarget",
10,
(url) => Promise.resolve(objects.get(url) ?? null),
{ suppressErrors: false },
);
assert.deepEqual(result, [note0]);
});

test("collectRecursiveObjects - respects recurse depth", async () => {
const note2 = new Note({
id: new URL("https://example.com/notes/2"),
replyTarget: new URL("https://example.com/notes/1"),
});
const note1 = new Note({
id: new URL("https://example.com/notes/1"),
replyTarget: new URL("https://example.com/notes/0"),
});
const note0 = new Note({
id: new URL("https://example.com/notes/0"),
});
const objects = new Map<string, Note>([
["https://example.com/notes/1", note1],
["https://example.com/notes/0", note0],
]);
const result = await collectRecursiveObjects(
note2,
"replyTarget",
1,
(url) => Promise.resolve(objects.get(url) ?? null),
{ suppressErrors: false },
);
assert.deepEqual(result, [note1]);
});

test("collectRecursiveObjects - stops on cycle", async () => {
const note2 = new Note({
id: new URL("https://example.com/notes/2"),
replyTarget: new URL("https://example.com/notes/1"),
});
const note1 = new Note({
id: new URL("https://example.com/notes/1"),
replyTarget: new URL("https://example.com/notes/2"),
});
const objects = new Map<string, Note>([
["https://example.com/notes/1", note1],
["https://example.com/notes/2", note2],
]);
const visited = new Set<string>();
const result = await collectRecursiveObjects(
note2,
"replyTarget",
10,
(url) => Promise.resolve(objects.get(url) ?? null),
{ suppressErrors: false, visited },
);
assert.deepEqual(result, [note1]);
});

test("collectRecursiveObjects - throws when lookup fails", async () => {
const note = new Note({
id: new URL("https://example.com/notes/1"),
replyTarget: new URL("https://example.com/notes/0"),
});
await assert.rejects(
collectRecursiveObjects(
note,
"replyTarget",
10,
() => Promise.resolve(null),
{ suppressErrors: false },
),
RecursiveLookupError,
);
});

test("collectRecursiveObjects - suppresses errors in best-effort mode", async () => {
const note = new Note({
id: new URL("https://example.com/notes/1"),
replyTarget: new URL("https://example.com/notes/0"),
});
const result = await collectRecursiveObjects(
note,
"replyTarget",
10,
() => Promise.resolve(null),
{ suppressErrors: true },
);
assert.deepEqual(result, []);
});
Loading
Loading