From 624631eb95bce6e2178a3f4c83a3c0221ea75f64 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 1 Sep 2025 12:32:08 +0000 Subject: [PATCH 01/14] Added Webfinger CLI --- packages/cli/src/webfinger.ts | 139 +++++++++++++++++++++++++++++++++- 1 file changed, 136 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/webfinger.ts b/packages/cli/src/webfinger.ts index 4d5a3c91b..dc24f4f02 100644 --- a/packages/cli/src/webfinger.ts +++ b/packages/cli/src/webfinger.ts @@ -1,13 +1,50 @@ +import { toAcctUrl } from "@fedify/fedify"; +import { + lookupWebFinger, + type LookupWebFingerOptions, +} from "@fedify/fedify/webfinger"; +import { getLogger } from "@logtape/logtape"; import { argument, command, constant, + flag, type InferValue, + integer, message, multiple, object, + option, + optional, string, + withDefault, } from "@optique/core"; +import ora from "ora"; +import { formatObject } from "./utils.ts"; + +const logger = getLogger(["fedify", "cli", "webfinger"]); + +const userAgent = optional(option( + "-u", + "--user-agent", + string({ metavar: "USER_AGENT" }), + { + description: message`The custom User-Agent header value.`, + }, +)); + +const allowPrivateAddresses = optional(flag("-p", "--allow-private-address", { + description: message`Allow private IP addresses in the URL.`, +})); + +const maxRedirection = withDefault( + option( + "--max-redirection", + integer({ min: 0 }), + { description: message`Maximum number of redirections to follow.` }, + ), + 5, +); export const webFingerCommand = command( "webfinger", @@ -16,14 +53,110 @@ export const webFingerCommand = command( resources: multiple(argument(string({ metavar: "RESOURCE" }), { description: message`WebFinger resource(s) to look up.`, })), + userAgent, + allowPrivateAddresses, + maxRedirection, }), { - description: message`Look up WebFinger resources.`, + description: + message`Look up WebFinger resources. The argument can be multiple.`, }, ); export function runWebFinger( - command: InferValue, + { command: _, resources, ...options }: InferValue, ) { - console.debug(command); + lookupWebFingers(options, resources); +} + +const lookupWebFingers = async ( + options: LookupWebFingerOptions, + resources: readonly string[], +) => { + for (const resource of resources) { + const spinner = ora({ // Create a spinner for the lookup process + text: `Looking up WebFinger for ${resource}`, + discardStdin: false, + }).start(); + try { + const url = convertUrlIfHandle(resource); // Convert resource to URL + const webFinger = await lookupWebFinger(url, options) ?? // Look up WebFinger + new NotFoundError(resource).throw(); // throw NotFoundError if not found + + spinner.succeed(`WebFinger found for ${resource}:`); // Succeed the spinner + console.log(formatObject(webFinger, undefined, true)); // Print the WebFinger + } catch (error) { + if (error instanceof InvalidHandleError) { // If the handle format is invalid, + spinner.fail(`Invalid handle format: ${error.handle}`); // log error message with handle + } else if (error instanceof NotFoundError) { // If the resource is not found, + spinner.fail(`Resource not found: ${error.resource}`); // log not found message + } else if (error instanceof Error) { + spinner.fail( // For other errors, log the error message + `Error looking up WebFinger for ${resource}: ${error}`, + ); + } + } + } +}; + +/** + * Converts a handle or URL to a URL object. + * If the input is a valid URL, it returns the URL object. + * If the input is a handle in the format `@username@domain`, it converts it to a URL. + * @param handleOrUrl The handle or URL to convert. + * @returns A URL object representing the handle or URL. + */ +function convertUrlIfHandle(handleOrUrl: string): URL { + try { + return new URL(handleOrUrl); // Try to convert the input to a URL + } catch { + return convertHandleToUrl(handleOrUrl); // If it fails, treat it as a handle + } +} + +/** + * Custom error class for invalid handle formats. + * @param {string} handle The invalid handle that caused the error. + * @extends {Error} + */ +class InvalidHandleError extends Error { + constructor(public handle: string) { + super(`Invalid handle format: ${handle}`); + this.name = "InvalidHandleError"; + } + throw(): never { + throw this; + } +} + +/** + * Custom error class for not found resources. + * @param {string} resource The resource that was not found. + * @extends {Error} + */ +class NotFoundError extends Error { + constructor(public resource: string) { + super(`Resource not found: ${resource}`); + this.name = "NotFoundError"; + } + throw(): never { + throw this; + } +} + +/** + * Converts a handle in the format `@username@domain` to a URL. + * The resulting URL will be in the format `https://domain/@username`. + * @param handle The handle to convert, in the format `@username@domain`. + * @returns A URL object representing the handle. + * @throws {Error} If the handle format is invalid. + * @example + * ```ts + * const url = convertHandleToUrl("@username@domain.com"); + * console.log(url.toString()); // "https://domain.com/@username" + * ``` + */ +export function convertHandleToUrl(handle: string): URL { + return toAcctUrl(handle) ?? // Convert the handle to a URL + new InvalidHandleError(handle).throw(); // or throw an error if invalid } From d8f6732b3aca8403b79910411aa5f1c7caa0877f Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 1 Sep 2025 12:32:27 +0000 Subject: [PATCH 02/14] Added cspell words --- cspell.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cspell.json b/cspell.json index 5f240a731..5ea6843f4 100644 --- a/cspell.json +++ b/cspell.json @@ -56,6 +56,7 @@ "lume", "lumocs", "metas", + "metavar", "microblog", "microblogging", "Minhee", @@ -67,6 +68,7 @@ "multitenancy", "Nexkey", "nodeinfo", + "optique", "phensley", "Pico", "Pixelfed", @@ -101,6 +103,7 @@ "unfollows", "urlpattern", "uuidv7", + "valueparser", "Vinxi", "vitepress", "vtsls", From 66c00f14fef4ceb2403e3bdbd9c571b43ff2363b Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 1 Sep 2025 13:37:22 +0000 Subject: [PATCH 03/14] Added test for Webfinger CLI --- cspell.json | 1 + packages/cli/src/mod.ts | 14 +++++++------- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/cspell.json b/cspell.json index 5ea6843f4..f96ad4611 100644 --- a/cspell.json +++ b/cspell.json @@ -108,6 +108,7 @@ "vitepress", "vtsls", "webfinger", + "webfingers", "webp" ] } diff --git a/packages/cli/src/mod.ts b/packages/cli/src/mod.ts index 404d3999b..caaa1e617 100644 --- a/packages/cli/src/mod.ts +++ b/packages/cli/src/mod.ts @@ -1,15 +1,15 @@ import { or } from "@optique/core"; import { run } from "@optique/run"; -import { lookupCommand, runLookup } from "./lookup.ts"; -import { runWebFinger, webFingerCommand } from "./webfinger.ts"; -import { initCommand, runInit } from "./init.ts"; import { inboxCommand, runInbox } from "./inbox.ts"; +import { initCommand, runInit } from "./init.ts"; +import { lookupCommand, runLookup } from "./lookup.ts"; import { nodeInfoCommand, runNodeInfo } from "./nodeinfo.ts"; import { runTunnel, tunnelCommand } from "./tunnel.ts"; +import { runWebFinger, webFingerCommand } from "./webfinger.ts"; const command = or( initCommand, - lookupCommand, + // lookupCommand, inboxCommand, nodeInfoCommand, tunnelCommand, @@ -24,9 +24,9 @@ async function main() { if (result.command === "init") { runInit(result); } - if (result.command === "lookup") { - await runLookup(result); - } + // if (result.command === "lookup") { + // await runLookup(result); + // } if (result.command === "webfinger") { runWebFinger(result); } From fecb60eb699a37d0f33cd494641ffd023a5dd33e Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 1 Sep 2025 13:38:10 +0000 Subject: [PATCH 04/14] Added test for Webfinger CLI --- packages/cli/src/webfinger.test.ts | 84 ++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 packages/cli/src/webfinger.test.ts diff --git a/packages/cli/src/webfinger.test.ts b/packages/cli/src/webfinger.test.ts new file mode 100644 index 000000000..64e7ead19 --- /dev/null +++ b/packages/cli/src/webfinger.test.ts @@ -0,0 +1,84 @@ +import { lookupWebFinger } from "@fedify/fedify/webfinger"; +import { run } from "@optique/run"; +import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert"; +import test from "node:test"; +import { convertHandleToUrl, webFingerCommand } from "./webfinger.ts"; + +const COMMAND = "webfinger"; +const RESOURCES = ["@hongminhee@hackers.pub", "@fedify@hollo.social"]; +const USER_AGENT = "MyUserAgent/1.0"; +const ALIASES = [ + "https://hackers.pub/ap/actors/019382d3-63d7-7cf7-86e8-91e2551c306c", + "https://hollo.social/@fedify", +]; + +test("webFingerCommand", () => { + // Resources only + const argsWithResourcesOnly = [COMMAND, ...RESOURCES]; + assertEquals( + run(webFingerCommand, { + args: argsWithResourcesOnly, + }), + { + command: COMMAND, + resources: RESOURCES, + allowPrivateAddresses: undefined, + maxRedirection: 5, + userAgent: undefined, + }, + ); + // With options + const maxRedirection = 10; + assertStrictEquals( + run(webFingerCommand, { + args: [ + ...argsWithResourcesOnly, + "-u", + USER_AGENT, + "--max-redirection", + String(maxRedirection), + "--allow-private-addresses", + ], + }), + { + command: COMMAND, + resources: RESOURCES, + allowPrivateAddresses: true, + maxRedirection, + userAgent: USER_AGENT, + }, + ); + // Wrong option + assertThrows(() => + run( + webFingerCommand, + { + args: [ + ...argsWithResourcesOnly, + "-Q", + ], + }, + ) + ); + // Wrong option value + assertThrows(() => + run( + webFingerCommand, + { + args: [ + ...argsWithResourcesOnly, + "--max-redirection", + "-10", + ], + }, + ) + ); +}); + +test("Lookup webfinger", async () => { + const aliases = (await Array.fromAsync( + RESOURCES.map(convertHandleToUrl), + (h) => lookupWebFinger(h), + )).map((w) => w?.aliases?.[0]); + assertStrictEquals(aliases, ALIASES); +}); From 4c3b85c9ba8236476360093768523e1b183ed97b Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 1 Sep 2025 13:38:10 +0000 Subject: [PATCH 05/14] Added test for Webfinger CLI --- packages/cli/src/webfinger.test.ts | 84 ++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 packages/cli/src/webfinger.test.ts diff --git a/packages/cli/src/webfinger.test.ts b/packages/cli/src/webfinger.test.ts new file mode 100644 index 000000000..64e7ead19 --- /dev/null +++ b/packages/cli/src/webfinger.test.ts @@ -0,0 +1,84 @@ +import { lookupWebFinger } from "@fedify/fedify/webfinger"; +import { run } from "@optique/run"; +import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert"; +import test from "node:test"; +import { convertHandleToUrl, webFingerCommand } from "./webfinger.ts"; + +const COMMAND = "webfinger"; +const RESOURCES = ["@hongminhee@hackers.pub", "@fedify@hollo.social"]; +const USER_AGENT = "MyUserAgent/1.0"; +const ALIASES = [ + "https://hackers.pub/ap/actors/019382d3-63d7-7cf7-86e8-91e2551c306c", + "https://hollo.social/@fedify", +]; + +test("webFingerCommand", () => { + // Resources only + const argsWithResourcesOnly = [COMMAND, ...RESOURCES]; + assertEquals( + run(webFingerCommand, { + args: argsWithResourcesOnly, + }), + { + command: COMMAND, + resources: RESOURCES, + allowPrivateAddresses: undefined, + maxRedirection: 5, + userAgent: undefined, + }, + ); + // With options + const maxRedirection = 10; + assertStrictEquals( + run(webFingerCommand, { + args: [ + ...argsWithResourcesOnly, + "-u", + USER_AGENT, + "--max-redirection", + String(maxRedirection), + "--allow-private-addresses", + ], + }), + { + command: COMMAND, + resources: RESOURCES, + allowPrivateAddresses: true, + maxRedirection, + userAgent: USER_AGENT, + }, + ); + // Wrong option + assertThrows(() => + run( + webFingerCommand, + { + args: [ + ...argsWithResourcesOnly, + "-Q", + ], + }, + ) + ); + // Wrong option value + assertThrows(() => + run( + webFingerCommand, + { + args: [ + ...argsWithResourcesOnly, + "--max-redirection", + "-10", + ], + }, + ) + ); +}); + +test("Lookup webfinger", async () => { + const aliases = (await Array.fromAsync( + RESOURCES.map(convertHandleToUrl), + (h) => lookupWebFinger(h), + )).map((w) => w?.aliases?.[0]); + assertStrictEquals(aliases, ALIASES); +}); From 88bbbcef0abd88386606988d0c159e378f7ddb22 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Mon, 1 Sep 2025 22:40:06 +0900 Subject: [PATCH 06/14] Revert "Added test for Webfinger CLI" This reverts commit 66c00f14fef4ceb2403e3bdbd9c571b43ff2363b. --- cspell.json | 1 - packages/cli/src/mod.ts | 14 +++++++------- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/cspell.json b/cspell.json index f96ad4611..5ea6843f4 100644 --- a/cspell.json +++ b/cspell.json @@ -108,7 +108,6 @@ "vitepress", "vtsls", "webfinger", - "webfingers", "webp" ] } diff --git a/packages/cli/src/mod.ts b/packages/cli/src/mod.ts index caaa1e617..404d3999b 100644 --- a/packages/cli/src/mod.ts +++ b/packages/cli/src/mod.ts @@ -1,15 +1,15 @@ import { or } from "@optique/core"; import { run } from "@optique/run"; -import { inboxCommand, runInbox } from "./inbox.ts"; -import { initCommand, runInit } from "./init.ts"; import { lookupCommand, runLookup } from "./lookup.ts"; +import { runWebFinger, webFingerCommand } from "./webfinger.ts"; +import { initCommand, runInit } from "./init.ts"; +import { inboxCommand, runInbox } from "./inbox.ts"; import { nodeInfoCommand, runNodeInfo } from "./nodeinfo.ts"; import { runTunnel, tunnelCommand } from "./tunnel.ts"; -import { runWebFinger, webFingerCommand } from "./webfinger.ts"; const command = or( initCommand, - // lookupCommand, + lookupCommand, inboxCommand, nodeInfoCommand, tunnelCommand, @@ -24,9 +24,9 @@ async function main() { if (result.command === "init") { runInit(result); } - // if (result.command === "lookup") { - // await runLookup(result); - // } + if (result.command === "lookup") { + await runLookup(result); + } if (result.command === "webfinger") { runWebFinger(result); } From ffa6dee6bde98e989916cd5156a1fb4a1b5984b9 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Fri, 5 Sep 2025 09:59:19 +0000 Subject: [PATCH 07/14] Separated functions --- packages/cli/src/mod.ts | 8 ++--- packages/cli/src/webfinger.ts | 65 ++++++++++++++++++++--------------- 2 files changed, 41 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/mod.ts b/packages/cli/src/mod.ts index f952fa360..cc68bd4f8 100644 --- a/packages/cli/src/mod.ts +++ b/packages/cli/src/mod.ts @@ -1,11 +1,11 @@ import { or } from "@optique/core"; import { run } from "@optique/run"; -import { lookupCommand, runLookup } from "./lookup.ts"; -import { runWebFinger, webFingerCommand } from "./webfinger.ts"; -import { initCommand, runInit } from "./init.ts"; import { inboxCommand, runInbox } from "./inbox.ts"; +import { initCommand, runInit } from "./init.ts"; +import { lookupCommand, runLookup } from "./lookup.ts"; import { nodeInfoCommand, runNodeInfo } from "./nodeinfo.ts"; import { runTunnel, tunnelCommand } from "./tunnel.ts"; +import { runWebFinger, webFingerCommand } from "./webfinger.ts"; const command = or( initCommand, @@ -28,7 +28,7 @@ async function main() { await runLookup(result); } if (result.command === "webfinger") { - runWebFinger(result); + await runWebFinger(result); } if (result.command === "inbox") { runInbox(result); diff --git a/packages/cli/src/webfinger.ts b/packages/cli/src/webfinger.ts index f920d93b6..921873d04 100644 --- a/packages/cli/src/webfinger.ts +++ b/packages/cli/src/webfinger.ts @@ -1,4 +1,4 @@ -import { toAcctUrl } from "@fedify/fedify"; +import { type ResourceDescriptor, toAcctUrl } from "@fedify/fedify"; import { lookupWebFinger, type LookupWebFingerOptions, @@ -21,8 +21,8 @@ import { withDefault, } from "@optique/core"; import ora from "ora"; -import { formatObject } from "./utils.ts"; import { debugOption } from "./globals.ts"; +import { formatObject } from "./utils.ts"; const logger = getLogger(["fedify", "cli", "webfinger"]); @@ -68,41 +68,50 @@ export const webFingerCommand = command( }, ); -export function runWebFinger( +export async function runWebFinger( { command: _, resources, ...options }: InferValue, ) { - lookupWebFingers(options, resources); + await Array.fromAsync( + resources.map((resource) => ({ resource, ...options })), + spinnerWrapper(lookupSingleWebFinger), + ); } -const lookupWebFingers = async ( - options: LookupWebFingerOptions, - resources: readonly string[], -) => { - for (const resource of resources) { - const spinner = ora({ // Create a spinner for the lookup process - text: `Looking up WebFinger for ${resource}`, +async function lookupSingleWebFinger< + T extends LookupWebFingerOptions & { resource: string }, +>({ resource, ...options }: T): Promise { + const url = convertUrlIfHandle(resource); + const webFinger = await lookupWebFinger(url, options) ?? + new NotFoundError(resource).throw(); + return webFinger; +} + +function spinnerWrapper( + func: (...args: Parameters) => ReturnType, +) { + return async (...args: Parameters) => { + const spinner = ora({ + text: `Looking up WebFinger for ${args[0]}`, discardStdin: false, }).start(); try { - const url = convertUrlIfHandle(resource); // Convert resource to URL - const webFinger = await lookupWebFinger(url, options) ?? // Look up WebFinger - new NotFoundError(resource).throw(); // throw NotFoundError if not found - - spinner.succeed(`WebFinger found for ${resource}:`); // Succeed the spinner - console.log(formatObject(webFinger, undefined, true)); // Print the WebFinger + const result = await func(...args); + spinner.succeed(`WebFinger found for ${args[0]}:`); + console.log(formatObject(result, undefined, true)); } catch (error) { - if (error instanceof InvalidHandleError) { // If the handle format is invalid, - spinner.fail(`Invalid handle format: ${error.handle}`); // log error message with handle - } else if (error instanceof NotFoundError) { // If the resource is not found, - spinner.fail(`Resource not found: ${error.resource}`); // log not found message - } else if (error instanceof Error) { - spinner.fail( // For other errors, log the error message - `Error looking up WebFinger for ${resource}: ${error}`, - ); - } + spinner.fail(getErrorMessage(args[0].resource, error)); } - } -}; + }; +} + +const getErrorMessage = (resource: string, error: unknown): string => + error instanceof InvalidHandleError + ? `Invalid handle format: ${error.handle}` + : error instanceof NotFoundError + ? `Resource not found: ${error.resource}` + : error instanceof Error + ? `Error looking up WebFinger for ${resource}: ${error.message}` + : `Error looking up WebFinger for ${resource}: ${error}`; /** * Converts a handle or URL to a URL object. From fffc0f608ae0e37b70604720208630f320550786 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 7 Sep 2025 03:59:10 +0000 Subject: [PATCH 08/14] Added _ prefix to unused var --- packages/cli/src/webfinger.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/webfinger.ts b/packages/cli/src/webfinger.ts index 921873d04..4ace126b3 100644 --- a/packages/cli/src/webfinger.ts +++ b/packages/cli/src/webfinger.ts @@ -24,7 +24,7 @@ import ora from "ora"; import { debugOption } from "./globals.ts"; import { formatObject } from "./utils.ts"; -const logger = getLogger(["fedify", "cli", "webfinger"]); +const _logger = getLogger(["fedify", "cli", "webfinger"]); const userAgent = optional(option( "-u", From 53c844eb8aac10e1432e6e0da445108921f1aef1 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 7 Sep 2025 03:59:44 +0000 Subject: [PATCH 09/14] Added debug options to test --- packages/cli/src/webfinger.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/webfinger.test.ts b/packages/cli/src/webfinger.test.ts index 64e7ead19..b292a2e31 100644 --- a/packages/cli/src/webfinger.test.ts +++ b/packages/cli/src/webfinger.test.ts @@ -20,6 +20,7 @@ test("webFingerCommand", () => { args: argsWithResourcesOnly, }), { + debug: false, command: COMMAND, resources: RESOURCES, allowPrivateAddresses: undefined, From 48c0f5f8d0260ea32ddf5be2360d8db317d0f9e8 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 7 Sep 2025 05:02:55 +0000 Subject: [PATCH 10/14] Fixed test --- packages/cli/src/webfinger.test.ts | 104 ++++++++++++++--------------- packages/cli/src/webfinger.ts | 6 +- 2 files changed, 51 insertions(+), 59 deletions(-) diff --git a/packages/cli/src/webfinger.test.ts b/packages/cli/src/webfinger.test.ts index b292a2e31..bd405ca9a 100644 --- a/packages/cli/src/webfinger.test.ts +++ b/packages/cli/src/webfinger.test.ts @@ -1,85 +1,79 @@ -import { lookupWebFinger } from "@fedify/fedify/webfinger"; -import { run } from "@optique/run"; -import { assertEquals, assertStrictEquals, assertThrows } from "@std/assert"; +import { parse } from "@optique/core/parser"; +import { assertEquals, assertObjectMatch } from "@std/assert"; import test from "node:test"; -import { convertHandleToUrl, webFingerCommand } from "./webfinger.ts"; +import { lookupSingleWebFinger, webFingerCommand } from "./webfinger.ts"; const COMMAND = "webfinger"; -const RESOURCES = ["@hongminhee@hackers.pub", "@fedify@hollo.social"]; const USER_AGENT = "MyUserAgent/1.0"; +const RESOURCES = [ + "@hongminhee@hackers.pub", + "@fedify@hollo.social", +]; const ALIASES = [ "https://hackers.pub/ap/actors/019382d3-63d7-7cf7-86e8-91e2551c306c", "https://hollo.social/@fedify", ]; -test("webFingerCommand", () => { +test("Test webFingerCommand", () => { // Resources only const argsWithResourcesOnly = [COMMAND, ...RESOURCES]; assertEquals( - run(webFingerCommand, { - args: argsWithResourcesOnly, - }), + parse(webFingerCommand, argsWithResourcesOnly), { - debug: false, - command: COMMAND, - resources: RESOURCES, - allowPrivateAddresses: undefined, - maxRedirection: 5, - userAgent: undefined, + success: true, + value: { + debug: false, + command: COMMAND, + resources: RESOURCES, + allowPrivateAddresses: undefined, + maxRedirection: 5, + userAgent: undefined, + }, }, ); // With options const maxRedirection = 10; - assertStrictEquals( - run(webFingerCommand, { - args: [ - ...argsWithResourcesOnly, - "-u", - USER_AGENT, - "--max-redirection", - String(maxRedirection), - "--allow-private-addresses", - ], - }), + assertEquals( + parse(webFingerCommand, [ + ...argsWithResourcesOnly, + "-d", + "-u", + USER_AGENT, + "--max-redirection", + String(maxRedirection), + "--allow-private-address", + ]), { - command: COMMAND, - resources: RESOURCES, - allowPrivateAddresses: true, - maxRedirection, - userAgent: USER_AGENT, + success: true, + value: { + debug: true, + command: COMMAND, + resources: RESOURCES, + allowPrivateAddresses: true, + maxRedirection, + userAgent: USER_AGENT, + }, }, ); // Wrong option - assertThrows(() => - run( - webFingerCommand, - { - args: [ - ...argsWithResourcesOnly, - "-Q", - ], - }, - ) + assertObjectMatch( + parse(webFingerCommand, [...argsWithResourcesOnly, "-Q"]), + { success: false }, ); // Wrong option value - assertThrows(() => - run( + assertObjectMatch( + parse( webFingerCommand, - { - args: [ - ...argsWithResourcesOnly, - "--max-redirection", - "-10", - ], - }, - ) + [...argsWithResourcesOnly, "--max-redirection", "-10"], + ), + { success: false }, ); }); -test("Lookup webfinger", async () => { +test("Test lookupSingleWebFinger", async () => { const aliases = (await Array.fromAsync( - RESOURCES.map(convertHandleToUrl), - (h) => lookupWebFinger(h), + RESOURCES, + (resource) => lookupSingleWebFinger({ resource }), )).map((w) => w?.aliases?.[0]); - assertStrictEquals(aliases, ALIASES); + assertEquals(aliases, ALIASES); }); diff --git a/packages/cli/src/webfinger.ts b/packages/cli/src/webfinger.ts index 4ace126b3..51925e84a 100644 --- a/packages/cli/src/webfinger.ts +++ b/packages/cli/src/webfinger.ts @@ -30,9 +30,7 @@ const userAgent = optional(option( "-u", "--user-agent", string({ metavar: "USER_AGENT" }), - { - description: message`The custom User-Agent header value.`, - }, + { description: message`The custom User-Agent header value.` }, )); const allowPrivateAddresses = optional(flag("-p", "--allow-private-address", { @@ -77,7 +75,7 @@ export async function runWebFinger( ); } -async function lookupSingleWebFinger< +export async function lookupSingleWebFinger< T extends LookupWebFingerOptions & { resource: string }, >({ resource, ...options }: T): Promise { const url = convertUrlIfHandle(resource); From 59b7db1d1f0fbfac919165c46cafe8625c6f5833 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 7 Sep 2025 05:53:40 +0000 Subject: [PATCH 11/14] Modularized webfinger --- packages/cli/src/mod.ts | 2 +- packages/cli/src/{webfinger.test.ts => webfinger/mod.test.ts} | 2 +- packages/cli/src/{webfinger.ts => webfinger/mod.ts} | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) rename packages/cli/src/{webfinger.test.ts => webfinger/mod.test.ts} (96%) rename packages/cli/src/{webfinger.ts => webfinger/mod.ts} (98%) diff --git a/packages/cli/src/mod.ts b/packages/cli/src/mod.ts index cc68bd4f8..b4c17ad07 100644 --- a/packages/cli/src/mod.ts +++ b/packages/cli/src/mod.ts @@ -5,7 +5,7 @@ import { initCommand, runInit } from "./init.ts"; import { lookupCommand, runLookup } from "./lookup.ts"; import { nodeInfoCommand, runNodeInfo } from "./nodeinfo.ts"; import { runTunnel, tunnelCommand } from "./tunnel.ts"; -import { runWebFinger, webFingerCommand } from "./webfinger.ts"; +import { runWebFinger, webFingerCommand } from "./webfinger/mod.ts"; const command = or( initCommand, diff --git a/packages/cli/src/webfinger.test.ts b/packages/cli/src/webfinger/mod.test.ts similarity index 96% rename from packages/cli/src/webfinger.test.ts rename to packages/cli/src/webfinger/mod.test.ts index bd405ca9a..a8bbea915 100644 --- a/packages/cli/src/webfinger.test.ts +++ b/packages/cli/src/webfinger/mod.test.ts @@ -1,7 +1,7 @@ import { parse } from "@optique/core/parser"; import { assertEquals, assertObjectMatch } from "@std/assert"; import test from "node:test"; -import { lookupSingleWebFinger, webFingerCommand } from "./webfinger.ts"; +import { lookupSingleWebFinger, webFingerCommand } from "./mod.ts"; const COMMAND = "webfinger"; const USER_AGENT = "MyUserAgent/1.0"; diff --git a/packages/cli/src/webfinger.ts b/packages/cli/src/webfinger/mod.ts similarity index 98% rename from packages/cli/src/webfinger.ts rename to packages/cli/src/webfinger/mod.ts index 51925e84a..d35c48a98 100644 --- a/packages/cli/src/webfinger.ts +++ b/packages/cli/src/webfinger/mod.ts @@ -21,8 +21,8 @@ import { withDefault, } from "@optique/core"; import ora from "ora"; -import { debugOption } from "./globals.ts"; -import { formatObject } from "./utils.ts"; +import { debugOption } from "../globals.ts"; +import { formatObject } from "../utils.ts"; const _logger = getLogger(["fedify", "cli", "webfinger"]); From 9bdf7b8c7da37552c46ebb32e594f358f710d052 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Sun, 7 Sep 2025 06:06:27 +0000 Subject: [PATCH 12/14] Modularized webfinger and separated code --- packages/cli/src/webfinger/action.ts | 46 +++++++ packages/cli/src/webfinger/command.ts | 59 +++++++++ packages/cli/src/webfinger/error.ts | 38 ++++++ packages/cli/src/webfinger/lib.ts | 37 ++++++ packages/cli/src/webfinger/mod.test.ts | 3 +- packages/cli/src/webfinger/mod.ts | 176 +------------------------ 6 files changed, 184 insertions(+), 175 deletions(-) create mode 100644 packages/cli/src/webfinger/action.ts create mode 100644 packages/cli/src/webfinger/command.ts create mode 100644 packages/cli/src/webfinger/error.ts create mode 100644 packages/cli/src/webfinger/lib.ts diff --git a/packages/cli/src/webfinger/action.ts b/packages/cli/src/webfinger/action.ts new file mode 100644 index 000000000..4cee29364 --- /dev/null +++ b/packages/cli/src/webfinger/action.ts @@ -0,0 +1,46 @@ +import type { ResourceDescriptor } from "@fedify/fedify"; +import { + lookupWebFinger, + type LookupWebFingerOptions, +} from "@fedify/fedify/webfinger"; +import ora from "ora"; +import { formatObject } from "../utils.ts"; +import type { WebFingerCommand } from "./command.ts"; +import { getErrorMessage, NotFoundError } from "./error.ts"; +import { convertUrlIfHandle } from "./lib.ts"; + +export default async function runWebFinger( + { command: _, resources, ...options }: WebFingerCommand, +) { + await Array.fromAsync( + resources.map((resource) => ({ resource, ...options })), + spinnerWrapper(lookupSingleWebFinger), + ); +} + +export async function lookupSingleWebFinger< + T extends LookupWebFingerOptions & { resource: string }, +>({ resource, ...options }: T): Promise { + const url = convertUrlIfHandle(resource); + const webFinger = await lookupWebFinger(url, options) ?? + new NotFoundError(resource).throw(); + return webFinger; +} + +function spinnerWrapper( + func: (...args: Parameters) => ReturnType, +) { + return async (...args: Parameters) => { + const spinner = ora({ + text: `Looking up WebFinger for ${args[0]}`, + discardStdin: false, + }).start(); + try { + const result = await func(...args); + spinner.succeed(`WebFinger found for ${args[0]}:`); + console.log(formatObject(result, undefined, true)); + } catch (error) { + spinner.fail(getErrorMessage(args[0].resource, error)); + } + }; +} diff --git a/packages/cli/src/webfinger/command.ts b/packages/cli/src/webfinger/command.ts new file mode 100644 index 000000000..e12f1e5fb --- /dev/null +++ b/packages/cli/src/webfinger/command.ts @@ -0,0 +1,59 @@ +import { + argument, + command, + constant, + flag, + type InferValue, + integer, + merge, + message, + multiple, + object, + option, + optional, + string, + withDefault, +} from "@optique/core"; +import { debugOption } from "../globals.ts"; + +const userAgent = optional(option( + "-u", + "--user-agent", + string({ metavar: "USER_AGENT" }), + { description: message`The custom User-Agent header value.` }, +)); + +const allowPrivateAddresses = optional(flag("-p", "--allow-private-address", { + description: message`Allow private IP addresses in the URL.`, +})); + +const maxRedirection = withDefault( + option( + "--max-redirection", + integer({ min: 0 }), + { description: message`Maximum number of redirections to follow.` }, + ), + 5, +); + +export const webFingerCommand = command( + "webfinger", + merge( + object({ + command: constant("webfinger"), + resources: multiple(argument(string({ metavar: "RESOURCE" }), { + description: message`WebFinger resource(s) to look up.`, + })), + userAgent, + allowPrivateAddresses, + maxRedirection, + }), + debugOption, + ), + { + description: + message`Look up WebFinger resources. The argument can be multiple.`, + }, +); + +export type WebFingerCommand = InferValue; diff --git a/packages/cli/src/webfinger/error.ts b/packages/cli/src/webfinger/error.ts new file mode 100644 index 000000000..abbd052e3 --- /dev/null +++ b/packages/cli/src/webfinger/error.ts @@ -0,0 +1,38 @@ +export const getErrorMessage = (resource: string, error: unknown): string => + error instanceof InvalidHandleError + ? `Invalid handle format: ${error.handle}` + : error instanceof NotFoundError + ? `Resource not found: ${error.resource}` + : error instanceof Error + ? `Error looking up WebFinger for ${resource}: ${error.message}` + : `Error looking up WebFinger for ${resource}: ${error}`; + +/** + * Custom error class for invalid handle formats. + * @param {string} handle The invalid handle that caused the error. + * @extends {Error} + */ +export class InvalidHandleError extends Error { + constructor(public handle: string) { + super(`Invalid handle format: ${handle}`); + this.name = "InvalidHandleError"; + } + throw(): never { + throw this; + } +} + +/** + * Custom error class for not found resources. + * @param {string} resource The resource that was not found. + * @extends {Error} + */ +export class NotFoundError extends Error { + constructor(public resource: string) { + super(`Resource not found: ${resource}`); + this.name = "NotFoundError"; + } + throw(): never { + throw this; + } +} diff --git a/packages/cli/src/webfinger/lib.ts b/packages/cli/src/webfinger/lib.ts new file mode 100644 index 000000000..30f22e3a6 --- /dev/null +++ b/packages/cli/src/webfinger/lib.ts @@ -0,0 +1,37 @@ +import { toAcctUrl } from "@fedify/fedify"; +import { getLogger } from "@logtape/logtape"; +import { InvalidHandleError } from "./error.ts"; + +export const logger = getLogger(["fedify", "cli", "webfinger"]); + +/** + * Converts a handle or URL to a URL object. + * If the input is a valid URL, it returns the URL object. + * If the input is a handle in the format `@username@domain`, it converts it to a URL. + * @param handleOrUrl The handle or URL to convert. + * @returns A URL object representing the handle or URL. + */ +export function convertUrlIfHandle(handleOrUrl: string): URL { + try { + return new URL(handleOrUrl); // Try to convert the input to a URL + } catch { + return convertHandleToUrl(handleOrUrl); // If it fails, treat it as a handle + } +} + +/** + * Converts a handle in the format `@username@domain` to a URL. + * The resulting URL will be in the format `https://domain/@username`. + * @param handle The handle to convert, in the format `@username@domain`. + * @returns A URL object representing the handle. + * @throws {Error} If the handle format is invalid. + * @example + * ```ts + * const url = convertHandleToUrl("@username@domain.com"); + * console.log(url.toString()); // "https://domain.com/@username" + * ``` + */ +export function convertHandleToUrl(handle: string): URL { + return toAcctUrl(handle) ?? // Convert the handle to a URL + new InvalidHandleError(handle).throw(); // or throw an error if invalid +} diff --git a/packages/cli/src/webfinger/mod.test.ts b/packages/cli/src/webfinger/mod.test.ts index a8bbea915..7e2437b65 100644 --- a/packages/cli/src/webfinger/mod.test.ts +++ b/packages/cli/src/webfinger/mod.test.ts @@ -1,7 +1,8 @@ import { parse } from "@optique/core/parser"; import { assertEquals, assertObjectMatch } from "@std/assert"; import test from "node:test"; -import { lookupSingleWebFinger, webFingerCommand } from "./mod.ts"; +import { lookupSingleWebFinger } from "./action.ts"; +import { webFingerCommand } from "./command.ts"; const COMMAND = "webfinger"; const USER_AGENT = "MyUserAgent/1.0"; diff --git a/packages/cli/src/webfinger/mod.ts b/packages/cli/src/webfinger/mod.ts index d35c48a98..ab859dcf1 100644 --- a/packages/cli/src/webfinger/mod.ts +++ b/packages/cli/src/webfinger/mod.ts @@ -1,174 +1,2 @@ -import { type ResourceDescriptor, toAcctUrl } from "@fedify/fedify"; -import { - lookupWebFinger, - type LookupWebFingerOptions, -} from "@fedify/fedify/webfinger"; -import { getLogger } from "@logtape/logtape"; -import { - argument, - command, - constant, - flag, - type InferValue, - integer, - merge, - message, - multiple, - object, - option, - optional, - string, - withDefault, -} from "@optique/core"; -import ora from "ora"; -import { debugOption } from "../globals.ts"; -import { formatObject } from "../utils.ts"; - -const _logger = getLogger(["fedify", "cli", "webfinger"]); - -const userAgent = optional(option( - "-u", - "--user-agent", - string({ metavar: "USER_AGENT" }), - { description: message`The custom User-Agent header value.` }, -)); - -const allowPrivateAddresses = optional(flag("-p", "--allow-private-address", { - description: message`Allow private IP addresses in the URL.`, -})); - -const maxRedirection = withDefault( - option( - "--max-redirection", - integer({ min: 0 }), - { description: message`Maximum number of redirections to follow.` }, - ), - 5, -); - -export const webFingerCommand = command( - "webfinger", - merge( - object({ - command: constant("webfinger"), - resources: multiple(argument(string({ metavar: "RESOURCE" }), { - description: message`WebFinger resource(s) to look up.`, - })), - userAgent, - allowPrivateAddresses, - maxRedirection, - }), - debugOption, - ), - { - description: - message`Look up WebFinger resources. The argument can be multiple.`, - }, -); - -export async function runWebFinger( - { command: _, resources, ...options }: InferValue, -) { - await Array.fromAsync( - resources.map((resource) => ({ resource, ...options })), - spinnerWrapper(lookupSingleWebFinger), - ); -} - -export async function lookupSingleWebFinger< - T extends LookupWebFingerOptions & { resource: string }, ->({ resource, ...options }: T): Promise { - const url = convertUrlIfHandle(resource); - const webFinger = await lookupWebFinger(url, options) ?? - new NotFoundError(resource).throw(); - return webFinger; -} - -function spinnerWrapper( - func: (...args: Parameters) => ReturnType, -) { - return async (...args: Parameters) => { - const spinner = ora({ - text: `Looking up WebFinger for ${args[0]}`, - discardStdin: false, - }).start(); - try { - const result = await func(...args); - spinner.succeed(`WebFinger found for ${args[0]}:`); - console.log(formatObject(result, undefined, true)); - } catch (error) { - spinner.fail(getErrorMessage(args[0].resource, error)); - } - }; -} - -const getErrorMessage = (resource: string, error: unknown): string => - error instanceof InvalidHandleError - ? `Invalid handle format: ${error.handle}` - : error instanceof NotFoundError - ? `Resource not found: ${error.resource}` - : error instanceof Error - ? `Error looking up WebFinger for ${resource}: ${error.message}` - : `Error looking up WebFinger for ${resource}: ${error}`; - -/** - * Converts a handle or URL to a URL object. - * If the input is a valid URL, it returns the URL object. - * If the input is a handle in the format `@username@domain`, it converts it to a URL. - * @param handleOrUrl The handle or URL to convert. - * @returns A URL object representing the handle or URL. - */ -function convertUrlIfHandle(handleOrUrl: string): URL { - try { - return new URL(handleOrUrl); // Try to convert the input to a URL - } catch { - return convertHandleToUrl(handleOrUrl); // If it fails, treat it as a handle - } -} - -/** - * Custom error class for invalid handle formats. - * @param {string} handle The invalid handle that caused the error. - * @extends {Error} - */ -class InvalidHandleError extends Error { - constructor(public handle: string) { - super(`Invalid handle format: ${handle}`); - this.name = "InvalidHandleError"; - } - throw(): never { - throw this; - } -} - -/** - * Custom error class for not found resources. - * @param {string} resource The resource that was not found. - * @extends {Error} - */ -class NotFoundError extends Error { - constructor(public resource: string) { - super(`Resource not found: ${resource}`); - this.name = "NotFoundError"; - } - throw(): never { - throw this; - } -} - -/** - * Converts a handle in the format `@username@domain` to a URL. - * The resulting URL will be in the format `https://domain/@username`. - * @param handle The handle to convert, in the format `@username@domain`. - * @returns A URL object representing the handle. - * @throws {Error} If the handle format is invalid. - * @example - * ```ts - * const url = convertHandleToUrl("@username@domain.com"); - * console.log(url.toString()); // "https://domain.com/@username" - * ``` - */ -export function convertHandleToUrl(handle: string): URL { - return toAcctUrl(handle) ?? // Convert the handle to a URL - new InvalidHandleError(handle).throw(); // or throw an error if invalid -} +export { default as runWebFinger } from "./action.ts"; +export { webFingerCommand } from "./command.ts"; From ee3f747d6e823ac4d9b1ca843df7143198624fd4 Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <2chanhaeng@gmail.com> Date: Tue, 9 Sep 2025 13:21:40 +0000 Subject: [PATCH 13/14] Applied @optique/core/message --- packages/cli/src/webfinger/action.ts | 10 +++++++--- packages/cli/src/webfinger/error.ts | 19 ++++++++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/webfinger/action.ts b/packages/cli/src/webfinger/action.ts index 4cee29364..5a425a48c 100644 --- a/packages/cli/src/webfinger/action.ts +++ b/packages/cli/src/webfinger/action.ts @@ -3,6 +3,8 @@ import { lookupWebFinger, type LookupWebFingerOptions, } from "@fedify/fedify/webfinger"; +import { formatMessage, message } from "@optique/core/message"; +import { print } from "@optique/run"; import ora from "ora"; import { formatObject } from "../utils.ts"; import type { WebFingerCommand } from "./command.ts"; @@ -37,10 +39,12 @@ function spinnerWrapper( }).start(); try { const result = await func(...args); - spinner.succeed(`WebFinger found for ${args[0]}:`); - console.log(formatObject(result, undefined, true)); + spinner.succeed( + formatMessage(message`WebFinger found for ${args[0].resource}:`), + ); + print([{ type: "text", text: formatObject(result) }]); } catch (error) { - spinner.fail(getErrorMessage(args[0].resource, error)); + spinner.fail(formatMessage(getErrorMessage(args[0].resource, error))); } }; } diff --git a/packages/cli/src/webfinger/error.ts b/packages/cli/src/webfinger/error.ts index abbd052e3..7b6b952f2 100644 --- a/packages/cli/src/webfinger/error.ts +++ b/packages/cli/src/webfinger/error.ts @@ -1,11 +1,20 @@ -export const getErrorMessage = (resource: string, error: unknown): string => +import { type Message, message } from "@optique/core"; + +/** + * Generates a user-friendly error message based on the type of error + * encountered during WebFinger lookup. + * @param {string} resource The resource being looked up. + * @param {unknown} error The error encountered. + * @returns {string} A descriptive error message. + */ +export const getErrorMessage = (resource: string, error: unknown): Message => error instanceof InvalidHandleError - ? `Invalid handle format: ${error.handle}` + ? message`Invalid handle format: ${error.handle}` : error instanceof NotFoundError - ? `Resource not found: ${error.resource}` + ? message`Resource not found: ${error.resource}` : error instanceof Error - ? `Error looking up WebFinger for ${resource}: ${error.message}` - : `Error looking up WebFinger for ${resource}: ${error}`; + ? message`Error looking up WebFinger for ${resource}: ${error.message}` + : message`Error looking up WebFinger for ${resource}: ${String(error)}`; /** * Custom error class for invalid handle formats. From ede5d077b028af898e00afaf5ec4677d64ef5f9c Mon Sep 17 00:00:00 2001 From: ChanHaeng Lee <61987505+2chanhaeng@users.noreply.github.com> Date: Tue, 9 Sep 2025 23:44:49 +0900 Subject: [PATCH 14/14] Fixed error message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/fedify-dev/fedify/pull/401#discussion_r2333772768 Co-authored-by: Hong Minhee (洪 民憙) --- packages/cli/src/webfinger/error.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/webfinger/error.ts b/packages/cli/src/webfinger/error.ts index 7b6b952f2..5912e64f1 100644 --- a/packages/cli/src/webfinger/error.ts +++ b/packages/cli/src/webfinger/error.ts @@ -13,8 +13,8 @@ export const getErrorMessage = (resource: string, error: unknown): Message => : error instanceof NotFoundError ? message`Resource not found: ${error.resource}` : error instanceof Error - ? message`Error looking up WebFinger for ${resource}: ${error.message}` - : message`Error looking up WebFinger for ${resource}: ${String(error)}`; + ? message`Failed to look up WebFinger for ${resource}: ${error.message}` + : message`Failed to look up WebFinger for ${resource}: ${String(error)}`; /** * Custom error class for invalid handle formats.