opencode-synced 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,3 @@
1
- import type { SyncPlan } from './paths.ts';
1
+ import type { SyncPlan } from './paths.js';
2
2
  export declare function syncRepoToLocal(plan: SyncPlan, overrides: Record<string, unknown> | null): Promise<void>;
3
3
  export declare function syncLocalToRepo(plan: SyncPlan, overrides: Record<string, unknown> | null): Promise<void>;
@@ -0,0 +1,195 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { deepMerge, parseJsonc, pathExists, stripOverrides, writeJsonFile } from './config.js';
4
+ import { normalizePath } from './paths.js';
5
+ export async function syncRepoToLocal(plan, overrides) {
6
+ for (const item of plan.items) {
7
+ await copyItem(item.repoPath, item.localPath, item.type);
8
+ }
9
+ await applyExtraSecrets(plan, true);
10
+ if (overrides && Object.keys(overrides).length > 0) {
11
+ await applyOverridesToLocalConfig(plan, overrides);
12
+ }
13
+ }
14
+ export async function syncLocalToRepo(plan, overrides) {
15
+ for (const item of plan.items) {
16
+ if (item.isConfigFile && overrides && Object.keys(overrides).length > 0) {
17
+ await copyConfigForRepo(item, overrides, plan.repoRoot);
18
+ continue;
19
+ }
20
+ await copyItem(item.localPath, item.repoPath, item.type, true);
21
+ }
22
+ await writeExtraSecretsManifest(plan);
23
+ }
24
+ async function copyItem(sourcePath, destinationPath, type, removeWhenMissing = false) {
25
+ if (!(await pathExists(sourcePath))) {
26
+ if (removeWhenMissing) {
27
+ await removePath(destinationPath);
28
+ }
29
+ return;
30
+ }
31
+ if (type === 'file') {
32
+ await copyFileWithMode(sourcePath, destinationPath);
33
+ return;
34
+ }
35
+ await removePath(destinationPath);
36
+ await copyDirRecursive(sourcePath, destinationPath);
37
+ }
38
+ async function copyConfigForRepo(item, overrides, repoRoot) {
39
+ if (!(await pathExists(item.localPath))) {
40
+ await removePath(item.repoPath);
41
+ return;
42
+ }
43
+ const localContent = await fs.readFile(item.localPath, 'utf8');
44
+ const localConfig = parseJsonc(localContent);
45
+ const baseConfig = await readRepoConfig(item, repoRoot);
46
+ if (baseConfig) {
47
+ const expectedLocal = deepMerge(baseConfig, overrides);
48
+ if (isDeepEqual(localConfig, expectedLocal)) {
49
+ return;
50
+ }
51
+ }
52
+ const stripped = stripOverrides(localConfig, overrides, baseConfig);
53
+ const stat = await fs.stat(item.localPath);
54
+ await fs.mkdir(path.dirname(item.repoPath), { recursive: true });
55
+ await writeJsonFile(item.repoPath, stripped, {
56
+ jsonc: item.localPath.endsWith('.jsonc'),
57
+ mode: stat.mode & 0o777,
58
+ });
59
+ }
60
+ async function readRepoConfig(item, repoRoot) {
61
+ if (!item.repoPath.startsWith(repoRoot)) {
62
+ return null;
63
+ }
64
+ if (!(await pathExists(item.repoPath))) {
65
+ return null;
66
+ }
67
+ const content = await fs.readFile(item.repoPath, 'utf8');
68
+ return parseJsonc(content);
69
+ }
70
+ async function applyOverridesToLocalConfig(plan, overrides) {
71
+ const configFiles = plan.items.filter((item) => item.isConfigFile);
72
+ for (const item of configFiles) {
73
+ if (!(await pathExists(item.localPath)))
74
+ continue;
75
+ const content = await fs.readFile(item.localPath, 'utf8');
76
+ const parsed = parseJsonc(content);
77
+ const merged = deepMerge(parsed, overrides);
78
+ const stat = await fs.stat(item.localPath);
79
+ await writeJsonFile(item.localPath, merged, {
80
+ jsonc: item.localPath.endsWith('.jsonc'),
81
+ mode: stat.mode & 0o777,
82
+ });
83
+ }
84
+ }
85
+ async function copyFileWithMode(sourcePath, destinationPath) {
86
+ const stat = await fs.stat(sourcePath);
87
+ await fs.mkdir(path.dirname(destinationPath), { recursive: true });
88
+ await fs.copyFile(sourcePath, destinationPath);
89
+ await fs.chmod(destinationPath, stat.mode & 0o777);
90
+ }
91
+ async function copyDirRecursive(sourcePath, destinationPath) {
92
+ const stat = await fs.stat(sourcePath);
93
+ await fs.mkdir(destinationPath, { recursive: true });
94
+ const entries = await fs.readdir(sourcePath, { withFileTypes: true });
95
+ for (const entry of entries) {
96
+ const entrySource = path.join(sourcePath, entry.name);
97
+ const entryDest = path.join(destinationPath, entry.name);
98
+ if (entry.isDirectory()) {
99
+ await copyDirRecursive(entrySource, entryDest);
100
+ continue;
101
+ }
102
+ if (entry.isFile()) {
103
+ await copyFileWithMode(entrySource, entryDest);
104
+ }
105
+ }
106
+ await fs.chmod(destinationPath, stat.mode & 0o777);
107
+ }
108
+ async function removePath(targetPath) {
109
+ await fs.rm(targetPath, { recursive: true, force: true });
110
+ }
111
+ async function applyExtraSecrets(plan, fromRepo) {
112
+ const allowlist = plan.extraSecrets.allowlist;
113
+ if (allowlist.length === 0)
114
+ return;
115
+ if (!(await pathExists(plan.extraSecrets.manifestPath)))
116
+ return;
117
+ const manifestContent = await fs.readFile(plan.extraSecrets.manifestPath, 'utf8');
118
+ const manifest = parseJsonc(manifestContent);
119
+ for (const entry of manifest.entries) {
120
+ const normalized = normalizePath(entry.sourcePath, plan.homeDir, plan.platform);
121
+ const isAllowed = allowlist.includes(normalized);
122
+ if (!isAllowed)
123
+ continue;
124
+ const repoPath = path.isAbsolute(entry.repoPath)
125
+ ? entry.repoPath
126
+ : path.join(plan.repoRoot, entry.repoPath);
127
+ const localPath = entry.sourcePath;
128
+ if (!(await pathExists(repoPath)))
129
+ continue;
130
+ if (fromRepo) {
131
+ await copyFileWithMode(repoPath, localPath);
132
+ if (entry.mode !== undefined) {
133
+ await fs.chmod(localPath, entry.mode);
134
+ }
135
+ }
136
+ }
137
+ }
138
+ async function writeExtraSecretsManifest(plan) {
139
+ const allowlist = plan.extraSecrets.allowlist;
140
+ const extraDir = path.join(path.dirname(plan.extraSecrets.manifestPath), 'extra');
141
+ if (allowlist.length === 0) {
142
+ await removePath(plan.extraSecrets.manifestPath);
143
+ await removePath(extraDir);
144
+ return;
145
+ }
146
+ await removePath(extraDir);
147
+ const entries = [];
148
+ for (const entry of plan.extraSecrets.entries) {
149
+ const sourcePath = entry.sourcePath;
150
+ if (!(await pathExists(sourcePath))) {
151
+ continue;
152
+ }
153
+ const stat = await fs.stat(sourcePath);
154
+ await copyFileWithMode(sourcePath, entry.repoPath);
155
+ entries.push({
156
+ sourcePath,
157
+ repoPath: path.relative(plan.repoRoot, entry.repoPath),
158
+ mode: stat.mode & 0o777,
159
+ });
160
+ }
161
+ await fs.mkdir(path.dirname(plan.extraSecrets.manifestPath), { recursive: true });
162
+ await writeJsonFile(plan.extraSecrets.manifestPath, { entries }, { jsonc: false });
163
+ }
164
+ function isDeepEqual(left, right) {
165
+ if (left === right)
166
+ return true;
167
+ if (typeof left !== typeof right)
168
+ return false;
169
+ if (!left || !right)
170
+ return false;
171
+ if (Array.isArray(left) && Array.isArray(right)) {
172
+ if (left.length !== right.length)
173
+ return false;
174
+ for (let i = 0; i < left.length; i += 1) {
175
+ if (!isDeepEqual(left[i], right[i]))
176
+ return false;
177
+ }
178
+ return true;
179
+ }
180
+ if (typeof left === 'object' && typeof right === 'object') {
181
+ const leftKeys = Object.keys(left);
182
+ const rightKeys = Object.keys(right);
183
+ if (leftKeys.length !== rightKeys.length)
184
+ return false;
185
+ for (const key of leftKeys) {
186
+ if (!Object.hasOwn(right, key))
187
+ return false;
188
+ if (!isDeepEqual(left[key], right[key])) {
189
+ return false;
190
+ }
191
+ }
192
+ return true;
193
+ }
194
+ return false;
195
+ }
@@ -0,0 +1,74 @@
1
+ import { extractTextFromResponse, resolveSmallModel, unwrapData } from './utils.js';
2
+ export async function generateCommitMessage(ctx, repoDir, fallbackDate = new Date()) {
3
+ const fallback = `Sync OpenCode config (${formatDate(fallbackDate)})`;
4
+ const diffSummary = await getDiffSummary(ctx.$, repoDir);
5
+ if (!diffSummary)
6
+ return fallback;
7
+ const model = await resolveSmallModel(ctx.client);
8
+ if (!model)
9
+ return fallback;
10
+ const prompt = [
11
+ 'Generate a concise single-line git commit message (max 72 chars).',
12
+ 'Focus on OpenCode config sync changes.',
13
+ 'Return only the message, no quotes.',
14
+ '',
15
+ 'Diff summary:',
16
+ diffSummary,
17
+ ].join('\n');
18
+ let sessionId = null;
19
+ try {
20
+ const sessionResult = await ctx.client.session.create({ body: { title: 'opencode-synced' } });
21
+ const session = unwrapData(sessionResult);
22
+ sessionId = session?.id ?? null;
23
+ if (!sessionId)
24
+ return fallback;
25
+ const response = await ctx.client.session.prompt({
26
+ path: { id: sessionId },
27
+ body: {
28
+ model,
29
+ parts: [{ type: 'text', text: prompt }],
30
+ },
31
+ });
32
+ const message = extractTextFromResponse(unwrapData(response) ?? response);
33
+ if (!message)
34
+ return fallback;
35
+ const sanitized = sanitizeMessage(message);
36
+ return sanitized || fallback;
37
+ }
38
+ catch {
39
+ return fallback;
40
+ }
41
+ finally {
42
+ if (sessionId) {
43
+ try {
44
+ await ctx.client.session.delete({ path: { id: sessionId } });
45
+ }
46
+ catch { }
47
+ }
48
+ }
49
+ }
50
+ function sanitizeMessage(message) {
51
+ const firstLine = message.split('\n')[0].trim();
52
+ const trimmed = firstLine.replace(/^["'`]+|["'`]+$/g, '').trim();
53
+ if (!trimmed)
54
+ return '';
55
+ if (trimmed.length <= 72)
56
+ return trimmed;
57
+ return trimmed.slice(0, 72).trim();
58
+ }
59
+ async function getDiffSummary($, repoDir) {
60
+ try {
61
+ const nameStatus = await $ `git -C ${repoDir} diff --name-status`.quiet().text();
62
+ const stats = await $ `git -C ${repoDir} diff --stat`.quiet().text();
63
+ return [nameStatus.trim(), stats.trim()].filter(Boolean).join('\n');
64
+ }
65
+ catch {
66
+ return '';
67
+ }
68
+ }
69
+ function formatDate(date) {
70
+ const year = String(date.getFullYear());
71
+ const month = String(date.getMonth() + 1).padStart(2, '0');
72
+ const day = String(date.getDate()).padStart(2, '0');
73
+ return `${year}-${month}-${day}`;
74
+ }
@@ -1,4 +1,4 @@
1
- import type { SyncLocations } from './paths.ts';
1
+ import type { SyncLocations } from './paths.js';
2
2
  export interface SyncRepoConfig {
3
3
  url?: string;
4
4
  owner?: string;
@@ -0,0 +1,176 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ export async function pathExists(filePath) {
4
+ try {
5
+ await fs.access(filePath);
6
+ return true;
7
+ }
8
+ catch {
9
+ return false;
10
+ }
11
+ }
12
+ export function normalizeSyncConfig(config) {
13
+ return {
14
+ includeSecrets: Boolean(config.includeSecrets),
15
+ includeSessions: Boolean(config.includeSessions),
16
+ includePromptStash: Boolean(config.includePromptStash),
17
+ extraSecretPaths: Array.isArray(config.extraSecretPaths) ? config.extraSecretPaths : [],
18
+ localRepoPath: config.localRepoPath,
19
+ repo: config.repo,
20
+ };
21
+ }
22
+ export async function loadSyncConfig(locations) {
23
+ if (!(await pathExists(locations.syncConfigPath))) {
24
+ return null;
25
+ }
26
+ const content = await fs.readFile(locations.syncConfigPath, 'utf8');
27
+ const parsed = parseJsonc(content);
28
+ return normalizeSyncConfig(parsed);
29
+ }
30
+ export async function writeSyncConfig(locations, config) {
31
+ await fs.mkdir(path.dirname(locations.syncConfigPath), { recursive: true });
32
+ const payload = normalizeSyncConfig(config);
33
+ await writeJsonFile(locations.syncConfigPath, payload, { jsonc: true });
34
+ }
35
+ export async function loadOverrides(locations) {
36
+ if (!(await pathExists(locations.overridesPath))) {
37
+ return null;
38
+ }
39
+ const content = await fs.readFile(locations.overridesPath, 'utf8');
40
+ const parsed = parseJsonc(content);
41
+ return parsed;
42
+ }
43
+ export async function loadState(locations) {
44
+ if (!(await pathExists(locations.statePath))) {
45
+ return {};
46
+ }
47
+ const content = await fs.readFile(locations.statePath, 'utf8');
48
+ return parseJsonc(content);
49
+ }
50
+ export async function writeState(locations, state) {
51
+ await fs.mkdir(path.dirname(locations.statePath), { recursive: true });
52
+ await writeJsonFile(locations.statePath, state, { jsonc: false });
53
+ }
54
+ export function applyOverridesToRuntimeConfig(config, overrides) {
55
+ const merged = deepMerge(config, overrides);
56
+ for (const key of Object.keys(config)) {
57
+ delete config[key];
58
+ }
59
+ Object.assign(config, merged);
60
+ }
61
+ export function deepMerge(base, override) {
62
+ if (!isPlainObject(base) || !isPlainObject(override)) {
63
+ return (override === undefined ? base : override);
64
+ }
65
+ const result = { ...base };
66
+ for (const [key, value] of Object.entries(override)) {
67
+ if (isPlainObject(value) && isPlainObject(result[key])) {
68
+ result[key] = deepMerge(result[key], value);
69
+ }
70
+ else {
71
+ result[key] = value;
72
+ }
73
+ }
74
+ return result;
75
+ }
76
+ export function stripOverrides(localConfig, overrides, baseConfig) {
77
+ if (!isPlainObject(localConfig) || !isPlainObject(overrides)) {
78
+ return localConfig;
79
+ }
80
+ const result = { ...localConfig };
81
+ for (const [key, overrideValue] of Object.entries(overrides)) {
82
+ const baseValue = baseConfig ? baseConfig[key] : undefined;
83
+ const currentValue = result[key];
84
+ if (isPlainObject(overrideValue) && isPlainObject(currentValue)) {
85
+ const stripped = stripOverrides(currentValue, overrideValue, isPlainObject(baseValue) ? baseValue : null);
86
+ if (Object.keys(stripped).length === 0 && !baseValue) {
87
+ delete result[key];
88
+ }
89
+ else {
90
+ result[key] = stripped;
91
+ }
92
+ continue;
93
+ }
94
+ if (baseValue === undefined) {
95
+ delete result[key];
96
+ }
97
+ else {
98
+ result[key] = baseValue;
99
+ }
100
+ }
101
+ return result;
102
+ }
103
+ export function parseJsonc(content) {
104
+ const stripped = stripJsonComments(content);
105
+ return JSON.parse(stripped);
106
+ }
107
+ export async function writeJsonFile(filePath, data, options = { jsonc: false }) {
108
+ const json = JSON.stringify(data, null, 2);
109
+ const content = options.jsonc ? `// Generated by opencode-synced\n${json}\n` : `${json}\n`;
110
+ await fs.writeFile(filePath, content, 'utf8');
111
+ if (options.mode !== undefined) {
112
+ await fs.chmod(filePath, options.mode);
113
+ }
114
+ }
115
+ function isPlainObject(value) {
116
+ if (!value || typeof value !== 'object')
117
+ return false;
118
+ return Object.getPrototypeOf(value) === Object.prototype;
119
+ }
120
+ function stripJsonComments(input) {
121
+ let output = '';
122
+ let inString = false;
123
+ let inSingleLine = false;
124
+ let inMultiLine = false;
125
+ let escapeNext = false;
126
+ for (let i = 0; i < input.length; i += 1) {
127
+ const current = input[i];
128
+ const next = input[i + 1];
129
+ if (inSingleLine) {
130
+ if (current === '\n') {
131
+ inSingleLine = false;
132
+ output += current;
133
+ }
134
+ continue;
135
+ }
136
+ if (inMultiLine) {
137
+ if (current === '*' && next === '/') {
138
+ inMultiLine = false;
139
+ i += 1;
140
+ }
141
+ continue;
142
+ }
143
+ if (inString) {
144
+ output += current;
145
+ if (escapeNext) {
146
+ escapeNext = false;
147
+ continue;
148
+ }
149
+ if (current === '\\') {
150
+ escapeNext = true;
151
+ continue;
152
+ }
153
+ if (current === '"') {
154
+ inString = false;
155
+ }
156
+ continue;
157
+ }
158
+ if (current === '"' && !inString) {
159
+ inString = true;
160
+ output += current;
161
+ continue;
162
+ }
163
+ if (current === '/' && next === '/') {
164
+ inSingleLine = true;
165
+ i += 1;
166
+ continue;
167
+ }
168
+ if (current === '/' && next === '*') {
169
+ inMultiLine = true;
170
+ i += 1;
171
+ continue;
172
+ }
173
+ output += current;
174
+ }
175
+ return output;
176
+ }
@@ -0,0 +1,32 @@
1
+ export class SyncError extends Error {
2
+ code;
3
+ constructor(code, message) {
4
+ super(message);
5
+ this.code = code;
6
+ }
7
+ }
8
+ export class SyncConfigMissingError extends SyncError {
9
+ constructor(message) {
10
+ super('sync_config_missing', message);
11
+ }
12
+ }
13
+ export class RepoDivergedError extends SyncError {
14
+ constructor(message) {
15
+ super('repo_diverged', message);
16
+ }
17
+ }
18
+ export class RepoPrivateRequiredError extends SyncError {
19
+ constructor(message) {
20
+ super('repo_private_required', message);
21
+ }
22
+ }
23
+ export class RepoVisibilityError extends SyncError {
24
+ constructor(message) {
25
+ super('repo_visibility_error', message);
26
+ }
27
+ }
28
+ export class SyncCommandError extends SyncError {
29
+ constructor(message) {
30
+ super('sync_command_error', message);
31
+ }
32
+ }
@@ -1,4 +1,4 @@
1
- import type { SyncConfig } from './config.ts';
1
+ import type { SyncConfig } from './config.js';
2
2
  export interface XdgPaths {
3
3
  homeDir: string;
4
4
  configDir: string;
@@ -0,0 +1,178 @@
1
+ import crypto from 'node:crypto';
2
+ import path from 'node:path';
3
+ const DEFAULT_CONFIG_NAME = 'opencode.json';
4
+ const DEFAULT_CONFIGC_NAME = 'opencode.jsonc';
5
+ const DEFAULT_AGENTS_NAME = 'AGENTS.md';
6
+ const DEFAULT_SYNC_CONFIG_NAME = 'opencode-synced.jsonc';
7
+ const DEFAULT_OVERRIDES_NAME = 'opencode-synced.overrides.jsonc';
8
+ const DEFAULT_STATE_NAME = 'sync-state.json';
9
+ const CONFIG_DIRS = ['agent', 'command', 'mode', 'tool', 'themes', 'plugin'];
10
+ const SESSION_DIRS = ['storage/session', 'storage/message', 'storage/part', 'storage/session_diff'];
11
+ const PROMPT_STASH_FILES = ['prompt-stash.jsonl', 'prompt-history.jsonl'];
12
+ export function resolveHomeDir(env = process.env, platform = process.platform) {
13
+ if (platform === 'win32') {
14
+ return env.USERPROFILE ?? env.HOMEDRIVE ?? env.HOME ?? '';
15
+ }
16
+ return env.HOME ?? '';
17
+ }
18
+ export function resolveXdgPaths(env = process.env, platform = process.platform) {
19
+ const homeDir = resolveHomeDir(env, platform);
20
+ if (!homeDir) {
21
+ return {
22
+ homeDir: '',
23
+ configDir: '',
24
+ dataDir: '',
25
+ stateDir: '',
26
+ };
27
+ }
28
+ if (platform === 'win32') {
29
+ const configDir = env.APPDATA ?? path.join(homeDir, 'AppData', 'Roaming');
30
+ const dataDir = env.LOCALAPPDATA ?? path.join(homeDir, 'AppData', 'Local');
31
+ // Windows doesn't have XDG_STATE_HOME equivalent, use LOCALAPPDATA
32
+ const stateDir = env.LOCALAPPDATA ?? path.join(homeDir, 'AppData', 'Local');
33
+ return { homeDir, configDir, dataDir, stateDir };
34
+ }
35
+ const configDir = env.XDG_CONFIG_HOME ?? path.join(homeDir, '.config');
36
+ const dataDir = env.XDG_DATA_HOME ?? path.join(homeDir, '.local', 'share');
37
+ const stateDir = env.XDG_STATE_HOME ?? path.join(homeDir, '.local', 'state');
38
+ return { homeDir, configDir, dataDir, stateDir };
39
+ }
40
+ export function resolveSyncLocations(env = process.env, platform = process.platform) {
41
+ const xdg = resolveXdgPaths(env, platform);
42
+ const customConfigDir = env.OPENCODE_CONFIG_DIR;
43
+ const configRoot = customConfigDir
44
+ ? path.resolve(expandHome(customConfigDir, xdg.homeDir))
45
+ : path.join(xdg.configDir, 'opencode');
46
+ const dataRoot = path.join(xdg.dataDir, 'opencode');
47
+ return {
48
+ xdg,
49
+ configRoot,
50
+ syncConfigPath: path.join(configRoot, DEFAULT_SYNC_CONFIG_NAME),
51
+ overridesPath: path.join(configRoot, DEFAULT_OVERRIDES_NAME),
52
+ statePath: path.join(dataRoot, DEFAULT_STATE_NAME),
53
+ defaultRepoDir: path.join(dataRoot, 'opencode-synced', 'repo'),
54
+ };
55
+ }
56
+ export function expandHome(inputPath, homeDir) {
57
+ if (!inputPath)
58
+ return inputPath;
59
+ if (!homeDir)
60
+ return inputPath;
61
+ if (inputPath === '~')
62
+ return homeDir;
63
+ if (inputPath.startsWith('~/'))
64
+ return path.join(homeDir, inputPath.slice(2));
65
+ return inputPath;
66
+ }
67
+ export function normalizePath(inputPath, homeDir, platform = process.platform) {
68
+ const expanded = expandHome(inputPath, homeDir);
69
+ const resolved = path.resolve(expanded);
70
+ if (platform === 'win32') {
71
+ return resolved.toLowerCase();
72
+ }
73
+ return resolved;
74
+ }
75
+ export function isSamePath(left, right, homeDir, platform = process.platform) {
76
+ return normalizePath(left, homeDir, platform) === normalizePath(right, homeDir, platform);
77
+ }
78
+ export function encodeSecretPath(inputPath) {
79
+ const normalized = inputPath.replace(/\\/g, '/');
80
+ const safeBase = normalized.replace(/[^a-zA-Z0-9._-]+/g, '_').replace(/^_+/, '');
81
+ const hash = crypto.createHash('sha1').update(normalized).digest('hex').slice(0, 8);
82
+ const base = safeBase ? safeBase.slice(-80) : 'secret';
83
+ return `${base}-${hash}`;
84
+ }
85
+ export function resolveRepoRoot(config, locations) {
86
+ if (config?.localRepoPath) {
87
+ return expandHome(config.localRepoPath, locations.xdg.homeDir);
88
+ }
89
+ return locations.defaultRepoDir;
90
+ }
91
+ export function buildSyncPlan(config, locations, repoRoot, platform = process.platform) {
92
+ const configRoot = locations.configRoot;
93
+ const dataRoot = path.join(locations.xdg.dataDir, 'opencode');
94
+ const repoConfigRoot = path.join(repoRoot, 'config');
95
+ const repoDataRoot = path.join(repoRoot, 'data');
96
+ const repoSecretsRoot = path.join(repoRoot, 'secrets');
97
+ const repoExtraDir = path.join(repoSecretsRoot, 'extra');
98
+ const manifestPath = path.join(repoSecretsRoot, 'extra-manifest.json');
99
+ const items = [];
100
+ const addFile = (name, isSecret, isConfigFile) => {
101
+ items.push({
102
+ localPath: path.join(configRoot, name),
103
+ repoPath: path.join(repoConfigRoot, name),
104
+ type: 'file',
105
+ isSecret,
106
+ isConfigFile,
107
+ });
108
+ };
109
+ addFile(DEFAULT_CONFIG_NAME, false, true);
110
+ addFile(DEFAULT_CONFIGC_NAME, false, true);
111
+ addFile(DEFAULT_AGENTS_NAME, false, false);
112
+ for (const dirName of CONFIG_DIRS) {
113
+ items.push({
114
+ localPath: path.join(configRoot, dirName),
115
+ repoPath: path.join(repoConfigRoot, dirName),
116
+ type: 'dir',
117
+ isSecret: false,
118
+ isConfigFile: false,
119
+ });
120
+ }
121
+ if (config.includeSecrets) {
122
+ items.push({
123
+ localPath: path.join(dataRoot, 'auth.json'),
124
+ repoPath: path.join(repoDataRoot, 'auth.json'),
125
+ type: 'file',
126
+ isSecret: true,
127
+ isConfigFile: false,
128
+ }, {
129
+ localPath: path.join(dataRoot, 'mcp-auth.json'),
130
+ repoPath: path.join(repoDataRoot, 'mcp-auth.json'),
131
+ type: 'file',
132
+ isSecret: true,
133
+ isConfigFile: false,
134
+ });
135
+ if (config.includeSessions) {
136
+ for (const dirName of SESSION_DIRS) {
137
+ items.push({
138
+ localPath: path.join(dataRoot, dirName),
139
+ repoPath: path.join(repoDataRoot, dirName),
140
+ type: 'dir',
141
+ isSecret: true,
142
+ isConfigFile: false,
143
+ });
144
+ }
145
+ }
146
+ if (config.includePromptStash) {
147
+ const stateRoot = path.join(locations.xdg.stateDir, 'opencode');
148
+ const repoStateRoot = path.join(repoRoot, 'state');
149
+ for (const fileName of PROMPT_STASH_FILES) {
150
+ items.push({
151
+ localPath: path.join(stateRoot, fileName),
152
+ repoPath: path.join(repoStateRoot, fileName),
153
+ type: 'file',
154
+ isSecret: true,
155
+ isConfigFile: false,
156
+ });
157
+ }
158
+ }
159
+ }
160
+ const allowlist = config.includeSecrets
161
+ ? (config.extraSecretPaths ?? []).map((entry) => normalizePath(entry, locations.xdg.homeDir, platform))
162
+ : [];
163
+ const entries = allowlist.map((sourcePath) => ({
164
+ sourcePath,
165
+ repoPath: path.join(repoExtraDir, encodeSecretPath(sourcePath)),
166
+ }));
167
+ return {
168
+ items,
169
+ extraSecrets: {
170
+ allowlist,
171
+ manifestPath,
172
+ entries,
173
+ },
174
+ repoRoot,
175
+ homeDir: locations.xdg.homeDir,
176
+ platform,
177
+ };
178
+ }
@@ -1,5 +1,5 @@
1
1
  import type { PluginInput } from '@opencode-ai/plugin';
2
- import type { SyncConfig } from './config.ts';
2
+ import type { SyncConfig } from './config.js';
3
3
  export interface RepoStatus {
4
4
  branch: string;
5
5
  changes: string[];