opencode-synced 0.3.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.
- package/dist/command/sync-enable-secrets.md +6 -0
- package/dist/command/sync-init.md +11 -0
- package/dist/command/sync-link.md +15 -0
- package/dist/command/sync-pull.md +6 -0
- package/dist/command/sync-push.md +5 -0
- package/dist/command/sync-resolve.md +5 -0
- package/dist/command/sync-status.md +5 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +171 -13877
- package/dist/sync/apply.d.ts +3 -0
- package/dist/sync/apply.js +195 -0
- package/dist/sync/commit.d.ts +9 -0
- package/dist/sync/commit.js +74 -0
- package/dist/sync/config.d.ts +35 -0
- package/dist/sync/config.js +176 -0
- package/dist/sync/errors.d.ts +19 -0
- package/dist/sync/errors.js +32 -0
- package/dist/sync/paths.d.ts +47 -0
- package/dist/sync/paths.js +178 -0
- package/dist/sync/repo.d.ts +31 -0
- package/dist/sync/repo.js +248 -0
- package/dist/sync/service.d.ts +31 -0
- package/dist/sync/service.js +453 -0
- package/dist/sync/utils.d.ts +17 -0
- package/dist/sync/utils.js +70 -0
- package/package.json +2 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { PluginInput } from '@opencode-ai/plugin';
|
|
2
|
+
import type { SyncConfig } from './config.js';
|
|
3
|
+
export interface RepoStatus {
|
|
4
|
+
branch: string;
|
|
5
|
+
changes: string[];
|
|
6
|
+
}
|
|
7
|
+
export interface RepoUpdateResult {
|
|
8
|
+
updated: boolean;
|
|
9
|
+
branch: string;
|
|
10
|
+
}
|
|
11
|
+
type Shell = PluginInput['$'];
|
|
12
|
+
export declare function isRepoCloned(repoDir: string): Promise<boolean>;
|
|
13
|
+
export declare function resolveRepoIdentifier(config: SyncConfig): string;
|
|
14
|
+
export declare function resolveRepoBranch(config: SyncConfig, fallback?: string): string;
|
|
15
|
+
export declare function ensureRepoCloned($: Shell, config: SyncConfig, repoDir: string): Promise<void>;
|
|
16
|
+
export declare function ensureRepoPrivate($: Shell, config: SyncConfig): Promise<void>;
|
|
17
|
+
export declare function parseRepoVisibility(output: string): boolean;
|
|
18
|
+
export declare function fetchAndFastForward($: Shell, repoDir: string, branch: string): Promise<RepoUpdateResult>;
|
|
19
|
+
export declare function getRepoStatus($: Shell, repoDir: string): Promise<RepoStatus>;
|
|
20
|
+
export declare function hasLocalChanges($: Shell, repoDir: string): Promise<boolean>;
|
|
21
|
+
export declare function commitAll($: Shell, repoDir: string, message: string): Promise<void>;
|
|
22
|
+
export declare function pushBranch($: Shell, repoDir: string, branch: string): Promise<void>;
|
|
23
|
+
export declare function repoExists($: Shell, repoIdentifier: string): Promise<boolean>;
|
|
24
|
+
export declare function getAuthenticatedUser($: Shell): Promise<string>;
|
|
25
|
+
export interface FoundRepo {
|
|
26
|
+
owner: string;
|
|
27
|
+
name: string;
|
|
28
|
+
isPrivate: boolean;
|
|
29
|
+
}
|
|
30
|
+
export declare function findSyncRepo($: Shell, repoName?: string): Promise<FoundRepo | null>;
|
|
31
|
+
export {};
|
|
@@ -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,31 @@
|
|
|
1
|
+
import type { PluginInput } from '@opencode-ai/plugin';
|
|
2
|
+
type SyncServiceContext = Pick<PluginInput, 'client' | '$'>;
|
|
3
|
+
interface InitOptions {
|
|
4
|
+
repo?: string;
|
|
5
|
+
owner?: string;
|
|
6
|
+
name?: string;
|
|
7
|
+
url?: string;
|
|
8
|
+
branch?: string;
|
|
9
|
+
includeSecrets?: boolean;
|
|
10
|
+
includeSessions?: boolean;
|
|
11
|
+
includePromptStash?: boolean;
|
|
12
|
+
create?: boolean;
|
|
13
|
+
private?: boolean;
|
|
14
|
+
extraSecretPaths?: string[];
|
|
15
|
+
localRepoPath?: string;
|
|
16
|
+
}
|
|
17
|
+
interface LinkOptions {
|
|
18
|
+
repo?: string;
|
|
19
|
+
}
|
|
20
|
+
export interface SyncService {
|
|
21
|
+
startupSync: () => Promise<void>;
|
|
22
|
+
status: () => Promise<string>;
|
|
23
|
+
init: (_options: InitOptions) => Promise<string>;
|
|
24
|
+
link: (_options: LinkOptions) => Promise<string>;
|
|
25
|
+
pull: () => Promise<string>;
|
|
26
|
+
push: () => Promise<string>;
|
|
27
|
+
enableSecrets: (_extraSecretPaths?: string[]) => Promise<string>;
|
|
28
|
+
resolve: () => Promise<string>;
|
|
29
|
+
}
|
|
30
|
+
export declare function createSyncService(ctx: SyncServiceContext): SyncService;
|
|
31
|
+
export {};
|