prospect-ai-agent 1.0.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 ADDED
@@ -0,0 +1,136 @@
1
+ # ProspectAI Desktop Agent
2
+
3
+ A lightweight CLI that connects to your ProspectAI workspace and runs LinkedIn automation (connect, message, comment, scrape) through a real headless browser. No Chrome extension needed.
4
+
5
+ Runs on **macOS** and **Windows**. Requires **Node.js 18+**.
6
+
7
+ ## Quick Start
8
+
9
+ ```bash
10
+ # 1. Install
11
+ npm install -g prospect-ai-agent
12
+
13
+ # 2. Pair with your workspace (get code from Campaigns > Desktop Agent)
14
+ prospect-ai-agent pair <CODE> -s https://your-app-url.com
15
+
16
+ # 3. One-time LinkedIn login (opens a browser window)
17
+ prospect-ai-agent login
18
+
19
+ # 4. Start the agent (runs headless in background)
20
+ prospect-ai-agent start
21
+ ```
22
+
23
+ Or run without installing:
24
+
25
+ ```bash
26
+ npx prospect-ai-agent pair <CODE> -s https://your-app-url.com
27
+ npx prospect-ai-agent login
28
+ npx prospect-ai-agent start
29
+ ```
30
+
31
+ ## How It Works
32
+
33
+ ```
34
+ ProspectAI Web App (control plane) Desktop Agent (execution layer)
35
+ ┌──────────────────────────────┐ ┌──────────────────────────────┐
36
+ │ Campaigns UI │ │ CLI (commander) │
37
+ │ - Create campaigns │ │ - pair / login / start │
38
+ │ - Approve AI comments │ │ - run / status / unpair │
39
+ │ - View connected agents │ │ │
40
+ │ │ │ Agent Daemon │
41
+ │ Automation APIs │ <──── │ - Heartbeat (60s) │
42
+ │ - GET /next-job │ │ - Poll for jobs (5 min) │
43
+ │ - POST /report │ │ - Launch headless browser │
44
+ │ - POST /heartbeat │ │ - Execute job via Playwright│
45
+ │ - GET/POST /cookies │ │ - Close browser after task │
46
+ └──────────────────────────────┘ └──────────────────────────────┘
47
+ ```
48
+
49
+ The agent polls for pending automation jobs, launches a headless Chromium browser only when there's work, executes the task, and closes the browser. Session cookies are encrypted and synced with the server.
50
+
51
+ ## Commands
52
+
53
+ | Command | Description |
54
+ |---|---|
55
+ | `pair <CODE> -s <URL>` | Pair this device with your ProspectAI workspace |
56
+ | `login` | One-time LinkedIn authentication (opens headed browser) |
57
+ | `start` | Start background daemon (heartbeat + job polling) |
58
+ | `start --interval 10` | Start with custom poll interval (1–30 minutes, default 5) |
59
+ | `run` | Execute one pending job immediately and exit |
60
+ | `status` | Show current device configuration |
61
+ | `unpair` | Disconnect this device and remove local config |
62
+
63
+ ## Supported Job Types
64
+
65
+ | Type | Description |
66
+ |---|---|
67
+ | `connect` | Send a LinkedIn connection request |
68
+ | `message` | Send a LinkedIn direct message |
69
+ | `scrape_connections` | Sync your LinkedIn connections list |
70
+ | `scrape_posts` | Fetch latest post per prospect (for comment campaigns) |
71
+ | `comment` | Post an approved comment on a LinkedIn post |
72
+
73
+ ## Setup Steps
74
+
75
+ ### 1. Pair with workspace
76
+
77
+ Generate a pairing code from the **Campaigns** page in ProspectAI (Desktop Agent button), then:
78
+
79
+ ```bash
80
+ prospect-ai-agent pair ABC123 -s https://your-app-url.com
81
+ ```
82
+
83
+ This saves a JWT token and device info to `~/.bluehans-agent/config.json`.
84
+
85
+ ### 2. Authenticate with LinkedIn
86
+
87
+ ```bash
88
+ prospect-ai-agent login
89
+ ```
90
+
91
+ A headed browser opens. Log in to LinkedIn normally. Once you reach the feed, the agent captures your session cookies, encrypts them, and closes the browser. This is a one-time step.
92
+
93
+ ### 3. Start the agent
94
+
95
+ ```bash
96
+ prospect-ai-agent start
97
+ ```
98
+
99
+ The agent runs continuously:
100
+ - Sends heartbeat every 60 seconds
101
+ - Polls for jobs every 5 minutes (configurable with `--interval`)
102
+ - Launches headless Chromium per task, closes after completion
103
+ - Reports results back to ProspectAI
104
+
105
+ Press **Ctrl+C** to stop.
106
+
107
+ ## Local Data
108
+
109
+ All data stored in `~/.bluehans-agent/`:
110
+
111
+ | Path | Contents |
112
+ |---|---|
113
+ | `config.json` | JWT token, device ID, server URL |
114
+ | `cookies.enc` | Encrypted LinkedIn session cookies |
115
+
116
+ ## Troubleshooting
117
+
118
+ **"Not paired"** — Run `pair` with a fresh code from the app.
119
+
120
+ **"No cookies available"** — Run `login` to authenticate with LinkedIn.
121
+
122
+ **Heartbeat failing (401)** — JWT expired (30-day lifespan). Run `unpair` then `pair` again.
123
+
124
+ **Jobs not executing** — Ensure campaigns are in Active status. Check with `status` command.
125
+
126
+ ## Security
127
+
128
+ - Agent JWT tokens expire after 30 days; re-pair to renew
129
+ - Revoke any device instantly from ProspectAI Settings
130
+ - LinkedIn cookies are encrypted with AES-256-GCM
131
+ - Only structured job types are executed (no arbitrary code)
132
+ - Browser launches headless and closes after each task
133
+
134
+ ## License
135
+
136
+ Private — for use with ProspectAI only.
package/dist/agent.js ADDED
@@ -0,0 +1,117 @@
1
+ import { ApiClient } from "./api-client.js";
2
+ import { BrowserManager } from "./executor/browser.js";
3
+ import { CookieManager } from "./cookie-manager.js";
4
+ import { executeJob } from "./executor/index.js";
5
+ import { log } from "./logger.js";
6
+ const HEARTBEAT_INTERVAL_MS = 60_000;
7
+ const DEFAULT_POLL_INTERVAL_MIN = 5;
8
+ export class Agent {
9
+ apiClient;
10
+ browser;
11
+ cookieManager;
12
+ workspaceId;
13
+ pollIntervalMs;
14
+ heartbeatTimer = null;
15
+ pollTimer = null;
16
+ running = false;
17
+ executing = false;
18
+ constructor(options) {
19
+ this.apiClient = new ApiClient(options.serverUrl, options.token);
20
+ this.browser = new BrowserManager();
21
+ this.cookieManager = new CookieManager(options.token);
22
+ this.workspaceId = options.workspaceId;
23
+ this.pollIntervalMs =
24
+ (options.pollIntervalMin ?? DEFAULT_POLL_INTERVAL_MIN) * 60_000;
25
+ }
26
+ async start() {
27
+ this.running = true;
28
+ const hasCookies = this.cookieManager.loadLocal();
29
+ if (!hasCookies) {
30
+ const remote = await this.cookieManager.fetchFromServer(this.apiClient);
31
+ if (!remote) {
32
+ log.warn("No LinkedIn cookies found. Run 'login' first to authenticate.");
33
+ }
34
+ }
35
+ await this.apiClient.heartbeat();
36
+ log.success("Heartbeat sent. Agent is online.");
37
+ this.heartbeatTimer = setInterval(async () => {
38
+ if (!this.running)
39
+ return;
40
+ const ok = await this.apiClient.heartbeat();
41
+ if (!ok)
42
+ log.warn("Heartbeat failed — will retry next interval.");
43
+ }, HEARTBEAT_INTERVAL_MS);
44
+ await this.delay(3_000);
45
+ await this.pollForJob();
46
+ const intervalMin = this.pollIntervalMs / 60_000;
47
+ this.pollTimer = setInterval(async () => {
48
+ if (!this.running)
49
+ return;
50
+ await this.pollForJob();
51
+ }, this.pollIntervalMs);
52
+ log.success(`Agent is running. Polling every ${intervalMin} min. Browser launches on-demand.`);
53
+ log.info("Press Ctrl+C to stop.\n");
54
+ }
55
+ async runOnce() {
56
+ log.info("Run-once mode: checking for pending jobs...");
57
+ await this.apiClient.heartbeat();
58
+ await this.pollForJob();
59
+ log.info("Run-once complete.");
60
+ }
61
+ async pollForJob() {
62
+ if (this.executing)
63
+ return;
64
+ const job = await this.apiClient.fetchNextJob();
65
+ if (!job)
66
+ return;
67
+ this.executing = true;
68
+ log.info(`Received job: ${job.type} (${job.id})`);
69
+ const cookies = await this.cookieManager.getCookies(this.apiClient);
70
+ if (!cookies || cookies.length === 0) {
71
+ log.error("No cookies available. Run 'login' to authenticate with LinkedIn.");
72
+ await this.apiClient.reportJob(job.id, "failed", "No LinkedIn session cookies available");
73
+ this.executing = false;
74
+ return;
75
+ }
76
+ try {
77
+ const page = await this.browser.launchForTask(cookies);
78
+ const result = await executeJob(job, this.browser, this.apiClient, this.workspaceId);
79
+ const updatedCookies = await this.browser.closeTask();
80
+ await this.cookieManager.syncAfterTask(this.apiClient, updatedCookies);
81
+ if (result.success) {
82
+ await this.apiClient.reportJob(job.id, "done");
83
+ log.success(`Job ${job.id} completed successfully.`);
84
+ }
85
+ else {
86
+ await this.apiClient.reportJob(job.id, "failed", result.error);
87
+ log.error(`Job ${job.id} failed: ${result.error}`);
88
+ }
89
+ }
90
+ catch (err) {
91
+ const message = err.message;
92
+ await this.apiClient.reportJob(job.id, "failed", message);
93
+ log.error(`Job ${job.id} threw error: ${message}`);
94
+ await this.browser.close();
95
+ }
96
+ finally {
97
+ this.executing = false;
98
+ }
99
+ }
100
+ async stop() {
101
+ log.info("Shutting down agent...");
102
+ this.running = false;
103
+ if (this.heartbeatTimer) {
104
+ clearInterval(this.heartbeatTimer);
105
+ this.heartbeatTimer = null;
106
+ }
107
+ if (this.pollTimer) {
108
+ clearInterval(this.pollTimer);
109
+ this.pollTimer = null;
110
+ }
111
+ await this.browser.close();
112
+ log.success("Agent stopped.");
113
+ }
114
+ delay(ms) {
115
+ return new Promise((resolve) => setTimeout(resolve, ms));
116
+ }
117
+ }
@@ -0,0 +1,153 @@
1
+ import { log } from "./logger.js";
2
+ export class ApiClient {
3
+ serverUrl;
4
+ token;
5
+ constructor(serverUrl, token = "") {
6
+ this.serverUrl = serverUrl;
7
+ this.token = token;
8
+ }
9
+ authHeaders() {
10
+ return {
11
+ "Content-Type": "application/json",
12
+ ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
13
+ };
14
+ }
15
+ async pair(code, deviceName, osType, agentVersion) {
16
+ const res = await fetch(`${this.serverUrl}/api/agent/pair`, {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify({ code, deviceName, osType, agentVersion }),
20
+ });
21
+ if (!res.ok) {
22
+ const body = await res.json().catch(() => ({}));
23
+ throw new Error(body.error ||
24
+ `Pairing failed (HTTP ${res.status})`);
25
+ }
26
+ return (await res.json());
27
+ }
28
+ async heartbeat() {
29
+ try {
30
+ const res = await fetch(`${this.serverUrl}/api/agent/heartbeat`, {
31
+ method: "POST",
32
+ headers: this.authHeaders(),
33
+ });
34
+ if (!res.ok) {
35
+ log.warn(`Heartbeat failed: HTTP ${res.status}`);
36
+ return false;
37
+ }
38
+ return true;
39
+ }
40
+ catch (err) {
41
+ log.warn(`Heartbeat error: ${err.message}`);
42
+ return false;
43
+ }
44
+ }
45
+ async fetchNextJob() {
46
+ try {
47
+ const res = await fetch(`${this.serverUrl}/api/automation/next-job`, {
48
+ method: "GET",
49
+ headers: this.authHeaders(),
50
+ });
51
+ if (!res.ok) {
52
+ log.warn(`Next-job request failed: HTTP ${res.status}`);
53
+ return null;
54
+ }
55
+ const data = await res.json();
56
+ if (!data || !data.id)
57
+ return null;
58
+ return data;
59
+ }
60
+ catch (err) {
61
+ log.warn(`Next-job error: ${err.message}`);
62
+ return null;
63
+ }
64
+ }
65
+ async reportJob(jobId, status, errorMessage) {
66
+ try {
67
+ const res = await fetch(`${this.serverUrl}/api/automation/report`, {
68
+ method: "POST",
69
+ headers: this.authHeaders(),
70
+ body: JSON.stringify({ jobId, status, errorMessage }),
71
+ });
72
+ if (!res.ok) {
73
+ log.warn(`Report failed: HTTP ${res.status}`);
74
+ return false;
75
+ }
76
+ return true;
77
+ }
78
+ catch (err) {
79
+ log.warn(`Report error: ${err.message}`);
80
+ return false;
81
+ }
82
+ }
83
+ async syncConnections(workspaceId, profiles) {
84
+ try {
85
+ const res = await fetch(`${this.serverUrl}/api/prospects/sync-connections`, {
86
+ method: "POST",
87
+ headers: this.authHeaders(),
88
+ body: JSON.stringify({ workspaceId, profiles }),
89
+ });
90
+ return res.ok;
91
+ }
92
+ catch (err) {
93
+ log.warn(`Sync connections error: ${err.message}`);
94
+ return false;
95
+ }
96
+ }
97
+ async getCookies() {
98
+ try {
99
+ const res = await fetch(`${this.serverUrl}/api/agent/cookies`, {
100
+ method: "GET",
101
+ headers: this.authHeaders(),
102
+ });
103
+ if (res.status === 404)
104
+ return null;
105
+ if (!res.ok) {
106
+ log.warn(`Get cookies failed: HTTP ${res.status}`);
107
+ return null;
108
+ }
109
+ const data = (await res.json());
110
+ return data.cookies ?? null;
111
+ }
112
+ catch (err) {
113
+ log.warn(`Get cookies error: ${err.message}`);
114
+ return null;
115
+ }
116
+ }
117
+ async uploadCookies(encrypted) {
118
+ try {
119
+ const res = await fetch(`${this.serverUrl}/api/agent/cookies`, {
120
+ method: "POST",
121
+ headers: this.authHeaders(),
122
+ body: JSON.stringify({ cookies: encrypted }),
123
+ });
124
+ if (!res.ok) {
125
+ log.warn(`Upload cookies failed: HTTP ${res.status}`);
126
+ return false;
127
+ }
128
+ return true;
129
+ }
130
+ catch (err) {
131
+ log.warn(`Upload cookies error: ${err.message}`);
132
+ return false;
133
+ }
134
+ }
135
+ async submitScrapedPosts(posts, campaignId) {
136
+ try {
137
+ const res = await fetch(`${this.serverUrl}/api/agent/scraped-posts`, {
138
+ method: "POST",
139
+ headers: this.authHeaders(),
140
+ body: JSON.stringify({ posts, campaignId }),
141
+ });
142
+ if (!res.ok) {
143
+ log.warn(`Submit scraped posts failed: HTTP ${res.status}`);
144
+ return false;
145
+ }
146
+ return true;
147
+ }
148
+ catch (err) {
149
+ log.warn(`Submit scraped posts error: ${err.message}`);
150
+ return false;
151
+ }
152
+ }
153
+ }
package/dist/config.js ADDED
@@ -0,0 +1,39 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ const CONFIG_DIR = join(homedir(), ".bluehans-agent");
5
+ const CONFIG_FILE = join(CONFIG_DIR, "config.json");
6
+ export function getConfigDir() {
7
+ return CONFIG_DIR;
8
+ }
9
+ export function getConfigPath() {
10
+ return CONFIG_FILE;
11
+ }
12
+ export function getBrowserProfileDir() {
13
+ return join(CONFIG_DIR, "browser-profile");
14
+ }
15
+ export function loadConfig() {
16
+ if (!existsSync(CONFIG_FILE))
17
+ return null;
18
+ try {
19
+ const raw = readFileSync(CONFIG_FILE, "utf-8");
20
+ return JSON.parse(raw);
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ export function saveConfig(config) {
27
+ if (!existsSync(CONFIG_DIR)) {
28
+ mkdirSync(CONFIG_DIR, { recursive: true });
29
+ }
30
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
31
+ }
32
+ export function clearConfig() {
33
+ if (existsSync(CONFIG_FILE)) {
34
+ unlinkSync(CONFIG_FILE);
35
+ }
36
+ }
37
+ export function detectOS() {
38
+ return process.platform === "darwin" ? "mac" : "windows";
39
+ }
@@ -0,0 +1,104 @@
1
+ import { createCipheriv, createDecipheriv, randomBytes, createHash, } from "node:crypto";
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { getConfigDir } from "./config.js";
5
+ import { log } from "./logger.js";
6
+ const ALGORITHM = "aes-256-gcm";
7
+ const COOKIE_FILE = "cookies.enc";
8
+ function deriveKey(secret) {
9
+ return createHash("sha256").update(secret).digest();
10
+ }
11
+ function encrypt(cookies, secret) {
12
+ const key = deriveKey(secret);
13
+ const iv = randomBytes(12);
14
+ const cipher = createCipheriv(ALGORITHM, key, iv);
15
+ const plaintext = JSON.stringify(cookies);
16
+ const encrypted = Buffer.concat([
17
+ cipher.update(plaintext, "utf8"),
18
+ cipher.final(),
19
+ ]);
20
+ const tag = cipher.getAuthTag();
21
+ return [
22
+ iv.toString("base64"),
23
+ tag.toString("base64"),
24
+ encrypted.toString("base64"),
25
+ ].join(":");
26
+ }
27
+ function decrypt(data, secret) {
28
+ const key = deriveKey(secret);
29
+ const [ivB64, tagB64, dataB64] = data.split(":");
30
+ if (!ivB64 || !tagB64 || !dataB64)
31
+ throw new Error("Invalid cookie data");
32
+ const iv = Buffer.from(ivB64, "base64");
33
+ const tag = Buffer.from(tagB64, "base64");
34
+ const ciphertext = Buffer.from(dataB64, "base64");
35
+ const decipher = createDecipheriv(ALGORITHM, key, iv);
36
+ decipher.setAuthTag(tag);
37
+ const decrypted = Buffer.concat([
38
+ decipher.update(ciphertext),
39
+ decipher.final(),
40
+ ]);
41
+ return JSON.parse(decrypted.toString("utf8"));
42
+ }
43
+ export class CookieManager {
44
+ encryptionKey;
45
+ filePath;
46
+ constructor(encryptionKey) {
47
+ this.encryptionKey = encryptionKey;
48
+ const dir = getConfigDir();
49
+ if (!existsSync(dir))
50
+ mkdirSync(dir, { recursive: true });
51
+ this.filePath = join(dir, COOKIE_FILE);
52
+ }
53
+ loadLocal() {
54
+ if (!existsSync(this.filePath))
55
+ return null;
56
+ try {
57
+ const raw = readFileSync(this.filePath, "utf-8");
58
+ return decrypt(raw, this.encryptionKey);
59
+ }
60
+ catch (err) {
61
+ log.warn(`Failed to load local cookies: ${err.message}`);
62
+ return null;
63
+ }
64
+ }
65
+ saveLocal(cookies) {
66
+ const encrypted = encrypt(cookies, this.encryptionKey);
67
+ writeFileSync(this.filePath, encrypted, "utf-8");
68
+ }
69
+ async fetchFromServer(apiClient) {
70
+ const encrypted = await apiClient.getCookies();
71
+ if (!encrypted)
72
+ return null;
73
+ try {
74
+ return decrypt(encrypted, this.encryptionKey);
75
+ }
76
+ catch (err) {
77
+ log.warn(`Failed to decrypt server cookies: ${err.message}`);
78
+ return null;
79
+ }
80
+ }
81
+ async uploadToServer(apiClient, cookies) {
82
+ const encrypted = encrypt(cookies, this.encryptionKey);
83
+ await apiClient.uploadCookies(encrypted);
84
+ }
85
+ async getCookies(apiClient) {
86
+ const local = this.loadLocal();
87
+ if (local && local.length > 0) {
88
+ log.info("Using locally cached cookies.");
89
+ return local;
90
+ }
91
+ log.info("No local cookies, fetching from server...");
92
+ const remote = await this.fetchFromServer(apiClient);
93
+ if (remote && remote.length > 0) {
94
+ this.saveLocal(remote);
95
+ log.info("Cookies fetched from server and cached locally.");
96
+ return remote;
97
+ }
98
+ return null;
99
+ }
100
+ async syncAfterTask(apiClient, cookies) {
101
+ this.saveLocal(cookies);
102
+ await this.uploadToServer(apiClient, cookies);
103
+ }
104
+ }
@@ -0,0 +1,89 @@
1
+ import { chromium } from "playwright";
2
+ import { getBrowserProfileDir } from "../config.js";
3
+ import { log } from "../logger.js";
4
+ export class BrowserManager {
5
+ browser = null;
6
+ context = null;
7
+ async launchForTask(cookies) {
8
+ log.info("Launching headless Chromium for task...");
9
+ this.browser = await chromium.launch({
10
+ headless: true,
11
+ args: [
12
+ "--disable-blink-features=AutomationControlled",
13
+ "--no-sandbox",
14
+ ],
15
+ });
16
+ this.context = await this.browser.newContext({
17
+ viewport: { width: 1280, height: 900 },
18
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
19
+ });
20
+ if (cookies.length > 0) {
21
+ await this.context.addCookies(cookies);
22
+ log.info(`Injected ${cookies.length} cookies.`);
23
+ }
24
+ const page = await this.context.newPage();
25
+ log.success("Headless browser ready.");
26
+ return page;
27
+ }
28
+ async closeTask() {
29
+ let cookies = [];
30
+ if (this.context) {
31
+ const raw = await this.context.cookies();
32
+ cookies = raw.map((c) => ({
33
+ name: c.name,
34
+ value: c.value,
35
+ domain: c.domain,
36
+ path: c.path,
37
+ expires: c.expires,
38
+ httpOnly: c.httpOnly,
39
+ secure: c.secure,
40
+ sameSite: c.sameSite,
41
+ }));
42
+ await this.context.close();
43
+ this.context = null;
44
+ }
45
+ if (this.browser) {
46
+ await this.browser.close();
47
+ this.browser = null;
48
+ }
49
+ log.info("Browser closed, cookies extracted.");
50
+ return cookies;
51
+ }
52
+ async launchHeaded() {
53
+ const userDataDir = getBrowserProfileDir();
54
+ log.info("Launching headed Chromium for login...");
55
+ log.info(`Browser profile: ${userDataDir}`);
56
+ const context = await chromium.launchPersistentContext(userDataDir, {
57
+ headless: false,
58
+ viewport: { width: 1280, height: 900 },
59
+ args: [
60
+ "--disable-blink-features=AutomationControlled",
61
+ "--no-sandbox",
62
+ ],
63
+ });
64
+ const pages = context.pages();
65
+ const page = pages.length > 0 ? pages[0] : await context.newPage();
66
+ log.success("Headed browser launched.");
67
+ return { page, context };
68
+ }
69
+ async getPage() {
70
+ if (!this.context) {
71
+ throw new Error("No browser context — call launchForTask() first");
72
+ }
73
+ const pages = this.context.pages();
74
+ return pages.length > 0 ? pages[0] : await this.context.newPage();
75
+ }
76
+ async close() {
77
+ if (this.context) {
78
+ await this.context.close();
79
+ this.context = null;
80
+ }
81
+ if (this.browser) {
82
+ await this.browser.close();
83
+ this.browser = null;
84
+ }
85
+ }
86
+ isRunning() {
87
+ return this.context !== null;
88
+ }
89
+ }
@@ -0,0 +1,49 @@
1
+ import { log } from "../logger.js";
2
+ export async function executeComment(payload, page) {
3
+ const { postUrl, comment } = payload;
4
+ if (!postUrl || !comment) {
5
+ return { success: false, error: "Missing postUrl or comment in payload" };
6
+ }
7
+ log.job("comment", `Navigating to post: ${postUrl}`);
8
+ await page.goto(postUrl, { waitUntil: "domcontentloaded" });
9
+ await page.waitForTimeout(3000 + Math.random() * 2000);
10
+ const commentButton = page.locator('button:has-text("Comment"), button[aria-label*="Comment" i], .comment-button').first();
11
+ const commentBtnVisible = await commentButton.isVisible().catch(() => false);
12
+ if (commentBtnVisible) {
13
+ await commentButton.click();
14
+ await page.waitForTimeout(1500);
15
+ }
16
+ const commentBox = page.locator('.ql-editor[data-placeholder*="Add a comment"], .comments-comment-box__form .ql-editor, div[role="textbox"][contenteditable="true"]').first();
17
+ const boxVisible = await commentBox.isVisible().catch(() => false);
18
+ if (!boxVisible) {
19
+ return {
20
+ success: false,
21
+ error: "Comment box not found — post may require different interaction",
22
+ };
23
+ }
24
+ await commentBox.click();
25
+ await page.waitForTimeout(500);
26
+ await commentBox.fill(comment);
27
+ await page.waitForTimeout(1000 + Math.random() * 1000);
28
+ const submitButton = page.locator('button.comments-comment-box__submit-button:not([disabled]), button.comments-comment-box-comment__submit-button:not([disabled]), button:has-text("Comment"):not([disabled])').first();
29
+ const submitVisible = await submitButton.isVisible().catch(() => false);
30
+ if (!submitVisible) {
31
+ return { success: false, error: "Post/Submit button not found or disabled" };
32
+ }
33
+ await submitButton.scrollIntoViewIfNeeded();
34
+ await page.waitForTimeout(500);
35
+ await submitButton.click();
36
+ await page.waitForTimeout(3000);
37
+ const stillVisible = await commentBox.isVisible().catch(() => false);
38
+ const stillHasText = stillVisible
39
+ ? await commentBox.evaluate((el) => el.innerText.trim().length > 0)
40
+ : false;
41
+ if (stillHasText) {
42
+ return {
43
+ success: false,
44
+ error: "Comment box still has text after submit — button click may have failed",
45
+ };
46
+ }
47
+ log.success(`Comment posted on ${postUrl}`);
48
+ return { success: true };
49
+ }
@@ -0,0 +1,58 @@
1
+ import { log } from "../logger.js";
2
+ export async function executeConnect(payload, page) {
3
+ const { profileUrl, note } = payload;
4
+ if (!profileUrl) {
5
+ return { success: false, error: "Missing profileUrl in payload" };
6
+ }
7
+ log.job("connect", `Navigating to ${profileUrl}`);
8
+ await page.goto(profileUrl, { waitUntil: "domcontentloaded" });
9
+ await page.waitForTimeout(2000 + Math.random() * 2000);
10
+ // Look for the Connect button — LinkedIn uses several variations
11
+ const connectButton = page.locator('button:has-text("Connect"), button[aria-label*="connect" i]').first();
12
+ const isVisible = await connectButton.isVisible().catch(() => false);
13
+ if (!isVisible) {
14
+ // Try the "More" dropdown which sometimes hides Connect
15
+ const moreButton = page.locator('button:has-text("More"), button[aria-label*="more actions" i]').first();
16
+ if (await moreButton.isVisible().catch(() => false)) {
17
+ await moreButton.click();
18
+ await page.waitForTimeout(1000);
19
+ }
20
+ const dropdownConnect = page.locator('div[role="listbox"] span:has-text("Connect"), li:has-text("Connect")').first();
21
+ if (await dropdownConnect.isVisible().catch(() => false)) {
22
+ await dropdownConnect.click();
23
+ await page.waitForTimeout(1000);
24
+ }
25
+ else {
26
+ return {
27
+ success: false,
28
+ error: "Connect button not found on profile page",
29
+ };
30
+ }
31
+ }
32
+ else {
33
+ await connectButton.click();
34
+ await page.waitForTimeout(1000);
35
+ }
36
+ // Handle the "Add a note" modal if a note is provided
37
+ if (note) {
38
+ const addNoteButton = page.locator('button:has-text("Add a note")').first();
39
+ if (await addNoteButton.isVisible().catch(() => false)) {
40
+ await addNoteButton.click();
41
+ await page.waitForTimeout(500);
42
+ const textarea = page.locator('textarea[name="message"], textarea#custom-message').first();
43
+ if (await textarea.isVisible().catch(() => false)) {
44
+ await textarea.fill(note);
45
+ await page.waitForTimeout(500);
46
+ }
47
+ }
48
+ }
49
+ // Click Send / Send invitation
50
+ const sendButton = page.locator('button:has-text("Send"), button[aria-label*="Send" i]').first();
51
+ if (await sendButton.isVisible().catch(() => false)) {
52
+ await sendButton.click();
53
+ await page.waitForTimeout(2000);
54
+ log.success(`Connection request sent to ${profileUrl}`);
55
+ return { success: true };
56
+ }
57
+ return { success: false, error: "Could not click Send button" };
58
+ }
@@ -0,0 +1,25 @@
1
+ import { executeConnect } from "./connect.js";
2
+ import { executeMessage } from "./message.js";
3
+ import { executeScrape } from "./scrape.js";
4
+ import { executeScrapePostsJob } from "./scrape-posts.js";
5
+ import { executeComment } from "./comment.js";
6
+ import { log } from "../logger.js";
7
+ export async function executeJob(job, browser, apiClient, workspaceId) {
8
+ const page = await browser.getPage();
9
+ log.info(`Executing job ${job.id} (type: ${job.type})`);
10
+ switch (job.type) {
11
+ case "connect":
12
+ return executeConnect(job.payload, page);
13
+ case "message":
14
+ return executeMessage(job.payload, page);
15
+ case "scrape_connections":
16
+ return executeScrape(job.payload, page, apiClient, workspaceId);
17
+ case "scrape_posts":
18
+ return executeScrapePostsJob(job.payload, page, apiClient, workspaceId);
19
+ case "comment":
20
+ return executeComment(job.payload, page);
21
+ default:
22
+ log.warn(`Unknown job type: ${job.type}`);
23
+ return { success: false, error: `Unknown job type: ${job.type}` };
24
+ }
25
+ }
@@ -0,0 +1,40 @@
1
+ import { log } from "../logger.js";
2
+ export async function executeMessage(payload, page) {
3
+ const { profileUrl, message } = payload;
4
+ if (!profileUrl || !message) {
5
+ return { success: false, error: "Missing profileUrl or message in payload" };
6
+ }
7
+ log.job("message", `Navigating to ${profileUrl}`);
8
+ await page.goto(profileUrl, { waitUntil: "domcontentloaded" });
9
+ await page.waitForTimeout(2000 + Math.random() * 2000);
10
+ // Click the Message button on the profile
11
+ const messageButton = page.locator('button:has-text("Message"), a:has-text("Message")').first();
12
+ if (!(await messageButton.isVisible().catch(() => false))) {
13
+ return { success: false, error: "Message button not found on profile page" };
14
+ }
15
+ await messageButton.click();
16
+ await page.waitForTimeout(2000);
17
+ // Wait for the messaging overlay / compose area
18
+ const messageBox = page.locator('div[role="textbox"][contenteditable="true"], div.msg-form__contenteditable').first();
19
+ const boxVisible = await messageBox
20
+ .waitFor({ state: "visible", timeout: 10000 })
21
+ .then(() => true)
22
+ .catch(() => false);
23
+ if (!boxVisible) {
24
+ return { success: false, error: "Message compose box not found" };
25
+ }
26
+ // Type the message with human-like delay
27
+ await messageBox.click();
28
+ await page.waitForTimeout(300);
29
+ await messageBox.fill(message);
30
+ await page.waitForTimeout(1000 + Math.random() * 1000);
31
+ // Click Send
32
+ const sendButton = page.locator('button:has-text("Send"), button[type="submit"].msg-form__send-button').first();
33
+ if (!(await sendButton.isVisible().catch(() => false))) {
34
+ return { success: false, error: "Send button not found in message overlay" };
35
+ }
36
+ await sendButton.click();
37
+ await page.waitForTimeout(2000);
38
+ log.success(`Message sent to ${profileUrl}`);
39
+ return { success: true };
40
+ }
@@ -0,0 +1,78 @@
1
+ import { log } from "../logger.js";
2
+ export async function executeScrapePostsJob(payload, page, apiClient, workspaceId) {
3
+ const { prospects, limit = 10, campaignId } = payload;
4
+ if (!prospects || prospects.length === 0) {
5
+ return { success: false, error: "No prospects in payload" };
6
+ }
7
+ const targets = prospects.slice(0, limit);
8
+ const scrapedPosts = [];
9
+ for (let i = 0; i < targets.length; i++) {
10
+ const prospect = targets[i];
11
+ const activityUrl = `${prospect.profileUrl.replace(/\/$/, "")}/recent-activity/all/`;
12
+ log.job("scrape_posts", `[${i + 1}/${targets.length}] Visiting ${activityUrl}`);
13
+ try {
14
+ await page.goto(activityUrl, { waitUntil: "domcontentloaded" });
15
+ await page.waitForTimeout(3000 + Math.random() * 2000);
16
+ const postData = await page.evaluate(() => {
17
+ const feedItems = document.querySelectorAll(".feed-shared-update-v2, [data-urn], .occludable-update");
18
+ for (const item of feedItems) {
19
+ const reshareIndicator = item.querySelector(".update-components-mini-update-v2, .feed-shared-reshared-update-v2");
20
+ if (reshareIndicator)
21
+ continue;
22
+ const contentEl = item.querySelector(".feed-shared-update-v2__description, .feed-shared-text, .update-components-text, [dir='ltr']");
23
+ const content = contentEl?.textContent?.trim() || "";
24
+ if (content.length < 10)
25
+ continue;
26
+ let postUrl = "";
27
+ const urn = item.getAttribute("data-urn");
28
+ if (urn) {
29
+ const activityId = urn.split(":").pop();
30
+ postUrl = `https://www.linkedin.com/feed/update/urn:li:activity:${activityId}/`;
31
+ }
32
+ if (!postUrl) {
33
+ const permalink = item.querySelector('a[href*="/feed/update/"], a[href*="activity"]');
34
+ if (permalink)
35
+ postUrl = permalink.href.split("?")[0];
36
+ }
37
+ const authorEl = item.querySelector(".update-components-actor__name span[aria-hidden='true'], .feed-shared-actor__name span");
38
+ const postAuthor = authorEl?.textContent?.trim() || "";
39
+ const timeEl = item.querySelector(".update-components-actor__sub-description span[aria-hidden='true'], time");
40
+ const postTimestamp = timeEl?.textContent?.trim() || "";
41
+ return {
42
+ postContent: content.slice(0, 2000),
43
+ postUrl: postUrl || window.location.href,
44
+ postAuthor,
45
+ postTimestamp,
46
+ };
47
+ }
48
+ return null;
49
+ });
50
+ if (postData && postData.postContent) {
51
+ scrapedPosts.push({
52
+ prospectId: prospect.id,
53
+ ...postData,
54
+ });
55
+ log.job("scrape_posts", `Found post for ${prospect.id} (${postData.postContent.slice(0, 80)}...)`);
56
+ }
57
+ else {
58
+ log.warn(`No post found for ${prospect.id}`);
59
+ }
60
+ }
61
+ catch (err) {
62
+ log.warn(`Error scraping ${prospect.id}: ${err.message}`);
63
+ }
64
+ if (i < targets.length - 1) {
65
+ const delay = 3000 + Math.random() * 2000;
66
+ await page.waitForTimeout(delay);
67
+ }
68
+ }
69
+ if (scrapedPosts.length === 0) {
70
+ return { success: false, error: "No posts found for any prospect" };
71
+ }
72
+ const submitted = await apiClient.submitScrapedPosts(scrapedPosts, campaignId);
73
+ if (!submitted) {
74
+ return { success: false, error: "Failed to submit scraped posts to server" };
75
+ }
76
+ log.success(`Scraped ${scrapedPosts.length} posts from ${targets.length} prospects`);
77
+ return { success: true, count: scrapedPosts.length };
78
+ }
@@ -0,0 +1,138 @@
1
+ import { log } from "../logger.js";
2
+ export async function executeScrape(payload, page, apiClient, workspaceId) {
3
+ const connectionsUrl = "https://www.linkedin.com/mynetwork/invite-connect/connections/";
4
+ log.job("scrape", "Navigating to LinkedIn connections page...");
5
+ await page.goto(connectionsUrl, { waitUntil: "domcontentloaded" });
6
+ await page.waitForTimeout(3000);
7
+ // Scroll to load all connections
8
+ log.job("scrape", "Scrolling to load connections...");
9
+ await scrollToLoadAll(page, 30);
10
+ // Extract profiles
11
+ log.job("scrape", "Extracting profiles...");
12
+ const profiles = await page.evaluate(() => {
13
+ const results = [];
14
+ const seen = new Set();
15
+ const anchors = document.querySelectorAll("a");
16
+ for (const anchor of anchors) {
17
+ const href = anchor.getAttribute("href") || "";
18
+ const match = href.match(/\/in\/([a-zA-Z0-9-]+)/);
19
+ if (!match)
20
+ continue;
21
+ const fullUrl = anchor.href.split("?")[0].replace(/\/$/, "");
22
+ if (seen.has(fullUrl))
23
+ continue;
24
+ if (href.includes("/overlay/") || href.includes("/detail/"))
25
+ continue;
26
+ const container = anchor.closest("li") ||
27
+ anchor.closest("[data-view-name]") ||
28
+ anchor.closest("div.entity-result") ||
29
+ anchor.parentElement?.parentElement;
30
+ if (!container)
31
+ continue;
32
+ let fullName = "";
33
+ const ariaLabel = anchor.getAttribute("aria-label");
34
+ if (ariaLabel) {
35
+ fullName = ariaLabel;
36
+ }
37
+ if (!fullName) {
38
+ const hiddenSpan = anchor.querySelector("span[aria-hidden='true']") ||
39
+ container.querySelector("span[aria-hidden='true']");
40
+ if (hiddenSpan)
41
+ fullName = hiddenSpan.textContent?.trim() || "";
42
+ }
43
+ if (!fullName) {
44
+ const text = anchor.textContent?.trim() || "";
45
+ if (text.length > 0 && text.length < 60)
46
+ fullName = text;
47
+ }
48
+ if (!fullName || fullName.length < 2)
49
+ continue;
50
+ fullName = fullName
51
+ .replace(/^View\s+/i, "")
52
+ .replace(/('s|'s)\s+profile$/i, "")
53
+ .trim();
54
+ let title = "";
55
+ const candidates = container.querySelectorAll("span, p, div");
56
+ for (const el of candidates) {
57
+ const t = el.textContent?.trim() || "";
58
+ if (t.length > 10 &&
59
+ t.length < 120 &&
60
+ t !== fullName &&
61
+ !t.includes("Connect") &&
62
+ !t.includes("Message") &&
63
+ !t.includes("ago")) {
64
+ title = t;
65
+ break;
66
+ }
67
+ }
68
+ const img = container.querySelector("img");
69
+ const avatarUrl = img?.src || "";
70
+ let connectedAt = "";
71
+ const timeEl = container.querySelector("time");
72
+ if (timeEl) {
73
+ connectedAt = timeEl.textContent?.trim() || "";
74
+ }
75
+ else {
76
+ const spans = container.querySelectorAll("span, time");
77
+ for (const el of spans) {
78
+ const t = el.textContent?.trim() || "";
79
+ if (/ago|day|week|month|year/i.test(t)) {
80
+ connectedAt = t;
81
+ break;
82
+ }
83
+ }
84
+ }
85
+ results.push({ fullName, url: fullUrl, title, avatarUrl, connectedAt });
86
+ seen.add(fullUrl);
87
+ }
88
+ return results;
89
+ });
90
+ log.job("scrape", `Extracted ${profiles.length} profiles`);
91
+ if (profiles.length === 0) {
92
+ return {
93
+ success: false,
94
+ error: "No connections found. You may need to log in to LinkedIn first.",
95
+ };
96
+ }
97
+ // Upload in batches
98
+ const batchSize = 100;
99
+ let totalSent = 0;
100
+ for (let i = 0; i < profiles.length; i += batchSize) {
101
+ const batch = profiles.slice(i, i + batchSize);
102
+ const ok = await apiClient.syncConnections(workspaceId, batch);
103
+ if (ok) {
104
+ totalSent += batch.length;
105
+ log.job("scrape", `Uploaded ${totalSent}/${profiles.length}`);
106
+ }
107
+ else {
108
+ log.warn(`Batch upload failed at offset ${i}`);
109
+ }
110
+ if (i + batchSize < profiles.length) {
111
+ await page.waitForTimeout(500);
112
+ }
113
+ }
114
+ log.success(`Scrape complete: ${profiles.length} connections synced`);
115
+ return { success: true, count: profiles.length };
116
+ }
117
+ async function scrollToLoadAll(page, maxScrolls) {
118
+ let lastHeight = 0;
119
+ let noChangeCount = 0;
120
+ for (let i = 0; i < maxScrolls; i++) {
121
+ const currentHeight = await page.evaluate(() => document.body.scrollHeight);
122
+ await page.evaluate(() => window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" }));
123
+ const wait = 2000 + Math.random() * 2000;
124
+ await page.waitForTimeout(wait);
125
+ if (currentHeight === lastHeight) {
126
+ noChangeCount++;
127
+ if (noChangeCount >= 3)
128
+ break;
129
+ }
130
+ else {
131
+ noChangeCount = 0;
132
+ }
133
+ lastHeight = currentHeight;
134
+ if ((i + 1) % 5 === 0) {
135
+ log.job("scrape", `Scrolled ${i + 1}/${maxScrolls}...`);
136
+ }
137
+ }
138
+ }
package/dist/index.js ADDED
@@ -0,0 +1,158 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { hostname } from "node:os";
4
+ import { loadConfig, saveConfig, clearConfig, getConfigPath, detectOS, } from "./config.js";
5
+ import { ApiClient } from "./api-client.js";
6
+ import { Agent } from "./agent.js";
7
+ import { runLogin } from "./login.js";
8
+ import { log } from "./logger.js";
9
+ const VERSION = "1.0.0";
10
+ const DEFAULT_SERVER = "http://localhost:3000";
11
+ const program = new Command();
12
+ program
13
+ .name("prospect-ai-agent")
14
+ .description("ProspectAI Desktop Agent — headless browser automation for campaigns")
15
+ .version(VERSION);
16
+ // ── pair ─────────────────────────────────────────────────────
17
+ program
18
+ .command("pair")
19
+ .description("Pair this device with the CRM using a 6-digit code")
20
+ .argument("<code>", "6-digit pairing code from CRM Settings")
21
+ .option("-n, --name <name>", "Device name", hostname())
22
+ .option("-s, --server <url>", "CRM server URL", DEFAULT_SERVER)
23
+ .action(async (code, opts) => {
24
+ const existing = loadConfig();
25
+ if (existing) {
26
+ log.warn("This device is already paired.");
27
+ log.info(`Device: ${existing.deviceName}`);
28
+ log.info(`Workspace: ${existing.workspaceId}`);
29
+ log.info('Run "unpair" first to re-pair with a different workspace.');
30
+ process.exit(1);
31
+ }
32
+ const osType = detectOS();
33
+ log.info(`Pairing with code ${code}...`);
34
+ log.info(`Server: ${opts.server}`);
35
+ log.info(`Device: ${opts.name} (${osType})`);
36
+ const client = new ApiClient(opts.server);
37
+ try {
38
+ const result = await client.pair(code, opts.name, osType, VERSION);
39
+ saveConfig({
40
+ serverUrl: opts.server,
41
+ token: result.token,
42
+ deviceId: result.deviceId,
43
+ workspaceId: result.workspaceId,
44
+ deviceName: opts.name,
45
+ osType,
46
+ pairedAt: new Date().toISOString(),
47
+ });
48
+ log.success("Paired successfully!");
49
+ log.info(`Device ID: ${result.deviceId}`);
50
+ log.info(`Workspace: ${result.workspaceId}`);
51
+ log.info(`Config saved to: ${getConfigPath()}`);
52
+ log.info('\nRun "login" to authenticate with LinkedIn, then "start".');
53
+ }
54
+ catch (err) {
55
+ log.error(`Pairing failed: ${err.message}`);
56
+ process.exit(1);
57
+ }
58
+ });
59
+ // ── login ────────────────────────────────────────────────────
60
+ program
61
+ .command("login")
62
+ .description("Log in to LinkedIn (opens headed browser, captures cookies for headless use)")
63
+ .action(async () => {
64
+ const config = loadConfig();
65
+ if (!config) {
66
+ log.error("Not paired. Run 'pair <CODE>' first.");
67
+ process.exit(1);
68
+ }
69
+ await runLogin(config.serverUrl, config.token);
70
+ });
71
+ // ── start ────────────────────────────────────────────────────
72
+ program
73
+ .command("start")
74
+ .description("Start the agent daemon (heartbeat + job polling, headless)")
75
+ .option("-i, --interval <minutes>", "Polling interval in minutes (1-30)", "5")
76
+ .action(async (opts) => {
77
+ const config = loadConfig();
78
+ if (!config) {
79
+ log.error("Not paired. Run 'pair <CODE>' first.");
80
+ process.exit(1);
81
+ }
82
+ const intervalMin = Math.max(1, Math.min(30, parseInt(opts.interval, 10) || 5));
83
+ log.info("Starting ProspectAI Desktop Agent...");
84
+ log.info(`Device: ${config.deviceName} (${config.osType})`);
85
+ log.info(`Server: ${config.serverUrl}`);
86
+ log.info(`Workspace: ${config.workspaceId}`);
87
+ log.info(`Mode: headless (browser launches on-demand)`);
88
+ log.info(`Poll interval: ${intervalMin} min\n`);
89
+ const agent = new Agent({
90
+ serverUrl: config.serverUrl,
91
+ token: config.token,
92
+ workspaceId: config.workspaceId,
93
+ pollIntervalMin: intervalMin,
94
+ });
95
+ const shutdown = async () => {
96
+ await agent.stop();
97
+ process.exit(0);
98
+ };
99
+ process.on("SIGINT", shutdown);
100
+ process.on("SIGTERM", shutdown);
101
+ await agent.start();
102
+ });
103
+ // ── run ──────────────────────────────────────────────────────
104
+ program
105
+ .command("run")
106
+ .description("Execute pending jobs immediately (one-shot, then exit)")
107
+ .action(async () => {
108
+ const config = loadConfig();
109
+ if (!config) {
110
+ log.error("Not paired. Run 'pair <CODE>' first.");
111
+ process.exit(1);
112
+ }
113
+ log.info("ProspectAI Agent — Run Now");
114
+ log.info(`Server: ${config.serverUrl}`);
115
+ log.info(`Workspace: ${config.workspaceId}\n`);
116
+ const agent = new Agent({
117
+ serverUrl: config.serverUrl,
118
+ token: config.token,
119
+ workspaceId: config.workspaceId,
120
+ });
121
+ await agent.runOnce();
122
+ process.exit(0);
123
+ });
124
+ // ── status ───────────────────────────────────────────────────
125
+ program
126
+ .command("status")
127
+ .description("Show current agent configuration")
128
+ .action(() => {
129
+ const config = loadConfig();
130
+ if (!config) {
131
+ log.info("Not paired. No configuration found.");
132
+ log.info(`Config path: ${getConfigPath()}`);
133
+ process.exit(0);
134
+ }
135
+ console.log("\n ProspectAI Desktop Agent Status\n");
136
+ console.log(` Device Name: ${config.deviceName}`);
137
+ console.log(` OS: ${config.osType === "mac" ? "macOS" : "Windows"}`);
138
+ console.log(` Device ID: ${config.deviceId}`);
139
+ console.log(` Workspace: ${config.workspaceId}`);
140
+ console.log(` Server: ${config.serverUrl}`);
141
+ console.log(` Paired at: ${config.pairedAt}`);
142
+ console.log(` Config file: ${getConfigPath()}\n`);
143
+ });
144
+ // ── unpair ───────────────────────────────────────────────────
145
+ program
146
+ .command("unpair")
147
+ .description("Remove local configuration and unpair this device")
148
+ .action(() => {
149
+ const config = loadConfig();
150
+ if (!config) {
151
+ log.info("Not paired. Nothing to remove.");
152
+ process.exit(0);
153
+ }
154
+ clearConfig();
155
+ log.success(`Unpaired device "${config.deviceName}".`);
156
+ log.info("Note: The device still appears in CRM Settings until revoked there.");
157
+ });
158
+ program.parse();
package/dist/logger.js ADDED
@@ -0,0 +1,26 @@
1
+ const RESET = "\x1b[0m";
2
+ const BLUE = "\x1b[34m";
3
+ const GREEN = "\x1b[32m";
4
+ const YELLOW = "\x1b[33m";
5
+ const RED = "\x1b[31m";
6
+ const DIM = "\x1b[2m";
7
+ function timestamp() {
8
+ return new Date().toLocaleTimeString("en-US", { hour12: false });
9
+ }
10
+ export const log = {
11
+ info(msg) {
12
+ console.log(`${DIM}${timestamp()}${RESET} ${BLUE}[bluehans]${RESET} ${msg}`);
13
+ },
14
+ success(msg) {
15
+ console.log(`${DIM}${timestamp()}${RESET} ${GREEN}[bluehans]${RESET} ${msg}`);
16
+ },
17
+ warn(msg) {
18
+ console.log(`${DIM}${timestamp()}${RESET} ${YELLOW}[bluehans]${RESET} ${msg}`);
19
+ },
20
+ error(msg) {
21
+ console.error(`${DIM}${timestamp()}${RESET} ${RED}[bluehans]${RESET} ${msg}`);
22
+ },
23
+ job(type, msg) {
24
+ console.log(`${DIM}${timestamp()}${RESET} ${BLUE}[job:${type}]${RESET} ${msg}`);
25
+ },
26
+ };
package/dist/login.js ADDED
@@ -0,0 +1,46 @@
1
+ import { BrowserManager } from "./executor/browser.js";
2
+ import { CookieManager } from "./cookie-manager.js";
3
+ import { ApiClient } from "./api-client.js";
4
+ import { log } from "./logger.js";
5
+ const LINKEDIN_LOGIN_URL = "https://www.linkedin.com/login";
6
+ const LINKEDIN_FEED_PATTERN = /linkedin\.com\/(feed|in\/|mynetwork|messaging)/;
7
+ export async function runLogin(serverUrl, token) {
8
+ const apiClient = new ApiClient(serverUrl, token);
9
+ const cookieManager = new CookieManager(token);
10
+ const browser = new BrowserManager();
11
+ log.info("Opening LinkedIn login page in headed browser...");
12
+ log.info("Please log in to your LinkedIn account.\n");
13
+ const { page, context } = await browser.launchHeaded();
14
+ await page.goto(LINKEDIN_LOGIN_URL, { waitUntil: "domcontentloaded" });
15
+ log.info("Waiting for you to complete login...");
16
+ log.info("(The browser will close automatically once logged in)\n");
17
+ try {
18
+ await page.waitForURL(LINKEDIN_FEED_PATTERN, { timeout: 300_000 });
19
+ }
20
+ catch {
21
+ log.error("Login timed out after 5 minutes. Please try again.");
22
+ await context.close();
23
+ return;
24
+ }
25
+ await page.waitForTimeout(2000);
26
+ const rawCookies = await context.cookies();
27
+ const linkedInCookies = rawCookies
28
+ .filter((c) => c.domain.includes("linkedin.com"))
29
+ .map((c) => ({
30
+ name: c.name,
31
+ value: c.value,
32
+ domain: c.domain,
33
+ path: c.path,
34
+ expires: c.expires,
35
+ httpOnly: c.httpOnly,
36
+ secure: c.secure,
37
+ sameSite: c.sameSite,
38
+ }));
39
+ log.info(`Captured ${linkedInCookies.length} LinkedIn cookies.`);
40
+ cookieManager.saveLocal(linkedInCookies);
41
+ log.success("Cookies saved locally.");
42
+ await cookieManager.uploadToServer(apiClient, linkedInCookies);
43
+ log.success("Cookies synced to server.");
44
+ await context.close();
45
+ log.success("LinkedIn login complete. You can now run 'start' in headless mode.");
46
+ }
package/package.json ADDED
@@ -0,0 +1,44 @@
1
+ {
2
+ "name": "prospect-ai-agent",
3
+ "version": "1.0.0",
4
+ "description": "ProspectAI Desktop Agent — browser automation for campaigns (no Chrome extension)",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "prospect-ai-agent": "dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "engines": {
14
+ "node": ">=18"
15
+ },
16
+ "scripts": {
17
+ "start": "tsx src/index.ts start",
18
+ "pair": "tsx src/index.ts pair",
19
+ "status": "tsx src/index.ts status",
20
+ "unpair": "tsx src/index.ts unpair",
21
+ "typecheck": "tsc --noEmit",
22
+ "build": "tsc -p tsconfig.build.json",
23
+ "build:binaries": "npm run build && node build-binaries.mjs",
24
+ "build:mac": "npm run build && npx pkg dist/index.js --target node18-macos-arm64 --output binaries/prospect-ai-agent-macos --compress GZip",
25
+ "build:win": "npm run build && npx pkg dist/index.js --target node18-win-x64 --output binaries/prospect-ai-agent-win.exe --compress GZip",
26
+ "prepublishOnly": "npm run build"
27
+ },
28
+ "pkg": {
29
+ "assets": [
30
+ "dist/**/*"
31
+ ],
32
+ "outputPath": "binaries"
33
+ },
34
+ "dependencies": {
35
+ "commander": "^13.1.0",
36
+ "playwright": "^1.52.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^20",
40
+ "@yao-pkg/pkg": "^6.14.1",
41
+ "tsx": "^4.21.0",
42
+ "typescript": "^5"
43
+ }
44
+ }