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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules/
dist/
*.log
.DS_Store
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ The interactive setup wizard will:
3. Let you select which repos to sync
4. Configure the plugin automatically

Running the wizard again will merge your changes with the existing configuration, preserving any custom settings like `debounceMs` or `credentialsPath` that you've modified.

## Manual Installation

Add to `~/.config/opencode/opencode.json`:
Expand Down
8 changes: 8 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

43 changes: 15 additions & 28 deletions cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { execSync } from "child_process"
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"
import { homedir } from "os"
import { join } from "path"
import { loadPluginConfigSync, mergeConfig } from "./lib/config"

const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
const OPENCODE_CONFIG_PATH = join(OPENCODE_CONFIG_DIR, "opencode.json")
Expand Down Expand Up @@ -76,17 +77,7 @@ function addPluginToConfig(config: Record<string, unknown>): Record<string, unkn
return { ...config, plugin: plugins }
}

function savePluginConfig(repositories: string[]): void {
const config = {
$schema: "https://raw.githubusercontent.com/activadee/opencode-auth-sync/main/schema.json",
enabled: true,
credentialsPath: "~/.local/share/opencode/auth.json",
secretName: "OPENCODE_AUTH_JSON",
repositories,
debounceMs: 1000,
}
writeFileSync(PLUGIN_CONFIG_PATH, JSON.stringify(config, null, 2) + "\n")
}


