k-linkedin-mcp 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.gitlab-ci.yml CHANGED
@@ -1,15 +1,11 @@
1
1
  # linkedin-mcp — GitLab CI/CD Pipeline
2
- # Stages: test build image → deploy to homelab
2
+ # test: shared runner (bun image)
3
+ # deploy: homelab runner (runs directly on the server — no SSH needed)
3
4
 
4
5
  stages:
5
6
  - test
6
- - build
7
7
  - deploy
8
8
 
9
- variables:
10
- IMAGE_NAME: registry.gitlab.com/kpihx-labs/linkedin-mcp
11
- CONTAINER_NAME: linkedin_mcp
12
-
13
9
  # ── Test ──────────────────────────────────────────────────────────────────────
14
10
 
15
11
  test:
@@ -18,61 +14,48 @@ test:
18
14
  script:
19
15
  - bun install --frozen-lockfile
20
16
  - bun test
21
- coverage: '/\d+ pass/'
22
- artifacts:
23
- reports:
24
- junit: test-results.xml
25
- when: always
26
- expire_in: 7 days
27
17
  rules:
28
18
  - if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
29
19
  - if: '$CI_COMMIT_BRANCH == "main"'
30
20
  - if: '$CI_COMMIT_BRANCH == "develop"'
31
21
 
32
- # ── Build Docker image ────────────────────────────────────────────────────────
33
-
34
- build:
35
- stage: build
36
- image: docker:27
37
- services:
38
- - docker:27-dind
39
- before_script:
40
- - echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
41
- script:
42
- - docker build -t $IMAGE_NAME:$CI_COMMIT_SHORT_SHA -t $IMAGE_NAME:latest .
43
- - docker push $IMAGE_NAME:$CI_COMMIT_SHORT_SHA
44
- - docker push $IMAGE_NAME:latest
45
- rules:
46
- - if: '$CI_COMMIT_BRANCH == "main"'
47
-
48
22
  # ── Deploy to Homelab ─────────────────────────────────────────────────────────
49
23
 
50
24
  deploy:
51
25
  stage: deploy
52
- image: alpine:3.20
53
- before_script:
54
- - apk add --no-cache openssh-client
55
- - mkdir -p ~/.ssh
56
- - echo "$HOMELAB_SSH_KEY" > ~/.ssh/id_ed25519
57
- - chmod 600 ~/.ssh/id_ed25519
58
- - ssh-keyscan -H $HOMELAB_HOST >> ~/.ssh/known_hosts
26
+ tags:
27
+ - homelab
28
+ only:
29
+ - main
30
+ needs:
31
+ - test
59
32
  script:
33
+ - echo "Deploying linkedin-mcp to the homelab..."
34
+ - cd deploy
35
+ - echo "LINKEDIN_CLIENT_ID=$LINKEDIN_CLIENT_ID" > .env
36
+ - echo "LINKEDIN_CLIENT_SECRET=$LINKEDIN_CLIENT_SECRET" >> .env
37
+ - echo "TELEGRAM_LINKEDIN_HOMELAB_TOKEN=$TELEGRAM_LINKEDIN_HOMELAB_TOKEN" >> .env
38
+ - echo "TELEGRAM_CHAT_IDS=$TELEGRAM_CHAT_IDS" >> .env
39
+ - echo "LINKEDIN_MCP_HTTP_HOST=0.0.0.0" >> .env
40
+ - echo "LINKEDIN_MCP_HTTP_PORT=8095" >> .env
41
+ - echo "LINKEDIN_MCP_HTTP_PATH=/mcp" >> .env
42
+ - echo "LINKEDIN_MCP_PUBLIC_URL=https://linkedin.kpihx-labs.com" >> .env
43
+ - echo "LINKEDIN_STATE_DIR=/data/state" >> .env
44
+ - docker compose -p linkedin-mcp config -q
45
+ - docker rm -f linkedin-mcp || true
46
+ - docker compose -p linkedin-mcp up -d --build --remove-orphans
60
47
  - |
