@fedify/cli 2.0.6 → 2.1.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
@@ -52,7 +52,7 @@ command:
52
52
  # Linux/macOS
53
53
  deno install \
54
54
  -A \
55
- --unstable-fs --unstable-kv --unstable-temporal \
55
+ --unstable-fs --unstable-kv \
56
56
  -n fedify \
57
57
  jsr:@fedify/cli
58
58
  ~~~~
@@ -61,11 +61,14 @@ deno install \
61
61
  # Windows
62
62
  deno install `
63
63
  -A `
64
- --unstable-fs --unstable-kv --unstable-temporal `
64
+ --unstable-fs --unstable-kv `
65
65
  -n fedify `
66
66
  jsr:@fedify/cli
67
67
  ~~~~
68
68
 
69
+ On Deno versions earlier than 2.7.0, add `--unstable-temporal` to the install
70
+ command above.
71
+
69
72
  [Deno]: https://deno.com/
70
73
 
71
74
  ### Downloading the executable
package/dist/config.js CHANGED
@@ -6,7 +6,7 @@ import { printError } from "@optique/run";
6
6
  import { readFileSync } from "node:fs";
7
7
  import { parse } from "smol-toml";
8
8
  import { createConfigContext } from "@optique/config";
9
- import { array, boolean, number, object as object$1, optional as optional$1, picklist, string as string$1 } from "valibot";
9
+ import { array, boolean, check, forward, integer as integer$1, minValue, number, object as object$1, optional as optional$1, picklist, pipe, string as string$1 } from "valibot";
10
10
 
11
11
  //#region src/config.ts
12
12
  /**
@@ -19,11 +19,22 @@ const webfingerSchema = object$1({
19
19
  /**
20
20
  * Schema for the lookup command configuration.
21
21
  */
22
- const lookupSchema = object$1({
22
+ const lookupSchema = pipe(object$1({
23
23
  authorizedFetch: optional$1(boolean()),
24
24
  firstKnock: optional$1(picklist(["draft-cavage-http-signatures-12", "rfc9421"])),
25
+ allowPrivateAddress: optional$1(boolean()),
25
26
  traverse: optional$1(boolean()),
27
+ recurse: optional$1(picklist([
28
+ "replyTarget",
29
+ "quoteUrl",
30
+ "https://www.w3.org/ns/activitystreams#inReplyTo",
31
+ "https://www.w3.org/ns/activitystreams#quoteUrl",
32
+ "https://misskey-hub.net/ns#_misskey_quote",
33
+ "http://fedibird.com/ns#quoteUri"
34
+ ])),
35
+ recurseDepth: optional$1(pipe(number(), integer$1(), minValue(1))),
26
36
  suppressErrors: optional$1(boolean()),
37
+ reverse: optional$1(boolean()),
27
38
  defaultFormat: optional$1(picklist([
28
39
  "default",
29
40
  "raw",
@@ -32,7 +43,7 @@ const lookupSchema = object$1({
32
43
  ])),
33
44
  separator: optional$1(string$1()),
34
45
  timeout: optional$1(number())
35
- });
46
+ }), forward(check((input) => !(input.traverse === true && input.recurse != null), "lookup.traverse and lookup.recurse cannot be used together."), ["recurse"]), forward(check((input) => input.recurse != null || input.recurseDepth == null, "lookup.recurseDepth requires lookup.recurse."), ["recurseDepth"]));
36
47
  /**
37
48
  * Schema for the inbox command configuration.
38
49
  */
package/dist/deno.js CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  //#region deno.json
5
5
  var name = "@fedify/cli";
6
- var version = "2.0.6";
6
+ var version = "2.1.0";
7
7
  var license = "MIT";
8
8
  var exports = "./src/mod.ts";
9
9
  var imports = {
package/dist/docloader.js CHANGED
@@ -7,11 +7,26 @@ import { getKvStore } from "#kv";
7
7
 
8
8
  //#region src/docloader.ts
9
9
  const documentLoaders = {};
10
- async function getDocumentLoader$1({ userAgent } = {}) {
11
- if (documentLoaders[userAgent ?? ""]) return documentLoaders[userAgent ?? ""];
10
+ /**
11
+ * Returns a cache prefix that separates document-loader entries by user agent
12
+ * and private-address policy.
13
+ */
14
+ function getDocumentLoaderCachePrefix(userAgent, allowPrivateAddress) {
15
+ return [
16
+ "_fedify",
17
+ "remoteDocument",
18
+ "cli",
19
+ userAgent ?? "",
20
+ allowPrivateAddress ? "allow-private" : "deny-private"
21
+ ];
22
+ }
23
+ async function getDocumentLoader$1({ userAgent, allowPrivateAddress = false } = {}) {
24
+ const cacheKey = `${userAgent ?? ""}:${allowPrivateAddress}`;
25
+ if (documentLoaders[cacheKey]) return documentLoaders[cacheKey];
12
26
  const kv = await getKvStore();
13
- return documentLoaders[userAgent ?? ""] = kvCache({
27
+ return documentLoaders[cacheKey] = kvCache({
14
28
  kv,
29
+ prefix: getDocumentLoaderCachePrefix(userAgent, allowPrivateAddress),
15
30
  rules: [
16
31
  [new URLPattern({
17
32
  protocol: "http{s}?",
@@ -39,7 +54,7 @@ async function getDocumentLoader$1({ userAgent } = {}) {
39
54
  }), { seconds: 0 }]
40
55
  ],
41
56
  loader: getDocumentLoader({
42
- allowPrivateAddress: true,
57
+ allowPrivateAddress,
43
58
  userAgent
44
59
  })
45
60
  });
@@ -6,6 +6,7 @@ import path from "node:path";
6
6
  import fs from "node:fs/promises";
7
7
  import os from "node:os";
8
8
  import process from "node:process";
9
+ import { validatePublicUrl } from "@fedify/vocab-runtime";
9
10
  import { encodeBase64 } from "byte-encodings/base64";
10
11
 
11
12
  //#region src/imagerenderer.ts
@@ -18,6 +19,22 @@ const KITTY_IDENTIFIERS = [
18
19
  "st",
19
20
  "ghostty"
20
21
  ];
22
+ function getExtensionFromContentType(contentType) {
23
+ const mime = contentType?.split(";")[0]?.trim().toLowerCase() ?? "";
24
+ switch (mime) {
25
+ case "image/jpeg":
26
+ case "image/jpg": return "jpg";
27
+ case "image/png": return "png";
28
+ case "image/gif": return "gif";
29
+ case "image/webp": return "webp";
30
+ case "image/avif": return "avif";
31
+ case "image/bmp": return "bmp";
32
+ case "image/svg+xml": return "svg";
33
+ default:
34
+ if (mime.startsWith("image/")) return mime.slice(6);
35
+ return "jpg";
36
+ }
37
+ }
21
38
  function detectTerminalCapabilities() {
22
39
  const termProgram = (process.env.TERM_PROGRAM || "").toLowerCase();
23
40
  if (KITTY_IDENTIFIERS.includes(termProgram)) return "kitty";
@@ -70,9 +87,37 @@ async function renderImageITerm2(imagePath) {
70
87
  }
71
88
  async function downloadImage(url) {
72
89
  try {
73
- const response = await fetch(url);
90
+ let targetUrl = url;
91
+ let response = null;
92
+ for (let redirectCount = 0; redirectCount < 10; redirectCount++) {
93
+ await validatePublicUrl(targetUrl);
94
+ response = await fetch(targetUrl, { redirect: "manual" });
95
+ if (response.status === 301 || response.status === 302 || response.status === 303 || response.status === 307 || response.status === 308) {
96
+ const location = response.headers.get("location");
97
+ if (location == null) {
98
+ await response.body?.cancel();
99
+ return null;
100
+ }
101
+ await response.body?.cancel();
102
+ targetUrl = new URL(location, targetUrl).href;
103
+ continue;
104
+ }
105
+ break;
106
+ }
107
+ if (response == null) return null;
108
+ if (!response.ok) {
109
+ await response.body?.cancel();
110
+ return null;
111
+ }
74
112
  const imageData = new Uint8Array(await response.arrayBuffer());
75
- const extension = new URL(url).pathname.split(".").pop() || "jpg";
113
+ const pathname = new URL(targetUrl).pathname;
114
+ const lowerPathname = pathname.toLowerCase();
115
+ if (lowerPathname.includes("%2f") || lowerPathname.includes("%5c") || lowerPathname.includes("..")) return null;
116
+ const pathSegments = pathname.split("/").filter((segment) => segment !== "");
117
+ const filename = pathSegments[pathSegments.length - 1] ?? "";
118
+ const extension = filename.includes(".") ? path.extname(filename).slice(1) : getExtensionFromContentType(response.headers.get("content-type"));
119
+ if (extension.length < 1) return null;
120
+ if (extension.includes("/") || extension.includes("\\") || extension.includes("..")) return null;
76
121
  const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "fedify"));
77
122
  const tempPath = path.join(tempDir, `image.${extension}`);
78
123
  await fs.writeFile(tempPath, imageData);
package/dist/lookup.js CHANGED
@@ -9,14 +9,15 @@ import { spawnTemporaryServer } from "./tempserver.js";
9
9
  import { colorEnabled, colors, formatObject } from "./utils.js";
10
10
  import { renderImages } from "./imagerenderer.js";
11
11
  import process from "node:process";
12
- import { argument, choice, command, constant, flag, float, map, merge, message, multiple, object, option, optionNames, optional, or, string, withDefault } from "@optique/core";
13
- import { path, print, printError } from "@optique/run";
12
+ import { argument, choice, command, constant, flag, float, integer, map, merge, message, multiple, object, option, optionNames, optional, or, string, withDefault } from "@optique/core";
13
+ import { path, printError } from "@optique/run";
14
14
  import { createWriteStream } from "node:fs";
15
15
  import { bindConfig } from "@optique/config";
16
16
  import { generateCryptoKeyPair, getAuthenticatedDocumentLoader, respondWithObject } from "@fedify/fedify";
17
17
  import { Application, Collection, CryptographicKey, Object as Object$1, lookupObject, traverseCollection } from "@fedify/vocab";
18
18
  import { getLogger } from "@logtape/logtape";
19
19
  import ora from "ora";
20
+ import { UrlError } from "@fedify/vocab-runtime";
20
21
 
21
22
  //#region src/lookup.ts
22
23
  const logger = getLogger([
@@ -24,6 +25,28 @@ const logger = getLogger([
24
25
  "cli",
25
26
  "lookup"
26
27
  ]);
28
+ const IN_REPLY_TO_IRI = "https://www.w3.org/ns/activitystreams#inReplyTo";
29
+ const QUOTE_URL_IRI = "https://www.w3.org/ns/activitystreams#quoteUrl";
30
+ const MISSKEY_QUOTE_IRI = "https://misskey-hub.net/ns#_misskey_quote";
31
+ const FEDIBIRD_QUOTE_IRI = "http://fedibird.com/ns#quoteUri";
32
+ const recurseProperties = [
33
+ "replyTarget",
34
+ "quoteUrl",
35
+ IN_REPLY_TO_IRI,
36
+ QUOTE_URL_IRI,
37
+ MISSKEY_QUOTE_IRI,
38
+ FEDIBIRD_QUOTE_IRI
39
+ ];
40
+ const suppressErrorsOption = bindConfig(flag("-S", "--suppress-errors", { description: message`Suppress partial errors during traversal or recursion.` }), {
41
+ context: configContext,
42
+ key: (config) => config.lookup?.suppressErrors ?? false,
43
+ default: false
44
+ });
45
+ const allowPrivateAddressOption = bindConfig(flag("-p", "--allow-private-address", { description: message`Allow private IP addresses for explicit lookup/traverse requests.` }), {
46
+ context: configContext,
47
+ key: (config) => config.lookup?.allowPrivateAddress ?? false,
48
+ default: false
49
+ });
27
50
  const authorizedFetchOption = withDefault(object("Authorized fetch options", {
28
51
  authorizedFetch: bindConfig(map(flag("-a", "--authorized-fetch", { description: message`Sign the request with an one-time key.` }), () => true), {
29
52
  context: configContext,
@@ -40,25 +63,50 @@ const authorizedFetchOption = withDefault(object("Authorized fetch options", {
40
63
  firstKnock: void 0,
41
64
  tunnelService: void 0
42
65
  });
43
- const traverseOption = object("Traverse options", {
66
+ const lookupModeOption = withDefault(or(object("Recurse options", {
67
+ traverse: constant(false),
68
+ recurse: bindConfig(option("--recurse", choice(recurseProperties, { metavar: "PROPERTY" }), { description: message`Recursively follow a relationship property.` }), {
69
+ context: configContext,
70
+ key: (config) => config.lookup?.recurse
71
+ }),
72
+ recurseDepth: withDefault(bindConfig(option("--recurse-depth", integer({
73
+ min: 1,
74
+ metavar: "DEPTH"
75
+ }), { description: message`Maximum recursion depth for ${optionNames(["--recurse"])}.` }), {
76
+ context: configContext,
77
+ key: (config) => config.lookup?.recurseDepth
78
+ }), 20),
79
+ suppressErrors: suppressErrorsOption
80
+ }), object("Traverse options", {
44
81
  traverse: bindConfig(flag("-t", "--traverse", { description: message`Traverse the given collection(s) to fetch all items.` }), {
45
82
  context: configContext,
46
83
  key: (config) => config.lookup?.traverse ?? false,
47
84
  default: false
48
85
  }),
49
- suppressErrors: bindConfig(flag("-S", "--suppress-errors", { description: message`Suppress partial errors while traversing the collection.` }), {
86
+ recurse: constant(void 0),
87
+ recurseDepth: constant(void 0),
88
+ suppressErrors: suppressErrorsOption
89
+ })), {
90
+ traverse: false,
91
+ recurse: void 0,
92
+ recurseDepth: void 0,
93
+ suppressErrors: false
94
+ });
95
+ const lookupCommand = command("lookup", merge(object({ command: constant("lookup") }), lookupModeOption, authorizedFetchOption, merge("Network options", userAgentOption, object({
96
+ allowPrivateAddress: allowPrivateAddressOption,
97
+ timeout: optional(bindConfig(option("-T", "--timeout", float({
98
+ min: 0,
99
+ metavar: "SECONDS"
100
+ }), { description: message`Set timeout for network requests in seconds.` }), {
50
101
  context: configContext,
51
- key: (config) => config.lookup?.suppressErrors ?? false,
102
+ key: (config) => config.lookup?.timeout
103
+ }))
104
+ })), object("Arguments", { urls: multiple(argument(string({ metavar: "URL_OR_HANDLE" }), { description: message`One or more URLs or handles to look up.` }), { min: 1 }) }), object("Output options", {
105
+ reverse: bindConfig(flag("--reverse", { description: message`Reverse the output order of fetched objects or items.` }), {
106
+ context: configContext,
107
+ key: (config) => config.lookup?.reverse ?? false,
52
108
  default: false
53
- })
54
- });
55
- const lookupCommand = command("lookup", merge(object({ command: constant("lookup") }), traverseOption, authorizedFetchOption, merge("Network options", userAgentOption, object({ timeout: optional(bindConfig(option("-T", "--timeout", float({
56
- min: 0,
57
- metavar: "SECONDS"
58
- }), { description: message`Set timeout for network requests in seconds.` }), {
59
- context: configContext,
60
- key: (config) => config.lookup?.timeout
61
- })) })), object("Arguments", { urls: multiple(argument(string({ metavar: "URL_OR_HANDLE" }), { description: message`One or more URLs or handles to look up.` }), { min: 1 }) }), object("Output options", {
109
+ }),
62
110
  format: bindConfig(optional(or(map(flag("-r", "--raw", { description: message`Print the fetched JSON-LD document as is.` }), () => "raw"), map(flag("-C", "--compact", { description: message`Compact the fetched JSON-LD document.` }), () => "compact"), map(flag("-e", "--expand", { description: message`Expand the fetched JSON-LD document.` }), () => "expand"))), {
63
111
  context: configContext,
64
112
  key: (config) => config.lookup?.defaultFormat ?? "default",
@@ -87,6 +135,55 @@ var TimeoutError = class extends Error {
87
135
  this.name = "TimeoutError";
88
136
  }
89
137
  };
138
+ /**
139
+ * Error thrown when a recursive lookup target cannot be fetched.
140
+ */
141
+ var RecursiveLookupError = class extends Error {
142
+ target;
143
+ constructor(target) {
144
+ super(`Failed to recursively fetch object: ${target}`);
145
+ this.name = "RecursiveLookupError";
146
+ this.target = target;
147
+ }
148
+ };
149
+ function writeToStream(stream, chunk) {
150
+ return new Promise((resolve, reject) => {
151
+ const onError = (error) => {
152
+ stream.off("error", onError);
153
+ reject(error);
154
+ };
155
+ stream.once("error", onError);
156
+ try {
157
+ stream.write(chunk, (error) => {
158
+ stream.off("error", onError);
159
+ if (error != null) reject(error);
160
+ else resolve();
161
+ });
162
+ } catch (error) {
163
+ stream.off("error", onError);
164
+ reject(error instanceof Error ? error : new Error(String(error)));
165
+ }
166
+ });
167
+ }
168
+ function endWritableStream(stream) {
169
+ return new Promise((resolve, reject) => {
170
+ const onError = (error) => {
171
+ stream.off("error", onError);
172
+ reject(error);
173
+ };
174
+ stream.once("error", onError);
175
+ try {
176
+ stream.end((error) => {
177
+ stream.off("error", onError);
178
+ if (error != null) reject(error);
179
+ else resolve();
180
+ });
181
+ } catch (error) {
182
+ stream.off("error", onError);
183
+ reject(error instanceof Error ? error : new Error(String(error)));
184
+ }
185
+ });
186
+ }
90
187
  async function findAllImages(obj) {
91
188
  const result = [];
92
189
  const icon = await obj.getIcon();
@@ -95,8 +192,9 @@ async function findAllImages(obj) {
95
192
  if (image && image.url instanceof URL) result.push(image.url);
96
193
  return result;
97
194
  }
98
- async function writeObjectToStream(object$1, outputPath, format, contextLoader) {
99
- const stream = outputPath ? createWriteStream(outputPath) : process.stdout;
195
+ async function writeObjectToStream(object$1, outputPath, format, contextLoader, stream) {
196
+ const localStream = stream ?? (outputPath ? createWriteStream(outputPath) : process.stdout);
197
+ const localFileStream = stream == null && outputPath != null ? localStream : void 0;
100
198
  let content;
101
199
  let json = true;
102
200
  let imageUrls = [];
@@ -117,13 +215,37 @@ async function writeObjectToStream(object$1, outputPath, format, contextLoader)
117
215
  content = object$1;
118
216
  json = false;
119
217
  }
120
- const enableColors = colorEnabled && outputPath === void 0;
218
+ const enableColors = colorEnabled && localStream === process.stdout;
121
219
  content = formatObject(content, enableColors, json);
122
220
  const encoder = new TextEncoder();
123
221
  const bytes = encoder.encode(content + "\n");
124
- stream.write(bytes);
222
+ await writeToStream(localStream, bytes);
223
+ if (localFileStream != null) await endWritableStream(localFileStream);
125
224
  if (object$1 instanceof Object$1) imageUrls = await findAllImages(object$1);
126
- if (!outputPath && imageUrls.length > 0) await renderImages(imageUrls);
225
+ if (localStream === process.stdout && imageUrls.length > 0) await renderImages(imageUrls);
226
+ }
227
+ async function closeWriteStream(stream) {
228
+ if (stream == null) return;
229
+ await endWritableStream(stream);
230
+ }
231
+ async function writeSeparator(separator, stream) {
232
+ await writeToStream(stream ?? process.stdout, `${separator}\n`);
233
+ }
234
+ function toPresentationOrder(items, reverse) {
235
+ if (reverse) return [...items].reverse();
236
+ return items;
237
+ }
238
+ async function collectAsyncItems(iterable) {
239
+ const items = [];
240
+ try {
241
+ for await (const item of iterable) items.push(item);
242
+ return { items };
243
+ } catch (error) {
244
+ return {
245
+ items,
246
+ error
247
+ };
248
+ }
127
249
  }
128
250
  const signalTimers = /* @__PURE__ */ new WeakMap();
129
251
  function createTimeoutSignal(timeoutSeconds) {
@@ -158,22 +280,152 @@ function handleTimeoutError(spinner, timeoutSeconds, url) {
158
280
  spinner.fail(`Request timed out after ${timeoutSeconds} seconds${urlText}.`);
159
281
  printError(message`Try increasing the timeout with -T/--timeout option or check network connectivity.`);
160
282
  }
161
- async function runLookup(command$1) {
283
+ function isPrivateAddressError(error) {
284
+ const errorMessage = error instanceof Error ? error.message : String(error);
285
+ const lowerMessage = errorMessage.toLowerCase();
286
+ if (error instanceof UrlError) return lowerMessage.includes("invalid or private address") || lowerMessage.includes("localhost is not allowed");
287
+ return lowerMessage.includes("private address") || lowerMessage.includes("private ip") || lowerMessage.includes("localhost") || lowerMessage.includes("loopback");
288
+ }
289
+ function getLookupFailureHint(error, options = {}) {
290
+ if (isPrivateAddressError(error)) return options.recursive ? "recursive-private-address" : "private-address";
291
+ return "authorized-fetch";
292
+ }
293
+ function shouldPrintLookupFailureHint(authLoader, hint) {
294
+ return hint !== "authorized-fetch" || authLoader == null;
295
+ }
296
+ function shouldSuggestSuppressErrorsForLookupFailure(authLoader, hint) {
297
+ return authLoader != null && hint === "authorized-fetch";
298
+ }
299
+ function printLookupFailureHint(authLoader, error, options = {}) {
300
+ const hint = getLookupFailureHint(error, options);
301
+ if (!shouldPrintLookupFailureHint(authLoader, hint)) return;
302
+ switch (hint) {
303
+ case "private-address":
304
+ printError(message`The URL appears to be private or localhost. Try with -p/--allow-private-address.`);
305
+ return;
306
+ case "recursive-private-address":
307
+ printError(message`Recursive fetches do not allow private/localhost URLs. Use -S/--suppress-errors to skip blocked steps, or fetch those targets explicitly without --recurse.`);
308
+ return;
309
+ case "authorized-fetch":
310
+ printError(message`It may be a private object. Try with -a/--authorized-fetch.`);
311
+ return;
312
+ }
313
+ }
314
+ /**
315
+ * Gets the next recursion target URL from an ActivityPub object.
316
+ */
317
+ function getRecursiveTargetId(object$1, recurseProperty) {
318
+ switch (recurseProperty) {
319
+ case "replyTarget":
320
+ case IN_REPLY_TO_IRI: return object$1.replyTargetId;
321
+ case "quoteUrl":
322
+ case QUOTE_URL_IRI:
323
+ case MISSKEY_QUOTE_IRI:
324
+ case FEDIBIRD_QUOTE_IRI: {
325
+ const quoteUrl = object$1.quoteUrl;
326
+ return quoteUrl instanceof URL ? quoteUrl : null;
327
+ }
328
+ default: return null;
329
+ }
330
+ }
331
+ /**
332
+ * Collects recursively linked objects up to a depth limit.
333
+ */
334
+ async function collectRecursiveObjects(initialObject, recurseProperty, recurseDepth, lookup, options) {
335
+ const visited = options.visited ?? /* @__PURE__ */ new Set();
336
+ const results = [];
337
+ let current = initialObject;
338
+ if (current.id != null) visited.add(current.id.href);
339
+ for (let depth = 0; depth < recurseDepth; depth++) {
340
+ const targetId = getRecursiveTargetId(current, recurseProperty);
341
+ if (targetId == null) break;
342
+ const target = targetId.href;
343
+ if (visited.has(target)) break;
344
+ let next;
345
+ try {
346
+ next = await lookup(target);
347
+ } catch (error) {
348
+ if (options.suppressErrors) {
349
+ logger.debug("Failed to recursively fetch object {target}, but suppressing error: {error}", {
350
+ target,
351
+ error
352
+ });
353
+ break;
354
+ }
355
+ throw error;
356
+ }
357
+ if (next == null) {
358
+ if (options.suppressErrors) {
359
+ logger.debug("Failed to recursively fetch object {target} (not found), but suppressing error.", { target });
360
+ break;
361
+ }
362
+ throw new RecursiveLookupError(target);
363
+ }
364
+ results.push(next);
365
+ visited.add(target);
366
+ if (next.id != null) visited.add(next.id.href);
367
+ current = next;
368
+ }
369
+ return results;
370
+ }
371
+ async function runLookup(command$1, deps = {}) {
372
+ const effectiveDeps = {
373
+ lookupObject,
374
+ traverseCollection,
375
+ exit: (code) => process.exit(code),
376
+ ...deps
377
+ };
162
378
  if (command$1.urls.length < 1) {
163
379
  printError(message`At least one URL or actor handle must be provided.`);
164
- process.exit(1);
380
+ effectiveDeps.exit(1);
165
381
  }
166
382
  if (command$1.debug) await configureLogging();
167
383
  const spinner = ora({
168
- text: `Looking up the ${command$1.traverse ? "collection" : command$1.urls.length > 1 ? "objects" : "object"}...`,
384
+ text: `Looking up the ${command$1.recurse != null ? "object chain" : command$1.traverse ? "collection" : command$1.urls.length > 1 ? "objects" : "object"}...`,
169
385
  discardStdin: false
170
386
  }).start();
171
387
  let server = void 0;
172
- const baseDocumentLoader = await getDocumentLoader({ userAgent: command$1.userAgent });
388
+ const baseDocumentLoader = await getDocumentLoader({
389
+ userAgent: command$1.userAgent,
390
+ allowPrivateAddress: command$1.allowPrivateAddress
391
+ });
173
392
  const documentLoader = wrapDocumentLoaderWithTimeout(baseDocumentLoader, command$1.timeout);
174
- const baseContextLoader = await getContextLoader({ userAgent: command$1.userAgent });
393
+ const baseContextLoader = await getContextLoader({
394
+ userAgent: command$1.userAgent,
395
+ allowPrivateAddress: command$1.allowPrivateAddress
396
+ });
175
397
  const contextLoader = wrapDocumentLoaderWithTimeout(baseContextLoader, command$1.timeout);
176
398
  let authLoader = void 0;
399
+ let authIdentity = void 0;
400
+ let outputStream;
401
+ let outputStreamError;
402
+ const getOutputStream = () => {
403
+ if (command$1.output == null) return void 0;
404
+ if (outputStream == null) {
405
+ outputStream = createWriteStream(command$1.output);
406
+ outputStream.once("error", (error) => {
407
+ outputStreamError = error;
408
+ });
409
+ }
410
+ if (outputStreamError != null) throw outputStreamError;
411
+ return outputStream;
412
+ };
413
+ const finalizeAndExit = async (code) => {
414
+ let cleanupFailed = false;
415
+ try {
416
+ await closeWriteStream(outputStream);
417
+ } catch (error) {
418
+ cleanupFailed = true;
419
+ logger.error("Failed to close output stream during shutdown: {error}", { error });
420
+ }
421
+ try {
422
+ await server?.close();
423
+ } catch (error) {
424
+ cleanupFailed = true;
425
+ logger.error("Failed to close temporary server during shutdown: {error}", { error });
426
+ }
427
+ effectiveDeps.exit(cleanupFailed && code === 0 ? 1 : code);
428
+ };
177
429
  if (command$1.authorizedFetch) {
178
430
  spinner.text = "Generating a one-time key pair...";
179
431
  const key = await generateCryptoKeyPair();
@@ -205,26 +457,174 @@ async function runLookup(command$1) {
205
457
  outbox: new URL("/outbox", serverUrl)
206
458
  }), { contextLoader });
207
459
  }, { service: command$1.tunnelService });
208
- const baseAuthLoader = getAuthenticatedDocumentLoader({
460
+ authIdentity = {
209
461
  keyId: new URL("#main-key", server.url),
210
462
  privateKey: key.privateKey
211
- }, { specDeterminer: {
212
- determineSpec() {
213
- return command$1.firstKnock;
214
- },
215
- rememberSpec() {}
216
- } });
463
+ };
464
+ const baseAuthLoader = getAuthenticatedDocumentLoader(authIdentity, {
465
+ allowPrivateAddress: command$1.allowPrivateAddress,
466
+ userAgent: command$1.userAgent,
467
+ specDeterminer: {
468
+ determineSpec() {
469
+ return command$1.firstKnock;
470
+ },
471
+ rememberSpec() {}
472
+ }
473
+ });
217
474
  authLoader = wrapDocumentLoaderWithTimeout(baseAuthLoader, command$1.timeout);
218
475
  }
219
- spinner.text = `Looking up the ${command$1.traverse ? "collection" : command$1.urls.length > 1 ? "objects" : "object"}...`;
476
+ spinner.text = `Looking up the ${command$1.recurse != null ? "object chain" : command$1.traverse ? "collection" : command$1.urls.length > 1 ? "objects" : "object"}...`;
477
+ if (command$1.recurse != null) {
478
+ const recursiveBaseDocumentLoader = await getDocumentLoader({
479
+ userAgent: command$1.userAgent,
480
+ allowPrivateAddress: false
481
+ });
482
+ const recursiveDocumentLoader = wrapDocumentLoaderWithTimeout(recursiveBaseDocumentLoader, command$1.timeout);
483
+ const recursiveBaseContextLoader = await getContextLoader({
484
+ userAgent: command$1.userAgent,
485
+ allowPrivateAddress: false
486
+ });
487
+ const recursiveContextLoader = wrapDocumentLoaderWithTimeout(recursiveBaseContextLoader, command$1.timeout);
488
+ const recursiveAuthLoader = command$1.authorizedFetch && authIdentity != null ? wrapDocumentLoaderWithTimeout(getAuthenticatedDocumentLoader(authIdentity, {
489
+ allowPrivateAddress: false,
490
+ userAgent: command$1.userAgent,
491
+ specDeterminer: {
492
+ determineSpec() {
493
+ return command$1.firstKnock;
494
+ },
495
+ rememberSpec() {}
496
+ }
497
+ }), command$1.timeout) : void 0;
498
+ const initialLookupDocumentLoader = authLoader ?? documentLoader;
499
+ const recursiveLookupDocumentLoader = recursiveAuthLoader ?? recursiveDocumentLoader;
500
+ let totalObjects = 0;
501
+ const recurseDepth = command$1.recurseDepth;
502
+ for (let urlIndex = 0; urlIndex < command$1.urls.length; urlIndex++) {
503
+ const visited = /* @__PURE__ */ new Set();
504
+ const url = command$1.urls[urlIndex];
505
+ if (urlIndex > 0) spinner.text = `Looking up object chain ${urlIndex + 1}/${command$1.urls.length}...`;
506
+ let current = null;
507
+ try {
508
+ current = await effectiveDeps.lookupObject(url, {
509
+ documentLoader: initialLookupDocumentLoader,
510
+ contextLoader,
511
+ userAgent: command$1.userAgent
512
+ });
513
+ } catch (error) {
514
+ if (error instanceof TimeoutError) handleTimeoutError(spinner, command$1.timeout, url);
515
+ else {
516
+ spinner.fail(`Failed to fetch object: ${colors.red(url)}.`);
517
+ printLookupFailureHint(authLoader, error);
518
+ }
519
+ await finalizeAndExit(1);
520
+ return;
521
+ }
522
+ if (current == null) {
523
+ spinner.fail(`Failed to fetch object: ${colors.red(url)}.`);
524
+ if (authLoader == null) printError(message`It may be a private object. Try with -a/--authorized-fetch.`);
525
+ await finalizeAndExit(1);
526
+ return;
527
+ }
528
+ visited.add(url);
529
+ if (current.id != null) visited.add(current.id.href);
530
+ if (!command$1.reverse) try {
531
+ if (totalObjects > 0) await writeSeparator(command$1.separator, getOutputStream());
532
+ await writeObjectToStream(current, command$1.output, command$1.format, contextLoader, getOutputStream());
533
+ totalObjects++;
534
+ } catch (error) {
535
+ logger.error("Failed to write lookup output: {error}", { error });
536
+ spinner.fail("Failed to write output.");
537
+ await finalizeAndExit(1);
538
+ return;
539
+ }
540
+ let chain = [];
541
+ try {
542
+ chain = await collectRecursiveObjects(current, command$1.recurse, recurseDepth, (target) => effectiveDeps.lookupObject(target, {
543
+ documentLoader: recursiveLookupDocumentLoader,
544
+ contextLoader: recursiveContextLoader,
545
+ userAgent: command$1.userAgent
546
+ }), {
547
+ suppressErrors: command$1.suppressErrors,
548
+ visited
549
+ });
550
+ } catch (error) {
551
+ if (command$1.reverse) try {
552
+ if (totalObjects > 0) await writeSeparator(command$1.separator, getOutputStream());
553
+ await writeObjectToStream(current, command$1.output, command$1.format, contextLoader, getOutputStream());
554
+ totalObjects++;
555
+ } catch (writeError) {
556
+ logger.error("Failed to write lookup output: {error}", { error: writeError });
557
+ spinner.fail("Failed to write output.");
558
+ await finalizeAndExit(1);
559
+ return;
560
+ }
561
+ logger.error("Failed to recursively fetch an object in chain: {error}", { error });
562
+ if (error instanceof TimeoutError) handleTimeoutError(spinner, command$1.timeout);
563
+ else if (error instanceof RecursiveLookupError) {
564
+ spinner.fail(`Failed to recursively fetch object: ${colors.red(error.target)}.`);
565
+ if (authLoader == null) printError(message`It may be a private object. Try with -a/--authorized-fetch.`);
566
+ } else {
567
+ spinner.fail("Failed to recursively fetch object.");
568
+ const hint = getLookupFailureHint(error, { recursive: true });
569
+ if (shouldSuggestSuppressErrorsForLookupFailure(authLoader, hint)) printError(message`Use the -S/--suppress-errors option to suppress partial errors.`);
570
+ else printLookupFailureHint(authLoader, error, { recursive: true });
571
+ }
572
+ await finalizeAndExit(1);
573
+ return;
574
+ }
575
+ if (command$1.reverse) {
576
+ const chainEntries = [{
577
+ object: current,
578
+ objectContextLoader: contextLoader
579
+ }, ...chain.map((next) => ({
580
+ object: next,
581
+ objectContextLoader: recursiveContextLoader
582
+ }))];
583
+ for (let chainIndex = chainEntries.length - 1; chainIndex >= 0; chainIndex--) {
584
+ const entry = chainEntries[chainIndex];
585
+ try {
586
+ if (totalObjects > 0 || chainIndex < chainEntries.length - 1) await writeSeparator(command$1.separator, getOutputStream());
587
+ await writeObjectToStream(entry.object, command$1.output, command$1.format, entry.objectContextLoader, getOutputStream());
588
+ totalObjects++;
589
+ } catch (error) {
590
+ logger.error("Failed to write lookup output: {error}", { error });
591
+ spinner.fail("Failed to write output.");
592
+ await finalizeAndExit(1);
593
+ return;
594
+ }
595
+ }
596
+ } else {
597
+ const chainEntries = chain.map((next) => ({
598
+ object: next,
599
+ objectContextLoader: recursiveContextLoader
600
+ }));
601
+ for (let chainIndex = 0; chainIndex < chainEntries.length; chainIndex++) {
602
+ const entry = chainEntries[chainIndex];
603
+ try {
604
+ if (totalObjects > 0 || chainIndex > 0) await writeSeparator(command$1.separator, getOutputStream());
605
+ await writeObjectToStream(entry.object, command$1.output, command$1.format, entry.objectContextLoader, getOutputStream());
606
+ totalObjects++;
607
+ } catch (error) {
608
+ logger.error("Failed to write lookup output: {error}", { error });
609
+ spinner.fail("Failed to write output.");
610
+ await finalizeAndExit(1);
611
+ return;
612
+ }
613
+ }
614
+ }
615
+ }
616
+ spinner.succeed("Successfully fetched all reachable objects in the chain.");
617
+ await finalizeAndExit(0);
618
+ return;
619
+ }
220
620
  if (command$1.traverse) {
221
621
  let totalItems = 0;
222
622
  for (let urlIndex = 0; urlIndex < command$1.urls.length; urlIndex++) {
223
623
  const url = command$1.urls[urlIndex];
224
624
  if (urlIndex > 0) spinner.text = `Looking up collection ${urlIndex + 1}/${command$1.urls.length}...`;
225
- let collection;
625
+ let collection = null;
226
626
  try {
227
- collection = await lookupObject(url, {
627
+ collection = await effectiveDeps.lookupObject(url, {
228
628
  documentLoader: authLoader ?? documentLoader,
229
629
  contextLoader,
230
630
  userAgent: command$1.userAgent
@@ -233,33 +633,64 @@ async function runLookup(command$1) {
233
633
  if (error instanceof TimeoutError) handleTimeoutError(spinner, command$1.timeout, url);
234
634
  else {
235
635
  spinner.fail(`Failed to fetch object: ${colors.red(url)}.`);
236
- if (authLoader == null) printError(message`It may be a private object. Try with -a/--authorized-fetch.`);
636
+ printLookupFailureHint(authLoader, error);
237
637
  }
238
- await server?.close();
239
- process.exit(1);
638
+ await finalizeAndExit(1);
639
+ return;
240
640
  }
241
641
  if (collection == null) {
242
642
  spinner.fail(`Failed to fetch object: ${colors.red(url)}.`);
243
643
  if (authLoader == null) printError(message`It may be a private object. Try with -a/--authorized-fetch.`);
244
- await server?.close();
245
- process.exit(1);
644
+ await finalizeAndExit(1);
645
+ return;
246
646
  }
247
647
  if (!(collection instanceof Collection)) {
248
648
  spinner.fail(`Not a collection: ${colors.red(url)}. The -t/--traverse option requires a collection.`);
249
- await server?.close();
250
- process.exit(1);
649
+ await finalizeAndExit(1);
650
+ return;
251
651
  }
252
652
  spinner.succeed(`Fetched collection: ${colors.green(url)}.`);
253
653
  try {
254
- let collectionItems = 0;
255
- for await (const item of traverseCollection(collection, {
654
+ if (command$1.reverse) {
655
+ const { items: traversedItems, error: traversalError } = await collectAsyncItems(effectiveDeps.traverseCollection(collection, {
656
+ documentLoader: authLoader ?? documentLoader,
657
+ contextLoader,
658
+ suppressError: command$1.suppressErrors
659
+ }));
660
+ for (let index = traversedItems.length - 1; index >= 0; index--) {
661
+ const item = traversedItems[index];
662
+ try {
663
+ if (totalItems > 0) await writeSeparator(command$1.separator, getOutputStream());
664
+ await writeObjectToStream(item, command$1.output, command$1.format, contextLoader, getOutputStream());
665
+ } catch (error) {
666
+ logger.error("Failed to write output for {url}: {error}", {
667
+ url,
668
+ error
669
+ });
670
+ spinner.fail(`Failed to write output for: ${colors.red(url)}.`);
671
+ await finalizeAndExit(1);
672
+ return;
673
+ }
674
+ totalItems++;
675
+ }
676
+ if (traversalError != null) throw traversalError;
677
+ } else for await (const item of effectiveDeps.traverseCollection(collection, {
256
678
  documentLoader: authLoader ?? documentLoader,
257
679
  contextLoader,
258
680
  suppressError: command$1.suppressErrors
259
681
  })) {
260
- if (!command$1.output && (totalItems > 0 || collectionItems > 0)) print(message`${command$1.separator}`);
261
- await writeObjectToStream(item, command$1.output, command$1.format, contextLoader);
262
- collectionItems++;
682
+ try {
683
+ if (totalItems > 0) await writeSeparator(command$1.separator, getOutputStream());
684
+ await writeObjectToStream(item, command$1.output, command$1.format, contextLoader, getOutputStream());
685
+ } catch (error) {
686
+ logger.error("Failed to write output for {url}: {error}", {
687
+ url,
688
+ error
689
+ });
690
+ spinner.fail(`Failed to write output for: ${colors.red(url)}.`);
691
+ await finalizeAndExit(1);
692
+ return;
693
+ }
263
694
  totalItems++;
264
695
  }
265
696
  } catch (error) {
@@ -270,19 +701,20 @@ async function runLookup(command$1) {
270
701
  if (error instanceof TimeoutError) handleTimeoutError(spinner, command$1.timeout, url);
271
702
  else {
272
703
  spinner.fail(`Failed to complete the traversal for: ${colors.red(url)}.`);
273
- if (authLoader == null) printError(message`It may be a private object. Try with -a/--authorized-fetch.`);
274
- else printError(message`Use the -S/--suppress-errors option to suppress partial errors.`);
704
+ const hint = getLookupFailureHint(error);
705
+ if (shouldSuggestSuppressErrorsForLookupFailure(authLoader, hint)) printError(message`Use the -S/--suppress-errors option to suppress partial errors.`);
706
+ else printLookupFailureHint(authLoader, error);
275
707
  }
276
- await server?.close();
277
- process.exit(1);
708
+ await finalizeAndExit(1);
709
+ return;
278
710
  }
279
711
  }
280
712
  spinner.succeed("Successfully fetched all items in the collection.");
281
- await server?.close();
282
- process.exit(0);
713
+ await finalizeAndExit(0);
714
+ return;
283
715
  }
284
716
  const promises = [];
285
- for (const url of command$1.urls) promises.push(lookupObject(url, {
717
+ for (const url of command$1.urls) promises.push(effectiveDeps.lookupObject(url, {
286
718
  documentLoader: authLoader ?? documentLoader,
287
719
  contextLoader,
288
720
  userAgent: command$1.userAgent
@@ -290,33 +722,54 @@ async function runLookup(command$1) {
290
722
  if (error instanceof TimeoutError) handleTimeoutError(spinner, command$1.timeout, url);
291
723
  throw error;
292
724
  }));
293
- let objects;
725
+ let objects = [];
294
726
  try {
295
727
  objects = await Promise.all(promises);
296
728
  } catch (_error) {
297
- await server?.close();
298
- process.exit(1);
729
+ await finalizeAndExit(1);
730
+ return;
299
731
  }
300
732
  spinner.stop();
301
733
  let success = true;
302
- let i = 0;
303
- for (const obj of objects) {
734
+ let printedCount = 0;
735
+ const successfulObjects = [];
736
+ for (const [i, obj] of objects.entries()) {
304
737
  const url = command$1.urls[i];
305
- if (i > 0) print(message`${command$1.separator}`);
306
- i++;
307
738
  if (obj == null) {
308
739
  spinner.fail(`Failed to fetch ${colors.red(url)}`);
309
740
  if (authLoader == null) printError(message`It may be a private object. Try with -a/--authorized-fetch.`);
310
741
  success = false;
311
742
  } else {
312
743
  spinner.succeed(`Fetched object: ${colors.green(url)}`);
313
- await writeObjectToStream(obj, command$1.output, command$1.format, contextLoader);
314
- if (i < command$1.urls.length - 1) print(message`${command$1.separator}`);
744
+ successfulObjects.push(obj);
315
745
  }
316
746
  }
747
+ for (const obj of toPresentationOrder(successfulObjects, command$1.reverse)) {
748
+ try {
749
+ if (printedCount > 0) await writeSeparator(command$1.separator, getOutputStream());
750
+ await writeObjectToStream(obj, command$1.output, command$1.format, contextLoader, getOutputStream());
751
+ } catch (error) {
752
+ logger.error("Failed to write lookup output: {error}", { error });
753
+ spinner.fail("Failed to write output.");
754
+ await finalizeAndExit(1);
755
+ return;
756
+ }
757
+ printedCount++;
758
+ }
317
759
  if (success) spinner.succeed(command$1.urls.length > 1 ? "Successfully fetched all objects." : "Successfully fetched the object.");
318
- await server?.close();
319
- if (!success) process.exit(1);
760
+ if (!success) {
761
+ await finalizeAndExit(1);
762
+ return;
763
+ }
764
+ try {
765
+ await closeWriteStream(outputStream);
766
+ await server?.close();
767
+ } catch (error) {
768
+ logger.error("Failed to finalize lookup resources: {error}", { error });
769
+ spinner.fail("Failed to finalize output.");
770
+ await finalizeAndExit(1);
771
+ return;
772
+ }
320
773
  if (success && command$1.output) spinner.succeed(`Successfully wrote output to ${colors.green(command$1.output)}.`);
321
774
  }
322
775
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/cli",
3
- "version": "2.0.6",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "README.md",
@@ -58,16 +58,16 @@
58
58
  },
59
59
  "dependencies": {
60
60
  "@fxts/core": "^1.20.0",
61
- "@optique/config": "^0.10.6",
62
- "@optique/core": "^0.10.6",
63
- "@optique/run": "^0.10.6",
61
+ "@optique/config": "^0.10.7",
62
+ "@optique/core": "^0.10.7",
63
+ "@optique/run": "^0.10.7",
64
64
  "@hongminhee/localtunnel": "^0.3.0",
65
65
  "@inquirer/prompts": "^7.8.4",
66
66
  "@jimp/core": "^1.6.0",
67
67
  "@jimp/wasm-webp": "^1.6.0",
68
68
  "@js-temporal/polyfill": "^0.5.1",
69
- "@logtape/file": "^2.0.0",
70
- "@logtape/logtape": "^2.0.0",
69
+ "@logtape/file": "^2.0.5",
70
+ "@logtape/logtape": "^2.0.5",
71
71
  "@poppanator/http-constants": "^1.1.1",
72
72
  "byte-encodings": "^1.0.11",
73
73
  "chalk": "^5.6.2",
@@ -86,14 +86,14 @@
86
86
  "smol-toml": "^1.6.0",
87
87
  "srvx": "^0.8.7",
88
88
  "valibot": "^1.2.0",
89
- "@fedify/fedify": "2.0.6",
90
- "@fedify/init": "2.0.6",
91
- "@fedify/relay": "2.0.6",
92
- "@fedify/sqlite": "2.0.6",
93
- "@fedify/vocab": "2.0.6",
94
- "@fedify/vocab-runtime": "2.0.6",
95
- "@fedify/vocab-tools": "2.0.6",
96
- "@fedify/webfinger": "2.0.6"
89
+ "@fedify/fedify": "2.1.0",
90
+ "@fedify/init": "2.1.0",
91
+ "@fedify/vocab-runtime": "2.1.0",
92
+ "@fedify/sqlite": "2.1.0",
93
+ "@fedify/vocab-tools": "2.1.0",
94
+ "@fedify/vocab": "2.1.0",
95
+ "@fedify/webfinger": "2.1.0",
96
+ "@fedify/relay": "2.1.0"
97
97
  },
98
98
  "devDependencies": {
99
99
  "@types/bun": "^1.2.23",