Loading .github/workflows/build.yaml +3 −0 Original line number Diff line number Diff line Loading @@ -426,6 +426,7 @@ jobs: | @fedify/nestjs | ${{ steps.versioning.outputs.version }} | | [npm][npm:@fedify/nestjs] | | @fedify/postgres | ${{ steps.versioning.outputs.version }} | [JSR][jsr:@fedify/postgres] | [npm][npm:@fedify/postgres] | | @fedify/redis | ${{ steps.versioning.outputs.version }} | [JSR][jsr:@fedify/redis] | [npm][npm:@fedify/redis] | | @fedify/testing | ${{ steps.versioning.outputs.version }} | [JSR][jsr:@fedify/testing] | [npm][npm:@fedify/testing] | [jsr:@fedify/fedify]: https://jsr.io/@fedify/fedify@${{ steps.versioning.outputs.version }} [npm:@fedify/fedify]: https://www.npmjs.com/package/@fedify/fedify/v/${{ steps.versioning.outputs.short_version }} Loading @@ -441,6 +442,8 @@ jobs: [npm:@fedify/postgres]: https://www.npmjs.com/package/@fedify/postgres/v/${{ steps.versioning.outputs.short_version }} [jsr:@fedify/redis]: https://jsr.io/@fedify/redis@${{ steps.versioning.outputs.version }} [npm:@fedify/redis]: https://www.npmjs.com/package/@fedify/redis/v/${{ steps.versioning.outputs.short_version }} [jsr:@fedify/testing]: https://jsr.io/@fedify/testing@${{ steps.versioning.outputs.version }} [npm:@fedify/testing]: https://www.npmjs.com/package/@fedify/testing/v/${{ steps.versioning.outputs.short_version }} pr-number: ${{ github.event.pull_request.number }} comment-tag: publish Loading CHANGES.md +51 −6 Original line number Diff line number Diff line Loading @@ -24,6 +24,16 @@ the versioning. All packages now follow the same version number and are released together. Previously, each package had independent versioning. - Added mock classes for `Federation` and `Context` interfaces to improve testability without requiring a real federation server setup. The mock classes track all sent activities with metadata and support all standard Fedify patterns including custom path registration and multiple activity type listeners. [[#197], [#283] by Lee ByeongJun] - Added `@fedify/testing` package. - Added `MockFederation` class. - Added `MockContext` class. - Key–value stores now optionally support CAS (compare-and-swap) operation for atomic updates. This is useful for implementing optimistic locking and preventing lost updates in concurrent environments. Loading Loading @@ -72,6 +82,9 @@ the versioning. in the WebFinger request. - The `--allow-private-address` or `-p` option allows looking up WebFinger information for private addresses (e.g., `localhost`). - The `--max-redirection` option allows uses to specify the maximum number of redirects to follow when performing WebFinger lookups. [[#311], [#328] by KeunHyeong Park] - Added `--dry-run` option to `fedify init` command. This option allows users to preview what files and configurations would be created without actually Loading @@ -87,20 +100,40 @@ the versioning. - Added `@fedify/nestjs` package. - Added `FedifyModule` for integrating Fedify into NestJS applications. - Added `-o`/`--output` option to `fedify lookup` command. This option allows users to save retrieved lookup results to specified path. [[#261], [#321] by Jiwon Kwon] [#168]: https://github.com/fedify-dev/fedify/issues/168 [#197]: https://github.com/fedify-dev/fedify/issues/197 [#248]: https://github.com/fedify-dev/fedify/issues/248 [#260]: https://github.com/fedify-dev/fedify/issues/260 [#261]: https://github.com/fedify-dev/fedify/issues/261 [#262]: https://github.com/fedify-dev/fedify/issues/262 [#263]: https://github.com/fedify-dev/fedify/issues/263 [#269]: https://github.com/fedify-dev/fedify/issues/269 [#278]: https://github.com/fedify-dev/fedify/pull/278 [#281]: https://github.com/fedify-dev/fedify/pull/281 [#282]: https://github.com/fedify-dev/fedify/pull/282 [#283]: https://github.com/fedify-dev/fedify/pull/283 [#285]: https://github.com/fedify-dev/fedify/pull/285 [#298]: https://github.com/fedify-dev/fedify/pull/298 [#300]: https://github.com/fedify-dev/fedify/pull/300 [#304]: https://github.com/fedify-dev/fedify/issues/304 [#309]: https://github.com/fedify-dev/fedify/pull/309 [#311]: https://github.com/fedify-dev/fedify/issues/311 [#321]: https://github.com/fedify-dev/fedify/pull/321 [#328]: https://github.com/fedify-dev/fedify/pull/309 Version 1.7.6 ------------- Released on July 24, 2025. - Fixed `doubleKnock()` to properly handle redirects with path-only `Location` headers by resolving them relative to the original request URL. [[#324] by Fabien O'Carroll] Version 1.7.5 Loading Loading @@ -186,6 +219,18 @@ Released on June 25, 2025. [#252]: https://github.com/fedify-dev/fedify/pull/252 Version 1.6.7 ------------- Released on July 24, 2025. - Fixed `doubleKnock()` to properly handle redirects with path-only `Location` headers by resolving them relative to the original request URL. [[#324] by Fabien O'Carroll] [#324]: https://github.com/fedify-dev/fedify/pull/324 Version 1.6.6 ------------- Loading cli/lookup.test.ts 0 → 100644 +301 −0 Original line number Diff line number Diff line import { Activity, Note } from "@fedify/fedify"; import { assertEquals, assertExists } from "@std/assert"; import { getContextLoader } from "./docloader.ts"; import { createFileStream, writeObjectToStream } from "./lookup.ts"; Deno.test("createFileStream - creates file stream with proper directory creation", async () => { const testDir = "./test_output"; const testFile = `${testDir}/test.json`; try { await Deno.remove(testDir, { recursive: true }); } catch { // Ignore if doesn't exist } const stream = await createFileStream(testFile); assertExists(stream); const stat = await Deno.stat(testDir); assertEquals(stat.isDirectory, true); stream.close(); await Deno.remove(testDir, { recursive: true }); }); Deno.test("createFileStream - works with absolute paths", async () => { const testDir = `${Deno.cwd()}/test_output_absolute`; const testFile = `${testDir}/test.json`; try { await Deno.remove(testDir, { recursive: true }); } catch { // Ignore if doesn't exist } const stream = await createFileStream(testFile); assertExists(stream); const stat = await Deno.stat(testDir); assertEquals(stat.isDirectory, true); stream.close(); await Deno.remove(testDir, { recursive: true }); }); Deno.test("createFileStream - creates nested directories", async () => { const testDir = "./test_output_nested/deep/path"; const testFile = `${testDir}/test.json`; try { await Deno.remove("./test_output_nested", { recursive: true }); } catch { // Ignore if doesn't exist } const stream = await createFileStream(testFile); assertExists(stream); // Verify nested directories were created const stat = await Deno.stat(testDir); assertEquals(stat.isDirectory, true); stream.close(); await Deno.remove("./test_output_nested", { recursive: true }); }); Deno.test("createFileStream - writes data correctly", async () => { const testDir = "./test_output_write"; const testFile = `${testDir}/test.txt`; try { await Deno.remove(testDir, { recursive: true }); } catch { // Ignore if doesn't exist } const stream = await createFileStream(testFile); const writer = stream.getWriter(); const testData = new TextEncoder().encode("Hello, World!"); await writer.write(testData); await writer.close(); const content = await Deno.readTextFile(testFile); assertEquals(content, "Hello, World!"); await Deno.remove(testDir, { recursive: true }); }); Deno.test("createFileStream - truncates existing file", async () => { const testDir = "./test_output_truncate"; const testFile = `${testDir}/test.txt`; try { await Deno.remove(testDir, { recursive: true }); } catch { // Ignore if doesn't exist } await Deno.mkdir(testDir, { recursive: true }); await Deno.writeTextFile(testFile, "Old content"); const stream = await createFileStream(testFile); const writer = stream.getWriter(); const testData = new TextEncoder().encode("New content"); await writer.write(testData); await writer.close(); // Verify file was truncated and new content written const content = await Deno.readTextFile(testFile); assertEquals(content, "New content"); await Deno.remove(testDir, { recursive: true }); }); Deno.test("writeObjectToStream - writes Note object with default options", { sanitizeResources: false, }, async () => { const testDir = "./test_output_note"; const testFile = `${testDir}/note.txt`; try { await Deno.remove(testDir, { recursive: true }); } catch { // Ignore if doesn't exist } const note = new Note({ id: new URL("https://example.com/notes/1"), content: "Hello, fediverse!", }); const options = { firstKnock: "rfc9421" as const, separator: "----", output: testFile, }; const contextLoader = await getContextLoader({}); await writeObjectToStream(note, options, contextLoader); const content = await Deno.readTextFile(testFile); assertExists(content); assertEquals(content.includes("Hello, fediverse!"), true); assertEquals(content.includes("Note"), true); await Deno.remove(testDir, { recursive: true }); }); Deno.test("writeObjectToStream - writes Activity object in raw JSON-LD format", async () => { const testDir = "./test_output_activity"; const testFile = `${testDir}/activity.json`; try { await Deno.remove(testDir, { recursive: true }); } catch { // Ignore if doesn't exist } const activity = new Activity({ id: new URL("https://example.com/activities/1"), }); const options = { firstKnock: "rfc9421" as const, separator: "----", output: testFile, raw: true, }; const contextLoader = await getContextLoader({}); await writeObjectToStream(activity, options, contextLoader); // Verify file exists and contains JSON-LD const content = await Deno.readTextFile(testFile); assertExists(content); assertEquals(content.includes("@context"), true); assertEquals(content.includes("id"), true); await Deno.remove(testDir, { recursive: true }); }); Deno.test("writeObjectToStream - writes object in compact JSON-LD format", async () => { const testDir = "./test_output_compact"; const testFile = `${testDir}/compact.json`; try { await Deno.remove(testDir, { recursive: true }); } catch { // Ignore if doesn't exist } const note = new Note({ id: new URL("https://example.com/notes/1"), content: "Test note", }); const options = { firstKnock: "rfc9421" as const, separator: "----", output: testFile, compact: true, }; const contextLoader = await getContextLoader({}); await writeObjectToStream(note, options, contextLoader); // Verify file exists and contains compacted JSON-LD const content = await Deno.readTextFile(testFile); assertExists(content); assertEquals(content.includes("Test note"), true); await Deno.remove(testDir, { recursive: true }); }); Deno.test("writeObjectToStream - writes object in expanded JSON-LD format", async () => { const testDir = "./test_output_expand"; const testFile = `${testDir}/expand.json`; try { await Deno.remove(testDir, { recursive: true }); } catch { // Ignore if doesn't exist } const note = new Note({ id: new URL("https://example.com/notes/1"), content: "Test note for expansion", }); const options = { firstKnock: "rfc9421" as const, separator: "----", output: testFile, expand: true, }; const contextLoader = await getContextLoader({}); await writeObjectToStream(note, options, contextLoader); const content = await Deno.readTextFile(testFile); assertExists(content); assertEquals(content.includes("Test note for expansion"), true); await Deno.remove(testDir, { recursive: true }); }); Deno.test("writeObjectToStream - writes to stdout when no output file specified", async () => { const note = new Note({ id: new URL("https://example.com/notes/1"), content: "Test stdout note", }); const options = { firstKnock: "rfc9421" as const, separator: "----", }; const contextLoader = await getContextLoader({}); await writeObjectToStream(note, options, contextLoader); }); Deno.test("writeObjectToStream - handles empty content properly", async () => { const testDir = "./test_output_empty"; const testFile = `${testDir}/empty.txt`; try { await Deno.remove(testDir, { recursive: true }); } catch { // Ignore if doesn't exist } const note = new Note({ id: new URL("https://example.com/notes/1"), }); const options = { firstKnock: "rfc9421" as const, separator: "----", output: testFile, }; const contextLoader = await getContextLoader({}); await writeObjectToStream(note, options, contextLoader); const content = await Deno.readTextFile(testFile); assertExists(content); assertEquals(content.includes("Note"), true); await Deno.remove(testDir, { recursive: true }); }); cli/lookup.ts +115 −20 Original line number Diff line number Diff line Loading @@ -15,15 +15,114 @@ import { traverseCollection, } from "@fedify/fedify"; import { getLogger } from "@logtape/logtape"; import { dirname, isAbsolute, resolve } from "@std/path"; import ora from "ora"; import { getContextLoader, getDocumentLoader } from "./docloader.ts"; import { spawnTemporaryServer, type TemporaryServer } from "./tempserver.ts"; import { printJson } from "./utils.ts"; const logger = getLogger(["fedify", "cli", "lookup"]); const sigSpec = new EnumType(["draft-cavage-http-signatures-12", "rfc9421"]); interface CommandOptions { authorizedFetch?: boolean; firstKnock: "draft-cavage-http-signatures-12" | "rfc9421"; traverse?: boolean; suppressErrors?: boolean; raw?: boolean; compact?: boolean; expand?: boolean; userAgent?: string; separator: string; output?: string; } export async function createFileStream( outputPath: string, ): Promise<WritableStream> { try { const filepath = isAbsolute(outputPath) ? outputPath : resolve(Deno.env.get("PWD") || Deno.cwd(), outputPath); const parentDir = dirname(filepath); await Deno.mkdir(parentDir, { recursive: true }); const file = await Deno.open(filepath, { write: true, create: true, truncate: true, }); return new WritableStream({ write: (chunk) => file.write(chunk).then(() => {}), close: () => file.close(), abort: (reason) => { file.close(); throw reason; }, }); } catch (err) { const spinner = ora({ text: `Failed to write output to ${colors.red(outputPath)}.`, discardStdin: false, }); spinner.fail(); console.error(`Error: ${String(err)}`); if (err instanceof Deno.errors.PermissionDenied) { console.error( "Permission denied. Try running with proper permissions.", ); } else if (err instanceof Deno.errors.NotFound) { console.error("Path does not exist or is invalid."); } else if (err instanceof Deno.errors.IsADirectory) { console.error("The specified path is a directory, not a file."); } Deno.exit(1); } } export async function writeObjectToStream( object: Object | Link, options: CommandOptions, contextLoader: DocumentLoader, ): Promise<void> { const stream = options.output ? await createFileStream(options.output) : Deno.stdout.writable; const writer = stream.getWriter(); try { let content; if (options.raw) { content = await object.toJsonLd({ contextLoader }); } else if (options.compact) { content = await object.toJsonLd({ format: "compact", contextLoader }); } else if (options.expand) { content = await object.toJsonLd({ format: "expand", contextLoader }); } else { content = object; } content = Deno.inspect(content, { colors: !(options.output), }); const encoder = new TextEncoder(); const bytes = encoder.encode(content + "\n"); await writer.write(bytes); } finally { writer.releaseLock(); if (options.output) { await stream.close(); } } } export const command = new Command() .type("sig-spec", sigSpec) .arguments("<...urls:string>") Loading Loading @@ -65,6 +164,10 @@ export const command = new Command() "collection items.", { default: "----" }, ) .option( "-o, --output <file>", "Specify the output file path.", ) .action(async (options, ...urls: string[]) => { if (urls.length < 1) { console.error("At least one URL or actor handle must be provided."); Loading @@ -75,6 +178,7 @@ export const command = new Command() ); Deno.exit(1); } const spinner = ora({ text: `Looking up the ${ options.traverse ? "collection" : urls.length > 1 ? "objects" : "object" Loading Loading @@ -143,26 +247,11 @@ export const command = new Command() }, ); } spinner.text = `Looking up the ${ options.traverse ? "collection" : urls.length > 1 ? "objects" : "object" }...`; async function printObject(object: Object | Link): Promise<void> { if (options.raw) { printJson(await object.toJsonLd({ contextLoader })); } else if (options.compact) { printJson( await object.toJsonLd({ format: "compact", contextLoader }), ); } else if (options.expand) { printJson( await object.toJsonLd({ format: "expand", contextLoader }), ); } else { console.log(object); } } if (options.traverse) { const url = urls[0]; const collection = await lookupObject(url, { Loading Loading @@ -198,8 +287,8 @@ export const command = new Command() suppressError: options.suppressErrors, }) ) { if (i > 0) console.log(options.separator); printObject(item); if (!options.output && i > 0) console.log(options.separator); await writeObjectToStream(item, options, contextLoader); i++; } } catch (error) { Loading @@ -218,6 +307,7 @@ export const command = new Command() Deno.exit(1); } spinner.succeed("Successfully fetched all items in the collection."); await server?.close(); Deno.exit(0); } Loading Loading @@ -254,7 +344,7 @@ export const command = new Command() success = false; } else { spinner.succeed(`Fetched object: ${colors.green(url)}.`); printObject(object); await writeObjectToStream(object, options, contextLoader); if (i < urls.length - 1) { console.log(options.separator); } Loading @@ -274,6 +364,11 @@ export const command = new Command() if (!success) { Deno.exit(1); } if (success && options.output) { spinner.succeed( `Successfully wrote output to ${colors.green(options.output)}.`, ); } }); // cSpell: ignore sigspec cli/webfinger.ts +12 −1 Original line number Diff line number Diff line import { Command } from "@cliffy/command"; import { Command, ValidationError } from "@cliffy/command"; import { toAcctUrl } from "@fedify/fedify/vocab"; import { lookupWebFinger } from "@fedify/fedify/webfinger"; import ora from "ora"; Loading @@ -17,7 +17,18 @@ export const command = new Command() "-p, --allow-private-address", "Allow private IP addresses in the URL.", ) .option( "--max-redirection <maxRedirection:integer>", "Maximum number of redirections to follow.", { default: 5 }, ) .action(async (options, ...resources: string[]) => { if (options.maxRedirection < 0) { // Validate maxRedirection option throw new ValidationError( `Option --max-redirection must be greater than or equal to 0, but got ${options.maxRedirection}.`, ); } for (const resource of resources) { const spinner = ora({ // Create a spinner for the lookup process text: `Looking up WebFinger for ${resource}`, Loading Loading
.github/workflows/build.yaml +3 −0 Original line number Diff line number Diff line Loading @@ -426,6 +426,7 @@ jobs: | @fedify/nestjs | ${{ steps.versioning.outputs.version }} | | [npm][npm:@fedify/nestjs] | | @fedify/postgres | ${{ steps.versioning.outputs.version }} | [JSR][jsr:@fedify/postgres] | [npm][npm:@fedify/postgres] | | @fedify/redis | ${{ steps.versioning.outputs.version }} | [JSR][jsr:@fedify/redis] | [npm][npm:@fedify/redis] | | @fedify/testing | ${{ steps.versioning.outputs.version }} | [JSR][jsr:@fedify/testing] | [npm][npm:@fedify/testing] | [jsr:@fedify/fedify]: https://jsr.io/@fedify/fedify@${{ steps.versioning.outputs.version }} [npm:@fedify/fedify]: https://www.npmjs.com/package/@fedify/fedify/v/${{ steps.versioning.outputs.short_version }} Loading @@ -441,6 +442,8 @@ jobs: [npm:@fedify/postgres]: https://www.npmjs.com/package/@fedify/postgres/v/${{ steps.versioning.outputs.short_version }} [jsr:@fedify/redis]: https://jsr.io/@fedify/redis@${{ steps.versioning.outputs.version }} [npm:@fedify/redis]: https://www.npmjs.com/package/@fedify/redis/v/${{ steps.versioning.outputs.short_version }} [jsr:@fedify/testing]: https://jsr.io/@fedify/testing@${{ steps.versioning.outputs.version }} [npm:@fedify/testing]: https://www.npmjs.com/package/@fedify/testing/v/${{ steps.versioning.outputs.short_version }} pr-number: ${{ github.event.pull_request.number }} comment-tag: publish Loading
CHANGES.md +51 −6 Original line number Diff line number Diff line Loading @@ -24,6 +24,16 @@ the versioning. All packages now follow the same version number and are released together. Previously, each package had independent versioning. - Added mock classes for `Federation` and `Context` interfaces to improve testability without requiring a real federation server setup. The mock classes track all sent activities with metadata and support all standard Fedify patterns including custom path registration and multiple activity type listeners. [[#197], [#283] by Lee ByeongJun] - Added `@fedify/testing` package. - Added `MockFederation` class. - Added `MockContext` class. - Key–value stores now optionally support CAS (compare-and-swap) operation for atomic updates. This is useful for implementing optimistic locking and preventing lost updates in concurrent environments. Loading Loading @@ -72,6 +82,9 @@ the versioning. in the WebFinger request. - The `--allow-private-address` or `-p` option allows looking up WebFinger information for private addresses (e.g., `localhost`). - The `--max-redirection` option allows uses to specify the maximum number of redirects to follow when performing WebFinger lookups. [[#311], [#328] by KeunHyeong Park] - Added `--dry-run` option to `fedify init` command. This option allows users to preview what files and configurations would be created without actually Loading @@ -87,20 +100,40 @@ the versioning. - Added `@fedify/nestjs` package. - Added `FedifyModule` for integrating Fedify into NestJS applications. - Added `-o`/`--output` option to `fedify lookup` command. This option allows users to save retrieved lookup results to specified path. [[#261], [#321] by Jiwon Kwon] [#168]: https://github.com/fedify-dev/fedify/issues/168 [#197]: https://github.com/fedify-dev/fedify/issues/197 [#248]: https://github.com/fedify-dev/fedify/issues/248 [#260]: https://github.com/fedify-dev/fedify/issues/260 [#261]: https://github.com/fedify-dev/fedify/issues/261 [#262]: https://github.com/fedify-dev/fedify/issues/262 [#263]: https://github.com/fedify-dev/fedify/issues/263 [#269]: https://github.com/fedify-dev/fedify/issues/269 [#278]: https://github.com/fedify-dev/fedify/pull/278 [#281]: https://github.com/fedify-dev/fedify/pull/281 [#282]: https://github.com/fedify-dev/fedify/pull/282 [#283]: https://github.com/fedify-dev/fedify/pull/283 [#285]: https://github.com/fedify-dev/fedify/pull/285 [#298]: https://github.com/fedify-dev/fedify/pull/298 [#300]: https://github.com/fedify-dev/fedify/pull/300 [#304]: https://github.com/fedify-dev/fedify/issues/304 [#309]: https://github.com/fedify-dev/fedify/pull/309 [#311]: https://github.com/fedify-dev/fedify/issues/311 [#321]: https://github.com/fedify-dev/fedify/pull/321 [#328]: https://github.com/fedify-dev/fedify/pull/309 Version 1.7.6 ------------- Released on July 24, 2025. - Fixed `doubleKnock()` to properly handle redirects with path-only `Location` headers by resolving them relative to the original request URL. [[#324] by Fabien O'Carroll] Version 1.7.5 Loading Loading @@ -186,6 +219,18 @@ Released on June 25, 2025. [#252]: https://github.com/fedify-dev/fedify/pull/252 Version 1.6.7 ------------- Released on July 24, 2025. - Fixed `doubleKnock()` to properly handle redirects with path-only `Location` headers by resolving them relative to the original request URL. [[#324] by Fabien O'Carroll] [#324]: https://github.com/fedify-dev/fedify/pull/324 Version 1.6.6 ------------- Loading
cli/lookup.test.ts 0 → 100644 +301 −0 Original line number Diff line number Diff line import { Activity, Note } from "@fedify/fedify"; import { assertEquals, assertExists } from "@std/assert"; import { getContextLoader } from "./docloader.ts"; import { createFileStream, writeObjectToStream } from "./lookup.ts"; Deno.test("createFileStream - creates file stream with proper directory creation", async () => { const testDir = "./test_output"; const testFile = `${testDir}/test.json`; try { await Deno.remove(testDir, { recursive: true }); } catch { // Ignore if doesn't exist } const stream = await createFileStream(testFile); assertExists(stream); const stat = await Deno.stat(testDir); assertEquals(stat.isDirectory, true); stream.close(); await Deno.remove(testDir, { recursive: true }); }); Deno.test("createFileStream - works with absolute paths", async () => { const testDir = `${Deno.cwd()}/test_output_absolute`; const testFile = `${testDir}/test.json`; try { await Deno.remove(testDir, { recursive: true }); } catch { // Ignore if doesn't exist } const stream = await createFileStream(testFile); assertExists(stream); const stat = await Deno.stat(testDir); assertEquals(stat.isDirectory, true); stream.close(); await Deno.remove(testDir, { recursive: true }); }); Deno.test("createFileStream - creates nested directories", async () => { const testDir = "./test_output_nested/deep/path"; const testFile = `${testDir}/test.json`; try { await Deno.remove("./test_output_nested", { recursive: true }); } catch { // Ignore if doesn't exist } const stream = await createFileStream(testFile); assertExists(stream); // Verify nested directories were created const stat = await Deno.stat(testDir); assertEquals(stat.isDirectory, true); stream.close(); await Deno.remove("./test_output_nested", { recursive: true }); }); Deno.test("createFileStream - writes data correctly", async () => { const testDir = "./test_output_write"; const testFile = `${testDir}/test.txt`; try { await Deno.remove(testDir, { recursive: true }); } catch { // Ignore if doesn't exist } const stream = await createFileStream(testFile); const writer = stream.getWriter(); const testData = new TextEncoder().encode("Hello, World!"); await writer.write(testData); await writer.close(); const content = await Deno.readTextFile(testFile); assertEquals(content, "Hello, World!"); await Deno.remove(testDir, { recursive: true }); }); Deno.test("createFileStream - truncates existing file", async () => { const testDir = "./test_output_truncate"; const testFile = `${testDir}/test.txt`; try { await Deno.remove(testDir, { recursive: true }); } catch { // Ignore if doesn't exist } await Deno.mkdir(testDir, { recursive: true }); await Deno.writeTextFile(testFile, "Old content"); const stream = await createFileStream(testFile); const writer = stream.getWriter(); const testData = new TextEncoder().encode("New content"); await writer.write(testData); await writer.close(); // Verify file was truncated and new content written const content = await Deno.readTextFile(testFile); assertEquals(content, "New content"); await Deno.remove(testDir, { recursive: true }); }); Deno.test("writeObjectToStream - writes Note object with default options", { sanitizeResources: false, }, async () => { const testDir = "./test_output_note"; const testFile = `${testDir}/note.txt`; try { await Deno.remove(testDir, { recursive: true }); } catch { // Ignore if doesn't exist } const note = new Note({ id: new URL("https://example.com/notes/1"), content: "Hello, fediverse!", }); const options = { firstKnock: "rfc9421" as const, separator: "----", output: testFile, }; const contextLoader = await getContextLoader({}); await writeObjectToStream(note, options, contextLoader); const content = await Deno.readTextFile(testFile); assertExists(content); assertEquals(content.includes("Hello, fediverse!"), true); assertEquals(content.includes("Note"), true); await Deno.remove(testDir, { recursive: true }); }); Deno.test("writeObjectToStream - writes Activity object in raw JSON-LD format", async () => { const testDir = "./test_output_activity"; const testFile = `${testDir}/activity.json`; try { await Deno.remove(testDir, { recursive: true }); } catch { // Ignore if doesn't exist } const activity = new Activity({ id: new URL("https://example.com/activities/1"), }); const options = { firstKnock: "rfc9421" as const, separator: "----", output: testFile, raw: true, }; const contextLoader = await getContextLoader({}); await writeObjectToStream(activity, options, contextLoader); // Verify file exists and contains JSON-LD const content = await Deno.readTextFile(testFile); assertExists(content); assertEquals(content.includes("@context"), true); assertEquals(content.includes("id"), true); await Deno.remove(testDir, { recursive: true }); }); Deno.test("writeObjectToStream - writes object in compact JSON-LD format", async () => { const testDir = "./test_output_compact"; const testFile = `${testDir}/compact.json`; try { await Deno.remove(testDir, { recursive: true }); } catch { // Ignore if doesn't exist } const note = new Note({ id: new URL("https://example.com/notes/1"), content: "Test note", }); const options = { firstKnock: "rfc9421" as const, separator: "----", output: testFile, compact: true, }; const contextLoader = await getContextLoader({}); await writeObjectToStream(note, options, contextLoader); // Verify file exists and contains compacted JSON-LD const content = await Deno.readTextFile(testFile); assertExists(content); assertEquals(content.includes("Test note"), true); await Deno.remove(testDir, { recursive: true }); }); Deno.test("writeObjectToStream - writes object in expanded JSON-LD format", async () => { const testDir = "./test_output_expand"; const testFile = `${testDir}/expand.json`; try { await Deno.remove(testDir, { recursive: true }); } catch { // Ignore if doesn't exist } const note = new Note({ id: new URL("https://example.com/notes/1"), content: "Test note for expansion", }); const options = { firstKnock: "rfc9421" as const, separator: "----", output: testFile, expand: true, }; const contextLoader = await getContextLoader({}); await writeObjectToStream(note, options, contextLoader); const content = await Deno.readTextFile(testFile); assertExists(content); assertEquals(content.includes("Test note for expansion"), true); await Deno.remove(testDir, { recursive: true }); }); Deno.test("writeObjectToStream - writes to stdout when no output file specified", async () => { const note = new Note({ id: new URL("https://example.com/notes/1"), content: "Test stdout note", }); const options = { firstKnock: "rfc9421" as const, separator: "----", }; const contextLoader = await getContextLoader({}); await writeObjectToStream(note, options, contextLoader); }); Deno.test("writeObjectToStream - handles empty content properly", async () => { const testDir = "./test_output_empty"; const testFile = `${testDir}/empty.txt`; try { await Deno.remove(testDir, { recursive: true }); } catch { // Ignore if doesn't exist } const note = new Note({ id: new URL("https://example.com/notes/1"), }); const options = { firstKnock: "rfc9421" as const, separator: "----", output: testFile, }; const contextLoader = await getContextLoader({}); await writeObjectToStream(note, options, contextLoader); const content = await Deno.readTextFile(testFile); assertExists(content); assertEquals(content.includes("Note"), true); await Deno.remove(testDir, { recursive: true }); });
cli/lookup.ts +115 −20 Original line number Diff line number Diff line Loading @@ -15,15 +15,114 @@ import { traverseCollection, } from "@fedify/fedify"; import { getLogger } from "@logtape/logtape"; import { dirname, isAbsolute, resolve } from "@std/path"; import ora from "ora"; import { getContextLoader, getDocumentLoader } from "./docloader.ts"; import { spawnTemporaryServer, type TemporaryServer } from "./tempserver.ts"; import { printJson } from "./utils.ts"; const logger = getLogger(["fedify", "cli", "lookup"]); const sigSpec = new EnumType(["draft-cavage-http-signatures-12", "rfc9421"]); interface CommandOptions { authorizedFetch?: boolean; firstKnock: "draft-cavage-http-signatures-12" | "rfc9421"; traverse?: boolean; suppressErrors?: boolean; raw?: boolean; compact?: boolean; expand?: boolean; userAgent?: string; separator: string; output?: string; } export async function createFileStream( outputPath: string, ): Promise<WritableStream> { try { const filepath = isAbsolute(outputPath) ? outputPath : resolve(Deno.env.get("PWD") || Deno.cwd(), outputPath); const parentDir = dirname(filepath); await Deno.mkdir(parentDir, { recursive: true }); const file = await Deno.open(filepath, { write: true, create: true, truncate: true, }); return new WritableStream({ write: (chunk) => file.write(chunk).then(() => {}), close: () => file.close(), abort: (reason) => { file.close(); throw reason; }, }); } catch (err) { const spinner = ora({ text: `Failed to write output to ${colors.red(outputPath)}.`, discardStdin: false, }); spinner.fail(); console.error(`Error: ${String(err)}`); if (err instanceof Deno.errors.PermissionDenied) { console.error( "Permission denied. Try running with proper permissions.", ); } else if (err instanceof Deno.errors.NotFound) { console.error("Path does not exist or is invalid."); } else if (err instanceof Deno.errors.IsADirectory) { console.error("The specified path is a directory, not a file."); } Deno.exit(1); } } export async function writeObjectToStream( object: Object | Link, options: CommandOptions, contextLoader: DocumentLoader, ): Promise<void> { const stream = options.output ? await createFileStream(options.output) : Deno.stdout.writable; const writer = stream.getWriter(); try { let content; if (options.raw) { content = await object.toJsonLd({ contextLoader }); } else if (options.compact) { content = await object.toJsonLd({ format: "compact", contextLoader }); } else if (options.expand) { content = await object.toJsonLd({ format: "expand", contextLoader }); } else { content = object; } content = Deno.inspect(content, { colors: !(options.output), }); const encoder = new TextEncoder(); const bytes = encoder.encode(content + "\n"); await writer.write(bytes); } finally { writer.releaseLock(); if (options.output) { await stream.close(); } } } export const command = new Command() .type("sig-spec", sigSpec) .arguments("<...urls:string>") Loading Loading @@ -65,6 +164,10 @@ export const command = new Command() "collection items.", { default: "----" }, ) .option( "-o, --output <file>", "Specify the output file path.", ) .action(async (options, ...urls: string[]) => { if (urls.length < 1) { console.error("At least one URL or actor handle must be provided."); Loading @@ -75,6 +178,7 @@ export const command = new Command() ); Deno.exit(1); } const spinner = ora({ text: `Looking up the ${ options.traverse ? "collection" : urls.length > 1 ? "objects" : "object" Loading Loading @@ -143,26 +247,11 @@ export const command = new Command() }, ); } spinner.text = `Looking up the ${ options.traverse ? "collection" : urls.length > 1 ? "objects" : "object" }...`; async function printObject(object: Object | Link): Promise<void> { if (options.raw) { printJson(await object.toJsonLd({ contextLoader })); } else if (options.compact) { printJson( await object.toJsonLd({ format: "compact", contextLoader }), ); } else if (options.expand) { printJson( await object.toJsonLd({ format: "expand", contextLoader }), ); } else { console.log(object); } } if (options.traverse) { const url = urls[0]; const collection = await lookupObject(url, { Loading Loading @@ -198,8 +287,8 @@ export const command = new Command() suppressError: options.suppressErrors, }) ) { if (i > 0) console.log(options.separator); printObject(item); if (!options.output && i > 0) console.log(options.separator); await writeObjectToStream(item, options, contextLoader); i++; } } catch (error) { Loading @@ -218,6 +307,7 @@ export const command = new Command() Deno.exit(1); } spinner.succeed("Successfully fetched all items in the collection."); await server?.close(); Deno.exit(0); } Loading Loading @@ -254,7 +344,7 @@ export const command = new Command() success = false; } else { spinner.succeed(`Fetched object: ${colors.green(url)}.`); printObject(object); await writeObjectToStream(object, options, contextLoader); if (i < urls.length - 1) { console.log(options.separator); } Loading @@ -274,6 +364,11 @@ export const command = new Command() if (!success) { Deno.exit(1); } if (success && options.output) { spinner.succeed( `Successfully wrote output to ${colors.green(options.output)}.`, ); } }); // cSpell: ignore sigspec
cli/webfinger.ts +12 −1 Original line number Diff line number Diff line import { Command } from "@cliffy/command"; import { Command, ValidationError } from "@cliffy/command"; import { toAcctUrl } from "@fedify/fedify/vocab"; import { lookupWebFinger } from "@fedify/fedify/webfinger"; import ora from "ora"; Loading @@ -17,7 +17,18 @@ export const command = new Command() "-p, --allow-private-address", "Allow private IP addresses in the URL.", ) .option( "--max-redirection <maxRedirection:integer>", "Maximum number of redirections to follow.", { default: 5 }, ) .action(async (options, ...resources: string[]) => { if (options.maxRedirection < 0) { // Validate maxRedirection option throw new ValidationError( `Option --max-redirection must be greater than or equal to 0, but got ${options.maxRedirection}.`, ); } for (const resource of resources) { const spinner = ora({ // Create a spinner for the lookup process text: `Looking up WebFinger for ${resource}`, Loading