61
- ssh -i ~/.ssh/id_ed25519 kpihx@$HOMELAB_HOST "
62
- echo '$CI_REGISTRY_PASSWORD' | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
63
- docker pull $IMAGE_NAME:latest
64
- cd ~/stacks/linkedin-mcp && docker compose pull && docker compose up -d --remove-orphans
65
- docker image prune -f
66
- "
67
- environment:
68
- name: homelab
69
- url: https://linkedin.kpihx-labs.com
70
- rules:
71
- - if: '$CI_COMMIT_BRANCH == "main"'
72
- needs: [build]
73
-
74
- # ── Variables to define in GitLab CI/CD Settings → Variables ─────────────────
75
- # HOMELAB_SSH_KEY — private SSH key for homelab deploy user (masked, protected)
76
- # HOMELAB_HOST — homelab IP/hostname reachable from CI runner (masked)
77
- # CI_REGISTRY_USER — GitLab container registry user (auto)
78
- # CI_REGISTRY_PASSWORD — GitLab registry password / deploy token (masked)
48
+ for attempt in $(seq 1 20); do
49
+ status="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}starting{{end}}' linkedin-mcp 2>/dev/null || true)"
50
+ if [ "$status" = "healthy" ]; then
51
+ break
52
+ fi
53
+ if [ "$status" = "unhealthy" ]; then
54
+ docker logs --tail 200 linkedin-mcp
55
+ exit 1
56
+ fi
57
+ sleep 3
58
+ done
59
+ - docker inspect --format '{{.State.Health.Status}}' linkedin-mcp | grep -qx healthy
60
+ - docker exec linkedin-mcp bun -e "fetch('http://127.0.0.1:8095/health').then(r=>{if(!r.ok)process.exit(1);console.log('health OK')}).catch(()=>process.exit(1))"
61
+ - docker image prune -f
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ---
4
4
 
5
+ ## [0.2.0] — 2026-03-22
6
+
7
+ ### Added
8
+ - [x] Unified credential management — CLI, HTTP, Telegram: `client-id set/unset`, `client-secret set/unset`, `token set/unset`
9
+ - [x] Admin env file pattern (`LINKEDIN_ADMIN_ENV_FILE`, default `/data/linkedin-admin.env`) — persists credentials across restarts
10
+ - [x] `auth --port N` option to avoid `EADDRINUSE` (default port moved to 3001 via `config.json`)
11
+ - [x] OAuth callback port externalized to `config.json` (not hardcoded — DEFAULTS remain 3000 as last-resort fallback)
12
+ - [x] New HTTP POST routes: `/admin/{client-id,client-secret,token}/{set,unset}`
13
+ - [x] New Telegram commands: `/token_set`, `/token_unset`, `/client_id_set`, `/client_id_unset`, `/client_secret_set`, `/client_secret_unset`
14
+ - [x] Status table cleaned: shows `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`, `OAuth Token` — Telegram token removed
15
+ - [x] 61 tests (18 new) — all pass
16
+
17
+ ### Fixed
18
+ - [x] `TELEGRAM_LINKEDIN_TOKEN` → `TELEGRAM_LINKEDIN_HOMELAB_TOKEN` reference in HTTP status handler
19
+
5
20
  ## [0.1.0] — 2026-03-22
6
21
 
7
22
  ### Added
package/Dockerfile CHANGED
@@ -16,6 +16,7 @@ RUN chmod +x /app/src/main.js /app/src/admin.js /app/src/admin/cli.js \
16
16
 
17
17
  ENV NODE_ENV=production
18
18
  ENV LINKEDIN_STATE_DIR=/data/state
19
+ ENV LINKEDIN_ADMIN_ENV_FILE=/data/linkedin-admin.env
19
20
  ENV LINKEDIN_MCP_HTTP_HOST=0.0.0.0
20
21
  ENV LINKEDIN_MCP_HTTP_PORT=8095
21
22
  ENV LINKEDIN_MCP_HTTP_PATH=/mcp
