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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Supported synchronized data:
- global `skills/`
- plaintext prompt history and prompt stash in a private repository
- only the `favorite` projection from `state/opencode/model.json`
- `main-model.txt` and `cheap-model.txt`
- `main-model.txt`, `cheap-model.txt`, and `frontier-model.txt`

Always local and rejected by configuration validation:

Expand Down Expand Up @@ -94,6 +94,10 @@ artifact into OpenCode's package cache. Do not use a mutable branch reference.
Prompt flags fail unless the risk acknowledgement is exactly `true`. The repository visibility
is checked before prompt data is read or written.

On Windows, sync locations follow OpenCode's XDG-compatible layout: `%USERPROFILE%\.config`,
`%USERPROFILE%\.local\share`, and `%USERPROFILE%\.local\state`. The native `APPDATA` and
`LOCALAPPDATA` roots are not used for sync locations.

## Commands

| Command | Description |
Expand Down Expand Up @@ -121,6 +125,7 @@ state/
model-selectors/
main-model.txt
cheap-model.txt
frontier-model.txt
prompts/
prompt-history.jsonl
prompt-stash.jsonl
Expand Down
5 changes: 4 additions & 1 deletion docs/extended-sync-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The fork may synchronize only these paths:
- `skills/`, excluding generated cache and platform metadata
- prompt history and prompt stash as plaintext in a private repository
- the `favorite` projection from `model.json`
- `main-model.txt` and `cheap-model.txt`
- `main-model.txt`, `cheap-model.txt`, and `frontier-model.txt`

The following remain local and are rejected when explicitly enabled:

Expand Down Expand Up @@ -43,6 +43,9 @@ This policy uses operation order, never wall-clock timestamps.
(`*:Zone.Identifier`, `.DS_Store`) are excluded.
- Files are staged and atomically renamed instead of overwriting live files in
place. Directory replacement must preserve a rollback copy until completion.
- On Windows, sync locations follow OpenCode's XDG-compatible `%USERPROFILE%\.config`,
`%USERPROFILE%\.local\share`, and `%USERPROFILE%\.local\state` roots instead of
native `APPDATA` and `LOCALAPPDATA` roots.
- Generated local state and overrides use mode `0600`; their parent directory uses
mode `0700`.
- Secrets, sessions, sync configuration, and overrides are never part of a plan.
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"vitest": "^3.2.4"
},
"scripts": {
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp -r src/command dist/command",
"build": "node scripts/build.mjs",
"prepack": "npm run build",
"test": "vitest run",
"test:watch": "vitest",
Expand Down
15 changes: 15 additions & 0 deletions scripts/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { spawnSync } from 'node:child_process';
import { cp, rm } from 'node:fs/promises';

await rm('dist', { recursive: true, force: true });

const result = spawnSync(
process.execPath,
['node_modules/typescript/bin/tsc', '-p', 'tsconfig.build.json'],
{ stdio: 'inherit' }
);

if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);

