diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c7f43d3b..14c27791 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: permissions: contents: write + id-token: write concurrency: group: release @@ -82,12 +83,20 @@ jobs: --mode "$RELEASE_MODE" --notes-file "${{ steps.release.outputs.release_notes_path }}" + - name: Stamp server.json version + if: steps.release.outputs.skip != 'true' + run: | + VERSION="${{ steps.release.outputs.version }}" + jq --arg v "$VERSION" \ + '.version = $v | .packages[0].version = $v' \ + server.json > server.tmp && mv server.tmp server.json + - name: Create release commit and tag if: steps.release.outputs.skip != 'true' run: | git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add package.json package-lock.json packages/core/package.json packages/cli/package.json packages/mcp/package.json + git add package.json package-lock.json packages/core/package.json packages/cli/package.json packages/mcp/package.json server.json git commit -m "chore(release): publish ${{ steps.release.outputs.version }} [skip ci]" git tag -a "${{ steps.release.outputs.tag }}" -m "Release ${{ steps.release.outputs.tag }}" @@ -123,3 +132,23 @@ jobs: make_latest: true name: ${{ steps.release.outputs.tag }} tag_name: ${{ steps.release.outputs.tag }} + + - name: Publish to MCP Registry + if: steps.release.outputs.skip != 'true' + continue-on-error: true + run: | + curl -fsSL "https://github.com/modelcontextprotocol/registry/releases/latest/download/mcp-publisher_linux_amd64.tar.gz" \ + | tar xz mcp-publisher + ./mcp-publisher login github-oidc + ./mcp-publisher publish + + - name: Notify Smithery + if: steps.release.outputs.skip != 'true' && env.SMITHERY_API_KEY != '' + env: + SMITHERY_API_KEY: ${{ secrets.SMITHERY_API_KEY }} + continue-on-error: true + run: | + curl -sf -X POST "https://api.smithery.ai/servers/linkedin-buddy/releases" \ + -H "Authorization: Bearer $SMITHERY_API_KEY" \ + -H "Content-Type: application/json" \ + -d '{"type":"repo","gitUrl":"https://github.com/${{ github.repository }}","ref":"refs/tags/${{ steps.release.outputs.tag }}"}' diff --git a/.mcpbignore b/.mcpbignore new file mode 100644 index 00000000..5f0e72aa --- /dev/null +++ b/.mcpbignore @@ -0,0 +1,16 @@ +*.test.ts +__tests__/ +src/ +docs/ +scripts/ +.github/ +.worktrees/ +node_modules/ +*.md +!README.md +.env* +*.sqlite +coverage/ +.eslint* +tsconfig*.json +vitest*.ts diff --git a/.opencode/todo.md b/.opencode/todo.md new file mode 100644 index 00000000..0d3143be --- /dev/null +++ b/.opencode/todo.md @@ -0,0 +1,6 @@ +# Mission Tasks + +## Task List + +[ ] *Start your mission by creating a task list + diff --git a/glama.json b/glama.json new file mode 100644 index 00000000..f1aa6927 --- /dev/null +++ b/glama.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://glama.ai/mcp/schemas/server.json", + "maintainers": ["sigvardt"] +} diff --git a/packages/cli/src/bin/linkedin.ts b/packages/cli/src/bin/linkedin.ts index 222c16ca..fcbe4e2b 100644 --- a/packages/cli/src/bin/linkedin.ts +++ b/packages/cli/src/bin/linkedin.ts @@ -36,11 +36,14 @@ import { importSessionState, buildFeedbackHintMessage, clearRateLimitState, + checkForUpdate, createLocalDataDeletionPlan, createEmptyFixtureManifest, createFeedbackTechnicalContext, buildFixtureRouteKey, buildLinkedInImagePersonaFromProfileSeed, + buildUpdateCommand, + detectInstallMethod, evaluateDraftQuality, FEEDBACK_TYPES, formatFeedbackDisplayPath, @@ -95,6 +98,7 @@ import { resolveLegacyRateLimitStateFilePath, resolvePrivacyConfig, resolveSchedulerConfig, + resolveUpdateCheckConfig, runLinkedInWriteValidation, runReadOnlyLinkedInLiveValidation, submitFeedback, @@ -127,6 +131,7 @@ import { type SearchResult, type SelectorAuditInput, type SelectorAuditReport, + type UpdateCheckResult, type WebhookDeliveryAttemptStatus, type WebhookSubscriptionStatus, type FeedbackType, @@ -251,6 +256,7 @@ const TOTAL_WRITE_VALIDATION_ACTIONS = LINKEDIN_WRITE_VALIDATION_ACTIONS.length; let cliEvasionEnabled = true; let cliEvasionLevel: string | undefined; let cliSelectorLocale: string | undefined; +let readUpdateCheckEnabled = (): boolean => true; let activeCliInvocation: | { commandName: string; @@ -1654,6 +1660,34 @@ async function maybeEmitCliFeedbackHint(error?: unknown): Promise { } } +async function maybeShowUpdateNotification(): Promise { + try { + if (!readUpdateCheckEnabled()) { + return; + } + + const config = resolveUpdateCheckConfig({ timeoutMs: 2_000 }); + if (!config.enabled) { + return; + } + + const result: UpdateCheckResult = await checkForUpdate( + config, + packageJson.version, + ); + if (!result.updateAvailable) { + return; + } + + process.stderr.write( + `\n Update available: ${result.currentVersion} → ${result.latestVersion}\n` + + ` Run \`${result.updateCommand}\` to update.\n\n`, + ); + } catch { + return; + } +} + async function pathExists(targetPath: string): Promise { try { await access(targetPath); @@ -9596,6 +9630,10 @@ export function createCliProgram(): Command { .name("linkedin") .description("LinkedIn Buddy CLI") .version(packageJson.version) + .option( + "--no-update-check", + "Disable automatic update check for this command", + ) .option( "--cdp-url ", "Connect to existing browser via CDP endpoint (e.g., http://127.0.0.1:18800)", @@ -9635,6 +9673,11 @@ export function createCliProgram(): Command { : undefined; }; + readUpdateCheckEnabled = function readUpdateCheckEnabled(): boolean { + const options = program.opts<{ updateCheck?: boolean }>(); + return options.updateCheck !== false; + }; + const readSelectorLocale = (): string | undefined => { const options = program.opts<{ selectorLocale?: string }>(); return typeof options.selectorLocale === "string" && @@ -9669,6 +9712,7 @@ export function createCliProgram(): Command { program.hook("postAction", async () => { await maybeEmitCliFeedbackHint(); + await maybeShowUpdateNotification(); }); program @@ -13402,6 +13446,54 @@ export function createCliProgram(): Command { } }); + program + .command("update") + .description("Check for updates and optionally install the latest version") + .option("--check-only", "Only check for updates without installing", false) + .action(async (options: { checkOnly: boolean }) => { + const config = resolveUpdateCheckConfig({ cacheTtlMs: 0, timeoutMs: 10_000 }); + const result: UpdateCheckResult = await checkForUpdate( + { ...config, enabled: true }, + packageJson.version, + ); + + if (options.checkOnly) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(`Current version: ${result.currentVersion}`); + console.log(`Latest version: ${result.latestVersion}`); + + if (!result.updateAvailable) { + console.log("\nYou are already on the latest version."); + return; + } + + console.log(`\nUpdate available: ${result.currentVersion} → ${result.latestVersion}`); + + const installMethod = detectInstallMethod(); + const updateCommand = buildUpdateCommand(installMethod); + + if (installMethod === "npx") { + console.log(`\nYou are running via npx, which always fetches the latest version.`); + console.log(`Simply restart your command to use the latest version.`); + return; + } + + console.log(`\nRunning: ${updateCommand}`); + + const { execSync } = await import("node:child_process"); + try { + execSync(updateCommand, { stdio: "inherit" }); + console.log(`\nSuccessfully updated to ${result.latestVersion}.`); + } catch { + console.error(`\nAutomatic update failed. Run the following command manually:`); + console.error(` ${updateCommand}`); + process.exitCode = 1; + } + }); + return program; } @@ -13422,6 +13514,7 @@ export async function runCli(argv: string[] = process.argv): Promise { cliEvasionEnabled = true; cliEvasionLevel = undefined; cliSelectorLocale = undefined; + readUpdateCheckEnabled = (): boolean => true; process.argv = originalArgv; } } diff --git a/packages/core/src/__tests__/e2e/helpers.ts b/packages/core/src/__tests__/e2e/helpers.ts index 395fc161..70eeb901 100644 --- a/packages/core/src/__tests__/e2e/helpers.ts +++ b/packages/core/src/__tests__/e2e/helpers.ts @@ -116,6 +116,7 @@ import { LINKEDIN_PRIVACY_PREPARE_UPDATE_SETTING_TOOL, LINKEDIN_SEARCH_TOOL, LINKEDIN_SESSION_HEALTH_TOOL, + LINKEDIN_UPDATE_CHECK_TOOL, LINKEDIN_SESSION_OPEN_LOGIN_TOOL, LINKEDIN_SESSION_STATUS_TOOL, SUBMIT_FEEDBACK_TOOL @@ -987,6 +988,7 @@ export async function getCliCoverageFixtures(runtime: CoreRuntime): Promise { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + process.argv = [...ORIGINAL_ARGV]; + + if (server) { + await new Promise((resolve, reject) => { + server?.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + server = null; + } +}); + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +async function startServer( + handler: ( + request: IncomingMessage, + response: ServerResponse + ) => void +): Promise { + server = createServer(handler); + await new Promise((resolve) => { + server?.listen(0, "127.0.0.1", () => resolve()); + }); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Expected an AddressInfo object."); + } + return `http://127.0.0.1:${address.port}`; +} + +function stubNpmFetch(baseUrl: string): void { + const originalFetch = globalThis.fetch; + vi.stubGlobal("fetch", (input: string | URL | Request, init?: Record) => { + const sourceUrl = + input instanceof Request + ? input.url + : input instanceof URL + ? input.toString() + : input; + if (sourceUrl.startsWith(NPM_REGISTRY_BASE_URL)) { + const redirectedUrl = `${baseUrl}${sourceUrl.slice(NPM_REGISTRY_BASE_URL.length)}`; + return originalFetch(redirectedUrl, init); + } + return originalFetch(input, init); + }); +} + +describe("isNewerVersion", () => { + it("handles calver and suffix comparisons", () => { + expect(isNewerVersion("2025.3.17", "2025.3.18")).toBe(true); + expect(isNewerVersion("2025.3.17", "2025.4.1")).toBe(true); + expect(isNewerVersion("2025.3.17", "2025.3.17")).toBe(false); + expect(isNewerVersion("2025.3.18", "2025.3.17")).toBe(false); + expect(isNewerVersion("0.1.0", "2025.3.17")).toBe(true); + expect(isNewerVersion("2025.3.17", "2025.3.17-1")).toBe(true); + expect(isNewerVersion("2025.3.17-1", "2025.3.17-2")).toBe(true); + expect(isNewerVersion("2025.3.17-2", "2025.3.17-1")).toBe(false); + }); +}); + +describe("fetchLatestVersion", () => { + it("returns the version string for successful responses", async () => { + const baseUrl = await startServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end('{"name":"pkg","version":"2025.3.18"}'); + }); + stubNpmFetch(baseUrl); + + await expect(fetchLatestVersion("pkg", 5_000)).resolves.toBe("2025.3.18"); + }); + + it("throws NETWORK_ERROR on non-200 responses", async () => { + const baseUrl = await startServer((_request, response) => { + response.writeHead(503, { "content-type": "application/json" }); + response.end('{"error":"service unavailable"}'); + }); + stubNpmFetch(baseUrl); + + await expect(fetchLatestVersion("pkg", 5_000)).rejects.toMatchObject({ + code: "NETWORK_ERROR" + }); + }); + + it("throws TIMEOUT when request exceeds timeout", async () => { + const baseUrl = await startServer(() => { + return; + }); + stubNpmFetch(baseUrl); + + await expect(fetchLatestVersion("pkg", 100)).rejects.toMatchObject({ + code: "TIMEOUT" + }); + }); + + it("throws NETWORK_ERROR for invalid JSON payloads", async () => { + const baseUrl = await startServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end("not-json"); + }); + stubNpmFetch(baseUrl); + + await expect(fetchLatestVersion("pkg", 5_000)).rejects.toBeInstanceOf( + LinkedInBuddyError + ); + await expect(fetchLatestVersion("pkg", 5_000)).rejects.toMatchObject({ + code: "NETWORK_ERROR" + }); + }); +}); + +describe("cache roundtrip", () => { + it("writes and reads cache entries", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "update-check-cache-")); + const cachePath = path.join(tempDir, "cache.json"); + + writeUpdateCheckCache(cachePath, { + latestVersion: "2025.3.18", + checkedAt: "2026-01-01T00:00:00.000Z" + }); + + expect(readUpdateCheckCache(cachePath)).toEqual({ + latestVersion: "2025.3.18", + checkedAt: "2026-01-01T00:00:00.000Z" + }); + }); + + it("returns null for missing cache file", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "update-check-cache-")); + const cachePath = path.join(tempDir, "missing.json"); + + expect(readUpdateCheckCache(cachePath)).toBeNull(); + }); + + it("returns null for corrupt cache JSON", () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "update-check-cache-")); + const cachePath = path.join(tempDir, "cache.json"); + writeFileSync(cachePath, "{ not valid json", "utf8"); + + expect(readUpdateCheckCache(cachePath)).toBeNull(); + }); +}); + +describe("checkForUpdate", () => { + it("returns cached result when cache is fresh", async () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "update-check-cache-")); + const cachePath = path.join(tempDir, "update-check.json"); + writeUpdateCheckCache(cachePath, { + latestVersion: "2025.3.18", + checkedAt: new Date().toISOString() + }); + + const fetchSpy = vi.spyOn(globalThis, "fetch"); + const result = await checkForUpdate( + { + enabled: true, + cacheTtlMs: DEFAULT_UPDATE_CHECK_CACHE_TTL_MS, + timeoutMs: 5_000, + cacheFilePath: cachePath + }, + "2025.3.17" + ); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + cached: true, + updateAvailable: true, + latestVersion: "2025.3.18" + }); + }); + + it("fetches latest version when cache is stale", async () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "update-check-cache-")); + const cachePath = path.join(tempDir, "update-check.json"); + writeUpdateCheckCache(cachePath, { + latestVersion: "2025.3.16", + checkedAt: "2000-01-01T00:00:00.000Z" + }); + + const baseUrl = await startServer((_request, response) => { + response.writeHead(200, { "content-type": "application/json" }); + response.end('{"name":"pkg","version":"2025.3.20"}'); + }); + stubNpmFetch(baseUrl); + + const result = await checkForUpdate( + { + enabled: true, + cacheTtlMs: 1, + timeoutMs: 5_000, + cacheFilePath: cachePath + }, + "2025.3.17" + ); + + expect(result).toMatchObject({ + cached: false, + updateAvailable: true, + latestVersion: "2025.3.20" + }); + }); + + it("returns updateAvailable false on fetch error", async () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "update-check-cache-")); + const cachePath = path.join(tempDir, "update-check.json"); + + vi.stubGlobal("fetch", () => Promise.reject(new Error("unreachable"))); + + const result = await checkForUpdate( + { + enabled: true, + cacheTtlMs: 1, + timeoutMs: 100, + cacheFilePath: cachePath + }, + "2025.3.17" + ); + + expect(result).toMatchObject({ + updateAvailable: false, + latestVersion: "2025.3.17", + updateCommand: "" + }); + }); + + it("returns updateAvailable false when disabled", async () => { + const tempDir = mkdtempSync(path.join(os.tmpdir(), "update-check-cache-")); + const cachePath = path.join(tempDir, "update-check.json"); + const result = await checkForUpdate( + { + enabled: false, + cacheTtlMs: 1, + timeoutMs: 100, + cacheFilePath: cachePath + }, + "2025.3.17" + ); + + expect(result).toMatchObject({ + updateAvailable: false, + latestVersion: "2025.3.17", + updateCommand: "", + cached: false + }); + }); +}); + +describe("resolveUpdateCheckConfig", () => { + it("uses defaults with update checks enabled", () => { + vi.stubEnv("NODE_ENV", "production"); + vi.stubEnv("CI", ""); + const config = resolveUpdateCheckConfig(); + expect(config.enabled).toBe(true); + expect(config.cacheTtlMs).toBe(DEFAULT_UPDATE_CHECK_CACHE_TTL_MS); + expect(config.cacheFilePath.endsWith("update-check.json")).toBe(true); + }); + + it("disables update checks from env flag", () => { + vi.stubEnv(LINKEDIN_BUDDY_UPDATE_CHECK_ENV, "false"); + + expect(resolveUpdateCheckConfig().enabled).toBe(false); + }); + + it("disables update checks in CI", () => { + vi.stubEnv("CI", "true"); + + expect(resolveUpdateCheckConfig().enabled).toBe(false); + }); + + it("disables update checks in NODE_ENV=test", () => { + vi.stubEnv("NODE_ENV", "test"); + + expect(resolveUpdateCheckConfig().enabled).toBe(false); + }); + + it("honors explicit option override", () => { + expect(resolveUpdateCheckConfig({ enabled: false }).enabled).toBe(false); + }); +}); + +describe("detectInstallMethod", () => { + it("returns npx when npm user agent indicates npx", () => { + vi.stubEnv("npm_config_user_agent", "npx/10.5.0 node/v22"); + + expect(detectInstallMethod()).toBe("npx"); + }); + + it("returns global-npm when argv points at global node_modules", () => { + vi.stubEnv("npm_config_user_agent", "npm/10.5.0 node/v22"); + process.argv = [ + "node", + "/usr/local/lib/node_modules/@linkedin-buddy/cli/dist/bin/linkedin.js" + ]; + + expect(detectInstallMethod()).toBe("global-npm"); + }); +}); + +describe("buildUpdateCommand", () => { + it("returns the expected command for each install method", () => { + expect(buildUpdateCommand("global-npm")).toBe( + "npm install -g @linkedin-buddy/cli@latest" + ); + expect(buildUpdateCommand("npx")).toBe( + "npx @linkedin-buddy/cli@latest (always runs latest)" + ); + expect(buildUpdateCommand("local-npm")).toBe( + "npm install @linkedin-buddy/cli@latest" + ); + expect(buildUpdateCommand("unknown")).toBe( + "npm install -g @linkedin-buddy/cli@latest" + ); + }); +}); + +describe("constants", () => { + it("keeps package name stable", () => { + expect(UPDATE_CHECK_PACKAGE_NAME).toBe("@linkedin-buddy/cli"); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4d1de82a..e6b567b8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -66,6 +66,7 @@ export * from "./selectorLocale.js"; export * from "./shared.js"; export * from "./stealth.js"; export * from "./writeValidation.js"; +export * from "./twoPhaseCommit.js"; +export * from "./updateCheck.js"; export * from "./webhookDelivery.js"; export * from "./writeValidationAccounts.js"; -export * from "./twoPhaseCommit.js"; diff --git a/packages/core/src/updateCheck.ts b/packages/core/src/updateCheck.ts new file mode 100644 index 00000000..2be8c57e --- /dev/null +++ b/packages/core/src/updateCheck.ts @@ -0,0 +1,304 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { DEFAULT_LINKEDIN_BUDDY_HOME } from "./config.js"; +import { LinkedInBuddyError } from "./errors.js"; + +export const LINKEDIN_BUDDY_UPDATE_CHECK_ENV = "LINKEDIN_BUDDY_UPDATE_CHECK"; +export const DEFAULT_UPDATE_CHECK_CACHE_TTL_MS = 24 * 60 * 60 * 1000; +export const DEFAULT_UPDATE_CHECK_TIMEOUT_MS = 5_000; +export const UPDATE_CHECK_CACHE_FILENAME = "update-check.json"; +export const NPM_REGISTRY_BASE_URL = "https://registry.npmjs.org"; +export const UPDATE_CHECK_PACKAGE_NAME = "@linkedin-buddy/cli"; + +export interface UpdateCheckConfig { + enabled: boolean; + cacheTtlMs: number; + timeoutMs: number; + cacheFilePath: string; +} + +export interface UpdateCheckResult { + updateAvailable: boolean; + currentVersion: string; + latestVersion: string; + updateCommand: string; + checkedAt: string; + cached: boolean; +} + +interface UpdateCheckCache { + latestVersion: string; + checkedAt: string; +} + +export type InstallMethod = "global-npm" | "npx" | "local-npm" | "unknown"; + +function parseBoolean(value: string | undefined, fallback: boolean): boolean { + if (typeof value !== "string") { + return fallback; + } + + const normalized = value.trim().toLowerCase(); + if (["1", "true", "yes", "on"].includes(normalized)) { + return true; + } + + if (["0", "false", "no", "off"].includes(normalized)) { + return false; + } + + return fallback; +} + +function normalizeNumericTuple(version: string): number[] { + return version + .split(".") + .flatMap((segment) => + segment.split("-").map((part) => { + const parsed = Number.parseInt(part, 10); + return Number.isFinite(parsed) ? parsed : 0; + }) + ); +} + +function createResult( + currentVersion: string, + latestVersion: string, + checkedAt: string, + cached: boolean +): UpdateCheckResult { + const updateAvailable = isNewerVersion(currentVersion, latestVersion); + return { + updateAvailable, + currentVersion, + latestVersion, + updateCommand: updateAvailable + ? buildUpdateCommand(detectInstallMethod()) + : "", + checkedAt, + cached + }; +} + +export function resolveUpdateCheckConfig( + options: Partial = {} +): UpdateCheckConfig { + const enabledFromSource = + typeof options.enabled === "boolean" + ? options.enabled + : parseBoolean(process.env[LINKEDIN_BUDDY_UPDATE_CHECK_ENV], true); + + const enabled = + enabledFromSource && + !parseBoolean(process.env.CI, false) && + process.env.NODE_ENV !== "test"; + + return { + enabled, + cacheTtlMs: + typeof options.cacheTtlMs === "number" && options.cacheTtlMs > 0 + ? options.cacheTtlMs + : DEFAULT_UPDATE_CHECK_CACHE_TTL_MS, + timeoutMs: + typeof options.timeoutMs === "number" && options.timeoutMs > 0 + ? options.timeoutMs + : DEFAULT_UPDATE_CHECK_TIMEOUT_MS, + cacheFilePath: + typeof options.cacheFilePath === "string" && options.cacheFilePath.length > 0 + ? options.cacheFilePath + : path.join(DEFAULT_LINKEDIN_BUDDY_HOME, UPDATE_CHECK_CACHE_FILENAME) + }; +} + +export async function checkForUpdate( + config: UpdateCheckConfig, + currentVersion: string +): Promise { + const checkedAt = new Date().toISOString(); + + if (!config.enabled) { + return { + updateAvailable: false, + currentVersion, + latestVersion: currentVersion, + updateCommand: "", + checkedAt, + cached: false + }; + } + + try { + const cache = readUpdateCheckCache(config.cacheFilePath); + if (cache) { + const ageMs = Date.now() - Date.parse(cache.checkedAt); + if (Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= config.cacheTtlMs) { + return createResult(currentVersion, cache.latestVersion, cache.checkedAt, true); + } + } + + const latestVersion = await fetchLatestVersion( + UPDATE_CHECK_PACKAGE_NAME, + config.timeoutMs + ); + const nextCheckedAt = new Date().toISOString(); + writeUpdateCheckCache(config.cacheFilePath, { + latestVersion, + checkedAt: nextCheckedAt + }); + return createResult(currentVersion, latestVersion, nextCheckedAt, false); + } catch { + return { + updateAvailable: false, + currentVersion, + latestVersion: currentVersion, + updateCommand: "", + checkedAt, + cached: false + }; + } +} + +export async function fetchLatestVersion( + packageName: string, + timeoutMs: number +): Promise { + let response: Response; + try { + response = await fetch( + `${NPM_REGISTRY_BASE_URL}/${encodeURIComponent(packageName)}/latest`, + { + headers: { + accept: "application/json" + }, + signal: AbortSignal.timeout(timeoutMs) + } + ); + } catch (error) { + if (error instanceof Error && /aborted|timeout/i.test(error.name)) { + throw new LinkedInBuddyError("TIMEOUT", "Update check timed out.", { + packageName, + timeoutMs + }); + } + + throw new LinkedInBuddyError("NETWORK_ERROR", "Failed to reach npm registry.", { + packageName + }); + } + + if (!response.ok) { + throw new LinkedInBuddyError( + "NETWORK_ERROR", + `npm registry returned HTTP ${response.status}.`, + { + packageName, + status: response.status + } + ); + } + + try { + const payload = (await response.json()) as { version?: unknown }; + if (typeof payload.version !== "string") { + throw new Error("Invalid version payload."); + } + return payload.version; + } catch (error) { + throw new LinkedInBuddyError( + "NETWORK_ERROR", + "Invalid npm registry response for latest package metadata.", + { + packageName, + cause: error instanceof Error ? error.message : String(error) + } + ); + } +} + +export function readUpdateCheckCache(filePath: string): UpdateCheckCache | null { + try { + const parsed = JSON.parse(readFileSync(filePath, "utf8")) as { + latestVersion?: unknown; + checkedAt?: unknown; + }; + if ( + typeof parsed.latestVersion !== "string" || + typeof parsed.checkedAt !== "string" + ) { + return null; + } + return { + latestVersion: parsed.latestVersion, + checkedAt: parsed.checkedAt + }; + } catch { + return null; + } +} + +export function writeUpdateCheckCache( + filePath: string, + cache: UpdateCheckCache +): void { + try { + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, JSON.stringify(cache, null, 2), "utf8"); + } catch { + return; + } +} + +export function isNewerVersion(current: string, latest: string): boolean { + const currentParts = normalizeNumericTuple(current); + const latestParts = normalizeNumericTuple(latest); + const maxLength = Math.max(currentParts.length, latestParts.length); + + for (let index = 0; index < maxLength; index += 1) { + const currentValue = currentParts[index] ?? 0; + const latestValue = latestParts[index] ?? 0; + if (latestValue > currentValue) { + return true; + } + if (latestValue < currentValue) { + return false; + } + } + + return false; +} + +export function detectInstallMethod(): InstallMethod { + const userAgent = process.env.npm_config_user_agent?.toLowerCase() ?? ""; + if (userAgent.includes("npx")) { + return "npx"; + } + + const executablePath = process.argv[1]?.toLowerCase() ?? ""; + const hasNodeModulesPath = executablePath.includes(`${path.sep}node_modules${path.sep}`); + const hasGlobalPrefix = + executablePath.includes(`${path.sep}lib${path.sep}node_modules${path.sep}`) || + executablePath.includes(`${path.sep}share${path.sep}node_modules${path.sep}`); + + if (hasNodeModulesPath && hasGlobalPrefix) { + return "global-npm"; + } + + if (hasNodeModulesPath) { + return "local-npm"; + } + + return "unknown"; +} + +export function buildUpdateCommand(installMethod: InstallMethod): string { + if (installMethod === "global-npm") { + return "npm install -g @linkedin-buddy/cli@latest"; + } + if (installMethod === "npx") { + return "npx @linkedin-buddy/cli@latest (always runs latest)"; + } + if (installMethod === "local-npm") { + return "npm install @linkedin-buddy/cli@latest"; + } + return "npm install -g @linkedin-buddy/cli@latest"; +} diff --git a/packages/mcp/src/bin/linkedin-mcp.ts b/packages/mcp/src/bin/linkedin-mcp.ts index 86a91543..5018e089 100644 --- a/packages/mcp/src/bin/linkedin-mcp.ts +++ b/packages/mcp/src/bin/linkedin-mcp.ts @@ -6,6 +6,7 @@ import { ACTIVITY_EVENT_TYPES, ACTIVITY_WATCH_KINDS, ACTIVITY_WATCH_STATUSES, + checkForUpdate, DEFAULT_LINKEDIN_PERSONA_POST_IMAGE_COUNT, DEFAULT_FOLLOWUP_SINCE, createFeedbackTechnicalContext, @@ -27,6 +28,7 @@ import { readFeedbackStateSnapshot, recordFeedbackInvocation, resolveFollowupSinceWindow, + resolveUpdateCheckConfig, SEARCH_CATEGORIES, toLinkedInBuddyErrorPayload, submitFeedback, @@ -152,7 +154,8 @@ import { LINKEDIN_SESSION_HEALTH_TOOL, LINKEDIN_SESSION_OPEN_LOGIN_TOOL, LINKEDIN_SESSION_STATUS_TOOL, - } from "../index.js"; + LINKEDIN_UPDATE_CHECK_TOOL, + } from "../index.js"; import { type ToolArgs, readString, @@ -232,16 +235,46 @@ async function handleSessionStatus(args: ToolArgs): Promise { evasion_diagnostics_enabled: status.evasion?.diagnosticsEnabled ?? false, }); + let updateInfo: + | { + updateAvailable: boolean; + currentVersion: string; + latestVersion: string; + updateCommand: string; + } + | undefined; + try { + const updateConfig = resolveUpdateCheckConfig({ timeoutMs: 2_000 }); + if (updateConfig.enabled) { + const result = await checkForUpdate(updateConfig, packageJson.version); + if (result.updateAvailable) { + updateInfo = { + updateAvailable: result.updateAvailable, + currentVersion: result.currentVersion, + latestVersion: result.latestVersion, + updateCommand: result.updateCommand, + }; + } + } + } catch { /* ignore */ } + return toToolResult({ run_id: runtime.runId, profile_name: profileName, status, + ...(updateInfo !== undefined ? { update: updateInfo } : {}), }); } finally { runtime.close(); } } +async function handleUpdateCheck(): Promise { + const config = resolveUpdateCheckConfig(); + const result = await checkForUpdate(config, packageJson.version); + return toToolResult(result); +} + async function handleSessionOpenLogin(args: ToolArgs): Promise { const runtime = createRuntime(args); @@ -4325,6 +4358,16 @@ export const LINKEDIN_MCP_TOOL_DEFINITIONS: LinkedInMcpToolDefinition[] = [ }), }, }, + { + name: LINKEDIN_UPDATE_CHECK_TOOL, + description: + "Check whether a newer version of LinkedIn Buddy is available. Returns the current version, latest version, and the command to run for updating.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }, { name: LINKEDIN_AUTH_WHOAMI_TOOL, description: @@ -7263,6 +7306,7 @@ const TOOL_HANDLERS: Record = { [LINKEDIN_SESSION_STATUS_TOOL]: handleSessionStatus, [LINKEDIN_SESSION_OPEN_LOGIN_TOOL]: handleSessionOpenLogin, [LINKEDIN_SESSION_HEALTH_TOOL]: handleSessionHealth, + [LINKEDIN_UPDATE_CHECK_TOOL]: handleUpdateCheck, [LINKEDIN_AUTH_WHOAMI_TOOL]: handleAuthWhoami, [LINKEDIN_INBOX_SEARCH_RECIPIENTS_TOOL]: handleSearchRecipients, [LINKEDIN_INBOX_LIST_THREADS_TOOL]: handleListThreads, diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index d5f22063..1f70d027 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -2,6 +2,7 @@ export const SUBMIT_FEEDBACK_TOOL = "submit_feedback"; export const LINKEDIN_SESSION_STATUS_TOOL = "linkedin.session.status"; export const LINKEDIN_SESSION_OPEN_LOGIN_TOOL = "linkedin.session.open_login"; export const LINKEDIN_SESSION_HEALTH_TOOL = "linkedin.session.health"; +export const LINKEDIN_UPDATE_CHECK_TOOL = "linkedin.update.check"; export const LINKEDIN_AUTH_WHOAMI_TOOL = "linkedin.auth.whoami"; export const LINKEDIN_INBOX_LIST_THREADS_TOOL = "linkedin.inbox.list_threads"; export const LINKEDIN_INBOX_GET_THREAD_TOOL = "linkedin.inbox.get_thread"; diff --git a/server.json b/server.json new file mode 100644 index 00000000..b9567bfd --- /dev/null +++ b/server.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.sigvardt/linkedin-buddy", + "title": "LinkedIn Buddy MCP Server", + "description": "LinkedIn automation via MCP — inbox, feed, connections, profile, search, jobs, notifications, and safe prepare-and-confirm actions.", + "repository": { + "url": "https://github.com/sigvardt/linkedin-buddy", + "source": "github" + }, + "version": "0.1.0", + "packages": [ + { + "registryType": "npm", + "registryBaseUrl": "https://registry.npmjs.org", + "identifier": "@linkedin-buddy/mcp", + "version": "0.1.0", + "runtimeHint": "npx", + "transport": { + "type": "stdio" + }, + "environmentVariables": [ + { + "name": "LINKEDIN_BUDDY_EVASION_LEVEL", + "description": "Anti-bot evasion level: off, light, moderate (default), aggressive", + "isRequired": false, + "isSecret": false + }, + { + "name": "LINKEDIN_BUDDY_SELECTOR_LOCALE", + "description": "LinkedIn UI language for selectors: en (default), da", + "isRequired": false, + "isSecret": false + } + ] + } + ] +} diff --git a/smithery.yaml b/smithery.yaml new file mode 100644 index 00000000..d0cb2199 --- /dev/null +++ b/smithery.yaml @@ -0,0 +1,28 @@ +startCommand: + type: stdio + configSchema: + type: object + properties: + evasionLevel: + type: string + enum: + - "off" + - "light" + - "moderate" + - "aggressive" + description: "Anti-bot evasion level (default: moderate)" + selectorLocale: + type: string + enum: + - "en" + - "da" + description: "LinkedIn UI language for selectors (default: en)" + commandFunction: |- + (config) => ({ + command: "npx", + args: ["-y", "@linkedin-buddy/mcp"], + env: { + ...(config.evasionLevel ? { LINKEDIN_BUDDY_EVASION_LEVEL: config.evasionLevel } : {}), + ...(config.selectorLocale ? { LINKEDIN_BUDDY_SELECTOR_LOCALE: config.selectorLocale } : {}) + } + })