package/PRIVACY.md ADDED
@@ -0,0 +1,36 @@
1
+ # Privacy Policy — linkedin-mcp
2
+
3
+ **Last updated: 2026-03-22**
4
+
5
+ ## Overview
6
+
7
+ `linkedin-mcp` is a personal tool used exclusively by its author (KpihX / Ivann KAMDEM).
8
+ It is not a public service and has no end-users other than the author.
9
+
10
+ ## Data collected
11
+
12
+ This application does **not** collect, store, or share any personal data from third parties.
13
+
14
+ The only data persisted is:
15
+
16
+ - **LinkedIn OAuth token** — stored locally on the author's own infrastructure (`~/.mcps/linkedin/token.json`), containing the access token, expiry date, and the author's own LinkedIn member ID, name, and email. This data is never transmitted to any third party.
17
+ - **Admin logs** — operational log lines (auth events, command invocations) stored locally in `~/.mcps/linkedin/linkedin-admin.log`. These logs are never transmitted externally.
18
+
19
+ ## LinkedIn API usage
20
+
21
+ This application uses the official LinkedIn OAuth 2.0 API with the following scopes:
22
+
23
+ - `openid` — identity verification
24
+ - `profile` — read own profile
25
+ - `email` — read own email
26
+ - `w_member_social` — post, like, and comment as the authenticated member
27
+
28
+ All API calls are made exclusively on behalf of the authenticated member (the author) to their own LinkedIn account.
29
+
30
+ ## Third-party services
31
+
32
+ No analytics, tracking, telemetry, or third-party SDKs are included.
33
+
34
+ ## Contact
35
+
36
+ For any questions: [GitHub](https://github.com/KpihX/linkedin-mcp)
package/config.json CHANGED
@@ -8,8 +8,8 @@
8
8
  "fallback_base_url": "https://linkedin.homelab"
9
9
  },
10
10
  "oauth": {
11
- "redirect_port": 3000,
12
- "redirect_uri": "http://localhost:3000/callback",
11
+ "redirect_port": 3001,
12
+ "redirect_uri": "http://localhost:3001/callback",
13
13
  "scopes": ["openid", "profile", "email", "w_member_social"]
14
14
  },
15
15
  "logging": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "k-linkedin-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "MCP server for LinkedIn — intent-first posting, profile management, and professional networking over stdio and HTTP.",
5
5
  "main": "src/main.js",
