rankcontrol 0.1.0 → 0.2.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 CHANGED
@@ -9,13 +9,21 @@ Every command calls the same authed, rate-limited, org-scoped API the RankContro
9
9
 
10
10
  ## Auth
11
11
 
12
- Create an API key in **RankControl Settings API**, then:
12
+ The easy way approve in your browser (you must be logged in to RankControl):
13
+
14
+ ```bash
15
+ npx rankcontrol login
16
+ ```
17
+
18
+ That opens your dashboard, you check the confirmation code matches, pick the permissions to grant, and the CLI stores a scoped key in `~/.rankcontrol/config.json`. `npx rankcontrol logout` removes it (revoke the key itself in Settings → API).
19
+
20
+ The headless way (CI, servers, MCP `env` blocks) — create a key in **RankControl → Settings → API**, then:
13
21
 
14
22
  ```bash
15
23
  export RANKCONTROL_API_KEY=rctrl_pk_...
16
24
  ```
17
25
 
18
- Scopes are set per key. Read-only keys work for all `get_*`/`list_*` tools.
26
+ The env var wins over the stored config when both exist. Scopes are set per key; read-only keys work for all `get_*`/`list_*` tools.
19
27
 
20
28
  ## CLI
21
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rankcontrol",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "RankControl CLI + MCP server: drive your SEO/AI-visibility workspace from the terminal or any AI agent",
5
5
  "license": "MIT",
6
6
  "homepage": "https://rctrl.com",
package/src/cli.mjs CHANGED
@@ -16,9 +16,9 @@ export function runCli(argv) {
16
16
  program
17
17
  .name("rankcontrol")
18
18
  .description(
19
- "RankControl from the terminal. Auth: export RANKCONTROL_API_KEY=rctrl_pk_... (Settings → API)"
19
+ "RankControl from the terminal. Auth: `rankcontrol login` (browser approval) or export RANKCONTROL_API_KEY=rctrl_pk_..."
20
20
  )
21
- .version("0.1.0");
21
+ .version("0.2.0");
22
22
 
23
23
  program
24
24
  .command("mcp")
@@ -28,6 +28,23 @@ export function runCli(argv) {
28
28
  await startMcpServer().catch(fail);
29
29
  });
30
30
 
31
+ program
32
+ .command("login")
33
+ .description("Authenticate via your browser (approves a scoped API key into ~/.rankcontrol)")
34
+ .option("--scopes <a,b,c>", "Scopes to request (default: reads + write:content + write:publish)")
35
+ .action(async (opts) => {
36
+ const { login } = await import("./login.mjs");
37
+ await login({ scopes: opts.scopes ? list(opts.scopes) : undefined }).catch(fail);
38
+ });
39
+
40
+ program
41
+ .command("logout")
42
+ .description("Remove locally stored credentials")
43
+ .action(async () => {
44
+ const { logout } = await import("./login.mjs");
45
+ logout();
46
+ });
47
+
31
48
  program
32
49
  .command("funnel")
33
50
  .description("AI pipeline last 30 days: crawls, AI visits, AI leads")
package/src/client.mjs CHANGED
@@ -1,15 +1,63 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+
1
5
  const DEFAULT_BASE = "https://api.rctrl.com";
6
+ const DEFAULT_APP = "https://rctrl.com";
7
+
8
+ const CONFIG_DIR = join(homedir(), ".rankcontrol");
9
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
10
+
11
+ export function readStoredConfig() {
12
+ try {
13
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ export function writeStoredConfig(config) {
20
+ mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
21
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n", {
22
+ mode: 0o600,
23
+ });
24
+ return CONFIG_FILE;
25
+ }
26
+
27
+ export function deleteStoredConfig() {
28
+ if (!existsSync(CONFIG_FILE)) return false;
29
+ rmSync(CONFIG_FILE);
30
+ return true;
31
+ }
2
32
 
3
33
  export function getConfig() {
4
- const apiKey = process.env.RANKCONTROL_API_KEY;
34
+ const stored = readStoredConfig();
35
+ const apiKey = process.env.RANKCONTROL_API_KEY || stored?.apiKey;
5
36
  if (!apiKey) {
6
37
  throw new Error(
7
- "RANKCONTROL_API_KEY is not set. Create a key in RankControl → Settings → API, then export RANKCONTROL_API_KEY=rctrl_pk_..."
38
+ "Not authenticated. Run `rankcontrol login`, or create a key in RankControl → Settings → API and export RANKCONTROL_API_KEY=rctrl_pk_..."
8
39
  );
9
40
  }
10
41
  return {
11
42
  apiKey,
43
+ baseUrl: (
44
+ process.env.RANKCONTROL_API_URL ||
45
+ stored?.baseUrl ||
46
+ DEFAULT_BASE
47
+ ).replace(/\/$/, ""),
48
+ appUrl: (
49
+ process.env.RANKCONTROL_APP_URL ||
50
+ stored?.appUrl ||
51
+ DEFAULT_APP
52
+ ).replace(/\/$/, ""),
53
+ };
54
+ }
55
+
56
+ // Unauthenticated variant for the login flow itself
57
+ export function getBaseUrls() {
58
+ return {
12
59
  baseUrl: (process.env.RANKCONTROL_API_URL || DEFAULT_BASE).replace(/\/$/, ""),
60
+ appUrl: (process.env.RANKCONTROL_APP_URL || DEFAULT_APP).replace(/\/$/, ""),
13
61
  };
14
62
  }
