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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ Create `~/.config/opencode/opencode-synced.jsonc`:
- `~/.config/opencode/agent/`, `command/`, `mode/`, `tool/`, `themes/`, `plugin/`, `skills/`
- `~/.agents/`
- `~/.local/state/opencode/model.json` (model favorites)
- Any additional paths in `extraConfigPaths` (allowlist, files or folders). You do not need to include default paths like `~/.config/opencode/skills` or `~/.agents`.
- Any additional paths in `extraConfigPaths` (allowlist, files or folders). Relative paths resolve from `$XDG_CONFIG_HOME/opencode` (normally `~/.config/opencode`). You do not need to include default paths like `~/.config/opencode/skills` or `~/.agents`.

`~/.agents/` is enabled by default and may contain instructions or skills you consider private.
Review it before syncing, keep the sync repository private when needed, or set
Expand All @@ -144,7 +144,7 @@ Enable secrets with `/sync-enable-secrets` or set `"includeSecrets": true`:

- `~/.local/share/opencode/auth.json`
- `~/.local/share/opencode/mcp-auth.json`
- Any extra paths in `extraSecretPaths` (allowlist, files or folders)
- Any extra paths in `extraSecretPaths` (allowlist, files or folders). Relative paths resolve from `$XDG_CONFIG_HOME/opencode` (normally `~/.config/opencode`).

MCP API keys stored inside `opencode.json(c)` are **not** committed by default. To allow them
in a private repo, set `"includeMcpSecrets": true` (requires `includeSecrets`).
Expand Down
132 changes: 132 additions & 0 deletions src/sync/apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { syncLocalToRepo, syncRepoToLocal } from './apply.js';
import type { SyncConfig } from './config.js';
import { loadOverrides, normalizeSyncConfig, parseJsonc } from './config.js';
import type { ExtraPathPlan, SyncItem, SyncPlan } from './paths.js';
import { buildSyncPlan, resolveSyncLocations } from './paths.js';
Expand Down Expand Up @@ -283,6 +284,137 @@ describe('syncRepoToLocal for session database', () => {
});
});

