From 649c651dc375247b86b667e6af215cbb7fcb3241 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Mon, 24 Aug 2026 12:34:13 +0530 Subject: [PATCH 1/3] feat: add organization switching --- README.md | 33 ++++- src/cli.ts | 155 ++++++++++++++++++++--- src/services/auth.ts | 71 ++++++++--- src/services/organization-switch.test.ts | 117 +++++++++++++++++ src/services/organization-switch.ts | 133 +++++++++++++++++++ 5 files changed, 475 insertions(+), 34 deletions(-) create mode 100644 src/services/organization-switch.test.ts create mode 100644 src/services/organization-switch.ts diff --git a/README.md b/README.md index a488d8f..fe634e9 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,14 @@ Check the connection any time: bunx opencode-supermemory@latest status ``` +To select a different organization later: + +```bash +bunx opencode-supermemory@latest switch-organization +``` + +You can also ask the agent to run `/supermemory-switch-organization`. + **Or let your agent do it** - paste this into OpenCode: ``` @@ -46,7 +54,9 @@ bunx opencode-supermemory@latest install --no-tui This will: - Register the plugin in `~/.config/opencode/opencode.jsonc` -- Create the `/supermemory-init` command +- Create the `/supermemory-init`, `/supermemory-login`, + `/supermemory-switch-organization`, `/supermemory-logout`, and + `/supermemory-status` commands #### Step 2: Verify the config @@ -119,6 +129,24 @@ Run `/supermemory-init` to have the agent explore and memorize the codebase. +## Switching Organizations + +Run the browser organization picker again without logging out: + +```bash +bunx opencode-supermemory@latest switch-organization +``` + +Or run `/supermemory-switch-organization` inside OpenCode. The command verifies +the selected credential with `/v3/session` and prints the organization before it +replaces the stored browser credential. Cancelling, timing out, or failing +verification leaves the previous credential unchanged. + +Restart or reload OpenCode after a successful switch because the running plugin +may still hold the credential it loaded at startup. If `SUPERMEMORY_API_KEY` or +an `apiKey` in `~/.config/opencode/supermemory.jsonc`/`supermemory.json` is set, +that value takes precedence; the switch command prints a warning in that case. + ## Features ### Context Injection @@ -289,7 +317,8 @@ Create `~/.config/opencode/supermemory.jsonc`: } ``` -All fields optional. Env var `SUPERMEMORY_API_KEY` takes precedence over config file. +All fields optional. `SUPERMEMORY_API_KEY` takes precedence over the config file, +which takes precedence over credentials created by browser authentication. ### Container Tag Selection diff --git a/src/cli.ts b/src/cli.ts index bfad2e8..6e57eab 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,14 +4,36 @@ import { join } from "node:path"; import { homedir } from "node:os"; import * as readline from "node:readline"; import { stripJsoncComments } from "./services/jsonc.js"; -import { startAuthFlow, clearCredentials, loadCredentials, CREDENTIALS_FILE } from "./services/auth.js"; -import { CONFIG, CONFIG_FILE, SUPERMEMORY_API_KEY, getApiBaseUrl, isConfigured, writeInstallDefaults } from "./config.js"; +import { + startAuthFlow, + clearCredentials, + loadCredentials, + saveCredentials, + CREDENTIALS_FILE, +} from "./services/auth.js"; +import { + CONFIG, + CONFIG_FILE, + DEFAULT_BASE_URL, + SUPERMEMORY_API_KEY, + getApiBaseUrl, + isConfigured, + writeInstallDefaults, +} from "./config.js"; import { SupermemoryClient } from "./services/client.js"; import { getTags } from "./services/tags.js"; +import { + getCredentialOverrideWarnings, + switchOrganizationCredential, +} from "./services/organization-switch.js"; const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode"); const OPENCODE_COMMAND_DIR = join(OPENCODE_CONFIG_DIR, "command"); const OH_MY_OPENCODE_CONFIG = join(OPENCODE_CONFIG_DIR, "oh-my-opencode.json"); +const SUPERMEMORY_CONFIG_FILES = [ + join(OPENCODE_CONFIG_DIR, "supermemory.jsonc"), + join(OPENCODE_CONFIG_DIR, "supermemory.json"), +]; const PLUGIN_NAME = "opencode-supermemory@latest"; const DEFAULT_CONFIG_FILE = CONFIG_FILE ?? join(OPENCODE_CONFIG_DIR, "supermemory.json"); @@ -180,15 +202,38 @@ bunx opencode-supermemory@latest login \`\`\` This will: -1. Start a local server on port 19877 +1. Start a temporary local callback 2. Open the browser to Supermemory's authentication page -3. After the user logs in, save credentials to ~/.supermemory-opencode/credentials.json +3. Let the user select an organization +4. Save credentials to ~/.supermemory-opencode/credentials.json Wait for the command to complete, then inform the user whether authentication succeeded or failed. If the user wants to log out instead, tell them to use the /supermemory-logout command. `; +const SUPERMEMORY_SWITCH_ORGANIZATION_COMMAND = `--- +description: Switch the organization used by Supermemory +--- + +# Switch Supermemory Organization + +Run this command to let the user select which Supermemory organization OpenCode uses: + +\`\`\`bash +bunx opencode-supermemory@latest switch-organization +\`\`\` + +This opens the browser organization picker even when OpenCode is already authenticated. Existing credentials remain unchanged if the user cancels or verification fails. + +After the command completes: +1. Report the verified organization shown by the command. +2. Relay any credential override warnings. +3. Tell the user to restart or reload OpenCode because the current process may retain the credential it loaded at startup. + +Never print the full API key. +`; + const SUPERMEMORY_LOGOUT_COMMAND = `--- description: Log out from Supermemory and clear credentials --- @@ -333,6 +378,16 @@ function createCommands(): boolean { writeFileSync(loginPath, SUPERMEMORY_LOGIN_COMMAND); console.log(`✓ Created /supermemory-login command`); + const switchOrganizationPath = join( + OPENCODE_COMMAND_DIR, + "supermemory-switch-organization.md", + ); + writeFileSync( + switchOrganizationPath, + SUPERMEMORY_SWITCH_ORGANIZATION_COMMAND, + ); + console.log(`✓ Created /supermemory-switch-organization command`); + const logoutPath = join(OPENCODE_COMMAND_DIR, "supermemory-logout.md"); writeFileSync(logoutPath, SUPERMEMORY_LOGOUT_COMMAND); console.log(`✓ Created /supermemory-logout command`); @@ -434,7 +489,7 @@ async function install(options: InstallOptions): Promise { } // Step 2: Create commands - console.log("\nStep 2: Create /supermemory-init, /supermemory-login, /supermemory-logout, and /supermemory-status commands"); + console.log("\nStep 2: Create /supermemory-init, /supermemory-login, /supermemory-switch-organization, /supermemory-logout, and /supermemory-status commands"); if (options.tui) { const shouldCreate = await confirm(rl!, "Add supermemory commands?"); if (!shouldCreate) { @@ -493,16 +548,28 @@ async function install(options: InstallOptions): Promise { async function login(): Promise { const existing = loadCredentials(); if (existing) { - console.log("Already authenticated. Use 'logout' first to re-authenticate."); + console.log( + "Already authenticated. Use 'switch-organization' to choose another organization.", + ); return 0; } const result = await startAuthFlow(); if (result.success) { - console.log("\n✓ Successfully authenticated with Supermemory!"); - console.log("Restart OpenCode to activate.\n"); - return 0; + try { + saveCredentials(result.apiKey, result.apiBaseUrl); + console.log("\n✓ Successfully authenticated with Supermemory!"); + console.log("Restart OpenCode to activate.\n"); + return 0; + } catch (error) { + console.error( + `\n✗ Authentication succeeded, but credentials could not be saved: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return 1; + } } else { console.error(`\n✗ Authentication failed: ${result.error}`); return 1; @@ -515,23 +582,72 @@ function maskKey(key: string | undefined): string { return `${key.slice(0, 6)}...${key.slice(-4)}`; } -function getConfiguredApiKeyFromFile(): string | undefined { - try { - if (!existsSync(DEFAULT_CONFIG_FILE)) return undefined; - const parsed = JSON.parse(readFileSync(DEFAULT_CONFIG_FILE, "utf-8")) as { apiKey?: string }; - return parsed.apiKey; - } catch { - return undefined; +function getConfiguredApiKeyFromFile(): + | { apiKey: string; path: string } + | undefined { + for (const path of SUPERMEMORY_CONFIG_FILES) { + try { + if (!existsSync(path)) continue; + const parsed = JSON.parse( + stripJsoncComments(readFileSync(path, "utf-8")), + ) as { apiKey?: unknown }; + if (typeof parsed.apiKey === "string" && parsed.apiKey) { + return { apiKey: parsed.apiKey, path }; + } + } catch { + continue; + } } + return undefined; } function getKeySource(): string { if (process.env.SUPERMEMORY_API_KEY) return "SUPERMEMORY_API_KEY env var"; - if (getConfiguredApiKeyFromFile()) return DEFAULT_CONFIG_FILE; + const configuredApiKey = getConfiguredApiKeyFromFile(); + if (configuredApiKey) return configuredApiKey.path; if (loadCredentials()) return CREDENTIALS_FILE; return "not configured"; } +async function switchOrganization(): Promise { + const hadExistingCredentials = loadCredentials() !== null; + console.log("Opening Supermemory so you can select an organization..."); + + const result = await switchOrganizationCredential({ + authorize: () => startAuthFlow(), + save: saveCredentials, + defaultApiBaseUrl: DEFAULT_BASE_URL, + }); + + if (!result.success) { + console.error(`\n✗ Organization switch failed: ${result.error}`); + if (hadExistingCredentials) { + console.log("Your existing browser credentials were kept unchanged."); + } + return 1; + } + + const organization = result.organization.name + ? `${result.organization.name} (${result.organization.id})` + : result.organization.id; + console.log(`\n✓ Supermemory organization switched to ${organization}.`); + console.log(`Verified with ${result.apiBaseUrl}/v3/session.`); + + const configuredApiKey = getConfiguredApiKeyFromFile(); + const warnings = getCredentialOverrideWarnings({ + environmentApiKey: Boolean(process.env.SUPERMEMORY_API_KEY), + configApiKeyPath: configuredApiKey?.path, + }); + for (const warning of warnings) { + console.warn(`⚠ ${warning}`); + } + + console.log( + "Restart or reload OpenCode before continuing; the current process may still be using the credential it loaded at startup.\n", + ); + return 0; +} + function getDevTlsHint(apiUrl: string): string | null { if (!apiUrl.includes(".dev.supermemory.ai")) return null; if (process.env.NODE_EXTRA_CA_CERTS) return null; @@ -656,12 +772,15 @@ Commands: --no-tui Non-interactive mode (for LLM agents) --disable-context-recovery Disable Oh My OpenCode's context hook login Authenticate with Supermemory (opens browser) + switch-organization + Select and verify the organization used by Supermemory logout Clear stored credentials status Show Supermemory connection status Examples: bunx opencode-supermemory@latest install bunx opencode-supermemory@latest login + bunx opencode-supermemory@latest switch-organization bunx opencode-supermemory@latest logout bunx opencode-supermemory@latest status `); @@ -685,6 +804,8 @@ if (args[0] === "install") { install({ tui: !noTui, disableAutoCompact }).then((code) => process.exit(code)); } else if (args[0] === "login") { login().then((code) => process.exit(code)); +} else if (args[0] === "switch-organization" || args[0] === "switch-org") { + switchOrganization().then((code) => process.exit(code)); } else if (args[0] === "logout") { process.exit(logout()); } else if (args[0] === "status") { diff --git a/src/services/auth.ts b/src/services/auth.ts index 5e368c0..56476f1 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -1,14 +1,22 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + rmSync, + writeFileSync, +} from "node:fs"; import { join } from "node:path"; import { arch, homedir, hostname, platform } from "node:os"; import { randomBytes } from "node:crypto"; import type { AddressInfo } from "node:net"; import { openUrl } from "./openUrl.js"; +import { PLUGIN_VERSION } from "../version.js"; const CREDENTIALS_DIR = join(homedir(), ".supermemory-opencode"); export const CREDENTIALS_FILE = join(CREDENTIALS_DIR, "credentials.json"); -const AUTH_BASE_URL = process.env.SUPERMEMORY_AUTH_URL || "https://app.supermemory.ai/auth/agent-connect"; +const AUTH_BASE_URL = process.env.SUPERMEMORY_AUTH_URL || "https://app.supermemory.ai/auth/connect"; const AUTH_TIMEOUT = Number(process.env.SUPERMEMORY_AUTH_TIMEOUT) || 5 * 60_000; const CLIENT_NAME = "opencode"; @@ -28,7 +36,7 @@ export function loadCredentials(): Credentials | null { } } -function normalizeApiBaseUrl(apiBaseUrl: string | null | undefined): string | undefined { +export function normalizeApiBaseUrl(apiBaseUrl: string | null | undefined): string | undefined { if (!apiBaseUrl) return undefined; try { const url = new URL(apiBaseUrl); @@ -50,7 +58,19 @@ export function saveCredentials(apiKey: string, apiBaseUrl?: string): void { }; const normalizedApiBaseUrl = normalizeApiBaseUrl(apiBaseUrl); if (normalizedApiBaseUrl) credentials.apiBaseUrl = normalizedApiBaseUrl; - writeFileSync(CREDENTIALS_FILE, JSON.stringify(credentials, null, 2), { mode: 0o600 }); + + const temporaryFile = join( + CREDENTIALS_DIR, + `.credentials-${process.pid}-${randomBytes(6).toString("hex")}.tmp`, + ); + try { + writeFileSync(temporaryFile, JSON.stringify(credentials, null, 2), { + mode: 0o600, + }); + renameSync(temporaryFile, CREDENTIALS_FILE); + } finally { + if (existsSync(temporaryFile)) rmSync(temporaryFile); + } } export function clearCredentials(): boolean { @@ -59,11 +79,9 @@ export function clearCredentials(): boolean { return true; } -export interface AuthResult { - success: boolean; - apiKey?: string; - error?: string; -} +export type AuthResult = + | { success: true; apiKey: string; apiBaseUrl?: string } + | { success: false; error: string }; export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { return new Promise((resolve) => { @@ -91,10 +109,30 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { `); + return; + } + + const authError = + url.searchParams.get("error_description") || + url.searchParams.get("error"); + if (authError) { + res.writeHead(400, { "Content-Type": "text/html" }); + res.end(` + + + Cancelled + +
+