await cp('src/command', 'dist/command', { recursive: true });
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ export const opencodeConfigSync: Plugin = async (ctx) => {
includeModelSelectors: tool.schema
.boolean()
.optional()
.describe('Sync main-model.txt and cheap-model.txt'),
.describe('Sync main-model.txt, cheap-model.txt, and frontier-model.txt'),
acknowledgePlaintextPromptRisk: tool.schema
.boolean()
.optional()
Expand Down
29 changes: 22 additions & 7 deletions src/sync/apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,16 @@ describe('safe synchronization', () => {
await writeFile(path.join(skills, 'demo', 'run.sh'), '#!/bin/sh\nexit 0\n');
await chmod(path.join(skills, 'demo', 'run.sh'), 0o755);
await writeFile(path.join(skills, 'demo', '__pycache__', 'cache.pyc'), 'cache');
await writeFile(path.join(skills, 'demo', 'SKILL.md:Zone.Identifier'), 'metadata');
const zoneIdentifierNames =
process.platform === 'win32'
? [
`SKILL.md${String.fromCharCode(0xf03a)}Zone.Identifier`,
`SKILL.md${String.fromCharCode(0xff1a)}Zone.Identifier`,
]
: ['SKILL.md:Zone.Identifier'];
for (const zoneIdentifierName of zoneIdentifierNames) {
await writeFile(path.join(skills, 'demo', zoneIdentifierName), 'metadata');
}
await writeFile(path.join(skills, '.DS_Store'), 'metadata');

const destination = path.join(repo, 'config', 'skills');
Expand All @@ -120,13 +129,17 @@ describe('safe synchronization', () => {
await expect(lstat(path.join(destination, 'demo', '__pycache__'))).rejects.toMatchObject({
code: 'ENOENT',
});
await expect(
lstat(path.join(destination, 'demo', 'SKILL.md:Zone.Identifier'))
).rejects.toMatchObject({ code: 'ENOENT' });
for (const zoneIdentifierName of zoneIdentifierNames) {
await expect(
lstat(path.join(destination, 'demo', zoneIdentifierName))
).rejects.toMatchObject({ code: 'ENOENT' });
}
await expect(lstat(path.join(destination, '.DS_Store'))).rejects.toMatchObject({
code: 'ENOENT',
});
expect((await lstat(path.join(destination, 'demo', 'run.sh'))).mode & 0o777).toBe(0o755);
if (process.platform !== 'win32') {
expect((await lstat(path.join(destination, 'demo', 'run.sh'))).mode & 0o777).toBe(0o755);
}
});

it('rejects sensitive files in skills', async () => {
Expand Down Expand Up @@ -377,8 +390,10 @@ describe('safe synchronization', () => {
await syncLocalToRepo(plan, null);

expect(await readFile(destination, 'utf8')).toBe(content);
expect((await lstat(destination)).mode & 0o777).toBe(0o600);
expect((await lstat(path.dirname(destination))).mode & 0o777).toBe(0o700);
if (process.platform !== 'win32') {
expect((await lstat(destination)).mode & 0o777).toBe(0o600);
expect((await lstat(path.dirname(destination))).mode & 0o777).toBe(0o700);
}
});

it('rejects malformed prompt JSONL before it reaches the repository', async () => {
Expand Down
3 changes: 2 additions & 1 deletion src/sync/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -501,7 +501,8 @@ function classifySkillPath(
]);
if (isDirectory && ignoredDirectories.has(name)) return 'ignore';
if (name.endsWith('.pyc') || name.endsWith('.pyo')) return 'ignore';
if (name.endsWith(':zone.identifier') || name === '.ds_store') return 'ignore';
const normalizedMetadataName = name.replace(/[\uF03A\uFF1A]/g, ':');
if (normalizedMetadataName.endsWith(':zone.identifier') || name === '.ds_store') return 'ignore';

const sensitiveDirectories = new Set(['.gnupg', '.ssh', 'private', 'secrets']);
if (lowerSegments.some((segment) => sensitiveDirectories.has(segment))) return 'reject';
Expand Down
12 changes: 8 additions & 4 deletions src/sync/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,10 @@ describe('secure local files', () => {
includeSkills: true,
});

expect((await lstat(locations.syncConfigPath)).mode & 0o777).toBe(0o600);
expect((await lstat(path.dirname(locations.syncConfigPath))).mode & 0o777).toBe(0o700);
if (process.platform !== 'win32') {
expect((await lstat(locations.syncConfigPath)).mode & 0o777).toBe(0o600);
expect((await lstat(path.dirname(locations.syncConfigPath))).mode & 0o777).toBe(0o700);
}
} finally {
await rm(tempDir, { recursive: true, force: true });
}
Expand All @@ -200,8 +202,10 @@ describe('secure local files', () => {
lastPull: 'pull-time',
lastPush: 'push-time',
});
expect((await lstat(locations.statePath)).mode & 0o777).toBe(0o600);
expect((await lstat(path.dirname(locations.statePath))).mode & 0o777).toBe(0o700);
if (process.platform !== 'win32') {
expect((await lstat(locations.statePath)).mode & 0o777).toBe(0o600);
expect((await lstat(path.dirname(locations.statePath))).mode & 0o777).toBe(0o700);
}
} finally {
await rm(tempDir, { recursive: true, force: true });
}
Expand Down
66 changes: 40 additions & 26 deletions src/sync/paths.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import path from 'node:path';

import { describe, expect, it } from 'vitest';

import type { SyncConfig } from './config.js';
Expand All @@ -8,8 +10,8 @@ describe('resolveXdgPaths', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const paths = resolveXdgPaths(env, 'linux');

expect(paths.configDir).toBe('/home/test/.config');
expect(paths.dataDir).toBe('/home/test/.local/share');
expect(paths.configDir).toBe(path.join('/home/test', '.config'));
expect(paths.dataDir).toBe(path.join('/home/test', '.local', 'share'));
});

it('resolves windows defaults', () => {
Expand All @@ -20,8 +22,9 @@ describe('resolveXdgPaths', () => {
} as NodeJS.ProcessEnv;
const paths = resolveXdgPaths(env, 'win32');

expect(paths.configDir).toBe('C:\\Users\\Test\\AppData\\Roaming');
expect(paths.dataDir).toBe('C:\\Users\\Test\\AppData\\Local');
expect(paths.configDir).toBe(path.join('C:\\Users\\Test', '.config'));
expect(paths.dataDir).toBe(path.join('C:\\Users\\Test', '.local', 'share'));
expect(paths.stateDir).toBe(path.join('C:\\Users\\Test', '.local', 'state'));
});
});

Expand All @@ -33,7 +36,7 @@ describe('resolveSyncLocations', () => {
} as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');

expect(locations.configRoot).toBe('/official/opencode');
expect(locations.configRoot).toBe(path.resolve('/official/opencode'));
});

it('respects opencode_config_dir', () => {
Expand All @@ -43,9 +46,13 @@ describe('resolveSyncLocations', () => {
} as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');

expect(locations.configRoot).toBe('/custom/opencode');
expect(locations.syncConfigPath).toBe('/custom/opencode/opencode-synced.jsonc');
expect(locations.overridesPath).toBe('/custom/opencode/opencode-synced.overrides.jsonc');
expect(locations.configRoot).toBe(path.resolve('/custom/opencode'));
expect(locations.syncConfigPath).toBe(
path.join(path.resolve('/custom/opencode'), 'opencode-synced.jsonc')
);
expect(locations.overridesPath).toBe(
path.join(path.resolve('/custom/opencode'), 'opencode-synced.overrides.jsonc')
);
});
});

