@kyoji2/raindrop-cli 0.1.1 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/dist/index.js +36 -16
- package/package.json +1 -1
- package/src/api/client.ts +9 -5
- package/src/commands/collections.ts +5 -4
- package/src/index.ts +15 -1
- package/src/utils/config.ts +18 -10
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# raindrop-cli
|
|
2
2
|
|
|
3
|
-
AI-native CLI for Raindrop.io. Built with TypeScript and
|
|
3
|
+
AI-native CLI for Raindrop.io. Built with TypeScript, using Bun for tooling and Node standard APIs in the codebase.
|
|
4
4
|
|
|
5
5
|
Designed for AI agents and automation scripts. **TOON** format for maximum token efficiency, with optional JSON output for standard integrations.
|
|
6
6
|
|
|
@@ -12,6 +12,11 @@ Designed for AI agents and automation scripts. **TOON** format for maximum token
|
|
|
12
12
|
- **Batch Operations:** Bulk update or delete bookmarks efficiently
|
|
13
13
|
- **Dry Run Mode:** Safe account management with `--dry-run` flag
|
|
14
14
|
|
|
15
|
+
## Runtime Notes
|
|
16
|
+
|
|
17
|
+
- Prefer Node standard APIs (`node:fs`, `node:timers/promises`, `node:child_process`) over `Bun.*` in source code.
|
|
18
|
+
- Tests still run with `bun test`.
|
|
19
|
+
|
|
15
20
|
## Installation
|
|
16
21
|
|
|
17
22
|
```bash
|
package/dist/index.js
CHANGED
|
@@ -2121,6 +2121,9 @@ var require_commander = __commonJS((exports) => {
|
|
|
2121
2121
|
exports.InvalidOptionArgumentError = InvalidArgumentError;
|
|
2122
2122
|
});
|
|
2123
2123
|
|
|
2124
|
+
// src/index.ts
|
|
2125
|
+
import { readFile as readFile3 } from "fs/promises";
|
|
2126
|
+
|
|
2124
2127
|
// node_modules/commander/esm.mjs
|
|
2125
2128
|
var import__ = __toESM(require_commander(), 1);
|
|
2126
2129
|
var {
|
|
@@ -2137,6 +2140,12 @@ var {
|
|
|
2137
2140
|
Help
|
|
2138
2141
|
} = import__.default;
|
|
2139
2142
|
|
|
2143
|
+
// src/api/client.ts
|
|
2144
|
+
import { Blob } from "buffer";
|
|
2145
|
+
import { readFile } from "fs/promises";
|
|
2146
|
+
import { basename } from "path";
|
|
2147
|
+
import { setTimeout as delay } from "timers/promises";
|
|
2148
|
+
|
|
2140
2149
|
// node_modules/zod/v4/classic/external.js
|
|
2141
2150
|
var exports_external = {};
|
|
2142
2151
|
__export(exports_external, {
|
|
@@ -15898,7 +15907,7 @@ class RaindropAPI {
|
|
|
15898
15907
|
throw new RaindropError("Rate limit exceeded. Maximum retries reached.", 429, "Wait a few minutes before trying again.");
|
|
15899
15908
|
}
|
|
15900
15909
|
this.logger.warn(`[${method} ${path}] Rate limited. Retrying in ${Math.round(backoff / 1000)}s (attempt ${attempt}/${RaindropAPI.MAX_RETRIES})`);
|
|
15901
|
-
await
|
|
15910
|
+
await delay(backoff);
|
|
15902
15911
|
continue;
|
|
15903
15912
|
}
|
|
15904
15913
|
if (response.status >= 500) {
|
|
@@ -15908,7 +15917,7 @@ class RaindropAPI {
|
|
|
15908
15917
|
}
|
|
15909
15918
|
const backoff = calculateBackoff(attempt);
|
|
15910
15919
|
this.logger.warn(`[${method} ${path}] Server error ${response.status}. Retrying in ${Math.round(backoff / 1000)}s (attempt ${attempt}/${RaindropAPI.MAX_RETRIES})`);
|
|
15911
|
-
await
|
|
15920
|
+
await delay(backoff);
|
|
15912
15921
|
continue;
|
|
15913
15922
|
}
|
|
15914
15923
|
if (!response.ok) {
|
|
@@ -15941,7 +15950,7 @@ class RaindropAPI {
|
|
|
15941
15950
|
}
|
|
15942
15951
|
const backoff = calculateBackoff(attempt);
|
|
15943
15952
|
this.logger.warn(`[${method} ${path}] Network error. Retrying in ${Math.round(backoff / 1000)}s (attempt ${attempt}/${RaindropAPI.MAX_RETRIES})`);
|
|
15944
|
-
await
|
|
15953
|
+
await delay(backoff);
|
|
15945
15954
|
}
|
|
15946
15955
|
}
|
|
15947
15956
|
throw new RaindropError("Maximum retries exceeded", 504);
|
|
@@ -16034,9 +16043,9 @@ class RaindropAPI {
|
|
|
16034
16043
|
this.logger.log(`File: ${filePath}`);
|
|
16035
16044
|
return { _id: id, title: "Dry Run Icon", count: 0 };
|
|
16036
16045
|
}
|
|
16037
|
-
const
|
|
16046
|
+
const fileBuffer = await readFile(filePath);
|
|
16038
16047
|
const formData = new FormData;
|
|
16039
|
-
formData.append("cover",
|
|
16048
|
+
formData.append("cover", new Blob([fileBuffer]), basename(filePath));
|
|
16040
16049
|
const response = await fetch(`${RaindropAPI.BASE_URL}/collection/${id}/cover`, {
|
|
16041
16050
|
method: "PUT",
|
|
16042
16051
|
headers: { Authorization: `Bearer ${this.token}` },
|
|
@@ -16167,6 +16176,7 @@ class RaindropAPI {
|
|
|
16167
16176
|
}
|
|
16168
16177
|
// src/utils/config.ts
|
|
16169
16178
|
import { existsSync, readFileSync } from "fs";
|
|
16179
|
+
import { mkdir, readFile as readFile2, rm, writeFile } from "fs/promises";
|
|
16170
16180
|
import { homedir } from "os";
|
|
16171
16181
|
import { join } from "path";
|
|
16172
16182
|
|
|
@@ -16203,15 +16213,12 @@ async function saveConfig(config2) {
|
|
|
16203
16213
|
if (!config2.token || typeof config2.token !== "string") {
|
|
16204
16214
|
throw new ConfigError("Invalid token: token must be a non-empty string");
|
|
16205
16215
|
}
|
|
16206
|
-
await
|
|
16207
|
-
await
|
|
16216
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
16217
|
+
await writeFile(CONFIG_FILE, JSON.stringify(config2, null, 2));
|
|
16208
16218
|
}
|
|
16209
16219
|
async function deleteConfig() {
|
|
16210
16220
|
try {
|
|
16211
|
-
|
|
16212
|
-
if (await file2.exists()) {
|
|
16213
|
-
await Bun.$`rm -f ${CONFIG_FILE}`;
|
|
16214
|
-
}
|
|
16221
|
+
await rm(CONFIG_FILE, { force: true });
|
|
16215
16222
|
} catch {}
|
|
16216
16223
|
}
|
|
16217
16224
|
function getToken() {
|
|
@@ -19900,6 +19907,9 @@ async function cmdBatchDelete(options) {
|
|
|
19900
19907
|
const success3 = await withSpinner(`Deleting ${ids.length} bookmark(s)...`, () => api2.batchDeleteRaindrops(collectionId, ids));
|
|
19901
19908
|
output({ success: success3 }, options.format);
|
|
19902
19909
|
}
|
|
19910
|
+
// src/commands/collections.ts
|
|
19911
|
+
import { rm as rm2, writeFile as writeFile2 } from "fs/promises";
|
|
19912
|
+
|
|
19903
19913
|
// src/utils/tempfile.ts
|
|
19904
19914
|
import { tmpdir } from "os";
|
|
19905
19915
|
import { join as join2 } from "path";
|
|
@@ -20027,13 +20037,13 @@ async function cmdCollectionCover(options) {
|
|
|
20027
20037
|
outputError(`Failed to download image: ${response.status}`, response.status);
|
|
20028
20038
|
}
|
|
20029
20039
|
filePath = getTempFilePath("raindrop_cover", ".png");
|
|
20030
|
-
await
|
|
20040
|
+
await writeFile2(filePath, Buffer.from(await response.arrayBuffer()));
|
|
20031
20041
|
stopSpinner(spinner, true, "Downloaded");
|
|
20032
20042
|
isTemp = true;
|
|
20033
20043
|
}
|
|
20034
20044
|
const result = await withSpinner("Uploading cover...", () => api2.uploadCollectionCover(id, filePath));
|
|
20035
20045
|
if (isTemp) {
|
|
20036
|
-
await
|
|
20046
|
+
await rm2(filePath, { force: true });
|
|
20037
20047
|
}
|
|
20038
20048
|
output(result, options.format);
|
|
20039
20049
|
}
|
|
@@ -20061,10 +20071,10 @@ async function cmdCollectionSetIcon(options) {
|
|
|
20061
20071
|
outputError(`Failed to download icon: ${response.status}`, response.status);
|
|
20062
20072
|
}
|
|
20063
20073
|
const filePath = getTempFilePath("raindrop_icon", ".png");
|
|
20064
|
-
await
|
|
20074
|
+
await writeFile2(filePath, Buffer.from(await response.arrayBuffer()));
|
|
20065
20075
|
stopSpinner(spinner, true, "Downloaded");
|
|
20066
20076
|
const result = await withSpinner("Uploading icon...", () => api2.uploadCollectionCover(id, filePath));
|
|
20067
|
-
await
|
|
20077
|
+
await rm2(filePath, { force: true });
|
|
20068
20078
|
output(result, options.format);
|
|
20069
20079
|
}
|
|
20070
20080
|
// src/commands/overview.ts
|
|
@@ -20254,7 +20264,17 @@ async function cmdTagRename(options) {
|
|
|
20254
20264
|
output({ success: success3 }, options.format);
|
|
20255
20265
|
}
|
|
20256
20266
|
// src/index.ts
|
|
20257
|
-
|
|
20267
|
+
async function getPackageVersion() {
|
|
20268
|
+
try {
|
|
20269
|
+
const text = await readFile3(new URL("../package.json", import.meta.url), "utf-8");
|
|
20270
|
+
const pkg = JSON.parse(text);
|
|
20271
|
+
if (pkg && typeof pkg === "object" && typeof pkg.version === "string" && pkg.version.trim().length > 0) {
|
|
20272
|
+
return pkg.version;
|
|
20273
|
+
}
|
|
20274
|
+
} catch {}
|
|
20275
|
+
return "0.0.0";
|
|
20276
|
+
}
|
|
20277
|
+
var VERSION = await getPackageVersion();
|
|
20258
20278
|
function getGlobalOptions(cmd) {
|
|
20259
20279
|
const opts = cmd.optsWithGlobals();
|
|
20260
20280
|
return {
|
package/package.json
CHANGED
package/src/api/client.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { Blob } from "node:buffer";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
1
5
|
import type { z } from "zod";
|
|
2
6
|
import {
|
|
3
7
|
CollectionResponseSchema,
|
|
@@ -181,7 +185,7 @@ export class RaindropAPI {
|
|
|
181
185
|
this.logger.warn(
|
|
182
186
|
`[${method} ${path}] Rate limited. Retrying in ${Math.round(backoff / 1000)}s (attempt ${attempt}/${RaindropAPI.MAX_RETRIES})`,
|
|
183
187
|
);
|
|
184
|
-
await
|
|
188
|
+
await delay(backoff);
|
|
185
189
|
continue;
|
|
186
190
|
}
|
|
187
191
|
|
|
@@ -199,7 +203,7 @@ export class RaindropAPI {
|
|
|
199
203
|
this.logger.warn(
|
|
200
204
|
`[${method} ${path}] Server error ${response.status}. Retrying in ${Math.round(backoff / 1000)}s (attempt ${attempt}/${RaindropAPI.MAX_RETRIES})`,
|
|
201
205
|
);
|
|
202
|
-
await
|
|
206
|
+
await delay(backoff);
|
|
203
207
|
continue;
|
|
204
208
|
}
|
|
205
209
|
|
|
@@ -245,7 +249,7 @@ export class RaindropAPI {
|
|
|
245
249
|
this.logger.warn(
|
|
246
250
|
`[${method} ${path}] Network error. Retrying in ${Math.round(backoff / 1000)}s (attempt ${attempt}/${RaindropAPI.MAX_RETRIES})`,
|
|
247
251
|
);
|
|
248
|
-
await
|
|
252
|
+
await delay(backoff);
|
|
249
253
|
}
|
|
250
254
|
}
|
|
251
255
|
|
|
@@ -355,9 +359,9 @@ export class RaindropAPI {
|
|
|
355
359
|
return { _id: id, title: "Dry Run Icon", count: 0 };
|
|
356
360
|
}
|
|
357
361
|
|
|
358
|
-
const
|
|
362
|
+
const fileBuffer = await readFile(filePath);
|
|
359
363
|
const formData = new FormData();
|
|
360
|
-
formData.append("cover",
|
|
364
|
+
formData.append("cover", new Blob([fileBuffer]), basename(filePath));
|
|
361
365
|
|
|
362
366
|
const response = await fetch(`${RaindropAPI.BASE_URL}/collection/${id}/cover`, {
|
|
363
367
|
method: "PUT",
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { rm, writeFile } from "node:fs/promises";
|
|
1
2
|
import type { CollectionCreate, CollectionUpdate } from "../api";
|
|
2
3
|
import { type GlobalOptions, output, outputError } from "../utils/output";
|
|
3
4
|
import { startSpinner, stopSpinner, withSpinner } from "../utils/spinner";
|
|
@@ -196,7 +197,7 @@ export async function cmdCollectionCover(options: CollectionCoverOptions): Promi
|
|
|
196
197
|
outputError(`Failed to download image: ${response.status}`, response.status);
|
|
197
198
|
}
|
|
198
199
|
filePath = getTempFilePath("raindrop_cover", ".png");
|
|
199
|
-
await
|
|
200
|
+
await writeFile(filePath, Buffer.from(await response.arrayBuffer()));
|
|
200
201
|
stopSpinner(spinner, true, "Downloaded");
|
|
201
202
|
isTemp = true;
|
|
202
203
|
}
|
|
@@ -204,7 +205,7 @@ export async function cmdCollectionCover(options: CollectionCoverOptions): Promi
|
|
|
204
205
|
const result = await withSpinner("Uploading cover...", () => api.uploadCollectionCover(id, filePath));
|
|
205
206
|
|
|
206
207
|
if (isTemp) {
|
|
207
|
-
await
|
|
208
|
+
await rm(filePath, { force: true });
|
|
208
209
|
}
|
|
209
210
|
|
|
210
211
|
output(result, options.format);
|
|
@@ -246,12 +247,12 @@ export async function cmdCollectionSetIcon(options: CollectionSetIconOptions): P
|
|
|
246
247
|
}
|
|
247
248
|
|
|
248
249
|
const filePath = getTempFilePath("raindrop_icon", ".png");
|
|
249
|
-
await
|
|
250
|
+
await writeFile(filePath, Buffer.from(await response.arrayBuffer()));
|
|
250
251
|
stopSpinner(spinner, true, "Downloaded");
|
|
251
252
|
|
|
252
253
|
const result = await withSpinner("Uploading icon...", () => api.uploadCollectionCover(id, filePath));
|
|
253
254
|
|
|
254
|
-
await
|
|
255
|
+
await rm(filePath, { force: true });
|
|
255
256
|
|
|
256
257
|
output(result, options.format);
|
|
257
258
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
3
4
|
import { Command } from "commander";
|
|
4
5
|
import { RaindropError } from "./api";
|
|
5
6
|
import {
|
|
@@ -35,7 +36,20 @@ import {
|
|
|
35
36
|
} from "./commands";
|
|
36
37
|
import { CLIError, type GlobalOptions, type OutputFormat } from "./utils";
|
|
37
38
|
|
|
38
|
-
|
|
39
|
+
async function getPackageVersion(): Promise<string> {
|
|
40
|
+
try {
|
|
41
|
+
const text = await readFile(new URL("../package.json", import.meta.url), "utf-8");
|
|
42
|
+
const pkg = JSON.parse(text) as { version?: unknown };
|
|
43
|
+
|
|
44
|
+
if (pkg && typeof pkg === "object" && typeof pkg.version === "string" && pkg.version.trim().length > 0) {
|
|
45
|
+
return pkg.version;
|
|
46
|
+
}
|
|
47
|
+
} catch {}
|
|
48
|
+
|
|
49
|
+
return "0.0.0";
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const VERSION = await getPackageVersion();
|
|
39
53
|
|
|
40
54
|
function getGlobalOptions(cmd: Command): GlobalOptions {
|
|
41
55
|
const opts = cmd.optsWithGlobals();
|
package/src/utils/config.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
3
|
import { homedir } from "node:os";
|
|
3
4
|
import { join } from "node:path";
|
|
4
5
|
|
|
@@ -22,13 +23,23 @@ function isValidConfig(data: unknown): data is Config {
|
|
|
22
23
|
return typeof obj.token === "string" && obj.token.length > 0;
|
|
23
24
|
}
|
|
24
25
|
|
|
26
|
+
function isNotFoundError(error: unknown): error is { code?: string } {
|
|
27
|
+
if (typeof error !== "object" || error === null || !("code" in error)) {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return (error as { code?: string }).code === "ENOENT";
|
|
31
|
+
}
|
|
32
|
+
|
|
25
33
|
export async function loadConfig(): Promise<Config | null> {
|
|
26
34
|
try {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
35
|
+
let text = "";
|
|
36
|
+
try {
|
|
37
|
+
text = await readFile(CONFIG_FILE, "utf-8");
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if (isNotFoundError(error)) return null;
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
30
42
|
|
|
31
|
-
const text = await file.text();
|
|
32
43
|
if (!text.trim()) return null;
|
|
33
44
|
|
|
34
45
|
let data: unknown;
|
|
@@ -74,16 +85,13 @@ export async function saveConfig(config: Config): Promise<void> {
|
|
|
74
85
|
throw new ConfigError("Invalid token: token must be a non-empty string");
|
|
75
86
|
}
|
|
76
87
|
|
|
77
|
-
await
|
|
78
|
-
await
|
|
88
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
89
|
+
await writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
79
90
|
}
|
|
80
91
|
|
|
81
92
|
export async function deleteConfig(): Promise<void> {
|
|
82
93
|
try {
|
|
83
|
-
|
|
84
|
-
if (await file.exists()) {
|
|
85
|
-
await Bun.$`rm -f ${CONFIG_FILE}`;
|
|
86
|
-
}
|
|
94
|
+
await rm(CONFIG_FILE, { force: true });
|
|
87
95
|
} catch {}
|
|
88
96
|
}
|
|
89
97
|
|