linkedin-automation-cli 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.
Files changed (314) hide show
  1. package/.env.example +12 -0
  2. package/.github/workflows/ci.yml +66 -0
  3. package/.github/workflows/publish.yml +48 -0
  4. package/.husky/pre-commit +6 -0
  5. package/.prettierignore +4 -0
  6. package/.prettierrc +10 -0
  7. package/AGENTS.md +294 -0
  8. package/CHANGELOG.md +40 -0
  9. package/GIT_RELEASE.md +167 -0
  10. package/LICENSE +21 -0
  11. package/Makefile +30 -0
  12. package/NPM_PUBLISHING.md +230 -0
  13. package/PYEOF +0 -0
  14. package/README.md +295 -0
  15. package/TESTING-GUIDE.md +151 -0
  16. package/cmd/linkedin/main.go +9 -0
  17. package/dist/agent/action-executor.d.ts +81 -0
  18. package/dist/agent/action-executor.d.ts.map +1 -0
  19. package/dist/agent/action-executor.js +170 -0
  20. package/dist/agent/action-executor.js.map +1 -0
  21. package/dist/agent/action-executor.test.d.ts +2 -0
  22. package/dist/agent/action-executor.test.d.ts.map +1 -0
  23. package/dist/agent/action-executor.test.js +366 -0
  24. package/dist/agent/action-executor.test.js.map +1 -0
  25. package/dist/agent/claude-client.d.ts +74 -0
  26. package/dist/agent/claude-client.d.ts.map +1 -0
  27. package/dist/agent/claude-client.js +314 -0
  28. package/dist/agent/claude-client.js.map +1 -0
  29. package/dist/agent/claude-client.test.d.ts +2 -0
  30. package/dist/agent/claude-client.test.d.ts.map +1 -0
  31. package/dist/agent/claude-client.test.js +590 -0
  32. package/dist/agent/claude-client.test.js.map +1 -0
  33. package/dist/agent/dom-extractor.d.ts +50 -0
  34. package/dist/agent/dom-extractor.d.ts.map +1 -0
  35. package/dist/agent/dom-extractor.js +374 -0
  36. package/dist/agent/dom-extractor.js.map +1 -0
  37. package/dist/agent/dom-extractor.test.d.ts +7 -0
  38. package/dist/agent/dom-extractor.test.d.ts.map +1 -0
  39. package/dist/agent/dom-extractor.test.js +504 -0
  40. package/dist/agent/dom-extractor.test.js.map +1 -0
  41. package/dist/agent/extension-client.d.ts +75 -0
  42. package/dist/agent/extension-client.d.ts.map +1 -0
  43. package/dist/agent/extension-client.js +245 -0
  44. package/dist/agent/extension-client.js.map +1 -0
  45. package/dist/agent/index.d.ts +8 -0
  46. package/dist/agent/index.d.ts.map +1 -0
  47. package/dist/agent/index.js +16 -0
  48. package/dist/agent/index.js.map +1 -0
  49. package/dist/agent/page-agent.d.ts +76 -0
  50. package/dist/agent/page-agent.d.ts.map +1 -0
  51. package/dist/agent/page-agent.js +236 -0
  52. package/dist/agent/page-agent.js.map +1 -0
  53. package/dist/agent/types.d.ts +236 -0
  54. package/dist/agent/types.d.ts.map +1 -0
  55. package/dist/agent/types.js +37 -0
  56. package/dist/agent/types.js.map +1 -0
  57. package/dist/cli/agent-commands.d.ts +3 -0
  58. package/dist/cli/agent-commands.d.ts.map +1 -0
  59. package/dist/cli/agent-commands.js +250 -0
  60. package/dist/cli/agent-commands.js.map +1 -0
  61. package/dist/cli/auth.d.ts +3 -0
  62. package/dist/cli/auth.d.ts.map +1 -0
  63. package/dist/cli/auth.js +288 -0
  64. package/dist/cli/auth.js.map +1 -0
  65. package/dist/cli/company.d.ts +3 -0
  66. package/dist/cli/company.d.ts.map +1 -0
  67. package/dist/cli/company.js +55 -0
  68. package/dist/cli/company.js.map +1 -0
  69. package/dist/cli/connection.d.ts +3 -0
  70. package/dist/cli/connection.d.ts.map +1 -0
  71. package/dist/cli/connection.js +79 -0
  72. package/dist/cli/connection.js.map +1 -0
  73. package/dist/cli/index.d.ts +7 -0
  74. package/dist/cli/index.d.ts.map +1 -0
  75. package/dist/cli/index.js +17 -0
  76. package/dist/cli/index.js.map +1 -0
  77. package/dist/cli/messages.d.ts +3 -0
  78. package/dist/cli/messages.d.ts.map +1 -0
  79. package/dist/cli/messages.js +268 -0
  80. package/dist/cli/messages.js.map +1 -0
  81. package/dist/cli/profile.d.ts +3 -0
  82. package/dist/cli/profile.d.ts.map +1 -0
  83. package/dist/cli/profile.js +81 -0
  84. package/dist/cli/profile.js.map +1 -0
  85. package/dist/cli/profile.test.d.ts +2 -0
  86. package/dist/cli/profile.test.d.ts.map +1 -0
  87. package/dist/cli/profile.test.js +15 -0
  88. package/dist/cli/profile.test.js.map +1 -0
  89. package/dist/cli/reply.d.ts +3 -0
  90. package/dist/cli/reply.d.ts.map +1 -0
  91. package/dist/cli/reply.js +129 -0
  92. package/dist/cli/reply.js.map +1 -0
  93. package/dist/core/audit.d.ts +17 -0
  94. package/dist/core/audit.d.ts.map +1 -0
  95. package/dist/core/audit.js +121 -0
  96. package/dist/core/audit.js.map +1 -0
  97. package/dist/core/audit.test.d.ts +2 -0
  98. package/dist/core/audit.test.d.ts.map +1 -0
  99. package/dist/core/audit.test.js +142 -0
  100. package/dist/core/audit.test.js.map +1 -0
  101. package/dist/core/browser-cookies.d.ts +19 -0
  102. package/dist/core/browser-cookies.d.ts.map +1 -0
  103. package/dist/core/browser-cookies.js +181 -0
  104. package/dist/core/browser-cookies.js.map +1 -0
  105. package/dist/core/browser.d.ts +50 -0
  106. package/dist/core/browser.d.ts.map +1 -0
  107. package/dist/core/browser.js +318 -0
  108. package/dist/core/browser.js.map +1 -0
  109. package/dist/core/config.d.ts +20 -0
  110. package/dist/core/config.d.ts.map +1 -0
  111. package/dist/core/config.js +103 -0
  112. package/dist/core/config.js.map +1 -0
  113. package/dist/core/config.test.d.ts +2 -0
  114. package/dist/core/config.test.d.ts.map +1 -0
  115. package/dist/core/config.test.js +111 -0
  116. package/dist/core/config.test.js.map +1 -0
  117. package/dist/core/storage.d.ts +19 -0
  118. package/dist/core/storage.d.ts.map +1 -0
  119. package/dist/core/storage.js +124 -0
  120. package/dist/core/storage.js.map +1 -0
  121. package/dist/core/storage.test.d.ts +2 -0
  122. package/dist/core/storage.test.d.ts.map +1 -0
  123. package/dist/core/storage.test.js +142 -0
  124. package/dist/core/storage.test.js.map +1 -0
  125. package/dist/index.d.ts +3 -0
  126. package/dist/index.d.ts.map +1 -0
  127. package/dist/index.js +63 -0
  128. package/dist/index.js.map +1 -0
  129. package/dist/linkedin/auth.d.ts +22 -0
  130. package/dist/linkedin/auth.d.ts.map +1 -0
  131. package/dist/linkedin/auth.js +167 -0
  132. package/dist/linkedin/auth.js.map +1 -0
  133. package/dist/linkedin/company-extractor.d.ts +36 -0
  134. package/dist/linkedin/company-extractor.d.ts.map +1 -0
  135. package/dist/linkedin/company-extractor.js +211 -0
  136. package/dist/linkedin/company-extractor.js.map +1 -0
  137. package/dist/linkedin/company-extractor.test.d.ts +2 -0
  138. package/dist/linkedin/company-extractor.test.d.ts.map +1 -0
  139. package/dist/linkedin/company-extractor.test.js +52 -0
  140. package/dist/linkedin/company-extractor.test.js.map +1 -0
  141. package/dist/linkedin/connector.d.ts +45 -0
  142. package/dist/linkedin/connector.d.ts.map +1 -0
  143. package/dist/linkedin/connector.js +245 -0
  144. package/dist/linkedin/connector.js.map +1 -0
  145. package/dist/linkedin/message-sender.d.ts +32 -0
  146. package/dist/linkedin/message-sender.d.ts.map +1 -0
  147. package/dist/linkedin/message-sender.js +112 -0
  148. package/dist/linkedin/message-sender.js.map +1 -0
  149. package/dist/linkedin/messages.d.ts +78 -0
  150. package/dist/linkedin/messages.d.ts.map +1 -0
  151. package/dist/linkedin/messages.js +745 -0
  152. package/dist/linkedin/messages.js.map +1 -0
  153. package/dist/linkedin/profile.d.ts +37 -0
  154. package/dist/linkedin/profile.d.ts.map +1 -0
  155. package/dist/linkedin/profile.js +268 -0
  156. package/dist/linkedin/profile.js.map +1 -0
  157. package/dist/linkedin/profile.test.d.ts +2 -0
  158. package/dist/linkedin/profile.test.d.ts.map +1 -0
  159. package/dist/linkedin/profile.test.js +68 -0
  160. package/dist/linkedin/profile.test.js.map +1 -0
  161. package/dist/linkedin/reply.d.ts +21 -0
  162. package/dist/linkedin/reply.d.ts.map +1 -0
  163. package/dist/linkedin/reply.js +76 -0
  164. package/dist/linkedin/reply.js.map +1 -0
  165. package/dist/linkedin/selector-engine.d.ts +69 -0
  166. package/dist/linkedin/selector-engine.d.ts.map +1 -0
  167. package/dist/linkedin/selector-engine.js +339 -0
  168. package/dist/linkedin/selector-engine.js.map +1 -0
  169. package/dist/linkedin/selector-engine.test.d.ts +2 -0
  170. package/dist/linkedin/selector-engine.test.d.ts.map +1 -0
  171. package/dist/linkedin/selector-engine.test.js +135 -0
  172. package/dist/linkedin/selector-engine.test.js.map +1 -0
  173. package/dist/linkedin/selectors.d.ts +65 -0
  174. package/dist/linkedin/selectors.d.ts.map +1 -0
  175. package/dist/linkedin/selectors.js +261 -0
  176. package/dist/linkedin/selectors.js.map +1 -0
  177. package/dist/templates/engine.d.ts +37 -0
  178. package/dist/templates/engine.d.ts.map +1 -0
  179. package/dist/templates/engine.js +215 -0
  180. package/dist/templates/engine.js.map +1 -0
  181. package/dist/templates/engine.test.d.ts +2 -0
  182. package/dist/templates/engine.test.d.ts.map +1 -0
  183. package/dist/templates/engine.test.js +212 -0
  184. package/dist/templates/engine.test.js.map +1 -0
  185. package/dist/templates/index.d.ts +2 -0
  186. package/dist/templates/index.d.ts.map +1 -0
  187. package/dist/templates/index.js +7 -0
  188. package/dist/templates/index.js.map +1 -0
  189. package/dist/types/index.d.ts +113 -0
  190. package/dist/types/index.d.ts.map +1 -0
  191. package/dist/types/index.js +3 -0
  192. package/dist/types/index.js.map +1 -0
  193. package/dist/types/index.test.d.ts +2 -0
  194. package/dist/types/index.test.d.ts.map +1 -0
  195. package/dist/types/index.test.js +90 -0
  196. package/dist/types/index.test.js.map +1 -0
  197. package/dist/utils/paths.d.ts +8 -0
  198. package/dist/utils/paths.d.ts.map +1 -0
  199. package/dist/utils/paths.js +68 -0
  200. package/dist/utils/paths.js.map +1 -0
  201. package/dist/utils/rate-limiter.d.ts +22 -0
  202. package/dist/utils/rate-limiter.d.ts.map +1 -0
  203. package/dist/utils/rate-limiter.js +57 -0
  204. package/dist/utils/rate-limiter.js.map +1 -0
  205. package/dist/utils/retry.d.ts +18 -0
  206. package/dist/utils/retry.d.ts.map +1 -0
  207. package/dist/utils/retry.js +49 -0
  208. package/dist/utils/retry.js.map +1 -0
  209. package/docs/connection-command.md +52 -0
  210. package/docs/plans/2025-03-03-linkedin-cli-design.md +280 -0
  211. package/docs/plans/2025-03-03-linkedin-cli-implementation-plan.md +2087 -0
  212. package/docs/plans/2025-03-03-linkedin-cli-implementation.md +2420 -0
  213. package/docs/plans/2026-02-19-linkedin-connection-feature.md +596 -0
  214. package/docs/plans/2026-02-28-messages-send-feature.md +480 -0
  215. package/docs/plans/2026-02-28-messages-show-design.md +243 -0
  216. package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-design.md +394 -0
  217. package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-plan.md +1592 -0
  218. package/docs/superpowers/plans/2026-03-13-linkedin-automation-resilience-migration.md +425 -0
  219. package/docs/superpowers/plans/2026-03-13-playwright-fara-migration.md +1112 -0
  220. package/docs/superpowers/plans/2026-03-14-page-agent-plan.md +1598 -0
  221. package/docs/superpowers/plans/2026-03-15-company-profile-extraction.md +591 -0
  222. package/docs/superpowers/plans/2026-03-15-profile-extraction-plan.md +943 -0
  223. package/docs/superpowers/specs/2026-03-14-company-profile-extraction-design.md +371 -0
  224. package/docs/superpowers/specs/2026-03-14-page-agent-design.md +385 -0
  225. package/docs/superpowers/specs/2026-03-15-profile-extraction-design.md +409 -0
  226. package/eslint.config.mjs +58 -0
  227. package/go.mod +9 -0
  228. package/go.sum +10 -0
  229. package/import-cookies.js +376 -0
  230. package/internal/cmd/actions.go +123 -0
  231. package/internal/cmd/auth.go +108 -0
  232. package/internal/cmd/connect.go +42 -0
  233. package/internal/cmd/message.go +44 -0
  234. package/internal/cmd/people.go +454 -0
  235. package/internal/cmd/profiles.go +121 -0
  236. package/internal/cmd/root.go +89 -0
  237. package/internal/cmd/sequence.go +192 -0
  238. package/internal/config/config.go +187 -0
  239. package/internal/config/config_test.go +121 -0
  240. package/internal/config/profile.go +65 -0
  241. package/internal/linkedin/navigator.go +195 -0
  242. package/internal/linkedin/selectors.go +39 -0
  243. package/internal/linkedin/validator.go +69 -0
  244. package/internal/pinchtab/client.go +183 -0
  245. package/internal/pinchtab/client_test.go +67 -0
  246. package/internal/pinchtab/types.go +50 -0
  247. package/internal/ratelimit/limiter.go +115 -0
  248. package/internal/ratelimit/limits.go +32 -0
  249. package/package.json +67 -0
  250. package/release.sh +66 -0
  251. package/scripts/debug-linkedin.js +156 -0
  252. package/scripts/debug-login.js +193 -0
  253. package/scripts/extract-from-edge.js +96 -0
  254. package/scripts/import-cookies.js +101 -0
  255. package/scripts/poc-show-data.js +205 -0
  256. package/scripts/proof-of-access.js +87 -0
  257. package/scripts/prove-connection.js +110 -0
  258. package/scripts/show-linkedin-data.js +173 -0
  259. package/src/agent/action-executor.test.ts +464 -0
  260. package/src/agent/action-executor.ts +203 -0
  261. package/src/agent/claude-client.test.ts +707 -0
  262. package/src/agent/claude-client.ts +422 -0
  263. package/src/agent/dom-extractor.test.ts +574 -0
  264. package/src/agent/dom-extractor.ts +437 -0
  265. package/src/agent/extension-client.ts +306 -0
  266. package/src/agent/index.ts +28 -0
  267. package/src/agent/page-agent.ts +292 -0
  268. package/src/agent/types.ts +288 -0
  269. package/src/cli/agent-commands.ts +274 -0
  270. package/src/cli/auth.ts +343 -0
  271. package/src/cli/company.ts +66 -0
  272. package/src/cli/connection.ts +89 -0
  273. package/src/cli/index.ts +7 -0
  274. package/src/cli/messages.ts +338 -0
  275. package/src/cli/profile.test.ts +14 -0
  276. package/src/cli/profile.ts +95 -0
  277. package/src/cli/reply.ts +110 -0
  278. package/src/core/audit.test.ts +134 -0
  279. package/src/core/audit.ts +98 -0
  280. package/src/core/browser-cookies.ts +203 -0
  281. package/src/core/browser.ts +304 -0
  282. package/src/core/config.test.ts +90 -0
  283. package/src/core/config.ts +81 -0
  284. package/src/core/storage.test.ts +129 -0
  285. package/src/core/storage.ts +100 -0
  286. package/src/index.ts +70 -0
  287. package/src/linkedin/auth.ts +218 -0
  288. package/src/linkedin/company-extractor.test.ts +58 -0
  289. package/src/linkedin/company-extractor.ts +222 -0
  290. package/src/linkedin/connector.ts +336 -0
  291. package/src/linkedin/message-sender.ts +141 -0
  292. package/src/linkedin/messages.ts +894 -0
  293. package/src/linkedin/profile.test.ts +79 -0
  294. package/src/linkedin/profile.ts +314 -0
  295. package/src/linkedin/reply.ts +96 -0
  296. package/src/linkedin/selector-engine.test.ts +167 -0
  297. package/src/linkedin/selector-engine.ts +393 -0
  298. package/src/linkedin/selectors.ts +268 -0
  299. package/src/templates/defaults/followup.txt +14 -0
  300. package/src/templates/defaults/meeting.txt +16 -0
  301. package/src/templates/defaults/welcome.txt +14 -0
  302. package/src/templates/engine.test.ts +228 -0
  303. package/src/templates/engine.ts +208 -0
  304. package/src/templates/index.ts +1 -0
  305. package/src/types/index.test.ts +94 -0
  306. package/src/types/index.ts +143 -0
  307. package/src/types/sql.js.d.ts +23 -0
  308. package/src/utils/paths.ts +33 -0
  309. package/src/utils/rate-limiter.ts +75 -0
  310. package/src/utils/retry.ts +78 -0
  311. package/test-cli.sh +85 -0
  312. package/test-real-data.sh +97 -0
  313. package/tsconfig.json +23 -0
  314. package/vitest.config.ts +35 -0