Connection Cancelled

+

Your existing credentials were not changed.

+
+ + + `); resolved = true; clearTimeout(timer); server.close(); - resolve({ success: false, error: "Invalid auth state" }); + resolve({ success: false, error: authError }); return; } @@ -102,7 +140,6 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { const apiBaseUrl = url.searchParams.get("api_url") || url.searchParams.get("api_base_url"); if (apiKey?.startsWith("sm_")) { - saveCredentials(apiKey, apiBaseUrl ?? undefined); res.writeHead(200, { "Content-Type": "text/html" }); res.end(` @@ -110,8 +147,8 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { Success
-

Connected!

-

You can close this window and return to your terminal.

+

Authorization Received!

+

Return to your terminal to finish verification.

@@ -119,7 +156,11 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { resolved = true; clearTimeout(timer); server.close(); - resolve({ success: true, apiKey }); + resolve({ + success: true, + apiKey, + apiBaseUrl: normalizeApiBaseUrl(apiBaseUrl), + }); } else { res.writeHead(400, { "Content-Type": "text/html" }); res.end(` @@ -162,7 +203,7 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { hostname: `opencode - ${hostname()}`, os: `${platform()}-${arch()}`, cwd: process.cwd(), - cli_version: "2.0.10", + cli_version: PLUGIN_VERSION, }); const authUrl = `${AUTH_BASE_URL}?${params.toString()}`; diff --git a/src/services/organization-switch.test.ts b/src/services/organization-switch.test.ts new file mode 100644 index 0000000..ae98acc --- /dev/null +++ b/src/services/organization-switch.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, test } from "bun:test"; +import { + getCredentialOverrideWarnings, + switchOrganizationCredential, + verifyOrganizationCredential, +} from "./organization-switch.js"; + +describe("organization switching", () => { + test("verifies and reports the organization from the selected key", async () => { + const result = await verifyOrganizationCredential( + "sm_org-1_secret", + "https://api.supermemory.ai/", + (async (url, init) => { + expect(url).toBe("https://api.supermemory.ai/v3/session"); + expect(init?.headers).toEqual({ + Authorization: "Bearer sm_org-1_secret", + "x-sm-source": "opencode", + }); + return new Response( + JSON.stringify({ org: { id: "org-1", name: "Engineering" } }), + { status: 200 }, + ); + }) as typeof fetch, + ); + + expect(result).toEqual({ + success: true, + organization: { id: "org-1", name: "Engineering" }, + }); + }); + + test("does not replace credentials when authorization is cancelled", async () => { + let saved = false; + const result = await switchOrganizationCredential({ + authorize: async () => ({ success: false, error: "Cancelled" }), + save: () => { + saved = true; + }, + defaultApiBaseUrl: "https://api.supermemory.ai", + }); + + expect(result).toEqual({ success: false, error: "Cancelled" }); + expect(saved).toBe(false); + }); + + test("does not replace credentials when the selected key cannot be verified", async () => { + let saved = false; + const result = await switchOrganizationCredential({ + authorize: async () => ({ success: true, apiKey: "sm_org-1_secret" }), + save: () => { + saved = true; + }, + defaultApiBaseUrl: "https://api.supermemory.ai", + verify: async () => ({ success: false, error: "Unauthorized" }), + }); + + expect(result).toEqual({ success: false, error: "Unauthorized" }); + expect(saved).toBe(false); + }); + + test("rejects a successful session response without an organization", async () => { + const result = await verifyOrganizationCredential( + "sm_unknown_secret", + "https://api.supermemory.ai", + (async () => + new Response(JSON.stringify({ user: { id: "user-1" } }), { + status: 200, + })) as unknown as typeof fetch, + ); + + expect(result).toEqual({ + success: false, + error: "The selected organization was missing from the session response.", + }); + }); + + test("saves only after verification succeeds", async () => { + let savedKey: string | undefined; + let savedApiBaseUrl: string | undefined; + const result = await switchOrganizationCredential({ + authorize: async () => ({ + success: true, + apiKey: "sm_org-2_secret", + apiBaseUrl: "https://custom.example.test", + }), + save: (apiKey, apiBaseUrl) => { + savedKey = apiKey; + savedApiBaseUrl = apiBaseUrl; + }, + defaultApiBaseUrl: "https://api.supermemory.ai", + verify: async () => ({ + success: true, + organization: { id: "org-2", name: "Research" }, + }), + }); + + expect(result).toEqual({ + success: true, + organization: { id: "org-2", name: "Research" }, + apiBaseUrl: "https://custom.example.test", + }); + expect(savedKey).toBe("sm_org-2_secret"); + expect(savedApiBaseUrl).toBe("https://custom.example.test"); + }); + + test("warns about every browser credential override", () => { + expect( + getCredentialOverrideWarnings({ + environmentApiKey: true, + configApiKeyPath: "/home/user/.config/opencode/supermemory.jsonc", + }), + ).toEqual([ + "SUPERMEMORY_API_KEY is set and takes precedence over the browser credential.", + "apiKey in /home/user/.config/opencode/supermemory.jsonc takes precedence over the browser credential.", + ]); + }); +}); diff --git a/src/services/organization-switch.ts b/src/services/organization-switch.ts new file mode 100644 index 0000000..b6c34bb --- /dev/null +++ b/src/services/organization-switch.ts @@ -0,0 +1,133 @@ +import type { AuthResult } from "./auth.js"; + +const SESSION_TIMEOUT_MS = 30_000; + +export interface SessionOrganization { + id: string; + name?: string; +} + +export type OrganizationVerificationResult = + | { success: true; organization: SessionOrganization } + | { success: false; error: string }; + +export type OrganizationSwitchResult = + | { + success: true; + organization: SessionOrganization; + apiBaseUrl: string; + } + | { success: false; error: string }; + +interface OrganizationSwitchDependencies { + authorize: () => Promise; + save: (apiKey: string, apiBaseUrl?: string) => void; + defaultApiBaseUrl: string; + verify?: ( + apiKey: string, + apiBaseUrl: string, + ) => Promise; +} + +interface SessionResponse { + org?: { + id?: unknown; + name?: unknown; + }; +} + +export async function verifyOrganizationCredential( + apiKey: string, + apiBaseUrl: string, + fetchImpl: typeof fetch = fetch, +): Promise { + try { + const response = await fetchImpl(`${apiBaseUrl.replace(/\/+$/, "")}/v3/session`, { + headers: { + Authorization: `Bearer ${apiKey}`, + "x-sm-source": "opencode", + }, + signal: AbortSignal.timeout(SESSION_TIMEOUT_MS), + }); + + if (!response.ok) { + return { + success: false, + error: `The selected organization could not be verified (HTTP ${response.status}).`, + }; + } + + const session = (await response.json()) as SessionResponse; + if (!session.org || typeof session.org.id !== "string" || !session.org.id) { + return { + success: false, + error: "The selected organization was missing from the session response.", + }; + } + + return { + success: true, + organization: { + id: session.org.id, + name: + typeof session.org.name === "string" && session.org.name + ? session.org.name + : undefined, + }, + }; + } catch (error) { + return { + success: false, + error: `The selected organization could not be verified: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } +} + +export async function switchOrganizationCredential( + dependencies: OrganizationSwitchDependencies, +): Promise { + const authorization = await dependencies.authorize(); + if (!authorization.success) return authorization; + + const apiBaseUrl = authorization.apiBaseUrl ?? dependencies.defaultApiBaseUrl; + const verify = dependencies.verify ?? verifyOrganizationCredential; + const verification = await verify(authorization.apiKey, apiBaseUrl); + if (!verification.success) return verification; + + try { + dependencies.save(authorization.apiKey, authorization.apiBaseUrl); + } catch (error) { + return { + success: false, + error: `The verified credentials could not be saved: ${ + error instanceof Error ? error.message : String(error) + }`, + }; + } + + return { + success: true, + organization: verification.organization, + apiBaseUrl, + }; +} + +export function getCredentialOverrideWarnings(options: { + environmentApiKey: boolean; + configApiKeyPath?: string; +}): string[] { + const warnings: string[] = []; + if (options.environmentApiKey) { + warnings.push( + "SUPERMEMORY_API_KEY is set and takes precedence over the browser credential.", + ); + } + if (options.configApiKeyPath) { + warnings.push( + `apiKey in ${options.configApiKeyPath} takes precedence over the browser credential.`, + ); + } + return warnings; +} From d2a9c0d55996d72b8325271f69c626d2500f6b40 Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Mon, 24 Aug 2026 19:05:34 +0530 Subject: [PATCH 2/3] separate login approval from organization switching --- src/cli.ts | 3 ++- src/services/auth.test.ts | 24 ++++++++++++++++++++++++ src/services/auth.ts | 35 +++++++++++++++++++++++++---------- 3 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 src/services/auth.test.ts diff --git a/src/cli.ts b/src/cli.ts index 6e57eab..25b59f3 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -614,7 +614,8 @@ async function switchOrganization(): Promise { console.log("Opening Supermemory so you can select an organization..."); const result = await switchOrganizationCredential({ - authorize: () => startAuthFlow(), + authorize: () => + startAuthFlow(undefined, { mode: "switch_organization" }), save: saveCredentials, defaultApiBaseUrl: DEFAULT_BASE_URL, }); diff --git a/src/services/auth.test.ts b/src/services/auth.test.ts new file mode 100644 index 0000000..b10c0a1 --- /dev/null +++ b/src/services/auth.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, test } from "bun:test"; +import { buildAuthUrl } from "./auth.js"; + +const CALLBACK_URL = + "http://127.0.0.1:43210/callback?state=expected-state"; + +describe("authentication URL modes", () => { + test("normal login does not request explicit organization switching", () => { + const authUrl = new URL(buildAuthUrl(CALLBACK_URL)); + + expect(authUrl.searchParams.get("callback")).toBe(CALLBACK_URL); + expect(authUrl.searchParams.get("client")).toBe("opencode"); + expect(authUrl.searchParams.has("mode")).toBe(false); + }); + + test("organization switching requests the explicit switch mode", () => { + const authUrl = new URL( + buildAuthUrl(CALLBACK_URL, { mode: "switch_organization" }), + ); + + expect(authUrl.searchParams.get("callback")).toBe(CALLBACK_URL); + expect(authUrl.searchParams.get("mode")).toBe("switch_organization"); + }); +}); diff --git a/src/services/auth.ts b/src/services/auth.ts index 56476f1..8f414ea 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -83,7 +83,30 @@ export type AuthResult = | { success: true; apiKey: string; apiBaseUrl?: string } | { success: false; error: string }; -export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { +export interface AuthFlowOptions { + mode?: "switch_organization"; +} + +export function buildAuthUrl( + callbackUrl: string, + options: AuthFlowOptions = {}, +): string { + const params = new URLSearchParams({ + callback: callbackUrl, + client: CLIENT_NAME, + hostname: `opencode - ${hostname()}`, + os: `${platform()}-${arch()}`, + cwd: process.cwd(), + cli_version: PLUGIN_VERSION, + }); + if (options.mode) params.set("mode", options.mode); + return `${AUTH_BASE_URL}?${params.toString()}`; +} + +export function startAuthFlow( + timeoutMs = AUTH_TIMEOUT, + options: AuthFlowOptions = {}, +): Promise { return new Promise((resolve) => { let resolved = false; const stateToken = randomBytes(16).toString("hex"); @@ -197,15 +220,7 @@ export function startAuthFlow(timeoutMs = AUTH_TIMEOUT): Promise { server.listen(0, "127.0.0.1", () => { const { port } = server.address() as AddressInfo; const callbackUrl = `http://127.0.0.1:${port}/callback?state=${stateToken}`; - const params = new URLSearchParams({ - callback: callbackUrl, - client: CLIENT_NAME, - hostname: `opencode - ${hostname()}`, - os: `${platform()}-${arch()}`, - cwd: process.cwd(), - cli_version: PLUGIN_VERSION, - }); - const authUrl = `${AUTH_BASE_URL}?${params.toString()}`; + const authUrl = buildAuthUrl(callbackUrl, options); console.log("Opening browser for authentication..."); console.log(`If it doesn't open, visit: ${authUrl}`); From 554b803efe79599f5e5ab1439442782fcfba937e Mon Sep 17 00:00:00 2001 From: Ishaan Gupta Date: Mon, 24 Aug 2026 20:07:40 +0530 Subject: [PATCH 3/3] Remove organization switch tests --- src/services/auth.test.ts | 24 ----- src/services/organization-switch.test.ts | 117 ----------------------- 2 files changed, 141 deletions(-) delete mode 100644 src/services/auth.test.ts delete mode 100644 src/services/organization-switch.test.ts diff --git a/src/services/auth.test.ts b/src/services/auth.test.ts deleted file mode 100644 index b10c0a1..0000000 --- a/src/services/auth.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { buildAuthUrl } from "./auth.js"; - -const CALLBACK_URL = - "http://127.0.0.1:43210/callback?state=expected-state"; - -describe("authentication URL modes", () => { - test("normal login does not request explicit organization switching", () => { - const authUrl = new URL(buildAuthUrl(CALLBACK_URL)); - - expect(authUrl.searchParams.get("callback")).toBe(CALLBACK_URL); - expect(authUrl.searchParams.get("client")).toBe("opencode"); - expect(authUrl.searchParams.has("mode")).toBe(false); - }); - - test("organization switching requests the explicit switch mode", () => { - const authUrl = new URL( - buildAuthUrl(CALLBACK_URL, { mode: "switch_organization" }), - ); - - expect(authUrl.searchParams.get("callback")).toBe(CALLBACK_URL); - expect(authUrl.searchParams.get("mode")).toBe("switch_organization"); - }); -}); diff --git a/src/services/organization-switch.test.ts b/src/services/organization-switch.test.ts deleted file mode 100644 index ae98acc..0000000 --- a/src/services/organization-switch.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - getCredentialOverrideWarnings, - switchOrganizationCredential, - verifyOrganizationCredential, -} from "./organization-switch.js"; - -describe("organization switching", () => { - test("verifies and reports the organization from the selected key", async () => { - const result = await verifyOrganizationCredential( - "sm_org-1_secret", - "https://api.supermemory.ai/", - (async (url, init) => { - expect(url).toBe("https://api.supermemory.ai/v3/session"); - expect(init?.headers).toEqual({ - Authorization: "Bearer sm_org-1_secret", - "x-sm-source": "opencode", - }); - return new Response( - JSON.stringify({ org: { id: "org-1", name: "Engineering" } }), - { status: 200 }, - ); - }) as typeof fetch, - ); - - expect(result).toEqual({ - success: true, - organization: { id: "org-1", name: "Engineering" }, - }); - }); - - test("does not replace credentials when authorization is cancelled", async () => { - let saved = false; - const result = await switchOrganizationCredential({ - authorize: async () => ({ success: false, error: "Cancelled" }), - save: () => { - saved = true; - }, - defaultApiBaseUrl: "https://api.supermemory.ai", - }); - - expect(result).toEqual({ success: false, error: "Cancelled" }); - expect(saved).toBe(false); - }); - - test("does not replace credentials when the selected key cannot be verified", async () => { - let saved = false; - const result = await switchOrganizationCredential({ - authorize: async () => ({ success: true, apiKey: "sm_org-1_secret" }), - save: () => { - saved = true; - }, - defaultApiBaseUrl: "https://api.supermemory.ai", - verify: async () => ({ success: false, error: "Unauthorized" }), - }); - - expect(result).toEqual({ success: false, error: "Unauthorized" }); - expect(saved).toBe(false); - }); - - test("rejects a successful session response without an organization", async () => { - const result = await verifyOrganizationCredential( - "sm_unknown_secret", - "https://api.supermemory.ai", - (async () => - new Response(JSON.stringify({ user: { id: "user-1" } }), { - status: 200, - })) as unknown as typeof fetch, - ); - - expect(result).toEqual({ - success: false, - error: "The selected organization was missing from the session response.", - }); - }); - - test("saves only after verification succeeds", async () => { - let savedKey: string | undefined; - let savedApiBaseUrl: string | undefined; - const result = await switchOrganizationCredential({ - authorize: async () => ({ - success: true, - apiKey: "sm_org-2_secret", - apiBaseUrl: "https://custom.example.test", - }), - save: (apiKey, apiBaseUrl) => { - savedKey = apiKey; - savedApiBaseUrl = apiBaseUrl; - }, - defaultApiBaseUrl: "https://api.supermemory.ai", - verify: async () => ({ - success: true, - organization: { id: "org-2", name: "Research" }, - }), - }); - - expect(result).toEqual({ - success: true, - organization: { id: "org-2", name: "Research" }, - apiBaseUrl: "https://custom.example.test", - }); - expect(savedKey).toBe("sm_org-2_secret"); - expect(savedApiBaseUrl).toBe("https://custom.example.test"); - }); - - test("warns about every browser credential override", () => { - expect( - getCredentialOverrideWarnings({ - environmentApiKey: true, - configApiKeyPath: "/home/user/.config/opencode/supermemory.jsonc", - }), - ).toEqual([ - "SUPERMEMORY_API_KEY is set and takes precedence over the browser credential.", - "apiKey in /home/user/.config/opencode/supermemory.jsonc takes precedence over the browser credential.", - ]); - }); -});