describe('relative extra paths', () => {
it('syncs config and secret files and directories from an unrelated cwd', async () => {
await withTempDir(async (root) => {
const homeDir = path.join(root, 'home');
const configRoot = path.join(homeDir, '.config', 'opencode');
const repoRoot = path.join(root, 'repo');
const unrelatedCwd = path.join(root, 'unrelated-cwd');
const configFile = path.join(configRoot, 'SOUL.md');
const configDirectory = path.join(configRoot, 'custom-configs');
const secretFile = path.join(configRoot, 'credentials', 'token.json');
const secretDirectory = path.join(configRoot, 'private-agents');
await fs.mkdir(configDirectory, { recursive: true });
await fs.mkdir(path.dirname(secretFile), { recursive: true });
await fs.mkdir(secretDirectory, { recursive: true });
await fs.mkdir(unrelatedCwd, { recursive: true });
await fs.writeFile(configFile, 'config-file', 'utf8');
await fs.writeFile(path.join(configDirectory, 'custom.md'), 'config-directory', 'utf8');
await fs.writeFile(secretFile, 'secret-file', 'utf8');
await fs.writeFile(path.join(secretDirectory, 'private.md'), 'secret-directory', 'utf8');

const locations = resolveSyncLocations({ HOME: homeDir }, 'linux');
const config: SyncConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: true,
extraConfigPaths: ['SOUL.md', 'custom-configs'],
extraSecretPaths: ['credentials/token.json', 'private-agents'],
};
const originalCwd = process.cwd();

try {
process.chdir(unrelatedCwd);
const plan = buildSyncPlan(normalizeSyncConfig(config), locations, repoRoot, 'linux');
await syncLocalToRepo(plan, null);

const configManifest = JSON.parse(
await fs.readFile(plan.extraConfigs.manifestPath, 'utf8')
) as {
entries: Array<{ sourcePath: string; repoPath: string; type: 'file' | 'dir' }>;
};
const secretManifest = JSON.parse(
await fs.readFile(plan.extraSecrets.manifestPath, 'utf8')
) as {
entries: Array<{ sourcePath: string; repoPath: string; type: 'file' | 'dir' }>;
};

expect(configManifest.entries.map((entry) => [entry.sourcePath, entry.type])).toEqual([
[configFile, 'file'],
[configDirectory, 'dir'],
]);
expect(secretManifest.entries.map((entry) => [entry.sourcePath, entry.type])).toEqual([
[secretFile, 'file'],
[secretDirectory, 'dir'],
]);

const configRepoPaths = new Map(
configManifest.entries.map((entry) => [
entry.sourcePath,
path.join(repoRoot, entry.repoPath),
])
);
const secretRepoPaths = new Map(
secretManifest.entries.map((entry) => [
entry.sourcePath,
path.join(repoRoot, entry.repoPath),
])
);
await expect(fs.readFile(configRepoPaths.get(configFile) ?? '', 'utf8')).resolves.toBe(
'config-file'
);
await expect(
fs.readFile(path.join(configRepoPaths.get(configDirectory) ?? '', 'custom.md'), 'utf8')
).resolves.toBe('config-directory');
await expect(fs.readFile(secretRepoPaths.get(secretFile) ?? '', 'utf8')).resolves.toBe(
'secret-file'
);
await expect(
fs.readFile(path.join(secretRepoPaths.get(secretDirectory) ?? '', 'private.md'), 'utf8')
).resolves.toBe('secret-directory');
} finally {
process.chdir(originalCwd);
}
});
});

it('does not apply a manifest source outside the resolved allowlist', async () => {
await withTempDir(async (root) => {
const homeDir = path.join(root, 'home');
const repoRoot = path.join(root, 'repo');
const locations = resolveSyncLocations({ HOME: homeDir }, 'linux');
const allowedPath = path.join(locations.configRoot, 'allowed.json');
const blockedPath = path.join(locations.configRoot, 'blocked.json');
const allowedRepoPath = path.join(repoRoot, 'config', 'extra', 'allowed.json');
const rogueRepoPath = path.join(repoRoot, 'config', 'extra', 'rogue.json');
await fs.mkdir(path.dirname(rogueRepoPath), { recursive: true });
await fs.writeFile(allowedRepoPath, 'allowed-copy', 'utf8');
await fs.writeFile(rogueRepoPath, 'must-not-copy', 'utf8');

const config: SyncConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: false,
extraConfigPaths: ['allowed.json'],
};
const plan = buildSyncPlan(normalizeSyncConfig(config), locations, repoRoot, 'linux');
await fs.mkdir(path.dirname(plan.extraConfigs.manifestPath), { recursive: true });
await fs.writeFile(
plan.extraConfigs.manifestPath,
JSON.stringify({
entries: [
{
sourcePath: allowedPath,
repoPath: path.relative(repoRoot, allowedRepoPath),
type: 'file',
},
{
sourcePath: blockedPath,
repoPath: path.relative(repoRoot, rogueRepoPath),
type: 'file',
},
],
}),
'utf8'
);

await syncRepoToLocal(plan, null);

await expect(fs.readFile(allowedPath, 'utf8')).resolves.toBe('allowed-copy');
await expect(fs.stat(blockedPath)).rejects.toMatchObject({ code: 'ENOENT' });
});
});
});