Expand All @@ -64,7 +71,10 @@ describe('buildSyncPlan', () => {
'linux'
);

expect(plan.localRoots).toEqual(['/mnt/config/opencode', '/mnt/state/opencode']);
expect(plan.localRoots).toEqual([
path.join('/mnt/config', 'opencode'),
path.join('/mnt/state', 'opencode'),
]);
});

it('excludes secrets and arbitrary extra paths', () => {
Expand Down Expand Up @@ -108,7 +118,7 @@ describe('buildSyncPlan', () => {

const plan = buildSyncPlan(config, locations, '/repo', 'linux');
const favoritesItem = plan.items.find((item) =>
item.localPath.endsWith('/.local/state/opencode/model.json')
item.localPath.endsWith(path.join('.local', 'state', 'opencode', 'model.json'))
);

expect(favoritesItem).toBeTruthy();
Expand All @@ -120,7 +130,7 @@ describe('buildSyncPlan', () => {
'linux'
);
const disabledItem = disabledPlan.items.find((item) =>
item.localPath.endsWith('/.local/state/opencode/model.json')
item.localPath.endsWith(path.join('.local', 'state', 'opencode', 'model.json'))
);

expect(disabledItem).toBeUndefined();
Expand All @@ -135,13 +145,13 @@ describe('buildSyncPlan', () => {
};

const disabled = buildSyncPlan(base, locations, '/repo', 'linux');
expect(disabled.items.some((item) => item.localPath.endsWith('/skills'))).toBe(false);
expect(disabled.items.some((item) => item.localPath.endsWith('skills'))).toBe(false);

const enabled = buildSyncPlan({ ...base, includeSkills: true }, locations, '/repo', 'linux');
const skills = enabled.items.find((item) => item.localPath.endsWith('/skills'));
const skills = enabled.items.find((item) => item.localPath.endsWith('skills'));

expect(skills).toMatchObject({
repoPath: '/repo/config/skills',
repoPath: path.join('/repo', 'config', 'skills'),
type: 'dir',
strategy: 'skills',
});
Expand All @@ -164,20 +174,20 @@ describe('buildSyncPlan', () => {
expect(promptItems).toEqual(
expect.arrayContaining([
expect.objectContaining({
localPath: '/home/test/.local/state/opencode/prompt-history.jsonl',
repoPath: '/repo/state/prompts/prompt-history.jsonl',
localPath: path.join('/home/test', '.local', 'state', 'opencode', 'prompt-history.jsonl'),
repoPath: path.join('/repo', 'state', 'prompts', 'prompt-history.jsonl'),
isSecret: true,
}),
expect.objectContaining({
localPath: '/home/test/.local/state/opencode/prompt-stash.jsonl',
repoPath: '/repo/state/prompts/prompt-stash.jsonl',
localPath: path.join('/home/test', '.local', 'state', 'opencode', 'prompt-stash.jsonl'),
repoPath: path.join('/repo', 'state', 'prompts', 'prompt-stash.jsonl'),
isSecret: true,
}),
])
);

expect(plan.items.some((item) => item.localPath.endsWith('/auth.json'))).toBe(false);
expect(plan.items.some((item) => item.localPath.endsWith('/mcp-auth.json'))).toBe(false);
expect(plan.items.some((item) => item.localPath.endsWith('auth.json'))).toBe(false);
expect(plan.items.some((item) => item.localPath.endsWith('mcp-auth.json'))).toBe(false);
});

it('adds portable model selector files when enabled', () => {
Expand All @@ -194,12 +204,16 @@ describe('buildSyncPlan', () => {
expect(selectors).toEqual(
expect.arrayContaining([
expect.objectContaining({
localPath: '/home/test/.config/opencode/main-model.txt',
repoPath: '/repo/state/model-selectors/main-model.txt',
localPath: path.join('/home/test', '.config', 'opencode', 'main-model.txt'),
repoPath: path.join('/repo', 'state', 'model-selectors', 'main-model.txt'),
}),
expect.objectContaining({
localPath: path.join('/home/test', '.config', 'opencode', 'cheap-model.txt'),
repoPath: path.join('/repo', 'state', 'model-selectors', 'cheap-model.txt'),
}),
expect.objectContaining({
localPath: '/home/test/.config/opencode/cheap-model.txt',
repoPath: '/repo/state/model-selectors/cheap-model.txt',
localPath: path.join('/home/test', '.config', 'opencode', 'frontier-model.txt'),
repoPath: path.join('/repo', 'state', 'model-selectors', 'frontier-model.txt'),
}),
])
);
Expand All @@ -215,8 +229,8 @@ describe('buildSyncPlan', () => {
'linux'
);

const favorites = plan.items.find((item) => item.localPath.endsWith('/model.json'));
const favorites = plan.items.find((item) => item.localPath.endsWith('model.json'));
expect(favorites?.strategy).toBe('model-favorites');
expect(favorites?.repoPath).toBe('/repo/state/model-favorites.json');
expect(favorites?.repoPath).toBe(path.join('/repo', 'state', 'model-favorites.json'));
});
});
10 changes: 5 additions & 5 deletions src/sync/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const CONFIG_DIRS = ['agent', 'command', 'mode', 'tool', 'themes', 'plugin'];
const MODEL_FAVORITES_FILE = 'model.json';
const PROMPT_HISTORY_FILE = 'prompt-history.jsonl';
const PROMPT_STASH_FILE = 'prompt-stash.jsonl';
const MODEL_SELECTOR_FILES = ['main-model.txt', 'cheap-model.txt'];
const MODEL_SELECTOR_FILES = ['main-model.txt', 'cheap-model.txt', 'frontier-model.txt'];

export function resolveHomeDir(
env: NodeJS.ProcessEnv = process.env,
Expand Down Expand Up @@ -83,10 +83,10 @@ export function resolveXdgPaths(
}

if (platform === 'win32') {
const configDir = env.APPDATA ?? path.join(homeDir, 'AppData', 'Roaming');
const dataDir = env.LOCALAPPDATA ?? path.join(homeDir, 'AppData', 'Local');
// Windows doesn't have XDG_STATE_HOME equivalent, use LOCALAPPDATA
const stateDir = env.LOCALAPPDATA ?? path.join(homeDir, 'AppData', 'Local');
// This OpenCode installation uses XDG-compatible roots on Windows.
const configDir = env.XDG_CONFIG_HOME ?? path.join(homeDir, '.config');
const dataDir = env.XDG_DATA_HOME ?? path.join(homeDir, '.local', 'share');
const stateDir = env.XDG_STATE_HOME ?? path.join(homeDir, '.local', 'state');
return { homeDir, configDir, dataDir, stateDir };
}

Expand Down
2 changes: 1 addition & 1 deletion src/sync/repo.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe('normalizeRepoRemote', () => {
});

it('normalizes local repository paths', () => {
expect(normalizeRepoRemote('/tmp/example.git')).toBe('local:/tmp/example.git');
expect(normalizeRepoRemote('/tmp/example.git')).toBe(`local:${path.resolve('/tmp/example.git')}`);
});

it('does not equate alternate protocols or ports with canonical GitHub', () => {
Expand Down
2 changes: 1 addition & 1 deletion src/sync/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,7 +306,7 @@ async function resolveRebaseConflictsLocalWins(
}

try {
await $`env GIT_EDITOR=true git -C ${repoDir} rebase --continue`.quiet();
await $`git -C ${repoDir} -c core.editor=true rebase --continue`.quiet();
return;
} catch {
const remaining = await $`git -C ${repoDir} diff --name-only --diff-filter=U -z`
Expand Down
Loading
Loading