Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
151 changes: 141 additions & 10 deletions src/sync/apply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function createPlan(repoRoot: string, homeDir: string, items: SyncItem[]): SyncP
repoRoot,
homeDir,
platform: 'linux',
configRoot: path.join(homeDir, '.config', 'opencode'),
};
}

Expand Down Expand Up @@ -330,12 +331,12 @@ describe('relative extra paths', () => {
};

expect(configManifest.entries.map((entry) => [entry.sourcePath, entry.type])).toEqual([
[configFile, 'file'],
[configDirectory, 'dir'],
['SOUL.md', 'file'],
['custom-configs', 'dir'],
]);
expect(secretManifest.entries.map((entry) => [entry.sourcePath, entry.type])).toEqual([
[secretFile, 'file'],
[secretDirectory, 'dir'],
['credentials/token.json', 'file'],
['private-agents', 'dir'],
]);

const configRepoPaths = new Map(
Expand All @@ -350,17 +351,17 @@ describe('relative extra paths', () => {
path.join(repoRoot, entry.repoPath),
])
);
await expect(fs.readFile(configRepoPaths.get(configFile) ?? '', 'utf8')).resolves.toBe(
await expect(fs.readFile(configRepoPaths.get('SOUL.md') ?? '', 'utf8')).resolves.toBe(
'config-file'
);
await expect(
fs.readFile(path.join(configRepoPaths.get(configDirectory) ?? '', 'custom.md'), 'utf8')
fs.readFile(path.join(configRepoPaths.get('custom-configs') ?? '', 'custom.md'), 'utf8')
).resolves.toBe('config-directory');
await expect(fs.readFile(secretRepoPaths.get(secretFile) ?? '', 'utf8')).resolves.toBe(
'secret-file'
);
await expect(
fs.readFile(path.join(secretRepoPaths.get(secretDirectory) ?? '', 'private.md'), 'utf8')
fs.readFile(secretRepoPaths.get('credentials/token.json') ?? '', 'utf8')
).resolves.toBe('secret-file');
await expect(
fs.readFile(path.join(secretRepoPaths.get('private-agents') ?? '', 'private.md'), 'utf8')
).resolves.toBe('secret-directory');
} finally {
process.chdir(originalCwd);
Expand Down Expand Up @@ -678,6 +679,136 @@ describe('syncing plural OpenCode config directories', () => {
});
});

