anyfreebook-cli 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +458 -2
- package/bin/anyfreebook.js +8 -0
- package/package.json +49 -4
- package/src/api.js +189 -0
- package/src/cli.js +241 -0
- package/src/commands/download.js +409 -0
- package/src/commands/health.js +24 -0
- package/src/commands/resolve.js +79 -0
- package/src/commands/search.js +128 -0
- package/src/config.js +113 -0
- package/src/errors.js +57 -0
- package/src/filename.js +75 -0
- package/src/format.js +167 -0
package/src/config.js
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration, limits and validation helpers.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is deliberately dependency-free and side-effect free so the
|
|
5
|
+
* same values can be reused by the CLI and by the tests.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync } from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
import { usageError } from './errors.js';
|
|
13
|
+
|
|
14
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
|
|
16
|
+
function readVersion() {
|
|
17
|
+
try {
|
|
18
|
+
const raw = readFileSync(path.join(here, '..', 'package.json'), 'utf8');
|
|
19
|
+
return JSON.parse(raw).version || '0.0.0';
|
|
20
|
+
} catch {
|
|
21
|
+
return '0.0.0';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const VERSION = readVersion();
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_API_BASE = 'https://anyfreebook.com/api/v1';
|
|
28
|
+
export const DEFAULT_TIMEOUT_MS = 20000;
|
|
29
|
+
export const DEFAULT_SEARCH_LIMIT = 10;
|
|
30
|
+
export const DEFAULT_DOWNLOAD_LIMIT = 5;
|
|
31
|
+
export const DEFAULT_OUT_DIR = './books';
|
|
32
|
+
export const MAX_LIMIT = 50;
|
|
33
|
+
export const MAX_QUERY_LENGTH = 200;
|
|
34
|
+
|
|
35
|
+
/** Source identifiers accepted by the `source` query parameter. */
|
|
36
|
+
export const SOURCES = [
|
|
37
|
+
'all',
|
|
38
|
+
'gutenberg',
|
|
39
|
+
'openlibrary',
|
|
40
|
+
'archive',
|
|
41
|
+
'googlebooks',
|
|
42
|
+
'pubmed',
|
|
43
|
+
'doaj',
|
|
44
|
+
'librivox',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/** File formats the CLI knows how to download. */
|
|
48
|
+
export const FORMATS = ['epub', 'pdf', 'txt', 'html'];
|
|
49
|
+
|
|
50
|
+
/** Preference order used when no explicit `--format` is given. */
|
|
51
|
+
export const FORMAT_PREFERENCE = ['epub', 'pdf', 'txt', 'html'];
|
|
52
|
+
|
|
53
|
+
export const USER_AGENT = `anyfreebook-cli/${VERSION} (+https://anyfreebook.com)`;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Merge CLI flags with environment variables.
|
|
57
|
+
* Flags win, then env, then the built-in defaults.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveConfig(flags = {}, env = {}) {
|
|
60
|
+
const base = String(flags.apiBase || env.ANYFREEBOOK_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
|
61
|
+
const apiKey = String(flags.apiKey || env.ANYFREEBOOK_API_KEY || '').trim();
|
|
62
|
+
const timeoutMs =
|
|
63
|
+
flags.timeout !== undefined && flags.timeout !== null
|
|
64
|
+
? Number(flags.timeout)
|
|
65
|
+
: env.ANYFREEBOOK_TIMEOUT_MS !== undefined
|
|
66
|
+
? Number(env.ANYFREEBOOK_TIMEOUT_MS)
|
|
67
|
+
: DEFAULT_TIMEOUT_MS;
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
base: base || DEFAULT_API_BASE,
|
|
71
|
+
apiKey,
|
|
72
|
+
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Parse an integer option and clamp it into [min, max]. */
|
|
77
|
+
export function clampInt(value, fallback, min, max, label) {
|
|
78
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
79
|
+
const parsed = Number(value);
|
|
80
|
+
if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
|
|
81
|
+
throw usageError(`${label} must be a whole number (got "${value}").`);
|
|
82
|
+
}
|
|
83
|
+
if (parsed < min || parsed > max) {
|
|
84
|
+
throw usageError(`${label} must be between ${min} and ${max} (got ${parsed}).`);
|
|
85
|
+
}
|
|
86
|
+
return parsed;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Validate the `--source` option. */
|
|
90
|
+
export function validateSource(value) {
|
|
91
|
+
if (value === undefined || value === null || value === '') return 'all';
|
|
92
|
+
const normalized = String(value).trim().toLowerCase();
|
|
93
|
+
if (!SOURCES.includes(normalized)) {
|
|
94
|
+
throw usageError(
|
|
95
|
+
`Unknown source "${value}".`,
|
|
96
|
+
`Valid sources: ${SOURCES.join(', ')}.`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
return normalized;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Validate the `--format` option. */
|
|
103
|
+
export function validateFormat(value) {
|
|
104
|
+
if (value === undefined || value === null || value === '') return null;
|
|
105
|
+
const normalized = String(value).trim().toLowerCase().replace(/^\./, '');
|
|
106
|
+
if (!FORMATS.includes(normalized)) {
|
|
107
|
+
throw usageError(
|
|
108
|
+
`Unknown format "${value}".`,
|
|
109
|
+
`Valid formats: ${FORMATS.join(', ')}.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return normalized;
|
|
113
|
+
}
|
package/src/errors.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error types shared by the CLI.
|
|
3
|
+
*
|
|
4
|
+
* Every error that reaches the top level carries an exit code, so the process
|
|
5
|
+
* never has to guess and never has to print a stack trace.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const EXIT_OK = 0;
|
|
9
|
+
export const EXIT_ERROR = 1;
|
|
10
|
+
export const EXIT_RATE_LIMITED = 2;
|
|
11
|
+
|
|
12
|
+
export class CliError extends Error {
|
|
13
|
+
constructor(message, options = {}) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'CliError';
|
|
16
|
+
this.exitCode = options.exitCode ?? EXIT_ERROR;
|
|
17
|
+
this.hint = options.hint ?? '';
|
|
18
|
+
this.code = options.code ?? 'error';
|
|
19
|
+
this.status = options.status ?? null;
|
|
20
|
+
this.retryAfter = options.retryAfter ?? null;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A mistake in how the command was invoked (bad flag, missing argument...). */
|
|
25
|
+
export function usageError(message, hint = '') {
|
|
26
|
+
return new CliError(message, { exitCode: EXIT_ERROR, code: 'usage', hint });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The API answered with a non-2xx status. */
|
|
30
|
+
export function apiError(message, options = {}) {
|
|
31
|
+
return new CliError(message, { code: 'api', ...options });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The API could not be reached at all. */
|
|
35
|
+
export function networkError(message, options = {}) {
|
|
36
|
+
return new CliError(message, { code: 'network', ...options });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Normalise anything thrown into a CliError. */
|
|
40
|
+
export function toCliError(err) {
|
|
41
|
+
if (err instanceof CliError) return err;
|
|
42
|
+
if (err instanceof Error) {
|
|
43
|
+
return new CliError(err.message || 'Unexpected error.', { code: 'error' });
|
|
44
|
+
}
|
|
45
|
+
return new CliError(String(err), { code: 'error' });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** JSON shape used for `--json` error output. */
|
|
49
|
+
export function errorPayload(err) {
|
|
50
|
+
const payload = { error: err.message, code: err.code };
|
|
51
|
+
if (err.hint) payload.hint = err.hint;
|
|
52
|
+
if (err.status) payload.status = err.status;
|
|
53
|
+
if (err.retryAfter !== null && err.retryAfter !== undefined) {
|
|
54
|
+
payload.retryAfter = err.retryAfter;
|
|
55
|
+
}
|
|
56
|
+
return payload;
|
|
57
|
+
}
|
package/src/filename.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filename helpers.
|
|
3
|
+
*
|
|
4
|
+
* Downloaded books are named after their title. Titles come from a remote API,
|
|
5
|
+
* so they are treated as untrusted input: the result is always a single flat
|
|
6
|
+
* file name that stays inside the destination directory.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
const WINDOWS_RESERVED = new Set([
|
|
12
|
+
'con', 'prn', 'aux', 'nul',
|
|
13
|
+
'com1', 'com2', 'com3', 'com4', 'com5', 'com6', 'com7', 'com8', 'com9',
|
|
14
|
+
'lpt1', 'lpt2', 'lpt3', 'lpt4', 'lpt5', 'lpt6', 'lpt7', 'lpt8', 'lpt9',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Letters that NFKD leaves alone but that a readable slug should keep.
|
|
19
|
+
* Without this, "ø" would silently disappear from a file name.
|
|
20
|
+
*/
|
|
21
|
+
const TRANSLITERATIONS = {
|
|
22
|
+
ø: 'o', Ø: 'O', æ: 'ae', Æ: 'ae', œ: 'oe', Œ: 'oe',
|
|
23
|
+
ß: 'ss', đ: 'd', Đ: 'd', ł: 'l', Ł: 'l', þ: 'th', Þ: 'th',
|
|
24
|
+
ð: 'd', Ð: 'd', ħ: 'h', Ħ: 'h', ŋ: 'ng', Ŋ: 'ng',
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Turn an arbitrary title into a lowercase, hyphenated, filesystem-safe slug.
|
|
29
|
+
* Never returns an empty string.
|
|
30
|
+
*/
|
|
31
|
+
export function slugify(input, { maxLength = 120 } = {}) {
|
|
32
|
+
const source = String(input ?? '')
|
|
33
|
+
.replace(/[øØæÆœŒßđĐłŁþÞðÐħĦŋŊ]/g, (char) => TRANSLITERATIONS[char] ?? char)
|
|
34
|
+
.normalize('NFKD')
|
|
35
|
+
.replace(/[\u0300-\u036f]/g, '');
|
|
36
|
+
|
|
37
|
+
let slug = source
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
40
|
+
.replace(/^-+/, '')
|
|
41
|
+
.replace(/-+$/, '');
|
|
42
|
+
|
|
43
|
+
if (slug.length > maxLength) {
|
|
44
|
+
slug = slug.slice(0, maxLength).replace(/-+$/, '');
|
|
45
|
+
}
|
|
46
|
+
if (!slug) slug = 'book';
|
|
47
|
+
if (WINDOWS_RESERVED.has(slug)) slug = `_${slug}`;
|
|
48
|
+
return slug;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build the file name for a download: `<slug>.<ext>`.
|
|
53
|
+
*
|
|
54
|
+
* When the source id is known it is appended (`<slug>-pg-1342.epub`).
|
|
55
|
+
* Two different books can slugify to the same title — "Pride and Prejudice"
|
|
56
|
+
* and "Pride and Prejudice, a play" both become `pride-and-prejudice` — and
|
|
57
|
+
* without the id the second one would be skipped as a duplicate of the first.
|
|
58
|
+
*/
|
|
59
|
+
export function fileNameFor(title, extension, id) {
|
|
60
|
+
const ext = String(extension ?? '').replace(/^\.+/, '').replace(/[^a-z0-9]/gi, '') || 'bin';
|
|
61
|
+
const suffix = id ? `-${slugify(id, { maxLength: 24 })}` : '';
|
|
62
|
+
return `${slugify(title)}${suffix}.${ext.toLowerCase()}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve `name` inside `dir` and guarantee the result stays inside it.
|
|
67
|
+
* Returns null when the name would escape the directory.
|
|
68
|
+
*/
|
|
69
|
+
export function resolveWithin(dir, name) {
|
|
70
|
+
const base = path.resolve(dir);
|
|
71
|
+
const target = path.resolve(base, name);
|
|
72
|
+
const relative = path.relative(base, target);
|
|
73
|
+
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
|
|
74
|
+
return target;
|
|
75
|
+
}
|
package/src/format.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Terminal formatting.
|
|
3
|
+
*
|
|
4
|
+
* Colour is opt-out: NO_COLOR, a non-TTY stdout, or --no-color all disable it.
|
|
5
|
+
* No ANSI escape ever ends up in JSON output.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const ANSI = {
|
|
9
|
+
reset: '\u001b[0m',
|
|
10
|
+
bold: '\u001b[1m',
|
|
11
|
+
dim: '\u001b[2m',
|
|
12
|
+
red: '\u001b[31m',
|
|
13
|
+
green: '\u001b[32m',
|
|
14
|
+
yellow: '\u001b[33m',
|
|
15
|
+
blue: '\u001b[34m',
|
|
16
|
+
magenta: '\u001b[35m',
|
|
17
|
+
cyan: '\u001b[36m',
|
|
18
|
+
gray: '\u001b[90m',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Decide whether ANSI colour should be used.
|
|
23
|
+
* @param {{noColor?: boolean, env?: object, stream?: object}} options
|
|
24
|
+
*/
|
|
25
|
+
export function resolveColor({ noColor = false, env = {}, stream = process.stdout } = {}) {
|
|
26
|
+
if (noColor) return false;
|
|
27
|
+
const environment = env || {};
|
|
28
|
+
if (environment.NO_COLOR !== undefined && environment.NO_COLOR !== '') return false;
|
|
29
|
+
if (environment.FORCE_COLOR === '0') return false;
|
|
30
|
+
if (environment.FORCE_COLOR !== undefined && environment.FORCE_COLOR !== '') return true;
|
|
31
|
+
return Boolean(stream && stream.isTTY);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Create a tiny styling helper bound to a colour decision. */
|
|
35
|
+
export function createStyler({ color = false } = {}) {
|
|
36
|
+
const wrap = (name) => (text) => (color ? `${ANSI[name]}${text}${ANSI.reset}` : String(text));
|
|
37
|
+
return {
|
|
38
|
+
color,
|
|
39
|
+
bold: wrap('bold'),
|
|
40
|
+
dim: wrap('dim'),
|
|
41
|
+
red: wrap('red'),
|
|
42
|
+
green: wrap('green'),
|
|
43
|
+
yellow: wrap('yellow'),
|
|
44
|
+
blue: wrap('blue'),
|
|
45
|
+
magenta: wrap('magenta'),
|
|
46
|
+
cyan: wrap('cyan'),
|
|
47
|
+
gray: wrap('gray'),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const TIER_STYLE = {
|
|
52
|
+
download: (style) => style.green('download'),
|
|
53
|
+
borrow: (style) => style.yellow('borrow'),
|
|
54
|
+
unverified: (style) => style.gray('unverified'),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Colour a tier label, falling back to the plain word for unknown tiers. */
|
|
58
|
+
export function formatTier(tier, style) {
|
|
59
|
+
const key = String(tier ?? '').toLowerCase();
|
|
60
|
+
const painter = TIER_STYLE[key];
|
|
61
|
+
return painter ? painter(style) : String(tier ?? 'unknown');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Shorten text to `width` characters, using an ellipsis when it does not fit. */
|
|
65
|
+
export function truncate(text, width) {
|
|
66
|
+
const value = String(text ?? '');
|
|
67
|
+
if (width <= 0) return '';
|
|
68
|
+
if (value.length <= width) return value;
|
|
69
|
+
if (width <= 1) return value.slice(0, width);
|
|
70
|
+
return `${value.slice(0, width - 1)}…`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Human readable byte size. */
|
|
74
|
+
export function humanBytes(bytes) {
|
|
75
|
+
if (bytes === null || bytes === undefined || !Number.isFinite(Number(bytes))) return 'unknown';
|
|
76
|
+
const units = ['B', 'KB', 'MB', 'GB'];
|
|
77
|
+
let value = Number(bytes);
|
|
78
|
+
let unit = 0;
|
|
79
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
80
|
+
value /= 1024;
|
|
81
|
+
unit += 1;
|
|
82
|
+
}
|
|
83
|
+
const rounded = unit === 0 ? Math.round(value) : Math.round(value * 10) / 10;
|
|
84
|
+
return `${rounded} ${units[unit]}`;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Formats available on a search result. */
|
|
88
|
+
export function formatsOf(result) {
|
|
89
|
+
const files = Array.isArray(result?.files) ? result.files : [];
|
|
90
|
+
const formats = [];
|
|
91
|
+
for (const file of files) {
|
|
92
|
+
const format = file && file.format ? String(file.format).toLowerCase() : '';
|
|
93
|
+
if (format && !formats.includes(format)) formats.push(format);
|
|
94
|
+
}
|
|
95
|
+
return formats.length ? formats.join(', ') : '—';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const GUTTER = ' ';
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Render the search result table.
|
|
102
|
+
* Column widths are computed from the plain text so ANSI codes never break the
|
|
103
|
+
* alignment.
|
|
104
|
+
*/
|
|
105
|
+
export function renderSearchTable(results, { style, width = process.stdout.columns || 100 } = {}) {
|
|
106
|
+
const columns = [
|
|
107
|
+
{ label: 'TITLE', width: 50, min: 22 },
|
|
108
|
+
{ label: 'AUTHOR', width: 24, min: 10 },
|
|
109
|
+
{ label: 'SOURCE', width: 20, min: 10 },
|
|
110
|
+
{ label: 'TIER', width: 10, min: 9 },
|
|
111
|
+
{ label: 'FORMATS', width: 22, min: 8 },
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
const fixedTotal = columns.slice(1).reduce((sum, col) => sum + col.width, 0);
|
|
115
|
+
const gutterTotal = GUTTER.length * (columns.length - 1);
|
|
116
|
+
const available = Math.max(30, width - fixedTotal - gutterTotal);
|
|
117
|
+
columns[0].width = Math.max(columns[0].min, Math.min(columns[0].width, available));
|
|
118
|
+
|
|
119
|
+
const rawRows = results.map((result) => [
|
|
120
|
+
result?.title || result?.id || '(untitled)',
|
|
121
|
+
result?.author || '—',
|
|
122
|
+
result?.source || result?.sourceType || '—',
|
|
123
|
+
String(result?.tier ?? 'unknown'),
|
|
124
|
+
formatsOf(result),
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
const widths = columns.map((col, index) =>
|
|
128
|
+
rawRows.reduce((max, row) => Math.max(max, String(row[index] ?? '').length), col.label.length),
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const pad = (text, width) => text + ' '.repeat(Math.max(0, width - text.length));
|
|
132
|
+
|
|
133
|
+
const header = columns.map((col, index) => style.bold(pad(col.label, widths[index]))).join(GUTTER).trimEnd();
|
|
134
|
+
const divider = columns.map((col, index) => style.dim('-'.repeat(widths[index]))).join(GUTTER).trimEnd();
|
|
135
|
+
|
|
136
|
+
const body = rawRows.map((row) =>
|
|
137
|
+
row
|
|
138
|
+
.map((cell, index) => {
|
|
139
|
+
const plain = truncate(String(cell ?? ''), widths[index]);
|
|
140
|
+
const shown = index === 3 ? formatTier(plain, style) : plain;
|
|
141
|
+
return shown + ' '.repeat(Math.max(0, widths[index] - plain.length));
|
|
142
|
+
})
|
|
143
|
+
.join(GUTTER)
|
|
144
|
+
.trimEnd(),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
return [header, divider, ...body].join('\n');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** One-line explanation of what a tier means. */
|
|
151
|
+
export function tierLegend(style) {
|
|
152
|
+
return (
|
|
153
|
+
`${style.green('download')} = verified public-domain full text · ` +
|
|
154
|
+
`${style.yellow('borrow')} = lending-restricted (needs a free Internet Archive account + loan) · ` +
|
|
155
|
+
`${style.gray('unverified')} = no confirmed free copy of this exact title`
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Warning shown when some upstream sources did not answer. */
|
|
160
|
+
export function partialWarning(failedSources, style) {
|
|
161
|
+
const names = Array.isArray(failedSources) ? failedSources.filter(Boolean) : [];
|
|
162
|
+
const listed = names.length ? names.join(', ') : 'one or more sources';
|
|
163
|
+
return (
|
|
164
|
+
`${style.yellow('Warning:')} partial results — ${listed} did not respond. ` +
|
|
165
|
+
'The list below is incomplete, not empty.'
|
|
166
|
+
);
|
|
167
|
+
}
|