@content-intel/cli 0.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/bin/content-intel.js +2 -0
- package/dist/commands/analytics/overview.js +55 -0
- package/dist/commands/audience/top-engagers.js +22 -0
- package/dist/commands/auth/login.js +153 -0
- package/dist/commands/auth/logout.js +11 -0
- package/dist/commands/auth/revoke.js +18 -0
- package/dist/commands/auth/set-token.js +53 -0
- package/dist/commands/auth/status.js +13 -0
- package/dist/commands/auth/token.js +11 -0
- package/dist/commands/config/path.js +10 -0
- package/dist/commands/posts/list.js +57 -0
- package/dist/commands/profiles/list.js +13 -0
- package/dist/commands/sync/status.js +22 -0
- package/dist/index.js +42 -0
- package/dist/lib/client.js +110 -0
- package/dist/lib/command.js +31 -0
- package/dist/lib/config.js +69 -0
- package/dist/types/index.js +24 -0
- package/package.json +46 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { runCommand, printJson } from "../../lib/command.js";
|
|
2
|
+
import { requireConfig } from "../../lib/config.js";
|
|
3
|
+
import { apiRequest } from "../../lib/client.js";
|
|
4
|
+
import { CliCommandError } from "../../types/index.js";
|
|
5
|
+
function assertValidDate(raw) {
|
|
6
|
+
if (!raw)
|
|
7
|
+
return;
|
|
8
|
+
const parsed = new Date(raw);
|
|
9
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
10
|
+
throw new CliCommandError("INVALID_ARGS", `Invalid date: ${raw}`);
|
|
11
|
+
}
|
|
12
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) {
|
|
13
|
+
const [y, m, d] = raw.split("-").map(Number);
|
|
14
|
+
const utc = new Date(Date.UTC(y, m - 1, d));
|
|
15
|
+
if (utc.getUTCFullYear() !== y ||
|
|
16
|
+
utc.getUTCMonth() !== m - 1 ||
|
|
17
|
+
utc.getUTCDate() !== d) {
|
|
18
|
+
throw new CliCommandError("INVALID_ARGS", `Invalid date: ${raw}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
export function registerAnalyticsOverviewCommand(analyticsCommand) {
|
|
23
|
+
analyticsCommand
|
|
24
|
+
.command("overview")
|
|
25
|
+
.description("Get aggregated analytics overview")
|
|
26
|
+
.option("--days <n>", "Last N days", (value) => {
|
|
27
|
+
if (!/^\d+$/.test(value))
|
|
28
|
+
return NaN;
|
|
29
|
+
return Number(value);
|
|
30
|
+
})
|
|
31
|
+
.option("--since <date>", "Start date (ISO or YYYY-MM-DD)")
|
|
32
|
+
.option("--until <date>", "End date (ISO or YYYY-MM-DD)")
|
|
33
|
+
.action((options) => runCommand(async () => {
|
|
34
|
+
if (options.days !== undefined &&
|
|
35
|
+
(!Number.isInteger(options.days) || options.days <= 0)) {
|
|
36
|
+
throw new CliCommandError("INVALID_ARGS", "--days must be a positive integer");
|
|
37
|
+
}
|
|
38
|
+
assertValidDate(options.since);
|
|
39
|
+
assertValidDate(options.until);
|
|
40
|
+
if (options.since && options.until) {
|
|
41
|
+
const sinceMs = new Date(options.since).getTime();
|
|
42
|
+
const untilMs = new Date(options.until).getTime();
|
|
43
|
+
if (sinceMs > untilMs) {
|
|
44
|
+
throw new CliCommandError("INVALID_ARGS", "--since cannot be after --until");
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const config = await requireConfig();
|
|
48
|
+
const response = await apiRequest(config, "/api/cli/analytics/overview", {
|
|
49
|
+
days: options.days,
|
|
50
|
+
since: options.since,
|
|
51
|
+
until: options.until,
|
|
52
|
+
});
|
|
53
|
+
printJson(response);
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { runCommand, printJson } from "../../lib/command.js";
|
|
2
|
+
import { requireConfig } from "../../lib/config.js";
|
|
3
|
+
import { apiRequest } from "../../lib/client.js";
|
|
4
|
+
import { CliCommandError } from "../../types/index.js";
|
|
5
|
+
export function registerAudienceTopEngagersCommand(audienceCommand) {
|
|
6
|
+
audienceCommand
|
|
7
|
+
.command("top-engagers")
|
|
8
|
+
.description("Get top engagers for a tracked profile")
|
|
9
|
+
.requiredOption("--profile <handle>", "Profile handle")
|
|
10
|
+
.option("--limit <n>", "Result limit", (value) => parseInt(value, 10), 10)
|
|
11
|
+
.action((options) => runCommand(async () => {
|
|
12
|
+
if (!Number.isInteger(options.limit) || options.limit <= 0) {
|
|
13
|
+
throw new CliCommandError("INVALID_ARGS", "--limit must be a positive integer");
|
|
14
|
+
}
|
|
15
|
+
const config = await requireConfig();
|
|
16
|
+
const response = await apiRequest(config, "/api/cli/audience/top-engagers", {
|
|
17
|
+
profileHandle: options.profile,
|
|
18
|
+
limit: options.limit,
|
|
19
|
+
});
|
|
20
|
+
printJson(response);
|
|
21
|
+
}));
|
|
22
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
import open from "open";
|
|
4
|
+
import { runCommand } from "../../lib/command.js";
|
|
5
|
+
import { saveConfig } from "../../lib/config.js";
|
|
6
|
+
import { CliCommandError } from "../../types/index.js";
|
|
7
|
+
const DEFAULT_APP_URL = process.env.CONTENT_INTEL_APP_URL?.trim() || "https://content-intel.com";
|
|
8
|
+
const LOGIN_TIMEOUT_MS = (() => {
|
|
9
|
+
const raw = process.env.CONTENT_INTEL_LOGIN_TIMEOUT_MS?.trim();
|
|
10
|
+
if (!raw)
|
|
11
|
+
return 5 * 60 * 1000;
|
|
12
|
+
const parsed = Number(raw);
|
|
13
|
+
if (!Number.isFinite(parsed) || parsed <= 0)
|
|
14
|
+
return 5 * 60 * 1000;
|
|
15
|
+
return parsed;
|
|
16
|
+
})();
|
|
17
|
+
export function validateLoginCallbackPayload(payload, expectedState) {
|
|
18
|
+
if (payload.state !== expectedState) {
|
|
19
|
+
throw new CliCommandError("AUTH_INVALID", "State validation failed.");
|
|
20
|
+
}
|
|
21
|
+
if (payload.error === "access_denied") {
|
|
22
|
+
throw new CliCommandError("AUTH_INVALID", "Authorization was cancelled.");
|
|
23
|
+
}
|
|
24
|
+
if (payload.error) {
|
|
25
|
+
throw new CliCommandError("AUTH_INVALID", `Authorization failed: ${payload.error}`);
|
|
26
|
+
}
|
|
27
|
+
if (!payload.token || !payload.apiUrl) {
|
|
28
|
+
throw new CliCommandError("AUTH_INVALID", "Missing token or apiUrl from callback.");
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
token: payload.token,
|
|
32
|
+
apiUrl: payload.apiUrl,
|
|
33
|
+
email: payload.email,
|
|
34
|
+
name: payload.name,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function parseCallbackPayload(req) {
|
|
38
|
+
const url = new URL(req.url, "http://localhost");
|
|
39
|
+
return {
|
|
40
|
+
token: url.searchParams.get("token") ?? undefined,
|
|
41
|
+
email: url.searchParams.get("email") ?? undefined,
|
|
42
|
+
name: url.searchParams.get("name") ?? undefined,
|
|
43
|
+
apiUrl: url.searchParams.get("apiUrl") ?? undefined,
|
|
44
|
+
state: url.searchParams.get("state") ?? undefined,
|
|
45
|
+
error: url.searchParams.get("error") ?? undefined,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function sendBrowserResponse(res, message, statusCode = 200) {
|
|
49
|
+
res.statusCode = statusCode;
|
|
50
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
51
|
+
res.end(`<!doctype html><html><body><p>${message}</p></body></html>`);
|
|
52
|
+
}
|
|
53
|
+
async function openBrowser(url) {
|
|
54
|
+
try {
|
|
55
|
+
await open(url);
|
|
56
|
+
/* c8 ignore next 3 */
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Fallback URL is always printed in terminal.
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function resolveAppUrl(rawUrl) {
|
|
63
|
+
const trimmed = rawUrl?.trim();
|
|
64
|
+
if (!trimmed) {
|
|
65
|
+
throw new CliCommandError("INVALID_ARGS", "Missing app URL. Pass --url <URL> or set CONTENT_INTEL_APP_URL.");
|
|
66
|
+
}
|
|
67
|
+
let parsed;
|
|
68
|
+
try {
|
|
69
|
+
parsed = new URL(trimmed);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
throw new CliCommandError("INVALID_ARGS", "Invalid app URL.");
|
|
73
|
+
}
|
|
74
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
75
|
+
throw new CliCommandError("INVALID_ARGS", "Invalid app URL protocol. Use http:// or https://.");
|
|
76
|
+
}
|
|
77
|
+
return parsed.toString().replace(/\/$/, "");
|
|
78
|
+
}
|
|
79
|
+
export function registerAuthLoginCommand(authCommand) {
|
|
80
|
+
authCommand
|
|
81
|
+
.command("login")
|
|
82
|
+
.description("Authenticate CLI via browser")
|
|
83
|
+
.option("--url <url>", "Override web app URL (or set CONTENT_INTEL_APP_URL)")
|
|
84
|
+
.action((options) => runCommand(async () => {
|
|
85
|
+
if (process.env.SSH_CONNECTION) {
|
|
86
|
+
throw new CliCommandError("INVALID_ARGS", "SSH session detected. Use: content-intel auth set-token <TOKEN> --api-url <URL>");
|
|
87
|
+
}
|
|
88
|
+
const appUrl = resolveAppUrl(options.url ?? DEFAULT_APP_URL);
|
|
89
|
+
const state = randomUUID();
|
|
90
|
+
let timeoutId;
|
|
91
|
+
let resolvePayload;
|
|
92
|
+
const callbackPromise = new Promise((resolve) => {
|
|
93
|
+
resolvePayload = resolve;
|
|
94
|
+
});
|
|
95
|
+
const server = createServer((req, res) => {
|
|
96
|
+
const requestUrl = new URL(req.url, "http://localhost");
|
|
97
|
+
if (requestUrl.pathname !== "/callback") {
|
|
98
|
+
sendBrowserResponse(res, "Not found", 404);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const payload = parseCallbackPayload(req);
|
|
102
|
+
if (payload.state !== state) {
|
|
103
|
+
sendBrowserResponse(res, "State mismatch. You can close this tab.", 400);
|
|
104
|
+
resolvePayload?.({ error: "invalid_state" });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (payload.error) {
|
|
108
|
+
sendBrowserResponse(res, "Authorization cancelled. You can close this tab.", 400);
|
|
109
|
+
resolvePayload?.(payload);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
sendBrowserResponse(res, "Login successful. You can close this tab.");
|
|
113
|
+
resolvePayload?.(payload);
|
|
114
|
+
});
|
|
115
|
+
try {
|
|
116
|
+
await new Promise((resolve, reject) => {
|
|
117
|
+
server.once("error", reject);
|
|
118
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
119
|
+
});
|
|
120
|
+
const address = server.address();
|
|
121
|
+
/* c8 ignore next 6 */
|
|
122
|
+
if (!address || typeof address === "string") {
|
|
123
|
+
throw new CliCommandError("SERVER_ERROR", "Failed to start local callback server.");
|
|
124
|
+
}
|
|
125
|
+
const authUrl = `${appUrl}/cli/auth?state=${encodeURIComponent(state)}&port=${address.port}`;
|
|
126
|
+
await openBrowser(authUrl);
|
|
127
|
+
process.stdout.write(`If browser didn't open, visit: ${authUrl}\n`);
|
|
128
|
+
timeoutId = setTimeout(() => {
|
|
129
|
+
resolvePayload?.({ error: "timeout", state });
|
|
130
|
+
}, LOGIN_TIMEOUT_MS);
|
|
131
|
+
const payload = await callbackPromise;
|
|
132
|
+
if (payload.error === "timeout") {
|
|
133
|
+
throw new CliCommandError("AUTH_EXPIRED", "Login timed out. Run content-intel auth login to try again.");
|
|
134
|
+
}
|
|
135
|
+
const validated = validateLoginCallbackPayload(payload, state);
|
|
136
|
+
await saveConfig({
|
|
137
|
+
token: validated.token,
|
|
138
|
+
apiUrl: validated.apiUrl,
|
|
139
|
+
email: validated.email,
|
|
140
|
+
name: validated.name,
|
|
141
|
+
});
|
|
142
|
+
process.stdout.write(`Logged in as ${validated.email ?? "unknown user"}\n`);
|
|
143
|
+
}
|
|
144
|
+
finally {
|
|
145
|
+
if (timeoutId) {
|
|
146
|
+
clearTimeout(timeoutId);
|
|
147
|
+
}
|
|
148
|
+
await new Promise((resolve) => {
|
|
149
|
+
server.close(() => resolve());
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { runCommand } from "../../lib/command.js";
|
|
2
|
+
import { clearConfig } from "../../lib/config.js";
|
|
3
|
+
export function registerAuthLogoutCommand(authCommand) {
|
|
4
|
+
authCommand
|
|
5
|
+
.command("logout")
|
|
6
|
+
.description("Remove local CLI credentials")
|
|
7
|
+
.action(() => runCommand(async () => {
|
|
8
|
+
await clearConfig();
|
|
9
|
+
process.stdout.write("Logged out.\n");
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { runCommand, printJson } from "../../lib/command.js";
|
|
2
|
+
import { requireConfig, clearConfig } from "../../lib/config.js";
|
|
3
|
+
import { apiRequest } from "../../lib/client.js";
|
|
4
|
+
export async function revokeCurrentAuth() {
|
|
5
|
+
const config = await requireConfig();
|
|
6
|
+
const response = await apiRequest(config, "/api/cli/auth/revoke", {});
|
|
7
|
+
await clearConfig();
|
|
8
|
+
return response;
|
|
9
|
+
}
|
|
10
|
+
export function registerAuthRevokeCommand(authCommand) {
|
|
11
|
+
authCommand
|
|
12
|
+
.command("revoke")
|
|
13
|
+
.description("Revoke current token and clear local config")
|
|
14
|
+
.action(() => runCommand(async () => {
|
|
15
|
+
const response = await revokeCurrentAuth();
|
|
16
|
+
printJson(response);
|
|
17
|
+
}));
|
|
18
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { runCommand } from "../../lib/command.js";
|
|
4
|
+
import { saveConfig } from "../../lib/config.js";
|
|
5
|
+
import { CliCommandError } from "../../types/index.js";
|
|
6
|
+
function normalizeApiUrl(raw) {
|
|
7
|
+
const value = raw.trim();
|
|
8
|
+
if (!/^https?:\/\//i.test(value)) {
|
|
9
|
+
throw new CliCommandError("INVALID_ARGS", "apiUrl must be an absolute URL, e.g. https://your-deployment.convex.site");
|
|
10
|
+
}
|
|
11
|
+
let parsed;
|
|
12
|
+
try {
|
|
13
|
+
parsed = new URL(value);
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
throw new CliCommandError("INVALID_ARGS", "apiUrl must be an absolute URL, e.g. https://your-deployment.convex.site");
|
|
17
|
+
}
|
|
18
|
+
if (parsed.hostname.endsWith(".convex.cloud")) {
|
|
19
|
+
parsed.hostname = parsed.hostname.replace(".convex.cloud", ".convex.site");
|
|
20
|
+
}
|
|
21
|
+
const normalizedPath = parsed.pathname.replace(/\/+$/, "");
|
|
22
|
+
return `${parsed.origin}${normalizedPath}`;
|
|
23
|
+
}
|
|
24
|
+
export function registerAuthSetTokenCommand(authCommand) {
|
|
25
|
+
authCommand
|
|
26
|
+
.command("set-token")
|
|
27
|
+
.description("Set token manually for headless/SSH environments")
|
|
28
|
+
.argument("<token>", "CLI token (ci_tok_...)")
|
|
29
|
+
.option("--api-url <url>", "Convex HTTP actions URL")
|
|
30
|
+
.action((token, options) => runCommand(async () => {
|
|
31
|
+
if (!token.startsWith("ci_tok_")) {
|
|
32
|
+
throw new CliCommandError("INVALID_ARGS", "Token format invalid. Expected token starting with ci_tok_.");
|
|
33
|
+
}
|
|
34
|
+
let apiUrl = options.apiUrl;
|
|
35
|
+
if (!apiUrl) {
|
|
36
|
+
const rl = createInterface({ input, output });
|
|
37
|
+
try {
|
|
38
|
+
apiUrl = await rl.question("Convex API URL (https://...convex.site): ");
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
rl.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (!apiUrl) {
|
|
45
|
+
throw new CliCommandError("INVALID_ARGS", "apiUrl is required.");
|
|
46
|
+
}
|
|
47
|
+
await saveConfig({
|
|
48
|
+
token,
|
|
49
|
+
apiUrl: normalizeApiUrl(apiUrl),
|
|
50
|
+
});
|
|
51
|
+
process.stdout.write("Token saved.\n");
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { runCommand, printJson } from "../../lib/command.js";
|
|
2
|
+
import { requireConfig } from "../../lib/config.js";
|
|
3
|
+
import { apiRequest } from "../../lib/client.js";
|
|
4
|
+
export function registerAuthStatusCommand(authCommand) {
|
|
5
|
+
authCommand
|
|
6
|
+
.command("status")
|
|
7
|
+
.description("Show current authentication status")
|
|
8
|
+
.action(() => runCommand(async () => {
|
|
9
|
+
const config = await requireConfig();
|
|
10
|
+
const response = await apiRequest(config, "/api/cli/auth/status", {});
|
|
11
|
+
printJson(response);
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { runCommand } from "../../lib/command.js";
|
|
2
|
+
import { requireConfig } from "../../lib/config.js";
|
|
3
|
+
export function registerAuthTokenCommand(authCommand) {
|
|
4
|
+
authCommand
|
|
5
|
+
.command("token")
|
|
6
|
+
.description("Print raw CLI token")
|
|
7
|
+
.action(() => runCommand(async () => {
|
|
8
|
+
const config = await requireConfig();
|
|
9
|
+
process.stdout.write(`${config.token}\n`);
|
|
10
|
+
}));
|
|
11
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { runCommand } from "../../lib/command.js";
|
|
2
|
+
import { getConfigPath } from "../../lib/config.js";
|
|
3
|
+
export function registerConfigPathCommand(configCommand) {
|
|
4
|
+
configCommand
|
|
5
|
+
.command("path")
|
|
6
|
+
.description("Print local config file path")
|
|
7
|
+
.action(() => runCommand(async () => {
|
|
8
|
+
process.stdout.write(`${getConfigPath()}\n`);
|
|
9
|
+
}));
|
|
10
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { runCommand, printJson } from "../../lib/command.js";
|
|
2
|
+
import { requireConfig } from "../../lib/config.js";
|
|
3
|
+
import { apiRequest } from "../../lib/client.js";
|
|
4
|
+
import { CliCommandError } from "../../types/index.js";
|
|
5
|
+
function assertValidDate(raw) {
|
|
6
|
+
if (!raw)
|
|
7
|
+
return;
|
|
8
|
+
if (Number.isNaN(new Date(raw).getTime())) {
|
|
9
|
+
throw new CliCommandError("INVALID_ARGS", `Invalid date: ${raw}`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function registerPostsListCommand(postsCommand) {
|
|
13
|
+
postsCommand
|
|
14
|
+
.command("list")
|
|
15
|
+
.description("List posts with filters")
|
|
16
|
+
.option("--profile <handle>", "Profile handle (username, @username, or URL)")
|
|
17
|
+
.option("--days <n>", "Last N days", (value) => parseInt(value, 10))
|
|
18
|
+
.option("--since <date>", "Start date (ISO or YYYY-MM-DD)")
|
|
19
|
+
.option("--until <date>", "End date (ISO or YYYY-MM-DD)")
|
|
20
|
+
.option("--type <type>", "Post type: repost|video|image|carousel|text")
|
|
21
|
+
.option("--sort <field>", "Sort field: date|likes|comments|shares", "date")
|
|
22
|
+
.option("--sort-dir <dir>", "Sort direction: asc|desc", "desc")
|
|
23
|
+
.option("--hide-reposts", "Exclude reposts")
|
|
24
|
+
.option("--limit <n>", "Result limit", (value) => parseInt(value, 10), 50)
|
|
25
|
+
.option("--cursor <id>", "Pagination cursor")
|
|
26
|
+
.action((options) => runCommand(async () => {
|
|
27
|
+
if (options.days !== undefined && (!Number.isInteger(options.days) || options.days <= 0)) {
|
|
28
|
+
throw new CliCommandError("INVALID_ARGS", "--days must be a positive integer");
|
|
29
|
+
}
|
|
30
|
+
if (!Number.isInteger(options.limit) || options.limit <= 0) {
|
|
31
|
+
throw new CliCommandError("INVALID_ARGS", "--limit must be a positive integer");
|
|
32
|
+
}
|
|
33
|
+
assertValidDate(options.since);
|
|
34
|
+
assertValidDate(options.until);
|
|
35
|
+
if (options.since && options.until) {
|
|
36
|
+
const sinceMs = new Date(options.since).getTime();
|
|
37
|
+
const untilMs = new Date(options.until).getTime();
|
|
38
|
+
if (sinceMs > untilMs) {
|
|
39
|
+
throw new CliCommandError("INVALID_ARGS", "--since cannot be after --until");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const config = await requireConfig();
|
|
43
|
+
const response = await apiRequest(config, "/api/cli/posts/list", {
|
|
44
|
+
profileHandle: options.profile,
|
|
45
|
+
days: options.days,
|
|
46
|
+
since: options.since,
|
|
47
|
+
until: options.until,
|
|
48
|
+
type: options.type,
|
|
49
|
+
sort: options.sort,
|
|
50
|
+
sortDir: options.sortDir,
|
|
51
|
+
hideReposts: options.hideReposts,
|
|
52
|
+
limit: options.limit,
|
|
53
|
+
cursor: options.cursor,
|
|
54
|
+
});
|
|
55
|
+
printJson(response);
|
|
56
|
+
}));
|
|
57
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { runCommand, printJson } from "../../lib/command.js";
|
|
2
|
+
import { requireConfig } from "../../lib/config.js";
|
|
3
|
+
import { apiRequest } from "../../lib/client.js";
|
|
4
|
+
export function registerProfilesListCommand(profilesCommand) {
|
|
5
|
+
profilesCommand
|
|
6
|
+
.command("list")
|
|
7
|
+
.description("List tracked LinkedIn profiles")
|
|
8
|
+
.action(() => runCommand(async () => {
|
|
9
|
+
const config = await requireConfig();
|
|
10
|
+
const response = await apiRequest(config, "/api/cli/profiles/list", {});
|
|
11
|
+
printJson(response);
|
|
12
|
+
}));
|
|
13
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { runCommand, printJson } from "../../lib/command.js";
|
|
2
|
+
import { requireConfig } from "../../lib/config.js";
|
|
3
|
+
import { apiRequest } from "../../lib/client.js";
|
|
4
|
+
import { CliCommandError } from "../../types/index.js";
|
|
5
|
+
export function registerSyncStatusCommand(syncCommand) {
|
|
6
|
+
syncCommand
|
|
7
|
+
.command("status")
|
|
8
|
+
.description("Get sync status for tracked profiles")
|
|
9
|
+
.option("--profile <handle>", "Profile handle")
|
|
10
|
+
.option("--limit <n>", "Number of jobs per profile", (value) => parseInt(value, 10), 1)
|
|
11
|
+
.action((options) => runCommand(async () => {
|
|
12
|
+
if (!Number.isInteger(options.limit) || options.limit <= 0) {
|
|
13
|
+
throw new CliCommandError("INVALID_ARGS", "--limit must be a positive integer");
|
|
14
|
+
}
|
|
15
|
+
const config = await requireConfig();
|
|
16
|
+
const response = await apiRequest(config, "/api/cli/sync/status", {
|
|
17
|
+
profileHandle: options.profile,
|
|
18
|
+
limit: options.limit,
|
|
19
|
+
});
|
|
20
|
+
printJson(response);
|
|
21
|
+
}));
|
|
22
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { registerAuthLoginCommand } from "./commands/auth/login.js";
|
|
4
|
+
import { registerAuthLogoutCommand } from "./commands/auth/logout.js";
|
|
5
|
+
import { registerAuthStatusCommand } from "./commands/auth/status.js";
|
|
6
|
+
import { registerAuthTokenCommand } from "./commands/auth/token.js";
|
|
7
|
+
import { registerAuthSetTokenCommand } from "./commands/auth/set-token.js";
|
|
8
|
+
import { registerAuthRevokeCommand } from "./commands/auth/revoke.js";
|
|
9
|
+
import { registerProfilesListCommand } from "./commands/profiles/list.js";
|
|
10
|
+
import { registerPostsListCommand } from "./commands/posts/list.js";
|
|
11
|
+
import { registerAnalyticsOverviewCommand } from "./commands/analytics/overview.js";
|
|
12
|
+
import { registerAudienceTopEngagersCommand } from "./commands/audience/top-engagers.js";
|
|
13
|
+
import { registerSyncStatusCommand } from "./commands/sync/status.js";
|
|
14
|
+
import { registerConfigPathCommand } from "./commands/config/path.js";
|
|
15
|
+
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
16
|
+
const program = new Command();
|
|
17
|
+
program
|
|
18
|
+
.name("content-intel")
|
|
19
|
+
.description("Content Intelligence CLI")
|
|
20
|
+
.version(packageJson.version);
|
|
21
|
+
const authCommand = program.command("auth").description("Authentication commands");
|
|
22
|
+
registerAuthLoginCommand(authCommand);
|
|
23
|
+
registerAuthLogoutCommand(authCommand);
|
|
24
|
+
registerAuthStatusCommand(authCommand);
|
|
25
|
+
registerAuthTokenCommand(authCommand);
|
|
26
|
+
registerAuthSetTokenCommand(authCommand);
|
|
27
|
+
registerAuthRevokeCommand(authCommand);
|
|
28
|
+
const profilesCommand = program.command("profiles").description("Profiles commands");
|
|
29
|
+
registerProfilesListCommand(profilesCommand);
|
|
30
|
+
const postsCommand = program.command("posts").description("Posts commands");
|
|
31
|
+
registerPostsListCommand(postsCommand);
|
|
32
|
+
const analyticsCommand = program
|
|
33
|
+
.command("analytics")
|
|
34
|
+
.description("Analytics commands");
|
|
35
|
+
registerAnalyticsOverviewCommand(analyticsCommand);
|
|
36
|
+
const audienceCommand = program.command("audience").description("Audience commands");
|
|
37
|
+
registerAudienceTopEngagersCommand(audienceCommand);
|
|
38
|
+
const syncCommand = program.command("sync").description("Sync commands");
|
|
39
|
+
registerSyncStatusCommand(syncCommand);
|
|
40
|
+
const configCommand = program.command("config").description("Config commands");
|
|
41
|
+
registerConfigPathCommand(configCommand);
|
|
42
|
+
void program.parseAsync(process.argv);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { CliCommandError, } from "../types/index.js";
|
|
2
|
+
const CLI_ERROR_CODES = new Set([
|
|
3
|
+
"AUTH_REQUIRED",
|
|
4
|
+
"AUTH_EXPIRED",
|
|
5
|
+
"AUTH_INVALID",
|
|
6
|
+
"ACCESS_DENIED",
|
|
7
|
+
"NOT_FOUND",
|
|
8
|
+
"INVALID_ARGS",
|
|
9
|
+
"SERVER_ERROR",
|
|
10
|
+
"NETWORK_ERROR",
|
|
11
|
+
]);
|
|
12
|
+
function isObject(value) {
|
|
13
|
+
return typeof value === "object" && value !== null;
|
|
14
|
+
}
|
|
15
|
+
function toCliErrorCode(code) {
|
|
16
|
+
if (typeof code === "string" && CLI_ERROR_CODES.has(code)) {
|
|
17
|
+
return code;
|
|
18
|
+
}
|
|
19
|
+
return "SERVER_ERROR";
|
|
20
|
+
}
|
|
21
|
+
function parseApiFailure(payload) {
|
|
22
|
+
if (!isObject(payload) || payload.ok !== false) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
const error = payload.error;
|
|
26
|
+
if (!isObject(error)) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const message = typeof error.message === "string" && error.message.length > 0
|
|
30
|
+
? error.message
|
|
31
|
+
: "Request failed with unknown server error";
|
|
32
|
+
return {
|
|
33
|
+
code: toCliErrorCode(error.code),
|
|
34
|
+
message,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function parsePlatformError(payload) {
|
|
38
|
+
if (!isObject(payload)) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
const rawCode = typeof payload.code === "string" ? payload.code : undefined;
|
|
42
|
+
const message = typeof payload.message === "string" && payload.message.trim().length > 0
|
|
43
|
+
? payload.message
|
|
44
|
+
: undefined;
|
|
45
|
+
if (!rawCode && !message) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
if (rawCode === "InvalidDeploymentName") {
|
|
49
|
+
return {
|
|
50
|
+
code: "INVALID_ARGS",
|
|
51
|
+
message: message ??
|
|
52
|
+
"Invalid Convex deployment URL configured. Re-run content-intel auth login.",
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return {
|
|
56
|
+
code: rawCode ? toCliErrorCode(rawCode) : "SERVER_ERROR",
|
|
57
|
+
message: message ?? "Request failed",
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function isApiSuccess(payload) {
|
|
61
|
+
return isObject(payload) && payload.ok === true && "data" in payload;
|
|
62
|
+
}
|
|
63
|
+
export async function apiRequest(config, endpoint, body = {}, options = {}) {
|
|
64
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
65
|
+
const controller = new AbortController();
|
|
66
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
67
|
+
try {
|
|
68
|
+
const response = await fetch(`${config.apiUrl}${endpoint}`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: {
|
|
71
|
+
"Content-Type": "application/json",
|
|
72
|
+
Authorization: `Bearer ${config.token}`,
|
|
73
|
+
},
|
|
74
|
+
body: JSON.stringify(body),
|
|
75
|
+
signal: controller.signal,
|
|
76
|
+
});
|
|
77
|
+
let payload = null;
|
|
78
|
+
try {
|
|
79
|
+
payload = await response.json();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
payload = null;
|
|
83
|
+
}
|
|
84
|
+
if (response.ok && isApiSuccess(payload)) {
|
|
85
|
+
return payload;
|
|
86
|
+
}
|
|
87
|
+
const apiFailure = parseApiFailure(payload);
|
|
88
|
+
if (apiFailure) {
|
|
89
|
+
throw new CliCommandError(apiFailure.code, apiFailure.message, response.status);
|
|
90
|
+
}
|
|
91
|
+
const platformError = parsePlatformError(payload);
|
|
92
|
+
if (platformError) {
|
|
93
|
+
throw new CliCommandError(platformError.code, platformError.message, response.status);
|
|
94
|
+
}
|
|
95
|
+
throw new CliCommandError("SERVER_ERROR", `Request failed with status ${response.status}`, response.status);
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error instanceof CliCommandError) {
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
102
|
+
throw new CliCommandError("NETWORK_ERROR", "Request timed out. Please try again.");
|
|
103
|
+
}
|
|
104
|
+
const message = error instanceof Error ? error.message : "Network request failed";
|
|
105
|
+
throw new CliCommandError("NETWORK_ERROR", message);
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
clearTimeout(timeoutId);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { CliCommandError, exitCodeForError, } from "../types/index.js";
|
|
2
|
+
function errorCodeFromUnknown(error) {
|
|
3
|
+
if (error instanceof CliCommandError) {
|
|
4
|
+
return error.code;
|
|
5
|
+
}
|
|
6
|
+
return "SERVER_ERROR";
|
|
7
|
+
}
|
|
8
|
+
function errorMessageFromUnknown(error) {
|
|
9
|
+
if (error instanceof Error) {
|
|
10
|
+
return error.message;
|
|
11
|
+
}
|
|
12
|
+
return "Unknown error";
|
|
13
|
+
}
|
|
14
|
+
export function printJson(data) {
|
|
15
|
+
process.stdout.write(`${JSON.stringify(data, null, 2)}\n`);
|
|
16
|
+
}
|
|
17
|
+
export async function runCommand(action) {
|
|
18
|
+
try {
|
|
19
|
+
await action();
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
printJson({
|
|
23
|
+
ok: false,
|
|
24
|
+
error: {
|
|
25
|
+
code: errorCodeFromUnknown(error),
|
|
26
|
+
message: errorMessageFromUnknown(error),
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
process.exitCode = exitCodeForError(error);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { CliCommandError } from "../types/index.js";
|
|
5
|
+
const CONFIG_DIR_NAME = ".content-intel";
|
|
6
|
+
const CONFIG_FILE_NAME = "config.json";
|
|
7
|
+
const CONFIG_DIR_ENV_VAR = "CONTENT_INTEL_CONFIG_DIR";
|
|
8
|
+
export function getConfigDirPath() {
|
|
9
|
+
const overrideDir = process.env[CONFIG_DIR_ENV_VAR]?.trim();
|
|
10
|
+
if (overrideDir) {
|
|
11
|
+
return path.resolve(overrideDir);
|
|
12
|
+
}
|
|
13
|
+
return path.join(os.homedir(), CONFIG_DIR_NAME);
|
|
14
|
+
}
|
|
15
|
+
export function getConfigPath() {
|
|
16
|
+
return path.join(getConfigDirPath(), CONFIG_FILE_NAME);
|
|
17
|
+
}
|
|
18
|
+
async function ensureConfigDir() {
|
|
19
|
+
const dirPath = getConfigDirPath();
|
|
20
|
+
await mkdir(dirPath, { recursive: true, mode: 0o700 });
|
|
21
|
+
await chmod(dirPath, 0o700);
|
|
22
|
+
}
|
|
23
|
+
export async function loadConfig() {
|
|
24
|
+
try {
|
|
25
|
+
const raw = await readFile(getConfigPath(), "utf8");
|
|
26
|
+
const parsed = JSON.parse(raw);
|
|
27
|
+
if (!parsed ||
|
|
28
|
+
typeof parsed.token !== "string" ||
|
|
29
|
+
typeof parsed.apiUrl !== "string") {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
token: parsed.token,
|
|
34
|
+
apiUrl: parsed.apiUrl,
|
|
35
|
+
email: parsed.email,
|
|
36
|
+
name: parsed.name,
|
|
37
|
+
createdAt: typeof parsed.createdAt === "number" ? parsed.createdAt : Date.now(),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error.code === "ENOENT") {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
export async function requireConfig() {
|
|
48
|
+
const config = await loadConfig();
|
|
49
|
+
if (!config) {
|
|
50
|
+
throw new CliCommandError("AUTH_REQUIRED", "No CLI credentials found. Run: content-intel auth login");
|
|
51
|
+
}
|
|
52
|
+
return config;
|
|
53
|
+
}
|
|
54
|
+
export async function saveConfig(config) {
|
|
55
|
+
await ensureConfigDir();
|
|
56
|
+
const payload = {
|
|
57
|
+
...config,
|
|
58
|
+
createdAt: config.createdAt ?? Date.now(),
|
|
59
|
+
};
|
|
60
|
+
const configPath = getConfigPath();
|
|
61
|
+
await writeFile(configPath, `${JSON.stringify(payload, null, 2)}\n`, {
|
|
62
|
+
encoding: "utf8",
|
|
63
|
+
mode: 0o600,
|
|
64
|
+
});
|
|
65
|
+
await chmod(configPath, 0o600);
|
|
66
|
+
}
|
|
67
|
+
export async function clearConfig() {
|
|
68
|
+
await rm(getConfigPath(), { force: true });
|
|
69
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export class CliCommandError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
status;
|
|
4
|
+
constructor(code, message, status) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = "CliCommandError";
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function exitCodeForError(error) {
|
|
12
|
+
if (error instanceof CliCommandError) {
|
|
13
|
+
if (error.code === "AUTH_REQUIRED" ||
|
|
14
|
+
error.code === "AUTH_EXPIRED" ||
|
|
15
|
+
error.code === "AUTH_INVALID") {
|
|
16
|
+
return 2;
|
|
17
|
+
}
|
|
18
|
+
if (error.code === "ACCESS_DENIED") {
|
|
19
|
+
return 3;
|
|
20
|
+
}
|
|
21
|
+
return 1;
|
|
22
|
+
}
|
|
23
|
+
return 1;
|
|
24
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@content-intel/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Command-line client for the Content Intelligence API",
|
|
5
|
+
"homepage": "https://github.com/Sales-Automation-Systems/content-intelligence/tree/main/cli#readme",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/Sales-Automation-Systems/content-intelligence/issues"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/Sales-Automation-Systems/content-intelligence.git",
|
|
12
|
+
"directory": "cli"
|
|
13
|
+
},
|
|
14
|
+
"type": "module",
|
|
15
|
+
"bin": {
|
|
16
|
+
"content-intel": "./bin/content-intel.js"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"bin",
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=20.0.0"
|
|
27
|
+
},
|
|
28
|
+
"scripts": {
|
|
29
|
+
"dev": "tsx watch src/index.ts",
|
|
30
|
+
"build": "rm -rf dist && tsc",
|
|
31
|
+
"prepack": "npm run build",
|
|
32
|
+
"start": "node dist/index.js",
|
|
33
|
+
"test:unit": "tsx --test $(find src -type f -name '*.test.ts')",
|
|
34
|
+
"test": "c8 --all --include \"src/**/*.ts\" --exclude \"src/**/*.test.ts\" --exclude \"src/types/open.d.ts\" --check-coverage --lines 100 --functions 100 --branches 100 --statements 100 npm run test:unit"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"commander": "^14.0.3",
|
|
38
|
+
"open": "^10.2.0"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/node": "^22.0.0",
|
|
42
|
+
"c8": "^10.1.3",
|
|
43
|
+
"tsx": "^4.20.3",
|
|
44
|
+
"typescript": "^5.8.3"
|
|
45
|
+
}
|
|
46
|
+
}
|