15
63
 
package/src/login.mjs ADDED
@@ -0,0 +1,111 @@
1
+ import { spawn } from "node:child_process";
2
+ import { hostname } from "node:os";
3
+ import {
4
+ getBaseUrls,
5
+ writeStoredConfig,
6
+ deleteStoredConfig,
7
+ } from "./client.mjs";
8
+
9
+ const DEFAULT_SCOPES = [
10
+ "read:citations",
11
+ "read:content",
12
+ "read:leads",
13
+ "read:analytics",
14
+ "write:content",
15
+ "write:publish",
16
+ ];
17
+
18
+ function openBrowser(url) {
19
+ if (process.env.RANKCONTROL_NO_BROWSER) return false;
20
+ const cmd =
21
+ process.platform === "darwin"
22
+ ? "open"
23
+ : process.platform === "win32"
24
+ ? "start"
25
+ : "xdg-open";
26
+ try {
27
+ spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
28
+ return true;
29
+ } catch {
30
+ return false;
31
+ }
32
+ }
33
+
34
+ export async function login({ scopes } = {}) {
35
+ const { baseUrl, appUrl } = getBaseUrls();
36
+ const requested = scopes?.length ? scopes : DEFAULT_SCOPES;
37
+
38
+ const startRes = await fetch(`${baseUrl}/api/v1/auth/device/start`, {
39
+ method: "POST",
40
+ headers: { "Content-Type": "application/json" },
41
+ body: JSON.stringify({
42
+ scopes: requested,
43
+ clientName: `CLI on ${hostname()}`,
44
+ }),
45
+ });
46
+ const startJson = await startRes.json();
47
+ if (!startRes.ok) {
48
+ throw new Error(startJson?.error || `Login start failed (HTTP ${startRes.status})`);
49
+ }
50
+ const { deviceCode, userCode, verificationPath, pollIntervalSeconds } =
51
+ startJson.data;
52
+
53
+ const url = `${appUrl}${verificationPath}`;
54
+ console.log(`\nConfirmation code: ${userCode}`);
55
+ console.log(`Approve access in your browser: ${url}`);
56
+ console.log("(check that the code on the page matches the one above)\n");
57
+ openBrowser(url);
58
+
59
+ const deadline = Date.now() + 10 * 60 * 1000;
60
+ process.stdout.write("Waiting for approval");
61
+ while (Date.now() < deadline) {
62
+ await new Promise((r) => setTimeout(r, (pollIntervalSeconds || 3) * 1000));
63
+ process.stdout.write(".");
64
+
65
+ const pollRes = await fetch(`${baseUrl}/api/v1/auth/device/poll`, {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/json" },
68
+ body: JSON.stringify({ deviceCode }),
69
+ }).catch(() => null);
70
+ if (!pollRes) continue;
71
+ const pollJson = await pollRes.json().catch(() => null);
72
+ const status = pollJson?.data?.status;
73
+
74
+ if (status === "pending") continue;
75
+ process.stdout.write("\n");
76
+
77
+ if (status === "complete") {
78
+ const file = writeStoredConfig({
79
+ apiKey: pollJson.data.apiKey,
80
+ keyPrefix: pollJson.data.keyPrefix,
81
+ orgName: pollJson.data.orgName,
82
+ ...(process.env.RANKCONTROL_API_URL
83
+ ? { baseUrl: process.env.RANKCONTROL_API_URL }
84
+ : {}),
85
+ ...(process.env.RANKCONTROL_APP_URL
86
+ ? { appUrl: process.env.RANKCONTROL_APP_URL }
87
+ : {}),
88
+ });
89
+ console.log(`Logged in to ${pollJson.data.orgName} (key ${pollJson.data.keyPrefix}...).`);
90
+ console.log(`Credentials saved to ${file}`);
91
+ return;
92
+ }
93
+ if (status === "denied") throw new Error("Login request was denied in the browser.");
94
+ if (status === "expired") throw new Error("Login request expired. Run login again.");
95
+ throw new Error(pollJson?.error || `Unexpected login status: ${status}`);
96
+ }
97
+ process.stdout.write("\n");
98
+ throw new Error("Timed out waiting for approval. Run login again.");
99
+ }
100
+
101
+ export function logout() {
102
+ const removed = deleteStoredConfig();
103
+ if (removed) {
104
+ console.log("Local credentials removed.");
105
+ console.log(
106
+ "The key itself is still active: revoke it in RankControl → Settings → API if this machine should lose access permanently."
107
+ );
108
+ } else {
109
+ console.log("No stored credentials found.");
110
+ }
111
+ }
package/src/mcp.mjs CHANGED
@@ -29,7 +29,7 @@ const plannedTitle = z.object({
29
29
  });
30
30
 
31
31
  export async function startMcpServer() {
32
- const server = new McpServer({ name: "rankcontrol", version: "0.1.0" });
32
+ const server = new McpServer({ name: "rankcontrol", version: "0.2.0" });
33
33
 
34
34
  server.tool(
35
35
  "get_overview_funnel",