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.
@@ -0,0 +1,3 @@
1
+ import type { SyncPlan } from './paths.js';
2
+ export declare function syncRepoToLocal(plan: SyncPlan, overrides: Record<string, unknown> | null): Promise<void>;
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,9 @@
1
+ import type { PluginInput } from '@opencode-ai/plugin';
2
+ type CommitClient = PluginInput['client'];
3
+ type Shell = PluginInput['$'];
4
+ interface CommitContext {
5
+ client: CommitClient;
6
+ $: Shell;
7
+ }
8
+ export declare function generateCommitMessage(ctx: CommitContext, repoDir: string, fallbackDate?: Date): Promise<string>;
9
+ export {};
@@ -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
+ }
@@ -0,0 +1,35 @@
1
+ import type { SyncLocations } from './paths.js';
2
+ export interface SyncRepoConfig {
3
+ url?: string;
4
+ owner?: string;
5
+ name?: string;
6
+ branch?: string;
7
+ }
8
+ export interface SyncConfig {
9
+ repo?: SyncRepoConfig;
10
+ localRepoPath?: string;
11
+ includeSecrets?: boolean;
12
+ includeSessions?: boolean;
13
+ includePromptStash?: boolean;
14
+ extraSecretPaths?: string[];
15
+ }
16
+ export interface SyncState {
17
+ lastPull?: string;
18
+ lastPush?: string;
19
+ lastRemoteUpdate?: string;
20
+ }
21
+ export declare function pathExists(filePath: string): Promise<boolean>;
22
+ export declare function normalizeSyncConfig(config: SyncConfig): SyncConfig;
23
+ export declare function loadSyncConfig(locations: SyncLocations): Promise<SyncConfig | null>;
24
+ export declare function writeSyncConfig(locations: SyncLocations, config: SyncConfig): Promise<void>;
25
+ export declare function loadOverrides(locations: SyncLocations): Promise<Record<string, unknown> | null>;
26
+ export declare function loadState(locations: SyncLocations): Promise<SyncState>;
27
+ export declare function writeState(locations: SyncLocations, state: SyncState): Promise<void>;
28
+ export declare function applyOverridesToRuntimeConfig(config: Record<string, unknown>, overrides: Record<string, unknown>): void;
29
+ export declare function deepMerge<T>(base: T, override: unknown): T;
30
+ export declare function stripOverrides(localConfig: Record<string, unknown>, overrides: Record<string, unknown>, baseConfig: Record<string, unknown> | null): Record<string, unknown>;
31
+ export declare function parseJsonc<T>(content: string): T;
32
+ export declare function writeJsonFile(filePath: string, data: unknown, options?: {
33
+ jsonc: boolean;
34
+ mode?: number;
35
+ }): Promise<void>;
@@ -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,19 @@
1
+ export declare class SyncError extends Error {
2
+ readonly code: string;
3
+ constructor(code: string, message: string);
4
+ }
5
+ export declare class SyncConfigMissingError extends SyncError {
6
+ constructor(message: string);
7
+ }
8
+ export declare class RepoDivergedError extends SyncError {
9
+ constructor(message: string);
10
+ }
11
+ export declare class RepoPrivateRequiredError extends SyncError {
12
+ constructor(message: string);
13
+ }
14
+ export declare class RepoVisibilityError extends SyncError {
15
+ constructor(message: string);
16
+ }
17
+ export declare class SyncCommandError extends SyncError {
18
+ constructor(message: string);
19
+ }
@@ -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
+ }
@@ -0,0 +1,47 @@
1
+ import type { SyncConfig } from './config.js';
2
+ export interface XdgPaths {
3
+ homeDir: string;
4
+ configDir: string;
5
+ dataDir: string;
6
+ stateDir: string;
7
+ }
8
+ export interface SyncLocations {
9
+ xdg: XdgPaths;
10
+ configRoot: string;
11
+ syncConfigPath: string;
12
+ overridesPath: string;
13
+ statePath: string;
14
+ defaultRepoDir: string;
15
+ }
16
+ export type SyncItemType = 'file' | 'dir';
17
+ export interface SyncItem {
18
+ localPath: string;
19
+ repoPath: string;
20
+ type: SyncItemType;
21
+ isSecret: boolean;
22
+ isConfigFile: boolean;
23
+ }
24
+ export interface ExtraSecretPlan {
25
+ allowlist: string[];
26
+ manifestPath: string;
27
+ entries: Array<{
28
+ sourcePath: string;
29
+ repoPath: string;
30
+ }>;
31
+ }
32
+ export interface SyncPlan {
33
+ items: SyncItem[];
34
+ extraSecrets: ExtraSecretPlan;
35
+ repoRoot: string;
36
+ homeDir: string;
37
+ platform: NodeJS.Platform;
38
+ }
39
+ export declare function resolveHomeDir(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): string;
40
+ export declare function resolveXdgPaths(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): XdgPaths;
41
+ export declare function resolveSyncLocations(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): SyncLocations;
42
+ export declare function expandHome(inputPath: string, homeDir: string): string;
43
+ export declare function normalizePath(inputPath: string, homeDir: string, platform?: NodeJS.Platform): string;
44
+ export declare function isSamePath(left: string, right: string, homeDir: string, platform?: NodeJS.Platform): boolean;
45
+ export declare function encodeSecretPath(inputPath: string): string;
46
+ export declare function resolveRepoRoot(config: SyncConfig | null, locations: SyncLocations): string;
47
+ export declare function buildSyncPlan(config: SyncConfig, locations: SyncLocations, repoRoot: string, platform?: NodeJS.Platform): SyncPlan;