284 lines
10 KiB
JavaScript
284 lines
10 KiB
JavaScript
import { existsSync } from "node:fs";
|
|
import { homedir } from "node:os";
|
|
import path from "node:path";
|
|
|
|
export const MAX_CONFIG_ROOT_LENGTH = 4_096;
|
|
const FORBIDDEN_CONFIG_ROOT_CHARS = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/u;
|
|
|
|
function pathApi(platform) {
|
|
return platform === "win32" ? path.win32 : path.posix;
|
|
}
|
|
|
|
function invalidWindowsWorkspaceRoot(value) {
|
|
const error = new TypeError(`Invalid Windows workspace root: ${value}`);
|
|
Object.defineProperty(error, "code", { value: "invalid_workspace_root" });
|
|
return error;
|
|
}
|
|
|
|
function isWindowsDeviceNamespace(value) {
|
|
return /^\\\\[?.]\\/.test(value);
|
|
}
|
|
|
|
/**
|
|
* Normalizes Windows verbatim drive and UNC paths without consulting the
|
|
* filesystem. Missing drives and disconnected shares must remain unchanged so
|
|
* callers can report their real accessibility error instead of resolving them
|
|
* against another drive.
|
|
*/
|
|
export function normalizeWorkspaceRootPath(value, opts) {
|
|
const trimmed = String(value ?? "").trim();
|
|
if (!trimmed || optionPlatform(opts) !== "win32") return trimmed;
|
|
|
|
const windowsPath = trimmed.replace(/\//g, "\\");
|
|
let normalized = windowsPath;
|
|
if (windowsPath.startsWith("\\\\?\\")) {
|
|
const verbatimPath = windowsPath.slice(4);
|
|
if (/^UNC(?:\\|$)/i.test(verbatimPath)) {
|
|
const uncPath = verbatimPath.slice(3).replace(/^\\/, "");
|
|
const [server, share] = uncPath.split("\\");
|
|
if (!server || !share) throw invalidWindowsWorkspaceRoot(trimmed);
|
|
normalized = `\\\\${uncPath}`;
|
|
} else {
|
|
if (!/^[A-Za-z]:\\/.test(verbatimPath)) throw invalidWindowsWorkspaceRoot(trimmed);
|
|
normalized = verbatimPath;
|
|
}
|
|
}
|
|
|
|
if (isWindowsDeviceNamespace(normalized)) {
|
|
throw invalidWindowsWorkspaceRoot(trimmed);
|
|
}
|
|
|
|
if (/^[A-Za-z]:(?!\\)/.test(normalized)) {
|
|
throw invalidWindowsWorkspaceRoot(trimmed);
|
|
}
|
|
if (normalized.startsWith("\\")) {
|
|
if (!normalized.startsWith("\\\\")) throw invalidWindowsWorkspaceRoot(trimmed);
|
|
const [server, share] = normalized.slice(2).split("\\");
|
|
if (!server || !share) throw invalidWindowsWorkspaceRoot(trimmed);
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
function optionEnv(opts) {
|
|
return opts?.env ?? process.env;
|
|
}
|
|
|
|
function optionPlatform(opts) {
|
|
return opts?.platform ?? process.platform;
|
|
}
|
|
|
|
function envValue(env, key) {
|
|
return String(env?.[key] ?? "").trim();
|
|
}
|
|
|
|
function envRawValue(env, key) {
|
|
const value = env?.[key];
|
|
return typeof value === "string" ? value : "";
|
|
}
|
|
|
|
function optionHomeDir(opts) {
|
|
const configured = opts?.homeDir?.trim();
|
|
if (configured) return configured;
|
|
const env = optionEnv(opts);
|
|
const platform = optionPlatform(opts);
|
|
const fromEnv = platform === "win32"
|
|
? envValue(env, "USERPROFILE") || envValue(env, "HOME")
|
|
: envValue(env, "HOME");
|
|
return fromEnv || homedir();
|
|
}
|
|
|
|
function defaultOpenworkConfigDir(opts) {
|
|
const env = optionEnv(opts);
|
|
const platform = optionPlatform(opts);
|
|
const paths = pathApi(platform);
|
|
const homeDir = optionHomeDir(opts);
|
|
if (platform === "win32") {
|
|
const appData = envValue(env, "APPDATA");
|
|
const root = appData || paths.join(homeDir, "AppData", "Roaming");
|
|
return paths.join(root, "openwork");
|
|
}
|
|
const xdgConfigHome = envValue(env, "XDG_CONFIG_HOME");
|
|
const root = xdgConfigHome || paths.join(homeDir, ".config");
|
|
return paths.join(root, "openwork");
|
|
}
|
|
|
|
export function openworkConfigDir(opts) {
|
|
const env = optionEnv(opts);
|
|
const platform = optionPlatform(opts);
|
|
const paths = pathApi(platform);
|
|
const override = envValue(env, "OPENWORK_SERVER_CONFIG");
|
|
if (override) return paths.dirname(paths.resolve(override));
|
|
return defaultOpenworkConfigDir(opts);
|
|
}
|
|
|
|
export function openworkServerConfigPath(opts) {
|
|
const env = optionEnv(opts);
|
|
const platform = optionPlatform(opts);
|
|
const paths = pathApi(platform);
|
|
const override = envValue(env, "OPENWORK_SERVER_CONFIG");
|
|
if (override) return paths.resolve(override);
|
|
return paths.join(defaultOpenworkConfigDir(opts), "server.json");
|
|
}
|
|
|
|
export function openworkEnvStorePath(opts) {
|
|
const env = optionEnv(opts);
|
|
const platform = optionPlatform(opts);
|
|
const paths = pathApi(platform);
|
|
const override = envValue(env, "OPENWORK_ENV_STORE");
|
|
if (override) return paths.resolve(override);
|
|
return paths.join(defaultOpenworkConfigDir(opts), "env.json");
|
|
}
|
|
|
|
function safeConfigRoot(value, paths) {
|
|
return value.length > 0
|
|
&& value.length <= MAX_CONFIG_ROOT_LENGTH
|
|
&& paths.isAbsolute(value)
|
|
&& !FORBIDDEN_CONFIG_ROOT_CHARS.test(value);
|
|
}
|
|
|
|
export function globalOpencodeConfigDir(opts) {
|
|
const env = optionEnv(opts);
|
|
const platform = optionPlatform(opts);
|
|
const paths = pathApi(platform);
|
|
const configuredDirectory = envRawValue(env, "OPENCODE_CONFIG_DIR");
|
|
if (safeConfigRoot(configuredDirectory, paths)) return configuredDirectory;
|
|
|
|
const configuredRoot = envRawValue(env, "XDG_CONFIG_HOME");
|
|
const configRoot = safeConfigRoot(configuredRoot, paths)
|
|
? configuredRoot
|
|
: paths.join(optionHomeDir(opts), ".config");
|
|
// OpenCode accepts OPENCODE_CONFIG_DIR as the directory containing its
|
|
// opencode.json(c) files. It is not an XDG parent directory.
|
|
return paths.join(configRoot, "opencode");
|
|
}
|
|
|
|
export function resolveGlobalOpencodeConfigPath(opts) {
|
|
const platform = optionPlatform(opts);
|
|
const paths = pathApi(platform);
|
|
const base = globalOpencodeConfigDir(opts);
|
|
const jsonc = paths.join(base, "opencode.jsonc");
|
|
const json = paths.join(base, "opencode.json");
|
|
if (existsSync(jsonc)) return jsonc;
|
|
if (existsSync(json)) return json;
|
|
return jsonc;
|
|
}
|
|
|
|
export function workspaceOpencodeConfigCandidates(workspaceRoot) {
|
|
return [
|
|
path.join(workspaceRoot, "opencode.jsonc"),
|
|
path.join(workspaceRoot, "opencode.json"),
|
|
path.join(workspaceRoot, ".opencode", "opencode.jsonc"),
|
|
path.join(workspaceRoot, ".opencode", "opencode.json"),
|
|
];
|
|
}
|
|
|
|
export function resolveWorkspaceOpencodeConfigPath(workspaceRoot) {
|
|
const candidates = workspaceOpencodeConfigCandidates(workspaceRoot);
|
|
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
|
|
}
|
|
|
|
function desktopConfigDir(opts) {
|
|
const env = optionEnv(opts);
|
|
const platform = optionPlatform(opts);
|
|
const paths = pathApi(platform);
|
|
const xdgConfigHome = envValue(env, "XDG_CONFIG_HOME");
|
|
if (xdgConfigHome) return xdgConfigHome;
|
|
if (platform === "win32") {
|
|
const localAppData = envValue(env, "LOCALAPPDATA");
|
|
if (localAppData) return localAppData;
|
|
return paths.join(optionHomeDir(opts), "AppData", "Local");
|
|
}
|
|
return paths.join(optionHomeDir(opts), ".config");
|
|
}
|
|
|
|
export function desktopBootstrapPath(opts) {
|
|
const env = optionEnv(opts);
|
|
const platform = optionPlatform(opts);
|
|
const paths = pathApi(platform);
|
|
const override = envValue(env, "OPENWORK_DESKTOP_BOOTSTRAP_PATH");
|
|
if (override) return override;
|
|
if (envValue(env, "OPENWORK_DEV_MODE") === "1" && opts?.userDataDir) {
|
|
return paths.join(opts.userDataDir, "openwork-dev-data", "home", ".config", "openwork", "desktop-bootstrap.json");
|
|
}
|
|
return paths.join(desktopConfigDir(opts), "openwork", "desktop-bootstrap.json");
|
|
}
|
|
|
|
export function legacyDesktopBootstrapPath(opts) {
|
|
const platform = optionPlatform(opts);
|
|
const paths = pathApi(platform);
|
|
// Existing installers may have written this file using USERPROFILE/HOME while
|
|
// Electron used os.homedir(). optionHomeDir accepts an explicit homeDir but
|
|
// otherwise checks the same env variables before os.homedir(), so both legacy
|
|
// locations continue to resolve for normal installs.
|
|
return paths.join(optionHomeDir(opts), ".config", "openwork", "desktop-bootstrap.json");
|
|
}
|
|
|
|
export function expandHomePath(value, opts) {
|
|
if (value === "~") return optionHomeDir(opts);
|
|
if (value.startsWith("~/") || value.startsWith("~\\")) {
|
|
return pathApi(optionPlatform(opts)).join(optionHomeDir(opts), value.slice(2));
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export function openworkServerDataDir(opts) {
|
|
const env = optionEnv(opts);
|
|
const platform = optionPlatform(opts);
|
|
const paths = pathApi(platform);
|
|
const override = envValue(env, "OPENWORK_DATA_DIR");
|
|
if (override) return expandHomePath(override, opts);
|
|
return paths.join(optionHomeDir(opts), ".openwork", "openwork-server");
|
|
}
|
|
|
|
export function opencodeDataDirs(opts) {
|
|
const env = optionEnv(opts);
|
|
const platform = optionPlatform(opts);
|
|
const paths = pathApi(platform);
|
|
const homeDir = optionHomeDir(opts);
|
|
const dirs = [];
|
|
const xdgDataHome = envValue(env, "XDG_DATA_HOME");
|
|
if (xdgDataHome) dirs.push(paths.join(xdgDataHome, "opencode"));
|
|
dirs.push(paths.join(homeDir, ".local", "share", "opencode"));
|
|
if (platform === "darwin") dirs.push(paths.join(homeDir, "Library", "Application Support", "opencode"));
|
|
if (platform === "win32") {
|
|
const appData = envValue(env, "APPDATA") || paths.join(homeDir, "AppData", "Roaming");
|
|
dirs.push(paths.join(appData, "opencode"));
|
|
}
|
|
return Array.from(new Set(dirs));
|
|
}
|
|
|
|
function truthy(value) {
|
|
const normalized = String(value ?? "").trim().toLowerCase();
|
|
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
}
|
|
|
|
/** Candidate OpenCode databases in the same override/channel order used by OpenWork. */
|
|
export function opencodeDbCandidates(opts) {
|
|
const env = optionEnv(opts);
|
|
const platform = optionPlatform(opts);
|
|
const paths = pathApi(platform);
|
|
const dirs = Array.from(new Set([...(opts?.dataDirs ?? []), ...opencodeDataDirs(opts)]));
|
|
const override = envValue(env, "OPENCODE_DB");
|
|
if (override) {
|
|
if (paths.isAbsolute(override)) return [override];
|
|
return dirs.map((dir) => paths.join(dir, override));
|
|
}
|
|
|
|
const channel = envValue(env, "OPENCODE_CHANNEL") || opts?.defaultChannel || "local";
|
|
const names = channel === "latest" || channel === "beta" || truthy(envValue(env, "OPENCODE_DISABLE_CHANNEL_DB"))
|
|
? ["opencode.db"]
|
|
: [`opencode-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.db`, "opencode.db"];
|
|
return dirs.flatMap((dir) => names.map((name) => paths.join(dir, name)));
|
|
}
|
|
|
|
export function opencodeCacheDirs(opts) {
|
|
const env = optionEnv(opts);
|
|
const platform = optionPlatform(opts);
|
|
const paths = pathApi(platform);
|
|
const dirs = [];
|
|
const xdgCacheHome = envValue(env, "XDG_CACHE_HOME");
|
|
if (xdgCacheHome) dirs.push(paths.join(xdgCacheHome, "opencode"));
|
|
dirs.push(paths.join(optionHomeDir(opts), ".cache", "opencode"));
|
|
return Array.from(new Set(dirs));
|
|
}
|