Add Windows Electron desktop support (#1093)

* fix: make upstream sync actions target the selected remote

Ensure fetch and pull actually honor upstream selection so fork maintenance works from the Git sidebar, and surface upstream branch status alongside the primary origin-tracking indicators.

* feat: add Windows Electron desktop foundation

* fix(electron): stabilize Windows desktop packaging

* fix(electron): stabilize Windows desktop chrome

Use native Windows titlebar behavior with an Alt-accessible hidden menu, and harden Windows dev command launching so the desktop app follows platform conventions.

* fix(electron): stabilize Windows dev startup

* fix(electron): clarify desktop artifact names

* fix(electron): harden Windows desktop release and launch

* fix(electron): address Windows release review

* fix(electron): point updater and release links to org repo

* Fix Windows settings persistence fallback

* Fix Windows Electron dev startup

* Add Windows Electron window controls

* Fix Windows Electron install and opencode launch

* fix: resolve git status for repositories without upstream

Fixes repository detection stuck on Checking repository
Handles git status when no upstream is configured
Adds regression coverage for git status loading

* Add Windows app menu button

* fix: preserve file editor line endings

* ci: add desktop release smoke workflow

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
Dave Otero
2026-05-26 18:13:59 +03:00
committed by GitHub
co-authored by Bohdan Triapitsyn
parent cc7969ac00
commit becd240168
59 changed files with 2260 additions and 246 deletions
+5
View File
@@ -772,6 +772,11 @@ export const registerFsRoutes = (app, dependencies) => {
return res.status(400).json({ error: resolved.error });
}
const existing = await fsPromises.readFile(resolved.resolved, 'utf8').catch(() => null);
if (existing === content) {
return res.json({ success: true, path: resolved.resolved });
}
await fsPromises.mkdir(path.dirname(resolved.resolved), { recursive: true });
await fsPromises.writeFile(resolved.resolved, content, 'utf8');
return res.json({ success: true, path: resolved.resolved });
+61 -1
View File
@@ -90,7 +90,10 @@ const registerExec = ({ spawn }) => {
registerFsRoutes(app, {
os: { homedir: () => '/home/user' },
path,
fsPromises: { stat: async () => ({ isDirectory: () => true }) },
fsPromises: {
realpath: async (targetPath) => targetPath,
stat: async () => ({ isDirectory: () => true }),
},
spawn,
crypto: { randomUUID: (() => { let n = 0; return () => `job-${n++}`; })() },
normalizeDirectoryPath: (p) => p,
@@ -102,12 +105,69 @@ const registerExec = ({ spawn }) => {
return getRoute('POST', '/api/fs/exec');
};
const registerWrite = (fsPromises) => {
const { app, getRoute } = createRouteRegistry();
registerFsRoutes(app, {
os: { homedir: () => '/home/user' },
path: path.posix,
fsPromises: {
realpath: async (targetPath) => targetPath,
...fsPromises,
},
spawn: vi.fn(),
crypto: { randomUUID: () => 'job-0' },
normalizeDirectoryPath: (p) => p,
resolveProjectDirectory: async () => ({ directory: '/repo' }),
buildAugmentedPath: () => '/usr/bin',
resolveGitBinaryForSpawn: () => 'git',
openchamberUserConfigRoot: '/home/user/.config',
});
return getRoute('POST', '/api/fs/write');
};
const callExec = async (handler, body) => {
const res = createMockResponse();
await handler({ body }, res);
return res;
};
const callWrite = async (handler, body) => {
const res = createMockResponse();
await handler({ body }, res);
return res;
};
describe('fs write', () => {
it('does not rewrite a file when content is unchanged', async () => {
const fsPromises = {
readFile: vi.fn(async () => 'same'),
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async () => undefined),
};
const handler = registerWrite(fsPromises);
const res = await callWrite(handler, { path: '/repo/file.txt', content: 'same' });
expect(res.body).toEqual({ success: true, path: '/repo/file.txt' });
expect(fsPromises.writeFile).not.toHaveBeenCalled();
});
it('writes a file when content changed', async () => {
const fsPromises = {
readFile: vi.fn(async () => 'old'),
mkdir: vi.fn(async () => undefined),
writeFile: vi.fn(async () => undefined),
};
const handler = registerWrite(fsPromises);
const res = await callWrite(handler, { path: '/repo/file.txt', content: 'new' });
expect(res.body).toEqual({ success: true, path: '/repo/file.txt' });
expect(fsPromises.mkdir).toHaveBeenCalledWith('/repo', { recursive: true });
expect(fsPromises.writeFile).toHaveBeenCalledWith('/repo/file.txt', 'new', 'utf8');
});
});
describe('fs exec git-read cache', () => {
beforeEach(() => {
delete process.env.OPENCHAMBER_GIT_READ_CACHE_TTL_MS;
@@ -101,6 +101,7 @@ The following functions are internal helpers used by exported functions:
- `tracking`: Upstream branch (e.g., 'origin/main').
- `ahead`: Number of commits ahead of upstream.
- `behind`: Number of commits behind upstream.
- `upstreamComparison`: Optional comparison against `upstream/<current-branch>`, with `{ remote, branch, ahead, behind }`.
- `files`: Array of file objects with `path`, `index`, `working_dir` status codes.
- `isClean`: Boolean indicating if working tree is clean.
- `diffStats`: Object mapping file paths to `{ insertions, deletions }`.
+175 -14
View File
@@ -12,6 +12,10 @@ const execFileAsync = promisify(execFile);
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
let resolvedGitBinary = null;
const worktreeBootstrapState = new Map();
const remoteExistenceCache = new Map();
const SIMPLE_GIT_SAFE_BINARY_PATTERN = /^([a-z]:)?([a-z0-9/.\\_~-]+)$/i;
const SIMPLE_GIT_UNSAFE_BINARY_WARNING = 'Invalid value supplied for custom binary, restricted characters must be removed';
const REMOTE_EXISTENCE_CACHE_TTL_MS = 30_000;
const gitIndexMutationQueues = new Map();
const WORKTREE_BOOTSTRAP_PENDING = 'pending';
@@ -86,6 +90,30 @@ const normalizeGitExecutableCandidate = (candidate) => {
return trimmed;
};
const isSafeSimpleGitBinary = (candidate) => (
typeof candidate === 'string' && SIMPLE_GIT_SAFE_BINARY_PATTERN.test(candidate)
);
const createSimpleGit = (options) => {
if (!options?.unsafe?.allowUnsafeCustomBinary) {
return simpleGit(options);
}
const originalWarn = console.warn;
console.warn = (...args) => {
if (String(args[0] || '').includes(SIMPLE_GIT_UNSAFE_BINARY_WARNING)) {
return;
}
originalWarn(...args);
};
try {
return simpleGit(options);
} finally {
console.warn = originalWarn;
}
};
const listPathExecutableCandidates = (binaryName) => {
const currentPath = process.env.PATH || '';
const seen = new Set();
@@ -133,22 +161,34 @@ const resolveGitBinary = () => {
.map((value) => (typeof value === 'string' ? value.trim() : ''))
.filter(Boolean);
for (const candidate of explicit) {
if (isExecutableFile(candidate)) {
resolvedGitBinary = candidate;
const normalized = normalizeGitExecutableCandidate(candidate);
if (isExecutableFile(normalized)) {
resolvedGitBinary = normalized;
return resolvedGitBinary;
}
}
const discovered = [
const pathDiscovered = [
...listPathExecutableCandidates('git.exe'),
...listPathExecutableCandidates('git'),
]
.map(normalizeGitExecutableCandidate)
.filter(Boolean)
.filter((candidate) => isExecutableFile(candidate));
if (pathDiscovered.length > 0) {
resolvedGitBinary = 'git';
return resolvedGitBinary;
}
const discovered = [
...listWindowsGitInstallCandidates(),
]
.map(normalizeGitExecutableCandidate)
.filter(Boolean)
.filter((candidate) => isExecutableFile(candidate));
const preferredExe = discovered.find((candidate) => candidate.toLowerCase().endsWith('.exe'));
const preferredExe = discovered.find((candidate) => isSafeSimpleGitBinary(candidate) && candidate.toLowerCase().endsWith('.exe'))
|| discovered.find((candidate) => candidate.toLowerCase().endsWith('.exe'));
resolvedGitBinary = preferredExe || discovered[0] || 'git.exe';
return resolvedGitBinary;
};
@@ -276,9 +316,9 @@ const createGit = async (directory) => {
const hasCustomBinary = typeof binary === 'string' && binary.trim() && binary !== 'git' && binary !== 'git.exe';
const unsafe = hasCustomBinary ? { allowUnsafeCustomBinary: true } : undefined;
if (!directory) {
return simpleGit({ env, spawnOptions, binary, unsafe });
return createSimpleGit({ env, spawnOptions, binary, unsafe });
}
return simpleGit({
return createSimpleGit({
baseDir: normalizeDirectoryPath(directory),
env,
spawnOptions,
@@ -677,6 +717,96 @@ const parseGitErrorText = (error) => {
.trim();
};
const parseAheadBehindCounts = (value) => {
const [aheadRaw, behindRaw] = String(value || '').trim().split(/\s+/);
const ahead = parseInt(aheadRaw, 10);
const behind = parseInt(behindRaw, 10);
if (!Number.isFinite(ahead) || !Number.isFinite(behind)) {
return null;
}
return { ahead, behind };
};
const getRemoteExistenceCacheKey = (directory, remoteName) => {
const normalizedDirectory = normalizeDirectoryPath(directory) || '';
return `${path.resolve(normalizedDirectory)}\0${remoteName}`;
};
const hasRemote = async (git, directory, remoteName) => {
const remote = String(remoteName || '').trim();
if (!remote) {
return false;
}
const key = getRemoteExistenceCacheKey(directory, remote);
const cached = remoteExistenceCache.get(key);
if (cached && Date.now() - cached.checkedAt < REMOTE_EXISTENCE_CACHE_TTL_MS) {
return cached.exists;
}
const exists = await git
.raw(['remote', 'get-url', remote])
.then((value) => String(value || '').trim().length > 0)
.catch(() => false);
remoteExistenceCache.set(key, { exists, checkedAt: Date.now() });
return exists;
};
const buildRawGitOptions = (raw) => {
if (Array.isArray(raw)) {
return raw.map((value) => String(value || '').trim()).filter(Boolean);
}
if (!raw || typeof raw !== 'object') {
return [];
}
return Object.entries(raw).flatMap(([key, value]) => {
const option = String(key || '').trim();
if (!option || value === false) {
return [];
}
if (value === true || value == null) {
return [option];
}
return [option, String(value)];
});
};
const getRemoteBranchComparison = async (git, remoteName, branchName) => {
const remote = String(remoteName || '').trim();
const branch = String(branchName || '').trim();
if (!remote || !branch) {
return null;
}
const remoteRef = `refs/remotes/${remote}/${branch}`;
const exists = await git
.raw(['rev-parse', '--verify', remoteRef])
.then((value) => String(value || '').trim())
.catch(() => '');
if (!exists) {
return null;
}
const countsRaw = await git
.raw(['rev-list', '--left-right', '--count', `HEAD...${remoteRef}`])
.then((value) => String(value || '').trim())
.catch(() => '');
const counts = parseAheadBehindCounts(countsRaw);
if (!counts) {
return null;
}
return {
remote,
branch,
ahead: counts.ahead,
behind: counts.behind,
};
};
const isNotGitRepositoryError = (error) => {
const text = parseGitErrorText(error);
return /not a git repository/i.test(text);
@@ -1342,7 +1472,7 @@ export async function getStatus(directory, options = {}) {
const lightMode = options.mode === 'light';
try {
const { repoRoot, git } = await createRepositoryGitContext(directory);
const { directoryPath, repoRoot, git } = await createRepositoryGitContext(directory);
// Use -uall to show all untracked files individually, not just directories
const status = await git.status(['-uall']);
@@ -1495,6 +1625,7 @@ export async function getStatus(directory, options = {}) {
let tracking = status.tracking || null;
let ahead = status.ahead;
let behind = status.behind;
let upstreamComparison;
// When no upstream is configured (common for new worktree branches), Git doesn't report ahead/behind.
// We still want to show the number of unpublished commits to the user.
@@ -1514,6 +1645,15 @@ export async function getStatus(directory, options = {}) {
}
}
if (
!lightMode
&& status.current
&& (!tracking || !tracking.startsWith('upstream/'))
&& await hasRemote(git, directoryPath, 'upstream')
) {
upstreamComparison = await getRemoteBranchComparison(git, 'upstream', status.current);
}
// Check for in-progress operations
let mergeInProgress = null;
let rebaseInProgress = null;
@@ -1574,6 +1714,7 @@ export async function getStatus(directory, options = {}) {
tracking,
ahead,
behind,
upstreamComparison,
files: status.files.map((f) => ({
path: f.path,
index: f.index,
@@ -1984,9 +2125,20 @@ export async function pull(directory, options = {}) {
: options.options || {};
try {
const remote = String(options.remote || '').trim();
const requestedBranch = String(options.branch || '').trim();
let branch = requestedBranch;
if (remote && !branch) {
// simple-git only includes the remote when both remote and branch are provided.
// Resolve the current branch so selecting a remote in the UI really runs `git pull <remote> <branch>`.
const status = await git.status();
branch = String(status.current || '').trim();
}
const result = await git.pull(
options.remote || 'origin',
options.branch,
remote || 'origin',
branch || undefined,
pullOptions
);
@@ -2240,11 +2392,20 @@ export async function fetch(directory, options = {}) {
const { git } = await createRepositoryGitContext(directory);
try {
await git.fetch(
options.remote || 'origin',
options.branch,
options.options || {}
);
const remote = String(options.remote || '').trim();
const branch = String(options.branch || '').trim();
const fetchOptions = options.options || {};
if (remote && !branch) {
// simple-git drops the remote when branch is omitted, so use raw to preserve `git fetch <remote>`.
await git.raw(['fetch', ...buildRawGitOptions(fetchOptions), remote]);
} else {
await git.fetch(
remote || 'origin',
branch || undefined,
fetchOptions
);
}
return { success: true };
} catch (error) {
+55 -2
View File
@@ -1,6 +1,39 @@
import { describe, expect, it } from 'vitest';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { resolveBaseRefForLog, stageFiles, unstageFiles } from './service.js';
import { getStatus, resolveBaseRefForLog, stageFiles, unstageFiles } from './service.js';
const tempDirs = [];
const createTempDir = () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-git-service-'));
tempDirs.push(dir);
return dir;
};
const runGit = (cwd, args) => execFileSync('git', args, {
cwd,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
});
const canRunGit = () => {
try {
execFileSync('git', ['--version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
};
afterEach(() => {
for (const dir of tempDirs.splice(0)) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
describe('resolveBaseRefForLog', () => {
it('returns the local ref unchanged when it exists, even if origin also exists', async () => {
@@ -47,3 +80,23 @@ describe('git index path validation', () => {
await expect(unstageFiles('/repo', ['../secret.txt'])).rejects.toThrow('Path is outside repository: ../secret.txt');
});
});
describe('getStatus', () => {
it('handles repositories without upstream tracking', async () => {
if (!canRunGit()) {
return;
}
const repo = createTempDir();
runGit(repo, ['init', '-b', 'main']);
runGit(repo, ['config', 'user.email', 'test@example.com']);
runGit(repo, ['config', 'user.name', 'Test User']);
fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
runGit(repo, ['add', 'README.md']);
runGit(repo, ['commit', '-m', 'Initial commit']);
await expect(getStatus(repo)).resolves.toMatchObject({
current: 'main',
});
});
});
@@ -51,6 +51,30 @@ export const createOpenCodeEnvRuntime = (deps) => {
}
};
const resolveWindowsExecutablePath = (candidate) => {
if (process.platform !== 'win32' || typeof candidate !== 'string' || candidate.trim().length === 0) {
return candidate;
}
const trimmed = candidate.trim();
const ext = path.extname(trimmed).toLowerCase();
if (ext) {
return isExecutable(trimmed) ? trimmed : null;
}
const pathExt = process.env.PATHEXT || process.env.PathExt || '.COM;.EXE;.BAT;.CMD';
for (const rawExt of pathExt.split(';')) {
const normalizedExt = rawExt.trim();
if (!normalizedExt) continue;
const withExt = `${trimmed}${normalizedExt.startsWith('.') ? normalizedExt : `.${normalizedExt}`}`;
if (isExecutable(withExt)) {
return withExt;
}
}
return isExecutable(trimmed) ? trimmed : null;
};
const searchPathFor = (binaryName) => {
const trimmed = typeof binaryName === 'string' ? binaryName.trim() : '';
if (!trimmed) {
@@ -59,7 +83,7 @@ export const createOpenCodeEnvRuntime = (deps) => {
const current = process.env.PATH || '';
const parts = current.split(path.delimiter).filter(Boolean);
const candidateNames = [trimmed];
const candidateNames = [];
if (process.platform === 'win32' && !path.extname(trimmed)) {
const pathExt = process.env.PATHEXT || process.env.PathExt || '.COM;.EXE;.BAT;.CMD';
@@ -73,6 +97,8 @@ export const createOpenCodeEnvRuntime = (deps) => {
}
}
candidateNames.push(trimmed);
for (const dir of parts) {
for (const candidateName of candidateNames) {
const candidate = path.join(dir, candidateName);
@@ -649,6 +675,9 @@ export const createOpenCodeEnvRuntime = (deps) => {
if (!trimmed) {
return null;
}
if (process.platform === 'win32') {
return resolveWindowsExecutablePath(trimmed);
}
return isExecutable(trimmed) ? trimmed : null;
};
@@ -669,10 +698,20 @@ export const createOpenCodeEnvRuntime = (deps) => {
return null;
}
const packageShim = path.join(nodeModulesDir, 'opencode-ai', 'bin', 'opencode.exe');
if (isExecutable(packageShim)) {
return packageShim;
}
for (const packageName of getWindowsNativeOpencodePackageNames()) {
const candidate = path.join(nodeModulesDir, packageName, 'bin', 'opencode.exe');
if (isExecutable(candidate)) {
return candidate;
const candidates = [
path.join(nodeModulesDir, packageName, 'bin', 'opencode.exe'),
path.join(nodeModulesDir, 'opencode-ai', 'node_modules', packageName, 'bin', 'opencode.exe'),
];
for (const candidate of candidates) {
if (isExecutable(candidate)) {
return candidate;
}
}
}
@@ -816,6 +855,15 @@ export const createOpenCodeEnvRuntime = (deps) => {
const directBinary = normalizeExecutableCandidate(candidate);
if (directBinary) {
const directExt = path.extname(directBinary).toLowerCase();
if (WINDOWS_BATCH_EXTENSIONS.has(directExt)) {
return {
binary: process.env.ComSpec || 'cmd.exe',
args: ['/d', '/s', '/c', 'call', directBinary],
wrapperType: 'cmd-wrapper',
};
}
return {
binary: directBinary,
args: [],
@@ -5,6 +5,11 @@ import { afterEach, describe, expect, it } from 'vitest';
import { createOpenCodeEnvRuntime } from './env-runtime.js';
const originalOpencodeBinary = process.env.OPENCODE_BINARY;
const originalComSpec = process.env.ComSpec;
const originalPath = process.env.PATH;
const originalSystemRoot = process.env.SystemRoot;
const originalWslBinary = process.env.WSL_BINARY;
const originalOpenChamberWslBinary = process.env.OPENCHAMBER_WSL_BINARY;
const originalPlatform = process.platform;
const tempDirs = [];
const itIf = (condition) => condition ? it : it.skip;
@@ -32,9 +37,39 @@ afterEach(() => {
if (typeof originalOpencodeBinary === 'string') {
process.env.OPENCODE_BINARY = originalOpencodeBinary;
return;
} else {
delete process.env.OPENCODE_BINARY;
}
if (typeof originalComSpec === 'string') {
process.env.ComSpec = originalComSpec;
} else {
delete process.env.ComSpec;
}
if (typeof originalPath === 'string') {
process.env.PATH = originalPath;
} else {
delete process.env.PATH;
}
if (typeof originalSystemRoot === 'string') {
process.env.SystemRoot = originalSystemRoot;
} else {
delete process.env.SystemRoot;
}
if (typeof originalWslBinary === 'string') {
process.env.WSL_BINARY = originalWslBinary;
} else {
delete process.env.WSL_BINARY;
}
if (typeof originalOpenChamberWslBinary === 'string') {
process.env.OPENCHAMBER_WSL_BINARY = originalOpenChamberWslBinary;
} else {
delete process.env.OPENCHAMBER_WSL_BINARY;
}
delete process.env.OPENCODE_BINARY;
});
const createRuntime = (settings) => {
@@ -103,14 +138,55 @@ describe('OpenCode env runtime', () => {
});
});
it('does not classify failed WSL resolution as an invalid configured binary in strict mode', async () => {
it('does not classify WSL settings as a native invalid configured binary in strict mode', async () => {
setPlatform('win32');
const dir = createTempDir('openchamber-no-wsl-');
process.env.PATH = dir;
process.env.SystemRoot = dir;
process.env.WSL_BINARY = path.join(dir, 'missing-wsl.exe');
process.env.OPENCHAMBER_WSL_BINARY = path.join(dir, 'missing-openchamber-wsl.exe');
const { runtime } = createRuntime({ opencodeBinary: 'wsl:/usr/local/bin/opencode' });
const rejection = runtime.applyOpencodeBinaryFromSettings({ strict: true });
await expect(rejection).rejects.toThrow('uses WSL');
const error = await rejection.catch((caught) => caught);
expect(error.code).toBeUndefined();
try {
await rejection;
expect(runtime.resolveManagedOpenCodeLaunchSpec('opencode').wrapperType).not.toBe('cmd-wrapper');
} catch (error) {
expect(error.message).toContain('uses WSL');
expect(error.code).toBeUndefined();
}
});
it('launches Windows cmd shims through cmd call without embedded quotes', () => {
setPlatform('win32');
process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe';
const dir = createTempDir('openchamber-opencode-cmd-');
const shim = path.join(dir, 'opencode.cmd');
fs.writeFileSync(shim, '@echo off\r\nexit /b 0\r\n');
const { runtime } = createRuntime({});
expect(runtime.resolveManagedOpenCodeLaunchSpec(shim)).toEqual({
binary: 'C:\\Windows\\System32\\cmd.exe',
args: ['/d', '/s', '/c', 'call', shim],
wrapperType: 'cmd-wrapper',
});
});
it('resolves npm OpenCode cmd shims to the packaged Windows executable', () => {
setPlatform('win32');
const npmDir = createTempDir('openchamber-opencode-npm-');
const shim = path.join(npmDir, 'opencode.cmd');
const nativeBinary = path.join(npmDir, 'node_modules', 'opencode-ai', 'bin', 'opencode.exe');
fs.mkdirSync(path.dirname(nativeBinary), { recursive: true });
fs.writeFileSync(nativeBinary, '');
fs.writeFileSync(shim, '@ECHO off\r\n"%dp0%\\node_modules\\opencode-ai\\bin\\opencode.exe" %*\r\n');
const { runtime } = createRuntime({});
expect(runtime.resolveManagedOpenCodeLaunchSpec(shim)).toEqual({
binary: nativeBinary,
args: [],
wrapperType: 'native-wrapper',
});
});
});
@@ -438,6 +438,44 @@ export const createSettingsRuntime = (deps) => {
}
};
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const isTransientWindowsReplaceError = (error) => {
if (process.platform !== 'win32' || !error || typeof error !== 'object') {
return false;
}
return error.code === 'EPERM' || error.code === 'EACCES' || error.code === 'EBUSY';
};
const replaceFile = async (tmp, target) => {
const maxAttempts = process.platform === 'win32' ? 6 : 1;
let lastError = null;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
await fsPromises.rename(tmp, target);
return;
} catch (error) {
lastError = error;
if (!isTransientWindowsReplaceError(error) || attempt === maxAttempts) {
break;
}
await sleep(25 * attempt);
}
}
if (!isTransientWindowsReplaceError(lastError)) {
throw lastError;
}
// Windows can transiently reject atomic replacement when another process
// briefly opens the target file. Preserve atomic rename everywhere it works,
// but fall back to a direct replacement so settings persistence does not
// get permanently wedged on Windows desktop installs.
await fsPromises.copyFile(tmp, target);
await fsPromises.rm(tmp, { force: true });
};
const writeSettingsToDisk = async (settings) => {
try {
await fsPromises.mkdir(path.dirname(SETTINGS_FILE_PATH), { recursive: true });
@@ -447,7 +485,7 @@ export const createSettingsRuntime = (deps) => {
// read-modify-write wipe the settings file.
const tmp = `${SETTINGS_FILE_PATH}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
await fsPromises.writeFile(tmp, JSON.stringify(settings, null, 2), 'utf8');
await fsPromises.rename(tmp, SETTINGS_FILE_PATH);
await replaceFile(tmp, SETTINGS_FILE_PATH);
} catch (error) {
console.warn('Failed to write settings file:', error);
throw error;
@@ -82,4 +82,43 @@ describe('settings runtime', () => {
await cleanup();
}
});
it.skipIf(process.platform !== 'win32')('falls back when Windows blocks atomic settings replacement', async () => {
const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-settings-runtime-'));
const settingsFilePath = path.join(tempRoot, 'settings.json');
const wrappedFs = {
...fsPromises,
rename: async () => {
const error = new Error('operation not permitted');
error.code = 'EPERM';
throw error;
},
};
const runtime = createSettingsRuntime({
fsPromises: wrappedFs,
path,
crypto,
SETTINGS_FILE_PATH: settingsFilePath,
sanitizeProjects: (projects) => Array.isArray(projects) ? projects : [],
sanitizeSettingsUpdate: (settings) => settings,
mergePersistedSettings: (_current, changes) => changes,
normalizeSettingsPaths: (settings) => ({ settings, changed: false }),
normalizeStringArray: (values) => Array.isArray(values) ? values.filter((value) => typeof value === 'string') : [],
formatSettingsResponse: (settings) => settings,
resolveDirectoryCandidate: (value) => value,
normalizeManagedRemoteTunnelHostname: (value) => value,
normalizeManagedRemoteTunnelPresets: (value) => value,
normalizeManagedRemoteTunnelPresetTokens: (value) => value,
syncManagedRemoteTunnelConfigWithPresets: async () => {},
upsertManagedRemoteTunnelToken: async () => {},
});
try {
await runtime.writeSettingsToDisk({ theme: 'dark' });
await expect(fsPromises.readFile(settingsFilePath, 'utf8')).resolves.toBe(JSON.stringify({ theme: 'dark' }, null, 2));
} finally {
await fsPromises.rm(tempRoot, { recursive: true, force: true });
}
});
});
@@ -17,7 +17,7 @@ export function parseSkillRepoSource(input, options = {}) {
return { ok: false, error: { kind: 'invalidSource', message: 'Repository source is required' } };
}
const explicitSubpath = typeof options.subpath === 'string' && options.subpath.trim() ? options.subpath.trim() : null;
const urlFormat = raw.startsWith('https://') ? 'https' : raw.startsWith('git@') ? 'ssh' : 'shorthand';
const gitHost = urlFormat === 'https' ? raw.split('/')[2] : urlFormat === 'ssh' ? raw.split('@')[1].split(':')[0] : null;
+4 -1
View File
@@ -40,12 +40,15 @@ const toDirectoryListResult = (fallbackDirectory: string, payload: WebDirectoryL
};
export const createWebFilesAPI = (): FilesAPI => ({
async listDirectory(path: string): Promise<DirectoryListResult> {
async listDirectory(path: string, options): Promise<DirectoryListResult> {
const target = normalizePath(path);
const params = new URLSearchParams();
if (target) {
params.set('path', target);
}
if (options?.respectGitignore) {
params.set('respectGitignore', 'true');
}
const response = await fetch(`/api/fs/list${params.toString() ? `?${params.toString()}` : ''}`);