Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 26 additions & 16 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,34 @@
#!/usr/bin/env node

import { Command } from "commander";
import { registerWhoamiCommand } from "./commands/whoami.js";
import { registerProfileCommand } from "./commands/profile.js";
import { registerFeedCommand } from "./commands/feed.js";
import { registerPostCommand } from "./commands/post.js";
import { registerAccountCommand } from "./commands/account.js";
import { registerCommentCommand } from "./commands/comment.js";
import { registerReactCommand } from "./commands/react.js";
import { registerSearchCommand } from "./commands/search.js";
import { registerConnectionsCommand } from "./commands/connections.js";
import { registerCompanyCommand } from "./commands/company.js";
import { registerConnectCommand } from "./commands/connect.js";
import { registerSendCommand } from "./commands/message.js";
import { registerConnectionsCommand } from "./commands/connections.js";
import { registerFeedCommand } from "./commands/feed.js";
import { registerInboxCommand } from "./commands/inbox.js";
import { registerNotificationsCommand } from "./commands/notifications.js";
import { registerCompanyCommand } from "./commands/company.js";
import { registerJobsCommand } from "./commands/jobs.js";
import { registerInvitationsCommand } from "./commands/invitations.js";
import { registerJobsCommand } from "./commands/jobs.js";
import { registerSendCommand } from "./commands/message.js";
import { registerNetworkCommand } from "./commands/network.js";
import { registerNotificationsCommand } from "./commands/notifications.js";
import { registerPostCommand } from "./commands/post.js";
import { registerProfileCommand } from "./commands/profile.js";
import { registerReactCommand } from "./commands/react.js";
import { registerSearchCommand } from "./commands/search.js";
import { registerWhoamiCommand } from "./commands/whoami.js";
import { setCurrentAccount } from "./lib/account-context.js";
import { LinkedInError } from "./lib/errors.js";
import { red, dim } from "./utils/terminal.js";
import { dim, red } from "./utils/terminal.js";

const program = new Command();

program
.name("linked")
.description("Fast LinkedIn CLI — read, post, message, and network from your terminal")
.version("0.1.0");
.version("0.1.0")
.option("-a, --account <name>", "Use a specific named account");

// Register all commands
registerWhoamiCommand(program);
Expand All @@ -44,9 +47,16 @@ registerCompanyCommand(program);
registerJobsCommand(program);
registerInvitationsCommand(program);
registerNetworkCommand(program);
registerAccountCommand(program);

