diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..df5fe30 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "printWidth": 120, + "tabWidth": 2, + "trailingComma": "all", + "endOfLine": "lf" +} diff --git a/src/commands/export.spec.ts b/src/commands/export.spec.ts new file mode 100644 index 0000000..e07278f --- /dev/null +++ b/src/commands/export.spec.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { Command } from "commander"; +import { writeFile } from "fs/promises"; + +// Default: valid config +let mockRequireConfig = () => ({ + apiKey: "test-key", + apiUrl: "https://api.test.com", +}); + +vi.mock("../config.js", () => ({ + requireConfig: () => mockRequireConfig(), +})); + +vi.mock("fs/promises", () => ({ + writeFile: vi.fn(), +})); + +const mockFetch = vi.fn(); +vi.stubGlobal("fetch", mockFetch); + +import { registerExportCommand } from "./export.js"; + +function mockTextResponse(body: string, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + text: () => Promise.resolve(body), + json: () => Promise.resolve({ message: `HTTP ${status}` }), + }; +} + +function mockErrorResponse(message: string, status = 500) { + return { + ok: false, + status, + text: () => Promise.resolve(""), + json: () => Promise.resolve({ message }), + }; +} + +describe("export command", () => { + let program: Command; + let stdout: string[]; + let stderr: string[]; + + beforeEach(() => { + mockFetch.mockReset(); + vi.mocked(writeFile).mockReset(); + mockRequireConfig = () => ({ + apiKey: "test-key", + apiUrl: "https://api.test.com", + }); + + program = new Command(); + program.exitOverride(); + stdout = []; + stderr = []; + + vi.spyOn(process.stdout, "write").mockImplementation((data) => { + stdout.push(String(data)); + return true; + }); + vi.spyOn(process.stderr, "write").mockImplementation((data) => { + stderr.push(String(data)); + return true; + }); + vi.spyOn(process, "exit").mockImplementation((() => { + throw new Error("process.exit"); + }) as () => never); + + registerExportCommand(program); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("calls GET /bookmarks/export for --format json (default)", async () => { + mockFetch.mockResolvedValue(mockTextResponse("[]")); + + await program.parseAsync(["node", "keeply", "export"]); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [calledUrl] = mockFetch.mock.calls[0] as [string, ...unknown[]]; + expect(calledUrl).toBe("https://api.test.com/bookmarks/export"); + }); + + it("calls GET /bookmarks/export/html for --format html", async () => { + mockFetch.mockResolvedValue( + mockTextResponse(""), + ); + + await program.parseAsync(["node", "keeply", "export", "--format", "html"]); + + expect(mockFetch).toHaveBeenCalledOnce(); + const [calledUrl] = mockFetch.mock.calls[0] as [string, ...unknown[]]; + expect(calledUrl).toBe("https://api.test.com/bookmarks/export/html"); + }); + + it("adds includeTrashed=true query param when --include-trashed is set", async () => { + mockFetch.mockResolvedValue(mockTextResponse("[]")); + + await program.parseAsync(["node", "keeply", "export", "--include-trashed"]); + + const [calledUrl] = mockFetch.mock.calls[0] as [string, ...unknown[]]; + expect(calledUrl).toBe( + "https://api.test.com/bookmarks/export?includeTrashed=true", + ); + }); + + it("adds includeTrashed=true for html format with --include-trashed", async () => { + mockFetch.mockResolvedValue( + mockTextResponse(""), + ); + + await program.parseAsync([ + "node", + "keeply", + "export", + "--format", + "html", + "--include-trashed", + ]); + + const [calledUrl] = mockFetch.mock.calls[0] as [string, ...unknown[]]; + expect(calledUrl).toBe( + "https://api.test.com/bookmarks/export/html?includeTrashed=true", + ); + }); + + it("sends Authorization header with ApiKey scheme", async () => { + mockFetch.mockResolvedValue(mockTextResponse("[]")); + + await program.parseAsync(["node", "keeply", "export"]); + + const [, init] = mockFetch.mock.calls[0] as [string, RequestInit]; + expect((init.headers as Record)["Authorization"]).toBe( + "ApiKey test-key", + ); + }); + + it("writes response body to stdout when no --output given", async () => { + mockFetch.mockResolvedValue(mockTextResponse('[{"id":"bm-1"}]')); + + await program.parseAsync(["node", "keeply", "export"]); + + expect(stdout.join("")).toBe('[{"id":"bm-1"}]'); + }); + + it('writes response body to file and prints "Saved to" on stderr when --output given', async () => { + mockFetch.mockResolvedValue(mockTextResponse('[{"id":"bm-1"}]')); + + await program.parseAsync([ + "node", + "keeply", + "export", + "--output", + "bookmarks.json", + ]); + + expect(vi.mocked(writeFile)).toHaveBeenCalledWith( + "bookmarks.json", + '[{"id":"bm-1"}]', + "utf8", + ); + expect(stderr.join("")).toContain("Saved to bookmarks.json"); + expect(stdout).toHaveLength(0); + }); + + it("prints error to stderr and exits 1 on API error", async () => { + mockFetch.mockResolvedValue(mockErrorResponse("Unauthorized")); + vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + program.parseAsync(["node", "keeply", "export"]), + ).rejects.toThrow("process.exit"); + + const errorOutput = vi + .mocked(console.error) + .mock.calls.map((c) => c.join(" ")) + .join("\n"); + expect(errorOutput).toContain("Unauthorized"); + }); + + it("shows helpful error and exits 1 when no API key is configured", async () => { + mockRequireConfig = () => { + console.error( + "Not configured. Run: keeply login (or: keeply config set-key )", + ); + throw new Error("process.exit"); + }; + vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + program.parseAsync(["node", "keeply", "export"]), + ).rejects.toThrow("process.exit"); + + const errorOutput = vi + .mocked(console.error) + .mock.calls.map((c) => c.join(" ")) + .join("\n"); + expect(errorOutput).toContain("keeply config set-key"); + }); +}); diff --git a/src/commands/export.ts b/src/commands/export.ts new file mode 100644 index 0000000..337a635 --- /dev/null +++ b/src/commands/export.ts @@ -0,0 +1,60 @@ +import { Command } from "commander"; +import { writeFile } from "fs/promises"; +import { requireConfig } from "../config.js"; +import { printError } from "../format.js"; + +interface ExportOptions { + format: "json" | "html"; + output?: string; + includeTrashed?: boolean; +} + +export function registerExportCommand(program: Command): void { + program + .command("export") + .description("Export bookmarks as JSON or HTML") + .option("--format ", "Output format: json or html", "json") + .option("--output ", "Write output to a file instead of stdout") + .option("--include-trashed", "Include soft-deleted bookmarks") + .action(async (opts: ExportOptions) => { + const { apiKey, apiUrl } = requireConfig(); + + const base = apiUrl.replace(/\/$/, ""); + const qs = opts.includeTrashed ? "?includeTrashed=true" : ""; + const path = + opts.format === "html" + ? `/bookmarks/export/html${qs}` + : `/bookmarks/export${qs}`; + const url = `${base}${path}`; + + try { + const res = await fetch(url, { + headers: { Authorization: `ApiKey ${apiKey}` }, + }); + + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { + message?: string; + }; + const msg = Array.isArray(body.message) + ? body.message.join(", ") + : (body.message ?? `HTTP ${res.status}`); + throw new Error(msg); + } + + const text = await res.text(); + + if (opts.output) { + await writeFile(opts.output, text, "utf8"); + process.stderr.write(`Saved to ${opts.output}\n`); + } else { + process.stdout.write(text); + } + } catch (err) { + printError( + err instanceof Error ? err.message : "Failed to export bookmarks", + ); + process.exit(1); + } + }); +} diff --git a/src/index.ts b/src/index.ts index d4bbd94..3317a37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -11,6 +11,7 @@ import { registerUpdateCommand } from './commands/update.js'; import { registerSearchCommand } from './commands/search.js'; import { registerFoldersCommand } from './commands/folders.js'; import { registerTagsCommand } from './commands/tags.js'; +import { registerExportCommand } from './commands/export.js'; import { checkForUpdate } from './update-check.js'; import { sendTelemetry } from './telemetry.js'; @@ -19,10 +20,7 @@ const { version } = require('../package.json') as { version: string }; const program = new Command(); -program - .name('keeply') - .description('Keeply CLI — manage your bookmarks from the terminal') - .version(version); +program.name('keeply').description('Keeply CLI — manage your bookmarks from the terminal').version(version); registerLoginCommand(program); registerConfigCommands(program); @@ -33,6 +31,7 @@ registerUpdateCommand(program); registerSearchCommand(program); registerFoldersCommand(program); registerTagsCommand(program); +registerExportCommand(program); program.hook('postAction', async (_thisCommand, actionCommand) => { // Build full command path, e.g. "config set-key", "list", "add" @@ -44,10 +43,7 @@ program.hook('postAction', async (_thisCommand, actionCommand) => { } const commandPath = parts.join(' '); - const [, updateResult] = await Promise.allSettled([ - sendTelemetry(commandPath, version), - checkForUpdate(version), - ]); + const [, updateResult] = await Promise.allSettled([sendTelemetry(commandPath, version), checkForUpdate(version)]); if (updateResult.status === 'fulfilled' && updateResult.value) { console.log(chalk.yellow(`\n Update available: ${version} → ${updateResult.value}`));