Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .prettierrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"semi": true,
"singleQuote": true,
"printWidth": 120,
"tabWidth": 2,
"trailingComma": "all",
"endOfLine": "lf"
}
205 changes: 205 additions & 0 deletions src/commands/export.spec.ts
Original file line number Diff line number Diff line change
@@ -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("<!DOCTYPE html><html></html>"),
);

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("<!DOCTYPE html><html></html>"),
);

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<string, string>)["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 <api-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");
});
});
60 changes: 60 additions & 0 deletions src/commands/export.ts
Original file line number Diff line number Diff line change
@@ -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 <format>", "Output format: json or html", "json")
.option("--output <file>", "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);
}
});
}
12 changes: 4 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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);
Expand All @@ -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"
Expand All @@ -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}`));
Expand Down
Loading