describe('chunked Git session items', () => {
const chunkOptions = {
thresholdBytes: 8,
Expand Down
120 changes: 120 additions & 0 deletions src/sync/paths.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import { promises as fs } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';

import type { SyncConfig } from './config.js';
Expand All @@ -6,6 +9,7 @@ import {
buildSyncPlan,
expandHome,
normalizePath,
resolveExtraPath,
resolveHomeDir,
resolveRepoRoot,
resolveSyncLocations,
Expand Down Expand Up @@ -235,6 +239,85 @@ describe('buildSyncPlan', () => {
expect(plan.extraConfigs.allowlist).toEqual([customConfigPath]);
});

it('resolves relative extra config and secret paths from the opencode config root', async () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
const unrelatedCwd = await fs.mkdtemp(path.join(tmpdir(), 'opencode-sync-cwd-'));
const originalCwd = process.cwd();
const config: SyncConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: true,
extraConfigPaths: ['SOUL.md', 'commands/custom'],
extraSecretPaths: ['credentials/token.json', 'private-agents'],
};

try {
process.chdir(unrelatedCwd);
const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux');

expect(plan.extraConfigs.allowlist).toEqual([
'/home/test/.config/opencode/SOUL.md',
'/home/test/.config/opencode/commands/custom',
]);
expect(plan.extraSecrets.allowlist).toEqual([
'/home/test/.config/opencode/credentials/token.json',
'/home/test/.config/opencode/private-agents',
]);
} finally {
process.chdir(originalCwd);
await fs.rm(unrelatedCwd, { recursive: true, force: true });
}
});

it('keeps home-relative and absolute extra path behavior', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
const config: SyncConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: true,
extraConfigPaths: ['~/shared/config.json', '/opt/opencode/config.json'],
extraSecretPaths: ['~/.ssh/id_rsa', '/run/secrets/opencode'],
};

const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux');

expect(plan.extraConfigs.allowlist).toEqual([
'/home/test/shared/config.json',
'/opt/opencode/config.json',
]);
expect(plan.extraSecrets.allowlist).toEqual([
'/home/test/.ssh/id_rsa',
'/run/secrets/opencode',
]);
});

it('uses the XDG opencode root for relative paths when a custom config dir is set', () => {
const env = {
HOME: '/home/test',
XDG_CONFIG_HOME: '/srv/xdg-config',
opencode_config_dir: '/custom/opencode',
} as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');

expect(resolveExtraPath('custom.json', locations, 'linux')).toBe(
'/srv/xdg-config/opencode/custom.json'
);
});

it('deduplicates relative paths that are already default sync items', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
const config: SyncConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: false,
extraConfigPaths: ['agent', 'opencode.json', 'skills', '~/.agents', 'custom.json'],
};

const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux');

expect(plan.extraConfigs.allowlist).toEqual(['/home/test/.config/opencode/custom.json']);
});

it('includes canonical plural and legacy singular config directories exactly once', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
Expand Down Expand Up @@ -267,6 +350,25 @@ describe('buildSyncPlan', () => {
expect(new Set(plan.items.map((item) => item.repoPath)).size).toBe(plan.items.length);
});

it('uses Windows path semantics for relative, home-relative, and absolute entries', () => {
const env = {
USERPROFILE: 'C:\\Users\\Test',
APPDATA: 'C:\\Users\\Test\\AppData\\Roaming',
LOCALAPPDATA: 'C:\\Users\\Test\\AppData\\Local',
} as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'win32');

expect(resolveExtraPath('commands\\custom.md', locations, 'win32')).toBe(
'c:\\users\\test\\.config\\opencode\\commands\\custom.md'
);
expect(resolveExtraPath('~/shared/config.json', locations, 'win32')).toBe(
'c:\\users\\test\\shared\\config.json'
);
expect(resolveExtraPath('D:\\opencode\\config.json', locations, 'win32')).toBe(
'd:\\opencode\\config.json'
);
});

it('filters plural defaults from extra config paths case-insensitively on Windows', () => {
const env = {
USERPROFILE: 'C:\\Users\\Test',
Expand All @@ -286,6 +388,24 @@ describe('buildSyncPlan', () => {
expect(plan.extraConfigs.allowlist).toEqual([]);
});

it('keeps traversing relative paths exact and their repository paths contained', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
const config: SyncConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: false,
extraConfigPaths: ['../shared/config.json'],
};

const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux');
const entry = plan.extraConfigs.entries[0];

expect(plan.extraConfigs.allowlist).toEqual(['/home/test/.config/shared/config.json']);
expect(entry?.sourcePath).toBe('/home/test/.config/shared/config.json');
expect(entry?.repoPath.startsWith('/repo/config/extra/')).toBe(true);
expect(path.relative('/repo/config/extra', entry?.repoPath ?? '').startsWith('..')).toBe(false);
});

it('includes skills directory in default sync items', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
Expand Down
Loading
Loading