@humanwhocodes/crosspost
Version:
A utility to post across multiple social networks.
208 lines (207 loc) • 8.04 kB
JavaScript
#!/usr/bin/env node
/**
* @fileoverview A CLI for tooting out updates.
* @author Nicholas C. Zakas
*/
//-----------------------------------------------------------------------------
// Imports
//-----------------------------------------------------------------------------
import fs from "node:fs";
import { parseArgs } from "node:util";
import * as dotenv from "dotenv";
import { Env } from "@humanwhocodes/env";
import { Client, TwitterStrategy, MastodonStrategy, BlueskyStrategy, LinkedInStrategy, DiscordStrategy, DiscordWebhookStrategy, DevtoStrategy, TelegramStrategy, } from "./index.js";
import { CrosspostMcpServer } from "./mcp-server.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
//-----------------------------------------------------------------------------
// Type Definitions
//-----------------------------------------------------------------------------
/** @typedef {import("./client.js").SuccessResponse} SuccessResponse */
/** @typedef {import("./client.js").Strategy} Strategy */
//-----------------------------------------------------------------------------
// Helpers
//-----------------------------------------------------------------------------
/**
* Determines if a response is successful.
* @param {any} response The response to check.
* @returns {response is SuccessResponse} True if the response is successful, false if not.
*/
function isSuccessResponse(response) {
return response.ok;
}
//-----------------------------------------------------------------------------
// Parse CLI Arguments
//-----------------------------------------------------------------------------
// appease TypeScript
const booleanType = /** @type {const} */ ("boolean");
const stringType = /** @type {const} */ ("string");
const options = {
twitter: { type: booleanType, short: "t" },
mastodon: { type: booleanType, short: "m" },
bluesky: { type: booleanType, short: "b" },
linkedin: { type: booleanType, short: "l" },
discord: { type: booleanType, short: "d" },
"discord-webhook": { type: booleanType },
devto: { type: booleanType },
telegram: { type: booleanType },
mcp: { type: booleanType },
file: { type: stringType },
image: { type: stringType },
"image-alt": { type: stringType },
help: { type: booleanType, short: "h" },
};
const { values: flags, positionals } = parseArgs({
options,
allowPositionals: true,
});
if (flags.mcp && flags.file) {
console.error("Error: --file cannot be used with --mcp");
process.exit(1);
}
if (flags.help ||
(!flags.mcp && positionals.length === 0 && !flags.file) ||
(!flags.twitter &&
!flags.mastodon &&
!flags.bluesky &&
!flags.linkedin &&
!flags.discord &&
!flags["discord-webhook"] &&
!flags.devto &&
!flags.telegram &&
!flags.mcp)) {
console.log('Usage: crosspost [options] ["Message to post."]');
console.log("--twitter, -t Post to Twitter.");
console.log("--mastodon, -m Post to Mastodon.");
console.log("--bluesky, -b Post to Bluesky.");
console.log("--linkedin, -l Post to LinkedIn.");
console.log("--discord, -d Post to Discord via bot.");
console.log("--discord-webhook Post to Discord via webhook.");
console.log("--devto Post to Dev.to.");
console.log("--telegram Post to Telegram.");
console.log("--mcp Start MCP server.");
console.log("--file The file to read the message from.");
console.log("--image The image file to upload with the message.");
console.log("--image-alt Alt text for the image (default: filename).");
console.log("--help, -h Show this message.");
process.exit(1);
}
//-----------------------------------------------------------------------------
// Load environment variables
//-----------------------------------------------------------------------------
// load environment variables from .env file if present
if (process.env.CROSSPOST_DOTENV) {
const filePath = process.env.CROSSPOST_DOTENV === "1"
? undefined
: process.env.CROSSPOST_DOTENV;
dotenv.config({ path: filePath });
}
const env = new Env();
//-----------------------------------------------------------------------------
// Determine which strategies to use
//-----------------------------------------------------------------------------
/** @type {Array<Strategy>} */
const strategies = [];
if (flags.twitter) {
strategies.push(new TwitterStrategy({
apiConsumerKey: env.require("TWITTER_API_CONSUMER_KEY"),
apiConsumerSecret: env.require("TWITTER_API_CONSUMER_SECRET"),
accessTokenKey: env.require("TWITTER_ACCESS_TOKEN_KEY"),
accessTokenSecret: env.require("TWITTER_ACCESS_TOKEN_SECRET"),
}));
}
if (flags.mastodon) {
strategies.push(new MastodonStrategy({
accessToken: env.require("MASTODON_ACCESS_TOKEN"),
host: env.require("MASTODON_HOST"),
}));
}
if (flags.bluesky) {
strategies.push(new BlueskyStrategy({
identifier: env.require("BLUESKY_IDENTIFIER"),
password: env.require("BLUESKY_PASSWORD"),
host: env.require("BLUESKY_HOST"),
}));
}
if (flags.linkedin) {
strategies.push(new LinkedInStrategy({
accessToken: env.require("LINKEDIN_ACCESS_TOKEN"),
}));
}
if (flags.discord) {
strategies.push(new DiscordStrategy({
botToken: env.require("DISCORD_BOT_TOKEN"),
channelId: env.require("DISCORD_CHANNEL_ID"),
}));
}
if (flags["discord-webhook"]) {
strategies.push(new DiscordWebhookStrategy({
webhookUrl: env.require("DISCORD_WEBHOOK_URL"),
}));
}
if (flags.devto) {
strategies.push(new DevtoStrategy({
apiKey: env.require("DEVTO_API_KEY"),
}));
}
if (flags.telegram) {
strategies.push(new TelegramStrategy({
botToken: env.require("TELEGRAM_BOT_TOKEN"),
chatId: env.require("TELEGRAM_CHAT_ID"),
}));
}
//-----------------------------------------------------------------------------
// Main
//-----------------------------------------------------------------------------
/** @type {import("./types.js").PostOptions} */
const postOptions = {};
// After strategies are created, start MCP server if requested
if (flags.mcp) {
const server = new CrosspostMcpServer({ strategies });
await server.connect(new StdioServerTransport());
console.error("MCP server started. You can now send messages to it via stdin.");
}
else {
// if an image is specified, read it and add to options
if (flags.image) {
try {
const imageData = fs.readFileSync(flags.image);
const basename = flags.image.split(/[\\/]/).pop() || flags.image;
postOptions.images = [
{
data: new Uint8Array(imageData),
alt: flags["image-alt"] || basename,
},
];
}
catch (error) {
const fileError = /** @type {Error} */ (error);
console.error(`Error reading image file: ${fileError.message}`);
process.exit(1);
}
}
/*
* Command line arguments will escape \n as \\n, which isn't what we want.
* Remove the extra escapes so newlines can be entered on the command line.
*/
const message = flags.file
? fs.readFileSync(flags.file, "utf8")
: positionals[0].replace(/\\n/g, "\n");
// normal CLI operation
const client = new Client({ strategies });
const responses = await client.post(message, postOptions);
let exitCode = 0;
responses.forEach((response, index) => {
if (isSuccessResponse(response)) {
console.log(`✅ ${strategies[index].name} succeeded.`);
console.log(response.url ?? response.response);
console.log("");
}
else {
exitCode = 1;
console.log(`❌ ${strategies[index].name} failed.`);
console.error(response.reason);
console.log("");
}
});
process.exit(exitCode);
}