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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Create `~/.config/opencode/opencode-auth-sync.json`:
| `secretName` | string | `OPENCODE_AUTH_JSON` | GitHub secret name |
| `repositories` | string[] | `[]` | Repositories to sync (`owner/repo` format) |
| `debounceMs` | number | `1000` | Debounce delay for file changes |
| `authFileHashes` | object | (auto-managed) | Per-repository SHA-256 hashes of last synced auth.json (managed by plugin) |

## Prerequisites

Expand All @@ -64,8 +65,11 @@ Create `~/.config/opencode/opencode-auth-sync.json`:

1. Plugin watches `~/.local/share/opencode/auth.json` for changes
2. When tokens refresh, the file updates
3. Plugin syncs the entire auth file to configured repositories via `gh secret set`
4. Toast notifications show sync status
3. Plugin computes a SHA-256 hash of the file content and compares it against the stored hash
4. If the hash differs (content actually changed), syncs to configured repositories via `gh secret set`
5. Toast notifications show sync status

The hash-based change detection reduces unnecessary GitHub API calls when file metadata changes but content remains the same.

## Using the Secret in GitHub Actions

Expand Down
60 changes: 43 additions & 17 deletions index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Plugin, PluginInput } from "@opencode-ai/plugin"
import { loadConfig, expandPath } from "./lib/config"
import { loadConfig, expandPath, getConfigPath, saveConfig } from "./lib/config"
import { watchCredentials } from "./lib/watcher"
import { syncToRepositories, verifyGhAuth } from "./lib/sync"
import type { OpenCodeAuth } from "./lib/types"
import type { AuthSyncConfig, OpenCodeAuth } from "./lib/types"

const PLUGIN_NAME = "opencode-auth-sync"

Expand Down Expand Up @@ -50,26 +50,50 @@ export const OpenCodeAuthSyncPlugin: Plugin = async ({ $, client, directory }: P
}

const credentialsPath = expandPath(config.credentialsPath)
let isFirstSync = true
const configPath = getConfigPath(directory)
let currentHashes: Record<string, string> = { ...config.authFileHashes }
let stopWatching: (() => void) | null = null

const handleCredentialsChange = async (_credentials: OpenCodeAuth, raw: string) => {
const action = isFirstSync ? "Initial sync" : "Syncing"
showToast(`${action} to ${config.repositories.length} repo(s)...`, "info", 2000)
const persistHashes = async (hashes: Record<string, string>) => {
if (!configPath) return

const summary = await syncToRepositories($, config.repositories, config.secretName, raw)
try {
currentHashes = { ...hashes }
const updatedConfig: AuthSyncConfig = { ...config, authFileHashes: currentHashes }
await saveConfig(configPath, updatedConfig)
} catch {
showToast("Could not save config, sync may repeat on restart", "warning", 3000)
}
}

if (summary.failed === 0) {
showToast(`Synced to ${summary.successful} repo(s)`, "success", 3000)
} else {
const failedRepos = summary.results
.filter((r) => !r.success)
.map((r) => r.repository)
.join(", ")
showToast(`${summary.successful} synced, ${summary.failed} failed: ${failedRepos}`, "warning", 5000)
const handleCredentialsChange = async (_credentials: OpenCodeAuth, raw: string, hash: string) => {
const reposNeedingSync = config.repositories.filter(
(repo) => currentHashes[repo] !== hash
)

if (reposNeedingSync.length === 0) {
return
}

const isInitialSync = Object.keys(currentHashes).length === 0
const action = isInitialSync ? "Initial sync" : "Syncing"
showToast(`${action} to ${reposNeedingSync.length} repo(s)...`, "info", 2000)

const summary = await syncToRepositories($, reposNeedingSync, config.secretName, raw)

const updatedHashes = { ...currentHashes }
for (const result of summary.results) {
if (result.success) {
updatedHashes[result.repository] = hash
} else {
showToast(`Failed to sync to ${result.repository}: ${result.error}`, "error", 5000)
}
}

isFirstSync = false
if (summary.successful > 0) {
await persistHashes(updatedHashes)
showToast(`Synced to ${summary.successful} repo(s)`, "success", 3000)
}
}

const handleError = async (error: Error) => {
Expand All @@ -82,7 +106,9 @@ export const OpenCodeAuthSyncPlugin: Plugin = async ({ $, client, directory }: P
onCredentialsChange: handleCredentialsChange,
onError: handleError,
},
config.debounceMs
{
debounceMs: config.debounceMs,
}
)

return {}
Expand Down
193 changes: 191 additions & 2 deletions lib/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { mkdirSync, writeFileSync, rmSync, existsSync } from "fs"
import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "fs"
import { join } from "path"
import { tmpdir } from "os"
import { loadPluginConfigSync, mergeConfig, DEFAULT_CONFIG } from "./config"
import { loadPluginConfigSync, mergeConfig, DEFAULT_CONFIG, saveConfig, getConfigPath } from "./config"
import type { AuthSyncConfig } from "./types"

describe("loadPluginConfigSync", () => {
Expand Down Expand Up @@ -239,3 +239,192 @@ describe("DEFAULT_CONFIG", () => {
expect(DEFAULT_CONFIG.debounceMs).toBe(1000)
})
})

describe("saveConfig", () => {
const testDir = join(tmpdir(), `opencode-auth-sync-save-${Date.now()}`)
const testConfigPath = join(testDir, "config.json")

beforeEach(() => {
mkdirSync(testDir, { recursive: true })
})

afterEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true })
}
})

