anyfreebook-cli 0.0.0-stage → 0.1.0

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/src/api.js ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * HTTP client for the ANYFREEBOOK public API (v1).
3
+ *
4
+ * Zero dependencies: Node's global fetch plus AbortSignal.timeout().
5
+ * Every failure mode is converted into a CliError so the caller never has to
6
+ * deal with a raw network exception.
7
+ */
8
+
9
+ import { apiError, networkError } from './errors.js';
10
+ import { USER_AGENT } from './config.js';
11
+
12
+ export class ApiClient {
13
+ constructor({ base, apiKey = '', timeoutMs = 20000, fetchImpl = globalThis.fetch } = {}) {
14
+ if (typeof fetchImpl !== 'function') {
15
+ throw new Error('No fetch implementation available. Node 18 or newer is required.');
16
+ }
17
+ this.base = String(base || '').replace(/\/+$/, '');
18
+ this.apiKey = apiKey;
19
+ this.timeoutMs = timeoutMs;
20
+ this.fetchImpl = fetchImpl;
21
+ }
22
+
23
+ /** Headers for every API request. The API key is only sent when present. */
24
+ headers() {
25
+ const headers = {
26
+ accept: 'application/json',
27
+ 'user-agent': USER_AGENT,
28
+ };
29
+ if (this.apiKey) headers.authorization = `Bearer ${this.apiKey}`;
30
+ return headers;
31
+ }
32
+
33
+ buildUrl(pathname, params = {}) {
34
+ const url = new URL(`${this.base}/${String(pathname).replace(/^\/+/, '')}`);
35
+ for (const [key, value] of Object.entries(params)) {
36
+ if (value === undefined || value === null || value === '') continue;
37
+ url.searchParams.set(key, String(value));
38
+ }
39
+ return url;
40
+ }
41
+
42
+ /** Perform a GET request and return the decoded JSON body. */
43
+ async request(pathname, params = {}) {
44
+ const url = this.buildUrl(pathname, params);
45
+ let response;
46
+ try {
47
+ response = await this.fetchImpl(url, {
48
+ method: 'GET',
49
+ headers: this.headers(),
50
+ signal: AbortSignal.timeout(this.timeoutMs),
51
+ });
52
+ } catch (err) {
53
+ throw this.#networkFailure(err, url);
54
+ }
55
+
56
+ let text = '';
57
+ try {
58
+ text = await response.text();
59
+ } catch (err) {
60
+ throw networkError(`The connection to ${this.base} broke while reading the response.`, {
61
+ code: 'network',
62
+ hint: 'Try again, or increase the timeout with --timeout <ms>.',
63
+ });
64
+ }
65
+
66
+ let data = null;
67
+ if (text) {
68
+ try {
69
+ data = JSON.parse(text);
70
+ } catch {
71
+ data = null;
72
+ }
73
+ }
74
+
75
+ if (!response.ok) throw this.#httpFailure(response, data, text);
76
+
77
+ if (data === null) {
78
+ throw apiError(`The API returned a non-JSON response (HTTP ${response.status}).`, {
79
+ status: response.status,
80
+ hint: 'Check that --api-base points at an ANYFREEBOOK API v1 base URL.',
81
+ });
82
+ }
83
+
84
+ return data;
85
+ }
86
+
87
+ /** GET /search */
88
+ async search({ q, page = 1, source = 'all', limit = 20 } = {}) {
89
+ return this.request('search', { q, page, source, limit });
90
+ }
91
+
92
+ /** GET /resolve */
93
+ async resolve({ title, author } = {}) {
94
+ return this.request('resolve', { title, author });
95
+ }
96
+
97
+ /** GET /health */
98
+ async health() {
99
+ return this.request('health');
100
+ }
101
+
102
+ #networkFailure(err, url) {
103
+ const name = err && err.name ? err.name : '';
104
+ if (name === 'TimeoutError' || name === 'AbortError') {
105
+ return networkError(`Request to ${url.host} timed out after ${this.timeoutMs} ms.`, {
106
+ code: 'timeout',
107
+ hint: 'Raise the timeout with --timeout <ms>, or check that the API is responding.',
108
+ });
109
+ }
110
+ const cause = (err && (err.cause || err)) || {};
111
+ const code = cause.code ? ` (${cause.code})` : '';
112
+ return networkError(`Could not reach the ANYFREEBOOK API at ${this.base}${code}.`, {
113
+ code: 'network',
114
+ hint:
115
+ 'Check your internet connection. If the API runs elsewhere, point the CLI at it with ' +
116
+ '--api-base <url> or the ANYFREEBOOK_API_BASE environment variable.',
117
+ });
118
+ }
119
+
120
+ #httpFailure(response, data, text) {
121
+ const status = response.status;
122
+ const serverMessage =
123
+ (data && typeof data === 'object' && typeof data.error === 'string' && data.error) ||
124
+ (data && typeof data === 'object' && typeof data.message === 'string' && data.message) ||
125
+ '';
126
+
127
+ if (status === 401) {
128
+ return apiError('The API rejected the request as unauthorized (HTTP 401).', {
129
+ status,
130
+ code: 'unauthorized',
131
+ hint:
132
+ 'Set ANYFREEBOOK_API_KEY=<your-key> or pass --api-key <your-key>. ' +
133
+ 'No key is needed for the anonymous tier, so try again without one; ' +
134
+ 'use --json if you want the failure as JSON.',
135
+ });
136
+ }
137
+
138
+ if (status === 429) {
139
+ const retryAfter = parseRetryAfter(response.headers && response.headers.get('retry-after'));
140
+ const seconds = retryAfter === null ? 'a short while' : `${retryAfter} second${retryAfter === 1 ? '' : 's'}`;
141
+ return apiError(`Rate limited by the API (HTTP 429). Retry after ${seconds}.`, {
142
+ status,
143
+ code: 'rate_limited',
144
+ exitCode: 2,
145
+ retryAfter,
146
+ hint: 'Slow down and retry shortly.',
147
+ });
148
+ }
149
+
150
+ if (status === 400) {
151
+ return apiError(serverMessage || 'The API rejected the request (HTTP 400).', {
152
+ status,
153
+ code: 'bad_request',
154
+ });
155
+ }
156
+
157
+ if (status === 404) {
158
+ return apiError(serverMessage || 'The API has no such endpoint (HTTP 404).', {
159
+ status,
160
+ code: 'not_found',
161
+ hint: 'Check --api-base; the public base URL is https://anyfreebook.com/api/v1.',
162
+ });
163
+ }
164
+
165
+ if (status >= 500) {
166
+ return apiError(
167
+ serverMessage || `The API failed to handle the request (HTTP ${status}).`,
168
+ { status, code: 'server_error', hint: 'This is a problem on the API side. Try again shortly.' },
169
+ );
170
+ }
171
+
172
+ return apiError(serverMessage || `Unexpected API response (HTTP ${status}).`, {
173
+ status,
174
+ code: 'http_error',
175
+ ...(text ? {} : {}),
176
+ });
177
+ }
178
+ }
179
+
180
+ function parseRetryAfter(value) {
181
+ if (!value) return null;
182
+ const seconds = Number(value);
183
+ if (Number.isFinite(seconds)) return Math.max(0, Math.round(seconds));
184
+ const asDate = Date.parse(value);
185
+ if (!Number.isNaN(asDate)) {
186
+ return Math.max(0, Math.round((asDate - Date.now()) / 1000));
187
+ }
188
+ return null;
189
+ }
package/src/cli.js ADDED
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Entry point: argument parsing, dispatch and top-level error handling.
3
+ *
4
+ * The CLI never prints a stack trace. Every failure becomes a short message
5
+ * and an exit code.
6
+ */
7
+
8
+ import { ApiClient } from './api.js';
9
+ import { resolveConfig, VERSION } from './config.js';
10
+ import { CliError, EXIT_ERROR, errorPayload, toCliError, usageError } from './errors.js';
11
+ import { createStyler, resolveColor } from './format.js';
12
+ import { downloadCommand } from './commands/download.js';
13
+ import { healthCommand } from './commands/health.js';
14
+ import { resolveCommand } from './commands/resolve.js';
15
+ import { searchCommand } from './commands/search.js';
16
+
17
+ const COMMANDS = {
18
+ search: searchCommand,
19
+ download: downloadCommand,
20
+ resolve: resolveCommand,
21
+ health: healthCommand,
22
+ };
23
+
24
+ const VALUE_OPTIONS = new Set([
25
+ '--api-base',
26
+ '--api-key',
27
+ '--timeout',
28
+ '--limit',
29
+ '--page',
30
+ '--source',
31
+ '--format',
32
+ '--out',
33
+ '--author',
34
+ ]);
35
+
36
+ const BOOLEAN_OPTIONS = new Set([
37
+ '--json',
38
+ '--no-color',
39
+ '--free-only',
40
+ '--force',
41
+ '--dry-run',
42
+ '--help',
43
+ '--version',
44
+ ]);
45
+
46
+ const SHORT_OPTIONS = { '-h': '--help', '-v': '--version' };
47
+
48
+ function toCamel(option) {
49
+ return option
50
+ .replace(/^--/, '')
51
+ .replace(/-([a-z0-9])/g, (_, chr) => chr.toUpperCase());
52
+ }
53
+
54
+ /** Parse argv into `{ _: [...], ...flags }`. */
55
+ export function parseArgs(argv) {
56
+ const opts = { _: [] };
57
+
58
+ for (let index = 0; index < argv.length; index += 1) {
59
+ const raw = argv[index];
60
+ if (raw === '--') {
61
+ opts._.push(...argv.slice(index + 1));
62
+ break;
63
+ }
64
+ if (raw.length > 1 && raw.startsWith('-')) {
65
+ let key = raw;
66
+ let inline;
67
+ const equals = raw.indexOf('=');
68
+ if (equals > -1) {
69
+ key = raw.slice(0, equals);
70
+ inline = raw.slice(equals + 1);
71
+ }
72
+ if (SHORT_OPTIONS[key]) key = SHORT_OPTIONS[key];
73
+
74
+ if (VALUE_OPTIONS.has(key)) {
75
+ const value = inline !== undefined ? inline : argv[(index += 1)];
76
+ if (value === undefined) throw usageError(`Option ${key} requires a value.`);
77
+ opts[toCamel(key)] = value;
78
+ } else if (BOOLEAN_OPTIONS.has(key)) {
79
+ if (inline !== undefined) throw usageError(`Option ${key} does not take a value.`);
80
+ opts[toCamel(key)] = true;
81
+ } else {
82
+ throw usageError(
83
+ `Unknown option: ${key}.`,
84
+ 'Run `anyfreebook --help` to see the supported options.',
85
+ );
86
+ }
87
+ } else {
88
+ opts._.push(raw);
89
+ }
90
+ }
91
+
92
+ return opts;
93
+ }
94
+
95
+ export const GENERAL_HELP = `anyfreebook ${VERSION} — search 7 open-access book sources, download only what is verifiably public domain.
96
+
97
+ Usage:
98
+ anyfreebook search <query> [options]
99
+ anyfreebook download <query> [options]
100
+ anyfreebook resolve <title> [--author <name>] [options]
101
+ anyfreebook health [options]
102
+
103
+ Global options:
104
+ --api-base <url> API base URL (env ANYFREEBOOK_API_BASE)
105
+ --api-key <key> API key, optional (env ANYFREEBOOK_API_KEY)
106
+ --timeout <ms> Request timeout (default 20000)
107
+ --json Print raw JSON to stdout, nothing else
108
+ --no-color Disable ANSI colour (NO_COLOR is honoured too)
109
+ --help, -h Show this help
110
+ --version, -v Show the version
111
+
112
+ Exit codes:
113
+ 0 success
114
+ 1 request or usage error
115
+ 2 rate limited (HTTP 429)
116
+
117
+ Docs: https://anyfreebook.com
118
+ `;
119
+
120
+ export const COMMAND_HELP = {
121
+ search: `
122
+ Search — fan one query out across Project Gutenberg, Open Library, the Internet
123
+ Archive, Google Books, PubMed Central, DOAJ and LibriVox.
124
+
125
+ anyfreebook search <query> [options]
126
+
127
+ --limit <n> Results per page (default 10, max 50)
128
+ --page <n> Page number (default 1)
129
+ --source <name> all | gutenberg | openlibrary | archive | googlebooks |
130
+ pubmed | doaj | librivox (default all)
131
+ --format <fmt> Keep only results offering epub | pdf | txt | html
132
+ --free-only Keep only results with a verified free download
133
+ `,
134
+ download: `
135
+ Download — search, then write verified public-domain books to a folder.
136
+
137
+ anyfreebook download <query> [options]
138
+
139
+ --limit <n> Results to consider (default 5, max 50)
140
+ --source <name> all | gutenberg | openlibrary | archive | googlebooks |
141
+ pubmed | doaj | librivox (default all)
142
+ --format <fmt> epub | pdf | txt | html (default: best available —
143
+ epub > pdf > txt > html)
144
+ --out <dir> Destination folder (default ./books)
145
+ --force Overwrite files that already exist
146
+ --dry-run Show what would be downloaded; write nothing
147
+ --free-only Accepted for symmetry; downloads are always limited to
148
+ tier=download results
149
+ `,
150
+ resolve: `
151
+ Resolve — the authoritative legal status of one title.
152
+
153
+ anyfreebook resolve <title> [--author <name>] [options]
154
+
155
+ --author <name> Author, to disambiguate between editions
156
+ `,
157
+ health: `
158
+ Health — check that the API is reachable.
159
+
160
+ anyfreebook health [options]
161
+ `,
162
+ };
163
+
164
+ function helpFor(command) {
165
+ return command && COMMAND_HELP[command]
166
+ ? `${GENERAL_HELP}${COMMAND_HELP[command]}`
167
+ : GENERAL_HELP;
168
+ }
169
+
170
+ export async function main(argv = [], env = process.env) {
171
+ let opts;
172
+ try {
173
+ opts = parseArgs(argv);
174
+ } catch (err) {
175
+ const error = toCliError(err);
176
+ process.stderr.write(`error: ${error.message}\n`);
177
+ if (error.hint) process.stderr.write(`hint: ${error.hint}\n`);
178
+ process.exitCode = error.exitCode;
179
+ return;
180
+ }
181
+
182
+ if (opts.version) {
183
+ process.stdout.write(`${VERSION}\n`);
184
+ return;
185
+ }
186
+
187
+ const command = opts._[0];
188
+ if (!command) {
189
+ if (opts.help) {
190
+ process.stdout.write(helpFor(null));
191
+ return;
192
+ }
193
+ process.stderr.write(`error: No command given.\n\n${helpFor(null)}`);
194
+ process.exitCode = EXIT_ERROR;
195
+ return;
196
+ }
197
+
198
+ const handler = COMMANDS[command];
199
+ if (!handler) {
200
+ process.stderr.write(
201
+ `error: Unknown command "${command}". Known commands: ${Object.keys(COMMANDS).join(', ')}.\n\n${helpFor(null)}`,
202
+ );
203
+ process.exitCode = EXIT_ERROR;
204
+ return;
205
+ }
206
+
207
+ if (opts.help) {
208
+ process.stdout.write(helpFor(command));
209
+ return;
210
+ }
211
+
212
+ const config = resolveConfig(opts, env);
213
+ const color = resolveColor({ noColor: Boolean(opts.noColor), env, stream: process.stdout });
214
+ const style = createStyler({ color });
215
+
216
+ const ctx = {
217
+ api: new ApiClient(config),
218
+ config,
219
+ style,
220
+ env,
221
+ json: Boolean(opts.json),
222
+ width: process.stdout.columns || 100,
223
+ out: process.stdout,
224
+ err: process.stderr,
225
+ };
226
+
227
+ try {
228
+ await handler(opts, ctx);
229
+ } catch (err) {
230
+ const error = toCliError(err);
231
+ if (ctx.json) {
232
+ process.stdout.write(`${JSON.stringify(errorPayload(error), null, 2)}\n`);
233
+ } else {
234
+ process.stderr.write(`error: ${error.message}\n`);
235
+ if (error.hint) process.stderr.write(`hint: ${error.hint}\n`);
236
+ }
237
+ process.exitCode = error.exitCode;
238
+ }
239
+ }
240
+
241
+ export { CliError };