describe('portable extra path manifests', () => {
it('moves an extra config file between different homes with canonical manifest paths', async () => {
await withTempDir(async (root) => {
const machineAHome = path.join(root, 'machine-a');
const machineBHome = path.join(root, 'machine-b');
const repoRoot = path.join(root, 'repo');
const machineALocations = resolveSyncLocations({ HOME: machineAHome }, 'linux');
const machineBLocations = resolveSyncLocations({ HOME: machineBHome }, 'linux');
const relativeExtraPath = 'custom/nested.json';
const machineAExtraPath = path.join(machineALocations.configRoot, relativeExtraPath);
const machineBExtraPath = path.join(machineBLocations.configRoot, relativeExtraPath);
await fs.mkdir(path.dirname(machineAExtraPath), { recursive: true });
await fs.writeFile(machineAExtraPath, 'portable-extra', 'utf8');

const commonConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: false,
includeOpencodeSkills: false,
includeAgentsDir: false,
includeModelFavorites: false,
};
const machineAPlan = buildSyncPlan(
normalizeSyncConfig({ ...commonConfig, extraConfigPaths: [machineAExtraPath] }),
machineALocations,
repoRoot,
'linux'
);
const machineBPlan = buildSyncPlan(
normalizeSyncConfig({ ...commonConfig, extraConfigPaths: [machineBExtraPath] }),
machineBLocations,
repoRoot,
'linux'
);

await syncLocalToRepo(machineAPlan, null);
const manifest = JSON.parse(
await fs.readFile(machineAPlan.extraConfigs.manifestPath, 'utf8')
) as { entries: Array<{ sourcePath: string; repoPath: string }> };

expect(manifest.entries).toHaveLength(1);
expect(manifest.entries[0]?.sourcePath).toBe('custom/nested.json');
expect(manifest.entries[0]?.repoPath).toMatch(/^config\/extra\//);
expect(manifest.entries[0]?.repoPath).not.toContain('\\');

await syncRepoToLocal(machineBPlan, null);
await expect(fs.readFile(machineBExtraPath, 'utf8')).resolves.toBe('portable-extra');
});
});

it('reads legacy Windows separators in a manifest on POSIX', async () => {
await withTempDir(async (root) => {
const homeDir = path.join(root, 'home');
const repoRoot = path.join(root, 'repo');
const locations = resolveSyncLocations({ HOME: homeDir }, 'linux');
const localPath = path.join(locations.configRoot, 'custom.json');
const repoPath = path.join(repoRoot, 'config', 'extra', 'payload');
await fs.mkdir(path.dirname(repoPath), { recursive: true });
await fs.writeFile(repoPath, 'legacy-windows-manifest', 'utf8');

const plan = buildSyncPlan(
normalizeSyncConfig({
repo: { owner: 'acme', name: 'config' },
includeSecrets: false,
extraConfigPaths: [localPath],
}),
locations,
repoRoot,
'linux'
);
await fs.mkdir(path.dirname(plan.extraConfigs.manifestPath), { recursive: true });
await fs.writeFile(
plan.extraConfigs.manifestPath,
JSON.stringify({
entries: [
{
sourcePath: 'custom.json',
repoPath: 'config\\extra\\payload',
type: 'file',
},
],
}),
'utf8'
);

await syncRepoToLocal(plan, null);
await expect(fs.readFile(localPath, 'utf8')).resolves.toBe('legacy-windows-manifest');
});
});

it('ignores manifest repository paths outside the sync repo', async () => {
await withTempDir(async (root) => {
const homeDir = path.join(root, 'home');
const repoRoot = path.join(root, 'repo');
const locations = resolveSyncLocations({ HOME: homeDir }, 'linux');
const localPath = path.join(locations.configRoot, 'custom.json');
const outsidePath = path.join(root, 'outside-payload');
await fs.mkdir(repoRoot, { recursive: true });
await fs.writeFile(outsidePath, 'must-not-copy', 'utf8');

const plan = buildSyncPlan(
normalizeSyncConfig({
repo: { owner: 'acme', name: 'config' },
includeSecrets: false,
extraConfigPaths: [localPath],
}),
locations,
repoRoot,
'linux'
);
await fs.mkdir(path.dirname(plan.extraConfigs.manifestPath), { recursive: true });
await fs.writeFile(
plan.extraConfigs.manifestPath,
JSON.stringify({
entries: [
{
sourcePath: 'custom.json',
repoPath: '../outside-payload',
type: 'file',
},
],
}),
'utf8'
);

await syncRepoToLocal(plan, null);
await expect(fs.stat(localPath)).rejects.toMatchObject({ code: 'ENOENT' });
});
});
});