6
6
  "bin": {
package/src/.env.example CHANGED
@@ -7,8 +7,8 @@ LINKEDIN_CLIENT_SECRET=your_client_secret_here
7
7
  # LINKEDIN_ACCESS_TOKEN=your_access_token_here
8
8
 
9
9
  # Optional: Telegram admin bot
10
- # TELEGRAM_LINKEDIN_TOKEN=your_bot_token_here (from @BotFather)
11
- # TELEGRAM_CHAT_IDS=123456789,987654321 (comma-separated chat IDs)
10
+ # TELEGRAM_LINKEDIN_HOMELAB_TOKEN=your_bot_token_here (from @BotFather)
11
+ # TELEGRAM_CHAT_IDS=123456789,987654321 (comma-separated chat IDs)
12
12
 
13
13
  # Optional server overrides
14
14
  # LINKEDIN_MCP_HTTP_HOST=0.0.0.0
package/src/admin/cli.js CHANGED
@@ -3,37 +3,119 @@
3
3
  * linkedin-mcp — Admin CLI (linkedin-admin).
4
4
  *
5
5
  * Commands:
6
- * auth Start OAuth 2.0 flow (opens browser, saves token)
7
- * status [--json] Show token + member info
8
- * logout Clear stored token
9
- * logs [--lines N] Tail the admin log
10
- * health Show HTTP endpoint URLs
11
- * urls Show all public URLs
12
- * guide Show admin capabilities
6
+ * auth [--port N] Start OAuth 2.0 flow (opens browser, saves token)
7
+ * status [--json] Show credentials + token status table
8
+ * logout Clear stored token
9
+ * logs [--lines N] Tail the admin log
10
+ * health Show HTTP endpoint URLs
11
+ * urls Show all public URLs
12
+ * client-id set <value> Set LINKEDIN_CLIENT_ID in admin env file
13
+ * client-id unset Clear LINKEDIN_CLIENT_ID from admin env file
14
+ * client-secret set <v> Set LINKEDIN_CLIENT_SECRET in admin env file
15
+ * client-secret unset Clear LINKEDIN_CLIENT_SECRET from admin env file
16
+ * token set <value> Set access token directly (bypasses OAuth)
17
+ * token unset Clear stored access token (logout)
13
18
  */
14
19
 
15
20
  "use strict";
16
21
 
17
22
  const { Command } = require("commander");
18
23
 
19
- const { loadConfig } = require("../config");
20
- const { tokenSummary, clearToken } = require("../token");
21
- const { runOAuthFlow } = require("./oauth");
24
+ const { loadConfig, resolveEnv, ENV_VARS } = require("../config");
25
+ const { tokenSummary, clearToken } = require("../token");
26
+ const { runOAuthFlow } = require("./oauth");
22
27
  const {
28
+ adminEnvFilePath,
23
29
  adminHelpText,
30
+ appendAdminLog,
24
31
  getLogsText,
32
+ getSecretsStatus,
25
33
  healthSummaryText,
34
+ setAccessToken,
35
+ setClientId,
36
+ setClientSecret,
26
37
  statusSummaryText,
38
+ unsetAccessToken,
39
+ unsetClientId,
40
+ unsetClientSecret,
27
41
  urlsSummary,
28
- appendAdminLog,
29
42
  } = require("./service");
30
43
 
31
44
  const PKG = require("../../package.json");
32
45
 
46
+ // ── Simple Rich-style table renderer ─────────────────────────────────────────
47
+
48
+ function _pad(str, len) {
49
+ return String(str || "").padEnd(len);
50
+ }
51
+
52
+ function _printStatusTable(title, rows) {
53
+ // rows: [{ variable, status, masked, source }]
54
+ const cols = [
55
+ { key: "variable", label: "Variable", width: 36 },
56
+ { key: "status", label: "Status", width: 11 },
57
+ { key: "masked", label: "Value (masked)", width: 16 },
58
+ { key: "source", label: "Source", width: 24 },
59
+ ];
60
+ const tl = "╭", tr = "╮", bl = "╰", br = "╯";
61
+ const ml = "├", mr = "┤", cross = "┼";
62
+ const vl = "│";
63
+ const hl = "─";
64
+
65
+ const totalWidth = cols.reduce((s, c) => s + c.width + 3, 1);
66
+
67
+ // Top border
68
+ let top = tl;
69
+ cols.forEach((c, i) => {
70
+ top += hl.repeat(c.width + 2);
71
+ top += (i < cols.length - 1) ? "┬" : tr;
72
+ });
73
+
74
+ // Header row
75
+ let header = vl;
76
+ cols.forEach((c) => {
77
+ header += ` ${_pad(c.label, c.width)} ${vl}`;
78
+ });
79
+
80
+ // Middle separator
81
+ let mid = ml;
82
+ cols.forEach((c, i) => {
83
+ mid += hl.repeat(c.width + 2);
84
+ mid += (i < cols.length - 1) ? cross : mr;
85
+ });
86
+
87
+ // Bottom border
88
+ let bot = bl;
89
+ cols.forEach((c, i) => {
90
+ bot += hl.repeat(c.width + 2);
91
+ bot += (i < cols.length - 1) ? "┴" : br;
92
+ });
93
+
94
+ const titlePad = Math.floor((totalWidth - 2 - title.length) / 2);
95
+ console.log(" ".repeat(Math.max(0, titlePad)) + title);
96
+ console.log(top);
97
+ console.log(header);
98
+ console.log(mid);
99
+
100
+ rows.forEach((r, idx) => {
101
+ let row = vl;
102
+ row += ` ${_pad(r.variable, cols[0].width)} ${vl}`;
103
+ row += ` ${_pad(r.status, cols[1].width)} ${vl}`;
104
+ row += ` ${_pad(r.masked, cols[2].width)} ${vl}`;
105
+ row += ` ${_pad(r.source, cols[3].width)} ${vl}`;
106
+ console.log(row);
107
+ if (idx < rows.length - 1) console.log(mid);
108
+ });
109
+
110
+ console.log(bot);
111
+ }
112
+
113
+ // ── CLI program ───────────────────────────────────────────────────────────────
114
+
33
115
  const program = new Command();
34
116
  program
35
117
  .name("linkedin-admin")
36
- .description("linkedin-mcp administration: auth, status, logout, logs")
118
+ .description("linkedin-mcp administration: auth, status, logout, logs, credential management")
37
119
  .version(PKG.version);
38
120
 
39
121
  // ── auth ─────────────────────────────────────────────────────────────────────
@@ -41,7 +123,8 @@ program
41
123
  program
42
124
  .command("auth")
43
125
  .description("Start LinkedIn OAuth 2.0 flow. Opens a browser for authorization.")
44
- .action(async () => {
126
+ .option("--port <n>", "Local callback port (default 3001)", "3001")
127
+ .action(async (opts) => {
45
128
  const config = loadConfig();
46
129
  if (!config.oauth.client_id || !config.oauth.client_secret) {
47
130
  console.error(
@@ -49,13 +132,15 @@ program
49
132
  "Steps:\n" +
50
133
  " 1. Create app at https://developer.linkedin.com\n" +
51
134
  " 2. Add products: 'Sign In with LinkedIn using OpenID Connect' + 'Share on LinkedIn'\n" +
52
- " 3. Set redirect URI: http://localhost:3000/callback\n" +
53
- " 4. Store credentials via bw-env: LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET\n",
135
+ " 3. Set redirect URI: http://localhost:3001/callback\n" +
136
+ " 4. Store credentials via bw-env: LINKEDIN_CLIENT_ID, LINKEDIN_CLIENT_SECRET\n" +
137
+ " or: linkedin-admin client-id set <value>\n",
54
138
  );
55
139
  process.exit(1);
56
140
  }
141
+ const port = parseInt(opts.port, 10) || 3000;
57
142
  console.log("\nStarting LinkedIn OAuth flow...");
58
- console.log(`Redirect URI : ${config.oauth.redirect_uri}`);
143
+ console.log(`Redirect URI : http://localhost:${port}/callback`);
59
144
  console.log(`Scopes : ${config.oauth.scopes.join(", ")}\n`);
60
145
  try {
61
146
  const tokenData = await runOAuthFlow(config, (url) => {
@@ -63,7 +148,7 @@ program
63
148
  console.log(` ${url}\n`);
64
149
  const { execSync } = require("child_process");
65
150
  try { execSync(`xdg-open "${url}"`, { stdio: "ignore" }); } catch { /* silent */ }
66
- });
151
+ }, port);
67
152
  appendAdminLog(`auth success member=${tokenData.member_id} name=${tokenData.name}`);
68
153
  console.log(`\nAuthenticated: ${tokenData.name || "LinkedIn member"}`);
69
154
  console.log(`Email : ${tokenData.email || "N/A"}`);
@@ -80,16 +165,42 @@ program
80
165
 
81
166
  program
82
167
  .command("status")
83
- .description("Show current authentication status and token details.")
168
+ .description("Show current authentication status, credentials, and token details.")
84
169
  .option("--json", "Output raw JSON")
85
170
  .action((opts) => {
86
171
  const config = loadConfig();
87
172
  const summary = tokenSummary(config);
173
+ const secrets = getSecretsStatus();
174
+
88
175
  if (opts.json) {
89
- console.log(JSON.stringify(summary, null, 2));
176
+ console.log(JSON.stringify({ token: summary, credentials: secrets }, null, 2));
177
+ return;
178
+ }
179
+
180
+ console.log();
181
+ console.log(`Admin env file : ${adminEnvFilePath()}`);
182
+ console.log();
183
+
184
+ // Credentials table
185
+ const rows = secrets.map((s) => ({
186
+ variable: s.name,
187
+ status: s.present ? "✓ set" : "✗ missing",
188
+ masked: s.masked,
189
+ source: s.source,
190
+ }));
191
+ _printStatusTable("linkedin-admin status", rows);
192
+
193
+ // Token summary below table
194
+ console.log();
195
+ if (summary.valid) {
196
+ console.log(`Token : valid — ${summary.name} <${summary.email}> — ${summary.days_left} days left`);
197
+ console.log(`Expires : ${summary.expires_at}`);
198
+ } else if (summary.present) {
199
+ console.log("Token : present but EXPIRED — run 'linkedin-admin auth' or 'linkedin-admin token set <value>'");
90
200
  } else {
91
- console.log(`\n${statusSummaryText()}\n`);
201
+ console.log("Token : absent — run 'linkedin-admin auth' or 'linkedin-admin token set <value>'");
92
202
  }
203
+ console.log();
93
204
  });
94
205
 
95
206
  // ── logout ────────────────────────────────────────────────────────────────────
@@ -133,13 +244,70 @@ program
133
244
  console.log(`\n${urlsSummary()}\n`);
134
245
  });
135
246
 
136
- // ── guide ─────────────────────────────────────────────────────────────────────
247
+ // ── client-id ─────────────────────────────────────────────────────────────────
137
248
 
138
- program
139
- .command("guide")
140
- .description("Show full admin capabilities (CLI + HTTP + Telegram).")
249
+ const clientIdCmd = program
250
+ .command("client-id")
251
+ .description("Manage LINKEDIN_CLIENT_ID in the admin env file.");
252
+
253
+ clientIdCmd
254
+ .command("set <value>")
255
+ .description("Set LINKEDIN_CLIENT_ID.")
256
+ .action((value) => {
257
+ setClientId(value);
258
+ console.log("\nLINKEDIN_CLIENT_ID set successfully.\n");
259
+ });
260
+
261
+ clientIdCmd
262
+ .command("unset")
263
+ .description("Clear LINKEDIN_CLIENT_ID from the admin env file.")
264
+ .action(() => {
265
+ unsetClientId();
266
+ console.log("\nLINKEDIN_CLIENT_ID cleared.\n");
267
+ });
268
+
269
+ // ── client-secret ─────────────────────────────────────────────────────────────
270
+
271
+ const clientSecretCmd = program
272
+ .command("client-secret")
273
+ .description("Manage LINKEDIN_CLIENT_SECRET in the admin env file.");
274
+
275
+ clientSecretCmd
276
+ .command("set <value>")
277
+ .description("Set LINKEDIN_CLIENT_SECRET.")
278
+ .action((value) => {
279
+ setClientSecret(value);
280
+ console.log("\nLINKEDIN_CLIENT_SECRET set successfully.\n");
281
+ });
282
+
283
+ clientSecretCmd
284
+ .command("unset")
285
+ .description("Clear LINKEDIN_CLIENT_SECRET from the admin env file.")
286
+ .action(() => {
287
+ unsetClientSecret();
288
+ console.log("\nLINKEDIN_CLIENT_SECRET cleared.\n");
289
+ });
290
+
291
+ // ── token ─────────────────────────────────────────────────────────────────────
292
+
293
+ const tokenCmd = program
294
+ .command("token")
295
+ .description("Manage the OAuth access token directly.");
296
+
297
+ tokenCmd
298
+ .command("set <value>")
299
+ .description("Set access token directly (bypasses OAuth flow, 60-day default expiry).")
300
+ .action((value) => {
301
+ setAccessToken(value);
302
+ console.log("\nAccess token set successfully. linkedin-mcp is ready.\n");
303
+ });
304
+
305
+ tokenCmd
306
+ .command("unset")
307
+ .description("Clear the stored access token (same as logout).")
141
308
  .action(() => {
142
- console.log(`\n${adminHelpText()}\n`);
309
+ unsetAccessToken();
310
+ console.log("\nAccess token cleared. Run 'linkedin-admin auth' to re-authenticate.\n");
143
311
  });
144
312
 
145
313
  module.exports = { program };
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * linkedin-mcp — OAuth 2.0 flow manager.
3
3
  *
4
- * Launches a local HTTP server on redirect_port (default 3000) to catch
4
+ * Launches a local HTTP server on a configurable port (default 3000) to catch
5
5
  * the authorization code from LinkedIn, then exchanges it for an access token.
6
6
  *
7
7
  * Usage:
8
8
  * const { runOAuthFlow } = require("./oauth");
9
- * const tokenData = await runOAuthFlow(config);
9
+ * const tokenData = await runOAuthFlow(config, onUrl);
10
+ * // or with port override:
11
+ * const tokenData = await runOAuthFlow(config, onUrl, 8080);
10
12
  */
11
13
 
12
14
  "use strict";
@@ -21,11 +23,11 @@ const LI_TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken";
21
23
  /**
22
24
  * Build the LinkedIn OAuth authorization URL.
23
25
  */
24
- function buildAuthUrl(config, state) {
26
+ function buildAuthUrl(config, state, redirectUri) {
25
27
  const params = new URLSearchParams({
26
28
  response_type: "code",
27
29
  client_id: config.oauth.client_id,
28
- redirect_uri: config.oauth.redirect_uri,
30
+ redirect_uri: redirectUri,
29
31
  state,
30
32
  scope: config.oauth.scopes.join(" "),
31
33
  });
@@ -35,11 +37,11 @@ function buildAuthUrl(config, state) {
35
37
  /**
36
38
  * Exchange an authorization code for an access token.
37
39
  */
38
- async function exchangeCode(config, code) {
40
+ async function exchangeCode(config, code, redirectUri) {
39
41
  const body = new URLSearchParams({
40
42
  grant_type: "authorization_code",
41
43
  code,
42
- redirect_uri: config.oauth.redirect_uri,
44
+ redirect_uri: redirectUri,
43
45
  client_id: config.oauth.client_id,
44
46
  client_secret: config.oauth.client_secret,
45
47
  });
@@ -68,18 +70,19 @@ async function fetchProfile(accessToken) {
68
70
 
69
71
  /**
70
72
  * Run the full OAuth flow:
71
- * 1. Start local callback server
73
+ * 1. Start local callback server on the given port
72
74
  * 2. Return the auth URL for the user to open
73
75
  * 3. Wait for callback with code
74
76
  * 4. Exchange code → token
75
77
  * 5. Fetch profile → enrich token
76
78
  * 6. Persist token to disk
77
79
  *
78
- * @param {object} config
79
- * @param {function} onUrl - Called with the auth URL string so caller can print/open it
80
+ * @param {object} config
81
+ * @param {function} onUrl - Called with the auth URL string so caller can print/open it
82
+ * @param {number} [portOverride] - Override the callback port (default: config.oauth.redirect_port || 3000)
80
83
  * @returns {Promise<object>} tokenData
81
84
  */
82
- function runOAuthFlow(config, onUrl) {
85
+ function runOAuthFlow(config, onUrl, portOverride) {
83
86
  return new Promise((resolve, reject) => {
84
87
  if (!config.oauth.client_id || !config.oauth.client_secret) {
85
88
  return reject(new Error(
@@ -88,9 +91,10 @@ function runOAuthFlow(config, onUrl) {
88
91
  ));
89
92
  }
90
93
 
91
- const state = crypto.randomBytes(16).toString("hex");
92
- const port = config.oauth.redirect_port || 3000;
93
- const authUrl = buildAuthUrl(config, state);
94
+ const port = portOverride || config.oauth.redirect_port || 3000;
95
+ const redirectUri = `http://localhost:${port}/callback`;
96
+ const state = crypto.randomBytes(16).toString("hex");
97
+ const authUrl = buildAuthUrl(config, state, redirectUri);
94
98
 
95
99
  const server = http.createServer(async (req, res) => {
96
100
  const url = new URL(req.url, `http://localhost:${port}`);
@@ -125,7 +129,7 @@ function runOAuthFlow(config, onUrl) {
125
129
  }
126
130
 
127
131
  try {
128
- const tokenData = await exchangeCode(config, code);
132
+ const tokenData = await exchangeCode(config, code, redirectUri);
129
133
  const profile = await fetchProfile(tokenData.access_token);
130
134
  const enriched = {
131
135
  ...tokenData,