From 3580ff5383ca2ad2061cb7c8f3c22b769b75ee3c Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 17 Jun 2026 01:24:32 +0900 Subject: [PATCH 1/8] Use Optique discovery in CLI Upgrade Optique to 1.1.0 and register CLI commands as static command descriptors. This keeps the command tree discoverable without runtime file scanning, so `deno compile` packaging continues to work. Dispatch the selected static command from the parsed program and await all command handlers so asynchronous failures remain observable. https://github.com/dahlia/optique/discussions/834 Assisted-by: Codex:gpt-5.5 --- deno.json | 7 +- deno.lock | 34 ++-- packages/cli/package.json | 1 + packages/cli/src/bench/command.ts | 18 +- packages/cli/src/commands.ts | 97 ++++++++++ packages/cli/src/generate-vocab/command.ts | 20 ++- packages/cli/src/inbox/command.ts | 144 +++++++-------- packages/cli/src/init/mod.ts | 1 - packages/cli/src/lookup.ts | 197 +++++++++++---------- packages/cli/src/mod.ts | 63 ++++--- packages/cli/src/nodeinfo.ts | 38 ++-- packages/cli/src/relay/command.ts | 174 +++++++++--------- packages/cli/src/runner.test.ts | 23 +++ packages/cli/src/runner.ts | 169 ++++++++++++------ packages/cli/src/startup.test.ts | 8 +- packages/cli/src/tunnel.ts | 48 ++--- packages/cli/src/webfinger/command.ts | 50 +++--- pnpm-lock.yaml | 63 ++++--- pnpm-workspace.yaml | 7 +- 19 files changed, 709 insertions(+), 453 deletions(-) create mode 100644 packages/cli/src/commands.ts delete mode 100644 packages/cli/src/init/mod.ts create mode 100644 packages/cli/src/runner.test.ts 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..9f8a4e581 --- /dev/null +++ b/packages/cli/src/commands.ts @@ -0,0 +1,97 @@ +import { initOptions } from "@fedify/init"; +import { type AnyStaticCommand, defineCommand } from "@optique/discover"; +import { constant, merge, message, object, optionNames } from "@optique/core"; +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.ts"; +import { nodeInfoMetadata, nodeInfoOptions } from "./nodeinfo.ts"; +import { relayMetadata, relayOptions } from "./relay/command.ts"; +import { tunnelMetadata, tunnelOptions } from "./tunnel.ts"; +import { webFingerMetadata, webFingerOptions } from "./webfinger/command.ts"; + +export type CliStaticCommand = AnyStaticCommand; + +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 = [ + defineCommand({ + path: ["init"], + parser: initParser, + metadata: initMetadata, + handler: () => {}, + }), + defineCommand({ + path: ["generate-vocab"], + parser: generateVocabOptions, + metadata: generateVocabMetadata, + handler: () => {}, + }), +] satisfies readonly CliStaticCommand[]; + +export const activityPubCommands = [ + defineCommand({ + path: ["webfinger"], + parser: webFingerOptions, + metadata: webFingerMetadata, + handler: () => {}, + }), + defineCommand({ + path: ["lookup"], + parser: lookupOptions, + metadata: lookupMetadata, + handler: () => {}, + }), + defineCommand({ + path: ["inbox"], + parser: inboxOptions, + metadata: inboxMetadata, + handler: () => {}, + }), + defineCommand({ + path: ["nodeinfo"], + parser: nodeInfoOptions, + metadata: nodeInfoMetadata, + handler: () => {}, + }), + defineCommand({ + path: ["relay"], + parser: relayOptions, + metadata: relayMetadata, + handler: () => {}, + }), + defineCommand({ + path: ["bench"], + parser: benchOptions, + metadata: benchMetadata, + handler: () => {}, + }), +] satisfies readonly CliStaticCommand[]; + +export const networkCommands = [ + defineCommand({ + path: ["tunnel"], + parser: tunnelOptions, + metadata: tunnelMetadata, + handler: () => {}, + }), +] satisfies readonly CliStaticCommand[]; 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..06c0f26ee 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -206,115 +206,118 @@ const lookupModeOption = withDefault( } as const, ); -export const lookupCommand = command( - "lookup", +export const lookupOptions = merge( + object({ command: constant("lookup") }), + lookupModeOption, + authorizedFetchOption, 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.`, - }, - ), + "Network options", + userAgentOption, + object({ + allowPrivateAddress: allowPrivateAddressOption, + timeout: optional( + bindConfig( + option( + "-T", + "--timeout", + float({ min: 0, metavar: "SECONDS" }), { - context: configContext, - key: (config) => config.lookup?.timeout, + 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, - ), + ), + 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. + { + 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, ); export class TimeoutError extends Error { diff --git a/packages/cli/src/mod.ts b/packages/cli/src/mod.ts index 61172eb96..0dac9d9db 100644 --- a/packages/cli/src/mod.ts +++ b/packages/cli/src/mod.ts @@ -1,39 +1,52 @@ #!/usr/bin/env -S node --disable-warning=ExperimentalWarning +import { runInit } from "@fedify/init"; +import process from "node:process"; 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 { parseCliProgram } from "./runner.ts"; import { runTunnel } from "./tunnel.ts"; import { runWebFinger } from "./webfinger/mod.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 { command, value } = await parseCliProgram(process.argv.slice(2)); + switch (command.path.join(" ")) { + case "init": + await runInit(value as unknown as Parameters[0]); + break; + case "generate-vocab": + await runGenerateVocab( + value as unknown as Parameters[0], + ); + break; + case "webfinger": + await runWebFinger( + value as unknown as Parameters[0], + ); + break; + case "lookup": + await runLookup(value as unknown as Parameters[0]); + break; + case "inbox": + await runInbox(value as unknown as Parameters[0]); + break; + case "nodeinfo": + await runNodeInfo(value as unknown as Parameters[0]); + break; + case "relay": + await runRelay(value as unknown as Parameters[0]); + break; + case "bench": + await runBench(value as unknown as Parameters[0]); + break; + case "tunnel": + await runTunnel(value as unknown as Parameters[0]); + break; + default: + throw new TypeError(`Unknown command: ${command.path.join(" ")}`); } } 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..3d9dbca4a --- /dev/null +++ b/packages/cli/src/runner.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseCliProgram, runCli } 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"); +}); diff --git a/packages/cli/src/runner.ts b/packages/cli/src/runner.ts index 3601f7646..6f1c6a811 100644 --- a/packages/cli/src/runner.ts +++ b/packages/cli/src/runner.ts @@ -1,23 +1,28 @@ -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 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 +56,80 @@ function getUserConfigPath(): string { return join(xdgConfigHome, "fedify", "config.toml"); } -export const command = merge( +const selectedCommand = "__fedifyCliSelectedCommand"; + +type CommandInvocation = Record & { + [selectedCommand]: CliStaticCommand; +}; + +type RunnableCommandValue = CommandInvocation & GlobalOptions; + +export type CliProgram = { + command: CliStaticCommand; + value: Record & GlobalOptions; +}; + +function staticCommandParser( + staticCommand: CliStaticCommand, +): 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", + Record, + 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", Record, unknown>; + } + + return map(parser, (value) => ({ + ...value, + [selectedCommand]: staticCommand, + })) as Parser<"sync", CommandInvocation, unknown>; +} + +function staticCommandsParser( + commands: readonly CliStaticCommand[], +): 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, ...value }) => value, ); type ConfigOptions = { @@ -84,6 +137,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 +210,13 @@ 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 async function parseCliProgram(args: string[]): Promise { + const { [selectedCommand]: command, ...value } = await run( + runnableCommand, + getRunOptions(args), + ); + return { command, value }; } 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" From aef42da82d0e575bf6f215dff24fa8731b97af04 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 17 Jun 2026 02:38:25 +0900 Subject: [PATCH 2/8] Preserve CLI command handler types Static CLI command definitions now carry the typed runtime handler next to the parser descriptor. The entrypoint dispatches through the parsed program without erasing each handler parameter type or casting through unknown. https://github.com/fedify-dev/fedify/pull/808#discussion_r3422527593 Assisted-by: Codex:gpt-5.5 --- packages/cli/src/commands.ts | 120 +++++++++++++++++++++++++++-------- packages/cli/src/lookup.ts | 32 ++++++++-- packages/cli/src/mod.ts | 47 +------------- packages/cli/src/runner.ts | 78 +++++++++++++++++------ 4 files changed, 182 insertions(+), 95 deletions(-) diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 9f8a4e581..ee02eb182 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -1,19 +1,75 @@ -import { initOptions } from "@fedify/init"; -import { type AnyStaticCommand, defineCommand } from "@optique/discover"; -import { constant, merge, message, object, optionNames } from "@optique/core"; +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 runBench from "./bench/action.ts"; import { benchMetadata, benchOptions } from "./bench/command.ts"; +import runGenerateVocab from "./generate-vocab/action.ts"; import { generateVocabMetadata, generateVocabOptions, } from "./generate-vocab/command.ts"; +import { runInbox } from "./inbox.tsx"; import { inboxMetadata, inboxOptions } from "./inbox/command.ts"; -import { lookupMetadata, lookupOptions } from "./lookup.ts"; -import { nodeInfoMetadata, nodeInfoOptions } from "./nodeinfo.ts"; +import { lookupMetadata, lookupOptions, runLookup } from "./lookup.ts"; +import { nodeInfoMetadata, nodeInfoOptions, runNodeInfo } from "./nodeinfo.ts"; +import type { GlobalOptions } from "./options.ts"; +import { runRelay } from "./relay.ts"; import { relayMetadata, relayOptions } from "./relay/command.ts"; -import { tunnelMetadata, tunnelOptions } from "./tunnel.ts"; +import { runTunnel, tunnelMetadata, tunnelOptions } from "./tunnel.ts"; +import runWebFinger from "./webfinger/action.ts"; import { webFingerMetadata, webFingerOptions } from "./webfinger/command.ts"; -export type CliStaticCommand = AnyStaticCommand; +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, @@ -34,64 +90,72 @@ Unless you specify all options (${optionNames(["-w", "--web-framework"])}, ${ }; export const generatingCommands = [ - defineCommand({ + defineCliCommand({ path: ["init"], parser: initParser, metadata: initMetadata, - handler: () => {}, + run: runInit, }), - defineCommand({ + defineCliCommand({ path: ["generate-vocab"], parser: generateVocabOptions, metadata: generateVocabMetadata, - handler: () => {}, + run: runGenerateVocab, }), -] satisfies readonly CliStaticCommand[]; +] satisfies readonly AnyCliStaticCommand[]; export const activityPubCommands = [ - defineCommand({ + defineCliCommand({ path: ["webfinger"], parser: webFingerOptions, metadata: webFingerMetadata, - handler: () => {}, + run: runWebFinger, }), - defineCommand({ + defineCliCommand({ path: ["lookup"], parser: lookupOptions, metadata: lookupMetadata, - handler: () => {}, + run: runLookup, }), - defineCommand({ + defineCliCommand({ path: ["inbox"], parser: inboxOptions, metadata: inboxMetadata, - handler: () => {}, + run: runInbox, }), - defineCommand({ + defineCliCommand({ path: ["nodeinfo"], parser: nodeInfoOptions, metadata: nodeInfoMetadata, - handler: () => {}, + run: runNodeInfo, }), - defineCommand({ + defineCliCommand({ path: ["relay"], parser: relayOptions, metadata: relayMetadata, - handler: () => {}, + run: runRelay, }), - defineCommand({ + defineCliCommand({ path: ["bench"], parser: benchOptions, metadata: benchMetadata, - handler: () => {}, + run: runBench, }), -] satisfies readonly CliStaticCommand[]; +] satisfies readonly AnyCliStaticCommand[]; export const networkCommands = [ - defineCommand({ + defineCliCommand({ path: ["tunnel"], parser: tunnelOptions, metadata: tunnelMetadata, - handler: () => {}, + run: runTunnel, }), -] satisfies readonly CliStaticCommand[]; +] 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/lookup.ts b/packages/cli/src/lookup.ts index 06c0f26ee..a524fd1a4 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -29,7 +29,6 @@ import { constant, flag, float, - type InferValue, integer, map, merge, @@ -56,6 +55,7 @@ import { configureLogging } from "./log.ts"; import { createTunnelServiceOption, type GlobalOptions, + type TunnelService, userAgentOption, } from "./options.ts"; import { spawnTemporaryServer, type TemporaryServer } from "./tempserver.ts"; @@ -340,6 +340,28 @@ export class RecursiveLookupError extends Error { } } +type LookupCommand = GlobalOptions & { + readonly command: "lookup"; + readonly traverse: boolean; + readonly recurse: RecurseProperty | undefined; + readonly recurseDepth: number | undefined; + readonly suppressErrors: boolean; + readonly authorizedFetch: boolean | undefined; + readonly firstKnock: + | "draft-cavage-http-signatures-12" + | "rfc9421" + | undefined; + readonly tunnelService: TunnelService | undefined; + readonly userAgent: string; + readonly allowPrivateAddress: boolean; + readonly timeout: number | undefined; + readonly urls: readonly string[]; + readonly reverse: boolean; + readonly format: string | undefined; + readonly separator: string; + readonly output: string | undefined; +}; + function writeToStream( stream: NodeJS.WritableStream, chunk: string | Uint8Array, @@ -785,7 +807,7 @@ export async function collectRecursiveObjects( } export async function runLookup( - command: InferValue & GlobalOptions, + command: LookupCommand, deps: Partial<{ lookupObject: typeof lookupObject; traverseCollection: typeof traverseCollection; @@ -901,6 +923,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..."; @@ -949,7 +973,7 @@ export async function runLookup( userAgent: command.userAgent, specDeterminer: { determineSpec() { - return command.firstKnock; + return firstKnock; }, rememberSpec() { }, @@ -967,7 +991,7 @@ export async function runLookup( userAgent: command.userAgent, specDeterminer: { determineSpec() { - return command.firstKnock; + return firstKnock; }, rememberSpec() { }, diff --git a/packages/cli/src/mod.ts b/packages/cli/src/mod.ts index 0dac9d9db..f30214c5a 100644 --- a/packages/cli/src/mod.ts +++ b/packages/cli/src/mod.ts @@ -1,53 +1,10 @@ #!/usr/bin/env -S node --disable-warning=ExperimentalWarning -import { runInit } from "@fedify/init"; import process from "node:process"; -import { runBench } from "./bench/mod.ts"; -import { runGenerateVocab } from "./generate-vocab/mod.ts"; -import { runInbox } from "./inbox.tsx"; -import { runLookup } from "./lookup.ts"; -import { runNodeInfo } from "./nodeinfo.ts"; -import { runRelay } from "./relay.ts"; import { parseCliProgram } from "./runner.ts"; -import { runTunnel } from "./tunnel.ts"; -import { runWebFinger } from "./webfinger/mod.ts"; async function main() { - const { command, value } = await parseCliProgram(process.argv.slice(2)); - switch (command.path.join(" ")) { - case "init": - await runInit(value as unknown as Parameters[0]); - break; - case "generate-vocab": - await runGenerateVocab( - value as unknown as Parameters[0], - ); - break; - case "webfinger": - await runWebFinger( - value as unknown as Parameters[0], - ); - break; - case "lookup": - await runLookup(value as unknown as Parameters[0]); - break; - case "inbox": - await runInbox(value as unknown as Parameters[0]); - break; - case "nodeinfo": - await runNodeInfo(value as unknown as Parameters[0]); - break; - case "relay": - await runRelay(value as unknown as Parameters[0]); - break; - case "bench": - await runBench(value as unknown as Parameters[0]); - break; - case "tunnel": - await runTunnel(value as unknown as Parameters[0]); - break; - default: - throw new TypeError(`Unknown command: ${command.path.join(" ")}`); - } + const program = await parseCliProgram(process.argv.slice(2)); + await program.run(); } await main(); diff --git a/packages/cli/src/runner.ts b/packages/cli/src/runner.ts index 6f1c6a811..a0d559953 100644 --- a/packages/cli/src/runner.ts +++ b/packages/cli/src/runner.ts @@ -16,6 +16,8 @@ import process from "node:process"; import { parse as parseToml } from "smol-toml"; import { activityPubCommands, + type CliCommand, + type CliCommandValue, type CliStaticCommand, generatingCommands, networkCommands, @@ -57,28 +59,44 @@ function getUserConfigPath(): string { } const selectedCommand = "__fedifyCliSelectedCommand"; +const selectedRun = "__fedifyCliRunCommand"; -type CommandInvocation = Record & { - [selectedCommand]: CliStaticCommand; -}; +type CommandRunner = ( + globalOptions: GlobalOptions, +) => unknown | Promise; + +type CommandInvocation = + TCommand extends CliCommand ? CliCommandValue & { + [selectedCommand]: TCommand; + [selectedRun]: CommandRunner; + } + : never; -type RunnableCommandValue = CommandInvocation & GlobalOptions; +type RunnableCommandValue = + & CommandInvocation + & GlobalOptions; + +type PublicCommandValue = Record & GlobalOptions; export type CliProgram = { - command: CliStaticCommand; - value: Record & GlobalOptions; + command: CliCommand; + value: PublicCommandValue; + run: () => unknown | Promise; }; -function staticCommandParser( - staticCommand: CliStaticCommand, -): Parser<"sync", CommandInvocation, unknown> { +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", - Record, + TValue, unknown >; for (let i = staticCommand.path.length - 1; i >= 0; i--) { @@ -90,18 +108,21 @@ function staticCommandParser( name, parser, i === staticCommand.path.length - 1 ? staticCommand.metadata : undefined, - ) as Parser<"sync", Record, unknown>; + ) as Parser<"sync", TValue, unknown>; } + const runCommand = staticCommand.run; return map(parser, (value) => ({ ...value, [selectedCommand]: staticCommand, - })) as Parser<"sync", CommandInvocation, unknown>; + [selectedRun]: (globalOptions: GlobalOptions) => + runCommand({ ...value, ...globalOptions }), + })) as Parser<"sync", CommandInvocation, unknown>; } -function staticCommandsParser( - commands: readonly CliStaticCommand[], -): 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."); @@ -129,7 +150,13 @@ const runnableCommand = merge( export const command = map( runnableCommand, - ({ [selectedCommand]: _selectedCommand, ...value }) => value, + ( + { + [selectedCommand]: _selectedCommand, + [selectedRun]: _selectedRun, + ...value + }, + ) => value as PublicCommandValue, ); type ConfigOptions = { @@ -213,10 +240,25 @@ export function runCli(args: string[]) { return run(command, getRunOptions(args)); } +function toCliProgram( + parsed: RunnableCommandValue, +): CliProgram { + const { + [selectedCommand]: command, + [selectedRun]: run, + ...value + } = parsed; + return { + command, + value: value as PublicCommandValue, + run: () => run(parsed), + }; +} + export async function parseCliProgram(args: string[]): Promise { - const { [selectedCommand]: command, ...value } = await run( + const parsed = await run( runnableCommand, getRunOptions(args), ); - return { command, value }; + return toCliProgram(parsed); } From 13d7fe7d79f0464a1c7e0bf9eb6076fbbfd142b9 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 17 Jun 2026 08:49:47 +0900 Subject: [PATCH 3/8] Delay CLI handler module loading Static command descriptors now import parser-only modules where available and load heavier command handlers only when the selected command runs. This keeps parse-only runner imports compatible with Node 20, where node:sqlite is not available. https://github.com/fedify-dev/fedify/pull/808#discussion_r3422845846 Assisted-by: Codex:gpt-5.5 --- packages/cli/src/commands.ts | 39 ++-- packages/cli/src/lookup.ts | 302 ++--------------------------- packages/cli/src/lookup/command.ts | 282 +++++++++++++++++++++++++++ 3 files changed, 326 insertions(+), 297 deletions(-) create mode 100644 packages/cli/src/lookup/command.ts diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index ee02eb182..4e5ccbc70 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -13,22 +13,17 @@ import { defineCommand, type StaticCommand, } from "@optique/discover"; -import runBench from "./bench/action.ts"; import { benchMetadata, benchOptions } from "./bench/command.ts"; -import runGenerateVocab from "./generate-vocab/action.ts"; import { generateVocabMetadata, generateVocabOptions, } from "./generate-vocab/command.ts"; -import { runInbox } from "./inbox.tsx"; import { inboxMetadata, inboxOptions } from "./inbox/command.ts"; -import { lookupMetadata, lookupOptions, runLookup } from "./lookup.ts"; +import { lookupMetadata, lookupOptions } from "./lookup/command.ts"; import { nodeInfoMetadata, nodeInfoOptions, runNodeInfo } from "./nodeinfo.ts"; import type { GlobalOptions } from "./options.ts"; -import { runRelay } from "./relay.ts"; import { relayMetadata, relayOptions } from "./relay/command.ts"; import { runTunnel, tunnelMetadata, tunnelOptions } from "./tunnel.ts"; -import runWebFinger from "./webfinger/action.ts"; import { webFingerMetadata, webFingerOptions } from "./webfinger/command.ts"; type CliCommandHandler = ( @@ -100,7 +95,12 @@ export const generatingCommands = [ path: ["generate-vocab"], parser: generateVocabOptions, metadata: generateVocabMetadata, - run: runGenerateVocab, + run: async (value) => { + const { default: runGenerateVocab } = await import( + "./generate-vocab/action.ts" + ); + return await runGenerateVocab(value); + }, }), ] satisfies readonly AnyCliStaticCommand[]; @@ -109,19 +109,28 @@ export const activityPubCommands = [ path: ["webfinger"], parser: webFingerOptions, metadata: webFingerMetadata, - run: runWebFinger, + run: async (value) => { + const { default: runWebFinger } = await import("./webfinger/action.ts"); + return await runWebFinger(value); + }, }), defineCliCommand({ path: ["lookup"], parser: lookupOptions, metadata: lookupMetadata, - run: runLookup, + run: async (value) => { + const { runLookup } = await import("./lookup.ts"); + return await runLookup(value); + }, }), defineCliCommand({ path: ["inbox"], parser: inboxOptions, metadata: inboxMetadata, - run: runInbox, + run: async (value) => { + const { runInbox } = await import("./inbox.tsx"); + return await runInbox(value); + }, }), defineCliCommand({ path: ["nodeinfo"], @@ -133,13 +142,19 @@ export const activityPubCommands = [ path: ["relay"], parser: relayOptions, metadata: relayMetadata, - run: runRelay, + run: async (value) => { + const { runRelay } = await import("./relay.ts"); + return await runRelay(value); + }, }), defineCliCommand({ path: ["bench"], parser: benchOptions, metadata: benchMetadata, - run: runBench, + run: async (value) => { + const { default: runBench } = await import("./bench/action.ts"); + return await runBench(value); + }, }), ] satisfies readonly AnyCliStaticCommand[]; diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index a524fd1a4..527a129a8 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -21,304 +21,36 @@ 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, - integer, - map, - merge, - message, - multiple, - object, - option, - optional, - optionNames, - or, - string, - withDefault, -} from "@optique/core"; +import { 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, - type TunnelService, - userAgentOption, -} from "./options.ts"; -import { spawnTemporaryServer, type TemporaryServer } from "./tempserver.ts"; -import { colorEnabled, colors, describeError, formatObject } from "./utils.ts"; - -const logger = getLogger(["fedify", "cli", "lookup"]); - -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", + FEDIBIRD_QUOTE_IRI, IN_REPLY_TO_IRI, + MISSKEY_QUOTE_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 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.`, -}; + type RecurseProperty, +} from "./lookup/command.ts"; +import { configureLogging } from "./log.ts"; +import type { GlobalOptions, TunnelService } from "./options.ts"; +import { spawnTemporaryServer, type TemporaryServer } from "./tempserver.ts"; +import { colorEnabled, colors, describeError, formatObject } from "./utils.ts"; -export const lookupCommand = command( - "lookup", - lookupOptions, +export { + authorizedFetchOption, + lookupCommand, lookupMetadata, -); + lookupOptions, +} from "./lookup/command.ts"; + +const logger = getLogger(["fedify", "cli", "lookup"]); export class TimeoutError extends Error { override name = "TimeoutError"; 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, +); From 0a0ad3be005108131547be275121b505086f7d05 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 17 Jun 2026 12:31:16 +0900 Subject: [PATCH 4/8] Derive lookup command inputs from parser The lookup runner used a hand-written command value type that duplicated the parser output shape. This made the runner easy to drift from lookupOptions as new flags moved into the command parser. Infer the command value from lookupOptions so runLookup stays aligned with the actual parser contract. Assisted-by: Codex:gpt-5.5 --- packages/cli/src/lookup.ts | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index 527a129a8..c6f94a100 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -21,7 +21,7 @@ import { } from "@fedify/vocab-runtime"; import type { ResourceDescriptor } from "@fedify/webfinger"; import { getLogger } from "@logtape/logtape"; -import { message, optionNames } from "@optique/core"; +import { type InferValue, message, optionNames } from "@optique/core"; import { url as messageUrl } from "@optique/core/message"; import { printError } from "@optique/run"; import { createWriteStream, type WriteStream } from "node:fs"; @@ -33,13 +33,14 @@ import { renderImages } from "./imagerenderer.ts"; import { 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, TunnelService } from "./options.ts"; +import type { GlobalOptions } from "./options.ts"; import { spawnTemporaryServer, type TemporaryServer } from "./tempserver.ts"; import { colorEnabled, colors, describeError, formatObject } from "./utils.ts"; @@ -72,27 +73,7 @@ export class RecursiveLookupError extends Error { } } -type LookupCommand = GlobalOptions & { - readonly command: "lookup"; - readonly traverse: boolean; - readonly recurse: RecurseProperty | undefined; - readonly recurseDepth: number | undefined; - readonly suppressErrors: boolean; - readonly authorizedFetch: boolean | undefined; - readonly firstKnock: - | "draft-cavage-http-signatures-12" - | "rfc9421" - | undefined; - readonly tunnelService: TunnelService | undefined; - readonly userAgent: string; - readonly allowPrivateAddress: boolean; - readonly timeout: number | undefined; - readonly urls: readonly string[]; - readonly reverse: boolean; - readonly format: string | undefined; - readonly separator: string; - readonly output: string | undefined; -}; +type LookupCommand = InferValue & GlobalOptions; function writeToStream( stream: NodeJS.WritableStream, From 9600afbc4bb6c5afd4f551db7d3827e14b45c272 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 17 Jun 2026 12:31:22 +0900 Subject: [PATCH 5/8] Avoid shadowing Optique run in CLI runner The parsed command runner was destructured into a local variable named run inside the module that imports Optique's run helper. Rename the local binding to make the dispatch path unambiguous. https://github.com/fedify-dev/fedify/pull/808#discussion_r3424740997 Assisted-by: Codex:gpt-5.5 --- packages/cli/src/runner.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/runner.ts b/packages/cli/src/runner.ts index a0d559953..6e9c47ccc 100644 --- a/packages/cli/src/runner.ts +++ b/packages/cli/src/runner.ts @@ -245,13 +245,13 @@ function toCliProgram( ): CliProgram { const { [selectedCommand]: command, - [selectedRun]: run, + [selectedRun]: runCommand, ...value } = parsed; return { command, value: value as PublicCommandValue, - run: () => run(parsed), + run: () => runCommand(parsed), }; } From 97c0fe57b894d5022afa7be74a8def4739efc25d Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 17 Jun 2026 16:38:58 +0900 Subject: [PATCH 6/8] Route benchmark commands through the mode dispatcher The rebased branch now sits on top of benchmark comparison support. The static command registry must load the dispatcher that handles both run and compare modes instead of loading the run-only action directly. Assisted-by: Codex:gpt-5.5 --- packages/cli/src/commands.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts index 4e5ccbc70..f2a8eb8f1 100644 --- a/packages/cli/src/commands.ts +++ b/packages/cli/src/commands.ts @@ -152,7 +152,7 @@ export const activityPubCommands = [ parser: benchOptions, metadata: benchMetadata, run: async (value) => { - const { default: runBench } = await import("./bench/action.ts"); + const { runBench } = await import("./bench/mod.ts"); return await runBench(value); }, }), From 66389047f4247af80fa74e4d240d1d7685677358 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 17 Jun 2026 17:28:52 +0900 Subject: [PATCH 7/8] Strip command markers before dispatch The parsed CLI value carries private marker fields used to identify and run the selected static command. Reuse the public value when dispatching through parseCliProgram so command handlers do not receive those internal fields. https://github.com/fedify-dev/fedify/pull/808#discussion_r3426442143 Assisted-by: Codex:gpt-5.5 --- packages/cli/src/runner.test.ts | 24 +++++++++++++++++++++++- packages/cli/src/runner.ts | 7 ++++--- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/runner.test.ts b/packages/cli/src/runner.test.ts index 3d9dbca4a..dd88d80a8 100644 --- a/packages/cli/src/runner.test.ts +++ b/packages/cli/src/runner.test.ts @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { parseCliProgram, runCli } from "./runner.ts"; +import { parseCliProgram, runCli, toCliProgram } from "./runner.ts"; test("parseCliProgram keeps the selected static command", async () => { const program = await parseCliProgram([ @@ -21,3 +21,25 @@ test("runCli does not expose the selected static command marker", async () => { 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 never); + 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 6e9c47ccc..844bd2526 100644 --- a/packages/cli/src/runner.ts +++ b/packages/cli/src/runner.ts @@ -240,7 +240,7 @@ export function runCli(args: string[]) { return run(command, getRunOptions(args)); } -function toCliProgram( +export function toCliProgram( parsed: RunnableCommandValue, ): CliProgram { const { @@ -248,10 +248,11 @@ function toCliProgram( [selectedRun]: runCommand, ...value } = parsed; + const publicValue = value as PublicCommandValue; return { command, - value: value as PublicCommandValue, - run: () => runCommand(parsed), + value: publicValue, + run: () => runCommand(publicValue), }; } From f2a541d17027b6e2e53226078848b597c86d31cc Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 17 Jun 2026 18:12:14 +0900 Subject: [PATCH 8/8] Tighten runner test cast The runner regression test still needs to construct a synthetic parsed value, but it can assert the parameter type expected by toCliProgram instead of bypassing type checking with never. https://github.com/fedify-dev/fedify/pull/808#discussion_r3426704014 Assisted-by: Codex:gpt-5.5 --- packages/cli/src/runner.test.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/runner.test.ts b/packages/cli/src/runner.test.ts index dd88d80a8..308476783 100644 --- a/packages/cli/src/runner.test.ts +++ b/packages/cli/src/runner.test.ts @@ -24,16 +24,18 @@ test("runCli does not expose the selected static command marker", async () => { 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 never); + 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);