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.
- package/.env.example +12 -0
- package/.github/workflows/ci.yml +66 -0
- package/.github/workflows/publish.yml +48 -0
- package/.husky/pre-commit +6 -0
- package/.prettierignore +4 -0
- package/.prettierrc +10 -0
- package/AGENTS.md +294 -0
- package/CHANGELOG.md +40 -0
- package/GIT_RELEASE.md +167 -0
- package/LICENSE +21 -0
- package/Makefile +30 -0
- package/NPM_PUBLISHING.md +230 -0
- package/PYEOF +0 -0
- package/README.md +295 -0
- package/TESTING-GUIDE.md +151 -0
- package/cmd/linkedin/main.go +9 -0
- package/dist/agent/action-executor.d.ts +81 -0
- package/dist/agent/action-executor.d.ts.map +1 -0
- package/dist/agent/action-executor.js +170 -0
- package/dist/agent/action-executor.js.map +1 -0
- package/dist/agent/action-executor.test.d.ts +2 -0
- package/dist/agent/action-executor.test.d.ts.map +1 -0
- package/dist/agent/action-executor.test.js +366 -0
- package/dist/agent/action-executor.test.js.map +1 -0
- package/dist/agent/claude-client.d.ts +74 -0
- package/dist/agent/claude-client.d.ts.map +1 -0
- package/dist/agent/claude-client.js +314 -0
- package/dist/agent/claude-client.js.map +1 -0
- package/dist/agent/claude-client.test.d.ts +2 -0
- package/dist/agent/claude-client.test.d.ts.map +1 -0
- package/dist/agent/claude-client.test.js +590 -0
- package/dist/agent/claude-client.test.js.map +1 -0
- package/dist/agent/dom-extractor.d.ts +50 -0
- package/dist/agent/dom-extractor.d.ts.map +1 -0
- package/dist/agent/dom-extractor.js +374 -0
- package/dist/agent/dom-extractor.js.map +1 -0
- package/dist/agent/dom-extractor.test.d.ts +7 -0
- package/dist/agent/dom-extractor.test.d.ts.map +1 -0
- package/dist/agent/dom-extractor.test.js +504 -0
- package/dist/agent/dom-extractor.test.js.map +1 -0
- package/dist/agent/extension-client.d.ts +75 -0
- package/dist/agent/extension-client.d.ts.map +1 -0
- package/dist/agent/extension-client.js +245 -0
- package/dist/agent/extension-client.js.map +1 -0
- package/dist/agent/index.d.ts +8 -0
- package/dist/agent/index.d.ts.map +1 -0
- package/dist/agent/index.js +16 -0
- package/dist/agent/index.js.map +1 -0
- package/dist/agent/page-agent.d.ts +76 -0
- package/dist/agent/page-agent.d.ts.map +1 -0
- package/dist/agent/page-agent.js +236 -0
- package/dist/agent/page-agent.js.map +1 -0
- package/dist/agent/types.d.ts +236 -0
- package/dist/agent/types.d.ts.map +1 -0
- package/dist/agent/types.js +37 -0
- package/dist/agent/types.js.map +1 -0
- package/dist/cli/agent-commands.d.ts +3 -0
- package/dist/cli/agent-commands.d.ts.map +1 -0
- package/dist/cli/agent-commands.js +250 -0
- package/dist/cli/agent-commands.js.map +1 -0
- package/dist/cli/auth.d.ts +3 -0
- package/dist/cli/auth.d.ts.map +1 -0
- package/dist/cli/auth.js +288 -0
- package/dist/cli/auth.js.map +1 -0
- package/dist/cli/company.d.ts +3 -0
- package/dist/cli/company.d.ts.map +1 -0
- package/dist/cli/company.js +55 -0
- package/dist/cli/company.js.map +1 -0
- package/dist/cli/connection.d.ts +3 -0
- package/dist/cli/connection.d.ts.map +1 -0
- package/dist/cli/connection.js +79 -0
- package/dist/cli/connection.js.map +1 -0
- package/dist/cli/index.d.ts +7 -0
- package/dist/cli/index.d.ts.map +1 -0
- package/dist/cli/index.js +17 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/cli/messages.d.ts +3 -0
- package/dist/cli/messages.d.ts.map +1 -0
- package/dist/cli/messages.js +268 -0
- package/dist/cli/messages.js.map +1 -0
- package/dist/cli/profile.d.ts +3 -0
- package/dist/cli/profile.d.ts.map +1 -0
- package/dist/cli/profile.js +81 -0
- package/dist/cli/profile.js.map +1 -0
- package/dist/cli/profile.test.d.ts +2 -0
- package/dist/cli/profile.test.d.ts.map +1 -0
- package/dist/cli/profile.test.js +15 -0
- package/dist/cli/profile.test.js.map +1 -0
- package/dist/cli/reply.d.ts +3 -0
- package/dist/cli/reply.d.ts.map +1 -0
- package/dist/cli/reply.js +129 -0
- package/dist/cli/reply.js.map +1 -0
- package/dist/core/audit.d.ts +17 -0
- package/dist/core/audit.d.ts.map +1 -0
- package/dist/core/audit.js +121 -0
- package/dist/core/audit.js.map +1 -0
- package/dist/core/audit.test.d.ts +2 -0
- package/dist/core/audit.test.d.ts.map +1 -0
- package/dist/core/audit.test.js +142 -0
- package/dist/core/audit.test.js.map +1 -0
- package/dist/core/browser-cookies.d.ts +19 -0
- package/dist/core/browser-cookies.d.ts.map +1 -0
- package/dist/core/browser-cookies.js +181 -0
- package/dist/core/browser-cookies.js.map +1 -0
- package/dist/core/browser.d.ts +50 -0
- package/dist/core/browser.d.ts.map +1 -0
- package/dist/core/browser.js +318 -0
- package/dist/core/browser.js.map +1 -0
- package/dist/core/config.d.ts +20 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +103 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/config.test.d.ts +2 -0
- package/dist/core/config.test.d.ts.map +1 -0
- package/dist/core/config.test.js +111 -0
- package/dist/core/config.test.js.map +1 -0
- package/dist/core/storage.d.ts +19 -0
- package/dist/core/storage.d.ts.map +1 -0
- package/dist/core/storage.js +124 -0
- package/dist/core/storage.js.map +1 -0
- package/dist/core/storage.test.d.ts +2 -0
- package/dist/core/storage.test.d.ts.map +1 -0
- package/dist/core/storage.test.js +142 -0
- package/dist/core/storage.test.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +63 -0
- package/dist/index.js.map +1 -0
- package/dist/linkedin/auth.d.ts +22 -0
- package/dist/linkedin/auth.d.ts.map +1 -0
- package/dist/linkedin/auth.js +167 -0
- package/dist/linkedin/auth.js.map +1 -0
- package/dist/linkedin/company-extractor.d.ts +36 -0
- package/dist/linkedin/company-extractor.d.ts.map +1 -0
- package/dist/linkedin/company-extractor.js +211 -0
- package/dist/linkedin/company-extractor.js.map +1 -0
- package/dist/linkedin/company-extractor.test.d.ts +2 -0
- package/dist/linkedin/company-extractor.test.d.ts.map +1 -0
- package/dist/linkedin/company-extractor.test.js +52 -0
- package/dist/linkedin/company-extractor.test.js.map +1 -0
- package/dist/linkedin/connector.d.ts +45 -0
- package/dist/linkedin/connector.d.ts.map +1 -0
- package/dist/linkedin/connector.js +245 -0
- package/dist/linkedin/connector.js.map +1 -0
- package/dist/linkedin/message-sender.d.ts +32 -0
- package/dist/linkedin/message-sender.d.ts.map +1 -0
- package/dist/linkedin/message-sender.js +112 -0
- package/dist/linkedin/message-sender.js.map +1 -0
- package/dist/linkedin/messages.d.ts +78 -0
- package/dist/linkedin/messages.d.ts.map +1 -0
- package/dist/linkedin/messages.js +745 -0
- package/dist/linkedin/messages.js.map +1 -0
- package/dist/linkedin/profile.d.ts +37 -0
- package/dist/linkedin/profile.d.ts.map +1 -0
- package/dist/linkedin/profile.js +268 -0
- package/dist/linkedin/profile.js.map +1 -0
- package/dist/linkedin/profile.test.d.ts +2 -0
- package/dist/linkedin/profile.test.d.ts.map +1 -0
- package/dist/linkedin/profile.test.js +68 -0
- package/dist/linkedin/profile.test.js.map +1 -0
- package/dist/linkedin/reply.d.ts +21 -0
- package/dist/linkedin/reply.d.ts.map +1 -0
- package/dist/linkedin/reply.js +76 -0
- package/dist/linkedin/reply.js.map +1 -0
- package/dist/linkedin/selector-engine.d.ts +69 -0
- package/dist/linkedin/selector-engine.d.ts.map +1 -0
- package/dist/linkedin/selector-engine.js +339 -0
- package/dist/linkedin/selector-engine.js.map +1 -0
- package/dist/linkedin/selector-engine.test.d.ts +2 -0
- package/dist/linkedin/selector-engine.test.d.ts.map +1 -0
- package/dist/linkedin/selector-engine.test.js +135 -0
- package/dist/linkedin/selector-engine.test.js.map +1 -0
- package/dist/linkedin/selectors.d.ts +65 -0
- package/dist/linkedin/selectors.d.ts.map +1 -0
- package/dist/linkedin/selectors.js +261 -0
- package/dist/linkedin/selectors.js.map +1 -0
- package/dist/templates/engine.d.ts +37 -0
- package/dist/templates/engine.d.ts.map +1 -0
- package/dist/templates/engine.js +215 -0
- package/dist/templates/engine.js.map +1 -0
- package/dist/templates/engine.test.d.ts +2 -0
- package/dist/templates/engine.test.d.ts.map +1 -0
- package/dist/templates/engine.test.js +212 -0
- package/dist/templates/engine.test.js.map +1 -0
- package/dist/templates/index.d.ts +2 -0
- package/dist/templates/index.d.ts.map +1 -0
- package/dist/templates/index.js +7 -0
- package/dist/templates/index.js.map +1 -0
- package/dist/types/index.d.ts +113 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/index.test.d.ts +2 -0
- package/dist/types/index.test.d.ts.map +1 -0
- package/dist/types/index.test.js +90 -0
- package/dist/types/index.test.js.map +1 -0
- package/dist/utils/paths.d.ts +8 -0
- package/dist/utils/paths.d.ts.map +1 -0
- package/dist/utils/paths.js +68 -0
- package/dist/utils/paths.js.map +1 -0
- package/dist/utils/rate-limiter.d.ts +22 -0
- package/dist/utils/rate-limiter.d.ts.map +1 -0
- package/dist/utils/rate-limiter.js +57 -0
- package/dist/utils/rate-limiter.js.map +1 -0
- package/dist/utils/retry.d.ts +18 -0
- package/dist/utils/retry.d.ts.map +1 -0
- package/dist/utils/retry.js +49 -0
- package/dist/utils/retry.js.map +1 -0
- package/docs/connection-command.md +52 -0
- package/docs/plans/2025-03-03-linkedin-cli-design.md +280 -0
- package/docs/plans/2025-03-03-linkedin-cli-implementation-plan.md +2087 -0
- package/docs/plans/2025-03-03-linkedin-cli-implementation.md +2420 -0
- package/docs/plans/2026-02-19-linkedin-connection-feature.md +596 -0
- package/docs/plans/2026-02-28-messages-send-feature.md +480 -0
- package/docs/plans/2026-02-28-messages-show-design.md +243 -0
- package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-design.md +394 -0
- package/docs/plans/2026-03-03-linkedin-cli-oss-publishing-plan.md +1592 -0
- package/docs/superpowers/plans/2026-03-13-linkedin-automation-resilience-migration.md +425 -0
- package/docs/superpowers/plans/2026-03-13-playwright-fara-migration.md +1112 -0
- package/docs/superpowers/plans/2026-03-14-page-agent-plan.md +1598 -0
- package/docs/superpowers/plans/2026-03-15-company-profile-extraction.md +591 -0
- package/docs/superpowers/plans/2026-03-15-profile-extraction-plan.md +943 -0
- package/docs/superpowers/specs/2026-03-14-company-profile-extraction-design.md +371 -0
- package/docs/superpowers/specs/2026-03-14-page-agent-design.md +385 -0
- package/docs/superpowers/specs/2026-03-15-profile-extraction-design.md +409 -0
- package/eslint.config.mjs +58 -0
- package/go.mod +9 -0
- package/go.sum +10 -0
- package/import-cookies.js +376 -0
- package/internal/cmd/actions.go +123 -0
- package/internal/cmd/auth.go +108 -0
- package/internal/cmd/connect.go +42 -0
- package/internal/cmd/message.go +44 -0
- package/internal/cmd/people.go +454 -0
- package/internal/cmd/profiles.go +121 -0
- package/internal/cmd/root.go +89 -0
- package/internal/cmd/sequence.go +192 -0
- package/internal/config/config.go +187 -0
- package/internal/config/config_test.go +121 -0
- package/internal/config/profile.go +65 -0
- package/internal/linkedin/navigator.go +195 -0
- package/internal/linkedin/selectors.go +39 -0
- package/internal/linkedin/validator.go +69 -0
- package/internal/pinchtab/client.go +183 -0
- package/internal/pinchtab/client_test.go +67 -0
- package/internal/pinchtab/types.go +50 -0
- package/internal/ratelimit/limiter.go +115 -0
- package/internal/ratelimit/limits.go +32 -0
- package/package.json +67 -0
- package/release.sh +66 -0
- package/scripts/debug-linkedin.js +156 -0
- package/scripts/debug-login.js +193 -0
- package/scripts/extract-from-edge.js +96 -0
- package/scripts/import-cookies.js +101 -0
- package/scripts/poc-show-data.js +205 -0
- package/scripts/proof-of-access.js +87 -0
- package/scripts/prove-connection.js +110 -0
- package/scripts/show-linkedin-data.js +173 -0
- package/src/agent/action-executor.test.ts +464 -0
- package/src/agent/action-executor.ts +203 -0
- package/src/agent/claude-client.test.ts +707 -0
- package/src/agent/claude-client.ts +422 -0
- package/src/agent/dom-extractor.test.ts +574 -0
- package/src/agent/dom-extractor.ts +437 -0
- package/src/agent/extension-client.ts +306 -0
- package/src/agent/index.ts +28 -0
- package/src/agent/page-agent.ts +292 -0
- package/src/agent/types.ts +288 -0
- package/src/cli/agent-commands.ts +274 -0
- package/src/cli/auth.ts +343 -0
- package/src/cli/company.ts +66 -0
- package/src/cli/connection.ts +89 -0
- package/src/cli/index.ts +7 -0
- package/src/cli/messages.ts +338 -0
- package/src/cli/profile.test.ts +14 -0
- package/src/cli/profile.ts +95 -0
- package/src/cli/reply.ts +110 -0
- package/src/core/audit.test.ts +134 -0
- package/src/core/audit.ts +98 -0
- package/src/core/browser-cookies.ts +203 -0
- package/src/core/browser.ts +304 -0
- package/src/core/config.test.ts +90 -0
- package/src/core/config.ts +81 -0
- package/src/core/storage.test.ts +129 -0
- package/src/core/storage.ts +100 -0
- package/src/index.ts +70 -0
- package/src/linkedin/auth.ts +218 -0
- package/src/linkedin/company-extractor.test.ts +58 -0
- package/src/linkedin/company-extractor.ts +222 -0
- package/src/linkedin/connector.ts +336 -0
- package/src/linkedin/message-sender.ts +141 -0
- package/src/linkedin/messages.ts +894 -0
- package/src/linkedin/profile.test.ts +79 -0
- package/src/linkedin/profile.ts +314 -0
- package/src/linkedin/reply.ts +96 -0
- package/src/linkedin/selector-engine.test.ts +167 -0
- package/src/linkedin/selector-engine.ts +393 -0
- package/src/linkedin/selectors.ts +268 -0
- package/src/templates/defaults/followup.txt +14 -0
- package/src/templates/defaults/meeting.txt +16 -0
- package/src/templates/defaults/welcome.txt +14 -0
- package/src/templates/engine.test.ts +228 -0
- package/src/templates/engine.ts +208 -0
- package/src/templates/index.ts +1 -0
- package/src/types/index.test.ts +94 -0
- package/src/types/index.ts +143 -0
- package/src/types/sql.js.d.ts +23 -0
- package/src/utils/paths.ts +33 -0
- package/src/utils/rate-limiter.ts +75 -0
- package/src/utils/retry.ts +78 -0
- package/test-cli.sh +85 -0
- package/test-real-data.sh +97 -0
- package/tsconfig.json +23 -0
- package/vitest.config.ts +35 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import { BrowserController } from '../core/browser';
|
|
5
|
+
import { LinkedInMessages } from '../linkedin/messages';
|
|
6
|
+
import { LinkedInMessageSender } from '../linkedin/message-sender';
|
|
7
|
+
import { getAuditLogger } from '../core/audit';
|
|
8
|
+
import { getConfig } from '../core/config';
|
|
9
|
+
|
|
10
|
+
export function registerMessageCommands(program: Command): void {
|
|
11
|
+
const messages = program.command('messages').description('Message management commands');
|
|
12
|
+
|
|
13
|
+
// List conversations
|
|
14
|
+
messages
|
|
15
|
+
.command('list')
|
|
16
|
+
.description('List recent conversations')
|
|
17
|
+
.option('-l, --limit <number>', 'Number of conversations to show', '20')
|
|
18
|
+
.option('-u, --unread', 'Show only unread conversations', false)
|
|
19
|
+
.option('--headless', 'Run browser in headless mode', false)
|
|
20
|
+
.action(async (options) => {
|
|
21
|
+
const spinner = ora('Loading conversations...').start();
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
// Launch browser - will connect to existing Edge/Chrome via CDP if available
|
|
25
|
+
const config = getConfig();
|
|
26
|
+
const browser = new BrowserController({
|
|
27
|
+
headless: options.headless ?? config.getValue('headless'),
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
await browser.launch();
|
|
31
|
+
|
|
32
|
+
// Get the page (could be existing CDP tab or new page)
|
|
33
|
+
const page = browser.getPage();
|
|
34
|
+
if (!page) throw new Error('Browser page not available');
|
|
35
|
+
|
|
36
|
+
// Check if user is on linkedin.cn (limited functionality)
|
|
37
|
+
if (browser.isOnLinkedInCN()) {
|
|
38
|
+
spinner.fail('Detected linkedin.cn - messaging requires linkedin.com');
|
|
39
|
+
console.error(chalk.red('\n⚠️ Network Issue Detected: linkedin.cn'));
|
|
40
|
+
console.error(
|
|
41
|
+
chalk.yellow(
|
|
42
|
+
"\nlinkedin.cn (China's local LinkedIn) has limited functionality and does not support messaging."
|
|
43
|
+
)
|
|
44
|
+
);
|
|
45
|
+
console.error(
|
|
46
|
+
chalk.yellow(
|
|
47
|
+
'\nPlease enable your VPN or switch to a network that can access linkedin.com'
|
|
48
|
+
)
|
|
49
|
+
);
|
|
50
|
+
console.error(
|
|
51
|
+
chalk.gray('\nOnce your VPN is active, close this tab and run the command again.')
|
|
52
|
+
);
|
|
53
|
+
await browser.close();
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// If connected via CDP to existing browser, use the existing session
|
|
58
|
+
if (browser.isUsingCDP()) {
|
|
59
|
+
console.log('Using existing browser session...');
|
|
60
|
+
spinner.text = 'Navigating to LinkedIn messaging...';
|
|
61
|
+
|
|
62
|
+
// Check current URL
|
|
63
|
+
const currentUrl = page.url();
|
|
64
|
+
console.log('Current URL:', currentUrl);
|
|
65
|
+
|
|
66
|
+
// If already on messaging page, just refresh to get the list view
|
|
67
|
+
if (currentUrl.includes('/messaging/')) {
|
|
68
|
+
if (currentUrl.includes('/thread/')) {
|
|
69
|
+
// We're in a thread, navigate back to main messaging
|
|
70
|
+
console.log('In thread, navigating to messaging list...');
|
|
71
|
+
await page.goto('https://www.linkedin.com/messaging/', {
|
|
72
|
+
waitUntil: 'domcontentloaded',
|
|
73
|
+
timeout: 10000,
|
|
74
|
+
});
|
|
75
|
+
} else {
|
|
76
|
+
// Already on messaging list, just reload
|
|
77
|
+
console.log('Already on messaging list, reloading...');
|
|
78
|
+
await page.reload({ waitUntil: 'domcontentloaded', timeout: 10000 });
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
// Navigate from elsewhere to messaging
|
|
82
|
+
await page.goto('https://www.linkedin.com/messaging/', {
|
|
83
|
+
waitUntil: 'domcontentloaded',
|
|
84
|
+
timeout: 10000,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
// Fresh browser, navigate to messaging
|
|
89
|
+
await page.goto('https://www.linkedin.com/messaging/', {
|
|
90
|
+
waitUntil: 'domcontentloaded',
|
|
91
|
+
timeout: 30000,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Wait a bit for the page to stabilize
|
|
96
|
+
await page.waitForTimeout(2000);
|
|
97
|
+
|
|
98
|
+
const linkedInMessages = new LinkedInMessages(page, browser);
|
|
99
|
+
|
|
100
|
+
spinner.text = 'Fetching conversations from LinkedIn...';
|
|
101
|
+
|
|
102
|
+
const threads = await linkedInMessages.getThreads({
|
|
103
|
+
limit: parseInt(options.limit),
|
|
104
|
+
unreadOnly: options.unread,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
spinner.succeed(`Found ${threads.length} conversations`);
|
|
108
|
+
|
|
109
|
+
// Display results
|
|
110
|
+
console.log('\n' + chalk.bold('Conversations:'));
|
|
111
|
+
console.log(chalk.gray('─'.repeat(80)));
|
|
112
|
+
|
|
113
|
+
threads.forEach((thread) => {
|
|
114
|
+
const participantNames = thread.participants.map((p) => p.name).join(', ');
|
|
115
|
+
const unreadIndicator = thread.unreadCount > 0 ? chalk.red('● ') : ' ';
|
|
116
|
+
const preview = thread.messages[0]?.content?.slice(0, 60) || 'No preview';
|
|
117
|
+
|
|
118
|
+
console.log(`${unreadIndicator}${chalk.bold(participantNames)}`);
|
|
119
|
+
console.log(` ${chalk.gray(preview)}${preview.length >= 60 ? '...' : ''}`);
|
|
120
|
+
console.log(` ${chalk.cyan(`ID: ${thread.id}`)}`);
|
|
121
|
+
console.log();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Log action
|
|
125
|
+
const logger = getAuditLogger();
|
|
126
|
+
logger.log('messages.list', { count: threads.length, unreadOnly: options.unread }, true);
|
|
127
|
+
|
|
128
|
+
await browser.close();
|
|
129
|
+
} catch (error) {
|
|
130
|
+
spinner.fail('Failed to load conversations');
|
|
131
|
+
console.error(chalk.red('Error:'), error instanceof Error ? error.message : error);
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Show thread details
|
|
137
|
+
messages
|
|
138
|
+
.command('show')
|
|
139
|
+
.description('Show conversation thread details')
|
|
140
|
+
.requiredOption('-t, --thread <id>', 'Thread ID to show')
|
|
141
|
+
.option('-l, --limit <number>', 'Number of messages to show', '50')
|
|
142
|
+
.option('--json', 'Output as JSON for LLM processing', false)
|
|
143
|
+
.option('--headless', 'Run browser in headless mode', false)
|
|
144
|
+
.action(async (options) => {
|
|
145
|
+
const spinner = ora('Loading conversation...').start();
|
|
146
|
+
|
|
147
|
+
try {
|
|
148
|
+
// Launch browser - will connect to existing Edge/Chrome via CDP if available
|
|
149
|
+
const config = getConfig();
|
|
150
|
+
const browser = new BrowserController({
|
|
151
|
+
headless: options.headless ?? config.getValue('headless'),
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await browser.launch();
|
|
155
|
+
|
|
156
|
+
// Get the page (could be existing CDP tab or new page)
|
|
157
|
+
const page = browser.getPage();
|
|
158
|
+
if (!page) throw new Error('Browser page not available');
|
|
159
|
+
|
|
160
|
+
// Check if user is on linkedin.cn (limited functionality)
|
|
161
|
+
if (browser.isOnLinkedInCN()) {
|
|
162
|
+
spinner.fail('Detected linkedin.cn - messaging requires linkedin.com');
|
|
163
|
+
console.error(chalk.red('\n⚠️ Network Issue Detected: linkedin.cn'));
|
|
164
|
+
console.error(
|
|
165
|
+
chalk.yellow(
|
|
166
|
+
"\nlinkedin.cn (China's local LinkedIn) has limited functionality and does not support messaging."
|
|
167
|
+
)
|
|
168
|
+
);
|
|
169
|
+
console.error(
|
|
170
|
+
chalk.yellow(
|
|
171
|
+
'\nPlease enable your VPN or switch to a network that can access linkedin.com'
|
|
172
|
+
)
|
|
173
|
+
);
|
|
174
|
+
console.error(
|
|
175
|
+
chalk.gray('\nOnce your VPN is active, close this tab and run the command again.')
|
|
176
|
+
);
|
|
177
|
+
await browser.close();
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// If connected via CDP to existing browser, use the existing session
|
|
182
|
+
if (browser.isUsingCDP()) {
|
|
183
|
+
console.log('Using existing browser session...');
|
|
184
|
+
spinner.text = 'Navigating to conversation...';
|
|
185
|
+
|
|
186
|
+
// Only navigate to messaging list for ember ID lookup
|
|
187
|
+
// Real thread IDs can be accessed directly
|
|
188
|
+
const isEmberId = options.thread.startsWith('ember');
|
|
189
|
+
if (isEmberId) {
|
|
190
|
+
console.log('Navigating to messaging list for ember ID lookup...');
|
|
191
|
+
await page.goto('https://www.linkedin.com/messaging/', {
|
|
192
|
+
waitUntil: 'domcontentloaded',
|
|
193
|
+
timeout: 15000,
|
|
194
|
+
});
|
|
195
|
+
await page.waitForTimeout(2000);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const linkedInMessages = new LinkedInMessages(page, browser);
|
|
200
|
+
|
|
201
|
+
spinner.text = 'Fetching messages from LinkedIn...';
|
|
202
|
+
|
|
203
|
+
const thread = await linkedInMessages.showThread(options.thread, parseInt(options.limit));
|
|
204
|
+
|
|
205
|
+
if (!thread) {
|
|
206
|
+
spinner.fail(`Thread ${options.thread} not found`);
|
|
207
|
+
process.exit(1);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
spinner.succeed(`Loaded ${thread.messages.length} messages`);
|
|
211
|
+
|
|
212
|
+
// Display thread based on output format
|
|
213
|
+
if (options.json) {
|
|
214
|
+
// JSON output for LLM processing
|
|
215
|
+
console.log(linkedInMessages.formatThreadJson(thread));
|
|
216
|
+
} else {
|
|
217
|
+
// Human-readable text output (Style A)
|
|
218
|
+
console.log('\n' + chalk.bold('='.repeat(60)));
|
|
219
|
+
console.log(
|
|
220
|
+
chalk.bold(`Conversations with ${thread.participants.map((p) => p.name).join(', ')}`)
|
|
221
|
+
);
|
|
222
|
+
console.log(chalk.gray(`Thread ID: ${thread.id}`));
|
|
223
|
+
console.log(chalk.bold('='.repeat(60)));
|
|
224
|
+
console.log(linkedInMessages.formatThreadText(thread));
|
|
225
|
+
console.log('\n' + chalk.bold('='.repeat(60)));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Log action
|
|
229
|
+
const logger = getAuditLogger();
|
|
230
|
+
logger.log(
|
|
231
|
+
'messages.show',
|
|
232
|
+
{ threadId: options.thread, messageCount: thread.messages.length, json: options.json },
|
|
233
|
+
true
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
await browser.close();
|
|
237
|
+
} catch (error) {
|
|
238
|
+
spinner.fail('Failed to load conversation');
|
|
239
|
+
console.error(chalk.red('Error:'), error instanceof Error ? error.message : error);
|
|
240
|
+
process.exit(1);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// Send new message to a profile
|
|
245
|
+
messages
|
|
246
|
+
.command('send')
|
|
247
|
+
.description('Send a new message to a LinkedIn profile')
|
|
248
|
+
.requiredOption('-u, --url <profile>', 'LinkedIn profile URL')
|
|
249
|
+
.requiredOption('-m, --message <text>', 'Message text to send')
|
|
250
|
+
.option('--dry-run', 'Simulate sending without actually sending', false)
|
|
251
|
+
.option('--headless', 'Run browser in headless mode', false)
|
|
252
|
+
.action(async (options) => {
|
|
253
|
+
const spinner = ora('Preparing to send message...').start();
|
|
254
|
+
|
|
255
|
+
try {
|
|
256
|
+
spinner.text = 'Launching browser...';
|
|
257
|
+
|
|
258
|
+
// Launch browser - will connect to existing Edge/Chrome via CDP if available
|
|
259
|
+
const config = getConfig();
|
|
260
|
+
const browser = new BrowserController({
|
|
261
|
+
headless: options.headless ?? config.getValue('headless'),
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await browser.launch();
|
|
265
|
+
|
|
266
|
+
// Get the page
|
|
267
|
+
const page = browser.getPage();
|
|
268
|
+
if (!page) throw new Error('Browser page not available');
|
|
269
|
+
|
|
270
|
+
// Check if user is on linkedin.cn (limited functionality, messaging won't work)
|
|
271
|
+
if (browser.isOnLinkedInCN()) {
|
|
272
|
+
spinner.fail('Detected linkedin.cn - messaging requires linkedin.com');
|
|
273
|
+
console.error(chalk.red('\n⚠️ Network Issue Detected: linkedin.cn'));
|
|
274
|
+
console.error(
|
|
275
|
+
chalk.yellow(
|
|
276
|
+
"\nlinkedin.cn (China's local LinkedIn) has limited functionality and does not support messaging via API."
|
|
277
|
+
)
|
|
278
|
+
);
|
|
279
|
+
console.error(
|
|
280
|
+
chalk.yellow(
|
|
281
|
+
'\nPlease enable your VPN or switch to a network that can access linkedin.com'
|
|
282
|
+
)
|
|
283
|
+
);
|
|
284
|
+
console.error(
|
|
285
|
+
chalk.gray('\nOnce your VPN is active, close this tab and run the command again.')
|
|
286
|
+
);
|
|
287
|
+
await browser.close();
|
|
288
|
+
process.exit(1);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
spinner.text = options.dryRun ? 'Simulating message send...' : 'Sending message...';
|
|
292
|
+
|
|
293
|
+
const sender = new LinkedInMessageSender(page);
|
|
294
|
+
|
|
295
|
+
const result = await sender.sendMessage({
|
|
296
|
+
profileUrl: options.url,
|
|
297
|
+
text: options.message,
|
|
298
|
+
dryRun: options.dryRun,
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
if (!result.success) {
|
|
302
|
+
spinner.fail(`Failed to send message: ${result.error}`);
|
|
303
|
+
await browser.close();
|
|
304
|
+
process.exit(1);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (options.dryRun) {
|
|
308
|
+
spinner.succeed('Dry run completed - message would have been sent');
|
|
309
|
+
console.log(chalk.gray('Profile URL: ' + options.url));
|
|
310
|
+
console.log(chalk.gray('Message content:'));
|
|
311
|
+
console.log(chalk.cyan(options.message));
|
|
312
|
+
if (result.threadId) {
|
|
313
|
+
console.log(chalk.gray(`Thread ID: ${result.threadId}`));
|
|
314
|
+
}
|
|
315
|
+
} else {
|
|
316
|
+
spinner.succeed('Message sent successfully!');
|
|
317
|
+
console.log(chalk.gray(`Thread ID: ${result.threadId}`));
|
|
318
|
+
if (result.messageId) {
|
|
319
|
+
console.log(chalk.gray(`Message ID: ${result.messageId}`));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Log action
|
|
324
|
+
const logger = getAuditLogger();
|
|
325
|
+
logger.log(
|
|
326
|
+
options.dryRun ? 'messages.send.dry-run' : 'messages.send',
|
|
327
|
+
{ profileUrl: options.url, messageLength: options.message.length },
|
|
328
|
+
true
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
await browser.close();
|
|
332
|
+
} catch (error) {
|
|
333
|
+
spinner.fail('Failed to send message');
|
|
334
|
+
console.error(chalk.red('Error:'), error instanceof Error ? error.message : error);
|
|
335
|
+
process.exit(1);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// src/cli/profile.test.ts
|
|
2
|
+
import { describe, it, expect } from 'vitest';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { registerProfileCommands } from './profile';
|
|
5
|
+
|
|
6
|
+
describe('registerProfileCommands', () => {
|
|
7
|
+
it('should register profile command', () => {
|
|
8
|
+
const program = new Command();
|
|
9
|
+
registerProfileCommands(program);
|
|
10
|
+
|
|
11
|
+
const commands = program.commands;
|
|
12
|
+
expect(commands.some((c) => c.name() === 'profile')).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// src/cli/profile.ts
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { BrowserController } from '../core/browser';
|
|
4
|
+
import { LinkedInProfile } from '../linkedin/profile';
|
|
5
|
+
import { getAuditLogger } from '../core/audit';
|
|
6
|
+
import type { ProfileData } from '../types';
|
|
7
|
+
|
|
8
|
+
const CDP_PORT = parseInt(process.env.PAGE_AGENT_CDP_PORT || '9222', 10);
|
|
9
|
+
|
|
10
|
+
export function registerProfileCommands(program: Command): void {
|
|
11
|
+
const profile = program.command('profile').description('LinkedIn profile commands');
|
|
12
|
+
|
|
13
|
+
profile
|
|
14
|
+
.command('get <url>')
|
|
15
|
+
.description('Extract profile data from a LinkedIn URL')
|
|
16
|
+
.option('--include-contact', 'Extract contact info (email, phone)')
|
|
17
|
+
.option('--json', 'Output as JSON')
|
|
18
|
+
.option('--no-cdp', 'Do not connect to existing browser via CDP')
|
|
19
|
+
.option('--cdp-port <port>', 'CDP port number', String(CDP_PORT))
|
|
20
|
+
.action(async (url: string, options) => {
|
|
21
|
+
const browser = new BrowserController();
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
// Initialize browser
|
|
25
|
+
await browser.launch();
|
|
26
|
+
|
|
27
|
+
const page = browser.getPage();
|
|
28
|
+
if (!page) {
|
|
29
|
+
console.error('Failed to get page from browser');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const extractor = new LinkedInProfile(page);
|
|
34
|
+
|
|
35
|
+
// Extract profile
|
|
36
|
+
const result = await extractor.extract({
|
|
37
|
+
profileUrl: url,
|
|
38
|
+
includeContact: options.includeContact,
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Log audit
|
|
42
|
+
getAuditLogger().log(
|
|
43
|
+
'profile_extract',
|
|
44
|
+
{
|
|
45
|
+
profileUrl: url,
|
|
46
|
+
includeContact: options.includeContact ?? false,
|
|
47
|
+
success: result.success,
|
|
48
|
+
extractedFields: result.data
|
|
49
|
+
? (Object.keys(result.data) as (keyof ProfileData)[]).filter(
|
|
50
|
+
(k) => result.data![k] !== null
|
|
51
|
+
)
|
|
52
|
+
: [],
|
|
53
|
+
},
|
|
54
|
+
result.success
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
if (result.success && result.data) {
|
|
58
|
+
if (options.json) {
|
|
59
|
+
console.log(JSON.stringify(result.data, null, 2));
|
|
60
|
+
} else {
|
|
61
|
+
printProfileOutput(result.data, options.includeContact);
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
console.error(`Error: ${result.error}`);
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
} finally {
|
|
68
|
+
await browser.close();
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Print profile data in human-readable format
|
|
75
|
+
*/
|
|
76
|
+
function printProfileOutput(data: ProfileData, includeContact?: boolean): void {
|
|
77
|
+
console.log(`\nProfile: ${data.full_name}`);
|
|
78
|
+
console.log('━'.repeat(50));
|
|
79
|
+
console.log(`Headline: ${data.headline || 'N/A'}`);
|
|
80
|
+
console.log(`Location: ${data.location || 'N/A'}`);
|
|
81
|
+
console.log(`Company: ${data.current_company ?? 'N/A'}`);
|
|
82
|
+
console.log(`Title: ${data.current_title ?? 'N/A'}`);
|
|
83
|
+
console.log(`Company URL: ${data.company_linkedin_url ?? 'N/A'}`);
|
|
84
|
+
console.log(
|
|
85
|
+
`Experience: ${data.years_experience !== null ? `${data.years_experience} years` : 'N/A'}`
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (includeContact) {
|
|
89
|
+
console.log(`Email: ${data.email ?? 'Not available'}`);
|
|
90
|
+
console.log(`Phone: ${data.phone ?? 'Not available'}`);
|
|
91
|
+
} else {
|
|
92
|
+
console.log(`Email: (use --include-contact to reveal)`);
|
|
93
|
+
console.log(`Phone: (use --include-contact to reveal)`);
|
|
94
|
+
}
|
|
95
|
+
}
|
package/src/cli/reply.ts
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import ora from 'ora';
|
|
4
|
+
import * as fs from 'fs';
|
|
5
|
+
import { BrowserController } from '../core/browser';
|
|
6
|
+
import { LinkedInReply } from '../linkedin/reply';
|
|
7
|
+
import { getSecureStorage } from '../core/storage';
|
|
8
|
+
import { getAuditLogger } from '../core/audit';
|
|
9
|
+
import { getConfig } from '../core/config';
|
|
10
|
+
|
|
11
|
+
export function registerReplyCommands(program: Command): void {
|
|
12
|
+
const reply = program.command('reply').description('Reply to a conversation thread');
|
|
13
|
+
|
|
14
|
+
reply
|
|
15
|
+
.command('send')
|
|
16
|
+
.description('Send a reply to a thread')
|
|
17
|
+
.requiredOption('-t, --thread <id>', 'Thread ID to reply to')
|
|
18
|
+
.option('-m, --message <text>', 'Message text to send')
|
|
19
|
+
.option('-f, --file <path>', 'Read message from file')
|
|
20
|
+
.option('--dry-run', 'Simulate sending without actually sending', false)
|
|
21
|
+
.option('--headless', 'Run browser in headless mode', true)
|
|
22
|
+
.action(async (options) => {
|
|
23
|
+
const spinner = ora('Preparing to send message...').start();
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
// Get message text
|
|
27
|
+
let messageText = options.message;
|
|
28
|
+
|
|
29
|
+
if (options.file) {
|
|
30
|
+
if (!fs.existsSync(options.file)) {
|
|
31
|
+
spinner.fail(`File not found: ${options.file}`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
messageText = fs.readFileSync(options.file, 'utf8');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!messageText || messageText.trim().length === 0) {
|
|
38
|
+
spinner.fail('Message text is required (use --message or --file)');
|
|
39
|
+
process.exit(1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
spinner.text = 'Checking authentication...';
|
|
43
|
+
|
|
44
|
+
// Check for existing session
|
|
45
|
+
const storage = getSecureStorage();
|
|
46
|
+
const sessionData = storage.load('linkedin-session');
|
|
47
|
+
|
|
48
|
+
if (!sessionData) {
|
|
49
|
+
spinner.fail('Not logged in. Run: linkedin-cli auth login');
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
spinner.text = 'Launching browser...';
|
|
54
|
+
|
|
55
|
+
// Launch browser and restore session
|
|
56
|
+
const config = getConfig();
|
|
57
|
+
const browser = new BrowserController({
|
|
58
|
+
headless: options.headless ?? config.getValue('headless'),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
await browser.launch();
|
|
62
|
+
const session = JSON.parse(sessionData);
|
|
63
|
+
await browser.restoreSession(session);
|
|
64
|
+
|
|
65
|
+
const page = browser.getPage();
|
|
66
|
+
if (!page) throw new Error('Browser page not available');
|
|
67
|
+
|
|
68
|
+
spinner.text = options.dryRun ? 'Simulating message send...' : 'Sending message...';
|
|
69
|
+
|
|
70
|
+
const linkedInReply = new LinkedInReply(page);
|
|
71
|
+
|
|
72
|
+
const result = await linkedInReply.sendMessage({
|
|
73
|
+
threadId: options.thread,
|
|
74
|
+
text: messageText,
|
|
75
|
+
dryRun: options.dryRun,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (!result.success) {
|
|
79
|
+
spinner.fail(`Failed to send message: ${result.error}`);
|
|
80
|
+
await browser.close();
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (options.dryRun) {
|
|
85
|
+
spinner.succeed('Dry run completed - message would have been sent');
|
|
86
|
+
console.log(chalk.gray('Message content:'));
|
|
87
|
+
console.log(chalk.cyan(messageText));
|
|
88
|
+
} else {
|
|
89
|
+
spinner.succeed('Message sent successfully!');
|
|
90
|
+
if (result.messageId) {
|
|
91
|
+
console.log(chalk.gray(`Message ID: ${result.messageId}`));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Log action
|
|
96
|
+
const logger = getAuditLogger();
|
|
97
|
+
logger.log(
|
|
98
|
+
options.dryRun ? 'reply.send.dry-run' : 'reply.send',
|
|
99
|
+
{ threadId: options.thread, messageLength: messageText.length },
|
|
100
|
+
true
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
await browser.close();
|
|
104
|
+
} catch (error) {
|
|
105
|
+
spinner.fail('Failed to send message');
|
|
106
|
+
console.error(chalk.red('Error:'), error instanceof Error ? error.message : error);
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'fs';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import * as os from 'os';
|
|
5
|
+
|
|
6
|
+
import { AuditLogger, getAuditLogger, type AuditEntry } from './audit';
|
|
7
|
+
|
|
8
|
+
describe('AuditLogger', () => {
|
|
9
|
+
let logger: AuditLogger;
|
|
10
|
+
const logPath = path.join(os.homedir(), '.linkedin-cli', 'audit-test.log');
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
// Clean up before test
|
|
14
|
+
if (fs.existsSync(logPath)) {
|
|
15
|
+
fs.rmSync(logPath, { force: true });
|
|
16
|
+
}
|
|
17
|
+
// Override log path for testing
|
|
18
|
+
logger = new AuditLogger();
|
|
19
|
+
(logger as any).logPath = logPath;
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
// Clean up after test
|
|
24
|
+
if (fs.existsSync(logPath)) {
|
|
25
|
+
fs.rmSync(logPath, { force: true });
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
describe('log', () => {
|
|
30
|
+
it('should create log file on first write', () => {
|
|
31
|
+
expect(fs.existsSync(logPath)).toBe(false);
|
|
32
|
+
|
|
33
|
+
logger.log('test.action', { key: 'value' }, true);
|
|
34
|
+
|
|
35
|
+
expect(fs.existsSync(logPath)).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('should append log entries', () => {
|
|
39
|
+
logger.log('action.one', {}, true);
|
|
40
|
+
logger.log('action.two', {}, false);
|
|
41
|
+
|
|
42
|
+
const content = fs.readFileSync(logPath, 'utf-8');
|
|
43
|
+
const lines = content
|
|
44
|
+
.trim()
|
|
45
|
+
.split('\n')
|
|
46
|
+
.filter((l) => l.trim());
|
|
47
|
+
expect(lines.length).toBe(2);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should log success entries correctly', () => {
|
|
51
|
+
logger.log('auth.login', { userId: '123' }, true);
|
|
52
|
+
|
|
53
|
+
const content = fs.readFileSync(logPath, 'utf-8');
|
|
54
|
+
const entry = JSON.parse(content.trim().split('\n')[0]) as AuditEntry;
|
|
55
|
+
|
|
56
|
+
expect(entry.action).toBe('auth.login');
|
|
57
|
+
expect(entry.success).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should log failure entries correctly', () => {
|
|
61
|
+
logger.log('auth.login', { userId: '123' }, false);
|
|
62
|
+
|
|
63
|
+
const content = fs.readFileSync(logPath, 'utf-8');
|
|
64
|
+
const entry = JSON.parse(content.trim().split('\n')[0]) as AuditEntry;
|
|
65
|
+
|
|
66
|
+
expect(entry.action).toBe('auth.login');
|
|
67
|
+
expect(entry.success).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should include timestamp', () => {
|
|
71
|
+
const beforeLog = Date.now();
|
|
72
|
+
logger.log('test.action', {}, true);
|
|
73
|
+
const afterLog = Date.now();
|
|
74
|
+
|
|
75
|
+
const content = fs.readFileSync(logPath, 'utf-8');
|
|
76
|
+
const entry = JSON.parse(content.trim().split('\n')[0]) as AuditEntry;
|
|
77
|
+
|
|
78
|
+
expect(entry.timestamp).toBeDefined();
|
|
79
|
+
const timestamp = new Date(entry.timestamp).getTime();
|
|
80
|
+
expect(timestamp).toBeGreaterThanOrEqual(beforeLog);
|
|
81
|
+
expect(timestamp).toBeLessThanOrEqual(afterLog);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('readRecent', () => {
|
|
86
|
+
it('should return empty array when no logs exist', () => {
|
|
87
|
+
const logs = logger.readRecent();
|
|
88
|
+
expect(logs).toEqual([]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should return recent logs in order', () => {
|
|
92
|
+
logger.log('action.first', {}, true);
|
|
93
|
+
logger.log('action.second', {}, true);
|
|
94
|
+
logger.log('action.third', {}, false);
|
|
95
|
+
|
|
96
|
+
const logs = logger.readRecent();
|
|
97
|
+
|
|
98
|
+
expect(logs.length).toBe(3);
|
|
99
|
+
expect(logs[0].action).toBe('action.first');
|
|
100
|
+
expect(logs[1].action).toBe('action.second');
|
|
101
|
+
expect(logs[2].action).toBe('action.third');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should respect count limit', () => {
|
|
105
|
+
for (let i = 0; i < 10; i++) {
|
|
106
|
+
logger.log(`action.${i}`, {}, true);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const logs = logger.readRecent(5);
|
|
110
|
+
expect(logs.length).toBe(5);
|
|
111
|
+
expect(logs[0].action).toBe('action.5'); // Last 5 entries
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('clear', () => {
|
|
116
|
+
it('should clear log file', () => {
|
|
117
|
+
logger.log('action.one', {}, true);
|
|
118
|
+
expect(fs.existsSync(logPath)).toBe(true);
|
|
119
|
+
|
|
120
|
+
logger.clear();
|
|
121
|
+
|
|
122
|
+
const content = fs.readFileSync(logPath, 'utf-8');
|
|
123
|
+
expect(content).toBe('');
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('getAuditLogger singleton', () => {
|
|
129
|
+
it('should return same instance on multiple calls', () => {
|
|
130
|
+
const logger1 = getAuditLogger();
|
|
131
|
+
const logger2 = getAuditLogger();
|
|
132
|
+
expect(logger1).toBe(logger2);
|
|
133
|
+
});
|
|
134
|
+
});
|