Launching: the VS Code extension spawned .cmd shims with shell:true, which builds an unquoted command line — a path like "C:\Program Files\nodejs\opencode.cmd" broke with "'C:\Program' is not recognized". It now spawns cmd.exe directly with the shim path as its own argv element (the web server's existing cmd-wrapper pattern), and the web server's final launch-spec fallback routes raw .cmd/.bat through the same wrapper instead of handing them to spawn. Configured paths: wrapping quote pairs (Windows "Copy as path" pastes) are now stripped everywhere a binary path enters — the VS Code setting and shared settings.json, env vars on both surfaces, the server's directory-path normalization, and the desktop settings input. Discovery: added the system-wide npm prefix (Program Files\nodejs) and scoop's .exe shim to the Windows candidates, Linuxbrew on Linux, and the where-probe now runs with windowsHide. The macOS desktop-app exclusion also covers the OpenCode Dev/Beta app bundles.
451 lines
13 KiB
JavaScript
451 lines
13 KiB
JavaScript
export const createSettingsNormalizationRuntime = (dependencies) => {
|
|
const {
|
|
os,
|
|
path,
|
|
processLike,
|
|
realpathSync,
|
|
tunnelBootstrapTtlDefaultMs,
|
|
tunnelBootstrapTtlMinMs,
|
|
tunnelBootstrapTtlMaxMs,
|
|
tunnelSessionTtlDefaultMs,
|
|
tunnelSessionTtlMinMs,
|
|
tunnelSessionTtlMaxMs,
|
|
} = dependencies;
|
|
|
|
const normalizeDirectoryPath = (value) => {
|
|
if (typeof value !== 'string') {
|
|
return value;
|
|
}
|
|
|
|
let trimmed = value.trim();
|
|
// Paths pasted from Windows "Copy as path" (or quoted shell snippets)
|
|
// arrive wrapped in quotes — a literal quote character can never be part
|
|
// of a real path, and it breaks every fs.stat/executable check.
|
|
if (trimmed.length >= 2
|
|
&& ((trimmed.startsWith('"') && trimmed.endsWith('"'))
|
|
|| (trimmed.startsWith("'") && trimmed.endsWith("'")))) {
|
|
trimmed = trimmed.slice(1, -1).trim();
|
|
}
|
|
if (!trimmed) {
|
|
return trimmed;
|
|
}
|
|
|
|
if (trimmed === '~') {
|
|
return os.homedir();
|
|
}
|
|
|
|
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
|
return path.join(os.homedir(), trimmed.slice(2));
|
|
}
|
|
|
|
return trimmed;
|
|
};
|
|
|
|
// Resolve symlinks, falling back to the original value on failure.
|
|
const safeRealpathSync = (value) => {
|
|
if (!realpathSync || typeof value !== 'string' || !value) {
|
|
return value;
|
|
}
|
|
try {
|
|
return realpathSync(value);
|
|
} catch {
|
|
return value;
|
|
}
|
|
};
|
|
|
|
const normalizePathForPersistence = (value, options = {}) => {
|
|
if (typeof value !== 'string') {
|
|
return value;
|
|
}
|
|
|
|
const normalized = normalizeDirectoryPath(value);
|
|
if (typeof normalized !== 'string') {
|
|
return normalized;
|
|
}
|
|
|
|
const trimmed = normalized.trim();
|
|
if (!trimmed) {
|
|
return trimmed;
|
|
}
|
|
|
|
const resolved = options.resolveRealpath === false ? trimmed : safeRealpathSync(trimmed);
|
|
|
|
if (processLike.platform !== 'win32') {
|
|
return resolved;
|
|
}
|
|
|
|
return resolved.replace(/\//g, '\\');
|
|
};
|
|
|
|
const areStringArraysEqual = (a, b) => {
|
|
if (!Array.isArray(a) || !Array.isArray(b)) {
|
|
return false;
|
|
}
|
|
if (a.length !== b.length) {
|
|
return false;
|
|
}
|
|
for (let i = 0; i < a.length; i += 1) {
|
|
if (a[i] !== b[i]) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const normalizeStringArray = (input) => {
|
|
if (!Array.isArray(input)) {
|
|
return [];
|
|
}
|
|
return Array.from(
|
|
new Set(
|
|
input.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
)
|
|
);
|
|
};
|
|
|
|
const sanitizeProjects = (input) => {
|
|
if (!Array.isArray(input)) {
|
|
return undefined;
|
|
}
|
|
|
|
const hexColorPattern = /^#(?:[\da-fA-F]{3}|[\da-fA-F]{6})$/;
|
|
const normalizeIconBackground = (value) => {
|
|
if (typeof value !== 'string') {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
return hexColorPattern.test(trimmed) ? trimmed.toLowerCase() : null;
|
|
};
|
|
|
|
const result = [];
|
|
const seenIds = new Set();
|
|
const seenPaths = new Set();
|
|
|
|
for (const entry of input) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
|
|
const candidate = entry;
|
|
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
|
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
|
|
const resolvedPath = rawPath ? safeRealpathSync(path.resolve(normalizeDirectoryPath(rawPath))) : '';
|
|
const normalizedPath = resolvedPath ? normalizePathForPersistence(resolvedPath, { resolveRealpath: false }) : '';
|
|
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
|
|
const icon = typeof candidate.icon === 'string' ? candidate.icon.trim() : '';
|
|
const iconImage = candidate.iconImage && typeof candidate.iconImage === 'object'
|
|
? candidate.iconImage
|
|
: null;
|
|
const iconBackground = normalizeIconBackground(candidate.iconBackground);
|
|
const color = typeof candidate.color === 'string' ? candidate.color.trim() : '';
|
|
const addedAt = Number.isFinite(candidate.addedAt) ? Number(candidate.addedAt) : null;
|
|
const lastOpenedAt = Number.isFinite(candidate.lastOpenedAt)
|
|
? Number(candidate.lastOpenedAt)
|
|
: null;
|
|
|
|
if (!id || !normalizedPath) continue;
|
|
if (seenIds.has(id)) continue;
|
|
if (seenPaths.has(normalizedPath)) continue;
|
|
|
|
seenIds.add(id);
|
|
seenPaths.add(normalizedPath);
|
|
|
|
const project = {
|
|
id,
|
|
path: normalizedPath,
|
|
...(label ? { label } : {}),
|
|
...(icon ? { icon } : {}),
|
|
...(iconBackground ? { iconBackground } : {}),
|
|
...(color ? { color } : {}),
|
|
...(Number.isFinite(addedAt) && addedAt >= 0 ? { addedAt } : {}),
|
|
...(Number.isFinite(lastOpenedAt) && lastOpenedAt >= 0 ? { lastOpenedAt } : {}),
|
|
};
|
|
|
|
if (candidate.iconImage === null) {
|
|
project.iconImage = null;
|
|
} else if (iconImage) {
|
|
const mime = typeof iconImage.mime === 'string' ? iconImage.mime.trim() : '';
|
|
const updatedAt = typeof iconImage.updatedAt === 'number' && Number.isFinite(iconImage.updatedAt)
|
|
? Math.max(0, Math.round(iconImage.updatedAt))
|
|
: 0;
|
|
const source = iconImage.source === 'custom' || iconImage.source === 'auto'
|
|
? iconImage.source
|
|
: null;
|
|
if (mime && updatedAt > 0 && source) {
|
|
project.iconImage = { mime, updatedAt, source };
|
|
}
|
|
}
|
|
|
|
if (candidate.iconBackground === null) {
|
|
project.iconBackground = null;
|
|
}
|
|
|
|
if (typeof candidate.sidebarCollapsed === 'boolean') {
|
|
project.sidebarCollapsed = candidate.sidebarCollapsed;
|
|
}
|
|
|
|
result.push(project);
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const normalizeSettingsPaths = (input) => {
|
|
const settings = input && typeof input === 'object' ? input : {};
|
|
let next = settings;
|
|
let changed = false;
|
|
|
|
const ensureNext = () => {
|
|
if (next === settings) {
|
|
next = { ...settings };
|
|
}
|
|
};
|
|
|
|
const normalizePathField = (key) => {
|
|
if (typeof settings[key] !== 'string' || settings[key].length === 0) {
|
|
return;
|
|
}
|
|
const normalized = normalizePathForPersistence(settings[key]);
|
|
if (normalized !== settings[key]) {
|
|
ensureNext();
|
|
next[key] = normalized;
|
|
changed = true;
|
|
}
|
|
};
|
|
|
|
const normalizePathArrayField = (key) => {
|
|
if (!Array.isArray(settings[key])) {
|
|
return;
|
|
}
|
|
|
|
const normalized = normalizeStringArray(
|
|
settings[key]
|
|
.map((entry) => (typeof entry === 'string' ? normalizePathForPersistence(entry) : entry))
|
|
.filter((entry) => typeof entry === 'string' && entry.length > 0)
|
|
);
|
|
|
|
if (!areStringArraysEqual(normalized, settings[key])) {
|
|
ensureNext();
|
|
next[key] = normalized;
|
|
changed = true;
|
|
}
|
|
};
|
|
|
|
normalizePathField('lastDirectory');
|
|
normalizePathField('homeDirectory');
|
|
normalizePathArrayField('pinnedDirectories');
|
|
|
|
if (Array.isArray(settings.projects)) {
|
|
const normalizedProjects = sanitizeProjects(settings.projects) || [];
|
|
if (JSON.stringify(normalizedProjects) !== JSON.stringify(settings.projects)) {
|
|
ensureNext();
|
|
next.projects = normalizedProjects;
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
return { settings: next, changed };
|
|
};
|
|
|
|
const clampNumber = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
|
|
const normalizeTunnelBootstrapTtlMs = (value) => {
|
|
if (value === null) {
|
|
return null;
|
|
}
|
|
if (!Number.isFinite(value)) {
|
|
return tunnelBootstrapTtlDefaultMs;
|
|
}
|
|
return clampNumber(Math.round(value), tunnelBootstrapTtlMinMs, tunnelBootstrapTtlMaxMs);
|
|
};
|
|
|
|
const normalizeTunnelSessionTtlMs = (value) => {
|
|
if (!Number.isFinite(value)) {
|
|
return tunnelSessionTtlDefaultMs;
|
|
}
|
|
return clampNumber(Math.round(value), tunnelSessionTtlMinMs, tunnelSessionTtlMaxMs);
|
|
};
|
|
|
|
const normalizeManagedRemoteTunnelHostname = (value) => {
|
|
if (typeof value !== 'string') {
|
|
return undefined;
|
|
}
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
|
|
const parsed = (() => {
|
|
try {
|
|
if (trimmed.includes('://')) {
|
|
return new URL(trimmed);
|
|
}
|
|
return new URL(`https://${trimmed}`);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
const hostname = parsed?.hostname?.trim().toLowerCase() || '';
|
|
if (!hostname) {
|
|
return undefined;
|
|
}
|
|
return hostname;
|
|
};
|
|
|
|
const normalizeManagedRemoteTunnelPresets = (value) => {
|
|
if (!Array.isArray(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
const result = [];
|
|
const seenIds = new Set();
|
|
const seenHostnames = new Set();
|
|
|
|
for (const entry of value) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
const candidate = entry;
|
|
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
|
|
const name = typeof candidate.name === 'string' ? candidate.name.trim() : '';
|
|
const hostname = normalizeManagedRemoteTunnelHostname(candidate.hostname);
|
|
if (!id || !name || !hostname) continue;
|
|
if (seenIds.has(id) || seenHostnames.has(hostname)) continue;
|
|
seenIds.add(id);
|
|
seenHostnames.add(hostname);
|
|
result.push({ id, name, hostname });
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const normalizeManagedRemoteTunnelPresetTokens = (value) => {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return undefined;
|
|
}
|
|
|
|
const result = {};
|
|
for (const [rawId, rawToken] of Object.entries(value)) {
|
|
const id = typeof rawId === 'string' ? rawId.trim() : '';
|
|
const token = typeof rawToken === 'string' ? rawToken.trim() : '';
|
|
if (!id || !token) {
|
|
continue;
|
|
}
|
|
result[id] = token;
|
|
}
|
|
|
|
return Object.keys(result).length > 0 ? result : undefined;
|
|
};
|
|
|
|
const isUnsafeSkillRelativePath = (value) => {
|
|
if (typeof value !== 'string' || value.length === 0) {
|
|
return true;
|
|
}
|
|
|
|
const normalized = value.replace(/\\/g, '/');
|
|
if (path.posix.isAbsolute(normalized)) {
|
|
return true;
|
|
}
|
|
|
|
return normalized.split('/').some((segment) => segment === '..');
|
|
};
|
|
|
|
const sanitizeTypographySizesPartial = (input) => {
|
|
if (!input || typeof input !== 'object') {
|
|
return undefined;
|
|
}
|
|
const candidate = input;
|
|
const result = {};
|
|
let populated = false;
|
|
|
|
const assign = (key) => {
|
|
if (typeof candidate[key] === 'string' && candidate[key].length > 0) {
|
|
result[key] = candidate[key];
|
|
populated = true;
|
|
}
|
|
};
|
|
|
|
assign('markdown');
|
|
assign('code');
|
|
assign('uiHeader');
|
|
assign('uiLabel');
|
|
assign('meta');
|
|
assign('micro');
|
|
|
|
return populated ? result : undefined;
|
|
};
|
|
|
|
const sanitizeModelRefs = (input, limit) => {
|
|
if (!Array.isArray(input)) {
|
|
return undefined;
|
|
}
|
|
|
|
const result = [];
|
|
const seen = new Set();
|
|
|
|
for (const entry of input) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
const providerID = typeof entry.providerID === 'string' ? entry.providerID.trim() : '';
|
|
const modelID = typeof entry.modelID === 'string' ? entry.modelID.trim() : '';
|
|
if (!providerID || !modelID) continue;
|
|
const key = `${providerID}/${modelID}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
result.push({ providerID, modelID });
|
|
if (result.length >= limit) break;
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const sanitizeSkillCatalogs = (input) => {
|
|
if (!Array.isArray(input)) {
|
|
return undefined;
|
|
}
|
|
|
|
const result = [];
|
|
const seen = new Set();
|
|
|
|
for (const entry of input) {
|
|
if (!entry || typeof entry !== 'object') continue;
|
|
|
|
const id = typeof entry.id === 'string' ? entry.id.trim() : '';
|
|
const label = typeof entry.label === 'string' ? entry.label.trim() : '';
|
|
const source = typeof entry.source === 'string' ? entry.source.trim() : '';
|
|
const subpath = typeof entry.subpath === 'string' ? entry.subpath.trim() : '';
|
|
const gitIdentityId = typeof entry.gitIdentityId === 'string' ? entry.gitIdentityId.trim() : '';
|
|
|
|
if (!id || !label || !source) continue;
|
|
if (seen.has(id)) continue;
|
|
seen.add(id);
|
|
|
|
result.push({
|
|
id,
|
|
label,
|
|
source,
|
|
...(subpath ? { subpath } : {}),
|
|
...(gitIdentityId ? { gitIdentityId } : {}),
|
|
});
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
return {
|
|
normalizeDirectoryPath,
|
|
normalizePathForPersistence,
|
|
normalizeSettingsPaths,
|
|
normalizeTunnelBootstrapTtlMs,
|
|
normalizeTunnelSessionTtlMs,
|
|
normalizeManagedRemoteTunnelHostname,
|
|
normalizeManagedRemoteTunnelPresets,
|
|
normalizeManagedRemoteTunnelPresetTokens,
|
|
isUnsafeSkillRelativePath,
|
|
sanitizeTypographySizesPartial,
|
|
normalizeStringArray,
|
|
sanitizeModelRefs,
|
|
sanitizeSkillCatalogs,
|
|
sanitizeProjects,
|
|
};
|
|
};
|