diff --git a/cspell.json b/cspell.json index 7e39020c7..f5f4617fd 100644 --- a/cspell.json +++ b/cspell.json @@ -58,6 +58,7 @@ "lume", "lumocs", "metas", + "metavar", "microblog", "microblogging", "Minhee", @@ -69,6 +70,7 @@ "multitenancy", "Nexkey", "nodeinfo", + "optique", "phensley", "Pico", "Pixelfed", @@ -105,6 +107,7 @@ "unfollows", "urlpattern", "uuidv7", + "valueparser", "Vinxi", "vitepress", "vtsls", diff --git a/packages/cli/src/mod.ts b/packages/cli/src/mod.ts index 585eaecc4..7dbfaf82e 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.tsx"; +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/mod.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 deleted file mode 100644 index 2333ada1e..000000000 --- a/packages/cli/src/webfinger.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - argument, - command, - constant, - type InferValue, - merge, - message, - multiple, - object, - string, -} from "@optique/core"; -import { debugOption } from "./globals.ts"; - -export const webFingerCommand = command( - "webfinger", - merge( - object({ - command: constant("webfinger"), - resources: multiple(argument(string({ metavar: "RESOURCE" }), { - description: message`WebFinger resource(s) to look up.`, - })), - }), - debugOption, - ), - { - description: message`Look up WebFinger resources.`, - }, -); - -export function runWebFinger( - command: InferValue, -) { - console.debug(command); -} diff --git a/packages/cli/src/webfinger/action.ts b/packages/cli/src/webfinger/action.ts new file mode 100644 index 000000000..5a425a48c --- /dev/null +++ b/packages/cli/src/webfinger/action.ts @@ -0,0 +1,50 @@ +import type { ResourceDescriptor } from "@fedify/fedify"; +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"; +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( + formatMessage(message`WebFinger found for ${args[0].resource}:`), + ); + print([{ type: "text", text: formatObject(result) }]); + } catch (error) { + spinner.fail(formatMessage(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..5912e64f1 --- /dev/null +++ b/packages/cli/src/webfinger/error.ts @@ -0,0 +1,47 @@ +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 + ? message`Invalid handle format: ${error.handle}` + : error instanceof NotFoundError + ? message`Resource not found: ${error.resource}` + : error instanceof 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. + * @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 new file mode 100644 index 000000000..7e2437b65 --- /dev/null +++ b/packages/cli/src/webfinger/mod.test.ts @@ -0,0 +1,80 @@ +import { parse } from "@optique/core/parser"; +import { assertEquals, assertObjectMatch } from "@std/assert"; +import test from "node:test"; +import { lookupSingleWebFinger } from "./action.ts"; +import { webFingerCommand } from "./command.ts"; + +const COMMAND = "webfinger"; +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("Test webFingerCommand", () => { + // Resources only + const argsWithResourcesOnly = [COMMAND, ...RESOURCES]; + assertEquals( + parse(webFingerCommand, argsWithResourcesOnly), + { + success: true, + value: { + debug: false, + command: COMMAND, + resources: RESOURCES, + allowPrivateAddresses: undefined, + maxRedirection: 5, + userAgent: undefined, + }, + }, + ); + // With options + const maxRedirection = 10; + assertEquals( + parse(webFingerCommand, [ + ...argsWithResourcesOnly, + "-d", + "-u", + USER_AGENT, + "--max-redirection", + String(maxRedirection), + "--allow-private-address", + ]), + { + success: true, + value: { + debug: true, + command: COMMAND, + resources: RESOURCES, + allowPrivateAddresses: true, + maxRedirection, + userAgent: USER_AGENT, + }, + }, + ); + // Wrong option + assertObjectMatch( + parse(webFingerCommand, [...argsWithResourcesOnly, "-Q"]), + { success: false }, + ); + // Wrong option value + assertObjectMatch( + parse( + webFingerCommand, + [...argsWithResourcesOnly, "--max-redirection", "-10"], + ), + { success: false }, + ); +}); + +test("Test lookupSingleWebFinger", async () => { + const aliases = (await Array.fromAsync( + RESOURCES, + (resource) => lookupSingleWebFinger({ resource }), + )).map((w) => w?.aliases?.[0]); + assertEquals(aliases, ALIASES); +}); diff --git a/packages/cli/src/webfinger/mod.ts b/packages/cli/src/webfinger/mod.ts new file mode 100644 index 000000000..ab859dcf1 --- /dev/null +++ b/packages/cli/src/webfinger/mod.ts @@ -0,0 +1,2 @@ +export { default as runWebFinger } from "./action.ts"; +export { webFingerCommand } from "./command.ts";