test("writes config to file with proper JSON formatting", async () => {
const config: Partial<AuthSyncConfig> = {
enabled: true,
repositories: ["org/repo"],
secretName: "TEST_SECRET",
}

await saveConfig(testConfigPath, config)

const content = readFileSync(testConfigPath, "utf-8")
const parsed = JSON.parse(content)

expect(parsed).toEqual(config)
expect(content).toContain("\n")
})

test("saves config with authFileHashes field", async () => {
const config: Partial<AuthSyncConfig> = {
enabled: true,
repositories: ["org/repo"],
authFileHashes: { "org/repo": "abc123def456" },
}

await saveConfig(testConfigPath, config)

const content = readFileSync(testConfigPath, "utf-8")
const parsed = JSON.parse(content)

expect(parsed.authFileHashes).toEqual({ "org/repo": "abc123def456" })
})

test("overwrites existing config file", async () => {
const oldConfig = { enabled: false, repositories: ["old/repo"] }
writeFileSync(testConfigPath, JSON.stringify(oldConfig))

const newConfig: Partial<AuthSyncConfig> = {
enabled: true,
repositories: ["new/repo"],
authFileHashes: { "new/repo": "newhash123" },
}

await saveConfig(testConfigPath, newConfig)

const content = readFileSync(testConfigPath, "utf-8")
const parsed = JSON.parse(content)

expect(parsed.enabled).toBe(true)
expect(parsed.repositories).toEqual(["new/repo"])
expect(parsed.authFileHashes).toEqual({ "new/repo": "newhash123" })
})
})

describe("getConfigPath", () => {
const testDir = join(tmpdir(), `opencode-auth-sync-path-${Date.now()}`)
const projectConfigPath = join(testDir, "opencode-auth-sync.json")

beforeEach(() => {
mkdirSync(testDir, { recursive: true })
})

afterEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true })
}
})

test("returns project config path when it exists", () => {
writeFileSync(projectConfigPath, JSON.stringify({ enabled: true }))

const result = getConfigPath(testDir)
expect(result).toBe(projectConfigPath)
})

test("returns string when some config file exists", () => {
const result = getConfigPath(testDir)
expect(typeof result === "string" || result === null).toBe(true)
})
})

