fxtwitter 1.0.0 → 2.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/dist/cli.mjs ADDED
@@ -0,0 +1,880 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from "node:util";
3
+ import { defu } from "defu";
4
+ import { FetchError, createFetch } from "ofetch";
5
+ import { hasWindow, nodeVersion, runtime } from "std-env";
6
+ //#region node_modules/citty/dist/_chunks/libs/scule.mjs
7
+ const NUMBER_CHAR_RE = /\d/;
8
+ const STR_SPLITTERS = [
9
+ "-",
10
+ "_",
11
+ "/",
12
+ "."
13
+ ];
14
+ function isUppercase(char = "") {
15
+ if (NUMBER_CHAR_RE.test(char)) return;
16
+ return char !== char.toLowerCase();
17
+ }
18
+ function splitByCase(str, separators) {
19
+ const splitters = separators ?? STR_SPLITTERS;
20
+ const parts = [];
21
+ if (!str || typeof str !== "string") return parts;
22
+ let buff = "";
23
+ let previousUpper;
24
+ let previousSplitter;
25
+ for (const char of str) {
26
+ const isSplitter = splitters.includes(char);
27
+ if (isSplitter === true) {
28
+ parts.push(buff);
29
+ buff = "";
30
+ previousUpper = void 0;
31
+ continue;
32
+ }
33
+ const isUpper = isUppercase(char);
34
+ if (previousSplitter === false) {
35
+ if (previousUpper === false && isUpper === true) {
36
+ parts.push(buff);
37
+ buff = char;
38
+ previousUpper = isUpper;
39
+ continue;
40
+ }
41
+ if (previousUpper === true && isUpper === false && buff.length > 1) {
42
+ const lastChar = buff.at(-1);
43
+ parts.push(buff.slice(0, Math.max(0, buff.length - 1)));
44
+ buff = lastChar + char;
45
+ previousUpper = isUpper;
46
+ continue;
47
+ }
48
+ }
49
+ buff += char;
50
+ previousUpper = isUpper;
51
+ previousSplitter = isSplitter;
52
+ }
53
+ parts.push(buff);
54
+ return parts;
55
+ }
56
+ function upperFirst(str) {
57
+ return str ? str[0].toUpperCase() + str.slice(1) : "";
58
+ }
59
+ function lowerFirst(str) {
60
+ return str ? str[0].toLowerCase() + str.slice(1) : "";
61
+ }
62
+ function pascalCase(str, opts) {
63
+ return str ? (Array.isArray(str) ? str : splitByCase(str)).map((p) => upperFirst(opts?.normalize ? p.toLowerCase() : p)).join("") : "";
64
+ }
65
+ function camelCase(str, opts) {
66
+ return lowerFirst(pascalCase(str || "", opts));
67
+ }
68
+ function kebabCase(str, joiner) {
69
+ return str ? (Array.isArray(str) ? str : splitByCase(str)).map((p) => p.toLowerCase()).join(joiner ?? "-") : "";
70
+ }
71
+ function snakeCase(str) {
72
+ return kebabCase(str || "", "_");
73
+ }
74
+ //#endregion
75
+ //#region node_modules/citty/dist/index.mjs
76
+ function toArray(val) {
77
+ if (Array.isArray(val)) return val;
78
+ return val === void 0 ? [] : [val];
79
+ }
80
+ function formatLineColumns(lines, linePrefix = "") {
81
+ const maxLength = [];
82
+ for (const line of lines) for (const [i, element] of line.entries()) maxLength[i] = Math.max(maxLength[i] || 0, element.length);
83
+ return lines.map((l) => l.map((c, i) => linePrefix + c[i === 0 ? "padStart" : "padEnd"](maxLength[i])).join(" ")).join("\n");
84
+ }
85
+ function resolveValue(input) {
86
+ return typeof input === "function" ? input() : input;
87
+ }
88
+ var CLIError = class extends Error {
89
+ code;
90
+ constructor(message, code) {
91
+ super(message);
92
+ this.name = "CLIError";
93
+ this.code = code;
94
+ }
95
+ };
96
+ function parseRawArgs(args = [], opts = {}) {
97
+ const booleans = new Set(opts.boolean || []);
98
+ const strings = new Set(opts.string || []);
99
+ const aliasMap = opts.alias || {};
100
+ const defaults = opts.default || {};
101
+ const aliasToMain = /* @__PURE__ */ new Map();
102
+ const mainToAliases = /* @__PURE__ */ new Map();
103
+ for (const [key, value] of Object.entries(aliasMap)) {
104
+ const targets = value;
105
+ for (const target of targets) {
106
+ aliasToMain.set(key, target);
107
+ if (!mainToAliases.has(target)) mainToAliases.set(target, []);
108
+ mainToAliases.get(target).push(key);
109
+ aliasToMain.set(target, key);
110
+ if (!mainToAliases.has(key)) mainToAliases.set(key, []);
111
+ mainToAliases.get(key).push(target);
112
+ }
113
+ }
114
+ const options = {};
115
+ function getType(name) {
116
+ if (booleans.has(name)) return "boolean";
117
+ const aliases = mainToAliases.get(name) || [];
118
+ for (const alias of aliases) if (booleans.has(alias)) return "boolean";
119
+ return "string";
120
+ }
121
+ function isStringType(name) {
122
+ if (strings.has(name)) return true;
123
+ const aliases = mainToAliases.get(name) || [];
124
+ for (const alias of aliases) if (strings.has(alias)) return true;
125
+ return false;
126
+ }
127
+ const allOptions = /* @__PURE__ */ new Set([
128
+ ...booleans,
129
+ ...strings,
130
+ ...Object.keys(aliasMap),
131
+ ...Object.values(aliasMap).flat(),
132
+ ...Object.keys(defaults)
133
+ ]);
134
+ for (const name of allOptions) if (!options[name]) options[name] = {
135
+ type: getType(name),
136
+ default: defaults[name]
137
+ };
138
+ for (const [alias, main] of aliasToMain.entries()) if (alias.length === 1 && options[main] && !options[main].short) options[main].short = alias;
139
+ const processedArgs = [];
140
+ const negatedFlags = {};
141
+ for (let i = 0; i < args.length; i++) {
142
+ const arg = args[i];
143
+ if (arg === "--") {
144
+ processedArgs.push(...args.slice(i));
145
+ break;
146
+ }
147
+ if (arg.startsWith("--no-")) {
148
+ const flagName = arg.slice(5);
149
+ negatedFlags[flagName] = true;
150
+ continue;
151
+ }
152
+ processedArgs.push(arg);
153
+ }
154
+ let parsed;
155
+ try {
156
+ parsed = parseArgs({
157
+ args: processedArgs,
158
+ options: Object.keys(options).length > 0 ? options : void 0,
159
+ allowPositionals: true,
160
+ strict: false
161
+ });
162
+ } catch {
163
+ parsed = {
164
+ values: {},
165
+ positionals: processedArgs
166
+ };
167
+ }
168
+ const out = { _: [] };
169
+ out._ = parsed.positionals;
170
+ for (const [key, value] of Object.entries(parsed.values)) {
171
+ let coerced = value;
172
+ if (getType(key) === "boolean" && typeof value === "string") coerced = value !== "false";
173
+ else if (isStringType(key) && typeof value === "boolean") coerced = "";
174
+ out[key] = coerced;
175
+ }
176
+ for (const [name] of Object.entries(negatedFlags)) {
177
+ out[name] = false;
178
+ const mainName = aliasToMain.get(name);
179
+ if (mainName) out[mainName] = false;
180
+ const aliases = mainToAliases.get(name);
181
+ if (aliases) for (const alias of aliases) out[alias] = false;
182
+ }
183
+ for (const [alias, main] of aliasToMain.entries()) {
184
+ if (out[alias] !== void 0 && out[main] === void 0) out[main] = out[alias];
185
+ if (out[main] !== void 0 && out[alias] === void 0) out[alias] = out[main];
186
+ if (out[alias] !== out[main] && defaults[main] === out[main]) out[main] = out[alias];
187
+ }
188
+ return out;
189
+ }
190
+ const noColor = /* @__PURE__ */ (() => {
191
+ const env = globalThis.process?.env ?? {};
192
+ return env.NO_COLOR === "1" || env.TERM === "dumb" || env.TEST || env.CI;
193
+ })();
194
+ const _c = (c, r = 39) => (t) => noColor ? t : `\u001B[${c}m${t}\u001B[${r}m`;
195
+ const bold = /* @__PURE__ */ _c(1, 22);
196
+ const cyan = /* @__PURE__ */ _c(36);
197
+ const gray = /* @__PURE__ */ _c(90);
198
+ const underline = /* @__PURE__ */ _c(4, 24);
199
+ function parseArgs$1(rawArgs, argsDef) {
200
+ const parseOptions = {
201
+ boolean: [],
202
+ string: [],
203
+ alias: {},
204
+ default: {}
205
+ };
206
+ const args = resolveArgs(argsDef);
207
+ for (const arg of args) {
208
+ if (arg.type === "positional") continue;
209
+ if (arg.type === "string" || arg.type === "enum") parseOptions.string.push(arg.name);
210
+ else if (arg.type === "boolean") parseOptions.boolean.push(arg.name);
211
+ if (arg.default !== void 0) parseOptions.default[arg.name] = arg.default;
212
+ if (arg.alias) parseOptions.alias[arg.name] = arg.alias;
213
+ const camelName = camelCase(arg.name);
214
+ const kebabName = kebabCase(arg.name);
215
+ if (camelName !== arg.name || kebabName !== arg.name) {
216
+ const existingAliases = toArray(parseOptions.alias[arg.name] || []);
217
+ if (camelName !== arg.name && !existingAliases.includes(camelName)) existingAliases.push(camelName);
218
+ if (kebabName !== arg.name && !existingAliases.includes(kebabName)) existingAliases.push(kebabName);
219
+ if (existingAliases.length > 0) parseOptions.alias[arg.name] = existingAliases;
220
+ }
221
+ }
222
+ const parsed = parseRawArgs(rawArgs, parseOptions);
223
+ const [ ...positionalArguments] = parsed._;
224
+ const parsedArgsProxy = new Proxy(parsed, { get(target, prop) {
225
+ return target[prop] ?? target[camelCase(prop)] ?? target[kebabCase(prop)];
226
+ } });
227
+ for (const [, arg] of args.entries()) if (arg.type === "positional") {
228
+ const nextPositionalArgument = positionalArguments.shift();
229
+ if (nextPositionalArgument !== void 0) parsedArgsProxy[arg.name] = nextPositionalArgument;
230
+ else if (arg.default === void 0 && arg.required !== false) throw new CLIError(`Missing required positional argument: ${arg.name.toUpperCase()}`, "EARG");
231
+ else parsedArgsProxy[arg.name] = arg.default;
232
+ } else if (arg.type === "enum") {
233
+ const argument = parsedArgsProxy[arg.name];
234
+ const options = arg.options || [];
235
+ if (argument !== void 0 && options.length > 0 && !options.includes(argument)) throw new CLIError(`Invalid value for argument: ${cyan(`--${arg.name}`)} (${cyan(argument)}). Expected one of: ${options.map((o) => cyan(o)).join(", ")}.`, "EARG");
236
+ } else if (arg.required && parsedArgsProxy[arg.name] === void 0) throw new CLIError(`Missing required argument: --${arg.name}`, "EARG");
237
+ return parsedArgsProxy;
238
+ }
239
+ function resolveArgs(argsDef) {
240
+ const args = [];
241
+ for (const [name, argDef] of Object.entries(argsDef || {})) args.push({
242
+ ...argDef,
243
+ name,
244
+ alias: toArray(argDef.alias)
245
+ });
246
+ return args;
247
+ }
248
+ const negativePrefixRe = /^no[-A-Z]/;
249
+ async function renderUsage(cmd, parent) {
250
+ const cmdMeta = await resolveValue(cmd.meta || {});
251
+ const cmdArgs = resolveArgs(await resolveValue(cmd.args || {}));
252
+ const parentMeta = await resolveValue(parent?.meta || {});
253
+ const commandName = `${parentMeta.name ? `${parentMeta.name} ` : ""}` + (cmdMeta.name || process.argv[1]);
254
+ const argLines = [];
255
+ const posLines = [];
256
+ const commandsLines = [];
257
+ const usageLine = [];
258
+ for (const arg of cmdArgs) if (arg.type === "positional") {
259
+ const name = arg.name.toUpperCase();
260
+ const isRequired = arg.required !== false && arg.default === void 0;
261
+ posLines.push([cyan(name + renderValueHint(arg)), renderDescription(arg, isRequired)]);
262
+ usageLine.push(isRequired ? `<${name}>` : `[${name}]`);
263
+ } else {
264
+ const isRequired = arg.required === true && arg.default === void 0;
265
+ const argStr = [...(arg.alias || []).map((a) => `-${a}`), `--${arg.name}`].join(", ") + renderValueHint(arg);
266
+ argLines.push([cyan(argStr), renderDescription(arg, isRequired)]);
267
+ /**
268
+ * print negative boolean arg variant usage when
269
+ * - enabled by default or has `negativeDescription`
270
+ * - not prefixed with `no-` or `no[A-Z]`
271
+ */
272
+ if (arg.type === "boolean" && (arg.default === true || arg.negativeDescription) && !negativePrefixRe.test(arg.name)) {
273
+ const negativeArgStr = [...(arg.alias || []).map((a) => `--no-${a}`), `--no-${arg.name}`].join(", ");
274
+ argLines.push([cyan(negativeArgStr), [arg.negativeDescription, isRequired ? gray("(Required)") : ""].filter(Boolean).join(" ")]);
275
+ }
276
+ if (isRequired) usageLine.push(`--${arg.name}` + renderValueHint(arg));
277
+ }
278
+ if (cmd.subCommands) {
279
+ const commandNames = [];
280
+ const subCommands = await resolveValue(cmd.subCommands);
281
+ for (const [name, sub] of Object.entries(subCommands)) {
282
+ const meta = await resolveValue((await resolveValue(sub))?.meta);
283
+ if (meta?.hidden) continue;
284
+ const aliases = toArray(meta?.alias);
285
+ const label = [name, ...aliases].join(", ");
286
+ commandsLines.push([cyan(label), meta?.description || ""]);
287
+ commandNames.push(name, ...aliases);
288
+ }
289
+ usageLine.push(commandNames.join("|"));
290
+ }
291
+ const usageLines = [];
292
+ const version = cmdMeta.version || parentMeta.version;
293
+ usageLines.push(gray(`${cmdMeta.description} (${commandName + (version ? ` v${version}` : "")})`), "");
294
+ const hasOptions = argLines.length > 0 || posLines.length > 0;
295
+ usageLines.push(`${underline(bold("USAGE"))} ${cyan(`${commandName}${hasOptions ? " [OPTIONS]" : ""} ${usageLine.join(" ")}`)}`, "");
296
+ if (posLines.length > 0) {
297
+ usageLines.push(underline(bold("ARGUMENTS")), "");
298
+ usageLines.push(formatLineColumns(posLines, " "));
299
+ usageLines.push("");
300
+ }
301
+ if (argLines.length > 0) {
302
+ usageLines.push(underline(bold("OPTIONS")), "");
303
+ usageLines.push(formatLineColumns(argLines, " "));
304
+ usageLines.push("");
305
+ }
306
+ if (commandsLines.length > 0) {
307
+ usageLines.push(underline(bold("COMMANDS")), "");
308
+ usageLines.push(formatLineColumns(commandsLines, " "));
309
+ usageLines.push("", `Use ${cyan(`${commandName} <command> --help`)} for more information about a command.`);
310
+ }
311
+ return usageLines.filter((l) => typeof l === "string").join("\n");
312
+ }
313
+ function renderValueHint(arg) {
314
+ const valueHint = arg.valueHint ? `=<${arg.valueHint}>` : "";
315
+ const fallbackValueHint = valueHint || `=<${snakeCase(arg.name)}>`;
316
+ if (!arg.type || arg.type === "positional" || arg.type === "boolean") return valueHint;
317
+ if (arg.type === "enum" && arg.options?.length) return `=<${arg.options.join("|")}>`;
318
+ return fallbackValueHint;
319
+ }
320
+ function renderDescription(arg, required) {
321
+ const requiredHint = required ? gray("(Required)") : "";
322
+ const defaultHint = arg.default === void 0 ? "" : gray(`(Default: ${arg.default})`);
323
+ return [
324
+ arg.description,
325
+ requiredHint,
326
+ defaultHint
327
+ ].filter(Boolean).join(" ");
328
+ }
329
+ //#endregion
330
+ //#region src/deprecation.ts
331
+ const emitted = /* @__PURE__ */ new Set();
332
+ /**
333
+ * Emits a deprecation warning once per process, the way Node's own deprecated
334
+ * APIs (and libraries such as discord.js) do — so `--no-deprecation` and
335
+ * `--throw-deprecation` apply, and repeated construction doesn't spam the
336
+ * console. Falls back to `console.warn` in runtimes without
337
+ * `process.emitWarning`.
338
+ */
339
+ function emitDeprecationWarning(code, message) {
340
+ if (emitted.has(code)) return;
341
+ emitted.add(code);
342
+ const proc = globalThis.process;
343
+ if (typeof proc?.emitWarning === "function") {
344
+ proc.emitWarning(message, {
345
+ type: "DeprecationWarning",
346
+ code
347
+ });
348
+ return;
349
+ }
350
+ console.warn(`DeprecationWarning [${code}]: ${message}`);
351
+ }
352
+ //#endregion
353
+ //#region src/errors.ts
354
+ /** Thrown for any non-2xx response, network failure, timeout, or unparsable body. */
355
+ var FxTwitterError = class extends Error {
356
+ status;
357
+ code;
358
+ body;
359
+ constructor(message, options = {}) {
360
+ super(message, options.cause !== void 0 ? { cause: options.cause } : void 0);
361
+ this.name = "FxTwitterError";
362
+ this.status = options.status;
363
+ this.code = options.code;
364
+ this.body = options.body;
365
+ }
366
+ };
367
+ //#endregion
368
+ //#region src/user-agent.ts
369
+ /** Reads a version off a runtime global without assuming it exists. */
370
+ function globalVersion(name, path) {
371
+ let value = globalThis[name];
372
+ for (const key of path) {
373
+ if (typeof value !== "object" || value === null) return null;
374
+ value = value[key];
375
+ }
376
+ return typeof value === "string" ? value : null;
377
+ }
378
+ function product(name, version) {
379
+ return version ? `${name}/${version}` : name;
380
+ }
381
+ /**
382
+ * A `User-Agent` describing the runtime the request is made from, such as
383
+ * `Node.js/22.16.0`, `Bun/1.3.0` or `Cloudflare-Workers`.
384
+ *
385
+ * The API requires callers to identify themselves and answers `401` otherwise.
386
+ * Runtimes that fill the header in do so very tersely — Node sends just `node`
387
+ * — so this states the runtime and version instead. It deliberately says
388
+ * nothing about this package.
389
+ *
390
+ * Returns `null` in browsers, where `User-Agent` is a forbidden header: the
391
+ * request carries the browser's own regardless, and setting it only logs a
392
+ * warning.
393
+ */
394
+ function environmentUserAgent() {
395
+ if (hasWindow) return null;
396
+ switch (runtime) {
397
+ case "node": return product("Node.js", nodeVersion);
398
+ case "bun": return product("Bun", globalVersion("Bun", ["version"]));
399
+ case "deno": return product("Deno", globalVersion("Deno", ["version", "deno"]));
400
+ case "workerd": return "Cloudflare-Workers";
401
+ case "edge-light": return "Vercel-Edge";
402
+ case "fastly": return "Fastly-Compute";
403
+ case "netlify": return "Netlify-Edge";
404
+ default: return runtime || null;
405
+ }
406
+ }
407
+ /** Header lookup that ignores case, as HTTP header names are case-insensitive. */
408
+ function hasHeader(headers, name) {
409
+ if (!headers) return false;
410
+ const wanted = name.toLowerCase();
411
+ return Object.keys(headers).some((key) => key.toLowerCase() === wanted);
412
+ }
413
+ //#endregion
414
+ //#region src/http.ts
415
+ const DEFAULT_BASE_URL = "https://api.fxtwitter.com";
416
+ function isRecord(value) {
417
+ return typeof value === "object" && value !== null;
418
+ }
419
+ /**
420
+ * Wraps ofetch with the conventions shared by the clients: `null` for 204
421
+ * responses, and `FxTwitterError` for everything that fails.
422
+ */
423
+ var HttpClient = class {
424
+ request;
425
+ constructor(options = {}) {
426
+ const headers = { ...options.headers };
427
+ if (!hasHeader(headers, "User-Agent")) {
428
+ const userAgent = environmentUserAgent();
429
+ if (userAgent) headers["User-Agent"] = userAgent;
430
+ }
431
+ const globalOptions = { defaults: defu({
432
+ baseURL: options.baseUrl,
433
+ headers,
434
+ timeout: options.timeout,
435
+ retry: options.retry,
436
+ retryDelay: options.retryDelay
437
+ }, { baseURL: DEFAULT_BASE_URL }) };
438
+ if (options.fetch) globalOptions.fetch = options.fetch;
439
+ this.request = createFetch(globalOptions);
440
+ }
441
+ /** Runs a GET request and returns the parsed JSON body, or `null` on a 204. */
442
+ async get(options) {
443
+ let response;
444
+ try {
445
+ response = await this.request.raw(options.path, {
446
+ query: options.query,
447
+ signal: options.signal
448
+ });
449
+ } catch (cause) {
450
+ throw toFxTwitterError(cause);
451
+ }
452
+ if (response.status === 204) return null;
453
+ const body = response._data;
454
+ if (!isRecord(body)) throw new FxTwitterError(nonJsonMessage(body, response.status), {
455
+ status: response.status,
456
+ body
457
+ });
458
+ return body;
459
+ }
460
+ };
461
+ function nonJsonMessage(body, status) {
462
+ return `Expected a JSON response but got ${typeof body === "string" ? body.trimStart().startsWith("<") ? "an HTML document" : "a plain text body" : `a ${typeof body} body`} (HTTP ${status}). The endpoint may not exist, or the request was redirected.`;
463
+ }
464
+ /** ofetch wraps aborts in a FetchError, so the original name lives on `cause`. */
465
+ function isAbort(error) {
466
+ const names = /* @__PURE__ */ new Set(["AbortError", "TimeoutError"]);
467
+ if (names.has(error.name)) return true;
468
+ const cause = error.cause;
469
+ return isRecord(cause) && typeof cause.name === "string" ? names.has(cause.name) : false;
470
+ }
471
+ function toFxTwitterError(cause) {
472
+ if (cause instanceof FxTwitterError) return cause;
473
+ if (!(cause instanceof Error)) return new FxTwitterError("Network request failed", { cause });
474
+ if (isAbort(cause)) return new FxTwitterError("Request aborted or timed out", { cause });
475
+ if (!(cause instanceof FetchError)) return new FxTwitterError(cause.message, { cause });
476
+ const status = cause.status ?? cause.response?.status;
477
+ const data = cause.data;
478
+ if (isRecord(data)) return new FxTwitterError(typeof data.message === "string" ? data.message : typeof data.error === "string" ? data.error : `Request failed with HTTP ${status ?? "error"}`, {
479
+ status,
480
+ code: typeof data.code === "number" ? data.code : void 0,
481
+ body: data,
482
+ cause
483
+ });
484
+ if (data !== void 0 && status !== void 0) return new FxTwitterError(nonJsonMessage(data, status), {
485
+ status,
486
+ body: data,
487
+ cause
488
+ });
489
+ return new FxTwitterError(cause.message, {
490
+ status,
491
+ body: data,
492
+ cause
493
+ });
494
+ }
495
+ //#endregion
496
+ //#region src/v1.ts
497
+ /**
498
+ * The API resolves a status from the first 2-20 digit run in the path segment,
499
+ * and answers anything else with its embed HTML at HTTP 200 rather than JSON.
500
+ */
501
+ const STATUS_ID_PATTERN = /^\d{2,20}$/;
502
+ /**
503
+ * Handles are matched as `\w{1,15}` upstream; anything else is redirected to
504
+ * the project's GitHub page instead of returning JSON.
505
+ */
506
+ const HANDLE_PATTERN = /^\w{1,15}$/;
507
+ const DEPRECATION_MESSAGE = "FxTwitterV1 targets the legacy FxTwitter v1 API, which is kept only for backwards compatibility and does not receive new features. Use FxTwitterV2 (import from \"fxtwitter/v2\") instead.";
508
+ /**
509
+ * Client for the legacy FxTwitter v1 API.
510
+ *
511
+ * @deprecated Use {@link import("./v2").FxTwitterV2} instead. v1 is kept only
512
+ * for backwards compatibility and does not receive new features.
513
+ */
514
+ var FxTwitterV1 = class {
515
+ http;
516
+ constructor(options = {}) {
517
+ if (!options.silenceDeprecationWarning) emitDeprecationWarning("FXTWITTER_V1_DEPRECATED", DEPRECATION_MESSAGE);
518
+ this.http = new HttpClient(options);
519
+ }
520
+ /**
521
+ * Fetches a single status by its snowflake ID.
522
+ *
523
+ * `GET /status/:id`, `GET /status/:id/:language`,
524
+ * `GET /:handle/status/:id`, `GET /:handle/status/:id/:language`
525
+ */
526
+ async getStatus(id, options = {}) {
527
+ if (!STATUS_ID_PATTERN.test(id)) throw new FxTwitterError(`Invalid status ID ${JSON.stringify(id)}: expected 2-20 digits`);
528
+ if (options.screenName !== void 0 && options.screenName.trim() === "") throw new FxTwitterError("screenName must not be empty");
529
+ const segments = [
530
+ options.screenName,
531
+ "status",
532
+ id,
533
+ options.translateTo
534
+ ].filter((segment) => segment !== void 0);
535
+ return requireBody(await this.http.get({
536
+ path: `/${segments.map(encodeURIComponent).join("/")}`,
537
+ signal: options.signal
538
+ }));
539
+ }
540
+ /** Fetches a user profile by handle (`GET /:handle`). */
541
+ async getUser(screenName, options = {}) {
542
+ assertHandle(screenName);
543
+ return requireBody(await this.http.get({
544
+ path: `/${encodeURIComponent(screenName)}`,
545
+ signal: options.signal
546
+ }));
547
+ }
548
+ };
549
+ function assertHandle(handle) {
550
+ if (!HANDLE_PATTERN.test(handle)) throw new FxTwitterError(`Invalid handle ${JSON.stringify(handle)}: expected 1-15 letters, digits or underscores, without a leading @`);
551
+ }
552
+ /** v1 never answers 204, so an empty body here means something went wrong. */
553
+ function requireBody(body) {
554
+ if (body === null) throw new FxTwitterError("The API returned an empty response");
555
+ return body;
556
+ }
557
+ //#endregion
558
+ //#region src/v2.ts
559
+ /**
560
+ * Recovers the body of a 404 that carries a well-formed, empty result page,
561
+ * which is how the list endpoints report "no results or timeline unavailable".
562
+ */
563
+ function emptyPage(error) {
564
+ if (!(error instanceof FxTwitterError) || error.status !== 404) return null;
565
+ const body = error.body;
566
+ if (typeof body !== "object" || body === null) return null;
567
+ const results = body.results;
568
+ return Array.isArray(results) ? body : null;
569
+ }
570
+ /** Client for the FxTwitter/FxEmbed v2 API (https://docs.fxembed.com/api/twitter/). */
571
+ var FxTwitterV2 = class {
572
+ http;
573
+ constructor(options = {}) {
574
+ this.http = new HttpClient(options);
575
+ }
576
+ /** Only `/2/profile/{handle}/statuses` can answer 204, so everything else demands a body. */
577
+ async get(path, query, signal) {
578
+ const body = await this.http.get({
579
+ path,
580
+ query,
581
+ signal
582
+ });
583
+ if (body === null) throw new FxTwitterError("The API returned an empty response");
584
+ return body;
585
+ }
586
+ /**
587
+ * Same as {@link get}, for the endpoints that report "no results" as a 404
588
+ * carrying an otherwise normal body. An empty page is a result, not an
589
+ * error, so it is returned rather than thrown.
590
+ *
591
+ * The API uses that same 404 for an unknown handle, which it does not
592
+ * distinguish from an empty timeline.
593
+ */
594
+ async getPage(path, query, signal) {
595
+ try {
596
+ return await this.get(path, query, signal);
597
+ } catch (error) {
598
+ const page = emptyPage(error);
599
+ if (page) return page;
600
+ throw error;
601
+ }
602
+ }
603
+ /** `GET /2/status/{id}` — a single post by snowflake ID. */
604
+ getStatus(id, options = {}) {
605
+ return this.get(`/2/status/${encodeURIComponent(id)}`, {
606
+ about_account: options.aboutAccount ? "1" : void 0,
607
+ lang: options.lang
608
+ }, options.signal);
609
+ }
610
+ /** `GET /2/status/{id}/reposts` — users who reposted a post. */
611
+ getStatusReposts(id, options = {}) {
612
+ return this.getPage(`/2/status/${encodeURIComponent(id)}/reposts`, {
613
+ count: options.count,
614
+ cursor: options.cursor
615
+ }, options.signal);
616
+ }
617
+ /** `GET /2/status/{id}/quotes` — posts quoting a post. */
618
+ getStatusQuotes(id, options = {}) {
619
+ return this.getPage(`/2/status/${encodeURIComponent(id)}/quotes`, {
620
+ count: options.count,
621
+ cursor: options.cursor,
622
+ lang: options.lang
623
+ }, options.signal);
624
+ }
625
+ /** `GET /2/thread/{id}` — a post with its unrolled thread. */
626
+ getThread(id, options = {}) {
627
+ return this.get(`/2/thread/${encodeURIComponent(id)}`, {
628
+ about_account: options.aboutAccount ? "1" : void 0,
629
+ lang: options.lang
630
+ }, options.signal);
631
+ }
632
+ /** `GET /2/conversation/{id}` — a post, its thread, and ranked replies. */
633
+ getConversation(id, options = {}) {
634
+ return this.get(`/2/conversation/${encodeURIComponent(id)}`, {
635
+ ranking_mode: options.rankingMode,
636
+ cursor: options.cursor,
637
+ about_account: options.aboutAccount ? "1" : void 0,
638
+ lang: options.lang
639
+ }, options.signal);
640
+ }
641
+ /**
642
+ * `GET /2/profile/{handle}` — a user's profile. `handle` is a username
643
+ * without `@`, or `byUserId(id)` for a numeric user ID.
644
+ */
645
+ getProfile(handle, options = {}) {
646
+ return this.get(`/2/profile/${encodeURIComponent(handle)}`, { about_account: options.aboutAccount ? "1" : void 0 }, options.signal);
647
+ }
648
+ /**
649
+ * `GET /2/profile/{handle}/statuses` — a user's posts. Resolves to `null`
650
+ * when `since` is set (without `cursor`) and nothing is newer than it.
651
+ */
652
+ async getProfileStatuses(handle, options = {}) {
653
+ try {
654
+ return await this.http.get({
655
+ path: `/2/profile/${encodeURIComponent(handle)}/statuses`,
656
+ query: {
657
+ count: options.count,
658
+ cursor: options.cursor,
659
+ since: options.since,
660
+ with_replies: options.withReplies ? "1" : void 0,
661
+ groupthreads: options.groupThreads ? "1" : void 0,
662
+ lang: options.lang
663
+ },
664
+ signal: options.signal
665
+ });
666
+ } catch (error) {
667
+ const page = emptyPage(error);
668
+ if (page) return page;
669
+ throw error;
670
+ }
671
+ }
672
+ /** `GET /2/profile/{handle}/articles` — a user's long-form articles. */
673
+ getProfileArticles(handle, options = {}) {
674
+ return this.getPage(`/2/profile/${encodeURIComponent(handle)}/articles`, {
675
+ count: options.count,
676
+ cursor: options.cursor,
677
+ lang: options.lang
678
+ }, options.signal);
679
+ }
680
+ /** `GET /2/profile/{handle}/about` — account metadata (based-in country, name changes). */
681
+ getProfileAbout(handle, options = {}) {
682
+ return this.get(`/2/profile/${encodeURIComponent(handle)}/about`, void 0, options.signal);
683
+ }
684
+ /** `GET /2/profile/{handle}/media` — a user's posts that contain media. */
685
+ getProfileMedia(handle, options = {}) {
686
+ return this.getPage(`/2/profile/${encodeURIComponent(handle)}/media`, {
687
+ count: options.count,
688
+ cursor: options.cursor,
689
+ lang: options.lang
690
+ }, options.signal);
691
+ }
692
+ /** `GET /2/profile/{handle}/followers` — a user's followers. */
693
+ getProfileFollowers(handle, options = {}) {
694
+ return this.getPage(`/2/profile/${encodeURIComponent(handle)}/followers`, {
695
+ count: options.count,
696
+ cursor: options.cursor
697
+ }, options.signal);
698
+ }
699
+ /** `GET /2/profile/{handle}/following` — accounts a user follows. */
700
+ getProfileFollowing(handle, options = {}) {
701
+ return this.getPage(`/2/profile/${encodeURIComponent(handle)}/following`, {
702
+ count: options.count,
703
+ cursor: options.cursor
704
+ }, options.signal);
705
+ }
706
+ /** `GET /2/search` — search posts. */
707
+ search(query, options = {}) {
708
+ return this.getPage("/2/search", {
709
+ q: query,
710
+ feed: options.feed,
711
+ count: options.count,
712
+ cursor: options.cursor,
713
+ lang: options.lang
714
+ }, options.signal);
715
+ }
716
+ /** `GET /2/typeahead` — search-box autocomplete suggestions. */
717
+ typeahead(query, options = {}) {
718
+ return this.get("/2/typeahead", {
719
+ q: query,
720
+ result_type: options.resultType?.join(","),
721
+ src: options.src
722
+ }, options.signal);
723
+ }
724
+ /** `GET /2/trends` — trending topics. */
725
+ trends(options = {}) {
726
+ return this.get("/2/trends", {
727
+ type: "trending",
728
+ count: options.count
729
+ }, options.signal);
730
+ }
731
+ };
732
+ //#endregion
733
+ //#region src/cli.ts
734
+ const flags = {
735
+ api: {
736
+ type: "enum",
737
+ options: ["v1", "v2"],
738
+ default: "v2",
739
+ description: "API version"
740
+ },
741
+ "base-url": {
742
+ type: "string",
743
+ valueHint: "url",
744
+ description: "Override the API base URL"
745
+ },
746
+ timeout: {
747
+ type: "string",
748
+ valueHint: "ms",
749
+ description: "Request timeout"
750
+ },
751
+ retry: {
752
+ type: "string",
753
+ valueHint: "count",
754
+ description: "Retry count (use 0 to disable retries)"
755
+ },
756
+ "retry-delay": {
757
+ type: "string",
758
+ valueHint: "ms",
759
+ description: "Delay between retries"
760
+ },
761
+ pretty: {
762
+ type: "boolean",
763
+ description: "Indent JSON output"
764
+ },
765
+ help: {
766
+ type: "boolean",
767
+ alias: "h",
768
+ description: "Show help"
769
+ }
770
+ };
771
+ const STATUS_ID = /^\d{2,20}$/;
772
+ const STATUS_HOSTS = /* @__PURE__ */ new Set(["x.com", "twitter.com"]);
773
+ const parseStatusId = (input) => {
774
+ if (STATUS_ID.test(input)) return input;
775
+ const url = URL.canParse(input) ? new URL(input) : null;
776
+ if (url && (url.protocol === "https:" || url.protocol === "http:")) {
777
+ const host = url.hostname.replace(/^(www|mobile)\./, "");
778
+ const id = /^\/(?:\w{1,15}|i\/web)\/status\/(\d{2,20})\/?$/.exec(url.pathname)?.[1];
779
+ if (STATUS_HOSTS.has(host) && id !== void 0) return id;
780
+ }
781
+ throw new Error("Expected a status ID (2–20 digits) or an X/Twitter status URL");
782
+ };
783
+ const parseBaseUrl = (input) => {
784
+ if (input === void 0) return void 0;
785
+ const url = URL.canParse(input) ? new URL(input) : null;
786
+ if (!url || url.protocol !== "https:" && url.protocol !== "http:") throw new Error("--base-url must be an absolute HTTP or HTTPS URL");
787
+ return input;
788
+ };
789
+ const parseNonNegativeInteger = (flag, value, max = Number.MAX_SAFE_INTEGER) => {
790
+ if (value === void 0) return void 0;
791
+ const number = Number(value);
792
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(number)) throw new Error(`--${flag} must be a non-negative integer`);
793
+ if (number > max) throw new Error(`--${flag} must be at most ${max}`);
794
+ return number;
795
+ };
796
+ const MAX_TIMER_DELAY = 2147483647;
797
+ const commands = {
798
+ status: {
799
+ argument: "id-or-url",
800
+ description: "Fetch a post by ID or X/Twitter status URL",
801
+ run: (input, version, options) => {
802
+ const id = parseStatusId(input);
803
+ return (version === "v1" ? new FxTwitterV1(options) : new FxTwitterV2(options)).getStatus(id);
804
+ }
805
+ },
806
+ profile: {
807
+ argument: "screen-name",
808
+ description: "Fetch a profile by screen name",
809
+ run: (input, version, options) => {
810
+ const handle = input.replace(/^@/, "");
811
+ if (!handle) throw new Error("Expected a profile screen name");
812
+ if (version === "v1") return new FxTwitterV1(options).getUser(handle);
813
+ return new FxTwitterV2(options).getProfile(handle);
814
+ }
815
+ },
816
+ search: {
817
+ argument: "query",
818
+ description: "Search posts (v2 only)",
819
+ run: (input, version, options) => {
820
+ if (version === "v1") throw new Error("search requires API v2");
821
+ return new FxTwitterV2(options).search(input);
822
+ }
823
+ }
824
+ };
825
+ const cliMeta = {
826
+ name: "fxtwitter",
827
+ description: "Query the FxTwitter API"
828
+ };
829
+ const printUsage = async (...params) => {
830
+ console.log((await renderUsage(...params)).replace(/(--[\w-]+)=</g, "$1 <"));
831
+ };
832
+ const main = async (rawArgs) => {
833
+ const args = parseArgs$1(rawArgs, flags);
834
+ for (const key of Object.keys(args)) {
835
+ const flag = key.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`);
836
+ if (key !== "_" && key !== "h" && !Object.hasOwn(flags, flag)) throw new Error(`Unknown option ${JSON.stringify(key)}`);
837
+ }
838
+ const [name, input] = args._;
839
+ if (name === void 0 && (args.help || rawArgs.length === 0)) {
840
+ const subCommands = Object.fromEntries(Object.entries(commands).map(([name, { description }]) => [name, { meta: { description } }]));
841
+ await printUsage({
842
+ meta: cliMeta,
843
+ args: flags,
844
+ subCommands
845
+ });
846
+ return;
847
+ }
848
+ if (name === void 0 || !Object.hasOwn(commands, name)) throw new Error(name === void 0 ? "Expected a command. Run fxtwitter --help for usage." : `Unknown command ${JSON.stringify(name)}. Run fxtwitter --help for usage.`);
849
+ const command = commands[name];
850
+ if (args.help) {
851
+ await printUsage({
852
+ meta: {
853
+ name,
854
+ description: command.description
855
+ },
856
+ args: {
857
+ ...flags,
858
+ [command.argument]: { type: "positional" }
859
+ }
860
+ }, { meta: cliMeta });
861
+ return;
862
+ }
863
+ if (args._.length !== 2 || !input.trim()) throw new Error(`${name} requires exactly one non-empty argument`);
864
+ const options = {
865
+ baseUrl: parseBaseUrl(args["base-url"]),
866
+ timeout: parseNonNegativeInteger("timeout", args.timeout, MAX_TIMER_DELAY),
867
+ retry: parseNonNegativeInteger("retry", args.retry),
868
+ retryDelay: parseNonNegativeInteger("retry-delay", args["retry-delay"], MAX_TIMER_DELAY)
869
+ };
870
+ const response = await command.run(input, args.api, options);
871
+ console.log(JSON.stringify(response, null, args.pretty ? 2 : void 0));
872
+ };
873
+ try {
874
+ await main(process.argv.slice(2));
875
+ } catch (error) {
876
+ console.error(`fxtwitter: ${error instanceof Error ? error.message : error}`);
877
+ process.exitCode = 1;
878
+ }
879
+ //#endregion
880
+ export {};