@kyoji2/raindrop-cli 0.1.1

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.
@@ -0,0 +1,114 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ export interface Config {
6
+ token: string;
7
+ }
8
+
9
+ export class ConfigError extends Error {
10
+ constructor(message: string) {
11
+ super(message);
12
+ this.name = "ConfigError";
13
+ }
14
+ }
15
+
16
+ const CONFIG_DIR = join(homedir(), ".config", "raindrop-cli");
17
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
18
+
19
+ function isValidConfig(data: unknown): data is Config {
20
+ if (typeof data !== "object" || data === null) return false;
21
+ const obj = data as Record<string, unknown>;
22
+ return typeof obj.token === "string" && obj.token.length > 0;
23
+ }
24
+
25
+ export async function loadConfig(): Promise<Config | null> {
26
+ try {
27
+ const file = Bun.file(CONFIG_FILE);
28
+ const exists = await file.exists();
29
+ if (!exists) return null;
30
+
31
+ const text = await file.text();
32
+ if (!text.trim()) return null;
33
+
34
+ let data: unknown;
35
+ try {
36
+ data = JSON.parse(text);
37
+ } catch {
38
+ throw new ConfigError(
39
+ `Invalid JSON in config file: ${CONFIG_FILE}. Delete the file and run 'raindrop login' again.`,
40
+ );
41
+ }
42
+
43
+ if (!isValidConfig(data)) {
44
+ throw new ConfigError(
45
+ `Invalid config format in ${CONFIG_FILE}. Expected { "token": "..." }. Delete the file and run 'raindrop login' again.`,
46
+ );
47
+ }
48
+
49
+ return data;
50
+ } catch (error) {
51
+ if (error instanceof ConfigError) throw error;
52
+ return null;
53
+ }
54
+ }
55
+
56
+ export function loadConfigSync(): Config | null {
57
+ try {
58
+ if (!existsSync(CONFIG_FILE)) return null;
59
+
60
+ const text = readFileSync(CONFIG_FILE, "utf-8");
61
+ if (!text.trim()) return null;
62
+
63
+ const data = JSON.parse(text);
64
+ if (!isValidConfig(data)) return null;
65
+
66
+ return data;
67
+ } catch {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ export async function saveConfig(config: Config): Promise<void> {
73
+ if (!config.token || typeof config.token !== "string") {
74
+ throw new ConfigError("Invalid token: token must be a non-empty string");
75
+ }
76
+
77
+ await Bun.$`mkdir -p ${CONFIG_DIR}`;
78
+ await Bun.write(CONFIG_FILE, JSON.stringify(config, null, 2));
79
+ }
80
+
81
+ export async function deleteConfig(): Promise<void> {
82
+ try {
83
+ const file = Bun.file(CONFIG_FILE);
84
+ if (await file.exists()) {
85
+ await Bun.$`rm -f ${CONFIG_FILE}`;
86
+ }
87
+ } catch {}
88
+ }
89
+
90
+ export function getToken(): string | null {
91
+ const envToken = process.env.RAINDROP_TOKEN;
92
+ if (envToken) {
93
+ if (envToken.trim().length === 0) {
94
+ return null;
95
+ }
96
+ return envToken.trim();
97
+ }
98
+
99
+ const config = loadConfigSync();
100
+ return config?.token || null;
101
+ }
102
+
103
+ export async function getTokenAsync(): Promise<string | null> {
104
+ const envToken = process.env.RAINDROP_TOKEN;
105
+ if (envToken) {
106
+ if (envToken.trim().length === 0) {
107
+ return null;
108
+ }
109
+ return envToken.trim();
110
+ }
111
+
112
+ const config = await loadConfig();
113
+ return config?.token || null;
114
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./config";
2
+ export * from "./output";
3
+ export * from "./spinner";
4
+ export * from "./tempfile";
@@ -0,0 +1,37 @@
1
+ import { encode as encodeToon } from "@toon-format/toon";
2
+
3
+ export type OutputFormat = "json" | "toon";
4
+
5
+ export interface GlobalOptions {
6
+ dryRun: boolean;
7
+ format: OutputFormat;
8
+ }
9
+
10
+ export { encodeToon };
11
+
12
+ /**
13
+ * CLI-specific error that should result in a non-zero exit code.
14
+ * Thrown instead of calling process.exit() directly for testability.
15
+ */
16
+ export class CLIError extends Error {
17
+ constructor(
18
+ message: string,
19
+ public statusCode: number,
20
+ public hint?: string,
21
+ ) {
22
+ super(message);
23
+ this.name = "CLIError";
24
+ }
25
+ }
26
+
27
+ export function output(data: unknown, format: OutputFormat): void {
28
+ if (format === "toon") {
29
+ console.log(encodeToon(data));
30
+ } else {
31
+ console.log(JSON.stringify(data, null, 2));
32
+ }
33
+ }
34
+
35
+ export function outputError(error: string, statusCode: number, hint?: string, _format?: OutputFormat): never {
36
+ throw new CLIError(error, statusCode, hint);
37
+ }
@@ -0,0 +1,35 @@
1
+ import ora, { type Ora } from "ora";
2
+
3
+ export function startSpinner(text: string): Ora {
4
+ return ora(text).start();
5
+ }
6
+
7
+ export function stopSpinner(spinner: Ora | null, success?: boolean, text?: string): void {
8
+ if (!spinner) return;
9
+
10
+ if (success === true) {
11
+ spinner.succeed(text);
12
+ } else if (success === false) {
13
+ spinner.fail(text);
14
+ } else {
15
+ spinner.stop();
16
+ }
17
+ }
18
+
19
+ export function updateSpinner(spinner: Ora | null, text: string): void {
20
+ if (spinner) {
21
+ spinner.text = text;
22
+ }
23
+ }
24
+
25
+ export async function withSpinner<T>(text: string, fn: () => Promise<T>, successText?: string): Promise<T> {
26
+ const spinner = ora(text).start();
27
+ try {
28
+ const result = await fn();
29
+ spinner.succeed(successText ?? text);
30
+ return result;
31
+ } catch (error) {
32
+ spinner.fail();
33
+ throw error;
34
+ }
35
+ }
@@ -0,0 +1,6 @@
1
+ import { tmpdir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ export function getTempFilePath(prefix: string, ext: string): string {
5
+ return join(tmpdir(), `${prefix}-${crypto.randomUUID()}${ext}`);
6
+ }