diff --git a/deno.json b/deno.json index 2740fe03e..c73a63da4 100644 --- a/deno.json +++ b/deno.json @@ -51,9 +51,10 @@ "@opentelemetry/sdk-metrics": "npm:@opentelemetry/sdk-metrics@2.7.1", "@opentelemetry/sdk-trace-base": "npm:@opentelemetry/sdk-trace-base@^2.7.1", "@opentelemetry/semantic-conventions": "npm:@opentelemetry/semantic-conventions@^1.40.0", - "@optique/config": "jsr:@optique/config@^1.0.2", - "@optique/core": "jsr:@optique/core@^1.0.2", - "@optique/run": "jsr:@optique/run@^1.0.2", + "@optique/config": "jsr:@optique/config@^1.1.0", + "@optique/core": "jsr:@optique/core@^1.1.0", + "@optique/discover": "jsr:@optique/discover@^1.1.0", + "@optique/run": "jsr:@optique/run@^1.1.0", "@standard-schema/spec": "jsr:@standard-schema/spec@^1.1.0", "@std/assert": "jsr:@std/assert@^1.0.13", "@std/async": "jsr:@std/async@^1.0.13", diff --git a/deno.lock b/deno.lock index 884f61e52..18f6f8f18 100644 --- a/deno.lock +++ b/deno.lock @@ -24,9 +24,11 @@ "jsr:@logtape/file@^2.1.0": "2.1.0", "jsr:@logtape/logtape@^1.0.4": "1.3.8", "jsr:@logtape/logtape@^2.1.0": "2.1.0", - "jsr:@optique/config@^1.0.2": "1.0.2", - "jsr:@optique/core@^1.0.2": "1.0.2", - "jsr:@optique/run@^1.0.2": "1.0.2", + "jsr:@optique/config@^1.1.0": "1.1.0", + "jsr:@optique/core@^1.1.0": "1.1.0", + "jsr:@optique/discover@1.1.0": "1.1.0", + "jsr:@optique/discover@^1.1.0": "1.1.0", + "jsr:@optique/run@^1.1.0": "1.1.0", "jsr:@standard-schema/spec@^1.1.0": "1.1.0", "jsr:@std/assert@0.224": "0.224.0", "jsr:@std/assert@0.226": "0.226.0", @@ -303,18 +305,25 @@ "@logtape/logtape@2.1.0": { "integrity": "8117d9afcd9ba1f2bb81b237d2ae452b8982e397aeabc9f05f58d7be452c15e2" }, - "@optique/config@1.0.2": { - "integrity": "9ba458a3cb2f00f83d36a70f942782cd2c622fd72c527f59e5f8234c780c4b30", + "@optique/config@1.1.0": { + "integrity": "9f9425156860e72f46717b8be8b01f0868f47465e9cd1d3788f4ba5038a355c9", "dependencies": [ "jsr:@optique/core", "npm:@standard-schema/spec@^1.1.0" ] }, - "@optique/core@1.0.2": { - "integrity": "3f90a2965286cd4d4d103f6605ed9a00951393dbab0748402576350ad3780b33" + "@optique/core@1.1.0": { + "integrity": "f864b1918415fd2a94c0b8ee6a31b6e6faeb2a972e6e75f0be4cf2d201e3fcf4" }, - "@optique/run@1.0.2": { - "integrity": "353c3cfafb69f2099ff2f29e1145696ef6ecd355d44d9626ab4726020c9d72f5", + "@optique/discover@1.1.0": { + "integrity": "dec51dd8ba79e70a1557e4f55e58a792c4ab10cbf63f7382dad80c902801d1b6", + "dependencies": [ + "jsr:@optique/core", + "jsr:@optique/run" + ] + }, + "@optique/run@1.1.0": { + "integrity": "4773919cde888d4b35dc8e34a3e87fab544ab9d00c7d5312de5a77b230a0cffe", "dependencies": [ "jsr:@optique/core" ] @@ -9306,9 +9315,10 @@ "jsr:@hono/hono@^4.8.3", "jsr:@logtape/file@^2.1.0", "jsr:@logtape/logtape@^2.1.0", - "jsr:@optique/config@^1.0.2", - "jsr:@optique/core@^1.0.2", - "jsr:@optique/run@^1.0.2", + "jsr:@optique/config@^1.1.0", + "jsr:@optique/core@^1.1.0", + "jsr:@optique/discover@^1.1.0", + "jsr:@optique/run@^1.1.0", "jsr:@standard-schema/spec@^1.1.0", "jsr:@std/assert@^1.0.13", "jsr:@std/async@^1.0.13", diff --git a/packages/cli/package.json b/packages/cli/package.json index 048d8bcd3..13c5126a3 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -84,6 +84,7 @@ "@fxts/core": "catalog:", "@optique/config": "catalog:", "@optique/core": "catalog:", + "@optique/discover": "catalog:", "@optique/run": "catalog:", "@standard-schema/spec": "catalog:", "@hongminhee/localtunnel": "^0.3.0", diff --git a/packages/cli/src/bench/command.ts b/packages/cli/src/bench/command.ts index fb5e02d41..7184e8ba7 100644 --- a/packages/cli/src/bench/command.ts +++ b/packages/cli/src/bench/command.ts @@ -159,19 +159,23 @@ tolerance and measured noise band.`, }, ); -export const benchCommand = command( - "bench", - or(compareParser, runParser), - { - brief: message`Benchmark a Fedify federation workload.`, - description: message`Run an ActivityPub-specific load benchmark against a \ +export const benchOptions = or(compareParser, runParser); + +export const benchMetadata = { + brief: message`Benchmark a Fedify federation workload.`, + description: message`Run an ActivityPub-specific load benchmark against a \ cooperative Fedify target running in benchmark mode. The suite file declares the target, actors, and scenarios. This version \ executes the \`inbox\`, \`webfinger\`, \`actor\`, \`object\`, \`fanout\`, \ \`failure\`, and \`mixed\` scenario types; \`collection\` remains reserved by \ the suite format.`, - }, +}; + +export const benchCommand = command( + "bench", + benchOptions, + benchMetadata, ); export type BenchCommand = InferValue; diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts new file mode 100644 index 000000000..f2a8eb8f1 --- /dev/null +++ b/packages/cli/src/commands.ts @@ -0,0 +1,176 @@ +import { initOptions, runInit } from "@fedify/init"; +import { + constant, + merge, + message, + object, + optionNames, + type Parser, +} from "@optique/core"; +import { + type CommandMetadata, + type CommandPath, + defineCommand, + type StaticCommand, +} from "@optique/discover"; +import { benchMetadata, benchOptions } from "./bench/command.ts"; +import { + generateVocabMetadata, + generateVocabOptions, +} from "./generate-vocab/command.ts"; +import { inboxMetadata, inboxOptions } from "./inbox/command.ts"; +import { lookupMetadata, lookupOptions } from "./lookup/command.ts"; +import { nodeInfoMetadata, nodeInfoOptions, runNodeInfo } from "./nodeinfo.ts"; +import type { GlobalOptions } from "./options.ts"; +import { relayMetadata, relayOptions } from "./relay/command.ts"; +import { runTunnel, tunnelMetadata, tunnelOptions } from "./tunnel.ts"; +import { webFingerMetadata, webFingerOptions } from "./webfinger/command.ts"; + +type CliCommandHandler = ( + value: TValue & GlobalOptions, +) => unknown | Promise; + +export type CliStaticCommand = + & Omit, "handler"> + & { + readonly handler: (value: never) => unknown | Promise; + readonly run: CliCommandHandler; + }; + +export type AnyCliStaticCommand = + & Omit, "handler" | "parser"> + & { + readonly parser: Parser<"sync", unknown, unknown>; + readonly handler: (value: never) => unknown | Promise; + readonly run: (value: never) => unknown | Promise; + }; + +export type CliCommandValue = + TCommand extends CliStaticCommand ? TValue : never; + +function defineCliCommand( + command: { + readonly path: CommandPath; + readonly parser: Parser<"sync", TValue, unknown>; + readonly metadata?: CommandMetadata; + readonly run: CliCommandHandler>; + }, +): CliStaticCommand { + const { run, ...definition } = command; + return { + ...defineCommand({ + ...definition, + handler: (_value: TValue) => {}, + }), + run, + }; +} + +const initParser = merge( + initOptions, + object({ command: constant("init") }), +); + +const initMetadata = { + brief: message`Initialize a new Fedify project directory.`, + description: message`Initialize a new Fedify project directory. + +By default, it initializes the current directory. You can specify a different directory as an argument. + +Unless you specify all options (${optionNames(["-w", "--web-framework"])}, ${ + optionNames(["-p", "--package-manager"]) + }, ${optionNames(["-k", "--kv-store"])}, and ${ + optionNames(["-m", "--message-queue"]) + }), it will prompt you to select the options interactively.`, +}; + +export const generatingCommands = [ + defineCliCommand({ + path: ["init"], + parser: initParser, + metadata: initMetadata, + run: runInit, + }), + defineCliCommand({ + path: ["generate-vocab"], + parser: generateVocabOptions, + metadata: generateVocabMetadata, + run: async (value) => { + const { default: runGenerateVocab } = await import( + "./generate-vocab/action.ts" + ); + return await runGenerateVocab(value); + }, + }), +] satisfies readonly AnyCliStaticCommand[]; + +export const activityPubCommands = [ + defineCliCommand({ + path: ["webfinger"], + parser: webFingerOptions, + metadata: webFingerMetadata, + run: async (value) => { + const { default: runWebFinger } = await import("./webfinger/action.ts"); + return await runWebFinger(value); + }, + }), + defineCliCommand({ + path: ["lookup"], + parser: lookupOptions, + metadata: lookupMetadata, + run: async (value) => { + const { runLookup } = await import("./lookup.ts"); + return await runLookup(value); + }, + }), + defineCliCommand({ + path: ["inbox"], + parser: inboxOptions, + metadata: inboxMetadata, + run: async (value) => { + const { runInbox } = await import("./inbox.tsx"); + return await runInbox(value); + }, + }), + defineCliCommand({ + path: ["nodeinfo"], + parser: nodeInfoOptions, + metadata: nodeInfoMetadata, + run: runNodeInfo, + }), + defineCliCommand({ + path: ["relay"], + parser: relayOptions, + metadata: relayMetadata, + run: async (value) => { + const { runRelay } = await import("./relay.ts"); + return await runRelay(value); + }, + }), + defineCliCommand({ + path: ["bench"], + parser: benchOptions, + metadata: benchMetadata, + run: async (value) => { + const { runBench } = await import("./bench/mod.ts"); + return await runBench(value); + }, + }), +] satisfies readonly AnyCliStaticCommand[]; + +export const networkCommands = [ + defineCliCommand({ + path: ["tunnel"], + parser: tunnelOptions, + metadata: tunnelMetadata, + run: runTunnel, + }), +] satisfies readonly AnyCliStaticCommand[]; + +export const cliCommands = [ + ...generatingCommands, + ...activityPubCommands, + ...networkCommands, +] as const satisfies readonly AnyCliStaticCommand[]; + +export type CliCommand = typeof cliCommands[number]; diff --git a/packages/cli/src/generate-vocab/command.ts b/packages/cli/src/generate-vocab/command.ts index b636da2e6..ac2983bd7 100644 --- a/packages/cli/src/generate-vocab/command.ts +++ b/packages/cli/src/generate-vocab/command.ts @@ -27,16 +27,20 @@ const generatedPath = argument( }, ); +export const generateVocabOptions = object("Generation options", { + command: constant("generate-vocab"), + schemaDir, + generatedPath, +}); + +export const generateVocabMetadata = { + description: message`Generate vocabulary classes from schema files.`, +}; + const generateVocabCommand = command( "generate-vocab", - object("Generation options", { - command: constant("generate-vocab"), - schemaDir, - generatedPath, - }), - { - description: message`Generate vocabulary classes from schema files.`, - }, + generateVocabOptions, + generateVocabMetadata, ); export default generateVocabCommand; diff --git a/packages/cli/src/inbox/command.ts b/packages/cli/src/inbox/command.ts index 59ee8d428..4e0b13194 100644 --- a/packages/cli/src/inbox/command.ts +++ b/packages/cli/src/inbox/command.ts @@ -17,81 +17,85 @@ const DEFAULT_EPHEMERAL_INBOX_NAME = "Fedify Ephemeral Inbox"; const DEFAULT_EPHEMERAL_INBOX_SUMMARY = "An ephemeral ActivityPub inbox for testing purposes."; -export const inboxCommand = command( - "inbox", - merge( - object("Inbox options", { - command: constant("inbox"), - follow: bindConfig( - multiple( - option("-f", "--follow", string({ metavar: "URI" }), { - description: - message`Follow the given actor. The argument can be either an actor URI or a handle. Can be specified multiple times.`, - }), - ), - { - context: configContext, - key: (config) => config.inbox?.follow ?? [], - default: [], - }, - ), - acceptFollow: bindConfig( - multiple( - option("-a", "--accept-follow", string({ metavar: "URI" }), { - description: - message`Accept follow requests from the given actor. The argument can be either an actor URI or a handle, or a wildcard (${"*"}). Can be specified multiple times. If a wildcard is specified, all follow requests will be accepted.`, - }), - ), - { - context: configContext, - key: (config) => config.inbox?.acceptFollow ?? [], - default: [], - }, - ), - actorName: bindConfig( - option("--actor-name", string({ metavar: "NAME" }), { - description: message`Customize the actor display name.`, +export const inboxOptions = merge( + object("Inbox options", { + command: constant("inbox"), + follow: bindConfig( + multiple( + option("-f", "--follow", string({ metavar: "URI" }), { + description: + message`Follow the given actor. The argument can be either an actor URI or a handle. Can be specified multiple times.`, }), - { - context: configContext, - key: (config) => - config.inbox?.actorName ?? DEFAULT_EPHEMERAL_INBOX_NAME, - default: DEFAULT_EPHEMERAL_INBOX_NAME, - }, ), - actorSummary: bindConfig( - option("--actor-summary", string({ metavar: "SUMMARY" }), { - description: message`Customize the actor description.`, + { + context: configContext, + key: (config) => config.inbox?.follow ?? [], + default: [], + }, + ), + acceptFollow: bindConfig( + multiple( + option("-a", "--accept-follow", string({ metavar: "URI" }), { + description: + message`Accept follow requests from the given actor. The argument can be either an actor URI or a handle, or a wildcard (${"*"}). Can be specified multiple times. If a wildcard is specified, all follow requests will be accepted.`, }), - { - context: configContext, - key: (config) => - config.inbox?.actorSummary ?? - DEFAULT_EPHEMERAL_INBOX_SUMMARY, - default: DEFAULT_EPHEMERAL_INBOX_SUMMARY, - }, ), - authorizedFetch: bindConfig( - option( - "-A", - "--authorized-fetch", - { - description: - message`Enable authorized fetch mode. Incoming requests without valid HTTP signatures will be rejected with 401 Unauthorized.`, - }, - ), + { + context: configContext, + key: (config) => config.inbox?.acceptFollow ?? [], + default: [], + }, + ), + actorName: bindConfig( + option("--actor-name", string({ metavar: "NAME" }), { + description: message`Customize the actor display name.`, + }), + { + context: configContext, + key: (config) => + config.inbox?.actorName ?? DEFAULT_EPHEMERAL_INBOX_NAME, + default: DEFAULT_EPHEMERAL_INBOX_NAME, + }, + ), + actorSummary: bindConfig( + option("--actor-summary", string({ metavar: "SUMMARY" }), { + description: message`Customize the actor description.`, + }), + { + context: configContext, + key: (config) => + config.inbox?.actorSummary ?? + DEFAULT_EPHEMERAL_INBOX_SUMMARY, + default: DEFAULT_EPHEMERAL_INBOX_SUMMARY, + }, + ), + authorizedFetch: bindConfig( + option( + "-A", + "--authorized-fetch", { - context: configContext, - key: (config) => config.inbox?.authorizedFetch ?? false, - default: false, + description: + message`Enable authorized fetch mode. Incoming requests without valid HTTP signatures will be rejected with 401 Unauthorized.`, }, ), - }), - group("Tunnel options", createTunnelOption("inbox")), - ), - { - brief: message`Run an ephemeral ActivityPub inbox server.`, - description: - message`Spins up an ephemeral server that serves the ActivityPub inbox with a one-time actor, through a short-lived public DNS with HTTPS. You can monitor the incoming activities in real-time.`, - }, + { + context: configContext, + key: (config) => config.inbox?.authorizedFetch ?? false, + default: false, + }, + ), + }), + group("Tunnel options", createTunnelOption("inbox")), +); + +export const inboxMetadata = { + brief: message`Run an ephemeral ActivityPub inbox server.`, + description: + message`Spins up an ephemeral server that serves the ActivityPub inbox with a one-time actor, through a short-lived public DNS with HTTPS. You can monitor the incoming activities in real-time.`, +}; + +export const inboxCommand = command( + "inbox", + inboxOptions, + inboxMetadata, ); diff --git a/packages/cli/src/init/mod.ts b/packages/cli/src/init/mod.ts deleted file mode 100644 index 05626f949..000000000 --- a/packages/cli/src/init/mod.ts +++ /dev/null @@ -1 +0,0 @@ -export { initCommand, runInit } from "@fedify/init"; diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index 48e1125c9..c6f94a100 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -21,301 +21,37 @@ import { } from "@fedify/vocab-runtime"; import type { ResourceDescriptor } from "@fedify/webfinger"; import { getLogger } from "@logtape/logtape"; -import { bindConfig } from "@optique/config"; -import { - argument, - choice, - command, - constant, - flag, - float, - type InferValue, - integer, - map, - merge, - message, - multiple, - object, - option, - optional, - optionNames, - or, - string, - withDefault, -} from "@optique/core"; +import { type InferValue, message, optionNames } from "@optique/core"; import { url as messageUrl } from "@optique/core/message"; -import { path, printError } from "@optique/run"; +import { printError } from "@optique/run"; import { createWriteStream, type WriteStream } from "node:fs"; import { isIP } from "node:net"; import process from "node:process"; import ora from "ora"; -import { configContext } from "./config.ts"; import { getContextLoader, getDocumentLoader } from "./docloader.ts"; import { renderImages } from "./imagerenderer.ts"; -import { configureLogging } from "./log.ts"; import { - createTunnelServiceOption, - type GlobalOptions, - userAgentOption, -} from "./options.ts"; + FEDIBIRD_QUOTE_IRI, + IN_REPLY_TO_IRI, + type lookupOptions, + MISSKEY_QUOTE_IRI, + QUOTE_IRI, + QUOTE_URL_IRI, + type RecurseProperty, +} from "./lookup/command.ts"; +import { configureLogging } from "./log.ts"; +import type { GlobalOptions } from "./options.ts"; import { spawnTemporaryServer, type TemporaryServer } from "./tempserver.ts"; import { colorEnabled, colors, describeError, formatObject } from "./utils.ts"; -const logger = getLogger(["fedify", "cli", "lookup"]); +export { + authorizedFetchOption, + lookupCommand, + lookupMetadata, + lookupOptions, +} from "./lookup/command.ts"; -const IN_REPLY_TO_IRI = "https://www.w3.org/ns/activitystreams#inReplyTo"; -const QUOTE_IRI = "https://w3id.org/fep/044f#quote"; -const QUOTE_URL_IRI = "https://www.w3.org/ns/activitystreams#quoteUrl"; -const MISSKEY_QUOTE_IRI = "https://misskey-hub.net/ns#_misskey_quote"; -const FEDIBIRD_QUOTE_IRI = "http://fedibird.com/ns#quoteUri"; -const recurseProperties = [ - "replyTarget", - "quote", - "quoteUrl", - IN_REPLY_TO_IRI, - QUOTE_IRI, - QUOTE_URL_IRI, - MISSKEY_QUOTE_IRI, - FEDIBIRD_QUOTE_IRI, -] as const; -type RecurseProperty = typeof recurseProperties[number]; - -const suppressErrorsOption = bindConfig( - flag("-S", "--suppress-errors", { - description: - message`Suppress partial errors during traversal or recursion.`, - }), - { - context: configContext, - key: (config) => config.lookup?.suppressErrors ?? false, - default: false, - }, -); - -const allowPrivateAddressOption = bindConfig( - flag("-p", "--allow-private-address", { - description: message`Allow private IP addresses for URLs discovered \ -during traversal or recursive object fetches. Recursive JSON-LD \ -context URLs always remain blocked. URLs explicitly provided on the \ -command line always allow private addresses.`, - }), - { - context: configContext, - key: (config) => config.lookup?.allowPrivateAddress ?? false, - default: false, - }, -); - -export const authorizedFetchOption = withDefault( - object("Authorized fetch options", { - authorizedFetch: bindConfig( - map( - flag("-a", "--authorized-fetch", { - description: message`Sign the request with an one-time key.`, - }), - () => true as const, - ), - { - context: configContext, - key: (config) => config.lookup?.authorizedFetch ? true : undefined, - }, - ), - firstKnock: bindConfig( - option( - "--first-knock", - choice(["draft-cavage-http-signatures-12", "rfc9421"]), - { - description: message`The first-knock spec for ${ - optionNames(["-a", "--authorized-fetch"]) - }. It is used for the double-knocking technique.`, - }, - ), - { - context: configContext, - key: (config) => - config.lookup?.firstKnock ?? "draft-cavage-http-signatures-12", - default: "draft-cavage-http-signatures-12" as const, - }, - ), - tunnelService: optional(createTunnelServiceOption()), - }), - { - authorizedFetch: false as const, - firstKnock: undefined, - tunnelService: undefined, - } as const, -); - -const lookupModeOption = withDefault( - or( - object("Recurse options", { - traverse: constant(false as const), - recurse: bindConfig( - option( - "--recurse", - choice(recurseProperties, { metavar: "PROPERTY" }), - { - description: message`Recursively follow a relationship property.`, - }, - ), - { - context: configContext, - key: (config) => config.lookup?.recurse, - }, - ), - recurseDepth: bindConfig( - option( - "--recurse-depth", - integer({ min: 1, metavar: "DEPTH" }), - { - description: message`Maximum recursion depth for ${ - optionNames(["--recurse"]) - }.`, - }, - ), - { - context: configContext, - key: (config) => config.lookup?.recurseDepth, - default: 20, - }, - ), - suppressErrors: suppressErrorsOption, - }), - object("Traverse options", { - traverse: bindConfig( - flag("-t", "--traverse", { - description: - message`Traverse the given collection(s) to fetch all items.`, - }), - { - context: configContext, - key: (config) => config.lookup?.traverse ?? false, - default: false, - }, - ), - recurse: constant(undefined), - recurseDepth: constant(undefined), - suppressErrors: suppressErrorsOption, - }), - ), - { - traverse: false, - recurse: undefined, - recurseDepth: undefined, - suppressErrors: false, - } as const, -); - -export const lookupCommand = command( - "lookup", - merge( - object({ command: constant("lookup") }), - lookupModeOption, - authorizedFetchOption, - merge( - "Network options", - userAgentOption, - object({ - allowPrivateAddress: allowPrivateAddressOption, - timeout: optional( - bindConfig( - option( - "-T", - "--timeout", - float({ min: 0, metavar: "SECONDS" }), - { - description: - message`Set timeout for network requests in seconds.`, - }, - ), - { - context: configContext, - key: (config) => config.lookup?.timeout, - }, - ), - ), - }), - ), - 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", { - reverse: bindConfig( - flag("--reverse", { - description: - message`Reverse the output order of fetched objects or items.`, - }), - { - context: configContext, - key: (config) => config.lookup?.reverse ?? false, - default: false, - }, - ), - format: bindConfig( - optional( - or( - map( - flag("-r", "--raw", { - description: message`Print the fetched JSON-LD document as is.`, - }), - () => "raw" as const, - ), - map( - flag("-C", "--compact", { - description: message`Compact the fetched JSON-LD document.`, - }), - () => "compact" as const, - ), - map( - flag("-e", "--expand", { - description: message`Expand the fetched JSON-LD document.`, - }), - () => "expand" as const, - ), - ), - ), - { - context: configContext, - key: (config) => config.lookup?.defaultFormat ?? "default", - default: "default", - }, - ), - separator: bindConfig( - option("-s", "--separator", string({ metavar: "SEPARATOR" }), { - description: - message`Specify the separator between adjacent output objects or collection items.`, - }), - { - context: configContext, - key: (config) => config.lookup?.separator ?? "----", - default: "----", - }, - ), - output: optional(option( - "-o", - "--output", - path({ - metavar: "OUTPUT_PATH", - type: "file", - allowCreate: true, - }), - { description: message`Specify the output file path.` }, - )), - }), - ), - { - brief: message`Look up Activity Streams objects.`, - description: - message`Look up Activity Streams objects by URL or actor handle. - -The arguments can be either URLs or actor handles (e.g., ${"@username@domain"}), and they can be multiple.`, - }, -); +const logger = getLogger(["fedify", "cli", "lookup"]); export class TimeoutError extends Error { override name = "TimeoutError"; @@ -337,6 +73,8 @@ export class RecursiveLookupError extends Error { } } +type LookupCommand = InferValue & GlobalOptions; + function writeToStream( stream: NodeJS.WritableStream, chunk: string | Uint8Array, @@ -782,7 +520,7 @@ export async function collectRecursiveObjects( } export async function runLookup( - command: InferValue & GlobalOptions, + command: LookupCommand, deps: Partial<{ lookupObject: typeof lookupObject; traverseCollection: typeof traverseCollection; @@ -898,6 +636,8 @@ export async function runLookup( }; if (command.authorizedFetch) { + const firstKnock = command.firstKnock ?? + "draft-cavage-http-signatures-12"; spinner.text = "Generating a one-time key pair..."; const key = await generateCryptoKeyPair(); spinner.text = "Spinning up a temporary ActivityPub server..."; @@ -946,7 +686,7 @@ export async function runLookup( userAgent: command.userAgent, specDeterminer: { determineSpec() { - return command.firstKnock; + return firstKnock; }, rememberSpec() { }, @@ -964,7 +704,7 @@ export async function runLookup( userAgent: command.userAgent, specDeterminer: { determineSpec() { - return command.firstKnock; + return firstKnock; }, rememberSpec() { }, diff --git a/packages/cli/src/lookup/command.ts b/packages/cli/src/lookup/command.ts new file mode 100644 index 000000000..7d4a140a6 --- /dev/null +++ b/packages/cli/src/lookup/command.ts @@ -0,0 +1,282 @@ +import { bindConfig } from "@optique/config"; +import { + argument, + choice, + command, + constant, + flag, + float, + integer, + map, + merge, + message, + multiple, + object, + option, + optional, + optionNames, + or, + string, + withDefault, +} from "@optique/core"; +import { path } from "@optique/run"; +import { configContext } from "../config.ts"; +import { createTunnelServiceOption, userAgentOption } from "../options.ts"; + +export const IN_REPLY_TO_IRI = + "https://www.w3.org/ns/activitystreams#inReplyTo"; +export const QUOTE_IRI = "https://w3id.org/fep/044f#quote"; +export const QUOTE_URL_IRI = "https://www.w3.org/ns/activitystreams#quoteUrl"; +export const MISSKEY_QUOTE_IRI = "https://misskey-hub.net/ns#_misskey_quote"; +export const FEDIBIRD_QUOTE_IRI = "http://fedibird.com/ns#quoteUri"; +export const recurseProperties = [ + "replyTarget", + "quote", + "quoteUrl", + IN_REPLY_TO_IRI, + QUOTE_IRI, + QUOTE_URL_IRI, + MISSKEY_QUOTE_IRI, + FEDIBIRD_QUOTE_IRI, +] as const; +export type RecurseProperty = typeof recurseProperties[number]; + +const suppressErrorsOption = bindConfig( + flag("-S", "--suppress-errors", { + description: + message`Suppress partial errors during traversal or recursion.`, + }), + { + context: configContext, + key: (config) => config.lookup?.suppressErrors ?? false, + default: false, + }, +); + +const allowPrivateAddressOption = bindConfig( + flag("-p", "--allow-private-address", { + description: message`Allow private IP addresses for URLs discovered \ +during traversal or recursive object fetches. Recursive JSON-LD \ +context URLs always remain blocked. URLs explicitly provided on the \ +command line always allow private addresses.`, + }), + { + context: configContext, + key: (config) => config.lookup?.allowPrivateAddress ?? false, + default: false, + }, +); + +export const authorizedFetchOption = withDefault( + object("Authorized fetch options", { + authorizedFetch: bindConfig( + map( + flag("-a", "--authorized-fetch", { + description: message`Sign the request with an one-time key.`, + }), + () => true as const, + ), + { + context: configContext, + key: (config) => config.lookup?.authorizedFetch ? true : undefined, + }, + ), + firstKnock: bindConfig( + option( + "--first-knock", + choice(["draft-cavage-http-signatures-12", "rfc9421"]), + { + description: message`The first-knock spec for ${ + optionNames(["-a", "--authorized-fetch"]) + }. It is used for the double-knocking technique.`, + }, + ), + { + context: configContext, + key: (config) => + config.lookup?.firstKnock ?? "draft-cavage-http-signatures-12", + default: "draft-cavage-http-signatures-12" as const, + }, + ), + tunnelService: optional(createTunnelServiceOption()), + }), + { + authorizedFetch: false as const, + firstKnock: undefined, + tunnelService: undefined, + } as const, +); + +const lookupModeOption = withDefault( + or( + object("Recurse options", { + traverse: constant(false as const), + recurse: bindConfig( + option( + "--recurse", + choice(recurseProperties, { metavar: "PROPERTY" }), + { + description: message`Recursively follow a relationship property.`, + }, + ), + { + context: configContext, + key: (config) => config.lookup?.recurse, + }, + ), + recurseDepth: bindConfig( + option( + "--recurse-depth", + integer({ min: 1, metavar: "DEPTH" }), + { + description: message`Maximum recursion depth for ${ + optionNames(["--recurse"]) + }.`, + }, + ), + { + context: configContext, + key: (config) => config.lookup?.recurseDepth, + default: 20, + }, + ), + suppressErrors: suppressErrorsOption, + }), + object("Traverse options", { + traverse: bindConfig( + flag("-t", "--traverse", { + description: + message`Traverse the given collection(s) to fetch all items.`, + }), + { + context: configContext, + key: (config) => config.lookup?.traverse ?? false, + default: false, + }, + ), + recurse: constant(undefined), + recurseDepth: constant(undefined), + suppressErrors: suppressErrorsOption, + }), + ), + { + traverse: false, + recurse: undefined, + recurseDepth: undefined, + suppressErrors: false, + } as const, +); + +export const lookupOptions = merge( + object({ command: constant("lookup") }), + lookupModeOption, + authorizedFetchOption, + merge( + "Network options", + userAgentOption, + object({ + allowPrivateAddress: allowPrivateAddressOption, + timeout: optional( + bindConfig( + option( + "-T", + "--timeout", + float({ min: 0, metavar: "SECONDS" }), + { + description: + message`Set timeout for network requests in seconds.`, + }, + ), + { + context: configContext, + key: (config) => config.lookup?.timeout, + }, + ), + ), + }), + ), + 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", { + reverse: bindConfig( + flag("--reverse", { + description: + message`Reverse the output order of fetched objects or items.`, + }), + { + context: configContext, + key: (config) => config.lookup?.reverse ?? false, + default: false, + }, + ), + format: bindConfig( + optional( + or( + map( + flag("-r", "--raw", { + description: message`Print the fetched JSON-LD document as is.`, + }), + () => "raw" as const, + ), + map( + flag("-C", "--compact", { + description: message`Compact the fetched JSON-LD document.`, + }), + () => "compact" as const, + ), + map( + flag("-e", "--expand", { + description: message`Expand the fetched JSON-LD document.`, + }), + () => "expand" as const, + ), + ), + ), + { + context: configContext, + key: (config) => config.lookup?.defaultFormat ?? "default", + default: "default", + }, + ), + separator: bindConfig( + option("-s", "--separator", string({ metavar: "SEPARATOR" }), { + description: + message`Specify the separator between adjacent output objects or collection items.`, + }), + { + context: configContext, + key: (config) => config.lookup?.separator ?? "----", + default: "----", + }, + ), + output: optional(option( + "-o", + "--output", + path({ + metavar: "OUTPUT_PATH", + type: "file", + allowCreate: true, + }), + { description: message`Specify the output file path.` }, + )), + }), +); + +export const lookupMetadata = { + brief: message`Look up Activity Streams objects.`, + description: message`Look up Activity Streams objects by URL or actor handle. + +The arguments can be either URLs or actor handles (e.g., ${"@username@domain"}), and they can be multiple.`, +}; + +export const lookupCommand = command( + "lookup", + lookupOptions, + lookupMetadata, +); diff --git a/packages/cli/src/mod.ts b/packages/cli/src/mod.ts index 61172eb96..f30214c5a 100644 --- a/packages/cli/src/mod.ts +++ b/packages/cli/src/mod.ts @@ -1,40 +1,10 @@ #!/usr/bin/env -S node --disable-warning=ExperimentalWarning -import { runBench } from "./bench/mod.ts"; -import { runGenerateVocab } from "./generate-vocab/mod.ts"; -import { runInbox } from "./inbox.tsx"; -import { runInit } from "./init/mod.ts"; -import { runLookup } from "./lookup.ts"; -import { runNodeInfo } from "./nodeinfo.ts"; import process from "node:process"; -import { runRelay } from "./relay.ts"; -import { runCli } from "./runner.ts"; -import { runTunnel } from "./tunnel.ts"; -import { runWebFinger } from "./webfinger/mod.ts"; +import { parseCliProgram } from "./runner.ts"; async function main() { - const result = await runCli(process.argv.slice(2)); - if (result.command === "init") { - await runInit(result); - } else if (result.command === "lookup") { - await runLookup(result); - } else if (result.command === "webfinger") { - await runWebFinger(result); - } else if (result.command === "inbox") { - await runInbox(result); - } else if (result.command === "nodeinfo") { - await runNodeInfo(result); - } else if (result.command === "tunnel") { - await runTunnel(result); - } else if (result.command === "generate-vocab") { - await runGenerateVocab(result); - } else if (result.command === "relay") { - await runRelay(result); - } else if (result.command === "bench") { - await runBench(result); - } else { - // Make this branch exhaustive for type safety, even though it should never happen: - const _exhaustiveCheck: never = result; - } + const program = await parseCliProgram(process.argv.slice(2)); + await program.run(); } await main(); diff --git a/packages/cli/src/nodeinfo.ts b/packages/cli/src/nodeinfo.ts index 21b4058ad..07d4cfac5 100644 --- a/packages/cli/src/nodeinfo.ts +++ b/packages/cli/src/nodeinfo.ts @@ -84,26 +84,30 @@ const nodeInfoOption = merge( }), ); -export const nodeInfoCommand = Command( - "nodeinfo", - merge( - object("Arguments", { - command: constant("nodeinfo"), - host: argument(string({ metavar: "HOST" }), { - description: message`Bare hostname or a full URL of the instance`, - }), +export const nodeInfoOptions = merge( + object("Arguments", { + command: constant("nodeinfo"), + host: argument(string({ metavar: "HOST" }), { + description: message`Bare hostname or a full URL of the instance`, }), - nodeInfoOption, - group("Network options", userAgentOption), - ), - { - brief: - message`Get information about a remote node using the NodeInfo protocol`, - description: - message`Get information about a remote node using the NodeInfo protocol. + }), + nodeInfoOption, + group("Network options", userAgentOption), +); + +export const nodeInfoMetadata = { + brief: + message`Get information about a remote node using the NodeInfo protocol`, + description: + message`Get information about a remote node using the NodeInfo protocol. The argument is the hostname of the remote node, or the URL of the remote node.`, - }, +}; + +export const nodeInfoCommand = Command( + "nodeinfo", + nodeInfoOptions, + nodeInfoMetadata, ); export async function runNodeInfo( diff --git a/packages/cli/src/relay/command.ts b/packages/cli/src/relay/command.ts index d745b50bc..e452fad12 100644 --- a/packages/cli/src/relay/command.ts +++ b/packages/cli/src/relay/command.ts @@ -18,103 +18,107 @@ import { choice } from "@optique/core/valueparser"; import { configContext } from "../config.ts"; import { createTunnelOption } from "../options.ts"; -export const relayCommand = command( - "relay", - merge( - object("Relay options", { - command: constant("relay"), - protocol: bindConfig( - option( - "-p", - "--protocol", - choice(["mastodon", "litepub"], { metavar: "TYPE" }), - { - description: message`The relay protocol to use. ${ - value("mastodon") - } for Mastodon-compatible relay, ${ - value("litepub") - } for LitePub-compatible relay.`, - }, - ), - { - context: configContext, - key: (config) => config.relay?.protocol ?? "mastodon", - default: "mastodon", - }, - ), - persistent: optional( - bindConfig( - option("--persistent", string({ metavar: "PATH" }), { - description: - message`Path to SQLite database file for persistent storage. If not specified, uses in-memory storage which is lost when the server stops.`, - }), - { - context: configContext, - key: (config) => config.relay?.persistent, - }, - ), - ), - port: bindConfig( - option( - "-P", - "--port", - integer({ min: 0, max: 65535, metavar: "PORT" }), - { - description: message`The local port to listen on.`, - }, - ), +export const relayOptions = merge( + object("Relay options", { + command: constant("relay"), + protocol: bindConfig( + option( + "-p", + "--protocol", + choice(["mastodon", "litepub"], { metavar: "TYPE" }), { - context: configContext, - key: (config) => config.relay?.port ?? 8000, - default: 8000, + description: message`The relay protocol to use. ${ + value("mastodon") + } for Mastodon-compatible relay, ${ + value("litepub") + } for LitePub-compatible relay.`, }, ), - name: bindConfig( - option("-n", "--name", string({ metavar: "NAME" }), { - description: message`The relay display name.`, + { + context: configContext, + key: (config) => config.relay?.protocol ?? "mastodon", + default: "mastodon", + }, + ), + persistent: optional( + bindConfig( + option("--persistent", string({ metavar: "PATH" }), { + description: + message`Path to SQLite database file for persistent storage. If not specified, uses in-memory storage which is lost when the server stops.`, }), { context: configContext, - key: (config) => config.relay?.name ?? "Fedify Relay", - default: "Fedify Relay", + key: (config) => config.relay?.persistent, }, ), - acceptFollow: bindConfig( - multiple( - option("-a", "--accept-follow", string({ metavar: "URI" }), { - description: - message`Accept follow requests from the given actor. The argument can be either an actor URI or a handle, or a wildcard (${"*"}). Can be specified multiple times. If a wildcard is specified, all follow requests will be accepted.`, - }), - ), + ), + port: bindConfig( + option( + "-P", + "--port", + integer({ min: 0, max: 65535, metavar: "PORT" }), { - context: configContext, - key: (config) => config.relay?.acceptFollow ?? [], - default: [], + description: message`The local port to listen on.`, }, ), - rejectFollow: bindConfig( - multiple( - option("-r", "--reject-follow", string({ metavar: "URI" }), { - description: - message`Reject follow requests from the given actor. The argument can be either an actor URI or a handle, or a wildcard (${"*"}). Can be specified multiple times. If a wildcard is specified, all follow requests will be rejected.`, - }), - ), - { - context: configContext, - key: (config) => config.relay?.rejectFollow ?? [], - default: [], - }, + { + context: configContext, + key: (config) => config.relay?.port ?? 8000, + default: 8000, + }, + ), + name: bindConfig( + option("-n", "--name", string({ metavar: "NAME" }), { + description: message`The relay display name.`, + }), + { + context: configContext, + key: (config) => config.relay?.name ?? "Fedify Relay", + default: "Fedify Relay", + }, + ), + acceptFollow: bindConfig( + multiple( + option("-a", "--accept-follow", string({ metavar: "URI" }), { + description: + message`Accept follow requests from the given actor. The argument can be either an actor URI or a handle, or a wildcard (${"*"}). Can be specified multiple times. If a wildcard is specified, all follow requests will be accepted.`, + }), + ), + { + context: configContext, + key: (config) => config.relay?.acceptFollow ?? [], + default: [], + }, + ), + rejectFollow: bindConfig( + multiple( + option("-r", "--reject-follow", string({ metavar: "URI" }), { + description: + message`Reject follow requests from the given actor. The argument can be either an actor URI or a handle, or a wildcard (${"*"}). Can be specified multiple times. If a wildcard is specified, all follow requests will be rejected.`, + }), ), - }), - group("Tunnel options", createTunnelOption("relay")), - ), - { - brief: message`Run an ephemeral ActivityPub relay server.`, - description: - message`Spins up an ActivityPub relay server that forwards activities between federated instances. The server can use either Mastodon or LitePub compatible relay protocol. + { + context: configContext, + key: (config) => config.relay?.rejectFollow ?? [], + default: [], + }, + ), + }), + group("Tunnel options", createTunnelOption("relay")), +); + +export const relayMetadata = { + brief: message`Run an ephemeral ActivityPub relay server.`, + description: + message`Spins up an ActivityPub relay server that forwards activities between federated instances. The server can use either Mastodon or LitePub compatible relay protocol. By default, the server is tunneled to the public internet for external access. Use ${ - optionName("--no-tunnel") - } to run locally only.`, - }, + optionName("--no-tunnel") + } to run locally only.`, +}; + +export const relayCommand = command( + "relay", + relayOptions, + relayMetadata, ); diff --git a/packages/cli/src/runner.test.ts b/packages/cli/src/runner.test.ts new file mode 100644 index 000000000..308476783 --- /dev/null +++ b/packages/cli/src/runner.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseCliProgram, runCli, toCliProgram } from "./runner.ts"; + +test("parseCliProgram keeps the selected static command", async () => { + const program = await parseCliProgram([ + "tunnel", + "3000", + "--ignore-config", + ]); + + assert.deepStrictEqual(program.command.path, ["tunnel"]); + assert.strictEqual(program.value.command, "tunnel"); + assert.strictEqual(program.value.port, 3000); + assert.strictEqual(program.value.ignoreConfig, true); +}); + +test("runCli does not expose the selected static command marker", async () => { + const result = await runCli(["tunnel", "3000", "--ignore-config"]); + + assert.strictEqual("__fedifyCliSelectedCommand" in result, false); + assert.strictEqual(result.command, "tunnel"); +}); + +test("toCliProgram runs commands with stripped values", async () => { + let receivedValue: Record | undefined; + const program = toCliProgram( + { + command: "fake", + port: 3000, + ignoreConfig: true, + debug: false, + __fedifyCliSelectedCommand: { path: ["fake"] }, + __fedifyCliRunCommand: (value: Record) => { + receivedValue = value; + }, + } as unknown as Parameters[0], + ); + await program.run(); + + assert.ok(receivedValue != null); + assert.strictEqual("__fedifyCliSelectedCommand" in receivedValue, false); + assert.strictEqual("__fedifyCliRunCommand" in receivedValue, false); + assert.strictEqual(receivedValue.command, "fake"); + assert.strictEqual(receivedValue.port, 3000); + assert.strictEqual(receivedValue.ignoreConfig, true); +}); diff --git a/packages/cli/src/runner.ts b/packages/cli/src/runner.ts index 3601f7646..844bd2526 100644 --- a/packages/cli/src/runner.ts +++ b/packages/cli/src/runner.ts @@ -1,23 +1,30 @@ -import { group, merge, message, or } from "@optique/core"; -import { printError, run } from "@optique/run"; +import { + command as optiqueCommand, + group, + map, + merge, + message, + or, + type Parser, +} from "@optique/core"; +import { printError, run, type RunOptions } from "@optique/run"; import { merge as deepMerge } from "es-toolkit"; import { readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import process from "node:process"; import { parse as parseToml } from "smol-toml"; -import { benchCommand } from "./bench/command.ts"; +import { + activityPubCommands, + type CliCommand, + type CliCommandValue, + type CliStaticCommand, + generatingCommands, + networkCommands, +} from "./commands.ts"; import { configContext, tryLoadToml } from "./config.ts"; -import { generateVocabCommand } from "./generate-vocab/mod.ts"; -import { inboxCommand } from "./inbox/command.ts"; -import { initCommand } from "./init/mod.ts"; -import { lookupCommand } from "./lookup.ts"; -import { nodeInfoCommand } from "./nodeinfo.ts"; -import { globalOptions } from "./options.ts"; -import { relayCommand } from "./relay/command.ts"; -import { tunnelCommand } from "./tunnel.ts"; +import { type GlobalOptions, globalOptions } from "./options.ts"; import { describeError } from "./utils.ts"; -import { webFingerCommand } from "./webfinger/mod.ts"; import metadata from "../deno.json" with { type: "json" }; /** @@ -51,32 +58,105 @@ function getUserConfigPath(): string { return join(xdgConfigHome, "fedify", "config.toml"); } -export const command = merge( +const selectedCommand = "__fedifyCliSelectedCommand"; +const selectedRun = "__fedifyCliRunCommand"; + +type CommandRunner = ( + globalOptions: GlobalOptions, +) => unknown | Promise; + +type CommandInvocation = + TCommand extends CliCommand ? CliCommandValue & { + [selectedCommand]: TCommand; + [selectedRun]: CommandRunner; + } + : never; + +type RunnableCommandValue = + & CommandInvocation + & GlobalOptions; + +type PublicCommandValue = Record & GlobalOptions; + +export type CliProgram = { + command: CliCommand; + value: PublicCommandValue; + run: () => unknown | Promise; +}; + +function staticCommandParser< + TValue extends object, + TCommand extends CliStaticCommand & CliCommand, +>( + staticCommand: TCommand, +): Parser<"sync", CommandInvocation, unknown> { + if (staticCommand.path.length < 1) { + throw new TypeError("Static command path must not be empty."); + } + + let parser = staticCommand.parser as Parser< + "sync", + TValue, + unknown + >; + for (let i = staticCommand.path.length - 1; i >= 0; i--) { + const name = staticCommand.path[i]; + if (name == null) { + throw new TypeError("Static command path contains an empty segment."); + } + parser = optiqueCommand( + name, + parser, + i === staticCommand.path.length - 1 ? staticCommand.metadata : undefined, + ) as Parser<"sync", TValue, unknown>; + } + + const runCommand = staticCommand.run; + return map(parser, (value) => ({ + ...value, + [selectedCommand]: staticCommand, + [selectedRun]: (globalOptions: GlobalOptions) => + runCommand({ ...value, ...globalOptions }), + })) as Parser<"sync", CommandInvocation, unknown>; +} + +function staticCommandsParser( + commands: readonly TCommand[], +): Parser<"sync", CommandInvocation, unknown> { + const parsers = commands.map(staticCommandParser); + if (parsers.length < 1) { + throw new TypeError("Static command group must not be empty."); + } + return parsers.length === 1 ? parsers[0]! : or(...parsers); +} + +const runnableCommand = merge( or( group( "Generating code", - or( - initCommand, - generateVocabCommand, - ), + staticCommandsParser(generatingCommands), ), group( "ActivityPub tools", - or( - webFingerCommand, - lookupCommand, - inboxCommand, - nodeInfoCommand, - relayCommand, - benchCommand, - ), + staticCommandsParser(activityPubCommands), ), group( "Network tools", - tunnelCommand, + staticCommandsParser(networkCommands), ), ), globalOptions, +) as Parser<"sync", RunnableCommandValue, unknown>; + +export const command = map( + runnableCommand, + ( + { + [selectedCommand]: _selectedCommand, + [selectedRun]: _selectedRun, + ...value + }, + ) => value as PublicCommandValue, ); type ConfigOptions = { @@ -84,6 +164,35 @@ type ConfigOptions = { configPath?: string; }; +function getRunOptions(args: string[]): RunOptions { + return { + contexts: [configContext], + contextOptions: { load: loadConfig }, + programName: "fedify", + args, + help: { + command: { group: "Meta commands" }, + option: { group: "Meta commands" }, + }, + version: { + value: metadata.version, + command: { group: "Meta commands" }, + option: { group: "Meta commands" }, + }, + completion: { + command: { + names: ["completions", "completion"] as const, + group: "Meta commands", + }, + }, + colors: process.stdout.isTTY && + (process.env.NO_COLOR == null || process.env.NO_COLOR === ""), + maxWidth: process.stdout.columns, + showDefault: true, + showChoices: true, + }; +} + export function loadConfig( parsed: ConfigOptions, ): { config: Record; meta: undefined } | undefined { @@ -128,30 +237,29 @@ export function loadConfig( * @returns The parsed command result from Optique's runner. */ export function runCli(args: string[]) { - return run(command, { - contexts: [configContext], - contextOptions: { load: loadConfig }, - programName: "fedify", - args, - help: { - command: { group: "Meta commands" }, - option: { group: "Meta commands" }, - }, - version: { - value: metadata.version, - command: { group: "Meta commands" }, - option: { group: "Meta commands" }, - }, - completion: { - command: { - names: ["completions", "completion"], - group: "Meta commands", - }, - }, - colors: process.stdout.isTTY && - (process.env.NO_COLOR == null || process.env.NO_COLOR === ""), - maxWidth: process.stdout.columns, - showDefault: true, - showChoices: true, - }); + return run(command, getRunOptions(args)); +} + +export function toCliProgram( + parsed: RunnableCommandValue, +): CliProgram { + const { + [selectedCommand]: command, + [selectedRun]: runCommand, + ...value + } = parsed; + const publicValue = value as PublicCommandValue; + return { + command, + value: publicValue, + run: () => runCommand(publicValue), + }; +} + +export async function parseCliProgram(args: string[]): Promise { + const parsed = await run( + runnableCommand, + getRunOptions(args), + ); + return toCliProgram(parsed); } diff --git a/packages/cli/src/startup.test.ts b/packages/cli/src/startup.test.ts index bb1fa3fea..e73f5098f 100644 --- a/packages/cli/src/startup.test.ts +++ b/packages/cli/src/startup.test.ts @@ -5,12 +5,12 @@ import test from "node:test"; import { fileURLToPath } from "node:url"; const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -test("CLI build keeps the init bridge entrypoint", async () => { +test("CLI build keeps the init command bridge", async () => { const entrypoint = resolve(packageDir, "dist/mod.js"); - const initBridge = resolve(packageDir, "dist/init/mod.js"); + const commandBridge = resolve(packageDir, "dist/commands.js"); await access(entrypoint); - await access(initBridge); + await access(commandBridge); - const bridgeSource = await readFile(initBridge, "utf8"); + const bridgeSource = await readFile(commandBridge, "utf8"); match(bridgeSource, /@fedify\/init/); }); diff --git a/packages/cli/src/tunnel.ts b/packages/cli/src/tunnel.ts index a3e8e1ea6..7270d4f4f 100644 --- a/packages/cli/src/tunnel.ts +++ b/packages/cli/src/tunnel.ts @@ -15,31 +15,35 @@ import ora from "ora"; import { configureLogging } from "./log.ts"; import { createTunnelServiceOption, type GlobalOptions } from "./options.ts"; -export const tunnelCommand = command( - "tunnel", - merge( - "Tunnel options", - object({ - command: constant("tunnel"), - }), - object({ - port: argument(integer({ metavar: "PORT", min: 0, max: 65535 }), { - description: message`The local port number to expose.`, - }), - service: createTunnelServiceOption([ - "-s", - "--service", - ]), +export const tunnelOptions = merge( + "Tunnel options", + object({ + command: constant("tunnel"), + }), + object({ + port: argument(integer({ metavar: "PORT", min: 0, max: 65535 }), { + description: message`The local port number to expose.`, }), - ), - { - brief: - message`Expose a local HTTP server to the public internet using a secure tunnel.`, - description: - message`Expose a local HTTP server to the public internet using a secure tunnel. + service: createTunnelServiceOption([ + "-s", + "--service", + ]), + }), +); + +export const tunnelMetadata = { + brief: + message`Expose a local HTTP server to the public internet using a secure tunnel.`, + description: + message`Expose a local HTTP server to the public internet using a secure tunnel. Note that the HTTP requests through the tunnel have X-Forwarded-* headers.`, - }, +}; + +export const tunnelCommand = command( + "tunnel", + tunnelOptions, + tunnelMetadata, ); export async function runTunnel( diff --git a/packages/cli/src/webfinger/command.ts b/packages/cli/src/webfinger/command.ts index 1b85fbbf9..e7f07b10f 100644 --- a/packages/cli/src/webfinger/command.ts +++ b/packages/cli/src/webfinger/command.ts @@ -41,32 +41,36 @@ const maxRedirection = bindConfig( }, ); -export const webFingerCommand = command( - "webfinger", - merge( - "Network options", - object({ - command: constant("webfinger"), - resources: group( - "Arguments", - multiple( - argument(string({ metavar: "RESOURCE" }), { - description: message`WebFinger resource(s) to look up.`, - }), - { min: 1 }, - ), +export const webFingerOptions = merge( + "Network options", + object({ + command: constant("webfinger"), + resources: group( + "Arguments", + multiple( + argument(string({ metavar: "RESOURCE" }), { + description: message`WebFinger resource(s) to look up.`, + }), + { min: 1 }, ), - allowPrivateAddresses, - maxRedirection, - }), - userAgentOption, - ), - { - brief: message`Look up WebFinger resources.`, - description: message`Look up WebFinger resources. + ), + allowPrivateAddresses, + maxRedirection, + }), + userAgentOption, +); + +export const webFingerMetadata = { + brief: message`Look up WebFinger resources.`, + description: message`Look up WebFinger resources. The argument can be multiple.`, - }, +}; + +export const webFingerCommand = command( + "webfinger", + webFingerOptions, + webFingerMetadata, ); export type WebFingerCommand = InferValue; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84a59ab96..7734bb02c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -58,14 +58,17 @@ catalogs: specifier: ^1.40.0 version: 1.40.0 '@optique/config': - specifier: ^1.0.2 - version: 1.0.2 + specifier: ^1.1.0 + version: 1.1.0 '@optique/core': - specifier: ^1.0.2 - version: 1.0.2 + specifier: ^1.1.0 + version: 1.1.0 + '@optique/discover': + specifier: ^1.1.0 + version: 1.1.0 '@optique/run': - specifier: ^1.0.2 - version: 1.0.2 + specifier: ^1.1.0 + version: 1.1.0 '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 @@ -950,13 +953,16 @@ importers: version: 2.1.0 '@optique/config': specifier: 'catalog:' - version: 1.0.2(@standard-schema/spec@1.1.0) + version: 1.1.0(@standard-schema/spec@1.1.0) '@optique/core': specifier: 'catalog:' - version: 1.0.2 + version: 1.1.0 + '@optique/discover': + specifier: 'catalog:' + version: 1.1.0 '@optique/run': specifier: 'catalog:' - version: 1.0.2 + version: 1.1.0 '@poppanator/http-constants': specifier: ^1.1.1 version: 1.1.1 @@ -1038,10 +1044,10 @@ importers: version: link:../init '@optique/core': specifier: 'catalog:' - version: 1.0.2 + version: 1.1.0 '@optique/run': specifier: 'catalog:' - version: 1.0.2 + version: 1.1.0 es-toolkit: specifier: 'catalog:' version: 1.46.1 @@ -1320,10 +1326,10 @@ importers: version: 2.1.0 '@optique/core': specifier: 'catalog:' - version: 1.0.2 + version: 1.1.0 '@optique/run': specifier: 'catalog:' - version: 1.0.2 + version: 1.1.0 chalk: specifier: 'catalog:' version: 5.6.2 @@ -4582,18 +4588,22 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 - '@optique/config@1.0.2': - resolution: {integrity: sha512-h0OnuXdVIE+bDxVuhMuYtEv0lzllSK7dYBTDSPdfDZq1NZ0dcpYs1dsgYKKG8PtuDN/svRr7xZ2oSonPhxNpow==} + '@optique/config@1.1.0': + resolution: {integrity: sha512-pl9xi0xbqVpDYM4rFcIaL8FDM5d1+rX0dWYl69Z/pAIVBG4JJBqucy0O5thXzKY/zW73i70D598jQ5lPThMcJw==} engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} peerDependencies: '@standard-schema/spec': ^1.1.0 - '@optique/core@1.0.2': - resolution: {integrity: sha512-znsqMmjAdeOgSJzdJlpZpgAscojwQmeQYXzYnuEKllz5VCj6WyEkdzU4QuvJQtWQY3ve2taXwudEBRur0VHBOQ==} + '@optique/core@1.1.0': + resolution: {integrity: sha512-eBqai76tHiFDoShlTNXN9AAPs9XznCJRrk4qmGhjZUSMmePCF9o1XU3okUHxHdDXXCHj4auKoIvCN79KNKErxA==} + engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} + + '@optique/discover@1.1.0': + resolution: {integrity: sha512-yyhBNS7CRxckZw14Hjw4qnvv8sOfcpM6uA7hhHr7IC4bWoIoLI3F6G2WhSpAuS/cIDXK7TfEoOHw03kC2ogrPg==} engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} - '@optique/run@1.0.2': - resolution: {integrity: sha512-0Wc+zC8SLGV8zXQX+pk+o0c6wE/ddx/36CHZ0toTh5lApsjruUuGhqbxvljerAAG5un1xQbOLxzksBVC6UPgSg==} + '@optique/run@1.1.0': + resolution: {integrity: sha512-dcuqqqU1Cpm9CLGEkCkpT/cpJ6H6a+hs0rP+iD8Tgwb+CPPZtX/hCfdIrqYyZ2RtYLxgc3S6KqC81AZAwEUPew==} engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} '@oslojs/encoding@1.1.0': @@ -16199,16 +16209,21 @@ snapshots: '@opentelemetry/api': 1.9.1 '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.1) - '@optique/config@1.0.2(@standard-schema/spec@1.1.0)': + '@optique/config@1.1.0(@standard-schema/spec@1.1.0)': dependencies: - '@optique/core': 1.0.2 + '@optique/core': 1.1.0 '@standard-schema/spec': 1.1.0 - '@optique/core@1.0.2': {} + '@optique/core@1.1.0': {} + + '@optique/discover@1.1.0': + dependencies: + '@optique/core': 1.1.0 + '@optique/run': 1.1.0 - '@optique/run@1.0.2': + '@optique/run@1.1.0': dependencies: - '@optique/core': 1.0.2 + '@optique/core': 1.1.0 '@oslojs/encoding@1.1.0': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 896419acb..93c89fb8a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -68,9 +68,10 @@ catalog: "@opentelemetry/sdk-metrics": 2.7.1 "@opentelemetry/sdk-trace-base": ^2.7.1 "@opentelemetry/semantic-conventions": ^1.40.0 - "@optique/config": ^1.0.2 - "@optique/core": ^1.0.2 - "@optique/run": ^1.0.2 + "@optique/config": ^1.1.0 + "@optique/core": ^1.1.0 + "@optique/discover": ^1.1.0 + "@optique/run": ^1.1.0 "@standard-schema/spec": ^1.1.0 "@std/assert": "jsr:^1.0.13" "@std/async": "jsr:^1.0.13"