diff --git a/.github/workflows/pr-title.yml b/.github/workflows/pr-title.yml index fd355cd..8295c63 100644 --- a/.github/workflows/pr-title.yml +++ b/.github/workflows/pr-title.yml @@ -14,6 +14,6 @@ jobs: ValidatePrTitle: runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v5 + - uses: amannn/action-semantic-pull-request@e32d7e603df1aa1ba07e981f2a23455dee596825 # v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 532a829..92328e5 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -17,7 +17,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 with: ref: ${{ github.event.pull_request.head.sha || github.ref }} @@ -79,7 +79,7 @@ jobs: Write-Host "Testing source archive pinned to $env:HEAD_SHA" - name: Setup Tooling - uses: oven-sh/setup-bun@v2 + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 with: bun-version: 1.3.2 diff --git a/README.md b/README.md index 0093184..d371aee 100644 --- a/README.md +++ b/README.md @@ -44,11 +44,18 @@ For opencode v2: opencode does not auto-update plugins. To update, modify the version number in your config file. +On v2, use the `opencode_sync` tool for sync operations. The `/sync-*` slash +commands are available on v1 only. V2 cannot display a slash command's direct +result without placing it in the model's prompt queue, which could trigger +another operation. For example, ask OpenCode to call `opencode_sync` with +`{"command":"status"}` to inspect the current state. + ## Configure ### First machine (create new sync repo) -Run `/sync-init` to create a new sync repo: +On v1, run `/sync-init` to create a new sync repo. On v2, ask OpenCode to call +`opencode_sync` with `{"command":"init"}`: 1. Detects your GitHub username 2. Creates a private repo (`my-opencode-config` by default) @@ -56,13 +63,15 @@ Run `/sync-init` to create a new sync repo: ### Additional machines (link to existing repo) -Run `/sync-link` to connect to your existing sync repo: +On v1, run `/sync-link` to connect to your existing sync repo. On v2, ask +OpenCode to call `opencode_sync` with `{"command":"link"}`: 1. Searches your GitHub for common sync repo names (prioritizes `my-opencode-config`) 2. Clones and applies the synced config 3. **Overwrites local config** with synced content (preserves your local overrides file) -If auto-detection fails, specify the repo name: `/sync-link my-opencode-config` +If auto-detection fails, specify the repo name with `/sync-link my-opencode-config` +on v1 or `{"command":"link","repo":"my-opencode-config"}` on v2. After linking, restart opencode to apply the synced settings. @@ -196,6 +205,9 @@ Best-effort session artifact sync via Git paths: - `~/.local/share/opencode/storage/session_diff/` This mode can conflict with concurrent writers. +`/sync-link` restores the repo's session snapshot on a new machine. On an already linked machine, +`/sync-pull` reapplies that snapshot even when Git has no new commits, replacing local session +artifacts. Restart opencode after either command to load the restored sessions. Large `opencode.db` files and legacy files under `storage/message/` are represented as a small, versioned pointer plus 40 MiB parts once they exceed 50 MiB. Parts live in the plugin-owned diff --git a/docs/v2.md b/docs/v2.md index 9e0ce32..8e0bb5e 100644 --- a/docs/v2.md +++ b/docs/v2.md @@ -22,23 +22,22 @@ Overrides mapping Override key V2 destination Notes mcp `ctx.mcp.transform` (set/update per server) Unresolvable `{env:…}`: store secret-free copy (`blankEnvPlaceholders` in `src/sync/config.ts`) with `disabled:true` + `console.error`. Mirrors v1 `disableMcpServerForResolutionFailure` intent without mutating caller state. agent `ctx.agent.transform` (update-only) Editor cannot create agents; unknown IDs warn once outside the transform, skip silently inside. -model `ctx.model.transform` (update-only) Non-object shapes warn; unknown provider IDs warn via `ctx.model.provider.list()` best-effort; unknown model IDs skip silently (no bulk "has" API). +model `ctx.model.transform` (update-only) Non-object shapes warn; unknown provider IDs warn via `ctx.provider.list()` best-effort; unknown model IDs skip silently (no bulk "has" API). provider `ctx.provider.transform` (update-only) Unknown IDs warn once outside via `ctx.provider.list()`, skip silently inside. -command Ignored with warn Sync commands are owned by this plugin and registered via `ctx.command.transform` from `src/command/*.md`. External `command` overrides are not applied. +command Ignored with warn V2 uses the `opencode_sync` tool. Slash commands and external `command` overrides are not registered. everything else `console.warn` with key name + docs pointer No fake global merge. File-level behavior (`syncRepoToLocal`/`syncLocalToRepo`, `stripOverrides` in `src/sync/apply.ts`, `src/sync/config.ts`) is runtime-independent and unchanged. -V2 command + tool limits (documented, not bugs) -- `CommandDefinition` only carries `name/description/execute` — unlike v1 - `config.command` there is no `template/agent/model/subtask`. The md template - is executed directly: run service → post via `ctx.session.synthetic`. -- Slash commands only carry free text (`prompt.text`). Only a single bare - `owner/repo` (or URL) is parsed (`parseCommandRepoArg` in `src/v2.ts` takes - the first token, strips quotes/`$ARGUMENTS`); `init`/`link` extra flags and - `enable-secrets`/Turso options are not parseable from slash text — use the - `opencode_sync` tool for full args. +V2 command + tool limits +- V2 does not register `/sync-*` slash commands. Its `CommandDefinition` + callback has no direct result channel. Posting command output with + `session.synthetic` resumes the model as if the output were a new user prompt; + even `/sync-status` can then prompt a follow-up sync operation. Using + `resume: false` prevents that operation but leaves the output in the inbox + rather than the visible session transcript. Use the `opencode_sync` tool, + which returns the result as tool content. - `Tool.Result.content` accepts `string | Content[]`; we return a plain string to keep status output readable. @@ -61,8 +60,7 @@ Implementation map no-op toast — v2 has neither toast nor log sink), session-status facade returns `{data:{}}` (empty = idle → Turso idle-gating intentionally skipped, syncs immediately), AI via `generate.text`. Registers tool (JSON Schema, - `required:["command"]`), commands (parse bare repo arg, post via - `session.synthetic`), mcp/agent/model/provider transforms, + `required:["command"]`), mcp/agent/model/provider transforms, `event.subscribe` → `service.handleEvent`, timed startup sync with dispose cleanup (`clearTimeout` + `abort` + `service.dispose()` which stops the Turso sync loop/idle-flush timers). @@ -70,20 +68,19 @@ Implementation map default export `{ id, setup: setupV2, server }` (no spread so runtimes do not leak fields). Deps: `@opencode-ai/plugin ^1.18.29`, `@opencode/plugin ^2.0.0`. README documents minimum versions. -5. Tests — `src/v2.test.ts`: dual export shape, mock-ctx tool/command/mcp/event - registration, status round-trip, missing-env disables server, transform - replay purity (no warn on second replay), Turso immediate-sync (empty status - = idle), `parseCommandRepoArg` edge cases. Existing v1 tests unchanged. +5. Tests — `src/v2.test.ts`: dual export shape, mock-ctx tool/mcp/event + registration, status round-trip without slash commands or synthetic messages, + missing-env disables server, transform replay purity (no warn on second + replay), Turso immediate-sync (empty status = idle). Existing v1 tests unchanged. 6. Local verify — `bun install`, `bun run check`, `bun test`, `bun run build`; manual packed-tarball smoke on opencode v1 (`plugin`) and v2 (`plugins`). -7. CI — build assertion that `dist` exposes both `setup` and `server`; unit - tests; matrix smoke (v1 + v2 installs vs packed plugin, fail on early exit). +7. CI — lint, unit tests, build, and Windows path tests. Packed v1/v2 host + installation is a manual verification step, not a CI matrix job. Risks / known parity gaps - Non-MCP runtime keys warn-only in v2 (no global merge possible). - No toasts in v2 (console + `app.log` facade); no session-idle gating (empty status map = idle → immediate Turso sync). - AI messages fall back to static when no model is available. -- Slash commands support bare repo/backend token only; full options require the - `opencode_sync` tool. +- V2 sync operations require the `opencode_sync` tool; v1 keeps `/sync-*` commands. - Overrides require restart; no watcher/`reload()` calls. diff --git a/src/sync/apply.ts b/src/sync/apply.ts index 897329a..0e0e9f7 100644 --- a/src/sync/apply.ts +++ b/src/sync/apply.ts @@ -69,6 +69,23 @@ export async function syncRepoToLocal( } } +export async function syncSessionArtifactsRepoToLocal( + plan: SyncPlan, + options: { chunkOptions?: ChunkOptions } = {} +): Promise { + let restored = false; + for (const item of plan.items) { + if (!item.preserveWhenMissing || !(await pathExists(item.repoPath))) continue; + if (item.chunkLargeFiles) { + await copyChunkableItemFromRepo(item, plan.repoRoot, options.chunkOptions); + } else { + await copyItem(item.repoPath, item.localPath, item.type); + } + restored = true; + } + return restored; +} + export async function syncLocalToRepo( plan: SyncPlan, overrides: Record | null, diff --git a/src/sync/service.test.ts b/src/sync/service.test.ts index 0d3cf6b..5d12cb9 100644 --- a/src/sync/service.test.ts +++ b/src/sync/service.test.ts @@ -6,7 +6,7 @@ import { promisify } from 'node:util'; import type { PluginInput } from '@opencode-ai/plugin'; import { describe, expect, it } from 'vitest'; -import { loadState, loadSyncConfig, writeSyncConfig } from './config.js'; +import { loadState, loadSyncConfig, writeState, writeSyncConfig } from './config.js'; import { resolveSyncLocations } from './paths.js'; import { createSyncService } from './service.js'; @@ -110,6 +110,88 @@ async function withIsolatedEnvironment(run: (root: string) => Promise): Pr } describe('explicit Git remote service flow', () => { + it('restores sessions on link and an up-to-date pull', async () => { + await withIsolatedEnvironment(async (root) => { + const testShell = createTestShell(); + const writeSqlite = async (dbPath: string, sql: string): Promise => { + const script = + 'import sqlite3, sys; db = sqlite3.connect(sys.argv[1]); db.executescript(sys.argv[2]); db.commit(); db.close()'; + await testShell`python3 -c ${script} ${dbPath} ${sql}`.quiet(); + }; + const readSessionTitle = async (dbPath: string): Promise => { + const script = + 'import sqlite3, sys; db = sqlite3.connect(sys.argv[1]); row = db.execute("SELECT title FROM session WHERE id = ?", ("ses_issue_51",)).fetchone(); print(row[0] if row else ""); db.close()'; + return (await testShell`python3 -c ${script} ${dbPath}`.text()).trim(); + }; + const remotePath = path.join(root, 'sync-remote.git'); + await testShell`git init --bare ${remotePath}`.quiet(); + + const machineAHome = path.join(root, 'machine-a'); + useIsolatedHome(machineAHome); + const machineALocations = resolveSyncLocations(); + await fs.mkdir(machineALocations.configRoot, { recursive: true }); + await fs.writeFile(path.join(machineALocations.configRoot, 'opencode.json'), '{}\n'); + const machineADbPath = path.join(machineALocations.xdg.dataDir, 'opencode', 'opencode.db'); + await fs.mkdir(path.dirname(machineADbPath), { recursive: true }); + await writeSqlite( + machineADbPath, + "CREATE TABLE session (id TEXT PRIMARY KEY, title TEXT); INSERT INTO session VALUES ('ses_issue_51', 'Machine A');" + ); + + const machineAService = createSyncService({ client: createClient(), $: testShell }); + await machineAService.init({ + repo: remotePath, + branch: 'main', + includeSecrets: true, + includeSessions: true, + acknowledgePrivateRemote: true, + }); + + useIsolatedHome(path.join(root, 'machine-unacknowledged')); + const unacknowledgedLocations = resolveSyncLocations(); + const unacknowledgedService = createSyncService({ client: createClient(), $: testShell }); + await expect( + unacknowledgedService.link({ repo: remotePath, branch: 'main' }) + ).rejects.toThrow('privacy cannot be verified'); + await expect( + fs.stat(path.join(unacknowledgedLocations.xdg.dataDir, 'opencode', 'opencode.db')) + ).rejects.toMatchObject({ code: 'ENOENT' }); + + const machineBHome = path.join(root, 'machine-b'); + useIsolatedHome(machineBHome); + const machineBLocations = resolveSyncLocations(); + const machineBDbPath = path.join(machineBLocations.xdg.dataDir, 'opencode', 'opencode.db'); + const machineBService = createSyncService({ client: createClient(), $: testShell }); + await machineBService.link({ + repo: remotePath, + branch: 'main', + acknowledgePrivateRemote: true, + }); + + await expect(readSessionTitle(machineBDbPath)).resolves.toBe('Machine A'); + const stateAfterLink = await loadState(machineBLocations); + expect(stateAfterLink.lastRemoteUpdate).toBeDefined(); + await writeState(machineBLocations, { ...stateAfterLink, lastPull: undefined }); + await expect(machineBService.status()).resolves.toContain('Last pull: never'); + + await writeSqlite(machineBDbPath, 'DELETE FROM session;'); + + await fs.writeFile( + path.join(machineBLocations.configRoot, 'opencode.json'), + '{"theme":"local"}\n' + ); + + await expect(machineBService.pull()).resolves.toContain('Restart opencode to load them'); + await expect(readSessionTitle(machineBDbPath)).resolves.toBe('Machine A'); + await expect(machineBService.status()).resolves.toMatch(/Last pull: \d{4}-\d\d-\d\dT/u); + const stateAfterPull = await loadState(machineBLocations); + expect(stateAfterPull.lastRemoteUpdate).toBe(stateAfterLink.lastRemoteUpdate); + await expect( + fs.readFile(path.join(machineBLocations.configRoot, 'opencode.json'), 'utf8') + ).resolves.toContain('local'); + }); + }, 30_000); + it('initializes, links, pushes, and pulls through a local bare remote', async () => { await withIsolatedEnvironment(async (root) => { const testShell = createTestShell(); diff --git a/src/sync/service.ts b/src/sync/service.ts index 0e0a8dd..ba27d7a 100644 --- a/src/sync/service.ts +++ b/src/sync/service.ts @@ -10,7 +10,7 @@ import { parseResolutionDecision, type ResolutionDecision, } from './ai.js'; -import { syncLocalToRepo, syncRepoToLocal } from './apply.js'; +import { syncLocalToRepo, syncRepoToLocal, syncSessionArtifactsRepoToLocal } from './apply.js'; import { generateCommitMessage } from './commit.js'; import type { NormalizedSyncConfig } from './config.js'; import { @@ -939,6 +939,8 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { await acknowledgePrivateRemote(locations, syncedConfig); } await ensureSensitiveSyncPolicy(ctx, locations, syncedConfig); + const sessionPlan = buildSyncPlan(syncedConfig, locations, repoRoot); + await syncSessionArtifactsRepoToLocal(sessionPlan); } if (syncedConfig && isTursoSessionBackend(syncedConfig)) { const setup = await runTursoSetup(syncedConfig, { allowLogin: true }); @@ -994,8 +996,22 @@ export function createSyncService(ctx: SyncServiceContext): SyncService { const update = await fetchAndFastForward(ctx.$, repoRoot, branch); if (!update.updated) { + const plan = buildSyncPlan(config, locations, repoRoot); + const restoredSessions = await syncSessionArtifactsRepoToLocal(plan); const tursoSummary = await runForegroundTursoCycle(config, 'pull-up-to-date'); ensureTursoSyncLoop(config); + if (restoredSessions) { + await updateState(locations, { lastPull: new Date().toISOString() }); + await showToast( + ctx.client, + 'Sessions restored. Restart opencode to load them.', + 'info' + ); + return [ + 'Remote sessions restored. Restart opencode to load them.', + ...(tursoSummary ? [tursoSummary] : []), + ].join('\n'); + } if (tursoSummary) { return ['Already up to date.', tursoSummary].join('\n'); } diff --git a/src/v2.test.ts b/src/v2.test.ts index b76496e..b48e467 100644 --- a/src/v2.test.ts +++ b/src/v2.test.ts @@ -27,7 +27,7 @@ vi.mock('@opencode-ai/plugin', () => { import pluginDefault, { opencodeConfigSync, opencodeSyncedV2 } from './index.js'; import { resolveSyncLocations } from './sync/paths.js'; -import { parseCommandRepoArg, setupV2 } from './v2.js'; +import { setupV2 } from './v2.js'; const ENV_KEYS = ['HOME', 'XDG_CONFIG_HOME', 'XDG_DATA_HOME', 'XDG_STATE_HOME'] as const; @@ -56,7 +56,7 @@ interface MockCtx { modelUpdates: [string, string, Record][]; transformCallbacks: Record; subscribed: boolean; - synthetics: { sessionID: string; text: string }[]; + synthetics: { sessionID: string; text: string; resume?: boolean }[]; knownAgents: { id: string }[]; knownProviders: { provider: { id: string } }[]; ctx: unknown; @@ -193,7 +193,7 @@ function createMockCtx(): MockCtx { text: async () => ({ text: 'Sync opencode config' }), }, session: { - synthetic: async (input: { sessionID: string; text: string }) => { + synthetic: async (input: { sessionID: string; text: string; resume?: boolean }) => { mock.synthetics.push(input); return {}; }, @@ -275,7 +275,7 @@ describe('v2 dual export', () => { }); describe('v2 setup', () => { - it('registers the sync tool and owned commands, with a status round-trip', async () => { + it('exposes status through the tool without registering model-resuming slash commands', async () => { await withIsolatedHome(async () => { const mock = createMockCtx(); const cleanup = await setupV2(mock.ctx as never); @@ -284,12 +284,14 @@ describe('v2 setup', () => { expect(mock.toolAdds[0].name).toBe('opencode_sync'); expect(mock.toolAdds[0].input.required).toEqual(['command']); - expect(mock.commandAdds.length).toBeGreaterThan(0); - expect(mock.commandAdds.map((command) => command.name)).toContain('sync-status'); + expect(mock.commandAdds).toHaveLength(0); const result = await mock.toolAdds[0].execute({ command: 'status' }); expect(typeof result.content).toBe('string'); expect(result.content).toContain('opencode-synced is not configured'); + expect(result.content).toContain('opencode_sync with {"command":"init"}'); + expect(result.content).not.toContain('/sync-init'); + expect(mock.synthetics).toHaveLength(0); expect(mock.subscribed).toBe(true); } finally { cleanup(); @@ -331,6 +333,88 @@ describe('v2 setup', () => { }); }); + it('keeps a valid mcp sibling when another server has an unresolvable placeholder', async () => { + await withIsolatedHome(async () => { + const locations = resolveSyncLocations(); + await fs.mkdir(locations.configRoot, { recursive: true }); + await fs.writeFile( + locations.overridesPath, + JSON.stringify({ + mcp: { + good: { + type: 'remote', + url: 'https://good.test/mcp', + headers: { Authorization: 'Bearer {env:V2_SET_PAT}' }, + }, + bad: { + type: 'remote', + url: 'https://bad.test/mcp', + headers: { Authorization: 'Bearer {env:V2_MISSING_PAT}' }, + }, + }, + }), + 'utf8' + ); + process.env.V2_SET_PAT = 'test-token'; + delete process.env.V2_MISSING_PAT; + + const originalError = console.error; + console.error = () => {}; + try { + const mock = createMockCtx(); + const cleanup = await setupV2(mock.ctx as never); + try { + expect(mock.mcpSets).toHaveLength(2); + expect(Object.fromEntries(mock.mcpSets)).toMatchObject({ + good: { headers: { Authorization: 'Bearer test-token' } }, + bad: { disabled: true, headers: { Authorization: 'Bearer ' } }, + }); + expect(JSON.stringify(mock.mcpSets)).not.toContain('{env:'); + } finally { + cleanup(); + } + } finally { + console.error = originalError; + delete process.env.V2_SET_PAT; + } + }); + }); + + it('rejects an unsafe mcp server key without discarding safe siblings', async () => { + await withIsolatedHome(async () => { + const locations = resolveSyncLocations(); + await fs.mkdir(locations.configRoot, { recursive: true }); + await fs.writeFile( + locations.overridesPath, + '{"mcp":{"__proto__":{"type":"remote","url":"https://unsafe.test/mcp"},"good":{"type":"remote","url":"https://good.test/mcp"}}}', + 'utf8' + ); + + const errors: unknown[][] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => { + errors.push(args); + }; + try { + const mock = createMockCtx(); + const cleanup = await setupV2(mock.ctx as never); + try { + expect(mock.mcpSets).toEqual([ + ['good', { type: 'remote', url: 'https://good.test/mcp' }], + ]); + expect( + errors.some((args) => JSON.stringify(args).includes('Unsafe local override')) + ).toBe(true); + expect(Object.prototype).not.toHaveProperty('type'); + } finally { + cleanup(); + } + } finally { + console.error = originalError; + } + }); + }); + it('warns once for unknown agent/provider overrides and keeps replays pure', async () => { await withIsolatedHome(async () => { const locations = resolveSyncLocations(); @@ -377,23 +461,6 @@ describe('v2 setup', () => { }); }); - it('posts command results via session.synthetic', async () => { - await withIsolatedHome(async () => { - const mock = createMockCtx(); - const cleanup = await setupV2(mock.ctx as never); - try { - const statusCommand = mock.commandAdds.find((command) => command.name === 'sync-status'); - expect(statusCommand).toBeDefined(); - await statusCommand?.execute({ sessionID: 'session-1', prompt: { text: '' } }); - expect(mock.synthetics).toHaveLength(1); - expect(mock.synthetics[0].sessionID).toBe('session-1'); - expect(mock.synthetics[0].text).toContain('opencode-synced is not configured'); - } finally { - cleanup(); - } - }); - }); - it('cleanup is idempotent and stops background work', async () => { await withIsolatedHome(async () => { const mock = createMockCtx(); @@ -404,19 +471,3 @@ describe('v2 setup', () => { }); }); }); - -describe('parseCommandRepoArg', () => { - it('parses bare repo args and slash-prefixed invocations', () => { - expect(parseCommandRepoArg('owner/repo', 'sync-link')).toBe('owner/repo'); - expect(parseCommandRepoArg('/sync-link owner/repo', 'sync-link')).toBe('owner/repo'); - expect(parseCommandRepoArg('', 'sync-link')).toBeUndefined(); - expect(parseCommandRepoArg('/sync-link', 'sync-link')).toBeUndefined(); - }); - - it('handles quotes, $ARGUMENTS, and extra tokens (first token wins)', () => { - expect(parseCommandRepoArg('$ARGUMENTS owner/repo', 'sync-link')).toBe('owner/repo'); - expect(parseCommandRepoArg('/sync-link "owner/repo"', 'sync-link')).toBe('owner/repo'); - expect(parseCommandRepoArg('owner/repo extra words', 'sync-link')).toBe('owner/repo'); - expect(parseCommandRepoArg(' ', 'sync-link')).toBeUndefined(); - }); -}); diff --git a/src/v2.ts b/src/v2.ts index 9e73dc5..df1c762 100644 --- a/src/v2.ts +++ b/src/v2.ts @@ -5,11 +5,8 @@ import type { PluginInput } from '@opencode-ai/plugin'; import { buildSyncToolInputSchema, executeSyncCommand, - loadCommands, - type ParsedCommand, SYNC_TOOL_COMMANDS, type SyncToolArgs, - type SyncToolCommand, } from './shared.js'; import { createNodeShell } from './shell-node.js'; import type { AiProvider } from './sync/ai.js'; @@ -22,9 +19,10 @@ import { resolveEnvPlaceholders, } from './sync/config.js'; import { resolveSyncLocations } from './sync/paths.js'; -import { createSyncService, type SyncService } from './sync/service.js'; +import { createSyncService } from './sync/service.js'; const PLUGIN_ID = 'opencode-synced'; +const SYNC_COMMAND_NAMES = new Set(SYNC_TOOL_COMMANDS); const OVERRIDES_DOC_POINTER = 'opencode-synced: ignoring unsupported override key (v2 applies only mcp/agent/model/provider via domain transforms; see https://opencode.ai/v2/docs/build/plugins/migrate-v1)'; @@ -41,6 +39,13 @@ function v2Error(message: string): void { console.error(`[opencode-synced] ${message}`); } +/** Replace v1 slash-command hints in shared service output with the v2 tool. */ +function v2ToolGuidance(result: string): string { + return result.replace(/\/sync-([a-z-]+)/g, (reference, command: string) => + SYNC_COMMAND_NAMES.has(command) ? `opencode_sync with {"command":"${command}"}` : reference + ); +} + interface OverrideFailure { fieldPath: readonly string[]; message: string; @@ -49,14 +54,15 @@ interface OverrideFailure { interface ResolvedOverrides { /** Raw document as loaded (used for secret-free fallback configs). */ raw: Record; - /** Per-top-level-key resolved values; keys with failures are omitted. */ + /** Resolved values; MCP servers are independent so one failure preserves siblings. */ values: Record; /** Field paths (e.g. ['overrides','mcp','github',...]) that failed {env:…} resolution. */ failures: OverrideFailure[]; } /** - * Load overrides once and resolve `{env:…}` per top-level key. + * Load overrides once and resolve `{env:…}` per top-level key, except MCP + * servers, which must fail independently. * Single read avoids TOCTOU drift between the resolved values and the raw * fallback used for disabled MCP servers. */ @@ -67,6 +73,24 @@ async function loadResolvedOverrides(): Promise { const values: Record = {}; const failures: OverrideFailure[] = []; for (const [key, value] of Object.entries(raw)) { + if (key === 'mcp' && isPlainObject(value)) { + const resolvedMcp: Record = {}; + for (const [name, config] of Object.entries(value)) { + if (name === '__proto__') { + v2Error('Unsafe local override field "overrides.mcp.__proto__" is not allowed.'); + continue; + } + try { + resolvedMcp[name] = resolveEnvPlaceholders(config, process.env, ['overrides', key, name]); + } catch (error) { + if (!(error instanceof EnvPlaceholderResolutionError)) throw error; + failures.push({ fieldPath: error.fieldPath, message: error.message }); + v2Error(error.message); + } + } + values[key] = resolvedMcp; + continue; + } try { values[key] = resolveEnvPlaceholders(value, process.env, ['overrides', key]); } catch (error) { @@ -393,91 +417,7 @@ function createV2AiProvider(ctx: V2Context): AiProvider { }; } -const SYNC_COMMAND_NAMES = new Set(SYNC_TOOL_COMMANDS as readonly string[]); - -function toolCommandForName(name: string): SyncToolCommand | null { - const suffix = name.startsWith('sync-') ? name.slice('sync-'.length) : name; - return SYNC_COMMAND_NAMES.has(suffix) ? (suffix as SyncToolCommand) : null; -} - -/** - * Parse a bare repo argument from a slash-command invocation - * (e.g. `/sync-link owner/repo`). - * - * V2 slash commands only carry free text (`prompt.text`), not structured tool - * args, so only a single bare `owner/repo` (or URL) is supported. Quoted - * multi-word input uses the first token; anything beyond `init`/`link` repo - * and `sessions-backend` backend is intentionally ignored (documented limit). - */ -export function parseCommandRepoArg( - text: string | undefined, - commandName: string -): string | undefined { - if (!text) return undefined; - let arg = text.trim(); - if (!arg) return undefined; - if (arg.startsWith('/')) { - const firstSpace = arg.indexOf(' '); - if (firstSpace === -1) return undefined; - arg = arg.slice(firstSpace + 1).trim(); - } else if (arg.startsWith(commandName)) { - arg = arg.slice(commandName.length).trim(); - } - if (arg.startsWith('$ARGUMENTS')) arg = arg.slice('$ARGUMENTS'.length).trim(); - if (!arg) return undefined; - // Strip surrounding quotes, then take the first whitespace-separated token. - arg = arg.replace(/^["']+|["']+$/g, '').trim(); - const firstToken = arg.split(/\s+/)[0]; - return firstToken || undefined; -} - -async function executeV2Command( - service: SyncService, - command: ParsedCommand, - invocation: { sessionID: string; prompt?: { text?: string }; text?: string } -): Promise { - const toolCommand = toolCommandForName(command.name); - if (!toolCommand) return `Unknown sync command: ${command.name}`; - - const rawText = invocation.prompt?.text ?? invocation.text; - const repo = parseCommandRepoArg(rawText, command.name); - - switch (toolCommand) { - case 'status': - return await service.status(); - case 'init': - return await service.init({ repo }); - case 'link': - return await service.link({ repo }); - case 'pull': - return await service.pull(); - case 'push': - return await service.push(); - case 'resolve': - return await service.resolve(); - case 'secrets-pull': - return await service.secretsPull(); - case 'secrets-push': - return await service.secretsPush(); - case 'secrets-status': - return await service.secretsStatus(); - case 'enable-secrets': - return await service.enableSecrets({}); - case 'sessions-backend': { - const backend = repo === 'git' || repo === 'turso' ? repo : undefined; - return await service.sessionsBackend({ backend }); - } - case 'sessions-setup-turso': - return await service.sessionsSetupTurso({}); - case 'sessions-migrate-turso': - return await service.sessionsMigrateTurso({}); - case 'sessions-cleanup-git': - return await service.sessionsCleanupGit(); - } -} - export async function setupV2(ctx: V2Context): Promise<() => void> { - const commands = await loadCommands(); const service = createSyncService({ client: createV2ClientFacade(), $: createNodeShell(), @@ -499,31 +439,11 @@ export async function setupV2(ctx: V2Context): Promise<() => void> { const result = await executeSyncCommand(service, input as unknown as SyncToolArgs); // Tool.Result.content accepts string | Content[]; plain string keeps // large status outputs readable without manual TextContent wrapping. - return { content: result }; + return { content: v2ToolGuidance(result) }; }, }); }); - // V2 `CommandDefinition` only carries name/description/execute (no - // template/agent/model/subtask like v1 `config.command`). The markdown - // template is therefore executed directly via `executeV2Command` + synthetic - // reply instead of being registered as a prompt template. - await ctx.command.transform((editor) => { - for (const command of commands) { - editor.add({ - name: command.name, - description: command.frontmatter.description, - execute: async ({ sessionID, prompt }) => { - const result = await executeV2Command(service, command, { - sessionID, - prompt: prompt as { text?: string }, - }); - await ctx.session.synthetic({ sessionID, text: result }); - }, - }); - } - }); - if (resolved) { await registerMcpTransform(ctx, resolved); await registerAgentTransform(ctx, resolved);