@@ -0,0 +1,81 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+
5
+ export interface Config {
6
+ headless: boolean;
7
+ rateLimit: number;
8
+ sessionTimeout: number;
9
+ dryRun: boolean;
10
+ dataDir: string;
11
+ }
12
+
13
+ const DEFAULT_CONFIG: Config = {
14
+ headless: true,
15
+ rateLimit: 5000,
16
+ sessionTimeout: 86400000,
17
+ dryRun: false,
18
+ dataDir: path.join(os.homedir(), '.linkedin-cli'),
19
+ };
20
+
21
+ export class ConfigManager {
22
+ private config: Config;
23
+ private configPath: string;
24
+
25
+ constructor() {
26
+ this.configPath = path.join(os.homedir(), '.linkedin-cli', 'config.json');
27
+ this.config = this.loadConfig();
28
+ }
29
+
30
+ private loadConfig(): Config {
31
+ try {
32
+ if (fs.existsSync(this.configPath)) {
33
+ const data = fs.readFileSync(this.configPath, 'utf-8');
34
+ const parsed = JSON.parse(data);
35
+ return { ...DEFAULT_CONFIG, ...parsed };
36
+ }
37
+ } catch (error) {
38
+ console.warn('Failed to load config, using defaults:', error);
39
+ }
40
+ return { ...DEFAULT_CONFIG };
41
+ }
42
+
43
+ private saveConfig(): void {
44
+ try {
45
+ const dir = path.dirname(this.configPath);
46
+ if (!fs.existsSync(dir)) {
47
+ fs.mkdirSync(dir, { recursive: true });
48
+ }
49
+ fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
50
+ } catch (error) {
51
+ console.error('Failed to save config:', error);
52
+ }
53
+ }
54
+
55
+ get(): Config {
56
+ return { ...this.config };
57
+ }
58
+
59
+ getValue<K extends keyof Config>(key: K): Config[K] {
60
+ return this.config[key];
61
+ }
62
+
63
+ set<K extends keyof Config>(key: K, value: Config[K]): void {
64
+ this.config[key] = value;
65
+ this.saveConfig();
66
+ }
67
+
68
+ reset(): void {
69
+ this.config = { ...DEFAULT_CONFIG };
70
+ this.saveConfig();
71
+ }
72
+ }
73
+
74
+ let configManager: ConfigManager | null = null;
75
+
76
+ export function getConfig(): ConfigManager {
77
+ if (!configManager) {
78
+ configManager = new ConfigManager();
79
+ }
80
+ return configManager;
81
+ }
@@ -0,0 +1,129 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import * as os from 'os';
5
+
6
+ // Mock the paths module
7
+ vi.mock('../utils/paths', () => {
8
+ const testDir = path.join(os.tmpdir(), 'linkedin-cli-test');
9
+ return {
10
+ getConfigDir: () => testDir,
11
+ getSessionsDir: () => path.join(testDir, 'sessions'),
12
+ getTemplatesDir: () => path.join(testDir, 'templates'),
13
+ getDefaultsDir: () => path.join(testDir, 'templates', 'defaults'),
14
+ getCustomDir: () => path.join(testDir, 'templates', 'custom'),
15
+ getConfigFile: () => path.join(testDir, 'config.json'),
16
+ getLogFile: () => path.join(testDir, 'audit.log'),
17
+ };
18
+ });
19
+
20
+ import { SecureStorage, encrypt, decrypt } from './storage';
21
+
22
+ describe('SecureStorage', () => {
23
+ let storage: SecureStorage;
24
+ const testDir = path.join(os.tmpdir(), 'linkedin-cli-test');
25
+
26
+ beforeEach(() => {
27
+ // Clean up test directory
28
+ if (fs.existsSync(testDir)) {
29
+ fs.rmSync(testDir, { recursive: true, force: true });
30
+ }
31
+ storage = new SecureStorage();
32
+ });
33
+
34
+ afterEach(() => {
35
+ // Clean up after tests
36
+ if (fs.existsSync(testDir)) {
37
+ fs.rmSync(testDir, { recursive: true, force: true });
38
+ }
39
+ });
40
+
41
+ describe('save and load', () => {
42
+ it('should save and retrieve data', () => {
43
+ const testData = 'test-secret-data';
44
+ storage.save('test-key', testData);
45
+
46
+ const loaded = storage.load('test-key');
47
+ expect(loaded).toBe(testData);
48
+ });
49
+
50
+ it('should return null for non-existent key', () => {
51
+ const loaded = storage.load('non-existent-key');
52
+ expect(loaded).toBeNull();
53
+ });
54
+
55
+ it('should overwrite existing key', () => {
56
+ storage.save('test-key', 'first-value');
57
+ storage.save('test-key', 'second-value');
58
+
59
+ const loaded = storage.load('test-key');
60
+ expect(loaded).toBe('second-value');
61
+ });
62
+ });
63
+
64
+ describe('delete', () => {
65
+ it('should delete existing key and return true', () => {
66
+ storage.save('test-key', 'test-data');
67
+ const deleted = storage.delete('test-key');
68
+
69
+ expect(deleted).toBe(true);
70
+ expect(storage.load('test-key')).toBeNull();
71
+ });
72
+
73
+ it('should return false for non-existent key', () => {
74
+ const deleted = storage.delete('non-existent-key');
75
+ expect(deleted).toBe(false);
76
+ });
77
+ });
78
+
79
+ describe('exists', () => {
80
+ it('should return true for existing key', () => {
81
+ storage.save('test-key', 'test-data');
82
+ expect(storage.exists('test-key')).toBe(true);
83
+ });
84
+
85
+ it('should return false for non-existent key', () => {
86
+ expect(storage.exists('non-existent-key')).toBe(false);
87
+ });
88
+ });
89
+ });
90
+
91
+ describe('encryption', () => {
92
+ it('should encrypt and decrypt data correctly', () => {
93
+ const originalData = 'sensitive-information';
94
+ const encrypted = encrypt(originalData);
95
+
96
+ expect(encrypted).toHaveProperty('encrypted');
97
+ expect(encrypted).toHaveProperty('iv');
98
+ expect(encrypted).toHaveProperty('salt');
99
+ expect(encrypted).toHaveProperty('tag');
100
+
101
+ const decrypted = decrypt(encrypted);
102
+ expect(decrypted).toBe(originalData);
103
+ });
104
+
105
+ it('should produce different encrypted output each time', () => {
106
+ const data = 'test-data';
107
+ const encrypted1 = encrypt(data);
108
+ const encrypted2 = encrypt(data);
109
+
110
+ // Salt should be different (random)
111
+ expect(encrypted1.salt).not.toBe(encrypted2.salt);
112
+ // But both should decrypt to the same value
113
+ expect(decrypt(encrypted1)).toBe(data);
114
+ expect(decrypt(encrypted2)).toBe(data);
115
+ });
116
+
117
+ it('should throw on tampered data', () => {
118
+ const originalData = 'important-data';
119
+ const encrypted = encrypt(originalData);
120
+
121
+ // Tamper with the encrypted data
122
+ const tampered = {
123
+ ...encrypted,
124
+ encrypted: encrypted.encrypted.split('').reverse().join(''),
125
+ };
126
+
127
+ expect(() => decrypt(tampered)).toThrow();
128
+ });
129
+ });
@@ -0,0 +1,100 @@
1
+ import * as fs from 'fs';
2
+ import * as crypto from 'crypto';
3
+ import * as path from 'path';
4
+ import { getSessionsDir } from '../utils/paths';
5
+
6
+ const ALGORITHM = 'aes-256-gcm';
7
+ const KEY_LENGTH = 32;
8
+ const IV_LENGTH = 16;
9
+ const SALT_LENGTH = 32;
10
+
11
+ function deriveKey(salt: Buffer): Buffer {
12
+ const machineData = [
13
+ process.env.USER || process.env.USERNAME || 'unknown',
14
+ process.env.HOME || process.env.USERPROFILE || 'unknown',
15
+ process.platform,
16
+ ].join('|');
17
+ return crypto.pbkdf2Sync(machineData, salt, 100000, KEY_LENGTH, 'sha256');
18
+ }
19
+
20
+ export interface EncryptedData {
21
+ encrypted: string;
22
+ iv: string;
23
+ salt: string;
24
+ tag: string;
25
+ }
26
+
27
+ export function encrypt(data: string): EncryptedData {
28
+ const salt = crypto.randomBytes(SALT_LENGTH);
29
+ const iv = crypto.randomBytes(IV_LENGTH);
30
+ const key = deriveKey(salt);
31
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
32
+ const encrypted = Buffer.concat([cipher.update(data, 'utf8'), cipher.final()]);
33
+ const tag = cipher.getAuthTag();
34
+ return {
35
+ encrypted: encrypted.toString('base64'),
36
+ iv: iv.toString('base64'),
37
+ salt: salt.toString('base64'),
38
+ tag: tag.toString('base64'),
39
+ };
40
+ }
41
+
42
+ export function decrypt(data: EncryptedData): string {
43
+ const salt = Buffer.from(data.salt, 'base64');
44
+ const iv = Buffer.from(data.iv, 'base64');
45
+ const key = deriveKey(salt);
46
+ const encrypted = Buffer.from(data.encrypted, 'base64');
47
+ const tag = Buffer.from(data.tag, 'base64');
48
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
49
+ decipher.setAuthTag(tag);
50
+ const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
51
+ return decrypted.toString('utf8');
52
+ }
53
+
54
+ export class SecureStorage {
55
+ private baseDir: string;
56
+
57
+ constructor() {
58
+ this.baseDir = getSessionsDir();
59
+ this.ensureDir();
60
+ }
61
+
62
+ private ensureDir(): void {
63
+ if (!fs.existsSync(this.baseDir)) {
64
+ fs.mkdirSync(this.baseDir, { recursive: true, mode: 0o700 });
65
+ }
66
+ }
67
+
68
+ save(key: string, data: string): void {
69
+ const encrypted = encrypt(data);
70
+ const filePath = path.join(this.baseDir, `${key}.json`);
71
+ fs.writeFileSync(filePath, JSON.stringify(encrypted, null, 2), { mode: 0o600 });
72
+ }
73
+
74
+ load(key: string): string | null {
75
+ const filePath = path.join(this.baseDir, `${key}.json`);
76
+ if (!fs.existsSync(filePath)) return null;
77
+ const encrypted = JSON.parse(fs.readFileSync(filePath, 'utf8'));
78
+ return decrypt(encrypted);
79
+ }
80
+
81
+ delete(key: string): boolean {
82
+ const filePath = path.join(this.baseDir, `${key}.json`);
83
+ if (fs.existsSync(filePath)) {
84
+ fs.unlinkSync(filePath);
85
+ return true;
86
+ }
87
+ return false;
88
+ }
89
+
90
+ exists(key: string): boolean {
91
+ const filePath = path.join(this.baseDir, `${key}.json`);
92
+ return fs.existsSync(filePath);
93
+ }
94
+ }
95
+
96
+ let secureStorage: SecureStorage | null = null;
97
+ export function getSecureStorage(): SecureStorage {
98
+ if (!secureStorage) secureStorage = new SecureStorage();
99
+ return secureStorage;
100
+ }
package/src/index.ts ADDED
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ import { program } from 'commander';
3
+ import chalk from 'chalk';
4
+ import {
5
+ registerAuthCommands,
6
+ registerMessageCommands,
7
+ registerReplyCommands,
8
+ registerConnectionCommands,
9
+ registerCompanyCommands,
10
+ registerProfileCommands,
11
+ } from './cli';
12
+ import { getConfig } from './core/config';
13
+
14
+ const packageJson = require('../package.json');
15
+
16
+ program
17
+ .name('linkedin-cli')
18
+ .description('LinkedIn automation CLI for sales representatives')
19
+ .version(packageJson.version);
20
+
21
+ // Register command groups
22
+ registerAuthCommands(program);
23
+ registerMessageCommands(program);
24
+ registerReplyCommands(program);
25
+ registerConnectionCommands(program);
26
+ registerCompanyCommands(program);
27
+ registerProfileCommands(program);
28
+
29
+ // Config command
30
+ program
31
+ .command('config')
32
+ .description('Manage configuration')
33
+ .option('--get', 'Show current configuration')
34
+ .option('--set <key=value>', 'Set a configuration value')
35
+ .action((options) => {
36
+ const config = getConfig();
37
+
38
+ if (options.get || !options.set) {
39
+ console.log(chalk.bold('Current Configuration:'));
40
+ console.log(JSON.stringify(config.get(), null, 2));
41
+ return;
42
+ }
43
+
44
+ if (options.set) {
45
+ const [key, value] = options.set.split('=');
46
+ if (!key || value === undefined) {
47
+ console.error(chalk.red('Invalid format. Use: --set key=value'));
48
+ process.exit(1);
49
+ }
50
+
51
+ // Try to parse as boolean or number
52
+ let parsedValue: string | boolean | number = value;
53
+ if (value === 'true') parsedValue = true;
54
+ else if (value === 'false') parsedValue = false;
55
+ else if (!isNaN(Number(value))) parsedValue = Number(value);
56
+
57
+ config.set(key as any, parsedValue as any);
58
+ console.log(chalk.green(`✓ Set ${key} = ${parsedValue}`));
59
+ }
60
+ });
61
+
62
+ // Global error handler
63
+ program.exitOverride();
64
+
65
+ try {
66
+ program.parse();
67
+ } catch (error) {
68
+ console.error(chalk.red('Error:'), error instanceof Error ? error.message : error);
69
+ process.exit(1);
70
+ }
@@ -0,0 +1,218 @@
1
+ import type { BrowserController } from '../core/browser';
2
+ import type { Session } from '../types';
3
+ import { SELECTORS } from './selectors';
4
+
5
+ export interface LoginCredentials {
6
+ email: string;
7
+ password: string;
8
+ totpCode?: string;
9
+ }
10
+
11
+ export interface LoginResult {
12
+ success: boolean;
13
+ session?: Session;
14
+ error?: string;
15
+ requires2FA?: boolean;
16
+ }
17
+
18
+ export class LinkedInAuth {
19
+ private browser: BrowserController;
20
+ private debug: boolean;
21
+
22
+ constructor(browser: BrowserController, debug: boolean = false) {
23
+ this.browser = browser;
24
+ this.debug = debug;
25
+ }
26
+ private async findElementWithFallbacks(
27
+ selectors: readonly string[],
28
+ elementName: string
29
+ ): Promise<{
30
+ element: Awaited<ReturnType<import('playwright').Page['$']>>;
31
+ selectorUsed: string;
32
+ } | null> {
33
+ const page = this.browser.getPage();
34
+ if (!page) return null;
35
+
36
+ for (const selector of selectors) {
37
+ try {
38
+ const element = await page.$(selector);
39
+ if (element) {
40
+ console.log(`Found ${elementName} using selector: ${selector}`);
41
+ if (this.debug) this.browser.debugLog(`Found ${elementName} using selector: ${selector}`);
42
+ return { element, selectorUsed: selector };
43
+ }
44
+ } catch {
45
+ // Continue to next selector
46
+ }
47
+ }
48
+
49
+ return null;
50
+ }
51
+
52
+ async login(credentials: LoginCredentials): Promise<LoginResult> {
53
+ const page = this.browser.getPage();
54
+ if (!page) {
55
+ return { success: false, error: 'Browser not initialized' };
56
+ }
57
+
58
+ try {
59
+ // Navigate to LinkedIn login
60
+ console.log('Navigating to LinkedIn login page...');
61
+ await page.goto('https://www.linkedin.com/login', {
62
+ waitUntil: 'domcontentloaded',
63
+ timeout: 30000,
64
+ });
65
+
66
+ const currentUrl = page.url();
67
+ console.log(`Current page URL: ${currentUrl}`);
68
+
69
+ // Debug: Log all input elements on the page
70
+ console.log('Scanning for form elements...');
71
+ const inputs = await page.$$('input');
72
+ console.log(`Found ${inputs.length} input elements`);
73
+ for (let i = 0; i < Math.min(inputs.length, 5); i++) {
74
+ const type = await inputs[i].getAttribute('type');
75
+ const id = await inputs[i].getAttribute('id');
76
+ const name = await inputs[i].getAttribute('name');
77
+ console.log(` Input ${i}: type=${type}, id=${id}, name=${name}`);
78
+ }
79
+
80
+ // Find and fill email input
81
+ console.log('Looking for email input...');
82
+ const emailResult = await this.findElementWithFallbacks(
83
+ SELECTORS.login.emailInput,
84
+ 'email input'
85
+ );
86
+ if (!emailResult) {
87
+ return {
88
+ success: false,
89
+ error:
90
+ 'Could not find email input field - LinkedIn may have changed their page structure',
91
+ };
92
+ }
93
+ await page.fill(emailResult.selectorUsed, credentials.email);
94
+ console.log('Email filled successfully');
95
+
96
+ // Find and fill password input
97
+ console.log('Looking for password input...');
98
+ const passwordResult = await this.findElementWithFallbacks(
99
+ SELECTORS.login.passwordInput,
100
+ 'password input'
101
+ );
102
+ if (!passwordResult) {
103
+ return {
104
+ success: false,
105
+ error:
106
+ 'Could not find password input field - LinkedIn may have changed their page structure',
107
+ };
108
+ }
109
+ await page.fill(passwordResult.selectorUsed, credentials.password);
110
+ console.log('Password filled successfully');
111
+
112
+ // Find and click submit button
113
+ console.log('Looking for submit button...');
114
+ const submitResult = await this.findElementWithFallbacks(
115
+ SELECTORS.login.submitButton,
116
+ 'submit button'
117
+ );
118
+ if (!submitResult) {
119
+ return {
120
+ success: false,
121
+ error: 'Could not find submit button - LinkedIn may have changed their page structure',
122
+ };
123
+ }
124
+ if (!submitResult.element) {
125
+ return {
126
+ success: false,
127
+ error: 'Submit button element is null',
128
+ };
129
+ }
130
+ await submitResult.element.click();
131
+ console.log('Submit button clicked');
132
+
133
+ // Wait for navigation or 2FA prompt
134
+ console.log('Waiting for page to load...');
135
+ await page.waitForLoadState('networkidle');
136
+
137
+ // Check for 2FA prompt
138
+ console.log('Checking for 2FA prompt...');
139
+ const has2FA = await this.findElementWithFallbacks(SELECTORS.twoFA.pinInput, '2FA pin input');
140
+ if (has2FA) {
141
+ console.log('2FA prompt detected');
142
+ if (credentials.totpCode) {
143
+ // Fill in TOTP code
144
+ await page.fill(has2FA.selectorUsed, credentials.totpCode);
145
+ const submit2FA = await this.findElementWithFallbacks(
146
+ SELECTORS.twoFA.submitButton,
147
+ '2FA submit button'
148
+ );
149
+ if (submit2FA && submit2FA.element) {
150
+ await submit2FA.element.click();
151
+ }
152
+ await page.waitForLoadState('networkidle');
153
+ } else {
154
+ return { success: false, requires2FA: true, error: '2FA required - provide TOTP code' };
155
+ }
156
+ }
157
+
158
+ // Check if login succeeded (look for feed or profile element)
159
+ console.log('Checking if login was successful...');
160
+ const navElement = await this.findElementWithFallbacks(
161
+ SELECTORS.nav.globalNav,
162
+ 'global navigation'
163
+ );
164
+ if (!navElement) {
165
+ // Check for error message
166
+ const errorElement = await page.$('div[role="alert"] div, div.error, .alert-content');
167
+ const errorText = errorElement ? await errorElement.textContent() : null;
168
+
169
+ if (errorText) {
170
+ return { success: false, error: `LinkedIn error: ${errorText.trim()}` };
171
+ }
172
+
173
+ // Take a screenshot for debugging
174
+ console.log('Login may have failed - no global nav found');
175
+ return { success: false, error: 'Login failed - could not verify successful login' };
176
+ }
177
+
178
+ console.log('Login successful! Saving session...');
179
+
180
+ // Save session
181
+ const session = await this.browser.saveSession();
182
+ if (!session) {
183
+ return { success: false, error: 'Failed to save session' };
184
+ }
185
+
186
+ console.log('Session saved successfully');
187
+ return { success: true, session };
188
+ } catch (error) {
189
+ const errorMessage = error instanceof Error ? error.message : String(error);
190
+ console.error('Login error:', errorMessage);
191
+ return {
192
+ success: false,
193
+ error: `Login error: ${errorMessage}`,
194
+ };
195
+ }
196
+ }
197
+
198
+ async isSessionValid(): Promise<boolean> {
199
+ const page = this.browser.getPage();
200
+ if (!page) return false;
201
+
202
+ try {
203
+ // Navigate to LinkedIn and check if we're logged in
204
+ await page.goto('https://www.linkedin.com/feed', {
205
+ waitUntil: 'domcontentloaded',
206
+ timeout: 10000,
207
+ });
208
+
209
+ const navElement = await this.findElementWithFallbacks(
210
+ SELECTORS.nav.globalNav,
211
+ 'global navigation'
212
+ );
213
+ return !!navElement;
214
+ } catch {
215
+ return false;
216
+ }
217
+ }
218
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isValidCompanyUrl, parseFollowerCount, parseSpecialties } from './company-extractor';
3
+
4
+ describe('isValidCompanyUrl', () => {
5
+ it('should accept valid HTTPS company URLs', () => {
6
+ expect(isValidCompanyUrl('https://www.linkedin.com/company/openai/')).toBe(true);
7
+ expect(isValidCompanyUrl('https://www.linkedin.com/company/microsoft')).toBe(true);
8
+ expect(isValidCompanyUrl('https://www.linkedin.com/company/123company/')).toBe(true);
9
+ });
10
+
11
+ it('should reject invalid URLs', () => {
12
+ expect(isValidCompanyUrl('http://www.linkedin.com/company/openai/')).toBe(false);
13
+ expect(isValidCompanyUrl('https://linkedin.com/company/openai/')).toBe(false);
14
+ expect(isValidCompanyUrl('https://www.linkedin.com/in/openai/')).toBe(false);
15
+ expect(isValidCompanyUrl('not-a-url')).toBe(false);
16
+ expect(isValidCompanyUrl('')).toBe(false);
17
+ });
18
+ });
19
+
20
+ describe('parseFollowerCount', () => {
21
+ it('should parse K suffix', () => {
22
+ expect(parseFollowerCount('12K followers')).toBe(12000);
23
+ expect(parseFollowerCount('5k')).toBe(5000);
24
+ });
25
+
26
+ it('should parse M suffix', () => {
27
+ expect(parseFollowerCount('2.5M followers')).toBe(2500000);
28
+ expect(parseFollowerCount('1.2m')).toBe(1200000);
29
+ });
30
+
31
+ it('should parse plain numbers with commas', () => {
32
+ expect(parseFollowerCount('1,234 followers')).toBe(1234);
33
+ expect(parseFollowerCount('500+ followers')).toBe(500);
34
+ });
35
+
36
+ it('should return null for invalid input', () => {
37
+ expect(parseFollowerCount(null)).toBe(null);
38
+ expect(parseFollowerCount('')).toBe(null);
39
+ expect(parseFollowerCount('no numbers')).toBe(null);
40
+ });
41
+ });
42
+
43
+ describe('parseSpecialties', () => {
44
+ it('should split by comma and trim', () => {
45
+ expect(parseSpecialties('AI, ML, Research')).toEqual(['AI', 'ML', 'Research']);
46
+ expect(parseSpecialties('Artificial Intelligence')).toEqual(['Artificial Intelligence']);
47
+ });
48
+
49
+ it('should handle whitespace', () => {
50
+ expect(parseSpecialties(' AI , ML ')).toEqual(['AI', 'ML']);
51
+ });
52
+
53
+ it('should return null for empty input', () => {
54
+ expect(parseSpecialties(null)).toBe(null);
55
+ expect(parseSpecialties('')).toBe(null);
56
+ expect(parseSpecialties(' ')).toBe(null);
57
+ });
58
+ });