describe("authFileHashes in config", () => {
const testDir = join(tmpdir(), `opencode-auth-sync-hash-${Date.now()}`)
const testConfigPath = join(testDir, "config.json")

beforeEach(() => {
mkdirSync(testDir, { recursive: true })
})

afterEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true })
}
})

test("loads config with authFileHashes field", () => {
const config = {
enabled: true,
repositories: ["org/repo"],
authFileHashes: { "org/repo": "sha256hashvalue123" },
}
writeFileSync(testConfigPath, JSON.stringify(config))

const result = loadPluginConfigSync(testConfigPath)

expect(result.authFileHashes).toEqual({ "org/repo": "sha256hashvalue123" })
})

test("backward compatibility: loads config without authFileHashes field", () => {
const config = {
enabled: true,
repositories: ["org/repo"],
secretName: "SECRET",
}
writeFileSync(testConfigPath, JSON.stringify(config))

const result = loadPluginConfigSync(testConfigPath)

expect(result.authFileHashes).toBeUndefined()
expect(result.enabled).toBe(true)
expect(result.repositories).toEqual(["org/repo"])
})

test("mergeConfig preserves authFileHashes from existing config", () => {
const existing: Partial<AuthSyncConfig> = {
enabled: true,
repositories: ["old/repo"],
authFileHashes: { "old/repo": "existinghash" },
}
const updates: Partial<AuthSyncConfig> = {
repositories: ["new/repo"],
}

const result = mergeConfig(existing, updates)

expect(result.authFileHashes).toEqual({ "old/repo": "existinghash" })
expect(result.repositories).toEqual(["new/repo"])
})

test("mergeConfig allows updating authFileHashes", () => {
const existing: Partial<AuthSyncConfig> = {
enabled: true,
authFileHashes: { "org/repo": "oldhash" },
}
const updates: Partial<AuthSyncConfig> = {
authFileHashes: { "org/repo": "newhash", "org/repo2": "hash2" },
}

const result = mergeConfig(existing, updates)

expect(result.authFileHashes).toEqual({ "org/repo": "newhash", "org/repo2": "hash2" })
})

test("full workflow: load, update hashes, save, reload", async () => {
const initialConfig = {
enabled: true,
repositories: ["org/repo"],
secretName: "SECRET",
}
writeFileSync(testConfigPath, JSON.stringify(initialConfig))

const loaded = loadPluginConfigSync(testConfigPath)
expect(loaded.authFileHashes).toBeUndefined()

const updated: Partial<AuthSyncConfig> = {
...loaded,
authFileHashes: { "org/repo": "newlycomputedhash" },
}
await saveConfig(testConfigPath, updated)

const reloaded = loadPluginConfigSync(testConfigPath)
expect(reloaded.authFileHashes).toEqual({ "org/repo": "newlycomputedhash" })
expect(reloaded.enabled).toBe(true)
expect(reloaded.repositories).toEqual(["org/repo"])
})
})
25 changes: 24 additions & 1 deletion lib/config.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { readFile } from "fs/promises"
import { readFile, writeFile } from "fs/promises"
import { existsSync, readFileSync } from "fs"
import { homedir } from "os"
import { join } from "path"
Expand Down Expand Up @@ -62,3 +62,26 @@ export function expandPath(path: string): string {
}
return path
}

export function getConfigPath(projectDir?: string): string | null {
const locations = [
projectDir && join(projectDir, "opencode-auth-sync.json"),
join(homedir(), ".config", "opencode", "opencode-auth-sync.json"),
].filter(Boolean) as string[]

for (const configPath of locations) {
if (existsSync(configPath)) {
return configPath
}
}

return null
}

export async function saveConfig(
configPath: string,
config: Partial<AuthSyncConfig>
): Promise<void> {
const content = JSON.stringify(config, null, 2)
await writeFile(configPath, content, "utf-8")
}
1 change: 1 addition & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export interface AuthSyncConfig {
secretName: string
repositories: string[]
debounceMs?: number
authFileHashes?: Record<string, string>
}

export interface OAuthEntry {
Expand Down
Loading