fxtwitter 2.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/README.md +21 -0
- package/dist/LICENSE-citty +21 -0
- package/dist/THIRD-PARTY-LICENSES-citty.md +33 -0
- package/dist/cli.mjs +880 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -13,6 +13,27 @@ Typed wrapper for the [FxTwitter](https://github.com/FxEmbed/FxEmbed) API.
|
|
|
13
13
|
npm install fxtwitter
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
+
## CLI
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
npx fxtwitter status 20
|
|
20
|
+
npx fxtwitter status "https://x.com/jack/status/20" --pretty
|
|
21
|
+
npx fxtwitter profile @jack --api v1
|
|
22
|
+
npx fxtwitter search "from:jack puppies" | jq '.results'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The CLI uses API v2 by default. Use `--api v1` with `status` or `profile`. Search is available in v2. Profile handles can include a leading `@`.
|
|
26
|
+
|
|
27
|
+
Successful commands write compact JSON to stdout. Use `--pretty` to indent it. Errors go to stderr and exit with a nonzero status.
|
|
28
|
+
|
|
29
|
+
| Option | Description |
|
|
30
|
+
| ------ | ----------- |
|
|
31
|
+
| `--base-url <url>` | Override the API base URL |
|
|
32
|
+
| `--timeout <ms>` | Set the request timeout |
|
|
33
|
+
| `--retry <count>` | Set the retry count (use `0` to disable retries) |
|
|
34
|
+
| `--retry-delay <ms>` | Set the delay between retries |
|
|
35
|
+
| `-h, --help` | Show commands and options. Use `fxtwitter status --help` for help with a specific command |
|
|
36
|
+
|
|
16
37
|
## Usage
|
|
17
38
|
|
|
18
39
|
```ts
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Pooya Parsa <pooya@pi0.io>
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Licenses of Bundled Dependencies
|
|
2
|
+
|
|
3
|
+
The published artifact additionally contains code with the following licenses:
|
|
4
|
+
MIT
|
|
5
|
+
|
|
6
|
+
# Bundled Dependencies
|
|
7
|
+
|
|
8
|
+
## scule
|
|
9
|
+
|
|
10
|
+
License: MIT
|
|
11
|
+
Repository: https://github.com/unjs/scule
|
|
12
|
+
|
|
13
|
+
> MIT License
|
|
14
|
+
>
|
|
15
|
+
> Copyright (c) Pooya Parsa <pooya@pi0.io>
|
|
16
|
+
>
|
|
17
|
+
> Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
18
|
+
> of this software and associated documentation files (the "Software"), to deal
|
|
19
|
+
> in the Software without restriction, including without limitation the rights
|
|
20
|
+
> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
21
|
+
> copies of the Software, and to permit persons to whom the Software is
|
|
22
|
+
> furnished to do so, subject to the following conditions:
|
|
23
|
+
>
|
|
24
|
+
> The above copyright notice and this permission notice shall be included in all
|
|
25
|
+
> copies or substantial portions of the Software.
|
|
26
|
+
>
|
|
27
|
+
> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
28
|
+
> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
29
|
+
> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
30
|
+
> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
31
|
+
> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
32
|
+
> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
33
|
+
> SOFTWARE.
|
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 {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fxtwitter",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Typed wrapper for the FxTwitter API.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"x",
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
"license": "MIT",
|
|
21
21
|
"author": "otoneko.",
|
|
22
22
|
"type": "module",
|
|
23
|
+
"bin": {
|
|
24
|
+
"fxtwitter": "./dist/cli.mjs"
|
|
25
|
+
},
|
|
23
26
|
"main": "dist/index.cjs",
|
|
24
27
|
"module": "dist/index.mjs",
|
|
25
28
|
"types": "dist/index.d.mts",
|
|
@@ -80,6 +83,7 @@
|
|
|
80
83
|
"@eslint/js": "^10.0.1",
|
|
81
84
|
"@types/node": "^22.20.1",
|
|
82
85
|
"@vitest/coverage-v8": "^4.1.10",
|
|
86
|
+
"citty": "^0.2.2",
|
|
83
87
|
"eslint": "^10.8.0",
|
|
84
88
|
"eslint-config-prettier": "^10.1.8",
|
|
85
89
|
"globals": "^17.8.0",
|