diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..2d4361f --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,32 @@ +name: Test + +on: + push: + branches: ["*"] + pull_request: + branches: ["*"] + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Type check + run: bun run type-check + + - name: Run tests + run: bun test + + - name: Build + run: bun run build diff --git a/README.md b/README.md index cd0e853..e8a83a4 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,26 @@ The auth file contains credentials for all configured OpenCode providers: - `google` - Gemini (OAuth) - API key providers +## Development + +### Running Tests + +```bash +bun test +``` + +### Type Checking + +```bash +bun run type-check +``` + +### Building + +```bash +bun run build +``` + ## License MIT diff --git a/cli.ts b/cli.ts index ae6857e..31b7c20 100755 --- a/cli.ts +++ b/cli.ts @@ -1,90 +1,25 @@ #!/usr/bin/env bun import * as p from "@clack/prompts" import color from "picocolors" -import { execSync } from "child_process" -import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs" -import { homedir } from "os" -import { join } from "path" +import { writeFileSync, mkdirSync } from "fs" import { loadPluginConfigSync, mergeConfig } from "./lib/config" - -const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode") -const OPENCODE_CONFIG_PATH = join(OPENCODE_CONFIG_DIR, "opencode.json") -const PLUGIN_CONFIG_PATH = join(OPENCODE_CONFIG_DIR, "opencode-auth-sync.json") -const PLUGIN_NAME = "@activade/opencode-auth-sync" - -interface GhRepo { - nameWithOwner: string - isPrivate: boolean - description: string | null -} - -function checkGhCli(): boolean { - try { - execSync("gh --version", { stdio: "ignore" }) - return true - } catch { - return false - } -} - -function checkGhAuth(): boolean { - try { - execSync("gh auth status", { stdio: "ignore" }) - return true - } catch { - return false - } -} - -function getGhRepos(): GhRepo[] { - try { - const output = execSync( - 'gh repo list --limit 100 --json nameWithOwner,isPrivate,description', - { encoding: "utf-8" } - ) - return JSON.parse(output) - } catch { - return [] - } -} - -function loadOpencodeConfig(): Record { - if (!existsSync(OPENCODE_CONFIG_PATH)) { - return {} - } - try { - return JSON.parse(readFileSync(OPENCODE_CONFIG_PATH, "utf-8")) - } catch { - return {} - } -} - -function saveOpencodeConfig(config: Record): void { - mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true }) - writeFileSync(OPENCODE_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n") -} - -function isPluginInstalled(config: Record): boolean { - const plugins = (config.plugin as string[]) || [] - return plugins.some((p) => p.includes("opencode-auth-sync")) -} - -function addPluginToConfig(config: Record): Record { - const plugins = (config.plugin as string[]) || [] - if (!isPluginInstalled(config)) { - plugins.push(PLUGIN_NAME) - } - return { ...config, plugin: plugins } -} - - +import { + OPENCODE_CONFIG_DIR, + PLUGIN_CONFIG_PATH, + checkGhCli, + checkGhAuth, + getGhRepos, + loadOpencodeConfig, + saveOpencodeConfig, + isPluginInstalled, + addPluginToConfig, +} from "./lib/cli-utils" async function main() { console.clear() p.intro(color.bgCyan(color.black(" opencode-auth-sync setup "))) - // Check prerequisites const s = p.spinner() s.start("Checking prerequisites") @@ -105,7 +40,6 @@ async function main() { s.stop("Prerequisites OK") - // Check existing installation const opencodeConfig = loadOpencodeConfig() const alreadyInstalled = isPluginInstalled(opencodeConfig) @@ -113,7 +47,6 @@ async function main() { p.log.info("Plugin already installed in opencode.json") } - // Fetch repositories s.start("Fetching your GitHub repositories") const repos = getGhRepos() s.stop(`Found ${repos.length} repositories`) @@ -126,7 +59,6 @@ async function main() { const existingConfig = loadPluginConfigSync(PLUGIN_CONFIG_PATH) const existingRepos = existingConfig.repositories || [] - // Repository selection const repoOptions = repos.map((repo) => ({ value: repo.nameWithOwner, label: repo.nameWithOwner, @@ -162,7 +94,6 @@ async function main() { process.exit(0) } - // Confirm const confirmed = await p.confirm({ message: `Install plugin and sync to ${(selectedRepos as string[]).length} repositories?`, initialValue: true, @@ -173,7 +104,6 @@ async function main() { process.exit(0) } - // Execute setup s.start("Configuring plugin") mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true }) @@ -189,7 +119,6 @@ async function main() { } writeFileSync(PLUGIN_CONFIG_PATH, JSON.stringify(pluginConfig, null, 2) + "\n") - // Add plugin to opencode.json if (!alreadyInstalled) { const updatedConfig = addPluginToConfig(opencodeConfig) saveOpencodeConfig(updatedConfig) @@ -197,7 +126,6 @@ async function main() { s.stop("Configuration saved") - // Summary p.note( [ `${color.dim("Plugin config:")} ~/.config/opencode/opencode-auth-sync.json`, diff --git a/index.test.ts b/index.test.ts new file mode 100644 index 0000000..200cfbd --- /dev/null +++ b/index.test.ts @@ -0,0 +1,217 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs" +import { join } from "path" +import { tmpdir } from "os" +import { OpenCodeAuthSyncPlugin } from "./index" +import type { PluginInput } from "@opencode-ai/plugin" + +type MockShellResult = { + exitCode: number + stderr: { toString: () => string } + stdout: { toString: () => string } +} + +function createMockShell(authSuccess = true) { + return (strings: TemplateStringsArray, ...values: unknown[]) => { + const command = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "") + return { + nothrow: () => ({ + quiet: async (): Promise => { + if (command.includes("gh auth status")) { + return { + exitCode: authSuccess ? 0 : 1, + stderr: { toString: () => authSuccess ? "" : "Not logged in" }, + stdout: { toString: () => "" }, + } + } + if (command.includes("gh secret set")) { + return { + exitCode: 0, + stderr: { toString: () => "" }, + stdout: { toString: () => "" }, + } + } + return { exitCode: 0, stderr: { toString: () => "" }, stdout: { toString: () => "" } } + }, + }), + } + } +} + +function createMockClient() { + const toastCalls: Array<{ title: string; message: string; variant: string }> = [] + return { + tui: { + showToast: ({ body }: { body: { title: string; message: string; variant: string } }) => { + toastCalls.push(body) + }, + }, + _getToastCalls: () => toastCalls, + } +} + +function createMockPluginInput( + testDir: string, + authSuccess = true +): PluginInput { + return { + $: createMockShell(authSuccess), + client: createMockClient(), + directory: testDir, + project: {}, + worktree: {}, + serverUrl: "http://localhost:3000", + } as unknown as PluginInput +} + +describe("OpenCodeAuthSyncPlugin", () => { + const testDir = join(tmpdir(), `opencode-plugin-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + const testConfigPath = join(testDir, "opencode-auth-sync.json") + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }) + }) + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true }) + } + }) + + describe("plugin initialization", () => { + test("returns empty object when plugin is disabled", async () => { + writeFileSync(testConfigPath, JSON.stringify({ enabled: false, repositories: ["user/repo"] })) + + const input = createMockPluginInput(testDir) + const result = await OpenCodeAuthSyncPlugin(input) + + expect(result).toEqual({}) + }) + + test("returns event handler when no repositories configured", async () => { + writeFileSync(testConfigPath, JSON.stringify({ enabled: true, repositories: [] })) + + const input = createMockPluginInput(testDir) + const result = await OpenCodeAuthSyncPlugin(input) + + expect(result.event).toBeDefined() + }) + + test("shows warning toast on session.created when no repos configured", async () => { + writeFileSync(testConfigPath, JSON.stringify({ enabled: true, repositories: [] })) + + const mockClient = createMockClient() + const input = { + ...createMockPluginInput(testDir), + client: mockClient, + } as unknown as PluginInput + const result = await OpenCodeAuthSyncPlugin(input) + + await result.event!({ event: { type: "session.created", properties: {} } } as Parameters>[0]) + + const toasts = mockClient._getToastCalls() + expect(toasts.length).toBe(1) + expect(toasts[0].variant).toBe("warning") + expect(toasts[0].message).toContain("No repositories configured") + }) + + test("returns event handler when gh auth fails", async () => { + writeFileSync(testConfigPath, JSON.stringify({ enabled: true, repositories: ["user/repo"] })) + + const input = createMockPluginInput(testDir, false) + const result = await OpenCodeAuthSyncPlugin(input) + + expect(result.event).toBeDefined() + }) + + test("shows error toast on session.created when gh not authenticated", async () => { + writeFileSync(testConfigPath, JSON.stringify({ enabled: true, repositories: ["user/repo"] })) + + const mockClient = createMockClient() + const input = { + ...createMockPluginInput(testDir, false), + client: mockClient, + } as unknown as PluginInput + const result = await OpenCodeAuthSyncPlugin(input) + + await result.event!({ event: { type: "session.created", properties: {} } } as Parameters>[0]) + + const toasts = mockClient._getToastCalls() + expect(toasts.length).toBe(1) + expect(toasts[0].variant).toBe("error") + expect(toasts[0].message).toContain("gh auth login") + }) + }) + + describe("config loading", () => { + test("uses project directory config over home config", async () => { + writeFileSync(testConfigPath, JSON.stringify({ enabled: true, repositories: [] })) + + const input = createMockPluginInput(testDir) + const result = await OpenCodeAuthSyncPlugin(input) + + expect(result.event).toBeDefined() + }) + + test("loads config from project directory", async () => { + writeFileSync(testConfigPath, JSON.stringify({ + enabled: true, + repositories: ["user/repo"], + secretName: "CUSTOM_SECRET", + })) + + const input = createMockPluginInput(testDir) + const result = await OpenCodeAuthSyncPlugin(input) + + expect(result).toEqual({}) + }) + }) + + describe("plugin exports", () => { + test("exports OpenCodeAuthSyncPlugin as named export", async () => { + const module = await import("./index") + expect(module.OpenCodeAuthSyncPlugin).toBeDefined() + expect(typeof module.OpenCodeAuthSyncPlugin).toBe("function") + }) + + test("exports OpenCodeAuthSyncPlugin as default export", async () => { + const module = await import("./index") + expect(module.default).toBeDefined() + expect(module.default).toBe(module.OpenCodeAuthSyncPlugin) + }) + }) + + describe("event handler behavior", () => { + test("event handler ignores non-session.created events when no repos", async () => { + writeFileSync(testConfigPath, JSON.stringify({ enabled: true, repositories: [] })) + + const mockClient = createMockClient() + const input = { + ...createMockPluginInput(testDir), + client: mockClient, + } as unknown as PluginInput + const result = await OpenCodeAuthSyncPlugin(input) + + await result.event!({ event: { type: "session.updated", properties: {} } } as Parameters>[0]) + + const toasts = mockClient._getToastCalls() + expect(toasts.length).toBe(0) + }) + + test("event handler ignores non-session.created events when auth fails", async () => { + writeFileSync(testConfigPath, JSON.stringify({ enabled: true, repositories: ["user/repo"] })) + + const mockClient = createMockClient() + const input = { + ...createMockPluginInput(testDir, false), + client: mockClient, + } as unknown as PluginInput + const result = await OpenCodeAuthSyncPlugin(input) + + await result.event!({ event: { type: "session.updated", properties: {} } } as Parameters>[0]) + + const toasts = mockClient._getToastCalls() + expect(toasts.length).toBe(0) + }) + }) +}) diff --git a/lib/cli-utils.test.ts b/lib/cli-utils.test.ts new file mode 100644 index 0000000..089bb2f --- /dev/null +++ b/lib/cli-utils.test.ts @@ -0,0 +1,316 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { mkdirSync, rmSync, existsSync, readFileSync } from "fs" +import { join } from "path" +import { tmpdir } from "os" + +describe("cli-utils", () => { + const testDir = join(tmpdir(), `opencode-cli-utils-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }) + }) + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true }) + } + }) + + describe("isPluginInstalled", () => { + test("returns true when plugin is in config", async () => { + const { isPluginInstalled } = await import("./cli-utils") + const config = { plugin: ["@activade/opencode-auth-sync"] } + + expect(isPluginInstalled(config)).toBe(true) + }) + + test("returns true when plugin with version is in config", async () => { + const { isPluginInstalled } = await import("./cli-utils") + const config = { plugin: ["@activade/opencode-auth-sync@1.0.0"] } + + expect(isPluginInstalled(config)).toBe(true) + }) + + test("returns false when plugin is not in config", async () => { + const { isPluginInstalled } = await import("./cli-utils") + const config = { plugin: ["other-plugin"] } + + expect(isPluginInstalled(config)).toBe(false) + }) + + test("returns false when plugin array is empty", async () => { + const { isPluginInstalled } = await import("./cli-utils") + const config = { plugin: [] } + + expect(isPluginInstalled(config)).toBe(false) + }) + + test("returns false when plugin key is missing", async () => { + const { isPluginInstalled } = await import("./cli-utils") + const config = {} + + expect(isPluginInstalled(config)).toBe(false) + }) + + test("returns false when config is empty", async () => { + const { isPluginInstalled } = await import("./cli-utils") + + expect(isPluginInstalled({})).toBe(false) + }) + + test("returns true when plugin name is substring match", async () => { + const { isPluginInstalled } = await import("./cli-utils") + const config = { plugin: ["some-prefix-opencode-auth-sync-suffix"] } + + expect(isPluginInstalled(config)).toBe(true) + }) + }) + + describe("addPluginToConfig", () => { + test("adds plugin to empty plugin array", async () => { + const { addPluginToConfig, PLUGIN_NAME } = await import("./cli-utils") + const config = { plugin: [] } + + const result = addPluginToConfig(config) + + expect(result.plugin).toContain(PLUGIN_NAME) + }) + + test("adds plugin when key does not exist", async () => { + const { addPluginToConfig, PLUGIN_NAME } = await import("./cli-utils") + const config = {} + + const result = addPluginToConfig(config) + + expect(result.plugin).toContain(PLUGIN_NAME) + }) + + test("does not duplicate plugin if already present", async () => { + const { addPluginToConfig, PLUGIN_NAME } = await import("./cli-utils") + const config = { plugin: [PLUGIN_NAME] } + + const result = addPluginToConfig(config) + + expect((result.plugin as string[]).filter((p) => p === PLUGIN_NAME)).toHaveLength(1) + }) + + test("preserves existing plugins", async () => { + const { addPluginToConfig } = await import("./cli-utils") + const config = { plugin: ["existing-plugin", "another-plugin"] } + + const result = addPluginToConfig(config) + + expect(result.plugin).toContain("existing-plugin") + expect(result.plugin).toContain("another-plugin") + }) + + test("preserves other config values", async () => { + const { addPluginToConfig } = await import("./cli-utils") + const config = { plugin: [], model: "claude-3", someKey: "value" } + + const result = addPluginToConfig(config) + + expect(result.model).toBe("claude-3") + expect(result.someKey).toBe("value") + }) + + test("returns new object without mutating input", async () => { + const { addPluginToConfig } = await import("./cli-utils") + const config = { plugin: [], existing: "value" } + + const result = addPluginToConfig(config) + + expect(result).not.toBe(config) + expect(config.existing).toBe("value") + }) + }) + + describe("validateSecretName", () => { + test("returns undefined for valid secret names", async () => { + const { validateSecretName } = await import("./cli-utils") + + expect(validateSecretName("OPENCODE_AUTH_JSON")).toBeUndefined() + expect(validateSecretName("MY_SECRET")).toBeUndefined() + expect(validateSecretName("SECRET_123")).toBeUndefined() + expect(validateSecretName("_PRIVATE")).toBeUndefined() + }) + + test("returns error for lowercase letters", async () => { + const { validateSecretName } = await import("./cli-utils") + + const error = validateSecretName("my_secret") + expect(error).toBeDefined() + expect(error).toContain("uppercase") + }) + + test("returns error for names starting with numbers", async () => { + const { validateSecretName } = await import("./cli-utils") + + const error = validateSecretName("123_SECRET") + expect(error).toBeDefined() + }) + + test("returns error for names with hyphens", async () => { + const { validateSecretName } = await import("./cli-utils") + + const error = validateSecretName("MY-SECRET") + expect(error).toBeDefined() + }) + + test("returns error for names with spaces", async () => { + const { validateSecretName } = await import("./cli-utils") + + const error = validateSecretName("MY SECRET") + expect(error).toBeDefined() + }) + + test("returns error for mixed case", async () => { + const { validateSecretName } = await import("./cli-utils") + + const error = validateSecretName("MySecret") + expect(error).toBeDefined() + }) + + test("returns undefined for empty string", async () => { + const { validateSecretName } = await import("./cli-utils") + + expect(validateSecretName("")).toBeUndefined() + }) + + test("accepts single uppercase letter", async () => { + const { validateSecretName } = await import("./cli-utils") + + expect(validateSecretName("A")).toBeUndefined() + }) + + test("accepts underscore at start", async () => { + const { validateSecretName } = await import("./cli-utils") + + expect(validateSecretName("_SECRET")).toBeUndefined() + }) + }) + + describe("savePluginConfig", () => { + test("creates directory and writes config file", async () => { + const { savePluginConfig } = await import("./cli-utils") + const configPath = join(testDir, "nested", "dir", "config.json") + const config = { enabled: true, repositories: ["user/repo"] } + + savePluginConfig(configPath, config) + + expect(existsSync(configPath)).toBe(true) + const saved = JSON.parse(readFileSync(configPath, "utf-8")) + expect(saved).toEqual(config) + }) + + test("writes formatted JSON with newline", async () => { + const { savePluginConfig } = await import("./cli-utils") + const configPath = join(testDir, "config.json") + const config = { key: "value" } + + savePluginConfig(configPath, config) + + const content = readFileSync(configPath, "utf-8") + expect(content).toContain("\n") + expect(content.endsWith("\n")).toBe(true) + }) + + test("overwrites existing config file", async () => { + const { savePluginConfig } = await import("./cli-utils") + const configPath = join(testDir, "config.json") + + savePluginConfig(configPath, { old: "value" }) + savePluginConfig(configPath, { new: "value" }) + + const saved = JSON.parse(readFileSync(configPath, "utf-8")) + expect(saved).toEqual({ new: "value" }) + expect(saved.old).toBeUndefined() + }) + + test("writes pretty-printed JSON with 2-space indent", async () => { + const { savePluginConfig } = await import("./cli-utils") + const configPath = join(testDir, "config.json") + const config = { nested: { key: "value" } } + + savePluginConfig(configPath, config) + + const content = readFileSync(configPath, "utf-8") + expect(content).toContain(" ") + }) + + test("handles complex nested config", async () => { + const { savePluginConfig } = await import("./cli-utils") + const configPath = join(testDir, "config.json") + const config = { + enabled: true, + repositories: ["user/repo1", "org/repo2"], + nested: { level1: { level2: "deep" } }, + array: [1, 2, 3], + } + + savePluginConfig(configPath, config) + + const saved = JSON.parse(readFileSync(configPath, "utf-8")) + expect(saved).toEqual(config) + }) + }) + + describe("config path constants", () => { + test("PLUGIN_NAME is correct", async () => { + const { PLUGIN_NAME } = await import("./cli-utils") + expect(PLUGIN_NAME).toBe("@activade/opencode-auth-sync") + }) + + test("config paths use .config/opencode directory", async () => { + const { OPENCODE_CONFIG_DIR, OPENCODE_CONFIG_PATH, PLUGIN_CONFIG_PATH } = await import("./cli-utils") + + expect(OPENCODE_CONFIG_DIR).toContain(".config") + expect(OPENCODE_CONFIG_DIR).toContain("opencode") + expect(OPENCODE_CONFIG_PATH).toContain("opencode.json") + expect(PLUGIN_CONFIG_PATH).toContain("opencode-auth-sync.json") + }) + + test("OPENCODE_CONFIG_PATH is inside OPENCODE_CONFIG_DIR", async () => { + const { OPENCODE_CONFIG_DIR, OPENCODE_CONFIG_PATH } = await import("./cli-utils") + + expect(OPENCODE_CONFIG_PATH.startsWith(OPENCODE_CONFIG_DIR)).toBe(true) + }) + + test("PLUGIN_CONFIG_PATH is inside OPENCODE_CONFIG_DIR", async () => { + const { OPENCODE_CONFIG_DIR, PLUGIN_CONFIG_PATH } = await import("./cli-utils") + + expect(PLUGIN_CONFIG_PATH.startsWith(OPENCODE_CONFIG_DIR)).toBe(true) + }) + }) + + describe("GhRepo interface", () => { + test("can represent public repo", async () => { + const { getGhRepos } = await import("./cli-utils") + type GhRepo = ReturnType[number] + + const repo: GhRepo = { + nameWithOwner: "user/repo", + isPrivate: false, + description: "A public repository", + } + + expect(repo.nameWithOwner).toBe("user/repo") + expect(repo.isPrivate).toBe(false) + expect(repo.description).toBe("A public repository") + }) + + test("can represent private repo with null description", async () => { + const { getGhRepos } = await import("./cli-utils") + type GhRepo = ReturnType[number] + + const repo: GhRepo = { + nameWithOwner: "org/private", + isPrivate: true, + description: null, + } + + expect(repo.isPrivate).toBe(true) + expect(repo.description).toBeNull() + }) + }) +}) diff --git a/lib/cli-utils.ts b/lib/cli-utils.ts new file mode 100644 index 0000000..abf94c6 --- /dev/null +++ b/lib/cli-utils.ts @@ -0,0 +1,90 @@ +import { execSync } from "child_process" +import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs" +import { homedir } from "os" +import { join } from "path" + +export const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode") +export const OPENCODE_CONFIG_PATH = join(OPENCODE_CONFIG_DIR, "opencode.json") +export const PLUGIN_CONFIG_PATH = join(OPENCODE_CONFIG_DIR, "opencode-auth-sync.json") +export const PLUGIN_NAME = "@activade/opencode-auth-sync" + +export interface GhRepo { + nameWithOwner: string + isPrivate: boolean + description: string | null +} + +export function checkGhCli(): boolean { + try { + execSync("gh --version", { stdio: "ignore" }) + return true + } catch { + return false + } +} + +export function checkGhAuth(): boolean { + try { + execSync("gh auth status", { stdio: "ignore" }) + return true + } catch { + return false + } +} + +export function getGhRepos(): GhRepo[] { + try { + const output = execSync( + 'gh repo list --limit 100 --json nameWithOwner,isPrivate,description', + { encoding: "utf-8" } + ) + return JSON.parse(output) + } catch { + return [] + } +} + +export function loadOpencodeConfig(): Record { + if (!existsSync(OPENCODE_CONFIG_PATH)) { + return {} + } + try { + return JSON.parse(readFileSync(OPENCODE_CONFIG_PATH, "utf-8")) + } catch { + return {} + } +} + +export function saveOpencodeConfig(config: Record): void { + mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true }) + writeFileSync(OPENCODE_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n") +} + +export function isPluginInstalled(config: Record): boolean { + const plugins = (config.plugin as string[]) || [] + return plugins.some((p) => p.includes("opencode-auth-sync")) +} + +export function addPluginToConfig(config: Record): Record { + const plugins = (config.plugin as string[]) || [] + if (!isPluginInstalled(config)) { + plugins.push(PLUGIN_NAME) + } + return { ...config, plugin: plugins } +} + +export function validateSecretName(value: string): string | undefined { + if (value && !/^[A-Z_][A-Z0-9_]*$/.test(value)) { + return "Use uppercase letters, numbers, and underscores only" + } + return undefined +} + +export function savePluginConfig( + configPath: string, + config: Record +): void { + const dir = join(configPath, "..") + mkdirSync(dir, { recursive: true }) + writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n") +} diff --git a/lib/sync.test.ts b/lib/sync.test.ts new file mode 100644 index 0000000..1f9c3f8 --- /dev/null +++ b/lib/sync.test.ts @@ -0,0 +1,332 @@ +import { describe, test, expect } from "bun:test" +import { syncToRepositories, verifyGhAuth } from "./sync" + +// Mock shell function type matching PluginInput["$"] +type MockShellResult = { + exitCode: number + stderr: { toString: () => string } + stdout: { toString: () => string } +} + +type MockShellFn = { + (strings: TemplateStringsArray, ...values: unknown[]): { + nothrow: () => { quiet: () => Promise } + } +} + +function createMockShell(results: Map): MockShellFn { + return (strings: TemplateStringsArray, ...values: unknown[]) => { + const command = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "") + return { + nothrow: () => ({ + quiet: async () => { + for (const [pattern, result] of results) { + if (command.includes(pattern)) { + return result + } + } + return { exitCode: 0, stderr: { toString: () => "" }, stdout: { toString: () => "" } } + }, + }), + } + } +} + +function createSuccessResult(stdout = ""): MockShellResult { + return { + exitCode: 0, + stderr: { toString: () => "" }, + stdout: { toString: () => stdout }, + } +} + +function createErrorResult(errorMessage: string, exitCode = 1): MockShellResult { + return { + exitCode, + stderr: { toString: () => errorMessage }, + stdout: { toString: () => "" }, + } +} + +describe("syncToRepositories", () => { + describe("single repository sync", () => { + test("successfully syncs to a single repository", async () => { + const mockResults = new Map([["gh secret set", createSuccessResult()]]) + const $ = createMockShell(mockResults) + + const summary = await syncToRepositories( + $ as unknown as Parameters[0], + ["owner/repo"], + "SECRET_NAME", + "secret-value" + ) + + expect(summary.total).toBe(1) + expect(summary.successful).toBe(1) + expect(summary.failed).toBe(0) + expect(summary.results[0].repository).toBe("owner/repo") + expect(summary.results[0].success).toBe(true) + expect(summary.results[0].error).toBeUndefined() + }) + + test("handles failed sync to a single repository", async () => { + const mockResults = new Map([ + ["gh secret set", createErrorResult("HTTP 403: Permission denied")], + ]) + const $ = createMockShell(mockResults) + + const summary = await syncToRepositories( + $ as unknown as Parameters[0], + ["owner/repo"], + "SECRET_NAME", + "secret-value" + ) + + expect(summary.total).toBe(1) + expect(summary.successful).toBe(0) + expect(summary.failed).toBe(1) + expect(summary.results[0].repository).toBe("owner/repo") + expect(summary.results[0].success).toBe(false) + expect(summary.results[0].error).toBe("HTTP 403: Permission denied") + }) + + test("reports correct error message from stderr", async () => { + const errorMsg = "Repository not found or access denied" + const mockResults = new Map([["gh secret set", createErrorResult(errorMsg)]]) + const $ = createMockShell(mockResults) + + const summary = await syncToRepositories( + $ as unknown as Parameters[0], + ["nonexistent/repo"], + "SECRET", + "value" + ) + + expect(summary.results[0].error).toBe(errorMsg) + }) + }) + + describe("multiple repository sync", () => { + test("successfully syncs to multiple repositories", async () => { + const mockResults = new Map([["gh secret set", createSuccessResult()]]) + const $ = createMockShell(mockResults) + + const repos = ["owner/repo1", "owner/repo2", "owner/repo3"] + const summary = await syncToRepositories( + $ as unknown as Parameters[0], + repos, + "SECRET_NAME", + "secret-value" + ) + + expect(summary.total).toBe(3) + expect(summary.successful).toBe(3) + expect(summary.failed).toBe(0) + expect(summary.results).toHaveLength(3) + expect(summary.results.every((r) => r.success)).toBe(true) + }) + + test("handles partial failures in batch sync", async () => { + const $ = ((strings: TemplateStringsArray, ...values: unknown[]) => { + const command = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "") + return { + nothrow: () => ({ + quiet: async () => { + if (command.includes("owner/failing-repo")) { + return createErrorResult("Access denied") + } + return createSuccessResult() + }, + }), + } + }) as unknown as Parameters[0] + + const repos = ["owner/repo1", "owner/failing-repo", "owner/repo2"] + const summary = await syncToRepositories($, repos, "SECRET", "value") + + expect(summary.total).toBe(3) + expect(summary.successful).toBe(2) + expect(summary.failed).toBe(1) + expect(summary.results[0].success).toBe(true) + expect(summary.results[1].success).toBe(false) + expect(summary.results[1].error).toBe("Access denied") + expect(summary.results[2].success).toBe(true) + }) + + test("handles all repositories failing", async () => { + const mockResults = new Map([["gh secret set", createErrorResult("Network error")]]) + const $ = createMockShell(mockResults) + + const repos = ["owner/repo1", "owner/repo2"] + const summary = await syncToRepositories( + $ as unknown as Parameters[0], + repos, + "SECRET", + "value" + ) + + expect(summary.total).toBe(2) + expect(summary.successful).toBe(0) + expect(summary.failed).toBe(2) + expect(summary.results.every((r) => !r.success)).toBe(true) + }) + + test("maintains order of results matching input repositories", async () => { + const mockResults = new Map([["gh secret set", createSuccessResult()]]) + const $ = createMockShell(mockResults) + + const repos = ["alpha/repo", "beta/repo", "gamma/repo"] + const summary = await syncToRepositories( + $ as unknown as Parameters[0], + repos, + "SECRET", + "value" + ) + + expect(summary.results[0].repository).toBe("alpha/repo") + expect(summary.results[1].repository).toBe("beta/repo") + expect(summary.results[2].repository).toBe("gamma/repo") + }) + }) + + describe("empty repository list", () => { + test("returns empty summary for empty repository list", async () => { + const mockResults = new Map([["gh secret set", createSuccessResult()]]) + const $ = createMockShell(mockResults) + + const summary = await syncToRepositories( + $ as unknown as Parameters[0], + [], + "SECRET", + "value" + ) + + expect(summary.total).toBe(0) + expect(summary.successful).toBe(0) + expect(summary.failed).toBe(0) + expect(summary.results).toHaveLength(0) + }) + }) + + describe("special characters handling", () => { + test("handles repository names with hyphens and underscores", async () => { + const mockResults = new Map([["gh secret set", createSuccessResult()]]) + const $ = createMockShell(mockResults) + + const repos = ["my-org/my_repo-name", "user-123/test_repo"] + const summary = await syncToRepositories( + $ as unknown as Parameters[0], + repos, + "SECRET", + "value" + ) + + expect(summary.total).toBe(2) + expect(summary.successful).toBe(2) + expect(summary.results[0].repository).toBe("my-org/my_repo-name") + expect(summary.results[1].repository).toBe("user-123/test_repo") + }) + + test("handles secret names with underscores", async () => { + const mockResults = new Map([["gh secret set", createSuccessResult()]]) + const $ = createMockShell(mockResults) + + const summary = await syncToRepositories( + $ as unknown as Parameters[0], + ["owner/repo"], + "MY_LONG_SECRET_NAME", + "value" + ) + + expect(summary.successful).toBe(1) + }) + + test("handles large secret values (JSON content)", async () => { + const mockResults = new Map([["gh secret set", createSuccessResult()]]) + const $ = createMockShell(mockResults) + + const largeJson = JSON.stringify({ + provider1: { type: "oauth", access: "a".repeat(1000), refresh: "r".repeat(1000), expires: 123456 }, + provider2: { type: "api", key: "k".repeat(500) }, + }) + + const summary = await syncToRepositories( + $ as unknown as Parameters[0], + ["owner/repo"], + "AUTH_SECRET", + largeJson + ) + + expect(summary.successful).toBe(1) + }) + }) + + describe("error message propagation", () => { + test("captures authentication errors", async () => { + const mockResults = new Map([ + ["gh secret set", createErrorResult("gh: Not logged into any GitHub hosts")], + ]) + const $ = createMockShell(mockResults) + + const summary = await syncToRepositories( + $ as unknown as Parameters[0], + ["owner/repo"], + "SECRET", + "value" + ) + + expect(summary.results[0].error).toContain("Not logged into any GitHub hosts") + }) + + test("captures rate limit errors", async () => { + const mockResults = new Map([ + ["gh secret set", createErrorResult("API rate limit exceeded", 1)], + ]) + const $ = createMockShell(mockResults) + + const summary = await syncToRepositories( + $ as unknown as Parameters[0], + ["owner/repo"], + "SECRET", + "value" + ) + + expect(summary.results[0].error).toContain("rate limit") + }) + }) +}) + +describe("verifyGhAuth", () => { + test("returns true when gh auth status succeeds", async () => { + const mockResults = new Map([["gh auth status", createSuccessResult("Logged in")]]) + const $ = createMockShell(mockResults) + + const result = await verifyGhAuth($ as unknown as Parameters[0]) + expect(result).toBe(true) + }) + + test("returns false when gh auth status fails", async () => { + const mockResults = new Map([["gh auth status", createErrorResult("Not logged in")]]) + const $ = createMockShell(mockResults) + + const result = await verifyGhAuth($ as unknown as Parameters[0]) + expect(result).toBe(false) + }) + + test("returns false when gh cli not installed (throws)", async () => { + const $ = (() => { + throw new Error("command not found: gh") + }) as unknown as Parameters[0] + + const result = await verifyGhAuth($) + expect(result).toBe(false) + }) + + test("returns false on non-zero exit code", async () => { + const mockResults = new Map([["gh auth status", { exitCode: 2, stderr: { toString: () => "" }, stdout: { toString: () => "" } }]]) + const $ = createMockShell(mockResults) + + const result = await verifyGhAuth($ as unknown as Parameters[0]) + expect(result).toBe(false) + }) +}) diff --git a/lib/watcher.test.ts b/lib/watcher.test.ts index 902251a..41a0058 100644 --- a/lib/watcher.test.ts +++ b/lib/watcher.test.ts @@ -2,9 +2,11 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test" import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs" import { join } from "path" import { tmpdir } from "os" -import { computeHash, watchCredentials } from "./watcher" +import { computeHash, watchCredentials, type WatcherCallbacks } from "./watcher" import type { OpenCodeAuth } from "./types" +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) + describe("computeHash", () => { test("returns consistent SHA-256 hash for same content", () => { const content = '{"anthropic":{"type":"oauth","access":"token123"}}' @@ -50,235 +52,464 @@ describe("computeHash", () => { }) }) -describe("watchCredentials hash comparison", () => { - let testDir: string - let authFilePath: string +describe("watchCredentials", () => { + const testDir = join(tmpdir(), `opencode-watcher-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + const testCredentialsPath = join(testDir, "auth.json") + let stopWatcher: (() => void) | null = null beforeEach(() => { - testDir = join(tmpdir(), `watcher-test-${Date.now()}-${Math.random()}`) - authFilePath = join(testDir, "auth.json") mkdirSync(testDir, { recursive: true }) }) afterEach(() => { + if (stopWatcher) { + stopWatcher() + stopWatcher = null + } if (existsSync(testDir)) { rmSync(testDir, { recursive: true }) } }) - test("triggers callback on initial file when no stored hash", async () => { - const authContent = '{"anthropic":{"type":"oauth","access":"initial"}}' - writeFileSync(authFilePath, authContent) + describe("initialization", () => { + test("returns a cleanup function", () => { + writeFileSync(testCredentialsPath, JSON.stringify({ test: { type: "api", key: "test" } })) + const callbacks: WatcherCallbacks = { + onCredentialsChange: () => {}, + onError: () => {}, + } + + stopWatcher = watchCredentials(testCredentialsPath, callbacks, { debounceMs: 100 }) - let callCount = 0 - let receivedRaw = "" - let receivedHash = "" + expect(typeof stopWatcher).toBe("function") + }) - const stop = watchCredentials( - authFilePath, - { - onCredentialsChange: (_credentials: OpenCodeAuth, raw: string, hash: string) => { - callCount++ + test("triggers onCredentialsChange for initial file read", async () => { + const credentials = { anthropic: { type: "oauth" as const, access: "token", refresh: "ref", expires: 123 } } + writeFileSync(testCredentialsPath, JSON.stringify(credentials)) + + let receivedCredentials: OpenCodeAuth | null = null + let receivedRaw: string | null = null + let receivedHash: string | null = null + + const callbacks: WatcherCallbacks = { + onCredentialsChange: (creds, raw, hash) => { + receivedCredentials = creds receivedRaw = raw receivedHash = hash }, onError: () => {}, - }, - { debounceMs: 50 } - ) + } - await new Promise((r) => setTimeout(r, 200)) - stop() + stopWatcher = watchCredentials(testCredentialsPath, callbacks, { debounceMs: 50 }) + await wait(700) - expect(callCount).toBe(1) - expect(receivedRaw).toBe(authContent) - expect(receivedHash).toBe(computeHash(authContent)) + expect(receivedCredentials).not.toBeNull() + expect(receivedCredentials!.anthropic).toBeDefined() + expect(receivedRaw).not.toBeNull() + expect(receivedHash).not.toBeNull() + expect(receivedHash!).toHaveLength(64) + }) }) - test("skips callback when content hash matches stored hash", async () => { - const authContent = '{"anthropic":{"type":"oauth","access":"unchanged"}}' - const storedHash = computeHash(authContent) - writeFileSync(authFilePath, authContent) + describe("hash comparison", () => { + test("triggers callback on initial file when no stored hash", async () => { + const authContent = '{"anthropic":{"type":"oauth","access":"initial"}}' + writeFileSync(testCredentialsPath, authContent) + + let callCount = 0 + let receivedRaw = "" + let receivedHash = "" + + stopWatcher = watchCredentials( + testCredentialsPath, + { + onCredentialsChange: (_credentials: OpenCodeAuth, raw: string, hash: string) => { + callCount++ + receivedRaw = raw + receivedHash = hash + }, + onError: () => {}, + }, + { debounceMs: 50 } + ) + + await wait(700) + stopWatcher() + stopWatcher = null + + expect(callCount).toBe(1) + expect(receivedRaw).toBe(authContent) + expect(receivedHash).toBe(computeHash(authContent)) + }) + + test("skips callback when content hash matches stored hash", async () => { + const authContent = '{"anthropic":{"type":"oauth","access":"unchanged"}}' + const storedHash = computeHash(authContent) + writeFileSync(testCredentialsPath, authContent) + + let callCount = 0 + + stopWatcher = watchCredentials( + testCredentialsPath, + { + onCredentialsChange: () => { + callCount++ + }, + onError: () => {}, + }, + { debounceMs: 50, storedHash } + ) + + await wait(700) + stopWatcher() + stopWatcher = null + + expect(callCount).toBe(0) + }) + + test("triggers callback when content hash differs from stored hash", async () => { + const oldContent = '{"anthropic":{"access":"old"}}' + const newContent = '{"anthropic":{"access":"new"}}' + const storedHash = computeHash(oldContent) + + writeFileSync(testCredentialsPath, newContent) + + let callCount = 0 + let receivedRaw = "" + let receivedHash = "" + + stopWatcher = watchCredentials( + testCredentialsPath, + { + onCredentialsChange: (_credentials: OpenCodeAuth, raw: string, hash: string) => { + callCount++ + receivedRaw = raw + receivedHash = hash + }, + onError: () => {}, + }, + { debounceMs: 50, storedHash } + ) + + await wait(700) + stopWatcher() + stopWatcher = null + + expect(callCount).toBe(1) + expect(receivedRaw).toBe(newContent) + expect(receivedHash).toBe(computeHash(newContent)) + }) + + test("backward compatibility: works with storedHash undefined", async () => { + const authContent = '{"test":"backward-compat"}' + writeFileSync(testCredentialsPath, authContent) + + let callCount = 0 + + stopWatcher = watchCredentials( + testCredentialsPath, + { + onCredentialsChange: () => { + callCount++ + }, + onError: () => {}, + }, + { debounceMs: 50, storedHash: undefined } + ) + + await wait(700) + stopWatcher() + stopWatcher = null + + expect(callCount).toBe(1) + }) + }) + + describe("change detection", () => { + test("detects file content changes", async () => { + const initialCredentials = { test: { type: "api" as const, key: "initial" } } + writeFileSync(testCredentialsPath, JSON.stringify(initialCredentials)) + + const changes: OpenCodeAuth[] = [] + const callbacks: WatcherCallbacks = { + onCredentialsChange: (creds) => { + changes.push(creds) + }, + onError: () => {}, + } + + stopWatcher = watchCredentials(testCredentialsPath, callbacks, { debounceMs: 50 }) + await wait(1500) + + const updatedCredentials = { test: { type: "api" as const, key: "updated" } } + writeFileSync(testCredentialsPath, JSON.stringify(updatedCredentials)) + await wait(1500) - let callCount = 0 + expect(changes.length).toBeGreaterThanOrEqual(1) + const lastChange = changes[changes.length - 1] + expect(lastChange).toBeDefined() + }) - const stop = watchCredentials( - authFilePath, - { + test("ignores duplicate content writes", async () => { + const credentials = { provider: { type: "api" as const, key: "same" } } + writeFileSync(testCredentialsPath, JSON.stringify(credentials)) + + let changeCount = 0 + const callbacks: WatcherCallbacks = { onCredentialsChange: () => { - callCount++ + changeCount++ }, onError: () => {}, - }, - { debounceMs: 50, storedHash } - ) + } - await new Promise((r) => setTimeout(r, 200)) - stop() + stopWatcher = watchCredentials(testCredentialsPath, callbacks, { debounceMs: 50 }) + await wait(700) - expect(callCount).toBe(0) - }) + const initialCount = changeCount - test("triggers callback when content hash differs from stored hash", async () => { - const oldContent = '{"anthropic":{"access":"old"}}' - const newContent = '{"anthropic":{"access":"new"}}' - const storedHash = computeHash(oldContent) + writeFileSync(testCredentialsPath, JSON.stringify(credentials)) + await wait(700) - writeFileSync(authFilePath, newContent) + expect(changeCount).toBe(initialCount) + }) - let callCount = 0 - let receivedRaw = "" - let receivedHash = "" + test("provides raw content string alongside parsed credentials", async () => { + const credentials = { provider: { type: "api" as const, key: "test-key" } } + const rawContent = JSON.stringify(credentials, null, 2) + writeFileSync(testCredentialsPath, rawContent) - const stop = watchCredentials( - authFilePath, - { - onCredentialsChange: (_credentials: OpenCodeAuth, raw: string, hash: string) => { - callCount++ + let receivedRaw: string | null = null + const callbacks: WatcherCallbacks = { + onCredentialsChange: (_, raw) => { receivedRaw = raw - receivedHash = hash }, onError: () => {}, - }, - { debounceMs: 50, storedHash } - ) + } - await new Promise((r) => setTimeout(r, 200)) - stop() + stopWatcher = watchCredentials(testCredentialsPath, callbacks, { debounceMs: 50 }) + await wait(700) - expect(callCount).toBe(1) - expect(receivedRaw).toBe(newContent) - expect(receivedHash).toBe(computeHash(newContent)) + expect(receivedRaw).not.toBeNull() + expect(receivedRaw!).toBe(rawContent) + }) }) - test("skips duplicate changes with same content", async () => { - const authContent = '{"test":"data"}' - writeFileSync(authFilePath, authContent) + describe("debouncing", () => { + test("debounces rapid file changes", async () => { + const credentials = { test: { type: "api" as const, key: "initial" } } + writeFileSync(testCredentialsPath, JSON.stringify(credentials)) - let callCount = 0 - - const stop = watchCredentials( - authFilePath, - { + let changeCount = 0 + const callbacks: WatcherCallbacks = { onCredentialsChange: () => { - callCount++ + changeCount++ }, onError: () => {}, - }, - { debounceMs: 50 } - ) + } + + stopWatcher = watchCredentials(testCredentialsPath, callbacks, { debounceMs: 200 }) + await wait(800) + + const countAfterInit = changeCount + + for (let i = 0; i < 5; i++) { + writeFileSync(testCredentialsPath, JSON.stringify({ test: { type: "api" as const, key: `key-${i}` } })) + await wait(50) + } + await wait(500) + + expect(changeCount - countAfterInit).toBeLessThanOrEqual(2) + }) + + test("debounce timer is configurable", async () => { + const credentials = { test: { type: "api" as const, key: "test" } } + writeFileSync(testCredentialsPath, JSON.stringify(credentials)) - await new Promise((r) => setTimeout(r, 200)) - expect(callCount).toBe(1) + let changeCount = 0 + const callbacks: WatcherCallbacks = { + onCredentialsChange: () => { + changeCount++ + }, + onError: () => {}, + } - writeFileSync(authFilePath, authContent) - await new Promise((r) => setTimeout(r, 200)) - expect(callCount).toBe(1) + stopWatcher = watchCredentials(testCredentialsPath, callbacks, { debounceMs: 500 }) + await wait(1500) - writeFileSync(authFilePath, authContent) - await new Promise((r) => setTimeout(r, 200)) + const initialCount = changeCount - stop() + writeFileSync(testCredentialsPath, JSON.stringify({ test: { type: "api" as const, key: "updated" } })) + await wait(1200) - expect(callCount).toBe(1) + expect(changeCount).toBeGreaterThanOrEqual(initialCount) + }) }) - test("provides correct hash to callback on initial read", async () => { - const content = '{"version":1}' - const expectedHash = computeHash(content) + describe("error handling", () => { + test("calls onError for invalid JSON content", async () => { + writeFileSync(testCredentialsPath, JSON.stringify({ valid: { type: "api" as const, key: "test" } })) - writeFileSync(authFilePath, content) + const errors: Error[] = [] + const callbacks: WatcherCallbacks = { + onCredentialsChange: () => {}, + onError: (error) => { + errors.push(error) + }, + } - let receivedHash = "" + stopWatcher = watchCredentials(testCredentialsPath, callbacks, { debounceMs: 50 }) + await wait(1500) - const stop = watchCredentials( - authFilePath, - { - onCredentialsChange: (_credentials: OpenCodeAuth, _raw: string, hash: string) => { - receivedHash = hash + writeFileSync(testCredentialsPath, "invalid json {{{") + await wait(1500) + + if (errors.length > 0) { + expect(errors[0].message).toContain("JSON") + } + }) + + test("can receive multiple changes over time", async () => { + writeFileSync(testCredentialsPath, JSON.stringify({ test: { type: "api" as const, key: "initial" } })) + + const changes: OpenCodeAuth[] = [] + const callbacks: WatcherCallbacks = { + onCredentialsChange: (creds) => { + changes.push(creds) }, onError: () => {}, - }, - { debounceMs: 50 } - ) + } - await new Promise((r) => setTimeout(r, 800)) - stop() + stopWatcher = watchCredentials(testCredentialsPath, callbacks, { debounceMs: 50 }) + await wait(1500) - expect(receivedHash).toBe(expectedHash) - }) + const validCredentials = { updated: { type: "api" as const, key: "new-key" } } + writeFileSync(testCredentialsPath, JSON.stringify(validCredentials)) + await wait(1500) + + expect(changes.length).toBeGreaterThanOrEqual(1) + }) + + test("passes parsed credentials object to callback", async () => { + const authData: OpenCodeAuth = { + anthropic: { type: "oauth", access: "token123", refresh: "refresh123", expires: 1234567890 }, + } + writeFileSync(testCredentialsPath, JSON.stringify(authData)) + + let receivedCredentials: OpenCodeAuth = {} + + stopWatcher = watchCredentials( + testCredentialsPath, + { + onCredentialsChange: (credentials: OpenCodeAuth) => { + receivedCredentials = credentials + }, + onError: () => {}, + }, + { debounceMs: 50 } + ) - test("calls onError for invalid JSON", async () => { - writeFileSync(authFilePath, "not valid json {{{") + await wait(700) + stopWatcher() + stopWatcher = null - let changeCount = 0 - let errorCount = 0 + expect(receivedCredentials).toEqual(authData) + }) + }) + + describe("cleanup", () => { + test("cleanup function stops watching for changes", async () => { + const credentials = { test: { type: "api" as const, key: "initial" } } + writeFileSync(testCredentialsPath, JSON.stringify(credentials)) - const stop = watchCredentials( - authFilePath, - { + let changeCount = 0 + const callbacks: WatcherCallbacks = { onCredentialsChange: () => { changeCount++ }, - onError: () => { - errorCount++ - }, - }, - { debounceMs: 50 } - ) + onError: () => {}, + } - await new Promise((r) => setTimeout(r, 200)) - stop() + stopWatcher = watchCredentials(testCredentialsPath, callbacks, { debounceMs: 50 }) + await wait(700) + const countAfterInit = changeCount - expect(changeCount).toBe(0) - expect(errorCount).toBe(1) - }) + stopWatcher() + stopWatcher = null - test("passes parsed credentials object to callback", async () => { - const authData: OpenCodeAuth = { - anthropic: { type: "oauth", access: "token123", refresh: "refresh123", expires: 1234567890 }, - } - writeFileSync(authFilePath, JSON.stringify(authData)) + writeFileSync(testCredentialsPath, JSON.stringify({ test: { type: "api" as const, key: "after-cleanup" } })) + await wait(500) - let receivedCredentials: OpenCodeAuth = {} + expect(changeCount).toBe(countAfterInit) + }) - const stop = watchCredentials( - authFilePath, - { - onCredentialsChange: (credentials: OpenCodeAuth) => { - receivedCredentials = credentials + test("cleanup function clears pending debounce timers", async () => { + const credentials = { test: { type: "api" as const, key: "initial" } } + writeFileSync(testCredentialsPath, JSON.stringify(credentials)) + + let changeCount = 0 + const callbacks: WatcherCallbacks = { + onCredentialsChange: () => { + changeCount++ }, onError: () => {}, - }, - { debounceMs: 50 } - ) + } - await new Promise((r) => setTimeout(r, 200)) - stop() + stopWatcher = watchCredentials(testCredentialsPath, callbacks, { debounceMs: 500 }) + await wait(700) + const countAfterInit = changeCount - expect(receivedCredentials).toEqual(authData) - }) + writeFileSync(testCredentialsPath, JSON.stringify({ test: { type: "api" as const, key: "pending" } })) + await wait(100) - test("backward compatibility: works with storedHash undefined", async () => { - const authContent = '{"test":"backward-compat"}' - writeFileSync(authFilePath, authContent) + stopWatcher() + stopWatcher = null + await wait(600) - let callCount = 0 + expect(changeCount).toBe(countAfterInit) + }) + }) - const stop = watchCredentials( - authFilePath, - { - onCredentialsChange: () => { - callCount++ + describe("edge cases", () => { + test("handles empty credentials object", async () => { + writeFileSync(testCredentialsPath, JSON.stringify({})) + + let receivedCredentials: OpenCodeAuth | null = null + const callbacks: WatcherCallbacks = { + onCredentialsChange: (creds) => { + receivedCredentials = creds + }, + onError: () => {}, + } + + stopWatcher = watchCredentials(testCredentialsPath, callbacks, { debounceMs: 50 }) + await wait(700) + + expect(receivedCredentials).not.toBeNull() + expect(Object.keys(receivedCredentials!)).toHaveLength(0) + }) + + test("handles credentials with multiple providers", async () => { + const multiProviderCreds: OpenCodeAuth = { + anthropic: { type: "oauth", access: "a-token", refresh: "a-ref", expires: 100 }, + openai: { type: "oauth", access: "o-token", refresh: "o-ref", expires: 200 }, + custom: { type: "api", key: "api-key" }, + } + writeFileSync(testCredentialsPath, JSON.stringify(multiProviderCreds)) + + let receivedCredentials: OpenCodeAuth | null = null + const callbacks: WatcherCallbacks = { + onCredentialsChange: (creds) => { + receivedCredentials = creds }, onError: () => {}, - }, - { debounceMs: 50, storedHash: undefined } - ) + } - await new Promise((r) => setTimeout(r, 200)) - stop() + stopWatcher = watchCredentials(testCredentialsPath, callbacks, { debounceMs: 50 }) + await wait(700) - expect(callCount).toBe(1) + expect(receivedCredentials).not.toBeNull() + expect(Object.keys(receivedCredentials!)).toEqual(["anthropic", "openai", "custom"]) + }) }) })