From 99d165cd064d815f46b82e647fe08be0ed426329 Mon Sep 17 00:00:00 2001 From: Rolf Koenders Date: Sun, 21 Jun 2026 11:04:09 +0200 Subject: [PATCH 1/2] feat: add keeply export command with JSON and HTML format support New keeply export command downloads all bookmarks to stdout (default) or a file via --output. Supports --format json (default) and --format html for browser-importable Netscape format. Composable with pipes: keeply export | jq '.[].url'. Closes Keeply-link/docs#3 --- src/commands/export.spec.ts | 205 ++++++++++++++++++++++++++++++++++++ src/commands/export.ts | 60 +++++++++++ src/index.ts | 50 +++++---- 3 files changed, 293 insertions(+), 22 deletions(-) create mode 100644 src/commands/export.spec.ts create mode 100644 src/commands/export.ts 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..9b8da3b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,27 +1,28 @@ #!/usr/bin/env node -import { Command } from 'commander'; -import chalk from 'chalk'; -import { createRequire } from 'module'; -import { registerLoginCommand } from './commands/login.js'; -import { registerConfigCommands } from './commands/config.js'; -import { registerListCommand } from './commands/list.js'; -import { registerGetCommand } from './commands/get.js'; -import { registerAddCommand } from './commands/add.js'; -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 { checkForUpdate } from './update-check.js'; -import { sendTelemetry } from './telemetry.js'; +import { Command } from "commander"; +import chalk from "chalk"; +import { createRequire } from "module"; +import { registerLoginCommand } from "./commands/login.js"; +import { registerConfigCommands } from "./commands/config.js"; +import { registerListCommand } from "./commands/list.js"; +import { registerGetCommand } from "./commands/get.js"; +import { registerAddCommand } from "./commands/add.js"; +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"; const require = createRequire(import.meta.url); -const { version } = require('../package.json') as { version: string }; +const { version } = require("../package.json") as { version: string }; const program = new Command(); program - .name('keeply') - .description('Keeply CLI — manage your bookmarks from the terminal') + .name("keeply") + .description("Keeply CLI — manage your bookmarks from the terminal") .version(version); registerLoginCommand(program); @@ -33,8 +34,9 @@ registerUpdateCommand(program); registerSearchCommand(program); registerFoldersCommand(program); registerTagsCommand(program); +registerExportCommand(program); -program.hook('postAction', async (_thisCommand, actionCommand) => { +program.hook("postAction", async (_thisCommand, actionCommand) => { // Build full command path, e.g. "config set-key", "list", "add" const parts: string[] = [actionCommand.name()]; let parent = actionCommand.parent; @@ -42,16 +44,20 @@ program.hook('postAction', async (_thisCommand, actionCommand) => { parts.unshift(parent.name()); parent = parent.parent; } - const commandPath = parts.join(' '); + const commandPath = parts.join(" "); 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}`)); - console.log(chalk.dim(' brew upgrade keeply or npm install -g @keeply-link/cli\n')); + if (updateResult.status === "fulfilled" && updateResult.value) { + console.log( + chalk.yellow(`\n Update available: ${version} → ${updateResult.value}`), + ); + console.log( + chalk.dim(" brew upgrade keeply or npm install -g @keeply-link/cli\n"), + ); } }); From 6b873833d6d60c8f708848cb05123bd2dcf49578 Mon Sep 17 00:00:00 2001 From: Rolf Koenders Date: Sun, 21 Jun 2026 11:22:51 +0200 Subject: [PATCH 2/2] fix: add .prettierrc and clean up index.ts quote churn The CLI project had no prettier config, so the pre-commit hook reformatted index.ts from single quotes (existing style) to double quotes (prettier default). Added .prettierrc matching the project standard (singleQuote: true) so future changes stay consistent. Now the only diff in index.ts vs main is the registerExportCommand import and call. --- .prettierrc | 8 ++++++++ src/index.ts | 56 +++++++++++++++++++++------------------------------- 2 files changed, 31 insertions(+), 33 deletions(-) create mode 100644 .prettierrc 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/index.ts b/src/index.ts index 9b8da3b..3317a37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,29 +1,26 @@ #!/usr/bin/env node -import { Command } from "commander"; -import chalk from "chalk"; -import { createRequire } from "module"; -import { registerLoginCommand } from "./commands/login.js"; -import { registerConfigCommands } from "./commands/config.js"; -import { registerListCommand } from "./commands/list.js"; -import { registerGetCommand } from "./commands/get.js"; -import { registerAddCommand } from "./commands/add.js"; -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"; +import { Command } from 'commander'; +import chalk from 'chalk'; +import { createRequire } from 'module'; +import { registerLoginCommand } from './commands/login.js'; +import { registerConfigCommands } from './commands/config.js'; +import { registerListCommand } from './commands/list.js'; +import { registerGetCommand } from './commands/get.js'; +import { registerAddCommand } from './commands/add.js'; +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'; const require = createRequire(import.meta.url); -const { version } = require("../package.json") as { version: string }; +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); @@ -36,7 +33,7 @@ registerFoldersCommand(program); registerTagsCommand(program); registerExportCommand(program); -program.hook("postAction", async (_thisCommand, actionCommand) => { +program.hook('postAction', async (_thisCommand, actionCommand) => { // Build full command path, e.g. "config set-key", "list", "add" const parts: string[] = [actionCommand.name()]; let parent = actionCommand.parent; @@ -44,20 +41,13 @@ program.hook("postAction", async (_thisCommand, actionCommand) => { parts.unshift(parent.name()); parent = parent.parent; } - const commandPath = parts.join(" "); + 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}`), - ); - console.log( - chalk.dim(" brew upgrade keeply or npm install -g @keeply-link/cli\n"), - ); + if (updateResult.status === 'fulfilled' && updateResult.value) { + console.log(chalk.yellow(`\n Update available: ${version} → ${updateResult.value}`)); + console.log(chalk.dim(' brew upgrade keeply or npm install -g @keeply-link/cli\n')); } });