describe('MCP secret scrub round trip', () => {
it('keeps the secret local, writes a placeholder to the repo, and protects overrides', async () => {
await withTempDir(async (root) => {
Expand Down
57 changes: 44 additions & 13 deletions src/sync/apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
stripOverrideKeys,
} from './mcp-secrets.js';
import type { ExtraPathPlan, SyncItem, SyncPlan } from './paths.js';
import { normalizePath } from './paths.js';
import { fromPortablePath, normalizePath, toPortablePath } from './paths.js';

type ExtraPathType = 'file' | 'dir';

Expand Down Expand Up @@ -577,14 +577,14 @@ async function applyExtraPaths(plan: SyncPlan, extra: ExtraPathPlan): Promise<vo
const manifest = parseJsonc<ExtraPathManifest>(manifestContent);

for (const entry of manifest.entries) {
const normalized = normalizePath(entry.sourcePath, plan.homeDir, plan.platform);
const pathApi = plan.platform === 'win32' ? path.win32 : path.posix;
const localPath = fromPortablePath(entry.sourcePath, plan.configRoot, plan.homeDir, pathApi);
const normalized = normalizePath(localPath, plan.homeDir, plan.platform);
const isAllowed = allowlist.includes(normalized);
if (!isAllowed) continue;

const repoPath = path.isAbsolute(entry.repoPath)
? entry.repoPath
: path.join(plan.repoRoot, entry.repoPath);
const localPath = entry.sourcePath;
const repoPath = resolveManifestRepoPath(plan.repoRoot, entry.repoPath);
if (!repoPath) continue;
const entryType: ExtraPathType = entry.type ?? 'file';

if (!(await pathExists(repoPath))) continue;
Expand Down Expand Up @@ -613,12 +613,15 @@ async function writeExtraPathManifest(plan: SyncPlan, extra: ExtraPathPlan): Pro
continue;
}
const stat = await fs.stat(sourcePath);
const pathApi = plan.platform === 'win32' ? path.win32 : path.posix;
const portableSourcePath = toPortablePath(sourcePath, plan.configRoot, plan.homeDir, pathApi);
const manifestRepoPath = toManifestRepoPath(plan.repoRoot, entry.repoPath);
if (stat.isDirectory()) {
await copyDirRecursive(sourcePath, entry.repoPath);
const items = await collectExtraPathItems(sourcePath, sourcePath);
entries.push({
sourcePath,
repoPath: path.relative(plan.repoRoot, entry.repoPath),
sourcePath: portableSourcePath,
repoPath: manifestRepoPath,
type: 'dir',
mode: stat.mode & 0o777,
items,
Expand All @@ -628,8 +631,8 @@ async function writeExtraPathManifest(plan: SyncPlan, extra: ExtraPathPlan): Pro
if (stat.isFile()) {
await copyFileWithMode(sourcePath, entry.repoPath);
entries.push({
sourcePath,
repoPath: path.relative(plan.repoRoot, entry.repoPath),
sourcePath: portableSourcePath,
repoPath: manifestRepoPath,
type: 'file',
mode: stat.mode & 0o777,
});
Expand All @@ -649,7 +652,7 @@ async function collectExtraPathItems(

for (const entry of entries) {
const entrySource = path.join(sourcePath, entry.name);
const relativePath = path.relative(basePath, entrySource);
const relativePath = toPortableSeparators(path.relative(basePath, entrySource));

if (entry.isDirectory()) {
const stat = await fs.stat(entrySource);
Expand Down Expand Up @@ -702,10 +705,11 @@ async function applyExtraPathModes(

function resolveExtraPathItem(basePath: string, relativePath: string): string | null {
if (!relativePath) return null;
if (path.isAbsolute(relativePath)) return null;
const normalizedRelativePath = toPortableSeparators(relativePath);
if (path.posix.isAbsolute(normalizedRelativePath)) return null;

const resolvedBase = path.resolve(basePath);
const resolvedPath = path.resolve(basePath, relativePath);
const resolvedPath = path.resolve(basePath, normalizedRelativePath);
const relative = path.relative(resolvedBase, resolvedPath);
if (relative === '..' || relative.startsWith(`..${path.sep}`)) {
return null;
Expand All @@ -717,6 +721,33 @@ function resolveExtraPathItem(basePath: string, relativePath: string): string |
return resolvedPath;
}

function resolveManifestRepoPath(repoRoot: string, manifestRepoPath: string): string | null {
if (!manifestRepoPath) return null;

const resolvedRoot = path.resolve(repoRoot);
const portableRepoPath = toPortableSeparators(manifestRepoPath);
const resolvedPath = path.isAbsolute(manifestRepoPath)
? path.resolve(manifestRepoPath)
: path.resolve(resolvedRoot, portableRepoPath);
const relative = path.relative(resolvedRoot, resolvedPath);
if (relative === '..' || relative.startsWith(`..${path.sep}`)) return null;
if (path.isAbsolute(relative)) return null;

return resolvedPath;
}

function toManifestRepoPath(repoRoot: string, repoPath: string): string {
const containedPath = resolveManifestRepoPath(repoRoot, repoPath);
if (!containedPath) {
throw new Error(`Extra path repository target is outside the sync repo: ${repoPath}`);
}
return toPortableSeparators(path.relative(path.resolve(repoRoot), containedPath));
}

function toPortableSeparators(inputPath: string): string {
return inputPath.replace(/\\/g, '/');
}

function isDeepEqual(left: unknown, right: unknown): boolean {
if (left === right) return true;
if (typeof left !== typeof right) return false;
Expand Down
Loading
Loading