aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/docker.yml2
-rw-r--r--apps/cli/commands/bookmarks.ts112
-rw-r--r--apps/cli/commands/whoami.ts10
-rw-r--r--apps/cli/index.ts29
-rw-r--r--apps/cli/lib/globals.ts17
-rw-r--r--apps/cli/lib/trpc.ts23
-rw-r--r--apps/cli/package.json35
-rw-r--r--apps/cli/tsconfig.json11
-rw-r--r--docker/Dockerfile19
-rw-r--r--docs/docs/09-command-line.md26
-rw-r--r--pnpm-lock.yaml57
11 files changed, 340 insertions, 1 deletions
diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml
index 72a82d33..b9e83afa 100644
--- a/.github/workflows/docker.yml
+++ b/.github/workflows/docker.yml
@@ -10,7 +10,7 @@ jobs:
push:
strategy:
matrix:
- package: [web, workers]
+ package: [web, workers, cli]
runs-on: ubuntu-latest
permissions:
packages: write
diff --git a/apps/cli/commands/bookmarks.ts b/apps/cli/commands/bookmarks.ts
new file mode 100644
index 00000000..1727db22
--- /dev/null
+++ b/apps/cli/commands/bookmarks.ts
@@ -0,0 +1,112 @@
+import * as fs from "node:fs";
+import { Command } from "@commander-js/extra-typings";
+import chalk from "chalk";
+import { getAPIClient } from "lib/trpc";
+
+import type { ZBookmark } from "@hoarder/trpc/types/bookmarks";
+
+export const bookmarkCmd = new Command()
+ .name("bookmarks")
+ .description("Manipulating bookmarks");
+
+function collect<T>(val: T, acc: T[]) {
+ acc.push(val);
+ return acc;
+}
+
+function normalizeBookmark(bookmark: ZBookmark) {
+ const ret = {
+ ...bookmark,
+ tags: bookmark.tags.map((t) => t.name),
+ };
+
+ if (ret.content.type == "link" && ret.content.htmlContent) {
+ if (ret.content.htmlContent.length > 10) {
+ ret.content.htmlContent =
+ ret.content.htmlContent.substring(0, 10) + "... <CROPPED>";
+ }
+ }
+ return ret;
+}
+
+bookmarkCmd
+ .command("add")
+ .description("Creates a new bookmark")
+ .option(
+ "--link <link>",
+ "the link to add. Specify multiple times to add multiple links",
+ collect<string>,
+ [],
+ )
+ .option(
+ "--note <note>",
+ "the note text to add. Specify multiple times to add multiple notes",
+ collect<string>,
+ [],
+ )
+ .option("--stdin", "reads the data from stdin and store it as a note")
+ .action(async (opts) => {
+ const api = getAPIClient();
+
+ const promises = [
+ ...opts.link.map((url) =>
+ api.bookmarks.createBookmark.mutate({ type: "link", url }),
+ ),
+ ...opts.note.map((text) =>
+ api.bookmarks.createBookmark.mutate({ type: "text", text }),
+ ),
+ ];
+
+ if (opts.stdin) {
+ const text = fs.readFileSync(0, "utf-8");
+ promises.push(
+ api.bookmarks.createBookmark.mutate({ type: "text", text }),
+ );
+ }
+
+ const results = await Promise.allSettled(promises);
+
+ for (const res of results) {
+ if (res.status == "fulfilled") {
+ console.log(normalizeBookmark(res.value));
+ } else {
+ console.log(chalk.red(`Error: ${res.reason}`));
+ }
+ }
+ });
+
+bookmarkCmd
+ .command("get")
+ .description("fetch information about a bookmark")
+ .argument("<id>", "The id of the bookmark to get")
+ .action(async (id) => {
+ const api = getAPIClient();
+ const resp = await api.bookmarks.getBookmark.query({ bookmarkId: id });
+ console.log(normalizeBookmark(resp));
+ });
+
+bookmarkCmd
+ .command("list")
+ .description("list all bookmarks")
+ .option(
+ "--include-archived",
+ "If set, archived bookmarks will be fetched as well",
+ false,
+ )
+ .action(async (opts) => {
+ const api = getAPIClient();
+ const resp = await api.bookmarks.getBookmarks.query({
+ archived: opts.includeArchived ? undefined : false,
+ });
+ console.log(resp.bookmarks.map(normalizeBookmark));
+ });
+
+bookmarkCmd
+ .command("delete")
+ .description("delete a bookmark")
+ .argument("<id>", "The id of the bookmark to delete")
+ .action(async (id) => {
+ const api = getAPIClient();
+ await api.bookmarks.deleteBookmark.mutate({ bookmarkId: id });
+ console.log(`Bookmark ${id} got deleted`);
+ });
diff --git a/apps/cli/commands/whoami.ts b/apps/cli/commands/whoami.ts
new file mode 100644
index 00000000..2b32f2f0
--- /dev/null
+++ b/apps/cli/commands/whoami.ts
@@ -0,0 +1,10 @@
+import { Command } from "@commander-js/extra-typings";
+import { getAPIClient } from "lib/trpc";
+
+export const whoamiCmd = new Command()
+ .name("whoami")
+ .description("returns info about the owner of this API key")
+ .action(async () => {
+ const resp = await getAPIClient().users.whoami.query();
+ console.log(resp);
+ });
diff --git a/apps/cli/index.ts b/apps/cli/index.ts
new file mode 100644
index 00000000..03699e55
--- /dev/null
+++ b/apps/cli/index.ts
@@ -0,0 +1,29 @@
+import { Command, Option } from "@commander-js/extra-typings";
+import { bookmarkCmd } from "commands/bookmarks";
+import { whoamiCmd } from "commands/whoami";
+import { setGlobalOptions } from "lib/globals";
+
+const program = new Command()
+ .name("hoarder-cli")
+ .description("A CLI interface to interact with the hoarder api")
+ .addOption(
+ new Option("--api-key <key>", "The API key to interact with the API")
+ .makeOptionMandatory(true)
+ .env("HOARDER_API_KEY"),
+ )
+ .addOption(
+ new Option(
+ "--server-addr <addr>",
+ "The address of the server to connect to",
+ )
+ .makeOptionMandatory(true)
+ .env("HOARDER_SERVER_ADDR"),
+ )
+ .version("0.1.0");
+
+program.addCommand(bookmarkCmd);
+program.addCommand(whoamiCmd);
+
+setGlobalOptions(program.opts());
+
+program.parse();
diff --git a/apps/cli/lib/globals.ts b/apps/cli/lib/globals.ts
new file mode 100644
index 00000000..771136da
--- /dev/null
+++ b/apps/cli/lib/globals.ts
@@ -0,0 +1,17 @@
+export interface GlobalOptions {
+ apiKey: string;
+ serverAddr: string;
+}
+
+export let globalOpts: GlobalOptions | undefined = undefined;
+
+export function setGlobalOptions(opts: GlobalOptions) {
+ globalOpts = opts;
+}
+
+export function getGlobalOptions() {
+ if (!globalOpts) {
+ throw new Error("Global options are not initalized yet");
+ }
+ return globalOpts;
+}
diff --git a/apps/cli/lib/trpc.ts b/apps/cli/lib/trpc.ts
new file mode 100644
index 00000000..6f0dccfe
--- /dev/null
+++ b/apps/cli/lib/trpc.ts
@@ -0,0 +1,23 @@
+import { createTRPCClient, httpBatchLink } from "@trpc/client";
+import superjson from "superjson";
+
+import type { AppRouter } from "@hoarder/trpc/routers/_app";
+
+import { getGlobalOptions } from "./globals";
+
+export function getAPIClient() {
+ const globals = getGlobalOptions();
+ return createTRPCClient<AppRouter>({
+ links: [
+ httpBatchLink({
+ url: `${globals.serverAddr}/api/trpc`,
+ transformer: superjson,
+ headers() {
+ return {
+ authorization: `Bearer ${globals.apiKey}`,
+ };
+ },
+ }),
+ ],
+ });
+}
diff --git a/apps/cli/package.json b/apps/cli/package.json
new file mode 100644
index 00000000..44f7e451
--- /dev/null
+++ b/apps/cli/package.json
@@ -0,0 +1,35 @@
+{
+ "$schema": "https://json.schemastore.org/package.json",
+ "name": "@hoarder/cli",
+ "version": "0.1.0",
+ "private": true,
+ "dependencies": {
+ "@commander-js/extra-typings": "^12.0.1",
+ "@hoarder/trpc": "workspace:^0.1.0",
+ "@hoarder/tsconfig": "workspace:^0.1.0",
+ "@tsconfig/node21": "^21.0.1",
+ "tsx": "^4.7.1",
+ "@trpc/client": "11.0.0-next-beta.308",
+ "@trpc/server": "11.0.0-next-beta.308",
+ "chalk": "^5.3.0",
+ "commander": "^12.0.0",
+ "superjson": "^2.2.1"
+ },
+ "devDependencies": {
+ "@hoarder/eslint-config": "workspace:^0.2.0",
+ "@hoarder/prettier-config": "workspace:^0.1.0"
+ },
+ "scripts": {
+ "run": "tsx index.ts",
+ "lint": "eslint .",
+ "format": "prettier . --ignore-path ../../.prettierignore",
+ "typecheck": "tsc --noEmit"
+ },
+ "eslintConfig": {
+ "root": true,
+ "extends": [
+ "@hoarder/eslint-config/base"
+ ]
+ },
+ "prettier": "@hoarder/prettier-config"
+}
diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json
new file mode 100644
index 00000000..dc71844c
--- /dev/null
+++ b/apps/cli/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "@hoarder/tsconfig/node.json",
+ "include": ["**/*.ts"],
+ "exclude": ["node_modules"],
+ "compilerOptions": {
+ "baseUrl": ".",
+ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
+ "strictNullChecks": true,
+ }
+}
diff --git a/docker/Dockerfile b/docker/Dockerfile
index 05432cbe..8551bbc7 100644
--- a/docker/Dockerfile
+++ b/docker/Dockerfile
@@ -81,3 +81,22 @@ WORKDIR /app/apps/workers
USER root
CMD ["pnpm", "run", "start:prod"]
+
+################# The cli builder ##############
+
+FROM base AS cli_builder
+
+RUN --mount=type=cache,id=pnpm_cli,target=/pnpm/store pnpm deploy --node-linker=isolated --filter @hoarder/cli --prod /prod
+
+################# The cli ##############
+
+FROM --platform=$BUILDPLATFORM node:21-alpine AS cli
+WORKDIR /app
+
+COPY --from=cli_builder /prod apps/cli
+
+RUN corepack enable
+
+WORKDIR /app/apps/cli
+
+ENTRYPOINT ["pnpm" , "exec", "tsx", "index.ts"]
diff --git a/docs/docs/09-command-line.md b/docs/docs/09-command-line.md
new file mode 100644
index 00000000..ca54394c
--- /dev/null
+++ b/docs/docs/09-command-line.md
@@ -0,0 +1,26 @@
+# Command Line Tool (CLI)
+
+Hoarder comes with a simple CLI for those users who want to do more advanced manipulation. Currently, the CLI comes packaged as a docker container. You can run it with:
+
+```
+docker run --rm ghcr.io/mohamedbassem/hoarder-cli --help
+```
+
+To use the CLI, you'll need to get an API key from your hoarder settings. You can validate that it's working by running:
+
+```
+docker run --rm ghcr.io/mohamedbassem/hoarder-cli --api-key <key> --server-addr <addr> whoami
+```
+
+For example:
+
+```
+docker run --rm ghcr.io/mohamedbassem/hoarder-cli --api-key mysupersecretkey --server-addr https://try.hoarder.app whoami
+{
+ id: 'j29gnbzxxd01q74j2lu88tnb',
+ name: 'Test User',
+ email: 'test@gmail.com'
+}
+```
+
+Check the help for the other available commands, but the main usecase for the CLI is to enable mass manipulation of your bookmarks. E.g. mass importing of bookmarks, mass deletions, etc.
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index fc7151ae..9e3c1219 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -135,6 +135,46 @@ importers:
specifier: ^5.1.0
version: 5.1.4(@types/node@20.11.20)
+ apps/cli:
+ dependencies:
+ '@commander-js/extra-typings':
+ specifier: ^12.0.1
+ version: 12.0.1(commander@12.0.0)
+ '@hoarder/trpc':
+ specifier: workspace:^0.1.0
+ version: link:../../packages/trpc
+ '@hoarder/tsconfig':
+ specifier: workspace:^0.1.0
+ version: link:../../tooling/typescript
+ '@trpc/client':
+ specifier: 11.0.0-next-beta.308
+ version: 11.0.0-next-beta.308(@trpc/server@11.0.0-next-beta.308)
+ '@trpc/server':
+ specifier: 11.0.0-next-beta.308
+ version: 11.0.0-next-beta.308
+ '@tsconfig/node21':
+ specifier: ^21.0.1
+ version: 21.0.1
+ chalk:
+ specifier: ^5.3.0
+ version: 5.3.0
+ commander:
+ specifier: ^12.0.0
+ version: 12.0.0
+ superjson:
+ specifier: ^2.2.1
+ version: 2.2.1
+ tsx:
+ specifier: ^4.7.1
+ version: 4.7.1
+ devDependencies:
+ '@hoarder/eslint-config':
+ specifier: workspace:^0.2.0
+ version: link:../../tooling/eslint
+ '@hoarder/prettier-config':
+ specifier: workspace:^0.1.0
+ version: link:../../tooling/prettier
+
apps/landing:
dependencies:
'@radix-ui/react-slot':
@@ -1810,6 +1850,11 @@ packages:
resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==}
engines: {node: '>=0.1.90'}
+ '@commander-js/extra-typings@12.0.1':
+ resolution: {integrity: sha512-OvkMobb1eMqOCuJdbuSin/KJkkZr7n24/UNV+Lcz/0Dhepf3r2p9PaGwpRpAWej7A+gQnny4h8mGhpFl4giKkg==}
+ peerDependencies:
+ commander: ~12.0.0
+
'@crxjs/vite-plugin@1.0.14':
resolution: {integrity: sha512-emOueVCqFRFmpcfT80Xsm4mfuFw9VSp5GY4eh5qeLDeiP81g0hddlobVQCo0pE2ZvNnWbyhLrXEYAaMAXjNL6A==}
engines: {node: '>=14'}
@@ -5180,6 +5225,10 @@ packages:
resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==}
engines: {node: '>=14'}
+ commander@12.0.0:
+ resolution: {integrity: sha512-MwVNWlYjDTtOjX5PiD7o5pK0UrFU/OYgcJfjjK4RaHZETNtjJqrZa9Y9ds88+A+f+d5lv+561eZ+yCKoS3gbAA==}
+ engines: {node: '>=18'}
+
commander@2.20.3:
resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==}
@@ -14071,6 +14120,11 @@ snapshots:
'@colors/colors@1.6.0':
dev: false
+ '@commander-js/extra-typings@12.0.1(commander@12.0.0)':
+ dependencies:
+ commander: 12.0.0
+ dev: false
+
'@crxjs/vite-plugin@1.0.14(vite@5.1.4(@types/node@20.11.20))':
dependencies:
'@rollup/pluginutils': 4.2.1
@@ -19361,6 +19415,9 @@ snapshots:
commander@10.0.1:
dev: false
+ commander@12.0.0:
+ dev: false
+
commander@2.20.3: {}
commander@4.1.1: {}