posthive-cli 0.1.0 → 0.1.4

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 CHANGED
@@ -18,11 +18,23 @@ npx posthive-cli help
18
18
 
19
19
  ## Setup
20
20
 
21
- You need a Posthive account and an API key ([posthive.co](https://posthive.co) → Settings → API Keys, Pro/Team plan).
21
+ Sign in once with your browser no API key copy-pasting:
22
+
23
+ ```bash
24
+ posthive login
25
+ ```
26
+
27
+ This opens your browser to sign in to Posthive, then stores credentials in `~/.posthive/config.json`. Run `posthive logout` to sign out, or `posthive whoami` to check who's currently logged in.
28
+
29
+ Self-hosted Posthive: `posthive login --api-url http://localhost:3001`
30
+
31
+ Prefer env vars (CI, scripts, or a manually generated key from Settings → API Keys)? They always take priority over the stored login:
22
32
 
23
33
  ```bash
24
34
  export POSTHIVE_API_KEY=ph_your_key_here
25
- export POSTHIVE_API_URL=https://api.posthive.co # or your self-hosted URL
35
+
36
+ # Optional — only needed for self-hosted Posthive. Defaults to https://api.posthive.co
37
+ export POSTHIVE_API_URL=http://localhost:3001
26
38
  ```
27
39
 
28
40
  ## Usage
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Shared credential store for posthive-cli.
3
+ *
4
+ * Stores the API key from `posthive login` in ~/.posthive/config.json
5
+ * (mode 0600 — owner read/write only). posthive-mcp reads the same file
6
+ * as a fallback when POSTHIVE_API_KEY isn't set in the environment.
7
+ */
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { readFile, writeFile, mkdir, unlink, chmod } from "node:fs/promises";
11
+ function configDir() {
12
+ return join(homedir(), ".posthive");
13
+ }
14
+ function configPath() {
15
+ return join(configDir(), "config.json");
16
+ }
17
+ export async function readCredentials() {
18
+ try {
19
+ const raw = await readFile(configPath(), "utf-8");
20
+ const parsed = JSON.parse(raw);
21
+ if (typeof parsed?.apiKey === "string" && typeof parsed?.apiUrl === "string") {
22
+ return parsed;
23
+ }
24
+ return null;
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ export async function writeCredentials(creds) {
31
+ await mkdir(configDir(), { recursive: true });
32
+ await writeFile(configPath(), JSON.stringify(creds, null, 2), "utf-8");
33
+ try {
34
+ await chmod(configPath(), 0o600);
35
+ }
36
+ catch {
37
+ // chmod may not be meaningful on some filesystems (e.g. Windows) — best effort
38
+ }
39
+ }
40
+ export async function clearCredentials() {
41
+ try {
42
+ await unlink(configPath());
43
+ return true;
44
+ }
45
+ catch {
46
+ return false;
47
+ }
48
+ }
package/dist/index.js CHANGED
@@ -7,14 +7,21 @@
7
7
  *
8
8
  * Every command outputs structured JSON for easy parsing by LLMs and scripts.
9
9
  *
10
- * Required env vars:
10
+ * Auth: run `posthive login` once to sign in via your browser (no API key
11
+ * copy-paste needed) — credentials are stored in ~/.posthive/config.json.
12
+ * Or set env vars directly, which always take priority:
11
13
  * POSTHIVE_API_KEY — API key from Posthive Settings → API Keys
12
- * POSTHIVE_API_URL — Base URL of the Posthive API (default: https://api.posthive.co)
14
+ * POSTHIVE_API_URL — Base URL of the Posthive API (default: https://api.posthive.co).
15
+ * Set this for self-hosted deployments.
13
16
  */
14
17
  import { readFile } from "node:fs/promises";
15
18
  import { basename } from "node:path";
16
- const API_KEY = process.env.POSTHIVE_API_KEY;
17
- const API_URL = (process.env.POSTHIVE_API_URL ?? "https://api.posthive.co").replace(/\/$/, "");
19
+ import { readCredentials } from "./credentials.js";
20
+ import { runLogin, runLogout } from "./login.js";
21
+ const DEFAULT_API_URL = "https://api.posthive.co";
22
+ const stored = process.env.POSTHIVE_API_KEY ? null : await readCredentials();
23
+ const API_KEY = process.env.POSTHIVE_API_KEY ?? stored?.apiKey;
24
+ const API_URL = (process.env.POSTHIVE_API_URL ?? stored?.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
18
25
  // ─── Output helpers ──────────────────────────────────────────────────────────
19
26
  function out(data) {
20
27
  process.stdout.write(JSON.stringify(data, null, 2) + "\n");
@@ -89,11 +96,15 @@ async function api(method, path, body) {
89
96
  // ─── Help text ───────────────────────────────────────────────────────────────
90
97
  const HELP = {
91
98
  usage: "posthive <command> [args] [--flags]",
99
+ auth: "Run `posthive login` once to sign in via your browser. No API key copy-paste needed.",
92
100
  env: {
93
- POSTHIVE_API_KEY: "required — API key from Posthive Settings → API Keys",
101
+ POSTHIVE_API_KEY: "optionaloverrides stored login. API key from Posthive Settings → API Keys",
94
102
  POSTHIVE_API_URL: "optional — API base URL (default: https://api.posthive.co)",
95
103
  },
96
104
  commands: {
105
+ "login": "Sign in via your browser. Stores credentials in ~/.posthive/config.json. [--api-url <url>] for self-hosted.",
106
+ "logout": "Clear stored login credentials",
107
+ "whoami": "Show the currently logged-in account",
97
108
  "accounts:list": "List connected social accounts and their IDs",
98
109
  "posts:create": "Create a post. --content <text> --accounts <id,id> [--schedule <ISO>] [--first-comment <text>] [--media <url,url>] [--media-type post|reel|story] [--youtube-type short|video] [--dry-run]. Saved as DRAFT unless --schedule is given.",
99
110
  "posts:list": "List posts. [--status pending|draft|done|failed] [--limit <n>]",
@@ -118,8 +129,25 @@ const { command, positional, flags } = parseArgs(process.argv.slice(2));
118
129
  if (command === "help" || command === "--help" || command === "-h") {
119
130
  out(HELP);
120
131
  }
132
+ if (command === "login") {
133
+ const loginUrl = (str(flags, "api-url") ?? process.env.POSTHIVE_API_URL ?? DEFAULT_API_URL).replace(/\/$/, "");
134
+ try {
135
+ await runLogin(loginUrl);
136
+ process.exit(0);
137
+ }
138
+ catch (err) {
139
+ fail(err instanceof Error ? err.message : String(err));
140
+ }
141
+ }
142
+ if (command === "logout") {
143
+ await runLogout();
144
+ process.exit(0);
145
+ }
121
146
  if (!API_KEY) {
122
- fail("POSTHIVE_API_KEY environment variable is required. Get a key at Posthive Settings → API Keys.");
147
+ fail("Not logged in. Run `posthive login`, or set POSTHIVE_API_KEY for CI/scripts.");
148
+ }
149
+ if (command === "whoami") {
150
+ out(await api("GET", "/api/v1/me"));
123
151
  }
124
152
  switch (command) {
125
153
  case "accounts:list": {
package/dist/login.js ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Browser-based OAuth login for posthive-cli.
3
+ *
4
+ * Reuses the existing Posthive OAuth 2.0 Authorization Code + PKCE server
5
+ * (built for the Claude.ai MCP connector) via the standard CLI "loopback"
6
+ * pattern: spin up a local HTTP server, open the browser to /oauth/authorize
7
+ * with a 127.0.0.1 redirect_uri, and receive the code on that local server —
8
+ * same approach used by gh, vercel, and gcloud CLIs. No API key copy-paste.
9
+ */
10
+ import { randomBytes, createHash } from "node:crypto";
11
+ import { createServer } from "node:http";
12
+ import { exec } from "node:child_process";
13
+ import { writeCredentials, clearCredentials, readCredentials } from "./credentials.js";
14
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
15
+ function base64url(buf) {
16
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
17
+ }
18
+ function openBrowser(url) {
19
+ const cmd = process.platform === "darwin" ? `open "${url}"`
20
+ : process.platform === "win32" ? `start "" "${url}"`
21
+ : `xdg-open "${url}"`;
22
+ exec(cmd, () => { }); // best-effort; URL is always printed as a fallback
23
+ }
24
+ const SUCCESS_HTML = `<!DOCTYPE html><html><head><title>Posthive</title><style>
25
+ body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a0a;color:#ededed;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
26
+ .card{text-align:center}
27
+ h1{font-size:20px;margin-bottom:8px}
28
+ p{color:#888;font-size:14px}
29
+ </style></head><body><div class="card"><h1>&#10003; Logged in to Posthive</h1><p>You can close this window and return to your terminal.</p></div></body></html>`;
30
+ const DENIED_HTML = `<!DOCTYPE html><html><head><title>Posthive</title><style>
31
+ body{font-family:system-ui,-apple-system,sans-serif;background:#0a0a0a;color:#ededed;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
32
+ .card{text-align:center}
33
+ h1{font-size:20px;margin-bottom:8px;color:#ef4444}
34
+ p{color:#888;font-size:14px}
35
+ </style></head><body><div class="card"><h1>Login cancelled</h1><p>You can close this window.</p></div></body></html>`;
36
+ export async function runLogin(apiUrl) {
37
+ const verifier = base64url(randomBytes(32));
38
+ const challenge = base64url(createHash("sha256").update(verifier).digest());
39
+ const state = base64url(randomBytes(16));
40
+ let redirectUri = "";
41
+ const code = await new Promise((resolve, reject) => {
42
+ const server = createServer((req, res) => {
43
+ const url = new URL(req.url ?? "/", "http://127.0.0.1");
44
+ if (url.pathname !== "/callback") {
45
+ res.writeHead(404).end();
46
+ return;
47
+ }
48
+ const returnedState = url.searchParams.get("state");
49
+ const authCode = url.searchParams.get("code");
50
+ const error = url.searchParams.get("error");
51
+ if (error) {
52
+ res.writeHead(200, { "Content-Type": "text/html" }).end(DENIED_HTML);
53
+ server.close();
54
+ reject(new Error(url.searchParams.get("error_description") ?? error));
55
+ return;
56
+ }
57
+ if (!authCode || returnedState !== state) {
58
+ res.writeHead(400, { "Content-Type": "text/html" }).end(DENIED_HTML);
59
+ server.close();
60
+ reject(new Error("State mismatch or missing authorization code."));
61
+ return;
62
+ }
63
+ res.writeHead(200, { "Content-Type": "text/html" }).end(SUCCESS_HTML);
64
+ server.close();
65
+ resolve(authCode);
66
+ });
67
+ server.on("error", reject);
68
+ server.listen(0, "127.0.0.1", () => {
69
+ const address = server.address();
70
+ if (!address || typeof address === "string") {
71
+ reject(new Error("Failed to start local login server"));
72
+ return;
73
+ }
74
+ redirectUri = `http://127.0.0.1:${address.port}/callback`;
75
+ const authorizeUrl = new URL(`${apiUrl}/oauth/authorize`);
76
+ authorizeUrl.searchParams.set("redirect_uri", redirectUri);
77
+ authorizeUrl.searchParams.set("code_challenge", challenge);
78
+ authorizeUrl.searchParams.set("code_challenge_method", "S256");
79
+ authorizeUrl.searchParams.set("state", state);
80
+ authorizeUrl.searchParams.set("client_id", "Posthive CLI");
81
+ process.stdout.write("Opening browser to sign in...\n");
82
+ process.stdout.write(`If it doesn't open automatically, visit:\n${authorizeUrl.toString()}\n\n`);
83
+ openBrowser(authorizeUrl.toString());
84
+ const timer = setTimeout(() => {
85
+ server.close();
86
+ reject(new Error("Login timed out after 5 minutes. Run `posthive login` again."));
87
+ }, LOGIN_TIMEOUT_MS);
88
+ timer.unref();
89
+ });
90
+ });
91
+ const tokenRes = await fetch(`${apiUrl}/oauth/token`, {
92
+ method: "POST",
93
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
94
+ body: new URLSearchParams({
95
+ grant_type: "authorization_code",
96
+ code,
97
+ code_verifier: verifier,
98
+ redirect_uri: redirectUri,
99
+ }),
100
+ });
101
+ if (!tokenRes.ok) {
102
+ const text = await tokenRes.text();
103
+ throw new Error(`Token exchange failed: ${text}`);
104
+ }
105
+ const { access_token: apiKey } = (await tokenRes.json());
106
+ let email;
107
+ try {
108
+ const meRes = await fetch(`${apiUrl}/api/v1/me`, {
109
+ headers: { Authorization: `Bearer ${apiKey}` },
110
+ });
111
+ if (meRes.ok) {
112
+ const { user } = (await meRes.json());
113
+ email = user.email;
114
+ }
115
+ }
116
+ catch {
117
+ // Non-fatal — login still succeeds without the email confirmation
118
+ }
119
+ await writeCredentials({ apiKey, apiUrl, email });
120
+ process.stdout.write(`✓ Logged in${email ? ` as ${email}` : ""}\n`);
121
+ }
122
+ export async function runLogout() {
123
+ const existing = await readCredentials();
124
+ const cleared = await clearCredentials();
125
+ if (cleared) {
126
+ process.stdout.write(`✓ Logged out${existing?.email ? ` (${existing.email})` : ""}\n`);
127
+ }
128
+ else {
129
+ process.stdout.write("Not logged in.\n");
130
+ }
131
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "posthive-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.4",
4
4
  "description": "Posthive CLI — schedule posts to 11 social platforms from the command line or any AI agent (Claude Code, OpenClaw, Cursor)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,11 +9,13 @@ Schedule posts to 11 social platforms from the command line. Every command outpu
9
9
 
10
10
  ## Setup
11
11
 
12
- Requires two environment variables:
12
+ If the user isn't already logged in, run `posthive login` — it opens their browser to sign in and stores credentials in `~/.posthive/config.json`. No API key needed. Check with `posthive whoami` first if unsure.
13
+
14
+ Alternatively, env vars always take priority over the stored login (useful for CI or scripts):
13
15
 
14
16
  ```bash
15
17
  export POSTHIVE_API_KEY=ph_xxx # Posthive → Settings → API Keys (Pro/Team plan)
16
- export POSTHIVE_API_URL=https://api.posthive.co # or your self-hosted URL
18
+ export POSTHIVE_API_URL=http://localhost:3001 # optional only for self-hosted Posthive, defaults to https://api.posthive.co
17
19
  ```
18
20
 
19
21
  Run commands with `npx posthive-cli` or `posthive` if installed globally (`npm i -g posthive-cli`).
@@ -27,6 +29,11 @@ Run commands with `npx posthive-cli` or `posthive` if installed globally (`npm i
27
29
  ## Commands
28
30
 
29
31
  ```bash
32
+ # Auth (only if not already logged in)
33
+ posthive login
34
+ posthive whoami
35
+ posthive logout
36
+
30
37
  # List connected accounts (do this first)
31
38
  posthive accounts:list
32
39