@shreejitverma/x-axi 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +401 -2
- package/dist/bin/x-axi.js +4 -0
- package/dist/src/api.js +112 -0
- package/dist/src/approve.js +325 -0
- package/dist/src/args.js +201 -0
- package/dist/src/auth.js +419 -0
- package/dist/src/cli.js +94 -0
- package/dist/src/commands.js +1611 -0
- package/dist/src/config.js +67 -0
- package/dist/src/keystore.js +186 -0
- package/dist/src/ledger.js +172 -0
- package/dist/src/media.js +123 -0
- package/dist/src/oauth.js +149 -0
- package/dist/src/rates.js +47 -0
- package/dist/src/version.js +4 -0
- package/package.json +57 -4
- package/skills/x-axi/SKILL.md +69 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
/** A fixed port and path, because X matches the registered callback exactly. 127.0.0.1 follows RFC 8252 section 7.3. */
|
|
6
|
+
export const DEFAULT_REDIRECT_URI = "http://127.0.0.1:8711/callback";
|
|
7
|
+
export function configDir() {
|
|
8
|
+
const xdg = process.env["XDG_CONFIG_HOME"];
|
|
9
|
+
return join(xdg || join(homedir(), ".config"), "x-axi");
|
|
10
|
+
}
|
|
11
|
+
export function configPath() {
|
|
12
|
+
return join(configDir(), "config.json");
|
|
13
|
+
}
|
|
14
|
+
export function readConfig() {
|
|
15
|
+
let raw;
|
|
16
|
+
try {
|
|
17
|
+
raw = readFileSync(configPath(), "utf8");
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (["ENOENT", "ENOTDIR"].includes(error.code ?? ""))
|
|
21
|
+
return {};
|
|
22
|
+
throw new AxiError(`cannot read ${configPath()}: ${error.message}`, "CONFIG_INVALID");
|
|
23
|
+
}
|
|
24
|
+
let value;
|
|
25
|
+
try {
|
|
26
|
+
value = JSON.parse(raw);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
throw new AxiError(`${configPath()} is not valid JSON: ${error.message}`, "CONFIG_INVALID", ["Fix or delete the file, then run `x-axi login --client-id YOUR_CLIENT_ID`"]);
|
|
30
|
+
}
|
|
31
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
32
|
+
throw new AxiError(`${configPath()} must hold a JSON object`, "CONFIG_INVALID");
|
|
33
|
+
const config = value;
|
|
34
|
+
const result = {};
|
|
35
|
+
if (config["client_id"] !== undefined)
|
|
36
|
+
result.client_id = parseClientId(config["client_id"], configPath());
|
|
37
|
+
if (config["redirect_uri"] !== undefined)
|
|
38
|
+
result.redirect_uri = parseRedirectUri(config["redirect_uri"], configPath());
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
export function writeConfig(config) {
|
|
42
|
+
const dir = configDir();
|
|
43
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
44
|
+
const temp = join(dir, "config.json.tmp");
|
|
45
|
+
writeFileSync(temp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
|
|
46
|
+
renameSync(temp, configPath());
|
|
47
|
+
}
|
|
48
|
+
/** X client IDs are URL-safe tokens. Anything else is a paste error, and must not reach a URL. */
|
|
49
|
+
export function parseClientId(value, source) {
|
|
50
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_-]{8,200}$/.test(value)) {
|
|
51
|
+
throw new AxiError(`${source} does not hold a valid OAuth 2.0 client ID`, "VALIDATION_ERROR", [
|
|
52
|
+
"Copy the OAuth 2.0 Client ID from your app's Keys and tokens page at https://console.x.com",
|
|
53
|
+
]);
|
|
54
|
+
}
|
|
55
|
+
return value;
|
|
56
|
+
}
|
|
57
|
+
/** Only a 127.0.0.1 callback on an unprivileged port: the browser must come back to this machine, and X must match it exactly. */
|
|
58
|
+
export function parseRedirectUri(value, source) {
|
|
59
|
+
const port = typeof value === "string" ? /^http:\/\/127\.0\.0\.1:([1-9]\d{3,4})\/[\w/-]*$/.exec(value)?.[1] : undefined;
|
|
60
|
+
if (port === undefined || Number(port) < 1024 || Number(port) > 65535) {
|
|
61
|
+
throw new AxiError(`${source} redirect_uri must be an http://127.0.0.1 URL with a port from 1024 to 65535, like ${DEFAULT_REDIRECT_URI}`, "VALIDATION_ERROR", [
|
|
62
|
+
"Register the same URI as the app's Callback URI in the X portal; X matches it character for character",
|
|
63
|
+
]);
|
|
64
|
+
}
|
|
65
|
+
return value;
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { accessSync, constants } from "node:fs";
|
|
4
|
+
import { delimiter, join } from "node:path";
|
|
5
|
+
const COMMAND_TIMEOUT_MS = 30_000;
|
|
6
|
+
export const runCommand = (spec) => new Promise((resolve) => {
|
|
7
|
+
const child = execFile(spec.file, spec.args, { timeout: COMMAND_TIMEOUT_MS, maxBuffer: 1 << 20 }, (error, stdout, stderr) => {
|
|
8
|
+
const failure = error;
|
|
9
|
+
resolve({
|
|
10
|
+
code: failure ? (typeof failure.code === "number" ? failure.code : null) : 0,
|
|
11
|
+
stdout,
|
|
12
|
+
stderr: failure?.killed ? `timed out after ${COMMAND_TIMEOUT_MS / 1000} seconds\n${stderr}` : stderr,
|
|
13
|
+
missing: failure?.code === "ENOENT",
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
// A child that exits before reading stdin raises EPIPE here; its exit code already carries the failure.
|
|
17
|
+
child.stdin?.on("error", () => undefined);
|
|
18
|
+
child.stdin?.end(spec.stdin ?? "");
|
|
19
|
+
});
|
|
20
|
+
const SERVICE = "x-axi";
|
|
21
|
+
const ACCOUNT = "oauth2";
|
|
22
|
+
const SECURITY = "/usr/bin/security";
|
|
23
|
+
// `security -i` splits a line near 4096 bytes and runs each fragment as its own command, which
|
|
24
|
+
// in the experiment wrote a fragment to the default keychain. Refusing well below that bound
|
|
25
|
+
// keeps a record from ever being split.
|
|
26
|
+
const MAX_SECURITY_LINE = 3000;
|
|
27
|
+
const ERR_SEC_ITEM_NOT_FOUND = 44;
|
|
28
|
+
const ERR_SEC_ITEM_NOT_FOUND_STATUS = "-25300";
|
|
29
|
+
const ERR_SEC_INTERACTION_NOT_ALLOWED = 36;
|
|
30
|
+
/** Base64 keeps the record to one shell-safe token with no quoting rules to get wrong. */
|
|
31
|
+
function encode(secret) {
|
|
32
|
+
return Buffer.from(secret, "utf8").toString("base64");
|
|
33
|
+
}
|
|
34
|
+
/** KEYSTORE_CORRUPT is the one read failure logout may treat as "a record exists": the backend worked, the content did not. */
|
|
35
|
+
function decode(stored, backend) {
|
|
36
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(stored)) {
|
|
37
|
+
throw new AxiError(`the ${backend} record for x-axi is not one this version wrote`, "KEYSTORE_CORRUPT", [
|
|
38
|
+
"Run `x-axi logout` to delete it, then `x-axi login`",
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
return Buffer.from(stored, "base64").toString("utf8");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* `security` exits with the low byte of an OSStatus, so the text is checked too before a failure counts as "no record".
|
|
45
|
+
* That text follows the system language, so `security error` supplies it. It exits 1 even when it prints the message.
|
|
46
|
+
*/
|
|
47
|
+
async function keychainSaysNotFound(result, run) {
|
|
48
|
+
if (result.code !== ERR_SEC_ITEM_NOT_FOUND)
|
|
49
|
+
return false;
|
|
50
|
+
const explained = await run({ file: SECURITY, args: ["error", ERR_SEC_ITEM_NOT_FOUND_STATUS] });
|
|
51
|
+
const message = new RegExp(`^Error: \\S+ ${ERR_SEC_ITEM_NOT_FOUND_STATUS} (.+)$`).exec(explained.stdout.trim())?.[1];
|
|
52
|
+
return message !== undefined && result.stderr.includes(message);
|
|
53
|
+
}
|
|
54
|
+
/** Only the first line of stderr, with the secret removed in case a tool echoed its input. */
|
|
55
|
+
function stderrSummary(result, secrets) {
|
|
56
|
+
let line = result.stderr.split("\n").find((l) => l.trim()) ?? "";
|
|
57
|
+
for (const secret of secrets)
|
|
58
|
+
if (secret)
|
|
59
|
+
line = line.replaceAll(secret, "[redacted]");
|
|
60
|
+
return line.slice(0, 160) || `exit code ${String(result.code)}`;
|
|
61
|
+
}
|
|
62
|
+
function keystoreFailure(backend, action, result, secrets = []) {
|
|
63
|
+
return new AxiError(`${backend} could not ${action} the x-axi login: ${stderrSummary(result, secrets)}`, "KEYSTORE_ERROR");
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* macOS Keychain through the system `security` tool at its absolute path. Writes go through
|
|
67
|
+
* `security -i`, which reads the whole command from stdin, so the record never appears in argv.
|
|
68
|
+
* The optional keychain path exists for tests, which use a temporary keychain and never the login keychain.
|
|
69
|
+
*/
|
|
70
|
+
export function keychainStore(run, keychain) {
|
|
71
|
+
const backend = "macOS Keychain";
|
|
72
|
+
if (keychain !== undefined && !/^[\w./-]+$/.test(keychain)) {
|
|
73
|
+
throw new AxiError("the keychain path may contain only letters, digits, and ./_-", "VALIDATION_ERROR");
|
|
74
|
+
}
|
|
75
|
+
const target = keychain === undefined ? [] : [keychain];
|
|
76
|
+
const fail = (action, result, secrets) => {
|
|
77
|
+
if (result.code === ERR_SEC_INTERACTION_NOT_ALLOWED) {
|
|
78
|
+
return new AxiError(`the keychain is locked and this session cannot show an unlock prompt, so x-axi could not ${action} its login`, "KEYSTORE_LOCKED", [
|
|
79
|
+
"Unlock it with `security unlock-keychain` in that session, or log in to the Mac's desktop session",
|
|
80
|
+
]);
|
|
81
|
+
}
|
|
82
|
+
return keystoreFailure(backend, action, result, secrets);
|
|
83
|
+
};
|
|
84
|
+
return {
|
|
85
|
+
backend,
|
|
86
|
+
async read() {
|
|
87
|
+
const result = await run({ file: SECURITY, args: ["find-generic-password", "-a", ACCOUNT, "-s", SERVICE, "-w", ...target] });
|
|
88
|
+
if (await keychainSaysNotFound(result, run))
|
|
89
|
+
return undefined;
|
|
90
|
+
if (result.code !== 0)
|
|
91
|
+
throw fail("read", result);
|
|
92
|
+
return decode(result.stdout.trim(), backend);
|
|
93
|
+
},
|
|
94
|
+
async write(secret) {
|
|
95
|
+
const encoded = encode(secret);
|
|
96
|
+
const line = `add-generic-password -a ${ACCOUNT} -s ${SERVICE} -w ${encoded} -U ${target.join(" ")}\n`;
|
|
97
|
+
if (Buffer.byteLength(line) > MAX_SECURITY_LINE) {
|
|
98
|
+
throw new AxiError("the login record is too large to store in the keychain safely; nothing was written", "KEYSTORE_ERROR");
|
|
99
|
+
}
|
|
100
|
+
const result = await run({ file: SECURITY, args: ["-i"], stdin: line });
|
|
101
|
+
if (result.code !== 0)
|
|
102
|
+
throw fail("store", result, [secret, encoded]);
|
|
103
|
+
},
|
|
104
|
+
async delete() {
|
|
105
|
+
const result = await run({ file: SECURITY, args: ["delete-generic-password", "-a", ACCOUNT, "-s", SERVICE, ...target] });
|
|
106
|
+
if (result.code !== 0 && !(await keychainSaysNotFound(result, run)))
|
|
107
|
+
throw fail("delete", result);
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
export const SECRET_SERVICE_INSTALL = [
|
|
112
|
+
"Install secret-tool: `sudo apt install libsecret-tools` (Debian, Ubuntu), `sudo dnf install libsecret` (Fedora), or `sudo pacman -S libsecret` (Arch)",
|
|
113
|
+
"Make sure a Secret Service is running, such as GNOME Keyring or KeePassXC with Secret Service integration enabled",
|
|
114
|
+
];
|
|
115
|
+
/** Linux Secret Service through `secret-tool`, which reads the secret from stdin when stdin is not a terminal. */
|
|
116
|
+
export function secretServiceStore(run, tool) {
|
|
117
|
+
const backend = "Secret Service";
|
|
118
|
+
const attrs = ["service", SERVICE, "account", ACCOUNT];
|
|
119
|
+
const unavailable = (result) => new AxiError(`secret-tool failed: ${stderrSummary(result, [])}`, "KEYSTORE_UNAVAILABLE", SECRET_SERVICE_INSTALL);
|
|
120
|
+
return {
|
|
121
|
+
backend,
|
|
122
|
+
async read() {
|
|
123
|
+
const result = await run({ file: tool, args: ["lookup", ...attrs] });
|
|
124
|
+
if (result.missing)
|
|
125
|
+
throw unavailable(result);
|
|
126
|
+
// secret-tool exits 1 both for a missing item and for a missing daemon; only the daemon case writes to stderr.
|
|
127
|
+
if (result.code === 1 && !result.stderr.trim())
|
|
128
|
+
return undefined;
|
|
129
|
+
if (result.code !== 0)
|
|
130
|
+
throw unavailable(result);
|
|
131
|
+
return decode(result.stdout.trim(), backend);
|
|
132
|
+
},
|
|
133
|
+
async write(secret) {
|
|
134
|
+
const encoded = encode(secret);
|
|
135
|
+
const result = await run({ file: tool, args: ["store", `--label=${SERVICE}`, ...attrs], stdin: encoded });
|
|
136
|
+
if (result.missing)
|
|
137
|
+
throw unavailable(result);
|
|
138
|
+
if (result.code !== 0)
|
|
139
|
+
throw keystoreFailure(backend, "store", result, [secret, encoded]);
|
|
140
|
+
},
|
|
141
|
+
async delete() {
|
|
142
|
+
const result = await run({ file: tool, args: ["clear", ...attrs] });
|
|
143
|
+
if (result.missing)
|
|
144
|
+
throw unavailable(result);
|
|
145
|
+
if (result.code !== 0)
|
|
146
|
+
throw keystoreFailure(backend, "delete", result);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/** Every call fails with the reason the platform has no keystore. There is no plaintext fallback. */
|
|
151
|
+
export function unavailableKeystore(error) {
|
|
152
|
+
const reject = () => Promise.reject(error);
|
|
153
|
+
return { backend: `unavailable (${error.message})`, read: reject, write: reject, delete: reject };
|
|
154
|
+
}
|
|
155
|
+
export function findOnPath(name, path = process.env["PATH"] ?? "") {
|
|
156
|
+
for (const dir of path.split(delimiter)) {
|
|
157
|
+
if (!dir)
|
|
158
|
+
continue;
|
|
159
|
+
const candidate = join(dir, name);
|
|
160
|
+
try {
|
|
161
|
+
accessSync(candidate, constants.X_OK);
|
|
162
|
+
return candidate;
|
|
163
|
+
}
|
|
164
|
+
catch {
|
|
165
|
+
// Not here; keep looking.
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return undefined;
|
|
169
|
+
}
|
|
170
|
+
export function selectKeystore(env = { platform: process.platform, run: runCommand, which: findOnPath }) {
|
|
171
|
+
if (env.platform === "darwin")
|
|
172
|
+
return keychainStore(env.run);
|
|
173
|
+
if (env.platform === "linux") {
|
|
174
|
+
const tool = env.which("secret-tool");
|
|
175
|
+
if (tool)
|
|
176
|
+
return secretServiceStore(env.run, tool);
|
|
177
|
+
return unavailableKeystore(new AxiError("no OS keystore is available: secret-tool is not installed, and x-axi never stores a login in a plain file", "KEYSTORE_UNAVAILABLE", SECRET_SERVICE_INSTALL));
|
|
178
|
+
}
|
|
179
|
+
if (env.platform === "win32") {
|
|
180
|
+
return unavailableKeystore(new AxiError("Windows is not supported: x-axi has no Windows Credential Manager backend yet, and it never stores a login in a plain file", "KEYSTORE_UNAVAILABLE", [
|
|
181
|
+
"Run x-axi on macOS or Linux, or add a Credential Manager backend",
|
|
182
|
+
]));
|
|
183
|
+
}
|
|
184
|
+
return unavailableKeystore(new AxiError(`no OS keystore backend exists for ${env.platform}, and x-axi never stores a login in a plain file`, "KEYSTORE_UNAVAILABLE"));
|
|
185
|
+
}
|
|
186
|
+
//# sourceMappingURL=keystore.js.map
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
import { appendFileSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
export function stateDir() {
|
|
6
|
+
return process.env["X_AXI_STATE_DIR"] ?? join(homedir(), ".local", "state", "x-axi");
|
|
7
|
+
}
|
|
8
|
+
function ensureDir() {
|
|
9
|
+
const dir = stateDir();
|
|
10
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
11
|
+
return dir;
|
|
12
|
+
}
|
|
13
|
+
/** Returns a warning instead of throwing: a ledger problem must never hide that a write succeeded. */
|
|
14
|
+
export function recordSpend(op, usd, now = new Date()) {
|
|
15
|
+
try {
|
|
16
|
+
const entry = { at: now.toISOString(), op, usd };
|
|
17
|
+
appendFileSync(join(ensureDir(), "ledger.jsonl"), `${JSON.stringify(entry)}\n`);
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
return `spend ledger not updated: ${String(error)}`;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function summarizeSpend(now = new Date()) {
|
|
25
|
+
let raw = "";
|
|
26
|
+
try {
|
|
27
|
+
raw = readFileSync(join(stateDir(), "ledger.jsonl"), "utf8");
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
if (error.code !== "ENOENT")
|
|
31
|
+
throw error;
|
|
32
|
+
}
|
|
33
|
+
const day = now.toISOString().slice(0, 10);
|
|
34
|
+
const cutoff = now.getTime() - 30 * 24 * 3600 * 1000;
|
|
35
|
+
let today = 0;
|
|
36
|
+
let month = 0;
|
|
37
|
+
let calls = 0;
|
|
38
|
+
let unpriced = 0;
|
|
39
|
+
let corrupt = 0;
|
|
40
|
+
for (const line of raw.split("\n")) {
|
|
41
|
+
if (!line)
|
|
42
|
+
continue;
|
|
43
|
+
let entry;
|
|
44
|
+
try {
|
|
45
|
+
entry = JSON.parse(line);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
corrupt += 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (new Date(entry.at).getTime() < cutoff)
|
|
52
|
+
continue;
|
|
53
|
+
calls += 1;
|
|
54
|
+
if (entry.usd === null)
|
|
55
|
+
unpriced += 1;
|
|
56
|
+
month += entry.usd ?? 0;
|
|
57
|
+
if (entry.at.startsWith(day))
|
|
58
|
+
today += entry.usd ?? 0;
|
|
59
|
+
}
|
|
60
|
+
return {
|
|
61
|
+
today_usd: today.toFixed(3),
|
|
62
|
+
last_30d_usd: month.toFixed(3),
|
|
63
|
+
calls_30d: calls,
|
|
64
|
+
unpriced_calls_30d: unpriced,
|
|
65
|
+
corrupt_lines: corrupt,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
export function readAccount() {
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(readFileSync(join(stateDir(), "account.json"), "utf8"));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export function writeAccount(account) {
|
|
77
|
+
migrateFollowLog(readAccount() ?? account);
|
|
78
|
+
writeFileSync(join(ensureDir(), "account.json"), JSON.stringify(account), { mode: 0o600 });
|
|
79
|
+
}
|
|
80
|
+
/** After logout the cached account would name a login that no longer exists. The ledger and follow log stay. */
|
|
81
|
+
export function clearAccount() {
|
|
82
|
+
const account = readAccount();
|
|
83
|
+
if (account)
|
|
84
|
+
migrateFollowLog(account);
|
|
85
|
+
try {
|
|
86
|
+
unlinkSync(join(stateDir(), "account.json"));
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error.code !== "ENOENT")
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
export const DAY_MS = 24 * 3600 * 1000;
|
|
94
|
+
/**
|
|
95
|
+
* Version 1 was a flat map of user ID to time, with no owner. Only account.json
|
|
96
|
+
* can name that owner, so this must run before account.json is overwritten or deleted.
|
|
97
|
+
* A missing or corrupt file is left for readFollows to handle.
|
|
98
|
+
*/
|
|
99
|
+
function migrateFollowLog(owner) {
|
|
100
|
+
let value;
|
|
101
|
+
try {
|
|
102
|
+
value = JSON.parse(readFileSync(join(stateDir(), "follows.json"), "utf8"));
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || "version" in value)
|
|
108
|
+
return;
|
|
109
|
+
const following = value;
|
|
110
|
+
saveFollowFile({ version: 2, accounts: { [owner.id]: { following, events: Object.values(following) } } });
|
|
111
|
+
}
|
|
112
|
+
/** Callers after a confirmed write report only the message, so the help can speak for the checks made before sending. */
|
|
113
|
+
function followLogError(path, reason) {
|
|
114
|
+
return new AxiError(`the follow log at ${path} is unusable: ${reason}`, "FOLLOW_LOG_CORRUPT", [
|
|
115
|
+
"Nothing was sent. Repair or restore that file; it holds the resume state and the 24-hour follow count",
|
|
116
|
+
]);
|
|
117
|
+
}
|
|
118
|
+
// A corrupt log must stop a batch, or the daily cap would silently reset.
|
|
119
|
+
function loadFollowFile() {
|
|
120
|
+
const path = join(stateDir(), "follows.json");
|
|
121
|
+
let file;
|
|
122
|
+
try {
|
|
123
|
+
file = JSON.parse(readFileSync(path, "utf8"));
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
if (error.code === "ENOENT")
|
|
127
|
+
return { version: 2, accounts: {} };
|
|
128
|
+
throw followLogError(path, error.message);
|
|
129
|
+
}
|
|
130
|
+
if (file?.version !== 2 || !file.accounts)
|
|
131
|
+
throw followLogError(path, "it is not a version 2 follow log");
|
|
132
|
+
return file;
|
|
133
|
+
}
|
|
134
|
+
function saveFollowFile(file) {
|
|
135
|
+
const dir = ensureDir();
|
|
136
|
+
const temp = join(dir, "follows.json.tmp");
|
|
137
|
+
writeFileSync(temp, JSON.stringify(file), { mode: 0o600 });
|
|
138
|
+
renameSync(temp, join(dir, "follows.json"));
|
|
139
|
+
}
|
|
140
|
+
export function readFollows(accountId) {
|
|
141
|
+
return loadFollowFile().accounts[accountId] ?? { following: {}, events: [] };
|
|
142
|
+
}
|
|
143
|
+
/** Throws if a later log update would fail on the current file, so a problem can stop a write before it is sent. */
|
|
144
|
+
export function assertFollowLogReadable() {
|
|
145
|
+
loadFollowFile();
|
|
146
|
+
}
|
|
147
|
+
function addFollowEvent(file, accountId, now) {
|
|
148
|
+
const record = file.accounts[accountId] ?? { following: {}, events: [] };
|
|
149
|
+
record.events = [...record.events.filter((event) => now.getTime() - new Date(event).getTime() < DAY_MS), now.toISOString()];
|
|
150
|
+
file.accounts[accountId] = record;
|
|
151
|
+
return record;
|
|
152
|
+
}
|
|
153
|
+
export function logFollow(accountId, targetId, now = new Date()) {
|
|
154
|
+
const file = loadFollowFile();
|
|
155
|
+
addFollowEvent(file, accountId, now).following[targetId] = now.toISOString();
|
|
156
|
+
saveFollowFile(file);
|
|
157
|
+
}
|
|
158
|
+
/** For a follow X may have applied: it counts against the cap, but resume must still check that account. */
|
|
159
|
+
export function logFollowAttempt(accountId, now = new Date()) {
|
|
160
|
+
const file = loadFollowFile();
|
|
161
|
+
addFollowEvent(file, accountId, now);
|
|
162
|
+
saveFollowFile(file);
|
|
163
|
+
}
|
|
164
|
+
export function logUnfollow(accountId, targetId) {
|
|
165
|
+
const file = loadFollowFile();
|
|
166
|
+
const record = file.accounts[accountId];
|
|
167
|
+
if (!record)
|
|
168
|
+
return;
|
|
169
|
+
delete record.following[targetId];
|
|
170
|
+
saveFollowFile(file);
|
|
171
|
+
}
|
|
172
|
+
//# sourceMappingURL=ledger.js.map
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { AxiError } from "axi-sdk-js";
|
|
2
|
+
import { closeSync, openSync, readSync, statSync } from "node:fs";
|
|
3
|
+
import { basename, extname, resolve } from "node:path";
|
|
4
|
+
import { displayText, shortenPath, usage } from "./args.js";
|
|
5
|
+
/**
|
|
6
|
+
* From https://docs.x.com/x-api/media/quickstart/media-upload-chunked and
|
|
7
|
+
* https://docs.x.com/x-api/media/quickstart/best-practices, read 2026-11-01.
|
|
8
|
+
* Each segment at or below 5 MB; the server maximum is 8 MB. Decimal here, so it holds whichever megabyte X means.
|
|
9
|
+
*/
|
|
10
|
+
export const MEDIA_CHUNK_BYTES = 5_000_000;
|
|
11
|
+
const MIB = 1024 * 1024;
|
|
12
|
+
// Video allows the Premium size, because the account's tier is not known locally. X applies the 8 GB default itself.
|
|
13
|
+
const KINDS = {
|
|
14
|
+
"image/jpeg": { category: "tweet_image", maxBytes: 5 * MIB, extensions: [".jpg", ".jpeg"] },
|
|
15
|
+
"image/png": { category: "tweet_image", maxBytes: 5 * MIB, extensions: [".png"] },
|
|
16
|
+
"image/webp": { category: "tweet_image", maxBytes: 5 * MIB, extensions: [".webp"] },
|
|
17
|
+
"image/gif": { category: "tweet_gif", maxBytes: 15 * MIB, extensions: [".gif"] },
|
|
18
|
+
"video/mp4": { category: "tweet_video", maxBytes: 16 * 1024 * MIB, extensions: [".mp4", ".m4v"] },
|
|
19
|
+
"video/quicktime": { category: "tweet_video", maxBytes: 16 * 1024 * MIB, extensions: [".mov"] },
|
|
20
|
+
"video/webm": { category: "tweet_video", maxBytes: 16 * 1024 * MIB, extensions: [".webm"] },
|
|
21
|
+
};
|
|
22
|
+
// ftyp major brands of plain MP4 files, from the MP4 Registration Authority (mp4ra.org). HEIC, AVIF, M4A, and 3GP share the container but are not on it.
|
|
23
|
+
const MP4_BRANDS = new Set(["isom", "iso2", "iso3", "iso4", "iso5", "iso6", "mp41", "mp42", "avc1", "M4V ", "M4VH", "M4VP", "dash", "MSNV", "XAVC"]);
|
|
24
|
+
const EBML_DOCTYPE_ID = Buffer.from([0x42, 0x82]);
|
|
25
|
+
/** The EBML header holds the DocType, which separates WebM from other Matroska files. */
|
|
26
|
+
function isWebm(header) {
|
|
27
|
+
const id = header.indexOf(EBML_DOCTYPE_ID);
|
|
28
|
+
return id !== -1 && header.toString("latin1", id + 3, id + 7) === "webm";
|
|
29
|
+
}
|
|
30
|
+
/** The file's own bytes decide its type, so a renamed file cannot pass as something else. */
|
|
31
|
+
function sniff(header) {
|
|
32
|
+
const ascii = (start, end) => header.toString("latin1", start, end);
|
|
33
|
+
if (header[0] === 0xff && header[1] === 0xd8 && header[2] === 0xff)
|
|
34
|
+
return "image/jpeg";
|
|
35
|
+
if (header[0] === 0x89 && ascii(1, 4) === "PNG")
|
|
36
|
+
return "image/png";
|
|
37
|
+
if (ascii(0, 4) === "GIF8")
|
|
38
|
+
return "image/gif";
|
|
39
|
+
if (ascii(0, 4) === "RIFF" && ascii(8, 12) === "WEBP")
|
|
40
|
+
return "image/webp";
|
|
41
|
+
if (ascii(4, 8) === "ftyp") {
|
|
42
|
+
const brand = ascii(8, 12);
|
|
43
|
+
if (brand === "qt ")
|
|
44
|
+
return "video/quicktime";
|
|
45
|
+
return MP4_BRANDS.has(brand) ? "video/mp4" : undefined;
|
|
46
|
+
}
|
|
47
|
+
if (header.subarray(0, 4).equals(Buffer.from([0x1a, 0x45, 0xdf, 0xa3])))
|
|
48
|
+
return isWebm(header) ? "video/webm" : undefined;
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
function mb(bytes) {
|
|
52
|
+
return `${(bytes / 1_000_000).toFixed(1)} MB`;
|
|
53
|
+
}
|
|
54
|
+
/** Checks everything knowable without spending. Video duration is not knowable here, so X judges that. */
|
|
55
|
+
export function inspectMedia(path) {
|
|
56
|
+
const header = Buffer.alloc(64);
|
|
57
|
+
let size;
|
|
58
|
+
let modifiedAt;
|
|
59
|
+
try {
|
|
60
|
+
const stat = statSync(path);
|
|
61
|
+
if (!stat.isFile())
|
|
62
|
+
throw usage(`${path} is not a file`);
|
|
63
|
+
size = stat.size;
|
|
64
|
+
modifiedAt = stat.mtimeMs;
|
|
65
|
+
const fd = openSync(path, "r");
|
|
66
|
+
try {
|
|
67
|
+
readSync(fd, header, 0, header.length, 0);
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
closeSync(fd);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
catch (error) {
|
|
74
|
+
if (error instanceof AxiError)
|
|
75
|
+
throw error;
|
|
76
|
+
throw usage(`cannot read ${path}: ${error.message}`);
|
|
77
|
+
}
|
|
78
|
+
if (size === 0)
|
|
79
|
+
throw usage(`${path} is empty`);
|
|
80
|
+
const mediaType = sniff(header);
|
|
81
|
+
const kind = mediaType ? KINDS[mediaType] : undefined;
|
|
82
|
+
if (!mediaType || !kind)
|
|
83
|
+
throw usage(`${path} is not a supported file: expected JPEG, PNG, WebP, GIF, MP4, MOV, or WebM content`);
|
|
84
|
+
if (!kind.extensions.includes(extname(path).toLowerCase())) {
|
|
85
|
+
throw usage(`${path} holds ${mediaType} content, but its extension says otherwise; rename it to ${kind.extensions[0]}`);
|
|
86
|
+
}
|
|
87
|
+
if (size > kind.maxBytes)
|
|
88
|
+
throw usage(`${path} is ${mb(size)}, over X's ${mb(kind.maxBytes)} limit for ${mediaType}`);
|
|
89
|
+
return { path, name: basename(path), mediaType, category: kind.category, size, modifiedAt };
|
|
90
|
+
}
|
|
91
|
+
export function chunkCount(size) {
|
|
92
|
+
return Math.ceil(size / MEDIA_CHUNK_BYTES);
|
|
93
|
+
}
|
|
94
|
+
/** The approval dialog and the dry-run output both name the file this way. */
|
|
95
|
+
export function describeMedia(media) {
|
|
96
|
+
return `${shortenPath(displayText(resolve(media.path)))} (${media.mediaType}, ${mb(media.size)})`;
|
|
97
|
+
}
|
|
98
|
+
/** Yields one reused buffer, so a consumer must copy what it keeps. Refuses a file that changed after approval. */
|
|
99
|
+
export function* readChunks(media) {
|
|
100
|
+
const assertUnchanged = () => {
|
|
101
|
+
const stat = statSync(media.path);
|
|
102
|
+
if (stat.size !== media.size || stat.mtimeMs !== media.modifiedAt) {
|
|
103
|
+
throw new AxiError(`${media.path} changed after it was approved; nothing was posted`, "VALIDATION_ERROR", ["Run the command again so the dialog shows the current file"]);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
assertUnchanged();
|
|
107
|
+
const fd = openSync(media.path, "r");
|
|
108
|
+
try {
|
|
109
|
+
const buffer = Buffer.alloc(Math.min(MEDIA_CHUNK_BYTES, media.size));
|
|
110
|
+
for (let offset = 0; offset < media.size; offset += MEDIA_CHUNK_BYTES) {
|
|
111
|
+
const want = Math.min(MEDIA_CHUNK_BYTES, media.size - offset);
|
|
112
|
+
const got = readSync(fd, buffer, 0, want, offset);
|
|
113
|
+
if (got !== want)
|
|
114
|
+
throw new AxiError(`${media.path} changed after it was approved; nothing was posted`, "VALIDATION_ERROR");
|
|
115
|
+
yield buffer.subarray(0, want);
|
|
116
|
+
}
|
|
117
|
+
assertUnchanged();
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
closeSync(fd);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
//# sourceMappingURL=media.js.map
|