async function main() {
console.clear()
Expand Down Expand Up @@ -132,14 +123,8 @@ async function main() {
process.exit(1)
}

// Load existing config if present
let existingRepos: string[] = []
if (existsSync(PLUGIN_CONFIG_PATH)) {
try {
const existing = JSON.parse(readFileSync(PLUGIN_CONFIG_PATH, "utf-8"))
existingRepos = existing.repositories || []
} catch {}
}
const existingConfig = loadPluginConfigSync(PLUGIN_CONFIG_PATH)
const existingRepos = existingConfig.repositories || []

// Repository selection
const repoOptions = repos.map((repo) => ({
Expand All @@ -160,11 +145,11 @@ async function main() {
process.exit(0)
}

// Secret name configuration
const existingSecretName = existingConfig.secretName || "OPENCODE_AUTH_JSON"
const secretName = await p.text({
message: "GitHub secret name",
placeholder: "OPENCODE_AUTH_JSON",
defaultValue: "OPENCODE_AUTH_JSON",
placeholder: existingSecretName,
defaultValue: existingSecretName,
validate: (value) => {
if (value && !/^[A-Z_][A-Z0-9_]*$/.test(value)) {
return "Use uppercase letters, numbers, and underscores only"
Expand All @@ -191,14 +176,16 @@ async function main() {
// Execute setup
s.start("Configuring plugin")

// Save plugin config
mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true })

const userUpdates = {
repositories: selectedRepos as string[],
secretName: secretName || existingSecretName,
}
const mergedConfig = mergeConfig(existingConfig, userUpdates)
const pluginConfig = {
$schema: "https://raw.githubusercontent.com/activadee/opencode-auth-sync/main/schema.json",
enabled: true,
credentialsPath: "~/.local/share/opencode/auth.json",
secretName: secretName || "OPENCODE_AUTH_JSON",
repositories: selectedRepos as string[],
debounceMs: 1000,
...mergedConfig,
}
writeFileSync(PLUGIN_CONFIG_PATH, JSON.stringify(pluginConfig, null, 2) + "\n")

Expand Down
241 changes: 241 additions & 0 deletions lib/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,241 @@
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 { loadPluginConfigSync, mergeConfig, DEFAULT_CONFIG } from "./config"
import type { AuthSyncConfig } from "./types"

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

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

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

test("returns empty object when file does not exist", () => {
const result = loadPluginConfigSync("/nonexistent/path/config.json")
expect(result).toEqual({})
})

test("returns empty object when file contains invalid JSON", () => {
writeFileSync(testConfigPath, "not valid json {{{")
const result = loadPluginConfigSync(testConfigPath)
expect(result).toEqual({})
})

test("loads and parses valid config file", () => {
const config = {
enabled: true,
debounceMs: 5000,
secretName: "CUSTOM_SECRET",
repositories: ["owner/repo1", "owner/repo2"],
}
writeFileSync(testConfigPath, JSON.stringify(config))

const result = loadPluginConfigSync(testConfigPath)
expect(result).toEqual(config)
})

test("preserves custom fields in config", () => {
const config = {
enabled: false,
debounceMs: 3000,
credentialsPath: "/custom/path/auth.json",
secretName: "MY_SECRET",
repositories: ["org/private-repo"],
customField: "should be preserved",
}
writeFileSync(testConfigPath, JSON.stringify(config))

const result = loadPluginConfigSync(testConfigPath)
expect(result).toEqual(config)
})
})

describe("mergeConfig", () => {
test("returns defaults when both existing and updates are empty", () => {
const result = mergeConfig({}, {})
expect(result).toEqual(DEFAULT_CONFIG)
})

test("applies defaults for missing fields in existing config", () => {
const existing = { repositories: ["owner/repo"] }
const result = mergeConfig(existing, {})

expect(result.enabled).toBe(DEFAULT_CONFIG.enabled)
expect(result.debounceMs).toBe(DEFAULT_CONFIG.debounceMs)
expect(result.credentialsPath).toBe(DEFAULT_CONFIG.credentialsPath)
expect(result.secretName).toBe(DEFAULT_CONFIG.secretName)
expect(result.repositories).toEqual(["owner/repo"])
})

test("preserves existing values when no updates provided", () => {
const existing: Partial<AuthSyncConfig> = {
enabled: false,
debounceMs: 5000,
credentialsPath: "/custom/path/auth.json",
secretName: "CUSTOM_SECRET",
repositories: ["org/repo1", "org/repo2"],
}

const result = mergeConfig(existing, {})

expect(result.enabled).toBe(false)
expect(result.debounceMs).toBe(5000)
expect(result.credentialsPath).toBe("/custom/path/auth.json")
expect(result.secretName).toBe("CUSTOM_SECRET")
expect(result.repositories).toEqual(["org/repo1", "org/repo2"])
})

test("updates override existing values", () => {
const existing: Partial<AuthSyncConfig> = {
debounceMs: 5000,
secretName: "OLD_SECRET",
repositories: ["old/repo"],
}
const updates: Partial<AuthSyncConfig> = {
secretName: "NEW_SECRET",
repositories: ["new/repo1", "new/repo2"],
}

const result = mergeConfig(existing, updates)

expect(result.secretName).toBe("NEW_SECRET")
expect(result.repositories).toEqual(["new/repo1", "new/repo2"])
expect(result.debounceMs).toBe(5000)
})

test("updates override default values", () => {
const updates: Partial<AuthSyncConfig> = {
debounceMs: 3000,
enabled: false,
}

const result = mergeConfig({}, updates)

expect(result.debounceMs).toBe(3000)
expect(result.enabled).toBe(false)
expect(result.secretName).toBe(DEFAULT_CONFIG.secretName)
})

test("preserves debounceMs when only repositories updated", () => {
const existing: Partial<AuthSyncConfig> = {
debounceMs: 8000,
repositories: ["old/repo"],
}
const updates: Partial<AuthSyncConfig> = {
repositories: ["new/repo"],
}

const result = mergeConfig(existing, updates)

expect(result.debounceMs).toBe(8000)
expect(result.repositories).toEqual(["new/repo"])
})

test("preserves credentialsPath when only secretName updated", () => {
const existing: Partial<AuthSyncConfig> = {
credentialsPath: "/my/custom/auth.json",
secretName: "OLD_NAME",
}
const updates: Partial<AuthSyncConfig> = {
secretName: "NEW_NAME",
}

const result = mergeConfig(existing, updates)

expect(result.credentialsPath).toBe("/my/custom/auth.json")
expect(result.secretName).toBe("NEW_NAME")
})
})

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

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

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

test("full merge workflow: load existing, merge updates, preserve custom values", () => {
const existingConfig = {
enabled: true,
debounceMs: 5000,
credentialsPath: "~/.local/share/opencode/auth.json",
secretName: "MY_SECRET",
repositories: ["org/repo1"],
}
writeFileSync(testConfigPath, JSON.stringify(existingConfig))

const loaded = loadPluginConfigSync(testConfigPath)
const userUpdates = {
repositories: ["org/repo1", "org/repo2", "org/repo3"],
secretName: "UPDATED_SECRET",
}
const merged = mergeConfig(loaded, userUpdates)

expect(merged.debounceMs).toBe(5000)
expect(merged.enabled).toBe(true)
expect(merged.secretName).toBe("UPDATED_SECRET")
expect(merged.repositories).toEqual(["org/repo1", "org/repo2", "org/repo3"])
})

test("creates new config with defaults when file does not exist", () => {
const loaded = loadPluginConfigSync("/nonexistent/config.json")
const userUpdates = {
repositories: ["user/new-repo"],
secretName: "NEW_SECRET",
}
const merged = mergeConfig(loaded, userUpdates)

expect(merged.enabled).toBe(DEFAULT_CONFIG.enabled)
expect(merged.debounceMs).toBe(DEFAULT_CONFIG.debounceMs)
expect(merged.credentialsPath).toBe(DEFAULT_CONFIG.credentialsPath)
expect(merged.secretName).toBe("NEW_SECRET")
expect(merged.repositories).toEqual(["user/new-repo"])
})

test("handles reinstall scenario: preserves debounceMs while updating repos", () => {
const originalConfig = {
enabled: true,
debounceMs: 10000,
credentialsPath: "~/.local/share/opencode/auth.json",
secretName: "OPENCODE_AUTH_JSON",
repositories: ["org/repo1", "org/repo2"],
}
writeFileSync(testConfigPath, JSON.stringify(originalConfig))

const loaded = loadPluginConfigSync(testConfigPath)
const reinstallUpdates = {
repositories: ["org/repo1", "org/repo3"],
secretName: "OPENCODE_AUTH_JSON",
}
const merged = mergeConfig(loaded, reinstallUpdates)

expect(merged.debounceMs).toBe(10000)
expect(merged.repositories).toEqual(["org/repo1", "org/repo3"])
})
})

describe("DEFAULT_CONFIG", () => {
test("has expected default values", () => {
expect(DEFAULT_CONFIG.enabled).toBe(true)
expect(DEFAULT_CONFIG.credentialsPath).toBe("~/.local/share/opencode/auth.json")
expect(DEFAULT_CONFIG.secretName).toBe("OPENCODE_AUTH_JSON")
expect(DEFAULT_CONFIG.repositories).toEqual([])
expect(DEFAULT_CONFIG.debounceMs).toBe(1000)
})
})
Loading