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.
@@ -0,0 +1,248 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { pathExists } from './config.js';
4
+ import { RepoDivergedError, RepoPrivateRequiredError, RepoVisibilityError, SyncCommandError, } from './errors.js';
5
+ export async function isRepoCloned(repoDir) {
6
+ const gitDir = path.join(repoDir, '.git');
7
+ return pathExists(gitDir);
8
+ }
9
+ export function resolveRepoIdentifier(config) {
10
+ const repo = config.repo;
11
+ if (!repo) {
12
+ throw new SyncCommandError('Missing repo configuration.');
13
+ }
14
+ if (repo.url)
15
+ return repo.url;
16
+ if (repo.owner && repo.name)
17
+ return `${repo.owner}/${repo.name}`;
18
+ throw new SyncCommandError('Repo configuration must include url or owner/name.');
19
+ }
20
+ export function resolveRepoBranch(config, fallback = 'main') {
21
+ const branch = config.repo?.branch;
22
+ if (branch)
23
+ return branch;
24
+ return fallback;
25
+ }
26
+ export async function ensureRepoCloned($, config, repoDir) {
27
+ if (await isRepoCloned(repoDir)) {
28
+ return;
29
+ }
30
+ await fs.mkdir(path.dirname(repoDir), { recursive: true });
31
+ const repoIdentifier = resolveRepoIdentifier(config);
32
+ try {
33
+ await $ `gh repo clone ${repoIdentifier} ${repoDir}`.quiet();
34
+ }
35
+ catch (error) {
36
+ throw new SyncCommandError(`Failed to clone repo: ${formatError(error)}`);
37
+ }
38
+ }
39
+ export async function ensureRepoPrivate($, config) {
40
+ const repoIdentifier = resolveRepoIdentifier(config);
41
+ let output;
42
+ try {
43
+ output = await $ `gh repo view ${repoIdentifier} --json isPrivate`.quiet().text();
44
+ }
45
+ catch (error) {
46
+ throw new RepoVisibilityError(`Unable to verify repo visibility: ${formatError(error)}`);
47
+ }
48
+ let isPrivate = false;
49
+ try {
50
+ isPrivate = parseRepoVisibility(output);
51
+ }
52
+ catch (error) {
53
+ throw new RepoVisibilityError(`Unable to verify repo visibility: ${formatError(error)}`);
54
+ }
55
+ if (!isPrivate) {
56
+ throw new RepoPrivateRequiredError('Secrets sync requires a private GitHub repo.');
57
+ }
58
+ }
59
+ export function parseRepoVisibility(output) {
60
+ const parsed = JSON.parse(output);
61
+ if (typeof parsed.isPrivate !== 'boolean') {
62
+ throw new Error('Invalid repo visibility response.');
63
+ }
64
+ return parsed.isPrivate;
65
+ }
66
+ export async function fetchAndFastForward($, repoDir, branch) {
67
+ try {
68
+ await $ `git -C ${repoDir} fetch --prune`.quiet();
69
+ }
70
+ catch (error) {
71
+ throw new SyncCommandError(`Failed to fetch repo: ${formatError(error)}`);
72
+ }
73
+ await checkoutBranch($, repoDir, branch);
74
+ const remoteRef = `origin/${branch}`;
75
+ const remoteExists = await hasRemoteRef($, repoDir, branch);
76
+ if (!remoteExists) {
77
+ return { updated: false, branch };
78
+ }
79
+ const { ahead, behind } = await getAheadBehind($, repoDir, remoteRef);
80
+ if (ahead > 0 && behind > 0) {
81
+ throw new RepoDivergedError(`Local sync repo has diverged. Resolve with: cd ${repoDir} && git status && git pull --rebase`);
82
+ }
83
+ if (behind > 0) {
84
+ try {
85
+ await $ `git -C ${repoDir} merge --ff-only ${remoteRef}`.quiet();
86
+ return { updated: true, branch };
87
+ }
88
+ catch (error) {
89
+ throw new SyncCommandError(`Failed to fast-forward: ${formatError(error)}`);
90
+ }
91
+ }
92
+ return { updated: false, branch };
93
+ }
94
+ export async function getRepoStatus($, repoDir) {
95
+ const branch = await getCurrentBranch($, repoDir);
96
+ const changes = await getStatusLines($, repoDir);
97
+ return { branch, changes };
98
+ }
99
+ export async function hasLocalChanges($, repoDir) {
100
+ const lines = await getStatusLines($, repoDir);
101
+ return lines.length > 0;
102
+ }
103
+ export async function commitAll($, repoDir, message) {
104
+ try {
105
+ await $ `git -C ${repoDir} add -A`.quiet();
106
+ await $ `git -C ${repoDir} commit -m ${message}`.quiet();
107
+ }
108
+ catch (error) {
109
+ throw new SyncCommandError(`Failed to commit changes: ${formatError(error)}`);
110
+ }
111
+ }
112
+ export async function pushBranch($, repoDir, branch) {
113
+ try {
114
+ await $ `git -C ${repoDir} push -u origin ${branch}`.quiet();
115
+ }
116
+ catch (error) {
117
+ throw new SyncCommandError(`Failed to push changes: ${formatError(error)}`);
118
+ }
119
+ }
120
+ async function getCurrentBranch($, repoDir) {
121
+ try {
122
+ const output = await $ `git -C ${repoDir} rev-parse --abbrev-ref HEAD`.quiet().text();
123
+ const branch = output.trim();
124
+ if (!branch || branch === 'HEAD')
125
+ return 'main';
126
+ return branch;
127
+ }
128
+ catch {
129
+ return 'main';
130
+ }
131
+ }
132
+ async function checkoutBranch($, repoDir, branch) {
133
+ const exists = await hasLocalBranch($, repoDir, branch);
134
+ try {
135
+ if (exists) {
136
+ await $ `git -C ${repoDir} checkout ${branch}`.quiet();
137
+ return;
138
+ }
139
+ await $ `git -C ${repoDir} checkout -b ${branch}`.quiet();
140
+ }
141
+ catch (error) {
142
+ throw new SyncCommandError(`Failed to checkout branch: ${formatError(error)}`);
143
+ }
144
+ }
145
+ async function hasLocalBranch($, repoDir, branch) {
146
+ try {
147
+ await $ `git -C ${repoDir} show-ref --verify refs/heads/${branch}`.quiet();
148
+ return true;
149
+ }
150
+ catch {
151
+ return false;
152
+ }
153
+ }
154
+ async function hasRemoteRef($, repoDir, branch) {
155
+ try {
156
+ await $ `git -C ${repoDir} show-ref --verify refs/remotes/origin/${branch}`.quiet();
157
+ return true;
158
+ }
159
+ catch {
160
+ return false;
161
+ }
162
+ }
163
+ async function getAheadBehind($, repoDir, remoteRef) {
164
+ try {
165
+ const output = await $ `git -C ${repoDir} rev-list --left-right --count HEAD...${remoteRef}`
166
+ .quiet()
167
+ .text();
168
+ const [aheadRaw, behindRaw] = output.trim().split(/\s+/);
169
+ const ahead = Number(aheadRaw ?? 0);
170
+ const behind = Number(behindRaw ?? 0);
171
+ return { ahead, behind };
172
+ }
173
+ catch {
174
+ return { ahead: 0, behind: 0 };
175
+ }
176
+ }
177
+ async function getStatusLines($, repoDir) {
178
+ try {
179
+ const output = await $ `git -C ${repoDir} status --porcelain`.quiet().text();
180
+ return output
181
+ .split('\n')
182
+ .map((line) => line.trim())
183
+ .filter(Boolean);
184
+ }
185
+ catch {
186
+ return [];
187
+ }
188
+ }
189
+ function formatError(error) {
190
+ if (error instanceof Error)
191
+ return error.message;
192
+ return String(error);
193
+ }
194
+ export async function repoExists($, repoIdentifier) {
195
+ try {
196
+ await $ `gh repo view ${repoIdentifier} --json name`.quiet();
197
+ return true;
198
+ }
199
+ catch {
200
+ return false;
201
+ }
202
+ }
203
+ export async function getAuthenticatedUser($) {
204
+ try {
205
+ const output = await $ `gh api user --jq .login`.quiet().text();
206
+ return output.trim();
207
+ }
208
+ catch (error) {
209
+ throw new SyncCommandError(`Failed to detect GitHub user. Ensure gh is authenticated: ${formatError(error)}`);
210
+ }
211
+ }
212
+ const LIKELY_SYNC_REPO_NAMES = [
213
+ 'my-opencode-config',
214
+ 'opencode-config',
215
+ 'opencode-sync',
216
+ 'opencode-synced',
217
+ 'dotfiles-opencode',
218
+ ];
219
+ export async function findSyncRepo($, repoName) {
220
+ const owner = await getAuthenticatedUser($);
221
+ // If user provided a specific name, check that first
222
+ if (repoName) {
223
+ const exists = await repoExists($, `${owner}/${repoName}`);
224
+ if (exists) {
225
+ const isPrivate = await checkRepoPrivate($, `${owner}/${repoName}`);
226
+ return { owner, name: repoName, isPrivate };
227
+ }
228
+ return null;
229
+ }
230
+ // Search through likely repo names
231
+ for (const name of LIKELY_SYNC_REPO_NAMES) {
232
+ const exists = await repoExists($, `${owner}/${name}`);
233
+ if (exists) {
234
+ const isPrivate = await checkRepoPrivate($, `${owner}/${name}`);
235
+ return { owner, name, isPrivate };
236
+ }
237
+ }
238
+ return null;
239
+ }
240
+ async function checkRepoPrivate($, repoIdentifier) {
241
+ try {
242
+ const output = await $ `gh repo view ${repoIdentifier} --json isPrivate`.quiet().text();
243
+ return parseRepoVisibility(output);
244
+ }
245
+ catch {
246
+ return false;
247
+ }
248
+ }
@@ -0,0 +1,453 @@
1
+ import { syncLocalToRepo, syncRepoToLocal } from './apply.js';
2
+ import { generateCommitMessage } from './commit.js';
3
+ import { loadOverrides, loadState, loadSyncConfig, normalizeSyncConfig, writeState, writeSyncConfig, } from './config.js';
4
+ import { SyncCommandError, SyncConfigMissingError } from './errors.js';
5
+ import { buildSyncPlan, resolveRepoRoot, resolveSyncLocations } from './paths.js';
6
+ import { commitAll, ensureRepoCloned, ensureRepoPrivate, fetchAndFastForward, findSyncRepo, getAuthenticatedUser, getRepoStatus, hasLocalChanges, isRepoCloned, pushBranch, repoExists, resolveRepoBranch, resolveRepoIdentifier, } from './repo.js';
7
+ import { createLogger, extractTextFromResponse, resolveSmallModel, showToast, unwrapData, } from './utils.js';
8
+ export function createSyncService(ctx) {
9
+ const locations = resolveSyncLocations();
10
+ const log = createLogger(ctx.client);
11
+ return {
12
+ startupSync: async () => {
13
+ const config = await loadSyncConfig(locations);
14
+ if (!config) {
15
+ await showToast(ctx.client, 'Configure opencode-synced with /sync-init.', 'info');
16
+ return;
17
+ }
18
+ try {
19
+ await runStartup(ctx, locations, config, log);
20
+ }
21
+ catch (error) {
22
+ log.error('Startup sync failed', { error: formatError(error) });
23
+ await showToast(ctx.client, formatError(error), 'error');
24
+ }
25
+ },
26
+ status: async () => {
27
+ const config = await loadSyncConfig(locations);
28
+ if (!config) {
29
+ return 'opencode-synced is not configured. Run /sync-init to set it up.';
30
+ }
31
+ const repoRoot = resolveRepoRoot(config, locations);
32
+ const state = await loadState(locations);
33
+ let repoStatus = [];
34
+ let branch = resolveRepoBranch(config);
35
+ const cloned = await isRepoCloned(repoRoot);
36
+ if (!cloned) {
37
+ repoStatus = ['Repo not cloned'];
38
+ }
39
+ else {
40
+ try {
41
+ const status = await getRepoStatus(ctx.$, repoRoot);
42
+ repoStatus = status.changes;
43
+ branch = status.branch;
44
+ }
45
+ catch {
46
+ repoStatus = ['Repo status unavailable'];
47
+ }
48
+ }
49
+ const repoIdentifier = resolveRepoIdentifier(config);
50
+ const includeSecrets = config.includeSecrets ? 'enabled' : 'disabled';
51
+ const includeSessions = config.includeSessions ? 'enabled' : 'disabled';
52
+ const includePromptStash = config.includePromptStash ? 'enabled' : 'disabled';
53
+ const lastPull = state.lastPull ?? 'never';
54
+ const lastPush = state.lastPush ?? 'never';
55
+ let changesLabel = 'clean';
56
+ if (!cloned) {
57
+ changesLabel = 'not cloned';
58
+ }
59
+ else if (repoStatus.length > 0) {
60
+ if (repoStatus[0] === 'Repo status unavailable') {
61
+ changesLabel = 'unknown';
62
+ }
63
+ else {
64
+ changesLabel = `${repoStatus.length} pending`;
65
+ }
66
+ }
67
+ const statusLines = [
68
+ `Repo: ${repoIdentifier}`,
69
+ `Branch: ${branch}`,
70
+ `Secrets: ${includeSecrets}`,
71
+ `Sessions: ${includeSessions}`,
72
+ `Prompt stash: ${includePromptStash}`,
73
+ `Last pull: ${lastPull}`,
74
+ `Last push: ${lastPush}`,
75
+ `Working tree: ${changesLabel}`,
76
+ ];
77
+ return statusLines.join('\n');
78
+ },
79
+ init: async (options) => {
80
+ const config = await buildConfigFromInit(ctx.$, options);
81
+ const repoIdentifier = resolveRepoIdentifier(config);
82
+ const isPrivate = options.private ?? true;
83
+ const exists = await repoExists(ctx.$, repoIdentifier);
84
+ let created = false;
85
+ if (!exists) {
86
+ await createRepo(ctx.$, config, isPrivate);
87
+ created = true;
88
+ }
89
+ await writeSyncConfig(locations, config);
90
+ const repoRoot = resolveRepoRoot(config, locations);
91
+ await ensureRepoCloned(ctx.$, config, repoRoot);
92
+ await ensureSecretsPolicy(ctx, config);
93
+ if (created) {
94
+ const overrides = await loadOverrides(locations);
95
+ const plan = buildSyncPlan(config, locations, repoRoot);
96
+ await syncLocalToRepo(plan, overrides);
97
+ const dirty = await hasLocalChanges(ctx.$, repoRoot);
98
+ if (dirty) {
99
+ const branch = resolveRepoBranch(config);
100
+ await commitAll(ctx.$, repoRoot, 'Initial sync from opencode-synced');
101
+ await pushBranch(ctx.$, repoRoot, branch);
102
+ await writeState(locations, { lastPush: new Date().toISOString() });
103
+ }
104
+ }
105
+ const lines = [
106
+ 'opencode-synced configured.',
107
+ `Repo: ${repoIdentifier}${created ? ' (created)' : ''}`,
108
+ `Branch: ${resolveRepoBranch(config)}`,
109
+ `Local repo: ${repoRoot}`,
110
+ ];
111
+ return lines.join('\n');
112
+ },
113
+ link: async (options) => {
114
+ const found = await findSyncRepo(ctx.$, options.repo);
115
+ if (!found) {
116
+ const searchedFor = options.repo
117
+ ? `"${options.repo}"`
118
+ : 'common sync repo names (my-opencode-config, opencode-config, etc.)';
119
+ const lines = [
120
+ `Could not find an existing sync repo. Searched for: ${searchedFor}`,
121
+ '',
122
+ 'To link to an existing repo, run:',
123
+ ' /sync-link <repo-name>',
124
+ '',
125
+ 'To create a new sync repo, run:',
126
+ ' /sync-init',
127
+ ];
128
+ return lines.join('\n');
129
+ }
130
+ const config = normalizeSyncConfig({
131
+ repo: { owner: found.owner, name: found.name },
132
+ includeSecrets: false,
133
+ includeSessions: false,
134
+ includePromptStash: false,
135
+ extraSecretPaths: [],
136
+ });
137
+ await writeSyncConfig(locations, config);
138
+ const repoRoot = resolveRepoRoot(config, locations);
139
+ await ensureRepoCloned(ctx.$, config, repoRoot);
140
+ const branch = await resolveBranch(ctx, config, repoRoot);
141
+ await fetchAndFastForward(ctx.$, repoRoot, branch);
142
+ const overrides = await loadOverrides(locations);
143
+ const plan = buildSyncPlan(config, locations, repoRoot);
144
+ await syncRepoToLocal(plan, overrides);
145
+ await writeState(locations, {
146
+ lastPull: new Date().toISOString(),
147
+ lastRemoteUpdate: new Date().toISOString(),
148
+ });
149
+ const lines = [
150
+ `Linked to existing sync repo: ${found.owner}/${found.name}`,
151
+ '',
152
+ 'Your local OpenCode config has been OVERWRITTEN with the synced config.',
153
+ 'Your local overrides file was preserved and applied on top.',
154
+ '',
155
+ 'Restart OpenCode to apply the new settings.',
156
+ '',
157
+ found.isPrivate
158
+ ? 'To enable secrets sync, run: /sync-enable-secrets'
159
+ : 'Note: Repo is public. Secrets sync is disabled.',
160
+ ];
161
+ await showToast(ctx.client, 'Config synced. Restart OpenCode to apply.', 'info');
162
+ return lines.join('\n');
163
+ },
164
+ pull: async () => {
165
+ const config = await getConfigOrThrow(locations);
166
+ const repoRoot = resolveRepoRoot(config, locations);
167
+ await ensureRepoCloned(ctx.$, config, repoRoot);
168
+ await ensureSecretsPolicy(ctx, config);
169
+ const branch = await resolveBranch(ctx, config, repoRoot);
170
+ const dirty = await hasLocalChanges(ctx.$, repoRoot);
171
+ if (dirty) {
172
+ throw new SyncCommandError(`Local sync repo has uncommitted changes. Resolve in ${repoRoot} before pulling.`);
173
+ }
174
+ const update = await fetchAndFastForward(ctx.$, repoRoot, branch);
175
+ if (!update.updated) {
176
+ return 'Already up to date.';
177
+ }
178
+ const overrides = await loadOverrides(locations);
179
+ const plan = buildSyncPlan(config, locations, repoRoot);
180
+ await syncRepoToLocal(plan, overrides);
181
+ await writeState(locations, {
182
+ lastPull: new Date().toISOString(),
183
+ lastRemoteUpdate: new Date().toISOString(),
184
+ });
185
+ await showToast(ctx.client, 'Config updated. Restart OpenCode to apply.', 'info');
186
+ return 'Remote config applied. Restart OpenCode to use new settings.';
187
+ },
188
+ push: async () => {
189
+ const config = await getConfigOrThrow(locations);
190
+ const repoRoot = resolveRepoRoot(config, locations);
191
+ await ensureRepoCloned(ctx.$, config, repoRoot);
192
+ await ensureSecretsPolicy(ctx, config);
193
+ const branch = await resolveBranch(ctx, config, repoRoot);
194
+ const preDirty = await hasLocalChanges(ctx.$, repoRoot);
195
+ if (preDirty) {
196
+ throw new SyncCommandError(`Local sync repo has uncommitted changes. Resolve in ${repoRoot} before pushing.`);
197
+ }
198
+ const overrides = await loadOverrides(locations);
199
+ const plan = buildSyncPlan(config, locations, repoRoot);
200
+ await syncLocalToRepo(plan, overrides);
201
+ const dirty = await hasLocalChanges(ctx.$, repoRoot);
202
+ if (!dirty) {
203
+ return 'No local changes to push.';
204
+ }
205
+ const message = await generateCommitMessage({ client: ctx.client, $: ctx.$ }, repoRoot);
206
+ await commitAll(ctx.$, repoRoot, message);
207
+ await pushBranch(ctx.$, repoRoot, branch);
208
+ await writeState(locations, {
209
+ lastPush: new Date().toISOString(),
210
+ });
211
+ return `Pushed changes: ${message}`;
212
+ },
213
+ enableSecrets: async (extraSecretPaths) => {
214
+ const config = await getConfigOrThrow(locations);
215
+ config.includeSecrets = true;
216
+ if (extraSecretPaths) {
217
+ config.extraSecretPaths = extraSecretPaths;
218
+ }
219
+ await ensureRepoPrivate(ctx.$, config);
220
+ await writeSyncConfig(locations, config);
221
+ return 'Secrets sync enabled for this repo.';
222
+ },
223
+ resolve: async () => {
224
+ const config = await getConfigOrThrow(locations);
225
+ const repoRoot = resolveRepoRoot(config, locations);
226
+ await ensureRepoCloned(ctx.$, config, repoRoot);
227
+ const dirty = await hasLocalChanges(ctx.$, repoRoot);
228
+ if (!dirty) {
229
+ return 'No uncommitted changes to resolve.';
230
+ }
231
+ const status = await getRepoStatus(ctx.$, repoRoot);
232
+ const decision = await analyzeAndDecideResolution({ client: ctx.client, $: ctx.$ }, repoRoot, status.changes);
233
+ if (decision.action === 'commit') {
234
+ const message = decision.message ?? 'Sync: Auto-resolved uncommitted changes';
235
+ await commitAll(ctx.$, repoRoot, message);
236
+ return `Resolved by committing changes: ${message}`;
237
+ }
238
+ if (decision.action === 'reset') {
239
+ try {
240
+ await ctx.$ `git -C ${repoRoot} reset --hard HEAD`.quiet();
241
+ await ctx.$ `git -C ${repoRoot} clean -fd`.quiet();
242
+ return 'Resolved by discarding all uncommitted changes.';
243
+ }
244
+ catch (error) {
245
+ throw new SyncCommandError(`Failed to reset changes: ${formatError(error)}`);
246
+ }
247
+ }
248
+ return `Unable to automatically resolve. Please manually resolve in: ${repoRoot}`;
249
+ },
250
+ };
251
+ }
252
+ async function runStartup(ctx, locations, config, log) {
253
+ const repoRoot = resolveRepoRoot(config, locations);
254
+ log.debug('Starting sync', { repoRoot });
255
+ await ensureRepoCloned(ctx.$, config, repoRoot);
256
+ await ensureSecretsPolicy(ctx, config);
257
+ const branch = await resolveBranch(ctx, config, repoRoot);
258
+ log.debug('Resolved branch', { branch });
259
+ const dirty = await hasLocalChanges(ctx.$, repoRoot);
260
+ if (dirty) {
261
+ log.warn('Uncommitted changes detected', { repoRoot });
262
+ await showToast(ctx.client, `Uncommitted changes detected. Run /sync-resolve to auto-fix, or manually resolve in: ${repoRoot}`, 'warning');
263
+ return;
264
+ }
265
+ const update = await fetchAndFastForward(ctx.$, repoRoot, branch);
266
+ if (update.updated) {
267
+ log.info('Pulled remote changes', { branch });
268
+ const overrides = await loadOverrides(locations);
269
+ const plan = buildSyncPlan(config, locations, repoRoot);
270
+ await syncRepoToLocal(plan, overrides);
271
+ await writeState(locations, {
272
+ lastPull: new Date().toISOString(),
273
+ lastRemoteUpdate: new Date().toISOString(),
274
+ });
275
+ await showToast(ctx.client, 'Config updated. Restart OpenCode to apply.', 'info');
276
+ return;
277
+ }
278
+ const overrides = await loadOverrides(locations);
279
+ const plan = buildSyncPlan(config, locations, repoRoot);
280
+ await syncLocalToRepo(plan, overrides);
281
+ const changes = await hasLocalChanges(ctx.$, repoRoot);
282
+ if (!changes) {
283
+ log.debug('No local changes to push');
284
+ return;
285
+ }
286
+ const message = await generateCommitMessage({ client: ctx.client, $: ctx.$ }, repoRoot);
287
+ log.info('Pushing local changes', { message });
288
+ await commitAll(ctx.$, repoRoot, message);
289
+ await pushBranch(ctx.$, repoRoot, branch);
290
+ await writeState(locations, {
291
+ lastPush: new Date().toISOString(),
292
+ });
293
+ }
294
+ async function getConfigOrThrow(locations) {
295
+ const config = await loadSyncConfig(locations);
296
+ if (!config) {
297
+ throw new SyncConfigMissingError('Missing opencode-synced config. Run /sync-init to set it up.');
298
+ }
299
+ return config;
300
+ }
301
+ async function ensureSecretsPolicy(ctx, config) {
302
+ if (!config.includeSecrets)
303
+ return;
304
+ await ensureRepoPrivate(ctx.$, config);
305
+ }
306
+ async function resolveBranch(ctx, config, repoRoot) {
307
+ try {
308
+ const status = await getRepoStatus(ctx.$, repoRoot);
309
+ return resolveRepoBranch(config, status.branch);
310
+ }
311
+ catch {
312
+ return resolveRepoBranch(config);
313
+ }
314
+ }
315
+ const DEFAULT_REPO_NAME = 'my-opencode-config';
316
+ async function buildConfigFromInit($, options) {
317
+ const repo = await resolveRepoFromInit($, options);
318
+ return normalizeSyncConfig({
319
+ repo,
320
+ includeSecrets: options.includeSecrets ?? false,
321
+ includeSessions: options.includeSessions ?? false,
322
+ includePromptStash: options.includePromptStash ?? false,
323
+ extraSecretPaths: options.extraSecretPaths ?? [],
324
+ localRepoPath: options.localRepoPath,
325
+ });
326
+ }
327
+ async function resolveRepoFromInit($, options) {
328
+ if (options.url) {
329
+ return { url: options.url, branch: options.branch };
330
+ }
331
+ if (options.owner && options.name) {
332
+ return { owner: options.owner, name: options.name, branch: options.branch };
333
+ }
334
+ if (options.repo) {
335
+ if (options.repo.includes('://') || options.repo.endsWith('.git')) {
336
+ return { url: options.repo, branch: options.branch };
337
+ }
338
+ if (options.repo.includes('/')) {
339
+ const [owner, name] = options.repo.split('/');
340
+ if (owner && name) {
341
+ return { owner, name, branch: options.branch };
342
+ }
343
+ }
344
+ const owner = await getAuthenticatedUser($);
345
+ return { owner, name: options.repo, branch: options.branch };
346
+ }
347
+ // Default: auto-detect owner, use default repo name
348
+ const owner = await getAuthenticatedUser($);
349
+ const name = DEFAULT_REPO_NAME;
350
+ return { owner, name, branch: options.branch };
351
+ }
352
+ async function createRepo($, config, isPrivate) {
353
+ const owner = config.repo?.owner;
354
+ const name = config.repo?.name;
355
+ if (!owner || !name) {
356
+ throw new SyncCommandError('Repo creation requires owner/name.');
357
+ }
358
+ const visibility = isPrivate ? '--private' : '--public';
359
+ try {
360
+ await $ `gh repo create ${owner}/${name} ${visibility} --confirm`.quiet();
361
+ }
362
+ catch (error) {
363
+ throw new SyncCommandError(`Failed to create repo: ${formatError(error)}`);
364
+ }
365
+ }
366
+ function formatError(error) {
367
+ if (error instanceof Error)
368
+ return error.message;
369
+ return String(error);
370
+ }
371
+ async function analyzeAndDecideResolution(ctx, repoRoot, changes) {
372
+ try {
373
+ const diff = await ctx.$ `git -C ${repoRoot} diff HEAD`.quiet().text();
374
+ const statusOutput = changes.join('\n');
375
+ const prompt = [
376
+ 'You are analyzing uncommitted changes in an opencode-synced repository.',
377
+ 'Decide whether to commit these changes or discard them.',
378
+ '',
379
+ 'IMPORTANT: Only choose "commit" if the changes appear to be legitimate config updates.',
380
+ 'Choose "discard" if the changes look like temporary files, cache, or corruption.',
381
+ '',
382
+ 'Respond with ONLY a JSON object in this exact format:',
383
+ '{"action": "commit", "message": "your commit message here"}',
384
+ 'OR',
385
+ '{"action": "discard", "reason": "explanation why discarding"}',
386
+ '',
387
+ 'Status:',
388
+ statusOutput,
389
+ '',
390
+ 'Diff preview (first 2000 chars):',
391
+ diff.slice(0, 2000),
392
+ ].join('\n');
393
+ const model = await resolveSmallModel(ctx.client);
394
+ if (!model) {
395
+ return { action: 'manual', reason: 'No AI model available' };
396
+ }
397
+ let sessionId = null;
398
+ try {
399
+ const sessionResult = await ctx.client.session.create({
400
+ body: { title: 'sync-resolve' },
401
+ });
402
+ const session = unwrapData(sessionResult);
403
+ sessionId = session?.id ?? null;
404
+ if (!sessionId) {
405
+ return { action: 'manual', reason: 'Failed to create session' };
406
+ }
407
+ const response = await ctx.client.session.prompt({
408
+ path: { id: sessionId },
409
+ body: {
410
+ model,
411
+ parts: [{ type: 'text', text: prompt }],
412
+ },
413
+ });
414
+ const messageText = extractTextFromResponse(unwrapData(response) ?? response);
415
+ if (!messageText) {
416
+ return { action: 'manual', reason: 'No response from AI' };
417
+ }
418
+ const decision = parseResolutionDecision(messageText);
419
+ return decision;
420
+ }
421
+ finally {
422
+ if (sessionId) {
423
+ try {
424
+ await ctx.client.session.delete({ path: { id: sessionId } });
425
+ }
426
+ catch { }
427
+ }
428
+ }
429
+ }
430
+ catch (error) {
431
+ console.error('[ERROR] AI resolution analysis failed:', error);
432
+ return { action: 'manual', reason: `Error analyzing changes: ${formatError(error)}` };
433
+ }
434
+ }
435
+ function parseResolutionDecision(text) {
436
+ try {
437
+ const jsonMatch = text.match(/\{[\s\S]*\}/);
438
+ if (!jsonMatch) {
439
+ return { action: 'manual', reason: 'Could not parse AI response' };
440
+ }
441
+ const parsed = JSON.parse(jsonMatch[0]);
442
+ if (parsed.action === 'commit' && parsed.message) {
443
+ return { action: 'commit', message: parsed.message };
444
+ }
445
+ if (parsed.action === 'discard') {
446
+ return { action: 'reset', reason: parsed.reason };
447
+ }
448
+ return { action: 'manual', reason: 'Unexpected AI response format' };
449
+ }
450
+ catch {
451
+ return { action: 'manual', reason: 'Failed to parse AI decision' };
452
+ }
453
+ }