diff --git a/deno.json b/deno.json index 869bc2e36..dfd25ff6b 100644 --- a/deno.json +++ b/deno.json @@ -48,9 +48,9 @@ "@opentelemetry/core": "npm:@opentelemetry/core@^2.5.0", "@opentelemetry/sdk-trace-base": "npm:@opentelemetry/sdk-trace-base@^2.5.0", "@opentelemetry/semantic-conventions": "npm:@opentelemetry/semantic-conventions@^1.39.0", - "@optique/config": "jsr:@optique/config@^0.10.7", - "@optique/core": "jsr:@optique/core@^0.10.7", - "@optique/run": "jsr:@optique/run@^0.10.7", + "@optique/config": "jsr:@optique/config@^1.0.0", + "@optique/core": "jsr:@optique/core@^1.0.0", + "@optique/run": "jsr:@optique/run@^1.0.0", "@std/assert": "jsr:@std/assert@^1.0.13", "@std/async": "jsr:@std/async@^1.0.13", "@std/encoding": "jsr:@std/encoding@^1.0.10", diff --git a/deno.lock b/deno.lock index 4949e9240..0ffe7824c 100644 --- a/deno.lock +++ b/deno.lock @@ -11,18 +11,18 @@ "jsr:@deno/loader@~0.3.2": "0.3.11", "jsr:@fresh/build-id@1": "1.0.1", "jsr:@fresh/core@2": "2.2.0", - "jsr:@fresh/core@^2.1.4": "2.2.0", + "jsr:@fresh/core@^2.1.4": "2.2.2", "jsr:@fresh/core@^2.2.0": "2.2.0", "jsr:@fresh/plugin-vite@^1.0.7": "1.0.8", "jsr:@hongminhee/localtunnel@0.3": "0.3.0", "jsr:@hono/hono@^4.7.1": "4.12.4", - "jsr:@hono/hono@^4.8.3": "4.12.4", + "jsr:@hono/hono@^4.8.3": "4.12.12", "jsr:@logtape/file@^2.0.5": "2.0.5", "jsr:@logtape/logtape@^1.0.4": "1.3.7", "jsr:@logtape/logtape@^2.0.5": "2.0.5", - "jsr:@optique/config@~0.10.7": "0.10.7", - "jsr:@optique/core@~0.10.7": "0.10.7", - "jsr:@optique/run@~0.10.7": "0.10.7", + "jsr:@optique/config@1": "1.0.0", + "jsr:@optique/core@1": "1.0.0", + "jsr:@optique/run@1": "1.0.0", "jsr:@std/assert@0.224": "0.224.0", "jsr:@std/assert@0.226": "0.226.0", "jsr:@std/assert@^1.0.13": "1.0.19", @@ -58,7 +58,7 @@ "jsr:@std/testing@0.224": "0.224.0", "jsr:@std/uuid@^1.0.9": "1.1.0", "jsr:@std/yaml@^1.0.8": "1.0.12", - "jsr:@valibot/valibot@^1.2.0": "1.2.0", + "jsr:@valibot/valibot@^1.2.0": "1.3.1", "npm:@alinea/suite@~0.6.3": "0.6.3", "npm:@astrojs/node@^10.0.3": "10.0.4_astro@5.18.1__@types+node@24.12.0__ioredis@5.10.1__tsx@4.21.0__typescript@6.0.2__yaml@2.8.3_@types+node@24.12.0_ioredis@5.10.1_tsx@4.21.0_typescript@6.0.2_yaml@2.8.3", "npm:@babel/core@^7.28.0": "7.29.0", @@ -86,6 +86,7 @@ "npm:@poppanator/http-constants@^1.1.1": "1.1.1", "npm:@preact/signals@^2.2.1": "2.9.0_preact@10.29.0", "npm:@preact/signals@^2.3.2": "2.9.0_preact@10.29.0", + "npm:@preact/signals@^2.5.1": "2.9.0_preact@10.29.0", "npm:@prefresh/vite@^2.4.8": "2.4.12_preact@10.29.0_vite@7.3.1__@types+node@24.12.0__tsx@4.21.0__yaml@2.8.3", "npm:@solidjs/start@^1.3.0": "1.3.2_vinxi@0.5.11__@emnapi+core@1.9.2__@emnapi+runtime@1.9.2__@types+node@24.12.0__ioredis@5.10.1__mysql2@3.20.0___@types+node@24.12.0__rolldown@1.0.0-rc.12___@emnapi+core@1.9.2___@emnapi+runtime@1.9.2__tsx@4.21.0__yaml@2.8.3_@emnapi+core@1.9.2_@emnapi+runtime@1.9.2_@types+node@24.12.0_ioredis@5.10.1_mysql2@3.20.0__@types+node@24.12.0_rolldown@1.0.0-rc.12__@emnapi+core@1.9.2__@emnapi+runtime@1.9.2_solid-js@1.9.12_tsx@4.21.0_vite@7.3.1__@types+node@24.12.0__tsx@4.21.0__yaml@2.8.3_yaml@2.8.3", "npm:@standard-schema/spec@^1.1.0": "1.1.0", @@ -144,6 +145,8 @@ "npm:preact@10.19.6": "10.19.6", "npm:preact@^10.27.0": "10.29.0", "npm:preact@^10.27.2": "10.29.0", + "npm:preact@^10.28.2": "10.29.0", + "npm:preact@^10.28.3": "10.29.0", "npm:rollup@^4.50.0": "4.60.1", "npm:shiki@^1.6.4": "1.29.2", "npm:smol-toml@^1.6.0": "1.6.1", @@ -231,6 +234,30 @@ "npm:preact@^10.27.2" ] }, + "@fresh/core@2.2.2": { + "integrity": "c34873df58457720f7b915a65fef6fe5cd7215f237cf4d1064d1925b320de96c", + "dependencies": [ + "jsr:@deno/esbuild-plugin", + "jsr:@fresh/build-id", + "jsr:@std/encoding", + "jsr:@std/fmt@^1.0.8", + "jsr:@std/fs@^1.0.19", + "jsr:@std/html", + "jsr:@std/http", + "jsr:@std/jsonc", + "jsr:@std/media-types", + "jsr:@std/path@^1.1.2", + "jsr:@std/semver", + "jsr:@std/uuid", + "npm:@opentelemetry/api", + "npm:@preact/signals@^2.5.1", + "npm:esbuild-wasm", + "npm:esbuild@0.25.7", + "npm:preact-render-to-string", + "npm:preact@^10.28.2", + "npm:preact@^10.28.3" + ] + }, "@fresh/plugin-vite@1.0.8": { "integrity": "5780d842ed82e4cbccd93dd8ba2d54bf59dff5aee65921134aab15a4cd457c56", "dependencies": [ @@ -257,6 +284,9 @@ "@hono/hono@4.12.4": { "integrity": "3b80b0165bbcaf8ddf23afc593e40416da8e9a27cb3dd460a1f04c7a44583030" }, + "@hono/hono@4.12.12": { + "integrity": "dc765178d38b5c4619b358062f6aa5514f7205bb0530b2823ff6265bec69c535" + }, "@logtape/file@2.0.5": { "integrity": "368621b15b73fd63c137e47ae43411e350627683ce7e0b4492af3ccb159e098b", "dependencies": [ @@ -270,18 +300,18 @@ "@logtape/logtape@2.0.5": { "integrity": "604d2121ed4ffb7aad35b47e729e2298d0cd7a7ddf1cb4171d27a61fa7d840a5" }, - "@optique/config@0.10.7": { - "integrity": "2b5faae72106c2158e07e2b9b3c34ef595708a004798914059657b91851a3680", + "@optique/config@1.0.0": { + "integrity": "8230c7f216b14ffd807df596bceee4d5ac756cf4dd0e493e175b2016f1c08043", "dependencies": [ "jsr:@optique/core", "npm:@standard-schema/spec" ] }, - "@optique/core@0.10.7": { - "integrity": "7669bbf1840c356526634d205c8803fa286147d2b112c67aa1057a9e60f1da3d" + "@optique/core@1.0.0": { + "integrity": "841e61ea4606cf1fbacac2e0fe16961decf8c0e1bcd86f9fcfb5e43c396a3b36" }, - "@optique/run@0.10.7": { - "integrity": "1529119beac780f219608dd59367c523f9e6c9ab711b15b6c6cb76e91b36851c", + "@optique/run@1.0.0": { + "integrity": "b5ba928cce667530f44265bebb71064a7974435c73f7608bdee90c310c415db3", "dependencies": [ "jsr:@optique/core" ] @@ -404,6 +434,9 @@ }, "@valibot/valibot@1.2.0": { "integrity": "61c118a4d027ed55912caf381c78f0a178f335f46ad0c4bcb136498dc1ef2285" + }, + "@valibot/valibot@1.3.1": { + "integrity": "635faaec9d32a25efca7b4614e7a07306cc03937bfd0679e05530d31081bc501" } }, "npm": { @@ -9182,9 +9215,9 @@ "jsr:@hono/hono@^4.8.3", "jsr:@logtape/file@^2.0.5", "jsr:@logtape/logtape@^2.0.5", - "jsr:@optique/config@~0.10.7", - "jsr:@optique/core@~0.10.7", - "jsr:@optique/run@~0.10.7", + "jsr:@optique/config@1", + "jsr:@optique/core@1", + "jsr:@optique/run@1", "jsr:@std/assert@^1.0.13", "jsr:@std/async@^1.0.13", "jsr:@std/encoding@^1.0.10", diff --git a/packages/cli/src/inbox.tsx b/packages/cli/src/inbox.tsx index 90d927184..d116a9615 100644 --- a/packages/cli/src/inbox.tsx +++ b/packages/cli/src/inbox.tsx @@ -23,31 +23,18 @@ import { type Recipient, } from "@fedify/vocab"; import { getLogger } from "@logtape/logtape"; -import { bindConfig } from "@optique/config"; -import { - command, - constant, - group, - type InferValue, - merge, - message, - multiple, - object, - option, - string, -} from "@optique/core"; +import type { InferValue } from "@optique/core"; import Table from "cli-table3"; import { type Context as HonoContext, Hono } from "hono"; import type { BlankEnv, BlankInput } from "hono/types"; import process from "node:process"; import ora from "ora"; import metadata from "../deno.json" with { type: "json" }; -import { configContext } from "./config.ts"; import { getDocumentLoader } from "./docloader.ts"; import type { ActivityEntry } from "./inbox/entry.ts"; import { ActivityEntryPage, ActivityListPage } from "./inbox/view.tsx"; import { configureLogging, recordingSink } from "./log.ts"; -import { createTunnelOption, type GlobalOptions } from "./options.ts"; +import type { GlobalOptions } from "./options.ts"; import { tableStyle } from "./table.ts"; import { spawnTemporaryServer, type TemporaryServer } from "./tempserver.ts"; import { colors, matchesActor } from "./utils.ts"; @@ -66,92 +53,18 @@ interface ContextData { const logger = getLogger(["fedify", "cli", "inbox"]); -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.`, - }), - { - context: configContext, - key: (config) => config.inbox?.actorName ?? "Fedify Ephemeral Inbox", - default: "Fedify Ephemeral Inbox", - }, - ), - actorSummary: bindConfig( - option("--actor-summary", string({ metavar: "SUMMARY" }), { - description: message`Customize the actor description.`, - }), - { - context: configContext, - key: (config) => - config.inbox?.actorSummary ?? - "An ephemeral ActivityPub inbox for testing purposes.", - default: "An ephemeral ActivityPub inbox for testing purposes.", - }, - ), - 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?.authorizedFetch ?? false, - default: false, - }, - ), - }), - 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 an one-time actor, through a short-lived public DNS with HTTPS. You can monitor the incoming activities in real-time.`, - }, -); - // Module-level state const activities: ActivityEntry[] = []; const acceptFollows: string[] = []; const peers: Record = {}; const followers: Record = {}; +type InboxCommand = + & InferValue + & GlobalOptions; + export async function runInbox( - command: InferValue & GlobalOptions, + command: InboxCommand, ) { // Reset module-level state for a clean run activities.length = 0; diff --git a/packages/cli/src/inbox/command.ts b/packages/cli/src/inbox/command.ts new file mode 100644 index 000000000..59ee8d428 --- /dev/null +++ b/packages/cli/src/inbox/command.ts @@ -0,0 +1,97 @@ +import { bindConfig } from "@optique/config"; +import { + command, + constant, + group, + merge, + message, + multiple, + object, + option, + string, +} from "@optique/core"; +import { configContext } from "../config.ts"; +import { createTunnelOption } from "../options.ts"; + +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.`, + }), + { + 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", + { + description: + message`Enable authorized fetch mode. Incoming requests without valid HTTP signatures will be rejected with 401 Unauthorized.`, + }, + ), + { + context: configContext, + key: (config) => config.inbox?.authorizedFetch ?? false, + default: false, + }, + ), + }), + 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.`, + }, +); diff --git a/packages/cli/src/lookup.test.ts b/packages/cli/src/lookup.test.ts index 859d32cd6..74e7abeb1 100644 --- a/packages/cli/src/lookup.test.ts +++ b/packages/cli/src/lookup.test.ts @@ -1,17 +1,19 @@ import { Activity, Collection, Note } from "@fedify/vocab"; -import { clearActiveConfig, setActiveConfig } from "@optique/config"; -import { runWithConfig } from "@optique/config/run"; -import { parse } from "@optique/core/parser"; +import type { Annotations } from "@optique/core/annotations"; +import { parse, type Parser, type Result } from "@optique/core/parser"; import { UrlError } from "@fedify/vocab-runtime"; import assert from "node:assert/strict"; import { Buffer } from "node:buffer"; import { createWriteStream } from "node:fs"; -import { mkdir, readFile, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import process from "node:process"; import { Writable } from "node:stream"; import test from "node:test"; import { configContext } from "./config.ts"; import { getContextLoader } from "./docloader.ts"; +import { runCli } from "./runner.ts"; import { authorizedFetchOption, clearTimeoutSignal, @@ -31,6 +33,24 @@ import { writeSeparator, } from "./lookup.ts"; +async function parseWithConfig( + parser: Parser<"sync", TValue, TState>, + args: readonly string[], + config: Record = {}, +): Promise> { + // Optique 1.0 removed the old active-config test helpers. For parser-only + // assertions, we still need the phase-2 annotations that bindConfig() + // consumes, so these tests emulate that request directly. + const annotations = await (configContext.getAnnotations as ( + request?: unknown, + options?: unknown, + ) => PromiseLike | Annotations)( + { phase: "phase2", parsed: {} }, + { load: () => ({ config, meta: undefined }) }, + ); + return parse(parser, args, { annotations }); +} + test("writeObjectToStream - writes Note object with default options", async () => { const testDir = "./test_output_note"; const testFile = `${testDir}/note.txt`; @@ -309,9 +329,7 @@ test("clearTimeoutSignal - cleans up timer properly", async () => { }); test("authorizedFetchOption - parses successfully without -a flag", () => { - setActiveConfig(configContext.id, {}); const result = parse(authorizedFetchOption, []); - clearActiveConfig(configContext.id); assert.ok(result.success); if (result.success) { assert.strictEqual(result.value.authorizedFetch, false); @@ -321,65 +339,124 @@ test("authorizedFetchOption - parses successfully without -a flag", () => { }); test("authorizedFetchOption - parses with -a without tunnelService config", async () => { - const result = await runWithConfig(authorizedFetchOption, configContext, { - load: () => ({}), - args: ["-a"], - }); - assert.strictEqual(result.authorizedFetch, true); - assert.strictEqual(result.firstKnock, "draft-cavage-http-signatures-12"); - assert.strictEqual(result.tunnelService, undefined); + const result = await parseWithConfig(authorizedFetchOption, ["-a"]); + assert.ok(result.success); + if (result.success) { + assert.strictEqual(result.value.authorizedFetch, true); + assert.strictEqual( + result.value.firstKnock, + "draft-cavage-http-signatures-12", + ); + assert.strictEqual(result.value.tunnelService, undefined); + } }); test("authorizedFetchOption - uses config to enable authorized fetch", async () => { - const result = await runWithConfig(authorizedFetchOption, configContext, { - load: () => ({ lookup: { authorizedFetch: true } }), - args: [], - }); - assert.strictEqual(result.authorizedFetch, true); - assert.strictEqual(result.firstKnock, "draft-cavage-http-signatures-12"); - assert.strictEqual(result.tunnelService, undefined); + const result = await parseWithConfig( + authorizedFetchOption, + [], + { lookup: { authorizedFetch: true } }, + ); + assert.ok(result.success); + if (result.success) { + assert.strictEqual(result.value.authorizedFetch, true); + assert.strictEqual( + result.value.firstKnock, + "draft-cavage-http-signatures-12", + ); + assert.strictEqual(result.value.tunnelService, undefined); + } }); test("authorizedFetchOption - reads firstKnock from config", async () => { - const result = await runWithConfig(authorizedFetchOption, configContext, { - load: () => ({ + const result = await parseWithConfig( + authorizedFetchOption, + [], + { lookup: { authorizedFetch: true, firstKnock: "rfc9421", }, tunnelService: "serveo.net", - }), - args: [], - }); - assert.strictEqual(result.authorizedFetch, true); - assert.strictEqual(result.firstKnock, "rfc9421"); - assert.strictEqual(result.tunnelService, undefined); + }, + ); + assert.ok(result.success); + if (result.success) { + assert.strictEqual(result.value.authorizedFetch, true); + assert.strictEqual(result.value.firstKnock, "rfc9421"); + assert.strictEqual(result.value.tunnelService, "serveo.net"); + } +}); + +test("lookup runner loads config from --config", async () => { + const tempDir = await mkdtemp(join(tmpdir(), "fedify-cli-lookup-")); + const configPath = join(tempDir, "fedify.toml"); + + try { + await writeFile(configPath, "[lookup]\nallowPrivateAddress = true\n"); + const result = await runCli([ + "--config", + configPath, + "lookup", + "https://example.com/notes/1", + ]); + + assert.strictEqual(result.command, "lookup"); + assert.strictEqual(result.allowPrivateAddress, true); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } +}); + +test("lookup runner honors --ignore-config", async () => { + const cwd = process.cwd(); + const tempDir = await mkdtemp(join(tmpdir(), "fedify-cli-ignore-config-")); + + try { + await writeFile( + join(tempDir, ".fedify.toml"), + "[lookup]\nallowPrivateAddress = true\n", + ); + process.chdir(tempDir); + + const withConfig = await runCli([ + "lookup", + "https://example.com/notes/1", + ]); + assert.strictEqual(withConfig.command, "lookup"); + assert.strictEqual(withConfig.allowPrivateAddress, true); + + const ignored = await runCli([ + "--ignore-config", + "lookup", + "https://example.com/notes/1", + ]); + assert.strictEqual(ignored.command, "lookup"); + assert.strictEqual(ignored.allowPrivateAddress, false); + } finally { + process.chdir(cwd); + await rm(tempDir, { recursive: true, force: true }); + } }); test("authorizedFetchOption - invalid when --first-knock is used without -a", () => { - setActiveConfig(configContext.id, {}); const result = parse(authorizedFetchOption, [ "--first-knock", "rfc9421", ]); - clearActiveConfig(configContext.id); assert.ok(!result.success); }); test("authorizedFetchOption - invalid when --tunnel-service is used without -a", () => { - setActiveConfig(configContext.id, {}); const result = parse(authorizedFetchOption, [ "--tunnel-service", "serveo.net", ]); - clearActiveConfig(configContext.id); assert.ok(!result.success); }); test("authorizedFetchOption - parses successfully with -a flag", () => { - setActiveConfig(configContext.id, {}); const result = parse(authorizedFetchOption, ["-a"]); - clearActiveConfig(configContext.id); assert.ok(result.success); if (result.success) { assert.strictEqual(result.value.authorizedFetch, true); @@ -392,13 +469,11 @@ test("authorizedFetchOption - parses successfully with -a flag", () => { }); test("authorizedFetchOption - parses with -a and --first-knock", () => { - setActiveConfig(configContext.id, {}); const result = parse(authorizedFetchOption, [ "-a", "--first-knock", "rfc9421", ]); - clearActiveConfig(configContext.id); assert.ok(result.success); if (result.success) { assert.strictEqual(result.value.authorizedFetch, true); @@ -408,13 +483,11 @@ test("authorizedFetchOption - parses with -a and --first-knock", () => { }); test("authorizedFetchOption - parses with -a and --tunnel-service", () => { - setActiveConfig(configContext.id, {}); const result = parse(authorizedFetchOption, [ "-a", "--tunnel-service", "serveo.net", ]); - clearActiveConfig(configContext.id); assert.ok(result.success); if (result.success) { assert.strictEqual(result.value.authorizedFetch, true); @@ -427,13 +500,11 @@ test("authorizedFetchOption - parses with -a and --tunnel-service", () => { }); test("lookupCommand - parses --allow-private-address", () => { - setActiveConfig(configContext.id, {}); const result = parse(lookupCommand, [ "lookup", "--allow-private-address", "https://example.com/notes/1", ]); - clearActiveConfig(configContext.id); assert.ok(result.success); if (result.success) { assert.strictEqual(result.value.allowPrivateAddress, true); @@ -441,21 +512,23 @@ test("lookupCommand - parses --allow-private-address", () => { }); test("lookupCommand - reads allowPrivateAddress from config", async () => { - const result = await runWithConfig(lookupCommand, configContext, { - load: () => ({ lookup: { allowPrivateAddress: true } }), - args: ["lookup", "https://example.com/notes/1"], - }); - assert.strictEqual(result.allowPrivateAddress, true); + const result = await parseWithConfig( + lookupCommand, + ["lookup", "https://example.com/notes/1"], + { lookup: { allowPrivateAddress: true } }, + ); + assert.ok(result.success); + if (result.success) { + assert.strictEqual(result.value.allowPrivateAddress, true); + } }); test("lookupCommand - parses --reverse", () => { - setActiveConfig(configContext.id, {}); const result = parse(lookupCommand, [ "lookup", "--reverse", "https://example.com/notes/1", ]); - clearActiveConfig(configContext.id); assert.ok(result.success); if (result.success) { assert.strictEqual(result.value.reverse, true); @@ -463,22 +536,24 @@ test("lookupCommand - parses --reverse", () => { }); test("lookupCommand - reads reverse from config", async () => { - const result = await runWithConfig(lookupCommand, configContext, { - load: () => ({ lookup: { reverse: true } }), - args: ["lookup", "https://example.com/notes/1"], - }); - assert.strictEqual(result.reverse, true); + const result = await parseWithConfig( + lookupCommand, + ["lookup", "https://example.com/notes/1"], + { lookup: { reverse: true } }, + ); + assert.ok(result.success); + if (result.success) { + assert.strictEqual(result.value.reverse, true); + } }); test("lookupCommand - parses recurse option", () => { - setActiveConfig(configContext.id, {}); const result = parse(lookupCommand, [ "lookup", "--recurse", "replyTarget", "https://example.com/notes/1", ]); - clearActiveConfig(configContext.id); assert.ok(result.success); if (result.success) { assert.strictEqual(result.value.recurse, "replyTarget"); @@ -488,19 +563,16 @@ test("lookupCommand - parses recurse option", () => { }); test("lookupCommand - rejects recurse-depth without recurse", () => { - setActiveConfig(configContext.id, {}); const result = parse(lookupCommand, [ "lookup", "--recurse-depth", "10", "https://example.com/notes/1", ]); - clearActiveConfig(configContext.id); assert.ok(!result.success); }); test("lookupCommand - rejects traverse with recurse", () => { - setActiveConfig(configContext.id, {}); const result = parse(lookupCommand, [ "lookup", "--traverse", @@ -508,31 +580,26 @@ test("lookupCommand - rejects traverse with recurse", () => { "replyTarget", "https://example.com/notes/1", ]); - clearActiveConfig(configContext.id); assert.ok(!result.success); }); test("lookupCommand - rejects short-form inReplyTo", () => { - setActiveConfig(configContext.id, {}); const result = parse(lookupCommand, [ "lookup", "--recurse", "inReplyTo", "https://example.com/notes/1", ]); - clearActiveConfig(configContext.id); assert.ok(!result.success); }); test("lookupCommand - accepts IRI inReplyTo", () => { - setActiveConfig(configContext.id, {}); const result = parse(lookupCommand, [ "lookup", "--recurse", "https://www.w3.org/ns/activitystreams#inReplyTo", "https://example.com/notes/1", ]); - clearActiveConfig(configContext.id); assert.ok(result.success); if (result.success) { assert.strictEqual( @@ -543,14 +610,12 @@ test("lookupCommand - accepts IRI inReplyTo", () => { }); test("lookupCommand - accepts short-form quoteUrl", () => { - setActiveConfig(configContext.id, {}); const result = parse(lookupCommand, [ "lookup", "--recurse", "quoteUrl", "https://example.com/notes/1", ]); - clearActiveConfig(configContext.id); assert.ok(result.success); if (result.success) { assert.strictEqual(result.value.recurse, "quoteUrl"); @@ -565,14 +630,12 @@ for ( ] ) { test(`lookupCommand - accepts IRI ${recurseProperty}`, () => { - setActiveConfig(configContext.id, {}); const result = parse(lookupCommand, [ "lookup", "--recurse", recurseProperty, "https://example.com/notes/1", ]); - clearActiveConfig(configContext.id); assert.ok(result.success); if (result.success) { assert.strictEqual(result.value.recurse, recurseProperty); diff --git a/packages/cli/src/lookup.ts b/packages/cli/src/lookup.ts index 6389bd322..a884bcf0d 100644 --- a/packages/cli/src/lookup.ts +++ b/packages/cli/src/lookup.ts @@ -124,7 +124,7 @@ export const authorizedFetchOption = withDefault( default: "draft-cavage-http-signatures-12" as const, }, ), - tunnelService: createTunnelServiceOption(), + tunnelService: optional(createTunnelServiceOption()), }), { authorizedFetch: false as const, diff --git a/packages/cli/src/mod.ts b/packages/cli/src/mod.ts index 06303bf43..e7dd885e0 100644 --- a/packages/cli/src/mod.ts +++ b/packages/cli/src/mod.ts @@ -1,145 +1,17 @@ #!/usr/bin/env -S node --disable-warning=ExperimentalWarning -import { runWithConfig } from "@optique/config/run"; -import { group, merge, message, or } from "@optique/core"; -import { printError } 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 { 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 { parse as parseToml } from "smol-toml"; -import { configContext, tryLoadToml } from "./config.ts"; -import { - generateVocabCommand, - runGenerateVocab, -} from "./generate-vocab/mod.ts"; -import { inboxCommand, runInbox } from "./inbox.tsx"; -import { initCommand, runInit } from "./init/mod.ts"; -import { lookupCommand, runLookup } from "./lookup.ts"; -import { nodeInfoCommand, runNodeInfo } from "./nodeinfo.ts"; -import { globalOptions } from "./options.ts"; -import { relayCommand, runRelay } from "./relay.ts"; -import { runTunnel, tunnelCommand } from "./tunnel.ts"; -import { runWebFinger, webFingerCommand } from "./webfinger/mod.ts"; -import metadata from "../deno.json" with { type: "json" }; - -/** - * Returns the system-wide configuration file paths. - * - Linux/macOS: Searches `$XDG_CONFIG_DIRS` (default: /etc/xdg) - * - Windows: Uses `%ProgramData%` (default: C:\ProgramData) - */ -function getSystemConfigPaths(): string[] { - if (process.platform === "win32") { - const programData = process.env.ProgramData || "C:\\ProgramData"; - return [join(programData, "fedify", "config.toml")]; - } - return (process.env.XDG_CONFIG_DIRS || "/etc/xdg") - .split(":") - .map((dir) => join(dir, "fedify", "config.toml")); -} - -/** - * Returns the user-level configuration file path. - * - Linux/macOS: `$XDG_CONFIG_HOME/fedify/config.toml` (default: ~/.config) - * - Windows: `%APPDATA%\fedify\config.toml` - */ -function getUserConfigPath(): string { - if (process.platform === "win32") { - const appData = process.env.APPDATA || - join(homedir(), "AppData", "Roaming"); - return join(appData, "fedify", "config.toml"); - } - const xdgConfigHome = process.env.XDG_CONFIG_HOME || - join(homedir(), ".config"); - return join(xdgConfigHome, "fedify", "config.toml"); -} - -const command = merge( - or( - group( - "Generating code", - or( - initCommand, - generateVocabCommand, - ), - ), - group( - "ActivityPub tools", - or( - webFingerCommand, - lookupCommand, - inboxCommand, - nodeInfoCommand, - relayCommand, - ), - ), - group( - "Network tools", - tunnelCommand, - ), - ), - globalOptions, -); +import { runRelay } from "./relay.ts"; +import { runCli } from "./runner.ts"; +import { runTunnel } from "./tunnel.ts"; +import { runWebFinger } from "./webfinger/mod.ts"; async function main() { - const result = await runWithConfig(command, configContext, { - programName: "fedify", - load: (parsed) => { - if (parsed.ignoreConfig) return {}; - - // Load system-wide configs (XDG_CONFIG_DIRS on Linux/macOS, ProgramData on Windows) - const systemConfigs = getSystemConfigPaths().map(tryLoadToml); - const system = systemConfigs.reduce( - (acc, config) => deepMerge(acc, config), - {}, - ); - const user = tryLoadToml(getUserConfigPath()); - const project = tryLoadToml(join(process.cwd(), ".fedify.toml")); - - // Custom config via --config exits with error if file is missing or invalid - let custom: Record = {}; - if (parsed.configPath) { - try { - custom = parseToml(readFileSync(parsed.configPath, "utf-8")); - } catch (error) { - printError( - message`Could not load config file at ${parsed.configPath}: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - process.exit(1); - } - } - - return [system, user, project, custom].reduce( - (acc, config) => deepMerge(acc, config), - {}, - ); - }, - args: process.argv.slice(2), - help: { - mode: "both", - onShow: () => process.exit(0), - group: "Meta commands", - }, - version: { - mode: "both", - value: metadata.version, - group: "Meta commands", - }, - completion: { - mode: "command", - name: "both", - helpVisibility: "plural", - group: "Meta commands", - }, - onError: () => process.exit(1), - colors: process.stdout.isTTY && - (process.env.NO_COLOR == null || process.env.NO_COLOR === ""), - maxWidth: process.stdout.columns, - showDefault: true, - showChoices: true, - }); + const result = await runCli(process.argv.slice(2)); if (result.command === "init") { await runInit(result); } else if (result.command === "lookup") { @@ -147,9 +19,9 @@ async function main() { } else if (result.command === "webfinger") { await runWebFinger(result); } else if (result.command === "inbox") { - runInbox(result); + await runInbox(result); } else if (result.command === "nodeinfo") { - runNodeInfo(result); + await runNodeInfo(result); } else if (result.command === "tunnel") { await runTunnel(result); } else if (result.command === "generate-vocab") { diff --git a/packages/cli/src/options.ts b/packages/cli/src/options.ts index 50bb35974..33c8011c3 100644 --- a/packages/cli/src/options.ts +++ b/packages/cli/src/options.ts @@ -35,14 +35,16 @@ export type TunnelService = typeof TUNNEL_SERVICES[number]; * Creates a tunnel service option with customizable option names. */ export function createTunnelServiceOption( - optionNames: OptionName[] = ["--tunnel-service"], + optionNames: readonly [OptionName, ...OptionName[]] = ["--tunnel-service"], ) { + const [firstOptionName, ...restOptionNames] = optionNames; // Note that we don't provide a default value here, since the tunneling // implementation will randomly select a service if none is specified. return withDefault( bindConfig( option( - ...optionNames, + firstOptionName, + ...restOptionNames, choice(TUNNEL_SERVICES, { metavar: "SERVICE" }), { description: message`The tunneling service to use. diff --git a/packages/cli/src/relay.ts b/packages/cli/src/relay.ts index 3085041f4..f21820853 100644 --- a/packages/cli/src/relay.ts +++ b/packages/cli/src/relay.ts @@ -2,140 +2,25 @@ import { MemoryKvStore } from "@fedify/fedify"; import { createRelay, type Relay, type RelayType } from "@fedify/relay"; import { SqliteKvStore } from "@fedify/sqlite"; import { getLogger } from "@logtape/logtape"; -import { bindConfig } from "@optique/config"; -import { - command, - constant, - group, - type InferValue, - integer, - merge, - message, - multiple, - object, - option, - optional, - optionName, - string, - value, -} from "@optique/core"; -import { choice } from "@optique/core/valueparser"; +import type { InferValue } from "@optique/core"; import Table from "cli-table3"; import process from "node:process"; import { DatabaseSync } from "node:sqlite"; import ora from "ora"; -import { configContext } from "./config.ts"; import { configureLogging } from "./log.ts"; -import { createTunnelOption, type GlobalOptions } from "./options.ts"; +import type { GlobalOptions } from "./options.ts"; import { tableStyle } from "./table.ts"; import { spawnTemporaryServer, type TemporaryServer } from "./tempserver.ts"; import { colors, matchesActor } from "./utils.ts"; const logger = getLogger(["fedify", "cli", "relay"]); -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.`, - }, - ), - { - 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.`, - }), - ), - { - context: configContext, - key: (config) => config.relay?.rejectFollow ?? [], - default: [], - }, - ), - }), - 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. - -By default, the server is tunneled to the public internet for external access. Use ${ - optionName("--no-tunnel") - } to run locally only.`, - }, -); +type RelayCommand = + & InferValue + & GlobalOptions; export async function runRelay( - command: InferValue & GlobalOptions, + command: RelayCommand, ): Promise { if (command.debug) { await configureLogging(); diff --git a/packages/cli/src/relay/command.ts b/packages/cli/src/relay/command.ts new file mode 100644 index 000000000..d745b50bc --- /dev/null +++ b/packages/cli/src/relay/command.ts @@ -0,0 +1,120 @@ +import { bindConfig } from "@optique/config"; +import { + command, + constant, + group, + integer, + merge, + message, + multiple, + object, + option, + optional, + optionName, + string, + value, +} from "@optique/core"; +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.`, + }, + ), + { + 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.`, + }), + ), + { + context: configContext, + key: (config) => config.relay?.rejectFollow ?? [], + default: [], + }, + ), + }), + 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. + +By default, the server is tunneled to the public internet for external access. Use ${ + optionName("--no-tunnel") + } to run locally only.`, + }, +); diff --git a/packages/cli/src/runner.ts b/packages/cli/src/runner.ts new file mode 100644 index 000000000..cdc0cefce --- /dev/null +++ b/packages/cli/src/runner.ts @@ -0,0 +1,154 @@ +import { group, merge, message, or } from "@optique/core"; +import { printError, run } 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 { 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 { webFingerCommand } from "./webfinger/mod.ts"; +import metadata from "../deno.json" with { type: "json" }; + +/** + * Returns the system-wide configuration file paths. + * - Linux/macOS: Searches `$XDG_CONFIG_DIRS` (default: /etc/xdg) + * - Windows: Uses `%ProgramData%` (default: C:\ProgramData) + */ +function getSystemConfigPaths(): string[] { + if (process.platform === "win32") { + const programData = process.env.ProgramData || "C:\\ProgramData"; + return [join(programData, "fedify", "config.toml")]; + } + return (process.env.XDG_CONFIG_DIRS || "/etc/xdg") + .split(":") + .map((dir) => join(dir, "fedify", "config.toml")); +} + +/** + * Returns the user-level configuration file path. + * - Linux/macOS: `$XDG_CONFIG_HOME/fedify/config.toml` (default: ~/.config) + * - Windows: `%APPDATA%\fedify\config.toml` + */ +function getUserConfigPath(): string { + if (process.platform === "win32") { + const appData = process.env.APPDATA || + join(homedir(), "AppData", "Roaming"); + return join(appData, "fedify", "config.toml"); + } + const xdgConfigHome = process.env.XDG_CONFIG_HOME || + join(homedir(), ".config"); + return join(xdgConfigHome, "fedify", "config.toml"); +} + +export const command = merge( + or( + group( + "Generating code", + or( + initCommand, + generateVocabCommand, + ), + ), + group( + "ActivityPub tools", + or( + webFingerCommand, + lookupCommand, + inboxCommand, + nodeInfoCommand, + relayCommand, + ), + ), + group( + "Network tools", + tunnelCommand, + ), + ), + globalOptions, +); + +type ConfigOptions = { + ignoreConfig: boolean; + configPath?: string; +}; + +export function loadConfig( + parsed: ConfigOptions, +): { config: Record; meta: undefined } | undefined { + if (parsed.ignoreConfig) return undefined; + + // Load system-wide configs (XDG_CONFIG_DIRS on Linux/macOS, ProgramData on Windows) + const systemConfigs = getSystemConfigPaths().map(tryLoadToml); + const system = systemConfigs.reduce( + (acc, config) => deepMerge(acc, config), + {}, + ); + const user = tryLoadToml(getUserConfigPath()); + const project = tryLoadToml(join(process.cwd(), ".fedify.toml")); + + // Custom config via --config exits with error if file is missing or invalid + let custom: Record = {}; + if (parsed.configPath) { + try { + custom = parseToml(readFileSync(parsed.configPath, "utf-8")); + } catch (error) { + printError( + message`Could not load config file at ${parsed.configPath}: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + process.exit(1); + } + } + + return { + config: [system, user, project, custom].reduce( + (acc, config) => deepMerge(acc, config), + {}, + ), + meta: undefined, + }; +} + +/** + * Runs the Fedify CLI with the given command-line arguments. + * @param args Command-line arguments, usually `process.argv.slice(2)`. + * @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, + }); +} diff --git a/packages/cli/src/tunnel.test.ts b/packages/cli/src/tunnel.test.ts index b7ef03f5e..d8e477ebc 100644 --- a/packages/cli/src/tunnel.test.ts +++ b/packages/cli/src/tunnel.test.ts @@ -1,15 +1,15 @@ import type { Tunnel, TunnelOptions } from "@hongminhee/localtunnel"; -import { run } from "@optique/run"; +import { runSync } from "@optique/run"; import { deepEqual, rejects } from "node:assert/strict"; import test from "node:test"; import type { Ora } from "ora"; import { runTunnel, tunnelCommand } from "./tunnel.ts"; test("tunnel command structure", () => { - const testCommandWithOptions = run(tunnelCommand, { + const testCommandWithOptions = runSync(tunnelCommand, { args: ["tunnel", "3001", "-s", "pinggy.io"], }); - const testCommandWithoutOptions = run(tunnelCommand, { + const testCommandWithoutOptions = runSync(tunnelCommand, { args: ["tunnel", "3000"], }); diff --git a/packages/cli/src/webfinger/mod.test.ts b/packages/cli/src/webfinger/mod.test.ts index fedca199f..e81ffb769 100644 --- a/packages/cli/src/webfinger/mod.test.ts +++ b/packages/cli/src/webfinger/mod.test.ts @@ -1,8 +1,6 @@ -import { clearActiveConfig, setActiveConfig } from "@optique/config"; import { parse } from "@optique/core/parser"; import assert from "node:assert/strict"; import test from "node:test"; -import { configContext } from "../config.ts"; import { lookupSingleWebFinger } from "./action.ts"; import { webFingerCommand } from "./command.ts"; @@ -19,9 +17,7 @@ const ALIASES = [ test("Test webFingerCommand - resources only", async () => { const argsWithResourcesOnly = [COMMAND, ...RESOURCES]; - setActiveConfig(configContext.id, {}); const result = await parse(webFingerCommand, argsWithResourcesOnly); - clearActiveConfig(configContext.id); assert.ok(result.success); if (result.success) { diff --git a/packages/init/src/action/configs.test.ts b/packages/init/src/action/configs.test.ts new file mode 100644 index 000000000..51ba1cb46 --- /dev/null +++ b/packages/init/src/action/configs.test.ts @@ -0,0 +1,96 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { message } from "@optique/core"; +import type { InitCommandData } from "../types.ts"; +import { loadDenoConfig } from "./configs.ts"; + +function createInitData(): InitCommandData { + const data = { + command: "init", + projectName: "example", + packageManager: "deno", + webFramework: "hono", + kvStore: "denokv", + messageQueue: "denokv", + dryRun: false, + testMode: false, + dir: "/tmp/example", + initializer: { + federationFile: "federation.ts", + loggingFile: "logging.ts", + instruction: message`done`, + tasks: {}, + compilerOptions: {}, + }, + kv: { + label: "Deno KV", + packageManagers: ["deno"], + imports: {}, + object: "kv", + denoUnstable: [], + }, + mq: { + label: "Deno KV", + packageManagers: ["deno"], + imports: {}, + object: "mq", + denoUnstable: [], + }, + env: {}, + } satisfies InitCommandData; + return data; +} + +function restoreDeno( + originalDeno: unknown, +) { + if (originalDeno == null) { + Reflect.deleteProperty(globalThis, "Deno"); + } else { + Object.defineProperty(globalThis, "Deno", { + value: originalDeno, + configurable: true, + enumerable: true, + writable: true, + }); + } +} + +function setDenoVersion( + version: { deno: string; v8: string; typescript: string }, +) { + const current = (globalThis as Record).Deno; + const value = current == null || typeof current !== "object" + ? { version } + : { ...(current as Record), version }; + Object.defineProperty(globalThis, "Deno", { + value, + configurable: true, + enumerable: true, + writable: true, + }); +} + +test("loadDenoConfig omits unstable.temporal on Deno 2.7.0", () => { + const originalDeno = (globalThis as Record).Deno; + setDenoVersion({ deno: "2.7.0", v8: "0.0.0", typescript: "0.0.0" }); + + try { + const config = loadDenoConfig(createInitData()).data; + assert.strictEqual(config.unstable, undefined); + } finally { + restoreDeno(originalDeno); + } +}); + +test("loadDenoConfig keeps unstable.temporal before Deno 2.7.0", () => { + const originalDeno = (globalThis as Record).Deno; + setDenoVersion({ deno: "2.6.9", v8: "0.0.0", typescript: "0.0.0" }); + + try { + const config = loadDenoConfig(createInitData()).data; + assert.deepStrictEqual(config.unstable, ["temporal"]); + } finally { + restoreDeno(originalDeno); + } +}); diff --git a/packages/init/src/action/configs.ts b/packages/init/src/action/configs.ts index 005665b34..17689d7ca 100644 --- a/packages/init/src/action/configs.ts +++ b/packages/init/src/action/configs.ts @@ -1,6 +1,4 @@ import { - always, - cases, concat, filter, head, @@ -59,26 +57,30 @@ export const loadDenoConfig = (data: InitCommandData) => ({ const getUnstable = >({ kv: { denoUnstable: kv = [] }, mq: { denoUnstable: mq = [] }, -}: T): { unstable?: string[] } => - pipe( +}: T): { unstable?: string[] } => { + const unstable = pipe( needsUnstableTemporal() ? ["temporal"] : [], concat(kv), concat(mq), uniq, toArray, - cases(isEmpty, always({}), (unstable) => ({ unstable })), - ) as { unstable?: string[] }; + ); + return isEmpty(unstable) ? {} : { unstable }; +}; type Version = [number, number, number]; const TEMPORAL_STABLE_FROM: Version = [2, 7, 0] as const; -const needsUnstableTemporal = (): boolean => - pipe( +const needsUnstableTemporal = (): boolean => { + const version = pipe( getDenoVersionFromRuntime(), when(isNull, getDenoVersionFromCommand), when(isString, parseVersion), - cases(isArray, isLaterOrEqualThan(TEMPORAL_STABLE_FROM), always(true)), ); + return isArray(version) + ? !isLaterOrEqualThan(TEMPORAL_STABLE_FROM)(version) + : false; +}; const getDenoVersionFromCommand = (): string | null => { try { @@ -106,7 +108,7 @@ const getDenoVersionFromRuntime = (): string | null => const parseVersion: (match: string) => Version | null = (deno: string) => pipe( deno.match(/^(\d+)\.(\d+)\.(\d+)/), - unless(isNull, (arr) => arr.map(Number) as Version), + unless(isNull, ([, ...segments]) => segments.map(Number) as Version), ); const isLaterOrEqualThan = (basis: Version) => (target: Version): boolean => diff --git a/packages/vocab-tools/package.json b/packages/vocab-tools/package.json index f72dd6c1c..08e85b4fe 100644 --- a/packages/vocab-tools/package.json +++ b/packages/vocab-tools/package.json @@ -45,11 +45,11 @@ "pretest": "pnpm build", "test": "node --experimental-transform-types --test 'src/**/*.test.ts'", "pretest:bun": "pnpm build", - "test:bun": "bun test", + "test:bun": "bun test --timeout 15000", "pretest:update_snapshots": "pnpm build", "test:update_snapshots": "node --experimental-transform-types --test --test-update-snapshots 'src/**/*.test.ts'", "pretest:bun:update_snapshots": "pnpm build", - "test:bun:update_snapshots": "bun test --update-snapshots" + "test:bun:update_snapshots": "bun test --timeout 15000 --update-snapshots" }, "keywords": [ "Fedify", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53107c1d5..9195dde14 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -49,14 +49,14 @@ catalogs: specifier: ^1.39.0 version: 1.39.0 '@optique/config': - specifier: ^0.10.7 - version: 0.10.7 + specifier: ^1.0.0 + version: 1.0.0 '@optique/core': - specifier: ^0.10.7 - version: 0.10.7 + specifier: ^1.0.0 + version: 1.0.0 '@optique/run': - specifier: ^0.10.7 - version: 0.10.7 + specifier: ^1.0.0 + version: 1.0.0 '@std/assert': specifier: jsr:^1.0.13 version: 1.0.13 @@ -877,13 +877,13 @@ importers: version: 2.0.5 '@optique/config': specifier: 'catalog:' - version: 0.10.7(@standard-schema/spec@1.0.0) + version: 1.0.0(@standard-schema/spec@1.0.0) '@optique/core': specifier: 'catalog:' - version: 0.10.7 + version: 1.0.0 '@optique/run': specifier: 'catalog:' - version: 0.10.7 + version: 1.0.0 '@poppanator/http-constants': specifier: ^1.1.1 version: 1.1.1 @@ -959,10 +959,10 @@ importers: version: link:../init '@optique/core': specifier: 'catalog:' - version: 0.10.7 + version: 1.0.0 '@optique/run': specifier: 'catalog:' - version: 0.10.7 + version: 1.0.0 es-toolkit: specifier: 'catalog:' version: 1.43.0 @@ -1238,10 +1238,10 @@ importers: version: 2.0.5 '@optique/core': specifier: 'catalog:' - version: 0.10.7 + version: 1.0.0 '@optique/run': specifier: 'catalog:' - version: 0.10.7 + version: 1.0.0 chalk: specifier: 'catalog:' version: 5.6.2 @@ -4104,18 +4104,18 @@ packages: peerDependencies: '@opentelemetry/api': ^1.1.0 - '@optique/config@0.10.7': - resolution: {integrity: sha512-U7RO2haMx6ggADCpQAuP4aL9PFbrT0yCgPzCQ3dPV1i0GceLjK6oNHO8ImCcHf/76Sqp+QCmfrEuuyeY7ZAYsQ==} + '@optique/config@1.0.0': + resolution: {integrity: sha512-O7PgpWLAEj+il0nZMKRnQq+kIA1uVfvH0PLyqRZowKkTBUbijiub5HI30Y7W5/ucx0zAg26dLDO7VnMETQ573Q==} engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} peerDependencies: '@standard-schema/spec': ^1.1.0 - '@optique/core@0.10.7': - resolution: {integrity: sha512-FwSX8ILFqzcCqZi6Xetsa4flJp/yyqFG4d4eFD98BtqdzxxuylzdrKvsXj/ow8mcoVjYkTuaIkqHSBxonqMcQg==} + '@optique/core@1.0.0': + resolution: {integrity: sha512-8h2GgREAQl4vGFUWLxqZQty4rg36FAlMvcAHPMGEAL5IUR97qSRZohtI99vwoQ6eHIRBOyF/krnMQZGPFyoNVw==} engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} - '@optique/run@0.10.7': - resolution: {integrity: sha512-1CVdH8uyptj1nFGS2MLacSmZceRClez4LD/G/Gm38wrAVnJq6I+9Fvyh2bVHErsZLQzR0a12CYMUWIgDKY3X1w==} + '@optique/run@1.0.0': + resolution: {integrity: sha512-QQywmuYmCWBYaok+MTL1c+NwGHzeUrV7AsMqiWYnRKcAxGmAhKKfdY2Hofq321fZWgqOpGBGGCW8YDGEr3zhmw==} engines: {bun: '>=1.2.0', deno: '>=2.3.0', node: '>=20.0.0'} '@oslojs/encoding@1.1.0': @@ -13708,16 +13708,16 @@ snapshots: '@opentelemetry/api': 1.9.0 '@opentelemetry/core': 1.30.1(@opentelemetry/api@1.9.0) - '@optique/config@0.10.7(@standard-schema/spec@1.0.0)': + '@optique/config@1.0.0(@standard-schema/spec@1.0.0)': dependencies: - '@optique/core': 0.10.7 + '@optique/core': 1.0.0 '@standard-schema/spec': 1.0.0 - '@optique/core@0.10.7': {} + '@optique/core@1.0.0': {} - '@optique/run@0.10.7': + '@optique/run@1.0.0': dependencies: - '@optique/core': 0.10.7 + '@optique/core': 1.0.0 '@oslojs/encoding@1.1.0': {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 59aeedee3..49ff62b0d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -56,9 +56,9 @@ catalog: "@opentelemetry/sdk-node": ^0.211.0 "@opentelemetry/sdk-trace-base": ^2.5.0 "@opentelemetry/semantic-conventions": ^1.39.0 - "@optique/config": ^0.10.7 - "@optique/core": ^0.10.7 - "@optique/run": ^0.10.7 + "@optique/config": ^1.0.0 + "@optique/core": ^1.0.0 + "@optique/run": ^1.0.0 "@std/assert": "jsr:^1.0.13" "@std/async": "jsr:^1.0.13" "@std/path": "jsr:^1.0.6"