// Global error handling and account context
program.hook("preAction", (thisCommand) => {
// Set account context from global --account flag
const rootOpts = program.opts<{ account?: string }>();
if (rootOpts.account) {
setCurrentAccount(rootOpts.account);
}

// Global error handling
program.hook("preAction", () => {
// Ensure unhandled rejections are caught
process.on("unhandledRejection", (err) => {
handleError(err);
Expand All @@ -64,7 +74,7 @@ function handleError(err: unknown): void {
}
} else if (err instanceof Error) {
console.error(red(`Error: ${err.message}`));
if (process.env["DEBUG"]) {
if (process.env.DEBUG) {
console.error(dim(err.stack ?? ""));
}
} else {
Expand Down
141 changes: 141 additions & 0 deletions src/commands/account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import type { Command } from "commander";
import { resolveCredentials } from "../lib/auth.js";
import { LinkedInClient } from "../lib/client.js";
import {
listAccounts,
loadConfig,
migrateLegacyCredentials,
removeAccount,
saveConfig,
setAccount,
setDefaultAccount,
} from "../lib/config.js";
import { formatProfile } from "../lib/formatters.js";
import type { AccountConfig } from "../lib/types.js";
import { bold, cyan, dim, green, yellow } from "../utils/terminal.js";

const ACCOUNT_NAME_RE = /^[a-zA-Z0-9_-]+$/;

function validateAccountName(name: string): void {
if (!ACCOUNT_NAME_RE.test(name)) {
throw new Error(
`Invalid account name "${name}". Use only letters, numbers, hyphens, and underscores.`,
);
}
}

export function registerAccountCommand(program: Command): void {
const account = program.command("account").description("Manage multiple LinkedIn accounts");

account
.command("list")
.alias("ls")
.description("List configured accounts")
.action(() => {
const config = loadConfig();
const accounts = listAccounts(config);

if (accounts.length === 0) {
console.log(dim("No accounts configured."));
console.log(
dim('Run "linked account add <name> --li-at <token> --jsessionid <token>" to add one.'),
);
return;
}

for (const acct of accounts) {
const marker = acct.isDefault ? green(" (default)") : "";
const status = acct.hasCookies ? cyan("credentials set") : yellow("no credentials");
const source = acct.cookieSource ? dim(` [${acct.cookieSource}]`) : "";
console.log(` ${bold(acct.name)}${marker} — ${status}${source}`);
}
});

account
.command("add")
.argument("<name>", "Account name (letters, numbers, hyphens, underscores)")
.option("--li-at <token>", "LinkedIn li_at cookie value")
.option("--jsessionid <token>", "LinkedIn JSESSIONID cookie value")
.option(
"--cookie-source <browser>",
"Browser to extract cookies from (safari, chrome, firefox)",
)
.option("--default", "Set as default account")
.description("Add or update a named account")
.action(
(
name: string,
opts: {
liAt?: string;
jsessionid?: string;
cookieSource?: string;
default?: boolean;
},
) => {
validateAccountName(name);
const acctConfig: AccountConfig = {};
if (opts.liAt) acctConfig.li_at = opts.liAt;
if (opts.jsessionid) acctConfig.jsessionid = opts.jsessionid;
if (opts.cookieSource)
acctConfig.cookieSource = opts.cookieSource as AccountConfig["cookieSource"];

let config = loadConfig();
config = setAccount(config, name, acctConfig);
if (opts.default) {
config = setDefaultAccount(config, name);
}
saveConfig(config);
console.log(green(`Account "${name}" saved.`));
},
);

account
.command("remove")
.alias("rm")
.argument("<name>", "Account name to remove")
.description("Remove a named account")
.action((name: string) => {
let config = loadConfig();
config = removeAccount(config, name);
saveConfig(config);
console.log(green(`Account "${name}" removed.`));
});

account
.command("default")
.argument("<name>", "Account name to set as default")
.description("Set the default account")
.action((name: string) => {
let config = loadConfig();
config = setDefaultAccount(config, name);
saveConfig(config);
console.log(green(`Default account set to "${name}".`));
});

account
.command("migrate")
.argument("[name]", "Account name for migrated credentials", "default")
.description("Migrate legacy flat credentials to a named account")
.action((name: string) => {
validateAccountName(name);
let config = loadConfig();
config = migrateLegacyCredentials(config, name);
saveConfig(config);
console.log(green(`Legacy credentials migrated to account "${name}".`));
});

account
.command("whoami")
.description("Show which account is currently active and verify credentials")
.option("--json", "Output as JSON")
.action(async (opts: { json?: boolean }) => {
const client = new LinkedInClient();
const profile = await client.getMe();

if (opts.json) {
console.log(JSON.stringify(profile, null, 2));
} else {
console.log(formatProfile(profile));
}
});
}
13 changes: 12 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@

export { LinkedInClient } from "./lib/client.js";
export { resolveCredentials } from "./lib/auth.js";
export { loadConfig, saveConfig } from "./lib/config.js";
export { getCurrentAccount, setCurrentAccount } from "./lib/account-context.js";
export {
loadConfig,
saveConfig,
getAccountCredentials,
listAccounts,
setAccount,
removeAccount,
setDefaultAccount,
migrateLegacyCredentials,
} from "./lib/config.js";
export { extractCookiesFromBrowser } from "./lib/cookie-extract.js";
export { endpoints } from "./lib/voyager-endpoints.js";
export { buildHeaders } from "./lib/headers.js";
Expand All @@ -18,6 +28,7 @@ export { buildPaginationParams, hasNextPage, nextPageStart } from "./lib/paginat

// Re-export all types
export type {
AccountConfig,
AuthOptions,
CookieSet,
CookieSource,
Expand Down
9 changes: 9 additions & 0 deletions src/lib/account-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
let currentAccount: string | undefined;

export function getCurrentAccount(): string | undefined {
return currentAccount;
}

export function setCurrentAccount(name: string | undefined): void {
currentAccount = name;
}
44 changes: 36 additions & 8 deletions src/lib/auth.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
import type { AuthOptions, CookieSet, CookieSource } from "./types.js";
import { loadConfig } from "./config.js";
import { getCurrentAccount } from "./account-context.js";
import { getAccountCredentials, loadConfig } from "./config.js";
import { extractCookiesFromBrowser } from "./cookie-extract.js";
import { AuthenticationError } from "./errors.js";
import type { AuthOptions, CookieSet, CookieSource } from "./types.js";

/**
* Resolve LinkedIn credentials from multiple sources in priority order:
* 1. Explicitly provided cookies
* 2. Environment variables (LINKEDIN_LI_AT, LINKEDIN_JSESSIONID)
* 3. Config file (~/.config/linked/config.json5)
* 4. Browser cookie extraction (Safari → Chrome → Firefox)
* 3. Named account from config (--account flag → getCurrentAccount() → config.defaultAccount)
* 4. Legacy flat config credentials
* 5. Browser cookie extraction (Safari → Chrome → Firefox)
*/
export function resolveCredentials(options?: AuthOptions): CookieSet {
// 1. Explicitly provided cookies
Expand All @@ -17,19 +19,45 @@ export function resolveCredentials(options?: AuthOptions): CookieSet {
}

// 2. Environment variables
const envLiAt = process.env["LINKEDIN_LI_AT"];
const envJsessionid = process.env["LINKEDIN_JSESSIONID"];
const envLiAt = process.env.LINKEDIN_LI_AT;
const envJsessionid = process.env.LINKEDIN_JSESSIONID;
if (envLiAt && envJsessionid) {
return { li_at: envLiAt, jsessionid: envJsessionid };
}

// 3. Config file
// 3. Named account from config
const config = loadConfig();
const accountName = options?.account ?? getCurrentAccount() ?? config.defaultAccount;

if (accountName && config.accounts) {
const creds = getAccountCredentials(config, accountName);
if (creds) return creds;

// Account specified but not found — provide helpful error
if (!config.accounts[accountName]) {
const available = Object.keys(config.accounts);
throw new AuthenticationError(
`Account "${accountName}" not found. Available accounts: ${available.join(", ") || "(none)"}`,
);
}

// Account exists but has no direct credentials — try its cookieSource
const account = config.accounts[accountName];
if (account?.cookieSource) {
try {
return extractCookiesFromBrowser(account.cookieSource);
} catch {
// fall through
}
}
}

// 4. Legacy flat config credentials
if (config.li_at && config.jsessionid) {
return { li_at: config.li_at, jsessionid: config.jsessionid };
}

// 4. Browser cookie extraction
// 5. Browser cookie extraction
const cookieSource: CookieSource | undefined =
options?.cookieSource ?? (config.cookieSource as CookieSource | undefined);

Expand Down
Loading