From 35929794ac8b0baaf1765b9891c74980e18e9a6e Mon Sep 17 00:00:00 2001 From: Khalil Gharbaoui Date: Sun, 1 Feb 2026 02:52:22 +0100 Subject: [PATCH 01/14] refactor: type normalized sync config Adds tests for extra path defaults and normalized plans. --- src/sync/config.test.ts | 6 ++++++ src/sync/config.ts | 16 ++++++++++++++-- src/sync/paths.test.ts | 9 +++++---- src/sync/paths.ts | 11 ++++++----- 4 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/sync/config.test.ts b/src/sync/config.test.ts index e8b925c..6c9f247 100644 --- a/src/sync/config.test.ts +++ b/src/sync/config.test.ts @@ -77,6 +77,12 @@ describe('normalizeSyncConfig', () => { const normalized = normalizeSyncConfig({}); expect(normalized.includeModelFavorites).toBe(true); }); + + it('defaults extra path lists when omitted', () => { + const normalized = normalizeSyncConfig({ includeSecrets: true }); + expect(normalized.extraSecretPaths).toEqual([]); + expect(normalized.extraConfigPaths).toEqual([]); + }); }); describe('canCommitMcpSecrets', () => { diff --git a/src/sync/config.ts b/src/sync/config.ts index cdc6b2d..60482e8 100644 --- a/src/sync/config.ts +++ b/src/sync/config.ts @@ -22,6 +22,16 @@ export interface SyncConfig { extraConfigPaths?: string[]; } +export interface NormalizedSyncConfig extends SyncConfig { + includeSecrets: boolean; + includeMcpSecrets: boolean; + includeSessions: boolean; + includePromptStash: boolean; + includeModelFavorites: boolean; + extraSecretPaths: string[]; + extraConfigPaths: string[]; +} + export interface SyncState { lastPull?: string; lastPush?: string; @@ -47,7 +57,7 @@ export async function chmodIfExists(filePath: string, mode: number): Promise { +export async function loadSyncConfig( + locations: SyncLocations +): Promise { if (!(await pathExists(locations.syncConfigPath))) { return null; } diff --git a/src/sync/paths.test.ts b/src/sync/paths.test.ts index 06764f4..e685596 100644 --- a/src/sync/paths.test.ts +++ b/src/sync/paths.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import type { SyncConfig } from './config.js'; +import { normalizeSyncConfig } from './config.js'; import { buildSyncPlan, resolveSyncLocations, resolveXdgPaths } from './paths.js'; describe('resolveXdgPaths', () => { @@ -50,7 +51,7 @@ describe('buildSyncPlan', () => { extraConfigPaths: ['/home/test/.config/opencode/custom.json'], }; - const plan = buildSyncPlan(config, locations, '/repo', 'linux'); + const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux'); const secretItems = plan.items.filter((item) => item.isSecret); expect(secretItems.length).toBe(0); @@ -68,7 +69,7 @@ describe('buildSyncPlan', () => { extraConfigPaths: ['/home/test/.config/opencode/custom.json'], }; - const plan = buildSyncPlan(config, locations, '/repo', 'linux'); + const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux'); const secretItems = plan.items.filter((item) => item.isSecret); expect(secretItems.length).toBe(2); @@ -84,7 +85,7 @@ describe('buildSyncPlan', () => { includeSecrets: false, }; - const plan = buildSyncPlan(config, locations, '/repo', 'linux'); + const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux'); const favoritesItem = plan.items.find((item) => item.localPath.endsWith('/.local/state/opencode/model.json') ); @@ -92,7 +93,7 @@ describe('buildSyncPlan', () => { expect(favoritesItem).toBeTruthy(); const disabledPlan = buildSyncPlan( - { ...config, includeModelFavorites: false }, + normalizeSyncConfig({ ...config, includeModelFavorites: false }), locations, '/repo', 'linux' diff --git a/src/sync/paths.ts b/src/sync/paths.ts index 6440394..bee74f3 100644 --- a/src/sync/paths.ts +++ b/src/sync/paths.ts @@ -1,7 +1,6 @@ import crypto from 'node:crypto'; import path from 'node:path'; - -import type { SyncConfig } from './config.js'; +import type { NormalizedSyncConfig, SyncConfig } from './config.js'; export interface XdgPaths { homeDir: string; @@ -167,7 +166,7 @@ export function resolveRepoRoot(config: SyncConfig | null, locations: SyncLocati } export function buildSyncPlan( - config: SyncConfig, + config: NormalizedSyncConfig, locations: SyncLocations, repoRoot: string, platform: NodeJS.Platform = process.platform @@ -185,6 +184,8 @@ export function buildSyncPlan( const configManifestPath = path.join(repoConfigRoot, 'extra-manifest.json'); const items: SyncItem[] = []; + const authJsonPath = path.join(dataRoot, 'auth.json'); + const mcpAuthJsonPath = path.join(dataRoot, 'mcp-auth.json'); const addFile = (name: string, isSecret: boolean, isConfigFile: boolean): void => { items.push({ @@ -223,14 +224,14 @@ export function buildSyncPlan( if (config.includeSecrets) { items.push( { - localPath: path.join(dataRoot, 'auth.json'), + localPath: authJsonPath, repoPath: path.join(repoDataRoot, 'auth.json'), type: 'file', isSecret: true, isConfigFile: false, }, { - localPath: path.join(dataRoot, 'mcp-auth.json'), + localPath: mcpAuthJsonPath, repoPath: path.join(repoDataRoot, 'mcp-auth.json'), type: 'file', isSecret: true, From 070065473753f7b61d937b117f00899c1a2b6eb3 Mon Sep 17 00:00:00 2001 From: Khalil Gharbaoui Date: Sun, 1 Feb 2026 01:45:36 +0100 Subject: [PATCH 02/14] docs: add 1Password backend plan Clarifies how to keep auth tokens out of git while syncing config. --- docs/1Password.md | 197 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 197 insertions(+) create mode 100644 docs/1Password.md diff --git a/docs/1Password.md b/docs/1Password.md new file mode 100644 index 0000000..3b8f3d8 --- /dev/null +++ b/docs/1Password.md @@ -0,0 +1,197 @@ +# Fork Feature: 1Password Secrets Backend (NO secrets in git) + +## Problem +Upstream `opencode-synced` can sync OpenCode secrets by committing: +- `~/.local/share/opencode/auth.json` +- `~/.local/share/opencode/mcp-auth.json` + +We want to keep using the plugin for config/sessions, but **never store tokens in git**. + +## Goal +Add an optional secrets backend to this plugin: +- `auth.json` and `mcp-auth.json` are stored in **1Password** (as opaque blobs) +- they are **pulled** from 1Password after syncing config +- they are **pushed** back to 1Password when changed +- they are **never committed** to git (even if `includeSecrets: true`) +- keep everything else working exactly like upstream + +## Non-goals +- Don’t parse or reinterpret `auth.json` / `mcp-auth.json` structure. +- Don’t implement a filesystem watcher daemon. Keep it command-based. +- Don’t leak secrets in logs/errors. + +## Configuration (add to opencode-synced.jsonc) +Add a new optional config block: + +```jsonc +{ + "includeSecrets": false, + "secretsBackend": { + "type": "1password", + "vault": "Personal", + "documents": { + "authJson": "opencode-auth.json", + "mcpAuthJson": "opencode-mcp-auth.json", + "envFile": ".env.opencode" // optional: store+restore the env file too + } + } +} +``` + +Rules: + +If secretsBackend.type is missing, run upstream behavior. + +If type === "1password", auth.json and mcp-auth.json must NOT be included in git sync, regardless of includeSecrets. + +1Password Storage Approach +Use 1Password Document items to store the raw files. + +Required CLI operations (execute via child process; never print file contents): + +op document get --vault --out-file + +op document create --vault --title + +op document edit --vault + +Implementation Plan (do in order) +1) Locate current secret sync logic +Search the repo for: + +includeSecrets + +auth.json / mcp-auth.json + +extraSecretPaths + +/sync-pull /sync-push +Identify the exact function(s) that assemble the list of paths to copy/commit. + +2) Add config typing + validation +Extend config types to include secretsBackend. + +Validate: + +vault required + +documents.authJson required + +documents.mcpAuthJson required + +documents.envFile optional + +3) Add a SecretsBackend interface +Internal interface: + +pull(): Promise // 1P -> local files + +push(): Promise // local files -> 1P + +status(): Promise<{ ok: boolean; message: string }> (optional) + +4) Implement OnePasswordBackend +Implementation rules: + +Use child_process to call op. + +Detect if op is installed; return a clear, non-secret error. + +For pull: + +op document get --vault --out-file + +atomically write to target path (write temp + rename) + +set restrictive perms (0600) where possible + +If document is missing, do not fail hard; just skip. + +For push: + +if local file doesn’t exist: skip. + +create doc if missing; otherwise edit doc. + +Files to manage: + +~/.local/share/opencode/auth.json + +~/.local/share/opencode/mcp-auth.json + +Optional: ~/.config/opencode/.env.opencode (only if configured) + +5) Wire backend into sync lifecycle +Hook points: + +After /sync-pull applies repo changes -> call backend.pull() + +Before /sync-push commits/pushes -> call backend.push() + +Add explicit commands: + +/sync-secrets-pull + +/sync-secrets-push + +/sync-secrets-status + +6) Enforce “never commit auth files” +When secretsBackend.type === "1password": + +Ensure the git sync path list excludes: + +~/.local/share/opencode/auth.json + +~/.local/share/opencode/mcp-auth.json + +Additionally: + +Detect if these files are already tracked in the sync repo. + +If yes: stop and print remediation instructions (remove + rewrite history). + +7) Change detection (recommended) +Add lightweight hashing: + +compute SHA256 of local auth.json and mcp-auth.json + +store last pushed hash in plugin state + +only call backend.push() when changed (avoid unnecessary 1P calls) + +8) QA / Acceptance Tests (manual) +Machine A: + +Configure secretsBackend=1password + +Run /sync-secrets-push (creates docs if missing) + +Run /sync-push (must NOT commit auth files) + +Machine B: + +/sync-link then /sync-pull + +/sync-secrets-pull + +Verify OpenCode is authenticated without manual token copy + +Update tokens: + +Run opencode auth login or OpenCode /connect (updates auth.json) + +Run /sync-secrets-push + +On machine B run /sync-secrets-pull and verify updated auth works + +Security Constraints (strict) +Never print secrets. + +Never write secrets into the repo. + +Never include secrets in thrown error messages. + +Ensure local auth files are chmod 0600 where supported. + +If 1Password backend fails, do not destroy local auth files. From b9455283e70b80eaefd9d07f5570abb877d7197d Mon Sep 17 00:00:00 2001 From: Khalil Gharbaoui Date: Sun, 1 Feb 2026 01:45:49 +0100 Subject: [PATCH 03/14] docs: update AGENTS purpose Mentions optional 1Password secrets backend support. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index c291745..b4dfe32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,4 +69,4 @@ - **Type**: ES Module package for opencode plugin system - **Target**: Bun runtime, ES2021+ -- **Purpose**: Sync global opencode config across machines via GitHub +- **Purpose**: Sync global opencode config across machines via GitHub, with optional secrets support (e.g., 1Password backend) From f6a56e28b2f34dd6f6ba08d3cebfc3fc30806048 Mon Sep 17 00:00:00 2001 From: Khalil Gharbaoui Date: Sun, 1 Feb 2026 02:53:23 +0100 Subject: [PATCH 04/14] feat: add secrets backend config support Lets the sync plan omit auth files when a 1Password backend is configured. --- src/sync/config.ts | 40 +++++++++++++++++++++++++++++++++++ src/sync/paths.test.ts | 29 ++++++++++++++++++++++++++ src/sync/paths.ts | 47 +++++++++++++++++++++++++++--------------- 3 files changed, 99 insertions(+), 17 deletions(-) diff --git a/src/sync/config.ts b/src/sync/config.ts index 60482e8..99886c5 100644 --- a/src/sync/config.ts +++ b/src/sync/config.ts @@ -10,6 +10,20 @@ export interface SyncRepoConfig { branch?: string; } +export type SecretsBackendType = '1password'; + +export interface SecretsBackendDocuments { + authJson?: string; + mcpAuthJson?: string; + envFile?: string; +} + +export interface SecretsBackendConfig { + type: SecretsBackendType; + vault?: string; + documents?: SecretsBackendDocuments; +} + export interface SyncConfig { repo?: SyncRepoConfig; localRepoPath?: string; @@ -18,6 +32,7 @@ export interface SyncConfig { includeSessions?: boolean; includePromptStash?: boolean; includeModelFavorites?: boolean; + secretsBackend?: SecretsBackendConfig; extraSecretPaths?: string[]; extraConfigPaths?: string[]; } @@ -28,6 +43,7 @@ export interface NormalizedSyncConfig extends SyncConfig { includeSessions: boolean; includePromptStash: boolean; includeModelFavorites: boolean; + secretsBackend?: SecretsBackendConfig; extraSecretPaths: string[]; extraConfigPaths: string[]; } @@ -57,6 +73,25 @@ export async function chmodIfExists(filePath: string, mode: number): Promise { diff --git a/src/sync/paths.test.ts b/src/sync/paths.test.ts index e685596..52195b9 100644 --- a/src/sync/paths.test.ts +++ b/src/sync/paths.test.ts @@ -77,6 +77,35 @@ describe('buildSyncPlan', () => { expect(plan.extraConfigs.allowlist.length).toBe(1); }); + it('excludes auth files when using 1password backend', () => { + const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'linux'); + const config: SyncConfig = { + repo: { owner: 'acme', name: 'config' }, + includeSecrets: true, + secretsBackend: { + type: '1password', + vault: 'Personal', + documents: { + authJson: 'opencode-auth.json', + mcpAuthJson: 'opencode-mcp-auth.json', + }, + }, + }; + + const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux'); + + const authItem = plan.items.find((item) => + item.localPath.endsWith('/.local/share/opencode/auth.json') + ); + const mcpItem = plan.items.find((item) => + item.localPath.endsWith('/.local/share/opencode/mcp-auth.json') + ); + + expect(authItem).toBeUndefined(); + expect(mcpItem).toBeUndefined(); + }); + it('includes model favorites by default and allows disabling', () => { const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; const locations = resolveSyncLocations(env, 'linux'); diff --git a/src/sync/paths.ts b/src/sync/paths.ts index bee74f3..596fdc8 100644 --- a/src/sync/paths.ts +++ b/src/sync/paths.ts @@ -1,6 +1,7 @@ import crypto from 'node:crypto'; import path from 'node:path'; import type { NormalizedSyncConfig, SyncConfig } from './config.js'; +import { isOnePasswordBackend } from './config.js'; export interface XdgPaths { homeDir: string; @@ -184,6 +185,7 @@ export function buildSyncPlan( const configManifestPath = path.join(repoConfigRoot, 'extra-manifest.json'); const items: SyncItem[] = []; + const usingOnePasswordBackend = isOnePasswordBackend(config); const authJsonPath = path.join(dataRoot, 'auth.json'); const mcpAuthJsonPath = path.join(dataRoot, 'mcp-auth.json'); @@ -222,22 +224,24 @@ export function buildSyncPlan( } if (config.includeSecrets) { - items.push( - { - localPath: authJsonPath, - repoPath: path.join(repoDataRoot, 'auth.json'), - type: 'file', - isSecret: true, - isConfigFile: false, - }, - { - localPath: mcpAuthJsonPath, - repoPath: path.join(repoDataRoot, 'mcp-auth.json'), - type: 'file', - isSecret: true, - isConfigFile: false, - } - ); + if (!usingOnePasswordBackend) { + items.push( + { + localPath: authJsonPath, + repoPath: path.join(repoDataRoot, 'auth.json'), + type: 'file', + isSecret: true, + isConfigFile: false, + }, + { + localPath: mcpAuthJsonPath, + repoPath: path.join(repoDataRoot, 'mcp-auth.json'), + type: 'file', + isSecret: true, + isConfigFile: false, + } + ); + } if (config.includeSessions) { for (const dirName of SESSION_DIRS) { @@ -264,8 +268,17 @@ export function buildSyncPlan( } } + const extraSecretPaths = config.includeSecrets ? config.extraSecretPaths : []; + const filteredExtraSecrets = usingOnePasswordBackend + ? extraSecretPaths.filter( + (entry) => + !isSamePath(entry, authJsonPath, locations.xdg.homeDir, platform) && + !isSamePath(entry, mcpAuthJsonPath, locations.xdg.homeDir, platform) + ) + : extraSecretPaths; + const extraSecrets = buildExtraPathPlan( - config.includeSecrets ? config.extraSecretPaths : [], + filteredExtraSecrets, locations, repoExtraDir, manifestPath, From ca6a5bb927402d1b302a8691a6add200ea00f3e1 Mon Sep 17 00:00:00 2001 From: Khalil Gharbaoui Date: Sun, 1 Feb 2026 02:53:35 +0100 Subject: [PATCH 05/14] feat: integrate 1Password secrets backend Adds backend pull/push hooks and blocks tracked auth files. --- src/sync/secrets-backend.ts | 241 ++++++++++++++++++++++++++++++++++++ src/sync/service.ts | 165 +++++++++++++++++++++++- 2 files changed, 404 insertions(+), 2 deletions(-) create mode 100644 src/sync/secrets-backend.ts diff --git a/src/sync/secrets-backend.ts b/src/sync/secrets-backend.ts new file mode 100644 index 0000000..9f04642 --- /dev/null +++ b/src/sync/secrets-backend.ts @@ -0,0 +1,241 @@ +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import type { PluginInput } from '@opencode-ai/plugin'; +import type { SecretsBackendConfig, SyncConfig } from './config.js'; +import { chmodIfExists, pathExists } from './config.js'; +import { SyncCommandError } from './errors.js'; +import type { SyncLocations } from './paths.js'; + +type Shell = PluginInput['$']; + +export interface OnePasswordConfig { + vault: string; + documents: Required>['documents'] & { + authJson: string; + mcpAuthJson: string; + }; +} + +export interface SecretsBackend { + pull: () => Promise; + push: () => Promise; + status: () => Promise; +} + +export type OnePasswordResolution = + | { state: 'none' } + | { state: 'invalid'; error: string } + | { state: 'ok'; config: OnePasswordConfig }; + +export function resolveOnePasswordConfig(config: SyncConfig): OnePasswordResolution { + const backend = config.secretsBackend; + if (!backend || backend.type !== '1password') { + return { state: 'none' }; + } + + const vault = backend.vault?.trim(); + if (!vault) { + return { + state: 'invalid', + error: 'secretsBackend.vault is required for type "1password".', + }; + } + + const documents = backend.documents ?? {}; + const authJson = documents.authJson?.trim(); + const mcpAuthJson = documents.mcpAuthJson?.trim(); + + if (!authJson || !mcpAuthJson) { + return { + state: 'invalid', + error: + 'secretsBackend.documents.authJson and secretsBackend.documents.mcpAuthJson ' + + 'are required for type "1password".', + }; + } + + return { + state: 'ok', + config: { + vault, + documents: { + authJson, + mcpAuthJson, + envFile: documents.envFile, + }, + }, + }; +} + +export function resolveAuthFilePaths(locations: SyncLocations): { + authPath: string; + mcpAuthPath: string; +} { + const dataRoot = path.join(locations.xdg.dataDir, 'opencode'); + return { + authPath: path.join(dataRoot, 'auth.json'), + mcpAuthPath: path.join(dataRoot, 'mcp-auth.json'), + }; +} + +export function resolveRepoAuthPaths(repoRoot: string): { + authRepoPath: string; + mcpAuthRepoPath: string; +} { + const repoDataRoot = path.join(repoRoot, 'data'); + return { + authRepoPath: path.join(repoDataRoot, 'auth.json'), + mcpAuthRepoPath: path.join(repoDataRoot, 'mcp-auth.json'), + }; +} + +export function createOnePasswordBackend(options: { + $: Shell; + locations: SyncLocations; + config: OnePasswordConfig; +}): SecretsBackend { + const { $, locations, config } = options; + const { authPath, mcpAuthPath } = resolveAuthFilePaths(locations); + + const pull = async (): Promise => { + await ensureOpAvailable($); + await pullDocument($, config.vault, config.documents.authJson, authPath); + await pullDocument($, config.vault, config.documents.mcpAuthJson, mcpAuthPath); + }; + + const push = async (): Promise => { + await ensureOpAvailable($); + await pushDocument($, config.vault, config.documents.authJson, authPath); + await pushDocument($, config.vault, config.documents.mcpAuthJson, mcpAuthPath); + }; + + const status = async (): Promise => { + await ensureOpAvailable($); + return `1Password backend configured for vault "${config.vault}".`; + }; + + return { pull, push, status }; +} + +async function ensureOpAvailable($: Shell): Promise { + try { + await $`op --version`.quiet(); + } catch { + throw new SyncCommandError('1Password CLI not found. Install it and sign in with `op signin`.'); + } +} + +async function pullDocument( + $: Shell, + vault: string, + documentName: string, + targetPath: string +): Promise { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-synced-')); + const tempPath = path.join(tempDir, path.basename(targetPath)); + + try { + const result = await opDocumentGet($, vault, documentName, tempPath); + if (result === 'not_found') { + return; + } + await replaceFile(tempPath, targetPath); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } +} + +async function pushDocument( + $: Shell, + vault: string, + documentName: string, + sourcePath: string +): Promise { + if (!(await pathExists(sourcePath))) { + return; + } + + try { + await opDocumentEdit($, vault, documentName, sourcePath); + } catch (error) { + if (!isNotFoundError(error)) { + throw new SyncCommandError(`1Password update failed: ${formatShellError(error)}`); + } + try { + await opDocumentCreate($, vault, documentName, sourcePath); + } catch (createError) { + throw new SyncCommandError(`1Password create failed: ${formatShellError(createError)}`); + } + } +} + +async function opDocumentGet( + $: Shell, + vault: string, + name: string, + outFile: string +): Promise<'ok' | 'not_found'> { + try { + await $`op document get ${name} --vault ${vault} --out-file ${outFile}`.quiet(); + return 'ok'; + } catch (error) { + if (isNotFoundError(error)) { + return 'not_found'; + } + throw new SyncCommandError(`1Password download failed: ${formatShellError(error)}`); + } +} + +async function opDocumentCreate( + $: Shell, + vault: string, + name: string, + sourcePath: string +): Promise { + await $`op document create --vault ${vault} ${sourcePath} --title ${name}`.quiet(); +} + +async function opDocumentEdit( + $: Shell, + vault: string, + name: string, + sourcePath: string +): Promise { + await $`op document edit ${name} --vault ${vault} ${sourcePath}`.quiet(); +} + +async function replaceFile(sourcePath: string, targetPath: string): Promise { + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + try { + await fs.rename(sourcePath, targetPath); + } catch (error) { + const maybeErrno = error as NodeJS.ErrnoException; + if (maybeErrno.code !== 'EXDEV') { + throw error; + } + await fs.copyFile(sourcePath, targetPath); + await fs.unlink(sourcePath); + } + await chmodIfExists(targetPath, 0o600); +} + +function isNotFoundError(error: unknown): boolean { + const text = formatShellError(error); + return /not found/i.test(text); +} + +function formatShellError(error: unknown): string { + if (!error) return 'Unknown error'; + if (typeof error === 'string') return error; + if (error instanceof Error && error.message) return error.message; + + const maybe = error as { stderr?: string; message?: string }; + const parts = [maybe.stderr, maybe.message].filter( + (value): value is string => typeof value === 'string' && value.length > 0 + ); + if (parts.length > 0) return parts.join('\n'); + + return String(error); +} diff --git a/src/sync/service.ts b/src/sync/service.ts index df7071b..17e1a47 100644 --- a/src/sync/service.ts +++ b/src/sync/service.ts @@ -3,8 +3,10 @@ import path from 'node:path'; import type { PluginInput } from '@opencode-ai/plugin'; import { syncLocalToRepo, syncRepoToLocal } from './apply.js'; import { generateCommitMessage } from './commit.js'; +import type { SyncConfig } from './config.js'; import { canCommitMcpSecrets, + isOnePasswordBackend, loadOverrides, loadState, loadSyncConfig, @@ -31,6 +33,12 @@ import { resolveRepoBranch, resolveRepoIdentifier, } from './repo.js'; +import { + createOnePasswordBackend, + resolveOnePasswordConfig, + resolveRepoAuthPaths, + type SecretsBackend, +} from './secrets-backend.js'; import { createLogger, extractTextFromResponse, @@ -72,6 +80,9 @@ export interface SyncService { link: (_options: LinkOptions) => Promise; pull: () => Promise; push: () => Promise; + secretsPull: () => Promise; + secretsPush: () => Promise; + secretsStatus: () => Promise; enableSecrets: (_options?: { extraSecretPaths?: string[]; includeMcpSecrets?: boolean; @@ -116,6 +127,70 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { fn ); + const resolveSecretsBackend = ( + config: SyncConfig, + options: { requireConfigured: boolean } + ): SecretsBackend | null => { + const resolution = resolveOnePasswordConfig(config); + if (resolution.state === 'none') { + return null; + } + + if (resolution.state === 'invalid') { + if (options.requireConfigured) { + throw new SyncCommandError(resolution.error); + } + log.warn('Secrets backend misconfigured; skipping', { error: resolution.error }); + return null; + } + + return createOnePasswordBackend({ $: ctx.$, locations, config: resolution.config }); + }; + + const ensureAuthFilesNotTracked = async (repoRoot: string, config: SyncConfig): Promise => { + if (!isOnePasswordBackend(config)) return; + + const { authRepoPath, mcpAuthRepoPath } = resolveRepoAuthPaths(repoRoot); + const tracked: string[] = []; + const authRelPath = toRepoRelativePath(repoRoot, authRepoPath); + const mcpRelPath = toRepoRelativePath(repoRoot, mcpAuthRepoPath); + + if (await isRepoPathTracked(ctx.$, repoRoot, authRelPath)) { + tracked.push(authRelPath); + } + if (await isRepoPathTracked(ctx.$, repoRoot, mcpRelPath)) { + tracked.push(mcpRelPath); + } + + if (tracked.length === 0) return; + + const trackedList = tracked.join(', '); + throw new SyncCommandError( + `Sync repo already tracks secret auth files (${trackedList}). ` + + 'Remove them and rewrite history before enabling the 1Password backend.' + ); + }; + + const runSecretsPullIfConfigured = async (config: SyncConfig): Promise => { + const backend = resolveSecretsBackend(config, { requireConfigured: false }); + if (!backend) return; + try { + await backend.pull(); + } catch (error) { + log.warn('Secrets backend pull failed; continuing', { error: formatError(error) }); + } + }; + + const runSecretsPushIfConfigured = async (config: SyncConfig): Promise => { + const backend = resolveSecretsBackend(config, { requireConfigured: false }); + if (!backend) return; + try { + await backend.push(); + } catch (error) { + log.warn('Secrets backend push failed; continuing', { error: formatError(error) }); + } + }; + return { startupSync: () => skipIfBusy(async () => { @@ -141,7 +216,10 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { return; } try { - await runStartup(ctx, locations, config, log); + await runStartup(ctx, locations, config, log, { + ensureAuthFilesNotTracked, + runSecretsPullIfConfigured, + }); } catch (error) { log.error('Startup sync failed', { error: formatError(error) }); await showToast(ctx.client, formatError(error), 'error'); @@ -177,6 +255,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const includeSessions = config.includeSessions ? 'enabled' : 'disabled'; const includePromptStash = config.includePromptStash ? 'enabled' : 'disabled'; const includeModelFavorites = config.includeModelFavorites ? 'enabled' : 'disabled'; + const secretsBackend = config.secretsBackend?.type ?? 'none'; const lastPull = state.lastPull ?? 'never'; const lastPush = state.lastPush ?? 'never'; @@ -194,6 +273,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { `Repo: ${repoIdentifier}`, `Branch: ${branch}`, `Secrets: ${includeSecrets}`, + `Secrets backend: ${secretsBackend}`, `MCP secrets: ${includeMcpSecrets}`, `Sessions: ${includeSessions}`, `Prompt stash: ${includePromptStash}`, @@ -320,6 +400,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const repoRoot = resolveRepoRoot(config, locations); await ensureRepoCloned(ctx.$, config, repoRoot); await ensureSecretsPolicy(ctx, config); + await ensureAuthFilesNotTracked(repoRoot, config); const branch = await resolveBranch(ctx, config, repoRoot); @@ -338,6 +419,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const overrides = await loadOverrides(locations); const plan = buildSyncPlan(config, locations, repoRoot); await syncRepoToLocal(plan, overrides); + await runSecretsPullIfConfigured(config); await writeState(locations, { lastPull: new Date().toISOString(), @@ -353,6 +435,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const repoRoot = resolveRepoRoot(config, locations); await ensureRepoCloned(ctx.$, config, repoRoot); await ensureSecretsPolicy(ctx, config); + await ensureAuthFilesNotTracked(repoRoot, config); const branch = await resolveBranch(ctx, config, repoRoot); const preDirty = await hasLocalChanges(ctx.$, repoRoot); @@ -362,6 +445,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { ); } + await runSecretsPushIfConfigured(config); const overrides = await loadOverrides(locations); const plan = buildSyncPlan(config, locations, repoRoot); await syncLocalToRepo(plan, overrides, { @@ -384,6 +468,59 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { return `Pushed changes: ${message}`; }), + secretsPull: () => + runExclusive(async () => { + const config = await getConfigOrThrow(locations); + const resolution = resolveOnePasswordConfig(config); + if (resolution.state === 'none') { + return 'Secrets backend not configured. Add secretsBackend to opencode-synced.jsonc.'; + } + if (resolution.state === 'invalid') { + throw new SyncCommandError(resolution.error); + } + const backend = createOnePasswordBackend({ + $: ctx.$, + locations, + config: resolution.config, + }); + await backend.pull(); + return 'Pulled secrets from 1Password.'; + }), + secretsPush: () => + runExclusive(async () => { + const config = await getConfigOrThrow(locations); + const resolution = resolveOnePasswordConfig(config); + if (resolution.state === 'none') { + return 'Secrets backend not configured. Add secretsBackend to opencode-synced.jsonc.'; + } + if (resolution.state === 'invalid') { + throw new SyncCommandError(resolution.error); + } + const backend = createOnePasswordBackend({ + $: ctx.$, + locations, + config: resolution.config, + }); + await backend.push(); + return 'Pushed secrets to 1Password.'; + }), + secretsStatus: () => + runExclusive(async () => { + const config = await getConfigOrThrow(locations); + const resolution = resolveOnePasswordConfig(config); + if (resolution.state === 'none') { + return 'Secrets backend not configured. Add secretsBackend to opencode-synced.jsonc.'; + } + if (resolution.state === 'invalid') { + throw new SyncCommandError(resolution.error); + } + const backend = createOnePasswordBackend({ + $: ctx.$, + locations, + config: resolution.config, + }); + return await backend.status(); + }), enableSecrets: (options?: { extraSecretPaths?: string[]; includeMcpSecrets?: boolean }) => runExclusive(async () => { const config = await getConfigOrThrow(locations); @@ -439,17 +576,40 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { }; } +async function isRepoPathTracked( + $: Shell, + repoRoot: string, + repoRelativePath: string +): Promise { + const safePath = repoRelativePath.split(path.sep).join('/'); + try { + await $`git -C ${repoRoot} ls-files --error-unmatch ${safePath}`.quiet(); + return true; + } catch { + return false; + } +} + +function toRepoRelativePath(repoRoot: string, absolutePath: string): string { + return path.relative(repoRoot, absolutePath).split(path.sep).join('/'); +} + async function runStartup( ctx: SyncServiceContext, locations: ReturnType, config: ReturnType, - log: Logger + log: Logger, + options: { + ensureAuthFilesNotTracked: (repoRoot: string, config: SyncConfig) => Promise; + runSecretsPullIfConfigured: (config: SyncConfig) => Promise; + } ): Promise { const repoRoot = resolveRepoRoot(config, locations); log.debug('Starting sync', { repoRoot }); await ensureRepoCloned(ctx.$, config, repoRoot); await ensureSecretsPolicy(ctx, config); + await options.ensureAuthFilesNotTracked(repoRoot, config); const branch = await resolveBranch(ctx, config, repoRoot); log.debug('Resolved branch', { branch }); @@ -470,6 +630,7 @@ async function runStartup( const overrides = await loadOverrides(locations); const plan = buildSyncPlan(config, locations, repoRoot); await syncRepoToLocal(plan, overrides); + await options.runSecretsPullIfConfigured(config); await writeState(locations, { lastPull: new Date().toISOString(), lastRemoteUpdate: new Date().toISOString(), From c29d63a2895eac43c4bb401bfed2597e38fe762d Mon Sep 17 00:00:00 2001 From: Khalil Gharbaoui Date: Sun, 1 Feb 2026 02:53:50 +0100 Subject: [PATCH 06/14] feat: add secrets sync commands Exposes 1Password pull/push/status via opencode_sync. --- src/command/sync-secrets-pull.md | 5 +++++ src/command/sync-secrets-push.md | 5 +++++ src/command/sync-secrets-status.md | 5 +++++ src/index.ts | 22 +++++++++++++++++++++- 4 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 src/command/sync-secrets-pull.md create mode 100644 src/command/sync-secrets-push.md create mode 100644 src/command/sync-secrets-status.md diff --git a/src/command/sync-secrets-pull.md b/src/command/sync-secrets-pull.md new file mode 100644 index 0000000..a3bcb1b --- /dev/null +++ b/src/command/sync-secrets-pull.md @@ -0,0 +1,5 @@ +--- +description: Pull secrets from the configured backend +--- + +Use the opencode_sync tool with command "secrets-pull". diff --git a/src/command/sync-secrets-push.md b/src/command/sync-secrets-push.md new file mode 100644 index 0000000..4b4e834 --- /dev/null +++ b/src/command/sync-secrets-push.md @@ -0,0 +1,5 @@ +--- +description: Push secrets to the configured backend +--- + +Use the opencode_sync tool with command "secrets-push". diff --git a/src/command/sync-secrets-status.md b/src/command/sync-secrets-status.md new file mode 100644 index 0000000..67c15ad --- /dev/null +++ b/src/command/sync-secrets-status.md @@ -0,0 +1,5 @@ +--- +description: Show secrets backend status +--- + +Use the opencode_sync tool with command "secrets-status". diff --git a/src/index.ts b/src/index.ts index c6d5d2b..1f314ce 100644 --- a/src/index.ts +++ b/src/index.ts @@ -117,7 +117,18 @@ export const opencodeConfigSync: Plugin = async (ctx) => { description: 'Manage opencode config sync with a GitHub repo', args: { command: tool.schema - .enum(['status', 'init', 'link', 'pull', 'push', 'enable-secrets', 'resolve']) + .enum([ + 'status', + 'init', + 'link', + 'pull', + 'push', + 'enable-secrets', + 'resolve', + 'secrets-pull', + 'secrets-push', + 'secrets-status', + ]) .describe('Sync command to execute'), repo: tool.schema.string().optional().describe('Repo owner/name or URL'), owner: tool.schema.string().optional().describe('Repo owner'), @@ -182,6 +193,15 @@ export const opencodeConfigSync: Plugin = async (ctx) => { if (args.command === 'push') { return await service.push(); } + if (args.command === 'secrets-pull') { + return await service.secretsPull(); + } + if (args.command === 'secrets-push') { + return await service.secretsPush(); + } + if (args.command === 'secrets-status') { + return await service.secretsStatus(); + } if (args.command === 'enable-secrets') { return await service.enableSecrets({ extraSecretPaths: args.extraSecretPaths, From f2eb33b9ea605a17230b5d43dae9891a59822d0b Mon Sep 17 00:00:00 2001 From: Khalil Gharbaoui Date: Sun, 1 Feb 2026 06:37:16 +0100 Subject: [PATCH 07/14] fix: address secrets backend review --- docs/1Password.md | 2 +- src/sync/config.ts | 2 +- src/sync/secrets-backend.ts | 8 ++-- src/sync/service.ts | 88 ++++++++++++++++++------------------- 4 files changed, 51 insertions(+), 49 deletions(-) diff --git a/docs/1Password.md b/docs/1Password.md index 3b8f3d8..d9a2739 100644 --- a/docs/1Password.md +++ b/docs/1Password.md @@ -88,7 +88,7 @@ pull(): Promise // 1P -> local files push(): Promise // local files -> 1P -status(): Promise<{ ok: boolean; message: string }> (optional) +status(): Promise (optional) 4) Implement OnePasswordBackend Implementation rules: diff --git a/src/sync/config.ts b/src/sync/config.ts index 99886c5..bda6d70 100644 --- a/src/sync/config.ts +++ b/src/sync/config.ts @@ -113,7 +113,7 @@ export function canCommitMcpSecrets(config: SyncConfig): boolean { return Boolean(config.includeSecrets) && Boolean(config.includeMcpSecrets); } -export function isOnePasswordBackend(config: SyncConfig): boolean { +export function isOnePasswordBackend(config: NormalizedSyncConfig): boolean { return config.secretsBackend?.type === '1password'; } diff --git a/src/sync/secrets-backend.ts b/src/sync/secrets-backend.ts index 9f04642..dbe632c 100644 --- a/src/sync/secrets-backend.ts +++ b/src/sync/secrets-backend.ts @@ -3,7 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import type { PluginInput } from '@opencode-ai/plugin'; -import type { SecretsBackendConfig, SyncConfig } from './config.js'; +import type { NormalizedSyncConfig } from './config.js'; import { chmodIfExists, pathExists } from './config.js'; import { SyncCommandError } from './errors.js'; import type { SyncLocations } from './paths.js'; @@ -12,9 +12,10 @@ type Shell = PluginInput['$']; export interface OnePasswordConfig { vault: string; - documents: Required>['documents'] & { + documents: { authJson: string; mcpAuthJson: string; + envFile?: string; }; } @@ -29,7 +30,7 @@ export type OnePasswordResolution = | { state: 'invalid'; error: string } | { state: 'ok'; config: OnePasswordConfig }; -export function resolveOnePasswordConfig(config: SyncConfig): OnePasswordResolution { +export function resolveOnePasswordConfig(config: NormalizedSyncConfig): OnePasswordResolution { const backend = config.secretsBackend; if (!backend || backend.type !== '1password') { return { state: 'none' }; @@ -208,6 +209,7 @@ async function opDocumentEdit( async function replaceFile(sourcePath: string, targetPath: string): Promise { await fs.mkdir(path.dirname(targetPath), { recursive: true }); + await chmodIfExists(sourcePath, 0o600); try { await fs.rename(sourcePath, targetPath); } catch (error) { diff --git a/src/sync/service.ts b/src/sync/service.ts index 17e1a47..8e1529f 100644 --- a/src/sync/service.ts +++ b/src/sync/service.ts @@ -3,7 +3,7 @@ import path from 'node:path'; import type { PluginInput } from '@opencode-ai/plugin'; import { syncLocalToRepo, syncRepoToLocal } from './apply.js'; import { generateCommitMessage } from './commit.js'; -import type { SyncConfig } from './config.js'; +import type { NormalizedSyncConfig } from './config.js'; import { canCommitMcpSecrets, isOnePasswordBackend, @@ -128,7 +128,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { ); const resolveSecretsBackend = ( - config: SyncConfig, + config: NormalizedSyncConfig, options: { requireConfigured: boolean } ): SecretsBackend | null => { const resolution = resolveOnePasswordConfig(config); @@ -147,7 +147,10 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { return createOnePasswordBackend({ $: ctx.$, locations, config: resolution.config }); }; - const ensureAuthFilesNotTracked = async (repoRoot: string, config: SyncConfig): Promise => { + const ensureAuthFilesNotTracked = async ( + repoRoot: string, + config: NormalizedSyncConfig + ): Promise => { if (!isOnePasswordBackend(config)) return; const { authRepoPath, mcpAuthRepoPath } = resolveRepoAuthPaths(repoRoot); @@ -171,7 +174,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { ); }; - const runSecretsPullIfConfigured = async (config: SyncConfig): Promise => { + const runSecretsPullIfConfigured = async (config: NormalizedSyncConfig): Promise => { const backend = resolveSecretsBackend(config, { requireConfigured: false }); if (!backend) return; try { @@ -181,7 +184,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { } }; - const runSecretsPushIfConfigured = async (config: SyncConfig): Promise => { + const runSecretsPushIfConfigured = async (config: NormalizedSyncConfig): Promise => { const backend = resolveSecretsBackend(config, { requireConfigured: false }); if (!backend) return; try { @@ -191,6 +194,28 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { } }; + const resolveSecretsBackendForCommand = async (): Promise< + { backend: SecretsBackend } | { message: string } + > => { + const config = await getConfigOrThrow(locations); + const resolution = resolveOnePasswordConfig(config); + if (resolution.state === 'none') { + return { + message: 'Secrets backend not configured. Add secretsBackend to opencode-synced.jsonc.', + }; + } + if (resolution.state === 'invalid') { + throw new SyncCommandError(resolution.error); + } + return { + backend: createOnePasswordBackend({ + $: ctx.$, + locations, + config: resolution.config, + }), + }; + }; + return { startupSync: () => skipIfBusy(async () => { @@ -470,56 +495,31 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { }), secretsPull: () => runExclusive(async () => { - const config = await getConfigOrThrow(locations); - const resolution = resolveOnePasswordConfig(config); - if (resolution.state === 'none') { - return 'Secrets backend not configured. Add secretsBackend to opencode-synced.jsonc.'; - } - if (resolution.state === 'invalid') { - throw new SyncCommandError(resolution.error); + const resolved = await resolveSecretsBackendForCommand(); + if ('message' in resolved) { + return resolved.message; } - const backend = createOnePasswordBackend({ - $: ctx.$, - locations, - config: resolution.config, - }); + const backend = resolved.backend; await backend.pull(); return 'Pulled secrets from 1Password.'; }), secretsPush: () => runExclusive(async () => { - const config = await getConfigOrThrow(locations); - const resolution = resolveOnePasswordConfig(config); - if (resolution.state === 'none') { - return 'Secrets backend not configured. Add secretsBackend to opencode-synced.jsonc.'; - } - if (resolution.state === 'invalid') { - throw new SyncCommandError(resolution.error); + const resolved = await resolveSecretsBackendForCommand(); + if ('message' in resolved) { + return resolved.message; } - const backend = createOnePasswordBackend({ - $: ctx.$, - locations, - config: resolution.config, - }); + const backend = resolved.backend; await backend.push(); return 'Pushed secrets to 1Password.'; }), secretsStatus: () => runExclusive(async () => { - const config = await getConfigOrThrow(locations); - const resolution = resolveOnePasswordConfig(config); - if (resolution.state === 'none') { - return 'Secrets backend not configured. Add secretsBackend to opencode-synced.jsonc.'; + const resolved = await resolveSecretsBackendForCommand(); + if ('message' in resolved) { + return resolved.message; } - if (resolution.state === 'invalid') { - throw new SyncCommandError(resolution.error); - } - const backend = createOnePasswordBackend({ - $: ctx.$, - locations, - config: resolution.config, - }); - return await backend.status(); + return await resolved.backend.status(); }), enableSecrets: (options?: { extraSecretPaths?: string[]; includeMcpSecrets?: boolean }) => runExclusive(async () => { @@ -600,8 +600,8 @@ async function runStartup( config: ReturnType, log: Logger, options: { - ensureAuthFilesNotTracked: (repoRoot: string, config: SyncConfig) => Promise; - runSecretsPullIfConfigured: (config: SyncConfig) => Promise; + ensureAuthFilesNotTracked: (repoRoot: string, config: NormalizedSyncConfig) => Promise; + runSecretsPullIfConfigured: (config: NormalizedSyncConfig) => Promise; } ): Promise { const repoRoot = resolveRepoRoot(config, locations); From 034bbe8feb72cf4f2306399788dca5d897d50283 Mon Sep 17 00:00:00 2001 From: Khalil Gharbaoui Date: Sun, 1 Feb 2026 09:38:20 +0100 Subject: [PATCH 08/14] fix: sync opencode-synced config Include opencode-synced.jsonc in the core plan and avoid duplicate extra paths. --- src/sync/paths.test.ts | 28 ++++++++++++++++++++++++++++ src/sync/paths.ts | 7 ++++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/sync/paths.test.ts b/src/sync/paths.test.ts index 52195b9..9ede236 100644 --- a/src/sync/paths.test.ts +++ b/src/sync/paths.test.ts @@ -59,6 +59,34 @@ describe('buildSyncPlan', () => { expect(plan.extraConfigs.allowlist.length).toBe(1); }); + it('includes opencode-synced config file in items', () => { + const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'linux'); + const config: SyncConfig = { + repo: { owner: 'acme', name: 'config' }, + includeSecrets: false, + }; + + const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux'); + const syncItem = plan.items.find((item) => item.localPath === locations.syncConfigPath); + + expect(syncItem).toBeTruthy(); + }); + + it('filters sync config from extra config paths', () => { + const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; + const locations = resolveSyncLocations(env, 'linux'); + const config: SyncConfig = { + repo: { owner: 'acme', name: 'config' }, + includeSecrets: false, + extraConfigPaths: [locations.syncConfigPath], + }; + + const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux'); + + expect(plan.extraConfigs.allowlist.length).toBe(0); + }); + it('includes secrets when includeSecrets is true', () => { const env = { HOME: '/home/test' } as NodeJS.ProcessEnv; const locations = resolveSyncLocations(env, 'linux'); diff --git a/src/sync/paths.ts b/src/sync/paths.ts index 596fdc8..b4a0c15 100644 --- a/src/sync/paths.ts +++ b/src/sync/paths.ts @@ -202,6 +202,7 @@ export function buildSyncPlan( addFile(DEFAULT_CONFIG_NAME, false, true); addFile(DEFAULT_CONFIGC_NAME, false, true); addFile(DEFAULT_AGENTS_NAME, false, false); + addFile(DEFAULT_SYNC_CONFIG_NAME, false, false); for (const dirName of CONFIG_DIRS) { items.push({ @@ -285,8 +286,12 @@ export function buildSyncPlan( platform ); + const extraConfigPaths = (config.extraConfigPaths ?? []).filter( + (entry) => !isSamePath(entry, locations.syncConfigPath, locations.xdg.homeDir, platform) + ); + const extraConfigs = buildExtraPathPlan( - config.extraConfigPaths, + extraConfigPaths, locations, repoConfigExtraDir, configManifestPath, From 5c37236ec76c27125adc9e99156e87622bf9ea8b Mon Sep 17 00:00:00 2001 From: Khalil Gharbaoui Date: Thu, 5 Feb 2026 03:34:55 +0100 Subject: [PATCH 09/14] fix: harden secrets backend integration --- src/sync/config.ts | 25 ++-- src/sync/paths.ts | 8 +- src/sync/secrets-backend.ts | 266 +++++++++++++++++++++++++++--------- src/sync/service.ts | 166 +++++++++++++--------- 4 files changed, 326 insertions(+), 139 deletions(-) diff --git a/src/sync/config.ts b/src/sync/config.ts index bda6d70..69d9e4d 100644 --- a/src/sync/config.ts +++ b/src/sync/config.ts @@ -15,7 +15,6 @@ export type SecretsBackendType = '1password'; export interface SecretsBackendDocuments { authJson?: string; mcpAuthJson?: string; - envFile?: string; } export interface SecretsBackendConfig { @@ -52,6 +51,7 @@ export interface SyncState { lastPull?: string; lastPush?: string; lastRemoteUpdate?: string; + lastSecretsHash?: string; } export async function pathExists(filePath: string): Promise { @@ -73,6 +73,11 @@ export async function chmodIfExists(filePath: string, mode: number): Promise { + if (!value || typeof value !== 'object') return false; + return Object.getPrototypeOf(value) === Object.prototype; +} + export function normalizeSecretsBackend( input: SyncConfig['secretsBackend'] ): SecretsBackendConfig | undefined { @@ -86,7 +91,6 @@ export function normalizeSecretsBackend( authJson: typeof documentsInput.authJson === 'string' ? documentsInput.authJson : undefined, mcpAuthJson: typeof documentsInput.mcpAuthJson === 'string' ? documentsInput.mcpAuthJson : undefined, - envFile: typeof documentsInput.envFile === 'string' ? documentsInput.envFile : undefined, }; return { type: '1password', vault, documents }; @@ -113,8 +117,8 @@ export function canCommitMcpSecrets(config: SyncConfig): boolean { return Boolean(config.includeSecrets) && Boolean(config.includeMcpSecrets); } -export function isOnePasswordBackend(config: NormalizedSyncConfig): boolean { - return config.secretsBackend?.type === '1password'; +export function hasSecretsBackend(config: SyncConfig | NormalizedSyncConfig): boolean { + return Boolean(config.secretsBackend); } export async function loadSyncConfig( @@ -161,6 +165,14 @@ export async function writeState(locations: SyncLocations, state: SyncState): Pr await writeJsonFile(locations.statePath, state, { jsonc: false }); } +export async function updateState( + locations: SyncLocations, + update: Partial +): Promise { + const existing = await loadState(locations); + await writeState(locations, { ...existing, ...update }); +} + export function applyOverridesToRuntimeConfig( config: Record, overrides: Record @@ -318,11 +330,6 @@ export async function writeJsonFile( } } -export function isPlainObject(value: unknown): value is Record { - if (!value || typeof value !== 'object') return false; - return Object.getPrototypeOf(value) === Object.prototype; -} - export function hasOwn(target: Record, key: string): boolean { return Object.hasOwn(target, key); } diff --git a/src/sync/paths.ts b/src/sync/paths.ts index b4a0c15..995118d 100644 --- a/src/sync/paths.ts +++ b/src/sync/paths.ts @@ -1,7 +1,7 @@ import crypto from 'node:crypto'; import path from 'node:path'; import type { NormalizedSyncConfig, SyncConfig } from './config.js'; -import { isOnePasswordBackend } from './config.js'; +import { hasSecretsBackend } from './config.js'; export interface XdgPaths { homeDir: string; @@ -185,7 +185,7 @@ export function buildSyncPlan( const configManifestPath = path.join(repoConfigRoot, 'extra-manifest.json'); const items: SyncItem[] = []; - const usingOnePasswordBackend = isOnePasswordBackend(config); + const usingSecretsBackend = hasSecretsBackend(config); const authJsonPath = path.join(dataRoot, 'auth.json'); const mcpAuthJsonPath = path.join(dataRoot, 'mcp-auth.json'); @@ -225,7 +225,7 @@ export function buildSyncPlan( } if (config.includeSecrets) { - if (!usingOnePasswordBackend) { + if (!usingSecretsBackend) { items.push( { localPath: authJsonPath, @@ -270,7 +270,7 @@ export function buildSyncPlan( } const extraSecretPaths = config.includeSecrets ? config.extraSecretPaths : []; - const filteredExtraSecrets = usingOnePasswordBackend + const filteredExtraSecrets = usingSecretsBackend ? extraSecretPaths.filter( (entry) => !isSamePath(entry, authJsonPath, locations.xdg.homeDir, platform) && diff --git a/src/sync/secrets-backend.ts b/src/sync/secrets-backend.ts index dbe632c..f39ccf8 100644 --- a/src/sync/secrets-backend.ts +++ b/src/sync/secrets-backend.ts @@ -1,9 +1,9 @@ +import crypto from 'node:crypto'; import { promises as fs } from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; import type { PluginInput } from '@opencode-ai/plugin'; -import type { NormalizedSyncConfig } from './config.js'; +import type { NormalizedSyncConfig, SecretsBackendConfig } from './config.js'; import { chmodIfExists, pathExists } from './config.js'; import { SyncCommandError } from './errors.js'; import type { SyncLocations } from './paths.js'; @@ -11,12 +11,10 @@ import type { SyncLocations } from './paths.js'; type Shell = PluginInput['$']; export interface OnePasswordConfig { + type: '1password'; vault: string; - documents: { - authJson: string; - mcpAuthJson: string; - envFile?: string; - }; + authJson: string; + mcpAuthJson: string; } export interface SecretsBackend { @@ -25,17 +23,75 @@ export interface SecretsBackend { status: () => Promise; } -export type OnePasswordResolution = +export type SecretsBackendResolution = | { state: 'none' } | { state: 'invalid'; error: string } | { state: 'ok'; config: OnePasswordConfig }; -export function resolveOnePasswordConfig(config: NormalizedSyncConfig): OnePasswordResolution { +type DocumentIndex = Map; + +interface VaultDocumentEntry { + id: string; + title: string; +} + +export function resolveSecretsBackendConfig( + config: NormalizedSyncConfig +): SecretsBackendResolution { const backend = config.secretsBackend; - if (!backend || backend.type !== '1password') { + if (!backend) { return { state: 'none' }; } + const backendType = backend.type as string; + if (backendType !== '1password') { + return { + state: 'invalid', + error: `Unsupported secrets backend type "${backendType}".`, + }; + } + return resolveOnePasswordConfig(backend); +} + +export function resolveAuthFilePaths(locations: SyncLocations): { + authPath: string; + mcpAuthPath: string; +} { + const dataRoot = path.join(locations.xdg.dataDir, 'opencode'); + return { + authPath: path.join(dataRoot, 'auth.json'), + mcpAuthPath: path.join(dataRoot, 'mcp-auth.json'), + }; +} + +export function resolveRepoAuthPaths(repoRoot: string): { + authRepoPath: string; + mcpAuthRepoPath: string; +} { + const repoDataRoot = path.join(repoRoot, 'data'); + return { + authRepoPath: path.join(repoDataRoot, 'auth.json'), + mcpAuthRepoPath: path.join(repoDataRoot, 'mcp-auth.json'), + }; +} + +export async function computeSecretsHash(locations: SyncLocations): Promise { + const { authPath, mcpAuthPath } = resolveAuthFilePaths(locations); + return await hashFiles([authPath, mcpAuthPath]); +} + +export function createSecretsBackend(options: { + $: Shell; + locations: SyncLocations; + config: OnePasswordConfig; +}): SecretsBackend { + const backendType = options.config.type as string; + if (backendType === '1password') { + return createOnePasswordBackend(options); + } + throw new SyncCommandError(`Unsupported secrets backend type "${backendType}".`); +} +function resolveOnePasswordConfig(backend: SecretsBackendConfig): SecretsBackendResolution { const vault = backend.vault?.trim(); if (!vault) { return { @@ -57,42 +113,30 @@ export function resolveOnePasswordConfig(config: NormalizedSyncConfig): OnePassw }; } + if (normalizeDocumentName(authJson) === normalizeDocumentName(mcpAuthJson)) { + return { + state: 'invalid', + error: + 'secretsBackend.documents.authJson and secretsBackend.documents.mcpAuthJson must be unique.', + }; + } + return { state: 'ok', config: { + type: '1password', vault, - documents: { - authJson, - mcpAuthJson, - envFile: documents.envFile, - }, + authJson, + mcpAuthJson, }, }; } -export function resolveAuthFilePaths(locations: SyncLocations): { - authPath: string; - mcpAuthPath: string; -} { - const dataRoot = path.join(locations.xdg.dataDir, 'opencode'); - return { - authPath: path.join(dataRoot, 'auth.json'), - mcpAuthPath: path.join(dataRoot, 'mcp-auth.json'), - }; +function normalizeDocumentName(name: string): string { + return name.trim().toLowerCase(); } -export function resolveRepoAuthPaths(repoRoot: string): { - authRepoPath: string; - mcpAuthRepoPath: string; -} { - const repoDataRoot = path.join(repoRoot, 'data'); - return { - authRepoPath: path.join(repoDataRoot, 'auth.json'), - mcpAuthRepoPath: path.join(repoDataRoot, 'mcp-auth.json'), - }; -} - -export function createOnePasswordBackend(options: { +function createOnePasswordBackend(options: { $: Shell; locations: SyncLocations; config: OnePasswordConfig; @@ -102,14 +146,20 @@ export function createOnePasswordBackend(options: { const pull = async (): Promise => { await ensureOpAvailable($); - await pullDocument($, config.vault, config.documents.authJson, authPath); - await pullDocument($, config.vault, config.documents.mcpAuthJson, mcpAuthPath); + const index = await listVaultDocuments($, config.vault); + await pullDocument($, config.vault, config.authJson, authPath, index); + await pullDocument($, config.vault, config.mcpAuthJson, mcpAuthPath, index); }; const push = async (): Promise => { await ensureOpAvailable($); - await pushDocument($, config.vault, config.documents.authJson, authPath); - await pushDocument($, config.vault, config.documents.mcpAuthJson, mcpAuthPath); + const existing = await Promise.all([pathExists(authPath), pathExists(mcpAuthPath)]); + if (!existing.some(Boolean)) { + return; + } + const index = await listVaultDocuments($, config.vault); + await pushDocument($, config.vault, config.authJson, authPath, index); + await pushDocument($, config.vault, config.mcpAuthJson, mcpAuthPath, index); }; const status = async (): Promise => { @@ -128,20 +178,86 @@ async function ensureOpAvailable($: Shell): Promise { } } +async function listVaultDocuments($: Shell, vault: string): Promise { + let output: string; + try { + output = await $`op item list --vault ${vault} --categories Document --format json` + .quiet() + .text(); + } catch (error) { + throw new SyncCommandError(`1Password document list failed: ${formatShellError(error)}`); + } + + let parsed: unknown; + try { + parsed = JSON.parse(output) as unknown; + } catch { + throw new SyncCommandError('1Password document list returned invalid JSON.'); + } + + if (!Array.isArray(parsed)) { + throw new SyncCommandError('1Password document list returned unexpected data.'); + } + + const index: DocumentIndex = new Map(); + for (const entry of parsed) { + if (!entry || typeof entry !== 'object') continue; + const record = entry as { id?: unknown; title?: unknown }; + const id = typeof record.id === 'string' ? record.id : ''; + const title = typeof record.title === 'string' ? record.title : ''; + if (!id || !title) continue; + const key = normalizeDocumentName(title); + const existing = index.get(key); + const item = { id, title }; + if (existing) { + existing.push(item); + } else { + index.set(key, [item]); + } + } + + return index; +} + +function lookupDocument( + index: DocumentIndex, + documentName: string +): { + state: 'missing' | 'duplicate' | 'ok'; + count: number; +} { + const key = normalizeDocumentName(documentName); + const matches = index.get(key) ?? []; + if (matches.length === 0) { + return { state: 'missing', count: 0 }; + } + if (matches.length > 1) { + return { state: 'duplicate', count: matches.length }; + } + return { state: 'ok', count: 1 }; +} + async function pullDocument( $: Shell, vault: string, documentName: string, - targetPath: string + targetPath: string, + index: DocumentIndex ): Promise { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'opencode-synced-')); - const tempPath = path.join(tempDir, path.basename(targetPath)); + const lookup = lookupDocument(index, documentName); + if (lookup.state === 'missing') { + return; + } + if (lookup.state === 'duplicate') { + throw new SyncCommandError( + `Multiple documents named "${documentName}" found in vault "${vault}". ` + + 'Rename them to be unique.' + ); + } + const { tempDir, tempPath } = await createTempPath(targetPath); try { - const result = await opDocumentGet($, vault, documentName, tempPath); - if (result === 'not_found') { - return; - } + await opDocumentGet($, vault, documentName, tempPath); await replaceFile(tempPath, targetPath); } finally { await fs.rm(tempDir, { recursive: true, force: true }); @@ -152,23 +268,34 @@ async function pushDocument( $: Shell, vault: string, documentName: string, - sourcePath: string + sourcePath: string, + index: DocumentIndex ): Promise { if (!(await pathExists(sourcePath))) { return; } - try { - await opDocumentEdit($, vault, documentName, sourcePath); - } catch (error) { - if (!isNotFoundError(error)) { - throw new SyncCommandError(`1Password update failed: ${formatShellError(error)}`); - } + const lookup = lookupDocument(index, documentName); + if (lookup.state === 'duplicate') { + throw new SyncCommandError( + `Multiple documents named "${documentName}" found in vault "${vault}". ` + + 'Rename them to be unique.' + ); + } + + if (lookup.state === 'missing') { try { await opDocumentCreate($, vault, documentName, sourcePath); } catch (createError) { throw new SyncCommandError(`1Password create failed: ${formatShellError(createError)}`); } + return; + } + + try { + await opDocumentEdit($, vault, documentName, sourcePath); + } catch (error) { + throw new SyncCommandError(`1Password update failed: ${formatShellError(error)}`); } } @@ -177,14 +304,10 @@ async function opDocumentGet( vault: string, name: string, outFile: string -): Promise<'ok' | 'not_found'> { +): Promise { try { await $`op document get ${name} --vault ${vault} --out-file ${outFile}`.quiet(); - return 'ok'; } catch (error) { - if (isNotFoundError(error)) { - return 'not_found'; - } throw new SyncCommandError(`1Password download failed: ${formatShellError(error)}`); } } @@ -207,6 +330,14 @@ async function opDocumentEdit( await $`op document edit ${name} --vault ${vault} ${sourcePath}`.quiet(); } +async function createTempPath(targetPath: string): Promise<{ tempDir: string; tempPath: string }> { + const targetDir = path.dirname(targetPath); + await fs.mkdir(targetDir, { recursive: true }); + const tempDir = await fs.mkdtemp(path.join(targetDir, '.opencode-synced-')); + const tempPath = path.join(tempDir, path.basename(targetPath)); + return { tempDir, tempPath }; +} + async function replaceFile(sourcePath: string, targetPath: string): Promise { await fs.mkdir(path.dirname(targetPath), { recursive: true }); await chmodIfExists(sourcePath, 0o600); @@ -223,9 +354,20 @@ async function replaceFile(sourcePath: string, targetPath: string): Promise { + const hash = crypto.createHash('sha256'); + for (const filePath of paths) { + hash.update(filePath); + hash.update('\0'); + const exists = await pathExists(filePath); + hash.update(exists ? '1' : '0'); + if (exists) { + const data = await fs.readFile(filePath); + hash.update(data); + } + hash.update('\0'); + } + return hash.digest('hex'); } function formatShellError(error: unknown): string { diff --git a/src/sync/service.ts b/src/sync/service.ts index 8e1529f..2a9701c 100644 --- a/src/sync/service.ts +++ b/src/sync/service.ts @@ -6,12 +6,12 @@ import { generateCommitMessage } from './commit.js'; import type { NormalizedSyncConfig } from './config.js'; import { canCommitMcpSecrets, - isOnePasswordBackend, + hasSecretsBackend, loadOverrides, loadState, loadSyncConfig, normalizeSyncConfig, - writeState, + updateState, writeSyncConfig, } from './config.js'; import { SyncCommandError, SyncConfigMissingError } from './errors.js'; @@ -34,9 +34,10 @@ import { resolveRepoIdentifier, } from './repo.js'; import { - createOnePasswordBackend, - resolveOnePasswordConfig, + computeSecretsHash, + createSecretsBackend, resolveRepoAuthPaths, + resolveSecretsBackendConfig, type SecretsBackend, } from './secrets-backend.js'; import { @@ -127,31 +128,24 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { fn ); - const resolveSecretsBackend = ( - config: NormalizedSyncConfig, - options: { requireConfigured: boolean } - ): SecretsBackend | null => { - const resolution = resolveOnePasswordConfig(config); + const resolveSecretsBackend = (config: NormalizedSyncConfig): SecretsBackend | null => { + const resolution = resolveSecretsBackendConfig(config); if (resolution.state === 'none') { return null; } if (resolution.state === 'invalid') { - if (options.requireConfigured) { - throw new SyncCommandError(resolution.error); - } - log.warn('Secrets backend misconfigured; skipping', { error: resolution.error }); - return null; + throw new SyncCommandError(resolution.error); } - return createOnePasswordBackend({ $: ctx.$, locations, config: resolution.config }); + return createSecretsBackend({ $: ctx.$, locations, config: resolution.config }); }; const ensureAuthFilesNotTracked = async ( repoRoot: string, config: NormalizedSyncConfig ): Promise => { - if (!isOnePasswordBackend(config)) return; + if (!hasSecretsBackend(config)) return; const { authRepoPath, mcpAuthRepoPath } = resolveRepoAuthPaths(repoRoot); const tracked: string[] = []; @@ -170,45 +164,75 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const trackedList = tracked.join(', '); throw new SyncCommandError( `Sync repo already tracks secret auth files (${trackedList}). ` + - 'Remove them and rewrite history before enabling the 1Password backend.' + 'Remove them and rewrite history before enabling a secrets backend.' ); }; - const runSecretsPullIfConfigured = async (config: NormalizedSyncConfig): Promise => { - const backend = resolveSecretsBackend(config, { requireConfigured: false }); - if (!backend) return; + const computeSecretsHashSafe = async (): Promise => { try { - await backend.pull(); + return await computeSecretsHash(locations); } catch (error) { - log.warn('Secrets backend pull failed; continuing', { error: formatError(error) }); + log.warn('Failed to compute secrets hash', { error: formatError(error) }); + return null; } }; - const runSecretsPushIfConfigured = async (config: NormalizedSyncConfig): Promise => { - const backend = resolveSecretsBackend(config, { requireConfigured: false }); - if (!backend) return; - try { - await backend.push(); - } catch (error) { - log.warn('Secrets backend push failed; continuing', { error: formatError(error) }); + const updateSecretsHashState = async (): Promise => { + const hash = await computeSecretsHashSafe(); + if (!hash) return; + await updateState(locations, { lastSecretsHash: hash }); + }; + + const pushSecretsWithBackend = async (backend: SecretsBackend): Promise<'skipped' | 'pushed'> => { + const hash = await computeSecretsHashSafe(); + if (hash) { + const state = await loadState(locations); + if (state.lastSecretsHash === hash) { + log.debug('Secrets unchanged; skipping secrets push'); + return 'skipped'; + } } + + await backend.push(); + if (hash) { + await updateState(locations, { lastSecretsHash: hash }); + } + return 'pushed'; + }; + + const runSecretsPullIfConfigured = async (config: NormalizedSyncConfig): Promise => { + const backend = resolveSecretsBackend(config); + if (!backend) return; + await backend.pull(); + await updateSecretsHashState(); }; + const runSecretsPushIfConfigured = async ( + config: NormalizedSyncConfig + ): Promise<'not_configured' | 'skipped' | 'pushed'> => { + const backend = resolveSecretsBackend(config); + if (!backend) return 'not_configured'; + return await pushSecretsWithBackend(backend); + }; + + const secretsBackendNotConfiguredMessage = + 'Secrets backend not configured. Add secretsBackend to opencode-synced.jsonc.'; + const resolveSecretsBackendForCommand = async (): Promise< { backend: SecretsBackend } | { message: string } > => { const config = await getConfigOrThrow(locations); - const resolution = resolveOnePasswordConfig(config); + const resolution = resolveSecretsBackendConfig(config); if (resolution.state === 'none') { return { - message: 'Secrets backend not configured. Add secretsBackend to opencode-synced.jsonc.', + message: secretsBackendNotConfiguredMessage, }; } if (resolution.state === 'invalid') { throw new SyncCommandError(resolution.error); } return { - backend: createOnePasswordBackend({ + backend: createSecretsBackend({ $: ctx.$, locations, config: resolution.config, @@ -216,6 +240,16 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { }; }; + const runSecretsCommand = async ( + action: (backend: SecretsBackend) => Promise + ): Promise => { + const resolved = await resolveSecretsBackendForCommand(); + if ('message' in resolved) { + return resolved.message; + } + return await action(resolved.backend); + }; + return { startupSync: () => skipIfBusy(async () => { @@ -342,7 +376,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const branch = resolveRepoBranch(config); await commitAll(ctx.$, repoRoot, 'Initial sync from opencode-synced'); await pushBranch(ctx.$, repoRoot, branch); - await writeState(locations, { lastPush: new Date().toISOString() }); + await updateState(locations, { lastPush: new Date().toISOString() }); } } @@ -398,7 +432,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const plan = buildSyncPlan(config, locations, repoRoot); await syncRepoToLocal(plan, overrides); - await writeState(locations, { + await updateState(locations, { lastPull: new Date().toISOString(), lastRemoteUpdate: new Date().toISOString(), }); @@ -446,7 +480,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { await syncRepoToLocal(plan, overrides); await runSecretsPullIfConfigured(config); - await writeState(locations, { + await updateState(locations, { lastPull: new Date().toISOString(), lastRemoteUpdate: new Date().toISOString(), }); @@ -470,7 +504,6 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { ); } - await runSecretsPushIfConfigured(config); const overrides = await loadOverrides(locations); const plan = buildSyncPlan(config, locations, repoRoot); await syncLocalToRepo(plan, overrides, { @@ -480,6 +513,13 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const dirty = await hasLocalChanges(ctx.$, repoRoot); if (!dirty) { + const secretsResult = await runSecretsPushIfConfigured(config); + if (secretsResult === 'pushed') { + return 'No local changes to push. Secrets updated.'; + } + if (secretsResult === 'skipped') { + return 'No local changes to push. Secrets unchanged.'; + } return 'No local changes to push.'; } @@ -487,40 +527,38 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { await commitAll(ctx.$, repoRoot, message); await pushBranch(ctx.$, repoRoot, branch); - await writeState(locations, { + await runSecretsPushIfConfigured(config); + + await updateState(locations, { lastPush: new Date().toISOString(), }); return `Pushed changes: ${message}`; }), secretsPull: () => - runExclusive(async () => { - const resolved = await resolveSecretsBackendForCommand(); - if ('message' in resolved) { - return resolved.message; - } - const backend = resolved.backend; - await backend.pull(); - return 'Pulled secrets from 1Password.'; - }), + runExclusive(() => + runSecretsCommand(async (backend) => { + await backend.pull(); + await updateSecretsHashState(); + return 'Pulled secrets from 1Password.'; + }) + ), secretsPush: () => - runExclusive(async () => { - const resolved = await resolveSecretsBackendForCommand(); - if ('message' in resolved) { - return resolved.message; - } - const backend = resolved.backend; - await backend.push(); - return 'Pushed secrets to 1Password.'; - }), + runExclusive(() => + runSecretsCommand(async (backend) => { + const result = await pushSecretsWithBackend(backend); + if (result === 'skipped') { + return 'Secrets unchanged; skipping 1Password push.'; + } + return 'Pushed secrets to 1Password.'; + }) + ), secretsStatus: () => - runExclusive(async () => { - const resolved = await resolveSecretsBackendForCommand(); - if ('message' in resolved) { - return resolved.message; - } - return await resolved.backend.status(); - }), + runExclusive(() => + runSecretsCommand(async (backend) => { + return await backend.status(); + }) + ), enableSecrets: (options?: { extraSecretPaths?: string[]; includeMcpSecrets?: boolean }) => runExclusive(async () => { const config = await getConfigOrThrow(locations); @@ -631,7 +669,7 @@ async function runStartup( const plan = buildSyncPlan(config, locations, repoRoot); await syncRepoToLocal(plan, overrides); await options.runSecretsPullIfConfigured(config); - await writeState(locations, { + await updateState(locations, { lastPull: new Date().toISOString(), lastRemoteUpdate: new Date().toISOString(), }); @@ -655,7 +693,7 @@ async function runStartup( log.info('Pushing local changes', { message }); await commitAll(ctx.$, repoRoot, message); await pushBranch(ctx.$, repoRoot, branch); - await writeState(locations, { + await updateState(locations, { lastPush: new Date().toISOString(), }); } From d1167df2bca7e3fe450790f5063b6d2dd0d01490 Mon Sep 17 00:00:00 2001 From: Khalil Gharbaoui Date: Thu, 5 Feb 2026 03:35:38 +0100 Subject: [PATCH 10/14] test: add secrets backend coverage --- src/sync/config.test.ts | 32 ++++++++- src/sync/secrets-backend.test.ts | 108 +++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 src/sync/secrets-backend.test.ts diff --git a/src/sync/config.test.ts b/src/sync/config.test.ts index 6c9f247..b99feef 100644 --- a/src/sync/config.test.ts +++ b/src/sync/config.test.ts @@ -3,11 +3,12 @@ import os from 'node:os'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; - +import type { SyncConfig } from './config.js'; import { canCommitMcpSecrets, chmodIfExists, deepMerge, + normalizeSecretsBackend, normalizeSyncConfig, parseJsonc, stripOverrides, @@ -85,6 +86,35 @@ describe('normalizeSyncConfig', () => { }); }); +describe('normalizeSecretsBackend', () => { + it('returns undefined when backend is missing or unknown', () => { + expect(normalizeSecretsBackend(undefined)).toBeUndefined(); + const unknownBackend = { type: 'unknown' } as unknown as SyncConfig['secretsBackend']; + expect(normalizeSecretsBackend(unknownBackend)).toBeUndefined(); + }); + + it('normalizes 1password documents', () => { + const raw = { + type: '1password', + vault: 'Personal', + documents: { + authJson: 'auth.json', + mcpAuthJson: 'mcp-auth.json', + extra: 'ignored', + }, + } as unknown as SyncConfig['secretsBackend']; + + expect(normalizeSecretsBackend(raw)).toEqual({ + type: '1password', + vault: 'Personal', + documents: { + authJson: 'auth.json', + mcpAuthJson: 'mcp-auth.json', + }, + }); + }); +}); + describe('canCommitMcpSecrets', () => { it('requires includeSecrets and includeMcpSecrets', () => { expect(canCommitMcpSecrets({ includeSecrets: false, includeMcpSecrets: true })).toBe(false); diff --git a/src/sync/secrets-backend.test.ts b/src/sync/secrets-backend.test.ts new file mode 100644 index 0000000..ae06d6c --- /dev/null +++ b/src/sync/secrets-backend.test.ts @@ -0,0 +1,108 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { normalizeSyncConfig } from './config.js'; +import { resolveSyncLocations } from './paths.js'; +import { computeSecretsHash, resolveSecretsBackendConfig } from './secrets-backend.js'; + +describe('resolveSecretsBackendConfig', () => { + it('returns none when backend is missing', () => { + const resolution = resolveSecretsBackendConfig(normalizeSyncConfig({})); + expect(resolution.state).toBe('none'); + }); + + it('validates required vault', () => { + const resolution = resolveSecretsBackendConfig( + normalizeSyncConfig({ + secretsBackend: { + type: '1password', + documents: { + authJson: 'opencode-auth.json', + mcpAuthJson: 'opencode-mcp-auth.json', + }, + }, + }) + ); + + expect(resolution.state).toBe('invalid'); + if (resolution.state === 'invalid') { + expect(resolution.error).toContain('vault'); + } + }); + + it('requires unique document names', () => { + const resolution = resolveSecretsBackendConfig( + normalizeSyncConfig({ + secretsBackend: { + type: '1password', + vault: 'Personal', + documents: { + authJson: 'shared.json', + mcpAuthJson: 'SHARED.json', + }, + }, + }) + ); + + expect(resolution.state).toBe('invalid'); + if (resolution.state === 'invalid') { + expect(resolution.error).toContain('unique'); + } + }); + + it('returns ok when valid', () => { + const resolution = resolveSecretsBackendConfig( + normalizeSyncConfig({ + secretsBackend: { + type: '1password', + vault: 'Personal', + documents: { + authJson: 'opencode-auth.json', + mcpAuthJson: 'opencode-mcp-auth.json', + }, + }, + }) + ); + + expect(resolution.state).toBe('ok'); + if (resolution.state === 'ok') { + expect(resolution.config.vault).toBe('Personal'); + expect(resolution.config.authJson).toBe('opencode-auth.json'); + } + }); +}); + +describe('computeSecretsHash', () => { + it('changes when auth files change', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'opencode-sync-')); + const env = { + HOME: root, + XDG_DATA_HOME: path.join(root, 'data'), + XDG_CONFIG_HOME: path.join(root, 'config'), + XDG_STATE_HOME: path.join(root, 'state'), + } as NodeJS.ProcessEnv; + + try { + const locations = resolveSyncLocations(env, 'linux'); + const dataRoot = path.join(locations.xdg.dataDir, 'opencode'); + const authPath = path.join(dataRoot, 'auth.json'); + const mcpPath = path.join(dataRoot, 'mcp-auth.json'); + + await mkdir(dataRoot, { recursive: true }); + + const emptyHash = await computeSecretsHash(locations); + await writeFile(authPath, 'first'); + const authHash = await computeSecretsHash(locations); + await writeFile(mcpPath, 'second'); + const bothHash = await computeSecretsHash(locations); + + expect(emptyHash).not.toBe(authHash); + expect(authHash).not.toBe(bothHash); + } finally { + await rm(root, { recursive: true, force: true }); + } + }); +}); From 25a51bc81947e2a57bff4a7b89150f2dbc29a758 Mon Sep 17 00:00:00 2001 From: Khalil Gharbaoui Date: Thu, 5 Feb 2026 03:35:58 +0100 Subject: [PATCH 11/14] docs: update 1Password backend guidance --- docs/1Password.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/docs/1Password.md b/docs/1Password.md index d9a2739..e7cad7a 100644 --- a/docs/1Password.md +++ b/docs/1Password.md @@ -31,8 +31,7 @@ Add a new optional config block: "vault": "Personal", "documents": { "authJson": "opencode-auth.json", - "mcpAuthJson": "opencode-mcp-auth.json", - "envFile": ".env.opencode" // optional: store+restore the env file too + "mcpAuthJson": "opencode-mcp-auth.json" } } } @@ -79,7 +78,7 @@ documents.authJson required documents.mcpAuthJson required -documents.envFile optional +documents.authJson and documents.mcpAuthJson must be unique 3) Add a SecretsBackend interface Internal interface: @@ -113,20 +112,18 @@ if local file doesn’t exist: skip. create doc if missing; otherwise edit doc. -Files to manage: +Files to manage (XDG-aware): -~/.local/share/opencode/auth.json - -~/.local/share/opencode/mcp-auth.json +Linux/macOS: ~/.local/share/opencode/auth.json and ~/.local/share/opencode/mcp-auth.json -Optional: ~/.config/opencode/.env.opencode (only if configured) +Windows: %LOCALAPPDATA%\opencode\auth.json and %LOCALAPPDATA%\opencode\mcp-auth.json 5) Wire backend into sync lifecycle Hook points: After /sync-pull applies repo changes -> call backend.pull() -Before /sync-push commits/pushes -> call backend.push() +After /sync-push successfully commits/pushes (or when no repo changes) -> call backend.push() Add explicit commands: From fe3c41fef4186416a02657d776455a88fa59d44a Mon Sep 17 00:00:00 2001 From: Khalil Gharbaoui Date: Thu, 5 Feb 2026 03:55:28 +0100 Subject: [PATCH 12/14] docs: document branch policy --- AGENTS.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index b4dfe32..2c1e8e9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,11 @@ - **PR Titles**: PR titles MUST follow conventional commit format (e.g., `fix: descriptive title`). This is enforced by GitHub checks. - **Workflow**: Run `bun run check` and `bun run test` before creating a PR. +## Branch Policy + +- **main**: used only for upstream PRs to `iHildy/opencode-synced` (maintainer repo). +- **fork-release**: fork-only release automation for `opencode-synced-1password`; keep release workflows/docs there. + ## Code Style Guidelines ### Imports & Module System From 790f85039b9a2c30ac66979ffdee8d426234e798 Mon Sep 17 00:00:00 2001 From: iHildy Date: Thu, 5 Feb 2026 21:43:58 -0800 Subject: [PATCH 13/14] fix: guard secrets backend validation before actions --- AGENTS.md | 5 --- opencode.json | 4 ++- package.json | 8 ++--- src/sync/config.test.ts | 7 ++-- src/sync/config.ts | 11 ++++-- src/sync/secrets-backend.test.ts | 15 ++++++++ src/sync/secrets-backend.ts | 62 +++++++++++++++++++++++++++++++- src/sync/service.ts | 41 ++++++++++++++++----- 8 files changed, 128 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 2c1e8e9..b4dfe32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,11 +17,6 @@ - **PR Titles**: PR titles MUST follow conventional commit format (e.g., `fix: descriptive title`). This is enforced by GitHub checks. - **Workflow**: Run `bun run check` and `bun run test` before creating a PR. -## Branch Policy - -- **main**: used only for upstream PRs to `iHildy/opencode-synced` (maintainer repo). -- **fork-release**: fork-only release automation for `opencode-synced-1password`; keep release workflows/docs there. - ## Code Style Guidelines ### Imports & Module System diff --git a/opencode.json b/opencode.json index 0967ef4..720ece5 100644 --- a/opencode.json +++ b/opencode.json @@ -1 +1,3 @@ -{} +{ + "$schema": "https://opencode.ai/config.json" +} diff --git a/package.json b/package.json index 4bf337d..6b5f16c 100644 --- a/package.json +++ b/package.json @@ -22,9 +22,7 @@ "publishConfig": { "access": "public" }, - "files": [ - "dist" - ], + "files": ["dist"], "dependencies": { "@opencode-ai/plugin": "1.0.85" }, @@ -51,8 +49,6 @@ "prepare": "husky" }, "lint-staged": { - "*.{js,ts,json}": [ - "biome check --write --no-errors-on-unmatched" - ] + "*.{js,ts,json}": ["biome check --write --no-errors-on-unmatched"] } } diff --git a/src/sync/config.test.ts b/src/sync/config.test.ts index b99feef..66b8894 100644 --- a/src/sync/config.test.ts +++ b/src/sync/config.test.ts @@ -87,10 +87,13 @@ describe('normalizeSyncConfig', () => { }); describe('normalizeSecretsBackend', () => { - it('returns undefined when backend is missing or unknown', () => { + it('returns undefined when backend is missing', () => { expect(normalizeSecretsBackend(undefined)).toBeUndefined(); + }); + + it('preserves unknown backend types for validation', () => { const unknownBackend = { type: 'unknown' } as unknown as SyncConfig['secretsBackend']; - expect(normalizeSecretsBackend(unknownBackend)).toBeUndefined(); + expect(normalizeSecretsBackend(unknownBackend)).toEqual({ type: 'unknown' }); }); it('normalizes 1password documents', () => { diff --git a/src/sync/config.ts b/src/sync/config.ts index 69d9e4d..b0d4af5 100644 --- a/src/sync/config.ts +++ b/src/sync/config.ts @@ -10,7 +10,8 @@ export interface SyncRepoConfig { branch?: string; } -export type SecretsBackendType = '1password'; +export type KnownSecretsBackendType = '1password'; +export type SecretsBackendType = KnownSecretsBackendType | (string & {}); export interface SecretsBackendDocuments { authJson?: string; @@ -82,7 +83,13 @@ export function normalizeSecretsBackend( input: SyncConfig['secretsBackend'] ): SecretsBackendConfig | undefined { if (!input || typeof input !== 'object') return undefined; - if (input.type !== '1password') return undefined; + + const type = typeof input.type === 'string' ? input.type : undefined; + if (!type) return undefined; + + if (type !== '1password') { + return { type }; + } const vault = typeof input.vault === 'string' ? input.vault : undefined; const documentsInput = isPlainObject(input.documents) ? input.documents : {}; diff --git a/src/sync/secrets-backend.test.ts b/src/sync/secrets-backend.test.ts index ae06d6c..a94092b 100644 --- a/src/sync/secrets-backend.test.ts +++ b/src/sync/secrets-backend.test.ts @@ -14,6 +14,21 @@ describe('resolveSecretsBackendConfig', () => { expect(resolution.state).toBe('none'); }); + it('rejects unsupported backend types', () => { + const resolution = resolveSecretsBackendConfig( + normalizeSyncConfig({ + secretsBackend: { + type: 'vaultpass', + }, + }) + ); + + expect(resolution.state).toBe('invalid'); + if (resolution.state === 'invalid') { + expect(resolution.error).toContain('Unsupported'); + } + }); + it('validates required vault', () => { const resolution = resolveSecretsBackendConfig( normalizeSyncConfig({ diff --git a/src/sync/secrets-backend.ts b/src/sync/secrets-backend.ts index f39ccf8..5f29d5e 100644 --- a/src/sync/secrets-backend.ts +++ b/src/sync/secrets-backend.ts @@ -257,7 +257,30 @@ async function pullDocument( const { tempDir, tempPath } = await createTempPath(targetPath); try { - await opDocumentGet($, vault, documentName, tempPath); + try { + await opDocumentGet($, vault, documentName, tempPath); + } catch (error) { + const retryLookup = await lookupDocumentWithRetry($, vault, documentName); + if (!retryLookup) { + if (error instanceof SyncCommandError) { + throw error; + } + throw new SyncCommandError(`1Password download failed: ${formatShellError(error)}`); + } + if (retryLookup.state === 'missing') { + return; + } + if (retryLookup.state === 'duplicate') { + throw new SyncCommandError( + `Multiple documents named "${documentName}" found in vault "${vault}". ` + + 'Rename them to be unique.' + ); + } + if (error instanceof SyncCommandError) { + throw error; + } + throw new SyncCommandError(`1Password download failed: ${formatShellError(error)}`); + } await replaceFile(tempPath, targetPath); } finally { await fs.rm(tempDir, { recursive: true, force: true }); @@ -295,6 +318,30 @@ async function pushDocument( try { await opDocumentEdit($, vault, documentName, sourcePath); } catch (error) { + const retryLookup = await lookupDocumentWithRetry($, vault, documentName); + if (!retryLookup) { + if (error instanceof SyncCommandError) { + throw error; + } + throw new SyncCommandError(`1Password update failed: ${formatShellError(error)}`); + } + if (retryLookup.state === 'missing') { + try { + await opDocumentCreate($, vault, documentName, sourcePath); + } catch (createError) { + throw new SyncCommandError(`1Password create failed: ${formatShellError(createError)}`); + } + return; + } + if (retryLookup.state === 'duplicate') { + throw new SyncCommandError( + `Multiple documents named "${documentName}" found in vault "${vault}". ` + + 'Rename them to be unique.' + ); + } + if (error instanceof SyncCommandError) { + throw error; + } throw new SyncCommandError(`1Password update failed: ${formatShellError(error)}`); } } @@ -330,6 +377,19 @@ async function opDocumentEdit( await $`op document edit ${name} --vault ${vault} ${sourcePath}`.quiet(); } +async function lookupDocumentWithRetry( + $: Shell, + vault: string, + documentName: string +): Promise<{ state: 'missing' | 'duplicate' | 'ok'; count: number } | null> { + try { + const retryIndex = await listVaultDocuments($, vault); + return lookupDocument(retryIndex, documentName); + } catch { + return null; + } +} + async function createTempPath(targetPath: string): Promise<{ tempDir: string; tempPath: string }> { const targetDir = path.dirname(targetPath); await fs.mkdir(targetDir, { recursive: true }); diff --git a/src/sync/service.ts b/src/sync/service.ts index 2a9701c..b9d4b65 100644 --- a/src/sync/service.ts +++ b/src/sync/service.ts @@ -275,6 +275,7 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { return; } try { + assertValidSecretsBackend(config); await runStartup(ctx, locations, config, log, { ensureAuthFilesNotTracked, runSecretsPullIfConfigured, @@ -290,6 +291,8 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { return 'opencode-synced is not configured. Run /sync-init to set it up.'; } + assertValidSecretsBackend(config); + const repoRoot = resolveRepoRoot(config, locations); const state = await loadState(locations); let repoStatus: string[] = []; @@ -513,26 +516,40 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const dirty = await hasLocalChanges(ctx.$, repoRoot); if (!dirty) { - const secretsResult = await runSecretsPushIfConfigured(config); - if (secretsResult === 'pushed') { - return 'No local changes to push. Secrets updated.'; - } - if (secretsResult === 'skipped') { - return 'No local changes to push. Secrets unchanged.'; + try { + const secretsResult = await runSecretsPushIfConfigured(config); + if (secretsResult === 'pushed') { + return 'No local changes to push. Secrets updated.'; + } + if (secretsResult === 'skipped') { + return 'No local changes to push. Secrets unchanged.'; + } + return 'No local changes to push.'; + } catch (error) { + log.warn('Secrets push failed after sync check', { error: formatError(error) }); + return `No local changes to push. Secrets push failed: ${formatError(error)}`; } - return 'No local changes to push.'; } const message = await generateCommitMessage({ client: ctx.client, $: ctx.$ }, repoRoot); await commitAll(ctx.$, repoRoot, message); await pushBranch(ctx.$, repoRoot, branch); - await runSecretsPushIfConfigured(config); + let secretsFailure: string | null = null; + try { + await runSecretsPushIfConfigured(config); + } catch (error) { + secretsFailure = formatError(error); + log.warn('Secrets push failed after repo push', { error: secretsFailure }); + } await updateState(locations, { lastPush: new Date().toISOString(), }); + if (secretsFailure) { + return `Pushed changes: ${message}. Secrets push failed: ${secretsFailure}`; + } return `Pushed changes: ${message}`; }), secretsPull: () => @@ -614,6 +631,13 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { }; } +function assertValidSecretsBackend(config: NormalizedSyncConfig): void { + const resolution = resolveSecretsBackendConfig(config); + if (resolution.state === 'invalid') { + throw new SyncCommandError(resolution.error); + } +} + async function isRepoPathTracked( $: Shell, repoRoot: string, @@ -707,6 +731,7 @@ async function getConfigOrThrow( 'Missing opencode-synced config. Run /sync-init to set it up.' ); } + assertValidSecretsBackend(config); return config; } From e67d6755a0c2024782b4eb2ec6da73f4a2223344 Mon Sep 17 00:00:00 2001 From: iHildy Date: Thu, 5 Feb 2026 22:34:14 -0800 Subject: [PATCH 14/14] fix: preserve original 1password errors --- src/sync/secrets-backend.ts | 26 +++++++++----------------- 1 file changed, 9 insertions(+), 17 deletions(-) diff --git a/src/sync/secrets-backend.ts b/src/sync/secrets-backend.ts index 5f29d5e..4cfeb23 100644 --- a/src/sync/secrets-backend.ts +++ b/src/sync/secrets-backend.ts @@ -262,10 +262,7 @@ async function pullDocument( } catch (error) { const retryLookup = await lookupDocumentWithRetry($, vault, documentName); if (!retryLookup) { - if (error instanceof SyncCommandError) { - throw error; - } - throw new SyncCommandError(`1Password download failed: ${formatShellError(error)}`); + throw error; } if (retryLookup.state === 'missing') { return; @@ -276,10 +273,7 @@ async function pullDocument( 'Rename them to be unique.' ); } - if (error instanceof SyncCommandError) { - throw error; - } - throw new SyncCommandError(`1Password download failed: ${formatShellError(error)}`); + throw error; } await replaceFile(tempPath, targetPath); } finally { @@ -320,10 +314,7 @@ async function pushDocument( } catch (error) { const retryLookup = await lookupDocumentWithRetry($, vault, documentName); if (!retryLookup) { - if (error instanceof SyncCommandError) { - throw error; - } - throw new SyncCommandError(`1Password update failed: ${formatShellError(error)}`); + throw error; } if (retryLookup.state === 'missing') { try { @@ -339,10 +330,7 @@ async function pushDocument( 'Rename them to be unique.' ); } - if (error instanceof SyncCommandError) { - throw error; - } - throw new SyncCommandError(`1Password update failed: ${formatShellError(error)}`); + throw error; } } @@ -374,7 +362,11 @@ async function opDocumentEdit( name: string, sourcePath: string ): Promise { - await $`op document edit ${name} --vault ${vault} ${sourcePath}`.quiet(); + try { + await $`op document edit ${name} --vault ${vault} ${sourcePath}`.quiet(); + } catch (error) { + throw new SyncCommandError(`1Password update failed: ${formatShellError(error)}`); + } } async function lookupDocumentWithRetry(