From 1be681c1c96c12f46df369bc3470eb8854b23f39 Mon Sep 17 00:00:00 2001 From: Grigori Kartashyan Date: Thu, 5 Feb 2026 23:14:49 +0100 Subject: [PATCH] Add multi-account support for managing multiple LinkedIn accounts Enables users to configure and switch between named LinkedIn accounts via --account flag or a configured default, with full backward compatibility for existing single-account configurations. Co-Authored-By: Claude Opus 4.6 --- src/cli.ts | 42 +++--- src/commands/account.ts | 141 +++++++++++++++++++ src/index.ts | 13 +- src/lib/account-context.ts | 9 ++ src/lib/auth.ts | 44 ++++-- src/lib/config.ts | 110 ++++++++++++++- src/lib/types.ts | 19 ++- tests/unit/account-context.test.ts | 27 ++++ tests/unit/auth.test.ts | 106 ++++++++++++++ tests/unit/config.test.ts | 217 ++++++++++++++++++++++++++++- 10 files changed, 692 insertions(+), 36 deletions(-) create mode 100644 src/commands/account.ts create mode 100644 src/lib/account-context.ts create mode 100644 tests/unit/account-context.test.ts diff --git a/src/cli.ts b/src/cli.ts index 167360c..883ef17 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 ", "Use a specific named account"); // Register all commands registerWhoamiCommand(program); @@ -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); @@ -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 { diff --git a/src/commands/account.ts b/src/commands/account.ts new file mode 100644 index 0000000..212a553 --- /dev/null +++ b/src/commands/account.ts @@ -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 --li-at --jsessionid " 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("", "Account name (letters, numbers, hyphens, underscores)") + .option("--li-at ", "LinkedIn li_at cookie value") + .option("--jsessionid ", "LinkedIn JSESSIONID cookie value") + .option( + "--cookie-source ", + "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("", "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("", "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)); + } + }); +} diff --git a/src/index.ts b/src/index.ts index e9d127a..5b9e009 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; @@ -18,6 +28,7 @@ export { buildPaginationParams, hasNextPage, nextPageStart } from "./lib/paginat // Re-export all types export type { + AccountConfig, AuthOptions, CookieSet, CookieSource, diff --git a/src/lib/account-context.ts b/src/lib/account-context.ts new file mode 100644 index 0000000..2fb8602 --- /dev/null +++ b/src/lib/account-context.ts @@ -0,0 +1,9 @@ +let currentAccount: string | undefined; + +export function getCurrentAccount(): string | undefined { + return currentAccount; +} + +export function setCurrentAccount(name: string | undefined): void { + currentAccount = name; +} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index d5130c2..90243de 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -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 @@ -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); diff --git a/src/lib/config.ts b/src/lib/config.ts index ea98148..7f7759f 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,8 +1,8 @@ -import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs"; -import { join } from "node:path"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { homedir } from "node:os"; +import { join } from "node:path"; import JSON5 from "json5"; -import type { LinkedConfig } from "./types.js"; +import type { AccountConfig, CookieSet, LinkedConfig } from "./types.js"; const CONFIG_DIR = join(homedir(), ".config", "linked"); const CONFIG_FILE = join(CONFIG_DIR, "config.json5"); @@ -40,3 +40,107 @@ export function getConfigDir(): string { export function getConfigPath(): string { return CONFIG_FILE; } + +export interface AccountInfo { + name: string; + isDefault: boolean; + hasCookies: boolean; + cookieSource?: string; +} + +export function getAccountCredentials(config: LinkedConfig, name: string): CookieSet | undefined { + const account = config.accounts?.[name]; + if (!account) return undefined; + if (account.li_at && account.jsessionid) { + return { li_at: account.li_at, jsessionid: account.jsessionid }; + } + return undefined; +} + +export function listAccounts(config: LinkedConfig): AccountInfo[] { + const results: AccountInfo[] = []; + + if (config.accounts) { + for (const [name, account] of Object.entries(config.accounts)) { + results.push({ + name, + isDefault: config.defaultAccount === name, + hasCookies: Boolean(account.li_at && account.jsessionid), + cookieSource: account.cookieSource, + }); + } + } else if (config.li_at && config.jsessionid) { + results.push({ + name: "(legacy)", + isDefault: true, + hasCookies: true, + cookieSource: config.cookieSource, + }); + } + + return results; +} + +export function setAccount( + config: LinkedConfig, + name: string, + account: AccountConfig, +): LinkedConfig { + const updated = { ...config }; + if (!updated.accounts) { + updated.accounts = {}; + } + updated.accounts = { ...updated.accounts, [name]: account }; + if (!updated.defaultAccount) { + updated.defaultAccount = name; + } + return updated; +} + +export function removeAccount(config: LinkedConfig, name: string): LinkedConfig { + if (!config.accounts?.[name]) { + throw new Error(`Account "${name}" does not exist.`); + } + const updated = { ...config }; + const accounts = updated.accounts ?? {}; + const { [name]: _, ...rest } = accounts; + updated.accounts = rest; + if (updated.defaultAccount === name) { + const remaining = Object.keys(updated.accounts); + updated.defaultAccount = remaining[0]; + } + if (Object.keys(updated.accounts).length === 0) { + updated.accounts = undefined; + updated.defaultAccount = undefined; + } + return updated; +} + +export function setDefaultAccount(config: LinkedConfig, name: string): LinkedConfig { + if (!config.accounts?.[name]) { + throw new Error(`Account "${name}" does not exist.`); + } + return { ...config, defaultAccount: name }; +} + +export function migrateLegacyCredentials(config: LinkedConfig, name: string): LinkedConfig { + if (!config.li_at || !config.jsessionid) { + throw new Error("No legacy credentials to migrate."); + } + const account: AccountConfig = { + li_at: config.li_at, + jsessionid: config.jsessionid, + }; + if (config.cookieSource) { + account.cookieSource = config.cookieSource; + } + const updated = { ...config }; + if (!updated.accounts) { + updated.accounts = {}; + } + updated.accounts = { ...updated.accounts, [name]: account }; + updated.defaultAccount = name; + updated.li_at = undefined; + updated.jsessionid = undefined; + return updated; +} diff --git a/src/lib/types.ts b/src/lib/types.ts index 4fb3df8..b8f9bd4 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -10,6 +10,15 @@ export type CookieSource = "safari" | "chrome" | "firefox" | "env" | "config"; export interface AuthOptions { cookies?: CookieSet; cookieSource?: CookieSource; + account?: string; +} + +// ── Account Types ── + +export interface AccountConfig { + li_at?: string; + jsessionid?: string; + cookieSource?: CookieSource; } // ── LinkedIn Entity Types ── @@ -100,13 +109,7 @@ export interface ReactionCount { count: number; } -export type ReactionType = - | "LIKE" - | "CELEBRATE" - | "SUPPORT" - | "LOVE" - | "INSIGHTFUL" - | "FUNNY"; +export type ReactionType = "LIKE" | "CELEBRATE" | "SUPPORT" | "LOVE" | "INSIGHTFUL" | "FUNNY"; export interface PostOptions { visibility?: "PUBLIC" | "CONNECTIONS"; @@ -302,6 +305,8 @@ export interface LinkedConfig { timeoutMs?: number; defaultCount?: number; delayMs?: number; + accounts?: Record; + defaultAccount?: string; } // ── API Response wrappers ── diff --git a/tests/unit/account-context.test.ts b/tests/unit/account-context.test.ts new file mode 100644 index 0000000..13509aa --- /dev/null +++ b/tests/unit/account-context.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + getCurrentAccount, + setCurrentAccount, +} from "../../src/lib/account-context.js"; + +describe("account-context", () => { + beforeEach(() => { + setCurrentAccount(undefined); + }); + + it("should return undefined by default", () => { + expect(getCurrentAccount()).toBeUndefined(); + }); + + it("should return the account name after setting it", () => { + setCurrentAccount("foo"); + expect(getCurrentAccount()).toBe("foo"); + }); + + it("should reset to undefined when set to undefined", () => { + setCurrentAccount("bar"); + expect(getCurrentAccount()).toBe("bar"); + setCurrentAccount(undefined); + expect(getCurrentAccount()).toBeUndefined(); + }); +}); diff --git a/tests/unit/auth.test.ts b/tests/unit/auth.test.ts index 144df98..cb32c2c 100644 --- a/tests/unit/auth.test.ts +++ b/tests/unit/auth.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { resolveCredentials } from "../../src/lib/auth.js"; +import { setCurrentAccount } from "../../src/lib/account-context.js"; +import * as configModule from "../../src/lib/config.js"; describe("resolveCredentials", () => { const originalEnv = process.env; @@ -10,6 +12,8 @@ describe("resolveCredentials", () => { afterEach(() => { process.env = originalEnv; + setCurrentAccount(undefined); + vi.restoreAllMocks(); }); it("should return explicitly provided cookies", () => { @@ -103,4 +107,106 @@ describe("resolveCredentials", () => { expect((err as Error).message).toBeTruthy(); } }); + + it("should use named account credentials from getCurrentAccount()", () => { + delete process.env["LINKEDIN_LI_AT"]; + delete process.env["LINKEDIN_JSESSIONID"]; + + vi.spyOn(configModule, "loadConfig").mockReturnValue({ + accounts: { + work: { li_at: "work-token", jsessionid: "work-session" }, + }, + defaultAccount: "work", + }); + + setCurrentAccount("work"); + const result = resolveCredentials(); + expect(result).toEqual({ + li_at: "work-token", + jsessionid: "work-session", + }); + }); + + it("should use defaultAccount credentials when no account flag set", () => { + delete process.env["LINKEDIN_LI_AT"]; + delete process.env["LINKEDIN_JSESSIONID"]; + + vi.spyOn(configModule, "loadConfig").mockReturnValue({ + accounts: { + personal: { li_at: "p-token", jsessionid: "p-session" }, + }, + defaultAccount: "personal", + }); + + const result = resolveCredentials(); + expect(result).toEqual({ + li_at: "p-token", + jsessionid: "p-session", + }); + }); + + it("should throw with available accounts when account not found", () => { + delete process.env["LINKEDIN_LI_AT"]; + delete process.env["LINKEDIN_JSESSIONID"]; + + vi.spyOn(configModule, "loadConfig").mockReturnValue({ + accounts: { + personal: { li_at: "a", jsessionid: "b" }, + work: { li_at: "c", jsessionid: "d" }, + }, + }); + + setCurrentAccount("nonexistent"); + expect(() => resolveCredentials()).toThrow(/nonexistent/); + expect(() => resolveCredentials()).toThrow(/personal/); + }); + + it("should prefer options.account over getCurrentAccount()", () => { + delete process.env["LINKEDIN_LI_AT"]; + delete process.env["LINKEDIN_JSESSIONID"]; + + vi.spyOn(configModule, "loadConfig").mockReturnValue({ + accounts: { + a: { li_at: "a-tok", jsessionid: "a-sess" }, + b: { li_at: "b-tok", jsessionid: "b-sess" }, + }, + defaultAccount: "a", + }); + + setCurrentAccount("a"); + const result = resolveCredentials({ account: "b" }); + expect(result).toEqual({ li_at: "b-tok", jsessionid: "b-sess" }); + }); + + it("should still use legacy flat credentials when no accounts map", () => { + delete process.env["LINKEDIN_LI_AT"]; + delete process.env["LINKEDIN_JSESSIONID"]; + + vi.spyOn(configModule, "loadConfig").mockReturnValue({ + li_at: "legacy-tok", + jsessionid: "legacy-sess", + }); + + const result = resolveCredentials(); + expect(result).toEqual({ + li_at: "legacy-tok", + jsessionid: "legacy-sess", + }); + }); + + it("should still prefer env vars over named accounts", () => { + process.env["LINKEDIN_LI_AT"] = "env-token"; + process.env["LINKEDIN_JSESSIONID"] = "env-session"; + + vi.spyOn(configModule, "loadConfig").mockReturnValue({ + accounts: { + work: { li_at: "work-token", jsessionid: "work-session" }, + }, + defaultAccount: "work", + }); + + setCurrentAccount("work"); + const result = resolveCredentials(); + expect(result.li_at).toBe("env-token"); + }); }); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts index bb30290..630f2a6 100644 --- a/tests/unit/config.test.ts +++ b/tests/unit/config.test.ts @@ -2,7 +2,19 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { join } from "node:path"; import { homedir } from "node:os"; import { mkdirSync, writeFileSync, rmSync, existsSync } from "node:fs"; -import { getConfigDir, getConfigPath, loadConfig, saveConfig } from "../../src/lib/config.js"; +import { + getConfigDir, + getConfigPath, + loadConfig, + saveConfig, + listAccounts, + setAccount, + removeAccount, + setDefaultAccount, + migrateLegacyCredentials, + getAccountCredentials, +} from "../../src/lib/config.js"; +import type { LinkedConfig } from "../../src/lib/types.js"; const TEST_CONFIG_DIR = join( process.env["TMPDIR"] ?? "/tmp", @@ -47,3 +59,206 @@ describe("saveConfig", () => { expect(typeof saveConfig).toBe("function"); }); }); + +describe("getAccountCredentials", () => { + it("should return cookies for a valid account", () => { + const config: LinkedConfig = { + accounts: { + personal: { li_at: "tok1", jsessionid: "sess1" }, + }, + }; + const result = getAccountCredentials(config, "personal"); + expect(result).toEqual({ li_at: "tok1", jsessionid: "sess1" }); + }); + + it("should return undefined for non-existent account", () => { + const config: LinkedConfig = { accounts: {} }; + expect(getAccountCredentials(config, "nope")).toBeUndefined(); + }); + + it("should return undefined when account has no credentials", () => { + const config: LinkedConfig = { + accounts: { partial: { cookieSource: "safari" } }, + }; + expect(getAccountCredentials(config, "partial")).toBeUndefined(); + }); + + it("should return undefined when no accounts map", () => { + const config: LinkedConfig = {}; + expect(getAccountCredentials(config, "any")).toBeUndefined(); + }); +}); + +describe("listAccounts", () => { + it("should return empty array when no accounts and no legacy creds", () => { + expect(listAccounts({})).toEqual([]); + }); + + it("should return legacy marker for flat-only config", () => { + const config: LinkedConfig = { li_at: "tok", jsessionid: "sess" }; + const result = listAccounts(config); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe("(legacy)"); + expect(result[0]!.isDefault).toBe(true); + expect(result[0]!.hasCookies).toBe(true); + }); + + it("should list all named accounts with default flag", () => { + const config: LinkedConfig = { + accounts: { + personal: { li_at: "a", jsessionid: "b" }, + company: { cookieSource: "chrome" }, + }, + defaultAccount: "personal", + }; + const result = listAccounts(config); + expect(result).toHaveLength(2); + + const personal = result.find((a) => a.name === "personal"); + expect(personal?.isDefault).toBe(true); + expect(personal?.hasCookies).toBe(true); + + const company = result.find((a) => a.name === "company"); + expect(company?.isDefault).toBe(false); + expect(company?.hasCookies).toBe(false); + expect(company?.cookieSource).toBe("chrome"); + }); +}); + +describe("setAccount", () => { + it("should create accounts map if missing", () => { + const result = setAccount({}, "work", { + li_at: "t", + jsessionid: "s", + }); + expect(result.accounts?.work).toEqual({ + li_at: "t", + jsessionid: "s", + }); + }); + + it("should auto-set first account as default", () => { + const result = setAccount({}, "first", { li_at: "a", jsessionid: "b" }); + expect(result.defaultAccount).toBe("first"); + }); + + it("should not override existing default", () => { + const config: LinkedConfig = { + accounts: { existing: { li_at: "a", jsessionid: "b" } }, + defaultAccount: "existing", + }; + const result = setAccount(config, "second", { + li_at: "c", + jsessionid: "d", + }); + expect(result.defaultAccount).toBe("existing"); + }); + + it("should update existing account", () => { + const config: LinkedConfig = { + accounts: { acct: { li_at: "old", jsessionid: "old" } }, + defaultAccount: "acct", + }; + const result = setAccount(config, "acct", { + li_at: "new", + jsessionid: "new", + }); + expect(result.accounts?.acct?.li_at).toBe("new"); + }); +}); + +describe("removeAccount", () => { + it("should remove an account", () => { + const config: LinkedConfig = { + accounts: { + a: { li_at: "1", jsessionid: "1" }, + b: { li_at: "2", jsessionid: "2" }, + }, + defaultAccount: "b", + }; + const result = removeAccount(config, "a"); + expect(result.accounts?.a).toBeUndefined(); + expect(result.accounts?.b).toBeDefined(); + }); + + it("should reset default if removed account was default", () => { + const config: LinkedConfig = { + accounts: { + a: { li_at: "1", jsessionid: "1" }, + b: { li_at: "2", jsessionid: "2" }, + }, + defaultAccount: "a", + }; + const result = removeAccount(config, "a"); + expect(result.defaultAccount).toBe("b"); + }); + + it("should clean up accounts map when last account removed", () => { + const config: LinkedConfig = { + accounts: { only: { li_at: "1", jsessionid: "1" } }, + defaultAccount: "only", + }; + const result = removeAccount(config, "only"); + expect(result.accounts).toBeUndefined(); + expect(result.defaultAccount).toBeUndefined(); + }); + + it("should throw on non-existent account", () => { + const config: LinkedConfig = { accounts: {} }; + expect(() => removeAccount(config, "nope")).toThrow( + 'Account "nope" does not exist', + ); + }); +}); + +describe("setDefaultAccount", () => { + it("should set the default account", () => { + const config: LinkedConfig = { + accounts: { + a: { li_at: "1", jsessionid: "1" }, + b: { li_at: "2", jsessionid: "2" }, + }, + defaultAccount: "a", + }; + const result = setDefaultAccount(config, "b"); + expect(result.defaultAccount).toBe("b"); + }); + + it("should throw on non-existent account", () => { + const config: LinkedConfig = { accounts: {} }; + expect(() => setDefaultAccount(config, "nope")).toThrow( + 'Account "nope" does not exist', + ); + }); +}); + +describe("migrateLegacyCredentials", () => { + it("should move flat fields to named account", () => { + const config: LinkedConfig = { + li_at: "tok", + jsessionid: "sess", + cookieSource: "safari", + }; + const result = migrateLegacyCredentials(config, "personal"); + expect(result.accounts?.personal).toEqual({ + li_at: "tok", + jsessionid: "sess", + cookieSource: "safari", + }); + expect(result.defaultAccount).toBe("personal"); + expect(result.li_at).toBeUndefined(); + expect(result.jsessionid).toBeUndefined(); + }); + + it("should set default to migrated account", () => { + const config: LinkedConfig = { li_at: "t", jsessionid: "s" }; + const result = migrateLegacyCredentials(config, "main"); + expect(result.defaultAccount).toBe("main"); + }); + + it("should throw when no legacy credentials", () => { + expect(() => migrateLegacyCredentials({}, "x")).toThrow( + "No legacy credentials to migrate", + ); + }); +});